diff --git a/CLAUDE.md b/CLAUDE.md index 43ccc31f..77a73262 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 00000000..fd840ab7 --- /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` 精准跳过该冗余段。 diff --git a/src/ntqqapi/helper/messageBuilding.ts b/src/ntqqapi/helper/messageBuilding.ts index 6529e1cc..1565668e 100644 --- a/src/ntqqapi/helper/messageBuilding.ts +++ b/src/ntqqapi/helper/messageBuilding.ts @@ -64,7 +64,9 @@ export class MessageBuilding { businessType: f.faceIndex, }, }) - } else if (faceElement.faceType === 3) { + } else if (faceElement.faceType === 3 && this.inputElems.length === 1) { + // 腾讯协议约束:超级大表情 LargeFaceExtra(dice / rps / 超级表情等)必须作为单体消息独立发送(消息段数组长度为 1), + // 此时才视为合法的大表情调用,构造 serviceType: 37;若在图文混排等情况(消息段数组的长度 > 1)中强传 37,腾讯服务端会拒收并报错 retcode: 1200。 const f = faceElement const pbElem = Msg.LargeFaceExtra.encode({ aniStickerPackId: f.packId ? String(f.packId) : '1', @@ -80,7 +82,10 @@ export class MessageBuilding { businessType: f.stickerType ?? 1, }, }) - } else if (faceElement.faceType === 2) { + } 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, diff --git a/src/ntqqapi/helper/messageParsing.ts b/src/ntqqapi/helper/messageParsing.ts index bbb850cb..786657e0 100644 --- a/src/ntqqapi/helper/messageParsing.ts +++ b/src/ntqqapi/helper/messageParsing.ts @@ -311,7 +311,22 @@ export function parseElements( resultId: ext.resultId, }, }) - break + // 剔除紧随其后的降级文本段(腾讯服务端为兼容老客户端,在下发 serviceType: 37 大表情时会附带 fallback 纯文本段) + // 实测典型案例:/吃糖 会附带 "[吃糖]",/菜汪 会附带 "[菜汪]",骰子附带 "[骰子]" 等 + // 采用 skipIndex 精准跳过此冗余段,避免直接 break 导致后续正常消息内容被截断 + 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({