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

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

消息处理是即时通讯插件的核心能力。本文将深入剖析 OpenClaw WeChat 插件的消息处理系统,包括入站消息的处理流程、出站消息的发送机制、媒体文件的处理、斜杠命令系统以及错误处理与通知机制。通过详细的源码解读,帮助开发者理解其设计原理和实现细节。

一、消息处理架构概览

OpenClaw WeChat 插件的消息处理系统采用经典的"生产者-消费者"模式,结合长轮询机制实现实时消息收发:

scss 复制代码
┌─────────────────────────────────────────────────────────────────────────┐
│                         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 监控器核心循环

监控器是消息处理的入口,负责通过长轮询从微信服务器获取消息:

typescript 复制代码
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)机制:

typescript 复制代码
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 长轮询与错误处理

监控器核心循环实现了完善的错误处理和退避策略:

typescript 复制代码
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 是入站消息处理的核心函数,负责完整的处理流水线:

typescript 复制代码
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 回复之前,首先检查是否是斜杠命令:

typescript 复制代码
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 媒体文件下载

微信消息可能包含图片、视频、文件或语音等媒体内容。插件需要下载并解密这些文件:

typescript 复制代码
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 之前,需要检查发送者是否有权限:

typescript 复制代码
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 处理:

typescript 复制代码
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 入站会话记录

将消息记录到会话存储,用于维护对话上下文:

typescript 复制代码
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 生成的回复:

typescript 复制代码
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 回调函数负责实际的消息发送:

typescript 复制代码
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 格式,需要转换为纯文本以适应微信:

typescript 复制代码
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 函数根据文件类型选择不同的上传和发送策略:

typescript 复制代码
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 密钥:

typescript 复制代码
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 媒体项发送

当消息同时包含文字和媒体时,分别发送为独立的消息项:

typescript 复制代码
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 命令处理架构

斜杠命令系统提供了一种快捷方式,让用户可以直接执行特定操作:

typescript 复制代码
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 命令用于测试通道延迟:

typescript 复制代码
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 命令用于开关调试模式:

typescript 复制代码
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 错误分类与处理

消息发送失败时,系统会尝试向用户发送错误通知:

typescript 复制代码
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"模式,不影响主流程:

typescript 复制代码
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 回复后追加详细的耗时统计:

typescript 复制代码
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 调试追踪信息

调试模式下会记录完整的处理轨迹:

typescript 复制代码
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 媒体服务系统的加密与上传机制。

相关推荐
m0_587383009 小时前
折扣卡CPS软件开发实战:从系统架构设计到上线指南
java·小程序·架构·需求分析
LEE9 小时前
前端转型全栈 00:AI 时代该学哪些,不该学哪些
前端·后端
Dawson Zhu9 小时前
基于 OKF 知识图谱的 Text2SQL 领域知识注入实践——半导体晶圆厂数据资产知识库 MVP 剖析
人工智能·语言模型·架构·aigc·agi
晚安日记wanna9 小时前
SSR 为什么不适合登录态从水合冲突到缓存串号
前端·react.js·面试
aixingpan9 小时前
aixingpan.cn API开发文档:api_docs_bichart_natalvssolararc2接口指南
前端·php
无限压榨切图仔9 小时前
一个优惠券需求改了三端,我才明白全栈不是多学一门语言
前端·后端
晚安日记wanna9 小时前
批量请求失败只弹一个 Toast面试官想听五层
前端·面试·架构
大模型码小白9 小时前
告别造假数据,直接连数据库查真实时序数据喂给 TimechoAI 大模型
java·数据库·人工智能·microsoft·架构
晚安日记wanna9 小时前
TS const 类型参数一道面试题的四层追问
前端·面试·typescript
晚安日记wanna9 小时前
大表 DDL 面试翻车现场Online DDL 为什么还会锁死业务
数据库·面试·架构