Improve command module and add "schedule" command group

This commit is contained in:
Richard Chien 2018-07-05 23:11:00 +08:00
parent f58bfa325e
commit 946d0a11cd
4 changed files with 231 additions and 42 deletions

16
none/argparse.py Normal file
View File

@ -0,0 +1,16 @@
from argparse import *
class ParserExit(RuntimeError):
def __init__(self, status=0, message=None):
self.status = status
self.message = message
class ArgumentParser(ArgumentParser):
def _print_message(self, *args, **kwargs):
# do nothing
pass
def exit(self, status=0, message=None):
raise ParserExit(status=status, message=message)

View File

@ -37,26 +37,38 @@ class Command:
self.only_to_me = only_to_me self.only_to_me = only_to_me
self.args_parser_func = None self.args_parser_func = None
async def run(self, session, check_perm: bool = True) -> bool: async def run(self, session, *,
check_perm: bool = True,
dry: bool = False) -> bool:
""" """
Run the command in a given session. Run the command in a given session.
:param session: CommandSession object :param session: CommandSession object
:param check_perm: should check permission before running :param check_perm: should check permission before running
:return: the command is finished :param dry: just check any prerequisite, without actually running
:return: the command is finished (or can be run, given dry == True)
""" """
if check_perm: has_perm = await self._check_perm(session) if check_perm else True
has_perm = await perm.check_permission(
session.bot, session.ctx, self.permission)
else:
has_perm = True
if self.func and has_perm: if self.func and has_perm:
if dry:
return True
if self.args_parser_func: if self.args_parser_func:
await self.args_parser_func(session) await self.args_parser_func(session)
await self.func(session) await self.func(session)
return True return True
return False return False
async def _check_perm(self, session) -> bool:
"""
Check if the session has sufficient permission to
call the command.
:param session: CommandSession object
:return: the session has the permission
"""
return await perm.check_permission(session.bot, session.ctx,
self.permission)
def on_command(name: Union[str, Tuple[str]], *, def on_command(name: Union[str, Tuple[str]], *,
aliases: Iterable = (), aliases: Iterable = (),
@ -226,31 +238,25 @@ class CommandSession(BaseSession):
return self.args.get(key, default) return self.args.get(key, default)
def _new_command_session(bot: NoneBot, def parse_command(bot: NoneBot,
ctx: Dict[str, Any]) -> Optional[CommandSession]: cmd_string: str) -> Tuple[Optional[Command], Optional[str]]:
""" """
Create a new session for a command. Parse a command string (typically from a message).
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: NoneBot instance :param bot: NoneBot instance
:param ctx: message context :param cmd_string: command string
:return: CommandSession object or None :return: (Command object, current arg string)
""" """
msg = str(ctx['message']).lstrip()
matched_start = None matched_start = None
for start in bot.config.COMMAND_START: for start in bot.config.COMMAND_START:
# loop through COMMAND_START to find the longest matched start # loop through COMMAND_START to find the longest matched start
curr_matched_start = None curr_matched_start = None
if isinstance(start, type(re.compile(''))): if isinstance(start, type(re.compile(''))):
m = start.search(msg) m = start.search(cmd_string)
if m and m.start(0) == 0: if m and m.start(0) == 0:
curr_matched_start = m.group(0) curr_matched_start = m.group(0)
elif isinstance(start, str): elif isinstance(start, str):
if msg.startswith(start): if cmd_string.startswith(start):
curr_matched_start = start curr_matched_start = start
if curr_matched_start is not None and \ if curr_matched_start is not None and \
@ -261,13 +267,13 @@ def _new_command_session(bot: NoneBot,
if matched_start is None: if matched_start is None:
# it's not a command # it's not a command
return None return None, None
full_command = msg[len(matched_start):].lstrip() full_command = cmd_string[len(matched_start):].lstrip()
if not full_command: if not full_command:
# command is empty # command is empty
return None return None, None
cmd_name_text, *cmd_remained = full_command.split(maxsplit=1) cmd_name_text, *cmd_remained = full_command.split(maxsplit=1)
cmd_name = _aliases.get(cmd_name_text) cmd_name = _aliases.get(cmd_name_text)
@ -291,11 +297,9 @@ def _new_command_session(bot: NoneBot,
cmd = _find_command(cmd_name) cmd = _find_command(cmd_name)
if not cmd: if not cmd:
return None return None, None
if cmd.only_to_me and not ctx['to_me']:
return None
return CommandSession(bot, ctx, cmd, current_arg=''.join(cmd_remained)) return cmd, ''.join(cmd_remained)
async def handle_command(bot: NoneBot, ctx: Dict[str, Any]) -> bool: async def handle_command(bot: NoneBot, ctx: Dict[str, Any]) -> bool:
@ -322,48 +326,65 @@ async def handle_command(bot: NoneBot, ctx: Dict[str, Any]) -> bool:
del _sessions[ctx_id] del _sessions[ctx_id]
session = None session = None
if not session: if not session:
session = _new_command_session(bot, ctx) cmd, current_arg = parse_command(bot, str(ctx['message']).lstrip())
if not session: if not cmd or cmd.only_to_me and not ctx['to_me']:
return False return False
session = CommandSession(bot, ctx, cmd, current_arg=current_arg)
return await _real_run_command(session, ctx_id, check_perm=check_perm) return await _real_run_command(session, ctx_id, check_perm=check_perm)
async def call_command(bot: NoneBot, ctx: Dict[str, Any], async def call_command(bot: NoneBot, ctx: Dict[str, Any],
name: Union[str, Tuple[str]], name: Union[str, Tuple[str]], *,
args: Dict[str, Any]) -> bool: current_arg: str = '',
args: Optional[Dict[str, Any]] = None,
check_perm: bool = True,
disable_interaction: bool = False) -> bool:
""" """
Call a command internally. Call a command internally.
This function is typically called by some other commands This function is typically called by some other commands
or "handle_natural_language" when handling NLPResult object. or "handle_natural_language" when handling NLPResult object.
Note: After calling this function, any previous command session Note: If disable_interaction is not True, after calling this function,
will be overridden, even if the command being called here does any previous command session will be overridden, even if the command
not need further interaction (a.k.a asking the user for more info). being called here does not need further interaction (a.k.a asking
the user for more info).
:param bot: NoneBot instance :param bot: NoneBot instance
:param ctx: message context :param ctx: message context
:param name: command name :param name: command name
:param current_arg: command current argument string
:param args: command args :param args: command args
:param check_perm: should check permission before running command
:param disable_interaction: disable the command's further interaction
:return: the command is successfully called :return: the command is successfully called
""" """
cmd = _find_command(name) cmd = _find_command(name)
if not cmd: if not cmd:
return False return False
session = CommandSession(bot, ctx, cmd, args=args) session = CommandSession(bot, ctx, cmd, current_arg=current_arg, args=args)
return await _real_run_command(session, context_id(session.ctx), return await _real_run_command(session, context_id(session.ctx),
check_perm=False) check_perm=check_perm,
disable_interaction=disable_interaction)
async def _real_run_command(session: CommandSession, async def _real_run_command(session: CommandSession,
ctx_id: str, **kwargs) -> bool: ctx_id: str,
disable_interaction: bool = False,
**kwargs) -> bool:
if not disable_interaction:
# override session only when not disabling interaction
_sessions[ctx_id] = session _sessions[ctx_id] = session
try: try:
res = await session.cmd.run(session, **kwargs) res = await session.cmd.run(session, **kwargs)
if not disable_interaction:
# the command is finished, remove the session # the command is finished, remove the session
del _sessions[ctx_id] del _sessions[ctx_id]
return res return res
except _FurtherInteractionNeeded: except _FurtherInteractionNeeded:
if disable_interaction:
# if the command needs further interaction, we view it as failed
return False
session.last_interaction = datetime.now() session.last_interaction = datetime.now()
# return True because this step of the session is successful # return True because this step of the session is successful
return True return True

View File

@ -117,6 +117,7 @@ async def handle_natural_language(bot: NoneBot, ctx: Dict[str, Any]) -> bool:
logger.debug(results) logger.debug(results)
if results and results[0].confidence >= 60.0: if results and results[0].confidence >= 60.0:
# choose the result with highest confidence # choose the result with highest confidence
return await call_command(bot, ctx, return await call_command(bot, ctx, results[0].cmd_name,
results[0].cmd_name, results[0].cmd_args) args=results[0].cmd_args,
check_perm=False)
return False return False

151
none/plugins/schedule.py Normal file
View File

@ -0,0 +1,151 @@
import atexit
import re
import shlex
from typing import Iterable, Tuple, Dict, Any
from apscheduler.jobstores.base import ConflictingIdError
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from none import get_bot, CommandGroup, CommandSession, permission as perm
from none.argparse import ArgumentParser, ParserExit
from none.command import parse_command, call_command
from none.helpers import context_id, send
sched = CommandGroup('schedule', permission=perm.PRIVATE | perm.GROUP_ADMIN,
only_to_me=False)
_bot = get_bot()
_scheduler = AsyncIOScheduler(
jobstores={
'default': SQLAlchemyJobStore(
url='sqlite:///' + _bot.get_data_file('db', 'schedule.sqlite'))
},
timezone='Asia/Shanghai'
)
if not _scheduler.running:
_scheduler.start()
@atexit.register
def _():
if _scheduler.running:
_scheduler.shutdown()
async def _schedule_callback(ctx: Dict[str, Any], name: str,
commands: Iterable[Tuple[Tuple[str], str]],
verbose: bool = False):
if verbose:
await send(_bot, ctx, f'开始执行计划任务 {name}……')
for cmd_name, current_arg in commands:
await call_command(_bot, ctx, cmd_name,
current_arg=current_arg,
check_perm=True,
disable_interaction=True)
@sched.command('add', aliases=('schedule',))
async def sched_add(session: CommandSession):
parser = ArgumentParser('schedule.add')
parser.add_argument('-S', '--second')
parser.add_argument('-M', '--minute')
parser.add_argument('-H', '--hour')
parser.add_argument('-d', '--day')
parser.add_argument('-m', '--month')
parser.add_argument('-w', '--day-of-week')
parser.add_argument('-f', '--force', action='store_true', default=False)
parser.add_argument('-v', '--verbose', action='store_true', default=False)
parser.add_argument('--name', required=True)
parser.add_argument('commands', nargs='+')
argv = session.get_optional('argv')
if not argv:
await session.send(_sched_add_help)
return
try:
args = parser.parse_args(argv)
except ParserExit as e:
if e.status == 0:
# --help
await session.send(_sched_add_help)
else:
await session.send('参数不足或不正确,请使用 --help 参数查询使用帮助')
return
if not re.match(r'[_a-zA-Z][_\w]*', args.name):
await session.send(
'计划任务名必须仅包含字母、数字、下划线,且以字母或下划线开头')
return
parsed_commands = []
invalid_commands = []
for cmd_str in args.commands:
cmd, current_arg = parse_command(session.bot, cmd_str)
if cmd:
tmp_session = CommandSession(session.bot, session.ctx, cmd,
current_arg=current_arg)
if await cmd.run(tmp_session, dry=True):
parsed_commands.append((cmd.name, current_arg))
else:
invalid_commands.append(cmd_str)
if invalid_commands:
invalid_commands_joined = '\r\n'.join(
[f'{i+1}: {c}' for i, c in enumerate(invalid_commands)])
await session.send(f'计划任务添加失败,'
f'因为下面的 {len(invalid_commands)} 个命令无法被运行'
f'(命令不存在或权限不够):\r\n\r\n'
f'{invalid_commands_joined}')
return
job_id = f'{context_id(session.ctx)}/job/{args.name}'
trigger_args = {k: v for k, v in args.__dict__.items()
if k in {'second', 'minute', 'hour',
'day', 'month', 'day_of_week'}}
try:
job = _scheduler.add_job(
_schedule_callback,
trigger='cron', **trigger_args,
id=job_id,
kwargs={
'ctx': session.ctx,
'name': args.name,
'commands': parsed_commands,
'verbose': args.verbose,
},
replace_existing=args.force,
misfire_grace_time=30
)
except ConflictingIdError:
# a job with same name exists
await session.send(f'计划任务 {args.name} 已存在,'
f'若要覆盖请使用 --force 参数')
return
await session.send(f'计划任务 {args.name} 添加成功,下次运行时间:'
f'{job.next_run_time.strftime("%Y-%m-%d %H:%M:%S")}')
@sched.command('list')
async def sched_list(session: CommandSession):
pass
@sched.command('remove')
async def sched_remove(session: CommandSession):
pass
@sched_add.args_parser
@sched_list.args_parser
@sched_remove.args_parser
async def _(session: CommandSession):
session.args['argv'] = shlex.split(session.current_arg_text)
_sched_add_help = r"""
使用方法schedule.add
""".strip()