mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
✨ 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:
parent
1df02300bc
commit
ecde45b4ce
38 changed files with 2625 additions and 89 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
| --------- | ------- | ----- | -------- | -- | -- | ---- | ---- |
|
||||
| 文本消息 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
|
||||
| 私人消息 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
|
||||
| 群组聊天 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
|
||||
| 表情反应 | 是 | 是 | 是 | 否 | 否 | 部分支持 | 部分支持 |
|
||||
| 图片 / 文件附件 | 是 | 是 | 是 | 是 | 否 | 是 | 是 |
|
||||
|
|
|
|||
96
docs/usage/channels/wechat.mdx
Normal file
96
docs/usage/channels/wechat.mdx
Normal 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: 100–2000) |
|
||||
| **Message Merge Window** | 2000 ms | How long to wait for additional messages before replying |
|
||||
| **Show Usage Stats** | Off | Display token/cost stats in replies |
|
||||
|
||||
## How It Works
|
||||
|
||||
Unlike webhook-based platforms (Telegram, Slack), WeChat uses a **long-polling** mechanism via the iLink Bot API:
|
||||
|
||||
1. When you scan the QR code, LobeHub obtains a bot token from WeChat's iLink API
|
||||
2. LobeHub continuously polls the iLink API for new messages (\~35 second intervals)
|
||||
3. When a message arrives, it is routed through the LobeHub agent for processing
|
||||
4. The agent's response is sent back to WeChat via the iLink API
|
||||
|
||||
This polling is managed by a background cron job, so the connection is maintained automatically.
|
||||
|
||||
## Limitations
|
||||
|
||||
- **No message editing** — WeChat does not support editing sent messages. Updated responses will be sent as new messages.
|
||||
- **No reactions** — WeChat iLink Bot API does not support emoji reactions.
|
||||
- **Text only** — Only text messages are currently supported. Image and file attachments are not yet available.
|
||||
- **Message length limit** — Messages exceeding 2000 characters will be automatically split into multiple messages.
|
||||
- **Session expiration** — The bot session may expire and require re-authentication by scanning a new QR code.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **QR code expired:** Click **Refresh QR Code** in the modal to generate a new one.
|
||||
- **Bot not responding:** The session may have expired. Go to the WeChat channel settings and re-scan the QR code to reconnect.
|
||||
- **Delayed responses:** Long-polling has a natural delay of up to 35 seconds between polls. This is expected behavior.
|
||||
- **Connection lost after some time:** WeChat sessions expire periodically. Re-authenticate by clicking "Scan QR Code to Connect" again.
|
||||
93
docs/usage/channels/wechat.zh-CN.mdx
Normal file
93
docs/usage/channels/wechat.zh-CN.mdx
Normal 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 | 每条消息的最大字符数(范围:100–2000) |
|
||||
| **消息合并窗口** | 2000 毫秒 | 等待更多消息再回复的时间 |
|
||||
| **显示使用统计** | 关闭 | 在回复中显示 Token 用量 / 成本统计 |
|
||||
|
||||
## 工作原理
|
||||
|
||||
与基于 Webhook 的平台(Telegram、Slack)不同,微信使用 iLink Bot API 的 **长轮询** 机制:
|
||||
|
||||
1. 当您扫描二维码时,LobeHub 从微信 iLink API 获取 bot token
|
||||
2. LobeHub 持续轮询 iLink API 获取新消息(约 35 秒间隔)
|
||||
3. 当消息到达时,通过 LobeHub 代理进行处理
|
||||
4. 代理的响应通过 iLink API 发送回微信
|
||||
|
||||
此轮询由后台定时任务管理,连接会自动维护。
|
||||
|
||||
## 功能限制
|
||||
|
||||
- **不支持消息编辑** — 微信不支持编辑已发送的消息。更新的回复将作为新消息发送。
|
||||
- **不支持表情回应** — 微信 iLink Bot API 不支持表情回应功能。
|
||||
- **仅支持文本** — 目前仅支持文本消息。图片和文件附件暂不可用。
|
||||
- **消息长度限制** — 超过 2000 个字符的消息将被自动拆分为多条消息发送。
|
||||
- **会话过期** — 机器人会话可能会过期,需要重新扫码认证。
|
||||
|
||||
## 故障排除
|
||||
|
||||
- **二维码已过期:** 在弹窗中点击 **刷新二维码** 生成新的二维码。
|
||||
- **机器人未响应:** 会话可能已过期。前往微信渠道设置,重新扫码连接。
|
||||
- **响应延迟:** 长轮询在两次轮询之间有最多 35 秒的自然延迟。这是预期行为。
|
||||
- **一段时间后连接断开:** 微信会话会定期过期。再次点击 "扫码连接" 重新认证。
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "扫码连接"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
26
packages/chat-adapter-wechat/package.json
Normal file
26
packages/chat-adapter-wechat/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
357
packages/chat-adapter-wechat/src/adapter.test.ts
Normal file
357
packages/chat-adapter-wechat/src/adapter.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
332
packages/chat-adapter-wechat/src/adapter.ts
Normal file
332
packages/chat-adapter-wechat/src/adapter.ts
Normal 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);
|
||||
}
|
||||
246
packages/chat-adapter-wechat/src/api.test.ts
Normal file
246
packages/chat-adapter-wechat/src/api.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
272
packages/chat-adapter-wechat/src/api.ts
Normal file
272
packages/chat-adapter-wechat/src/api.ts
Normal 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;
|
||||
}
|
||||
46
packages/chat-adapter-wechat/src/format-converter.test.ts
Normal file
46
packages/chat-adapter-wechat/src/format-converter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
19
packages/chat-adapter-wechat/src/format-converter.ts
Normal file
19
packages/chat-adapter-wechat/src/format-converter.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
13
packages/chat-adapter-wechat/src/index.ts
Normal file
13
packages/chat-adapter-wechat/src/index.ts
Normal 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';
|
||||
154
packages/chat-adapter-wechat/src/types.ts
Normal file
154
packages/chat-adapter-wechat/src/types.ts
Normal 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;
|
||||
21
packages/chat-adapter-wechat/tsconfig.json
Normal file
21
packages/chat-adapter-wechat/tsconfig.json
Normal 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/**/*"]
|
||||
}
|
||||
8
packages/chat-adapter-wechat/tsup.config.ts
Normal file
8
packages/chat-adapter-wechat/tsup.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
dts: true,
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
sourcemap: true,
|
||||
});
|
||||
10
packages/chat-adapter-wechat/vitest.config.mts
Normal file
10
packages/chat-adapter-wechat/vitest.config.mts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
all: false,
|
||||
},
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
128
src/app/(backend)/api/agent/gateway/wechat/route.ts
Normal file
128
src/app/(backend)/api/agent/gateway/wechat/route.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
150
src/routes/(main)/agent/channel/detail/QrCodeAuth.tsx
Normal file
150
src/routes/(main)/agent/channel/detail/QrCodeAuth.tsx
Normal 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;
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
296
src/server/services/bot/platforms/wechat/client.ts
Normal file
296
src/server/services/bot/platforms/wechat/client.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
17
src/server/services/bot/platforms/wechat/definition.ts
Normal file
17
src/server/services/bot/platforms/wechat/definition.ts
Normal 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(),
|
||||
};
|
||||
39
src/server/services/bot/platforms/wechat/schema.ts
Normal file
39
src/server/services/bot/platforms/wechat/schema.ts
Normal 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',
|
||||
},
|
||||
];
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue