From 225ab65468a96fe4f83a9397960e1727d3f36ea5 Mon Sep 17 00:00:00 2001 From: H3CoF6 Date: Fri, 31 Jul 2026 19:03:01 +0800 Subject: [PATCH 1/8] chore: update market face csv --- resources/emoji/market.csv | 40 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/resources/emoji/market.csv b/resources/emoji/market.csv index 744ce4b..fb8e5d6 100644 --- a/resources/emoji/market.csv +++ b/resources/emoji/market.csv @@ -25922,3 +25922,43 @@ Some are born to endless night.",1 248069,夜白,一只叫夜白的崽崽 画师,亚麒,1 248070,猫猫龙喵酱,画师@Nichomiazzz,1 248072,核桃桃v2,一只路过的蓝色小猫咪,1 +248074,方菱律动1,沐泞和她们的日常,1 +248075,比噶鳅二,玄隍的小小份表情包2,1 +248076,比噶鳅,玄隍的小小份表情包,1 +248079,熊猫弟弟01,一头来自深山竹林里的野生熊猫。,1 +248080,狐月月1,尾巴很多的矮狐狸,1 +248081,Klau克劳狼,Klau克劳狼的日常表情包,1 +248082,洛水新明2.0,关注洛水新明谢谢喵,1 +248083,简笔画兰特,简笔画兰特狐狐,1 +248084,圆&咪次5,画师:柃,记录圆子和mst的日常,1 +248086,Tockey,Tockey 画师:盐水毛小豆,1 +248087,芒果蛋糕,再见了,所有的芒果,1 +248088,核桃桃v4,一只路过的蓝色小猫咪,1 +248089,莫璇小可爱,B站关注莫璇star,1 +248090,圆&咪次4,画师:柃,圆子和mst日常,1 +248091,小福猩趣味篇64,小福猩趣味篇64,1 +248092,小福猩呆萌篇4,小福猩呆萌篇4,1 +248093,小福猩呆萌篇5,小福猩呆萌篇5,1 +248094,小粉猩活力篇2,小粉猩活力篇2,1 +248095,小福猩活力篇45,小福猩活力篇45,1 +248096,小福猩活力篇47,小福猩活力篇47,1 +248097,小粉猩活力篇3,小粉猩活力篇3,1 +248098,小福猩呆萌篇6,小福猩呆萌篇6,1 +248099,小福猩活力篇46,小福猩活力篇46,1 +248100,库斯开播了,真的假的,1 +248101,蕾米莱娅,支持蕾米莱娅谢谢喵,1 +248102,核桃桃v3副本篇,一只路过的蓝色小猫咪,1 +248103,爱丽丝与彼岸,一起坠入彼岸吧!,1 +248104,猫羽3,猫羽的日常,1 +248105,猫羽2,猫羽的日常,1 +248106,桂木狐桂木,(假装有一段介绍),1 +248107,鳆炤1,b站vup鳆炤,1 +248108,故障机器龙,?!鸡煲 !?,1 +248109,蓝莓列巴第一弹,蓝莓列巴第一弹,1 +248110,海鲜鱼龙(1),扩列幺久三溜溜零四溜漆画师夜兰幽,1 +248111,吃鼠饼的小虎3,b站vup吃鼠饼的小虎,1 +248112,6D6L.(沪),本地特别版,1 +248113,猫耳精灵,精选萌系表情一网打尽,1 +248114,精灵猫娘,梦幻精灵猫娘表情包,1 +248117,是白泠雨狐,B站白泠雨bravo,画师炸虾,1 +248118,方方鼠,方糖好吃爱吃,1 From b9d313a806bc4b8623c7315994f03e9b427db796 Mon Sep 17 00:00:00 2001 From: H3CoF6 Date: Fri, 31 Jul 2026 21:52:14 +0800 Subject: [PATCH 2/8] docs: add 40800 element codec docs --- docs/README.md | 3 +- docs/TODO.md | 99 +++++ docs/database/nt_msg/40800.md | 259 ++++++++++++- docs/database/nt_msg/40900.md | 172 ++++++++- docs/database/nt_msg/elements/ark.md | 197 +++++++++- docs/database/nt_msg/elements/call.md | 48 ++- docs/database/nt_msg/elements/emoji-bounce.md | 29 +- docs/database/nt_msg/elements/face.md | 65 +++- docs/database/nt_msg/elements/file.md | 55 ++- docs/database/nt_msg/elements/gray-tip.md | 349 +++++++++++++++++- docs/database/nt_msg/elements/markdown.md | 68 +++- docs/database/nt_msg/elements/mface.md | 72 +++- docs/database/nt_msg/elements/multi-msg.md | 48 ++- docs/database/nt_msg/elements/online-file.md | 19 +- .../database/nt_msg/elements/online-folder.md | 17 +- docs/database/nt_msg/elements/pic.md | 91 ++++- docs/database/nt_msg/elements/ptt.md | 70 +++- docs/database/nt_msg/elements/qq-dynamic.md | 40 +- docs/database/nt_msg/elements/reply.md | 65 +++- docs/database/nt_msg/elements/text.md | 66 +++- docs/database/nt_msg/elements/video.md | 84 ++++- docs/database/nt_msg/elements/wallet.md | 73 +++- docs/database/nt_msg/index.md | 8 +- 23 files changed, 1910 insertions(+), 87 deletions(-) create mode 100644 docs/TODO.md diff --git a/docs/README.md b/docs/README.md index 71263f8..5809b16 100644 --- a/docs/README.md +++ b/docs/README.md @@ -30,4 +30,5 @@ NTQQ 各数据库表结构与字段解析(并入 [QQBackup](https://github.com --- -> 文档正在持续补充中,欢迎在 [Issue](../../issues) 或 [交流群](https://qm.qq.com/q/ysMZoAcC1a) 反馈。 +> 文档正在持续补充中 —— 编写进度与待办见 [文档待办总目录](./TODO.md)。 +> 欢迎在 [Issue](../../issues) 或 [交流群](https://qm.qq.com/q/ysMZoAcC1a) 反馈。 diff --git a/docs/TODO.md b/docs/TODO.md new file mode 100644 index 0000000..97d9d19 --- /dev/null +++ b/docs/TODO.md @@ -0,0 +1,99 @@ +# 文档待办总目录 + +本页跟踪 `docs/` 下**非使用手册**部分(原理 / 架构 / 数据库分析)的编写进度。 +使用手册(`docs/guide/`)由维护者本人撰写,不在本表范围内。 + +图例:`✅ 已完成` · `🚧 进行中` · `⬜ 待写` + +--- + +## 一、Desktop 架构设计(`docs/develop/`) + +| 状态 | 文档 | 内容要点 | +| ---- | ---- | -------- | +| ⬜ | [architecture.md](./develop/architecture.md) | monorepo 分层:`native → db → codec → service → desktop`,各 package 职责与依赖方向 | +| ⬜ | develop/ipc-trpc.md | 主进程 / 渲染进程边界:tRPC over IPC、router 组织、缓存失效约定 | +| ⬜ | develop/data-flow.md | 一条消息从加密 DB 到界面的完整链路(解密 → 取行 → 解 protobuf → domain → view) | +| ⬜ | [build-release.md](./develop/build-release.md) | 构建、打包、Tag 发版、应用内自动更新 | +| ⬜ | [platform-linux.md](./develop/platform-linux.md) | 跨平台与 Linux 移植现状 | +| ✅ | [testing.md](./develop/testing.md) | `@weq/testkit` 测试约定 | + +## 二、数据库分析(`docs/database/`) + +> 通用表结构指向 [QQBackup/QQDecrypt](https://qqbackup.github.io/QQDecrypt/); +> 这里只维护 WeQ 自己 RE 出来、文档站尚未系统化的深度部分。 + +### `nt_msg.db` 消息体 + +| 状态 | 文档 | 内容要点 | +| ---- | ---- | -------- | +| ✅ | [nt_msg/index.md](./database/nt_msg/index.md) | 两列职责 + 消息段索引 | +| ✅ | [40800.md](./database/nt_msg/40800.md) | ElementWire 信封、tag 分段约定、跨类型共用字段族、容错解码 | +| ✅ | [40900.md](./database/nt_msg/40900.md) | MsgCache 字段表与递归嵌套 | + +### 消息段(element)逐类型字段解析 + +| 状态 | elementType | 文档 | +| ---- | ----------- | ---- | +| ✅ | 1 文本 / @ | [text.md](./database/nt_msg/elements/text.md) | +| ✅ | 2 图片 | [pic.md](./database/nt_msg/elements/pic.md) | +| ✅ | 3 文件 | [file.md](./database/nt_msg/elements/file.md) | +| ✅ | 4 语音 | [ptt.md](./database/nt_msg/elements/ptt.md) | +| ✅ | 5 视频 | [video.md](./database/nt_msg/elements/video.md) | +| ✅ | 6 系统表情 | [face.md](./database/nt_msg/elements/face.md) | +| ✅ | 7 回复引用 | [reply.md](./database/nt_msg/elements/reply.md) | +| ✅ | 8 灰字提示 | [gray-tip.md](./database/nt_msg/elements/gray-tip.md) | +| ✅ | 9 红包 / 转账 | [wallet.md](./database/nt_msg/elements/wallet.md) | +| ✅ | 10 ARK 卡片 | [ark.md](./database/nt_msg/elements/ark.md) | +| ✅ | 11 商城表情 | [mface.md](./database/nt_msg/elements/mface.md) | +| ✅ | 14 Markdown | [markdown.md](./database/nt_msg/elements/markdown.md) | +| ✅ | 16 合并转发 | [multi-msg.md](./database/nt_msg/elements/multi-msg.md) | +| ✅ | 21 通话记录 | [call.md](./database/nt_msg/elements/call.md) | +| ✅ | 23 在线文件 | [online-file.md](./database/nt_msg/elements/online-file.md) | +| ✅ | 26 空间动态 | [qq-dynamic.md](./database/nt_msg/elements/qq-dynamic.md) | +| ✅ | 27 弹射表情 | [emoji-bounce.md](./database/nt_msg/elements/emoji-bounce.md) | +| ✅ | 30 在线文件夹 | [online-folder.md](./database/nt_msg/elements/online-folder.md) | +| ⬜ | 28 位置共享 | 目前只有一个文案字段(52152),暂并入 40800 总览说明 | + +### 其它列 / 其它库 + +| 状态 | 文档 | 内容要点 | +| ---- | ---- | -------- | +| ⬜ | database/nt_msg/row.md | 消息行本身的列(40001/40003/40011/40012/40050/40800…)与「删除 / 撤回」签名 | +| ⬜ | database/nt_msg/40051.md | 会话列表外显预览(PreviewElement + tag 49093) | +| ⬜ | database/nt_msg/40062.md | 消息表情回应(贴表情) | +| ⬜ | database/nt_msg/48902.md | 未读信息块(含特别关心的嵌套结构) | +| ⬜ | database/collection.md | `collection.db` 收藏:type ↔ 子标签公式、8 种类型 | +| ⬜ | database/profile-group.md | `profile_info.db` / `group_info.db` 中 WeQ 用到的 protobuf 列 | + +## 三、QQ 数据库密钥获取原理(`docs/principles/`) + +| 状态 | 文档 | 内容要点 | +| ---- | ---- | -------- | +| ⬜ | [key-extraction.md](./principles/key-extraction.md) | 两条路线总览与取舍 | +| ⬜ | principles/key-nt-helper.md | nt_helper 路线(`nt_helper/src`):原理与关键步骤 | +| ⬜ | principles/key-ninebird.md | ninebird 路线(`nt_helper/ninebird`):原理与关键步骤 | +| ⬜ | [db-decrypt.md](./principles/db-decrypt.md) | 拿到密钥之后:文件头处理、SQLCipher 参数、`login.db` 特例 | +| ⬜ | [native-boundary.md](./principles/native-boundary.md) | native / JS 边界约定与打包坑 | + +## 四、一些小巧思(`docs/principles/`) + +| 状态 | 文档 | 内容要点 | +| ---- | ---- | -------- | +| ⬜ | principles/anti-recall-trigger.md | 防撤回:用 SQLite trigger 拦截 QQ 本体的撤回写入 | +| ⬜ | principles/mface-decrypt.md | 商城表情本地文件的解密(`encryptKey` / 80824) | +| ⬜ | [avatar-hash.md](./principles/avatar-hash.md) | 本地头像文件名的三重 md5 定位公式 | +| ⬜ | principles/msg-delete.md | 「删除消息」的 QQ 原生改法与可恢复设计 | + +--- + +## 编写约定 + +- **只写代码里能直接看出来的东西**:字段解析以 `packages/codec/src/proto/` 的 schema 为准, + 行为描述以实际实现为准;推测性内容必须显式标注「未验证」。 +- **可信度分级**:字段表统一用「置信度」列区分 `已验证` / `观测一致` / `推测`。 +- **枚举可以只进文档**:解析层用不到的 QQ 原生枚举(如视频封装格式 `NTVideoType`、 + JSON 灰条业务 id `JsonGrayBusiId`)不必写进代码,直接记在对应字段文档里即可。 +- 每篇文末保留返回上级的导航链接。 + +[← 返回文档中心](./README.md) diff --git a/docs/database/nt_msg/40800.md b/docs/database/nt_msg/40800.md index a647719..55ec1fa 100644 --- a/docs/database/nt_msg/40800.md +++ b/docs/database/nt_msg/40800.md @@ -1,16 +1,263 @@ # 40800 — 消息正文(MsgBody) -`40800` 是 `c2c_msg_table` / `group_msg_table` / `dataline_msg_table` 中存贮消息正文的列,为 protobuf,结构为可重复的消息段(`ElementWire`)序列——类似富文本,一条消息可包含多个消息段,按内容顺序排列,部分类型可嵌套。 +`40800` 是 `c2c_msg_table` / `group_msg_table` / `dataline_msg_table` 中存贮消息正文的列, +内容是 **protobuf**,结构为可重复的消息段(element)序列 —— 类似富文本:一条消息可包含多个消息段, +按内容顺序排列,部分类型可嵌套。 -> 🚧 骨架待补充。 +对应 WeQ 解析实现: -## 结构概览 +| 文件 | 职责 | +| ---- | ---- | +| `packages/codec/src/proto/msg/40800.ts` | 列本身的外壳(`MsgBody`) | +| `packages/codec/src/proto/msg/element.ts` | 单个消息段的**物理 wire 结构**(`ElementWire`),全部 tag 的唯一事实来源 | +| `packages/codec/src/element/types.ts` | `elementType` / 各 `subType` 的枚举 | +| `packages/codec/src/element/registry.ts` | wire → 语义 `Element` 的分发 | +| `packages/codec/src/element/spec.ts` | 每种 element 的 zod schema(哪些字段必有、哪些可选) | - +--- + +## 一、外壳:列 = 重复的 element + +`40800` 这个 BLOB 本身是一个 protobuf 消息,里面**只有一个字段**:tag 同样为 `40800` 的重复子消息。 +每一个 tag-40800 条目就是一个消息段。 + +```text +40800 (BLOB) +└── 40800 repeated ElementWire ← 每项 = 一个消息段 + ├── [0] elementType=1 文本 + ├── [1] elementType=6 表情 + └── [2] elementType=1 文本 +``` + +C2C 与群聊的 `40800` **结构完全一致**,两者的差异(发送者 uid、会话对端 uid…)在行级列上,不在这一列里。 + +## 二、扁平信封:`elementType` 是判别式,不是 oneof + +这是理解 `40800` 最关键的一点: + +> QQ 并没有把各类型的字段包在各自的子消息里,而是把**所有类型的字段平铺在同一层**, +> 用 `45002 (elementType)` 这个判别式告诉你该看哪一批 tag。 + +也就是说 `ElementWire` 是一个「大而全」的扁平结构:文本的 `45101`、图片的 `45402`、 +表情的 `47601`、商城表情的 `80900` 全都是同一层的兄弟字段。一个具体的 element 只会填写 +其中属于自己类型的那几个,其余留空。 + +带来的直接后果: + +- **解析简单**:一次 decode 就能拿到所有字段,不需要按类型切换 schema。 +- **字段号必须全局唯一**:不同类型不能复用同一个 tag 表达不同含义,否则会打架。 + 这也是 QQ 把 tag 编到 4~5 位数的原因。 +- **共用字段天然复用**:图片 / 文件 / 视频 / 语音都是「一个文件」,于是共享同一批 `454xx/455xx` + 文件族 tag(见下文第五节)。 + +## 三、tag 分段约定 + +`element.ts` 顶部记录的分段规律,按 tag 区间划分归属: + +| tag 区间 | 归属 | +| -------- | ---- | +| `40010..40021` | 信封级元数据(发送方标记、原消息 uid…),与消息行的同名列同义 | +| `45001..45099` | element 通用字段(id / type / subType) | +| `45101..45199` | 文本(TEXT) | +| `45201..45299` | 表情(FACE,历史区段) | +| `45401..45599` | **文件族**:图片 / 文件 / 视频 / 语音 / 在线文件共用的文件元数据 | +| `45801..45899` | 文件族的 URL / 本地缓存路径 / CDN 信息 | +| `45901..45999` | 语音(PTT)专属 + 文件缩略图路径 | +| `47401..47425` | 回复引用(REPLY) | +| `47501..47502` | 灰条 · 临时会话(GRAY_TIP subType=15) | +| `47601..47622` | 表情(FACE) | +| `47702..47716` | 灰条 · 撤回(GRAY_TIP subType=1) | +| `47901..47904` | ARK 卡片 | +| `48101` | 回复引用(补充) | +| `48151..48157` | 通话记录(CALL) | +| `48172..48193` | QQ 动态分享卡(QQ_DYNAMIC) | +| `48210..48275` | 灰条 · JSON / XML(GRAY_TIP subType=17 / 12) | +| `48401..48461` | 红包 / 转账(WALLET) | +| `48501..48542` | 灰条 · 群通知(GRAY_TIP subType=4) | +| `48601..48603` | 合并转发(MULTI_MSG) | +| `48701..48722` | Markdown | +| `49154/49155` | 漫游 / 消息同步标记 | +| `52132..52152` | 弹射表情(EMOJI_BOUNCE)、位置共享(SHARE_LOCATION) | +| `80810..80995` | 商城表情(MFACE),完全独立的一段 | + +> ⚠️ 这个规律是**观测归纳**,不是 QQ 的正式约定。大多数区段吻合,但也有例外 +> (如 FACE 同时用了 `45004` 和 `476xx`,REPLY 用了 `40020/40021` 这两个信封级 tag)。 + +## 四、每个 element 都有的通用字段 + +| tag | 字段名 | 类型 | 含义 | 置信度 | +| --- | ------ | ---- | ---- | ------ | +| 40010 | `isSender` | bool | **本机**是否发出者。注意语义很窄:只有「这台设备按下发送」才为 true;别人发来的、以及本账号其它端发的都不带此字段 | 已验证 | +| 45001 | `elementId` | uint64 | 消息段序号 | 已验证 | +| 45002 | `elementType` | uint32 | 类型判别式,取值见下表 | 已验证 | +| 45003 | `subType` | uint32 | 子类型,语义**取决于 `elementType`**(同一个数字在不同类型下含义完全不同) | 已验证 | + +另有两个「读出来只为完整性」的信封级 tag,不属于任何 element: + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 49154 | `roaming` | bytes | 漫游标记 | +| 49155 | `msgSyncFlag` | uint64 | 消息同步时间戳 | + +### `elementType` 全表 + +值来自 QQ NT 自身的 `NTMsgElementType` 枚举。标注「未观测」的表示本地样本库中从未出现, +声明出来只是为了将来遇到时有名字可对,而不是一个裸数字。 + +| 值 | 名称 | 说明 | 文档 | +| -- | ---- | ---- | ---- | +| 0 | UNKNOWN | 占位(未观测) | — | +| 1 | TEXT | 文本 / @ | [text](./elements/text.md) | +| 2 | PIC | 图片 | [pic](./elements/pic.md) | +| 3 | FILE | 文件 | [file](./elements/file.md) | +| 4 | PTT | 语音 | [ptt](./elements/ptt.md) | +| 5 | VIDEO | 视频 | [video](./elements/video.md) | +| 6 | FACE | 系统表情 | [face](./elements/face.md) | +| 7 | REPLY | 回复引用 | [reply](./elements/reply.md) | +| 8 | GRAY_TIP | 小灰条(撤回 / 拍一拍 / 入群…),语义由 subType 决定 | [gray-tip](./elements/gray-tip.md) | +| 9 | WALLET | 红包 / 转账 | [wallet](./elements/wallet.md) | +| 10 | ARK | ARK 卡片 | [ark](./elements/ark.md) | +| 11 | MFACE | 商城表情 | [mface](./elements/mface.md) | +| 12 | LIVE_GIFT | 直播礼物(未观测) | — | +| 13 | STRUCT_LONG_MSG | 结构化长消息(未观测) | — | +| 14 | MARKDOWN | Markdown | [markdown](./elements/markdown.md) | +| 15 | GIPHY | Giphy 动图(未观测) | — | +| 16 | MULTI_MSG | 合并转发(QQ 内部名 MULTIFORWARD) | [multi-msg](./elements/multi-msg.md) | +| 17 | INLINE_KEYBOARD | 内联键盘 / 机器人按钮(未观测) | — | +| 18 | IN_TEXT_GIFT | 文字内嵌礼物(未观测) | — | +| 19 | CALENDAR | 日程(未观测) | — | +| 20 | YOLO_GAME_RESULT | YOLO 小游戏结果(未观测) | — | +| 21 | CALL | 音视频通话记录(QQ 内部名 AVRECORD) | [call](./elements/call.md) | +| 22 | FEED | 动态 Feed(未观测) | — | +| 23 | ONLINE_FILE | 在线文件(QQ 内部名 TOFURECORD) | [online-file](./elements/online-file.md) | +| 24 | ACE_BUBBLE | 气泡(未观测) | — | +| 25 | ACTIVITY | 活动卡片(未观测) | — | +| 26 | QQ_DYNAMIC | QQ 动态分享卡(QQ 内部名 TOFU) | [qq-dynamic](./elements/qq-dynamic.md) | +| 27 | EMOJI_BOUNCE | 表情弹射(QQ 内部名 FACEBUBBLE) | [emoji-bounce](./elements/emoji-bounce.md) | +| 28 | SHARE_LOCATION | 位置共享 | 见下方 | +| 29 | TASK_TOP_MSG | 任务置顶消息(未观测) | — | +| 30 | ONLINE_FOLDER | 在线文件夹 | [online-folder](./elements/online-folder.md) | +| 43 | RECOMMENDED_MSG | 推荐消息(未观测) | — | +| 44 | ACTION_BAR | 操作栏(未观测) | — | + +**elementType=28(位置共享)** 字段极少,不单独成篇: + +| tag | 字段名 | 类型 | 必有 | 含义 | +| --- | ------ | ---- | ---- | ---- | +| 52152 | `shareLocationText` | string | ✅ | 展示文案,如「发起了位置共享」 | + +## 五、跨类型共用的「文件族」字段 + +图片 / 文件 / 视频 / 语音 / 在线文件在 QQ 眼里都是「一个待传输的文件」, +因此共用同一批 tag。理解这一批,五种类型就都懂了一半。 + +### 基本元数据 + +| tag | 字段名 | 类型 | 含义 | 用于 | +| --- | ------ | ---- | ---- | ---- | +| 45402 | `fileName` | string | 文件名 | PIC / FILE / VIDEO / PTT / ONLINE_FILE | +| 45403 | `filePath` | string | 本地文件路径 | PTT / FILE / ONLINE_FILE | +| 45405 | `fileSize` | uint32 | 字节数 | 全部 | +| 45406 | `md5Bytes` | bytes | 二进制 MD5 | 全部 | +| 45407 | `md5Bytes2` | bytes | 第二份 MD5,作用与 45406 相同 | FILE | +| 45408 | `contentHash` | bytes | 内容校验 hash | 全部 | +| 45424 | `md5` | string | 大写十六进制 MD5 字符串 | PIC / PTT | +| 45411 / 45412 | `imgWidth` / `imgHeight` | uint32 | 宽 / 高(像素) | PIC / FILE / VIDEO / ONLINE_FILE | +| 45418 | `isOriginal` | bool | 是否原图 / 原画质 | PIC / VIDEO / PTT | + +### 传输与下载 + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 45503 | `fileToken` | string | 下载凭据 | +| 45505 | `uploadTime` | uint32 | 上传 / 处理时间戳 | +| 45510 | `videoToken` | string | 下载 token(VIDEO / FILE) | +| 45511 | `picTransferState` | uint32 | 传输状态 | +| 45513 | `transferVersion` | uint32 | 传输版本 | +| 45517 | `uploadTimestamp` | uint32 | 上传时间戳 | +| 45518 | `fileTTL` | uint32 | 有效期(秒) | +| 45550 | `transferState` | uint32 | 传输状态(PTT / FILE) | +| 45554 | `transferErrorText` | string | 传输失败文案,如「传输失败,请稍后重试」 | + +### URL / 本地缓存路径 + +QQ 对同一张图存了**三档尺寸**,每档都有一个远端 URL 和一个本地缓存路径,两两对应: + +| 档位 | 远端 URL | 本地缓存路径 | 本地文件名后缀 | +| ---- | -------- | ------------ | -------------- | +| 缩略图 | 45802 `thumbnailUrl` | 45812 `thumbnailLocalPath` | `…_0.jpg` | +| 预览图 | 45803 `previewUrl` | 45813 `previewLocalPath` | `…_198.jpg` | +| 大图 | 45804 `originalUrl` | 45814 `originalLocalPath` | `…_720.jpg` | + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 45806 | `cdnServerIp` | uint32 | CDN 服务器地址,**大端打包的 IPv4**(如 `3082863821` → `183.192.196.205`)。为 0 时表示应由客户端自行解析 `cdnHost` | +| 45807 | `cdnServerPort` | uint32 | CDN 端口。观测到 80 / 443 / 8080 / 14000 / 57897 / 0 | +| 45815 | `summary` | string(repeated) | 摘要 / 描述文案 | +| 45816 | `cdnHost` | string | CDN 域名 | + +### 近似常量的传输标记 + +| tag | 字段名 | 说明 | +| --- | ------ | ---- | +| 45507 | `transferFlag45507` | 只有按**有符号 int64** 解释才有意义:几乎恒为 `18446744073704048574`(即 `-5503042`)。声明成 INT64 是为了让 varint 能原样回写,而不被截断成 32 位 | +| 45509 | `transferFlag45509` | 只要 45507 存在就恒为 1 | + +> 45507 / 45509 总是成对出现,PIC、FILE、VIDEO 上都能看到。 + +## 六、容错解码:`sanitizeBytes` + +`40800` 里有大量 tag 的类型是**逆向猜出来的**,猜错会有真实代价: + +protobuf-ts 解码一个「已知」字段时,只按 schema 里**声明的类型**读,不校验实际 wire type。 +所以若某个 tag 被声明成了 `uint32`(varint),实际却是长度分隔的 blob, +解码器就会读错字节数、游标错位,进而**整条消息 decode 抛异常** —— 丢的不是一个字段,是整条消息。 + +WeQ 的解法是在 decode 前加一道 `sanitizeBytes`(`packages/codec/src/raw/sanitize.ts`): + +1. 纯 wire 级遍历 buffer(始终按**真实** wire type 走,因此不可能读错); +2. 逐字段比对「实际 wire type」与「schema 声明类型」,**冲突就丢弃该字段**; +3. 声明为 `string` 但字节不是合法 UTF-8 的也丢弃 —— protobuf-ts 用的是 fatal 的 `TextDecoder`, + 否则同样会炸掉整条消息; +4. 嵌套消息递归处理;未知 tag 原样保留(protobuf-ts 会安全跳过); +5. 尾部损坏 / 截断时,保留已累积的合法前缀。 + +结果是:猜错类型的字段只会「消失」,而不会让整条消息渲染失败。 + +> 注意范围:只丢弃**真正的 wire type 冲突**。varint 数值超出声明范围的不动它 —— +> protobuf-ts 会无害地截断,丢掉反而会损失调用方可能需要的数据。 + +## 七、语义化:从 wire 到 `Element` + +`registry.ts` 里的 `decodeElement` 把扁平 wire 结构映射成带 `kind` 的语义对象。由于 +element 接口的字段名与 wire schema 的字段名**完全一致**,这层薄到只剩分发: + +```text +decode: 按 elementType 查出 kind → 展开 wire 全部字段 + 补一个 kind +encode: 去掉 kind → 展开字段 + 由 kind 反推回 elementType +``` + +两处例外值得注意: + +- **`text` 与 `at` 共用 elementType=1**。区分方式是看 `45105 (bubbleId)` 有没有值: + 有值即为 @,其中 `bubbleId` 装的是被 @ 者的 uid、`45103 (textEncodingFlag)` 装 uin。 + (字段名是历史遗留,与聊天「气泡」无关。) +- **`GRAY_TIP` 一个 elementType 对应 6 种 kind**,靠 `subType` 再分一次(见 [gray-tip](./elements/gray-tip.md))。 + +无法识别的 `elementType` 会被包成 `UnknownElement`,原始 wire 完整保留在 `raw` 里, +保证「读出来再写回去」字节不丢。 + +--- + +## 字段表约定 -## 消息段字段 +各消息段文档的字段表统一使用以下列: -各 `elementType` 的具体字段解析见 [消息段索引](./index.md#消息段element索引)。 +- **tag** — protobuf 字段号 +- **字段名** — `element.ts` 中的名字(同时也是 `Element` 对象上的属性名) +- **类型** — wire 类型 +- **必有** — 该类型的 element 是否必带此字段(依据 `spec.ts` 的 zod schema) +- **含义** — 名字里带 `flagNNNNN` 的表示**语义未知**,仅为往返保真而解析 --- diff --git a/docs/database/nt_msg/40900.md b/docs/database/nt_msg/40900.md index fc49895..d7319fd 100644 --- a/docs/database/nt_msg/40900.md +++ b/docs/database/nt_msg/40900.md @@ -1,19 +1,175 @@ # 40900 — 消息缓存(MsgCache) -`40900` 存贮转发 / 引用场景下缓存的源消息快照,为 protobuf,结构为可重复的 `MsgCache` 记录,且可递归嵌套(转发中套转发)。 +`40900` 存贮转发 / 引用场景下缓存的**源消息快照**,为 protobuf,结构为可重复的 `MsgCache` 记录, +且可递归嵌套(转发里套转发)。 + +对应 WeQ 解析实现:`packages/codec/src/proto/msg/40900.ts`。 以列 `40011`(msgType)区分用途: -| 40011 值 | 含义 | 40900 内容 | -| -------- | -------- | -------------------------- | -| 8 | 合并转发 | 被转发的源消息缓存 | -| 9 | 回复引用 | 被引用的源消息缓存 | +| 40011 值 | 含义 | 40900 内容 | +| -------- | ---- | ---------- | +| 8 | 合并转发 | 被转发的源消息缓存 | +| 9 | 回复引用 | 被引用的源消息缓存 | + +--- + +## 一、与 40800 的关系 + +这是理解 `40900` 的入口: + +- **`40800` 是「一条消息的正文」** —— 只有 element 列表,没有身份、没有时间、没有发送者。 +- **`40900` 是「一整行消息的自包含快照」** —— 身份(msgId / seq / random)、路由(收发双方 uid + uin)、 + 时间、状态、发送者昵称头像,**外加那条消息的 40800 element 列表本身**。 + +换句话说:`MsgCache` ⊃ `MsgBody`。`MsgCache` 里 tag 为 `40800` 的字段, +就是原样的 `repeated ElementWire`,与 `40800` 列里的东西**结构完全一致**。 + +```text +40900 (BLOB) +└── 40900 repeated MsgCache ← 每项 = 一条被缓存的完整消息 + ├── 40001 msgId / 40003 msgSeq / 40050 sendTime / … (行级标量) + ├── 40600 senderInfo (发送者展示信息) + ├── 40800 repeated ElementWire ← 这条消息的正文,同 40800 列 + └── 40900 repeated MsgCache ← 递归:它自己缓存的源消息 +``` + +## 二、递归嵌套 + +当 `msgType` 为 `MULTI_FORWARD (8)` 或 `REPLY (9)` 时,这条消息会把它所缓存的源消息 +挂在 tag `40900` 下(repeated)。而那些条目**本身也是完整的 `MsgCache`**,可以再挂 `40900` —— +即结构是任意深的。 + +典型场景:把一段「合并转发」再引用回复一次,就会出现两层嵌套。 + +> 实现上,`subMsgs` 字段用 `(): ProtoMessageType => MsgCache` 这个显式返回类型的 thunk +> 打断自引用的类型推导循环。运行时是惰性解析的,调用 thunk 时 `MsgCache` 早已定义完毕。 + +## 三、字段表 + +### 身份与序号 + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 40001 | `msgId` | int64 | 消息 id(雪花 id) | +| 40002 | `msgRandom` | uint32 | 消息随机数,用于去重 | +| 40003 | `msgSeq` | uint32 | 消息序列号 | + +### 类型与状态 + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 40010 | `isSender` | bool | 是否本机发送(语义同 `ElementWire.isSender`) | +| 40011 | `msgType` | uint32 | 消息类型,见下方 `MsgType` | +| 40012 | `msgSubType` | uint32 | 消息子类型 | +| 40013 | `sendType` | uint32 | 消息来源,见下方 `SendType` | +| 40041 | `sendStatus` | uint32 | 发送状态,见下方 `SendStatus` | + +### 收发双方 + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 40020 | `senderUid` | string | 发送者 uid | +| 40021 | `peerUid` | string | 会话对端 uid | +| 40033 | `senderUin` | uint32 | 发送者 QQ 号 | +| 40093 | `sendNick` | string | 发送者昵称 | + +### 时间 + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 40050 | `sendTime` | uint32 | 发送时间,unix 秒 | +| 40058 | `sendDayStartTime` | uint32 | 发送当天 00:00 的时间戳,unix 秒 | + +### 内容与嵌套 + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 40600 | `senderInfo` | message | 发送者展示信息,见下方嵌套结构 | +| 40800 | `elements` | repeated ElementWire | **消息正文**,结构与 [40800 列](./40800.md) 完全一致 | +| 40801 | `proto40801` | bytes | 低价值嵌套 protobuf,保留为不透明字节 | +| 40802 | `proto40802` | bytes | 同上 | +| 40900 | `subMsgs` | repeated MsgCache | **递归**:被缓存的源 / 引用消息 | + +### 语义未知的标量 + +以下 tag 为往返保真而解析,语义未定,字段名统一为 `flagNNNNN`(均为 uint32): + +`40005` · `40006` · `40008` · `40009` · `40016` · `40105` + +## 四、嵌套结构 + +### 发送者展示块(tag 40600) + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 42261 | `flag42261` | uint32 | 未知 | +| 42341 | `avatar` | message | 头像信息,见下 | + +### 头像信息(tag 42341,位于 40600 内) + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 42342 | `flag42342` | uint32 | 未知 | +| 42343 | `flag42343` | uint32 | 未知 | +| 42344 | `avatarType` | uint32 | 头像图片类型 | +| 42345 | `encryptedUin` | string | 头像加密 uin | +| 42346 | `avatarUrl` | string | 头像加密外链 URL | + +## 五、枚举 + +### `MsgType`(tag 40011) + +其中 8 / 9 已在真实数据上确认;其余镜像自 QQ NT 的 `KMSGTYPE*` 表,视作尽力而为。 + +| 值 | 名称 | 说明 | +| -- | ---- | ---- | +| 0 | UNKNOWN | | +| 1 | NULL | QQ 撤回 / 删除消息时会把**行级** 40011 改写为 1 | +| 2 | MIX | 图文混排(普通消息的常见值) | +| 3 | FILE | | +| 4 | STRUCT | | +| 5 | GRAY_TIP | 小灰条 | +| 6 | PTT | | +| 7 | VIDEO | | +| 8 | MULTI_FORWARD | **合并转发**(已确认) | +| 9 | REPLY | **引用回复**(已确认) | +| 10 | WALLET | | +| 11 | ARK_STRUCT | | +| 12 | STRUCT_LONG_MSG | | +| 13 | GIPHY | | +| 14 | GIFT | | +| 15 | TEXT_GIFT | | +| 21 | ONLINE_FILE | | +| 24 | FACE_BUBBLE | | +| 25 | SHARE_LOCATION | | +| 27 | ONLINE_FOLDER | | +| 29 | PROLOGUE | | + +### `SendType`(tag 40013) + +消息相对于**本设备 / 本账号**的来源。 + +| 值 | 名称 | 说明 | +| -- | ---- | ---- | +| 0 | RECEIVED | 别人发来的消息 | +| 1 | LOCAL | 本机发送 | +| 2 | OTHER_CLIENT | 本账号的其它客户端发送 | +| 5 | FORWARD | 转发产生的消息 | + +> 这几档解释了为什么 `isSender`(40010)语义那么窄:只有 `LOCAL` 才置位; +> `OTHER_CLIENT` 虽然也是「我发的」,却不带 `isSender`。 -> 🚧 骨架待补充。 +### `SendStatus`(tag 40041) -## 结构概览 +注意取值**不与** NapCat 的 `SendStatusType` 命名一一对应,下列语义来自实际观测。 - +| 值 | 名称 | 说明 | +| -- | ---- | ---- | +| 0 | BLOCKED | 发送被阻止(如不是对方好友) | +| 1 | PENDING | 尚未发送成功(如网络问题) | +| 2 | SUCCESS | 发送成功 | +| 3 | BANNED | 消息被 QQ 封禁 | --- diff --git a/docs/database/nt_msg/elements/ark.md b/docs/database/nt_msg/elements/ark.md index 784b8aa..283b403 100644 --- a/docs/database/nt_msg/elements/ark.md +++ b/docs/database/nt_msg/elements/ark.md @@ -1,12 +1,201 @@ # elementType 10 — ARK 卡片 -> 🚧 骨架待补充。 - 对应 WeQ 解析实现:`packages/codec/src/proto/msg/element.ts`(`elementType = 10`)。 -## 字段 +ARK 是 QQ 的结构化卡片消息:分享链接、小程序、群公告卡、游戏中心广告等等, +渲染样式由服务端下发的模板决定。 + +wire 层极其简单——**只有三个字段**,真正的内容全在一段 JSON 字符串里。 + +--- + +## 一、字段 + +| tag | 字段名 | 类型 | 必有 | 含义 | +| --- | ------ | ---- | ---- | ---- | +| 47901 | `arkData` | string | ✅ | **卡片 JSON 负载**(UTF-8 字符串装的 JSON 文档) | +| 47902 | `arkSignature` | string | | 64 字符 base64 卡片签名,与 JSON 里的 `config.token` 配对 | +| 47904 | `arkCardId` | string | | 卡片实例 UUID,如 `33cad47c-09f0-42a6-ab42-6329c0898765` | + +## 二、`arkData` 的 JSON 结构 + +顶层形状固定(`ArkPayload`),但 `meta` 的内部结构**随 `view` 而变**—— +这是解析 ARK 的关键:先看 `view`,再决定怎么读 `meta`。 + +```ts +interface ArkPayload { + app: string; // 应用标识,如 "com.tencent.gamecenter.mall" + desc: string; // 描述,如 "QQ手游消息" + meta: Record>; // 形状取决于 view + prompt: string; // 会话列表外显文案 + sourceName?: string; + ver?: string; + view: string; // 模板名 —— 决定 meta 的形状 + config?: { + ctime: number; // unix 秒 + token: string; // 卡片签名 token + }; +} +``` + +典型读法: + +```ts +const payload = JSON.parse(el.arkData) as ArkPayload; +if (payload.view === 'pubAdArkView') { + const t = payload.meta.template3 as Record; + // ... +} +``` + +## 三、样例:`view: "pubAdArkView"` + +代码里保留了一份实际抓取的完整样例 +(`packages/codec/src/element/ark.ts` 的 `SAMPLE_GAME_CENTER_AD`), +是推送进聊天的 QQ 游戏中心广告,`meta` 下挂 `template3`: + +| 字段 | 说明 | +| ---- | ---- | +| `arkType` | 具体子模板,如 `pubSinglePicArk` | +| `title` / `contentText` | 标题 / 正文 | +| `coverUrl` | 封面图 | +| `url` | 点击跳转地址 | +| `appid` / `adId` / `actId` / `feedId` | 各类业务 id | +| `time` | 时间戳(字符串形式) | +| `__preloadFields` | 客户端预加载提示,值为需要预取的字段名 | + +> 想支持新的卡片样式,就在 `ark.ts` 里再加一个样例常量:把逆向出来的 `view` +> 与对应 `meta` 形状固化下来,避免每次都重新猜。 + +--- + +## 四、渲染:`app` 才是真正的判别式 + +上面说 `view` 决定 `meta` 的形状,那是从 JSON 文档自身看。但**渲染**这一侧, +WeQ 的判别式是 `app`(如 `com.tencent.structmsg`)而不是 `view`—— +因为 QQ 官方的 ark 资源包本身就是按 app 组织的。 + +渲染链路(`apps/desktop/src/renderer/src/components/ark/`): + +| 文件 | 职责 | +| ---- | ---- | +| `ark-cards.generated.json` | **机械提取**的绑定表:`app → metaKey → { jump, slots, bindings }` | +| `arkCards.ts` | 在其上叠加**人工策展**(布局归类 + 标准字段兜底),产出渲染器直接消费的 `ArkValues` | +| `QqArk.tsx` | 按布局类型渲染 | + +### 数据从哪来 + +`ark-cards.generated.json` 由 `scripts/extract-ark-cards.mjs` 从 QQ 官方 ark 资源包 +(`resources/arks_resource//<时间戳>/index.js`)机械提取: + +- 每个 `index.js` 用 `_setViewTemplate('', \`\`)` 注册布局模板; +- 用 Lua 的 `ViewModel:OnSetMetadata(value)` 定义 `data["字段"] → self.<节点>` 的绑定。 + +脚本只抽取静态渲染需要的「排版 + 字段绑定」,运行时 Lua 逻辑、网络请求、动效一律忽略。 + +> ⚠️ 原始资源包约 25MB,提取完成后已从仓库删除,只保留脚本与生成的 JSON。 +> 需要重新生成时得先把 ark 包放回 `resources/arks_resource/` 再跑 +> `node scripts/extract-ark-cards.mjs`。 + +### 生成 JSON 的结构 + +```jsonc +{ + "com.tencent.music.lua": { + "defaultMetaKey": "music", // payload 没命中已知变体时的兜底 + "variants": { + "music": { + "jump": "jumpUrl", // 点击跳转取自 meta 的哪个字段 + "slots": { // 自动归一化的「语义槽 → meta 字段」 + "title": "title", + "desc": "desc", + "thumb": "preview", + "sourceIcon": "tagIcon" + }, + "bindings": { // 原始的「模板节点 id → meta 字段」,未归一化 + "titleView": "title", + "descView": "desc", + "background": "preview", + "tagIcon": "tagIcon" + } + } + } + } +} +``` + +`slots` 与 `bindings` 的区别:`bindings` 是从 Lua 里原样抠出来的节点绑定, +`slots` 是在其之上自动归一化出的语义槽位(长尾 app 的兜底); +常见 app 的权威槽位在 `arkCards.ts` 里手写覆盖。 + +### 已收录的 16 个 app + +`defaultMetaKey` 是 payload 的 `meta` 里没有任何已知变体时的兜底选择。 + +| app | 默认 metaKey | 变体 | +| --- | ------------ | ---- | +| `com.tencent.contact.lua` | contact | contact | +| `com.tencent.miniapp.lua` | miniapp | miniapp | +| `com.tencent.mobileqq.cardshare` | contact | contact | +| `com.tencent.music.lua` | music | music | +| `com.tencent.od` | pic | pic, news, music, video, contact, messages, miniapp | +| `com.tencent.qidian.general` | pic | pic, transfercontact | +| `com.tencent.qqgxh.general` | pic | pic, news, music, video, contact, messages, miniapp | +| `com.tencent.qun.invite` | pic | pic, news, music, video, contact, messages, miniapp | +| `com.tencent.structmsg` | news | news, music, video, contact, messages | +| `com.tencent.tdoc.qqpush` | pic | pic, news, music, video, contact, messages, miniapp | +| `com.tencent.template.qqfavorite.share` | news | news | +| `com.tencent.tianxuan.share` | pic | pic, news, music, video, contact, messages, miniapp | +| `com.tencent.together` | invite | invite | +| `com.tencent.troopsharecard` | pic | pic, news, music, video, contact, messages, miniapp | +| `com.tencent.tuwen.lua` | news | news | +| `com.tencent.weishi.public.share` | pic | pic, news, music, video, contact, messages, miniapp | + +### 布局归类 + +布局**不进** JSON —— 刻意保持「机械提取的数据」与「人工策展」分离。 +`arkCards.ts` 里有两张表,按下面的优先级决定用哪种布局: + +1. **`APP_LAYOUT`** — 单一用途的 app 直接钉死布局(覆盖 metaKey 推断): + + | app | 布局 | + | --- | ---- | + | `com.tencent.miniapp.lua` | appBlock | + | `com.tencent.contact.lua` | contact | + | `com.tencent.mobileqq.cardshare` | contact | + | `com.tencent.music.lua` | news | + | `com.tencent.tuwen.lua` | news | + | `com.tencent.together` | mediaBlock | + +2. **`METAKEY_LAYOUT`** — 多模板分享类 app(structmsg / troopsharecard / …)按变体名推断: + + | metaKey | 布局 | + | ------- | ---- | + | news / music / video / messages / pic | news | + | contact / transfercontact | contact | + | miniapp | appBlock | + | invite | mediaBlock | + +3. 两张表都没命中 → `generic`(仍然带槽位值,好过纯猜)。 + +### 语义槽位 + +归一化后交给渲染器的字段(`ArkValues`): + +| 槽位 | 含义 | +| ---- | ---- | +| `title` / `desc` / `summary` | 标题 / 描述 / 摘要 | +| `thumb` | 小方缩略图 | +| `cover` | 通栏大图 | +| `name` / `avatar` | 名称 / 头像(联系人类卡片) | +| `source` / `sourceIcon` | 顶部主来源标签文字 / 图标 | +| `footerSource` / `footerIcon` | 底部来源标签(部分卡片顶底各有一个来源,如小程序顶=来源、底=「QQ小程序」) | +| `button` | 按钮文案 | +| `jump` | 点击跳转地址 | - +槽位没覆盖到的,再用 QQ 通用字段名兜底(各卡字段命名高度一致), +按顺序尝试,例如 `desc` 依次试 `desc` → `digest` → `contactInfo` → `contact` → `address`, +`jump` 依次试 `jumpUrl` → `qqdocurl` → `url`。 --- diff --git a/docs/database/nt_msg/elements/call.md b/docs/database/nt_msg/elements/call.md index 0d717d7..35253f4 100644 --- a/docs/database/nt_msg/elements/call.md +++ b/docs/database/nt_msg/elements/call.md @@ -1,12 +1,52 @@ # elementType 21 — 通话记录 -> 🚧 骨架待补充。 - 对应 WeQ 解析实现:`packages/codec/src/proto/msg/element.ts`(`elementType = 21`)。 -## 字段 +音视频通话记录(QQ 内部名 AVRECORD):语音通话、视频通话、屏幕共享、远程协助的结果条目。 + +--- + +## 一、字段 + +| tag | 字段名 | 类型 | 必有 | 含义 | +| --- | ------ | ---- | ---- | ---- | +| 48151 | `answerType` | uint32 | ✅ | 接听 / 挂断类型,与 `subType` 一致,取值见 `CallSubType` | +| 48152 | `duration` | uint32 | ✅ | **通话时长(毫秒)** | +| 48154 | `callMethod` | uint32 | ✅ | **通话方式**,见下表 | +| 48157 | `callSummary` | string(repeated) | ✅ | 通话摘要文案 | +| 48153 | `callFlag48153` | string | | 协议标志(长度分隔) | +| 48155 | `callUnknownType` | uint32 | | 未知类型标志。观测到 0 / 1 / 2 或缺失 | +| 48156 | `callFlag48156` | uint32 | | 协议标志 | + +> ⚠️ `48152` 的单位是**毫秒**,与语音元素的 `45906`(秒)不同。 + +## 二、`callMethod`(48154) + +| 值 | 名称 | 说明 | +| -- | ---- | ---- | +| 1 | VOICE | 语音通话 | +| 2 | VIDEO | 视频通话 | +| 3 | SCREEN_SHARE | 屏幕共享 | +| 5 | REMOTE_ASSIST | 远程协助 | + +## 三、`subType` / `answerType` + +`45003 (subType)` 与 `48151 (answerType)` 取值一致,合起来描述「什么类型的通话、以什么方式结束」: - +| 值 | 名称 | 说明 | +| -- | ---- | ---- | +| 2 | VIDEO_ACCEPTED | 视频通话已接通 | +| 3 | VIDEO_REJECTED_BY_US | 视频通话被本方拒绝 | +| 6 | VIDEO_REJECTED_BY_PEER | 视频通话被对方拒绝 | +| 7 | VOICE_ACCEPTED | 语音通话已接通 | +| 8 | VOICE_REJECTED_BY_US | 语音通话被本方拒绝 | +| 11 | VOICE_REJECTED_BY_PEER | 语音通话被对方拒绝 | +| 12 | VIDEO_HANDLED_OTHER_DEVICE | 视频通话已在其它设备处理 | +| 13 | VOICE_HANDLED_OTHER_DEVICE | 语音通话已在其它设备处理 | +| 19 | SCREEN_SHARE_ACCEPTED | 屏幕共享已接通 | +| 22 | SCREEN_SHARE_REJECTED | 屏幕共享被拒绝 | +| 33 | REMOTE_ASSIST_ACCEPTED | 远程协助已接通 | +| 34 | REMOTE_ASSIST_FAILED | 远程协助失败 | --- diff --git a/docs/database/nt_msg/elements/emoji-bounce.md b/docs/database/nt_msg/elements/emoji-bounce.md index e375d20..1474346 100644 --- a/docs/database/nt_msg/elements/emoji-bounce.md +++ b/docs/database/nt_msg/elements/emoji-bounce.md @@ -1,12 +1,33 @@ # elementType 27 — 弹射表情 -> 🚧 骨架待补充。 - 对应 WeQ 解析实现:`packages/codec/src/proto/msg/element.ts`(`elementType = 27`)。 -## 字段 +表情弹射(QQ 内部名 FACEBUBBLE)—— 会「弹进」聊天窗口的动画表情。tag 段为 `521xx`。 + +--- + +## 一、字段 + +下列字段在 EMOJI_BOUNCE element 上都是必有的。 + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 52132 | `emojiBounceId` | uint32 | 弹射表情 id | +| 52133 | `emojiBounceFlag52133` | bool | 未知布尔标志 | +| 52134 | `emojiBounceName` | string | 弹射表情名称 | +| 52137 | `emojiBounceDetail` | message | 嵌套详情,见下 | +| 52138 | `emojiBounceTextSummary` | string | **文本总结(含弹射个数)** | +| 52139 | `emojiBouncePcText` | string | 电脑端显示文本 | + +## 二、`emojiBounceDetail`(52137) + +冗余地重复了一份表情名称与文本摘要: - +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 52142 | `flag52142` | uint32 | 未知 | +| 52143 | `name` | string | 名称(同 52134) | +| 52144 | `textSummary` | string | 文本摘要(同 52138) | --- diff --git a/docs/database/nt_msg/elements/face.md b/docs/database/nt_msg/elements/face.md index d1a4c40..7ee362a 100644 --- a/docs/database/nt_msg/elements/face.md +++ b/docs/database/nt_msg/elements/face.md @@ -1,12 +1,69 @@ # elementType 6 — 系统表情 -> 🚧 骨架待补充。 - 对应 WeQ 解析实现:`packages/codec/src/proto/msg/element.ts`(`elementType = 6`)。 -## 字段 +QQ 自带表情,包含普通小黄脸、超级表情、互动表情。 +(**商城表情**是另一个类型,见 [mface](./mface.md)。) + +--- + +## 一、subType + +| 值 | 名称 | 说明 | +| -- | ---- | ---- | +| 1 | QQ_BUILTIN_OLD | 旧版内置表情 | +| 2 | QQ_BUILTIN_NEW | 新版内置表情 | +| 3 | SUPER_EMOJI | 超级表情(会带 `476xx` 的超级表情字段) | +| 4 | UNKNOWN_4 | 未知 | +| 5 | INTERACTIVE | 互动表情 | + +## 二、必有字段 + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 47601 | `faceId` | uint32 | 表情 id(例:骰子 `FaceIndex.DICE = 358`) | +| 47602 | `faceText` | string | 表情文字描述 | + +## 三、超级表情 + +仅 subType=3 时出现。 + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 47603 | `superEmojiCategory` | string | 超级表情分类 | +| 47604 | `AniStickerId` | string | 动画贴纸 ID | +| 47605 | `superEmojiFlag1` | uint32 | 标志 1 | +| 47606 | `superEmojiFlag2` | uint32 | 标志 2 | +| 47607 | `diceValue` | string | **命名为骰子点数**,实际上是随机表情的随机值
包括骰子,包剪锤,篮球等等 | +| 47609 | `superEmojiFlag3` | uint32 | 标志 3 | +| 47610 | `superEmojiFlag4` | uint32 | 标志 4 | + +## 四、其它可选字段 + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 45004 | `faceExtDesc` | string | 扩展描述(注意这个 tag 落在通用段而非 476xx 段) | +| 47608 | `faceFlag47608` | bytes | 未知的长度分隔字段 | +| 47622 | `canChain` | bool | 该表情是否支持连锁反应 | + +## 五、`47611..47621` —— 几乎每个表情都会捎带的一块 + +不论 subType 如何,这一块基本都会出现在 FACE element 上。其中只有 +`47612` / `47615` / `47616` / `47621` 真正携带内容,其余是标志位,绝大多数行里为 0。 - +| tag | 字段名 | 类型 | 含义 / 观测 | +| --- | ------ | ---- | ----------- | +| 47611 | `faceFlag47611` | uint32 | 多数为 0/1,偶见 2..6 或 126 | +| 47612 | `interactiveFaceName` | string | **互动表情名称**,如「模了个块」。通常为空 | +| 47613 | `faceFlag47613` | uint32 | 恒为 0(有一行为 1) | +| 47614 | `faceFlag47614` | uint32 | 恒为 0(有三行为 2003) | +| 47615 | `interactiveFaceName2` | string | 互动表情名称副本 —— 所有观测行都与 47612 相同 | +| 47616 | `interactiveFaceVersion` | string | **互动表情版本号**,如 `7.2.0`。通常为空 | +| 47617 | `faceFlag47617` | uint32 | 取值 0/1/2/3 | +| 47618 | `faceFlag47618` | uint32 | 恒为 0 | +| 47619 | `faceFlag47619` | uint32 | 恒为 0 | +| 47620 | `faceFlag47620` | uint32 | 恒为 0 | +| 47621 | `faceFallbackText` | string | **旧版客户端降级文案**,如「[戳一戳]请使用最新版手机QQ体验新功能。」。所有客户端都能渲染的表情上为空 | --- diff --git a/docs/database/nt_msg/elements/file.md b/docs/database/nt_msg/elements/file.md index 497d340..e35f007 100644 --- a/docs/database/nt_msg/elements/file.md +++ b/docs/database/nt_msg/elements/file.md @@ -1,12 +1,59 @@ # elementType 3 — 文件 -> 🚧 骨架待补充。 - 对应 WeQ 解析实现:`packages/codec/src/proto/msg/element.ts`(`elementType = 3`)。 -## 字段 +通用文件传输。大量复用「文件族」共用 tag,共用部分见 +[40800 总览 · 第五节](../40800.md#五跨类型共用的文件族字段)。 + +--- + +## 一、复用自文件族的字段 + +| tag | 字段名 | 说明 | +| --- | ------ | ---- | +| 45402 | `fileName` | 文件名 | +| 45403 | `filePath` | 本地路径 | +| 45405 | `fileSize` | 字节数 | +| 45406 | `md5Bytes` | 二进制 MD5 | +| 45408 | `contentHash` | 内容校验 hash | +| 45411 / 45412 | `imgWidth` / `imgHeight` | 宽高(图片类文件才有意义) | +| 45415 | `fileFlag45415` | 文件相关标识 | +| 45503 | `fileToken` | 下载凭据 | +| 45504 | `transferFlag45504` | 传输标志(string) | +| 45505 | `uploadTime` | 上传 / 处理时间戳 | +| 45510 | `videoToken` | 下载 token | +| 45511 | `picTransferState` | 传输状态 | +| 45513 | `transferVersion` | 传输版本 | +| 45550 | `transferState` | 传输状态 | + +> `45510` 曾被误标为 `fileFlag45510`,实为下载 token,VIDEO 与 FILE 共用。 + +## 二、FILE 专属字段 + +### 必有 + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 45003 | `subType` | uint32 | 文件类型判别式,观测到约 20 种取值,尚未逐一映射(代码里留有 `FileSubType` 的 TODO) | +| 45407 | `md5Bytes2` | bytes | 第二份 MD5,形状与作用同 45406 | +| 45409 | `fileFlag45409` | bytes | 未知字节串 | +| 45501 | `fileFlag45501` | uint32 | 未知整数(可能是 bool) | +| 45512 | `fileFlag45512` | bool | 未知布尔标志 | +| 45514 | `fileFlag45514` | bool | 未知布尔标志 | + +### 可选 - +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 45554 | `transferErrorText` | string | 人类可读的传输错误,如「传输失败,请稍后重试」 | +| 45533 | `fileFlag45533` | uint32 | 仅在一条 subType=4 的行上观测到(值为 2) | +| 45951 | `fileThumbPathRemote` | string | **发送端**的缩略图路径(手机端,`.thumbnails/…`) | +| 45953 | `fileThumbPathRemote2` | string | 发送端的第二个缩略图路径(`qlarge-dsc-…`) | +| 45954 | `fileThumbLocalPath` | string | 本机缓存的缩略图路径(`nt_data/File/Thumb/…`) | +| 45966 | `fileFlag45966` | bytes | 所有观测行均为空 | +| 45967 | `fileFlag45967` | bytes | 所有观测行均为空 | +| 45968 | `fileGroupMeta` | bytes | 群文件元数据块(上传者 uin / 昵称、文件 uuid、上传时间…)。仅观测到一次,子 tag 未验证,故保留为原始字节 | +| 45507 / 45509 | 传输标记 | | 见总览 | --- diff --git a/docs/database/nt_msg/elements/gray-tip.md b/docs/database/nt_msg/elements/gray-tip.md index d69e06c..7686cc1 100644 --- a/docs/database/nt_msg/elements/gray-tip.md +++ b/docs/database/nt_msg/elements/gray-tip.md @@ -1,12 +1,353 @@ # elementType 8 — 灰字提示 -> 🚧 骨架待补充。 - 对应 WeQ 解析实现:`packages/codec/src/proto/msg/element.ts`(`elementType = 8`)。 -## 字段 +「小灰条」是聊天中居中显示的那一行灰色提示:撤回、拍一拍、入群、禁言、临时会话…… +它们**共用一个 elementType**,靠 `45003 (subType)` 再分一次类, +而不同 subType 使用的 tag 段**几乎不重叠**——实际上是六种完全不同的结构挤在同一个类型下。 + +--- + +## 一、subType 全表 + +名字取自 QQ NT 的 `NTGrayTipElementSubTypeV2` 枚举。 +目前只观测到 1 / 4 / 10 / 12 / 15 / 17;其余声明出来只为将来遇到时有名字可对。 + +| 值 | 名称 | WeQ 的 kind | 说明 | tag 段 | +| -- | ---- | ----------- | ---- | ------ | +| 0 | UNKNOWN | — | 占位(未观测) | — | +| 1 | REVOKE | `grayTipRevoke` | **撤回提示**(「XX 撤回了一条消息」) | `477xx` | +| 2 | PROCLAMATION | — | 群公告(未观测) | — | +| 3 | EMOJI_REPLY | — | 表情回应(未观测) | — | +| 4 | GROUP_TIP | `grayTipGroup` | **群通知**:入群 / 退群 / 禁言 / 改群名 | `485xx` | +| 5 | BUDDY | — | 好友相关提示(未观测) | — | +| 6 | FEED | — | 动态 Feed 提示(未观测) | — | +| 7 | ESSENCE | — | 设为精华消息(未观测) | — | +| 8 | GROUP_NOTIFY | — | 群通知(系统下发,未观测) | — | +| 9 | BUDDY_NOTIFY | — | 好友通知(系统下发,未观测) | — | +| 10 | FILE | `grayTipFileRecv` | **文件接收完成灰条** | 文件族 `454xx/455xx` | +| 11 | FEED_CHANNEL_MSG | — | 频道消息 Feed(未观测) | — | +| 12 | XML_MSG | `grayTipInvite` | **XML 灰条**(通用 XML 消息) | `482xx` | +| 13 | LOCAL_MSG | — | 本地消息,仅本端可见(未观测) | — | +| 14 | BLOCK | — | 拉黑相关(未观测) | — | +| 15 | AIO_OP | `grayTipTempSession` | **临时会话提示** | `475xx` | +| 16 | WALLET | — | 钱包相关(未观测) | — | +| 17 | JSON | `grayTipPoke` | **JSON 灰条**:拍一拍、互动标识 | `482xx` | + +> 历史上 subType=12 曾被 WeQ 当作「邀请」处理,故 kind 名叫 `grayTipInvite`; +> 实际它是通用的 XML 灰条。 + +--- + +## 二、subType=1 — 撤回 + +| tag | 字段名 | 类型 | 必有 | 含义 | +| --- | ------ | ---- | ---- | ---- | +| 47702 | `recallFlag47702` | uint32 | ✅ | 未知整数 | +| 47703 | `recallRevokeUid` | string | ✅ | **撤回操作者** uid | +| 47704 | `recallSenderUid` | string | ✅ | **被撤回消息发送者** uid | +| 47705 | `recallSenderNick` | string | ✅ | 被撤回者昵称 | +| 47713 | `recallDisplayText` | string | ✅ | 撤回展示文案 | +| 47714 | `recallRevokeNick` | string | ✅ | 撤回者昵称 | +| 47710 | `recallElements` | repeated `ReplyElementWire` | | 被撤回消息的原始 element 副本。**只在撤回自己的消息时才有** | +| 47711 | `recallFlag47711` | uint32 | | 未知整数 | +| 47712 | `recallFlag47712` | uint32 | | 两条观测行均为 1 | + +### 六个昵称字段的关系 + +`47706/47715` 是昵称副本,`47707/47716` 装的是**群名片**。这一点在一次群撤回上得到验证: +该用户昵称为「🍬🐱帕罗丁,然后8年之誓」,而群名片是「期中考试加油」,两者确实不同。 + +QQ 会**同时写一份「被撤回者」副本和一份「撤回者」副本**,即使两者是同一个人。 +所以自己撤回自己的消息时,这六个字段值全部一致。 + +| tag | 字段名 | 归属 | 含义 | +| --- | ------ | ---- | ---- | +| 47705 | `recallSenderNick` | 被撤回者 | 昵称 | +| 47706 | `recallSenderNickCopy` | 被撤回者 | 昵称副本(同 47705) | +| 47707 | `recallSenderGroupNick` | 被撤回者 | **群名片**(群里与昵称不同) | +| 47714 | `recallRevokeNick` | 撤回者 | 昵称 | +| 47715 | `recallRevokeNickCopy` | 撤回者 | 昵称副本(同 47714) | +| 47716 | `recallRevokeGroupNick` | 撤回者 | **群名片** | + +--- + +## 三、subType=4 — 群通知 + +| tag | 字段名 | 类型 | 必有 | 含义 | +| --- | ------ | ---- | ---- | ---- | +| 48501 | `groupTipType` | uint32 | ✅ | 事件类型,见下表 | +| 48503 | `user1Uid` | string | | 用户 1 uid | +| 48504 | `user1Nick` | string | | 用户 1 昵称 | +| 48505 | `user1GroupNick` | string | | 用户 1 群名片 | +| 48506 | `user2Uid` | string | | 用户 2 uid | +| 48507 | `user2Nick` | string | | 用户 2 昵称 | +| 48508 | `user2GroupNick` | string | | 用户 2 群名片 | +| 48509 | `groupTipGroupName` | string | | **群名称** —— 出现在「XX 邀请你加入 <群名>」这类提示上(该群不是当前会话) | +| 48541 | `muteInfo` | message | | 禁言详情,见下 | +| 48542 | `grayTipTimestamp` | uint32 | | 提示时间戳,unix 秒。约 9700 个不同取值,均与消息自身发送时间一致 | + +### `groupTipType`(48501) + +名字取自 QQ NT 的 `TipGroupElementType` 枚举。本地库里出现过 1/2/3/5/8。 + +| 值 | 名称 | 说明 | +| -- | ---- | ---- | +| 0 | KUNKNOWN | 占位(未观测) | +| 1 | KMEMBERADD | **成员入群**:user1 是新成员,user2 是邀请人(无邀请人时 QQ 只写 user1) | +| 2 | KDISBANDED | 群已解散 | +| 3 | KQUITTE | **成员被移出群聊**:user1Nick 是操作者昵称,user2Uid 是被移出者 | +| 4 | KCREATED | 群创建成功(未观测) | +| 5 | KGROUPNAMEMODIFIED | **群名被修改**:user1 是操作者,`groupTipGroupName` 是新群名 | +| 6 | KBLOCK | 成员被拉黑(未观测) | +| 7 | KUNBLOCK | 成员被移出黑名单(未观测) | +| 8 | KSHUTUP | **禁言**:详情在 `muteInfo`,时长为 0 表示解除禁言 | +| 9 | KBERECYCLED | 群因违规被回收(未观测) | +| 10 | KDISBANDORBERECYCLED | 群被解散或被回收(未观测) | + +### `muteInfo`(48541) + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 48521 | `operator` | message | 操作者,内含 `1000: uid` | +| 48522 | `mutedUser` | message | 被禁言者,内含 `1000: uid`、`20002: groupNick` | +| 48531 | `timestamp` | uint64 | 时间戳 | +| 48532 | `duration` | uint32 | **禁言时长(秒)**,0 = 解除 | + +### 未验证 + +| tag | 字段名 | 观测情况 | +| --- | ------ | -------- | +| 48502 | `groupTipFlag48502` | 取值 0/1/2 | +| 48510 | `groupTipFlag48510` | 只要出现就恒为 1 | +| 48511 | `groupTipFlag48511` | 取值 0(×2874)/ 2 / 1 / 3 | + +--- + +## 四、subType=10 — 文件接收完成 + +结构上是一个**披着 elementType=8 外壳的 FILE element**:它复用 `454xx/455xx` 文件族 tag, +一个灰条专属字段都没有。QQ 把它作为一条独立的行,写在真正的 FILE 消息旁边。 + +已验证:发送者(行级 `40020`)**永远是对端,绝不是本账号**, +所以它表达的是「对方发来的文件已接收完成」。 +行级 msgType 为 `40011=5 / 40012=1`(系统提示)或 `40011=1 / 40012=2`。 + +| tag | 字段名 | 类型 | 必有 | +| --- | ------ | ---- | ---- | +| 45402 | `fileName` | string | ✅ | +| 45405 | `fileSize` | uint32 | ✅ | +| 45407 | `md5Bytes2` | bytes | | +| 45503 | `fileToken` | string | | +| 45411 / 45412 | `imgWidth` / `imgHeight` | uint32 | | +| 45410 | `videoDuration` | uint32 | | + +> `fileName` 在 wire 上出现了两次,WeQ 取第一个。 + +--- + +## 五、subType=15 — 临时会话 + +QQ 渲染为「该用户通过 xxx 群聊向你发起临时会话」。 +恒为一个 C2C 会话的**首条消息**(seq=1),且该行本身不带发送者(`40020` 为空)。 + +| tag | 字段名 | 类型 | 必有 | 含义 | +| --- | ------ | ---- | ---- | ---- | +| 47502 | `tempSessionGroupCode` | string | ✅ | **发起临时会话的来源群号**(十进制字符串) | +| 47501 | `aioOpFlag47501` | uint32 | | 恒为 1,疑似提示变体标识 | +| 40021 | `origReceiverUid` | string | | 行级对端 uid 的冗余副本 | + +> `47502` 已验证确实是群号而非对方 QQ 号:所有观测值在 `group_msg_table` 里都有真实群聊记录, +> 且没有一个与对端自身 uin 相同。 + +--- + +## 六、subType=17(JSON)与 subType=12(XML) + +这两者共用 `482xx` 段。subType=17 是拍一拍、互动标识(畅聊之火 / 初泛涟漪)等; +subType=12 是通用 XML 灰条。 + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 48210 | `actionInitiator` | message | **动作发起者**,内含 `1005: uid`、`1006: nickname` | +| 43210 | `actionTarget` | message | **动作目标**,结构同上 | +| 48211 | `actionId` | uint32 | 动作类型 id。观测到 12(拍一拍)、16(红包) | +| 48212 | `detailedId` | uint32 | 详细动作 id。1=系统,1061=拍一拍,19357=红包 | +| 48213 | `typeFlag` | uint32 | 类型标志。观测到 7 | +| 48214 | `grayTipXmlContent` | string | **XML 预览文档** | +| 48215 | `businessId` | uint32 | **业务 id**,见下方 `JsonGrayBusiId` 全表。观测到 1132 | +| 48216 | `actionUniqueId` | uint32 | 本次动作的唯一 id | +| 48217 | `actionAttributes` | repeated message | 附加属性,每项 `{1005: key, 1006: value}` | +| 48271 | `tipJson` | string | **提示的 JSON 负载**(subType=17 必有) | +| 48273 | `tipType` | uint32 | 提示类型,1=系统,与 `detailedId` 对应 | +| 48274 | `grayTipPlainText` | string | **互动标识提示原文**,如「你和XX互发消息连续超过7天,已获得畅聊之火标识」。`tipJson` 的纯文本版 | +| 48542 | `grayTipTimestamp` | uint32 | 提示时间戳 | + +### `tipJson`(48271)的形状 + +```jsonc +{ + "items": [ + { "type": "nor", "txt": "文本片段" }, + { "type": "qq", "uid": "u_xxx", "col": "#ff0000", "jp": "跳转协议" }, + { "type": "img", "src": "图片地址" }, + { "type": "url", "txt": "链接文字", "jp": "跳转地址" } + ] +} +``` + +`type` 已知 `img` / `qq` / `nor` / `url` 四种;`jp` 是点击跳转协议。 + +### `grayTipXmlContent`(48214)的形状 + +```xml + + + + + +``` + +### 未验证 + +| tag | 字段名 | 观测情况 | +| --- | ------ | -------- | +| 48218 | `grayTipReserved` | 保留字段(string) | +| 48219 | `grayTipFlag48219` | 取值 0(×49)/ 1(×1) | +| 48220 | `grayTipFlag48220` | 取值 0(×59)/ 64(×1) | +| 48272 | `grayTipFlag48272` | 观测为 true | +| 48275 | `grayTipFlag48275` | uint32,语义未知 | + +--- + +## 七、`businessId`(48215)全表 + +取值来自 QQ NT 自身的 `JsonGrayBusiId` 枚举 —— 它标识这条 JSON 灰条**由哪个业务下发**, +决定了 `tipJson` 里的文案与跳转协议属于什么场景。本地样本只覆盖到其中极少数(如 1132), +这里完整列出以便遇到时能直接对号入座。 + +> ⚠️ 枚举仅记录在此文档,未进代码:解析层不依赖它,`48215` 照旧按裸数字解析。 +> 取值命名保留 QQ 原样。 + +### 在线文件传输(1..13) + +| 值 | 名称 | 含义 | +| -- | ---- | ---- | +| 1 | ONLINE_FILE_STOP_SEND | 停止发送 | +| 2 | ONLINE_FILE_STOP_SEND_ON_SENDING | 发送中停止发送 | +| 3 | ONLINE_FILE_REFUSE_RECV | 拒绝接收 | +| 4 | ONLINE_FILE_CANCEL_RECV_ON_RECVING | 接收中取消接收 | +| 5 | ONLINE_FILE_STOP_ALL_SEND | 停止全部发送 | +| 6 | ONLINE_FILE_STOP_ALL_SEND_ON_SENDING | 发送中停止全部发送 | +| 7 | ONLINE_FILE_REFUSE_ALL_RECV | 拒绝接收全部 | +| 8 | ONLINE_FILE_REFUSE_ALL_RECV_ON_RECVING | 接收中拒绝接收全部 | +| 9 | ONLINE_FILE_SEND_ERROR | 发送出错 | +| 10 | ONLINE_FILE_RECV_ERROR | 接收出错 | +| 11 | ONLINE_FILE_GO_OFFLINE | 离线 | +| 12 | ONLINE_FILE_GO_OFFLINE_ALL | 全部离线 | +| 13 | ONLINE_FILE_RECV_BY_MOBILE | 已由手机接收 | + +### 杂项 + +| 值 | 名称 | 含义 | +| -- | ---- | ---- | +| 51 | ONLINE_GROUP_HOME_WORK | 群作业 | +| 81 | RED_BAG | 红包 | +| 86 | LITE_ACTION | 轻量动作 | + +### 关系链(1000..1022) + +| 值 | 名称 | 含义 | +| -- | ---- | ---- | +| 1000 | RELATION_CHAIN_BLACKED | 被拉黑 | +| 1001 | RELATION_EMOJIEGG_SHOW | 表情彩蛋展示 | +| 1002 | RELATION_EMOJIEGG_WILL_DEGRADE | 表情彩蛋即将降级 | +| 1003 | RELATION_C2C_LOVER_BONUS | 情侣加成 | +| 1004 | RELATION_C2C_SAY_HELLO | 打招呼 | +| 1005 | RELATION_C2C_GROUP_AIO_SETUP_GROUP_AND_REMARK | 设置分组与备注 | +| 1006 | RELATION_FRIEND_CLONE_INFO | 好友克隆信息 | +| 1007 | RELATION_CHAIN_MATCH_FRIEND | 匹配好友 | +| 1008 | RELATION_NEARBY_GOTO_VERIFY | 附近的人去验证 | +| 1009 | RELATION_CREATE_GROUP_GRAY_TIP_ID | 创建群聊 | +| 1010 | RELATION_YQT | 一起听 | +| 1011 | RELATION_LIMIT_TMP_CONVERSATION_SET | 临时会话限制设置 | +| 1012 | RELATION_ONEWAY_FRIEND_GRAY_TIP_ID | 单向好友 | +| 1013 | RELATION_ONEWAY_FRIEND_NEW_GRAY_TIP_ID | 单向好友(新) | +| 1014 | RELATION_GROUP_SHUT_UP | 群禁言 | +| 1015 | RELATION_GROUP_MEMBER_ADD_WITH_MODIFY_NAME | 加群成员并改名 | +| 1016 | RELATION_GROUP_MEMBER_ADD_WITH_WELCOME | 加群成员并欢迎 | +| 1017 | RELATION_C2C_MEMBER_ADD | 添加好友 | +| 1018 | RELATION_C2C_REACTIVE_UPGRADE_MSG | 亲密度升级 | +| 1019 | RELATION_C2C_REACTIVE_DEGRADE_MSG | 亲密度降级 | +| 1020 | RELATION_GROUP_BATCH_ADD_FRIEND | 群内批量加好友 | +| 1021 | RELATION_GROUP_MEMBER_RECOMMEND | 群成员推荐 | +| 1022 | RELATION_GROUP_MEMBER_ADD | 加群成员 | + +### AIO 会话内提示(2000..2100) + +| 值 | 名称 | 含义 | +| -- | ---- | ---- | +| 2000 | AIO_RECALL_MSGCUSTOM_WORDINGGUIDE | 撤回自定义文案引导 | +| 2021 | AIO_AV_C2C_NOTICE | 私聊音视频通知 | +| 2022 | AIO_AV_GROUP_NOTICE | 群音视频通知 | +| 2041 | AIO_NUDGE_CUSTOM_GUIDE | 拍一拍自定义引导 | +| 2050 | AIO_CRM_FLAGS_TIPS | CRM 标识提示 | +| 2060 | PTT_AUTO_CHANGE_GUIDE | 语音自动变声引导 | +| 2100 | AIO_C2C_DONT_DISTURB | 私聊免打扰 | + +### Z-Plan / 机器人 / 推送(2201..2701) + +| 值 | 名称 | 含义 | +| -- | ---- | ---- | +| 2201 | AIO_ROBOT_SAFETY_TIP | 机器人安全提示 | +| 2300 | AIO_ZPLAN_SEND_MEME | Z-Plan 发送梗图 | +| 2301 | AIO_ZPLAN_EMOTICON_GUIDE | Z-Plan 表情引导 | +| 2302 | AIO_ZPLAN_SCENE_LINKAGE | Z-Plan 场景联动 | +| 2601 | QCIRCLE_SHOW_FULE_TIPS | 小世界提示 | +| 2602 | QWALLET_GRAY_TIP_ID | QQ 钱包 | +| 2603 | DISBAND_DISCUSSION_GRAY_TIP_ID | 解散讨论组 | +| 2701 | AIO_PUSH_GUIDE_GRAY_TIPS | 推送引导 | + +### 群会话(2401..2408) + +| 值 | 名称 | 含义 | +| -- | ---- | ---- | +| 2401 | AIO_GROUP_ESSENCE_MSG_TIP | 群精华消息 | +| 2402 | GROUP_AIO_SHUTUP_GRAY_TIP_ID | 群禁言 | +| 2403 | GROUP_AIO_UPLOAD_PERMISSIONS_GRAY_TIP_ID | 群上传权限 | +| 2404 | GROUP_AIO_HOME_SCHOOL_WELCOME_GRAY_TIP_ID | 家校群欢迎 | +| 2405 | GROUP_AIO_TEMPORARY_GRAY_TIP_ID | 群临时会话 | +| 2406 | GROUP_AIO_MSG_FREQUENCY_GRAY_TIP_ID | 群消息频率 | +| 2407 | GROUP_AIO_CONFIGURABLE_GRAY_TIPS | 群可配置灰条 | +| 2408 | GROUP_AIO_UNREAD_MSG_AI_SUMMARY | 群未读消息 AI 总结 | + +### 文件大小限制(3001..3003) + +| 值 | 名称 | 含义 | +| -- | ---- | ---- | +| 3001 | VAS_FILE_UPLOAD_OVER_LIMIT | 上传超出限制 | +| 3002 | VAS_FILE_UPLOAD_OVER_1G | 上传超过 1G | +| 3003 | FILE_SENDING_SIZE_4GB_LIMIT | 发送 4GB 限制 | + +### 群加好友 / 破冰(10405, 19264..19273) + +| 值 | 名称 | 含义 | +| -- | ---- | ---- | +| 10405 | TROOP_BREAK_ICE | 群破冰 | +| 19264 | TROOP_ADD_FRIEND_ACTIVE | 加好友:活跃成员 | +| 19265 | TROOP_ADD_FRIEND_HOT_CHAT | 加好友:热聊 | +| 19266 | TROOP_ADD_FRIEND_REPLY_OR_AT | 加好友:回复或 @ | +| 19267 | TROOP_ADD_FRIEND_NEW_MEMBER | 加好友:新成员 | +| 19273 | TROOP_FLAME_IGNITED | 群火花点燃 | + +### 预留 + +| 值 | 名称 | 含义 | +| -- | ---- | ---- | +| 100000 | UI_RESERVE_100000_110000 | UI 预留区间起点(100000–110000) | - +> 💡 WeQ 的[防撤回](../../../guide/anti-recall.md)功能正是构造一条 subType=17 的自定义灰条 +> 来记录被拦截的撤回事件。 --- diff --git a/docs/database/nt_msg/elements/markdown.md b/docs/database/nt_msg/elements/markdown.md index 5f50eb3..046495f 100644 --- a/docs/database/nt_msg/elements/markdown.md +++ b/docs/database/nt_msg/elements/markdown.md @@ -1,12 +1,72 @@ # elementType 14 — Markdown -> 🚧 骨架待补充。 - 对应 WeQ 解析实现:`packages/codec/src/proto/msg/element.ts`(`elementType = 14`)。 -## 字段 +富文本 markdown 消息,tag 段为 `487xx`。其中三个用于「QQ 闪传」的嵌套结构 +(`48707` / `48708` / `48711`)过于复杂,除 `48708` 外均保留为原始字节 —— +它们属于可选边缘功能,逐字段维护的成本不划算。 + +--- + +## 一、必有字段 + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 48701 | `markdownContent` | string | **markdown 正文** | +| 48702 | `markdownMeta` | message | 元数据(构建时间戳、标志),见下 | +| 48703 | `markdownFlag48703` | message | 嵌套标志块,见下 | +| 48704 | `markdownFlag48704` | string | 未知的长度分隔字段 | +| 48705 | `markdownTextSummary` | string | 文本摘要 | +| 48706 | `markdownFlag48706` | uint32 | 未知整数标志 | + +## 二、`markdownMeta`(48702) + +这个嵌套块用的是**小 tag**(1..4): + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 1 | `flag1` | uint32 | 未知 | +| 2 | `buildTimestamp` | uint32 | 构建时间戳 | +| 3 | `flag3` | bytes | 未知 | +| 4 | `flag4` | uint32 | 未知 | + +## 三、`markdownFlag48703`(48703) + +与 `48702` 相反,这个嵌套块用的是**绝对 tag**(`487xx` 段): + +| tag | 字段名 | 类型 | +| --- | ------ | ---- | +| 48720 | `field48720` | string | +| 48721 | `field48721` | string | +| 48722 | `field48722` | uint32 | + +> 同一个 element 里,一个嵌套块用小 tag、另一个用绝对 tag —— 这是 QQ 的实际做法, +> 不是解析实现的笔误。 + +## 四、QQ 闪传 + +| tag | 字段名 | 类型 | 说明 | +| --- | ------ | ---- | ---- | +| 48707 | `flashTransferProto1` | bytes | 复杂嵌套结构,保留为原始字节 | +| 48708 | `flashTransferInfo` | message | 已解析,见下 | +| 48711 | `flashTransferProto3` | bytes | 复杂嵌套结构,保留为原始字节 | + +### `flashTransferInfo`(48708) + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 1 | `fileSetId` | string | 文件集 ID | +| 2 | `thumbnailName` | string | 缩略图名称 | +| 3 | `fileBytes` | uint32 | 文件字节数 | +| 4 | `thumbAlt` | message | 缩略图备选,见下 | +| 6 | `createTime` | uint32 | 创建时间 | + +`thumbAlt`(48708 → 4): - +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 1 | `fileId` | string | 文件 ID | +| 2 | `urlInfo` | message | `{1: type(uint32), 2: url(string)}` | --- diff --git a/docs/database/nt_msg/elements/mface.md b/docs/database/nt_msg/elements/mface.md index 93197d5..9593593 100644 --- a/docs/database/nt_msg/elements/mface.md +++ b/docs/database/nt_msg/elements/mface.md @@ -1,12 +1,76 @@ # elementType 11 — 商城表情 -> 🚧 骨架待补充。 - 对应 WeQ 解析实现:`packages/codec/src/proto/msg/element.ts`(`elementType = 11`)。 -## 字段 +商城表情(marketface / 商业贴纸)。它使用**完全独立的 `808xx / 809xx` tag 段**, +与其它所有 element 类型都不重叠。 + +--- + +## 一、已理解的字段 + +| tag | 字段名 | 类型 | 必有 | 含义 | +| --- | ------ | ---- | ---- | ---- | +| 80810 | `emojiPackId` | uint32 | ✅ | 表情包 ID | +| 80900 | `emojiDesc` | string | ✅ | 表情描述文字,如 `[嗨]` | +| 80901 | `mfaceType` | uint32 | ✅ | 表情类型 | +| 80902 | `mfaceSubType` | bool | ✅ | 表情子类型标志 | +| 80903 | `marketEmoticonId` | bytes | ✅ | **真正的贴纸 id**,也是磁盘上的文件名 | +| 80905 | `mediaType` | uint32 | ✅ | 媒体类型标志 | +| 80908 | `renderFlag` | bool | ✅ | 渲染标志 | +| 80909 | `previewWidth` | uint32 | ✅ | 预览图宽 | +| 80910 | `previewHeight` | uint32 | ✅ | 预览图高 | +| 80935 | `isAnimated` | bool | ✅ | 是否动图 | +| 80824 | `encryptKey` | string | | **本地 / CDN 加密表情图的解密密钥** | + +## 二、`encryptKey`(80824)—— 渲染无需爆破 + +商城表情的图片文件(本地缓存与 CDN 上)都是**加密**的。关键结论是: + +> 消息 element 里自带的 `encryptKey` 就是 QQTEA 的 16 字节密钥 +> (形式为 `md5(时间戳)[:16]` 的 ASCII 串),拿它直接就能解密。 + +这意味着渲染聊天记录里的商城表情**完全不需要爆破时间戳**。 +只有 `encryptKey` 为空的旧消息,才退化到「靠 `emojiPackId` 去 CDN + 爆破」的路子。 + +CDN 地址由 `marketEmoticonId` 的十六进制串拼出: + +```text +https://i.gtimg.cn/club/item/parcel/item///<300_300 或 200_200> +``` + +解密流程(QQTEA,交织链式 CBC): + +1. 取 `encryptKey` 的 16 个 ASCII 字节作为 TEA key; +2. 按 8 字节分块做 16 轮大端 TEA 解密,前后块交织异或; +3. 去头:第 1 字节是控制位,`控制位 & 7` 得到填充长度,再跳过 2 字节 salt; +4. 去尾:截到最后一个 GIF trailer `0x3b`; +5. 结果应以 `GIF89a` / `GIF87a` 开头。 + +端到端验证脚本:`packages/db/tools/mface_tea_decrypt.ts` +(`pnpm --filter @weq/db test:mface-tea-decrypt`)。 + +> 📌 详细原理另见 [商城表情的解密](../../../principles/index.md)(编写中)。 + +## 三、语义未验证的字段 + +以下 `808xx/809xx` tag 仅为往返完整性而解析,wire 类型是**依字段标签猜的**, +含义均未验证。 - +| tag | 字段名 | 类型(推测) | 推测含义 | +| --- | ------ | ------------ | -------- | +| 80907 | `mfaceFlag80907` | bytes | 空对象 | +| 80913 | `mfaceFlag80913` | bytes | 扩展元数据 | +| 80941 | `mfaceFlag80941` | bytes | 样式 / 空对象 | +| 80942 | `mfaceFlag80942` | bytes | 样式 / 空对象 | +| 80970 | `sizeInfo` | bytes | protobuf 编码的宽高,如 `e0 c1 27 c8 01 e8 c1 27 c8 01` | +| 80975 | `mfaceFlag80975` | uint32 | 兼容性标志 | +| 80977 | `mfaceFlag80977` | bytes | 样式 / 空对象 | +| 80978 | `mfaceFlag80978` | string | 颜色 / 样式代码 | +| 80980 | `mfaceFlag80980` | uint32 | 权限标志 | +| 80981 | `mfaceFlag80981` | uint32 | 权限标志 | +| 80983 | `mfaceFlag80983` | string | 扩展 JSON | +| 80995 | `mfaceFlag80995` | uint32 | 结束 / 填充标志 | --- diff --git a/docs/database/nt_msg/elements/multi-msg.md b/docs/database/nt_msg/elements/multi-msg.md index cd543f1..5bebc18 100644 --- a/docs/database/nt_msg/elements/multi-msg.md +++ b/docs/database/nt_msg/elements/multi-msg.md @@ -1,12 +1,52 @@ # elementType 16 — 合并转发 -> 🚧 骨架待补充。 - 对应 WeQ 解析实现:`packages/codec/src/proto/msg/element.ts`(`elementType = 16`)。 -## 字段 +合并转发(QQ 内部名 MULTIFORWARD)。element 本身**只是一张卡片壳**: +三个字段分别是服务端资源 id、渲染用的 XML 预览、以及上传会话 id。 +真正的消息链条要么去服务端拉(凭 `resId`),要么读本地 [40900 列](../40900.md) 的缓存。 + +--- + +## 一、字段 + +| tag | 字段名 | 类型 | 必有 | 含义 | +| --- | ------ | ---- | ---- | ---- | +| 48601 | `resId` | string | ✅ | **服务端资源 ID**,用于向 QQ 服务器拉取完整消息链 | +| 48602 | `xmlContent` | string | ✅ | **XML 预览文档**,携带标题、摘要与元数据,用于渲染转发卡片 | +| 48603 | `sessionId` | string | ✅ | 关联本次上传会话的标识,在 XML 中体现为 `m_fileName` | + +## 二、`xmlContent`(48602)的结构 + +```ts +interface MultiMsgXmlPayload { + serviceID: string; + templateID: string; + action: string; + brief: string; // 会话列表外显的简述 + m_resid: string; // 与 48601 对应 + m_fileName: string; // 与 48603 对应 + tSum: string; // 条数 + flag: string; + item?: { + layout: string; + titles: Array<{ color: string; size: string; text: string }>; // 预览的前几条 + summary?: { color: string; text: string }; // 「查看 N 条转发消息」 + }; + source?: { name: string }; +} +``` + +## 三、与 40900 的关系 + +一条合并转发消息的行级 `40011 = 8`(`MsgType.MULTI_FORWARD`),此时该行的 +[`40900` 列](../40900.md)里存着被转发消息的**完整快照**(每条都是一个 `MsgCache`, +含各自的 `40800` 正文)。 + +也就是说: - +- **离线可读**:不联网也能展开转发内容,因为快照就在本地库里; +- **可递归**:被转发的消息若本身也是合并转发,`40900` 会再嵌套一层。 --- diff --git a/docs/database/nt_msg/elements/online-file.md b/docs/database/nt_msg/elements/online-file.md index aa465e7..24ad45c 100644 --- a/docs/database/nt_msg/elements/online-file.md +++ b/docs/database/nt_msg/elements/online-file.md @@ -1,12 +1,25 @@ # elementType 23 — 在线文件 -> 🚧 骨架待补充。 - 对应 WeQ 解析实现:`packages/codec/src/proto/msg/element.ts`(`elementType = 23`)。 +在线文件(QQ 内部名 TOFURECORD)—— 不落地下载、直接在线预览 / 转存的文件。 +字段完全复用「文件族」,没有专属 tag,共用部分见 +[40800 总览 · 第五节](../40800.md#五跨类型共用的文件族字段)。 + +--- + ## 字段 - +| tag | 字段名 | 类型 | 必有 | 含义 | +| --- | ------ | ---- | ---- | ---- | +| 45402 | `fileName` | string | ✅ | 文件名 | +| 45403 | `filePath` | string | ✅ | 本地路径 | +| 45405 | `fileSize` | uint32 | ✅ | 字节数 | +| 45411 | `imgWidth` | uint32 | ✅ | 宽(图片类文件才有意义) | +| 45412 | `imgHeight` | uint32 | ✅ | 高 | +| 45503 | `fileToken` | string | ✅ | 下载凭据 | +| 45415 | `fileFlag45415` | uint32 | | 文件相关标识 | +| 45504 | `transferFlag45504` | string | | 传输标志 | --- diff --git a/docs/database/nt_msg/elements/online-folder.md b/docs/database/nt_msg/elements/online-folder.md index ec19cbf..f45b71f 100644 --- a/docs/database/nt_msg/elements/online-folder.md +++ b/docs/database/nt_msg/elements/online-folder.md @@ -1,12 +1,23 @@ # elementType 30 — 在线文件夹 -> 🚧 骨架待补充。 - 对应 WeQ 解析实现:`packages/codec/src/proto/msg/element.ts`(`elementType = 30`)。 +在线文件夹。结构与[在线文件](./online-file.md)几乎一致,只是没有宽高 +(文件夹没有尺寸概念)。字段全部复用「文件族」,见 +[40800 总览 · 第五节](../40800.md#五跨类型共用的文件族字段)。 + +--- + ## 字段 - +| tag | 字段名 | 类型 | 必有 | 含义 | +| --- | ------ | ---- | ---- | ---- | +| 45402 | `fileName` | string | ✅ | 文件夹名 | +| 45403 | `filePath` | string | ✅ | 本地路径 | +| 45405 | `fileSize` | uint32 | ✅ | 总字节数 | +| 45503 | `fileToken` | string | ✅ | 下载凭据 | +| 45415 | `fileFlag45415` | uint32 | | 文件相关标识 | +| 45504 | `transferFlag45504` | string | | 传输标志 | --- diff --git a/docs/database/nt_msg/elements/pic.md b/docs/database/nt_msg/elements/pic.md index b70e469..02fc874 100644 --- a/docs/database/nt_msg/elements/pic.md +++ b/docs/database/nt_msg/elements/pic.md @@ -1,12 +1,95 @@ # elementType 2 — 图片 -> 🚧 骨架待补充。 - 对应 WeQ 解析实现:`packages/codec/src/proto/msg/element.ts`(`elementType = 2`)。 -## 字段 +图片是「文件族」中字段最全的一类,绝大多数 `454xx / 455xx / 458xx` tag 都能在图片行上看到。 +文件族的**共用字段**(文件名 / 大小 / MD5 / 三档 URL 与本地路径 / CDN 信息 / 传输标记) +统一在 [40800 总览 · 第五节](../40800.md#五跨类型共用的文件族字段) 说明,本页不重复。 + +--- + +## 一、subType — 图片的来源分类 + +名字取自 QQ NT 自身的 `PicSubType` 枚举。实践中 0 / 1 最常见,2/3/4/7 也确有出现; +另有 10..14 出现在野外,超出厂商枚举范围,未命名(需要有渲染结果才能确认)。 + +| 值 | 名称 | 说明 | +| -- | ---- | ---- | +| 0 | NORMAL | 普通图片 | +| 1 | CUSTOM | 自定义表情(收藏的表情包) | +| 2 | HOT | 热图 | +| 3 | DIPPER_CHART | 斗图 | +| 4 | SMART | 智能图 | +| 5 | SPACE | 空间图(未观测) | +| 6 | UNKNOW | 厂商枚举里就叫 `KUNKNOW`(未观测) | +| 7 | RELATED | 关联图 | + +## 二、`imgType`(45416) + +| 值 | 名称 | 说明 | +| -- | ---- | ---- | +| 1000 | NORMAL | 普通图 | +| 1001 | ORIGINAL | 原图 | +| 2000 | EMOJI | 表情 | + +## 三、必有字段 + +依据 `element/spec.ts` 的 `PicElementSchema`,一个图片 element 一定带这些字段: + +| tag | 字段名 | 类型 | 说明 | +| --- | ------ | ---- | ---- | +| 45402 | `fileName` | string | 图片文件名 | +| 45405 | `fileSize` | uint32 | 字节数 | +| 45406 | `md5Bytes` | bytes | 二进制 MD5 | +| 45408 | `contentHash` | bytes | 内容校验 hash | +| 45411 | `imgWidth` | uint32 | 宽(像素) | +| 45412 | `imgHeight` | uint32 | 高(像素) | +| 45416 | `imgType` | uint32 | 见上表 | +| 45418 | `isOriginal` | bool | 是否原图 | +| 45424 | `md5` | string | 大写十六进制 MD5 | +| 45503 | `fileToken` | string | 下载凭据 | +| 45505 | `uploadTime` | uint32 | 上传 / 处理时间戳 | +| 45517 | `uploadTimestamp` | uint32 | 上传时间戳 | +| 45518 | `fileTTL` | uint32 | 有效期(秒) | +| 45802 | `thumbnailUrl` | string | 缩略图 URL | +| 45803 | `previewUrl` | string | 预览图 URL | +| 45804 | `originalUrl` | string | 大图 URL | +| 45815 | `summary` | string(repeated) | 摘要 / 描述 | +| 45816 | `cdnHost` | string | CDN 域名 | + +## 四、可选字段 + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 45403 | `filePath` | string | 本地文件路径 | +| 45511 | `picTransferState` | uint32 | 传输状态 | +| 45513 | `transferVersion` | uint32 | 传输版本 | +| 45806 | `cdnServerIp` | uint32 | CDN 地址,大端打包 IPv4 | +| 45807 | `cdnServerPort` | uint32 | CDN 端口 | +| 45812 | `thumbnailLocalPath` | string | 缩略图本地缓存路径(`…_0.jpg`) | +| 45813 | `previewLocalPath` | string | 预览图本地缓存路径(`…_198.jpg`) | +| 45814 | `originalLocalPath` | string | 大图本地缓存路径(`…_720.jpg`) | +| 45507 | `transferFlag45507` | int64 | 近似常量哨兵,见总览 | +| 45509 | `transferFlag45509` | uint32 | 恒为 1(与 45507 成对) | +| 45600 | `picFlag45600` | bytes | 复杂嵌套结构(图片冗余信息),保留为原始字节 | + +## 五、观测到但语义未验证 + +以下 tag 在图片行上出现过,但取值几乎恒定,携带不了可利用的信息,仅为往返保真而解析。 - +| tag | 观测情况 | +| --- | -------- | +| 45425 | 只在 subType=13 的行上出现,取值 1(×93)/ 2(×2) | +| 45801 | 唯一一次观测为空字符串 | +| 45557 | 唯一一次观测为 0 | +| 45805 | 所有观测行恒为 0 | +| 45817 | PIC 协议标志(uint32) | +| 45818 / 45819 / 45820 | string,语义未知 | +| 45821 / 45822 / 45823 | uint32,语义未知 | +| 45824 | string,语义未知 | +| 45825 / 45826 / 45827 | uint32,语义未知 | +| 45828 | string,语义未知 | +| 45829 / 45830 / 45831 | 恒为 0 | --- diff --git a/docs/database/nt_msg/elements/ptt.md b/docs/database/nt_msg/elements/ptt.md index 7adef60..c48bb3a 100644 --- a/docs/database/nt_msg/elements/ptt.md +++ b/docs/database/nt_msg/elements/ptt.md @@ -1,12 +1,74 @@ # elementType 4 — 语音 -> 🚧 骨架待补充。 - 对应 WeQ 解析实现:`packages/codec/src/proto/msg/element.ts`(`elementType = 4`)。 -## 字段 +语音(PTT)复用大部分「文件族」tag(`45402`–`45518`、`45815`)承载文件元数据, +共用部分见 [40800 总览 · 第五节](../40800.md#五跨类型共用的文件族字段)。 + +--- + +## 一、两个容易踩坑的字段 + +### 时长看 `45906`,不要看波形 + +`45906 (pttDuration)` 是唯一可靠的时长来源,同时决定界面上的「时长」标签和气泡宽度。 + +波形数据 `45925 (waveform)` 是**装饰性**的,不能反推时长:AI 声聊的音频无论多长, +都携带一条固定的 30 字节合成波形。 + +### AI 声聊看 `45915` + +`45915 (isAiVoice)` **只在 AI 声聊片段上出现**(值为 true);普通麦克风录音、对讲、 +其它端发来的语音都不带这个字段。这是唯一可靠的判据 —— QQ 没有其它地方标记它, +而它们的波形又是上面说的合成占位。 + +## 二、必有字段 + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 45402 | `fileName` | string | 文件名 | +| 45403 | `filePath` | string | 本地路径 | +| 45405 | `fileSize` | uint32 | 字节数 | +| 45406 | `md5Bytes` | bytes | 二进制 MD5 | +| 45408 | `contentHash` | bytes | 内容校验 hash | +| 45418 | `isOriginal` | bool | 是否原始音质 | +| 45424 | `md5` | string | 大写十六进制 MD5 | +| 45503 | `fileToken` | string | 下载凭据 | +| 45505 | `uploadTime` | uint32 | 上传时间戳 | +| 45517 | `uploadTimestamp` | uint32 | 上传时间戳 | +| 45518 | `fileTTL` | uint32 | 有效期(秒) | +| 45815 | `summary` | string(repeated) | 摘要 | +| 45906 | `pttDuration` | uint32 | **时长(秒)** | +| 45911 | `voiceChanged` | bool | 是否变声 | +| 45925 | `waveform` | bytes | 波形可视化数据 | + +> 字段名之所以叫 `pttDuration` 而不是 `duration`:CALL(通话记录)的 `48152` 也叫 +> `duration`,而 `ElementWire` 是扁平结构,键名重复会让 `45906` 被静默丢弃。 + +## 三、可选字段 + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 45915 | `isAiVoice` | bool | **AI 声聊标记**,见上 | +| 45923 | `pttTranscript` | string | **QQ 自带的语音转文字结果**,缓存在行上。跑过一次「转文字」之后才有;转录结果为空时是空字符串 | +| 45905 | `pttVoiceId` | string | 服务端语音 id,如 `98PO#bjWUtk8qVPcpAiG57xZrOeS28AXRNmR` | +| 45550 | `transferState` | uint32 | 传输状态 | +| 45511 | `picTransferState` | uint32 | 传输状态 | +| 45513 | `transferVersion` | uint32 | 传输版本 | + +## 四、观测到但语义未验证 - +| tag | 观测情况 | +| --- | -------- | +| 45903 | 恒为 0 | +| 45907 | PTT 协议标志(uint32) | +| 45909 | uint32,语义未知 | +| 45912 | 取值 2(×39)/ 1(×1) | +| 45922 | uint32,语义未知 | +| 45924 | 只要出现就恒为 1,疑似「转录结果可用」 | +| 45926 | 只要出现就恒为 2 | +| 45908 | 嵌套 `{1, 5, 7}`,所有观测行三项全为 0 | +| 45601 | 嵌套 `{2:{37}, 4:{1,2}}`,所有观测行均为空 / 0,保留为原始字节 | --- diff --git a/docs/database/nt_msg/elements/qq-dynamic.md b/docs/database/nt_msg/elements/qq-dynamic.md index a8a78b8..59024a6 100644 --- a/docs/database/nt_msg/elements/qq-dynamic.md +++ b/docs/database/nt_msg/elements/qq-dynamic.md @@ -1,12 +1,44 @@ # elementType 26 — 空间动态提示 -> 🚧 骨架待补充。 - 对应 WeQ 解析实现:`packages/codec/src/proto/msg/element.ts`(`elementType = 26`)。 -## 字段 +QQ 空间动态(说说 / 动态)的分享卡,QQ 内部名 TOFU。tag 段为 `481xx`。 + +--- + +## 一、字段 + +除 `dynamicTags` 外,下列字段在 QQ_DYNAMIC element 上都是必有的。 + +| tag | 字段名 | 类型 | 必有 | 含义 | +| --- | ------ | ---- | ---- | ---- | +| 48172 | `dynamicType` | uint32 | ✅ | 动态类型 | +| 48173 | `dynamicId` | string | ✅ | 动态 id | +| 48174 | `dynamicFlag48174` | uint32 | ✅ | 未知整数 | +| 48175 | `dynamicDesc` | message | ✅ | 主描述块,见下 | +| 48176 | `dynamicDesc2` | message | ✅ | 次描述块(结构同 48175) | +| 48180 | `dynamicCoverUrl` | string | ✅ | 封面图 URL | +| 48181 | `dynamicZoneLogoUrl` | string | ✅ | QQ 空间 logo URL | +| 48182 | `dynamicPublisherUin` | uint32 | ✅ | 动态发布者 QQ 号 | +| 48183 | `dynamicMeta` | string | ✅ | 动态 meta 数据 | +| 48189 | `dynamicTags` | repeated message | | 标签列表,见下 | + +## 二、描述块(48175 / 48176) + +两者结构相同: + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 48178 | `mainDesc` | string | 主描述 | +| 48179 | `subDesc` | string | 次描述 | + +## 三、标签项(48189,repeated) - +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 48191 | `flag48191` | bool | 未知标志 | +| 48192 | `tagId` | uint32 | 标签 id | +| 48193 | `tagContent` | string | 标签内容 | --- diff --git a/docs/database/nt_msg/elements/reply.md b/docs/database/nt_msg/elements/reply.md index aa1883b..3cc9cf1 100644 --- a/docs/database/nt_msg/elements/reply.md +++ b/docs/database/nt_msg/elements/reply.md @@ -1,12 +1,69 @@ # elementType 7 — 回复引用 -> 🚧 骨架待补充。 - 对应 WeQ 解析实现:`packages/codec/src/proto/msg/element.ts`(`elementType = 7`)。 -## 字段 +引用一条更早的消息。回复元素**自带被引用消息的快照**(`47423`), +所以即使原消息已被删除,引用块依然能渲染出内容。 + +> 与 [40900 列](../40900.md) 的关系:行级 `40011 = 9` 时,`40900` 列里也存了一份 +> 被引用消息的**完整行快照**。`47423` 是元素内的轻量副本,`40900` 是列级的完整副本, +> 两者并存、粒度不同。 + +--- + +## 一、被引用消息的定位 + +| tag | 字段名 | 类型 | 必有 | 含义 | +| --- | ------ | ---- | ---- | ---- | +| 40020 | `origSenderUid` | string | ✅ | 原消息发送者 uid(复用信封级 tag) | +| 40021 | `origReceiverUid` | string | ✅ | 原消息接收者 uid(复用信封级 tag) | +| 47402 | `origMsgSeq` | uint32 | ✅ | 原消息序列号 | +| 47403 | `origSenderUin` | uint32 | ✅ | 原消息发送者 QQ 号 | +| 47404 | `origMsgTime` | uint32 | ✅ | 原消息时间戳,unix 秒 | +| 47411 | `origReceiverUin` | uint32 | ✅ | 原消息接收者 QQ 号 | +| 47416 | `origMsgId` | uint64 | ✅ | 原消息 id | +| 47419 | `origMsgIndex` | uint32 | ✅ | 原消息在会话内的序号 | +| 47401 | `replyOrigMsgIdRef` | uint64 | | 原消息 id 引用 | +| 48101 | `replyOrigMsgSeqCopy` | uint32 | | `47402` 的副本 —— 所有观测行完全相同 | + +> 群聊里 `origReceiverUid` / `origMsgIndex` 常常缺失。WeQ 自己构造回复时会填无害的占位值。 + +## 二、被引用内容的快照 + +| tag | 字段名 | 类型 | 必有 | 含义 | +| --- | ------ | ---- | ---- | ---- | +| 47423 | `origElements` | repeated `ReplyElementWire` | ✅ | **原消息 element 的轻量快照** | +| 47413 | `replyTextSummary` | string | | 原消息的文本摘要 | +| 47421 | `replyOrigSenderNick` | string | | 被回复者的群名片 / 昵称 | +| 47410 | `replyOrigSenderBlob` | bytes | | 被引用消息发送者的完整快照(uin、uid、群名片、seq/time…)。嵌套很深,且携带的信息 `47402..47423` 已全部提供,故保留为原始字节 | + +### `ReplyElementWire`(47423 的每一项) + +结构是 `ElementWire` 的**裁剪版**:`elementId` + `elementType` 打头, +后面跟着「实际出现在 wire 上的」各类型字段。由于捕获时并不知道原消息是什么类型, +除前两项外**全部可选**: + +- 通用:`45001` `45002` `45003` +- 文本:`45101` +- 文件族:`45402` `45403` `45405` `45406` `45407` `45408` `45411` `45412` `45416` + `45418` `45424` `45503` `45505` `45511` `45513` `45517` `45518` `45550` + `45802` `45803` `45804` `45815` `45816` +- 语音:`45906` `45911` `45915` `45925` +- 表情:`47601` `47602` +- ARK:`47901` +- 合并转发:`48601` `48602` `48603` + +## 三、语义未验证的字段 - +| tag | 字段名 | 类型 | 观测情况 | +| --- | ------ | ---- | -------- | +| 47422 | `replyFlag47422` | uint64 | 必有。大小接近 `elementId` | +| 47405 | `replyFlag47405` | uint32 | 取值 1(×290)/ 0(×14) | +| 47407 | `replyFlag47407` | uint32 | 取值 1(×207)/ 0(×96) | +| 47415 | `replyFlag47415` | bool | 未知 | +| 47418 | `replyFlag47418` | bool | 未知 | +| 47424 | `replyFlag47424` | uint32 | 恒为 1 | +| 47425 | `replyFlag47425` | uint32 | 恒为 1 | --- diff --git a/docs/database/nt_msg/elements/text.md b/docs/database/nt_msg/elements/text.md index daedaad..cfcbb53 100644 --- a/docs/database/nt_msg/elements/text.md +++ b/docs/database/nt_msg/elements/text.md @@ -1,12 +1,70 @@ # elementType 1 — 文本 / @ -> 🚧 骨架待补充。 - 对应 WeQ 解析实现:`packages/codec/src/proto/msg/element.ts`(`elementType = 1`)。 -## 字段 +文本是最常见的消息段。**@ 提及并不是独立的 elementType**,它同样是 `elementType = 1`, +靠额外字段区分。 + +--- + +## 一、text 与 at 的区分 + +WeQ 在 `element/registry.ts` 里的判据只有一句: + +```ts +const kind = wire.bubbleId ? 'at' : 'text'; +``` + +即:**`45105` 有值就是 @,没值就是普通文本**。 + +@ 元素上三个字段的实际用途(注意字段名是历史遗留,与聊天「气泡」无关): + +| tag | 字段名 | @ 元素中装的东西 | +| --- | ------ | ---------------- | +| 45101 | `textContent` | 展示文本,形如 `@某人 `(末尾带一个空格) | +| 45105 | `bubbleId` | **被 @ 者的 uid** | +| 45103 | `textEncodingFlag` | **被 @ 者的 uin**(QQ 号) | + +`@全体成员` 同样走这条路径,只是 uid / uin 为特殊值。 + +## 二、subType — 是链接安全分级,不是文字样式 + +`45003 (subType)` 在文本上的语义容易误解:它**不表示字体或样式**,而是 QQ 对链接的安全分类。 +全表扫描的结论是:`subType > 0` 只出现在内容是 / 含 URL 的消息上。 + +| 值 | 名称 | 说明 | +| -- | ---- | ---- | +| 0 | PLAIN | 普通文本,不含链接 | +| 1 | EXTERNAL_LINK | 外部链接。会附带 `45112 (urlVerifyFlag)` —— QQ 扫描域名后附的 12 / 24 / 248 字节安全校验负载 | +| 2 | TRUSTED_LINK | 可信链接(腾讯系域名:`docs.qq.com` / `mp.weixin.qq.com` …)。**不带 45112**,QQ 对自家域名跳过安全检查 | + +## 三、字段 + +### 核心 + +| tag | 字段名 | 类型 | 必有 | 含义 | +| --- | ------ | ---- | ---- | ---- | +| 45101 | `textContent` | string | ✅ | 文本内容 | +| 45102 | `textReserve` | uint32 | | 文本信封标志。构造 @ 时 WeQ 写 2 | + +### 观测到但语义未验证 + +以下字段在真实文本行上出现过,会被解析(这样 tag 字典能给它们名字),但既不会被提升进 +`TextElement` 的渲染路径,也不参与回写。含义列均为**推测**,无一验证。 + +| tag | 字段名 | 类型 | 推测含义 | +| --- | ------ | ---- | -------- | +| 45103 | `textEncodingFlag` | uint32 | 文本编码 / 加密标志。**@ 元素中确定装的是被 @ 者 uin** | +| 45104 | `fontStyle` | uint32 | 字体 / 样式相关 | +| 45105 | `bubbleId` | string | 气泡 ID。**实际用途见上:@ 的目标 uid** | +| 45106 | `textInputState` | uint32 | 文本输入状态 | +| 45108 | `translationFlag` | uint32 | 翻译 / 转换标志 | +| 45109 | `linkDetectionFlag` | uint32 | 链接识别标志 | +| 45110 | `atMentionMask` | string | @ 相关位掩码(字符串编码) | +| 45111 | `walletFlag` | uint32 | 红包 / 钱包含义标志 | +| 45112 | `urlVerifyFlag` | bytes | 网址校验字段,见上方 subType=1 | - +> `45107` 至今未观测到。 --- diff --git a/docs/database/nt_msg/elements/video.md b/docs/database/nt_msg/elements/video.md index 329fbde..42de3f5 100644 --- a/docs/database/nt_msg/elements/video.md +++ b/docs/database/nt_msg/elements/video.md @@ -1,12 +1,88 @@ # elementType 5 — 视频 -> 🚧 骨架待补充。 - 对应 WeQ 解析实现:`packages/codec/src/proto/msg/element.ts`(`elementType = 5`)。 -## 字段 +短视频。复用「文件族」tag:`45402` `45405` `45406` `45408` `45411` `45412` `45415` +`45418` `45503` `45505` `45511` `45513` `45517` `45518` `45815`, +共用部分见 [40800 总览 · 第五节](../40800.md#五跨类型共用的文件族字段)。 + +--- + +## 一、宽高有两套 + +视频行上同时存在两组宽高,来源不同: + +| 用途 | tag | +| ---- | --- | +| 封面图(缩略图)宽高 | `45411` / `45412`(`imgWidth` / `imgHeight`,文件族共用) | +| **视频本身**宽高 | `45413` / `45414`(`videoWidth` / `videoHeight`) | + +## 二、两级过期时间 + +视频在服务端有**两段**过期:第一次过期下线原片,第二次过期把它从服务端彻底清除。 + +| tag | 字段名 | 含义 | +| --- | ------ | ---- | +| 45515 | `expireTimestamp` | 第一级过期时间,unix 秒 | +| 45516 | `validPeriodSec` | 有效期长度,秒 | +| 45519 | `secondExpireTimestamp` | 第二级过期时间,unix 秒 | + +## 三、必有字段 + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 45003 | `subType` | uint32 | 视频文件类型判别式,尚未逐一映射(代码里留有 `VideoSubType` 的 TODO) | +| 45410 | `videoDuration` | uint32 | 时长(秒) | +| 45413 | `videoWidth` | uint32 | 视频宽 | +| 45414 | `videoHeight` | uint32 | 视频高 | +| 45421 | `videoFlag45421` | bytes | 未知字节串 | +| 45422 | `coverFileName` | string | 封面(缩略图)文件名 | +| 45423 | `videoFlag45423` | bool | 未知布尔标志 | +| 45510 | `videoToken` | string | 下载 token | +| 45515 | `expireTimestamp` | uint32 | 见上 | +| 45516 | `validPeriodSec` | uint32 | 见上 | +| 45519 | `secondExpireTimestamp` | uint32 | 见上 | +| 45862 | `channelParams` | bytes | 文件通道参数 | +| 45863 | `videoFlag45863` | uint32 | 未知整数 | + +## 四、可选字段 + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 45404 | `videoCoverLocalPath` | string | 封面的本地缓存路径(`nt_data/Video/…/Thumb/…_0.png`) | +| 45954 | `fileThumbLocalPath` | string | 缩略图本地缓存路径 | +| 45507 / 45509 | 传输标记 | | 见总览 | + +## 五、观测到但语义未验证 + +| tag | 观测情况 | +| --- | -------- | +| 45852 / 45853 / 45854 | 近似恒为 0(各有一行为 1) | +| 45855 | 恒为 0 | +| 45856 | 嵌套块 `45857..45861`,所有观测行的五个子字段全为空 / 0 | +| 45865 | 取值 0(×30)/ 2(×4) | + +## 六、`45851` —— 视频封装格式 + +本地样本里 `45851` 恒为 2,一度以为是常量;对照 QQ NT 自身的 `NTVideoType` 枚举可知, +它其实是**视频封装格式**——恒为 2 只是因为发到 QQ 的视频几乎都会被转成 MP4。 + +| 值 | 名称 | 格式 | +| -- | ---- | ---- | +| 1 | VIDEO_FORMAT_AVI | AVI | +| 2 | VIDEO_FORMAT_MP4 | **MP4**(实际观测到的唯一取值) | +| 3 | VIDEO_FORMAT_WMV | WMV | +| 4 | VIDEO_FORMAT_MKV | MKV | +| 5 | VIDEO_FORMAT_RMVB | RMVB | +| 6 | VIDEO_FORMAT_RM | RM | +| 7 | VIDEO_FORMAT_AFS | AFS | +| 8 | VIDEO_FORMAT_MOV | MOV | +| 9 | VIDEO_FORMAT_MOD | MOD | +| 10 | VIDEO_FORMAT_TS | TS | +| 11 | VIDEO_FORMAT_MTS | MTS | - +> 枚举仅记录在此文档,未进代码:解析层用不到它,`45851` 目前仍以 +> `videoFlag45851` 的名义原样解析、原样回写。 --- diff --git a/docs/database/nt_msg/elements/wallet.md b/docs/database/nt_msg/elements/wallet.md index 34b5e57..9153090 100644 --- a/docs/database/nt_msg/elements/wallet.md +++ b/docs/database/nt_msg/elements/wallet.md @@ -1,12 +1,77 @@ # elementType 9 — 红包 / 转账 -> 🚧 骨架待补充。 - 对应 WeQ 解析实现:`packages/codec/src/proto/msg/element.ts`(`elementType = 9`)。 -## 字段 +QQ 钱包相关消息:转账、各类红包。tag 段为 `484xx`。 + +--- + +## 一、红包类型(48412) + +`RedbagType` 中 TRANSFER / NORMAL / PASSWORD / VOICE 来自早期逆向; +LUCKY(3) 与 DESIGNATED(8) 为**实测确认**(对比同群的拼手气红包与专属红包的 msgBody)。 + +| 值 | 名称 | 说明 | +| -- | ---- | ---- | +| 1 | TRANSFER | 转账 | +| 2 | NORMAL | 普通红包(等额) | +| 3 | LUCKY | 拼手气红包 | +| 6 | PASSWORD | 口令红包 | +| 8 | DESIGNATED | **专属红包**(指定领取人),会额外带 `48420` | +| 15 | VOICE | 语音红包 | + +## 二、主要字段 + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 48401 | `walletTargetUin` | uint32 | 目标 uin | +| 48402 | `walletTransferProto` | bytes | 转账 protobuf 字节 | +| 48403 | `walletDetail` | message | 钱包详情,见下 | +| 48409 | `walletOrderId` | string | 订单 ID | +| 48412 | `walletRedbagType` | uint32 | **红包类型**,见上表 | +| 48420 | `walletDesignatedUin` | uint32 | **指定领取人 uin**。只在专属红包(`48412 = 8`)上出现,是唯一被允许领取的群成员 | +| 48421 | `walletExt` | message | 扩展字段,见下 | + +## 三、`walletDetail`(48403) + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 48442 | `redbagType` | uint32 | 红包类型(与 48412 呼应) | +| 48443 | `redbagTitle` | string | 红包标题 | +| 48444 | `openPrompt` | string | 「开」的提示文案 | +| 48445 | `subTitle` | string | 副标题 | +| 48448 | `display` | string | 展示文案 | +| 48454 | `orderUrl` | string | 订单链接 | +| 48441 | `flag48441` | uint32 | 未知 | +| 48446 / 48447 | `flag48446` / `flag48447` | string | 未知 | +| 48449 / 48450 | `flag48449` / `flag48450` | uint32 | 未知 | +| 48451 / 48452 / 48453 | `flag4845x` | string | 未知 | +| 48461 | `flag48461` | bytes | 未知 | + +## 四、`walletExt`(48421) + +注意这个嵌套块用的是**小 tag**(1..8),不是 `484xx` 段。 + +| tag | 字段名 | 类型 | 含义 | +| --- | ------ | ---- | ---- | +| 5 | `redbagCover` | string | **红包封面** | +| 3 | `flag3` | bool | 未知 | +| 7 | `flag7` | bool | 未知 | +| 8 | `flag8` | bool | 未知 | + +## 五、语义未验证的标量 + +以下 tag 为往返保真而解析,含义未定: - +| tag | 类型 | +| --- | ---- | +| 48404 / 48405 / 48406 / 48407 / 48408 | uint32 | +| 48410 | string | +| 48411 | uint32 | +| 48417 | bytes | +| 48418 | string | +| 48419 | uint32 | +| 48437 / 48438 | uint32 | --- diff --git a/docs/database/nt_msg/index.md b/docs/database/nt_msg/index.md index ade8d34..a6de598 100644 --- a/docs/database/nt_msg/index.md +++ b/docs/database/nt_msg/index.md @@ -2,7 +2,8 @@ `nt_msg.db` 的消息行中,最复杂的两列是 `40800`(消息正文)与 `40900`(消息缓存)。二者均为 protobuf,文档站仅有零散字段引用,这里由 WeQ 依据实际解析实现单独系统维护。 -> 🚧 本章节为骨架结构,内容待补充。 +> 📖 建议先读 [40800 解析](./40800.md) 的前半部分 —— 「扁平信封」与「文件族共用字段」 +> 两节是理解所有消息段的前提,各消息段文档不再重复这些内容。 ## 两列职责 @@ -13,7 +14,9 @@ ## 消息段(Element)索引 -`40800` 由若干消息段(Element)组成,每段以 `elementType` 区分类型。各类型的字段解析见下: +`40800` 由若干消息段(Element)组成,每段以 `elementType` 区分类型。各类型的字段解析见下; +未观测到的 elementType(12/13/15/17/18/19/20/22/24/25/29/43/44)不单独成篇, +完整枚举见 [40800 解析 · elementType 全表](./40800.md#elementtype-全表)。 | elementType | 名称 | 文档 | | ----------- | --------------------- | ------------------------------------------- | @@ -34,6 +37,7 @@ | 23 | 在线文件 | [online-file](./elements/online-file.md) | | 26 | 空间动态提示 | [qq-dynamic](./elements/qq-dynamic.md) | | 27 | 弹射表情 | [emoji-bounce](./elements/emoji-bounce.md) | +| 28 | 位置共享 | 仅一个字段,见 [40800 解析](./40800.md#elementtype-全表) | | 30 | 在线文件夹 | [online-folder](./elements/online-folder.md)| --- From 0474efe6c42db5458bd5107f506ac2c5ef1821e3 Mon Sep 17 00:00:00 2001 From: H3CoF6 Date: Fri, 31 Jul 2026 22:34:27 +0800 Subject: [PATCH 3/8] fix: call in group render(grapTip for callElement) and update docs --- .../src/components/GroupCallEndedMessage.tsx | 50 ++++ .../src/renderer/src/components/QqCall.tsx | 13 +- .../src/im-template/template/chatPane.tsx | 10 + .../desktop/src/renderer/src/styles/index.css | 12 + docs/database/nt_msg/elements/call.md | 99 ++++++-- packages/codec/src/element/types.ts | 33 +++ packages/db/tools/dump_call_element.ts | 48 ++++ packages/db/tools/scan_call_types.ts | 220 ++++++++++++++++++ 8 files changed, 466 insertions(+), 19 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/GroupCallEndedMessage.tsx create mode 100644 packages/db/tools/dump_call_element.ts create mode 100644 packages/db/tools/scan_call_types.ts diff --git a/apps/desktop/src/renderer/src/components/GroupCallEndedMessage.tsx b/apps/desktop/src/renderer/src/components/GroupCallEndedMessage.tsx new file mode 100644 index 0000000..c4bd6da --- /dev/null +++ b/apps/desktop/src/renderer/src/components/GroupCallEndedMessage.tsx @@ -0,0 +1,50 @@ +/** + * 群通话「已结束」灰条 (CALL element, elementType=21, subType=16/25). + * + * 群聊和私聊的通话记录结构完全不同:私聊一条消息就是整通电话的最终状态(接通 / + * 未接 / 拒绝),群聊则拆成两条独立消息 —— 「XXX 发起了语音通话」(callMethod= + * 1/2,40020 是发起人)和「语音通话已结束」(callMethod=0,40020 为空),中间没 + * 有任何状态。 + * + * 发起那条有正常的发送人,仍走气泡(QqCall);结束这条谁也不属于,套气泡会凭空 + * 多出一个发送者,所以画成居中灰条。 + */ + +import { Phone, Video } from 'lucide-react'; + +interface GroupCallEndedMessageProps { + element: { + type: 'call'; + data?: { + subType?: number; + callSummary?: string[]; + }; + }; +} + +/** CALL subType:群通话结束。见 packages/codec/src/element/types.ts。 */ +const GROUP_VOICE_ENDED = 16; +const GROUP_VIDEO_ENDED = 25; + +export const GROUP_CALL_ENDED_SUBTYPES = new Set([ + GROUP_VOICE_ENDED, + GROUP_VIDEO_ENDED, +]); + +export function GroupCallEndedMessage({ element }: GroupCallEndedMessageProps) { + const { subType, callSummary } = element.data || {}; + const isVideo = Number(subType) === GROUP_VIDEO_ENDED; + const Icon = isVideo ? Video : Phone; + + // QQ 自己写好的文案("语音通话已结束"),拿不到就自己拼。 + const summary = Array.isArray(callSummary) + ? callSummary.filter((s) => typeof s === 'string' && s).join(' ') + : ''; + + return ( +
+ + {summary || `${isVideo ? '视频通话' : '语音通话'}已结束`} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/QqCall.tsx b/apps/desktop/src/renderer/src/components/QqCall.tsx index b65e4d3..c0e4115 100644 --- a/apps/desktop/src/renderer/src/components/QqCall.tsx +++ b/apps/desktop/src/renderer/src/components/QqCall.tsx @@ -17,15 +17,26 @@ import { Phone, Video, MonitorUp, LaptopMinimal, PhoneCall } from 'lucide-react' // CallSubType values that mean "the call actually connected". Every other // value (rejected by either side, handled on another device, plain failure) // gets the red treatment. See packages/codec/src/element/types.ts. +// +// 群聊的「发起」(1/26)也算非失败 —— 那是一通电话的开头,不是未接来电。群聊的 +// 「已结束」(16/25)走灰条(GroupCallEndedMessage),走不到这里,但导出/转发等 +// 旁路会,所以一并列上。 const CONNECTED_SUBTYPES = new Set([ + 1, // GROUP_VOICE_STARTED 2, // VIDEO_ACCEPTED + 5, // VIDEO_ACCEPTED_LEGACY — 旧版客户端的视频接通 7, // VOICE_ACCEPTED + 16, // GROUP_VOICE_ENDED 19, // SCREEN_SHARE_ACCEPTED + 25, // GROUP_VIDEO_ENDED + 26, // GROUP_VIDEO_STARTED 33, // REMOTE_ASSIST_ACCEPTED ]); -// CallType (callMethod) → display label + lucide icon. +// CallType (callMethod) → display label + lucide icon. 0 是群通话结束提示, +// QQ 不在这条消息里区分语音/视频(只能看 subType),所以用中性的话筒图标。 const CALL_KIND: Record = { + 0: { label: '通话已结束', Icon: PhoneCall }, 1: { label: '语音通话', Icon: Phone }, 2: { label: '视频通话', Icon: Video }, 3: { label: '屏幕共享', Icon: MonitorUp }, diff --git a/apps/desktop/src/renderer/src/im-template/template/chatPane.tsx b/apps/desktop/src/renderer/src/im-template/template/chatPane.tsx index 8ec1d64..7c161dd 100644 --- a/apps/desktop/src/renderer/src/im-template/template/chatPane.tsx +++ b/apps/desktop/src/renderer/src/im-template/template/chatPane.tsx @@ -87,6 +87,7 @@ import { GrayTipGroupMessage } from '../../components/GrayTipGroupMessage'; import { GrayTipInviteMessage } from '../../components/GrayTipInviteMessage'; import { GrayTipFileRecvMessage } from '../../components/GrayTipFileRecvMessage'; import { GrayTipTempSessionMessage } from '../../components/GrayTipTempSessionMessage'; +import { GroupCallEndedMessage, GROUP_CALL_ENDED_SUBTYPES } from '../../components/GroupCallEndedMessage'; const composerHeightStorageKey = "chat-template.layout.composerHeight"; const groupInfoCollapsedStorageKey = "chat-template.layout.groupInfoCollapsed"; @@ -1490,6 +1491,9 @@ export function ChatPane({ ) : ( (() => { // Detect the gray-tip element (if any) a message carries. + // 群通话的「已结束」(CALL 元素,subType 16/25)也走灰条:那条消息的 + // 40020 是空的,谁也不属于,套气泡会凭空多出一个发送者。发起那条有正常 + // 发送人,和私聊的 CALL 一样继续走气泡。 const GRAY_TIP_KINDS = ['grayTipPoke', 'grayTipRevoke', 'grayTipGroup', 'grayTipInvite', 'grayTipFileRecv', 'grayTipTempSession']; const grayTipOf = (message) => { const els = message.qqElements ?? []; @@ -1497,6 +1501,10 @@ export function ChatPane({ const el = els.find((e) => e?.type === kind); if (el) return { kind, el }; } + const callEnded = els.find( + (e) => e?.type === 'call' && GROUP_CALL_ENDED_SUBTYPES.has(Number(e?.data?.subType)), + ); + if (callEnded) return { kind: 'groupCallEnded', el: callEnded }; return null; }; @@ -1515,6 +1523,8 @@ export function ChatPane({ return ; case 'grayTipTempSession': return ; + case 'groupCallEnded': + return ; default: return null; } diff --git a/apps/desktop/src/renderer/src/styles/index.css b/apps/desktop/src/renderer/src/styles/index.css index 3a9025d..f019c2a 100644 --- a/apps/desktop/src/renderer/src/styles/index.css +++ b/apps/desktop/src/renderer/src/styles/index.css @@ -10971,6 +10971,18 @@ html[data-theme="dark"] .weq-graytip-band { color: var(--weq-fg-muted, var(--weq-fg-tertiary, var(--weq-fg-secondary))); } +/* 群通话灰条:图标与文字同行居中(发起人名字用 .weq-graytip-accent 着色)。 */ +.weq-group-call { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; +} +.weq-group-call-icon { + flex-shrink: 0; + color: currentColor; +} + /* "本地暂无内容" hint — centered, small, non-bold, accent-leaning. */ .weq-graytip-band-hint { margin-top: 2px; diff --git a/docs/database/nt_msg/elements/call.md b/docs/database/nt_msg/elements/call.md index 35253f4..0d460c9 100644 --- a/docs/database/nt_msg/elements/call.md +++ b/docs/database/nt_msg/elements/call.md @@ -1,9 +1,12 @@ # elementType 21 — 通话记录 对应 WeQ 解析实现:`packages/codec/src/proto/msg/element.ts`(`elementType = 21`)。 +枚举定义:`packages/codec/src/element/types.ts`(`CallType` / `CallSubType`)。 音视频通话记录(QQ 内部名 AVRECORD):语音通话、视频通话、屏幕共享、远程协助的结果条目。 +> ⚠️ **私聊与群聊是两套结构**,`subType` 的编号也不互通,详见第四节。 + --- ## 一、字段 @@ -11,19 +14,32 @@ | tag | 字段名 | 类型 | 必有 | 含义 | | --- | ------ | ---- | ---- | ---- | | 48151 | `answerType` | uint32 | ✅ | 接听 / 挂断类型,与 `subType` 一致,取值见 `CallSubType` | -| 48152 | `duration` | uint32 | ✅ | **通话时长(毫秒)** | +| 48152 | `duration` | uint32 | ✅ | **通话时长(毫秒)**,但旧版客户端写的是时间戳,见下方说明 | | 48154 | `callMethod` | uint32 | ✅ | **通话方式**,见下表 | | 48157 | `callSummary` | string(repeated) | ✅ | 通话摘要文案 | -| 48153 | `callFlag48153` | string | | 协议标志(长度分隔) | +| 48153 | `callFlag48153` | string | | 协议标志(长度分隔)。实测恒等于 `callSummary` 去掉前缀后的正文 | | 48155 | `callUnknownType` | uint32 | | 未知类型标志。观测到 0 / 1 / 2 或缺失 | -| 48156 | `callFlag48156` | uint32 | | 协议标志 | +| 48156 | `callFlag48156` | uint32 | | 协议标志。可当作**客户端世代标记**:旧版写 0,新版写 1 | > ⚠️ `48152` 的单位是**毫秒**,与语音元素的 `45906`(秒)不同。 +### `duration` 的世代差异(坑) + +2025 年前后 QQ 重构过通话模块,`48152` 的语义跟着变了: + +| 世代 | `callFlag48156` | `duration` 实际内容 | `callSummary` 形态 | +| ---- | --------------- | ------------------- | ------------------ | +| 旧版 | `0` | **unix 秒级时间戳**(如 `1726891067`) | 带方式前缀:`[语音通话] 通话时长 00:35` | +| 新版 | `1` | 毫秒时长(如 `1001` = 00:01) | 无前缀:`通话时长 00:01` | + +所以**不要直接拿 `duration` 格式化时长** —— 旧记录会算出荒谬的值。渲染一律优先用 +QQ 已经排好版的 `callSummary`(WeQ 的 `QqCall` 就是这么做的)。 + ## 二、`callMethod`(48154) | 值 | 名称 | 说明 | | -- | ---- | ---- | +| 0 | GROUP_ENDED | **群聊**「通话已结束」提示,无方式字段;具体是语音还是视频只能看 `subType` | | 1 | VOICE | 语音通话 | | 2 | VIDEO | 视频通话 | | 3 | SCREEN_SHARE | 屏幕共享 | @@ -31,22 +47,69 @@ ## 三、`subType` / `answerType` -`45003 (subType)` 与 `48151 (answerType)` 取值一致,合起来描述「什么类型的通话、以什么方式结束」: +`45003 (subType)`、`48151 (answerType)` 与消息行的 `40012` 列三者取值一致,合起来 +描述「什么类型的通话、以什么方式结束」。 -| 值 | 名称 | 说明 | -| -- | ---- | ---- | -| 2 | VIDEO_ACCEPTED | 视频通话已接通 | -| 3 | VIDEO_REJECTED_BY_US | 视频通话被本方拒绝 | -| 6 | VIDEO_REJECTED_BY_PEER | 视频通话被对方拒绝 | -| 7 | VOICE_ACCEPTED | 语音通话已接通 | -| 8 | VOICE_REJECTED_BY_US | 语音通话被本方拒绝 | -| 11 | VOICE_REJECTED_BY_PEER | 语音通话被对方拒绝 | -| 12 | VIDEO_HANDLED_OTHER_DEVICE | 视频通话已在其它设备处理 | -| 13 | VOICE_HANDLED_OTHER_DEVICE | 语音通话已在其它设备处理 | -| 19 | SCREEN_SHARE_ACCEPTED | 屏幕共享已接通 | -| 22 | SCREEN_SHARE_REJECTED | 屏幕共享被拒绝 | -| 33 | REMOTE_ASSIST_ACCEPTED | 远程协助已接通 | -| 34 | REMOTE_ASSIST_FAILED | 远程协助失败 | +### 私聊(c2c_msg_table) + +一条消息 = 一整通电话的**最终状态**,中间过程不落库。 + +| 值 | 名称 | 摘要文案 | 说明 | +| -- | ---- | -------- | ---- | +| 2 | VIDEO_ACCEPTED | 通话时长 xx:xx | 视频通话已接通 | +| 3 | VIDEO_REJECTED_BY_US | 未接听,点击回拨 | 视频通话被本方拒绝 | +| 5 | VIDEO_ACCEPTED_LEGACY | [视频通话] 通话时长 xx:xx | 视频接通,**旧版客户端编号**,语义同 2 | +| 6 | VIDEO_REJECTED_BY_PEER | 对方已拒绝 | 视频通话被对方拒绝 | +| 7 | VOICE_ACCEPTED | 通话时长 xx:xx | 语音通话已接通 | +| 8 | VOICE_REJECTED_BY_US | 未接听,点击回拨 | 语音通话被本方拒绝 | +| 9 | VOICE_PEER_NO_ANSWER | 对方未接听 | 我方拨出、对方一直没接(**旧版客户端**) | +| 10 | VOICE_CANCELED_BY_US | 已取消,点击重拨 | 我方拨出后自己取消 | +| 11 | VOICE_REJECTED_BY_PEER | 对方已拒绝 | 语音通话被对方拒绝 | +| 12 | VIDEO_HANDLED_OTHER_DEVICE | 已在其他设备处理 | 视频通话已在其它设备处理 | +| 13 | VOICE_HANDLED_OTHER_DEVICE | 已在其他设备处理 | 语音通话已在其它设备处理 | +| 19 | SCREEN_SHARE_ACCEPTED | 通话时长 xx:xx | 屏幕共享已接通 | +| 22 | SCREEN_SHARE_REJECTED | 对方未接听 | 屏幕共享被拒绝 | +| 33 | REMOTE_ASSIST_ACCEPTED | 远程协助时长 xx:xx | 远程协助已接通 | +| 34 | REMOTE_ASSIST_FAILED | 已取消,点击重新发起 | 远程协助失败 | + +> 5 / 9 是旧版客户端才写的编号(`callFlag48156 = 0`),2025 年后的记录不再出现。 +> 保留是为了历史消息能正确渲染。 + +### 群聊(group_msg_table) + +**拆成两条独立消息**,中间状态(谁加入、谁离开、通了多久)一概不落库: + +| 值 | 名称 | `callMethod` | 40020(发送者) | 摘要文案 | +| -- | ---- | ------------ | --------------- | -------- | +| 1 | GROUP_VOICE_STARTED | 1 (VOICE) | 发起人 uid | 发起了语音通话 | +| 26 | GROUP_VIDEO_STARTED | 2 (VIDEO) | 发起人 uid | 发起了视频通话 | +| 16 | GROUP_VOICE_ENDED | **0** | **空** | 语音通话已结束 | +| 25 | GROUP_VIDEO_ENDED | **0** | **空** | 视频通话已结束 | + +结束那条的 `40020` / `40033` 都是空的 —— 它不属于任何人,QQ 电脑端也把它画成居中 +灰条。另外**只有本机发起的通话才会写「发起」消息**:别人在群里发起时本机只收得到 +结束提示,所以历史记录里「结束」往往比「发起」多。 + +## 四、渲染约定(WeQ) + +| 场景 | 组件 | 形态 | +| ---- | ---- | ---- | +| 私聊全部 | `QqCall` | 气泡内联:图标 + `callSummary`,失败/拒绝/未接标红 | +| 群聊「发起」(1/26) | `QqCall` | 同上,气泡带发起人头像与昵称 | +| 群聊「已结束」(16/25) | `GroupCallEndedMessage` | 居中灰条,无发送者 | + +分流在 `apps/desktop/src/renderer/src/im-template/template/chatPane.tsx` 的 +`grayTipOf()` 里:CALL 元素且 `subType ∈ {16, 25}` 的走灰条 band,其余照常走气泡。 + +## 五、排查工具 + +```bash +# 全表扫描,统计 (callMethod, subType) 组合并列出未枚举值的会话与时间 +pnpm tsx packages/db/tools/scan_call_types.ts + +# 解码指定 msgId 的 CALL 元素(含原始 hex) +pnpm tsx packages/db/tools/dump_call_element.ts [ ...] +``` --- diff --git a/packages/codec/src/element/types.ts b/packages/codec/src/element/types.ts index d582ae5..53197e1 100644 --- a/packages/codec/src/element/types.ts +++ b/packages/codec/src/element/types.ts @@ -234,22 +234,55 @@ export enum TipGroupElementType { KDISBANDORBERECYCLED = 10, } +/** + * CALL 元素的 subType(= wire tag 48151 `answerType`,也等于消息行的 40012 列)。 + * + * 私聊与群聊是两套编号:私聊一条消息就是一整通电话的最终状态(接通 / 未接 / + * 拒绝 / 取消),群聊则拆成「发起」与「已结束」两条独立消息,中间状态不落库。 + * + * 2025 年前后 QQ 重构过通话模块,旧客户端与新客户端对同一语义会写不同的编号 + * (例如视频接通旧版写 5、新版写 2),所以两个都得留着。旧记录还能靠 + * `callFlag48156 === 0`、`duration` 存的是 unix 秒级时间戳(新版是毫秒时长)、 + * 以及 callSummary 带「[语音通话] 」前缀来辨认。 + */ export enum CallSubType { + /** 群聊:某人发起了语音通话(callMethod=VOICE,发送者即发起人)。 */ + GROUP_VOICE_STARTED = 1, VIDEO_ACCEPTED = 2, VIDEO_REJECTED_BY_US = 3, + /** 视频接通(旧版客户端编号,语义同 VIDEO_ACCEPTED)。 */ + VIDEO_ACCEPTED_LEGACY = 5, VIDEO_REJECTED_BY_PEER = 6, VOICE_ACCEPTED = 7, VOICE_REJECTED_BY_US = 8, + /** 我方拨出、对方一直没接(旧版客户端)。 */ + VOICE_PEER_NO_ANSWER = 9, + /** 我方拨出后自己取消 —「已取消,点击重拨」。 */ + VOICE_CANCELED_BY_US = 10, VOICE_REJECTED_BY_PEER = 11, VIDEO_HANDLED_OTHER_DEVICE = 12, VOICE_HANDLED_OTHER_DEVICE = 13, + /** 群聊:语音通话已结束(callMethod=0,无发送者)。 */ + GROUP_VOICE_ENDED = 16, SCREEN_SHARE_ACCEPTED = 19, SCREEN_SHARE_REJECTED = 22, + /** 群聊:视频通话已结束(callMethod=0,无发送者)。 */ + GROUP_VIDEO_ENDED = 25, + /** 群聊:某人发起了视频通话(callMethod=VIDEO,发送者即发起人)。 */ + GROUP_VIDEO_STARTED = 26, REMOTE_ASSIST_ACCEPTED = 33, REMOTE_ASSIST_FAILED = 34, } +/** + * CALL 元素的通话方式(wire tag 48154 `callMethod`)。 + * + * 群聊的「通话已结束」消息写 0:那条消息不属于任何发起人(40020 为空),QQ 也 + * 不再区分是语音还是视频 —— 具体类型只能从 subType(16 / 25)看出来。 + */ export enum CallType { + /** 群通话结束提示,无方式字段。 */ + GROUP_ENDED = 0, VOICE = 1, VIDEO = 2, SCREEN_SHARE = 3, diff --git a/packages/db/tools/dump_call_element.ts b/packages/db/tools/dump_call_element.ts new file mode 100644 index 0000000..238406a --- /dev/null +++ b/packages/db/tools/dump_call_element.ts @@ -0,0 +1,48 @@ +/** + * Decode the CALL elements of specific msgIds from group_msg_table / c2c_msg_table. + * + * Run: pnpm tsx packages/db/tools/dump_call_element.ts [ ...] + */ + +import { loadNative } from '@weq/native'; +import { testEnv } from '@weq/testkit'; +import { QqDb } from '../src/qq_db'; +import { decodeBody } from '../src/msg/util'; + +const ALGO = { pageHmacAlgorithm: 'SHA1', kdfHmacAlgorithm: 'SHA512' } as const; + +const json = (v: unknown) => + JSON.stringify(v, (_k, x) => (typeof x === 'bigint' ? x.toString() : x), 2); + +async function main(): Promise { + const ids = process.argv.slice(2); + const native = loadNative(); + const db = new QqDb(native.ntHelper, { + dbPath: testEnv.msgDbPath, + key: testEnv.key, + algo: ALGO, + }); + + for (const id of ids) { + for (const table of ['group_msg_table', 'c2c_msg_table']) { + const rows = await db.query( + `SELECT "40001","40012","40020","40033","40050","40800" FROM ${table} WHERE "40001" = ?`, + [id], + ); + for (const r of rows) { + console.log( + `\n═══ ${table} msgId=${id} 40012=${r[1]} sender=${r[3]}(${r[2]}) time=${r[4]} ═══`, + ); + console.log('raw 40800 hex:', Buffer.from(r[5] as Uint8Array).toString('hex')); + console.log(json(decodeBody(r[5]))); + } + } + } + + db.close(); +} + +main().catch((e) => { + console.error('failed:', e); + process.exit(1); +}); diff --git a/packages/db/tools/scan_call_types.ts b/packages/db/tools/scan_call_types.ts new file mode 100644 index 0000000..90f6795 --- /dev/null +++ b/packages/db/tools/scan_call_types.ts @@ -0,0 +1,220 @@ +/** + * Scan every message table for CALL elements (elementType=21) and tally the + * distinct (callMethod, subType/answerType) combinations, flagging the ones + * missing from CallType / CallSubType in packages/codec/src/element/types.ts. + * + * For each unknown combination we print the conversation key and send time of + * up to a few sample messages so they can be located in QQ itself. + * + * Run: pnpm tsx packages/db/tools/scan_call_types.ts + */ + +import { loadNative } from '@weq/native'; +import { testEnv } from '@weq/testkit'; +import { QqDb } from '../src/qq_db'; +import { decodeBody } from '../src/msg/util'; + +const ALGO = { pageHmacAlgorithm: 'SHA1', kdfHmacAlgorithm: 'SHA512' } as const; + +const KNOWN_METHOD: Record = { + 1: 'VOICE', + 2: 'VIDEO', + 3: 'SCREEN_SHARE', + 5: 'REMOTE_ASSIST', +}; + +const KNOWN_SUBTYPE: Record = { + 2: 'VIDEO_ACCEPTED', + 3: 'VIDEO_REJECTED_BY_US', + 6: 'VIDEO_REJECTED_BY_PEER', + 7: 'VOICE_ACCEPTED', + 8: 'VOICE_REJECTED_BY_US', + 11: 'VOICE_REJECTED_BY_PEER', + 12: 'VIDEO_HANDLED_OTHER_DEVICE', + 13: 'VOICE_HANDLED_OTHER_DEVICE', + 19: 'SCREEN_SHARE_ACCEPTED', + 22: 'SCREEN_SHARE_REJECTED', + 33: 'REMOTE_ASSIST_ACCEPTED', + 34: 'REMOTE_ASSIST_FAILED', +}; + +interface Sample { + table: string; + msgId: string; + conv: string; + senderUin: string; + time: string; + duration: number; + summary: string; + answerType: number; + unknownType: number | undefined; + flag48153: string | undefined; + flag48156: number | undefined; +} + +interface Bucket { + count: number; + samples: Sample[]; +} + +const fmtTime = (sec: number): string => { + if (!sec) return '(no time)'; + const d = new Date(sec * 1000); + const p = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; +}; + +const num = (v: unknown): number => (typeof v === 'bigint' ? Number(v) : Number(v ?? 0)); +const str = (v: unknown): string => (v === null || v === undefined ? '' : String(v)); + +const TABLES: { name: string; convCol: string; label: string }[] = [ + { name: 'group_msg_table', convCol: '40027', label: '群' }, + { name: 'c2c_msg_table', convCol: '40030', label: '私聊对端QQ' }, + { name: 'dataline_msg_table', convCol: '40030', label: '数据线' }, +]; + +const MAX_SAMPLES = 20; + +async function main(): Promise { + const native = loadNative(); + const db = new QqDb(native.ntHelper, { + dbPath: testEnv.msgDbPath, + key: testEnv.key, + algo: ALGO, + }); + + const buckets = new Map(); + const methodTally = new Map(); + const subTypeTally = new Map(); + let totalCalls = 0; + + for (const t of TABLES) { + let rows: Awaited>; + try { + rows = await db.query( + `SELECT "40001","${t.convCol}","40033","40050","40800" FROM ${t.name}`, + [], + ); + } catch (e) { + console.log(`skip ${t.name}: ${(e as Error).message}`); + continue; + } + let calls = 0; + for (const r of rows) { + const blob = r[4]; + if (!(blob instanceof Uint8Array)) continue; + let els: ReturnType; + try { + els = decodeBody(blob); + } catch { + continue; + } + for (const el of els ?? []) { + const e = el as Record; + if (num(e.elementType) !== 21 && e.kind !== 'call') continue; + calls++; + totalCalls++; + const method = num(e.callMethod); + const sub = num(e.subType); + methodTally.set(method, (methodTally.get(method) ?? 0) + 1); + subTypeTally.set(sub, (subTypeTally.get(sub) ?? 0) + 1); + + const key = `${method}|${sub}`; + let b = buckets.get(key); + if (!b) { + b = { count: 0, samples: [] }; + buckets.set(key, b); + } + b.count++; + if (b.samples.length < MAX_SAMPLES) { + const summary = Array.isArray(e.callSummary) + ? (e.callSummary as unknown[]).map(String).join(' / ') + : ''; + b.samples.push({ + table: t.name, + msgId: str(r[0]), + conv: `${t.label} ${str(r[1])}`, + senderUin: str(r[2]), + time: fmtTime(num(r[3])), + duration: num(e.duration), + summary, + answerType: num(e.answerType), + unknownType: e.callUnknownType === undefined ? undefined : num(e.callUnknownType), + flag48153: e.callFlag48153 === undefined ? undefined : String(e.callFlag48153), + flag48156: e.callFlag48156 === undefined ? undefined : num(e.callFlag48156), + }); + } + } + } + console.log(`${t.name}: rows=${rows.length} callElements=${calls}`); + } + + console.log(`\n=== 总计 CALL 元素: ${totalCalls} ===`); + + console.log('\n--- callMethod (48154 / CallType) 分布 ---'); + for (const [m, c] of [...methodTally.entries()].sort((a, b) => a[0] - b[0])) { + const tag = KNOWN_METHOD[m] ?? '*** 未枚举 ***'; + console.log(` callMethod=${m} count=${c} ${tag}`); + } + + console.log('\n--- subType (CallSubType) 分布 ---'); + for (const [s, c] of [...subTypeTally.entries()].sort((a, b) => a[0] - b[0])) { + const tag = KNOWN_SUBTYPE[s] ?? '*** 未枚举 ***'; + console.log(` subType=${s} count=${c} ${tag}`); + } + + const entries = [...buckets.entries()] + .map(([k, b]) => { + const parts = k.split('|'); + return { m: Number(parts[0]), s: Number(parts[1]), b }; + }) + .sort((a, b) => a.m - b.m || a.s - b.s); + + console.log('\n--- (callMethod, subType) 组合矩阵 ---'); + for (const { m, s, b } of entries) { + const unknownM = KNOWN_METHOD[m] === undefined; + const unknownS = KNOWN_SUBTYPE[s] === undefined; + const mark = unknownM || unknownS ? ' <== 未枚举' : ''; + console.log( + ` method=${m}(${KNOWN_METHOD[m] ?? '?'}) subType=${s}(${KNOWN_SUBTYPE[s] ?? '?'}) count=${b.count}${mark}`, + ); + } + + console.log('\n\n================ 未枚举组合的样本 ================'); + let any = false; + for (const { m, s, b } of entries) { + if (KNOWN_METHOD[m] !== undefined && KNOWN_SUBTYPE[s] !== undefined) continue; + any = true; + console.log( + `\n### callMethod=${m} (${KNOWN_METHOD[m] ?? '未枚举'}) subType=${s} (${KNOWN_SUBTYPE[s] ?? '未枚举'}) 共 ${b.count} 条`, + ); + for (const sm of b.samples) { + console.log( + ` - [${sm.time}] ${sm.conv} 发送者=${sm.senderUin} msgId=${sm.msgId}\n` + + ` 表=${sm.table} 时长=${sm.duration} answerType=${sm.answerType}` + + ` unknownType=${sm.unknownType ?? '-'} flag48153=${sm.flag48153 ?? '-'} flag48156=${sm.flag48156 ?? '-'}\n` + + ` 摘要="${sm.summary}"`, + ); + } + } + if (!any) console.log('(没有未枚举的组合)'); + + console.log('\n\n================ 已枚举组合的样本(对照用) ================'); + for (const { m, s, b } of entries) { + if (KNOWN_METHOD[m] === undefined || KNOWN_SUBTYPE[s] === undefined) continue; + console.log(`\n### method=${m}/${KNOWN_METHOD[m]} sub=${s}/${KNOWN_SUBTYPE[s]} count=${b.count}`); + for (const sm of b.samples) { + console.log( + ` - [${sm.time}] ${sm.conv} 发送者=${sm.senderUin} 时长=${sm.duration}` + + ` u=${sm.unknownType ?? '-'} f56=${sm.flag48156 ?? '-'} 摘要="${sm.summary}"`, + ); + } + } + + db.close(); +} + +main().catch((e) => { + console.error('failed:', e); + process.exit(1); +}); From 7140d4bbe396ecdbc509907a02e5c1ae77102a61 Mon Sep 17 00:00:00 2001 From: H3CoF6 Date: Fri, 31 Jul 2026 23:00:12 +0800 Subject: [PATCH 4/8] docs: add contact and unread table docs --- docs/README.md | 2 +- docs/TODO.md | 21 +-- docs/database/index.md | 39 ++--- docs/database/nt_msg/index.md | 43 ++++- docs/database/nt_msg/recent-contact.md | 211 +++++++++++++++++++++++++ docs/database/nt_msg/unread-info.md | 165 +++++++++++++++++++ 6 files changed, 447 insertions(+), 34 deletions(-) create mode 100644 docs/database/nt_msg/recent-contact.md create mode 100644 docs/database/nt_msg/unread-info.md diff --git a/docs/README.md b/docs/README.md index 5809b16..2e477e1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,7 +17,7 @@ Native 部分闭源**仅为防止倒卖**,原理完全公开。 ## 🗄️ 数据库分析 -NTQQ 各数据库表结构与字段解析(并入 [QQBackup](https://github.com/QQBackup) 维护)。 +NTQQ 各数据库的表结构与字段解析,全部依据 WeQ 自己的解析实现手写维护。 - [数据库分析入口](./database/index.md) diff --git a/docs/TODO.md b/docs/TODO.md index 97d9d19..353aa21 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -20,16 +20,21 @@ ## 二、数据库分析(`docs/database/`) -> 通用表结构指向 [QQBackup/QQDecrypt](https://qqbackup.github.io/QQDecrypt/); -> 这里只维护 WeQ 自己 RE 出来、文档站尚未系统化的深度部分。 +> 全部依据 WeQ 自己的解析实现手写维护,不转述二手资料; +> 未解析过的表宁可留空,也不写没验证过的内容。 -### `nt_msg.db` 消息体 +### `nt_msg.db` | 状态 | 文档 | 内容要点 | | ---- | ---- | -------- | -| ✅ | [nt_msg/index.md](./database/nt_msg/index.md) | 两列职责 + 消息段索引 | +| ✅ | [nt_msg/index.md](./database/nt_msg/index.md) | 表一览 + `nt_uid_mapping_table` + 消息段索引 | | ✅ | [40800.md](./database/nt_msg/40800.md) | ElementWire 信封、tag 分段约定、跨类型共用字段族、容错解码 | | ✅ | [40900.md](./database/nt_msg/40900.md) | MsgCache 字段表与递归嵌套 | +| ✅ | [recent-contact.md](./database/nt_msg/recent-contact.md) | 会话列表:列结构、40051 外显预览、ChatType 全表、免打扰 41220 | +| ✅ | [unread-info.md](./database/nt_msg/unread-info.md) | 未读信息:48902 嵌套结构、未读数算法、50000 提醒类别码枚举 | +| ⬜ | database/nt_msg/row.md | 消息行本身的列(40001/40003/40011/40012/40050…)与「删除 / 撤回」签名 | +| ⬜ | database/nt_msg/40062.md | 消息表情回应(贴表情) | +| ⬜ | database/nt_msg/draft.md | `draft_storage_table_v1` 草稿表(尚未解析) | ### 消息段(element)逐类型字段解析 @@ -55,16 +60,14 @@ | ✅ | 30 在线文件夹 | [online-folder.md](./database/nt_msg/elements/online-folder.md) | | ⬜ | 28 位置共享 | 目前只有一个文案字段(52152),暂并入 40800 总览说明 | -### 其它列 / 其它库 +### 其它库 | 状态 | 文档 | 内容要点 | | ---- | ---- | -------- | -| ⬜ | database/nt_msg/row.md | 消息行本身的列(40001/40003/40011/40012/40050/40800…)与「删除 / 撤回」签名 | -| ⬜ | database/nt_msg/40051.md | 会话列表外显预览(PreviewElement + tag 49093) | -| ⬜ | database/nt_msg/40062.md | 消息表情回应(贴表情) | -| ⬜ | database/nt_msg/48902.md | 未读信息块(含特别关心的嵌套结构) | | ⬜ | database/collection.md | `collection.db` 收藏:type ↔ 子标签公式、8 种类型 | | ⬜ | database/profile-group.md | `profile_info.db` / `group_info.db` 中 WeQ 用到的 protobuf 列 | +| ⬜ | database/emoji.md | `emoji.db` 系统表情 / 商城表情包 | +| ⬜ | database/login.md | `login.db` 账号列表 | ## 三、QQ 数据库密钥获取原理(`docs/principles/`) diff --git a/docs/database/index.md b/docs/database/index.md index f5feec2..f222245 100644 --- a/docs/database/index.md +++ b/docs/database/index.md @@ -1,30 +1,31 @@ # 数据库分析 -NTQQ 各数据库的**表结构与字段解析**主要维护在文档站项目 **[QQBackup/QQDecrypt](https://github.com/QQBackup/QQDecrypt)**(在线阅读:)。 +NTQQ 把数据分散在若干个 SQLCipher 加密数据库里。本栏目记录 WeQ **实际解析过**的表与字段 —— +全部依据自己的解析实现手写维护,不转述二手资料;未解析过的部分宁可留空,也不写没验证过的东西。 -WeQ 文档**不重复**收录通用表结构,仅维护与本项目实现强相关、且文档站尚未系统化的深度解析部分。 +## 数据库一览 -## 通用表结构(指向文档站) +| 数据库 | 内容 | 文档 | +| ------ | ---- | ---- | +| `nt_msg.db` | 聊天记录本体:消息行、会话列表、未读状态 | [nt_msg.db](./nt_msg/index.md) | +| `profile_info.db` | 好友 / 陌生人资料 | ⬜ 待写 | +| `group_info.db` | 群资料、群成员、公告、精华 | ⬜ 待写 | +| `collection.db` | QQ 收藏 | ⬜ 待写 | +| `emoji.db` | 系统表情、商城表情包 | ⬜ 待写 | +| `login.db` | 登录过的账号列表 | ⬜ 待写 | -各数据库已解密后的表结构、列含义,请前往文档站的「数据库解析」栏目查阅: +> 📌 数据库**解密**(取密钥、去文件头、SQLCipher 参数)属于原理部分,见 +> [原理总览](../principles/index.md)。 -| 数据库 | 文档站链接 | -| ------------------ | --------------------------------------------------------------------------------------- | -| `nt_msg.db` | | -| `profile_info.db` | | -| `group_info.db` | | -| `collection.db` | | -| `emoji.db` | | -| `login.db` | | -| 其它 | | +## 字段表约定 -> 📌 数据库解密(取密钥、去文件头、SQLCipher 参数)同样见文档站的「数据库解密」栏目。 +各页的字段表统一使用「置信度」一列区分三档: -## WeQ 单独维护的深度解析 - -以下内容结构复杂、文档站仅有零散引用,由 WeQ 依据实际解析实现单独维护: - -- [`nt_msg.db` 消息体解析(40800 / 40900)](./nt_msg/index.md) +| 档位 | 含义 | +| ---- | ---- | +| 已验证 | 主动构造场景 / 前后 diff 确认过语义 | +| 观测一致 | 大量真实样本上表现一致,但没有主动构造验证 | +| 推测 | 只是看起来合理,**未验证** —— 会显式标注 | --- diff --git a/docs/database/nt_msg/index.md b/docs/database/nt_msg/index.md index a6de598..f4aa0bd 100644 --- a/docs/database/nt_msg/index.md +++ b/docs/database/nt_msg/index.md @@ -1,17 +1,50 @@ -# nt_msg.db 消息体解析(40800 / 40900) +# nt_msg.db — 消息数据库 -`nt_msg.db` 的消息行中,最复杂的两列是 `40800`(消息正文)与 `40900`(消息缓存)。二者均为 protobuf,文档站仅有零散字段引用,这里由 WeQ 依据实际解析实现单独系统维护。 +`nt_msg.db` 是 NTQQ 存贮**聊天记录本体**的数据库:消息行、会话列表、未读状态都在这里。 +本栏目的内容全部由 WeQ 依据自己的解析实现手写维护。 -> 📖 建议先读 [40800 解析](./40800.md) 的前半部分 —— 「扁平信封」与「文件族共用字段」 -> 两节是理解所有消息段的前提,各消息段文档不再重复这些内容。 +## 表一览 + +| 表 | 内容 | 文档 | +| -- | ---- | ---- | +| `c2c_msg_table` | 好友单聊消息 | [消息行](#消息行) | +| `group_msg_table` | 群聊消息(结构同 c2c) | [消息行](#消息行) | +| `dataline_msg_table` | 数据线消息(我的手机 / 电脑 / 平板,结构同 c2c) | [消息行](#消息行) | +| `recent_contact_v3_table` | 会话列表 | [recent-contact](./recent-contact.md) | +| `msg_unread_info_table` | 未读信息 + 提醒高亮 | [unread-info](./unread-info.md) | +| `nt_uid_mapping_table` | uid ↔ uin ↔ sortNo 目录 | [下见](#nt_uid_mapping_table) | +| `draft_storage_table_v1` | 草稿:输入了但还没点发送的内容 | 暂未解析 | + +### 消息行 -## 两列职责 +三张消息表的**列布局完全一致**,差别只在会话维度(c2c 按 sortNo 分区、群按群号)。 +一行里最复杂的是两个 protobuf 列: | 列 | 名称 | 结构 | 说明 | | ----- | -------------------- | --------------------------------- | ------------------------------------------------------ | | 40800 | 消息正文(MsgBody) | `repeated ElementWire` | 一条消息的富文本消息段序列,见 [40800 解析](./40800.md) | | 40900 | 消息缓存(MsgCache) | `repeated MsgCache`(可递归嵌套) | 转发/引用时缓存的源消息快照,见 [40900 解析](./40900.md) | +> 📖 建议先读 [40800 解析](./40800.md) 的前半部分 —— 「扁平信封」与「文件族共用字段」 +> 两节是理解所有消息段的前提,各消息段文档不再重复这些内容。 + +### nt_uid_mapping_table + +账号级的身份目录,三列: + +| 列 | 含义 | +| -- | ---- | +| 48901 | sortNo —— 该账号给每个交互过的对端分配的 1 起递增小整数 | +| 48902 | uid —— 其它地方通用的不透明对端标识 | +| 1002 | uin —— 对端 QQ 号 | + +它之所以重要:`c2c_msg_table` 的**会话分区列是 `40027`(= 这里的 sortNo)**, +所有有用的复合索引都建在它上面(`(40027,40003)` 等);而应用层是按 uid 找会话的, +`40021` 恰恰**没有索引**。所以走快路径查 c2c 消息,必须先 uid → sortNo 翻译一次。 +这张表很小且稳定,WeQ 在会话启动时整表读进内存(`UidMap`)常驻。 + +--- + ## 消息段(Element)索引 `40800` 由若干消息段(Element)组成,每段以 `elementType` 区分类型。各类型的字段解析见下; diff --git a/docs/database/nt_msg/recent-contact.md b/docs/database/nt_msg/recent-contact.md new file mode 100644 index 0000000..2a5c9ab --- /dev/null +++ b/docs/database/nt_msg/recent-contact.md @@ -0,0 +1,211 @@ +# recent_contact_v3_table — 会话列表 + +`nt_msg.db` 里的**最近会话列表**,一行 = 一个会话(好友 / 群 / 临时会话 / 公众号 / 频道…)。 +它是聊天软件左侧那一栏的数据源:会话名、头像、最后一条消息的外显文本、时间、免打扰状态全在这里。 + +对应 WeQ 解析实现: + +| 文件 | 职责 | +| ---- | ---- | +| `packages/db/src/contact/recent_contact.ts` | 取行 + 列 → `RecentContact` | +| `packages/db/src/contact/types.ts` | `RecentContact` 的字段语义 | +| `packages/codec/src/proto/msg/40051.ts` | `40051` 预览列的 protobuf 外壳 | + +--- + +## 一、这张表的定位 + +关键认识:**这张表是一份「冗余快照」,不是消息表的视图。** + +会话的最新一条消息,其正文明明已经存在 `c2c_msg_table` / `group_msg_table` 里, +QQ 仍然把「发送者昵称 / 群名片 / 外显文本 / 头像路径」等一整套展示信息**再抄一份**进这张表。 +原因很实际:渲染会话列表时不能为每个会话都去消息表里查一次、再去 profile 库查一次昵称, +那是 N 次跨库查询。抄一份进来,列表就是一条 `ORDER BY 40050 DESC LIMIT n`。 + +带来的两个后果,写代码时必须记住: + +1. **这里的展示信息可能过时**。对方改了昵称、群改了名,只有下次这个会话来消息、这一行被重写时才会更新。 + 要「当前」的昵称/群名,得去 `profile_info.db` / `group_info.db`,不能信这里。 +2. **删掉这一行 ≠ 删掉聊天记录**。QQ 里「删除会话」删的就是这一行,消息表纹丝不动。 + WeQ 的「WeQ 助手」开关也正是这么做的(关掉只删 `recent_contact_v3_table` 的行, + `c2c_msg_table` 从不动 —— 见 `packages/service/src/account/weq_assistant.ts`)。 + +## 二、列结构 + +WeQ 实际读取的列(`SELECT` 见 `recent_contact.ts`),按用途分组。 + +### 会话身份 + +| 列 | 字段名 | 类型 | 含义 | 置信度 | +| -- | ------ | ---- | ---- | ------ | +| 41102 | — | INTEGER | 行主键。WeQ 只在**插入**伪造会话时用到(取 `MAX + 随机`),读取路径不关心 | 观测一致 | +| 40010 | `chatType` | INTEGER | 会话类型,见下方 [ChatType](#四chattype会话类型) | 已验证 | +| 40021 | `targetUid` | TEXT | **会话标识**:c2c 是对端 uid,群是群号 | 已验证 | +| 40030 | `targetUin` | INTEGER | 会话对端的 QQ 号(c2c 用;无则 0) | 已验证 | +| 40027 | — | INTEGER | 会话 sortNo,同 `nt_uid_mapping_table.48901`,与消息表的分区列同义 | 观测一致 | + +> 注意 `40021` 一列同时兼任「好友 uid」和「群号」两种身份 —— 靠 `40010` 区分。 +> 这与消息表的 `40021` 是同一约定。 + +### 最后一条消息 + +| 列 | 字段名 | 类型 | 含义 | 置信度 | +| -- | ------ | ---- | ---- | ------ | +| 40001 | — | INTEGER | 最后一条消息的 msgId,指回消息表 | 观测一致 | +| 40003 | `msgSeq` | INTEGER | 最后一条消息的序列号。**未读数就是拿它减去已读 seq 算的**,见 [msg_unread_info_table](./unread-info.md) | 已验证 | +| 40011 | — | INTEGER | 最后一条消息的 msgType,枚举同 [40900 · MsgType](./40900.md#msgtypetag-40011) | 观测一致 | +| 40050 | `sendTime` | INTEGER | 最后一条消息的时间,unix 秒。**会话列表的排序键** | 已验证 | +| 41136 | — | INTEGER | 与 `40050` 同值的时间镜像列 | 观测一致 | +| 40051 | `preview` | BLOB | **外显预览**,protobuf,见下方第三节 | 已验证 | + +### 发送者展示信息(最后一条消息的发送者) + +| 列 | 字段名 | 类型 | 含义 | 置信度 | +| -- | ------ | ---- | ---- | ------ | +| 40020 | `senderUid` | TEXT | 发送者 uid | 已验证 | +| 40033 | — | INTEGER | 发送者 QQ 号 | 观测一致 | +| 40090 | `senderDisplayName` | TEXT | 发送者展示名,群聊场景主要是**群名片** | 已验证 | +| 40093 | `senderNick` | TEXT | 发送者昵称 | 已验证 | +| 40095 | `senderRemark` | TEXT | 发送者备注名 | 已验证 | + +> 三个名字列的优先级由使用方决定。WeQ 的会话列表取 `senderDisplayName || senderNick`。 + +### 会话自身展示信息 + +| 列 | 字段名 | 类型 | 含义 | 置信度 | +| -- | ------ | ---- | ---- | ------ | +| 40094 | `targetDisplayName` | TEXT | **会话名**:好友昵称 / 群名 | 已验证 | +| 41135 | `targetRemark` | TEXT | 会话备注名(好友备注) | 已验证 | +| 41110 | `targetAvatar` | TEXT | 会话头像。**是本地文件的绝对路径**,不是 URL —— QQ 直接读这个路径渲染 | 已验证 | +| 41148 | `targetGroupNick` | TEXT | 对方的**群名片**,只在「群里发起的临时会话」行上有值,其它场景为空 | 观测一致 | + +> `41110` 是本地路径这一点被 WeQ 反向利用:「WeQ 助手」把自己的头像图片写进 QQ 自己的 +> `nt_data/avatar/weq/` 目录,再把绝对路径填进这一列,QQ 就会像渲染任何缓存头像一样渲染它。 + +### 会话设置与来源 + +| 列 | 字段名 | 类型 | 含义 | 置信度 | +| -- | ------ | ---- | ---- | ------ | +| 41220 | `notifyLevel` | INTEGER | **免打扰**。`0`/`1` = 正常提醒,其它值(实测 `4`)= 免打扰 | 已验证 | +| 60001 | `tempSourceGroupCode` | INTEGER | 临时会话的**来源群号**。非临时会话为 0 | 已验证 | + +`41220` 是逆向出来的:开关某个群的免打扰、前后各 dump 一次整行做 diff,只有这一列变。 +84 个群被这一列干净二分。`msg_unread_info_table` 里**没有**免打扰字段,别去那边找。 + +判定写法(`MainView.tsx`): + +```ts +function mutedFromNotifyLevel(notifyLevel: number | undefined): boolean { + return notifyLevel !== undefined && notifyLevel !== 0 && notifyLevel !== 1; +} +``` + +`60001` 用于「群 xxx 的临时会话」这种标题回退:群还在我的群列表里就显示群名,退群了就退化成群号。 + +## 三、`40051` — 外显预览 + +会话列表里那句「张三:[图片]」的来源。它是 protobuf,外壳里只有一个 tag 同为 `40051` 的子消息: + +```text +40051 (BLOB) +└── 40051 PreviewElementWire ← 单个消息段,不是列表 + ├── … 与 40800 的 ElementWire 完全相同的全部字段 + └── 49093 displayText ← 会话列表外显文本 +``` + +理解要点: + +- **它就是一个 `ElementWire`**(结构见 [40800 解析](./40800.md)),只是**单个**,不是 `repeated`。 + 多消息段的消息(图 + 文)只挑第一段做预览。 +- **多出来的只有一个 tag:`49093`**,装的是最终外显文本。图片消息这里就是 `"[图片]"`, + 也就是说**外显文案是 QQ 写进库的,不需要解析方自己按类型翻译**。 + +| tag | 字段名 | 类型 | 含义 | 置信度 | +| --- | ------ | ---- | ---- | ------ | +| 49093 | `displayText` | string | 会话列表外显的最新消息文本 | 已验证 | + +解码同样先过 `sanitizeBytes` 容错(原因见 [40800 · 容错解码](./40800.md#六容错解码sanitizebytes)), +一个猜错类型的 tag 不至于让整个会话列表少一行。 + +## 四、ChatType(会话类型) + +`40010` 的取值,来自 QQ NT 自身的 `KCHATTYPE*` 枚举(`packages/codec/src/domain/msg/enums.ts`)。 +下表按「WeQ 是否实际渲染」分组,值全部来自枚举定义本身。 + +### 常规会话 + +| 值 | 名称 | 说明 | +| -- | ---- | ---- | +| 1 | KCHATTYPEC2C | 好友单聊 | +| 2 | KCHATTYPEGROUP | 群聊 | +| 3 | KCHATTYPEDISC | 讨论组(历史遗留) | +| 8 | KCHATTYPEDATALINE | 数据线(我的手机 / 电脑 / 平板),消息落在 `dataline_msg_table` | +| 134 | KCHATTYPEDATALINEMQQ | 数据线(手机 QQ) | + +### 临时会话 + +| 值 | 名称 | 说明 | +| -- | ---- | ---- | +| 99 | KCHATTYPETEMPC2CFROMUNKNOWN | 来源未知的临时会话 | +| 100 | KCHATTYPETEMPC2CFROMGROUP | **群聊发起的临时会话**,来源群号在 `60001`,对方群名片在 `41148` | +| 101 | KCHATTYPETEMPFRIENDVERIFY | 好友验证 | +| 102 | KCHATTYPETEMPBUSSINESSCRM | 商家客服 | +| 103 | KCHATTYPETEMPPUBLICACCOUNT | 公众号 / 服务号 | +| 111 | KCHATTYPETEMPADDRESSBOOK | 通讯录来源 | +| 117 | KCHATTYPETEMPWPA | 网页发起会话 | +| 119 | KCHATTYPETEMPNEARBYPRO | 附近的人(Pro) | + +### 系统 / 折叠入口 + +这些不是真人会话,而是各种「助手」「通知」的折叠入口。 + +| 值 | 名称 | 说明 | +| -- | ---- | ---- | +| 5 | KCHATTYPEBUDDYNOTIFY | 好友通知 | +| 6 | KCHATTYPEGROUPNOTIFY | 群通知 | +| 7 | KCHATTYPEGROUPHELPER | 群助手 | +| 30 | KCHATTYPESUBSCRIBEFOLDER | 订阅号折叠 | +| 40 | KCHATTYPEWEIYUN | 微云 | +| 41 | KCHATTYPEFAV | 收藏 | +| 42 | KCHATTYPEADELIE | Adelie(内部) | +| 104 / 109 | KCHATTYPEMATCHFRIEND(FOLDER) | 交友匹配(及其折叠) | +| 105 / 116 | KCHATTYPEGAMEMESSAGE(FOLDER) | 游戏消息(及其折叠) | +| 106 ~ 112 | KCHATTYPENEARBY* | 附近的人相关(助手 / 折叠 / 互动 / 打招呼折叠) | +| 113 | KCHATTYPECIRCLE | 圈子 | +| 115 | KCHATTYPESQUAREPUBLIC | 广场 | +| 118 / 201 | KCHATTYPESERVICEASSISTANT(SUB) | 服务号助手(及子项) | +| 131 | KCHATTYPERELATEACCOUNT | 关联账号 | +| 132 | KCHATTYPEQQNOTIFY | QQ 官方通知 | +| 133 | KCHATTYPEGROUPBLESS | 群祝福 | + +### 频道(被 WeQ 排除) + +| 值 | 名称 | 说明 | +| -- | ---- | ---- | +| 0 | KCHATTYPEUNKNOWN | 占位 | +| 4 | KCHATTYPEGUILD | 频道 | +| 9 | KCHATTYPEGROUPGUILD | 群频道 | +| 16 | KCHATTYPEGUILDMETA | **频道元信息** | + +`16` 被 WeQ 显式过滤掉(`BLOCKED_CHAT_TYPES`)。原因是频道行**换了一套列布局**: +会话名在 `40091` 而不是 `40094`,预览嵌在 `41150` 而不是 `40051`,也没有头像列 —— +按常规列读出来是一行空白,不如不显示。 + +## 五、常见查询 + +会话列表(WeQ 的读法): + +```sql +SELECT "40003","40010","40020","40021","40030","40050","40051", + "40090","40093","40094","40095","41110","41135","41148","41220","60001" +FROM recent_contact_v3_table +WHERE "40010" NOT IN (16) -- 排除频道元信息行 +ORDER BY "40050" DESC +LIMIT ? OFFSET ?; +``` + +`40050` 上有索引,会话数量本身也就几百,所以单条有序 `LIMIT` 足够,不需要分页优化。 + +--- + +[← 返回 nt_msg.db](./index.md) diff --git a/docs/database/nt_msg/unread-info.md b/docs/database/nt_msg/unread-info.md new file mode 100644 index 0000000..b79d39b --- /dev/null +++ b/docs/database/nt_msg/unread-info.md @@ -0,0 +1,165 @@ +# msg_unread_info_table — 未读信息 + +`nt_msg.db` 里记录每个会话**读到哪了**的表,两列而已,但第二列是一个嵌套 protobuf, +里面藏着「特别关心 / @我 / @全体 / 新文件」这些提醒标记 —— 它们**不是**独立的列或表。 + +对应 WeQ 解析实现: + +| 文件 | 职责 | +| ---- | ---- | +| `packages/db/src/msg/unread_info.ts` | 取行 + 展平高亮 → `UnreadInfoResult` | +| `packages/codec/src/proto/msg/48902.ts` | `48902` blob 的 protobuf 结构 | + +--- + +## 一、列结构 + +只有两列: + +| 列 | 类型 | 含义 | +| -- | ---- | ---- | +| 48901 | TEXT | 会话键,格式 **`"chatType_uid"`**,如 `2_673646675`(群)、`1_u_xxxx`(好友) | +| 48902 | BLOB | 未读信息 protobuf,见下 | + +> 注意 `48901`/`48902` 这对列号在 `nt_uid_mapping_table` 里是另一套含义(sortNo / uid)。 +> QQ 的列号只在**表内**唯一,跨表复用很常见,不要按列号去猜语义。 + +查询就是一次主键命中: + +```sql +SELECT "48901", "48902" FROM msg_unread_info_table WHERE "48901" = ? LIMIT 1; +-- 参数形如 "2_673646675" +``` + +## 二、未读数是算出来的,不是存出来的 + +**这张表里没有「未读数」这个字段。** 它只存「已读到的 seq」,未读数要自己减: + +```text +未读数 = recent_contact_v3_table.40003 (会话最新消息 seq) + - msg_unread_info_table.48902.41002 (已读到的 seq) +``` + +即需要**跨两张表**。WeQ 的做法(`MainView.tsx`)是先加载会话列表, +再按 12 个一批并发查每个会话的未读信息,两边相减;差值 ≤ 0 视作 0。 + +这个设计也解释了一个现象:未读数是「消息条数差」而不是精确的未读消息数 —— +中间若有被撤回/删除的消息,seq 仍然占位,所以红点数字可能比实际能看到的消息条数多。 + +## 三、`48902` 的结构 + +外壳只有一个 tag 同为 `48902` 的子消息。整体形状(已在真实 blob 上验证): + +```text +48902 (BLOB) +└── 48902 { + ├── 40010 chatType 会话类型(与 48901 前缀重复) + ├── 40021 peerUid 会话 uid(与 48901 后缀重复) + ├── 41002 msgSeq ★ 已读到的消息序号 + └── 50005 { 会话扩展 + ├── 50001 peerUid 再重复一次 + ├── 50002 chatType 再重复一次 + └── 50060 { ★ 提醒高亮组(没有提醒时整块不存在) + ├── 50000 kind 类别码,见第四节 + └── 50040 { 该类别下的高亮消息(可多条) + ├── 50020 msgSeq + ├── 50022 senderUid + ├── 50023 sendTime + └── 50024 text + } + } + } +} +``` + +### 顶层字段 + +| tag | 字段名 | 类型 | 含义 | 置信度 | +| --- | ------ | ---- | ---- | ------ | +| 40010 | `chatType` | uint32 | 会话类型,同 `recent_contact_v3_table.40010` | 已验证 | +| 40021 | `peerUid` | string | 会话 uid | 已验证 | +| 41002 | `msgSeq` | uint32 | **已读到的消息序号** | 已验证 | +| 50005 | `ext` | message | 会话扩展,见下 | 已验证 | + +另观测到 `41027` / `41032` / `41037` 三个计数类字段,语义未定,WeQ 未解析。 + +### 会话扩展(tag 50005) + +| tag | 字段名 | 类型 | 含义 | 置信度 | +| --- | ------ | ---- | ---- | ------ | +| 50001 | `peerUid` | string | 会话 uid(重复 40021) | 已验证 | +| 50002 | `chatType` | uint32 | 会话类型(重复 40010) | 已验证 | +| 50060 | `highlight` | repeated message | **提醒高亮组**,见下 | 已验证 | + +**普通会话的 `50005` 里只有 `50001` / `50002` 两个字段,没有 `50060`。** +所以判定很直接:`50060` 存在 ⇒ 这个会话有提醒类未读。 + +### 提醒高亮组(tag 50060) + +| tag | 字段名 | 类型 | 含义 | 置信度 | +| --- | ------ | ---- | ---- | ------ | +| 50000 | `kind` | uint32 | **类别码**,见第四节 | 已验证 | +| 50040 | `items` | repeated message | 该类别下的高亮消息 | 已验证 | + +**每个类别一个 `50060` 组**,壳子结构完全一样,只有 `50000` 的值不同。所以它被建模成 `repeated`。 + +### 高亮消息(tag 50040) + +| tag | 字段名 | 类型 | 含义 | 置信度 | +| --- | ------ | ---- | ---- | ------ | +| 50020 | `msgSeq` | uint32 | 该条消息的序号 | 已验证 | +| 50022 | `senderUid` | string | 发送者 uid | 已验证 | +| 50023 | `sendTime` | uint32 | 发送时间,unix 秒 | 已验证 | +| 50024 | `text` | string | 预览文本,**实测常为空** | 观测一致 | + +## 四、`50000` 类别码枚举 + +这是本页的重点。**这批码不是从 QQ 的枚举表抄来的**,QQ 前端不暴露它; +它是逐个触发场景、每次抓一份 blob 做 diff 逆向出来的 —— 同一个群(673646675)反复制造不同提醒, +观察到壳子完全不变、**只有 `50000` 的值在变**,于是把值和场景一一对上。 + +| 值 | 类别 | WeQ `HighlightKind` | 触发场景 | 置信度 | +| -- | ---- | ------------------- | -------- | ------ | +| 1000 | @我 | `atMe` | 群里有人 @ 我 | 已验证 | +| 1006 | 特别关心 | `specialCare` | 被设为「特别关心」的好友/群友发来消息 | 已验证 | +| 1007 | QQ 红包 | `redPacket` | 会话内有未领取的红包 | 观测一致 | +| 2000 | @全体 | `atAll` | 群里有人 @全体成员 | 已验证 | +| 2001 | 新文件 | `newFile` | 群文件有新上传 | 已验证 | + +未映射到的码会落到 `'unknown'`,同时保留 `rawKind` 原值,方便后续继续逆向。 + +**已知缺口:「回复我」的码还没抓到。** 补一个新类别的成本很低 —— 三处各加一行: +`HIGHLIGHT_KIND_BY_CODE`(db)、`ConversationHighlightKind`(前端类型)、`ConversationList` 的渲染分支。 + +> 数值本身也透着分组规律:`10xx` 像是「找我的」(@我 / 特别关心 / 红包), +> `20xx` 像是「群内广播」(@全体 / 新文件)。**未验证**,仅供继续逆向时参考。 + +## 五、展平约定 + +一个类别下可能有多条高亮消息(比如被 @ 了三次)。WeQ 每个类别只保留 **seq 最大的那一条** +(`extractHighlights`),因为界面只需要「有没有」这个布尔,`msgSeq` 保留但不展示。 + +结果对象: + +```ts +interface UnreadHighlight { + kind: HighlightKind; // 映射后的类别 + rawKind: number; // 50000 原值,未映射类别也能被识别 + msgSeq: number; // 该类别下最新的一条 + senderUid: string; + sendTime: number; +} +``` + +界面上分成两类颜色:**「找我的」告警类用红色**(`[特别关心]` / `[有人@我]` / `[@全体]`), +**内容类用蓝色**(`[新文件]`)。 + +## 六、和免打扰的关系:没有关系 + +一个容易走的弯路:免打扰状态**不在这张表里**。它在 +[`recent_contact_v3_table` 的 `41220` 列](./recent-contact.md#会话设置与来源)。 +这张表只管「读到哪了」和「有哪些提醒」,不管「要不要提醒」。 + +--- + +[← 返回 nt_msg.db](./index.md) From 686a540b2c45549421b661b5876dde335f887801 Mon Sep 17 00:00:00 2001 From: H3CoF6 Date: Fri, 31 Jul 2026 23:28:18 +0800 Subject: [PATCH 5/8] feat: web!!! --- .github/workflows/ci.yml | 36 ++ .github/workflows/release.yml | 60 +++- .gitignore | 4 + README.md | 15 + apps/desktop/electron.vite.config.ts | 2 + apps/desktop/package.json | 3 + apps/desktop/src/main/avatar_protocol.ts | 55 +-- apps/desktop/src/main/context/app_context.ts | 2 +- apps/desktop/src/main/context/qq_protocol.ts | 30 +- .../src/main/context/qq_protocol_cache.ts | 23 ++ apps/desktop/src/main/file_response.ts | 111 ++++++ apps/desktop/src/main/host.ts | 65 ++++ apps/desktop/src/main/index.ts | 22 +- apps/desktop/src/main/ipc/router.ts | 5 +- apps/desktop/src/main/ipc/routers/account.ts | 111 +++--- .../desktop/src/main/ipc/routers/bootstrap.ts | 38 +-- apps/desktop/src/main/ipc/routers/dressup.ts | 8 +- .../src/main/ipc/routers/file_resource.ts | 13 +- apps/desktop/src/main/ipc/routers/update.ts | 19 +- apps/desktop/src/main/media_protocol.ts | 22 +- apps/desktop/src/main/protocol_register.ts | 25 ++ apps/desktop/src/main/resource.ts | 8 +- apps/desktop/src/main/resource_protocol.ts | 41 +-- apps/desktop/src/main/update/state.ts | 86 +++++ apps/desktop/src/main/update/updater.ts | 70 ++-- apps/desktop/src/main/weq_assistant/ipc.ts | 23 ++ apps/desktop/src/main/weq_assistant/server.ts | 17 +- apps/desktop/src/renderer/src/App.tsx | 9 +- .../renderer/src/components/OnlineStatus.tsx | 3 +- .../src/renderer/src/components/QqMedia.tsx | 10 +- .../settings/GlobalSettingsSection.tsx | 11 +- .../src/im-template/template/TitleBar.tsx | 25 +- .../template/conversationLists.tsx | 3 +- .../src/renderer/src/lib/avatarCache.ts | 8 +- .../src/renderer/src/lib/resourceUrl.ts | 24 +- apps/desktop/src/renderer/src/lib/target.tsx | 23 ++ apps/desktop/src/renderer/src/trpc/client.ts | 9 +- .../renderer/src/trpc/transport.electron.ts | 15 + .../src/renderer/src/trpc/transport.web.ts | 35 ++ .../src/views/agentlab/ChatBubble.tsx | 5 +- .../src/views/cache/AvatarExplorer.tsx | 3 +- .../src/views/cache/AvatarPathDialog.tsx | 3 +- .../src/views/cache/DownloadFileExplorer.tsx | Bin 5756 -> 5968 bytes .../src/views/cache/FileDirExplorer.tsx | Bin 6211 -> 6379 bytes apps/desktop/tsconfig.web.json | 3 +- apps/web/README.md | 148 ++++++++ apps/web/package.json | 43 +++ apps/web/scripts/build-server.mjs | 124 +++++++ apps/web/scripts/check-bundle.ts | 59 ++++ apps/web/scripts/check-electron-free.ts | 98 ++++++ apps/web/scripts/pack-release.mjs | 67 ++++ apps/web/scripts/smoke-dist.ts | 140 ++++++++ apps/web/src/server/auth.test.ts | 136 ++++++++ apps/web/src/server/auth.ts | 155 +++++++++ apps/web/src/server/host.ts | 75 +++++ apps/web/src/server/http.test.ts | 129 +++++++ apps/web/src/server/http.ts | 188 +++++++++++ apps/web/src/server/index.ts | 106 ++++++ apps/web/src/server/login_page.ts | 86 +++++ apps/web/src/server/protocol_adapter.ts | 67 ++++ apps/web/src/server/static.ts | 40 +++ apps/web/src/server/trpc_adapter.ts | 68 ++++ apps/web/tsconfig.server.json | 9 + apps/web/vite.config.ts | 37 ++ docs/web-app-plan.md | 317 ++++++++++++++++++ .../service/src/account/agentlab_export.ts | 17 + packages/service/src/common/host.ts | 82 +++++ packages/service/src/index.ts | 4 +- pnpm-lock.yaml | 73 ++++ scripts/set-version.mjs | 2 +- 70 files changed, 3076 insertions(+), 297 deletions(-) create mode 100644 apps/desktop/src/main/context/qq_protocol_cache.ts create mode 100644 apps/desktop/src/main/file_response.ts create mode 100644 apps/desktop/src/main/host.ts create mode 100644 apps/desktop/src/main/protocol_register.ts create mode 100644 apps/desktop/src/main/update/state.ts create mode 100644 apps/desktop/src/main/weq_assistant/ipc.ts create mode 100644 apps/desktop/src/renderer/src/lib/target.tsx create mode 100644 apps/desktop/src/renderer/src/trpc/transport.electron.ts create mode 100644 apps/desktop/src/renderer/src/trpc/transport.web.ts create mode 100644 apps/web/README.md create mode 100644 apps/web/package.json create mode 100644 apps/web/scripts/build-server.mjs create mode 100644 apps/web/scripts/check-bundle.ts create mode 100644 apps/web/scripts/check-electron-free.ts create mode 100644 apps/web/scripts/pack-release.mjs create mode 100644 apps/web/scripts/smoke-dist.ts create mode 100644 apps/web/src/server/auth.test.ts create mode 100644 apps/web/src/server/auth.ts create mode 100644 apps/web/src/server/host.ts create mode 100644 apps/web/src/server/http.test.ts create mode 100644 apps/web/src/server/http.ts create mode 100644 apps/web/src/server/index.ts create mode 100644 apps/web/src/server/login_page.ts create mode 100644 apps/web/src/server/protocol_adapter.ts create mode 100644 apps/web/src/server/static.ts create mode 100644 apps/web/src/server/trpc_adapter.ts create mode 100644 apps/web/tsconfig.server.json create mode 100644 apps/web/vite.config.ts create mode 100644 docs/web-app-plan.md create mode 100644 packages/service/src/common/host.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26b1815..e475426 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,3 +130,39 @@ jobs: mv apps/desktop/package.dev.json apps/desktop/package.json fi rm -f apps/desktop/package.release.json + + # 浏览器版打包冒烟:构建 → 两个守卫 → 启动真实产物走一遍鉴权流程。 + # 守卫防的是同一类回归:Electron-only 的代码混进 web 构建后不会报错, + # 只在运行时炸成一句看不懂的 "does not provide an export named 'app'"。 + build-web: + runs-on: ubuntu-latest + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: '1' + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # 鉴权闸 + 端到端路由测试。不需要构建产物,先跑、失败得早。 + - name: Test auth gate + run: pnpm --filter @weq/web test + + # build 内含 check:check-electron-free(走真实 import 图) + # + check-bundle(扫前端产物里残留的自定义协议 URL)。 + - name: Build + guards + run: pnpm --filter @weq/web build + + # 启动打包好的 server.mjs 走一遍完整流程 —— 覆盖 esbuild 的 CJS 互操作 + # 和原生模块解析,这些只有真跑起来才会暴露。 + - name: Smoke-test packaged server + run: pnpm --filter @weq/web test:dist diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 530382b..50c9f20 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -221,11 +221,64 @@ jobs: echo "== release/ tree ==" ls -laR apps/desktop/release 2>/dev/null || echo "(no release/ dir)" + # ============================ Web(浏览器版)============================ + # 平台无关:一个 tar.gz 同时含 win32-x64 / linux-x64 / linux-arm64 三份 + # native/,运行时按 process.platform/arch 自动选,所以只构建一次。 + # 不内置 Node(要求机器上有 Node ≥22),产物约 24 MB。 + # + # 依赖 windows job:Release 由 electron-builder --publish 创建,这里只往 + # 已存在的 Release 上传,不自己建。 + web: + needs: [windows] + runs-on: ubuntu-latest + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: '1' + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # 版本号会被 esbuild 烘焙进 server.mjs(设置页显示 + 压缩包命名)。 + - name: Sync version from tag + run: node scripts/set-version.mjs ${{ github.ref_name }} + + # 含 check 守卫:确认没有 Electron 代码泄漏进 web 构建。 + - name: Build + guards + run: pnpm --filter @weq/web build + + # 发布前先启动打包产物验一遍,别把跑不起来的包传上去。 + - name: Smoke-test packaged server + run: pnpm --filter @weq/web test:dist + + - name: Pack tarball + run: pnpm --filter @weq/web pack:release ${{ github.ref_name }} + + - name: Upload to release + run: gh release upload "${{ github.ref_name }}" apps/web/release/*.tar.gz --clobber + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Dump build output on failure + if: failure() + run: | + echo "== apps/web/dist ==" + ls -laR apps/web/dist 2>/dev/null | head -50 || echo "(no dist/)" + # ======================= Release 说明(中文)======================= - # 等三个平台都发完(Release 已存在),用「中文说明头 + GitHub 自动 + # 等四个产物都发完(Release 已存在),用「中文说明头 + GitHub 自动 # 生成的 commit/PR 变更列表」覆写 Release 正文,并标注 aarch64 未实测。 notes: - needs: [windows, linux-x64, linux-arm64] + needs: [windows, linux-x64, linux-arm64, web] runs-on: ubuntu-latest steps: - name: 生成并写入中文 Release 说明 @@ -254,6 +307,9 @@ jobs: - **Windows**: \`weQ-${TAG#v}-setup.exe\` - **Linux x64**: \`weQ-${TAG#v}-linux-x64.AppImage\` / \`.tar.gz\` - **Linux arm64(未实测)**: \`weQ-${TAG#v}-linux-arm64.AppImage\` / \`.tar.gz\` + - **浏览器版**: \`weq-web-${TAG#v}.tar.gz\` —— 三平台通用,需自备 Node ≥22。 + 解压后 \`npm install --omit=dev && node server.mjs\`, + 详见[使用说明](https://github.com/${REPO}/blob/main/apps/web/README.md) ### 变更 ${CHANGES} diff --git a/.gitignore b/.gitignore index 2ea9672..807d89c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ out/ build/ *.tsbuildinfo +# weq web runtime dirs (exports / logs, created next to the server) +weq-exports/ +weq-data/ + # editor / OS .DS_Store diff --git a/README.md b/README.md index 144a5f1..0ef3a7d 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,21 @@ 2. 按照引导操作获取数据库密钥 (**无需提前打开QQ**) 3. 打开对应账号即可开始使用 +### 浏览器版 + +除桌面版外还提供 **WeQ Web** —— 同一套界面与功能,跑在浏览器里。适合无桌面环境的机器 +(NAS / 服务器 / WSL),或想从别的设备访问。 + +下载 `weq-web-<版本>.tar.gz`(三平台通用,需自备 Node ≥ 22),解压后: + +```bash +npm install --omit=dev +node server.mjs +``` + +终端会打印地址和访问令牌,浏览器打开即可。默认只监听本机; +**对外暴露前请先读 [apps/web/README.md](./apps/web/README.md)**。 + #### 开发者指南 > diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 65612e5..5f920d2 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -58,6 +58,8 @@ export default defineConfig({ alias: { '@renderer': resolve(__dirname, 'src/renderer/src'), '@resources': resolve(__dirname, '../../resources'), + // Per-target tRPC transport; the web app aliases this to transport.web. + '@transport': resolve(__dirname, 'src/renderer/src/trpc/transport.electron.ts'), }, }, build: { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a50e52e..ee8d596 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -4,6 +4,9 @@ "private": true, "type": "module", "main": "./out/main/index.js", + "exports": { + "./main/*": "./src/main/*.ts" + }, "scripts": { "dev": "electron-vite dev", "build": "electron-vite build && electron-builder", diff --git a/apps/desktop/src/main/avatar_protocol.ts b/apps/desktop/src/main/avatar_protocol.ts index 7e83ecb..c0f3272 100644 --- a/apps/desktop/src/main/avatar_protocol.ts +++ b/apps/desktop/src/main/avatar_protocol.ts @@ -16,7 +16,6 @@ * `ready`; `registerAvatarProtocol()` MUST run after. */ -import { protocol } from 'electron'; import { getAppContext } from './context/app_context'; export const AVATAR_SCHEME = 'weq-avatar'; @@ -36,32 +35,34 @@ export const AVATAR_PRIVILEGED_SCHEME = { }, } as const; -export function registerAvatarProtocol(): void { - protocol.handle(AVATAR_SCHEME, async (request) => { - const url = new URL(request.url); - const src = url.searchParams.get('src'); - if (!src) { - return new Response('missing src', { status: 400 }); - } +/** + * Serve one `weq-avatar://` request. Pure `Request`→`Response`, so the web app + * can mount it on a plain HTTP route (see `apps/web`) without Electron. + */ +export async function handleAvatarRequest(request: Request): Promise { + const url = new URL(request.url); + const src = url.searchParams.get('src'); + if (!src) { + return new Response('missing src', { status: 400 }); + } - const ctx = getAppContext(); - if (!ctx.bootstrap) { - return new Response('native unavailable', { status: 503 }); - } + const ctx = getAppContext(); + if (!ctx.bootstrap) { + return new Response('native unavailable', { status: 503 }); + } - try { - const blob = await ctx.bootstrap.avatarCache.get(src); - return new Response(new Uint8Array(blob.data), { - status: 200, - headers: { - 'Content-Type': blob.contentType, - // Let the renderer / Chromium memory-cache it too; the on-disk cache - // is authoritative, this just avoids re-asking the protocol. - 'Cache-Control': 'public, max-age=86400', - }, - }); - } catch { - return new Response('avatar fetch failed', { status: 502 }); - } - }); + try { + const blob = await ctx.bootstrap.avatarCache.get(src); + return new Response(new Uint8Array(blob.data), { + status: 200, + headers: { + 'Content-Type': blob.contentType, + // Let the renderer / Chromium memory-cache it too; the on-disk cache + // is authoritative, this just avoids re-asking the protocol. + 'Cache-Control': 'public, max-age=86400', + }, + }); + } catch { + return new Response('avatar fetch failed', { status: 502 }); + } } diff --git a/apps/desktop/src/main/context/app_context.ts b/apps/desktop/src/main/context/app_context.ts index 26a0c31..02aef54 100644 --- a/apps/desktop/src/main/context/app_context.ts +++ b/apps/desktop/src/main/context/app_context.ts @@ -31,7 +31,7 @@ import { aiToolSpecs, runAiTool } from '../mcp/openai_tools'; import { getExternalMcpHub, disposeExternalMcp } from '../mcp/external'; import { sampleHitokoto } from '../hitokoto'; import { pkexecStubHooks } from '../stub_elevation'; -import { getQqProtocolExe } from './qq_protocol'; +import { getQqProtocolExe } from './qq_protocol_cache'; import { createPkexecInjectHook } from '../inject_elevation'; import { accountConfigId, diff --git a/apps/desktop/src/main/context/qq_protocol.ts b/apps/desktop/src/main/context/qq_protocol.ts index 1c66423..375f3c5 100644 --- a/apps/desktop/src/main/context/qq_protocol.ts +++ b/apps/desktop/src/main/context/qq_protocol.ts @@ -1,32 +1,24 @@ /** - * Resolve which exe the OS associates with QQ's `tencent://` URL scheme, so - * the win32 platform can anchor every install path (QQ.exe / wrapper.node / - * version) on it instead of the `Uninstall\QQ` registry key — which is missing - * or relocated for portable installs, non-standard layouts, and machines whose - * registry has been cleaned. + * Probe which exe the OS associates with QQ's `tencent://` URL scheme. * - * The handler is QQNT's `timwp.exe`, sitting in the same `resources/app` dir as - * `wrapper.node`. We prefer `tencent://`, then `mqqapi://` (both point at the - * same handler in practice; the second is a fallback for installs that only - * registered one). Anything else — no association, throw — leaves the cached - * value null and the platform silently falls back to the registry probe. + * We prefer `tencent://`, then `mqqapi://` (both point at the same handler in + * practice; the second is a fallback for installs that only registered one). + * Anything else — no association, throw — leaves the cached value null and the + * platform silently falls back to the registry probe. * * Win32-only: linux QQ doesn't register these schemes, so the caller skips the - * probe there entirely and the getter stays null. + * probe there entirely and the cache stays null. + * + * Electron-only (needs `app.getApplicationInfoForProtocol`). The cache itself + * lives in `qq_protocol_cache.ts` so non-Electron hosts can read it. */ import { app } from 'electron'; import { getLogger } from '@weq/service'; +import { setQqProtocolExe } from './qq_protocol_cache'; const SCHEMES = ['tencent://', 'mqqapi://'] as const; -let cachedExe: string | null = null; - -/** The resolved protocol-handler exe path, or null until/unless the probe finds one. */ -export function getQqProtocolExe(): string | null { - return cachedExe; -} - /** * Probe the OS protocol association once and cache the handler exe path. Safe * to call before any path lookup; resolves (never rejects) so a missing @@ -38,7 +30,7 @@ export async function probeQqProtocolHandler(): Promise { try { const info = await app.getApplicationInfoForProtocol(scheme); if (info.path) { - cachedExe = info.path; + setQqProtocolExe(info.path); logger.info('resolved QQ protocol handler', { event: 'qq-protocol-resolved', scheme, diff --git a/apps/desktop/src/main/context/qq_protocol_cache.ts b/apps/desktop/src/main/context/qq_protocol_cache.ts new file mode 100644 index 0000000..a499f3d --- /dev/null +++ b/apps/desktop/src/main/context/qq_protocol_cache.ts @@ -0,0 +1,23 @@ +/** + * Cache for the exe the OS associates with QQ's `tencent://` URL scheme, so the + * win32 platform can anchor every install path (QQ.exe / wrapper.node / + * version) on it instead of the `Uninstall\QQ` registry key — which is missing + * or relocated for portable installs, non-standard layouts, and machines whose + * registry has been cleaned. + * + * Only the cache lives here. The probe that fills it needs Electron's + * `app.getApplicationInfoForProtocol` and therefore sits in `qq_protocol.ts`, + * which only the desktop shell imports — this module stays Electron-free so + * `app_context` (and through it the web app) can depend on it. + */ + +let cachedExe: string | null = null; + +/** The resolved protocol-handler exe path, or null until/unless a probe finds one. */ +export function getQqProtocolExe(): string | null { + return cachedExe; +} + +export function setQqProtocolExe(path: string | null): void { + cachedExe = path; +} diff --git a/apps/desktop/src/main/file_response.ts b/apps/desktop/src/main/file_response.ts new file mode 100644 index 0000000..49c71f4 --- /dev/null +++ b/apps/desktop/src/main/file_response.ts @@ -0,0 +1,111 @@ +/** + * `file://` → `Response`, without Electron's `net.fetch`. + * + * The protocol handlers used `net.fetch(pathToFileURL(p))` to stream a file off + * disk with `Range` support. Node's global `fetch` refuses `file://`, so the web + * app needs its own implementation — and since it must behave identically in + * both shells, Electron uses this one too. + * + * Streams via `createReadStream` (never buffers whole files: videos are large + * and `