� backup: 2026-03-24 04:00

This commit is contained in:
huan
2026-03-24 04:00:48 +08:00
parent 7e143d3ebc
commit 31786dee08
193 changed files with 73520 additions and 1915 deletions
@@ -0,0 +1,5 @@
# Changelog
[简体中文](CHANGELOG.zh_CN.md)
This project follows the [Keep a Changelog](https://keepachangelog.com/) format.
@@ -0,0 +1,3 @@
# 变更日志
本项目遵循 [Keep a Changelog](https://keepachangelog.com/) 格式。
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Tencent Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,271 @@
# WeChat
[简体中文](./README.zh_CN.md)
OpenClaw's WeChat channel plugin, supporting login authorization via QR code scanning.
## Prerequisites
[OpenClaw](https://docs.openclaw.ai/install) must be installed (the `openclaw` CLI needs to be available).
## Quick Install
```bash
npx -y @tencent-weixin/openclaw-weixin-cli install
```
## Manual Installation
If the quick install doesn't work, follow these steps manually:
### 1. Install the plugin
```bash
openclaw plugins install "@tencent-weixin/openclaw-weixin"
```
### 2. Enable the plugin
```bash
openclaw config set plugins.entries.openclaw-weixin.enabled true
```
### 3. QR code login
```bash
openclaw channels login --channel openclaw-weixin
```
A QR code will appear in the terminal. Scan it with your phone and confirm the authorization. Once confirmed, the login credentials will be saved locally automatically — no further action is needed.
### 4. Restart the gateway
```bash
openclaw gateway restart
```
## Adding More WeChat Accounts
```bash
openclaw channels login --channel openclaw-weixin
```
Each QR code login creates a new account entry, supporting multiple WeChat accounts online simultaneously.
## Multi-Account Context Isolation
By default, all channels share the same AI conversation context. To isolate conversation context for each WeChat account:
```bash
openclaw config set agents.mode per-channel-per-peer
```
This gives each "WeChat account + message sender" combination its own independent AI memory, preventing context cross-talk between accounts.
## Backend API Protocol
This plugin communicates with the backend gateway via HTTP JSON API. Developers integrating with their own backend need to implement the following interfaces.
All endpoints use `POST` with JSON request and response bodies. Common request headers:
| Header | Description |
|--------|-------------|
| `Content-Type` | `application/json` |
| `AuthorizationType` | Fixed value `ilink_bot_token` |
| `Authorization` | `Bearer <token>` (obtained after login) |
| `X-WECHAT-UIN` | Base64-encoded random uint32 |
### Endpoint List
| Endpoint | Path | Description |
|----------|------|-------------|
| getUpdates | `getupdates` | Long-poll for new messages |
| sendMessage | `sendmessage` | Send a message (text/image/video/file) |
| getUploadUrl | `getuploadurl` | Get CDN upload pre-signed URL |
| getConfig | `getconfig` | Get account config (typing ticket, etc.) |
| sendTyping | `sendtyping` | Send/cancel typing status indicator |
### getUpdates
Long-polling endpoint. The server responds when new messages arrive or on timeout.
**Request body:**
```json
{
"get_updates_buf": ""
}
```
| Field | Type | Description |
|-------|------|-------------|
| `get_updates_buf` | `string` | Sync cursor from the previous response; empty string for the first request |
**Response body:**
```json
{
"ret": 0,
"msgs": [...],
"get_updates_buf": "<new cursor>",
"longpolling_timeout_ms": 35000
}
```
| Field | Type | Description |
|-------|------|-------------|
| `ret` | `number` | Return code, `0` = success |
| `errcode` | `number?` | Error code (e.g., `-14` = session timeout) |
| `errmsg` | `string?` | Error description |
| `msgs` | `WeixinMessage[]` | Message list (structure below) |
| `get_updates_buf` | `string` | New sync cursor to pass in the next request |
| `longpolling_timeout_ms` | `number?` | Server-suggested long-poll timeout for the next request (ms) |
### sendMessage
Send a message to a user.
**Request body:**
```json
{
"msg": {
"to_user_id": "<target user ID>",
"context_token": "<conversation context token>",
"item_list": [
{
"type": 1,
"text_item": { "text": "Hello" }
}
]
}
}
```
### getUploadUrl
Get CDN upload pre-signed parameters. Call this endpoint before uploading a file to obtain `upload_param` and `thumb_upload_param`.
**Request body:**
```json
{
"filekey": "<file identifier>",
"media_type": 1,
"to_user_id": "<target user ID>",
"rawsize": 12345,
"rawfilemd5": "<plaintext MD5>",
"filesize": 12352,
"thumb_rawsize": 1024,
"thumb_rawfilemd5": "<thumbnail plaintext MD5>",
"thumb_filesize": 1040
}
```
| Field | Type | Description |
|-------|------|-------------|
| `media_type` | `number` | `1` = IMAGE, `2` = VIDEO, `3` = FILE |
| `rawsize` | `number` | Original file plaintext size |
| `rawfilemd5` | `string` | Original file plaintext MD5 |
| `filesize` | `number` | Ciphertext size after AES-128-ECB encryption |
| `thumb_rawsize` | `number?` | Thumbnail plaintext size (required for IMAGE/VIDEO) |
| `thumb_rawfilemd5` | `string?` | Thumbnail plaintext MD5 (required for IMAGE/VIDEO) |
| `thumb_filesize` | `number?` | Thumbnail ciphertext size (required for IMAGE/VIDEO) |
**Response body:**
```json
{
"upload_param": "<original image upload encrypted parameters>",
"thumb_upload_param": "<thumbnail upload encrypted parameters>"
}
```
### getConfig
Get account configuration, including the typing ticket.
**Request body:**
```json
{
"ilink_user_id": "<user ID>",
"context_token": "<optional, conversation context token>"
}
```
**Response body:**
```json
{
"ret": 0,
"typing_ticket": "<base64-encoded typing ticket>"
}
```
### sendTyping
Send or cancel the typing status indicator.
**Request body:**
```json
{
"ilink_user_id": "<user ID>",
"typing_ticket": "<obtained from getConfig>",
"status": 1
}
```
| Field | Type | Description |
|-------|------|-------------|
| `status` | `number` | `1` = typing, `2` = cancel typing |
### Message Structure
#### WeixinMessage
| Field | Type | Description |
|-------|------|-------------|
| `seq` | `number?` | Message sequence number |
| `message_id` | `number?` | Unique message ID |
| `from_user_id` | `string?` | Sender ID |
| `to_user_id` | `string?` | Receiver ID |
| `create_time_ms` | `number?` | Creation timestamp (ms) |
| `session_id` | `string?` | Session ID |
| `message_type` | `number?` | `1` = USER, `2` = BOT |
| `message_state` | `number?` | `0` = NEW, `1` = GENERATING, `2` = FINISH |
| `item_list` | `MessageItem[]?` | Message content list |
| `context_token` | `string?` | Conversation context token, must be passed back when replying |
#### MessageItem
| Field | Type | Description |
|-------|------|-------------|
| `type` | `number` | `1` TEXT, `2` IMAGE, `3` VOICE, `4` FILE, `5` VIDEO |
| `text_item` | `{ text: string }?` | Text content |
| `image_item` | `ImageItem?` | Image (with CDN reference and AES key) |
| `voice_item` | `VoiceItem?` | Voice (SILK encoded) |
| `file_item` | `FileItem?` | File attachment |
| `video_item` | `VideoItem?` | Video |
| `ref_msg` | `RefMessage?` | Referenced message |
#### CDN Media Reference (CDNMedia)
All media types (image/voice/file/video) are transferred via CDN using AES-128-ECB encryption:
| Field | Type | Description |
|-------|------|-------------|
| `encrypt_query_param` | `string?` | Encrypted parameters for CDN download/upload |
| `aes_key` | `string?` | Base64-encoded AES-128 key |
### CDN Upload Flow
1. Calculate the file's plaintext size, MD5, and ciphertext size after AES-128-ECB encryption
2. If a thumbnail is needed (image/video), calculate the thumbnail's plaintext and ciphertext parameters as well
3. Call `getUploadUrl` to get `upload_param` (and `thumb_upload_param`)
4. Encrypt the file content with AES-128-ECB and PUT upload to the CDN URL
5. Encrypt and upload the thumbnail in the same way
6. Use the returned `encrypt_query_param` to construct a `CDNMedia` reference, include it in the `MessageItem`, and send
> For complete type definitions, see [`src/api/types.ts`](src/api/types.ts). For API call implementations, see [`src/api/api.ts`](src/api/api.ts).
@@ -0,0 +1,271 @@
# 微信
[English](./README.md)
OpenClaw 的微信渠道插件,支持通过扫码完成登录授权。
## 前提条件
已安装 [OpenClaw](https://docs.openclaw.ai/install)(需要 `openclaw` CLI 可用)。
## 一键安装
```bash
npx -y @tencent-weixin/openclaw-weixin-cli install
```
## 手动安装
如果一键安装不适用,可以按以下步骤手动操作:
### 1. 安装插件
```bash
openclaw plugins install "@tencent-weixin/openclaw-weixin"
```
### 2. 启用插件
```bash
openclaw config set plugins.entries.openclaw-weixin.enabled true
```
### 3. 扫码登录
```bash
openclaw channels login --channel openclaw-weixin
```
终端会显示一个二维码,用手机扫码并在手机上确认授权。确认后,登录凭证会自动保存到本地,无需额外操作。
### 4. 重启 gateway
```bash
openclaw gateway restart
```
## 添加更多微信账号
```bash
openclaw channels login --channel openclaw-weixin
```
每次扫码登录都会创建一个新的账号条目,支持多个微信号同时在线。
## 多账号上下文隔离
默认情况下,所有渠道的 AI 会话共享同一个上下文。如果希望每个微信账号的对话上下文相互隔离:
```bash
openclaw config set agents.mode per-channel-per-peer
```
这样每个「微信账号 + 发消息用户」组合都会拥有独立的 AI 记忆,账号之间不会串台。
## 后端 API 协议
本插件通过 HTTP JSON API 与后端网关通信。二次开发者若需对接自有后端,需实现以下接口。
所有接口均为 `POST`,请求和响应均为 JSON。通用请求头:
| Header | 说明 |
|--------|------|
| `Content-Type` | `application/json` |
| `AuthorizationType` | 固定值 `ilink_bot_token` |
| `Authorization` | `Bearer <token>`(登录后获取) |
| `X-WECHAT-UIN` | 随机 uint32 的 base64 编码 |
### 接口列表
| 接口 | 路径 | 说明 |
|------|------|------|
| getUpdates | `getupdates` | 长轮询获取新消息 |
| sendMessage | `sendmessage` | 发送消息(文本/图片/视频/文件) |
| getUploadUrl | `getuploadurl` | 获取 CDN 上传预签名 URL |
| getConfig | `getconfig` | 获取账号配置(typing ticket 等) |
| sendTyping | `sendtyping` | 发送/取消输入状态指示 |
### getUpdates
长轮询接口。服务端在有新消息或超时后返回。
**请求体:**
```json
{
"get_updates_buf": ""
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `get_updates_buf` | `string` | 上次响应返回的同步游标,首次请求传空字符串 |
**响应体:**
```json
{
"ret": 0,
"msgs": [...],
"get_updates_buf": "<新游标>",
"longpolling_timeout_ms": 35000
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `ret` | `number` | 返回码,`0` = 成功 |
| `errcode` | `number?` | 错误码(如 `-14` = 会话超时) |
| `errmsg` | `string?` | 错误描述 |
| `msgs` | `WeixinMessage[]` | 消息列表(结构见下方) |
| `get_updates_buf` | `string` | 新的同步游标,下次请求时回传 |
| `longpolling_timeout_ms` | `number?` | 服务端建议的下次长轮询超时(ms) |
### sendMessage
发送一条消息给用户。
**请求体:**
```json
{
"msg": {
"to_user_id": "<目标用户 ID>",
"context_token": "<会话上下文令牌>",
"item_list": [
{
"type": 1,
"text_item": { "text": "你好" }
}
]
}
}
```
### getUploadUrl
获取 CDN 上传预签名参数。上传文件前需先调用此接口获取 `upload_param``thumb_upload_param`
**请求体:**
```json
{
"filekey": "<文件标识>",
"media_type": 1,
"to_user_id": "<目标用户 ID>",
"rawsize": 12345,
"rawfilemd5": "<明文 MD5>",
"filesize": 12352,
"thumb_rawsize": 1024,
"thumb_rawfilemd5": "<缩略图明文 MD5>",
"thumb_filesize": 1040
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `media_type` | `number` | `1` = IMAGE, `2` = VIDEO, `3` = FILE |
| `rawsize` | `number` | 原文件明文大小 |
| `rawfilemd5` | `string` | 原文件明文 MD5 |
| `filesize` | `number` | AES-128-ECB 加密后的密文大小 |
| `thumb_rawsize` | `number?` | 缩略图明文大小(IMAGE/VIDEO 时必填) |
| `thumb_rawfilemd5` | `string?` | 缩略图明文 MD5IMAGE/VIDEO 时必填) |
| `thumb_filesize` | `number?` | 缩略图密文大小(IMAGE/VIDEO 时必填) |
**响应体:**
```json
{
"upload_param": "<原图上传加密参数>",
"thumb_upload_param": "<缩略图上传加密参数>"
}
```
### getConfig
获取账号配置,包括 typing ticket。
**请求体:**
```json
{
"ilink_user_id": "<用户 ID>",
"context_token": "<可选,会话上下文令牌>"
}
```
**响应体:**
```json
{
"ret": 0,
"typing_ticket": "<base64 编码的 typing ticket>"
}
```
### sendTyping
发送或取消输入状态指示。
**请求体:**
```json
{
"ilink_user_id": "<用户 ID>",
"typing_ticket": "<从 getConfig 获取>",
"status": 1
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `status` | `number` | `1` = 正在输入,`2` = 取消输入 |
### 消息结构
#### WeixinMessage
| 字段 | 类型 | 说明 |
|------|------|------|
| `seq` | `number?` | 消息序列号 |
| `message_id` | `number?` | 消息唯一 ID |
| `from_user_id` | `string?` | 发送者 ID |
| `to_user_id` | `string?` | 接收者 ID |
| `create_time_ms` | `number?` | 创建时间戳(ms |
| `session_id` | `string?` | 会话 ID |
| `message_type` | `number?` | `1` = USER, `2` = BOT |
| `message_state` | `number?` | `0` = NEW, `1` = GENERATING, `2` = FINISH |
| `item_list` | `MessageItem[]?` | 消息内容列表 |
| `context_token` | `string?` | 会话上下文令牌,回复时需回传 |
#### MessageItem
| 字段 | 类型 | 说明 |
|------|------|------|
| `type` | `number` | `1` TEXT, `2` IMAGE, `3` VOICE, `4` FILE, `5` VIDEO |
| `text_item` | `{ text: string }?` | 文本内容 |
| `image_item` | `ImageItem?` | 图片(含 CDN 引用和 AES 密钥) |
| `voice_item` | `VoiceItem?` | 语音(SILK 编码) |
| `file_item` | `FileItem?` | 文件附件 |
| `video_item` | `VideoItem?` | 视频 |
| `ref_msg` | `RefMessage?` | 引用消息 |
#### CDN 媒体引用 (CDNMedia)
所有媒体类型(图片/语音/文件/视频)通过 CDN 传输,使用 AES-128-ECB 加密:
| 字段 | 类型 | 说明 |
|------|------|------|
| `encrypt_query_param` | `string?` | CDN 下载/上传的加密参数 |
| `aes_key` | `string?` | base64 编码的 AES-128 密钥 |
### CDN 上传流程
1. 计算文件明文大小、MD5,以及 AES-128-ECB 加密后的密文大小
2. 如需缩略图(图片/视频),同样计算缩略图的明文和密文参数
3. 调用 `getUploadUrl` 获取 `upload_param`(和 `thumb_upload_param`
4. 使用 AES-128-ECB 加密文件内容,PUT 上传到 CDN URL
5. 缩略图同理加密并上传
6. 使用返回的 `encrypt_query_param` 构造 `CDNMedia` 引用,放入 `MessageItem` 发送
> 完整的类型定义见 [`src/api/types.ts`](src/api/types.ts)API 调用实现见 [`src/api/api.ts`](src/api/api.ts)。
@@ -0,0 +1,27 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { buildChannelConfigSchema } from "openclaw/plugin-sdk";
import { weixinPlugin } from "./src/channel.js";
import { WeixinConfigSchema } from "./src/config/config-schema.js";
import { registerWeixinCli } from "./src/log-upload.js";
import { setWeixinRuntime } from "./src/runtime.js";
const plugin = {
id: "openclaw-weixin",
name: "Weixin",
description: "Weixin channel (getUpdates long-poll + sendMessage)",
configSchema: buildChannelConfigSchema(WeixinConfigSchema),
register(api: OpenClawPluginApi) {
if (!api?.runtime) {
throw new Error("[weixin] api.runtime is not available in register()");
}
setWeixinRuntime(api.runtime);
api.registerChannel({ plugin: weixinPlugin });
api.registerCli(({ program, config }) => registerWeixinCli({ program, config }), {
commands: ["openclaw-weixin"],
});
},
};
export default plugin;
@@ -0,0 +1,9 @@
{
"id": "openclaw-weixin",
"channels": ["openclaw-weixin"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,55 @@
{
"name": "@tencent-weixin/openclaw-weixin",
"version": "1.0.2",
"description": "OpenClaw Weixin channel",
"license": "MIT",
"author": "Tencent",
"type": "module",
"files": [
"src/",
"!src/**/*.test.ts",
"!src/**/node_modules/",
"index.ts",
"openclaw.plugin.json",
"README.md",
"README.zh_CN.md",
"CHANGELOG.md",
"CHANGELOG.zh_CN.md"
],
"scripts": {
"test": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"build": "tsc",
"prepublishOnly": "npm run typecheck && npm run build"
},
"engines": {
"node": ">=22"
},
"dependencies": {
"qrcode-terminal": "0.12.0",
"zod": "4.3.6"
},
"devDependencies": {
"@vitest/coverage-v8": "^3.1.0",
"typescript": "^5.8.0",
"vitest": "^3.1.0"
},
"openclaw": {
"extensions": [
"./index.ts"
],
"channel": {
"id": "openclaw-weixin",
"label": "openclaw-weixin",
"selectionLabel": "openclaw-weixin",
"docsPath": "/channels/openclaw-weixin",
"docsLabel": "openclaw-weixin",
"blurb": "Weixin channel",
"order": 75
},
"install": {
"npmSpec": "@tencent-weixin/openclaw-weixin",
"defaultChoice": "npm"
}
}
}
@@ -0,0 +1,240 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadConfigRouteTag } from "../auth/accounts.js";
import { logger } from "../util/logger.js";
import { redactBody, redactUrl } from "../util/redact.js";
import type {
BaseInfo,
GetUploadUrlReq,
GetUploadUrlResp,
GetUpdatesReq,
GetUpdatesResp,
SendMessageReq,
SendTypingReq,
GetConfigResp,
} from "./types.js";
export type WeixinApiOptions = {
baseUrl: string;
token?: string;
timeoutMs?: number;
/** Long-poll timeout for getUpdates (server may hold the request up to this). */
longPollTimeoutMs?: number;
};
// ---------------------------------------------------------------------------
// BaseInfo — attached to every outgoing CGI request
// ---------------------------------------------------------------------------
function readChannelVersion(): string {
try {
const dir = path.dirname(fileURLToPath(import.meta.url));
const pkgPath = path.resolve(dir, "..", "..", "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { version?: string };
return pkg.version ?? "unknown";
} catch {
return "unknown";
}
}
const CHANNEL_VERSION = readChannelVersion();
/** Build the `base_info` payload included in every API request. */
export function buildBaseInfo(): BaseInfo {
return { channel_version: CHANNEL_VERSION };
}
/** Default timeout for long-poll getUpdates requests. */
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
/** Default timeout for regular API requests (sendMessage, getUploadUrl). */
const DEFAULT_API_TIMEOUT_MS = 15_000;
/** Default timeout for lightweight API requests (getConfig, sendTyping). */
const DEFAULT_CONFIG_TIMEOUT_MS = 10_000;
function ensureTrailingSlash(url: string): string {
return url.endsWith("/") ? url : `${url}/`;
}
/** X-WECHAT-UIN header: random uint32 -> decimal string -> base64. */
function randomWechatUin(): string {
const uint32 = crypto.randomBytes(4).readUInt32BE(0);
return Buffer.from(String(uint32), "utf-8").toString("base64");
}
function buildHeaders(opts: { token?: string; body: string }): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
AuthorizationType: "ilink_bot_token",
"Content-Length": String(Buffer.byteLength(opts.body, "utf-8")),
"X-WECHAT-UIN": randomWechatUin(),
};
if (opts.token?.trim()) {
headers.Authorization = `Bearer ${opts.token.trim()}`;
}
const routeTag = loadConfigRouteTag();
if (routeTag) {
headers.SKRouteTag = routeTag;
}
logger.debug(
`requestHeaders: ${JSON.stringify({ ...headers, Authorization: headers.Authorization ? "Bearer ***" : undefined })}`,
);
return headers;
}
/**
* Common fetch wrapper: POST JSON to a Weixin API endpoint with timeout + abort.
* Returns the raw response text on success; throws on HTTP error or timeout.
*/
async function apiFetch(params: {
baseUrl: string;
endpoint: string;
body: string;
token?: string;
timeoutMs: number;
label: string;
}): Promise<string> {
const base = ensureTrailingSlash(params.baseUrl);
const url = new URL(params.endpoint, base);
const hdrs = buildHeaders({ token: params.token, body: params.body });
logger.debug(`POST ${redactUrl(url.toString())} body=${redactBody(params.body)}`);
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), params.timeoutMs);
try {
const res = await fetch(url.toString(), {
method: "POST",
headers: hdrs,
body: params.body,
signal: controller.signal,
});
clearTimeout(t);
const rawText = await res.text();
logger.debug(`${params.label} status=${res.status} raw=${redactBody(rawText)}`);
if (!res.ok) {
throw new Error(`${params.label} ${res.status}: ${rawText}`);
}
return rawText;
} catch (err) {
clearTimeout(t);
throw err;
}
}
/**
* Long-poll getUpdates. Server should hold the request until new messages or timeout.
*
* On client-side timeout (no server response within timeoutMs), returns an empty response
* with ret=0 so the caller can simply retry. This is normal for long-poll.
*/
export async function getUpdates(
params: GetUpdatesReq & {
baseUrl: string;
token?: string;
timeoutMs?: number;
},
): Promise<GetUpdatesResp> {
const timeout = params.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
try {
const rawText = await apiFetch({
baseUrl: params.baseUrl,
endpoint: "ilink/bot/getupdates",
body: JSON.stringify({
get_updates_buf: params.get_updates_buf ?? "",
base_info: buildBaseInfo(),
}),
token: params.token,
timeoutMs: timeout,
label: "getUpdates",
});
const resp: GetUpdatesResp = JSON.parse(rawText);
return resp;
} catch (err) {
// Long-poll timeout is normal; return empty response so caller can retry
if (err instanceof Error && err.name === "AbortError") {
logger.debug(`getUpdates: client-side timeout after ${timeout}ms, returning empty response`);
return { ret: 0, msgs: [], get_updates_buf: params.get_updates_buf };
}
throw err;
}
}
/** Get a pre-signed CDN upload URL for a file. */
export async function getUploadUrl(
params: GetUploadUrlReq & WeixinApiOptions,
): Promise<GetUploadUrlResp> {
const rawText = await apiFetch({
baseUrl: params.baseUrl,
endpoint: "ilink/bot/getuploadurl",
body: JSON.stringify({
filekey: params.filekey,
media_type: params.media_type,
to_user_id: params.to_user_id,
rawsize: params.rawsize,
rawfilemd5: params.rawfilemd5,
filesize: params.filesize,
thumb_rawsize: params.thumb_rawsize,
thumb_rawfilemd5: params.thumb_rawfilemd5,
thumb_filesize: params.thumb_filesize,
no_need_thumb: params.no_need_thumb,
aeskey: params.aeskey,
base_info: buildBaseInfo(),
}),
token: params.token,
timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
label: "getUploadUrl",
});
const resp: GetUploadUrlResp = JSON.parse(rawText);
return resp;
}
/** Send a single message downstream. */
export async function sendMessage(
params: WeixinApiOptions & { body: SendMessageReq },
): Promise<void> {
await apiFetch({
baseUrl: params.baseUrl,
endpoint: "ilink/bot/sendmessage",
body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
token: params.token,
timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
label: "sendMessage",
});
}
/** Fetch bot config (includes typing_ticket) for a given user. */
export async function getConfig(
params: WeixinApiOptions & { ilinkUserId: string; contextToken?: string },
): Promise<GetConfigResp> {
const rawText = await apiFetch({
baseUrl: params.baseUrl,
endpoint: "ilink/bot/getconfig",
body: JSON.stringify({
ilink_user_id: params.ilinkUserId,
context_token: params.contextToken,
base_info: buildBaseInfo(),
}),
token: params.token,
timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
label: "getConfig",
});
const resp: GetConfigResp = JSON.parse(rawText);
return resp;
}
/** Send a typing indicator to a user. */
export async function sendTyping(
params: WeixinApiOptions & { body: SendTypingReq },
): Promise<void> {
await apiFetch({
baseUrl: params.baseUrl,
endpoint: "ilink/bot/sendtyping",
body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
token: params.token,
timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
label: "sendTyping",
});
}
@@ -0,0 +1,79 @@
import { getConfig } from "./api.js";
/** Subset of getConfig fields that we actually need; add new fields here as needed. */
export interface CachedConfig {
typingTicket: string;
}
const CONFIG_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const CONFIG_CACHE_INITIAL_RETRY_MS = 2_000;
const CONFIG_CACHE_MAX_RETRY_MS = 60 * 60 * 1000;
interface ConfigCacheEntry {
config: CachedConfig;
everSucceeded: boolean;
nextFetchAt: number;
retryDelayMs: number;
}
/**
* Per-user getConfig cache with periodic random refresh (within 24h) and
* exponential-backoff retry (up to 1h) on failure.
*/
export class WeixinConfigManager {
private cache = new Map<string, ConfigCacheEntry>();
constructor(
private apiOpts: { baseUrl: string; token?: string },
private log: (msg: string) => void,
) {}
async getForUser(userId: string, contextToken?: string): Promise<CachedConfig> {
const now = Date.now();
const entry = this.cache.get(userId);
const shouldFetch = !entry || now >= entry.nextFetchAt;
if (shouldFetch) {
let fetchOk = false;
try {
const resp = await getConfig({
baseUrl: this.apiOpts.baseUrl,
token: this.apiOpts.token,
ilinkUserId: userId,
contextToken,
});
if (resp.ret === 0) {
this.cache.set(userId, {
config: { typingTicket: resp.typing_ticket ?? "" },
everSucceeded: true,
nextFetchAt: now + Math.random() * CONFIG_CACHE_TTL_MS,
retryDelayMs: CONFIG_CACHE_INITIAL_RETRY_MS,
});
this.log(
`[weixin] config ${entry?.everSucceeded ? "refreshed" : "cached"} for ${userId}`,
);
fetchOk = true;
}
} catch (err) {
this.log(`[weixin] getConfig failed for ${userId} (ignored): ${String(err)}`);
}
if (!fetchOk) {
const prevDelay = entry?.retryDelayMs ?? CONFIG_CACHE_INITIAL_RETRY_MS;
const nextDelay = Math.min(prevDelay * 2, CONFIG_CACHE_MAX_RETRY_MS);
if (entry) {
entry.nextFetchAt = now + nextDelay;
entry.retryDelayMs = nextDelay;
} else {
this.cache.set(userId, {
config: { typingTicket: "" },
everSucceeded: false,
nextFetchAt: now + CONFIG_CACHE_INITIAL_RETRY_MS,
retryDelayMs: CONFIG_CACHE_INITIAL_RETRY_MS,
});
}
}
}
return this.cache.get(userId)?.config ?? { typingTicket: "" };
}
}
@@ -0,0 +1,58 @@
import { logger } from "../util/logger.js";
const SESSION_PAUSE_DURATION_MS = 60 * 60 * 1000;
/** Error code returned by the server when the bot session has expired. */
export const SESSION_EXPIRED_ERRCODE = -14;
const pauseUntilMap = new Map<string, number>();
/** Pause all inbound/outbound API calls for `accountId` for one hour. */
export function pauseSession(accountId: string): void {
const until = Date.now() + SESSION_PAUSE_DURATION_MS;
pauseUntilMap.set(accountId, until);
logger.info(
`session-guard: paused accountId=${accountId} until=${new Date(until).toISOString()} (${SESSION_PAUSE_DURATION_MS / 1000}s)`,
);
}
/** Returns `true` when the bot is still within its one-hour cooldown window. */
export function isSessionPaused(accountId: string): boolean {
const until = pauseUntilMap.get(accountId);
if (until === undefined) return false;
if (Date.now() >= until) {
pauseUntilMap.delete(accountId);
return false;
}
return true;
}
/** Milliseconds remaining until the pause expires (0 when not paused). */
export function getRemainingPauseMs(accountId: string): number {
const until = pauseUntilMap.get(accountId);
if (until === undefined) return 0;
const remaining = until - Date.now();
if (remaining <= 0) {
pauseUntilMap.delete(accountId);
return 0;
}
return remaining;
}
/** Throw if the session is currently paused. Call before any API request. */
export function assertSessionActive(accountId: string): void {
if (isSessionPaused(accountId)) {
const remainingMin = Math.ceil(getRemainingPauseMs(accountId) / 60_000);
throw new Error(
`session paused for accountId=${accountId}, ${remainingMin} min remaining (errcode ${SESSION_EXPIRED_ERRCODE})`,
);
}
}
/**
* Reset internal state — only for tests.
* @internal
*/
export function _resetForTest(): void {
pauseUntilMap.clear();
}
@@ -0,0 +1,222 @@
/**
* Weixin protocol types (mirrors proto: GetUpdatesReq/Resp, WeixinMessage, SendMessageReq).
* API uses JSON over HTTP; bytes fields are base64 strings in JSON.
*/
/** Common request metadata attached to every CGI request. */
export interface BaseInfo {
channel_version?: string;
}
/** proto: UploadMediaType */
export const UploadMediaType = {
IMAGE: 1,
VIDEO: 2,
FILE: 3,
VOICE: 4,
} as const;
export interface GetUploadUrlReq {
filekey?: string;
/** proto field 2: media_type, see UploadMediaType */
media_type?: number;
to_user_id?: string;
/** 原文件明文大小 */
rawsize?: number;
/** 原文件明文 MD5 */
rawfilemd5?: string;
/** 原文件密文大小(AES-128-ECB 加密后) */
filesize?: number;
/** 缩略图明文大小(IMAGE/VIDEO 时必填) */
thumb_rawsize?: number;
/** 缩略图明文 MD5IMAGE/VIDEO 时必填) */
thumb_rawfilemd5?: string;
/** 缩略图密文大小(IMAGE/VIDEO 时必填) */
thumb_filesize?: number;
/** 不需要缩略图上传 URL,默认 false */
no_need_thumb?: boolean;
/** 加密 key */
aeskey?: string;
}
export interface GetUploadUrlResp {
/** 原图上传加密参数 */
upload_param?: string;
/** 缩略图上传加密参数,无缩略图时为空 */
thumb_upload_param?: string;
}
export const MessageType = {
NONE: 0,
USER: 1,
BOT: 2,
} as const;
export const MessageItemType = {
NONE: 0,
TEXT: 1,
IMAGE: 2,
VOICE: 3,
FILE: 4,
VIDEO: 5,
} as const;
export const MessageState = {
NEW: 0,
GENERATING: 1,
FINISH: 2,
} as const;
export interface TextItem {
text?: string;
}
/** CDN media reference; aes_key is base64-encoded bytes in JSON. */
export interface CDNMedia {
encrypt_query_param?: string;
aes_key?: string;
/** 加密类型: 0=只加密fileid, 1=打包缩略图/中图等信息 */
encrypt_type?: number;
}
export interface ImageItem {
/** 原图 CDN 引用 */
media?: CDNMedia;
/** 缩略图 CDN 引用 */
thumb_media?: CDNMedia;
/** Raw AES-128 key as hex string (16 bytes); preferred over media.aes_key for inbound decryption. */
aeskey?: string;
url?: string;
mid_size?: number;
thumb_size?: number;
thumb_height?: number;
thumb_width?: number;
hd_size?: number;
}
export interface VoiceItem {
media?: CDNMedia;
/** 语音编码类型:1=pcm 2=adpcm 3=feature 4=speex 5=amr 6=silk 7=mp3 8=ogg-speex */
encode_type?: number;
bits_per_sample?: number;
/** 采样率 (Hz) */
sample_rate?: number;
/** 语音长度 (毫秒) */
playtime?: number;
/** 语音转文字内容 */
text?: string;
}
export interface FileItem {
media?: CDNMedia;
file_name?: string;
md5?: string;
len?: string;
}
export interface VideoItem {
media?: CDNMedia;
video_size?: number;
play_length?: number;
video_md5?: string;
thumb_media?: CDNMedia;
thumb_size?: number;
thumb_height?: number;
thumb_width?: number;
}
export interface RefMessage {
message_item?: MessageItem;
title?: string; // 摘要
}
export interface MessageItem {
type?: number;
create_time_ms?: number;
update_time_ms?: number;
is_completed?: boolean;
msg_id?: string;
ref_msg?: RefMessage;
text_item?: TextItem;
image_item?: ImageItem;
voice_item?: VoiceItem;
file_item?: FileItem;
video_item?: VideoItem;
}
/** Unified message (proto: WeixinMessage). Replaces the old split Message + MessageContent + FullMessage. */
export interface WeixinMessage {
seq?: number;
message_id?: number;
from_user_id?: string;
to_user_id?: string;
client_id?: string;
create_time_ms?: number;
update_time_ms?: number;
delete_time_ms?: number;
session_id?: string;
group_id?: string;
message_type?: number;
message_state?: number;
item_list?: MessageItem[];
context_token?: string;
}
/** GetUpdates request: bytes fields are base64 strings in JSON. */
export interface GetUpdatesReq {
/** @deprecated compat only, will be removed */
sync_buf?: string;
/** Full context buf cached locally; send "" when none (first request or after reset). */
get_updates_buf?: string;
}
/** GetUpdates response: bytes fields are base64 strings in JSON. */
export interface GetUpdatesResp {
ret?: number;
/** Error code returned by the server (e.g. -14 = session timeout). Present when request fails. */
errcode?: number;
errmsg?: string;
msgs?: WeixinMessage[];
/** @deprecated compat only */
sync_buf?: string;
/** Full context buf to cache locally and send on next request. */
get_updates_buf?: string;
/** Server-suggested timeout (ms) for the next getUpdates long-poll. */
longpolling_timeout_ms?: number;
}
/** SendMessage request: wraps a single WeixinMessage. */
export interface SendMessageReq {
msg?: WeixinMessage;
}
export interface SendMessageResp {
// empty
}
/** Typing status: 1 = typing (default), 2 = cancel typing. */
export const TypingStatus = {
TYPING: 1,
CANCEL: 2,
} as const;
/** SendTyping request: send a typing indicator to a user. */
export interface SendTypingReq {
ilink_user_id?: string;
typing_ticket?: string;
/** 1=typing (default), 2=cancel typing */
status?: number;
}
export interface SendTypingResp {
ret?: number;
errmsg?: string;
}
/** GetConfig response: bot config including typing_ticket. */
export interface GetConfigResp {
ret?: number;
errmsg?: string;
/** Base64-encoded typing ticket for sendTyping. */
typing_ticket?: string;
}
@@ -0,0 +1,289 @@
import fs from "node:fs";
import path from "node:path";
import { normalizeAccountId } from "openclaw/plugin-sdk";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { getWeixinRuntime } from "../runtime.js";
import { resolveStateDir } from "../storage/state-dir.js";
import { logger } from "../util/logger.js";
export const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
export const CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
// ---------------------------------------------------------------------------
// Account ID compatibility (legacy raw ID → normalized ID)
// ---------------------------------------------------------------------------
/**
* Pattern-based reverse of normalizeWeixinAccountId for known weixin ID suffixes.
* Used only as a compatibility fallback when loading accounts / sync bufs stored
* under the old raw ID.
* e.g. "b0f5860fdecb-im-bot" → "b0f5860fdecb@im.bot"
*/
export function deriveRawAccountId(normalizedId: string): string | undefined {
if (normalizedId.endsWith("-im-bot")) {
return `${normalizedId.slice(0, -7)}@im.bot`;
}
if (normalizedId.endsWith("-im-wechat")) {
return `${normalizedId.slice(0, -10)}@im.wechat`;
}
return undefined;
}
// ---------------------------------------------------------------------------
// Account index (persistent list of registered account IDs)
// ---------------------------------------------------------------------------
function resolveWeixinStateDir(): string {
return path.join(resolveStateDir(), "openclaw-weixin");
}
function resolveAccountIndexPath(): string {
return path.join(resolveWeixinStateDir(), "accounts.json");
}
/** Returns all accountIds registered via QR login. */
export function listIndexedWeixinAccountIds(): string[] {
const filePath = resolveAccountIndexPath();
try {
if (!fs.existsSync(filePath)) return [];
const raw = fs.readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((id): id is string => typeof id === "string" && id.trim() !== "");
} catch {
return [];
}
}
/** Add accountId to the persistent index (no-op if already present). */
export function registerWeixinAccountId(accountId: string): void {
const dir = resolveWeixinStateDir();
fs.mkdirSync(dir, { recursive: true });
const existing = listIndexedWeixinAccountIds();
if (existing.includes(accountId)) return;
const updated = [...existing, accountId];
fs.writeFileSync(resolveAccountIndexPath(), JSON.stringify(updated, null, 2), "utf-8");
}
// ---------------------------------------------------------------------------
// Account store (per-account credential files)
// ---------------------------------------------------------------------------
/** Unified per-account data: token + baseUrl in one file. */
export type WeixinAccountData = {
token?: string;
savedAt?: string;
baseUrl?: string;
/** Last linked Weixin user id from QR login (optional). */
userId?: string;
};
function resolveAccountsDir(): string {
return path.join(resolveWeixinStateDir(), "accounts");
}
function resolveAccountPath(accountId: string): string {
return path.join(resolveAccountsDir(), `${accountId}.json`);
}
/**
* Legacy single-file token: `credentials/openclaw-weixin/credentials.json` (pre per-account files).
*/
function loadLegacyToken(): string | undefined {
const legacyPath = path.join(resolveStateDir(), "credentials", "openclaw-weixin", "credentials.json");
try {
if (!fs.existsSync(legacyPath)) return undefined;
const raw = fs.readFileSync(legacyPath, "utf-8");
const parsed = JSON.parse(raw) as { token?: string };
return typeof parsed.token === "string" ? parsed.token : undefined;
} catch {
return undefined;
}
}
function readAccountFile(filePath: string): WeixinAccountData | null {
try {
if (fs.existsSync(filePath)) {
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as WeixinAccountData;
}
} catch {
// ignore
}
return null;
}
/** Load account data by ID, with compatibility fallbacks. */
export function loadWeixinAccount(accountId: string): WeixinAccountData | null {
// Primary: try given accountId (normalized IDs written after this change).
const primary = readAccountFile(resolveAccountPath(accountId));
if (primary) return primary;
// Compatibility: if the given ID is normalized, derive the old raw filename
// (e.g. "b0f5860fdecb-im-bot" → "b0f5860fdecb@im.bot") for existing installs.
const rawId = deriveRawAccountId(accountId);
if (rawId) {
const compat = readAccountFile(resolveAccountPath(rawId));
if (compat) return compat;
}
// Legacy fallback: read token from old single-account credentials file.
const token = loadLegacyToken();
if (token) return { token };
return null;
}
/**
* Persist account data after QR login (merges into existing file).
* - token: overwritten when provided.
* - baseUrl: stored when non-empty; resolveWeixinAccount falls back to DEFAULT_BASE_URL.
* - userId: set when `update.userId` is provided; omitted from file when cleared to empty.
*/
export function saveWeixinAccount(
accountId: string,
update: { token?: string; baseUrl?: string; userId?: string },
): void {
const dir = resolveAccountsDir();
fs.mkdirSync(dir, { recursive: true });
const existing = loadWeixinAccount(accountId) ?? {};
const token = update.token?.trim() || existing.token;
const baseUrl = update.baseUrl?.trim() || existing.baseUrl;
const userId =
update.userId !== undefined
? update.userId.trim() || undefined
: existing.userId?.trim() || undefined;
const data: WeixinAccountData = {
...(token ? { token, savedAt: new Date().toISOString() } : {}),
...(baseUrl ? { baseUrl } : {}),
...(userId ? { userId } : {}),
};
const filePath = resolveAccountPath(accountId);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
try {
fs.chmodSync(filePath, 0o600);
} catch {
// best-effort
}
}
/** Remove account data file. */
export function clearWeixinAccount(accountId: string): void {
try {
fs.unlinkSync(resolveAccountPath(accountId));
} catch {
// ignore if not found
}
}
/**
* Resolve the openclaw.json config file path.
* Checks OPENCLAW_CONFIG env var, then state dir.
*/
function resolveConfigPath(): string {
const envPath = process.env.OPENCLAW_CONFIG?.trim();
if (envPath) return envPath;
return path.join(resolveStateDir(), "openclaw.json");
}
/**
* Read `routeTag` from openclaw.json (for callers without an `OpenClawConfig` object).
* Checks per-account `channels.<id>.accounts[accountId].routeTag` first, then section-level
* `channels.<id>.routeTag`. Matches `feat_weixin_extension` behavior; channel key is `"openclaw-weixin"`.
*/
export function loadConfigRouteTag(accountId?: string): string | undefined {
try {
const configPath = resolveConfigPath();
if (!fs.existsSync(configPath)) return undefined;
const raw = fs.readFileSync(configPath, "utf-8");
const cfg = JSON.parse(raw) as Record<string, unknown>;
const channels = cfg.channels as Record<string, unknown> | undefined;
const section = channels?.["openclaw-weixin"] as Record<string, unknown> | undefined;
if (!section) return undefined;
if (accountId) {
const accounts = section.accounts as Record<string, Record<string, unknown>> | undefined;
const tag = accounts?.[accountId]?.routeTag;
if (typeof tag === "number") return String(tag);
if (typeof tag === "string" && tag.trim()) return tag.trim();
}
if (typeof section.routeTag === "number") return String(section.routeTag);
return typeof section.routeTag === "string" && section.routeTag.trim()
? section.routeTag.trim()
: undefined;
} catch {
return undefined;
}
}
/**
* No-op stub — config reload is now handled externally via `openclaw gateway restart`.
*/
export async function triggerWeixinChannelReload(): Promise<void> {}
// ---------------------------------------------------------------------------
// Account resolution (merge config + stored credentials)
// ---------------------------------------------------------------------------
export type ResolvedWeixinAccount = {
accountId: string;
baseUrl: string;
cdnBaseUrl: string;
token?: string;
enabled: boolean;
/** true when a token has been obtained via QR login. */
configured: boolean;
name?: string;
};
type WeixinAccountConfig = {
name?: string;
enabled?: boolean;
cdnBaseUrl?: string;
/** Optional SKRouteTag source; read from openclaw.json when `accountId` is passed to `loadConfigRouteTag`. */
routeTag?: number | string;
};
type WeixinSectionConfig = WeixinAccountConfig & {
accounts?: Record<string, WeixinAccountConfig>;
};
/** List accountIds from the index file (written at QR login). */
export function listWeixinAccountIds(_cfg: OpenClawConfig): string[] {
return listIndexedWeixinAccountIds();
}
/** Resolve a weixin account by ID, merging config and stored credentials. */
export function resolveWeixinAccount(
cfg: OpenClawConfig,
accountId?: string | null,
): ResolvedWeixinAccount {
const raw = accountId?.trim();
if (!raw) {
throw new Error("weixin: accountId is required (no default account)");
}
const id = normalizeAccountId(raw);
const section = cfg.channels?.["openclaw-weixin"] as WeixinSectionConfig | undefined;
const accountCfg: WeixinAccountConfig = section?.accounts?.[id] ?? section ?? {};
const accountData = loadWeixinAccount(id);
const token = accountData?.token?.trim() || undefined;
const stateBaseUrl = accountData?.baseUrl?.trim() || "";
return {
accountId: id,
baseUrl: stateBaseUrl || DEFAULT_BASE_URL,
cdnBaseUrl: accountCfg.cdnBaseUrl?.trim() || CDN_BASE_URL,
token,
enabled: accountCfg.enabled !== false,
configured: Boolean(token),
name: accountCfg.name?.trim() || undefined,
};
}
@@ -0,0 +1,333 @@
import { randomUUID } from "node:crypto";
import { loadConfigRouteTag } from "./accounts.js";
import { logger } from "../util/logger.js";
import { redactToken } from "../util/redact.js";
type ActiveLogin = {
sessionKey: string;
id: string;
qrcode: string;
qrcodeUrl: string;
startedAt: number;
botToken?: string;
status?: "wait" | "scaned" | "confirmed" | "expired";
error?: string;
};
const ACTIVE_LOGIN_TTL_MS = 5 * 60_000;
/** Client-side timeout for the long-poll get_qrcode_status request. */
const QR_LONG_POLL_TIMEOUT_MS = 35_000;
/** Default `bot_type` for ilink get_bot_qrcode / get_qrcode_status (this channel build). */
export const DEFAULT_ILINK_BOT_TYPE = "3";
const activeLogins = new Map<string, ActiveLogin>();
interface QRCodeResponse {
qrcode: string;
qrcode_img_content: string;
}
interface StatusResponse {
status: "wait" | "scaned" | "confirmed" | "expired";
bot_token?: string;
ilink_bot_id?: string;
baseurl?: string;
/** The user ID of the person who scanned the QR code. */
ilink_user_id?: string;
}
function isLoginFresh(login: ActiveLogin): boolean {
return Date.now() - login.startedAt < ACTIVE_LOGIN_TTL_MS;
}
/** Remove all expired entries from the activeLogins map to prevent memory leaks. */
function purgeExpiredLogins(): void {
for (const [id, login] of activeLogins) {
if (!isLoginFresh(login)) {
activeLogins.delete(id);
}
}
}
async function fetchQRCode(apiBaseUrl: string, botType: string): Promise<QRCodeResponse> {
const base = apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`;
const url = new URL(`ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`, base);
logger.info(`Fetching QR code from: ${url.toString()}`);
const headers: Record<string, string> = {};
const routeTag = loadConfigRouteTag();
if (routeTag) {
headers.SKRouteTag = routeTag;
}
const response = await fetch(url.toString(), { headers });
if (!response.ok) {
const body = await response.text().catch(() => "(unreadable)");
logger.error(`QR code fetch failed: ${response.status} ${response.statusText} body=${body}`);
throw new Error(`Failed to fetch QR code: ${response.status} ${response.statusText}`);
}
return await response.json();
}
async function pollQRStatus(apiBaseUrl: string, qrcode: string): Promise<StatusResponse> {
const base = apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`;
const url = new URL(`ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, base);
logger.debug(`Long-poll QR status from: ${url.toString()}`);
const headers: Record<string, string> = {
"iLink-App-ClientVersion": "1",
};
const routeTag = loadConfigRouteTag();
if (routeTag) {
headers.SKRouteTag = routeTag;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), QR_LONG_POLL_TIMEOUT_MS);
try {
const response = await fetch(url.toString(), { headers, signal: controller.signal });
clearTimeout(timer);
logger.debug(`pollQRStatus: HTTP ${response.status}, reading body...`);
const rawText = await response.text();
logger.debug(`pollQRStatus: body=${rawText.substring(0, 200)}`);
if (!response.ok) {
logger.error(`QR status poll failed: ${response.status} ${response.statusText} body=${rawText}`);
throw new Error(`Failed to poll QR status: ${response.status} ${response.statusText}`);
}
return JSON.parse(rawText) as StatusResponse;
} catch (err) {
clearTimeout(timer);
if (err instanceof Error && err.name === "AbortError") {
logger.debug(`pollQRStatus: client-side timeout after ${QR_LONG_POLL_TIMEOUT_MS}ms, returning wait`);
return { status: "wait" };
}
throw err;
}
}
export type WeixinQrStartResult = {
qrcodeUrl?: string;
message: string;
sessionKey: string;
};
export type WeixinQrWaitResult = {
connected: boolean;
botToken?: string;
accountId?: string;
baseUrl?: string;
/** The user ID of the person who scanned the QR code; add to allowFrom. */
userId?: string;
message: string;
};
export async function startWeixinLoginWithQr(opts: {
verbose?: boolean;
timeoutMs?: number;
force?: boolean;
accountId?: string;
apiBaseUrl: string;
botType?: string;
}): Promise<WeixinQrStartResult> {
const sessionKey = opts.accountId || randomUUID();
purgeExpiredLogins();
const existing = activeLogins.get(sessionKey);
if (!opts.force && existing && isLoginFresh(existing) && existing.qrcodeUrl) {
return {
qrcodeUrl: existing.qrcodeUrl,
message: "二维码已就绪,请使用微信扫描。",
sessionKey,
};
}
try {
const botType = opts.botType || DEFAULT_ILINK_BOT_TYPE;
logger.info(`Starting Weixin login with bot_type=${botType}`);
if (!opts.apiBaseUrl) {
return {
message:
"No baseUrl configured. Add channels.openclaw-weixin.baseUrl to your config before logging in.",
sessionKey,
};
}
const qrResponse = await fetchQRCode(opts.apiBaseUrl, botType);
logger.info(
`QR code received, qrcode=${redactToken(qrResponse.qrcode)} imgContentLen=${qrResponse.qrcode_img_content?.length ?? 0}`,
);
logger.info(`二维码链接: ${qrResponse.qrcode_img_content}`);
const login: ActiveLogin = {
sessionKey,
id: randomUUID(),
qrcode: qrResponse.qrcode,
qrcodeUrl: qrResponse.qrcode_img_content,
startedAt: Date.now(),
};
activeLogins.set(sessionKey, login);
return {
qrcodeUrl: qrResponse.qrcode_img_content,
message: "使用微信扫描以下二维码,以完成连接。",
sessionKey,
};
} catch (err) {
logger.error(`Failed to start Weixin login: ${String(err)}`);
return {
message: `Failed to start login: ${String(err)}`,
sessionKey,
};
}
}
const MAX_QR_REFRESH_COUNT = 3;
export async function waitForWeixinLogin(opts: {
timeoutMs?: number;
verbose?: boolean;
sessionKey: string;
apiBaseUrl: string;
botType?: string;
}): Promise<WeixinQrWaitResult> {
let activeLogin = activeLogins.get(opts.sessionKey);
if (!activeLogin) {
logger.warn(`waitForWeixinLogin: no active login sessionKey=${opts.sessionKey}`);
return {
connected: false,
message: "当前没有进行中的登录,请先发起登录。",
};
}
if (!isLoginFresh(activeLogin)) {
logger.warn(`waitForWeixinLogin: login QR expired sessionKey=${opts.sessionKey}`);
activeLogins.delete(opts.sessionKey);
return {
connected: false,
message: "二维码已过期,请重新生成。",
};
}
const timeoutMs = Math.max(opts.timeoutMs ?? 480_000, 1000);
const deadline = Date.now() + timeoutMs;
let scannedPrinted = false;
let qrRefreshCount = 1;
logger.info("Starting to poll QR code status...");
while (Date.now() < deadline) {
try {
const statusResponse = await pollQRStatus(opts.apiBaseUrl, activeLogin.qrcode);
logger.debug(`pollQRStatus: status=${statusResponse.status} hasBotToken=${Boolean(statusResponse.bot_token)} hasBotId=${Boolean(statusResponse.ilink_bot_id)}`);
activeLogin.status = statusResponse.status;
switch (statusResponse.status) {
case "wait":
if (opts.verbose) {
process.stdout.write(".");
}
break;
case "scaned":
if (!scannedPrinted) {
process.stdout.write("\n👀 已扫码,在微信继续操作...\n");
scannedPrinted = true;
}
break;
case "expired": {
qrRefreshCount++;
if (qrRefreshCount > MAX_QR_REFRESH_COUNT) {
logger.warn(
`waitForWeixinLogin: QR expired ${MAX_QR_REFRESH_COUNT} times, giving up sessionKey=${opts.sessionKey}`,
);
activeLogins.delete(opts.sessionKey);
return {
connected: false,
message: "登录超时:二维码多次过期,请重新开始登录流程。",
};
}
process.stdout.write(`\n⏳ 二维码已过期,正在刷新...(${qrRefreshCount}/${MAX_QR_REFRESH_COUNT})\n`);
logger.info(
`waitForWeixinLogin: QR expired, refreshing (${qrRefreshCount}/${MAX_QR_REFRESH_COUNT})`,
);
try {
const botType = opts.botType || DEFAULT_ILINK_BOT_TYPE;
const qrResponse = await fetchQRCode(opts.apiBaseUrl, botType);
activeLogin.qrcode = qrResponse.qrcode;
activeLogin.qrcodeUrl = qrResponse.qrcode_img_content;
activeLogin.startedAt = Date.now();
scannedPrinted = false;
logger.info(`waitForWeixinLogin: new QR code obtained qrcode=${redactToken(qrResponse.qrcode)}`);
process.stdout.write(`🔄 新二维码已生成,请重新扫描\n\n`);
try {
const qrterm = await import("qrcode-terminal");
qrterm.default.generate(qrResponse.qrcode_img_content, { small: true });
} catch {
process.stdout.write(`QR Code URL: ${qrResponse.qrcode_img_content}\n`);
}
} catch (refreshErr) {
logger.error(`waitForWeixinLogin: failed to refresh QR code: ${String(refreshErr)}`);
activeLogins.delete(opts.sessionKey);
return {
connected: false,
message: `刷新二维码失败: ${String(refreshErr)}`,
};
}
break;
}
case "confirmed": {
if (!statusResponse.ilink_bot_id) {
activeLogins.delete(opts.sessionKey);
logger.error("Login confirmed but ilink_bot_id missing from response");
return {
connected: false,
message: "登录失败:服务器未返回 ilink_bot_id。",
};
}
activeLogin.botToken = statusResponse.bot_token;
activeLogins.delete(opts.sessionKey);
logger.info(
`✅ Login confirmed! ilink_bot_id=${statusResponse.ilink_bot_id} ilink_user_id=${redactToken(statusResponse.ilink_user_id)}`,
);
return {
connected: true,
botToken: statusResponse.bot_token,
accountId: statusResponse.ilink_bot_id,
baseUrl: statusResponse.baseurl,
userId: statusResponse.ilink_user_id,
message: "✅ 与微信连接成功!",
};
}
}
} catch (err) {
logger.error(`Error polling QR status: ${String(err)}`);
activeLogins.delete(opts.sessionKey);
return {
connected: false,
message: `Login failed: ${String(err)}`,
};
}
await new Promise((r) => setTimeout(r, 1000));
}
logger.warn(
`waitForWeixinLogin: timed out waiting for QR scan sessionKey=${opts.sessionKey} timeoutMs=${timeoutMs}`,
);
activeLogins.delete(opts.sessionKey);
return {
connected: false,
message: "登录超时,请重试。",
};
}
@@ -0,0 +1,120 @@
import fs from "node:fs";
import path from "node:path";
import { withFileLock } from "openclaw/plugin-sdk";
import { resolveStateDir } from "../storage/state-dir.js";
import { logger } from "../util/logger.js";
/**
* Resolve the framework credentials directory (mirrors core resolveOAuthDir).
* Path: $OPENCLAW_OAUTH_DIR || $OPENCLAW_STATE_DIR/credentials || ~/.openclaw/credentials
*/
function resolveCredentialsDir(): string {
const override = process.env.OPENCLAW_OAUTH_DIR?.trim();
if (override) return override;
return path.join(resolveStateDir(), "credentials");
}
/**
* Sanitize a channel/account key for safe use in filenames (mirrors core safeChannelKey).
*/
function safeKey(raw: string): string {
const trimmed = raw.trim().toLowerCase();
if (!trimmed) throw new Error("invalid key for allowFrom path");
const safe = trimmed.replace(/[\\/:*?"<>|]/g, "_").replace(/\.\./g, "_");
if (!safe || safe === "_") throw new Error("invalid key for allowFrom path");
return safe;
}
/**
* Resolve the framework allowFrom file path for a given account.
* Mirrors: `resolveAllowFromPath(channel, env, accountId)` from core.
* Path: `<credDir>/openclaw-weixin-<accountId>-allowFrom.json`
*/
export function resolveFrameworkAllowFromPath(accountId: string): string {
const base = safeKey("openclaw-weixin");
const safeAccount = safeKey(accountId);
return path.join(resolveCredentialsDir(), `${base}-${safeAccount}-allowFrom.json`);
}
type AllowFromFileContent = {
version: number;
allowFrom: string[];
};
/**
* Read the framework allowFrom list for an account (user IDs authorized via pairing).
* Returns an empty array when the file is missing or unreadable.
*/
export function readFrameworkAllowFromList(accountId: string): string[] {
const filePath = resolveFrameworkAllowFromPath(accountId);
try {
if (!fs.existsSync(filePath)) return [];
const raw = fs.readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw) as AllowFromFileContent;
if (Array.isArray(parsed.allowFrom)) {
return parsed.allowFrom.filter((id): id is string => typeof id === "string" && id.trim() !== "");
}
} catch {
// best-effort
}
return [];
}
/** File lock options matching the framework's pairing store lock settings. */
const LOCK_OPTIONS = {
retries: { retries: 3, factor: 2, minTimeout: 100, maxTimeout: 2000 },
stale: 10_000,
};
/**
* Register a user ID in the framework's channel allowFrom store.
* This writes directly to the same JSON file that `readChannelAllowFromStore` reads,
* making the user visible to the framework authorization pipeline.
*
* Uses file locking to avoid races with concurrent readers/writers.
*/
export async function registerUserInFrameworkStore(params: {
accountId: string;
userId: string;
}): Promise<{ changed: boolean }> {
const { accountId, userId } = params;
const trimmedUserId = userId.trim();
if (!trimmedUserId) return { changed: false };
const filePath = resolveFrameworkAllowFromPath(accountId);
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
// Ensure the file exists before locking
if (!fs.existsSync(filePath)) {
const initial: AllowFromFileContent = { version: 1, allowFrom: [] };
fs.writeFileSync(filePath, JSON.stringify(initial, null, 2), "utf-8");
}
return await withFileLock(filePath, LOCK_OPTIONS, async () => {
let content: AllowFromFileContent = { version: 1, allowFrom: [] };
try {
const raw = fs.readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw) as AllowFromFileContent;
if (Array.isArray(parsed.allowFrom)) {
content = parsed;
}
} catch {
// If read/parse fails, start fresh
}
if (content.allowFrom.includes(trimmedUserId)) {
return { changed: false };
}
content.allowFrom.push(trimmedUserId);
fs.writeFileSync(filePath, JSON.stringify(content, null, 2), "utf-8");
logger.info(
`registerUserInFrameworkStore: added userId=${trimmedUserId} accountId=${accountId} path=${filePath}`,
);
return { changed: true };
});
}
@@ -0,0 +1,21 @@
/**
* Shared AES-128-ECB crypto utilities for CDN upload and download.
*/
import { createCipheriv, createDecipheriv } from "node:crypto";
/** Encrypt buffer with AES-128-ECB (PKCS7 padding is default). */
export function encryptAesEcb(plaintext: Buffer, key: Buffer): Buffer {
const cipher = createCipheriv("aes-128-ecb", key, null);
return Buffer.concat([cipher.update(plaintext), cipher.final()]);
}
/** Decrypt buffer with AES-128-ECB (PKCS7 padding). */
export function decryptAesEcb(ciphertext: Buffer, key: Buffer): Buffer {
const decipher = createDecipheriv("aes-128-ecb", key, null);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}
/** Compute AES-128-ECB ciphertext size (PKCS7 padding to 16-byte boundary). */
export function aesEcbPaddedSize(plaintextSize: number): number {
return Math.ceil((plaintextSize + 1) / 16) * 16;
}
@@ -0,0 +1,77 @@
import { encryptAesEcb } from "./aes-ecb.js";
import { buildCdnUploadUrl } from "./cdn-url.js";
import { logger } from "../util/logger.js";
import { redactUrl } from "../util/redact.js";
/** Maximum retry attempts for CDN upload. */
const UPLOAD_MAX_RETRIES = 3;
/**
* Upload one buffer to the Weixin CDN with AES-128-ECB encryption.
* Returns the download encrypted_query_param from the CDN response.
* Retries up to UPLOAD_MAX_RETRIES times on server errors; client errors (4xx) abort immediately.
*/
export async function uploadBufferToCdn(params: {
buf: Buffer;
uploadParam: string;
filekey: string;
cdnBaseUrl: string;
label: string;
aeskey: Buffer;
}): Promise<{ downloadParam: string }> {
const { buf, uploadParam, filekey, cdnBaseUrl, label, aeskey } = params;
const ciphertext = encryptAesEcb(buf, aeskey);
const cdnUrl = buildCdnUploadUrl({ cdnBaseUrl, uploadParam, filekey });
logger.debug(`${label}: CDN POST url=${redactUrl(cdnUrl)} ciphertextSize=${ciphertext.length}`);
let downloadParam: string | undefined;
let lastError: unknown;
for (let attempt = 1; attempt <= UPLOAD_MAX_RETRIES; attempt++) {
try {
const res = await fetch(cdnUrl, {
method: "POST",
headers: { "Content-Type": "application/octet-stream" },
body: new Uint8Array(ciphertext),
});
if (res.status >= 400 && res.status < 500) {
const errMsg = res.headers.get("x-error-message") ?? (await res.text());
logger.error(
`${label}: CDN client error attempt=${attempt} status=${res.status} errMsg=${errMsg}`,
);
throw new Error(`CDN upload client error ${res.status}: ${errMsg}`);
}
if (res.status !== 200) {
const errMsg = res.headers.get("x-error-message") ?? `status ${res.status}`;
logger.error(
`${label}: CDN server error attempt=${attempt} status=${res.status} errMsg=${errMsg}`,
);
throw new Error(`CDN upload server error: ${errMsg}`);
}
downloadParam = res.headers.get("x-encrypted-param") ?? undefined;
if (!downloadParam) {
logger.error(
`${label}: CDN response missing x-encrypted-param header attempt=${attempt}`,
);
throw new Error("CDN upload response missing x-encrypted-param header");
}
logger.debug(`${label}: CDN upload success attempt=${attempt}`);
break;
} catch (err) {
lastError = err;
if (err instanceof Error && err.message.includes("client error")) throw err;
if (attempt < UPLOAD_MAX_RETRIES) {
logger.error(`${label}: attempt ${attempt} failed, retrying... err=${String(err)}`);
} else {
logger.error(`${label}: all ${UPLOAD_MAX_RETRIES} attempts failed err=${String(err)}`);
}
}
}
if (!downloadParam) {
throw lastError instanceof Error
? lastError
: new Error(`CDN upload failed after ${UPLOAD_MAX_RETRIES} attempts`);
}
return { downloadParam };
}
@@ -0,0 +1,17 @@
/**
* Unified CDN URL construction for Weixin CDN upload/download.
*/
/** Build a CDN download URL from encrypt_query_param. */
export function buildCdnDownloadUrl(encryptedQueryParam: string, cdnBaseUrl: string): string {
return `${cdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}`;
}
/** Build a CDN upload URL from upload_param and filekey. */
export function buildCdnUploadUrl(params: {
cdnBaseUrl: string;
uploadParam: string;
filekey: string;
}): string {
return `${params.cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(params.uploadParam)}&filekey=${encodeURIComponent(params.filekey)}`;
}
@@ -0,0 +1,85 @@
import { decryptAesEcb } from "./aes-ecb.js";
import { buildCdnDownloadUrl } from "./cdn-url.js";
import { logger } from "../util/logger.js";
/**
* Download raw bytes from the CDN (no decryption).
*/
async function fetchCdnBytes(url: string, label: string): Promise<Buffer> {
let res: Response;
try {
res = await fetch(url);
} catch (err) {
const cause =
(err as NodeJS.ErrnoException).cause ?? (err as NodeJS.ErrnoException).code ?? "(no cause)";
logger.error(
`${label}: fetch network error url=${url} err=${String(err)} cause=${String(cause)}`,
);
throw err;
}
logger.debug(`${label}: response status=${res.status} ok=${res.ok}`);
if (!res.ok) {
const body = await res.text().catch(() => "(unreadable)");
const msg = `${label}: CDN download ${res.status} ${res.statusText} body=${body}`;
logger.error(msg);
throw new Error(msg);
}
return Buffer.from(await res.arrayBuffer());
}
/**
* Parse CDNMedia.aes_key into a raw 16-byte AES key.
*
* Two encodings are seen in the wild:
* - base64(raw 16 bytes) → images (aes_key from media field)
* - base64(hex string of 16 bytes) → file / voice / video
*
* In the second case, base64-decoding yields 32 ASCII hex chars which must
* then be parsed as hex to recover the actual 16-byte key.
*/
function parseAesKey(aesKeyBase64: string, label: string): Buffer {
const decoded = Buffer.from(aesKeyBase64, "base64");
if (decoded.length === 16) {
return decoded;
}
if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString("ascii"))) {
// hex-encoded key: base64 → hex string → raw bytes
return Buffer.from(decoded.toString("ascii"), "hex");
}
const msg = `${label}: aes_key must decode to 16 raw bytes or 32-char hex string, got ${decoded.length} bytes (base64="${aesKeyBase64}")`;
logger.error(msg);
throw new Error(msg);
}
/**
* Download and AES-128-ECB decrypt a CDN media file. Returns plaintext Buffer.
* aesKeyBase64: CDNMedia.aes_key JSON field (see parseAesKey for supported formats).
*/
export async function downloadAndDecryptBuffer(
encryptedQueryParam: string,
aesKeyBase64: string,
cdnBaseUrl: string,
label: string,
): Promise<Buffer> {
const key = parseAesKey(aesKeyBase64, label);
const url = buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl);
logger.debug(`${label}: fetching url=${url}`);
const encrypted = await fetchCdnBytes(url, label);
logger.debug(`${label}: downloaded ${encrypted.byteLength} bytes, decrypting`);
const decrypted = decryptAesEcb(encrypted, key);
logger.debug(`${label}: decrypted ${decrypted.length} bytes`);
return decrypted;
}
/**
* Download plain (unencrypted) bytes from the CDN. Returns the raw Buffer.
*/
export async function downloadPlainCdnBuffer(
encryptedQueryParam: string,
cdnBaseUrl: string,
label: string,
): Promise<Buffer> {
const url = buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl);
logger.debug(`${label}: fetching url=${url}`);
return fetchCdnBytes(url, label);
}
@@ -0,0 +1,155 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { getUploadUrl } from "../api/api.js";
import type { WeixinApiOptions } from "../api/api.js";
import { aesEcbPaddedSize } from "./aes-ecb.js";
import { uploadBufferToCdn } from "./cdn-upload.js";
import { logger } from "../util/logger.js";
import { getExtensionFromContentTypeOrUrl } from "../media/mime.js";
import { tempFileName } from "../util/random.js";
import { UploadMediaType } from "../api/types.js";
export type UploadedFileInfo = {
filekey: string;
/** 由 upload_param 上传后 CDN 返回的下载加密参数; fill into ImageItem.media.encrypt_query_param */
downloadEncryptedQueryParam: string;
/** AES-128-ECB key, hex-encoded; convert to base64 for CDNMedia.aes_key */
aeskey: string;
/** Plaintext file size in bytes */
fileSize: number;
/** Ciphertext file size in bytes (AES-128-ECB with PKCS7 padding); use for ImageItem.hd_size / mid_size */
fileSizeCiphertext: number;
};
/**
* Download a remote media URL (image, video, file) to a local temp file in destDir.
* Returns the local file path; extension is inferred from Content-Type / URL.
*/
export async function downloadRemoteImageToTemp(url: string, destDir: string): Promise<string> {
logger.debug(`downloadRemoteImageToTemp: fetching url=${url}`);
const res = await fetch(url);
if (!res.ok) {
const msg = `remote media download failed: ${res.status} ${res.statusText} url=${url}`;
logger.error(`downloadRemoteImageToTemp: ${msg}`);
throw new Error(msg);
}
const buf = Buffer.from(await res.arrayBuffer());
logger.debug(`downloadRemoteImageToTemp: downloaded ${buf.length} bytes`);
await fs.mkdir(destDir, { recursive: true });
const ext = getExtensionFromContentTypeOrUrl(res.headers.get("content-type"), url);
const name = tempFileName("weixin-remote", ext);
const filePath = path.join(destDir, name);
await fs.writeFile(filePath, buf);
logger.debug(`downloadRemoteImageToTemp: saved to ${filePath} ext=${ext}`);
return filePath;
}
/**
* Common upload pipeline: read file → hash → gen aeskey → getUploadUrl → uploadBufferToCdn → return info.
*/
async function uploadMediaToCdn(params: {
filePath: string;
toUserId: string;
opts: WeixinApiOptions;
cdnBaseUrl: string;
mediaType: (typeof UploadMediaType)[keyof typeof UploadMediaType];
label: string;
}): Promise<UploadedFileInfo> {
const { filePath, toUserId, opts, cdnBaseUrl, mediaType, label } = params;
const plaintext = await fs.readFile(filePath);
const rawsize = plaintext.length;
const rawfilemd5 = crypto.createHash("md5").update(plaintext).digest("hex");
const filesize = aesEcbPaddedSize(rawsize);
const filekey = crypto.randomBytes(16).toString("hex");
const aeskey = crypto.randomBytes(16);
logger.debug(
`${label}: file=${filePath} rawsize=${rawsize} filesize=${filesize} md5=${rawfilemd5} filekey=${filekey}`,
);
const uploadUrlResp = await getUploadUrl({
...opts,
filekey,
media_type: mediaType,
to_user_id: toUserId,
rawsize,
rawfilemd5,
filesize,
no_need_thumb: true,
aeskey: aeskey.toString("hex"),
});
const uploadParam = uploadUrlResp.upload_param;
if (!uploadParam) {
logger.error(
`${label}: getUploadUrl returned no upload_param, resp=${JSON.stringify(uploadUrlResp)}`,
);
throw new Error(`${label}: getUploadUrl returned no upload_param`);
}
const { downloadParam: downloadEncryptedQueryParam } = await uploadBufferToCdn({
buf: plaintext,
uploadParam,
filekey,
cdnBaseUrl,
aeskey,
label: `${label}[orig filekey=${filekey}]`,
});
return {
filekey,
downloadEncryptedQueryParam,
aeskey: aeskey.toString("hex"),
fileSize: rawsize,
fileSizeCiphertext: filesize,
};
}
/** Upload a local image file to the Weixin CDN with AES-128-ECB encryption. */
export async function uploadFileToWeixin(params: {
filePath: string;
toUserId: string;
opts: WeixinApiOptions;
cdnBaseUrl: string;
}): Promise<UploadedFileInfo> {
return uploadMediaToCdn({
...params,
mediaType: UploadMediaType.IMAGE,
label: "uploadFileToWeixin",
});
}
/** Upload a local video file to the Weixin CDN. */
export async function uploadVideoToWeixin(params: {
filePath: string;
toUserId: string;
opts: WeixinApiOptions;
cdnBaseUrl: string;
}): Promise<UploadedFileInfo> {
return uploadMediaToCdn({
...params,
mediaType: UploadMediaType.VIDEO,
label: "uploadVideoToWeixin",
});
}
/**
* Upload a local file attachment (non-image, non-video) to the Weixin CDN.
* Uses media_type=FILE; no thumbnail required.
*/
export async function uploadFileAttachmentToWeixin(params: {
filePath: string;
fileName: string;
toUserId: string;
opts: WeixinApiOptions;
cdnBaseUrl: string;
}): Promise<UploadedFileInfo> {
return uploadMediaToCdn({
...params,
mediaType: UploadMediaType.FILE,
label: "uploadFileAttachmentToWeixin",
});
}
@@ -0,0 +1,380 @@
import path from "node:path";
import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk";
import { normalizeAccountId } from "openclaw/plugin-sdk";
import {
registerWeixinAccountId,
loadWeixinAccount,
saveWeixinAccount,
listWeixinAccountIds,
resolveWeixinAccount,
triggerWeixinChannelReload,
DEFAULT_BASE_URL,
} from "./auth/accounts.js";
import type { ResolvedWeixinAccount } from "./auth/accounts.js";
import { assertSessionActive } from "./api/session-guard.js";
import { getContextToken } from "./messaging/inbound.js";
import { logger } from "./util/logger.js";
import {
DEFAULT_ILINK_BOT_TYPE,
startWeixinLoginWithQr,
waitForWeixinLogin,
} from "./auth/login-qr.js";
import type { WeixinQrStartResult, WeixinQrWaitResult } from "./auth/login-qr.js";
import { monitorWeixinProvider } from "./monitor/monitor.js";
import { sendWeixinMediaFile } from "./messaging/send-media.js";
import { sendMessageWeixin } from "./messaging/send.js";
import { downloadRemoteImageToTemp } from "./cdn/upload.js";
/** Returns true when mediaUrl refers to a local filesystem path (absolute or relative). */
function isLocalFilePath(mediaUrl: string): boolean {
// Treat anything without a URL scheme (no "://") as a local path.
return !mediaUrl.includes("://");
}
function isRemoteUrl(mediaUrl: string): boolean {
return mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://");
}
const MEDIA_OUTBOUND_TEMP_DIR = "/tmp/openclaw/weixin/media/outbound-temp";
/** Resolve any local path scheme to an absolute filesystem path. */
function resolveLocalPath(mediaUrl: string): string {
if (mediaUrl.startsWith("file://")) return new URL(mediaUrl).pathname;
// Resolve any relative path (./foo, ../foo, .openclaw/foo, foo/bar) against cwd
if (!path.isAbsolute(mediaUrl)) return path.resolve(mediaUrl);
return mediaUrl;
}
async function sendWeixinOutbound(params: {
cfg: OpenClawConfig;
to: string;
text: string;
accountId?: string | null;
contextToken?: string;
mediaUrl?: string;
}): Promise<{ channel: string; messageId: string }> {
const account = resolveWeixinAccount(params.cfg, params.accountId);
const aLog = logger.withAccount(account.accountId);
assertSessionActive(account.accountId);
if (!account.configured) {
aLog.error(`sendWeixinOutbound: account not configured`);
throw new Error("weixin not configured: please run `openclaw channels login --channel openclaw-weixin`");
}
if (!params.contextToken) {
aLog.error(`sendWeixinOutbound: contextToken missing, refusing to send to=${params.to}`);
throw new Error("sendWeixinOutbound: contextToken is required");
}
const result = await sendMessageWeixin({ to: params.to, text: params.text, opts: {
baseUrl: account.baseUrl,
token: account.token,
contextToken: params.contextToken,
}});
return { channel: "openclaw-weixin", messageId: result.messageId };
}
export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
id: "openclaw-weixin",
meta: {
id: "openclaw-weixin",
label: "openclaw-weixin",
selectionLabel: "openclaw-weixin (long-poll)",
docsPath: "/channels/openclaw-weixin",
docsLabel: "openclaw-weixin",
blurb: "getUpdates long-poll upstream, sendMessage downstream; token auth.",
order: 75,
},
configSchema: {
schema: {
type: "object",
additionalProperties: false,
properties: {},
},
},
capabilities: {
chatTypes: ["direct"],
media: true,
},
messaging: {
targetResolver: {
// Weixin user IDs always end with @im.wechat; treat as direct IDs, skip directory lookup.
looksLikeId: (raw) => raw.endsWith("@im.wechat"),
},
},
agentPrompt: {
messageToolHints: () => [
"To send an image or file to the current user, use the message tool with action='send' and set 'media' to a local file path or a remote URL. You do not need to specify 'to' — the current conversation recipient is used automatically.",
"When the user asks you to find an image from the web, use a web search or browser tool to find a suitable image URL, then send it using the message tool with 'media' set to that HTTPS image URL — do NOT download the image first.",
"IMPORTANT: When generating or saving a file to send, always use an absolute path (e.g. /tmp/photo.png), never a relative path like ./photo.png. Relative paths cannot be resolved and the file will not be delivered.",
"IMPORTANT: When creating a cron job (scheduled task) for the current Weixin user, you MUST set delivery.to to the user's Weixin ID (the xxx@im.wechat address from the current conversation). Without an explicit 'to', the cron delivery will fail with 'requires target'. Example: delivery: { mode: 'announce', channel: 'openclaw-weixin', to: '<current_user_id@im.wechat>' }.",
],
},
reload: { configPrefixes: ["channels.openclaw-weixin"] },
config: {
listAccountIds: (cfg) => listWeixinAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveWeixinAccount(cfg, accountId),
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
}),
},
outbound: {
deliveryMode: "direct",
textChunkLimit: 4000,
sendText: async (ctx) => {
const result = await sendWeixinOutbound({
cfg: ctx.cfg,
to: ctx.to,
text: ctx.text,
accountId: ctx.accountId,
contextToken: getContextToken(ctx.accountId!, ctx.to),
});
return result;
},
sendMedia: async (ctx) => {
const account = resolveWeixinAccount(ctx.cfg, ctx.accountId);
const aLog = logger.withAccount(account.accountId);
assertSessionActive(account.accountId);
if (!account.configured) {
aLog.error(`sendMedia: account not configured`);
throw new Error(
"weixin not configured: please run `openclaw channels login --channel openclaw-weixin`",
);
}
const mediaUrl = ctx.mediaUrl;
if (mediaUrl && (isLocalFilePath(mediaUrl) || isRemoteUrl(mediaUrl))) {
let filePath: string;
if (isLocalFilePath(mediaUrl)) {
filePath = resolveLocalPath(mediaUrl);
aLog.debug(`sendMedia: uploading local file ${filePath}`);
} else {
aLog.debug(`sendMedia: downloading remote mediaUrl=${mediaUrl.slice(0, 80)}...`);
filePath = await downloadRemoteImageToTemp(mediaUrl, MEDIA_OUTBOUND_TEMP_DIR);
aLog.debug(`sendMedia: remote image downloaded to ${filePath}`);
}
const contextToken = getContextToken(account.accountId, ctx.to);
const result = await sendWeixinMediaFile({
filePath,
to: ctx.to,
text: ctx.text ?? "",
opts: { baseUrl: account.baseUrl, token: account.token, contextToken },
cdnBaseUrl: account.cdnBaseUrl,
});
return { channel: "openclaw-weixin", messageId: result.messageId };
}
const result = await sendWeixinOutbound({
cfg: ctx.cfg,
to: ctx.to,
text: ctx.text ?? "",
accountId: ctx.accountId,
contextToken: getContextToken(ctx.accountId!, ctx.to),
});
return result;
},
},
status: {
defaultRuntime: {
accountId: "",
lastError: null,
lastInboundAt: null,
lastOutboundAt: null,
},
collectStatusIssues: () => [],
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
lastError: snapshot.lastError ?? null,
lastInboundAt: snapshot.lastInboundAt ?? null,
lastOutboundAt: snapshot.lastOutboundAt ?? null,
}),
buildAccountSnapshot: ({ account, runtime }) => ({
...runtime,
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
}),
},
auth: {
login: async ({ cfg, accountId, verbose, runtime }) => {
const account = resolveWeixinAccount(cfg, accountId);
const log = (msg: string) => {
runtime?.log?.(msg);
};
log(`正在启动微信扫码登录...`);
const startResult: WeixinQrStartResult = await startWeixinLoginWithQr({
accountId: account.accountId,
apiBaseUrl: account.baseUrl,
botType: DEFAULT_ILINK_BOT_TYPE,
verbose: Boolean(verbose),
});
if (!startResult.qrcodeUrl) {
logger.warn(
`auth.login: failed to get QR code accountId=${account.accountId} message=${startResult.message}`,
);
log(startResult.message);
throw new Error(startResult.message);
}
log(`\n使用微信扫描以下二维码,以完成连接:\n`);
try {
const qrcodeterminal = await import("qrcode-terminal");
await new Promise<void>((resolve) => {
qrcodeterminal.default.generate(startResult.qrcodeUrl!, { small: true }, (qr: string) => {
console.log(qr);
resolve();
});
});
} catch (err) {
logger.warn(
`auth.login: qrcode-terminal unavailable, falling back to URL err=${String(err)}`,
);
log(`二维码链接: ${startResult.qrcodeUrl}`);
}
const loginTimeoutMs = 480_000;
log(`\n等待连接结果...\n`);
const waitResult: WeixinQrWaitResult = await waitForWeixinLogin({
sessionKey: startResult.sessionKey,
apiBaseUrl: account.baseUrl,
timeoutMs: loginTimeoutMs,
verbose: Boolean(verbose),
botType: DEFAULT_ILINK_BOT_TYPE,
});
if (waitResult.connected && waitResult.botToken && waitResult.accountId) {
try {
// Normalize the raw ilink_bot_id (e.g. "hex@im.bot") to a filesystem-safe
// key (e.g. "hex-im-bot") so account files have no special chars.
const normalizedId = normalizeAccountId(waitResult.accountId);
saveWeixinAccount(normalizedId, {
token: waitResult.botToken,
baseUrl: waitResult.baseUrl,
userId: waitResult.userId,
});
registerWeixinAccountId(normalizedId);
void triggerWeixinChannelReload();
log(`\n✅ 与微信连接成功!`);
} catch (err) {
logger.error(
`auth.login: failed to save account data accountId=${waitResult.accountId} err=${String(err)}`,
);
log(`⚠️ 保存账号数据失败: ${String(err)}`);
}
} else {
logger.warn(
`auth.login: login did not complete accountId=${account.accountId} message=${waitResult.message}`,
);
// log(waitResult.message);
throw new Error(waitResult.message);
}
},
},
gateway: {
startAccount: async (ctx) => {
logger.debug(`startAccount entry`);
if (!ctx) {
logger.warn(`gateway.startAccount: called with undefined ctx, skipping`);
return;
}
const account = ctx.account;
const aLog = logger.withAccount(account.accountId);
aLog.debug(`about to call monitorWeixinProvider`);
aLog.info(`starting weixin webhook`);
ctx.setStatus?.({
accountId: account.accountId,
running: true,
lastStartAt: Date.now(),
lastEventAt: Date.now(),
});
if (!account.configured) {
aLog.error(`account not configured`);
ctx.log?.error?.(
`[${account.accountId}] weixin not logged in — run: openclaw channels login --channel openclaw-weixin`,
);
ctx.setStatus?.({ accountId: account.accountId, running: false });
throw new Error("weixin not configured: missing token");
}
ctx.log?.info?.(`[${account.accountId}] starting weixin provider (${DEFAULT_BASE_URL})`);
const logPath = aLog.getLogFilePath();
ctx.log?.info?.(`[${account.accountId}] weixin logs: ${logPath}`);
return monitorWeixinProvider({
baseUrl: account.baseUrl,
cdnBaseUrl: account.cdnBaseUrl,
token: account.token,
accountId: account.accountId,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
setStatus: ctx.setStatus,
});
},
loginWithQrStart: async ({ accountId, force, timeoutMs, verbose }) => {
// For re-login: use saved baseUrl from account data; fall back to default for new accounts.
const savedBaseUrl = accountId ? loadWeixinAccount(accountId)?.baseUrl?.trim() : "";
const result: WeixinQrStartResult = await startWeixinLoginWithQr({
accountId: accountId ?? undefined,
apiBaseUrl: savedBaseUrl || DEFAULT_BASE_URL,
botType: DEFAULT_ILINK_BOT_TYPE,
force,
timeoutMs,
verbose,
});
// Return sessionKey so the client can pass it back in loginWithQrWait.
return {
qrDataUrl: result.qrcodeUrl,
message: result.message,
sessionKey: result.sessionKey,
} as { qrDataUrl?: string; message: string };
},
loginWithQrWait: async (params) => {
// sessionKey is forwarded by the client after loginWithQrStart (runtime param extension).
const sessionKey = (params as { sessionKey?: string }).sessionKey || params.accountId || "";
const savedBaseUrl = params.accountId
? loadWeixinAccount(params.accountId)?.baseUrl?.trim()
: "";
const result: WeixinQrWaitResult = await waitForWeixinLogin({
sessionKey,
apiBaseUrl: savedBaseUrl || DEFAULT_BASE_URL,
timeoutMs: params.timeoutMs,
});
if (result.connected && result.botToken && result.accountId) {
try {
const normalizedId = normalizeAccountId(result.accountId);
saveWeixinAccount(normalizedId, {
token: result.botToken,
baseUrl: result.baseUrl,
userId: result.userId,
});
registerWeixinAccountId(normalizedId);
triggerWeixinChannelReload();
logger.info(`loginWithQrWait: saved account data for accountId=${normalizedId}`);
} catch (err) {
logger.error(`loginWithQrWait: failed to save account data err=${String(err)}`);
}
}
return {
connected: result.connected,
message: result.message,
accountId: result.accountId,
} as { connected: boolean; message: string };
},
},
};
@@ -0,0 +1,22 @@
import { z } from "zod";
import { CDN_BASE_URL, DEFAULT_BASE_URL } from "../auth/accounts.js";
// ---------------------------------------------------------------------------
// Zod config schema
// ---------------------------------------------------------------------------
const weixinAccountSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
baseUrl: z.string().default(DEFAULT_BASE_URL),
cdnBaseUrl: z.string().default(CDN_BASE_URL),
routeTag: z.number().optional(),
});
/** Top-level weixin config schema (token is stored in credentials file, not config). */
export const WeixinConfigSchema = weixinAccountSchema.extend({
accounts: z.record(z.string(), weixinAccountSchema).optional(),
/** Default URL for `openclaw openclaw-weixin logs-upload`. Set via `openclaw config set channels.openclaw-weixin.logUploadUrl <url>`. */
logUploadUrl: z.string().optional(),
});
@@ -0,0 +1,126 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
/** Minimal subset of commander's Command used by registerWeixinCli. */
type CliCommand = {
command(name: string): CliCommand;
description(str: string): CliCommand;
option(flags: string, description: string): CliCommand;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
action(fn: (...args: any[]) => void | Promise<void>): CliCommand;
};
function currentDayLogFileName(): string {
const now = new Date();
const offsetMs = -now.getTimezoneOffset() * 60_000;
const dateKey = new Date(now.getTime() + offsetMs).toISOString().slice(0, 10);
return `openclaw-${dateKey}.log`;
}
/**
* Parse --file argument: accepts a short 8-digit date (YYYYMMDD)
* like "20260316", a full filename like "openclaw-2026-03-16.log",
* or a legacy 10-digit hour timestamp "2026031614".
*/
function resolveLogFileName(file: string): string {
if (/^\d{8}$/.test(file)) {
const yyyy = file.slice(0, 4);
const mm = file.slice(4, 6);
const dd = file.slice(6, 8);
return `openclaw-${yyyy}-${mm}-${dd}.log`;
}
if (/^\d{10}$/.test(file)) {
const yyyy = file.slice(0, 4);
const mm = file.slice(4, 6);
const dd = file.slice(6, 8);
return `openclaw-${yyyy}-${mm}-${dd}.log`;
}
return file;
}
function mainLogDir(): string {
return path.join("/tmp", "openclaw");
}
function getConfiguredUploadUrl(config: OpenClawConfig): string | undefined {
const section = config.channels?.["openclaw-weixin"] as { logUploadUrl?: string } | undefined;
return section?.logUploadUrl;
}
/** Register the `openclaw openclaw-weixin logs-upload` CLI subcommand. */
export function registerWeixinCli(params: { program: CliCommand; config: OpenClawConfig }): void {
const { program, config } = params;
const root = program.command("openclaw-weixin").description("Weixin channel utilities");
root
.command("logs-upload")
.description("Upload a Weixin log file to a remote URL via HTTP POST")
.option("--url <url>", "Remote URL to POST the log file to (overrides config)")
.option(
"--file <file>",
"Log file to upload: full filename or 8-digit date YYYYMMDD (default: today)",
)
.action(async (options: { url?: string; file?: string }) => {
const uploadUrl = options.url ?? getConfiguredUploadUrl(config);
if (!uploadUrl) {
console.error(
`[weixin] No upload URL specified. Pass --url or set it with:\n openclaw config set channels.openclaw-weixin.logUploadUrl <url>`,
);
process.exit(1);
}
const logDir = mainLogDir();
const rawFile = options.file ?? currentDayLogFileName();
const fileName = resolveLogFileName(rawFile);
const filePath = path.isAbsolute(fileName) ? fileName : path.join(logDir, fileName);
let content: Buffer;
try {
content = await fs.readFile(filePath);
} catch (err) {
console.error(`[weixin] Failed to read log file: ${filePath}\n ${String(err)}`);
process.exit(1);
}
console.log(`[weixin] Uploading ${filePath} (${content.length} bytes) to ${uploadUrl} ...`);
const formData = new FormData();
formData.append("file", new Blob([new Uint8Array(content)], { type: "text/plain" }), fileName);
let res: Response;
try {
res = await fetch(uploadUrl, { method: "POST", body: formData });
} catch (err) {
console.error(`[weixin] Upload request failed: ${String(err)}`);
process.exit(1);
}
const responseBody = await res.text().catch(() => "");
if (!res.ok) {
console.error(
`[weixin] Upload failed: HTTP ${res.status} ${res.statusText}\n ${responseBody}`,
);
process.exit(1);
}
console.log(`[weixin] Upload succeeded (HTTP ${res.status})`);
const fileid = res.headers.get("fileid");
if (fileid) {
console.log(`fileid: ${fileid}`);
} else {
// fileid not found; dump all headers for diagnosis
const headers: Record<string, string> = {};
res.headers.forEach((value, key) => {
headers[key] = value;
});
console.log("headers:", JSON.stringify(headers, null, 2));
}
if (responseBody) {
console.log("body:", responseBody);
}
});
}
@@ -0,0 +1,141 @@
import type { WeixinInboundMediaOpts } from "../messaging/inbound.js";
import { logger } from "../util/logger.js";
import { getMimeFromFilename } from "./mime.js";
import {
downloadAndDecryptBuffer,
downloadPlainCdnBuffer,
} from "../cdn/pic-decrypt.js";
import { silkToWav } from "./silk-transcode.js";
import type { WeixinMessage } from "../api/types.js";
import { MessageItemType } from "../api/types.js";
const WEIXIN_MEDIA_MAX_BYTES = 100 * 1024 * 1024;
/** Persist a buffer via the framework's unified media store. */
type SaveMediaFn = (
buffer: Buffer,
contentType?: string,
subdir?: string,
maxBytes?: number,
originalFilename?: string,
) => Promise<{ path: string }>;
/**
* Download and decrypt media from a single MessageItem.
* Returns the populated WeixinInboundMediaOpts fields; empty object on unsupported type or failure.
*/
export async function downloadMediaFromItem(
item: WeixinMessage["item_list"] extends (infer T)[] | undefined ? T : never,
deps: {
cdnBaseUrl: string;
saveMedia: SaveMediaFn;
log: (msg: string) => void;
errLog: (msg: string) => void;
label: string;
},
): Promise<WeixinInboundMediaOpts> {
const { cdnBaseUrl, saveMedia, log, errLog, label } = deps;
const result: WeixinInboundMediaOpts = {};
if (item.type === MessageItemType.IMAGE) {
const img = item.image_item;
if (!img?.media?.encrypt_query_param) return result;
const aesKeyBase64 = img.aeskey
? Buffer.from(img.aeskey, "hex").toString("base64")
: img.media.aes_key;
logger.debug(
`${label} image: encrypt_query_param=${img.media.encrypt_query_param.slice(0, 40)}... hasAesKey=${Boolean(aesKeyBase64)} aeskeySource=${img.aeskey ? "image_item.aeskey" : "media.aes_key"}`,
);
try {
const buf = aesKeyBase64
? await downloadAndDecryptBuffer(
img.media.encrypt_query_param,
aesKeyBase64,
cdnBaseUrl,
`${label} image`,
)
: await downloadPlainCdnBuffer(
img.media.encrypt_query_param,
cdnBaseUrl,
`${label} image-plain`,
);
const saved = await saveMedia(buf, undefined, "inbound", WEIXIN_MEDIA_MAX_BYTES);
result.decryptedPicPath = saved.path;
logger.debug(`${label} image saved: ${saved.path}`);
} catch (err) {
logger.error(`${label} image download/decrypt failed: ${String(err)}`);
errLog(`weixin ${label} image download/decrypt failed: ${String(err)}`);
}
} else if (item.type === MessageItemType.VOICE) {
const voice = item.voice_item;
if (!voice?.media?.encrypt_query_param || !voice.media.aes_key) return result;
try {
const silkBuf = await downloadAndDecryptBuffer(
voice.media.encrypt_query_param,
voice.media.aes_key,
cdnBaseUrl,
`${label} voice`,
);
logger.debug(`${label} voice: decrypted ${silkBuf.length} bytes, attempting silk transcode`);
const wavBuf = await silkToWav(silkBuf);
if (wavBuf) {
const saved = await saveMedia(wavBuf, "audio/wav", "inbound", WEIXIN_MEDIA_MAX_BYTES);
result.decryptedVoicePath = saved.path;
result.voiceMediaType = "audio/wav";
logger.debug(`${label} voice: saved WAV to ${saved.path}`);
} else {
const saved = await saveMedia(silkBuf, "audio/silk", "inbound", WEIXIN_MEDIA_MAX_BYTES);
result.decryptedVoicePath = saved.path;
result.voiceMediaType = "audio/silk";
logger.debug(`${label} voice: silk transcode unavailable, saved raw SILK to ${saved.path}`);
}
} catch (err) {
logger.error(`${label} voice download/transcode failed: ${String(err)}`);
errLog(`weixin ${label} voice download/transcode failed: ${String(err)}`);
}
} else if (item.type === MessageItemType.FILE) {
const fileItem = item.file_item;
if (!fileItem?.media?.encrypt_query_param || !fileItem.media.aes_key) return result;
try {
const buf = await downloadAndDecryptBuffer(
fileItem.media.encrypt_query_param,
fileItem.media.aes_key,
cdnBaseUrl,
`${label} file`,
);
const mime = getMimeFromFilename(fileItem.file_name ?? "file.bin");
const saved = await saveMedia(
buf,
mime,
"inbound",
WEIXIN_MEDIA_MAX_BYTES,
fileItem.file_name ?? undefined,
);
result.decryptedFilePath = saved.path;
result.fileMediaType = mime;
logger.debug(`${label} file: saved to ${saved.path} mime=${mime}`);
} catch (err) {
logger.error(`${label} file download failed: ${String(err)}`);
errLog(`weixin ${label} file download failed: ${String(err)}`);
}
} else if (item.type === MessageItemType.VIDEO) {
const videoItem = item.video_item;
if (!videoItem?.media?.encrypt_query_param || !videoItem.media.aes_key) return result;
try {
const buf = await downloadAndDecryptBuffer(
videoItem.media.encrypt_query_param,
videoItem.media.aes_key,
cdnBaseUrl,
`${label} video`,
);
const saved = await saveMedia(buf, "video/mp4", "inbound", WEIXIN_MEDIA_MAX_BYTES);
result.decryptedVideoPath = saved.path;
logger.debug(`${label} video: saved to ${saved.path}`);
} catch (err) {
logger.error(`${label} video download failed: ${String(err)}`);
errLog(`weixin ${label} video download failed: ${String(err)}`);
}
}
return result;
}
@@ -0,0 +1,76 @@
import path from "node:path";
const EXTENSION_TO_MIME: Record<string, string> = {
".pdf": "application/pdf",
".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",
".txt": "text/plain",
".csv": "text/csv",
".zip": "application/zip",
".tar": "application/x-tar",
".gz": "application/gzip",
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
".wav": "audio/wav",
".mp4": "video/mp4",
".mov": "video/quicktime",
".webm": "video/webm",
".mkv": "video/x-matroska",
".avi": "video/x-msvideo",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
};
const MIME_TO_EXTENSION: Record<string, string> = {
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"image/bmp": ".bmp",
"video/mp4": ".mp4",
"video/quicktime": ".mov",
"video/webm": ".webm",
"video/x-matroska": ".mkv",
"video/x-msvideo": ".avi",
"audio/mpeg": ".mp3",
"audio/ogg": ".ogg",
"audio/wav": ".wav",
"application/pdf": ".pdf",
"application/zip": ".zip",
"application/x-tar": ".tar",
"application/gzip": ".gz",
"text/plain": ".txt",
"text/csv": ".csv",
};
/** Get MIME type from filename extension. Returns "application/octet-stream" for unknown extensions. */
export function getMimeFromFilename(filename: string): string {
const ext = path.extname(filename).toLowerCase();
return EXTENSION_TO_MIME[ext] ?? "application/octet-stream";
}
/** Get file extension from MIME type. Returns ".bin" for unknown types. */
export function getExtensionFromMime(mimeType: string): string {
const ct = mimeType.split(";")[0].trim().toLowerCase();
return MIME_TO_EXTENSION[ct] ?? ".bin";
}
/** Get file extension from Content-Type header or URL path. Returns ".bin" for unknown. */
export function getExtensionFromContentTypeOrUrl(contentType: string | null, url: string): string {
if (contentType) {
const ext = getExtensionFromMime(contentType);
if (ext !== ".bin") return ext;
}
const ext = path.extname(new URL(url).pathname).toLowerCase();
const knownExts = new Set(Object.keys(EXTENSION_TO_MIME));
return knownExts.has(ext) ? ext : ".bin";
}
@@ -0,0 +1,74 @@
import { logger } from "../util/logger.js";
/** Default sample rate for Weixin voice messages. */
const SILK_SAMPLE_RATE = 24_000;
/**
* Wrap raw pcm_s16le bytes in a WAV container.
* Mono channel, 16-bit signed little-endian.
*/
function pcmBytesToWav(pcm: Uint8Array, sampleRate: number): Buffer {
const pcmBytes = pcm.byteLength;
const totalSize = 44 + pcmBytes;
const buf = Buffer.allocUnsafe(totalSize);
let offset = 0;
buf.write("RIFF", offset);
offset += 4;
buf.writeUInt32LE(totalSize - 8, offset);
offset += 4;
buf.write("WAVE", offset);
offset += 4;
buf.write("fmt ", offset);
offset += 4;
buf.writeUInt32LE(16, offset);
offset += 4; // fmt chunk size
buf.writeUInt16LE(1, offset);
offset += 2; // PCM format
buf.writeUInt16LE(1, offset);
offset += 2; // mono
buf.writeUInt32LE(sampleRate, offset);
offset += 4;
buf.writeUInt32LE(sampleRate * 2, offset);
offset += 4; // byte rate (mono 16-bit)
buf.writeUInt16LE(2, offset);
offset += 2; // block align
buf.writeUInt16LE(16, offset);
offset += 2; // bits per sample
buf.write("data", offset);
offset += 4;
buf.writeUInt32LE(pcmBytes, offset);
offset += 4;
Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength).copy(buf, offset);
return buf;
}
/**
* Try to transcode a SILK audio buffer to WAV using silk-wasm.
* silk-wasm's decode() returns { data: Uint8Array (pcm_s16le), duration: number }.
*
* Returns a WAV Buffer on success, or null if silk-wasm is unavailable or decoding fails.
* Callers should fall back to passing the raw SILK file when null is returned.
*/
export async function silkToWav(silkBuf: Buffer): Promise<Buffer | null> {
try {
const { decode } = await import("silk-wasm");
logger.debug(`silkToWav: decoding ${silkBuf.length} bytes of SILK`);
const result = await decode(silkBuf, SILK_SAMPLE_RATE);
logger.debug(
`silkToWav: decoded duration=${result.duration}ms pcmBytes=${result.data.byteLength}`,
);
const wav = pcmBytesToWav(result.data, SILK_SAMPLE_RATE);
logger.debug(`silkToWav: WAV size=${wav.length}`);
return wav;
} catch (err) {
logger.warn(`silkToWav: transcode failed, will use raw silk err=${String(err)}`);
return null;
}
}
@@ -0,0 +1,69 @@
/**
* Per-bot debug mode toggle, persisted to disk so it survives gateway restarts.
*
* State file: `<stateDir>/openclaw-weixin/debug-mode.json`
* Format: `{ "accounts": { "<accountId>": true, ... } }`
*
* When enabled, processOneMessage appends a timing summary after each
* AI reply is delivered to the user.
*/
import fs from "node:fs";
import path from "node:path";
import { resolveStateDir } from "../storage/state-dir.js";
import { logger } from "../util/logger.js";
interface DebugModeState {
accounts: Record<string, boolean>;
}
function resolveDebugModePath(): string {
return path.join(resolveStateDir(), "openclaw-weixin", "debug-mode.json");
}
function loadState(): DebugModeState {
try {
const raw = fs.readFileSync(resolveDebugModePath(), "utf-8");
const parsed = JSON.parse(raw) as DebugModeState;
if (parsed && typeof parsed.accounts === "object") return parsed;
} catch {
// missing or corrupt — start fresh
}
return { accounts: {} };
}
function saveState(state: DebugModeState): void {
const filePath = resolveDebugModePath();
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(state, null, 2), "utf-8");
}
/** Toggle debug mode for a bot account. Returns the new state. */
export function toggleDebugMode(accountId: string): boolean {
const state = loadState();
const next = !state.accounts[accountId];
state.accounts[accountId] = next;
try {
saveState(state);
} catch (err) {
logger.error(`debug-mode: failed to persist state: ${String(err)}`);
}
return next;
}
/** Check whether debug mode is active for a bot account. */
export function isDebugMode(accountId: string): boolean {
return loadState().accounts[accountId] === true;
}
/**
* Reset internal state only for tests.
* @internal
*/
export function _resetForTest(): void {
try {
fs.unlinkSync(resolveDebugModePath());
} catch {
// ignore if not present
}
}
@@ -0,0 +1,31 @@
import { logger } from "../util/logger.js";
import { sendMessageWeixin } from "./send.js";
/**
* Send a plain-text error notice back to the user.
* Fire-and-forget: errors are logged but never thrown, so callers stay unaffected.
* No-op when contextToken is absent (we have no conversation reference to reply into).
*/
export async function sendWeixinErrorNotice(params: {
to: string;
contextToken: string | undefined;
message: string;
baseUrl: string;
token?: string;
errLog: (m: string) => void;
}): Promise<void> {
if (!params.contextToken) {
logger.warn(`sendWeixinErrorNotice: no contextToken for to=${params.to}, cannot notify user`);
return;
}
try {
await sendMessageWeixin({ to: params.to, text: params.message, opts: {
baseUrl: params.baseUrl,
token: params.token,
contextToken: params.contextToken,
}});
logger.debug(`sendWeixinErrorNotice: sent to=${params.to}`);
} catch (err) {
params.errLog(`[weixin] sendWeixinErrorNotice failed to=${params.to}: ${String(err)}`);
}
}
@@ -0,0 +1,171 @@
import { logger } from "../util/logger.js";
import { generateId } from "../util/random.js";
import type { WeixinMessage, MessageItem } from "../api/types.js";
import { MessageItemType } from "../api/types.js";
// ---------------------------------------------------------------------------
// Context token store (in-process cache: accountId+userId → contextToken)
// ---------------------------------------------------------------------------
/**
* contextToken is issued per-message by the Weixin getupdates API and must
* be echoed verbatim in every outbound send. It is not persisted: the monitor
* loop populates this map on each inbound message, and the outbound adapter
* reads it back when the agent sends a reply.
*/
const contextTokenStore = new Map<string, string>();
function contextTokenKey(accountId: string, userId: string): string {
return `${accountId}:${userId}`;
}
/** Store a context token for a given account+user pair. */
export function setContextToken(accountId: string, userId: string, token: string): void {
const k = contextTokenKey(accountId, userId);
logger.debug(`setContextToken: key=${k}`);
contextTokenStore.set(k, token);
}
/** Retrieve the cached context token for a given account+user pair. */
export function getContextToken(accountId: string, userId: string): string | undefined {
const k = contextTokenKey(accountId, userId);
const val = contextTokenStore.get(k);
logger.debug(
`getContextToken: key=${k} found=${val !== undefined} storeSize=${contextTokenStore.size}`,
);
return val;
}
// ---------------------------------------------------------------------------
// Message ID generation
// ---------------------------------------------------------------------------
function generateMessageSid(): string {
return generateId("openclaw-weixin");
}
/** Inbound context passed to the OpenClaw core pipeline (matches MsgContext shape). */
export type WeixinMsgContext = {
Body: string;
From: string;
To: string;
AccountId: string;
OriginatingChannel: "openclaw-weixin";
OriginatingTo: string;
MessageSid: string;
Timestamp?: number;
Provider: "openclaw-weixin";
ChatType: "direct";
/** Set by monitor after resolveAgentRoute so dispatchReplyFromConfig uses the correct session. */
SessionKey?: string;
context_token?: string;
MediaUrl?: string;
MediaPath?: string;
MediaType?: string;
/** Raw message body for framework command authorization. */
CommandBody?: string;
/** Whether the sender is authorized to execute slash commands. */
CommandAuthorized?: boolean;
};
/** Returns true if the message item is a media type (image, video, file, or voice). */
export function isMediaItem(item: MessageItem): boolean {
return (
item.type === MessageItemType.IMAGE ||
item.type === MessageItemType.VIDEO ||
item.type === MessageItemType.FILE ||
item.type === MessageItemType.VOICE
);
}
function bodyFromItemList(itemList?: MessageItem[]): string {
if (!itemList?.length) return "";
for (const item of itemList) {
if (item.type === MessageItemType.TEXT && item.text_item?.text != null) {
const text = String(item.text_item.text);
const ref = item.ref_msg;
if (!ref) return text;
// Quoted media is passed as MediaPath; only include the current text as body.
if (ref.message_item && isMediaItem(ref.message_item)) return text;
// Build quoted context from both title and message_item content.
const parts: string[] = [];
if (ref.title) parts.push(ref.title);
if (ref.message_item) {
const refBody = bodyFromItemList([ref.message_item]);
if (refBody) parts.push(refBody);
}
if (!parts.length) return text;
return `[引用: ${parts.join(" | ")}]\n${text}`;
}
// 语音转文字:如果语音消息有 text 字段,直接使用文字内容
if (item.type === MessageItemType.VOICE && item.voice_item?.text) {
return item.voice_item.text;
}
}
return "";
}
export type WeixinInboundMediaOpts = {
/** Local path to decrypted image file. */
decryptedPicPath?: string;
/** Local path to transcoded/raw voice file (.wav or .silk). */
decryptedVoicePath?: string;
/** MIME type for the voice file (e.g. "audio/wav" or "audio/silk"). */
voiceMediaType?: string;
/** Local path to decrypted file attachment. */
decryptedFilePath?: string;
/** MIME type for the file attachment (guessed from file_name). */
fileMediaType?: string;
/** Local path to decrypted video file. */
decryptedVideoPath?: string;
};
/**
* Convert a WeixinMessage from getUpdates to the inbound MsgContext for the core pipeline.
* Media: only pass MediaPath (local file, after CDN download + decrypt).
* We never pass MediaUrl the upstream CDN URL is encrypted/auth-only.
* Priority when multiple media types present: image > video > file > voice.
*/
export function weixinMessageToMsgContext(
msg: WeixinMessage,
accountId: string,
opts?: WeixinInboundMediaOpts,
): WeixinMsgContext {
const from_user_id = msg.from_user_id ?? "";
const ctx: WeixinMsgContext = {
Body: bodyFromItemList(msg.item_list),
From: from_user_id,
To: from_user_id,
AccountId: accountId,
OriginatingChannel: "openclaw-weixin",
OriginatingTo: from_user_id,
MessageSid: generateMessageSid(),
Timestamp: msg.create_time_ms,
Provider: "openclaw-weixin",
ChatType: "direct",
};
if (msg.context_token) {
ctx.context_token = msg.context_token;
}
if (opts?.decryptedPicPath) {
ctx.MediaPath = opts.decryptedPicPath;
ctx.MediaType = "image/*";
} else if (opts?.decryptedVideoPath) {
ctx.MediaPath = opts.decryptedVideoPath;
ctx.MediaType = "video/mp4";
} else if (opts?.decryptedFilePath) {
ctx.MediaPath = opts.decryptedFilePath;
ctx.MediaType = opts.fileMediaType ?? "application/octet-stream";
} else if (opts?.decryptedVoicePath) {
ctx.MediaPath = opts.decryptedVoicePath;
ctx.MediaType = opts.voiceMediaType ?? "audio/wav";
}
return ctx;
}
/** Extract the context_token from an inbound WeixinMsgContext. */
export function getContextTokenFromMsgContext(ctx: WeixinMsgContext): string | undefined {
return ctx.context_token;
}
@@ -0,0 +1,481 @@
import path from "node:path";
import {
createTypingCallbacks,
resolveSenderCommandAuthorizationWithRuntime,
resolveDirectDmAuthorizationOutcome,
resolvePreferredOpenClawTmpDir,
} from "openclaw/plugin-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk";
import { sendTyping } from "../api/api.js";
import type { WeixinMessage } from "../api/types.js";
import { MessageItemType, TypingStatus } from "../api/types.js";
import { loadWeixinAccount } from "../auth/accounts.js";
import { readFrameworkAllowFromList } from "../auth/pairing.js";
import { downloadRemoteImageToTemp } from "../cdn/upload.js";
import { downloadMediaFromItem } from "../media/media-download.js";
import { logger } from "../util/logger.js";
import { redactBody, redactToken } from "../util/redact.js";
import { isDebugMode } from "./debug-mode.js";
import { sendWeixinErrorNotice } from "./error-notice.js";
import {
setContextToken,
weixinMessageToMsgContext,
getContextTokenFromMsgContext,
isMediaItem,
} from "./inbound.js";
import type { WeixinInboundMediaOpts } from "./inbound.js";
import { sendWeixinMediaFile } from "./send-media.js";
import { markdownToPlainText, sendMessageWeixin } from "./send.js";
import { handleSlashCommand } from "./slash-commands.js";
const MEDIA_OUTBOUND_TEMP_DIR = path.join(resolvePreferredOpenClawTmpDir(), "weixin/media/outbound-temp");
/** Dependencies for processOneMessage, injected by the monitor loop. */
export type ProcessMessageDeps = {
accountId: string;
config: import("openclaw/plugin-sdk/core").OpenClawConfig;
channelRuntime: PluginRuntime["channel"];
baseUrl: string;
cdnBaseUrl: string;
token?: string;
typingTicket?: string;
log: (msg: string) => void;
errLog: (m: string) => void;
};
/** Extract text body from item_list (for slash command detection). */
function extractTextBody(itemList?: import("../api/types.js").MessageItem[]): string {
if (!itemList?.length) return "";
for (const item of itemList) {
if (item.type === MessageItemType.TEXT && item.text_item?.text != null) {
return String(item.text_item.text);
}
}
return "";
}
/**
* Process a single inbound message: route download media dispatch reply.
* Extracted from the monitor loop to keep monitoring and message handling separate.
*/
export async function processOneMessage(
full: WeixinMessage,
deps: ProcessMessageDeps,
): Promise<void> {
if (!deps?.channelRuntime) {
logger.error(
`processOneMessage: channelRuntime is undefined, skipping message from=${full.from_user_id}`,
);
deps.errLog("processOneMessage: channelRuntime is undefined, skip");
return;
}
const receivedAt = Date.now();
const debug = isDebugMode(deps.accountId);
const debugTrace: string[] = [];
const debugTs: Record<string, number> = { received: receivedAt };
const textBody = extractTextBody(full.item_list);
if (textBody.startsWith("/")) {
const slashResult = await handleSlashCommand(textBody, {
to: full.from_user_id ?? "",
contextToken: full.context_token,
baseUrl: deps.baseUrl,
token: deps.token,
accountId: deps.accountId,
log: deps.log,
errLog: deps.errLog,
}, receivedAt, full.create_time_ms);
if (slashResult.handled) {
logger.info(`[weixin] Slash command handled, skipping AI pipeline`);
return;
}
}
if (debug) {
const itemTypes = full.item_list?.map((i) => i.type).join(",") ?? "none";
debugTrace.push(
"── 收消息 ──",
`│ seq=${full.seq ?? "?"} msgId=${full.message_id ?? "?"} from=${full.from_user_id ?? "?"}`,
`│ body="${textBody.slice(0, 40)}${textBody.length > 40 ? "…" : ""}" (len=${textBody.length}) itemTypes=[${itemTypes}]`,
`│ sessionId=${full.session_id ?? "?"} contextToken=${full.context_token ? "present" : "none"}`,
);
}
const mediaOpts: WeixinInboundMediaOpts = {};
// Find the first downloadable media item (priority: IMAGE > VIDEO > FILE > VOICE).
// When none found in the main item_list, fall back to media referenced via a quoted message.
const mainMediaItem =
full.item_list?.find(
(i) => i.type === MessageItemType.IMAGE && i.image_item?.media?.encrypt_query_param,
) ??
full.item_list?.find(
(i) => i.type === MessageItemType.VIDEO && i.video_item?.media?.encrypt_query_param,
) ??
full.item_list?.find(
(i) => i.type === MessageItemType.FILE && i.file_item?.media?.encrypt_query_param,
) ??
full.item_list?.find(
(i) =>
i.type === MessageItemType.VOICE &&
i.voice_item?.media?.encrypt_query_param &&
!i.voice_item.text,
);
const refMediaItem = !mainMediaItem
? full.item_list?.find(
(i) =>
i.type === MessageItemType.TEXT &&
i.ref_msg?.message_item &&
isMediaItem(i.ref_msg.message_item!),
)?.ref_msg?.message_item
: undefined;
const mediaDownloadStart = Date.now();
const mediaItem = mainMediaItem ?? refMediaItem;
if (mediaItem) {
const label = refMediaItem ? "ref" : "inbound";
const downloaded = await downloadMediaFromItem(mediaItem, {
cdnBaseUrl: deps.cdnBaseUrl,
saveMedia: deps.channelRuntime.media.saveMediaBuffer,
log: deps.log,
errLog: deps.errLog,
label,
});
Object.assign(mediaOpts, downloaded);
}
const mediaDownloadMs = Date.now() - mediaDownloadStart;
if (debug) {
debugTrace.push(mediaItem
? `│ mediaDownload: type=${mediaItem.type} cost=${mediaDownloadMs}ms`
: "│ mediaDownload: none",
);
}
const ctx = weixinMessageToMsgContext(full, deps.accountId, mediaOpts);
// --- Framework command authorization ---
const rawBody = ctx.Body?.trim() ?? "";
ctx.CommandBody = rawBody;
const senderId = full.from_user_id ?? "";
const { senderAllowedForCommands, commandAuthorized } =
await resolveSenderCommandAuthorizationWithRuntime({
cfg: deps.config,
rawBody,
isGroup: false,
dmPolicy: "pairing",
configuredAllowFrom: [],
configuredGroupAllowFrom: [],
senderId,
isSenderAllowed: (id: string, list: string[]) => list.length === 0 || list.includes(id),
/** Pairing: framework credentials `*-allowFrom.json`, with account `userId` fallback for legacy installs. */
readAllowFromStore: async () => {
const fromStore = readFrameworkAllowFromList(deps.accountId);
if (fromStore.length > 0) return fromStore;
const uid = loadWeixinAccount(deps.accountId)?.userId?.trim();
return uid ? [uid] : [];
},
runtime: deps.channelRuntime.commands,
});
const directDmOutcome = resolveDirectDmAuthorizationOutcome({
isGroup: false,
dmPolicy: "pairing",
senderAllowedForCommands,
});
if (directDmOutcome === "disabled" || directDmOutcome === "unauthorized") {
logger.info(
`authorization: dropping message from=${senderId} outcome=${directDmOutcome}`,
);
return;
}
ctx.CommandAuthorized = commandAuthorized;
logger.debug(
`authorization: senderId=${senderId} commandAuthorized=${String(commandAuthorized)} senderAllowed=${String(senderAllowedForCommands)}`,
);
if (debug) {
debugTrace.push(
"── 鉴权 & 路由 ──",
`│ auth: cmdAuthorized=${String(commandAuthorized)} senderAllowed=${String(senderAllowedForCommands)}`,
);
}
const route = deps.channelRuntime.routing.resolveAgentRoute({
cfg: deps.config,
channel: "openclaw-weixin",
accountId: deps.accountId,
peer: { kind: "direct", id: ctx.To },
});
logger.debug(
`resolveAgentRoute: agentId=${route.agentId ?? "(none)"} sessionKey=${route.sessionKey ?? "(none)"} mainSessionKey=${route.mainSessionKey ?? "(none)"}`,
);
if (!route.agentId) {
logger.error(
`resolveAgentRoute: no agentId resolved for peer=${ctx.To} accountId=${deps.accountId} — message will not be dispatched`,
);
}
if (debug) {
debugTrace.push(
`│ route: agent=${route.agentId ?? "none"} session=${route.sessionKey ?? "none"}`,
);
debugTs.preDispatch = Date.now();
}
// Propagate the resolved session key into ctx so dispatchReplyFromConfig uses
// the correct session (matching the dmScope from config) instead of falling back
// to agent:main:main.
ctx.SessionKey = route.sessionKey;
const storePath = deps.channelRuntime.session.resolveStorePath(deps.config.session?.store, {
agentId: route.agentId,
});
const finalized = deps.channelRuntime.reply.finalizeInboundContext(
ctx as Parameters<typeof deps.channelRuntime.reply.finalizeInboundContext>[0],
);
logger.info(
`inbound: from=${finalized.From} to=${finalized.To} bodyLen=${(finalized.Body ?? "").length} hasMedia=${Boolean(finalized.MediaPath ?? finalized.MediaUrl)}`,
);
logger.debug(`inbound context: ${redactBody(JSON.stringify(finalized))}`);
await deps.channelRuntime.session.recordInboundSession({
storePath,
sessionKey: route.sessionKey,
ctx: finalized as Parameters<typeof deps.channelRuntime.session.recordInboundSession>[0]["ctx"],
updateLastRoute: {
sessionKey: route.mainSessionKey,
channel: "openclaw-weixin",
to: ctx.To,
accountId: deps.accountId,
},
onRecordError: (err) => deps.errLog(`recordInboundSession: ${String(err)}`),
});
logger.debug(
`recordInboundSession: done storePath=${storePath} sessionKey=${route.sessionKey ?? "(none)"}`,
);
const contextToken = getContextTokenFromMsgContext(ctx);
if (contextToken) {
setContextToken(deps.accountId, full.from_user_id ?? "", contextToken);
}
const humanDelay = deps.channelRuntime.reply.resolveHumanDelayConfig(deps.config, route.agentId);
const hasTypingTicket = Boolean(deps.typingTicket);
const typingCallbacks = createTypingCallbacks({
start: hasTypingTicket
? () =>
sendTyping({
baseUrl: deps.baseUrl,
token: deps.token,
body: {
ilink_user_id: ctx.To,
typing_ticket: deps.typingTicket!,
status: TypingStatus.TYPING,
},
})
: async () => {},
stop: hasTypingTicket
? () =>
sendTyping({
baseUrl: deps.baseUrl,
token: deps.token,
body: {
ilink_user_id: ctx.To,
typing_ticket: deps.typingTicket!,
status: TypingStatus.CANCEL,
},
})
: async () => {},
onStartError: (err) => deps.log(`[weixin] typing send error: ${String(err)}`),
onStopError: (err) => deps.log(`[weixin] typing cancel error: ${String(err)}`),
keepaliveIntervalMs: 5000,
});
/** Delivery records populated synchronously at deliver() entry, safe to read in finally. */
const debugDeliveries: Array<{ textLen: number; media: string; preview: string; ts: number }> = [];
const { dispatcher, replyOptions, markDispatchIdle } =
deps.channelRuntime.reply.createReplyDispatcherWithTyping({
humanDelay,
typingCallbacks,
deliver: async (payload) => {
const text = markdownToPlainText(payload.text ?? "");
const mediaUrl = payload.mediaUrl ?? payload.mediaUrls?.[0];
logger.debug(`outbound payload: ${redactBody(JSON.stringify(payload))}`);
logger.info(
`outbound: to=${ctx.To} contextToken=${redactToken(contextToken)} textLen=${text.length} mediaUrl=${mediaUrl ? "present" : "none"}`,
);
if (debug) {
debugDeliveries.push({
textLen: text.length,
media: mediaUrl ? "present" : "none",
preview: `${text.slice(0, 60)}${text.length > 60 ? "…" : ""}`,
ts: Date.now(),
});
}
try {
if (mediaUrl) {
let filePath: string;
if (!mediaUrl.includes("://") || mediaUrl.startsWith("file://")) {
// Local path: absolute, relative, or file:// URL
if (mediaUrl.startsWith("file://")) {
filePath = new URL(mediaUrl).pathname;
} else if (!path.isAbsolute(mediaUrl)) {
filePath = path.resolve(mediaUrl);
logger.debug(`outbound: resolved relative path ${mediaUrl} -> ${filePath}`);
} else {
filePath = mediaUrl;
}
logger.debug(`outbound: local file path resolved filePath=${filePath}`);
} else if (mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://")) {
logger.debug(`outbound: downloading remote mediaUrl=${mediaUrl.slice(0, 80)}...`);
filePath = await downloadRemoteImageToTemp(mediaUrl, MEDIA_OUTBOUND_TEMP_DIR);
logger.debug(`outbound: remote image downloaded to filePath=${filePath}`);
} else {
logger.warn(
`outbound: unrecognized mediaUrl scheme, sending text only mediaUrl=${mediaUrl.slice(0, 80)}`,
);
await sendMessageWeixin({ to: ctx.To, text, opts: {
baseUrl: deps.baseUrl,
token: deps.token,
contextToken,
}});
logger.info(`outbound: text sent to=${ctx.To}`);
return;
}
await sendWeixinMediaFile({
filePath,
to: ctx.To,
text,
opts: { baseUrl: deps.baseUrl, token: deps.token, contextToken },
cdnBaseUrl: deps.cdnBaseUrl,
});
logger.info(`outbound: media sent OK to=${ctx.To}`);
} else {
logger.debug(`outbound: sending text message to=${ctx.To}`);
await sendMessageWeixin({ to: ctx.To, text, opts: {
baseUrl: deps.baseUrl,
token: deps.token,
contextToken,
}});
logger.info(`outbound: text sent OK to=${ctx.To}`);
}
} catch (err) {
logger.error(
`outbound: FAILED to=${ctx.To} mediaUrl=${mediaUrl ?? "none"} err=${String(err)} stack=${(err as Error).stack ?? ""}`,
);
throw err;
}
},
onError: (err, info) => {
deps.errLog(`weixin reply ${info.kind}: ${String(err)}`);
const errMsg = err instanceof Error ? err.message : String(err);
let notice: string;
if (errMsg.includes("contextToken is required")) {
// No contextToken means we cannot send a notice either; just log.
logger.warn(`onError: contextToken missing, cannot send error notice to=${ctx.To}`);
return;
} else if (errMsg.includes("remote media download failed") || errMsg.includes("fetch")) {
notice = `⚠️ 媒体文件下载失败,请检查链接是否可访问。`;
} else if (
errMsg.includes("getUploadUrl") ||
errMsg.includes("CDN upload") ||
errMsg.includes("upload_param")
) {
notice = `⚠️ 媒体文件上传失败,请稍后重试。`;
} else {
notice = `⚠️ 消息发送失败:${errMsg}`;
}
void sendWeixinErrorNotice({
to: ctx.To,
contextToken,
message: notice,
baseUrl: deps.baseUrl,
token: deps.token,
errLog: deps.errLog,
});
},
});
logger.debug(`dispatchReplyFromConfig: starting agentId=${route.agentId ?? "(none)"}`);
try {
await deps.channelRuntime.reply.withReplyDispatcher({
dispatcher,
run: () =>
deps.channelRuntime.reply.dispatchReplyFromConfig({
ctx: finalized,
cfg: deps.config,
dispatcher,
replyOptions,
}),
});
logger.debug(`dispatchReplyFromConfig: done agentId=${route.agentId ?? "(none)"}`);
} catch (err) {
logger.error(
`dispatchReplyFromConfig: error agentId=${route.agentId ?? "(none)"} err=${String(err)}`,
);
throw err;
} finally {
markDispatchIdle();
logger.info(
`debug-check: accountId=${deps.accountId} debug=${String(debug)} hasContextToken=${Boolean(contextToken)} stateDir=${process.env.OPENCLAW_STATE_DIR ?? "(unset)"}`,
);
if (debug && contextToken) {
const dispatchDoneAt = Date.now();
const eventTs = full.create_time_ms ?? 0;
const platformDelay = eventTs > 0 ? `${receivedAt - eventTs}ms` : "N/A";
const inboundProcessMs = (debugTs.preDispatch ?? receivedAt) - receivedAt;
const aiMs = dispatchDoneAt - (debugTs.preDispatch ?? receivedAt);
const totalTime = eventTs > 0 ? `${dispatchDoneAt - eventTs}ms` : `${dispatchDoneAt - receivedAt}ms`;
if (debugDeliveries.length > 0) {
debugTrace.push("── 回复 ──");
for (const d of debugDeliveries) {
debugTrace.push(
`│ textLen=${d.textLen} media=${d.media}`,
`│ text="${d.preview}"`,
);
}
const firstTs = debugDeliveries[0].ts;
debugTrace.push(`│ deliver耗时: ${dispatchDoneAt - firstTs}ms`);
} else {
debugTrace.push("── 回复 ──", "│ (deliver未捕获)");
}
debugTrace.push(
"── 耗时 ──",
`├ 平台→插件: ${platformDelay}`,
`├ 入站处理(auth+route+media): ${inboundProcessMs}ms (mediaDownload: ${mediaDownloadMs}ms)`,
`├ AI生成+回复: ${aiMs}ms`,
`├ 总耗时: ${totalTime}`,
`└ eventTime: ${eventTs > 0 ? new Date(eventTs).toISOString() : "N/A"}`,
);
const timingText = `⏱ Debug 全链路\n${debugTrace.join("\n")}`;
logger.info(`debug-timing: sending to=${ctx.To}`);
try {
await sendMessageWeixin({
to: ctx.To,
text: timingText,
opts: { baseUrl: deps.baseUrl, token: deps.token, contextToken },
});
logger.info(`debug-timing: sent OK`);
} catch (debugErr) {
logger.error(`debug-timing: send FAILED err=${String(debugErr)}`);
}
}
}
}
@@ -0,0 +1,72 @@
import path from "node:path";
import type { WeixinApiOptions } from "../api/api.js";
import { logger } from "../util/logger.js";
import { getMimeFromFilename } from "../media/mime.js";
import { sendFileMessageWeixin, sendImageMessageWeixin, sendVideoMessageWeixin } from "./send.js";
import { uploadFileAttachmentToWeixin, uploadFileToWeixin, uploadVideoToWeixin } from "../cdn/upload.js";
/**
* Upload a local file and send it as a weixin message, routing by MIME type:
* video/* uploadVideoToWeixin + sendVideoMessageWeixin
* image/* uploadFileToWeixin + sendImageMessageWeixin
* else uploadFileAttachmentToWeixin + sendFileMessageWeixin
*
* Used by both the auto-reply deliver path (monitor.ts) and the outbound
* sendMedia path (channel.ts) so they stay in sync.
*/
export async function sendWeixinMediaFile(params: {
filePath: string;
to: string;
text: string;
opts: WeixinApiOptions & { contextToken?: string };
cdnBaseUrl: string;
}): Promise<{ messageId: string }> {
const { filePath, to, text, opts, cdnBaseUrl } = params;
const mime = getMimeFromFilename(filePath);
const uploadOpts: WeixinApiOptions = { baseUrl: opts.baseUrl, token: opts.token };
if (mime.startsWith("video/")) {
logger.info(`[weixin] sendWeixinMediaFile: uploading video filePath=${filePath} to=${to}`);
const uploaded = await uploadVideoToWeixin({
filePath,
toUserId: to,
opts: uploadOpts,
cdnBaseUrl,
});
logger.info(
`[weixin] sendWeixinMediaFile: video upload done filekey=${uploaded.filekey} size=${uploaded.fileSize}`,
);
return sendVideoMessageWeixin({ to, text, uploaded, opts });
}
if (mime.startsWith("image/")) {
logger.info(`[weixin] sendWeixinMediaFile: uploading image filePath=${filePath} to=${to}`);
const uploaded = await uploadFileToWeixin({
filePath,
toUserId: to,
opts: uploadOpts,
cdnBaseUrl,
});
logger.info(
`[weixin] sendWeixinMediaFile: image upload done filekey=${uploaded.filekey} size=${uploaded.fileSize}`,
);
return sendImageMessageWeixin({ to, text, uploaded, opts });
}
// File attachment: pdf, doc, zip, etc.
const fileName = path.basename(filePath);
logger.info(
`[weixin] sendWeixinMediaFile: uploading file attachment filePath=${filePath} name=${fileName} to=${to}`,
);
const uploaded = await uploadFileAttachmentToWeixin({
filePath,
fileName,
toUserId: to,
opts: uploadOpts,
cdnBaseUrl,
});
logger.info(
`[weixin] sendWeixinMediaFile: file upload done filekey=${uploaded.filekey} size=${uploaded.fileSize}`,
);
return sendFileMessageWeixin({ to, text, fileName, uploaded, opts });
}
@@ -0,0 +1,267 @@
import type { ReplyPayload } from "openclaw/plugin-sdk";
import { stripMarkdown } from "openclaw/plugin-sdk";
import { sendMessage as sendMessageApi } from "../api/api.js";
import type { WeixinApiOptions } from "../api/api.js";
import { logger } from "../util/logger.js";
import { generateId } from "../util/random.js";
import type { MessageItem, SendMessageReq } from "../api/types.js";
import { MessageItemType, MessageState, MessageType } from "../api/types.js";
import type { UploadedFileInfo } from "../cdn/upload.js";
function generateClientId(): string {
return generateId("openclaw-weixin");
}
/**
* Convert markdown-formatted model reply to plain text for Weixin delivery.
* Preserves newlines; strips markdown syntax.
*/
export function markdownToPlainText(text: string): string {
let result = text;
// Code blocks: strip fences, keep code content
result = result.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_, code: string) => code.trim());
// Images: remove entirely
result = result.replace(/!\[[^\]]*\]\([^)]*\)/g, "");
// Links: keep display text only
result = result.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
// Tables: remove separator rows, then strip leading/trailing pipes and convert inner pipes to spaces
result = result.replace(/^\|[\s:|-]+\|$/gm, "");
result = result.replace(/^\|(.+)\|$/gm, (_, inner: string) =>
inner.split("|").map((cell) => cell.trim()).join(" "),
);
result = stripMarkdown(result);
return result;
}
/** Build a SendMessageReq containing a single text message. */
function buildTextMessageReq(params: {
to: string;
text: string;
contextToken?: string;
clientId: string;
}): SendMessageReq {
const { to, text, contextToken, clientId } = params;
const item_list: MessageItem[] = text
? [{ type: MessageItemType.TEXT, text_item: { text } }]
: [];
return {
msg: {
from_user_id: "",
to_user_id: to,
client_id: clientId,
message_type: MessageType.BOT,
message_state: MessageState.FINISH,
item_list: item_list.length ? item_list : undefined,
context_token: contextToken ?? undefined,
},
};
}
/** Build a SendMessageReq from a reply payload (text only; image send uses sendImageMessageWeixin). */
function buildSendMessageReq(params: {
to: string;
contextToken?: string;
payload: ReplyPayload;
clientId: string;
}): SendMessageReq {
const { to, contextToken, payload, clientId } = params;
return buildTextMessageReq({
to,
text: payload.text ?? "",
contextToken,
clientId,
});
}
/**
* Send a plain text message downstream.
* contextToken is required for all reply sends; missing it breaks conversation association.
*/
export async function sendMessageWeixin(params: {
to: string;
text: string;
opts: WeixinApiOptions & { contextToken?: string };
}): Promise<{ messageId: string }> {
const { to, text, opts } = params;
if (!opts.contextToken) {
logger.error(`sendMessageWeixin: contextToken missing, refusing to send to=${to}`);
throw new Error("sendMessageWeixin: contextToken is required");
}
const clientId = generateClientId();
const req = buildSendMessageReq({
to,
contextToken: opts.contextToken,
payload: { text },
clientId,
});
try {
await sendMessageApi({
baseUrl: opts.baseUrl,
token: opts.token,
timeoutMs: opts.timeoutMs,
body: req,
});
} catch (err) {
logger.error(`sendMessageWeixin: failed to=${to} clientId=${clientId} err=${String(err)}`);
throw err;
}
return { messageId: clientId };
}
/**
* Send one or more MessageItems (optionally preceded by a text caption) downstream.
* Each item is sent as its own request so that item_list always has exactly one entry.
*/
async function sendMediaItems(params: {
to: string;
text: string;
mediaItem: MessageItem;
opts: WeixinApiOptions & { contextToken?: string };
label: string;
}): Promise<{ messageId: string }> {
const { to, text, mediaItem, opts, label } = params;
const items: MessageItem[] = [];
if (text) {
items.push({ type: MessageItemType.TEXT, text_item: { text } });
}
items.push(mediaItem);
let lastClientId = "";
for (const item of items) {
lastClientId = generateClientId();
const req: SendMessageReq = {
msg: {
from_user_id: "",
to_user_id: to,
client_id: lastClientId,
message_type: MessageType.BOT,
message_state: MessageState.FINISH,
item_list: [item],
context_token: opts.contextToken ?? undefined,
},
};
try {
await sendMessageApi({
baseUrl: opts.baseUrl,
token: opts.token,
timeoutMs: opts.timeoutMs,
body: req,
});
} catch (err) {
logger.error(
`${label}: failed to=${to} clientId=${lastClientId} err=${String(err)}`,
);
throw err;
}
}
logger.debug(`${label}: success to=${to} clientId=${lastClientId}`);
return { messageId: lastClientId };
}
/**
* Send an image message downstream using a previously uploaded file.
* Optionally include a text caption as a separate TEXT item before the image.
*
* ImageItem fields:
* - media.encrypt_query_param: CDN download param
* - media.aes_key: AES key, base64-encoded
* - mid_size: original ciphertext file size
*/
export async function sendImageMessageWeixin(params: {
to: string;
text: string;
uploaded: UploadedFileInfo;
opts: WeixinApiOptions & { contextToken?: string };
}): Promise<{ messageId: string }> {
const { to, text, uploaded, opts } = params;
if (!opts.contextToken) {
logger.error(`sendImageMessageWeixin: contextToken missing, refusing to send to=${to}`);
throw new Error("sendImageMessageWeixin: contextToken is required");
}
logger.debug(
`sendImageMessageWeixin: to=${to} filekey=${uploaded.filekey} fileSize=${uploaded.fileSize} aeskey=present`,
);
const imageItem: MessageItem = {
type: MessageItemType.IMAGE,
image_item: {
media: {
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
encrypt_type: 1,
},
mid_size: uploaded.fileSizeCiphertext,
},
};
return sendMediaItems({ to, text, mediaItem: imageItem, opts, label: "sendImageMessageWeixin" });
}
/**
* Send a video message downstream using a previously uploaded file.
* VideoItem: media (CDN ref), video_size (ciphertext bytes).
* Includes an optional text caption sent as a separate TEXT item first.
*/
export async function sendVideoMessageWeixin(params: {
to: string;
text: string;
uploaded: UploadedFileInfo;
opts: WeixinApiOptions & { contextToken?: string };
}): Promise<{ messageId: string }> {
const { to, text, uploaded, opts } = params;
if (!opts.contextToken) {
logger.error(`sendVideoMessageWeixin: contextToken missing, refusing to send to=${to}`);
throw new Error("sendVideoMessageWeixin: contextToken is required");
}
const videoItem: MessageItem = {
type: MessageItemType.VIDEO,
video_item: {
media: {
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
encrypt_type: 1,
},
video_size: uploaded.fileSizeCiphertext,
},
};
return sendMediaItems({ to, text, mediaItem: videoItem, opts, label: "sendVideoMessageWeixin" });
}
/**
* Send a file attachment downstream using a previously uploaded file.
* FileItem: media (CDN ref), file_name, len (plaintext bytes as string).
* Includes an optional text caption sent as a separate TEXT item first.
*/
export async function sendFileMessageWeixin(params: {
to: string;
text: string;
fileName: string;
uploaded: UploadedFileInfo;
opts: WeixinApiOptions & { contextToken?: string };
}): Promise<{ messageId: string }> {
const { to, text, fileName, uploaded, opts } = params;
if (!opts.contextToken) {
logger.error(`sendFileMessageWeixin: contextToken missing, refusing to send to=${to}`);
throw new Error("sendFileMessageWeixin: contextToken is required");
}
const fileItem: MessageItem = {
type: MessageItemType.FILE,
file_item: {
media: {
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
encrypt_type: 1,
},
file_name: fileName,
len: String(uploaded.fileSize),
},
};
return sendMediaItems({ to, text, mediaItem: fileItem, opts, label: "sendFileMessageWeixin" });
}
@@ -0,0 +1,110 @@
/**
* Weixin
*
*
* - /echo <message> AI
* - /toggle-debug debug AI
*/
import type { WeixinApiOptions } from "../api/api.js";
import { logger } from "../util/logger.js";
import { toggleDebugMode, isDebugMode } from "./debug-mode.js";
import { sendMessageWeixin } from "./send.js";
export interface SlashCommandResult {
/** 是否是斜杠指令(true 表示已处理,不需要继续走 AI) */
handled: boolean;
}
export interface SlashCommandContext {
to: string;
contextToken?: string;
baseUrl: string;
token?: string;
accountId: string;
log: (msg: string) => void;
errLog: (msg: string) => void;
}
/** 发送回复消息 */
async function sendReply(ctx: SlashCommandContext, text: string): Promise<void> {
const opts: WeixinApiOptions & { contextToken?: string } = {
baseUrl: ctx.baseUrl,
token: ctx.token,
contextToken: ctx.contextToken,
};
await sendMessageWeixin({ to: ctx.to, text, opts });
}
/** 处理 /echo 指令 */
async function handleEcho(
ctx: SlashCommandContext,
args: string,
receivedAt: number,
eventTimestamp?: number,
): Promise<void> {
const message = args.trim();
if (message) {
await sendReply(ctx, message);
}
const eventTs = eventTimestamp ?? 0;
const platformDelay = eventTs > 0 ? `${receivedAt - eventTs}ms` : "N/A";
const timing = [
"⏱ 通道耗时",
`├ 事件时间: ${eventTs > 0 ? new Date(eventTs).toISOString() : "N/A"}`,
`├ 平台→插件: ${platformDelay}`,
`└ 插件处理: ${Date.now() - receivedAt}ms`,
].join("\n");
await sendReply(ctx, timing);
}
/**
*
*
* @returns handled=true AI
*/
export async function handleSlashCommand(
content: string,
ctx: SlashCommandContext,
receivedAt: number,
eventTimestamp?: number,
): Promise<SlashCommandResult> {
const trimmed = content.trim();
if (!trimmed.startsWith("/")) {
return { handled: false };
}
const spaceIdx = trimmed.indexOf(" ");
const command = spaceIdx === -1 ? trimmed.toLowerCase() : trimmed.slice(0, spaceIdx).toLowerCase();
const args = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1);
logger.info(`[weixin] Slash command: ${command}, args: ${args.slice(0, 50)}`);
try {
switch (command) {
case "/echo":
await handleEcho(ctx, args, receivedAt, eventTimestamp);
return { handled: true };
case "/toggle-debug": {
const enabled = toggleDebugMode(ctx.accountId);
await sendReply(
ctx,
enabled
? "Debug 模式已开启"
: "Debug 模式已关闭",
);
return { handled: true };
}
default:
return { handled: false };
}
} catch (err) {
logger.error(`[weixin] Slash command error: ${String(err)}`);
try {
await sendReply(ctx, `❌ 指令执行失败: ${String(err).slice(0, 200)}`);
} catch {
// 发送错误消息也失败了,只能记日志
}
return { handled: true };
}
}
@@ -0,0 +1,221 @@
import type { ChannelAccountSnapshot, PluginRuntime } from "openclaw/plugin-sdk";
import { getUpdates } from "../api/api.js";
import { WeixinConfigManager } from "../api/config-cache.js";
import { SESSION_EXPIRED_ERRCODE, pauseSession, getRemainingPauseMs } from "../api/session-guard.js";
import { processOneMessage } from "../messaging/process-message.js";
import { getWeixinRuntime, waitForWeixinRuntime } from "../runtime.js";
import { getSyncBufFilePath, loadGetUpdatesBuf, saveGetUpdatesBuf } from "../storage/sync-buf.js";
import { logger } from "../util/logger.js";
import type { Logger } from "../util/logger.js";
import { redactBody } from "../util/redact.js";
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
const MAX_CONSECUTIVE_FAILURES = 3;
const BACKOFF_DELAY_MS = 30_000;
const RETRY_DELAY_MS = 2_000;
export type MonitorWeixinOpts = {
baseUrl: string;
cdnBaseUrl: string;
token?: string;
accountId: string;
/** When non-empty, only messages whose from_user_id is in this list are processed. */
allowFrom?: string[];
config: import("openclaw/plugin-sdk/core").OpenClawConfig;
runtime?: { log?: (msg: string) => void; error?: (msg: string) => void };
abortSignal?: AbortSignal;
longPollTimeoutMs?: number;
/** Gateway status callback — called on each successful poll and inbound message. */
setStatus?: (next: ChannelAccountSnapshot) => void;
};
/**
* Long-poll loop: getUpdates -> normalize -> recordInboundSession -> dispatchReplyFromConfig.
* Runs until abort.
*/
export async function monitorWeixinProvider(opts: MonitorWeixinOpts): Promise<void> {
const {
baseUrl,
cdnBaseUrl,
token,
accountId,
config,
abortSignal,
longPollTimeoutMs,
setStatus,
} = opts;
const log = opts.runtime?.log ?? (() => {});
const errLog = opts.runtime?.error ?? ((m: string) => log(m));
const aLog: Logger = logger.withAccount(accountId);
aLog.info(`waiting for Weixin runtime...`);
let channelRuntime: PluginRuntime["channel"];
try {
const pluginRuntime = await waitForWeixinRuntime();
channelRuntime = pluginRuntime.channel;
aLog.info(`Weixin runtime acquired, channelRuntime type: ${typeof channelRuntime}`);
} catch (err) {
aLog.error(`waitForWeixinRuntime() failed: ${String(err)}`);
throw err;
}
log(`weixin monitor started (${baseUrl}, account=${accountId})`);
aLog.info(
`Monitor started: baseUrl=${baseUrl} timeoutMs=${longPollTimeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS}`,
);
const syncFilePath = getSyncBufFilePath(accountId);
aLog.debug(`syncFilePath: ${syncFilePath}`);
const previousGetUpdatesBuf = loadGetUpdatesBuf(syncFilePath);
let getUpdatesBuf = previousGetUpdatesBuf ?? "";
if (previousGetUpdatesBuf) {
log(`[weixin] resuming from previous sync buf (${getUpdatesBuf.length} bytes)`);
aLog.debug(`Using previous get_updates_buf (${getUpdatesBuf.length} bytes)`);
} else {
log(`[weixin] no previous sync buf, starting fresh`);
aLog.info(`No previous get_updates_buf found, starting fresh`);
}
const configManager = new WeixinConfigManager({ baseUrl, token }, log);
let nextTimeoutMs = longPollTimeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
let consecutiveFailures = 0;
while (!abortSignal?.aborted) {
try {
aLog.debug(
`getUpdates: get_updates_buf=${getUpdatesBuf.substring(0, 50)}..., timeoutMs=${nextTimeoutMs}`,
);
const resp = await getUpdates({
baseUrl,
token,
get_updates_buf: getUpdatesBuf,
timeoutMs: nextTimeoutMs,
});
aLog.debug(
`getUpdates response: ret=${resp.ret}, msgs=${resp.msgs?.length ?? 0}, get_updates_buf_length=${resp.get_updates_buf?.length ?? 0}`,
);
if (resp.longpolling_timeout_ms != null && resp.longpolling_timeout_ms > 0) {
nextTimeoutMs = resp.longpolling_timeout_ms;
aLog.debug(`Updated next poll timeout: ${nextTimeoutMs}ms`);
}
const isApiError =
(resp.ret !== undefined && resp.ret !== 0) ||
(resp.errcode !== undefined && resp.errcode !== 0);
if (isApiError) {
const isSessionExpired =
resp.errcode === SESSION_EXPIRED_ERRCODE || resp.ret === SESSION_EXPIRED_ERRCODE;
if (isSessionExpired) {
pauseSession(accountId);
const pauseMs = getRemainingPauseMs(accountId);
errLog(
`weixin getUpdates: session expired (errcode ${SESSION_EXPIRED_ERRCODE}), pausing bot for ${Math.ceil(pauseMs / 60_000)} min`,
);
aLog.error(
`getUpdates: session expired (errcode=${resp.errcode} ret=${resp.ret}), pausing all requests for ${Math.ceil(pauseMs / 60_000)} min`,
);
consecutiveFailures = 0;
await sleep(pauseMs, abortSignal);
continue;
}
consecutiveFailures += 1;
errLog(
`weixin getUpdates failed: ret=${resp.ret} errcode=${resp.errcode} errmsg=${resp.errmsg ?? ""} (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES})`,
);
aLog.error(
`getUpdates failed: ret=${resp.ret} errcode=${resp.errcode} errmsg=${resp.errmsg} response=${redactBody(JSON.stringify(resp))}`,
);
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
errLog(
`weixin getUpdates: ${MAX_CONSECUTIVE_FAILURES} consecutive failures, backing off 30s`,
);
aLog.error(
`getUpdates: ${MAX_CONSECUTIVE_FAILURES} consecutive failures, backing off 30s`,
);
consecutiveFailures = 0;
await sleep(BACKOFF_DELAY_MS, abortSignal);
} else {
await sleep(RETRY_DELAY_MS, abortSignal);
}
continue;
}
consecutiveFailures = 0;
setStatus?.({ accountId, lastEventAt: Date.now() });
if (resp.get_updates_buf != null && resp.get_updates_buf !== "") {
saveGetUpdatesBuf(syncFilePath, resp.get_updates_buf);
getUpdatesBuf = resp.get_updates_buf;
aLog.debug(`Saved new get_updates_buf (${getUpdatesBuf.length} bytes)`);
}
const list = resp.msgs ?? [];
for (const full of list) {
aLog.info(
`inbound message: from=${full.from_user_id} types=${full.item_list?.map((i) => i.type).join(",") ?? "none"}`,
);
const now = Date.now();
setStatus?.({ accountId, lastEventAt: now, lastInboundAt: now });
// allowFrom filtering is delegated to processOneMessage via the framework
// authorization pipeline (resolveSenderCommandAuthorizationWithRuntime).
const fromUserId = full.from_user_id ?? "";
const cachedConfig = await configManager.getForUser(fromUserId, full.context_token);
await processOneMessage(full, {
accountId,
config,
channelRuntime,
baseUrl,
cdnBaseUrl,
token,
typingTicket: cachedConfig.typingTicket,
log: opts.runtime?.log ?? (() => {}),
errLog,
});
}
} catch (err) {
if (abortSignal?.aborted) {
aLog.info(`Monitor stopped (aborted)`);
return;
}
consecutiveFailures += 1;
errLog(
`weixin getUpdates error (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}): ${String(err)}`,
);
aLog.error(`getUpdates error: ${String(err)}, stack=${(err as Error).stack}`);
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
errLog(
`weixin getUpdates: ${MAX_CONSECUTIVE_FAILURES} consecutive failures, backing off 30s`,
);
aLog.error(
`getUpdates: ${MAX_CONSECUTIVE_FAILURES} consecutive failures, backing off 30s`,
);
consecutiveFailures = 0;
await sleep(30_000, abortSignal);
} else {
await sleep(2000, abortSignal);
}
}
}
aLog.info(`Monitor ended`);
}
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const t = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(t);
reject(new Error("aborted"));
},
{ once: true },
);
});
}
@@ -0,0 +1,70 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import { logger } from "./util/logger.js";
let pluginRuntime: PluginRuntime | null = null;
export type PluginChannelRuntime = PluginRuntime["channel"];
/**
* Sets the global Weixin runtime (called from plugin register).
*/
export function setWeixinRuntime(next: PluginRuntime): void {
pluginRuntime = next;
logger.info(`[runtime] setWeixinRuntime called, runtime set successfully`);
}
/**
* Gets the global Weixin runtime (throws if not initialized).
*/
export function getWeixinRuntime(): PluginRuntime {
if (!pluginRuntime) {
throw new Error("Weixin runtime not initialized");
}
return pluginRuntime;
}
const WAIT_INTERVAL_MS = 100;
const DEFAULT_TIMEOUT_MS = 10_000;
/**
* Waits for the Weixin runtime to be initialized (async polling).
*/
export async function waitForWeixinRuntime(
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<PluginRuntime> {
const start = Date.now();
while (!pluginRuntime) {
if (Date.now() - start > timeoutMs) {
throw new Error("Weixin runtime initialization timeout");
}
await new Promise((resolve) => setTimeout(resolve, WAIT_INTERVAL_MS));
}
return pluginRuntime;
}
/**
* Resolves `PluginRuntime["channel"]` for the long-poll monitor.
*
* Prefer the gateway-injected `channelRuntime` on `ChannelGatewayContext` when present (avoids
* races with the module-global from `register()`). Fall back to the global set by `setWeixinRuntime()`,
* then to a short wait for legacy hosts.
*/
export async function resolveWeixinChannelRuntime(params: {
channelRuntime?: PluginChannelRuntime;
waitTimeoutMs?: number;
}): Promise<PluginChannelRuntime> {
if (params.channelRuntime) {
logger.debug("[runtime] channelRuntime from gateway context");
return params.channelRuntime;
}
if (pluginRuntime) {
logger.debug("[runtime] channelRuntime from register() global");
return pluginRuntime.channel;
}
logger.warn(
"[runtime] no channelRuntime on ctx and no global runtime yet; waiting for register()",
);
const pr = await waitForWeixinRuntime(params.waitTimeoutMs ?? DEFAULT_TIMEOUT_MS);
return pr.channel;
}
@@ -0,0 +1,11 @@
import os from "node:os";
import path from "node:path";
/** Resolve the OpenClaw state directory (mirrors core logic in src/infra). */
export function resolveStateDir(): string {
return (
process.env.OPENCLAW_STATE_DIR?.trim() ||
process.env.CLAWDBOT_STATE_DIR?.trim() ||
path.join(os.homedir(), ".openclaw")
);
}
@@ -0,0 +1,81 @@
import fs from "node:fs";
import path from "node:path";
import { deriveRawAccountId } from "../auth/accounts.js";
import { resolveStateDir } from "./state-dir.js";
function resolveAccountsDir(): string {
return path.join(resolveStateDir(), "openclaw-weixin", "accounts");
}
/**
* Path to the persistent get_updates_buf file for an account.
* Stored alongside account data: ~/.openclaw/openclaw-weixin/accounts/{accountId}.sync.json
*/
export function getSyncBufFilePath(accountId: string): string {
return path.join(resolveAccountsDir(), `${accountId}.sync.json`);
}
/** Legacy single-account syncbuf (pre multi-account): `.openclaw-weixin-sync/default.json`. */
function getLegacySyncBufDefaultJsonPath(): string {
return path.join(
resolveStateDir(),
"agents",
"default",
"sessions",
".openclaw-weixin-sync",
"default.json",
);
}
export type SyncBufData = {
get_updates_buf: string;
};
function readSyncBufFile(filePath: string): string | undefined {
try {
const raw = fs.readFileSync(filePath, "utf-8");
const data = JSON.parse(raw) as { get_updates_buf?: string };
if (typeof data.get_updates_buf === "string") {
return data.get_updates_buf;
}
} catch {
// file not found or invalid
}
return undefined;
}
/**
* Load persisted get_updates_buf.
* Falls back in order:
* 1. Primary path (normalized accountId, new installs)
* 2. Compat path (raw accountId derived from pattern, old installs)
* 3. Legacy single-account path (very old installs without multi-account support)
*/
export function loadGetUpdatesBuf(filePath: string): string | undefined {
const value = readSyncBufFile(filePath);
if (value !== undefined) return value;
// Compat: if given path uses a normalized accountId (e.g. "b0f5860fdecb-im-bot.sync.json"),
// also try the old raw-ID filename (e.g. "b0f5860fdecb@im.bot.sync.json").
const accountId = path.basename(filePath, ".sync.json");
const rawId = deriveRawAccountId(accountId);
if (rawId) {
const compatPath = path.join(resolveAccountsDir(), `${rawId}.sync.json`);
const compatValue = readSyncBufFile(compatPath);
if (compatValue !== undefined) return compatValue;
}
// Legacy fallback: old single-account installs stored syncbuf without accountId.
return readSyncBufFile(getLegacySyncBufDefaultJsonPath());
}
/**
* Persist get_updates_buf. Creates parent dir if needed.
*/
export function saveGetUpdatesBuf(filePath: string, getUpdatesBuf: string): void {
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(filePath, JSON.stringify({ get_updates_buf: getUpdatesBuf }, null, 0), "utf-8");
}
@@ -0,0 +1,143 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
/**
* Plugin logger writes JSON lines to the main openclaw log file:
* /tmp/openclaw/openclaw-YYYY-MM-DD.log
* Same file and format used by all other channels.
*/
const MAIN_LOG_DIR = path.join("/tmp", "openclaw");
const SUBSYSTEM = "gateway/channels/openclaw-weixin";
const RUNTIME = "node";
const RUNTIME_VERSION = process.versions.node;
const HOSTNAME = os.hostname() || "unknown";
const PARENT_NAMES = ["openclaw"];
/** tslog-compatible level IDs (higher = more severe). */
const LEVEL_IDS: Record<string, number> = {
TRACE: 1,
DEBUG: 2,
INFO: 3,
WARN: 4,
ERROR: 5,
FATAL: 6,
};
const DEFAULT_LOG_LEVEL = "INFO";
function resolveMinLevel(): number {
const env = process.env.OPENCLAW_LOG_LEVEL?.toUpperCase();
if (env && env in LEVEL_IDS) return LEVEL_IDS[env];
return LEVEL_IDS[DEFAULT_LOG_LEVEL];
}
let minLevelId = resolveMinLevel();
/** Dynamically change the minimum log level at runtime. */
export function setLogLevel(level: string): void {
const upper = level.toUpperCase();
if (!(upper in LEVEL_IDS)) {
throw new Error(`Invalid log level: ${level}. Valid levels: ${Object.keys(LEVEL_IDS).join(", ")}`);
}
minLevelId = LEVEL_IDS[upper];
}
/** Shift a Date into local time so toISOString() renders local clock digits. */
function toLocalISO(now: Date): string {
const offsetMs = -now.getTimezoneOffset() * 60_000;
const sign = offsetMs >= 0 ? "+" : "-";
const abs = Math.abs(now.getTimezoneOffset());
const offStr = `${sign}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`;
return new Date(now.getTime() + offsetMs).toISOString().replace("Z", offStr);
}
function localDateKey(now: Date): string {
return toLocalISO(now).slice(0, 10);
}
function resolveMainLogPath(): string {
const dateKey = localDateKey(new Date());
return path.join(MAIN_LOG_DIR, `openclaw-${dateKey}.log`);
}
let logDirEnsured = false;
export type Logger = {
info(message: string): void;
debug(message: string): void;
warn(message: string): void;
error(message: string): void;
/** Returns a child logger whose messages are prefixed with `[accountId]`. */
withAccount(accountId: string): Logger;
/** Returns the current main log file path. */
getLogFilePath(): string;
close(): void;
};
function buildLoggerName(accountId?: string): string {
return accountId ? `${SUBSYSTEM}/${accountId}` : SUBSYSTEM;
}
function writeLog(level: string, message: string, accountId?: string): void {
const levelId = LEVEL_IDS[level] ?? LEVEL_IDS.INFO;
if (levelId < minLevelId) return;
const now = new Date();
const loggerName = buildLoggerName(accountId);
const prefixedMessage = accountId ? `[${accountId}] ${message}` : message;
const entry = JSON.stringify({
"0": loggerName,
"1": prefixedMessage,
_meta: {
runtime: RUNTIME,
runtimeVersion: RUNTIME_VERSION,
hostname: HOSTNAME,
name: loggerName,
parentNames: PARENT_NAMES,
date: now.toISOString(),
logLevelId: LEVEL_IDS[level] ?? LEVEL_IDS.INFO,
logLevelName: level,
},
time: toLocalISO(now),
});
try {
if (!logDirEnsured) {
fs.mkdirSync(MAIN_LOG_DIR, { recursive: true });
logDirEnsured = true;
}
fs.appendFileSync(resolveMainLogPath(), `${entry}\n`, "utf-8");
} catch {
// Best-effort; never block on logging failures.
}
}
/** Creates a logger instance, optionally bound to a specific account. */
function createLogger(accountId?: string): Logger {
return {
info(message: string): void {
writeLog("INFO", message, accountId);
},
debug(message: string): void {
writeLog("DEBUG", message, accountId);
},
warn(message: string): void {
writeLog("WARN", message, accountId);
},
error(message: string): void {
writeLog("ERROR", message, accountId);
},
withAccount(id: string): Logger {
return createLogger(id);
},
getLogFilePath(): string {
return resolveMainLogPath();
},
close(): void {
// No-op: appendFileSync has no persistent handle to close.
},
};
}
export const logger: Logger = createLogger();
@@ -0,0 +1,17 @@
import crypto from "node:crypto";
/**
* Generate a prefixed unique ID using timestamp + crypto random bytes.
* Format: `{prefix}:{timestamp}-{8-char hex}`
*/
export function generateId(prefix: string): string {
return `${prefix}:${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
}
/**
* Generate a temporary file name with random suffix.
* Format: `{prefix}-{timestamp}-{8-char hex}{ext}`
*/
export function tempFileName(prefix: string, ext: string): string {
return `${prefix}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}${ext}`;
}
@@ -0,0 +1,46 @@
const DEFAULT_BODY_MAX_LEN = 200;
const DEFAULT_TOKEN_PREFIX_LEN = 6;
/**
* Truncate a string, appending a length indicator when trimmed.
* Returns `""` for empty/undefined input.
*/
export function truncate(s: string | undefined, max: number): string {
if (!s) return "";
if (s.length <= max) return s;
return `${s.slice(0, max)}…(len=${s.length})`;
}
/**
* Redact a token/secret: show only the first few chars + total length.
* Returns `"(none)"` when absent.
*/
export function redactToken(token: string | undefined, prefixLen = DEFAULT_TOKEN_PREFIX_LEN): string {
if (!token) return "(none)";
if (token.length <= prefixLen) return `****(len=${token.length})`;
return `${token.slice(0, prefixLen)}…(len=${token.length})`;
}
/**
* Truncate a JSON body string to `maxLen` chars for safe logging.
* Appends original length so the reader knows how much was dropped.
*/
export function redactBody(body: string | undefined, maxLen = DEFAULT_BODY_MAX_LEN): string {
if (!body) return "(empty)";
if (body.length <= maxLen) return body;
return `${body.slice(0, maxLen)}…(truncated, totalLen=${body.length})`;
}
/**
* Strip query string (which often contains signatures/tokens) from a URL,
* keeping only origin + pathname.
*/
export function redactUrl(rawUrl: string): string {
try {
const u = new URL(rawUrl);
const base = `${u.origin}${u.pathname}`;
return u.search ? `${base}?<redacted>` : base;
} catch {
return truncate(rawUrl, 80);
}
}
+25
View File
@@ -0,0 +1,25 @@
declare module "qrcode-terminal" {
const qrcodeTerminal: {
generate(
text: string,
options?: { small?: boolean },
callback?: (qr: string) => void,
): void;
};
export default qrcodeTerminal;
}
declare module "fluent-ffmpeg" {
interface FfmpegCommand {
setFfmpegPath(path: string): FfmpegCommand;
seekInput(time: number): FfmpegCommand;
frames(n: number): FfmpegCommand;
outputOptions(opts: string[]): FfmpegCommand;
output(path: string): FfmpegCommand;
on(event: "end", cb: () => void): FfmpegCommand;
on(event: "error", cb: (err: Error) => void): FfmpegCommand;
run(): void;
}
function ffmpeg(input: string): FfmpegCommand;
export default ffmpeg;
}