From 68d29506f09a63424f3daae0ffdf97fa08097a82 Mon Sep 17 00:00:00 2001 From: DONGH4O <18155106710@163.com> Date: Sun, 17 May 2026 21:51:42 +0800 Subject: [PATCH] Complete M11-M13 release readiness --- .github/workflows/ci.yml | 47 +++ CHANGELOG.md | 8 + README.md | 54 +++- docs/m11-validation-report.md | 64 ++++ docs/m12-validation-report.md | 54 ++++ docs/m13-validation-report.md | 47 +++ docs/next-milestones.md | 18 +- docs/release-process.md | 39 ++- docs/troubleshooting.md | 115 +++++++ ilink-wechat-development-plan.md | 38 ++- ilink-wechat-test-plan.md | 34 +- package.json | 6 +- skills/wechat-bridge/SKILL.md | 12 +- skills/wechat-bridge/references/api.md | 37 +++ src/cli/commands/send.js | 42 ++- src/cli/index.js | 4 +- src/config/load-config.js | 4 +- src/core/ilink-client.js | 118 +++++++ src/core/protocol-constants.js | 18 +- src/core/send-media.js | 427 +++++++++++++++++++++++++ src/core/send-text.js | 209 ++++++------ src/core/typing.js | 120 +++++++ test/helpers/mock-ilink-server.js | 55 +++- test/unit/cli-entry.test.js | 8 +- test/unit/cli-m11.test.js | 253 +++++++++++++++ test/unit/config-load.test.js | 6 +- test/unit/ilink-client.test.js | 110 ++++++- test/unit/m10-readiness.test.js | 2 +- test/unit/m12-readiness.test.js | 30 ++ test/unit/m13-readiness.test.js | 68 ++++ test/unit/m6-readiness.test.js | 19 +- test/unit/protocol-constants.test.js | 11 +- test/unit/send-media.test.js | 333 +++++++++++++++++++ test/unit/send-text.test.js | 126 ++++++++ 34 files changed, 2386 insertions(+), 150 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 docs/m11-validation-report.md create mode 100644 docs/m12-validation-report.md create mode 100644 docs/m13-validation-report.md create mode 100644 docs/troubleshooting.md create mode 100644 src/core/send-media.js create mode 100644 src/core/typing.js create mode 100644 test/unit/cli-m11.test.js create mode 100644 test/unit/m12-readiness.test.js create mode 100644 test/unit/m13-readiness.test.js create mode 100644 test/unit/send-media.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4396579 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f5780d..722ceda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index c6905c8..b7fa768 100644 --- a/README.md +++ b/README.md @@ -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 `:AES-128-ECB 加密并上传本地文件,再发送给已有上下文的微信用户。 +- `wxb send --image `:校验本地图片 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 --text "收到" --json "来自 PowerShell 管道的回复" | node .\src\cli\index.js send --user --stdin --json +node .\src\cli\index.js send --user --file "C:\path\to\report.pdf" --json +node .\src\cli\index.js send --user --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 "张三" @@ -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 `。 +文本、文件和图片发送: + +```powershell +node .\src\cli\index.js send --user --text "收到" --json +node .\src\cli\index.js send --user --file "C:\path\to\report.pdf" --json +node .\src\cli\index.js send --user --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 运维命令 diff --git a/docs/m11-validation-report.md b/docs/m11-validation-report.md new file mode 100644 index 0000000..e0a5c8e --- /dev/null +++ b/docs/m11-validation-report.md @@ -0,0 +1,64 @@ +# M11 P1 发送侧补齐验收记录 + +## 范围 + +M11 将 P1 从“媒体可接收”补齐到“媒体可发送”,并加入可选 typing 状态: + +- `wxb send --file `。 +- `wxb send --image `。 +- `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 真实接口冒烟通过。 diff --git a/docs/m12-validation-report.md b/docs/m12-validation-report.md new file mode 100644 index 0000000..9ad7b49 --- /dev/null +++ b/docs/m12-validation-report.md @@ -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 +``` + +执行前需确认当前 npm 账号对 `@dongh4o` scope 具备发布权限。不要把本地状态目录、真实 stdout 捕获、token、context token、上传 URL、CDN 签名参数或 AES key 放入包、日志或 issue。 diff --git a/docs/m13-validation-report.md b/docs/m13-validation-report.md new file mode 100644 index 0000000..cc2e8d7 --- /dev/null +++ b/docs/m13-validation-report.md @@ -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`。 diff --git a/docs/next-milestones.md b/docs/next-milestones.md index 3e44b85..55c8ee4 100644 --- a/docs/next-milestones.md +++ b/docs/next-milestones.md @@ -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 @/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 收敛为第一个稳定公开版本。 任务: diff --git a/docs/release-process.md b/docs/release-process.md index 6e038b0..759cb59 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -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. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..4e1728f --- /dev/null +++ b/docs/troubleshooting.md @@ -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 --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. diff --git a/ilink-wechat-development-plan.md b/ilink-wechat-development-plan.md index e11f6ab..a375839 100644 --- a/ilink-wechat-development-plan.md +++ b/ilink-wechat-development-plan.md @@ -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 `、`wxb send --image ` 和可选 `--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 阶段: - 本地源码运行。 diff --git a/ilink-wechat-test-plan.md b/ilink-wechat-test-plan.md index abb8fdb..17104ad 100644 --- a/ilink-wechat-test-plan.md +++ b/ilink-wechat-test-plan.md @@ -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 ` 发送本地文件。 +- `send --image ` 发送本地图片。 +- `send --image --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 基础命令均可运行。 diff --git a/package.json b/package.json index 1e78538..5912e8e 100644 --- a/package.json +++ b/package.json @@ -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/", diff --git a/skills/wechat-bridge/SKILL.md b/skills/wechat-bridge/SKILL.md index ce0fa3b..092d438 100644 --- a/skills/wechat-bridge/SKILL.md +++ b/skills/wechat-bridge/SKILL.md @@ -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 --user --text "收到,我会继续处理。" --json "长回复内容" | wxb send --account --user --stdin --json +wxb send --account --user --file "C:\path\to\report.pdf" --json +wxb send --account --user --image "C:\path\to\image.jpg" --typing --json wxb alias set "张三" wxb send --account --alias "张三" --text "收到。" --json wxb queue list --json @@ -45,6 +47,8 @@ Use `--account ` 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. diff --git a/skills/wechat-bridge/references/api.md b/skills/wechat-bridge/references/api.md index bc2b711..25b7cad 100644 --- a/skills/wechat-bridge/references/api.md +++ b/skills/wechat-bridge/references/api.md @@ -202,6 +202,8 @@ Important: wxb send --user --text "reply text" --json "reply text" | wxb send --user --stdin --json wxb send --account --user --text "reply text" --json +wxb send --user --file "C:\path\to\report.pdf" --json +wxb send --user --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. | diff --git a/src/cli/commands/send.js b/src/cli/commands/send.js index eddfe20..5d240e6 100644 --- a/src/cli/commands/send.js +++ b/src/cli/commands/send.js @@ -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); diff --git a/src/cli/index.js b/src/cli/index.js index fc5dd3f..ea362e7 100644 --- a/src/cli/index.js +++ b/src/cli/index.js @@ -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", diff --git a/src/config/load-config.js b/src/config/load-config.js index 4db1adc..ff7bba3 100644 --- a/src/config/load-config.js +++ b/src/config/load-config.js @@ -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) { diff --git a/src/core/ilink-client.js b/src/core/ilink-client.js index 4d940c3..18cc3b3 100644 --- a/src/core/ilink-client.js +++ b/src/core/ilink-client.js @@ -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); + } + } + } } diff --git a/src/core/protocol-constants.js b/src/core/protocol-constants.js index 2f2c3b5..85a1e9f 100644 --- a/src/core/protocol-constants.js +++ b/src/core/protocol-constants.js @@ -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", diff --git a/src/core/send-media.js b/src/core/send-media.js new file mode 100644 index 0000000..c97126d --- /dev/null +++ b/src/core/send-media.js @@ -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 + }; + }); + }); +} diff --git a/src/core/send-text.js b/src/core/send-text.js index 030b104..d459cc5 100644 --- a/src/core/send-text.js +++ b/src/core/send-text.js @@ -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 + }; + }); }); } diff --git a/src/core/typing.js b/src/core/typing.js new file mode 100644 index 0000000..bbc31da --- /dev/null +++ b/src/core/typing.js @@ -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) } : {}) + } + }; +} diff --git a/test/helpers/mock-ilink-server.js b/test/helpers/mock-ilink-server.js index 0fdf46b..93fb1cb 100644 --- a/test/helpers/mock-ilink-server.js +++ b/test/helpers/mock-ilink-server.js @@ -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; diff --git a/test/unit/cli-entry.test.js b/test/unit/cli-entry.test.js index 792b094..21de296 100644 --- a/test/unit/cli-entry.test.js +++ b/test/unit/cli-entry.test.js @@ -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( diff --git a/test/unit/cli-m11.test.js b/test/unit/cli-m11.test.js new file mode 100644 index 0000000..8d9fbb2 --- /dev/null +++ b/test/unit/cli-m11.test.js @@ -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(); + } + }); +}); diff --git a/test/unit/config-load.test.js b/test/unit/config-load.test.js index cdd1a99..a310665 100644 --- a/test/unit/config-load.test.js +++ b/test/unit/config-load.test.js @@ -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")); }); }); diff --git a/test/unit/ilink-client.test.js b/test/unit/ilink-client.test.js index c743ed0..75fbea5 100644 --- a/test/unit/ilink-client.test.js +++ b/test/unit/ilink-client.test.js @@ -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"); +}); diff --git a/test/unit/m10-readiness.test.js b/test/unit/m10-readiness.test.js index c226ac5..6ddf05d 100644 --- a/test/unit/m10-readiness.test.js +++ b/test/unit/m10-readiness.test.js @@ -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/); diff --git a/test/unit/m12-readiness.test.js b/test/unit/m12-readiness.test.js new file mode 100644 index 0000000..b41ca1d --- /dev/null +++ b/test/unit/m12-readiness.test.js @@ -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 /); +}); + +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` 安装/); +}); diff --git a/test/unit/m13-readiness.test.js b/test/unit/m13-readiness.test.js new file mode 100644 index 0000000..09f70cf --- /dev/null +++ b/test/unit/m13-readiness.test.js @@ -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/); +}); diff --git a/test/unit/m6-readiness.test.js b/test/unit/m6-readiness.test.js index 07bf611..88e464a 100644 --- a/test/unit/m6-readiness.test.js +++ b/test/unit/m6-readiness.test.js @@ -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/); }); diff --git a/test/unit/protocol-constants.test.js b/test/unit/protocol-constants.test.js index 767a244..60a6cfb 100644 --- a/test/unit/protocol-constants.test.js +++ b/test/unit/protocol-constants.test.js @@ -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"); }); diff --git a/test/unit/send-media.test.js b/test/unit/send-media.test.js new file mode 100644 index 0000000..fb1b7ad --- /dev/null +++ b/test/unit/send-media.test.js @@ -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); + }); +}); diff --git a/test/unit/send-text.test.js b/test/unit/send-text.test.js index 5cac288..ed6d2cd 100644 --- a/test/unit/send-text.test.js +++ b/test/unit/send-text.test.js @@ -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);