@tencent-weixin/openclaw-weixin 插件深度解析(二):消息处理系统架构

25 阅读5分钟

长轮询、入站/出站消息、斜杠命令

消息处理是即时通讯插件的核心能力。本文将深入剖析 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`);
}

同步缓冲区的工作原理:

  1. 首次启动时,get_updates_buf 为空字符串
  2. 每次成功获取消息后,服务器返回新的 get_updates_buf
  3. 插件将其持久化到本地文件
  4. 重启后从文件恢复,确保消息连续性

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;

媒体处理的优先级设计:

  1. 主消息媒体:优先处理消息本身附带的媒体
  2. 引用消息媒体:如果主消息没有媒体,检查是否引用了媒体消息
  3. 类型优先级:图片 > 视频 > 文件 > 语音
  4. 语音特殊处理:如果语音已转文字(有 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 插件的消息处理系统展现了以下设计亮点:

  1. 长轮询架构:实现低延迟消息接收,支持断点续传
  2. 分层处理:监控、处理、发送职责分离,便于维护
  3. 媒体处理:支持多种媒体类型,自动下载解密
  4. 授权机制:基于配对的用户授权,确保安全性
  5. 错误恢复:完善的错误处理和用户通知机制
  6. 调试支持:全链路耗时追踪,便于性能优化

这些设计不仅保证了系统的稳定性和可靠性,也为开发者提供了丰富的调试和监控手段。在下一篇文章中,我们将深入探讨 CDN 媒体服务系统的加密与上传机制。