长轮询、入站/出站消息、斜杠命令
消息处理是即时通讯插件的核心能力。本文将深入剖析 OpenClaw WeChat 插件的消息处理系统,包括入站消息的处理流程、出站消息的发送机制、媒体文件的处理、斜杠命令系统以及错误处理与通知机制。通过详细的源码解读,帮助开发者理解其设计原理和实现细节。
一、消息处理架构概览
OpenClaw WeChat 插件的消息处理系统采用经典的"生产者-消费者"模式,结合长轮询机制实现实时消息收发:
┌─────────────────────────────────────────────────────────────────────────┐
│ Message Processing Architecture │
├─────────────────────────────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Monitor │ ───> │ Process │ ───> │ Dispatch │ │
│ │ (Long Poll) │ │ Message │ │ Reply Dispatcher │ │
│ └──────────────┘ └──────────────┘ └──────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ getUpdates │ │ Media │ │ AI Pipeline │ │
│ │ Sync Buffer │ │ Download │ │ (Agent Reply) │ │
│ └──────────────┘ └──────────────┘ └──────────────────────┘ │
├─────────────────────────────────────────────────────────────────────────┤
│ Outbound Flow │
├─────────────────────────────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Deliver │ ───> │ Upload │ ───> │ Send Message │ │
│ │ Callback │ │ to CDN │ │ (Weixin API) │ │
│ └──────────────┘ └──────────────┘ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
这种架构的优势在于:职责分离清晰,便于独立测试和维护;支持媒体文件的异步处理;通过长轮询实现低延迟消息接收;完善的错误处理和重试机制。
二、长轮询监控器(Monitor)
2.1 监控器核心循环
监控器是消息处理的入口,负责通过长轮询从微信服务器获取消息:
export async function monitorWeixinProvider(opts: MonitorWeixinOpts): Promise<void> {
const {
baseUrl,
cdnBaseUrl,
token,
accountId,
config,
abortSignal,
longPollTimeoutMs,
setStatus,
} = opts;
const log = opts.runtime?.log ?? (() => {});
const errLog = opts.runtime?.error ?? ((m: string) => log(m));
const aLog: Logger = logger.withAccount(accountId);
aLog.info(`waiting for Weixin runtime...`);
let channelRuntime: PluginRuntime["channel"];
try {
const pluginRuntime = await waitForWeixinRuntime();
channelRuntime = pluginRuntime.channel;
aLog.info(`Weixin runtime acquired, channelRuntime type: ${typeof channelRuntime}`);
} catch (err) {
aLog.error(`waitForWeixinRuntime() failed: ${String(err)}`);
throw err;
}
log(`weixin monitor started (${baseUrl}, account=${accountId})`);
监控器首先等待运行时初始化完成,这是与 OpenClaw 框架集成的关键步骤。
2.2 同步缓冲区管理
为了实现断点续传和消息不丢失,插件使用同步缓冲区(sync buffer)机制:
const syncFilePath = getSyncBufFilePath(accountId);
aLog.debug(`syncFilePath: ${syncFilePath}`);
const previousGetUpdatesBuf = loadGetUpdatesBuf(syncFilePath);
let getUpdatesBuf = previousGetUpdatesBuf ?? "";
if (previousGetUpdatesBuf) {
log(`[weixin] resuming from previous sync buf (${getUpdatesBuf.length} bytes)`);
aLog.debug(`Using previous get_updates_buf (${getUpdatesBuf.length} bytes)`);
} else {
log(`[weixin] no previous sync buf, starting fresh`);
aLog.info(`No previous get_updates_buf found, starting fresh`);
}
同步缓冲区的工作原理:
- 首次启动时,
get_updates_buf为空字符串 - 每次成功获取消息后,服务器返回新的
get_updates_buf - 插件将其持久化到本地文件
- 重启后从文件恢复,确保消息连续性
2.3 长轮询与错误处理
监控器核心循环实现了完善的错误处理和退避策略:
while (!abortSignal?.aborted) {
try {
aLog.debug(
`getUpdates: get_updates_buf=${getUpdatesBuf.substring(0, 50)}..., timeoutMs=${nextTimeoutMs}`,
);
const resp = await getUpdates({
baseUrl,
token,
get_updates_buf: getUpdatesBuf,
timeoutMs: nextTimeoutMs,
});
if (resp.longpolling_timeout_ms != null && resp.longpolling_timeout_ms > 0) {
nextTimeoutMs = resp.longpolling_timeout_ms;
aLog.debug(`Updated next poll timeout: ${nextTimeoutMs}ms`);
}
const isApiError =
(resp.ret !== undefined && resp.ret !== 0) ||
(resp.errcode !== undefined && resp.errcode !== 0);
if (isApiError) {
const isSessionExpired =
resp.errcode === SESSION_EXPIRED_ERRCODE || resp.ret === SESSION_EXPIRED_ERRCODE;
if (isSessionExpired) {
pauseSession(accountId);
const pauseMs = getRemainingPauseMs(accountId);
errLog(
`weixin getUpdates: session expired (errcode ${SESSION_EXPIRED_ERRCODE}), pausing bot for ${Math.ceil(pauseMs / 60_000)} min`,
);
consecutiveFailures = 0;
await sleep(pauseMs, abortSignal);
continue;
}
consecutiveFailures += 1;
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
errLog(
`weixin getUpdates: ${MAX_CONSECUTIVE_FAILURES} consecutive failures, backing off 30s`,
);
consecutiveFailures = 0;
await sleep(BACKOFF_DELAY_MS, abortSignal);
} else {
await sleep(RETRY_DELAY_MS, abortSignal);
}
continue;
}
consecutiveFailures = 0;
setStatus?.({ accountId, lastEventAt: Date.now() });
if (resp.get_updates_buf != null && resp.get_updates_buf !== "") {
saveGetUpdatesBuf(syncFilePath, resp.get_updates_buf);
getUpdatesBuf = resp.get_updates_buf;
aLog.debug(`Saved new get_updates_buf (${getUpdatesBuf.length} bytes)`);
}
const list = resp.msgs ?? [];
for (const full of list) {
// 处理每条消息...
}
} catch (err) {
// 异常处理...
}
}
错误处理策略包括:
- 会话过期:暂停该账号 1 小时,避免频繁请求导致封号
- 连续失败:最多容忍 3 次连续失败,之后退避 30 秒
- 一般错误:2 秒后重试
- 优雅退出:响应 abortSignal,确保资源正确释放
三、入站消息处理流程
3.1 消息处理入口
processOneMessage 是入站消息处理的核心函数,负责完整的处理流水线:
export async function processOneMessage(
full: WeixinMessage,
deps: ProcessMessageDeps,
): Promise<void> {
if (!deps?.channelRuntime) {
logger.error(
`processOneMessage: channelRuntime is undefined, skipping message from=${full.from_user_id}`,
);
deps.errLog("processOneMessage: channelRuntime is undefined, skip");
return;
}
const receivedAt = Date.now();
const debug = isDebugMode(deps.accountId);
const debugTrace: string[] = [];
const debugTs: Record<string, number> = { received: receivedAt };
3.2 斜杠命令处理
在处理 AI 回复之前,首先检查是否是斜杠命令:
const textBody = extractTextBody(full.item_list);
if (textBody.startsWith("/")) {
const slashResult = await handleSlashCommand(textBody, {
to: full.from_user_id ?? "",
contextToken: full.context_token,
baseUrl: deps.baseUrl,
token: deps.token,
accountId: deps.accountId,
log: deps.log,
errLog: deps.errLog,
}, receivedAt, full.create_time_ms);
if (slashResult.handled) {
logger.info(`[weixin] Slash command handled, skipping AI pipeline`);
return;
}
}
斜杠命令系统允许用户执行一些快捷操作,如 /echo 和 /toggle-debug,这些命令直接响应,不经过 AI 处理管道。
3.3 媒体文件下载
微信消息可能包含图片、视频、文件或语音等媒体内容。插件需要下载并解密这些文件:
const mediaOpts: WeixinInboundMediaOpts = {};
// Find the first downloadable media item (priority: IMAGE > VIDEO > FILE > VOICE).
// When none found in the main item_list, fall back to media referenced via a quoted message.
const mainMediaItem =
full.item_list?.find(
(i) => i.type === MessageItemType.IMAGE && i.image_item?.media?.encrypt_query_param,
) ??
full.item_list?.find(
(i) => i.type === MessageItemType.VIDEO && i.video_item?.media?.encrypt_query_param,
) ??
full.item_list?.find(
(i) => i.type === MessageItemType.FILE && i.file_item?.media?.encrypt_query_param,
) ??
full.item_list?.find(
(i) =>
i.type === MessageItemType.VOICE &&
i.voice_item?.media?.encrypt_query_param &&
!i.voice_item.text,
);
const refMediaItem = !mainMediaItem
? full.item_list?.find(
(i) =>
i.type === MessageItemType.TEXT &&
i.ref_msg?.message_item &&
isMediaItem(i.ref_msg.message_item!),
)?.ref_msg?.message_item
: undefined;
const mediaDownloadStart = Date.now();
const mediaItem = mainMediaItem ?? refMediaItem;
if (mediaItem) {
const label = refMediaItem ? "ref" : "inbound";
const downloaded = await downloadMediaFromItem(mediaItem, {
cdnBaseUrl: deps.cdnBaseUrl,
saveMedia: deps.channelRuntime.media.saveMediaBuffer,
log: deps.log,
errLog: deps.errLog,
label,
});
Object.assign(mediaOpts, downloaded);
}
const mediaDownloadMs = Date.now() - mediaDownloadStart;
媒体处理的优先级设计:
- 主消息媒体:优先处理消息本身附带的媒体
- 引用消息媒体:如果主消息没有媒体,检查是否引用了媒体消息
- 类型优先级:图片 > 视频 > 文件 > 语音
- 语音特殊处理:如果语音已转文字(有 text 字段),跳过下载
3.4 用户授权检查
在将消息路由给 AI 之前,需要检查发送者是否有权限:
const { senderAllowedForCommands, commandAuthorized } =
await resolveSenderCommandAuthorizationWithRuntime({
cfg: deps.config,
rawBody,
isGroup: false,
dmPolicy: "pairing",
configuredAllowFrom: [],
configuredGroupAllowFrom: [],
senderId,
isSenderAllowed: (id: string, list: string[]) => list.length === 0 || list.includes(id),
readAllowFromStore: async () => {
const fromStore = readFrameworkAllowFromList(deps.accountId);
if (fromStore.length > 0) return fromStore;
const uid = loadWeixinAccount(deps.accountId)?.userId?.trim();
return uid ? [uid] : [];
},
runtime: deps.channelRuntime.commands,
});
const directDmOutcome = resolveDirectDmAuthorizationOutcome({
isGroup: false,
dmPolicy: "pairing",
senderAllowedForCommands,
});
if (directDmOutcome === "disabled" || directDmOutcome === "unauthorized") {
logger.info(
`authorization: dropping message from=${senderId} outcome=${directDmOutcome}`,
);
return;
}
授权检查采用"配对"(pairing)模式:
- 只有通过 QR 码登录授权的用户才能与 Bot 交互
- 授权列表存储在框架的
allowFrom文件中 - 支持向后兼容:如果没有配对文件,使用登录时的 userId 作为备选
3.5 消息路由与会话管理
通过 OpenClaw 框架的路由系统,确定消息应该由哪个 Agent 处理:
const route = deps.channelRuntime.routing.resolveAgentRoute({
cfg: deps.config,
channel: "openclaw-weixin",
accountId: deps.accountId,
peer: { kind: "direct", id: ctx.To },
});
logger.debug(
`resolveAgentRoute: agentId=${route.agentId ?? "(none)"} sessionKey=${route.sessionKey ?? "(none)"} mainSessionKey=${route.mainSessionKey ?? "(none)"}`,
);
if (!route.agentId) {
logger.error(
`resolveAgentRoute: no agentId resolved for peer=${ctx.To} accountId=${deps.accountId} — message will not be dispatched`,
);
}
ctx.SessionKey = route.sessionKey;
const storePath = deps.channelRuntime.session.resolveStorePath(deps.config.session?.store, {
agentId: route.agentId,
});
const finalized = deps.channelRuntime.reply.finalizeInboundContext(ctx);
路由解析后,消息上下文被"最终化"(finalize),准备进入 AI 处理管道。
3.6 入站会话记录
将消息记录到会话存储,用于维护对话上下文:
await deps.channelRuntime.session.recordInboundSession({
storePath,
sessionKey: route.sessionKey,
ctx: finalized,
updateLastRoute: {
sessionKey: route.mainSessionKey,
channel: "openclaw-weixin",
to: ctx.To,
accountId: deps.accountId,
},
onRecordError: (err) => deps.errLog(`recordInboundSession: ${String(err)}`),
});
const contextToken = getContextTokenFromMsgContext(ctx);
if (contextToken) {
setContextToken(deps.accountId, full.from_user_id ?? "", contextToken);
}
这里同时缓存了 context_token,这是后续回复消息时必需的参数。
四、出站消息发送机制
4.1 回复分发器
OpenClaw 框架提供了回复分发器(Reply Dispatcher)机制,用于管理 AI 生成的回复:
const humanDelay = deps.channelRuntime.reply.resolveHumanDelayConfig(deps.config, route.agentId);
const hasTypingTicket = Boolean(deps.typingTicket);
const typingCallbacks = createTypingCallbacks({
start: hasTypingTicket
? () =>
sendTyping({
baseUrl: deps.baseUrl,
token: deps.token,
body: {
ilink_user_id: ctx.To,
typing_ticket: deps.typingTicket!,
status: TypingStatus.TYPING,
},
})
: async () => {},
stop: hasTypingTicket
? () =>
sendTyping({
baseUrl: deps.baseUrl,
token: deps.token,
body: {
ilink_user_id: ctx.To,
typing_ticket: deps.typingTicket!,
status: TypingStatus.CANCEL,
},
})
: async () => {},
onStartError: (err) => deps.log(`[weixin] typing send error: ${String(err)}`),
onStopError: (err) => deps.log(`[weixin] typing cancel error: ${String(err)}`),
keepaliveIntervalMs: 5000,
});
打字指示器(Typing Indicator)通过 typingTicket 实现,让用户体验更加自然。
4.2 消息投递回调
deliver 回调函数负责实际的消息发送:
const { dispatcher, replyOptions, markDispatchIdle } =
deps.channelRuntime.reply.createReplyDispatcherWithTyping({
humanDelay,
typingCallbacks,
deliver: async (payload) => {
const text = markdownToPlainText(payload.text ?? "");
const mediaUrl = payload.mediaUrl ?? payload.mediaUrls?.[0];
logger.debug(`outbound payload: ${redactBody(JSON.stringify(payload))}`);
logger.info(
`outbound: to=${ctx.To} contextToken=${redactToken(contextToken)} textLen=${text.length} mediaUrl=${mediaUrl ? "present" : "none"}`,
);
try {
if (mediaUrl) {
let filePath: string;
if (!mediaUrl.includes("://") || mediaUrl.startsWith("file://")) {
// Local path handling
if (mediaUrl.startsWith("file://")) {
filePath = new URL(mediaUrl).pathname;
} else if (!path.isAbsolute(mediaUrl)) {
filePath = path.resolve(mediaUrl);
} else {
filePath = mediaUrl;
}
} else if (mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://")) {
filePath = await downloadRemoteImageToTemp(mediaUrl, MEDIA_OUTBOUND_TEMP_DIR);
} else {
await sendMessageWeixin({ to: ctx.To, text, opts: {
baseUrl: deps.baseUrl,
token: deps.token,
contextToken,
}});
return;
}
await sendWeixinMediaFile({
filePath,
to: ctx.To,
text,
opts: { baseUrl: deps.baseUrl, token: deps.token, contextToken },
cdnBaseUrl: deps.cdnBaseUrl,
});
} else {
await sendMessageWeixin({ to: ctx.To, text, opts: {
baseUrl: deps.baseUrl,
token: deps.token,
contextToken,
}});
}
} catch (err) {
logger.error(`outbound: FAILED to=${ctx.To} err=${String(err)}`);
throw err;
}
},
onError: (err, info) => {
// Error handling...
},
});
投递逻辑支持多种媒体来源:
- 本地文件:绝对路径、相对路径或
file://URL - 远程 URL:自动下载到临时目录
- 纯文本:直接发送文字消息
4.3 Markdown 转纯文本
AI 生成的回复通常是 Markdown 格式,需要转换为纯文本以适应微信:
export function markdownToPlainText(text: string): string {
let result = text;
// Code blocks: strip fences, keep code content
result = result.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_, code: string) => code.trim());
// Images: remove entirely
result = result.replace(/!\[[^\]]*\]\([^)]*\)/g, "");
// Links: keep display text only
result = result.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
// Tables: remove separator rows, then strip leading/trailing pipes
result = result.replace(/^\|[\s:|-]+\|$/gm, "");
result = result.replace(/^\|(.+)\|$/gm, (_, inner: string) =>
inner.split("|").map((cell) => cell.trim()).join(" "),
);
result = stripMarkdown(result);
return result;
}
转换规则包括:
- 代码块:保留代码内容,去除围栏标记
- 图片:完全移除(图片会作为独立媒体发送)
- 链接:保留显示文本,去除 URL
- 表格:转换为文本格式
五、媒体文件处理
5.1 媒体发送流程
sendWeixinMediaFile 函数根据文件类型选择不同的上传和发送策略:
export async function sendWeixinMediaFile(params: {
filePath: string;
to: string;
text: string;
opts: WeixinApiOptions & { contextToken?: string };
cdnBaseUrl: string;
}): Promise<{ messageId: string }> {
const { filePath, to, text, opts, cdnBaseUrl } = params;
const mime = getMimeFromFilename(filePath);
const uploadOpts: WeixinApiOptions = { baseUrl: opts.baseUrl, token: opts.token };
if (mime.startsWith("video/")) {
const uploaded = await uploadVideoToWeixin({
filePath,
toUserId: to,
opts: uploadOpts,
cdnBaseUrl,
});
return sendVideoMessageWeixin({ to, text, uploaded, opts });
}
if (mime.startsWith("image/")) {
const uploaded = await uploadFileToWeixin({
filePath,
toUserId: to,
opts: uploadOpts,
cdnBaseUrl,
});
return sendImageMessageWeixin({ to, text, uploaded, opts });
}
// File attachment: pdf, doc, zip, etc.
const fileName = path.basename(filePath);
const uploaded = await uploadFileAttachmentToWeixin({
filePath,
fileName,
toUserId: to,
opts: uploadOpts,
cdnBaseUrl,
});
return sendFileMessageWeixin({ to, text, fileName, uploaded, opts });
}
5.2 图片消息构建
图片消息需要包含加密参数和 AES 密钥:
export async function sendImageMessageWeixin(params: {
to: string;
text: string;
uploaded: UploadedFileInfo;
opts: WeixinApiOptions & { contextToken?: string };
}): Promise<{ messageId: string }> {
const { to, text, uploaded, opts } = params;
if (!opts.contextToken) {
throw new Error("sendImageMessageWeixin: contextToken is required");
}
const imageItem: MessageItem = {
type: MessageItemType.IMAGE,
image_item: {
media: {
encrypt_query_param: uploaded.downloadEncryptedQueryParam,
aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
encrypt_type: 1,
},
mid_size: uploaded.fileSizeCiphertext,
},
};
return sendMediaItems({ to, text, mediaItem: imageItem, opts, label: "sendImageMessageWeixin" });
}
5.3 媒体项发送
当消息同时包含文字和媒体时,分别发送为独立的消息项:
async function sendMediaItems(params: {
to: string;
text: string;
mediaItem: MessageItem;
opts: WeixinApiOptions & { contextToken?: string };
label: string;
}): Promise<{ messageId: string }> {
const { to, text, mediaItem, opts, label } = params;
const items: MessageItem[] = [];
if (text) {
items.push({ type: MessageItemType.TEXT, text_item: { text } });
}
items.push(mediaItem);
let lastClientId = "";
for (const item of items) {
lastClientId = generateClientId();
const req: SendMessageReq = {
msg: {
from_user_id: "",
to_user_id: to,
client_id: lastClientId,
message_type: MessageType.BOT,
message_state: MessageState.FINISH,
item_list: [item],
context_token: opts.contextToken ?? undefined,
},
};
await sendMessageApi({
baseUrl: opts.baseUrl,
token: opts.token,
timeoutMs: opts.timeoutMs,
body: req,
});
}
return { messageId: lastClientId };
}
六、斜杠命令系统
6.1 命令处理架构
斜杠命令系统提供了一种快捷方式,让用户可以直接执行特定操作:
export async function handleSlashCommand(
content: string,
ctx: SlashCommandContext,
receivedAt: number,
eventTimestamp?: number,
): Promise<SlashCommandResult> {
const trimmed = content.trim();
if (!trimmed.startsWith("/")) {
return { handled: false };
}
const spaceIdx = trimmed.indexOf(" ");
const command = spaceIdx === -1 ? trimmed.toLowerCase() : trimmed.slice(0, spaceIdx).toLowerCase();
const args = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1);
logger.info(`[weixin] Slash command: ${command}, args: ${args.slice(0, 50)}`);
try {
switch (command) {
case "/echo":
await handleEcho(ctx, args, receivedAt, eventTimestamp);
return { handled: true };
case "/toggle-debug": {
const enabled = toggleDebugMode(ctx.accountId);
await sendReply(ctx, enabled ? "Debug 模式已开启" : "Debug 模式已关闭");
return { handled: true };
}
default:
return { handled: false };
}
} catch (err) {
logger.error(`[weixin] Slash command error: ${String(err)}`);
try {
await sendReply(ctx, `❌ 指令执行失败: ${String(err).slice(0, 200)}`);
} catch {
// 发送错误消息也失败了
}
return { handled: true };
}
}
6.2 Echo 命令实现
/echo 命令用于测试通道延迟:
async function handleEcho(
ctx: SlashCommandContext,
args: string,
receivedAt: number,
eventTimestamp?: number,
): Promise<void> {
const message = args.trim();
if (message) {
await sendReply(ctx, message);
}
const eventTs = eventTimestamp ?? 0;
const platformDelay = eventTs > 0 ? `${receivedAt - eventTs}ms` : "N/A";
const timing = [
"⏱ 通道耗时",
`├ 事件时间: ${eventTs > 0 ? new Date(eventTs).toISOString() : "N/A"}`,
`├ 平台→插件: ${platformDelay}`,
`└ 插件处理: ${Date.now() - receivedAt}ms`,
].join("\n");
await sendReply(ctx, timing);
}
6.3 Debug 模式切换
/toggle-debug 命令用于开关调试模式:
export function toggleDebugMode(accountId: string): boolean {
const state = loadState();
const next = !state.accounts[accountId];
state.accounts[accountId] = next;
try {
saveState(state);
} catch (err) {
logger.error(`debug-mode: failed to persist state: ${String(err)}`);
}
return next;
}
调试模式状态持久化到磁盘,确保网关重启后设置不丢失。
七、错误处理与通知
7.1 错误分类与处理
消息发送失败时,系统会尝试向用户发送错误通知:
onError: (err, info) => {
deps.errLog(`weixin reply ${info.kind}: ${String(err)}`);
const errMsg = err instanceof Error ? err.message : String(err);
let notice: string;
if (errMsg.includes("contextToken is required")) {
logger.warn(`onError: contextToken missing, cannot send error notice to=${ctx.To}`);
return;
} else if (errMsg.includes("remote media download failed") || errMsg.includes("fetch")) {
notice = `⚠️ 媒体文件下载失败,请检查链接是否可访问。`;
} else if (
errMsg.includes("getUploadUrl") ||
errMsg.includes("CDN upload") ||
errMsg.includes("upload_param")
) {
notice = `⚠️ 媒体文件上传失败,请稍后重试。`;
} else {
notice = `⚠️ 消息发送失败:${errMsg}`;
}
void sendWeixinErrorNotice({
to: ctx.To,
contextToken,
message: notice,
baseUrl: deps.baseUrl,
token: deps.token,
errLog: deps.errLog,
});
}
7.2 错误通知发送
错误通知采用"fire-and-forget"模式,不影响主流程:
export async function sendWeixinErrorNotice(params: {
to: string;
contextToken: string | undefined;
message: string;
baseUrl: string;
token?: string;
errLog: (m: string) => void;
}): Promise<void> {
if (!params.contextToken) {
logger.warn(`sendWeixinErrorNotice: no contextToken for to=${params.to}, cannot notify user`);
return;
}
try {
await sendMessageWeixin({ to: params.to, text: params.message, opts: {
baseUrl: params.baseUrl,
token: params.token,
contextToken: params.contextToken,
}});
logger.debug(`sendWeixinErrorNotice: sent to=${params.to}`);
} catch (err) {
params.errLog(`[weixin] sendWeixinErrorNotice failed to=${params.to}: ${String(err)}`);
}
}
八、调试模式与性能追踪
8.1 全链路耗时统计
当调试模式开启时,插件会在每条 AI 回复后追加详细的耗时统计:
if (debug && contextToken) {
const dispatchDoneAt = Date.now();
const eventTs = full.create_time_ms ?? 0;
const platformDelay = eventTs > 0 ? `${receivedAt - eventTs}ms` : "N/A";
const inboundProcessMs = (debugTs.preDispatch ?? receivedAt) - receivedAt;
const aiMs = dispatchDoneAt - (debugTs.preDispatch ?? receivedAt);
const totalTime = eventTs > 0 ? `${dispatchDoneAt - eventTs}ms` : `${dispatchDoneAt - receivedAt}ms`;
debugTrace.push(
"── 耗时 ──",
`├ 平台→插件: ${platformDelay}`,
`├ 入站处理(auth+route+media): ${inboundProcessMs}ms (mediaDownload: ${mediaDownloadMs}ms)`,
`├ AI生成+回复: ${aiMs}ms`,
`├ 总耗时: ${totalTime}`,
`└ eventTime: ${eventTs > 0 ? new Date(eventTs).toISOString() : "N/A"}`,
);
const timingText = `⏱ Debug 全链路\n${debugTrace.join("\n")}`;
await sendMessageWeixin({
to: ctx.To,
text: timingText,
opts: { baseUrl: deps.baseUrl, token: deps.token, contextToken },
});
}
8.2 调试追踪信息
调试模式下会记录完整的处理轨迹:
if (debug) {
const itemTypes = full.item_list?.map((i) => i.type).join(",") ?? "none";
debugTrace.push(
"── 收消息 ──",
`│ seq=${full.seq ?? "?"} msgId=${full.message_id ?? "?"} from=${full.from_user_id ?? "?"}`,
`│ body="${textBody.slice(0, 40)}${textBody.length > 40 ? "…" : ""}" (len=${textBody.length}) itemTypes=[${itemTypes}]`,
`│ sessionId=${full.session_id ?? "?"} contextToken=${full.context_token ? "present" : "none"}`,
);
}
九、总结
OpenClaw WeChat 插件的消息处理系统展现了以下设计亮点:
- 长轮询架构:实现低延迟消息接收,支持断点续传
- 分层处理:监控、处理、发送职责分离,便于维护
- 媒体处理:支持多种媒体类型,自动下载解密
- 授权机制:基于配对的用户授权,确保安全性
- 错误恢复:完善的错误处理和用户通知机制
- 调试支持:全链路耗时追踪,便于性能优化
这些设计不仅保证了系统的稳定性和可靠性,也为开发者提供了丰富的调试和监控手段。在下一篇文章中,我们将深入探讨 CDN 媒体服务系统的加密与上传机制。