Complete M11-M13 release readiness

This commit is contained in:
DONGH4O committed 2026-05-17 21:51:42 +08:00
1 parent 83cecb0304
commit 68d29506f0
34 files changed
+2386 -150

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
name: CI
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
test:
name: ${{ matrix.os }} / Node ${{ matrix.node }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- windows-latest
- ubuntu-latest
- macos-latest
node:
- 18.x
- 20.x
- 22.x
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
registry-url: https://registry.npmjs.org
- name: Show tool versions
run: |
node --version
npm --version
- name: Run tests
run: npm test
- name: Verify package boundary
run: npm pack --dry-run
+8
View File
@@ -6,7 +6,15 @@ This project follows semantic versioning after the first public package. Pre-`0.
## Unreleased
- Added M13 CI matrix and stable-release readiness documentation.
- Added troubleshooting guidance for install, login, sending, media, state, and safe debugging.
## 0.1.0-beta.1 - 2026-05-17
- Added M10 GitHub source release readiness: source install docs, issue templates, PR template, security policy, and validation report.
- Added M11 outbound file/image sending with AES upload, optional typing status, safe CLI output, and mock protocol coverage.
- Made optional typing best-effort so typing setup or stop failures do not block text, file, or image delivery.
- Prepared the M12 npm beta package by setting `private: false`, updating the package version, and documenting beta install validation.
## 0.1.0-beta.0 - 2026-05-17
+50 -4
View File
@@ -1,6 +1,6 @@
# WeChat-iLink Bridge
WeChat-iLink Bridge(`wxb`)是一个面向 AI Agent 的微信 iLink 本地桥接 CLI。当前 M10 版本在 M9 发布骨架之上完成 GitHub 源码发布准备:本地 git 边界、公开仓库元数据、源码安装说明、issue 模板、security policy 和 secret audit 记录。
WeChat-iLink Bridge(`wxb`)是一个面向 AI Agent 的微信 iLink 本地桥接 CLI。当前 M12 beta 版本在 M11 P1 发送侧能力基础上进入 npm beta 分发验证:`wxb send` 支持文本、文件、图片和可选 typing 状态,上传密钥和 CDN URL 继续只在 bridge 内部流转。
## P0 能力范围
@@ -30,10 +30,18 @@ WeChat-iLink Bridge(`wxb`)是一个面向 AI Agent 的微信 iLink 本地桥
- 下载失败不会影响同一条消息中的文本项输出,也不会阻断游标和消息历史写入。
- 附件文件名会清理 Windows 保留字符、路径穿越和极长文件名。
## M11 能力范围
- `wxb send --file <path>`:AES-128-ECB 加密并上传本地文件,再发送给已有上下文的微信用户。
- `wxb send --image <path>`:校验本地图片 MIME,上传并发送图片消息。
- `wxb send --typing`:发送前获取 `typing_ticket` 并显示输入状态,发送后尽力停止输入状态。
- 本地文件不存在、目录路径、超过 `WX_MAX_UPLOAD_BYTES`、未知 MIME 或图片模式传入非图片文件时返回结构化错误。
- stdout 不输出上传 URL、AES key、签名 query、bot token 或 context token。
## 发布状态
- 当前版本为 `0.1.0-beta.0` 本地发布骨架,`package.json.private` 仍为 `true`,不会直接发布 npm。
- 包名草案为 `@dongh4o/wechat-ilink-bridge`;npm scope `@dongh4o` 已存在,M12 npm beta 发布前需确认当前账号有发布权限。
- 当前版本为 `0.1.0-beta.1` npm beta 包,已可通过 `@dongh4o/wechat-ilink-bridge@beta` 安装。
- 包名为 `@dongh4o/wechat-ilink-bridge`;npm scope `@dongh4o` 已存在,发布前需确认当前 npm 账号有该 scope 的发布权限。
- GitHub 仓库目标为 `DONGH4O/wechat-ilink-bridge`。
- `LICENSE` 当前按 MIT 落地。
- npm 全局安装后只公开稳定 CLI bin `wxb`;`wxb-spike` 不作为公开 bin 发布,协议 spike helper 仅保留为源码仓库维护脚本。
@@ -57,6 +65,31 @@ npm.cmd install -g .
wxb help
```
## npm Beta 安装
当前公开 beta 可用 beta tag 安装:
```powershell
npm.cmd install -g @dongh4o/wechat-ilink-bridge@beta
wxb help
wxb status --json
```
发布前本地 `.tgz` smoke:
```powershell
npm.cmd pack
npm.cmd install -g .\dongh4o-wechat-ilink-bridge-0.1.0-beta.1.tgz
wxb help
wxb status --json
```
## 稳定版准备状态
- GitHub Actions CI 将覆盖 Windows、Ubuntu、macOS 和 Node.js 18/20/22。
- `0.1.0` stable 发布前会先确认 CI 全平台通过、README 安装路径可用、`CHANGELOG.md` 增加稳定版条目、GitHub Release 与 npm 版本一致。
- 安装、登录、发送、媒体和状态文件的常见问题见 `docs/troubleshooting.md`。
macOS/Linux:
```bash
@@ -94,6 +127,8 @@ node .\src\cli\index.js fetch --timeout 1000 --json
node .\src\cli\index.js fetch --timeout 3000 --download-media --json
node .\src\cli\index.js send --user <fromUserId> --text "收到" --json
"来自 PowerShell 管道的回复" | node .\src\cli\index.js send --user <fromUserId> --stdin --json
node .\src\cli\index.js send --user <fromUserId> --file "C:\path\to\report.pdf" --json
node .\src\cli\index.js send --user <fromUserId> --image "C:\path\to\image.jpg" --typing --json
node .\src\cli\index.js poll --limit 3 --interval 1000 --json
node .\src\cli\index.js heartbeat --json
node .\src\cli\index.js alias set <fromUserId> "张三"
@@ -125,6 +160,7 @@ node .\src\cli\index.js --base-url "https://ilinkai.weixin.qq.com" fetch --json
| `WX_MESSAGE_RETENTION_DAYS` | `--message-retention-days` | cleanup 消息历史保留天数 |
| `WX_ATTACHMENT_RETENTION_DAYS` | `--attachment-retention-days` | cleanup inbox 附件保留天数 |
| `WX_MAX_HISTORY_MESSAGES` | `--max-history-messages` | cleanup 每账号最多保留消息条数 |
| `WX_MAX_UPLOAD_BYTES` | `--max-upload-bytes` | 单个发送文件最大字节数,默认 25 MiB |
默认状态目录:
@@ -138,7 +174,7 @@ node .\src\cli\index.js --base-url "https://ilinkai.weixin.qq.com" fetch --json
## 发布前检查
本项目目前适合从源码运行,尚未发布 npm。准备公开 GitHub 或 npm 前,先运行:
准备公开 GitHub 或 npm 前,先运行:
```powershell
npm.cmd test
@@ -154,6 +190,16 @@ npm.cmd run pack:dry-run
3. 生成回复文本。
4. 使用消息里的 `fromUserId` 调用 `send --user <fromUserId>`。
文本、文件和图片发送:
```powershell
node .\src\cli\index.js send --user <fromUserId> --text "收到" --json
node .\src\cli\index.js send --user <fromUserId> --file "C:\path\to\report.pdf" --json
node .\src\cli\index.js send --user <fromUserId> --image "C:\path\to\image.jpg" --typing --json
```
`--file` 和 `--image` 只接受本地文件路径。bridge 会负责 AES 加密、上传 URL 获取、CDN 上传和 `sendmessage`,Agent 不需要也不应处理上传密钥、CDN 签名 URL 或 `context_token`。
如果 `send` 返回 `NO_CONTEXT_TOKEN`,说明还没有该用户的可用入站上下文,需要先让用户发来一条消息并运行 `fetch`。如果返回 `SESSION_EXPIRED`,需要重新执行 `login`。
## M7 运维命令
+64
View File
@@ -0,0 +1,64 @@
# M11 P1 发送侧补齐验收记录
## 范围
M11 将 P1 从“媒体可接收”补齐到“媒体可发送”,并加入可选 typing 状态:
- `wxb send --file <path>`。
- `wxb send --image <path>`。
- `wxb send --typing`。
- `/ilink/bot/getuploadurl`、CDN 上传、`sendmessage` 媒体 item 的 mock 协议覆盖。
- `/ilink/bot/getconfig` 和 `/ilink/bot/sendtyping` 的 mock 协议覆盖。
- stdout 继续保持 token-safe,不输出上传 URL、AES key、签名 query、bot token 或 context token。
## 自动化覆盖
新增和更新:
- `src/core/send-media.js`:文件/图片发送 core。
- `src/core/typing.js`:可选 typing 包装。
- `src/core/ilink-client.js`:`getUploadUrl`、`uploadBytes`、`sendMediaMessage`、`getConfig`、`sendTyping`。
- `src/cli/commands/send.js`:`--file`、`--image`、`--typing`。
- `test/unit/send-media.test.js`。
- `test/unit/cli-m11.test.js`。
- `test/unit/ilink-client.test.js`、`send-text.test.js`、`protocol-constants.test.js`、`config-load.test.js` 回归。
## 已验证行为
- 图片发送会读取本地文件、推断 MIME、AES-128-ECB 加密、调用 `getuploadurl`、上传加密字节、再调用 `sendmessage`。
- 媒体上传按真实协议使用 `upload_param` 构造 CDN `/upload` URL,并将 CDN 返回的 `x-encrypted-param` 写入 `media.encrypt_query_param`。
- 出站媒体 item 使用发送侧类型编号:image `2`、file `4`;上传类型使用 image `1`、file `3`。
- 文件发送支持常见文档、文本、压缩包和 Office 扩展名。
- `--typing` 会先取 `typing_ticket`,发送 `status: 1`,发送后尽力发送 `status: 2`。
- `getconfig`、缺少 `typing_ticket` 或开始 typing 失败都不会阻断主发送;CLI 返回安全的 `typing.startError` 元数据。
- 停止 typing 失败不会把已发送消息改判失败;CLI 返回安全的 `typing.stopError` 元数据。
- `getconfig` 返回新的 `context_token` 时会更新本地 context token,并用于随后发送。
- 文件不存在、目录路径、超大文件、未知 MIME、图片模式传入非图片均返回结构化错误。
- 上传成功但 `sendmessage` 失败时不会误报成功,错误 details 包含安全的 uploaded/clientId/fileName 元数据。
- 媒体发送成功后写入出站历史。
- stdout 不包含 `botToken`、`contextToken`、typing ticket、上传 URL、AES key 或签名 query。
## 验证结果
```powershell
npm.cmd test
npm.cmd run pack:dry-run
```
- `npm.cmd test`:124 项测试通过。
- `npm.cmd run pack:dry-run`:通过,tarball 为 `dongh4o-wechat-ilink-bridge-0.1.0-beta.0.tgz`,共 53 个文件,package size 55.6 kB,unpacked size 199.2 kB。
- dry-run 包内容包含 M11 新增 core、CLI、Skill/API 和本报告;未包含本地状态目录、真实 token、真实媒体输出或测试原始 live fixture。
## 真实接口说明
2026-05-17 首轮真实接口反馈:
- `login`、`fetch fromUserId`、`send text`、`send text --typing` 和负向校验通过。
- `send --file`、`send --image --typing` 在媒体 `sendmessage` 阶段返回 `ret: -2`,表现为 `INVALID_CONTEXT_TOKEN`,实际含义更接近“请求参数错误”。
- 已根据真实协议资料校准媒体发送:`upload_param` CDN 上传、`x-encrypted-param` 回填、出站 image/file item 类型和 file 上传类型。
2026-05-17 复测结果:
- `send --file`:`ok: true`,微信端收到文件。
- `send --image --typing`:`ok: true`,`typing.started: true`,`typing.stopped: true`,微信端收到图片。
- M11 真实接口冒烟通过。
+54
View File
@@ -0,0 +1,54 @@
# M12 npm beta 分发验收记录
## 范围
M12 将项目从源码预览推进到 npm beta 候选包:
- package 版本设为 `0.1.0-beta.1`。
- `package.json.private` 设为 `false`,允许发布者执行 npm beta publish。
- README 增加 beta tag 安装和本地 `.tgz` 安装 smoke。
- 本地包内容继续由 `package.json.files` 和 `.npmignore` 约束。
## 验证结果
```powershell
npm.cmd test
npm.cmd run pack:dry-run
npm.cmd pack
npm.cmd install -g --prefix C:\tmp\wxb-m12-global .\dongh4o-wechat-ilink-bridge-0.1.0-beta.1.tgz
& C:\tmp\wxb-m12-global\wxb.cmd help
& C:\tmp\wxb-m12-global\wxb.cmd status --json --state-dir C:\tmp\wxb-m12-smoke-state
```
- `npm.cmd test`:126 项测试通过。
- `npm.cmd run pack:dry-run`:通过,tarball 为 `dongh4o-wechat-ilink-bridge-0.1.0-beta.1.tgz`,共 54 个文件,package size 56.9 kB,unpacked size 204.1 kB。
- `npm.cmd pack`:通过,生成 `dongh4o-wechat-ilink-bridge-0.1.0-beta.1.tgz`。
- 临时 prefix 安装:通过,`npm.cmd install -g --prefix C:\tmp\wxb-m12-global .\dongh4o-wechat-ilink-bridge-0.1.0-beta.1.tgz` 成功。
- 已安装 `wxb help`:通过,返回 `milestone: "M12"`。
- 已安装 `wxb status --json --state-dir C:\tmp\wxb-m12-smoke-state`:通过,返回空账号列表。
- 只读 npm 登录检查:`npm.cmd whoami` 通过,当前账号为 `dongh4o`。
- 首次 `npm.cmd publish --tag beta --access public` 已连接 registry,但返回 `E403`:npm 要求双因素验证码或启用 bypass 2FA 的 granular access token。
- 发布前 npm 将 `bin.wxb` 从 `./src/cli/index.js` 规范化为 `src/cli/index.js`;已按该格式更新本地 manifest 并重新验证。
- `npm.cmd publish --dry-run --tag beta --access public`:通过,修正后发布 dry-run 不再触发 package.json 自动纠正。
- 用户已在 npm 完成 `@dongh4o/wechat-ilink-bridge@0.1.0-beta.1` 发布,并收到 npm 确认邮件。
- `npm.cmd view @dongh4o/wechat-ilink-bridge@beta name version dist-tags bin --json`:通过,`beta` 指向 `0.1.0-beta.1`,`bin.wxb` 为 `src/cli/index.js`。
- 公开 registry 安装 smoke:`npm.cmd install -g --prefix C:\tmp\wxb-m12-registry @dongh4o/wechat-ilink-bridge@beta` 通过。
- registry 安装后的 `wxb help`:通过,返回 `milestone: "M12"`。
- registry 安装后的 `wxb status --json --state-dir C:\tmp\wxb-m12-registry-state`:通过,返回空账号列表。
## 包内容结论
- 包内包含 `src/`、`skills/`、`docs/`、`README.md`、`CHANGELOG.md`、`LICENSE` 和 `package.json`。
- 包内不包含 `scripts/`、`test/`、`.env`、本地状态目录、真实 stdout 捕获、`node_modules` 或历史 `.tgz` 归档。
- 发布前 secret scan 已执行;命中项均为需求文档字段名、示例值、源码字段名或测试 fixture,未发现真实 token、真实 context token、真实上传 URL、真实 CDN 签名参数或 AES key。
- `git status --short --ignored` 显示 `.tgz`、历史真实 stdout 捕获、`.workbuddy/` 和真实测试状态目录均为 ignored 文件。
## 发布说明
M12 npm beta 已发布。后续若需重发 beta,发布者可在更新版本号后执行:
```powershell
npm publish --tag beta --access public --otp <one-time-code>
```
执行前需确认当前 npm 账号对 `@dongh4o` scope 具备发布权限。不要把本地状态目录、真实 stdout 捕获、token、context token、上传 URL、CDN 签名参数或 AES key 放入包、日志或 issue。
+47
View File
@@ -0,0 +1,47 @@
# M13 P1 稳定版准备验收记录
## 范围
M13 将 beta 收敛为第一个 P1 稳定版候选。当前阶段先完成稳定版发布前工程准备,不直接发布 `0.1.0`:
- GitHub Actions CI matrix。
- 稳定版安装与故障排查文档。
- release process 中的 stable release checklist。
- M13 readiness 测试。
## 当前状态
- M12 npm beta 已发布,`@dongh4o/wechat-ilink-bridge@beta` 指向 `0.1.0-beta.1`。
- `0.1.0` stable version bump、git tag、GitHub Release 和 npm stable publish 待 CI 在 GitHub 上真实跑绿后执行。
## 本地验收结果
```powershell
npm.cmd test
npm.cmd run pack:dry-run
```
- `npm.cmd test`:130 项测试通过。
- `npm.cmd run pack:dry-run`:通过,tarball 为 `dongh4o-wechat-ilink-bridge-0.1.0-beta.1.tgz`,共 56 个文件,package size 58.9 kB,unpacked size 210.4 kB。
- 包内容包含 M13 新增 `docs/troubleshooting.md` 和 `docs/m13-validation-report.md`。
- `.github/workflows/ci.yml` 属于 GitHub 仓库工作流文件,不进入 npm 包内容。
## GitHub 验收
推送到 GitHub 后确认:
- `.github/workflows/ci.yml` 在 Windows、Ubuntu、macOS 上运行。
- Node.js 18、20、22 matrix 均通过 `npm test`。
- 每个 matrix job 均通过 `npm pack --dry-run`。
## Stable Release Gate
只有以下条件全部满足后,才进入 `0.1.0` stable 发布:
- GitHub CI 全平台通过。
- README 的 beta 和源码安装路径均可用。
- `docs/troubleshooting.md` 覆盖安装、登录、发送、媒体和状态常见问题。
- `CHANGELOG.md` 增加 `0.1.0` 条目。
- `package.json` version bump 到 `0.1.0`。
- 创建 Git tag 和 GitHub Release。
- 执行 `npm publish --access public` 并确认 npm `latest` 指向 `0.1.0`。
+13 -5
View File
@@ -1,8 +1,8 @@
# WeChat-iLink Bridge 后续里程碑规划
> 日期:2026-05-17
> 基线:M0-M8 已完成,当前版本为 M8/P1 媒体下载保存能力。
> 目标:先把发布、版本、源码分发边界稳住,再补齐剩余 P1 发送侧能力,最后进入 P2 适配层与多模态增强。
> 基线:M0-M11 已完成,当前版本为 M11/P1 媒体发送侧补齐能力。
> 目标:进入 npm beta 分发验证,再打磨 P1 稳定版,最后进入 P2 适配层与多模态增强。
## 1. 当前判断
@@ -11,6 +11,7 @@ M8 之后项目已经具备可被 Agent 使用的核心闭环:
- 入站消息可通过 `wxb fetch --json` 交付给 Agent。
- 文本回复可通过 `wxb send --text/--stdin` 发送。
- 图片、文件、语音、视频可通过 `wxb fetch --download-media` 下载到本地 inbox,并以附件绝对路径交付给 Agent。
- 文件和图片可通过 `wxb send --file/--image` 发送,可选 `--typing` 展示输入状态。
- bot token、context token、CDN URL、AES key 等敏感值仍由 bridge 本地管理,不进入 Agent stdout。
下一阶段不应优先把语音转写、图片理解、视频摘要等模型能力写进 core library。bridge 的边界应保持为:协议适配、状态安全、媒体收发、文件落盘、Agent 友好的结构化接口。多模态理解优先由 Agent 或 P2 adapter 调用外部模型能力完成。
@@ -107,6 +108,8 @@ M8 之后项目已经具备可被 Agent 使用的核心闭环:
## 6. M11 P1 发送侧补齐
状态:已完成。自动化 mock 验收、`npm pack --dry-run` 和真实接口文件/图片发送冒烟均已通过,详见 `docs/m11-validation-report.md`。
目标:把 P1 从“媒体可接收”补齐到“媒体可发送”,并补齐 typing 状态。
任务:
@@ -138,10 +141,13 @@ M8 之后项目已经具备可被 Agent 使用的核心闭环:
- 本地文件不存在、目录路径、超大文件、未知 MIME 返回结构化错误。
- 上传成功后返回安全元数据和 `clientId`。
- 部分失败不会误报成功。
- `npm.cmd test` 通过。
- `npm.cmd test` 通过,当前为 124 项测试通过。
- `npm.cmd run pack:dry-run` 通过,M11 新增文件进入包内容清单。
## 7. M12 npm beta 分发
状态:已完成。`0.1.0-beta.1` 已发布到 npm,`@dongh4o/wechat-ilink-bridge@beta` 安装 smoke 已通过,详见 `docs/m12-validation-report.md`。
目标:发布可安装、可回滚的 beta 包,验证真实用户安装路径。
任务:
@@ -158,13 +164,15 @@ M8 之后项目已经具备可被 Agent 使用的核心闭环:
验收:
- npm 页面可访问。
- `npm install -g @<scope>/wechat-ilink-bridge@beta` 可安装。
- npm 页面可访问,`beta` dist-tag 指向 `0.1.0-beta.1`。
- `npm install -g @dongh4o/wechat-ilink-bridge@beta` 可安装。
- `wxb` bin 在 Windows PowerShell 可执行。
- README beta 安装路径与源码路径都可用。
## 8. M13 P1 稳定版
状态:稳定版候选准备已完成本地部分。CI matrix、稳定版准备文档、故障排查文档和本地验收已落地;`0.1.0` stable 发布需等待 GitHub CI 真实跑绿并人工确认。
目标:把 beta 收敛为第一个稳定公开版本。
任务:
+35 -4
View File
@@ -1,6 +1,6 @@
# Release Process
> Status: M9 local release framework.
> Status: M12 npm beta release path.
> Scope: This document defines the manual release path before GitHub automation exists.
## 1. Version Policy
@@ -18,15 +18,15 @@ Pre-release versions:
- `0.1.0`: first stable P1 release.
- `0.2.0`: first P2 adapter or optional multimodal helper release.
Current M9 draft:
Current M12 beta candidate:
- Version: `0.1.0-beta.0`.
- Version: `0.1.0-beta.1`.
- Package name: `@dongh4o/wechat-ilink-bridge`.
- npm scope: `@dongh4o` exists.
- License: MIT in `LICENSE`.
- Repository: `DONGH4O/wechat-ilink-bridge`.
Do not publish while `package.json.private` is `true`. Keep it true until the publishing account has access to the `@dongh4o` scope and the public repository is ready.
Do not run `npm publish` until the publishing account has access to the `@dongh4o` scope and the release candidate has passed local `.tgz` install smoke. Do not publish while `package.json.private` is `true`.
## 2. Required Decisions Before Public Release
@@ -120,6 +120,15 @@ wxb help
wxb status --json
```
For local smoke without changing the user's global prefix, use a temporary prefix:
```powershell
$prefix="C:\tmp\wxb-m12-global"
npm.cmd install -g --prefix $prefix .\dongh4o-wechat-ilink-bridge-0.1.0-beta.1.tgz
& "$prefix\wxb.cmd" help
& "$prefix\wxb.cmd" status --json --state-dir "C:\tmp\wxb-m12-smoke-state"
```
3. Publish beta:
```powershell
@@ -139,3 +148,25 @@ Before `0.1.0`:
5. Tag the release.
6. Publish npm stable.
7. Create a GitHub Release with the changelog summary.
Stable release checklist:
```powershell
npm.cmd test
npm.cmd run pack:dry-run
npm.cmd version 0.1.0 --no-git-tag-version
npm.cmd pack
npm.cmd install -g --prefix C:\tmp\wxb-stable-global .\dongh4o-wechat-ilink-bridge-0.1.0.tgz
& C:\tmp\wxb-stable-global\wxb.cmd help
& C:\tmp\wxb-stable-global\wxb.cmd status --json --state-dir C:\tmp\wxb-stable-smoke-state
```
After the version bump and smoke pass:
```powershell
git tag v0.1.0
npm publish --access public
npm.cmd view @dongh4o/wechat-ilink-bridge@latest name version dist-tags bin --json
```
Only publish stable after GitHub Actions CI has passed on Windows, Ubuntu, macOS, and Node.js 18/20/22.
+115
View File
@@ -0,0 +1,115 @@
# Troubleshooting
This guide covers the common install, login, send, media, and state issues for `wxb`.
## Install
### `wxb` is not found after global install
Check the package installed and the npm global bin directory:
```powershell
npm.cmd list -g @dongh4o/wechat-ilink-bridge
npm.cmd bin -g
```
If the global bin directory is not on `PATH`, either add it to `PATH` or run the local source entrypoint:
```powershell
node .\src\cli\index.js help
```
### PowerShell blocks `npm.ps1`
Use `npm.cmd` instead of `npm`:
```powershell
npm.cmd install -g @dongh4o/wechat-ilink-bridge@beta
npm.cmd test
```
### npm install requires permissions
Use a user-writable prefix for smoke tests:
```powershell
$prefix="C:\tmp\wxb-global"
npm.cmd install -g --prefix $prefix @dongh4o/wechat-ilink-bridge@beta
& "$prefix\wxb.cmd" help
```
## Login And Session
### `NO_ACCOUNT`
No account is saved in the selected state directory. Run:
```powershell
wxb login --json
wxb accounts --json
```
### `SESSION_EXPIRED`
The local iLink session expired. Re-run login:
```powershell
wxb login --json
```
## Sending
### `NO_CONTEXT_TOKEN`
The target user has no cached context token. Ask the user to send a WeChat message first, then run:
```powershell
wxb fetch --timeout 15000 --json
```
Use the returned `fromUserId` for `wxb send --user`.
### `INVALID_CONTEXT_TOKEN`
iLink rejected the request arguments. For text sends, ask the user to send a fresh message and run `fetch` again. For media sends, confirm the package is at least `0.1.0-beta.1`, because older M11 candidates used the wrong outbound media payload shape.
## Media
### `MEDIA_FILE_NOT_FOUND`
The path passed to `--file` or `--image` does not exist. Use an absolute path when in doubt.
### `MEDIA_TYPE_UNSUPPORTED`
The bridge could not infer MIME from the extension, or `--image` received a non-image file. Use a supported extension such as `.jpg`, `.png`, `.pdf`, `.txt`, `.docx`, or `.xlsx`.
### `MEDIA_FILE_TOO_LARGE`
The file exceeds `WX_MAX_UPLOAD_BYTES`. Send a smaller file or intentionally raise the limit:
```powershell
wxb send --max-upload-bytes 52428800 --user <fromUserId> --file "C:\path\to\file.pdf" --json
```
## State
### State directory contains spaces or Chinese characters
Quote paths in PowerShell:
```powershell
wxb --state-dir "C:\tmp\wxb 测试 状态" status --json
```
### `STATE_JSON_INVALID` Or `STATE_JSONL_INVALID`
The local state file is corrupted. Preserve the state directory for investigation, then retry with a fresh state directory if you need to keep working:
```powershell
$env:WX_STATE_DIR="C:\tmp\wxb-fresh"
wxb login --json
```
## Safe Debugging
Do not paste bot tokens, context tokens, upload URLs, signed CDN query parameters, AES keys, or full local state files into public issues. Share only error `code`, sanitized `message`, command shape, platform, Node.js version, package version, and whether the user received the message.
+36 -2
View File
@@ -388,7 +388,35 @@ iLinkBot/
- 已完成真实接口冒烟:图片、文件、语音、视频均保存成功,附件路径存在。
- 最终自动化回归:`npm.cmd test`,103 项测试通过。
## 13. 风险与缓解
## 13. M11 P1 发送侧补齐
目标:将 P1 从“媒体可接收”补齐到“媒体可发送”,并加入可选 typing 状态。
状态:已完成。自动化 mock 验收和 npm 包 dry-run 均已通过,详见 `docs/m11-validation-report.md`。
任务:
1. 实现本地文件/图片 MIME 推断、大小校验、AES-128-ECB 加密与上传。
2. 封装 `/ilink/bot/getuploadurl`、CDN 上传、媒体 `sendmessage`。
3. 支持 `wxb send --file <path>`、`wxb send --image <path>` 和可选 `--typing`。
4. 封装 `/ilink/bot/getconfig`、`/ilink/bot/sendtyping`,并支持刷新后的 context token。
5. 更新 Skill、API reference、README 与验收报告。
验收:
- mock server 覆盖上传 URL、二进制上传、typing、媒体 `sendmessage`。
- 本地文件不存在、目录路径、超大文件、未知 MIME 和图片类型错误均返回结构化错误。
- 上传 URL、AES key、typing ticket、bot token 和 context token 不进入 stdout。
- 上传成功但发送失败时不会误报成功,并返回安全失败元数据。
- `npm.cmd test` 通过,当前为 124 项测试通过。
- `npm.cmd run pack:dry-run` 通过,M11 新增文件进入包内容清单。
真实接口说明:
- M11 真实接口冒烟已通过:登录、fetch、文本发送、typing、文件发送、图片发送和负向校验均完成。
- 媒体发送首轮 `ret: -2` 已通过 `upload_param`、CDN `x-encrypted-param` 和出站媒体 item 类型校准解决。
## 14. 风险与缓解
| 风险 | 影响 | 缓解 |
|---|---|---|
@@ -400,10 +428,16 @@ iLinkBot/
| 媒体 CDN 复杂 | P1 延误 | P0 只做元数据,不阻塞文本闭环 |
| 消息历史膨胀 | 本地磁盘增长 | P1 cleanup + retention config |
## 14. 发布与分发建议
## 15. 发布与分发建议
后续 M9-M15 的详细里程碑、交付品、版本策略和验收标准见 `docs/next-milestones.md`。
M12 当前结果:
- `0.1.0-beta.1` npm beta 已发布。
- 本地 `.tgz` 安装 smoke 和公开 registry 安装 smoke 均已通过。
- `@dongh4o/wechat-ilink-bridge@beta` 可安装,详见 `docs/m12-validation-report.md`。
P0 阶段:
- 本地源码运行。
+30 -4
View File
@@ -15,7 +15,7 @@
4. 验证 token、context token 不暴露给 Agent。
5. 验证状态文件跨平台路径设计,P0 优先 Windows。
6. 验证状态文件原子写和并发保护。
7. 为 P1 poller、cleanup、媒体下载保留测试扩展点。
7. 验证 P1 poller、cleanup、媒体下载、媒体发送和 typing 的回归边界。
## 2. 测试分层
@@ -27,7 +27,7 @@
| 真实接口手工测试 | 扫码、真实微信消息、真实发送 | 是 |
| 并发与故障测试 | 文件锁、原子写、中断恢复 | 是 |
| 跨平台测试 | Windows/macOS/Linux | Windows P0,其他 P1 |
| 媒体测试 | 图片/语音/文件/视频 | P0 元数据,P1 下载 |
| 媒体测试 | 图片/语音/文件/视频 | P0 元数据,P1 下载与发送 |
| 安全测试 | 脱敏、路径、权限 | 是 |
## 3. 测试环境
@@ -539,6 +539,30 @@ M8 完成记录:
- 自动化回归:`npm.cmd test`,103 项测试通过。
- 真实接口冒烟:图片、文件、语音、视频均 `succeeded = 1`、`failed = 0`,附件路径均存在。
### 10.5 P1 媒体发送与 typing
用例:
- `send --file <path>` 发送本地文件。
- `send --image <path>` 发送本地图片。
- `send --image <path> --typing` 发送前展示输入状态,发送后停止输入状态。
- 本地文件不存在、目录路径、超大文件、未知 MIME、图片模式传入非图片。
- CDN 上传成功后 `sendmessage` 失败。
验收:
- 媒体发送会先获取上传 URL、上传加密字节,再发送媒体 `item`。
- 文件/图片成功输出只包含安全元数据和 `clientId`。
- stdout 不出现上传 URL、AES key、签名 query、typing ticket、bot token 或 context token。
- 本地校验错误返回结构化 JSON,且不会访问 iLink client。
- 上传成功但发送失败时不会误报成功,并暴露安全的 uploaded/clientId/fileName 元数据。
M11 完成记录:
- 自动化回归:`npm.cmd test`,124 项测试通过。
- npm 包边界:`npm.cmd run pack:dry-run` 通过,共 53 个文件。
- 真实接口冒烟:登录、fetch、文本发送、typing、文件发送、图片发送和负向校验通过;媒体发送首轮 `ret: -2` 已通过 `upload_param`、CDN `x-encrypted-param` 和出站媒体 item 类型校准解决。
## 11. 数据保留与清理测试
P1 测试。
@@ -619,5 +643,7 @@ P1 开发后必须回归:
4. alias 不影响 userId 直发。
5. 媒体下载失败不影响文本消息处理。
6. `fetch --download-media` 可保存图片、文件、语音、视频到 inbox。
7. 媒体 stdout 不泄露 AES key、CDN 下载 URL 或签名查询参数。
8. Windows/macOS/Linux 基础命令均可运行。
7. `send --file/--image` 可上传并发送本地媒体。
8. `send --typing` 不影响发送结果,停止 typing 失败只作为安全元数据返回。
9. 媒体 stdout 不泄露 AES key、CDN URL、上传 URL、typing ticket 或签名查询参数。
10. Windows/macOS/Linux 基础命令均可运行。
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@dongh4o/wechat-ilink-bridge",
"version": "0.1.0-beta.0",
"private": true,
"version": "0.1.0-beta.1",
"private": false,
"type": "module",
"description": "Local WeChat iLink Bridge CLI for Agent-safe fetch, send, state, and media handoff workflows.",
"keywords": [
@@ -12,7 +12,7 @@
"agent"
],
"bin": {
"wxb": "./src/cli/index.js"
"wxb": "src/cli/index.js"
},
"files": [
"src/",
+9 -3
View File
@@ -1,11 +1,11 @@
---
name: wechat-bridge
description: Use this skill when an agent needs to receive WeChat messages or send WeChat text replies through the local WeChat-iLink Bridge CLI (`wxb`), including tasks phrased as "fetch WeChat messages", "reply on WeChat", "send a WeChat notification", "微信消息", "发微信", or "微信通知". The bridge handles iLink credentials and context tokens locally; the agent should use the CLI JSON interface and never handle token values directly.
description: Use this skill when an agent needs to receive WeChat messages or send WeChat text, file, or image messages through the local WeChat-iLink Bridge CLI (`wxb`), including tasks phrased as "fetch WeChat messages", "reply on WeChat", "send a WeChat notification", "微信消息", "发微信", or "微信通知". The bridge handles iLink credentials, context tokens, media upload secrets, and CDN URLs locally; the agent should use the CLI JSON interface and never handle token values directly.
---
# WeChat Bridge
Use the local `wxb` CLI to receive WeChat input and send text replies through the iLink bridge. Keep all tool-facing output JSON-only, and treat `context_token` as an internal implementation detail managed by the bridge.
Use the local `wxb` CLI to receive WeChat input and send text, file, or image messages through the iLink bridge. Keep all tool-facing output JSON-only, and treat `context_token`, upload URLs, and AES keys as internal implementation details managed by the bridge.
For command schemas and error details, read [references/api.md](references/api.md).
@@ -31,6 +31,8 @@ wxb fetch --json --timeout 15000
wxb fetch --json --timeout 15000 --download-media
wxb send --account <accountId> --user <fromUserId> --text "收到,我会继续处理。" --json
"长回复内容" | wxb send --account <accountId> --user <fromUserId> --stdin --json
wxb send --account <accountId> --user <fromUserId> --file "C:\path\to\report.pdf" --json
wxb send --account <accountId> --user <fromUserId> --image "C:\path\to\image.jpg" --typing --json
wxb alias set <fromUserId> "张三"
wxb send --account <accountId> --alias "张三" --text "收到。" --json
wxb queue list --json
@@ -45,6 +47,8 @@ Use `--account <accountId>` when multiple accounts are configured or when an err
- When `attachments[]` is present, use the returned `path` directly. Do not infer paths, and do not ask for media secrets such as AES keys, CDN URLs, or signed query parameters.
- If `download.ok` is false on a media item, continue processing any text in the message and ask the user for a text description only if the missing media is essential.
- Long replies are split by the bridge. Keep replies natural and avoid sending many chunks unless the task truly needs detail.
- Files and images must be local filesystem paths. Pass only the path to `wxb send --file` or `wxb send --image`; the bridge handles AES encryption, upload URL retrieval, CDN upload, media item creation, and local history.
- Use `--typing` only when sending immediately visible user-facing replies where a typing indicator is helpful.
- The bridge writes inbound and outbound history locally; do not create a separate token or cursor store.
## Proactive Sending
@@ -63,8 +67,10 @@ Aliases are local convenience labels for opaque `fromUserId` values. Use `wxb al
- `INVALID_CONTEXT_TOKEN`: The bridge queues the message if no chunks were delivered. Ask the user to send a fresh WeChat message, run `wxb fetch --json`, and let the bridge attempt one delayed resend.
- `TEXT_TOO_LONG`: Summarize or split the content into fewer messages.
- `OUTGOING_HISTORY_WRITE_FAILED`: The message was already delivered to WeChat, but local history could not be written. Do not blindly retry. Tell the user it may have been sent and include the returned `clientId` or `deliveredClientIds` if you summarize the failure.
- `MEDIA_FILE_NOT_FOUND`, `MEDIA_PATH_NOT_FILE`, `MEDIA_FILE_TOO_LARGE`, `MEDIA_TYPE_UNSUPPORTED`: Ask the user for a valid local file path or a supported file type; do not retry unchanged.
- `MEDIA_UPLOAD_FAILED`: The file was not delivered. Retry only if the user still wants to send the same local file.
- `STATE_LOCK_TIMEOUT`: Another bridge command is using the same account. Wait briefly and retry.
Delayed queue rule: each inbound message triggers at most one queued resend for that same user. Do not manually flush the whole queue unless the user explicitly asks.
Do not print secrets, bearer values, `bot_token`, or `context_token` values in chat, logs, or summaries.
Do not print secrets, bearer values, `bot_token`, `context_token`, typing tickets, upload URLs, signed query parameters, or AES keys in chat, logs, or summaries.
+37
View File
@@ -202,6 +202,8 @@ Important:
wxb send --user <fromUserId> --text "reply text" --json
"reply text" | wxb send --user <fromUserId> --stdin --json
wxb send --account <accountId> --user <fromUserId> --text "reply text" --json
wxb send --user <fromUserId> --file "C:\path\to\report.pdf" --json
wxb send --user <fromUserId> --image "C:\path\to\image.jpg" --typing --json
```
Alias send:
@@ -217,8 +219,11 @@ Behavior:
- `--user` must be an opaque `fromUserId` previously returned by `wxb fetch`; nicknames, phone numbers, and remarks are not supported.
- Splits long text according to configured limits.
- Sends each chunk with a distinct `client_id`.
- For `--file` and `--image`, reads a local file path, infers MIME, encrypts bytes with AES-128-ECB, requests an upload URL, uploads encrypted bytes, then sends the media item.
- For `--typing`, fetches a typing ticket, sends typing start before delivery, and sends typing stop after the delivery attempt.
- Writes successful chunks to outbound message history.
- Returns a send summary without credential or context token values.
- Does not return upload URLs, AES keys, signed query parameters, typing tickets, bot tokens, or context tokens.
- If iLink rejects an expired context token before any chunk is delivered, the bridge queues the original text for delayed resend.
Success shape:
@@ -241,6 +246,31 @@ Success shape:
}
```
Media success shape:
```json
{
"ok": true,
"data": {
"accountId": "bot id",
"toUserId": "sender id",
"kind": "image",
"clientId": "wxb timestamp random",
"fileName": "image.jpg",
"mimeType": "image/jpeg",
"bytes": 12345,
"encryptedBytes": 12352,
"uploaded": true,
"sent": true,
"typing": {
"requested": true,
"started": true,
"stopped": true
}
}
}
```
Queued shape:
```json
@@ -330,6 +360,13 @@ Common errors:
| `SESSION_EXPIRED` | iLink session expired. | Ask the user to scan-login again. |
| `INVALID_CONTEXT_TOKEN` | Cached route token was rejected. | Run fetch after the user sends a fresh message; retry only after state refresh. |
| `TEXT_TOO_LONG` | Reply exceeds chunk/message limits. | Summarize or split into fewer messages. |
| `SEND_SOURCE_AMBIGUOUS` | More than one of `--text`, `--stdin`, `--file`, or `--image` was provided. | Retry with exactly one source. |
| `MEDIA_FILE_NOT_FOUND` | Local file path does not exist. | Ask for a valid local file path. |
| `MEDIA_PATH_NOT_FILE` | Path points to a directory or non-file. | Ask for a regular file path. |
| `MEDIA_FILE_TOO_LARGE` | File exceeds `WX_MAX_UPLOAD_BYTES`. | Send a smaller file or raise the configured limit intentionally. |
| `MEDIA_TYPE_UNSUPPORTED` | MIME cannot be inferred or image mode got a non-image. | Use a supported extension or send a different file. |
| `MEDIA_UPLOAD_FAILED` | CDN upload failed. | Retry later if the user still wants to send the file. |
| `MEDIA_UPLOAD_PARAM_MISSING` | CDN upload did not return the encrypted media parameter required by `sendmessage`. | Treat as protocol drift; preserve the error code and retry after bridge update. |
| `OUTGOING_HISTORY_WRITE_FAILED` | WeChat delivery succeeded but local outgoing history write failed. | Do not blindly retry. Tell the user the message may already be delivered and preserve the returned `clientId` or `deliveredClientIds`. |
| `STATE_LOCK_TIMEOUT` | Another command holds the account lock. | Wait briefly and retry. |
+37 -5
View File
@@ -1,5 +1,6 @@
import { parseCliArgs } from "../../config/load-config.js";
import { cliSuccess, WxbError } from "../../core/errors.js";
import { sendMedia } from "../../core/send-media.js";
import { sendText } from "../../core/send-text.js";
function optionalNumber(value) {
@@ -26,7 +27,7 @@ function collectPositionals(argv = []) {
}
const key = entry.slice(2).split("=", 1)[0];
if (!entry.includes("=") && !["stdin", "json"].includes(key)) {
if (!entry.includes("=") && !["stdin", "json", "typing", "no-queue", "queue", "queue-on-failure"].includes(key)) {
index += 1;
}
}
@@ -49,9 +50,39 @@ export async function runSendCommand(argv, context) {
const userId = flags.user === true ? undefined : flags.user ?? (alias ? undefined : positionals[0]);
const hasTextFlag = flags.text !== undefined && flags.text !== true;
const wantsStdin = flags.stdin === true;
const filePath = flags.file === true ? undefined : flags.file;
const imagePath = flags.image === true ? undefined : flags.image;
const mediaSources = [filePath ? "file" : undefined, imagePath ? "image" : undefined].filter(Boolean);
if (hasTextFlag && wantsStdin) {
throw new WxbError("TEXT_SOURCE_AMBIGUOUS", "Use either --text or --stdin, not both.", {
const sourceCount = [hasTextFlag, wantsStdin, Boolean(filePath), Boolean(imagePath)]
.filter(Boolean)
.length;
if (sourceCount > 1) {
throw new WxbError("SEND_SOURCE_AMBIGUOUS", "Use only one of --text, --stdin, --file, or --image.", {
retryable: false
});
}
if (mediaSources.length === 1) {
const result = await sendMedia({
stateDir: context.config.stateDir,
accountId: flags.account === true ? undefined : flags.account,
userId,
alias,
filePath: filePath ?? imagePath,
kind: mediaSources[0],
config: context.config,
timeoutMs: optionalNumber(flags.timeout) ?? context.config.fetchTimeoutMs,
client: context.client,
typing: flags.typing === true
});
return cliSuccess(result);
}
if (!hasTextFlag && !wantsStdin && positionals.length < (alias ? 1 : 2)) {
throw new WxbError("SEND_SOURCE_REQUIRED", "Use --text, --stdin, --file, or --image.", {
retryable: false
});
}
@@ -60,7 +91,7 @@ export async function runSendCommand(argv, context) {
? await readStream(context.stdin)
: hasTextFlag
? String(flags.text)
: positionals.slice(1).join(" ");
: (alias ? positionals : positionals.slice(1)).join(" ");
const result = await sendText({
stateDir: context.config.stateDir,
@@ -72,7 +103,8 @@ export async function runSendCommand(argv, context) {
timeoutMs: optionalNumber(flags.timeout) ?? context.config.fetchTimeoutMs,
client: context.client,
queueOnInvalidContext: flags.noQueue !== true,
queueOnNoContext: flags.queue === true || flags.queueOnFailure === true
queueOnNoContext: flags.queue === true || flags.queueOnFailure === true,
typing: flags.typing === true
});
return cliSuccess(result);
+2 -2
View File
@@ -21,13 +21,13 @@ function printJson(payload) {
function help() {
return cliSuccess({
name: "wxb",
milestone: "M8",
milestone: "M12",
commands: {
login: "Scan-login and save an iLink account",
accounts: "List saved accounts without tokens",
status: "Show local account, cursor, conversation, and history status",
fetch: "Long-poll one batch of inbound messages, optionally downloading media, and save local state",
send: "Send text to a user using cached context token",
send: "Send text, files, or images to a user using cached context token",
poll: "Run repeated foreground fetch loops for keepalive and local processing",
heartbeat: "Run one scheduled keepalive fetch without a daemon",
alias: "Manage readable aliases for opaque user IDs",
+3 -1
View File
@@ -20,6 +20,7 @@ const envKeyByConfigKey = Object.freeze({
messageRetentionDays: "WX_MESSAGE_RETENTION_DAYS",
attachmentRetentionDays: "WX_ATTACHMENT_RETENTION_DAYS",
maxHistoryMessages: "WX_MAX_HISTORY_MESSAGES",
maxUploadBytes: "WX_MAX_UPLOAD_BYTES",
botUserName: "BOT_USER_NAME"
});
@@ -34,7 +35,8 @@ const numberKeys = new Set([
"delayedQueueMaxItems",
"messageRetentionDays",
"attachmentRetentionDays",
"maxHistoryMessages"
"maxHistoryMessages",
"maxUploadBytes"
]);
function kebabToCamel(value) {
+118
View File
@@ -71,6 +71,52 @@ export class IlinkClient {
});
}
async getConfig({ token, userId, contextToken, timeoutMs } = {}) {
return this.postJson(endpoints.getConfig, {
ilink_user_id: userId,
context_token: contextToken,
base_info: {
channel_version: this.channelVersion
}
}, { token, timeoutMs });
}
async sendTyping({ token, userId, typingTicket, status, timeoutMs } = {}) {
return this.postJson(endpoints.sendTyping, {
ilink_user_id: userId,
typing_ticket: typingTicket,
status,
base_info: {
channel_version: this.channelVersion
}
}, { token, timeoutMs });
}
async getUploadUrl({ token, upload, timeoutMs } = {}) {
return this.postJson(endpoints.getUploadUrl, {
...upload,
base_info: {
channel_version: this.channelVersion
}
}, { token, timeoutMs });
}
async sendMediaMessage({ token, toUserId, item, contextToken, clientId = generateClientId(), timeoutMs } = {}) {
return this.sendMessage({
token,
timeoutMs,
msg: {
from_user_id: "",
to_user_id: toUserId,
client_id: clientId,
message_type: 2,
message_state: 2,
item_list: [item],
context_token: contextToken
}
});
}
async sendMessage({ token, msg, timeoutMs } = {}) {
return this.postJson(endpoints.sendMessage, {
msg,
@@ -80,6 +126,32 @@ export class IlinkClient {
}, { token, timeoutMs });
}
async uploadBytes({ uploadUrl, bytes, contentType = "application/octet-stream", timeoutMs } = {}) {
if (!uploadUrl) {
throw new WxbError("MEDIA_UPLOAD_URL_MISSING", "iLink did not return a media upload URL.", {
retryable: false
});
}
const response = await this.requestRaw(new URL(uploadUrl, this.baseUrl), {
method: "POST",
headers: {
"Content-Type": contentType
},
body: bytes,
timeoutMs
});
if (response.status >= 400) {
throw new WxbError("MEDIA_UPLOAD_FAILED", "Media upload failed.", {
retryable: response.status >= 500,
status: response.status
});
}
return response;
}
async getJson(pathname, query = {}, options = {}) {
const response = await this.request(joinUrl(this.baseUrl, pathname, query), {
method: "GET",
@@ -169,4 +241,50 @@ export class IlinkClient {
}
}
}
async requestRaw(url, options = {}) {
if (!this.fetchImpl) {
throw new Error("global fetch is unavailable; use Node.js 18+ or pass fetchImpl.");
}
const controller = options.timeoutMs ? new AbortController() : undefined;
const timeout = controller
? setTimeout(() => controller.abort(new Error("request timeout")), options.timeoutMs)
: undefined;
try {
const response = await this.fetchImpl(url, {
method: options.method,
headers: options.headers,
body: options.body,
signal: controller?.signal
});
const responseText = await response.text();
let body;
if (responseText) {
try {
body = JSON.parse(responseText);
} catch {
body = responseText;
}
}
return {
status: response.status,
headers: Object.fromEntries(response.headers.entries()),
body
};
} catch (error) {
const mapped = mapProtocolError({ cause: error });
if (mapped && error.name !== "WxbError") {
throw mapped;
}
throw error;
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
}
+17 -1
View File
@@ -12,7 +12,8 @@ export const protocolDefaults = Object.freeze({
delayedQueueMaxItems: 100,
messageRetentionDays: 30,
attachmentRetentionDays: 30,
maxHistoryMessages: 10000
maxHistoryMessages: 10000,
maxUploadBytes: 25 * 1024 * 1024
});
export const endpoints = Object.freeze({
@@ -38,6 +39,21 @@ export const itemTypes = Object.freeze({
videoCandidate: 43
});
export const outgoingItemTypes = Object.freeze({
text: 1,
image: 2,
voice: 3,
file: 4,
video: 5
});
export const uploadMediaTypes = Object.freeze({
image: 1,
video: 2,
file: 3,
voice: 4
});
export const itemTypeByCode = Object.freeze({
1: "text",
3: "image",
+427
View File
@@ -0,0 +1,427 @@
import { createCipheriv, createHash, randomBytes } from "node:crypto";
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import { WxbError } from "./errors.js";
import { generateClientId, IlinkClient } from "./ilink-client.js";
import { outgoingItemTypes, protocolDefaults, uploadMediaTypes } from "./protocol-constants.js";
import { resolveSendAccount, resolveTargetUserOrAlias } from "./send-text.js";
import { sanitizeAttachmentFileName } from "./media-download.js";
import { withOptionalTyping } from "./typing.js";
import { resolveContextToken } from "../state/context-token-store.js";
import { appendMessageHistory } from "../state/message-history.js";
import { withAccountLock } from "../state/lock.js";
const mediaTypeByKind = Object.freeze({
image: uploadMediaTypes.image,
file: uploadMediaTypes.file
});
const mimeByExtension = Object.freeze({
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".pdf": "application/pdf",
".txt": "text/plain",
".md": "text/markdown",
".json": "application/json",
".csv": "text/csv",
".zip": "application/zip",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".ppt": "application/vnd.ms-powerpoint",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
});
function inferMimeType(filePath) {
return mimeByExtension[path.extname(filePath).toLowerCase()];
}
function md5Hex(buffer) {
return createHash("md5").update(buffer).digest("hex");
}
function encryptAes128Ecb(buffer, key) {
const cipher = createCipheriv("aes-128-ecb", key, null);
return Buffer.concat([cipher.update(buffer), cipher.final()]);
}
function resolveUploadParam(response = {}) {
return response.upload_param
?? response.uploadParam
?? response.data?.upload_param
?? response.data?.uploadParam;
}
function buildCdnUploadUrl({ cdnBaseUrl, uploadParam, fileKey }) {
const url = new URL("upload", cdnBaseUrl.endsWith("/") ? cdnBaseUrl : `${cdnBaseUrl}/`);
url.searchParams.set("encrypted_query_param", uploadParam);
url.searchParams.set("filekey", fileKey);
return url.toString();
}
function resolveUploadUrl(response = {}, { cdnBaseUrl, fileKey } = {}) {
const directUrl = response.upload_url
?? response.uploadUrl
?? response.url
?? response.cdn_upload_url
?? response.cdnUploadUrl
?? response.upload_info?.url
?? response.uploadInfo?.url
?? response.data?.upload_url
?? response.data?.uploadUrl
?? response.data?.url
?? response.data?.upload_info?.url
?? response.data?.uploadInfo?.url;
if (directUrl) {
return directUrl;
}
const uploadParam = resolveUploadParam(response);
if (uploadParam) {
return buildCdnUploadUrl({
cdnBaseUrl: cdnBaseUrl ?? protocolDefaults.cdnBaseUrl,
uploadParam,
fileKey
});
}
return undefined;
}
function resolveDownloadParam(response = {}) {
return response.headers?.["x-encrypted-param"]
?? response.headers?.["X-Encrypted-Param"]
?? response.body?.encrypt_query_param
?? response.body?.encryptQueryParam
?? response.body?.download_param
?? response.body?.downloadParam
?? response.body?.data?.encrypt_query_param
?? response.body?.data?.encryptQueryParam
?? response.body?.data?.download_param
?? response.body?.data?.downloadParam;
}
function resolveUploadConfigDownloadParam(response = {}) {
return response.upload_url
? undefined
: response.encrypt_query_param
?? response.encryptQueryParam
?? response.download_param
?? response.downloadParam
?? response.data?.encrypt_query_param
?? response.data?.encryptQueryParam
?? response.data?.download_param
?? response.data?.downloadParam;
}
function encodeMediaAesKey(aesKeyHex) {
return Buffer.from(aesKeyHex).toString("base64");
}
async function inspectLocalMedia(filePath, kind, config = {}) {
const absolutePath = path.resolve(filePath ?? "");
let info;
try {
info = await stat(absolutePath);
} catch (error) {
if (error.code === "ENOENT") {
throw new WxbError("MEDIA_FILE_NOT_FOUND", "Media file does not exist.", {
retryable: false,
details: { path: absolutePath }
});
}
throw error;
}
if (!info.isFile()) {
throw new WxbError("MEDIA_PATH_NOT_FILE", "Media path must point to a regular file.", {
retryable: false,
details: { path: absolutePath }
});
}
const maxUploadBytes = config.maxUploadBytes ?? 25 * 1024 * 1024;
if (info.size > maxUploadBytes) {
throw new WxbError("MEDIA_FILE_TOO_LARGE", "Media file exceeds the configured upload size limit.", {
retryable: false,
details: {
path: absolutePath,
bytes: info.size,
maxUploadBytes
}
});
}
const mimeType = inferMimeType(absolutePath);
if (!mimeType || (kind === "image" && !mimeType.startsWith("image/"))) {
throw new WxbError("MEDIA_TYPE_UNSUPPORTED", "Media MIME type could not be inferred or is not supported for this send mode.", {
retryable: false,
details: {
path: absolutePath,
kind,
extension: path.extname(absolutePath).toLowerCase() || undefined
}
});
}
const bytes = await readFile(absolutePath);
return {
path: absolutePath,
bytes,
rawSize: bytes.length,
rawMd5: md5Hex(bytes),
fileName: sanitizeAttachmentFileName(path.basename(absolutePath)),
mimeType
};
}
function buildUploadRequest({ fileKey, mediaType, targetUserId, local, encryptedBytes, aesKeyHex }) {
return {
filekey: fileKey,
media_type: mediaType,
to_user_id: targetUserId,
rawsize: local.rawSize,
rawfilemd5: local.rawMd5,
filesize: encryptedBytes.length,
thumb_rawsize: 0,
thumb_rawfilemd5: "",
thumb_filesize: 0,
no_need_thumb: true,
aeskey: aesKeyHex
};
}
function buildCdnMedia({ downloadParam, aesKeyHex }) {
return {
encrypt_query_param: downloadParam,
aes_key: encodeMediaAesKey(aesKeyHex),
encrypt_type: 1
};
}
function buildMediaItem({ kind, local, encryptedBytes, aesKeyHex, downloadParam }) {
if (kind === "image") {
return {
type: outgoingItemTypes.image,
image_item: {
media: {
...buildCdnMedia({ downloadParam, aesKeyHex })
},
mid_size: encryptedBytes.length
}
};
}
return {
type: outgoingItemTypes.file,
file_item: {
media: {
...buildCdnMedia({ downloadParam, aesKeyHex })
},
file_name: local.fileName,
md5: local.rawMd5,
len: String(local.rawSize)
}
};
}
export async function sendMedia(options = {}) {
const {
stateDir,
accountId,
userId,
filePath,
kind = "file",
config = {},
client: providedClient,
timeoutMs = config.fetchTimeoutMs,
typing = false
} = options;
if (!stateDir) {
throw new TypeError("stateDir is required");
}
if (!["file", "image"].includes(kind)) {
throw new WxbError("MEDIA_KIND_UNSUPPORTED", "Only file and image sends are supported.", {
retryable: false,
details: { kind }
});
}
const targetUserId = await resolveTargetUserOrAlias(stateDir, {
userId,
alias: options.alias
});
const account = await resolveSendAccount(stateDir, accountId);
const local = await inspectLocalMedia(filePath, kind, config);
const client = providedClient ?? new IlinkClient({
baseUrl: account.baseUrl ?? config.baseUrl,
channelVersion: config.channelVersion
});
return withAccountLock(stateDir, account.accountId, async () => {
const contextToken = await resolveContextToken(stateDir, account.accountId, targetUserId);
if (!contextToken) {
throw new WxbError("NO_CONTEXT_TOKEN", "No context token is cached for this user; fetch an inbound message first.", {
retryable: false,
details: {
accountId: account.accountId,
userId: targetUserId
}
});
}
return withOptionalTyping({
enabled: typing,
client,
token: account.token,
stateDir,
accountId: account.accountId,
userId: targetUserId,
contextToken,
timeoutMs
}, async (activeContextToken) => {
const clientId = generateClientId();
const aesKey = randomBytes(16);
const aesKeyHex = aesKey.toString("hex");
const encryptedBytes = encryptAes128Ecb(local.bytes, aesKey);
const fileKey = randomBytes(16).toString("hex");
const uploadRequest = buildUploadRequest({
fileKey,
mediaType: mediaTypeByKind[kind],
targetUserId,
local,
encryptedBytes,
aesKeyHex
});
const uploadConfig = await client.getUploadUrl({
token: account.token,
upload: uploadRequest,
timeoutMs
});
const uploadUrl = resolveUploadUrl(uploadConfig, {
cdnBaseUrl: config.cdnBaseUrl,
fileKey
});
if (!uploadUrl) {
throw new WxbError("MEDIA_UPLOAD_URL_MISSING", "iLink did not return a media upload URL.", {
retryable: false,
details: { kind, fileName: local.fileName }
});
}
let uploadResponse;
try {
uploadResponse = await client.uploadBytes({
uploadUrl,
bytes: encryptedBytes,
contentType: "application/octet-stream",
timeoutMs
});
} catch (error) {
if (error instanceof WxbError) {
throw error;
}
throw new WxbError("MEDIA_UPLOAD_FAILED", "Media upload failed.", {
retryable: true,
details: { cause: error?.message ?? String(error) }
});
}
const downloadParam = resolveDownloadParam(uploadResponse) ?? resolveUploadConfigDownloadParam(uploadConfig);
if (!downloadParam) {
throw new WxbError("MEDIA_UPLOAD_PARAM_MISSING", "CDN upload did not return an encrypted media download parameter.", {
retryable: false,
details: { kind, fileName: local.fileName }
});
}
const item = buildMediaItem({
kind,
local,
encryptedBytes,
aesKeyHex,
downloadParam
});
try {
await client.sendMediaMessage({
token: account.token,
toUserId: targetUserId,
item,
contextToken: activeContextToken,
clientId,
timeoutMs
});
} catch (error) {
if (!(error instanceof WxbError)) {
throw error;
}
throw new WxbError(error.code, error.message, {
retryable: error.retryable,
status: error.status,
details: {
...(error.details ?? {}),
uploaded: true,
clientId,
kind,
fileName: local.fileName
}
});
}
const historyEntry = {
id: clientId,
direction: "outgoing",
fromUserId: account.ownerUserId,
toUserId: targetUserId,
timestamp: Math.floor(Date.now() / 1000),
type: kind,
contextToken: activeContextToken,
attachment: {
kind,
fileKey,
fileName: local.fileName,
mimeType: local.mimeType,
bytes: local.rawSize,
encrypted: true,
uploaded: true
}
};
try {
await appendMessageHistory(stateDir, account.accountId, historyEntry);
} catch (error) {
throw new WxbError("OUTGOING_HISTORY_WRITE_FAILED", "Media was sent but local outgoing history could not be written.", {
retryable: false,
details: {
delivered: true,
sentCount: 1,
clientId,
cause: error?.message ?? String(error)
}
});
}
return {
accountId: account.accountId,
toUserId: targetUserId,
kind,
clientId,
fileName: local.fileName,
mimeType: local.mimeType,
bytes: local.rawSize,
encryptedBytes: encryptedBytes.length,
uploaded: true,
sent: true
};
});
});
}
+111 -98
View File
@@ -7,6 +7,7 @@ import { resolveContextToken } from "../state/context-token-store.js";
import { appendMessageHistory } from "../state/message-history.js";
import { withAccountLock } from "../state/lock.js";
import { queueTextDelivery } from "./delivery-queue.js";
import { withOptionalTyping } from "./typing.js";
export async function resolveSendAccount(stateDir, accountId) {
const accounts = await listAccounts(stateDir, { includeSecrets: true });
@@ -83,7 +84,8 @@ export async function sendText(options = {}) {
timeoutMs = config.fetchTimeoutMs,
chunkOptions = {},
queueOnInvalidContext = true,
queueOnNoContext = false
queueOnNoContext = false,
typing = false
} = options;
if (!stateDir) {
@@ -138,103 +140,114 @@ export async function sendText(options = {}) {
});
}
const sent = [];
const startedAt = Math.floor(Date.now() / 1000);
for (let index = 0; index < chunks.length; index += 1) {
const chunk = chunks[index];
const clientId = generateClientId();
try {
await client.sendTextMessage({
token: account.token,
toUserId: targetUserId,
text: chunk,
contextToken,
clientId,
timeoutMs
});
} catch (error) {
if (!(error instanceof WxbError)) {
throw error;
}
if (queueOnInvalidContext && error.code === "INVALID_CONTEXT_TOKEN" && sent.length === 0) {
const queued = await queueTextDelivery({
stateDir,
accountId: account.accountId,
userId: targetUserId,
text,
source: "invalid_context",
error,
maxItems: config.delayedQueueMaxItems,
lock: false
});
return {
accountId: account.accountId,
toUserId: targetUserId,
delivered: false,
queued: true,
queue: queued
};
}
throw new WxbError(error.code, error.message, {
retryable: error.retryable,
status: error.status,
details: {
...(error.details ?? {}),
sentCount: sent.length,
failedChunkIndex: sent.length + 1,
totalChunks: chunks.length
}
});
}
const entry = {
id: clientId,
direction: "outgoing",
fromUserId: account.ownerUserId,
toUserId: targetUserId,
timestamp: startedAt,
type: "text",
text: chunk,
contextToken,
chunkIndex: index + 1,
chunkCount: chunks.length
};
const sentItem = {
clientId: entry.id,
chunkIndex: entry.chunkIndex,
chars: chunk.length
};
try {
await appendMessageHistory(stateDir, account.accountId, entry);
} catch (error) {
throw new WxbError("OUTGOING_HISTORY_WRITE_FAILED", "Message was sent but local outgoing history could not be written.", {
retryable: false,
details: {
delivered: true,
sentCount: sent.length + 1,
failedChunkIndex: entry.chunkIndex,
totalChunks: chunks.length,
clientId: entry.id,
deliveredClientIds: [...sent.map((item) => item.clientId), entry.id],
cause: error?.message ?? String(error)
}
});
}
sent.push(sentItem);
}
return {
return withOptionalTyping({
enabled: typing,
client,
token: account.token,
stateDir,
accountId: account.accountId,
toUserId: targetUserId,
chunkCount: chunks.length,
sent
};
userId: targetUserId,
contextToken,
timeoutMs
}, async (activeContextToken) => {
const sent = [];
const startedAt = Math.floor(Date.now() / 1000);
for (let index = 0; index < chunks.length; index += 1) {
const chunk = chunks[index];
const clientId = generateClientId();
try {
await client.sendTextMessage({
token: account.token,
toUserId: targetUserId,
text: chunk,
contextToken: activeContextToken,
clientId,
timeoutMs
});
} catch (error) {
if (!(error instanceof WxbError)) {
throw error;
}
if (queueOnInvalidContext && error.code === "INVALID_CONTEXT_TOKEN" && sent.length === 0) {
const queued = await queueTextDelivery({
stateDir,
accountId: account.accountId,
userId: targetUserId,
text,
source: "invalid_context",
error,
maxItems: config.delayedQueueMaxItems,
lock: false
});
return {
accountId: account.accountId,
toUserId: targetUserId,
delivered: false,
queued: true,
queue: queued
};
}
throw new WxbError(error.code, error.message, {
retryable: error.retryable,
status: error.status,
details: {
...(error.details ?? {}),
sentCount: sent.length,
failedChunkIndex: sent.length + 1,
totalChunks: chunks.length
}
});
}
const entry = {
id: clientId,
direction: "outgoing",
fromUserId: account.ownerUserId,
toUserId: targetUserId,
timestamp: startedAt,
type: "text",
text: chunk,
contextToken: activeContextToken,
chunkIndex: index + 1,
chunkCount: chunks.length
};
const sentItem = {
clientId: entry.id,
chunkIndex: entry.chunkIndex,
chars: chunk.length
};
try {
await appendMessageHistory(stateDir, account.accountId, entry);
} catch (error) {
throw new WxbError("OUTGOING_HISTORY_WRITE_FAILED", "Message was sent but local outgoing history could not be written.", {
retryable: false,
details: {
delivered: true,
sentCount: sent.length + 1,
failedChunkIndex: entry.chunkIndex,
totalChunks: chunks.length,
clientId: entry.id,
deliveredClientIds: [...sent.map((item) => item.clientId), entry.id],
cause: error?.message ?? String(error)
}
});
}
sent.push(sentItem);
}
return {
accountId: account.accountId,
toUserId: targetUserId,
chunkCount: chunks.length,
sent
};
});
});
}
+120
View File
@@ -0,0 +1,120 @@
import { rememberContextToken } from "../state/context-token-store.js";
function pickTypingTicket(response = {}) {
return response.typing_ticket
?? response.typingTicket
?? response.data?.typing_ticket
?? response.data?.typingTicket;
}
function pickContextToken(response = {}) {
return response.context_token
?? response.contextToken
?? response.data?.context_token
?? response.data?.contextToken;
}
function publicTypingError(error, fallbackCode, message) {
return {
code: error?.code ?? fallbackCode,
message
};
}
function publicStartError(error) {
return publicTypingError(error, "TYPING_START_FAILED", "Typing setup failed before delivery.");
}
function publicStopError(error) {
return publicTypingError(error, "TYPING_STOP_FAILED", "Typing stop failed after the delivery attempt.");
}
export async function withOptionalTyping(options = {}, operation) {
if (!options.enabled) {
return operation(options.contextToken);
}
let activeContextToken = options.contextToken;
let typingTicket;
let startError;
let started = false;
try {
const config = await options.client.getConfig({
token: options.token,
userId: options.userId,
contextToken: options.contextToken,
timeoutMs: options.timeoutMs
});
typingTicket = pickTypingTicket(config);
activeContextToken = pickContextToken(config) ?? options.contextToken;
if (activeContextToken && activeContextToken !== options.contextToken) {
try {
await rememberContextToken(options.stateDir, options.accountId, options.userId, activeContextToken, { lock: false });
} catch (error) {
startError = publicStartError(error);
}
}
if (!typingTicket) {
startError = startError ?? {
code: "TYPING_TICKET_MISSING",
message: "Typing setup failed before delivery."
};
} else {
try {
await options.client.sendTyping({
token: options.token,
userId: options.userId,
typingTicket,
status: 1,
timeoutMs: options.timeoutMs
});
started = true;
} catch (error) {
startError = publicStartError(error);
}
}
} catch (error) {
startError = publicStartError(error);
}
let result;
let operationError;
try {
result = await operation(activeContextToken);
} catch (error) {
operationError = error;
}
let stopError;
if (started) {
try {
await options.client.sendTyping({
token: options.token,
userId: options.userId,
typingTicket,
status: 2,
timeoutMs: options.timeoutMs
});
} catch (error) {
stopError = error;
}
}
if (operationError) {
throw operationError;
}
return {
...result,
typing: {
requested: true,
started,
stopped: started && !stopError,
...(startError ? { startError } : {}),
...(stopError ? { stopError: publicStopError(stopError) } : {})
}
};
}
+53 -2
View File
@@ -12,6 +12,15 @@ export async function startMockIlinkServer(options = {}) {
])];
const getUpdatesResponses = [...(options.getUpdatesResponses ?? [])];
const sendMessageResponses = [...(options.sendMessageResponses ?? [{ ret: 0 }])];
const getUploadUrlResponses = [...(options.getUploadUrlResponses ?? [{ ret: 0, upload_param: "upload_param_fixture" }])];
const uploadResponses = options.uploadResponses ?? {
"/upload": {
headers: { "x-encrypted-param": "download_param_fixture" },
body: { ret: 0 }
}
};
const getConfigResponses = [...(options.getConfigResponses ?? [{ ret: 0, typing_ticket: "typing_ticket_fixture" }])];
const sendTypingResponses = [...(options.sendTypingResponses ?? [{ ret: 0 }])];
const mediaResponses = options.mediaResponses ?? {};
const server = http.createServer(async (req, res) => {
@@ -20,13 +29,20 @@ export async function startMockIlinkServer(options = {}) {
for await (const chunk of req) {
chunks.push(chunk);
}
const rawBody = Buffer.concat(chunks).toString("utf8");
const body = rawBody ? JSON.parse(rawBody) : undefined;
const rawBuffer = Buffer.concat(chunks);
const rawBody = rawBuffer.toString("utf8");
const contentType = String(req.headers["content-type"] ?? "");
const body = rawBuffer.length === 0
? undefined
: contentType.includes("application/json")
? JSON.parse(rawBody)
: rawBuffer;
requests.push({
method: req.method,
pathname: url.pathname,
searchParams: Object.fromEntries(url.searchParams.entries()),
headers: req.headers,
rawBody,
body
});
@@ -59,6 +75,41 @@ export async function startMockIlinkServer(options = {}) {
return;
}
if (req.method === "POST" && url.pathname === "/ilink/bot/getuploadurl") {
const response = getUploadUrlResponses.length > 1 ? getUploadUrlResponses.shift() : getUploadUrlResponses[0];
const status = response?.httpStatus ?? 200;
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(response?.body ?? response ?? { ret: 0, upload_param: "upload_param_fixture" }));
return;
}
if (req.method === "POST" && Object.hasOwn(uploadResponses, url.pathname)) {
const response = uploadResponses[url.pathname];
const status = response?.httpStatus ?? 200;
res.writeHead(status, {
"Content-Type": response?.contentType ?? "application/json",
...(response?.headers ?? {})
});
res.end(Buffer.isBuffer(response?.body) ? response.body : JSON.stringify(response?.body ?? response ?? { ret: 0 }));
return;
}
if (req.method === "POST" && url.pathname === "/ilink/bot/getconfig") {
const response = getConfigResponses.length > 1 ? getConfigResponses.shift() : getConfigResponses[0];
const status = response?.httpStatus ?? 200;
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(response?.body ?? response ?? { ret: 0, typing_ticket: "typing_ticket_fixture" }));
return;
}
if (req.method === "POST" && url.pathname === "/ilink/bot/sendtyping") {
const response = sendTypingResponses.length > 1 ? sendTypingResponses.shift() : sendTypingResponses[0];
const status = response?.httpStatus ?? 200;
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(response?.body ?? response ?? { ret: 0 }));
return;
}
if (req.method === "GET" && Object.hasOwn(mediaResponses, url.pathname)) {
const response = mediaResponses[url.pathname];
const status = response?.httpStatus ?? 200;
+4 -4
View File
@@ -6,7 +6,7 @@ import test from "node:test";
const execFileAsync = promisify(execFile);
test("wxb M8 CLI entry emits JSON help", async () => {
test("wxb M12 CLI entry emits JSON help", async () => {
const cliPath = path.resolve("src", "cli", "index.js");
const { stdout } = await execFileAsync(process.execPath, [cliPath, "help"], {
cwd: path.resolve(".")
@@ -15,15 +15,15 @@ test("wxb M8 CLI entry emits JSON help", async () => {
const parsed = JSON.parse(stdout);
assert.equal(parsed.ok, true);
assert.equal(parsed.data.name, "wxb");
assert.equal(parsed.data.milestone, "M8");
assert.equal(parsed.data.milestone, "M12");
assert.equal(parsed.data.commands.login, "Scan-login and save an iLink account");
assert.equal(parsed.data.commands.fetch, "Long-poll one batch of inbound messages, optionally downloading media, and save local state");
assert.equal(parsed.data.commands.send, "Send text to a user using cached context token");
assert.equal(parsed.data.commands.send, "Send text, files, or images to a user using cached context token");
assert.equal(parsed.data.commands.poll, "Run repeated foreground fetch loops for keepalive and local processing");
assert.equal(parsed.data.commands.cleanup, "Prune local message history and inbox attachments");
});
test("wxb M8 CLI entry emits structured errors for unknown commands", async () => {
test("wxb M12 CLI entry emits structured errors for unknown commands", async () => {
const cliPath = path.resolve("src", "cli", "index.js");
await assert.rejects(
+253
View File
@@ -0,0 +1,253 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { promisify } from "node:util";
import { startMockIlinkServer } from "../helpers/mock-ilink-server.js";
import { rememberContextToken } from "../../src/state/context-token-store.js";
import { readMessageHistory } from "../../src/state/message-history.js";
const execFileAsync = promisify(execFile);
async function withTempDir(callback) {
const dir = await mkdtemp(path.join(os.tmpdir(), "wxb-m11 状态 "));
try {
return await callback(dir);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
async function runCli(args, options = {}) {
return execFileAsync(process.execPath, [path.resolve("src", "cli", "index.js"), ...args], {
cwd: path.resolve("."),
env: {
...process.env,
...options.env
}
});
}
async function loginFixtureAccount(server, stateDir) {
await runCli([
"login",
"--quiet",
"--base-url", server.baseUrl,
"--state-dir", stateDir,
"--poll-interval-ms", "0",
"--max-polls", "1"
]);
}
test("wxb send --image uploads media, sends typing status, and hides upload secrets", async () => {
await withTempDir(async (stateDir) => {
const imagePath = path.join(stateDir, "sample.jpg");
await writeFile(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]));
const server = await startMockIlinkServer({
statusResponses: [
{
status: "confirmed",
bot_token: "bot_secret_m11",
ilink_bot_id: "bot_m11",
ilink_user_id: "owner_m11"
}
],
getConfigResponses: [
{
ret: 0,
typing_ticket: "typing_ticket_m11",
context_token: "ctx_secret_m11_refreshed"
}
],
getUploadUrlResponses: [
{
ret: 0,
upload_param: "upload_param_m11"
}
],
uploadResponses: {
"/upload": {
headers: {
"x-encrypted-param": "download_param_m11"
},
body: { ret: 0 }
}
},
sendMessageResponses: [{ ret: 0 }]
});
try {
await loginFixtureAccount(server, stateDir);
await rememberContextToken(stateDir, "bot_m11", "user_m11", "ctx_secret_m11");
const { stdout } = await runCli([
"send",
"--state-dir", stateDir,
"--cdn-base-url", server.baseUrl,
"--user", "user_m11",
"--image", imagePath,
"--typing",
"--timeout", "1000"
]);
const parsed = JSON.parse(stdout);
assert.equal(parsed.ok, true);
assert.equal(parsed.data.kind, "image");
assert.equal(parsed.data.fileName, "sample.jpg");
assert.equal(parsed.data.mimeType, "image/jpeg");
assert.equal(parsed.data.typing.started, true);
assert.equal(parsed.data.typing.stopped, true);
assert.equal(stdout.includes("ctx_secret_m11"), false);
assert.equal(stdout.includes("bot_secret_m11"), false);
assert.equal(stdout.includes("typing_ticket_m11"), false);
assert.equal(stdout.includes("upload_param_m11"), false);
assert.equal(stdout.includes("download_param_m11"), false);
const getConfigRequest = server.requests.find((request) => request.pathname === "/ilink/bot/getconfig");
assert.equal(getConfigRequest.body.ilink_user_id, "user_m11");
assert.equal(getConfigRequest.body.context_token, "ctx_secret_m11");
const typingRequests = server.requests.filter((request) => request.pathname === "/ilink/bot/sendtyping");
assert.deepEqual(typingRequests.map((request) => request.body.status), [1, 2]);
assert.equal(typingRequests[0].body.typing_ticket, "typing_ticket_m11");
const uploadUrlRequest = server.requests.find((request) => request.pathname === "/ilink/bot/getuploadurl");
assert.equal(uploadUrlRequest.headers.authorization, "Bearer bot_secret_m11");
assert.equal(uploadUrlRequest.body.to_user_id, "user_m11");
assert.equal(uploadUrlRequest.body.media_type, 1);
assert.equal(uploadUrlRequest.body.rawsize, 6);
assert.match(uploadUrlRequest.body.filekey, /^[a-f0-9]{32}$/);
assert.match(uploadUrlRequest.body.aeskey, /^[a-f0-9]{32}$/);
const uploadRequest = server.requests.find((request) => request.pathname === "/upload");
assert.equal(uploadRequest.searchParams.encrypted_query_param, "upload_param_m11");
assert.match(uploadRequest.searchParams.filekey, /^[a-f0-9]{32}$/);
assert.ok(Buffer.isBuffer(uploadRequest.body));
assert.ok(uploadRequest.body.length > 6);
const sendRequest = server.requests.find((request) => request.pathname === "/ilink/bot/sendmessage");
assert.equal(sendRequest.body.msg.context_token, "ctx_secret_m11_refreshed");
assert.equal(sendRequest.body.msg.item_list[0].type, 2);
assert.equal(sendRequest.body.msg.item_list[0].image_item.media.encrypt_query_param, "download_param_m11");
assert.match(sendRequest.body.msg.item_list[0].image_item.media.aes_key, /^[A-Za-z0-9+/]+=*$/);
assert.equal(sendRequest.body.msg.item_list[0].image_item.media.encrypt_type, 1);
const history = await readMessageHistory(stateDir, "bot_m11");
assert.equal(history.length, 1);
assert.equal(history[0].type, "image");
assert.equal(history[0].attachment.fileName, "sample.jpg");
} finally {
await server.close();
}
});
});
test("wxb send reports media validation and source errors as structured JSON", async () => {
await withTempDir(async (stateDir) => {
const server = await startMockIlinkServer({
statusResponses: [
{
status: "confirmed",
bot_token: "bot_secret_m11",
ilink_bot_id: "bot_m11",
ilink_user_id: "owner_m11"
}
]
});
try {
await loginFixtureAccount(server, stateDir);
await rememberContextToken(stateDir, "bot_m11", "user_m11", "ctx_secret_m11");
await assert.rejects(
runCli([
"send",
"--state-dir", stateDir,
"--user", "user_m11",
"--text", "hello",
"--file", path.join(stateDir, "missing.pdf")
]),
(error) => {
const parsed = JSON.parse(error.stdout);
assert.equal(parsed.error.code, "SEND_SOURCE_AMBIGUOUS");
return true;
}
);
await assert.rejects(
runCli([
"send",
"--state-dir", stateDir,
"--user", "user_m11",
"--file", path.join(stateDir, "missing.pdf")
]),
(error) => {
const parsed = JSON.parse(error.stdout);
assert.equal(parsed.error.code, "MEDIA_FILE_NOT_FOUND");
return true;
}
);
} finally {
await server.close();
}
});
});
test("wxb send reports upload failures before sendmessage", async () => {
await withTempDir(async (stateDir) => {
const filePath = path.join(stateDir, "broken.png");
await writeFile(filePath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
const server = await startMockIlinkServer({
statusResponses: [
{
status: "confirmed",
bot_token: "bot_secret_m11",
ilink_bot_id: "bot_m11",
ilink_user_id: "owner_m11"
}
],
getUploadUrlResponses: [
{
ret: 0,
upload_param: "upload_param_m11_fail"
}
],
uploadResponses: {
"/upload": {
httpStatus: 500,
body: { ret: 500, errmsg: "cdn failed" }
}
}
});
try {
await loginFixtureAccount(server, stateDir);
await rememberContextToken(stateDir, "bot_m11", "user_m11", "ctx_secret_m11");
await assert.rejects(
runCli([
"send",
"--state-dir", stateDir,
"--cdn-base-url", server.baseUrl,
"--user", "user_m11",
"--image", filePath,
"--timeout", "1000"
]),
(error) => {
const parsed = JSON.parse(error.stdout);
assert.equal(parsed.error.code, "MEDIA_UPLOAD_FAILED");
assert.equal(error.stdout.includes("upload_param_m11_fail"), false);
assert.equal(error.stdout.includes("ctx_secret_m11"), false);
return true;
}
);
assert.ok(server.requests.some((request) => request.pathname === "/upload"));
assert.equal(server.requests.some((request) => request.pathname === "/ilink/bot/sendmessage"), false);
} finally {
await server.close();
}
});
});
+4 -2
View File
@@ -28,10 +28,11 @@ test("parses .env content with comments and quoted values", () => {
});
test("parses kebab-case CLI flags to camelCase config keys", () => {
assert.deepEqual(parseCliArgs(["--base-url", "https://cli.example", "--fetch-timeout-ms=123", "--cdn-base-url=https://cdn.example/path?x=1&y=2"]), {
assert.deepEqual(parseCliArgs(["--base-url", "https://cli.example", "--fetch-timeout-ms=123", "--max-upload-bytes=456", "--cdn-base-url=https://cdn.example/path?x=1&y=2"]), {
baseUrl: "https://cli.example",
cdnBaseUrl: "https://cdn.example/path?x=1&y=2",
fetchTimeoutMs: "123"
fetchTimeoutMs: "123",
maxUploadBytes: "456"
});
});
@@ -47,6 +48,7 @@ test("loads defaults and resolves a Windows default state directory", async () =
assert.equal(config.baseUrl, "https://ilinkai.weixin.qq.com");
assert.equal(config.channelVersion, "0.1.0");
assert.equal(config.maxUploadBytes, 25 * 1024 * 1024);
assert.equal(config.stateDir, path.join(cwd, "Local App Data", "wxb"));
});
});
+109 -1
View File
@@ -2,9 +2,19 @@ import assert from "node:assert/strict";
import test from "node:test";
import { IlinkClient } from "../../src/core/ilink-client.js";
function jsonResponse(status, body) {
function responseHeaders(headers = {}) {
const normalized = Object.fromEntries(
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value])
);
return {
entries: () => Object.entries(normalized)
};
}
function jsonResponse(status, body, headers) {
return {
status,
headers: responseHeaders(headers),
text: async () => JSON.stringify(body)
};
}
@@ -12,6 +22,7 @@ function jsonResponse(status, body) {
function textResponse(status, body) {
return {
status,
headers: responseHeaders(),
text: async () => body
};
}
@@ -65,3 +76,100 @@ test("maps successful non-JSON responses as invalid responses", async () => {
}
);
});
test("posts upload, typing, and media send payloads with iLink headers", async () => {
const requests = [];
const client = new IlinkClient({
baseUrl: "https://mock.example",
fetchImpl: async (url, options) => {
requests.push({
url: String(url),
method: options.method,
headers: options.headers,
body: options.body
});
return jsonResponse(200, { ret: 0, upload_url: "https://upload.example/media", typing_ticket: "ticket" });
}
});
await client.getUploadUrl({
token: "token_fixture",
upload: {
filekey: "filekey_fixture",
media_type: 1,
to_user_id: "user_fixture"
}
});
await client.getConfig({
token: "token_fixture",
userId: "user_fixture",
contextToken: "ctx_fixture"
});
await client.sendTyping({
token: "token_fixture",
userId: "user_fixture",
typingTicket: "ticket_fixture",
status: 1
});
await client.sendMediaMessage({
token: "token_fixture",
toUserId: "user_fixture",
contextToken: "ctx_fixture",
clientId: "client_fixture",
item: {
type: 2,
image_item: {
media: {
encrypt_query_param: "download_param_fixture",
aes_key: "YWVzX2ZpeHR1cmU=",
encrypt_type: 1
}
}
}
});
assert.equal(requests[0].url, "https://mock.example/ilink/bot/getuploadurl");
assert.equal(JSON.parse(requests[0].body).filekey, "filekey_fixture");
assert.equal(JSON.parse(requests[0].body).base_info.channel_version, "0.1.0");
assert.equal(JSON.parse(requests[1].body).context_token, "ctx_fixture");
assert.equal(JSON.parse(requests[2].body).typing_ticket, "ticket_fixture");
assert.equal(JSON.parse(requests[2].body).status, 1);
const mediaBody = JSON.parse(requests[3].body);
assert.equal(mediaBody.msg.client_id, "client_fixture");
assert.equal(mediaBody.msg.item_list[0].type, 2);
for (const request of requests) {
assert.equal(request.headers.Authorization, "Bearer token_fixture");
assert.equal(request.headers.AuthorizationType, "ilink_bot_token");
assert.match(request.headers["X-WECHAT-UIN"], /^[A-Za-z0-9+/]+=*$/);
}
});
test("uploads raw media bytes without bearer headers", async () => {
const requests = [];
const bytes = Buffer.from("encrypted bytes");
const client = new IlinkClient({
baseUrl: "https://mock.example",
fetchImpl: async (url, options) => {
requests.push({
url: String(url),
method: options.method,
headers: options.headers,
body: options.body
});
return jsonResponse(200, { ret: 0 }, { "x-encrypted-param": "download_param_fixture" });
}
});
const response = await client.uploadBytes({
uploadUrl: "https://upload.example/media",
bytes,
contentType: "application/octet-stream"
});
assert.equal(requests[0].url, "https://upload.example/media");
assert.equal(requests[0].method, "POST");
assert.equal(requests[0].headers.Authorization, undefined);
assert.equal(requests[0].headers["Content-Type"], "application/octet-stream");
assert.equal(requests[0].body, bytes);
assert.equal(response.headers["x-encrypted-param"], "download_param_fixture");
});
+1 -1
View File
@@ -5,7 +5,7 @@ import test from "node:test";
test("README documents M10 source installation and public repository safety", async () => {
const readme = await readFile("README.md", "utf8");
assert.match(readme, /当前 M10 版本/);
assert.match(readme, /当前 M12 beta 版本/);
assert.match(readme, /git clone https:\/\/github\.com\/DONGH4O\/wechat-ilink-bridge\.git/);
assert.match(readme, /node \.\\src\\cli\\index\.js help/);
assert.match(readme, /node \.\/src\/cli\/index\.js help/);
+30
View File
@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
test("M12 validation report records beta package install smoke", async () => {
const report = await readFile("docs/m12-validation-report.md", "utf8");
assert.match(report, /M12 npm beta 分发验收记录/);
assert.match(report, /0\.1\.0-beta\.1/);
assert.match(report, /npm\.cmd test/);
assert.match(report, /126 项测试通过/);
assert.match(report, /npm\.cmd run pack:dry-run/);
assert.match(report, /54 个文件/);
assert.match(report, /npm\.cmd pack/);
assert.match(report, /--prefix C:\\tmp\\wxb-m12-global/);
assert.match(report, /milestone: "M12"/);
assert.match(report, /E403/);
assert.match(report, /双因素验证码/);
assert.match(report, /publish --dry-run --tag beta --access public/);
assert.match(report, /--otp <one-time-code>/);
});
test("README documents beta tag and local tarball install paths", async () => {
const readme = await readFile("README.md", "utf8");
assert.match(readme, /npm Beta 安装/);
assert.match(readme, /npm\.cmd install -g @dongh4o\/wechat-ilink-bridge@beta/);
assert.match(readme, /dongh4o-wechat-ilink-bridge-0\.1\.0-beta\.1\.tgz/);
assert.match(readme, /已可通过 `@dongh4o\/wechat-ilink-bridge@beta` 安装/);
});
+68
View File
@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
test("GitHub Actions CI covers M13 platform and Node matrix", async () => {
const workflow = await readFile(".github/workflows/ci.yml", "utf8");
assert.match(workflow, /name: CI/);
assert.match(workflow, /windows-latest/);
assert.match(workflow, /ubuntu-latest/);
assert.match(workflow, /macos-latest/);
assert.match(workflow, /18\.x/);
assert.match(workflow, /20\.x/);
assert.match(workflow, /22\.x/);
assert.match(workflow, /actions\/checkout@v4/);
assert.match(workflow, /actions\/setup-node@v4/);
assert.match(workflow, /npm test/);
assert.match(workflow, /npm pack --dry-run/);
});
test("M13 troubleshooting docs cover stable user-facing failure modes", async () => {
const troubleshooting = await readFile("docs/troubleshooting.md", "utf8");
for (const phrase of [
"is not found",
"PowerShell blocks",
"NO_ACCOUNT",
"SESSION_EXPIRED",
"NO_CONTEXT_TOKEN",
"INVALID_CONTEXT_TOKEN",
"MEDIA_FILE_NOT_FOUND",
"MEDIA_TYPE_UNSUPPORTED",
"MEDIA_FILE_TOO_LARGE",
"STATE_JSON_INVALID",
"Safe Debugging"
]) {
assert.match(troubleshooting, new RegExp(phrase));
}
});
test("M13 validation report records stable release gates", async () => {
const report = await readFile("docs/m13-validation-report.md", "utf8");
assert.match(report, /M13 P1 稳定版准备验收记录/);
assert.match(report, /GitHub Actions CI matrix/);
assert.match(report, /0\.1\.0-beta\.1/);
assert.match(report, /0\.1\.0/);
assert.match(report, /Windows、Ubuntu、macOS/);
assert.match(report, /Node\.js 18、20、22/);
assert.match(report, /CHANGELOG\.md/);
assert.match(report, /npm publish --access public/);
});
test("README and release process link stable readiness guidance", async () => {
const [readme, releaseProcess, changelog] = await Promise.all([
readFile("README.md", "utf8"),
readFile("docs/release-process.md", "utf8"),
readFile("CHANGELOG.md", "utf8")
]);
assert.match(readme, /稳定版准备状态/);
assert.match(readme, /docs\/troubleshooting\.md/);
assert.match(releaseProcess, /Stable release checklist/);
assert.match(releaseProcess, /git tag v0\.1\.0/);
assert.match(releaseProcess, /GitHub Actions CI/);
assert.match(changelog, /M13 CI matrix/);
assert.match(changelog, /troubleshooting guidance/);
});
+12 -7
View File
@@ -2,14 +2,14 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
test("package metadata marks the M9 local release framework", async () => {
test("package metadata marks the M12 npm beta candidate", async () => {
const manifest = JSON.parse(await readFile("package.json", "utf8"));
assert.equal(manifest.name, "@dongh4o/wechat-ilink-bridge");
assert.equal(manifest.version, "0.1.0-beta.0");
assert.equal(manifest.private, true);
assert.equal(manifest.version, "0.1.0-beta.1");
assert.equal(manifest.private, false);
assert.equal(manifest.license, "MIT");
assert.equal(manifest.bin.wxb, "./src/cli/index.js");
assert.equal(manifest.bin.wxb, "src/cli/index.js");
assert.equal("wxb-spike" in manifest.bin, false);
assert.equal(manifest.scripts["pack:dry-run"], "npm pack --dry-run");
assert.equal(manifest.scripts.spike, "node scripts/protocol-spike.js");
@@ -47,8 +47,10 @@ test("README documents Windows usage and M9 release expectations", async () => {
assert.match(readme, /恢复提示/);
assert.match(readme, /NO_CONTEXT_TOKEN/);
assert.match(readme, /SESSION_EXPIRED/);
assert.match(readme, /0\.1\.0-beta\.0/);
assert.match(readme, /package\.json\.private/);
assert.match(readme, /0\.1\.0-beta\.1/);
assert.match(readme, /已可通过 `@dongh4o\/wechat-ilink-bridge@beta` 安装/);
assert.match(readme, /npm\.cmd install -g @dongh4o\/wechat-ilink-bridge@beta/);
assert.match(readme, /dongh4o-wechat-ilink-bridge-0\.1\.0-beta\.1\.tgz/);
assert.match(readme, /@dongh4o\/wechat-ilink-bridge/);
assert.match(readme, /DONGH4O\/wechat-ilink-bridge/);
assert.match(readme, /LICENSE/);
@@ -66,7 +68,7 @@ test("release framework documents package boundaries and secret audit", async ()
readFile("CHANGELOG.md", "utf8")
]);
assert.match(releaseProcess, /0\.1\.0-beta\.0/);
assert.match(releaseProcess, /0\.1\.0-beta\.1/);
assert.match(releaseProcess, /@dongh4o\/wechat-ilink-bridge/);
assert.match(releaseProcess, /DONGH4O\/wechat-ilink-bridge/);
assert.match(releaseProcess, /package\.json\.private/);
@@ -80,6 +82,8 @@ test("release framework documents package boundaries and secret audit", async ()
assert.match(releaseProcess, /LICENSE/);
assert.match(releaseProcess, /m8-image-fetch\.stdout\.json/);
assert.match(releaseProcess, /git status --short --ignored/);
assert.match(releaseProcess, /npm publish --tag beta --access public/);
assert.match(releaseProcess, /--prefix \$prefix/);
assert.match(releaseProcess, /\.workbuddy\//);
assert.match(npmignore, /^\.env$/m);
assert.match(npmignore, /^m\*-\*\.stdout\.json$/m);
@@ -91,5 +95,6 @@ test("release framework documents package boundaries and secret audit", async ()
assert.match(gitignore, /^\*\.tgz$/m);
assert.match(gitignore, /^\*\.log$/m);
assert.match(license, /^MIT License/);
assert.match(changelog, /## 0\.1\.0-beta\.1 - 2026-05-17/);
assert.match(changelog, /## 0\.1\.0-beta\.0 - 2026-05-17/);
});
+10 -1
View File
@@ -4,18 +4,27 @@ import {
endpoints,
itemTypeByCode,
messageIdFields,
outgoingItemTypes,
protocolDefaults,
timestampFields
timestampFields,
uploadMediaTypes
} from "../../src/core/protocol-constants.js";
import { generateClientId, makeWechatUin } from "../../src/core/ilink-client.js";
test("M0 constants match documented defaults", () => {
assert.equal(protocolDefaults.channelVersion, "0.1.0");
assert.equal(protocolDefaults.qrBotType, 3);
assert.equal(protocolDefaults.maxUploadBytes, 25 * 1024 * 1024);
assert.equal(endpoints.getUpdates, "/ilink/bot/getupdates");
assert.equal(endpoints.getUploadUrl, "/ilink/bot/getuploadurl");
assert.equal(endpoints.sendTyping, "/ilink/bot/sendtyping");
assert.equal(itemTypeByCode[1], "text");
assert.equal(itemTypeByCode[3], "image");
assert.equal(itemTypeByCode[4], "file");
assert.equal(outgoingItemTypes.image, 2);
assert.equal(outgoingItemTypes.file, 4);
assert.equal(uploadMediaTypes.image, 1);
assert.equal(uploadMediaTypes.file, 3);
assert.equal(messageIdFields[0], "msg_id");
assert.equal(timestampFields[0], "timestamp");
});
+333
View File
@@ -0,0 +1,333 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { sendMedia } from "../../src/core/send-media.js";
import { WxbError } from "../../src/core/errors.js";
import { saveAccount } from "../../src/state/account-store.js";
import { rememberContextToken, resolveContextToken } from "../../src/state/context-token-store.js";
import { readMessageHistory } from "../../src/state/message-history.js";
async function withTempDir(callback) {
const dir = await mkdtemp(path.join(os.tmpdir(), "wxb-send-media-"));
try {
return await callback(dir);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
async function seedAccount(stateDir) {
await saveAccount(stateDir, {
accountId: "bot_media",
token: "bot_secret_media",
baseUrl: "https://mock.example",
ownerUserId: "owner_media"
});
await rememberContextToken(stateDir, "bot_media", "user_media", "ctx_media");
}
function fakeMediaClient(options = {}) {
const calls = {
getUploadUrl: [],
uploadBytes: [],
sendMediaMessage: [],
getConfig: [],
sendTyping: []
};
return {
calls,
async getUploadUrl(request) {
calls.getUploadUrl.push(request);
return options.uploadConfig ?? {
ret: 0,
upload_param: "upload_param_fixture"
};
},
async uploadBytes(request) {
calls.uploadBytes.push(request);
if (options.uploadError) {
throw options.uploadError;
}
return {
status: 200,
headers: { "x-encrypted-param": "download_param_fixture" },
body: { ret: 0 }
};
},
async sendMediaMessage(request) {
calls.sendMediaMessage.push(request);
if (options.sendError) {
throw options.sendError;
}
return { ret: 0 };
},
async getConfig(request) {
calls.getConfig.push(request);
if (options.configError) {
throw options.configError;
}
return options.configResponse ?? {
ret: 0,
typing_ticket: "typing_ticket",
context_token: "ctx_media_refreshed"
};
},
async sendTyping(request) {
calls.sendTyping.push(request);
const failure = options.typingFailAt === calls.sendTyping.length ? options.typingError : undefined;
if (failure) {
throw failure;
}
return { ret: 0 };
}
};
}
test("sendMedia uploads encrypted image bytes, sends media message, and writes safe history", async () => {
await withTempDir(async (stateDir) => {
await seedAccount(stateDir);
const imagePath = path.join(stateDir, "sample.png");
await writeFile(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]));
const client = fakeMediaClient();
const result = await sendMedia({
stateDir,
userId: "user_media",
filePath: imagePath,
kind: "image",
client,
config: { maxUploadBytes: 1024, cdnBaseUrl: "https://upload.example/c2c" }
});
assert.equal(result.kind, "image");
assert.equal(result.fileName, "sample.png");
assert.equal(result.mimeType, "image/png");
assert.equal(result.bytes, 6);
assert.equal(result.uploaded, true);
assert.equal(result.sent, true);
assert.equal(JSON.stringify(result).includes("ctx_media"), false);
assert.equal(JSON.stringify(result).includes("bot_secret_media"), false);
assert.equal(client.calls.getUploadUrl.length, 1);
assert.equal(client.calls.getUploadUrl[0].token, "bot_secret_media");
assert.equal(client.calls.getUploadUrl[0].upload.media_type, 1);
assert.equal(client.calls.getUploadUrl[0].upload.to_user_id, "user_media");
assert.equal(client.calls.getUploadUrl[0].upload.rawsize, 6);
assert.match(client.calls.getUploadUrl[0].upload.rawfilemd5, /^[a-f0-9]{32}$/);
assert.match(client.calls.getUploadUrl[0].upload.aeskey, /^[a-f0-9]{32}$/);
assert.equal(client.calls.uploadBytes.length, 1);
assert.match(client.calls.uploadBytes[0].uploadUrl, /^https:\/\/upload\.example\/c2c\/upload\?/);
assert.match(client.calls.uploadBytes[0].uploadUrl, /encrypted_query_param=upload_param_fixture/);
assert.match(client.calls.uploadBytes[0].uploadUrl, /filekey=[a-f0-9]{32}/);
assert.ok(Buffer.isBuffer(client.calls.uploadBytes[0].bytes));
assert.notEqual(client.calls.uploadBytes[0].bytes.toString("hex"), (await readFile(imagePath)).toString("hex"));
const send = client.calls.sendMediaMessage[0];
assert.equal(send.toUserId, "user_media");
assert.equal(send.contextToken, "ctx_media");
assert.equal(send.item.type, 2);
assert.equal(send.item.image_item.media.encrypt_query_param, "download_param_fixture");
assert.match(send.item.image_item.media.aes_key, /^[A-Za-z0-9+/]+=*$/);
assert.equal(send.item.image_item.media.encrypt_type, 1);
assert.equal(send.item.image_item.mid_size, client.calls.uploadBytes[0].bytes.length);
assert.match(send.clientId, /^wxb-\d+-[a-f0-9]{8}$/);
const history = await readMessageHistory(stateDir, "bot_media");
assert.equal(history.length, 1);
assert.equal(history[0].type, "image");
assert.match(history[0].attachment.fileKey, /^[a-f0-9]{32}$/);
assert.equal(history[0].attachment.fileName, "sample.png");
});
});
test("sendMedia supports typing and refreshed context tokens", async () => {
await withTempDir(async (stateDir) => {
await seedAccount(stateDir);
const filePath = path.join(stateDir, "report.pdf");
await writeFile(filePath, Buffer.from("%PDF fixture"));
const client = fakeMediaClient();
const result = await sendMedia({
stateDir,
userId: "user_media",
filePath,
kind: "file",
client,
typing: true,
config: { maxUploadBytes: 1024, cdnBaseUrl: "https://upload.example/c2c" }
});
assert.deepEqual(client.calls.sendTyping.map((call) => call.status), [1, 2]);
assert.equal(client.calls.getConfig[0].contextToken, "ctx_media");
assert.equal(client.calls.sendMediaMessage[0].contextToken, "ctx_media_refreshed");
assert.equal(await resolveContextToken(stateDir, "bot_media", "user_media"), "ctx_media_refreshed");
assert.equal(result.typing.started, true);
assert.equal(result.typing.stopped, true);
});
});
test("sendMedia continues delivery when typing ticket is missing", async () => {
await withTempDir(async (stateDir) => {
await seedAccount(stateDir);
const filePath = path.join(stateDir, "report.pdf");
await writeFile(filePath, Buffer.from("%PDF fixture"));
const client = fakeMediaClient({
configResponse: {
ret: 0,
context_token: "ctx_media_refreshed"
}
});
const result = await sendMedia({
stateDir,
userId: "user_media",
filePath,
kind: "file",
client,
typing: true,
config: { maxUploadBytes: 1024, cdnBaseUrl: "https://upload.example/c2c" }
});
assert.equal(client.calls.getConfig.length, 1);
assert.equal(client.calls.sendTyping.length, 0);
assert.equal(client.calls.sendMediaMessage.length, 1);
assert.equal(client.calls.sendMediaMessage[0].contextToken, "ctx_media_refreshed");
assert.equal(await resolveContextToken(stateDir, "bot_media", "user_media"), "ctx_media_refreshed");
assert.equal(result.sent, true);
assert.equal(result.typing.requested, true);
assert.equal(result.typing.started, false);
assert.equal(result.typing.stopped, false);
assert.equal(result.typing.startError.code, "TYPING_TICKET_MISSING");
assert.equal(JSON.stringify(result).includes("ctx_media"), false);
assert.equal(JSON.stringify(result).includes("bot_secret_media"), false);
});
});
test("sendMedia reports local file validation failures before contacting the client", async () => {
await withTempDir(async (stateDir) => {
await seedAccount(stateDir);
const client = fakeMediaClient();
await assert.rejects(
sendMedia({
stateDir,
userId: "user_media",
filePath: path.join(stateDir, "missing.png"),
kind: "image",
client
}),
(error) => error.code === "MEDIA_FILE_NOT_FOUND"
);
const dirPath = path.join(stateDir, "folder.pdf");
await mkdir(dirPath);
await assert.rejects(
sendMedia({
stateDir,
userId: "user_media",
filePath: dirPath,
kind: "file",
client
}),
(error) => error.code === "MEDIA_PATH_NOT_FILE"
);
const tooLargePath = path.join(stateDir, "large.pdf");
await writeFile(tooLargePath, Buffer.alloc(4));
await assert.rejects(
sendMedia({
stateDir,
userId: "user_media",
filePath: tooLargePath,
kind: "file",
client,
config: { maxUploadBytes: 3 }
}),
(error) => error.code === "MEDIA_FILE_TOO_LARGE"
);
const unknownPath = path.join(stateDir, "unknown.bin");
await writeFile(unknownPath, Buffer.from("unknown"));
await assert.rejects(
sendMedia({
stateDir,
userId: "user_media",
filePath: unknownPath,
kind: "file",
client
}),
(error) => error.code === "MEDIA_TYPE_UNSUPPORTED"
);
assert.equal(client.calls.getUploadUrl.length, 0);
assert.equal(client.calls.uploadBytes.length, 0);
assert.equal(client.calls.sendMediaMessage.length, 0);
});
});
test("sendMedia reports upload failures without sending media message", async () => {
await withTempDir(async (stateDir) => {
await seedAccount(stateDir);
const filePath = path.join(stateDir, "broken.pdf");
await writeFile(filePath, Buffer.from("%PDF fixture"));
const client = fakeMediaClient({
uploadError: new Error("upload unavailable")
});
await assert.rejects(
sendMedia({
stateDir,
userId: "user_media",
filePath,
kind: "file",
client,
config: { maxUploadBytes: 1024, cdnBaseUrl: "https://upload.example/c2c" }
}),
(error) => {
assert.equal(error.code, "MEDIA_UPLOAD_FAILED");
assert.equal(error.details.cause, "upload unavailable");
return true;
}
);
assert.equal(client.calls.uploadBytes.length, 1);
assert.equal(client.calls.sendMediaMessage.length, 0);
assert.equal((await readMessageHistory(stateDir, "bot_media")).length, 0);
});
});
test("sendMedia reports upload success metadata when sendmessage later fails", async () => {
await withTempDir(async (stateDir) => {
await seedAccount(stateDir);
const filePath = path.join(stateDir, "report.pdf");
await writeFile(filePath, Buffer.from("%PDF fixture"));
const client = fakeMediaClient({
sendError: new WxbError("SESSION_EXPIRED", "expired", { retryable: false })
});
await assert.rejects(
sendMedia({
stateDir,
userId: "user_media",
filePath,
kind: "file",
client,
config: { maxUploadBytes: 1024, cdnBaseUrl: "https://upload.example/c2c" }
}),
(error) => {
assert.equal(error.code, "SESSION_EXPIRED");
assert.equal(error.details.uploaded, true);
assert.equal(error.details.fileName, "report.pdf");
assert.match(error.details.clientId, /^wxb-\d+-[a-f0-9]{8}$/);
return true;
}
);
assert.equal(client.calls.uploadBytes.length, 1);
assert.equal((await readMessageHistory(stateDir, "bot_media")).length, 0);
});
});
+126
View File
@@ -31,8 +31,12 @@ async function seedAccount(stateDir) {
function fakeSendClient(options = {}) {
const calls = [];
const typingCalls = [];
const configCalls = [];
return {
calls,
typingCalls,
configCalls,
async sendTextMessage(request) {
calls.push(request);
const failure = options.failAt === calls.length ? options.error : undefined;
@@ -40,6 +44,25 @@ function fakeSendClient(options = {}) {
throw failure;
}
return { ret: 0 };
},
async getConfig(request) {
configCalls.push(request);
if (options.configError) {
throw options.configError;
}
return options.configResponse ?? {
ret: 0,
typing_ticket: "typing_ticket_text",
context_token: "ctx_secret_refreshed"
};
},
async sendTyping(request) {
typingCalls.push(request);
const failure = options.typingFailAt === typingCalls.length ? options.typingError : undefined;
if (failure) {
throw failure;
}
return { ret: 0 };
}
};
}
@@ -105,6 +128,109 @@ test("sendText sends long text as multiple chunks with unique client IDs", async
});
});
test("sendText can send typing status and use refreshed context token", async () => {
await withTempDir(async (stateDir) => {
await seedAccount(stateDir);
await rememberContextToken(stateDir, "bot_001", "user_001", "ctx_secret");
const client = fakeSendClient();
const result = await sendText({
stateDir,
userId: "user_001",
text: "hello with typing",
client,
typing: true
});
assert.equal(client.configCalls[0].contextToken, "ctx_secret");
assert.deepEqual(client.typingCalls.map((call) => call.status), [1, 2]);
assert.equal(client.calls[0].contextToken, "ctx_secret_refreshed");
assert.equal(result.typing.started, true);
assert.equal(result.typing.stopped, true);
});
});
test("sendText continues delivery when typing setup fails", async () => {
await withTempDir(async (stateDir) => {
await seedAccount(stateDir);
await rememberContextToken(stateDir, "bot_001", "user_001", "ctx_secret");
const client = fakeSendClient({
configError: new WxbError("SERVER_ERROR", "typing unavailable", { retryable: true })
});
const result = await sendText({
stateDir,
userId: "user_001",
text: "hello despite typing",
client,
typing: true
});
assert.equal(client.configCalls.length, 1);
assert.equal(client.typingCalls.length, 0);
assert.equal(client.calls.length, 1);
assert.equal(client.calls[0].contextToken, "ctx_secret");
assert.equal(result.typing.requested, true);
assert.equal(result.typing.started, false);
assert.equal(result.typing.stopped, false);
assert.equal(result.typing.startError.code, "SERVER_ERROR");
assert.equal(JSON.stringify(result).includes("ctx_secret"), false);
assert.equal(JSON.stringify(result).includes("bot_secret"), false);
});
});
test("sendText continues delivery when typing start fails", async () => {
await withTempDir(async (stateDir) => {
await seedAccount(stateDir);
await rememberContextToken(stateDir, "bot_001", "user_001", "ctx_secret");
const client = fakeSendClient({
typingFailAt: 1,
typingError: new WxbError("NETWORK_ERROR", "start failed", { retryable: true })
});
const result = await sendText({
stateDir,
userId: "user_001",
text: "hello after start failure",
client,
typing: true
});
assert.equal(client.configCalls.length, 1);
assert.deepEqual(client.typingCalls.map((call) => call.status), [1]);
assert.equal(client.calls.length, 1);
assert.equal(client.calls[0].contextToken, "ctx_secret_refreshed");
assert.equal(result.typing.started, false);
assert.equal(result.typing.stopped, false);
assert.equal(result.typing.startError.code, "NETWORK_ERROR");
});
});
test("sendText keeps delivery successful when typing stop fails", async () => {
await withTempDir(async (stateDir) => {
await seedAccount(stateDir);
await rememberContextToken(stateDir, "bot_001", "user_001", "ctx_secret");
const client = fakeSendClient({
typingFailAt: 2,
typingError: new WxbError("NETWORK_ERROR", "stop failed", { retryable: true })
});
const result = await sendText({
stateDir,
userId: "user_001",
text: "hello with stop failure",
client,
typing: true
});
assert.deepEqual(client.typingCalls.map((call) => call.status), [1, 2]);
assert.equal(client.calls.length, 1);
assert.equal(result.typing.started, true);
assert.equal(result.typing.stopped, false);
assert.equal(result.typing.stopError.code, "NETWORK_ERROR");
});
});
test("sendText fails with NO_CONTEXT_TOKEN when no token is cached", async () => {
await withTempDir(async (stateDir) => {
await seedAccount(stateDir);