diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..1331434a --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -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/)。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4c10c767..87db0ee9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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` 常用:`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 —— 这个项目能走多远,取决于你们每一个人。❄️ diff --git a/package.json b/package.json index 94be4e78..57eb5c9f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "snowluma-monorepo", - "version": "1.8.0", + "version": "1.8.1", "description": "SnowLuma monorepo workspace (pnpm)", "private": true, "type": "module", diff --git a/packages/core/package.json b/packages/core/package.json index 14c74e5d..0b706b72 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/bridge/actions/extras.ts b/packages/core/src/bridge/actions/extras.ts new file mode 100644 index 00000000..38a082a0 --- /dev/null +++ b/packages/core/src/bridge/actions/extras.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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`); +} diff --git a/packages/core/src/bridge/actions/forward.ts b/packages/core/src/bridge/actions/forward.ts index 8d232d52..0181a5fc 100644 --- a/packages/core/src/bridge/actions/forward.ts +++ b/packages/core/src/bridge/actions/forward.ts @@ -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 { diff --git a/packages/core/src/bridge/actions/group-file.ts b/packages/core/src/bridge/actions/group-file.ts index 5cc61225..969b13ff 100644 --- a/packages/core/src/bridge/actions/group-file.ts +++ b/packages/core/src/bridge/actions/group-file.ts @@ -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 { diff --git a/packages/core/src/bridge/actions/profile.ts b/packages/core/src/bridge/actions/profile.ts index 793b058d..849e6fb6 100644 --- a/packages/core/src/bridge/actions/profile.ts +++ b/packages/core/src/bridge/actions/profile.ts @@ -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 { - 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 { + 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 { + 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 { + 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( diff --git a/packages/core/src/bridge/bridge-interface.ts b/packages/core/src/bridge/bridge-interface.ts index ca841697..a459b8f8 100644 --- a/packages/core/src/bridge/bridge-interface.ts +++ b/packages/core/src/bridge/bridge-interface.ts @@ -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; + // ─── 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; + // ─── Send (messages) ─── sendGroupMessage(groupId: number, elements: MessageElement[]): Promise; sendPrivateMessage(userUin: number, elements: MessageElement[]): Promise; @@ -113,10 +120,12 @@ export interface BridgeInterface { // ─── Personal profile ─── setOnlineStatus(status: number, extStatus?: number, batteryStatus?: number): Promise; + setDiyOnlineStatus(faceId: number, wording: string, faceType: number): Promise; setProfile(nickname?: string, personalNote?: string): Promise; setSelfLongNick(longNick: string): Promise; setInputStatus(userId: number, eventType: number): Promise; setAvatar(source: string): Promise; + setGroupAvatar(groupId: number, source: string): Promise; fetchCustomFace(count?: number): Promise; getProfileLike(userId?: number, start?: number, limit?: number): Promise; getUnidirectionalFriendList(): Promise; @@ -127,4 +136,12 @@ export interface BridgeInterface { getMiniAppArk(type: string, title: string, desc: string, picUrl: string, jumpUrl: string): Promise; clickInlineKeyboardButton(groupId: number, botAppid: number, buttonId: string, callbackData: string, msgSeq: number): Promise; sendGroupSign(groupId: number): Promise; + + // ─── Tier-2 napcat parity extras ─── + setGroupTodo(groupId: number, msgSeq: bigint | number | string): Promise; + completeGroupTodo(groupId: number, msgSeq: bigint | number | string): Promise; + cancelGroupTodo(groupId: number, msgSeq: bigint | number | string): Promise; + getStrangerStatus(uin: number): Promise<{ status: number; ext_status: number } | null>; + fetchAiVoiceList(groupId: number, chatType: number): Promise }>>; + fetchAiVoice(groupId: number, voiceId: string, text: string, chatType: number): Promise; } diff --git a/packages/core/src/bridge/bridge.ts b/packages/core/src/bridge/bridge.ts index f1c127ce..acdbcc1f 100644 --- a/packages/core/src/bridge/bridge.ts +++ b/packages/core/src/bridge/bridge.ts @@ -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 { return setOnlineStatus_(this, status, extStatus, batteryStatus); } + async setDiyOnlineStatus(faceId: number, wording: string, faceType: number): Promise { + return setDiyOnlineStatus_(this, faceId, wording, faceType); + } async setProfile(nickname?: string, personalNote?: string): Promise { return setProfile_(this, nickname, personalNote); } @@ -522,11 +541,35 @@ export class Bridge implements BridgeInterface { async setAvatar(source: string): Promise { return setAvatar_(this, source); } + async setGroupAvatar(groupId: number, source: string): Promise { + return setGroupAvatar_(this, groupId, source); + } async fetchCustomFace(count?: number): Promise { 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 { + return setGroupTodo_(this, groupId, BigInt(msgSeq)); + } + async completeGroupTodo(groupId: number, msgSeq: bigint | number | string): Promise { + return completeGroupTodo_(this, groupId, BigInt(msgSeq)); + } + async cancelGroupTodo(groupId: number, msgSeq: bigint | number | string): Promise { + return cancelGroupTodo_(this, groupId, BigInt(msgSeq)); + } + async getStrangerStatus(uin: number): Promise { + return getStrangerStatus_(this, uin); + } + async fetchAiVoiceList(groupId: number, chatType: AiVoiceChatTypeT): Promise { + return fetchAiVoiceList_(this, groupId, chatType); + } + async fetchAiVoice(groupId: number, voiceId: string, text: string, chatType: AiVoiceChatTypeT) { + return fetchAiVoice_(this, groupId, voiceId, text, chatType); + } } diff --git a/packages/core/src/bridge/element-builder.ts b/packages/core/src/bridge/element-builder.ts index 8158a319..b07f7980 100644 --- a/packages/core/src/bridge/element-builder.ts +++ b/packages/core/src/bridge/element-builder.ts @@ -208,11 +208,14 @@ async function makePttElem(ctx: SendContext, element: MessageElement): Promise

> 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 { - const map = new Map(); - for (const p of params) { - if (p.name !== undefined) map.set(p.name, p.value ?? ''); - } - return map; -} - -function findTemplateValue(map: Map, ...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; - -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 | 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, - 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 []; -} diff --git a/packages/core/src/bridge/msg-push/context.ts b/packages/core/src/bridge/msg-push/context.ts new file mode 100644 index 00000000..556be152 --- /dev/null +++ b/packages/core/src/bridge/msg-push/context.ts @@ -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; +export type PushMsgResponseHead = ProtoDecoded; +export type PushMsgContentHead = ProtoDecoded; + +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, + }; +} diff --git a/packages/core/src/bridge/msg-push/decoders/event-0x210.ts b/packages/core/src/bridge/msg-push/decoders/event-0x210.ts new file mode 100644 index 00000000..5343d4bb --- /dev/null +++ b/packages/core/src/bridge/msg-push/decoders/event-0x210.ts @@ -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]; +} diff --git a/packages/core/src/bridge/msg-push/decoders/event-0x2dc.ts b/packages/core/src/bridge/msg-push/decoders/event-0x2dc.ts new file mode 100644 index 00000000..e8f485a8 --- /dev/null +++ b/packages/core/src/bridge/msg-push/decoders/event-0x2dc.ts @@ -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]; +} diff --git a/packages/core/src/bridge/msg-push/decoders/friend-message.ts b/packages/core/src/bridge/msg-push/decoders/friend-message.ts new file mode 100644 index 00000000..c07a28bc --- /dev/null +++ b/packages/core/src/bridge/msg-push/decoders/friend-message.ts @@ -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]; +}; diff --git a/packages/core/src/bridge/msg-push/decoders/group-admin.ts b/packages/core/src/bridge/msg-push/decoders/group-admin.ts new file mode 100644 index 00000000..57bd9f23 --- /dev/null +++ b/packages/core/src/bridge/msg-push/decoders/group-admin.ts @@ -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]; +}; diff --git a/packages/core/src/bridge/msg-push/decoders/group-join-request.ts b/packages/core/src/bridge/msg-push/decoders/group-join-request.ts new file mode 100644 index 00000000..2cfdf6a3 --- /dev/null +++ b/packages/core/src/bridge/msg-push/decoders/group-join-request.ts @@ -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]; +}; diff --git a/packages/core/src/bridge/msg-push/decoders/group-member-change.ts b/packages/core/src/bridge/msg-push/decoders/group-member-change.ts new file mode 100644 index 00000000..25cdf59a --- /dev/null +++ b/packages/core/src/bridge/msg-push/decoders/group-member-change.ts @@ -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]; +}; diff --git a/packages/core/src/bridge/msg-push/decoders/group-message.ts b/packages/core/src/bridge/msg-push/decoders/group-message.ts new file mode 100644 index 00000000..6def3b67 --- /dev/null +++ b/packages/core/src/bridge/msg-push/decoders/group-message.ts @@ -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]; +}; diff --git a/packages/core/src/bridge/msg-push/decoders/temp-message.ts b/packages/core/src/bridge/msg-push/decoders/temp-message.ts new file mode 100644 index 00000000..f2c89f72 --- /dev/null +++ b/packages/core/src/bridge/msg-push/decoders/temp-message.ts @@ -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]; +}; diff --git a/packages/core/src/bridge/msg-push/enums.ts b/packages/core/src/bridge/msg-push/enums.ts new file mode 100644 index 00000000..c94b3875 --- /dev/null +++ b/packages/core/src/bridge/msg-push/enums.ts @@ -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, +} diff --git a/packages/core/src/bridge/msg-push/helpers.ts b/packages/core/src/bridge/msg-push/helpers.ts new file mode 100644 index 00000000..125d5d9e --- /dev/null +++ b/packages/core/src/bridge/msg-push/helpers.ts @@ -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 { + const map = new Map(); + for (const p of params) { + if (p.name !== undefined) map.set(p.name, p.value ?? ''); + } + return map; +} + +export function findTemplateValue(map: Map, ...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); +} diff --git a/packages/core/src/bridge/msg-push/index.ts b/packages/core/src/bridge/msg-push/index.ts new file mode 100644 index 00000000..536c4c6f --- /dev/null +++ b/packages/core/src/bridge/msg-push/index.ts @@ -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); +} diff --git a/packages/core/src/bridge/msg-push/registry.ts b/packages/core/src/bridge/msg-push/registry.ts new file mode 100644 index 00000000..1ac309ec --- /dev/null +++ b/packages/core/src/bridge/msg-push/registry.ts @@ -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(); + + 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 []; + } + } +} diff --git a/packages/core/src/bridge/msg-push/rich-body-decoder.ts b/packages/core/src/bridge/msg-push/rich-body-decoder.ts new file mode 100644 index 00000000..118a26b4 --- /dev/null +++ b/packages/core/src/bridge/msg-push/rich-body-decoder.ts @@ -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; +type RichTextDecoded = ProtoDecoded; +export type PushMsgBody = ProtoDecoded; + +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 | 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, + }); + } +} diff --git a/packages/core/src/bridge/proto/oidb-action.ts b/packages/core/src/bridge/proto/oidb-action.ts index 5e4b8a78..b4b28a1a 100644 --- a/packages/core/src/bridge/proto/oidb-action.ts +++ b/packages/core/src/bridge/proto/oidb-action.ts @@ -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; \ No newline at end of file +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; \ No newline at end of file diff --git a/packages/core/src/onebot/actions/extended.ts b/packages/core/src/onebot/actions/extended.ts index 5400c464..82fd4f99 100644 --- a/packages/core/src/onebot/actions/extended.ts +++ b/packages/core/src/onebot/actions/extended.ts @@ -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; + 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'); } /** diff --git a/packages/core/src/onebot/actions/group-admin.ts b/packages/core/src/onebot/actions/group-admin.ts index 5771bfcc..d27332af 100644 --- a/packages/core/src/onebot/actions/group-admin.ts +++ b/packages/core/src/onebot/actions/group-admin.ts @@ -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)); + } }); } diff --git a/packages/core/src/utils/hex.ts b/packages/core/src/utils/hex.ts index 79852e5f..26974244 100644 --- a/packages/core/src/utils/hex.ts +++ b/packages/core/src/utils/hex.ts @@ -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)); diff --git a/packages/core/tests/actions/extras.test.ts b/packages/core/tests/actions/extras.test.ts new file mode 100644 index 00000000..eca813a8 --- /dev/null +++ b/packages/core/tests/actions/extras.test.ts @@ -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); + }); +}); diff --git a/packages/core/tests/actions/profile.test.ts b/packages/core/tests/actions/profile.test.ts index e1342b1d..52feb3f2 100644 --- a/packages/core/tests/actions/profile.test.ts +++ b/packages/core/tests/actions/profile.test.ts @@ -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 () => { diff --git a/packages/core/tests/element-builder.test.ts b/packages/core/tests/element-builder.test.ts new file mode 100644 index 00000000..ebe4e639 --- /dev/null +++ b/packages/core/tests/element-builder.test.ts @@ -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); + }); + }); +}); diff --git a/packages/core/tests/hook-reconnect-no-login-e2e.test.ts b/packages/core/tests/hook-reconnect-no-login-e2e.test.ts new file mode 100644 index 00000000..4c0a61f9 --- /dev/null +++ b/packages/core/tests/hook-reconnect-no-login-e2e.test.ts @@ -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(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([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(); + }); +}); diff --git a/packages/core/tests/hook-reconnect-no-login.test.ts b/packages/core/tests/hook-reconnect-no-login.test.ts new file mode 100644 index 00000000..a6ab3a90 --- /dev/null +++ b/packages/core/tests/hook-reconnect-no-login.test.ts @@ -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 { + 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(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); + } + }); +}); diff --git a/packages/core/tests/msg-push-handler.test.ts b/packages/core/tests/msg-push-handler.test.ts index 4fc8b245..554b5f10 100644 --- a/packages/core/tests/msg-push-handler.test.ts +++ b/packages/core/tests/msg-push-handler.test.ts @@ -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'; diff --git a/packages/core/tests/onebot/extended-actions.test.ts b/packages/core/tests/onebot/extended-actions.test.ts new file mode 100644 index 00000000..95ddbb8e --- /dev/null +++ b/packages/core/tests/onebot/extended-actions.test.ts @@ -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 { + 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 { + 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 { + 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; + 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 { + 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' }); + }); +}); diff --git a/packages/runtime/package.json b/packages/runtime/package.json index e8a7e48d..3cfbfdd6 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@snowluma/runtime", - "version": "1.8.0", + "version": "1.8.1", "private": true, "type": "module", "main": "index.mjs", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 5376da6a..1e072aee 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -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", diff --git a/packages/websocket/package.json b/packages/websocket/package.json index 5459cef7..6a11dc09 100644 --- a/packages/websocket/package.json +++ b/packages/websocket/package.json @@ -1,6 +1,6 @@ { "name": "@snowluma/websocket", - "version": "1.8.0", + "version": "1.8.1", "private": true, "type": "module", "main": "./src/index.ts", diff --git a/packages/webui/package.json b/packages/webui/package.json index 68b0b190..bf46cf99 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -1,7 +1,7 @@ { "name": "webui", "private": true, - "version": "1.8.0", + "version": "1.8.1", "type": "module", "scripts": { "dev": "vite",