@tencent-weixin/openclaw-weixin 插件深度解析(一):认证与会话管理机制

26 阅读7分钟

QR 码登录、账户管理、Session Guard

在即时通讯插件的开发中,认证与会话管理是核心基础设施。本文将深入剖析 OpenClaw WeChat 插件的认证体系,包括 QR 码登录流程、账户配对机制、多账号管理以及会话状态保护等关键模块。通过详细的源码解读,帮助开发者理解其设计原理和实现细节。

一、架构概览

OpenClaw WeChat 插件的认证系统采用分层架构设计,主要包含以下核心模块:

┌─────────────────────────────────────────────────────────────┐
│                    Authentication Layer                      │
├─────────────────────────────────────────────────────────────┤
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────┐  │
│  │  QR Code     │  │   Account    │  │   Session Guard  │  │
│  │  Login       │  │   Manager    │  │                  │  │
│  └──────────────┘  └──────────────┘  └──────────────────┘  │
├─────────────────────────────────────────────────────────────┤
│                    Storage Layer                             │
├─────────────────────────────────────────────────────────────┤
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────┐  │
│  │ Account Index│  │ Account Data │  │  AllowFrom Store │  │
│  │ (accounts.json)│  │ (*.json)     │  │  (pairing)       │  │
│  └──────────────┘  └──────────────┘  └──────────────────┘  │
└─────────────────────────────────────────────────────────────┘

这种分层设计带来了几个显著优势:首先,认证逻辑与存储逻辑解耦,便于独立测试和维护;其次,支持多账号并发管理,每个账号拥有独立的凭证存储;最后,会话保护机制可以防止因频繁 API 调用导致的账号限制。

二、QR 码登录机制详解

2.1 登录流程状态机

QR 码登录是一个典型的异步流程,涉及多个状态转换。插件实现了完整的状态机来管理这个过程:

type ActiveLogin = {
  sessionKey: string;
  id: string;
  qrcode: string;
  qrcodeUrl: string;
  startedAt: number;
  botToken?: string;
  status?: "wait" | "scaned" | "confirmed" | "expired";
  error?: string;
};

登录状态包含四个阶段:

  • wait:二维码已生成,等待用户扫描
  • scaned:用户已扫码,在微信端确认中
  • confirmed:用户确认登录,获取到 bot_token
  • expired:二维码过期,需要刷新

这种设计使得登录过程可以被中断和恢复,支持在 CLI 和 Gateway 两种模式下使用。

2.2 二维码获取与长轮询

登录流程始于向微信服务器申请二维码。插件通过 fetchQRCode 函数发起请求:

async function fetchQRCode(apiBaseUrl: string, botType: string): Promise<QRCodeResponse> {
  const base = apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`;
  const url = new URL(`ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`, base);
  logger.info(`Fetching QR code from: ${url.toString()}`);

  const headers: Record<string, string> = {};
  const routeTag = loadConfigRouteTag();
  if (routeTag) {
    headers.SKRouteTag = routeTag;
  }

  const response = await fetch(url.toString(), { headers });
  if (!response.ok) {
    const body = await response.text().catch(() => "(unreadable)");
    logger.error(`QR code fetch failed: ${response.status} ${response.statusText} body=${body}`);
    throw new Error(`Failed to fetch QR code: ${response.status} ${response.statusText}`);
  }
  return await response.json();
}

这里有几个值得注意的设计点。首先是 SKRouteTag 请求头的支持,这允许通过配置指定路由标签,在多租户或代理场景下非常有用。其次是详细的错误日志记录,包括状态码和响应体,便于问题排查。

获取二维码后,插件进入长轮询状态检查阶段:

async function pollQRStatus(apiBaseUrl: string, qrcode: string): Promise<StatusResponse> {
  const base = apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`;
  const url = new URL(`ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, base);
  logger.debug(`Long-poll QR status from: ${url.toString()}`);

  const headers: Record<string, string> = {
    "iLink-App-ClientVersion": "1",
  };
  const routeTag = loadConfigRouteTag();
  if (routeTag) {
    headers.SKRouteTag = routeTag;
  }

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), QR_LONG_POLL_TIMEOUT_MS);
  try {
    const response = await fetch(url.toString(), { headers, signal: controller.signal });
    clearTimeout(timer);
    logger.debug(`pollQRStatus: HTTP ${response.status}, reading body...`);
    const rawText = await response.text();
    logger.debug(`pollQRStatus: body=${rawText.substring(0, 200)}`);
    if (!response.ok) {
      logger.error(`QR status poll failed: ${response.status} ${response.statusText} body=${rawText}`);
      throw new Error(`Failed to poll QR status: ${response.status} ${response.statusText}`);
    }
    return JSON.parse(rawText) as StatusResponse;
  } catch (err) {
    clearTimeout(timer);
    if (err instanceof Error && err.name === "AbortError") {
      logger.debug(`pollQRStatus: client-side timeout after ${QR_LONG_POLL_TIMEOUT_MS}ms, returning wait`);
      return { status: "wait" };
    }
    throw err;
  }
}

长轮询的实现使用了 AbortController 来处理超时。客户端设置 35 秒的超时时间,如果服务器在此时间内没有返回,则视为正常的长轮询超时,返回 "wait" 状态继续下一轮轮询。这种设计避免了保持长连接导致的资源浪费,同时保证了实时性。

2.3 登录会话管理与自动刷新

登录会话在内存中通过 activeLogins Map 进行管理:

const activeLogins = new Map<string, ActiveLogin>();
const ACTIVE_LOGIN_TTL_MS = 5 * 60_000;

每个登录会话有 5 分钟的有效期。插件实现了自动清理机制来防止内存泄漏:

function purgeExpiredLogins(): void {
  for (const [id, login] of activeLogins) {
    if (!isLoginFresh(login)) {
      activeLogins.delete(id);
    }
  }
}

function isLoginFresh(login: ActiveLogin): boolean {
  return Date.now() - login.startedAt < ACTIVE_LOGIN_TTL_MS;
}

更重要的是,插件支持二维码自动刷新机制。当二维码过期时,系统会自动重新获取新的二维码,最多尝试 3 次:

case "expired": {
  qrRefreshCount++;
  if (qrRefreshCount > MAX_QR_REFRESH_COUNT) {
    logger.warn(
      `waitForWeixinLogin: QR expired ${MAX_QR_REFRESH_COUNT} times, giving up sessionKey=${opts.sessionKey}`,
    );
    activeLogins.delete(opts.sessionKey);
    return {
      connected: false,
      message: "登录超时:二维码多次过期,请重新开始登录流程。",
    };
  }

  process.stdout.write(`\n⏳ 二维码已过期,正在刷新...(${qrRefreshCount}/${MAX_QR_REFRESH_COUNT})\n`);
  
  try {
    const botType = opts.botType || DEFAULT_ILINK_BOT_TYPE;
    const qrResponse = await fetchQRCode(opts.apiBaseUrl, botType);
    activeLogin.qrcode = qrResponse.qrcode;
    activeLogin.qrcodeUrl = qrResponse.qrcode_img_content;
    activeLogin.startedAt = Date.now();
    scannedPrinted = false;
    logger.info(`waitForWeixinLogin: new QR code obtained`);
    process.stdout.write(`🔄 新二维码已生成,请重新扫描\n\n`);
  } catch (refreshErr) {
    logger.error(`waitForWeixinLogin: failed to refresh QR code: ${String(refreshErr)}`);
    activeLogins.delete(opts.sessionKey);
    return {
      connected: false,
      message: `刷新二维码失败: ${String(refreshErr)}`,
    };
  }
  break;
}

这种自动刷新机制大大提升了用户体验,用户无需在二维码过期后手动重新开始整个登录流程。

2.4 登录完成与凭证保存

当用户确认登录后,服务器返回 confirmed 状态和 bot_token。插件会将这些凭证持久化存储:

case "confirmed": {
  if (!statusResponse.ilink_bot_id) {
    activeLogins.delete(opts.sessionKey);
    logger.error("Login confirmed but ilink_bot_id missing from response");
    return {
      connected: false,
      message: "登录失败:服务器未返回 ilink_bot_id。",
    };
  }

  activeLogin.botToken = statusResponse.bot_token;
  activeLogins.delete(opts.sessionKey);

  logger.info(
    `✅ Login confirmed! ilink_bot_id=${statusResponse.ilink_bot_id}`,
  );

  return {
    connected: true,
    botToken: statusResponse.bot_token,
    accountId: statusResponse.ilink_bot_id,
    baseUrl: statusResponse.baseurl,
    userId: statusResponse.ilink_user_id,
    message: "✅ 与微信连接成功!",
  };
}

注意这里在登录成功后立即清理了内存中的登录会话,这是为了防止凭证在内存中长时间驻留带来的安全风险。

三、账户管理系统

3.1 账户索引与数据存储

OpenClaw WeChat 插件支持多账号管理,每个账号拥有独立的凭证文件。账户系统采用双层存储结构:

账户索引(accounts.json):记录所有已登录的账号 ID 列表

function resolveAccountIndexPath(): string {
  return path.join(resolveWeixinStateDir(), "accounts.json");
}

export function listIndexedWeixinAccountIds(): string[] {
  const filePath = resolveAccountIndexPath();
  try {
    if (!fs.existsSync(filePath)) return [];
    const raw = fs.readFileSync(filePath, "utf-8");
    const parsed = JSON.parse(raw);
    if (!Array.isArray(parsed)) return [];
    return parsed.filter((id): id is string => typeof id === "string" && id.trim() !== "");
  } catch {
    return [];
  }
}

账户数据文件({accountId}.json):存储每个账号的详细凭证信息

export type WeixinAccountData = {
  token?: string;
  savedAt?: string;
  baseUrl?: string;
  userId?: string;
};

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

这种分离设计使得账号列表的读取非常轻量,而详细的凭证数据只在需要时才加载。

3.2 账户数据的安全存储

凭证保存时,插件采取了多项安全措施:

export function saveWeixinAccount(
  accountId: string,
  update: { token?: string; baseUrl?: string; userId?: string },
): void {
  const dir = resolveAccountsDir();
  fs.mkdirSync(dir, { recursive: true });

  const existing = loadWeixinAccount(accountId) ?? {};

  const token = update.token?.trim() || existing.token;
  const baseUrl = update.baseUrl?.trim() || existing.baseUrl;
  const userId =
    update.userId !== undefined
      ? update.userId.trim() || undefined
      : existing.userId?.trim() || undefined;

  const data: WeixinAccountData = {
    ...(token ? { token, savedAt: new Date().toISOString() } : {}),
    ...(baseUrl ? { baseUrl } : {}),
    ...(userId ? { userId } : {}),
  };

  const filePath = resolveAccountPath(accountId);
  fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
  try {
    fs.chmodSync(filePath, 0o600);
  } catch {
    // best-effort
  }
}

关键安全特性包括:

  1. 文件权限控制:使用 chmod 0o600 确保只有文件所有者可以读写
  2. 数据合并策略:新数据与现有数据合并,避免意外覆盖
  3. 时间戳记录:记录凭证保存时间,便于审计和过期检查

3.3 向后兼容性设计

插件在演进过程中经历了从单账号到多账号的架构变更。为了保证现有用户的平滑升级,实现了多层兼容性回退:

export function loadWeixinAccount(accountId: string): WeixinAccountData | null {
  // Primary: try given accountId (normalized IDs written after this change).
  const primary = readAccountFile(resolveAccountPath(accountId));
  if (primary) return primary;

  // Compatibility: if the given ID is normalized, derive the old raw filename
  // (e.g. "b0f5860fdecb-im-bot" → "b0f5860fdecb@im.bot") for existing installs.
  const rawId = deriveRawAccountId(accountId);
  if (rawId) {
    const compat = readAccountFile(resolveAccountPath(rawId));
    if (compat) return compat;
  }

  // Legacy fallback: read token from old single-account credentials file.
  const token = loadLegacyToken();
  if (token) return { token };

  return null;
}

兼容性层级包括:

  1. 主路径:使用规范化后的账号 ID 读取
  2. 兼容路径:将规范化 ID 还原为原始格式读取(处理 @ 符号被替换为 - 的情况)
  3. 遗留路径:从旧的单账号凭证文件读取

这种设计确保了任何历史版本的凭证数据都能被正确加载。

3.4 账号配置解析

账号解析时,插件会合并配置文件和存储的凭证数据:

export type ResolvedWeixinAccount = {
  accountId: string;
  baseUrl: string;
  cdnBaseUrl: string;
  token?: string;
  enabled: boolean;
  configured: boolean;
  name?: string;
};

export function resolveWeixinAccount(
  cfg: OpenClawConfig,
  accountId?: string | null,
): ResolvedWeixinAccount {
  const raw = accountId?.trim();
  if (!raw) {
    throw new Error("weixin: accountId is required (no default account)");
  }
  const id = normalizeAccountId(raw);
  const section = cfg.channels?.["openclaw-weixin"] as WeixinSectionConfig | undefined;
  const accountCfg: WeixinAccountConfig = section?.accounts?.[id] ?? section ?? {};

  const accountData = loadWeixinAccount(id);
  const token = accountData?.token?.trim() || undefined;
  const stateBaseUrl = accountData?.baseUrl?.trim() || "";

  return {
    accountId: id,
    baseUrl: stateBaseUrl || DEFAULT_BASE_URL,
    cdnBaseUrl: accountCfg.cdnBaseUrl?.trim() || CDN_BASE_URL,
    token,
    enabled: accountCfg.enabled !== false,
    configured: Boolean(token),
    name: accountCfg.name?.trim() || undefined,
  };
}

解析逻辑遵循以下优先级:

  1. baseUrl:存储的 baseUrl → 配置中的 baseUrl → 默认值
  2. cdnBaseUrl:配置中的 cdnBaseUrl → 默认值
  3. enabled:默认为 true,除非显式设置为 false
  4. configured:基于是否存在有效 token 判断

四、用户配对机制

4.1 AllowFrom 文件系统

OpenClaw 框架采用"配对"模式管理用户授权——只有经过授权的用户才能与 Bot 交互。插件通过 pairing.ts 模块与框架的授权系统对接:

export function resolveFrameworkAllowFromPath(accountId: string): string {
  const base = safeKey("openclaw-weixin");
  const safeAccount = safeKey(accountId);
  return path.join(resolveCredentialsDir(), `${base}-${safeAccount}-allowFrom.json`);
}

文件路径遵循框架规范:{channel}-{accountId}-allowFrom.json

4.2 用户注册与文件锁

用户注册时需要写入 allowFrom 文件,为了防止并发冲突,插件使用了文件锁机制:

const LOCK_OPTIONS = {
  retries: { retries: 3, factor: 2, minTimeout: 100, maxTimeout: 2000 },
  stale: 10_000,
};

export async function registerUserInFrameworkStore(params: {
  accountId: string;
  userId: string;
}): Promise<{ changed: boolean }> {
  const { accountId, userId } = params;
  const trimmedUserId = userId.trim();
  if (!trimmedUserId) return { changed: false };

  const filePath = resolveFrameworkAllowFromPath(accountId);
  const dir = path.dirname(filePath);
  fs.mkdirSync(dir, { recursive: true });

  if (!fs.existsSync(filePath)) {
    const initial: AllowFromFileContent = { version: 1, allowFrom: [] };
    fs.writeFileSync(filePath, JSON.stringify(initial, null, 2), "utf-8");
  }

  return await withFileLock(filePath, LOCK_OPTIONS, async () => {
    let content: AllowFromFileContent = { version: 1, allowFrom: [] };
    try {
      const raw = fs.readFileSync(filePath, "utf-8");
      const parsed = JSON.parse(raw) as AllowFromFileContent;
      if (Array.isArray(parsed.allowFrom)) {
        content = parsed;
      }
    } catch {
      // If read/parse fails, start fresh
    }

    if (content.allowFrom.includes(trimmedUserId)) {
      return { changed: false };
    }

    content.allowFrom.push(trimmedUserId);
    fs.writeFileSync(filePath, JSON.stringify(content, null, 2), "utf-8");
    logger.info(
      `registerUserInFrameworkStore: added userId=${trimmedUserId} accountId=${accountId}`,
    );
    return { changed: true };
  });
}

文件锁配置采用了指数退避策略:

  • 最多重试 3 次
  • 退避因子为 2
  • 最小等待 100ms,最大 2000ms
  • 锁文件 10 秒后视为过期

这种设计在高并发场景下能有效避免文件写入冲突,同时防止死锁。

五、会话保护与限流机制

5.1 Session Guard 设计

微信服务器在检测到异常行为时会返回特定的错误码(-14 表示会话过期),如果插件继续频繁请求可能导致账号被限制。为此,插件实现了 Session Guard 机制:

const SESSION_PAUSE_DURATION_MS = 60 * 60 * 1000;
export const SESSION_EXPIRED_ERRCODE = -14;

const pauseUntilMap = new Map<string, number>();

export function pauseSession(accountId: string): void {
  const until = Date.now() + SESSION_PAUSE_DURATION_MS;
  pauseUntilMap.set(accountId, until);
  logger.info(
    `session-guard: paused accountId=${accountId} until=${new Date(until).toISOString()}`,
  );
}

当检测到会话过期错误时,插件会自动暂停该账号的所有 API 调用 1 小时。

5.2 暂停状态检查

每次 API 调用前都会检查会话状态:

export function assertSessionActive(accountId: string): void {
  if (isSessionPaused(accountId)) {
    const remainingMin = Math.ceil(getRemainingPauseMs(accountId) / 60_000);
    throw new Error(
      `session paused for accountId=${accountId}, ${remainingMin} min remaining (errcode ${SESSION_EXPIRED_ERRCODE})`,
    );
  }
}

这个检查在消息发送流程中被调用:

async function sendWeixinOutbound(params: {
  cfg: OpenClawConfig;
  to: string;
  text: string;
  accountId?: string | null;
  contextToken?: string;
  mediaUrl?: string;
}): Promise<{ channel: string; messageId: string }> {
  const account = resolveWeixinAccount(params.cfg, params.accountId);
  const aLog = logger.withAccount(account.accountId);
  assertSessionActive(account.accountId);
  if (!account.configured) {
    aLog.error(`sendWeixinOutbound: account not configured`);
    throw new Error("weixin not configured: please run `openclaw channels login --channel openclaw-weixin`");
  }
  // ... 发送逻辑
}

5.3 自动恢复机制

暂停状态是自动过期的,无需手动干预:

export function isSessionPaused(accountId: string): boolean {
  const until = pauseUntilMap.get(accountId);
  if (until === undefined) return false;
  if (Date.now() >= until) {
    pauseUntilMap.delete(accountId);
    return false;
  }
  return true;
}

当暂停时间到期后,系统会自动清理该账号的暂停状态,恢复正常服务。

六、运行时上下文管理

6.1 全局运行时对象

插件通过全局变量管理运行时上下文:

let pluginRuntime: PluginRuntime | null = null;

export function setWeixinRuntime(next: PluginRuntime): void {
  pluginRuntime = next;
  logger.info(`[runtime] setWeixinRuntime called, runtime set successfully`);
}

export function getWeixinRuntime(): PluginRuntime {
  if (!pluginRuntime) {
    throw new Error("Weixin runtime not initialized");
  }
  return pluginRuntime;
}

6.2 异步等待机制

考虑到运行时可能在某些场景下尚未初始化,插件提供了异步等待机制:

const WAIT_INTERVAL_MS = 100;
const DEFAULT_TIMEOUT_MS = 10_000;

export async function waitForWeixinRuntime(
  timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<PluginRuntime> {
  const start = Date.now();
  while (!pluginRuntime) {
    if (Date.now() - start > timeoutMs) {
      throw new Error("Weixin runtime initialization timeout");
    }
    await new Promise((resolve) => setTimeout(resolve, WAIT_INTERVAL_MS));
  }
  return pluginRuntime;
}

6.3 多渠道运行时解析

在 Gateway 模式下,运行时可能通过上下文注入,插件优先使用上下文中的运行时:

export async function resolveWeixinChannelRuntime(params: {
  channelRuntime?: PluginChannelRuntime;
  waitTimeoutMs?: number;
}): Promise<PluginChannelRuntime> {
  if (params.channelRuntime) {
    logger.debug("[runtime] channelRuntime from gateway context");
    return params.channelRuntime;
  }
  if (pluginRuntime) {
    logger.debug("[runtime] channelRuntime from register() global");
    return pluginRuntime.channel;
  }
  logger.warn(
    "[runtime] no channelRuntime on ctx and no global runtime yet; waiting for register()",
  );
  const pr = await waitForWeixinRuntime(params.waitTimeoutMs ?? DEFAULT_TIMEOUT_MS);
  return pr.channel;
}

这种多层回退机制确保了插件在不同部署模式下都能正确获取运行时上下文。

七、Context Token 管理

7.1 上下文令牌的作用

Context Token 是微信 API 的重要安全机制,每个入站消息都会附带一个唯一的 context_token,出站回复时必须携带相同的 token 才能被服务器接受。

7.2 内存缓存实现

插件使用内存 Map 缓存 context token:

const contextTokenStore = new Map<string, string>();

function contextTokenKey(accountId: string, userId: string): string {
  return `${accountId}:${userId}`;
}

export function setContextToken(accountId: string, userId: string, token: string): void {
  const k = contextTokenKey(accountId, userId);
  logger.debug(`setContextToken: key=${k}`);
  contextTokenStore.set(k, token);
}

export function getContextToken(accountId: string, userId: string): string | undefined {
  const k = contextTokenKey(accountId, userId);
  const val = contextTokenStore.get(k);
  logger.debug(
    `getContextToken: key=${k} found=${val !== undefined} storeSize=${contextTokenStore.size}`,
  );
  return val;
}

7.3 设计考量

Context Token 不持久化存储,仅保存在内存中。这是因为:

  1. Token 具有时效性,通常只对一个会话有效
  2. 每次新的入站消息都会更新 token
  3. 持久化会增加复杂性且没有实际收益

这种设计简化了实现,同时满足了功能需求。

八、登录流程集成

8.1 CLI 模式登录

在 CLI 模式下,登录流程通过 auth.login 钩子实现:

auth: {
  login: async ({ cfg, accountId, verbose, runtime }) => {
    const account = resolveWeixinAccount(cfg, accountId);

    const log = (msg: string) => {
      runtime?.log?.(msg);
    };

    log(`正在启动微信扫码登录...`);
    const startResult: WeixinQrStartResult = await startWeixinLoginWithQr({
      accountId: account.accountId,
      apiBaseUrl: account.baseUrl,
      botType: DEFAULT_ILINK_BOT_TYPE,
      verbose: Boolean(verbose),
    });

    if (!startResult.qrcodeUrl) {
      log(startResult.message);
      throw new Error(startResult.message);
    }

    log(`\n使用微信扫描以下二维码,以完成连接:\n`);
    try {
      const qrcodeterminal = await import("qrcode-terminal");
      await new Promise<void>((resolve) => {
        qrcodeterminal.default.generate(startResult.qrcodeUrl!, { small: true }, (qr: string) => {
          console.log(qr);
          resolve();
        });
      });
    } catch (err) {
      log(`二维码链接: ${startResult.qrcodeUrl}`);
    }

    const waitResult: WeixinQrWaitResult = await waitForWeixinLogin({
      sessionKey: startResult.sessionKey,
      apiBaseUrl: account.baseUrl,
      timeoutMs: 480_000,
      verbose: Boolean(verbose),
      botType: DEFAULT_ILINK_BOT_TYPE,
    });

    if (waitResult.connected && waitResult.botToken && waitResult.accountId) {
      const normalizedId = normalizeAccountId(waitResult.accountId);
      saveWeixinAccount(normalizedId, {
        token: waitResult.botToken,
        baseUrl: waitResult.baseUrl,
        userId: waitResult.userId,
      });
      registerWeixinAccountId(normalizedId);
      log(`\n✅ 与微信连接成功!`);
    } else {
      throw new Error(waitResult.message);
    }
  },
}

8.2 Gateway 模式登录

Gateway 模式支持通过 HTTP API 进行登录,分为两个步骤:

步骤 1:获取二维码

gateway: {
  loginWithQrStart: async ({ accountId, force, timeoutMs, verbose }) => {
    const savedBaseUrl = accountId ? loadWeixinAccount(accountId)?.baseUrl?.trim() : "";
    const result: WeixinQrStartResult = await startWeixinLoginWithQr({
      accountId: accountId ?? undefined,
      apiBaseUrl: savedBaseUrl || DEFAULT_BASE_URL,
      botType: DEFAULT_ILINK_BOT_TYPE,
      force,
      timeoutMs,
      verbose,
    });
    return {
      qrDataUrl: result.qrcodeUrl,
      message: result.message,
      sessionKey: result.sessionKey,
    };
  },
}

步骤 2:等待登录结果

loginWithQrWait: async (params) => {
  const sessionKey = (params as { sessionKey?: string }).sessionKey || params.accountId || "";
  const savedBaseUrl = params.accountId
    ? loadWeixinAccount(params.accountId)?.baseUrl?.trim()
    : "";
  const result: WeixinQrWaitResult = await waitForWeixinLogin({
    sessionKey,
    apiBaseUrl: savedBaseUrl || DEFAULT_BASE_URL,
    timeoutMs: params.timeoutMs,
  });

  if (result.connected && result.botToken && result.accountId) {
    const normalizedId = normalizeAccountId(result.accountId);
    saveWeixinAccount(normalizedId, {
      token: result.botToken,
      baseUrl: result.baseUrl,
      userId: result.userId,
    });
    registerWeixinAccountId(normalizedId);
  }

  return {
    connected: result.connected,
    message: result.message,
    accountId: result.accountId,
  };
}

Gateway 模式的分步设计允许前端应用实现更好的用户体验,比如在二维码展示页面实时轮询登录状态。

九、总结

OpenClaw WeChat 插件的认证与会话管理系统展现了成熟的工程实践:

  1. 分层架构:清晰的模块划分使得代码易于理解和维护
  2. 状态管理:完整的状态机设计确保登录流程可靠执行
  3. 安全存储:文件权限控制和敏感信息脱敏保护用户凭证
  4. 兼容演进:多层回退机制保证平滑升级
  5. 容错设计:自动刷新、会话保护等机制提升系统稳定性
  6. 多模式支持:CLI 和 Gateway 两种模式满足不同部署需求

这些设计不仅适用于微信插件,也为其他即时通讯渠道的集成提供了有价值的参考模式。在下一篇文章中,我们将深入探讨消息处理系统的架构与实现。