mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
3600 字
9 分钟
从协议移植到可用机器人:舞萌 DX AstrBot 插件开发笔记

从协议移植到可用机器人:舞萌 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 预览是,不写入存档

有两条边界在开发中被刻意写死:

  1. 插件只处理 QQ/OneBot v11 的群聊命令与私聊二维码;非目标平台直接退出,避免把依赖平台报文结构的代码误用到其他适配器。
  2. /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-model
maimai/b50_image.py HTML 模板注入与 Playwright 截图
maimai/b50_text.py 图片渲染不可用时的文本降级输出
tests/ 协议、安全、缓存与降级回归测试

main.py 只负责业务层的“何时做什么”:注册指令、记录群消息上下文、在私聊收图、将认证成功后的数据送到具体流程。协议细节放在 maimai/ 内部,因而后续排查某个接口或替换渲染方式时,不会牵动机器人事件逻辑。

二维码登录:群里发起,私聊完成#

交互看似简单,但机器人场景有一个天然约束:群里的提问与私聊里的二维码必须可靠地关联到同一个人、同一条原始消息和同一个业务动作。

因此实现了 PendingLoginManager。它以 QQ 号为键,保存一次性回调、原群 ID、原群消息 ID 与超时任务:

sequenceDiagram participant U as 用户 participant G as QQ 群 participant B as AstrBot 插件 participant P as 私聊 participant A as Aime / 称号服务 U->>G: /b50 或 /whoami G->>B: 群聊事件 B->>B: 注册 QQ 对应的 120 秒登录上下文 B-->>U: 请在私聊发送登录二维码 U->>P: 二维码图片 P->>B: 私聊图片事件 B->>B: 检查图片并消费一次性上下文 B->>A: 二维码认证与后续查询 A-->>B: 用户数据 B->>G: 回复原消息并 @ 用户

这里有几个容易被忽略的实现细节:

  • 同一用户再次发起命令会先取消旧的超时任务,再覆盖为新上下文,避免旧流程在 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 的 QRCodeDetectorpyzbar

这种做法的重点不是追求“任何图片都能识别”,而是把失败变成可控的 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-AgentMai-EncodingAccept-EncodingCharsetContent-EncodingHost 等请求头需要保持与参考实现一致。
  • 请求体并非只要 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
-> UserLogoutApi

client.call(..., http=session) 显式接收这个会话对象。没有登录需求的轻量查询仍可使用一次性客户端;进入登录后的完整流程时,所有请求都必须使用同一个连接池和 Cookie Jar。这一改动使“认证成功”真正变成了“可继续调用受会话保护接口”。

重试不是越多越好#

网络波动时重试可以改善只读查询的体验,但如果把这个策略套用到写操作,就会把一次不确定的请求变成重复提交。

maimai/client.py 因此把接口分成两类:

  • 普通读取接口最多尝试两次,失败间隔一秒。
  • UpsertUploadUserLoginUserLogout 开头的操作不自动重试。

这既避免了写接口重复产生副作用,也避免在登录/登出阶段制造难以判断的状态。日志记录响应头时会对 AuthorizationCookieSet-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 到图片的渲染管线#

flowchart LR A[Best50 缓存 JSON] --> B[build_view] B --> C[渲染用 view-model] C --> D[注入 HTML 模板] D --> E[Playwright / Chromium 截图] E --> F[Base64 PNG] E -.失败或未安装.-> G[format_b50 文本表格]

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,真机验证尚未形成稳定的成功结论。尽管已经依据真实客户端样本调整过请求体字段顺序、playCountplayerRatingloginDateTime 等字段,仍不应把它描述为可靠功能。

这也是本项目一个刻意保留的开发原则:对会产生账户副作用的能力,未知状态必须写在文档和代码边界里,而不是用“看起来已经发出请求”掩盖结果。

回归测试覆盖了哪些风险#

当前测试通过 24 项,重点不在堆高覆盖率数字,而在固定容易回归的边界条件:

  • AES 加解密往返与不合法 PKCS#7 Padding 拒绝;
  • 请求格式的字段顺序与序列化结果;
  • 只读接口可重试、登录/登出/写接口不可重试;
  • 响应日志中的敏感请求头脱敏;
  • 超大 PNG 在真正解码前被拒绝;
  • 非法资源 ID 不会拼接远程 URL;
  • 曲库刷新失败时保留可用旧缓存;
  • /unlock 源码路径不包含 Upsert 写接口;
  • 图片渲染与文本渲染双重失败时给出可控错误;
  • 私聊图片无法读取时正确停止事件传播。

本地验证命令:

python -m unittest discover -v
python -m scripts.parity_check

后续工作#

接下来最值得继续投入的方向有三个:

  1. /ticket 建立更严格的真机验证记录,在确认服务端写入语义之前维持保守提示。
  2. 将协议层的成功与失败报文样本进一步固化为离线 fixture,减少只能在线调试的情况。
  3. 为 B50 模板提供独立的视觉回归截图,保证后续调整数据模型时不悄悄破坏排版。

这次开发最有价值的收获不是“把一个查分功能跑起来”,而是把不可见的约束变成代码结构:会话必须共享、用户图片要先验、写操作不能重试、渲染总要能退回文本、未知的写入结果不能被包装成成功。对一个常驻机器人的插件来说,这些细节才是真正决定可用性的部分。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

从协议移植到可用机器人:舞萌 DX AstrBot 插件开发笔记
https://cccy0721.top/posts/astrbot-maimai-plugin/
作者
Cccy
发布于
2026-07-24
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

封面
Sample Song
Sample Artist
封面
Sample Song
Sample Artist
0:00 / 0:00