import asyncio import os import random import re from collections import defaultdict from functools import wraps import nonebot import pytz from nonebot.command import SwitchException, _FinishException, _PauseException from nonebot.message import CanceledException import hoshino from hoshino import log, priv, trigger from hoshino.typing import * try: import ujson as json except: import json # service management _loaded_services: Dict[str, "Service"] = {} # {name: service} _service_bundle: Dict[str, List["Service"]] = defaultdict(list) _re_illegal_char = re.compile(r'[\\/:*?"<>|\.]') _service_config_dir = os.path.expanduser('~/.hoshino/service_config/') os.makedirs(_service_config_dir, exist_ok=True) def _load_service_config(service_name): config_file = os.path.join(_service_config_dir, f'{service_name}.json') if not os.path.exists(config_file): return {} # config file not found, return default config. try: with open(config_file, encoding='utf8') as f: config = json.load(f) return config except Exception as e: hoshino.logger.exception(e) return {} def _save_service_config(service): config_file = os.path.join(_service_config_dir, f'{service.name}.json') with open(config_file, 'w', encoding='utf8') as f: json.dump( { "name": service.name, "use_priv": service.use_priv, "manage_priv": service.manage_priv, "enable_on_default": service.enable_on_default, "visible": service.visible, "enable_group": list(service.enable_group), "disable_group": list(service.disable_group) }, f, ensure_ascii=False, indent=2) class ServiceFunc: def __init__(self, sv: "Service", func: Callable, only_to_me: bool, normalize_text: bool=False): self.sv = sv self.func = func self.only_to_me = only_to_me self.normalize_text = normalize_text self.__name__ = func.__name__ def __call__(self, *args, **kwargs): return self.func(*args, **kwargs) class Service: """将一组功能包装为服务, 提供增强的触发条件与分群权限管理. 支持的触发条件: `on_message`, `on_prefix`, `on_fullmatch`, `on_suffix`, `on_keyword`, `on_rex`, `on_command`, `on_natural_language` 提供接口: `scheduled_job`, `broadcast` 服务的配置文件格式为: { "name": "ServiceName", "use_priv": priv.NORMAL, "manage_priv": priv.ADMIN, "enable_on_default": true/false, "visible": true/false, "enable_group": [], "disable_group": [] } 储存位置: `~/.hoshino/service_config/{ServiceName}.json` """ def __init__(self, name, use_priv=None, manage_priv=None, enable_on_default=None, visible=None, help_=None, bundle=None): """ 定义一个服务 配置的优先级别:配置文件 > 程序指定 > 缺省值 """ assert not _re_illegal_char.search(name), r'Service name cannot contain character in `\/:*?"<>|.`' config = _load_service_config(name) self.name = name self.use_priv = config.get('use_priv') or use_priv or priv.NORMAL self.manage_priv = config.get( 'manage_priv') or manage_priv or priv.ADMIN self.enable_on_default = config.get('enable_on_default') if self.enable_on_default is None: self.enable_on_default = enable_on_default if self.enable_on_default is None: self.enable_on_default = True self.visible = config.get('visible') if self.visible is None: self.visible = visible if self.visible is None: self.visible = True self.help = help_ self.enable_group = set(config.get('enable_group', [])) self.disable_group = set(config.get('disable_group', [])) self.logger = log.new_logger(name, hoshino.config.DEBUG) assert self.name not in _loaded_services, f'Service name "{self.name}" already exist!' _loaded_services[self.name] = self _service_bundle[bundle or "通用"].append(self) @property def bot(self): return hoshino.get_bot() @staticmethod def get_loaded_services() -> Dict[str, "Service"]: return _loaded_services @staticmethod def get_bundles(): return _service_bundle def set_enable(self, group_id): self.enable_group.add(group_id) self.disable_group.discard(group_id) _save_service_config(self) self.logger.info(f'Service {self.name} is enabled at group {group_id}') def set_disable(self, group_id): self.enable_group.discard(group_id) self.disable_group.add(group_id) _save_service_config(self) self.logger.info( f'Service {self.name} is disabled at group {group_id}') def check_enabled(self, group_id): return bool((group_id in self.enable_group) or (self.enable_on_default and group_id not in self.disable_group)) def _check_all(self, ev: CQEvent): if ev.detail_type == 'private': return True else: gid = ev.group_id return self.check_enabled(gid) and not priv.check_block_group(gid) and priv.check_priv(ev, self.use_priv) async def get_enable_groups(self) -> dict: """获取所有启用本服务的群 @return { group_id: [self_id1, self_id2] } """ gl = defaultdict(list) for sid in hoshino.get_self_ids(): try: sgl = await self.bot.get_group_list(self_id=sid) except CQHttpError: sgl = [] sgl = set(g['group_id'] for g in sgl) if self.enable_on_default: sgl = sgl - self.disable_group else: sgl = sgl & self.enable_group for g in sgl: gl[g].append(sid) return gl def on_message(self, *events) -> Callable: def deco(func) -> Callable: @wraps(func) async def wrapper(ctx): if self._check_all(ctx): try: return await func(self.bot, ctx) except Exception as e: self.logger.error(f'{type(e)} occured when {func.__name__} handling message {ctx["message_id"]}.') self.logger.exception(e) return return self.bot.on_message(*events)(wrapper) #return self.bot.on_message(event)(wrapper) if type(event) is str else self.bot.on_message(*events)(wrapper) return deco def on_prefix(self, *prefix, only_to_me=False) -> Callable: if len(prefix) == 1 and not isinstance(prefix[0], str): prefix = prefix[0] def deco(func) -> Callable: sf = ServiceFunc(self, func, only_to_me) for p in prefix: if isinstance(p, str): trigger.prefix.add(p, sf) else: self.logger.error(f'Failed to add prefix trigger `{p}`, expecting `str` but `{type(p)}` given!') return func return deco def on_fullmatch(self, *word, only_to_me=False) -> Callable: if len(word) == 1 and not isinstance(word[0], str): word = word[0] def deco(func) -> Callable: @wraps(func) async def wrapper(bot: HoshinoBot, event: CQEvent): if len(event.message) != 1 or event.message[0].data.get('text'): self.logger.info(f'Message {event.message_id} is ignored by fullmatch condition.') return return await func(bot, event) sf = ServiceFunc(self, wrapper, only_to_me) for w in word: if isinstance(w, str): trigger.prefix.add(w, sf) else: self.logger.error(f'Failed to add fullmatch trigger `{w}`, expecting `str` but `{type(w)}` given!') return func # func itself is still func, not wrapper. wrapper is a part of trigger. # so that we could use multi-trigger freely, regardless of the order of decorators. # ``` # """the order doesn't matter""" # @on_keyword(...) # @on_fullmatch(...) # async def func(...): # ... # ``` return deco def on_suffix(self, *suffix, only_to_me=False) -> Callable: if len(suffix) == 1 and not isinstance(suffix[0], str): suffix = suffix[0] def deco(func) -> Callable: sf = ServiceFunc(self, func, only_to_me) for s in suffix: if isinstance(s, str): trigger.suffix.add(s, sf) else: self.logger.error(f'Failed to add suffix trigger `{s}`, expecting `str` but `{type(s)}` given!') return func return deco def on_keyword(self, *keywords, only_to_me=False, normalize=True) -> Callable: if len(keywords) == 1 and not isinstance(keywords[0], str): keywords = keywords[0] def deco(func) -> Callable: sf = ServiceFunc(self, func, only_to_me, normalize) for kw in keywords: if isinstance(kw, str): trigger.keyword.add(kw, sf) else: self.logger.error(f'Failed to add keyword trigger `{kw}`, expecting `str` but `{type(kw)}` given!') return func return deco def on_rex(self, rex: Union[str, re.Pattern], only_to_me=False, normalize=True) -> Callable: if isinstance(rex, str): rex = re.compile(rex) def deco(func) -> Callable: sf = ServiceFunc(self, func, only_to_me, normalize) if isinstance(rex, re.Pattern): trigger.rex.add(rex, sf) else: self.logger.error(f'Failed to add rex trigger `{rex}`, expecting `str` or `re.Pattern` but `{type(rex)}` given!') return func return deco def on_command(self, name, *, only_to_me=False, deny_tip=None, **kwargs) -> Callable: kwargs['only_to_me'] = only_to_me def deco(func) -> Callable: @wraps(func) async def wrapper(session): #if session.ctx['message_type'] != 'group' or session.ctx['message_type']!='guild': # return if not self.check_enabled(session.ctx['group_id']): self.logger.debug( f'Message {session.ctx["message_id"]} is command of a disabled service, ignored.' ) if deny_tip: session.finish(deny_tip, at_sender=True) return if self._check_all(session.ctx): try: ret = await func(session) self.logger.info( f'Message {session.ctx["message_id"]} is handled as command by {func.__name__}.' ) return ret except CanceledException: raise _FinishException except (_PauseException, _FinishException, SwitchException) as e: raise e except Exception as e: self.logger.error(f'{type(e)} occured when {func.__name__} handling message {session.ctx["message_id"]}.') self.logger.exception(e) return nonebot.on_command(name, **kwargs)(wrapper) return deco def on_natural_language(self, keywords=None, **kwargs) -> Callable: def deco(func) -> Callable: @wraps(func) async def wrapper(session: nonebot.NLPSession): if self._check_all(session.ctx): try: ret = await func(session) self.logger.info( f'Message {session.ctx["message_id"]} is handled as natural language by {func.__name__}.' ) return ret except CanceledException: raise _FinishException except (_PauseException, _FinishException, SwitchException) as e: raise e except Exception as e: self.logger.error(f'{type(e)} occured when {func.__name__} handling message {session.ctx["message_id"]}.') self.logger.exception(e) return nonebot.on_natural_language(keywords, **kwargs)(wrapper) return deco def scheduled_job(self, *args, **kwargs) -> Callable: kwargs.setdefault('timezone', pytz.timezone('Asia/Shanghai')) kwargs.setdefault('misfire_grace_time', 60) kwargs.setdefault('coalesce', True) def deco(func: Callable[[], Any]) -> Callable: @wraps(func) async def wrapper(): try: self.logger.info(f'Scheduled job {func.__name__} start.') ret = await func() self.logger.info(f'Scheduled job {func.__name__} completed.') return ret except Exception as e: self.logger.error(f'{type(e)} occured when doing scheduled job {func.__name__}.') self.logger.exception(e) return nonebot.scheduler.scheduled_job(*args, **kwargs)(wrapper) return deco async def broadcast(self, msgs, TAG='', interval_time=0.5, randomiser=None): bot = self.bot if isinstance(msgs, (str, MessageSegment, Message)): msgs = (msgs, ) groups = await self.get_enable_groups() for gid, selfids in groups.items(): try: for msg in msgs: await asyncio.sleep(interval_time) msg = randomiser(msg) if randomiser else msg await bot.send_group_msg(self_id=random.choice(selfids), group_id=gid, message=msg) l = len(msgs) if l: self.logger.info(f"群{gid} 投递{TAG}成功 共{l}条消息") except Exception as e: self.logger.error(f"群{gid} 投递{TAG}失败:{type(e)}") self.logger.exception(e) def on_request(self, *events): def deco(func): @wraps(func) async def wrapper(session): if not self.check_enabled(session.event.group_id): return return await func(session) return nonebot.on_request(*events)(wrapper) return deco def on_notice(self, *events): def deco(func): @wraps(func) async def wrapper(session): if not self.check_enabled(session.event.group_id): return return await func(session) return nonebot.on_notice(*events)(wrapper) return deco sulogger = log.new_logger('sucmd', hoshino.config.DEBUG) def sucmd(name, force_private=True, **kwargs) -> Callable: kwargs['privileged'] = True kwargs['only_to_me'] = False def deco(func) -> Callable: @wraps(func) async def wrapper(session: CommandSession): if session.event.user_id not in hoshino.config.SUPERUSERS: return if force_private and session.event.detail_type != 'private': await session.send('> This command should only be used in private session.') return try: return await func(session) except CanceledException: raise _FinishException except (_PauseException, _FinishException, SwitchException): raise except Exception as e: sulogger.error(f'{type(e)} occured when {func.__name__} handling message {session.event.message_id}.') sulogger.exception(e) return nonebot.on_command(name, **kwargs)(wrapper) return deco