feat: support wechat bot (#13191)

* 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
This commit is contained in:
Rdmclin2 2026-03-23 12:52:11 +08:00 committed by GitHub
parent 1df02300bc
commit ecde45b4ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 2625 additions and 89 deletions

View file

@ -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<string, string[]> = {
discord: ['botToken', 'publicKey'],
@ -13,6 +13,7 @@ const PLATFORM_CREDENTIAL_FIELDS: Record<string, string[]> = {
lark: ['appSecret'],
slack: ['botToken', 'signingSecret'],
telegram: ['botToken'],
wechat: ['botToken', 'botId'],
};
function parseCredentials(
@ -22,6 +23,7 @@ function parseCredentials(
const creds: Record<string, string> = {};
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>', `Platform: ${SUPPORTED_PLATFORMS.join(', ')}`)
.requiredOption('--app-id <appId>', 'Application ID for webhook routing')
.option('--bot-token <token>', 'Bot token')
.option('--bot-id <id>', 'Bot ID (WeChat)')
.option('--public-key <key>', 'Public key (Discord)')
.option('--signing-secret <secret>', 'Signing secret (Slack)')
.option('--app-secret <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 <botId>')
.description('Update a bot integration')
.option('--bot-token <token>', 'New bot token')
.option('--bot-id <id>', 'New bot ID (WeChat)')
.option('--public-key <key>', 'New public key')
.option('--signing-secret <secret>', 'New signing secret')
.option('--app-secret <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<string, string> = {};
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;

View file

@ -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 |

View file

@ -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 |
| --------- | ------- | ----- | -------- | -- | -- | ---- | ---- |
| 文本消息 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
| 私人消息 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
| 群组聊天 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
| 表情反应 | 是 | 是 | 是 | 否 | 否 | 部分支持 | 部分支持 |
| 图片 / 文件附件 | 是 | 是 | 是 | 是 | 否 | 是 | 是 |

View file

@ -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
<Callout type={'info'}>
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**.
</Callout>
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
<Steps>
### 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.
</Steps>
## 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: 1002000) |
| **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.

View file

@ -0,0 +1,93 @@
---
title: 将 LobeHub 连接到微信
description: 了解如何通过 iLink Bot API 将微信机器人连接到您的 LobeHub 代理,使您的 AI 助手能够在微信私聊和群聊中与用户互动。
tags:
- 微信
- 消息渠道
- 机器人设置
- 集成
---
# 将 LobeHub 连接到微信
<Callout type={'info'}>
此功能目前正在开发中,可能尚未完全稳定。您可以通过在 **设置** → **高级设置** → **开发者模式**
中启用 **开发者模式** 来使用此功能。
</Callout>
通过将微信渠道连接到您的 LobeHub 代理,用户可以通过微信私聊和群聊与 AI 助手互动。
## 前置条件
- 一个拥有有效订阅的 LobeHub 账户
- 一个微信账户
## 第一步:打开渠道设置
在 LobeHub 中,导航到您的代理设置,然后选择 **渠道** 标签页。从平台列表中点击 **微信**。
## 第二步:扫码连接
<Steps>
### 点击 "扫码连接"
在微信渠道页面中,点击 **扫码连接** 按钮。将弹出一个显示二维码的对话框。
### 使用微信扫码
打开手机微信,点击右上角的 **+** 按钮,选择 **扫一扫**,扫描 LobeHub 中显示的二维码。
### 确认登录
扫码后,微信中会出现确认提示。点击 **确认** 授权连接。
### 连接完成
确认后LobeHub 将自动保存凭证并连接机器人。您应该会在渠道设置中看到成功消息。
</Steps>
## 第三步:测试机器人
打开微信,找到您的机器人联系人,发送一条消息。机器人应通过您的 LobeHub 代理进行响应。
## 将机器人添加到群聊
要在微信群聊中使用机器人:
1. 将机器人添加到微信群聊中
2. @提及机器人或在群中发送消息以触发响应
3. 机器人将在群聊中回复
## 高级设置
| 设置 | 默认值 | 描述 |
| ---------- | ------- | ----------------------- |
| **字符限制** | 2000 | 每条消息的最大字符数范围1002000 |
| **消息合并窗口** | 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 秒的自然延迟。这是预期行为。
- **一段时间后连接断开:** 微信会话会定期过期。再次点击 "扫码连接" 重新认证。

View file

@ -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"
}

View file

@ -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": "扫码连接"
}

View file

@ -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:*",

View file

@ -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"
}
}

View file

@ -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> = {}): 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');
});
});

View file

@ -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<WechatThreadId, WechatRawMessage> {
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<string, string>();
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<void> {
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<Response> {
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<RawMessage<WechatRawMessage>> {
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<RawMessage<WechatRawMessage>> {
// WeChat doesn't support editing — fall back to posting a new message
return this.postMessage(threadId, message);
}
async deleteMessage(_threadId: string, _messageId: string): Promise<void> {
this.logger.warn('Message deletion not supported for WeChat');
}
async fetchMessages(
_threadId: string,
_options?: FetchOptions,
): Promise<FetchResult<WechatRawMessage>> {
return { messages: [], nextCursor: undefined };
}
async fetchThread(threadId: string): Promise<ThreadInfo> {
const { type, id } = this.decodeThreadId(threadId);
return {
channelId: threadId,
id: threadId,
isDM: type === 'single',
metadata: { id, type },
};
}
// ------------------------------------------------------------------
// Message parsing
// ------------------------------------------------------------------
parseMessage(raw: WechatRawMessage): Message<WechatRawMessage> {
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<Message<WechatRawMessage>> {
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<void> {}
async removeReaction(
_threadId: string,
_messageId: string,
_emoji: EmojiValue | string,
): Promise<void> {}
async startTyping(threadId: string): Promise<void> {
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);
}

View file

@ -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<typeof fetch>();
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<string, string>;
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');
});
});

View file

@ -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<string, string> {
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<T>(response: Response, label: string): Promise<T> {
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<WechatGetUpdatesResponse> {
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<WechatGetUpdatesResponse>(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<WechatSendMessageResponse> {
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<WechatSendMessageResponse>(response, 'sendmessage');
}
return lastResponse;
}
/**
* Send typing indicator via iLink Bot API.
*/
async sendTyping(toUserId: string, typingTicket: string, start = true): Promise<void> {
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<void> {
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<WechatGetConfigResponse> {
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<WechatGetConfigResponse>(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<QrCodeResponse> {
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<QrCodeResponse>;
}
/**
* Poll the QR code scan status.
*/
export async function pollQrStatus(
qrcode: string,
baseUrl: string = DEFAULT_BASE_URL,
): Promise<QrStatusResponse> {
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<QrStatusResponse>;
}
// ============================================================================
// 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;
}

View file

@ -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);
});
});
});

View file

@ -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());
}
}

View file

@ -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';

View file

@ -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;

View file

@ -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/**/*"]
}

View file

@ -0,0 +1,8 @@
import { defineConfig } from 'tsup';
export default defineConfig({
dts: true,
entry: ['src/index.ts'],
format: ['esm'],
sourcemap: true,
});

View file

@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
all: false,
},
environment: 'node',
},
});

View file

@ -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<string, string>) {
const config: BotProviderConfig = {
applicationId,
credentials,
platform: 'wechat',
settings: {},
};
return wechat.clientFactory.createClient(config, { appUrl: process.env.APP_URL });
}
async function processConnectQueue(remainingMs: number): Promise<number> {
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<any>) => {
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<any>) => {
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 });
}

View file

@ -35,10 +35,10 @@ export async function POST(request: Request): Promise<Response> {
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 },
);

View file

@ -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',

View file

@ -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 && (
<NavItem
active={isIntegrationActive}
icon={RadioTowerIcon}

View file

@ -14,6 +14,7 @@ import type {
} from '@/server/services/bot/platforms/types';
import type { ChannelFormValues } from './index';
import QrCodeAuth from './QrCodeAuth';
const prefixCls = 'ant';
@ -189,10 +190,11 @@ const SettingsTitle = memo<{ schema: FieldSchema[] }>(({ schema }) => {
interface BodyProps {
form: FormInstance<ChannelFormValues>;
onQrAuthenticated?: (credentials: { botId: string; botToken: string; userId: string }) => void;
platformDef: SerializedPlatformDefinition;
}
const Body = memo<BodyProps>(({ platformDef, form }) => {
const Body = memo<BodyProps>(({ platformDef, form, onQrAuthenticated }) => {
const { t: _t } = useTranslation('agent');
const t = _t as (key: string) => string;
@ -233,15 +235,21 @@ const Body = memo<BodyProps>(({ platformDef, form }) => {
style={{ maxWidth: 1024, padding: '16px 0', width: '100%' }}
variant={'borderless'}
>
{platformDef.authFlow === 'qrcode' && onQrAuthenticated && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0' }}>
<QrCodeAuth onAuthenticated={onQrAuthenticated} />
</div>
)}
{applicationIdField && <ApplicationIdField field={applicationIdField} />}
{credentialFields.map((field, i) => (
<SchemaField
divider={applicationIdField ? true : i !== 0}
field={field}
key={field.key}
parentKey="credentials"
/>
))}
{!platformDef.authFlow &&
credentialFields.map((field, i) => (
<SchemaField
divider={applicationIdField ? true : i !== 0}
field={field}
key={field.key}
parentKey="credentials"
/>
))}
{settingsFields.length > 0 && (
<FormGroup
collapsible

View file

@ -0,0 +1,150 @@
'use client';
import { Alert, Button, Modal, QRCode, Spin, Typography } from 'antd';
import { QrCode, RefreshCw } from 'lucide-react';
import { memo, useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { agentBotProviderService } from '@/services/agentBotProvider';
const QR_POLL_INTERVAL_MS = 2000;
interface QrCodeAuthProps {
onAuthenticated: (credentials: { botId: string; botToken: string; userId: string }) => void;
}
const QrCodeAuth = memo<QrCodeAuthProps>(({ onAuthenticated }) => {
const { t } = useTranslation('agent');
const [open, setOpen] = useState(false);
const [qrImgUrl, setQrImgUrl] = useState<string>();
const [status, setStatus] = useState<string>('');
const [error, setError] = useState<string>();
const [loading, setLoading] = useState(false);
const pollingRef = useRef(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<>
<Button icon={<QrCode size={16} />} type="primary" onClick={handleOpen}>
{t('channel.wechatScanToConnect')}
</Button>
<Modal
centered
footer={null}
open={open}
title={t('channel.wechatScanTitle')}
width={400}
onCancel={handleClose}
>
<div
style={{
alignItems: 'center',
display: 'flex',
flexDirection: 'column',
gap: 16,
padding: '16px 0',
}}
>
{loading && <Spin size="large" />}
{qrImgUrl && !error && <QRCode size={240} value={qrImgUrl} />}
{statusText && !error && <Typography.Text type="secondary">{statusText}</Typography.Text>}
{error && (
<>
<Alert showIcon message={error} type="warning" />
<Button icon={<RefreshCw size={14} />} onClick={startQrFlow}>
{t('channel.wechatQrRefresh')}
</Button>
</>
)}
</div>
</Modal>
</>
);
});
export default QrCodeAuth;

View file

@ -156,6 +156,69 @@ const PlatformDetail = memo<PlatformDetailProps>(({ 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<PlatformDetailProps>(({ platformDef, agentId, curren
platformDef={platformDef}
onToggleEnable={handleToggleEnable}
/>
<Body form={form} platformDef={platformDef} />
<Body
form={form}
platformDef={platformDef}
onQrAuthenticated={platformDef.authFlow === 'qrcode' ? handleQrAuthenticated : undefined}
/>
<Footer
connectResult={connectResult}
connecting={connecting}

View file

@ -1,3 +1,4 @@
import { fetchQrCode, pollQrStatus } from '@lobechat/chat-adapter-wechat';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
@ -136,6 +137,16 @@ export const agentBotProviderRouter = router({
return { valid: true };
}),
wechatGetQrCode: authedProcedure.mutation(async () => {
return fetchQrCode();
}),
wechatPollQrStatus: authedProcedure
.input(z.object({ qrcode: z.string() }))
.query(async ({ input }) => {
return pollQrStatus(input.qrcode);
}),
update: agentBotProviderProcedure
.input(
z.object({

View file

@ -15,6 +15,7 @@ import { SystemAgentService } from '@/server/services/systemAgent';
import { formatPrompt as formatPromptUtil } from './formatPrompt';
import type { PlatformClient } from './platforms';
import { platformRegistry } from './platforms';
import { DEFAULT_DEBOUNCE_MS } from './platforms/const';
import {
renderError,
@ -247,8 +248,9 @@ export class AgentBridgeService {
// Immediate feedback: mark as received + show typing
const { client } = opts;
const reactionThreadId = client?.resolveReactionThreadId?.(thread.id, message.id) ?? thread.id;
await safeReaction(
() => thread.adapter.addReaction(thread.id, message.id, RECEIVED_EMOJI),
() => thread.adapter.addReaction(reactionThreadId, message.id, RECEIVED_EMOJI),
'add eyes',
);
@ -297,7 +299,7 @@ export class AgentBridgeService {
clearInterval(typingInterval);
// In queue mode, reaction is removed by the bot-callback webhook on completion
if (!queueMode) {
await this.removeReceivedReaction(thread, message);
await this.removeReceivedReaction(thread, message, client);
}
}
}
@ -356,9 +358,10 @@ export class AgentBridgeService {
const queueMode = isQueueAgentRuntimeEnabled();
// Immediate feedback: mark as received + show typing
// Subscribed messages are inside the thread, so pass thread.id directly
const reactionThreadId =
opts.client?.resolveReactionThreadId?.(thread.id, message.id) ?? thread.id;
await safeReaction(
() => thread.adapter.addReaction(thread.id, message.id, RECEIVED_EMOJI),
() => thread.adapter.addReaction(reactionThreadId, message.id, RECEIVED_EMOJI),
'add eyes',
);
await thread.startTyping();
@ -399,7 +402,7 @@ export class AgentBridgeService {
clearInterval(typingInterval);
// In queue mode, reaction is removed by the bot-callback webhook on completion
if (!queueMode) {
await this.removeReceivedReaction(thread, message);
await this.removeReceivedReaction(thread, message, opts.client);
}
}
}
@ -448,22 +451,32 @@ export class AgentBridgeService {
const aiAgentService = new AiAgentService(this.db, this.userId);
const timezone = await this.loadTimezone();
// Post initial progress message to get the message ID
let progressMessage: SentMessage | undefined;
try {
progressMessage = await thread.post(renderStart(userMessage.text, { timezone }));
} catch (error) {
log('executeWithWebhooks: failed to post progress message: %O', error);
}
// Skip initial ack message for platforms that don't support editing (e.g. WeChat, QQ).
// The ack can't be edited into the final reply, so it would stay as a stale extra message.
const canEdit = platformRegistry.getPlatform(client?.id ?? '')?.supportsMessageEdit !== false;
const progressMessageId = progressMessage?.id;
if (!progressMessageId) {
throw new Error('Failed to post initial progress message');
}
let progressMessageId: string | undefined;
if (canEdit) {
// Post initial progress message to get the message ID
let progressMessage: SentMessage | undefined;
try {
progressMessage = await thread.post(renderStart(userMessage.text, { timezone }));
} catch (error) {
log('executeWithWebhooks: failed to post progress message: %O', error);
}
// Refresh typing indicator after posting the ack message,
// so typing stays active until the first step webhook arrives
await thread.startTyping();
progressMessageId = progressMessage?.id;
if (!progressMessageId) {
throw new Error('Failed to post initial progress message');
}
// Refresh typing indicator after posting the ack message,
// so typing stays active until the first step webhook arrives
await thread.startTyping();
} else {
// No ack message — still refresh typing so the user knows the bot is working
await thread.startTyping();
}
// Build webhook URL for bot-callback endpoint
// Prefer INTERNAL_APP_URL for server-to-server calls (bypasses CDN/proxy)
@ -541,12 +554,20 @@ export class AgentBridgeService {
const aiAgentService = new AiAgentService(this.db, this.userId);
const timezone = await this.loadTimezone();
// Post initial progress message
// Skip initial ack message for platforms that don't support editing (e.g. WeChat, QQ).
const canEdit = platformRegistry.getPlatform(client?.id ?? '')?.supportsMessageEdit !== false;
let progressMessage: SentMessage | undefined;
try {
progressMessage = await thread.post(renderStart(userMessage.text, { timezone }));
} catch (error) {
log('executeWithInMemoryCallbacks: failed to post progress message: %O', error);
if (canEdit) {
// Post initial progress message
try {
progressMessage = await thread.post(renderStart(userMessage.text, { timezone }));
} catch (error) {
log('executeWithInMemoryCallbacks: failed to post progress message: %O', error);
}
} else {
// No ack message — still refresh typing so the user knows the bot is working
await thread.startTyping();
}
// Track the last LLM content and tool calls for showing during tool execution
@ -628,12 +649,15 @@ export class AgentBridgeService {
if (reason === 'error') {
const errorMsg = extractErrorMessage(finalState.error);
if (progressMessage) {
try {
await progressMessage.edit(renderError(errorMsg));
} catch {
// ignore edit failure
try {
const errorText = renderError(errorMsg);
if (progressMessage) {
await progressMessage.edit(errorText);
} else {
await thread.post(errorText);
}
} catch {
// ignore send failure
}
reject(new Error(errorMsg));
return;
@ -661,19 +685,21 @@ export class AgentBridgeService {
const chunks = splitMessage(finalText, charLimit);
if (progressMessage) {
try {
try {
if (progressMessage) {
await progressMessage.edit(chunks[0]);
// Post overflow chunks as follow-up messages
for (let i = 1; i < chunks.length; i++) {
await thread.post(chunks[i]);
}
} catch (error) {
log(
'executeWithInMemoryCallbacks: failed to edit final progress message: %O',
error,
);
} else {
// No progress message (non-editable platform) — post all chunks as new messages
for (const chunk of chunks) {
await thread.post(chunk);
}
}
} catch (error) {
log('executeWithInMemoryCallbacks: failed to send final message: %O', error);
}
log(
@ -876,9 +902,11 @@ export class AgentBridgeService {
private async removeReceivedReaction(
thread: Thread<ThreadState>,
message: Message,
client?: PlatformClient,
): Promise<void> {
const reactionThreadId = client?.resolveReactionThreadId?.(thread.id, message.id) ?? thread.id;
await safeReaction(
() => thread.adapter.removeReaction(thread.id, message.id, RECEIVED_EMOJI),
() => thread.adapter.removeReaction(reactionThreadId, message.id, RECEIVED_EMOJI),
'remove eyes',
);
}

View file

@ -3,6 +3,7 @@ import debug from 'debug';
import { AgentBotProviderModel } from '@/database/models/agentBotProvider';
import { TopicModel } from '@/database/models/topic';
import { type LobeChatDatabase } from '@/database/type';
import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { SystemAgentService } from '@/server/services/systemAgent';
@ -27,7 +28,7 @@ export interface BotCallbackBody {
lastToolsCalling?: any;
llmCalls?: number;
platformThreadId: string;
progressMessageId: string;
progressMessageId?: string;
reason?: string;
reasoning?: string;
shouldContinue?: boolean;
@ -72,13 +73,22 @@ export class BotCallbackService {
const canEdit = entry?.supportsMessageEdit !== false;
if (type === 'step') {
// Skip step progress updates for platforms that can't edit messages
if (canEdit) {
if (canEdit && progressMessageId) {
await this.handleStep(body, messenger, progressMessageId, client);
} else if (body.shouldContinue) {
// For platforms without progress messages (e.g. WeChat), still send typing indicator
await messenger.triggerTyping();
}
} else if (type === 'completion') {
await this.handleCompletion(body, messenger, progressMessageId, client, charLimit, canEdit);
await this.removeEyesReaction(body, messenger);
await this.handleCompletion(
body,
messenger,
progressMessageId ?? '',
client,
charLimit,
canEdit,
);
await this.removeEyesReaction(body, client, platformThreadId);
this.summarizeTopicTitle(body, messenger);
}
}
@ -121,7 +131,9 @@ export class BotCallbackService {
settings: settings || {},
};
const client = entry.clientFactory.createClient(config, {});
const client = entry.clientFactory.createClient(config, {
redisClient: getAgentRuntimeRedisClient() as any,
});
const messenger = client.getMessenger(platformThreadId);
return { charLimit, messenger, client };
@ -236,11 +248,18 @@ export class BotCallbackService {
private async removeEyesReaction(
body: BotCallbackBody,
messenger: PlatformMessenger,
client: PlatformClient,
platformThreadId: string,
): Promise<void> {
const { userMessageId } = body;
if (!userMessageId) return;
// Thread-starter messages may live in the parent channel (e.g. Discord),
// so resolve the correct thread ID before obtaining the messenger.
const reactionThreadId =
client.resolveReactionThreadId?.(platformThreadId, userMessageId) ?? platformThreadId;
const messenger = client.getMessenger(reactionThreadId);
try {
await messenger.removeReaction(userMessageId, '👀');
} catch (error) {

View file

@ -64,6 +64,10 @@ vi.mock('@/server/modules/KeyVaultsEncrypt', () => ({
},
}));
vi.mock('@/server/modules/AgentRuntime/redis', () => ({
getAgentRuntimeRedisClient: vi.fn().mockReturnValue(null),
}));
vi.mock('@/server/services/systemAgent', () => ({
SystemAgentService: vi.fn().mockImplementation(() => ({
generateTopicTitle: mockGenerateTopicTitle,

View file

@ -160,6 +160,20 @@ class DiscordGatewayClient implements PlatformClient {
return compositeId;
}
/**
* Discord thread-starter messages live in the parent channel, not the thread.
* When the message ID matches the Discord thread segment, route the reaction
* to the parent channel so the API call targets the correct channel.
*/
resolveReactionThreadId(threadId: string, messageId: string): string {
const parts = threadId.split(':');
// Format: discord:guildId:channelId:discordThreadId
if (parts.length === 4 && parts[3] === messageId) {
return parts.slice(0, 3).join(':');
}
return threadId;
}
sanitizeUserInput(text: string): string {
return text.replaceAll(new RegExp(`<@!?${this.applicationId}>\\s*`, 'g'), '').trim();
}

View file

@ -7,6 +7,7 @@ import { qq } from './qq/definition';
import { PlatformRegistry } from './registry';
import { slack } from './slack/definition';
import { telegram } from './telegram/definition';
import { wechat } from './wechat/definition';
export { PlatformRegistry } from './registry';
export type {
@ -39,6 +40,7 @@ export { lark } from './feishu/definitions/lark';
export { qq } from './qq/definition';
export { slack } from './slack/definition';
export { telegram } from './telegram/definition';
export { wechat } from './wechat/definition';
export const platformRegistry = new PlatformRegistry();
@ -48,3 +50,4 @@ platformRegistry.register(slack);
platformRegistry.register(feishu);
platformRegistry.register(lark);
platformRegistry.register(qq);
platformRegistry.register(wechat);

View file

@ -109,6 +109,17 @@ export interface PlatformClient {
/** Parse a composite message ID into the platform-native format. */
parseMessageId: (compositeId: string) => string | number;
/**
* Resolve the correct thread ID for reaction API calls.
*
* Some platforms (e.g. Discord) need to route reactions to a different channel
* than the thread itself for instance, a thread-starter message lives in
* the parent channel, not in the thread.
*
* When not implemented, `threadId` is used as-is.
*/
resolveReactionThreadId?: (threadId: string, messageId: string) => string;
/** Strip platform-specific bot mention artifacts from user input. */
sanitizeUserInput?: (text: string) => string;
@ -213,6 +224,13 @@ export abstract class ClientFactory {
* Contains metadata, factory, and validation. All runtime operations go through PlatformClient.
*/
export interface PlatformDefinition {
/**
* Authentication flow for obtaining credentials.
* - 'qrcode': QR code scan flow (e.g. WeChat iLink)
* When set, the frontend renders a QR code auth UI instead of manual credential inputs.
*/
authFlow?: 'qrcode';
/** Factory for creating PlatformClient instances and validating credentials/settings. */
clientFactory: ClientFactory;

View file

@ -0,0 +1,296 @@
import type { WechatRawMessage } from '@lobechat/chat-adapter-wechat';
import {
createWechatAdapter,
MessageState,
MessageType,
WechatApiClient,
} from '@lobechat/chat-adapter-wechat';
import debug from 'debug';
import {
type BotPlatformRuntimeContext,
type BotProviderConfig,
ClientFactory,
type PlatformClient,
type PlatformMessenger,
type UsageStats,
type ValidationResult,
} from '../types';
import { formatUsageStats } from '../utils';
const log = debug('bot-platform:wechat:bot');
const DEFAULT_DURATION_MS = 10 * 60 * 1000; // 10 minutes
const MAX_RETRY_DELAY_MS = 10_000; // 10 seconds cap (matches reference)
const SESSION_EXPIRED_BACKOFF_MS = 60 * 60 * 1000; // 60 minutes
export interface WechatGatewayOptions {
durationMs?: number;
waitUntil?: (task: Promise<any>) => void;
}
function extractChatId(platformThreadId: string): string {
// Thread ID format: wechat:type:userId (userId may contain colons)
const parts = platformThreadId.split(':');
return parts.slice(2).join(':');
}
class WechatGatewayClient implements PlatformClient {
readonly id = 'wechat';
readonly applicationId: string;
private abort = new AbortController();
private config: BotProviderConfig;
private context: BotPlatformRuntimeContext;
private api: WechatApiClient;
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
private stopped = false;
/** Cached context tokens per user ID for replies */
private contextTokens = new Map<string, string>();
constructor(config: BotProviderConfig, context: BotPlatformRuntimeContext) {
this.config = config;
this.context = context;
this.applicationId = config.applicationId || config.credentials.botToken.slice(0, 8);
this.api = new WechatApiClient(config.credentials.botToken, config.credentials.botId);
}
// --- Lifecycle ---
async start(options?: WechatGatewayOptions): Promise<void> {
log('Starting WechatBot appId=%s', this.applicationId);
this.stopped = false;
this.abort = new AbortController();
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
const waitUntil = options?.waitUntil ?? ((task: Promise<any>) => task.catch(() => {}));
const webhookUrl = `${(this.context.appUrl || '').trim()}/api/agent/webhooks/wechat/${this.applicationId}`;
// Start the long-polling loop in background
const pollTask = this.pollLoop(durationMs, webhookUrl);
waitUntil(pollTask);
// When called from GatewayManager (no explicit options), schedule auto-refresh
// so the poller restarts after the duration instead of going silent.
if (!options) {
this.refreshTimer = setTimeout(() => {
if (this.abort.signal.aborted || this.stopped) return;
log(
'WechatBot appId=%s duration elapsed (%dmin), refreshing...',
this.applicationId,
durationMs / 60_000,
);
this.abort.abort();
this.start().catch((err) => {
log('Failed to refresh WechatBot appId=%s: %O', this.applicationId, err);
});
}, durationMs);
}
log('WechatBot appId=%s started, webhookUrl=%s', this.applicationId, webhookUrl);
}
async stop(): Promise<void> {
log('Stopping WechatBot appId=%s', this.applicationId);
this.stopped = true;
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
this.abort.abort();
}
// --- Long-polling loop ---
private async pollLoop(durationMs: number, webhookUrl: string): Promise<void> {
const endTime = Date.now() + durationMs;
let cursor: string | undefined;
let retryDelay = 1000; // Start at 1s, exponential up to MAX_RETRY_DELAY_MS
while (!this.stopped && !this.abort.signal.aborted && Date.now() < endTime) {
try {
const response = await this.api.getUpdates(cursor, this.abort.signal);
// Reset retry delay on success
retryDelay = 1000;
// Update cursor
if (response.get_updates_buf) {
cursor = response.get_updates_buf;
}
// Process messages
if (response.msgs && response.msgs.length > 0) {
for (const msg of response.msgs) {
// Skip bot's own messages and non-finished user messages
if (msg.message_type === MessageType.BOT) continue;
if (msg.message_state !== undefined && msg.message_state !== MessageState.FINISH)
continue;
// Cache context token in memory and persist to Redis for queue-mode callbacks
this.contextTokens.set(msg.from_user_id, msg.context_token);
this.persistContextToken(msg.from_user_id, msg.context_token);
// Forward to webhook
await this.forwardToWebhook(webhookUrl, msg);
}
}
} catch (err: any) {
if (this.abort.signal.aborted) break;
// Session expired (errcode -14) — clear cursor, long backoff
if (err?.code === -14) {
log(
'WechatBot appId=%s session expired, backing off %dmin',
this.applicationId,
SESSION_EXPIRED_BACKOFF_MS / 60_000,
);
await this.sleep(SESSION_EXPIRED_BACKOFF_MS);
break;
}
log('WechatBot appId=%s poll error: %s', this.applicationId, err?.message || err);
// Exponential backoff capped at MAX_RETRY_DELAY_MS (matches reference)
await this.sleep(retryDelay);
retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY_MS);
}
}
log('WechatBot appId=%s poll loop ended', this.applicationId);
}
/**
* Forward a polled message to the webhook endpoint for Chat SDK processing.
*/
private async forwardToWebhook(webhookUrl: string, msg: WechatRawMessage): Promise<void> {
try {
log('WechatBot appId=%s forwarding msg from %s', this.applicationId, msg.from_user_id);
const response = await fetch(webhookUrl, {
body: JSON.stringify(msg),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
if (!response.ok) {
log('WechatBot appId=%s webhook forward failed: %d', this.applicationId, response.status);
}
} catch (err) {
log('WechatBot appId=%s webhook forward error: %O', this.applicationId, err);
}
}
private contextTokenRedisKey(userId: string): string {
return `wechat:ctx-token:${this.applicationId}:${userId}`;
}
private persistContextToken(userId: string, token: string): void {
if (!this.context.redisClient) return;
const key = this.contextTokenRedisKey(userId);
// 24h TTL — tokens are refreshed on every inbound message.
// The redisClient is a raw ioredis instance (cast via `as any`), so use
// positional args instead of the { ex } object form.
(this.context.redisClient as any).set(key, token, 'EX', 86_400).catch((err: any) => {
log('WechatBot appId=%s failed to persist context token: %s', this.applicationId, err);
});
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(resolve, ms);
this.abort.signal.addEventListener('abort', () => {
clearTimeout(timer);
resolve();
});
});
}
// --- Runtime Operations ---
createAdapter(): Record<string, any> {
return {
wechat: createWechatAdapter({
botId: this.config.credentials.botId,
botToken: this.config.credentials.botToken,
}),
};
}
getMessenger(platformThreadId: string): PlatformMessenger {
const targetId = extractChatId(platformThreadId);
// Resolve context token: in-memory cache first, then Redis fallback.
// This allows queue-mode callbacks (fresh client instances) to recover
// the token that was persisted by the long-polling instance.
const resolveToken = async (): Promise<string> => {
const cached = this.contextTokens.get(targetId);
if (cached) return cached;
if (this.context.redisClient) {
const redisKey = this.contextTokenRedisKey(targetId);
const token = await this.context.redisClient.get(redisKey);
if (token) {
this.contextTokens.set(targetId, token);
return token;
}
}
return '';
};
return {
createMessage: async (content) => {
const token = await resolveToken();
await this.api.sendMessage(targetId, content, token);
},
editMessage: async (_messageId, content) => {
// WeChat doesn't support editing — send a new message
const token = await resolveToken();
await this.api.sendMessage(targetId, content, token);
},
removeReaction: () => Promise.resolve(),
triggerTyping: async () => {
const token = await resolveToken();
if (!token) {
log('triggerTyping: no context token for user=%s', targetId);
return;
}
await this.api.startTyping(targetId, token);
},
};
}
extractChatId(platformThreadId: string): string {
return extractChatId(platformThreadId);
}
formatReply(body: string, stats?: UsageStats): string {
if (!stats || !this.config.settings?.showUsageStats) return body;
return `${body}\n\n${formatUsageStats(stats)}`;
}
parseMessageId(compositeId: string): string {
return compositeId;
}
}
export class WechatClientFactory extends ClientFactory {
createClient(config: BotProviderConfig, context: BotPlatformRuntimeContext): PlatformClient {
return new WechatGatewayClient(config, context);
}
async validateCredentials(credentials: Record<string, string>): Promise<ValidationResult> {
if (!credentials.botToken) {
return {
errors: [{ field: 'botToken', message: 'Bot Token is required' }],
valid: false,
};
}
// WeChat token validity is verified during the long-polling connection.
// The iLink API doesn't provide a lightweight token check endpoint.
return { valid: true };
}
}

View file

@ -0,0 +1,17 @@
import type { PlatformDefinition } from '../types';
import { WechatClientFactory } from './client';
import { schema } from './schema';
export const wechat: PlatformDefinition = {
authFlow: 'qrcode',
id: 'wechat',
name: 'WeChat',
connectionMode: 'websocket',
description: 'Connect a WeChat bot via iLink API',
documentation: {
setupGuideUrl: 'https://lobehub.com/docs/usage/channels/wechat',
},
schema,
supportsMessageEdit: false,
clientFactory: new WechatClientFactory(),
};

View file

@ -0,0 +1,39 @@
import { DEFAULT_DEBOUNCE_MS, MAX_DEBOUNCE_MS } from '../const';
import type { FieldSchema } from '../types';
export const schema: FieldSchema[] = [
// No credentials fields — WeChat uses QR code auth flow (authFlow: 'qrcode').
// botToken, botId, and userId are populated automatically after QR scan.
{
key: 'settings',
label: 'channel.settings',
properties: [
{
key: 'charLimit',
default: 2000,
description: 'channel.charLimitHint',
label: 'channel.charLimit',
maximum: 2000,
minimum: 100,
type: 'number',
},
{
key: 'debounceMs',
default: DEFAULT_DEBOUNCE_MS,
description: 'channel.debounceMsHint',
label: 'channel.debounceMs',
maximum: MAX_DEBOUNCE_MS,
minimum: 0,
type: 'number',
},
{
key: 'showUsageStats',
default: false,
description: 'channel.showUsageStatsHint',
label: 'channel.showUsageStats',
type: 'boolean',
},
],
type: 'object',
},
];

View file

@ -44,6 +44,14 @@ class AgentBotProviderService {
testConnection = async (params: { applicationId: string; platform: string }) => {
return lambdaClient.agentBotProvider.testConnection.mutate(params);
};
wechatGetQrCode = async () => {
return lambdaClient.agentBotProvider.wechatGetQrCode.mutate();
};
wechatPollQrStatus = async (qrcode: string) => {
return lambdaClient.agentBotProvider.wechatPollQrStatus.query({ qrcode });
};
}
export const agentBotProviderService = new AgentBotProviderService();