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:
49
common/agent-events.ts
Normal file
49
common/agent-events.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { onAgentEvent as OnAgentEventType } from "openclaw/plugin-sdk";
|
||||
|
||||
export type AgentEventStream = "lifecycle" | "tool" | "assistant" | "error" | (string & {});
|
||||
|
||||
export type AgentEventPayload = {
|
||||
runId: string;
|
||||
seq: number;
|
||||
stream: AgentEventStream;
|
||||
ts: number;
|
||||
data: Record<string, unknown>;
|
||||
sessionKey?: string;
|
||||
};
|
||||
|
||||
// 动态导入,兼容 openclaw 未导出该函数的情况
|
||||
let _onAgentEvent: typeof OnAgentEventType | undefined;
|
||||
|
||||
// SDK 加载完成的 Promise,确保只加载一次
|
||||
const sdkReady: Promise<typeof OnAgentEventType | undefined> = (async () => {
|
||||
try {
|
||||
const sdk = await import("openclaw/plugin-sdk");
|
||||
if (typeof sdk.onAgentEvent === "function") {
|
||||
_onAgentEvent = sdk.onAgentEvent;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return _onAgentEvent;
|
||||
})();
|
||||
|
||||
/**
|
||||
* 注册 Agent 事件监听器。
|
||||
*
|
||||
* 修复了原版的时序问题:原版使用 loadOnAgentEvent().then() 异步注册,
|
||||
* 导致在 dispatchReplyWithBufferedBlockDispatcher 调用之前注册的监听器
|
||||
* 实际上在 Agent 开始产生事件时还未真正挂载,造成事件全部丢失。
|
||||
*
|
||||
* 新版通过 await sdkReady 确保 SDK 加载完成后再注册监听器,
|
||||
* 调用方需要 await 此函数返回的 Promise,再调用 dispatchReply。
|
||||
*/
|
||||
export const onAgentEvent = async (
|
||||
listener: Parameters<typeof OnAgentEventType>[0]
|
||||
): Promise<() => boolean> => {
|
||||
const fn = await sdkReady;
|
||||
if (fn) {
|
||||
const unsubscribe = fn(listener);
|
||||
return unsubscribe;
|
||||
}
|
||||
return () => false;
|
||||
};
|
||||
174
common/message-context.ts
Normal file
174
common/message-context.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import type { FuwuhaoMessage } from "../http/types.js";
|
||||
import { getWecomRuntime } from "./runtime.js";
|
||||
|
||||
// ============================================
|
||||
// 渠道来源标签
|
||||
// ============================================
|
||||
// 用于 ChannelSource,标识消息来自哪个微信渠道
|
||||
// UI 侧可通过此字段区分不同来源,做差异化展示或交互限制
|
||||
export const WECHAT_CHANNEL_LABELS = {
|
||||
/** 微信服务号 */
|
||||
serviceAccount: "serviceAccount",
|
||||
/** 微信小程序 */
|
||||
miniProgram: "miniProgram",
|
||||
} as const;
|
||||
|
||||
// ============================================
|
||||
// 消息上下文构建
|
||||
// ============================================
|
||||
// 将微信服务号的原始消息转换为 OpenClaw 标准的消息上下文
|
||||
// 包括路由解析、会话管理、消息格式化等核心功能
|
||||
|
||||
/**
|
||||
* 消息上下文返回类型
|
||||
* @property ctx - OpenClaw 标准的消息上下文对象,包含所有必要的消息元数据
|
||||
* @property route - 路由信息,用于确定消息应该发送到哪个 Agent
|
||||
* @property storePath - 会话存储路径,用于持久化会话数据
|
||||
*/
|
||||
export interface MessageContext {
|
||||
ctx: Record<string, unknown>;
|
||||
route: {
|
||||
sessionKey: string; // 会话唯一标识,用于关联同一用户的多轮对话
|
||||
agentId: string; // Agent ID,标识处理此消息的 Agent
|
||||
accountId: string; // 账号 ID,用于多账号场景
|
||||
};
|
||||
storePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建消息上下文
|
||||
* @param message - 微信服务号的原始消息对象
|
||||
* @returns MessageContext 包含上下文、路由和存储路径的完整消息上下文
|
||||
* @description
|
||||
* 此函数是消息处理的核心,负责:
|
||||
* 1. 提取和标准化消息字段(兼容多种格式)
|
||||
* 2. 解析路由,确定消息应该发送到哪个 Agent
|
||||
* 3. 获取会话存储路径,用于持久化对话历史
|
||||
* 4. 格式化消息为 OpenClaw 标准格式
|
||||
* 5. 构建完整的消息上下文对象
|
||||
*
|
||||
* 内部流程:
|
||||
* - 从 runtime 获取配置
|
||||
* - 提取用户 ID、消息 ID、内容等关键信息
|
||||
* - 调用 routing.resolveAgentRoute 解析路由
|
||||
* - 调用 session.resolveStorePath 获取存储路径
|
||||
* - 调用 reply.formatInboundEnvelope 格式化消息
|
||||
* - 调用 reply.finalizeInboundContext 构建最终上下文
|
||||
*/
|
||||
export const buildMessageContext = (message: FuwuhaoMessage): MessageContext => {
|
||||
// 获取 OpenClaw 运行时实例
|
||||
const runtime = getWecomRuntime();
|
||||
// 加载全局配置(包含 Agent 配置、路由规则等)
|
||||
const cfg = runtime.config.loadConfig();
|
||||
|
||||
// ============================================
|
||||
// 1. 提取和标准化消息字段
|
||||
// ============================================
|
||||
// 兼容多种字段命名(FromUserName/userid)
|
||||
const userId = message.FromUserName || message.userid || "unknown";
|
||||
const toUser = message.ToUserName || "unknown";
|
||||
// 确保消息 ID 唯一(用于去重和追踪)
|
||||
const messageId = message.MsgId || message.msgid || `${Date.now()}`;
|
||||
// TODO: 微信的 CreateTime 是秒级时间戳,需要转换为毫秒
|
||||
// const timestamp = message.CreateTime ? message.CreateTime * 1000 : Date.now();
|
||||
const timestamp = Date.now();
|
||||
// 提取消息内容(兼容 Content 和 text.content 两种格式)
|
||||
const content = message.Content || message.text?.content || "";
|
||||
|
||||
// ============================================
|
||||
// 2. 解析路由 - 确定消息应该发送到哪个 Agent
|
||||
// ============================================
|
||||
// runtime.channel.routing.resolveAgentRoute 是 OpenClaw 的核心路由方法
|
||||
// 根据频道、账号、对话类型等信息,决定使用哪个 Agent 处理消息
|
||||
const frameworkRoute = runtime.channel.routing.resolveAgentRoute({
|
||||
cfg, // 全局配置
|
||||
channel: "wechat-access", // 频道标识
|
||||
accountId: "default", // 账号 ID(支持多账号场景)
|
||||
peer: {
|
||||
kind: "dm", // 对话类型:dm=私聊,group=群聊
|
||||
id: userId, // 对话对象 ID(用户 ID)
|
||||
},
|
||||
});
|
||||
// 框架返回的 sessionKey 通常是 agent:main:main,与 PC 端默认 session 相同。
|
||||
// 为了让 UI 能区分外部渠道消息,使用独立的 sessionKey 格式:
|
||||
// agent:{agentId}:wechat-access:direct:{userId}
|
||||
const channelSessionKey = `agent:${frameworkRoute.agentId}:wechat-access:direct:${userId}`;
|
||||
const route = {
|
||||
...frameworkRoute,
|
||||
sessionKey: channelSessionKey,
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 3. 获取消息格式化选项
|
||||
// ============================================
|
||||
// runtime.channel.reply.resolveEnvelopeFormatOptions 获取消息格式化配置
|
||||
// 包括时间格式、前缀、后缀等显示选项
|
||||
const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
||||
|
||||
// ============================================
|
||||
// 4. 获取会话存储路径
|
||||
// ============================================
|
||||
// runtime.channel.session.resolveStorePath 计算会话数据的存储路径
|
||||
// 用于持久化对话历史、上下文等信息
|
||||
const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
// 存储路径通常类似:/data/sessions/{agentId}/{sessionKey}.json
|
||||
|
||||
// ============================================
|
||||
// 5. 读取上次会话时间
|
||||
// ============================================
|
||||
// runtime.channel.session.readSessionUpdatedAt 读取上次会话的更新时间
|
||||
// 用于判断会话是否过期,是否需要重置上下文
|
||||
const previousTimestamp = runtime.channel.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
// 如果距离上次会话时间过长,可能会清空历史上下文
|
||||
|
||||
// ============================================
|
||||
// 6. 格式化入站消息
|
||||
// ============================================
|
||||
// runtime.channel.reply.formatInboundEnvelope 将原始消息格式化为标准格式
|
||||
// 添加时间戳、发送者信息、格式化选项等
|
||||
const body = runtime.channel.reply.formatInboundEnvelope({
|
||||
channel: "wechat-access", // 频道标识
|
||||
from: userId, // 发送者 ID
|
||||
timestamp, // 消息时间戳
|
||||
body: content, // 消息内容
|
||||
chatType: "direct", // 对话类型(direct=私聊)
|
||||
sender: {
|
||||
id: userId, // 发送者 ID
|
||||
},
|
||||
previousTimestamp, // 上次会话时间(用于判断是否需要添加时间分隔符)
|
||||
envelope: envelopeOptions, // 格式化选项
|
||||
});
|
||||
// 返回格式化后的消息体,可能包含时间前缀、发送者名称等
|
||||
|
||||
// ============================================
|
||||
// 7. 构建完整的消息上下文
|
||||
// ============================================
|
||||
// runtime.channel.reply.finalizeInboundContext 构建 OpenClaw 标准的消息上下文
|
||||
// 这是 Agent 处理消息时使用的核心数据结构
|
||||
const ctx = runtime.channel.reply.finalizeInboundContext({
|
||||
Body: body, // 格式化后的消息体
|
||||
RawBody: content, // 原始消息内容
|
||||
CommandBody: content, // 命令体(用于解析命令)
|
||||
From: `wechat-access:${userId}`, // 发送者标识(带频道前缀)
|
||||
To: `wechat-access:${toUser}`, // 接收者标识
|
||||
SessionKey: route.sessionKey, // 会话键
|
||||
AccountId: route.accountId, // 账号 ID
|
||||
ChatType: "direct" as const, // 对话类型
|
||||
ChannelSource: WECHAT_CHANNEL_LABELS.serviceAccount, // 渠道来源标识(用于 UI 侧区分消息来源)
|
||||
SenderId: userId, // 发送者 ID
|
||||
Provider: "wechat-access", // 提供商标识
|
||||
Surface: "wechat-access", // 界面标识
|
||||
MessageSid: messageId, // 消息唯一标识
|
||||
Timestamp: timestamp, // 时间戳
|
||||
OriginatingChannel: "wechat-access" as const, // 原始频道
|
||||
OriginatingTo: `wechat-access:${userId}`, // 原始接收者
|
||||
});
|
||||
// ctx 包含了 Agent 处理消息所需的所有信息
|
||||
|
||||
return { ctx, route, storePath };
|
||||
};
|
||||
35
common/runtime.ts
Normal file
35
common/runtime.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
|
||||
// ============================================
|
||||
// Runtime 管理
|
||||
// ============================================
|
||||
// 用于存储和获取 OpenClaw 的运行时实例
|
||||
// Runtime 提供了访问配置、会话、路由、事件等核心功能的接口
|
||||
|
||||
/**
|
||||
* 全局运行时实例
|
||||
* 在插件初始化时由 OpenClaw 框架注入
|
||||
*/
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
/**
|
||||
* 设置微信企业号运行时实例
|
||||
* @param next - OpenClaw 提供的运行时实例
|
||||
* @description 此方法应在插件初始化时调用一次,用于注入运行时依赖
|
||||
*/
|
||||
export const setWecomRuntime = (next: PluginRuntime): void => {
|
||||
runtime = next;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取微信企业号运行时实例
|
||||
* @returns OpenClaw 运行时实例
|
||||
* @throws 如果运行时未初始化则抛出错误
|
||||
* @description 在需要访问 OpenClaw 核心功能时调用此方法
|
||||
*/
|
||||
export const getWecomRuntime = (): PluginRuntime => {
|
||||
if (!runtime) {
|
||||
throw new Error("WeCom runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
};
|
||||
Reference in New Issue
Block a user