Repository: wong2/weixin-agent-sdk
Branch: main
Commit: c05f68422ce7
Files: 57
Total size: 150.4 KB
Directory structure:
gitextract_v2sg2u_c/
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── package.json
├── packages/
│ ├── example-openai/
│ │ ├── main.ts
│ │ ├── package.json
│ │ ├── src/
│ │ │ └── openai-agent.ts
│ │ └── tsconfig.json
│ ├── sdk/
│ │ ├── .gitignore
│ │ ├── index.ts
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── agent/
│ │ │ │ └── interface.ts
│ │ │ ├── api/
│ │ │ │ ├── api.ts
│ │ │ │ ├── config-cache.ts
│ │ │ │ ├── session-guard.ts
│ │ │ │ └── types.ts
│ │ │ ├── auth/
│ │ │ │ ├── accounts.ts
│ │ │ │ ├── login-qr.ts
│ │ │ │ └── pairing.ts
│ │ │ ├── bot.ts
│ │ │ ├── cdn/
│ │ │ │ ├── aes-ecb.ts
│ │ │ │ ├── cdn-upload.ts
│ │ │ │ ├── cdn-url.ts
│ │ │ │ ├── pic-decrypt.ts
│ │ │ │ └── upload.ts
│ │ │ ├── media/
│ │ │ │ ├── media-download.ts
│ │ │ │ ├── mime.ts
│ │ │ │ └── silk-transcode.ts
│ │ │ ├── messaging/
│ │ │ │ ├── debug-mode.ts
│ │ │ │ ├── error-notice.ts
│ │ │ │ ├── inbound.ts
│ │ │ │ ├── process-message.ts
│ │ │ │ ├── send-media.ts
│ │ │ │ ├── send.ts
│ │ │ │ └── slash-commands.ts
│ │ │ ├── monitor/
│ │ │ │ └── monitor.ts
│ │ │ ├── storage/
│ │ │ │ ├── state-dir.ts
│ │ │ │ └── sync-buf.ts
│ │ │ ├── util/
│ │ │ │ ├── logger.ts
│ │ │ │ ├── random.ts
│ │ │ │ └── redact.ts
│ │ │ └── vendor.d.ts
│ │ ├── tsconfig.json
│ │ └── tsdown.config.ts
│ └── weixin-acp/
│ ├── .gitignore
│ ├── index.ts
│ ├── main.ts
│ ├── package.json
│ ├── src/
│ │ ├── acp-agent.ts
│ │ ├── acp-connection.ts
│ │ ├── content-converter.ts
│ │ ├── response-collector.ts
│ │ └── types.ts
│ ├── tsconfig.json
│ └── tsdown.config.ts
└── pnpm-workspace.yaml
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
node_modules
dist
.env
*.log
package-lock.json
================================================
FILE: CHANGELOG.md
================================================
# Changelog
## 0.5.0 (2026-04-19)
### weixin-agent-sdk
- **fix**: `start(agent)` 现在会立即返回 `Bot` 实例,并新增 `bot.wait()` 用于等待后台消息循环结束,修复主动发送示例在停止前拿不到 `bot` 的问题
## 0.4.0 (2026-04-05)
### weixin-agent-sdk
- **feat**: `start(agent)` 现在返回 `Bot` 实例,支持通过 `bot.sendMessage()` 主动向当前登录账号发送文本、图片、视频和文件
- **fix**: 会话过期时给出更明确的重新登录提示
- **docs**: 补充 `Bot.sendMessage()` 的使用说明和注意事项
## 0.5.0 (2026-03-24)
### weixin-acp
- **feat**: 启动 agent 时自动检测登录状态,未登录则自动触发扫码登录
- **feat**: 新增 `logout` 命令,支持清除已保存的登录凭证
- **fix**: 定期重发 typing 状态,防止超时
- **fix**: 改进 ACP tool-call 日志的 fallback 链
- **fix**: 修复 ACP 权限自动审批中的 optionId 字段
### weixin-agent-sdk
- **feat**: 新增 `isLoggedIn()` 函数,检查是否已登录
- **feat**: 新增 `logout()` 函数,清除所有账号凭证
## 0.4.0 (2026-03-24)
### weixin-acp
- **feat**: 内置 `claude-code` 和 `codex` 快捷命令,无需单独安装 `@zed-industries/claude-agent-acp` 和 `@zed-industries/codex-acp`
- **feat**: 添加 `/clear` 斜杠命令,支持重置对话会话
- **fix**: Ctrl+C 时立即中断长轮询请求,不再卡住等待超时
- **fix**: ACP 子进程退出时自动清除会话缓存
- **fix**: 改进二维码显示的中文提示,始终显示 URL 链接
## 0.2.0 (2026-03-23)
### weixin-acp
- **feat**: 支持 Windows 平台(修复 `child_process.spawn` 对特殊后缀的识别问题)
- **fix**: 修复 README 中 shell 示例的注释语法
### weixin-agent-sdk
- 首次发布到 npm
## 0.1.0 (2026-03-22)
- 初始发布
- **weixin-agent-sdk**: 微信 AI Agent 桥接 SDK,支持文本、图片、语音、视频、文件收发
- **weixin-acp**: ACP (Agent Client Protocol) 适配器,可接入任意 ACP 兼容 agent
- **example-openai**: 基于 OpenAI 的完整示例,支持多轮对话和图片输入
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2026 wong2
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.
================================================
FILE: README.md
================================================
# weixin-agent-sdk
> 本项目非微信官方项目,代码由 [@tencent-weixin/openclaw-weixin](https://npmx.dev/package/@tencent-weixin/openclaw-weixin) 改造而来,仅供学习交流使用。
微信 AI Agent 桥接框架 —— 通过简单的 Agent 接口,将任意 AI 后端接入微信。
## 项目结构
```
packages/
sdk/ weixin-agent-sdk —— 微信桥接 SDK
weixin-acp/ ACP (Agent Client Protocol) 适配器
example-openai/ 基于 OpenAI 的示例
```
## 通过 ACP 接入 Claude Code, Codex, kimi-cli 等 Agent
[ACP (Agent Client Protocol)](https://agentclientprotocol.com/) 是一个开放的 Agent 通信协议。如果你已有兼容 ACP 的 agent,可以直接通过 [`weixin-acp`](https://www.npmjs.com/package/weixin-acp) 接入微信,无需编写任何代码。
### Claude Code
```bash
npx weixin-acp claude-code
```
### Codex
```bash
npx weixin-acp codex
```
### 其它 ACP Agent
比如 kimi-cli:
```bash
npx weixin-acp start -- kimi acp
```
`--` 后面的部分就是你的 ACP agent 启动命令,`weixin-acp` 会自动以子进程方式启动它,通过 JSON-RPC over stdio 进行通信。
更多 ACP 兼容 agent 请参考 [ACP agent 列表](https://agentclientprotocol.com/get-started/agents)。
## 自定义 Agent
SDK 主要导出三样东西:
- **`Agent`** 接口 —— 实现它就能接入微信
- **`login()`** —— 扫码登录
- **`start(agent)`** —— 启动消息循环,立即返回可主动发消息的 `Bot`
### Agent 接口
```typescript
interface Agent {
chat(request: ChatRequest): Promise<ChatResponse>;
}
interface ChatRequest {
conversationId: string; // 用户标识,可用于维护多轮对话
text: string; // 文本内容
media?: { // 附件(图片/语音/视频/文件)
type: "image" | "audio" | "video" | "file";
filePath: string; // 本地文件路径(已下载解密)
mimeType: string;
fileName?: string;
};
}
interface ChatResponse {
text?: string; // 回复文本(支持 markdown,发送前自动转纯文本)
media?: { // 回复媒体
type: "image" | "video" | "file";
url: string; // 本地路径或 HTTPS URL
fileName?: string;
};
}
```
### 最简示例
```typescript
import { login, start, type Agent } from "weixin-agent-sdk";
const echo: Agent = {
async chat(req) {
return { text: `你说了: ${req.text}` };
},
};
await login();
const bot = start(echo);
await bot.wait();
```
### 完整示例(自己管理对话历史)
```typescript
import { login, start, type Agent } from "weixin-agent-sdk";
const conversations = new Map<string, string[]>();
const myAgent: Agent = {
async chat(req) {
const history = conversations.get(req.conversationId) ?? [];
history.push(req.text);
// 调用你的 AI 服务...
const reply = await callMyAI(history);
history.push(reply);
conversations.set(req.conversationId, history);
return { text: reply };
},
};
await login();
const bot = start(myAgent);
await bot.wait();
```
### 主动发送消息
`start()` 会立即返回 `Bot` 实例。`Bot` 提供了 `sendMessage()`,可以在收到微信消息之外,主动给当前登录用户发送内容;如果是 CLI/脚本程序,可以用 `bot.wait()` 等待消息循环结束。
```typescript
import { login, start, type Agent } from "weixin-agent-sdk";
const agent: Agent = {
async chat(req) {
if (req.text === "ping") {
return { text: "pong" };
}
return { text: `收到:${req.text}` };
},
};
await login();
const bot = start(agent);
setInterval(() => {
void bot.sendMessage("定时提醒:记得查看最新状态");
}, 60_000);
await bot.wait();
```
也可以主动发送完整的 `ChatResponse`,包括图片、视频或文件:
```typescript
await bot.sendMessage({
text: "这是最新报表",
media: {
type: "file",
url: "./reports/daily.pdf",
fileName: "daily.pdf",
},
});
```
注意事项:
- 主动发送依赖微信下发的 `context_token`
- 需要在 `start()` 运行期间,至少先收到过当前账号的一条入站消息
- `context_token` 有时效,可能是 24 小时;过期后需要再次收到新消息才能继续主动发送
### OpenAI 示例
`packages/example-openai/` 是一个完整的 OpenAI Agent 实现,支持多轮对话和图片输入:
```bash
pnpm install
# 扫码登录微信
pnpm run login -w packages/example-openai
# 启动 bot
OPENAI_API_KEY=sk-xxx pnpm run start -w packages/example-openai
```
支持的环境变量:
| 变量 | 必填 | 说明 |
|------|------|------|
| `OPENAI_API_KEY` | 是 | OpenAI API Key |
| `OPENAI_BASE_URL` | 否 | 自定义 API 地址(兼容 OpenAI 接口的第三方服务) |
| `OPENAI_MODEL` | 否 | 模型名称,默认 `gpt-5.4` |
| `SYSTEM_PROMPT` | 否 | 系统提示词 |
## 支持的消息类型
### 接收(微信 → Agent)
| 类型 | `media.type` | 说明 |
|------|-------------|------|
| 文本 | — | `request.text` 直接拿到文字 |
| 图片 | `image` | 自动从 CDN 下载解密,`filePath` 指向本地文件 |
| 语音 | `audio` | SILK 格式自动转 WAV(需安装 `silk-wasm`) |
| 视频 | `video` | 自动下载解密 |
| 文件 | `file` | 自动下载解密,保留原始文件名 |
| 引用消息 | — | 被引用的文本拼入 `request.text`,被引用的媒体作为 `media` 传入 |
| 语音转文字 | — | 微信侧转写的文字直接作为 `request.text` |
### 发送(Agent → 微信)
| 类型 | 用法 |
|------|------|
| 文本 | 返回 `{ text: "..." }` |
| 图片 | 返回 `{ media: { type: "image", url: "/path/to/img.png" } }` |
| 视频 | 返回 `{ media: { type: "video", url: "/path/to/video.mp4" } }` |
| 文件 | 返回 `{ media: { type: "file", url: "/path/to/doc.pdf" } }` |
| 文本 + 媒体 | `text` 和 `media` 同时返回,文本作为附带说明发送 |
| 远程图片 | `url` 填 HTTPS 链接,SDK 自动下载后上传到微信 CDN |
| 主动发送 | 通过 `const bot = start(agent)` 后调用 `bot.sendMessage(...)` |
## 内置斜杠命令
在微信中发送以下命令:
- `/echo <消息>` —— 直接回复(不经过 Agent),附带通道耗时统计
- `/toggle-debug` —— 开关 debug 模式,启用后每条回复追加全链路耗时
## 技术细节
- 使用 **长轮询** (`getUpdates`) 接收消息,无需公网服务器
- 媒体文件通过微信 CDN 中转,**AES-128-ECB** 加密传输
- 单账号模式:每次 `login` 覆盖之前的账号
- 断点续传:`get_updates_buf` 持久化到 `~/.openclaw/`,重启后从上次位置继续
- 会话过期自动重连(errcode -14 触发 1 小时冷却后恢复)
- Node.js >= 22
## Star History
<a href="https://www.star-history.com/?repos=wong2%2Fweixin-agent-sdk&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=wong2/weixin-agent-sdk&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=wong2/weixin-agent-sdk&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=wong2/weixin-agent-sdk&type=date&legend=top-left" />
</picture>
</a>
================================================
FILE: package.json
================================================
{
"private": true,
"scripts": {
"typecheck": "pnpm -r run typecheck"
}
}
================================================
FILE: packages/example-openai/main.ts
================================================
#!/usr/bin/env node
/**
* WeChat + OpenAI example.
*
* Usage:
* npx tsx main.ts login # QR-code login
* npx tsx main.ts start # Start bot
*
* Environment variables:
* OPENAI_API_KEY — Required
* OPENAI_BASE_URL — Optional: custom API base URL
* OPENAI_MODEL — Optional: model name (default: gpt-5.4)
* SYSTEM_PROMPT — Optional: system prompt for the agent
*/
import { login, start } from "weixin-agent-sdk";
import { OpenAIAgent } from "./src/openai-agent.js";
const command = process.argv[2];
async function main() {
switch (command) {
case "login": {
await login();
break;
}
case "start": {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
console.error("错误: 请设置 OPENAI_API_KEY 环境变量");
process.exit(1);
}
const agent = new OpenAIAgent({
apiKey,
baseURL: process.env.OPENAI_BASE_URL,
model: process.env.OPENAI_MODEL,
systemPrompt: process.env.SYSTEM_PROMPT,
});
// Graceful shutdown
const ac = new AbortController();
process.on("SIGINT", () => {
console.log("\n正在停止...");
ac.abort();
});
process.on("SIGTERM", () => ac.abort());
const bot = start(agent, { abortSignal: ac.signal });
await bot.wait();
break;
}
default:
console.log(`weixin-agent-openai — 微信 + OpenAI 示例
用法:
npx tsx main.ts login 扫码登录微信
npx tsx main.ts start 启动 bot
环境变量:
OPENAI_API_KEY OpenAI API Key (必填)
OPENAI_BASE_URL 自定义 API 地址
OPENAI_MODEL 模型名称 (默认 gpt-5.4)
SYSTEM_PROMPT 系统提示词`);
break;
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
================================================
FILE: packages/example-openai/package.json
================================================
{
"name": "example-openai",
"version": "0.1.0",
"private": true,
"description": "WeChat + OpenAI example using weixin-agent-sdk",
"type": "module",
"scripts": {
"login": "tsx main.ts login",
"start": "tsx main.ts start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"openai": "^4.0.0",
"weixin-agent-sdk": "workspace:*"
},
"devDependencies": {
"tsx": "^4.0.0",
"typescript": "^5.8.0"
}
}
================================================
FILE: packages/example-openai/src/openai-agent.ts
================================================
/**
* Example Agent implementation using the OpenAI Chat Completions API.
*
* Supports:
* - Multi-turn conversation (per-user message history)
* - Vision (image input via base64)
* - Configurable model, system prompt, and base URL
*/
import fs from "node:fs/promises";
import path from "node:path";
import OpenAI from "openai";
import type { Agent, ChatRequest, ChatResponse } from "weixin-agent-sdk";
export type OpenAIAgentOptions = {
apiKey: string;
/** Model name, defaults to "gpt-5.4". */
model?: string;
/** Custom base URL (for proxies or compatible APIs). */
baseURL?: string;
/** System prompt prepended to every conversation. */
systemPrompt?: string;
/** Max history messages to keep per conversation (default: 50). */
maxHistory?: number;
};
type Message = OpenAI.ChatCompletionMessageParam;
export class OpenAIAgent implements Agent {
private client: OpenAI;
private model: string;
private systemPrompt: string | undefined;
private maxHistory: number;
private conversations = new Map<string, Message[]>();
constructor(opts: OpenAIAgentOptions) {
this.client = new OpenAI({ apiKey: opts.apiKey, baseURL: opts.baseURL });
this.model = opts.model ?? "gpt-5.4";
this.systemPrompt = opts.systemPrompt;
this.maxHistory = opts.maxHistory ?? 50;
}
async chat(request: ChatRequest): Promise<ChatResponse> {
const history = this.conversations.get(request.conversationId) ?? [];
// Build user message content
const content: OpenAI.ChatCompletionContentPart[] = [];
if (request.text) {
content.push({ type: "text", text: request.text });
}
if (request.media?.type === "image") {
// Send image as base64 for vision models
const imageData = await fs.readFile(request.media.filePath);
const base64 = imageData.toString("base64");
const mimeType = request.media.mimeType || "image/jpeg";
content.push({
type: "image_url",
image_url: { url: `data:${mimeType};base64,${base64}` },
});
} else if (request.media) {
// Non-image media: describe as text attachment
const fileName =
request.media.fileName ?? path.basename(request.media.filePath);
content.push({
type: "text",
text: `[Attachment: ${request.media.type} — ${fileName}]`,
});
}
if (content.length === 0) {
return { text: "" };
}
const userMessage: Message = {
role: "user" as const,
content:
content.length === 1 && content[0].type === "text"
? content[0].text
: content,
};
history.push(userMessage);
// Build messages array with optional system prompt
const messages: Message[] = [];
if (this.systemPrompt) {
messages.push({ role: "system", content: this.systemPrompt });
}
messages.push(...history);
const response = await this.client.chat.completions.create({
model: this.model,
messages,
});
const reply = response.choices[0]?.message?.content ?? "";
history.push({ role: "assistant", content: reply });
// Trim history to prevent unbounded growth
if (history.length > this.maxHistory) {
history.splice(0, history.length - this.maxHistory);
}
this.conversations.set(request.conversationId, history);
return { text: reply };
}
}
================================================
FILE: packages/example-openai/tsconfig.json
================================================
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["main.ts", "src/**/*.ts", "../sdk/src/vendor.d.ts"],
"exclude": ["node_modules"]
}
================================================
FILE: packages/sdk/.gitignore
================================================
dist
================================================
FILE: packages/sdk/index.ts
================================================
export type { Agent, ChatRequest, ChatResponse } from "./src/agent/interface.js";
export { Bot, isLoggedIn, login, logout, start } from "./src/bot.js";
export type { LoginOptions, StartOptions } from "./src/bot.js";
================================================
FILE: packages/sdk/package.json
================================================
{
"name": "weixin-agent-sdk",
"version": "0.5.0",
"description": "WeChat ↔ AI Agent bridge SDK — connect any AI backend to WeChat via a simple Agent interface",
"license": "MIT",
"type": "module",
"main": "dist/index.mjs",
"types": "dist/index.d.mts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
}
},
"files": [
"dist"
],
"publishConfig": {
"registry": "https://registry.npmjs.org/"
},
"engines": {
"node": ">=22"
},
"dependencies": {
"qrcode-terminal": "0.12.0"
},
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch",
"prepublishOnly": "pnpm run build",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsdown": "^0.21.0",
"typescript": "^5.8.0"
}
}
================================================
FILE: packages/sdk/src/agent/interface.ts
================================================
/**
* Agent interface — any AI backend that can handle a chat message.
*
* Implement this interface to connect WeChat to your own AI service.
* The WeChat bridge calls `chat()` for each inbound message and sends
* the returned response back to the user.
*/
export interface Agent {
/** Process a single message and return a reply. */
chat(request: ChatRequest): Promise<ChatResponse>;
/** Clear/reset the session for a given conversation. */
clearSession?(conversationId: string): void;
}
export interface ChatRequest {
/** Conversation / user identifier. Use this to maintain per-user context. */
conversationId: string;
/** Text content of the message. */
text: string;
/** Attached media file (image, audio, video, or generic file). */
media?: {
type: "image" | "audio" | "video" | "file";
/** Local file path (already downloaded and decrypted). */
filePath: string;
/** MIME type, e.g. "image/jpeg", "audio/wav". */
mimeType: string;
/** Original filename (available for file attachments). */
fileName?: string;
};
}
export interface ChatResponse {
/** Reply text (may contain markdown — will be converted to plain text before sending). */
text?: string;
/** Reply media file. */
media?: {
type: "image" | "video" | "file";
/** Local file path or HTTPS URL. */
url: string;
/** Filename hint (for file attachments). */
fileName?: string;
};
}
================================================
FILE: packages/sdk/src/api/api.ts
================================================
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");
}
/** Build headers shared by both GET and POST requests. */
function buildCommonHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
const routeTag = loadConfigRouteTag();
if (routeTag) {
headers.SKRouteTag = routeTag;
}
return headers;
}
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(),
...buildCommonHeaders(),
};
if (opts.token?.trim()) {
headers.Authorization = `Bearer ${opts.token.trim()}`;
}
logger.debug(
`requestHeaders: ${JSON.stringify({ ...headers, Authorization: headers.Authorization ? "Bearer ***" : undefined })}`,
);
return headers;
}
/**
* GET fetch wrapper: send a GET request to a Weixin API endpoint with timeout + abort.
* Query parameters should already be encoded in `endpoint`.
* Returns the raw response text on success; throws on HTTP error or timeout.
*/
export async function apiGetFetch(params: {
baseUrl: string;
endpoint: string;
timeoutMs: number;
label: string;
}): Promise<string> {
const base = ensureTrailingSlash(params.baseUrl);
const url = new URL(params.endpoint, base);
const hdrs = buildCommonHeaders();
logger.debug(`GET ${redactUrl(url.toString())}`);
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), params.timeoutMs);
try {
const res = await fetch(url.toString(), {
method: "GET",
headers: hdrs,
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;
}
}
/**
* 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;
abortSignal?: AbortSignal;
}): 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);
// Forward external abort signal to our controller
const onAbort = () => controller.abort();
params.abortSignal?.addEventListener("abort", onAbort, { once: true });
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;
} finally {
params.abortSignal?.removeEventListener("abort", onAbort);
}
}
/**
* 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;
abortSignal?: AbortSignal;
},
): 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",
abortSignal: params.abortSignal,
});
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",
});
}
================================================
FILE: packages/sdk/src/api/config-cache.ts
================================================
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: "" };
}
}
================================================
FILE: packages/sdk/src/api/session-guard.ts
================================================
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();
}
================================================
FILE: packages/sdk/src/api/types.ts
================================================
/**
* 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;
/** 缩略图明文 MD5(IMAGE/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;
/** 完整上传 URL(服务端直接返回,无需客户端拼接) */
upload_full_url?: 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;
/** 完整下载 URL(服务端直接返回,无需客户端拼接) */
full_url?: string;
}
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;
}
================================================
FILE: packages/sdk/src/auth/accounts.ts
================================================
import fs from "node:fs";
import path from "node:path";
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";
/** Normalize an account ID to a filesystem-safe string. */
export function normalizeAccountId(raw: string): string {
return raw.trim().toLowerCase().replace(/[@.]/g, "-");
}
// ---------------------------------------------------------------------------
// 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 [];
}
}
/** Register accountId as the sole account in the persistent index. */
export function registerWeixinAccountId(accountId: string): void {
const dir = resolveWeixinStateDir();
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(resolveAccountIndexPath(), JSON.stringify([accountId], 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
}
}
/** Remove all account data files and clear the account index. */
export function clearAllWeixinAccounts(): void {
const ids = listIndexedWeixinAccountIds();
for (const id of ids) {
clearWeixinAccount(id);
}
try {
fs.writeFileSync(resolveAccountIndexPath(), "[]", "utf-8");
} catch {
// ignore
}
}
/**
* 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;
};
/** List accountIds from the index file (written at QR login). */
export function listWeixinAccountIds(): string[] {
return listIndexedWeixinAccountIds();
}
/** Resolve a weixin account by ID, reading stored credentials. */
export function resolveWeixinAccount(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 accountData = loadWeixinAccount(id);
const token = accountData?.token?.trim() || undefined;
const stateBaseUrl = accountData?.baseUrl?.trim() || "";
return {
accountId: id,
baseUrl: stateBaseUrl || DEFAULT_BASE_URL,
cdnBaseUrl: CDN_BASE_URL,
token,
enabled: true,
configured: Boolean(token),
};
}
================================================
FILE: packages/sdk/src/auth/login-qr.ts
================================================
import { randomUUID } from "node:crypto";
import { apiGetFetch } from "../api/api.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" | "scaned_but_redirect";
error?: string;
/** The current effective polling base URL; may be updated on IDC redirect. */
currentApiBaseUrl?: string;
};
const ACTIVE_LOGIN_TTL_MS = 5 * 60_000;
/** Client-side timeout for the get_bot_qrcode request. */
const GET_QRCODE_TIMEOUT_MS = 5_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";
/** Fixed API base URL for all QR code requests. */
const FIXED_BASE_URL = "https://ilinkai.weixin.qq.com";
const activeLogins = new Map<string, ActiveLogin>();
interface QRCodeResponse {
qrcode: string;
qrcode_img_content: string;
}
interface StatusResponse {
status: "wait" | "scaned" | "confirmed" | "expired" | "scaned_but_redirect";
bot_token?: string;
ilink_bot_id?: string;
baseurl?: string;
/** The user ID of the person who scanned the QR code. */
ilink_user_id?: string;
/** New host to redirect polling to when status is scaned_but_redirect. */
redirect_host?: 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> {
logger.info(`Fetching QR code from: ${apiBaseUrl} bot_type=${botType}`);
const rawText = await apiGetFetch({
baseUrl: apiBaseUrl,
endpoint: `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`,
timeoutMs: GET_QRCODE_TIMEOUT_MS,
label: "fetchQRCode",
});
return JSON.parse(rawText) as QRCodeResponse;
}
async function pollQRStatus(apiBaseUrl: string, qrcode: string): Promise<StatusResponse> {
logger.debug(`Long-poll QR status from: ${apiBaseUrl} qrcode=***`);
try {
const rawText = await apiGetFetch({
baseUrl: apiBaseUrl,
endpoint: `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`,
timeoutMs: QR_LONG_POLL_TIMEOUT_MS,
label: "pollQRStatus",
});
logger.debug(`pollQRStatus: body=${rawText.substring(0, 200)}`);
return JSON.parse(rawText) as StatusResponse;
} catch (err) {
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" };
}
// 网关超时(如 Cloudflare 524)或其他网络错误,视为等待状态继续轮询
logger.warn(`pollQRStatus: network/gateway error, will retry: ${String(err)}`);
return { status: "wait" };
}
}
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}`);
const qrResponse = await fetchQRCode(FIXED_BASE_URL, 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;
// Initialize the effective polling base URL; may be updated on IDC redirect.
activeLogin.currentApiBaseUrl = FIXED_BASE_URL;
logger.info("Starting to poll QR code status...");
while (Date.now() < deadline) {
try {
const currentBaseUrl = activeLogin.currentApiBaseUrl ?? FIXED_BASE_URL;
const statusResponse = await pollQRStatus(currentBaseUrl, 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(FIXED_BASE_URL, 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 });
process.stdout.write(`如果二维码未能成功展示,请用浏览器打开以下链接扫码:\n`);
process.stdout.write(`${qrResponse.qrcode_img_content}\n`);
} catch {
process.stdout.write(`二维码未加载成功,请用浏览器打开以下链接扫码:\n`);
process.stdout.write(`${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 "scaned_but_redirect": {
const redirectHost = statusResponse.redirect_host;
if (redirectHost) {
const newBaseUrl = `https://${redirectHost}`;
activeLogin.currentApiBaseUrl = newBaseUrl;
logger.info(`waitForWeixinLogin: IDC redirect, switching polling host to ${redirectHost}`);
} else {
logger.warn(`waitForWeixinLogin: received scaned_but_redirect but redirect_host is missing, continuing with current host`);
}
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: "登录超时,请重试。",
};
}
================================================
FILE: packages/sdk/src/auth/pairing.ts
================================================
import fs from "node:fs";
import path from "node:path";
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 [];
}
/**
* Register a user ID in the channel allowFrom store.
*/
export function registerUserInAllowFromStore(params: {
accountId: string;
userId: string;
}): { 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 });
let content: AllowFromFileContent = { version: 1, allowFrom: [] };
try {
if (fs.existsSync(filePath)) {
const raw = fs.readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw) as AllowFromFileContent;
if (Array.isArray(parsed.allowFrom)) {
content = parsed;
}
}
} catch {
// 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(
`registerUserInAllowFromStore: added userId=${trimmedUserId} accountId=${accountId} path=${filePath}`,
);
return { changed: true };
}
================================================
FILE: packages/sdk/src/bot.ts
================================================
import os from "node:os";
import path from "node:path";
import type { Agent, ChatResponse } from "./agent/interface.js";
import {
clearAllWeixinAccounts,
DEFAULT_BASE_URL,
listWeixinAccountIds,
loadWeixinAccount,
normalizeAccountId,
registerWeixinAccountId,
resolveWeixinAccount,
saveWeixinAccount,
} from "./auth/accounts.js";
import {
DEFAULT_ILINK_BOT_TYPE,
startWeixinLoginWithQr,
waitForWeixinLogin,
} from "./auth/login-qr.js";
import { downloadRemoteImageToTemp } from "./cdn/upload.js";
import { getContextToken } from "./messaging/inbound.js";
import { sendWeixinMediaFile } from "./messaging/send-media.js";
import { markdownToPlainText, sendMessageWeixin } from "./messaging/send.js";
import { monitorWeixinProvider } from "./monitor/monitor.js";
import { logger } from "./util/logger.js";
const MEDIA_TEMP_DIR = path.join(os.tmpdir(), "weixin-agent/media");
export type LoginOptions = {
/** Override the API base URL. */
baseUrl?: string;
/** Log callback (defaults to console.log). */
log?: (msg: string) => void;
};
export type StartOptions = {
/** Account ID to use. Auto-selects the first registered account if omitted. */
accountId?: string;
/** AbortSignal to stop the bot. */
abortSignal?: AbortSignal;
/** Log callback (defaults to console.log). */
log?: (msg: string) => void;
};
/**
* Interactive QR-code login. Prints the QR code to the terminal and waits
* for the user to scan it with WeChat.
*
* Returns the normalized account ID on success.
*/
export async function login(opts?: LoginOptions): Promise<string> {
const log = opts?.log ?? console.log;
const apiBaseUrl = opts?.baseUrl ?? DEFAULT_BASE_URL;
log("正在启动微信扫码登录...");
const startResult = await startWeixinLoginWithQr({
apiBaseUrl,
botType: DEFAULT_ILINK_BOT_TYPE,
});
if (!startResult.qrcodeUrl) {
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 {
log(`二维码链接: ${startResult.qrcodeUrl}`);
}
log("\n等待扫码...\n");
const waitResult = await waitForWeixinLogin({
sessionKey: startResult.sessionKey,
apiBaseUrl,
timeoutMs: 480_000,
botType: DEFAULT_ILINK_BOT_TYPE,
});
if (!waitResult.connected || !waitResult.botToken || !waitResult.accountId) {
throw new Error(waitResult.message);
}
const normalizedId = normalizeAccountId(waitResult.accountId);
saveWeixinAccount(normalizedId, {
token: waitResult.botToken,
baseUrl: waitResult.baseUrl,
userId: waitResult.userId,
});
registerWeixinAccountId(normalizedId);
log("\n✅ 与微信连接成功!");
return normalizedId;
}
/**
* Remove all stored WeChat account credentials.
*/
export function logout(opts?: { log?: (msg: string) => void }): void {
const log = opts?.log ?? console.log;
const ids = listWeixinAccountIds();
if (ids.length === 0) {
log("当前没有已登录的账号");
return;
}
clearAllWeixinAccounts();
log("✅ 已退出登录");
}
/**
* Check whether at least one WeChat account is logged in and configured.
*/
export function isLoggedIn(): boolean {
const ids = listWeixinAccountIds();
if (ids.length === 0) return false;
const account = resolveWeixinAccount(ids[0]);
return account.configured;
}
/**
* A running bot instance — provides proactive messaging capability.
*
* - `sendMessage(text)` — send a text message to the logged-in user.
* - `sendMessage(response)` — send a ChatResponse (text and/or media).
*/
export class Bot {
private readonly _accountId: string;
private readonly _baseUrl: string;
private readonly _cdnBaseUrl: string;
private readonly _token?: string;
private readonly _userId: string;
private readonly _monitorPromise: Promise<void>;
/** @internal */
constructor(params: {
accountId: string;
baseUrl: string;
cdnBaseUrl: string;
token?: string;
userId: string;
monitorPromise: Promise<void>;
}) {
this._accountId = params.accountId;
this._baseUrl = params.baseUrl;
this._cdnBaseUrl = params.cdnBaseUrl;
this._token = params.token;
this._userId = params.userId;
this._monitorPromise = params.monitorPromise;
this._monitorPromise.catch(() => {
// Errors are re-thrown to callers that explicitly wait for the bot.
});
}
/**
* Wait until the background WeChat monitor stops.
*
* This is useful for CLI programs that should keep running until the bot is
* aborted, and for surfacing unrecoverable monitor errors to the caller.
*/
async wait(): Promise<void> {
await this._monitorPromise;
}
/**
* Proactively send a message to the logged-in WeChat user.
*
* Accepts either a plain string (sent as text) or a full `ChatResponse`
* object (text and/or media).
*
* Requires at least one inbound message to have been received so that a
* valid `context_token` is cached (tokens are valid for ~24 hours).
*/
async sendMessage(message: string | ChatResponse): Promise<void> {
const response: ChatResponse =
typeof message === "string" ? { text: message } : message;
const contextToken = getContextToken(this._accountId, this._userId);
if (!contextToken) {
throw new Error(
"没有找到 context_token,需要在 start() 运行期间至少收到过一条消息",
);
}
const apiOpts = {
baseUrl: this._baseUrl,
token: this._token,
contextToken,
};
if (response.media) {
let filePath: string;
const mediaUrl = response.media.url;
if (mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://")) {
filePath = await downloadRemoteImageToTemp(
mediaUrl,
path.join(MEDIA_TEMP_DIR, "outbound"),
);
} else {
filePath = path.isAbsolute(mediaUrl) ? mediaUrl : path.resolve(mediaUrl);
}
await sendWeixinMediaFile({
filePath,
to: this._userId,
text: response.text ? markdownToPlainText(response.text) : "",
opts: apiOpts,
cdnBaseUrl: this._cdnBaseUrl,
});
return;
}
if (response.text) {
await sendMessageWeixin({
to: this._userId,
text: markdownToPlainText(response.text),
opts: apiOpts,
});
return;
}
throw new Error("消息必须包含 text 或 media");
}
}
/**
* Start the bot — long-polls for new messages and dispatches them to the agent.
*
* Returns a `Bot` instance immediately. Use `bot.wait()` when a CLI program
* should block until the background monitor stops.
*/
export function start(agent: Agent, opts?: StartOptions): Bot {
const log = opts?.log ?? console.log;
// Resolve account
let accountId = opts?.accountId;
if (!accountId) {
const ids = listWeixinAccountIds();
if (ids.length === 0) {
throw new Error("没有已登录的账号,请先运行 login");
}
accountId = ids[0];
if (ids.length > 1) {
log(`[weixin] 检测到多个账号,使用第一个: ${accountId}`);
}
}
const account = resolveWeixinAccount(accountId);
if (!account.configured) {
throw new Error(
`账号 ${accountId} 未配置 (缺少 token),请先运行 login`,
);
}
const accountData = loadWeixinAccount(account.accountId);
const userId = accountData?.userId;
if (!userId) {
throw new Error(
`账号 ${accountId} 没有关联的用户 ID,请重新运行 login`,
);
}
log(`[weixin] 启动 bot, account=${account.accountId}`);
const monitorPromise = monitorWeixinProvider({
baseUrl: account.baseUrl,
cdnBaseUrl: account.cdnBaseUrl,
token: account.token,
accountId: account.accountId,
agent,
abortSignal: opts?.abortSignal,
log,
});
return new Bot({
accountId: account.accountId,
baseUrl: account.baseUrl,
cdnBaseUrl: account.cdnBaseUrl,
token: account.token,
userId,
monitorPromise,
});
}
================================================
FILE: packages/sdk/src/cdn/aes-ecb.ts
================================================
/**
* 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;
}
================================================
FILE: packages/sdk/src/cdn/cdn-upload.ts
================================================
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;
/** From getUploadUrl.upload_full_url; POST target when set (takes precedence over uploadParam). */
uploadFullUrl?: string;
uploadParam?: string;
filekey: string;
cdnBaseUrl: string;
label: string;
aeskey: Buffer;
}): Promise<{ downloadParam: string }> {
const { buf, uploadFullUrl, uploadParam, filekey, cdnBaseUrl, label, aeskey } = params;
const ciphertext = encryptAesEcb(buf, aeskey);
const trimmedFull = uploadFullUrl?.trim();
let cdnUrl: string;
if (trimmedFull) {
cdnUrl = trimmedFull;
} else if (uploadParam) {
cdnUrl = buildCdnUploadUrl({ cdnBaseUrl, uploadParam, filekey });
} else {
throw new Error(`${label}: CDN upload URL missing (need upload_full_url or upload_param)`);
}
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 };
}
================================================
FILE: packages/sdk/src/cdn/cdn-url.ts
================================================
/**
* 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)}`;
}
================================================
FILE: packages/sdk/src/cdn/pic-decrypt.ts
================================================
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,
fullUrl?: string,
): Promise<Buffer> {
const key = parseAesKey(aesKeyBase64, label);
const url = fullUrl || 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,
fullUrl?: string,
): Promise<Buffer> {
const url = fullUrl || buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl);
logger.debug(`${label}: fetching url=${url}`);
return fetchCdnBytes(url, label);
}
================================================
FILE: packages/sdk/src/cdn/upload.ts
================================================
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 uploadFullUrl = uploadUrlResp.upload_full_url?.trim();
const uploadParam = uploadUrlResp.upload_param;
if (!uploadFullUrl && !uploadParam) {
logger.error(
`${label}: getUploadUrl returned no upload URL (need upload_full_url or upload_param), resp=${JSON.stringify(uploadUrlResp)}`,
);
throw new Error(`${label}: getUploadUrl returned no upload URL`);
}
const { downloadParam: downloadEncryptedQueryParam } = await uploadBufferToCdn({
buf: plaintext,
uploadFullUrl: uploadFullUrl || undefined,
uploadParam: uploadParam ?? undefined,
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",
});
}
================================================
FILE: packages/sdk/src/media/media-download.ts
================================================
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 && !img?.media?.full_url) 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"} full_url=${Boolean(img.media.full_url)}`,
);
try {
const buf = aesKeyBase64
? await downloadAndDecryptBuffer(
img.media.encrypt_query_param ?? "",
aesKeyBase64,
cdnBaseUrl,
`${label} image`,
img.media.full_url,
)
: await downloadPlainCdnBuffer(
img.media.encrypt_query_param ?? "",
cdnBaseUrl,
`${label} image-plain`,
img.media.full_url,
);
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?.full_url) || !voice?.media?.aes_key)
return result;
try {
const silkBuf = await downloadAndDecryptBuffer(
voice.media.encrypt_query_param ?? "",
voice.media.aes_key,
cdnBaseUrl,
`${label} voice`,
voice.media.full_url,
);
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?.full_url) || !fileItem?.media?.aes_key)
return result;
try {
const buf = await downloadAndDecryptBuffer(
fileItem.media.encrypt_query_param ?? "",
fileItem.media.aes_key,
cdnBaseUrl,
`${label} file`,
fileItem.media.full_url,
);
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?.full_url) || !videoItem?.media?.aes_key)
return result;
try {
const buf = await downloadAndDecryptBuffer(
videoItem.media.encrypt_query_param ?? "",
videoItem.media.aes_key,
cdnBaseUrl,
`${label} video`,
videoItem.media.full_url,
);
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;
}
================================================
FILE: packages/sdk/src/media/mime.ts
================================================
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";
}
================================================
FILE: packages/sdk/src/media/silk-transcode.ts
================================================
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;
}
}
================================================
FILE: packages/sdk/src/messaging/debug-mode.ts
================================================
/**
* 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
}
}
================================================
FILE: packages/sdk/src/messaging/error-notice.ts
================================================
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)}`);
}
}
================================================
FILE: packages/sdk/src/messaging/inbound.ts
================================================
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
);
}
export 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;
}
================================================
FILE: packages/sdk/src/messaging/process-message.ts
================================================
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { Agent, ChatRequest } from "../agent/interface.js";
import { sendTyping } from "../api/api.js";
import type { WeixinMessage, MessageItem } from "../api/types.js";
import { MessageItemType, TypingStatus } from "../api/types.js";
import { downloadRemoteImageToTemp } from "../cdn/upload.js";
import { downloadMediaFromItem } from "../media/media-download.js";
import { getExtensionFromMime } from "../media/mime.js";
import { logger } from "../util/logger.js";
import { setContextToken, bodyFromItemList, isMediaItem } from "./inbound.js";
import { sendWeixinErrorNotice } from "./error-notice.js";
import { sendWeixinMediaFile } from "./send-media.js";
import { markdownToPlainText, sendMessageWeixin } from "./send.js";
import { handleSlashCommand } from "./slash-commands.js";
const MEDIA_TEMP_DIR = path.join(os.tmpdir(), "weixin-agent/media");
/** Save a buffer to a temporary file, returning the file path. */
async function saveMediaBuffer(
buffer: Buffer,
contentType?: string,
subdir?: string,
_maxBytes?: number,
originalFilename?: string,
): Promise<{ path: string }> {
const dir = path.join(MEDIA_TEMP_DIR, subdir ?? "");
await fs.mkdir(dir, { recursive: true });
let ext = ".bin";
if (originalFilename) {
ext = path.extname(originalFilename) || ".bin";
} else if (contentType) {
ext = getExtensionFromMime(contentType);
}
const name = `${Date.now()}-${crypto.randomBytes(4).toString("hex")}${ext}`;
const filePath = path.join(dir, name);
await fs.writeFile(filePath, buffer);
return { path: filePath };
}
/** Dependencies for processOneMessage. */
export type ProcessMessageDeps = {
accountId: string;
agent: Agent;
baseUrl: string;
cdnBaseUrl: string;
token?: string;
typingTicket?: string;
log: (msg: string) => void;
errLog: (msg: string) => void;
};
/** Extract raw text from item_list (for slash command detection). */
function extractTextBody(itemList?: 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 "";
}
/** Find the first downloadable media item from a message. */
function findMediaItem(itemList?: MessageItem[]): MessageItem | undefined {
if (!itemList?.length) return undefined;
const hasDownloadableMedia = (m?: { encrypt_query_param?: string; full_url?: string }) =>
m?.encrypt_query_param || m?.full_url;
// Direct media: IMAGE > VIDEO > FILE > VOICE (skip voice with transcription)
const direct =
itemList.find(
(i) => i.type === MessageItemType.IMAGE && hasDownloadableMedia(i.image_item?.media),
) ??
itemList.find(
(i) => i.type === MessageItemType.VIDEO && hasDownloadableMedia(i.video_item?.media),
) ??
itemList.find(
(i) => i.type === MessageItemType.FILE && hasDownloadableMedia(i.file_item?.media),
) ??
itemList.find(
(i) =>
i.type === MessageItemType.VOICE &&
hasDownloadableMedia(i.voice_item?.media) &&
!i.voice_item?.text,
);
if (direct) return direct;
// Quoted media: check ref_msg
const refItem = itemList.find(
(i) =>
i.type === MessageItemType.TEXT &&
i.ref_msg?.message_item &&
isMediaItem(i.ref_msg.message_item),
);
return refItem?.ref_msg?.message_item ?? undefined;
}
/**
* Process a single inbound message:
* slash command check → download media → call agent → send reply.
*/
export async function processOneMessage(
full: WeixinMessage,
deps: ProcessMessageDeps,
): Promise<void> {
const receivedAt = Date.now();
const textBody = extractTextBody(full.item_list);
// --- Slash commands ---
if (textBody.startsWith("/")) {
const conversationId = full.from_user_id ?? "";
const slashResult = await handleSlashCommand(
textBody,
{
to: conversationId,
contextToken: full.context_token,
baseUrl: deps.baseUrl,
token: deps.token,
accountId: deps.accountId,
log: deps.log,
errLog: deps.errLog,
onClear: () => deps.agent.clearSession?.(conversationId),
},
receivedAt,
full.create_time_ms,
);
if (slashResult.handled) return;
}
// --- Store context token ---
const contextToken = full.context_token;
if (contextToken) {
setContextToken(deps.accountId, full.from_user_id ?? "", contextToken);
}
// --- Download media ---
let media: ChatRequest["media"];
const mediaItem = findMediaItem(full.item_list);
if (mediaItem) {
try {
const downloaded = await downloadMediaFromItem(mediaItem, {
cdnBaseUrl: deps.cdnBaseUrl,
saveMedia: saveMediaBuffer,
log: deps.log,
errLog: deps.errLog,
label: "inbound",
});
if (downloaded.decryptedPicPath) {
media = { type: "image", filePath: downloaded.decryptedPicPath, mimeType: "image/*" };
} else if (downloaded.decryptedVideoPath) {
media = { type: "video", filePath: downloaded.decryptedVideoPath, mimeType: "video/mp4" };
} else if (downloaded.decryptedFilePath) {
media = {
type: "file",
filePath: downloaded.decryptedFilePath,
mimeType: downloaded.fileMediaType ?? "application/octet-stream",
};
} else if (downloaded.decryptedVoicePath) {
media = {
type: "audio",
filePath: downloaded.decryptedVoicePath,
mimeType: downloaded.voiceMediaType ?? "audio/wav",
};
}
} catch (err) {
logger.error(`media download failed: ${String(err)}`);
}
}
// --- Build ChatRequest ---
const request: ChatRequest = {
conversationId: full.from_user_id ?? "",
text: bodyFromItemList(full.item_list),
media,
};
// --- Typing indicator (start + periodic refresh) ---
const to = full.from_user_id ?? "";
let typingTimer: ReturnType<typeof setInterval> | undefined;
const startTyping = () => {
if (!deps.typingTicket) return;
sendTyping({
baseUrl: deps.baseUrl,
token: deps.token,
body: {
ilink_user_id: to,
typing_ticket: deps.typingTicket,
status: TypingStatus.TYPING,
},
}).catch(() => {});
};
if (deps.typingTicket) {
startTyping();
typingTimer = setInterval(startTyping, 10_000);
}
// --- Call agent & send reply ---
try {
const response = await deps.agent.chat(request);
if (response.media) {
let filePath: string;
const mediaUrl = response.media.url;
if (mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://")) {
filePath = await downloadRemoteImageToTemp(
mediaUrl,
path.join(MEDIA_TEMP_DIR, "outbound"),
);
} else {
filePath = path.isAbsolute(mediaUrl) ? mediaUrl : path.resolve(mediaUrl);
}
await sendWeixinMediaFile({
filePath,
to,
text: response.text ? markdownToPlainText(response.text) : "",
opts: { baseUrl: deps.baseUrl, token: deps.token, contextToken },
cdnBaseUrl: deps.cdnBaseUrl,
});
} else if (response.text) {
await sendMessageWeixin({
to,
text: markdownToPlainText(response.text),
opts: { baseUrl: deps.baseUrl, token: deps.token, contextToken },
});
}
} catch (err) {
logger.error(`processOneMessage: agent or send failed: ${err instanceof Error ? err.stack ?? err.message : JSON.stringify(err)}`);
void sendWeixinErrorNotice({
to,
contextToken,
message: `⚠️ 处理消息失败:${err instanceof Error ? err.message : JSON.stringify(err)}`,
baseUrl: deps.baseUrl,
token: deps.token,
errLog: deps.errLog,
});
} finally {
// --- Typing indicator (cancel) ---
if (typingTimer) clearInterval(typingTimer);
if (deps.typingTicket) {
sendTyping({
baseUrl: deps.baseUrl,
token: deps.token,
body: {
ilink_user_id: to,
typing_ticket: deps.typingTicket,
status: TypingStatus.CANCEL,
},
}).catch(() => {});
}
}
}
================================================
FILE: packages/sdk/src/messaging/send-media.ts
================================================
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 });
}
================================================
FILE: packages/sdk/src/messaging/send.ts
================================================
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";
export 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(" "),
);
// Strip inline markdown formatting
result = result
.replace(/\*\*(.+?)\*\*/g, "$1")
.replace(/\*(.+?)\*/g, "$1")
.replace(/__(.+?)__/g, "$1")
.replace(/_(.+?)_/g, "$1")
.replace(/~~(.+?)~~/g, "$1")
.replace(/`(.+?)`/g, "$1");
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 text payload. */
function buildSendMessageReq(params: {
to: string;
contextToken?: string;
text: string;
clientId: string;
}): SendMessageReq {
const { to, contextToken, text, clientId } = params;
return buildTextMessageReq({ to, 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,
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" });
}
================================================
FILE: packages/sdk/src/messaging/slash-commands.ts
================================================
/**
* Weixin 斜杠指令处理模块
*
* 支持的指令:
* - /echo <message> 直接回复消息(不经过 AI),并附带通道耗时统计
* - /toggle-debug 开关 debug 模式,启用后每条 AI 回复追加全链路耗时
* - /clear 清除当前会话,重新开始对话
*/
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;
/** Called when /clear is invoked to reset the agent session. */
onClear?: () => 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 };
}
case "/clear": {
ctx.onClear?.();
await sendReply(ctx, "✅ 会话已清除,重新开始对话");
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 };
}
}
================================================
FILE: packages/sdk/src/monitor/monitor.ts
================================================
import type { Agent } from "../agent/interface.js";
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 { getSyncBufFilePath, loadGetUpdatesBuf, saveGetUpdatesBuf } from "../storage/sync-buf.js";
import { 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;
agent: Agent;
abortSignal?: AbortSignal;
longPollTimeoutMs?: number;
log?: (msg: string) => void;
};
/**
* Long-poll loop: getUpdates → process message → call agent → send reply.
* Runs until aborted.
*/
export async function monitorWeixinProvider(opts: MonitorWeixinOpts): Promise<void> {
const {
baseUrl,
cdnBaseUrl,
token,
accountId,
agent,
abortSignal,
longPollTimeoutMs,
} = opts;
const log = opts.log ?? ((msg: string) => console.log(msg));
const errLog = (msg: string) => {
log(msg);
logger.error(msg);
};
const aLog = logger.withAccount(accountId);
log(`[weixin] monitor started (${baseUrl}, account=${accountId})`);
aLog.info(`Monitor started: baseUrl=${baseUrl}`);
const syncFilePath = getSyncBufFilePath(accountId);
const previousGetUpdatesBuf = loadGetUpdatesBuf(syncFilePath);
let getUpdatesBuf = previousGetUpdatesBuf ?? "";
if (previousGetUpdatesBuf) {
log(`[weixin] resuming from previous sync buf (${getUpdatesBuf.length} bytes)`);
} else {
log(`[weixin] no previous sync buf, starting fresh`);
}
const configManager = new WeixinConfigManager({ baseUrl, token }, log);
let nextTimeoutMs = longPollTimeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
let consecutiveFailures = 0;
while (!abortSignal?.aborted) {
try {
const resp = await getUpdates({
baseUrl,
token,
get_updates_buf: getUpdatesBuf,
timeoutMs: nextTimeoutMs,
abortSignal,
});
if (resp.longpolling_timeout_ms != null && resp.longpolling_timeout_ms > 0) {
nextTimeoutMs = resp.longpolling_timeout_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] session expired (errcode ${SESSION_EXPIRED_ERRCODE}), pausing for ${Math.ceil(pauseMs / 60_000)} min. Please run \`npx weixin-acp login\` to re-login.`,
);
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})`,
);
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
errLog(`[weixin] ${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;
if (resp.get_updates_buf != null && resp.get_updates_buf !== "") {
saveGetUpdatesBuf(syncFilePath, resp.get_updates_buf);
getUpdatesBuf = resp.get_updates_buf;
}
const list = resp.msgs ?? [];
for (const full of list) {
aLog.info(
`inbound: from=${full.from_user_id} types=${full.item_list?.map((i) => i.type).join(",") ?? "none"}`,
);
const fromUserId = full.from_user_id ?? "";
const cachedConfig = await configManager.getForUser(fromUserId, full.context_token);
await processOneMessage(full, {
accountId,
agent,
baseUrl,
cdnBaseUrl,
token,
typingTicket: cachedConfig.typingTicket,
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)}`,
);
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
consecutiveFailures = 0;
await sleep(BACKOFF_DELAY_MS, abortSignal);
} else {
await sleep(RETRY_DELAY_MS, 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 },
);
});
}
================================================
FILE: packages/sdk/src/storage/state-dir.ts
================================================
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")
);
}
================================================
FILE: packages/sdk/src/storage/sync-buf.ts
================================================
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");
}
================================================
FILE: packages/sdk/src/util/logger.ts
================================================
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:
* <tmpdir>/openclaw/openclaw-YYYY-MM-DD.log
* Same file and format used by all other channels.
*/
const MAIN_LOG_DIR = path.join(os.tmpdir(), "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();
================================================
FILE: packages/sdk/src/util/random.ts
================================================
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}`;
}
================================================
FILE: packages/sdk/src/util/redact.ts
================================================
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);
}
}
================================================
FILE: packages/sdk/src/vendor.d.ts
================================================
declare module "silk-wasm" {
export function decode(
input: Buffer,
sampleRate: number,
): Promise<{ data: Uint8Array; duration: number }>;
}
declare module "qrcode-terminal" {
const qrcodeTerminal: {
generate(
text: string,
options?: { small?: boolean },
callback?: (qr: string) => void,
): void;
};
export default qrcodeTerminal;
}
================================================
FILE: packages/sdk/tsconfig.json
================================================
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true
},
"include": ["index.ts", "src/**/*.ts"],
"exclude": ["node_modules"]
}
================================================
FILE: packages/sdk/tsdown.config.ts
================================================
import { defineConfig } from "tsdown";
export default defineConfig({
entry: ["./index.ts"],
dts: true,
});
================================================
FILE: packages/weixin-acp/.gitignore
================================================
dist
================================================
FILE: packages/weixin-acp/index.ts
================================================
export { AcpAgent } from "./src/acp-agent.js";
export type { AcpAgentOptions } from "./src/types.js";
================================================
FILE: packages/weixin-acp/main.ts
================================================
#!/usr/bin/env node
/**
* WeChat + ACP (Agent Client Protocol) adapter.
*
* Usage:
* npx weixin-acp login # QR-code login
* npx weixin-acp claude-code # Start with Claude Code
* npx weixin-acp codex # Start with Codex
* npx weixin-acp start -- <command> [args...] # Start with custom agent
*
* Examples:
* npx weixin-acp start -- node ./my-agent.js
*/
import { isLoggedIn, login, logout, start } from "weixin-agent-sdk";
import { AcpAgent } from "./src/acp-agent.js";
/** Built-in agent shortcuts */
const BUILTIN_AGENTS: Record<string, { command: string }> = {
"claude-code": { command: "claude-agent-acp" },
codex: { command: "codex-acp" },
};
const command = process.argv[2];
async function ensureLoggedIn() {
if (!isLoggedIn()) {
console.log("未检测到登录信息,请先扫码登录微信\n");
await login();
}
}
async function startAgent(acpCommand: string, acpArgs: string[] = []) {
await ensureLoggedIn();
const agent = new AcpAgent({ command: acpCommand, args: acpArgs });
const ac = new AbortController();
process.on("SIGINT", () => {
console.log("\n正在停止...");
agent.dispose();
ac.abort();
});
process.on("SIGTERM", () => {
agent.dispose();
ac.abort();
});
return start(agent, { abortSignal: ac.signal });
}
async function main() {
if (command === "login") {
await login();
return;
}
if (command === "logout") {
logout();
return;
}
if (command === "start") {
const ddIndex = process.argv.indexOf("--");
if (ddIndex === -1 || ddIndex + 1 >= process.argv.length) {
console.error("错误: 请在 -- 后指定 ACP agent 启动命令");
console.error("示例: npx weixin-acp start -- codex-acp");
process.exit(1);
}
const [acpCommand, ...acpArgs] = process.argv.slice(ddIndex + 1);
const bot = await startAgent(acpCommand, acpArgs);
await bot.wait();
return;
}
if (command && command in BUILTIN_AGENTS) {
const { command: acpCommand } = BUILTIN_AGENTS[command];
const bot = await startAgent(acpCommand);
await bot.wait();
return;
}
console.log(`weixin-acp — 微信 + ACP 适配器
用法:
npx weixin-acp login 扫码登录微信
npx weixin-acp logout 退出登录
npx weixin-acp claude-code 使用 Claude Code
npx weixin-acp codex 使用 Codex
npx weixin-acp start -- <command> [args...] 使用自定义 agent
示例:
npx weixin-acp start -- node ./my-agent.js`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
================================================
FILE: packages/weixin-acp/package.json
================================================
{
"name": "weixin-acp",
"version": "0.6.0",
"description": "WeChat + ACP (Agent Client Protocol) adapter — connect any ACP agent to WeChat",
"license": "MIT",
"type": "module",
"bin": {
"weixin-acp": "dist/main.mjs"
},
"main": "dist/index.mjs",
"types": "dist/index.d.mts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
}
},
"files": [
"dist"
],
"publishConfig": {
"registry": "https://registry.npmjs.org/"
},
"engines": {
"node": ">=22"
},
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "^0.31.4",
"@agentclientprotocol/codex-acp": "^0.0.38",
"@agentclientprotocol/sdk": "^0.21.0",
"cross-spawn": "^7.0.6",
"weixin-agent-sdk": "workspace:*"
},
"scripts": {
"build": "tsdown",
"prepublishOnly": "pnpm run build",
"login": "tsx main.ts login",
"start": "tsx main.ts start",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/cross-spawn": "^6.0.6",
"@types/node": "^22.0.0",
"tsdown": "^0.21.7",
"tsx": "^4.0.0",
"typescript": "^5.8.0"
}
}
================================================
FILE: packages/weixin-acp/src/acp-agent.ts
================================================
import type { Agent, ChatRequest, ChatResponse } from "weixin-agent-sdk";
import type { SessionId } from "@agentclientprotocol/sdk";
import type { AcpAgentOptions } from "./types.js";
import { AcpConnection } from "./acp-connection.js";
import { convertRequestToContentBlocks } from "./content-converter.js";
import { ResponseCollector } from "./response-collector.js";
function log(msg: string) {
console.log(`[acp] ${msg}`);
}
/**
* Agent adapter that bridges ACP (Agent Client Protocol) agents
* to the weixin-agent-sdk Agent interface.
*/
export class AcpAgent implements Agent {
private connection: AcpConnection;
private sessions = new Map<string, SessionId>();
private options: AcpAgentOptions;
constructor(options: AcpAgentOptions) {
this.options = options;
this.connection = new AcpConnection(options, () => {
log("subprocess exited, clearing session cache");
this.sessions.clear();
});
}
async chat(request: ChatRequest): Promise<ChatResponse> {
const conn = await this.connection.ensureReady();
// Get or create an ACP session for this conversation
const sessionId = await this.getOrCreateSession(request.conversationId, conn);
// Convert the ChatRequest to ACP ContentBlock[]
const blocks = await convertRequestToContentBlocks(request);
if (blocks.length === 0) {
return { text: "" };
}
// Register a collector, send the prompt, then gather the response
const preview = request.text?.slice(0, 50) || (request.media ? `[${request.media.type}]` : "");
log(`prompt: "${preview}" (session=${sessionId})`);
const collector = new ResponseCollector();
this.connection.registerCollector(sessionId, collector);
try {
await conn.prompt({ sessionId, prompt: blocks });
} finally {
this.connection.unregisterCollector(sessionId);
}
const response = await collector.toResponse();
log(`response: ${response.text?.slice(0, 80) ?? "[no text]"}${response.media ? " +media" : ""}`);
return response;
}
private async getOrCreateSession(
conversationId: string,
conn: Awaited<ReturnType<AcpConnection["ensureReady"]>>,
): Promise<SessionId> {
const existing = this.sessions.get(conversationId);
if (existing) return existing;
log(`creating new session for conversation=${conversationId}`);
const res = await conn.newSession({
cwd: this.options.cwd ?? process.cwd(),
mcpServers: [],
});
log(`session created: ${res.sessionId}`);
this.sessions.set(conversationId, res.sessionId);
return res.sessionId;
}
/**
* Clear/reset the session for a given conversation.
* The next message will automatically create a fresh session.
*/
clearSession(conversationId: string): void {
const sessionId = this.sessions.get(conversationId);
if (sessionId) {
log(`clearing session for conversation=${conversationId} (session=${sessionId})`);
this.connection.unregisterCollector(sessionId);
this.sessions.delete(conversationId);
}
}
/**
* Kill the ACP subprocess and clean up all sessions.
*/
dispose(): void {
this.sessions.clear();
this.connection.dispose();
}
}
================================================
FILE: packages/weixin-acp/src/acp-connection.ts
================================================
import type { ChildProcess } from "node:child_process";
import spawn from "cross-spawn";
import { Readable, Writable } from "node:stream";
import {
ClientSideConnection,
ndJsonStream,
PROTOCOL_VERSION,
} from "@agentclientprotocol/sdk";
import type { SessionId } from "@agentclientprotocol/sdk";
import type { AcpAgentOptions } from "./types.js";
import { ResponseCollector } from "./response-collector.js";
function log(msg: string) {
console.log(`[acp] ${msg}`);
}
function describeToolCall(update: {
title?: string | null;
kind?: string | null;
toolCallId?: string;
}): string {
return update.title ?? update.kind ?? update.toolCallId ?? "tool";
}
/**
* Manages the ACP agent subprocess and ClientSideConnection lifecycle.
*/
export class AcpConnection {
private process: ChildProcess | null = null;
private connection: ClientSideConnection | null = null;
private ready = false;
private collectors = new Map<SessionId, ResponseCollector>();
private onExit?: () => void;
constructor(private options: AcpAgentOptions, onExit?: () => void) {
this.onExit = onExit;
}
registerCollector(sessionId: SessionId, collector: ResponseCollector): void {
this.collectors.set(sessionId, collector);
}
unregisterCollector(sessionId: SessionId): void {
this.collectors.delete(sessionId);
}
/**
* Ensure the subprocess is running and the connection is initialized.
*/
async ensureReady(): Promise<ClientSideConnection> {
if (this.ready && this.connection) {
return this.connection;
}
const args = this.options.args ?? [];
log(`spawning: ${this.options.command} ${args.join(" ")}`);
const proc = spawn(this.options.command, args, {
stdio: ["pipe", "pipe", "inherit"],
env: { ...process.env, ...this.options.env },
cwd: this.options.cwd,
});
this.process = proc;
proc.on("exit", (code) => {
log(`subprocess exited (code=${code})`);
this.ready = false;
this.connection = null;
this.process = null;
this.onExit?.();
});
const writable = Writable.toWeb(proc.stdin!) as WritableStream<Uint8Array>;
const readable = Readable.toWeb(proc.stdout!) as ReadableStream<Uint8Array>;
const stream = ndJsonStream(writable, readable);
const conn = new ClientSideConnection((_agent) => ({
sessionUpdate: async (params) => {
const update = params.update;
switch (update.sessionUpdate) {
case "tool_call":
log(`tool_call: ${describeToolCall(update)} (${update.status ?? "started"})`);
break;
case "tool_call_update":
if (update.status) {
log(`tool_call_update: ${describeToolCall(update)} → ${update.status}`);
}
break;
case "agent_thought_chunk":
if (update.content.type === "text") {
log(`thinking: ${update.content.text.slice(0, 100)}`);
}
break;
}
const collector = this.collectors.get(params.sessionId);
if (collector) {
collector.handleUpdate(params);
}
},
requestPermission: async (params) => {
const firstOption = params.options[0];
log(
`permission: auto-approved "${firstOption?.name ?? "allow"}" (${firstOption?.optionId ?? "unknown"})`,
);
return {
outcome: {
outcome: "selected" as const,
optionId: firstOption?.optionId ?? "allow",
},
};
},
}), stream);
log("initializing connection...");
await conn.initialize({
protocolVersion: PROTOCOL_VERSION,
clientInfo: { name: "weixin-agent-sdk", version: "0.1.0" },
clientCapabilities: {},
});
log("connection initialized");
this.connection = conn;
this.ready = true;
return conn;
}
/**
* Kill the subprocess and clean up.
*/
dispose(): void {
this.ready = false;
this.collectors.clear();
if (this.process) {
this.process.kill();
this.process = null;
}
this.connection = null;
}
}
================================================
FILE: packages/weixin-acp/src/content-converter.ts
================================================
import fs from "node:fs/promises";
import type { ChatRequest } from "weixin-agent-sdk";
import type { ContentBlock } from "@agentclientprotocol/sdk";
/**
* Convert a ChatRequest into ACP ContentBlock[].
*/
export async function convertRequestToContentBlocks(
request: ChatRequest,
): Promise<ContentBlock[]> {
const blocks: ContentBlock[] = [];
if (request.text) {
blocks.push({ type: "text", text: request.text });
}
if (request.media) {
const data = await fs.readFile(request.media.filePath);
const base64 = data.toString("base64");
const mimeType = request.media.mimeType;
switch (request.media.type) {
case "image":
blocks.push({ type: "image", data: base64, mimeType });
break;
case "audio":
blocks.push({ type: "audio", data: base64, mimeType });
break;
case "video":
case "file": {
const uri = `file://${request.media.filePath}`;
blocks.push({
type: "resource",
resource: { uri, blob: base64, mimeType },
});
break;
}
}
}
return blocks;
}
================================================
FILE: packages/weixin-acp/src/response-collector.ts
================================================
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { ChatResponse } from "weixin-agent-sdk";
import type { SessionNotification } from "@agentclientprotocol/sdk";
const ACP_MEDIA_OUT_DIR = path.join(os.tmpdir(), "weixin-agent/media/acp-out");
/**
* Collects sessionUpdate notifications for a single prompt round-trip
* and converts the accumulated result into a ChatResponse.
*/
export class ResponseCollector {
private textChunks: string[] = [];
private imageData: { base64: string; mimeType: string } | null = null;
/**
* Feed a sessionUpdate notification into the collector.
*/
handleUpdate(notification: SessionNotification): void {
const update = notification.update;
if (update.sessionUpdate === "agent_message_chunk") {
const content = update.content;
if (content.type === "text") {
this.textChunks.push(content.text);
} else if (content.type === "image") {
this.imageData = {
base64: content.data,
mimeType: content.mimeType,
};
}
}
}
/**
* Build a ChatResponse from all collected chunks.
*/
async toResponse(): Promise<ChatResponse> {
const response: ChatResponse = {};
const text = this.textChunks.join("");
if (text) {
response.text = text;
}
if (this.imageData) {
await fs.mkdir(ACP_MEDIA_OUT_DIR, { recursive: true });
const ext = this.imageData.mimeType.split("/")[1] ?? "png";
const filename = `${crypto.randomUUID()}.${ext}`;
const filePath = path.join(ACP_MEDIA_OUT_DIR, filename);
await fs.writeFile(filePath, Buffer.from(this.imageData.base64, "base64"));
response.media = { type: "image", url: filePath };
}
return response;
}
}
================================================
FILE: packages/weixin-acp/src/types.ts
================================================
export type AcpAgentOptions = {
/** Command to launch the ACP agent, e.g. "npx" */
command: string;
/** Command arguments, e.g. ["@agentclientprotocol/codex-acp"] */
args?: string[];
/** Extra environment variables for the subprocess */
env?: Record<string, string>;
/** Working directory for the subprocess and ACP sessions */
cwd?: string;
/** Prompt timeout in milliseconds (default: 120_000) */
promptTimeoutMs?: number;
};
================================================
FILE: packages/weixin-acp/tsconfig.json
================================================
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["main.ts", "index.ts", "src/**/*.ts", "../sdk/src/vendor.d.ts"],
"exclude": ["node_modules"]
}
================================================
FILE: packages/weixin-acp/tsdown.config.ts
================================================
import { defineConfig } from "tsdown";
export default defineConfig({
entry: ["./main.ts", "./index.ts"],
dts: true,
});
================================================
FILE: pnpm-workspace.yaml
================================================
packages:
- "packages/*"
gitextract_v2sg2u_c/ ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package.json ├── packages/ │ ├── example-openai/ │ │ ├── main.ts │ │ ├── package.json │ │ ├── src/ │ │ │ └── openai-agent.ts │ │ └── tsconfig.json │ ├── sdk/ │ │ ├── .gitignore │ │ ├── index.ts │ │ ├── package.json │ │ ├── src/ │ │ │ ├── agent/ │ │ │ │ └── interface.ts │ │ │ ├── api/ │ │ │ │ ├── api.ts │ │ │ │ ├── config-cache.ts │ │ │ │ ├── session-guard.ts │ │ │ │ └── types.ts │ │ │ ├── auth/ │ │ │ │ ├── accounts.ts │ │ │ │ ├── login-qr.ts │ │ │ │ └── pairing.ts │ │ │ ├── bot.ts │ │ │ ├── cdn/ │ │ │ │ ├── aes-ecb.ts │ │ │ │ ├── cdn-upload.ts │ │ │ │ ├── cdn-url.ts │ │ │ │ ├── pic-decrypt.ts │ │ │ │ └── upload.ts │ │ │ ├── media/ │ │ │ │ ├── media-download.ts │ │ │ │ ├── mime.ts │ │ │ │ └── silk-transcode.ts │ │ │ ├── messaging/ │ │ │ │ ├── debug-mode.ts │ │ │ │ ├── error-notice.ts │ │ │ │ ├── inbound.ts │ │ │ │ ├── process-message.ts │ │ │ │ ├── send-media.ts │ │ │ │ ├── send.ts │ │ │ │ └── slash-commands.ts │ │ │ ├── monitor/ │ │ │ │ └── monitor.ts │ │ │ ├── storage/ │ │ │ │ ├── state-dir.ts │ │ │ │ └── sync-buf.ts │ │ │ ├── util/ │ │ │ │ ├── logger.ts │ │ │ │ ├── random.ts │ │ │ │ └── redact.ts │ │ │ └── vendor.d.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ └── weixin-acp/ │ ├── .gitignore │ ├── index.ts │ ├── main.ts │ ├── package.json │ ├── src/ │ │ ├── acp-agent.ts │ │ ├── acp-connection.ts │ │ ├── content-converter.ts │ │ ├── response-collector.ts │ │ └── types.ts │ ├── tsconfig.json │ └── tsdown.config.ts └── pnpm-workspace.yaml
SYMBOL INDEX (250 symbols across 38 files)
FILE: packages/example-openai/main.ts
function main (line 23) | async function main() {
FILE: packages/example-openai/src/openai-agent.ts
type OpenAIAgentOptions (line 15) | type OpenAIAgentOptions = {
type Message (line 27) | type Message = OpenAI.ChatCompletionMessageParam;
class OpenAIAgent (line 29) | class OpenAIAgent implements Agent {
method constructor (line 36) | constructor(opts: OpenAIAgentOptions) {
method chat (line 43) | async chat(request: ChatRequest): Promise<ChatResponse> {
FILE: packages/sdk/src/agent/interface.ts
type Agent (line 9) | interface Agent {
type ChatRequest (line 16) | interface ChatRequest {
type ChatResponse (line 33) | interface ChatResponse {
FILE: packages/sdk/src/api/api.ts
type WeixinApiOptions (line 21) | type WeixinApiOptions = {
function readChannelVersion (line 33) | function readChannelVersion(): string {
constant CHANNEL_VERSION (line 44) | const CHANNEL_VERSION = readChannelVersion();
function buildBaseInfo (line 47) | function buildBaseInfo(): BaseInfo {
constant DEFAULT_LONG_POLL_TIMEOUT_MS (line 52) | const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
constant DEFAULT_API_TIMEOUT_MS (line 54) | const DEFAULT_API_TIMEOUT_MS = 15_000;
constant DEFAULT_CONFIG_TIMEOUT_MS (line 56) | const DEFAULT_CONFIG_TIMEOUT_MS = 10_000;
function ensureTrailingSlash (line 58) | function ensureTrailingSlash(url: string): string {
function randomWechatUin (line 63) | function randomWechatUin(): string {
function buildCommonHeaders (line 69) | function buildCommonHeaders(): Record<string, string> {
function buildHeaders (line 78) | function buildHeaders(opts: { token?: string; body: string }): Record<st...
function apiGetFetch (line 100) | async function apiGetFetch(params: {
function apiFetch (line 136) | async function apiFetch(params: {
function getUpdates (line 185) | async function getUpdates(
function getUploadUrl (line 220) | async function getUploadUrl(
function sendMessage (line 249) | async function sendMessage(
function getConfig (line 263) | async function getConfig(
function sendTyping (line 283) | async function sendTyping(
FILE: packages/sdk/src/api/config-cache.ts
type CachedConfig (line 4) | interface CachedConfig {
constant CONFIG_CACHE_TTL_MS (line 8) | const CONFIG_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
constant CONFIG_CACHE_INITIAL_RETRY_MS (line 9) | const CONFIG_CACHE_INITIAL_RETRY_MS = 2_000;
constant CONFIG_CACHE_MAX_RETRY_MS (line 10) | const CONFIG_CACHE_MAX_RETRY_MS = 60 * 60 * 1000;
type ConfigCacheEntry (line 12) | interface ConfigCacheEntry {
class WeixinConfigManager (line 23) | class WeixinConfigManager {
method constructor (line 26) | constructor(
method getForUser (line 31) | async getForUser(userId: string, contextToken?: string): Promise<Cache...
FILE: packages/sdk/src/api/session-guard.ts
constant SESSION_PAUSE_DURATION_MS (line 3) | const SESSION_PAUSE_DURATION_MS = 60 * 60 * 1000;
constant SESSION_EXPIRED_ERRCODE (line 6) | const SESSION_EXPIRED_ERRCODE = -14;
function pauseSession (line 11) | function pauseSession(accountId: string): void {
function isSessionPaused (line 20) | function isSessionPaused(accountId: string): boolean {
function getRemainingPauseMs (line 31) | function getRemainingPauseMs(accountId: string): number {
function assertSessionActive (line 43) | function assertSessionActive(accountId: string): void {
function _resetForTest (line 56) | function _resetForTest(): void {
FILE: packages/sdk/src/api/types.ts
type BaseInfo (line 7) | interface BaseInfo {
type GetUploadUrlReq (line 19) | interface GetUploadUrlReq {
type GetUploadUrlResp (line 42) | interface GetUploadUrlResp {
type TextItem (line 72) | interface TextItem {
type CDNMedia (line 77) | interface CDNMedia {
type ImageItem (line 86) | interface ImageItem {
type VoiceItem (line 101) | interface VoiceItem {
type FileItem (line 114) | interface FileItem {
type VideoItem (line 121) | interface VideoItem {
type RefMessage (line 132) | interface RefMessage {
type MessageItem (line 137) | interface MessageItem {
type WeixinMessage (line 152) | interface WeixinMessage {
type GetUpdatesReq (line 170) | interface GetUpdatesReq {
type GetUpdatesResp (line 178) | interface GetUpdatesResp {
type SendMessageReq (line 193) | interface SendMessageReq {
type SendMessageResp (line 197) | interface SendMessageResp {
type SendTypingReq (line 208) | interface SendTypingReq {
type SendTypingResp (line 215) | interface SendTypingResp {
type GetConfigResp (line 221) | interface GetConfigResp {
FILE: packages/sdk/src/auth/accounts.ts
constant DEFAULT_BASE_URL (line 7) | const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
constant CDN_BASE_URL (line 8) | const CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
function normalizeAccountId (line 11) | function normalizeAccountId(raw: string): string {
function deriveRawAccountId (line 26) | function deriveRawAccountId(normalizedId: string): string | undefined {
function resolveWeixinStateDir (line 40) | function resolveWeixinStateDir(): string {
function resolveAccountIndexPath (line 44) | function resolveAccountIndexPath(): string {
function listIndexedWeixinAccountIds (line 49) | function listIndexedWeixinAccountIds(): string[] {
function registerWeixinAccountId (line 63) | function registerWeixinAccountId(accountId: string): void {
type WeixinAccountData (line 75) | type WeixinAccountData = {
function resolveAccountsDir (line 83) | function resolveAccountsDir(): string {
function resolveAccountPath (line 87) | function resolveAccountPath(accountId: string): string {
function loadLegacyToken (line 94) | function loadLegacyToken(): string | undefined {
function readAccountFile (line 106) | function readAccountFile(filePath: string): WeixinAccountData | null {
function loadWeixinAccount (line 118) | function loadWeixinAccount(accountId: string): WeixinAccountData | null {
function saveWeixinAccount (line 144) | function saveWeixinAccount(
function clearWeixinAccount (line 176) | function clearWeixinAccount(accountId: string): void {
function clearAllWeixinAccounts (line 185) | function clearAllWeixinAccounts(): void {
function resolveConfigPath (line 201) | function resolveConfigPath(): string {
function loadConfigRouteTag (line 212) | function loadConfigRouteTag(accountId?: string): string | undefined {
function triggerWeixinChannelReload (line 239) | async function triggerWeixinChannelReload(): Promise<void> {}
type ResolvedWeixinAccount (line 245) | type ResolvedWeixinAccount = {
function listWeixinAccountIds (line 256) | function listWeixinAccountIds(): string[] {
function resolveWeixinAccount (line 261) | function resolveWeixinAccount(accountId?: string | null): ResolvedWeixin...
FILE: packages/sdk/src/auth/login-qr.ts
type ActiveLogin (line 7) | type ActiveLogin = {
constant ACTIVE_LOGIN_TTL_MS (line 20) | const ACTIVE_LOGIN_TTL_MS = 5 * 60_000;
constant GET_QRCODE_TIMEOUT_MS (line 22) | const GET_QRCODE_TIMEOUT_MS = 5_000;
constant QR_LONG_POLL_TIMEOUT_MS (line 24) | const QR_LONG_POLL_TIMEOUT_MS = 35_000;
constant DEFAULT_ILINK_BOT_TYPE (line 27) | const DEFAULT_ILINK_BOT_TYPE = "3";
constant FIXED_BASE_URL (line 30) | const FIXED_BASE_URL = "https://ilinkai.weixin.qq.com";
type QRCodeResponse (line 34) | interface QRCodeResponse {
type StatusResponse (line 39) | interface StatusResponse {
function isLoginFresh (line 50) | function isLoginFresh(login: ActiveLogin): boolean {
function purgeExpiredLogins (line 55) | function purgeExpiredLogins(): void {
function fetchQRCode (line 63) | async function fetchQRCode(apiBaseUrl: string, botType: string): Promise...
function pollQRStatus (line 74) | async function pollQRStatus(apiBaseUrl: string, qrcode: string): Promise...
type WeixinQrStartResult (line 96) | type WeixinQrStartResult = {
type WeixinQrWaitResult (line 102) | type WeixinQrWaitResult = {
function startWeixinLoginWithQr (line 112) | async function startWeixinLoginWithQr(opts: {
constant MAX_QR_REFRESH_COUNT (line 167) | const MAX_QR_REFRESH_COUNT = 3;
function waitForWeixinLogin (line 169) | async function waitForWeixinLogin(opts: {
FILE: packages/sdk/src/auth/pairing.ts
function resolveCredentialsDir (line 11) | function resolveCredentialsDir(): string {
function safeKey (line 20) | function safeKey(raw: string): string {
function resolveFrameworkAllowFromPath (line 33) | function resolveFrameworkAllowFromPath(accountId: string): string {
type AllowFromFileContent (line 39) | type AllowFromFileContent = {
function readFrameworkAllowFromList (line 48) | function readFrameworkAllowFromList(accountId: string): string[] {
function registerUserInAllowFromStore (line 66) | function registerUserInAllowFromStore(params: {
FILE: packages/sdk/src/bot.ts
constant MEDIA_TEMP_DIR (line 27) | const MEDIA_TEMP_DIR = path.join(os.tmpdir(), "weixin-agent/media");
type LoginOptions (line 29) | type LoginOptions = {
type StartOptions (line 36) | type StartOptions = {
function login (line 51) | async function login(opts?: LoginOptions): Promise<string> {
function logout (line 107) | function logout(opts?: { log?: (msg: string) => void }): void {
function isLoggedIn (line 121) | function isLoggedIn(): boolean {
class Bot (line 134) | class Bot {
method constructor (line 143) | constructor(params: {
method wait (line 168) | async wait(): Promise<void> {
method sendMessage (line 181) | async sendMessage(message: string | ChatResponse): Promise<void> {
function start (line 238) | function start(agent: Agent, opts?: StartOptions): Bot {
FILE: packages/sdk/src/cdn/aes-ecb.ts
function encryptAesEcb (line 7) | function encryptAesEcb(plaintext: Buffer, key: Buffer): Buffer {
function decryptAesEcb (line 13) | function decryptAesEcb(ciphertext: Buffer, key: Buffer): Buffer {
function aesEcbPaddedSize (line 19) | function aesEcbPaddedSize(plaintextSize: number): number {
FILE: packages/sdk/src/cdn/cdn-upload.ts
constant UPLOAD_MAX_RETRIES (line 7) | const UPLOAD_MAX_RETRIES = 3;
function uploadBufferToCdn (line 14) | async function uploadBufferToCdn(params: {
FILE: packages/sdk/src/cdn/cdn-url.ts
function buildCdnDownloadUrl (line 6) | function buildCdnDownloadUrl(encryptedQueryParam: string, cdnBaseUrl: st...
function buildCdnUploadUrl (line 11) | function buildCdnUploadUrl(params: {
FILE: packages/sdk/src/cdn/pic-decrypt.ts
function fetchCdnBytes (line 8) | async function fetchCdnBytes(url: string, label: string): Promise<Buffer> {
function parseAesKey (line 40) | function parseAesKey(aesKeyBase64: string, label: string): Buffer {
function downloadAndDecryptBuffer (line 58) | async function downloadAndDecryptBuffer(
function downloadPlainCdnBuffer (line 78) | async function downloadPlainCdnBuffer(
FILE: packages/sdk/src/cdn/upload.ts
type UploadedFileInfo (line 14) | type UploadedFileInfo = {
function downloadRemoteImageToTemp (line 30) | async function downloadRemoteImageToTemp(url: string, destDir: string): ...
function uploadMediaToCdn (line 52) | async function uploadMediaToCdn(params: {
function uploadFileToWeixin (line 114) | async function uploadFileToWeixin(params: {
function uploadVideoToWeixin (line 128) | async function uploadVideoToWeixin(params: {
function uploadFileAttachmentToWeixin (line 145) | async function uploadFileAttachmentToWeixin(params: {
FILE: packages/sdk/src/media/media-download.ts
constant WEIXIN_MEDIA_MAX_BYTES (line 12) | const WEIXIN_MEDIA_MAX_BYTES = 100 * 1024 * 1024;
type SaveMediaFn (line 15) | type SaveMediaFn = (
function downloadMediaFromItem (line 27) | async function downloadMediaFromItem(
FILE: packages/sdk/src/media/mime.ts
constant EXTENSION_TO_MIME (line 3) | const EXTENSION_TO_MIME: Record<string, string> = {
constant MIME_TO_EXTENSION (line 32) | const MIME_TO_EXTENSION: Record<string, string> = {
function getMimeFromFilename (line 56) | function getMimeFromFilename(filename: string): string {
function getExtensionFromMime (line 62) | function getExtensionFromMime(mimeType: string): string {
function getExtensionFromContentTypeOrUrl (line 68) | function getExtensionFromContentTypeOrUrl(contentType: string | null, ur...
FILE: packages/sdk/src/media/silk-transcode.ts
constant SILK_SAMPLE_RATE (line 4) | const SILK_SAMPLE_RATE = 24_000;
function pcmBytesToWav (line 10) | function pcmBytesToWav(pcm: Uint8Array, sampleRate: number): Buffer {
function silkToWav (line 57) | async function silkToWav(silkBuf: Buffer): Promise<Buffer | null> {
FILE: packages/sdk/src/messaging/debug-mode.ts
type DebugModeState (line 16) | interface DebugModeState {
function resolveDebugModePath (line 20) | function resolveDebugModePath(): string {
function loadState (line 24) | function loadState(): DebugModeState {
function saveState (line 35) | function saveState(state: DebugModeState): void {
function toggleDebugMode (line 42) | function toggleDebugMode(accountId: string): boolean {
function isDebugMode (line 55) | function isDebugMode(accountId: string): boolean {
function _resetForTest (line 63) | function _resetForTest(): void {
FILE: packages/sdk/src/messaging/error-notice.ts
function sendWeixinErrorNotice (line 9) | async function sendWeixinErrorNotice(params: {
FILE: packages/sdk/src/messaging/inbound.ts
function contextTokenKey (line 18) | function contextTokenKey(accountId: string, userId: string): string {
function setContextToken (line 23) | function setContextToken(accountId: string, userId: string, token: strin...
function getContextToken (line 30) | function getContextToken(accountId: string, userId: string): string | un...
function generateMessageSid (line 43) | function generateMessageSid(): string {
type WeixinMsgContext (line 48) | type WeixinMsgContext = {
function isMediaItem (line 72) | function isMediaItem(item: MessageItem): boolean {
function bodyFromItemList (line 81) | function bodyFromItemList(itemList?: MessageItem[]): string {
type WeixinInboundMediaOpts (line 108) | type WeixinInboundMediaOpts = {
function weixinMessageToMsgContext (line 129) | function weixinMessageToMsgContext(
function getContextTokenFromMsgContext (line 169) | function getContextTokenFromMsgContext(ctx: WeixinMsgContext): string | ...
FILE: packages/sdk/src/messaging/process-message.ts
constant MEDIA_TEMP_DIR (line 21) | const MEDIA_TEMP_DIR = path.join(os.tmpdir(), "weixin-agent/media");
function saveMediaBuffer (line 24) | async function saveMediaBuffer(
type ProcessMessageDeps (line 46) | type ProcessMessageDeps = {
function extractTextBody (line 58) | function extractTextBody(itemList?: MessageItem[]): string {
function findMediaItem (line 69) | function findMediaItem(itemList?: MessageItem[]): MessageItem | undefined {
function processOneMessage (line 108) | async function processOneMessage(
FILE: packages/sdk/src/messaging/send-media.ts
function sendWeixinMediaFile (line 17) | async function sendWeixinMediaFile(params: {
FILE: packages/sdk/src/messaging/send.ts
function generateClientId (line 9) | function generateClientId(): string {
function markdownToPlainText (line 17) | function markdownToPlainText(text: string): string {
function buildTextMessageReq (line 43) | function buildTextMessageReq(params: {
function buildSendMessageReq (line 67) | function buildSendMessageReq(params: {
function sendMessageWeixin (line 81) | async function sendMessageWeixin(params: {
function sendMediaItems (line 116) | async function sendMediaItems(params: {
function sendImageMessageWeixin (line 173) | async function sendImageMessageWeixin(params: {
function sendVideoMessageWeixin (line 208) | async function sendVideoMessageWeixin(params: {
function sendFileMessageWeixin (line 240) | async function sendFileMessageWeixin(params: {
FILE: packages/sdk/src/messaging/slash-commands.ts
type SlashCommandResult (line 15) | interface SlashCommandResult {
type SlashCommandContext (line 20) | interface SlashCommandContext {
function sendReply (line 33) | async function sendReply(ctx: SlashCommandContext, text: string): Promis...
function handleEcho (line 43) | async function handleEcho(
function handleSlashCommand (line 69) | async function handleSlashCommand(
FILE: packages/sdk/src/monitor/monitor.ts
constant DEFAULT_LONG_POLL_TIMEOUT_MS (line 10) | const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
constant MAX_CONSECUTIVE_FAILURES (line 11) | const MAX_CONSECUTIVE_FAILURES = 3;
constant BACKOFF_DELAY_MS (line 12) | const BACKOFF_DELAY_MS = 30_000;
constant RETRY_DELAY_MS (line 13) | const RETRY_DELAY_MS = 2_000;
type MonitorWeixinOpts (line 15) | type MonitorWeixinOpts = {
function monitorWeixinProvider (line 30) | async function monitorWeixinProvider(opts: MonitorWeixinOpts): Promise<v...
function sleep (line 159) | function sleep(ms: number, signal?: AbortSignal): Promise<void> {
FILE: packages/sdk/src/storage/state-dir.ts
function resolveStateDir (line 5) | function resolveStateDir(): string {
FILE: packages/sdk/src/storage/sync-buf.ts
function resolveAccountsDir (line 8) | function resolveAccountsDir(): string {
function getSyncBufFilePath (line 16) | function getSyncBufFilePath(accountId: string): string {
function getLegacySyncBufDefaultJsonPath (line 21) | function getLegacySyncBufDefaultJsonPath(): string {
type SyncBufData (line 32) | type SyncBufData = {
function readSyncBufFile (line 36) | function readSyncBufFile(filePath: string): string | undefined {
function loadGetUpdatesBuf (line 56) | function loadGetUpdatesBuf(filePath: string): string | undefined {
function saveGetUpdatesBuf (line 77) | function saveGetUpdatesBuf(filePath: string, getUpdatesBuf: string): void {
FILE: packages/sdk/src/util/logger.ts
constant MAIN_LOG_DIR (line 11) | const MAIN_LOG_DIR = path.join(os.tmpdir(), "openclaw");
constant SUBSYSTEM (line 12) | const SUBSYSTEM = "gateway/channels/openclaw-weixin";
constant RUNTIME (line 13) | const RUNTIME = "node";
constant RUNTIME_VERSION (line 14) | const RUNTIME_VERSION = process.versions.node;
constant HOSTNAME (line 15) | const HOSTNAME = os.hostname() || "unknown";
constant PARENT_NAMES (line 16) | const PARENT_NAMES = ["openclaw"];
constant LEVEL_IDS (line 19) | const LEVEL_IDS: Record<string, number> = {
constant DEFAULT_LOG_LEVEL (line 28) | const DEFAULT_LOG_LEVEL = "INFO";
function resolveMinLevel (line 30) | function resolveMinLevel(): number {
function setLogLevel (line 39) | function setLogLevel(level: string): void {
function toLocalISO (line 48) | function toLocalISO(now: Date): string {
function localDateKey (line 56) | function localDateKey(now: Date): string {
function resolveMainLogPath (line 60) | function resolveMainLogPath(): string {
type Logger (line 67) | type Logger = {
function buildLoggerName (line 79) | function buildLoggerName(accountId?: string): string {
function writeLog (line 83) | function writeLog(level: string, message: string, accountId?: string): v...
function createLogger (line 117) | function createLogger(accountId?: string): Logger {
FILE: packages/sdk/src/util/random.ts
function generateId (line 7) | function generateId(prefix: string): string {
function tempFileName (line 15) | function tempFileName(prefix: string, ext: string): string {
FILE: packages/sdk/src/util/redact.ts
constant DEFAULT_BODY_MAX_LEN (line 1) | const DEFAULT_BODY_MAX_LEN = 200;
constant DEFAULT_TOKEN_PREFIX_LEN (line 2) | const DEFAULT_TOKEN_PREFIX_LEN = 6;
function truncate (line 8) | function truncate(s: string | undefined, max: number): string {
function redactToken (line 18) | function redactToken(token: string | undefined, prefixLen = DEFAULT_TOKE...
function redactBody (line 28) | function redactBody(body: string | undefined, maxLen = DEFAULT_BODY_MAX_...
function redactUrl (line 38) | function redactUrl(rawUrl: string): string {
FILE: packages/weixin-acp/main.ts
constant BUILTIN_AGENTS (line 21) | const BUILTIN_AGENTS: Record<string, { command: string }> = {
function ensureLoggedIn (line 28) | async function ensureLoggedIn() {
function startAgent (line 35) | async function startAgent(acpCommand: string, acpArgs: string[] = []) {
function main (line 54) | async function main() {
FILE: packages/weixin-acp/src/acp-agent.ts
function log (line 9) | function log(msg: string) {
class AcpAgent (line 17) | class AcpAgent implements Agent {
method constructor (line 22) | constructor(options: AcpAgentOptions) {
method chat (line 30) | async chat(request: ChatRequest): Promise<ChatResponse> {
method getOrCreateSession (line 59) | private async getOrCreateSession(
method clearSession (line 80) | clearSession(conversationId: string): void {
method dispose (line 92) | dispose(): void {
FILE: packages/weixin-acp/src/acp-connection.ts
function log (line 15) | function log(msg: string) {
function describeToolCall (line 19) | function describeToolCall(update: {
class AcpConnection (line 30) | class AcpConnection {
method constructor (line 38) | constructor(private options: AcpAgentOptions, onExit?: () => void) {
method registerCollector (line 42) | registerCollector(sessionId: SessionId, collector: ResponseCollector):...
method unregisterCollector (line 46) | unregisterCollector(sessionId: SessionId): void {
method ensureReady (line 53) | async ensureReady(): Promise<ClientSideConnection> {
method dispose (line 133) | dispose(): void {
FILE: packages/weixin-acp/src/content-converter.ts
function convertRequestToContentBlocks (line 9) | async function convertRequestToContentBlocks(
FILE: packages/weixin-acp/src/response-collector.ts
constant ACP_MEDIA_OUT_DIR (line 9) | const ACP_MEDIA_OUT_DIR = path.join(os.tmpdir(), "weixin-agent/media/acp...
class ResponseCollector (line 15) | class ResponseCollector {
method handleUpdate (line 22) | handleUpdate(notification: SessionNotification): void {
method toResponse (line 42) | async toResponse(): Promise<ChatResponse> {
FILE: packages/weixin-acp/src/types.ts
type AcpAgentOptions (line 1) | type AcpAgentOptions = {
Condensed preview — 57 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (169K chars).
[
{
"path": ".gitignore",
"chars": 47,
"preview": "node_modules\ndist\n.env\n*.log\npackage-lock.json\n"
},
{
"path": "CHANGELOG.md",
"chars": 1391,
"preview": "# Changelog\n\n## 0.5.0 (2026-04-19)\n\n### weixin-agent-sdk\n\n- **fix**: `start(agent)` 现在会立即返回 `Bot` 实例,并新增 `bot.wait()` 用于"
},
{
"path": "LICENSE",
"chars": 1062,
"preview": "MIT License\n\nCopyright (c) 2026 wong2\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof t"
},
{
"path": "README.md",
"chars": 5601,
"preview": "# weixin-agent-sdk\n\n> 本项目非微信官方项目,代码由 [@tencent-weixin/openclaw-weixin](https://npmx.dev/package/@tencent-weixin/openclaw"
},
{
"path": "package.json",
"chars": 83,
"preview": "{\n \"private\": true,\n \"scripts\": {\n \"typecheck\": \"pnpm -r run typecheck\"\n }\n}\n"
},
{
"path": "packages/example-openai/main.ts",
"chars": 1778,
"preview": "#!/usr/bin/env node\n\n/**\n * WeChat + OpenAI example.\n *\n * Usage:\n * npx tsx main.ts login # QR-code logi"
},
{
"path": "packages/example-openai/package.json",
"chars": 442,
"preview": "{\n \"name\": \"example-openai\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"description\": \"WeChat + OpenAI example using w"
},
{
"path": "packages/example-openai/src/openai-agent.ts",
"chars": 3354,
"preview": "/**\n * Example Agent implementation using the OpenAI Chat Completions API.\n *\n * Supports:\n * - Multi-turn conversatio"
},
{
"path": "packages/example-openai/tsconfig.json",
"chars": 369,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"NodeNext\",\n \"moduleResolution\": \"NodeNext\",\n \"esMo"
},
{
"path": "packages/sdk/.gitignore",
"chars": 5,
"preview": "dist\n"
},
{
"path": "packages/sdk/index.ts",
"chars": 216,
"preview": "export type { Agent, ChatRequest, ChatResponse } from \"./src/agent/interface.js\";\nexport { Bot, isLoggedIn, login, logou"
},
{
"path": "packages/sdk/package.json",
"chars": 828,
"preview": "{\n \"name\": \"weixin-agent-sdk\",\n \"version\": \"0.5.0\",\n \"description\": \"WeChat ↔ AI Agent bridge SDK — connect any AI ba"
},
{
"path": "packages/sdk/src/agent/interface.ts",
"chars": 1436,
"preview": "/**\n * Agent interface — any AI backend that can handle a chat message.\n *\n * Implement this interface to connect WeChat"
},
{
"path": "packages/sdk/src/api/api.ts",
"chars": 9377,
"preview": "import crypto from \"node:crypto\";\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport { fileURLToPath } from "
},
{
"path": "packages/sdk/src/api/config-cache.ts",
"chars": 2496,
"preview": "import { getConfig } from \"./api.js\";\n\n/** Subset of getConfig fields that we actually need; add new fields here as need"
},
{
"path": "packages/sdk/src/api/session-guard.ts",
"chars": 1883,
"preview": "import { logger } from \"../util/logger.js\";\n\nconst SESSION_PAUSE_DURATION_MS = 60 * 60 * 1000;\n\n/** Error code returned "
},
{
"path": "packages/sdk/src/api/types.ts",
"chars": 5292,
"preview": "/**\n * Weixin protocol types (mirrors proto: GetUpdatesReq/Resp, WeixinMessage, SendMessageReq).\n * API uses JSON over H"
},
{
"path": "packages/sdk/src/auth/accounts.ts",
"chars": 9512,
"preview": "import fs from \"node:fs\";\nimport path from \"node:path\";\n\nimport { resolveStateDir } from \"../storage/state-dir.js\";\nimpo"
},
{
"path": "packages/sdk/src/auth/login-qr.ts",
"chars": 10942,
"preview": "import { randomUUID } from \"node:crypto\";\n\nimport { apiGetFetch } from \"../api/api.js\";\nimport { logger } from \"../util/"
},
{
"path": "packages/sdk/src/auth/pairing.ts",
"chars": 3315,
"preview": "import fs from \"node:fs\";\nimport path from \"node:path\";\n\nimport { resolveStateDir } from \"../storage/state-dir.js\";\nimpo"
},
{
"path": "packages/sdk/src/bot.ts",
"chars": 7984,
"preview": "import os from \"node:os\";\nimport path from \"node:path\";\n\nimport type { Agent, ChatResponse } from \"./agent/interface.js\""
},
{
"path": "packages/sdk/src/cdn/aes-ecb.ts",
"chars": 877,
"preview": "/**\n * Shared AES-128-ECB crypto utilities for CDN upload and download.\n */\nimport { createCipheriv, createDecipheriv } "
},
{
"path": "packages/sdk/src/cdn/cdn-upload.ts",
"chars": 3373,
"preview": "import { encryptAesEcb } from \"./aes-ecb.js\";\nimport { buildCdnUploadUrl } from \"./cdn-url.js\";\nimport { logger } from \""
},
{
"path": "packages/sdk/src/cdn/cdn-url.ts",
"chars": 663,
"preview": "/**\n * Unified CDN URL construction for Weixin CDN upload/download.\n */\n\n/** Build a CDN download URL from encrypt_query"
},
{
"path": "packages/sdk/src/cdn/pic-decrypt.ts",
"chars": 3111,
"preview": "import { decryptAesEcb } from \"./aes-ecb.js\";\nimport { buildCdnDownloadUrl } from \"./cdn-url.js\";\nimport { logger } from"
},
{
"path": "packages/sdk/src/cdn/upload.ts",
"chars": 5191,
"preview": "import crypto from \"node:crypto\";\nimport fs from \"node:fs/promises\";\nimport path from \"node:path\";\n\nimport { getUploadUr"
},
{
"path": "packages/sdk/src/media/media-download.ts",
"chars": 5864,
"preview": "import type { WeixinInboundMediaOpts } from \"../messaging/inbound.js\";\nimport { logger } from \"../util/logger.js\";\nimpor"
},
{
"path": "packages/sdk/src/media/mime.ts",
"chars": 2541,
"preview": "import path from \"node:path\";\n\nconst EXTENSION_TO_MIME: Record<string, string> = {\n \".pdf\": \"application/pdf\",\n \".doc\""
},
{
"path": "packages/sdk/src/media/silk-transcode.ts",
"chars": 2256,
"preview": "import { logger } from \"../util/logger.js\";\n\n/** Default sample rate for Weixin voice messages. */\nconst SILK_SAMPLE_RAT"
},
{
"path": "packages/sdk/src/messaging/debug-mode.ts",
"chars": 1981,
"preview": "/**\n * Per-bot debug mode toggle, persisted to disk so it survives gateway restarts.\n *\n * State file: `<stateDir>/openc"
},
{
"path": "packages/sdk/src/messaging/error-notice.ts",
"chars": 1052,
"preview": "import { logger } from \"../util/logger.js\";\nimport { sendMessageWeixin } from \"./send.js\";\n\n/**\n * Send a plain-text err"
},
{
"path": "packages/sdk/src/messaging/inbound.ts",
"chars": 6202,
"preview": "import { logger } from \"../util/logger.js\";\nimport { generateId } from \"../util/random.js\";\nimport type { WeixinMessage,"
},
{
"path": "packages/sdk/src/messaging/process-message.ts",
"chars": 8285,
"preview": "import crypto from \"node:crypto\";\nimport fs from \"node:fs/promises\";\nimport os from \"node:os\";\nimport path from \"node:pa"
},
{
"path": "packages/sdk/src/messaging/send-media.ts",
"chars": 2723,
"preview": "import path from \"node:path\";\nimport type { WeixinApiOptions } from \"../api/api.js\";\nimport { logger } from \"../util/log"
},
{
"path": "packages/sdk/src/messaging/send.ts",
"chars": 8582,
"preview": "import { sendMessage as sendMessageApi } from \"../api/api.js\";\nimport type { WeixinApiOptions } from \"../api/api.js\";\nim"
},
{
"path": "packages/sdk/src/messaging/slash-commands.ts",
"chars": 3186,
"preview": "/**\n * Weixin 斜杠指令处理模块\n *\n * 支持的指令:\n * - /echo <message> 直接回复消息(不经过 AI),并附带通道耗时统计\n * - /toggle-debug 开"
},
{
"path": "packages/sdk/src/monitor/monitor.ts",
"chars": 5424,
"preview": "import type { Agent } from \"../agent/interface.js\";\nimport { getUpdates } from \"../api/api.js\";\nimport { WeixinConfigMan"
},
{
"path": "packages/sdk/src/storage/state-dir.ts",
"chars": 331,
"preview": "import os from \"node:os\";\nimport path from \"node:path\";\n\n/** Resolve the OpenClaw state directory (mirrors core logic in"
},
{
"path": "packages/sdk/src/storage/sync-buf.ts",
"chars": 2692,
"preview": "import fs from \"node:fs\";\nimport path from \"node:path\";\n\nimport { deriveRawAccountId } from \"../auth/accounts.js\";\n\nimpo"
},
{
"path": "packages/sdk/src/util/logger.ts",
"chars": 4252,
"preview": "import fs from \"node:fs\";\nimport os from \"node:os\";\nimport path from \"node:path\";\n\n/**\n * Plugin logger — writes JSON li"
},
{
"path": "packages/sdk/src/util/random.ts",
"chars": 562,
"preview": "import crypto from \"node:crypto\";\n\n/**\n * Generate a prefixed unique ID using timestamp + crypto random bytes.\n * Format"
},
{
"path": "packages/sdk/src/util/redact.ts",
"chars": 1492,
"preview": "const DEFAULT_BODY_MAX_LEN = 200;\nconst DEFAULT_TOKEN_PREFIX_LEN = 6;\n\n/**\n * Truncate a string, appending a length indi"
},
{
"path": "packages/sdk/src/vendor.d.ts",
"chars": 379,
"preview": "declare module \"silk-wasm\" {\n export function decode(\n input: Buffer,\n sampleRate: number,\n ): Promise<{ data: U"
},
{
"path": "packages/sdk/tsconfig.json",
"chars": 369,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"NodeNext\",\n \"moduleResolution\": \"NodeNext\",\n \"esMo"
},
{
"path": "packages/sdk/tsdown.config.ts",
"chars": 112,
"preview": "import { defineConfig } from \"tsdown\";\n\nexport default defineConfig({\n entry: [\"./index.ts\"],\n dts: true,\n});\n"
},
{
"path": "packages/weixin-acp/.gitignore",
"chars": 5,
"preview": "dist\n"
},
{
"path": "packages/weixin-acp/index.ts",
"chars": 102,
"preview": "export { AcpAgent } from \"./src/acp-agent.js\";\nexport type { AcpAgentOptions } from \"./src/types.js\";\n"
},
{
"path": "packages/weixin-acp/main.ts",
"chars": 2601,
"preview": "#!/usr/bin/env node\n\n/**\n * WeChat + ACP (Agent Client Protocol) adapter.\n *\n * Usage:\n * npx weixin-acp login "
},
{
"path": "packages/weixin-acp/package.json",
"chars": 1137,
"preview": "{\n \"name\": \"weixin-acp\",\n \"version\": \"0.6.0\",\n \"description\": \"WeChat + ACP (Agent Client Protocol) adapter — connect"
},
{
"path": "packages/weixin-acp/src/acp-agent.ts",
"chars": 3205,
"preview": "import type { Agent, ChatRequest, ChatResponse } from \"weixin-agent-sdk\";\nimport type { SessionId } from \"@agentclientpr"
},
{
"path": "packages/weixin-acp/src/acp-connection.ts",
"chars": 4114,
"preview": "import type { ChildProcess } from \"node:child_process\";\nimport spawn from \"cross-spawn\";\nimport { Readable, Writable } f"
},
{
"path": "packages/weixin-acp/src/content-converter.ts",
"chars": 1106,
"preview": "import fs from \"node:fs/promises\";\n\nimport type { ChatRequest } from \"weixin-agent-sdk\";\nimport type { ContentBlock } fr"
},
{
"path": "packages/weixin-acp/src/response-collector.ts",
"chars": 1825,
"preview": "import crypto from \"node:crypto\";\nimport fs from \"node:fs/promises\";\nimport os from \"node:os\";\nimport path from \"node:pa"
},
{
"path": "packages/weixin-acp/src/types.ts",
"chars": 448,
"preview": "export type AcpAgentOptions = {\n /** Command to launch the ACP agent, e.g. \"npx\" */\n command: string;\n /** Command ar"
},
{
"path": "packages/weixin-acp/tsconfig.json",
"chars": 381,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"NodeNext\",\n \"moduleResolution\": \"NodeNext\",\n \"esMo"
},
{
"path": "packages/weixin-acp/tsdown.config.ts",
"chars": 125,
"preview": "import { defineConfig } from \"tsdown\";\n\nexport default defineConfig({\n entry: [\"./main.ts\", \"./index.ts\"],\n dts: true,"
},
{
"path": "pnpm-workspace.yaml",
"chars": 27,
"preview": "packages:\n - \"packages/*\"\n"
}
]
About this extraction
This page contains the full source code of the wong2/weixin-agent-sdk GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 57 files (150.4 KB), approximately 41.6k tokens, and a symbol index with 250 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.