From a42c169d23bce42949e7fe624bb7924a41b4c305 Mon Sep 17 00:00:00 2001 From: VincentZyu233 <1830540513zyu@gmail.com> Date: Wed, 30 Sep 2026 00:54:58 +0800 Subject: [PATCH 1/7] =?UTF-8?q?fix(msg):=20=E4=BF=AE=E5=A4=8D=E5=9B=BE?= =?UTF-8?q?=E6=96=87=E6=B7=B7=E6=8E=92=E6=97=B6=E8=B6=85=E7=BA=A7=E8=A1=A8?= =?UTF-8?q?=E6=83=85=E5=AF=BC=E8=87=B4=E7=9A=84=E6=88=AA=E6=96=AD=E4=B8=8E?= =?UTF-8?q?=E6=8E=A5=E6=94=B6=E7=AB=AF=E8=A7=A3=E6=9E=90=E4=B8=AD=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 发送端:多元素图文混排时将超级表情降级为小表情形态(serviceType: 33),仅在单体唯一表情时构造 serviceType: 37 大表情 - 接收端:移除 messageParsing 遍历 elements 循环中 svcType===37 分支内错误的 break 语句,防止后续消息元素被丢弃 Co-authored-by: gemini-code-assist <200291788+gemini-code-assist@users.noreply.github.com> --- src/ntqqapi/helper/messageBuilding.ts | 4 ++-- src/ntqqapi/helper/messageParsing.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/ntqqapi/helper/messageBuilding.ts b/src/ntqqapi/helper/messageBuilding.ts index 6529e1cca..8417aef3f 100644 --- a/src/ntqqapi/helper/messageBuilding.ts +++ b/src/ntqqapi/helper/messageBuilding.ts @@ -64,7 +64,7 @@ export class MessageBuilding { businessType: f.faceIndex, }, }) - } else if (faceElement.faceType === 3) { + } else if (faceElement.faceType === 3 && this.inputElems.length === 1) { const f = faceElement const pbElem = Msg.LargeFaceExtra.encode({ aniStickerPackId: f.packId ? String(f.packId) : '1', @@ -80,7 +80,7 @@ export class MessageBuilding { businessType: f.stickerType ?? 1, }, }) - } else if (faceElement.faceType === 2) { + } else if (faceElement.faceType === 2 || faceElement.faceType === 3) { const f = faceElement const pbElem = Msg.QSmallFaceExtra.encode({ faceId: f.faceIndex, diff --git a/src/ntqqapi/helper/messageParsing.ts b/src/ntqqapi/helper/messageParsing.ts index bbb850cbb..9bde72893 100644 --- a/src/ntqqapi/helper/messageParsing.ts +++ b/src/ntqqapi/helper/messageParsing.ts @@ -311,7 +311,6 @@ export function parseElements( resultId: ext.resultId, }, }) - break } else if (svcType === 45) { const ext = Msg.MarkdownExtra.decode(pbElem) result.push({ From 6296b9eee3e886d1958d1cad642d8e2c1485791c Mon Sep 17 00:00:00 2001 From: VincentZyu233 <1830540513zyu@gmail.com> Date: Wed, 30 Sep 2026 02:19:10 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(msg):=20=E6=8E=A5=E6=94=B6=E8=B6=85?= =?UTF-8?q?=E7=BA=A7=E5=A4=A7=E8=A1=A8=E6=83=85=E6=97=B6=E8=B7=B3=E8=BF=87?= =?UTF-8?q?=E7=B4=A7=E9=9A=8F=E5=85=B6=E5=90=8E=E7=9A=84=E9=99=8D=E7=BA=A7?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 替换原有的 break 语句为针对 fallback 文本的跳过逻辑(skipIndex) - 自动剔除紧随其后的 [表情描述] 或 [动画表情] 等降级文本,同时确保后续正常消息内容不被截断 Co-authored-by: gemini-code-assist <200291788+gemini-code-assist@users.noreply.github.com> --- src/ntqqapi/helper/messageParsing.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/ntqqapi/helper/messageParsing.ts b/src/ntqqapi/helper/messageParsing.ts index 9bde72893..5689ad713 100644 --- a/src/ntqqapi/helper/messageParsing.ts +++ b/src/ntqqapi/helper/messageParsing.ts @@ -311,6 +311,20 @@ export function parseElements( resultId: ext.resultId, }, }) + // 剔除紧随其后的降级文本段(腾讯服务端为兼容老客户端附带的 [吃糖] / [动画表情] 等 fallback 文本) + const nextElem = elems[index + 1] + if (nextElem?.text?.str) { + const nextStr = nextElem.text.str + const faceName = face?.QDes ? face.QDes.replace(/^\//, '') : '' + const isFallbackText = + nextStr === '[动画表情]' || + (faceName && (nextStr === `[${faceName}]` || nextStr === face.QDes)) || + (faceIndex === 358 && nextStr === '[骰子]') || + (faceIndex === 359 && (nextStr === '[包剪锤]' || nextStr === '[剪刀石头布]')) + if (isFallbackText) { + skipIndex = index + 1 + } + } } else if (svcType === 45) { const ext = Msg.MarkdownExtra.decode(pbElem) result.push({ From a2aa7590648f2909affa25d122521988503d97ce Mon Sep 17 00:00:00 2001 From: VincentZyu233 <1830540513zyu@gmail.com> Date: Wed, 30 Sep 2026 02:43:41 +0800 Subject: [PATCH 3/7] =?UTF-8?q?docs(msg):=20=E5=AE=8C=E5=96=84=E8=B6=85?= =?UTF-8?q?=E7=BA=A7=E8=A1=A8=E6=83=85=20fallback=20=E9=99=8D=E7=BA=A7?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E8=B7=B3=E8=BF=87=E9=80=BB=E8=BE=91=E6=B3=A8?= =?UTF-8?q?=E9=87=8A=E4=B8=8E=E5=AE=9E=E6=B5=8B=E6=A1=88=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 补充对腾讯服务端在 serviceType: 37 大表情后附带 fallback 纯文本段的机制说明 - 记录实测确认的典型案例(/吃糖 附带 [吃糖],/菜汪 附带 [菜汪] 等) Co-authored-by: gemini-code-assist <200291788+gemini-code-assist@users.noreply.github.com> --- src/ntqqapi/helper/messageParsing.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ntqqapi/helper/messageParsing.ts b/src/ntqqapi/helper/messageParsing.ts index 5689ad713..786657e0b 100644 --- a/src/ntqqapi/helper/messageParsing.ts +++ b/src/ntqqapi/helper/messageParsing.ts @@ -311,7 +311,9 @@ export function parseElements( resultId: ext.resultId, }, }) - // 剔除紧随其后的降级文本段(腾讯服务端为兼容老客户端附带的 [吃糖] / [动画表情] 等 fallback 文本) + // 剔除紧随其后的降级文本段(腾讯服务端为兼容老客户端,在下发 serviceType: 37 大表情时会附带 fallback 纯文本段) + // 实测典型案例:/吃糖 会附带 "[吃糖]",/菜汪 会附带 "[菜汪]",骰子附带 "[骰子]" 等 + // 采用 skipIndex 精准跳过此冗余段,避免直接 break 导致后续正常消息内容被截断 const nextElem = elems[index + 1] if (nextElem?.text?.str) { const nextStr = nextElem.text.str From f9cef0d1ac905463fd5af62c29a30168848b6f37 Mon Sep 17 00:00:00 2001 From: VincentZyu233 <1830540513zyu@gmail.com> Date: Wed, 30 Sep 2026 02:44:06 +0800 Subject: [PATCH 4/7] =?UTF-8?q?docs(msg):=20=E8=A1=A5=E5=85=85=E5=8F=91?= =?UTF-8?q?=E9=80=81=E7=AB=AF=E6=B7=B7=E6=8E=92=E8=B6=85=E7=BA=A7=E8=A1=A8?= =?UTF-8?q?=E6=83=85=E7=9A=84=20QQ=20=E6=9C=8D=E5=8A=A1=E7=AB=AF=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=E7=BA=A6=E6=9D=9F=E4=B8=8E=E9=98=B2=E5=91=86=E8=AF=B4?= =?UTF-8?q?=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 注释说明腾讯 QQ 服务端禁止在混排中携带 serviceType: 37(会触发 retcode 1200 拦截) - 阐明多元素混排时必须降级为 serviceType: 33 的官方客户端行为依据 Co-authored-by: gemini-code-assist <200291788+gemini-code-assist@users.noreply.github.com> --- src/ntqqapi/helper/messageBuilding.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ntqqapi/helper/messageBuilding.ts b/src/ntqqapi/helper/messageBuilding.ts index 8417aef3f..b8b73800d 100644 --- a/src/ntqqapi/helper/messageBuilding.ts +++ b/src/ntqqapi/helper/messageBuilding.ts @@ -65,6 +65,9 @@ export class MessageBuilding { }, }) } else if (faceElement.faceType === 3 && this.inputElems.length === 1) { + // 仅在单体唯一表情时构造 serviceType: 37 大表情。 + // 注意:腾讯服务器禁止在图文混排(inputElems.length > 1)中携带 serviceType: 37, + // 否则会直接拒收并抛出 retcode: 1200 错误;官方客户端混排时亦统一降级为小表情形态(serviceType: 33)。 const f = faceElement const pbElem = Msg.LargeFaceExtra.encode({ aniStickerPackId: f.packId ? String(f.packId) : '1', From 34869a36f40799089a41d523e98352fa12a348c9 Mon Sep 17 00:00:00 2001 From: VincentZyu233 <1830540513zyu@gmail.com> Date: Wed, 30 Sep 2026 02:44:41 +0800 Subject: [PATCH 5/7] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=E8=B6=85?= =?UTF-8?q?=E7=BA=A7=E8=A1=A8=E6=83=85=E4=B8=8E=E5=A4=9A=E5=85=83=E7=B4=A0?= =?UTF-8?q?=E5=9B=BE=E6=96=87=E6=B7=B7=E6=8E=92=E5=8D=8F=E8=AE=AE=E9=99=8D?= =?UTF-8?q?=E7=BA=A7=E6=9C=BA=E5=88=B6=E6=96=87=E6=A1=A3=E7=B4=A2=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 docs/super-face-protocol.md 总结 serviceType 33 与 37 协议陷阱与防呆规则 - 在 CLAUDE.md 文档索引表中补充对应条目 Co-authored-by: gemini-code-assist <200291788+gemini-code-assist@users.noreply.github.com> --- CLAUDE.md | 1 + docs/super-face-protocol.md | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 docs/super-face-protocol.md diff --git a/CLAUDE.md b/CLAUDE.md index 43ccc31fa..77a732621 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,3 +12,4 @@ | [docs/multi-protocol.md](docs/multi-protocol.md) | 改协议常量 / 加协议端 / 改 --protocol 或 profile / session 命名 / 排查换协议后登录发包问题时 / 处理某端不支持的 cmd (-10122) 时 / 改各端 sign token 取法 (macOS ESK/A2/SA2) 时 | | [docs/session-lifecycle.md](docs/session-lifecycle.md) | 排查"假在线"(在线但收不到消息) / 改登录凭据失效检测 / 心跳 / 掉线监控 / session 存删时 / 改设备 guid (machine_guid.bin) 或从它派生的东西 (session 加密 key / macos_device.json) / 排查顶号或切号后快速登录失效时 / 改掉线重连或二维码自动刷新 (qrLoop / 刷新上限) 时 | | [docs/dev-mode.md](docs/dev-mode.md) | 用 --dev 连本地 manager 联调 / 同步 SignProxy dev 构建 (build:dev-bot) / 改 sign-proxy loader 选哪个 .node、tmpdir 缓存或版本文件 / 改 auth token 文件路径 / 排查实际加载了哪个 .node、"换了 .node 没生效" 或 dev 构建混进 dist 时 | +| [docs/super-face-protocol.md](docs/super-face-protocol.md) | 修改超级表情发送/接收解析、排查图文混排截断或 fallback 降级文本冗余时 | diff --git a/docs/super-face-protocol.md b/docs/super-face-protocol.md new file mode 100644 index 000000000..fd840ab79 --- /dev/null +++ b/docs/super-face-protocol.md @@ -0,0 +1,32 @@ +# 超级表情与多元素图文混排协议备忘 + +## 1. 协议背景与服务类型定义 + +QQ 协议中的表情元素包含多种服务类型(`serviceType`): + +- **普通表情 / 经典小黄脸**:直接通过 `elem.face` 呈现。 +- **行内小表情 (`serviceType: 33`, `QSmallFaceExtra`)**:包含小黄脸、超级表情的行内小表情形态。 +- **全屏超级大表情 (`serviceType: 37`, `LargeFaceExtra`)**:包含带动画的超级大表情(如 `/吃糖`、`/菜汪`)、骰子(Dice)和猜拳(RPS)的互动大动画形态。 + +--- + +## 2. 发送端规则 (MessageBuilding) + +### 2.1 混排降级机制 +- **单体唯一表情 (`inputElems.length === 1`)**: + 构造 `serviceType: 37`(`LargeFaceExtra`),此时客户端正常播放全屏大表情或骰子掷点动画。 +- **图文混排 (`inputElems.length > 1`)**: + **必须降级为 `serviceType: 33`(`QSmallFaceExtra`)**。 + - **原因**:腾讯 QQ 服务端严格禁止在多元素混排消息中包含 `serviceType: 37`,否则会直接拦截并抛出 `retcode: 1200`(发送失败); + - **表现**:官方 QQ 客户端在输入框中混排输入超级表情/互动表情时,底层行为与视觉表现均自动降级为行内小表情形态。 + +--- + +## 3. 接收端规则 (MessageParsing) + +### 3.1 降级文本 (Fallback Text) 处理 +- **腾讯服务端下发逻辑**: + 当用户发送单张超级大表情(`serviceType: 37`)时,为了兼容旧版 QQ / TIM 等老旧客户端,腾讯服务器会在数据包中紧随其后附带一个纯文本元素(例如 `[吃糖]`、`[菜汪]` 或 `[动画表情]`)。 +- **解析与清洗规范**: + - 严禁在解析完 `serviceType: 37` 后直接调用 `break`,否则会中断后续元素遍历,误杀后续真正的聊天消息; + - 应当智能检测紧随其后的下一个元素(`elems[index + 1]`),若其内容匹配该表情的 fallback 降级纯文本(如 `[${faceName}]`、`[动画表情]` 等),则标记 `skipIndex = index + 1` 精准跳过该冗余段。 From 9fba3539b815ca78324f8629d1a64f89163af310 Mon Sep 17 00:00:00 2001 From: VincentZyu233 <1830540513zyu@gmail.com> Date: Wed, 30 Sep 2026 03:47:18 +0800 Subject: [PATCH 6/7] =?UTF-8?q?docs(msg):=20=E8=A1=A5=E5=85=85=E5=8F=91?= =?UTF-8?q?=E9=80=81=E7=AB=AF=E6=B7=B7=E6=8E=92=E9=99=8D=E7=BA=A7=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E4=B8=8E=E5=8D=95=E4=BD=93=E5=90=88=E6=B3=95=E6=80=A7?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 明确 LargeFaceExtra 单体发送约束:仅在消息段长度为 1 时构造 serviceType: 37 大表情 - 阐明混排降级分支原理:具备大表情潜质(faceType: 3)的元素在混排时自动委屈降级为 serviceType: 33 小表情形态,避免腾讯服务端报错与客户端截断 Co-authored-by: gemini-code-assist <200291788+gemini-code-assist@users.noreply.github.com> --- src/ntqqapi/helper/messageBuilding.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/ntqqapi/helper/messageBuilding.ts b/src/ntqqapi/helper/messageBuilding.ts index b8b73800d..16c0e718d 100644 --- a/src/ntqqapi/helper/messageBuilding.ts +++ b/src/ntqqapi/helper/messageBuilding.ts @@ -65,9 +65,8 @@ export class MessageBuilding { }, }) } else if (faceElement.faceType === 3 && this.inputElems.length === 1) { - // 仅在单体唯一表情时构造 serviceType: 37 大表情。 - // 注意:腾讯服务器禁止在图文混排(inputElems.length > 1)中携带 serviceType: 37, - // 否则会直接拒收并抛出 retcode: 1200 错误;官方客户端混排时亦统一降级为小表情形态(serviceType: 33)。 + // 腾讯协议约束:超级大表情 LargeFaceExtra(dice / rps / 超级表情等)必须作为单体消息独立发送(消息段数组长度为 1), + // 此时才视为合法的大表情调用,构造 serviceType: 37;若在图文混排(length > 1)中强传 37,腾讯服务端会拒收并报错 retcode: 1200。 const f = faceElement const pbElem = Msg.LargeFaceExtra.encode({ aniStickerPackId: f.packId ? String(f.packId) : '1', @@ -84,6 +83,9 @@ export class MessageBuilding { }, }) } else if (faceElement.faceType === 2 || faceElement.faceType === 3) { + // 1. faceType === 2: 原生小黄豆扩展表情。 + // 2. faceType === 3: 虽然具有成为大表情的潜质,但由于与其他文字/图片混排(未通过上方 length === 1 的独立发送检验), + // 因此对齐官方 QQ 客户端行为,委屈其降级为小表情形态(serviceType: 33 / QSmallFaceExtra)打包进消息段数组,确保混排消息完整送达且不截断。 const f = faceElement const pbElem = Msg.QSmallFaceExtra.encode({ faceId: f.faceIndex, From 84efc7a2d608e4519a2ea9940ee2f6e6e9052102 Mon Sep 17 00:00:00 2001 From: VincentZyu233 <1830540513zyu@gmail.com> Date: Wed, 30 Sep 2026 03:53:26 +0800 Subject: [PATCH 7/7] =?UTF-8?q?docs(msg):=20=E8=BF=9B=E4=B8=80=E6=AD=A5?= =?UTF-8?q?=E6=B6=A6=E8=89=B2=E5=8F=91=E9=80=81=E7=AB=AF=E6=B7=B7=E6=8E=92?= =?UTF-8?q?=E5=88=A4=E5=AE=9A=E4=B8=8E=E6=B6=88=E6=81=AF=E6=AE=B5=E9=95=BF?= =?UTF-8?q?=E5=BA=A6=E7=BA=A6=E6=9D=9F=E6=B3=A8=E9=87=8A=E6=8E=AA=E8=BE=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 完善图文混排描述:细化为“图文混排等情况(消息段数组的长度 > 1)” - 明确混排对象范围:补充“与其他文字/图片等消息段混排” Co-authored-by: gemini-code-assist <200291788+gemini-code-assist@users.noreply.github.com> --- src/ntqqapi/helper/messageBuilding.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ntqqapi/helper/messageBuilding.ts b/src/ntqqapi/helper/messageBuilding.ts index 16c0e718d..1565668ef 100644 --- a/src/ntqqapi/helper/messageBuilding.ts +++ b/src/ntqqapi/helper/messageBuilding.ts @@ -66,7 +66,7 @@ export class MessageBuilding { }) } else if (faceElement.faceType === 3 && this.inputElems.length === 1) { // 腾讯协议约束:超级大表情 LargeFaceExtra(dice / rps / 超级表情等)必须作为单体消息独立发送(消息段数组长度为 1), - // 此时才视为合法的大表情调用,构造 serviceType: 37;若在图文混排(length > 1)中强传 37,腾讯服务端会拒收并报错 retcode: 1200。 + // 此时才视为合法的大表情调用,构造 serviceType: 37;若在图文混排等情况(消息段数组的长度 > 1)中强传 37,腾讯服务端会拒收并报错 retcode: 1200。 const f = faceElement const pbElem = Msg.LargeFaceExtra.encode({ aniStickerPackId: f.packId ? String(f.packId) : '1', @@ -84,7 +84,7 @@ export class MessageBuilding { }) } else if (faceElement.faceType === 2 || faceElement.faceType === 3) { // 1. faceType === 2: 原生小黄豆扩展表情。 - // 2. faceType === 3: 虽然具有成为大表情的潜质,但由于与其他文字/图片混排(未通过上方 length === 1 的独立发送检验), + // 2. faceType === 3: 虽然具有成为大表情的潜质,但由于与其他文字/图片等消息段混排(未通过上方 length === 1 的独立发送检验), // 因此对齐官方 QQ 客户端行为,委屈其降级为小表情形态(serviceType: 33 / QSmallFaceExtra)打包进消息段数组,确保混排消息完整送达且不截断。 const f = faceElement const pbElem = Msg.QSmallFaceExtra.encode({