Skill: lroolle/wxclawbot-send (version 0.5.2)

--- name: wxclawbot-send version: 0.5.2 description: > 主动给微信用户发消息（文本、图片、视频、文件）。微信机器人默认只能被动回复， 这个技能让 agent 能主动推送消息到用户微信。 Use when: 主动发微信, 定时提醒, 告警通知, 推送报告, 发图片/文件到微信, proactive WeChat message, push notification to WeChat, send image/file to WeChat. Triggers: 发微信, 微信通知, 微信提醒, 推送微信, wxclawbot, send wechat, wechat message, weixin message, wx message, push wechat, send image wechat. DO NOT TRIGGER when: 发邮件, 发短信, Slack, Teams, Telegram 等非微信消息。 metadata: openclaw: requires: bins: [wxclawbot] config: [~/.openclaw/openclaw-weixin/accounts/] primaryEnv: WXCLAW_TOKEN install: - kind: node package: "@claw-lab/wxclawbot-cli" bins: [wxclawbot] os: [macos, linux] envVars: - name: WXCLAW_TOKEN required: false description: "覆盖 bot token ([REDACTED_SECRET]:your-token)" - name: WXCLAW_BASE_URL required: false description: "覆盖 API 端点 (默认: https://ilinkai.weixin.qq.com)" author: lroolle links: homepage: https://github.com/lroolle/wxclawbot-cli repository: https://github.com/lroolle/wxclawbot-cli --- # wxclawbot-send ## 这个技能解决什么问题 微信机器人（iLink Bot）只能被动回复用户消息，不能主动发起对话。 这意味着 agent 没法做定时提醒、告警推送、报告发送等主动触达场景。 这个技能通过 `wxclawbot` CLI 让 agent 能主动给微信用户推送消息， 包括文本、图片、视频、文件。配合 cron 可实现定时任务。 ## 前置条件 - Node.js >= 20 - `npm install -g @claw-lab/wxclawbot-cli` - openclaw-weixin 已登录（凭证在 `~/.openclaw/openclaw-weixin/accounts/`） 验证: `wxclawbot accounts --json` ## 快速上手 ```bash wxclawbot send --text "消息内容" --json wxclawbot send --file ./photo.jpg --json wxclawbot send --file ./report.pdf --text "请查收" --json ``` ## 什么时候用什么 ``` 主动发文本消息 → wxclawbot send --text "msg" --json 主动发文件/图片 → wxclawbot send --file ./path --json 检查账号状态 → wxclawbot accounts --json 编程调用 → see references/programmatic-api.md ``` ## 命令参考 ### send ```bash wxclawbot send --text "message" --json wxclawbot send --to "[REDACTED_SECRET]" --text "你好" --json wxclawbot send --file ./photo.jpg --json wxclawbot send --file ./report.pdf --text "请查收" --json wxclawbot send --file "https://example.com/img.png" --json echo "日报已生成" | wxclawbot send --json wxclawbot send --text "test" --dry-run ``` | 参数 | 说明 | |------|------| | `--text <msg>` | 消息文本。`"-"` 显式读 stdin | | `--file <path>` | 本地文件或 URL（图片 / 视频 / 文件） | | `--to <userId>` | 目标用户 ID。默认: 账号绑定用户 | | `--account <id>` | 指定账号。默认: 第一个可用的 | | `--json` | JSON 格式输出。**编程调用必须带上** | | `--dry-run` | 预览，不发送 | 媒体类型按扩展名自动识别: - 图片: .png .jpg .jpeg .gif .webp .bmp - 视频: .mp4 .mov .webm .mkv .avi - 文件: 其他所有 ### accounts ```bash wxclawbot accounts --json ``` 返回: `[{"id":"<botId>-im-bot","configured":true,"baseUrl":"..."}]` ## 账号发现机制 CLI 自动从 `~/.openclaw/openclaw-weixin/accounts/*.json` 发现账号。 每个文件包含 `token`, `baseUrl`, `userId`（默认 `--to` 目标）。 - Bot ID 从 token 运行时提取（不是硬编码的） - 账号 ID 会在 openclaw-weixin 升级或重新注册后变化 - `--to` 默认是账号文件里的 `userId`（绑定用户） - 设了 `WXCLAW_TOKEN` 环境变量会覆盖文件发现 ### Context Token（主动推送关键） 微信要求 `context_token` 才能主动推送消息。没有它，消息会留在服务器上， 直到用户主动打开聊天窗口才能看到——这就不叫"主动推送"了。 CLI 自动读取 `{accountId}.context-tokens.json`（和账号文件同目录）。 这个文件由 openclaw-weixin 维护，格式是 `userId → contextToken` 的映射。 不需要手动配置——用户给机器人发过消息后 openclaw-weixin 会自动保存。 如果 context_token 缺失（新用户、token 过期），消息仍然会发送成功 （`ok:true`），但不会主动推送通知。 升级 openclaw-weixin 后，务必用 `wxclawbot accounts --json` 验证。 ## Agent 集成 编程调用**必须**用 `--json`。解析 JSON 判断是否成功。 ```bash result=$(wxclawbot send --text "任务完成" --json) result=$(wxclawbot send --file ./chart.png --text "每日指标" --json) ``` - 成功: `{"ok":true,"to":"[REDACTED_SECRET]","clientId":"..."}` - 失败: `{"ok":false,"error":"..."}` - 退出码: 0 = CLI 执行成功, 1 = 失败 注意: exit 0 只代表 CLI 跑完了，不代表消息送达。**看 `ok` 字段**。 ## 错误处理 | 错误 | 含义 | 处理 | |------|------|------| | `ret=-2` | 频率限制（每 bot 约 7 条 / 5 分钟，所有客户端共享） | 等 60-120 秒重试，别搞紧循环 | | `ret=-14` | 会话过期 | 通过 openclaw 重新登录 | | No account found | 凭证缺失 | 跑 `wxclawbot accounts` 排查 | | CDN upload error | 文件上传失败 | 检查文件大小 / 格式，重试 | | Request timeout | 网络问题（15 秒超时） | 重试 | 频率限制是服务端的，同一个 bot token 下所有客户端共享配额。 ### 结构化传输错误 (v0.5.1+) `--json` 模式下，传输层失败会包含额外字段: ```json {"ok":false,"error":"send failed: ...","errorKind":"timeout","retryable":true} ``` | `errorKind` | 含义 | `retryable` | |-------------|------|-------------| | `timeout` | 请求超过 15 秒 | true | | `dns` | DNS 解析失败 | true | | `connection` | 连接重置 / socket hang up | true | | `network` | 连接被拒 / 主机不可达 / fetch failed | true | | `tls` | 证书或 TLS 错误 | false | | `unknown` | 未分类错误 | false | 用 `retryable` 字段判断应该重试还是直接失败。 ## 常见坑 - **必须用 `--json`** —— 不带的话输出是人类可读格式，没法解析 - **看 `ok` 字段** —— exit 0 只代表 CLI 跑了，不代表消息到了 - **别紧循环重试 `ret=-2`** —— 至少等 5-10 秒 - **大文本用 stdin** —— `echo "..." | wxclawbot send --json`，避免 shell 引号问题 - **文件会加密上传到微信 CDN** —— 大文件可能要等一会 ## 环境变量 | 变量 | 用途 | |------|------| | `WXCLAW_TOKEN` | 覆盖 bot token (`[REDACTED_SECRET]:your-token`) | | `WXCLAW_BASE_URL` | 覆盖 API 端点 (默认: `https://ilinkai.weixin.qq.com`) | 编程接口详见 [references/programmatic-api.md](references/programmatic-api.md)。

path: README.en.md
content: # @claw-lab/wxclawbot-cli

[简体中文](./README.md) | [npm](https://www.npmjs.com/package/@claw-lab/wxclawbot-cli) | [GitHub](https://github.com/lroolle/wxclawbot-cli) | [ClawHub](https://clawhub.ai/lroolle/wxclawbot-send)

Let your AI agent proactively send WeChat messages. Text, images, video, files -- whatever you need.

## Why This Exists

WeChat bots can only reply. They can't initiate. That's like having a secretary who only talks when spoken to and otherwise sits there staring at the wall.

> "Currently doesn't support proactively sending you messages on a schedule"
>
> -- [Lobster's WeChat Integration Tutorial](https://mp.weixin.qq.com/s/nYDQ1obQEHe1WavGpNzasQ)

**Now it does.**

## Install / Update

Send this to your OpenClaw agent (Lobster):

```
Install/update a skill so you can proactively WeChat me.

Skill source: https://github.com/lroolle/wxclawbot-cli
SKILL.md is at repo root, put it in skills/wxclawbot-send/SKILL.md
CLI dependency: npm install -g @claw-lab/wxclawbot-cli
If you have clawhub: clawhub install wxclawbot-send

Verify with: wxclawbot accounts --json
```

The agent handles the rest: download SKILL.md, install CLI dependency, place it in the right skills directory, reload.

You don't touch the CLI. You don't write cron jobs. You don't need the technical docs below.

## What You Can Do With It

You're not "running commands." You're talking to your agent. These are prompts you send directly:

**Stay-alive reminders:**

> Remind me to drink water every 45 minutes, don't be polite about it

> Every hour of sitting, tell me to get up and move. Be aggressive.

> If I'm still chatting with you after 1 AM, yell at me to go to sleep

> Friday 5:55 PM -- remind me to stop working. Do not accept new tasks.

> Weekdays 11:15 -- remind me to order lunch before delivery queues explode

**DevOps alerts:**

> If CI breaks, WeChat me immediately with whose commit blew it up

> PR sitting unreviewed for 24 hours? Ping me.

> Notify me on every deploy -- success or failure, I want to know

> Production error rate over 1%? Alert me via WeChat, right now

**Business ops:**

> Every morning at 9, send me yesterday's key metrics summary

> Ticket exceeds SLA by 4 hours -- auto-escalate and notify me

> Suspicious login detected? Alert me immediately

> Server disk over 90%? WeChat me

You talk. The agent works. That's the whole idea.

---

Everything below is technical reference for agents, scripts, and developers. Normal humans can stop here.

## CLI Reference

```bash
wxclawbot send --text "message" --json
wxclawbot send --file ./photo.jpg --json
wxclawbot send --file ./report.pdf --text "See attached" --json
wxclawbot send --to "[REDACTED_SECRET]" --text "Hello" --json
echo "report ready" | wxclawbot send --json
wxclawbot send --text "test" --dry-run
```

| Flag | Description |
|------|-------------|
| `--text <msg>` | Message text. `"-"` to explicitly read stdin |
| `--file <path>` | Local file path or URL (image/video/file) |
| `--to <userId>` | Target user ID. Default: bound user from account |
| `--account <id>` | Account ID. Default: first available |
| `--json` | JSON output. **Always use this.** |
| `--dry-run` | Preview without sending |

Media type auto-detected by extension:

- Image: .png .jpg .jpeg .gif .webp .bmp
- Video: .mp4 .mov .webm .mkv .avi
- File: everything else

## Output Format

```json
{"ok":true,"to":"[REDACTED_SECRET]","clientId":"..."}
{"ok":false,"error":"ret=-2 (rate limited, try again later)"}
```

Exit codes: 0 = CLI ran, 1 = failure. Note: exit 0 means the CLI executed, not that the message was delivered. Check the `ok` field.

## Error Codes

| ret | meaning | action |
|-----|---------|--------|
| -2 | rate limited | wait 5-10s, retry. Don't tight-loop this. |
| -14 | session expired | re-login via openclaw |

Rate limit: **~7 msgs / 5 min** per bot account, server-side, shared across all clients on the same token.

## Accounts

```bash
wxclawbot accounts --json
```

Auto-discovers from `~/.openclaw/openclaw-weixin/accounts/`. Env override:

```bash
export WXCLAW_TOKEN="[REDACTED_SECRET]:your-token"
export WXCLAW_BASE_URL="https://ilinkai.weixin.qq.com"
```

## Programmatic API

```typescript
import { WxClawClient } from "@claw-lab/wxclawbot-cli";
import { resolveAccount } from "@claw-lab/wxclawbot-cli/accounts";

const account = resolveAccount();
const client = new WxClawClient({
  baseUrl: account.baseUrl,
  [REDACTED_SECRET],
  botId: account.botId,
});

await client.sendText("[REDACTED_SECRET]", "Hello");
await client.sendFile("[REDACTED_SECRET]", "./photo.jpg", { text: "Check this" });
```

See [references/programmatic-api.md](references/programmatic-api.md) for full details.

## Links

- [npm](https://www.npmjs.com/package/@claw-lab/wxclawbot-cli)
- [GitHub](https://github.com/lroolle/wxclawbot-cli)
- [ClawHub](https://clawhub.ai/lroolle/wxclawbot-send) -- `clawhub install wxclawbot-send`
- [WeChat Integration Tutorial](https://mp.weixin.qq.com/s/nYDQ1obQEHe1WavGpNzasQ) -- start here for the basics

## License

MIT


path: README.md
content: # @claw-lab/wxclawbot-cli

[English](./README.en.md) | [npm](https://www.npmjs.com/package/@claw-lab/wxclawbot-cli) | [GitHub](https://github.com/lroolle/wxclawbot-cli) | [ClawHub](https://clawhub.ai/lroolle/wxclawbot-send)

让你的 AI agent 主动给微信用户发消息。文本、图片、视频、文件，想发就发。

## 为什么做这个

> 7）现在还不支持主动定时给你发消息
> 也就是说，它不会像小秘书一样，自己跳出来提醒你。
>
> -- [龙虾接入微信教程](https://mp.weixin.qq.com/s/nYDQ1obQEHe1WavGpNzasQ)

**现在可以了。**

## 安装 / 更新

把下面这段直接发给你的龙虾就行：

```
安装/更新一个技能让你能主动给我发微信。

技能源码：https://github.com/lroolle/wxclawbot-cli
SKILL.md 在仓库根目录，把它放到 skills/wxclawbot-send/SKILL.md
CLI 依赖：npm install -g @claw-lab/wxclawbot-cli
如果你有 clawhub 可以直接：clawhub install wxclawbot-send

装好后用 wxclawbot accounts --json 验证账号是否正常。
```

agent 会自己搞定：下载 SKILL.md、装 CLI 依赖、放到正确的 skills 目录、重新加载。

你不需要碰命令行。你不需要写 cron。你不需要看下面的技术文档。

## 使用场景

你不是在"用 CLI"，你是在跟 agent 说话。以下是你可以直接发给龙虾的提示词：

**活着提醒系列：**

> 每 45 分钟提醒我喝水，别客气直接骂

> 每坐满 1 小时提醒我站起来，语气凶一点

> 凌晨 1 点还在跟你聊天直接骂我去睡觉

> 周五 5:55 提醒我下班，不要接新需求

> 工作日 11:15 提醒我点外卖，不然配送排到下午两点

**搬砖告警系列：**

> CI 挂了第一时间微信通知我，告诉我是谁的 commit 炸的

> 有 PR 超过 24 小时没人 review 就催我

> 每次部署完成通知我，成功失败都要说

> 生产环境错误率超过 1% 立刻告警

**老板视角系列：**

> 每天早上 9 点把昨天的核心指标发给我

> 工单超 SLA 4 小时自动升级通知我

> 检测到异常登录立刻告警

> 服务器磁盘超 90% 微信通知我

你说人话，agent 干活。就这么简单。

---

以下是给 agent、脚本和开发者看的技术文档。普通用户到这里就可以关掉了。

## CLI 参考

```bash
wxclawbot send --text "消息内容" --json
wxclawbot send --file ./photo.jpg --json
wxclawbot send --file ./report.pdf --text "请查收" --json
wxclawbot send --to "[REDACTED_SECRET]" --text "你好" --json
echo "日报已生成" | wxclawbot send --json
wxclawbot send --text "test" --dry-run
```

| 参数 | 说明 |
|------|------|
| `--text <msg>` | 消息文本。`"-"` 显式读 stdin |
| `--file <path>` | 本地文件或 URL（图片 / 视频 / 文件） |
| `--to <userId>` | 目标用户 ID。默认：账号绑定用户 |
| `--account <id>` | 指定账号。默认：第一个可用的 |
| `--json` | JSON 格式输出。**始终带上** |
| `--dry-run` | 预览，不发送 |

媒体类型按扩展名自动识别：

- 图片：.png .jpg .jpeg .gif .webp .bmp
- 视频：.mp4 .mov .webm .mkv .avi
- 文件：其他所有

## 输出格式

```json
{"ok":true,"to":"[REDACTED_SECRET]","clientId":"..."}
{"ok":false,"error":"ret=-2 (rate limited, try again later)"}
```

退出码：0 = CLI 执行成功，1 = 失败。注意：exit 0 只代表 CLI 跑完了，不代表消息送达。看 `ok` 字段。

## 错误码

| ret | 含义 | 处理 |
|-----|------|------|
| -2 | 频率限制 | 等 5-10 秒重试，别搞紧循环 |
| -14 | 会话过期 | 通过 openclaw 重新登录 |

频率限制：每个机器人账号约 **7 条 / 5 分钟**，服务端限制，所有客户端共享。

## 账号

```bash
wxclawbot accounts --json
```

自动从 `~/.openclaw/openclaw-weixin/accounts/` 发现。环境变量覆盖：

```bash
export WXCLAW_TOKEN="[REDACTED_SECRET]:your-token"
export WXCLAW_BASE_URL="https://ilinkai.weixin.qq.com"
```

## 编程接口

```typescript
import { WxClawClient } from "@claw-lab/wxclawbot-cli";
import { resolveAccount } from "@claw-lab/wxclawbot-cli/accounts";

const account = resolveAccount();
const client = new WxClawClient({
  baseUrl: account.baseUrl,
  [REDACTED_SECRET],
  botId: account.botId,
});

await client.sendText("[REDACTED_SECRET]", "你好");
await client.sendFile("[REDACTED_SECRET]", "./photo.jpg", { text: "请查收" });
```

详见 [references/programmatic-api.md](references/programmatic-api.md)。

## 相关链接

- [npm](https://www.npmjs.com/package/@claw-lab/wxclawbot-cli)
- [GitHub](https://github.com/lroolle/wxclawbot-cli)
- [ClawHub](https://clawhub.ai/lroolle/wxclawbot-send) -- `clawhub install wxclawbot-send`
- [龙虾接入微信教程](https://mp.weixin.qq.com/s/nYDQ1obQEHe1WavGpNzasQ) -- 先看这个搞定基础接入

## 开源许可

MIT


path: package.json
content: {
  "name": "@claw-lab/wxclawbot-cli",
  "version": "0.5.2",
  "description": "WeixinClawBot CLI - send text, images, video, and files to WeChat users",
  "type": "module",
  "bin": {
    "wxclawbot": "dist/cli.js"
  },
  "exports": {
    ".": "./dist/client.js",
    "./accounts": "./dist/accounts.js",
    "./media": "./dist/media.js"
  },
  "scripts": {
    "build": "tsc",
    "dev": "node --import tsx src/cli.ts",
    "start": "node dist/cli.js",
    "test": "node --import tsx --test src/accounts.test.ts src/media.test.ts",
    "typecheck": "tsc --noEmit"
  },
  "engines": {
    "node": ">=20"
  },
  "files": [
    "dist/",
    "LICENSE",
    "README.md"
  ],
  "repository": {
    "type": "git",
    "url": "git+https://github.com/lroolle/wxclawbot-cli.git"
  },
  "keywords": [
    "wechat",
    "weixin",
    "openclaw",
    "clawbot",
    "weixinclawbot",
    "cli",
    "proactive-messaging"
  ],
  "dependencies": {
    "commander": "^13.1.0"
  },
  "devDependencies": {
    "@types/node": "^25.5.0",
    "tsx": "^4.21.0",
    "typescript": "^5.9.3"
  },
  "license": "MIT"
}


path: references/programmatic-api.md
content: # wxclaw Programmatic API

## TypeScript Usage

```typescript
import { WxClawClient } from "@claw-lab/wxclawbot-cli";
import { resolveAccount } from "@claw-lab/wxclawbot-cli/accounts";

const account = resolveAccount();
const client = new WxClawClient({
  baseUrl: account.baseUrl,
  [REDACTED_SECRET],
  botId: account.botId,
  contextToken: account.contextToken,
});

const result = await client.sendText("[REDACTED_SECRET]", "Hello");
// { ok: true, to: "...", clientId: "..." }
```

`contextToken` is required for proactive push notifications. Without it,
messages send successfully but won't push to the user's WeChat client.
`resolveAccount()` auto-resolves it from `{accountId}.context-tokens.json`.

## Account File Format

Accounts are stored at `~/.openclaw/openclaw-weixin/accounts/{accountId}.json`.

Each file contains bot credentials including token, botId, baseUrl, and the
bound user ID (default --to target).

Context tokens are stored separately at `{accountId}.context-tokens.json`
as a `userId → token` map, maintained by openclaw-weixin.

## Package Exports

| Export | Module |
|--------|--------|
| `@claw-lab/wxclawbot-cli` | `WxClawClient` class |
| `@claw-lab/wxclawbot-cli/accounts` | `resolveAccount()`, account discovery |


path: src/accounts.ts
content: import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import type { AccountData, ResolvedAccount } from "./types.js";

const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";

function extractBotId([REDACTED_SECRET]): string {
  const colonIdx = token.indexOf(":");
  return colonIdx > 0 ? token.substring(0, colonIdx) : "";
}

function stateDir(): string {
  return (
    process.env.OPENCLAW_STATE_DIR?.trim() ||
    process.env.CLAWDBOT_STATE_DIR?.trim() ||
    path.join(os.homedir(), ".openclaw")
  );
}

function weixinStateDir(): string {
  return path.join(stateDir(), "openclaw-weixin");
}

function accountsDir(): string {
  return path.join(weixinStateDir(), "accounts");
}

function indexPath(): string {
  return path.join(weixinStateDir(), "accounts.json");
}

export function listAccountIds(): string[] {
  try {
    const raw = fs.readFileSync(indexPath(), "utf-8");
    const parsed = JSON.parse(raw);
    if (Array.isArray(parsed)) {
      const ids = parsed.filter(
        (id: unknown): id is string => typeof id === "string" && id !== "",
      );
      if (ids.length > 0) return ids;
    }
  } catch {
    // fall through to directory scan
  }
  return scanAccountsDir();
}

function scanAccountsDir(): string[] {
  try {
    const dir = accountsDir();
    return fs
      .readdirSync(dir)
      .filter((f) => f.endsWith(".json") && !f.endsWith(".sync.json"))
      .map((f) => f.replace(/\.json$/, ""));
  } catch {
    return [];
  }
}

export function loadAccountData(accountId: string): AccountData | null {
  if (
    accountId.includes("/") ||
    accountId.includes("\\") ||
    accountId.includes("..")
  ) {
    return null;
  }
  const filePath = path.join(accountsDir(), `${accountId}.json`);
  try {
    const raw = fs.readFileSync(filePath, "utf-8");
    return JSON.parse(raw) as AccountData;
  } catch {
    return null;
  }
}

function resolveBotId(data: AccountData): string {
  return data.userId?.trim() || extractBotId(data.token ?? "");
}

function loadContextTokens(
  accountId: string,
): Record<string, string> {
  const filePath = path.join(
    accountsDir(),
    `${accountId}.context-tokens.json`,
  );
  try {
    const raw = fs.readFileSync(filePath, "utf-8");
    return JSON.parse(raw) as Record<string, string>;
  } catch {
    return {};
  }
}

export function resolveAccount(accountId?: string): ResolvedAccount | null {
  const envToken = process.env.WXCLAW_TOKEN?.trim();
  const envBaseUrl = process.env.WXCLAW_BASE_URL?.trim();

  if (envToken && !accountId) {
    return {
      id: "env",
      [REDACTED_SECRET],
      baseUrl: envBaseUrl || DEFAULT_BASE_URL,
      botId: extractBotId(envToken),
    };
  }

  const ids = listAccountIds();
  const targetId = accountId || ids[0];
  if (!targetId) return null;

  const data = loadAccountData(targetId);
  if (!data?.token) return null;

  const defaultTo = data.userId?.trim() || undefined;
  const contextTokens = loadContextTokens(targetId);
  const contextToken = defaultTo ? contextTokens[defaultTo] : undefined;

  return {
    id: targetId,
    [REDACTED_SECRET],
    baseUrl: data.baseUrl?.trim() || DEFAULT_BASE_URL,
    botId: resolveBotId(data),
    defaultTo,
    contextToken,
  };
}

export function listAccounts(): Array<{
  id: string;
  configured: boolean;
  baseUrl: string;
}> {
  const ids = listAccountIds();
  return ids.map((id) => {
    const data = loadAccountData(id);
    return {
      id,
      configured: Boolean(data?.token),
      baseUrl: data?.baseUrl?.trim() || DEFAULT_BASE_URL,
    };
  });
}


path: src/cli.ts
content: #!/usr/bin/env node

import { readFileSync } from "node:fs";

import { Command } from "commander";

import { listAccounts, resolveAccount } from "./accounts.js";
import { WxClawClient } from "./client.js";
import { VERSION } from "./version.js";

function readStdin(): string {
  return readFileSync(0, "utf-8").trim();
}

function fail(msg: string, json?: boolean): never {
  if (json) {
    console.log(JSON.stringify({ ok: false, error: msg }));
  } else {
    console.error(msg);
  }
  process.exit(1);
}

const program = new Command();

program
  .name("wxclawbot")
  .description("WeixinClawBot CLI - proactive messaging")
  .version(VERSION);

program
  .command("send")
  .description("Send a message to a WeChat user")
  .option("--to <userId>", "target user ID (default: bound user from account)")
  .option("--text <message>", 'message text (use "-" to read from stdin)')
  .option("--file <path>", "file or URL to send (image/video/file)")
  .option("--account <id>", "account ID (default: first available)")
  .option("--json", "output result as JSON")
  .option("--dry-run", "preview without sending")
  .argument("[text...]", "message text (alternative to --text)")
  .action(
    async (
      args: string[],
      opts: {
        to?: string;
        text?: string;
        file?: string;
        account?: string;
        json?: boolean;
        dryRun?: boolean;
      },
    ) => {
      let text = opts.text;
      if (text === "-") {
        text = readStdin();
      } else if (!text && !opts.file && args.length > 0) {
        text = args.join(" ");
      } else if (!text && !opts.file) {
        if (!process.stdin.isTTY) {
          text = readStdin();
        }
      }

      if (!text && !opts.file) {
        fail(
          "no message. use --text, --file, positional args, or pipe via stdin.",
          opts.json,
        );
      }

      const account = resolveAccount(opts.account);
      if (!account) {
        fail(
          "no account found. login via openclaw first, or set WXCLAW_TOKEN env var.",
          opts.json,
        );
      }

      const to = opts.to || account.defaultTo;
      if (!to) {
        fail(
          "no --to specified and no default user bound to this account.",
          opts.json,
        );
      }

      const client = new WxClawClient({
        baseUrl: account.baseUrl,
        [REDACTED_SECRET],
        botId: account.botId,
        contextToken: account.contextToken,
      });

      try {
        let result;

        if (opts.file) {
          result = await client.sendFile(to, opts.file, {
            text,
            dryRun: opts.dryRun,
          });
        } else {
          result = await client.sendText(to, text!, {
            dryRun: opts.dryRun,
          });
        }

        if (opts.json) {
          console.log(JSON.stringify(result));
        } else if (result.ok) {
          const prefix = opts.dryRun ? "[dry-run] would send" : "sent";
          const what = opts.file ? `file ${opts.file}` : "text";
          console.log(`${prefix} ${what} to ${to}`);
        } else {
          console.error(`send failed: ${result.error}`);
          process.exit(1);
        }
      } catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        const info =
          err && typeof err === "object" && "info" in err
            ? (err as {
                info?: {
                  kind?: string;
                  retryable?: boolean;
                  code?: string;
                  cause?: string;
                };
              }).info
            : undefined;

        if (opts.json) {
          console.log(
            JSON.stringify({
              ok: false,
              error: `send failed: ${msg}`,
              errorKind: info?.kind,
              retryable: info?.retryable,
              errorCode: info?.code,
              cause: info?.cause,
            }),
          );
          process.exit(1);
        }

        fail(`send failed: ${msg}`, false);
      }
    },
  );

program
  .command("accounts")
  .description("List available OpenClaw WeChat accounts")
  .option("--json", "output as JSON")
  .action((opts: { json?: boolean }) => {
    const accounts = listAccounts();
    if (opts.json) {
      console.log(JSON.stringify(accounts));
      return;
    }
    if (accounts.length === 0) {
      console.log("no accounts found. login via openclaw first.");
      return;
    }
    for (const a of accounts) {
      const status = a.configured ? "ok" : "no token";
      console.log(`  ${a.id}  [${status}]  ${a.baseUrl}`);
    }
  });

program.parse();


path: src/client.ts
content: import crypto from "node:crypto";

import { readFileOrUrl, uploadFile } from "./media.js";
import {
  ItemType,
  MessageState,
  MessageType,
  type MessageItem,
  type SendMessageReq,
} from "./types.js";
import { VERSION } from "./version.js";

const SEND_TIMEOUT_MS = 15_000;

type TransportErrorKind =
  | "timeout"
  | "abort"
  | "dns"
  | "tls"
  | "connection"
  | "network"
  | "unknown";

export interface TransportErrorInfo {
  kind: TransportErrorKind;
  retryable: boolean;
  message: string;
  code?: string;
  cause?: string;
}

const KNOWN_ERRORS: Record<number, string> = {
  [-2]: "rate limited, try again later",
  [-14]: "session expired, re-login via openclaw",
};

function randomUIN(): string {
  const n = crypto.randomBytes(4).readUInt32BE(0);
  return Buffer.from(String(n), "utf-8").toString("base64");
}

function classifyTransportError(err: unknown): TransportErrorInfo {
  if (err instanceof Error && err.name === "AbortError") {
    return {
      kind: "timeout",
      retryable: true,
      message: `request timeout after ${SEND_TIMEOUT_MS}ms`,
      cause: err.message,
    };
  }

  const message = err instanceof Error ? err.message : String(err);
  const code =
    err && typeof err === "object" && "code" in err && typeof (err as { code?: unknown }).code === "string"
      ? ((err as { code: string }).code)
      : undefined;
  const cause =
    err instanceof Error && err.cause != null ? String(err.cause) : undefined;
  const combined = `${message} ${code ?? ""} ${cause ?? ""}`.toLowerCase();

  if (combined.includes("enotfound") || combined.includes("dns")) {
    return { kind: "dns", retryable: true, message, code, cause };
  }
  if (combined.includes("econnreset") || combined.includes("socket hang up")) {
    return { kind: "connection", retryable: true, message, code, cause };
  }
  if (
    combined.includes("econnrefused") ||
    combined.includes("ehostunreach") ||
    combined.includes("enetunreach") ||
    combined.includes("network") ||
    combined.includes("fetch failed")
  ) {
    return { kind: "network", retryable: true, message, code, cause };
  }
  if (combined.includes("cert") || combined.includes("tls") || combined.includes("ssl")) {
    return { kind: "tls", retryable: false, message, code, cause };
  }

  return { kind: "unknown", retryable: false, message, code, cause };
}

export interface SendResult {
  ok: boolean;
  to: string;
  clientId: string;
  error?: string;
  retryable?: boolean;
  errorKind?: string;
  errorCode?: string;
  cause?: string;
}

export class WxClawClient {
  private baseUrl: string;
  private [REDACTED_SECRET];
  private botId: string;
  private contextToken?: string;

  constructor(opts: {
    baseUrl: string;
    [REDACTED_SECRET];
    botId?: string;
    contextToken?: string;
  }) {
    this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
    this.[REDACTED_SECRET];
    this.botId = opts.botId ?? "";
    this.contextToken = opts.contextToken;
  }

  async sendText(
    to: string,
    text: string,
    opts?: { dryRun?: boolean },
  ): Promise<SendResult> {
    const clientId = crypto.randomUUID();

    if (opts?.dryRun) {
      return { ok: true, to, clientId };
    }

    const items: MessageItem[] = [
      { type: ItemType.TEXT, text_item: { text } },
    ];

    return this.sendItems(to, items, clientId);
  }

  async sendFile(
    to: string,
    filePath: string,
    opts?: { text?: string; dryRun?: boolean },
  ): Promise<SendResult> {
    const clientId = crypto.randomUUID();

    if (opts?.dryRun) {
      return { ok: true, to, clientId };
    }

    const { data, name } = await readFileOrUrl(filePath);

    const { item } = await uploadFile({
      data,
      filePath: name,
      toUserId: to,
      baseUrl: this.baseUrl,
      [REDACTED_SECRET],
    });

    const items: MessageItem[] = [];
    if (opts?.text) {
      items.push({ type: ItemType.TEXT, text_item: { text: opts.text } });
    }
    items.push(item);

    return this.sendItems(to, items, clientId);
  }

  private async sendItems(
    to: string,
    items: MessageItem[],
    clientId: string,
  ): Promise<SendResult> {
    let lastClientId = clientId;

    for (const item of items) {
      lastClientId = crypto.randomUUID();
      const req: SendMessageReq = {
        msg: {
          from_user_id: this.botId,
          to_user_id: to,
          client_id: lastClientId,
          message_type: MessageType.BOT,
          message_state: MessageState.FINISH,
          item_list: [item],
          context_token: this.contextToken,
        },
        base_info: { channel_version: VERSION },
      };

      const resp = await this.post<Record<string, unknown>>(
        "/ilink/bot/sendmessage",
        req,
      );

      const ret = typeof resp.ret === "number" ? resp.ret : 0;
      if (ret !== 0) {
        const hint = KNOWN_ERRORS[ret] ?? "";
        const detail = hint ? ` (${hint})` : "";
        return { ok: false, to, clientId: lastClientId, error: `ret=${ret}${detail}` };
      }
    }

    return { ok: true, to, clientId: lastClientId };
  }

  private async post<T>(endpoint: string, body: unknown): Promise<T> {
    const url = `${this.baseUrl}${endpoint}`;
    const bodyStr = JSON.stringify(body);

    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS);

    try {
      const res = await fetch(url, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          AuthorizationType: "ilink_bot_token",
          Authorization: `Bearer ${this.token}`,
          "X-WECHAT-UIN": randomUIN(),
        },
        body: bodyStr,
        signal: controller.signal,
      });

      const raw = await res.text();
      if (!res.ok) {
        const truncated = raw.length > 200 ? raw.slice(0, 200) + "..." : raw;
        throw new Error(`HTTP ${res.status}: ${truncated}`);
      }

      try {
        return JSON.parse(raw) as T;
      } catch {
        const truncated = raw.length > 200 ? raw.slice(0, 200) + "..." : raw;
        throw new Error(
          `invalid JSON response (HTTP ${res.status}): ${truncated}`,
        );
      }
    } catch (err) {
      const info = classifyTransportError(err);
      const detail = [info.message, info.code, info.cause].filter(Boolean).join(" | ");
      const wrapped = new Error(detail || info.message);
      wrapped.name = "WxClawTransportError";
      (wrapped as Error & { info?: TransportErrorInfo }).info = info;
      throw wrapped;
    } finally {
      clearTimeout(timer);
    }
  }
}


path: src/media.ts
content: import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";

import {
  CDNMediaType,
  ItemType,
  type GetUploadUrlReq,
  type GetUploadUrlResp,
  type MessageItem,
} from "./types.js";
import { VERSION } from "./version.js";

const CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
const API_TIMEOUT_MS = 15_000;
const CDN_UPLOAD_TIMEOUT_MS = 60_000;
const CDN_MAX_RETRIES = 3;
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB

const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"]);
const VIDEO_EXTS = new Set([".mp4", ".mov", ".webm", ".mkv", ".avi"]);

// PKCS7 padded ciphertext size for AES-128-ECB (16-byte blocks).
// Matches openclaw-weixin's formula. Note: weclaw Go uses a slightly
// different formula that over-reports by 16 bytes at block boundaries,
// but the server accepts both.
export function aesEcbPaddedSize(plaintextSize: number): number {
  return Math.ceil((plaintextSize + 1) / 16) * 16;
}

export function encryptAesEcb(plaintext: Buffer, key: Buffer): Buffer {
  const cipher = crypto.createCipheriv("aes-128-ecb", key, null);
  return Buffer.concat([cipher.update(plaintext), cipher.final()]);
}

export function classifyMedia(
  filePath: string,
): { cdnType: number; itemType: number } {
  const ext = path.extname(filePath).toLowerCase();
  if (IMAGE_EXTS.has(ext)) {
    return { cdnType: CDNMediaType.IMAGE, itemType: ItemType.IMAGE };
  }
  if (VIDEO_EXTS.has(ext)) {
    return { cdnType: CDNMediaType.VIDEO, itemType: ItemType.VIDEO };
  }
  return { cdnType: CDNMediaType.FILE, itemType: ItemType.FILE };
}

function filenameFromPath(filePath: string): string {
  return path.basename(filePath) || "file";
}

interface UploadResult {
  downloadParam: string;
  aesKeyHex: string;
  fileSize: number;
  cipherSize: number;
}

export async function uploadFile(opts: {
  data: Buffer;
  filePath: string;
  toUserId: string;
  baseUrl: string;
  [REDACTED_SECRET];
  cdnBaseUrl?: string;
}): Promise<{ item: MessageItem }> {
  const { data, filePath, toUserId, baseUrl, token } = opts;
  const cdnBaseUrl = opts.cdnBaseUrl || CDN_BASE_URL;
  const { cdnType, itemType } = classifyMedia(filePath);

  const uploaded = await uploadToCdn({
    data,
    toUserId,
    cdnMediaType: cdnType,
    baseUrl,
    token,
    cdnBaseUrl,
  });

  const media = {
    encrypt_query_param: uploaded.downloadParam,
    aes_key: Buffer.from(uploaded.aesKeyHex).toString("base64"),
    encrypt_type: 1,
  };

  let item: MessageItem;
  switch (itemType) {
    case ItemType.IMAGE:
      item = {
        type: ItemType.IMAGE,
        image_item: { media, mid_size: uploaded.cipherSize },
      };
      break;
    case ItemType.VIDEO:
      item = {
        type: ItemType.VIDEO,
        video_item: { media, video_size: uploaded.cipherSize },
      };
      break;
    default:
      item = {
        type: ItemType.FILE,
        file_item: {
          media,
          file_name: filenameFromPath(filePath),
          len: String(uploaded.fileSize),
        },
      };
  }

  return { item };
}

async function fetchWithTimeout(
  url: string,
  init: RequestInit,
  timeoutMs: number,
): Promise<Response> {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    const res = await fetch(url, { ...init, signal: controller.signal });
    return res;
  } finally {
    clearTimeout(timer);
  }
}

async function uploadToCdn(opts: {
  data: Buffer;
  toUserId: string;
  cdnMediaType: number;
  baseUrl: string;
  [REDACTED_SECRET];
  cdnBaseUrl: string;
}): Promise<UploadResult> {
  const { data, toUserId, cdnMediaType, baseUrl, token, cdnBaseUrl } = opts;

  const filekey = crypto.randomBytes(16).toString("hex");
  const aeskey = crypto.randomBytes(16);
  const aesKeyHex = aeskey.toString("hex");

  const rawMd5 = crypto.createHash("md5").update(data).digest("hex");
  const cipherSize = aesEcbPaddedSize(data.length);

  const body: GetUploadUrlReq = {
    filekey,
    media_type: cdnMediaType as 1 | 2 | 3,
    to_user_id: toUserId,
    rawsize: data.length,
    rawfilemd5: rawMd5,
    filesize: cipherSize,
    no_need_thumb: true,
    aeskey: aesKeyHex,
    base_info: { channel_version: VERSION },
  };

  const uin = crypto.randomBytes(4).readUInt32BE(0);
  const uinHeader = Buffer.from(String(uin), "utf-8").toString("base64");

  const uploadRes = await fetchWithTimeout(
    `${baseUrl}/ilink/bot/getuploadurl`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        AuthorizationType: "ilink_bot_token",
        Authorization: `Bearer ${token}`,
        "X-WECHAT-UIN": uinHeader,
      },
      body: JSON.stringify(body),
    },
    API_TIMEOUT_MS,
  );

  const uploadRespRaw = await uploadRes.text();
  if (!uploadRes.ok) {
    throw new Error(
      `getuploadurl HTTP ${uploadRes.status}: ${uploadRespRaw.slice(0, 200)}`,
    );
  }

  let uploadResp: GetUploadUrlResp;
  try {
    uploadResp = JSON.parse(uploadRespRaw);
  } catch {
    throw new Error(
      `getuploadurl invalid JSON: ${uploadRespRaw.slice(0, 200)}`,
    );
  }

  if ((uploadResp.ret ?? 0) !== 0 || !uploadResp.upload_param) {
    throw new Error(
      `getuploadurl failed: ret=${uploadResp.ret} errmsg=${uploadResp.errmsg ?? "no upload_param"}`,
    );
  }

  const ciphertext = encryptAesEcb(data, aeskey);

  const cdnUrl =
    `${cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(uploadResp.upload_param)}&filekey=${encodeURIComponent(filekey)}`;

  let downloadParam: string | undefined;
  let lastError: unknown;

  for (let attempt = 1; attempt <= CDN_MAX_RETRIES; attempt++) {
    try {
      const cdnRes = await fetchWithTimeout(
        cdnUrl,
        {
          method: "POST",
          headers: { "Content-Type": "application/octet-stream" },
          body: new Uint8Array(ciphertext),
        },
        CDN_UPLOAD_TIMEOUT_MS,
      );

      if (cdnRes.status >= 400 && cdnRes.status < 500) {
        throw new Error(`CDN upload client error ${cdnRes.status}`);
      }
      if (cdnRes.status !== 200) {
        throw new Error(`CDN upload server error ${cdnRes.status}`);
      }

      downloadParam = cdnRes.headers.get("x-encrypted-param") ?? undefined;
      if (!downloadParam) {
        throw new Error("CDN response missing x-encrypted-param header");
      }
      break;
    } catch (err) {
      lastError = err;
      if (err instanceof Error && err.message.includes("client error"))
        throw err;
      if (attempt >= CDN_MAX_RETRIES) throw err;
      await new Promise((r) => setTimeout(r, 1000 * attempt));
    }
  }

  if (!downloadParam) {
    throw lastError instanceof Error
      ? lastError
      : new Error(`CDN upload failed after ${CDN_MAX_RETRIES} attempts`);
  }

  return { downloadParam, aesKeyHex, fileSize: data.length, cipherSize };
}

export async function readFileOrUrl(filePath: string): Promise<{
  data: Buffer;
  name: string;
}> {
  if (filePath.startsWith("http://") || filePath.startsWith("https://")) {
    const res = await fetchWithTimeout(
      filePath,
      {},
      CDN_UPLOAD_TIMEOUT_MS,
    );
    if (!res.ok) {
      throw new Error(`download ${filePath}: HTTP ${res.status}`);
    }
    const buf = Buffer.from(await res.arrayBuffer());
    if (buf.length > MAX_FILE_SIZE) {
      throw new Error(
        `file too large: ${buf.length} bytes (max ${MAX_FILE_SIZE / 1024 / 1024}MB)`,
      );
    }
    const urlPath = new URL(filePath).pathname;
    const name = path.basename(urlPath) || "file";
    return { data: buf, name };
  }

  const stat = fs.statSync(filePath);
  if (stat.size > MAX_FILE_SIZE) {
    throw new Error(
      `file too large: ${stat.size} bytes (max ${MAX_FILE_SIZE / 1024 / 1024}MB)`,
    );
  }
  const data = fs.readFileSync(filePath);
  return { data, name: filePath };
}


path: src/types.ts
content: export const MessageType = {
  USER: 1,
  BOT: 2,
} as const;

export const MessageState = {
  NEW: 0,
  GENERATING: 1,
  FINISH: 2,
} as const;

export const ItemType = {
  TEXT: 1,
  IMAGE: 2,
  VOICE: 3,
  FILE: 4,
  VIDEO: 5,
} as const;

export const CDNMediaType = {
  IMAGE: 1,
  VIDEO: 2,
  FILE: 3,
} as const;

type ValueOf<T> = T[keyof T];

export interface TextItem {
  text: string;
}

export interface MediaInfo {
  encrypt_query_param: string;
  aes_key: string;
  encrypt_type: number;
}

export interface ImageItem {
  media: MediaInfo;
  mid_size: number;
}

export interface VideoItem {
  media: MediaInfo;
  video_size: number;
}

export interface FileItem {
  media: MediaInfo;
  file_name: string;
  len: string;
}

export interface MessageItem {
  type: ValueOf<typeof ItemType>;
  text_item?: TextItem;
  image_item?: ImageItem;
  video_item?: VideoItem;
  file_item?: FileItem;
}

export interface SendMsg {
  from_user_id: string;
  to_user_id: string;
  client_id: string;
  message_type: ValueOf<typeof MessageType>;
  message_state: ValueOf<typeof MessageState>;
  item_list: MessageItem[];
  context_token?: string;
}

export interface SendMessageReq {
  msg: SendMsg;
  base_info?: { channel_version?: string };
}

export interface GetUploadUrlReq {
  filekey: string;
  media_type: ValueOf<typeof CDNMediaType>;
  to_user_id: string;
  rawsize: number;
  rawfilemd5: string;
  filesize: number;
  no_need_thumb: boolean;
  aeskey: string;
  base_info?: { channel_version?: string };
}

export interface GetUploadUrlResp {
  ret?: number;
  errmsg?: string;
  upload_param?: string;
}

export interface AccountData {
  token?: string;
  savedAt?: string;
  baseUrl?: string;
  userId?: string;
}

export interface ResolvedAccount {
  id: string;
  [REDACTED_SECRET];
  baseUrl: string;
  botId: string;
  defaultTo?: string;
  contextToken?: string;
}


path: src/version.ts
content: import { createRequire } from "node:module";

const require = createRequire(import.meta.url);
const pkg = require("../package.json") as { version: string };

export const VERSION = pkg.version;


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)