简介:这是一份面向Python开发者与Bot入门学习者的多用途QQ群机器人实战项目,基于NoneBot2框架实现群管理、自动回复、游戏互动等常见功能,适用于社交平台智能应用开发与自动化运维场景。资源包共213个文件,含141个核心Python源码(插件逻辑与事件处理)、34份Markdown文档(含部署指南、API说明与开发规范)、20份License协议文件,辅以JPG/PNG示例图、JSON配置及Shell脚本等,完整覆盖开发、调试与部署全流程,压缩包仅3.04MB,轻量易上手。已有633人学习下载,资源结构清晰,主目录mokabot2-master包含可直接运行的工程骨架、典型功能插件示例(如公告、档案、二次元互动等)及配套配置文件,附带.gitignore与CI相关yml文件,体现良好工程实践。读者可快速掌握NoneBot2插件开发范式、异步消息处理机制及QQ Bot Token接入流程,并复用模块化代码拓展自定义功能。
1. 为什么一个“多用途QQ群机器人”必须用 NoneBot2 而不是自己轮子重写?
你刚在群里看到一个能自动查天气、转发 RSS、抽签、统计发言热词、甚至对接内部 Jenkins 的 QQ 群机器人,点开介绍页发现它只依赖nonebot2和几个 Python 包——没有 Web 框架胶水层、没手写长连接心跳、没硬编码消息解析逻辑。这不是巧合。NoneBot2 是目前唯一把「QQ 协议适配」和「插件化业务逻辑」彻底解耦的 Python 框架:它不绑定具体协议(支持 go-cqhttp、onebot v12、KOOK),不强制 MVC 结构,但通过事件驱动 + 依赖注入 + 插件生命周期管理,让「加一个查汇率功能」变成pip install nonebot-plugin-exchange+ 两行配置。对运维人员,它提供nb run一键启停和nb deploy容器化打包;对开发者,它用 Pydantic 模型校验每条入参,用matcher.pause()实现多轮对话状态机,用Depends()注入数据库连接或缓存客户端。如果你正被「每次加新功能都要改 main.py、消息解析总出 UnicodeDecodeError、群聊私聊逻辑混在一起」折磨,这个标题不是教你搭玩具,而是给出一套可随业务增长横向扩展的群机器人生产级架构。
2. 从零初始化一个可热重载、带基础命令的 NoneBot2 项目
2.1 初始化项目结构与核心依赖安装
NoneBot2 不是单个包,而是一套分层生态:nonebot2是核心运行时,nonebot-adapter-onebot提供 QQ 协议适配,nonebot-plugin-apscheduler支持定时任务。我们跳过pip install nonebot2这种易出错的手动安装,直接用官方推荐的nb-cli工具链:
# 全局安装 nb-cli(需 Python 3.8+) pip install nb-cli # 创建项目(自动选择 onebot v11 适配器、生成标准目录结构) nb create my-qq-bot --adapter onebot-v11 # 进入项目并安装依赖(会自动处理 nonebot2 + 适配器 + uvicorn 等) cd my-qq-bot pip install -e .提示:
-e参数启用开发模式,后续修改插件代码无需重新pip install。若使用 Conda 环境,请先conda activate your-env再执行上述命令,避免 pip 与 conda 混装导致依赖冲突。
生成的目录结构中,关键路径为:
bot.py:主入口,定义 Bot 实例和全局配置src/plugins/:所有插件存放目录(每个子目录是一个独立插件)pyproject.toml:声明项目元信息、插件入口点、依赖版本约束
2.2 配置 go-cqhttp 作为底层消息桥接器
NoneBot2 本身不直连 QQ 服务器,必须通过go-cqhttp(或其他 OneBot 实现)接收/发送消息。下载对应系统版本的go-cqhttp二进制文件后,执行首次启动:
# Linux/macOS 下赋予执行权限并启动(Windows 直接双击 go-cqhttp.exe) chmod +x go-cqhttp ./go-cqhttp # 按提示扫码登录 QQ,成功后 Ctrl+C 退出 # 编辑生成的 config.yml,重点修改以下三处: # 1. 启用反向 WebSocket(NoneBot2 默认监听此端口) # 2. 设置 access_token(与 bot.py 中保持一致) # 3. 开放本地监听地址(避免 Docker 网络问题)config.yml关键片段:
# 反向 WebSocket 配置(NoneBot2 将从此处拉取消息) servers: - ws-reverse: url: "ws://127.0.0.1:8080/ws" reverse-api-url: "http://127.0.0.1:8080/api" reverse-event-url: "http://127.0.0.1:8080/event" access-token: "your_secure_token_here" # 必须与 bot.py 中 token 一致2.3 在 bot.py 中声明适配器与插件加载逻辑
bot.py是整个项目的调度中枢。它不包含业务逻辑,只负责注册适配器、加载插件、设置全局中间件:
# bot.py from nonebot import init, load_plugins, get_driver from nonebot.adapters.onebot.v11 import Adapter as OneBotV11Adapter # 初始化 NoneBot2 核心(读取 pyproject.toml 中的 [tool.nonebot] 配置) init() # 获取驱动器实例(用于注册适配器) driver = get_driver() # 注册 OneBot v11 适配器(必须在 load_plugins 之前) driver.register_adapter(OneBotV11Adapter) # 加载 src/plugins/ 下所有插件(支持子目录递归) load_plugins("src/plugins") # 可选:添加全局异常处理器(捕获未处理的插件异常) @driver.on_exception async def handle_exception(event, exception): from nonebot.log import logger logger.error(f"全局异常: {type(exception).__name__}: {exception}")参数说明:
load_plugins("src/plugins")会扫描该路径下所有含__init__.py的子目录,并执行其中的export函数(由nonebot.plugin.load_plugins自动触发)。若插件需禁用,只需重命名其目录(如weather_off),无需注释代码。
2.4 创建第一个插件:响应/help命令的文本帮助系统
在src/plugins/help/__init__.py中编写最简插件:
# src/plugins/help/__init__.py from nonebot import on_command from nonebot.adapters.onebot.v11 import Message, MessageEvent # 定义命令匹配器(响应群聊和私聊中的 /help) help_cmd = on_command("help", aliases={"帮助", "/help"}, priority=10, block=True) @help_cmd.handle() async def send_help(event: MessageEvent): # 判断消息来源(群聊 or 私聊) if event.group_id: target = f"群 {event.group_id}" else: target = "私聊" # 构建帮助文本(支持 Markdown 风格换行) help_text = ( f"📌 {target} 机器人帮助\n" "────────────────\n" "• /help — 显示本帮助\n" "• /status — 查看机器人运行状态\n" "• /ping — 测试响应延迟\n" "• /weather 上海 — 查询指定城市天气(需额外安装 weather 插件)\n" "────────────────\n" "💡 提示:所有命令均不区分大小写,支持中文别名" ) await help_cmd.finish(Message(help_text))逻辑说明:
on_command创建的匹配器默认监听群聊和私聊事件;priority=10表示该命令优先级为 10(数值越小优先级越高,系统内置命令通常为 1~5);block=True表示匹配成功后阻断后续同类型匹配器执行,避免多个插件同时响应同一命令。
验证方式:启动go-cqhttp后,在终端执行nb run,然后在 QQ 群中发送/help,应立即收到格式化帮助文本。
3. 实现多用途能力:天气查询、RSS 订阅与群内投票的插件化落地
3.1 天气查询插件:调用高德 API 并结构化渲染
天气功能需外部 API,我们选用免费额度充足的高德地图 API(需申请 key)。插件结构为src/plugins/weather/,核心文件__init__.py:
# src/plugins/weather/__init__.py import httpx from nonebot import on_command from nonebot.adapters.onebot.v11 import Message, MessageEvent from nonebot.params import CommandArg from pydantic import BaseModel class WeatherResponse(BaseModel): city: str temperature: str weather: str humidity: str winddirection: str weather_cmd = on_command("weather", aliases={"天气", "查天气"}, priority=5) @weather_cmd.handle() async def query_weather(event: MessageEvent, city_name: Message = CommandArg()): city = city_name.extract_plain_text().strip() if not city: await weather_cmd.finish("请指定城市名称,例如:/weather 北京") # 调用高德 API(替换 YOUR_AMAP_KEY) async with httpx.AsyncClient() as client: try: resp = await client.get( "https://restapi.amap.com/v3/weather/weatherInfo", params={ "key": "YOUR_AMAP_KEY", "city": await _get_city_code(client, city), "extensions": "base" }, timeout=10.0 ) data = resp.json() if data["status"] != "1": raise ValueError(data.get("info", "API 请求失败")) w = WeatherResponse(**data["lives"][0]) msg = ( f"🌤️ {w.city} 天气\n" f"温度:{w.temperature}℃\n" f"天气:{w.weather}\n" f"湿度:{w.humidity}\n" f"风向:{w.winddirection}" ) await weather_cmd.finish(Message(msg)) except httpx.TimeoutException: await weather_cmd.finish("⚠️ 请求超时,请稍后重试") except Exception as e: await weather_cmd.finish(f"❌ 查询失败:{str(e)}") async def _get_city_code(client: httpx.AsyncClient, city_name: str) -> str: """根据城市名获取高德 citycode""" resp = await client.get( "https://restapi.amap.com/v3/config/district", params={"key": "YOUR_AMAP_KEY", "keywords": city_name, "subdistrict": 0} ) districts = resp.json().get("districts", []) return districts[0]["adcode"] if districts else "110000" # 默认北京参数说明:
CommandArg()自动提取命令后跟随的纯文本参数;httpx.AsyncClient支持异步 HTTP 请求,避免阻塞事件循环;timeout=10.0防止 API 响应慢拖垮整个机器人;_get_city_code是辅助函数,将城市名转为高德要求的adcode(行政区划编码)。
3.2 RSS 订阅插件:用 APScheduler 实现定时抓取与去重推送
RSS 功能需定时轮询源站,NoneBot2 官方插件nonebot-plugin-apscheduler提供无缝集成:
# 安装定时任务插件 pip install nonebot-plugin-apscheduler在src/plugins/rss/__init__.py中:
# src/plugins/rss/__init__.py from nonebot import require, on_command from nonebot.adapters.onebot.v11 import Message, MessageEvent, GroupMessageEvent from nonebot.plugin import PluginMetadata from nonebot_plugin_apscheduler import scheduler import feedparser import sqlite3 from datetime import datetime require("nonebot_plugin_apscheduler") rss_cmd = on_command("rss", aliases={"订阅", "RSS"}, priority=5) # SQLite 存储已推送条目(轻量级,避免引入 Redis) DB_PATH = "data/rss.db" def init_db(): conn = sqlite3.connect(DB_PATH) conn.execute(""" CREATE TABLE IF NOT EXISTS rss_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, feed_url TEXT NOT NULL, entry_id TEXT UNIQUE NOT NULL, title TEXT NOT NULL, link TEXT NOT NULL, published TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) conn.commit() conn.close() init_db() @rss_cmd.handle() async def add_rss(event: GroupMessageEvent, url: Message = CommandArg()): feed_url = url.extract_plain_text().strip() if not feed_url.startswith(("http://", "https://")): await rss_cmd.finish("请输入有效的 RSS 地址,例如:/rss https://example.com/feed.xml") # 添加到数据库(此处简化,实际应校验 URL 可访问性) conn = sqlite3.connect(DB_PATH) conn.execute("INSERT OR IGNORE INTO rss_items (feed_url, entry_id) VALUES (?, ?)", (feed_url, "placeholder")) conn.commit() conn.close() await rss_cmd.finish(f"✅ 已订阅:{feed_url}") # 每 15 分钟检查一次所有 RSS 源 @scheduler.scheduled_job("interval", minutes=15, id="check_rss_feeds") async def check_rss_feeds(): conn = sqlite3.connect(DB_PATH) cursor = conn.cursor() cursor.execute("SELECT DISTINCT feed_url FROM rss_items") feeds = cursor.fetchall() for (feed_url,) in feeds: try: feed = feedparser.parse(feed_url) for entry in feed.entries[:3]: # 只检查最新 3 条 if not cursor.execute( "SELECT 1 FROM rss_items WHERE entry_id = ?", (entry.id,) ).fetchone(): # 新条目,推送到所有已订阅群(此处简化为固定群号) from nonebot import get_bot bot = get_bot() msg = f"📰 {entry.title}\n{entry.link}" await bot.send_group_msg(group_id=123456789, message=msg) cursor.execute( "INSERT INTO rss_items (feed_url, entry_id, title, link) VALUES (?, ?, ?, ?)", (feed_url, entry.id, entry.title, entry.link) ) except Exception as e: print(f"RSS 抓取失败 {feed_url}: {e}") conn.commit() conn.close()关键设计:
@scheduler.scheduled_job装饰器将函数注册为定时任务,interval类型按固定间隔执行;SQLite 表rss_items以entry_id为主键实现天然去重;get_bot()获取当前 Bot 实例,调用send_group_msg主动推送消息(需确保机器人已在目标群中)。
3.3 群内投票插件:用 Matcher 状态机实现多轮交互
投票功能需用户输入选项、确认、实时统计,NoneBot2 的Matcher状态管理比手动维护字典更可靠:
# src/plugins/vote/__init__.py from nonebot import on_command from nonebot.adapters.onebot.v11 import Message, MessageEvent, GroupMessageEvent from nonebot.matcher import Matcher from nonebot.params import Arg, ArgPlainText, CommandArg from nonebot.rule import to_me vote_cmd = on_command("vote", aliases={"投票", "发起投票"}, rule=to_me(), priority=5) # 全局存储投票状态(生产环境建议用 Redis) _VOTE_STATE = {} @vote_cmd.handle() async def start_vote(matcher: Matcher, event: GroupMessageEvent, arg: Message = CommandArg()): text = arg.extract_plain_text().strip() if not text: await vote_cmd.finish("请用空格分隔选项,例如:/vote 吃火锅 吃烧烤 吃寿司") options = [opt.strip() for opt in text.split() if opt.strip()] if len(options) < 2: await vote_cmd.finish("至少需要 2 个选项!") # 存储当前群的投票状态 group_id = event.group_id _VOTE_STATE[group_id] = { "options": options, "votes": {opt: 0 for opt in options}, "voters": set() # 记录已投票用户 ID,防重复 } await vote_cmd.send( f"📊 投票已开启!\n" f"选项:{' | '.join(f'[{i+1}] {opt}' for i, opt in enumerate(options))}\n" f"请回复数字(如 1)选择,或发送“结束”终止投票" ) # 设置下一步等待用户输入 matcher.set_arg("vote_choice", Arg()) @vote_cmd.got("vote_choice", prompt="请选择序号(1,2,3...)") async def handle_choice( matcher: Matcher, event: GroupMessageEvent, choice: str = ArgPlainText("vote_choice") ): group_id = event.group_id state = _VOTE_STATE.get(group_id) if not state: await vote_cmd.finish("当前无进行中的投票,请先发起。") try: idx = int(choice) - 1 if 0 <= idx < len(state["options"]): option = state["options"][idx] user_id = event.user_id if user_id not in state["voters"]: state["voters"].add(user_id) state["votes"][option] += 1 await vote_cmd.send(f"✅ 您选择了:{option}") else: await vote_cmd.send("⚠️ 您已投过票,不能重复投票!") else: await vote_cmd.send("❌ 选项序号超出范围,请重新输入。") except ValueError: if choice == "结束": await _show_result(matcher, group_id) _VOTE_STATE.pop(group_id, None) return await vote_cmd.send("❌ 请输入有效数字或“结束”。") async def _show_result(matcher: Matcher, group_id: int): state = _VOTE_STATE.get(group_id) if not state: return total = sum(state["votes"].values()) result_lines = ["🗳️ 投票结果:"] for opt, count in sorted(state["votes"].items(), key=lambda x: x[1], reverse=True): pct = f"{count/total*100:.1f}%" if total > 0 else "0%" result_lines.append(f" • {opt}: {count} 票 ({pct})") result_lines.append(f" 📊 总票数:{total}") await matcher.send("\n".join(result_lines))状态机说明:
matcher.set_arg()触发got事件,@vote_cmd.got()捕获用户下一条消息;_VOTE_STATE字典按group_id隔离不同群的投票状态;to_me()规则确保只有 @ 机器人时才触发,避免刷屏;voters集合防止同一用户多次投票。
4. 解决 nonebot2 插件冲突:依赖隔离、加载顺序与调试技巧
4.1 插件冲突的三大典型场景与定位方法
NoneBot2 插件冲突并非框架 Bug,而是模块间隐式耦合导致。常见场景包括:
| 场景 | 表现 | 定位命令 |
|---|---|---|
| 同名命令覆盖 | 执行/status时只响应某个插件,另一个插件的同名命令失效 | nb plugin list查看所有已加载插件及其命令 |
| Pydantic 模型冲突 | 启动时报ValidationError,提示字段重复定义 | nb plugin show <plugin-name>检查插件依赖树 |
| 全局中间件干扰 | 某插件的日志突然消失,或所有命令都返回空响应 | nb run --log-level DEBUG开启调试日志,搜索matcher和event |
注意:
nb plugin list输出中,Priority列显示命令匹配优先级,数值越小越先匹配;若两个插件都注册了on_command("status")且 priority 相同,则按插件加载顺序(目录字母序)决定谁生效。
4.2 用插件入口点(entrypoint)机制实现依赖隔离
NoneBot2 推荐通过pyproject.toml声明插件入口点,而非在bot.py中硬编码load_plugins。在pyproject.toml中添加:
[project.entry-points."nonebot.plugins"] weather = "src.plugins.weather" rss = "src.plugins.rss" vote = "src.plugins.vote" # 若某插件需条件加载(如仅限特定群),可设为可选 [tool.nonebot.plugins] optional = ["src.plugins.admin"] # 此插件不会自动加载,需手动 load_plugin这样做的好处:
nb plugin list可精确控制启用/禁用插件(nb plugin disable weather)- 插件间依赖关系显式化(
src/plugins/weather/pyproject.toml中声明requires = ["httpx"]) - 避免
load_plugins("src/plugins")扫描到测试代码或废弃插件
4.3 调试插件加载失败的四步法
当nb run启动后插件未生效,按此顺序排查:
- 检查插件目录结构:确认
src/plugins/<name>/__init__.py存在且无语法错误(python -m py_compile src/plugins/weather/__init__.py) - 验证插件入口点:运行
python -c "import nonebot; print(nonebot.load_plugins('src/plugins'))",观察是否返回[] - 查看 import 错误:在
__init__.py开头添加print("Loading weather plugin"),启动时观察是否打印 - 检查 Pydantic 版本兼容性:NoneBot2 v2.2+ 要求 Pydantic v2.x,若插件依赖旧版
pydantic<2,需升级插件或降级 NoneBot2(不推荐)
4.4 生产环境插件热重载的边界与替代方案
NoneBot2 的nb run --reload支持代码修改后自动重启,但存在限制:
- 仅监控
.py文件变化,不监控pyproject.toml或config.yml - 修改
bot.py或适配器配置需手动重启 - 热重载期间
go-cqhttp连接可能中断,导致消息丢失
推荐生产部署方案:
# 使用 systemd 管理进程(Linux) # /etc/systemd/system/qq-bot.service [Unit] Description=QQ Bot Service After=network.target [Service] Type=simple User=botuser WorkingDirectory=/opt/my-qq-bot ExecStart=/opt/my-qq-bot/venv/bin/nb run Restart=always RestartSec=10 [Install] WantedBy=multi-user.target关键参数:
Restart=always确保崩溃后自动恢复;RestartSec=10避免频繁重启;WorkingDirectory必须指向项目根目录,否则load_plugins无法定位插件。
5. 高级技巧:用自定义 Matcher 实现跨插件上下文感知与敏感词过滤
5.1 构建跨插件共享的上下文管理器
当多个插件需共享用户状态(如语言偏好、所在群权限等级),不应各自维护字典。NoneBot2 提供Matcher.state,但更健壮的方式是创建全局 ContextManager:
# src/utils/context.py from typing import Dict, Any, Optional from nonebot import get_driver from nonebot.adapters.onebot.v11 import Event class BotContext: _storage: Dict[str, Dict[str, Any]] = {} @classmethod def get(cls, key: str, default=None) -> Any: return cls._storage.get(key, default) @classmethod def set(cls, key: str, value: Any): cls._storage[key] = value @classmethod def clear(cls, key: str): cls._storage.pop(key, None) # 在 bot.py 中初始化(确保早于插件加载) driver = get_driver() @driver.on_startup async def init_context(): BotContext.set("plugin_config", {"weather_api_key": "xxx"})在任意插件中使用:
# src/plugins/admin/__init__.py from src.utils.context import BotContext @admin_cmd.handle() async def set_api_key(event: MessageEvent, arg: Message = CommandArg()): key = arg.extract_plain_text().strip() BotContext.set("weather_api_key", key) # 全局生效 await admin_cmd.finish("✅ API Key 已更新")5.2 用全局 Rule 实现敏感词实时拦截
在src/plugins/sensitive/__init__.py中,不注册命令,而是注入全局 Rule:
# src/plugins/sensitive/__init__.py from nonebot import get_driver, on_message from nonebot.adapters.onebot.v11 import MessageEvent, Message from nonebot.rule import Rule from nonebot.matcher import Matcher # 敏感词列表(生产环境应从数据库或远程配置中心加载) SENSITIVE_WORDS = ["违禁词1", "违禁词2"] async def sensitive_rule(event: MessageEvent) -> bool: msg = event.get_message().extract_plain_text() return any(word in msg for word in SENSITIVE_WORDS) # 创建全局拦截器(priority=1,最高优先级) sensitive_blocker = on_message(rule=Rule(sensitive_rule), priority=1, block=True) @sensitive_blocker.handle() async def handle_sensitive(event: MessageEvent): await sensitive_blocker.send("⚠️ 检测到不适宜内容,已自动拦截") # 可选:记录日志、通知管理员、调用审核 API # from nonebot.log import logger # logger.warning(f"Sensitive content from {event.user_id}: {event.get_message()}")原理说明:
on_message+Rule组合会在所有命令匹配前执行;priority=1确保它最先被检查;block=True阻断后续所有匹配器,使敏感消息完全不进入业务逻辑层。
5.3 验证插件是否真正生效的三类检查命令
不要仅靠 QQ 群里发命令测试,用以下命令快速验证:
| 命令 | 作用 | 示例输出 |
|---|---|---|
nb plugin list --enabled | 列出所有启用插件及命令 | weather: /weather, /天气 |
nb run --log-level INFO --debug | 启动时输出详细加载日志 | INFO: Loaded plugin 'weather' |
curl -X POST http://127.0.0.1:8080/api/get_status | 直接调用 go-cqhttp API 检查连接 | {"status":"ok","retcode":0,"data":{"good":true}} |
最后一步:在go-cqhttp日志中搜索ws connected,确认 WebSocket 连接建立成功;若出现connection refused,检查bot.py中access-token是否与config.yml一致,以及go-cqhttp是否在运行。
本文还有配套的精品资源,点击获取