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

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(),
});
}
};