From 6ec3ac66f73930fd1fec34acae5d39f92e8f5fb5 Mon Sep 17 00:00:00 2001 From: Richard Chien Date: Sun, 1 Jul 2018 17:51:01 +0800 Subject: [PATCH] Add NLP --- none/__init__.py | 1 + none/command.py | 80 ++++++++++++++++++-------- none/default_config.py | 1 + none/natural_language.py | 83 +++++++++++++++++++++++++-- none/plugins/base.py | 4 +- none_demo/config.py | 1 + none_demo/plugins/tuling.py | 37 ++++++++++++ none_demo/plugins/weather/__init__.py | 35 +++++------ 8 files changed, 189 insertions(+), 53 deletions(-) create mode 100644 none_demo/plugins/tuling.py diff --git a/none/__init__.py b/none/__init__.py index 9bef65d7..02899b0b 100644 --- a/none/__init__.py +++ b/none/__init__.py @@ -75,6 +75,7 @@ def load_builtin_plugins(): from .command import on_command, CommandSession, CommandGroup +from .natural_language import on_natural_language, NLPSession, NLPResult from .notice_request import ( on_notice, NoticeSession, on_request, RequestSession, diff --git a/none/command.py b/none/command.py index 80e0c979..eab86df3 100644 --- a/none/command.py +++ b/none/command.py @@ -29,7 +29,7 @@ _sessions = {} class Command: __slots__ = ('name', 'func', 'permission', 'only_to_me', 'args_parser_func') - def __init__(self, name: Tuple[str], func: Callable, permission: int, *, + def __init__(self, *, name: Tuple[str], func: Callable, permission: int, only_to_me: bool): self.name = name self.func = func @@ -79,11 +79,11 @@ def on_command(name: Union[str, Tuple[str]], *, for alias in aliases: _aliases[alias] = cmd_name - def args_parser(parser_func: Callable): + def args_parser_deco(parser_func: Callable): cmd.args_parser_func = parser_func return parser_func - func.args_parser = args_parser + func.args_parser = args_parser_deco return func return deco @@ -156,10 +156,10 @@ class CommandSession(BaseSession): super().__init__(bot, ctx) self.cmd = cmd self.current_key = None - self.current_arg = current_arg - self.current_arg_text = Message(current_arg).extract_plain_text() - self.current_arg_images = [s.data['url'] for s in ctx['message'] - if s.type == 'image' and 'url' in s.data] + self.current_arg = None + self.current_arg_text = None + self.current_arg_images = None + self.refresh(ctx, current_arg=current_arg) self.args = args or {} self.last_interaction = None @@ -172,8 +172,9 @@ class CommandSession(BaseSession): """ self.ctx = ctx self.current_arg = current_arg - self.current_arg_text = Message(current_arg).extract_plain_text() - self.current_arg_images = [s.data['url'] for s in ctx['message'] + current_arg_as_msg = Message(current_arg) + self.current_arg_text = current_arg_as_msg.extract_plain_text() + self.current_arg_images = [s.data['url'] for s in current_arg_as_msg if s.type == 'image' and 'url' in s.data] @property @@ -185,37 +186,38 @@ class CommandSession(BaseSession): return False return True - def require_arg(self, key: str, prompt: str = None, *, - prompt_expr: Union[str, Sequence[str], Callable] = None, - interactive: bool = True) -> Any: + def get(self, key: str, *, prompt: str = None, + prompt_expr: Union[str, Sequence[str], Callable] = None) -> Any: """ Get an argument with a given key. - If "interactive" is True, and the argument does not exist - in the current session, a FurtherInteractionNeeded exception - will be raised, and the caller of the command will know - it should keep the session for further interaction with the user. - - If "interactive" is False, missed key will cause a result of None. + If the argument does not exist in the current session, + a FurtherInteractionNeeded exception will be raised, + and the caller of the command will know it should keep + the session for further interaction with the user. :param key: argument key :param prompt: prompt to ask the user :param prompt_expr: prompt expression to ask the user - :param interactive: should enter interactive mode while key missing :return: the argument value :raise FurtherInteractionNeeded: further interaction is needed """ - value = self.args.get(key) - if value is not None or not interactive: + value = self.get_optional(key) + if value is not None: return value self.current_key = key # ask the user for more information if prompt_expr is not None: prompt = render(prompt_expr, key=key) - asyncio.ensure_future(self.send(prompt)) + if prompt: + asyncio.ensure_future(self.send(prompt)) raise _FurtherInteractionNeeded + def get_optional(self, key: str, + default: Optional[Any] = None) -> Optional[Any]: + return self.args.get(key, default) + def _new_command_session(bot: CQHttp, ctx: Dict[str, Any]) -> Optional[CommandSession]: @@ -301,12 +303,40 @@ async def handle_command(bot: CQHttp, ctx: Dict[str, Any]) -> bool: session = _new_command_session(bot, ctx) if not session: return False - _sessions[src] = session + return await _real_run_command(session, src, check_perm=check_perm) + + +async def call_command(bot: CQHttp, ctx: Dict[str, Any], + name: Union[str, Tuple[str]], + args: Dict[str, Any]) -> bool: + """ + Call a command internally. + + This function is typically called by some other commands + or "handle_natural_language" when handling NLPResult object. + + :param bot: CQHttp instance + :param ctx: message context + :param name: command name + :param args: command args + :return: the command is successfully called + """ + cmd = _find_command(name) + if not cmd: + return False + session = CommandSession(bot, ctx, cmd, args=args) + return await _real_run_command(session, context_source(session.ctx), + check_perm=False) + + +async def _real_run_command(session: CommandSession, + ctx_src: str, **kwargs) -> bool: + _sessions[ctx_src] = session try: - res = await session.cmd.run(session, check_perm=check_perm) + res = await session.cmd.run(session, **kwargs) # the command is finished, remove the session - del _sessions[src] + del _sessions[ctx_src] return res except _FurtherInteractionNeeded: session.last_interaction = datetime.now() diff --git a/none/default_config.py b/none/default_config.py index bb2b5ca3..25bbb783 100644 --- a/none/default_config.py +++ b/none/default_config.py @@ -8,6 +8,7 @@ PORT = 8080 DEBUG = True SUPERUSERS = set() +NICKNAME = '' COMMAND_START = {'/', '!', '/', '!'} COMMAND_SEP = {'/', '.'} SESSION_EXPIRE_TIMEOUT = timedelta(minutes=5) diff --git a/none/natural_language.py b/none/natural_language.py index dc1f68be..568697d9 100644 --- a/none/natural_language.py +++ b/none/natural_language.py @@ -1,14 +1,56 @@ +import re +import asyncio from collections import namedtuple -from typing import Dict, Any +from typing import Dict, Any, Iterable, Optional, Callable, Union from aiocqhttp import CQHttp +from aiocqhttp.message import Message + +from . import permission as perm +from .session import BaseSession +from .command import call_command +from .log import logger _nl_processors = set() class NLProcessor: - __slots__ = ('func', 'permission', 'only_to_me', 'keywords', - 'precondition_func') + __slots__ = ('func', 'keywords', 'permission', 'only_to_me') + + def __init__(self, *, func: Callable, keywords: Optional[Iterable], + permission: int, only_to_me: bool): + self.func = func + self.keywords = keywords + self.permission = permission + self.only_to_me = only_to_me + + +def on_natural_language(keywords: Union[Optional[Iterable], Callable] = None, *, + permission: int = perm.EVERYBODY, + only_to_me: bool = True) -> Callable: + def deco(func: Callable) -> Callable: + nl_processor = NLProcessor(func=func, keywords=keywords, + permission=permission, only_to_me=only_to_me) + _nl_processors.add(nl_processor) + return func + + if isinstance(keywords, Callable): + # here "keywords" is the function to be decorated + return on_natural_language()(keywords) + else: + return deco + + +class NLPSession(BaseSession): + __slots__ = ('msg', 'msg_text', 'msg_images') + + def __init__(self, bot: CQHttp, ctx: Dict[str, Any], msg: str): + super().__init__(bot, ctx) + self.msg = msg + tmp_msg = Message(msg) + self.msg_text = tmp_msg.extract_plain_text() + self.msg_images = [s.data['url'] for s in tmp_msg + if s.type == 'image' and 'url' in s.data] NLPResult = namedtuple('NLPResult', ( @@ -18,5 +60,36 @@ NLPResult = namedtuple('NLPResult', ( )) -async def handle_natural_language(bot: CQHttp, ctx: Dict[str, Any]) -> None: - pass +async def handle_natural_language(bot: CQHttp, ctx: Dict[str, Any]) -> bool: + msg = str(ctx['message']) + if bot.config.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 = [] + for p in _nl_processors: + should_run = await perm.check_permission(bot, ctx, p.permission) + if should_run and p.keywords: + for kw in p.keywords: + if kw in session.msg_text: + break + else: + # no keyword matches + should_run = False + if should_run and p.only_to_me and not ctx['to_me']: + should_run = False + + if should_run: + coros.append(p.func(session)) + + if coros: + 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: + return await call_command(bot, ctx, + results[0].cmd_name, results[0].cmd_args) + return False diff --git a/none/plugins/base.py b/none/plugins/base.py index c6648a27..73d79427 100644 --- a/none/plugins/base.py +++ b/none/plugins/base.py @@ -5,10 +5,10 @@ from none import on_command, CommandSession, permission as perm @on_command('echo', only_to_me=False) async def echo(session: CommandSession): - await session.send(session.args.get('message') or session.current_arg) + await session.send(session.get_optional('message') or session.current_arg) @on_command('say', permission=perm.SUPERUSER, only_to_me=False) async def _(session: CommandSession): await session.send( - unescape(session.args.get('message') or session.current_arg)) + unescape(session.get_optional('message') or session.current_arg)) diff --git a/none_demo/config.py b/none_demo/config.py index df7985d0..7eaf9195 100644 --- a/none_demo/config.py +++ b/none_demo/config.py @@ -6,5 +6,6 @@ HOST = '0.0.0.0' SECRET = 'abc' SUPERUSERS = {1002647525} +NICKNAME = '奶茶' COMMAND_START = {'', '/', '!', '/', '!', re.compile(r'^>+\s*')} COMMAND_SEP = {'/', '.', re.compile(r'#|::?')} diff --git a/none_demo/plugins/tuling.py b/none_demo/plugins/tuling.py new file mode 100644 index 00000000..96929e02 --- /dev/null +++ b/none_demo/plugins/tuling.py @@ -0,0 +1,37 @@ +import asyncio + +from none import ( + on_command, CommandSession, + on_natural_language, NLPSession, NLPResult +) + + +@on_command('tuling', aliases=('聊天', '对话')) +async def tuling(session: CommandSession): + message = session.get('message', prompt='我已经准备好啦,来跟我聊天吧~') + + finish = message in ('结束', '拜拜', '再见') + if finish: + asyncio.ensure_future(session.send('拜拜啦,你忙吧,下次想聊天随时找我哦~')) + return + + # call tuling api + reply = f'你说了:{message}' + + one_time = session.get_optional('one_time', False) + if one_time: + asyncio.ensure_future(session.send(reply)) + else: + del session.args['message'] + session.get('message', prompt=reply) + + +@tuling.args_parser +async def _(session: CommandSession): + if session.current_key == 'message': + session.args[session.current_key] = session.current_arg_text.strip() + + +@on_natural_language +async def _(session: NLPSession): + return NLPResult(60.0, 'tuling', {'message': session.msg, 'one_time': True}) diff --git a/none_demo/plugins/weather/__init__.py b/none_demo/plugins/weather/__init__.py index 0b112e18..4696ff10 100644 --- a/none_demo/plugins/weather/__init__.py +++ b/none_demo/plugins/weather/__init__.py @@ -1,4 +1,7 @@ -from none import CommandSession, CommandGroup +from none import ( + CommandSession, CommandGroup, + on_natural_language, NLPSession, NLPResult +) from . import expressions as expr @@ -7,34 +10,24 @@ w = CommandGroup('weather') @w.command('weather', aliases=('天气', '天气预报')) async def weather(session: CommandSession): - city = session.require_arg('city', prompt_expr=expr.WHICH_CITY) + city = session.get('city', prompt_expr=expr.WHICH_CITY) await session.send_expr(expr.REPORT, city=city) @weather.args_parser async def _(session: CommandSession): + striped_arg = session.current_arg_text.strip() if session.current_key: - session.args[session.current_key] = session.current_arg_text.strip() + session.args[session.current_key] = striped_arg + elif striped_arg: + session.args['city'] = striped_arg -# @on_natural_language(keywords={'天气', '雨', '雪', '晴', '阴', '多云', '冰雹'}, -# only_to_me=False) -# async def weather_nlp(session: NaturalLanguageSession): -# return NLPResult(89.5, ('weather', 'weather'), {'city': '南京'}) -# -# -# @weather_nlp.condition -# async def _(session: NaturalLanguageSession): -# keywords = {'天气', '雨', '雪', '晴', '阴', '多云', '冰雹'} -# for kw in keywords: -# if kw in session.text: -# keyword_hit = True -# break -# else: -# keyword_hit = False -# if session.ctx['to_me'] and keyword_hit: -# return True -# return False +@on_natural_language({'天气', '雨', '雪', '晴', '阴'}, only_to_me=False) +async def _(session: NLPSession): + if not ('?' in session.msg_text or '?' in session.msg_text): + return None + return NLPResult(90.0, ('weather', 'weather'), {}) @w.command('suggestion', aliases=('生活指数', '生活建议', '生活提示'))