Complete M11-M13 release readiness
This commit is contained in:
1 parent
83cecb0304
commit
68d29506f0
34 files changed
+2386
-150
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 运维命令
|
||||
|
||||
@@ -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 真实接口冒烟通过。
|
||||
@@ -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。
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 阶段:
|
||||
|
||||
- 本地源码运行。
|
||||
|
||||
@@ -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
@@ -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/",
|
||||
|
||||
@@ -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.
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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) } : {})
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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/);
|
||||
|
||||
@@ -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` 安装/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user