@tencent-weixin/openclaw-weixin 插件深度解析(四):API 协议与数据流设计

14 阅读7分钟

RESTful API、类型系统、同步缓冲区

API 协议是插件与微信服务器通信的基础,而数据流设计决定了消息如何在整个系统中流转。本文将深入剖析 OpenClaw WeChat 插件的 API 协议设计、数据类型系统、同步缓冲区机制以及日志与监控体系,帮助开发者理解其底层通信原理。

一、API 协议架构概览

OpenClaw WeChat 插件采用 RESTful API 与微信服务器通信,所有请求使用 JSON 格式,通过 HTTP/HTTPS 传输:

┌─────────────────────────────────────────────────────────────────────────┐
│                         API Protocol Stack                               │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │                        Application Layer                         │   │
│  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐  ┌─────────┐ │   │
│  │  │ getUpdates  │  │ sendMessage │  │ getUploadUrl│  │getConfig│ │   │
│  │  └─────────────┘  └─────────────┘  └─────────────┘  └─────────┘ │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                                                                          │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │                      Transport Layer (HTTP)                      │   │
│  │  POST /ilink/bot/getupdates          JSON Request/Response      │   │
│  │  POST /ilink/bot/sendmessage                                    │   │
│  │  POST /ilink/bot/getuploadurl                                   │   │
│  │  POST /ilink/bot/getconfig                                      │   │
│  │  POST /ilink/bot/sendtyping                                     │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                                                                          │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │                      Security Layer                              │   │
│  │  Authorization: Bearer <token>                                   │   │
│  │  AuthorizationType: ilink_bot_token                              │   │
│  │  X-WECHAT-UIN: <random>                                          │   │
│  │  SKRouteTag: <optional>                                          │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

二、核心 API 接口详解

2.1 API 选项配置

所有 API 调用共享统一的选项配置:

export type WeixinApiOptions = {
  baseUrl: string;
  token?: string;
  timeoutMs?: number;
  /** Long-poll timeout for getUpdates (server may hold the request up to this). */
  longPollTimeoutMs?: number;
};

默认超时配置根据 API 类型区分:

const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
const DEFAULT_API_TIMEOUT_MS = 15_000;
const DEFAULT_CONFIG_TIMEOUT_MS = 10_000;

2.2 通用请求构建

所有 API 请求共享统一的请求构建逻辑:

async function apiFetch(params: {
  baseUrl: string;
  endpoint: string;
  body: string;
  token?: string;
  timeoutMs: number;
  label: string;
}): Promise<string> {
  const base = ensureTrailingSlash(params.baseUrl);
  const url = new URL(params.endpoint, base);
  const hdrs = buildHeaders({ token: params.token, body: params.body });
  logger.debug(`POST ${redactUrl(url.toString())} body=${redactBody(params.body)}`);

  const controller = new AbortController();
  const t = setTimeout(() => controller.abort(), params.timeoutMs);
  try {
    const res = await fetch(url.toString(), {
      method: "POST",
      headers: hdrs,
      body: params.body,
      signal: controller.signal,
    });
    clearTimeout(t);
    const rawText = await res.text();
    logger.debug(`${params.label} status=${res.status} raw=${redactBody(rawText)}`);
    if (!res.ok) {
      throw new Error(`${params.label} ${res.status}: ${rawText}`);
    }
    return rawText;
  } catch (err) {
    clearTimeout(t);
    throw err;
  }
}

2.3 请求头构建

请求头包含身份验证和路由信息:

function buildHeaders(opts: { token?: string; body: string }): Record<string, string> {
  const headers: Record<string, string> = {
    "Content-Type": "application/json",
    AuthorizationType: "ilink_bot_token",
    "Content-Length": String(Buffer.byteLength(opts.body, "utf-8")),
    "X-WECHAT-UIN": randomWechatUin(),
  };
  if (opts.token?.trim()) {
    headers.Authorization = `Bearer ${opts.token.trim()}`;
  }
  const routeTag = loadConfigRouteTag();
  if (routeTag) {
    headers.SKRouteTag = routeTag;
  }
  return headers;
}

/** X-WECHAT-UIN header: random uint32 -> decimal string -> base64. */
function randomWechatUin(): string {
  const uint32 = crypto.randomBytes(4).readUInt32BE(0);
  return Buffer.from(String(uint32), "utf-8").toString("base64");
}

2.4 GetUpdates 长轮询

GetUpdates 是消息接收的核心接口,采用长轮询机制:

export async function getUpdates(
  params: GetUpdatesReq & {
    baseUrl: string;
    token?: string;
    timeoutMs?: number;
  },
): Promise<GetUpdatesResp> {
  const timeout = params.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
  try {
    const rawText = await apiFetch({
      baseUrl: params.baseUrl,
      endpoint: "ilink/bot/getupdates",
      body: JSON.stringify({
        get_updates_buf: params.get_updates_buf ?? "",
        base_info: buildBaseInfo(),
      }),
      token: params.token,
      timeoutMs: timeout,
      label: "getUpdates",
    });
    const resp: GetUpdatesResp = JSON.parse(rawText);
    return resp;
  } catch (err) {
    // Long-poll timeout is normal; return empty response so caller can retry
    if (err instanceof Error && err.name === "AbortError") {
      logger.debug(`getUpdates: client-side timeout after ${timeout}ms, returning empty response`);
      return { ret: 0, msgs: [], get_updates_buf: params.get_updates_buf };
    }
    throw err;
  }
}

长轮询的特点:

  • 客户端设置 35 秒超时
  • 服务器保持连接直到有新消息或超时
  • 客户端超时视为正常情况,自动重试
  • 返回 get_updates_buf 用于下次请求

2.5 发送消息

SendMessage 用于向用户发送消息:

export async function sendMessage(
  params: WeixinApiOptions & { body: SendMessageReq },
): Promise<void> {
  await apiFetch({
    baseUrl: params.baseUrl,
    endpoint: "ilink/bot/sendmessage",
    body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
    token: params.token,
    timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
    label: "sendMessage",
  });
}

2.6 获取上传 URL

GetUploadUrl 用于获取 CDN 上传的预签名 URL:

export async function getUploadUrl(
  params: GetUploadUrlReq & WeixinApiOptions,
): Promise<GetUploadUrlResp> {
  const rawText = await apiFetch({
    baseUrl: params.baseUrl,
    endpoint: "ilink/bot/getuploadurl",
    body: JSON.stringify({
      filekey: params.filekey,
      media_type: params.media_type,
      to_user_id: params.to_user_id,
      rawsize: params.rawsize,
      rawfilemd5: params.rawfilemd5,
      filesize: params.filesize,
      no_need_thumb: params.no_need_thumb,
      aeskey: params.aeskey,
      base_info: buildBaseInfo(),
    }),
    token: params.token,
    timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
    label: "getUploadUrl",
  });
  const resp: GetUploadUrlResp = JSON.parse(rawText);
  return resp;
}

2.7 获取配置

GetConfig 用于获取用户的配置信息,包括 typing_ticket:

export async function getConfig(
  params: WeixinApiOptions & { ilinkUserId: string; contextToken?: string },
): Promise<GetConfigResp> {
  const rawText = await apiFetch({
    baseUrl: params.baseUrl,
    endpoint: "ilink/bot/getconfig",
    body: JSON.stringify({
      ilink_user_id: params.ilinkUserId,
      context_token: params.contextToken,
      base_info: buildBaseInfo(),
    }),
    token: params.token,
    timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
    label: "getConfig",
  });
  const resp: GetConfigResp = JSON.parse(rawText);
  return resp;
}

2.8 发送打字指示器

SendTyping 用于向用户显示"正在输入"状态:

export async function sendTyping(
  params: WeixinApiOptions & { body: SendTypingReq },
): Promise<void> {
  await apiFetch({
    baseUrl: params.baseUrl,
    endpoint: "ilink/bot/sendtyping",
    body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
    token: params.token,
    timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
    label: "sendTyping",
  });
}

三、数据类型系统

3.1 基础信息类型

每个 API 请求都包含基础信息:

export interface BaseInfo {
  channel_version?: string;
}

function readChannelVersion(): string {
  try {
    const dir = path.dirname(fileURLToPath(import.meta.url));
    const pkgPath = path.resolve(dir, "..", "..", "package.json");
    const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { version?: string };
    return pkg.version ?? "unknown";
  } catch {
    return "unknown";
  }
}

const CHANNEL_VERSION = readChannelVersion();

export function buildBaseInfo(): BaseInfo {
  return { channel_version: CHANNEL_VERSION };
}

3.2 消息类型定义

消息系统支持多种类型的消息项:

export const MessageItemType = {
  NONE: 0,
  TEXT: 1,
  IMAGE: 2,
  VOICE: 3,
  FILE: 4,
  VIDEO: 5,
} as const;

export const MessageType = {
  NONE: 0,
  USER: 1,
  BOT: 2,
} as const;

export const MessageState = {
  NEW: 0,
  GENERATING: 1,
  FINISH: 2,
} as const;

3.3 消息项结构

消息项采用联合类型设计,通过 type 字段区分:

export interface MessageItem {
  type?: number;
  create_time_ms?: number;
  update_time_ms?: number;
  is_completed?: boolean;
  msg_id?: string;
  ref_msg?: RefMessage;
  text_item?: TextItem;
  image_item?: ImageItem;
  voice_item?: VoiceItem;
  file_item?: FileItem;
  video_item?: VideoItem;
}

export interface TextItem {
  text?: string;
}

export interface ImageItem {
  media?: CDNMedia;
  thumb_media?: CDNMedia;
  aeskey?: string;
  url?: string;
  mid_size?: number;
  thumb_size?: number;
  hd_size?: number;
}

export interface VoiceItem {
  media?: CDNMedia;
  encode_type?: number;
  sample_rate?: number;
  playtime?: number;
  text?: string;
}

export interface FileItem {
  media?: CDNMedia;
  file_name?: string;
  md5?: string;
  len?: string;
}

export interface VideoItem {
  media?: CDNMedia;
  video_size?: number;
  play_length?: number;
  thumb_media?: CDNMedia;
}

3.4 CDN 媒体引用

媒体文件通过 CDN 引用访问:

export interface CDNMedia {
  encrypt_query_param?: string;
  aes_key?: string;
  encrypt_type?: number;
}

3.5 统一消息结构

WeixinMessage 是统一的消息结构:

export interface WeixinMessage {
  seq?: number;
  message_id?: number;
  from_user_id?: string;
  to_user_id?: string;
  client_id?: string;
  create_time_ms?: number;
  update_time_ms?: number;
  delete_time_ms?: number;
  session_id?: string;
  group_id?: string;
  message_type?: number;
  message_state?: number;
  item_list?: MessageItem[];
  context_token?: string;
}

关键字段说明:

  • seq:消息序列号,用于排序
  • message_id:唯一消息标识
  • from_user_id / to_user_id:发送者和接收者
  • client_id:客户端生成的消息 ID
  • create_time_ms:消息创建时间(毫秒时间戳)
  • session_id:会话标识
  • item_list:消息内容项列表
  • context_token:上下文令牌,回复时必须携带

3.6 GetUpdates 请求/响应

export interface GetUpdatesReq {
  /** @deprecated compat only, will be removed */
  sync_buf?: string;
  /** Full context buf cached locally; send "" when none (first request or after reset). */
  get_updates_buf?: string;
}

export interface GetUpdatesResp {
  ret?: number;
  errcode?: number;
  errmsg?: string;
  msgs?: WeixinMessage[];
  get_updates_buf?: string;
  longpolling_timeout_ms?: number;
}

3.7 打字状态

export const TypingStatus = {
  TYPING: 1,
  CANCEL: 2,
} as const;

export interface SendTypingReq {
  ilink_user_id?: string;
  typing_ticket?: string;
  status?: number;
}

四、同步缓冲区机制

4.1 同步缓冲区的作用

同步缓冲区(sync buffer)是实现消息不丢失的关键机制:

┌─────────────────────────────────────────────────────────────────────────┐
│                      Sync Buffer Flow                                    │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  Client                    Weixin Server                                 │
│    │                            │                                        │
│    │  1. getUpdates(buf="")    │                                        │
│    │ -------------------------> │                                        │
│    │                            │                                        │
│    │  2. msgs: [A, B, C]       │                                        │
│    │     new_buf: "XYZ123"     │                                        │
│    │ <------------------------- │                                        │
│    │                            │                                        │
│    │  [Save "XYZ123" to file]  │                                        │
│    │                            │                                        │
│    │  3. getUpdates(buf="XYZ123")                                       │
│    │ -------------------------> │                                        │
│    │                            │                                        │
│    │  [Server knows client has A, B, C]                                 │
│    │                            │                                        │
│    │  4. msgs: [D, E]          │                                        │
│    │     new_buf: "ABC789"     │                                        │
│    │ <------------------------- │                                        │
│    │                            │                                        │
│    │  [Save "ABC789" to file]  │                                        │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

4.2 同步缓冲区存储

export type SyncBufData = {
  get_updates_buf: string;
};

export function getSyncBufFilePath(accountId: string): string {
  return path.join(resolveAccountsDir(), `${accountId}.sync.json`);
}

export function saveGetUpdatesBuf(filePath: string, getUpdatesBuf: string): void {
  const dir = path.dirname(filePath);
  fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(filePath, JSON.stringify({ get_updates_buf: getUpdatesBuf }, null, 0), "utf-8");
}

4.3 多层兼容性回退

同步缓冲区加载支持多层回退:

export function loadGetUpdatesBuf(filePath: string): string | undefined {
  const value = readSyncBufFile(filePath);
  if (value !== undefined) return value;

  // Compat: if given path uses a normalized accountId (e.g. "b0f5860fdecb-im-bot.sync.json"),
  // also try the old raw-ID filename (e.g. "b0f5860fdecb@im.bot.sync.json").
  const accountId = path.basename(filePath, ".sync.json");
  const rawId = deriveRawAccountId(accountId);
  if (rawId) {
    const compatPath = path.join(resolveAccountsDir(), `${rawId}.sync.json`);
    const compatValue = readSyncBufFile(compatPath);
    if (compatValue !== undefined) return compatValue;
  }

  // Legacy fallback: old single-account installs stored syncbuf without accountId.
  return readSyncBufFile(getLegacySyncBufDefaultJsonPath());
}

回退层级:

  1. 主路径:规范化账号 ID 的同步文件
  2. 兼容路径:原始格式账号 ID 的同步文件
  3. 遗留路径:单账号时代的默认同步文件

五、状态目录管理

5.1 状态目录解析

插件使用统一的状态目录存储所有持久化数据:

export function resolveStateDir(): string {
  return (
    process.env.OPENCLAW_STATE_DIR?.trim() ||
    process.env.CLAWDBOT_STATE_DIR?.trim() ||
    path.join(os.homedir(), ".openclaw")
  );
}

环境变量优先级:

  1. OPENCLAW_STATE_DIR:首选环境变量
  2. CLAWDBOT_STATE_DIR:向后兼容的旧变量名
  3. 默认路径:~/.openclaw

5.2 目录结构

~/.openclaw/
├── openclaw-weixin/
│   ├── accounts.json              # 账号索引
│   ├── accounts/
│   │   ├── {accountId}.json       # 账号凭证
│   │   └── {accountId}.sync.json  # 同步缓冲区
│   └── debug-mode.json            # 调试模式状态
└── credentials/
    └── openclaw-weixin-{accountId}-allowFrom.json  # 授权列表

六、日志系统

6.1 日志架构

插件使用与 OpenClaw 核心统一的日志格式:

const MAIN_LOG_DIR = path.join("/tmp", "openclaw");
const SUBSYSTEM = "gateway/channels/openclaw-weixin";
const RUNTIME = "node";
const RUNTIME_VERSION = process.versions.node;
const HOSTNAME = os.hostname() || "unknown";

6.2 日志级别

const LEVEL_IDS: Record<string, number> = {
  TRACE: 1,
  DEBUG: 2,
  INFO: 3,
  WARN: 4,
  ERROR: 5,
  FATAL: 6,
};

const DEFAULT_LOG_LEVEL = "INFO";

6.3 日志记录实现

function writeLog(level: string, message: string, accountId?: string): void {
  const levelId = LEVEL_IDS[level] ?? LEVEL_IDS.INFO;
  if (levelId < minLevelId) return;

  const now = new Date();
  const loggerName = buildLoggerName(accountId);
  const prefixedMessage = accountId ? `[${accountId}] ${message}` : message;
  const entry = JSON.stringify({
    "0": loggerName,
    "1": prefixedMessage,
    _meta: {
      runtime: RUNTIME,
      runtimeVersion: RUNTIME_VERSION,
      hostname: HOSTNAME,
      name: loggerName,
      parentNames: PARENT_NAMES,
      date: now.toISOString(),
      logLevelId: LEVEL_IDS[level] ?? LEVEL_IDS.INFO,
      logLevelName: level,
    },
    time: toLocalISO(now),
  });

  try {
    if (!logDirEnsured) {
      fs.mkdirSync(MAIN_LOG_DIR, { recursive: true });
      logDirEnsured = true;
    }
    fs.appendFileSync(resolveMainLogPath(), `${entry}\n`, "utf-8");
  } catch {
    // Best-effort; never block on logging failures.
  }
}

6.4 日志格式

日志采用 JSON Lines 格式,便于结构化处理:

{
  "0": "gateway/channels/openclaw-weixin/b0f5860fdecb-im-bot",
  "1": "[b0f5860fdecb-im-bot] inbound message: from=xxx@im.wechat types=1",
  "_meta": {
    "runtime": "node",
    "runtimeVersion": "22.0.0",
    "hostname": "myhost",
    "name": "gateway/channels/openclaw-weixin/b0f5860fdecb-im-bot",
    "parentNames": ["openclaw"],
    "date": "2026-03-22T10:30:00.000Z",
    "logLevelId": 3,
    "logLevelName": "INFO"
  },
  "time": "2026-03-22T18:30:00.000+08:00"
}

6.5 子日志器

支持按账号创建子日志器:

export type Logger = {
  info(message: string): void;
  debug(message: string): void;
  warn(message: string): void;
  error(message: string): void;
  withAccount(accountId: string): Logger;
  getLogFilePath(): string;
  close(): void;
};

function createLogger(accountId?: string): Logger {
  return {
    info(message: string): void {
      writeLog("INFO", message, accountId);
    },
    // ... 其他级别
    withAccount(id: string): Logger {
      return createLogger(id);
    },
  };
}

七、敏感信息脱敏

7.1 脱敏工具函数

export function truncate(s: string | undefined, max: number): string {
  if (!s) return "";
  if (s.length <= max) return s;
  return `${s.slice(0, max)}…(len=${s.length})`;
}

export function redactToken(token: string | undefined, prefixLen = 6): string {
  if (!token) return "(none)";
  if (token.length <= prefixLen) return `****(len=${token.length})`;
  return `${token.slice(0, prefixLen)}…(len=${token.length})`;
}

export function redactBody(body: string | undefined, maxLen = 200): string {
  if (!body) return "(empty)";
  if (body.length <= maxLen) return body;
  return `${body.slice(0, maxLen)}…(truncated, totalLen=${body.length})`;
}

export function redactUrl(rawUrl: string): string {
  try {
    const u = new URL(rawUrl);
    const base = `${u.origin}${u.pathname}`;
    return u.search ? `${base}?<redacted>` : base;
  } catch {
    return truncate(rawUrl, 80);
  }
}

7.2 脱敏策略

  • Token:显示前 6 个字符,隐藏其余部分
  • 请求体:截断至 200 字符
  • URL:隐藏查询字符串(可能包含签名)
  • 空值:明确标记为 "(none)" 或 "(empty)"

八、ID 生成与随机数

8.1 消息 ID 生成

export function generateId(prefix: string): string {
  return `${prefix}:${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
}

格式:{prefix}:{timestamp}-{8-char hex}

示例:openclaw-weixin:1711090800000-a1b2c3d4

8.2 临时文件名生成

export function tempFileName(prefix: string, ext: string): string {
  return `${prefix}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}${ext}`;
}

格式:{prefix}-{timestamp}-{8-char hex}{ext}

示例:weixin-remote-1711090800000-a1b2c3d4.jpg

8.3 设计考量

  • 时间戳:确保基本的有序性
  • 随机数:防止冲突,增强不可预测性
  • 前缀:便于识别和分类
  • crypto 模块:使用加密安全的随机数生成

九、总结

OpenClaw WeChat 插件的 API 协议与数据流设计展现了以下特点:

  1. RESTful API:统一的 HTTP JSON 接口,易于理解和调试
  2. 长轮询机制:实现低延迟消息接收,同时保持简单性
  3. 类型安全:完整的 TypeScript 类型定义,编译时检查
  4. 同步缓冲:确保消息不丢失,支持断点续传
  5. 统一日志:与 OpenClaw 核心一致的日志格式,便于集中分析
  6. 安全脱敏:敏感信息自动脱敏,防止日志泄露
  7. ID 生成:时间戳+随机数的混合策略,兼顾有序性和唯一性

这些设计不仅保证了系统的稳定性和可靠性,也为开发者提供了清晰的接口契约和调试手段。在下一篇文章中,我们将探讨进阶开发与实践,包括调试技巧、性能优化和故障排查。