Rename package to "nonebot"

This commit is contained in:
Richard Chien
2018-12-27 20:23:45 +08:00
parent b8cb8e440b
commit 663797219a
71 changed files with 221 additions and 220 deletions

169
nonebot/__init__.py Normal file
View File

@ -0,0 +1,169 @@
import asyncio
import importlib
import logging
import os
import re
from typing import Any, Optional
import aiocqhttp.message
from aiocqhttp import CQHttp
from .log import logger
from .sched import Scheduler
if Scheduler:
scheduler = Scheduler()
else:
scheduler = None
class NoneBot(CQHttp):
def __init__(self, config_object: Optional[Any] = None):
if config_object is None:
from . import default_config as config_object
config_dict = {k: v for k, v in config_object.__dict__.items()
if k.isupper() and not k.startswith('_')}
logger.debug(f'Loaded configurations: {config_dict}')
super().__init__(message_class=aiocqhttp.message.Message,
**{k.lower(): v for k, v in config_dict.items()})
self.config = config_object
self.asgi.debug = self.config.DEBUG
from .message import handle_message
from .notice_request import handle_notice_or_request
@self.on_message
async def _(ctx):
asyncio.ensure_future(handle_message(self, ctx))
@self.on_notice
async def _(ctx):
asyncio.ensure_future(handle_notice_or_request(self, ctx))
@self.on_request
async def _(ctx):
asyncio.ensure_future(handle_notice_or_request(self, ctx))
def run(self, host: Optional[str] = None, port: Optional[int] = None,
*args, **kwargs) -> None:
host = host or self.config.HOST
port = port or self.config.PORT
if 'debug' not in kwargs:
kwargs['debug'] = self.config.DEBUG
logger.info(f'Running on {host}:{port}')
super().run(host=host, port=port, loop=asyncio.get_event_loop(),
*args, **kwargs)
_bot: Optional[NoneBot] = None
def init(config_object: Optional[Any] = None) -> None:
"""
Initialize NoneBot instance.
This function must be called at the very beginning of code,
otherwise the get_bot() function will return None and nothing
is gonna work properly.
:param config_object: configuration object
"""
global _bot
_bot = NoneBot(config_object)
if _bot.config.DEBUG:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
if scheduler and not scheduler.running:
scheduler.configure(_bot.config.APSCHEDULER_CONFIG)
scheduler.start()
def get_bot() -> NoneBot:
"""
Get the NoneBot instance.
The result is ensured to be not None, otherwise an exception will
be raised.
:raise ValueError: instance not initialized
"""
if _bot is None:
raise ValueError('NoneBot instance has not been initialized')
return _bot
def run(host: Optional[str] = None, port: Optional[int] = None,
*args, **kwargs) -> None:
"""Run the NoneBot instance."""
get_bot().run(host=host, port=port, *args, **kwargs)
_plugins = set()
def load_plugin(module_name: str) -> bool:
"""
Load a module as a plugin.
:param module_name: name of module to import
:return: successful or not
"""
try:
_plugins.add(importlib.import_module(module_name))
logger.info(f'Succeeded to import "{module_name}"')
return True
except Exception as e:
logger.error(f'Failed to import "{module_name}", error: {e}')
logger.exception(e)
return False
def load_plugins(plugin_dir: str, module_prefix: str) -> int:
"""
Find all non-hidden modules or packages in a given directory,
and import them with the given module prefix.
:param plugin_dir: plugin directory to search
:param module_prefix: module prefix used while importing
:return: number of plugins successfully loaded
"""
count = 0
for name in os.listdir(plugin_dir):
path = os.path.join(plugin_dir, name)
if os.path.isfile(path) and \
(name.startswith('_') or not name.endswith('.py')):
continue
if os.path.isdir(path) and \
(name.startswith('_') or not os.path.exists(
os.path.join(path, '__init__.py'))):
continue
m = re.match(r'([_A-Z0-9a-z]+)(.py)?', name)
if not m:
continue
if load_plugin(f'{module_prefix}.{m.group(1)}'):
count += 1
return count
def load_builtin_plugins() -> int:
"""
Load built-in plugins distributed along with "nonebot" package.
"""
plugin_dir = os.path.join(os.path.dirname(__file__), 'plugins')
return load_plugins(plugin_dir, 'nonebot.plugins')
from .exceptions import *
from .message import message_preprocessor, Message, MessageSegment
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)

44
nonebot/argparse.py Normal file
View File

@ -0,0 +1,44 @@
from argparse import *
from .command import CommandSession
class ParserExit(RuntimeError):
def __init__(self, status=0, message=None):
self.status = status
self.message = message
class ArgumentParser(ArgumentParser):
"""
An ArgumentParser wrapper that avoid printing messages to
standard I/O.
"""
def __init__(self, *args, **kwargs):
self.session = kwargs.pop('session', None)
super().__init__(*args, **kwargs)
def _print_message(self, *args, **kwargs):
# do nothing
pass
def exit(self, status=0, message=None):
raise ParserExit(status=status, message=message)
def parse_args(self, args=None, namespace=None):
def finish(msg):
if self.session and isinstance(self.session, CommandSession):
self.session.finish(msg)
if not args:
finish(self.usage)
else:
try:
return super().parse_args(args=args, namespace=namespace)
except ParserExit as e:
if e.status == 0:
# --help
finish(self.usage)
else:
finish('参数不足或不正确,请使用 --help 参数查询使用帮助')

616
nonebot/command.py Normal file
View File

@ -0,0 +1,616 @@
import asyncio
import re
import shlex
from datetime import datetime
from typing import (
Tuple, Union, Callable, Iterable, Any, Optional, List
)
from . import NoneBot, permission as perm
from .helpers import context_id, send, render_expression
from .log import logger
from .message import Message
from .session import BaseSession
from .typing import (
Context_T, CommandName_T, CommandArgs_T, Message_T
)
# Key: str (one segment of command name)
# Value: subtree or a leaf Command object
_registry = {}
# Key: str
# Value: tuple that identifies a command
_aliases = {}
# Key: context id
# Value: CommandSession object
_sessions = {}
class Command:
__slots__ = ('name', 'func', 'permission',
'only_to_me', 'privileged', 'args_parser_func')
def __init__(self, *, name: CommandName_T, func: Callable,
permission: int, only_to_me: bool, privileged: bool):
self.name = name
self.func = func
self.permission = permission
self.only_to_me = only_to_me
self.privileged = privileged
self.args_parser_func = None
async def run(self, session, *,
check_perm: bool = True,
dry: bool = False) -> bool:
"""
Run the command in a given session.
:param session: CommandSession object
:param check_perm: should check permission before running
:param dry: just check any prerequisite, without actually running
:return: the command is finished (or can be run, given dry == True)
"""
has_perm = await self._check_perm(session) if check_perm else True
if self.func and has_perm:
if dry:
return True
if self.args_parser_func:
await self.args_parser_func(session)
await self.func(session)
return True
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)
class CommandFunc:
__slots__ = ('cmd', 'func')
def __init__(self, cmd: Command, func: Callable):
self.cmd = cmd
self.func = func
def __call__(self, *args, **kwargs):
return self.func(*args, **kwargs)
def args_parser(self, parser_func: Callable):
"""
Decorator to register a function as the arguments parser of
the corresponding command.
"""
self.cmd.args_parser_func = parser_func
return parser_func
def on_command(name: Union[str, CommandName_T], *,
aliases: Iterable[str] = (),
permission: int = perm.EVERYBODY,
only_to_me: bool = True,
privileged: bool = False,
shell_like: bool = False) -> 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
:param privileged: can be run even when there is already a session
:param shell_like: use shell-like syntax to split arguments
"""
def deco(func: Callable) -> Callable:
if not isinstance(name, (str, tuple)):
raise TypeError('the name of a command must be a str or tuple')
if not name:
raise ValueError('the name of a command must not be empty')
cmd_name = (name,) if isinstance(name, str) else name
cmd = Command(name=cmd_name, func=func, permission=permission,
only_to_me=only_to_me, privileged=privileged)
if shell_like:
async def shell_like_args_parser(session):
session.args['argv'] = shlex.split(session.current_arg)
cmd.args_parser_func = shell_like_args_parser
current_parent = _registry
for parent_key in cmd_name[:-1]:
current_parent[parent_key] = current_parent.get(parent_key) or {}
current_parent = current_parent[parent_key]
current_parent[cmd_name[-1]] = cmd
for alias in aliases:
_aliases[alias] = cmd_name
return CommandFunc(cmd, func)
return deco
class CommandGroup:
"""
Group a set of commands with same name prefix.
"""
__slots__ = ('basename', 'permission', 'only_to_me', 'privileged',
'shell_like')
def __init__(self, name: Union[str, CommandName_T],
permission: Optional[int] = None, *,
only_to_me: Optional[bool] = None,
privileged: Optional[bool] = None,
shell_like: Optional[bool] = None):
self.basename = (name,) if isinstance(name, str) else name
self.permission = permission
self.only_to_me = only_to_me
self.privileged = privileged
self.shell_like = shell_like
def command(self, name: Union[str, CommandName_T], *,
aliases: Optional[Iterable[str]] = None,
permission: Optional[int] = None,
only_to_me: Optional[bool] = None,
privileged: Optional[bool] = None,
shell_like: Optional[bool] = None) -> Callable:
sub_name = (name,) if isinstance(name, str) else name
name = self.basename + sub_name
kwargs = {}
if aliases is not None:
kwargs['aliases'] = aliases
if permission is not None:
kwargs['permission'] = permission
elif self.permission is not None:
kwargs['permission'] = self.permission
if only_to_me is not None:
kwargs['only_to_me'] = only_to_me
elif self.only_to_me is not None:
kwargs['only_to_me'] = self.only_to_me
if privileged is not None:
kwargs['privileged'] = privileged
elif self.privileged is not None:
kwargs['privileged'] = self.privileged
if shell_like is not None:
kwargs['shell_like'] = shell_like
elif self.shell_like is not None:
kwargs['shell_like'] = self.shell_like
return on_command(name, **kwargs)
def _find_command(name: Union[str, CommandName_T]) -> Optional[Command]:
cmd_name = (name,) if isinstance(name, str) else name
if not cmd_name:
return None
cmd_tree = _registry
for part in cmd_name[:-1]:
if part not in cmd_tree or not isinstance(cmd_tree[part], dict):
return None
cmd_tree = cmd_tree[part]
cmd = cmd_tree.get(cmd_name[-1])
return cmd if isinstance(cmd, Command) else None
class _FurtherInteractionNeeded(Exception):
"""
Raised by session.pause() indicating that the command should
enter interactive mode to ask the user for some arguments.
"""
pass
class _FinishException(Exception):
"""
Raised by session.finish() indicating that the command session
should be stopped and removed.
"""
def __init__(self, result: bool = True):
"""
:param result: succeeded to call the command
"""
self.result = result
class SwitchException(Exception):
"""
Raised by session.switch() indicating that the command session
should be stopped and replaced with a new one (going through
handle_message() again).
Since the new context message will go through handle_message()
again, the later function should be notified. So this exception
is designed to be propagated to handle_message().
"""
def __init__(self, new_ctx_message: Message):
"""
:param new_ctx_message: new message which should be placed in context
"""
self.new_ctx_message = new_ctx_message
class CommandSession(BaseSession):
__slots__ = ('cmd', 'current_key', 'current_arg', 'current_arg_text',
'current_arg_images', 'args', '_last_interaction', '_running')
def __init__(self, bot: NoneBot, ctx: Context_T, cmd: Command, *,
current_arg: str = '', args: Optional[CommandArgs_T] = None):
super().__init__(bot, ctx)
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 # last interaction time of this session
self._running = False
@property
def running(self) -> bool:
return self._running
@running.setter
def running(self, value) -> None:
if self._running is True and value is False:
# change status from running to not running, record the time
self._last_interaction = datetime.now()
self._running = value
@property
def is_valid(self) -> bool:
"""Check if the session is expired or not."""
if self.bot.config.SESSION_EXPIRE_TIMEOUT and \
self._last_interaction and \
datetime.now() - self._last_interaction > \
self.bot.config.SESSION_EXPIRE_TIMEOUT:
return False
return True
@property
def is_first_run(self) -> bool:
return self._last_interaction is None
@property
def argv(self) -> List[str]:
"""
Shell-like argument list.
Only available while shell_like is True in on_command decorator.
"""
return self.get_optional('argv', [])
def refresh(self, ctx: Context_T, *, current_arg: str = '') -> None:
"""
Refill the session with a new message context.
:param ctx: new message context
:param current_arg: new command argument as a string
"""
self.ctx = ctx
self.current_arg = current_arg
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]
def get(self, key: Any, *,
prompt: Optional[Message_T] = None, **kwargs) -> Any:
"""
Get an argument with a given key.
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
:return: the argument value
"""
value = self.get_optional(key)
if value is not None:
return value
self.current_key = key
# ask the user for more information
self.pause(prompt, **kwargs)
def get_optional(self, key: Any,
default: Optional[Any] = None) -> Optional[Any]:
"""Simply get a argument with given key."""
return self.args.get(key, default)
def pause(self, message: Optional[Message_T] = None, **kwargs) -> None:
"""Pause the session for further interaction."""
if message:
asyncio.ensure_future(self.send(message, **kwargs))
raise _FurtherInteractionNeeded
def finish(self, message: Optional[Message_T] = None, **kwargs) -> None:
"""Finish the session."""
if message:
asyncio.ensure_future(self.send(message, **kwargs))
raise _FinishException
def switch(self, new_ctx_message: Message_T) -> None:
"""
Finish the session and switch to a new (fake) message context.
The user may send another command (or another intention as natural
language) when interacting with the current session. In this case,
the session may not understand what the user is saying, so it
should call this method and pass in that message, then NoneBot will
handle the situation properly.
"""
if self.is_first_run:
# if calling this method during first run,
# we think the command is not handled
raise _FinishException(result=False)
if not isinstance(new_ctx_message, Message):
new_ctx_message = Message(new_ctx_message)
raise SwitchException(new_ctx_message)
def parse_command(bot: NoneBot,
cmd_string: str) -> Tuple[Optional[Command], Optional[str]]:
"""
Parse a command string (typically from a message).
:param bot: NoneBot instance
:param cmd_string: command string
:return: (Command object, current arg string)
"""
logger.debug(f'Parsing command: {cmd_string}')
matched_start = None
for start in bot.config.COMMAND_START:
# loop through COMMAND_START to find the longest matched start
curr_matched_start = None
if isinstance(start, type(re.compile(''))):
m = start.search(cmd_string)
if m and m.start(0) == 0:
curr_matched_start = m.group(0)
elif isinstance(start, str):
if cmd_string.startswith(start):
curr_matched_start = start
if curr_matched_start is not None and \
(matched_start is None or
len(curr_matched_start) > len(matched_start)):
# a longer start, use it
matched_start = curr_matched_start
if matched_start is None:
# it's not a command
logger.debug('It\'s not a command')
return None, None
logger.debug(f'Matched command start: '
f'{matched_start}{"(empty)" if not matched_start else ""}')
full_command = cmd_string[len(matched_start):].lstrip()
if not full_command:
# command is empty
return None, None
cmd_name_text, *cmd_remained = full_command.split(maxsplit=1)
cmd_name = _aliases.get(cmd_name_text)
if not cmd_name:
for sep in bot.config.COMMAND_SEP:
# loop through COMMAND_SEP to find the most optimized split
curr_cmd_name = None
if isinstance(sep, type(re.compile(''))):
curr_cmd_name = tuple(sep.split(cmd_name_text))
elif isinstance(sep, str):
curr_cmd_name = tuple(cmd_name_text.split(sep))
if curr_cmd_name is not None and \
(not cmd_name or len(curr_cmd_name) > len(cmd_name)):
# a more optimized split, use it
cmd_name = curr_cmd_name
if not cmd_name:
cmd_name = (cmd_name_text,)
logger.debug(f'Split command name: {cmd_name}')
cmd = _find_command(cmd_name)
if not cmd:
logger.debug(f'Command {cmd_name} not found')
return None, None
logger.debug(f'Command {cmd.name} found, function: {cmd.func}')
return cmd, ''.join(cmd_remained)
async def handle_command(bot: NoneBot, ctx: Context_T) -> bool:
"""
Handle a message as a command.
This function is typically called by "handle_message".
:param bot: NoneBot instance
:param ctx: message context
:return: the message is handled as a command
"""
cmd, current_arg = parse_command(bot, str(ctx['message']).lstrip())
is_privileged_cmd = cmd and cmd.privileged
if is_privileged_cmd and cmd.only_to_me and not ctx['to_me']:
is_privileged_cmd = False
disable_interaction = is_privileged_cmd
if is_privileged_cmd:
logger.debug(f'Command {cmd.name} is a privileged command')
ctx_id = context_id(ctx)
if not is_privileged_cmd:
# wait for 1.5 seconds (at most) if the current session is running
retry = 5
while retry > 0 and \
_sessions.get(ctx_id) and _sessions[ctx_id].running:
retry -= 1
await asyncio.sleep(0.3)
check_perm = True
session = _sessions.get(ctx_id) if not is_privileged_cmd else None
if session:
if session.running:
logger.warning(f'There is a session of command '
f'{session.cmd.name} running, notify the user')
asyncio.ensure_future(send(
bot, ctx,
render_expression(bot.config.SESSION_RUNNING_EXPRESSION)
))
# pretend we are successful, so that NLP won't handle it
return True
if session.is_valid:
logger.debug(f'Session of command {session.cmd.name} exists')
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
logger.debug(f'Session of command {session.cmd.name} is expired')
if ctx_id in _sessions:
del _sessions[ctx_id]
session = None
if not session:
if not cmd:
logger.debug('Not a known command, ignored')
return False
if cmd.only_to_me and not ctx['to_me']:
logger.debug('Not to me, ignored')
return False
session = CommandSession(bot, ctx, cmd, current_arg=current_arg)
logger.debug(f'New session of command {session.cmd.name} created')
return await _real_run_command(session, ctx_id, check_perm=check_perm,
disable_interaction=disable_interaction)
async def call_command(bot: NoneBot, ctx: Context_T,
name: Union[str, CommandName_T], *,
current_arg: str = '',
args: Optional[CommandArgs_T] = None,
check_perm: bool = True,
disable_interaction: bool = False) -> bool:
"""
Call a command internally.
This function is typically called by some other commands
or "handle_natural_language" when handling NLPResult object.
Note: If disable_interaction is not True, 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: NoneBot instance
:param ctx: message context
:param name: command name
:param current_arg: command current argument string
: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
"""
cmd = _find_command(name)
if not cmd:
return False
session = CommandSession(bot, ctx, cmd, current_arg=current_arg, args=args)
return await _real_run_command(session, context_id(session.ctx),
check_perm=check_perm,
disable_interaction=disable_interaction)
async def _real_run_command(session: CommandSession,
ctx_id: str,
disable_interaction: bool = False,
**kwargs) -> bool:
if not disable_interaction:
# override session only when interaction is not disabled
_sessions[ctx_id] = session
try:
logger.debug(f'Running command {session.cmd.name}')
session.running = True
future = asyncio.ensure_future(session.cmd.run(session, **kwargs))
timeout = None
if session.bot.config.SESSION_RUN_TIMEOUT:
timeout = session.bot.config.SESSION_RUN_TIMEOUT.total_seconds()
try:
await asyncio.wait_for(future, timeout)
handled = future.result()
except asyncio.TimeoutError:
handled = True
except (_FurtherInteractionNeeded,
_FinishException,
SwitchException) as e:
raise e
except Exception as e:
logger.error(f'An exception occurred while '
f'running command {session.cmd.name}:')
logger.exception(e)
handled = True
raise _FinishException(handled)
except _FurtherInteractionNeeded:
session.running = False
if disable_interaction:
# if the command needs further interaction, we view it as failed
return False
logger.debug(f'Further interaction needed for '
f'command {session.cmd.name}')
# return True because this step of the session is successful
return True
except (_FinishException, SwitchException) as e:
session.running = False
logger.debug(f'Session of command {session.cmd.name} finished')
if not disable_interaction and ctx_id in _sessions:
# the command is finished, remove the session,
# but if interaction is disabled during this command call,
# we leave the _sessions untouched.
del _sessions[ctx_id]
if isinstance(e, _FinishException):
return e.result
elif isinstance(e, SwitchException):
# we are guaranteed that the session is not first run here,
# which means interaction is definitely enabled,
# so we can safely touch _sessions here.
if ctx_id in _sessions:
# make sure there is no session waiting
del _sessions[ctx_id]
logger.debug(f'Session of command {session.cmd.name} switching, '
f'new context message: {e.new_ctx_message}')
raise e # this is intended to be propagated to handle_message()
def kill_current_session(bot: NoneBot, ctx: Context_T) -> None:
"""
Force kill current session of the given context,
despite whether it is running or not.
:param bot: NoneBot instance
:param ctx: message context
"""
ctx_id = context_id(ctx)
if ctx_id in _sessions:
del _sessions[ctx_id]

40
nonebot/default_config.py Normal file
View File

@ -0,0 +1,40 @@
"""
Default configurations.
Any derived configurations must import everything from this module
at the very beginning of their code, and then set their own value
to override the default one.
For example:
>>> from nonebot.default_config import *
>>> PORT = 9090
>>> DEBUG = False
>>> SUPERUSERS.add(123456)
>>> NICKNAME = '小明'
"""
from datetime import timedelta
from typing import Container, Union, Iterable, Pattern, Optional, Dict, Any
from .typing import Expression_T
API_ROOT: str = ''
ACCESS_TOKEN: str = ''
SECRET: str = ''
HOST: str = '127.0.0.1'
PORT: int = 8080
DEBUG: bool = True
SUPERUSERS: Container[int] = set()
NICKNAME: Union[str, Iterable[str]] = ''
COMMAND_START: Iterable[Union[str, Pattern]] = {'/', '!', '', ''}
COMMAND_SEP: Iterable[Union[str, Pattern]] = {'/', '.'}
SESSION_EXPIRE_TIMEOUT: Optional[timedelta] = timedelta(minutes=5)
SESSION_RUN_TIMEOUT: Optional[timedelta] = None
SESSION_RUNNING_EXPRESSION: Expression_T = '您有命令正在执行,请稍后再试'
SHORT_MESSAGE_MAX_LENGTH: int = 50
APSCHEDULER_CONFIG: Dict[str, Any] = {
'apscheduler.timezone': 'Asia/Shanghai'
}

1
nonebot/exceptions.py Normal file
View File

@ -0,0 +1 @@
from aiocqhttp import Error as CQHttpError

81
nonebot/helpers.py Normal file
View File

@ -0,0 +1,81 @@
import hashlib
import random
from typing import Sequence, Callable
from . import NoneBot
from .exceptions import CQHttpError
from .message import escape
from .typing import Context_T, Message_T, Expression_T
def context_id(ctx: Context_T, *,
mode: str = 'default', use_hash: bool = False) -> str:
"""
Calculate a unique id representing the current context.
mode:
default: one id for one context
group: one id for one group or discuss
user: one id for one user
:param ctx: the context dict
:param mode: unique id mode: "default", "group", or "user"
:param use_hash: use md5 to hash the id or not
"""
ctx_id = ''
if mode == 'default':
if ctx.get('group_id'):
ctx_id += f'/group/{ctx["group_id"]}'
elif ctx.get('discuss_id'):
ctx_id += f'/discuss/{ctx["discuss_id"]}'
if ctx.get('user_id'):
ctx_id += f'/user/{ctx["user_id"]}'
elif mode == 'group':
if ctx.get('group_id'):
ctx_id += f'/group/{ctx["group_id"]}'
elif ctx.get('discuss_id'):
ctx_id += f'/discuss/{ctx["discuss_id"]}'
elif mode == 'user':
if ctx.get('user_id'):
ctx_id += f'/user/{ctx["user_id"]}'
if ctx_id and use_hash:
ctx_id = hashlib.md5(ctx_id.encode('ascii')).hexdigest()
return ctx_id
async def send(bot: NoneBot, ctx: Context_T,
message: Message_T, *,
ensure_private: bool = False,
ignore_failure: bool = True,
**kwargs) -> None:
"""Send a message ignoring failure by default."""
try:
if ensure_private:
ctx = ctx.copy()
ctx['message_type'] = 'private'
await bot.send(ctx, message, **kwargs)
except CQHttpError:
if not ignore_failure:
raise
def render_expression(expr: Expression_T, *,
escape_args: bool = 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(**kwargs)
elif isinstance(expr, Sequence) and not isinstance(expr, str):
expr = random.choice(expr)
if escape_args:
for k, v in kwargs.items():
if isinstance(v, str):
kwargs[k] = escape(v)
return expr.format(**kwargs)

16
nonebot/log.py Normal file
View File

@ -0,0 +1,16 @@
"""
Provide logger object.
Any other modules in "nonebot" should use "logger" from this module
to log messages.
"""
import logging
import sys
logger = logging.getLogger('nonebot')
default_handler = logging.StreamHandler(sys.stdout)
default_handler.setFormatter(logging.Formatter(
'[%(asctime)s %(name)s] %(levelname)s: %(message)s'
))
logger.addHandler(default_handler)

68
nonebot/message.py Normal file
View File

@ -0,0 +1,68 @@
import asyncio
from typing import Callable
from aiocqhttp.message import *
from . import NoneBot
from .command import handle_command, SwitchException
from .log import logger
from .natural_language import handle_natural_language
from .typing import Context_T
_message_preprocessors = set()
def message_preprocessor(func: Callable) -> Callable:
_message_preprocessors.add(func)
return func
async def handle_message(bot: NoneBot, ctx: Context_T) -> None:
_log_message(ctx)
coros = []
for processor in _message_preprocessors:
coros.append(processor(bot, ctx))
if coros:
await asyncio.wait(coros)
if 'to_me' not in ctx:
if ctx['message_type'] != 'private':
# group or discuss
ctx['to_me'] = False
first_message_seg = ctx['message'][0]
if first_message_seg == MessageSegment.at(ctx['self_id']):
ctx['to_me'] = True
del ctx['message'][0]
if not ctx['message']:
ctx['message'].append(MessageSegment.text(''))
else:
ctx['to_me'] = True
while True:
try:
handled = await handle_command(bot, ctx)
break
except SwitchException as e:
# we are sure that there is no session existing now
ctx['message'] = e.new_ctx_message
ctx['to_me'] = True
if handled:
logger.info(f'Message {ctx["message_id"]} is handled as a command')
return
handled = await handle_natural_language(bot, ctx)
if handled:
logger.info(f'Message {ctx["message_id"]} is handled '
f'as natural language')
return
def _log_message(ctx: Context_T) -> None:
msg_from = f'{ctx["user_id"]}'
if ctx['message_type'] == 'group':
msg_from += f'@[群:{ctx["group_id"]}]'
elif ctx['message_type'] == 'discuss':
msg_from += f'@[讨论组:{ctx["discuss_id"]}]'
logger.info(f'Message {ctx["message_id"]} from {msg_from}: '
f'{ctx["message"]}')

158
nonebot/natural_language.py Normal file
View File

@ -0,0 +1,158 @@
import asyncio
import re
from typing import Iterable, Optional, Callable, Union, NamedTuple
from . import NoneBot, permission as perm
from .command import call_command
from .log import logger
from .message import Message
from .session import BaseSession
from .typing import Context_T, CommandName_T, CommandArgs_T
_nl_processors = set()
class NLProcessor:
__slots__ = ('func', 'keywords', 'permission',
'only_to_me', 'only_short_message',
'allow_empty_message')
def __init__(self, *, func: Callable, keywords: Optional[Iterable],
permission: int, only_to_me: bool, only_short_message: bool,
allow_empty_message: bool):
self.func = func
self.keywords = keywords
self.permission = permission
self.only_to_me = only_to_me
self.only_short_message = only_short_message
self.allow_empty_message = allow_empty_message
def on_natural_language(keywords: Union[Optional[Iterable], Callable] = None,
*, permission: int = perm.EVERYBODY,
only_to_me: bool = True,
only_short_message: bool = True,
allow_empty_message: bool = False) -> Callable:
"""
Decorator to register a function as a natural language processor.
:param keywords: keywords to respond to, if None, respond to all messages
:param permission: permission required by the processor
:param only_to_me: only handle messages to me
:param only_short_message: only handle short messages
:param allow_empty_message: handle empty messages
"""
def deco(func: Callable) -> Callable:
nl_processor = NLProcessor(func=func, keywords=keywords,
permission=permission,
only_to_me=only_to_me,
only_short_message=only_short_message,
allow_empty_message=allow_empty_message)
_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: NoneBot, ctx: Context_T, 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]
class NLPResult(NamedTuple):
confidence: float
cmd_name: Union[str, CommandName_T]
cmd_args: Optional[CommandArgs_T] = None
async def handle_natural_language(bot: NoneBot, ctx: Context_T) -> bool:
"""
Handle a message as natural language.
This function is typically called by "handle_message".
:param bot: NoneBot 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 me with my nickname
if isinstance(bot.config.NICKNAME, str) or \
not isinstance(bot.config.NICKNAME, Iterable):
nicknames = (bot.config.NICKNAME,)
else:
nicknames = filter(lambda n: n, bot.config.NICKNAME)
nickname_regex = '|'.join(nicknames)
m = re.search(rf'^({nickname_regex})([\s,]|$)', msg, re.IGNORECASE)
if m:
nickname = m.group(1)
logger.debug(f'User is calling me {nickname}')
ctx['to_me'] = True
msg = msg[m.end():]
session = NLPSession(bot, ctx, msg)
# use msg_text here because CQ code "share" may be very long,
# at the same time some plugins may want to handle it
msg_text_length = len(session.msg_text)
futures = []
for p in _nl_processors:
if not p.allow_empty_message and not session.msg:
# don't allow empty msg, but it is one, so skip to next
continue
if p.only_short_message and \
msg_text_length > bot.config.SHORT_MESSAGE_MAX_LENGTH:
continue
if p.only_to_me and not ctx['to_me']:
continue
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:
futures.append(asyncio.ensure_future(p.func(session)))
if futures:
# wait for possible results, and sort them by confidence
results = []
for fut in futures:
try:
results.append(await fut)
except Exception as e:
logger.error('An exception occurred while running '
'some natural language processor:')
logger.exception(e)
results = sorted(filter(lambda r: r, results),
key=lambda r: r.confidence, reverse=True)
logger.debug(f'NLP results: {results}')
if results and results[0].confidence >= 60.0:
# choose the result with highest confidence
logger.debug(f'NLP result with highest confidence: {results[0]}')
return await call_command(bot, ctx, results[0].cmd_name,
args=results[0].cmd_args,
check_perm=False)
else:
logger.debug('No NLP result having enough confidence')
return False

109
nonebot/notice_request.py Normal file
View File

@ -0,0 +1,109 @@
from typing import Optional, Callable, Union
from aiocqhttp.bus import EventBus
from . import NoneBot
from .exceptions import CQHttpError
from .log import logger
from .session import BaseSession
from .typing import Context_T
_bus = EventBus()
def _make_event_deco(post_type: str) -> Callable:
def deco_deco(arg: Optional[Union[str, Callable]] = None,
*events: str) -> Callable:
def deco(func: Callable) -> Callable:
if isinstance(arg, str):
for e in [arg] + list(events):
_bus.subscribe(f'{post_type}.{e}', func)
else:
_bus.subscribe(post_type, func)
return func
if isinstance(arg, Callable):
return deco(arg)
return deco
return deco_deco
on_notice = _make_event_deco('notice')
on_request = _make_event_deco('request')
class NoticeSession(BaseSession):
__slots__ = ()
def __init__(self, bot: NoneBot, ctx: Context_T):
super().__init__(bot, ctx)
class RequestSession(BaseSession):
__slots__ = ()
def __init__(self, bot: NoneBot, ctx: Context_T):
super().__init__(bot, ctx)
async def approve(self, remark: str = '') -> None:
"""
Approve the request.
:param remark: remark of friend (only works in friend request)
"""
try:
await self.bot.call_action(
action='.handle_quick_operation_async',
self_id=self.ctx.get('self_id'),
context=self.ctx,
operation={'approve': True, 'remark': remark}
)
except CQHttpError:
pass
async def reject(self, reason: str = '') -> None:
"""
Reject the request.
:param reason: reason to reject (only works in group request)
"""
try:
await self.bot.call_action(
action='.handle_quick_operation_async',
self_id=self.ctx.get('self_id'),
context=self.ctx,
operation={'approve': False, 'reason': reason}
)
except CQHttpError:
pass
async def handle_notice_or_request(bot: NoneBot, ctx: Context_T) -> None:
post_type = ctx['post_type'] # "notice" or "request"
detail_type = ctx[f'{post_type}_type']
event = f'{post_type}.{detail_type}'
if ctx.get('sub_type'):
event += f'.{ctx["sub_type"]}'
if post_type == 'notice':
_log_notice(ctx)
session = NoticeSession(bot, ctx)
else: # must be 'request'
_log_request(ctx)
session = RequestSession(bot, ctx)
logger.debug(f'Emitting event: {event}')
try:
await _bus.emit(event, session)
except Exception as e:
logger.error(f'An exception occurred while handling event {event}:')
logger.exception(e)
def _log_notice(ctx: Context_T) -> None:
logger.info(f'Notice: {ctx}')
def _log_request(ctx: Context_T) -> None:
logger.info(f'Request: {ctx}')

103
nonebot/permission.py Normal file
View File

@ -0,0 +1,103 @@
from collections import namedtuple
from aiocache import cached
from . import NoneBot
from .exceptions import CQHttpError
from .typing import Context_T
PRIVATE_FRIEND = 0x0001
PRIVATE_GROUP = 0x0002
PRIVATE_DISCUSS = 0x0004
PRIVATE_OTHER = 0x0008
PRIVATE = 0x000F
DISCUSS = 0x00F0
GROUP_MEMBER = 0x0100
GROUP_ADMIN = 0x0200
GROUP_OWNER = 0x0400
GROUP = 0x0F00
SUPERUSER = 0xF000
EVERYBODY = 0xFFFF
IS_NOBODY = 0x0000
IS_PRIVATE_FRIEND = PRIVATE_FRIEND
IS_PRIVATE_GROUP = PRIVATE_GROUP
IS_PRIVATE_DISCUSS = PRIVATE_DISCUSS
IS_PRIVATE_OTHER = PRIVATE_OTHER
IS_PRIVATE = PRIVATE
IS_DISCUSS = DISCUSS
IS_GROUP_MEMBER = GROUP_MEMBER
IS_GROUP_ADMIN = GROUP_MEMBER | GROUP_ADMIN
IS_GROUP_OWNER = GROUP_ADMIN | GROUP_OWNER
IS_GROUP = GROUP
IS_SUPERUSER = 0xFFFF
_min_context_fields = (
'self_id',
'message_type',
'sub_type',
'user_id',
'discuss_id',
'group_id',
'anonymous',
)
_MinContext = namedtuple('MinContext', _min_context_fields)
async def check_permission(bot: NoneBot, ctx: Context_T,
permission_required: int) -> bool:
"""
Check if the context has the permission required.
:param bot: NoneBot 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:
min_ctx_kwargs[field] = ctx[field]
else:
min_ctx_kwargs[field] = None
min_ctx = _MinContext(**min_ctx_kwargs)
return await _check(bot, min_ctx, permission_required)
@cached(ttl=2 * 60) # cache the result for 2 minute
async def _check(bot: NoneBot, min_ctx: _MinContext,
permission_required: int) -> bool:
permission = 0
if min_ctx.user_id in bot.config.SUPERUSERS:
permission |= IS_SUPERUSER
if min_ctx.message_type == 'private':
if min_ctx.sub_type == 'friend':
permission |= IS_PRIVATE_FRIEND
elif min_ctx.sub_type == 'group':
permission |= IS_PRIVATE_GROUP
elif min_ctx.sub_type == 'discuss':
permission |= IS_PRIVATE_DISCUSS
elif min_ctx.sub_type == 'other':
permission |= IS_PRIVATE_OTHER
elif min_ctx.message_type == 'group':
permission |= IS_GROUP_MEMBER
if not min_ctx.anonymous:
try:
member_info = await bot.get_group_member_info(
self_id=min_ctx.self_id,
group_id=min_ctx.group_id,
user_id=min_ctx.user_id,
no_cache=True
)
if member_info:
if member_info['role'] == 'owner':
permission |= IS_GROUP_OWNER
elif member_info['role'] == 'admin':
permission |= IS_GROUP_ADMIN
except CQHttpError:
pass
elif min_ctx.message_type == 'discuss':
permission |= IS_DISCUSS
return bool(permission & permission_required)

View File

13
nonebot/plugins/base.py Normal file
View File

@ -0,0 +1,13 @@
from nonebot import on_command, CommandSession, permission as perm
from nonebot.message import unescape
@on_command('echo')
async def echo(session: CommandSession):
await session.send(session.get_optional('message') or session.current_arg)
@on_command('say', permission=perm.SUPERUSER)
async def say(session: CommandSession):
await session.send(
unescape(session.get_optional('message') or session.current_arg))

11
nonebot/sched.py Normal file
View File

@ -0,0 +1,11 @@
try:
from apscheduler.schedulers.asyncio import AsyncIOScheduler
except ImportError:
# APScheduler is not installed
AsyncIOScheduler = None
if AsyncIOScheduler:
class Scheduler(AsyncIOScheduler):
pass
else:
Scheduler = None

30
nonebot/session.py Normal file
View File

@ -0,0 +1,30 @@
from . import NoneBot
from .helpers import send
from .typing import Context_T, Message_T
class BaseSession:
__slots__ = ('bot', 'ctx')
def __init__(self, bot: NoneBot, ctx: Context_T):
self.bot = bot
self.ctx = ctx
async def send(self, message: Message_T, *,
at_sender: bool = False,
ensure_private: bool = False,
ignore_failure: bool = True,
**kwargs) -> None:
"""
Send a message ignoring failure by default.
:param message: message to send
:param at_sender: @ the sender if in group or discuss chat
:param ensure_private: ensure the message is sent to private chat
:param ignore_failure: if any CQHttpError raised, ignore it
:return: the result returned by CQHTTP
"""
return await send(self.bot, self.ctx, message,
at_sender=at_sender,
ensure_private=ensure_private,
ignore_failure=ignore_failure, **kwargs)

7
nonebot/typing.py Normal file
View File

@ -0,0 +1,7 @@
from typing import Union, List, Dict, Any, Sequence, Callable, Tuple
Context_T = Dict[str, Any]
Message_T = Union[str, Dict[str, Any], List[Dict[str, Any]]]
Expression_T = Union[str, Sequence[str], Callable]
CommandName_T = Tuple[str, ...]
CommandArgs_T = Dict[str, Any]