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

49
common/agent-events.ts Normal file
View File

@@ -0,0 +1,49 @@
import type { onAgentEvent as OnAgentEventType } from "openclaw/plugin-sdk";
export type AgentEventStream = "lifecycle" | "tool" | "assistant" | "error" | (string & {});
export type AgentEventPayload = {
runId: string;
seq: number;
stream: AgentEventStream;
ts: number;
data: Record<string, unknown>;
sessionKey?: string;
};
// 动态导入,兼容 openclaw 未导出该函数的情况
let _onAgentEvent: typeof OnAgentEventType | undefined;
// SDK 加载完成的 Promise确保只加载一次
const sdkReady: Promise<typeof OnAgentEventType | undefined> = (async () => {
try {
const sdk = await import("openclaw/plugin-sdk");
if (typeof sdk.onAgentEvent === "function") {
_onAgentEvent = sdk.onAgentEvent;
}
} catch {
// ignore
}
return _onAgentEvent;
})();
/**
* 注册 Agent 事件监听器。
*
* 修复了原版的时序问题:原版使用 loadOnAgentEvent().then() 异步注册,
* 导致在 dispatchReplyWithBufferedBlockDispatcher 调用之前注册的监听器
* 实际上在 Agent 开始产生事件时还未真正挂载,造成事件全部丢失。
*
* 新版通过 await sdkReady 确保 SDK 加载完成后再注册监听器,
* 调用方需要 await 此函数返回的 Promise再调用 dispatchReply。
*/
export const onAgentEvent = async (
listener: Parameters<typeof OnAgentEventType>[0]
): Promise<() => boolean> => {
const fn = await sdkReady;
if (fn) {
const unsubscribe = fn(listener);
return unsubscribe;
}
return () => false;
};

174
common/message-context.ts Normal file
View File

@@ -0,0 +1,174 @@
import type { FuwuhaoMessage } from "../http/types.js";
import { getWecomRuntime } from "./runtime.js";
// ============================================
// 渠道来源标签
// ============================================
// 用于 ChannelSource标识消息来自哪个微信渠道
// UI 侧可通过此字段区分不同来源,做差异化展示或交互限制
export const WECHAT_CHANNEL_LABELS = {
/** 微信服务号 */
serviceAccount: "serviceAccount",
/** 微信小程序 */
miniProgram: "miniProgram",
} as const;
// ============================================
// 消息上下文构建
// ============================================
// 将微信服务号的原始消息转换为 OpenClaw 标准的消息上下文
// 包括路由解析、会话管理、消息格式化等核心功能
/**
* 消息上下文返回类型
* @property ctx - OpenClaw 标准的消息上下文对象,包含所有必要的消息元数据
* @property route - 路由信息,用于确定消息应该发送到哪个 Agent
* @property storePath - 会话存储路径,用于持久化会话数据
*/
export interface MessageContext {
ctx: Record<string, unknown>;
route: {
sessionKey: string; // 会话唯一标识,用于关联同一用户的多轮对话
agentId: string; // Agent ID标识处理此消息的 Agent
accountId: string; // 账号 ID用于多账号场景
};
storePath: string;
}
/**
* 构建消息上下文
* @param message - 微信服务号的原始消息对象
* @returns MessageContext 包含上下文、路由和存储路径的完整消息上下文
* @description
* 此函数是消息处理的核心,负责:
* 1. 提取和标准化消息字段(兼容多种格式)
* 2. 解析路由,确定消息应该发送到哪个 Agent
* 3. 获取会话存储路径,用于持久化对话历史
* 4. 格式化消息为 OpenClaw 标准格式
* 5. 构建完整的消息上下文对象
*
* 内部流程:
* - 从 runtime 获取配置
* - 提取用户 ID、消息 ID、内容等关键信息
* - 调用 routing.resolveAgentRoute 解析路由
* - 调用 session.resolveStorePath 获取存储路径
* - 调用 reply.formatInboundEnvelope 格式化消息
* - 调用 reply.finalizeInboundContext 构建最终上下文
*/
export const buildMessageContext = (message: FuwuhaoMessage): MessageContext => {
// 获取 OpenClaw 运行时实例
const runtime = getWecomRuntime();
// 加载全局配置(包含 Agent 配置、路由规则等)
const cfg = runtime.config.loadConfig();
// ============================================
// 1. 提取和标准化消息字段
// ============================================
// 兼容多种字段命名FromUserName/userid
const userId = message.FromUserName || message.userid || "unknown";
const toUser = message.ToUserName || "unknown";
// 确保消息 ID 唯一(用于去重和追踪)
const messageId = message.MsgId || message.msgid || `${Date.now()}`;
// TODO: 微信的 CreateTime 是秒级时间戳,需要转换为毫秒
// const timestamp = message.CreateTime ? message.CreateTime * 1000 : Date.now();
const timestamp = Date.now();
// 提取消息内容(兼容 Content 和 text.content 两种格式)
const content = message.Content || message.text?.content || "";
// ============================================
// 2. 解析路由 - 确定消息应该发送到哪个 Agent
// ============================================
// runtime.channel.routing.resolveAgentRoute 是 OpenClaw 的核心路由方法
// 根据频道、账号、对话类型等信息,决定使用哪个 Agent 处理消息
const frameworkRoute = runtime.channel.routing.resolveAgentRoute({
cfg, // 全局配置
channel: "wechat-access", // 频道标识
accountId: "default", // 账号 ID支持多账号场景
peer: {
kind: "dm", // 对话类型dm=私聊group=群聊
id: userId, // 对话对象 ID用户 ID
},
});
// 框架返回的 sessionKey 通常是 agent:main:main与 PC 端默认 session 相同。
// 为了让 UI 能区分外部渠道消息,使用独立的 sessionKey 格式:
// agent:{agentId}:wechat-access:direct:{userId}
const channelSessionKey = `agent:${frameworkRoute.agentId}:wechat-access:direct:${userId}`;
const route = {
...frameworkRoute,
sessionKey: channelSessionKey,
};
// ============================================
// 3. 获取消息格式化选项
// ============================================
// runtime.channel.reply.resolveEnvelopeFormatOptions 获取消息格式化配置
// 包括时间格式、前缀、后缀等显示选项
const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg);
// ============================================
// 4. 获取会话存储路径
// ============================================
// runtime.channel.session.resolveStorePath 计算会话数据的存储路径
// 用于持久化对话历史、上下文等信息
const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
// 存储路径通常类似:/data/sessions/{agentId}/{sessionKey}.json
// ============================================
// 5. 读取上次会话时间
// ============================================
// runtime.channel.session.readSessionUpdatedAt 读取上次会话的更新时间
// 用于判断会话是否过期,是否需要重置上下文
const previousTimestamp = runtime.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
// 如果距离上次会话时间过长,可能会清空历史上下文
// ============================================
// 6. 格式化入站消息
// ============================================
// runtime.channel.reply.formatInboundEnvelope 将原始消息格式化为标准格式
// 添加时间戳、发送者信息、格式化选项等
const body = runtime.channel.reply.formatInboundEnvelope({
channel: "wechat-access", // 频道标识
from: userId, // 发送者 ID
timestamp, // 消息时间戳
body: content, // 消息内容
chatType: "direct", // 对话类型direct=私聊)
sender: {
id: userId, // 发送者 ID
},
previousTimestamp, // 上次会话时间(用于判断是否需要添加时间分隔符)
envelope: envelopeOptions, // 格式化选项
});
// 返回格式化后的消息体,可能包含时间前缀、发送者名称等
// ============================================
// 7. 构建完整的消息上下文
// ============================================
// runtime.channel.reply.finalizeInboundContext 构建 OpenClaw 标准的消息上下文
// 这是 Agent 处理消息时使用的核心数据结构
const ctx = runtime.channel.reply.finalizeInboundContext({
Body: body, // 格式化后的消息体
RawBody: content, // 原始消息内容
CommandBody: content, // 命令体(用于解析命令)
From: `wechat-access:${userId}`, // 发送者标识(带频道前缀)
To: `wechat-access:${toUser}`, // 接收者标识
SessionKey: route.sessionKey, // 会话键
AccountId: route.accountId, // 账号 ID
ChatType: "direct" as const, // 对话类型
ChannelSource: WECHAT_CHANNEL_LABELS.serviceAccount, // 渠道来源标识(用于 UI 侧区分消息来源)
SenderId: userId, // 发送者 ID
Provider: "wechat-access", // 提供商标识
Surface: "wechat-access", // 界面标识
MessageSid: messageId, // 消息唯一标识
Timestamp: timestamp, // 时间戳
OriginatingChannel: "wechat-access" as const, // 原始频道
OriginatingTo: `wechat-access:${userId}`, // 原始接收者
});
// ctx 包含了 Agent 处理消息所需的所有信息
return { ctx, route, storePath };
};

35
common/runtime.ts Normal file
View File

@@ -0,0 +1,35 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
// ============================================
// Runtime 管理
// ============================================
// 用于存储和获取 OpenClaw 的运行时实例
// Runtime 提供了访问配置、会话、路由、事件等核心功能的接口
/**
* 全局运行时实例
* 在插件初始化时由 OpenClaw 框架注入
*/
let runtime: PluginRuntime | null = null;
/**
* 设置微信企业号运行时实例
* @param next - OpenClaw 提供的运行时实例
* @description 此方法应在插件初始化时调用一次,用于注入运行时依赖
*/
export const setWecomRuntime = (next: PluginRuntime): void => {
runtime = next;
};
/**
* 获取微信企业号运行时实例
* @returns OpenClaw 运行时实例
* @throws 如果运行时未初始化则抛出错误
* @description 在需要访问 OpenClaw 核心功能时调用此方法
*/
export const getWecomRuntime = (): PluginRuntime => {
if (!runtime) {
throw new Error("WeCom runtime not initialized");
}
return runtime;
};