
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~0x7F穷举生成<$X>,对多字符族固定基座、扫 c 或扫 d(255 256 1-16 1-64这种笛卡尔积),一次性生成成百上千个模板。 - 验证:把模板作为文本发给 QQ,能渲染成表情的就是有效值,只显示乱码的就是无效值。把”有效区间”标出来。
- 推算:把有效值按字段排开,找结构性规律(哪个字段是什么语义、哪些区间密集、哪些基座等价)。
这个流程里,工具帮了大忙:/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 # 引用消息 → 解析成码点序列输出
只负责”按码点生成 + 发送”,不维护目录、不记忆状态,所有数据都来自用户给定的数字或真实消息的原始字节。
小结与启示
- 协议端看不见的东西,客户端看得见。魔法表情、
[聊天记录]卡片都是”文本里的模板”,要多想一层”客户端渲染时发生了什么”。 - 字节是真相。不可见字符会丢,肉眼比对会骗人,逐字节验证才可靠。
- 穷举 + 用户验证是可行的逆向路径。没有文档、没有映射表,就把候选集铺开、发给真实客户端看,再根据”有效/无效”反推结构。
- 工具要贴合探索流程。生成器、范围扫描、引用解析,都是为了让”自己填数字找表情”这件事变快——最后用户真的靠它扫出了”表情包序号 + 表情序号”的规律。