diff --git a/none/__init__.py b/none/__init__.py index 02899b0b..1d31de04 100644 --- a/none/__init__.py +++ b/none/__init__.py @@ -13,7 +13,7 @@ from .notice_request import handle_notice_or_request from .log import logger -def create_bot(config_object: Any = None): +def create_bot(config_object: Any = None) -> CQHttp: if config_object is None: from . import default_config as config_object @@ -46,7 +46,7 @@ def create_bot(config_object: Any = None): _plugins = set() -def load_plugins(plugin_dir: str, module_prefix: str): +def load_plugins(plugin_dir: str, module_prefix: str) -> None: for name in os.listdir(plugin_dir): path = os.path.join(plugin_dir, name) if os.path.isfile(path) and \ @@ -69,7 +69,7 @@ def load_plugins(plugin_dir: str, module_prefix: str): logger.warning('Failed to import "{}"'.format(mod_name)) -def load_builtin_plugins(): +def load_builtin_plugins() -> None: plugin_dir = os.path.join(os.path.dirname(__file__), 'plugins') load_plugins(plugin_dir, 'none.plugins') diff --git a/none/command.py b/none/command.py index eab86df3..0b68040c 100644 --- a/none/command.py +++ b/none/command.py @@ -9,7 +9,7 @@ from aiocqhttp import CQHttp from aiocqhttp.message import Message from . import permission as perm -from .helpers import context_source +from .helpers import context_id from .expression import render from .session import BaseSession @@ -21,8 +21,8 @@ _registry = {} # Value: tuple that identifies a command _aliases = {} -# Key: context source -# Value: Session object +# Key: context id +# Value: CommandSession object _sessions = {} @@ -62,6 +62,15 @@ def on_command(name: Union[str, Tuple[str]], *, aliases: Iterable = (), permission: int = perm.EVERYBODY, only_to_me: bool = True) -> Callable: + """ + Decorator to register a function as a command. + + :param name: command name (e.g. 'echo' or ('random', 'number')) + :param aliases: aliases of command name, for convenient access + :param permission: permission required by the command + :param only_to_me: only handle messages to me + """ + def deco(func: Callable) -> Callable: if not isinstance(name, (str, tuple)): raise TypeError('the name of a command must be a str or tuple') @@ -93,7 +102,6 @@ class CommandGroup: """ Group a set of commands with same name prefix. """ - __slots__ = ('basename', 'permission', 'only_to_me') def __init__(self, name: Union[str, Tuple[str]], @@ -140,9 +148,8 @@ def _find_command(name: Union[str, Tuple[str]]) -> Optional[Command]: class _FurtherInteractionNeeded(Exception): """ - Raised by session.require_arg() indicating - that the command should enter interactive mode - to ask the user for some arguments. + Raised by session.get() indicating that the command should + enter interactive mode to ask the user for some arguments. """ pass @@ -154,14 +161,14 @@ class CommandSession(BaseSession): def __init__(self, bot: CQHttp, ctx: Dict[str, Any], cmd: Command, *, current_arg: str = '', args: Optional[Dict[str, Any]] = None): super().__init__(bot, ctx) - self.cmd = cmd - self.current_key = None - self.current_arg = None - self.current_arg_text = None - self.current_arg_images = None + self.cmd = cmd # Command object + self.current_key = None # current key that the command handler needs + self.current_arg = None # current argument (with potential CQ codes) + self.current_arg_text = None # current argument without any CQ codes + self.current_arg_images = None # image urls in current argument self.refresh(ctx, current_arg=current_arg) self.args = args or {} - self.last_interaction = None + self.last_interaction = None # last interaction time of this session def refresh(self, ctx: Dict[str, Any], *, current_arg: str = '') -> None: """ @@ -224,26 +231,25 @@ def _new_command_session(bot: CQHttp, """ Create a new session for a command. - This will firstly attempt to parse the current message as - a command, and if succeeded, it then create a session for - the command and return. If the message is not a valid command, - None will be returned. + This will attempt to parse the current message as a command, + and if succeeded, it then create a session for the command and return. + If the message is not a valid command, None will be returned. :param bot: CQHttp instance :param ctx: message context :return: CommandSession object or None """ - msg_text = str(ctx['message']).lstrip() + msg = str(ctx['message']).lstrip() for start in bot.config.COMMAND_START: if isinstance(start, type(re.compile(''))): - m = start.search(msg_text) + m = start.search(msg) if m: - full_command = msg_text[len(m.group(0)):].lstrip() + full_command = msg[len(m.group(0)):].lstrip() break elif isinstance(start, str): - if msg_text.startswith(start): - full_command = msg_text[len(start):].lstrip() + if msg.startswith(start): + full_command = msg[len(start):].lstrip() break else: # it's not a command @@ -286,25 +292,24 @@ async def handle_command(bot: CQHttp, ctx: Dict[str, Any]) -> bool: :param ctx: message context :return: the message is handled as a command """ - src = context_source(ctx) + ctx_id = context_id(ctx) session = None check_perm = True - if _sessions.get(src): - session = _sessions[src] + if _sessions.get(ctx_id): + session = _sessions[ctx_id] if session and session.is_valid: session.refresh(ctx, current_arg=str(ctx['message'])) # there is no need to check permission for existing session check_perm = False else: # the session is expired, remove it - del _sessions[src] + del _sessions[ctx_id] session = None if not session: session = _new_command_session(bot, ctx) if not session: return False - - return await _real_run_command(session, src, check_perm=check_perm) + return await _real_run_command(session, ctx_id, check_perm=check_perm) async def call_command(bot: CQHttp, ctx: Dict[str, Any], @@ -316,6 +321,10 @@ async def call_command(bot: CQHttp, ctx: Dict[str, Any], This function is typically called by some other commands or "handle_natural_language" when handling NLPResult object. + Note: After calling this function, any previous command session + will be overridden, even if the command being called here does + not need further interaction (a.k.a asking the user for more info). + :param bot: CQHttp instance :param ctx: message context :param name: command name @@ -326,17 +335,17 @@ async def call_command(bot: CQHttp, ctx: Dict[str, Any], if not cmd: return False session = CommandSession(bot, ctx, cmd, args=args) - return await _real_run_command(session, context_source(session.ctx), + return await _real_run_command(session, context_id(session.ctx), check_perm=False) async def _real_run_command(session: CommandSession, - ctx_src: str, **kwargs) -> bool: - _sessions[ctx_src] = session + ctx_id: str, **kwargs) -> bool: + _sessions[ctx_id] = session try: res = await session.cmd.run(session, **kwargs) # the command is finished, remove the session - del _sessions[ctx_src] + del _sessions[ctx_id] return res except _FurtherInteractionNeeded: session.last_interaction = datetime.now() diff --git a/none/expression.py b/none/expression.py index 9549132b..c2062f55 100644 --- a/none/expression.py +++ b/none/expression.py @@ -6,6 +6,14 @@ from aiocqhttp import message def render(expr: Union[str, Sequence[str], Callable], *, escape_args=True, **kwargs) -> str: + """ + Render an expression to message string. + + :param expr: expression to render + :param escape_args: should escape arguments or not + :param kwargs: keyword arguments used in str.format() + :return: the rendered message + """ if isinstance(expr, Callable): expr = expr() elif isinstance(expr, Sequence): diff --git a/none/helpers.py b/none/helpers.py index 326198e9..f27ee857 100644 --- a/none/helpers.py +++ b/none/helpers.py @@ -5,7 +5,10 @@ from aiocqhttp import CQHttp, Error as CQHttpError from . import expression -def context_source(ctx: Dict[str, Any]) -> str: +def context_id(ctx: Dict[str, Any]) -> str: + """ + Calculate a unique id representing the current user. + """ src = '' if ctx.get('group_id'): src += f'/group/{ctx["group_id"]}' @@ -19,6 +22,9 @@ def context_source(ctx: Dict[str, Any]) -> str: async def send(bot: CQHttp, ctx: Dict[str, Any], message: Union[str, Dict[str, Any], List[Dict[str, Any]]], *, ignore_failure: bool = True) -> None: + """ + Send a message ignoring failure by default. + """ try: if ctx.get('post_type') == 'message': await bot.send(ctx, message) @@ -40,4 +46,7 @@ async def send(bot: CQHttp, ctx: Dict[str, Any], async def send_expr(bot: CQHttp, ctx: Dict[str, Any], expr: Union[str, Sequence[str], Callable], **kwargs): + """ + Sending a expression message ignoring failure by default. + """ return await send(bot, ctx, expression.render(expr, **kwargs)) diff --git a/none/message.py b/none/message.py index 57b68bd1..0dbc65f5 100644 --- a/none/message.py +++ b/none/message.py @@ -23,7 +23,7 @@ async def handle_message(bot: CQHttp, ctx: Dict[str, Any]) -> None: handled = await handle_command(bot, ctx) if handled: - logger.debug('Message is handled as command') + logger.debug('Message is handled as a command') return handled = await handle_natural_language(bot, ctx) diff --git a/none/natural_language.py b/none/natural_language.py index 568697d9..b5b5ba71 100644 --- a/none/natural_language.py +++ b/none/natural_language.py @@ -28,6 +28,14 @@ class NLProcessor: def on_natural_language(keywords: Union[Optional[Iterable], Callable] = None, *, permission: int = perm.EVERYBODY, only_to_me: bool = True) -> Callable: + """ + Decorator to register a function as a natural language processor. + + :param keywords: keywords to respond, if None, respond to all messages + :param permission: permission required by the processor + :param only_to_me: only handle messages to me + """ + def deco(func: Callable) -> Callable: nl_processor = NLProcessor(func=func, keywords=keywords, permission=permission, only_to_me=only_to_me) @@ -61,12 +69,23 @@ NLPResult = namedtuple('NLPResult', ( async def handle_natural_language(bot: CQHttp, ctx: Dict[str, Any]) -> bool: + """ + Handle a message as natural language. + + This function is typically called by "handle_message". + + :param bot: CQHttp instance + :param ctx: message context + :return: the message is handled as natural language + """ msg = str(ctx['message']) if bot.config.NICKNAME: + # check if the user is calling to me with my nickname m = re.search(rf'^{bot.config.NICKNAME}[\s,,]+', msg) if m: ctx['to_me'] = True msg = msg[m.end():] + session = NLPSession(bot, ctx, msg) coros = [] @@ -86,10 +105,12 @@ async def handle_natural_language(bot: CQHttp, ctx: Dict[str, Any]) -> bool: coros.append(p.func(session)) if coros: + # wait for possible results, and sort them by confidence results = sorted(filter(lambda r: r, await asyncio.gather(*coros)), key=lambda r: r.confidence, reverse=True) logger.debug(results) if results and results[0].confidence >= 60.0: + # choose the result with highest confidence return await call_command(bot, ctx, results[0].cmd_name, results[0].cmd_args) return False diff --git a/none/notice_request.py b/none/notice_request.py index fcfcff65..ca9a9049 100644 --- a/none/notice_request.py +++ b/none/notice_request.py @@ -82,7 +82,7 @@ async def handle_notice_or_request(bot: CQHttp, ctx: Dict[str, Any]) -> None: if post_type == 'notice': session = NoticeSession(bot, ctx) - else: + else: # must be 'request' session = RequestSession(bot, ctx) logger.debug(f'Emitting event: {event}') diff --git a/none/permission.py b/none/permission.py index b08a52c7..36984e23 100644 --- a/none/permission.py +++ b/none/permission.py @@ -44,6 +44,14 @@ _MinContext = namedtuple('MinContext', _min_context_fields) async def check_permission(bot: CQHttp, ctx: Dict[str, Any], permission_required: int) -> bool: + """ + Check if the context has the permission required. + + :param bot: CQHttp instance + :param ctx: message context + :param permission_required: permission required + :return: the context has the permission + """ min_ctx_kwargs = {} for field in _min_context_fields: if field in ctx: @@ -54,7 +62,7 @@ async def check_permission(bot: CQHttp, ctx: Dict[str, Any], return await _check(bot, min_ctx, permission_required) -@cached(ttl=2 * 60) # cache the result for 1 minute +@cached(ttl=2 * 60) # cache the result for 2 minute async def _check(bot: CQHttp, min_ctx: _MinContext, permission_required: int) -> bool: permission = 0 diff --git a/none/session.py b/none/session.py index 30a5b8c8..9a3b5195 100644 --- a/none/session.py +++ b/none/session.py @@ -15,10 +15,16 @@ class BaseSession: async def send(self, message: Union[str, Dict[str, Any], List[Dict[str, Any]]], *, ignore_failure: bool = True) -> None: + """ + Send a message ignoring failure by default. + """ return await send(self.bot, self.ctx, message, ignore_failure=ignore_failure) async def send_expr(self, expr: Union[str, Sequence[str], Callable], **kwargs): + """ + Sending a expression message ignoring failure by default. + """ return await send_expr(self.bot, self.ctx, expr, **kwargs)