This commit is contained in:
Richard Chien 2018-07-01 17:51:01 +08:00
parent a13128f356
commit 6ec3ac66f7
8 changed files with 189 additions and 53 deletions

View File

@ -75,6 +75,7 @@ def load_builtin_plugins():
from .command import on_command, CommandSession, CommandGroup from .command import on_command, CommandSession, CommandGroup
from .natural_language import on_natural_language, NLPSession, NLPResult
from .notice_request import ( from .notice_request import (
on_notice, NoticeSession, on_notice, NoticeSession,
on_request, RequestSession, on_request, RequestSession,

View File

@ -29,7 +29,7 @@ _sessions = {}
class Command: class Command:
__slots__ = ('name', 'func', 'permission', 'only_to_me', 'args_parser_func') __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): only_to_me: bool):
self.name = name self.name = name
self.func = func self.func = func
@ -79,11 +79,11 @@ def on_command(name: Union[str, Tuple[str]], *,
for alias in aliases: for alias in aliases:
_aliases[alias] = cmd_name _aliases[alias] = cmd_name
def args_parser(parser_func: Callable): def args_parser_deco(parser_func: Callable):
cmd.args_parser_func = parser_func cmd.args_parser_func = parser_func
return parser_func return parser_func
func.args_parser = args_parser func.args_parser = args_parser_deco
return func return func
return deco return deco
@ -156,10 +156,10 @@ class CommandSession(BaseSession):
super().__init__(bot, ctx) super().__init__(bot, ctx)
self.cmd = cmd self.cmd = cmd
self.current_key = None self.current_key = None
self.current_arg = current_arg self.current_arg = None
self.current_arg_text = Message(current_arg).extract_plain_text() self.current_arg_text = None
self.current_arg_images = [s.data['url'] for s in ctx['message'] self.current_arg_images = None
if s.type == 'image' and 'url' in s.data] self.refresh(ctx, current_arg=current_arg)
self.args = args or {} self.args = args or {}
self.last_interaction = None self.last_interaction = None
@ -172,8 +172,9 @@ class CommandSession(BaseSession):
""" """
self.ctx = ctx self.ctx = ctx
self.current_arg = current_arg self.current_arg = current_arg
self.current_arg_text = Message(current_arg).extract_plain_text() current_arg_as_msg = Message(current_arg)
self.current_arg_images = [s.data['url'] for s in ctx['message'] 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] if s.type == 'image' and 'url' in s.data]
@property @property
@ -185,37 +186,38 @@ class CommandSession(BaseSession):
return False return False
return True return True
def require_arg(self, key: str, prompt: str = None, *, def get(self, key: str, *, prompt: str = None,
prompt_expr: Union[str, Sequence[str], Callable] = None, prompt_expr: Union[str, Sequence[str], Callable] = None) -> Any:
interactive: bool = True) -> Any:
""" """
Get an argument with a given key. Get an argument with a given key.
If "interactive" is True, and the argument does not exist If the argument does not exist in the current session,
in the current session, a FurtherInteractionNeeded exception a FurtherInteractionNeeded exception will be raised,
will be raised, and the caller of the command will know and the caller of the command will know it should keep
it should keep the session for further interaction with the user. the session for further interaction with the user.
If "interactive" is False, missed key will cause a result of None.
:param key: argument key :param key: argument key
:param prompt: prompt to ask the user :param prompt: prompt to ask the user
:param prompt_expr: prompt expression 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 :return: the argument value
:raise FurtherInteractionNeeded: further interaction is needed :raise FurtherInteractionNeeded: further interaction is needed
""" """
value = self.args.get(key) value = self.get_optional(key)
if value is not None or not interactive: if value is not None:
return value return value
self.current_key = key self.current_key = key
# ask the user for more information # ask the user for more information
if prompt_expr is not None: if prompt_expr is not None:
prompt = render(prompt_expr, key=key) prompt = render(prompt_expr, key=key)
if prompt:
asyncio.ensure_future(self.send(prompt)) asyncio.ensure_future(self.send(prompt))
raise _FurtherInteractionNeeded 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, def _new_command_session(bot: CQHttp,
ctx: Dict[str, Any]) -> Optional[CommandSession]: 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) session = _new_command_session(bot, ctx)
if not session: if not session:
return False 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: 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 # the command is finished, remove the session
del _sessions[src] del _sessions[ctx_src]
return res return res
except _FurtherInteractionNeeded: except _FurtherInteractionNeeded:
session.last_interaction = datetime.now() session.last_interaction = datetime.now()

View File

@ -8,6 +8,7 @@ PORT = 8080
DEBUG = True DEBUG = True
SUPERUSERS = set() SUPERUSERS = set()
NICKNAME = ''
COMMAND_START = {'/', '!', '', ''} COMMAND_START = {'/', '!', '', ''}
COMMAND_SEP = {'/', '.'} COMMAND_SEP = {'/', '.'}
SESSION_EXPIRE_TIMEOUT = timedelta(minutes=5) SESSION_EXPIRE_TIMEOUT = timedelta(minutes=5)

View File

@ -1,14 +1,56 @@
import re
import asyncio
from collections import namedtuple from collections import namedtuple
from typing import Dict, Any from typing import Dict, Any, Iterable, Optional, Callable, Union
from aiocqhttp import CQHttp 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() _nl_processors = set()
class NLProcessor: class NLProcessor:
__slots__ = ('func', 'permission', 'only_to_me', 'keywords', __slots__ = ('func', 'keywords', 'permission', 'only_to_me')
'precondition_func')
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', ( NLPResult = namedtuple('NLPResult', (
@ -18,5 +60,36 @@ NLPResult = namedtuple('NLPResult', (
)) ))
async def handle_natural_language(bot: CQHttp, ctx: Dict[str, Any]) -> None: async def handle_natural_language(bot: CQHttp, ctx: Dict[str, Any]) -> bool:
pass 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

View File

@ -5,10 +5,10 @@ from none import on_command, CommandSession, permission as perm
@on_command('echo', only_to_me=False) @on_command('echo', only_to_me=False)
async def echo(session: CommandSession): 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) @on_command('say', permission=perm.SUPERUSER, only_to_me=False)
async def _(session: CommandSession): async def _(session: CommandSession):
await session.send( await session.send(
unescape(session.args.get('message') or session.current_arg)) unescape(session.get_optional('message') or session.current_arg))

View File

@ -6,5 +6,6 @@ HOST = '0.0.0.0'
SECRET = 'abc' SECRET = 'abc'
SUPERUSERS = {1002647525} SUPERUSERS = {1002647525}
NICKNAME = '奶茶'
COMMAND_START = {'', '/', '!', '', '', re.compile(r'^>+\s*')} COMMAND_START = {'', '/', '!', '', '', re.compile(r'^>+\s*')}
COMMAND_SEP = {'/', '.', re.compile(r'#|::?')} COMMAND_SEP = {'/', '.', re.compile(r'#|::?')}

View File

@ -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})

View File

@ -1,4 +1,7 @@
from none import CommandSession, CommandGroup from none import (
CommandSession, CommandGroup,
on_natural_language, NLPSession, NLPResult
)
from . import expressions as expr from . import expressions as expr
@ -7,34 +10,24 @@ w = CommandGroup('weather')
@w.command('weather', aliases=('天气', '天气预报')) @w.command('weather', aliases=('天气', '天气预报'))
async def weather(session: CommandSession): 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) await session.send_expr(expr.REPORT, city=city)
@weather.args_parser @weather.args_parser
async def _(session: CommandSession): async def _(session: CommandSession):
striped_arg = session.current_arg_text.strip()
if session.current_key: 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={'天气', '雨', '雪', '晴', '阴', '多云', '冰雹'}, @on_natural_language({'天气', '', '', '', ''}, only_to_me=False)
# only_to_me=False) async def _(session: NLPSession):
# async def weather_nlp(session: NaturalLanguageSession): if not ('?' in session.msg_text or '' in session.msg_text):
# return NLPResult(89.5, ('weather', 'weather'), {'city': '南京'}) return None
# return NLPResult(90.0, ('weather', 'weather'), {})
#
# @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
@w.command('suggestion', aliases=('生活指数', '生活建议', '生活提示')) @w.command('suggestion', aliases=('生活指数', '生活建议', '生活提示'))