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:
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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user