从协议移植到可用机器人:舞萌 DX AstrBot 插件开发笔记
这个项目是一个运行在 AstrBot 中的舞萌 DX 查询插件。它面向 QQ 的 OneBot v11(aiocqhttp)适配器:用户在群里发出查询指令,机器人转而在私聊中接收 Aime 登录二维码,完成认证与数据查询后,再把结果发回原群。
项目的起点不是“调用一个现成 API”,而是把原有 Kotlin 项目的实现迁移到 Python,并把它收束成一个适合机器人长期运行的插件。最后留下来的核心问题因此很具体:如何保持登录会话、如何不让二维码成为安全入口、如何在 B50 大图生成失败时仍保证用户能拿到结果,以及如何把有副作用的能力限制在明确的边界内。
仓库地址:https://github.com/Cccy666/astrbot_plugin_maimai
项目目标与能力边界
目前插件提供以下命令:
| 命令 | 作用 | 是否需要扫码 |
|---|---|---|
/maihelp | 显示插件帮助 | 否 |
/whoami / /wami | 查询昵称、Rating、版本、登录状态等账号信息 | 是 |
/b50 | 优先用本地缓存生成 Best50 | 缓存未命中时需要 |
/b50 u | 强制刷新 Best50 | 是 |
/ticket <1..5> | 请求发放功能票或倍率票 | 是,属于真实写入链路 |
/unlock | 生成解锁数据的 dry-run 预览 | 是,不写入存档 |
有两条边界在开发中被刻意写死:
- 插件只处理 QQ/OneBot v11 的群聊命令与私聊二维码;非目标平台直接退出,避免把依赖平台报文结构的代码误用到其他适配器。
/unlock confirm被明确禁用。当前实现最多读取数据并生成预览,绝不调用Upsert*存档写接口,避免不完整快照覆盖玩家已有数据。
整体结构:把易变的部分隔离开
最初的移植版本很容易把命令、网络报文、二维码、缓存和展示全堆到入口文件中。后续将其拆分为几层,使每个模块的失败模式都更清楚。
main.py AstrBot 命令、事件路由与业务编排pending_login.py 二维码等待状态与 120 秒超时管理maimai/runtime.py 配置、数据目录与代理参数maimai/qr_decode.py 图片尺寸检查与二维码解码maimai/qr_auth.py Aime 二维码认证maimai/client.py 加密请求、会话、重试与日志脱敏maimai/requests.py 协议请求体的固定字段与字段顺序maimai/music_data.py 曲库定数与物量缓存maimai/cover.py 封面、头像、名牌资源下载与本地缓存maimai/b50_view.py 原始成绩 -> 可渲染 view-modelmaimai/b50_image.py HTML 模板注入与 Playwright 截图maimai/b50_text.py 图片渲染不可用时的文本降级输出tests/ 协议、安全、缓存与降级回归测试main.py 只负责业务层的“何时做什么”:注册指令、记录群消息上下文、在私聊收图、将认证成功后的数据送到具体流程。协议细节放在 maimai/ 内部,因而后续排查某个接口或替换渲染方式时,不会牵动机器人事件逻辑。
二维码登录:群里发起,私聊完成
交互看似简单,但机器人场景有一个天然约束:群里的提问与私聊里的二维码必须可靠地关联到同一个人、同一条原始消息和同一个业务动作。
因此实现了 PendingLoginManager。它以 QQ 号为键,保存一次性回调、原群 ID、原群消息 ID 与超时任务:
这里有几个容易被忽略的实现细节:
- 同一用户再次发起命令会先取消旧的超时任务,再覆盖为新上下文,避免旧流程在 120 秒后意外回调。
- 收到二维码时会立刻从待处理表中弹出该条记录,保证同一登录凭据只会被消费一次。
- 认证和完整数据拉取放入
asyncio.create_task()后台运行。完整 B50 流程可能需要等待服务端会话就绪,不能阻塞私聊消息处理器。 - 所有命令与已接管的二维码事件都会调用
event.stop_event(),防止 AstrBot 默认 LLM 再把命令或图片交给模型处理,产生“模型不支持图片输入”之类无关的噪声。
二维码不是普通图片:先拒绝危险输入
图片解码通常是被忽略的攻击面。二维码图片来自用户,直接交给 Pillow、OpenCV 等解码器之前,可能先触发巨幅图片解压带来的内存问题。
maimai/qr_decode.py 先只读取 PNG、JPEG、GIF 与 WebP 的文件头,得到宽高而不解压像素数据;总像素数超过 16,000,000 时立即拒绝。入口侧还限制单个图片不超过 8 MiB。通过这两道检查后,才按顺序尝试 OpenCV 的 QRCodeDetector 与 pyzbar。
这种做法的重点不是追求“任何图片都能识别”,而是把失败变成可控的 None:用户会收到“图片无法读取、过大或未识别”的提示,插件进程则不会因为一张异常图片失去响应。
协议移植:报文正确远不止 AES 正确
称号服务的请求管线可以概括为:
JSON 字符串 -> UTF-8 -> zlib deflate -> AES-CBC + PKCS#7 -> HTTP body
HTTP body -> AES 解密 -> zlib inflate -> UTF-8 JSON看起来是标准的“压缩加密 HTTP”,真正难的是协议兼容性。移植时确认了以下约束:
- API 路径不是明文接口名,而是
md5(api + "MaimaiChn" + obfuscateParam)的小写十六进制结果。 User-Agent、Mai-Encoding、Accept-Encoding、Charset、Content-Encoding与Host等请求头需要保持与参考实现一致。- 请求体并非只要 JSON 语义相同即可。
json.dumps的默认空格、字段声明顺序都被固定在requests.py中,避免“逻辑正确、服务端仍拒绝”的隐性差异。 - AES key、IV、机台身份和服务地址都从 AstrBot 配置或环境变量读取;公开的
_conf_schema.json只保留空默认值,不把真实凭据提交到仓库。
最关键的一次排错:Cookie 才是完整会话
曾经遇到一个很迷惑的现象:UserLoginApi 返回成功,但随后读取成绩或登出却得到加密后的空响应,或者看似登出成功但 isLogin 状态没有清掉。
问题不在 AES,也不在请求体,而在 HTTP 客户端的生命周期。登录响应发下来的 JSESSIONID 只存在于该 httpx.AsyncClient 的 Cookie Jar 中;若每个请求都新建客户端,后续请求就是没有会话的匿名请求。
修复方式是让完整流程共享同一个异步客户端:
new_session() -> UserLoginApi -> 等待会话生效 -> GetUserRatingApi -> GetUserMusicApi -> UserLogoutApiclient.call(..., http=session) 显式接收这个会话对象。没有登录需求的轻量查询仍可使用一次性客户端;进入登录后的完整流程时,所有请求都必须使用同一个连接池和 Cookie Jar。这一改动使“认证成功”真正变成了“可继续调用受会话保护接口”。
重试不是越多越好
网络波动时重试可以改善只读查询的体验,但如果把这个策略套用到写操作,就会把一次不确定的请求变成重复提交。
maimai/client.py 因此把接口分成两类:
- 普通读取接口最多尝试两次,失败间隔一秒。
- 以
Upsert、Upload、UserLogin、UserLogout开头的操作不自动重试。
这既避免了写接口重复产生副作用,也避免在登录/登出阶段制造难以判断的状态。日志记录响应头时会对 Authorization、Cookie 与 Set-Cookie 做脱敏,保留足够的诊断信息而不把会话凭据写进日志。
B50:缓存、资源下载与多层降级
B50 是项目里体验最重要、链路也最长的功能。它不能只把服务器 JSON 原样贴到聊天里:需要补曲名和定数、算单曲 RA、读取封面/头像/名牌,最后还要以易读的方式排版。
缓存优先的查询策略
用户执行 /b50 时,插件优先读取 data/.../{qq}_best50.json。命中后不需要重新扫码,直接从缓存重新渲染;只有使用 /b50 u 或缓存不存在时才进入认证流程。
曲库定数使用“内存 + 落盘”两级缓存:
- 内存 TTL 为 5 分钟,避免每次渲染都访问曲库服务。
- 首次启动优先读取本地
music_data.json。 - 网络刷新成功后才整体替换内存映射并写入磁盘;解析失败时保留旧缓存。
- 若刷新失败但旧数据尚在,继续以 stale cache 工作,而不是让 B50 全部不可用。
这种“先构建临时映射,验证后再替换”的做法很小,却避免了半份坏数据污染正在服务的缓存。
50 条成绩并发构建,但不让网络失控
b50_view.py 会并发构建 Best35 和 Best15 的条目。每条记录都补齐曲名、定数、谱面类型、物量、RA、评级、DX 分数和 FC/FS 徽章,再交给模板渲染。
资源请求也做了控制:
- 每种资源按 ID 使用
asyncio.Lock去重,同一张封面不会被多个协程重复下载。 - 全局下载并发数限制为 6,避免一次 B50 把网络连接打满。
- 单个资源限制为 5 MiB,并检查
Content-Type是否含image。 - 曲绘会依次尝试原 ID、宴谱 ID、DX/SD 互转 ID,并在 diving-fish 与 lxns 两个来源之间回退。
这让并发的目标从“尽可能快”变为“在机器人中稳定地快”。
从缓存 JSON 到图片的渲染管线
build_view() 是这条链路的分界线:它把协议原始字段变成展示层可直接使用的 view-model,例如 achievement 统一为百分比、rate 统一为小写评级码、dxScoreMax 由曲目总物量乘 3 得出,封面与头像则解析成本地绝对路径。
图片层使用用户设计的 HTML/CSS 模板,模板中的玩家信息、B35 和 B15 JSON 占位符由 Python 安全注入。这里特意处理了 </script> 等会提前结束脚本标签的内容,避免歌曲名或玩家名破坏模板脚本上下文。
截图时没有使用 page.set_content(),而是把临时 HTML 写入文件后通过 file:// 打开。原因是模板引用的是本地缓存资源;about:blank 源会阻止这些 file:/// 封面读取。Chromium 以高像素密度全页截图生成 PNG。
最重要的不是图片效果,而是降级策略:Playwright 未安装、Chromium 不可用、模板抛异常、资源缺失,都不会让 B50 命令失败。插件会自动退回为同一份数据生成的文本简表;如果两层渲染都异常,才返回明确错误信息。
Less 与 Full:根据账号状态选择成本
认证后,插件先取得用户预览信息,并依据 isLogin 选择不同路径:
- Less 路径:账号已经登录时,仅获取 Rating 数据;速度更快,也不会强行登录或登出。代价是不能可靠获取完整的 combo/sync 徽章。
- Full 路径:账号未登录时,使用共享 Cookie 的会话完成登录、等待、Rating 与 Music 查询,并在
finally路径中尽力登出。音乐数据获取失败并不是致命错误,会降级为没有徽章的信息而不是丢失整份 B50。
这不是单纯的性能优化,而是把“尽量少改变账号状态”作为默认原则。
写入能力:宁可诚实地保留未解问题
/ticket 需要调用真实写接口 UpsertUserChargelogApi,它与只读查询不同,不能拿到 HTTP 200 就当作成功。开发时已实现以下保护:登录、读取当前库存、确认未拥有目标票券、同会话提交写入、最后登出;写操作不重试。
不过截至本文更新,服务端对该写入请求仍可能返回 returnCode=0,真机验证尚未形成稳定的成功结论。尽管已经依据真实客户端样本调整过请求体字段顺序、playCount、playerRating 和 loginDateTime 等字段,仍不应把它描述为可靠功能。
这也是本项目一个刻意保留的开发原则:对会产生账户副作用的能力,未知状态必须写在文档和代码边界里,而不是用“看起来已经发出请求”掩盖结果。
回归测试覆盖了哪些风险
当前测试通过 24 项,重点不在堆高覆盖率数字,而在固定容易回归的边界条件:
- AES 加解密往返与不合法 PKCS#7 Padding 拒绝;
- 请求格式的字段顺序与序列化结果;
- 只读接口可重试、登录/登出/写接口不可重试;
- 响应日志中的敏感请求头脱敏;
- 超大 PNG 在真正解码前被拒绝;
- 非法资源 ID 不会拼接远程 URL;
- 曲库刷新失败时保留可用旧缓存;
/unlock源码路径不包含Upsert写接口;- 图片渲染与文本渲染双重失败时给出可控错误;
- 私聊图片无法读取时正确停止事件传播。
本地验证命令:
python -m unittest discover -vpython -m scripts.parity_check后续工作
接下来最值得继续投入的方向有三个:
- 为
/ticket建立更严格的真机验证记录,在确认服务端写入语义之前维持保守提示。 - 将协议层的成功与失败报文样本进一步固化为离线 fixture,减少只能在线调试的情况。
- 为 B50 模板提供独立的视觉回归截图,保证后续调整数据模型时不悄悄破坏排版。
这次开发最有价值的收获不是“把一个查分功能跑起来”,而是把不可见的约束变成代码结构:会话必须共享、用户图片要先验、写操作不能重试、渲染总要能退回文本、未知的写入结果不能被包装成成功。对一个常驻机器人的插件来说,这些细节才是真正决定可用性的部分。
部分信息可能已经过时









