� 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;
}
+207
View File
@@ -0,0 +1,207 @@
# @ynhcj/xiaoyichannel
XiaoYi channel plugin for OpenClaw with A2A protocol support.
## Features
- WebSocket-based connection to XiaoYi servers
- AK/SK authentication mechanism
- A2A (Agent-to-Agent) message protocol support
- Automatic reconnection with exponential backoff
- Heartbeat mechanism for connection health monitoring
- Full integration with OpenClaw's message routing and session management
## Installation
Install the plugin in your OpenClaw project:
```bash
openclaw plugins install @ynhcj/xiaoyichannel@1.0.0
```
## Configuration
After installation, add the XiaoYi channel configuration to your `openclaw.json` (or `.openclawd.json`):
```json
{
"channels": {
"xiaoyi": {
"enabled": true,
"accounts": {
"default": {
"enabled": true,
"wsUrl": "wss://hag.com/ws/link",
"ak": "your-access-key",
"sk": "your-secret-key",
"agentId": "your-agent-id"
}
}
}
},
"agents": {
"bindings": [
{
"agentId": "main",
"match": {
"channel": "xiaoyi",
"accountId": "default"
}
}
]
}
}
```
### Configuration Parameters
- `wsUrl`: WebSocket server URL (e.g., `wss://hag.com/ws/link`)
- `ak`: Access Key for authentication
- `sk`: Secret Key for authentication
- `agentId`: Your agent identifier
### Multiple Accounts
You can configure multiple XiaoYi accounts:
```json
{
"channels": {
"xiaoyi": {
"enabled": true,
"accounts": {
"account1": {
"enabled": true,
"wsUrl": "wss://hag.com/ws/link",
"ak": "ak1",
"sk": "sk1",
"agentId": "agent1"
},
"account2": {
"enabled": true,
"wsUrl": "wss://hag.com/ws/link",
"ak": "ak2",
"sk": "sk2",
"agentId": "agent2"
}
}
}
}
}
```
## A2A Protocol
This plugin implements the A2A (Agent-to-Agent) message protocol as specified in the [Huawei Message Stream documentation](https://developer.huawei.com/consumer/cn/doc/service/message-stream-0000002505761434).
### Message Structure
**Incoming Request Message:**
```json
{
"sessionId": "session-123",
"messageId": "msg-456",
"timestamp": 1234567890,
"sender": {
"id": "user-id",
"name": "User Name",
"type": "user"
},
"content": {
"type": "text",
"text": "Hello, agent!"
}
}
```
**Outgoing Response Message:**
```json
{
"sessionId": "session-123",
"messageId": "msg-789",
"timestamp": 1234567891,
"agentId": "your-agent-id",
"sender": {
"id": "your-agent-id",
"name": "OpenClaw Agent",
"type": "agent"
},
"content": {
"type": "text",
"text": "Hello! How can I help you?"
},
"status": "success"
}
```
## Authentication
The plugin uses AK/SK authentication as specified in the [Huawei Push Message documentation](https://developer.huawei.com/consumer/cn/doc/service/pushmessage-0000002505761436).
The authentication signature is generated using HMAC-SHA256:
```
signature = HMAC-SHA256(SK, "ak={AK}&timestamp={TIMESTAMP}")
```
## Connection Management
The plugin automatically manages WebSocket connections with the following features:
- **Automatic Reconnection**: Reconnects automatically on connection loss with exponential backoff
- **Heartbeat Monitoring**: Sends ping messages every 30 seconds to keep the connection alive
- **Connection Health**: Monitors connection status and reports health via OpenClaw's status system
- **Max Retry Limit**: Stops reconnection attempts after 10 failed attempts
## Session Management
The plugin integrates with OpenClaw's session management system:
- Sessions are scoped by `sessionId` from incoming A2A messages
- Each conversation maintains its own session context
- Session keys are automatically generated based on OpenClaw's configuration
## Usage
Once configured, the plugin will:
1. Automatically connect to the XiaoYi WebSocket server on startup
2. Authenticate using the provided AK/SK credentials
3. Receive incoming messages via WebSocket
4. Route messages to the appropriate OpenClaw agent
5. Send agent responses back through the WebSocket connection
## Troubleshooting
### Connection Issues
Check the OpenClaw logs for connection status:
```bash
openclaw logs
```
### Authentication Failures
Verify your AK/SK credentials are correct and have the necessary permissions.
### Message Delivery
Ensure your `agentId` is correctly configured and matches your XiaoYi account settings.
## Development
To build the plugin from source:
```bash
npm install
npm run build
```
## License
MIT
## Support
For issues and questions, please visit the [GitHub repository](https://github.com/ynhcj/xiaoyichannel).
+36
View File
@@ -0,0 +1,36 @@
import { AuthCredentials } from "./types";
/**
* Generate authentication signature using AK/SK mechanism
* Based on: https://developer.huawei.com/consumer/cn/doc/service/pushmessage-0000002505761436
*
* Signature format: Base64(HMAC-SHA256(secretKey, ts))
*/
export declare class XiaoYiAuth {
private ak;
private sk;
private agentId;
constructor(ak: string, sk: string, agentId: string);
/**
* Generate authentication credentials with signature
*/
generateAuthCredentials(): AuthCredentials;
/**
* Generate HMAC-SHA256 signature
* Format: Base64(HMAC-SHA256(secretKey, ts))
* Reference: https://developer.huawei.com/consumer/cn/doc/service/pushmessage-0000002505761436
* @param timestamp - Timestamp as string (e.g., "1514764800000")
*/
private generateSignature;
/**
* Verify if credentials are valid
*/
verifyCredentials(credentials: AuthCredentials): boolean;
/**
* Generate authentication headers for WebSocket connection
*/
generateAuthHeaders(): Record<string, string>;
/**
* Generate authentication message for WebSocket (legacy, kept for compatibility)
*/
generateAuthMessage(): any;
}
+111
View File
@@ -0,0 +1,111 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.XiaoYiAuth = void 0;
const crypto = __importStar(require("crypto"));
/**
* Generate authentication signature using AK/SK mechanism
* Based on: https://developer.huawei.com/consumer/cn/doc/service/pushmessage-0000002505761436
*
* Signature format: Base64(HMAC-SHA256(secretKey, ts))
*/
class XiaoYiAuth {
constructor(ak, sk, agentId) {
this.ak = ak;
this.sk = sk;
this.agentId = agentId;
}
/**
* Generate authentication credentials with signature
*/
generateAuthCredentials() {
const timestamp = Date.now();
const signature = this.generateSignature(timestamp.toString());
return {
ak: this.ak,
sk: this.sk,
timestamp,
signature,
};
}
/**
* Generate HMAC-SHA256 signature
* Format: Base64(HMAC-SHA256(secretKey, ts))
* Reference: https://developer.huawei.com/consumer/cn/doc/service/pushmessage-0000002505761436
* @param timestamp - Timestamp as string (e.g., "1514764800000")
*/
generateSignature(timestamp) {
// HMAC-SHA256(secretKey, ts)
const hmac = crypto.createHmac("sha256", this.sk);
hmac.update(timestamp);
const digest = hmac.digest();
// Base64 encode
return digest.toString("base64");
}
/**
* Verify if credentials are valid
*/
verifyCredentials(credentials) {
const expectedSignature = this.generateSignature(credentials.timestamp.toString());
return credentials.signature === expectedSignature;
}
/**
* Generate authentication headers for WebSocket connection
*/
generateAuthHeaders() {
const timestamp = Date.now();
const signature = this.generateSignature(timestamp.toString());
return {
"x-access-key": this.ak,
"x-sign": signature,
"x-ts": timestamp.toString(),
"x-agent-id": this.agentId,
};
}
/**
* Generate authentication message for WebSocket (legacy, kept for compatibility)
*/
generateAuthMessage() {
const credentials = this.generateAuthCredentials();
return {
type: "auth",
ak: credentials.ak,
agentId: this.agentId,
timestamp: credentials.timestamp,
signature: credentials.signature,
};
}
}
exports.XiaoYiAuth = XiaoYiAuth;
+144
View File
@@ -0,0 +1,144 @@
import type { ChannelOutboundContext, OutboundDeliveryResult, ChannelGatewayContext, ChannelMessagingNormalizeTargetContext, ChannelStatusGetAccountStatusContext, OpenClawConfig } from "openclaw";
import { XiaoYiChannelConfig } from "./types";
/**
* Resolved XiaoYi account configuration (single account mode)
*/
export interface ResolvedXiaoYiAccount {
accountId: string;
config: XiaoYiChannelConfig;
}
/**
* XiaoYi Channel Plugin
* Implements OpenClaw ChannelPlugin interface for XiaoYi A2A protocol
* Single account mode only
*/
export declare const xiaoyiPlugin: {
id: string;
meta: {
id: string;
label: string;
selectionLabel: string;
docsPath: string;
blurb: string;
aliases: string[];
};
capabilities: {
chatTypes: string[];
polls: boolean;
reactions: boolean;
threads: boolean;
media: boolean;
nativeCommands: boolean;
};
/**
* Config schema for UI form rendering
*/
configSchema: {
schema: {
type: string;
properties: {
enabled: {
type: string;
default: boolean;
description: string;
};
wsUrl1: {
type: string;
default: string;
description: string;
};
wsUrl2: {
type: string;
default: string;
description: string;
};
ak: {
type: string;
description: string;
};
sk: {
type: string;
description: string;
};
agentId: {
type: string;
description: string;
};
debug: {
type: string;
default: boolean;
description: string;
};
apiId: {
type: string;
default: string;
description: string;
};
pushId: {
type: string;
default: string;
description: string;
};
taskTimeoutMs: {
type: string;
default: number;
description: string;
};
};
};
};
onboarding: any;
/**
* Config adapter - single account mode
*/
config: {
listAccountIds: (cfg: OpenClawConfig) => string[];
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => {
accountId: string;
config: XiaoYiChannelConfig;
enabled: boolean;
};
defaultAccountId: (cfg: OpenClawConfig) => string;
isConfigured: (account: any, cfg: OpenClawConfig) => boolean;
isEnabled: (account: any, cfg: OpenClawConfig) => boolean;
disabledReason: (account: any, cfg: OpenClawConfig) => string;
unconfiguredReason: (account: any, cfg: OpenClawConfig) => string;
describeAccount: (account: any, cfg: OpenClawConfig) => {
accountId: any;
name: string;
enabled: any;
configured: boolean;
};
};
/**
* Outbound adapter - send messages
*/
outbound: {
deliveryMode: string;
textChunkLimit: number;
sendText: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
sendMedia: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
};
/**
* Gateway adapter - manage connections
*/
gateway: {
startAccount: (ctx: ChannelGatewayContext<ResolvedXiaoYiAccount>) => Promise<void>;
stopAccount: (ctx: ChannelGatewayContext<ResolvedXiaoYiAccount>) => Promise<void>;
};
/**
* Messaging adapter - normalize targets
*/
messaging: {
normalizeTarget: (ctx: ChannelMessagingNormalizeTargetContext) => Promise<string>;
};
/**
* Status adapter - health checks
*/
status: {
getAccountStatus: (ctx: ChannelStatusGetAccountStatusContext) => Promise<{
status: string;
message: string;
}>;
};
};
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
import { z } from 'zod';
/**
* XiaoYi configuration schema using Zod
* Defines the structure for XiaoYi A2A protocol configuration
*/
export declare const XiaoYiConfigSchema: z.ZodObject<{
/** Account name (optional display name) */
name: z.ZodOptional<z.ZodString>;
/** Whether this channel is enabled */
enabled: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
/** First WebSocket server URL */
wsUrl1: z.ZodDefault<z.ZodOptional<z.ZodString>>;
/** Second WebSocket server URL */
wsUrl2: z.ZodDefault<z.ZodOptional<z.ZodString>>;
/** Access Key for authentication */
ak: z.ZodOptional<z.ZodString>;
/** Secret Key for authentication */
sk: z.ZodOptional<z.ZodString>;
/** Agent ID for this XiaoYi agent */
agentId: z.ZodOptional<z.ZodString>;
/** Enable debug logging */
debug: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
/** Multi-account configuration */
accounts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
}, "strip", z.ZodTypeAny, {
enabled?: boolean;
wsUrl1?: string;
wsUrl2?: string;
ak?: string;
sk?: string;
agentId?: string;
name?: string;
debug?: boolean;
accounts?: Record<string, unknown>;
}, {
enabled?: boolean;
wsUrl1?: string;
wsUrl2?: string;
ak?: string;
sk?: string;
agentId?: string;
name?: string;
debug?: boolean;
accounts?: Record<string, unknown>;
}>;
export type XiaoYiConfig = z.infer<typeof XiaoYiConfigSchema>;
+28
View File
@@ -0,0 +1,28 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.XiaoYiConfigSchema = void 0;
const zod_1 = require("zod");
/**
* XiaoYi configuration schema using Zod
* Defines the structure for XiaoYi A2A protocol configuration
*/
exports.XiaoYiConfigSchema = zod_1.z.object({
/** Account name (optional display name) */
name: zod_1.z.string().optional(),
/** Whether this channel is enabled */
enabled: zod_1.z.boolean().optional().default(false),
/** First WebSocket server URL */
wsUrl1: zod_1.z.string().optional().default("wss://hag.cloud.huawei.com/openclaw/v1/ws/link"),
/** Second WebSocket server URL */
wsUrl2: zod_1.z.string().optional().default("wss://116.63.174.231/openclaw/v1/ws/link"),
/** Access Key for authentication */
ak: zod_1.z.string().optional(),
/** Secret Key for authentication */
sk: zod_1.z.string().optional(),
/** Agent ID for this XiaoYi agent */
agentId: zod_1.z.string().optional(),
/** Enable debug logging */
debug: zod_1.z.boolean().optional().default(false),
/** Multi-account configuration */
accounts: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).optional(),
});
+36
View File
@@ -0,0 +1,36 @@
/**
* Simple file and image handler for XiaoYi Channel
* Handles downloading and extracting content from URIs
*/
export interface InputImageContent {
type: "image";
data: string;
mimeType: string;
}
export interface ImageLimits {
allowUrl: boolean;
allowedMimes: Set<string>;
maxBytes: number;
maxRedirects: number;
timeoutMs: number;
}
/**
* Extract image content from URL
*/
export declare function extractImageFromUrl(url: string, limits?: Partial<ImageLimits>): Promise<InputImageContent>;
/**
* Extract text content from URL (for text-based files)
*/
export declare function extractTextFromUrl(url: string, maxBytes?: number, timeoutMs?: number): Promise<string>;
/**
* Check if a MIME type is an image
*/
export declare function isImageMimeType(mimeType: string | undefined): boolean;
/**
* Check if a MIME type is a PDF
*/
export declare function isPdfMimeType(mimeType: string | undefined): boolean;
/**
* Check if a MIME type is text-based
*/
export declare function isTextMimeType(mimeType: string | undefined): boolean;
+113
View File
@@ -0,0 +1,113 @@
"use strict";
/**
* Simple file and image handler for XiaoYi Channel
* Handles downloading and extracting content from URIs
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.extractImageFromUrl = extractImageFromUrl;
exports.extractTextFromUrl = extractTextFromUrl;
exports.isImageMimeType = isImageMimeType;
exports.isPdfMimeType = isPdfMimeType;
exports.isTextMimeType = isTextMimeType;
// Default limits
const DEFAULT_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
const DEFAULT_MAX_BYTES = 10000000; // 10MB
const DEFAULT_TIMEOUT = 30000; // 30 seconds
const DEFAULT_MAX_REDIRECTS = 3;
/**
* Fetch content from URL with basic validation
*/
async function fetchFromUrl(url, maxBytes, timeoutMs) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
signal: controller.signal,
headers: { "User-Agent": "XiaoYi-Channel/1.0" },
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// Check content-length header if available
const contentLength = response.headers.get("content-length");
if (contentLength) {
const size = parseInt(contentLength, 10);
if (size > maxBytes) {
throw new Error(`File too large: ${size} bytes (limit: ${maxBytes})`);
}
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.byteLength > maxBytes) {
throw new Error(`File too large: ${buffer.byteLength} bytes (limit: ${maxBytes})`);
}
// Detect MIME type
const contentType = response.headers.get("content-type");
const mimeType = contentType?.split(";")[0]?.trim() || "application/octet-stream";
return { buffer, mimeType };
}
finally {
clearTimeout(timeout);
}
}
/**
* Extract image content from URL
*/
async function extractImageFromUrl(url, limits) {
const finalLimits = {
allowUrl: limits?.allowUrl ?? true,
allowedMimes: limits?.allowedMimes ?? DEFAULT_IMAGE_MIMES,
maxBytes: limits?.maxBytes ?? DEFAULT_MAX_BYTES,
maxRedirects: limits?.maxRedirects ?? DEFAULT_MAX_REDIRECTS,
timeoutMs: limits?.timeoutMs ?? DEFAULT_TIMEOUT,
};
if (!finalLimits.allowUrl) {
throw new Error("URL sources are disabled");
}
const { buffer, mimeType } = await fetchFromUrl(url, finalLimits.maxBytes, finalLimits.timeoutMs);
if (!finalLimits.allowedMimes.has(mimeType)) {
throw new Error(`Unsupported image type: ${mimeType}`);
}
return {
type: "image",
data: buffer.toString("base64"),
mimeType,
};
}
/**
* Extract text content from URL (for text-based files)
*/
async function extractTextFromUrl(url, maxBytes = 5000000, timeoutMs = 30000) {
const { buffer, mimeType } = await fetchFromUrl(url, maxBytes, timeoutMs);
// Only process text-based MIME types
const textMimes = ["text/plain", "text/markdown", "text/html", "text/csv", "application/json", "application/xml"];
if (!textMimes.some((tm) => mimeType.startsWith(tm) || mimeType === tm)) {
throw new Error(`Unsupported text type: ${mimeType}`);
}
// Try to decode as UTF-8
return buffer.toString("utf-8");
}
/**
* Check if a MIME type is an image
*/
function isImageMimeType(mimeType) {
if (!mimeType)
return false;
return DEFAULT_IMAGE_MIMES.has(mimeType.toLowerCase());
}
/**
* Check if a MIME type is a PDF
*/
function isPdfMimeType(mimeType) {
return mimeType?.toLowerCase() === "application/pdf" || false;
}
/**
* Check if a MIME type is text-based
*/
function isTextMimeType(mimeType) {
if (!mimeType)
return false;
const lower = mimeType.toLowerCase();
return (lower.startsWith("text/") ||
lower === "application/json" ||
lower === "application/xml");
}
+29
View File
@@ -0,0 +1,29 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
/**
* XiaoYi Channel Plugin for OpenClaw
*
* This plugin enables integration with XiaoYi's A2A protocol via WebSocket.
* Supports dual server mode for high availability.
*
* Configuration example in openclaw.json:
* {
* "channels": {
* "xiaoyi": {
* "enabled": true,
* "wsUrl1": "ws://localhost:8765/ws/link",
* "wsUrl2": "ws://localhost:8766/ws/link",
* "ak": "test_ak",
* "sk": "test_sk",
* "agentId": "your-agent-id"
* }
* }
* }
*/
declare const plugin: {
id: string;
name: string;
description: string;
configSchema: any;
register(api: OpenClawPluginApi): void;
};
export default plugin;
+49
View File
@@ -0,0 +1,49 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const channel_1 = require("./channel");
const runtime_1 = require("./runtime");
/**
* XiaoYi Channel Plugin for OpenClaw
*
* This plugin enables integration with XiaoYi's A2A protocol via WebSocket.
* Supports dual server mode for high availability.
*
* Configuration example in openclaw.json:
* {
* "channels": {
* "xiaoyi": {
* "enabled": true,
* "wsUrl1": "ws://localhost:8765/ws/link",
* "wsUrl2": "ws://localhost:8766/ws/link",
* "ak": "test_ak",
* "sk": "test_sk",
* "agentId": "your-agent-id"
* }
* }
* }
*/
const plugin = {
id: "xiaoyi",
name: "XiaoYi Channel",
description: "XiaoYi channel plugin with A2A protocol support",
configSchema: undefined,
register(api) {
console.log("XiaoYi: register() called - START");
// Set runtime for managing WebSocket connections
(0, runtime_1.setXiaoYiRuntime)(api.runtime);
console.log("XiaoYi: setXiaoYiRuntime() completed");
// Clean up any existing connections from previous plugin loads
const runtime = require("./runtime").getXiaoYiRuntime();
console.log(`XiaoYi: Got runtime instance: ${runtime.getInstanceId()}, isConnected: ${runtime.isConnected()}`);
if (runtime.isConnected()) {
console.log("XiaoYi: Cleaning up existing connection from previous load");
runtime.stop();
}
// Register the channel plugin
console.log("XiaoYi: About to call registerChannel()");
api.registerChannel({ plugin: channel_1.xiaoyiPlugin });
console.log("XiaoYi: registerChannel() completed");
console.log("XiaoYi channel plugin registered - END");
},
};
exports.default = plugin;
+6
View File
@@ -0,0 +1,6 @@
/**
* XiaoYi onboarding adapter for CLI setup wizard.
*/
type ChannelOnboardingAdapter = any;
export declare const xiaoyiOnboardingAdapter: ChannelOnboardingAdapter;
export {};
+167
View File
@@ -0,0 +1,167 @@
"use strict";
/**
* XiaoYi onboarding adapter for CLI setup wizard.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.xiaoyiOnboardingAdapter = void 0;
const channel = "xiaoyi";
/**
* Get XiaoYi channel config from OpenClaw config
*/
function getXiaoYiConfig(cfg) {
return cfg?.channels?.xiaoyi;
}
/**
* Check if XiaoYi is properly configured
*/
function isXiaoYiConfigured(config) {
if (!config) {
return false;
}
// Check required fields: ak, sk, agentId
// wsUrl1/wsUrl2 are optional (defaults will be used if not provided)
const hasAk = typeof config.ak === "string" && config.ak.trim().length > 0;
const hasSk = typeof config.sk === "string" && config.sk.trim().length > 0;
const hasAgentId = typeof config.agentId === "string" && config.agentId.trim().length > 0;
return hasAk && hasSk && hasAgentId;
}
/**
* Set XiaoYi channel configuration
*/
function setXiaoYiConfig(cfg, config) {
const existing = getXiaoYiConfig(cfg);
const merged = {
enabled: config.enabled ?? existing?.enabled ?? true,
wsUrl: config.wsUrl ?? existing?.wsUrl ?? "",
wsUrl1: config.wsUrl1 ?? existing?.wsUrl1 ?? "",
wsUrl2: config.wsUrl2 ?? existing?.wsUrl2 ?? "",
ak: config.ak ?? existing?.ak ?? "",
sk: config.sk ?? existing?.sk ?? "",
agentId: config.agentId ?? existing?.agentId ?? "",
enableStreaming: config.enableStreaming ?? existing?.enableStreaming ?? true,
};
return {
...cfg,
channels: {
...cfg.channels,
xiaoyi: merged,
},
};
}
/**
* Note about XiaoYi setup
*/
async function noteXiaoYiSetupHelp(prompter) {
await prompter.note([
"XiaoYi (小艺) uses A2A protocol via WebSocket connection.",
"",
"Required credentials:",
" - ak: Access Key for authentication",
" - sk: Secret Key for authentication",
" - agentId: Your agent identifier",
"",
"WebSocket URLs will use default values.",
"",
"Docs: https://docs.openclaw.ai/channels/xiaoyi",
].join("\n"), "XiaoYi setup");
}
/**
* Prompt for Access Key
*/
async function promptAk(prompter, config) {
const existing = config?.ak ?? "";
return String(await prompter.text({
message: "XiaoYi Access Key (ak)",
initialValue: existing,
validate: (value) => (value?.trim() ? undefined : "Required"),
})).trim();
}
/**
* Prompt for Secret Key
*/
async function promptSk(prompter, config) {
const existing = config?.sk ?? "";
return String(await prompter.text({
message: "XiaoYi Secret Key (sk)",
initialValue: existing,
validate: (value) => (value?.trim() ? undefined : "Required"),
})).trim();
}
/**
* Prompt for Agent ID
*/
async function promptAgentId(prompter, config) {
const existing = config?.agentId ?? "";
return String(await prompter.text({
message: "XiaoYi Agent ID",
initialValue: existing,
validate: (value) => (value?.trim() ? undefined : "Required"),
})).trim();
}
exports.xiaoyiOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const config = getXiaoYiConfig(cfg);
const configured = isXiaoYiConfigured(config);
const enabled = config?.enabled !== false;
const statusLines = [];
if (configured) {
statusLines.push(`XiaoYi: ${enabled ? "enabled" : "disabled"}`);
if (config?.wsUrl1 || config?.wsUrl) {
statusLines.push(` WebSocket: ${config.wsUrl1 || config.wsUrl}`);
}
if (config?.wsUrl2) {
statusLines.push(` Secondary: ${config.wsUrl2}`);
}
if (config?.agentId) {
statusLines.push(` Agent ID: ${config.agentId}`);
}
}
else {
statusLines.push("XiaoYi: needs ak, sk, and agentId");
}
return {
channel,
configured,
statusLines,
selectionHint: configured ? "configured" : "needs setup",
quickstartScore: 50,
};
},
configure: async ({ cfg, prompter }) => {
const config = getXiaoYiConfig(cfg);
if (!isXiaoYiConfigured(config)) {
await noteXiaoYiSetupHelp(prompter);
}
else {
const reconfigure = await prompter.confirm({
message: "XiaoYi already configured. Reconfigure?",
initialValue: false,
});
if (!reconfigure) {
return { cfg, accountId: "default" };
}
}
// Prompt for required credentials
const ak = await promptAk(prompter, config);
const sk = await promptSk(prompter, config);
const agentId = await promptAgentId(prompter, config);
const cfgWithConfig = setXiaoYiConfig(cfg, {
ak,
sk,
agentId,
enabled: true,
});
return { cfg: cfgWithConfig, accountId: "default" };
},
disable: (cfg) => {
const xiaoyi = getXiaoYiConfig(cfg);
return {
...cfg,
channels: {
...cfg.channels,
xiaoyi: { ...xiaoyi, enabled: false },
},
};
},
};
+28
View File
@@ -0,0 +1,28 @@
import { XiaoYiChannelConfig } from "./types";
/**
* Push message sending service
* Sends notifications to XiaoYi clients via webhook API
*/
export declare class XiaoYiPushService {
private config;
private readonly pushUrl;
constructor(config: XiaoYiChannelConfig);
/**
* Check if push functionality is configured
*/
isConfigured(): boolean;
/**
* Generate HMAC-SHA256 signature
*/
private generateSignature;
/**
* Generate UUID
*/
private generateUUID;
/**
* Send push notification (with summary text)
* @param text - Summary text to send (e.g., first 30 characters)
* @param pushText - Push notification message (e.g., "任务已完成:xxx...")
*/
sendPush(text: string, pushText: string): Promise<boolean>;
}
+135
View File
@@ -0,0 +1,135 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.XiaoYiPushService = void 0;
const crypto = __importStar(require("crypto"));
/**
* Push message sending service
* Sends notifications to XiaoYi clients via webhook API
*/
class XiaoYiPushService {
constructor(config) {
this.pushUrl = "https://hag.cloud.huawei.com/open-ability-agent/v1/agent-webhook";
this.config = config;
}
/**
* Check if push functionality is configured
*/
isConfigured() {
return Boolean(this.config.apiId?.trim() &&
this.config.pushId?.trim() &&
this.config.ak?.trim() &&
this.config.sk?.trim());
}
/**
* Generate HMAC-SHA256 signature
*/
generateSignature(timestamp) {
const hmac = crypto.createHmac("sha256", this.config.sk);
hmac.update(timestamp);
return hmac.digest().toString("base64");
}
/**
* Generate UUID
*/
generateUUID() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
* Send push notification (with summary text)
* @param text - Summary text to send (e.g., first 30 characters)
* @param pushText - Push notification message (e.g., "任务已完成:xxx...")
*/
async sendPush(text, pushText) {
if (!this.isConfigured()) {
console.log("[PUSH] Push not configured, skipping");
return false;
}
try {
const timestamp = Date.now().toString();
const signature = this.generateSignature(timestamp);
const messageId = this.generateUUID();
const payload = {
jsonrpc: "2.0",
id: messageId,
result: {
id: this.generateUUID(),
apiId: this.config.apiId,
pushId: this.config.pushId,
pushText: pushText,
kind: "task",
artifacts: [{
artifactId: this.generateUUID(),
parts: [{
kind: "text",
text: text, // Summary text
}]
}],
status: { state: "completed" }
}
};
console.log(`[PUSH] Sending push notification: ${pushText}`);
const response = await fetch(this.pushUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"x-hag-trace-id": this.generateUUID(),
"X-Access-Key": this.config.ak,
"X-Sign": signature,
"X-Ts": timestamp,
},
body: JSON.stringify(payload),
});
if (response.ok) {
console.log("[PUSH] Push notification sent successfully");
return true;
}
else {
console.error(`[PUSH] Failed: HTTP ${response.status}`);
return false;
}
}
catch (error) {
console.error("[PUSH] Error:", error);
return false;
}
}
}
exports.XiaoYiPushService = XiaoYiPushService;
+191
View File
@@ -0,0 +1,191 @@
import { XiaoYiWebSocketManager } from "./websocket";
import { XiaoYiChannelConfig } from "./types";
/**
* Timeout configuration
*/
export interface TimeoutConfig {
enabled: boolean;
duration: number;
message: string;
}
/**
* Runtime state for XiaoYi channel
* Manages single WebSocket connection (single account mode)
*/
export declare class XiaoYiRuntime {
private connection;
private pluginRuntime;
private config;
private sessionToTaskIdMap;
private instanceId;
private sessionTimeoutMap;
private sessionTimeoutSent;
private timeoutConfig;
private sessionAbortControllerMap;
private sessionActiveRunMap;
private sessionStartTimeMap;
private static readonly SESSION_STALE_TIMEOUT_MS;
private sessionTaskTimeoutMap;
private sessionPushPendingMap;
private taskTimeoutMs;
constructor();
getInstanceId(): string;
/**
* Set OpenClaw PluginRuntime (from api.runtime in register())
*/
setPluginRuntime(runtime: any): void;
/**
* Get OpenClaw PluginRuntime
*/
getPluginRuntime(): any;
/**
* Start connection (single account mode)
*/
start(config: XiaoYiChannelConfig): Promise<void>;
/**
* Stop connection
*/
stop(): void;
/**
* Set timeout configuration
*/
setTimeoutConfig(config: Partial<TimeoutConfig>): void;
/**
* Get timeout configuration
*/
getTimeoutConfig(): TimeoutConfig;
/**
* Set timeout for a session
* @param sessionId - Session ID
* @param callback - Function to call when timeout occurs
* @returns The interval ID (for cancellation)
*
* IMPORTANT: This now uses setInterval instead of setTimeout
* - First trigger: after 60 seconds
* - Subsequent triggers: every 60 seconds after that
* - Cleared when: response received, session completed, or explicitly cleared
*/
setTimeoutForSession(sessionId: string, callback: () => void): NodeJS.Timeout | undefined;
/**
* Clear timeout interval for a session
* @param sessionId - Session ID
*/
clearSessionTimeout(sessionId: string): void;
/**
* Check if timeout has been sent for a session
* @param sessionId - Session ID
*/
isSessionTimeout(sessionId: string): boolean;
/**
* Mark session as completed (clear timeout and timeout flag)
* @param sessionId - Session ID
*/
markSessionCompleted(sessionId: string): void;
/**
* Clear all timeout intervals
*/
clearAllTimeouts(): void;
/**
* Get WebSocket manager
*/
getConnection(): XiaoYiWebSocketManager | null;
/**
* Check if connected
*/
isConnected(): boolean;
/**
* Get configuration
*/
getConfig(): XiaoYiChannelConfig | null;
/**
* Set taskId for a session
*/
setTaskIdForSession(sessionId: string, taskId: string): void;
/**
* Get taskId for a session
*/
getTaskIdForSession(sessionId: string): string | undefined;
/**
* Clear taskId for a session
*/
clearTaskIdForSession(sessionId: string): void;
/**
* Create and register an AbortController for a session
* @param sessionId - Session ID
* @returns The AbortController and its signal, or null if session is busy
*/
createAbortControllerForSession(sessionId: string): {
controller: AbortController;
signal: AbortSignal;
} | null;
/**
* Check if a session has an active agent run
* If session is active but stale (超过 SESSION_STALE_TIMEOUT_MS), automatically clean up
* @param sessionId - Session ID
* @returns true if session is busy
*/
isSessionActive(sessionId: string): boolean;
/**
* Abort a session's agent run
* @param sessionId - Session ID
* @returns true if a controller was found and aborted, false otherwise
*/
abortSession(sessionId: string): boolean;
/**
* Check if a session has been aborted
* @param sessionId - Session ID
* @returns true if the session's abort signal was triggered
*/
isSessionAborted(sessionId: string): boolean;
/**
* Clear the AbortController for a session (call when agent completes successfully)
* @param sessionId - Session ID
*/
clearAbortControllerForSession(sessionId: string): void;
/**
* Clear all AbortControllers
*/
clearAllAbortControllers(): void;
/**
* Generate a composite key for session+task combination
* This ensures each task has its own push state, even within the same session
*/
private getPushStateKey;
/**
* Set task timeout time (from configuration)
*/
setTaskTimeout(timeoutMs: number): void;
/**
* Set a 1-hour task timeout timer for a session
* @returns timeout ID
*/
setTaskTimeoutForSession(sessionId: string, taskId: string, callback: (sessionId: string, taskId: string) => void): NodeJS.Timeout;
/**
* Clear the task timeout timer for a session
*/
clearTaskTimeoutForSession(sessionId: string): void;
/**
* Check if session+task is waiting for push notification
* @param sessionId - Session ID
* @param taskId - Task ID (optional, for per-task tracking)
*/
isSessionWaitingForPush(sessionId: string, taskId?: string): boolean;
/**
* Mark session+task as waiting for push notification
* @param sessionId - Session ID
* @param taskId - Task ID (optional, for per-task tracking)
*/
markSessionWaitingForPush(sessionId: string, taskId?: string): void;
/**
* Clear the waiting push state for a session+task
* @param sessionId - Session ID
* @param taskId - Task ID (optional, for per-task tracking)
*/
clearSessionWaitingForPush(sessionId: string, taskId?: string): void;
/**
* Clear all task timeout related state for a session
*/
clearTaskTimeoutState(sessionId: string): void;
}
export declare function getXiaoYiRuntime(): XiaoYiRuntime;
export declare function setXiaoYiRuntime(runtime: any): void;
+438
View File
@@ -0,0 +1,438 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.XiaoYiRuntime = void 0;
exports.getXiaoYiRuntime = getXiaoYiRuntime;
exports.setXiaoYiRuntime = setXiaoYiRuntime;
const websocket_1 = require("./websocket");
/**
* Default timeout configuration
*/
const DEFAULT_TIMEOUT_CONFIG = {
enabled: true,
duration: 60000, // 60 seconds
message: "任务正在处理中,请稍后",
};
/**
* Runtime state for XiaoYi channel
* Manages single WebSocket connection (single account mode)
*/
class XiaoYiRuntime {
constructor() {
this.connection = null;
this.pluginRuntime = null; // Store PluginRuntime from OpenClaw
this.config = null;
this.sessionToTaskIdMap = new Map(); // Map sessionId to taskId
// Timeout management
this.sessionTimeoutMap = new Map();
this.sessionTimeoutSent = new Set();
this.timeoutConfig = DEFAULT_TIMEOUT_CONFIG;
// AbortController management for canceling agent runs
this.sessionAbortControllerMap = new Map();
// Track if a session has an active agent run (for concurrent request detection)
this.sessionActiveRunMap = new Map();
// Track session start time for timeout detection
this.sessionStartTimeMap = new Map();
// 1-hour task timeout mechanism
this.sessionTaskTimeoutMap = new Map();
this.sessionPushPendingMap = new Map();
this.taskTimeoutMs = 3600000; // Default 1 hour
this.instanceId = `runtime_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
console.log(`XiaoYi: Created new runtime instance: ${this.instanceId}`);
}
getInstanceId() {
return this.instanceId;
}
/**
* Set OpenClaw PluginRuntime (from api.runtime in register())
*/
setPluginRuntime(runtime) {
console.log(`XiaoYi: [${this.instanceId}] Setting PluginRuntime`);
this.pluginRuntime = runtime;
}
/**
* Get OpenClaw PluginRuntime
*/
getPluginRuntime() {
return this.pluginRuntime;
}
/**
* Start connection (single account mode)
*/
async start(config) {
if (this.connection) {
console.log("XiaoYi channel already connected");
return;
}
this.config = config;
const manager = new websocket_1.XiaoYiWebSocketManager(config);
// Setup basic event handlers (message handling is done in channel.ts)
manager.on("error", (error) => {
console.error("XiaoYi channel error:", error);
});
manager.on("disconnected", () => {
console.log("XiaoYi channel disconnected");
});
manager.on("authenticated", () => {
console.log("XiaoYi channel authenticated");
});
manager.on("maxReconnectAttemptsReached", (serverId) => {
console.error(`XiaoYi channel ${serverId} max reconnect attempts reached`);
// Check if the other server is still connected and ready
const otherServerId = serverId === 'server1' ? 'server2' : 'server1';
const serverStates = manager.getServerStates();
const otherServerState = otherServerId === 'server1' ? serverStates.server1 : serverStates.server2;
if (otherServerState?.connected && otherServerState?.ready) {
console.warn(`[${otherServerId}] is still connected and ready, continuing in single-server mode`);
console.warn(`System will continue running with ${otherServerId} only`);
// Don't stop, continue with the other server
return;
}
// Only stop when both servers have failed
console.error("Both servers have reached max reconnect attempts, stopping connection");
console.error(`Server1: ${serverStates.server1.connected ? 'connected' : 'disconnected'}, Server2: ${serverStates.server2.connected ? 'connected' : 'disconnected'}`);
this.stop();
});
// Connect
await manager.connect();
this.connection = manager;
console.log("XiaoYi channel started");
}
/**
* Stop connection
*/
stop() {
if (this.connection) {
this.connection.disconnect();
this.connection = null;
console.log("XiaoYi channel stopped");
}
// Clear session mappings
this.sessionToTaskIdMap.clear();
// Clear all timeouts
this.clearAllTimeouts();
// Clear all abort controllers
this.clearAllAbortControllers();
// Clear all task timeout state
for (const sessionId of this.sessionTaskTimeoutMap.keys()) {
this.clearTaskTimeoutState(sessionId);
}
}
/**
* Set timeout configuration
*/
setTimeoutConfig(config) {
this.timeoutConfig = { ...this.timeoutConfig, ...config };
console.log(`XiaoYi: Timeout config updated:`, this.timeoutConfig);
}
/**
* Get timeout configuration
*/
getTimeoutConfig() {
return { ...this.timeoutConfig };
}
/**
* Set timeout for a session
* @param sessionId - Session ID
* @param callback - Function to call when timeout occurs
* @returns The interval ID (for cancellation)
*
* IMPORTANT: This now uses setInterval instead of setTimeout
* - First trigger: after 60 seconds
* - Subsequent triggers: every 60 seconds after that
* - Cleared when: response received, session completed, or explicitly cleared
*/
setTimeoutForSession(sessionId, callback) {
if (!this.timeoutConfig.enabled) {
console.log(`[TIMEOUT] Timeout disabled, skipping for session ${sessionId}`);
return undefined;
}
// Clear existing timeout AND timeout flag if any (reuse session scenario)
const hadExistingTimeout = this.sessionTimeoutMap.has(sessionId);
const hadSentTimeout = this.sessionTimeoutSent.has(sessionId);
this.clearSessionTimeout(sessionId);
// Clear the timeout sent flag to allow this session to timeout again
if (hadSentTimeout) {
this.sessionTimeoutSent.delete(sessionId);
console.log(`[TIMEOUT] Previous timeout flag cleared for session ${sessionId} (session reuse)`);
}
// Use setInterval for periodic timeout triggers
// First trigger after duration, then every duration after that
const intervalId = setInterval(() => {
console.log(`[TIMEOUT] Timeout triggered for session ${sessionId} (will trigger again in ${this.timeoutConfig.duration}ms if still active)`);
this.sessionTimeoutSent.add(sessionId);
callback();
}, this.timeoutConfig.duration);
this.sessionTimeoutMap.set(sessionId, intervalId);
const logSuffix = hadExistingTimeout ? " (replacing existing interval)" : "";
console.log(`[TIMEOUT] ${this.timeoutConfig.duration}ms periodic timeout started for session ${sessionId}${logSuffix}`);
return intervalId;
}
/**
* Clear timeout interval for a session
* @param sessionId - Session ID
*/
clearSessionTimeout(sessionId) {
const intervalId = this.sessionTimeoutMap.get(sessionId);
if (intervalId) {
clearInterval(intervalId);
this.sessionTimeoutMap.delete(sessionId);
console.log(`[TIMEOUT] Timeout interval cleared for session ${sessionId}`);
}
}
/**
* Check if timeout has been sent for a session
* @param sessionId - Session ID
*/
isSessionTimeout(sessionId) {
return this.sessionTimeoutSent.has(sessionId);
}
/**
* Mark session as completed (clear timeout and timeout flag)
* @param sessionId - Session ID
*/
markSessionCompleted(sessionId) {
this.clearSessionTimeout(sessionId);
this.sessionTimeoutSent.delete(sessionId);
console.log(`[TIMEOUT] Session ${sessionId} marked as completed`);
}
/**
* Clear all timeout intervals
*/
clearAllTimeouts() {
for (const [sessionId, intervalId] of this.sessionTimeoutMap.entries()) {
clearInterval(intervalId);
}
this.sessionTimeoutMap.clear();
this.sessionTimeoutSent.clear();
console.log("[TIMEOUT] All timeout intervals cleared");
}
/**
* Get WebSocket manager
*/
getConnection() {
return this.connection;
}
/**
* Check if connected
*/
isConnected() {
return this.connection ? this.connection.isReady() : false;
}
/**
* Get configuration
*/
getConfig() {
return this.config;
}
/**
* Set taskId for a session
*/
setTaskIdForSession(sessionId, taskId) {
this.sessionToTaskIdMap.set(sessionId, taskId);
}
/**
* Get taskId for a session
*/
getTaskIdForSession(sessionId) {
return this.sessionToTaskIdMap.get(sessionId);
}
/**
* Clear taskId for a session
*/
clearTaskIdForSession(sessionId) {
this.sessionToTaskIdMap.delete(sessionId);
}
/**
* Create and register an AbortController for a session
* @param sessionId - Session ID
* @returns The AbortController and its signal, or null if session is busy
*/
createAbortControllerForSession(sessionId) {
// Check if there's an active agent run for this session
if (this.sessionActiveRunMap.get(sessionId)) {
console.log(`[CONCURRENT] Session ${sessionId} has an active agent run, cannot create new AbortController`);
return null;
}
const controller = new AbortController();
this.sessionAbortControllerMap.set(sessionId, controller);
this.sessionActiveRunMap.set(sessionId, true);
this.sessionStartTimeMap.set(sessionId, Date.now());
console.log(`[ABORT] Created AbortController for session ${sessionId}`);
return { controller, signal: controller.signal };
}
/**
* Check if a session has an active agent run
* If session is active but stale (超过 SESSION_STALE_TIMEOUT_MS), automatically clean up
* @param sessionId - Session ID
* @returns true if session is busy
*/
isSessionActive(sessionId) {
const isActive = this.sessionActiveRunMap.get(sessionId) || false;
if (isActive) {
// Check if the session has been active for too long
const startTime = this.sessionStartTimeMap.get(sessionId);
if (startTime) {
const elapsed = Date.now() - startTime;
if (elapsed > XiaoYiRuntime.SESSION_STALE_TIMEOUT_MS) {
// Session is stale, auto-cleanup and return false
console.log(`[CONCURRENT] Session ${sessionId} is stale (active for ${elapsed}ms), auto-cleaning`);
this.clearAbortControllerForSession(sessionId);
this.clearTaskIdForSession(sessionId);
this.clearSessionTimeout(sessionId);
this.sessionStartTimeMap.delete(sessionId);
return false;
}
}
}
return isActive;
}
/**
* Abort a session's agent run
* @param sessionId - Session ID
* @returns true if a controller was found and aborted, false otherwise
*/
abortSession(sessionId) {
const controller = this.sessionAbortControllerMap.get(sessionId);
if (controller) {
console.log(`[ABORT] Aborting session ${sessionId}`);
controller.abort();
this.sessionAbortControllerMap.delete(sessionId);
return true;
}
console.log(`[ABORT] No AbortController found for session ${sessionId}`);
return false;
}
/**
* Check if a session has been aborted
* @param sessionId - Session ID
* @returns true if the session's abort signal was triggered
*/
isSessionAborted(sessionId) {
const controller = this.sessionAbortControllerMap.get(sessionId);
return controller ? controller.signal.aborted : false;
}
/**
* Clear the AbortController for a session (call when agent completes successfully)
* @param sessionId - Session ID
*/
clearAbortControllerForSession(sessionId) {
const controller = this.sessionAbortControllerMap.get(sessionId);
if (controller) {
this.sessionAbortControllerMap.delete(sessionId);
console.log(`[ABORT] Cleared AbortController for session ${sessionId}`);
}
// Also clear the active run flag
this.sessionActiveRunMap.delete(sessionId);
// Clear the session start time
this.sessionStartTimeMap.delete(sessionId);
console.log(`[CONCURRENT] Session ${sessionId} marked as inactive`);
}
/**
* Clear all AbortControllers
*/
clearAllAbortControllers() {
this.sessionAbortControllerMap.clear();
console.log("[ABORT] All AbortControllers cleared");
}
// ==================== PUSH STATE MANAGEMENT HELPERS ====================
/**
* Generate a composite key for session+task combination
* This ensures each task has its own push state, even within the same session
*/
getPushStateKey(sessionId, taskId) {
return `${sessionId}:${taskId}`;
}
// ==================== END PUSH STATE MANAGEMENT HELPERS ====================
// ==================== 1-HOUR TASK TIMEOUT METHODS ====================
/**
* Set task timeout time (from configuration)
*/
setTaskTimeout(timeoutMs) {
this.taskTimeoutMs = timeoutMs;
console.log(`[TASK TIMEOUT] Task timeout set to ${timeoutMs}ms`);
}
/**
* Set a 1-hour task timeout timer for a session
* @returns timeout ID
*/
setTaskTimeoutForSession(sessionId, taskId, callback) {
this.clearTaskTimeoutForSession(sessionId);
const timeoutId = setTimeout(() => {
console.log(`[TASK TIMEOUT] ${this.taskTimeoutMs}ms timeout triggered for session ${sessionId}, task ${taskId}`);
callback(sessionId, taskId);
}, this.taskTimeoutMs);
this.sessionTaskTimeoutMap.set(sessionId, timeoutId);
console.log(`[TASK TIMEOUT] ${this.taskTimeoutMs}ms task timeout started for session ${sessionId}`);
return timeoutId;
}
/**
* Clear the task timeout timer for a session
*/
clearTaskTimeoutForSession(sessionId) {
const timeoutId = this.sessionTaskTimeoutMap.get(sessionId);
if (timeoutId) {
clearTimeout(timeoutId);
this.sessionTaskTimeoutMap.delete(sessionId);
console.log(`[TASK TIMEOUT] Timeout cleared for session ${sessionId}`);
}
}
/**
* Check if session+task is waiting for push notification
* @param sessionId - Session ID
* @param taskId - Task ID (optional, for per-task tracking)
*/
isSessionWaitingForPush(sessionId, taskId) {
const key = taskId ? this.getPushStateKey(sessionId, taskId) : sessionId;
return this.sessionPushPendingMap.get(key) === true;
}
/**
* Mark session+task as waiting for push notification
* @param sessionId - Session ID
* @param taskId - Task ID (optional, for per-task tracking)
*/
markSessionWaitingForPush(sessionId, taskId) {
const key = taskId ? this.getPushStateKey(sessionId, taskId) : sessionId;
this.sessionPushPendingMap.set(key, true);
const taskInfo = taskId ? `, task ${taskId}` : '';
console.log(`[PUSH] Session ${sessionId}${taskInfo} marked as waiting for push`);
}
/**
* Clear the waiting push state for a session+task
* @param sessionId - Session ID
* @param taskId - Task ID (optional, for per-task tracking)
*/
clearSessionWaitingForPush(sessionId, taskId) {
const key = taskId ? this.getPushStateKey(sessionId, taskId) : sessionId;
this.sessionPushPendingMap.delete(key);
const taskInfo = taskId ? `, task ${taskId}` : '';
console.log(`[PUSH] Session ${sessionId}${taskInfo} cleared from waiting for push`);
}
/**
* Clear all task timeout related state for a session
*/
clearTaskTimeoutState(sessionId) {
this.clearTaskTimeoutForSession(sessionId);
this.clearSessionWaitingForPush(sessionId);
console.log(`[TASK TIMEOUT] All timeout state cleared for session ${sessionId}`);
}
}
exports.XiaoYiRuntime = XiaoYiRuntime;
// Maximum time a session can be active before we consider it stale (5 minutes)
XiaoYiRuntime.SESSION_STALE_TIMEOUT_MS = 5 * 60 * 1000;
// Global runtime instance - use global object to survive module reloads
// CRITICAL: Use string key instead of Symbol to ensure consistency across module reloads
const GLOBAL_KEY = '__xiaoyi_runtime_instance__';
function getXiaoYiRuntime() {
const g = global;
if (!g[GLOBAL_KEY]) {
console.log("XiaoYi: Creating NEW runtime instance (global storage)");
g[GLOBAL_KEY] = new XiaoYiRuntime();
}
else {
console.log(`XiaoYi: Reusing EXISTING runtime instance: ${g[GLOBAL_KEY].getInstanceId()}`);
}
return g[GLOBAL_KEY];
}
function setXiaoYiRuntime(runtime) {
getXiaoYiRuntime().setPluginRuntime(runtime);
}
+207
View File
@@ -0,0 +1,207 @@
export interface A2ARequestMessage {
agentId: string;
jsonrpc: "2.0";
id: string;
method: "message/stream";
deviceId?: string;
conversationId?: string;
sessionId?: string;
params: {
id: string;
sessionId?: string;
agentLoginSessionId?: string;
message: {
kind?: string;
messageId?: string;
role: "user" | "agent";
parts: Array<{
kind: "text" | "file" | "data";
text?: string;
file?: {
name: string;
mimeType: string;
bytes?: string;
uri?: string;
};
data?: any;
}>;
};
};
}
export interface A2AResponseMessage {
sessionId: string;
messageId: string;
timestamp: number;
agentId: string;
sender: {
id: string;
name?: string;
type: "agent";
};
content: {
type: "text" | "image" | "audio" | "video" | "file";
text?: string;
mediaUrl?: string;
fileName?: string;
fileSize?: number;
mimeType?: string;
};
context?: {
conversationId?: string;
threadId?: string;
replyToMessageId?: string;
};
status: "success" | "error" | "processing";
error?: {
code: string;
message: string;
};
}
export interface A2AJsonRpcResponse {
jsonrpc: "2.0";
id: string;
result?: A2ATaskArtifactUpdateEvent | A2ATaskStatusUpdateEvent | A2AClearContextResult | A2ATasksCancelResult;
error?: {
code: number | string;
message: string;
};
}
export interface A2ATaskArtifactUpdateEvent {
taskId: string;
kind: "artifact-update";
append?: boolean;
lastChunk?: boolean;
final: boolean;
artifact: {
artifactId: string;
parts: Array<{
kind: "text" | "file" | "data";
text?: string;
file?: {
name: string;
mimeType: string;
bytes?: string;
uri?: string;
};
data?: any;
}>;
};
}
export interface A2ATaskStatusUpdateEvent {
taskId: string;
kind: "status-update";
final: boolean;
status: {
message: {
role: "agent";
parts: Array<{
kind: "text";
text: string;
}>;
};
state: "submitted" | "working" | "input-required" | "completed" | "canceled" | "failed" | "unknown";
};
}
export interface A2AClearContextResult {
status: {
state: "cleared" | "failed" | "unknown";
};
}
export interface A2ATasksCancelResult {
id: string;
status: {
state: "canceled" | "failed" | "unknown";
};
}
export interface A2AWebSocketMessage {
type: "message" | "heartbeat" | "auth" | "error";
data: A2ARequestMessage | A2AResponseMessage | any;
}
export type OutboundMessageType = "clawd_bot_init" | "agent_response" | "heartbeat";
export interface OutboundWebSocketMessage {
msgType: OutboundMessageType;
agentId: string;
sessionId?: string;
taskId?: string;
msgDetail?: string;
}
export interface A2AClearMessage {
agentId: string;
sessionId: string;
id: string;
action: "clear";
timestamp: number;
}
export interface A2ATasksCancelMessage {
agentId: string;
sessionId: string;
id: string;
action?: "tasks/cancel";
method?: "tasks/cancel";
taskId?: string;
jsonrpc?: "2.0";
conversationId?: string;
timestamp?: number;
}
export interface XiaoYiChannelConfig {
enabled: boolean;
wsUrl?: string;
wsUrl1?: string;
wsUrl2?: string;
ak: string;
sk: string;
agentId: string;
enableStreaming?: boolean;
apiId?: string;
pushId?: string;
taskTimeoutMs?: number;
/**
* Session cleanup timeout in milliseconds
* When user clears context, old sessions are cleaned up after this timeout
* Default: 1 hour (60 * 60 * 1000)
*/
sessionCleanupTimeoutMs?: number;
}
export interface AuthCredentials {
ak: string;
sk: string;
timestamp: number;
signature: string;
}
export interface WebSocketConnectionState {
connected: boolean;
authenticated: boolean;
lastHeartbeat: number;
lastAppHeartbeat: number;
reconnectAttempts: number;
maxReconnectAttempts: number;
}
export declare const DEFAULT_WS_URL_1 = "wss://hag.cloud.huawei.com/openclaw/v1/ws/link";
export declare const DEFAULT_WS_URL_2 = "wss://116.63.174.231/openclaw/v1/ws/link";
export interface InternalWebSocketConfig {
wsUrl1: string;
wsUrl2: string;
agentId: string;
ak: string;
sk: string;
enableStreaming?: boolean;
sessionCleanupTimeoutMs?: number;
}
export type ServerId = 'server1' | 'server2';
export interface ServerConnectionState {
connected: boolean;
ready: boolean;
lastHeartbeat: number;
reconnectAttempts: number;
}
/**
* Session cleanup state for delayed cleanup
*/
export interface SessionCleanupState {
sessionId: string;
serverId: ServerId;
markedForCleanupAt: number;
cleanupTimeoutId?: NodeJS.Timeout;
reason: 'user_cleared' | 'timeout' | 'error';
accumulatedText?: string;
}
+8
View File
@@ -0,0 +1,8 @@
"use strict";
// A2A Message Structure Types
// Based on: https://developer.huawei.com/consumer/cn/doc/service/message-stream-0000002505761434
Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_WS_URL_2 = exports.DEFAULT_WS_URL_1 = void 0;
// Dual server configuration
exports.DEFAULT_WS_URL_1 = "wss://hag.cloud.huawei.com/openclaw/v1/ws/link";
exports.DEFAULT_WS_URL_2 = "wss://116.63.174.231/openclaw/v1/ws/link";
+211
View File
@@ -0,0 +1,211 @@
import { EventEmitter } from "events";
import { A2AResponseMessage, WebSocketConnectionState, XiaoYiChannelConfig, ServerId, ServerConnectionState, SessionCleanupState } from "./types";
export declare class XiaoYiWebSocketManager extends EventEmitter {
private ws1;
private ws2;
private state1;
private state2;
private sessionServerMap;
private sessionCleanupStateMap;
private static readonly DEFAULT_CLEANUP_TIMEOUT_MS;
private auth;
private config;
private heartbeatTimeout1?;
private heartbeatTimeout2?;
private appHeartbeatInterval?;
private reconnectTimeout1?;
private reconnectTimeout2?;
private stableConnectionTimer1?;
private stableConnectionTimer2?;
private static readonly STABLE_CONNECTION_THRESHOLD;
private activeTasks;
constructor(config: XiaoYiChannelConfig);
/**
* Check if URL is wss + IP format (skip certificate verification)
*/
private isWssWithIp;
/**
* Resolve configuration with defaults and backward compatibility
*/
private resolveConfig;
/**
* Connect to both WebSocket servers
*/
connect(): Promise<void>;
/**
* Connect to server 1
*/
private connectToServer1;
/**
* Connect to server 2
*/
private connectToServer2;
/**
* Disconnect from all servers
*/
disconnect(): void;
/**
* Send init message to specific server
*/
private sendInitMessage;
/**
* Setup WebSocket event handlers for specific server
*/
private setupWebSocketHandlers;
/**
* Extract sessionId from message based on method type
* Different methods have sessionId in different locations:
* - message/stream: sessionId in params, fallback to top-level sessionId
* - tasks/cancel: sessionId at top level
* - clearContext: sessionId at top level
*/
private extractSessionId;
/**
* Handle incoming message from specific server
*/
private handleIncomingMessage;
/**
* Send A2A response message with automatic routing
*/
sendResponse(response: A2AResponseMessage, taskId: string, sessionId: string, isFinal?: boolean, append?: boolean): Promise<void>;
/**
* Send clear context response to specific server
*/
sendClearContextResponse(requestId: string, sessionId: string, success?: boolean, targetServer?: ServerId): Promise<void>;
/**
* Send status update (for intermediate status messages, e.g., timeout warnings)
* This uses "status-update" event type which keeps the conversation active
*/
sendStatusUpdate(taskId: string, sessionId: string, message: string, targetServer?: ServerId): Promise<void>;
/**
* Send PUSH message (主动推送) via HTTP API
*
* This is used when SubAgent completes execution and needs to push results to user
* independently of the original A2A request-response flow.
*
* Unlike sendResponse (which responds to a specific request via WebSocket), push messages are
* sent through HTTP API asynchronously.
*
* @param sessionId - User's session ID
* @param message - Message content to push
*
* Reference: 华为小艺推送消息 API
* TODO: 实现实际的推送消息发送逻辑
*/
sendPushMessage(sessionId: string, message: string): Promise<void>;
/**
* Send tasks cancel response to specific server
*/
sendTasksCancelResponse(requestId: string, sessionId: string, success?: boolean, targetServer?: ServerId): Promise<void>;
/**
* Handle clearContext method
*/
private handleClearContext;
/**
* Handle clear message (legacy format)
*/
private handleClearMessage;
/**
* Handle tasks/cancel message
*/
private handleTasksCancelMessage;
/**
* Convert A2AResponseMessage to JSON-RPC 2.0 format
*/
private convertToJsonRpcFormat;
/**
* Check if at least one server is ready
*/
isReady(): boolean;
/**
* Get combined connection state
*/
getState(): WebSocketConnectionState;
/**
* Get individual server states
*/
getServerStates(): {
server1: ServerConnectionState;
server2: ServerConnectionState;
};
/**
* Start protocol-level heartbeat for specific server
*/
private startProtocolHeartbeat;
/**
* Clear protocol heartbeat for specific server
*/
private clearProtocolHeartbeat;
/**
* Start application-level heartbeat (shared across both servers)
*/
private startAppHeartbeat;
/**
* Schedule reconnection for specific server
*/
private scheduleReconnect;
/**
* Clear all timers
*/
private clearTimers;
/**
* Schedule a connection stability check
* Only reset reconnect counter after connection has been stable for threshold time
*/
private scheduleStableConnectionCheck;
/**
* Clear the connection stability check timer
*/
private clearStableConnectionCheck;
/**
* Type guard for A2A request messages
* sessionId can be in params OR at top level (fallback)
*/
private isA2ARequestMessage;
/**
* Get active tasks
*/
getActiveTasks(): Map<string, any>;
/**
* Remove task from active tasks
*/
removeActiveTask(taskId: string): void;
/**
* Get server for a specific session
*/
getServerForSession(sessionId: string): ServerId | undefined;
/**
* Remove session mapping
*/
removeSession(sessionId: string): void;
/**
* Mark a session for delayed cleanup
* @param sessionId The session ID to mark for cleanup
* @param serverId The server ID associated with this session
* @param timeoutMs Timeout in milliseconds before forcing cleanup
*/
private markSessionForCleanup;
/**
* Force cleanup a session immediately
* @param sessionId The session ID to cleanup
*/
forceCleanupSession(sessionId: string): void;
/**
* Check if a session is pending cleanup
* @param sessionId The session ID to check
* @returns True if session is pending cleanup
*/
isSessionPendingCleanup(sessionId: string): boolean;
/**
* Get cleanup state for a session
* @param sessionId The session ID to check
* @returns Cleanup state if exists, undefined otherwise
*/
getSessionCleanupState(sessionId: string): SessionCleanupState | undefined;
/**
* Update accumulated text for a pending cleanup session
* @param sessionId The session ID
* @param text The accumulated text
*/
updateAccumulatedTextForCleanup(sessionId: string, text: string): void;
}
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
/**
* XiaoYi Media Handler - Downloads and saves media files locally
* Similar to clawdbot-feishu's media.ts approach
*/
type PluginRuntime = any;
export interface DownloadedMedia {
path: string;
contentType: string;
placeholder: string;
fileName?: string;
}
export interface MediaDownloadOptions {
maxBytes?: number;
timeoutMs?: number;
}
/**
* Check if a MIME type is an image
*/
export declare function isImageMimeType(mimeType: string | undefined): boolean;
/**
* Check if a MIME type is a PDF
*/
export declare function isPdfMimeType(mimeType: string | undefined): boolean;
/**
* Check if a MIME type is text-based
*/
export declare function isTextMimeType(mimeType: string | undefined): boolean;
/**
* Download and save media file to local disk
* This is the key function that follows clawdbot-feishu's approach
*/
export declare function downloadAndSaveMedia(runtime: PluginRuntime, uri: string, mimeType: string, fileName: string, options?: MediaDownloadOptions): Promise<DownloadedMedia>;
/**
* Download and save multiple media files
*/
export declare function downloadAndSaveMediaList(runtime: PluginRuntime, files: Array<{
uri: string;
mimeType: string;
name: string;
}>, options?: MediaDownloadOptions): Promise<DownloadedMedia[]>;
/**
* Build media payload for inbound context
* Similar to clawdbot-feishu's buildFeishuMediaPayload()
*/
export declare function buildXiaoYiMediaPayload(mediaList: DownloadedMedia[]): {
MediaPath?: string;
MediaType?: string;
MediaUrl?: string;
MediaPaths?: string[];
MediaUrls?: string[];
MediaTypes?: string[];
};
/**
* Extract text from downloaded file for including in message body
*/
export declare function extractTextFromFile(path: string, mimeType: string): Promise<string | null>;
/**
* Input image content type for AI processing
*/
export interface InputImageContent {
type: "image";
data: string;
mimeType: string;
}
/**
* Image download limits
*/
export interface ImageLimits {
maxBytes?: number;
timeoutMs?: number;
}
/**
* Extract image from URL and return base64 encoded data
*/
export declare function extractImageFromUrl(url: string, limits?: Partial<ImageLimits>): Promise<InputImageContent>;
/**
* Extract text content from URL
* Supports text-based files (txt, md, json, xml, csv, etc.)
*/
export declare function extractTextFromUrl(url: string, maxBytes?: number, timeoutMs?: number): Promise<string>;
export {};
+216
View File
@@ -0,0 +1,216 @@
"use strict";
/**
* XiaoYi Media Handler - Downloads and saves media files locally
* Similar to clawdbot-feishu's media.ts approach
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.isImageMimeType = isImageMimeType;
exports.isPdfMimeType = isPdfMimeType;
exports.isTextMimeType = isTextMimeType;
exports.downloadAndSaveMedia = downloadAndSaveMedia;
exports.downloadAndSaveMediaList = downloadAndSaveMediaList;
exports.buildXiaoYiMediaPayload = buildXiaoYiMediaPayload;
exports.extractTextFromFile = extractTextFromFile;
exports.extractImageFromUrl = extractImageFromUrl;
exports.extractTextFromUrl = extractTextFromUrl;
/**
* Download content from URL with validation
*/
async function fetchFromUrl(url, maxBytes, timeoutMs) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
signal: controller.signal,
headers: { "User-Agent": "XiaoYi-Channel/1.0" },
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// Check content-length header if available
const contentLength = response.headers.get("content-length");
if (contentLength) {
const size = parseInt(contentLength, 10);
if (size > maxBytes) {
throw new Error(`File too large: ${size} bytes (limit: ${maxBytes})`);
}
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.byteLength > maxBytes) {
throw new Error(`File too large: ${buffer.byteLength} bytes (limit: ${maxBytes})`);
}
// Detect MIME type
const contentType = response.headers.get("content-type");
const mimeType = contentType?.split(";")[0]?.trim() || "application/octet-stream";
return { buffer, mimeType };
}
finally {
clearTimeout(timeout);
}
}
/**
* Infer placeholder text based on MIME type
*/
function inferPlaceholder(mimeType) {
if (mimeType.startsWith("image/")) {
return "<media:image>";
}
else if (mimeType.startsWith("video/")) {
return "<media:video>";
}
else if (mimeType.startsWith("audio/")) {
return "<media:audio>";
}
else if (mimeType === "application/pdf") {
return "<media:document>";
}
else if (mimeType.startsWith("text/")) {
return "<media:text>";
}
else {
return "<media:document>";
}
}
/**
* Check if a MIME type is an image
*/
function isImageMimeType(mimeType) {
if (!mimeType)
return false;
const lower = mimeType.toLowerCase();
// Standard formats: image/jpeg, image/png, etc.
if (lower.startsWith("image/")) {
return true;
}
// Handle non-standard formats like "jpeg" instead of "image/jpeg"
// Extract subtype if format is "type/subtype", otherwise use whole string
const subtype = lower.includes("/") ? lower.split("/")[1] : lower;
const imageSubtypes = [
"jpeg", "jpg", "png", "gif", "webp", "bmp", "svg+xml", "svg"
];
return imageSubtypes.includes(subtype);
}
/**
* Check if a MIME type is a PDF
*/
function isPdfMimeType(mimeType) {
return mimeType?.toLowerCase() === "application/pdf" || false;
}
/**
* Check if a MIME type is text-based
*/
function isTextMimeType(mimeType) {
if (!mimeType)
return false;
const lower = mimeType.toLowerCase();
return (lower.startsWith("text/") ||
lower === "application/json" ||
lower === "application/xml");
}
/**
* Download and save media file to local disk
* This is the key function that follows clawdbot-feishu's approach
*/
async function downloadAndSaveMedia(runtime, uri, mimeType, fileName, options) {
const maxBytes = options?.maxBytes ?? 30000000; // 30MB default
const timeoutMs = options?.timeoutMs ?? 60000; // 60 seconds default
console.log(`[XiaoYi Media] Downloading: ${fileName} (${mimeType}) from ${uri}`);
// Download the file
const { buffer, mimeType: detectedMimeType } = await fetchFromUrl(uri, maxBytes, timeoutMs);
// Use detected MIME type if provided type is generic
const finalMimeType = mimeType === "application/octet-stream" ? detectedMimeType : mimeType;
// Save to local disk using OpenClaw's core.media API
// This is the critical step - saves file locally and returns path
const saved = await runtime.channel.media.saveMediaBuffer(buffer, finalMimeType, "inbound", maxBytes, fileName);
console.log(`[XiaoYi Media] Saved to: ${saved.path}`);
return {
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder(saved.contentType),
fileName,
};
}
/**
* Download and save multiple media files
*/
async function downloadAndSaveMediaList(runtime, files, options) {
const results = [];
for (const file of files) {
try {
const downloaded = await downloadAndSaveMedia(runtime, file.uri, file.mimeType, file.name, options);
results.push(downloaded);
}
catch (error) {
console.error(`[XiaoYi Media] Failed to download ${file.name}:`, error);
// Continue with other files
}
}
return results;
}
/**
* Build media payload for inbound context
* Similar to clawdbot-feishu's buildFeishuMediaPayload()
*/
function buildXiaoYiMediaPayload(mediaList) {
if (mediaList.length === 0) {
return {};
}
const first = mediaList[0];
const mediaPaths = mediaList.map((media) => media.path);
const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean);
return {
MediaPath: first?.path,
MediaType: first?.contentType,
MediaUrl: first?.path,
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
};
}
/**
* Extract text from downloaded file for including in message body
*/
async function extractTextFromFile(path, mimeType) {
// For now, just return null - Agent can read file directly from path
// This could be enhanced to extract text from specific file types
return null;
}
/**
* Extract image from URL and return base64 encoded data
*/
async function extractImageFromUrl(url, limits) {
const maxBytes = limits?.maxBytes ?? 10000000; // 10MB default
const timeoutMs = limits?.timeoutMs ?? 30000; // 30 seconds default
const { buffer, mimeType } = await fetchFromUrl(url, maxBytes, timeoutMs);
// Validate it's an image MIME type
if (!isImageMimeType(mimeType)) {
throw new Error(`Unsupported image type: ${mimeType}`);
}
return {
type: "image",
data: buffer.toString("base64"),
mimeType,
};
}
/**
* Extract text content from URL
* Supports text-based files (txt, md, json, xml, csv, etc.)
*/
async function extractTextFromUrl(url, maxBytes = 5000000, timeoutMs = 30000) {
const { buffer, mimeType } = await fetchFromUrl(url, maxBytes, timeoutMs);
// Check if it's a text-based MIME type
const textMimes = [
"text/plain",
"text/markdown",
"text/html",
"text/csv",
"application/json",
"application/xml",
"text/xml",
];
const isTextFile = textMimes.some(tm => mimeType.startsWith(tm) || mimeType === tm);
if (!isTextFile) {
throw new Error(`Unsupported text type: ${mimeType}`);
}
return buffer.toString("utf-8");
}
@@ -0,0 +1,9 @@
{
"id": "xiaoyi",
"channels": ["xiaoyi"],
"configSchema": {
"type": "object",
"additionalProperties": true,
"properties": {}
}
}
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
{
"name": "@ynhcj/xiaoyi",
"version": "2.5.6",
"description": "XiaoYi channel plugin for OpenClaw",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
},
"keywords": [
"openclaw",
"openclaw-plugin",
"xiaoyi",
"channel"
],
"author": "ynhcj",
"license": "MIT",
"openclaw": {
"extensions": ["xiaoyi.js"],
"channels": ["xiaoyi"],
"installDependencies": true,
"install": {
"npmSpec": "@ynhcj/xiaoyi@latest",
"localPath": ".",
"defaultChoice": "npm"
},
"channel": {
"id": "xiaoyi",
"label": "XiaoYi",
"selectionLabel": "XiaoYi (小艺)",
"docsPath": "/channels/xiaoyi",
"docsLabel": "xiaoyi",
"blurb": "小艺 A2A 协议支持,通过 WebSocket 连接。",
"order": 80,
"aliases": ["xy"]
}
},
"peerDependencies": {
"openclaw": "*"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"dependencies": {
"ws": "^8.16.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/ws": "^8.5.10",
"openclaw": "^2026.2.24",
"typescript": "^5.3.3"
},
"files": [
"dist",
"xiaoyi.js",
"openclaw.plugin.json",
"README.md"
]
}
+1
View File
@@ -0,0 +1 @@
module.exports = require('./dist/index.js');