feat: add WeChat QR code login and AGP WebSocket channel plugin
- Auth module: WeChat OAuth2 scan-to-login flow with terminal QR code - Token persistence to ~/.openclaw/wechat-access-auth.json (chmod 600) - Token resolution: config > saved state > interactive login - Invite code verification (configurable bypass) - Production/test environment support - AGP WebSocket client with heartbeat, reconnect, wake detection - Message handler: Agent dispatch with streaming text and tool calls - Random device GUID generation (persisted, no real machine ID)
This commit is contained in:
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.js
|
||||||
|
*.d.ts
|
||||||
|
*.js.map
|
||||||
|
*.d.ts.map
|
||||||
|
!jest.config.js
|
||||||
|
!vitest.config.js
|
||||||
|
|
||||||
|
# Auth state (tokens)
|
||||||
|
.openclaw/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
81
README.md
Normal file
81
README.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# wechat-access-unqclawed
|
||||||
|
|
||||||
|
OpenClaw 微信通路插件 — 通过 WeChat OAuth 扫码登录获取 token,连接 AGP WebSocket 网关收发消息。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- 微信扫码登录(终端二维码 + 浏览器链接)
|
||||||
|
- Token 自动持久化,重启免登录
|
||||||
|
- AGP 协议 WebSocket 双向通信(流式文本、工具调用)
|
||||||
|
- 邀请码验证(可配置跳过)
|
||||||
|
- 支持生产/测试环境切换
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
依赖 Node.js v22+,作为 OpenClaw 渠道插件运行。
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
在 OpenClaw 配置文件的 `channels.wechat-access` 下:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"wechat-access": {
|
||||||
|
"token": "",
|
||||||
|
"wsUrl": "",
|
||||||
|
"bypassInvite": false,
|
||||||
|
"environment": "production"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `token` | string | 手动指定 channel token(留空则走扫码登录) |
|
||||||
|
| `wsUrl` | string | WebSocket 网关地址(留空则使用环境默认值) |
|
||||||
|
| `bypassInvite` | boolean | 跳过邀请码验证 |
|
||||||
|
| `environment` | string | `production` 或 `test` |
|
||||||
|
| `authStatePath` | string | 自定义 token 持久化路径 |
|
||||||
|
|
||||||
|
## Token 获取策略
|
||||||
|
|
||||||
|
1. 读取配置中的 `token` — 如果有,直接使用
|
||||||
|
2. 读取本地保存的登录态(`~/.openclaw/wechat-access-auth.json`)
|
||||||
|
3. 以上都没有 — 启动交互式微信扫码登录
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
index.ts # 插件入口,注册渠道、启停 WebSocket
|
||||||
|
auth/
|
||||||
|
types.ts # 认证相关类型
|
||||||
|
environments.ts # 生产/测试环境配置
|
||||||
|
device-guid.ts # 设备 GUID 生成(随机,持久化)
|
||||||
|
qclaw-api.ts # QClaw JPRX 网关 API 客户端
|
||||||
|
state-store.ts # Token 持久化
|
||||||
|
wechat-login.ts # 扫码登录流程编排
|
||||||
|
websocket/
|
||||||
|
types.ts # AGP 协议类型
|
||||||
|
websocket-client.ts # WebSocket 客户端(连接、心跳、重连)
|
||||||
|
message-handler.ts # 消息处理(调用 Agent)
|
||||||
|
message-adapter.ts # AGP <-> OpenClaw 消息适配
|
||||||
|
common/
|
||||||
|
runtime.ts # OpenClaw 运行时单例
|
||||||
|
agent-events.ts # Agent 事件订阅
|
||||||
|
message-context.ts # 消息上下文构建
|
||||||
|
http/ # HTTP webhook 通道(备用)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 协议
|
||||||
|
|
||||||
|
AGP (Agent Gateway Protocol) — 基于 WebSocket Text 帧的 JSON 消息协议,详见 `websocket.md`。
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
43
auth/device-guid.ts
Normal file
43
auth/device-guid.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* @file device-guid.ts
|
||||||
|
* @description 设备唯一标识生成
|
||||||
|
*
|
||||||
|
* 不使用真实机器码,而是首次运行时随机生成一个 GUID 并持久化到本地文件。
|
||||||
|
* 后续启动自动加载,保证同一台机器上 GUID 稳定不变。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomUUID, createHash } from "node:crypto";
|
||||||
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||||
|
import { join, dirname } from "node:path";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
|
||||||
|
const GUID_FILE = join(homedir(), ".openclaw", "wechat-access-guid");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取设备唯一标识
|
||||||
|
*
|
||||||
|
* 首次运行时随机生成一个 MD5 格式的 GUID 并保存到 ~/.openclaw/wechat-access-guid,
|
||||||
|
* 后续启动直接从文件读取,确保稳定。
|
||||||
|
*/
|
||||||
|
export const getDeviceGuid = (): string => {
|
||||||
|
// 尝试从文件加载已有 GUID
|
||||||
|
try {
|
||||||
|
const existing = readFileSync(GUID_FILE, "utf-8").trim();
|
||||||
|
if (existing) return existing;
|
||||||
|
} catch {
|
||||||
|
// 文件不存在或读取失败,继续生成
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首次运行:生成随机 GUID(MD5 hex 格式,32 字符)
|
||||||
|
const guid = createHash("md5").update(randomUUID()).digest("hex");
|
||||||
|
|
||||||
|
// 持久化
|
||||||
|
try {
|
||||||
|
mkdirSync(dirname(GUID_FILE), { recursive: true });
|
||||||
|
writeFileSync(GUID_FILE, guid, "utf-8");
|
||||||
|
} catch {
|
||||||
|
// 写入失败不致命,本次仍返回生成的 GUID
|
||||||
|
}
|
||||||
|
|
||||||
|
return guid;
|
||||||
|
};
|
||||||
29
auth/environments.ts
Normal file
29
auth/environments.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* @file environments.ts
|
||||||
|
* @description QClaw 环境配置(生产/测试)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { QClawEnvironment } from "./types.js";
|
||||||
|
|
||||||
|
const ENVIRONMENTS: Record<string, QClawEnvironment> = {
|
||||||
|
production: {
|
||||||
|
jprxGateway: "https://jprx.m.qq.com/",
|
||||||
|
qclawBaseUrl: "https://mmgrcalltoken.3g.qq.com/aizone/v1",
|
||||||
|
wxLoginRedirectUri: "https://security.guanjia.qq.com/login",
|
||||||
|
wechatWsUrl: "wss://mmgrcalltoken.3g.qq.com/agentwss",
|
||||||
|
wxAppId: "wx9d11056dd75b7240",
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
jprxGateway: "https://jprx.sparta.html5.qq.com/",
|
||||||
|
qclawBaseUrl: "https://jprx.sparta.html5.qq.com/aizone/v1",
|
||||||
|
wxLoginRedirectUri: "https://security-test.guanjia.qq.com/login",
|
||||||
|
wechatWsUrl: "wss://jprx.sparta.html5.qq.com/agentwss",
|
||||||
|
wxAppId: "wx3dd49afb7e2cf957",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEnvironment = (name: string): QClawEnvironment => {
|
||||||
|
const env = ENVIRONMENTS[name];
|
||||||
|
if (!env) throw new Error(`未知环境: ${name},可选: ${Object.keys(ENVIRONMENTS).join(", ")}`);
|
||||||
|
return env;
|
||||||
|
};
|
||||||
19
auth/index.ts
Normal file
19
auth/index.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* @file auth/index.ts
|
||||||
|
* @description 认证模块导出
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type {
|
||||||
|
QClawEnvironment,
|
||||||
|
LoginCredentials,
|
||||||
|
PersistedAuthState,
|
||||||
|
QClawApiResponse,
|
||||||
|
} from "./types.js";
|
||||||
|
export { TokenExpiredError } from "./types.js";
|
||||||
|
|
||||||
|
export { getEnvironment } from "./environments.js";
|
||||||
|
export { getDeviceGuid } from "./device-guid.js";
|
||||||
|
export { QClawAPI } from "./qclaw-api.js";
|
||||||
|
export { loadState, saveState, clearState } from "./state-store.js";
|
||||||
|
export { performLogin } from "./wechat-login.js";
|
||||||
|
export type { PerformLoginOptions } from "./wechat-login.js";
|
||||||
129
auth/qclaw-api.ts
Normal file
129
auth/qclaw-api.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* @file qclaw-api.ts
|
||||||
|
* @description QClaw JPRX 网关 API 客户端
|
||||||
|
*
|
||||||
|
* 对应 Python demo 的 QClawAPI 类,所有业务接口走 POST {jprxGateway}data/{cmdId}/forward。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { QClawEnvironment, QClawApiResponse } from "./types.js";
|
||||||
|
import { TokenExpiredError } from "./types.js";
|
||||||
|
import { nested } from "./utils.js";
|
||||||
|
|
||||||
|
export class QClawAPI {
|
||||||
|
private env: QClawEnvironment;
|
||||||
|
private guid: string;
|
||||||
|
|
||||||
|
/** 鉴权 key,登录后可由服务端返回新值 */
|
||||||
|
loginKey = "m83qdao0AmE5";
|
||||||
|
|
||||||
|
jwtToken: string;
|
||||||
|
userId = "";
|
||||||
|
|
||||||
|
constructor(env: QClawEnvironment, guid: string, jwtToken = "") {
|
||||||
|
this.env = env;
|
||||||
|
this.guid = guid;
|
||||||
|
this.jwtToken = jwtToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
private headers(): Record<string, string> {
|
||||||
|
const h: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Version": "1",
|
||||||
|
"X-Token": this.loginKey,
|
||||||
|
"X-Guid": this.guid,
|
||||||
|
"X-Account": this.userId || "1",
|
||||||
|
"X-Session": "",
|
||||||
|
};
|
||||||
|
if (this.jwtToken) {
|
||||||
|
h["X-OpenClaw-Token"] = this.jwtToken;
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async post(path: string, body: Record<string, unknown> = {}): Promise<QClawApiResponse> {
|
||||||
|
const url = `${this.env.jprxGateway}${path}`;
|
||||||
|
const payload = { ...body, web_version: "1.4.0", web_env: "release" };
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: this.headers(),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Token 续期
|
||||||
|
const newToken = res.headers.get("X-New-Token");
|
||||||
|
if (newToken) this.jwtToken = newToken;
|
||||||
|
|
||||||
|
const data = (await res.json()) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const ret = data.ret;
|
||||||
|
const commonCode =
|
||||||
|
nested(data, "data", "resp", "common", "code") ??
|
||||||
|
nested(data, "data", "common", "code") ??
|
||||||
|
nested(data, "resp", "common", "code") ??
|
||||||
|
nested(data, "common", "code");
|
||||||
|
|
||||||
|
// Token 过期
|
||||||
|
if (commonCode === 21004) {
|
||||||
|
throw new TokenExpiredError();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ret === 0 || commonCode === 0) {
|
||||||
|
const respData =
|
||||||
|
nested(data, "data", "resp", "data") ??
|
||||||
|
nested(data, "data", "data") ??
|
||||||
|
data.data ??
|
||||||
|
data;
|
||||||
|
return { success: true, data: respData as Record<string, unknown> };
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
(nested(data, "data", "common", "message") as string) ??
|
||||||
|
(nested(data, "resp", "common", "message") as string) ??
|
||||||
|
(nested(data, "common", "message") as string) ??
|
||||||
|
"请求失败";
|
||||||
|
return { success: false, message, data: data as Record<string, unknown> };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 业务 API ----------
|
||||||
|
|
||||||
|
/** 获取微信登录 state(OAuth2 安全校验) */
|
||||||
|
async getWxLoginState(): Promise<QClawApiResponse> {
|
||||||
|
return this.post("data/4050/forward", { guid: this.guid });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用微信授权 code 换取 token */
|
||||||
|
async wxLogin(code: string, state: string): Promise<QClawApiResponse> {
|
||||||
|
return this.post("data/4026/forward", { guid: this.guid, code, state });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建模型 API Key */
|
||||||
|
async createApiKey(): Promise<QClawApiResponse> {
|
||||||
|
return this.post("data/4055/forward", {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取用户信息 */
|
||||||
|
async getUserInfo(): Promise<QClawApiResponse> {
|
||||||
|
return this.post("data/4027/forward", {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 检查邀请码验证状态 */
|
||||||
|
async checkInviteCode(userId: string): Promise<QClawApiResponse> {
|
||||||
|
return this.post("data/4056/forward", { user_id: userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交邀请码 */
|
||||||
|
async submitInviteCode(userId: string, code: string): Promise<QClawApiResponse> {
|
||||||
|
return this.post("data/4057/forward", { user_id: userId, code });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 刷新渠道 token */
|
||||||
|
async refreshChannelToken(): Promise<string | null> {
|
||||||
|
const result = await this.post("data/4058/forward", {});
|
||||||
|
if (result.success) {
|
||||||
|
return (result.data as Record<string, unknown>)?.openclaw_channel_token as string ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
auth/state-store.ts
Normal file
48
auth/state-store.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* @file state-store.ts
|
||||||
|
* @description 登录态持久化存储
|
||||||
|
*
|
||||||
|
* 将 token 保存到本地文件,下次启动时自动加载,避免重复扫码。
|
||||||
|
* 文件权限设为 0o600,仅当前用户可读写。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, chmodSync } from "node:fs";
|
||||||
|
import { join, dirname } from "node:path";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import type { PersistedAuthState } from "./types.js";
|
||||||
|
|
||||||
|
const DEFAULT_STATE_PATH = join(homedir(), ".openclaw", "wechat-access-auth.json");
|
||||||
|
|
||||||
|
export const getStatePath = (customPath?: string): string =>
|
||||||
|
customPath || DEFAULT_STATE_PATH;
|
||||||
|
|
||||||
|
export const loadState = (customPath?: string): PersistedAuthState | null => {
|
||||||
|
const filePath = getStatePath(customPath);
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(filePath, "utf-8");
|
||||||
|
return JSON.parse(raw) as PersistedAuthState;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveState = (state: PersistedAuthState, customPath?: string): void => {
|
||||||
|
const filePath = getStatePath(customPath);
|
||||||
|
mkdirSync(dirname(filePath), { recursive: true });
|
||||||
|
writeFileSync(filePath, JSON.stringify(state, null, 2), { encoding: "utf-8", mode: 0o600 });
|
||||||
|
// 确保已有文件也收紧权限
|
||||||
|
try {
|
||||||
|
chmodSync(filePath, 0o600);
|
||||||
|
} catch {
|
||||||
|
// Windows 等平台可能不支持 chmod,忽略
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearState = (customPath?: string): void => {
|
||||||
|
const filePath = getStatePath(customPath);
|
||||||
|
try {
|
||||||
|
unlinkSync(filePath);
|
||||||
|
} catch {
|
||||||
|
// file not found — ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
57
auth/types.ts
Normal file
57
auth/types.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* @file types.ts
|
||||||
|
* @description 微信扫码登录相关类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** QClaw 环境配置 */
|
||||||
|
export interface QClawEnvironment {
|
||||||
|
/** JPRX 网关地址 */
|
||||||
|
jprxGateway: string;
|
||||||
|
/** QClaw 基础 URL (未直接使用,走 JPRX 网关) */
|
||||||
|
qclawBaseUrl: string;
|
||||||
|
/** 微信登录回调地址 */
|
||||||
|
wxLoginRedirectUri: string;
|
||||||
|
/** WebSocket 网关地址 */
|
||||||
|
wechatWsUrl: string;
|
||||||
|
/** 微信开放平台 AppID */
|
||||||
|
wxAppId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录凭证 */
|
||||||
|
export interface LoginCredentials {
|
||||||
|
/** JWT Token (用于 API 鉴权) */
|
||||||
|
jwtToken: string;
|
||||||
|
/** Channel Token (用于 WebSocket 连接) */
|
||||||
|
channelToken: string;
|
||||||
|
/** 用户信息 */
|
||||||
|
userInfo: Record<string, unknown>;
|
||||||
|
/** API Key (用于调用模型) */
|
||||||
|
apiKey: string;
|
||||||
|
/** 设备 GUID */
|
||||||
|
guid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 持久化的登录态 */
|
||||||
|
export interface PersistedAuthState {
|
||||||
|
jwtToken: string;
|
||||||
|
channelToken: string;
|
||||||
|
apiKey: string;
|
||||||
|
guid: string;
|
||||||
|
userInfo: Record<string, unknown>;
|
||||||
|
savedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** QClaw API 通用响应 */
|
||||||
|
export interface QClawApiResponse<T = unknown> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Token 过期错误 */
|
||||||
|
export class TokenExpiredError extends Error {
|
||||||
|
constructor(message = "登录已过期,请重新登录") {
|
||||||
|
super(message);
|
||||||
|
this.name = "TokenExpiredError";
|
||||||
|
}
|
||||||
|
}
|
||||||
14
auth/utils.ts
Normal file
14
auth/utils.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* @file utils.ts
|
||||||
|
* @description 认证模块共享工具函数
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 安全嵌套取值 */
|
||||||
|
export const nested = (obj: unknown, ...keys: string[]): unknown => {
|
||||||
|
let current = obj;
|
||||||
|
for (const key of keys) {
|
||||||
|
if (current == null || typeof current !== "object") return undefined;
|
||||||
|
current = (current as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
};
|
||||||
238
auth/wechat-login.ts
Normal file
238
auth/wechat-login.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
/**
|
||||||
|
* @file wechat-login.ts
|
||||||
|
* @description 微信扫码登录流程编排
|
||||||
|
*
|
||||||
|
* 对应 Python demo 的 WeChatLogin 类和 do_login 函数。
|
||||||
|
* 流程:获取 state → 生成二维码 → 等待 code → 换 token → (邀请码) → 保存
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createInterface } from "node:readline";
|
||||||
|
import type { QClawEnvironment, LoginCredentials, PersistedAuthState } from "./types.js";
|
||||||
|
import { QClawAPI } from "./qclaw-api.js";
|
||||||
|
import { saveState } from "./state-store.js";
|
||||||
|
import { nested } from "./utils.js";
|
||||||
|
|
||||||
|
/** 构造微信 OAuth2 授权 URL */
|
||||||
|
const buildAuthUrl = (state: string, env: QClawEnvironment): string => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
appid: env.wxAppId,
|
||||||
|
redirect_uri: env.wxLoginRedirectUri,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "snsapi_login",
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
return `https://open.weixin.qq.com/connect/qrconnect?${params.toString()}#wechat_redirect`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 在终端显示二维码 */
|
||||||
|
const displayQrCode = async (url: string): Promise<void> => {
|
||||||
|
console.log("\n" + "=".repeat(60));
|
||||||
|
console.log(" 请用微信扫描下方二维码登录");
|
||||||
|
console.log("=".repeat(60));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// qrcode-terminal 是 CJS 模块,动态 import
|
||||||
|
const qrterm = await import("qrcode-terminal");
|
||||||
|
const generate = qrterm.default?.generate ?? qrterm.generate;
|
||||||
|
generate(url, { small: true }, (qrcode: string) => {
|
||||||
|
console.log(qrcode);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
console.log("\n(未安装 qrcode-terminal,无法在终端显示二维码)");
|
||||||
|
console.log("请安装: npm install qrcode-terminal");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n或者在浏览器中打开以下链接:");
|
||||||
|
console.log(` ${url}`);
|
||||||
|
console.log("=".repeat(60));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 从 stdin 读取一行 */
|
||||||
|
const readLine = (prompt: string): Promise<string> => {
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
rl.question(prompt, (answer) => {
|
||||||
|
rl.close();
|
||||||
|
resolve(answer.trim());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等待用户输入微信授权后重定向 URL 中的 code
|
||||||
|
*
|
||||||
|
* 接受两种输入:
|
||||||
|
* 1. 完整 URL(自动从 query string 或 fragment 提取 code)
|
||||||
|
* 2. 裸 code 字符串
|
||||||
|
*/
|
||||||
|
const waitForAuthCode = async (): Promise<string> => {
|
||||||
|
console.log();
|
||||||
|
console.log("微信扫码授权后,浏览器会跳转到一个新页面。");
|
||||||
|
console.log("请从浏览器地址栏复制完整 URL,或只复制 code 参数值。");
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
const raw = await readLine("请粘贴 URL 或 code: ");
|
||||||
|
if (!raw) return "";
|
||||||
|
|
||||||
|
// 尝试从 URL 中提取 code
|
||||||
|
if (raw.includes("code=")) {
|
||||||
|
try {
|
||||||
|
const url = new URL(raw);
|
||||||
|
// 先查 query string
|
||||||
|
const code = url.searchParams.get("code");
|
||||||
|
if (code) return code;
|
||||||
|
// 再查 fragment(微信可能将 code 放在 hash 后面)
|
||||||
|
if (url.hash) {
|
||||||
|
const fragmentParams = new URLSearchParams(url.hash.replace(/^#/, ""));
|
||||||
|
const fCode = fragmentParams.get("code");
|
||||||
|
if (fCode) return fCode;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// URL 解析失败,尝试正则
|
||||||
|
}
|
||||||
|
const match = raw.match(/[?&#]code=([^&#]+)/);
|
||||||
|
if (match?.[1]) return match[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接就是 code
|
||||||
|
return raw;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PerformLoginOptions {
|
||||||
|
guid: string;
|
||||||
|
env: QClawEnvironment;
|
||||||
|
bypassInvite?: boolean;
|
||||||
|
/** 自定义 state 文件路径 */
|
||||||
|
authStatePath?: string;
|
||||||
|
/** 日志函数 */
|
||||||
|
log?: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行完整的微信扫码登录流程
|
||||||
|
*
|
||||||
|
* 步骤:
|
||||||
|
* 1. 获取 OAuth state
|
||||||
|
* 2. 生成二维码并展示
|
||||||
|
* 3. 等待用户输入 code
|
||||||
|
* 4. 用 code 换 token
|
||||||
|
* 5. 创建 API Key(非致命)
|
||||||
|
* 6. 邀请码检查(可绕过)
|
||||||
|
* 7. 保存登录态
|
||||||
|
*/
|
||||||
|
export const performLogin = async (options: PerformLoginOptions): Promise<LoginCredentials> => {
|
||||||
|
const { guid, env, bypassInvite = false, authStatePath, log } = options;
|
||||||
|
const info = (...args: unknown[]) => log?.info?.(...args) ?? console.log(...args);
|
||||||
|
const warn = (...args: unknown[]) => log?.warn?.(...args) ?? console.warn(...args);
|
||||||
|
|
||||||
|
const api = new QClawAPI(env, guid);
|
||||||
|
|
||||||
|
// 1. 获取 OAuth state
|
||||||
|
info("[Login] 步骤 1/5: 获取登录 state...");
|
||||||
|
let state = String(Math.floor(Math.random() * 10000)); // 随机兜底
|
||||||
|
const stateResult = await api.getWxLoginState();
|
||||||
|
if (stateResult.success) {
|
||||||
|
const s = nested(stateResult.data, "state") as string | undefined;
|
||||||
|
if (s) state = s;
|
||||||
|
}
|
||||||
|
info(`[Login] state=${state}`);
|
||||||
|
|
||||||
|
// 2. 生成二维码
|
||||||
|
info("[Login] 步骤 2/5: 生成微信登录二维码...");
|
||||||
|
const authUrl = buildAuthUrl(state, env);
|
||||||
|
await displayQrCode(authUrl);
|
||||||
|
|
||||||
|
// 3. 等待 code
|
||||||
|
info("[Login] 步骤 3/5: 等待微信扫码授权...");
|
||||||
|
const code = await waitForAuthCode();
|
||||||
|
if (!code) {
|
||||||
|
throw new Error("未获取到授权 code");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 用 code 换 token
|
||||||
|
info(`[Login] 步骤 4/5: 用授权码登录 (code=${code.substring(0, 10)}...)`);
|
||||||
|
const loginResult = await api.wxLogin(code, state);
|
||||||
|
if (!loginResult.success) {
|
||||||
|
throw new Error(`登录失败: ${loginResult.message ?? "未知错误"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginData = loginResult.data as Record<string, unknown>;
|
||||||
|
const jwtToken = (loginData.token as string) || "";
|
||||||
|
const channelToken = (loginData.openclaw_channel_token as string) || "";
|
||||||
|
const userInfo = (loginData.user_info as Record<string, unknown>) || {};
|
||||||
|
|
||||||
|
api.jwtToken = jwtToken;
|
||||||
|
api.userId = String(userInfo.user_id ?? "");
|
||||||
|
// 更新 loginKey(服务端可能返回新值,后续 API 调用需要使用)
|
||||||
|
const loginKey = userInfo.loginKey as string | undefined;
|
||||||
|
if (loginKey) {
|
||||||
|
api.loginKey = loginKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
info(`[Login] 登录成功! 用户: ${(userInfo.nickname as string) ?? "unknown"}`);
|
||||||
|
|
||||||
|
// 5. 创建 API Key(非致命)
|
||||||
|
info("[Login] 步骤 5/5: 创建 API Key...");
|
||||||
|
let apiKey = "";
|
||||||
|
try {
|
||||||
|
const keyResult = await api.createApiKey();
|
||||||
|
if (keyResult.success) {
|
||||||
|
apiKey =
|
||||||
|
(nested(keyResult.data, "key") as string) ??
|
||||||
|
(nested(keyResult.data, "resp", "data", "key") as string) ??
|
||||||
|
"";
|
||||||
|
if (apiKey) info(`[Login] API Key: ${apiKey.substring(0, 8)}...`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
warn(`[Login] 创建 API Key 失败(非致命): ${e}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 邀请码检查
|
||||||
|
const userId = String(userInfo.user_id ?? "");
|
||||||
|
if (userId && !bypassInvite) {
|
||||||
|
try {
|
||||||
|
const check = await api.checkInviteCode(userId);
|
||||||
|
if (check.success) {
|
||||||
|
const verified = nested(check.data, "already_verified");
|
||||||
|
if (!verified) {
|
||||||
|
info("\n[Login] 需要邀请码验证。");
|
||||||
|
const inviteCode = await readLine("请输入邀请码: ");
|
||||||
|
if (inviteCode) {
|
||||||
|
const submitResult = await api.submitInviteCode(userId, inviteCode);
|
||||||
|
if (!submitResult.success) {
|
||||||
|
throw new Error(`邀请码验证失败: ${submitResult.message}`);
|
||||||
|
}
|
||||||
|
info("[Login] 邀请码验证通过!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message.includes("邀请码验证失败")) throw e;
|
||||||
|
warn(`[Login] 邀请码检查失败(非致命): ${e}`);
|
||||||
|
}
|
||||||
|
} else if (bypassInvite) {
|
||||||
|
info("[Login] 已跳过邀请码验证 (bypassInvite=true)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存登录态
|
||||||
|
const credentials: LoginCredentials = {
|
||||||
|
jwtToken,
|
||||||
|
channelToken,
|
||||||
|
userInfo,
|
||||||
|
apiKey,
|
||||||
|
guid,
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistedState: PersistedAuthState = {
|
||||||
|
jwtToken,
|
||||||
|
channelToken,
|
||||||
|
apiKey,
|
||||||
|
guid,
|
||||||
|
userInfo,
|
||||||
|
savedAt: Date.now(),
|
||||||
|
};
|
||||||
|
saveState(persistedState, authStatePath);
|
||||||
|
info("[Login] 登录态已保存");
|
||||||
|
|
||||||
|
return credentials;
|
||||||
|
};
|
||||||
49
common/agent-events.ts
Normal file
49
common/agent-events.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { onAgentEvent as OnAgentEventType } from "openclaw/plugin-sdk";
|
||||||
|
|
||||||
|
export type AgentEventStream = "lifecycle" | "tool" | "assistant" | "error" | (string & {});
|
||||||
|
|
||||||
|
export type AgentEventPayload = {
|
||||||
|
runId: string;
|
||||||
|
seq: number;
|
||||||
|
stream: AgentEventStream;
|
||||||
|
ts: number;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
sessionKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 动态导入,兼容 openclaw 未导出该函数的情况
|
||||||
|
let _onAgentEvent: typeof OnAgentEventType | undefined;
|
||||||
|
|
||||||
|
// SDK 加载完成的 Promise,确保只加载一次
|
||||||
|
const sdkReady: Promise<typeof OnAgentEventType | undefined> = (async () => {
|
||||||
|
try {
|
||||||
|
const sdk = await import("openclaw/plugin-sdk");
|
||||||
|
if (typeof sdk.onAgentEvent === "function") {
|
||||||
|
_onAgentEvent = sdk.onAgentEvent;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return _onAgentEvent;
|
||||||
|
})();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册 Agent 事件监听器。
|
||||||
|
*
|
||||||
|
* 修复了原版的时序问题:原版使用 loadOnAgentEvent().then() 异步注册,
|
||||||
|
* 导致在 dispatchReplyWithBufferedBlockDispatcher 调用之前注册的监听器
|
||||||
|
* 实际上在 Agent 开始产生事件时还未真正挂载,造成事件全部丢失。
|
||||||
|
*
|
||||||
|
* 新版通过 await sdkReady 确保 SDK 加载完成后再注册监听器,
|
||||||
|
* 调用方需要 await 此函数返回的 Promise,再调用 dispatchReply。
|
||||||
|
*/
|
||||||
|
export const onAgentEvent = async (
|
||||||
|
listener: Parameters<typeof OnAgentEventType>[0]
|
||||||
|
): Promise<() => boolean> => {
|
||||||
|
const fn = await sdkReady;
|
||||||
|
if (fn) {
|
||||||
|
const unsubscribe = fn(listener);
|
||||||
|
return unsubscribe;
|
||||||
|
}
|
||||||
|
return () => false;
|
||||||
|
};
|
||||||
174
common/message-context.ts
Normal file
174
common/message-context.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import type { FuwuhaoMessage } from "../http/types.js";
|
||||||
|
import { getWecomRuntime } from "./runtime.js";
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 渠道来源标签
|
||||||
|
// ============================================
|
||||||
|
// 用于 ChannelSource,标识消息来自哪个微信渠道
|
||||||
|
// UI 侧可通过此字段区分不同来源,做差异化展示或交互限制
|
||||||
|
export const WECHAT_CHANNEL_LABELS = {
|
||||||
|
/** 微信服务号 */
|
||||||
|
serviceAccount: "serviceAccount",
|
||||||
|
/** 微信小程序 */
|
||||||
|
miniProgram: "miniProgram",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 消息上下文构建
|
||||||
|
// ============================================
|
||||||
|
// 将微信服务号的原始消息转换为 OpenClaw 标准的消息上下文
|
||||||
|
// 包括路由解析、会话管理、消息格式化等核心功能
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息上下文返回类型
|
||||||
|
* @property ctx - OpenClaw 标准的消息上下文对象,包含所有必要的消息元数据
|
||||||
|
* @property route - 路由信息,用于确定消息应该发送到哪个 Agent
|
||||||
|
* @property storePath - 会话存储路径,用于持久化会话数据
|
||||||
|
*/
|
||||||
|
export interface MessageContext {
|
||||||
|
ctx: Record<string, unknown>;
|
||||||
|
route: {
|
||||||
|
sessionKey: string; // 会话唯一标识,用于关联同一用户的多轮对话
|
||||||
|
agentId: string; // Agent ID,标识处理此消息的 Agent
|
||||||
|
accountId: string; // 账号 ID,用于多账号场景
|
||||||
|
};
|
||||||
|
storePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建消息上下文
|
||||||
|
* @param message - 微信服务号的原始消息对象
|
||||||
|
* @returns MessageContext 包含上下文、路由和存储路径的完整消息上下文
|
||||||
|
* @description
|
||||||
|
* 此函数是消息处理的核心,负责:
|
||||||
|
* 1. 提取和标准化消息字段(兼容多种格式)
|
||||||
|
* 2. 解析路由,确定消息应该发送到哪个 Agent
|
||||||
|
* 3. 获取会话存储路径,用于持久化对话历史
|
||||||
|
* 4. 格式化消息为 OpenClaw 标准格式
|
||||||
|
* 5. 构建完整的消息上下文对象
|
||||||
|
*
|
||||||
|
* 内部流程:
|
||||||
|
* - 从 runtime 获取配置
|
||||||
|
* - 提取用户 ID、消息 ID、内容等关键信息
|
||||||
|
* - 调用 routing.resolveAgentRoute 解析路由
|
||||||
|
* - 调用 session.resolveStorePath 获取存储路径
|
||||||
|
* - 调用 reply.formatInboundEnvelope 格式化消息
|
||||||
|
* - 调用 reply.finalizeInboundContext 构建最终上下文
|
||||||
|
*/
|
||||||
|
export const buildMessageContext = (message: FuwuhaoMessage): MessageContext => {
|
||||||
|
// 获取 OpenClaw 运行时实例
|
||||||
|
const runtime = getWecomRuntime();
|
||||||
|
// 加载全局配置(包含 Agent 配置、路由规则等)
|
||||||
|
const cfg = runtime.config.loadConfig();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 1. 提取和标准化消息字段
|
||||||
|
// ============================================
|
||||||
|
// 兼容多种字段命名(FromUserName/userid)
|
||||||
|
const userId = message.FromUserName || message.userid || "unknown";
|
||||||
|
const toUser = message.ToUserName || "unknown";
|
||||||
|
// 确保消息 ID 唯一(用于去重和追踪)
|
||||||
|
const messageId = message.MsgId || message.msgid || `${Date.now()}`;
|
||||||
|
// TODO: 微信的 CreateTime 是秒级时间戳,需要转换为毫秒
|
||||||
|
// const timestamp = message.CreateTime ? message.CreateTime * 1000 : Date.now();
|
||||||
|
const timestamp = Date.now();
|
||||||
|
// 提取消息内容(兼容 Content 和 text.content 两种格式)
|
||||||
|
const content = message.Content || message.text?.content || "";
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 2. 解析路由 - 确定消息应该发送到哪个 Agent
|
||||||
|
// ============================================
|
||||||
|
// runtime.channel.routing.resolveAgentRoute 是 OpenClaw 的核心路由方法
|
||||||
|
// 根据频道、账号、对话类型等信息,决定使用哪个 Agent 处理消息
|
||||||
|
const frameworkRoute = runtime.channel.routing.resolveAgentRoute({
|
||||||
|
cfg, // 全局配置
|
||||||
|
channel: "wechat-access", // 频道标识
|
||||||
|
accountId: "default", // 账号 ID(支持多账号场景)
|
||||||
|
peer: {
|
||||||
|
kind: "dm", // 对话类型:dm=私聊,group=群聊
|
||||||
|
id: userId, // 对话对象 ID(用户 ID)
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// 框架返回的 sessionKey 通常是 agent:main:main,与 PC 端默认 session 相同。
|
||||||
|
// 为了让 UI 能区分外部渠道消息,使用独立的 sessionKey 格式:
|
||||||
|
// agent:{agentId}:wechat-access:direct:{userId}
|
||||||
|
const channelSessionKey = `agent:${frameworkRoute.agentId}:wechat-access:direct:${userId}`;
|
||||||
|
const route = {
|
||||||
|
...frameworkRoute,
|
||||||
|
sessionKey: channelSessionKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 3. 获取消息格式化选项
|
||||||
|
// ============================================
|
||||||
|
// runtime.channel.reply.resolveEnvelopeFormatOptions 获取消息格式化配置
|
||||||
|
// 包括时间格式、前缀、后缀等显示选项
|
||||||
|
const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 4. 获取会话存储路径
|
||||||
|
// ============================================
|
||||||
|
// runtime.channel.session.resolveStorePath 计算会话数据的存储路径
|
||||||
|
// 用于持久化对话历史、上下文等信息
|
||||||
|
const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
|
||||||
|
agentId: route.agentId,
|
||||||
|
});
|
||||||
|
// 存储路径通常类似:/data/sessions/{agentId}/{sessionKey}.json
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 5. 读取上次会话时间
|
||||||
|
// ============================================
|
||||||
|
// runtime.channel.session.readSessionUpdatedAt 读取上次会话的更新时间
|
||||||
|
// 用于判断会话是否过期,是否需要重置上下文
|
||||||
|
const previousTimestamp = runtime.channel.session.readSessionUpdatedAt({
|
||||||
|
storePath,
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
});
|
||||||
|
// 如果距离上次会话时间过长,可能会清空历史上下文
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 6. 格式化入站消息
|
||||||
|
// ============================================
|
||||||
|
// runtime.channel.reply.formatInboundEnvelope 将原始消息格式化为标准格式
|
||||||
|
// 添加时间戳、发送者信息、格式化选项等
|
||||||
|
const body = runtime.channel.reply.formatInboundEnvelope({
|
||||||
|
channel: "wechat-access", // 频道标识
|
||||||
|
from: userId, // 发送者 ID
|
||||||
|
timestamp, // 消息时间戳
|
||||||
|
body: content, // 消息内容
|
||||||
|
chatType: "direct", // 对话类型(direct=私聊)
|
||||||
|
sender: {
|
||||||
|
id: userId, // 发送者 ID
|
||||||
|
},
|
||||||
|
previousTimestamp, // 上次会话时间(用于判断是否需要添加时间分隔符)
|
||||||
|
envelope: envelopeOptions, // 格式化选项
|
||||||
|
});
|
||||||
|
// 返回格式化后的消息体,可能包含时间前缀、发送者名称等
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 7. 构建完整的消息上下文
|
||||||
|
// ============================================
|
||||||
|
// runtime.channel.reply.finalizeInboundContext 构建 OpenClaw 标准的消息上下文
|
||||||
|
// 这是 Agent 处理消息时使用的核心数据结构
|
||||||
|
const ctx = runtime.channel.reply.finalizeInboundContext({
|
||||||
|
Body: body, // 格式化后的消息体
|
||||||
|
RawBody: content, // 原始消息内容
|
||||||
|
CommandBody: content, // 命令体(用于解析命令)
|
||||||
|
From: `wechat-access:${userId}`, // 发送者标识(带频道前缀)
|
||||||
|
To: `wechat-access:${toUser}`, // 接收者标识
|
||||||
|
SessionKey: route.sessionKey, // 会话键
|
||||||
|
AccountId: route.accountId, // 账号 ID
|
||||||
|
ChatType: "direct" as const, // 对话类型
|
||||||
|
ChannelSource: WECHAT_CHANNEL_LABELS.serviceAccount, // 渠道来源标识(用于 UI 侧区分消息来源)
|
||||||
|
SenderId: userId, // 发送者 ID
|
||||||
|
Provider: "wechat-access", // 提供商标识
|
||||||
|
Surface: "wechat-access", // 界面标识
|
||||||
|
MessageSid: messageId, // 消息唯一标识
|
||||||
|
Timestamp: timestamp, // 时间戳
|
||||||
|
OriginatingChannel: "wechat-access" as const, // 原始频道
|
||||||
|
OriginatingTo: `wechat-access:${userId}`, // 原始接收者
|
||||||
|
});
|
||||||
|
// ctx 包含了 Agent 处理消息所需的所有信息
|
||||||
|
|
||||||
|
return { ctx, route, storePath };
|
||||||
|
};
|
||||||
35
common/runtime.ts
Normal file
35
common/runtime.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Runtime 管理
|
||||||
|
// ============================================
|
||||||
|
// 用于存储和获取 OpenClaw 的运行时实例
|
||||||
|
// Runtime 提供了访问配置、会话、路由、事件等核心功能的接口
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局运行时实例
|
||||||
|
* 在插件初始化时由 OpenClaw 框架注入
|
||||||
|
*/
|
||||||
|
let runtime: PluginRuntime | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置微信企业号运行时实例
|
||||||
|
* @param next - OpenClaw 提供的运行时实例
|
||||||
|
* @description 此方法应在插件初始化时调用一次,用于注入运行时依赖
|
||||||
|
*/
|
||||||
|
export const setWecomRuntime = (next: PluginRuntime): void => {
|
||||||
|
runtime = next;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取微信企业号运行时实例
|
||||||
|
* @returns OpenClaw 运行时实例
|
||||||
|
* @throws 如果运行时未初始化则抛出错误
|
||||||
|
* @description 在需要访问 OpenClaw 核心功能时调用此方法
|
||||||
|
*/
|
||||||
|
export const getWecomRuntime = (): PluginRuntime => {
|
||||||
|
if (!runtime) {
|
||||||
|
throw new Error("WeCom runtime not initialized");
|
||||||
|
}
|
||||||
|
return runtime;
|
||||||
|
};
|
||||||
138
http/README.md
Normal file
138
http/README.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# Fuwuhao (微信服务号) 模块
|
||||||
|
|
||||||
|
## 📁 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── types.ts # 类型定义
|
||||||
|
├── crypto-utils.ts # 加密解密工具
|
||||||
|
├── http-utils.ts # HTTP 请求处理工具
|
||||||
|
├── callback-service.ts # 后置回调服务
|
||||||
|
├── message-context.ts # 消息上下文构建
|
||||||
|
├── message-handler.ts # 消息处理器(核心业务逻辑)
|
||||||
|
├── webhook.ts # Webhook 处理器(主入口)
|
||||||
|
├── runtime.ts # Runtime 配置
|
||||||
|
└── index.ts # 模块导出索引
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📦 模块说明
|
||||||
|
|
||||||
|
### 1. `types.ts` - 类型定义
|
||||||
|
定义所有 TypeScript 类型和接口:
|
||||||
|
- `AgentEventPayload` - Agent 事件载荷
|
||||||
|
- `FuwuhaoMessage` - 服务号消息格式
|
||||||
|
- `SimpleAccount` - 账号配置
|
||||||
|
- `CallbackPayload` - 回调数据格式
|
||||||
|
- `StreamChunk` - 流式消息块
|
||||||
|
- `StreamCallback` - 流式回调函数类型
|
||||||
|
|
||||||
|
### 2. `crypto-utils.ts` - 加密解密工具
|
||||||
|
处理微信服务号的签名验证和消息加密解密:
|
||||||
|
- `verifySignature()` - 验证签名
|
||||||
|
- `decryptMessage()` - 解密消息
|
||||||
|
|
||||||
|
### 3. `http-utils.ts` - HTTP 工具
|
||||||
|
处理 HTTP 请求相关的工具方法:
|
||||||
|
- `parseQuery()` - 解析查询参数
|
||||||
|
- `readBody()` - 读取请求体
|
||||||
|
- `isFuwuhaoWebhookPath()` - 检查是否是服务号 webhook 路径
|
||||||
|
|
||||||
|
### 4. `callback-service.ts` - 后置回调服务
|
||||||
|
将处理结果发送到外部回调服务:
|
||||||
|
- `sendToCallbackService()` - 发送回调数据
|
||||||
|
|
||||||
|
### 5. `message-context.ts` - 消息上下文构建
|
||||||
|
构建消息处理所需的上下文信息:
|
||||||
|
- `buildMessageContext()` - 构建消息上下文(路由、会话、格式化等)
|
||||||
|
|
||||||
|
### 6. `message-handler.ts` - 消息处理器
|
||||||
|
核心业务逻辑,处理消息并调用 Agent:
|
||||||
|
- `handleMessage()` - 同步处理消息
|
||||||
|
- `handleMessageStream()` - 流式处理消息(SSE)
|
||||||
|
|
||||||
|
### 7. `webhook.ts` - Webhook 处理器
|
||||||
|
主入口,处理微信服务号的 webhook 请求:
|
||||||
|
- `handleSimpleWecomWebhook()` - 处理 GET/POST 请求,支持同步和流式返回
|
||||||
|
|
||||||
|
### 8. `runtime.ts` - Runtime 配置
|
||||||
|
获取 OpenClaw 运行时实例
|
||||||
|
|
||||||
|
### 9. `index.ts` - 模块导出
|
||||||
|
统一导出所有公共 API
|
||||||
|
|
||||||
|
## 🔄 数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
微信服务号
|
||||||
|
↓
|
||||||
|
webhook.ts (入口)
|
||||||
|
↓
|
||||||
|
http-utils.ts (解析请求)
|
||||||
|
↓
|
||||||
|
crypto-utils.ts (验证签名/解密)
|
||||||
|
↓
|
||||||
|
message-context.ts (构建上下文)
|
||||||
|
↓
|
||||||
|
message-handler.ts (处理消息)
|
||||||
|
↓
|
||||||
|
OpenClaw Agent (AI 处理)
|
||||||
|
↓
|
||||||
|
callback-service.ts (后置回调)
|
||||||
|
↓
|
||||||
|
返回响应
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 使用示例
|
||||||
|
|
||||||
|
### 基本使用
|
||||||
|
```typescript
|
||||||
|
import { handleSimpleWecomWebhook } from "./src/webhook.js";
|
||||||
|
|
||||||
|
// 在 HTTP 服务器中使用
|
||||||
|
server.on("request", async (req, res) => {
|
||||||
|
const handled = await handleSimpleWecomWebhook(req, res);
|
||||||
|
if (!handled) {
|
||||||
|
// 处理其他路由
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 流式返回(SSE)
|
||||||
|
```typescript
|
||||||
|
// 客户端请求时添加 stream 参数
|
||||||
|
fetch("/fuwuhao?stream=true", {
|
||||||
|
headers: {
|
||||||
|
"Accept": "text/event-stream"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 配置
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
- `FUWUHAO_CALLBACK_URL` - 后置回调服务 URL(默认:`http://localhost:3001/api/fuwuhao/callback`)
|
||||||
|
|
||||||
|
### 账号配置
|
||||||
|
在 `webhook.ts` 中修改 `mockAccount` 对象:
|
||||||
|
```typescript
|
||||||
|
const mockAccount: SimpleAccount = {
|
||||||
|
token: "your_token_here",
|
||||||
|
encodingAESKey: "your_encoding_aes_key_here",
|
||||||
|
receiveId: "your_receive_id_here"
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 注意事项
|
||||||
|
|
||||||
|
1. **加密解密**:当前 `crypto-utils.ts` 中的加密解密方法是简化版,生产环境需要实现真实的加密逻辑
|
||||||
|
2. **签名验证**:同样需要在生产环境中实现真实的签名验证算法
|
||||||
|
3. **错误处理**:所有模块都包含完善的错误处理和日志记录
|
||||||
|
4. **类型安全**:所有模块都使用 TypeScript 严格类型检查
|
||||||
|
|
||||||
|
## 🎯 设计原则
|
||||||
|
|
||||||
|
- **单一职责**:每个文件只负责一个特定功能
|
||||||
|
- **低耦合**:模块之间通过明确的接口通信
|
||||||
|
- **高内聚**:相关功能集中在同一模块
|
||||||
|
- **可测试**:每个模块都可以独立测试
|
||||||
|
- **可扩展**:易于添加新功能或修改现有功能
|
||||||
73
http/callback-service.ts
Normal file
73
http/callback-service.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import type { CallbackPayload } from "./types.js";
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 后置回调服务
|
||||||
|
// ============================================
|
||||||
|
// 用于将消息处理结果发送到外部服务进行后续处理
|
||||||
|
// 例如:数据统计、日志记录、业务逻辑触发等
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后置回调服务的 URL 地址
|
||||||
|
* @description
|
||||||
|
* 可通过环境变量 WECHAT_ACCESS_CALLBACK_URL 配置
|
||||||
|
* 默认值:http://localhost:3001/api/wechat-access/callback
|
||||||
|
*/
|
||||||
|
const CALLBACK_SERVICE_URL = process.env.WECHAT_ACCESS_CALLBACK_URL || "http://localhost:3001/api/wechat-access/callback";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送消息处理结果到后置回调服务
|
||||||
|
* @param payload - 回调数据载荷,包含用户消息、AI 回复、会话信息等
|
||||||
|
* @returns Promise<void> 异步执行,不阻塞主流程
|
||||||
|
* @description
|
||||||
|
* 后置回调的作用:
|
||||||
|
* 1. 记录消息处理日志
|
||||||
|
* 2. 统计用户交互数据
|
||||||
|
* 3. 触发业务逻辑(如积分、通知等)
|
||||||
|
* 4. 数据分析和监控
|
||||||
|
*
|
||||||
|
* 特点:
|
||||||
|
* - 异步执行,失败不影响主流程
|
||||||
|
* - 支持自定义认证(通过 Authorization header)
|
||||||
|
* - 自动处理错误,只记录日志
|
||||||
|
* @example
|
||||||
|
* await sendToCallbackService({
|
||||||
|
* userId: 'user123',
|
||||||
|
* messageId: 'msg456',
|
||||||
|
* userMessage: '你好',
|
||||||
|
* aiReply: '您好!有什么可以帮您?',
|
||||||
|
* success: true
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export const sendToCallbackService = async (payload: CallbackPayload): Promise<void> => {
|
||||||
|
try {
|
||||||
|
console.log("[wechat-access] 发送后置回调:", {
|
||||||
|
url: CALLBACK_SERVICE_URL,
|
||||||
|
userId: payload.userId,
|
||||||
|
hasReply: !!payload.aiReply,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(CALLBACK_SERVICE_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
// 可以添加认证头
|
||||||
|
// "Authorization": `Bearer ${process.env.CALLBACK_AUTH_TOKEN}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("[wechat-access] 后置回调服务返回错误:", {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json().catch(() => ({}));
|
||||||
|
console.log("[wechat-access] 后置回调成功:", result);
|
||||||
|
} catch (err) {
|
||||||
|
// 后置回调失败不影响主流程,只记录日志
|
||||||
|
console.error("[wechat-access] 后置回调失败:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
96
http/crypto-utils.ts
Normal file
96
http/crypto-utils.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// ============================================
|
||||||
|
// 加密解密工具
|
||||||
|
// ============================================
|
||||||
|
// 处理微信服务号的消息加密、解密和签名验证
|
||||||
|
// 微信使用 AES-256-CBC 加密算法和 SHA-1 签名算法
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证签名参数接口
|
||||||
|
* @property token - 微信服务号配置的 Token
|
||||||
|
* @property timestamp - 时间戳
|
||||||
|
* @property nonce - 随机数
|
||||||
|
* @property encrypt - 加密的消息内容
|
||||||
|
* @property signature - 微信生成的签名,用于验证消息来源
|
||||||
|
*/
|
||||||
|
export interface VerifySignatureParams {
|
||||||
|
token: string;
|
||||||
|
timestamp: string;
|
||||||
|
nonce: string;
|
||||||
|
encrypt: string;
|
||||||
|
signature: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解密消息参数接口
|
||||||
|
* @property encodingAESKey - 微信服务号配置的 EncodingAESKey(43位字符)
|
||||||
|
* @property receiveId - 接收方 ID(通常是服务号的原始 ID)
|
||||||
|
* @property encrypt - 加密的消息内容(Base64 编码)
|
||||||
|
*/
|
||||||
|
export interface DecryptMessageParams {
|
||||||
|
encodingAESKey: string;
|
||||||
|
receiveId: string;
|
||||||
|
encrypt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证微信消息签名
|
||||||
|
* @param params - 签名验证参数
|
||||||
|
* @returns 签名是否有效
|
||||||
|
* @description
|
||||||
|
* 验证流程:
|
||||||
|
* 1. 将 token、timestamp、nonce、encrypt 按字典序排序
|
||||||
|
* 2. 拼接成字符串
|
||||||
|
* 3. 进行 SHA-1 哈希
|
||||||
|
* 4. 与微信提供的 signature 比对
|
||||||
|
*
|
||||||
|
* **注意:当前为简化实现,生产环境需要实现真实的 SHA-1 签名验证**
|
||||||
|
*/
|
||||||
|
export const verifySignature = (params: VerifySignatureParams): boolean => {
|
||||||
|
// TODO: 实现真实的签名验证逻辑
|
||||||
|
// 参考算法:
|
||||||
|
// const arr = [params.token, params.timestamp, params.nonce, params.encrypt].sort();
|
||||||
|
// const str = arr.join('');
|
||||||
|
// const hash = crypto.createHash('sha1').update(str).digest('hex');
|
||||||
|
// return hash === params.signature;
|
||||||
|
|
||||||
|
console.log("[wechat-access] 验证签名参数:", params);
|
||||||
|
return true; // 简化实现,直接返回 true
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解密微信消息
|
||||||
|
* @param params - 解密参数
|
||||||
|
* @returns 解密后的明文消息(JSON 字符串)
|
||||||
|
* @description
|
||||||
|
* 解密流程:
|
||||||
|
* 1. 将 Base64 编码的 encrypt 解码为二进制
|
||||||
|
* 2. 使用 AES-256-CBC 算法解密(密钥由 encodingAESKey 派生)
|
||||||
|
* 3. 去除填充(PKCS7)
|
||||||
|
* 4. 提取消息内容(格式:随机16字节 + 4字节消息长度 + 消息内容 + receiveId)
|
||||||
|
* 5. 验证 receiveId 是否匹配
|
||||||
|
*
|
||||||
|
* **注意:当前为简化实现,返回模拟数据,生产环境需要实现真实的 AES 解密**
|
||||||
|
*/
|
||||||
|
export const decryptMessage = (params: DecryptMessageParams): string => {
|
||||||
|
// TODO: 实现真实的解密逻辑
|
||||||
|
// 参考算法:
|
||||||
|
// const key = Buffer.from(params.encodingAESKey + '=', 'base64');
|
||||||
|
// const iv = key.slice(0, 16);
|
||||||
|
// const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
||||||
|
// decipher.setAutoPadding(false);
|
||||||
|
// let decrypted = Buffer.concat([decipher.update(params.encrypt, 'base64'), decipher.final()]);
|
||||||
|
// // 去除 PKCS7 填充
|
||||||
|
// const pad = decrypted[decrypted.length - 1];
|
||||||
|
// decrypted = decrypted.slice(0, decrypted.length - pad);
|
||||||
|
// // 提取消息内容
|
||||||
|
// const content = decrypted.slice(16);
|
||||||
|
// const msgLen = content.readUInt32BE(0);
|
||||||
|
// const message = content.slice(4, 4 + msgLen).toString('utf8');
|
||||||
|
// const receiveId = content.slice(4 + msgLen).toString('utf8');
|
||||||
|
// if (receiveId !== params.receiveId) throw new Error('receiveId mismatch');
|
||||||
|
// return message;
|
||||||
|
|
||||||
|
console.log("[wechat-access] 解密参数:", params);
|
||||||
|
// 返回模拟的解密结果(标准微信消息格式)
|
||||||
|
return '{"msgtype":"text","Content":"Hello from 服务号","MsgId":"123456","FromUserName":"user001","ToUserName":"gh_test","CreateTime":1234567890}';
|
||||||
|
};
|
||||||
81
http/http-utils.ts
Normal file
81
http/http-utils.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import type { IncomingMessage } from "node:http";
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// HTTP 工具方法
|
||||||
|
// ============================================
|
||||||
|
// 提供 HTTP 请求处理的通用工具函数
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 URL 查询参数
|
||||||
|
* @param req - Node.js HTTP 请求对象
|
||||||
|
* @returns URLSearchParams 对象,可通过 get() 方法获取参数值
|
||||||
|
* @description
|
||||||
|
* 从请求 URL 中提取查询参数,例如:
|
||||||
|
* - /wechat-access?timestamp=123&nonce=abc
|
||||||
|
* - 可通过 params.get('timestamp') 获取值
|
||||||
|
* @example
|
||||||
|
* const query = parseQuery(req);
|
||||||
|
* const timestamp = query.get('timestamp');
|
||||||
|
*/
|
||||||
|
export const parseQuery = (req: IncomingMessage): URLSearchParams => {
|
||||||
|
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
||||||
|
return url.searchParams;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取 HTTP 请求体内容
|
||||||
|
* @param req - Node.js HTTP 请求对象
|
||||||
|
* @returns Promise<string> 请求体的完整内容(字符串格式)
|
||||||
|
* @description
|
||||||
|
* 异步读取请求体的所有数据块,适用于:
|
||||||
|
* - POST 请求的 JSON 数据
|
||||||
|
* - XML 格式的微信消息
|
||||||
|
* - 表单数据
|
||||||
|
*
|
||||||
|
* 内部实现:
|
||||||
|
* 1. 监听 'data' 事件,累积数据块
|
||||||
|
* 2. 监听 'end' 事件,返回完整内容
|
||||||
|
* 3. 监听 'error' 事件,处理读取错误
|
||||||
|
* @example
|
||||||
|
* const body = await readBody(req);
|
||||||
|
* const data = JSON.parse(body);
|
||||||
|
*/
|
||||||
|
export const readBody = async (req: IncomingMessage): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let body = "";
|
||||||
|
// 监听数据块事件,累积内容
|
||||||
|
req.on("data", (chunk) => {
|
||||||
|
body += chunk.toString();
|
||||||
|
});
|
||||||
|
// 监听结束事件,返回完整内容
|
||||||
|
req.on("end", () => {
|
||||||
|
resolve(body);
|
||||||
|
});
|
||||||
|
// 监听错误事件
|
||||||
|
req.on("error", reject);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查请求路径是否是服务号 webhook 路径
|
||||||
|
* @param url - 请求的完整 URL 或路径
|
||||||
|
* @returns 是否匹配服务号 webhook 路径
|
||||||
|
* @description
|
||||||
|
* 支持多种路径格式:
|
||||||
|
* - /wechat-access - 基础路径
|
||||||
|
* - /wechat-access/webhook - 标准 webhook 路径
|
||||||
|
* - /wechat-access/* - 任何以 /wechat-access/ 开头的路径
|
||||||
|
*
|
||||||
|
* 用于路由判断,确保只处理服务号相关的请求
|
||||||
|
* @example
|
||||||
|
* if (isFuwuhaoWebhookPath(req.url)) {
|
||||||
|
* // 处理服务号消息
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export const isFuwuhaoWebhookPath = (url: string): boolean => {
|
||||||
|
const pathname = new URL(url, "http://localhost").pathname;
|
||||||
|
// 支持多种路径格式
|
||||||
|
return pathname === "/wechat-access" ||
|
||||||
|
pathname === "/wechat-access/webhook" ||
|
||||||
|
pathname.startsWith("/wechat-access/");
|
||||||
|
};
|
||||||
59
http/index.ts
Normal file
59
http/index.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// ============================================
|
||||||
|
// Fuwuhao (微信服务号) 模块导出
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 类型定义
|
||||||
|
export type {
|
||||||
|
AgentEventPayload,
|
||||||
|
FuwuhaoMessage,
|
||||||
|
SimpleAccount,
|
||||||
|
CallbackPayload,
|
||||||
|
StreamChunk,
|
||||||
|
StreamCallback,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
// 加密解密工具
|
||||||
|
export type {
|
||||||
|
VerifySignatureParams,
|
||||||
|
DecryptMessageParams,
|
||||||
|
} from "./crypto-utils.js";
|
||||||
|
export {
|
||||||
|
verifySignature,
|
||||||
|
decryptMessage,
|
||||||
|
} from "./crypto-utils.js";
|
||||||
|
|
||||||
|
// HTTP 工具
|
||||||
|
export {
|
||||||
|
parseQuery,
|
||||||
|
readBody,
|
||||||
|
isFuwuhaoWebhookPath,
|
||||||
|
} from "./http-utils.js";
|
||||||
|
|
||||||
|
// 回调服务
|
||||||
|
export {
|
||||||
|
sendToCallbackService,
|
||||||
|
} from "./callback-service.js";
|
||||||
|
|
||||||
|
// 消息上下文
|
||||||
|
export type {
|
||||||
|
MessageContext,
|
||||||
|
} from "./message-context.js";
|
||||||
|
export {
|
||||||
|
buildMessageContext,
|
||||||
|
} from "./message-context.js";
|
||||||
|
|
||||||
|
// 消息处理器
|
||||||
|
export {
|
||||||
|
handleMessage,
|
||||||
|
handleMessageStream,
|
||||||
|
} from "./message-handler.js";
|
||||||
|
|
||||||
|
// Webhook 处理器(主入口)
|
||||||
|
export {
|
||||||
|
handleSimpleWecomWebhook,
|
||||||
|
} from "./webhook.js";
|
||||||
|
|
||||||
|
// Runtime
|
||||||
|
export {
|
||||||
|
getWecomRuntime,
|
||||||
|
} from "../common/runtime.js";
|
||||||
4
http/message-context.ts
Normal file
4
http/message-context.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// 已迁移至 common/message-context.ts
|
||||||
|
// 此文件保留以兼容现有 http 模块内部引用
|
||||||
|
export type { MessageContext } from "../common/message-context.js";
|
||||||
|
export { buildMessageContext, WECHAT_CHANNEL_LABELS } from "../common/message-context.js";
|
||||||
560
http/message-handler.ts
Normal file
560
http/message-handler.ts
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
import type { FuwuhaoMessage, CallbackPayload, StreamCallback } from "./types.js";
|
||||||
|
import { onAgentEvent, type AgentEventPayload } from "../common/agent-events.js";
|
||||||
|
import { getWecomRuntime } from "../common/runtime.js";
|
||||||
|
import { buildMessageContext } from "./message-context.js";
|
||||||
|
|
||||||
|
/** 内容安全审核拦截标记,由 content-security 插件的 fetch 拦截器嵌入伪 SSE 响应中 */
|
||||||
|
const SECURITY_BLOCK_MARKER = "<!--CONTENT_SECURITY_BLOCK-->";
|
||||||
|
|
||||||
|
/** 安全拦截后返回给微信用户的通用提示文本(不暴露具体拦截原因) */
|
||||||
|
const SECURITY_BLOCK_USER_MESSAGE = "抱歉,我无法处理该任务,让我们换个任务试试看?";
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 工具函数
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除 LLM 输出中泄漏的 thinking 标签及其内容
|
||||||
|
* 兼容 kimi-k2.5 等模型在 streaming 时 <think>...</think> 边界不稳定的问题
|
||||||
|
*/
|
||||||
|
const stripThinkingTags = (text: string): string => {
|
||||||
|
return text
|
||||||
|
.replace(/<\s*think(?:ing)?\s*>[\s\S]*?<\s*\/\s*think(?:ing)?\s*>/gi, "")
|
||||||
|
.replace(/<\s*\/\s*think(?:ing)?\s*>/gi, "") // 移除孤立的结束标签
|
||||||
|
.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 消息处理器
|
||||||
|
// ============================================
|
||||||
|
// 负责处理微信服务号消息并调用 OpenClaw Agent
|
||||||
|
// 支持同步和流式两种处理模式
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理消息并转发给 Agent(同步模式)
|
||||||
|
* @param message - 微信服务号的原始消息对象
|
||||||
|
* @returns Promise<string | null> Agent 生成的回复文本,失败时返回 null
|
||||||
|
* @description
|
||||||
|
* 同步处理流程:
|
||||||
|
* 1. 提取消息基本信息(用户 ID、消息 ID、内容等)
|
||||||
|
* 2. 构建消息上下文(调用 buildMessageContext)
|
||||||
|
* 3. 记录会话元数据和频道活动
|
||||||
|
* 4. 调用 Agent 处理消息(dispatchReplyWithBufferedBlockDispatcher)
|
||||||
|
* 5. 收集 Agent 的回复(通过 deliver 回调)
|
||||||
|
* 6. 返回最终回复文本
|
||||||
|
*
|
||||||
|
* 内部关键方法:
|
||||||
|
* - runtime.channel.session.recordSessionMetaFromInbound: 记录会话元数据
|
||||||
|
* - runtime.channel.activity.record: 记录频道活动统计
|
||||||
|
* - runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher: 分发消息到 Agent
|
||||||
|
* - deliver 回调: 接收 Agent 的回复(block/tool/final 三种类型)
|
||||||
|
*/
|
||||||
|
export const handleMessage = async (message: FuwuhaoMessage): Promise<string | null> => {
|
||||||
|
const runtime = getWecomRuntime();
|
||||||
|
const cfg = runtime.config.loadConfig();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 1. 提取消息基本信息
|
||||||
|
// ============================================
|
||||||
|
const content = message.Content || message.text?.content || "";
|
||||||
|
const userId = message.FromUserName || message.userid || "unknown";
|
||||||
|
const messageId = String(message.MsgId || message.msgid || Date.now());
|
||||||
|
const messageType = message.msgtype || "text";
|
||||||
|
const timestamp = message.CreateTime || Date.now();
|
||||||
|
|
||||||
|
console.log("[wechat-access] 收到消息:", {
|
||||||
|
类型: messageType,
|
||||||
|
消息ID: messageId,
|
||||||
|
内容: content,
|
||||||
|
用户ID: userId,
|
||||||
|
时间戳: timestamp
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 2. 构建消息上下文
|
||||||
|
// ============================================
|
||||||
|
// buildMessageContext 将微信消息转换为 OpenClaw 标准格式
|
||||||
|
// 返回:ctx(消息上下文)、route(路由信息)、storePath(存储路径)
|
||||||
|
const { ctx, route, storePath } = buildMessageContext(message);
|
||||||
|
|
||||||
|
console.log("[wechat-access] 路由信息:", {
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
agentId: route.agentId,
|
||||||
|
accountId: route.accountId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 3. 记录会话元数据
|
||||||
|
// ============================================
|
||||||
|
// runtime.channel.session.recordSessionMetaFromInbound 记录会话的元数据
|
||||||
|
// 包括:最后活跃时间、消息计数、用户信息等
|
||||||
|
// 用于会话管理、超时检测、数据统计等
|
||||||
|
void runtime.channel.session.recordSessionMetaFromInbound({
|
||||||
|
storePath, // 会话存储路径
|
||||||
|
sessionKey: ctx.SessionKey as string ?? route.sessionKey, // 会话键
|
||||||
|
ctx, // 消息上下文
|
||||||
|
}).catch((err: unknown) => {
|
||||||
|
console.log(`[wechat-access] 记录会话元数据失败: ${String(err)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 4. 记录频道活动统计
|
||||||
|
// ============================================
|
||||||
|
// runtime.channel.activity.record 记录频道的活动统计
|
||||||
|
// 用于监控、分析、计费等场景
|
||||||
|
runtime.channel.activity.record({
|
||||||
|
channel: "wechat-access", // 频道标识
|
||||||
|
accountId: "default", // 账号 ID
|
||||||
|
direction: "inbound", // 方向:inbound=入站(用户发送),outbound=出站(Bot 回复)
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 5. 调用 OpenClaw Agent 处理消息
|
||||||
|
// ============================================
|
||||||
|
try {
|
||||||
|
let responseText: string | null = null;
|
||||||
|
|
||||||
|
// 获取响应前缀配置(例如:是否显示"正在思考..."等提示)
|
||||||
|
// runtime.channel.reply.resolveEffectiveMessagesConfig 解析消息配置
|
||||||
|
const messagesConfig = runtime.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId);
|
||||||
|
|
||||||
|
console.log("[wechat-access] 开始调用 Agent...");
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher
|
||||||
|
// 这是 OpenClaw 的核心消息分发方法
|
||||||
|
// ============================================
|
||||||
|
// 功能:
|
||||||
|
// 1. 将消息发送给 Agent 进行处理
|
||||||
|
// 2. 通过 deliver 回调接收 Agent 的回复
|
||||||
|
// 3. 支持流式回复(block)和最终回复(final)
|
||||||
|
// 4. 支持工具调用(tool)的结果
|
||||||
|
//
|
||||||
|
// 参数说明:
|
||||||
|
// - ctx: 消息上下文(包含用户消息、会话信息等)
|
||||||
|
// - cfg: 全局配置
|
||||||
|
// - dispatcherOptions: 分发器选项
|
||||||
|
// - responsePrefix: 响应前缀(例如:"正在思考...")
|
||||||
|
// - deliver: 回调函数,接收 Agent 的回复
|
||||||
|
// - onError: 错误处理回调
|
||||||
|
// - replyOptions: 回复选项(可选)
|
||||||
|
//
|
||||||
|
// deliver 回调的 info.kind 类型:
|
||||||
|
// - "block": 流式分块回复(增量文本)
|
||||||
|
// - "tool": 工具调用结果(如 read_file、write 等)
|
||||||
|
// - "final": 最终完整回复
|
||||||
|
const { queuedFinal } = await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
dispatcherOptions: {
|
||||||
|
responsePrefix: messagesConfig.responsePrefix,
|
||||||
|
deliver: async (
|
||||||
|
payload: { text?: string; mediaUrl?: string; mediaUrls?: string[]; isError?: boolean; channelData?: unknown },
|
||||||
|
info: { kind: string }
|
||||||
|
) => {
|
||||||
|
console.log(`[wechat-access] Agent ${info.kind} 回复:`, payload, info);
|
||||||
|
|
||||||
|
if (info.kind === "tool") {
|
||||||
|
// ============================================
|
||||||
|
// 工具调用结果
|
||||||
|
// ============================================
|
||||||
|
// Agent 调用工具(如 write、read_file 等)后的结果
|
||||||
|
// 通常不需要直接返回给用户,仅记录日志
|
||||||
|
console.log("[wechat-access] 工具调用结果:", payload);
|
||||||
|
} else if (info.kind === "block") {
|
||||||
|
// ============================================
|
||||||
|
// 流式分块回复
|
||||||
|
// ============================================
|
||||||
|
// Agent 生成的增量文本(流式输出)
|
||||||
|
// 累积到 responseText 中
|
||||||
|
if (payload.text) {
|
||||||
|
// 检测安全审核拦截标记:替换为通用安全提示,不暴露具体拦截原因
|
||||||
|
if (payload.text.includes(SECURITY_BLOCK_MARKER)) {
|
||||||
|
console.warn("[wechat-access] block 回复中检测到安全审核拦截标记,替换为安全提示");
|
||||||
|
responseText = SECURITY_BLOCK_USER_MESSAGE;
|
||||||
|
} else {
|
||||||
|
responseText = payload.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (info.kind === "final") {
|
||||||
|
// ============================================
|
||||||
|
// 最终完整回复
|
||||||
|
// ============================================
|
||||||
|
// Agent 生成的完整回复文本
|
||||||
|
// 这是最终返回给用户的内容
|
||||||
|
if (payload.text) {
|
||||||
|
// 检测安全审核拦截标记:替换为通用安全提示
|
||||||
|
if (payload.text.includes(SECURITY_BLOCK_MARKER)) {
|
||||||
|
console.warn("[wechat-access] final 回复中检测到安全审核拦截标记,替换为安全提示");
|
||||||
|
responseText = SECURITY_BLOCK_USER_MESSAGE;
|
||||||
|
} else {
|
||||||
|
responseText = payload.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("[wechat-access] 最终回复:", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录出站活动统计(Bot 回复)
|
||||||
|
runtime.channel.activity.record({
|
||||||
|
channel: "wechat-access",
|
||||||
|
accountId: "default",
|
||||||
|
direction: "outbound", // 出站:Bot 发送给用户
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err: unknown, info: { kind: string }) => {
|
||||||
|
console.error(`[wechat-access] ${info.kind} 回复失败:`, err);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
replyOptions: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!queuedFinal) {
|
||||||
|
console.log("[wechat-access] Agent 没有生成回复");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 后置处理:将结果发送到回调服务
|
||||||
|
// ============================================
|
||||||
|
const callbackPayload: CallbackPayload = {
|
||||||
|
userId,
|
||||||
|
messageId,
|
||||||
|
messageType,
|
||||||
|
userMessage: content,
|
||||||
|
aiReply: responseText,
|
||||||
|
timestamp,
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 异步发送,不阻塞返回
|
||||||
|
// void sendToCallbackService(callbackPayload);
|
||||||
|
|
||||||
|
return responseText;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[wechat-access] 消息分发失败:", err);
|
||||||
|
|
||||||
|
// 即使失败也发送回调(带错误信息)
|
||||||
|
const callbackPayload: CallbackPayload = {
|
||||||
|
userId,
|
||||||
|
messageId,
|
||||||
|
messageType,
|
||||||
|
userMessage: content,
|
||||||
|
aiReply: null,
|
||||||
|
timestamp,
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
|
||||||
|
// void sendToCallbackService(callbackPayload);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理消息并流式返回结果(SSE 模式)
|
||||||
|
* @param message - 微信服务号的原始消息对象
|
||||||
|
* @param onChunk - 流式数据块回调函数,每次有新数据时调用
|
||||||
|
* @returns Promise<void> 异步执行,通过 onChunk 回调返回数据
|
||||||
|
* @description
|
||||||
|
* 流式处理流程:
|
||||||
|
* 1. 提取消息基本信息
|
||||||
|
* 2. 构建消息上下文
|
||||||
|
* 3. 记录会话元数据和频道活动
|
||||||
|
* 4. 订阅全局 Agent 事件(onAgentEvent)
|
||||||
|
* 5. 调用 Agent 处理消息
|
||||||
|
* 6. 通过 onChunk 回调实时推送数据
|
||||||
|
* 7. 发送完成信号
|
||||||
|
*
|
||||||
|
* 流式数据类型:
|
||||||
|
* - block: 流式文本块(增量文本)
|
||||||
|
* - tool_start: 工具开始执行
|
||||||
|
* - tool_update: 工具执行中间状态
|
||||||
|
* - tool_result: 工具执行完成
|
||||||
|
* - final: 最终完整回复
|
||||||
|
* - error: 错误信息
|
||||||
|
* - done: 流式传输完成
|
||||||
|
*
|
||||||
|
* 内部关键方法:
|
||||||
|
* - runtime.events.onAgentEvent: 订阅 Agent 事件(assistant/tool/lifecycle 流)
|
||||||
|
* - runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher: 分发消息到 Agent
|
||||||
|
*/
|
||||||
|
export const handleMessageStream = async (
|
||||||
|
message: FuwuhaoMessage,
|
||||||
|
onChunk: StreamCallback
|
||||||
|
): Promise<void> => {
|
||||||
|
const runtime = getWecomRuntime();
|
||||||
|
const cfg = runtime.config.loadConfig();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 1. 提取消息基本信息
|
||||||
|
// ============================================
|
||||||
|
const content = message.Content || message.text?.content || "";
|
||||||
|
const userId = message.FromUserName || message.userid || "unknown";
|
||||||
|
const messageId = String(message.MsgId || message.msgid || Date.now());
|
||||||
|
const messageType = message.msgtype || "text";
|
||||||
|
|
||||||
|
console.log("[wechat-access] 流式处理消息:", {
|
||||||
|
类型: messageType,
|
||||||
|
消息ID: messageId,
|
||||||
|
内容: content,
|
||||||
|
用户ID: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 2. 构建消息上下文
|
||||||
|
// ============================================
|
||||||
|
const { ctx, route, storePath } = buildMessageContext(message);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 3. 记录会话元数据
|
||||||
|
// ============================================
|
||||||
|
void runtime.channel.session.recordSessionMetaFromInbound({
|
||||||
|
storePath,
|
||||||
|
sessionKey: ctx.SessionKey as string ?? route.sessionKey,
|
||||||
|
ctx,
|
||||||
|
}).catch((err: unknown) => {
|
||||||
|
console.log(`[wechat-access] 记录会话元数据失败: ${String(err)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 4. 记录频道活动统计
|
||||||
|
// ============================================
|
||||||
|
runtime.channel.activity.record({
|
||||||
|
channel: "wechat-access",
|
||||||
|
accountId: "default",
|
||||||
|
direction: "inbound",
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 5. 订阅全局 Agent 事件
|
||||||
|
// ============================================
|
||||||
|
// runtime.events.onAgentEvent 订阅 Agent 运行时产生的所有事件
|
||||||
|
// 用于捕获流式文本、工具调用、生命周期等信息
|
||||||
|
//
|
||||||
|
// 事件流类型:
|
||||||
|
// - assistant: 助手流(流式文本输出)
|
||||||
|
// - tool: 工具流(工具调用的各个阶段)
|
||||||
|
// - lifecycle: 生命周期流(start/end/error 等)
|
||||||
|
console.log("[wechat-access] 注册 onAgentEvent 监听器...");
|
||||||
|
let lastEmittedText = ""; // 用于去重,只发送增量文本
|
||||||
|
|
||||||
|
const unsubscribeAgentEvents = onAgentEvent((evt: AgentEventPayload) => {
|
||||||
|
// 记录所有事件(调试用)
|
||||||
|
console.log(`[wechat-access] 收到 AgentEvent: stream=${evt.stream}, runId=${evt.runId}`);
|
||||||
|
|
||||||
|
const data = evt.data as Record<string, unknown>;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 处理流式文本(assistant 流)
|
||||||
|
// ============================================
|
||||||
|
// evt.stream === "assistant" 表示这是助手的文本输出流
|
||||||
|
// data.delta: 增量文本(新增的部分)
|
||||||
|
// data.text: 累积文本(从开始到现在的完整文本)
|
||||||
|
if (evt.stream === "assistant") {
|
||||||
|
const delta = data.delta as string | undefined;
|
||||||
|
const text = data.text as string | undefined;
|
||||||
|
|
||||||
|
// 优先使用 delta(增量文本),如果没有则计算增量
|
||||||
|
let textToSend = delta;
|
||||||
|
if (!textToSend && text && text !== lastEmittedText) {
|
||||||
|
// 计算增量:新文本 - 已发送文本
|
||||||
|
textToSend = text.slice(lastEmittedText.length);
|
||||||
|
lastEmittedText = text;
|
||||||
|
} else if (delta) {
|
||||||
|
lastEmittedText += delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测安全审核拦截标记:流式文本中包含拦截标记时,停止继续推送
|
||||||
|
if (textToSend && textToSend.includes(SECURITY_BLOCK_MARKER)) {
|
||||||
|
console.warn("[wechat-access] 流式文本中检测到安全审核拦截标记,停止推送");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lastEmittedText.includes(SECURITY_BLOCK_MARKER)) {
|
||||||
|
console.warn("[wechat-access] 累积文本中检测到安全审核拦截标记,停止推送");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textToSend) {
|
||||||
|
const cleanedText = stripThinkingTags(textToSend);
|
||||||
|
if (!cleanedText) return; // 过滤后为空则跳过
|
||||||
|
console.log(`[wechat-access] 流式文本:`, cleanedText.slice(0, 50) + (cleanedText.length > 50 ? "..." : ""));
|
||||||
|
// 通过 onChunk 回调发送增量文本
|
||||||
|
onChunk({
|
||||||
|
type: "block",
|
||||||
|
text: cleanedText,
|
||||||
|
timestamp: evt.ts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 处理工具调用事件(tool 流)
|
||||||
|
// ============================================
|
||||||
|
// evt.stream === "tool" 表示这是工具调用流
|
||||||
|
// data.phase: 工具调用的阶段(start/update/result)
|
||||||
|
// data.name: 工具名称(如 read_file、write 等)
|
||||||
|
// data.toolCallId: 工具调用 ID(用于关联同一次调用的多个事件)
|
||||||
|
if (evt.stream === "tool") {
|
||||||
|
const phase = data.phase as string | undefined;
|
||||||
|
const toolName = data.name as string | undefined;
|
||||||
|
const toolCallId = data.toolCallId as string | undefined;
|
||||||
|
|
||||||
|
console.log(`[wechat-access] 工具事件 [${phase}]:`, toolName, toolCallId);
|
||||||
|
|
||||||
|
if (phase === "start") {
|
||||||
|
// ============================================
|
||||||
|
// 工具开始执行
|
||||||
|
// ============================================
|
||||||
|
// 发送工具开始事件,包含工具名称和参数
|
||||||
|
onChunk({
|
||||||
|
type: "tool_start",
|
||||||
|
toolName,
|
||||||
|
toolCallId,
|
||||||
|
toolArgs: data.args as Record<string, unknown> | undefined,
|
||||||
|
toolMeta: data.meta as Record<string, unknown> | undefined,
|
||||||
|
timestamp: evt.ts,
|
||||||
|
});
|
||||||
|
} else if (phase === "update") {
|
||||||
|
// ============================================
|
||||||
|
// 工具执行中间状态更新
|
||||||
|
// ============================================
|
||||||
|
// 某些工具(如长时间运行的任务)会发送中间状态
|
||||||
|
onChunk({
|
||||||
|
type: "tool_update",
|
||||||
|
toolName,
|
||||||
|
toolCallId,
|
||||||
|
text: data.text as string | undefined,
|
||||||
|
toolMeta: data.meta as Record<string, unknown> | undefined,
|
||||||
|
timestamp: evt.ts,
|
||||||
|
});
|
||||||
|
} else if (phase === "result") {
|
||||||
|
// ============================================
|
||||||
|
// 工具执行完成
|
||||||
|
// ============================================
|
||||||
|
// 发送工具执行结果,包含返回值和是否出错
|
||||||
|
onChunk({
|
||||||
|
type: "tool_result",
|
||||||
|
toolName,
|
||||||
|
toolCallId,
|
||||||
|
text: data.result as string | undefined,
|
||||||
|
isError: data.isError as boolean | undefined,
|
||||||
|
toolMeta: data.meta as Record<string, unknown> | undefined,
|
||||||
|
timestamp: evt.ts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 处理生命周期事件(lifecycle 流)
|
||||||
|
// ============================================
|
||||||
|
// evt.stream === "lifecycle" 表示这是生命周期事件
|
||||||
|
// data.phase: 生命周期阶段(start/end/error)
|
||||||
|
if (evt.stream === "lifecycle") {
|
||||||
|
const phase = data.phase as string | undefined;
|
||||||
|
console.log(`[wechat-access] 生命周期事件 [${phase}]`);
|
||||||
|
// 可以在这里处理 start/end/error 事件,例如:
|
||||||
|
// if (phase === "error") {
|
||||||
|
// onChunk({ type: "error", text: data.error as string, timestamp: evt.ts });
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取响应前缀配置
|
||||||
|
const messagesConfig = runtime.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId);
|
||||||
|
|
||||||
|
console.log("[wechat-access] 开始流式调用 Agent...");
|
||||||
|
console.log("[wechat-access] ctx:", JSON.stringify(ctx));
|
||||||
|
|
||||||
|
const dispatchResult = await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
dispatcherOptions: {
|
||||||
|
responsePrefix: messagesConfig.responsePrefix,
|
||||||
|
deliver: async (
|
||||||
|
payload: { text?: string; mediaUrl?: string; mediaUrls?: string[]; isError?: boolean; channelData?: unknown },
|
||||||
|
info: { kind: string }
|
||||||
|
) => {
|
||||||
|
console.log(`[wechat-access] 流式 ${info.kind} 回复:`, payload, info);
|
||||||
|
|
||||||
|
if (info.kind === "tool") {
|
||||||
|
// 工具调用结果
|
||||||
|
onChunk({
|
||||||
|
type: "tool",
|
||||||
|
text: payload.text,
|
||||||
|
isError: payload.isError,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
} else if (info.kind === "block") {
|
||||||
|
// 流式分块回复
|
||||||
|
// 检测安全审核拦截标记:替换为通用安全提示
|
||||||
|
let blockText = payload.text ? stripThinkingTags(payload.text) : payload.text;
|
||||||
|
if (blockText && blockText.includes(SECURITY_BLOCK_MARKER)) {
|
||||||
|
console.warn("[wechat-access] 流式 block deliver 中检测到安全审核拦截标记,替换为安全提示");
|
||||||
|
blockText = SECURITY_BLOCK_USER_MESSAGE;
|
||||||
|
}
|
||||||
|
onChunk({
|
||||||
|
type: "block",
|
||||||
|
text: blockText,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
} else if (info.kind === "final") {
|
||||||
|
// 最终完整回复
|
||||||
|
// 检测安全审核拦截标记:替换为通用安全提示
|
||||||
|
let finalText = payload.text ? stripThinkingTags(payload.text) : payload.text;
|
||||||
|
if (finalText && finalText.includes(SECURITY_BLOCK_MARKER)) {
|
||||||
|
console.warn("[wechat-access] 流式 final deliver 中检测到安全审核拦截标记,替换为安全提示");
|
||||||
|
finalText = SECURITY_BLOCK_USER_MESSAGE;
|
||||||
|
}
|
||||||
|
onChunk({
|
||||||
|
type: "final",
|
||||||
|
text: finalText,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录出站活动
|
||||||
|
runtime.channel.activity.record({
|
||||||
|
channel: "wechat-access",
|
||||||
|
accountId: "default",
|
||||||
|
direction: "outbound",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err: unknown, info: { kind: string }) => {
|
||||||
|
console.error(`[wechat-access] 流式 ${info.kind} 回复失败:`, err);
|
||||||
|
onChunk({
|
||||||
|
type: "error",
|
||||||
|
text: err instanceof Error ? err.message : String(err),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
replyOptions: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[wechat-access] dispatchReplyWithBufferedBlockDispatcher 完成, 结果:", dispatchResult);
|
||||||
|
|
||||||
|
// 取消订阅 Agent 事件
|
||||||
|
unsubscribeAgentEvents();
|
||||||
|
|
||||||
|
// 发送完成信号
|
||||||
|
onChunk({
|
||||||
|
type: "done",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
// 确保在异常时也取消订阅
|
||||||
|
unsubscribeAgentEvents();
|
||||||
|
console.error("[wechat-access] 流式消息分发失败:", err);
|
||||||
|
onChunk({
|
||||||
|
type: "error",
|
||||||
|
text: err instanceof Error ? err.message : String(err),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
148
http/types.ts
Normal file
148
http/types.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
// ============================================
|
||||||
|
// Agent 事件类型
|
||||||
|
// ============================================
|
||||||
|
/**
|
||||||
|
* Agent 事件载荷
|
||||||
|
* @description OpenClaw Agent 运行时产生的事件数据
|
||||||
|
* @property runId - 运行 ID,标识一次完整的 Agent 执行
|
||||||
|
* @property seq - 事件序列号,用于排序
|
||||||
|
* @property stream - 事件流类型(assistant/tool/lifecycle)
|
||||||
|
* @property ts - 时间戳(毫秒)
|
||||||
|
* @property data - 事件数据,根据 stream 类型不同而不同
|
||||||
|
* @property sessionKey - 会话键(可选)
|
||||||
|
*/
|
||||||
|
export type AgentEventPayload = {
|
||||||
|
runId: string;
|
||||||
|
seq: number;
|
||||||
|
stream: string;
|
||||||
|
ts: number;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
sessionKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 消息类型
|
||||||
|
// ============================================
|
||||||
|
/**
|
||||||
|
* 微信服务号消息格式
|
||||||
|
* @description 兼容多种消息格式(加密/明文、不同字段命名)
|
||||||
|
* @property msgtype - 消息类型(text/image/voice 等)
|
||||||
|
* @property msgid - 消息 ID(小写)
|
||||||
|
* @property MsgId - 消息 ID(大写,微信标准格式)
|
||||||
|
* @property text - 文本消息对象(包含 content 字段)
|
||||||
|
* @property Content - 文本内容(直接字段)
|
||||||
|
* @property chattype - 聊天类型
|
||||||
|
* @property chatid - 聊天 ID
|
||||||
|
* @property userid - 用户 ID(小写)
|
||||||
|
* @property FromUserName - 发送者 OpenID(微信标准格式)
|
||||||
|
* @property ToUserName - 接收者 ID(服务号原始 ID)
|
||||||
|
* @property CreateTime - 消息创建时间(Unix 时间戳,秒)
|
||||||
|
*/
|
||||||
|
export interface FuwuhaoMessage {
|
||||||
|
msgtype?: string;
|
||||||
|
msgid?: string;
|
||||||
|
MsgId?: string;
|
||||||
|
text?: {
|
||||||
|
content?: string;
|
||||||
|
};
|
||||||
|
Content?: string;
|
||||||
|
chattype?: string;
|
||||||
|
chatid?: string;
|
||||||
|
userid?: string;
|
||||||
|
FromUserName?: string;
|
||||||
|
ToUserName?: string;
|
||||||
|
CreateTime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 账号配置类型
|
||||||
|
// ============================================
|
||||||
|
/**
|
||||||
|
* 微信服务号账号配置
|
||||||
|
* @description 用于消息加密解密和签名验证
|
||||||
|
* @property token - 微信服务号配置的 Token(用于签名验证)
|
||||||
|
* @property encodingAESKey - 消息加密密钥(43位字符,Base64 编码)
|
||||||
|
* @property receiveId - 接收方 ID(服务号的原始 ID,用于解密验证)
|
||||||
|
*/
|
||||||
|
export interface SimpleAccount {
|
||||||
|
token: string;
|
||||||
|
encodingAESKey: string;
|
||||||
|
receiveId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 回调相关类型
|
||||||
|
// ============================================
|
||||||
|
/**
|
||||||
|
* 后置回调数据载荷
|
||||||
|
* @description 发送到外部回调服务的数据格式
|
||||||
|
* @property userId - 用户唯一标识(OpenID)
|
||||||
|
* @property messageId - 消息唯一标识
|
||||||
|
* @property messageType - 消息类型(text/image/voice 等)
|
||||||
|
* @property userMessage - 用户发送的原始消息内容
|
||||||
|
* @property aiReply - AI 生成的回复内容(如果失败则为 null)
|
||||||
|
* @property timestamp - 消息时间戳(毫秒)
|
||||||
|
* @property sessionKey - 会话键,用于关联上下文
|
||||||
|
* @property success - 处理是否成功
|
||||||
|
* @property error - 错误信息(仅在失败时存在)
|
||||||
|
*/
|
||||||
|
export interface CallbackPayload {
|
||||||
|
// 用户信息
|
||||||
|
userId: string;
|
||||||
|
// 消息信息
|
||||||
|
messageId: string;
|
||||||
|
messageType: string;
|
||||||
|
// 用户发送的原始内容
|
||||||
|
userMessage: string;
|
||||||
|
// AI 回复的内容
|
||||||
|
aiReply: string | null;
|
||||||
|
// 时间戳
|
||||||
|
timestamp: number;
|
||||||
|
// 会话信息
|
||||||
|
sessionKey: string;
|
||||||
|
// 是否成功
|
||||||
|
success: boolean;
|
||||||
|
// 错误信息(如果有)
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 流式消息类型
|
||||||
|
// ============================================
|
||||||
|
/**
|
||||||
|
* 流式消息数据块
|
||||||
|
* @description Server-Sent Events (SSE) 推送的数据格式
|
||||||
|
* @property type - 数据块类型
|
||||||
|
* - block: 流式文本块(增量文本)
|
||||||
|
* - tool: 工具调用结果
|
||||||
|
* - tool_start: 工具开始执行
|
||||||
|
* - tool_update: 工具执行中间状态
|
||||||
|
* - tool_result: 工具执行完成
|
||||||
|
* - final: 最终完整回复
|
||||||
|
* - error: 错误信息
|
||||||
|
* - done: 流式传输完成
|
||||||
|
* @property text - 文本内容(适用于 block/final/error)
|
||||||
|
* @property toolName - 工具名称(适用于 tool_* 类型)
|
||||||
|
* @property toolCallId - 工具调用 ID(用于关联同一次调用)
|
||||||
|
* @property toolArgs - 工具调用参数(适用于 tool_start)
|
||||||
|
* @property toolMeta - 工具元数据(适用于 tool_* 类型)
|
||||||
|
* @property isError - 是否是错误(适用于 tool_result)
|
||||||
|
* @property timestamp - 时间戳(毫秒)
|
||||||
|
*/
|
||||||
|
export interface StreamChunk {
|
||||||
|
type: "block" | "tool" | "tool_start" | "tool_update" | "tool_result" | "final" | "error" | "done";
|
||||||
|
text?: string;
|
||||||
|
toolName?: string;
|
||||||
|
toolCallId?: string;
|
||||||
|
toolArgs?: Record<string, unknown>;
|
||||||
|
toolMeta?: Record<string, unknown>;
|
||||||
|
isError?: boolean;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 流式消息回调函数类型
|
||||||
|
* @description 用于接收流式数据块的回调函数
|
||||||
|
* @param chunk - 流式数据块
|
||||||
|
*/
|
||||||
|
export type StreamCallback = (chunk: StreamChunk) => void;
|
||||||
278
http/webhook.ts
Normal file
278
http/webhook.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
import type { FuwuhaoMessage, SimpleAccount } from "./types.js";
|
||||||
|
import { verifySignature, decryptMessage } from "./crypto-utils.js";
|
||||||
|
import { parseQuery, readBody, isFuwuhaoWebhookPath } from "./http-utils.js";
|
||||||
|
import { handleMessage, handleMessageStream } from "./message-handler.js";
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 账号配置
|
||||||
|
// ============================================
|
||||||
|
// 微信服务号的账号配置信息
|
||||||
|
// 生产环境应从环境变量或配置文件中读取
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模拟账号存储
|
||||||
|
* @description
|
||||||
|
* 生产环境建议:
|
||||||
|
* 1. 从环境变量读取:process.env.FUWUHAO_TOKEN 等
|
||||||
|
* 2. 从配置文件读取:config.json
|
||||||
|
* 3. 从数据库读取:支持多账号场景
|
||||||
|
* 4. 使用密钥管理服务:如 AWS Secrets Manager
|
||||||
|
*/
|
||||||
|
const mockAccount: SimpleAccount = {
|
||||||
|
token: "your_token_here", // 微信服务号配置的 Token
|
||||||
|
encodingAESKey: "your_encoding_aes_key_here", // 消息加密密钥(43位字符)
|
||||||
|
receiveId: "your_receive_id_here" // 服务号的原始 ID
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Webhook 处理器(主入口)
|
||||||
|
// ============================================
|
||||||
|
/**
|
||||||
|
* 处理微信服务号的 Webhook 请求
|
||||||
|
* @param req - Node.js HTTP 请求对象
|
||||||
|
* @param res - Node.js HTTP 响应对象
|
||||||
|
* @returns Promise<boolean> 是否处理了此请求(true=已处理,false=交给其他处理器)
|
||||||
|
* @description
|
||||||
|
* 此函数是微信服务号集成的主入口,负责:
|
||||||
|
* 1. 路径匹配:检查是否是服务号 webhook 路径
|
||||||
|
* 2. GET 请求:处理 URL 验证(微信服务器验证)
|
||||||
|
* 3. POST 请求:处理用户消息
|
||||||
|
* - 支持加密消息(验证签名 + 解密)
|
||||||
|
* - 支持明文消息(测试用)
|
||||||
|
* - 支持同步返回和流式返回(SSE)
|
||||||
|
*
|
||||||
|
* 请求流程:
|
||||||
|
* - GET /wechat-access?signature=xxx×tamp=xxx&nonce=xxx&echostr=xxx
|
||||||
|
* → 验证签名 → 解密 echostr → 返回明文
|
||||||
|
* - POST /wechat-access (同步)
|
||||||
|
* → 验证签名 → 解密消息 → 调用 Agent → 返回 JSON
|
||||||
|
* - POST /wechat-access?stream=true (流式)
|
||||||
|
* → 验证签名 → 解密消息 → 调用 Agent → 返回 SSE 流
|
||||||
|
*/
|
||||||
|
export const handleSimpleWecomWebhook = async (
|
||||||
|
req: IncomingMessage,
|
||||||
|
res: ServerResponse
|
||||||
|
): Promise<boolean> => {
|
||||||
|
// ============================================
|
||||||
|
// 1. 路径匹配检查
|
||||||
|
// ============================================
|
||||||
|
// 检查请求路径是否匹配服务号 webhook 路径
|
||||||
|
// 支持:/wechat-access、/wechat-access/webhook、/wechat-access/*
|
||||||
|
if (!isFuwuhaoWebhookPath(req.url || "")) {
|
||||||
|
return false; // 不是我们的路径,交给其他处理器
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[wechat-access] 收到请求: ${req.method} ${req.url}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ============================================
|
||||||
|
// 2. 解析查询参数
|
||||||
|
// ============================================
|
||||||
|
// 微信服务器会在 URL 中附加验证参数
|
||||||
|
const query = parseQuery(req);
|
||||||
|
const timestamp = query.get("timestamp") || ""; // 时间戳
|
||||||
|
const nonce = query.get("nonce") || ""; // 随机数
|
||||||
|
const signature = query.get("msg_signature") || query.get("signature") || ""; // 签名
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 3. 处理 GET 请求 - URL 验证
|
||||||
|
// ============================================
|
||||||
|
// 微信服务器在配置 webhook 时会发送 GET 请求验证 URL
|
||||||
|
// 请求格式:GET /wechat-access?signature=xxx×tamp=xxx&nonce=xxx&echostr=xxx
|
||||||
|
if (req.method === "GET") {
|
||||||
|
const echostr = query.get("echostr") || "";
|
||||||
|
|
||||||
|
// 验证签名(确保请求来自微信服务器)
|
||||||
|
const isValid = verifySignature({
|
||||||
|
token: mockAccount.token,
|
||||||
|
timestamp,
|
||||||
|
nonce,
|
||||||
|
encrypt: echostr,
|
||||||
|
signature
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
res.statusCode = 401;
|
||||||
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||||
|
res.end("签名验证失败");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密 echostr 并返回(微信服务器会验证返回值)
|
||||||
|
try {
|
||||||
|
const decrypted = decryptMessage({
|
||||||
|
encodingAESKey: mockAccount.encodingAESKey,
|
||||||
|
receiveId: mockAccount.receiveId,
|
||||||
|
encrypt: echostr
|
||||||
|
});
|
||||||
|
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||||
|
res.end(decrypted);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||||
|
res.end("解密失败");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 4. 处理 POST 请求 - 用户消息
|
||||||
|
// ============================================
|
||||||
|
// 微信服务器会将用户发送的消息通过 POST 请求转发过来
|
||||||
|
// 请求格式:POST /wechat-access?signature=xxx×tamp=xxx&nonce=xxx
|
||||||
|
// 请求体:加密的 JSON 或 XML 格式消息
|
||||||
|
if (req.method === "POST") {
|
||||||
|
// 读取请求体
|
||||||
|
const body = await readBody(req);
|
||||||
|
|
||||||
|
let message: FuwuhaoMessage;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 4.1 解析和解密消息
|
||||||
|
// ============================================
|
||||||
|
// 尝试解析 JSON 格式
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
const encrypt = data.encrypt || data.Encrypt || "";
|
||||||
|
|
||||||
|
if (encrypt) {
|
||||||
|
// ============================================
|
||||||
|
// 加密消息处理流程
|
||||||
|
// ============================================
|
||||||
|
// 1. 验证签名(确保消息来自微信服务器)
|
||||||
|
const isValid = verifySignature({
|
||||||
|
token: mockAccount.token,
|
||||||
|
timestamp,
|
||||||
|
nonce,
|
||||||
|
encrypt,
|
||||||
|
signature
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
res.statusCode = 401;
|
||||||
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||||
|
res.end("签名验证失败");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 解密消息
|
||||||
|
const decrypted = decryptMessage({
|
||||||
|
encodingAESKey: mockAccount.encodingAESKey,
|
||||||
|
receiveId: mockAccount.receiveId,
|
||||||
|
encrypt
|
||||||
|
});
|
||||||
|
message = JSON.parse(decrypted);
|
||||||
|
} else {
|
||||||
|
// ============================================
|
||||||
|
// 明文消息(用于测试)
|
||||||
|
// ============================================
|
||||||
|
// 直接使用 JSON 数据,无需解密
|
||||||
|
message = data;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ============================================
|
||||||
|
// XML 格式处理(简化版)
|
||||||
|
// ============================================
|
||||||
|
// 可能是 XML 格式,简单处理
|
||||||
|
console.log("[wechat-access] 收到非JSON格式数据,尝试简单解析");
|
||||||
|
message = {
|
||||||
|
msgtype: "text",
|
||||||
|
Content: body,
|
||||||
|
FromUserName: "unknown",
|
||||||
|
MsgId: `${Date.now()}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 4.2 检查是否请求流式返回(SSE)
|
||||||
|
// ============================================
|
||||||
|
// 客户端可以通过以下方式请求流式返回:
|
||||||
|
// 1. Accept: text/event-stream header
|
||||||
|
// 2. ?stream=true 查询参数
|
||||||
|
// 3. ?stream=1 查询参数
|
||||||
|
const acceptHeader = req.headers.accept || "";
|
||||||
|
const wantsStream = acceptHeader.includes("text/event-stream") ||
|
||||||
|
query.get("stream") === "true" ||
|
||||||
|
query.get("stream") === "1";
|
||||||
|
console.log('adam-sssss-markoint===wantsStreamwantsStreamwantsStream', wantsStream)
|
||||||
|
if (wantsStream) {
|
||||||
|
// ============================================
|
||||||
|
// 流式返回模式(Server-Sent Events)
|
||||||
|
// ============================================
|
||||||
|
// SSE 是一种服务器向客户端推送实时数据的技术
|
||||||
|
// 适用于:实时显示 AI 生成过程、工具调用状态等
|
||||||
|
console.log("[wechat-access] 使用流式返回模式 (SSE)");
|
||||||
|
|
||||||
|
// 设置 SSE 响应头
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", "text/event-stream; charset=utf-8"); // SSE 标准格式
|
||||||
|
res.setHeader("Cache-Control", "no-cache, no-transform"); // 禁用缓存
|
||||||
|
res.setHeader("Connection", "keep-alive"); // 保持连接
|
||||||
|
res.setHeader("X-Accel-Buffering", "no"); // 禁用 nginx 缓冲
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", "*"); // 允许跨域
|
||||||
|
res.flushHeaders(); // 立即发送 headers,建立 SSE 连接
|
||||||
|
|
||||||
|
// 发送初始连接确认事件
|
||||||
|
const connectedEvent = `data: ${JSON.stringify({ type: "connected", timestamp: Date.now() })}\n\n`;
|
||||||
|
console.log("[wechat-access] SSE 发送连接确认:", connectedEvent.trim());
|
||||||
|
res.write(connectedEvent);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 调用流式消息处理器
|
||||||
|
// handleMessageStream 会通过回调函数实时推送数据
|
||||||
|
await handleMessageStream(message, (chunk) => {
|
||||||
|
// SSE 数据格式:data: {JSON}\n\n
|
||||||
|
const sseData = `data: ${JSON.stringify(chunk)}\n\n`;
|
||||||
|
console.log("[wechat-access] SSE 发送数据:", chunk.type, chunk.text?.slice(0, 50));
|
||||||
|
res.write(sseData);
|
||||||
|
|
||||||
|
// 如果是完成或错误,关闭连接
|
||||||
|
if (chunk.type === "done" || chunk.type === "error") {
|
||||||
|
console.log("[wechat-access] SSE 连接关闭:", chunk.type);
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (streamErr) {
|
||||||
|
// 流式处理异常,发送错误事件
|
||||||
|
console.error("[wechat-access] SSE 流式处理异常:", streamErr);
|
||||||
|
const errorData = `data: ${JSON.stringify({ type: "error", text: String(streamErr), timestamp: Date.now() })}\n\n`;
|
||||||
|
res.write(errorData);
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 4.3 普通同步返回模式
|
||||||
|
// ============================================
|
||||||
|
// 等待 Agent 处理完成后一次性返回结果
|
||||||
|
// 适用于:简单问答、不需要实时反馈的场景
|
||||||
|
const reply = await handleMessage(message);
|
||||||
|
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
reply: reply || "消息已接收,正在处理中..."
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
// ============================================
|
||||||
|
// 5. 异常处理
|
||||||
|
// ============================================
|
||||||
|
// 捕获所有未处理的异常,返回 500 错误
|
||||||
|
console.error("[wechat-access] Webhook 处理异常:", error);
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||||
|
res.end("服务器内部错误");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
236
index.ts
Normal file
236
index.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||||
|
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||||
|
import { WechatAccessWebSocketClient, handlePrompt, handleCancel } from "./websocket/index.js";
|
||||||
|
// import { handleSimpleWecomWebhook } from "./http/webhook.js";
|
||||||
|
import { setWecomRuntime } from "./common/runtime.js";
|
||||||
|
import { performLogin, loadState, getDeviceGuid, getEnvironment } from "./auth/index.js";
|
||||||
|
|
||||||
|
// 类型定义
|
||||||
|
type NormalizedChatType = "direct" | "group" | "channel";
|
||||||
|
|
||||||
|
// WebSocket 客户端实例(按 accountId 存储)
|
||||||
|
const wsClients = new Map<string, WechatAccessWebSocketClient>();
|
||||||
|
|
||||||
|
// 渠道元数据
|
||||||
|
const meta = {
|
||||||
|
id: "wechat-access",
|
||||||
|
label: "腾讯通路",
|
||||||
|
/** 选择时的显示文本 */
|
||||||
|
selectionLabel: "腾讯通路",
|
||||||
|
detailLabel: "腾讯通路",
|
||||||
|
/** 文档路径 */
|
||||||
|
docsPath: "/channels/wechat-access",
|
||||||
|
docsLabel: "wechat-access",
|
||||||
|
/** 简介 */
|
||||||
|
blurb: "通用通路",
|
||||||
|
/** 图标 */
|
||||||
|
systemImage: "message.fill",
|
||||||
|
/** 排序权重 */
|
||||||
|
order: 85,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渠道插件
|
||||||
|
const tencentAccessPlugin = {
|
||||||
|
id: "wechat-access",
|
||||||
|
meta,
|
||||||
|
|
||||||
|
// 能力声明
|
||||||
|
capabilities: {
|
||||||
|
chatTypes: ["direct"] as NormalizedChatType[],
|
||||||
|
reactions: false,
|
||||||
|
threads: false,
|
||||||
|
media: true,
|
||||||
|
nativeCommands: false,
|
||||||
|
blockStreaming: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 热重载:token 或 wsUrl 变更时触发 gateway 重启
|
||||||
|
reload: {
|
||||||
|
configPrefixes: ["channels.wechat-access.token", "channels.wechat-access.wsUrl"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 配置适配器(必需)
|
||||||
|
config: {
|
||||||
|
listAccountIds: (cfg: any) => {
|
||||||
|
const accounts = cfg.channels?.["wechat-access"]?.accounts;
|
||||||
|
if (accounts && typeof accounts === "object") {
|
||||||
|
return Object.keys(accounts);
|
||||||
|
}
|
||||||
|
// 没有配置账号时,返回默认账号
|
||||||
|
return ["default"];
|
||||||
|
},
|
||||||
|
resolveAccount: (cfg: any, accountId: string) => {
|
||||||
|
const accounts = cfg.channels?.["wechat-access"]?.accounts;
|
||||||
|
const account = accounts?.[accountId ?? "default"];
|
||||||
|
return account ?? { accountId: accountId ?? "default" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 出站适配器(必需)
|
||||||
|
outbound: {
|
||||||
|
deliveryMode: "direct" as const,
|
||||||
|
sendText: async () => ({ ok: true }),
|
||||||
|
},
|
||||||
|
|
||||||
|
// 状态适配器:上报 WebSocket 连接状态
|
||||||
|
status: {
|
||||||
|
buildAccountSnapshot: ({ accountId }: { accountId?: string; cfg: any; runtime?: any }) => {
|
||||||
|
const client = wsClients.get(accountId ?? "default");
|
||||||
|
const running = client?.getState() === "connected";
|
||||||
|
return { running };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Gateway 适配器:按账号启动/停止 WebSocket 连接
|
||||||
|
gateway: {
|
||||||
|
startAccount: async (ctx: any) => {
|
||||||
|
const { cfg, accountId, abortSignal, log } = ctx;
|
||||||
|
|
||||||
|
const tencentAccessConfig = cfg?.channels?.["wechat-access"];
|
||||||
|
let token = tencentAccessConfig?.token ? String(tencentAccessConfig.token) : "";
|
||||||
|
const configWsUrl = tencentAccessConfig?.wsUrl ? String(tencentAccessConfig.wsUrl) : "";
|
||||||
|
const bypassInvite = tencentAccessConfig?.bypassInvite === true;
|
||||||
|
const authStatePath = tencentAccessConfig?.authStatePath
|
||||||
|
? String(tencentAccessConfig.authStatePath)
|
||||||
|
: undefined;
|
||||||
|
const envName: string = tencentAccessConfig?.environment
|
||||||
|
? String(tencentAccessConfig.environment)
|
||||||
|
: "production";
|
||||||
|
const gatewayPort = cfg?.gateway?.port ? String(cfg.gateway.port) : "unknown";
|
||||||
|
|
||||||
|
const env = getEnvironment(envName);
|
||||||
|
const guid = getDeviceGuid();
|
||||||
|
const wsUrl = configWsUrl || env.wechatWsUrl;
|
||||||
|
|
||||||
|
// 启动诊断日志
|
||||||
|
log?.info(`[wechat-access] 启动账号 ${accountId}`, {
|
||||||
|
platform: process.platform,
|
||||||
|
nodeVersion: process.version,
|
||||||
|
hasToken: !!token,
|
||||||
|
hasUrl: !!wsUrl,
|
||||||
|
url: wsUrl || "(未配置)",
|
||||||
|
tokenPrefix: token ? token.substring(0, 6) + "..." : "(未配置)",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Token 获取策略:配置 > 已保存的登录态 > 交互式扫码登录
|
||||||
|
if (!token) {
|
||||||
|
const savedState = loadState(authStatePath);
|
||||||
|
if (savedState?.channelToken) {
|
||||||
|
token = savedState.channelToken;
|
||||||
|
log?.info(`[wechat-access] 使用已保存的 token: ${token.substring(0, 6)}...`);
|
||||||
|
} else {
|
||||||
|
log?.info(`[wechat-access] 未找到 token,启动微信扫码登录...`);
|
||||||
|
try {
|
||||||
|
const credentials = await performLogin({
|
||||||
|
guid,
|
||||||
|
env,
|
||||||
|
bypassInvite,
|
||||||
|
authStatePath,
|
||||||
|
log,
|
||||||
|
});
|
||||||
|
token = credentials.channelToken;
|
||||||
|
} catch (err) {
|
||||||
|
log?.error(`[wechat-access] 登录失败: ${err}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
log?.warn(`[wechat-access] token 为空,跳过 WebSocket 连接`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsConfig = {
|
||||||
|
url: wsUrl,
|
||||||
|
token,
|
||||||
|
guid,
|
||||||
|
userId: "",
|
||||||
|
gatewayPort,
|
||||||
|
reconnectInterval: 3000,
|
||||||
|
maxReconnectAttempts: 10,
|
||||||
|
heartbeatInterval: 20000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = new WechatAccessWebSocketClient(wsConfig, {
|
||||||
|
onConnected: () => {
|
||||||
|
log?.info(`[wechat-access] WebSocket 连接成功`);
|
||||||
|
ctx.setStatus({ running: true });
|
||||||
|
},
|
||||||
|
onDisconnected: (reason?: string) => {
|
||||||
|
log?.warn(`[wechat-access] WebSocket 连接断开: ${reason}`);
|
||||||
|
ctx.setStatus({ running: false });
|
||||||
|
},
|
||||||
|
onPrompt: (message: any) => {
|
||||||
|
void handlePrompt(message, client).catch((err: Error) => {
|
||||||
|
log?.error(`[wechat-access] 处理 prompt 失败: ${err.message}`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onCancel: (message: any) => {
|
||||||
|
handleCancel(message, client);
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
log?.error(`[wechat-access] WebSocket 错误: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
wsClients.set(accountId, client);
|
||||||
|
client.start();
|
||||||
|
|
||||||
|
// 等待框架发出停止信号
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
abortSignal.addEventListener("abort", () => {
|
||||||
|
log?.info(`[wechat-access] 停止账号 ${accountId}`);
|
||||||
|
// 始终停止当前闭包捕获的 client,避免多次 startAccount 时
|
||||||
|
// wsClients 被新 client 覆盖后,旧 client 的 stop() 永远不被调用,导致无限重连
|
||||||
|
client.stop();
|
||||||
|
// 仅当 wsClients 中存的还是当前 client 时才删除,避免误删新 client
|
||||||
|
if (wsClients.get(accountId) === client) {
|
||||||
|
wsClients.delete(accountId);
|
||||||
|
ctx.setStatus({ running: false });
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
stopAccount: async (ctx: any) => {
|
||||||
|
const { accountId, log } = ctx;
|
||||||
|
log?.info(`[wechat-access] stopAccount 钩子触发,停止账号 ${accountId}`);
|
||||||
|
const client = wsClients.get(accountId);
|
||||||
|
if (client) {
|
||||||
|
client.stop();
|
||||||
|
wsClients.delete(accountId);
|
||||||
|
ctx.setStatus({ running: false });
|
||||||
|
log?.info(`[wechat-access] 账号 ${accountId} 已停止`);
|
||||||
|
} else {
|
||||||
|
log?.warn(`[wechat-access] stopAccount: 未找到账号 ${accountId} 的客户端`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const index = {
|
||||||
|
id: "wechat-access",
|
||||||
|
name: "通用通路插件",
|
||||||
|
description: "腾讯通用通路插件",
|
||||||
|
configSchema: emptyPluginConfigSchema(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件注册入口点
|
||||||
|
*/
|
||||||
|
register(api: OpenClawPluginApi) {
|
||||||
|
// 1. 设置运行时环境
|
||||||
|
setWecomRuntime(api.runtime);
|
||||||
|
|
||||||
|
// 2. 注册渠道插件
|
||||||
|
api.registerChannel({ plugin: tencentAccessPlugin as any });
|
||||||
|
|
||||||
|
// 3. 注册 HTTP 处理器(如需要)
|
||||||
|
// api.registerHttpHandler(handleSimpleWecomWebhook);
|
||||||
|
|
||||||
|
console.log("[wechat-access] 腾讯通路插件已注册");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default index;
|
||||||
38
openclaw.plugin.json
Normal file
38
openclaw.plugin.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"id": "wechat-access",
|
||||||
|
"channels": ["wechat-access"],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "是否启用服务号渠道"
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "WebSocket 连接 token(手动配置时使用)"
|
||||||
|
},
|
||||||
|
"wsUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "WebSocket 网关地址"
|
||||||
|
},
|
||||||
|
"bypassInvite": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "跳过邀请码验证"
|
||||||
|
},
|
||||||
|
"authStatePath": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "自定义 token 持久化路径"
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["production", "test"],
|
||||||
|
"description": "API 环境(production / test)"
|
||||||
|
},
|
||||||
|
"accounts": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "多账号配置"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10202
package-lock.json
generated
Normal file
10202
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
package.json
Normal file
44
package.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "wechat-access",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "简化的企业微信 服务号 接收消息 Demo",
|
||||||
|
"author": "aa",
|
||||||
|
"openclaw": {
|
||||||
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
],
|
||||||
|
"channel": {
|
||||||
|
"id": "wechat-access",
|
||||||
|
"label": "wechat-access",
|
||||||
|
"selectionLabel": "WeCom (plugin)",
|
||||||
|
"detailLabel": "WeCom Bot",
|
||||||
|
"docsPath": "/channels/wechat-access",
|
||||||
|
"docsLabel": "wechat-access",
|
||||||
|
"blurb": "Enterprise WeCom intelligent bot (API mode) via encrypted webhooks + passive replies.",
|
||||||
|
"aliases": [
|
||||||
|
"wechatwork",
|
||||||
|
"wework",
|
||||||
|
"服务号",
|
||||||
|
"企微",
|
||||||
|
"企业微信"
|
||||||
|
],
|
||||||
|
"order": 85
|
||||||
|
},
|
||||||
|
"install": {
|
||||||
|
"npmSpec": "@yanhaidao/wechat-access",
|
||||||
|
"localPath": "extensions/wechat-access",
|
||||||
|
"defaultChoice": "npm"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"fast-xml-parser": "^5.4.1",
|
||||||
|
"qrcode-terminal": "^0.12.0",
|
||||||
|
"undici": "^7.20.0",
|
||||||
|
"ws": "^8.18.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"openclaw": ">=2026.1.26"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
tsconfig.json
Normal file
28
tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"index.ts",
|
||||||
|
"http/**/*.ts",
|
||||||
|
"websocket/**/*.ts",
|
||||||
|
"auth/**/*.ts",
|
||||||
|
"common/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
|
}
|
||||||
273
websocket.md
Normal file
273
websocket.md
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
agentwsserver WebSocket 接口文档
|
||||||
|
目录
|
||||||
|
1.概述
|
||||||
|
2.连接
|
||||||
|
3.数据协议 (AGP Envelope)
|
||||||
|
4.下行消息 (服务端 → 客户端)
|
||||||
|
5.上行消息 (客户端 → 服务端)
|
||||||
|
6.通用数据结构
|
||||||
|
7.时序示意
|
||||||
|
|
||||||
|
概述
|
||||||
|
为独立 APP 提供 WebSocket 双向通信能力。
|
||||||
|
WebSocket 服务 — 运行于 :8080 端口,处理客户端的 WebSocket 长连接
|
||||||
|
数据协议 — 使用 AGP (Agent Gateway Protocol) 统一消息信封
|
||||||
|
消息传输 — 所有消息均为 WebSocket Text 帧,内容为 JSON
|
||||||
|
|
||||||
|
连接
|
||||||
|
地址
|
||||||
|
ws://21.0.62.97:8080/?token={token}
|
||||||
|
Query 参数
|
||||||
|
参数 类型 必填 说明
|
||||||
|
token string 否 鉴权 token(当前未校验,后续启用)
|
||||||
|
连接行为
|
||||||
|
握手成功后服务端注册连接,同一 guid 的旧连接会被踢下线
|
||||||
|
空闲超时 5 分钟,超时无消息收发将断开
|
||||||
|
连接断开后服务端自动清理路由注册
|
||||||
|
错误场景
|
||||||
|
场景 行为
|
||||||
|
缺少 guid 或 user_id 握手拒绝,WebSocket 连接不会建立
|
||||||
|
URL 解析失败 握手拒绝
|
||||||
|
|
||||||
|
数据协议 (AGP Envelope)
|
||||||
|
Envelope 结构
|
||||||
|
所有 WebSocket 消息(上行和下行)均使用统一的 AGP 信封格式:
|
||||||
|
{
|
||||||
|
"msg_id": "string",
|
||||||
|
"guid": "string",
|
||||||
|
"user_id": "string",
|
||||||
|
"method": "string",
|
||||||
|
"payload": {}
|
||||||
|
}
|
||||||
|
字段 类型 必填 说明
|
||||||
|
msg_id string 是 全局唯一消息 ID(UUID),用于幂等去重
|
||||||
|
guid string 是 设备 GUID
|
||||||
|
user_id string 是 用户账户 ID
|
||||||
|
method string 是 消息类型,见下方枚举
|
||||||
|
payload object 是 消息载荷(JSON 对象,根据 method 类型而异)
|
||||||
|
Method 枚举
|
||||||
|
method 方向 说明
|
||||||
|
session.prompt 服务端 → 客户端 下发用户指令
|
||||||
|
session.cancel 服务端 → 客户端 取消 Prompt Turn
|
||||||
|
session.update 客户端 → 服务端 流式中间更新
|
||||||
|
session.promptResponse 客户端 → 服务端 最终结果
|
||||||
|
|
||||||
|
下行消息 (服务端 → 客户端)
|
||||||
|
session.prompt — 下发用户指令
|
||||||
|
{
|
||||||
|
"msg_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"guid": "device_001",
|
||||||
|
"user_id": "user_123",
|
||||||
|
"method": "session.prompt",
|
||||||
|
"payload": {
|
||||||
|
"session_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"prompt_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
"agent_app": "openclaw",
|
||||||
|
"content": [
|
||||||
|
{ "type": "text", "text": "帮我查一下今天的天气" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payload 字段:
|
||||||
|
字段 类型 必填 说明
|
||||||
|
session_id string 是 所属 Session ID
|
||||||
|
prompt_id string 是 本次 Turn 唯一 ID
|
||||||
|
agent_app string 是 目标 AI 应用标识,客户端据此路由到本地 AI 应用
|
||||||
|
content ContentBlock[] 是 用户指令内容(数组)
|
||||||
|
session.cancel — 取消 Prompt Turn
|
||||||
|
{
|
||||||
|
"msg_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
"guid": "device_001",
|
||||||
|
"user_id": "user_123",
|
||||||
|
"method": "session.cancel",
|
||||||
|
"payload": {
|
||||||
|
"session_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"prompt_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
"agent_app": "openclaw"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payload 字段:
|
||||||
|
字段 类型 必填 说明
|
||||||
|
session_id string 是 所属 Session ID
|
||||||
|
prompt_id string 是 要取消的 Turn ID
|
||||||
|
agent_app string 是 目标 AI 应用标识
|
||||||
|
|
||||||
|
上行消息 (客户端 → 服务端)
|
||||||
|
session.update — 流式中间更新
|
||||||
|
客户端在处理 session.prompt 期间,通过此消息上报中间进度。可多次发送。
|
||||||
|
update_type 枚举
|
||||||
|
update_type 说明 使用字段
|
||||||
|
message_chunk 增量文本/内容(Agent 消息片段) content
|
||||||
|
tool_call AI 正在调用工具 tool_call
|
||||||
|
tool_call_update 工具执行状态变更 tool_call
|
||||||
|
payload 字段
|
||||||
|
字段 类型 必填 说明
|
||||||
|
session_id string 是 所属 Session ID
|
||||||
|
prompt_id string 是 所属 Turn ID
|
||||||
|
update_type string 是 更新类型,取值见上方枚举
|
||||||
|
content ContentBlock 条件 update_type=message_chunk 时使用,单个对象(非数组)
|
||||||
|
tool_call ToolCall 条件 update_type=tool_call 或 tool_call_update 时使用
|
||||||
|
注意: content 字段为单个 ContentBlock 对象,不是数组。与 session.promptResponse 的 content 数组不同。
|
||||||
|
示例 — message_chunk(增量文本)
|
||||||
|
{
|
||||||
|
"msg_id": "550e8400-e29b-41d4-a716-446655440002",
|
||||||
|
"guid": "device_001",
|
||||||
|
"user_id": "user_123",
|
||||||
|
"method": "session.update",
|
||||||
|
"payload": {
|
||||||
|
"session_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"prompt_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
"update_type": "message_chunk",
|
||||||
|
"content": {
|
||||||
|
"type": "text",
|
||||||
|
"text": "正在思考中...第一步是..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
示例 — tool_call(工具调用)
|
||||||
|
{
|
||||||
|
"msg_id": "550e8400-e29b-41d4-a716-446655440003",
|
||||||
|
"guid": "device_001",
|
||||||
|
"user_id": "user_123",
|
||||||
|
"method": "session.update",
|
||||||
|
"payload": {
|
||||||
|
"session_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"prompt_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
"update_type": "tool_call",
|
||||||
|
"tool_call": {
|
||||||
|
"tool_call_id": "tc-001",
|
||||||
|
"title": "扫描临时文件",
|
||||||
|
"kind": "execute",
|
||||||
|
"status": "pending"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
示例 — tool_call_update(工具状态更新)
|
||||||
|
{
|
||||||
|
"msg_id": "550e8400-e29b-41d4-a716-446655440004",
|
||||||
|
"guid": "device_001",
|
||||||
|
"user_id": "user_123",
|
||||||
|
"method": "session.update",
|
||||||
|
"payload": {
|
||||||
|
"session_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"prompt_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
"update_type": "tool_call_update",
|
||||||
|
"tool_call": {
|
||||||
|
"tool_call_id": "tc-001",
|
||||||
|
"status": "completed",
|
||||||
|
"content": [{ "type": "text", "text": "发现临时文件 2.3GB" }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.promptResponse — 最终结果
|
||||||
|
客户端完成 session.prompt 处理后,上报最终结果。每个 prompt_id 只接受一次最终响应,重复的 msg_id 会被去重。
|
||||||
|
{
|
||||||
|
"msg_id": "550e8400-e29b-41d4-a716-446655440005",
|
||||||
|
"guid": "device_001",
|
||||||
|
"user_id": "user_123",
|
||||||
|
"method": "session.promptResponse",
|
||||||
|
"payload": {
|
||||||
|
"session_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"prompt_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
"stop_reason": "end_turn",
|
||||||
|
"content": [
|
||||||
|
{ "type": "text", "text": "今天北京晴,气温 15°C" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payload 字段:
|
||||||
|
字段 类型 必填 说明
|
||||||
|
session_id string 是 所属 Session ID
|
||||||
|
prompt_id string 是 所属 Turn ID
|
||||||
|
stop_reason string 是 停止原因
|
||||||
|
content ContentBlock[] 否 最终结果内容(数组)
|
||||||
|
error string 否 错误描述(stop_reason 为 error / refusal 时附带)
|
||||||
|
stop_reason 枚举:
|
||||||
|
值 说明
|
||||||
|
end_turn 正常完成
|
||||||
|
cancelled 被取消
|
||||||
|
refusal AI 应用拒绝执行
|
||||||
|
error 技术错误
|
||||||
|
错误响应示例
|
||||||
|
{
|
||||||
|
"msg_id": "550e8400-e29b-41d4-a716-446655440006",
|
||||||
|
"guid": "device_001",
|
||||||
|
"user_id": "user_123",
|
||||||
|
"method": "session.promptResponse",
|
||||||
|
"payload": {
|
||||||
|
"session_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"prompt_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
"stop_reason": "error",
|
||||||
|
"error": "AI 应用执行超时"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
通用数据结构
|
||||||
|
ContentBlock — 内容块
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "文本内容"
|
||||||
|
}
|
||||||
|
字段 类型 必填 说明
|
||||||
|
type string 是 内容类型,当前仅支持 "text"
|
||||||
|
text string 是 type=text 时必填
|
||||||
|
ToolCall — 工具调用
|
||||||
|
{
|
||||||
|
"tool_call_id": "tc-001",
|
||||||
|
"title": "扫描临时文件",
|
||||||
|
"kind": "execute",
|
||||||
|
"status": "in_progress",
|
||||||
|
"content": [{ "type": "text", "text": "发现临时文件 2.3GB" }],
|
||||||
|
"locations": [{ "path": "/tmp" }]
|
||||||
|
}
|
||||||
|
字段 类型 必填 说明
|
||||||
|
tool_call_id string 是 工具调用唯一 ID
|
||||||
|
title string 否 工具调用标题(展示用)
|
||||||
|
kind string 否 工具类型
|
||||||
|
status string 是 工具调用状态
|
||||||
|
content ContentBlock[] 否 工具调用结果内容
|
||||||
|
locations Location[] 否 工具操作路径
|
||||||
|
kind 枚举:
|
||||||
|
值 说明
|
||||||
|
read 读取
|
||||||
|
edit 编辑
|
||||||
|
delete 删除
|
||||||
|
execute 执行
|
||||||
|
search 搜索
|
||||||
|
fetch 获取
|
||||||
|
think 思考
|
||||||
|
other 其他
|
||||||
|
status 枚举:
|
||||||
|
值 说明
|
||||||
|
pending 等待中
|
||||||
|
in_progress 执行中
|
||||||
|
completed 已完成
|
||||||
|
failed 失败
|
||||||
|
Location — 路径
|
||||||
|
{ "path": "/tmp" }
|
||||||
|
字段 类型 说明
|
||||||
|
path string 操作路径
|
||||||
|
|
||||||
|
时序示意
|
||||||
|
正常流程
|
||||||
|
客户端 (APP) 服务端
|
||||||
|
| |
|
||||||
|
|--- WS 握手 (guid/user_id) ----->|
|
||||||
|
|<-- 101 Switching Protocols -----| 连接建立
|
||||||
|
| |
|
||||||
|
|<-- session.prompt (WS Text) ----| 下发指令
|
||||||
|
| |
|
||||||
|
|--- session.update (WS Text) --->| 流式上报(可多次)
|
||||||
|
|--- session.update (WS Text) --->|
|
||||||
|
| |
|
||||||
|
|--- promptResponse (WS Text) --->| 最终结果
|
||||||
|
| |
|
||||||
|
|--- 断开 / 超时 ---------------->| 连接清理
|
||||||
|
取消流程
|
||||||
|
客户端 (APP) 服务端
|
||||||
|
| |
|
||||||
|
| (正在处理 session.prompt) |
|
||||||
|
|<-- session.cancel (WS Text) ----| 服务端取消
|
||||||
|
| |
|
||||||
|
|--- promptResponse (WS Text) --->| stop_reason: "cancelled"
|
||||||
40
websocket/index.ts
Normal file
40
websocket/index.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// ============================================
|
||||||
|
// WebSocket 模块导出
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 类型定义
|
||||||
|
export type {
|
||||||
|
AGPEnvelope,
|
||||||
|
AGPMethod,
|
||||||
|
ContentBlock,
|
||||||
|
ToolCall,
|
||||||
|
ToolCallKind,
|
||||||
|
ToolCallStatus,
|
||||||
|
ToolLocation,
|
||||||
|
PromptPayload,
|
||||||
|
CancelPayload,
|
||||||
|
UpdatePayload,
|
||||||
|
UpdateType,
|
||||||
|
PromptResponsePayload,
|
||||||
|
StopReason,
|
||||||
|
PromptMessage,
|
||||||
|
CancelMessage,
|
||||||
|
UpdateMessage,
|
||||||
|
PromptResponseMessage,
|
||||||
|
WebSocketClientConfig,
|
||||||
|
ConnectionState,
|
||||||
|
WebSocketClientCallbacks,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
// WebSocket 客户端
|
||||||
|
export { WechatAccessWebSocketClient } from "./websocket-client.js";
|
||||||
|
|
||||||
|
// 消息处理器
|
||||||
|
export { handlePrompt, handleCancel } from "./message-handler.js";
|
||||||
|
|
||||||
|
// 消息适配器
|
||||||
|
export {
|
||||||
|
extractTextFromContent,
|
||||||
|
promptPayloadToFuwuhaoMessage,
|
||||||
|
buildWebSocketMessageContext,
|
||||||
|
} from "./message-adapter.js";
|
||||||
116
websocket/message-adapter.ts
Normal file
116
websocket/message-adapter.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* @file message-adapter.ts
|
||||||
|
* @description AGP 协议消息与 OpenClaw 内部格式之间的适配器
|
||||||
|
*
|
||||||
|
* 设计思路:
|
||||||
|
* WebSocket 通道(AGP 协议)和 HTTP 通道(微信服务号 Webhook)使用不同的消息格式,
|
||||||
|
* 但底层的 Agent 路由、会话管理、消息处理逻辑是完全相同的。
|
||||||
|
* 此适配器将 AGP 消息转换为 OpenClaw 内部的 FuwuhaoMessage 格式,
|
||||||
|
* 从而复用 HTTP 通道已有的 buildMessageContext 逻辑,避免重复实现。
|
||||||
|
*
|
||||||
|
* 转换链路:
|
||||||
|
* AGP PromptPayload → FuwuhaoMessage → MsgContext(OpenClaw 内部格式)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PromptPayload, ContentBlock } from "./types.js";
|
||||||
|
import type { FuwuhaoMessage } from "../http/types.js";
|
||||||
|
import { getWecomRuntime } from "../common/runtime.js";
|
||||||
|
import { buildMessageContext } from "../common/message-context.js";
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 消息适配器
|
||||||
|
// ============================================
|
||||||
|
// 负责 AGP 协议消息与 OpenClaw 内部格式之间的转换
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 ContentBlock 数组中提取纯文本
|
||||||
|
* @param content - AGP 协议的内容块数组(每个块有 type 和 text 字段)
|
||||||
|
* @returns 合并后的纯文本字符串(多个文本块用换行符连接)
|
||||||
|
* @description
|
||||||
|
* AGP 协议的消息内容是结构化的 ContentBlock 数组,支持多种类型(目前只有 text)。
|
||||||
|
* 此函数将所有 text 类型的块提取出来,合并为一个纯文本字符串。
|
||||||
|
*
|
||||||
|
* 处理步骤:
|
||||||
|
* 1. filter: 过滤出 type === "text" 的块(忽略未来可能新增的其他类型)
|
||||||
|
* 2. map: 提取每个块的 text 字段
|
||||||
|
* 3. join: 用换行符连接多个文本块
|
||||||
|
*
|
||||||
|
* 示例:
|
||||||
|
* ```
|
||||||
|
* extractTextFromContent([
|
||||||
|
* { type: "text", text: "你好" },
|
||||||
|
* { type: "text", text: "请帮我写代码" }
|
||||||
|
* ])
|
||||||
|
* // 返回:"你好\n请帮我写代码"
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const extractTextFromContent = (content: ContentBlock[]): string => {
|
||||||
|
return content
|
||||||
|
.filter((block) => block.type === "text")
|
||||||
|
.map((block) => block.text)
|
||||||
|
.join("\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 AGP session.prompt 载荷转换为 FuwuhaoMessage 格式
|
||||||
|
* @param payload - AGP 协议的 prompt 载荷(包含 session_id、prompt_id、content 等)
|
||||||
|
* @param userId - 用户 ID(来自 AGP 信封的 user_id 字段)
|
||||||
|
* @returns OpenClaw 内部的 FuwuhaoMessage 格式
|
||||||
|
* @description
|
||||||
|
* FuwuhaoMessage 是 OpenClaw 为微信服务号定义的内部消息格式,
|
||||||
|
* 与微信服务号 Webhook 推送的消息格式保持一致。
|
||||||
|
* 通过将 AGP 消息转换为此格式,可以复用 HTTP 通道的所有处理逻辑。
|
||||||
|
*
|
||||||
|
* 字段映射:
|
||||||
|
* - msgtype: 固定为 "text"(当前只支持文本消息)
|
||||||
|
* - MsgId: 使用 prompt_id 作为消息 ID(保证唯一性)
|
||||||
|
* - Content: 从 ContentBlock 数组提取的纯文本
|
||||||
|
* - FromUserName: 发送者 ID(来自 AGP 信封的 user_id)
|
||||||
|
* - ToUserName: 固定为 "fuwuhao_bot"(接收方标识)
|
||||||
|
* - CreateTime: 当前时间戳(秒级,Math.floor(Date.now() / 1000))
|
||||||
|
*
|
||||||
|
* `Date.now()` 返回毫秒级时间戳,除以 1000 并取整得到秒级时间戳,
|
||||||
|
* 与微信服务号 Webhook 的 CreateTime 字段格式一致。
|
||||||
|
*/
|
||||||
|
export const promptPayloadToFuwuhaoMessage = (
|
||||||
|
payload: PromptPayload,
|
||||||
|
userId: string
|
||||||
|
): FuwuhaoMessage => {
|
||||||
|
const textContent = extractTextFromContent(payload.content);
|
||||||
|
|
||||||
|
return {
|
||||||
|
msgtype: "text",
|
||||||
|
MsgId: payload.prompt_id, // 使用 prompt_id 作为消息唯一 ID
|
||||||
|
Content: textContent,
|
||||||
|
FromUserName: userId,
|
||||||
|
ToUserName: "fuwuhao_bot",
|
||||||
|
CreateTime: Math.floor(Date.now() / 1000), // 秒级时间戳
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 WebSocket 消息的完整上下文
|
||||||
|
* @param payload - AGP 协议的 prompt 载荷
|
||||||
|
* @param userId - 用户 ID
|
||||||
|
* @returns 消息上下文对象,包含:
|
||||||
|
* - ctx: MsgContext — OpenClaw 内部消息上下文(含路由、会话等信息)
|
||||||
|
* - route: 路由信息(agentId、accountId、sessionKey 等)
|
||||||
|
* - storePath: 会话存储文件路径
|
||||||
|
* @description
|
||||||
|
* 这是适配器的核心函数,完成两步转换:
|
||||||
|
* 1. AGP PromptPayload → FuwuhaoMessage(通过 promptPayloadToFuwuhaoMessage)
|
||||||
|
* 2. FuwuhaoMessage → MsgContext(通过 buildMessageContext,复用 HTTP 通道逻辑)
|
||||||
|
*
|
||||||
|
* buildMessageContext 内部会:
|
||||||
|
* - 根据消息的 FromUserName 和 ToUserName 确定路由(选择哪个 Agent)
|
||||||
|
* - 计算 sessionKey(用于关联历史对话)
|
||||||
|
* - 确定 storePath(会话历史存储位置)
|
||||||
|
* - 构建完整的 MsgContext(包含所有 Agent 处理所需的上下文信息)
|
||||||
|
*
|
||||||
|
* 通过这种适配方式,WebSocket 通道和 HTTP 通道共享同一套路由和会话管理逻辑,
|
||||||
|
* 确保两个通道的行为完全一致。
|
||||||
|
*/
|
||||||
|
export const buildWebSocketMessageContext = (payload: PromptPayload, userId: string) => {
|
||||||
|
const message = promptPayloadToFuwuhaoMessage(payload, userId);
|
||||||
|
return buildMessageContext(message);
|
||||||
|
};
|
||||||
612
websocket/message-handler.ts
Normal file
612
websocket/message-handler.ts
Normal file
@@ -0,0 +1,612 @@
|
|||||||
|
/**
|
||||||
|
* @file message-handler.ts
|
||||||
|
* @description WebSocket 消息处理器
|
||||||
|
*
|
||||||
|
* 负责处理从 AGP 服务端收到的下行消息,核心流程:
|
||||||
|
* 1. 收到 session.prompt → 调用 OpenClaw Agent 处理用户指令
|
||||||
|
* 2. 通过 runtime.events.onAgentEvent 监听 Agent 的流式输出
|
||||||
|
* 3. 将流式输出实时通过 WebSocket 推送给服务端(session.update)
|
||||||
|
* 4. Agent 处理完成后发送最终结果(session.promptResponse)
|
||||||
|
* 5. 收到 session.cancel → 中断正在处理的 Turn
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PromptMessage,
|
||||||
|
CancelMessage,
|
||||||
|
ContentBlock,
|
||||||
|
ToolCall,
|
||||||
|
} from "./types.js";
|
||||||
|
import { onAgentEvent, type AgentEventPayload } from "../common/agent-events.js";
|
||||||
|
import type { WechatAccessWebSocketClient } from "./websocket-client.js";
|
||||||
|
|
||||||
|
/** 内容安全审核拦截标记,由 content-security 插件的 fetch 拦截器嵌入伪 SSE 响应中 */
|
||||||
|
const SECURITY_BLOCK_MARKER = "<!--CONTENT_SECURITY_BLOCK-->";
|
||||||
|
|
||||||
|
/** 安全拦截后返回给微信用户的通用提示文本(不暴露具体拦截原因) */
|
||||||
|
const SECURITY_BLOCK_USER_MESSAGE = "抱歉,我无法处理该任务,让我们换个任务试试看?";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `getWecomRuntime` 返回 OpenClaw 框架注入的运行时实例(PluginRuntime)。
|
||||||
|
* 运行时提供了访问框架核心功能的统一入口,包括:
|
||||||
|
* - runtime.config.loadConfig():读取 openclaw 配置文件(~/.openclaw/config.json)
|
||||||
|
* - runtime.events.onAgentEvent():订阅 Agent 运行时事件(流式输出、工具调用等)
|
||||||
|
* - runtime.channel.session:会话元数据管理(记录用户会话信息)
|
||||||
|
* - runtime.channel.activity:渠道活动统计(记录收发消息次数)
|
||||||
|
* - runtime.channel.reply:消息回复调度(调用 Agent 并分发回复)
|
||||||
|
*/
|
||||||
|
import { getWecomRuntime } from "../common/runtime.js";
|
||||||
|
import {
|
||||||
|
extractTextFromContent,
|
||||||
|
buildWebSocketMessageContext,
|
||||||
|
} from "./message-adapter.js";
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// WebSocket 消息处理器
|
||||||
|
// ============================================
|
||||||
|
// 接收 AGP 下行消息 → 调用 OpenClaw Agent → 发送 AGP 上行消息
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 活跃的 Prompt Turn 追踪器
|
||||||
|
* @description
|
||||||
|
* 每个正在处理中的用户请求(Turn)都会在 activeTurns Map 中注册一条记录。
|
||||||
|
* 用于支持取消操作:收到 session.cancel 时,通过 promptId 找到对应的 Turn,
|
||||||
|
* 将其标记为已取消,并取消 Agent 事件订阅。
|
||||||
|
*/
|
||||||
|
interface ActiveTurn {
|
||||||
|
sessionId: string;
|
||||||
|
promptId: string;
|
||||||
|
/** 是否已被取消(标志位,Agent 事件回调中检查此值决定是否继续处理) */
|
||||||
|
cancelled: boolean;
|
||||||
|
/**
|
||||||
|
* Agent 事件取消订阅函数。
|
||||||
|
* `runtime.events.onAgentEvent()` 返回一个函数,调用该函数可以取消订阅,
|
||||||
|
* 停止接收后续的 Agent 事件(类似 EventEmitter 的 removeListener)。
|
||||||
|
*/
|
||||||
|
unsubscribe?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前活跃的 Turn 映射(promptId → ActiveTurn)
|
||||||
|
* @description
|
||||||
|
* 使用 Map 而非对象,因为 Map 的 key 可以是任意类型,且有更好的增删性能。
|
||||||
|
* promptId 是服务端分配的唯一 Turn ID,用于关联 prompt 和 cancel 消息。
|
||||||
|
*/
|
||||||
|
const activeTurns = new Map<string, ActiveTurn>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 session.prompt 消息 — 接收用户指令并调用 Agent
|
||||||
|
* @param message - AGP session.prompt 消息(包含用户指令内容)
|
||||||
|
* @param client - WebSocket 客户端实例(用于发送上行消息回服务端)
|
||||||
|
* @description
|
||||||
|
* 完整处理流程:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* 服务端 → session.prompt
|
||||||
|
* ↓
|
||||||
|
* 1. 注册 ActiveTurn(支持后续取消)
|
||||||
|
* ↓
|
||||||
|
* 2. getWecomRuntime() 获取运行时
|
||||||
|
* ↓
|
||||||
|
* 3. runtime.config.loadConfig() 读取配置
|
||||||
|
* ↓
|
||||||
|
* 4. buildWebSocketMessageContext() 构建消息上下文(路由、会话路径等)
|
||||||
|
* ↓
|
||||||
|
* 5. runtime.channel.session.recordSessionMetaFromInbound() 记录会话元数据
|
||||||
|
* ↓
|
||||||
|
* 6. runtime.channel.activity.record() 记录入站活动统计
|
||||||
|
* ↓
|
||||||
|
* 7. runtime.events.onAgentEvent() 订阅 Agent 流式事件
|
||||||
|
* ↓
|
||||||
|
* 8. runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher() 调用 Agent
|
||||||
|
* ↓ (Agent 运行期间,步骤 7 的回调持续触发)
|
||||||
|
* ├── assistant 流 → client.sendMessageChunk() → session.update(message_chunk)
|
||||||
|
* └── tool 流 → client.sendToolCall/sendToolCallUpdate() → session.update(tool_call)
|
||||||
|
* ↓
|
||||||
|
* 9. client.sendPromptResponse() → session.promptResponse(最终结果)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const handlePrompt = async (
|
||||||
|
message: PromptMessage,
|
||||||
|
client: WechatAccessWebSocketClient
|
||||||
|
): Promise<void> => {
|
||||||
|
const { payload } = message;
|
||||||
|
const { session_id: sessionId, prompt_id: promptId } = payload;
|
||||||
|
const userId = message.user_id ?? "";
|
||||||
|
const guid = message.guid ?? "";
|
||||||
|
//message {
|
||||||
|
// msg_id: '9b842a47-c07d-4307-974f-42a4f8eeecb4',
|
||||||
|
// guid: '0ef9cc5e5dcb7ca068b0fb9982352c33',
|
||||||
|
// user_id: '3730000',
|
||||||
|
// method: 'session.prompt',
|
||||||
|
// payload: {
|
||||||
|
// session_id: '384f885b-4387-4f2b-9233-89a5fe6f94ee',
|
||||||
|
// prompt_id: 'ca694ac8-35e3-4e8b-9ecc-88efd4324515',
|
||||||
|
// agent_app: 'agent_demo',
|
||||||
|
// content: [ [Object] ]
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
const textContent = extractTextFromContent(payload.content);
|
||||||
|
console.log("[wechat-access-ws] 收到 prompt:", payload);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 1. 注册活跃 Turn
|
||||||
|
// ============================================
|
||||||
|
// 在 activeTurns Map 中注册此次请求,以便 handleCancel 能找到并取消它
|
||||||
|
const turn: ActiveTurn = {
|
||||||
|
sessionId,
|
||||||
|
promptId,
|
||||||
|
cancelled: false,
|
||||||
|
};
|
||||||
|
activeTurns.set(promptId, turn);
|
||||||
|
|
||||||
|
try {
|
||||||
|
/**
|
||||||
|
* getWecomRuntime() 返回 OpenClaw 框架的运行时实例(PluginRuntime)。
|
||||||
|
* 这是一个单例,在插件初始化时由 setWecomRuntime(api.runtime) 注入。
|
||||||
|
* 如果未初始化就调用会抛出错误。
|
||||||
|
*/
|
||||||
|
const runtime = getWecomRuntime();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* runtime.config.loadConfig() 同步读取 OpenClaw 配置文件。
|
||||||
|
* 配置文件通常位于 ~/.openclaw/config.json,包含:
|
||||||
|
* - Agent 配置(模型、系统提示词等)
|
||||||
|
* - 渠道配置(各渠道的账号信息)
|
||||||
|
* - 会话存储路径等
|
||||||
|
* 返回的 cfg 对象在后续的 dispatchReplyWithBufferedBlockDispatcher 中使用。
|
||||||
|
*/
|
||||||
|
const cfg = runtime.config.loadConfig();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 2. 构建消息上下文
|
||||||
|
// ============================================
|
||||||
|
/**
|
||||||
|
* buildWebSocketMessageContext() 将 AGP 消息转换为 OpenClaw 内部的消息上下文格式。
|
||||||
|
* 返回值包含:
|
||||||
|
* - ctx: MsgContext — 消息上下文(包含 From、To、SessionKey、AgentId 等字段)
|
||||||
|
* - route: 路由信息(agentId、accountId、sessionKey 等)
|
||||||
|
* - storePath: 会话存储文件路径(如 ~/.openclaw/sessions/agent-xxx.json)
|
||||||
|
*
|
||||||
|
* 这样可以复用 HTTP 通道的路由和会话管理逻辑,保持一致性。
|
||||||
|
*/
|
||||||
|
const { ctx, route, storePath } = buildWebSocketMessageContext(payload, userId);
|
||||||
|
|
||||||
|
console.log("[wechat-access-ws] 路由信息:", {
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
agentId: route.agentId,
|
||||||
|
accountId: route.accountId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 3. 记录会话元数据
|
||||||
|
// ============================================
|
||||||
|
/**
|
||||||
|
* runtime.channel.session.recordSessionMetaFromInbound() 将本次消息的元数据
|
||||||
|
* 写入会话存储文件(storePath 指向的 JSON 文件)。
|
||||||
|
* 元数据包括:用户 ID、渠道类型、最后活跃时间等。
|
||||||
|
* 这些数据用于会话管理、上下文恢复等功能。
|
||||||
|
*
|
||||||
|
* 使用 void + .catch() 的原因:
|
||||||
|
* - void: 明确表示不等待此 Promise(不阻塞主流程)
|
||||||
|
* - .catch(): 捕获错误并打印日志,避免未处理的 Promise rejection
|
||||||
|
* 会话元数据写入失败不影响消息处理,所以不需要 await。
|
||||||
|
*/
|
||||||
|
void runtime.channel.session
|
||||||
|
.recordSessionMetaFromInbound({
|
||||||
|
storePath,
|
||||||
|
sessionKey: (ctx.SessionKey as string) ?? route.sessionKey,
|
||||||
|
ctx,
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.log(`[wechat-access-ws] 记录会话元数据失败: ${String(err)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 4. 记录入站活动
|
||||||
|
// ============================================
|
||||||
|
/**
|
||||||
|
* runtime.channel.activity.record() 记录渠道活动统计数据。
|
||||||
|
* direction: "inbound" 表示这是一条收到的消息(用户 → 系统)。
|
||||||
|
* 这些统计数据用于 OpenClaw 控制台的活动监控面板。
|
||||||
|
*/
|
||||||
|
runtime.channel.activity.record({
|
||||||
|
channel: "wechat-access",
|
||||||
|
accountId: route.accountId ?? "default",
|
||||||
|
direction: "inbound",
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 5. 订阅 Agent 事件(流式输出)
|
||||||
|
// ============================================
|
||||||
|
/**
|
||||||
|
* runtime.events.onAgentEvent() 注册一个全局 Agent 事件监听器。
|
||||||
|
* 当 Agent 运行时,会通过事件总线(EventEmitter)广播各种事件。
|
||||||
|
*
|
||||||
|
* AgentEventPayload 结构:
|
||||||
|
* {
|
||||||
|
* runId: string; // Agent 运行实例 ID
|
||||||
|
* seq: number; // 事件序号(严格递增,用于检测丢失事件)
|
||||||
|
* stream: string; // 事件流类型(见下方说明)
|
||||||
|
* ts: number; // 时间戳(毫秒)
|
||||||
|
* data: Record<string, unknown>; // 事件数据(不同 stream 有不同结构)
|
||||||
|
* sessionKey?: string; // 关联的会话 key
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* stream 类型说明:
|
||||||
|
* - "assistant": AI 助手的文本输出流
|
||||||
|
* data.delta: 增量文本(本次新增的部分)
|
||||||
|
* data.text: 累积文本(从开始到现在的完整文本)
|
||||||
|
* - "tool": 工具调用流
|
||||||
|
* data.phase: 阶段("start" | "update" | "result")
|
||||||
|
* data.name: 工具名称(如 "read_file"、"write")
|
||||||
|
* data.toolCallId: 工具调用唯一 ID
|
||||||
|
* data.args: 工具参数(phase=start 时)
|
||||||
|
* data.result: 工具执行结果(phase=result 时)
|
||||||
|
* data.isError: 是否执行失败(phase=result 时)
|
||||||
|
* - "lifecycle": 生命周期事件(start/end/error)
|
||||||
|
* - "compaction": 上下文压缩事件
|
||||||
|
*
|
||||||
|
* 返回值是取消订阅函数,调用后停止接收事件。
|
||||||
|
* 注意:这是全局事件总线,所有 Agent 运行的事件都会触发此回调,
|
||||||
|
* 但目前没有按 runId 过滤(因为同一时间通常只有一个 Agent 在运行)。
|
||||||
|
*/
|
||||||
|
let lastEmittedText = ""; // 记录已发送的累积文本,用于计算增量
|
||||||
|
let toolCallCounter = 0; // 工具调用计数器,用于生成备用 toolCallId
|
||||||
|
|
||||||
|
// await 确保 SDK 加载完成、监听器真正挂载后,再调用 dispatchReply
|
||||||
|
// 否则 Agent 产生事件时监听器还未注册,导致所有事件丢失
|
||||||
|
const unsubscribe = await onAgentEvent((evt: AgentEventPayload) => {
|
||||||
|
// 如果 Turn 已被取消,忽略后续事件(不再向服务端推送)
|
||||||
|
if (turn.cancelled) return;
|
||||||
|
// 过滤非本 Turn 的事件,避免并发多个 prompt 时事件串流
|
||||||
|
if (evt.sessionKey && evt.sessionKey !== route.sessionKey) return;
|
||||||
|
|
||||||
|
const data = evt.data as Record<string, unknown>;
|
||||||
|
|
||||||
|
// --- 处理流式文本(assistant 流)---
|
||||||
|
if (evt.stream === "assistant") {
|
||||||
|
/**
|
||||||
|
* Agent 生成文本时,事件总线会持续触发 assistant 流事件。
|
||||||
|
* 每个事件包含:
|
||||||
|
* - data.delta: 本次新增的文本片段(增量)
|
||||||
|
* - data.text: 从开始到现在的完整文本(累积)
|
||||||
|
*
|
||||||
|
* 优先使用 delta(增量),因为它直接就是需要发送的内容。
|
||||||
|
* 如果没有 delta(某些 AI 提供商只提供累积文本),
|
||||||
|
* 则通过 text.slice(lastEmittedText.length) 手动计算增量。
|
||||||
|
*/
|
||||||
|
const delta = data.delta as string | undefined;
|
||||||
|
const text = data.text as string | undefined;
|
||||||
|
|
||||||
|
let textToSend = delta;
|
||||||
|
if (!textToSend && text && text !== lastEmittedText) {
|
||||||
|
// 手动计算增量:新的累积文本 - 已发送的累积文本 = 本次增量
|
||||||
|
textToSend = text.slice(lastEmittedText.length);
|
||||||
|
lastEmittedText = text;
|
||||||
|
} else if (delta) {
|
||||||
|
lastEmittedText += delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测安全审核拦截标记:如果流式文本中包含拦截标记,停止向用户推送
|
||||||
|
// 拦截标记由 content-security 插件的 fetch 拦截器注入伪 SSE 响应
|
||||||
|
if (textToSend && textToSend.includes(SECURITY_BLOCK_MARKER)) {
|
||||||
|
console.warn("[wechat-access-ws] 流式文本中检测到安全审核拦截标记,停止推送");
|
||||||
|
turn.cancelled = true; // 标记为已取消,阻止后续流式事件继续推送
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lastEmittedText.includes(SECURITY_BLOCK_MARKER)) {
|
||||||
|
console.warn("[wechat-access-ws] 累积文本中检测到安全审核拦截标记,停止推送");
|
||||||
|
turn.cancelled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textToSend) {
|
||||||
|
// 将增量文本作为 session.update(message_chunk) 发送给服务端
|
||||||
|
client.sendMessageChunk(sessionId, promptId, {
|
||||||
|
type: "text",
|
||||||
|
text: textToSend,
|
||||||
|
}, guid, userId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 处理工具调用事件(tool 流)---
|
||||||
|
if (evt.stream === "tool") {
|
||||||
|
/**
|
||||||
|
* 工具调用有三个阶段(phase):
|
||||||
|
* - "start": 工具开始执行(发送 tool_call,status=in_progress)
|
||||||
|
* - "update": 工具执行中有中间结果(发送 tool_call_update,status=in_progress)
|
||||||
|
* - "result": 工具执行完成(发送 tool_call_update,status=completed/failed)
|
||||||
|
*
|
||||||
|
* toolCallId 是工具调用的唯一标识,用于关联同一次工具调用的多个事件。
|
||||||
|
* 如果 Agent 没有提供 toolCallId,则用计数器生成一个备用 ID。
|
||||||
|
*/
|
||||||
|
const phase = data.phase as string | undefined;
|
||||||
|
const toolName = data.name as string | undefined;
|
||||||
|
const toolCallId = (data.toolCallId as string) || `tc-${++toolCallCounter}`;
|
||||||
|
|
||||||
|
if (phase === "start") {
|
||||||
|
// 工具开始执行:通知服务端展示工具调用状态(进行中)
|
||||||
|
const toolCall: ToolCall = {
|
||||||
|
tool_call_id: toolCallId,
|
||||||
|
title: toolName,
|
||||||
|
kind: mapToolKind(toolName), // 根据工具名推断工具类型(read/edit/search 等)
|
||||||
|
status: "in_progress",
|
||||||
|
};
|
||||||
|
client.sendToolCall(sessionId, promptId, toolCall, guid, userId);
|
||||||
|
} else if (phase === "update") {
|
||||||
|
// 工具执行中有中间结果(如读取文件的部分内容)
|
||||||
|
const toolCall: ToolCall = {
|
||||||
|
tool_call_id: toolCallId,
|
||||||
|
title: toolName,
|
||||||
|
status: "in_progress",
|
||||||
|
content: data.text
|
||||||
|
? [{ type: "text" as const, text: data.text as string }]
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
client.sendToolCallUpdate(sessionId, promptId, toolCall, guid, userId);
|
||||||
|
} else if (phase === "result") {
|
||||||
|
// 工具执行完成:更新状态为 completed 或 failed
|
||||||
|
const isError = data.isError as boolean | undefined;
|
||||||
|
const toolCall: ToolCall = {
|
||||||
|
tool_call_id: toolCallId,
|
||||||
|
title: toolName,
|
||||||
|
status: isError ? "failed" : "completed",
|
||||||
|
// 将工具执行结果作为内容块附加(可选,用于展示)
|
||||||
|
content: data.result
|
||||||
|
? [{ type: "text" as const, text: data.result as string }]
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
client.sendToolCallUpdate(sessionId, promptId, toolCall, guid, userId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 将取消订阅函数保存到 Turn 记录中,以便 handleCancel 调用
|
||||||
|
turn.unsubscribe = unsubscribe;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 6. 调用 Agent 处理消息
|
||||||
|
// ============================================
|
||||||
|
/**
|
||||||
|
* runtime.channel.reply.resolveEffectiveMessagesConfig() 解析当前 Agent 的消息配置。
|
||||||
|
* 返回值包含:
|
||||||
|
* - responsePrefix: 回复前缀(如果配置了的话)
|
||||||
|
* - 其他消息格式配置
|
||||||
|
* 参数 route.agentId 指定要查询哪个 Agent 的配置。
|
||||||
|
*/
|
||||||
|
const messagesConfig = runtime.channel.reply.resolveEffectiveMessagesConfig(
|
||||||
|
cfg,
|
||||||
|
route.agentId
|
||||||
|
);
|
||||||
|
|
||||||
|
let finalText: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher() 是核心调用。
|
||||||
|
* 它完成以下工作:
|
||||||
|
* 1. 根据 ctx(消息上下文)和 cfg(配置)确定使用哪个 Agent
|
||||||
|
* 2. 加载该 Agent 的历史会话记录(上下文)
|
||||||
|
* 3. 调用 AI 模型生成回复(流式)
|
||||||
|
* 4. 在生成过程中,通过事件总线广播 assistant/tool 流事件(步骤 5 的回调会收到)
|
||||||
|
* 5. 将生成的回复通过 dispatcherOptions.deliver 回调交付
|
||||||
|
* 6. 保存本次对话到会话历史
|
||||||
|
*
|
||||||
|
* "BufferedBlockDispatcher" 的含义:
|
||||||
|
* - Buffered: 将流式输出缓冲后再交付(避免过于频繁的回调)
|
||||||
|
* - Block: 按块(段落/句子)分割回复
|
||||||
|
* - Dispatcher: 负责将回复分发给 deliver 回调
|
||||||
|
*
|
||||||
|
* 返回值 { queuedFinal } 包含最终排队的回复内容(此处未使用,通过 deliver 回调获取)。
|
||||||
|
*
|
||||||
|
* 注意:此函数是 async 的,会等待 Agent 完全处理完毕才 resolve。
|
||||||
|
* 在等待期间,步骤 5 注册的 onAgentEvent 回调会持续被触发(流式推送)。
|
||||||
|
*/
|
||||||
|
const { queuedFinal } = await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
dispatcherOptions: {
|
||||||
|
responsePrefix: messagesConfig.responsePrefix,
|
||||||
|
/**
|
||||||
|
* deliver 回调:当 Agent 生成了一个完整的回复块时调用。
|
||||||
|
* @param payload - 回复内容(text、mediaUrl 等)
|
||||||
|
* @param info - 回复元信息(kind: "final" | "chunk" | "error" 等)
|
||||||
|
*
|
||||||
|
* 这里主要用于:
|
||||||
|
* 1. 捕获最终回复文本(finalText)
|
||||||
|
* 2. 记录出站活动统计
|
||||||
|
*
|
||||||
|
* 注意:流式文本已经通过 onAgentEvent 的 assistant 流实时推送了,
|
||||||
|
* 这里的 deliver 是最终汇总的回调,用于获取完整的最终文本。
|
||||||
|
*/
|
||||||
|
deliver: async (
|
||||||
|
payload: {
|
||||||
|
text?: string;
|
||||||
|
mediaUrl?: string;
|
||||||
|
mediaUrls?: string[];
|
||||||
|
isError?: boolean;
|
||||||
|
channelData?: unknown;
|
||||||
|
},
|
||||||
|
info: { kind: string }
|
||||||
|
) => {
|
||||||
|
if (turn.cancelled) return;
|
||||||
|
|
||||||
|
console.log(`[wechat-access-ws] Agent ${info.kind} 回复:`, payload.text?.slice(0, 50));
|
||||||
|
|
||||||
|
// 保存最终回复文本,用于构建 session.promptResponse 的 content
|
||||||
|
// 不限制 kind,只要有 text 就更新(final/chunk 都可能携带完整文本)
|
||||||
|
if (payload.text) {
|
||||||
|
// 检测安全审核拦截标记:如果回复文本包含拦截标记,
|
||||||
|
// 替换为通用安全提示,不向用户暴露具体拦截原因和内部标记
|
||||||
|
if (payload.text.includes(SECURITY_BLOCK_MARKER)) {
|
||||||
|
console.warn("[wechat-access-ws] deliver 回复中检测到安全审核拦截标记,替换为安全提示");
|
||||||
|
finalText = SECURITY_BLOCK_USER_MESSAGE;
|
||||||
|
} else {
|
||||||
|
finalText = payload.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录出站活动统计(每次 deliver 都算一次出站)
|
||||||
|
runtime.channel.activity.record({
|
||||||
|
channel: "wechat-access",
|
||||||
|
accountId: route.accountId ?? "default",
|
||||||
|
direction: "outbound",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err: unknown, info: { kind: string }) => {
|
||||||
|
console.error(`[wechat-access-ws] Agent ${info.kind} 回复失败:`, err);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
replyOptions: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 7. 发送最终结果
|
||||||
|
// ============================================
|
||||||
|
// Agent 处理完成,取消事件订阅并清理 Turn 记录
|
||||||
|
unsubscribe();
|
||||||
|
activeTurns.delete(promptId);
|
||||||
|
|
||||||
|
if (turn.cancelled) {
|
||||||
|
// 如果在 Agent 处理期间收到了 cancel 消息,发送 cancelled 响应
|
||||||
|
client.sendPromptResponse({
|
||||||
|
session_id: sessionId,
|
||||||
|
prompt_id: promptId,
|
||||||
|
stop_reason: "cancelled",
|
||||||
|
}, guid, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建最终内容块(如果有文本回复的话)
|
||||||
|
// 优先用 deliver 回调收到的 finalText,兜底用流式事件累积的 lastEmittedText
|
||||||
|
let replyText = finalText || (lastEmittedText.trim() ? lastEmittedText : null);
|
||||||
|
|
||||||
|
// 最后一道防线:检查最终回复文本是否包含安全拦截标记
|
||||||
|
// 正常情况下 deliver 回调和流式事件中已经处理过了,这里是兜底
|
||||||
|
if (replyText && replyText.includes(SECURITY_BLOCK_MARKER)) {
|
||||||
|
console.warn("[wechat-access-ws] 最终回复文本中检测到安全审核拦截标记,替换为安全提示");
|
||||||
|
replyText = SECURITY_BLOCK_USER_MESSAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseContent: ContentBlock[] = replyText
|
||||||
|
? [{ type: "text", text: replyText }]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// 发送 session.promptResponse,告知服务端本次 Turn 已正常完成
|
||||||
|
client.sendPromptResponse({
|
||||||
|
session_id: sessionId,
|
||||||
|
prompt_id: promptId,
|
||||||
|
stop_reason: "end_turn",
|
||||||
|
content: responseContent,
|
||||||
|
}, guid, userId);
|
||||||
|
|
||||||
|
console.log("[wechat-access-ws] prompt 处理完成:", { promptId, hasReply: !!replyText, finalText: !!finalText, lastEmittedText: lastEmittedText.length });
|
||||||
|
} catch (err) {
|
||||||
|
// ============================================
|
||||||
|
// 错误处理
|
||||||
|
// ============================================
|
||||||
|
console.error("[wechat-access-ws] prompt 处理失败:", err);
|
||||||
|
|
||||||
|
// 清理活跃 Turn(取消事件订阅,从 Map 中移除)
|
||||||
|
const currentTurn = activeTurns.get(promptId);
|
||||||
|
currentTurn?.unsubscribe?.();
|
||||||
|
activeTurns.delete(promptId);
|
||||||
|
|
||||||
|
// 发送错误响应,告知服务端本次 Turn 因错误终止
|
||||||
|
client.sendPromptResponse({
|
||||||
|
session_id: sessionId,
|
||||||
|
prompt_id: promptId,
|
||||||
|
stop_reason: "error",
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
}, guid, userId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 session.cancel 消息 — 取消正在处理的 Prompt Turn
|
||||||
|
* @param message - AGP session.cancel 消息
|
||||||
|
* @param client - WebSocket 客户端实例
|
||||||
|
* @description
|
||||||
|
* 取消流程:
|
||||||
|
* 1. 通过 promptId 在 activeTurns Map 中查找对应的 Turn
|
||||||
|
* 2. 将 turn.cancelled 标记为 true(handlePrompt 中的 onAgentEvent 回调会检查此标志)
|
||||||
|
* 3. 调用 turn.unsubscribe() 停止接收后续 Agent 事件
|
||||||
|
* 4. 从 activeTurns 中移除此 Turn
|
||||||
|
* 5. 发送 session.promptResponse(stop_reason: "cancelled")
|
||||||
|
*
|
||||||
|
* 注意:取消操作是"尽力而为"的,Agent 可能已经处理完毕,
|
||||||
|
* 此时 activeTurns 中找不到对应 Turn,但仍然发送 cancelled 响应。
|
||||||
|
*/
|
||||||
|
export const handleCancel = (
|
||||||
|
message: CancelMessage,
|
||||||
|
client: WechatAccessWebSocketClient
|
||||||
|
): void => {
|
||||||
|
const { session_id: sessionId, prompt_id: promptId } = message.payload;
|
||||||
|
|
||||||
|
console.log("[wechat-access-ws] 收到 cancel:", { sessionId, promptId });
|
||||||
|
|
||||||
|
const turn = activeTurns.get(promptId);
|
||||||
|
if (!turn) {
|
||||||
|
console.warn(`[wechat-access-ws] 未找到活跃 Turn: ${promptId}`);
|
||||||
|
// 即使找不到对应 Turn(可能已处理完毕),也发送 cancelled 响应
|
||||||
|
// 确保服务端收到明确的结束信号
|
||||||
|
client.sendPromptResponse({
|
||||||
|
session_id: sessionId,
|
||||||
|
prompt_id: promptId,
|
||||||
|
stop_reason: "cancelled",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记为已取消:handlePrompt 中的 onAgentEvent 回调会检查此标志,
|
||||||
|
// 一旦为 true,后续的流式事件都会被忽略,不再向服务端推送
|
||||||
|
turn.cancelled = true;
|
||||||
|
|
||||||
|
// 取消 Agent 事件订阅,停止接收后续事件
|
||||||
|
// 可选链 ?.() 是因为 unsubscribe 可能还未赋值(Turn 刚注册但还未到步骤 5)
|
||||||
|
turn.unsubscribe?.();
|
||||||
|
activeTurns.delete(promptId);
|
||||||
|
|
||||||
|
// 发送 cancelled 响应
|
||||||
|
client.sendPromptResponse({
|
||||||
|
session_id: sessionId,
|
||||||
|
prompt_id: promptId,
|
||||||
|
stop_reason: "cancelled",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[wechat-access-ws] Turn 已取消:", promptId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 辅助函数
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将工具名称映射为 AGP 协议的 ToolCallKind
|
||||||
|
* @param toolName - 工具名称(如 "read_file"、"write"、"grep_search" 等)
|
||||||
|
* @returns ToolCallKind 枚举值,用于服务端展示不同类型的工具调用图标
|
||||||
|
* @description
|
||||||
|
* 通过关键词匹配推断工具类型,映射规则:
|
||||||
|
* - read/get/view → "read"(读取操作)
|
||||||
|
* - write/edit/replace → "edit"(编辑操作)
|
||||||
|
* - delete/remove → "delete"(删除操作)
|
||||||
|
* - search/find/grep → "search"(搜索操作)
|
||||||
|
* - fetch/request/http → "fetch"(网络请求)
|
||||||
|
* - think/reason → "think"(思考/推理)
|
||||||
|
* - exec/run/terminal → "execute"(执行命令)
|
||||||
|
* - 其他 → "other"
|
||||||
|
*/
|
||||||
|
const mapToolKind = (toolName?: string): ToolCall["kind"] => {
|
||||||
|
if (!toolName) return "other";
|
||||||
|
|
||||||
|
const name = toolName.toLowerCase();
|
||||||
|
if (name.includes("read") || name.includes("get") || name.includes("view")) return "read";
|
||||||
|
if (name.includes("write") || name.includes("edit") || name.includes("replace")) return "edit";
|
||||||
|
if (name.includes("delete") || name.includes("remove")) return "delete";
|
||||||
|
if (name.includes("search") || name.includes("find") || name.includes("grep")) return "search";
|
||||||
|
if (name.includes("fetch") || name.includes("request") || name.includes("http")) return "fetch";
|
||||||
|
if (name.includes("think") || name.includes("reason")) return "think";
|
||||||
|
if (name.includes("exec") || name.includes("run") || name.includes("terminal")) return "execute";
|
||||||
|
return "other";
|
||||||
|
};
|
||||||
290
websocket/types.ts
Normal file
290
websocket/types.ts
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
/**
|
||||||
|
* @file types.ts
|
||||||
|
* @description AGP (Agent Gateway Protocol) 协议类型定义
|
||||||
|
*
|
||||||
|
* AGP 是 OpenClaw 与外部服务(如微信服务号后端)之间的 WebSocket 通信协议。
|
||||||
|
* 所有消息都使用统一的「信封(Envelope)」格式,通过 method 字段区分消息类型。
|
||||||
|
*
|
||||||
|
* 消息方向:
|
||||||
|
* 下行(服务端 → 客户端):session.prompt、session.cancel
|
||||||
|
* 上行(客户端 → 服务端):session.update、session.promptResponse
|
||||||
|
*
|
||||||
|
* 基于 websocket.md 协议文档定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// AGP 消息信封
|
||||||
|
// ============================================
|
||||||
|
/**
|
||||||
|
* AGP 统一消息信封
|
||||||
|
* 所有 WebSocket 消息(上行和下行)均使用此格式
|
||||||
|
*/
|
||||||
|
export interface AGPEnvelope<T = unknown> {
|
||||||
|
/** 全局唯一消息 ID(UUID),用于幂等去重 */
|
||||||
|
msg_id: string;
|
||||||
|
/** 设备唯一标识(下行消息携带,上行消息需原样回传) */
|
||||||
|
guid?: string;
|
||||||
|
/** 用户 ID(下行消息携带,上行消息需原样回传) */
|
||||||
|
user_id?: string;
|
||||||
|
/** 消息类型 */
|
||||||
|
method: AGPMethod;
|
||||||
|
/** 消息载荷 */
|
||||||
|
payload: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Method 枚举
|
||||||
|
// ============================================
|
||||||
|
/**
|
||||||
|
* AGP 消息方法枚举
|
||||||
|
* - session.prompt: 下发用户指令(服务端 → 客户端)
|
||||||
|
* - session.cancel: 取消 Prompt Turn(服务端 → 客户端)
|
||||||
|
* - session.update: 流式中间更新(客户端 → 服务端)
|
||||||
|
* - session.promptResponse: 最终结果(客户端 → 服务端)
|
||||||
|
*/
|
||||||
|
export type AGPMethod =
|
||||||
|
| "session.prompt"
|
||||||
|
| "session.cancel"
|
||||||
|
| "session.update"
|
||||||
|
| "session.promptResponse"
|
||||||
|
| "ping";
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 通用数据结构
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内容块
|
||||||
|
* 当前仅支持 text 类型
|
||||||
|
*/
|
||||||
|
export interface ContentBlock {
|
||||||
|
type: "text";
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具调用状态枚举
|
||||||
|
*/
|
||||||
|
export type ToolCallStatus = "pending" | "in_progress" | "completed" | "failed";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具调用类型枚举
|
||||||
|
*/
|
||||||
|
export type ToolCallKind = "read" | "edit" | "delete" | "execute" | "search" | "fetch" | "think" | "other";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具操作路径
|
||||||
|
* @description 记录工具调用涉及的文件或目录路径,用于在 UI 中展示操作位置
|
||||||
|
*/
|
||||||
|
export interface ToolLocation {
|
||||||
|
/** 文件或目录的绝对路径 */
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具调用
|
||||||
|
* @description
|
||||||
|
* 描述一次工具调用的完整信息,用于在 session.update 消息中实时推送工具执行状态。
|
||||||
|
* 一次工具调用会产生多个 session.update 消息:
|
||||||
|
* 1. update_type=tool_call:工具开始执行(status=in_progress)
|
||||||
|
* 2. update_type=tool_call_update:执行中间状态(status=in_progress,可选)
|
||||||
|
* 3. update_type=tool_call_update:执行完成(status=completed/failed)
|
||||||
|
*/
|
||||||
|
export interface ToolCall {
|
||||||
|
/** 工具调用唯一 ID,用于关联同一次工具调用的多个 update 消息 */
|
||||||
|
tool_call_id: string;
|
||||||
|
/** 工具调用标题(展示用,通常是工具名称,如 "read_file") */
|
||||||
|
title?: string;
|
||||||
|
/** 工具类型,用于 UI 展示不同的图标 */
|
||||||
|
kind?: ToolCallKind;
|
||||||
|
/** 工具调用当前状态 */
|
||||||
|
status: ToolCallStatus;
|
||||||
|
/** 工具调用结果内容(phase=result 时附带,用于展示工具输出) */
|
||||||
|
content?: ContentBlock[];
|
||||||
|
/** 工具操作涉及的文件路径(可选,用于 UI 展示操作位置) */
|
||||||
|
locations?: ToolLocation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 下行消息(服务端 → 客户端)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* session.prompt 载荷 — 下发用户指令
|
||||||
|
* @description
|
||||||
|
* 服务端收到用户消息后,通过此消息将用户指令下发给客户端(OpenClaw Agent)处理。
|
||||||
|
* 客户端处理完毕后,需要发送 session.promptResponse 作为响应。
|
||||||
|
*/
|
||||||
|
export interface PromptPayload {
|
||||||
|
/** 所属 Session ID(标识一个完整的对话会话) */
|
||||||
|
session_id: string;
|
||||||
|
/** 本次 Turn 唯一 ID(标识一次「用户提问 + AI 回答」的完整交互) */
|
||||||
|
prompt_id: string;
|
||||||
|
/** 目标 AI 应用标识(指定由哪个 Agent 处理此消息) */
|
||||||
|
agent_app: string;
|
||||||
|
/** 用户指令内容(结构化内容块数组,目前只支持 text 类型) */
|
||||||
|
content: ContentBlock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* session.cancel 载荷 — 取消 Prompt Turn
|
||||||
|
* @description
|
||||||
|
* 用户主动取消正在处理的请求时,服务端发送此消息。
|
||||||
|
* 客户端收到后应停止 Agent 处理,并发送 stop_reason=cancelled 的 promptResponse。
|
||||||
|
*/
|
||||||
|
export interface CancelPayload {
|
||||||
|
/** 所属 Session ID */
|
||||||
|
session_id: string;
|
||||||
|
/** 要取消的 Turn ID(与对应 session.prompt 的 prompt_id 一致) */
|
||||||
|
prompt_id: string;
|
||||||
|
/** 目标 AI 应用标识 */
|
||||||
|
agent_app: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 上行消息(客户端 → 服务端)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* session.update 的更新类型
|
||||||
|
* @description
|
||||||
|
* 定义 session.update 消息中 update_type 字段的可选值:
|
||||||
|
* - message_chunk: Agent 生成的增量文本片段(流式输出,每次只包含新增的部分)
|
||||||
|
* - tool_call: Agent 开始调用一个工具(通知服务端展示工具调用状态)
|
||||||
|
* - tool_call_update: 工具调用状态变更(执行中的中间结果,或执行完成/失败)
|
||||||
|
*/
|
||||||
|
export type UpdateType = "message_chunk" | "tool_call" | "tool_call_update";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* session.update 载荷 — 流式中间更新
|
||||||
|
* @description
|
||||||
|
* 在 Agent 处理 session.prompt 的过程中,通过此消息实时推送中间状态。
|
||||||
|
* 服务端收到后转发给用户端,实现流式输出效果。
|
||||||
|
*
|
||||||
|
* 根据 update_type 的不同,使用不同的字段:
|
||||||
|
* - message_chunk: 使用 content 字段(单个 ContentBlock,非数组)
|
||||||
|
* - tool_call / tool_call_update: 使用 tool_call 字段
|
||||||
|
*/
|
||||||
|
export interface UpdatePayload {
|
||||||
|
/** 所属 Session ID */
|
||||||
|
session_id: string;
|
||||||
|
/** 所属 Turn ID(与对应 session.prompt 的 prompt_id 一致) */
|
||||||
|
prompt_id: string;
|
||||||
|
/** 更新类型,决定使用 content 还是 tool_call 字段 */
|
||||||
|
update_type: UpdateType;
|
||||||
|
/**
|
||||||
|
* 文本内容块(update_type=message_chunk 时使用)
|
||||||
|
* 注意:这里是单个 ContentBlock 对象,而非数组
|
||||||
|
*/
|
||||||
|
content?: ContentBlock;
|
||||||
|
/** 工具调用信息(update_type=tool_call 或 tool_call_update 时使用) */
|
||||||
|
tool_call?: ToolCall;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止原因枚举
|
||||||
|
* - end_turn: 正常完成
|
||||||
|
* - cancelled: 被取消
|
||||||
|
* - refusal: AI 应用拒绝执行
|
||||||
|
* - error: 技术错误
|
||||||
|
*/
|
||||||
|
export type StopReason = "end_turn" | "cancelled" | "refusal" | "error";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* session.promptResponse 载荷 — 最终结果
|
||||||
|
* @description
|
||||||
|
* Agent 处理完 session.prompt 后,必须发送此消息告知服务端本次 Turn 已结束。
|
||||||
|
* 无论正常完成、被取消还是出错,都需要发送此消息。
|
||||||
|
* 服务端收到后才会认为本次 Turn 已关闭,可以接受下一个 prompt。
|
||||||
|
*/
|
||||||
|
export interface PromptResponsePayload {
|
||||||
|
/** 所属 Session ID */
|
||||||
|
session_id: string;
|
||||||
|
/** 所属 Turn ID(与对应 session.prompt 的 prompt_id 一致) */
|
||||||
|
prompt_id: string;
|
||||||
|
/** 停止原因,告知服务端 Turn 是如何结束的 */
|
||||||
|
stop_reason: StopReason;
|
||||||
|
/**
|
||||||
|
* 最终结果内容(ContentBlock 数组)
|
||||||
|
* stop_reason=end_turn 时附带,包含 Agent 的完整回复文本
|
||||||
|
* stop_reason=cancelled/error 时通常为空
|
||||||
|
*/
|
||||||
|
content?: ContentBlock[];
|
||||||
|
/** 错误描述(stop_reason 为 error 或 refusal 时附带,说明失败原因) */
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 类型别名(方便使用)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/** 下行:session.prompt 消息 */
|
||||||
|
export type PromptMessage = AGPEnvelope<PromptPayload>;
|
||||||
|
/** 下行:session.cancel 消息 */
|
||||||
|
export type CancelMessage = AGPEnvelope<CancelPayload>;
|
||||||
|
/** 上行:session.update 消息 */
|
||||||
|
export type UpdateMessage = AGPEnvelope<UpdatePayload>;
|
||||||
|
/** 上行:session.promptResponse 消息 */
|
||||||
|
export type PromptResponseMessage = AGPEnvelope<PromptResponsePayload>;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// WebSocket 客户端配置
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket 客户端配置
|
||||||
|
* @description
|
||||||
|
* 在插件入口(index.ts)的 WS_CONFIG 常量中配置,传入 WechatAccessWebSocketClient 构造函数。
|
||||||
|
*/
|
||||||
|
export interface WebSocketClientConfig {
|
||||||
|
/** WebSocket 服务端地址(如 ws://21.0.62.97:8080/) */
|
||||||
|
url: string;
|
||||||
|
/** 设备唯一标识,用于服务端识别连接来源(作为 URL 查询参数传递) */
|
||||||
|
guid: string;
|
||||||
|
/** 用户账户 ID(作为 URL 查询参数传递,也用于上行消息的 user_id 字段) */
|
||||||
|
userId: string;
|
||||||
|
/** 鉴权 token(可选,作为 URL 查询参数传递,当前服务端未校验) */
|
||||||
|
token?: string;
|
||||||
|
/**
|
||||||
|
* 重连间隔基准值(毫秒),默认 3000(3秒)
|
||||||
|
* 实际重连间隔使用指数退避策略,此值是第一次重连的等待时间
|
||||||
|
*/
|
||||||
|
reconnectInterval?: number;
|
||||||
|
/**
|
||||||
|
* 最大重连次数,默认 0(无限重连)
|
||||||
|
* 设为正整数时,超过此次数后停止重连并将状态设为 disconnected
|
||||||
|
*/
|
||||||
|
maxReconnectAttempts?: number;
|
||||||
|
/**
|
||||||
|
* 心跳间隔(毫秒),默认 240000(4分钟)
|
||||||
|
* 应小于服务端的空闲超时时间(通常为 5 分钟),确保连接不会因空闲被断开
|
||||||
|
* 心跳使用 WebSocket 原生 ping 控制帧(ws 库的 ws.ping() 方法)
|
||||||
|
*/
|
||||||
|
heartbeatInterval?: number;
|
||||||
|
/**
|
||||||
|
* 当前 openclaw gateway 监听的端口号(来自 cfg.gateway.port)
|
||||||
|
* 用于日志前缀,方便区分多实例
|
||||||
|
*/
|
||||||
|
gatewayPort?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket 连接状态
|
||||||
|
*/
|
||||||
|
export type ConnectionState = "disconnected" | "connecting" | "connected" | "reconnecting";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket 客户端事件回调
|
||||||
|
*/
|
||||||
|
export interface WebSocketClientCallbacks {
|
||||||
|
/** 连接成功 */
|
||||||
|
onConnected?: () => void;
|
||||||
|
/** 连接断开 */
|
||||||
|
onDisconnected?: (reason?: string) => void;
|
||||||
|
/** 收到 session.prompt 消息 */
|
||||||
|
onPrompt?: (message: PromptMessage) => void;
|
||||||
|
/** 收到 session.cancel 消息 */
|
||||||
|
onCancel?: (message: CancelMessage) => void;
|
||||||
|
/** 发生错误 */
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
}
|
||||||
739
websocket/websocket-client.ts
Normal file
739
websocket/websocket-client.ts
Normal file
@@ -0,0 +1,739 @@
|
|||||||
|
/**
|
||||||
|
* `randomUUID` 来自 Node.js 内置的 `node:crypto` 模块。
|
||||||
|
* 用于生成符合 RFC 4122 标准的 UUID v4 字符串,格式如:
|
||||||
|
* "550e8400-e29b-41d4-a716-446655440000"
|
||||||
|
* 每次调用都会生成一个全局唯一的随机字符串,用作消息的 msg_id。
|
||||||
|
* 注意:这是 Node.js 原生 API,不需要安装任何第三方库。
|
||||||
|
*/
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import WebSocket from "ws";
|
||||||
|
import type {
|
||||||
|
AGPEnvelope,
|
||||||
|
AGPMethod,
|
||||||
|
WebSocketClientConfig,
|
||||||
|
ConnectionState,
|
||||||
|
WebSocketClientCallbacks,
|
||||||
|
PromptMessage,
|
||||||
|
CancelMessage,
|
||||||
|
UpdatePayload,
|
||||||
|
PromptResponsePayload,
|
||||||
|
ContentBlock,
|
||||||
|
ToolCall,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// WebSocket 客户端核心
|
||||||
|
// ============================================
|
||||||
|
// 负责 WebSocket 连接管理、消息收发、自动重连、心跳保活
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket 客户端
|
||||||
|
* @description
|
||||||
|
* 连接到 AGP WebSocket 服务端,处理双向通信:
|
||||||
|
* - 接收下行消息:session.prompt / session.cancel
|
||||||
|
* - 发送上行消息:session.update / session.promptResponse
|
||||||
|
* - 自动重连:连接断开后自动尝试重连(指数退避策略)
|
||||||
|
* - 心跳保活:定期发送 WebSocket 原生 ping 帧,防止服务端因空闲超时断开连接
|
||||||
|
* - 消息去重:通过 msg_id 实现幂等处理,避免重复消息被处理两次
|
||||||
|
*/
|
||||||
|
export class WechatAccessWebSocketClient {
|
||||||
|
private config: Required<Omit<WebSocketClientConfig, "token" | "gatewayPort">> & { token?: string; gatewayPort?: string };
|
||||||
|
private callbacks: WebSocketClientCallbacks;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ws 库的 WebSocket 实例。
|
||||||
|
* 类型写作 `WebSocket.WebSocket` 是因为 ws 库的默认导出是类本身,
|
||||||
|
* 而 `WebSocket.WebSocket` 是其实例类型(TypeScript 类型系统的要求)。
|
||||||
|
* 未连接时为 null。
|
||||||
|
*/
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
|
||||||
|
/** 当前连接状态 */
|
||||||
|
private state: ConnectionState = "disconnected";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重连定时器句柄。
|
||||||
|
* `ReturnType<typeof setTimeout>` 是 TypeScript 推荐的写法,
|
||||||
|
* 可以同时兼容 Node.js(返回 Timeout 对象)和浏览器(返回 number)环境。
|
||||||
|
*/
|
||||||
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 心跳定时器句柄。
|
||||||
|
* `ReturnType<typeof setInterval>` 同上,兼容 Node.js 和浏览器。
|
||||||
|
*/
|
||||||
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
/** 当前已尝试的重连次数 */
|
||||||
|
private reconnectAttempts = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已处理的消息 ID 集合(用于去重)。
|
||||||
|
* 使用 Set 而非数组,查找时间复杂度为 O(1)。
|
||||||
|
* 当消息因网络问题被重发时,通过检查 msg_id 是否已存在来避免重复处理。
|
||||||
|
*/
|
||||||
|
private processedMsgIds = new Set<string>();
|
||||||
|
|
||||||
|
/** 消息 ID 缓存定期清理定时器(防止 Set 无限增长导致内存泄漏) */
|
||||||
|
private msgIdCleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
/** 上次收到 pong 的时间戳(用于检测连接假死) */
|
||||||
|
private lastPongTime = Date.now();
|
||||||
|
|
||||||
|
/** 系统唤醒检测定时器 */
|
||||||
|
private wakeupCheckTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
/** 唤醒检测:上次 tick 的时间戳 */
|
||||||
|
private lastTickTime = Date.now();
|
||||||
|
|
||||||
|
/** 消息 ID 缓存的最大容量,超过此值时触发清理 */
|
||||||
|
private static readonly MAX_MSG_ID_CACHE = 1000;
|
||||||
|
|
||||||
|
/** 从 config.url 中解析出端口号,用于日志前缀 */
|
||||||
|
private get port(): string {
|
||||||
|
return this.config.gatewayPort ?? 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 带端口号的日志前缀 */
|
||||||
|
private get logPrefix(): string {
|
||||||
|
return `[wechat-access-ws:${this.port}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(config: WebSocketClientConfig, callbacks: WebSocketClientCallbacks = {}) {
|
||||||
|
this.config = {
|
||||||
|
url: config.url,
|
||||||
|
guid: config.guid ?? '',
|
||||||
|
userId: config.userId ?? '',
|
||||||
|
token: config.token,
|
||||||
|
gatewayPort: config.gatewayPort,
|
||||||
|
reconnectInterval: config.reconnectInterval ?? 3000,
|
||||||
|
maxReconnectAttempts: config.maxReconnectAttempts ?? 0,
|
||||||
|
// 默认 20s发一次心跳,小于服务端 1 分钟的空闲超时时间
|
||||||
|
heartbeatInterval: config.heartbeatInterval ?? 20000,
|
||||||
|
};
|
||||||
|
this.callbacks = callbacks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动 WebSocket 连接
|
||||||
|
* @description
|
||||||
|
* 如果当前已连接或正在连接中,则直接返回,避免重复建立连接。
|
||||||
|
* 同时启动消息 ID 缓存的定期清理任务。
|
||||||
|
*/
|
||||||
|
start = (): void => {
|
||||||
|
if (this.state === "connected" || this.state === "connecting") {
|
||||||
|
console.log(`${this.logPrefix} 已连接或正在连接,跳过`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.connect();
|
||||||
|
this.startMsgIdCleanup();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止 WebSocket 连接
|
||||||
|
* @description
|
||||||
|
* 主动断开连接时调用。会:
|
||||||
|
* 1. 将状态设为 "disconnected"(阻止断开后触发自动重连)
|
||||||
|
* 2. 清理所有定时器(重连、心跳、消息 ID 清理)
|
||||||
|
* 3. 清空消息 ID 缓存
|
||||||
|
* 4. 关闭 WebSocket 连接
|
||||||
|
*/
|
||||||
|
stop = (): void => {
|
||||||
|
console.log(`${this.logPrefix} 正在停止...`);
|
||||||
|
this.state = "disconnected";
|
||||||
|
this.clearReconnectTimer();
|
||||||
|
this.clearHeartbeat();
|
||||||
|
this.clearWakeupDetection();
|
||||||
|
this.clearMsgIdCleanup();
|
||||||
|
this.processedMsgIds.clear();
|
||||||
|
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
console.log(`${this.logPrefix} 已停止`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前连接状态
|
||||||
|
* @returns "disconnected" | "connecting" | "connected" | "reconnecting"
|
||||||
|
*/
|
||||||
|
getState = (): ConnectionState => this.state;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新事件回调
|
||||||
|
* @description 使用对象展开合并,只更新传入的回调,保留未传入的原有回调
|
||||||
|
*/
|
||||||
|
setCallbacks = (callbacks: Partial<WebSocketClientCallbacks>): void => {
|
||||||
|
this.callbacks = { ...this.callbacks, ...callbacks };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送 session.update 消息 — 流式中间更新(文本块)
|
||||||
|
* @param sessionId - 所属 Session ID
|
||||||
|
* @param promptId - 所属 Turn ID
|
||||||
|
* @param content - 文本内容块(type: "text")
|
||||||
|
* @description
|
||||||
|
* 在 Agent 生成回复的过程中,将增量文本实时推送给服务端,
|
||||||
|
* 服务端再转发给用户端展示流式输出效果。
|
||||||
|
*/
|
||||||
|
sendMessageChunk = (sessionId: string, promptId: string, content: ContentBlock, guid?: string, userId?: string): void => {
|
||||||
|
console.log(`${this.logPrefix} [sendMessageChunk] sessionId=${sessionId}, promptId=${promptId}, guid=${guid}, userId=${userId}, content=${JSON.stringify(content).substring(0, 200)}`);
|
||||||
|
const payload: UpdatePayload = {
|
||||||
|
session_id: sessionId,
|
||||||
|
prompt_id: promptId,
|
||||||
|
update_type: "message_chunk",
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
this.sendEnvelope("session.update", payload, guid, userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送 session.update 消息 — 工具调用开始
|
||||||
|
* @param sessionId - 所属 Session ID
|
||||||
|
* @param promptId - 所属 Turn ID
|
||||||
|
* @param toolCall - 工具调用信息(包含 tool_call_id、title、kind、status)
|
||||||
|
* @description
|
||||||
|
* 当 Agent 开始调用某个工具时发送,通知服务端展示工具调用状态。
|
||||||
|
*/
|
||||||
|
sendToolCall = (sessionId: string, promptId: string, toolCall: ToolCall, guid?: string, userId?: string): void => {
|
||||||
|
console.log(`${this.logPrefix} [sendToolCall] sessionId=${sessionId}, promptId=${promptId}, guid=${guid}, userId=${userId}, toolCall=${JSON.stringify(toolCall)}`);
|
||||||
|
const payload: UpdatePayload = {
|
||||||
|
session_id: sessionId,
|
||||||
|
prompt_id: promptId,
|
||||||
|
update_type: "tool_call",
|
||||||
|
tool_call: toolCall,
|
||||||
|
};
|
||||||
|
this.sendEnvelope("session.update", payload, guid, userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送 session.update 消息 — 工具调用状态变更
|
||||||
|
* @param sessionId - 所属 Session ID
|
||||||
|
* @param promptId - 所属 Turn ID
|
||||||
|
* @param toolCall - 更新后的工具调用信息(status 变为 completed/failed)
|
||||||
|
* @description
|
||||||
|
* 当工具执行完成或失败时发送,通知服务端更新工具调用的展示状态。
|
||||||
|
*/
|
||||||
|
sendToolCallUpdate = (sessionId: string, promptId: string, toolCall: ToolCall, guid?: string, userId?: string): void => {
|
||||||
|
console.log(`${this.logPrefix} [sendToolCallUpdate] sessionId=${sessionId}, promptId=${promptId}, guid=${guid}, userId=${userId}, toolCall=${JSON.stringify(toolCall)}`);
|
||||||
|
const payload: UpdatePayload = {
|
||||||
|
session_id: sessionId,
|
||||||
|
prompt_id: promptId,
|
||||||
|
update_type: "tool_call_update",
|
||||||
|
tool_call: toolCall,
|
||||||
|
};
|
||||||
|
this.sendEnvelope("session.update", payload, guid, userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送 session.promptResponse 消息 — 最终结果
|
||||||
|
* @param payload - 包含 stop_reason、content、error 等最终结果信息
|
||||||
|
* @description
|
||||||
|
* Agent 处理完成后发送,告知服务端本次 Turn 已结束。
|
||||||
|
* stop_reason 可以是:end_turn(正常完成)、cancelled(被取消)、error(出错)
|
||||||
|
*/
|
||||||
|
sendPromptResponse = (payload: PromptResponsePayload, guid?: string, userId?: string): void => {
|
||||||
|
const contentPreview = payload.content ? JSON.stringify(payload.content).substring(0, 200) : '(empty)';
|
||||||
|
console.log(`${this.logPrefix} [sendPromptResponse] sessionId=${payload.session_id}, promptId=${payload.prompt_id}, stopReason=${payload.stop_reason}, guid=${guid}, userId=${userId}, content=${contentPreview}`);
|
||||||
|
this.sendEnvelope("session.promptResponse", payload, guid, userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立 WebSocket 连接
|
||||||
|
* @description
|
||||||
|
* 使用 ws 库的 `new WebSocket(url)` 创建连接。
|
||||||
|
* ws 库会在内部自动完成 TCP 握手和 WebSocket 升级协议(HTTP Upgrade)。
|
||||||
|
* 连接是异步建立的,实际连接成功会触发 "open" 事件。
|
||||||
|
*/
|
||||||
|
private connect = (): void => {
|
||||||
|
// url 为空时不进行连接,避免 new URL("") 抛出 TypeError
|
||||||
|
if (!this.config.url) {
|
||||||
|
console.error(`${this.logPrefix} wsUrl 未配置,跳过连接`);
|
||||||
|
this.state = "disconnected";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// token 为空时不进行连接,避免无效请求
|
||||||
|
if (!this.config.token) {
|
||||||
|
console.error(`${this.logPrefix} token 为空,跳过 WebSocket 连接`);
|
||||||
|
this.state = "disconnected";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = "connecting";
|
||||||
|
console.error(`${this.logPrefix} 连接配置: url=${this.config.url}, token=${this.config.token.substring(0, 6) + '...'}, guid=${this.config.guid}, userId=${this.config.userId}`);
|
||||||
|
const wsUrl = this.buildConnectionUrl();
|
||||||
|
console.error(`${this.logPrefix} 正在连接: ${wsUrl}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// new WebSocket(url) 立即返回,不会阻塞
|
||||||
|
// 连接过程在后台异步进行,通过事件通知结果
|
||||||
|
this.ws = new WebSocket(wsUrl);
|
||||||
|
this.setupEventHandlers();
|
||||||
|
} catch (error) {
|
||||||
|
// 同步错误(如 URL 格式非法)会在这里捕获
|
||||||
|
// 异步连接失败(如服务端拒绝)会触发 "error" 事件
|
||||||
|
console.error(`${this.logPrefix} 创建连接失败:`, error);
|
||||||
|
this.handleConnectionError(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 WebSocket 连接 URL
|
||||||
|
* @description
|
||||||
|
* 使用 Node.js 内置的 `URL` 类(全局可用,无需 import)构建带查询参数的 URL。
|
||||||
|
* `url.searchParams.set()` 会自动对参数值进行 URL 编码(encodeURIComponent),
|
||||||
|
* 避免特殊字符导致的 URL 解析问题。
|
||||||
|
*
|
||||||
|
* 最终格式:ws://host:port/?token={token}
|
||||||
|
*/
|
||||||
|
private buildConnectionUrl = (): string => {
|
||||||
|
const url = new URL(this.config.url);
|
||||||
|
if (this.config.token) {
|
||||||
|
url.searchParams.set("token", this.config.token);
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册 ws 库的事件监听器
|
||||||
|
* @description
|
||||||
|
* ws 库使用 Node.js EventEmitter 风格的 `.on(event, handler)` 注册事件,
|
||||||
|
* 而非浏览器的 `.addEventListener(event, handler)`。
|
||||||
|
* 两者功能相同,但回调参数类型不同:
|
||||||
|
*
|
||||||
|
* | 事件 | 浏览器原生参数 | ws 库参数 |
|
||||||
|
* |---------|----------------------|----------------------------------|
|
||||||
|
* | open | Event | 无参数 |
|
||||||
|
* | message | MessageEvent | (data: RawData, isBinary: bool) |
|
||||||
|
* | close | CloseEvent | (code: number, reason: Buffer) |
|
||||||
|
* | error | Event | (error: Error) |
|
||||||
|
* | pong | 不支持 | 无参数(ws 库特有) |
|
||||||
|
*/
|
||||||
|
private setupEventHandlers = (): void => {
|
||||||
|
if (!this.ws) return;
|
||||||
|
|
||||||
|
this.ws.on("open", this.handleOpen);
|
||||||
|
this.ws.on("message", this.handleRawMessage);
|
||||||
|
this.ws.on("close", this.handleClose);
|
||||||
|
this.ws.on("error", this.handleError);
|
||||||
|
// "pong" 是 ws 库特有的事件,当收到服务端的 pong 控制帧时触发
|
||||||
|
// 浏览器原生 WebSocket API 不暴露此事件
|
||||||
|
this.ws.on("pong", this.handlePong);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 事件处理
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理连接建立事件
|
||||||
|
* @description
|
||||||
|
* ws 库的 "open" 事件在 WebSocket 握手完成后触发,此时可以开始收发消息。
|
||||||
|
* 连接成功后:
|
||||||
|
* 1. 更新状态为 "connected"
|
||||||
|
* 2. 重置重连计数器
|
||||||
|
* 3. 重置 pong 时间戳
|
||||||
|
* 4. 启动心跳定时器
|
||||||
|
* 5. 启动系统唤醒检测
|
||||||
|
* 6. 触发 onConnected 回调
|
||||||
|
*/
|
||||||
|
private handleOpen = (): void => {
|
||||||
|
console.log(`${this.logPrefix} 连接成功`);
|
||||||
|
this.state = "connected";
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.lastPongTime = Date.now();
|
||||||
|
this.startHeartbeat();
|
||||||
|
this.startWakeupDetection();
|
||||||
|
this.callbacks.onConnected?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理收到的原始消息
|
||||||
|
* @param data - ws 库的原始消息数据,类型为 `WebSocket.RawData`
|
||||||
|
* @description
|
||||||
|
* `WebSocket.RawData` 是 ws 库定义的联合类型:`Buffer | ArrayBuffer | Buffer[]`
|
||||||
|
* - 文本消息(text frame):通常是 Buffer 类型
|
||||||
|
* - 二进制消息(binary frame):可能是 Buffer 或 ArrayBuffer
|
||||||
|
*
|
||||||
|
* 处理步骤:
|
||||||
|
* 1. 将 RawData 转为字符串(Buffer.toString() 默认使用 UTF-8 编码)
|
||||||
|
* 2. JSON.parse 解析为 AGPEnvelope 对象
|
||||||
|
* 3. 检查 msg_id 去重
|
||||||
|
* 4. 根据 method 字段分发到对应的回调
|
||||||
|
*/
|
||||||
|
private handleRawMessage = (data: WebSocket.RawData): void => {
|
||||||
|
try {
|
||||||
|
// Buffer.toString() 默认 UTF-8 编码,等同于 data.toString("utf8")
|
||||||
|
// 如果 data 已经是 string 类型(理论上 ws 库不会这样,但做兼容处理)
|
||||||
|
const raw = typeof data === "string" ? data : data.toString();
|
||||||
|
const envelope = JSON.parse(raw) as AGPEnvelope;
|
||||||
|
|
||||||
|
// 消息去重:同一个 msg_id 只处理一次
|
||||||
|
// 网络不稳定时服务端可能重发消息,通过 msg_id 避免重复处理
|
||||||
|
if (this.processedMsgIds.has(envelope.msg_id)) {
|
||||||
|
console.log(`${this.logPrefix} 重复消息,跳过: ${envelope.msg_id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.processedMsgIds.add(envelope.msg_id);
|
||||||
|
|
||||||
|
console.log(`${this.logPrefix} 收到消息: method=${envelope.method}, msg_id=${envelope.msg_id}`);
|
||||||
|
|
||||||
|
// 根据 method 字段分发消息到对应的业务处理回调
|
||||||
|
switch (envelope.method) {
|
||||||
|
case "session.prompt":
|
||||||
|
// 下行:服务端下发用户指令,需要调用 Agent 处理
|
||||||
|
this.callbacks.onPrompt?.(envelope as PromptMessage);
|
||||||
|
break;
|
||||||
|
case "session.cancel":
|
||||||
|
// 下行:服务端要求取消正在处理的 Turn
|
||||||
|
this.callbacks.onCancel?.(envelope as CancelMessage);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn(`${this.logPrefix} 未知消息类型: ${envelope.method}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${this.logPrefix} 消息解析失败:`, error, '原始数据:', data);
|
||||||
|
this.callbacks.onError?.(
|
||||||
|
error instanceof Error ? error : new Error(`消息解析失败: ${String(error)}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理连接关闭事件
|
||||||
|
* @param code - WebSocket 关闭状态码(RFC 6455 定义)
|
||||||
|
* 常见值:
|
||||||
|
* - 1000: 正常关闭
|
||||||
|
* - 1001: 端点离开(如服务端重启)
|
||||||
|
* - 1006: 异常关闭(连接被强制断开,无关闭握手)
|
||||||
|
* - 1008: 策略违规(如 token 不匹配)
|
||||||
|
* @param reason - 关闭原因,ws 库中类型为 `Buffer`,需要调用 `.toString()` 转为字符串
|
||||||
|
* @description
|
||||||
|
* 注意:ws 库的 close 事件参数与浏览器不同:
|
||||||
|
* - 浏览器:`(event: CloseEvent)` → 通过 event.code 和 event.reason 获取
|
||||||
|
* - ws 库:`(code: number, reason: Buffer)` → 直接获取,reason 是 Buffer 需要转换
|
||||||
|
*
|
||||||
|
* 只有在非主动关闭(state !== "disconnected")时才触发重连,
|
||||||
|
* 避免调用 stop() 后又自动重连。
|
||||||
|
*/
|
||||||
|
private handleClose = (code: number, reason: Buffer): void => {
|
||||||
|
// Buffer.toString() 将 Buffer 转为 UTF-8 字符串
|
||||||
|
// 如果 reason 为空 Buffer,toString() 返回空字符串,此时用 code 作为描述
|
||||||
|
const reasonStr = reason.toString() || `code=${code}`;
|
||||||
|
console.log(`${this.logPrefix} 连接关闭: ${reasonStr}`);
|
||||||
|
this.clearHeartbeat();
|
||||||
|
this.clearWakeupDetection();
|
||||||
|
this.ws = null;
|
||||||
|
|
||||||
|
// 仅在非主动关闭的情况下尝试重连
|
||||||
|
// 主动调用 stop() 时会先将 state 设为 "disconnected",此处就不会触发重连
|
||||||
|
if (this.state !== "disconnected") {
|
||||||
|
this.callbacks.onDisconnected?.(reasonStr);
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 pong 控制帧
|
||||||
|
* @description
|
||||||
|
* 当服务端收到我们发送的 ping 帧后,会自动回复一个 pong 帧。
|
||||||
|
* ws 库会触发 "pong" 事件通知我们。
|
||||||
|
* 记录收到 pong 的时间戳,供心跳定时器检测连接是否假死。
|
||||||
|
* 如果长时间未收到 pong,说明连接已不可用(如电脑休眠导致 TCP 断开)。
|
||||||
|
*/
|
||||||
|
private handlePong = (): void => {
|
||||||
|
this.lastPongTime = Date.now();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理连接错误事件
|
||||||
|
* @param error - ws 库直接传递 Error 对象(浏览器原生 API 传递的是 Event 对象)
|
||||||
|
* @description
|
||||||
|
* ws 库的 "error" 事件在以下情况触发:
|
||||||
|
* - 连接被拒绝(如服务端不可达)
|
||||||
|
* - TLS 握手失败
|
||||||
|
* - 消息发送失败
|
||||||
|
* 注意:error 事件之后通常会紧跟 close 事件,重连逻辑在 handleClose 中处理。
|
||||||
|
*/
|
||||||
|
private handleError = (error: Error): void => {
|
||||||
|
console.error(`${this.logPrefix} 连接错误:`, error);
|
||||||
|
this.callbacks.onError?.(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理连接创建时的同步错误
|
||||||
|
* @description
|
||||||
|
* 当 `new WebSocket(url)` 抛出同步异常时调用(如 URL 格式非法)。
|
||||||
|
* 此时不会触发 "error" 和 "close" 事件,需要手动触发重连。
|
||||||
|
*/
|
||||||
|
private handleConnectionError = (error: Error): void => {
|
||||||
|
this.callbacks.onError?.(error);
|
||||||
|
this.scheduleReconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安排下一次重连
|
||||||
|
* @description
|
||||||
|
* 使用指数退避(Exponential Backoff)策略计算重连延迟:
|
||||||
|
* delay = min(reconnectInterval × 1.5^(attempts-1), 30000)
|
||||||
|
*
|
||||||
|
* 例如 reconnectInterval=3000 时:
|
||||||
|
* 第 1 次:3000ms
|
||||||
|
* 第 2 次:4500ms
|
||||||
|
* 第 3 次:6750ms
|
||||||
|
* 第 4 次:10125ms
|
||||||
|
* 第 5 次:15187ms(之后趋近 30000ms 上限)
|
||||||
|
*
|
||||||
|
* 指数退避的目的:避免服务端故障时大量客户端同时重连造成雪崩效应。
|
||||||
|
*
|
||||||
|
* `setTimeout` 是 Node.js 全局函数,在指定延迟后执行一次回调。
|
||||||
|
* 返回值是 Timeout 对象(Node.js)或 number(浏览器),
|
||||||
|
* 需要保存以便后续调用 clearTimeout 取消。
|
||||||
|
*/
|
||||||
|
private scheduleReconnect = (): void => {
|
||||||
|
// 检查是否超过最大重连次数(0 表示无限重连)
|
||||||
|
if (
|
||||||
|
this.config.maxReconnectAttempts > 0 &&
|
||||||
|
this.reconnectAttempts >= this.config.maxReconnectAttempts
|
||||||
|
) {
|
||||||
|
console.error(`${this.logPrefix} 已达最大重连次数 (${this.config.maxReconnectAttempts}),停止重连`);
|
||||||
|
this.state = "disconnected";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = "reconnecting";
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
|
||||||
|
// 指数退避:每次重连等待时间递增,最大 25 秒
|
||||||
|
const delay = Math.min(
|
||||||
|
this.config.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1),
|
||||||
|
25000
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`${this.logPrefix} ${delay}ms 后尝试第 ${this.reconnectAttempts} 次重连...`);
|
||||||
|
|
||||||
|
// setTimeout 返回的句柄保存到 reconnectTimer,
|
||||||
|
// 以便在 stop() 或成功连接时通过 clearTimeout 取消待执行的重连
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
this.connect();
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除重连定时器
|
||||||
|
* @description
|
||||||
|
* `clearTimeout` 是 Node.js 全局函数,取消由 setTimeout 创建的定时器。
|
||||||
|
* 如果定时器已执行或已被取消,调用 clearTimeout 不会报错(安全操作)。
|
||||||
|
*/
|
||||||
|
private clearReconnectTimer = (): void => {
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 心跳保活
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动心跳定时器
|
||||||
|
* @description
|
||||||
|
* 使用 `setInterval` 定期发送 WebSocket ping 控制帧,并检测 pong 超时。
|
||||||
|
*
|
||||||
|
* `ws.ping()` 发送 WebSocket 协议层的 ping 控制帧(opcode=0x9),
|
||||||
|
* 服务端必须自动回复 pong 帧。
|
||||||
|
*
|
||||||
|
* Pong 超时检测:
|
||||||
|
* 如果超过 2 倍心跳间隔仍未收到 pong,判定连接已死(如休眠后 TCP 已断),
|
||||||
|
* 主动 terminate 触发 close 事件 → 自动重连。
|
||||||
|
*
|
||||||
|
* Ping 失败处理:
|
||||||
|
* 如果 ping 发送抛异常(底层 socket 已关闭),也主动 terminate 触发重连。
|
||||||
|
*/
|
||||||
|
private startHeartbeat = (): void => {
|
||||||
|
this.clearHeartbeat();
|
||||||
|
this.heartbeatTimer = setInterval(() => {
|
||||||
|
if (this.ws && this.state === "connected") {
|
||||||
|
// 检测 pong 超时:超过 2 倍心跳间隔未收到 pong,判定连接已死
|
||||||
|
const pongTimeout = this.config.heartbeatInterval * 2;
|
||||||
|
if (Date.now() - this.lastPongTime > pongTimeout) {
|
||||||
|
console.warn(`${this.logPrefix} pong 超时 (${pongTimeout}ms 未收到),判定连接已死,主动断开`);
|
||||||
|
this.ws.terminate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ws.ping() 发送 WebSocket 原生 ping 控制帧
|
||||||
|
this.ws.ping();
|
||||||
|
} catch {
|
||||||
|
console.warn(`${this.logPrefix} 心跳发送失败,主动断开触发重连`);
|
||||||
|
this.ws?.terminate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, this.config.heartbeatInterval);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除心跳定时器
|
||||||
|
* @description
|
||||||
|
* `clearInterval` 是 Node.js 全局函数,停止由 setInterval 创建的定时器。
|
||||||
|
* 在连接关闭或主动停止时调用,避免向已断开的连接发送 ping。
|
||||||
|
*/
|
||||||
|
private clearHeartbeat = (): void => {
|
||||||
|
if (this.heartbeatTimer) {
|
||||||
|
clearInterval(this.heartbeatTimer);
|
||||||
|
this.heartbeatTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 系统唤醒检测
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动系统唤醒检测
|
||||||
|
* @description
|
||||||
|
* 电脑休眠时 setInterval 会被冻结,唤醒后恢复。
|
||||||
|
* 利用「两次 tick 之间实际经过的时间」远大于「setInterval 设定的间隔」来检测唤醒事件。
|
||||||
|
*
|
||||||
|
* 例如:CHECK_INTERVAL = 5s,但实际两次 tick 间隔了 60s → 说明系统休眠了约 55s。
|
||||||
|
* 此时 TCP 连接大概率已被服务端超时关闭,需要主动 terminate 触发重连。
|
||||||
|
*
|
||||||
|
* 同时重置重连计数器,确保唤醒后有足够的重连机会。
|
||||||
|
*/
|
||||||
|
private startWakeupDetection = (): void => {
|
||||||
|
this.clearWakeupDetection();
|
||||||
|
this.lastTickTime = Date.now();
|
||||||
|
|
||||||
|
const CHECK_INTERVAL = 5000; // 每 5 秒检查一次
|
||||||
|
const WAKEUP_THRESHOLD = 15000; // 实际间隔超过 15 秒视为休眠唤醒
|
||||||
|
|
||||||
|
this.wakeupCheckTimer = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsed = now - this.lastTickTime;
|
||||||
|
this.lastTickTime = now;
|
||||||
|
|
||||||
|
if (elapsed > WAKEUP_THRESHOLD) {
|
||||||
|
console.warn(`${this.logPrefix} 检测到系统唤醒 (tick 间隔 ${elapsed}ms,阈值 ${WAKEUP_THRESHOLD}ms)`);
|
||||||
|
// 重置重连计数器,给予唤醒后充足的重连机会
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
// 如果当前连接还标记为已连接,主动断开触发重连
|
||||||
|
if (this.ws && this.state === "connected") {
|
||||||
|
console.warn(`${this.logPrefix} 唤醒后主动断开连接,触发重连`);
|
||||||
|
this.ws.terminate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, CHECK_INTERVAL);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除系统唤醒检测定时器
|
||||||
|
*/
|
||||||
|
private clearWakeupDetection = (): void => {
|
||||||
|
if (this.wakeupCheckTimer) {
|
||||||
|
clearInterval(this.wakeupCheckTimer);
|
||||||
|
this.wakeupCheckTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 消息发送
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送 AGP 信封消息(内部通用方法)
|
||||||
|
* @param method - AGP 消息类型(如 "session.update"、"session.promptResponse")
|
||||||
|
* @param payload - 消息载荷,泛型 T 由调用方决定具体类型
|
||||||
|
* @description
|
||||||
|
* 所有上行消息都通过此方法发送,统一处理:
|
||||||
|
* 1. 检查连接状态
|
||||||
|
* 2. 构建 AGP 信封(添加 msg_id等公共字段)
|
||||||
|
* 3. JSON 序列化
|
||||||
|
* 4. 调用 ws.send() 发送文本帧
|
||||||
|
*
|
||||||
|
* `ws.send(data)` 是 ws 库的发送方法:
|
||||||
|
* - 传入 string:发送文本帧(opcode=0x1)
|
||||||
|
* - 传入 Buffer/ArrayBuffer:发送二进制帧(opcode=0x2)
|
||||||
|
* - 这里传入 JSON 字符串,发送文本帧
|
||||||
|
*
|
||||||
|
* `randomUUID()` 为每条消息生成唯一 ID,服务端可用于去重和追踪。
|
||||||
|
*/
|
||||||
|
private sendEnvelope = <T>(method: AGPMethod, payload: T, guid?: string, userId?: string): void => {
|
||||||
|
if (!this.ws || this.state !== "connected") {
|
||||||
|
console.warn(`${this.logPrefix} 无法发送消息,当前状态: ${this.state}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope: AGPEnvelope<T> = {
|
||||||
|
msg_id: randomUUID(),
|
||||||
|
guid: guid ?? this.config.guid,
|
||||||
|
user_id: userId ?? this.config.userId,
|
||||||
|
method,
|
||||||
|
payload,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.stringify(envelope);
|
||||||
|
// ws.send() 将字符串作为 WebSocket 文本帧发送
|
||||||
|
this.ws.send(data);
|
||||||
|
// 截断过长的 JSON 日志,避免日志文件膨胀
|
||||||
|
const jsonPreview = data.length > 500 ? data.substring(0, 500) + `...(truncated, total ${data.length} chars)` : data;
|
||||||
|
console.log(`${this.logPrefix} 发送消息: method=${method}, msg_id=${envelope.msg_id}, json=${jsonPreview}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${this.logPrefix} 消息发送失败:`, error);
|
||||||
|
this.callbacks.onError?.(
|
||||||
|
error instanceof Error ? error : new Error(`消息发送失败: ${String(error)}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 消息 ID 缓存清理
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动消息 ID 缓存定期清理任务
|
||||||
|
* @description
|
||||||
|
* `processedMsgIds` 是一个 Set,会随着消息的接收不断增长。
|
||||||
|
* 如果不清理,长时间运行后会占用大量内存(内存泄漏)。
|
||||||
|
*
|
||||||
|
* 清理策略:
|
||||||
|
* - 每 5 分钟检查一次
|
||||||
|
* - 当 Set 大小超过 MAX_MSG_ID_CACHE(1000)时触发清理
|
||||||
|
* - 清理时保留最新的一半(500 条),丢弃最旧的一半
|
||||||
|
*
|
||||||
|
* 为什么保留最新的一半而不是全部清空?
|
||||||
|
* 因为刚处理过的消息 ID 最有可能被重发,保留它们可以继续防重。
|
||||||
|
*
|
||||||
|
* `[...this.processedMsgIds]` 将 Set 转为数组,
|
||||||
|
* Set 的迭代顺序是插入顺序,所以 slice(-500) 取的是最后插入的 500 条(最新的)。
|
||||||
|
*/
|
||||||
|
private startMsgIdCleanup = (): void => {
|
||||||
|
this.clearMsgIdCleanup();
|
||||||
|
this.msgIdCleanupTimer = setInterval(() => {
|
||||||
|
if (this.processedMsgIds.size > WechatAccessWebSocketClient.MAX_MSG_ID_CACHE) {
|
||||||
|
console.log(`${this.logPrefix} 清理消息 ID 缓存: ${this.processedMsgIds.size} → ${WechatAccessWebSocketClient.MAX_MSG_ID_CACHE / 2}`);
|
||||||
|
// 将 Set 转为数组(保持插入顺序),取后半部分(最新的),重建 Set
|
||||||
|
const entries = [...this.processedMsgIds];
|
||||||
|
this.processedMsgIds.clear();
|
||||||
|
entries.slice(-WechatAccessWebSocketClient.MAX_MSG_ID_CACHE / 2).forEach((id) => {
|
||||||
|
this.processedMsgIds.add(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 5 * 60 * 1000); // 每 5 分钟执行一次
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除消息 ID 缓存清理定时器
|
||||||
|
*/
|
||||||
|
private clearMsgIdCleanup = (): void => {
|
||||||
|
if (this.msgIdCleanupTimer) {
|
||||||
|
clearInterval(this.msgIdCleanupTimer);
|
||||||
|
this.msgIdCleanupTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user