Skip to content
Merged
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 降级文本冗余时 |
32 changes: 32 additions & 0 deletions docs/super-face-protocol.md
Original file line number Diff line number Diff line change
@@ -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` 精准跳过该冗余段。
9 changes: 7 additions & 2 deletions src/ntqqapi/helper/messageBuilding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -80,7 +82,10 @@ export class MessageBuilding {
businessType: f.stickerType ?? 1,
},
})
} else if (faceElement.faceType === 2) {
} else if (faceElement.faceType === 2 || faceElement.faceType === 3) {
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
// 1. faceType === 2: 原生小黄豆扩展表情。
// 2. faceType === 3: 虽然具有成为大表情的潜质,但由于与其他文字/图片等消息段混排(未通过上方 length === 1 的独立发送检验),
// 因此对齐官方 QQ 客户端行为,委屈其降级为小表情形态(serviceType: 33 / QSmallFaceExtra)打包进消息段数组,确保混排消息完整送达且不截断。
const f = faceElement
const pbElem = Msg.QSmallFaceExtra.encode({
faceId: f.faceIndex,
Expand Down
17 changes: 16 additions & 1 deletion src/ntqqapi/helper/messageParsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down