[
  {
    "path": ".gitignore",
    "content": "node_modules\ndist\n.env\n*.log\npackage-lock.json\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\n## 0.5.0 (2026-04-19)\n\n### weixin-agent-sdk\n\n- **fix**: `start(agent)` 现在会立即返回 `Bot` 实例，并新增 `bot.wait()` 用于等待后台消息循环结束，修复主动发送示例在停止前拿不到 `bot` 的问题\n\n## 0.4.0 (2026-04-05)\n\n### weixin-agent-sdk\n\n- **feat**: `start(agent)` 现在返回 `Bot` 实例，支持通过 `bot.sendMessage()` 主动向当前登录账号发送文本、图片、视频和文件\n- **fix**: 会话过期时给出更明确的重新登录提示\n- **docs**: 补充 `Bot.sendMessage()` 的使用说明和注意事项\n\n## 0.5.0 (2026-03-24)\n\n### weixin-acp\n\n- **feat**: 启动 agent 时自动检测登录状态，未登录则自动触发扫码登录\n- **feat**: 新增 `logout` 命令，支持清除已保存的登录凭证\n- **fix**: 定期重发 typing 状态，防止超时\n- **fix**: 改进 ACP tool-call 日志的 fallback 链\n- **fix**: 修复 ACP 权限自动审批中的 optionId 字段\n\n### weixin-agent-sdk\n\n- **feat**: 新增 `isLoggedIn()` 函数，检查是否已登录\n- **feat**: 新增 `logout()` 函数，清除所有账号凭证\n\n## 0.4.0 (2026-03-24)\n\n### weixin-acp\n\n- **feat**: 内置 `claude-code` 和 `codex` 快捷命令，无需单独安装 `@zed-industries/claude-agent-acp` 和 `@zed-industries/codex-acp`\n- **feat**: 添加 `/clear` 斜杠命令，支持重置对话会话\n- **fix**: Ctrl+C 时立即中断长轮询请求，不再卡住等待超时\n- **fix**: ACP 子进程退出时自动清除会话缓存\n- **fix**: 改进二维码显示的中文提示，始终显示 URL 链接\n\n## 0.2.0 (2026-03-23)\n\n### weixin-acp\n\n- **feat**: 支持 Windows 平台（修复 `child_process.spawn` 对特殊后缀的识别问题）\n- **fix**: 修复 README 中 shell 示例的注释语法\n\n### weixin-agent-sdk\n\n- 首次发布到 npm\n\n## 0.1.0 (2026-03-22)\n\n- 初始发布\n- **weixin-agent-sdk**: 微信 AI Agent 桥接 SDK，支持文本、图片、语音、视频、文件收发\n- **weixin-acp**: ACP (Agent Client Protocol) 适配器，可接入任意 ACP 兼容 agent\n- **example-openai**: 基于 OpenAI 的完整示例，支持多轮对话和图片输入\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2026 wong2\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# weixin-agent-sdk\n\n> 本项目非微信官方项目，代码由 [@tencent-weixin/openclaw-weixin](https://npmx.dev/package/@tencent-weixin/openclaw-weixin) 改造而来，仅供学习交流使用。\n\n微信 AI Agent 桥接框架 —— 通过简单的 Agent 接口，将任意 AI 后端接入微信。\n\n## 项目结构\n\n```\npackages/\n  sdk/                  weixin-agent-sdk —— 微信桥接 SDK\n  weixin-acp/           ACP (Agent Client Protocol) 适配器\n  example-openai/       基于 OpenAI 的示例\n```\n\n## 通过 ACP 接入 Claude Code, Codex, kimi-cli 等 Agent\n\n[ACP (Agent Client Protocol)](https://agentclientprotocol.com/) 是一个开放的 Agent 通信协议。如果你已有兼容 ACP 的 agent，可以直接通过 [`weixin-acp`](https://www.npmjs.com/package/weixin-acp) 接入微信，无需编写任何代码。\n\n\n### Claude Code\n\n```bash\nnpx weixin-acp claude-code\n```\n\n### Codex\n\n```bash\nnpx weixin-acp codex\n```\n\n### 其它 ACP Agent\n\n比如 kimi-cli：\n\n```bash\nnpx weixin-acp start -- kimi acp\n```\n\n`--` 后面的部分就是你的 ACP agent 启动命令，`weixin-acp` 会自动以子进程方式启动它，通过 JSON-RPC over stdio 进行通信。\n\n更多 ACP 兼容 agent 请参考 [ACP agent 列表](https://agentclientprotocol.com/get-started/agents)。\n\n## 自定义 Agent\n\nSDK 主要导出三样东西：\n\n- **`Agent`** 接口 —— 实现它就能接入微信\n- **`login()`** —— 扫码登录\n- **`start(agent)`** —— 启动消息循环，立即返回可主动发消息的 `Bot`\n\n### Agent 接口\n\n```typescript\ninterface Agent {\n  chat(request: ChatRequest): Promise<ChatResponse>;\n}\n\ninterface ChatRequest {\n  conversationId: string;         // 用户标识，可用于维护多轮对话\n  text: string;                   // 文本内容\n  media?: {                       // 附件（图片/语音/视频/文件）\n    type: \"image\" | \"audio\" | \"video\" | \"file\";\n    filePath: string;             // 本地文件路径（已下载解密）\n    mimeType: string;\n    fileName?: string;\n  };\n}\n\ninterface ChatResponse {\n  text?: string;                  // 回复文本（支持 markdown，发送前自动转纯文本）\n  media?: {                       // 回复媒体\n    type: \"image\" | \"video\" | \"file\";\n    url: string;                  // 本地路径或 HTTPS URL\n    fileName?: string;\n  };\n}\n```\n\n### 最简示例\n\n```typescript\nimport { login, start, type Agent } from \"weixin-agent-sdk\";\n\nconst echo: Agent = {\n  async chat(req) {\n    return { text: `你说了: ${req.text}` };\n  },\n};\n\nawait login();\nconst bot = start(echo);\nawait bot.wait();\n```\n\n### 完整示例（自己管理对话历史）\n\n```typescript\nimport { login, start, type Agent } from \"weixin-agent-sdk\";\n\nconst conversations = new Map<string, string[]>();\n\nconst myAgent: Agent = {\n  async chat(req) {\n    const history = conversations.get(req.conversationId) ?? [];\n    history.push(req.text);\n\n    // 调用你的 AI 服务...\n    const reply = await callMyAI(history);\n\n    history.push(reply);\n    conversations.set(req.conversationId, history);\n    return { text: reply };\n  },\n};\n\nawait login();\nconst bot = start(myAgent);\nawait bot.wait();\n```\n\n### 主动发送消息\n\n`start()` 会立即返回 `Bot` 实例。`Bot` 提供了 `sendMessage()`，可以在收到微信消息之外，主动给当前登录用户发送内容；如果是 CLI/脚本程序，可以用 `bot.wait()` 等待消息循环结束。\n\n```typescript\nimport { login, start, type Agent } from \"weixin-agent-sdk\";\n\nconst agent: Agent = {\n  async chat(req) {\n    if (req.text === \"ping\") {\n      return { text: \"pong\" };\n    }\n    return { text: `收到：${req.text}` };\n  },\n};\n\nawait login();\nconst bot = start(agent);\n\nsetInterval(() => {\n  void bot.sendMessage(\"定时提醒：记得查看最新状态\");\n}, 60_000);\n\nawait bot.wait();\n```\n\n也可以主动发送完整的 `ChatResponse`，包括图片、视频或文件：\n\n```typescript\nawait bot.sendMessage({\n  text: \"这是最新报表\",\n  media: {\n    type: \"file\",\n    url: \"./reports/daily.pdf\",\n    fileName: \"daily.pdf\",\n  },\n});\n```\n\n注意事项：\n\n- 主动发送依赖微信下发的 `context_token`\n- 需要在 `start()` 运行期间，至少先收到过当前账号的一条入站消息\n- `context_token` 有时效，可能是 24 小时；过期后需要再次收到新消息才能继续主动发送\n\n### OpenAI 示例\n\n`packages/example-openai/` 是一个完整的 OpenAI Agent 实现，支持多轮对话和图片输入：\n\n```bash\npnpm install\n\n# 扫码登录微信\npnpm run login -w packages/example-openai\n\n# 启动 bot\nOPENAI_API_KEY=sk-xxx pnpm run start -w packages/example-openai\n```\n\n支持的环境变量：\n\n| 变量 | 必填 | 说明 |\n|------|------|------|\n| `OPENAI_API_KEY` | 是 | OpenAI API Key |\n| `OPENAI_BASE_URL` | 否 | 自定义 API 地址（兼容 OpenAI 接口的第三方服务） |\n| `OPENAI_MODEL` | 否 | 模型名称，默认 `gpt-5.4` |\n| `SYSTEM_PROMPT` | 否 | 系统提示词 |\n\n## 支持的消息类型\n\n### 接收（微信 → Agent）\n\n| 类型 | `media.type` | 说明 |\n|------|-------------|------|\n| 文本 | — | `request.text` 直接拿到文字 |\n| 图片 | `image` | 自动从 CDN 下载解密，`filePath` 指向本地文件 |\n| 语音 | `audio` | SILK 格式自动转 WAV（需安装 `silk-wasm`） |\n| 视频 | `video` | 自动下载解密 |\n| 文件 | `file` | 自动下载解密，保留原始文件名 |\n| 引用消息 | — | 被引用的文本拼入 `request.text`，被引用的媒体作为 `media` 传入 |\n| 语音转文字 | — | 微信侧转写的文字直接作为 `request.text` |\n\n### 发送（Agent → 微信）\n\n| 类型 | 用法 |\n|------|------|\n| 文本 | 返回 `{ text: \"...\" }` |\n| 图片 | 返回 `{ media: { type: \"image\", url: \"/path/to/img.png\" } }` |\n| 视频 | 返回 `{ media: { type: \"video\", url: \"/path/to/video.mp4\" } }` |\n| 文件 | 返回 `{ media: { type: \"file\", url: \"/path/to/doc.pdf\" } }` |\n| 文本 + 媒体 | `text` 和 `media` 同时返回，文本作为附带说明发送 |\n| 远程图片 | `url` 填 HTTPS 链接，SDK 自动下载后上传到微信 CDN |\n| 主动发送 | 通过 `const bot = start(agent)` 后调用 `bot.sendMessage(...)` |\n\n## 内置斜杠命令\n\n在微信中发送以下命令：\n\n- `/echo <消息>` —— 直接回复（不经过 Agent），附带通道耗时统计\n- `/toggle-debug` —— 开关 debug 模式，启用后每条回复追加全链路耗时\n\n## 技术细节\n\n- 使用 **长轮询** (`getUpdates`) 接收消息，无需公网服务器\n- 媒体文件通过微信 CDN 中转，**AES-128-ECB** 加密传输\n- 单账号模式：每次 `login` 覆盖之前的账号\n- 断点续传：`get_updates_buf` 持久化到 `~/.openclaw/`，重启后从上次位置继续\n- 会话过期自动重连（errcode -14 触发 1 小时冷却后恢复）\n- Node.js >= 22\n\n## Star History\n\n<a href=\"https://www.star-history.com/?repos=wong2%2Fweixin-agent-sdk&type=date&legend=top-left\">\n <picture>\n   <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://api.star-history.com/image?repos=wong2/weixin-agent-sdk&type=date&theme=dark&legend=top-left\" />\n   <source media=\"(prefers-color-scheme: light)\" srcset=\"https://api.star-history.com/image?repos=wong2/weixin-agent-sdk&type=date&legend=top-left\" />\n   <img alt=\"Star History Chart\" src=\"https://api.star-history.com/image?repos=wong2/weixin-agent-sdk&type=date&legend=top-left\" />\n </picture>\n</a>\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"private\": true,\n  \"scripts\": {\n    \"typecheck\": \"pnpm -r run typecheck\"\n  }\n}\n"
  },
  {
    "path": "packages/example-openai/main.ts",
    "content": "#!/usr/bin/env node\n\n/**\n * WeChat + OpenAI example.\n *\n * Usage:\n *   npx tsx main.ts login              # QR-code login\n *   npx tsx main.ts start              # Start bot\n *\n * Environment variables:\n *   OPENAI_API_KEY      — Required\n *   OPENAI_BASE_URL     — Optional: custom API base URL\n *   OPENAI_MODEL        — Optional: model name (default: gpt-5.4)\n *   SYSTEM_PROMPT       — Optional: system prompt for the agent\n */\n\nimport { login, start } from \"weixin-agent-sdk\";\n\nimport { OpenAIAgent } from \"./src/openai-agent.js\";\n\nconst command = process.argv[2];\n\nasync function main() {\n  switch (command) {\n    case \"login\": {\n      await login();\n      break;\n    }\n\n    case \"start\": {\n      const apiKey = process.env.OPENAI_API_KEY;\n      if (!apiKey) {\n        console.error(\"错误: 请设置 OPENAI_API_KEY 环境变量\");\n        process.exit(1);\n      }\n\n      const agent = new OpenAIAgent({\n        apiKey,\n        baseURL: process.env.OPENAI_BASE_URL,\n        model: process.env.OPENAI_MODEL,\n        systemPrompt: process.env.SYSTEM_PROMPT,\n      });\n\n      // Graceful shutdown\n      const ac = new AbortController();\n      process.on(\"SIGINT\", () => {\n        console.log(\"\\n正在停止...\");\n        ac.abort();\n      });\n      process.on(\"SIGTERM\", () => ac.abort());\n\n      const bot = start(agent, { abortSignal: ac.signal });\n      await bot.wait();\n      break;\n    }\n\n    default:\n      console.log(`weixin-agent-openai — 微信 + OpenAI 示例\n\n用法:\n  npx tsx main.ts login    扫码登录微信\n  npx tsx main.ts start    启动 bot\n\n环境变量:\n  OPENAI_API_KEY           OpenAI API Key (必填)\n  OPENAI_BASE_URL          自定义 API 地址\n  OPENAI_MODEL             模型名称 (默认 gpt-5.4)\n  SYSTEM_PROMPT            系统提示词`);\n      break;\n  }\n}\n\nmain().catch((err) => {\n  console.error(err);\n  process.exit(1);\n});\n"
  },
  {
    "path": "packages/example-openai/package.json",
    "content": "{\n  \"name\": \"example-openai\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"WeChat + OpenAI example using weixin-agent-sdk\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"login\": \"tsx main.ts login\",\n    \"start\": \"tsx main.ts start\",\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"dependencies\": {\n    \"openai\": \"^4.0.0\",\n    \"weixin-agent-sdk\": \"workspace:*\"\n  },\n  \"devDependencies\": {\n    \"tsx\": \"^4.0.0\",\n    \"typescript\": \"^5.8.0\"\n  }\n}\n"
  },
  {
    "path": "packages/example-openai/src/openai-agent.ts",
    "content": "/**\n * Example Agent implementation using the OpenAI Chat Completions API.\n *\n * Supports:\n *   - Multi-turn conversation (per-user message history)\n *   - Vision (image input via base64)\n *   - Configurable model, system prompt, and base URL\n */\nimport fs from \"node:fs/promises\";\nimport path from \"node:path\";\n\nimport OpenAI from \"openai\";\nimport type { Agent, ChatRequest, ChatResponse } from \"weixin-agent-sdk\";\n\nexport type OpenAIAgentOptions = {\n  apiKey: string;\n  /** Model name, defaults to \"gpt-5.4\". */\n  model?: string;\n  /** Custom base URL (for proxies or compatible APIs). */\n  baseURL?: string;\n  /** System prompt prepended to every conversation. */\n  systemPrompt?: string;\n  /** Max history messages to keep per conversation (default: 50). */\n  maxHistory?: number;\n};\n\ntype Message = OpenAI.ChatCompletionMessageParam;\n\nexport class OpenAIAgent implements Agent {\n  private client: OpenAI;\n  private model: string;\n  private systemPrompt: string | undefined;\n  private maxHistory: number;\n  private conversations = new Map<string, Message[]>();\n\n  constructor(opts: OpenAIAgentOptions) {\n    this.client = new OpenAI({ apiKey: opts.apiKey, baseURL: opts.baseURL });\n    this.model = opts.model ?? \"gpt-5.4\";\n    this.systemPrompt = opts.systemPrompt;\n    this.maxHistory = opts.maxHistory ?? 50;\n  }\n\n  async chat(request: ChatRequest): Promise<ChatResponse> {\n    const history = this.conversations.get(request.conversationId) ?? [];\n\n    // Build user message content\n    const content: OpenAI.ChatCompletionContentPart[] = [];\n\n    if (request.text) {\n      content.push({ type: \"text\", text: request.text });\n    }\n\n    if (request.media?.type === \"image\") {\n      // Send image as base64 for vision models\n      const imageData = await fs.readFile(request.media.filePath);\n      const base64 = imageData.toString(\"base64\");\n      const mimeType = request.media.mimeType || \"image/jpeg\";\n      content.push({\n        type: \"image_url\",\n        image_url: { url: `data:${mimeType};base64,${base64}` },\n      });\n    } else if (request.media) {\n      // Non-image media: describe as text attachment\n      const fileName =\n        request.media.fileName ?? path.basename(request.media.filePath);\n      content.push({\n        type: \"text\",\n        text: `[Attachment: ${request.media.type} — ${fileName}]`,\n      });\n    }\n\n    if (content.length === 0) {\n      return { text: \"\" };\n    }\n\n    const userMessage: Message = {\n      role: \"user\" as const,\n      content:\n        content.length === 1 && content[0].type === \"text\"\n          ? content[0].text\n          : content,\n    };\n    history.push(userMessage);\n\n    // Build messages array with optional system prompt\n    const messages: Message[] = [];\n    if (this.systemPrompt) {\n      messages.push({ role: \"system\", content: this.systemPrompt });\n    }\n    messages.push(...history);\n\n    const response = await this.client.chat.completions.create({\n      model: this.model,\n      messages,\n    });\n\n    const reply = response.choices[0]?.message?.content ?? \"\";\n    history.push({ role: \"assistant\", content: reply });\n\n    // Trim history to prevent unbounded growth\n    if (history.length > this.maxHistory) {\n      history.splice(0, history.length - this.maxHistory);\n    }\n\n    this.conversations.set(request.conversationId, history);\n\n    return { text: reply };\n  }\n}\n"
  },
  {
    "path": "packages/example-openai/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"NodeNext\",\n    \"moduleResolution\": \"NodeNext\",\n    \"esModuleInterop\": true,\n    \"strict\": true,\n    \"skipLibCheck\": true,\n    \"noEmit\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true\n  },\n  \"include\": [\"main.ts\", \"src/**/*.ts\", \"../sdk/src/vendor.d.ts\"],\n  \"exclude\": [\"node_modules\"]\n}\n"
  },
  {
    "path": "packages/sdk/.gitignore",
    "content": "dist\n"
  },
  {
    "path": "packages/sdk/index.ts",
    "content": "export type { Agent, ChatRequest, ChatResponse } from \"./src/agent/interface.js\";\nexport { Bot, isLoggedIn, login, logout, start } from \"./src/bot.js\";\nexport type { LoginOptions, StartOptions } from \"./src/bot.js\";\n"
  },
  {
    "path": "packages/sdk/package.json",
    "content": "{\n  \"name\": \"weixin-agent-sdk\",\n  \"version\": \"0.5.0\",\n  \"description\": \"WeChat ↔ AI Agent bridge SDK — connect any AI backend to WeChat via a simple Agent interface\",\n  \"license\": \"MIT\",\n  \"type\": \"module\",\n  \"main\": \"dist/index.mjs\",\n  \"types\": \"dist/index.d.mts\",\n  \"exports\": {\n    \".\": {\n      \"types\": \"./dist/index.d.mts\",\n      \"default\": \"./dist/index.mjs\"\n    }\n  },\n  \"files\": [\n    \"dist\"\n  ],\n  \"publishConfig\": {\n    \"registry\": \"https://registry.npmjs.org/\"\n  },\n  \"engines\": {\n    \"node\": \">=22\"\n  },\n  \"dependencies\": {\n    \"qrcode-terminal\": \"0.12.0\"\n  },\n  \"scripts\": {\n    \"build\": \"tsdown\",\n    \"dev\": \"tsdown --watch\",\n    \"prepublishOnly\": \"pnpm run build\",\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"devDependencies\": {\n    \"@types/node\": \"^22.0.0\",\n    \"tsdown\": \"^0.21.0\",\n    \"typescript\": \"^5.8.0\"\n  }\n}\n"
  },
  {
    "path": "packages/sdk/src/agent/interface.ts",
    "content": "/**\n * Agent interface — any AI backend that can handle a chat message.\n *\n * Implement this interface to connect WeChat to your own AI service.\n * The WeChat bridge calls `chat()` for each inbound message and sends\n * the returned response back to the user.\n */\n\nexport interface Agent {\n  /** Process a single message and return a reply. */\n  chat(request: ChatRequest): Promise<ChatResponse>;\n  /** Clear/reset the session for a given conversation. */\n  clearSession?(conversationId: string): void;\n}\n\nexport interface ChatRequest {\n  /** Conversation / user identifier. Use this to maintain per-user context. */\n  conversationId: string;\n  /** Text content of the message. */\n  text: string;\n  /** Attached media file (image, audio, video, or generic file). */\n  media?: {\n    type: \"image\" | \"audio\" | \"video\" | \"file\";\n    /** Local file path (already downloaded and decrypted). */\n    filePath: string;\n    /** MIME type, e.g. \"image/jpeg\", \"audio/wav\". */\n    mimeType: string;\n    /** Original filename (available for file attachments). */\n    fileName?: string;\n  };\n}\n\nexport interface ChatResponse {\n  /** Reply text (may contain markdown — will be converted to plain text before sending). */\n  text?: string;\n  /** Reply media file. */\n  media?: {\n    type: \"image\" | \"video\" | \"file\";\n    /** Local file path or HTTPS URL. */\n    url: string;\n    /** Filename hint (for file attachments). */\n    fileName?: string;\n  };\n}\n"
  },
  {
    "path": "packages/sdk/src/api/api.ts",
    "content": "import crypto from \"node:crypto\";\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nimport { loadConfigRouteTag } from \"../auth/accounts.js\";\nimport { logger } from \"../util/logger.js\";\nimport { redactBody, redactUrl } from \"../util/redact.js\";\n\nimport type {\n  BaseInfo,\n  GetUploadUrlReq,\n  GetUploadUrlResp,\n  GetUpdatesReq,\n  GetUpdatesResp,\n  SendMessageReq,\n  SendTypingReq,\n  GetConfigResp,\n} from \"./types.js\";\n\nexport type WeixinApiOptions = {\n  baseUrl: string;\n  token?: string;\n  timeoutMs?: number;\n  /** Long-poll timeout for getUpdates (server may hold the request up to this). */\n  longPollTimeoutMs?: number;\n};\n\n// ---------------------------------------------------------------------------\n// BaseInfo — attached to every outgoing CGI request\n// ---------------------------------------------------------------------------\n\nfunction readChannelVersion(): string {\n  try {\n    const dir = path.dirname(fileURLToPath(import.meta.url));\n    const pkgPath = path.resolve(dir, \"..\", \"..\", \"package.json\");\n    const pkg = JSON.parse(fs.readFileSync(pkgPath, \"utf-8\")) as { version?: string };\n    return pkg.version ?? \"unknown\";\n  } catch {\n    return \"unknown\";\n  }\n}\n\nconst CHANNEL_VERSION = readChannelVersion();\n\n/** Build the `base_info` payload included in every API request. */\nexport function buildBaseInfo(): BaseInfo {\n  return { channel_version: CHANNEL_VERSION };\n}\n\n/** Default timeout for long-poll getUpdates requests. */\nconst DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;\n/** Default timeout for regular API requests (sendMessage, getUploadUrl). */\nconst DEFAULT_API_TIMEOUT_MS = 15_000;\n/** Default timeout for lightweight API requests (getConfig, sendTyping). */\nconst DEFAULT_CONFIG_TIMEOUT_MS = 10_000;\n\nfunction ensureTrailingSlash(url: string): string {\n  return url.endsWith(\"/\") ? url : `${url}/`;\n}\n\n/** X-WECHAT-UIN header: random uint32 -> decimal string -> base64. */\nfunction randomWechatUin(): string {\n  const uint32 = crypto.randomBytes(4).readUInt32BE(0);\n  return Buffer.from(String(uint32), \"utf-8\").toString(\"base64\");\n}\n\n/** Build headers shared by both GET and POST requests. */\nfunction buildCommonHeaders(): Record<string, string> {\n  const headers: Record<string, string> = {};\n  const routeTag = loadConfigRouteTag();\n  if (routeTag) {\n    headers.SKRouteTag = routeTag;\n  }\n  return headers;\n}\n\nfunction buildHeaders(opts: { token?: string; body: string }): Record<string, string> {\n  const headers: Record<string, string> = {\n    \"Content-Type\": \"application/json\",\n    AuthorizationType: \"ilink_bot_token\",\n    \"Content-Length\": String(Buffer.byteLength(opts.body, \"utf-8\")),\n    \"X-WECHAT-UIN\": randomWechatUin(),\n    ...buildCommonHeaders(),\n  };\n  if (opts.token?.trim()) {\n    headers.Authorization = `Bearer ${opts.token.trim()}`;\n  }\n  logger.debug(\n    `requestHeaders: ${JSON.stringify({ ...headers, Authorization: headers.Authorization ? \"Bearer ***\" : undefined })}`,\n  );\n  return headers;\n}\n\n/**\n * GET fetch wrapper: send a GET request to a Weixin API endpoint with timeout + abort.\n * Query parameters should already be encoded in `endpoint`.\n * Returns the raw response text on success; throws on HTTP error or timeout.\n */\nexport async function apiGetFetch(params: {\n  baseUrl: string;\n  endpoint: string;\n  timeoutMs: number;\n  label: string;\n}): Promise<string> {\n  const base = ensureTrailingSlash(params.baseUrl);\n  const url = new URL(params.endpoint, base);\n  const hdrs = buildCommonHeaders();\n  logger.debug(`GET ${redactUrl(url.toString())}`);\n\n  const controller = new AbortController();\n  const t = setTimeout(() => controller.abort(), params.timeoutMs);\n  try {\n    const res = await fetch(url.toString(), {\n      method: \"GET\",\n      headers: hdrs,\n      signal: controller.signal,\n    });\n    clearTimeout(t);\n    const rawText = await res.text();\n    logger.debug(`${params.label} status=${res.status} raw=${redactBody(rawText)}`);\n    if (!res.ok) {\n      throw new Error(`${params.label} ${res.status}: ${rawText}`);\n    }\n    return rawText;\n  } catch (err) {\n    clearTimeout(t);\n    throw err;\n  }\n}\n\n/**\n * Common fetch wrapper: POST JSON to a Weixin API endpoint with timeout + abort.\n * Returns the raw response text on success; throws on HTTP error or timeout.\n */\nasync function apiFetch(params: {\n  baseUrl: string;\n  endpoint: string;\n  body: string;\n  token?: string;\n  timeoutMs: number;\n  label: string;\n  abortSignal?: AbortSignal;\n}): Promise<string> {\n  const base = ensureTrailingSlash(params.baseUrl);\n  const url = new URL(params.endpoint, base);\n  const hdrs = buildHeaders({ token: params.token, body: params.body });\n  logger.debug(`POST ${redactUrl(url.toString())} body=${redactBody(params.body)}`);\n\n  const controller = new AbortController();\n  const t = setTimeout(() => controller.abort(), params.timeoutMs);\n\n  // Forward external abort signal to our controller\n  const onAbort = () => controller.abort();\n  params.abortSignal?.addEventListener(\"abort\", onAbort, { once: true });\n\n  try {\n    const res = await fetch(url.toString(), {\n      method: \"POST\",\n      headers: hdrs,\n      body: params.body,\n      signal: controller.signal,\n    });\n    clearTimeout(t);\n    const rawText = await res.text();\n    logger.debug(`${params.label} status=${res.status} raw=${redactBody(rawText)}`);\n    if (!res.ok) {\n      throw new Error(`${params.label} ${res.status}: ${rawText}`);\n    }\n    return rawText;\n  } catch (err) {\n    clearTimeout(t);\n    throw err;\n  } finally {\n    params.abortSignal?.removeEventListener(\"abort\", onAbort);\n  }\n}\n\n/**\n * Long-poll getUpdates. Server should hold the request until new messages or timeout.\n *\n * On client-side timeout (no server response within timeoutMs), returns an empty response\n * with ret=0 so the caller can simply retry. This is normal for long-poll.\n */\nexport async function getUpdates(\n  params: GetUpdatesReq & {\n    baseUrl: string;\n    token?: string;\n    timeoutMs?: number;\n    abortSignal?: AbortSignal;\n  },\n): Promise<GetUpdatesResp> {\n  const timeout = params.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;\n  try {\n    const rawText = await apiFetch({\n      baseUrl: params.baseUrl,\n      endpoint: \"ilink/bot/getupdates\",\n      body: JSON.stringify({\n        get_updates_buf: params.get_updates_buf ?? \"\",\n        base_info: buildBaseInfo(),\n      }),\n      token: params.token,\n      timeoutMs: timeout,\n      label: \"getUpdates\",\n      abortSignal: params.abortSignal,\n    });\n    const resp: GetUpdatesResp = JSON.parse(rawText);\n    return resp;\n  } catch (err) {\n    // Long-poll timeout is normal; return empty response so caller can retry\n    if (err instanceof Error && err.name === \"AbortError\") {\n      logger.debug(`getUpdates: client-side timeout after ${timeout}ms, returning empty response`);\n      return { ret: 0, msgs: [], get_updates_buf: params.get_updates_buf };\n    }\n    throw err;\n  }\n}\n\n/** Get a pre-signed CDN upload URL for a file. */\nexport async function getUploadUrl(\n  params: GetUploadUrlReq & WeixinApiOptions,\n): Promise<GetUploadUrlResp> {\n  const rawText = await apiFetch({\n    baseUrl: params.baseUrl,\n    endpoint: \"ilink/bot/getuploadurl\",\n    body: JSON.stringify({\n      filekey: params.filekey,\n      media_type: params.media_type,\n      to_user_id: params.to_user_id,\n      rawsize: params.rawsize,\n      rawfilemd5: params.rawfilemd5,\n      filesize: params.filesize,\n      thumb_rawsize: params.thumb_rawsize,\n      thumb_rawfilemd5: params.thumb_rawfilemd5,\n      thumb_filesize: params.thumb_filesize,\n      no_need_thumb: params.no_need_thumb,\n      aeskey: params.aeskey,\n      base_info: buildBaseInfo(),\n    }),\n    token: params.token,\n    timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,\n    label: \"getUploadUrl\",\n  });\n  const resp: GetUploadUrlResp = JSON.parse(rawText);\n  return resp;\n}\n\n/** Send a single message downstream. */\nexport async function sendMessage(\n  params: WeixinApiOptions & { body: SendMessageReq },\n): Promise<void> {\n  await apiFetch({\n    baseUrl: params.baseUrl,\n    endpoint: \"ilink/bot/sendmessage\",\n    body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),\n    token: params.token,\n    timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,\n    label: \"sendMessage\",\n  });\n}\n\n/** Fetch bot config (includes typing_ticket) for a given user. */\nexport async function getConfig(\n  params: WeixinApiOptions & { ilinkUserId: string; contextToken?: string },\n): Promise<GetConfigResp> {\n  const rawText = await apiFetch({\n    baseUrl: params.baseUrl,\n    endpoint: \"ilink/bot/getconfig\",\n    body: JSON.stringify({\n      ilink_user_id: params.ilinkUserId,\n      context_token: params.contextToken,\n      base_info: buildBaseInfo(),\n    }),\n    token: params.token,\n    timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,\n    label: \"getConfig\",\n  });\n  const resp: GetConfigResp = JSON.parse(rawText);\n  return resp;\n}\n\n/** Send a typing indicator to a user. */\nexport async function sendTyping(\n  params: WeixinApiOptions & { body: SendTypingReq },\n): Promise<void> {\n  await apiFetch({\n    baseUrl: params.baseUrl,\n    endpoint: \"ilink/bot/sendtyping\",\n    body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),\n    token: params.token,\n    timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,\n    label: \"sendTyping\",\n  });\n}\n"
  },
  {
    "path": "packages/sdk/src/api/config-cache.ts",
    "content": "import { getConfig } from \"./api.js\";\n\n/** Subset of getConfig fields that we actually need; add new fields here as needed. */\nexport interface CachedConfig {\n  typingTicket: string;\n}\n\nconst CONFIG_CACHE_TTL_MS = 24 * 60 * 60 * 1000;\nconst CONFIG_CACHE_INITIAL_RETRY_MS = 2_000;\nconst CONFIG_CACHE_MAX_RETRY_MS = 60 * 60 * 1000;\n\ninterface ConfigCacheEntry {\n  config: CachedConfig;\n  everSucceeded: boolean;\n  nextFetchAt: number;\n  retryDelayMs: number;\n}\n\n/**\n * Per-user getConfig cache with periodic random refresh (within 24h) and\n * exponential-backoff retry (up to 1h) on failure.\n */\nexport class WeixinConfigManager {\n  private cache = new Map<string, ConfigCacheEntry>();\n\n  constructor(\n    private apiOpts: { baseUrl: string; token?: string },\n    private log: (msg: string) => void,\n  ) {}\n\n  async getForUser(userId: string, contextToken?: string): Promise<CachedConfig> {\n    const now = Date.now();\n    const entry = this.cache.get(userId);\n    const shouldFetch = !entry || now >= entry.nextFetchAt;\n\n    if (shouldFetch) {\n      let fetchOk = false;\n      try {\n        const resp = await getConfig({\n          baseUrl: this.apiOpts.baseUrl,\n          token: this.apiOpts.token,\n          ilinkUserId: userId,\n          contextToken,\n        });\n        if (resp.ret === 0) {\n          this.cache.set(userId, {\n            config: { typingTicket: resp.typing_ticket ?? \"\" },\n            everSucceeded: true,\n            nextFetchAt: now + Math.random() * CONFIG_CACHE_TTL_MS,\n            retryDelayMs: CONFIG_CACHE_INITIAL_RETRY_MS,\n          });\n          this.log(\n            `[weixin] config ${entry?.everSucceeded ? \"refreshed\" : \"cached\"} for ${userId}`,\n          );\n          fetchOk = true;\n        }\n      } catch (err) {\n        this.log(`[weixin] getConfig failed for ${userId} (ignored): ${String(err)}`);\n      }\n      if (!fetchOk) {\n        const prevDelay = entry?.retryDelayMs ?? CONFIG_CACHE_INITIAL_RETRY_MS;\n        const nextDelay = Math.min(prevDelay * 2, CONFIG_CACHE_MAX_RETRY_MS);\n        if (entry) {\n          entry.nextFetchAt = now + nextDelay;\n          entry.retryDelayMs = nextDelay;\n        } else {\n          this.cache.set(userId, {\n            config: { typingTicket: \"\" },\n            everSucceeded: false,\n            nextFetchAt: now + CONFIG_CACHE_INITIAL_RETRY_MS,\n            retryDelayMs: CONFIG_CACHE_INITIAL_RETRY_MS,\n          });\n        }\n      }\n    }\n\n    return this.cache.get(userId)?.config ?? { typingTicket: \"\" };\n  }\n}\n"
  },
  {
    "path": "packages/sdk/src/api/session-guard.ts",
    "content": "import { logger } from \"../util/logger.js\";\n\nconst SESSION_PAUSE_DURATION_MS = 60 * 60 * 1000;\n\n/** Error code returned by the server when the bot session has expired. */\nexport const SESSION_EXPIRED_ERRCODE = -14;\n\nconst pauseUntilMap = new Map<string, number>();\n\n/** Pause all inbound/outbound API calls for `accountId` for one hour. */\nexport function pauseSession(accountId: string): void {\n  const until = Date.now() + SESSION_PAUSE_DURATION_MS;\n  pauseUntilMap.set(accountId, until);\n  logger.info(\n    `session-guard: paused accountId=${accountId} until=${new Date(until).toISOString()} (${SESSION_PAUSE_DURATION_MS / 1000}s)`,\n  );\n}\n\n/** Returns `true` when the bot is still within its one-hour cooldown window. */\nexport function isSessionPaused(accountId: string): boolean {\n  const until = pauseUntilMap.get(accountId);\n  if (until === undefined) return false;\n  if (Date.now() >= until) {\n    pauseUntilMap.delete(accountId);\n    return false;\n  }\n  return true;\n}\n\n/** Milliseconds remaining until the pause expires (0 when not paused). */\nexport function getRemainingPauseMs(accountId: string): number {\n  const until = pauseUntilMap.get(accountId);\n  if (until === undefined) return 0;\n  const remaining = until - Date.now();\n  if (remaining <= 0) {\n    pauseUntilMap.delete(accountId);\n    return 0;\n  }\n  return remaining;\n}\n\n/** Throw if the session is currently paused. Call before any API request. */\nexport function assertSessionActive(accountId: string): void {\n  if (isSessionPaused(accountId)) {\n    const remainingMin = Math.ceil(getRemainingPauseMs(accountId) / 60_000);\n    throw new Error(\n      `session paused for accountId=${accountId}, ${remainingMin} min remaining (errcode ${SESSION_EXPIRED_ERRCODE})`,\n    );\n  }\n}\n\n/**\n * Reset internal state — only for tests.\n * @internal\n */\nexport function _resetForTest(): void {\n  pauseUntilMap.clear();\n}\n"
  },
  {
    "path": "packages/sdk/src/api/types.ts",
    "content": "/**\n * Weixin protocol types (mirrors proto: GetUpdatesReq/Resp, WeixinMessage, SendMessageReq).\n * API uses JSON over HTTP; bytes fields are base64 strings in JSON.\n */\n\n/** Common request metadata attached to every CGI request. */\nexport interface BaseInfo {\n  channel_version?: string;\n}\n\n/** proto: UploadMediaType */\nexport const UploadMediaType = {\n  IMAGE: 1,\n  VIDEO: 2,\n  FILE: 3,\n  VOICE: 4,\n} as const;\n\nexport interface GetUploadUrlReq {\n  filekey?: string;\n  /** proto field 2: media_type, see UploadMediaType */\n  media_type?: number;\n  to_user_id?: string;\n  /** 原文件明文大小 */\n  rawsize?: number;\n  /** 原文件明文 MD5 */\n  rawfilemd5?: string;\n  /** 原文件密文大小（AES-128-ECB 加密后） */\n  filesize?: number;\n  /** 缩略图明文大小（IMAGE/VIDEO 时必填） */\n  thumb_rawsize?: number;\n  /** 缩略图明文 MD5（IMAGE/VIDEO 时必填） */\n  thumb_rawfilemd5?: string;\n  /** 缩略图密文大小（IMAGE/VIDEO 时必填） */\n  thumb_filesize?: number;\n  /** 不需要缩略图上传 URL，默认 false */\n  no_need_thumb?: boolean;\n  /** 加密 key */\n  aeskey?: string;\n}\n\nexport interface GetUploadUrlResp {\n  /** 原图上传加密参数 */\n  upload_param?: string;\n  /** 缩略图上传加密参数，无缩略图时为空 */\n  thumb_upload_param?: string;\n  /** 完整上传 URL（服务端直接返回，无需客户端拼接） */\n  upload_full_url?: string;\n}\n\nexport const MessageType = {\n  NONE: 0,\n  USER: 1,\n  BOT: 2,\n} as const;\n\nexport const MessageItemType = {\n  NONE: 0,\n  TEXT: 1,\n  IMAGE: 2,\n  VOICE: 3,\n  FILE: 4,\n  VIDEO: 5,\n} as const;\n\nexport const MessageState = {\n  NEW: 0,\n  GENERATING: 1,\n  FINISH: 2,\n} as const;\n\nexport interface TextItem {\n  text?: string;\n}\n\n/** CDN media reference; aes_key is base64-encoded bytes in JSON. */\nexport interface CDNMedia {\n  encrypt_query_param?: string;\n  aes_key?: string;\n  /** 加密类型: 0=只加密fileid, 1=打包缩略图/中图等信息 */\n  encrypt_type?: number;\n  /** 完整下载 URL（服务端直接返回，无需客户端拼接） */\n  full_url?: string;\n}\n\nexport interface ImageItem {\n  /** 原图 CDN 引用 */\n  media?: CDNMedia;\n  /** 缩略图 CDN 引用 */\n  thumb_media?: CDNMedia;\n  /** Raw AES-128 key as hex string (16 bytes); preferred over media.aes_key for inbound decryption. */\n  aeskey?: string;\n  url?: string;\n  mid_size?: number;\n  thumb_size?: number;\n  thumb_height?: number;\n  thumb_width?: number;\n  hd_size?: number;\n}\n\nexport interface VoiceItem {\n  media?: CDNMedia;\n  /** 语音编码类型：1=pcm 2=adpcm 3=feature 4=speex 5=amr 6=silk 7=mp3 8=ogg-speex */\n  encode_type?: number;\n  bits_per_sample?: number;\n  /** 采样率 (Hz) */\n  sample_rate?: number;\n  /** 语音长度 (毫秒) */\n  playtime?: number;\n  /** 语音转文字内容 */\n  text?: string;\n}\n\nexport interface FileItem {\n  media?: CDNMedia;\n  file_name?: string;\n  md5?: string;\n  len?: string;\n}\n\nexport interface VideoItem {\n  media?: CDNMedia;\n  video_size?: number;\n  play_length?: number;\n  video_md5?: string;\n  thumb_media?: CDNMedia;\n  thumb_size?: number;\n  thumb_height?: number;\n  thumb_width?: number;\n}\n\nexport interface RefMessage {\n  message_item?: MessageItem;\n  title?: string; // 摘要\n}\n\nexport interface MessageItem {\n  type?: number;\n  create_time_ms?: number;\n  update_time_ms?: number;\n  is_completed?: boolean;\n  msg_id?: string;\n  ref_msg?: RefMessage;\n  text_item?: TextItem;\n  image_item?: ImageItem;\n  voice_item?: VoiceItem;\n  file_item?: FileItem;\n  video_item?: VideoItem;\n}\n\n/** Unified message (proto: WeixinMessage). Replaces the old split Message + MessageContent + FullMessage. */\nexport interface WeixinMessage {\n  seq?: number;\n  message_id?: number;\n  from_user_id?: string;\n  to_user_id?: string;\n  client_id?: string;\n  create_time_ms?: number;\n  update_time_ms?: number;\n  delete_time_ms?: number;\n  session_id?: string;\n  group_id?: string;\n  message_type?: number;\n  message_state?: number;\n  item_list?: MessageItem[];\n  context_token?: string;\n}\n\n/** GetUpdates request: bytes fields are base64 strings in JSON. */\nexport interface GetUpdatesReq {\n  /** @deprecated compat only, will be removed */\n  sync_buf?: string;\n  /** Full context buf cached locally; send \"\" when none (first request or after reset). */\n  get_updates_buf?: string;\n}\n\n/** GetUpdates response: bytes fields are base64 strings in JSON. */\nexport interface GetUpdatesResp {\n  ret?: number;\n  /** Error code returned by the server (e.g. -14 = session timeout). Present when request fails. */\n  errcode?: number;\n  errmsg?: string;\n  msgs?: WeixinMessage[];\n  /** @deprecated compat only */\n  sync_buf?: string;\n  /** Full context buf to cache locally and send on next request. */\n  get_updates_buf?: string;\n  /** Server-suggested timeout (ms) for the next getUpdates long-poll. */\n  longpolling_timeout_ms?: number;\n}\n\n/** SendMessage request: wraps a single WeixinMessage. */\nexport interface SendMessageReq {\n  msg?: WeixinMessage;\n}\n\nexport interface SendMessageResp {\n  // empty\n}\n\n/** Typing status: 1 = typing (default), 2 = cancel typing. */\nexport const TypingStatus = {\n  TYPING: 1,\n  CANCEL: 2,\n} as const;\n\n/** SendTyping request: send a typing indicator to a user. */\nexport interface SendTypingReq {\n  ilink_user_id?: string;\n  typing_ticket?: string;\n  /** 1=typing (default), 2=cancel typing */\n  status?: number;\n}\n\nexport interface SendTypingResp {\n  ret?: number;\n  errmsg?: string;\n}\n\n/** GetConfig response: bot config including typing_ticket. */\nexport interface GetConfigResp {\n  ret?: number;\n  errmsg?: string;\n  /** Base64-encoded typing ticket for sendTyping. */\n  typing_ticket?: string;\n}\n"
  },
  {
    "path": "packages/sdk/src/auth/accounts.ts",
    "content": "import fs from \"node:fs\";\nimport path from \"node:path\";\n\nimport { resolveStateDir } from \"../storage/state-dir.js\";\nimport { logger } from \"../util/logger.js\";\n\nexport const DEFAULT_BASE_URL = \"https://ilinkai.weixin.qq.com\";\nexport const CDN_BASE_URL = \"https://novac2c.cdn.weixin.qq.com/c2c\";\n\n/** Normalize an account ID to a filesystem-safe string. */\nexport function normalizeAccountId(raw: string): string {\n  return raw.trim().toLowerCase().replace(/[@.]/g, \"-\");\n}\n\n\n// ---------------------------------------------------------------------------\n// Account ID compatibility (legacy raw ID → normalized ID)\n// ---------------------------------------------------------------------------\n\n/**\n * Pattern-based reverse of normalizeWeixinAccountId for known weixin ID suffixes.\n * Used only as a compatibility fallback when loading accounts / sync bufs stored\n * under the old raw ID.\n * e.g. \"b0f5860fdecb-im-bot\" → \"b0f5860fdecb@im.bot\"\n */\nexport function deriveRawAccountId(normalizedId: string): string | undefined {\n  if (normalizedId.endsWith(\"-im-bot\")) {\n    return `${normalizedId.slice(0, -7)}@im.bot`;\n  }\n  if (normalizedId.endsWith(\"-im-wechat\")) {\n    return `${normalizedId.slice(0, -10)}@im.wechat`;\n  }\n  return undefined;\n}\n\n// ---------------------------------------------------------------------------\n// Account index (persistent list of registered account IDs)\n// ---------------------------------------------------------------------------\n\nfunction resolveWeixinStateDir(): string {\n  return path.join(resolveStateDir(), \"openclaw-weixin\");\n}\n\nfunction resolveAccountIndexPath(): string {\n  return path.join(resolveWeixinStateDir(), \"accounts.json\");\n}\n\n/** Returns all accountIds registered via QR login. */\nexport function listIndexedWeixinAccountIds(): string[] {\n  const filePath = resolveAccountIndexPath();\n  try {\n    if (!fs.existsSync(filePath)) return [];\n    const raw = fs.readFileSync(filePath, \"utf-8\");\n    const parsed = JSON.parse(raw);\n    if (!Array.isArray(parsed)) return [];\n    return parsed.filter((id): id is string => typeof id === \"string\" && id.trim() !== \"\");\n  } catch {\n    return [];\n  }\n}\n\n/** Register accountId as the sole account in the persistent index. */\nexport function registerWeixinAccountId(accountId: string): void {\n  const dir = resolveWeixinStateDir();\n  fs.mkdirSync(dir, { recursive: true });\n\n  fs.writeFileSync(resolveAccountIndexPath(), JSON.stringify([accountId], null, 2), \"utf-8\");\n}\n\n// ---------------------------------------------------------------------------\n// Account store (per-account credential files)\n// ---------------------------------------------------------------------------\n\n/** Unified per-account data: token + baseUrl in one file. */\nexport type WeixinAccountData = {\n  token?: string;\n  savedAt?: string;\n  baseUrl?: string;\n  /** Last linked Weixin user id from QR login (optional). */\n  userId?: string;\n};\n\nfunction resolveAccountsDir(): string {\n  return path.join(resolveWeixinStateDir(), \"accounts\");\n}\n\nfunction resolveAccountPath(accountId: string): string {\n  return path.join(resolveAccountsDir(), `${accountId}.json`);\n}\n\n/**\n * Legacy single-file token: `credentials/openclaw-weixin/credentials.json` (pre per-account files).\n */\nfunction loadLegacyToken(): string | undefined {\n  const legacyPath = path.join(resolveStateDir(), \"credentials\", \"openclaw-weixin\", \"credentials.json\");\n  try {\n    if (!fs.existsSync(legacyPath)) return undefined;\n    const raw = fs.readFileSync(legacyPath, \"utf-8\");\n    const parsed = JSON.parse(raw) as { token?: string };\n    return typeof parsed.token === \"string\" ? parsed.token : undefined;\n  } catch {\n    return undefined;\n  }\n}\n\nfunction readAccountFile(filePath: string): WeixinAccountData | null {\n  try {\n    if (fs.existsSync(filePath)) {\n      return JSON.parse(fs.readFileSync(filePath, \"utf-8\")) as WeixinAccountData;\n    }\n  } catch {\n    // ignore\n  }\n  return null;\n}\n\n/** Load account data by ID, with compatibility fallbacks. */\nexport function loadWeixinAccount(accountId: string): WeixinAccountData | null {\n  // Primary: try given accountId (normalized IDs written after this change).\n  const primary = readAccountFile(resolveAccountPath(accountId));\n  if (primary) return primary;\n\n  // Compatibility: if the given ID is normalized, derive the old raw filename\n  // (e.g. \"b0f5860fdecb-im-bot\" → \"b0f5860fdecb@im.bot\") for existing installs.\n  const rawId = deriveRawAccountId(accountId);\n  if (rawId) {\n    const compat = readAccountFile(resolveAccountPath(rawId));\n    if (compat) return compat;\n  }\n\n  // Legacy fallback: read token from old single-account credentials file.\n  const token = loadLegacyToken();\n  if (token) return { token };\n\n  return null;\n}\n\n/**\n * Persist account data after QR login (merges into existing file).\n * - token: overwritten when provided.\n * - baseUrl: stored when non-empty; resolveWeixinAccount falls back to DEFAULT_BASE_URL.\n * - userId: set when `update.userId` is provided; omitted from file when cleared to empty.\n */\nexport function saveWeixinAccount(\n  accountId: string,\n  update: { token?: string; baseUrl?: string; userId?: string },\n): void {\n  const dir = resolveAccountsDir();\n  fs.mkdirSync(dir, { recursive: true });\n\n  const existing = loadWeixinAccount(accountId) ?? {};\n\n  const token = update.token?.trim() || existing.token;\n  const baseUrl = update.baseUrl?.trim() || existing.baseUrl;\n  const userId =\n    update.userId !== undefined\n      ? update.userId.trim() || undefined\n      : existing.userId?.trim() || undefined;\n\n  const data: WeixinAccountData = {\n    ...(token ? { token, savedAt: new Date().toISOString() } : {}),\n    ...(baseUrl ? { baseUrl } : {}),\n    ...(userId ? { userId } : {}),\n  };\n\n  const filePath = resolveAccountPath(accountId);\n  fs.writeFileSync(filePath, JSON.stringify(data, null, 2), \"utf-8\");\n  try {\n    fs.chmodSync(filePath, 0o600);\n  } catch {\n    // best-effort\n  }\n}\n\n/** Remove account data file. */\nexport function clearWeixinAccount(accountId: string): void {\n  try {\n    fs.unlinkSync(resolveAccountPath(accountId));\n  } catch {\n    // ignore if not found\n  }\n}\n\n/** Remove all account data files and clear the account index. */\nexport function clearAllWeixinAccounts(): void {\n  const ids = listIndexedWeixinAccountIds();\n  for (const id of ids) {\n    clearWeixinAccount(id);\n  }\n  try {\n    fs.writeFileSync(resolveAccountIndexPath(), \"[]\", \"utf-8\");\n  } catch {\n    // ignore\n  }\n}\n\n/**\n * Resolve the openclaw.json config file path.\n * Checks OPENCLAW_CONFIG env var, then state dir.\n */\nfunction resolveConfigPath(): string {\n  const envPath = process.env.OPENCLAW_CONFIG?.trim();\n  if (envPath) return envPath;\n  return path.join(resolveStateDir(), \"openclaw.json\");\n}\n\n/**\n * Read `routeTag` from openclaw.json (for callers without an `OpenClawConfig` object).\n * Checks per-account `channels.<id>.accounts[accountId].routeTag` first, then section-level\n * `channels.<id>.routeTag`. Matches `feat_weixin_extension` behavior; channel key is `\"openclaw-weixin\"`.\n */\nexport function loadConfigRouteTag(accountId?: string): string | undefined {\n  try {\n    const configPath = resolveConfigPath();\n    if (!fs.existsSync(configPath)) return undefined;\n    const raw = fs.readFileSync(configPath, \"utf-8\");\n    const cfg = JSON.parse(raw) as Record<string, unknown>;\n    const channels = cfg.channels as Record<string, unknown> | undefined;\n    const section = channels?.[\"openclaw-weixin\"] as Record<string, unknown> | undefined;\n    if (!section) return undefined;\n    if (accountId) {\n      const accounts = section.accounts as Record<string, Record<string, unknown>> | undefined;\n      const tag = accounts?.[accountId]?.routeTag;\n      if (typeof tag === \"number\") return String(tag);\n      if (typeof tag === \"string\" && tag.trim()) return tag.trim();\n    }\n    if (typeof section.routeTag === \"number\") return String(section.routeTag);\n    return typeof section.routeTag === \"string\" && section.routeTag.trim()\n      ? section.routeTag.trim()\n      : undefined;\n  } catch {\n    return undefined;\n  }\n}\n\n/**\n * No-op stub — config reload is now handled externally via `openclaw gateway restart`.\n */\nexport async function triggerWeixinChannelReload(): Promise<void> {}\n\n// ---------------------------------------------------------------------------\n// Account resolution (merge config + stored credentials)\n// ---------------------------------------------------------------------------\n\nexport type ResolvedWeixinAccount = {\n  accountId: string;\n  baseUrl: string;\n  cdnBaseUrl: string;\n  token?: string;\n  enabled: boolean;\n  /** true when a token has been obtained via QR login. */\n  configured: boolean;\n};\n\n/** List accountIds from the index file (written at QR login). */\nexport function listWeixinAccountIds(): string[] {\n  return listIndexedWeixinAccountIds();\n}\n\n/** Resolve a weixin account by ID, reading stored credentials. */\nexport function resolveWeixinAccount(accountId?: string | null): ResolvedWeixinAccount {\n  const raw = accountId?.trim();\n  if (!raw) {\n    throw new Error(\"weixin: accountId is required (no default account)\");\n  }\n  const id = normalizeAccountId(raw);\n\n  const accountData = loadWeixinAccount(id);\n  const token = accountData?.token?.trim() || undefined;\n  const stateBaseUrl = accountData?.baseUrl?.trim() || \"\";\n\n  return {\n    accountId: id,\n    baseUrl: stateBaseUrl || DEFAULT_BASE_URL,\n    cdnBaseUrl: CDN_BASE_URL,\n    token,\n    enabled: true,\n    configured: Boolean(token),\n  };\n}\n"
  },
  {
    "path": "packages/sdk/src/auth/login-qr.ts",
    "content": "import { randomUUID } from \"node:crypto\";\n\nimport { apiGetFetch } from \"../api/api.js\";\nimport { logger } from \"../util/logger.js\";\nimport { redactToken } from \"../util/redact.js\";\n\ntype ActiveLogin = {\n  sessionKey: string;\n  id: string;\n  qrcode: string;\n  qrcodeUrl: string;\n  startedAt: number;\n  botToken?: string;\n  status?: \"wait\" | \"scaned\" | \"confirmed\" | \"expired\" | \"scaned_but_redirect\";\n  error?: string;\n  /** The current effective polling base URL; may be updated on IDC redirect. */\n  currentApiBaseUrl?: string;\n};\n\nconst ACTIVE_LOGIN_TTL_MS = 5 * 60_000;\n/** Client-side timeout for the get_bot_qrcode request. */\nconst GET_QRCODE_TIMEOUT_MS = 5_000;\n/** Client-side timeout for the long-poll get_qrcode_status request. */\nconst QR_LONG_POLL_TIMEOUT_MS = 35_000;\n\n/** Default `bot_type` for ilink get_bot_qrcode / get_qrcode_status (this channel build). */\nexport const DEFAULT_ILINK_BOT_TYPE = \"3\";\n\n/** Fixed API base URL for all QR code requests. */\nconst FIXED_BASE_URL = \"https://ilinkai.weixin.qq.com\";\n\nconst activeLogins = new Map<string, ActiveLogin>();\n\ninterface QRCodeResponse {\n  qrcode: string;\n  qrcode_img_content: string;\n}\n\ninterface StatusResponse {\n  status: \"wait\" | \"scaned\" | \"confirmed\" | \"expired\" | \"scaned_but_redirect\";\n  bot_token?: string;\n  ilink_bot_id?: string;\n  baseurl?: string;\n  /** The user ID of the person who scanned the QR code. */\n  ilink_user_id?: string;\n  /** New host to redirect polling to when status is scaned_but_redirect. */\n  redirect_host?: string;\n}\n\nfunction isLoginFresh(login: ActiveLogin): boolean {\n  return Date.now() - login.startedAt < ACTIVE_LOGIN_TTL_MS;\n}\n\n/** Remove all expired entries from the activeLogins map to prevent memory leaks. */\nfunction purgeExpiredLogins(): void {\n  for (const [id, login] of activeLogins) {\n    if (!isLoginFresh(login)) {\n      activeLogins.delete(id);\n    }\n  }\n}\n\nasync function fetchQRCode(apiBaseUrl: string, botType: string): Promise<QRCodeResponse> {\n  logger.info(`Fetching QR code from: ${apiBaseUrl} bot_type=${botType}`);\n  const rawText = await apiGetFetch({\n    baseUrl: apiBaseUrl,\n    endpoint: `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`,\n    timeoutMs: GET_QRCODE_TIMEOUT_MS,\n    label: \"fetchQRCode\",\n  });\n  return JSON.parse(rawText) as QRCodeResponse;\n}\n\nasync function pollQRStatus(apiBaseUrl: string, qrcode: string): Promise<StatusResponse> {\n  logger.debug(`Long-poll QR status from: ${apiBaseUrl} qrcode=***`);\n  try {\n    const rawText = await apiGetFetch({\n      baseUrl: apiBaseUrl,\n      endpoint: `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`,\n      timeoutMs: QR_LONG_POLL_TIMEOUT_MS,\n      label: \"pollQRStatus\",\n    });\n    logger.debug(`pollQRStatus: body=${rawText.substring(0, 200)}`);\n    return JSON.parse(rawText) as StatusResponse;\n  } catch (err) {\n    if (err instanceof Error && err.name === \"AbortError\") {\n      logger.debug(`pollQRStatus: client-side timeout after ${QR_LONG_POLL_TIMEOUT_MS}ms, returning wait`);\n      return { status: \"wait\" };\n    }\n    // 网关超时（如 Cloudflare 524）或其他网络错误，视为等待状态继续轮询\n    logger.warn(`pollQRStatus: network/gateway error, will retry: ${String(err)}`);\n    return { status: \"wait\" };\n  }\n}\n\nexport type WeixinQrStartResult = {\n  qrcodeUrl?: string;\n  message: string;\n  sessionKey: string;\n};\n\nexport type WeixinQrWaitResult = {\n  connected: boolean;\n  botToken?: string;\n  accountId?: string;\n  baseUrl?: string;\n  /** The user ID of the person who scanned the QR code; add to allowFrom. */\n  userId?: string;\n  message: string;\n};\n\nexport async function startWeixinLoginWithQr(opts: {\n  verbose?: boolean;\n  timeoutMs?: number;\n  force?: boolean;\n  accountId?: string;\n  apiBaseUrl: string;\n  botType?: string;\n}): Promise<WeixinQrStartResult> {\n  const sessionKey = opts.accountId || randomUUID();\n\n  purgeExpiredLogins();\n\n  const existing = activeLogins.get(sessionKey);\n  if (!opts.force && existing && isLoginFresh(existing) && existing.qrcodeUrl) {\n    return {\n      qrcodeUrl: existing.qrcodeUrl,\n      message: \"二维码已就绪，请使用微信扫描。\",\n      sessionKey,\n    };\n  }\n\n  try {\n    const botType = opts.botType || DEFAULT_ILINK_BOT_TYPE;\n    logger.info(`Starting Weixin login with bot_type=${botType}`);\n\n    const qrResponse = await fetchQRCode(FIXED_BASE_URL, botType);\n    logger.info(\n      `QR code received, qrcode=${redactToken(qrResponse.qrcode)} imgContentLen=${qrResponse.qrcode_img_content?.length ?? 0}`,\n    );\n    logger.info(`二维码链接: ${qrResponse.qrcode_img_content}`);\n\n    const login: ActiveLogin = {\n      sessionKey,\n      id: randomUUID(),\n      qrcode: qrResponse.qrcode,\n      qrcodeUrl: qrResponse.qrcode_img_content,\n      startedAt: Date.now(),\n    };\n\n    activeLogins.set(sessionKey, login);\n\n    return {\n      qrcodeUrl: qrResponse.qrcode_img_content,\n      message: \"使用微信扫描以下二维码，以完成连接。\",\n      sessionKey,\n    };\n  } catch (err) {\n    logger.error(`Failed to start Weixin login: ${String(err)}`);\n    return {\n      message: `Failed to start login: ${String(err)}`,\n      sessionKey,\n    };\n  }\n}\n\nconst MAX_QR_REFRESH_COUNT = 3;\n\nexport async function waitForWeixinLogin(opts: {\n  timeoutMs?: number;\n  verbose?: boolean;\n  sessionKey: string;\n  apiBaseUrl: string;\n  botType?: string;\n}): Promise<WeixinQrWaitResult> {\n  let activeLogin = activeLogins.get(opts.sessionKey);\n\n  if (!activeLogin) {\n    logger.warn(`waitForWeixinLogin: no active login sessionKey=${opts.sessionKey}`);\n    return {\n      connected: false,\n      message: \"当前没有进行中的登录，请先发起登录。\",\n    };\n  }\n\n  if (!isLoginFresh(activeLogin)) {\n    logger.warn(`waitForWeixinLogin: login QR expired sessionKey=${opts.sessionKey}`);\n    activeLogins.delete(opts.sessionKey);\n    return {\n      connected: false,\n      message: \"二维码已过期，请重新生成。\",\n    };\n  }\n\n  const timeoutMs = Math.max(opts.timeoutMs ?? 480_000, 1000);\n  const deadline = Date.now() + timeoutMs;\n  let scannedPrinted = false;\n  let qrRefreshCount = 1;\n\n  // Initialize the effective polling base URL; may be updated on IDC redirect.\n  activeLogin.currentApiBaseUrl = FIXED_BASE_URL;\n\n  logger.info(\"Starting to poll QR code status...\");\n\n  while (Date.now() < deadline) {\n    try {\n      const currentBaseUrl = activeLogin.currentApiBaseUrl ?? FIXED_BASE_URL;\n      const statusResponse = await pollQRStatus(currentBaseUrl, activeLogin.qrcode);\n      logger.debug(`pollQRStatus: status=${statusResponse.status} hasBotToken=${Boolean(statusResponse.bot_token)} hasBotId=${Boolean(statusResponse.ilink_bot_id)}`);\n      activeLogin.status = statusResponse.status;\n\n      switch (statusResponse.status) {\n        case \"wait\":\n          if (opts.verbose) {\n            process.stdout.write(\".\");\n          }\n          break;\n        case \"scaned\":\n          if (!scannedPrinted) {\n            process.stdout.write(\"\\n👀 已扫码，在微信继续操作...\\n\");\n            scannedPrinted = true;\n          }\n          break;\n        case \"expired\": {\n          qrRefreshCount++;\n          if (qrRefreshCount > MAX_QR_REFRESH_COUNT) {\n            logger.warn(\n              `waitForWeixinLogin: QR expired ${MAX_QR_REFRESH_COUNT} times, giving up sessionKey=${opts.sessionKey}`,\n            );\n            activeLogins.delete(opts.sessionKey);\n            return {\n              connected: false,\n              message: \"登录超时：二维码多次过期，请重新开始登录流程。\",\n            };\n          }\n\n          process.stdout.write(`\\n⏳ 二维码已过期，正在刷新...(${qrRefreshCount}/${MAX_QR_REFRESH_COUNT})\\n`);\n          logger.info(\n            `waitForWeixinLogin: QR expired, refreshing (${qrRefreshCount}/${MAX_QR_REFRESH_COUNT})`,\n          );\n\n          try {\n            const botType = opts.botType || DEFAULT_ILINK_BOT_TYPE;\n            const qrResponse = await fetchQRCode(FIXED_BASE_URL, botType);\n            activeLogin.qrcode = qrResponse.qrcode;\n            activeLogin.qrcodeUrl = qrResponse.qrcode_img_content;\n            activeLogin.startedAt = Date.now();\n            scannedPrinted = false;\n            logger.info(`waitForWeixinLogin: new QR code obtained qrcode=${redactToken(qrResponse.qrcode)}`);\n            process.stdout.write(`🔄 新二维码已生成，请重新扫描\\n\\n`);\n            try {\n              const qrterm = await import(\"qrcode-terminal\");\n              qrterm.default.generate(qrResponse.qrcode_img_content, { small: true });\n              process.stdout.write(`如果二维码未能成功展示，请用浏览器打开以下链接扫码：\\n`);\n              process.stdout.write(`${qrResponse.qrcode_img_content}\\n`);\n            } catch {\n              process.stdout.write(`二维码未加载成功，请用浏览器打开以下链接扫码：\\n`);\n              process.stdout.write(`${qrResponse.qrcode_img_content}\\n`);\n            }\n          } catch (refreshErr) {\n            logger.error(`waitForWeixinLogin: failed to refresh QR code: ${String(refreshErr)}`);\n            activeLogins.delete(opts.sessionKey);\n            return {\n              connected: false,\n              message: `刷新二维码失败: ${String(refreshErr)}`,\n            };\n          }\n          break;\n        }\n        case \"scaned_but_redirect\": {\n          const redirectHost = statusResponse.redirect_host;\n          if (redirectHost) {\n            const newBaseUrl = `https://${redirectHost}`;\n            activeLogin.currentApiBaseUrl = newBaseUrl;\n            logger.info(`waitForWeixinLogin: IDC redirect, switching polling host to ${redirectHost}`);\n          } else {\n            logger.warn(`waitForWeixinLogin: received scaned_but_redirect but redirect_host is missing, continuing with current host`);\n          }\n          break;\n        }\n        case \"confirmed\": {\n          if (!statusResponse.ilink_bot_id) {\n            activeLogins.delete(opts.sessionKey);\n            logger.error(\"Login confirmed but ilink_bot_id missing from response\");\n            return {\n              connected: false,\n              message: \"登录失败：服务器未返回 ilink_bot_id。\",\n            };\n          }\n\n          activeLogin.botToken = statusResponse.bot_token;\n          activeLogins.delete(opts.sessionKey);\n\n          logger.info(\n            `✅ Login confirmed! ilink_bot_id=${statusResponse.ilink_bot_id} ilink_user_id=${redactToken(statusResponse.ilink_user_id)}`,\n          );\n\n          return {\n            connected: true,\n            botToken: statusResponse.bot_token,\n            accountId: statusResponse.ilink_bot_id,\n            baseUrl: statusResponse.baseurl,\n            userId: statusResponse.ilink_user_id,\n            message: \"✅ 与微信连接成功！\",\n          };\n        }\n      }\n\n    } catch (err) {\n      logger.error(`Error polling QR status: ${String(err)}`);\n      activeLogins.delete(opts.sessionKey);\n      return {\n        connected: false,\n        message: `Login failed: ${String(err)}`,\n      };\n    }\n\n    await new Promise((r) => setTimeout(r, 1000));\n  }\n\n  logger.warn(\n    `waitForWeixinLogin: timed out waiting for QR scan sessionKey=${opts.sessionKey} timeoutMs=${timeoutMs}`,\n  );\n  activeLogins.delete(opts.sessionKey);\n  return {\n    connected: false,\n    message: \"登录超时，请重试。\",\n  };\n}\n"
  },
  {
    "path": "packages/sdk/src/auth/pairing.ts",
    "content": "import fs from \"node:fs\";\nimport path from \"node:path\";\n\nimport { resolveStateDir } from \"../storage/state-dir.js\";\nimport { logger } from \"../util/logger.js\";\n\n/**\n * Resolve the framework credentials directory (mirrors core resolveOAuthDir).\n * Path: $OPENCLAW_OAUTH_DIR || $OPENCLAW_STATE_DIR/credentials || ~/.openclaw/credentials\n */\nfunction resolveCredentialsDir(): string {\n  const override = process.env.OPENCLAW_OAUTH_DIR?.trim();\n  if (override) return override;\n  return path.join(resolveStateDir(), \"credentials\");\n}\n\n/**\n * Sanitize a channel/account key for safe use in filenames (mirrors core safeChannelKey).\n */\nfunction safeKey(raw: string): string {\n  const trimmed = raw.trim().toLowerCase();\n  if (!trimmed) throw new Error(\"invalid key for allowFrom path\");\n  const safe = trimmed.replace(/[\\\\/:*?\"<>|]/g, \"_\").replace(/\\.\\./g, \"_\");\n  if (!safe || safe === \"_\") throw new Error(\"invalid key for allowFrom path\");\n  return safe;\n}\n\n/**\n * Resolve the framework allowFrom file path for a given account.\n * Mirrors: `resolveAllowFromPath(channel, env, accountId)` from core.\n * Path: `<credDir>/openclaw-weixin-<accountId>-allowFrom.json`\n */\nexport function resolveFrameworkAllowFromPath(accountId: string): string {\n  const base = safeKey(\"openclaw-weixin\");\n  const safeAccount = safeKey(accountId);\n  return path.join(resolveCredentialsDir(), `${base}-${safeAccount}-allowFrom.json`);\n}\n\ntype AllowFromFileContent = {\n  version: number;\n  allowFrom: string[];\n};\n\n/**\n * Read the framework allowFrom list for an account (user IDs authorized via pairing).\n * Returns an empty array when the file is missing or unreadable.\n */\nexport function readFrameworkAllowFromList(accountId: string): string[] {\n  const filePath = resolveFrameworkAllowFromPath(accountId);\n  try {\n    if (!fs.existsSync(filePath)) return [];\n    const raw = fs.readFileSync(filePath, \"utf-8\");\n    const parsed = JSON.parse(raw) as AllowFromFileContent;\n    if (Array.isArray(parsed.allowFrom)) {\n      return parsed.allowFrom.filter((id): id is string => typeof id === \"string\" && id.trim() !== \"\");\n    }\n  } catch {\n    // best-effort\n  }\n  return [];\n}\n\n/**\n * Register a user ID in the channel allowFrom store.\n */\nexport function registerUserInAllowFromStore(params: {\n  accountId: string;\n  userId: string;\n}): { changed: boolean } {\n  const { accountId, userId } = params;\n  const trimmedUserId = userId.trim();\n  if (!trimmedUserId) return { changed: false };\n\n  const filePath = resolveFrameworkAllowFromPath(accountId);\n  const dir = path.dirname(filePath);\n  fs.mkdirSync(dir, { recursive: true });\n\n  let content: AllowFromFileContent = { version: 1, allowFrom: [] };\n  try {\n    if (fs.existsSync(filePath)) {\n      const raw = fs.readFileSync(filePath, \"utf-8\");\n      const parsed = JSON.parse(raw) as AllowFromFileContent;\n      if (Array.isArray(parsed.allowFrom)) {\n        content = parsed;\n      }\n    }\n  } catch {\n    // start fresh\n  }\n\n  if (content.allowFrom.includes(trimmedUserId)) {\n    return { changed: false };\n  }\n\n  content.allowFrom.push(trimmedUserId);\n  fs.writeFileSync(filePath, JSON.stringify(content, null, 2), \"utf-8\");\n  logger.info(\n    `registerUserInAllowFromStore: added userId=${trimmedUserId} accountId=${accountId} path=${filePath}`,\n  );\n  return { changed: true };\n}\n"
  },
  {
    "path": "packages/sdk/src/bot.ts",
    "content": "import os from \"node:os\";\nimport path from \"node:path\";\n\nimport type { Agent, ChatResponse } from \"./agent/interface.js\";\nimport {\n  clearAllWeixinAccounts,\n  DEFAULT_BASE_URL,\n  listWeixinAccountIds,\n  loadWeixinAccount,\n  normalizeAccountId,\n  registerWeixinAccountId,\n  resolveWeixinAccount,\n  saveWeixinAccount,\n} from \"./auth/accounts.js\";\nimport {\n  DEFAULT_ILINK_BOT_TYPE,\n  startWeixinLoginWithQr,\n  waitForWeixinLogin,\n} from \"./auth/login-qr.js\";\nimport { downloadRemoteImageToTemp } from \"./cdn/upload.js\";\nimport { getContextToken } from \"./messaging/inbound.js\";\nimport { sendWeixinMediaFile } from \"./messaging/send-media.js\";\nimport { markdownToPlainText, sendMessageWeixin } from \"./messaging/send.js\";\nimport { monitorWeixinProvider } from \"./monitor/monitor.js\";\nimport { logger } from \"./util/logger.js\";\n\nconst MEDIA_TEMP_DIR = path.join(os.tmpdir(), \"weixin-agent/media\");\n\nexport type LoginOptions = {\n  /** Override the API base URL. */\n  baseUrl?: string;\n  /** Log callback (defaults to console.log). */\n  log?: (msg: string) => void;\n};\n\nexport type StartOptions = {\n  /** Account ID to use. Auto-selects the first registered account if omitted. */\n  accountId?: string;\n  /** AbortSignal to stop the bot. */\n  abortSignal?: AbortSignal;\n  /** Log callback (defaults to console.log). */\n  log?: (msg: string) => void;\n};\n\n/**\n * Interactive QR-code login. Prints the QR code to the terminal and waits\n * for the user to scan it with WeChat.\n *\n * Returns the normalized account ID on success.\n */\nexport async function login(opts?: LoginOptions): Promise<string> {\n  const log = opts?.log ?? console.log;\n  const apiBaseUrl = opts?.baseUrl ?? DEFAULT_BASE_URL;\n\n  log(\"正在启动微信扫码登录...\");\n\n  const startResult = await startWeixinLoginWithQr({\n    apiBaseUrl,\n    botType: DEFAULT_ILINK_BOT_TYPE,\n  });\n\n  if (!startResult.qrcodeUrl) {\n    throw new Error(startResult.message);\n  }\n\n  log(\"\\n使用微信扫描以下二维码，以完成连接：\\n\");\n  try {\n    const qrcodeterminal = await import(\"qrcode-terminal\");\n    await new Promise<void>((resolve) => {\n      qrcodeterminal.default.generate(startResult.qrcodeUrl!, { small: true }, (qr: string) => {\n        console.log(qr);\n        resolve();\n      });\n    });\n  } catch {\n    log(`二维码链接: ${startResult.qrcodeUrl}`);\n  }\n\n  log(\"\\n等待扫码...\\n\");\n\n  const waitResult = await waitForWeixinLogin({\n    sessionKey: startResult.sessionKey,\n    apiBaseUrl,\n    timeoutMs: 480_000,\n    botType: DEFAULT_ILINK_BOT_TYPE,\n  });\n\n  if (!waitResult.connected || !waitResult.botToken || !waitResult.accountId) {\n    throw new Error(waitResult.message);\n  }\n\n  const normalizedId = normalizeAccountId(waitResult.accountId);\n  saveWeixinAccount(normalizedId, {\n    token: waitResult.botToken,\n    baseUrl: waitResult.baseUrl,\n    userId: waitResult.userId,\n  });\n  registerWeixinAccountId(normalizedId);\n\n  log(\"\\n✅ 与微信连接成功！\");\n  return normalizedId;\n}\n\n/**\n * Remove all stored WeChat account credentials.\n */\nexport function logout(opts?: { log?: (msg: string) => void }): void {\n  const log = opts?.log ?? console.log;\n  const ids = listWeixinAccountIds();\n  if (ids.length === 0) {\n    log(\"当前没有已登录的账号\");\n    return;\n  }\n  clearAllWeixinAccounts();\n  log(\"✅ 已退出登录\");\n}\n\n/**\n * Check whether at least one WeChat account is logged in and configured.\n */\nexport function isLoggedIn(): boolean {\n  const ids = listWeixinAccountIds();\n  if (ids.length === 0) return false;\n  const account = resolveWeixinAccount(ids[0]);\n  return account.configured;\n}\n\n/**\n * A running bot instance — provides proactive messaging capability.\n *\n * - `sendMessage(text)` — send a text message to the logged-in user.\n * - `sendMessage(response)` — send a ChatResponse (text and/or media).\n */\nexport class Bot {\n  private readonly _accountId: string;\n  private readonly _baseUrl: string;\n  private readonly _cdnBaseUrl: string;\n  private readonly _token?: string;\n  private readonly _userId: string;\n  private readonly _monitorPromise: Promise<void>;\n\n  /** @internal */\n  constructor(params: {\n    accountId: string;\n    baseUrl: string;\n    cdnBaseUrl: string;\n    token?: string;\n    userId: string;\n    monitorPromise: Promise<void>;\n  }) {\n    this._accountId = params.accountId;\n    this._baseUrl = params.baseUrl;\n    this._cdnBaseUrl = params.cdnBaseUrl;\n    this._token = params.token;\n    this._userId = params.userId;\n    this._monitorPromise = params.monitorPromise;\n    this._monitorPromise.catch(() => {\n      // Errors are re-thrown to callers that explicitly wait for the bot.\n    });\n  }\n\n  /**\n   * Wait until the background WeChat monitor stops.\n   *\n   * This is useful for CLI programs that should keep running until the bot is\n   * aborted, and for surfacing unrecoverable monitor errors to the caller.\n   */\n  async wait(): Promise<void> {\n    await this._monitorPromise;\n  }\n\n  /**\n   * Proactively send a message to the logged-in WeChat user.\n   *\n   * Accepts either a plain string (sent as text) or a full `ChatResponse`\n   * object (text and/or media).\n   *\n   * Requires at least one inbound message to have been received so that a\n   * valid `context_token` is cached (tokens are valid for ~24 hours).\n   */\n  async sendMessage(message: string | ChatResponse): Promise<void> {\n    const response: ChatResponse =\n      typeof message === \"string\" ? { text: message } : message;\n\n    const contextToken = getContextToken(this._accountId, this._userId);\n    if (!contextToken) {\n      throw new Error(\n        \"没有找到 context_token，需要在 start() 运行期间至少收到过一条消息\",\n      );\n    }\n\n    const apiOpts = {\n      baseUrl: this._baseUrl,\n      token: this._token,\n      contextToken,\n    };\n\n    if (response.media) {\n      let filePath: string;\n      const mediaUrl = response.media.url;\n      if (mediaUrl.startsWith(\"http://\") || mediaUrl.startsWith(\"https://\")) {\n        filePath = await downloadRemoteImageToTemp(\n          mediaUrl,\n          path.join(MEDIA_TEMP_DIR, \"outbound\"),\n        );\n      } else {\n        filePath = path.isAbsolute(mediaUrl) ? mediaUrl : path.resolve(mediaUrl);\n      }\n      await sendWeixinMediaFile({\n        filePath,\n        to: this._userId,\n        text: response.text ? markdownToPlainText(response.text) : \"\",\n        opts: apiOpts,\n        cdnBaseUrl: this._cdnBaseUrl,\n      });\n      return;\n    }\n\n    if (response.text) {\n      await sendMessageWeixin({\n        to: this._userId,\n        text: markdownToPlainText(response.text),\n        opts: apiOpts,\n      });\n      return;\n    }\n\n    throw new Error(\"消息必须包含 text 或 media\");\n  }\n}\n\n/**\n * Start the bot — long-polls for new messages and dispatches them to the agent.\n *\n * Returns a `Bot` instance immediately. Use `bot.wait()` when a CLI program\n * should block until the background monitor stops.\n */\nexport function start(agent: Agent, opts?: StartOptions): Bot {\n  const log = opts?.log ?? console.log;\n\n  // Resolve account\n  let accountId = opts?.accountId;\n  if (!accountId) {\n    const ids = listWeixinAccountIds();\n    if (ids.length === 0) {\n      throw new Error(\"没有已登录的账号，请先运行 login\");\n    }\n    accountId = ids[0];\n    if (ids.length > 1) {\n      log(`[weixin] 检测到多个账号，使用第一个: ${accountId}`);\n    }\n  }\n\n  const account = resolveWeixinAccount(accountId);\n  if (!account.configured) {\n    throw new Error(\n      `账号 ${accountId} 未配置 (缺少 token)，请先运行 login`,\n    );\n  }\n\n  const accountData = loadWeixinAccount(account.accountId);\n  const userId = accountData?.userId;\n  if (!userId) {\n    throw new Error(\n      `账号 ${accountId} 没有关联的用户 ID，请重新运行 login`,\n    );\n  }\n\n  log(`[weixin] 启动 bot, account=${account.accountId}`);\n\n  const monitorPromise = monitorWeixinProvider({\n    baseUrl: account.baseUrl,\n    cdnBaseUrl: account.cdnBaseUrl,\n    token: account.token,\n    accountId: account.accountId,\n    agent,\n    abortSignal: opts?.abortSignal,\n    log,\n  });\n\n  return new Bot({\n    accountId: account.accountId,\n    baseUrl: account.baseUrl,\n    cdnBaseUrl: account.cdnBaseUrl,\n    token: account.token,\n    userId,\n    monitorPromise,\n  });\n}\n"
  },
  {
    "path": "packages/sdk/src/cdn/aes-ecb.ts",
    "content": "/**\n * Shared AES-128-ECB crypto utilities for CDN upload and download.\n */\nimport { createCipheriv, createDecipheriv } from \"node:crypto\";\n\n/** Encrypt buffer with AES-128-ECB (PKCS7 padding is default). */\nexport function encryptAesEcb(plaintext: Buffer, key: Buffer): Buffer {\n  const cipher = createCipheriv(\"aes-128-ecb\", key, null);\n  return Buffer.concat([cipher.update(plaintext), cipher.final()]);\n}\n\n/** Decrypt buffer with AES-128-ECB (PKCS7 padding). */\nexport function decryptAesEcb(ciphertext: Buffer, key: Buffer): Buffer {\n  const decipher = createDecipheriv(\"aes-128-ecb\", key, null);\n  return Buffer.concat([decipher.update(ciphertext), decipher.final()]);\n}\n\n/** Compute AES-128-ECB ciphertext size (PKCS7 padding to 16-byte boundary). */\nexport function aesEcbPaddedSize(plaintextSize: number): number {\n  return Math.ceil((plaintextSize + 1) / 16) * 16;\n}\n"
  },
  {
    "path": "packages/sdk/src/cdn/cdn-upload.ts",
    "content": "import { encryptAesEcb } from \"./aes-ecb.js\";\nimport { buildCdnUploadUrl } from \"./cdn-url.js\";\nimport { logger } from \"../util/logger.js\";\nimport { redactUrl } from \"../util/redact.js\";\n\n/** Maximum retry attempts for CDN upload. */\nconst UPLOAD_MAX_RETRIES = 3;\n\n/**\n * Upload one buffer to the Weixin CDN with AES-128-ECB encryption.\n * Returns the download encrypted_query_param from the CDN response.\n * Retries up to UPLOAD_MAX_RETRIES times on server errors; client errors (4xx) abort immediately.\n */\nexport async function uploadBufferToCdn(params: {\n  buf: Buffer;\n  /** From getUploadUrl.upload_full_url; POST target when set (takes precedence over uploadParam). */\n  uploadFullUrl?: string;\n  uploadParam?: string;\n  filekey: string;\n  cdnBaseUrl: string;\n  label: string;\n  aeskey: Buffer;\n}): Promise<{ downloadParam: string }> {\n  const { buf, uploadFullUrl, uploadParam, filekey, cdnBaseUrl, label, aeskey } = params;\n  const ciphertext = encryptAesEcb(buf, aeskey);\n  const trimmedFull = uploadFullUrl?.trim();\n  let cdnUrl: string;\n  if (trimmedFull) {\n    cdnUrl = trimmedFull;\n  } else if (uploadParam) {\n    cdnUrl = buildCdnUploadUrl({ cdnBaseUrl, uploadParam, filekey });\n  } else {\n    throw new Error(`${label}: CDN upload URL missing (need upload_full_url or upload_param)`);\n  }\n  logger.debug(`${label}: CDN POST url=${redactUrl(cdnUrl)} ciphertextSize=${ciphertext.length}`);\n\n  let downloadParam: string | undefined;\n  let lastError: unknown;\n\n  for (let attempt = 1; attempt <= UPLOAD_MAX_RETRIES; attempt++) {\n    try {\n      const res = await fetch(cdnUrl, {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/octet-stream\" },\n        body: new Uint8Array(ciphertext),\n      });\n      if (res.status >= 400 && res.status < 500) {\n        const errMsg = res.headers.get(\"x-error-message\") ?? (await res.text());\n        logger.error(\n          `${label}: CDN client error attempt=${attempt} status=${res.status} errMsg=${errMsg}`,\n        );\n        throw new Error(`CDN upload client error ${res.status}: ${errMsg}`);\n      }\n      if (res.status !== 200) {\n        const errMsg = res.headers.get(\"x-error-message\") ?? `status ${res.status}`;\n        logger.error(\n          `${label}: CDN server error attempt=${attempt} status=${res.status} errMsg=${errMsg}`,\n        );\n        throw new Error(`CDN upload server error: ${errMsg}`);\n      }\n      downloadParam = res.headers.get(\"x-encrypted-param\") ?? undefined;\n      if (!downloadParam) {\n        logger.error(\n          `${label}: CDN response missing x-encrypted-param header attempt=${attempt}`,\n        );\n        throw new Error(\"CDN upload response missing x-encrypted-param header\");\n      }\n      logger.debug(`${label}: CDN upload success attempt=${attempt}`);\n      break;\n    } catch (err) {\n      lastError = err;\n      if (err instanceof Error && err.message.includes(\"client error\")) throw err;\n      if (attempt < UPLOAD_MAX_RETRIES) {\n        logger.error(`${label}: attempt ${attempt} failed, retrying... err=${String(err)}`);\n      } else {\n        logger.error(`${label}: all ${UPLOAD_MAX_RETRIES} attempts failed err=${String(err)}`);\n      }\n    }\n  }\n\n  if (!downloadParam) {\n    throw lastError instanceof Error\n      ? lastError\n      : new Error(`CDN upload failed after ${UPLOAD_MAX_RETRIES} attempts`);\n  }\n  return { downloadParam };\n}\n"
  },
  {
    "path": "packages/sdk/src/cdn/cdn-url.ts",
    "content": "/**\n * Unified CDN URL construction for Weixin CDN upload/download.\n */\n\n/** Build a CDN download URL from encrypt_query_param. */\nexport function buildCdnDownloadUrl(encryptedQueryParam: string, cdnBaseUrl: string): string {\n  return `${cdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}`;\n}\n\n/** Build a CDN upload URL from upload_param and filekey. */\nexport function buildCdnUploadUrl(params: {\n  cdnBaseUrl: string;\n  uploadParam: string;\n  filekey: string;\n}): string {\n  return `${params.cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(params.uploadParam)}&filekey=${encodeURIComponent(params.filekey)}`;\n}\n"
  },
  {
    "path": "packages/sdk/src/cdn/pic-decrypt.ts",
    "content": "import { decryptAesEcb } from \"./aes-ecb.js\";\nimport { buildCdnDownloadUrl } from \"./cdn-url.js\";\nimport { logger } from \"../util/logger.js\";\n\n/**\n * Download raw bytes from the CDN (no decryption).\n */\nasync function fetchCdnBytes(url: string, label: string): Promise<Buffer> {\n  let res: Response;\n  try {\n    res = await fetch(url);\n  } catch (err) {\n    const cause =\n      (err as NodeJS.ErrnoException).cause ?? (err as NodeJS.ErrnoException).code ?? \"(no cause)\";\n    logger.error(\n      `${label}: fetch network error url=${url} err=${String(err)} cause=${String(cause)}`,\n    );\n    throw err;\n  }\n  logger.debug(`${label}: response status=${res.status} ok=${res.ok}`);\n  if (!res.ok) {\n    const body = await res.text().catch(() => \"(unreadable)\");\n    const msg = `${label}: CDN download ${res.status} ${res.statusText} body=${body}`;\n    logger.error(msg);\n    throw new Error(msg);\n  }\n  return Buffer.from(await res.arrayBuffer());\n}\n\n/**\n * Parse CDNMedia.aes_key into a raw 16-byte AES key.\n *\n * Two encodings are seen in the wild:\n *   - base64(raw 16 bytes)          → images (aes_key from media field)\n *   - base64(hex string of 16 bytes) → file / voice / video\n *\n * In the second case, base64-decoding yields 32 ASCII hex chars which must\n * then be parsed as hex to recover the actual 16-byte key.\n */\nfunction parseAesKey(aesKeyBase64: string, label: string): Buffer {\n  const decoded = Buffer.from(aesKeyBase64, \"base64\");\n  if (decoded.length === 16) {\n    return decoded;\n  }\n  if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString(\"ascii\"))) {\n    // hex-encoded key: base64 → hex string → raw bytes\n    return Buffer.from(decoded.toString(\"ascii\"), \"hex\");\n  }\n  const msg = `${label}: aes_key must decode to 16 raw bytes or 32-char hex string, got ${decoded.length} bytes (base64=\"${aesKeyBase64}\")`;\n  logger.error(msg);\n  throw new Error(msg);\n}\n\n/**\n * Download and AES-128-ECB decrypt a CDN media file. Returns plaintext Buffer.\n * aesKeyBase64: CDNMedia.aes_key JSON field (see parseAesKey for supported formats).\n */\nexport async function downloadAndDecryptBuffer(\n  encryptedQueryParam: string,\n  aesKeyBase64: string,\n  cdnBaseUrl: string,\n  label: string,\n  fullUrl?: string,\n): Promise<Buffer> {\n  const key = parseAesKey(aesKeyBase64, label);\n  const url = fullUrl || buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl);\n  logger.debug(`${label}: fetching url=${url}`);\n  const encrypted = await fetchCdnBytes(url, label);\n  logger.debug(`${label}: downloaded ${encrypted.byteLength} bytes, decrypting`);\n  const decrypted = decryptAesEcb(encrypted, key);\n  logger.debug(`${label}: decrypted ${decrypted.length} bytes`);\n  return decrypted;\n}\n\n/**\n * Download plain (unencrypted) bytes from the CDN. Returns the raw Buffer.\n */\nexport async function downloadPlainCdnBuffer(\n  encryptedQueryParam: string,\n  cdnBaseUrl: string,\n  label: string,\n  fullUrl?: string,\n): Promise<Buffer> {\n  const url = fullUrl || buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl);\n  logger.debug(`${label}: fetching url=${url}`);\n  return fetchCdnBytes(url, label);\n}\n"
  },
  {
    "path": "packages/sdk/src/cdn/upload.ts",
    "content": "import crypto from \"node:crypto\";\nimport fs from \"node:fs/promises\";\nimport path from \"node:path\";\n\nimport { getUploadUrl } from \"../api/api.js\";\nimport type { WeixinApiOptions } from \"../api/api.js\";\nimport { aesEcbPaddedSize } from \"./aes-ecb.js\";\nimport { uploadBufferToCdn } from \"./cdn-upload.js\";\nimport { logger } from \"../util/logger.js\";\nimport { getExtensionFromContentTypeOrUrl } from \"../media/mime.js\";\nimport { tempFileName } from \"../util/random.js\";\nimport { UploadMediaType } from \"../api/types.js\";\n\nexport type UploadedFileInfo = {\n  filekey: string;\n  /** 由 upload_param 上传后 CDN 返回的下载加密参数; fill into ImageItem.media.encrypt_query_param */\n  downloadEncryptedQueryParam: string;\n  /** AES-128-ECB key, hex-encoded; convert to base64 for CDNMedia.aes_key */\n  aeskey: string;\n  /** Plaintext file size in bytes */\n  fileSize: number;\n  /** Ciphertext file size in bytes (AES-128-ECB with PKCS7 padding); use for ImageItem.hd_size / mid_size */\n  fileSizeCiphertext: number;\n};\n\n/**\n * Download a remote media URL (image, video, file) to a local temp file in destDir.\n * Returns the local file path; extension is inferred from Content-Type / URL.\n */\nexport async function downloadRemoteImageToTemp(url: string, destDir: string): Promise<string> {\n  logger.debug(`downloadRemoteImageToTemp: fetching url=${url}`);\n  const res = await fetch(url);\n  if (!res.ok) {\n    const msg = `remote media download failed: ${res.status} ${res.statusText} url=${url}`;\n    logger.error(`downloadRemoteImageToTemp: ${msg}`);\n    throw new Error(msg);\n  }\n  const buf = Buffer.from(await res.arrayBuffer());\n  logger.debug(`downloadRemoteImageToTemp: downloaded ${buf.length} bytes`);\n  await fs.mkdir(destDir, { recursive: true });\n  const ext = getExtensionFromContentTypeOrUrl(res.headers.get(\"content-type\"), url);\n  const name = tempFileName(\"weixin-remote\", ext);\n  const filePath = path.join(destDir, name);\n  await fs.writeFile(filePath, buf);\n  logger.debug(`downloadRemoteImageToTemp: saved to ${filePath} ext=${ext}`);\n  return filePath;\n}\n\n/**\n * Common upload pipeline: read file → hash → gen aeskey → getUploadUrl → uploadBufferToCdn → return info.\n */\nasync function uploadMediaToCdn(params: {\n  filePath: string;\n  toUserId: string;\n  opts: WeixinApiOptions;\n  cdnBaseUrl: string;\n  mediaType: (typeof UploadMediaType)[keyof typeof UploadMediaType];\n  label: string;\n}): Promise<UploadedFileInfo> {\n  const { filePath, toUserId, opts, cdnBaseUrl, mediaType, label } = params;\n\n  const plaintext = await fs.readFile(filePath);\n  const rawsize = plaintext.length;\n  const rawfilemd5 = crypto.createHash(\"md5\").update(plaintext).digest(\"hex\");\n  const filesize = aesEcbPaddedSize(rawsize);\n  const filekey = crypto.randomBytes(16).toString(\"hex\");\n  const aeskey = crypto.randomBytes(16);\n\n  logger.debug(\n    `${label}: file=${filePath} rawsize=${rawsize} filesize=${filesize} md5=${rawfilemd5} filekey=${filekey}`,\n  );\n\n  const uploadUrlResp = await getUploadUrl({\n    ...opts,\n    filekey,\n    media_type: mediaType,\n    to_user_id: toUserId,\n    rawsize,\n    rawfilemd5,\n    filesize,\n    no_need_thumb: true,\n    aeskey: aeskey.toString(\"hex\"),\n  });\n\n  const uploadFullUrl = uploadUrlResp.upload_full_url?.trim();\n  const uploadParam = uploadUrlResp.upload_param;\n  if (!uploadFullUrl && !uploadParam) {\n    logger.error(\n      `${label}: getUploadUrl returned no upload URL (need upload_full_url or upload_param), resp=${JSON.stringify(uploadUrlResp)}`,\n    );\n    throw new Error(`${label}: getUploadUrl returned no upload URL`);\n  }\n\n  const { downloadParam: downloadEncryptedQueryParam } = await uploadBufferToCdn({\n    buf: plaintext,\n    uploadFullUrl: uploadFullUrl || undefined,\n    uploadParam: uploadParam ?? undefined,\n    filekey,\n    cdnBaseUrl,\n    aeskey,\n    label: `${label}[orig filekey=${filekey}]`,\n  });\n\n  return {\n    filekey,\n    downloadEncryptedQueryParam,\n    aeskey: aeskey.toString(\"hex\"),\n    fileSize: rawsize,\n    fileSizeCiphertext: filesize,\n  };\n}\n\n/** Upload a local image file to the Weixin CDN with AES-128-ECB encryption. */\nexport async function uploadFileToWeixin(params: {\n  filePath: string;\n  toUserId: string;\n  opts: WeixinApiOptions;\n  cdnBaseUrl: string;\n}): Promise<UploadedFileInfo> {\n  return uploadMediaToCdn({\n    ...params,\n    mediaType: UploadMediaType.IMAGE,\n    label: \"uploadFileToWeixin\",\n  });\n}\n\n/** Upload a local video file to the Weixin CDN. */\nexport async function uploadVideoToWeixin(params: {\n  filePath: string;\n  toUserId: string;\n  opts: WeixinApiOptions;\n  cdnBaseUrl: string;\n}): Promise<UploadedFileInfo> {\n  return uploadMediaToCdn({\n    ...params,\n    mediaType: UploadMediaType.VIDEO,\n    label: \"uploadVideoToWeixin\",\n  });\n}\n\n/**\n * Upload a local file attachment (non-image, non-video) to the Weixin CDN.\n * Uses media_type=FILE; no thumbnail required.\n */\nexport async function uploadFileAttachmentToWeixin(params: {\n  filePath: string;\n  fileName: string;\n  toUserId: string;\n  opts: WeixinApiOptions;\n  cdnBaseUrl: string;\n}): Promise<UploadedFileInfo> {\n  return uploadMediaToCdn({\n    ...params,\n    mediaType: UploadMediaType.FILE,\n    label: \"uploadFileAttachmentToWeixin\",\n  });\n}\n"
  },
  {
    "path": "packages/sdk/src/media/media-download.ts",
    "content": "import type { WeixinInboundMediaOpts } from \"../messaging/inbound.js\";\nimport { logger } from \"../util/logger.js\";\nimport { getMimeFromFilename } from \"./mime.js\";\nimport {\n  downloadAndDecryptBuffer,\n  downloadPlainCdnBuffer,\n} from \"../cdn/pic-decrypt.js\";\nimport { silkToWav } from \"./silk-transcode.js\";\nimport type { WeixinMessage } from \"../api/types.js\";\nimport { MessageItemType } from \"../api/types.js\";\n\nconst WEIXIN_MEDIA_MAX_BYTES = 100 * 1024 * 1024;\n\n/** Persist a buffer via the framework's unified media store. */\ntype SaveMediaFn = (\n  buffer: Buffer,\n  contentType?: string,\n  subdir?: string,\n  maxBytes?: number,\n  originalFilename?: string,\n) => Promise<{ path: string }>;\n\n/**\n * Download and decrypt media from a single MessageItem.\n * Returns the populated WeixinInboundMediaOpts fields; empty object on unsupported type or failure.\n */\nexport async function downloadMediaFromItem(\n  item: WeixinMessage[\"item_list\"] extends (infer T)[] | undefined ? T : never,\n  deps: {\n    cdnBaseUrl: string;\n    saveMedia: SaveMediaFn;\n    log: (msg: string) => void;\n    errLog: (msg: string) => void;\n    label: string;\n  },\n): Promise<WeixinInboundMediaOpts> {\n  const { cdnBaseUrl, saveMedia, log, errLog, label } = deps;\n  const result: WeixinInboundMediaOpts = {};\n\n  if (item.type === MessageItemType.IMAGE) {\n    const img = item.image_item;\n    if (!img?.media?.encrypt_query_param && !img?.media?.full_url) return result;\n    const aesKeyBase64 = img.aeskey\n      ? Buffer.from(img.aeskey, \"hex\").toString(\"base64\")\n      : img.media.aes_key;\n    logger.debug(\n      `${label} image: encrypt_query_param=${(img.media.encrypt_query_param ?? \"\").slice(0, 40)}... hasAesKey=${Boolean(aesKeyBase64)} aeskeySource=${img.aeskey ? \"image_item.aeskey\" : \"media.aes_key\"} full_url=${Boolean(img.media.full_url)}`,\n    );\n    try {\n      const buf = aesKeyBase64\n        ? await downloadAndDecryptBuffer(\n            img.media.encrypt_query_param ?? \"\",\n            aesKeyBase64,\n            cdnBaseUrl,\n            `${label} image`,\n            img.media.full_url,\n          )\n        : await downloadPlainCdnBuffer(\n            img.media.encrypt_query_param ?? \"\",\n            cdnBaseUrl,\n            `${label} image-plain`,\n            img.media.full_url,\n          );\n      const saved = await saveMedia(buf, undefined, \"inbound\", WEIXIN_MEDIA_MAX_BYTES);\n      result.decryptedPicPath = saved.path;\n      logger.debug(`${label} image saved: ${saved.path}`);\n    } catch (err) {\n      logger.error(`${label} image download/decrypt failed: ${String(err)}`);\n      errLog(`weixin ${label} image download/decrypt failed: ${String(err)}`);\n    }\n  } else if (item.type === MessageItemType.VOICE) {\n    const voice = item.voice_item;\n    if ((!voice?.media?.encrypt_query_param && !voice?.media?.full_url) || !voice?.media?.aes_key)\n      return result;\n    try {\n      const silkBuf = await downloadAndDecryptBuffer(\n        voice.media.encrypt_query_param ?? \"\",\n        voice.media.aes_key,\n        cdnBaseUrl,\n        `${label} voice`,\n        voice.media.full_url,\n      );\n      logger.debug(`${label} voice: decrypted ${silkBuf.length} bytes, attempting silk transcode`);\n      const wavBuf = await silkToWav(silkBuf);\n      if (wavBuf) {\n        const saved = await saveMedia(wavBuf, \"audio/wav\", \"inbound\", WEIXIN_MEDIA_MAX_BYTES);\n        result.decryptedVoicePath = saved.path;\n        result.voiceMediaType = \"audio/wav\";\n        logger.debug(`${label} voice: saved WAV to ${saved.path}`);\n      } else {\n        const saved = await saveMedia(silkBuf, \"audio/silk\", \"inbound\", WEIXIN_MEDIA_MAX_BYTES);\n        result.decryptedVoicePath = saved.path;\n        result.voiceMediaType = \"audio/silk\";\n        logger.debug(`${label} voice: silk transcode unavailable, saved raw SILK to ${saved.path}`);\n      }\n    } catch (err) {\n      logger.error(`${label} voice download/transcode failed: ${String(err)}`);\n      errLog(`weixin ${label} voice download/transcode failed: ${String(err)}`);\n    }\n  } else if (item.type === MessageItemType.FILE) {\n    const fileItem = item.file_item;\n    if ((!fileItem?.media?.encrypt_query_param && !fileItem?.media?.full_url) || !fileItem?.media?.aes_key)\n      return result;\n    try {\n      const buf = await downloadAndDecryptBuffer(\n        fileItem.media.encrypt_query_param ?? \"\",\n        fileItem.media.aes_key,\n        cdnBaseUrl,\n        `${label} file`,\n        fileItem.media.full_url,\n      );\n      const mime = getMimeFromFilename(fileItem.file_name ?? \"file.bin\");\n      const saved = await saveMedia(\n        buf,\n        mime,\n        \"inbound\",\n        WEIXIN_MEDIA_MAX_BYTES,\n        fileItem.file_name ?? undefined,\n      );\n      result.decryptedFilePath = saved.path;\n      result.fileMediaType = mime;\n      logger.debug(`${label} file: saved to ${saved.path} mime=${mime}`);\n    } catch (err) {\n      logger.error(`${label} file download failed: ${String(err)}`);\n      errLog(`weixin ${label} file download failed: ${String(err)}`);\n    }\n  } else if (item.type === MessageItemType.VIDEO) {\n    const videoItem = item.video_item;\n    if ((!videoItem?.media?.encrypt_query_param && !videoItem?.media?.full_url) || !videoItem?.media?.aes_key)\n      return result;\n    try {\n      const buf = await downloadAndDecryptBuffer(\n        videoItem.media.encrypt_query_param ?? \"\",\n        videoItem.media.aes_key,\n        cdnBaseUrl,\n        `${label} video`,\n        videoItem.media.full_url,\n      );\n      const saved = await saveMedia(buf, \"video/mp4\", \"inbound\", WEIXIN_MEDIA_MAX_BYTES);\n      result.decryptedVideoPath = saved.path;\n      logger.debug(`${label} video: saved to ${saved.path}`);\n    } catch (err) {\n      logger.error(`${label} video download failed: ${String(err)}`);\n      errLog(`weixin ${label} video download failed: ${String(err)}`);\n    }\n  }\n\n  return result;\n}\n"
  },
  {
    "path": "packages/sdk/src/media/mime.ts",
    "content": "import path from \"node:path\";\n\nconst EXTENSION_TO_MIME: Record<string, string> = {\n  \".pdf\": \"application/pdf\",\n  \".doc\": \"application/msword\",\n  \".docx\": \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\n  \".xls\": \"application/vnd.ms-excel\",\n  \".xlsx\": \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n  \".ppt\": \"application/vnd.ms-powerpoint\",\n  \".pptx\": \"application/vnd.openxmlformats-officedocument.presentationml.presentation\",\n  \".txt\": \"text/plain\",\n  \".csv\": \"text/csv\",\n  \".zip\": \"application/zip\",\n  \".tar\": \"application/x-tar\",\n  \".gz\": \"application/gzip\",\n  \".mp3\": \"audio/mpeg\",\n  \".ogg\": \"audio/ogg\",\n  \".wav\": \"audio/wav\",\n  \".mp4\": \"video/mp4\",\n  \".mov\": \"video/quicktime\",\n  \".webm\": \"video/webm\",\n  \".mkv\": \"video/x-matroska\",\n  \".avi\": \"video/x-msvideo\",\n  \".png\": \"image/png\",\n  \".jpg\": \"image/jpeg\",\n  \".jpeg\": \"image/jpeg\",\n  \".gif\": \"image/gif\",\n  \".webp\": \"image/webp\",\n  \".bmp\": \"image/bmp\",\n};\n\nconst MIME_TO_EXTENSION: Record<string, string> = {\n  \"image/jpeg\": \".jpg\",\n  \"image/jpg\": \".jpg\",\n  \"image/png\": \".png\",\n  \"image/gif\": \".gif\",\n  \"image/webp\": \".webp\",\n  \"image/bmp\": \".bmp\",\n  \"video/mp4\": \".mp4\",\n  \"video/quicktime\": \".mov\",\n  \"video/webm\": \".webm\",\n  \"video/x-matroska\": \".mkv\",\n  \"video/x-msvideo\": \".avi\",\n  \"audio/mpeg\": \".mp3\",\n  \"audio/ogg\": \".ogg\",\n  \"audio/wav\": \".wav\",\n  \"application/pdf\": \".pdf\",\n  \"application/zip\": \".zip\",\n  \"application/x-tar\": \".tar\",\n  \"application/gzip\": \".gz\",\n  \"text/plain\": \".txt\",\n  \"text/csv\": \".csv\",\n};\n\n/** Get MIME type from filename extension. Returns \"application/octet-stream\" for unknown extensions. */\nexport function getMimeFromFilename(filename: string): string {\n  const ext = path.extname(filename).toLowerCase();\n  return EXTENSION_TO_MIME[ext] ?? \"application/octet-stream\";\n}\n\n/** Get file extension from MIME type. Returns \".bin\" for unknown types. */\nexport function getExtensionFromMime(mimeType: string): string {\n  const ct = mimeType.split(\";\")[0].trim().toLowerCase();\n  return MIME_TO_EXTENSION[ct] ?? \".bin\";\n}\n\n/** Get file extension from Content-Type header or URL path. Returns \".bin\" for unknown. */\nexport function getExtensionFromContentTypeOrUrl(contentType: string | null, url: string): string {\n  if (contentType) {\n    const ext = getExtensionFromMime(contentType);\n    if (ext !== \".bin\") return ext;\n  }\n  const ext = path.extname(new URL(url).pathname).toLowerCase();\n  const knownExts = new Set(Object.keys(EXTENSION_TO_MIME));\n  return knownExts.has(ext) ? ext : \".bin\";\n}\n"
  },
  {
    "path": "packages/sdk/src/media/silk-transcode.ts",
    "content": "import { logger } from \"../util/logger.js\";\n\n/** Default sample rate for Weixin voice messages. */\nconst SILK_SAMPLE_RATE = 24_000;\n\n/**\n * Wrap raw pcm_s16le bytes in a WAV container.\n * Mono channel, 16-bit signed little-endian.\n */\nfunction pcmBytesToWav(pcm: Uint8Array, sampleRate: number): Buffer {\n  const pcmBytes = pcm.byteLength;\n  const totalSize = 44 + pcmBytes;\n  const buf = Buffer.allocUnsafe(totalSize);\n  let offset = 0;\n\n  buf.write(\"RIFF\", offset);\n  offset += 4;\n  buf.writeUInt32LE(totalSize - 8, offset);\n  offset += 4;\n  buf.write(\"WAVE\", offset);\n  offset += 4;\n\n  buf.write(\"fmt \", offset);\n  offset += 4;\n  buf.writeUInt32LE(16, offset);\n  offset += 4; // fmt chunk size\n  buf.writeUInt16LE(1, offset);\n  offset += 2; // PCM format\n  buf.writeUInt16LE(1, offset);\n  offset += 2; // mono\n  buf.writeUInt32LE(sampleRate, offset);\n  offset += 4;\n  buf.writeUInt32LE(sampleRate * 2, offset);\n  offset += 4; // byte rate (mono 16-bit)\n  buf.writeUInt16LE(2, offset);\n  offset += 2; // block align\n  buf.writeUInt16LE(16, offset);\n  offset += 2; // bits per sample\n\n  buf.write(\"data\", offset);\n  offset += 4;\n  buf.writeUInt32LE(pcmBytes, offset);\n  offset += 4;\n\n  Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength).copy(buf, offset);\n\n  return buf;\n}\n\n/**\n * Try to transcode a SILK audio buffer to WAV using silk-wasm.\n * silk-wasm's decode() returns { data: Uint8Array (pcm_s16le), duration: number }.\n *\n * Returns a WAV Buffer on success, or null if silk-wasm is unavailable or decoding fails.\n * Callers should fall back to passing the raw SILK file when null is returned.\n */\nexport async function silkToWav(silkBuf: Buffer): Promise<Buffer | null> {\n  try {\n    const { decode } = await import(\"silk-wasm\");\n\n    logger.debug(`silkToWav: decoding ${silkBuf.length} bytes of SILK`);\n    const result = await decode(silkBuf, SILK_SAMPLE_RATE);\n    logger.debug(\n      `silkToWav: decoded duration=${result.duration}ms pcmBytes=${result.data.byteLength}`,\n    );\n\n    const wav = pcmBytesToWav(result.data, SILK_SAMPLE_RATE);\n    logger.debug(`silkToWav: WAV size=${wav.length}`);\n    return wav;\n  } catch (err) {\n    logger.warn(`silkToWav: transcode failed, will use raw silk err=${String(err)}`);\n    return null;\n  }\n}\n"
  },
  {
    "path": "packages/sdk/src/messaging/debug-mode.ts",
    "content": "/**\n * Per-bot debug mode toggle, persisted to disk so it survives gateway restarts.\n *\n * State file: `<stateDir>/openclaw-weixin/debug-mode.json`\n * Format:     `{ \"accounts\": { \"<accountId>\": true, ... } }`\n *\n * When enabled, processOneMessage appends a timing summary after each\n * AI reply is delivered to the user.\n */\nimport fs from \"node:fs\";\nimport path from \"node:path\";\n\nimport { resolveStateDir } from \"../storage/state-dir.js\";\nimport { logger } from \"../util/logger.js\";\n\ninterface DebugModeState {\n  accounts: Record<string, boolean>;\n}\n\nfunction resolveDebugModePath(): string {\n  return path.join(resolveStateDir(), \"openclaw-weixin\", \"debug-mode.json\");\n}\n\nfunction loadState(): DebugModeState {\n  try {\n    const raw = fs.readFileSync(resolveDebugModePath(), \"utf-8\");\n    const parsed = JSON.parse(raw) as DebugModeState;\n    if (parsed && typeof parsed.accounts === \"object\") return parsed;\n  } catch {\n    // missing or corrupt — start fresh\n  }\n  return { accounts: {} };\n}\n\nfunction saveState(state: DebugModeState): void {\n  const filePath = resolveDebugModePath();\n  fs.mkdirSync(path.dirname(filePath), { recursive: true });\n  fs.writeFileSync(filePath, JSON.stringify(state, null, 2), \"utf-8\");\n}\n\n/** Toggle debug mode for a bot account. Returns the new state. */\nexport function toggleDebugMode(accountId: string): boolean {\n  const state = loadState();\n  const next = !state.accounts[accountId];\n  state.accounts[accountId] = next;\n  try {\n    saveState(state);\n  } catch (err) {\n    logger.error(`debug-mode: failed to persist state: ${String(err)}`);\n  }\n  return next;\n}\n\n/** Check whether debug mode is active for a bot account. */\nexport function isDebugMode(accountId: string): boolean {\n  return loadState().accounts[accountId] === true;\n}\n\n/**\n * Reset internal state — only for tests.\n * @internal\n */\nexport function _resetForTest(): void {\n  try {\n    fs.unlinkSync(resolveDebugModePath());\n  } catch {\n    // ignore if not present\n  }\n}\n"
  },
  {
    "path": "packages/sdk/src/messaging/error-notice.ts",
    "content": "import { logger } from \"../util/logger.js\";\nimport { sendMessageWeixin } from \"./send.js\";\n\n/**\n * Send a plain-text error notice back to the user.\n * Fire-and-forget: errors are logged but never thrown, so callers stay unaffected.\n * No-op when contextToken is absent (we have no conversation reference to reply into).\n */\nexport async function sendWeixinErrorNotice(params: {\n  to: string;\n  contextToken: string | undefined;\n  message: string;\n  baseUrl: string;\n  token?: string;\n  errLog: (m: string) => void;\n}): Promise<void> {\n  if (!params.contextToken) {\n    logger.warn(`sendWeixinErrorNotice: no contextToken for to=${params.to}, cannot notify user`);\n    return;\n  }\n  try {\n    await sendMessageWeixin({ to: params.to, text: params.message, opts: {\n      baseUrl: params.baseUrl,\n      token: params.token,\n      contextToken: params.contextToken,\n    }});\n    logger.debug(`sendWeixinErrorNotice: sent to=${params.to}`);\n  } catch (err) {\n    params.errLog(`[weixin] sendWeixinErrorNotice failed to=${params.to}: ${String(err)}`);\n  }\n}\n"
  },
  {
    "path": "packages/sdk/src/messaging/inbound.ts",
    "content": "import { logger } from \"../util/logger.js\";\nimport { generateId } from \"../util/random.js\";\nimport type { WeixinMessage, MessageItem } from \"../api/types.js\";\nimport { MessageItemType } from \"../api/types.js\";\n\n// ---------------------------------------------------------------------------\n// Context token store (in-process cache: accountId+userId → contextToken)\n// ---------------------------------------------------------------------------\n\n/**\n * contextToken is issued per-message by the Weixin getupdates API and must\n * be echoed verbatim in every outbound send. It is not persisted: the monitor\n * loop populates this map on each inbound message, and the outbound adapter\n * reads it back when the agent sends a reply.\n */\nconst contextTokenStore = new Map<string, string>();\n\nfunction contextTokenKey(accountId: string, userId: string): string {\n  return `${accountId}:${userId}`;\n}\n\n/** Store a context token for a given account+user pair. */\nexport function setContextToken(accountId: string, userId: string, token: string): void {\n  const k = contextTokenKey(accountId, userId);\n  logger.debug(`setContextToken: key=${k}`);\n  contextTokenStore.set(k, token);\n}\n\n/** Retrieve the cached context token for a given account+user pair. */\nexport function getContextToken(accountId: string, userId: string): string | undefined {\n  const k = contextTokenKey(accountId, userId);\n  const val = contextTokenStore.get(k);\n  logger.debug(\n    `getContextToken: key=${k} found=${val !== undefined} storeSize=${contextTokenStore.size}`,\n  );\n  return val;\n}\n\n// ---------------------------------------------------------------------------\n// Message ID generation\n// ---------------------------------------------------------------------------\n\nfunction generateMessageSid(): string {\n  return generateId(\"openclaw-weixin\");\n}\n\n/** Inbound context passed to the OpenClaw core pipeline (matches MsgContext shape). */\nexport type WeixinMsgContext = {\n  Body: string;\n  From: string;\n  To: string;\n  AccountId: string;\n  OriginatingChannel: \"openclaw-weixin\";\n  OriginatingTo: string;\n  MessageSid: string;\n  Timestamp?: number;\n  Provider: \"openclaw-weixin\";\n  ChatType: \"direct\";\n  /** Set by monitor after resolveAgentRoute so dispatchReplyFromConfig uses the correct session. */\n  SessionKey?: string;\n  context_token?: string;\n  MediaUrl?: string;\n  MediaPath?: string;\n  MediaType?: string;\n  /** Raw message body for framework command authorization. */\n  CommandBody?: string;\n  /** Whether the sender is authorized to execute slash commands. */\n  CommandAuthorized?: boolean;\n};\n\n/** Returns true if the message item is a media type (image, video, file, or voice). */\nexport function isMediaItem(item: MessageItem): boolean {\n  return (\n    item.type === MessageItemType.IMAGE ||\n    item.type === MessageItemType.VIDEO ||\n    item.type === MessageItemType.FILE ||\n    item.type === MessageItemType.VOICE\n  );\n}\n\nexport function bodyFromItemList(itemList?: MessageItem[]): string {\n  if (!itemList?.length) return \"\";\n  for (const item of itemList) {\n    if (item.type === MessageItemType.TEXT && item.text_item?.text != null) {\n      const text = String(item.text_item.text);\n      const ref = item.ref_msg;\n      if (!ref) return text;\n      // Quoted media is passed as MediaPath; only include the current text as body.\n      if (ref.message_item && isMediaItem(ref.message_item)) return text;\n      // Build quoted context from both title and message_item content.\n      const parts: string[] = [];\n      if (ref.title) parts.push(ref.title);\n      if (ref.message_item) {\n        const refBody = bodyFromItemList([ref.message_item]);\n        if (refBody) parts.push(refBody);\n      }\n      if (!parts.length) return text;\n      return `[引用: ${parts.join(\" | \")}]\\n${text}`;\n    }\n    // 语音转文字：如果语音消息有 text 字段，直接使用文字内容\n    if (item.type === MessageItemType.VOICE && item.voice_item?.text) {\n      return item.voice_item.text;\n    }\n  }\n  return \"\";\n}\n\nexport type WeixinInboundMediaOpts = {\n  /** Local path to decrypted image file. */\n  decryptedPicPath?: string;\n  /** Local path to transcoded/raw voice file (.wav or .silk). */\n  decryptedVoicePath?: string;\n  /** MIME type for the voice file (e.g. \"audio/wav\" or \"audio/silk\"). */\n  voiceMediaType?: string;\n  /** Local path to decrypted file attachment. */\n  decryptedFilePath?: string;\n  /** MIME type for the file attachment (guessed from file_name). */\n  fileMediaType?: string;\n  /** Local path to decrypted video file. */\n  decryptedVideoPath?: string;\n};\n\n/**\n * Convert a WeixinMessage from getUpdates to the inbound MsgContext for the core pipeline.\n * Media: only pass MediaPath (local file, after CDN download + decrypt).\n * We never pass MediaUrl — the upstream CDN URL is encrypted/auth-only.\n * Priority when multiple media types present: image > video > file > voice.\n */\nexport function weixinMessageToMsgContext(\n  msg: WeixinMessage,\n  accountId: string,\n  opts?: WeixinInboundMediaOpts,\n): WeixinMsgContext {\n  const from_user_id = msg.from_user_id ?? \"\";\n  const ctx: WeixinMsgContext = {\n    Body: bodyFromItemList(msg.item_list),\n    From: from_user_id,\n    To: from_user_id,\n    AccountId: accountId,\n    OriginatingChannel: \"openclaw-weixin\",\n    OriginatingTo: from_user_id,\n    MessageSid: generateMessageSid(),\n    Timestamp: msg.create_time_ms,\n    Provider: \"openclaw-weixin\",\n    ChatType: \"direct\",\n  };\n  if (msg.context_token) {\n    ctx.context_token = msg.context_token;\n  }\n\n  if (opts?.decryptedPicPath) {\n    ctx.MediaPath = opts.decryptedPicPath;\n    ctx.MediaType = \"image/*\";\n  } else if (opts?.decryptedVideoPath) {\n    ctx.MediaPath = opts.decryptedVideoPath;\n    ctx.MediaType = \"video/mp4\";\n  } else if (opts?.decryptedFilePath) {\n    ctx.MediaPath = opts.decryptedFilePath;\n    ctx.MediaType = opts.fileMediaType ?? \"application/octet-stream\";\n  } else if (opts?.decryptedVoicePath) {\n    ctx.MediaPath = opts.decryptedVoicePath;\n    ctx.MediaType = opts.voiceMediaType ?? \"audio/wav\";\n  }\n\n  return ctx;\n}\n\n/** Extract the context_token from an inbound WeixinMsgContext. */\nexport function getContextTokenFromMsgContext(ctx: WeixinMsgContext): string | undefined {\n  return ctx.context_token;\n}\n"
  },
  {
    "path": "packages/sdk/src/messaging/process-message.ts",
    "content": "import crypto from \"node:crypto\";\nimport fs from \"node:fs/promises\";\nimport os from \"node:os\";\nimport path from \"node:path\";\n\nimport type { Agent, ChatRequest } from \"../agent/interface.js\";\nimport { sendTyping } from \"../api/api.js\";\nimport type { WeixinMessage, MessageItem } from \"../api/types.js\";\nimport { MessageItemType, TypingStatus } from \"../api/types.js\";\nimport { downloadRemoteImageToTemp } from \"../cdn/upload.js\";\nimport { downloadMediaFromItem } from \"../media/media-download.js\";\nimport { getExtensionFromMime } from \"../media/mime.js\";\nimport { logger } from \"../util/logger.js\";\n\nimport { setContextToken, bodyFromItemList, isMediaItem } from \"./inbound.js\";\nimport { sendWeixinErrorNotice } from \"./error-notice.js\";\nimport { sendWeixinMediaFile } from \"./send-media.js\";\nimport { markdownToPlainText, sendMessageWeixin } from \"./send.js\";\nimport { handleSlashCommand } from \"./slash-commands.js\";\n\nconst MEDIA_TEMP_DIR = path.join(os.tmpdir(), \"weixin-agent/media\");\n\n/** Save a buffer to a temporary file, returning the file path. */\nasync function saveMediaBuffer(\n  buffer: Buffer,\n  contentType?: string,\n  subdir?: string,\n  _maxBytes?: number,\n  originalFilename?: string,\n): Promise<{ path: string }> {\n  const dir = path.join(MEDIA_TEMP_DIR, subdir ?? \"\");\n  await fs.mkdir(dir, { recursive: true });\n  let ext = \".bin\";\n  if (originalFilename) {\n    ext = path.extname(originalFilename) || \".bin\";\n  } else if (contentType) {\n    ext = getExtensionFromMime(contentType);\n  }\n  const name = `${Date.now()}-${crypto.randomBytes(4).toString(\"hex\")}${ext}`;\n  const filePath = path.join(dir, name);\n  await fs.writeFile(filePath, buffer);\n  return { path: filePath };\n}\n\n/** Dependencies for processOneMessage. */\nexport type ProcessMessageDeps = {\n  accountId: string;\n  agent: Agent;\n  baseUrl: string;\n  cdnBaseUrl: string;\n  token?: string;\n  typingTicket?: string;\n  log: (msg: string) => void;\n  errLog: (msg: string) => void;\n};\n\n/** Extract raw text from item_list (for slash command detection). */\nfunction extractTextBody(itemList?: MessageItem[]): string {\n  if (!itemList?.length) return \"\";\n  for (const item of itemList) {\n    if (item.type === MessageItemType.TEXT && item.text_item?.text != null) {\n      return String(item.text_item.text);\n    }\n  }\n  return \"\";\n}\n\n/** Find the first downloadable media item from a message. */\nfunction findMediaItem(itemList?: MessageItem[]): MessageItem | undefined {\n  if (!itemList?.length) return undefined;\n\n  const hasDownloadableMedia = (m?: { encrypt_query_param?: string; full_url?: string }) =>\n    m?.encrypt_query_param || m?.full_url;\n\n  // Direct media: IMAGE > VIDEO > FILE > VOICE (skip voice with transcription)\n  const direct =\n    itemList.find(\n      (i) => i.type === MessageItemType.IMAGE && hasDownloadableMedia(i.image_item?.media),\n    ) ??\n    itemList.find(\n      (i) => i.type === MessageItemType.VIDEO && hasDownloadableMedia(i.video_item?.media),\n    ) ??\n    itemList.find(\n      (i) => i.type === MessageItemType.FILE && hasDownloadableMedia(i.file_item?.media),\n    ) ??\n    itemList.find(\n      (i) =>\n        i.type === MessageItemType.VOICE &&\n        hasDownloadableMedia(i.voice_item?.media) &&\n        !i.voice_item?.text,\n    );\n  if (direct) return direct;\n\n  // Quoted media: check ref_msg\n  const refItem = itemList.find(\n    (i) =>\n      i.type === MessageItemType.TEXT &&\n      i.ref_msg?.message_item &&\n      isMediaItem(i.ref_msg.message_item),\n  );\n  return refItem?.ref_msg?.message_item ?? undefined;\n}\n\n/**\n * Process a single inbound message:\n *   slash command check → download media → call agent → send reply.\n */\nexport async function processOneMessage(\n  full: WeixinMessage,\n  deps: ProcessMessageDeps,\n): Promise<void> {\n  const receivedAt = Date.now();\n  const textBody = extractTextBody(full.item_list);\n\n  // --- Slash commands ---\n  if (textBody.startsWith(\"/\")) {\n    const conversationId = full.from_user_id ?? \"\";\n    const slashResult = await handleSlashCommand(\n      textBody,\n      {\n        to: conversationId,\n        contextToken: full.context_token,\n        baseUrl: deps.baseUrl,\n        token: deps.token,\n        accountId: deps.accountId,\n        log: deps.log,\n        errLog: deps.errLog,\n        onClear: () => deps.agent.clearSession?.(conversationId),\n      },\n      receivedAt,\n      full.create_time_ms,\n    );\n    if (slashResult.handled) return;\n  }\n\n  // --- Store context token ---\n  const contextToken = full.context_token;\n  if (contextToken) {\n    setContextToken(deps.accountId, full.from_user_id ?? \"\", contextToken);\n  }\n\n  // --- Download media ---\n  let media: ChatRequest[\"media\"];\n  const mediaItem = findMediaItem(full.item_list);\n  if (mediaItem) {\n    try {\n      const downloaded = await downloadMediaFromItem(mediaItem, {\n        cdnBaseUrl: deps.cdnBaseUrl,\n        saveMedia: saveMediaBuffer,\n        log: deps.log,\n        errLog: deps.errLog,\n        label: \"inbound\",\n      });\n      if (downloaded.decryptedPicPath) {\n        media = { type: \"image\", filePath: downloaded.decryptedPicPath, mimeType: \"image/*\" };\n      } else if (downloaded.decryptedVideoPath) {\n        media = { type: \"video\", filePath: downloaded.decryptedVideoPath, mimeType: \"video/mp4\" };\n      } else if (downloaded.decryptedFilePath) {\n        media = {\n          type: \"file\",\n          filePath: downloaded.decryptedFilePath,\n          mimeType: downloaded.fileMediaType ?? \"application/octet-stream\",\n        };\n      } else if (downloaded.decryptedVoicePath) {\n        media = {\n          type: \"audio\",\n          filePath: downloaded.decryptedVoicePath,\n          mimeType: downloaded.voiceMediaType ?? \"audio/wav\",\n        };\n      }\n    } catch (err) {\n      logger.error(`media download failed: ${String(err)}`);\n    }\n  }\n\n  // --- Build ChatRequest ---\n  const request: ChatRequest = {\n    conversationId: full.from_user_id ?? \"\",\n    text: bodyFromItemList(full.item_list),\n    media,\n  };\n\n  // --- Typing indicator (start + periodic refresh) ---\n  const to = full.from_user_id ?? \"\";\n  let typingTimer: ReturnType<typeof setInterval> | undefined;\n  const startTyping = () => {\n    if (!deps.typingTicket) return;\n    sendTyping({\n      baseUrl: deps.baseUrl,\n      token: deps.token,\n      body: {\n        ilink_user_id: to,\n        typing_ticket: deps.typingTicket,\n        status: TypingStatus.TYPING,\n      },\n    }).catch(() => {});\n  };\n  if (deps.typingTicket) {\n    startTyping();\n    typingTimer = setInterval(startTyping, 10_000);\n  }\n\n  // --- Call agent & send reply ---\n  try {\n    const response = await deps.agent.chat(request);\n\n    if (response.media) {\n      let filePath: string;\n      const mediaUrl = response.media.url;\n      if (mediaUrl.startsWith(\"http://\") || mediaUrl.startsWith(\"https://\")) {\n        filePath = await downloadRemoteImageToTemp(\n          mediaUrl,\n          path.join(MEDIA_TEMP_DIR, \"outbound\"),\n        );\n      } else {\n        filePath = path.isAbsolute(mediaUrl) ? mediaUrl : path.resolve(mediaUrl);\n      }\n      await sendWeixinMediaFile({\n        filePath,\n        to,\n        text: response.text ? markdownToPlainText(response.text) : \"\",\n        opts: { baseUrl: deps.baseUrl, token: deps.token, contextToken },\n        cdnBaseUrl: deps.cdnBaseUrl,\n      });\n    } else if (response.text) {\n      await sendMessageWeixin({\n        to,\n        text: markdownToPlainText(response.text),\n        opts: { baseUrl: deps.baseUrl, token: deps.token, contextToken },\n      });\n    }\n  } catch (err) {\n    logger.error(`processOneMessage: agent or send failed: ${err instanceof Error ? err.stack ?? err.message : JSON.stringify(err)}`);\n    void sendWeixinErrorNotice({\n      to,\n      contextToken,\n      message: `⚠️ 处理消息失败：${err instanceof Error ? err.message : JSON.stringify(err)}`,\n      baseUrl: deps.baseUrl,\n      token: deps.token,\n      errLog: deps.errLog,\n    });\n  } finally {\n    // --- Typing indicator (cancel) ---\n    if (typingTimer) clearInterval(typingTimer);\n    if (deps.typingTicket) {\n      sendTyping({\n        baseUrl: deps.baseUrl,\n        token: deps.token,\n        body: {\n          ilink_user_id: to,\n          typing_ticket: deps.typingTicket,\n          status: TypingStatus.CANCEL,\n        },\n      }).catch(() => {});\n    }\n  }\n}\n"
  },
  {
    "path": "packages/sdk/src/messaging/send-media.ts",
    "content": "import path from \"node:path\";\nimport type { WeixinApiOptions } from \"../api/api.js\";\nimport { logger } from \"../util/logger.js\";\nimport { getMimeFromFilename } from \"../media/mime.js\";\nimport { sendFileMessageWeixin, sendImageMessageWeixin, sendVideoMessageWeixin } from \"./send.js\";\nimport { uploadFileAttachmentToWeixin, uploadFileToWeixin, uploadVideoToWeixin } from \"../cdn/upload.js\";\n\n/**\n * Upload a local file and send it as a weixin message, routing by MIME type:\n *   video/*  → uploadVideoToWeixin        + sendVideoMessageWeixin\n *   image/*  → uploadFileToWeixin         + sendImageMessageWeixin\n *   else     → uploadFileAttachmentToWeixin + sendFileMessageWeixin\n *\n * Used by both the auto-reply deliver path (monitor.ts) and the outbound\n * sendMedia path (channel.ts) so they stay in sync.\n */\nexport async function sendWeixinMediaFile(params: {\n  filePath: string;\n  to: string;\n  text: string;\n  opts: WeixinApiOptions & { contextToken?: string };\n  cdnBaseUrl: string;\n}): Promise<{ messageId: string }> {\n  const { filePath, to, text, opts, cdnBaseUrl } = params;\n  const mime = getMimeFromFilename(filePath);\n  const uploadOpts: WeixinApiOptions = { baseUrl: opts.baseUrl, token: opts.token };\n\n  if (mime.startsWith(\"video/\")) {\n    logger.info(`[weixin] sendWeixinMediaFile: uploading video filePath=${filePath} to=${to}`);\n    const uploaded = await uploadVideoToWeixin({\n      filePath,\n      toUserId: to,\n      opts: uploadOpts,\n      cdnBaseUrl,\n    });\n    logger.info(\n      `[weixin] sendWeixinMediaFile: video upload done filekey=${uploaded.filekey} size=${uploaded.fileSize}`,\n    );\n    return sendVideoMessageWeixin({ to, text, uploaded, opts });\n  }\n\n  if (mime.startsWith(\"image/\")) {\n    logger.info(`[weixin] sendWeixinMediaFile: uploading image filePath=${filePath} to=${to}`);\n    const uploaded = await uploadFileToWeixin({\n      filePath,\n      toUserId: to,\n      opts: uploadOpts,\n      cdnBaseUrl,\n    });\n    logger.info(\n      `[weixin] sendWeixinMediaFile: image upload done filekey=${uploaded.filekey} size=${uploaded.fileSize}`,\n    );\n    return sendImageMessageWeixin({ to, text, uploaded, opts });\n  }\n\n  // File attachment: pdf, doc, zip, etc.\n  const fileName = path.basename(filePath);\n  logger.info(\n    `[weixin] sendWeixinMediaFile: uploading file attachment filePath=${filePath} name=${fileName} to=${to}`,\n  );\n  const uploaded = await uploadFileAttachmentToWeixin({\n    filePath,\n    fileName,\n    toUserId: to,\n    opts: uploadOpts,\n    cdnBaseUrl,\n  });\n  logger.info(\n    `[weixin] sendWeixinMediaFile: file upload done filekey=${uploaded.filekey} size=${uploaded.fileSize}`,\n  );\n  return sendFileMessageWeixin({ to, text, fileName, uploaded, opts });\n}\n"
  },
  {
    "path": "packages/sdk/src/messaging/send.ts",
    "content": "import { sendMessage as sendMessageApi } from \"../api/api.js\";\nimport type { WeixinApiOptions } from \"../api/api.js\";\nimport { logger } from \"../util/logger.js\";\nimport { generateId } from \"../util/random.js\";\nimport type { MessageItem, SendMessageReq } from \"../api/types.js\";\nimport { MessageItemType, MessageState, MessageType } from \"../api/types.js\";\nimport type { UploadedFileInfo } from \"../cdn/upload.js\";\n\nexport function generateClientId(): string {\n  return generateId(\"openclaw-weixin\");\n}\n\n/**\n * Convert markdown-formatted model reply to plain text for Weixin delivery.\n * Preserves newlines; strips markdown syntax.\n */\nexport function markdownToPlainText(text: string): string {\n  let result = text;\n  // Code blocks: strip fences, keep code content\n  result = result.replace(/```[^\\n]*\\n?([\\s\\S]*?)```/g, (_, code: string) => code.trim());\n  // Images: remove entirely\n  result = result.replace(/!\\[[^\\]]*\\]\\([^)]*\\)/g, \"\");\n  // Links: keep display text only\n  result = result.replace(/\\[([^\\]]+)\\]\\([^)]*\\)/g, \"$1\");\n  // Tables: remove separator rows, then strip leading/trailing pipes and convert inner pipes to spaces\n  result = result.replace(/^\\|[\\s:|-]+\\|$/gm, \"\");\n  result = result.replace(/^\\|(.+)\\|$/gm, (_, inner: string) =>\n    inner.split(\"|\").map((cell) => cell.trim()).join(\"  \"),\n  );\n  // Strip inline markdown formatting\n  result = result\n    .replace(/\\*\\*(.+?)\\*\\*/g, \"$1\")\n    .replace(/\\*(.+?)\\*/g, \"$1\")\n    .replace(/__(.+?)__/g, \"$1\")\n    .replace(/_(.+?)_/g, \"$1\")\n    .replace(/~~(.+?)~~/g, \"$1\")\n    .replace(/`(.+?)`/g, \"$1\");\n  return result;\n}\n\n\n/** Build a SendMessageReq containing a single text message. */\nfunction buildTextMessageReq(params: {\n  to: string;\n  text: string;\n  contextToken?: string;\n  clientId: string;\n}): SendMessageReq {\n  const { to, text, contextToken, clientId } = params;\n  const item_list: MessageItem[] = text\n    ? [{ type: MessageItemType.TEXT, text_item: { text } }]\n    : [];\n  return {\n    msg: {\n      from_user_id: \"\",\n      to_user_id: to,\n      client_id: clientId,\n      message_type: MessageType.BOT,\n      message_state: MessageState.FINISH,\n      item_list: item_list.length ? item_list : undefined,\n      context_token: contextToken ?? undefined,\n    },\n  };\n}\n\n/** Build a SendMessageReq from a text payload. */\nfunction buildSendMessageReq(params: {\n  to: string;\n  contextToken?: string;\n  text: string;\n  clientId: string;\n}): SendMessageReq {\n  const { to, contextToken, text, clientId } = params;\n  return buildTextMessageReq({ to, text, contextToken, clientId });\n}\n\n/**\n * Send a plain text message downstream.\n * contextToken is required for all reply sends; missing it breaks conversation association.\n */\nexport async function sendMessageWeixin(params: {\n  to: string;\n  text: string;\n  opts: WeixinApiOptions & { contextToken?: string };\n}): Promise<{ messageId: string }> {\n  const { to, text, opts } = params;\n  if (!opts.contextToken) {\n    logger.error(`sendMessageWeixin: contextToken missing, refusing to send to=${to}`);\n    throw new Error(\"sendMessageWeixin: contextToken is required\");\n  }\n  const clientId = generateClientId();\n  const req = buildSendMessageReq({\n    to,\n    contextToken: opts.contextToken,\n    text,\n    clientId,\n  });\n  try {\n    await sendMessageApi({\n      baseUrl: opts.baseUrl,\n      token: opts.token,\n      timeoutMs: opts.timeoutMs,\n      body: req,\n    });\n  } catch (err) {\n    logger.error(`sendMessageWeixin: failed to=${to} clientId=${clientId} err=${String(err)}`);\n    throw err;\n  }\n  return { messageId: clientId };\n}\n\n/**\n * Send one or more MessageItems (optionally preceded by a text caption) downstream.\n * Each item is sent as its own request so that item_list always has exactly one entry.\n */\nasync function sendMediaItems(params: {\n  to: string;\n  text: string;\n  mediaItem: MessageItem;\n  opts: WeixinApiOptions & { contextToken?: string };\n  label: string;\n}): Promise<{ messageId: string }> {\n  const { to, text, mediaItem, opts, label } = params;\n\n  const items: MessageItem[] = [];\n  if (text) {\n    items.push({ type: MessageItemType.TEXT, text_item: { text } });\n  }\n  items.push(mediaItem);\n\n  let lastClientId = \"\";\n  for (const item of items) {\n    lastClientId = generateClientId();\n    const req: SendMessageReq = {\n      msg: {\n        from_user_id: \"\",\n        to_user_id: to,\n        client_id: lastClientId,\n        message_type: MessageType.BOT,\n        message_state: MessageState.FINISH,\n        item_list: [item],\n        context_token: opts.contextToken ?? undefined,\n      },\n    };\n    try {\n      await sendMessageApi({\n        baseUrl: opts.baseUrl,\n        token: opts.token,\n        timeoutMs: opts.timeoutMs,\n        body: req,\n      });\n    } catch (err) {\n      logger.error(\n        `${label}: failed to=${to} clientId=${lastClientId} err=${String(err)}`,\n      );\n      throw err;\n    }\n  }\n\n  logger.debug(`${label}: success to=${to} clientId=${lastClientId}`);\n  return { messageId: lastClientId };\n}\n\n/**\n * Send an image message downstream using a previously uploaded file.\n * Optionally include a text caption as a separate TEXT item before the image.\n *\n * ImageItem fields:\n *   - media.encrypt_query_param: CDN download param\n *   - media.aes_key: AES key, base64-encoded\n *   - mid_size: original ciphertext file size\n */\nexport async function sendImageMessageWeixin(params: {\n  to: string;\n  text: string;\n  uploaded: UploadedFileInfo;\n  opts: WeixinApiOptions & { contextToken?: string };\n}): Promise<{ messageId: string }> {\n  const { to, text, uploaded, opts } = params;\n  if (!opts.contextToken) {\n    logger.error(`sendImageMessageWeixin: contextToken missing, refusing to send to=${to}`);\n    throw new Error(\"sendImageMessageWeixin: contextToken is required\");\n  }\n  logger.debug(\n    `sendImageMessageWeixin: to=${to} filekey=${uploaded.filekey} fileSize=${uploaded.fileSize} aeskey=present`,\n  );\n\n  const imageItem: MessageItem = {\n    type: MessageItemType.IMAGE,\n    image_item: {\n      media: {\n        encrypt_query_param: uploaded.downloadEncryptedQueryParam,\n        aes_key: Buffer.from(uploaded.aeskey).toString(\"base64\"),\n        encrypt_type: 1,\n      },\n      mid_size: uploaded.fileSizeCiphertext,\n    },\n  };\n\n  return sendMediaItems({ to, text, mediaItem: imageItem, opts, label: \"sendImageMessageWeixin\" });\n}\n\n/**\n * Send a video message downstream using a previously uploaded file.\n * VideoItem: media (CDN ref), video_size (ciphertext bytes).\n * Includes an optional text caption sent as a separate TEXT item first.\n */\nexport async function sendVideoMessageWeixin(params: {\n  to: string;\n  text: string;\n  uploaded: UploadedFileInfo;\n  opts: WeixinApiOptions & { contextToken?: string };\n}): Promise<{ messageId: string }> {\n  const { to, text, uploaded, opts } = params;\n  if (!opts.contextToken) {\n    logger.error(`sendVideoMessageWeixin: contextToken missing, refusing to send to=${to}`);\n    throw new Error(\"sendVideoMessageWeixin: contextToken is required\");\n  }\n\n  const videoItem: MessageItem = {\n    type: MessageItemType.VIDEO,\n    video_item: {\n      media: {\n        encrypt_query_param: uploaded.downloadEncryptedQueryParam,\n        aes_key: Buffer.from(uploaded.aeskey).toString(\"base64\"),\n        encrypt_type: 1,\n      },\n      video_size: uploaded.fileSizeCiphertext,\n    },\n  };\n\n  return sendMediaItems({ to, text, mediaItem: videoItem, opts, label: \"sendVideoMessageWeixin\" });\n}\n\n/**\n * Send a file attachment downstream using a previously uploaded file.\n * FileItem: media (CDN ref), file_name, len (plaintext bytes as string).\n * Includes an optional text caption sent as a separate TEXT item first.\n */\nexport async function sendFileMessageWeixin(params: {\n  to: string;\n  text: string;\n  fileName: string;\n  uploaded: UploadedFileInfo;\n  opts: WeixinApiOptions & { contextToken?: string };\n}): Promise<{ messageId: string }> {\n  const { to, text, fileName, uploaded, opts } = params;\n  if (!opts.contextToken) {\n    logger.error(`sendFileMessageWeixin: contextToken missing, refusing to send to=${to}`);\n    throw new Error(\"sendFileMessageWeixin: contextToken is required\");\n  }\n  const fileItem: MessageItem = {\n    type: MessageItemType.FILE,\n    file_item: {\n      media: {\n        encrypt_query_param: uploaded.downloadEncryptedQueryParam,\n        aes_key: Buffer.from(uploaded.aeskey).toString(\"base64\"),\n        encrypt_type: 1,\n      },\n      file_name: fileName,\n      len: String(uploaded.fileSize),\n    },\n  };\n\n  return sendMediaItems({ to, text, mediaItem: fileItem, opts, label: \"sendFileMessageWeixin\" });\n}\n"
  },
  {
    "path": "packages/sdk/src/messaging/slash-commands.ts",
    "content": "/**\n * Weixin 斜杠指令处理模块\n *\n * 支持的指令：\n * - /echo <message>         直接回复消息（不经过 AI），并附带通道耗时统计\n * - /toggle-debug           开关 debug 模式，启用后每条 AI 回复追加全链路耗时\n * - /clear                  清除当前会话，重新开始对话\n */\nimport type { WeixinApiOptions } from \"../api/api.js\";\nimport { logger } from \"../util/logger.js\";\n\nimport { toggleDebugMode, isDebugMode } from \"./debug-mode.js\";\nimport { sendMessageWeixin } from \"./send.js\";\n\nexport interface SlashCommandResult {\n  /** 是否是斜杠指令（true 表示已处理，不需要继续走 AI） */\n  handled: boolean;\n}\n\nexport interface SlashCommandContext {\n  to: string;\n  contextToken?: string;\n  baseUrl: string;\n  token?: string;\n  accountId: string;\n  log: (msg: string) => void;\n  errLog: (msg: string) => void;\n  /** Called when /clear is invoked to reset the agent session. */\n  onClear?: () => void;\n}\n\n/** 发送回复消息 */\nasync function sendReply(ctx: SlashCommandContext, text: string): Promise<void> {\n  const opts: WeixinApiOptions & { contextToken?: string } = {\n    baseUrl: ctx.baseUrl,\n    token: ctx.token,\n    contextToken: ctx.contextToken,\n  };\n  await sendMessageWeixin({ to: ctx.to, text, opts });\n}\n\n/** 处理 /echo 指令 */\nasync function handleEcho(\n  ctx: SlashCommandContext,\n  args: string,\n  receivedAt: number,\n  eventTimestamp?: number,\n): Promise<void> {\n  const message = args.trim();\n  if (message) {\n    await sendReply(ctx, message);\n  }\n  const eventTs = eventTimestamp ?? 0;\n  const platformDelay = eventTs > 0 ? `${receivedAt - eventTs}ms` : \"N/A\";\n  const timing = [\n    \"⏱ 通道耗时\",\n    `├ 事件时间: ${eventTs > 0 ? new Date(eventTs).toISOString() : \"N/A\"}`,\n    `├ 平台→插件: ${platformDelay}`,\n    `└ 插件处理: ${Date.now() - receivedAt}ms`,\n  ].join(\"\\n\");\n  await sendReply(ctx, timing);\n}\n\n/**\n * 尝试处理斜杠指令\n *\n * @returns handled=true 表示该消息已作为指令处理，不需要继续走 AI 管道\n */\nexport async function handleSlashCommand(\n  content: string,\n  ctx: SlashCommandContext,\n  receivedAt: number,\n  eventTimestamp?: number,\n): Promise<SlashCommandResult> {\n  const trimmed = content.trim();\n  if (!trimmed.startsWith(\"/\")) {\n    return { handled: false };\n  }\n\n  const spaceIdx = trimmed.indexOf(\" \");\n  const command = spaceIdx === -1 ? trimmed.toLowerCase() : trimmed.slice(0, spaceIdx).toLowerCase();\n  const args = spaceIdx === -1 ? \"\" : trimmed.slice(spaceIdx + 1);\n\n  logger.info(`[weixin] Slash command: ${command}, args: ${args.slice(0, 50)}`);\n\n  try {\n    switch (command) {\n      case \"/echo\":\n        await handleEcho(ctx, args, receivedAt, eventTimestamp);\n        return { handled: true };\n      case \"/toggle-debug\": {\n        const enabled = toggleDebugMode(ctx.accountId);\n        await sendReply(\n          ctx,\n          enabled\n            ? \"Debug 模式已开启\"\n            : \"Debug 模式已关闭\",\n        );\n        return { handled: true };\n      }\n      case \"/clear\": {\n        ctx.onClear?.();\n        await sendReply(ctx, \"✅ 会话已清除，重新开始对话\");\n        return { handled: true };\n      }\n      default:\n        return { handled: false };\n    }\n  } catch (err) {\n    logger.error(`[weixin] Slash command error: ${String(err)}`);\n    try {\n      await sendReply(ctx, `❌ 指令执行失败: ${String(err).slice(0, 200)}`);\n    } catch {\n      // 发送错误消息也失败了，只能记日志\n    }\n    return { handled: true };\n  }\n}\n"
  },
  {
    "path": "packages/sdk/src/monitor/monitor.ts",
    "content": "import type { Agent } from \"../agent/interface.js\";\nimport { getUpdates } from \"../api/api.js\";\nimport { WeixinConfigManager } from \"../api/config-cache.js\";\nimport { SESSION_EXPIRED_ERRCODE, pauseSession, getRemainingPauseMs } from \"../api/session-guard.js\";\nimport { processOneMessage } from \"../messaging/process-message.js\";\nimport { getSyncBufFilePath, loadGetUpdatesBuf, saveGetUpdatesBuf } from \"../storage/sync-buf.js\";\nimport { logger } from \"../util/logger.js\";\nimport { redactBody } from \"../util/redact.js\";\n\nconst DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;\nconst MAX_CONSECUTIVE_FAILURES = 3;\nconst BACKOFF_DELAY_MS = 30_000;\nconst RETRY_DELAY_MS = 2_000;\n\nexport type MonitorWeixinOpts = {\n  baseUrl: string;\n  cdnBaseUrl: string;\n  token?: string;\n  accountId: string;\n  agent: Agent;\n  abortSignal?: AbortSignal;\n  longPollTimeoutMs?: number;\n  log?: (msg: string) => void;\n};\n\n/**\n * Long-poll loop: getUpdates → process message → call agent → send reply.\n * Runs until aborted.\n */\nexport async function monitorWeixinProvider(opts: MonitorWeixinOpts): Promise<void> {\n  const {\n    baseUrl,\n    cdnBaseUrl,\n    token,\n    accountId,\n    agent,\n    abortSignal,\n    longPollTimeoutMs,\n  } = opts;\n  const log = opts.log ?? ((msg: string) => console.log(msg));\n  const errLog = (msg: string) => {\n    log(msg);\n    logger.error(msg);\n  };\n  const aLog = logger.withAccount(accountId);\n\n  log(`[weixin] monitor started (${baseUrl}, account=${accountId})`);\n  aLog.info(`Monitor started: baseUrl=${baseUrl}`);\n\n  const syncFilePath = getSyncBufFilePath(accountId);\n  const previousGetUpdatesBuf = loadGetUpdatesBuf(syncFilePath);\n  let getUpdatesBuf = previousGetUpdatesBuf ?? \"\";\n\n  if (previousGetUpdatesBuf) {\n    log(`[weixin] resuming from previous sync buf (${getUpdatesBuf.length} bytes)`);\n  } else {\n    log(`[weixin] no previous sync buf, starting fresh`);\n  }\n\n  const configManager = new WeixinConfigManager({ baseUrl, token }, log);\n\n  let nextTimeoutMs = longPollTimeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;\n  let consecutiveFailures = 0;\n\n  while (!abortSignal?.aborted) {\n    try {\n      const resp = await getUpdates({\n        baseUrl,\n        token,\n        get_updates_buf: getUpdatesBuf,\n        timeoutMs: nextTimeoutMs,\n        abortSignal,\n      });\n\n      if (resp.longpolling_timeout_ms != null && resp.longpolling_timeout_ms > 0) {\n        nextTimeoutMs = resp.longpolling_timeout_ms;\n      }\n\n      const isApiError =\n        (resp.ret !== undefined && resp.ret !== 0) ||\n        (resp.errcode !== undefined && resp.errcode !== 0);\n\n      if (isApiError) {\n        const isSessionExpired =\n          resp.errcode === SESSION_EXPIRED_ERRCODE || resp.ret === SESSION_EXPIRED_ERRCODE;\n\n        if (isSessionExpired) {\n          pauseSession(accountId);\n          const pauseMs = getRemainingPauseMs(accountId);\n          errLog(\n            `[weixin] session expired (errcode ${SESSION_EXPIRED_ERRCODE}), pausing for ${Math.ceil(pauseMs / 60_000)} min. Please run \\`npx weixin-acp login\\` to re-login.`,\n          );\n          consecutiveFailures = 0;\n          await sleep(pauseMs, abortSignal);\n          continue;\n        }\n\n        consecutiveFailures += 1;\n        errLog(\n          `[weixin] getUpdates failed: ret=${resp.ret} errcode=${resp.errcode} errmsg=${resp.errmsg ?? \"\"} (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES})`,\n        );\n        if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {\n          errLog(`[weixin] ${MAX_CONSECUTIVE_FAILURES} consecutive failures, backing off 30s`);\n          consecutiveFailures = 0;\n          await sleep(BACKOFF_DELAY_MS, abortSignal);\n        } else {\n          await sleep(RETRY_DELAY_MS, abortSignal);\n        }\n        continue;\n      }\n\n      consecutiveFailures = 0;\n\n      if (resp.get_updates_buf != null && resp.get_updates_buf !== \"\") {\n        saveGetUpdatesBuf(syncFilePath, resp.get_updates_buf);\n        getUpdatesBuf = resp.get_updates_buf;\n      }\n\n      const list = resp.msgs ?? [];\n      for (const full of list) {\n        aLog.info(\n          `inbound: from=${full.from_user_id} types=${full.item_list?.map((i) => i.type).join(\",\") ?? \"none\"}`,\n        );\n\n        const fromUserId = full.from_user_id ?? \"\";\n        const cachedConfig = await configManager.getForUser(fromUserId, full.context_token);\n\n        await processOneMessage(full, {\n          accountId,\n          agent,\n          baseUrl,\n          cdnBaseUrl,\n          token,\n          typingTicket: cachedConfig.typingTicket,\n          log,\n          errLog,\n        });\n      }\n    } catch (err) {\n      if (abortSignal?.aborted) {\n        aLog.info(`Monitor stopped (aborted)`);\n        return;\n      }\n      consecutiveFailures += 1;\n      errLog(\n        `[weixin] getUpdates error (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}): ${String(err)}`,\n      );\n      if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {\n        consecutiveFailures = 0;\n        await sleep(BACKOFF_DELAY_MS, abortSignal);\n      } else {\n        await sleep(RETRY_DELAY_MS, abortSignal);\n      }\n    }\n  }\n  aLog.info(`Monitor ended`);\n}\n\nfunction sleep(ms: number, signal?: AbortSignal): Promise<void> {\n  return new Promise((resolve, reject) => {\n    const t = setTimeout(resolve, ms);\n    signal?.addEventListener(\n      \"abort\",\n      () => {\n        clearTimeout(t);\n        reject(new Error(\"aborted\"));\n      },\n      { once: true },\n    );\n  });\n}\n"
  },
  {
    "path": "packages/sdk/src/storage/state-dir.ts",
    "content": "import os from \"node:os\";\nimport path from \"node:path\";\n\n/** Resolve the OpenClaw state directory (mirrors core logic in src/infra). */\nexport function resolveStateDir(): string {\n  return (\n    process.env.OPENCLAW_STATE_DIR?.trim() ||\n    process.env.CLAWDBOT_STATE_DIR?.trim() ||\n    path.join(os.homedir(), \".openclaw\")\n  );\n}\n"
  },
  {
    "path": "packages/sdk/src/storage/sync-buf.ts",
    "content": "import fs from \"node:fs\";\nimport path from \"node:path\";\n\nimport { deriveRawAccountId } from \"../auth/accounts.js\";\n\nimport { resolveStateDir } from \"./state-dir.js\";\n\nfunction resolveAccountsDir(): string {\n  return path.join(resolveStateDir(), \"openclaw-weixin\", \"accounts\");\n}\n\n/**\n * Path to the persistent get_updates_buf file for an account.\n * Stored alongside account data: ~/.openclaw/openclaw-weixin/accounts/{accountId}.sync.json\n */\nexport function getSyncBufFilePath(accountId: string): string {\n  return path.join(resolveAccountsDir(), `${accountId}.sync.json`);\n}\n\n/** Legacy single-account syncbuf (pre multi-account): `.openclaw-weixin-sync/default.json`. */\nfunction getLegacySyncBufDefaultJsonPath(): string {\n  return path.join(\n    resolveStateDir(),\n    \"agents\",\n    \"default\",\n    \"sessions\",\n    \".openclaw-weixin-sync\",\n    \"default.json\",\n  );\n}\n\nexport type SyncBufData = {\n  get_updates_buf: string;\n};\n\nfunction readSyncBufFile(filePath: string): string | undefined {\n  try {\n    const raw = fs.readFileSync(filePath, \"utf-8\");\n    const data = JSON.parse(raw) as { get_updates_buf?: string };\n    if (typeof data.get_updates_buf === \"string\") {\n      return data.get_updates_buf;\n    }\n  } catch {\n    // file not found or invalid\n  }\n  return undefined;\n}\n\n/**\n * Load persisted get_updates_buf.\n * Falls back in order:\n *   1. Primary path (normalized accountId, new installs)\n *   2. Compat path (raw accountId derived from pattern, old installs)\n *   3. Legacy single-account path (very old installs without multi-account support)\n */\nexport function loadGetUpdatesBuf(filePath: string): string | undefined {\n  const value = readSyncBufFile(filePath);\n  if (value !== undefined) return value;\n\n  // Compat: if given path uses a normalized accountId (e.g. \"b0f5860fdecb-im-bot.sync.json\"),\n  // also try the old raw-ID filename (e.g. \"b0f5860fdecb@im.bot.sync.json\").\n  const accountId = path.basename(filePath, \".sync.json\");\n  const rawId = deriveRawAccountId(accountId);\n  if (rawId) {\n    const compatPath = path.join(resolveAccountsDir(), `${rawId}.sync.json`);\n    const compatValue = readSyncBufFile(compatPath);\n    if (compatValue !== undefined) return compatValue;\n  }\n\n  // Legacy fallback: old single-account installs stored syncbuf without accountId.\n  return readSyncBufFile(getLegacySyncBufDefaultJsonPath());\n}\n\n/**\n * Persist get_updates_buf. Creates parent dir if needed.\n */\nexport function saveGetUpdatesBuf(filePath: string, getUpdatesBuf: string): void {\n  const dir = path.dirname(filePath);\n  fs.mkdirSync(dir, { recursive: true });\n  fs.writeFileSync(filePath, JSON.stringify({ get_updates_buf: getUpdatesBuf }, null, 0), \"utf-8\");\n}\n"
  },
  {
    "path": "packages/sdk/src/util/logger.ts",
    "content": "import fs from \"node:fs\";\nimport os from \"node:os\";\nimport path from \"node:path\";\n\n/**\n * Plugin logger — writes JSON lines to the main openclaw log file:\n *   <tmpdir>/openclaw/openclaw-YYYY-MM-DD.log\n * Same file and format used by all other channels.\n */\n\nconst MAIN_LOG_DIR = path.join(os.tmpdir(), \"openclaw\");\nconst SUBSYSTEM = \"gateway/channels/openclaw-weixin\";\nconst RUNTIME = \"node\";\nconst RUNTIME_VERSION = process.versions.node;\nconst HOSTNAME = os.hostname() || \"unknown\";\nconst PARENT_NAMES = [\"openclaw\"];\n\n/** tslog-compatible level IDs (higher = more severe). */\nconst LEVEL_IDS: Record<string, number> = {\n  TRACE: 1,\n  DEBUG: 2,\n  INFO: 3,\n  WARN: 4,\n  ERROR: 5,\n  FATAL: 6,\n};\n\nconst DEFAULT_LOG_LEVEL = \"INFO\";\n\nfunction resolveMinLevel(): number {\n  const env = process.env.OPENCLAW_LOG_LEVEL?.toUpperCase();\n  if (env && env in LEVEL_IDS) return LEVEL_IDS[env];\n  return LEVEL_IDS[DEFAULT_LOG_LEVEL];\n}\n\nlet minLevelId = resolveMinLevel();\n\n/** Dynamically change the minimum log level at runtime. */\nexport function setLogLevel(level: string): void {\n  const upper = level.toUpperCase();\n  if (!(upper in LEVEL_IDS)) {\n    throw new Error(`Invalid log level: ${level}. Valid levels: ${Object.keys(LEVEL_IDS).join(\", \")}`);\n  }\n  minLevelId = LEVEL_IDS[upper];\n}\n\n/** Shift a Date into local time so toISOString() renders local clock digits. */\nfunction toLocalISO(now: Date): string {\n  const offsetMs = -now.getTimezoneOffset() * 60_000;\n  const sign = offsetMs >= 0 ? \"+\" : \"-\";\n  const abs = Math.abs(now.getTimezoneOffset());\n  const offStr = `${sign}${String(Math.floor(abs / 60)).padStart(2, \"0\")}:${String(abs % 60).padStart(2, \"0\")}`;\n  return new Date(now.getTime() + offsetMs).toISOString().replace(\"Z\", offStr);\n}\n\nfunction localDateKey(now: Date): string {\n  return toLocalISO(now).slice(0, 10);\n}\n\nfunction resolveMainLogPath(): string {\n  const dateKey = localDateKey(new Date());\n  return path.join(MAIN_LOG_DIR, `openclaw-${dateKey}.log`);\n}\n\nlet logDirEnsured = false;\n\nexport type Logger = {\n  info(message: string): void;\n  debug(message: string): void;\n  warn(message: string): void;\n  error(message: string): void;\n  /** Returns a child logger whose messages are prefixed with `[accountId]`. */\n  withAccount(accountId: string): Logger;\n  /** Returns the current main log file path. */\n  getLogFilePath(): string;\n  close(): void;\n};\n\nfunction buildLoggerName(accountId?: string): string {\n  return accountId ? `${SUBSYSTEM}/${accountId}` : SUBSYSTEM;\n}\n\nfunction writeLog(level: string, message: string, accountId?: string): void {\n  const levelId = LEVEL_IDS[level] ?? LEVEL_IDS.INFO;\n  if (levelId < minLevelId) return;\n\n  const now = new Date();\n  const loggerName = buildLoggerName(accountId);\n  const prefixedMessage = accountId ? `[${accountId}] ${message}` : message;\n  const entry = JSON.stringify({\n    \"0\": loggerName,\n    \"1\": prefixedMessage,\n    _meta: {\n      runtime: RUNTIME,\n      runtimeVersion: RUNTIME_VERSION,\n      hostname: HOSTNAME,\n      name: loggerName,\n      parentNames: PARENT_NAMES,\n      date: now.toISOString(),\n      logLevelId: LEVEL_IDS[level] ?? LEVEL_IDS.INFO,\n      logLevelName: level,\n    },\n    time: toLocalISO(now),\n  });\n  try {\n    if (!logDirEnsured) {\n      fs.mkdirSync(MAIN_LOG_DIR, { recursive: true });\n      logDirEnsured = true;\n    }\n    fs.appendFileSync(resolveMainLogPath(), `${entry}\\n`, \"utf-8\");\n  } catch {\n    // Best-effort; never block on logging failures.\n  }\n}\n\n/** Creates a logger instance, optionally bound to a specific account. */\nfunction createLogger(accountId?: string): Logger {\n  return {\n    info(message: string): void {\n      writeLog(\"INFO\", message, accountId);\n    },\n    debug(message: string): void {\n      writeLog(\"DEBUG\", message, accountId);\n    },\n    warn(message: string): void {\n      writeLog(\"WARN\", message, accountId);\n    },\n    error(message: string): void {\n      writeLog(\"ERROR\", message, accountId);\n    },\n    withAccount(id: string): Logger {\n      return createLogger(id);\n    },\n    getLogFilePath(): string {\n      return resolveMainLogPath();\n    },\n    close(): void {\n      // No-op: appendFileSync has no persistent handle to close.\n    },\n  };\n}\n\nexport const logger: Logger = createLogger();\n"
  },
  {
    "path": "packages/sdk/src/util/random.ts",
    "content": "import crypto from \"node:crypto\";\n\n/**\n * Generate a prefixed unique ID using timestamp + crypto random bytes.\n * Format: `{prefix}:{timestamp}-{8-char hex}`\n */\nexport function generateId(prefix: string): string {\n  return `${prefix}:${Date.now()}-${crypto.randomBytes(4).toString(\"hex\")}`;\n}\n\n/**\n * Generate a temporary file name with random suffix.\n * Format: `{prefix}-{timestamp}-{8-char hex}{ext}`\n */\nexport function tempFileName(prefix: string, ext: string): string {\n  return `${prefix}-${Date.now()}-${crypto.randomBytes(4).toString(\"hex\")}${ext}`;\n}\n"
  },
  {
    "path": "packages/sdk/src/util/redact.ts",
    "content": "const DEFAULT_BODY_MAX_LEN = 200;\nconst DEFAULT_TOKEN_PREFIX_LEN = 6;\n\n/**\n * Truncate a string, appending a length indicator when trimmed.\n * Returns `\"\"` for empty/undefined input.\n */\nexport function truncate(s: string | undefined, max: number): string {\n  if (!s) return \"\";\n  if (s.length <= max) return s;\n  return `${s.slice(0, max)}…(len=${s.length})`;\n}\n\n/**\n * Redact a token/secret: show only the first few chars + total length.\n * Returns `\"(none)\"` when absent.\n */\nexport function redactToken(token: string | undefined, prefixLen = DEFAULT_TOKEN_PREFIX_LEN): string {\n  if (!token) return \"(none)\";\n  if (token.length <= prefixLen) return `****(len=${token.length})`;\n  return `${token.slice(0, prefixLen)}…(len=${token.length})`;\n}\n\n/**\n * Truncate a JSON body string to `maxLen` chars for safe logging.\n * Appends original length so the reader knows how much was dropped.\n */\nexport function redactBody(body: string | undefined, maxLen = DEFAULT_BODY_MAX_LEN): string {\n  if (!body) return \"(empty)\";\n  if (body.length <= maxLen) return body;\n  return `${body.slice(0, maxLen)}…(truncated, totalLen=${body.length})`;\n}\n\n/**\n * Strip query string (which often contains signatures/tokens) from a URL,\n * keeping only origin + pathname.\n */\nexport function redactUrl(rawUrl: string): string {\n  try {\n    const u = new URL(rawUrl);\n    const base = `${u.origin}${u.pathname}`;\n    return u.search ? `${base}?<redacted>` : base;\n  } catch {\n    return truncate(rawUrl, 80);\n  }\n}\n"
  },
  {
    "path": "packages/sdk/src/vendor.d.ts",
    "content": "declare module \"silk-wasm\" {\n  export function decode(\n    input: Buffer,\n    sampleRate: number,\n  ): Promise<{ data: Uint8Array; duration: number }>;\n}\n\ndeclare module \"qrcode-terminal\" {\n  const qrcodeTerminal: {\n    generate(\n      text: string,\n      options?: { small?: boolean },\n      callback?: (qr: string) => void,\n    ): void;\n  };\n  export default qrcodeTerminal;\n}\n"
  },
  {
    "path": "packages/sdk/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"NodeNext\",\n    \"moduleResolution\": \"NodeNext\",\n    \"esModuleInterop\": true,\n    \"strict\": true,\n    \"skipLibCheck\": true,\n    \"noEmit\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"declaration\": true\n  },\n  \"include\": [\"index.ts\", \"src/**/*.ts\"],\n  \"exclude\": [\"node_modules\"]\n}\n"
  },
  {
    "path": "packages/sdk/tsdown.config.ts",
    "content": "import { defineConfig } from \"tsdown\";\n\nexport default defineConfig({\n  entry: [\"./index.ts\"],\n  dts: true,\n});\n"
  },
  {
    "path": "packages/weixin-acp/.gitignore",
    "content": "dist\n"
  },
  {
    "path": "packages/weixin-acp/index.ts",
    "content": "export { AcpAgent } from \"./src/acp-agent.js\";\nexport type { AcpAgentOptions } from \"./src/types.js\";\n"
  },
  {
    "path": "packages/weixin-acp/main.ts",
    "content": "#!/usr/bin/env node\n\n/**\n * WeChat + ACP (Agent Client Protocol) adapter.\n *\n * Usage:\n *   npx weixin-acp login                          # QR-code login\n *   npx weixin-acp claude-code                     # Start with Claude Code\n *   npx weixin-acp codex                           # Start with Codex\n *   npx weixin-acp start -- <command> [args...]    # Start with custom agent\n *\n * Examples:\n *   npx weixin-acp start -- node ./my-agent.js\n */\n\nimport { isLoggedIn, login, logout, start } from \"weixin-agent-sdk\";\n\nimport { AcpAgent } from \"./src/acp-agent.js\";\n\n/** Built-in agent shortcuts */\nconst BUILTIN_AGENTS: Record<string, { command: string }> = {\n  \"claude-code\": { command: \"claude-agent-acp\" },\n  codex: { command: \"codex-acp\" },\n};\n\nconst command = process.argv[2];\n\nasync function ensureLoggedIn() {\n  if (!isLoggedIn()) {\n    console.log(\"未检测到登录信息，请先扫码登录微信\\n\");\n    await login();\n  }\n}\n\nasync function startAgent(acpCommand: string, acpArgs: string[] = []) {\n  await ensureLoggedIn();\n\n  const agent = new AcpAgent({ command: acpCommand, args: acpArgs });\n\n  const ac = new AbortController();\n  process.on(\"SIGINT\", () => {\n    console.log(\"\\n正在停止...\");\n    agent.dispose();\n    ac.abort();\n  });\n  process.on(\"SIGTERM\", () => {\n    agent.dispose();\n    ac.abort();\n  });\n\n  return start(agent, { abortSignal: ac.signal });\n}\n\nasync function main() {\n  if (command === \"login\") {\n    await login();\n    return;\n  }\n\n  if (command === \"logout\") {\n    logout();\n    return;\n  }\n\n  if (command === \"start\") {\n    const ddIndex = process.argv.indexOf(\"--\");\n    if (ddIndex === -1 || ddIndex + 1 >= process.argv.length) {\n      console.error(\"错误: 请在 -- 后指定 ACP agent 启动命令\");\n      console.error(\"示例: npx weixin-acp start -- codex-acp\");\n      process.exit(1);\n    }\n\n    const [acpCommand, ...acpArgs] = process.argv.slice(ddIndex + 1);\n    const bot = await startAgent(acpCommand, acpArgs);\n    await bot.wait();\n    return;\n  }\n\n  if (command && command in BUILTIN_AGENTS) {\n    const { command: acpCommand } = BUILTIN_AGENTS[command];\n    const bot = await startAgent(acpCommand);\n    await bot.wait();\n    return;\n  }\n\n  console.log(`weixin-acp — 微信 + ACP 适配器\n\n用法:\n  npx weixin-acp login                          扫码登录微信\n  npx weixin-acp logout                         退出登录\n  npx weixin-acp claude-code                     使用 Claude Code\n  npx weixin-acp codex                           使用 Codex\n  npx weixin-acp start -- <command> [args...]    使用自定义 agent\n\n示例:\n  npx weixin-acp start -- node ./my-agent.js`);\n}\n\nmain().catch((err) => {\n  console.error(err);\n  process.exit(1);\n});\n"
  },
  {
    "path": "packages/weixin-acp/package.json",
    "content": "{\n  \"name\": \"weixin-acp\",\n  \"version\": \"0.6.0\",\n  \"description\": \"WeChat + ACP (Agent Client Protocol) adapter — connect any ACP agent to WeChat\",\n  \"license\": \"MIT\",\n  \"type\": \"module\",\n  \"bin\": {\n    \"weixin-acp\": \"dist/main.mjs\"\n  },\n  \"main\": \"dist/index.mjs\",\n  \"types\": \"dist/index.d.mts\",\n  \"exports\": {\n    \".\": {\n      \"types\": \"./dist/index.d.mts\",\n      \"default\": \"./dist/index.mjs\"\n    }\n  },\n  \"files\": [\n    \"dist\"\n  ],\n  \"publishConfig\": {\n    \"registry\": \"https://registry.npmjs.org/\"\n  },\n  \"engines\": {\n    \"node\": \">=22\"\n  },\n  \"dependencies\": {\n    \"@agentclientprotocol/claude-agent-acp\": \"^0.31.4\",\n    \"@agentclientprotocol/codex-acp\": \"^0.0.38\",\n    \"@agentclientprotocol/sdk\": \"^0.21.0\",\n    \"cross-spawn\": \"^7.0.6\",\n    \"weixin-agent-sdk\": \"workspace:*\"\n  },\n  \"scripts\": {\n    \"build\": \"tsdown\",\n    \"prepublishOnly\": \"pnpm run build\",\n    \"login\": \"tsx main.ts login\",\n    \"start\": \"tsx main.ts start\",\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"devDependencies\": {\n    \"@types/cross-spawn\": \"^6.0.6\",\n    \"@types/node\": \"^22.0.0\",\n    \"tsdown\": \"^0.21.7\",\n    \"tsx\": \"^4.0.0\",\n    \"typescript\": \"^5.8.0\"\n  }\n}\n"
  },
  {
    "path": "packages/weixin-acp/src/acp-agent.ts",
    "content": "import type { Agent, ChatRequest, ChatResponse } from \"weixin-agent-sdk\";\nimport type { SessionId } from \"@agentclientprotocol/sdk\";\n\nimport type { AcpAgentOptions } from \"./types.js\";\nimport { AcpConnection } from \"./acp-connection.js\";\nimport { convertRequestToContentBlocks } from \"./content-converter.js\";\nimport { ResponseCollector } from \"./response-collector.js\";\n\nfunction log(msg: string) {\n  console.log(`[acp] ${msg}`);\n}\n\n/**\n * Agent adapter that bridges ACP (Agent Client Protocol) agents\n * to the weixin-agent-sdk Agent interface.\n */\nexport class AcpAgent implements Agent {\n  private connection: AcpConnection;\n  private sessions = new Map<string, SessionId>();\n  private options: AcpAgentOptions;\n\n  constructor(options: AcpAgentOptions) {\n    this.options = options;\n    this.connection = new AcpConnection(options, () => {\n      log(\"subprocess exited, clearing session cache\");\n      this.sessions.clear();\n    });\n  }\n\n  async chat(request: ChatRequest): Promise<ChatResponse> {\n    const conn = await this.connection.ensureReady();\n\n    // Get or create an ACP session for this conversation\n    const sessionId = await this.getOrCreateSession(request.conversationId, conn);\n\n    // Convert the ChatRequest to ACP ContentBlock[]\n    const blocks = await convertRequestToContentBlocks(request);\n    if (blocks.length === 0) {\n      return { text: \"\" };\n    }\n\n    // Register a collector, send the prompt, then gather the response\n    const preview = request.text?.slice(0, 50) || (request.media ? `[${request.media.type}]` : \"\");\n    log(`prompt: \"${preview}\" (session=${sessionId})`);\n\n    const collector = new ResponseCollector();\n    this.connection.registerCollector(sessionId, collector);\n    try {\n      await conn.prompt({ sessionId, prompt: blocks });\n    } finally {\n      this.connection.unregisterCollector(sessionId);\n    }\n\n    const response = await collector.toResponse();\n    log(`response: ${response.text?.slice(0, 80) ?? \"[no text]\"}${response.media ? \" +media\" : \"\"}`);\n    return response;\n  }\n\n  private async getOrCreateSession(\n    conversationId: string,\n    conn: Awaited<ReturnType<AcpConnection[\"ensureReady\"]>>,\n  ): Promise<SessionId> {\n    const existing = this.sessions.get(conversationId);\n    if (existing) return existing;\n\n    log(`creating new session for conversation=${conversationId}`);\n    const res = await conn.newSession({\n      cwd: this.options.cwd ?? process.cwd(),\n      mcpServers: [],\n    });\n    log(`session created: ${res.sessionId}`);\n    this.sessions.set(conversationId, res.sessionId);\n    return res.sessionId;\n  }\n\n  /**\n   * Clear/reset the session for a given conversation.\n   * The next message will automatically create a fresh session.\n   */\n  clearSession(conversationId: string): void {\n    const sessionId = this.sessions.get(conversationId);\n    if (sessionId) {\n      log(`clearing session for conversation=${conversationId} (session=${sessionId})`);\n      this.connection.unregisterCollector(sessionId);\n      this.sessions.delete(conversationId);\n    }\n  }\n\n  /**\n   * Kill the ACP subprocess and clean up all sessions.\n   */\n  dispose(): void {\n    this.sessions.clear();\n    this.connection.dispose();\n  }\n}\n"
  },
  {
    "path": "packages/weixin-acp/src/acp-connection.ts",
    "content": "import type { ChildProcess } from \"node:child_process\";\nimport spawn from \"cross-spawn\";\nimport { Readable, Writable } from \"node:stream\";\n\nimport {\n  ClientSideConnection,\n  ndJsonStream,\n  PROTOCOL_VERSION,\n} from \"@agentclientprotocol/sdk\";\nimport type { SessionId } from \"@agentclientprotocol/sdk\";\n\nimport type { AcpAgentOptions } from \"./types.js\";\nimport { ResponseCollector } from \"./response-collector.js\";\n\nfunction log(msg: string) {\n  console.log(`[acp] ${msg}`);\n}\n\nfunction describeToolCall(update: {\n  title?: string | null;\n  kind?: string | null;\n  toolCallId?: string;\n}): string {\n  return update.title ?? update.kind ?? update.toolCallId ?? \"tool\";\n}\n\n/**\n * Manages the ACP agent subprocess and ClientSideConnection lifecycle.\n */\nexport class AcpConnection {\n  private process: ChildProcess | null = null;\n  private connection: ClientSideConnection | null = null;\n  private ready = false;\n  private collectors = new Map<SessionId, ResponseCollector>();\n\n  private onExit?: () => void;\n\n  constructor(private options: AcpAgentOptions, onExit?: () => void) {\n    this.onExit = onExit;\n  }\n\n  registerCollector(sessionId: SessionId, collector: ResponseCollector): void {\n    this.collectors.set(sessionId, collector);\n  }\n\n  unregisterCollector(sessionId: SessionId): void {\n    this.collectors.delete(sessionId);\n  }\n\n  /**\n   * Ensure the subprocess is running and the connection is initialized.\n   */\n  async ensureReady(): Promise<ClientSideConnection> {\n    if (this.ready && this.connection) {\n      return this.connection;\n    }\n\n    const args = this.options.args ?? [];\n    log(`spawning: ${this.options.command} ${args.join(\" \")}`);\n\n    const proc = spawn(this.options.command, args, {\n      stdio: [\"pipe\", \"pipe\", \"inherit\"],\n      env: { ...process.env, ...this.options.env },\n      cwd: this.options.cwd,\n    });\n    this.process = proc;\n\n    proc.on(\"exit\", (code) => {\n      log(`subprocess exited (code=${code})`);\n      this.ready = false;\n      this.connection = null;\n      this.process = null;\n      this.onExit?.();\n    });\n\n    const writable = Writable.toWeb(proc.stdin!) as WritableStream<Uint8Array>;\n    const readable = Readable.toWeb(proc.stdout!) as ReadableStream<Uint8Array>;\n    const stream = ndJsonStream(writable, readable);\n\n    const conn = new ClientSideConnection((_agent) => ({\n      sessionUpdate: async (params) => {\n        const update = params.update;\n        switch (update.sessionUpdate) {\n          case \"tool_call\":\n            log(`tool_call: ${describeToolCall(update)} (${update.status ?? \"started\"})`);\n            break;\n          case \"tool_call_update\":\n            if (update.status) {\n              log(`tool_call_update: ${describeToolCall(update)} → ${update.status}`);\n            }\n            break;\n          case \"agent_thought_chunk\":\n            if (update.content.type === \"text\") {\n              log(`thinking: ${update.content.text.slice(0, 100)}`);\n            }\n            break;\n        }\n        const collector = this.collectors.get(params.sessionId);\n        if (collector) {\n          collector.handleUpdate(params);\n        }\n      },\n      requestPermission: async (params) => {\n        const firstOption = params.options[0];\n        log(\n          `permission: auto-approved \"${firstOption?.name ?? \"allow\"}\" (${firstOption?.optionId ?? \"unknown\"})`,\n        );\n        return {\n          outcome: {\n            outcome: \"selected\" as const,\n            optionId: firstOption?.optionId ?? \"allow\",\n          },\n        };\n      },\n    }), stream);\n\n    log(\"initializing connection...\");\n    await conn.initialize({\n      protocolVersion: PROTOCOL_VERSION,\n      clientInfo: { name: \"weixin-agent-sdk\", version: \"0.1.0\" },\n      clientCapabilities: {},\n    });\n    log(\"connection initialized\");\n\n    this.connection = conn;\n    this.ready = true;\n    return conn;\n  }\n\n  /**\n   * Kill the subprocess and clean up.\n   */\n  dispose(): void {\n    this.ready = false;\n    this.collectors.clear();\n    if (this.process) {\n      this.process.kill();\n      this.process = null;\n    }\n    this.connection = null;\n  }\n}\n"
  },
  {
    "path": "packages/weixin-acp/src/content-converter.ts",
    "content": "import fs from \"node:fs/promises\";\n\nimport type { ChatRequest } from \"weixin-agent-sdk\";\nimport type { ContentBlock } from \"@agentclientprotocol/sdk\";\n\n/**\n * Convert a ChatRequest into ACP ContentBlock[].\n */\nexport async function convertRequestToContentBlocks(\n  request: ChatRequest,\n): Promise<ContentBlock[]> {\n  const blocks: ContentBlock[] = [];\n\n  if (request.text) {\n    blocks.push({ type: \"text\", text: request.text });\n  }\n\n  if (request.media) {\n    const data = await fs.readFile(request.media.filePath);\n    const base64 = data.toString(\"base64\");\n    const mimeType = request.media.mimeType;\n\n    switch (request.media.type) {\n      case \"image\":\n        blocks.push({ type: \"image\", data: base64, mimeType });\n        break;\n      case \"audio\":\n        blocks.push({ type: \"audio\", data: base64, mimeType });\n        break;\n      case \"video\":\n      case \"file\": {\n        const uri = `file://${request.media.filePath}`;\n        blocks.push({\n          type: \"resource\",\n          resource: { uri, blob: base64, mimeType },\n        });\n        break;\n      }\n    }\n  }\n\n  return blocks;\n}\n"
  },
  {
    "path": "packages/weixin-acp/src/response-collector.ts",
    "content": "import crypto from \"node:crypto\";\nimport fs from \"node:fs/promises\";\nimport os from \"node:os\";\nimport path from \"node:path\";\n\nimport type { ChatResponse } from \"weixin-agent-sdk\";\nimport type { SessionNotification } from \"@agentclientprotocol/sdk\";\n\nconst ACP_MEDIA_OUT_DIR = path.join(os.tmpdir(), \"weixin-agent/media/acp-out\");\n\n/**\n * Collects sessionUpdate notifications for a single prompt round-trip\n * and converts the accumulated result into a ChatResponse.\n */\nexport class ResponseCollector {\n  private textChunks: string[] = [];\n  private imageData: { base64: string; mimeType: string } | null = null;\n\n  /**\n   * Feed a sessionUpdate notification into the collector.\n   */\n  handleUpdate(notification: SessionNotification): void {\n    const update = notification.update;\n\n    if (update.sessionUpdate === \"agent_message_chunk\") {\n      const content = update.content;\n\n      if (content.type === \"text\") {\n        this.textChunks.push(content.text);\n      } else if (content.type === \"image\") {\n        this.imageData = {\n          base64: content.data,\n          mimeType: content.mimeType,\n        };\n      }\n    }\n  }\n\n  /**\n   * Build a ChatResponse from all collected chunks.\n   */\n  async toResponse(): Promise<ChatResponse> {\n    const response: ChatResponse = {};\n\n    const text = this.textChunks.join(\"\");\n    if (text) {\n      response.text = text;\n    }\n\n    if (this.imageData) {\n      await fs.mkdir(ACP_MEDIA_OUT_DIR, { recursive: true });\n      const ext = this.imageData.mimeType.split(\"/\")[1] ?? \"png\";\n      const filename = `${crypto.randomUUID()}.${ext}`;\n      const filePath = path.join(ACP_MEDIA_OUT_DIR, filename);\n      await fs.writeFile(filePath, Buffer.from(this.imageData.base64, \"base64\"));\n      response.media = { type: \"image\", url: filePath };\n    }\n\n    return response;\n  }\n}\n"
  },
  {
    "path": "packages/weixin-acp/src/types.ts",
    "content": "export type AcpAgentOptions = {\n  /** Command to launch the ACP agent, e.g. \"npx\" */\n  command: string;\n  /** Command arguments, e.g. [\"@agentclientprotocol/codex-acp\"] */\n  args?: string[];\n  /** Extra environment variables for the subprocess */\n  env?: Record<string, string>;\n  /** Working directory for the subprocess and ACP sessions */\n  cwd?: string;\n  /** Prompt timeout in milliseconds (default: 120_000) */\n  promptTimeoutMs?: number;\n};\n"
  },
  {
    "path": "packages/weixin-acp/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"NodeNext\",\n    \"moduleResolution\": \"NodeNext\",\n    \"esModuleInterop\": true,\n    \"strict\": true,\n    \"skipLibCheck\": true,\n    \"noEmit\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true\n  },\n  \"include\": [\"main.ts\", \"index.ts\", \"src/**/*.ts\", \"../sdk/src/vendor.d.ts\"],\n  \"exclude\": [\"node_modules\"]\n}\n"
  },
  {
    "path": "packages/weixin-acp/tsdown.config.ts",
    "content": "import { defineConfig } from \"tsdown\";\n\nexport default defineConfig({\n  entry: [\"./main.ts\", \"./index.ts\"],\n  dts: true,\n});\n"
  },
  {
    "path": "pnpm-workspace.yaml",
    "content": "packages:\n  - \"packages/*\"\n"
  }
]