Merge pull request #37 from SnowLuma/dev

chore: promote dev → main (7da52bc)
This commit is contained in:
snowluma-bot[bot]
2026-05-15 23:18:33 +08:00
committed by GitHub
42 changed files with 3218 additions and 944 deletions
+90
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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",
+155
View File
@@ -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`);
}
+1 -1
View File
@@ -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 {
+64 -5
View File
@@ -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>;
}
+44 -1
View File
@@ -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);
}
}
+8 -2
View File
@@ -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,
});
}
}
+149 -1
View File
@@ -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;
+235 -6
View File
@@ -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));
}
});
}
+10
View File
@@ -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));
+141
View File
@@ -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);
});
});
+81 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@snowluma/runtime",
"version": "1.8.0",
"version": "1.8.1",
"private": true,
"type": "module",
"main": "index.mjs",
+1 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@snowluma/websocket",
"version": "1.8.0",
"version": "1.8.1",
"private": true,
"type": "module",
"main": "./src/index.ts",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "webui",
"private": true,
"version": "1.8.0",
"version": "1.8.1",
"type": "module",
"scripts": {
"dev": "vite",