From ecde45b4ce2be567e9bd0780d6caea960e1b089c Mon Sep 17 00:00:00 2001 From: Rdmclin2 Date: Mon, 23 Mar 2026 12:52:11 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20support=20wechat=20bot=20(#?= =?UTF-8?q?13191)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: support weixin channel * chore: rename to wechat * chore: refact wechat adapter with ilink spec * feat: add qrcode generate and refresh * chore: update wechat docs * fix: qrcode * chore: remove developer mode restrict * fix: wechat link error * chore: add thread typing * chore: support skip progressMessageId * fix: discord eye reaction * chore: resolve CodeQL regex rule * test: add chat adapter wechat test case * chore: wechat refresh like discord * fix: perist token and add typing action * chore: bot cli support weixin * fix: database test case --- apps/cli/src/commands/bot.ts | 9 +- docs/usage/channels/overview.mdx | 21 +- docs/usage/channels/overview.zh-CN.mdx | 37 +- docs/usage/channels/wechat.mdx | 96 +++++ docs/usage/channels/wechat.zh-CN.mdx | 93 +++++ locales/en-US/agent.json | 9 +- locales/zh-CN/agent.json | 9 +- package.json | 1 + packages/chat-adapter-wechat/package.json | 26 ++ .../chat-adapter-wechat/src/adapter.test.ts | 357 ++++++++++++++++++ packages/chat-adapter-wechat/src/adapter.ts | 332 ++++++++++++++++ packages/chat-adapter-wechat/src/api.test.ts | 246 ++++++++++++ packages/chat-adapter-wechat/src/api.ts | 272 +++++++++++++ .../src/format-converter.test.ts | 46 +++ .../src/format-converter.ts | 19 + packages/chat-adapter-wechat/src/index.ts | 13 + packages/chat-adapter-wechat/src/types.ts | 154 ++++++++ packages/chat-adapter-wechat/tsconfig.json | 21 ++ packages/chat-adapter-wechat/tsup.config.ts | 8 + .../chat-adapter-wechat/vitest.config.mts | 10 + .../api/agent/gateway/wechat/route.ts | 128 +++++++ .../api/agent/webhooks/bot-callback/route.ts | 4 +- src/locales/default/agent.ts | 8 + .../agent/_layout/Sidebar/Header/Nav.tsx | 5 +- .../(main)/agent/channel/detail/Body.tsx | 26 +- .../agent/channel/detail/QrCodeAuth.tsx | 150 ++++++++ .../(main)/agent/channel/detail/index.tsx | 69 +++- src/server/routers/lambda/agentBotProvider.ts | 11 + src/server/services/bot/AgentBridgeService.ts | 102 +++-- src/server/services/bot/BotCallbackService.ts | 33 +- .../bot/__tests__/BotCallbackService.test.ts | 4 + .../services/bot/platforms/discord/client.ts | 14 + src/server/services/bot/platforms/index.ts | 3 + src/server/services/bot/platforms/types.ts | 18 + .../services/bot/platforms/wechat/client.ts | 296 +++++++++++++++ .../bot/platforms/wechat/definition.ts | 17 + .../services/bot/platforms/wechat/schema.ts | 39 ++ src/services/agentBotProvider.ts | 8 + 38 files changed, 2625 insertions(+), 89 deletions(-) create mode 100644 docs/usage/channels/wechat.mdx create mode 100644 docs/usage/channels/wechat.zh-CN.mdx create mode 100644 packages/chat-adapter-wechat/package.json create mode 100644 packages/chat-adapter-wechat/src/adapter.test.ts create mode 100644 packages/chat-adapter-wechat/src/adapter.ts create mode 100644 packages/chat-adapter-wechat/src/api.test.ts create mode 100644 packages/chat-adapter-wechat/src/api.ts create mode 100644 packages/chat-adapter-wechat/src/format-converter.test.ts create mode 100644 packages/chat-adapter-wechat/src/format-converter.ts create mode 100644 packages/chat-adapter-wechat/src/index.ts create mode 100644 packages/chat-adapter-wechat/src/types.ts create mode 100644 packages/chat-adapter-wechat/tsconfig.json create mode 100644 packages/chat-adapter-wechat/tsup.config.ts create mode 100644 packages/chat-adapter-wechat/vitest.config.mts create mode 100644 src/app/(backend)/api/agent/gateway/wechat/route.ts create mode 100644 src/routes/(main)/agent/channel/detail/QrCodeAuth.tsx create mode 100644 src/server/services/bot/platforms/wechat/client.ts create mode 100644 src/server/services/bot/platforms/wechat/definition.ts create mode 100644 src/server/services/bot/platforms/wechat/schema.ts diff --git a/apps/cli/src/commands/bot.ts b/apps/cli/src/commands/bot.ts index 9b00340f7e..d0ada779f8 100644 --- a/apps/cli/src/commands/bot.ts +++ b/apps/cli/src/commands/bot.ts @@ -5,7 +5,7 @@ import { getTrpcClient } from '../api/client'; import { confirm, outputJson, printTable } from '../utils/format'; import { log } from '../utils/logger'; -const SUPPORTED_PLATFORMS = ['discord', 'slack', 'telegram', 'lark', 'feishu']; +const SUPPORTED_PLATFORMS = ['discord', 'slack', 'telegram', 'lark', 'feishu', 'wechat']; const PLATFORM_CREDENTIAL_FIELDS: Record = { discord: ['botToken', 'publicKey'], @@ -13,6 +13,7 @@ const PLATFORM_CREDENTIAL_FIELDS: Record = { lark: ['appSecret'], slack: ['botToken', 'signingSecret'], telegram: ['botToken'], + wechat: ['botToken', 'botId'], }; function parseCredentials( @@ -22,6 +23,7 @@ function parseCredentials( const creds: Record = {}; if (options.botToken) creds.botToken = options.botToken; + if (options.botId) creds.botId = options.botId; if (options.publicKey) creds.publicKey = options.publicKey; if (options.signingSecret) creds.signingSecret = options.signingSecret; if (options.appSecret) creds.appSecret = options.appSecret; @@ -125,6 +127,7 @@ export function registerBotCommand(program: Command) { .requiredOption('--platform ', `Platform: ${SUPPORTED_PLATFORMS.join(', ')}`) .requiredOption('--app-id ', 'Application ID for webhook routing') .option('--bot-token ', 'Bot token') + .option('--bot-id ', 'Bot ID (WeChat)') .option('--public-key ', 'Public key (Discord)') .option('--signing-secret ', 'Signing secret (Slack)') .option('--app-secret ', 'App secret (Lark/Feishu)') @@ -133,6 +136,7 @@ export function registerBotCommand(program: Command) { agent: string; appId: string; appSecret?: string; + botId?: string; botToken?: string; platform: string; publicKey?: string; @@ -175,6 +179,7 @@ export function registerBotCommand(program: Command) { .command('update ') .description('Update a bot integration') .option('--bot-token ', 'New bot token') + .option('--bot-id ', 'New bot ID (WeChat)') .option('--public-key ', 'New public key') .option('--signing-secret ', 'New signing secret') .option('--app-secret ', 'New app secret') @@ -186,6 +191,7 @@ export function registerBotCommand(program: Command) { options: { appId?: string; appSecret?: string; + botId?: string; botToken?: string; platform?: string; publicKey?: string; @@ -196,6 +202,7 @@ export function registerBotCommand(program: Command) { const credentials: Record = {}; if (options.botToken) credentials.botToken = options.botToken; + if (options.botId) credentials.botId = options.botId; if (options.publicKey) credentials.publicKey = options.publicKey; if (options.signingSecret) credentials.signingSecret = options.signingSecret; if (options.appSecret) credentials.appSecret = options.appSecret; diff --git a/docs/usage/channels/overview.mdx b/docs/usage/channels/overview.mdx index 3dd2b96285..9dcfff7fc2 100644 --- a/docs/usage/channels/overview.mdx +++ b/docs/usage/channels/overview.mdx @@ -2,7 +2,7 @@ title: Channels Overview description: >- Connect your LobeHub agents to external messaging platforms like Discord, - Slack, Telegram, QQ, Feishu, and Lark, allowing users to interact with AI + Slack, Telegram, QQ, WeChat, Feishu, and Lark, allowing users to interact with AI assistants directly in their favorite chat apps. tags: - Channels @@ -12,6 +12,7 @@ tags: - Slack - Telegram - QQ + - WeChat - Feishu - Lark --- @@ -32,6 +33,7 @@ Channels allow you to connect your LobeHub agents to external messaging platform | [Slack](/docs/usage/channels/slack) | Connect to Slack for channel and direct message conversations | | [Telegram](/docs/usage/channels/telegram) | Connect to Telegram for private and group conversations | | [QQ](/docs/usage/channels/qq) | Connect to QQ for group chats and direct messages | +| [WeChat (微信)](/docs/usage/channels/wechat) | Connect to WeChat via iLink Bot for private and group chats | | [Feishu (飞书)](/docs/usage/channels/feishu) | Connect to Feishu for team collaboration (Chinese version) | | [Lark](/docs/usage/channels/lark) | Connect to Lark for team collaboration (international version) | @@ -40,7 +42,7 @@ Channels allow you to connect your LobeHub agents to external messaging platform Each channel integration works by linking a bot account on the target platform to a LobeHub agent. When a user sends a message to the bot, LobeHub processes it through the agent and sends the response back to the same conversation. - **Per-agent configuration** — Each agent can have its own set of channel connections, so different agents can serve different platforms or communities. -- **Multiple channels simultaneously** — A single agent can be connected to Discord, Slack, Telegram, QQ, Feishu, and Lark at the same time. LobeHub routes messages to the correct agent automatically. +- **Multiple channels simultaneously** — A single agent can be connected to Discord, Slack, Telegram, QQ, WeChat, Feishu, and Lark at the same time. LobeHub routes messages to the correct agent automatically. - **Secure credential storage** — All bot tokens and app secrets are encrypted before being stored. ## Getting Started @@ -52,6 +54,7 @@ Each channel integration works by linking a bot account on the target platform t - [Slack](/docs/usage/channels/slack) - [Telegram](/docs/usage/channels/telegram) - [QQ](/docs/usage/channels/qq) + - [WeChat (微信)](/docs/usage/channels/wechat) - [Feishu (飞书)](/docs/usage/channels/feishu) - [Lark](/docs/usage/channels/lark) @@ -59,10 +62,10 @@ Each channel integration works by linking a bot account on the target platform t Text messages are supported across all platforms. Some features vary by platform: -| Feature | Discord | Slack | Telegram | QQ | Feishu | Lark | -| ---------------------- | ------- | ----- | -------- | --- | ------- | ------- | -| Text messages | Yes | Yes | Yes | Yes | Yes | Yes | -| Direct messages | Yes | Yes | Yes | Yes | Yes | Yes | -| Group chats | Yes | Yes | Yes | Yes | Yes | Yes | -| Reactions | Yes | Yes | Yes | No | Partial | Partial | -| Image/file attachments | Yes | Yes | Yes | Yes | Yes | Yes | +| Feature | Discord | Slack | Telegram | QQ | WeChat | Feishu | Lark | +| ---------------------- | ------- | ----- | -------- | --- | ------ | ------- | ------- | +| Text messages | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Direct messages | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Group chats | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Reactions | Yes | Yes | Yes | No | No | Partial | Partial | +| Image/file attachments | Yes | Yes | Yes | Yes | No | Yes | Yes | diff --git a/docs/usage/channels/overview.zh-CN.mdx b/docs/usage/channels/overview.zh-CN.mdx index dd24d0e60a..440329528d 100644 --- a/docs/usage/channels/overview.zh-CN.mdx +++ b/docs/usage/channels/overview.zh-CN.mdx @@ -1,6 +1,6 @@ --- title: 渠道概览 -description: 将 LobeHub 代理连接到外部消息平台,如 Discord、Slack、Telegram、QQ、飞书和 Lark,让用户可以直接在他们喜欢的聊天应用中与 AI 助手互动。 +description: 将 LobeHub 代理连接到外部消息平台,如 Discord、Slack、Telegram、QQ、微信、飞书和 Lark,让用户可以直接在他们喜欢的聊天应用中与 AI 助手互动。 tags: - 渠道 - 消息渠道 @@ -9,6 +9,7 @@ tags: - Slack - Telegram - QQ + - 微信 - 飞书 - Lark --- @@ -23,21 +24,22 @@ tags: ## 支持的平台 -| 平台 | 描述 | -| ----------------------------------------- | ------------------------- | -| [Discord](/docs/usage/channels/discord) | 连接到 Discord 服务器,用于频道聊天和私信 | -| [Slack](/docs/usage/channels/slack) | 连接到 Slack,用于频道和私信对话 | -| [Telegram](/docs/usage/channels/telegram) | 连接到 Telegram,用于私人和群组对话 | -| [QQ](/docs/usage/channels/qq) | 连接到 QQ,用于群聊和私信 | -| [飞书](/docs/usage/channels/feishu) | 连接到飞书,用于团队协作(中国版) | -| [Lark](/docs/usage/channels/lark) | 连接到 Lark,用于团队协作(国际版) | +| 平台 | 描述 | +| ----------------------------------------- | -------------------------- | +| [Discord](/docs/usage/channels/discord) | 连接到 Discord 服务器,用于频道聊天和私信 | +| [Slack](/docs/usage/channels/slack) | 连接到 Slack,用于频道和私信对话 | +| [Telegram](/docs/usage/channels/telegram) | 连接到 Telegram,用于私人和群组对话 | +| [QQ](/docs/usage/channels/qq) | 连接到 QQ,用于群聊和私信 | +| [微信](/docs/usage/channels/wechat) | 通过 iLink Bot 连接到微信,用于私聊和群聊 | +| [飞书](/docs/usage/channels/feishu) | 连接到飞书,用于团队协作(中国版) | +| [Lark](/docs/usage/channels/lark) | 连接到 Lark,用于团队协作(国际版) | ## 工作原理 每个渠道集成都通过将目标平台上的机器人账户与 LobeHub 代理连接来实现。当用户向机器人发送消息时,LobeHub 会通过代理处理消息并将响应发送回同一对话。 - **按代理配置** — 每个代理可以拥有自己的一组渠道连接,因此不同的代理可以服务于不同的平台或社区。 -- **同时支持多个渠道** — 单个代理可以同时连接到 Discord、Slack、Telegram、QQ、飞书和 Lark。LobeHub 会自动将消息路由到正确的代理。 +- **同时支持多个渠道** — 单个代理可以同时连接到 Discord、Slack、Telegram、QQ、微信、飞书和 Lark。LobeHub 会自动将消息路由到正确的代理。 - **安全的凭据存储** — 所有机器人令牌和应用密钥在存储前都会被加密。 ## 快速开始 @@ -49,6 +51,7 @@ tags: - [Slack](/docs/usage/channels/slack) - [Telegram](/docs/usage/channels/telegram) - [QQ](/docs/usage/channels/qq) + - [微信](/docs/usage/channels/wechat) - [飞书](/docs/usage/channels/feishu) - [Lark](/docs/usage/channels/lark) @@ -56,10 +59,10 @@ tags: 所有平台均支持文本消息。某些功能因平台而异: -| 功能 | Discord | Slack | Telegram | QQ | 飞书 | Lark | -| --------- | ------- | ----- | -------- | -- | ---- | ---- | -| 文本消息 | 是 | 是 | 是 | 是 | 是 | 是 | -| 私人消息 | 是 | 是 | 是 | 是 | 是 | 是 | -| 群组聊天 | 是 | 是 | 是 | 是 | 是 | 是 | -| 表情反应 | 是 | 是 | 是 | 否 | 部分支持 | 部分支持 | -| 图片 / 文件附件 | 是 | 是 | 是 | 是 | 是 | 是 | +| 功能 | Discord | Slack | Telegram | QQ | 微信 | 飞书 | Lark | +| --------- | ------- | ----- | -------- | -- | -- | ---- | ---- | +| 文本消息 | 是 | 是 | 是 | 是 | 是 | 是 | 是 | +| 私人消息 | 是 | 是 | 是 | 是 | 是 | 是 | 是 | +| 群组聊天 | 是 | 是 | 是 | 是 | 是 | 是 | 是 | +| 表情反应 | 是 | 是 | 是 | 否 | 否 | 部分支持 | 部分支持 | +| 图片 / 文件附件 | 是 | 是 | 是 | 是 | 否 | 是 | 是 | diff --git a/docs/usage/channels/wechat.mdx b/docs/usage/channels/wechat.mdx new file mode 100644 index 0000000000..ecc700400d --- /dev/null +++ b/docs/usage/channels/wechat.mdx @@ -0,0 +1,96 @@ +--- +title: Connect LobeHub to WeChat +description: >- + Learn how to connect a WeChat bot to your LobeHub agent via the iLink Bot API, + enabling your AI assistant to chat with users in WeChat private and group + conversations. +tags: + - WeChat + - Message Channels + - Bot Setup + - Integration +--- + +# Connect LobeHub to WeChat + + + This feature is currently in development and may not be fully stable. You can enable it by turning + on **Developer Mode** in **Settings** → **Advanced Settings** → **Developer Mode**. + + +By connecting a WeChat channel to your LobeHub agent, users can interact with the AI assistant through WeChat private chats and group conversations. + +## Prerequisites + +- A LobeHub account with an active subscription +- A WeChat account + +## Step 1: Open Channel Settings + +In LobeHub, navigate to your agent's settings, then select the **Channels** tab. Click **WeChat** from the platform list. + +## Step 2: Scan QR Code to Connect + + + ### Click "Scan QR Code to Connect" + + On the WeChat channel page, click the **Scan QR Code to Connect** button. A modal dialog will appear displaying a QR code. + + ### Scan with WeChat + + Open WeChat on your phone, go to **Scan** (via the + button in the top right), and scan the QR code displayed in LobeHub. + + ### Confirm Login + + After scanning, a confirmation prompt will appear in WeChat. Tap **Confirm** to authorize the connection. + + ### Connection Complete + + Once confirmed, LobeHub will automatically save your credentials and connect the bot. You should see a success message in the channel settings. + + +## Step 3: Test the Bot + +Open WeChat, find your bot contact, and send a message. The bot should respond through your LobeHub agent. + +## Adding the Bot to Group Chats + +To use the bot in WeChat groups: + +1. Add the bot to a WeChat group +2. @mention the bot or send a message in the group to trigger a response +3. The bot will reply in the group conversation + +## Advanced Settings + +| Setting | Default | Description | +| ------------------------ | ------- | -------------------------------------------------------- | +| **Character Limit** | 2000 | Maximum characters per message (range: 100–2000) | +| **Message Merge Window** | 2000 ms | How long to wait for additional messages before replying | +| **Show Usage Stats** | Off | Display token/cost stats in replies | + +## How It Works + +Unlike webhook-based platforms (Telegram, Slack), WeChat uses a **long-polling** mechanism via the iLink Bot API: + +1. When you scan the QR code, LobeHub obtains a bot token from WeChat's iLink API +2. LobeHub continuously polls the iLink API for new messages (\~35 second intervals) +3. When a message arrives, it is routed through the LobeHub agent for processing +4. The agent's response is sent back to WeChat via the iLink API + +This polling is managed by a background cron job, so the connection is maintained automatically. + +## Limitations + +- **No message editing** — WeChat does not support editing sent messages. Updated responses will be sent as new messages. +- **No reactions** — WeChat iLink Bot API does not support emoji reactions. +- **Text only** — Only text messages are currently supported. Image and file attachments are not yet available. +- **Message length limit** — Messages exceeding 2000 characters will be automatically split into multiple messages. +- **Session expiration** — The bot session may expire and require re-authentication by scanning a new QR code. + +## Troubleshooting + +- **QR code expired:** Click **Refresh QR Code** in the modal to generate a new one. +- **Bot not responding:** The session may have expired. Go to the WeChat channel settings and re-scan the QR code to reconnect. +- **Delayed responses:** Long-polling has a natural delay of up to 35 seconds between polls. This is expected behavior. +- **Connection lost after some time:** WeChat sessions expire periodically. Re-authenticate by clicking "Scan QR Code to Connect" again. diff --git a/docs/usage/channels/wechat.zh-CN.mdx b/docs/usage/channels/wechat.zh-CN.mdx new file mode 100644 index 0000000000..a385012727 --- /dev/null +++ b/docs/usage/channels/wechat.zh-CN.mdx @@ -0,0 +1,93 @@ +--- +title: 将 LobeHub 连接到微信 +description: 了解如何通过 iLink Bot API 将微信机器人连接到您的 LobeHub 代理,使您的 AI 助手能够在微信私聊和群聊中与用户互动。 +tags: + - 微信 + - 消息渠道 + - 机器人设置 + - 集成 +--- + +# 将 LobeHub 连接到微信 + + + 此功能目前正在开发中,可能尚未完全稳定。您可以通过在 **设置** → **高级设置** → **开发者模式** + 中启用 **开发者模式** 来使用此功能。 + + +通过将微信渠道连接到您的 LobeHub 代理,用户可以通过微信私聊和群聊与 AI 助手互动。 + +## 前置条件 + +- 一个拥有有效订阅的 LobeHub 账户 +- 一个微信账户 + +## 第一步:打开渠道设置 + +在 LobeHub 中,导航到您的代理设置,然后选择 **渠道** 标签页。从平台列表中点击 **微信**。 + +## 第二步:扫码连接 + + + ### 点击 "扫码连接" + + 在微信渠道页面中,点击 **扫码连接** 按钮。将弹出一个显示二维码的对话框。 + + ### 使用微信扫码 + + 打开手机微信,点击右上角的 **+** 按钮,选择 **扫一扫**,扫描 LobeHub 中显示的二维码。 + + ### 确认登录 + + 扫码后,微信中会出现确认提示。点击 **确认** 授权连接。 + + ### 连接完成 + + 确认后,LobeHub 将自动保存凭证并连接机器人。您应该会在渠道设置中看到成功消息。 + + +## 第三步:测试机器人 + +打开微信,找到您的机器人联系人,发送一条消息。机器人应通过您的 LobeHub 代理进行响应。 + +## 将机器人添加到群聊 + +要在微信群聊中使用机器人: + +1. 将机器人添加到微信群聊中 +2. @提及机器人或在群中发送消息以触发响应 +3. 机器人将在群聊中回复 + +## 高级设置 + +| 设置 | 默认值 | 描述 | +| ---------- | ------- | ----------------------- | +| **字符限制** | 2000 | 每条消息的最大字符数(范围:100–2000) | +| **消息合并窗口** | 2000 毫秒 | 等待更多消息再回复的时间 | +| **显示使用统计** | 关闭 | 在回复中显示 Token 用量 / 成本统计 | + +## 工作原理 + +与基于 Webhook 的平台(Telegram、Slack)不同,微信使用 iLink Bot API 的 **长轮询** 机制: + +1. 当您扫描二维码时,LobeHub 从微信 iLink API 获取 bot token +2. LobeHub 持续轮询 iLink API 获取新消息(约 35 秒间隔) +3. 当消息到达时,通过 LobeHub 代理进行处理 +4. 代理的响应通过 iLink API 发送回微信 + +此轮询由后台定时任务管理,连接会自动维护。 + +## 功能限制 + +- **不支持消息编辑** — 微信不支持编辑已发送的消息。更新的回复将作为新消息发送。 +- **不支持表情回应** — 微信 iLink Bot API 不支持表情回应功能。 +- **仅支持文本** — 目前仅支持文本消息。图片和文件附件暂不可用。 +- **消息长度限制** — 超过 2000 个字符的消息将被自动拆分为多条消息发送。 +- **会话过期** — 机器人会话可能会过期,需要重新扫码认证。 + +## 故障排除 + +- **二维码已过期:** 在弹窗中点击 **刷新二维码** 生成新的二维码。 +- **机器人未响应:** 会话可能已过期。前往微信渠道设置,重新扫码连接。 +- **响应延迟:** 长轮询在两次轮询之间有最多 35 秒的自然延迟。这是预期行为。 +- **一段时间后连接断开:** 微信会话会定期过期。再次点击 "扫码连接" 重新认证。 diff --git a/locales/en-US/agent.json b/locales/en-US/agent.json index 91bfedfd74..d2fe6623ec 100644 --- a/locales/en-US/agent.json +++ b/locales/en-US/agent.json @@ -79,5 +79,12 @@ "channel.validationError": "Please fill in Application ID and Token", "channel.verificationToken": "Verification Token", "channel.verificationTokenHint": "Optional. Used to verify webhook event source.", - "channel.verificationTokenPlaceholder": "Paste your verification token here" + "channel.verificationTokenPlaceholder": "Paste your verification token here", + "channel.wechat.description": "Connect this assistant to WeChat via iLink Bot for private and group chats.", + "channel.wechatQrExpired": "QR code expired. Please refresh to get a new one.", + "channel.wechatQrRefresh": "Refresh QR Code", + "channel.wechatQrScaned": "QR code scanned. Please confirm the login in WeChat.", + "channel.wechatQrWait": "Open WeChat and scan the QR code to connect.", + "channel.wechatScanTitle": "Connect WeChat Bot", + "channel.wechatScanToConnect": "Scan QR Code to Connect" } diff --git a/locales/zh-CN/agent.json b/locales/zh-CN/agent.json index 26402fb086..c4c89a0e9c 100644 --- a/locales/zh-CN/agent.json +++ b/locales/zh-CN/agent.json @@ -79,5 +79,12 @@ "channel.validationError": "请填写应用 ID 和 Token", "channel.verificationToken": "Verification Token", "channel.verificationTokenHint": "可选。用于验证事件推送来源。", - "channel.verificationTokenPlaceholder": "在此粘贴你的 Verification Token" + "channel.verificationTokenPlaceholder": "在此粘贴你的 Verification Token", + "channel.wechat.description": "通过 iLink Bot 将助手连接到微信,支持私聊和群聊。", + "channel.wechatQrExpired": "二维码已过期,请刷新获取新的二维码。", + "channel.wechatQrRefresh": "刷新二维码", + "channel.wechatQrScaned": "已扫码,请在微信中确认登录。", + "channel.wechatQrWait": "打开微信扫描二维码以连接。", + "channel.wechatScanTitle": "连接微信机器人", + "channel.wechatScanToConnect": "扫码连接" } diff --git a/package.json b/package.json index 344546cab2..c4df7c3075 100644 --- a/package.json +++ b/package.json @@ -225,6 +225,7 @@ "@lobechat/business-const": "workspace:*", "@lobechat/chat-adapter-feishu": "workspace:*", "@lobechat/chat-adapter-qq": "workspace:*", + "@lobechat/chat-adapter-wechat": "workspace:*", "@lobechat/config": "workspace:*", "@lobechat/const": "workspace:*", "@lobechat/context-engine": "workspace:*", diff --git a/packages/chat-adapter-wechat/package.json b/packages/chat-adapter-wechat/package.json new file mode 100644 index 0000000000..d2cb783683 --- /dev/null +++ b/packages/chat-adapter-wechat/package.json @@ -0,0 +1,26 @@ +{ + "name": "@lobechat/chat-adapter-wechat", + "version": "0.1.0", + "description": "WeChat (iLink) Bot adapter for chat SDK", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "clean": "rm -rf dist", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "chat": "^4.14.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsup": "^8.3.5", + "typescript": "^5.7.2" + } +} diff --git a/packages/chat-adapter-wechat/src/adapter.test.ts b/packages/chat-adapter-wechat/src/adapter.test.ts new file mode 100644 index 0000000000..3d24754e54 --- /dev/null +++ b/packages/chat-adapter-wechat/src/adapter.test.ts @@ -0,0 +1,357 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createWechatAdapter, WechatAdapter } from './adapter'; +import type { WechatRawMessage } from './types'; +import { MessageItemType, MessageState, MessageType } from './types'; + +// ---- helpers ---- + +function makeRawMessage(overrides: Partial = {}): WechatRawMessage { + return { + client_id: 'client_1', + context_token: 'ctx_tok', + create_time_ms: 1700000000000, + from_user_id: 'user_abc@im.wechat', + item_list: [{ text_item: { text: 'hello' }, type: MessageItemType.TEXT }], + message_id: 42, + message_state: MessageState.FINISH, + message_type: MessageType.USER, + to_user_id: 'bot_id', + ...overrides, + }; +} + +function makeRequest(body: unknown): Request { + return new Request('http://localhost/webhook', { + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); +} + +// ---- tests ---- + +describe('WechatAdapter', () => { + let adapter: WechatAdapter; + + const mockChat = { + getLogger: vi.fn(() => ({ + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + })), + getUserName: vi.fn(() => 'TestBot'), + processMessage: vi.fn(), + }; + + beforeEach(() => { + vi.resetAllMocks(); + adapter = new WechatAdapter({ botId: 'bot_123', botToken: 'tok' }); + adapter.initialize(mockChat as any); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ---------- constructor & initialize ---------- + + describe('constructor', () => { + it('should set botUserId from config', () => { + expect(adapter.botUserId).toBe('bot_123'); + }); + + it('should default userName to "wechat-bot"', () => { + const a = new WechatAdapter({ botToken: 'tok' }); + // Before initialize, userName comes from config + expect(a.userName).toBe('wechat-bot'); + }); + + it('should use custom userName if provided', () => { + const a = new WechatAdapter({ botToken: 'tok', userName: 'MyBot' }); + expect(a.userName).toBe('MyBot'); + }); + }); + + describe('initialize', () => { + it('should set userName from chat instance', () => { + expect(adapter.userName).toBe('TestBot'); + }); + }); + + // ---------- thread ID encoding/decoding ---------- + + describe('encodeThreadId / decodeThreadId', () => { + it('should encode thread ID with wechat prefix', () => { + const encoded = adapter.encodeThreadId({ id: 'user_abc@im.wechat', type: 'single' }); + expect(encoded).toBe('wechat:single:user_abc@im.wechat'); + }); + + it('should encode group thread ID', () => { + const encoded = adapter.encodeThreadId({ id: 'group_1', type: 'group' }); + expect(encoded).toBe('wechat:group:group_1'); + }); + + it('should decode valid thread ID', () => { + const decoded = adapter.decodeThreadId('wechat:single:user_abc@im.wechat'); + expect(decoded).toEqual({ id: 'user_abc@im.wechat', type: 'single' }); + }); + + it('should decode thread ID with colons in user ID', () => { + const decoded = adapter.decodeThreadId('wechat:single:id:with:colons'); + expect(decoded).toEqual({ id: 'id:with:colons', type: 'single' }); + }); + + it('should fallback for invalid thread ID', () => { + const decoded = adapter.decodeThreadId('some-random-id'); + expect(decoded).toEqual({ id: 'some-random-id', type: 'single' }); + }); + + it('should round-trip encode/decode', () => { + const original = { id: 'user_xyz@im.wechat', type: 'single' as const }; + const encoded = adapter.encodeThreadId(original); + const decoded = adapter.decodeThreadId(encoded); + expect(decoded).toEqual(original); + }); + }); + + // ---------- isDM ---------- + + describe('isDM', () => { + it('should return true for single type', () => { + const threadId = adapter.encodeThreadId({ id: 'u', type: 'single' }); + expect(adapter.isDM(threadId)).toBe(true); + }); + + it('should return false for group type', () => { + const threadId = adapter.encodeThreadId({ id: 'g', type: 'group' }); + expect(adapter.isDM(threadId)).toBe(false); + }); + }); + + // ---------- channelIdFromThreadId ---------- + + describe('channelIdFromThreadId', () => { + it('should return threadId as-is', () => { + expect(adapter.channelIdFromThreadId('wechat:single:u')).toBe('wechat:single:u'); + }); + }); + + // ---------- handleWebhook ---------- + + describe('handleWebhook', () => { + it('should return 400 for invalid JSON', async () => { + const req = new Request('http://localhost/webhook', { + body: 'not json', + method: 'POST', + }); + + const res = await adapter.handleWebhook(req); + expect(res.status).toBe(400); + }); + + it('should skip bot messages', async () => { + const msg = makeRawMessage({ message_type: MessageType.BOT }); + const res = await adapter.handleWebhook(makeRequest(msg)); + + expect(res.status).toBe(200); + expect(mockChat.processMessage).not.toHaveBeenCalled(); + }); + + it('should skip non-finished messages', async () => { + const msg = makeRawMessage({ message_state: MessageState.GENERATING }); + const res = await adapter.handleWebhook(makeRequest(msg)); + + expect(res.status).toBe(200); + expect(mockChat.processMessage).not.toHaveBeenCalled(); + }); + + it('should skip empty text messages', async () => { + const msg = makeRawMessage({ + item_list: [{ text_item: { text: ' ' }, type: MessageItemType.TEXT }], + }); + const res = await adapter.handleWebhook(makeRequest(msg)); + + expect(res.status).toBe(200); + expect(mockChat.processMessage).not.toHaveBeenCalled(); + }); + + it('should process valid user message', async () => { + const msg = makeRawMessage(); + const res = await adapter.handleWebhook(makeRequest(msg)); + + expect(res.status).toBe(200); + expect(mockChat.processMessage).toHaveBeenCalledTimes(1); + expect(mockChat.processMessage).toHaveBeenCalledWith( + adapter, + 'wechat:single:user_abc@im.wechat', + expect.any(Function), + undefined, + ); + }); + + it('should cache context token from message', async () => { + const msg = makeRawMessage({ context_token: 'new_ctx' }); + await adapter.handleWebhook(makeRequest(msg)); + + const threadId = adapter.encodeThreadId({ id: msg.from_user_id, type: 'single' }); + expect(adapter.getContextToken(threadId)).toBe('new_ctx'); + }); + }); + + // ---------- parseMessage ---------- + + describe('parseMessage', () => { + it('should parse text message', () => { + const raw = makeRawMessage(); + const message = adapter.parseMessage(raw); + + expect(message.text).toBe('hello'); + expect(message.id).toBe('42'); + expect(message.author.userId).toBe('user_abc@im.wechat'); + expect(message.author.isBot).toBe(false); + }); + + it('should parse bot message', () => { + const raw = makeRawMessage({ message_type: MessageType.BOT }); + const message = adapter.parseMessage(raw); + + expect(message.author.isBot).toBe(true); + }); + + it('should extract image placeholder', () => { + const raw = makeRawMessage({ + item_list: [ + { + image_item: { media: { aes_key: '', encrypt_query_param: '' } }, + type: MessageItemType.IMAGE, + }, + ], + }); + const message = adapter.parseMessage(raw); + expect(message.text).toBe('[image]'); + }); + + it('should extract voice text or placeholder', () => { + const raw = makeRawMessage({ + item_list: [ + { + type: MessageItemType.VOICE, + voice_item: { + media: { aes_key: '', encrypt_query_param: '' }, + text: 'transcribed text', + }, + }, + ], + }); + const message = adapter.parseMessage(raw); + expect(message.text).toBe('transcribed text'); + }); + + it('should extract file name', () => { + const raw = makeRawMessage({ + item_list: [ + { + file_item: { file_name: 'doc.pdf', media: { aes_key: '', encrypt_query_param: '' } }, + type: MessageItemType.FILE, + }, + ], + }); + const message = adapter.parseMessage(raw); + expect(message.text).toBe('[file: doc.pdf]'); + }); + + it('should extract video placeholder', () => { + const raw = makeRawMessage({ + item_list: [ + { + type: MessageItemType.VIDEO, + video_item: { media: { aes_key: '', encrypt_query_param: '' } }, + }, + ], + }); + const message = adapter.parseMessage(raw); + expect(message.text).toBe('[video]'); + }); + + it('should join multiple items with newline', () => { + const raw = makeRawMessage({ + item_list: [ + { text_item: { text: 'line1' }, type: MessageItemType.TEXT }, + { text_item: { text: 'line2' }, type: MessageItemType.TEXT }, + ], + }); + const message = adapter.parseMessage(raw); + expect(message.text).toBe('line1\nline2'); + }); + }); + + // ---------- context token management ---------- + + describe('context token management', () => { + it('should get and set context tokens', () => { + adapter.setContextToken('thread_1', 'token_a'); + expect(adapter.getContextToken('thread_1')).toBe('token_a'); + }); + + it('should return undefined for unknown thread', () => { + expect(adapter.getContextToken('unknown')).toBeUndefined(); + }); + }); + + // ---------- fetchThread ---------- + + describe('fetchThread', () => { + it('should return thread info for single chat', async () => { + const threadId = adapter.encodeThreadId({ id: 'user_1', type: 'single' }); + const info = await adapter.fetchThread(threadId); + + expect(info.id).toBe(threadId); + expect(info.isDM).toBe(true); + expect(info.metadata).toEqual({ id: 'user_1', type: 'single' }); + }); + + it('should return thread info for group chat', async () => { + const threadId = adapter.encodeThreadId({ id: 'group_1', type: 'group' }); + const info = await adapter.fetchThread(threadId); + + expect(info.isDM).toBe(false); + }); + }); + + // ---------- fetchMessages ---------- + + describe('fetchMessages', () => { + it('should return empty result', async () => { + const result = await adapter.fetchMessages('any'); + expect(result).toEqual({ messages: [], nextCursor: undefined }); + }); + }); + + // ---------- no-op methods ---------- + + describe('no-op methods', () => { + it('addReaction should resolve', async () => { + await expect(adapter.addReaction('t', 'm', 'emoji')).resolves.toBeUndefined(); + }); + + it('removeReaction should resolve', async () => { + await expect(adapter.removeReaction('t', 'm', 'emoji')).resolves.toBeUndefined(); + }); + + it('startTyping should resolve', async () => { + await expect(adapter.startTyping('t')).resolves.toBeUndefined(); + }); + }); +}); + +// ---------- createWechatAdapter factory ---------- + +describe('createWechatAdapter', () => { + it('should return a WechatAdapter instance', () => { + const adapter = createWechatAdapter({ botToken: 'tok' }); + expect(adapter).toBeInstanceOf(WechatAdapter); + expect(adapter.name).toBe('wechat'); + }); +}); diff --git a/packages/chat-adapter-wechat/src/adapter.ts b/packages/chat-adapter-wechat/src/adapter.ts new file mode 100644 index 0000000000..08689a2933 --- /dev/null +++ b/packages/chat-adapter-wechat/src/adapter.ts @@ -0,0 +1,332 @@ +import type { + Adapter, + AdapterPostableMessage, + Author, + ChatInstance, + EmojiValue, + FetchOptions, + FetchResult, + FormattedContent, + Logger, + RawMessage, + ThreadInfo, + WebhookOptions, +} from 'chat'; +import { Message, parseMarkdown } from 'chat'; + +import { WechatApiClient } from './api'; +import { WechatFormatConverter } from './format-converter'; +import type { WechatAdapterConfig, WechatRawMessage, WechatThreadId } from './types'; +import { MessageItemType, MessageState, MessageType } from './types'; + +/** + * Extract text content from a WechatRawMessage's item_list. + */ +function extractText(msg: WechatRawMessage): string { + const parts: string[] = []; + for (const item of msg.item_list) { + switch (item.type) { + case MessageItemType.TEXT: { + if (item.text_item?.text) parts.push(item.text_item.text); + break; + } + case MessageItemType.IMAGE: { + parts.push('[image]'); + break; + } + case MessageItemType.VOICE: { + parts.push(item.voice_item?.text || '[voice]'); + break; + } + case MessageItemType.FILE: { + parts.push(`[file: ${item.file_item?.file_name || 'unknown'}]`); + break; + } + case MessageItemType.VIDEO: { + parts.push('[video]'); + break; + } + } + } + return parts.join('\n'); +} + +/** + * WeChat (iLink) adapter for Chat SDK. + * + * Handles webhook requests forwarded by the long-polling monitor + * and message operations via iLink Bot API. + */ +export class WechatAdapter implements Adapter { + readonly name = 'wechat'; + private readonly api: WechatApiClient; + private readonly formatConverter: WechatFormatConverter; + private _userName: string; + private _botUserId?: string; + private chat!: ChatInstance; + private logger!: Logger; + + /** + * Per-thread contextToken cache. + * WeChat requires echoing the context_token from the latest inbound message. + */ + private contextTokens = new Map(); + + get userName(): string { + return this._userName; + } + + get botUserId(): string | undefined { + return this._botUserId; + } + + constructor(config: WechatAdapterConfig & { userName?: string }) { + this.api = new WechatApiClient(config.botToken, config.botId); + this.formatConverter = new WechatFormatConverter(); + this._userName = config.userName || 'wechat-bot'; + this._botUserId = config.botId; + } + + async initialize(chat: ChatInstance): Promise { + this.chat = chat; + this.logger = chat.getLogger(this.name); + this._userName = chat.getUserName(); + + this.logger.info('Initialized WeChat adapter (botUserId=%s)', this._botUserId); + } + + // ------------------------------------------------------------------ + // Webhook handling — processes forwarded messages from the monitor + // ------------------------------------------------------------------ + + async handleWebhook(request: Request, options?: WebhookOptions): Promise { + const bodyText = await request.text(); + + let msg: WechatRawMessage; + try { + msg = JSON.parse(bodyText); + } catch { + return new Response('Invalid JSON', { status: 400 }); + } + + // Skip bot's own messages and non-finished messages + if (msg.message_type === MessageType.BOT) { + return Response.json({ ok: true }); + } + if (msg.message_state !== undefined && msg.message_state !== MessageState.FINISH) { + return Response.json({ ok: true }); + } + + const text = extractText(msg); + if (!text.trim()) { + return Response.json({ ok: true }); + } + + // Build thread ID and cache context token + const threadId = this.encodeThreadId({ id: msg.from_user_id, type: 'single' }); + this.contextTokens.set(threadId, msg.context_token); + + const messageFactory = () => this.parseRawEvent(msg, threadId, text); + this.chat.processMessage(this, threadId, messageFactory, options); + + return Response.json({ ok: true }); + } + + // ------------------------------------------------------------------ + // Message operations + // ------------------------------------------------------------------ + + async postMessage( + threadId: string, + message: AdapterPostableMessage, + ): Promise> { + const { id } = this.decodeThreadId(threadId); + const text = this.formatConverter.renderPostable(message); + const contextToken = this.contextTokens.get(threadId) || ''; + + await this.api.sendMessage(id, text, contextToken); + + return { + id: `bot_${Date.now()}`, + raw: { + client_id: `lobehub_${Date.now()}`, + context_token: contextToken, + create_time_ms: Date.now(), + from_user_id: this._botUserId || '', + item_list: [{ text_item: { text }, type: MessageItemType.TEXT }], + message_id: 0, + message_state: MessageState.FINISH, + message_type: MessageType.BOT, + to_user_id: id, + }, + threadId, + }; + } + + async editMessage( + threadId: string, + _messageId: string, + message: AdapterPostableMessage, + ): Promise> { + // WeChat doesn't support editing — fall back to posting a new message + return this.postMessage(threadId, message); + } + + async deleteMessage(_threadId: string, _messageId: string): Promise { + this.logger.warn('Message deletion not supported for WeChat'); + } + + async fetchMessages( + _threadId: string, + _options?: FetchOptions, + ): Promise> { + return { messages: [], nextCursor: undefined }; + } + + async fetchThread(threadId: string): Promise { + const { type, id } = this.decodeThreadId(threadId); + return { + channelId: threadId, + id: threadId, + isDM: type === 'single', + metadata: { id, type }, + }; + } + + // ------------------------------------------------------------------ + // Message parsing + // ------------------------------------------------------------------ + + parseMessage(raw: WechatRawMessage): Message { + const text = extractText(raw); + const formatted = parseMarkdown(text); + const threadId = this.encodeThreadId({ id: raw.from_user_id, type: 'single' }); + + return new Message({ + attachments: [], + author: { + fullName: raw.from_user_id, + isBot: raw.message_type === MessageType.BOT, + isMe: raw.message_type === MessageType.BOT, + userId: raw.from_user_id, + userName: raw.from_user_id, + }, + formatted, + id: String(raw.message_id || 0), + metadata: { + dateSent: new Date(raw.create_time_ms || Date.now()), + edited: false, + }, + raw, + text, + threadId, + }); + } + + private async parseRawEvent( + msg: WechatRawMessage, + threadId: string, + text: string, + ): Promise> { + const formatted = parseMarkdown(text); + + const author: Author = { + fullName: msg.from_user_id, + isBot: false, + isMe: false, + userId: msg.from_user_id, + userName: msg.from_user_id, + }; + + return new Message({ + attachments: [], + author, + formatted, + id: String(msg.message_id || 0), + metadata: { + dateSent: new Date(msg.create_time_ms || Date.now()), + edited: false, + }, + raw: msg, + text, + threadId, + }); + } + + // ------------------------------------------------------------------ + // Reactions & typing (limited support) + // ------------------------------------------------------------------ + + async addReaction( + _threadId: string, + _messageId: string, + _emoji: EmojiValue | string, + ): Promise {} + + async removeReaction( + _threadId: string, + _messageId: string, + _emoji: EmojiValue | string, + ): Promise {} + + async startTyping(threadId: string): Promise { + const { id } = this.decodeThreadId(threadId); + const contextToken = this.contextTokens.get(threadId); + if (!contextToken) return; + await this.api.startTyping(id, contextToken); + } + + // ------------------------------------------------------------------ + // Thread ID encoding + // ------------------------------------------------------------------ + + encodeThreadId(data: WechatThreadId): string { + return `wechat:${data.type}:${data.id}`; + } + + decodeThreadId(threadId: string): WechatThreadId { + const parts = threadId.split(':'); + if (parts.length < 3 || parts[0] !== 'wechat') { + return { id: threadId, type: 'single' }; + } + return { id: parts.slice(2).join(':'), type: parts[1] as WechatThreadId['type'] }; + } + + channelIdFromThreadId(threadId: string): string { + return threadId; + } + + isDM(threadId: string): boolean { + const { type } = this.decodeThreadId(threadId); + return type === 'single'; + } + + // ------------------------------------------------------------------ + // Format rendering + // ------------------------------------------------------------------ + + renderFormatted(content: FormattedContent): string { + return this.formatConverter.fromAst(content); + } + + // ------------------------------------------------------------------ + // Context token management (public for platform client use) + // ------------------------------------------------------------------ + + getContextToken(threadId: string): string | undefined { + return this.contextTokens.get(threadId); + } + + setContextToken(threadId: string, token: string): void { + this.contextTokens.set(threadId, token); + } +} + +/** + * Factory function to create a WechatAdapter. + */ +export function createWechatAdapter( + config: WechatAdapterConfig & { userName?: string }, +): WechatAdapter { + return new WechatAdapter(config); +} diff --git a/packages/chat-adapter-wechat/src/api.test.ts b/packages/chat-adapter-wechat/src/api.test.ts new file mode 100644 index 0000000000..baf86c6118 --- /dev/null +++ b/packages/chat-adapter-wechat/src/api.test.ts @@ -0,0 +1,246 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DEFAULT_BASE_URL, fetchQrCode, pollQrStatus, WechatApiClient } from './api'; +import { WECHAT_RET_CODES } from './types'; + +// ---- helpers ---- + +const mockFetch = vi.fn(); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + headers: { 'Content-Type': 'application/json' }, + status, + }); +} + +beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// ---- tests ---- + +describe('WechatApiClient', () => { + let client: WechatApiClient; + + beforeEach(() => { + mockFetch.mockReset(); + client = new WechatApiClient('test-token', 'bot-123'); + }); + + // ---------- constructor ---------- + + describe('constructor', () => { + it('should use default base URL when none provided', () => { + const c = new WechatApiClient('tok'); + expect(c.botId).toBe(''); + }); + + it('should strip trailing slashes from base URL', async () => { + const c = new WechatApiClient('tok', 'id', 'https://example.com///'); + mockFetch.mockResolvedValueOnce(jsonResponse({ ret: 0, msgs: [], get_updates_buf: '' })); + + await c.getUpdates(); + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com/ilink/bot/getupdates', + expect.anything(), + ); + }); + }); + + // ---------- getUpdates ---------- + + describe('getUpdates', () => { + it('should return parsed response on success', async () => { + const payload = { ret: 0, msgs: [], get_updates_buf: 'cursor_1' }; + mockFetch.mockResolvedValueOnce(jsonResponse(payload)); + + const result = await client.getUpdates(); + expect(result).toEqual(payload); + }); + + it('should send cursor in request body', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ ret: 0, msgs: [], get_updates_buf: 'cursor_2' }), + ); + + await client.getUpdates('prev_cursor'); + const body = JSON.parse(mockFetch.mock.calls[0][1]!.body as string); + expect(body.get_updates_buf).toBe('prev_cursor'); + }); + + it('should throw on HTTP error', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ errmsg: 'Unauthorized' }, 401)); + + await expect(client.getUpdates()).rejects.toThrow('Unauthorized'); + }); + + it('should throw on non-zero ret code', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ ret: WECHAT_RET_CODES.SESSION_EXPIRED, errmsg: 'session expired' }), + ); + + await expect(client.getUpdates()).rejects.toThrow('session expired'); + }); + + it('should include Authorization and X-WECHAT-UIN headers', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ ret: 0, msgs: [], get_updates_buf: '' })); + + await client.getUpdates(); + const headers = mockFetch.mock.calls[0][1]!.headers as Record; + expect(headers['Authorization']).toBe('Bearer test-token'); + expect(headers['X-WECHAT-UIN']).toBeDefined(); + }); + }); + + // ---------- sendMessage ---------- + + describe('sendMessage', () => { + it('should send a short text in a single call', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ ret: 0 })); + + const result = await client.sendMessage('user_1', 'hello', 'ctx_token'); + expect(result).toEqual({ ret: 0 }); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('should chunk long text into multiple requests', async () => { + mockFetch.mockImplementation(() => Promise.resolve(jsonResponse({ ret: 0 }))); + + const longText = 'a'.repeat(4500); // > 2 * 2000 + await client.sendMessage('user_1', longText, 'ctx'); + + // 4500 / 2000 = 3 chunks + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + it('should include correct fields in request body', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ ret: 0 })); + + await client.sendMessage('user_1', 'hi', 'ctx_tok'); + const body = JSON.parse(mockFetch.mock.calls[0][1]!.body as string); + + expect(body.msg.to_user_id).toBe('user_1'); + expect(body.msg.context_token).toBe('ctx_tok'); + expect(body.msg.from_user_id).toBe(''); + expect(body.msg.item_list[0].text_item.text).toBe('hi'); + expect(body.msg.message_state).toBe(2); // FINISH + expect(body.msg.message_type).toBe(2); // BOT + }); + + it('should throw on API error', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ ret: -1, errmsg: 'send failed' })); + + await expect(client.sendMessage('u', 'hi', 'ctx')).rejects.toThrow('send failed'); + }); + }); + + // ---------- sendTyping ---------- + + describe('sendTyping', () => { + it('should not throw on success', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ ret: 0 })); + + await expect(client.sendTyping('user_1', 'ticket_1')).resolves.toBeUndefined(); + }); + + it('should not throw on network error (best-effort)', async () => { + mockFetch.mockRejectedValueOnce(new Error('network error')); + + await expect(client.sendTyping('user_1', 'ticket_1')).resolves.toBeUndefined(); + }); + + it('should send status=1 for start and status=2 for stop', async () => { + mockFetch.mockResolvedValue(jsonResponse({ ret: 0 })); + + await client.sendTyping('u', 'tk', true); + const startBody = JSON.parse(mockFetch.mock.calls[0][1]!.body as string); + expect(startBody.status).toBe(1); + + await client.sendTyping('u', 'tk', false); + const stopBody = JSON.parse(mockFetch.mock.calls[1][1]!.body as string); + expect(stopBody.status).toBe(2); + }); + }); + + // ---------- getConfig ---------- + + describe('getConfig', () => { + it('should return config with typing_ticket', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ ret: 0, typing_ticket: 'ticket_abc' })); + + const config = await client.getConfig('user_1', 'ctx_tok'); + expect(config.typing_ticket).toBe('ticket_abc'); + }); + + it('should throw on non-zero ret', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ ret: -14, errmsg: 'expired' })); + + await expect(client.getConfig('u', 'c')).rejects.toThrow('expired'); + }); + }); +}); + +// ---- QR code helpers ---- + +describe('fetchQrCode', () => { + beforeEach(() => mockFetch.mockReset()); + + it('should return qr code data on success', async () => { + const payload = { qrcode: 'qr_123', qrcode_img_content: 'base64...' }; + mockFetch.mockResolvedValueOnce(jsonResponse(payload)); + + const result = await fetchQrCode(); + expect(result).toEqual(payload); + expect(mockFetch).toHaveBeenCalledWith( + `${DEFAULT_BASE_URL}/ilink/bot/get_bot_qrcode?bot_type=3`, + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('should throw on HTTP error', async () => { + mockFetch.mockResolvedValueOnce(new Response('Not Found', { status: 404 })); + + await expect(fetchQrCode()).rejects.toThrow('iLink get_bot_qrcode failed'); + }); + + it('should strip trailing slashes from custom base URL', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ qrcode: 'x', qrcode_img_content: 'y' })); + + await fetchQrCode('https://custom.example.com//'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://custom.example.com/ilink/bot/get_bot_qrcode?bot_type=3', + expect.anything(), + ); + }); +}); + +describe('pollQrStatus', () => { + beforeEach(() => mockFetch.mockReset()); + + it('should return status on success', async () => { + const payload = { status: 'wait' as const }; + mockFetch.mockResolvedValueOnce(jsonResponse(payload)); + + const result = await pollQrStatus('qr_123'); + expect(result.status).toBe('wait'); + }); + + it('should encode qrcode in URL', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ status: 'scaned' })); + + await pollQrStatus('qr=special&chars'); + const url = mockFetch.mock.calls[0][0] as string; + expect(url).toContain(encodeURIComponent('qr=special&chars')); + }); + + it('should throw on HTTP error', async () => { + mockFetch.mockResolvedValueOnce(new Response('error', { status: 500 })); + + await expect(pollQrStatus('qr')).rejects.toThrow('iLink get_qrcode_status failed'); + }); +}); diff --git a/packages/chat-adapter-wechat/src/api.ts b/packages/chat-adapter-wechat/src/api.ts new file mode 100644 index 0000000000..3d70b31913 --- /dev/null +++ b/packages/chat-adapter-wechat/src/api.ts @@ -0,0 +1,272 @@ +import type { + BaseInfo, + MessageItem, + WechatGetConfigResponse, + WechatGetUpdatesResponse, + WechatSendMessageResponse, +} from './types'; +import { MessageItemType, MessageState, MessageType, WECHAT_RET_CODES } from './types'; + +export const DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com'; + +/** Strip trailing slashes without regex (avoids ReDoS on untrusted input). */ +function stripTrailingSlashes(url: string): string { + let end = url.length; + while (end > 0 && url[end - 1] === '/') end--; + return url.slice(0, end); +} + +const CHANNEL_VERSION = '1.0.0'; +const MAX_TEXT_LENGTH = 2000; +const POLL_TIMEOUT_MS = 40_000; +const DEFAULT_TIMEOUT_MS = 15_000; + +const BASE_INFO: BaseInfo = { channel_version: CHANNEL_VERSION }; + +/** + * Generate a random X-WECHAT-UIN header value as required by the iLink API. + */ +function randomUin(): string { + const uint32 = Math.floor(Math.random() * 0xffff_ffff); + return btoa(String(uint32)); +} + +function buildHeaders(botToken: string): Record { + return { + 'Authorization': `Bearer ${botToken}`, + 'AuthorizationType': 'ilink_bot_token', + 'Content-Type': 'application/json', + 'X-WECHAT-UIN': randomUin(), + }; +} + +/** + * Parse JSON response. Throws if HTTP error or ret is non-zero. + * Matches reference: only throws when ret IS a number AND not 0. + */ +async function parseResponse(response: Response, label: string): Promise { + const text = await response.text(); + const payload = text ? (JSON.parse(text) as T) : ({} as T); + + if (!response.ok) { + const msg = + (payload as { errmsg?: string } | null)?.errmsg ?? + `${label} failed with HTTP ${response.status}`; + throw new Error(msg); + } + + const ret = (payload as { ret?: number } | null)?.ret; + if (typeof ret === 'number' && ret !== WECHAT_RET_CODES.OK) { + const body = payload as { errcode?: number; errmsg?: string; ret: number }; + throw Object.assign(new Error(body.errmsg ?? `${label} failed with ret=${ret}`), { + code: body.errcode ?? ret, + }); + } + + return payload; +} + +/** + * Build a combined AbortSignal from an optional external signal and a timeout. + */ +function combinedSignal(signal?: AbortSignal, timeoutMs: number = POLL_TIMEOUT_MS): AbortSignal { + const timeoutSignal = AbortSignal.timeout(timeoutMs); + return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; +} + +export class WechatApiClient { + private readonly botToken: string; + private readonly baseUrl: string; + botId: string; + + constructor(botToken: string, botId?: string, baseUrl?: string) { + this.botToken = botToken; + this.botId = botId || ''; + this.baseUrl = stripTrailingSlashes(baseUrl || DEFAULT_BASE_URL); + } + + /** + * Long-poll for new messages via iLink Bot API. + * Server holds connection for ~35 seconds. + */ + async getUpdates(cursor?: string, signal?: AbortSignal): Promise { + const body = { + base_info: BASE_INFO, + get_updates_buf: cursor || '', + }; + + const response = await fetch(`${this.baseUrl}/ilink/bot/getupdates`, { + body: JSON.stringify(body), + headers: buildHeaders(this.botToken), + method: 'POST', + signal: combinedSignal(signal, POLL_TIMEOUT_MS), + }); + + return parseResponse(response, 'getupdates'); + } + + /** + * Send a text message via iLink Bot API. + * Reference: from_user_id is empty string, client_id is random UUID. + */ + async sendMessage( + toUserId: string, + text: string, + contextToken: string, + ): Promise { + const chunks = chunkText(text, MAX_TEXT_LENGTH); + let lastResponse: WechatSendMessageResponse = { ret: 0 }; + + for (const chunk of chunks) { + const item: MessageItem = { + text_item: { text: chunk }, + type: MessageItemType.TEXT, + }; + + const body = { + base_info: BASE_INFO, + msg: { + client_id: crypto.randomUUID(), + context_token: contextToken, + from_user_id: '', + item_list: [item], + message_state: MessageState.FINISH, + message_type: MessageType.BOT, + to_user_id: toUserId, + }, + }; + + const response = await fetch(`${this.baseUrl}/ilink/bot/sendmessage`, { + body: JSON.stringify(body), + headers: buildHeaders(this.botToken), + method: 'POST', + signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS), + }); + + lastResponse = await parseResponse(response, 'sendmessage'); + } + + return lastResponse; + } + + /** + * Send typing indicator via iLink Bot API. + */ + async sendTyping(toUserId: string, typingTicket: string, start = true): Promise { + await fetch(`${this.baseUrl}/ilink/bot/sendtyping`, { + body: JSON.stringify({ + base_info: BASE_INFO, + ilink_user_id: toUserId, + status: start ? 1 : 2, + typing_ticket: typingTicket, + }), + headers: buildHeaders(this.botToken), + method: 'POST', + signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS), + }).catch(() => { + // Typing is best-effort + }); + } + + /** + * Convenience: getConfig + sendTyping in one call. Best-effort, never throws. + */ + async startTyping(toUserId: string, contextToken: string): Promise { + try { + const config = await this.getConfig(toUserId, contextToken); + if (config.typing_ticket) { + await this.sendTyping(toUserId, config.typing_ticket); + } + } catch { + // typing is best-effort + } + } + + /** + * Get bot configuration (including typing_ticket). + * Requires userId and contextToken per reference implementation. + */ + async getConfig(userId: string, contextToken: string): Promise { + const response = await fetch(`${this.baseUrl}/ilink/bot/getconfig`, { + body: JSON.stringify({ + base_info: BASE_INFO, + context_token: contextToken, + ilink_user_id: userId, + }), + headers: buildHeaders(this.botToken), + method: 'POST', + signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS), + }); + + return parseResponse(response, 'getconfig'); + } +} + +// ============================================================================ +// QR Code Authentication (unauthenticated endpoints) +// ============================================================================ + +export interface QrCodeResponse { + qrcode: string; + qrcode_img_content: string; +} + +export interface QrStatusResponse { + baseurl?: string; + bot_token?: string; + ilink_bot_id?: string; + ilink_user_id?: string; + status: 'wait' | 'scaned' | 'confirmed' | 'expired'; +} + +/** + * Request a new QR code for bot login. + */ +export async function fetchQrCode(baseUrl: string = DEFAULT_BASE_URL): Promise { + const url = `${stripTrailingSlashes(baseUrl)}/ilink/bot/get_bot_qrcode?bot_type=3`; + const response = await fetch(url, { method: 'GET' }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`iLink get_bot_qrcode failed: ${response.status} ${text}`); + } + + return response.json() as Promise; +} + +/** + * Poll the QR code scan status. + */ +export async function pollQrStatus( + qrcode: string, + baseUrl: string = DEFAULT_BASE_URL, +): Promise { + const url = `${stripTrailingSlashes(baseUrl)}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`; + const response = await fetch(url, { + headers: { 'iLink-App-ClientVersion': '1' }, + method: 'GET', + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`iLink get_qrcode_status failed: ${response.status} ${text}`); + } + + return response.json() as Promise; +} + +// ============================================================================ +// Utilities +// ============================================================================ + +function chunkText(text: string, limit: number): string[] { + if (text.length <= limit) return [text]; + + const chunks: string[] = []; + let remaining = text; + while (remaining.length > 0) { + chunks.push(remaining.slice(0, limit)); + remaining = remaining.slice(limit); + } + return chunks; +} diff --git a/packages/chat-adapter-wechat/src/format-converter.test.ts b/packages/chat-adapter-wechat/src/format-converter.test.ts new file mode 100644 index 0000000000..f1a16e4b3a --- /dev/null +++ b/packages/chat-adapter-wechat/src/format-converter.test.ts @@ -0,0 +1,46 @@ +import { parseMarkdown } from 'chat'; +import { describe, expect, it } from 'vitest'; + +import { WechatFormatConverter } from './format-converter'; + +describe('WechatFormatConverter', () => { + const converter = new WechatFormatConverter(); + + describe('toAst', () => { + it('should convert plain text to AST', () => { + const ast = converter.toAst('hello world'); + expect(ast.type).toBe('root'); + expect(ast.children.length).toBeGreaterThan(0); + }); + + it('should trim whitespace before parsing', () => { + const ast = converter.toAst(' hello '); + const text = converter.fromAst(ast); + expect(text.trim()).toBe('hello'); + }); + }); + + describe('fromAst', () => { + it('should convert AST back to text', () => { + const ast = parseMarkdown('hello world'); + const text = converter.fromAst(ast); + expect(text.trim()).toBe('hello world'); + }); + + it('should handle markdown formatting', () => { + const ast = parseMarkdown('**bold** and *italic*'); + const text = converter.fromAst(ast); + expect(text).toContain('bold'); + expect(text).toContain('italic'); + }); + }); + + describe('round-trip', () => { + it('should preserve plain text through round-trip', () => { + const original = 'simple text message'; + const ast = converter.toAst(original); + const result = converter.fromAst(ast); + expect(result.trim()).toBe(original); + }); + }); +}); diff --git a/packages/chat-adapter-wechat/src/format-converter.ts b/packages/chat-adapter-wechat/src/format-converter.ts new file mode 100644 index 0000000000..6502c5ad92 --- /dev/null +++ b/packages/chat-adapter-wechat/src/format-converter.ts @@ -0,0 +1,19 @@ +import type { Root } from 'chat'; +import { BaseFormatConverter, parseMarkdown, stringifyMarkdown } from 'chat'; + +export class WechatFormatConverter extends BaseFormatConverter { + /** + * Convert mdast AST to WeChat-compatible text. + * WeChat does not support Markdown; convert to plain text. + */ + fromAst(ast: Root): string { + return stringifyMarkdown(ast); + } + + /** + * Convert WeChat message text to mdast AST. + */ + toAst(text: string): Root { + return parseMarkdown(text.trim()); + } +} diff --git a/packages/chat-adapter-wechat/src/index.ts b/packages/chat-adapter-wechat/src/index.ts new file mode 100644 index 0000000000..502e14d60c --- /dev/null +++ b/packages/chat-adapter-wechat/src/index.ts @@ -0,0 +1,13 @@ +export { createWechatAdapter, WechatAdapter } from './adapter'; +export type { QrCodeResponse, QrStatusResponse } from './api'; +export { DEFAULT_BASE_URL, fetchQrCode, pollQrStatus, WechatApiClient } from './api'; +export { WechatFormatConverter } from './format-converter'; +export type { + WechatAdapterConfig, + WechatGetConfigResponse, + WechatGetUpdatesResponse, + WechatRawMessage, + WechatSendMessageResponse, + WechatThreadId, +} from './types'; +export { MessageItemType, MessageState, MessageType, WECHAT_RET_CODES } from './types'; diff --git a/packages/chat-adapter-wechat/src/types.ts b/packages/chat-adapter-wechat/src/types.ts new file mode 100644 index 0000000000..ee0c93ac63 --- /dev/null +++ b/packages/chat-adapter-wechat/src/types.ts @@ -0,0 +1,154 @@ +export interface WechatAdapterConfig { + /** Bot's iLink user ID (from QR login) */ + botId?: string; + /** Bot token obtained from iLink QR code authentication */ + botToken: string; +} + +export interface WechatThreadId { + /** The WeChat user ID (xxx@im.wechat format) */ + id: string; + /** Chat type */ + type: 'single' | 'group'; +} + +// ---------- iLink protocol enums ---------- + +export enum MessageType { + USER = 1, + BOT = 2, +} + +export enum MessageState { + NEW = 0, + GENERATING = 1, + FINISH = 2, +} + +export enum MessageItemType { + TEXT = 1, + IMAGE = 2, + VOICE = 3, + FILE = 4, + VIDEO = 5, +} + +// ---------- iLink API raw types ---------- + +export interface BaseInfo { + channel_version: string; +} + +export interface CDNMedia { + aes_key: string; + encrypt_query_param: string; + encrypt_type?: 0 | 1; +} + +export interface TextItem { + text: string; +} + +export interface ImageItem { + aeskey?: string; + media: CDNMedia; + url?: string; +} + +export interface VoiceItem { + encode_type?: number; + media: CDNMedia; + playtime?: number; + text?: string; +} + +export interface FileItem { + file_name?: string; + len?: string; + md5?: string; + media: CDNMedia; +} + +export interface VideoItem { + media: CDNMedia; + play_length?: number; + thumb_media?: CDNMedia; + video_size?: string | number; +} + +export interface MessageItem { + file_item?: FileItem; + image_item?: ImageItem; + text_item?: TextItem; + type: MessageItemType; + video_item?: VideoItem; + voice_item?: VoiceItem; +} + +/** Raw message from getupdates */ +export interface WechatRawMessage { + client_id: string; + context_token: string; + create_time_ms: number; + from_user_id: string; + item_list: MessageItem[]; + message_id: number; + message_state: MessageState; + message_type: MessageType; + to_user_id: string; +} + +/** getupdates response */ +export interface WechatGetUpdatesResponse { + errcode?: number; + errmsg?: string; + get_updates_buf: string; + longpolling_timeout_ms?: number; + msgs: WechatRawMessage[]; + ret: number; +} + +/** sendmessage request body */ +export interface WechatSendMessageReq { + base_info: BaseInfo; + msg: { + client_id: string; + context_token: string; + from_user_id: string; + item_list: MessageItem[]; + message_state: MessageState; + message_type: MessageType; + to_user_id: string; + }; +} + +/** sendmessage response */ +export interface WechatSendMessageResponse { + errmsg?: string; + ret: number; +} + +/** getconfig response */ +export interface WechatGetConfigResponse { + errcode?: number; + errmsg?: string; + ret?: number; + typing_ticket?: string; +} + +/** sendtyping request body */ +export interface WechatSendTypingReq { + base_info: BaseInfo; + ilink_user_id: string; + /** 1 = start, 2 = stop */ + status: 1 | 2; + typing_ticket: string; +} + +/** iLink API return codes */ +export const WECHAT_RET_CODES = { + /** Success */ + OK: 0, + /** Session expired — requires re-authentication via QR code */ + SESSION_EXPIRED: -14, +} as const; diff --git a/packages/chat-adapter-wechat/tsconfig.json b/packages/chat-adapter-wechat/tsconfig.json new file mode 100644 index 0000000000..5c90650b8a --- /dev/null +++ b/packages/chat-adapter-wechat/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "lib": ["ES2022"] + }, + "exclude": ["node_modules", "dist"], + "include": ["src/**/*"] +} diff --git a/packages/chat-adapter-wechat/tsup.config.ts b/packages/chat-adapter-wechat/tsup.config.ts new file mode 100644 index 0000000000..d4c69d1df6 --- /dev/null +++ b/packages/chat-adapter-wechat/tsup.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + dts: true, + entry: ['src/index.ts'], + format: ['esm'], + sourcemap: true, +}); diff --git a/packages/chat-adapter-wechat/vitest.config.mts b/packages/chat-adapter-wechat/vitest.config.mts new file mode 100644 index 0000000000..5b40bc079f --- /dev/null +++ b/packages/chat-adapter-wechat/vitest.config.mts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + coverage: { + all: false, + }, + environment: 'node', + }, +}); diff --git a/src/app/(backend)/api/agent/gateway/wechat/route.ts b/src/app/(backend)/api/agent/gateway/wechat/route.ts new file mode 100644 index 0000000000..cbd8fad4cf --- /dev/null +++ b/src/app/(backend)/api/agent/gateway/wechat/route.ts @@ -0,0 +1,128 @@ +import debug from 'debug'; +import type { NextRequest } from 'next/server'; +import { after } from 'next/server'; + +import { getServerDB } from '@/database/core/db-adaptor'; +import { AgentBotProviderModel } from '@/database/models/agentBotProvider'; +import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt'; +import { type BotProviderConfig, wechat } from '@/server/services/bot/platforms'; +import { BotConnectQueue } from '@/server/services/gateway/botConnectQueue'; + +const log = debug('lobe-server:bot:gateway:cron:wechat'); + +const GATEWAY_DURATION_MS = 600_000; // 10 minutes +const POLL_INTERVAL_MS = 30_000; // 30 seconds + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function createWechatBot(applicationId: string, credentials: Record) { + const config: BotProviderConfig = { + applicationId, + credentials, + platform: 'wechat', + settings: {}, + }; + return wechat.clientFactory.createClient(config, { appUrl: process.env.APP_URL }); +} + +async function processConnectQueue(remainingMs: number): Promise { + const queue = new BotConnectQueue(); + const items = await queue.popAll(); + const wechatItems = items.filter((item) => item.platform === 'wechat'); + + if (wechatItems.length === 0) return 0; + + log('Processing %d queued wechat connect requests', wechatItems.length); + + const serverDB = await getServerDB(); + const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey(); + let processed = 0; + + for (const item of wechatItems) { + try { + const model = new AgentBotProviderModel(serverDB, item.userId, gateKeeper); + const provider = await model.findEnabledByApplicationId('wechat', item.applicationId); + + if (!provider) { + log('No enabled provider found for queued appId=%s', item.applicationId); + await queue.remove('wechat', item.applicationId); + continue; + } + + const bot = createWechatBot(provider.applicationId, provider.credentials); + + await bot.start({ + durationMs: remainingMs, + waitUntil: (task: Promise) => { + after(() => task); + }, + }); + + processed++; + log('Started queued bot appId=%s', item.applicationId); + } catch (err) { + log('Failed to start queued bot appId=%s: %O', item.applicationId, err); + } + + await queue.remove('wechat', item.applicationId); + } + + return processed; +} + +export async function GET(request: NextRequest) { + const authHeader = request.headers.get('authorization'); + if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { + return new Response('Unauthorized', { status: 401 }); + } + + const serverDB = await getServerDB(); + const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey(); + const providers = await AgentBotProviderModel.findEnabledByPlatform( + serverDB, + 'wechat', + gateKeeper, + ); + + log('Found %d enabled WeChat providers', providers.length); + + let started = 0; + + for (const provider of providers) { + const { applicationId, credentials } = provider; + + try { + const bot = createWechatBot(applicationId, credentials); + + await bot.start({ + durationMs: GATEWAY_DURATION_MS, + waitUntil: (task: Promise) => { + after(() => task); + }, + }); + + started++; + log('Started gateway listener for appId=%s', applicationId); + } catch (err) { + log('Failed to start gateway listener for appId=%s: %O', applicationId, err); + } + } + + // Process any queued connect requests immediately + const queued = await processConnectQueue(GATEWAY_DURATION_MS); + + // Poll for new connect requests in background + after(async () => { + const pollEnd = Date.now() + GATEWAY_DURATION_MS; + + while (Date.now() < pollEnd) { + await sleep(POLL_INTERVAL_MS); + if (Date.now() >= pollEnd) break; + + const remaining = pollEnd - Date.now(); + await processConnectQueue(remaining); + } + }); + + return Response.json({ queued, started, total: providers.length }); +} diff --git a/src/app/(backend)/api/agent/webhooks/bot-callback/route.ts b/src/app/(backend)/api/agent/webhooks/bot-callback/route.ts index 2ceb946694..0cca7b4b55 100644 --- a/src/app/(backend)/api/agent/webhooks/bot-callback/route.ts +++ b/src/app/(backend)/api/agent/webhooks/bot-callback/route.ts @@ -35,10 +35,10 @@ export async function POST(request: Request): Promise { progressMessageId, ); - if (!type || !applicationId || !platformThreadId || !progressMessageId) { + if (!type || !applicationId || !platformThreadId) { return NextResponse.json( { - error: 'Missing required fields: type, applicationId, platformThreadId, progressMessageId', + error: 'Missing required fields: type, applicationId, platformThreadId', }, { status: 400 }, ); diff --git a/src/locales/default/agent.ts b/src/locales/default/agent.ts index ce575b79cd..1a12aba524 100644 --- a/src/locales/default/agent.ts +++ b/src/locales/default/agent.ts @@ -41,6 +41,14 @@ export default { 'channel.publicKeyPlaceholder': 'Required for interaction verification', 'channel.qq.appIdHint': 'Your QQ Bot App ID from QQ Open Platform', 'channel.qq.description': 'Connect this assistant to QQ for group chats and direct messages.', + 'channel.wechat.description': + 'Connect this assistant to WeChat via iLink Bot for private and group chats.', + 'channel.wechatQrExpired': 'QR code expired. Please refresh to get a new one.', + 'channel.wechatQrRefresh': 'Refresh QR Code', + 'channel.wechatQrScaned': 'QR code scanned. Please confirm the login in WeChat.', + 'channel.wechatQrWait': 'Open WeChat and scan the QR code to connect.', + 'channel.wechatScanTitle': 'Connect WeChat Bot', + 'channel.wechatScanToConnect': 'Scan QR Code to Connect', 'channel.removeChannel': 'Remove Channel', 'channel.removed': 'Channel removed', 'channel.removeFailed': 'Failed to remove channel', diff --git a/src/routes/(main)/agent/_layout/Sidebar/Header/Nav.tsx b/src/routes/(main)/agent/_layout/Sidebar/Header/Nav.tsx index 364fc7566b..a989340b23 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Header/Nav.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Header/Nav.tsx @@ -15,8 +15,6 @@ import { useActionSWR } from '@/libs/swr'; import { useChatStore } from '@/store/chat'; import { useGlobalStore } from '@/store/global'; import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig'; -import { useUserStore } from '@/store/user'; -import { userGeneralSettingsSelectors } from '@/store/user/selectors'; const Nav = memo(() => { const { t } = useTranslation('chat'); @@ -29,7 +27,6 @@ const Nav = memo(() => { const router = useQueryRoute(); const { isAgentEditable } = useServerConfigStore(featureFlagsSelectors); const toggleCommandMenu = useGlobalStore((s) => s.toggleCommandMenu); - const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode); const hideProfile = !isAgentEditable; const switchTopic = useChatStore((s) => s.switchTopic); const [openNewTopicOrSaveTopic] = useChatStore((s) => [s.openNewTopicOrSaveTopic]); @@ -61,7 +58,7 @@ const Nav = memo(() => { }} /> )} - {!hideProfile && isDevMode && ( + {!hideProfile && ( (({ schema }) => { interface BodyProps { form: FormInstance; + onQrAuthenticated?: (credentials: { botId: string; botToken: string; userId: string }) => void; platformDef: SerializedPlatformDefinition; } -const Body = memo(({ platformDef, form }) => { +const Body = memo(({ platformDef, form, onQrAuthenticated }) => { const { t: _t } = useTranslation('agent'); const t = _t as (key: string) => string; @@ -233,15 +235,21 @@ const Body = memo(({ platformDef, form }) => { style={{ maxWidth: 1024, padding: '16px 0', width: '100%' }} variant={'borderless'} > + {platformDef.authFlow === 'qrcode' && onQrAuthenticated && ( +
+ +
+ )} {applicationIdField && } - {credentialFields.map((field, i) => ( - - ))} + {!platformDef.authFlow && + credentialFields.map((field, i) => ( + + ))} {settingsFields.length > 0 && ( void; +} + +const QrCodeAuth = memo(({ onAuthenticated }) => { + const { t } = useTranslation('agent'); + const [open, setOpen] = useState(false); + const [qrImgUrl, setQrImgUrl] = useState(); + const [status, setStatus] = useState(''); + const [error, setError] = useState(); + const [loading, setLoading] = useState(false); + const pollingRef = useRef(false); + const timerRef = useRef | null>(null); + + const stopPolling = useCallback(() => { + pollingRef.current = false; + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + const startQrFlow = useCallback(async () => { + setLoading(true); + setError(undefined); + setStatus(''); + setQrImgUrl(undefined); + stopPolling(); + + try { + const qr = await agentBotProviderService.wechatGetQrCode(); + setQrImgUrl(qr.qrcode_img_content); + setStatus('wait'); + setLoading(false); + + // Start polling + pollingRef.current = true; + const poll = async () => { + if (!pollingRef.current) return; + + try { + const res = await agentBotProviderService.wechatPollQrStatus(qr.qrcode); + if (!pollingRef.current) return; + + setStatus(res.status); + + if (res.status === 'confirmed' && res.bot_token) { + stopPolling(); + onAuthenticated({ + botId: res.ilink_bot_id || '', + botToken: res.bot_token, + userId: res.ilink_user_id || '', + }); + setOpen(false); + return; + } + + if (res.status === 'expired') { + stopPolling(); + setError(t('channel.wechatQrExpired')); + return; + } + + timerRef.current = setTimeout(poll, QR_POLL_INTERVAL_MS); + } catch { + if (pollingRef.current) { + timerRef.current = setTimeout(poll, QR_POLL_INTERVAL_MS); + } + } + }; + + timerRef.current = setTimeout(poll, QR_POLL_INTERVAL_MS); + } catch (err: any) { + setError(err?.message || 'Failed to get QR code'); + setLoading(false); + } + }, [onAuthenticated, stopPolling, t]); + + const handleOpen = useCallback(() => { + setOpen(true); + startQrFlow(); + }, [startQrFlow]); + + const handleClose = useCallback(() => { + stopPolling(); + setOpen(false); + }, [stopPolling]); + + const statusText = + status === 'wait' + ? t('channel.wechatQrWait') + : status === 'scaned' + ? t('channel.wechatQrScaned') + : ''; + + return ( + <> + + + +
+ {loading && } + + {qrImgUrl && !error && } + + {statusText && !error && {statusText}} + + {error && ( + <> + + + + )} +
+
+ + ); +}); + +export default QrCodeAuth; diff --git a/src/routes/(main)/agent/channel/detail/index.tsx b/src/routes/(main)/agent/channel/detail/index.tsx index f569fb5ed8..c1e19e39d3 100644 --- a/src/routes/(main)/agent/channel/detail/index.tsx +++ b/src/routes/(main)/agent/channel/detail/index.tsx @@ -156,6 +156,69 @@ const PlatformDetail = memo(({ platformDef, agentId, curren } }, [agentId, platformDef, form, currentConfig, createBotProvider, updateBotProvider, connectBot]); + const handleQrAuthenticated = useCallback( + async (creds: { botId: string; botToken: string; userId: string }) => { + setSaving(true); + setSaveResult(undefined); + setConnectResult(undefined); + + try { + const credentials = { + botId: creds.botId, + botToken: creds.botToken, + userId: creds.userId, + }; + const applicationId = creds.botId || creds.botToken.slice(0, 16); + const settings = form.getFieldValue('settings') || {}; + + if (currentConfig) { + await updateBotProvider(currentConfig.id, agentId, { + applicationId, + credentials, + settings, + }); + } else { + await createBotProvider({ + agentId, + applicationId, + credentials, + platform: platformDef.id, + settings, + }); + } + + setSaveResult({ type: 'success' }); + msg.success(t('channel.saved')); + + // Auto-connect + setConnecting(true); + try { + await connectBot({ applicationId, platform: platformDef.id }); + setConnectResult({ type: 'success' }); + } catch (e: any) { + setConnectResult({ errorDetail: e?.message || String(e), type: 'error' }); + } finally { + setConnecting(false); + } + } catch (e: any) { + setSaveResult({ errorDetail: e?.message || String(e), type: 'error' }); + } finally { + setSaving(false); + } + }, + [ + agentId, + platformDef, + form, + currentConfig, + createBotProvider, + updateBotProvider, + connectBot, + msg, + t, + ], + ); + const handleDelete = useCallback(async () => { if (!currentConfig) return; @@ -224,7 +287,11 @@ const PlatformDetail = memo(({ platformDef, agentId, curren platformDef={platformDef} onToggleEnable={handleToggleEnable} /> - +