前言

nonebot 是一个 QQ 消息机器人框架,它的一些实现机制,值得参考。

NoneBot

初始化(配置加载)

阅读 nonebot 文档,第一个示例如下:

import nonebot

if __name__ == '__main__':
nonebot.init()
nonebot.load_builtin_plugins()
nonebot.run(host='127.0.0.1', port=8080)

首先思考一下,要运行几个 QQ 机器人,肯定是要保存一些动态的数据的。但是从上面的示例看,我们并没有创建什么对象来保存动态数据,很简单的就直接调用 nontbot.run() 了。这说明动态的数据被隐藏在了 nonebot 内部。

接下来详细分析这几行代码:

第一步是 nonebot.init(),该方法源码如下:

#  这个全局变量用于保存 NoneBot 对象
_bot: Optional[NoneBot] = None def init(config_object: Optional[Any] = None) -> None:
global _bot
_bot = NoneBot(config_object) # 通过传入的配置对象,构造 NoneBot 实例。 if _bot.config.DEBUG: # 根据是否 debug 模式,来配置日志级别
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO) # 在 websocket 启动前,先启动 scheduler(通过调用 quart 的 before_serving 装饰器)
# 这实际上是将 _start_scheduler 包装成一个 coroutine,然后丢到 quart 的 before_serving_funcs 队列中去。
_bot.server_app.before_serving(_start_scheduler) def _start_scheduler():
if scheduler and not scheduler.running: # 这个 scheduler 是使用的 apscheduler.schedulers.asyncio.AsyncIOScheduler
scheduler.configure(_bot.config.APSCHEDULER_CONFIG) # 配置 scheduler 参数,该参数可通过 `nonebot.init()` 配置
scheduler.start() # 启动 scheduler,用于定时任务(如定时发送消息、每隔一定时间执行某任务)
logger.info('Scheduler started')

可以看到,nonebot.init() 做了三件事:

  1. 通过传入的配置对象,构造 NoneBot 实例。该实例对用户不可见
  2. 配置日志级别
  3. 让 quart 在服务启动前,先启动 AsyncIOScheduler
    • AsyncIOScheduler 是一个异步 scheduler,这意味着它本身也会由 asyncio 的 eventloop 调度。它和 quart 是并发执行的。

1. plugins 加载机制

第二步是 nonebot.load_builtin_plugins(),直接加载了 nonebot 内置的插件。该函数来自 plugin.py

class Plugin:
__slots__ = ('module', 'name', 'usage') def __init__(self, module: Any,
name: Optional[str] = None,
usage: Optional[Any] = None):
self.module = module # 插件对象本身
self.name = name # 插件名称
self.usage = usage # 插件的 help 字符串 # 和 `_bot` 类似的设计,用全局变量保存状态
_plugins: Set[Plugin] = set() def load_plugin(module_name: str) -> bool:
try:
module = importlib.import_module(module_name) # 通过模块名,动态 import 该模块
name = getattr(module, '__plugin_name__', None)
usage = getattr(module, '__plugin_usage__', None) # 模块的全局变量
_plugins.add(Plugin(module, name, usage)) # 将加载好的模块放入 _plugins
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:
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:
plugin_dir = os.path.join(os.path.dirname(__file__), 'plugins') # 得到内部 plugins 目录的路径
return load_plugins(plugin_dir, 'nonebot.plugins') # 直接加载该目录下的所有插件 def get_loaded_plugins() -> Set[Plugin]:
"""
获取所有加载好的插件,一般用于提供命令帮助。
比如在收到 "帮助 拆字" 时,就从这里查询到 “拆字” 插件的 usage,返回给用户。 :return: a set of Plugin objects
"""
return _plugins

这就是插件的动态加载机制,可以看到获取已加载插件的唯一方法,就是 get_loaded_plugins(),而且 plugins 是用集合来保存的。

  1. 优化:仔细想想,我觉得用字典(Dict)来代替 Set 会更好一些,用“插件名”索引,这样可以防止出现同名的插件,而且查询插件时也不需要遍历整个 Set。

  2. 思考:插件是 python 模块,但是这里加载好了,却没有手动将它注册到别的地方,那加载它还有什么用?

    • 插件中的“命令解析器”、“消息处理器”等,都是使用的是 nonebot 的装饰器装饰了的。
    • 该装饰器会直接将命令处理函数,连同命令解析参数等直接注册到 nonebot 的命令集合中。(这个后面会看到。)因此不需要在 load_plugin() 中手动注册。

这两行之后,就直接 nonebot.run() 启动 quart 服务器了。

QQ消息的处理

从第一个例子中,只能看到上面这些。接下来考虑写一个自定义插件,看看 nonebot 的消息处理机制。项目结构如下:

awesome-bot
├── awesome
│ └── plugins
│ └── usage.py
├── bot.py
└── config.py # 配置文件,写法参考 nonebot.default_config,建议使用类方式保存配置

bot.py:

from os import path

import nonebot
import config if __name__ == '__main__':
nonebot.init(config) # 使用自定义配置
nonebot.load_plugins( # 加载 awesome/plugins 下的自定义插件
path.join(path.dirname(__file__), 'awesome', 'plugins'),
'awesome.plugins'
)
nonebot.run()

usage.py:

import nonebot
from nonebot import on_command, CommandSession @on_command('usage', aliases=['使用帮助', '帮助', '使用方法'])
async def _(session: CommandSession):
"""之前说过的“帮助”命令"""
plugins = list(filter(lambda p: p.name, nonebot.get_loaded_plugins()))
arg = session.current_arg_text.strip().lower()
if not arg:
session.finish(
'我现在支持的功能有:\n\n' + '\n'.join(p.name for p in plugins))
for p in plugins: # 如果 plugins 换成 dict 类型,就不需要循环遍历了
if p.name.lower() == arg:
await session.send(p.usage)

查看装饰器 on_command 的内容,有 command/__init__.py


# key: one segment of command name
# value: subtree or a leaf Command object
_registry = {} # type: Dict[str, Union[Dict, Command]] # 保存命令与命令处理器 # key: alias
# value: real command name
_aliases = {} # type: Dict[str, CommandName_T] # 保存命令的别名(利用别名,从这里查找真正的命令名称,再用该名称查找命令处理器) # key: context id
# value: CommandSession object
_sessions = {} # type: Dict[str, CommandSession] # 保存与用户的会话,这样就能支持一些需要关联上下文的命令。比如赛文续传,或者需要花一定时间执行的命令,Session 有个 is_running。 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:
"""
用于注册命令处理器 :param name: 命令名称 (e.g. 'echo' or ('random', 'number'))
:param aliases: 命令别名,建议用元组
:param permission: 该命令的默认权限
:param only_to_me: 是否只处理发送给“我”的消息
:param privileged: 已经存在此 session 时,是否仍然能被运行
:param shell_like: 使用类似 shell 的语法传递参数
""" def deco(func: CommandHandler_T) -> CommandHandler_T:
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]: # 循环将命令树添加到 _registry
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

该装饰器将命令处理器注册到模块的全局变量中,然后 quart 在收到消息时,会调用该模块的如下方法,查找对应的命令处理器,并使用它处理该命令:

async def handle_command(bot: NoneBot, ctx: Context_T) -> bool:
"""
尝试将消息解析为命令,如果解析成功,而且用户拥有权限,就执行该命令。否则忽略。 此函数会被 "handle_message" 调用
"""
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')
# since it's in a session, the user must be talking to me
ctx['to_me'] = True
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) # 构造命令 Session,某些上下文相关的命令需要用到。
logger.debug(f'New session of command {session.cmd.name} created') return await _real_run_command(session, ctx_id, check_perm=check_perm, # 这个函数将命令处理函数包装成 task,然后等待该 task 完成,再返回结果。
disable_interaction=disable_interaction)

Web 中的 Session 一般是用于保存登录状态,而聊天程序的 session,则主要是保存上下文。

如果要做赛文续传与成绩统计,Session 和 Command 肯定是需要的,但是不能像 nonebot 这样做。

NoneBot 的命令格式限制得比较严,没法用来解析跟打器自动发送的成绩消息。也许命令应该更宽松:

  1. 命令前缀仍然通过全局配置来做,但是用 dict 来存,给每个前缀一个名字,默认使用 default。

    • @command 应该给一个参数用于指定前缀:None 为不需要前缀,默认为 config.Prefix.DEFAULT.
  2. 添加一个正则消息匹配的命令注册器,要匹配多个正则,则多次使用该装饰器。正则匹配到的 groupdict 会被传到命令处理器中。

其他

还有就是 NoneBot 作者提到的一些问题:

  1. 基于 python-aiocqhttp(跟 酷Q 强耦合),无法支持其它机器人平台:我写 xhup-bot 时,也需要把这一点考虑进去。机器人核心不应该依赖任何平台相关的东西。
  2. 过于以命令为核心:这也是我体会到的。这导致很多功能无法使用 nonebot 实现。只能借助底层的 on_message。
  3. 没有全局黑名单机制,无法简单地屏蔽其它 bot 的消息。全局黑名单感觉还算比较容易做。
  4. 权限控制功能不够强大,无法进行单用户和群组粒度的控制:我这边也有考虑这个。
    • 细粒度权限控制的话,可以将 on_command 的 permission 当成该命令的默认权限。然后可以在 config 里针对不同的群/用户,添加不同的权限。
    • 但是这可能会导致配置变复杂。最好还是通过后端提供的 Web 网页来配置。每个群管理都可以自己配置自己群的一些权限。然后 bot 在启动时通过 http 从后端获取配置信息。
    • 会话只针对单用户,无法简单地实现多用户游戏功能:这个我暂时不需要。。而且我的 xhup-bot 是有后端的,我觉得这个可以放到后端做。

本文为个人杂谈,不保证正确。如有错误,还请指正。

nonebot 源码阅读笔记的更多相关文章

  1. CI框架源码阅读笔记5 基准测试 BenchMark.php

    上一篇博客(CI框架源码阅读笔记4 引导文件CodeIgniter.php)中,我们已经看到:CI中核心流程的核心功能都是由不同的组件来完成的.这些组件类似于一个一个单独的模块,不同的模块完成不同的功 ...

  2. CI框架源码阅读笔记4 引导文件CodeIgniter.php

    到了这里,终于进入CI框架的核心了.既然是“引导”文件,那么就是对用户的请求.参数等做相应的导向,让用户请求和数据流按照正确的线路各就各位.例如,用户的请求url: http://you.host.c ...

  3. CI框架源码阅读笔记3 全局函数Common.php

    从本篇开始,将深入CI框架的内部,一步步去探索这个框架的实现.结构和设计. Common.php文件定义了一系列的全局函数(一般来说,全局函数具有最高的加载优先权,因此大多数的框架中BootStrap ...

  4. CI框架源码阅读笔记2 一切的入口 index.php

    上一节(CI框架源码阅读笔记1 - 环境准备.基本术语和框架流程)中,我们提到了CI框架的基本流程,这里再次贴出流程图,以备参考: 作为CI框架的入口文件,源码阅读,自然由此开始.在源码阅读的过程中, ...

  5. 源码阅读笔记 - 1 MSVC2015中的std::sort

    大约寒假开始的时候我就已经把std::sort的源码阅读完毕并理解其中的做法了,到了寒假结尾,姑且把它写出来 这是我的第一篇源码阅读笔记,以后会发更多的,包括算法和库实现,源码会按照我自己的代码风格格 ...

  6. Three.js源码阅读笔记-5

    Core::Ray 该类用来表示空间中的“射线”,主要用来进行碰撞检测. THREE.Ray = function ( origin, direction ) { this.origin = ( or ...

  7. PHP源码阅读笔记一(explode和implode函数分析)

    PHP源码阅读笔记一一.explode和implode函数array explode ( string separator, string string [, int limit] )此函数返回由字符 ...

  8. AQS源码阅读笔记(一)

    AQS源码阅读笔记 先看下这个类张非常重要的一个静态内部类Node.如下: static final class Node { //表示当前节点以共享模式等待锁 static final Node S ...

  9. libevent源码阅读笔记(一):libevent对epoll的封装

    title: libevent源码阅读笔记(一):libevent对epoll的封装 最近开始阅读网络库libevent的源码,阅读源码之前,大致看了张亮写的几篇博文(libevent源码深度剖析 h ...

随机推荐

  1. Hands-On Modeler (建模人员参与程序开发)

    如果编写代码的人员认为自己没必要对模型负责,或者不知道让模型为应用程序服务,那么这个模型就和程序没有任何关联.如果开发人员没有意识到改变代码就意味着改变模型,那么他们对程序的重构不但不会增强模型的作用 ...

  2. Java中Lambda表达式的简单使用

    Lambda表达式是Java SE 8中一个重要的新特性.你可以把 Lambda表达式 理解为是一段可以传递的代码 (将代码像数据一样进行传递).可以写出更简洁.更灵活的代码.作为一种更紧凑的代码风格 ...

  3. (搬运以学习)flask 上下文的实现

    引言 本文主要梳理了flask的current_app, request, session, g的实现原理 源码说明 本文使用flask 0.5 版本 application context 和req ...

  4. Java集合类——Set、List、Map、Queue接口

    目录 Java 集合类的基本概念 Java 集合类的层次关系 Java 集合类的应用场景 一. Java集合类的基本概念 在编程中,常需要集中存放多个数据,数组是一个很好的选择,但数组的长度需提前指定 ...

  5. This system is registered to Red Hat Subscription Management, but is not receiving updates. You can use subscription-manager to assign subscriptions.

    Wrong date and time, reset the date and time in the system properly. It may also happen that system ...

  6. pyqt 多窗口跳转

    今天在做pyqt5的多页面跳转时遇到问题,一点击button按钮,程序会崩溃.在网上查了下,应该是当窗口A调用窗口B的时候,两个窗口不能是同一类型.我写的时候把A.B同时写成了QWidget.把窗口B ...

  7. Spark-源码-SparkContext的初始化

    Spark版本 1.3SparkContext初始化流程 1.0 在我们的主类 main() 方法中经常会这么写 val conf = new SparkConf().setAppName(" ...

  8. 基于OMAPL:Linux3.3内核的编译

    基于OMAPL:Linux3.3内核的编译 OMAPL对应3个版本的linux源代码,分别是:Linux-3.3.Linux-2.6.37.Linux2.6.33,这里的差距在于Linux2,缺少SY ...

  9. 解决MySQL server has gone away问题的两种有效办法

    最近做网站有一个站要用到WEB网页采集器功能,当一个PHP脚本在请求URL的时候,可能这个被请求的网页非常慢慢,超过了mysql的 wait-timeout时间,然后当网页内容被抓回来后,准备插入到M ...

  10. JAVA 反射之Method

    ★ Method没有构造器,只能通过Class获取. 重点方法: class.getDeclaredMethods():获取所有方法. class.getDeclaredMethod(String n ...