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:
HenryXiaoYang
2026-03-10 02:29:06 +08:00
commit ba754ccc31
33 changed files with 14992 additions and 0 deletions

138
http/README.md Normal file
View 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
View 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
View 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 - 微信服务号配置的 EncodingAESKey43位字符
* @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
View 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
View 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
View 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
View 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
View 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
View 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&timestamp=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&timestamp=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&timestamp=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;
}
};