Merge pull request #37 from SnowLuma/dev
chore: promote dev → main (7da52bc)
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
# 贡献者公约 (Contributor Covenant Code of Conduct)
|
||||
|
||||
本文档改编自 [Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/),是 SnowLuma 社区共同遵守的行为准则。
|
||||
|
||||
## 我们的承诺
|
||||
|
||||
身为社区成员、贡献者、维护者,我们承诺:让每一个参与 SnowLuma 项目和社区的人,不因年龄、体型、可见或不可见的身体差异、身心障碍、族裔、性别特征、性别认同与表达、经验水平、教育背景、社会经济地位、国籍、外貌、种族、宗教信仰、或性取向,而遭受骚扰。
|
||||
|
||||
我们承诺以营造开放、热情、多元、包容、健康社区的方式行事和互动。
|
||||
|
||||
## 我们的准则
|
||||
|
||||
有助于建立正向社区环境的行为包括但不限于:
|
||||
|
||||
- 对他人展现同理心与善意
|
||||
- 尊重不同的意见、观点与经验
|
||||
- 给予并优雅地接受建设性反馈
|
||||
- 承担责任并向受我们错误影响的人致歉,并从中吸取教训
|
||||
- 关注的不仅是个人,更是整个社区的最大利益
|
||||
|
||||
不可接受的行为包括但不限于:
|
||||
|
||||
- 使用情色化的语言或意象,及任何形式的性关注或性骚扰
|
||||
- 嘲讽、侮辱或贬损性的评论,以及人身或政治攻击
|
||||
- 公开或私下的骚扰
|
||||
- 未经明确许可,公布他人的私人信息,例如物理或电子邮件地址
|
||||
- 其他在专业场合中可被合理认定为不当的行为
|
||||
|
||||
## 适用范围
|
||||
|
||||
本行为准则适用于所有社区场所,也适用于个人在公开场合代表社区时的言行。代表本社区的情形包括但不限于:使用项目官方电子邮件地址、通过项目官方社交媒体账号发文、以指定代表身份出席线上或线下活动。
|
||||
|
||||
具体到 SnowLuma,本准则适用于:
|
||||
|
||||
- 本仓库(含 issue、PR、discussion、commit message、code review 评论)
|
||||
- 项目相关的 QQ 群、Telegram 群以及未来可能新增的官方社群
|
||||
- 任何以 SnowLuma 名义对外发起的活动或交流
|
||||
|
||||
## 执行责任
|
||||
|
||||
社区维护者负责诠释并执行我们所认定的可接受行为标准,并将对任何他们认为不当、具威胁性、冒犯性或有害的行为采取适当且公正的纠正措施。
|
||||
|
||||
社区维护者有权且有责任删除、编辑或拒绝任何不符合本行为准则的评论、提交、代码、wiki 编辑、issue 及其他贡献,并将在适当时机说明做出审核决策的理由。
|
||||
|
||||
## 举报方式
|
||||
|
||||
如果你目击或亲身经历了不当行为,请通过以下任一渠道**私下**联系维护者:
|
||||
|
||||
- QQ:[SnowLuma-QQ](https://qm.qq.com/q/g3UMLpWALe)(私聊管理员,请勿在群内公开)
|
||||
- Telegram:[SnowLuma-TG](https://t.me/napcatqq)(私聊管理员)
|
||||
|
||||
请勿在 GitHub issue 或公开频道直接发布举报内容,以免对相关方造成二次伤害。
|
||||
|
||||
所有投诉都会被及时和公平地审查与调查。所有社区维护者都有义务尊重举报者的隐私与人身安全。
|
||||
|
||||
## 执行准则
|
||||
|
||||
社区维护者将依据以下「社区影响指引」来判定违反本行为准则之行为的处置方式:
|
||||
|
||||
### 1. 更正 (Correction)
|
||||
|
||||
**社区影响**:使用了不恰当的言语,或其他在社区中被视为不专业或不受欢迎的行为。
|
||||
|
||||
**后果**:由社区维护者私下提出书面警告,明确指出该行为违反规则之处并解释为何不妥。可能要求公开致歉。
|
||||
|
||||
### 2. 警告 (Warning)
|
||||
|
||||
**社区影响**:通过单一事件或一系列行为造成违规。
|
||||
|
||||
**后果**:警告并说明持续行为的后果。在指定时间内禁止与相关人员互动,包括主动联系本行为准则执行者。这包括避免在社区场所及外部渠道(如社交媒体)的互动。违反这些条款可能导致临时或永久封禁。
|
||||
|
||||
### 3. 临时封禁 (Temporary Ban)
|
||||
|
||||
**社区影响**:严重违反社区准则,包括持续的不当行为。
|
||||
|
||||
**后果**:在指定时间内禁止与社区进行任何形式的互动或公开交流。在此期间不得与相关人员有任何公开或私下的互动,包括主动联系本行为准则执行者。违反这些条款可能导致永久封禁。
|
||||
|
||||
### 4. 永久封禁 (Permanent Ban)
|
||||
|
||||
**社区影响**:表现出违反社区准则的模式,包括持续的不当行为、骚扰个人、或对某类群体进行攻击或贬低。
|
||||
|
||||
**后果**:永久禁止在社区内进行任何形式的公开互动。
|
||||
|
||||
## 致谢
|
||||
|
||||
本行为准则改编自 [Contributor Covenant 2.1 版](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html)。
|
||||
|
||||
社区影响指引灵感来自 [Mozilla 的行为准则执行阶梯](https://github.com/mozilla/diversity)。
|
||||
|
||||
如需了解本准则的常见问题,请参阅 [FAQ](https://www.contributor-covenant.org/faq)。中文版译本可参考 [Contributor Covenant 中文翻译](https://www.contributor-covenant.org/zh-cn/version/2/1/code_of_conduct/)。
|
||||
+212
-31
@@ -1,49 +1,233 @@
|
||||
# 贡献指南
|
||||
|
||||
## 分支模型
|
||||
欢迎!如果你正在阅读这份文档,多半是因为你想给 SnowLuma 提一个 PR、修一个 bug,或者只是想了解这个项目是怎么运作的。无论你是第一次接触开源、还是已经有十年经验,我们都希望让你尽快上手。
|
||||
|
||||
参与项目即视为你同意遵守 [行为准则(Code of Conduct)](./CODE_OF_CONDUCT.md)。在开始之前请花两分钟读一下,它定义了我们希望社区维持的氛围。
|
||||
|
||||
---
|
||||
|
||||
## 在动手之前
|
||||
|
||||
SnowLuma 处于开发早期,迭代速度很快,**接口与目录结构随时可能变化**。如果你打算做较大的改动,强烈建议先在 issue 或者社群里和维护者对齐一下方向,避免做了一半发现已经被另一条路替代。
|
||||
|
||||
不知道从哪里入手?以下几类贡献最容易被合并:
|
||||
|
||||
- 修复你在使用中遇到的具体 bug,附最小复现步骤。
|
||||
- 改善错误提示、文档、注释、类型签名。
|
||||
- 给已有模块补测试,尤其是 `packages/core` 下覆盖率较低的部分。
|
||||
- 实现 [RoadMap.md](./RoadMap.md) 中列出的、还没被认领的项。
|
||||
|
||||
如果你只是想看看代码长什么样,可以直接跳到 [本地开发环境](#本地开发环境)。
|
||||
|
||||
---
|
||||
|
||||
## 项目结构速览
|
||||
|
||||
这是一个 pnpm workspace monorepo,主要 package 如下:
|
||||
|
||||
| 路径 | 作用 |
|
||||
| --- | --- |
|
||||
| `packages/core` | 核心运行时:协议、桥接、OneBot 适配、WebUI 服务端 |
|
||||
| `packages/sdk` | 面向第三方开发者的 SDK,会发布到 npm |
|
||||
| `packages/runtime` | 运行时辅助包 |
|
||||
| `packages/websocket` | WebSocket 实现 |
|
||||
| `packages/webui` | 基于 Vite + React 的前端控制台 |
|
||||
| `tools/` | 版本管理、发布脚本 |
|
||||
| `.github/workflows/` | CI / 发布 / 自动合并工作流 |
|
||||
|
||||
入口在 `packages/core/src/index.ts`,整体架构在 `packages/core/src/bridge/` 下能看出大致脉络。
|
||||
|
||||
---
|
||||
|
||||
## 本地开发环境
|
||||
|
||||
### 系统要求
|
||||
|
||||
- **Node.js ≥ 22**(`engines` 字段强制,低版本会报错)
|
||||
- **pnpm 10.28.0+**(项目通过 `packageManager` 字段锁定,建议用 `corepack enable` 安装匹配版本)
|
||||
- macOS / Linux / Windows 均可。如果要构建原生产物,需要对应平台的工具链。
|
||||
|
||||
### 拉代码、装依赖、跑起来
|
||||
|
||||
```bash
|
||||
# 1. Fork 仓库后克隆你自己的 fork
|
||||
git clone https://github.com/<你的用户名>/SnowLuma.git
|
||||
cd SnowLuma
|
||||
|
||||
# 2. 切到 dev 分支(所有开发都基于 dev,不是 main)
|
||||
git checkout dev
|
||||
|
||||
# 3. 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 4. 跑类型检查,确认环境 OK
|
||||
pnpm typecheck
|
||||
|
||||
# 5. 跑测试
|
||||
pnpm test
|
||||
|
||||
# 6. 启动开发模式
|
||||
pnpm dev # core
|
||||
pnpm dev:web # webui
|
||||
```
|
||||
|
||||
如果 `pnpm install` 卡住或报错,先确认 Node 版本和 pnpm 版本,再看 `.npmrc` 里是否配置了你环境下可用的 registry。
|
||||
|
||||
---
|
||||
|
||||
## 第一次提交 PR
|
||||
|
||||
### 1. 创建分支
|
||||
|
||||
请基于 `dev` 创建分支,不是 `main`。`main` 是受保护的稳定分支,PR 也不要直接提到 `main`。
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull upstream dev # 假设你把官方仓库设为 upstream remote
|
||||
git checkout -b fix/onebot-mention-encoding
|
||||
```
|
||||
|
||||
分支命名没有强制规则,但建议用 `fix/...`、`feat/...`、`docs/...`、`refactor/...` 这种前缀,便于一眼看出意图。
|
||||
|
||||
### 2. 写代码
|
||||
|
||||
- **保持改动聚焦**:一个 PR 只做一件事。顺手发现的其他问题请单独开 issue 或新 PR。
|
||||
- **跟随既有风格**:不要为了你个人偏好引入大规模格式化或重命名。
|
||||
- **写测试**:修 bug 时优先写一个能复现该 bug 的测试,确认它会 fail,再让它 pass。新增功能至少补上 happy path 的测试。
|
||||
- **不要添加无意义的注释**:标识符已经说清楚的事情不需要再写一遍。注释应当解释"为什么",而不是"做了什么"。
|
||||
|
||||
提交之前请本地跑一遍:
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
```
|
||||
|
||||
CI 上跑的是同一套检查,本地通过几乎能保证 CI 通过。
|
||||
|
||||
### 3. 提交信息
|
||||
|
||||
我们大致遵循 [Conventional Commits](https://www.conventionalcommits.org/zh-hans/v1.0.0/):
|
||||
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
<body>
|
||||
```
|
||||
|
||||
`type` 常用:`feat` / `fix` / `docs` / `refactor` / `test` / `chore` / `perf`。
|
||||
`scope` 常用:`core` / `sdk` / `webui` / `onebot` / `bridge` / `protocol` 等模块名。
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
fix(onebot): handle empty mention segments in group messages
|
||||
refactor(bridge): extract BridgeInterface as the OneBot-facing seam
|
||||
docs: clarify dev environment setup steps
|
||||
```
|
||||
|
||||
> ⚠️ **请不要**在你的提交信息里使用 `[merge]` 前缀或 `chore(release):` 前缀 —— 这两个前缀是维护者用来触发自动合并到 `main` 的,会让你的 PR 行为异常。详见后文 [维护者参考](#维护者参考release--自动合并流程)。
|
||||
|
||||
### 4. 推送并发起 PR
|
||||
|
||||
```bash
|
||||
git push origin fix/onebot-mention-encoding
|
||||
```
|
||||
|
||||
到 GitHub 上发起 PR,**目标分支选 `dev`**。请在 PR 描述里写明:
|
||||
|
||||
- 这个 PR 解决什么问题 / 实现什么功能。
|
||||
- 主要的实现思路(如果有非显然的取舍)。
|
||||
- 测试方式(你跑了什么、怎么验证有效)。
|
||||
- 关联的 issue(如有)。
|
||||
|
||||
### 5. Code Review 与合并
|
||||
|
||||
- 维护者会在能腾出时间时来 review。如果一周内没有任何回应,欢迎在 PR 里 @ 一下,或者去社群里提醒。
|
||||
- review 意见请尽量在同一个 PR 内修改,避免反复关闭重开。
|
||||
- 合并方式由维护者决定,通常是 squash merge 到 `dev`。**`main` 由维护者通过自动化工作流统一从 `dev` 合入**,你不需要操心 `main`。
|
||||
|
||||
---
|
||||
|
||||
## 风格与约定
|
||||
|
||||
### 代码风格
|
||||
|
||||
- TypeScript 严格模式,按 `tsconfig.base.json` 配置。
|
||||
- 优先用现有抽象,不要为"假想的未来需求"提前抽象。
|
||||
- 不要给内部函数加防御性的参数校验,除非是系统边界(用户输入、外部 API)。
|
||||
- 不要保留死代码、被注释掉的代码或者 `// TODO` 没有具体上下文的占位。
|
||||
|
||||
### 测试
|
||||
|
||||
- 单测放在被测代码同包的 `tests/` 或同目录的 `*.test.ts`,按既有风格走。
|
||||
- 测试要描述行为,不要测实现细节。`it('should call internal foo')` 这种是反例。
|
||||
- 涉及 IO / 时序 / 外部进程的,用现有的 fake/stub 模式,参考 `packages/core/src/onebot/` 下的 `fake-bridge` 类测试。
|
||||
|
||||
### 文档
|
||||
|
||||
- 用户/贡献者可见的行为变化,需要相应更新 README 或对应 package 下的文档。
|
||||
- SDK 的破坏性变更必须更新 `packages/sdk/CHANGELOG.md` 和 `packages/sdk/VERSIONING.md`。
|
||||
|
||||
---
|
||||
|
||||
## 报告 Bug / 请求功能
|
||||
|
||||
- **Bug 报告**:开 issue,附最小复现步骤、期望行为、实际行为、版本号、运行环境。能附上日志最好。
|
||||
- **功能请求**:先描述你想解决的实际场景,再描述你想要的接口。维护者更在意"为什么需要"而不是"具体怎么做"。
|
||||
- **安全问题**:请勿公开发 issue,先通过社群私聊联系维护者。
|
||||
|
||||
社群入口(见 [README](./README.md)):
|
||||
|
||||
- QQ:[SnowLuma-QQ](https://qm.qq.com/q/g3UMLpWALe)
|
||||
- Telegram:[SnowLuma-TG](https://t.me/napcatqq)
|
||||
|
||||
---
|
||||
|
||||
## 维护者参考:release & 自动合并流程
|
||||
|
||||
> 以下内容主要写给具备 `main` 推送 / 发版权限的维护者。普通贡献者只需要把 PR 提到 `dev`,不需要关心这一节。
|
||||
|
||||
### 分支模型
|
||||
|
||||
SnowLuma 使用 **`main` + `dev`** 双分支模型:
|
||||
|
||||
- **`main`** — 受保护的稳定分支,仅接收来自 `dev` 的合并 PR。**禁止直接 push**。
|
||||
- **`dev`** — 日常开发分支。所有功能、修复、文档变更都先提交到 `dev`。
|
||||
- **`dev`** — 日常开发分支。所有功能、修复、文档变更都先合入 `dev`。
|
||||
- **`native/auto-update-*`** — 由 `SnowLuma Bot` 自动创建的原生产物更新分支(来自 `SnowLumaNative`,不需要手动维护)。
|
||||
|
||||
## 把 `dev` 合并到 `main`
|
||||
### 把 `dev` 合并到 `main`
|
||||
|
||||
新增工作流 `.github/workflows/promote-dev-to-main.yml` 会以 `SnowLuma Bot` 身份开 / 更新一个 `dev → main` 的 PR,并可选地自动合并。三种触发方式:
|
||||
工作流 `.github/workflows/promote-dev-to-main.yml` 会以 `SnowLuma Bot` 身份开 / 更新一个 `dev → main` 的 PR,并可选地自动合并。无论用哪种触发方式,工作流都会调用 `gh pr merge --auto`:**只有 `main` 分支保护中所有 required status checks(typecheck + 各架构 build)都通过后,机器人才会真正合并**。失败的工作流会卡住合并。
|
||||
|
||||
无论使用哪种触发方式,工作流都会调用 `gh pr merge --auto`:**只有 `main` 分支保护中所有 required status checks(typecheck + 各架构 build)都通过后,机器人才会真正合并**。失败的工作流会卡住合并。
|
||||
|
||||
### 1. 提交信息前缀(推荐日常使用)
|
||||
#### 1. 提交信息前缀(推荐日常使用)
|
||||
|
||||
向 `dev` push 一个提交,提交信息**以**以下任一前缀**开头**即触发(不区分大小写):
|
||||
|
||||
- `[merge]` — 例如 `[merge] fix: hotfix for OneBot mention`
|
||||
- `chore(release):` — 例如 `chore(release): v1.7.0`,符合 conventional commits 的发版习惯
|
||||
|
||||
> 注意:是**前缀**匹配,不是任意位置。`fix: something [merge]` 不会触发,必须把 `[merge]` 写在最前面。
|
||||
> 是**前缀**匹配,不是任意位置。`fix: something [merge]` 不会触发,必须把 `[merge]` 写在最前面。
|
||||
|
||||
### 2. 推送 `chore.*` Tag
|
||||
#### 2. 推送 `chore.*` Tag
|
||||
|
||||
任何符合 `chore.*` 的 tag(如 `chore.merge-20240509`、`chore.promote-v1.7.0`)被推送时也会触发本工作流。这是和发布 tag 解耦的「合并触发 tag」:
|
||||
任何符合 `chore.*` 的 tag(如 `chore.merge-20240509`、`chore.promote-v1.7.0`)被推送时也会触发:
|
||||
|
||||
- `chore.*` → 仅触发 `promote-dev-to-main.yml`(开 PR,自动合并)
|
||||
- `v*` → 仅触发 `release.yml`(构建发布产物)
|
||||
|
||||
推荐流程:先用 `chore.*` tag 把 `dev` 合入 `main`,待合并完成后再在 `main` 上打 `v*` tag 触发发布。这样 `release.yml` 始终基于已经过完所有检查的 `main` HEAD 构建。
|
||||
推荐流程:先用 `chore.*` tag 把 `dev` 合入 `main`,待合并完成后再在 `main` 上打 `v*` tag 触发发布。这样 `release.yml` 始终基于已过完所有检查的 `main` HEAD 构建。
|
||||
|
||||
### 3. 手动触发
|
||||
#### 3. 手动触发
|
||||
|
||||
打开仓库 Actions 页面 → `Promote Dev to Main` → `Run workflow`。可选输入:
|
||||
|
||||
- `auto_merge`:是否启用 auto-merge(默认 `true`)。
|
||||
- `merge_method`:`merge` / `squash` / `rebase`(默认 `merge`)。
|
||||
|
||||
工作流会按照当前 `dev` 与 `main` 的差异开 / 更新 PR。
|
||||
### 启用 `main` 分支保护(必做)
|
||||
|
||||
## 启用 `main` 分支保护(必做)
|
||||
|
||||
仓库管理员需要在 GitHub 上为 `main` 配置一条规则集(Repository Rules)或经典分支保护,至少满足:
|
||||
仓库管理员需要在 GitHub 上为 `main` 配置规则集(Repository Rules)或经典分支保护,至少满足:
|
||||
|
||||
1. **Settings → Rules → Rulesets**(推荐)或 **Settings → Branches → Branch protection rules**。
|
||||
2. 选择 `main` 作为目标分支。
|
||||
@@ -62,7 +246,7 @@ SnowLuma 使用 **`main` + `dev`** 双分支模型:
|
||||
|
||||
效果:任何对 `dev` 的推送都会先在 `dev-build.yml` 上跑 typecheck + 三个架构的 build;只有全部成功,promote 工作流的 `--auto` 合并才会真正发生。失败时 PR 会保留在 open 状态,修复后再次推到 `dev` 即可重新触发检查。
|
||||
|
||||
## 必需的 Secrets
|
||||
### 必需的 Secrets
|
||||
|
||||
`promote-dev-to-main.yml` 复用 `SnowLumaNative/build-native.yml` 同一个 GitHub App。请确认 SnowLuma 仓库(或 organization)级别已经配置:
|
||||
|
||||
@@ -71,22 +255,9 @@ SnowLuma 使用 **`main` + `dev`** 双分支模型:
|
||||
|
||||
App 必须在本仓库已安装,且授予 **Contents: Read+Write**、**Pull requests: Read+Write**。
|
||||
|
||||
## 本地工作流速查
|
||||
### 本地工作流速查(维护者)
|
||||
|
||||
```bash
|
||||
# 切到 dev 开发
|
||||
git checkout dev
|
||||
git pull
|
||||
|
||||
# 写代码 / 跑测试
|
||||
pnpm install
|
||||
pnpm typecheck
|
||||
pnpm -s --filter @snowluma/core test
|
||||
|
||||
# 普通提交(不会触发 promote)
|
||||
git commit -m "fix: something"
|
||||
git push
|
||||
|
||||
# 把 dev 合入 main(三选一)
|
||||
|
||||
# 方式 A:commit-msg 前缀
|
||||
@@ -104,3 +275,13 @@ git checkout main && git pull
|
||||
git tag v1.7.0
|
||||
git push origin v1.7.0 # → 仅触发 release.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 还有问题?
|
||||
|
||||
- 看一下已有的 issue 和 PR,可能你的问题已经被讨论过。
|
||||
- 去社群问一句(QQ / TG 链接见上方)。
|
||||
- 如果只是想确认"我这么改 OK 吗",欢迎开 draft PR,先把代码亮出来再讨论。
|
||||
|
||||
感谢你愿意花时间贡献 SnowLuma —— 这个项目能走多远,取决于你们每一个人。❄️
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "snowluma-monorepo",
|
||||
"version": "1.8.0",
|
||||
"version": "1.8.1",
|
||||
"description": "SnowLuma monorepo workspace (pnpm)",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@snowluma/core",
|
||||
"version": "1.8.0",
|
||||
"version": "1.8.1",
|
||||
"description": "SnowLuma core - NTQQ packet sniffer with Node.js native addon",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// Tier-2 napcat-parity actions ported as pure oidb calls. Each function
|
||||
// here corresponds to one napcat PacketApi.pkt.operation.* entry; the
|
||||
// schemas live in proto/oidb-action.ts. Nothing in this file touches
|
||||
// the NTQQ NodeIKernel — every wire trip is one runOidb round-trip.
|
||||
|
||||
import type { Bridge } from '../bridge';
|
||||
import { runOidb } from '../bridge-oidb';
|
||||
import {
|
||||
OidbGroupTodoSchema,
|
||||
OidbStrangerStatusReqSchema,
|
||||
OidbStrangerStatusRespSchema,
|
||||
OidbAiVoiceListReqSchema,
|
||||
OidbAiVoiceListRespSchema,
|
||||
OidbAiVoiceReqSchema,
|
||||
OidbAiVoiceRespSchema,
|
||||
} from '../proto/oidb-action';
|
||||
import type { MediaIndexNode } from './shared';
|
||||
|
||||
// ─────────────── Group todo (0xF90) ───────────────
|
||||
|
||||
export async function setGroupTodo(bridge: Bridge, groupId: number, msgSeq: bigint): Promise<void> {
|
||||
await runOidb(bridge, {
|
||||
cmd: 'OidbSvcTrpcTcp.0xf90_1',
|
||||
oidbCmd: 0xF90, subCmd: 1,
|
||||
request: { schema: OidbGroupTodoSchema, value: { groupUin: groupId, msgSeq } },
|
||||
});
|
||||
}
|
||||
|
||||
export async function completeGroupTodo(bridge: Bridge, groupId: number, msgSeq: bigint): Promise<void> {
|
||||
await runOidb(bridge, {
|
||||
cmd: 'OidbSvcTrpcTcp.0xf90_2',
|
||||
oidbCmd: 0xF90, subCmd: 2,
|
||||
request: { schema: OidbGroupTodoSchema, value: { groupUin: groupId, msgSeq } },
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelGroupTodo(bridge: Bridge, groupId: number, msgSeq: bigint): Promise<void> {
|
||||
await runOidb(bridge, {
|
||||
cmd: 'OidbSvcTrpcTcp.0xf90_3',
|
||||
oidbCmd: 0xF90, subCmd: 3,
|
||||
request: { schema: OidbGroupTodoSchema, value: { groupUin: groupId, msgSeq } },
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────── Stranger online/ext status (0xFE1_2) ───────────────
|
||||
|
||||
export interface StrangerStatus {
|
||||
status: number;
|
||||
ext_status: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror napcat's GetStrangerStatus: decode the extBigInt status word.
|
||||
* Values ≤10 fold into a 10×status; everything else is split into the
|
||||
* high-byte / low-byte ext_status the OneBot dialect uses.
|
||||
*
|
||||
* Returns null on transport / decode failure rather than throwing, so
|
||||
* the OneBot action can produce a clean retcode without try/catch
|
||||
* gymnastics.
|
||||
*/
|
||||
export async function getStrangerStatus(bridge: Bridge, uin: number): Promise<StrangerStatus | null> {
|
||||
try {
|
||||
const resp = await runOidb<{ data?: { status?: { value?: bigint | number } } }>(bridge, {
|
||||
cmd: 'OidbSvcTrpcTcp.0xfe1_2',
|
||||
oidbCmd: 0xFE1, subCmd: 2,
|
||||
request: {
|
||||
schema: OidbStrangerStatusReqSchema,
|
||||
value: { uin, key: [{ key: 27372 }] },
|
||||
},
|
||||
response: { schema: OidbStrangerStatusRespSchema },
|
||||
});
|
||||
const raw = resp?.data?.status?.value;
|
||||
if (raw === undefined || raw === null) return null;
|
||||
const extBig = typeof raw === 'bigint' ? raw : BigInt(raw);
|
||||
if (extBig <= 10n) {
|
||||
return { status: Number(extBig) * 10, ext_status: 0 };
|
||||
}
|
||||
const status = Number((extBig & 0xff00n) + ((extBig >> 16n) & 0xffn));
|
||||
return { status: 10, ext_status: status };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────── AI voice (0x929D / 0x929B) ───────────────
|
||||
|
||||
export const AiVoiceChatType = {
|
||||
Unknown: 0,
|
||||
Sound: 1,
|
||||
Sing: 2,
|
||||
} as const;
|
||||
export type AiVoiceChatType = typeof AiVoiceChatType[keyof typeof AiVoiceChatType];
|
||||
|
||||
export interface AiVoiceItem {
|
||||
voiceId: string;
|
||||
voiceDisplayName: string;
|
||||
voiceExampleUrl: string;
|
||||
}
|
||||
|
||||
export interface AiVoiceCategory {
|
||||
category: string;
|
||||
voices: AiVoiceItem[];
|
||||
}
|
||||
|
||||
export async function fetchAiVoiceList(
|
||||
bridge: Bridge,
|
||||
groupId: number,
|
||||
chatType: AiVoiceChatType,
|
||||
): Promise<AiVoiceCategory[]> {
|
||||
const resp = await runOidb<{ content?: AiVoiceCategory[] }>(bridge, {
|
||||
cmd: 'OidbSvcTrpcTcp.0x929d_0',
|
||||
oidbCmd: 0x929D, subCmd: 0,
|
||||
request: { schema: OidbAiVoiceListReqSchema, value: { groupUin: groupId, chatType } },
|
||||
response: { schema: OidbAiVoiceListRespSchema },
|
||||
});
|
||||
return resp?.content ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger AI voice synthesis. Server may return an empty msgInfo while
|
||||
* the render is in-flight; we retry until a node materialises or the
|
||||
* cap is hit. napcat uses the same 30-retry budget.
|
||||
*
|
||||
* The returned MediaIndexNode plugs directly into
|
||||
* `bridge.fetchGroupPttUrlByNode`, which already handles every other
|
||||
* download-URL fetch in SnowLuma.
|
||||
*/
|
||||
export async function fetchAiVoice(
|
||||
bridge: Bridge,
|
||||
groupId: number,
|
||||
voiceId: string,
|
||||
text: string,
|
||||
chatType: AiVoiceChatType,
|
||||
maxRetries = 30,
|
||||
): Promise<MediaIndexNode> {
|
||||
// Random 32-bit session id — server uses this to deduplicate polls.
|
||||
const sessionId = Math.floor(Math.random() * 0xFFFFFFFF) >>> 0;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
const resp = await runOidb<{
|
||||
statusCode?: number;
|
||||
msgInfo?: { msgInfoBody?: Array<{ index?: MediaIndexNode }> };
|
||||
}>(bridge, {
|
||||
cmd: 'OidbSvcTrpcTcp.0x929b_0',
|
||||
oidbCmd: 0x929B, subCmd: 0,
|
||||
request: {
|
||||
schema: OidbAiVoiceReqSchema,
|
||||
value: { groupUin: groupId, voiceId, text, chatType, session: { sessionId } },
|
||||
},
|
||||
response: { schema: OidbAiVoiceRespSchema },
|
||||
});
|
||||
const node = resp?.msgInfo?.msgInfoBody?.[0]?.index;
|
||||
if (node) return node;
|
||||
}
|
||||
throw new Error(`AI voice synthesis did not complete after ${maxRetries} polls`);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import type { Bridge } from '../bridge';
|
||||
import { gunzipSync, gzipSync } from 'zlib';
|
||||
import { protoDecode, protoEncode } from '../../protobuf/decode';
|
||||
import { buildSendElems } from '../element-builder';
|
||||
import { parseMsgPush } from '../msg-push-handler';
|
||||
import { parseMsgPush } from '../msg-push';
|
||||
import type { ForwardNodePayload } from '../events';
|
||||
import type { PacketInfo } from '../../protocol/types';
|
||||
import {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
NTV2RichMediaRespSchema,
|
||||
} from '../proto/oidb-action';
|
||||
import { FileUploadExtSchema } from '../proto/highway';
|
||||
import { toHexUpper } from '../../utils/hex';
|
||||
import { ensureRetCodeZero, resolveSelfUid, toInt, type MediaIndexNode } from './shared';
|
||||
|
||||
// ─────────────── public result types ───────────────
|
||||
@@ -76,7 +77,7 @@ function normalizeDirectory(dir?: string): string {
|
||||
|
||||
function bytesToHexUpper(data: unknown): string {
|
||||
if (!(data instanceof Uint8Array) || data.length === 0) return '';
|
||||
return Buffer.from(data).toString('hex').toUpperCase();
|
||||
return toHexUpper(data);
|
||||
}
|
||||
|
||||
function normalizeUploadFileName(name: string, fallback: string): string {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { computeHashes, loadBinarySource } from '../highway/utils';
|
||||
import {
|
||||
FaceroamOpReqSchema,
|
||||
FaceroamOpRespSchema,
|
||||
GroupAvatarExtraSchema,
|
||||
Oidb0x112aReqSchema,
|
||||
Oidb0x112aRespSchema,
|
||||
Oidb0x7edReqSchema,
|
||||
@@ -32,11 +33,39 @@ export async function setOnlineStatus(
|
||||
extStatus: number = 0,
|
||||
batteryStatus: number = 100,
|
||||
): Promise<void> {
|
||||
const request = protoEncode({
|
||||
status,
|
||||
extStatus,
|
||||
batteryStatus,
|
||||
}, SetStatusReqSchema);
|
||||
await dispatchSetStatus(bridge, { status, extStatus, batteryStatus });
|
||||
}
|
||||
|
||||
/**
|
||||
* DIY (custom) online status. napcat fixes status=10 / extStatus=2000
|
||||
* — the values QQ associates with "I have a custom status string" —
|
||||
* and threads the faceId / wording / faceType through the customExt
|
||||
* sub-message of the same SetStatus wire call.
|
||||
*/
|
||||
export async function setDiyOnlineStatus(
|
||||
bridge: Bridge,
|
||||
faceId: number,
|
||||
wording: string,
|
||||
faceType: number,
|
||||
): Promise<void> {
|
||||
await dispatchSetStatus(bridge, {
|
||||
status: 10,
|
||||
extStatus: 2000,
|
||||
batteryStatus: 0,
|
||||
customExt: { faceId, text: wording, faceType },
|
||||
});
|
||||
}
|
||||
|
||||
async function dispatchSetStatus(
|
||||
bridge: Bridge,
|
||||
value: {
|
||||
status: number;
|
||||
extStatus: number;
|
||||
batteryStatus: number;
|
||||
customExt?: { faceId: number; text: string; faceType: number };
|
||||
},
|
||||
): Promise<void> {
|
||||
const request = protoEncode(value, SetStatusReqSchema);
|
||||
|
||||
const result = await bridge.sendRawPacket('trpc.qq_new_tech.status_svc.StatusService.SetStatus', request);
|
||||
|
||||
@@ -147,6 +176,36 @@ export async function setAvatar(
|
||||
await uploadHighwayHttp(bridge, session, 90, loaded.bytes, hashes.md5, new Uint8Array(0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set group avatar. Mirrors Lagrange.Core's GroupSetAvatar:
|
||||
* - same highway HTTP upload as personal avatar
|
||||
* - cmdId 3000 (instead of 90)
|
||||
* - GroupAvatarExtra proto carried as the `extend` blob, with the
|
||||
* four protocol-prescribed constants (type=101, field5=3, field6=1,
|
||||
* field3.field1=1) and the target groupUin.
|
||||
*
|
||||
* Source ref: Lagrange.Core/Internal/Context/Logic/Implementation/OperationLogic.cs#GroupSetAvatar.
|
||||
*/
|
||||
export async function setGroupAvatar(
|
||||
bridge: Bridge,
|
||||
groupId: number,
|
||||
source: string,
|
||||
): Promise<void> {
|
||||
const loaded = await loadBinarySource(source, 'group-avatar');
|
||||
if (!loaded.bytes.length) throw new Error('group avatar file is empty');
|
||||
|
||||
const hashes = computeHashes(loaded.bytes);
|
||||
const session = await fetchHighwaySession(bridge);
|
||||
const extra = protoEncode({
|
||||
type: 101,
|
||||
groupUin: groupId,
|
||||
field3: { field1: 1 },
|
||||
field5: 3,
|
||||
field6: 1,
|
||||
}, GroupAvatarExtraSchema);
|
||||
await uploadHighwayHttp(bridge, session, 3000, loaded.bytes, hashes.md5, extra);
|
||||
}
|
||||
|
||||
// ─────────────── queries on me / my contacts ───────────────
|
||||
|
||||
export async function getProfileLike(
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
DownloadRKeyInfo,
|
||||
ClientKeyInfo,
|
||||
} from './bridge';
|
||||
import type { SendPacketResult } from '../protocol/packet-sender';
|
||||
|
||||
export interface BridgeInterface {
|
||||
// ─── Shared state ───
|
||||
@@ -33,6 +34,12 @@ export interface BridgeInterface {
|
||||
// ─── Resolution ───
|
||||
resolveUserUid(uin: number, groupId?: number): Promise<string>;
|
||||
|
||||
// ─── Raw packet (deliberate escape hatch for `send_packet` action) ───
|
||||
// OneBot clients use this to debug or invoke commands SnowLuma has no
|
||||
// typed wrapper for. Every other method on this interface eventually
|
||||
// routes through here.
|
||||
sendRawPacket(serviceCmd: string, body: Uint8Array, timeoutMs?: number): Promise<SendPacketResult>;
|
||||
|
||||
// ─── Send (messages) ───
|
||||
sendGroupMessage(groupId: number, elements: MessageElement[]): Promise<SendMessageReceipt>;
|
||||
sendPrivateMessage(userUin: number, elements: MessageElement[]): Promise<SendMessageReceipt>;
|
||||
@@ -113,10 +120,12 @@ export interface BridgeInterface {
|
||||
|
||||
// ─── Personal profile ───
|
||||
setOnlineStatus(status: number, extStatus?: number, batteryStatus?: number): Promise<void>;
|
||||
setDiyOnlineStatus(faceId: number, wording: string, faceType: number): Promise<void>;
|
||||
setProfile(nickname?: string, personalNote?: string): Promise<void>;
|
||||
setSelfLongNick(longNick: string): Promise<any>;
|
||||
setInputStatus(userId: number, eventType: number): Promise<any>;
|
||||
setAvatar(source: string): Promise<void>;
|
||||
setGroupAvatar(groupId: number, source: string): Promise<void>;
|
||||
fetchCustomFace(count?: number): Promise<string[]>;
|
||||
getProfileLike(userId?: number, start?: number, limit?: number): Promise<any>;
|
||||
getUnidirectionalFriendList(): Promise<any>;
|
||||
@@ -127,4 +136,12 @@ export interface BridgeInterface {
|
||||
getMiniAppArk(type: string, title: string, desc: string, picUrl: string, jumpUrl: string): Promise<any>;
|
||||
clickInlineKeyboardButton(groupId: number, botAppid: number, buttonId: string, callbackData: string, msgSeq: number): Promise<any>;
|
||||
sendGroupSign(groupId: number): Promise<any>;
|
||||
|
||||
// ─── Tier-2 napcat parity extras ───
|
||||
setGroupTodo(groupId: number, msgSeq: bigint | number | string): Promise<void>;
|
||||
completeGroupTodo(groupId: number, msgSeq: bigint | number | string): Promise<void>;
|
||||
cancelGroupTodo(groupId: number, msgSeq: bigint | number | string): Promise<void>;
|
||||
getStrangerStatus(uin: number): Promise<{ status: number; ext_status: number } | null>;
|
||||
fetchAiVoiceList(groupId: number, chatType: number): Promise<Array<{ category: string; voices: Array<{ voiceId: string; voiceDisplayName: string; voiceExampleUrl: string }> }>>;
|
||||
fetchAiVoice(groupId: number, voiceId: string, text: string, chatType: number): Promise<MediaIndexNode>;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import type { PacketInfo } from '../protocol/types';
|
||||
import type { ForwardNodePayload, QQEventVariant, MessageElement } from './events';
|
||||
import type { FriendInfo, QQGroupInfo, GroupMemberInfo, UserProfileInfo, GroupRequestInfo } from './qq-info';
|
||||
import { MSG_PUSH_CMD, parseMsgPush } from './msg-push-handler';
|
||||
import { MSG_PUSH_CMD, parseMsgPush } from './msg-push';
|
||||
import type { PacketSender, SendPacketResult } from '../protocol/packet-sender';
|
||||
import { protoEncode, protoDecode } from '../protobuf/decode';
|
||||
import { buildSendElems } from './element-builder';
|
||||
@@ -85,10 +85,12 @@ import {
|
||||
} from './actions/friend';
|
||||
import {
|
||||
setOnlineStatus as setOnlineStatus_,
|
||||
setDiyOnlineStatus as setDiyOnlineStatus_,
|
||||
setProfile as setProfile_,
|
||||
setSelfLongNick as setSelfLongNick_,
|
||||
setInputStatus as setInputStatus_,
|
||||
setAvatar as setAvatar_,
|
||||
setGroupAvatar as setGroupAvatar_,
|
||||
fetchCustomFace as fetchCustomFace_,
|
||||
getProfileLike as getProfileLike_,
|
||||
getUnidirectionalFriendList as getUnidirectionalFriendList_,
|
||||
@@ -99,6 +101,20 @@ import {
|
||||
clickInlineKeyboardButton as clickInlineKeyboardButton_,
|
||||
sendGroupSign as sendGroupSign_,
|
||||
} from './actions/misc';
|
||||
import {
|
||||
setGroupTodo as setGroupTodo_,
|
||||
completeGroupTodo as completeGroupTodo_,
|
||||
cancelGroupTodo as cancelGroupTodo_,
|
||||
getStrangerStatus as getStrangerStatus_,
|
||||
fetchAiVoiceList as fetchAiVoiceList_,
|
||||
fetchAiVoice as fetchAiVoice_,
|
||||
AiVoiceChatType,
|
||||
type AiVoiceCategory,
|
||||
type AiVoiceChatType as AiVoiceChatTypeT,
|
||||
type StrangerStatus,
|
||||
} from './actions/extras';
|
||||
export { AiVoiceChatType };
|
||||
export type { AiVoiceCategory, StrangerStatus };
|
||||
import {
|
||||
getGroupHonorInfo as getGroupHonorInfo_,
|
||||
forceFetchClientKey as forceFetchClientKey_,
|
||||
@@ -489,6 +505,9 @@ export class Bridge implements BridgeInterface {
|
||||
async setOnlineStatus(status: number, extStatus: number = 0, batteryStatus: number = 100): Promise<void> {
|
||||
return setOnlineStatus_(this, status, extStatus, batteryStatus);
|
||||
}
|
||||
async setDiyOnlineStatus(faceId: number, wording: string, faceType: number): Promise<void> {
|
||||
return setDiyOnlineStatus_(this, faceId, wording, faceType);
|
||||
}
|
||||
async setProfile(nickname?: string, personalNote?: string): Promise<void> {
|
||||
return setProfile_(this, nickname, personalNote);
|
||||
}
|
||||
@@ -522,11 +541,35 @@ export class Bridge implements BridgeInterface {
|
||||
async setAvatar(source: string): Promise<void> {
|
||||
return setAvatar_(this, source);
|
||||
}
|
||||
async setGroupAvatar(groupId: number, source: string): Promise<void> {
|
||||
return setGroupAvatar_(this, groupId, source);
|
||||
}
|
||||
async fetchCustomFace(count?: number): Promise<string[]> {
|
||||
return fetchCustomFace_(this, count);
|
||||
}
|
||||
async getEmojiLikes(groupId: number, sequence: number, emojiId: string, emojiType?: number, count?: number, cookie?: string) {
|
||||
return getEmojiLikes_(this, groupId, sequence, emojiId, emojiType, count, cookie);
|
||||
}
|
||||
|
||||
// --- Tier-2 napcat-parity extras (group todo, stranger status, AI voice) ---
|
||||
|
||||
async setGroupTodo(groupId: number, msgSeq: bigint | number | string): Promise<void> {
|
||||
return setGroupTodo_(this, groupId, BigInt(msgSeq));
|
||||
}
|
||||
async completeGroupTodo(groupId: number, msgSeq: bigint | number | string): Promise<void> {
|
||||
return completeGroupTodo_(this, groupId, BigInt(msgSeq));
|
||||
}
|
||||
async cancelGroupTodo(groupId: number, msgSeq: bigint | number | string): Promise<void> {
|
||||
return cancelGroupTodo_(this, groupId, BigInt(msgSeq));
|
||||
}
|
||||
async getStrangerStatus(uin: number): Promise<StrangerStatus | null> {
|
||||
return getStrangerStatus_(this, uin);
|
||||
}
|
||||
async fetchAiVoiceList(groupId: number, chatType: AiVoiceChatTypeT): Promise<AiVoiceCategory[]> {
|
||||
return fetchAiVoiceList_(this, groupId, chatType);
|
||||
}
|
||||
async fetchAiVoice(groupId: number, voiceId: string, text: string, chatType: AiVoiceChatTypeT) {
|
||||
return fetchAiVoice_(this, groupId, voiceId, text, chatType);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -208,11 +208,14 @@ async function makePttElem(ctx: SendContext, element: MessageElement): Promise<P
|
||||
|
||||
const msgInfo = await uploadPttMsgInfo(ctx.bridge, isGroup, targetIdOrUid, element);
|
||||
|
||||
// commonElem.businessType is the QQ NT scene tag the receive-side
|
||||
// decoder pairs with: 12=c2c, 22=group. Sending the group tag on a
|
||||
// c2c message bounces with PbSendMsg result=79.
|
||||
return {
|
||||
commonElem: {
|
||||
serviceType: 48,
|
||||
pbElem: msgInfo,
|
||||
businessType: 22,
|
||||
businessType: isGroup ? 22 : 12,
|
||||
} as any,
|
||||
};
|
||||
}
|
||||
@@ -226,11 +229,14 @@ async function makeVideoElem(ctx: SendContext, element: MessageElement): Promise
|
||||
|
||||
const msgInfo = await uploadVideoMsgInfo(ctx.bridge, isGroup, targetIdOrUid, element);
|
||||
|
||||
// commonElem.businessType is the QQ NT scene tag the receive-side
|
||||
// decoder pairs with: 11=c2c, 21=group. Sending the group tag on a
|
||||
// c2c message bounces with PbSendMsg result=79.
|
||||
return {
|
||||
commonElem: {
|
||||
serviceType: 48,
|
||||
pbElem: msgInfo,
|
||||
businessType: 21,
|
||||
businessType: isGroup ? 21 : 11,
|
||||
} as any,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -487,7 +487,11 @@ export async function uploadVideoMsgInfo(
|
||||
subFileType: 100,
|
||||
},
|
||||
],
|
||||
compatQmsgSceneType: 2,
|
||||
// Scene tag the NTV2 upload registers under. image-upload and
|
||||
// ptt-upload both differentiate (group=2, c2c=1); video was
|
||||
// hardcoded to the group value, which contributed to the c2c
|
||||
// send-back rejection (PbSendMsg result=79).
|
||||
compatQmsgSceneType: isGroup ? 2 : 1,
|
||||
extBizInfo: {
|
||||
pic: { bizType: 0, textSummary: 'Nya~' },
|
||||
video: { bytesPbReserve: new Uint8Array([0x80, 0x01, 0x00]) },
|
||||
|
||||
@@ -1,885 +0,0 @@
|
||||
// MsgPush packet handler — parses trpc.msg.olpush.OlPushService.MsgPush
|
||||
// into bridge events. Port of src/bridge/src/handlers/msg_push_handler.h/.cpp
|
||||
|
||||
import { inflateSync } from 'zlib';
|
||||
import { protoDecode } from '../protobuf/decode';
|
||||
import { WireMessage } from '../protobuf/wire';
|
||||
import { PushMsgSchema } from './proto/message';
|
||||
import {
|
||||
MentionExtraSchema, QFaceExtraSchema, QSmallFaceExtraSchema,
|
||||
MsgInfoSchema, GroupFileExtraSchema, NotOnlineImageSchema,
|
||||
} from './proto/element';
|
||||
import { FileExtraSchema } from './proto/message';
|
||||
import {
|
||||
GroupChangeSchema, GroupAdminSchema, GroupJoinSchema,
|
||||
GroupInvitationSchema, GroupInviteSchema,
|
||||
FriendRequestSchema, FriendRecallSchema,
|
||||
GroupMuteSchema, NotifyMessageBodySchema,
|
||||
GeneralGrayTipInfoSchema, OperatorInfoSchema,
|
||||
} from './proto/notify';
|
||||
import type { IdentityService } from './identity-service';
|
||||
import type { PacketInfo } from '../protocol/types';
|
||||
import type {
|
||||
QQEventVariant, MessageElement,
|
||||
FriendMessage, GroupMessage, TempMessage,
|
||||
GroupMemberJoin, GroupMemberLeave, GroupMuteEvent, GroupAdminEvent,
|
||||
FriendRecall, GroupRecallEvent, FriendRequestEvent, GroupInviteEvent,
|
||||
FriendPokeEvent, GroupPokeEvent, GroupEssenceEvent,
|
||||
} from './events';
|
||||
import type { ProtoDecoded, ProtoSchema } from '../protobuf/decode';
|
||||
|
||||
// --- PkgType enum ---
|
||||
const enum PkgType {
|
||||
ForwardFakePrivateMessage = 9,
|
||||
PrivateMessage = 166,
|
||||
GroupMessage = 82,
|
||||
TempMessage = 141,
|
||||
Event0x210 = 528,
|
||||
Event0x2DC = 732,
|
||||
PrivateRecordMessage = 208,
|
||||
PrivateFileMessage = 529,
|
||||
GroupRequestInvitationNotice = 525,
|
||||
GroupRequestJoinNotice = 84,
|
||||
GroupInviteNotice = 87,
|
||||
GroupAdminChangedNotice = 44,
|
||||
GroupMemberIncreaseNotice = 33,
|
||||
GroupMemberDecreaseNotice = 34,
|
||||
}
|
||||
|
||||
const enum Event0x2DCSubType {
|
||||
GroupMuteNotice = 12,
|
||||
GroupRecallNotice = 17,
|
||||
GroupGreyTipNotice = 20,
|
||||
GroupEssenceNotice = 21,
|
||||
}
|
||||
|
||||
const enum Event0x210SubType {
|
||||
FriendRequestNotice = 35,
|
||||
FriendRecallNotice = 138,
|
||||
FriendPokeNotice = 290,
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function makeImageUrl(origUrl: string): string {
|
||||
if (!origUrl) return '';
|
||||
if (origUrl.includes('rkey')) return 'https://multimedia.nt.qq.com.cn' + origUrl;
|
||||
return 'http://gchat.qpic.cn' + origUrl;
|
||||
}
|
||||
|
||||
const HEX_CHARS = '0123456789ABCDEF';
|
||||
function bytesToHex(data: Uint8Array): string {
|
||||
let r = '';
|
||||
for (const b of data) { r += HEX_CHARS[b >> 4]; r += HEX_CHARS[b & 0x0f]; }
|
||||
return r;
|
||||
}
|
||||
|
||||
function decompressData(data: Uint8Array): string {
|
||||
if (!data || data.length === 0) return '';
|
||||
if (data[0] === 0x01 && data.length > 1) {
|
||||
try {
|
||||
const inflated = inflateSync(Buffer.from(data.subarray(1)));
|
||||
return inflated.toString('utf8');
|
||||
} catch { return ''; }
|
||||
}
|
||||
if (data[0] === 0x00 && data.length > 1) {
|
||||
return Buffer.from(data.subarray(1)).toString('utf8');
|
||||
}
|
||||
return Buffer.from(data).toString('utf8');
|
||||
}
|
||||
|
||||
function isNumericUin(value: string): boolean {
|
||||
return value.length > 0 && /^\d+$/.test(value);
|
||||
}
|
||||
|
||||
function parseU64OrZero(value: string): number {
|
||||
if (!value) return 0;
|
||||
const n = parseInt(value, 10);
|
||||
return isNaN(n) ? 0 : n;
|
||||
}
|
||||
|
||||
function resolveUidToUin(identity: IdentityService, groupId: number, uid: string, fallback = 0): number {
|
||||
if (!uid) return fallback;
|
||||
if (isNumericUin(uid)) {
|
||||
const n = parseInt(uid, 10);
|
||||
if (!isNaN(n)) return n;
|
||||
}
|
||||
// identity.findUinByUid cascades group-scoped lookup → in-memory map → SQLite.
|
||||
// No network fallback on the parse hot path: missing identities fall back to
|
||||
// `fallback` and downstream events drive a roster refresh asynchronously.
|
||||
const uin = identity.findUinByUid(uid, groupId || undefined);
|
||||
if (uin !== null) return uin;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function decodeOperatorUid(bytes: Uint8Array): string {
|
||||
if (!bytes || bytes.length === 0) return '';
|
||||
const info = protoDecode(bytes, OperatorInfoSchema);
|
||||
if (info?.operatorField?.uid) return info.operatorField.uid;
|
||||
return Buffer.from(bytes).toString('utf8');
|
||||
}
|
||||
|
||||
function buildTemplateMap(params: Array<{name?: string; value?: string}>): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
for (const p of params) {
|
||||
if (p.name !== undefined) map.set(p.name, p.value ?? '');
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function findTemplateValue(map: Map<string, string>, ...keys: string[]): string {
|
||||
for (const k of keys) {
|
||||
const v = map.get(k);
|
||||
if (v) return v;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function unwrapGroupNotifyPayload(content: Uint8Array): Uint8Array | null {
|
||||
if (content.length <= 7) return null;
|
||||
const lenBe = (content[5] << 8) | content[6];
|
||||
const lenLe = content[5] | (content[6] << 8);
|
||||
if (7 + lenBe <= content.length) return content.subarray(7, 7 + lenBe);
|
||||
if (7 + lenLe <= content.length) return content.subarray(7, 7 + lenLe);
|
||||
return content.subarray(7);
|
||||
}
|
||||
|
||||
// --- Element conversion ---
|
||||
|
||||
type ElemDecoded = ProtoDecoded<typeof import('./proto/element').ElemSchema>;
|
||||
|
||||
function convertElements(elems: ElemDecoded[]): MessageElement[] {
|
||||
const result: MessageElement[] = [];
|
||||
let skipNext = false;
|
||||
|
||||
for (const elem of elems) {
|
||||
if (skipNext) { skipNext = false; continue; }
|
||||
|
||||
// Reply / quote
|
||||
if (elem.srcMsg?.origSeqs && elem.srcMsg.origSeqs.length > 0) {
|
||||
result.push({ type: 'reply', replySeq: elem.srcMsg.origSeqs[0] });
|
||||
}
|
||||
|
||||
// Text (with possible @ detection)
|
||||
if (elem.text) {
|
||||
const t = elem.text;
|
||||
let mention: ProtoDecoded<typeof MentionExtraSchema> | null = null;
|
||||
if (t.pbReserve && t.pbReserve.length > 0) {
|
||||
mention = protoDecode(t.pbReserve, MentionExtraSchema);
|
||||
}
|
||||
const hasAttr6 = t.attr6Buf && t.attr6Buf.length > 11;
|
||||
const hasMention = mention && (mention.type === 1 || mention.type === 2);
|
||||
|
||||
if (hasAttr6 || hasMention) {
|
||||
const me: MessageElement = { type: 'at', text: t.str ?? '' };
|
||||
if (hasAttr6) {
|
||||
const buf = t.attr6Buf!;
|
||||
me.targetUin = ((buf[7] << 24) | (buf[8] << 16) | (buf[9] << 8) | buf[10]) >>> 0;
|
||||
}
|
||||
if (hasMention && mention) {
|
||||
me.uid = mention.uid ?? '';
|
||||
if (!me.targetUin) me.targetUin = mention.uin ?? 0;
|
||||
}
|
||||
result.push(me);
|
||||
} else {
|
||||
const text = t.str ?? '';
|
||||
if (text) result.push({ type: 'text', text });
|
||||
}
|
||||
}
|
||||
|
||||
// Face
|
||||
if (elem.face) {
|
||||
result.push({ type: 'face', faceId: elem.face.index ?? 0 });
|
||||
}
|
||||
|
||||
// MarketFace
|
||||
if (elem.marketFace) {
|
||||
result.push({
|
||||
type: 'mface',
|
||||
text: elem.marketFace.faceName ?? '',
|
||||
faceId: elem.marketFace.tabId ?? 0,
|
||||
subType: elem.marketFace.subType ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
// NotOnlineImage (C2C image)
|
||||
if (elem.notOnlineImage) {
|
||||
const img = elem.notOnlineImage;
|
||||
if (img.picMd5 && img.picMd5.length > 0) {
|
||||
const urlPath = img.origUrl || img.bigUrl || '';
|
||||
result.push({
|
||||
type: 'image',
|
||||
imageUrl: makeImageUrl(urlPath),
|
||||
fileId: img.filePath ?? '',
|
||||
fileSize: img.fileLen ?? 0,
|
||||
width: img.picWidth ?? 0,
|
||||
height: img.picHeight ?? 0,
|
||||
subType: img.pbRes?.subType ?? 0,
|
||||
summary: img.pbRes?.summary ?? '[image]',
|
||||
md5Hex: bytesToHex(img.picMd5),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// CustomFace (group image)
|
||||
if (elem.customFace) {
|
||||
const img = elem.customFace;
|
||||
if (img.md5 && img.md5.length > 0) {
|
||||
result.push({
|
||||
type: 'image',
|
||||
imageUrl: makeImageUrl(img.origUrl ?? ''),
|
||||
fileId: img.filePath ?? '',
|
||||
fileSize: img.size ?? 0,
|
||||
width: img.width ?? 0,
|
||||
height: img.height ?? 0,
|
||||
subType: img.pbRes?.subType ?? 0,
|
||||
summary: img.pbRes?.summary ?? '[image]',
|
||||
md5Hex: bytesToHex(img.md5),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// VideoFile
|
||||
if (elem.videoFile) {
|
||||
const v = elem.videoFile;
|
||||
result.push({
|
||||
type: 'video',
|
||||
fileId: v.fileUuid ?? '',
|
||||
fileName: v.fileName ?? '',
|
||||
fileSize: v.fileSize ?? 0,
|
||||
duration: v.fileTime ?? 0,
|
||||
fileHash: v.fileMd5 && v.fileMd5.length > 0 ? bytesToHex(v.fileMd5) : '',
|
||||
mediaNode: {
|
||||
fileUuid: v.fileUuid ?? '',
|
||||
info: {
|
||||
fileSize: v.fileSize ?? 0,
|
||||
fileHash: v.fileMd5 && v.fileMd5.length > 0 ? bytesToHex(v.fileMd5) : '',
|
||||
fileName: v.fileName ?? '',
|
||||
width: v.fileWidth ?? 0,
|
||||
height: v.fileHeight ?? 0,
|
||||
time: v.fileTime ?? 0,
|
||||
type: {
|
||||
type: 2,
|
||||
videoFormat: v.fileFormat ?? 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// GroupFile
|
||||
if (elem.groupFile) {
|
||||
const f = elem.groupFile;
|
||||
result.push({
|
||||
type: 'file',
|
||||
fileId: f.fileId ?? '',
|
||||
fileName: f.filename ?? '',
|
||||
fileSize: f.fileSize !== undefined ? Number(f.fileSize) : 0,
|
||||
});
|
||||
}
|
||||
|
||||
// TransElem type=24 (group file via transport)
|
||||
if (elem.transElem) {
|
||||
const te = elem.transElem;
|
||||
if ((te.elemType ?? 0) === 24 && te.elemValue && te.elemValue.length > 3) {
|
||||
const val = te.elemValue;
|
||||
const len = (val[1] << 8) | val[2];
|
||||
if (val.length >= 3 + len) {
|
||||
const extra = protoDecode(val.subarray(3, 3 + len), GroupFileExtraSchema);
|
||||
if (extra?.inner?.info) {
|
||||
const info = extra.inner.info;
|
||||
result.push({
|
||||
type: 'file',
|
||||
fileName: info.fileName ?? '',
|
||||
fileSize: info.fileSize !== undefined ? Number(info.fileSize) : 0,
|
||||
fileId: info.fileId ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RichMsg
|
||||
if (elem.richMsg) {
|
||||
const rm = elem.richMsg;
|
||||
if (rm.template1 && rm.template1.length > 0) {
|
||||
const content = decompressData(rm.template1);
|
||||
if (content) {
|
||||
const svcId = rm.serviceId ?? 0;
|
||||
if (svcId === 35) {
|
||||
const pos = content.indexOf('m_resid="');
|
||||
if (pos !== -1) {
|
||||
const start = pos + 9;
|
||||
const end = content.indexOf('"', start);
|
||||
if (end !== -1) {
|
||||
result.push({ type: 'forward', resId: content.substring(start, end) });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result.push({ type: 'xml', text: content, subType: svcId });
|
||||
} else if (svcId === 1) {
|
||||
result.push({ type: 'json', text: content });
|
||||
} else {
|
||||
result.push({ type: 'xml', text: content, subType: svcId });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LightApp
|
||||
if (elem.lightApp) {
|
||||
const la = elem.lightApp;
|
||||
if (la.data && la.data.length > 0) {
|
||||
const content = decompressData(la.data);
|
||||
if (content) result.push({ type: 'json', text: content });
|
||||
}
|
||||
}
|
||||
|
||||
// CommonElem
|
||||
if (elem.commonElem) {
|
||||
const ce = elem.commonElem;
|
||||
const svcType = ce.serviceType ?? 0;
|
||||
const bizType = ce.businessType ?? 0;
|
||||
|
||||
if (svcType === 2) {
|
||||
// Poke
|
||||
result.push({ type: 'poke', subType: bizType });
|
||||
} else if (svcType === 3 && ce.pbElem && ce.pbElem.length > 1) {
|
||||
// Flash image
|
||||
const pb = ce.pbElem;
|
||||
let pos = 1;
|
||||
let length = 0, shift = 0;
|
||||
while (pos < pb.length) {
|
||||
const b = pb[pos++];
|
||||
length |= (b & 0x7f) << shift;
|
||||
shift += 7;
|
||||
if ((b & 0x80) === 0) break;
|
||||
}
|
||||
if (pos + length <= pb.length) {
|
||||
const img = protoDecode(pb.subarray(pos, pos + length), NotOnlineImageSchema);
|
||||
if (img) {
|
||||
const me: MessageElement = {
|
||||
type: 'image', fileId: img.filePath ?? '',
|
||||
fileSize: img.fileLen ?? 0, width: img.picWidth ?? 0,
|
||||
height: img.picHeight ?? 0, flash: true, summary: '[flash image]',
|
||||
};
|
||||
if (img.pbRes) me.subType = img.pbRes.subType ?? 0;
|
||||
if (img.picMd5 && img.picMd5.length > 0) {
|
||||
me.imageUrl = 'http://gchat.qpic.cn/gchatpic_new/0/0-0-' + bytesToHex(img.picMd5) + '/0';
|
||||
}
|
||||
result.push(me);
|
||||
}
|
||||
}
|
||||
skipNext = true;
|
||||
} else if (ce.pbElem && (svcType === 48 || bizType === 10 || bizType === 20 || bizType === 11 || bizType === 21 || bizType === 12 || bizType === 22)) {
|
||||
// NTQQ new protocol image/record/video
|
||||
const info = protoDecode(ce.pbElem, MsgInfoSchema);
|
||||
if (info?.msgInfoBody && info.msgInfoBody.length > 0) {
|
||||
const body = info.msgInfoBody[0];
|
||||
if (body.index?.info) {
|
||||
const idx = body.index;
|
||||
const fi = idx.info!;
|
||||
|
||||
if (bizType === 10 || bizType === 20) {
|
||||
// Image
|
||||
let url = '';
|
||||
if (body.picture) {
|
||||
const domain = body.picture.domain ?? 'multimedia.nt.qq.com.cn';
|
||||
const path = body.picture.urlPath ?? '';
|
||||
if (path) {
|
||||
url = 'https://' + domain + path;
|
||||
if (body.picture.ext?.originalParameter) {
|
||||
url += body.picture.ext.originalParameter;
|
||||
}
|
||||
}
|
||||
}
|
||||
const me: MessageElement = {
|
||||
type: 'image', fileId: fi.fileName ?? '',
|
||||
fileSize: fi.fileSize ?? 0, width: fi.width ?? 0,
|
||||
height: fi.height ?? 0, imageUrl: url,
|
||||
};
|
||||
if (fi.fileHash) me.md5Hex = fi.fileHash;
|
||||
if (fi.fileSha1) me.sha1Hex = fi.fileSha1;
|
||||
if (fi.type?.picFormat) me.picFormat = fi.type.picFormat;
|
||||
if (info.extBizInfo?.pic) {
|
||||
me.subType = info.extBizInfo.pic.bizType ?? 0;
|
||||
me.summary = info.extBizInfo.pic.textSummary || '[image]';
|
||||
}
|
||||
result.push(me);
|
||||
} else if (bizType === 12 || bizType === 22) {
|
||||
// Record
|
||||
result.push({
|
||||
type: 'record', fileName: fi.fileName ?? '',
|
||||
fileId: idx.fileUuid ?? '', duration: fi.time ?? 0,
|
||||
fileHash: fi.fileHash ?? '',
|
||||
fileSize: fi.fileSize ?? 0,
|
||||
md5Hex: fi.fileHash ?? '',
|
||||
sha1Hex: fi.fileSha1 ?? '',
|
||||
voiceFormat: fi.type?.voiceFormat ?? 0,
|
||||
mediaNode: {
|
||||
fileUuid: idx.fileUuid,
|
||||
storeId: idx.storeId,
|
||||
uploadTime: idx.uploadTime,
|
||||
ttl: idx.ttl,
|
||||
subType: idx.subType,
|
||||
info: {
|
||||
fileSize: fi.fileSize,
|
||||
fileHash: fi.fileHash,
|
||||
fileSha1: fi.fileSha1,
|
||||
fileName: fi.fileName,
|
||||
width: fi.width,
|
||||
height: fi.height,
|
||||
time: fi.time,
|
||||
original: fi.original,
|
||||
type: {
|
||||
type: fi.type?.type,
|
||||
picFormat: fi.type?.picFormat,
|
||||
videoFormat: fi.type?.videoFormat,
|
||||
voiceFormat: fi.type?.voiceFormat,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} else if (bizType === 11 || bizType === 21) {
|
||||
// Video
|
||||
result.push({
|
||||
type: 'video', fileName: fi.fileName ?? '',
|
||||
fileId: idx.fileUuid ?? '', fileSize: fi.fileSize ?? 0,
|
||||
duration: fi.time ?? 0,
|
||||
fileHash: fi.fileHash ?? '',
|
||||
width: fi.width ?? 0,
|
||||
height: fi.height ?? 0,
|
||||
md5Hex: fi.fileHash ?? '',
|
||||
sha1Hex: fi.fileSha1 ?? '',
|
||||
videoFormat: fi.type?.videoFormat ?? 0,
|
||||
mediaNode: {
|
||||
fileUuid: idx.fileUuid,
|
||||
storeId: idx.storeId,
|
||||
uploadTime: idx.uploadTime,
|
||||
ttl: idx.ttl,
|
||||
subType: idx.subType,
|
||||
info: {
|
||||
fileSize: fi.fileSize,
|
||||
fileHash: fi.fileHash,
|
||||
fileSha1: fi.fileSha1,
|
||||
fileName: fi.fileName,
|
||||
width: fi.width,
|
||||
height: fi.height,
|
||||
time: fi.time,
|
||||
original: fi.original,
|
||||
type: {
|
||||
type: fi.type?.type,
|
||||
picFormat: fi.type?.picFormat,
|
||||
videoFormat: fi.type?.videoFormat,
|
||||
voiceFormat: fi.type?.voiceFormat,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (svcType === 33 && ce.pbElem) {
|
||||
// Small face
|
||||
const extra = protoDecode(ce.pbElem, QSmallFaceExtraSchema);
|
||||
if (extra) result.push({ type: 'face', faceId: extra.faceId ?? 0 });
|
||||
} else if (svcType === 37 && ce.pbElem) {
|
||||
// Big face
|
||||
const extra = protoDecode(ce.pbElem, QFaceExtraSchema);
|
||||
if (extra?.qsid !== undefined) result.push({ type: 'face', faceId: extra.qsid });
|
||||
skipNext = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractRichtextExtras(
|
||||
rt: ProtoDecoded<typeof import('./proto/message').RichTextSchema>,
|
||||
elements: MessageElement[],
|
||||
isGroup = false
|
||||
): void {
|
||||
// Ptt (voice)
|
||||
if (rt.ptt) {
|
||||
const p = rt.ptt;
|
||||
const md5Hex = p.fileMd5 && p.fileMd5.length > 0 ? bytesToHex(p.fileMd5) : '';
|
||||
const me: MessageElement = {
|
||||
type: 'record', fileName: p.fileName ?? '',
|
||||
fileSize: p.fileSize ?? 0, duration: p.time ?? 0,
|
||||
fileHash: md5Hex,
|
||||
md5Hex,
|
||||
voiceFormat: p.format ?? 0,
|
||||
};
|
||||
if (isGroup && (p.fileId ?? 0n) !== 0n) {
|
||||
me.fileId = p.groupFileKey ?? '';
|
||||
} else {
|
||||
if (p.fileUuid && p.fileUuid.length > 0) {
|
||||
me.fileId = Buffer.from(p.fileUuid).toString('utf8');
|
||||
}
|
||||
}
|
||||
me.mediaNode = {
|
||||
fileUuid: me.fileId ?? '',
|
||||
info: {
|
||||
fileSize: p.fileSize ?? 0,
|
||||
fileHash: p.fileMd5 && p.fileMd5.length > 0 ? bytesToHex(p.fileMd5) : '',
|
||||
fileName: p.fileName ?? '',
|
||||
time: p.time ?? 0,
|
||||
type: {
|
||||
type: 3,
|
||||
voiceFormat: p.format ?? 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
elements.push(me);
|
||||
}
|
||||
|
||||
// NotOnlineFile (C2C file)
|
||||
if (rt.notOnlineFile) {
|
||||
const f = rt.notOnlineFile;
|
||||
elements.push({
|
||||
type: 'file', fileId: f.fileUuid ?? '',
|
||||
fileName: f.fileName ?? '',
|
||||
fileSize: f.fileSize !== undefined ? Number(f.fileSize) : 0,
|
||||
fileHash: f.fileHash ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function extractMsgContent(msgContent: Uint8Array, elements: MessageElement[]): void {
|
||||
const extra = protoDecode(msgContent, FileExtraSchema);
|
||||
if (!extra?.file) return;
|
||||
const f = extra.file;
|
||||
if (f.fileSize !== undefined && f.fileName && f.fileMd5 && f.fileUuid && f.fileHash) {
|
||||
elements.push({
|
||||
type: 'file', fileName: f.fileName, fileId: f.fileUuid,
|
||||
fileSize: f.fileSize !== undefined ? Number(f.fileSize) : 0,
|
||||
fileHash: f.fileHash,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main handler ---
|
||||
|
||||
export const MSG_PUSH_CMD = 'trpc.msg.olpush.OlPushService.MsgPush';
|
||||
|
||||
export function parseMsgPush(pkt: PacketInfo, identity: IdentityService): QQEventVariant[] {
|
||||
if (pkt.body.length === 0) return [];
|
||||
|
||||
const push = protoDecode(Buffer.from(pkt.body), PushMsgSchema);
|
||||
if (!push?.message) return [];
|
||||
|
||||
const msg = push.message;
|
||||
if (!msg.contentHead) return [];
|
||||
|
||||
const head = msg.contentHead;
|
||||
const msgType = head.msgType ?? 0;
|
||||
const subType = head.subType ?? 0;
|
||||
const sequence = head.sequence ?? 0;
|
||||
const timestamp = head.timestamp ?? 0;
|
||||
const msgId = head.msgId ?? 0;
|
||||
|
||||
let fromUin = 0;
|
||||
let fromUid = '';
|
||||
if (msg.responseHead) {
|
||||
fromUin = msg.responseHead.fromUin ?? 0;
|
||||
fromUid = msg.responseHead.fromUid ?? '';
|
||||
}
|
||||
|
||||
let selfUin = 0;
|
||||
if (pkt.uin) {
|
||||
const n = parseInt(pkt.uin, 10);
|
||||
if (!isNaN(n)) selfUin = n;
|
||||
}
|
||||
|
||||
const content = (msg.body?.msgContent) ? msg.body.msgContent : new Uint8Array(0);
|
||||
|
||||
const buildElements = (isGroup: boolean): MessageElement[] => {
|
||||
const elements: MessageElement[] = [];
|
||||
if (msg.body?.richText) {
|
||||
const rt = msg.body.richText;
|
||||
if (rt.elems) elements.push(...convertElements(rt.elems as ElemDecoded[]));
|
||||
extractRichtextExtras(rt, elements, isGroup);
|
||||
}
|
||||
if (msg.body?.msgContent && msg.body.msgContent.length > 0) {
|
||||
extractMsgContent(msg.body.msgContent, elements);
|
||||
}
|
||||
return elements;
|
||||
};
|
||||
|
||||
// --- Switch on msgType ---
|
||||
|
||||
switch (msgType as PkgType) {
|
||||
case PkgType.GroupMemberIncreaseNotice: {
|
||||
const change = protoDecode(content, GroupChangeSchema);
|
||||
if (!change) return [];
|
||||
const groupId = change.groupUin ?? 0;
|
||||
const userUid = change.memberUid ?? '';
|
||||
const operatorUid = decodeOperatorUid(change.operatorBytes ?? new Uint8Array(0));
|
||||
const ev: GroupMemberJoin = {
|
||||
kind: 'group_member_join', time: timestamp, selfUin,
|
||||
groupId,
|
||||
userUin: resolveUidToUin(identity, groupId, userUid, 0),
|
||||
operatorUin: resolveUidToUin(identity, groupId, operatorUid, 0),
|
||||
userUid,
|
||||
operatorUid,
|
||||
};
|
||||
return [ev];
|
||||
}
|
||||
|
||||
case PkgType.GroupMemberDecreaseNotice: {
|
||||
const change = protoDecode(content, GroupChangeSchema);
|
||||
if (!change) return [];
|
||||
const dt = change.decreaseType ?? 0;
|
||||
const groupId = change.groupUin ?? 0;
|
||||
const userUid = change.memberUid ?? '';
|
||||
const operatorUid = decodeOperatorUid(change.operatorBytes ?? new Uint8Array(0));
|
||||
const ev: GroupMemberLeave = {
|
||||
kind: 'group_member_leave', time: timestamp, selfUin,
|
||||
groupId,
|
||||
userUin: resolveUidToUin(identity, groupId, userUid, 0),
|
||||
operatorUin: resolveUidToUin(identity, groupId, operatorUid, 0),
|
||||
userUid,
|
||||
operatorUid,
|
||||
isKick: dt !== 0 && dt !== 130,
|
||||
};
|
||||
return [ev];
|
||||
}
|
||||
|
||||
case PkgType.GroupAdminChangedNotice: {
|
||||
const admin = protoDecode(content, GroupAdminSchema);
|
||||
if (!admin?.body) return [];
|
||||
const extra = admin.body.extraEnable ?? admin.body.extraDisable;
|
||||
if (!extra) return [];
|
||||
const ev: GroupAdminEvent = {
|
||||
kind: 'group_admin', time: timestamp, selfUin,
|
||||
groupId: admin.groupUin ?? 0,
|
||||
userUin: resolveUidToUin(identity, admin.groupUin ?? 0, extra.adminUid ?? '', fromUin),
|
||||
set: admin.body.extraEnable !== undefined,
|
||||
};
|
||||
return [ev];
|
||||
}
|
||||
|
||||
case PkgType.GroupRequestJoinNotice: {
|
||||
const join = protoDecode(content, GroupJoinSchema);
|
||||
if (!join) return [];
|
||||
const ev: GroupInviteEvent = {
|
||||
kind: 'group_invite', time: timestamp, selfUin,
|
||||
groupId: join.groupUin ?? 0,
|
||||
fromUin: resolveUidToUin(identity, join.groupUin ?? 0, join.targetUid ?? '', fromUin),
|
||||
fromUid: join.targetUid ?? '',
|
||||
subType: 'add', message: '',
|
||||
flag: 'add:' + (join.groupUin ?? 0) + ':' + (join.targetUid ?? ''),
|
||||
};
|
||||
return [ev];
|
||||
}
|
||||
|
||||
case PkgType.GroupRequestInvitationNotice: {
|
||||
const invitation = protoDecode(content, GroupInvitationSchema);
|
||||
if (!invitation?.info?.inner) return [];
|
||||
const inner = invitation.info.inner;
|
||||
const ev: GroupInviteEvent = {
|
||||
kind: 'group_invite', time: timestamp, selfUin,
|
||||
groupId: inner.groupUin ?? 0,
|
||||
fromUin: resolveUidToUin(identity, inner.groupUin ?? 0, inner.invitorUid ?? '', fromUin),
|
||||
fromUid: inner.invitorUid ?? '',
|
||||
subType: 'invite', message: '',
|
||||
flag: 'invite:' + (inner.groupUin ?? 0) + ':' + (inner.invitorUid ?? ''),
|
||||
};
|
||||
return [ev];
|
||||
}
|
||||
|
||||
case PkgType.GroupInviteNotice: {
|
||||
const invite = protoDecode(content, GroupInviteSchema);
|
||||
if (!invite) return [];
|
||||
const ev: GroupInviteEvent = {
|
||||
kind: 'group_invite', time: timestamp, selfUin,
|
||||
groupId: invite.groupUin ?? 0,
|
||||
fromUin: resolveUidToUin(identity, invite.groupUin ?? 0, invite.invitorUid ?? '', fromUin),
|
||||
fromUid: invite.invitorUid ?? '',
|
||||
subType: 'invite', message: '',
|
||||
flag: 'invite:' + (invite.groupUin ?? 0) + ':' + (invite.invitorUid ?? ''),
|
||||
};
|
||||
return [ev];
|
||||
}
|
||||
|
||||
case PkgType.Event0x210: {
|
||||
switch (subType as Event0x210SubType) {
|
||||
case Event0x210SubType.FriendRequestNotice: {
|
||||
const request = protoDecode(content, FriendRequestSchema);
|
||||
if (!request?.info) return [];
|
||||
const sourceUid = request.info.newSource || request.info.sourceUid || '';
|
||||
const ev: FriendRequestEvent = {
|
||||
kind: 'friend_request', time: timestamp, selfUin,
|
||||
fromUin: resolveUidToUin(identity, 0, sourceUid, fromUin),
|
||||
fromUid: sourceUid,
|
||||
message: request.info.message ?? '', flag: sourceUid,
|
||||
};
|
||||
return [ev];
|
||||
}
|
||||
case Event0x210SubType.FriendRecallNotice: {
|
||||
const recall = protoDecode(content, FriendRecallSchema);
|
||||
if (!recall?.info) return [];
|
||||
const ev: FriendRecall = {
|
||||
kind: 'friend_recall', time: recall.info.time ?? timestamp, selfUin,
|
||||
userUin: resolveUidToUin(identity, 0, recall.info.fromUid ?? '', fromUin),
|
||||
msgSeq: recall.info.clientSequence ?? 0,
|
||||
};
|
||||
return [ev];
|
||||
}
|
||||
case Event0x210SubType.FriendPokeNotice: {
|
||||
const grayTip = protoDecode(content, GeneralGrayTipInfoSchema);
|
||||
if (!grayTip || (grayTip.busiType ?? 0n) !== 12n) return [];
|
||||
const templates = buildTemplateMap(grayTip.msgTemplParam ?? []);
|
||||
const actor = findTemplateValue(templates, 'uin_str1');
|
||||
const target = findTemplateValue(templates, 'uin_str2');
|
||||
const ev: FriendPokeEvent = {
|
||||
kind: 'friend_poke', time: timestamp, selfUin,
|
||||
userUin: resolveUidToUin(identity, 0, actor, parseU64OrZero(actor)),
|
||||
targetUin: resolveUidToUin(identity, 0, target, parseU64OrZero(target)),
|
||||
action: findTemplateValue(templates, 'action_str', 'alt_str1'),
|
||||
suffix: findTemplateValue(templates, 'suffix_str'),
|
||||
actionImgUrl: findTemplateValue(templates, 'action_img_url'),
|
||||
};
|
||||
return [ev];
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case PkgType.Event0x2DC: {
|
||||
switch (subType as Event0x2DCSubType) {
|
||||
case Event0x2DCSubType.GroupMuteNotice: {
|
||||
const mute = protoDecode(content, GroupMuteSchema);
|
||||
if (!mute?.data?.state) return [];
|
||||
const duration = mute.data.state.duration ?? 0;
|
||||
const ev: GroupMuteEvent = {
|
||||
kind: 'group_mute', time: mute.data.timestamp ?? timestamp, selfUin,
|
||||
groupId: mute.groupUin ?? 0,
|
||||
operatorUin: resolveUidToUin(identity, mute.groupUin ?? 0, mute.operatorUid ?? '', fromUin),
|
||||
userUin: resolveUidToUin(identity, mute.groupUin ?? 0, mute.data.state.targetUid ?? '', 0),
|
||||
duration: duration === 0xFFFFFFFF ? 0x7FFFFFFF : duration,
|
||||
};
|
||||
return [ev];
|
||||
}
|
||||
case Event0x2DCSubType.GroupRecallNotice: {
|
||||
const payload = unwrapGroupNotifyPayload(content);
|
||||
if (!payload) return [];
|
||||
const notify = protoDecode(payload, NotifyMessageBodySchema);
|
||||
if (!notify?.recall?.recallMessages || notify.recall.recallMessages.length === 0) return [];
|
||||
const recalled = notify.recall.recallMessages[0];
|
||||
const ev: GroupRecallEvent = {
|
||||
kind: 'group_recall', time: recalled.time ?? timestamp, selfUin,
|
||||
groupId: notify.groupUin ?? 0,
|
||||
operatorUin: resolveUidToUin(identity, notify.groupUin ?? 0,
|
||||
notify.recall.operatorUid || notify.operatorUid || '', fromUin),
|
||||
authorUin: resolveUidToUin(identity, notify.groupUin ?? 0, recalled.authorUid ?? '', fromUin),
|
||||
msgSeq: recalled.sequence ?? 0,
|
||||
};
|
||||
return [ev];
|
||||
}
|
||||
case Event0x2DCSubType.GroupGreyTipNotice: {
|
||||
const payload = unwrapGroupNotifyPayload(content);
|
||||
if (!payload) return [];
|
||||
const notify = protoDecode(payload, NotifyMessageBodySchema);
|
||||
if (!notify?.generalGrayTip || (notify.generalGrayTip.busiType ?? 0n) !== 12n) return [];
|
||||
const templates = buildTemplateMap(notify.generalGrayTip.msgTemplParam ?? []);
|
||||
const actor = findTemplateValue(templates, 'uin_str1');
|
||||
const target = findTemplateValue(templates, 'uin_str2');
|
||||
const ev: GroupPokeEvent = {
|
||||
kind: 'group_poke', time: timestamp, selfUin,
|
||||
groupId: notify.groupUin ?? 0,
|
||||
userUin: resolveUidToUin(identity, notify.groupUin ?? 0, actor, parseU64OrZero(actor)),
|
||||
targetUin: resolveUidToUin(identity, notify.groupUin ?? 0, target, parseU64OrZero(target)),
|
||||
action: findTemplateValue(templates, 'action_str', 'alt_str1'),
|
||||
suffix: findTemplateValue(templates, 'suffix_str'),
|
||||
actionImgUrl: findTemplateValue(templates, 'action_img_url'),
|
||||
};
|
||||
return [ev];
|
||||
}
|
||||
case Event0x2DCSubType.GroupEssenceNotice: {
|
||||
const payload = unwrapGroupNotifyPayload(content);
|
||||
if (!payload) return [];
|
||||
const notify = protoDecode(payload, NotifyMessageBodySchema);
|
||||
if (!notify?.essenceMessage) return [];
|
||||
const essence = notify.essenceMessage;
|
||||
const setFlag = essence.setFlag ?? essence.setFlag2 ?? 0;
|
||||
const ev: GroupEssenceEvent = {
|
||||
kind: 'group_essence', time: essence.timestamp ?? timestamp, selfUin,
|
||||
groupId: essence.groupUin ?? notify.groupUin ?? 0,
|
||||
senderUin: essence.memberUin ?? 0,
|
||||
operatorUin: essence.operatorUin ?? fromUin,
|
||||
msgSeq: essence.msgSequence ?? essence.msgSequence2 ?? notify.msgSequence ?? 0,
|
||||
random: essence.random ?? 0,
|
||||
set: setFlag === 1,
|
||||
};
|
||||
return [ev];
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Message types ---
|
||||
|
||||
if (msgType === PkgType.GroupMessage) {
|
||||
const ev: GroupMessage = {
|
||||
kind: 'group_message', time: timestamp, selfUin,
|
||||
senderUin: fromUin, msgSeq: sequence,
|
||||
msgId: msgId & 0x7FFFFFFF,
|
||||
elements: buildElements(true),
|
||||
groupId: 0, senderNick: '', senderCard: '', senderRole: '',
|
||||
};
|
||||
if (msg.responseHead?.grp) {
|
||||
ev.groupId = msg.responseHead.grp.groupUin ?? 0;
|
||||
ev.senderNick = msg.responseHead.grp.memberName ?? '';
|
||||
}
|
||||
const member = identity.findGroupMember(ev.groupId, fromUin);
|
||||
if (member) {
|
||||
if (!ev.senderNick) ev.senderNick = member.nickname;
|
||||
ev.senderCard = member.card;
|
||||
ev.senderRole = member.role;
|
||||
}
|
||||
return [ev];
|
||||
}
|
||||
|
||||
if (msgType === PkgType.TempMessage) {
|
||||
const ev: TempMessage = {
|
||||
kind: 'temp_message', time: timestamp, selfUin,
|
||||
senderUin: fromUin, msgSeq: sequence,
|
||||
elements: buildElements(false),
|
||||
groupId: 0, senderNick: '',
|
||||
};
|
||||
if (msg.responseHead?.grp) {
|
||||
ev.groupId = msg.responseHead.grp.groupUin ?? 0;
|
||||
ev.senderNick = msg.responseHead.grp.memberName ?? '';
|
||||
}
|
||||
if (!ev.senderNick) {
|
||||
const friend = identity.findFriend(fromUin);
|
||||
if (friend) ev.senderNick = friend.nickname;
|
||||
}
|
||||
return [ev];
|
||||
}
|
||||
|
||||
if (msgType === PkgType.PrivateMessage ||
|
||||
msgType === PkgType.ForwardFakePrivateMessage ||
|
||||
msgType === PkgType.PrivateRecordMessage ||
|
||||
msgType === PkgType.PrivateFileMessage) {
|
||||
const ev: FriendMessage = {
|
||||
kind: 'friend_message', time: timestamp, selfUin,
|
||||
senderUin: fromUin, msgSeq: sequence,
|
||||
msgId: msgId & 0x7FFFFFFF,
|
||||
elements: buildElements(false),
|
||||
senderNick: '',
|
||||
};
|
||||
if (msg.responseHead?.forward?.friendName) {
|
||||
ev.senderNick = msg.responseHead.forward.friendName;
|
||||
}
|
||||
const friend = identity.findFriend(fromUin);
|
||||
if (friend && !ev.senderNick) ev.senderNick = friend.nickname;
|
||||
return [ev];
|
||||
}
|
||||
|
||||
// const preview = bytesToHex(content.subarray(0, Math.min(content.length, 48)));
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// MsgPushContext — the decoded envelope fields every decoder may need.
|
||||
// One unified shape: notice decoders pick from `content`, message decoders
|
||||
// pick from `body` / `responseHead`. Decoders ignore fields they don't need.
|
||||
|
||||
import { protoDecode } from '../../protobuf/decode';
|
||||
import type { ProtoDecoded } from '../../protobuf/decode';
|
||||
import {
|
||||
PushMsgSchema, MessageBodySchema, ResponseHeadSchema, ContentHeadSchema,
|
||||
} from '../proto/message';
|
||||
import type { PacketInfo } from '../../protocol/types';
|
||||
import type { IdentityService } from '../identity-service';
|
||||
|
||||
export type PushMsgBody = ProtoDecoded<typeof MessageBodySchema>;
|
||||
export type PushMsgResponseHead = ProtoDecoded<typeof ResponseHeadSchema>;
|
||||
export type PushMsgContentHead = ProtoDecoded<typeof ContentHeadSchema>;
|
||||
|
||||
export interface MsgPushHead {
|
||||
readonly msgType: number;
|
||||
readonly subType: number;
|
||||
readonly sequence: number;
|
||||
readonly timestamp: number;
|
||||
readonly msgId: number;
|
||||
}
|
||||
|
||||
export interface MsgPushContext {
|
||||
readonly head: MsgPushHead;
|
||||
readonly fromUin: number;
|
||||
readonly fromUid: string;
|
||||
readonly selfUin: number;
|
||||
readonly content: Uint8Array;
|
||||
readonly body: PushMsgBody | undefined;
|
||||
readonly responseHead: PushMsgResponseHead | undefined;
|
||||
readonly identity: IdentityService;
|
||||
}
|
||||
|
||||
export function buildContext(pkt: PacketInfo, identity: IdentityService): MsgPushContext | null {
|
||||
if (pkt.body.length === 0) return null;
|
||||
|
||||
const push = protoDecode(Buffer.from(pkt.body), PushMsgSchema);
|
||||
if (!push?.message) return null;
|
||||
|
||||
const msg = push.message;
|
||||
if (!msg.contentHead) return null;
|
||||
|
||||
const head: MsgPushHead = {
|
||||
msgType: msg.contentHead.msgType ?? 0,
|
||||
subType: msg.contentHead.subType ?? 0,
|
||||
sequence: msg.contentHead.sequence ?? 0,
|
||||
timestamp: msg.contentHead.timestamp ?? 0,
|
||||
msgId: msg.contentHead.msgId ?? 0,
|
||||
};
|
||||
|
||||
let fromUin = 0;
|
||||
let fromUid = '';
|
||||
if (msg.responseHead) {
|
||||
fromUin = msg.responseHead.fromUin ?? 0;
|
||||
fromUid = msg.responseHead.fromUid ?? '';
|
||||
}
|
||||
|
||||
let selfUin = 0;
|
||||
if (pkt.uin) {
|
||||
const n = parseInt(pkt.uin, 10);
|
||||
if (!isNaN(n)) selfUin = n;
|
||||
}
|
||||
|
||||
const content = msg.body?.msgContent ?? new Uint8Array(0);
|
||||
|
||||
return {
|
||||
head,
|
||||
fromUin,
|
||||
fromUid,
|
||||
selfUin,
|
||||
content,
|
||||
body: msg.body,
|
||||
responseHead: msg.responseHead,
|
||||
identity,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Handles Event0x210 wrapper (528). Internal switch on subType dispatches
|
||||
// to FriendRequest (35) / FriendRecall (138) / FriendPoke (290).
|
||||
|
||||
import { protoDecode } from '../../../protobuf/decode';
|
||||
import {
|
||||
FriendRequestSchema, FriendRecallSchema, GeneralGrayTipInfoSchema,
|
||||
} from '../../proto/notify';
|
||||
import type {
|
||||
FriendRequestEvent, FriendRecall, FriendPokeEvent, QQEventVariant,
|
||||
} from '../../events';
|
||||
import type { MsgPushDecoder } from '../registry';
|
||||
import type { MsgPushContext } from '../context';
|
||||
import { Event0x210SubType } from '../enums';
|
||||
import {
|
||||
resolveUidToUin, parseU64OrZero, buildTemplateMap, findTemplateValue,
|
||||
} from '../helpers';
|
||||
|
||||
export const decodeEvent0x210: MsgPushDecoder = (ctx) => {
|
||||
switch (ctx.head.subType as Event0x210SubType) {
|
||||
case Event0x210SubType.FriendRequestNotice: return decodeFriendRequest(ctx);
|
||||
case Event0x210SubType.FriendRecallNotice: return decodeFriendRecall(ctx);
|
||||
case Event0x210SubType.FriendPokeNotice: return decodeFriendPoke(ctx);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
function decodeFriendRequest(ctx: MsgPushContext): QQEventVariant[] {
|
||||
const request = protoDecode(ctx.content, FriendRequestSchema);
|
||||
if (!request?.info) return [];
|
||||
const sourceUid = request.info.newSource || request.info.sourceUid || '';
|
||||
const ev: FriendRequestEvent = {
|
||||
kind: 'friend_request',
|
||||
time: ctx.head.timestamp,
|
||||
selfUin: ctx.selfUin,
|
||||
fromUin: resolveUidToUin(ctx.identity, 0, sourceUid, ctx.fromUin),
|
||||
fromUid: sourceUid,
|
||||
message: request.info.message ?? '',
|
||||
flag: sourceUid,
|
||||
};
|
||||
return [ev];
|
||||
}
|
||||
|
||||
function decodeFriendRecall(ctx: MsgPushContext): QQEventVariant[] {
|
||||
const recall = protoDecode(ctx.content, FriendRecallSchema);
|
||||
if (!recall?.info) return [];
|
||||
const ev: FriendRecall = {
|
||||
kind: 'friend_recall',
|
||||
time: recall.info.time ?? ctx.head.timestamp,
|
||||
selfUin: ctx.selfUin,
|
||||
userUin: resolveUidToUin(ctx.identity, 0, recall.info.fromUid ?? '', ctx.fromUin),
|
||||
msgSeq: recall.info.clientSequence ?? 0,
|
||||
};
|
||||
return [ev];
|
||||
}
|
||||
|
||||
function decodeFriendPoke(ctx: MsgPushContext): QQEventVariant[] {
|
||||
const grayTip = protoDecode(ctx.content, GeneralGrayTipInfoSchema);
|
||||
if (!grayTip || (grayTip.busiType ?? 0n) !== 12n) return [];
|
||||
const templates = buildTemplateMap(grayTip.msgTemplParam ?? []);
|
||||
const actor = findTemplateValue(templates, 'uin_str1');
|
||||
const target = findTemplateValue(templates, 'uin_str2');
|
||||
const ev: FriendPokeEvent = {
|
||||
kind: 'friend_poke',
|
||||
time: ctx.head.timestamp,
|
||||
selfUin: ctx.selfUin,
|
||||
userUin: resolveUidToUin(ctx.identity, 0, actor, parseU64OrZero(actor)),
|
||||
targetUin: resolveUidToUin(ctx.identity, 0, target, parseU64OrZero(target)),
|
||||
action: findTemplateValue(templates, 'action_str', 'alt_str1'),
|
||||
suffix: findTemplateValue(templates, 'suffix_str'),
|
||||
actionImgUrl: findTemplateValue(templates, 'action_img_url'),
|
||||
};
|
||||
return [ev];
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Handles Event0x2DC wrapper (732). Internal switch on subType dispatches
|
||||
// to GroupMute (12) / GroupRecall (17) / GroupGreyTip (20) / GroupEssence (21).
|
||||
|
||||
import { protoDecode } from '../../../protobuf/decode';
|
||||
import {
|
||||
GroupMuteSchema, NotifyMessageBodySchema,
|
||||
} from '../../proto/notify';
|
||||
import type {
|
||||
GroupMuteEvent, GroupRecallEvent, GroupPokeEvent, GroupEssenceEvent,
|
||||
QQEventVariant,
|
||||
} from '../../events';
|
||||
import type { MsgPushDecoder } from '../registry';
|
||||
import type { MsgPushContext } from '../context';
|
||||
import { Event0x2DCSubType } from '../enums';
|
||||
import {
|
||||
resolveUidToUin, parseU64OrZero, buildTemplateMap, findTemplateValue,
|
||||
unwrapGroupNotifyPayload,
|
||||
} from '../helpers';
|
||||
|
||||
export const decodeEvent0x2DC: MsgPushDecoder = (ctx) => {
|
||||
switch (ctx.head.subType as Event0x2DCSubType) {
|
||||
case Event0x2DCSubType.GroupMuteNotice: return decodeGroupMute(ctx);
|
||||
case Event0x2DCSubType.GroupRecallNotice: return decodeGroupRecall(ctx);
|
||||
case Event0x2DCSubType.GroupGreyTipNotice: return decodeGroupGreyTip(ctx);
|
||||
case Event0x2DCSubType.GroupEssenceNotice: return decodeGroupEssence(ctx);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
function decodeGroupMute(ctx: MsgPushContext): QQEventVariant[] {
|
||||
const mute = protoDecode(ctx.content, GroupMuteSchema);
|
||||
if (!mute?.data?.state) return [];
|
||||
const duration = mute.data.state.duration ?? 0;
|
||||
const ev: GroupMuteEvent = {
|
||||
kind: 'group_mute',
|
||||
time: mute.data.timestamp ?? ctx.head.timestamp,
|
||||
selfUin: ctx.selfUin,
|
||||
groupId: mute.groupUin ?? 0,
|
||||
operatorUin: resolveUidToUin(ctx.identity, mute.groupUin ?? 0, mute.operatorUid ?? '', ctx.fromUin),
|
||||
userUin: resolveUidToUin(ctx.identity, mute.groupUin ?? 0, mute.data.state.targetUid ?? '', 0),
|
||||
duration: duration === 0xFFFFFFFF ? 0x7FFFFFFF : duration,
|
||||
};
|
||||
return [ev];
|
||||
}
|
||||
|
||||
function decodeGroupRecall(ctx: MsgPushContext): QQEventVariant[] {
|
||||
const payload = unwrapGroupNotifyPayload(ctx.content);
|
||||
if (!payload) return [];
|
||||
const notify = protoDecode(payload, NotifyMessageBodySchema);
|
||||
if (!notify?.recall?.recallMessages || notify.recall.recallMessages.length === 0) return [];
|
||||
const recalled = notify.recall.recallMessages[0];
|
||||
const ev: GroupRecallEvent = {
|
||||
kind: 'group_recall',
|
||||
time: recalled.time ?? ctx.head.timestamp,
|
||||
selfUin: ctx.selfUin,
|
||||
groupId: notify.groupUin ?? 0,
|
||||
operatorUin: resolveUidToUin(ctx.identity, notify.groupUin ?? 0,
|
||||
notify.recall.operatorUid || notify.operatorUid || '', ctx.fromUin),
|
||||
authorUin: resolveUidToUin(ctx.identity, notify.groupUin ?? 0, recalled.authorUid ?? '', ctx.fromUin),
|
||||
msgSeq: recalled.sequence ?? 0,
|
||||
};
|
||||
return [ev];
|
||||
}
|
||||
|
||||
function decodeGroupGreyTip(ctx: MsgPushContext): QQEventVariant[] {
|
||||
const payload = unwrapGroupNotifyPayload(ctx.content);
|
||||
if (!payload) return [];
|
||||
const notify = protoDecode(payload, NotifyMessageBodySchema);
|
||||
if (!notify?.generalGrayTip || (notify.generalGrayTip.busiType ?? 0n) !== 12n) return [];
|
||||
const templates = buildTemplateMap(notify.generalGrayTip.msgTemplParam ?? []);
|
||||
const actor = findTemplateValue(templates, 'uin_str1');
|
||||
const target = findTemplateValue(templates, 'uin_str2');
|
||||
const ev: GroupPokeEvent = {
|
||||
kind: 'group_poke',
|
||||
time: ctx.head.timestamp,
|
||||
selfUin: ctx.selfUin,
|
||||
groupId: notify.groupUin ?? 0,
|
||||
userUin: resolveUidToUin(ctx.identity, notify.groupUin ?? 0, actor, parseU64OrZero(actor)),
|
||||
targetUin: resolveUidToUin(ctx.identity, notify.groupUin ?? 0, target, parseU64OrZero(target)),
|
||||
action: findTemplateValue(templates, 'action_str', 'alt_str1'),
|
||||
suffix: findTemplateValue(templates, 'suffix_str'),
|
||||
actionImgUrl: findTemplateValue(templates, 'action_img_url'),
|
||||
};
|
||||
return [ev];
|
||||
}
|
||||
|
||||
function decodeGroupEssence(ctx: MsgPushContext): QQEventVariant[] {
|
||||
const payload = unwrapGroupNotifyPayload(ctx.content);
|
||||
if (!payload) return [];
|
||||
const notify = protoDecode(payload, NotifyMessageBodySchema);
|
||||
if (!notify?.essenceMessage) return [];
|
||||
const essence = notify.essenceMessage;
|
||||
const setFlag = essence.setFlag ?? essence.setFlag2 ?? 0;
|
||||
const ev: GroupEssenceEvent = {
|
||||
kind: 'group_essence',
|
||||
time: essence.timestamp ?? ctx.head.timestamp,
|
||||
selfUin: ctx.selfUin,
|
||||
groupId: essence.groupUin ?? notify.groupUin ?? 0,
|
||||
senderUin: essence.memberUin ?? 0,
|
||||
operatorUin: essence.operatorUin ?? ctx.fromUin,
|
||||
msgSeq: essence.msgSequence ?? essence.msgSequence2 ?? notify.msgSequence ?? 0,
|
||||
random: essence.random ?? 0,
|
||||
set: setFlag === 1,
|
||||
};
|
||||
return [ev];
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Handles the four PkgTypes that produce a FriendMessage event:
|
||||
// PrivateMessage (166), ForwardFakePrivateMessage (9),
|
||||
// PrivateRecordMessage (208), PrivateFileMessage (529). The PkgType
|
||||
// difference only affects which proto-level elements show up in the
|
||||
// body — decodeRichBody handles all of them uniformly.
|
||||
|
||||
import type { FriendMessage } from '../../events';
|
||||
import type { MsgPushDecoder } from '../registry';
|
||||
import { decodeRichBody } from '../rich-body-decoder';
|
||||
|
||||
export const decodeFriendMessage: MsgPushDecoder = (ctx) => {
|
||||
const ev: FriendMessage = {
|
||||
kind: 'friend_message',
|
||||
time: ctx.head.timestamp,
|
||||
selfUin: ctx.selfUin,
|
||||
senderUin: ctx.fromUin,
|
||||
msgSeq: ctx.head.sequence,
|
||||
msgId: ctx.head.msgId & 0x7FFFFFFF,
|
||||
elements: decodeRichBody(ctx.body, false),
|
||||
senderNick: '',
|
||||
};
|
||||
if (ctx.responseHead?.forward?.friendName) {
|
||||
ev.senderNick = ctx.responseHead.forward.friendName;
|
||||
}
|
||||
const friend = ctx.identity.findFriend(ctx.fromUin);
|
||||
if (friend && !ev.senderNick) ev.senderNick = friend.nickname;
|
||||
return [ev];
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
// Handles GroupAdminChangedNotice (44). extraEnable / extraDisable
|
||||
// distinguishes set vs. unset.
|
||||
|
||||
import { protoDecode } from '../../../protobuf/decode';
|
||||
import { GroupAdminSchema } from '../../proto/notify';
|
||||
import type { GroupAdminEvent } from '../../events';
|
||||
import type { MsgPushDecoder } from '../registry';
|
||||
import { resolveUidToUin } from '../helpers';
|
||||
|
||||
export const decodeGroupAdmin: MsgPushDecoder = (ctx) => {
|
||||
const admin = protoDecode(ctx.content, GroupAdminSchema);
|
||||
if (!admin?.body) return [];
|
||||
const extra = admin.body.extraEnable ?? admin.body.extraDisable;
|
||||
if (!extra) return [];
|
||||
const ev: GroupAdminEvent = {
|
||||
kind: 'group_admin',
|
||||
time: ctx.head.timestamp,
|
||||
selfUin: ctx.selfUin,
|
||||
groupId: admin.groupUin ?? 0,
|
||||
userUin: resolveUidToUin(ctx.identity, admin.groupUin ?? 0, extra.adminUid ?? '', ctx.fromUin),
|
||||
set: admin.body.extraEnable !== undefined,
|
||||
};
|
||||
return [ev];
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
// Handles GroupRequestJoinNotice (84) + GroupRequestInvitationNotice (525)
|
||||
// + GroupInviteNotice (87). All three produce GroupInviteEvent with
|
||||
// different subType ('add' / 'invite') and flag prefixes.
|
||||
|
||||
import { protoDecode } from '../../../protobuf/decode';
|
||||
import {
|
||||
GroupJoinSchema, GroupInvitationSchema, GroupInviteSchema,
|
||||
} from '../../proto/notify';
|
||||
import type { GroupInviteEvent } from '../../events';
|
||||
import type { MsgPushDecoder } from '../registry';
|
||||
import { resolveUidToUin } from '../helpers';
|
||||
|
||||
export const decodeGroupJoinRequest: MsgPushDecoder = (ctx) => {
|
||||
const join = protoDecode(ctx.content, GroupJoinSchema);
|
||||
if (!join) return [];
|
||||
const ev: GroupInviteEvent = {
|
||||
kind: 'group_invite',
|
||||
time: ctx.head.timestamp,
|
||||
selfUin: ctx.selfUin,
|
||||
groupId: join.groupUin ?? 0,
|
||||
fromUin: resolveUidToUin(ctx.identity, join.groupUin ?? 0, join.targetUid ?? '', ctx.fromUin),
|
||||
fromUid: join.targetUid ?? '',
|
||||
subType: 'add',
|
||||
message: '',
|
||||
flag: 'add:' + (join.groupUin ?? 0) + ':' + (join.targetUid ?? ''),
|
||||
};
|
||||
return [ev];
|
||||
};
|
||||
|
||||
export const decodeGroupInvitation: MsgPushDecoder = (ctx) => {
|
||||
const invitation = protoDecode(ctx.content, GroupInvitationSchema);
|
||||
if (!invitation?.info?.inner) return [];
|
||||
const inner = invitation.info.inner;
|
||||
const ev: GroupInviteEvent = {
|
||||
kind: 'group_invite',
|
||||
time: ctx.head.timestamp,
|
||||
selfUin: ctx.selfUin,
|
||||
groupId: inner.groupUin ?? 0,
|
||||
fromUin: resolveUidToUin(ctx.identity, inner.groupUin ?? 0, inner.invitorUid ?? '', ctx.fromUin),
|
||||
fromUid: inner.invitorUid ?? '',
|
||||
subType: 'invite',
|
||||
message: '',
|
||||
flag: 'invite:' + (inner.groupUin ?? 0) + ':' + (inner.invitorUid ?? ''),
|
||||
};
|
||||
return [ev];
|
||||
};
|
||||
|
||||
export const decodeGroupInvite: MsgPushDecoder = (ctx) => {
|
||||
const invite = protoDecode(ctx.content, GroupInviteSchema);
|
||||
if (!invite) return [];
|
||||
const ev: GroupInviteEvent = {
|
||||
kind: 'group_invite',
|
||||
time: ctx.head.timestamp,
|
||||
selfUin: ctx.selfUin,
|
||||
groupId: invite.groupUin ?? 0,
|
||||
fromUin: resolveUidToUin(ctx.identity, invite.groupUin ?? 0, invite.invitorUid ?? '', ctx.fromUin),
|
||||
fromUid: invite.invitorUid ?? '',
|
||||
subType: 'invite',
|
||||
message: '',
|
||||
flag: 'invite:' + (invite.groupUin ?? 0) + ':' + (invite.invitorUid ?? ''),
|
||||
};
|
||||
return [ev];
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
// Handles GroupMemberIncreaseNotice (33) + GroupMemberDecreaseNotice (34).
|
||||
// Both share GroupChangeSchema; the decreaseType field distinguishes kick vs
|
||||
// voluntary leave (dt != 0 && dt != 130 means kicked).
|
||||
|
||||
import { protoDecode } from '../../../protobuf/decode';
|
||||
import { GroupChangeSchema } from '../../proto/notify';
|
||||
import type { GroupMemberJoin, GroupMemberLeave } from '../../events';
|
||||
import type { MsgPushDecoder } from '../registry';
|
||||
import { decodeOperatorUid, resolveUidToUin } from '../helpers';
|
||||
|
||||
export const decodeGroupMemberJoin: MsgPushDecoder = (ctx) => {
|
||||
const change = protoDecode(ctx.content, GroupChangeSchema);
|
||||
if (!change) return [];
|
||||
const groupId = change.groupUin ?? 0;
|
||||
const userUid = change.memberUid ?? '';
|
||||
const operatorUid = decodeOperatorUid(change.operatorBytes ?? new Uint8Array(0));
|
||||
const ev: GroupMemberJoin = {
|
||||
kind: 'group_member_join',
|
||||
time: ctx.head.timestamp,
|
||||
selfUin: ctx.selfUin,
|
||||
groupId,
|
||||
userUin: resolveUidToUin(ctx.identity, groupId, userUid, 0),
|
||||
operatorUin: resolveUidToUin(ctx.identity, groupId, operatorUid, 0),
|
||||
userUid,
|
||||
operatorUid,
|
||||
};
|
||||
return [ev];
|
||||
};
|
||||
|
||||
export const decodeGroupMemberLeave: MsgPushDecoder = (ctx) => {
|
||||
const change = protoDecode(ctx.content, GroupChangeSchema);
|
||||
if (!change) return [];
|
||||
const dt = change.decreaseType ?? 0;
|
||||
const groupId = change.groupUin ?? 0;
|
||||
const userUid = change.memberUid ?? '';
|
||||
const operatorUid = decodeOperatorUid(change.operatorBytes ?? new Uint8Array(0));
|
||||
const ev: GroupMemberLeave = {
|
||||
kind: 'group_member_leave',
|
||||
time: ctx.head.timestamp,
|
||||
selfUin: ctx.selfUin,
|
||||
groupId,
|
||||
userUin: resolveUidToUin(ctx.identity, groupId, userUid, 0),
|
||||
operatorUin: resolveUidToUin(ctx.identity, groupId, operatorUid, 0),
|
||||
userUid,
|
||||
operatorUid,
|
||||
isKick: dt !== 0 && dt !== 130,
|
||||
};
|
||||
return [ev];
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
// Handles GroupMessage (82). Resolves senderCard / senderRole from the
|
||||
// group member roster when present.
|
||||
|
||||
import type { GroupMessage } from '../../events';
|
||||
import type { MsgPushDecoder } from '../registry';
|
||||
import { decodeRichBody } from '../rich-body-decoder';
|
||||
|
||||
export const decodeGroupMessage: MsgPushDecoder = (ctx) => {
|
||||
const ev: GroupMessage = {
|
||||
kind: 'group_message',
|
||||
time: ctx.head.timestamp,
|
||||
selfUin: ctx.selfUin,
|
||||
senderUin: ctx.fromUin,
|
||||
msgSeq: ctx.head.sequence,
|
||||
msgId: ctx.head.msgId & 0x7FFFFFFF,
|
||||
elements: decodeRichBody(ctx.body, true),
|
||||
groupId: 0,
|
||||
senderNick: '',
|
||||
senderCard: '',
|
||||
senderRole: '',
|
||||
};
|
||||
if (ctx.responseHead?.grp) {
|
||||
ev.groupId = ctx.responseHead.grp.groupUin ?? 0;
|
||||
ev.senderNick = ctx.responseHead.grp.memberName ?? '';
|
||||
}
|
||||
const member = ctx.identity.findGroupMember(ev.groupId, ctx.fromUin);
|
||||
if (member) {
|
||||
if (!ev.senderNick) ev.senderNick = member.nickname;
|
||||
ev.senderCard = member.card;
|
||||
ev.senderRole = member.role;
|
||||
}
|
||||
return [ev];
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
// Handles TempMessage (141). Pulls senderNick from group hint first
|
||||
// then falls back to friend roster.
|
||||
|
||||
import type { TempMessage } from '../../events';
|
||||
import type { MsgPushDecoder } from '../registry';
|
||||
import { decodeRichBody } from '../rich-body-decoder';
|
||||
|
||||
export const decodeTempMessage: MsgPushDecoder = (ctx) => {
|
||||
const ev: TempMessage = {
|
||||
kind: 'temp_message',
|
||||
time: ctx.head.timestamp,
|
||||
selfUin: ctx.selfUin,
|
||||
senderUin: ctx.fromUin,
|
||||
msgSeq: ctx.head.sequence,
|
||||
elements: decodeRichBody(ctx.body, false),
|
||||
groupId: 0,
|
||||
senderNick: '',
|
||||
};
|
||||
if (ctx.responseHead?.grp) {
|
||||
ev.groupId = ctx.responseHead.grp.groupUin ?? 0;
|
||||
ev.senderNick = ctx.responseHead.grp.memberName ?? '';
|
||||
}
|
||||
if (!ev.senderNick) {
|
||||
const friend = ctx.identity.findFriend(ctx.fromUin);
|
||||
if (friend) ev.senderNick = friend.nickname;
|
||||
}
|
||||
return [ev];
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
// MsgPush PkgType taxonomy. The top-level msgType field in PushMsg.contentHead
|
||||
// names a QQ-protocol-defined packet shape; Event0x210 / Event0x2DC are wrapper
|
||||
// PkgTypes whose payload is further dispatched by subType.
|
||||
|
||||
export enum PkgType {
|
||||
ForwardFakePrivateMessage = 9,
|
||||
PrivateMessage = 166,
|
||||
GroupMessage = 82,
|
||||
TempMessage = 141,
|
||||
Event0x210 = 528,
|
||||
Event0x2DC = 732,
|
||||
PrivateRecordMessage = 208,
|
||||
PrivateFileMessage = 529,
|
||||
GroupRequestInvitationNotice = 525,
|
||||
GroupRequestJoinNotice = 84,
|
||||
GroupInviteNotice = 87,
|
||||
GroupAdminChangedNotice = 44,
|
||||
GroupMemberIncreaseNotice = 33,
|
||||
GroupMemberDecreaseNotice = 34,
|
||||
}
|
||||
|
||||
export enum Event0x2DCSubType {
|
||||
GroupMuteNotice = 12,
|
||||
GroupRecallNotice = 17,
|
||||
GroupGreyTipNotice = 20,
|
||||
GroupEssenceNotice = 21,
|
||||
}
|
||||
|
||||
export enum Event0x210SubType {
|
||||
FriendRequestNotice = 35,
|
||||
FriendRecallNotice = 138,
|
||||
FriendPokeNotice = 290,
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// MsgPush decode helpers — pure functions shared across PkgType decoders
|
||||
// and the rich-body decoder. No state, no I/O.
|
||||
|
||||
import { inflateSync } from 'zlib';
|
||||
import { protoDecode } from '../../protobuf/decode';
|
||||
import { OperatorInfoSchema } from '../proto/notify';
|
||||
import type { IdentityService } from '../identity-service';
|
||||
|
||||
export function makeImageUrl(origUrl: string): string {
|
||||
if (!origUrl) return '';
|
||||
if (origUrl.includes('rkey')) return 'https://multimedia.nt.qq.com.cn' + origUrl;
|
||||
return 'http://gchat.qpic.cn' + origUrl;
|
||||
}
|
||||
|
||||
export function decompressData(data: Uint8Array): string {
|
||||
if (!data || data.length === 0) return '';
|
||||
if (data[0] === 0x01 && data.length > 1) {
|
||||
try {
|
||||
const inflated = inflateSync(Buffer.from(data.subarray(1)));
|
||||
return inflated.toString('utf8');
|
||||
} catch { return ''; }
|
||||
}
|
||||
if (data[0] === 0x00 && data.length > 1) {
|
||||
return Buffer.from(data.subarray(1)).toString('utf8');
|
||||
}
|
||||
return Buffer.from(data).toString('utf8');
|
||||
}
|
||||
|
||||
export function isNumericUin(value: string): boolean {
|
||||
return value.length > 0 && /^\d+$/.test(value);
|
||||
}
|
||||
|
||||
export function parseU64OrZero(value: string): number {
|
||||
if (!value) return 0;
|
||||
const n = parseInt(value, 10);
|
||||
return isNaN(n) ? 0 : n;
|
||||
}
|
||||
|
||||
// Cascades group-scoped lookup → in-memory map → SQLite.
|
||||
// No network fallback on the parse hot path: missing identities fall back to
|
||||
// `fallback` and downstream events drive a roster refresh asynchronously.
|
||||
export function resolveUidToUin(identity: IdentityService, groupId: number, uid: string, fallback = 0): number {
|
||||
if (!uid) return fallback;
|
||||
if (isNumericUin(uid)) {
|
||||
const n = parseInt(uid, 10);
|
||||
if (!isNaN(n)) return n;
|
||||
}
|
||||
const uin = identity.findUinByUid(uid, groupId || undefined);
|
||||
if (uin !== null) return uin;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function decodeOperatorUid(bytes: Uint8Array): string {
|
||||
if (!bytes || bytes.length === 0) return '';
|
||||
const info = protoDecode(bytes, OperatorInfoSchema);
|
||||
if (info?.operatorField?.uid) return info.operatorField.uid;
|
||||
return Buffer.from(bytes).toString('utf8');
|
||||
}
|
||||
|
||||
export function buildTemplateMap(params: Array<{name?: string; value?: string}>): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
for (const p of params) {
|
||||
if (p.name !== undefined) map.set(p.name, p.value ?? '');
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function findTemplateValue(map: Map<string, string>, ...keys: string[]): string {
|
||||
for (const k of keys) {
|
||||
const v = map.get(k);
|
||||
if (v) return v;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function unwrapGroupNotifyPayload(content: Uint8Array): Uint8Array | null {
|
||||
if (content.length <= 7) return null;
|
||||
const lenBe = (content[5] << 8) | content[6];
|
||||
const lenLe = content[5] | (content[6] << 8);
|
||||
if (7 + lenBe <= content.length) return content.subarray(7, 7 + lenBe);
|
||||
if (7 + lenLe <= content.length) return content.subarray(7, 7 + lenLe);
|
||||
return content.subarray(7);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// MsgPush entry point — assembles the PkgType registry and exposes
|
||||
// `parseMsgPush(pkt, identity)` as the single CmdParser registered against
|
||||
// `MSG_PUSH_CMD` on the IncomingPacketPipeline.
|
||||
|
||||
import type { PacketInfo } from '../../protocol/types';
|
||||
import type { IdentityService } from '../identity-service';
|
||||
import type { QQEventVariant } from '../events';
|
||||
import { buildContext } from './context';
|
||||
import { MsgPushRegistry } from './registry';
|
||||
import { PkgType } from './enums';
|
||||
import {
|
||||
decodeGroupMemberJoin, decodeGroupMemberLeave,
|
||||
} from './decoders/group-member-change';
|
||||
import { decodeGroupAdmin } from './decoders/group-admin';
|
||||
import {
|
||||
decodeGroupJoinRequest, decodeGroupInvitation, decodeGroupInvite,
|
||||
} from './decoders/group-join-request';
|
||||
import { decodeEvent0x210 } from './decoders/event-0x210';
|
||||
import { decodeEvent0x2DC } from './decoders/event-0x2dc';
|
||||
import { decodeGroupMessage } from './decoders/group-message';
|
||||
import { decodeTempMessage } from './decoders/temp-message';
|
||||
import { decodeFriendMessage } from './decoders/friend-message';
|
||||
|
||||
export const MSG_PUSH_CMD = 'trpc.msg.olpush.OlPushService.MsgPush';
|
||||
|
||||
const registry = new MsgPushRegistry();
|
||||
registry.register(PkgType.GroupMemberIncreaseNotice, decodeGroupMemberJoin);
|
||||
registry.register(PkgType.GroupMemberDecreaseNotice, decodeGroupMemberLeave);
|
||||
registry.register(PkgType.GroupAdminChangedNotice, decodeGroupAdmin);
|
||||
registry.register(PkgType.GroupRequestJoinNotice, decodeGroupJoinRequest);
|
||||
registry.register(PkgType.GroupRequestInvitationNotice, decodeGroupInvitation);
|
||||
registry.register(PkgType.GroupInviteNotice, decodeGroupInvite);
|
||||
registry.register(PkgType.Event0x210, decodeEvent0x210);
|
||||
registry.register(PkgType.Event0x2DC, decodeEvent0x2DC);
|
||||
registry.register(PkgType.GroupMessage, decodeGroupMessage);
|
||||
registry.register(PkgType.TempMessage, decodeTempMessage);
|
||||
registry.register([
|
||||
PkgType.PrivateMessage,
|
||||
PkgType.ForwardFakePrivateMessage,
|
||||
PkgType.PrivateRecordMessage,
|
||||
PkgType.PrivateFileMessage,
|
||||
], decodeFriendMessage);
|
||||
|
||||
export function parseMsgPush(pkt: PacketInfo, identity: IdentityService): QQEventVariant[] {
|
||||
const ctx = buildContext(pkt, identity);
|
||||
if (!ctx) return [];
|
||||
return registry.decode(ctx);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// MsgPushRegistry — per-PkgType dispatch table. Each PkgType maps to a single
|
||||
// decoder; double-registration is a programming error (throws at register time).
|
||||
// Decoder errors are caught and logged with the PkgType so a single bad decoder
|
||||
// can never silently take down sibling decoders sharing the cmd.
|
||||
|
||||
import { createLogger } from '../../utils/logger';
|
||||
import type { QQEventVariant } from '../events';
|
||||
import type { MsgPushContext } from './context';
|
||||
import type { PkgType } from './enums';
|
||||
|
||||
const log = createLogger('MsgPush');
|
||||
|
||||
export type MsgPushDecoder = (ctx: MsgPushContext) => QQEventVariant[];
|
||||
|
||||
export class MsgPushRegistry {
|
||||
private readonly decoders_ = new Map<PkgType, MsgPushDecoder>();
|
||||
|
||||
register(pkgType: PkgType | PkgType[], decoder: MsgPushDecoder): void {
|
||||
const list = Array.isArray(pkgType) ? pkgType : [pkgType];
|
||||
for (const t of list) {
|
||||
if (this.decoders_.has(t)) {
|
||||
throw new Error(`MsgPushRegistry: PkgType ${t} already registered`);
|
||||
}
|
||||
this.decoders_.set(t, decoder);
|
||||
}
|
||||
}
|
||||
|
||||
decode(ctx: MsgPushContext): QQEventVariant[] {
|
||||
const decoder = this.decoders_.get(ctx.head.msgType as PkgType);
|
||||
if (!decoder) return [];
|
||||
try {
|
||||
return decoder(ctx);
|
||||
} catch (e) {
|
||||
log.error('decoder error for PkgType=%d subType=%d: %s',
|
||||
ctx.head.msgType, ctx.head.subType,
|
||||
e instanceof Error ? (e.stack ?? e.message) : String(e));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
// Rich body decoder — turns the proto MessageBody (richText.elems +
|
||||
// richText.ptt/notOnlineFile + msgContent) of a MsgPush message into
|
||||
// the bridge-internal MessageElement[] sequence. Pure, no identity
|
||||
// lookups, no I/O. The single small interface `decodeRichBody` hides
|
||||
// ~400 lines of per-element-type decoding behind it.
|
||||
|
||||
import { protoDecode } from '../../protobuf/decode';
|
||||
import type { ProtoDecoded } from '../../protobuf/decode';
|
||||
import { ElemSchema } from '../proto/element';
|
||||
import {
|
||||
MentionExtraSchema, QFaceExtraSchema, QSmallFaceExtraSchema,
|
||||
MsgInfoSchema, GroupFileExtraSchema, NotOnlineImageSchema,
|
||||
} from '../proto/element';
|
||||
import {
|
||||
MessageBodySchema, RichTextSchema, FileExtraSchema,
|
||||
} from '../proto/message';
|
||||
import type { MessageElement } from '../events';
|
||||
import { toHexUpper } from '../../utils/hex';
|
||||
import { decompressData, makeImageUrl } from './helpers';
|
||||
|
||||
type ElemDecoded = ProtoDecoded<typeof ElemSchema>;
|
||||
type RichTextDecoded = ProtoDecoded<typeof RichTextSchema>;
|
||||
export type PushMsgBody = ProtoDecoded<typeof MessageBodySchema>;
|
||||
|
||||
export function decodeRichBody(body: PushMsgBody | undefined, isGroup: boolean): MessageElement[] {
|
||||
const elements: MessageElement[] = [];
|
||||
if (body?.richText) {
|
||||
const rt = body.richText;
|
||||
if (rt.elems) elements.push(...convertElements(rt.elems as ElemDecoded[]));
|
||||
extractRichtextExtras(rt, elements, isGroup);
|
||||
}
|
||||
if (body?.msgContent && body.msgContent.length > 0) {
|
||||
extractMsgContent(body.msgContent, elements);
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
function convertElements(elems: ElemDecoded[]): MessageElement[] {
|
||||
const result: MessageElement[] = [];
|
||||
let skipNext = false;
|
||||
|
||||
for (const elem of elems) {
|
||||
if (skipNext) { skipNext = false; continue; }
|
||||
|
||||
// Reply / quote
|
||||
if (elem.srcMsg?.origSeqs && elem.srcMsg.origSeqs.length > 0) {
|
||||
result.push({ type: 'reply', replySeq: elem.srcMsg.origSeqs[0] });
|
||||
}
|
||||
|
||||
// Text (with possible @ detection)
|
||||
if (elem.text) {
|
||||
const t = elem.text;
|
||||
let mention: ProtoDecoded<typeof MentionExtraSchema> | null = null;
|
||||
if (t.pbReserve && t.pbReserve.length > 0) {
|
||||
mention = protoDecode(t.pbReserve, MentionExtraSchema);
|
||||
}
|
||||
const hasAttr6 = t.attr6Buf && t.attr6Buf.length > 11;
|
||||
const hasMention = mention && (mention.type === 1 || mention.type === 2);
|
||||
|
||||
if (hasAttr6 || hasMention) {
|
||||
const me: MessageElement = { type: 'at', text: t.str ?? '' };
|
||||
if (hasAttr6) {
|
||||
const buf = t.attr6Buf!;
|
||||
me.targetUin = ((buf[7] << 24) | (buf[8] << 16) | (buf[9] << 8) | buf[10]) >>> 0;
|
||||
}
|
||||
if (hasMention && mention) {
|
||||
me.uid = mention.uid ?? '';
|
||||
if (!me.targetUin) me.targetUin = mention.uin ?? 0;
|
||||
}
|
||||
result.push(me);
|
||||
} else {
|
||||
const text = t.str ?? '';
|
||||
if (text) result.push({ type: 'text', text });
|
||||
}
|
||||
}
|
||||
|
||||
// Face
|
||||
if (elem.face) {
|
||||
result.push({ type: 'face', faceId: elem.face.index ?? 0 });
|
||||
}
|
||||
|
||||
// MarketFace
|
||||
if (elem.marketFace) {
|
||||
result.push({
|
||||
type: 'mface',
|
||||
text: elem.marketFace.faceName ?? '',
|
||||
faceId: elem.marketFace.tabId ?? 0,
|
||||
subType: elem.marketFace.subType ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
// NotOnlineImage (C2C image)
|
||||
if (elem.notOnlineImage) {
|
||||
const img = elem.notOnlineImage;
|
||||
if (img.picMd5 && img.picMd5.length > 0) {
|
||||
const urlPath = img.origUrl || img.bigUrl || '';
|
||||
result.push({
|
||||
type: 'image',
|
||||
imageUrl: makeImageUrl(urlPath),
|
||||
fileId: img.filePath ?? '',
|
||||
fileSize: img.fileLen ?? 0,
|
||||
width: img.picWidth ?? 0,
|
||||
height: img.picHeight ?? 0,
|
||||
subType: img.pbRes?.subType ?? 0,
|
||||
summary: img.pbRes?.summary ?? '[image]',
|
||||
md5Hex: toHexUpper(img.picMd5),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// CustomFace (group image)
|
||||
if (elem.customFace) {
|
||||
const img = elem.customFace;
|
||||
if (img.md5 && img.md5.length > 0) {
|
||||
result.push({
|
||||
type: 'image',
|
||||
imageUrl: makeImageUrl(img.origUrl ?? ''),
|
||||
fileId: img.filePath ?? '',
|
||||
fileSize: img.size ?? 0,
|
||||
width: img.width ?? 0,
|
||||
height: img.height ?? 0,
|
||||
subType: img.pbRes?.subType ?? 0,
|
||||
summary: img.pbRes?.summary ?? '[image]',
|
||||
md5Hex: toHexUpper(img.md5),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// VideoFile
|
||||
if (elem.videoFile) {
|
||||
const v = elem.videoFile;
|
||||
result.push({
|
||||
type: 'video',
|
||||
fileId: v.fileUuid ?? '',
|
||||
fileName: v.fileName ?? '',
|
||||
fileSize: v.fileSize ?? 0,
|
||||
duration: v.fileTime ?? 0,
|
||||
fileHash: v.fileMd5 && v.fileMd5.length > 0 ? toHexUpper(v.fileMd5) : '',
|
||||
mediaNode: {
|
||||
fileUuid: v.fileUuid ?? '',
|
||||
info: {
|
||||
fileSize: v.fileSize ?? 0,
|
||||
fileHash: v.fileMd5 && v.fileMd5.length > 0 ? toHexUpper(v.fileMd5) : '',
|
||||
fileName: v.fileName ?? '',
|
||||
width: v.fileWidth ?? 0,
|
||||
height: v.fileHeight ?? 0,
|
||||
time: v.fileTime ?? 0,
|
||||
type: {
|
||||
type: 2,
|
||||
videoFormat: v.fileFormat ?? 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// GroupFile
|
||||
if (elem.groupFile) {
|
||||
const f = elem.groupFile;
|
||||
result.push({
|
||||
type: 'file',
|
||||
fileId: f.fileId ?? '',
|
||||
fileName: f.filename ?? '',
|
||||
fileSize: f.fileSize !== undefined ? Number(f.fileSize) : 0,
|
||||
});
|
||||
}
|
||||
|
||||
// TransElem type=24 (group file via transport)
|
||||
if (elem.transElem) {
|
||||
const te = elem.transElem;
|
||||
if ((te.elemType ?? 0) === 24 && te.elemValue && te.elemValue.length > 3) {
|
||||
const val = te.elemValue;
|
||||
const len = (val[1] << 8) | val[2];
|
||||
if (val.length >= 3 + len) {
|
||||
const extra = protoDecode(val.subarray(3, 3 + len), GroupFileExtraSchema);
|
||||
if (extra?.inner?.info) {
|
||||
const info = extra.inner.info;
|
||||
result.push({
|
||||
type: 'file',
|
||||
fileName: info.fileName ?? '',
|
||||
fileSize: info.fileSize !== undefined ? Number(info.fileSize) : 0,
|
||||
fileId: info.fileId ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RichMsg
|
||||
if (elem.richMsg) {
|
||||
const rm = elem.richMsg;
|
||||
if (rm.template1 && rm.template1.length > 0) {
|
||||
const content = decompressData(rm.template1);
|
||||
if (content) {
|
||||
const svcId = rm.serviceId ?? 0;
|
||||
if (svcId === 35) {
|
||||
const pos = content.indexOf('m_resid="');
|
||||
if (pos !== -1) {
|
||||
const start = pos + 9;
|
||||
const end = content.indexOf('"', start);
|
||||
if (end !== -1) {
|
||||
result.push({ type: 'forward', resId: content.substring(start, end) });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result.push({ type: 'xml', text: content, subType: svcId });
|
||||
} else if (svcId === 1) {
|
||||
result.push({ type: 'json', text: content });
|
||||
} else {
|
||||
result.push({ type: 'xml', text: content, subType: svcId });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LightApp
|
||||
if (elem.lightApp) {
|
||||
const la = elem.lightApp;
|
||||
if (la.data && la.data.length > 0) {
|
||||
const content = decompressData(la.data);
|
||||
if (content) result.push({ type: 'json', text: content });
|
||||
}
|
||||
}
|
||||
|
||||
// CommonElem
|
||||
if (elem.commonElem) {
|
||||
const ce = elem.commonElem;
|
||||
const svcType = ce.serviceType ?? 0;
|
||||
const bizType = ce.businessType ?? 0;
|
||||
|
||||
if (svcType === 2) {
|
||||
// Poke
|
||||
result.push({ type: 'poke', subType: bizType });
|
||||
} else if (svcType === 3 && ce.pbElem && ce.pbElem.length > 1) {
|
||||
// Flash image
|
||||
const pb = ce.pbElem;
|
||||
let pos = 1;
|
||||
let length = 0, shift = 0;
|
||||
while (pos < pb.length) {
|
||||
const b = pb[pos++];
|
||||
length |= (b & 0x7f) << shift;
|
||||
shift += 7;
|
||||
if ((b & 0x80) === 0) break;
|
||||
}
|
||||
if (pos + length <= pb.length) {
|
||||
const img = protoDecode(pb.subarray(pos, pos + length), NotOnlineImageSchema);
|
||||
if (img) {
|
||||
const me: MessageElement = {
|
||||
type: 'image', fileId: img.filePath ?? '',
|
||||
fileSize: img.fileLen ?? 0, width: img.picWidth ?? 0,
|
||||
height: img.picHeight ?? 0, flash: true, summary: '[flash image]',
|
||||
};
|
||||
if (img.pbRes) me.subType = img.pbRes.subType ?? 0;
|
||||
if (img.picMd5 && img.picMd5.length > 0) {
|
||||
me.imageUrl = 'http://gchat.qpic.cn/gchatpic_new/0/0-0-' + toHexUpper(img.picMd5) + '/0';
|
||||
}
|
||||
result.push(me);
|
||||
}
|
||||
}
|
||||
skipNext = true;
|
||||
} else if (ce.pbElem && (svcType === 48 || bizType === 10 || bizType === 20 || bizType === 11 || bizType === 21 || bizType === 12 || bizType === 22)) {
|
||||
// NTQQ new protocol image/record/video
|
||||
const info = protoDecode(ce.pbElem, MsgInfoSchema);
|
||||
if (info?.msgInfoBody && info.msgInfoBody.length > 0) {
|
||||
const body = info.msgInfoBody[0];
|
||||
if (body.index?.info) {
|
||||
const idx = body.index;
|
||||
const fi = idx.info!;
|
||||
|
||||
if (bizType === 10 || bizType === 20) {
|
||||
// Image
|
||||
let url = '';
|
||||
if (body.picture) {
|
||||
const domain = body.picture.domain ?? 'multimedia.nt.qq.com.cn';
|
||||
const path = body.picture.urlPath ?? '';
|
||||
if (path) {
|
||||
url = 'https://' + domain + path;
|
||||
if (body.picture.ext?.originalParameter) {
|
||||
url += body.picture.ext.originalParameter;
|
||||
}
|
||||
}
|
||||
}
|
||||
const me: MessageElement = {
|
||||
type: 'image', fileId: fi.fileName ?? '',
|
||||
fileSize: fi.fileSize ?? 0, width: fi.width ?? 0,
|
||||
height: fi.height ?? 0, imageUrl: url,
|
||||
};
|
||||
if (fi.fileHash) me.md5Hex = fi.fileHash;
|
||||
if (fi.fileSha1) me.sha1Hex = fi.fileSha1;
|
||||
if (fi.type?.picFormat) me.picFormat = fi.type.picFormat;
|
||||
if (info.extBizInfo?.pic) {
|
||||
me.subType = info.extBizInfo.pic.bizType ?? 0;
|
||||
me.summary = info.extBizInfo.pic.textSummary || '[image]';
|
||||
}
|
||||
result.push(me);
|
||||
} else if (bizType === 12 || bizType === 22) {
|
||||
// Record
|
||||
result.push({
|
||||
type: 'record', fileName: fi.fileName ?? '',
|
||||
fileId: idx.fileUuid ?? '', duration: fi.time ?? 0,
|
||||
fileHash: fi.fileHash ?? '',
|
||||
fileSize: fi.fileSize ?? 0,
|
||||
md5Hex: fi.fileHash ?? '',
|
||||
sha1Hex: fi.fileSha1 ?? '',
|
||||
voiceFormat: fi.type?.voiceFormat ?? 0,
|
||||
mediaNode: {
|
||||
fileUuid: idx.fileUuid,
|
||||
storeId: idx.storeId,
|
||||
uploadTime: idx.uploadTime,
|
||||
ttl: idx.ttl,
|
||||
subType: idx.subType,
|
||||
info: {
|
||||
fileSize: fi.fileSize,
|
||||
fileHash: fi.fileHash,
|
||||
fileSha1: fi.fileSha1,
|
||||
fileName: fi.fileName,
|
||||
width: fi.width,
|
||||
height: fi.height,
|
||||
time: fi.time,
|
||||
original: fi.original,
|
||||
type: {
|
||||
type: fi.type?.type,
|
||||
picFormat: fi.type?.picFormat,
|
||||
videoFormat: fi.type?.videoFormat,
|
||||
voiceFormat: fi.type?.voiceFormat,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} else if (bizType === 11 || bizType === 21) {
|
||||
// Video
|
||||
result.push({
|
||||
type: 'video', fileName: fi.fileName ?? '',
|
||||
fileId: idx.fileUuid ?? '', fileSize: fi.fileSize ?? 0,
|
||||
duration: fi.time ?? 0,
|
||||
fileHash: fi.fileHash ?? '',
|
||||
width: fi.width ?? 0,
|
||||
height: fi.height ?? 0,
|
||||
md5Hex: fi.fileHash ?? '',
|
||||
sha1Hex: fi.fileSha1 ?? '',
|
||||
videoFormat: fi.type?.videoFormat ?? 0,
|
||||
mediaNode: {
|
||||
fileUuid: idx.fileUuid,
|
||||
storeId: idx.storeId,
|
||||
uploadTime: idx.uploadTime,
|
||||
ttl: idx.ttl,
|
||||
subType: idx.subType,
|
||||
info: {
|
||||
fileSize: fi.fileSize,
|
||||
fileHash: fi.fileHash,
|
||||
fileSha1: fi.fileSha1,
|
||||
fileName: fi.fileName,
|
||||
width: fi.width,
|
||||
height: fi.height,
|
||||
time: fi.time,
|
||||
original: fi.original,
|
||||
type: {
|
||||
type: fi.type?.type,
|
||||
picFormat: fi.type?.picFormat,
|
||||
videoFormat: fi.type?.videoFormat,
|
||||
voiceFormat: fi.type?.voiceFormat,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (svcType === 33 && ce.pbElem) {
|
||||
// Small face
|
||||
const extra = protoDecode(ce.pbElem, QSmallFaceExtraSchema);
|
||||
if (extra) result.push({ type: 'face', faceId: extra.faceId ?? 0 });
|
||||
} else if (svcType === 37 && ce.pbElem) {
|
||||
// Big face
|
||||
const extra = protoDecode(ce.pbElem, QFaceExtraSchema);
|
||||
if (extra?.qsid !== undefined) result.push({ type: 'face', faceId: extra.qsid });
|
||||
skipNext = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractRichtextExtras(
|
||||
rt: RichTextDecoded,
|
||||
elements: MessageElement[],
|
||||
isGroup = false
|
||||
): void {
|
||||
// Ptt (voice)
|
||||
if (rt.ptt) {
|
||||
const p = rt.ptt;
|
||||
const md5Hex = p.fileMd5 && p.fileMd5.length > 0 ? toHexUpper(p.fileMd5) : '';
|
||||
const me: MessageElement = {
|
||||
type: 'record', fileName: p.fileName ?? '',
|
||||
fileSize: p.fileSize ?? 0, duration: p.time ?? 0,
|
||||
fileHash: md5Hex,
|
||||
md5Hex,
|
||||
voiceFormat: p.format ?? 0,
|
||||
};
|
||||
if (isGroup && (p.fileId ?? 0n) !== 0n) {
|
||||
me.fileId = p.groupFileKey ?? '';
|
||||
} else {
|
||||
if (p.fileUuid && p.fileUuid.length > 0) {
|
||||
me.fileId = Buffer.from(p.fileUuid).toString('utf8');
|
||||
}
|
||||
}
|
||||
me.mediaNode = {
|
||||
fileUuid: me.fileId ?? '',
|
||||
info: {
|
||||
fileSize: p.fileSize ?? 0,
|
||||
fileHash: p.fileMd5 && p.fileMd5.length > 0 ? toHexUpper(p.fileMd5) : '',
|
||||
fileName: p.fileName ?? '',
|
||||
time: p.time ?? 0,
|
||||
type: {
|
||||
type: 3,
|
||||
voiceFormat: p.format ?? 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
elements.push(me);
|
||||
}
|
||||
|
||||
// NotOnlineFile (C2C file)
|
||||
if (rt.notOnlineFile) {
|
||||
const f = rt.notOnlineFile;
|
||||
elements.push({
|
||||
type: 'file', fileId: f.fileUuid ?? '',
|
||||
fileName: f.fileName ?? '',
|
||||
fileSize: f.fileSize !== undefined ? Number(f.fileSize) : 0,
|
||||
fileHash: f.fileHash ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function extractMsgContent(msgContent: Uint8Array, elements: MessageElement[]): void {
|
||||
const extra = protoDecode(msgContent, FileExtraSchema);
|
||||
if (!extra?.file) return;
|
||||
const f = extra.file;
|
||||
if (f.fileSize !== undefined && f.fileName && f.fileMd5 && f.fileUuid && f.fileHash) {
|
||||
elements.push({
|
||||
type: 'file', fileName: f.fileName, fileId: f.fileUuid,
|
||||
fileSize: f.fileSize !== undefined ? Number(f.fileSize) : 0,
|
||||
fileHash: f.fileHash,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -861,10 +861,25 @@ export const OidbGetPskeyRespSchema = {
|
||||
} satisfies ProtoSchema;
|
||||
|
||||
|
||||
// customExt sub-message of SetStatus. Populated only by
|
||||
// set_diy_online_status (status=10 + extStatus=2000 + this payload);
|
||||
// regular set_online_status leaves field 4 unset.
|
||||
//
|
||||
// Wire (from napcat packet/transformer/proto/action/action.ts SetStatusCustomExt):
|
||||
// 1 faceId (uint32) — face icon id, e.g. "Q我吧" sticker
|
||||
// 2 text (string) — wording shown next to the icon
|
||||
// 3 field3 (uint32) — face_type / template id; observed values 1..3
|
||||
export const SetStatusCustomExtSchema = {
|
||||
faceId: { field: 1, type: 'uint32' as const },
|
||||
text: { field: 2, type: 'string' as const },
|
||||
faceType: { field: 3, type: 'uint32' as const },
|
||||
} satisfies ProtoSchema;
|
||||
|
||||
export const SetStatusReqSchema = {
|
||||
status: { field: 1, type: 'int32' as const },
|
||||
extStatus: { field: 2, type: 'int32' as const },
|
||||
batteryStatus: { field: 3, type: 'int32' as const },
|
||||
customExt: { field: 4, type: 'message' as const, schema: SetStatusCustomExtSchema },
|
||||
} satisfies ProtoSchema;
|
||||
|
||||
export const SetStatusRespSchema = {
|
||||
@@ -1125,4 +1140,137 @@ export const Oidb0xf16ReqSchema = {
|
||||
field12: { field: 12, type: 'uint32' as const },
|
||||
} satisfies ProtoSchema;
|
||||
|
||||
export const Oidb0xf16RespSchema = {} satisfies ProtoSchema;
|
||||
export const Oidb0xf16RespSchema = {} satisfies ProtoSchema;
|
||||
|
||||
// --- 0xF90_{1,2,3}: Set / complete / cancel group todo ---
|
||||
// All three subCmds share the same body (groupUin + msgSeq); only the
|
||||
// envelope subCmd differs. Sources: napcat
|
||||
// packet/transformer/action/{Set,Complete,Cancel}GroupTodo.ts.
|
||||
|
||||
export const OidbGroupTodoSchema = {
|
||||
groupUin: { field: 1, type: 'uint32' as const },
|
||||
msgSeq: { field: 2, type: 'uint64' as const },
|
||||
} satisfies ProtoSchema;
|
||||
|
||||
// --- 0xFE1_2: Get stranger profile (used for online/ext status lookup) ---
|
||||
|
||||
export const OidbStrangerStatusKeySchema = {
|
||||
key: { field: 1, type: 'uint32' as const },
|
||||
} satisfies ProtoSchema;
|
||||
|
||||
export const OidbStrangerStatusReqSchema = {
|
||||
uin: { field: 1, type: 'uint32' as const },
|
||||
key: { field: 3, type: 'repeated_message' as const, schema: OidbStrangerStatusKeySchema },
|
||||
} satisfies ProtoSchema;
|
||||
|
||||
export const OidbStrangerStatusRespStatusSchema = {
|
||||
key: { field: 1, type: 'uint32' as const },
|
||||
value: { field: 2, type: 'uint64' as const },
|
||||
} satisfies ProtoSchema;
|
||||
|
||||
export const OidbStrangerStatusRespDataSchema = {
|
||||
status: { field: 2, type: 'message' as const, schema: OidbStrangerStatusRespStatusSchema },
|
||||
} satisfies ProtoSchema;
|
||||
|
||||
export const OidbStrangerStatusRespSchema = {
|
||||
data: { field: 1, type: 'message' as const, schema: OidbStrangerStatusRespDataSchema },
|
||||
} satisfies ProtoSchema;
|
||||
|
||||
// --- 0x929D_0: Fetch AI voice character list ---
|
||||
|
||||
export const OidbAiVoiceListReqSchema = {
|
||||
groupUin: { field: 1, type: 'uint32' as const },
|
||||
chatType: { field: 2, type: 'uint32' as const },
|
||||
} satisfies ProtoSchema;
|
||||
|
||||
export const OidbAiVoiceListEntrySchema = {
|
||||
voiceId: { field: 1, type: 'string' as const },
|
||||
voiceDisplayName: { field: 2, type: 'string' as const },
|
||||
voiceExampleUrl: { field: 3, type: 'string' as const },
|
||||
} satisfies ProtoSchema;
|
||||
|
||||
export const OidbAiVoiceListCategorySchema = {
|
||||
category: { field: 1, type: 'string' as const },
|
||||
voices: { field: 2, type: 'repeated_message' as const, schema: OidbAiVoiceListEntrySchema },
|
||||
} satisfies ProtoSchema;
|
||||
|
||||
export const OidbAiVoiceListRespSchema = {
|
||||
content: { field: 1, type: 'repeated_message' as const, schema: OidbAiVoiceListCategorySchema },
|
||||
} satisfies ProtoSchema;
|
||||
|
||||
// --- 0x929B_0: Trigger AI voice synthesis (returns server-side IndexNode) ---
|
||||
|
||||
export const OidbAiVoiceSessionSchema = {
|
||||
sessionId: { field: 1, type: 'uint32' as const },
|
||||
} satisfies ProtoSchema;
|
||||
|
||||
export const OidbAiVoiceReqSchema = {
|
||||
groupUin: { field: 1, type: 'uint32' as const },
|
||||
voiceId: { field: 2, type: 'string' as const },
|
||||
text: { field: 3, type: 'string' as const },
|
||||
chatType: { field: 4, type: 'uint32' as const },
|
||||
session: { field: 5, type: 'message' as const, schema: OidbAiVoiceSessionSchema },
|
||||
} satisfies ProtoSchema;
|
||||
|
||||
// We only need the fields the OneBot adapter consumes back: the
|
||||
// IndexNode payload (which feeds fetchGroupPttUrlByNode) and the
|
||||
// statusCode that the server uses to signal "still rendering, retry".
|
||||
|
||||
export const OidbAiVoiceFileTypeSchema = {
|
||||
type: { field: 1, type: 'uint32' as const },
|
||||
picFormat: { field: 2, type: 'uint32' as const },
|
||||
videoFormat: { field: 3, type: 'uint32' as const },
|
||||
voiceFormat: { field: 4, type: 'uint32' as const },
|
||||
} satisfies ProtoSchema;
|
||||
|
||||
export const OidbAiVoiceFileInfoSchema = {
|
||||
fileSize: { field: 1, type: 'uint32' as const },
|
||||
fileHash: { field: 2, type: 'string' as const },
|
||||
fileSha1: { field: 3, type: 'string' as const },
|
||||
fileName: { field: 4, type: 'string' as const },
|
||||
type: { field: 5, type: 'message' as const, schema: OidbAiVoiceFileTypeSchema },
|
||||
width: { field: 6, type: 'uint32' as const },
|
||||
height: { field: 7, type: 'uint32' as const },
|
||||
time: { field: 8, type: 'uint32' as const },
|
||||
original: { field: 9, type: 'uint32' as const },
|
||||
} satisfies ProtoSchema;
|
||||
|
||||
export const OidbAiVoiceIndexNodeSchema = {
|
||||
info: { field: 1, type: 'message' as const, schema: OidbAiVoiceFileInfoSchema },
|
||||
fileUuid: { field: 2, type: 'string' as const },
|
||||
storeId: { field: 3, type: 'uint32' as const },
|
||||
uploadTime: { field: 4, type: 'uint32' as const },
|
||||
ttl: { field: 5, type: 'uint32' as const },
|
||||
subType: { field: 6, type: 'uint32' as const },
|
||||
} satisfies ProtoSchema;
|
||||
|
||||
export const OidbAiVoiceMsgInfoBodySchema = {
|
||||
index: { field: 1, type: 'message' as const, schema: OidbAiVoiceIndexNodeSchema },
|
||||
} satisfies ProtoSchema;
|
||||
|
||||
export const OidbAiVoiceMsgInfoSchema = {
|
||||
msgInfoBody: { field: 1, type: 'repeated_message' as const, schema: OidbAiVoiceMsgInfoBodySchema },
|
||||
} satisfies ProtoSchema;
|
||||
|
||||
export const OidbAiVoiceRespSchema = {
|
||||
statusCode: { field: 1, type: 'uint32' as const },
|
||||
field2: { field: 2, type: 'uint32' as const },
|
||||
field3: { field: 3, type: 'uint32' as const },
|
||||
msgInfo: { field: 4, type: 'message' as const, schema: OidbAiVoiceMsgInfoSchema },
|
||||
} satisfies ProtoSchema;
|
||||
|
||||
// --- Highway cmdId 3000: GroupAvatarExtra ---
|
||||
// Ported from Lagrange.Core/Internal/Packets/Service/Highway/GroupAvatarExtra.cs.
|
||||
// Field values are protocol-prescribed constants (Lagrange comments on each).
|
||||
|
||||
export const GroupAvatarExtraField3Schema = {
|
||||
field1: { field: 1, type: 'uint32' as const }, // observed value: 1
|
||||
} satisfies ProtoSchema;
|
||||
|
||||
export const GroupAvatarExtraSchema = {
|
||||
type: { field: 1, type: 'uint32' as const }, // observed value: 101
|
||||
groupUin: { field: 2, type: 'uint32' as const },
|
||||
field3: { field: 3, type: 'message' as const, schema: GroupAvatarExtraField3Schema },
|
||||
field5: { field: 5, type: 'uint32' as const }, // observed value: 3
|
||||
field6: { field: 6, type: 'uint32' as const }, // observed value: 1
|
||||
} satisfies ProtoSchema;
|
||||
@@ -221,12 +221,16 @@ export function register(h: ApiHandler, ctx: ApiActionContext): void {
|
||||
|
||||
// --- RKey ---
|
||||
|
||||
h.registerAction('get_rkey', async () => {
|
||||
const handleGetRkey = async () => {
|
||||
if (ctx.getDownloadRKeys) {
|
||||
return okResponse(await ctx.getDownloadRKeys());
|
||||
}
|
||||
return failedResponse(RETCODE.ACTION_FAILED, 'not implemented');
|
||||
});
|
||||
};
|
||||
h.registerAction('get_rkey', handleGetRkey);
|
||||
// napcat exposes the same payload under `nc_get_rkey`; mirror the alias
|
||||
// so clients that follow napcat's docs work out-of-the-box.
|
||||
h.registerAction('nc_get_rkey', handleGetRkey);
|
||||
|
||||
// --- OCR stubs ---
|
||||
|
||||
@@ -597,10 +601,74 @@ export function register(h: ApiHandler, ctx: ApiActionContext): void {
|
||||
}
|
||||
});
|
||||
|
||||
h.registerAction('get_group_ignored_notifies', async () => {
|
||||
return okResponse([]);
|
||||
// DIY status — same wire packet as set_online_status, only the
|
||||
// customExt sub-message is populated and status/extStatus are forced
|
||||
// to the QQ-defined "I have a custom status" values (10 / 2000).
|
||||
// face_id / face_type accept either number or numeric string (napcat
|
||||
// parity); wording is the human-readable text shown next to the icon.
|
||||
h.registerAction('set_diy_online_status', async (params) => {
|
||||
const faceId = asNumber(params.face_id);
|
||||
const faceType = asNumber(params.face_type) || 1;
|
||||
const wording = asString(params.wording);
|
||||
if (!faceId) return failedResponse(RETCODE.BAD_REQUEST, 'face_id is required');
|
||||
try {
|
||||
await ctx.bridge.setDiyOnlineStatus(faceId, wording, faceType);
|
||||
return okResponse();
|
||||
} catch (err) {
|
||||
return failedResponse(RETCODE.ACTION_FAILED, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
});
|
||||
|
||||
// Filtered (机器人/被忽略) group join requests. SnowLuma already
|
||||
// implements the underlying oidb 0x10c8_2 fetch via fetchGroupRequests;
|
||||
// these three actions just rename / project the same data for the
|
||||
// OneBot dialects clients use in the wild.
|
||||
const fetchFilteredGroupRequests = async () => {
|
||||
try {
|
||||
return await ctx.bridge.fetchGroupRequests(true);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
h.registerAction('get_group_ignored_notifies', async () => {
|
||||
const reqs = await fetchFilteredGroupRequests();
|
||||
return okResponse(reqs.map((r) => ({
|
||||
group_id: r.groupId,
|
||||
group_name: r.groupName,
|
||||
request_id: r.sequence,
|
||||
requester_uin: r.targetUin,
|
||||
requester_nick: r.targetName,
|
||||
message: r.comment,
|
||||
checked: r.state !== 1,
|
||||
actor: r.operatorUin,
|
||||
invitor_uin: r.invitorUin,
|
||||
invitor_nick: r.invitorName,
|
||||
flag: `${r.eventType}:${r.groupId}:${r.targetUid}:filtered`,
|
||||
})));
|
||||
});
|
||||
|
||||
// napcat name for the subset of ignored notifies that are join requests
|
||||
// (notify type==7). We map every entry that flowed through filtered
|
||||
// 0x10c8_2 into napcat's shape — eventType already encodes the request
|
||||
// category in our pipeline.
|
||||
h.registerAction('get_group_ignore_add_request', async () => {
|
||||
const reqs = await fetchFilteredGroupRequests();
|
||||
return okResponse(reqs.map((r) => ({
|
||||
request_id: r.sequence,
|
||||
invitor_uin: r.invitorUin,
|
||||
invitor_nick: r.invitorName,
|
||||
group_id: r.groupId,
|
||||
message: r.comment,
|
||||
group_name: r.groupName,
|
||||
checked: r.state !== 1,
|
||||
actor: r.operatorUin,
|
||||
requester_nick: r.targetName,
|
||||
})));
|
||||
});
|
||||
|
||||
// get_group_shut_list lives behind an oidb we don't yet wrap; honour
|
||||
// the napcat contract (empty list) so callers don't blow up.
|
||||
h.registerAction('get_group_shut_list', async () => {
|
||||
return okResponse([]);
|
||||
});
|
||||
@@ -934,9 +1002,170 @@ export function register(h: ApiHandler, ctx: ApiActionContext): void {
|
||||
return failedResponse(RETCODE.ACTION_FAILED, 'not yet implemented');
|
||||
});
|
||||
|
||||
h.registerAction('.send_packet', async () => {
|
||||
return failedResponse(RETCODE.ACTION_FAILED, 'not yet implemented');
|
||||
// --- Raw packet escape hatch (napcat parity) ---
|
||||
//
|
||||
// napcat exposes both names; the dot-prefix variant is the original
|
||||
// gocqhttp-era backdoor, the no-prefix one is the modern API. They do
|
||||
// the same thing in SnowLuma: encode hex → Bridge.sendRawPacket → hex.
|
||||
const handleSendPacket = async (params: import('../types').JsonObject) => {
|
||||
const cmd = asString(params.cmd);
|
||||
const dataHex = asString(params.data);
|
||||
const rsp = asBoolean(params.rsp, true);
|
||||
if (!cmd) return failedResponse(RETCODE.BAD_REQUEST, 'cmd is required');
|
||||
if (!/^[0-9a-fA-F]*$/.test(dataHex) || dataHex.length % 2 !== 0) {
|
||||
return failedResponse(RETCODE.BAD_REQUEST, 'data must be a hex string of even length');
|
||||
}
|
||||
try {
|
||||
const body = hexToBytes(dataHex);
|
||||
const result = await ctx.bridge.sendRawPacket(cmd, body);
|
||||
if (!result.success) {
|
||||
return failedResponse(RETCODE.ACTION_FAILED, result.errorMessage || 'send failed');
|
||||
}
|
||||
if (!rsp) return okResponse(null);
|
||||
const respHex = result.responseData ? bytesToHex(result.responseData) : '';
|
||||
return okResponse(respHex);
|
||||
} catch (err) {
|
||||
return failedResponse(RETCODE.ACTION_FAILED, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
h.registerAction('send_packet', handleSendPacket);
|
||||
h.registerAction('.send_packet', handleSendPacket);
|
||||
|
||||
// --- Bot lifecycle (napcat parity) ---
|
||||
|
||||
h.registerAction('bot_exit', async () => {
|
||||
// Defer so the OK response actually flushes before the process dies.
|
||||
setTimeout(() => process.exit(0), 50);
|
||||
return okResponse();
|
||||
});
|
||||
|
||||
// SnowLuma has no separate packet-backend process (napcat's packet
|
||||
// service runs out-of-process and can fail independently). Always
|
||||
// report healthy.
|
||||
h.registerAction('nc_get_packet_status', async () => {
|
||||
return okResponse(null);
|
||||
});
|
||||
|
||||
// napcat exposes `delete_group_folder`; SnowLuma's existing
|
||||
// `delete_group_file_folder` is the same operation. Alias so clients
|
||||
// following napcat docs work without rewriting payloads.
|
||||
h.registerAction('delete_group_folder', async (params) => {
|
||||
const groupId = asNumber(params.group_id);
|
||||
const folderId = asString(params.folder_id);
|
||||
if (!groupId || !folderId) {
|
||||
return failedResponse(RETCODE.BAD_REQUEST, 'group_id and folder_id are required');
|
||||
}
|
||||
await ctx.bridge.deleteGroupFileFolder(groupId, folderId);
|
||||
return okResponse();
|
||||
});
|
||||
|
||||
// --- Group todo (oidb 0xF90) ---
|
||||
//
|
||||
// The three subcommands share an identical payload (group + msgSeq);
|
||||
// we extract once and dispatch by action name. msgSeq comes from the
|
||||
// message metadata cache (set/complete/cancel always target a real
|
||||
// message the bot has seen).
|
||||
type GroupTodoOp = (groupId: number, msgSeq: bigint | number | string) => Promise<void>;
|
||||
const handleGroupTodo = (op: GroupTodoOp) => async (params: import('../types').JsonObject) => {
|
||||
const groupId = asNumber(params.group_id);
|
||||
const messageId = asNumber(params.message_id);
|
||||
if (!groupId) return failedResponse(RETCODE.BAD_REQUEST, 'group_id is required');
|
||||
if (!messageId) return failedResponse(RETCODE.BAD_REQUEST, 'message_id is required');
|
||||
const meta = ctx.getMessageMeta(messageId);
|
||||
if (!meta) return failedResponse(RETCODE.ACTION_FAILED, 'message not found');
|
||||
if (!meta.isGroup || meta.targetId !== groupId) {
|
||||
return failedResponse(RETCODE.ACTION_FAILED, 'message does not belong to this group');
|
||||
}
|
||||
try {
|
||||
await op(groupId, BigInt(meta.sequence));
|
||||
return okResponse();
|
||||
} catch (err) {
|
||||
return failedResponse(RETCODE.ACTION_FAILED, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
h.registerAction('set_group_todo', handleGroupTodo((g, s) => ctx.bridge.setGroupTodo(g, s)));
|
||||
h.registerAction('complete_group_todo', handleGroupTodo((g, s) => ctx.bridge.completeGroupTodo(g, s)));
|
||||
h.registerAction('cancel_group_todo', handleGroupTodo((g, s) => ctx.bridge.cancelGroupTodo(g, s)));
|
||||
|
||||
// --- User online/ext status (napcat: nc_get_user_status) ---
|
||||
|
||||
h.registerAction('nc_get_user_status', async (params) => {
|
||||
const userId = asNumber(params.user_id);
|
||||
if (!userId) return failedResponse(RETCODE.BAD_REQUEST, 'user_id is required');
|
||||
const status = await ctx.bridge.getStrangerStatus(userId);
|
||||
if (!status) return failedResponse(RETCODE.ACTION_FAILED, 'failed to fetch user status');
|
||||
return okResponse(status);
|
||||
});
|
||||
|
||||
// --- AI voice (oidb 0x929D / 0x929B) ---
|
||||
|
||||
h.registerAction('get_ai_characters', async (params) => {
|
||||
const groupId = asNumber(params.group_id);
|
||||
const chatType = asNumber(params.chat_type) || 1;
|
||||
if (!groupId) return failedResponse(RETCODE.BAD_REQUEST, 'group_id is required');
|
||||
try {
|
||||
const list = await ctx.bridge.fetchAiVoiceList(groupId, chatType);
|
||||
return okResponse(list.map((cat) => ({
|
||||
type: cat.category,
|
||||
characters: cat.voices.map((v) => ({
|
||||
character_id: v.voiceId,
|
||||
character_name: v.voiceDisplayName,
|
||||
preview_url: v.voiceExampleUrl,
|
||||
})),
|
||||
})));
|
||||
} catch (err) {
|
||||
return failedResponse(RETCODE.ACTION_FAILED, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
});
|
||||
|
||||
h.registerAction('get_ai_record', async (params) => {
|
||||
const groupId = asNumber(params.group_id);
|
||||
const character = asString(params.character);
|
||||
const text = asString(params.text);
|
||||
const chatType = asNumber(params.chat_type) || 1;
|
||||
if (!groupId || !character || !text) {
|
||||
return failedResponse(RETCODE.BAD_REQUEST, 'group_id, character and text are required');
|
||||
}
|
||||
try {
|
||||
const node = await ctx.bridge.fetchAiVoice(groupId, character, text, chatType);
|
||||
const url = await ctx.bridge.fetchGroupPttUrlByNode(groupId, node);
|
||||
return okResponse(url);
|
||||
} catch (err) {
|
||||
return failedResponse(RETCODE.ACTION_FAILED, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
});
|
||||
|
||||
// napcat's send_group_ai_record is a side-effect-only call: invoking
|
||||
// fetchAiVoice publishes the voice into the group; the returned
|
||||
// message_id is always 0 because the oidb call doesn't echo one back.
|
||||
h.registerAction('send_group_ai_record', async (params) => {
|
||||
const groupId = asNumber(params.group_id);
|
||||
const character = asString(params.character);
|
||||
const text = asString(params.text);
|
||||
const chatType = asNumber(params.chat_type) || 1;
|
||||
if (!groupId || !character || !text) {
|
||||
return failedResponse(RETCODE.BAD_REQUEST, 'group_id, character and text are required');
|
||||
}
|
||||
try {
|
||||
await ctx.bridge.fetchAiVoice(groupId, character, text, chatType);
|
||||
return okResponse({ message_id: 0 });
|
||||
} catch (err) {
|
||||
return failedResponse(RETCODE.ACTION_FAILED, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function hexToBytes(hex: string): Uint8Array {
|
||||
const out = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
out[i] = parseInt(hex.substr(i * 2, 2), 16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function bytesToHex(buf: Buffer | Uint8Array): string {
|
||||
const arr = buf instanceof Buffer ? buf : Buffer.from(buf);
|
||||
return arr.toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -103,7 +103,20 @@ export function register(h: ApiHandler, ctx: ApiActionContext): void {
|
||||
return okResponse();
|
||||
});
|
||||
|
||||
h.registerAction('set_group_portrait', async () => {
|
||||
return failedResponse(RETCODE.ACTION_FAILED, 'not yet implemented');
|
||||
// Highway upload with cmdId 3000 + GroupAvatarExtra (Lagrange protocol).
|
||||
// `file` accepts everything bridge.setGroupAvatar's underlying
|
||||
// loadBinarySource handles: local paths, file:// URLs, http(s) URLs,
|
||||
// and base64:// data URIs — same surface as set_qq_avatar.
|
||||
h.registerAction('set_group_portrait', async (params) => {
|
||||
const groupId = asNumber(params.group_id);
|
||||
const file = asString(params.file);
|
||||
if (!groupId) return failedResponse(RETCODE.BAD_REQUEST, 'group_id is required');
|
||||
if (!file) return failedResponse(RETCODE.BAD_REQUEST, 'file is required');
|
||||
try {
|
||||
await ctx.bridge.setGroupAvatar(groupId, file);
|
||||
return okResponse();
|
||||
} catch (err) {
|
||||
return failedResponse(RETCODE.ACTION_FAILED, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const HEX_CHARS = '0123456789abcdef';
|
||||
const HEX_CHARS_UPPER = '0123456789ABCDEF';
|
||||
|
||||
export function toHex(data: Uint8Array | Buffer): string {
|
||||
let h = '';
|
||||
@@ -9,6 +10,15 @@ export function toHex(data: Uint8Array | Buffer): string {
|
||||
return h;
|
||||
}
|
||||
|
||||
export function toHexUpper(data: Uint8Array | Buffer): string {
|
||||
let h = '';
|
||||
for (const b of data) {
|
||||
h += HEX_CHARS_UPPER[b >> 4];
|
||||
h += HEX_CHARS_UPPER[b & 0xf];
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
export function hexPreview(data: Uint8Array | Buffer, maxBytes = 64): string {
|
||||
const limit = Math.max(0, Math.min(maxBytes, data.length));
|
||||
const head = toHex(data.subarray(0, limit));
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Same stub-the-oidb-runner pattern as actions/group-admin.test.ts —
|
||||
// we don't care about wire bytes, just that each function targets the
|
||||
// right cmd / subCmd / schema.
|
||||
vi.mock('../../src/bridge/bridge-oidb', () => ({
|
||||
runOidb: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
import * as oidb from '../../src/bridge/bridge-oidb';
|
||||
import * as extras from '../../src/bridge/actions/extras';
|
||||
import * as schemas from '../../src/bridge/proto/oidb-action';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
describe('actions/extras / group todo (0xF90)', () => {
|
||||
beforeEach(() => vi.mocked(oidb.runOidb).mockClear());
|
||||
|
||||
it.each([
|
||||
['setGroupTodo', extras.setGroupTodo, 'OidbSvcTrpcTcp.0xf90_1', 1],
|
||||
['completeGroupTodo', extras.completeGroupTodo, 'OidbSvcTrpcTcp.0xf90_2', 2],
|
||||
['cancelGroupTodo', extras.cancelGroupTodo, 'OidbSvcTrpcTcp.0xf90_3', 3],
|
||||
] as const)('%s dispatches the right subCmd with shared body', async (_name, fn, cmd, subCmd) => {
|
||||
const bridge = mockBridge();
|
||||
await fn(bridge as any, 12345, 9876543210n);
|
||||
const call = vi.mocked(oidb.runOidb).mock.calls[0]![1];
|
||||
expect(call.cmd).toBe(cmd);
|
||||
expect(call.oidbCmd).toBe(0xF90);
|
||||
expect(call.subCmd).toBe(subCmd);
|
||||
expect(call.request.schema).toBe(schemas.OidbGroupTodoSchema);
|
||||
expect(call.request.value).toEqual({ groupUin: 12345, msgSeq: 9876543210n });
|
||||
});
|
||||
});
|
||||
|
||||
describe('actions/extras / getStrangerStatus (0xFE1_2)', () => {
|
||||
beforeEach(() => vi.mocked(oidb.runOidb).mockClear());
|
||||
|
||||
it('issues the FE1_2 query with key=27372', async () => {
|
||||
const bridge = mockBridge();
|
||||
vi.mocked(oidb.runOidb).mockResolvedValueOnce({ data: { status: { value: 5n } } } as any);
|
||||
await extras.getStrangerStatus(bridge as any, 100200);
|
||||
const call = vi.mocked(oidb.runOidb).mock.calls[0]![1];
|
||||
expect(call.cmd).toBe('OidbSvcTrpcTcp.0xfe1_2');
|
||||
expect(call.oidbCmd).toBe(0xFE1);
|
||||
expect(call.subCmd).toBe(2);
|
||||
expect(call.request.value).toMatchObject({ uin: 100200, key: [{ key: 27372 }] });
|
||||
});
|
||||
|
||||
it('low-band values (≤10) map to status*10 with ext_status=0', async () => {
|
||||
const bridge = mockBridge();
|
||||
vi.mocked(oidb.runOidb).mockResolvedValueOnce({ data: { status: { value: 7n } } } as any);
|
||||
expect(await extras.getStrangerStatus(bridge as any, 1)).toEqual({ status: 70, ext_status: 0 });
|
||||
});
|
||||
|
||||
it('high-band values decompose into the (0xff00 + (>>16 & 0xff)) status word', async () => {
|
||||
const bridge = mockBridge();
|
||||
// value 0x42F100: bits 8-15 (the 0xff00 mask) = 0xF100;
|
||||
// bits 16-23 (>>16 & 0xff) = 0x42.
|
||||
// ext_status = 0xF100 + 0x42 = 0xF142.
|
||||
vi.mocked(oidb.runOidb).mockResolvedValueOnce({ data: { status: { value: 0x42F100n } } } as any);
|
||||
const status = await extras.getStrangerStatus(bridge as any, 1);
|
||||
expect(status).toEqual({ status: 10, ext_status: 0xF142 });
|
||||
});
|
||||
|
||||
it('returns null when the runner throws (transport error)', async () => {
|
||||
const bridge = mockBridge();
|
||||
vi.mocked(oidb.runOidb).mockRejectedValueOnce(new Error('boom'));
|
||||
expect(await extras.getStrangerStatus(bridge as any, 1)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the server omits the status field', async () => {
|
||||
const bridge = mockBridge();
|
||||
vi.mocked(oidb.runOidb).mockResolvedValueOnce({} as any);
|
||||
expect(await extras.getStrangerStatus(bridge as any, 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('actions/extras / AI voice (0x929D / 0x929B)', () => {
|
||||
beforeEach(() => vi.mocked(oidb.runOidb).mockClear());
|
||||
|
||||
it('fetchAiVoiceList sends 0x929D_0 and returns server content verbatim', async () => {
|
||||
const bridge = mockBridge();
|
||||
const fake = [
|
||||
{ category: 'cute', voices: [{ voiceId: 'v1', voiceDisplayName: 'V1', voiceExampleUrl: 'http://x' }] },
|
||||
];
|
||||
vi.mocked(oidb.runOidb).mockResolvedValueOnce({ content: fake } as any);
|
||||
const out = await extras.fetchAiVoiceList(bridge as any, 4242, extras.AiVoiceChatType.Sound);
|
||||
expect(out).toEqual(fake);
|
||||
const call = vi.mocked(oidb.runOidb).mock.calls[0]![1];
|
||||
expect(call.cmd).toBe('OidbSvcTrpcTcp.0x929d_0');
|
||||
expect(call.request.value).toEqual({ groupUin: 4242, chatType: 1 });
|
||||
});
|
||||
|
||||
it('fetchAiVoiceList returns [] when server replies with no content', async () => {
|
||||
const bridge = mockBridge();
|
||||
vi.mocked(oidb.runOidb).mockResolvedValueOnce({} as any);
|
||||
const out = await extras.fetchAiVoiceList(bridge as any, 1, 1 as any);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it('fetchAiVoice retries while msgInfo is empty, returns the first IndexNode it sees', async () => {
|
||||
const bridge = mockBridge();
|
||||
const node = { fileUuid: 'uuid-1', subType: 0 };
|
||||
vi.mocked(oidb.runOidb)
|
||||
.mockResolvedValueOnce({ statusCode: 2 } as any)
|
||||
.mockResolvedValueOnce({ msgInfo: { msgInfoBody: [] } } as any)
|
||||
.mockResolvedValueOnce({ msgInfo: { msgInfoBody: [{ index: node }] } } as any);
|
||||
const out = await extras.fetchAiVoice(bridge as any, 100, 'voice-id', 'hi', extras.AiVoiceChatType.Sound);
|
||||
expect(out).toBe(node);
|
||||
expect(vi.mocked(oidb.runOidb)).toHaveBeenCalledTimes(3);
|
||||
const call = vi.mocked(oidb.runOidb).mock.calls[0]![1];
|
||||
expect(call.cmd).toBe('OidbSvcTrpcTcp.0x929b_0');
|
||||
expect(call.request.value).toMatchObject({
|
||||
groupUin: 100, voiceId: 'voice-id', text: 'hi', chatType: 1,
|
||||
});
|
||||
// sessionId is randomized but must be a uint32.
|
||||
expect((call.request.value as any).session.sessionId).toBeTypeOf('number');
|
||||
expect((call.request.value as any).session.sessionId).toBeGreaterThanOrEqual(0);
|
||||
expect((call.request.value as any).session.sessionId).toBeLessThanOrEqual(0xFFFFFFFF);
|
||||
});
|
||||
|
||||
it('fetchAiVoice throws after exhausting the retry budget', async () => {
|
||||
const bridge = mockBridge();
|
||||
vi.mocked(oidb.runOidb).mockResolvedValue({ statusCode: 1 } as any);
|
||||
await expect(extras.fetchAiVoice(bridge as any, 1, 'v', 't', 1 as any, 3)).rejects.toThrow(
|
||||
/AI voice synthesis did not complete/,
|
||||
);
|
||||
expect(vi.mocked(oidb.runOidb)).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('fetchAiVoice keeps the same sessionId across retries', async () => {
|
||||
const bridge = mockBridge();
|
||||
const node = { fileUuid: 'uuid', subType: 0 };
|
||||
vi.mocked(oidb.runOidb)
|
||||
.mockResolvedValueOnce({} as any)
|
||||
.mockResolvedValueOnce({ msgInfo: { msgInfoBody: [{ index: node }] } } as any);
|
||||
await extras.fetchAiVoice(bridge as any, 1, 'v', 't', 1 as any);
|
||||
const first = (vi.mocked(oidb.runOidb).mock.calls[0]![1].request.value as any).session.sessionId;
|
||||
const second = (vi.mocked(oidb.runOidb).mock.calls[1]![1].request.value as any).session.sessionId;
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,54 @@ describe('actions/profile', () => {
|
||||
expect(serviceCmd).toBe('trpc.qq_new_tech.status_svc.StatusService.SetStatus');
|
||||
});
|
||||
|
||||
it('setOnlineStatus does NOT include the customExt field 4 (varint 0x22 absent in body)', async () => {
|
||||
const bridge = mockBridge();
|
||||
await profile.setOnlineStatus(bridge as any, 11, 0, 100);
|
||||
const [, body] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
// proto field 4 length-delimited tag = (4 << 3) | 2 = 0x22. The
|
||||
// body should not contain that byte; if encoder ever leaks an
|
||||
// empty customExt it'd appear here.
|
||||
expect(Buffer.from(body).includes(0x22)).toBe(false);
|
||||
});
|
||||
|
||||
it('setDiyOnlineStatus hardcodes status=10 / extStatus=2000 and packs faceId+wording+faceType into customExt', async () => {
|
||||
const bridge = mockBridge();
|
||||
await profile.setDiyOnlineStatus(bridge as any, 1234, '摸鱼中', 2);
|
||||
const [serviceCmd, body] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(serviceCmd).toBe('trpc.qq_new_tech.status_svc.StatusService.SetStatus');
|
||||
// Decode the wire bytes back through the same schema to assert
|
||||
// every field landed in the right place.
|
||||
const { protoDecode } = await import('../../src/protobuf/decode');
|
||||
const { SetStatusReqSchema } = await import('../../src/bridge/proto/oidb-action');
|
||||
const decoded = protoDecode(body as Uint8Array, SetStatusReqSchema);
|
||||
expect(decoded).toMatchObject({
|
||||
status: 10,
|
||||
extStatus: 2000,
|
||||
batteryStatus: 0,
|
||||
customExt: { faceId: 1234, text: '摸鱼中', faceType: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('setDiyOnlineStatus surfaces server errors via the same path as setOnlineStatus', async () => {
|
||||
const bridge = mockBridge();
|
||||
// Build a response that decodes to errCode != 0.
|
||||
const { protoEncode } = await import('../../src/protobuf/decode');
|
||||
const { SetStatusRespSchema } = await import('../../src/bridge/proto/oidb-action');
|
||||
const respBuf = Buffer.from(protoEncode({ errCode: 1, errMsg: 'denied' }, SetStatusRespSchema));
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: respBuf,
|
||||
} as any);
|
||||
await expect(profile.setDiyOnlineStatus(bridge as any, 1, 't', 1)).rejects.toThrow(/denied/);
|
||||
});
|
||||
|
||||
it('setDiyOnlineStatus rejects when the transport itself fails', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: false, gotResponse: false, errorCode: -1, errorMessage: 'pipe closed', responseData: null,
|
||||
} as any);
|
||||
await expect(profile.setDiyOnlineStatus(bridge as any, 1, 't', 1)).rejects.toThrow(/pipe closed/);
|
||||
});
|
||||
|
||||
it('setProfile is a no-op when both arguments are undefined', async () => {
|
||||
const bridge = mockBridge();
|
||||
await profile.setProfile(bridge as any);
|
||||
@@ -69,8 +117,40 @@ describe('actions/profile', () => {
|
||||
await profile.setAvatar(bridge as any, '/some/avatar.png');
|
||||
expect(highwayClient.fetchHighwaySession).toHaveBeenCalledOnce();
|
||||
expect(highwayClient.uploadHighwayHttp).toHaveBeenCalledOnce();
|
||||
const [, , cmdId] = vi.mocked(highwayClient.uploadHighwayHttp).mock.calls[0]!;
|
||||
const [, , cmdId, , , extend] = vi.mocked(highwayClient.uploadHighwayHttp).mock.calls[0]!;
|
||||
expect(cmdId).toBe(90);
|
||||
expect((extend as Uint8Array).length).toBe(0); // personal avatar has no extra payload
|
||||
});
|
||||
|
||||
it('setGroupAvatar uses cmdId 3000 and packs the Lagrange GroupAvatarExtra constants', async () => {
|
||||
const bridge = mockBridge();
|
||||
await profile.setGroupAvatar(bridge as any, 12345, '/some/group-avatar.png');
|
||||
expect(highwayClient.fetchHighwaySession).toHaveBeenCalledOnce();
|
||||
expect(highwayClient.uploadHighwayHttp).toHaveBeenCalledOnce();
|
||||
const [, , cmdId, , , extend] = vi.mocked(highwayClient.uploadHighwayHttp).mock.calls[0]!;
|
||||
expect(cmdId).toBe(3000);
|
||||
// Decode the extra blob back through the schema and assert every
|
||||
// protocol-prescribed constant lands where it should.
|
||||
const { protoDecode } = await import('../../src/protobuf/decode');
|
||||
const { GroupAvatarExtraSchema } = await import('../../src/bridge/proto/oidb-action');
|
||||
const decoded = protoDecode(extend as Uint8Array, GroupAvatarExtraSchema);
|
||||
expect(decoded).toEqual({
|
||||
type: 101,
|
||||
groupUin: 12345,
|
||||
field3: { field1: 1 },
|
||||
field5: 3,
|
||||
field6: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('setGroupAvatar rejects an empty file before hitting highway', async () => {
|
||||
const bridge = mockBridge();
|
||||
const { loadBinarySource } = await import('../../src/bridge/highway/utils');
|
||||
vi.mocked(loadBinarySource).mockResolvedValueOnce({
|
||||
bytes: new Uint8Array(0), fileName: 'empty.png',
|
||||
} as any);
|
||||
await expect(profile.setGroupAvatar(bridge as any, 1, 'empty.png')).rejects.toThrow(/empty/);
|
||||
expect(highwayClient.fetchHighwaySession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getProfileLike (self): resolves self UID, returns formatted favorite + vote info', async () => {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Regression test for the c2c-vs-group businessType asymmetry that made
|
||||
// private-chat video / record sends bounce with `send private message
|
||||
// rejected: result=79`. The receive-side decoder
|
||||
// (msg-push/rich-body-decoder.ts) explicitly treats the businessType
|
||||
// pairs as:
|
||||
//
|
||||
// image : c2c=10, group=20
|
||||
// video : c2c=11, group=21
|
||||
// record : c2c=12, group=22
|
||||
//
|
||||
// `makeImageElem` always honoured this split (`isGroup ? 20 : 10`).
|
||||
// `makeVideoElem` and `makePttElem` used to hardcode the group value
|
||||
// for both scenes, so any c2c video / private voice send arrived at the
|
||||
// QQ NT server with a businessType the c2c routing path did not
|
||||
// recognise and got rejected with result=79.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../src/bridge/highway/image-upload', () => ({
|
||||
uploadImageMsgInfo: vi.fn(async () => new Uint8Array([7, 8, 9])),
|
||||
}));
|
||||
vi.mock('../src/bridge/highway/ptt-upload', () => ({
|
||||
uploadPttMsgInfo: vi.fn(async () => new Uint8Array([4, 5, 6])),
|
||||
}));
|
||||
vi.mock('../src/bridge/highway/video-upload', () => ({
|
||||
uploadVideoMsgInfo: vi.fn(async () => new Uint8Array([1, 2, 3])),
|
||||
}));
|
||||
|
||||
import { buildSendElems } from '../src/bridge/element-builder';
|
||||
|
||||
const fakeBridge = {} as any;
|
||||
|
||||
function commonElem(elem: any): { serviceType: number; businessType: number; pbElem: Uint8Array } {
|
||||
return elem.commonElem;
|
||||
}
|
||||
|
||||
describe('element-builder / commonElem.businessType per scene', () => {
|
||||
describe('image', () => {
|
||||
it('c2c → businessType 10', async () => {
|
||||
const [elem] = await buildSendElems(
|
||||
[{ type: 'image', url: 'file:///tmp/a.png' } as any],
|
||||
{ bridge: fakeBridge, userUid: 'u_peer' },
|
||||
);
|
||||
expect(commonElem(elem).serviceType).toBe(48);
|
||||
expect(commonElem(elem).businessType).toBe(10);
|
||||
});
|
||||
|
||||
it('group → businessType 20', async () => {
|
||||
const [elem] = await buildSendElems(
|
||||
[{ type: 'image', url: 'file:///tmp/a.png' } as any],
|
||||
{ bridge: fakeBridge, groupId: 12345 },
|
||||
);
|
||||
expect(commonElem(elem).businessType).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('video', () => {
|
||||
it('c2c → businessType 11 (regression: was 21, server returned result=79)', async () => {
|
||||
const [elem] = await buildSendElems(
|
||||
[{ type: 'video', url: 'file:///tmp/clip.mp4' } as any],
|
||||
{ bridge: fakeBridge, userUid: 'u_peer' },
|
||||
);
|
||||
expect(commonElem(elem).serviceType).toBe(48);
|
||||
expect(commonElem(elem).businessType).toBe(11);
|
||||
});
|
||||
|
||||
it('group → businessType 21', async () => {
|
||||
const [elem] = await buildSendElems(
|
||||
[{ type: 'video', url: 'file:///tmp/clip.mp4' } as any],
|
||||
{ bridge: fakeBridge, groupId: 12345 },
|
||||
);
|
||||
expect(commonElem(elem).businessType).toBe(21);
|
||||
});
|
||||
});
|
||||
|
||||
describe('record', () => {
|
||||
it('c2c → businessType 12 (regression: was 22)', async () => {
|
||||
const [elem] = await buildSendElems(
|
||||
[{ type: 'record', url: 'file:///tmp/voice.amr' } as any],
|
||||
{ bridge: fakeBridge, userUid: 'u_peer' },
|
||||
);
|
||||
expect(commonElem(elem).serviceType).toBe(48);
|
||||
expect(commonElem(elem).businessType).toBe(12);
|
||||
});
|
||||
|
||||
it('group → businessType 22', async () => {
|
||||
const [elem] = await buildSendElems(
|
||||
[{ type: 'record', url: 'file:///tmp/voice.amr' } as any],
|
||||
{ bridge: fakeBridge, groupId: 12345 },
|
||||
);
|
||||
expect(commonElem(elem).businessType).toBe(22);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// E2E for Docker "QQ never logs in + pipe keeps reconnecting" scenario.
|
||||
//
|
||||
// Drives the real PipeWatcher + HookManager through repeated same-PID
|
||||
// pipe up/down cycles via tickNow() and asserts the BridgeManager mock
|
||||
// is never touched (no login → no Bridge / OneBotInstance / Identity).
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { EventEmitter } from 'events';
|
||||
import { HookManager } from '../src/hook/hook-manager';
|
||||
import { PipeWatcher } from '../src/hook/pipe-watcher';
|
||||
import type { ManualMapHandle } from '../src/hook/injector';
|
||||
import type { BridgeManager } from '../src/bridge/manager';
|
||||
import type { QqHookClient } from '../src/hook/qq-hook-client';
|
||||
|
||||
const DUMMY_HANDLE: ManualMapHandle = { base: 0n, entry: 0n, exceptionTable: 0n, size: 0 };
|
||||
const flush = () => new Promise<void>(r => setImmediate(r));
|
||||
|
||||
class FakeClient extends EventEmitter {
|
||||
isClosed = false;
|
||||
isLoggedIn = false;
|
||||
async connectAll(_opts: { recv: boolean }) {
|
||||
if (this.isClosed) throw new Error('client is closed');
|
||||
}
|
||||
getLoginState() { return { loggedIn: false, uin: '0', uinNumber: 0n }; }
|
||||
close() {
|
||||
if (this.isClosed) return;
|
||||
this.isClosed = true;
|
||||
this.emit('close');
|
||||
}
|
||||
}
|
||||
|
||||
describe('HookManager — Docker reconnect-without-login leak guard', () => {
|
||||
it('after N pipe up/down cycles: no Bridge calls, no client accumulation', async () => {
|
||||
const PID = 61;
|
||||
const liveSet = new Set<number>([PID]);
|
||||
const clients: FakeClient[] = [];
|
||||
|
||||
const pipeWatcher = new PipeWatcher({
|
||||
listProcesses: () => [{ pid: PID, name: 'qq', path: '' }],
|
||||
listLivePipes: async () => new Set(liveSet),
|
||||
intervalMs: 60_000, // manual ticks only
|
||||
});
|
||||
|
||||
const bridgeManager = {
|
||||
onPacket: vi.fn(),
|
||||
onHookLogin: vi.fn(),
|
||||
onPidDisconnected: vi.fn(),
|
||||
} as unknown as BridgeManager;
|
||||
|
||||
const manager = new HookManager({
|
||||
bridgeManager,
|
||||
pipeWatcher,
|
||||
injector: {
|
||||
inject: vi.fn(() => ({ method: 'loadModuleManual' as const, handle: DUMMY_HANDLE })),
|
||||
unload: vi.fn(),
|
||||
},
|
||||
makeClient: () => {
|
||||
const c = new FakeClient();
|
||||
clients.push(c);
|
||||
return c as unknown as QqHookClient;
|
||||
},
|
||||
autoLoadOnDiscovery: true,
|
||||
listProcesses: () => [{ pid: PID, name: 'qq', path: '' }],
|
||||
});
|
||||
|
||||
// Initial discovery + first connect.
|
||||
await pipeWatcher.start();
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
// Drive 50 pipe up/down cycles with the QQ process staying alive but
|
||||
// the pipe (= QQ control socket) bouncing every "tick".
|
||||
const CYCLES = 50;
|
||||
for (let i = 0; i < CYCLES; i++) {
|
||||
liveSet.delete(PID);
|
||||
await pipeWatcher.tickNow();
|
||||
await flush();
|
||||
liveSet.add(PID);
|
||||
await pipeWatcher.tickNow();
|
||||
await flush();
|
||||
}
|
||||
|
||||
// The "QQ never logs in" hypothesis: BridgeManager should never have
|
||||
// been told about a login or a disconnect.
|
||||
expect(bridgeManager.onHookLogin).not.toHaveBeenCalled();
|
||||
expect(bridgeManager.onPidDisconnected).not.toHaveBeenCalled();
|
||||
expect(bridgeManager.onPacket).not.toHaveBeenCalled();
|
||||
|
||||
// Hook layer should hold at most one live client.
|
||||
const alive = clients.filter(c => !c.isClosed);
|
||||
expect(alive.length, `live clients after ${CYCLES} cycles: ${alive.length}`).toBeLessThanOrEqual(1);
|
||||
|
||||
// Total clients minted = roughly one per up cycle. Sanity bound:
|
||||
// we must have actually exercised the reconnect path.
|
||||
expect(clients.length).toBeGreaterThan(1);
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
// Regression test for the Docker "QQ-never-logs-in + pipe keeps reconnecting" leak hypothesis.
|
||||
//
|
||||
// User-reported pattern (Docker, idle login screen):
|
||||
// 02:41:23 [Hook] pipe connected: PID=61
|
||||
// 04:37:55 [Hook] pipe connected: PID=61 (same PID, ~2h later)
|
||||
// 04:40:44 [Hook] pipe connected: PID=61
|
||||
// 04:43:48 [Hook] pipe connected: PID=61 → eventual OOM-kill
|
||||
//
|
||||
// Hypothesis (H2): every onPipeDown → onPipeUp cycle leaks the prior
|
||||
// QqHookClient (listeners or socket-ref keeping it pinned in memory),
|
||||
// so repeated reconnect drives unbounded growth.
|
||||
//
|
||||
// This test drives N up/down cycles against a HookSession with a FakeClient
|
||||
// factory and asserts:
|
||||
// - at most one client is alive at any point
|
||||
// - every prior client's EventEmitter listener count drops to 0 after teardown
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { EventEmitter } from 'events';
|
||||
import { HookSession } from '../src/hook/hook-session';
|
||||
import type { ManualMapHandle } from '../src/hook/injector';
|
||||
import type { QqHookClient } from '../src/hook/qq-hook-client';
|
||||
|
||||
const DUMMY_HANDLE: ManualMapHandle = { base: 0n, entry: 0n, exceptionTable: 0n, size: 0 };
|
||||
|
||||
class FakeClient extends EventEmitter {
|
||||
isClosed = false;
|
||||
isLoggedIn = false;
|
||||
private loginState = { loggedIn: false, uin: '0', uinNumber: 0n };
|
||||
|
||||
async connectAll(_opts: { recv: boolean }): Promise<void> {
|
||||
if (this.isClosed) throw new Error('client is closed');
|
||||
}
|
||||
getLoginState() { return { ...this.loginState }; }
|
||||
close(): void {
|
||||
if (this.isClosed) return;
|
||||
this.isClosed = true;
|
||||
this.emit('close');
|
||||
}
|
||||
}
|
||||
|
||||
function totalListenerCount(emitter: EventEmitter): number {
|
||||
return emitter.eventNames().reduce((n, evt) => n + emitter.listenerCount(evt), 0);
|
||||
}
|
||||
|
||||
const flush = () => new Promise<void>(r => setImmediate(r));
|
||||
|
||||
describe('HookSession — pipe reconnect with QQ never logging in', () => {
|
||||
it('does not accumulate live FakeClient instances across N pipe up/down cycles', async () => {
|
||||
let pipeLive = true;
|
||||
const clients: FakeClient[] = [];
|
||||
const session = new HookSession(61, {
|
||||
injector: {
|
||||
inject: vi.fn(() => ({ method: 'loadModuleManual' as const, handle: DUMMY_HANDLE })),
|
||||
unload: vi.fn(),
|
||||
},
|
||||
makeClient: () => {
|
||||
const c = new FakeClient();
|
||||
clients.push(c);
|
||||
return c as unknown as QqHookClient;
|
||||
},
|
||||
pipeWatcher: { isPipeLive: () => pipeLive },
|
||||
});
|
||||
|
||||
await session.load();
|
||||
session.onPipeUp();
|
||||
await flush();
|
||||
|
||||
const CYCLES = 50;
|
||||
for (let i = 0; i < CYCLES; i++) {
|
||||
pipeLive = false;
|
||||
session.onPipeDown();
|
||||
await flush();
|
||||
pipeLive = true;
|
||||
session.onPipeUp();
|
||||
await flush();
|
||||
}
|
||||
|
||||
const alive = clients.filter(c => !c.isClosed);
|
||||
expect.soft(clients.length).toBeGreaterThan(1);
|
||||
expect(alive.length, `expected ≤1 live client after ${CYCLES} reconnects, got ${alive.length}`)
|
||||
.toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('drops all listeners from each torn-down client', async () => {
|
||||
let pipeLive = true;
|
||||
const clients: FakeClient[] = [];
|
||||
const session = new HookSession(61, {
|
||||
injector: {
|
||||
inject: vi.fn(() => ({ method: 'loadModuleManual' as const, handle: DUMMY_HANDLE })),
|
||||
unload: vi.fn(),
|
||||
},
|
||||
makeClient: () => {
|
||||
const c = new FakeClient();
|
||||
clients.push(c);
|
||||
return c as unknown as QqHookClient;
|
||||
},
|
||||
pipeWatcher: { isPipeLive: () => pipeLive },
|
||||
});
|
||||
|
||||
await session.load();
|
||||
session.onPipeUp();
|
||||
await flush();
|
||||
|
||||
const CYCLES = 20;
|
||||
for (let i = 0; i < CYCLES; i++) {
|
||||
pipeLive = false;
|
||||
session.onPipeDown();
|
||||
await flush();
|
||||
pipeLive = true;
|
||||
session.onPipeUp();
|
||||
await flush();
|
||||
}
|
||||
|
||||
// Every client except the current one should have 0 listeners attached.
|
||||
// (HookSession.tearDownClient does client.removeAllListeners().)
|
||||
for (let i = 0; i < clients.length - 1; i++) {
|
||||
const lc = totalListenerCount(clients[i]);
|
||||
expect(lc, `client #${i} of ${clients.length} still has ${lc} listeners after teardown`)
|
||||
.toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseMsgPush, MSG_PUSH_CMD } from '../src/bridge/msg-push-handler';
|
||||
import { parseMsgPush, MSG_PUSH_CMD } from '../src/bridge/msg-push';
|
||||
import { IdentityService } from '../src/bridge/identity-service';
|
||||
import type { GroupMemberInfo, QQGroupInfo } from '../src/bridge/qq-info';
|
||||
import { PushMsgSchema } from '../src/bridge/proto/message';
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
// End-to-end OneBot action tests for the napcat-parity surface added in
|
||||
// Tier 1 + Tier 2: send_packet, bot_exit, nc_*, group-todo, AI voice,
|
||||
// and the rewired ignored-notifies family. We drive everything through
|
||||
// the public `ApiHandler.handle()` entry point so the wiring (including
|
||||
// param coercion, retcode shape) is part of what's under test.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ApiHandler, type ApiActionContext } from '../../src/onebot/api-handler';
|
||||
import type { BridgeInterface } from '../../src/bridge/bridge-interface';
|
||||
import type { GroupRequestInfo } from '../../src/bridge/qq-info';
|
||||
import type { MessageMeta } from '../../src/onebot/types';
|
||||
|
||||
function fakeMeta(overrides: Partial<MessageMeta> = {}): MessageMeta {
|
||||
return {
|
||||
isGroup: true,
|
||||
targetId: 100,
|
||||
sequence: 555,
|
||||
eventName: 'group_message',
|
||||
clientSequence: 0,
|
||||
random: 0,
|
||||
timestamp: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a BridgeInterface stub that throws on any method we haven't
|
||||
* pre-stubbed for the test. Same pattern as contact-actions.test.ts —
|
||||
* keeps tests honest about which surface they actually need.
|
||||
*/
|
||||
function fakeBridge(overrides: Partial<BridgeInterface> = {}): BridgeInterface {
|
||||
return new Proxy(overrides as BridgeInterface, {
|
||||
get(target, prop) {
|
||||
if (prop in target) return (target as any)[prop];
|
||||
throw new Error(`fakeBridge: '${String(prop)}' was not stubbed`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the minimum ApiActionContext needed for the tested actions.
|
||||
* Anything not stubbed throws on access — keeps the dependency surface
|
||||
* explicit. Only fields the tested actions read are populated.
|
||||
*/
|
||||
function fakeCtx(bridge: BridgeInterface, overrides: Partial<ApiActionContext> = {}): ApiActionContext {
|
||||
const base = {
|
||||
bridge,
|
||||
getMessageMeta: () => null,
|
||||
getMessage: () => null,
|
||||
getLoginInfo: () => ({ userId: 1, nickname: '' }),
|
||||
isOnline: () => true,
|
||||
canSendImage: () => true,
|
||||
canSendRecord: () => true,
|
||||
getDownloadRKeys: async () => [],
|
||||
...overrides,
|
||||
};
|
||||
return new Proxy(base as ApiActionContext, {
|
||||
get(target, prop) {
|
||||
if (prop in target) return (target as any)[prop];
|
||||
throw new Error(`fakeCtx: '${String(prop)}' was not stubbed`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function makeHandler(ctx: ApiActionContext): ApiHandler {
|
||||
return new ApiHandler(ctx);
|
||||
}
|
||||
|
||||
// ─── Tier 1: send_packet / .send_packet ───
|
||||
|
||||
describe('extended-actions / send_packet', () => {
|
||||
it('hex-decodes data, calls Bridge.sendRawPacket, hex-encodes the response', async () => {
|
||||
const sendRawPacket: BridgeInterface['sendRawPacket'] = vi.fn(async () => ({
|
||||
success: true, gotResponse: true, errorCode: 0, errorMessage: '',
|
||||
responseData: Buffer.from([0xDE, 0xAD, 0xBE, 0xEF]),
|
||||
}));
|
||||
const bridge = fakeBridge({ sendRawPacket });
|
||||
const h = makeHandler(fakeCtx(bridge));
|
||||
const res = await h.handle('send_packet', { cmd: 'Some.Cmd', data: 'cafebabe', rsp: true });
|
||||
const spy = vi.mocked(sendRawPacket);
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
const sentBody = spy.mock.calls[0]![1];
|
||||
expect(Buffer.from(sentBody).toString('hex')).toBe('cafebabe');
|
||||
expect(res).toMatchObject({ status: 'ok', retcode: 0, data: 'deadbeef' });
|
||||
});
|
||||
|
||||
it('.send_packet shares the same handler', async () => {
|
||||
const sendRawPacket = vi.fn(async () => ({
|
||||
success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData: Buffer.alloc(0),
|
||||
}));
|
||||
const h = makeHandler(fakeCtx(fakeBridge({ sendRawPacket: sendRawPacket as any })));
|
||||
const res = await h.handle('.send_packet', { cmd: 'C', data: '' });
|
||||
expect(res.status).toBe('ok');
|
||||
expect(sendRawPacket).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('with rsp=false returns null and ignores responseData', async () => {
|
||||
const bridge = fakeBridge({
|
||||
sendRawPacket: (async () => ({
|
||||
success: true, gotResponse: true, errorCode: 0, errorMessage: '',
|
||||
responseData: Buffer.from('00ff', 'hex'),
|
||||
})) as any,
|
||||
});
|
||||
const res = await makeHandler(fakeCtx(bridge)).handle('send_packet', { cmd: 'C', data: '', rsp: false });
|
||||
expect(res).toMatchObject({ status: 'ok', data: null });
|
||||
});
|
||||
|
||||
it('rejects missing cmd', async () => {
|
||||
const bridge = fakeBridge({ sendRawPacket: vi.fn() as any });
|
||||
const res = await makeHandler(fakeCtx(bridge)).handle('send_packet', { cmd: '', data: '' });
|
||||
expect(res).toMatchObject({ status: 'failed', retcode: 1400 });
|
||||
});
|
||||
|
||||
it('rejects malformed hex', async () => {
|
||||
const sendRawPacket = vi.fn();
|
||||
const bridge = fakeBridge({ sendRawPacket: sendRawPacket as any });
|
||||
const res = await makeHandler(fakeCtx(bridge)).handle('send_packet', { cmd: 'C', data: 'ZZZZ' });
|
||||
expect(res).toMatchObject({ status: 'failed', retcode: 1400 });
|
||||
expect(sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects odd-length hex', async () => {
|
||||
const bridge = fakeBridge({ sendRawPacket: vi.fn() as any });
|
||||
const res = await makeHandler(fakeCtx(bridge)).handle('send_packet', { cmd: 'C', data: 'abc' });
|
||||
expect(res).toMatchObject({ status: 'failed', retcode: 1400 });
|
||||
});
|
||||
|
||||
it('propagates wire-level failure as action_failed', async () => {
|
||||
const bridge = fakeBridge({
|
||||
sendRawPacket: (async () => ({
|
||||
success: false, gotResponse: false, errorCode: -1, errorMessage: 'no sender', responseData: null,
|
||||
})) as any,
|
||||
});
|
||||
const res = await makeHandler(fakeCtx(bridge)).handle('send_packet', { cmd: 'C', data: '' });
|
||||
expect(res).toMatchObject({ status: 'failed', retcode: 100, wording: 'no sender' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tier 1: bot_exit ───
|
||||
|
||||
describe('extended-actions / bot_exit', () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation(((_code?: number) => undefined) as any);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns ok immediately, then exits on the deferred timer', async () => {
|
||||
const h = makeHandler(fakeCtx(fakeBridge()));
|
||||
const res = await h.handle('bot_exit', {});
|
||||
expect(res).toMatchObject({ status: 'ok', retcode: 0 });
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(exitSpy).toHaveBeenCalledWith(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tier 1: nc_get_packet_status / nc_get_rkey ───
|
||||
|
||||
describe('extended-actions / nc_get_packet_status', () => {
|
||||
it('reports healthy with no dependency on bridge', async () => {
|
||||
const h = makeHandler(fakeCtx(fakeBridge()));
|
||||
const res = await h.handle('nc_get_packet_status', {});
|
||||
expect(res).toEqual({ status: 'ok', retcode: 0, data: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('extended-actions / nc_get_rkey', () => {
|
||||
it('reuses the same data the get_rkey handler returns', async () => {
|
||||
const ctx = fakeCtx(fakeBridge(), {
|
||||
getDownloadRKeys: async () => [{ rkey: 'abc', type: 1, ttl: 60, create_time: 1 }],
|
||||
});
|
||||
const h = makeHandler(ctx);
|
||||
const a = await h.handle('get_rkey', {});
|
||||
const b = await h.handle('nc_get_rkey', {});
|
||||
expect(b).toEqual(a);
|
||||
expect(b.data).toEqual([{ rkey: 'abc', type: 1, ttl: 60, create_time: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tier 1: group-request ignored / shut list / ignore-add ───
|
||||
|
||||
function fakeFilteredRequest(overrides: Partial<GroupRequestInfo> = {}): GroupRequestInfo {
|
||||
return {
|
||||
groupId: 999,
|
||||
groupName: 'g',
|
||||
targetUid: 'u_t',
|
||||
targetUin: 5555,
|
||||
targetName: 'target',
|
||||
invitorUid: 'u_i',
|
||||
invitorUin: 7777,
|
||||
invitorName: 'inviter',
|
||||
operatorUid: 'u_o',
|
||||
operatorUin: 8888,
|
||||
operatorName: 'op',
|
||||
sequence: 42,
|
||||
state: 1,
|
||||
eventType: 7,
|
||||
comment: 'pls',
|
||||
filtered: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('extended-actions / get_group_ignored_notifies', () => {
|
||||
it('maps filtered fetchGroupRequests(true) into the napcat shape', async () => {
|
||||
const fetchGroupRequests = vi.fn(async (filtered: boolean) =>
|
||||
filtered ? [fakeFilteredRequest()] : []
|
||||
);
|
||||
const bridge = fakeBridge({ fetchGroupRequests: fetchGroupRequests as any });
|
||||
const h = makeHandler(fakeCtx(bridge));
|
||||
const res = await h.handle('get_group_ignored_notifies', {});
|
||||
expect(fetchGroupRequests).toHaveBeenCalledWith(true);
|
||||
expect(res.status).toBe('ok');
|
||||
expect(res.data).toEqual([{
|
||||
group_id: 999,
|
||||
group_name: 'g',
|
||||
request_id: 42,
|
||||
requester_uin: 5555,
|
||||
requester_nick: 'target',
|
||||
message: 'pls',
|
||||
checked: false, // state == 1 → un-checked
|
||||
actor: 8888,
|
||||
invitor_uin: 7777,
|
||||
invitor_nick: 'inviter',
|
||||
flag: '7:999:u_t:filtered',
|
||||
}]);
|
||||
});
|
||||
|
||||
it('returns [] when the fetch throws', async () => {
|
||||
const bridge = fakeBridge({
|
||||
fetchGroupRequests: (async () => { throw new Error('boom'); }) as any,
|
||||
});
|
||||
const res = await makeHandler(fakeCtx(bridge)).handle('get_group_ignored_notifies', {});
|
||||
expect(res).toMatchObject({ status: 'ok', data: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('extended-actions / get_group_ignore_add_request', () => {
|
||||
it('projects the same filtered list into napcat\'s ignore-add-request shape', async () => {
|
||||
const bridge = fakeBridge({
|
||||
fetchGroupRequests: (async () => [fakeFilteredRequest({ state: 2 })]) as any,
|
||||
});
|
||||
const res = await makeHandler(fakeCtx(bridge)).handle('get_group_ignore_add_request', {});
|
||||
expect(res.status).toBe('ok');
|
||||
expect(res.data).toEqual([{
|
||||
request_id: 42,
|
||||
invitor_uin: 7777,
|
||||
invitor_nick: 'inviter',
|
||||
group_id: 999,
|
||||
message: 'pls',
|
||||
group_name: 'g',
|
||||
checked: true, // state == 2 → checked
|
||||
actor: 8888,
|
||||
requester_nick: 'target',
|
||||
}]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extended-actions / get_group_shut_list', () => {
|
||||
it('returns empty list (oidb not yet wrapped)', async () => {
|
||||
const res = await makeHandler(fakeCtx(fakeBridge())).handle('get_group_shut_list', {});
|
||||
expect(res).toEqual({ status: 'ok', retcode: 0, data: [] });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tier 1: delete_group_folder alias ───
|
||||
|
||||
describe('extended-actions / delete_group_folder', () => {
|
||||
it('forwards to bridge.deleteGroupFileFolder', async () => {
|
||||
const deleteGroupFileFolder = vi.fn(async () => {});
|
||||
const bridge = fakeBridge({ deleteGroupFileFolder: deleteGroupFileFolder as any });
|
||||
const res = await makeHandler(fakeCtx(bridge)).handle('delete_group_folder', {
|
||||
group_id: 100, folder_id: 'fid-1',
|
||||
});
|
||||
expect(deleteGroupFileFolder).toHaveBeenCalledWith(100, 'fid-1');
|
||||
expect(res.status).toBe('ok');
|
||||
});
|
||||
|
||||
it('rejects missing fields', async () => {
|
||||
const bridge = fakeBridge({ deleteGroupFileFolder: vi.fn() as any });
|
||||
const r1 = await makeHandler(fakeCtx(bridge)).handle('delete_group_folder', { folder_id: 'x' });
|
||||
const r2 = await makeHandler(fakeCtx(bridge)).handle('delete_group_folder', { group_id: 1 });
|
||||
expect(r1).toMatchObject({ status: 'failed', retcode: 1400 });
|
||||
expect(r2).toMatchObject({ status: 'failed', retcode: 1400 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tier 2: group todo ───
|
||||
|
||||
describe('extended-actions / set_/complete_/cancel_group_todo', () => {
|
||||
it.each([
|
||||
['set_group_todo', 'setGroupTodo'],
|
||||
['complete_group_todo', 'completeGroupTodo'],
|
||||
['cancel_group_todo', 'cancelGroupTodo'],
|
||||
] as const)('%s resolves message meta then calls bridge.%s with the sequence', async (action, method) => {
|
||||
const bridgeMethod = vi.fn(async () => {});
|
||||
const bridge = fakeBridge({ [method]: bridgeMethod } as any);
|
||||
const ctx = fakeCtx(bridge, {
|
||||
getMessageMeta: (id: number) => id === 7 ? fakeMeta({ targetId: 100, sequence: 555 }) : null,
|
||||
});
|
||||
const res = await makeHandler(ctx).handle(action, { group_id: 100, message_id: 7 });
|
||||
expect(res.status).toBe('ok');
|
||||
expect(bridgeMethod).toHaveBeenCalledWith(100, 555n);
|
||||
});
|
||||
|
||||
it('rejects when message meta is missing', async () => {
|
||||
const ctx = fakeCtx(fakeBridge(), { getMessageMeta: () => null });
|
||||
const res = await makeHandler(ctx).handle('set_group_todo', { group_id: 1, message_id: 9999 });
|
||||
expect(res).toMatchObject({ status: 'failed', retcode: 100, wording: 'message not found' });
|
||||
});
|
||||
|
||||
it('rejects when message belongs to a different chat', async () => {
|
||||
const ctx = fakeCtx(fakeBridge(), {
|
||||
getMessageMeta: () => fakeMeta({ targetId: 222 }),
|
||||
});
|
||||
const res = await makeHandler(ctx).handle('set_group_todo', { group_id: 100, message_id: 1 });
|
||||
expect(res).toMatchObject({ status: 'failed', retcode: 100 });
|
||||
});
|
||||
|
||||
it('rejects when message is a private message', async () => {
|
||||
const ctx = fakeCtx(fakeBridge(), {
|
||||
getMessageMeta: () => fakeMeta({ isGroup: false }),
|
||||
});
|
||||
const res = await makeHandler(ctx).handle('set_group_todo', { group_id: 100, message_id: 1 });
|
||||
expect(res).toMatchObject({ status: 'failed', retcode: 100 });
|
||||
});
|
||||
|
||||
it('rejects missing params', async () => {
|
||||
const r1 = await makeHandler(fakeCtx(fakeBridge())).handle('set_group_todo', { message_id: 1 });
|
||||
const r2 = await makeHandler(fakeCtx(fakeBridge())).handle('set_group_todo', { group_id: 1 });
|
||||
expect(r1).toMatchObject({ status: 'failed', retcode: 1400 });
|
||||
expect(r2).toMatchObject({ status: 'failed', retcode: 1400 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tier 2: nc_get_user_status ───
|
||||
|
||||
describe('extended-actions / nc_get_user_status', () => {
|
||||
it('returns whatever bridge.getStrangerStatus reports', async () => {
|
||||
const getStrangerStatus = vi.fn(async () => ({ status: 10, ext_status: 0x1234 }));
|
||||
const bridge = fakeBridge({ getStrangerStatus: getStrangerStatus as any });
|
||||
const res = await makeHandler(fakeCtx(bridge)).handle('nc_get_user_status', { user_id: 999 });
|
||||
expect(getStrangerStatus).toHaveBeenCalledWith(999);
|
||||
expect(res).toMatchObject({ status: 'ok', data: { status: 10, ext_status: 0x1234 } });
|
||||
});
|
||||
|
||||
it('reports action_failed when bridge returns null', async () => {
|
||||
const bridge = fakeBridge({ getStrangerStatus: (async () => null) as any });
|
||||
const res = await makeHandler(fakeCtx(bridge)).handle('nc_get_user_status', { user_id: 1 });
|
||||
expect(res).toMatchObject({ status: 'failed', retcode: 100 });
|
||||
});
|
||||
|
||||
it('rejects missing user_id', async () => {
|
||||
const bridge = fakeBridge({ getStrangerStatus: vi.fn() as any });
|
||||
const res = await makeHandler(fakeCtx(bridge)).handle('nc_get_user_status', {});
|
||||
expect(res).toMatchObject({ status: 'failed', retcode: 1400 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tier 2: AI voice trio ───
|
||||
|
||||
describe('extended-actions / get_ai_characters', () => {
|
||||
it('flattens server categories into {type, characters[]}', async () => {
|
||||
const fetchAiVoiceList = vi.fn(async () => [{
|
||||
category: 'cute',
|
||||
voices: [
|
||||
{ voiceId: 'v1', voiceDisplayName: 'V1', voiceExampleUrl: 'http://a' },
|
||||
{ voiceId: 'v2', voiceDisplayName: 'V2', voiceExampleUrl: 'http://b' },
|
||||
],
|
||||
}]);
|
||||
const bridge = fakeBridge({ fetchAiVoiceList: fetchAiVoiceList as any });
|
||||
const res = await makeHandler(fakeCtx(bridge)).handle('get_ai_characters', {
|
||||
group_id: 100, chat_type: 1,
|
||||
});
|
||||
expect(fetchAiVoiceList).toHaveBeenCalledWith(100, 1);
|
||||
expect(res.data).toEqual([{
|
||||
type: 'cute',
|
||||
characters: [
|
||||
{ character_id: 'v1', character_name: 'V1', preview_url: 'http://a' },
|
||||
{ character_id: 'v2', character_name: 'V2', preview_url: 'http://b' },
|
||||
],
|
||||
}]);
|
||||
});
|
||||
|
||||
it('defaults chat_type to 1 (Sound) when unspecified', async () => {
|
||||
const fetchAiVoiceList = vi.fn(async () => []);
|
||||
const bridge = fakeBridge({ fetchAiVoiceList: fetchAiVoiceList as any });
|
||||
await makeHandler(fakeCtx(bridge)).handle('get_ai_characters', { group_id: 100 });
|
||||
expect(fetchAiVoiceList).toHaveBeenCalledWith(100, 1);
|
||||
});
|
||||
|
||||
it('surfaces bridge errors as action_failed', async () => {
|
||||
const bridge = fakeBridge({
|
||||
fetchAiVoiceList: (async () => { throw new Error('rate limited'); }) as any,
|
||||
});
|
||||
const res = await makeHandler(fakeCtx(bridge)).handle('get_ai_characters', { group_id: 100 });
|
||||
expect(res).toMatchObject({ status: 'failed', retcode: 100, wording: 'rate limited' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('extended-actions / get_ai_record', () => {
|
||||
it('feeds IndexNode from fetchAiVoice into fetchGroupPttUrlByNode and returns the URL', async () => {
|
||||
const node = { fileUuid: 'voice-uuid' };
|
||||
const fetchAiVoice = vi.fn(async () => node);
|
||||
const fetchGroupPttUrlByNode = vi.fn(async () => 'http://voice.silk');
|
||||
const bridge = fakeBridge({
|
||||
fetchAiVoice: fetchAiVoice as any,
|
||||
fetchGroupPttUrlByNode: fetchGroupPttUrlByNode as any,
|
||||
});
|
||||
const res = await makeHandler(fakeCtx(bridge)).handle('get_ai_record', {
|
||||
group_id: 100, character: 'v1', text: 'hello',
|
||||
});
|
||||
expect(fetchAiVoice).toHaveBeenCalledWith(100, 'v1', 'hello', 1);
|
||||
expect(fetchGroupPttUrlByNode).toHaveBeenCalledWith(100, node);
|
||||
expect(res).toMatchObject({ status: 'ok', data: 'http://voice.silk' });
|
||||
});
|
||||
|
||||
it('rejects missing fields', async () => {
|
||||
const r1 = await makeHandler(fakeCtx(fakeBridge())).handle('get_ai_record', { character: 'v', text: 't' });
|
||||
const r2 = await makeHandler(fakeCtx(fakeBridge())).handle('get_ai_record', { group_id: 1, text: 't' });
|
||||
const r3 = await makeHandler(fakeCtx(fakeBridge())).handle('get_ai_record', { group_id: 1, character: 'v' });
|
||||
expect(r1).toMatchObject({ status: 'failed', retcode: 1400 });
|
||||
expect(r2).toMatchObject({ status: 'failed', retcode: 1400 });
|
||||
expect(r3).toMatchObject({ status: 'failed', retcode: 1400 });
|
||||
});
|
||||
|
||||
it('reports action_failed when synthesis exhausts retries', async () => {
|
||||
const bridge = fakeBridge({
|
||||
fetchAiVoice: (async () => { throw new Error('AI voice synthesis did not complete'); }) as any,
|
||||
});
|
||||
const res = await makeHandler(fakeCtx(bridge)).handle('get_ai_record', {
|
||||
group_id: 1, character: 'v', text: 't',
|
||||
});
|
||||
expect(res).toMatchObject({ status: 'failed', retcode: 100 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('extended-actions / send_group_ai_record', () => {
|
||||
it('side-effects fetchAiVoice (no URL fetch) and returns message_id=0', async () => {
|
||||
const fetchAiVoice = vi.fn(async () => ({ fileUuid: 'uuid' }));
|
||||
const fetchGroupPttUrlByNode = vi.fn();
|
||||
const bridge = fakeBridge({
|
||||
fetchAiVoice: fetchAiVoice as any,
|
||||
fetchGroupPttUrlByNode: fetchGroupPttUrlByNode as any,
|
||||
});
|
||||
const res = await makeHandler(fakeCtx(bridge)).handle('send_group_ai_record', {
|
||||
group_id: 100, character: 'v', text: 'hi',
|
||||
});
|
||||
expect(fetchAiVoice).toHaveBeenCalledOnce();
|
||||
expect(fetchGroupPttUrlByNode).not.toHaveBeenCalled();
|
||||
expect(res).toMatchObject({ status: 'ok', data: { message_id: 0 } });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tier 3: set_diy_online_status ───
|
||||
|
||||
describe('extended-actions / set_diy_online_status', () => {
|
||||
it('coerces face_id / face_type from string-or-number and forwards to bridge.setDiyOnlineStatus', async () => {
|
||||
const setDiyOnlineStatus = vi.fn(async () => {});
|
||||
const bridge = fakeBridge({ setDiyOnlineStatus: setDiyOnlineStatus as any });
|
||||
const res = await makeHandler(fakeCtx(bridge)).handle('set_diy_online_status', {
|
||||
face_id: '1234',
|
||||
face_type: '2',
|
||||
wording: '摸鱼中',
|
||||
});
|
||||
expect(res.status).toBe('ok');
|
||||
expect(setDiyOnlineStatus).toHaveBeenCalledWith(1234, '摸鱼中', 2);
|
||||
});
|
||||
|
||||
it('defaults face_type to 1 when omitted, wording to empty string', async () => {
|
||||
const setDiyOnlineStatus = vi.fn(async () => {});
|
||||
const bridge = fakeBridge({ setDiyOnlineStatus: setDiyOnlineStatus as any });
|
||||
await makeHandler(fakeCtx(bridge)).handle('set_diy_online_status', { face_id: 99 });
|
||||
expect(setDiyOnlineStatus).toHaveBeenCalledWith(99, '', 1);
|
||||
});
|
||||
|
||||
it('rejects missing face_id', async () => {
|
||||
const setDiyOnlineStatus = vi.fn();
|
||||
const bridge = fakeBridge({ setDiyOnlineStatus: setDiyOnlineStatus as any });
|
||||
const res = await makeHandler(fakeCtx(bridge)).handle('set_diy_online_status', { wording: 'x' });
|
||||
expect(res).toMatchObject({ status: 'failed', retcode: 1400 });
|
||||
expect(setDiyOnlineStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces bridge errors as action_failed with the original message', async () => {
|
||||
const bridge = fakeBridge({
|
||||
setDiyOnlineStatus: (async () => { throw new Error('denied'); }) as any,
|
||||
});
|
||||
const res = await makeHandler(fakeCtx(bridge)).handle('set_diy_online_status', {
|
||||
face_id: 1, wording: 'x',
|
||||
});
|
||||
expect(res).toMatchObject({ status: 'failed', retcode: 100, wording: 'denied' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── set_group_portrait (Lagrange-protocol highway upload, cmdId 3000) ───
|
||||
|
||||
describe('extended-actions / set_group_portrait', () => {
|
||||
it('forwards group_id + file to bridge.setGroupAvatar', async () => {
|
||||
const setGroupAvatar = vi.fn(async () => {});
|
||||
const bridge = fakeBridge({ setGroupAvatar: setGroupAvatar as any });
|
||||
const res = await makeHandler(fakeCtx(bridge)).handle('set_group_portrait', {
|
||||
group_id: 12345, file: '/tmp/avatar.png',
|
||||
});
|
||||
expect(res.status).toBe('ok');
|
||||
expect(setGroupAvatar).toHaveBeenCalledWith(12345, '/tmp/avatar.png');
|
||||
});
|
||||
|
||||
it('rejects missing group_id or file', async () => {
|
||||
const setGroupAvatar = vi.fn();
|
||||
const bridge = fakeBridge({ setGroupAvatar: setGroupAvatar as any });
|
||||
const r1 = await makeHandler(fakeCtx(bridge)).handle('set_group_portrait', { file: 'x' });
|
||||
const r2 = await makeHandler(fakeCtx(bridge)).handle('set_group_portrait', { group_id: 1 });
|
||||
expect(r1).toMatchObject({ status: 'failed', retcode: 1400 });
|
||||
expect(r2).toMatchObject({ status: 'failed', retcode: 1400 });
|
||||
expect(setGroupAvatar).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces highway / decode errors as action_failed', async () => {
|
||||
const bridge = fakeBridge({
|
||||
setGroupAvatar: (async () => { throw new Error('highway 500'); }) as any,
|
||||
});
|
||||
const res = await makeHandler(fakeCtx(bridge)).handle('set_group_portrait', {
|
||||
group_id: 1, file: 'x.png',
|
||||
});
|
||||
expect(res).toMatchObject({ status: 'failed', retcode: 100, wording: 'highway 500' });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@snowluma/runtime",
|
||||
"version": "1.8.0",
|
||||
"version": "1.8.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.mjs",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"name": "MotricSeven",
|
||||
"email": "motricseven@gmail.com"
|
||||
},
|
||||
"version": "1.8.0",
|
||||
"version": "1.8.1",
|
||||
"description": "TypeScript SDK for SnowLuma OneBot HTTP and WebSocket transports",
|
||||
"keywords": [
|
||||
"snowluma",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@snowluma/websocket",
|
||||
"version": "1.8.0",
|
||||
"version": "1.8.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webui",
|
||||
"private": true,
|
||||
"version": "1.8.0",
|
||||
"version": "1.8.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Reference in New Issue
Block a user