LOADING

页面加载中...

QmessageQ 插件开发记录

astrbot_plugin_qmessageq

QmessageQ 插件里魔法表情的逆向

记录 AstrBot 插件 astrbot_plugin_qmessageq(QmessageQ 工具箱)的研发过程,重点复盘”魔法表情”这一块——从在消息里看到一堆 <$ÿĀ> 乱码,到搞清它的结构、推算出能批量生成任意表情的方法。

缘起

这个插件一开始是给 AstrBot(跑在 NapCat / OneBot v11 上)做一组 QQ 消息工具:图片藏文案(/himg)、伪造合并转发(/fake)、真 @(/at)、名片(/card)、引用消息回应表情(/face)、发送内置表情(/hface)、解析引用消息(/parse)以及让 LLM 用的 @ 工具(at_user)。

功能本身不复杂,真正把我绕进去的是最后一块——魔法表情(/magic

发现:消息里的 <$...> 乱码

某天我在做 /parse(解析被引用消息)时,发现有些消息内容解析出来是这么一串东西:

<$ÿĀ><$ÿĀ><$ÿ%><$ÿĀþ><$ÿĀ><$ÿĀ>

在 QQ 客户端里,它明明是个会动的表情;但 NapCat 上报给我们的却是一段文本。也就是说,QQ 的”魔法表情/动态表情”根本不走 face 段,而是内嵌在纯文本里:客户端在渲染消息时扫描 <$...> 这种模板,把中间那段字节按内置表替换成对应的动画表情。对协议端来说,它就是普通文本。

所以第一个结论:要”发送”魔法表情,其实只要把 <$...> 原文作为文本发出去即可,客户端会自己渲染。

解剖结构:单字符族 = face ID

有了真实模板,我按码点把它们拆开,发现两条清晰的”族”:

单字符族 <$X>——<$ 加一个字符加 >。我枚举了一批码点发给用户验证,基本都能渲染成表情。进一步发现:

<$X> 渲染出来的,就是 face ID 为 X 的表情。

比如 滑稽 = <$²>,而 ² = U+00B2 = 178,所以 <$²> 和 face ID 178 是同一个表情。换句话说,单字符族只是 face ID 的”文本内嵌写法”,/magic 178[CQ:face,id=178] 等价。

这一下子把”魔法表情”的神秘感去掉了大半——至少单字符这一族,本质就是表情 ID 表。

多字符族:<$a b c d> 的规律

更麻烦的是另一族,4 个字符:

摇手手       <$ÿĀ\x11\x10>
四叶草🍀     <$ÿĀB >
小爪爪       <$ǿĀC\x01>
升级版小爪爪  <$ÿĀ\x11">
小心心       <$ǿĀF\x13>
小熊熊       <$ÿĀD#>
魔法棒🪄     <$ǿĀB#>
钻石         <$ǿĀD\x0e>

全部是 <$a b c d> 固定 4 码。逐个字符拆码点:

  • a(基座):只见过 ÿ(0x00FF=255) 和 ǿ(0x01FF=511),二者等价
  • b:恒为 Ā(0x0100=256);
  • c:多变;
  • d:多变。

样本太少时,c、d 看起来毫无规律(17、66、67、68、70…),扫了几轮,规律浮出来了:

  • c = 表情包序号,多个有效区间,1~80 最密集
  • d = 表情包内部的表情序号,多个有效区间,1~40 最密集

于是”摇手手”就变成了可推算的东西:<$ÿĀ\x11\x10> = 255 256 17 16 = 17 号表情包的第 16 号表情/magic 255 256 17 16 就能稳定复现。

方法论:枚举 → 验证 → 推算

整个逆向过程其实很朴素:

  1. 枚举:对单字符族按码点 1~0x7F 穷举生成 <$X>,对多字符族固定基座、扫 c 或扫 d(255 256 1-16 1-64 这种笛卡尔积),一次性生成成百上千个模板。
  2. 验证:把模板作为文本发给 QQ,能渲染成表情的就是有效值,只显示乱码的就是无效值。把”有效区间”标出来。
  3. 推算:把有效值按字段排开,找结构性规律(哪个字段是什么语义、哪些区间密集、哪些基座等价)。

这个流程里,工具帮了大忙:/magic 支持数字序列/magic 255 256 17 16)、每个字段 a-b 范围(自动笛卡尔积、分块发送避免消息过长)、十进制/十六进制混用,还有引用模式(引用一条含魔法表情的消息,直接把它的模板解析成 magic 255 256 17 16 | 0xff 0x100 0x11 0x10,方便复现和继续推算)。

落地:/magic 命令的演进

/magic 一开始是”原样转发模板”,后来试过内置目录、自动收录,最后都推翻了——因为手抄的模板不可靠、自动收录的目录又臃肿。最终形态是一个无状态生成器

/magic 178             # 单字符 → <$²>(滑稽)
/magic 255 256 17 16   # 多字符 → <$ÿĀ\x11\x10>(摇手手)
/magic 255 256 1-16 1-64  # 范围 → 笛卡尔积,每 100 个一条
/magic 0x42 0x23       # 十进制 / 0x 十六进制混用
/magic <模板>          # 原样发出
/magic                 # 引用消息 → 解析成码点序列输出

只负责”按码点生成 + 发送”,不维护目录、不记忆状态,所有数据都来自用户给定的数字或真实消息的原始字节。

小结与启示

  • 协议端看不见的东西,客户端看得见。魔法表情、[聊天记录] 卡片都是”文本里的模板”,要多想一层”客户端渲染时发生了什么”。
  • 字节是真相。不可见字符会丢,肉眼比对会骗人,逐字节验证才可靠。
  • 穷举 + 用户验证是可行的逆向路径。没有文档、没有映射表,就把候选集铺开、发给真实客户端看,再根据”有效/无效”反推结构。
  • 工具要贴合探索流程。生成器、范围扫描、引用解析,都是为了让”自己填数字找表情”这件事变快——最后用户真的靠它扫出了”表情包序号 + 表情序号”的规律。