Compare commits

...

15 Commits

Author SHA1 Message Date
rdmclin2 a35a69d1e2 chore: remove database migration 2026-03-18 13:23:26 +08:00
rdmclin2 0b3713d79a chore: bot architecure refact 2026-03-18 12:48:11 +08:00
rdmclin2 b65c06a02f fix: shared redis proxy 2026-03-12 21:43:19 +08:00
rdmclin2 2027df3d30 fix: lint error 2026-03-12 21:03:48 +08:00
rdmclin2 54e443bd55 chore: update platfom icon color 2026-03-12 21:02:00 +08:00
rdmclin2 3de1a4e412 chore: use lobe channel icon 2026-03-12 21:01:20 +08:00
rdmclin2 69ba6e8714 chore: update memory tool icon 2026-03-12 20:07:20 +08:00
rdmclin2 5e39345c8d fix: edit messsage throw error 2026-03-12 19:53:22 +08:00
rdmclin2 185e598532 fix: discord threadId bypass 2026-03-12 19:39:05 +08:00
rdmclin2 e680dd9b7c fix: discord metion thread 2026-03-12 19:17:38 +08:00
rdmclin2 c2dae40303 fix: crypto algorithm 2026-03-12 19:17:38 +08:00
rdmclin2 d43dd2d7e0 docs : add qq channel 2026-03-12 19:17:38 +08:00
rdmclin2 265b39615d feat: support QQ platform 2026-03-12 19:17:38 +08:00
rdmclin2 2b46f65571 chore: refactor platform abstract 2026-03-12 19:17:38 +08:00
rdmclin2 802a8aee64 chore: add bot platform abstract 2026-03-12 19:17:38 +08:00
82 changed files with 4335 additions and 2023 deletions
+5 -1
View File
@@ -77,6 +77,7 @@ table agent_bot_providers {
agent_id text [not null]
user_id text [not null]
platform varchar(50) [not null]
connection_mode varchar(20) [not null, default: 'webhook']
application_id varchar(255) [not null]
credentials text
enabled boolean [not null, default: true]
@@ -85,7 +86,7 @@ table agent_bot_providers {
updated_at "timestamp with time zone" [not null, default: `now()`]
indexes {
(platform, application_id) [name: 'agent_bot_providers_platform_app_id_unique', unique]
(platform, connection_mode, application_id) [name: 'agent_bot_providers_platform_conn_app_id_unique', unique]
platform [name: 'agent_bot_providers_platform_idx']
agent_id [name: 'agent_bot_providers_agent_id_idx']
user_id [name: 'agent_bot_providers_user_id_idx']
@@ -307,6 +308,7 @@ table api_keys {
id text [pk, not null]
name varchar(256) [not null]
key varchar(256) [not null, unique]
key_hash varchar(128) [unique]
enabled boolean [default: true]
expires_at "timestamp with time zone"
last_used_at "timestamp with time zone"
@@ -1767,6 +1769,8 @@ ref: messages_files.file_id > files.id
ref: messages_files.message_id > messages.id
ref: messages.id - message_translates.id
ref: messages.session_id - sessions.id
ref: messages.parent_id - messages.id
+19 -4
View File
@@ -1,9 +1,9 @@
---
title: Connect LobeHub to Discord
description: >-
Learn how to create a Discord bot and connect it to your LobeHub agent as a
message channel, allowing your AI assistant to interact with users directly in
Discord servers and direct messages.
Learn how to create a Discord bot and connect it to your LobeHub agent as a message channel, allowing your AI assistant to interact with users directly in Discord servers and direct messages.
tags:
- Discord
- Message Channels
@@ -14,7 +14,8 @@ tags:
# Connect LobeHub to Discord
<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**.
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 Discord channel to your LobeHub agent, users can interact with the AI assistant directly through Discord server channels and direct messages.
@@ -29,6 +30,8 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
<Steps>
### Go to the Discord Developer Portal
![](https://hub-apac-1.lobeobjects.space/docs/83f435317ea2c9c4a2adcbfd74301536.png)
Visit the [Discord Developer Portal](https://discord.com/developers/applications) and click **New Application**. Give your application a name (e.g., "LobeHub Assistant") and click **Create**.
### Create a Bot
@@ -37,6 +40,8 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
### Enable Privileged Gateway Intents
![](https://hub-apac-1.lobeobjects.space/docs/6126baa4154be45eefdad73c576723d0.png)
On the Bot settings page, scroll down to **Privileged Gateway Intents** and enable:
- **Message Content Intent** — Required for the bot to read message content
@@ -47,12 +52,16 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
### Copy the Bot Token
![](https://hub-apac-1.lobeobjects.space/docs/e76272de65ad8db8746b1dcafeafdce8.png)
On the **Bot** page, click **Reset Token** to generate your bot token. Copy and save it securely.
> **Important:** Treat your bot token like a password. Never share it publicly or commit it to version control.
### Copy the Application ID and Public Key
![](https://hub-apac-1.lobeobjects.space/docs/d42901c6eb84e3e335d9a8535f317a35.png)
Go to **General Information** in the left sidebar. Copy and save:
- **Application ID**
@@ -70,6 +79,8 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
### Fill in the Credentials
![](https://hub-apac-1.lobeobjects.space/docs/c5ced26ea287ee215a9dc385367c1083.png)
Enter the following fields:
- **Application ID** — The Application ID from your Discord app's General Information page
@@ -88,6 +99,8 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
<Steps>
### Generate an Invite URL
![](https://hub-apac-1.lobeobjects.space/docs/5e8a93f33e085a187deddb87704f0bd3.png)
In the Discord Developer Portal, go to **OAuth2** → **URL Generator**. Select the following scopes:
- `bot`
@@ -104,6 +117,8 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
### Authorize the Bot
![](https://hub-apac-1.lobeobjects.space/docs/2e47836fe4ac988e76460534ee57efa4.png)
Copy the generated URL, open it in your browser, select the server you want to add the bot to, and click **Authorize**.
</Steps>
+139
View File
@@ -0,0 +1,139 @@
---
title: Connect LobeHub to QQ
description: >-
Learn how to create a QQ bot and connect it to your LobeHub agent as a
message channel, enabling your AI assistant to chat with users in QQ
group chats and direct messages.
tags:
- QQ
- Message Channels
- Bot Setup
- Integration
---
# Connect LobeHub to QQ
<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 QQ channel to your LobeHub agent, users can interact with the AI assistant through QQ group chats, guild channels, and direct messages.
## Prerequisites
- A LobeHub account with an active subscription
- A QQ account
## Step 1: Create a QQ Bot
<Steps>
### Open the QQ Open Platform
Visit [q.qq.com](https://q.qq.com) and sign in with your QQ account.
### Create an Application
In the QQ Open Platform dashboard, click **Create Bot**. Fill in the bot name, description, and avatar.
### Copy App Credentials
After the application is created, go to **Development Settings** and copy:
- **App ID** — Your bot's unique identifier
- **App Secret** — Your bot's secret key
> **Important:** Keep your App Secret confidential. Never share it publicly.
### Configure Webhook URL
In the QQ Open Platform, navigate to **Development Settings** → **Callback Configuration**. You will need to paste the LobeHub Callback URL here after completing Step 2.
</Steps>
## Step 2: Configure QQ in LobeHub
<Steps>
### Open Channel Settings
In LobeHub, navigate to your agent's settings, then select the **Channels** tab. Click **QQ** from the platform list.
### Enter App Credentials
Fill in the following fields:
- **Application ID** — The App ID from the QQ Open Platform
- **App Secret** — The App Secret from the QQ Open Platform
### Save and Copy the Callback URL
Click **Save Configuration**. After saving, a **Callback URL** will be displayed. Copy this URL.
Your credentials will be encrypted and stored securely.
</Steps>
## Step 3: Configure Callback in QQ Open Platform
<Steps>
### Paste the Callback URL
Go back to the QQ Open Platform, navigate to **Development Settings** → **Callback Configuration**. Paste the **Callback URL** you copied from LobeHub.
### Select Event Types
Subscribe to the message events your bot needs. Common events include:
- `GROUP_AT_MESSAGE_CREATE` — Triggered when the bot is @mentioned in a group
- `C2C_MESSAGE_CREATE` — Triggered when the bot receives a private message
- `AT_MESSAGE_CREATE` — Triggered when the bot is @mentioned in a guild channel
- `DIRECT_MESSAGE_CREATE` — Triggered for direct messages in a guild
### Verify the Callback
The QQ Open Platform will send a verification request to your Callback URL. LobeHub handles this automatically using Ed25519 signature verification.
</Steps>
## Step 4: Publish the Bot
<Steps>
### Submit for Review
In the QQ Open Platform, go to **Version Management** and create a new version. Submit the bot for review.
### Wait for Approval
QQ will review your bot. Once approved, the bot will be published and ready to use. For sandbox testing, you can add test users directly without publishing.
</Steps>
## Step 5: Test the Connection
Click **Test Connection** in LobeHub's channel settings to verify the integration. Then open QQ, find your bot, and send a message. The bot should respond through your LobeHub agent.
## Adding the Bot to Group Chats
To use the bot in QQ groups:
1. Add the bot to a QQ group
2. @mention the bot in a message to trigger a response
3. The bot will reply in the group conversation
## Configuration Reference
| Field | Required | Description |
| ------------------ | -------- | -------------------------------------------------------- |
| **Application ID** | Yes | Your bot's App ID from QQ Open Platform |
| **App Secret** | Yes | Your bot's App Secret from QQ Open Platform |
| **Callback URL** | — | Auto-generated after saving; paste into QQ Open Platform |
## Limitations
- **No message editing** — QQ Bot API does not support editing sent messages. Updated responses will be sent as new messages.
- **No reactions** — QQ Bot API does not support emoji reactions.
- **No typing indicator** — QQ Bot API does not provide typing indicator support for bots.
- **Message length limit** — Messages exceeding 2000 characters will be automatically truncated.
## Troubleshooting
- **Callback URL verification failed:** Ensure you saved the configuration in LobeHub first and the URL was copied correctly. LobeHub handles Ed25519 verification automatically.
- **Bot not responding:** Verify the App ID and App Secret are correct, the bot is published (or you are a sandbox test user), and the required message events are subscribed.
- **Group chat issues:** Make sure the bot has been added to the group. @mention the bot to trigger a response.
- **Test Connection failed:** Double-check the App ID and App Secret in LobeHub's channel settings.
+136
View File
@@ -0,0 +1,136 @@
---
title: 将 LobeHub 连接到 QQ
description: 了解如何创建 QQ 机器人并将其连接到您的 LobeHub 代理作为消息渠道,使您的 AI 助手能够在 QQ 群聊和私聊中与用户互动。
tags:
- QQ
- 消息渠道
- 机器人设置
- 集成
---
# 将 LobeHub 连接到 QQ
<Callout type={'info'}>
此功能目前正在开发中,可能尚未完全稳定。您可以通过在 **设置** → **高级设置** → **开发者模式**
中启用 **开发者模式** 来使用此功能。
</Callout>
通过将 QQ 渠道连接到您的 LobeHub 代理,用户可以通过 QQ 群聊、频道和私聊与 AI 助手互动。
## 前置条件
- 一个拥有有效订阅的 LobeHub 账户
- 一个 QQ 账户
## 第一步:创建 QQ 机器人
<Steps>
### 打开 QQ 开放平台
访问 [q.qq.com](https://q.qq.com),使用您的 QQ 账号登录。
### 创建应用
在 QQ 开放平台控制台中,点击 **创建机器人**。填写机器人名称、描述和头像。
### 复制应用凭证
应用创建完成后,进入 **开发设置**,复制以下内容:
- **App ID** — 机器人的唯一标识符
- **App Secret** — 机器人的密钥
> **重要提示:** 请妥善保管您的 App Secret,切勿公开分享。
### 配置回调地址
在 QQ 开放平台中,导航到 **开发设置** → **回调配置**。您需要在完成第二步后将 LobeHub 的回调地址粘贴到此处。
</Steps>
## 第二步:在 LobeHub 中配置 QQ
<Steps>
### 打开渠道设置
在 LobeHub 中,导航到您的代理设置,然后选择 **渠道** 标签页。从平台列表中点击 **QQ**。
### 输入应用凭证
填写以下字段:
- **应用 ID** — 来自 QQ 开放平台的 App ID
- **App Secret** — 来自 QQ 开放平台的 App Secret
### 保存并复制回调地址
点击 **保存配置**。保存后,将显示一个 **回调地址(Callback URL**。复制此地址。
您的凭证将被加密并安全存储。
</Steps>
## 第三步:在 QQ 开放平台配置回调
<Steps>
### 粘贴回调地址
返回 QQ 开放平台,导航到 **开发设置** → **回调配置**。将您从 LobeHub 复制的 **回调地址** 粘贴到此处。
### 选择事件类型
订阅您的机器人需要的消息事件。常用事件包括:
- `GROUP_AT_MESSAGE_CREATE` — 在群聊中被 @提及时触发
- `C2C_MESSAGE_CREATE` — 收到私聊消息时触发
- `AT_MESSAGE_CREATE` — 在频道中被 @提及时触发
- `DIRECT_MESSAGE_CREATE` — 频道私信时触发
### 验证回调
QQ 开放平台将向您的回调地址发送验证请求。LobeHub 会通过 Ed25519 签名验证自动处理此请求。
</Steps>
## 第四步:发布机器人
<Steps>
### 提交审核
在 QQ 开放平台中,进入 **版本管理** 并创建一个新版本。提交机器人进行审核。
### 等待审核通过
QQ 会对您的机器人进行审核。审核通过后,机器人将发布并可投入使用。在沙盒测试阶段,您可以直接添加测试用户而无需发布。
</Steps>
## 第五步:测试连接
在 LobeHub 的渠道设置中点击 **测试连接** 以验证集成。然后打开 QQ,找到您的机器人并发送消息。机器人应通过您的 LobeHub 代理进行响应。
## 将机器人添加到群聊
要在 QQ 群聊中使用机器人:
1. 将机器人添加到 QQ 群聊中
2. 在消息中 @提及机器人以触发响应
3. 机器人将在群聊中回复
## 配置参考
| 字段 | 是否必需 | 描述 |
| -------------- | ---- | ---------------------- |
| **应用 ID** | 是 | 来自 QQ 开放平台的 App ID |
| **App Secret** | 是 | 来自 QQ 开放平台的 App Secret |
| **回调地址** | — | 保存后自动生成;粘贴到 QQ 开放平台 |
## 功能限制
- **不支持消息编辑** — QQ 机器人 API 不支持编辑已发送的消息。更新的回复将作为新消息发送。
- **不支持表情回应** — QQ 机器人 API 不支持表情回应功能。
- **不支持输入状态提示** — QQ 机器人 API 不提供输入状态指示器功能。
- **消息长度限制** — 超过 2000 个字符的消息将被自动截断。
## 故障排除
- **回调地址验证失败:** 确保您已在 LobeHub 中保存配置,并正确复制了 URL。LobeHub 会自动处理 Ed25519 验证。
- **机器人未响应:** 验证 App ID 和 App Secret 是否正确,机器人是否已发布(或您是沙盒测试用户),以及是否订阅了所需的消息事件。
- **群聊问题:** 确保机器人已被添加到群聊中。@提及机器人以触发响应。
- **测试连接失败:** 仔细检查 LobeHub 渠道设置中的 App ID 和 App Secret。
+3
View File
@@ -30,6 +30,8 @@
"channel.publicKey": "Public Key",
"channel.publicKeyHint": "Optional. Used to verify interaction requests from Discord.",
"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.removeChannel": "Remove Channel",
"channel.removeFailed": "Failed to remove channel",
"channel.removed": "Channel removed",
@@ -40,6 +42,7 @@
"channel.secretToken": "Webhook Secret Token",
"channel.secretTokenHint": "Optional. Used to verify webhook requests from Telegram.",
"channel.secretTokenPlaceholder": "Optional secret for webhook verification",
"channel.setupGuide": "Setup Guide",
"channel.telegram.description": "Connect this assistant to Telegram for private and group chats.",
"channel.testConnection": "Test Connection",
"channel.testFailed": "Connection test failed",
+3
View File
@@ -27,6 +27,8 @@
"channel.platforms": "平台",
"channel.publicKey": "公钥",
"channel.publicKeyPlaceholder": "用于交互验证",
"channel.qq.appIdHint": "您在 QQ 开放平台获取的机器人 App ID",
"channel.qq.description": "将助手连接到 QQ,支持群聊和私聊。",
"channel.removeChannel": "移除频道",
"channel.removeFailed": "移除频道失败",
"channel.removed": "频道已移除",
@@ -37,6 +39,7 @@
"channel.secretToken": "Webhook 密钥",
"channel.secretTokenHint": "可选。用于验证来自 Telegram 的 Webhook 请求。",
"channel.secretTokenPlaceholder": "可选的 Webhook 验证密钥",
"channel.setupGuide": "配置教程",
"channel.telegram.description": "将助手连接到 Telegram,支持私聊和群聊。",
"channel.testConnection": "测试连接",
"channel.testFailed": "连接测试失败",
+6 -5
View File
@@ -177,9 +177,9 @@
"@better-auth/expo": "1.4.6",
"@better-auth/passkey": "1.4.6",
"@cfworker/json-schema": "^4.1.1",
"@chat-adapter/discord": "^4.17.0",
"@chat-adapter/state-ioredis": "^4.17.0",
"@chat-adapter/telegram": "^4.17.0",
"@chat-adapter/discord": "^4.20.0",
"@chat-adapter/state-ioredis": "^4.20.0",
"@chat-adapter/telegram": "^4.20.0",
"@codesandbox/sandpack-react": "^2.20.0",
"@discordjs/rest": "^2.6.0",
"@dnd-kit/core": "^6.3.1",
@@ -195,7 +195,6 @@
"@icons-pack/react-simple-icons": "^13.8.0",
"@khmyznikov/pwa-install": "0.3.9",
"@langchain/community": "^0.3.59",
"@lobechat/adapter-lark": "workspace:*",
"@lobechat/agent-runtime": "workspace:*",
"@lobechat/builtin-agents": "workspace:*",
"@lobechat/builtin-skills": "workspace:*",
@@ -219,6 +218,8 @@
"@lobechat/builtin-tools": "workspace:*",
"@lobechat/business-config": "workspace:*",
"@lobechat/business-const": "workspace:*",
"@lobechat/chat-adapter-feishu": "workspace:*",
"@lobechat/chat-adapter-qq": "workspace:*",
"@lobechat/config": "workspace:*",
"@lobechat/const": "workspace:*",
"@lobechat/context-engine": "workspace:*",
@@ -292,7 +293,7 @@
"better-auth-harmony": "^1.2.5",
"better-call": "1.1.8",
"brotli-wasm": "^3.0.1",
"chat": "^4.14.0",
"chat": "^4.20.0",
"chroma-js": "^3.2.0",
"class-variance-authority": "^0.7.1",
"cmdk": "^1.1.1",
@@ -1,5 +1,5 @@
{
"name": "@lobechat/adapter-lark",
"name": "@lobechat/chat-adapter-feishu",
"version": "0.1.0",
"description": "Lark/Feishu adapter for chat SDK",
"type": "module",
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@lobechat/chat-adapter-qq",
"version": "0.1.0",
"description": "QQ 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"
}
}
+426
View File
@@ -0,0 +1,426 @@
import type {
Adapter,
AdapterPostableMessage,
Author,
ChatInstance,
EmojiValue,
FetchOptions,
FetchResult,
FormattedContent,
Logger,
RawMessage,
ThreadInfo,
WebhookOptions,
} from 'chat';
import { Message, parseMarkdown } from 'chat';
import { QQApiClient } from './api';
import { signWebhookResponse } from './crypto';
import { QQFormatConverter } from './format-converter';
import type {
QQAdapterConfig,
QQRawMessage,
QQThreadId,
QQWebhookEventData,
QQWebhookPayload,
} from './types';
import { QQ_EVENT_TYPES, QQ_OP_CODES } from './types';
export class QQAdapter implements Adapter<QQThreadId, QQRawMessage> {
readonly name = 'qq';
private readonly api: QQApiClient;
private readonly clientSecret: string;
private readonly formatConverter: QQFormatConverter;
private _userName: string;
private _botUserId?: string;
private chat!: ChatInstance;
private logger!: Logger;
get userName(): string {
return this._userName;
}
get botUserId(): string | undefined {
return this._botUserId;
}
constructor(config: QQAdapterConfig & { userName?: string }) {
this.api = new QQApiClient(config.appId, config.clientSecret);
this.clientSecret = config.clientSecret;
this.formatConverter = new QQFormatConverter();
this._userName = config.userName || 'qq-bot';
}
async initialize(chat: ChatInstance): Promise<void> {
this.chat = chat;
this.logger = chat.getLogger(this.name);
this._userName = chat.getUserName();
// Validate credentials by getting access token
await this.api.getAccessToken();
// Try to fetch bot info
try {
const botInfo = await this.api.getBotInfo();
if (botInfo) {
if (botInfo.username) this._userName = botInfo.username;
if (botInfo.id) this._botUserId = botInfo.id;
}
} catch {
// Bot info not critical
}
this.logger.info('Initialized QQ adapter (botUserId=%s)', this._botUserId);
}
// ------------------------------------------------------------------
// Webhook handling
// ------------------------------------------------------------------
async handleWebhook(request: Request, options?: WebhookOptions): Promise<Response> {
const bodyText = await request.text();
let payload: QQWebhookPayload;
try {
payload = JSON.parse(bodyText);
} catch {
return new Response('Invalid JSON', { status: 400 });
}
// Handle webhook verification (op: 13)
if (payload.op === QQ_OP_CODES.VERIFY) {
const verifyData = payload.d as { event_ts: string; plain_token: string };
if (verifyData.plain_token && verifyData.event_ts) {
const signature = signWebhookResponse(
verifyData.event_ts,
verifyData.plain_token,
this.clientSecret,
);
return Response.json({
plain_token: verifyData.plain_token,
signature,
});
}
return new Response('Missing verification data', { status: 400 });
}
// Handle dispatch events (op: 0)
if (payload.op !== QQ_OP_CODES.DISPATCH) {
return Response.json({ ok: true });
}
const eventType = payload.t;
const eventData = payload.d;
// Only handle message events
if (!this.isMessageEvent(eventType)) {
return Response.json({ ok: true });
}
// Extract message content
const content = eventData.content;
if (!content?.trim()) {
return Response.json({ ok: true });
}
// Build thread ID based on event type
const threadId = this.buildThreadId(eventType, eventData);
if (!threadId) {
return Response.json({ ok: true });
}
// Create message via factory
const messageFactory = () => this.parseRawEvent(eventData, threadId, eventType!);
// Delegate to Chat SDK pipeline
this.chat.processMessage(this, threadId, messageFactory, options);
return Response.json({ ok: true });
}
private isMessageEvent(eventType?: string): boolean {
if (!eventType) return false;
return (
eventType === QQ_EVENT_TYPES.GROUP_AT_MESSAGE_CREATE ||
eventType === QQ_EVENT_TYPES.C2C_MESSAGE_CREATE ||
eventType === QQ_EVENT_TYPES.AT_MESSAGE_CREATE ||
eventType === QQ_EVENT_TYPES.DIRECT_MESSAGE_CREATE
);
}
private buildThreadId(eventType: string | undefined, data: QQWebhookEventData): string | null {
if (!eventType) return null;
switch (eventType) {
case QQ_EVENT_TYPES.GROUP_AT_MESSAGE_CREATE: {
if (!data.group_openid) return null;
return this.encodeThreadId({ id: data.group_openid, type: 'group' });
}
case QQ_EVENT_TYPES.C2C_MESSAGE_CREATE: {
if (!data.author?.id) return null;
return this.encodeThreadId({ id: data.author.id, type: 'c2c' });
}
case QQ_EVENT_TYPES.AT_MESSAGE_CREATE: {
if (!data.channel_id) return null;
return this.encodeThreadId({
guildId: data.guild_id,
id: data.channel_id,
type: 'guild',
});
}
case QQ_EVENT_TYPES.DIRECT_MESSAGE_CREATE: {
if (!data.guild_id) return null;
return this.encodeThreadId({ id: data.guild_id, type: 'dms' });
}
default: {
return null;
}
}
}
// ------------------------------------------------------------------
// Message operations
// ------------------------------------------------------------------
async postMessage(
threadId: string,
message: AdapterPostableMessage,
): Promise<RawMessage<QQRawMessage>> {
const { type, id, guildId } = this.decodeThreadId(threadId);
const text = this.formatConverter.renderPostable(message);
let response;
switch (type) {
case 'group': {
response = await this.api.sendGroupMessage(id, text);
break;
}
case 'guild': {
response = await this.api.sendGuildMessage(id, text);
break;
}
case 'c2c': {
response = await this.api.sendC2CMessage(id, text);
break;
}
case 'dms': {
response = await this.api.sendDmsMessage(guildId || id, text);
break;
}
default: {
throw new Error(`Unknown thread type: ${type}`);
}
}
return {
id: response.id,
raw: {
author: { id: this._botUserId || '' },
content: text,
id: response.id,
timestamp: response.timestamp,
} as QQRawMessage,
threadId,
};
}
async editMessage(
threadId: string,
_messageId: string,
message: AdapterPostableMessage,
): Promise<RawMessage<QQRawMessage>> {
// QQ doesn't support editing — fall back to posting a new message
return this.postMessage(threadId, message);
}
async deleteMessage(_threadId: string, _messageId: string): Promise<void> {
// TODO: Implement message recall if QQ API supports it
this.logger.warn('Message deletion not implemented for QQ');
}
async fetchMessages(
_threadId: string,
_options?: FetchOptions,
): Promise<FetchResult<QQRawMessage>> {
// QQ doesn't provide message history API for bots
return {
messages: [],
nextCursor: undefined,
};
}
async fetchThread(threadId: string): Promise<ThreadInfo> {
const { type, id } = this.decodeThreadId(threadId);
return {
channelId: threadId,
id: threadId,
isDM: type === 'c2c' || type === 'dms',
metadata: { id, type },
};
}
// ------------------------------------------------------------------
// Message parsing
// ------------------------------------------------------------------
parseMessage(raw: QQRawMessage): Message<QQRawMessage> {
const cleanText = this.formatConverter.cleanMentions(raw.content || '');
const formatted = parseMarkdown(cleanText);
let threadId: string;
if (raw.group_openid) {
threadId = this.encodeThreadId({ id: raw.group_openid, type: 'group' });
} else if (raw.channel_id) {
threadId = this.encodeThreadId({
guildId: raw.guild_id,
id: raw.channel_id,
type: 'guild',
});
} else {
threadId = this.encodeThreadId({ id: raw.author.id, type: 'c2c' });
}
return new Message({
attachments: [],
author: {
fullName: 'Unknown',
isBot: false,
isMe: false,
userId: raw.author.id,
userName: 'unknown',
},
formatted,
id: raw.id,
metadata: {
dateSent: new Date(raw.timestamp),
edited: false,
},
raw,
text: cleanText,
threadId,
});
}
private async parseRawEvent(
data: QQWebhookEventData,
threadId: string,
_eventType: string,
): Promise<Message<QQRawMessage>> {
const content = data.content || '';
const cleanText = this.formatConverter.cleanMentions(content);
const formatted = parseMarkdown(cleanText);
const authorId = data.author?.id || 'unknown';
const isBot = false; // Webhook events are from users
const author: Author = {
fullName: authorId,
isBot,
isMe: isBot && authorId === this._botUserId,
userId: authorId,
userName: authorId,
};
const raw: QQRawMessage = {
author: data.author || { id: 'unknown' },
channel_id: data.channel_id,
content,
group_openid: data.group_openid,
guild_id: data.guild_id,
id: data.id || '',
timestamp: data.timestamp || new Date().toISOString(),
};
return new Message({
attachments: [],
author,
formatted,
id: data.id || '',
metadata: {
dateSent: new Date(data.timestamp || Date.now()),
edited: false,
},
raw,
text: cleanText,
threadId,
});
}
// ------------------------------------------------------------------
// Reactions (not supported by QQ Bot API)
// ------------------------------------------------------------------
async addReaction(
_threadId: string,
_messageId: string,
_emoji: EmojiValue | string,
): Promise<void> {
// QQ Bot API doesn't support reactions
}
async removeReaction(
_threadId: string,
_messageId: string,
_emoji: EmojiValue | string,
): Promise<void> {
// QQ Bot API doesn't support reactions
}
// ------------------------------------------------------------------
// Typing (not supported by QQ Bot API)
// ------------------------------------------------------------------
async startTyping(_threadId: string): Promise<void> {
// QQ has no typing indicator API for bots
}
// ------------------------------------------------------------------
// Thread ID encoding
// ------------------------------------------------------------------
encodeThreadId(data: QQThreadId): string {
if (data.guildId) {
return `qq:${data.type}:${data.id}:${data.guildId}`;
}
return `qq:${data.type}:${data.id}`;
}
decodeThreadId(threadId: string): QQThreadId {
const parts = threadId.split(':');
if (parts.length < 3 || parts[0] !== 'qq') {
// Fallback for malformed thread IDs
return { id: threadId, type: 'group' };
}
const type = parts[1] as QQThreadId['type'];
const id = parts[2];
const guildId = parts[3];
return { guildId, id, type };
}
channelIdFromThreadId(threadId: string): string {
return threadId;
}
isDM(threadId: string): boolean {
const { type } = this.decodeThreadId(threadId);
return type === 'c2c' || type === 'dms';
}
// ------------------------------------------------------------------
// Format rendering
// ------------------------------------------------------------------
renderFormatted(content: FormattedContent): string {
return this.formatConverter.fromAst(content);
}
}
/**
* Factory function to create a QQAdapter.
*/
export function createQQAdapter(config: QQAdapterConfig & { userName?: string }): QQAdapter {
return new QQAdapter(config);
}
+198
View File
@@ -0,0 +1,198 @@
import type { QQAccessTokenResponse, QQSendMessageParams, QQSendMessageResponse } from './types';
import { QQ_MSG_TYPE } from './types';
const AUTH_URL = 'https://bots.qq.com/app/getAppAccessToken';
const API_BASE_URL = 'https://api.sgroup.qq.com';
const MAX_TEXT_LENGTH = 2000;
export class QQApiClient {
private readonly appId: string;
private readonly clientSecret: string;
private cachedToken?: string;
private tokenExpiresAt = 0;
constructor(appId: string, clientSecret: string) {
this.appId = appId;
this.clientSecret = clientSecret;
}
async getAccessToken(): Promise<string> {
if (this.cachedToken && Date.now() < this.tokenExpiresAt) {
return this.cachedToken;
}
const response = await fetch(AUTH_URL, {
body: JSON.stringify({
appId: this.appId,
clientSecret: this.clientSecret,
}),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
if (!response.ok) {
const text = await response.text();
throw new Error(`QQ auth failed: ${response.status} ${text}`);
}
const data = (await response.json()) as QQAccessTokenResponse;
this.cachedToken = data.access_token;
// Refresh 5 minutes before expiration
this.tokenExpiresAt = Date.now() + (data.expires_in - 300) * 1000;
return this.cachedToken;
}
private async call<T>(method: string, path: string, body?: Record<string, unknown>): Promise<T> {
const token = await this.getAccessToken();
const url = `${API_BASE_URL}${path}`;
const init: RequestInit = {
headers: {
'Authorization': `QQBot ${token}`,
'Content-Type': 'application/json',
},
method,
};
if (body && method !== 'GET' && method !== 'DELETE') {
init.body = JSON.stringify(body);
}
const response = await fetch(url, init);
if (!response.ok) {
const text = await response.text();
throw new Error(`QQ API ${method} ${path} failed: ${response.status} ${text}`);
}
// Some endpoints return empty response
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
return response.json() as Promise<T>;
}
return {} as T;
}
/**
* Send message to a QQ group
*/
async sendGroupMessage(
groupOpenId: string,
content: string,
options?: { eventId?: string; msgId?: string; msgSeq?: number },
): Promise<QQSendMessageResponse> {
const params: QQSendMessageParams = {
content: this.truncateText(content),
msg_type: QQ_MSG_TYPE.TEXT,
};
if (options?.msgId) {
params.msg_id = options.msgId;
}
if (options?.eventId) {
params.event_id = options.eventId;
}
if (options?.msgSeq !== undefined) {
params.msg_seq = options.msgSeq;
}
return this.call<QQSendMessageResponse>('POST', `/v2/groups/${groupOpenId}/messages`, params);
}
/**
* Send message to a QQ guild channel
*/
async sendGuildMessage(
channelId: string,
content: string,
options?: { eventId?: string; msgId?: string },
): Promise<QQSendMessageResponse> {
const params: QQSendMessageParams = {
content: this.truncateText(content),
msg_type: QQ_MSG_TYPE.TEXT,
};
if (options?.msgId) {
params.msg_id = options.msgId;
}
if (options?.eventId) {
params.event_id = options.eventId;
}
return this.call<QQSendMessageResponse>('POST', `/channels/${channelId}/messages`, params);
}
/**
* Send direct message to a user (C2C)
*/
async sendC2CMessage(
openId: string,
content: string,
options?: { eventId?: string; msgId?: string; msgSeq?: number },
): Promise<QQSendMessageResponse> {
const params: QQSendMessageParams = {
content: this.truncateText(content),
msg_type: QQ_MSG_TYPE.TEXT,
};
if (options?.msgId) {
params.msg_id = options.msgId;
}
if (options?.eventId) {
params.event_id = options.eventId;
}
if (options?.msgSeq !== undefined) {
params.msg_seq = options.msgSeq;
}
return this.call<QQSendMessageResponse>('POST', `/v2/users/${openId}/messages`, params);
}
/**
* Send direct message in a guild (DMS)
*/
async sendDmsMessage(
guildId: string,
content: string,
options?: { eventId?: string; msgId?: string },
): Promise<QQSendMessageResponse> {
const params: QQSendMessageParams = {
content: this.truncateText(content),
msg_type: QQ_MSG_TYPE.TEXT,
};
if (options?.msgId) {
params.msg_id = options.msgId;
}
if (options?.eventId) {
params.event_id = options.eventId;
}
return this.call<QQSendMessageResponse>('POST', `/dms/${guildId}/messages`, params);
}
/**
* Get bot information
*/
async getBotInfo(): Promise<{ avatar: string; id: string; username: string } | null> {
try {
const data = await this.call<{ avatar: string; id: string; username: string }>(
'GET',
'/users/@me',
);
return data;
} catch {
return null;
}
}
private truncateText(text: string): string {
if (text.length > MAX_TEXT_LENGTH) {
return text.slice(0, MAX_TEXT_LENGTH - 3) + '...';
}
return text;
}
}
+45
View File
@@ -0,0 +1,45 @@
import { createPrivateKey, sign } from 'node:crypto';
/**
* PKCS8 DER prefix for Ed25519 private keys.
*
* ASN.1 structure:
* SEQUENCE {
* INTEGER 0 (version)
* SEQUENCE { OID 1.3.101.112 (Ed25519) }
* OCTET STRING { OCTET STRING { <32-byte seed> } }
* }
*/
const ED25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
/**
* Sign the webhook verification response using Ed25519.
*
* QQ Bot webhook verification requires:
* 1. Repeat the clientSecret until >= 32 bytes, then truncate to 32 as the seed
* 2. Create an Ed25519 private key from the seed
* 3. Sign the concatenated message (eventTs + plainToken)
* 4. Return the signature as a hex string
*/
export function signWebhookResponse(
eventTs: string,
plainToken: string,
clientSecret: string,
): string {
// QQ requires: repeat the secret string until length >= 32, then truncate to 32 bytes
let seedStr = clientSecret;
while (seedStr.length < 32) {
seedStr = seedStr.repeat(2);
}
const seed = Buffer.from(seedStr.slice(0, 32), 'utf8');
// Build PKCS8 DER key — Node.js derives the public key from the seed automatically
const pkcs8Der = Buffer.concat([ED25519_PKCS8_PREFIX, seed]);
const privateKey = createPrivateKey({ format: 'der', key: pkcs8Der, type: 'pkcs8' });
// Sign the message
const message = Buffer.from(eventTs + plainToken);
const signature = sign(null, message, privateKey);
return signature.toString('hex');
}
@@ -0,0 +1,38 @@
import type { Root } from 'chat';
import { BaseFormatConverter, parseMarkdown, stringifyMarkdown } from 'chat';
export class QQFormatConverter extends BaseFormatConverter {
/**
* Convert mdast AST to QQ-compatible text.
* QQ supports basic text messages, we convert markdown to plain text for now.
*/
fromAst(ast: Root): string {
return stringifyMarkdown(ast);
}
/**
* Convert QQ message text to mdast AST.
* Clean up QQ @mention markers before parsing.
*/
toAst(text: string): Root {
// Clean QQ @mention markers (e.g., <@!user_id>, <@user_id>)
const cleaned = text
.replaceAll(/<@!?\d+>/g, '')
.replaceAll('<@everyone>', '')
.replaceAll(/<#\d+>/g, '')
.trim();
return parseMarkdown(cleaned);
}
/**
* Clean @mention markers from text
*/
cleanMentions(text: string): string {
return text
.replaceAll(/<@!?\d+>/g, '')
.replaceAll('<@everyone>', '')
.replaceAll(/<#\d+>/g, '')
.trim();
}
}
+19
View File
@@ -0,0 +1,19 @@
export { createQQAdapter, QQAdapter } from './adapter';
export { QQApiClient } from './api';
export { signWebhookResponse } from './crypto';
export { QQFormatConverter } from './format-converter';
export type {
QQAccessTokenResponse,
QQAdapterConfig,
QQAttachment,
QQAuthor,
QQMessageType,
QQRawMessage,
QQSendMessageParams,
QQSendMessageResponse,
QQThreadId,
QQWebhookEventData,
QQWebhookPayload,
QQWebhookVerifyData,
} from './types';
export { QQ_EVENT_TYPES, QQ_MSG_TYPE, QQ_OP_CODES } from './types';
+123
View File
@@ -0,0 +1,123 @@
export interface QQAdapterConfig {
appId: string;
clientSecret: string;
}
export interface QQThreadId {
/** For guild channels, the guild_id is needed for some operations */
guildId?: string;
id: string;
type: 'group' | 'guild' | 'c2c' | 'dms';
}
export interface QQAuthor {
id: string;
member_openid?: string;
union_openid?: string;
}
export interface QQAttachment {
content_type: string;
filename: string;
height?: number;
size: number;
url: string;
width?: number;
}
export interface QQMessageReference {
message_id: string;
}
export interface QQRawMessage {
attachments?: QQAttachment[];
author: QQAuthor;
channel_id?: string;
content: string;
group_openid?: string;
guild_id?: string;
id: string;
member?: {
joined_at: string;
roles?: string[];
};
mentions?: QQAuthor[];
message_reference?: QQMessageReference;
seq?: number;
seq_in_channel?: string;
timestamp: string;
}
export interface QQWebhookPayload {
d: QQWebhookEventData;
id: string;
op: number;
s?: number;
t?: string;
}
export interface QQWebhookEventData {
author?: QQAuthor;
channel_id?: string;
content?: string;
event_ts?: string;
group_openid?: string;
guild_id?: string;
id?: string;
member?: {
joined_at: string;
roles?: string[];
};
plain_token?: string;
timestamp?: string;
}
export interface QQWebhookVerifyData {
event_ts: string;
plain_token: string;
}
export interface QQAccessTokenResponse {
access_token: string;
expires_in: number;
}
export interface QQSendMessageParams {
[key: string]: unknown;
content?: string;
event_id?: string;
markdown?: {
content: string;
};
msg_id?: string;
msg_seq?: number;
msg_type: number;
}
export interface QQSendMessageResponse {
id: string;
timestamp: string;
}
export type QQMessageType = 'group' | 'guild' | 'c2c' | 'dms';
export const QQ_MSG_TYPE = {
ARK: 3,
EMBED: 4,
MARKDOWN: 2,
MEDIA: 7,
TEXT: 0,
} as const;
export const QQ_EVENT_TYPES = {
AT_MESSAGE_CREATE: 'AT_MESSAGE_CREATE',
C2C_MESSAGE_CREATE: 'C2C_MESSAGE_CREATE',
DIRECT_MESSAGE_CREATE: 'DIRECT_MESSAGE_CREATE',
GROUP_AT_MESSAGE_CREATE: 'GROUP_AT_MESSAGE_CREATE',
} as const;
export const QQ_OP_CODES = {
DISPATCH: 0,
HTTP_CALLBACK_ACK: 12,
VERIFY: 13,
} as const;
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"lib": ["ES2022"]
},
"exclude": ["node_modules", "dist"],
"include": ["src/**/*"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'tsup';
export default defineConfig({
dts: true,
entry: ['src/index.ts'],
format: ['esm'],
sourcemap: true,
});
@@ -5,7 +5,7 @@ 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 { Discord, type DiscordBotConfig } from '@/server/services/bot/platforms/discord';
import { type BotProviderConfig, discord } from '@/server/services/bot/platforms';
import { BotConnectQueue } from '@/server/services/gateway/botConnectQueue';
const log = debug('lobe-server:bot:gateway:cron:discord');
@@ -15,6 +15,16 @@ const POLL_INTERVAL_MS = 30_000; // 30 seconds
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
function createDiscordBot(applicationId: string, credentials: Record<string, string>) {
const config: BotProviderConfig = {
applicationId,
credentials,
platform: 'discord',
settings: {},
};
return discord.clientFactory.createClient(config, {});
}
async function processConnectQueue(remainingMs: number): Promise<number> {
const queue = new BotConnectQueue();
const items = await queue.popAll();
@@ -39,14 +49,11 @@ async function processConnectQueue(remainingMs: number): Promise<number> {
continue;
}
const bot = new Discord({
...provider.credentials,
applicationId: provider.applicationId,
} as DiscordBotConfig);
const bot = createDiscordBot(provider.applicationId, provider.credentials);
await bot.start({
durationMs: remainingMs,
waitUntil: (task) => {
waitUntil: (task: Promise<any>) => {
after(() => task);
},
});
@@ -85,11 +92,11 @@ export async function GET(request: NextRequest) {
const { applicationId, credentials } = provider;
try {
const bot = new Discord({ ...credentials, applicationId } as DiscordBotConfig);
const bot = createDiscordBot(applicationId, credentials);
await bot.start({
durationMs: GATEWAY_DURATION_MS,
waitUntil: (task) => {
waitUntil: (task: Promise<any>) => {
after(() => task);
},
});
@@ -1,9 +1,10 @@
import { type UserMemoryEffort } from '@lobechat/types';
import { Center, Flexbox, Icon } from '@lobehub/ui';
import { BrainOffIcon } from '@lobehub/ui/icons';
import { Divider } from 'antd';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { type LucideIcon } from 'lucide-react';
import { BrainCircuit, CircleOff } from 'lucide-react';
import { Brain } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -99,13 +100,13 @@ const Controls = memo(() => {
const toggleOptions: ToggleOption[] = [
{
description: t('memory.off.desc'),
icon: CircleOff,
icon: BrainOffIcon,
label: t('memory.off.title'),
value: 'off',
},
{
description: t('memory.on.desc'),
icon: BrainCircuit,
icon: Brain,
label: t('memory.on.title'),
value: 'on',
},
@@ -1,5 +1,6 @@
import { BrainOffIcon } from '@lobehub/ui/icons';
import { cssVar } from 'antd-style';
import { Brain, BrainCircuit } from 'lucide-react';
import { Brain } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -27,7 +28,7 @@ const Memory = memo(() => {
return (
<Action
color={isEnabled ? cssVar.colorInfo : undefined}
icon={isEnabled ? BrainCircuit : Brain}
icon={isEnabled ? Brain : BrainOffIcon}
showTooltip={false}
title={t('memory.title')}
popover={{
+3
View File
@@ -33,10 +33,13 @@ export default {
'channel.publicKey': 'Public Key',
'channel.publicKeyHint': 'Optional. Used to verify interaction requests from Discord.',
'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.removeChannel': 'Remove Channel',
'channel.removed': 'Channel removed',
'channel.removeFailed': 'Failed to remove channel',
'channel.save': 'Save Configuration',
'channel.setupGuide': 'Setup Guide',
'channel.saveFailed': 'Failed to save configuration',
'channel.saveFirstWarning': 'Please save configuration first',
'channel.saved': 'Configuration saved successfully',
@@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next';
import { isDesktop } from '@/const/version';
import { pluginRegistry } from '@/features/Electron/titlebar/RecentlyViewed/plugins';
import NavItem from '@/features/NavPanel/components/NavItem';
import { CHANNEL_PROVIDERS } from '@/routes/(main)/agent/channel/const';
import { getPlatformIcon } from '@/routes/(main)/agent/channel/const';
import { useAgentStore } from '@/store/agent';
import { useChatStore } from '@/store/chat';
import { operationSelectors } from '@/store/chat/selectors';
@@ -206,10 +206,9 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
title={title}
icon={(() => {
if (metadata?.bot?.platform) {
const provider = CHANNEL_PROVIDERS.find((p) => p.id === metadata.bot!.platform);
if (provider) {
const ProviderIcon = provider.icon;
return <ProviderIcon color={provider.color} size={16} />;
const ProviderIcon = getPlatformIcon(metadata.bot!.platform);
if (ProviderIcon) {
return <ProviderIcon color={cssVar.colorTextDescription} size={16} />;
}
}
return (
+42 -92
View File
@@ -1,97 +1,47 @@
import { SiDiscord, SiTelegram } from '@icons-pack/react-simple-icons';
import type { LucideIcon } from 'lucide-react';
import * as Icons from '@lobehub/ui/icons';
import type { FC } from 'react';
import { LarkIcon } from './icons';
export interface ChannelProvider {
/** Lark-style auth: appId + appSecret instead of botToken */
authMode?: 'app-secret' | 'bot-token';
/** Whether applicationId can be auto-derived from the bot token */
autoAppId?: boolean;
color: string;
description: string;
docsLink: string;
fieldTags: {
appId: string;
appSecret?: string;
encryptKey?: string;
publicKey?: string;
secretToken?: string;
token?: string;
verificationToken?: string;
webhook?: string;
};
icon: FC<any> | LucideIcon;
id: string;
name: string;
/** 'manual' = user must copy endpoint URL to platform portal (Discord, Lark);
* 'auto' = webhook is set automatically via API (Telegram) */
export interface PlatformUI {
/** 'auto' = webhook set via API (no URL to copy), 'manual' = user must copy endpoint URL */
webhookMode?: 'auto' | 'manual';
}
export const CHANNEL_PROVIDERS: ChannelProvider[] = [
{
color: '#5865F2',
description: 'channel.discord.description',
docsLink: 'https://discord.com/developers/docs/intro',
fieldTags: {
appId: 'Application ID',
publicKey: 'Public Key',
token: 'Bot Token',
},
icon: SiDiscord,
id: 'discord',
name: 'Discord',
webhookMode: 'auto',
},
{
autoAppId: true,
color: '#26A5E4',
description: 'channel.telegram.description',
docsLink: 'https://core.telegram.org/bots#how-do-i-create-a-bot',
fieldTags: {
appId: 'Bot User ID',
secretToken: 'Webhook Secret',
token: 'Bot Token',
},
icon: SiTelegram,
id: 'telegram',
name: 'Telegram',
webhookMode: 'auto',
},
{
authMode: 'app-secret',
color: '#3370FF',
description: 'channel.feishu.description',
docsLink:
'https://open.feishu.cn/document/home/introduction-to-custom-app-development/self-built-application-development-process',
fieldTags: {
appId: 'App ID',
appSecret: 'App Secret',
encryptKey: 'Encrypt Key',
verificationToken: 'Verification Token',
webhook: 'Event Subscription URL',
},
icon: LarkIcon,
id: 'feishu',
name: '飞书',
},
{
authMode: 'app-secret',
color: '#00D6B9',
description: 'channel.lark.description',
docsLink:
'https://open.larksuite.com/document/home/introduction-to-custom-app-development/self-built-application-development-process',
fieldTags: {
appId: 'App ID',
appSecret: 'App Secret',
encryptKey: 'Encrypt Key',
verificationToken: 'Verification Token',
webhook: 'Event Subscription URL',
},
icon: LarkIcon,
id: 'lark',
name: 'Lark',
},
];
/** Known icon names from @lobehub/ui/icons that correspond to chat platforms. */
const ICON_NAMES = [
'Discord',
'GoogleChat',
'Lark',
'MicrosoftTeams',
'QQ',
'Slack',
'Telegram',
'WeChat',
'WhatsApp',
] as const;
/** Alias map for platforms whose display name differs from the icon name. */
const ICON_ALIASES: Record<string, string> = {
feishu: 'Lark',
};
/**
* Resolve icon component by matching against known icon names.
* Accepts either a platform display name (e.g. "Feishu / Lark") or id (e.g. "discord").
*/
export function getPlatformIcon(nameOrId: string): FC<any> | undefined {
const alias = ICON_ALIASES[nameOrId.toLowerCase()];
if (alias) return (Icons as Record<string, any>)[alias];
const name = ICON_NAMES.find(
(n) => nameOrId.includes(n) || nameOrId.toLowerCase() === n.toLowerCase(),
);
return name ? (Icons as Record<string, any>)[name] : undefined;
}
export const PLATFORM_UI: Record<string, PlatformUI> = {
discord: { webhookMode: 'auto' },
feishu: { webhookMode: 'manual' },
lark: { webhookMode: 'manual' },
qq: { webhookMode: 'manual' },
telegram: { webhookMode: 'auto' },
};
+149 -60
View File
@@ -1,28 +1,22 @@
'use client';
import {
Alert,
Flexbox,
Form,
type FormGroupItemType,
type FormItemProps,
Icon,
Tag,
} from '@lobehub/ui';
import { Button, Form as AntdForm, type FormInstance, Switch } from 'antd';
import { Alert, Flexbox, Form, type FormGroupItemType, type FormItemProps, Tag } from '@lobehub/ui';
import { Button, Form as AntdForm, type FormInstance, InputNumber, Select, Switch } from 'antd';
import { createStaticStyles } from 'antd-style';
import { RefreshCw, Save, Trash2 } from 'lucide-react';
import { memo } from 'react';
import { memo, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { FormInput, FormPassword } from '@/components/FormInput';
import InfoTooltip from '@/components/InfoTooltip';
import { useAppOrigin } from '@/hooks/useAppOrigin';
import type {
FieldSchema,
SerializedPlatformDefinition,
} from '@/server/services/bot/platforms/types';
import { type ChannelProvider } from '../const';
import { getPlatformIcon, PLATFORM_UI } from '../const';
import type { ChannelFormValues, TestResult } from './index';
import { getDiscordFormItems } from './platforms/discord';
import { getFeishuFormItems } from './platforms/feishu';
import { getLarkFormItems } from './platforms/lark';
import { getTelegramFormItems } from './platforms/telegram';
const prefixCls = 'ant';
@@ -33,11 +27,6 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
justify-content: space-between;
padding-block-start: 16px;
`,
form: css`
.${prefixCls}-form-item-control:has(.${prefixCls}-input, .${prefixCls}-select) {
flex: none;
}
`,
bottom: css`
display: flex;
flex-direction: column;
@@ -50,6 +39,11 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
padding-block: 0 24px;
padding-inline: 24px;
`,
form: css`
.${prefixCls}-form-item-control:has(.${prefixCls}-input, .${prefixCls}-select, .${prefixCls}-input-number) {
flex: none;
}
`,
webhookBox: css`
overflow: hidden;
flex: 1;
@@ -70,15 +64,98 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
`,
}));
const platformFormItemsMap: Record<
string,
(t: any, hasConfig: boolean, provider: ChannelProvider) => FormItemProps[]
> = {
discord: getDiscordFormItems,
feishu: getFeishuFormItems,
lark: getLarkFormItems,
telegram: getTelegramFormItems,
};
// --------------- Field → FormItem renderer ---------------
function renderFieldComponent(field: FieldSchema, hasConfig: boolean): React.ReactNode {
switch (field.type) {
case 'password': {
return (
<FormPassword
autoComplete="new-password"
placeholder={field.placeholder || (hasConfig ? '••••••••' : undefined)}
/>
);
}
case 'boolean': {
return <Switch />;
}
case 'number':
case 'integer': {
return (
<InputNumber
max={field.maximum}
min={field.minimum}
placeholder={field.placeholder}
style={{ width: '100%' }}
/>
);
}
case 'string': {
if (field.enum) {
return (
<Select
placeholder={field.placeholder}
options={field.enum.map((value, i) => ({
label: field.enumLabels?.[i] || value,
value,
}))}
/>
);
}
return <FormInput placeholder={field.placeholder || field.label} />;
}
default: {
return <FormInput placeholder={field.placeholder || field.label} />;
}
}
}
function fieldToFormItem(field: FieldSchema, hasConfig: boolean): FormItemProps {
return {
children: renderFieldComponent(field, hasConfig),
desc: field.description,
label: field.label,
name: field.key,
rules: field.required ? [{ required: true }] : undefined,
tag: field.label,
valuePropName: field.type === 'boolean' ? 'checked' : undefined,
};
}
// --------------- Build form groups ---------------
function buildCredentialItems(fields: FieldSchema[], hasConfig: boolean): FormItemProps[] {
return fields
.filter((f) => !f.devOnly || process.env.NODE_ENV === 'development')
.map((f) => fieldToFormItem(f, hasConfig));
}
function buildSettingsGroups(fields: FieldSchema[], hasConfig: boolean): FormGroupItemType[] {
const grouped = new Map<string, FieldSchema[]>();
for (const field of fields) {
if (field.devOnly && process.env.NODE_ENV !== 'development') continue;
const groupKey = field.group || 'general';
const list = grouped.get(groupKey) || [];
list.push(field);
grouped.set(groupKey, list);
}
return [...grouped.entries()].map(([key, groupFields]) => ({
children: groupFields.flatMap((f) => {
if (f.type === 'object' && f.properties) {
// Flatten nested object fields with dot-path names
return f.properties.map((child) => fieldToFormItem(child, hasConfig));
}
return fieldToFormItem(f, hasConfig);
}),
defaultActive: true,
key,
title: key.charAt(0).toUpperCase() + key.slice(1),
}));
}
// --------------- Body component ---------------
interface BodyProps {
currentConfig?: { enabled: boolean };
@@ -89,7 +166,7 @@ interface BodyProps {
onSave: () => void;
onTestConnection: () => void;
onToggleEnable: (enabled: boolean) => void;
provider: ChannelProvider;
platformDef: SerializedPlatformDefinition;
saveResult?: TestResult;
saving: boolean;
testing: boolean;
@@ -98,7 +175,7 @@ interface BodyProps {
const Body = memo<BodyProps>(
({
provider,
platformDef,
form,
hasConfig,
currentConfig,
@@ -114,31 +191,33 @@ const Body = memo<BodyProps>(
}) => {
const { t } = useTranslation('agent');
const origin = useAppOrigin();
const platformId = platformDef.id;
const platformName = platformDef.name;
const applicationId = AntdForm.useWatch('applicationId', form);
const webhookUrl = applicationId
? `${origin}/api/agent/webhooks/${provider.id}/${applicationId}`
: `${origin}/api/agent/webhooks/${provider.id}`;
? `${origin}/api/agent/webhooks/${platformId}/${applicationId}`
: `${origin}/api/agent/webhooks/${platformId}`;
const getItems = platformFormItemsMap[provider.id];
const configItems = getItems ? getItems(t, hasConfig, provider) : [];
const ui = PLATFORM_UI[platformId];
const PlatformIcon = getPlatformIcon(platformName);
const ColorIcon =
PlatformIcon && 'Color' in PlatformIcon ? (PlatformIcon as any).Color : PlatformIcon;
const headerTitle = (
<Flexbox horizontal align="center" gap={8}>
<Flexbox
align="center"
justify="center"
style={{
background: provider.color,
borderRadius: 10,
flexShrink: 0,
height: 32,
width: 32,
}}
>
<Icon fill="white" icon={provider.icon} size="middle" />
</Flexbox>
{provider.name}
{ColorIcon && <ColorIcon size={32} />}
{platformName}
{platformDef.documentation?.setupGuideUrl && (
<a
href={platformDef.documentation.setupGuideUrl}
rel="noopener noreferrer"
target="_blank"
onClick={(e) => e.stopPropagation()}
>
<InfoTooltip title={t('channel.setupGuide')} />
</a>
)}
</Flexbox>
);
@@ -146,12 +225,22 @@ const Body = memo<BodyProps>(
<Switch checked={currentConfig.enabled} onChange={onToggleEnable} />
) : undefined;
const group: FormGroupItemType = {
children: configItems,
defaultActive: true,
extra: headerExtra,
title: headerTitle,
};
const formGroups = useMemo<FormGroupItemType[]>(() => {
// Credentials group
const credentialGroup: FormGroupItemType = {
children: buildCredentialItems(platformDef.credentials, hasConfig),
defaultActive: true,
extra: headerExtra,
title: headerTitle,
};
// Settings groups
const settingsGroups = platformDef.settings
? buildSettingsGroups(platformDef.settings, hasConfig)
: [];
return [credentialGroup, ...settingsGroups];
}, [platformDef, hasConfig, headerTitle, headerExtra]);
return (
<>
@@ -159,7 +248,7 @@ const Body = memo<BodyProps>(
className={styles.form}
form={form}
itemMinWidth={'max(50%, 400px)'}
items={[group]}
items={formGroups}
requiredMark={false}
style={{ maxWidth: 1024, padding: 24, width: '100%' }}
variant={'borderless'}
@@ -208,11 +297,11 @@ const Body = memo<BodyProps>(
/>
)}
{hasConfig && provider.webhookMode !== 'auto' && (
{hasConfig && ui?.webhookMode !== 'auto' && (
<Flexbox gap={8}>
<Flexbox horizontal align="center" gap={8}>
<span style={{ fontWeight: 600 }}>{t('channel.endpointUrl')}</span>
{provider.fieldTags.webhook && <Tag>{provider.fieldTags.webhook}</Tag>}
<Tag>{'Event Subscription URL'}</Tag>
</Flexbox>
<Flexbox horizontal gap={8}>
<div className={styles.webhookBox}>{webhookUrl}</div>
@@ -233,7 +322,7 @@ const Body = memo<BodyProps>(
components={{ bold: <strong /> }}
i18nKey="channel.endpointUrlHint"
ns="agent"
values={{ fieldName: provider.fieldTags.webhook, name: provider.name }}
values={{ fieldName: 'Event Subscription URL', name: platformDef.name }}
/>
}
/>
@@ -5,11 +5,29 @@ import { createStaticStyles } from 'antd-style';
import { memo, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { SerializedPlatformDefinition } from '@/server/services/bot/platforms/types';
import { useAgentStore } from '@/store/agent';
import { type ChannelProvider } from '../const';
import Body from './Body';
/**
* Resolve applicationId from credentials by convention:
* 1. Explicit `applicationId` field (Discord)
* 2. `appId` field (Feishu, QQ)
* 3. Derive from `botToken` before ':' (Telegram: "123456:ABC" → "123456")
*/
export function resolveApplicationId(credentials: Record<string, string>): string {
if (credentials.applicationId) return credentials.applicationId;
if (credentials.appId) return credentials.appId;
if (credentials.botToken) {
const colonIdx = credentials.botToken.indexOf(':');
if (colonIdx !== -1) return credentials.botToken.slice(0, colonIdx);
}
return '';
}
const styles = createStaticStyles(({ css, cssVar }) => ({
main: css`
position: relative;
@@ -32,29 +50,20 @@ interface CurrentConfig {
platform: string;
}
export interface ChannelFormValues {
applicationId: string;
appSecret?: string;
botToken: string;
encryptKey?: string;
publicKey: string;
secretToken?: string;
verificationToken?: string;
webhookProxyUrl?: string;
}
export type ChannelFormValues = Record<string, string>;
export interface TestResult {
errorDetail?: string;
type: 'success' | 'error';
type: 'error' | 'success';
}
interface PlatformDetailProps {
agentId: string;
currentConfig?: CurrentConfig;
provider: ChannelProvider;
platformDef: SerializedPlatformDefinition;
}
const PlatformDetail = memo<PlatformDetailProps>(({ provider, agentId, currentConfig }) => {
const PlatformDetail = memo<PlatformDetailProps>(({ platformDef, agentId, currentConfig }) => {
const { t } = useTranslation('agent');
const { message: msg, modal } = App.useApp();
const [form] = Form.useForm<ChannelFormValues>();
@@ -71,23 +80,20 @@ const PlatformDetail = memo<PlatformDetailProps>(({ provider, agentId, currentCo
// Reset form when switching platforms
useEffect(() => {
form.resetFields();
}, [provider.id, form]);
}, [platformDef.id, form]);
// Sync form with saved config
useEffect(() => {
if (currentConfig) {
form.setFieldsValue({
const values: Record<string, string> = {
applicationId: currentConfig.applicationId || '',
appSecret: currentConfig.credentials?.appSecret || '',
botToken: currentConfig.credentials?.botToken || '',
encryptKey: currentConfig.credentials?.encryptKey || '',
publicKey: currentConfig.credentials?.publicKey || '',
secretToken: currentConfig.credentials?.secretToken || '',
verificationToken: currentConfig.credentials?.verificationToken || '',
webhookProxyUrl: currentConfig.credentials?.webhookProxyUrl || '',
});
};
for (const field of platformDef.credentials) {
values[field.key] = currentConfig.credentials?.[field.key] || '';
}
form.setFieldsValue(values);
}
}, [currentConfig, form]);
}, [currentConfig, form, platformDef.credentials]);
const handleSave = useCallback(async () => {
try {
@@ -96,37 +102,14 @@ const PlatformDetail = memo<PlatformDetailProps>(({ provider, agentId, currentCo
setSaving(true);
setSaveResult(undefined);
// Auto-derive applicationId from bot token for Telegram
let applicationId = values.applicationId;
if (provider.autoAppId && values.botToken) {
const colonIdx = values.botToken.indexOf(':');
if (colonIdx !== -1) {
applicationId = values.botToken.slice(0, colonIdx);
form.setFieldValue('applicationId', applicationId);
}
// Build credentials from platform definition
const credentials: Record<string, string> = {};
for (const field of platformDef.credentials) {
const value = values[field.key];
if (value) credentials[field.key] = value;
}
// Build platform-specific credentials
const credentials: Record<string, string> =
provider.authMode === 'app-secret'
? { appId: applicationId, appSecret: values.appSecret || '' }
: { botToken: values.botToken };
if (provider.fieldTags.publicKey) {
credentials.publicKey = values.publicKey || 'default';
}
if (provider.fieldTags.secretToken && values.secretToken) {
credentials.secretToken = values.secretToken;
}
if (provider.fieldTags.verificationToken && values.verificationToken) {
credentials.verificationToken = values.verificationToken;
}
if (provider.fieldTags.encryptKey && values.encryptKey) {
credentials.encryptKey = values.encryptKey;
}
if (provider.webhookMode === 'auto' && values.webhookProxyUrl) {
credentials.webhookProxyUrl = values.webhookProxyUrl;
}
const applicationId = resolveApplicationId(credentials);
if (currentConfig) {
await updateBotProvider(currentConfig.id, agentId, {
@@ -138,7 +121,7 @@ const PlatformDetail = memo<PlatformDetailProps>(({ provider, agentId, currentCo
agentId,
applicationId,
credentials,
platform: provider.id,
platform: platformDef.id,
});
}
@@ -150,18 +133,7 @@ const PlatformDetail = memo<PlatformDetailProps>(({ provider, agentId, currentCo
} finally {
setSaving(false);
}
}, [
agentId,
provider.id,
provider.autoAppId,
provider.authMode,
provider.fieldTags,
provider.webhookMode,
form,
currentConfig,
createBotProvider,
updateBotProvider,
]);
}, [agentId, platformDef, form, currentConfig, createBotProvider, updateBotProvider]);
const handleDelete = useCallback(async () => {
if (!currentConfig) return;
@@ -204,7 +176,7 @@ const PlatformDetail = memo<PlatformDetailProps>(({ provider, agentId, currentCo
try {
await connectBot({
applicationId: currentConfig.applicationId,
platform: provider.id,
platform: platformDef.id,
});
setTestResult({ type: 'success' });
} catch (e: any) {
@@ -215,7 +187,7 @@ const PlatformDetail = memo<PlatformDetailProps>(({ provider, agentId, currentCo
} finally {
setTesting(false);
}
}, [currentConfig, provider.id, connectBot, msg, t]);
}, [currentConfig, platformDef.id, connectBot, msg, t]);
return (
<main className={styles.main}>
@@ -223,7 +195,7 @@ const PlatformDetail = memo<PlatformDetailProps>(({ provider, agentId, currentCo
currentConfig={currentConfig}
form={form}
hasConfig={!!currentConfig}
provider={provider}
platformDef={platformDef}
saveResult={saveResult}
saving={saving}
testResult={testResult}
@@ -1,43 +0,0 @@
import type { FormItemProps } from '@lobehub/ui';
import type { TFunction } from 'i18next';
import { FormInput, FormPassword } from '@/components/FormInput';
import type { ChannelProvider } from '../../const';
export const getDiscordFormItems = (
t: TFunction<'agent'>,
hasConfig: boolean,
provider: ChannelProvider,
): FormItemProps[] => [
{
children: <FormInput placeholder={t('channel.applicationIdPlaceholder')} />,
desc: t('channel.applicationIdHint'),
label: t('channel.applicationId'),
name: 'applicationId',
rules: [{ required: true }],
tag: provider.fieldTags.appId,
},
{
children: (
<FormPassword
autoComplete="new-password"
placeholder={
hasConfig ? t('channel.botTokenPlaceholderExisting') : t('channel.botTokenPlaceholderNew')
}
/>
),
desc: t('channel.botTokenEncryptedHint'),
label: t('channel.botToken'),
name: 'botToken',
rules: [{ required: true }],
tag: provider.fieldTags.token,
},
{
children: <FormInput placeholder={t('channel.publicKeyPlaceholder')} />,
desc: t('channel.publicKeyHint'),
label: t('channel.publicKey'),
name: 'publicKey',
tag: provider.fieldTags.publicKey,
},
];
@@ -1,50 +0,0 @@
import type { FormItemProps } from '@lobehub/ui';
import type { TFunction } from 'i18next';
import { FormInput, FormPassword } from '@/components/FormInput';
import type { ChannelProvider } from '../../const';
export const getFeishuFormItems = (
t: TFunction<'agent'>,
hasConfig: boolean,
provider: ChannelProvider,
): FormItemProps[] => [
{
children: <FormInput placeholder={t('channel.applicationIdPlaceholder')} />,
desc: t('channel.applicationIdHint'),
label: t('channel.applicationId'),
name: 'applicationId',
rules: [{ required: true }],
tag: provider.fieldTags.appId,
},
{
children: (
<FormPassword
autoComplete="new-password"
placeholder={
hasConfig ? t('channel.botTokenPlaceholderExisting') : t('channel.appSecretPlaceholder')
}
/>
),
desc: t('channel.botTokenEncryptedHint'),
label: t('channel.appSecret'),
name: 'appSecret',
rules: [{ required: true }],
tag: provider.fieldTags.appSecret,
},
{
children: <FormInput placeholder={t('channel.verificationTokenPlaceholder')} />,
desc: t('channel.verificationTokenHint'),
label: t('channel.verificationToken'),
name: 'verificationToken',
tag: provider.fieldTags.verificationToken,
},
{
children: <FormPassword placeholder={t('channel.encryptKeyPlaceholder')} />,
desc: t('channel.encryptKeyHint'),
label: t('channel.encryptKey'),
name: 'encryptKey',
tag: provider.fieldTags.encryptKey,
},
];
@@ -1,50 +0,0 @@
import type { FormItemProps } from '@lobehub/ui';
import type { TFunction } from 'i18next';
import { FormInput, FormPassword } from '@/components/FormInput';
import type { ChannelProvider } from '../../const';
export const getLarkFormItems = (
t: TFunction<'agent'>,
hasConfig: boolean,
provider: ChannelProvider,
): FormItemProps[] => [
{
children: <FormInput placeholder={t('channel.applicationIdPlaceholder')} />,
desc: t('channel.applicationIdHint'),
label: t('channel.applicationId'),
name: 'applicationId',
rules: [{ required: true }],
tag: provider.fieldTags.appId,
},
{
children: (
<FormPassword
autoComplete="new-password"
placeholder={
hasConfig ? t('channel.botTokenPlaceholderExisting') : t('channel.appSecretPlaceholder')
}
/>
),
desc: t('channel.botTokenEncryptedHint'),
label: t('channel.appSecret'),
name: 'appSecret',
rules: [{ required: true }],
tag: provider.fieldTags.appSecret,
},
{
children: <FormInput placeholder={t('channel.verificationTokenPlaceholder')} />,
desc: t('channel.verificationTokenHint'),
label: t('channel.verificationToken'),
name: 'verificationToken',
tag: provider.fieldTags.verificationToken,
},
{
children: <FormPassword placeholder={t('channel.encryptKeyPlaceholder')} />,
desc: t('channel.encryptKeyHint'),
label: t('channel.encryptKey'),
name: 'encryptKey',
tag: provider.fieldTags.encryptKey,
},
];
@@ -1,46 +0,0 @@
import type { FormItemProps } from '@lobehub/ui';
import type { TFunction } from 'i18next';
import { FormInput, FormPassword } from '@/components/FormInput';
import type { ChannelProvider } from '../../const';
export const getTelegramFormItems = (
t: TFunction<'agent'>,
hasConfig: boolean,
provider: ChannelProvider,
): FormItemProps[] => [
{
children: (
<FormPassword
autoComplete="new-password"
placeholder={
hasConfig ? t('channel.botTokenPlaceholderExisting') : t('channel.botTokenPlaceholderNew')
}
/>
),
desc: t('channel.botTokenEncryptedHint'),
label: t('channel.botToken'),
name: 'botToken',
rules: [{ required: true }],
tag: provider.fieldTags.token,
},
{
children: <FormPassword placeholder={t('channel.secretTokenPlaceholder')} />,
desc: t('channel.secretTokenHint'),
label: t('channel.secretToken'),
name: 'secretToken',
tag: provider.fieldTags.secretToken,
},
...(process.env.NODE_ENV === 'development'
? ([
{
children: <FormInput placeholder="https://xxx.trycloudflare.com" />,
desc: t('channel.devWebhookProxyUrlHint'),
label: t('channel.devWebhookProxyUrl'),
name: 'webhookProxyUrl',
rules: [{ type: 'url' as const }],
},
] as FormItemProps[])
: []),
];
-34
View File
@@ -1,34 +0,0 @@
import { type SVGProps } from 'react';
interface IconProps extends SVGProps<SVGSVGElement> {
color?: string;
size?: number | string;
}
/**
* Lark / Feishu brand mark icon.
* Extracted from the official Lark word-mark SVG.
* Renders as monochrome (currentColor) by default.
*/
export const LarkIcon = ({
ref,
color,
size = 24,
style,
...props
}: IconProps & { ref?: React.RefObject<SVGSVGElement | null> }) => (
<svg
fill={color || 'currentColor'}
height={size}
ref={ref}
style={{ flexShrink: 0, ...style }}
viewBox="0 0 36 29"
width={size}
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M18.43 15.043l.088-.087c.056-.057.117-.117.177-.174l.122-.117.36-.356.495-.481.42-.417.395-.39.412-.408.378-.373.53-.52c.099-.1.203-.196.307-.291.191-.174.39-.343.59-.508a13.271 13.271 0 0 1 1.414-.976c.283-.165.573-.321.868-.469a11.562 11.562 0 0 1 1.345-.55c.083-.027.165-.057.252-.083A20.808 20.808 0 0 0 22.648.947a1.904 1.904 0 0 0-1.48-.707H5.962a.286.286 0 0 0-.17.516 44.38 44.38 0 0 1 12.604 14.326l.035-.04z" />
<path d="M12.386 28.427c7.853 0 14.695-4.334 18.261-10.738.126-.226.247-.451.364-.681a8.405 8.405 0 0 1-.837 1.31 9.404 9.404 0 0 1-.581.677 7.485 7.485 0 0 1-.911.815 6.551 6.551 0 0 1-.412.295 8.333 8.333 0 0 1-.555.343 7.887 7.887 0 0 1-1.754.72 7.58 7.58 0 0 1-.932.2c-.226.035-.46.06-.69.078-.243.017-.49.022-.738.022a8.826 8.826 0 0 1-.824-.052 9.901 9.901 0 0 1-.612-.087 7.81 7.81 0 0 1-.533-.113c-.096-.022-.187-.048-.282-.074a56.83 56.83 0 0 1-.781-.217c-.13-.039-.26-.073-.386-.112a22.1 22.1 0 0 1-.578-.178c-.156-.048-.312-.1-.468-.152-.148-.048-.3-.096-.447-.148l-.304-.104-.368-.13-.26-.095a18.462 18.462 0 0 1-.517-.191c-.1-.04-.2-.074-.3-.113l-.398-.156-.421-.17-.274-.112-.338-.14-.26-.107-.27-.118-.234-.104-.212-.095-.217-.1-.221-.104-.282-.13-.295-.14c-.104-.051-.209-.099-.313-.151l-.264-.13A43.902 43.902 0 0 1 .495 8.665.287.287 0 0 0 0 8.86l.009 13.42v1.089c0 .633.312 1.223.837 1.575a20.685 20.685 0 0 0 11.54 3.484z" />
<path d="M35.463 9.511a12.003 12.003 0 0 0-8.88-.672c-.083.026-.166.052-.252.082a12.415 12.415 0 0 0-2.213 1.015c-.29.17-.569.352-.842.547a11.063 11.063 0 0 0-1.163.937c-.104.096-.203.191-.308.29l-.529.521-.377.374-.412.407-.395.39-.421.417-.49.486-.36.356-.122.117a6.7 6.7 0 0 1-.178.174l-.087.087-.134.125-.152.14a21.037 21.037 0 0 1-4.33 3.066l.282.13.222.105.217.1.212.095.234.104.27.117.26.109.338.139.273.112.421.17c.13.052.265.104.4.156.1.039.199.073.299.113.173.065.347.125.516.19l.26.096c.122.043.243.087.37.13l.303.104c.147.048.295.1.447.148.156.052.312.1.468.152.191.06.386.117.577.177a51.658 51.658 0 0 0 1.167.33c.096.026.187.048.282.074.178.043.356.078.534.113.204.034.408.065.612.086a8.286 8.286 0 0 0 2.252-.048c.312-.047.624-.116.932-.199a7.619 7.619 0 0 0 1.15-.416 7.835 7.835 0 0 0 .89-.473c.095-.057.181-.117.268-.174.139-.095.278-.19.412-.295.117-.087.23-.178.339-.273a8.34 8.34 0 0 0 1.15-1.22 9.294 9.294 0 0 0 .833-1.302l.203-.402 1.814-3.614.021-.044a11.865 11.865 0 0 1 2.417-3.449z" />
</svg>
);
+25 -12
View File
@@ -9,7 +9,6 @@ import Loading from '@/components/Loading/BrandTextLoading';
import NavHeader from '@/features/NavHeader';
import { useAgentStore } from '@/store/agent';
import { CHANNEL_PROVIDERS } from './const';
import PlatformDetail from './detail';
import PlatformList from './list';
@@ -26,23 +25,33 @@ const styles = createStaticStyles(({ css }) => ({
const ChannelPage = memo(() => {
const { aid } = useParams<{ aid?: string }>();
const [activeProviderId, setActiveProviderId] = useState(CHANNEL_PROVIDERS[0].id);
const [activeProviderId, setActiveProviderId] = useState<string>('');
const { data: providers, isLoading } = useAgentStore((s) => s.useFetchBotProviders(aid));
const { data: platforms, isLoading: platformsLoading } = useAgentStore((s) =>
s.useFetchPlatformDefinitions(),
);
const { data: providers, isLoading: providersLoading } = useAgentStore((s) =>
s.useFetchBotProviders(aid),
);
const isLoading = platformsLoading || providersLoading;
// Default to first platform once loaded
const effectiveActiveId = activeProviderId || platforms?.[0]?.id || '';
const connectedPlatforms = useMemo(
() => new Set(providers?.map((p) => p.platform) ?? []),
[providers],
);
const activeProvider = useMemo(
() => CHANNEL_PROVIDERS.find((p) => p.id === activeProviderId) || CHANNEL_PROVIDERS[0],
[activeProviderId],
const activePlatformDef = useMemo(
() => platforms?.find((p) => p.id === effectiveActiveId) || platforms?.[0],
[platforms, effectiveActiveId],
);
const currentConfig = useMemo(
() => providers?.find((p) => p.platform === activeProviderId),
[providers, activeProviderId],
() => providers?.find((p) => p.platform === effectiveActiveId),
[providers, effectiveActiveId],
);
if (!aid) return null;
@@ -53,15 +62,19 @@ const ChannelPage = memo(() => {
<Flexbox flex={1} style={{ overflowY: 'auto' }}>
{isLoading && <Loading debugId="ChannelPage" />}
{!isLoading && (
{!isLoading && platforms && platforms.length > 0 && activePlatformDef && (
<div className={styles.container}>
<PlatformList
activeId={activeProviderId}
activeId={effectiveActiveId}
connectedPlatforms={connectedPlatforms}
providers={CHANNEL_PROVIDERS}
platforms={platforms}
onSelect={setActiveProviderId}
/>
<PlatformDetail agentId={aid} currentConfig={currentConfig} provider={activeProvider} />
<PlatformDetail
agentId={aid}
currentConfig={currentConfig}
platformDef={activePlatformDef}
/>
</div>
)}
</Flexbox>
+23 -22
View File
@@ -6,17 +6,11 @@ import { Info } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { ChannelProvider } from './const';
import type { SerializedPlatformDefinition } from '@/server/services/bot/platforms/types';
import { getPlatformIcon } from './const';
const styles = createStaticStyles(({ css, cssVar }) => ({
root: css`
display: flex;
flex-direction: column;
flex-shrink: 0;
width: 260px;
border-inline-end: 1px solid ${cssVar.colorBorder};
`,
item: css`
cursor: pointer;
@@ -58,6 +52,14 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
padding: 12px;
padding-block-start: 16px;
`,
root: css`
display: flex;
flex-direction: column;
flex-shrink: 0;
width: 260px;
border-inline-end: 1px solid ${cssVar.colorBorder};
`,
statusDot: css`
width: 8px;
height: 8px;
@@ -78,11 +80,11 @@ interface PlatformListProps {
activeId: string;
connectedPlatforms: Set<string>;
onSelect: (id: string) => void;
providers: ChannelProvider[];
platforms: SerializedPlatformDefinition[];
}
const PlatformList = memo<PlatformListProps>(
({ providers, activeId, connectedPlatforms, onSelect }) => {
({ platforms, activeId, connectedPlatforms, onSelect }) => {
const { t } = useTranslation('agent');
const theme = useTheme();
@@ -90,20 +92,19 @@ const PlatformList = memo<PlatformListProps>(
<aside className={styles.root}>
<div className={styles.list}>
<div className={styles.title}>{t('channel.platforms')}</div>
{providers.map((provider) => {
const ProviderIcon = provider.icon;
{platforms.map((platform) => {
const PlatformIcon = getPlatformIcon(platform.name);
const ColorIcon =
PlatformIcon && 'Color' in PlatformIcon ? (PlatformIcon as any).Color : PlatformIcon;
return (
<button
className={cx(styles.item, activeId === provider.id && 'active')}
key={provider.id}
onClick={() => onSelect(provider.id)}
className={cx(styles.item, activeId === platform.id && 'active')}
key={platform.id}
onClick={() => onSelect(platform.id)}
>
<ProviderIcon
color={activeId === provider.id ? provider.color : theme.colorTextSecondary}
size={20}
/>
<span style={{ flex: 1 }}>{provider.name}</span>
{connectedPlatforms.has(provider.id) && <div className={styles.statusDot} />}
{ColorIcon && <ColorIcon size={20} />}
<span style={{ flex: 1 }}>{platform.name}</span>
{connectedPlatforms.has(platform.id) && <div className={styles.statusDot} />}
</button>
);
})}
@@ -5,6 +5,7 @@ import { AgentBotProviderModel } from '@/database/models/agentBotProvider';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { platformRegistry } from '@/server/services/bot/platforms';
import { GatewayService } from '@/server/services/gateway';
const agentBotProviderProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
@@ -19,6 +20,10 @@ const agentBotProviderProcedure = authedProcedure.use(serverDatabase).use(async
});
export const agentBotProviderRouter = router({
listPlatforms: authedProcedure.query(() => {
return platformRegistry.listSerializedPlatforms();
}),
create: agentBotProviderProcedure
.input(
z.object({
@@ -72,7 +77,7 @@ export const agentBotProviderRouter = router({
.input(z.object({ applicationId: z.string(), platform: z.string() }))
.mutation(async ({ input, ctx }) => {
const service = new GatewayService();
const status = await service.startBot(input.platform, input.applicationId, ctx.userId);
const status = await service.startClient(input.platform, input.applicationId, ctx.userId);
return { status };
}),
+201 -56
View File
@@ -13,6 +13,7 @@ import { isQueueAgentRuntimeEnabled } from '@/server/services/queue/impls';
import { SystemAgentService } from '@/server/services/systemAgent';
import { formatPrompt as formatPromptUtil } from './formatPrompt';
import type { PlatformClient } from './platforms';
import {
renderError,
renderFinalReply,
@@ -66,21 +67,6 @@ async function safeReaction(fn: () => Promise<void>, label: string): Promise<voi
}
}
/**
* Extract the parent channel thread ID for reacting to the original mention message.
* In Discord, when a thread is created on a message, that message still belongs to
* the parent channel. To add/remove reactions on it, we need to use the parent channel ID.
*
* e.g. "discord:guild:parentChannel:thread" → "discord:guild:parentChannel"
*/
function parentChannelThreadId(threadId: string): string {
const parts = threadId.split(':');
if (parts.length >= 4 && parts[0] === 'discord') {
return `discord:${parts[1]}:${parts[2]}`;
}
return threadId;
}
interface DiscordChannelContext {
channel: { id: string; name?: string; topic?: string; type?: number };
guild: { id: string };
@@ -94,6 +80,7 @@ interface ThreadState {
interface BridgeHandlerOpts {
agentId: string;
botContext?: ChatTopicBotContext;
client?: PlatformClient;
}
/**
@@ -111,6 +98,102 @@ export class AgentBridgeService {
private timezone: string | undefined;
private timezoneLoaded = false;
/**
* Tracks threads that have an active agent execution in progress.
* In queue mode the Chat SDK lock is released before the agent finishes,
* so we need our own guard to prevent duplicate executions on the same thread.
*/
private static activeThreads = new Set<string>();
/**
* Debounce buffer for incoming messages per thread.
* Users often send multiple short messages in quick succession (e.g. "hello" + "how are you").
* Instead of triggering separate agent executions for each, we collect messages arriving
* within a short window and merge them into a single prompt.
*/
private static pendingMessages = new Map<
string,
{
messages: Message[];
resolve: () => void;
timer: ReturnType<typeof setTimeout>;
}
>();
/** Default debounce window (ms). Platforms can override via settings.debounceMs. */
private static readonly DEFAULT_DEBOUNCE_MS = 2000;
/**
* Buffer a message and return a promise that resolves when the debounce window closes.
* Returns the collected messages if this call "wins" the debounce (is the first),
* or null if the message was appended to an existing pending batch.
*
* Messages with attachments flush immediately (no debounce) to avoid delaying
* file-heavy interactions.
*/
private static bufferMessage(
threadId: string,
message: Message,
debounceMs: number,
): Promise<Message[] | null> {
// Flush immediately if the message has attachments
const hasAttachments = !!(message as any).attachments?.length;
const existing = AgentBridgeService.pendingMessages.get(threadId);
if (existing) {
// Append to existing batch and reset the timer
existing.messages.push(message);
clearTimeout(existing.timer);
if (hasAttachments) {
// Flush now
existing.resolve();
} else {
existing.timer = setTimeout(() => existing.resolve(), debounceMs);
}
return Promise.resolve(null); // not the owner
}
// First message — create a new batch
if (hasAttachments) {
return Promise.resolve([message]); // no debounce
}
return new Promise<Message[]>((resolve) => {
const batch = {
messages: [message],
resolve: () => {
const entry = AgentBridgeService.pendingMessages.get(threadId);
AgentBridgeService.pendingMessages.delete(threadId);
resolve(entry?.messages ?? [message]);
},
timer: setTimeout(() => {
const entry = AgentBridgeService.pendingMessages.get(threadId);
if (entry) entry.resolve();
}, debounceMs),
};
AgentBridgeService.pendingMessages.set(threadId, batch);
});
}
/**
* Merge multiple messages into a single synthetic Message for the agent.
* Preserves the first message's metadata (author, raw, attachments) and
* concatenates all text with newlines.
*/
private static mergeMessages(messages: Message[]): Message {
if (messages.length === 1) return messages[0];
const first = messages[0];
const mergedText = messages.map((m) => m.text).join('\n');
return Object.assign(Object.create(Object.getPrototypeOf(first)), first, {
text: mergedText,
});
}
constructor(db: LobeChatDatabase, userId: string) {
this.db = db;
this.userId = userId;
@@ -133,15 +216,48 @@ export class AgentBridgeService {
message.text.slice(0, 80),
);
// Skip if there's already an active execution for this thread
if (AgentBridgeService.activeThreads.has(thread.id)) {
log('handleMention: skipping, thread=%s already has an active execution', thread.id);
return;
}
// Debounce: buffer rapid-fire messages and merge them into one prompt.
// The first caller wins and drives the execution; subsequent callers
// append their message to the buffer and return immediately.
// TODO: resolve debounceMs from settings when entry-based registry is wired
const batch = await AgentBridgeService.bufferMessage(
thread.id,
message,
AgentBridgeService.DEFAULT_DEBOUNCE_MS,
);
if (!batch) {
log('handleMention: message buffered for thread=%s, waiting for debounce', thread.id);
return;
}
const mergedMessage = AgentBridgeService.mergeMessages(batch);
log(
'handleMention: debounce done, %d message(s) merged for thread=%s',
batch.length,
thread.id,
);
AgentBridgeService.activeThreads.add(thread.id);
// Immediate feedback: mark as received + show typing
// The mention message lives in the parent channel (not the thread), so we strip
// the thread segment from the ID to target the parent channel for reactions.
const { client } = opts;
await safeReaction(
() =>
thread.adapter.addReaction(parentChannelThreadId(thread.id), message.id, RECEIVED_EMOJI),
() => thread.adapter.addReaction(thread.id, message.id, RECEIVED_EMOJI),
'add eyes',
);
await thread.subscribe();
// Auto-subscribe to thread (platforms can opt out, e.g. Discord top-level channels)
const subscribe = client?.shouldSubscribe?.(thread.id) ?? true;
if (subscribe) {
await thread.subscribe();
}
await thread.startTyping();
// Keep typing indicator alive (Telegram's expires after ~5s)
@@ -157,16 +273,17 @@ export class AgentBridgeService {
try {
// executeWithCallback handles progress message (post + edit at each step)
// The final reply is edited into the progress message by onComplete
const { topicId } = await this.executeWithCallback(thread, message, {
const { topicId } = await this.executeWithCallback(thread, mergedMessage, {
agentId,
botContext,
channelContext,
reactionThreadId: parentChannelThreadId(thread.id),
client,
trigger: 'bot',
});
// Persist topic mapping and channel context in thread state for follow-up messages
if (topicId) {
// Skip if the platform opted out of auto-subscribe (no subscribe = no follow-up)
if (topicId && subscribe) {
await thread.setState({ channelContext, topicId });
log('handleMention: stored topicId=%s in thread=%s state', topicId, thread.id);
}
@@ -175,11 +292,11 @@ export class AgentBridgeService {
const msg = error instanceof Error ? error.message : String(error);
await thread.post(`**Agent Execution Failed**\n\`\`\`\n${msg}\n\`\`\``);
} finally {
AgentBridgeService.activeThreads.delete(thread.id);
clearInterval(typingInterval);
// In queue mode, reaction is removed by the bot-callback webhook on completion
if (!queueMode) {
// Mention message is in parent channel
await this.removeReceivedReaction(thread, message, parentChannelThreadId(thread.id));
await this.removeReceivedReaction(thread, message);
}
}
}
@@ -203,6 +320,36 @@ export class AgentBridgeService {
return this.handleMention(thread, message, { agentId, botContext });
}
// Skip if there's already an active execution for this thread
if (AgentBridgeService.activeThreads.has(thread.id)) {
log(
'handleSubscribedMessage: skipping, thread=%s already has an active execution',
thread.id,
);
return;
}
// Debounce: same as handleMention — merge rapid-fire messages
// TODO: resolve debounceMs from settings when entry-based registry is wired
const batch = await AgentBridgeService.bufferMessage(
thread.id,
message,
AgentBridgeService.DEFAULT_DEBOUNCE_MS,
);
if (!batch) {
log('handleSubscribedMessage: message buffered for thread=%s', thread.id);
return;
}
const mergedMessage = AgentBridgeService.mergeMessages(batch);
log(
'handleSubscribedMessage: debounce done, %d message(s) merged for thread=%s',
batch.length,
thread.id,
);
AgentBridgeService.activeThreads.add(thread.id);
// Read cached channel context from thread state
const channelContext = threadState?.channelContext;
@@ -223,10 +370,11 @@ export class AgentBridgeService {
try {
// executeWithCallback handles progress message (post + edit at each step)
await this.executeWithCallback(thread, message, {
await this.executeWithCallback(thread, mergedMessage, {
agentId,
botContext,
channelContext,
client: opts.client,
topicId,
trigger: 'bot',
});
@@ -246,6 +394,7 @@ export class AgentBridgeService {
log('handleSubscribedMessage error: %O', error);
await thread.post(`**Agent Execution Failed**. Details:\n\`\`\`\n${errMsg}\n\`\`\``);
} finally {
AgentBridgeService.activeThreads.delete(thread.id);
clearInterval(typingInterval);
// In queue mode, reaction is removed by the bot-callback webhook on completion
if (!queueMode) {
@@ -264,8 +413,7 @@ export class AgentBridgeService {
agentId: string;
botContext?: ChatTopicBotContext;
channelContext?: DiscordChannelContext;
/** Thread ID to use for removing the user message reaction in queue mode */
reactionThreadId?: string;
client?: PlatformClient;
topicId?: string;
trigger?: string;
},
@@ -288,12 +436,12 @@ export class AgentBridgeService {
agentId: string;
botContext?: ChatTopicBotContext;
channelContext?: DiscordChannelContext;
reactionThreadId?: string;
client?: PlatformClient;
topicId?: string;
trigger?: string;
},
): Promise<{ reply: string; topicId: string }> {
const { agentId, botContext, channelContext, reactionThreadId, topicId, trigger } = opts;
const { agentId, botContext, channelContext, client, topicId, trigger } = opts;
const aiAgentService = new AiAgentService(this.db, this.userId);
const timezone = await this.loadTimezone();
@@ -323,20 +471,15 @@ export class AgentBridgeService {
}
const callbackUrl = urlJoin(baseURL, '/api/agent/webhooks/bot-callback');
// Shared webhook body with bot context
// reactionChannelId: the Discord channel where the user message lives (for reaction removal).
// For mention messages this is the parent channel; for thread messages it's the thread itself.
const reactionChannelId = reactionThreadId ? reactionThreadId.split(':')[2] : undefined;
const webhookBody = {
applicationId: botContext?.applicationId,
platformThreadId: botContext?.platformThreadId,
progressMessageId,
reactionChannelId,
userMessageId: userMessage.id,
};
const files = this.extractFiles(userMessage);
const prompt = this.formatPrompt(userMessage, botContext);
const prompt = this.formatPrompt(userMessage, client);
log(
'executeWithWebhooks: agentId=%s, callbackUrl=%s, progressMessageId=%s, prompt=%s, files=%d',
@@ -385,11 +528,12 @@ export class AgentBridgeService {
agentId: string;
botContext?: ChatTopicBotContext;
channelContext?: DiscordChannelContext;
client?: PlatformClient;
topicId?: string;
trigger?: string;
},
): Promise<{ reply: string; topicId: string }> {
const { agentId, botContext, channelContext, topicId, trigger } = opts;
const { agentId, botContext, channelContext, client, topicId, trigger } = opts;
const aiAgentService = new AiAgentService(this.db, this.userId);
const timezone = await this.loadTimezone();
@@ -402,8 +546,6 @@ export class AgentBridgeService {
log('executeWithInMemoryCallbacks: failed to post progress message: %O', error);
}
const platform = botContext?.platform;
// Track the last LLM content and tool calls for showing during tool execution
let lastLLMContent = '';
let lastToolsCalling:
@@ -423,7 +565,7 @@ export class AgentBridgeService {
const getElapsedMs = () => (operationStartTime > 0 ? Date.now() - operationStartTime : 0);
const files = this.extractFiles(userMessage);
const prompt = this.formatPrompt(userMessage, botContext);
const prompt = this.formatPrompt(userMessage, client);
log(
'executeWithInMemoryCallbacks: agentId=%s, prompt=%s, files=%d',
@@ -451,15 +593,21 @@ export class AgentBridgeService {
if (toolsCalling) totalToolCalls += toolsCalling.length;
const progressText = renderStepProgress({
const msgBody = renderStepProgress({
...stepData,
elapsedMs: getElapsedMs(),
lastContent: lastLLMContent,
lastToolsCalling,
platform,
totalToolCalls,
});
const stats = {
elapsedMs: getElapsedMs(),
totalCost: stepData.totalCost ?? 0,
totalTokens: stepData.totalTokens ?? 0,
};
const progressText = client?.formatReply?.(msgBody, stats) ?? msgBody;
if (content) lastLLMContent = content;
if (toolsCalling) lastToolsCalling = toolsCalling;
@@ -498,18 +646,18 @@ export class AgentBridgeService {
)?.content;
if (lastAssistantContent) {
const finalText = renderFinalReply(lastAssistantContent, {
const replyBody = renderFinalReply(lastAssistantContent);
const replyStats = {
elapsedMs: getElapsedMs(),
llmCalls: finalState.usage?.llm?.apiCalls ?? 0,
platform,
toolCalls: finalState.usage?.tools?.totalCalls ?? 0,
totalCost: finalState.cost?.total ?? 0,
totalTokens: finalState.usage?.llm?.tokens?.total ?? 0,
});
};
const finalText = client?.formatReply?.(replyBody, replyStats) ?? replyBody;
// Telegram supports 4096 chars vs Discord's 2000
const charLimit = platform === 'telegram' ? 4000 : undefined;
const chunks = splitMessage(finalText, charLimit);
// TODO: resolve charLimit from settings when entry-based registry is wired
const chunks = splitMessage(finalText);
if (progressMessage) {
try {
@@ -694,8 +842,10 @@ export class AgentBridgeService {
* Format user message into agent prompt.
* Delegates to the standalone formatPrompt utility.
*/
private formatPrompt(message: Message, botContext?: ChatTopicBotContext): string {
return formatPromptUtil(message as any, botContext);
private formatPrompt(message: Message, client?: PlatformClient): string {
return formatPromptUtil(message as any, {
sanitizeUserInput: client?.sanitizeUserInput?.bind(client),
});
}
/**
@@ -720,18 +870,13 @@ export class AgentBridgeService {
/**
* Remove the received reaction from a user message (fire-and-forget).
* @param reactionThreadId - The thread ID to use for the reaction API call.
* For messages in parent channels (handleMention), use parentChannelThreadId(thread.id).
* For messages inside threads (handleSubscribedMessage), use thread.id directly.
*/
private async removeReceivedReaction(
thread: Thread<ThreadState>,
message: Message,
reactionThreadId?: string,
): Promise<void> {
await safeReaction(
() =>
thread.adapter.removeReaction(reactionThreadId ?? thread.id, message.id, RECEIVED_EMOJI),
() => thread.adapter.removeReaction(thread.id, message.id, RECEIVED_EMOJI),
'remove eyes',
);
}
+42 -133
View File
@@ -6,84 +6,12 @@ import { type LobeChatDatabase } from '@/database/type';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { SystemAgentService } from '@/server/services/systemAgent';
import { DiscordRestApi } from './discordRestApi';
import { LarkRestApi } from './larkRestApi';
import type { BotProviderConfig, PlatformClient, PlatformMessenger, UsageStats } from './platforms';
import { platformRegistry } from './platforms';
import { renderError, renderFinalReply, renderStepProgress, splitMessage } from './replyTemplate';
import { TelegramRestApi } from './telegramRestApi';
const log = debug('lobe-server:bot:callback');
// --------------- Platform helpers ---------------
function extractDiscordChannelId(platformThreadId: string): string {
const parts = platformThreadId.split(':');
return parts[3] || parts[2];
}
function extractTelegramChatId(platformThreadId: string): string {
return platformThreadId.split(':')[1];
}
function extractLarkChatId(platformThreadId: string): string {
return platformThreadId.split(':')[1];
}
function parseTelegramMessageId(compositeId: string): number {
const colonIdx = compositeId.lastIndexOf(':');
return colonIdx !== -1 ? Number(compositeId.slice(colonIdx + 1)) : Number(compositeId);
}
const TELEGRAM_CHAR_LIMIT = 4000;
const LARK_CHAR_LIMIT = 4000;
// --------------- Platform-agnostic messenger ---------------
interface PlatformMessenger {
createMessage: (content: string) => Promise<void>;
editMessage: (messageId: string, content: string) => Promise<void>;
removeReaction: (messageId: string, emoji: string) => Promise<void>;
triggerTyping: () => Promise<void>;
updateThreadName?: (name: string) => Promise<void>;
}
function createDiscordMessenger(
discord: DiscordRestApi,
channelId: string,
platformThreadId: string,
): PlatformMessenger {
return {
createMessage: (content) => discord.createMessage(channelId, content).then(() => {}),
editMessage: (messageId, content) => discord.editMessage(channelId, messageId, content),
removeReaction: (messageId, emoji) => discord.removeOwnReaction(channelId, messageId, emoji),
triggerTyping: () => discord.triggerTyping(channelId),
updateThreadName: (name) => {
const threadId = platformThreadId.split(':')[3];
return threadId ? discord.updateChannelName(threadId, name) : Promise.resolve();
},
};
}
function createTelegramMessenger(telegram: TelegramRestApi, chatId: string): PlatformMessenger {
return {
createMessage: (content) => telegram.sendMessage(chatId, content).then(() => {}),
editMessage: (messageId, content) =>
telegram.editMessageText(chatId, parseTelegramMessageId(messageId), content),
removeReaction: (messageId) =>
telegram.removeMessageReaction(chatId, parseTelegramMessageId(messageId)),
triggerTyping: () => telegram.sendChatAction(chatId, 'typing'),
};
}
function createLarkMessenger(lark: LarkRestApi, chatId: string): PlatformMessenger {
return {
createMessage: (content) => lark.sendMessage(chatId, content).then(() => {}),
editMessage: (messageId, content) => lark.editMessage(messageId, content),
// Lark has no reaction/typing API for bots
removeReaction: () => Promise.resolve(),
triggerTyping: () => Promise.resolve(),
};
}
// --------------- Callback body types ---------------
export interface BotCallbackBody {
@@ -100,7 +28,6 @@ export interface BotCallbackBody {
llmCalls?: number;
platformThreadId: string;
progressMessageId: string;
reactionChannelId?: string;
reason?: string;
reasoning?: string;
shouldContinue?: boolean;
@@ -135,17 +62,17 @@ export class BotCallbackService {
const { type, applicationId, platformThreadId, progressMessageId } = body;
const platform = platformThreadId.split(':')[0];
const { botToken, messenger, charLimit } = await this.createMessenger(
const { client, messenger, charLimit } = await this.createMessenger(
platform,
applicationId,
platformThreadId,
);
if (type === 'step') {
await this.handleStep(body, messenger, progressMessageId, platform);
await this.handleStep(body, messenger, progressMessageId, client);
} else if (type === 'completion') {
await this.handleCompletion(body, messenger, progressMessageId, platform, charLimit);
await this.removeEyesReaction(body, messenger, botToken, platform, platformThreadId);
await this.handleCompletion(body, messenger, progressMessageId, client, charLimit);
await this.removeEyesReaction(body, messenger);
this.summarizeTopicTitle(body, messenger);
}
}
@@ -154,7 +81,7 @@ export class BotCallbackService {
platform: string,
applicationId: string,
platformThreadId: string,
): Promise<{ botToken: string; charLimit?: number; messenger: PlatformMessenger }> {
): Promise<{ charLimit?: number; messenger: PlatformMessenger; client: PlatformClient }> {
const row = await AgentBotProviderModel.findByPlatformAndAppId(
this.db,
platform,
@@ -173,59 +100,41 @@ export class BotCallbackService {
credentials = JSON.parse(row.credentials);
}
const isLark = platform === 'lark' || platform === 'feishu';
if (isLark ? !credentials.appId || !credentials.appSecret : !credentials.botToken) {
throw new Error(`Bot credentials incomplete for ${platform} appId=${applicationId}`);
const entry = platformRegistry.getPlatform(platform);
if (!entry) {
throw new Error(`Unsupported platform: ${platform}`);
}
switch (platform) {
case 'telegram': {
const telegram = new TelegramRestApi(credentials.botToken);
const chatId = extractTelegramChatId(platformThreadId);
return {
botToken: credentials.botToken,
charLimit: TELEGRAM_CHAR_LIMIT,
messenger: createTelegramMessenger(telegram, chatId),
};
}
case 'lark':
case 'feishu': {
const lark = new LarkRestApi(credentials.appId, credentials.appSecret, platform);
const chatId = extractLarkChatId(platformThreadId);
return {
botToken: credentials.appId,
charLimit: LARK_CHAR_LIMIT,
messenger: createLarkMessenger(lark, chatId),
};
}
case 'discord':
default: {
const discord = new DiscordRestApi(credentials.botToken);
const channelId = extractDiscordChannelId(platformThreadId);
return {
botToken: credentials.botToken,
messenger: createDiscordMessenger(discord, channelId, platformThreadId),
};
}
}
const settings = (row as any).settings as Record<string, unknown> | undefined;
const charLimit = (settings?.charLimit as number) || undefined;
const config: BotProviderConfig = {
applicationId,
credentials,
platform,
settings: settings || {},
};
const client = entry.clientFactory.createClient(config, {});
const messenger = client.getMessenger(platformThreadId);
return { charLimit, messenger, client };
}
private async handleStep(
body: BotCallbackBody,
messenger: PlatformMessenger,
progressMessageId: string,
platform: string,
client: PlatformClient,
): Promise<void> {
if (!body.shouldContinue) return;
const progressText = renderStepProgress({
const msgBody = renderStepProgress({
content: body.content,
elapsedMs: body.elapsedMs,
executionTimeMs: body.executionTimeMs ?? 0,
lastContent: body.lastLLMContent,
lastToolsCalling: body.lastToolsCalling,
platform,
reasoning: body.reasoning,
stepType: body.stepType ?? ('call_llm' as const),
thinking: body.thinking ?? false,
@@ -239,6 +148,14 @@ export class BotCallbackService {
totalToolCalls: body.totalToolCalls,
});
const stats: UsageStats = {
elapsedMs: body.elapsedMs,
totalCost: body.totalCost ?? 0,
totalTokens: body.totalTokens ?? 0,
};
const progressText = client.formatReply?.(msgBody, stats) ?? msgBody;
const isLlmFinalResponse =
body.stepType === 'call_llm' && !body.toolsCalling?.length && body.content;
@@ -256,7 +173,7 @@ export class BotCallbackService {
body: BotCallbackBody,
messenger: PlatformMessenger,
progressMessageId: string,
platform: string,
client: PlatformClient,
charLimit?: number,
): Promise<void> {
const { reason, lastAssistantContent, errorMessage } = body;
@@ -276,15 +193,17 @@ export class BotCallbackService {
return;
}
const finalText = renderFinalReply(lastAssistantContent, {
const msgBody = renderFinalReply(lastAssistantContent);
const stats: UsageStats = {
elapsedMs: body.duration,
llmCalls: body.llmCalls ?? 0,
platform,
toolCalls: body.toolCalls ?? 0,
totalCost: body.cost ?? 0,
totalTokens: body.totalTokens ?? 0,
});
};
const finalText = client.formatReply?.(msgBody, stats) ?? msgBody;
const chunks = splitMessage(finalText, charLimit);
try {
@@ -300,22 +219,12 @@ export class BotCallbackService {
private async removeEyesReaction(
body: BotCallbackBody,
messenger: PlatformMessenger,
botToken: string,
platform: string,
platformThreadId: string,
): Promise<void> {
const { userMessageId, reactionChannelId } = body;
const { userMessageId } = body;
if (!userMessageId) return;
try {
if (platform === 'discord') {
// Use reactionChannelId (parent channel for mentions, thread for follow-ups)
const discord = new DiscordRestApi(botToken);
const targetChannelId = reactionChannelId || extractDiscordChannelId(platformThreadId);
await discord.removeOwnReaction(targetChannelId, userMessageId, '👀');
} else {
await messenger.removeReaction(userMessageId, '👀');
}
await messenger.removeReaction(userMessageId, '👀');
} catch (error) {
log('removeEyesReaction: failed: %O', error);
}
+184 -359
View File
@@ -1,19 +1,23 @@
import { createDiscordAdapter } from '@chat-adapter/discord';
import { createIoRedisState } from '@chat-adapter/state-ioredis';
import { createTelegramAdapter } from '@chat-adapter/telegram';
import { createLarkAdapter } from '@lobechat/adapter-lark';
import { Chat, ConsoleLogger } from 'chat';
import debug from 'debug';
import { getServerDB } from '@/database/core/db-adaptor';
import type { DecryptedBotProvider } from '@/database/models/agentBotProvider';
import { AgentBotProviderModel } from '@/database/models/agentBotProvider';
import type { LobeChatDatabase } from '@/database/type';
import { appEnv } from '@/envs/app';
import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { AgentBridgeService } from './AgentBridgeService';
import { setTelegramWebhook } from './platforms/telegram';
import {
type BotPlatformRuntimeContext,
type BotProviderConfig,
buildRuntimeKey,
type PlatformClient,
type PlatformDefinition,
platformRegistry,
} from './platforms';
const log = debug('lobe-server:bot:message-router');
@@ -22,416 +26,231 @@ interface ResolvedAgentInfo {
userId: string;
}
interface StoredCredentials {
[key: string]: string;
}
/**
* Adapter factory: creates the correct Chat SDK adapter from platform + credentials.
*/
function createAdapterForPlatform(
platform: string,
credentials: StoredCredentials,
applicationId: string,
): Record<string, any> | null {
switch (platform) {
case 'discord': {
return {
discord: createDiscordAdapter({
applicationId,
botToken: credentials.botToken,
publicKey: credentials.publicKey,
}),
};
}
case 'telegram': {
return {
telegram: createTelegramAdapter({
botToken: credentials.botToken,
secretToken: credentials.secretToken,
}),
};
}
case 'lark':
case 'feishu': {
return {
[platform]: createLarkAdapter({
appId: credentials.appId,
appSecret: credentials.appSecret,
encryptKey: credentials.encryptKey,
platform: platform as 'lark' | 'feishu',
verificationToken: credentials.verificationToken,
}),
};
}
default: {
return null;
}
}
interface RegisteredBot {
agentInfo: ResolvedAgentInfo;
chatBot: Chat<any>;
client: PlatformClient;
}
/**
* Routes incoming webhook events to the correct Chat SDK Bot instance
* and triggers message processing via AgentBridgeService.
*
* All platforms require appId in the webhook URL:
* POST /api/agent/webhooks/[platform]/[appId]
*
* Bots are loaded on-demand: only the bot targeted by the incoming webhook
* is created, not all bots across all platforms.
*/
export class BotMessageRouter {
/** botToken → Chat instance (for Discord webhook routing via x-discord-gateway-token) */
private botInstancesByToken = new Map<string, Chat<any>>();
/** "platform:applicationId" → registered bot */
private bots = new Map<string, RegisteredBot>();
/** "platform:applicationId" → { agentId, userId } */
private agentMap = new Map<string, ResolvedAgentInfo>();
/** "platform:applicationId" → Chat instance */
private botInstances = new Map<string, Chat<any>>();
/** "platform:applicationId" → credentials */
private credentialsByKey = new Map<string, StoredCredentials>();
/** Per-key init promises to avoid duplicate concurrent loading */
private loadingPromises = new Map<string, Promise<RegisteredBot | null>>();
// ------------------------------------------------------------------
// Public API
// ------------------------------------------------------------------
/**
* Get the webhook handler for a given platform.
* Get the webhook handler for a given platform + appId.
* Returns a function compatible with Next.js Route Handler: `(req: Request) => Promise<Response>`
*
* @param appId Optional application ID for direct bot lookup (e.g. Telegram bot-specific endpoints).
*/
getWebhookHandler(platform: string, appId?: string): (req: Request) => Promise<Response> {
return async (req: Request) => {
await this.ensureInitialized();
switch (platform) {
case 'discord': {
return this.handleDiscordWebhook(req);
}
case 'telegram': {
return this.handleTelegramWebhook(req, appId);
}
case 'lark':
case 'feishu': {
return this.handleChatSdkWebhook(req, platform, appId);
}
default: {
return new Response('No bot configured for this platform', { status: 404 });
}
const entry = platformRegistry.getPlatform(platform);
if (!entry) {
return new Response('No bot configured for this platform', { status: 404 });
}
if (!appId) {
return new Response(`Missing appId for ${platform} webhook`, { status: 400 });
}
return this.handleWebhook(req, platform, appId);
};
}
// ------------------------------------------------------------------
// Discord webhook routing
// Webhook handling
// ------------------------------------------------------------------
private async handleDiscordWebhook(req: Request): Promise<Response> {
const bodyBuffer = await req.arrayBuffer();
private async handleWebhook(req: Request, platform: string, appId: string): Promise<Response> {
log('handleWebhook: platform=%s, appId=%s', platform, appId);
log('handleDiscordWebhook: method=%s, content-length=%d', req.method, bodyBuffer.byteLength);
// Check for forwarded Gateway event (from Gateway worker)
const gatewayToken = req.headers.get('x-discord-gateway-token');
if (gatewayToken) {
// Log forwarded event details
try {
const bodyText = new TextDecoder().decode(bodyBuffer);
const event = JSON.parse(bodyText);
if (event.type === 'GATEWAY_MESSAGE_CREATE') {
const d = event.data;
const mentions = d?.mentions?.map((m: any) => m.username).join(', ');
log(
'Gateway MESSAGE_CREATE: author=%s (bot=%s), mentions=[%s], content=%s',
d?.author?.username,
d?.author?.bot,
mentions || '',
d?.content?.slice(0, 100),
);
}
} catch {
// ignore parse errors
}
const bot = this.botInstancesByToken.get(gatewayToken);
if (bot?.webhooks && 'discord' in bot.webhooks) {
return bot.webhooks.discord(this.cloneRequest(req, bodyBuffer));
}
log('No matching bot for gateway token');
return new Response('No matching bot for gateway token', { status: 404 });
}
// HTTP Interactions — route by applicationId in the interaction payload
try {
const bodyText = new TextDecoder().decode(bodyBuffer);
const payload = JSON.parse(bodyText);
const appId = payload.application_id;
if (appId) {
const bot = this.botInstances.get(`discord:${appId}`);
if (bot?.webhooks && 'discord' in bot.webhooks) {
return bot.webhooks.discord(this.cloneRequest(req, bodyBuffer));
}
}
} catch {
// Not valid JSON — fall through
}
// Fallback: try all registered Discord bots
for (const [key, bot] of this.botInstances) {
if (!key.startsWith('discord:')) continue;
if (bot.webhooks && 'discord' in bot.webhooks) {
try {
const resp = await bot.webhooks.discord(this.cloneRequest(req, bodyBuffer));
if (resp.status !== 401) return resp;
} catch {
// signature mismatch — try next
}
}
}
return new Response('No bot configured for Discord', { status: 404 });
}
// ------------------------------------------------------------------
// Telegram webhook routing
// ------------------------------------------------------------------
private async handleTelegramWebhook(req: Request, appId?: string): Promise<Response> {
const bodyBuffer = await req.arrayBuffer();
log(
'handleTelegramWebhook: method=%s, appId=%s, content-length=%d',
req.method,
appId ?? '(none)',
bodyBuffer.byteLength,
);
// Log raw update for debugging
try {
const bodyText = new TextDecoder().decode(bodyBuffer);
const update = JSON.parse(bodyText);
const msg = update.message;
if (msg) {
log(
'Telegram update: chat_type=%s, from=%s (id=%s), text=%s',
msg.chat?.type,
msg.from?.username || msg.from?.first_name,
msg.from?.id,
msg.text?.slice(0, 100),
);
} else {
log('Telegram update (non-message): keys=%s', Object.keys(update).join(','));
}
} catch {
// ignore parse errors
}
// Direct lookup by applicationId (bot-specific endpoint: /webhooks/telegram/{appId})
if (appId) {
const key = `telegram:${appId}`;
const bot = this.botInstances.get(key);
if (bot?.webhooks && 'telegram' in bot.webhooks) {
log('handleTelegramWebhook: direct lookup hit for %s', key);
return bot.webhooks.telegram(this.cloneRequest(req, bodyBuffer));
}
log('handleTelegramWebhook: no bot registered for %s', key);
return new Response('No bot configured for Telegram', { status: 404 });
}
// Fallback: iterate all registered Telegram bots (legacy /webhooks/telegram endpoint).
// Secret token verification will reject mismatches.
for (const [key, bot] of this.botInstances) {
if (!key.startsWith('telegram:')) continue;
if (bot.webhooks && 'telegram' in bot.webhooks) {
try {
log('handleTelegramWebhook: trying bot %s', key);
const resp = await bot.webhooks.telegram(this.cloneRequest(req, bodyBuffer));
log('handleTelegramWebhook: bot %s responded with status=%d', key, resp.status);
if (resp.status !== 401) return resp;
} catch (error) {
log('handleTelegramWebhook: bot %s webhook error: %O', key, error);
}
}
}
log('handleTelegramWebhook: no matching bot found');
return new Response('No bot configured for Telegram', { status: 404 });
}
// ------------------------------------------------------------------
// Generic Chat SDK webhook routing (Lark/Feishu)
// ------------------------------------------------------------------
private async handleChatSdkWebhook(
req: Request,
platform: string,
appId?: string,
): Promise<Response> {
log('handleChatSdkWebhook: platform=%s, appId=%s', platform, appId);
const bodyBuffer = await req.arrayBuffer();
// Direct lookup by applicationId
if (appId) {
const key = `${platform}:${appId}`;
const bot = this.botInstances.get(key);
if (bot?.webhooks && platform in bot.webhooks) {
return (bot.webhooks as any)[platform](this.cloneRequest(req, bodyBuffer));
}
log('handleChatSdkWebhook: no bot registered for %s', key);
const bot = await this.getOrCreateBot(platform, appId);
if (!bot) {
return new Response(`No bot configured for ${platform}`, { status: 404 });
}
// Fallback: try all registered bots for this platform
for (const [key, bot] of this.botInstances) {
if (!key.startsWith(`${platform}:`)) continue;
if (bot.webhooks && platform in bot.webhooks) {
try {
const resp = await (bot.webhooks as any)[platform](this.cloneRequest(req, bodyBuffer));
if (resp.status !== 401) return resp;
} catch {
// try next
}
}
if (bot.chatBot.webhooks && platform in bot.chatBot.webhooks) {
return (bot.chatBot.webhooks as any)[platform](req);
}
return new Response(`No bot configured for ${platform}`, { status: 404 });
}
private cloneRequest(req: Request, body: ArrayBuffer): Request {
return new Request(req.url, {
body,
headers: req.headers,
method: req.method,
});
}
// ------------------------------------------------------------------
// Initialisation
// On-demand bot loading
// ------------------------------------------------------------------
private static REFRESH_INTERVAL_MS = 5 * 60_000;
/**
* Get an existing bot or create one on-demand from DB.
* Concurrent calls for the same key are deduplicated.
*/
private async getOrCreateBot(platform: string, appId: string): Promise<RegisteredBot | null> {
const key = buildRuntimeKey(platform, appId);
private initPromise: Promise<void> | null = null;
private lastLoadedAt = 0;
private refreshPromise: Promise<void> | null = null;
// Return cached bot
const existing = this.bots.get(key);
if (existing) return existing;
private async ensureInitialized(): Promise<void> {
if (!this.initPromise) {
this.initPromise = this.initialize();
}
await this.initPromise;
// Deduplicate concurrent loads for the same key
const inflight = this.loadingPromises.get(key);
if (inflight) return inflight;
// Periodically refresh bot mappings in the background so newly added bots are discovered
if (
Date.now() - this.lastLoadedAt > BotMessageRouter.REFRESH_INTERVAL_MS &&
!this.refreshPromise
) {
this.refreshPromise = this.loadAgentBots().finally(() => {
this.refreshPromise = null;
});
}
}
const promise = this.loadBot(platform, appId);
this.loadingPromises.set(key, promise);
async initialize(): Promise<void> {
log('Initializing BotMessageRouter');
await this.loadAgentBots();
log('Initialized: %d agent bots', this.botInstances.size);
}
// ------------------------------------------------------------------
// Per-agent bots from DB
// ------------------------------------------------------------------
private async loadAgentBots(): Promise<void> {
try {
return await promise;
} finally {
this.loadingPromises.delete(key);
}
}
private async loadBot(platform: string, appId: string): Promise<RegisteredBot | null> {
const key = buildRuntimeKey(platform, appId);
try {
const entry = platformRegistry.getPlatform(platform);
if (!entry) {
log('No definition for platform: %s', platform);
return null;
}
const serverDB = await getServerDB();
const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey();
// Load all supported platforms
for (const platform of ['discord', 'telegram', 'lark', 'feishu']) {
const providers = await AgentBotProviderModel.findEnabledByPlatform(
serverDB,
platform,
gateKeeper,
);
// Find the specific provider — search across all users
const providers = await AgentBotProviderModel.findEnabledByPlatform(
serverDB,
platform,
gateKeeper,
);
const provider = providers.find((p) => p.applicationId === appId);
log('Found %d %s bot providers in DB', providers.length, platform);
for (const provider of providers) {
const { agentId, userId, applicationId, credentials } = provider;
const key = `${platform}:${applicationId}`;
if (this.agentMap.has(key)) {
log('Skipping provider %s: already registered', key);
continue;
}
const adapters = createAdapterForPlatform(platform, credentials, applicationId);
if (!adapters) {
log('Unsupported platform: %s', platform);
continue;
}
const bot = this.createBot(adapters, `agent-${agentId}`);
this.registerHandlers(bot, serverDB, {
agentId,
applicationId,
platform,
userId,
});
await bot.initialize();
this.botInstances.set(key, bot);
this.agentMap.set(key, { agentId, userId });
this.credentialsByKey.set(key, credentials);
// Discord-specific: also index by botToken for gateway forwarding
if (platform === 'discord' && credentials.botToken) {
this.botInstancesByToken.set(credentials.botToken, bot);
}
// Telegram: call setWebhook to ensure Telegram-side secret_token
// stays in sync with the adapter config (idempotent, safe on every init)
if (platform === 'telegram' && credentials.botToken) {
const baseUrl = (credentials.webhookProxyUrl || appEnv.APP_URL || '').replace(
/\/$/,
'',
);
const webhookUrl = `${baseUrl}/api/agent/webhooks/telegram/${applicationId}`;
setTelegramWebhook(credentials.botToken, webhookUrl, credentials.secretToken).catch(
(err) => {
log('Failed to set Telegram webhook for appId=%s: %O', applicationId, err);
},
);
}
log('Created %s bot for agent=%s, appId=%s', platform, agentId, applicationId);
}
if (!provider) {
log('No enabled provider found for %s', key);
return null;
}
this.lastLoadedAt = Date.now();
const registered = await this.createAndRegisterBot(entry, provider, serverDB);
log('Created %s bot on-demand for agent=%s, appId=%s', platform, provider.agentId, appId);
return registered;
} catch (error) {
log('Failed to load agent bots: %O', error);
log('Failed to load bot %s: %O', key, error);
return null;
}
}
private async createAndRegisterBot(
entry: PlatformDefinition,
provider: DecryptedBotProvider,
serverDB: LobeChatDatabase,
): Promise<RegisteredBot> {
const { agentId, userId, applicationId, credentials } = provider;
const platform = entry.id;
const key = buildRuntimeKey(platform, applicationId);
const providerConfig: BotProviderConfig = {
applicationId,
credentials,
platform,
settings: (provider.settings as Record<string, unknown>) || {},
};
const runtimeContext: BotPlatformRuntimeContext = {
appUrl: process.env.APP_URL,
redisClient: getAgentRuntimeRedisClient() as any,
};
const client = entry.clientFactory.createClient(providerConfig, runtimeContext);
const adapters = client.createAdapter();
const chatBot = this.createChatBot(adapters, `agent-${agentId}`);
this.registerHandlers(chatBot, serverDB, client, {
agentId,
applicationId,
platform,
settings: provider.settings as Record<string, any> | undefined,
userId,
});
await chatBot.initialize();
const registered: RegisteredBot = {
agentInfo: { agentId, userId },
chatBot,
client,
};
this.bots.set(key, registered);
return registered;
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
private createBot(adapters: Record<string, any>, label: string): Chat<any> {
/**
* A proxy around the shared Redis client that suppresses duplicate `on('error', ...)`
* registrations. Each `createIoRedisState()` call adds an error listener to the client;
* with many bot instances sharing one client this would trigger
* MaxListenersExceededWarning. The proxy lets the first error listener through and
* silently drops subsequent ones, so it scales to any number of bots.
*/
private sharedRedisProxy: ReturnType<typeof getAgentRuntimeRedisClient> | undefined;
private getSharedRedisProxy() {
if (this.sharedRedisProxy !== undefined) return this.sharedRedisProxy;
const redisClient = getAgentRuntimeRedisClient();
if (!redisClient) {
this.sharedRedisProxy = null;
return null;
}
let errorListenerRegistered = false;
this.sharedRedisProxy = new Proxy(redisClient, {
get(target, prop, receiver) {
if (prop === 'on') {
return (event: string, listener: (...args: any[]) => void) => {
if (event === 'error') {
if (errorListenerRegistered) return target;
errorListenerRegistered = true;
}
return target.on(event, listener);
};
}
return Reflect.get(target, prop, receiver);
},
});
return this.sharedRedisProxy;
}
private createChatBot(adapters: Record<string, any>, label: string): Chat<any> {
const config: any = {
adapters,
userName: `lobehub-bot-${label}`,
};
const redisClient = getAgentRuntimeRedisClient();
if (redisClient) {
const redisProxy = this.getSharedRedisProxy();
if (redisProxy) {
config.state = createIoRedisState({
client: redisClient,
client: redisProxy,
keyPrefix: `chat-sdk:${label}`,
logger: new ConsoleLogger(),
});
@@ -443,7 +262,12 @@ export class BotMessageRouter {
private registerHandlers(
bot: Chat<any>,
serverDB: LobeChatDatabase,
info: ResolvedAgentInfo & { applicationId: string; platform: string },
client: PlatformClient,
info: ResolvedAgentInfo & {
applicationId: string;
platform: string;
settings?: Record<string, any>;
},
): void {
const { agentId, applicationId, platform, userId } = info;
const bridge = new AgentBridgeService(serverDB, userId);
@@ -459,6 +283,7 @@ export class BotMessageRouter {
await bridge.handleMention(thread, message, {
agentId,
botContext: { applicationId, platform, platformThreadId: thread.id },
client,
});
});
@@ -476,14 +301,13 @@ export class BotMessageRouter {
await bridge.handleSubscribedMessage(thread, message, {
agentId,
botContext: { applicationId, platform, platformThreadId: thread.id },
client,
});
});
// Telegram/Lark: handle messages in unsubscribed threads that aren't @mentions.
// This covers direct messages where users message the bot without an explicit @mention.
// Discord relies solely on onNewMention/onSubscribedMessage — registering a
// catch-all there would cause unsolicited replies in active channels.
if (platform === 'telegram' || platform === 'lark' || platform === 'feishu') {
// Register onNewMessage handler based on platform config
const dmEnabled = info.settings?.dm?.enabled ?? false;
if (dmEnabled) {
bot.onNewMessage(/./, async (thread, message) => {
if (message.author.isBot === true) return;
@@ -499,6 +323,7 @@ export class BotMessageRouter {
await bridge.handleMention(thread, message, {
agentId,
botContext: { applicationId, platform, platformThreadId: thread.id },
client,
});
});
}
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
// ==================== Import after mocks ====================
import type { BotCallbackBody } from '../BotCallbackService';
import { BotCallbackService } from '../BotCallbackService';
@@ -13,18 +12,36 @@ const mockFindById = vi.hoisted(() => vi.fn());
const mockTopicUpdate = vi.hoisted(() => vi.fn());
const mockGenerateTopicTitle = vi.hoisted(() => vi.fn());
// Discord REST mock methods
const mockDiscordEditMessage = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockDiscordTriggerTyping = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockDiscordRemoveOwnReaction = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockDiscordCreateMessage = vi.hoisted(() => vi.fn().mockResolvedValue({ id: 'new-msg' }));
const mockDiscordUpdateChannelName = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
// Unified messenger mock methods (used by all platforms via PlatformClient)
const mockEditMessage = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockTriggerTyping = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockRemoveReaction = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockCreateMessage = vi.hoisted(() => vi.fn().mockResolvedValue({ id: 'new-msg' }));
const mockUpdateThreadName = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
// Telegram REST mock methods
const mockTelegramSendMessage = vi.hoisted(() => vi.fn().mockResolvedValue({ message_id: 12345 }));
const mockTelegramEditMessageText = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockTelegramRemoveMessageReaction = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockTelegramSendChatAction = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
// Mock PlatformClient's getMessenger
const mockGetMessenger = vi.hoisted(() =>
vi.fn().mockImplementation(() => ({
createMessage: mockCreateMessage,
editMessage: mockEditMessage,
removeReaction: mockRemoveReaction,
triggerTyping: mockTriggerTyping,
updateThreadName: mockUpdateThreadName,
})),
);
const mockCreateBot = vi.hoisted(() =>
vi.fn().mockImplementation(() => ({
applicationId: 'mock-app',
createAdapter: () => ({}),
extractChatId: (id: string) => id,
getMessenger: mockGetMessenger,
parseMessageId: (id: string) => id,
id: 'mock',
start: vi.fn(),
stop: vi.fn(),
})),
);
// ==================== vi.mock ====================
@@ -53,23 +70,18 @@ vi.mock('@/server/services/systemAgent', () => ({
})),
}));
vi.mock('../discordRestApi', () => ({
DiscordRestApi: vi.fn().mockImplementation(() => ({
createMessage: mockDiscordCreateMessage,
editMessage: mockDiscordEditMessage,
removeOwnReaction: mockDiscordRemoveOwnReaction,
triggerTyping: mockDiscordTriggerTyping,
updateChannelName: mockDiscordUpdateChannelName,
})),
}));
vi.mock('../telegramRestApi', () => ({
TelegramRestApi: vi.fn().mockImplementation(() => ({
editMessageText: mockTelegramEditMessageText,
removeMessageReaction: mockTelegramRemoveMessageReaction,
sendChatAction: mockTelegramSendChatAction,
sendMessage: mockTelegramSendMessage,
})),
vi.mock('../platforms', () => ({
platformRegistry: {
getPlatform: vi.fn().mockImplementation((platform: string) => {
if (platform === 'unknown') return undefined;
return {
clientFactory: { createClient: mockCreateBot },
credentials: [],
name: platform,
id: platform,
};
}),
},
}));
// ==================== Helpers ====================
@@ -78,8 +90,8 @@ const FAKE_DB = {} as any;
const FAKE_BOT_TOKEN = 'fake-bot-token-123';
const FAKE_CREDENTIALS = JSON.stringify({ botToken: FAKE_BOT_TOKEN });
function setupCredentials(credentials = FAKE_CREDENTIALS) {
mockFindByPlatformAndAppId.mockResolvedValue({ credentials });
function setupCredentials(credentials = FAKE_CREDENTIALS, extra?: Record<string, unknown>) {
mockFindByPlatformAndAppId.mockResolvedValue({ credentials, ...extra });
mockInitWithEnvKey.mockResolvedValue({ decrypt: mockDecrypt });
mockDecrypt.mockResolvedValue({ plaintext: credentials });
}
@@ -110,6 +122,15 @@ describe('BotCallbackService', () => {
vi.clearAllMocks();
service = new BotCallbackService(FAKE_DB);
setupCredentials();
// Default: getMessenger returns the main messenger mock
mockGetMessenger.mockImplementation(() => ({
createMessage: mockCreateMessage,
editMessage: mockEditMessage,
removeReaction: mockRemoveReaction,
triggerTyping: mockTriggerTyping,
updateThreadName: mockUpdateThreadName,
}));
});
// ==================== Platform detection ====================
@@ -153,17 +174,6 @@ describe('BotCallbackService', () => {
);
});
it('should throw when credentials have no botToken', async () => {
const noTokenCreds = JSON.stringify({ someOtherKey: 'value' });
setupCredentials(noTokenCreds);
const body = makeBody({ type: 'step' });
await expect(service.handleCallback(body)).rejects.toThrow(
'Bot credentials incomplete for discord appId=app-123',
);
});
it('should fall back to raw credentials when decryption fails', async () => {
mockFindByPlatformAndAppId.mockResolvedValue({ credentials: FAKE_CREDENTIALS });
mockInitWithEnvKey.mockResolvedValue({
@@ -179,7 +189,7 @@ describe('BotCallbackService', () => {
// Should not throw because it falls back to raw JSON parse
await service.handleCallback(body);
expect(mockDiscordEditMessage).toHaveBeenCalled();
expect(mockEditMessage).toHaveBeenCalled();
});
});
@@ -196,11 +206,7 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
expect(mockDiscordEditMessage).toHaveBeenCalledWith(
'channel-id',
'progress-msg-1',
expect.any(String),
);
expect(mockEditMessage).toHaveBeenCalledWith('progress-msg-1', expect.any(String));
});
it('should route completion type to handleCompletion', async () => {
@@ -212,8 +218,7 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
expect(mockDiscordEditMessage).toHaveBeenCalledWith(
'channel-id',
expect(mockEditMessage).toHaveBeenCalledWith(
'progress-msg-1',
expect.stringContaining('Here is the answer.'),
);
@@ -231,7 +236,7 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
expect(mockDiscordEditMessage).not.toHaveBeenCalled();
expect(mockEditMessage).not.toHaveBeenCalled();
});
it('should edit progress message and trigger typing for non-final LLM step', async () => {
@@ -245,8 +250,8 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
expect(mockDiscordEditMessage).toHaveBeenCalledTimes(1);
expect(mockDiscordTriggerTyping).toHaveBeenCalledTimes(1);
expect(mockEditMessage).toHaveBeenCalledTimes(1);
expect(mockTriggerTyping).toHaveBeenCalledTimes(1);
});
it('should NOT trigger typing for final LLM response (no tool calls + has content)', async () => {
@@ -260,8 +265,8 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
expect(mockDiscordEditMessage).toHaveBeenCalledTimes(1);
expect(mockDiscordTriggerTyping).not.toHaveBeenCalled();
expect(mockEditMessage).toHaveBeenCalledTimes(1);
expect(mockTriggerTyping).not.toHaveBeenCalled();
});
it('should handle tool step type', async () => {
@@ -275,12 +280,12 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
expect(mockDiscordEditMessage).toHaveBeenCalledTimes(1);
expect(mockDiscordTriggerTyping).toHaveBeenCalledTimes(1);
expect(mockEditMessage).toHaveBeenCalledTimes(1);
expect(mockTriggerTyping).toHaveBeenCalledTimes(1);
});
it('should not throw when edit message fails during step', async () => {
mockDiscordEditMessage.mockRejectedValueOnce(new Error('Discord API error'));
mockEditMessage.mockRejectedValueOnce(new Error('API error'));
const body = makeBody({
content: 'Processing...',
@@ -306,8 +311,7 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
expect(mockDiscordEditMessage).toHaveBeenCalledWith(
'channel-id',
expect(mockEditMessage).toHaveBeenCalledWith(
'progress-msg-1',
expect.stringContaining('Model quota exceeded'),
);
@@ -321,8 +325,7 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
expect(mockDiscordEditMessage).toHaveBeenCalledWith(
'channel-id',
expect(mockEditMessage).toHaveBeenCalledWith(
'progress-msg-1',
expect.stringContaining('Agent execution failed'),
);
@@ -336,7 +339,7 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
expect(mockDiscordEditMessage).not.toHaveBeenCalled();
expect(mockEditMessage).not.toHaveBeenCalled();
});
it('should edit progress message with final reply content', async () => {
@@ -353,15 +356,14 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
expect(mockDiscordEditMessage).toHaveBeenCalledWith(
'channel-id',
expect(mockEditMessage).toHaveBeenCalledWith(
'progress-msg-1',
expect.stringContaining('The answer is 42.'),
);
});
it('should not throw when editing completion message fails', async () => {
mockDiscordEditMessage.mockRejectedValueOnce(new Error('Edit failed'));
mockEditMessage.mockRejectedValueOnce(new Error('Edit failed'));
const body = makeBody({
lastAssistantContent: 'Some response',
@@ -376,8 +378,7 @@ describe('BotCallbackService', () => {
// ==================== Message splitting ====================
describe('message splitting', () => {
it('should split long Discord messages into multiple chunks', async () => {
// Default Discord limit is 1800 chars (from splitMessage default)
it('should split long messages into multiple chunks', async () => {
const longContent = 'A'.repeat(3000);
const body = makeBody({
@@ -389,12 +390,14 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
// First chunk via editMessage, additional chunks via createMessage
expect(mockDiscordEditMessage).toHaveBeenCalledTimes(1);
expect(mockDiscordCreateMessage).toHaveBeenCalled();
expect(mockEditMessage).toHaveBeenCalledTimes(1);
expect(mockCreateMessage).toHaveBeenCalled();
});
it('should use Telegram char limit (4000) for Telegram platform', async () => {
// Content just over default 1800 but under 4000 should NOT split for Telegram
it('should use custom charLimit from provider settings', async () => {
setupCredentials(FAKE_CREDENTIALS, { settings: { charLimit: 4000 } });
// Content just over default 1800 but under 4000 should NOT split
const mediumContent = 'B'.repeat(2500);
const body = makeTelegramBody({
@@ -406,11 +409,12 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
// Should be single message (4000 limit), so only editMessage
expect(mockTelegramEditMessageText).toHaveBeenCalledTimes(1);
expect(mockTelegramSendMessage).not.toHaveBeenCalled();
expect(mockEditMessage).toHaveBeenCalledTimes(1);
expect(mockCreateMessage).not.toHaveBeenCalled();
});
it('should split Telegram messages that exceed 4000 chars', async () => {
it('should split messages that exceed custom charLimit', async () => {
setupCredentials(FAKE_CREDENTIALS, { settings: { charLimit: 4000 } });
const longContent = 'C'.repeat(6000);
const body = makeTelegramBody({
@@ -421,15 +425,15 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
expect(mockTelegramEditMessageText).toHaveBeenCalledTimes(1);
expect(mockTelegramSendMessage).toHaveBeenCalled();
expect(mockEditMessage).toHaveBeenCalledTimes(1);
expect(mockCreateMessage).toHaveBeenCalled();
});
});
// ==================== Eyes reaction removal ====================
describe('removeEyesReaction', () => {
it('should remove eyes reaction on completion for Discord', async () => {
it('should remove eyes reaction on completion', async () => {
const body = makeBody({
lastAssistantContent: 'Done.',
reason: 'completed',
@@ -439,26 +443,7 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
// Discord uses a separate DiscordRestApi instance for reaction removal
expect(mockDiscordRemoveOwnReaction).toHaveBeenCalled();
});
it('should use reactionChannelId when provided for Discord', async () => {
const body = makeBody({
lastAssistantContent: 'Done.',
reactionChannelId: 'parent-channel-id',
reason: 'completed',
type: 'completion',
userMessageId: 'user-msg-1',
});
await service.handleCallback(body);
expect(mockDiscordRemoveOwnReaction).toHaveBeenCalledWith(
'parent-channel-id',
'user-msg-1',
'👀',
);
expect(mockRemoveReaction).toHaveBeenCalledWith('user-msg-1', '👀');
});
it('should skip reaction removal when no userMessageId', async () => {
@@ -470,8 +455,7 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
// removeReaction should not be called
expect(mockDiscordRemoveOwnReaction).not.toHaveBeenCalled();
expect(mockRemoveReaction).not.toHaveBeenCalled();
});
it('should remove reaction for Telegram using messenger', async () => {
@@ -484,12 +468,11 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
// Telegram uses messenger.removeReaction which calls removeMessageReaction
expect(mockTelegramRemoveMessageReaction).toHaveBeenCalledWith('chat-456', 789);
expect(mockRemoveReaction).toHaveBeenCalledWith('telegram:chat-456:789', '👀');
});
it('should not throw when reaction removal fails', async () => {
mockDiscordRemoveOwnReaction.mockRejectedValueOnce(new Error('Reaction not found'));
mockRemoveReaction.mockRejectedValueOnce(new Error('Reaction not found'));
const body = makeBody({
lastAssistantContent: 'Done.',
@@ -521,7 +504,6 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
// summarizeTopicTitle is fire-and-forget; wait for promises to settle
await vi.waitFor(() => {
expect(mockFindById).toHaveBeenCalledWith('topic-1');
});
@@ -609,7 +591,7 @@ describe('BotCallbackService', () => {
expect(mockFindById).not.toHaveBeenCalled();
});
it('should update thread name on Discord after generating title', async () => {
it('should update thread name after generating title', async () => {
mockFindById.mockResolvedValue({ title: null });
mockGenerateTopicTitle.mockResolvedValue('New Title');
mockTopicUpdate.mockResolvedValue(undefined);
@@ -627,7 +609,7 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
await vi.waitFor(() => {
expect(mockDiscordUpdateChannelName).toHaveBeenCalledWith('thread-id', 'New Title');
expect(mockUpdateThreadName).toHaveBeenCalledWith('New Title');
});
});
@@ -651,92 +633,7 @@ describe('BotCallbackService', () => {
// Wait for async chain
await new Promise((r) => setTimeout(r, 50));
expect(mockTopicUpdate).not.toHaveBeenCalled();
expect(mockDiscordUpdateChannelName).not.toHaveBeenCalled();
});
});
// ==================== Discord channel ID extraction ====================
describe('Discord channel ID extraction', () => {
it('should extract channel ID from 3-part platformThreadId (no thread)', async () => {
const body = makeBody({
platformThreadId: 'discord:guild:channel-123',
shouldContinue: true,
stepType: 'call_llm',
type: 'step',
});
await service.handleCallback(body);
expect(mockDiscordEditMessage).toHaveBeenCalledWith(
'channel-123',
expect.any(String),
expect.any(String),
);
});
it('should extract thread ID (4th part) as channel when thread exists', async () => {
const body = makeBody({
platformThreadId: 'discord:guild:parent-channel:thread-456',
shouldContinue: true,
stepType: 'call_llm',
type: 'step',
});
await service.handleCallback(body);
expect(mockDiscordEditMessage).toHaveBeenCalledWith(
'thread-456',
expect.any(String),
expect.any(String),
);
});
});
// ==================== Telegram chat ID and message ID ====================
describe('Telegram message handling', () => {
it('should extract chat ID from platformThreadId', async () => {
const body = makeTelegramBody({
shouldContinue: true,
stepType: 'call_llm',
type: 'step',
});
await service.handleCallback(body);
expect(mockTelegramEditMessageText).toHaveBeenCalledWith(
'chat-456',
expect.any(Number),
expect.any(String),
);
});
it('should parse composite message ID for Telegram', async () => {
const body = makeTelegramBody({
lastAssistantContent: 'Done.',
progressMessageId: 'telegram:chat-456:99',
reason: 'completed',
type: 'completion',
userMessageId: 'telegram:chat-456:100',
});
await service.handleCallback(body);
// editMessageText should receive parsed numeric message ID
expect(mockTelegramEditMessageText).toHaveBeenCalledWith('chat-456', 99, expect.any(String));
});
it('should trigger typing for Telegram steps', async () => {
const body = makeTelegramBody({
shouldContinue: true,
stepType: 'call_tool',
type: 'step',
});
await service.handleCallback(body);
expect(mockTelegramSendChatAction).toHaveBeenCalledWith('chat-456', 'typing');
expect(mockUpdateThreadName).not.toHaveBeenCalled();
});
});
@@ -762,10 +659,10 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
// Completion: edit message
expect(mockDiscordEditMessage).toHaveBeenCalled();
expect(mockEditMessage).toHaveBeenCalled();
// Reaction removal
expect(mockDiscordRemoveOwnReaction).toHaveBeenCalled();
expect(mockRemoveReaction).toHaveBeenCalled();
// Topic summarization (async)
await vi.waitFor(() => {
@@ -786,7 +683,7 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
expect(mockDiscordRemoveOwnReaction).not.toHaveBeenCalled();
expect(mockRemoveReaction).not.toHaveBeenCalled();
await new Promise((r) => setTimeout(r, 50));
expect(mockFindById).not.toHaveBeenCalled();
});
@@ -0,0 +1,260 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { BotMessageRouter } from '../BotMessageRouter';
// ==================== Hoisted mocks ====================
const mockFindEnabledByPlatform = vi.hoisted(() => vi.fn());
const mockInitWithEnvKey = vi.hoisted(() => vi.fn());
const mockGetServerDB = vi.hoisted(() => vi.fn());
vi.mock('@/database/core/db-adaptor', () => ({
getServerDB: mockGetServerDB,
}));
vi.mock('@/database/models/agentBotProvider', () => ({
AgentBotProviderModel: {
findEnabledByPlatform: mockFindEnabledByPlatform,
},
}));
vi.mock('@/server/modules/KeyVaultsEncrypt', () => ({
KeyVaultsGateKeeper: {
initWithEnvKey: mockInitWithEnvKey,
},
}));
vi.mock('@/server/modules/AgentRuntime/redis', () => ({
getAgentRuntimeRedisClient: vi.fn().mockReturnValue(null),
}));
vi.mock('@chat-adapter/state-ioredis', () => ({
createIoRedisState: vi.fn(),
}));
// Mock Chat SDK
const mockInitialize = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockOnNewMention = vi.hoisted(() => vi.fn());
const mockOnSubscribedMessage = vi.hoisted(() => vi.fn());
const mockOnNewMessage = vi.hoisted(() => vi.fn());
vi.mock('chat', () => ({
BaseFormatConverter: class {},
Chat: vi.fn().mockImplementation(() => ({
initialize: mockInitialize,
onNewMention: mockOnNewMention,
onNewMessage: mockOnNewMessage,
onSubscribedMessage: mockOnSubscribedMessage,
webhooks: {},
})),
ConsoleLogger: vi.fn(),
}));
vi.mock('../AgentBridgeService', () => ({
AgentBridgeService: vi.fn().mockImplementation(() => ({
handleMention: vi.fn().mockResolvedValue(undefined),
handleSubscribedMessage: vi.fn().mockResolvedValue(undefined),
})),
}));
// Mock platform entries
const mockCreateAdapter = vi.hoisted(() =>
vi.fn().mockReturnValue({ testplatform: { type: 'mock-adapter' } }),
);
const mockGetPlatform = vi.hoisted(() =>
vi.fn().mockImplementation((platform: string) => {
if (platform === 'unknown') return undefined;
return {
clientFactory: {
createClient: vi.fn().mockReturnValue({
applicationId: 'mock-app',
createAdapter: mockCreateAdapter,
extractChatId: (id: string) => id.split(':')[1],
getMessenger: () => ({
createMessage: vi.fn(),
editMessage: vi.fn(),
removeReaction: vi.fn(),
triggerTyping: vi.fn(),
}),
id: platform,
parseMessageId: (id: string) => id,
start: vi.fn(),
stop: vi.fn(),
}),
},
credentials: [],
id: platform,
name: platform,
};
}),
);
vi.mock('../platforms', () => ({
buildRuntimeKey: (platform: string, appId: string) => `${platform}:${appId}`,
platformRegistry: {
getPlatform: mockGetPlatform,
},
}));
// ==================== Helpers ====================
const FAKE_DB = {} as any;
const FAKE_GATEKEEPER = { decrypt: vi.fn() };
function makeProvider(overrides: Record<string, any> = {}) {
return {
agentId: 'agent-1',
applicationId: 'app-123',
credentials: { botToken: 'token' },
userId: 'user-1',
...overrides,
};
}
// ==================== Tests ====================
describe('BotMessageRouter', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetServerDB.mockResolvedValue(FAKE_DB);
mockInitWithEnvKey.mockResolvedValue(FAKE_GATEKEEPER);
mockFindEnabledByPlatform.mockResolvedValue([]);
});
describe('getWebhookHandler', () => {
it('should return 404 for unknown platform', async () => {
const router = new BotMessageRouter();
const handler = router.getWebhookHandler('unknown');
const req = new Request('https://example.com/webhook', { method: 'POST' });
const resp = await handler(req);
expect(resp.status).toBe(404);
expect(await resp.text()).toBe('No bot configured for this platform');
});
it('should return a handler function', () => {
const router = new BotMessageRouter();
const handler = router.getWebhookHandler('telegram', 'app-123');
expect(typeof handler).toBe('function');
});
});
describe('on-demand loading', () => {
it('should load bot on first webhook request', async () => {
mockFindEnabledByPlatform.mockResolvedValue([makeProvider({ applicationId: 'tg-bot-123' })]);
const router = new BotMessageRouter();
const handler = router.getWebhookHandler('telegram', 'tg-bot-123');
const req = new Request('https://example.com/webhook', { body: '{}', method: 'POST' });
await handler(req);
// Should only query the specific platform, not all platforms
expect(mockFindEnabledByPlatform).toHaveBeenCalledTimes(1);
expect(mockFindEnabledByPlatform).toHaveBeenCalledWith(FAKE_DB, 'telegram', FAKE_GATEKEEPER);
// Chat SDK should be initialized
expect(mockInitialize).toHaveBeenCalled();
expect(mockCreateAdapter).toHaveBeenCalled();
});
it('should return cached bot on subsequent requests', async () => {
mockFindEnabledByPlatform.mockResolvedValue([makeProvider({ applicationId: 'tg-bot-123' })]);
const router = new BotMessageRouter();
const handler = router.getWebhookHandler('telegram', 'tg-bot-123');
const req1 = new Request('https://example.com/webhook', { body: '{}', method: 'POST' });
await handler(req1);
const req2 = new Request('https://example.com/webhook', { body: '{}', method: 'POST' });
await handler(req2);
// DB should only be queried once — second call uses cache
expect(mockFindEnabledByPlatform).toHaveBeenCalledTimes(1);
expect(mockInitialize).toHaveBeenCalledTimes(1);
});
it('should return 404 when no provider found in DB', async () => {
mockFindEnabledByPlatform.mockResolvedValue([]);
const router = new BotMessageRouter();
const handler = router.getWebhookHandler('telegram', 'non-existent');
const req = new Request('https://example.com/webhook', { body: '{}', method: 'POST' });
const resp = await handler(req);
expect(resp.status).toBe(404);
});
it('should return 400 when appId is missing for generic platform', async () => {
const router = new BotMessageRouter();
const handler = router.getWebhookHandler('telegram');
const req = new Request('https://example.com/webhook', { body: '{}', method: 'POST' });
const resp = await handler(req);
expect(resp.status).toBe(400);
});
it('should handle DB errors gracefully', async () => {
mockFindEnabledByPlatform.mockRejectedValue(new Error('DB connection failed'));
const router = new BotMessageRouter();
const handler = router.getWebhookHandler('telegram', 'app-123');
const req = new Request('https://example.com/webhook', { body: '{}', method: 'POST' });
const resp = await handler(req);
// Should return 404, not throw
expect(resp.status).toBe(404);
});
});
describe('handler registration', () => {
it('should always register onNewMention and onSubscribedMessage', async () => {
mockFindEnabledByPlatform.mockResolvedValue([makeProvider({ applicationId: 'tg-123' })]);
const router = new BotMessageRouter();
const handler = router.getWebhookHandler('telegram', 'tg-123');
const req = new Request('https://example.com/webhook', { body: '{}', method: 'POST' });
await handler(req);
expect(mockOnNewMention).toHaveBeenCalled();
expect(mockOnSubscribedMessage).toHaveBeenCalled();
});
it('should register onNewMessage when dm.enabled is true', async () => {
mockFindEnabledByPlatform.mockResolvedValue([
makeProvider({
applicationId: 'tg-123',
settings: { dm: { enabled: true } },
}),
]);
const router = new BotMessageRouter();
const handler = router.getWebhookHandler('telegram', 'tg-123');
const req = new Request('https://example.com/webhook', { body: '{}', method: 'POST' });
await handler(req);
expect(mockOnNewMessage).toHaveBeenCalled();
});
it('should NOT register onNewMessage when dm is not enabled', async () => {
mockFindEnabledByPlatform.mockResolvedValue([makeProvider({ applicationId: 'app-123' })]);
const router = new BotMessageRouter();
const handler = router.getWebhookHandler('telegram', 'app-123');
const req = new Request('https://example.com/webhook', { body: '{}', method: 'POST' });
await handler(req);
expect(mockOnNewMessage).not.toHaveBeenCalled();
});
});
});
@@ -77,6 +77,8 @@ describe('formatPrompt', () => {
text: 'hello world',
};
const discordSanitize = (text: string) => text.replaceAll(/<@!?bot123>\s*/g, '').trim();
it('should format basic message with speaker tag', () => {
const result = formatPrompt(baseMessage);
@@ -88,7 +90,7 @@ describe('formatPrompt', () => {
it('should strip bot @mention from text', () => {
const msg = { ...baseMessage, text: '<@bot123> hello world' };
const result = formatPrompt(msg, { applicationId: 'bot123' });
const result = formatPrompt(msg, { sanitizeUserInput: discordSanitize });
expect(result).toContain('hello world');
expect(result).not.toContain('<@bot123>');
@@ -96,12 +98,19 @@ describe('formatPrompt', () => {
it('should strip bot @mention with ! format', () => {
const msg = { ...baseMessage, text: '<@!bot123> hello world' };
const result = formatPrompt(msg, { applicationId: 'bot123' });
const result = formatPrompt(msg, { sanitizeUserInput: discordSanitize });
expect(result).toContain('hello world');
expect(result).not.toContain('<@!bot123>');
});
it('should not strip mentions when no sanitizeUserInput provided', () => {
const msg = { ...baseMessage, text: '<@bot123> hello world' };
const result = formatPrompt(msg);
expect(result).toContain('<@bot123>');
});
it('should prepend referenced message before user text', () => {
const msg = {
...baseMessage,
@@ -146,6 +155,8 @@ describe('formatPrompt', () => {
});
it('should handle both @mention stripping and referenced message together', () => {
const sanitize = (text: string) => text.replaceAll(/<@!?bot999>\s*/g, '').trim();
const msg = {
...baseMessage,
raw: {
@@ -156,7 +167,7 @@ describe('formatPrompt', () => {
},
text: '<@bot999> yes we can',
};
const result = formatPrompt(msg, { applicationId: 'bot999' });
const result = formatPrompt(msg, { sanitizeUserInput: sanitize });
expect(result).not.toContain('<@bot999>');
expect(result).toContain('yes we can');
@@ -315,65 +315,12 @@ describe('replyTemplate', () => {
// ==================== renderFinalReply ====================
describe('renderFinalReply', () => {
it('should append usage footer with tokens, cost, and call counts', () => {
expect(
renderFinalReply('Here is the answer.', {
llmCalls: 5,
toolCalls: 4,
totalCost: 0.0312,
totalTokens: 1234,
}),
).toBe('Here is the answer.\n\n-# 1.2k tokens · $0.0312 | llm×5 | tools×4');
it('should return content body only (no stats)', () => {
expect(renderFinalReply('Here is the answer.')).toBe('Here is the answer.');
});
it('should hide call counts when llmCalls=1 and toolCalls=0', () => {
expect(
renderFinalReply('Simple answer.', {
llmCalls: 1,
toolCalls: 0,
totalCost: 0.001,
totalTokens: 500,
}),
).toBe('Simple answer.\n\n-# 500 tokens · $0.0010');
});
it('should show call counts when toolCalls > 0 even if llmCalls=1', () => {
expect(
renderFinalReply('Answer.', {
llmCalls: 1,
toolCalls: 2,
totalCost: 0.005,
totalTokens: 800,
}),
).toBe('Answer.\n\n-# 800 tokens · $0.0050 | llm×1 | tools×2');
});
it('should show call counts when llmCalls > 1 even if toolCalls=0', () => {
expect(
renderFinalReply('Answer.', {
llmCalls: 3,
toolCalls: 0,
totalCost: 0.01,
totalTokens: 2000,
}),
).toBe('Answer.\n\n-# 2.0k tokens · $0.0100 | llm×3 | tools×0');
});
it('should hide call counts for zero usage', () => {
expect(
renderFinalReply('Done.', { llmCalls: 0, toolCalls: 0, totalCost: 0, totalTokens: 0 }),
).toBe('Done.\n\n-# 0 tokens · $0.0000');
});
it('should format large token counts', () => {
expect(
renderFinalReply('Result', {
llmCalls: 10,
toolCalls: 20,
totalCost: 1.5,
totalTokens: 1_234_567,
}),
).toBe('Result\n\n-# 1.2m tokens · $1.5000 | llm×10 | tools×20');
it('should trim trailing whitespace', () => {
expect(renderFinalReply('Answer. \n\n')).toBe('Answer.');
});
});
+8 -7
View File
@@ -14,12 +14,13 @@ interface MessageLike {
text: string;
}
interface BotContext {
applicationId?: string;
interface FormatPromptOptions {
/** Strip platform-specific bot mention artifacts from user input. */
sanitizeUserInput?: (text: string) => string;
}
/**
* Extract referenced (replied-to) message from Discord raw payload
* Extract referenced (replied-to) message from raw payload
* and format it as an XML tag for the agent prompt.
*/
export const formatReferencedMessage = (
@@ -35,15 +36,15 @@ export const formatReferencedMessage = (
/**
* Format user message into agent prompt:
* 1. Strip bot's own @mention (Discord format: <@botId>)
* 1. Strip platform-specific bot mentions via sanitizeUserInput
* 2. Prepend referenced (quoted/replied) message if present
* 3. Add speaker tag with user identity
*/
export const formatPrompt = (message: MessageLike, botContext?: BotContext): string => {
export const formatPrompt = (message: MessageLike, options?: FormatPromptOptions): string => {
let text = message.text;
if (botContext?.applicationId) {
text = text.replaceAll(new RegExp(`<@!?${botContext.applicationId}>\\s*`, 'g'), '').trim();
if (options?.sanitizeUserInput) {
text = options.sanitizeUserInput(text);
}
// Prepend referenced (quoted/replied) message if present
+1 -4
View File
@@ -1,6 +1,3 @@
export { AgentBridgeService } from './AgentBridgeService';
export { BotMessageRouter, getBotMessageRouter } from './BotMessageRouter';
export { platformBotRegistry } from './platforms';
export { Discord, type DiscordBotConfig } from './platforms/discord';
export { Telegram, type TelegramBotConfig } from './platforms/telegram';
export type { PlatformBot, PlatformBotClass } from './types';
export type { PlatformClient, PlatformMessenger } from './types';
-135
View File
@@ -1,135 +0,0 @@
import debug from 'debug';
const log = debug('lobe-server:bot:lark-rest');
const BASE_URLS: Record<string, string> = {
feishu: 'https://open.feishu.cn/open-apis',
lark: 'https://open.larksuite.com/open-apis',
};
// Lark message limit is ~32KB for content, but we cap text at 4000 chars for readability
const MAX_TEXT_LENGTH = 4000;
/**
* Lightweight wrapper around the Lark/Feishu Open API.
* Used by bot-callback webhooks and BotMessageRouter to send/edit messages directly.
*
* Auth: app_id + app_secret → tenant_access_token (cached, auto-refreshed).
*/
export class LarkRestApi {
private readonly appId: string;
private readonly appSecret: string;
private readonly baseUrl: string;
private cachedToken?: string;
private tokenExpiresAt = 0;
constructor(appId: string, appSecret: string, platform: string = 'lark') {
this.appId = appId;
this.appSecret = appSecret;
this.baseUrl = BASE_URLS[platform] || BASE_URLS.lark;
}
// ------------------------------------------------------------------
// Messages
// ------------------------------------------------------------------
async sendMessage(chatId: string, text: string): Promise<{ messageId: string }> {
log('sendMessage: chatId=%s', chatId);
const data = await this.call('POST', '/im/v1/messages?receive_id_type=chat_id', {
content: JSON.stringify({ text: this.truncateText(text) }),
msg_type: 'text',
receive_id: chatId,
});
return { messageId: data.data.message_id };
}
async editMessage(messageId: string, text: string): Promise<void> {
log('editMessage: messageId=%s', messageId);
await this.call('PUT', `/im/v1/messages/${messageId}`, {
content: JSON.stringify({ text: this.truncateText(text) }),
msg_type: 'text',
});
}
async replyMessage(messageId: string, text: string): Promise<{ messageId: string }> {
log('replyMessage: messageId=%s', messageId);
const data = await this.call('POST', `/im/v1/messages/${messageId}/reply`, {
content: JSON.stringify({ text: this.truncateText(text) }),
msg_type: 'text',
});
return { messageId: data.data.message_id };
}
// ------------------------------------------------------------------
// Auth
// ------------------------------------------------------------------
async getTenantAccessToken(): Promise<string> {
if (this.cachedToken && Date.now() < this.tokenExpiresAt) {
return this.cachedToken;
}
log('getTenantAccessToken: refreshing for appId=%s', this.appId);
const response = await fetch(`${this.baseUrl}/auth/v3/tenant_access_token/internal`, {
body: JSON.stringify({ app_id: this.appId, app_secret: this.appSecret }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Lark auth failed: ${response.status} ${text}`);
}
const data = await response.json();
if (data.code !== 0) {
throw new Error(`Lark auth error: ${data.code} ${data.msg}`);
}
this.cachedToken = data.tenant_access_token;
// Expire 5 minutes early to avoid edge cases
this.tokenExpiresAt = Date.now() + (data.expire - 300) * 1000;
return this.cachedToken!;
}
// ------------------------------------------------------------------
// Internal
// ------------------------------------------------------------------
private truncateText(text: string): string {
if (text.length > MAX_TEXT_LENGTH) return text.slice(0, MAX_TEXT_LENGTH - 3) + '...';
return text;
}
private async call(method: string, path: string, body: Record<string, unknown>): Promise<any> {
const token = await this.getTenantAccessToken();
const url = `${this.baseUrl}${path}`;
const response = await fetch(url, {
body: JSON.stringify(body),
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
method,
});
if (!response.ok) {
const text = await response.text();
log('Lark API error: %s %s, status=%d, body=%s', method, path, response.status, text);
throw new Error(`Lark API ${method} ${path} failed: ${response.status} ${text}`);
}
const data = await response.json();
if (data.code !== 0) {
log('Lark API logical error: %s %s, code=%d, msg=%s', method, path, data.code, data.msg);
throw new Error(`Lark API ${method} ${path} failed: ${data.code} ${data.msg}`);
}
return data;
}
}
@@ -1,112 +0,0 @@
import type { DiscordAdapter } from '@chat-adapter/discord';
import { createDiscordAdapter } from '@chat-adapter/discord';
import { createIoRedisState } from '@chat-adapter/state-ioredis';
import { Chat, ConsoleLogger } from 'chat';
import debug from 'debug';
import { appEnv } from '@/envs/app';
import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis';
import type { PlatformBot } from '../types';
const log = debug('lobe-server:bot:gateway:discord');
const DEFAULT_DURATION_MS = 8 * 60 * 60 * 1000; // 8 hours
export interface DiscordBotConfig {
[key: string]: string;
applicationId: string;
botToken: string;
publicKey: string;
}
export interface GatewayListenerOptions {
durationMs?: number;
waitUntil?: (task: Promise<any>) => void;
}
export class Discord implements PlatformBot {
static readonly persistent = true;
readonly platform = 'discord';
readonly applicationId: string;
private abort = new AbortController();
private config: DiscordBotConfig;
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
private stopped = false;
constructor(config: DiscordBotConfig) {
this.config = config;
this.applicationId = config.applicationId;
}
async start(options?: GatewayListenerOptions): Promise<void> {
log('Starting DiscordBot appId=%s', this.applicationId);
this.stopped = false;
this.abort = new AbortController();
const adapter = createDiscordAdapter({
applicationId: this.config.applicationId,
botToken: this.config.botToken,
publicKey: this.config.publicKey,
});
const chatConfig: any = {
adapters: { discord: adapter },
userName: `lobehub-gateway-${this.applicationId}`,
};
const redisClient = getAgentRuntimeRedisClient();
if (redisClient) {
chatConfig.state = createIoRedisState({ client: redisClient, logger: new ConsoleLogger() });
}
const bot = new Chat(chatConfig);
await bot.initialize();
const discordAdapter = (bot as any).adapters.get('discord') as DiscordAdapter;
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
const waitUntil = options?.waitUntil ?? ((task: Promise<any>) => task.catch(() => {}));
const webhookUrl = `${(appEnv.APP_URL || '').trim()}/api/agent/webhooks/discord`;
await discordAdapter.startGatewayListener(
{ waitUntil },
durationMs,
this.abort.signal,
webhookUrl,
);
// Only schedule refresh timer in long-running mode (no custom options)
if (!options) {
this.refreshTimer = setTimeout(() => {
if (this.abort.signal.aborted || this.stopped) return;
log(
'DiscordBot appId=%s duration elapsed (%dh), refreshing...',
this.applicationId,
durationMs / 3_600_000,
);
this.abort.abort();
this.start().catch((err) => {
log('Failed to refresh DiscordBot appId=%s: %O', this.applicationId, err);
});
}, durationMs);
}
log('DiscordBot appId=%s started, webhookUrl=%s', this.applicationId, webhookUrl);
}
async stop(): Promise<void> {
log('Stopping DiscordBot appId=%s', this.applicationId);
this.stopped = true;
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
this.abort.abort();
}
}
@@ -2,9 +2,9 @@ import { REST } from '@discordjs/rest';
import debug from 'debug';
import { type RESTPostAPIChannelMessageResult, Routes } from 'discord-api-types/v10';
const log = debug('lobe-server:bot:discord-rest');
const log = debug('bot-platform:discord:client');
export class DiscordRestApi {
export class DiscordApi {
private readonly rest: REST;
constructor(botToken: string) {
@@ -0,0 +1,202 @@
import type { DiscordAdapter } from '@chat-adapter/discord';
import { createDiscordAdapter } from '@chat-adapter/discord';
import debug from 'debug';
import {
type BotPlatformRuntimeContext,
type BotProviderConfig,
ClientFactory,
type PlatformClient,
type PlatformMessenger,
type UsageStats,
type ValidationResult,
} from '../types';
import { formatUsageStats } from '../utils';
import { DiscordApi } from './api';
const log = debug('bot-platform:discord:bot');
const DEFAULT_DURATION_MS = 8 * 60 * 60 * 1000; // 8 hours
export interface GatewayListenerOptions {
durationMs?: number;
waitUntil?: (task: Promise<any>) => void;
}
function extractChannelId(platformThreadId: string): string {
const parts = platformThreadId.split(':');
return parts[3] || parts[2];
}
class DiscordGatewayClient implements PlatformClient {
readonly id = 'discord';
readonly applicationId: string;
private abort = new AbortController();
private config: BotProviderConfig;
private context: BotPlatformRuntimeContext;
private discord: DiscordApi;
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
private stopped = false;
constructor(config: BotProviderConfig, context: BotPlatformRuntimeContext) {
this.config = config;
this.context = context;
this.applicationId = config.applicationId;
this.discord = new DiscordApi(config.credentials.botToken);
}
// --- Lifecycle ---
async start(options?: GatewayListenerOptions): Promise<void> {
log('Starting DiscordBot appId=%s', this.applicationId);
this.stopped = false;
this.abort = new AbortController();
const adapter = createDiscordAdapter({
applicationId: this.config.applicationId,
botToken: this.config.credentials.botToken,
publicKey: this.config.credentials.publicKey,
});
const { Chat, ConsoleLogger } = await import('chat');
const chatConfig: any = {
adapters: { discord: adapter },
userName: `lobehub-gateway-${this.applicationId}`,
};
if (this.context.redisClient) {
const { createIoRedisState } = await import('@chat-adapter/state-ioredis');
chatConfig.state = createIoRedisState({
client: this.context.redisClient as any,
logger: new ConsoleLogger(),
});
}
const bot = new Chat(chatConfig);
await bot.initialize();
const discordAdapter = (bot as any).adapters.get('discord') as DiscordAdapter;
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/discord/${this.applicationId}`;
await discordAdapter.startGatewayListener(
{ waitUntil },
durationMs,
this.abort.signal,
webhookUrl,
);
if (!options) {
this.refreshTimer = setTimeout(() => {
if (this.abort.signal.aborted || this.stopped) return;
log(
'DiscordBot appId=%s duration elapsed (%dh), refreshing...',
this.applicationId,
durationMs / 3_600_000,
);
this.abort.abort();
this.start().catch((err) => {
log('Failed to refresh DiscordBot appId=%s: %O', this.applicationId, err);
});
}, durationMs);
}
log('DiscordBot appId=%s started, webhookUrl=%s', this.applicationId, webhookUrl);
}
async stop(): Promise<void> {
log('Stopping DiscordBot appId=%s', this.applicationId);
this.stopped = true;
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
this.abort.abort();
}
// --- Runtime Operations ---
createAdapter(): Record<string, any> {
return {
discord: createDiscordAdapter({
applicationId: this.config.applicationId,
botToken: this.config.credentials.botToken,
publicKey: this.config.credentials.publicKey,
}),
};
}
getMessenger(platformThreadId: string): PlatformMessenger {
const channelId = extractChannelId(platformThreadId);
return {
createMessage: (content) => this.discord.createMessage(channelId, content).then(() => {}),
editMessage: (messageId, content) => this.discord.editMessage(channelId, messageId, content),
removeReaction: (messageId, emoji) =>
this.discord.removeOwnReaction(channelId, messageId, emoji),
triggerTyping: () => this.discord.triggerTyping(channelId),
updateThreadName: (name) => {
const threadId = platformThreadId.split(':')[3];
return threadId ? this.discord.updateChannelName(threadId, name) : Promise.resolve();
},
};
}
extractChatId(platformThreadId: string): string {
return extractChannelId(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;
}
sanitizeUserInput(text: string): string {
return text.replaceAll(new RegExp(`<@!?${this.applicationId}>\\s*`, 'g'), '').trim();
}
shouldSubscribe(threadId: string): boolean {
// Only auto-subscribe to actual threads (4-part ID), not top-level channels (3-part ID).
const parts = threadId.split(':');
return parts.length >= 4;
}
}
export class DiscordClientFactory extends ClientFactory {
createClient(config: BotProviderConfig, context: BotPlatformRuntimeContext): PlatformClient {
// TODO: use config.settings.connectionMode to choose between gateway and webhook client
return new DiscordGatewayClient(config, context);
}
async validateCredentials(credentials: Record<string, string>): Promise<ValidationResult> {
const errors: Array<{ field: string; message: string }> = [];
if (!credentials.botToken) errors.push({ field: 'botToken', message: 'Bot Token is required' });
if (!credentials.publicKey)
errors.push({ field: 'publicKey', message: 'Public Key is required' });
if (errors.length > 0) return { errors, valid: false };
try {
const res = await fetch('https://discord.com/api/v10/users/@me', {
headers: { Authorization: `Bot ${credentials.botToken}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return { valid: true };
} catch {
return {
errors: [{ field: 'botToken', message: 'Failed to authenticate with Discord API' }],
valid: false,
};
}
}
}
@@ -0,0 +1,76 @@
import type { FieldSchema, PlatformDefinition } from '../types';
import { DiscordClientFactory } from './client';
const settings: FieldSchema[] = [
{
key: 'connectionMode',
default: 'websocket',
description: 'How the bot connects to Discord',
enum: ['websocket', 'webhook'],
enumLabels: ['WebSocket (Gateway)', 'Webhook'],
group: 'connection',
label: 'Connection Mode',
type: 'string',
},
{
key: 'charLimit',
default: 2000,
group: 'general',
label: 'Character Limit',
minimum: 100,
type: 'number',
},
{
key: 'debounceMs',
default: 2000,
description: 'How long to wait for additional messages before dispatching to the agent (ms)',
group: 'general',
label: 'Message Merge Window (ms)',
minimum: 0,
type: 'number',
},
{
key: 'showUsageStats',
default: false,
description: 'Show token usage, cost, and duration stats in bot replies',
group: 'general',
label: 'Show Usage Stats',
type: 'boolean',
},
{
key: 'dm',
group: 'dm',
label: 'Direct Messages',
properties: [
{ key: 'enabled', default: false, label: 'Enable DMs', type: 'boolean' },
{
key: 'policy',
default: 'disabled',
enum: ['open', 'allowlist', 'disabled'],
enumLabels: ['Open', 'Allowlist', 'Disabled'],
label: 'DM Policy',
type: 'string',
visibleWhen: { field: 'enabled', value: true },
},
],
type: 'object',
},
];
export const discord: PlatformDefinition = {
id: 'discord',
name: 'Discord',
description: 'Connect a Discord bot',
documentation: {
portalUrl: 'https://discord.com/developers/applications',
setupGuideUrl: 'https://lobehub.com/docs/usage/channels/discord',
},
credentials: [
{ key: 'botToken', label: 'Bot Token', required: true, type: 'password' },
{ key: 'publicKey', label: 'Public Key', required: true, type: 'string' },
{ key: 'applicationId', label: 'Application ID', required: true, type: 'string' },
],
settings,
clientFactory: new DiscordClientFactory(),
};
@@ -0,0 +1,121 @@
import { createLarkAdapter, LarkApiClient } from '@lobechat/chat-adapter-feishu';
import debug from 'debug';
import {
type BotPlatformRuntimeContext,
type BotProviderConfig,
ClientFactory,
type PlatformClient,
type PlatformMessenger,
type ValidationResult,
} from '../types';
const log = debug('bot-platform:feishu:client');
function extractChatId(platformThreadId: string): string {
return platformThreadId.split(':')[1];
}
/** Resolve the Lark/Feishu domain from settings, defaulting to 'feishu'. */
function resolveDomain(settings: Record<string, unknown>): 'lark' | 'feishu' {
const domain = settings.domain;
return domain === 'lark' ? 'lark' : 'feishu';
}
class FeishuWebhookClient implements PlatformClient {
readonly id = 'feishu';
readonly applicationId: string;
private config: BotProviderConfig;
private domain: 'lark' | 'feishu';
constructor(config: BotProviderConfig, _context: BotPlatformRuntimeContext) {
this.config = config;
this.applicationId = config.applicationId;
this.domain = resolveDomain(config.settings);
}
// --- Lifecycle ---
async start(): Promise<void> {
log('Starting FeishuClient appId=%s domain=%s', this.applicationId, this.domain);
const api = new LarkApiClient(
this.config.credentials.appId,
this.config.credentials.appSecret,
this.domain,
);
await api.getTenantAccessToken();
log('FeishuClient appId=%s credentials verified', this.applicationId);
}
async stop(): Promise<void> {
log('Stopping FeishuClient appId=%s', this.applicationId);
}
// --- Runtime Operations ---
createAdapter(): Record<string, any> {
return {
feishu: createLarkAdapter({
appId: this.config.credentials.appId,
appSecret: this.config.credentials.appSecret,
encryptKey: this.config.credentials.encryptKey,
platform: this.domain,
verificationToken: this.config.credentials.verificationToken,
}),
};
}
getMessenger(platformThreadId: string): PlatformMessenger {
const api = new LarkApiClient(
this.config.credentials.appId,
this.config.credentials.appSecret,
this.domain,
);
const chatId = extractChatId(platformThreadId);
return {
createMessage: (content) => api.sendMessage(chatId, content).then(() => {}),
editMessage: (messageId, content) => api.editMessage(messageId, content).then(() => {}),
removeReaction: () => Promise.resolve(),
triggerTyping: () => Promise.resolve(),
};
}
extractChatId(platformThreadId: string): string {
return extractChatId(platformThreadId);
}
parseMessageId(compositeId: string): string {
return compositeId;
}
}
export class FeishuClientFactory extends ClientFactory {
createClient(config: BotProviderConfig, context: BotPlatformRuntimeContext): PlatformClient {
return new FeishuWebhookClient(config, context);
}
async validateCredentials(credentials: Record<string, string>): Promise<ValidationResult> {
const errors: Array<{ field: string; message: string }> = [];
if (!credentials.appId) errors.push({ field: 'appId', message: 'App ID is required' });
if (!credentials.appSecret)
errors.push({ field: 'appSecret', message: 'App Secret is required' });
if (errors.length > 0) return { errors, valid: false };
try {
const domain = 'feishu'; // default domain for validation
const api = new LarkApiClient(credentials.appId, credentials.appSecret, domain);
await api.getTenantAccessToken();
return { valid: true };
} catch {
return {
errors: [{ field: 'credentials', message: 'Failed to authenticate with Feishu API' }],
valid: false,
};
}
}
}
@@ -0,0 +1,15 @@
import type { PlatformDefinition } from '../../types';
import { sharedClientFactory, sharedCredentials, sharedSettings } from './shared';
export const feishu: PlatformDefinition = {
id: 'feishu',
name: 'Feishu',
description: 'Connect a Feishu bot',
documentation: {
portalUrl: 'https://open.feishu.cn/app',
setupGuideUrl: 'https://lobehub.com/docs/usage/channels/feishu',
},
credentials: sharedCredentials,
settings: sharedSettings,
clientFactory: sharedClientFactory,
};
@@ -0,0 +1,15 @@
import type { PlatformDefinition } from '../../types';
import { sharedClientFactory, sharedCredentials, sharedSettings } from './shared';
export const lark: PlatformDefinition = {
id: 'lark',
name: 'Lark',
description: 'Connect a Lark bot',
documentation: {
portalUrl: 'https://open.larksuite.com/app',
setupGuideUrl: 'https://lobehub.com/docs/usage/channels/lark',
},
credentials: sharedCredentials,
settings: sharedSettings,
clientFactory: sharedClientFactory,
};
@@ -0,0 +1,61 @@
import type { FieldSchema } from '../../types';
import { FeishuClientFactory } from '../client';
export const sharedCredentials: FieldSchema[] = [
{ key: 'appId', label: 'App ID', required: true, type: 'string' },
{ key: 'appSecret', label: 'App Secret', required: true, type: 'password' },
{
key: 'encryptKey',
description: 'AES decrypt key for encrypted events (optional)',
label: 'Encrypt Key',
required: false,
type: 'password',
},
{
key: 'verificationToken',
description: 'Token for webhook event validation (optional)',
label: 'Verification Token',
required: false,
type: 'password',
},
];
export const sharedSettings: FieldSchema[] = [
{
key: 'charLimit',
default: 4000,
group: 'general',
label: 'Character Limit',
minimum: 100,
type: 'number',
},
{
key: 'debounceMs',
default: 2000,
description: 'How long to wait for additional messages before dispatching to the agent (ms)',
group: 'general',
label: 'Message Merge Window (ms)',
minimum: 0,
type: 'number',
},
{
key: 'dm',
group: 'dm',
label: 'Direct Messages',
properties: [
{ key: 'enabled', default: true, label: 'Enable DMs', type: 'boolean' },
{
key: 'policy',
default: 'open',
enum: ['open', 'allowlist', 'disabled'],
enumLabels: ['Open', 'Allowlist', 'Disabled'],
label: 'DM Policy',
type: 'string',
visibleWhen: { field: 'enabled', value: true },
},
],
type: 'object',
},
];
export const sharedClientFactory = new FeishuClientFactory();
+46 -10
View File
@@ -1,11 +1,47 @@
import type { PlatformBotClass } from '../types';
import { Discord } from './discord';
import { Lark } from './lark';
import { Telegram } from './telegram';
// --------------- Core types & utilities ---------------
// --------------- Registry singleton ---------------
import { discord } from './discord/definition';
import { feishu } from './feishu/definitions/feishu';
import { lark } from './feishu/definitions/lark';
import { qq } from './qq/definition';
import { PlatformRegistry } from './registry';
import { telegram } from './telegram/definition';
export const platformBotRegistry: Record<string, PlatformBotClass> = {
discord: Discord,
feishu: Lark,
lark: Lark,
telegram: Telegram,
};
export { PlatformRegistry } from './registry';
export type {
BotPlatformRedisClient,
BotPlatformRuntimeContext,
BotProviderConfig,
FieldSchema,
PlatformClient,
PlatformDefinition,
PlatformDocumentation,
PlatformMessenger,
SerializedPlatformDefinition,
UsageStats,
ValidationResult,
} from './types';
export { ClientFactory } from './types';
export {
buildRuntimeKey,
extractDefaults,
formatDuration,
formatTokens,
formatUsageStats,
parseRuntimeKey,
} from './utils';
// --------------- Platform definitions ---------------
export { discord } from './discord/definition';
export { feishu } from './feishu/definitions/feishu';
export { lark } from './feishu/definitions/lark';
export { qq } from './qq/definition';
export { telegram } from './telegram/definition';
export const platformRegistry = new PlatformRegistry();
platformRegistry.register(discord);
platformRegistry.register(telegram);
platformRegistry.register(feishu);
platformRegistry.register(lark);
platformRegistry.register(qq);
-53
View File
@@ -1,53 +0,0 @@
import debug from 'debug';
import { LarkRestApi } from '../larkRestApi';
import type { PlatformBot } from '../types';
const log = debug('lobe-server:bot:gateway:lark');
export interface LarkBotConfig {
[key: string]: string | undefined;
appId: string;
appSecret: string;
/** AES decrypt key for encrypted events (optional) */
encryptKey?: string;
/** 'lark' or 'feishu' — determines API base URL */
platform?: string;
/** Verification token for webhook event validation (optional) */
verificationToken?: string;
}
/**
* Lark/Feishu platform bot.
*
* Unlike Telegram, Lark does not support programmatic webhook registration.
* The user must configure the webhook URL manually in the Lark Developer Console.
* `start()` verifies credentials by fetching a tenant access token.
*/
export class Lark implements PlatformBot {
readonly platform: string;
readonly applicationId: string;
private config: LarkBotConfig;
constructor(config: LarkBotConfig) {
this.config = config;
this.applicationId = config.appId;
this.platform = config.platform || 'lark';
}
async start(): Promise<void> {
log('Starting LarkBot appId=%s platform=%s', this.applicationId, this.platform);
// Verify credentials by fetching a tenant access token
const api = new LarkRestApi(this.config.appId, this.config.appSecret, this.platform);
await api.getTenantAccessToken();
log('LarkBot appId=%s credentials verified', this.applicationId);
}
async stop(): Promise<void> {
log('Stopping LarkBot appId=%s', this.applicationId);
// No cleanup needed — webhook is managed in Lark Developer Console
}
}
@@ -0,0 +1,140 @@
import { createQQAdapter, QQApiClient } from '@lobechat/chat-adapter-qq';
import debug from 'debug';
import {
type BotPlatformRuntimeContext,
type BotProviderConfig,
ClientFactory,
type PlatformClient,
type PlatformMessenger,
type ValidationResult,
} from '../types';
const log = debug('bot-platform:qq:bot');
function extractChatId(platformThreadId: string): string {
return platformThreadId.split(':')[2];
}
function extractThreadType(platformThreadId: string): string {
return platformThreadId.split(':')[1] || 'group';
}
async function sendQQMessage(
api: QQApiClient,
threadType: string,
targetId: string,
content: string,
): Promise<void> {
switch (threadType) {
case 'group': {
await api.sendGroupMessage(targetId, content);
return;
}
case 'guild': {
await api.sendGuildMessage(targetId, content);
return;
}
case 'c2c': {
await api.sendC2CMessage(targetId, content);
return;
}
case 'dms': {
await api.sendDmsMessage(targetId, content);
return;
}
default: {
await api.sendGroupMessage(targetId, content);
}
}
}
class QQWebhookClient implements PlatformClient {
readonly id = 'qq';
readonly applicationId: string;
private config: BotProviderConfig;
constructor(config: BotProviderConfig, _context: BotPlatformRuntimeContext) {
this.config = config;
this.applicationId = config.applicationId;
}
// --- Lifecycle ---
async start(): Promise<void> {
log('Starting QQBot appId=%s', this.applicationId);
// Verify credentials by fetching an access token
const api = new QQApiClient(this.config.credentials.appId, this.config.credentials.appSecret);
await api.getAccessToken();
log('QQBot appId=%s credentials verified', this.applicationId);
}
async stop(): Promise<void> {
log('Stopping QQBot appId=%s', this.applicationId);
// No cleanup needed — webhook is configured in QQ Open Platform
}
// --- Runtime Operations ---
createAdapter(): Record<string, any> {
return {
qq: createQQAdapter({
appId: this.config.credentials.appId,
clientSecret: this.config.credentials.appSecret,
}),
};
}
getMessenger(platformThreadId: string): PlatformMessenger {
const api = new QQApiClient(this.config.credentials.appId, this.config.credentials.appSecret);
const targetId = extractChatId(platformThreadId);
const threadType = extractThreadType(platformThreadId);
return {
createMessage: (content) => sendQQMessage(api, threadType, targetId, content),
editMessage: (_messageId, content) =>
// QQ does not support editing — send a new message as fallback
sendQQMessage(api, threadType, targetId, content),
// QQ Bot API doesn't support reactions or typing
removeReaction: () => Promise.resolve(),
triggerTyping: () => Promise.resolve(),
};
}
extractChatId(platformThreadId: string): string {
return extractChatId(platformThreadId);
}
parseMessageId(compositeId: string): string {
return compositeId;
}
}
export class QQClientFactory extends ClientFactory {
createClient(config: BotProviderConfig, context: BotPlatformRuntimeContext): PlatformClient {
return new QQWebhookClient(config, context);
}
async validateCredentials(credentials: Record<string, string>): Promise<ValidationResult> {
const errors: Array<{ field: string; message: string }> = [];
if (!credentials.appId) errors.push({ field: 'appId', message: 'App ID is required' });
if (!credentials.appSecret)
errors.push({ field: 'appSecret', message: 'App Secret is required' });
if (errors.length > 0) return { errors, valid: false };
try {
const api = new QQApiClient(credentials.appId, credentials.appSecret);
await api.getAccessToken();
return { valid: true };
} catch {
return {
errors: [{ field: 'credentials', message: 'Failed to authenticate with QQ API' }],
valid: false,
};
}
}
}
@@ -0,0 +1,57 @@
import type { FieldSchema, PlatformDefinition } from '../types';
import { QQClientFactory } from './client';
const settings: FieldSchema[] = [
{
key: 'charLimit',
default: 2000,
group: 'general',
label: 'Character Limit',
minimum: 100,
type: 'number',
},
{
key: 'debounceMs',
default: 2000,
description: 'How long to wait for additional messages before dispatching to the agent (ms)',
group: 'general',
label: 'Message Merge Window (ms)',
minimum: 0,
type: 'number',
},
{
key: 'dm',
group: 'dm',
label: 'Direct Messages',
properties: [
{ key: 'enabled', default: true, label: 'Enable DMs', type: 'boolean' },
{
key: 'policy',
default: 'open',
enum: ['open', 'allowlist', 'disabled'],
enumLabels: ['Open', 'Allowlist', 'Disabled'],
label: 'DM Policy',
type: 'string',
visibleWhen: { field: 'enabled', value: true },
},
],
type: 'object',
},
];
export const qq: PlatformDefinition = {
id: 'qq',
name: 'QQ',
description: 'Connect a QQ bot',
documentation: {
portalUrl: 'https://q.qq.com/',
setupGuideUrl: 'https://lobehub.com/docs/usage/channels/qq',
},
credentials: [
{ key: 'appId', label: 'App ID', required: true, type: 'string' },
{ key: 'appSecret', label: 'App Secret', required: true, type: 'password' },
],
settings,
clientFactory: new QQClientFactory(),
};
@@ -0,0 +1,119 @@
import { describe, expect, it, vi } from 'vitest';
import { PlatformRegistry } from './registry';
import { buildRuntimeKey, parseRuntimeKey } from './utils';
describe('PlatformRegistry', () => {
const fakeFactory = (overrides?: any) => ({
createClient: vi.fn(),
validateCredentials: vi.fn().mockResolvedValue({ valid: true }),
validateSettings: vi.fn().mockResolvedValue({ valid: true }),
...overrides,
});
const fakeDef = (id: string, overrides?: any) =>
({
clientFactory: fakeFactory(overrides?.clientFactory),
...overrides,
id,
}) as any;
describe('register / getPlatform', () => {
it('should register and retrieve a platform definition', () => {
const registry = new PlatformRegistry();
const def = fakeDef('test');
registry.register(def);
expect(registry.getPlatform('test')).toBe(def);
});
it('should throw on duplicate registration', () => {
const registry = new PlatformRegistry();
registry.register(fakeDef('test'));
expect(() => registry.register(fakeDef('test'))).toThrow('already registered');
});
it('should return undefined for unknown platform', () => {
const registry = new PlatformRegistry();
expect(registry.getPlatform('unknown')).toBeUndefined();
});
});
describe('listPlatforms', () => {
it('should list all registered definitions', () => {
const registry = new PlatformRegistry();
const a = fakeDef('a');
const b = fakeDef('b');
registry.register(a).register(b);
expect(registry.listPlatforms()).toEqual([a, b]);
});
});
describe('createClient', () => {
it('should delegate to definition.clientFactory.createClient', () => {
const mockClient = { id: 'test' };
const mockCreateClient = vi.fn().mockReturnValue(mockClient);
const registry = new PlatformRegistry();
registry.register(fakeDef('test', { clientFactory: { createClient: mockCreateClient } }));
const config = { applicationId: 'app-1', credentials: {}, platform: 'test', settings: {} };
const result = registry.createClient('test', config);
expect(result).toBe(mockClient);
expect(mockCreateClient).toHaveBeenCalledWith(config, {});
});
it('should throw for unknown platform', () => {
const registry = new PlatformRegistry();
const config = { applicationId: 'app-1', credentials: {}, platform: 'x', settings: {} };
expect(() => registry.createClient('x', config)).toThrow('not registered');
});
});
describe('validateCredentials', () => {
it('should delegate to definition.clientFactory.validateCredentials', async () => {
const mockValidate = vi.fn().mockResolvedValue({ valid: true });
const registry = new PlatformRegistry();
registry.register(fakeDef('test', { clientFactory: { validateCredentials: mockValidate } }));
const result = await registry.validateCredentials('test', { token: 'abc' });
expect(result).toEqual({ valid: true });
expect(mockValidate).toHaveBeenCalledWith({ token: 'abc' }, undefined);
});
it('should return error for unknown platform', async () => {
const registry = new PlatformRegistry();
const result = await registry.validateCredentials('unknown', {});
expect(result.valid).toBe(false);
});
});
});
describe('buildRuntimeKey', () => {
it('should build a runtime key from entry and applicationId', () => {
expect(buildRuntimeKey('telegram', 'bot-123')).toBe('telegram:bot-123');
});
});
describe('parseRuntimeKey', () => {
it('should parse a runtime key into components', () => {
expect(parseRuntimeKey('discord:app-456')).toEqual({
applicationId: 'app-456',
platform: 'discord',
});
});
it('should roundtrip with buildRuntimeKey', () => {
const key = buildRuntimeKey('feishu', 'my-app');
expect(parseRuntimeKey(key)).toEqual({
applicationId: 'my-app',
platform: 'feishu',
});
});
});
@@ -0,0 +1,80 @@
import type {
BotPlatformRuntimeContext,
BotProviderConfig,
PlatformClient,
PlatformDefinition,
SerializedPlatformDefinition,
ValidationResult,
} from './types';
/**
* Platform registry manages all platform definitions.
*
* Integrates with chat-sdk's Chat class by providing adapter creation
* and credential validation through the registered platform definitions.
*/
export class PlatformRegistry {
private platforms = new Map<string, PlatformDefinition>();
/** Register a platform definition. Throws if the platform ID is already registered. */
register(definition: PlatformDefinition): this {
if (this.platforms.has(definition.id)) {
throw new Error(`Platform "${definition.id}" is already registered`);
}
this.platforms.set(definition.id, definition);
return this;
}
/** Get a platform definition by ID. */
getPlatform(platform: string): PlatformDefinition | undefined {
return this.platforms.get(platform);
}
/** List all registered platform definitions. */
listPlatforms(): PlatformDefinition[] {
return [...this.platforms.values()];
}
/** List platform definitions serialized for frontend consumption. */
listSerializedPlatforms(): SerializedPlatformDefinition[] {
return this.listPlatforms().map(({ clientFactory, ...rest }) => rest);
}
/**
* Create a PlatformClient for a given platform.
*
* Looks up the platform definition and delegates to its createClient.
* Throws if the platform is not registered.
*/
createClient(
platform: string,
config: BotProviderConfig,
context?: BotPlatformRuntimeContext,
): PlatformClient {
const definition = this.platforms.get(platform);
if (!definition) {
throw new Error(`Platform "${platform}" is not registered`);
}
return definition.clientFactory.createClient(config, context ?? {});
}
/**
* Validate credentials for a given platform.
*
* Delegates to the platform's clientFactory.validateCredentials.
*/
async validateCredentials(
platform: string,
credentials: Record<string, string>,
settings?: Record<string, unknown>,
): Promise<ValidationResult> {
const definition = this.platforms.get(platform);
if (!definition) {
return {
errors: [{ field: 'platform', message: `Platform "${platform}" is not registered` }],
valid: false,
};
}
return definition.clientFactory.validateCredentials(credentials, settings);
}
}
@@ -1,106 +0,0 @@
import debug from 'debug';
import { appEnv } from '@/envs/app';
import type { PlatformBot } from '../types';
const log = debug('lobe-server:bot:gateway:telegram');
export interface TelegramBotConfig {
[key: string]: string | undefined;
botToken: string;
secretToken?: string;
/** Optional HTTPS proxy URL for webhook (e.g. cloudflare tunnel for local dev) */
webhookProxyUrl?: string;
}
/**
* Extract the bot user ID from a Telegram bot token.
* Telegram bot tokens have the format: `<bot_id>:<secret>`.
*/
function extractBotId(botToken: string): string {
const colonIndex = botToken.indexOf(':');
if (colonIndex === -1) return botToken;
return botToken.slice(0, colonIndex);
}
/**
* Call Telegram setWebhook API. Idempotent safe to call on every startup.
*/
export async function setTelegramWebhook(
botToken: string,
url: string,
secretToken?: string,
): Promise<void> {
const params: Record<string, string> = { url };
if (secretToken) {
params.secret_token = secretToken;
}
const response = await fetch(`https://api.telegram.org/bot${botToken}/setWebhook`, {
body: JSON.stringify(params),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to set Telegram webhook: ${response.status} ${text}`);
}
}
export class Telegram implements PlatformBot {
readonly platform = 'telegram';
readonly applicationId: string;
private config: TelegramBotConfig;
constructor(config: TelegramBotConfig) {
this.config = config;
this.applicationId = extractBotId(config.botToken);
}
async start(): Promise<void> {
log('Starting TelegramBot appId=%s', this.applicationId);
// Set the webhook URL so Telegram pushes updates to us.
// Include applicationId in the path so the router can do a direct lookup
// without iterating all registered bots.
// Always call setWebhook (it's idempotent) to ensure Telegram-side
// secret_token stays in sync with the adapter config.
const baseUrl = (this.config.webhookProxyUrl || appEnv.APP_URL || '').trim().replace(/\/$/, '');
const webhookUrl = `${baseUrl}/api/agent/webhooks/telegram/${this.applicationId}`;
await this.setWebhookInternal(webhookUrl);
log('TelegramBot appId=%s started', this.applicationId);
}
async stop(): Promise<void> {
log('Stopping TelegramBot appId=%s', this.applicationId);
// Optionally remove the webhook on stop
try {
await this.deleteWebhook();
} catch (error) {
log('Failed to delete webhook for appId=%s: %O', this.applicationId, error);
}
}
private async setWebhookInternal(url: string): Promise<void> {
await setTelegramWebhook(this.config.botToken, url, this.config.secretToken);
log('TelegramBot appId=%s webhook set to %s', this.applicationId, url);
}
private async deleteWebhook(): Promise<void> {
const response = await fetch(
`https://api.telegram.org/bot${this.config.botToken}/deleteWebhook`,
{ method: 'POST' },
);
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to delete Telegram webhook: ${response.status} ${text}`);
}
log('TelegramBot appId=%s webhook deleted', this.applicationId);
}
}
@@ -1,15 +1,14 @@
import debug from 'debug';
const log = debug('lobe-server:bot:telegram-rest');
const log = debug('bot-platform:telegram:client');
const TELEGRAM_API_BASE = 'https://api.telegram.org';
export const TELEGRAM_API_BASE = 'https://api.telegram.org';
/**
* Lightweight wrapper around the Telegram Bot API.
* Used by bot-callback webhooks to update messages directly
* (bypassing the Chat SDK adapter).
* Lightweight platform client for Telegram Bot API operations used by
* callback and extension flows outside the Chat SDK adapter surface.
*/
export class TelegramRestApi {
export class TelegramApi {
private readonly botToken: string;
constructor(botToken: string) {
@@ -0,0 +1,128 @@
import { createTelegramAdapter } from '@chat-adapter/telegram';
import debug from 'debug';
import {
type BotPlatformRuntimeContext,
type BotProviderConfig,
ClientFactory,
type PlatformClient,
type PlatformMessenger,
type ValidationResult,
} from '../types';
import { TELEGRAM_API_BASE, TelegramApi } from './api';
import { extractBotId, setTelegramWebhook } from './helpers';
const log = debug('bot-platform:telegram:bot');
function extractChatId(platformThreadId: string): string {
return platformThreadId.split(':')[1];
}
function parseTelegramMessageId(compositeId: string): number {
const colonIdx = compositeId.lastIndexOf(':');
return colonIdx !== -1 ? Number(compositeId.slice(colonIdx + 1)) : Number(compositeId);
}
class TelegramWebhookClient implements PlatformClient {
readonly id = 'telegram';
readonly applicationId: string;
private config: BotProviderConfig;
private context: BotPlatformRuntimeContext;
constructor(config: BotProviderConfig, context: BotPlatformRuntimeContext) {
this.config = config;
this.context = context;
this.applicationId = extractBotId(config.credentials.botToken);
}
// --- Lifecycle ---
async start(): Promise<void> {
log('Starting TelegramBot appId=%s', this.applicationId);
const baseUrl = (this.config.credentials.webhookProxyUrl || this.context.appUrl || '')
.trim()
.replace(/\/$/, '');
const webhookUrl = `${baseUrl}/api/agent/webhooks/telegram/${this.applicationId}`;
await setTelegramWebhook(
this.config.credentials.botToken,
webhookUrl,
this.config.credentials.secretToken,
);
log('TelegramBot appId=%s started, webhook=%s', this.applicationId, webhookUrl);
}
async stop(): Promise<void> {
log('Stopping TelegramBot appId=%s', this.applicationId);
try {
const response = await fetch(
`${TELEGRAM_API_BASE}/bot${this.config.credentials.botToken}/deleteWebhook`,
{ method: 'POST' },
);
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to delete Telegram webhook: ${response.status} ${text}`);
}
log('TelegramBot appId=%s webhook deleted', this.applicationId);
} catch (error) {
log('Failed to delete webhook for appId=%s: %O', this.applicationId, error);
}
}
// --- Runtime Operations ---
createAdapter(): Record<string, any> {
return {
telegram: createTelegramAdapter({
botToken: this.config.credentials.botToken,
secretToken: this.config.credentials.secretToken,
}),
};
}
getMessenger(platformThreadId: string): PlatformMessenger {
const telegram = new TelegramApi(this.config.credentials.botToken);
const chatId = extractChatId(platformThreadId);
return {
createMessage: (content) => telegram.sendMessage(chatId, content).then(() => {}),
editMessage: (messageId, content) =>
telegram.editMessageText(chatId, parseTelegramMessageId(messageId), content),
removeReaction: (messageId) =>
telegram.removeMessageReaction(chatId, parseTelegramMessageId(messageId)),
triggerTyping: () => telegram.sendChatAction(chatId, 'typing'),
};
}
extractChatId(platformThreadId: string): string {
return extractChatId(platformThreadId);
}
parseMessageId(compositeId: string): number {
return parseTelegramMessageId(compositeId);
}
}
export class TelegramClientFactory extends ClientFactory {
createClient(config: BotProviderConfig, context: BotPlatformRuntimeContext): PlatformClient {
return new TelegramWebhookClient(config, context);
}
async validateCredentials(credentials: Record<string, string>): Promise<ValidationResult> {
if (!credentials.botToken) {
return { errors: [{ field: 'botToken', message: 'Bot Token is required' }], valid: false };
}
try {
const res = await fetch(`${TELEGRAM_API_BASE}/bot${credentials.botToken}/getMe`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return { valid: true };
} catch {
return {
errors: [{ field: 'botToken', message: 'Failed to authenticate with Telegram API' }],
valid: false,
};
}
}
}
@@ -0,0 +1,71 @@
import type { FieldSchema, PlatformDefinition } from '../types';
import { TelegramClientFactory } from './client';
const settings: FieldSchema[] = [
{
key: 'charLimit',
default: 4000,
group: 'general',
label: 'Character Limit',
minimum: 100,
type: 'number',
},
{
key: 'debounceMs',
default: 2000,
description: 'How long to wait for additional messages before dispatching to the agent (ms)',
group: 'general',
label: 'Message Merge Window (ms)',
minimum: 0,
type: 'number',
},
{
key: 'dm',
group: 'dm',
label: 'Direct Messages',
properties: [
{ key: 'enabled', default: true, label: 'Enable DMs', type: 'boolean' },
{
key: 'policy',
default: 'open',
enum: ['open', 'allowlist', 'disabled'],
enumLabels: ['Open', 'Allowlist', 'Disabled'],
label: 'DM Policy',
type: 'string',
visibleWhen: { field: 'enabled', value: true },
},
],
type: 'object',
},
];
export const telegram: PlatformDefinition = {
id: 'telegram',
name: 'Telegram',
description: 'Connect a Telegram bot',
documentation: {
portalUrl: 'https://t.me/BotFather',
setupGuideUrl: 'https://lobehub.com/docs/usage/channels/telegram',
},
credentials: [
{ key: 'botToken', label: 'Bot Token', required: true, type: 'password' },
{
key: 'secretToken',
description: 'Optional secret token for webhook verification',
label: 'Webhook Secret Token',
required: false,
type: 'password',
},
{
devOnly: true,
key: 'webhookProxyUrl',
description: 'HTTPS proxy URL for local development (e.g. Cloudflare tunnel)',
label: 'Webhook Proxy URL',
required: false,
type: 'string',
},
],
settings,
clientFactory: new TelegramClientFactory(),
};
@@ -0,0 +1,36 @@
import { TELEGRAM_API_BASE } from './api';
/**
* Extract the bot user ID from a Telegram bot token.
* Telegram bot tokens have the format: `<bot_id>:<secret>`.
*/
export function extractBotId(botToken: string): string {
const colonIndex = botToken.indexOf(':');
if (colonIndex === -1) return botToken;
return botToken.slice(0, colonIndex);
}
/**
* Call Telegram setWebhook API. Idempotent safe to call on every startup.
*/
export async function setTelegramWebhook(
botToken: string,
url: string,
secretToken?: string,
): Promise<void> {
const params: Record<string, string> = { url };
if (secretToken) {
params.secret_token = secretToken;
}
const response = await fetch(`${TELEGRAM_API_BASE}/bot${botToken}/setWebhook`, {
body: JSON.stringify(params),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to set Telegram webhook: ${response.status} ${text}`);
}
}
+243
View File
@@ -0,0 +1,243 @@
// ============================================================================
// Bot Platform Core Types
// ============================================================================
// --------------- Field Schema ---------------
/**
* Unified field schema for both credentials and settings.
*
* Drives:
* - Server: validation + default value extraction
* - Frontend: auto-generated form (type component mapping)
*/
export interface FieldSchema {
/** Default value (settings only) */
default?: unknown;
description?: string;
/** Only show in development environment */
devOnly?: boolean;
/** Enum options for select fields */
enum?: string[];
/** Display labels for enum options */
enumLabels?: string[];
/** Group key — fields with the same group render together */
group?: string;
/** Array item schema */
items?: FieldSchema;
/** Unique field identifier */
key: string;
/** Display label */
label: string;
maximum?: number;
minimum?: number;
placeholder?: string;
/** Nested fields (for type: 'object') */
properties?: FieldSchema[];
required?: boolean;
/**
* Field type, maps to UI component:
* - 'string' Input
* - 'password' Password input
* - 'number' / 'integer' NumberInput
* - 'boolean' Switch
* - 'object' nested group
* - 'array' list
*/
type: 'array' | 'boolean' | 'integer' | 'number' | 'object' | 'password' | 'string';
/** Conditional visibility: show only when another field matches a value */
visibleWhen?: { field: string; value: unknown };
}
// --------------- Platform Messenger ---------------
/**
* LobeHub-specific outbound capabilities used by callback and bridge services.
*/
export interface PlatformMessenger {
createMessage: (content: string) => Promise<void>;
editMessage: (messageId: string, content: string) => Promise<void>;
removeReaction: (messageId: string, emoji: string) => Promise<void>;
triggerTyping: () => Promise<void>;
updateThreadName?: (name: string) => Promise<void>;
}
// --------------- Usage Stats ---------------
/**
* Raw usage statistics for a bot response.
* Passed to `PlatformClient.formatReply` so each platform can decide
* whether and how to render usage information.
*/
export interface UsageStats {
elapsedMs?: number;
llmCalls?: number;
toolCalls?: number;
totalCost: number;
totalTokens: number;
}
// --------------- Platform Client ---------------
/**
* A client to a specific platform instance, holding credentials and runtime context.
*
* Server services interact with the platform through this interface only.
* All platform-specific operations are encapsulated here.
*/
export interface PlatformClient {
readonly applicationId: string;
/** Create a Chat SDK adapter config for inbound message handling. */
createAdapter: () => Record<string, any>;
/** Extract the chat/channel ID from a composite platformThreadId. */
extractChatId: (platformThreadId: string) => string;
/**
* Format the final outbound reply from body content and optional usage stats.
* Each platform decides whether to render the stats and how to format them
* (e.g. Discord uses `-# stats` when the user enables usage display).
* When not implemented, the caller returns body as-is (no stats).
*/
formatReply?: (body: string, stats?: UsageStats) => string;
/** Get a messenger for a specific thread (outbound messaging). */
getMessenger: (platformThreadId: string) => PlatformMessenger;
// --- Runtime Operations ---
readonly id: string;
/** Parse a composite message ID into the platform-native format. */
parseMessageId: (compositeId: string) => string | number;
/** Strip platform-specific bot mention artifacts from user input. */
sanitizeUserInput?: (text: string) => string;
/**
* Whether the bot should subscribe to a thread. Default: true.
* Discord: returns false for top-level channels (not threads).
*/
shouldSubscribe?: (threadId: string) => boolean;
// --- Lifecycle ---
start: (options?: any) => Promise<void>;
stop: () => Promise<void>;
}
// --------------- Provider Config ---------------
/**
* Represents a concrete bot provider configuration.
* Corresponds to a row in the `agentBotProviders` table.
*/
export interface BotProviderConfig {
applicationId: string;
credentials: Record<string, string>;
platform: string;
settings: Record<string, unknown>;
}
// --------------- Runtime Context ---------------
export interface BotPlatformRedisClient {
del: (key: string) => Promise<number>;
get: (key: string) => Promise<string | null>;
set: (key: string, value: string, options?: { ex?: number }) => Promise<string | null>;
subscribe?: (channel: string, callback: (message: string) => void) => Promise<void>;
}
export interface BotPlatformRuntimeContext {
appUrl?: string;
redisClient?: BotPlatformRedisClient;
registerByToken?: (token: string) => void;
}
// --------------- Validation ---------------
export interface ValidationResult {
errors?: Array<{ field: string; message: string }>;
valid: boolean;
}
// --------------- Platform Documentation ---------------
export interface PlatformDocumentation {
/** URL to the platform's developer portal / open platform console */
portalUrl?: string;
/** URL to the usage documentation (e.g. LobeHub docs for this platform) */
setupGuideUrl?: string;
}
// --------------- Client Factory ---------------
/**
* Abstract base class for creating PlatformClient instances.
*
* - `createClient` (abstract): instantiate a PlatformClient (e.g. based on connectionMode)
* - `validateCredentials`: verify credentials against the platform API called from UI flow only
* - `validateSettings`: validate platform-specific settings called from UI flow only
*/
export abstract class ClientFactory {
/** Create a PlatformClient instance. Fast and sync — no network calls. */
abstract createClient(
config: BotProviderConfig,
context: BotPlatformRuntimeContext,
): PlatformClient;
/**
* Verify credentials against the platform API.
* Called explicitly from the UI/API layer when the user saves credentials.
*/
async validateCredentials(
_credentials: Record<string, string>,
_settings?: Record<string, unknown>,
): Promise<ValidationResult> {
return { valid: true };
}
/**
* Validate platform-specific settings.
* Called explicitly from the UI/API layer when the user saves settings.
*/
async validateSettings(_settings: Record<string, unknown>): Promise<ValidationResult> {
return { valid: true };
}
}
// --------------- Platform Definition ---------------
/**
* A platform definition, uniquely identified by `id`.
*
* Contains metadata, factory, and validation. All runtime operations go through PlatformClient.
*/
export interface PlatformDefinition {
/** Factory for creating PlatformClient instances and validating credentials/settings. */
clientFactory: ClientFactory;
/** The credentials required for the platform. */
credentials: FieldSchema[];
/** The description of the platform. */
description?: string;
/** Documentation links for the platform */
documentation?: PlatformDocumentation;
/** The unique identifier of the platform. */
id: string;
/** The name of the platform. */
name: string;
/** Platform settings schema, drives form generation + default extraction. */
settings?: FieldSchema[];
}
/** Serialized platform definition for frontend consumption (excludes runtime-only fields). */
export type SerializedPlatformDefinition = Omit<
PlatformDefinition,
'clientFactory' | 'sanitizeUserInput'
>;
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import { formatDuration, formatTokens, formatUsageStats } from './utils';
describe('formatTokens', () => {
it('should return raw number for < 1000', () => {
expect(formatTokens(0)).toBe('0');
expect(formatTokens(999)).toBe('999');
});
it('should format thousands as k', () => {
expect(formatTokens(1000)).toBe('1.0k');
expect(formatTokens(1234)).toBe('1.2k');
expect(formatTokens(20_400)).toBe('20.4k');
});
it('should format millions as m', () => {
expect(formatTokens(1_000_000)).toBe('1.0m');
expect(formatTokens(1_234_567)).toBe('1.2m');
});
});
describe('formatDuration', () => {
it('should format seconds', () => {
expect(formatDuration(5000)).toBe('5s');
expect(formatDuration(0)).toBe('0s');
});
it('should format minutes and seconds', () => {
expect(formatDuration(65_000)).toBe('1m5s');
expect(formatDuration(120_000)).toBe('2m0s');
});
});
describe('formatUsageStats', () => {
it('should format basic stats', () => {
expect(formatUsageStats({ totalCost: 0.0312, totalTokens: 1234 })).toBe(
'1.2k tokens · $0.0312',
);
});
it('should include duration when provided', () => {
expect(formatUsageStats({ elapsedMs: 3000, totalCost: 0.01, totalTokens: 500 })).toBe(
'500 tokens · $0.0100 · 3s',
);
});
it('should include call counts when llmCalls > 1', () => {
expect(
formatUsageStats({ llmCalls: 3, toolCalls: 2, totalCost: 0.05, totalTokens: 2000 }),
).toBe('2.0k tokens · $0.0500 | llm×3 | tools×2');
});
it('should include call counts when toolCalls > 0', () => {
expect(formatUsageStats({ llmCalls: 1, toolCalls: 5, totalCost: 0.01, totalTokens: 800 })).toBe(
'800 tokens · $0.0100 | llm×1 | tools×5',
);
});
it('should hide call counts when llmCalls=1 and toolCalls=0', () => {
expect(
formatUsageStats({ llmCalls: 1, toolCalls: 0, totalCost: 0.001, totalTokens: 100 }),
).toBe('100 tokens · $0.0010');
});
});
@@ -0,0 +1,91 @@
import type { FieldSchema, UsageStats } from './types';
// --------------- Settings defaults ---------------
/**
* Recursively extract default values from a FieldSchema.
*/
function extractFieldDefault(field: FieldSchema): unknown {
if (field.type === 'object' && field.properties) {
const obj: Record<string, unknown> = {};
for (const child of field.properties) {
const value = extractFieldDefault(child);
if (value !== undefined) obj[child.key] = value;
}
return Object.keys(obj).length > 0 ? obj : undefined;
}
return field.default;
}
/**
* Extract defaults from a FieldSchema array.
*
* Recursively walks the fields and collects all `default` values.
* Use this to merge with user-provided settings at runtime:
*
* const settings = { ...extractDefaults(definition.settings), ...provider.settings };
*/
export function extractDefaults(fields?: FieldSchema[]): Record<string, unknown> {
if (!fields) return {};
const result: Record<string, unknown> = {};
for (const field of fields) {
const value = extractFieldDefault(field);
if (value !== undefined) result[field.key] = value;
}
return result;
}
// --------------- Runtime key helpers ---------------
/**
* Build a runtime key for a registered bot instance.
* Format: `platform:applicationId`
*/
export function buildRuntimeKey(platform: string, applicationId: string): string {
return `${platform}:${applicationId}`;
}
/**
* Parse a runtime key back into its components.
*/
export function parseRuntimeKey(key: string): {
applicationId: string;
platform: string;
} {
const idx = key.indexOf(':');
return {
applicationId: idx === -1 ? key : key.slice(idx + 1),
platform: idx === -1 ? '' : key.slice(0, idx),
};
}
// --------------- Formatting helpers ---------------
export function formatTokens(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}m`;
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`;
return String(tokens);
}
export function formatDuration(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes > 0) return `${minutes}m${seconds}s`;
return `${seconds}s`;
}
/**
* Format usage stats into a human-readable line.
* e.g. "1.2k tokens · $0.0312 · 3s | llm×5 | tools×4"
*/
export function formatUsageStats(stats: UsageStats): string {
const { totalTokens, totalCost, elapsedMs, llmCalls, toolCalls } = stats;
const time = elapsedMs && elapsedMs > 0 ? ` · ${formatDuration(elapsedMs)}` : '';
const calls =
(llmCalls && llmCalls > 1) || (toolCalls && toolCalls > 0)
? ` | llm×${llmCalls ?? 0} | tools×${toolCalls ?? 0}`
: '';
return `${formatTokens(totalTokens)} tokens · $${totalCost.toFixed(4)}${time}${calls}`;
}
+27 -106
View File
@@ -1,11 +1,11 @@
import type { StepPresentationData } from '../agentRuntime/types';
import { getExtremeAck } from './ackPhrases';
import { formatDuration } from './platforms';
// Use raw Unicode emoji instead of Chat SDK emoji placeholders,
// because bot-callback webhooks send via DiscordRestApi directly
// because bot-callback webhooks send via DiscordPlatformClient directly
// (not through the Chat SDK adapter that resolves placeholders).
const EMOJI_THINKING = '💭';
const EMOJI_SUCCESS = '✅';
// ==================== Message Splitting ====================
@@ -46,7 +46,6 @@ export interface RenderStepParams extends StepPresentationData {
elapsedMs?: number;
lastContent?: string;
lastToolsCalling?: ToolCallItem[];
platform?: string;
totalToolCalls?: number;
}
@@ -107,44 +106,14 @@ function formatCompletedTools(
.join('\n');
}
export function formatTokens(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}m`;
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`;
return String(tokens);
}
export { formatDuration, formatTokens } from './platforms';
export function formatDuration(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
function renderProgressHeader(params: { elapsedMs?: number; totalToolCalls?: number }): string {
const { elapsedMs, totalToolCalls } = params;
if (!totalToolCalls || totalToolCalls <= 0) return '';
if (minutes > 0) return `${minutes}m${seconds}s`;
return `${seconds}s`;
}
function renderInlineStats(params: {
elapsedMs?: number;
platform?: string;
totalCost: number;
totalTokens: number;
totalToolCalls?: number;
}): { footer: string; header: string } {
const { elapsedMs, platform, totalToolCalls, totalTokens, totalCost } = params;
const time = elapsedMs && elapsedMs > 0 ? ` · ${formatDuration(elapsedMs)}` : '';
const header =
totalToolCalls && totalToolCalls > 0
? `> total **${totalToolCalls}** tools calling ${time}\n\n`
: '';
if (totalTokens <= 0) return { footer: '', header };
const stats = `${formatTokens(totalTokens)} tokens · $${totalCost.toFixed(4)}`;
// Discord uses -# for small text; other platforms render it as literal text
const useSmallText = !platform || platform === 'discord';
const footer = useSmallText ? `\n\n-# ${stats}` : `\n\n${stats}`;
return { footer, header };
return `> total **${totalToolCalls}** tools calling ${time}\n\n`;
}
// ==================== 1. Start ====================
@@ -154,77 +123,44 @@ export const renderStart = getExtremeAck;
// ==================== 2. LLM Generating ====================
/**
* LLM step just finished. Three sub-states:
* - has reasoning (thinking)
* - pure text content
* - has tool calls (about to execute tools)
* LLM step just finished. Returns the message body (no usage stats).
* Stats are handled separately via `PlatformClient.formatReply`.
*/
export function renderLLMGenerating(params: RenderStepParams): string {
const {
content,
elapsedMs,
lastContent,
platform,
reasoning,
toolsCalling,
totalCost,
totalTokens,
totalToolCalls,
} = params;
const { content, elapsedMs, lastContent, reasoning, toolsCalling, totalToolCalls } = params;
const displayContent = (content || lastContent)?.trim();
const { header, footer } = renderInlineStats({
elapsedMs,
platform,
totalCost,
totalTokens,
totalToolCalls,
});
const header = renderProgressHeader({ elapsedMs, totalToolCalls });
// Sub-state: LLM decided to call tools → show content + pending tool calls (○)
if (toolsCalling && toolsCalling.length > 0) {
const toolsList = formatPendingTools(toolsCalling);
if (displayContent) return `${header}${displayContent}\n\n${toolsList}${footer}`;
return `${header}${toolsList}${footer}`;
if (displayContent) return `${header}${displayContent}\n\n${toolsList}`;
return `${header}${toolsList}`;
}
// Sub-state: has reasoning (thinking)
if (reasoning && !content) {
return `${header}${EMOJI_THINKING} ${reasoning?.trim()}${footer}`;
return `${header}${EMOJI_THINKING} ${reasoning?.trim()}`;
}
// Sub-state: pure text content (waiting for next step)
if (displayContent) {
return `${header}${displayContent}${footer}`;
return `${header}${displayContent}`;
}
return `${header}${EMOJI_THINKING} Processing...${footer}`;
return `${header}${EMOJI_THINKING} Processing...`;
}
// ==================== 3. Tool Executing ====================
/**
* Tool step just finished, LLM is next.
* Shows completed tools with results ().
* Returns the message body (no usage stats).
*/
export function renderToolExecuting(params: RenderStepParams): string {
const {
elapsedMs,
lastContent,
lastToolsCalling,
platform,
toolsResult,
totalCost,
totalTokens,
totalToolCalls,
} = params;
const { header, footer } = renderInlineStats({
elapsedMs,
platform,
totalCost,
totalTokens,
totalToolCalls,
});
const { elapsedMs, lastContent, lastToolsCalling, toolsResult, totalToolCalls } = params;
const header = renderProgressHeader({ elapsedMs, totalToolCalls });
const parts: string[] = [];
@@ -239,30 +175,17 @@ export function renderToolExecuting(params: RenderStepParams): string {
parts.push(`${EMOJI_THINKING} Processing...`);
}
return parts.join('\n\n') + footer;
return parts.join('\n\n');
}
// ==================== 4. Final Output ====================
export function renderFinalReply(
content: string,
params: {
elapsedMs?: number;
llmCalls: number;
platform?: string;
toolCalls: number;
totalCost: number;
totalTokens: number;
},
): string {
const { totalTokens, totalCost, llmCalls, toolCalls, elapsedMs, platform } = params;
const time = elapsedMs && elapsedMs > 0 ? ` · ${formatDuration(elapsedMs)}` : '';
const calls = llmCalls > 1 || toolCalls > 0 ? ` | llm×${llmCalls} | tools×${toolCalls}` : '';
const stats = `${formatTokens(totalTokens)} tokens · $${totalCost.toFixed(4)}${time}${calls}`;
// Discord uses -# for small text; other platforms render it as literal text
const useSmallText = !platform || platform === 'discord';
const footer = useSmallText ? `-# ${stats}` : stats;
return `${content.trimEnd()}\n\n${footer}`;
/**
* Returns the final reply body (content only, no usage stats).
* Stats are handled separately via `PlatformClient.formatReply`.
*/
export function renderFinalReply(content: string): string {
return content.trimEnd();
}
export function renderError(errorMessage: string): string {
@@ -273,13 +196,11 @@ export function renderError(errorMessage: string): string {
/**
* Dispatch to the correct template based on step state.
* Returns message body only caller handles stats via platform.
*/
export function renderStepProgress(params: RenderStepParams): string {
if (params.stepType === 'call_llm') {
// LLM step finished → about to execute tools
return renderLLMGenerating(params);
}
// Tool step finished → LLM is next
return renderToolExecuting(params);
}
+4 -11
View File
@@ -1,11 +1,4 @@
export interface PlatformBot {
readonly applicationId: string;
readonly platform: string;
start: () => Promise<void>;
stop: () => Promise<void>;
}
export type PlatformBotClass = (new (config: any) => PlatformBot) & {
/** Whether instances require a persistent connection (e.g. WebSocket). */
persistent?: boolean;
};
/**
* Re-export core platform types.
*/
export type { PlatformClient, PlatformMessenger } from './platforms';
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getServerDB } from '@/database/core/db-adaptor';
import { AgentBotProviderModel } from '@/database/models/agentBotProvider';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import type { PlatformClient, PlatformDefinition } from '@/server/services/bot/platforms';
import { createGatewayManager, GatewayManager, getGatewayManager } from './GatewayManager';
@@ -28,16 +29,38 @@ vi.mock('@/server/modules/KeyVaultsEncrypt', () => ({
},
}));
// Helper: create a mock PlatformBot instance
const createMockBot = () => ({
// Helper: create a mock PlatformClient instance
const createMockBot = (): PlatformClient => ({
applicationId: 'app-1',
createAdapter: () => ({}),
extractChatId: (id: string) => id,
getMessenger: () => ({
createMessage: async () => {},
editMessage: async () => {},
removeReaction: async () => {},
triggerTyping: async () => {},
}),
parseMessageId: (id: string) => id,
id: 'slack',
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
});
// Helper: create a mock PlatformBot class (constructor)
const createMockBotClass = (instance = createMockBot()) => {
return vi.fn().mockImplementation(() => instance);
};
// Helper: create a fake definition that returns the given bot
const createFakeDefinition = (
id: string,
factoryFn?: (...args: any[]) => PlatformClient,
): PlatformDefinition =>
({
clientFactory: {
createClient: factoryFn || (() => createMockBot()),
validateCredentials: async () => ({ valid: true }),
validateSettings: async () => ({ valid: true }),
},
credentials: [],
name: id,
id,
}) as any;
describe('GatewayManager', () => {
let mockDb: any;
@@ -69,20 +92,19 @@ describe('GatewayManager', () => {
describe('constructor and isRunning', () => {
it('should initialize with isRunning = false', () => {
const manager = new GatewayManager({ registry: {} });
const manager = new GatewayManager({ definitions: [] });
expect(manager.isRunning).toBe(false);
});
it('should accept a registry configuration', () => {
const BotClass = createMockBotClass();
const manager = new GatewayManager({ registry: { slack: BotClass } });
it('should accept a definitions configuration', () => {
const manager = new GatewayManager({ definitions: [createFakeDefinition('slack')] });
expect(manager.isRunning).toBe(false);
});
});
describe('start', () => {
it('should set isRunning to true after start', async () => {
const manager = new GatewayManager({ registry: {} });
const manager = new GatewayManager({ definitions: [] });
await manager.start();
@@ -90,7 +112,7 @@ describe('GatewayManager', () => {
});
it('should not start again if already running', async () => {
const manager = new GatewayManager({ registry: {} });
const manager = new GatewayManager({ definitions: [] });
await manager.start();
expect(manager.isRunning).toBe(true);
@@ -110,7 +132,7 @@ describe('GatewayManager', () => {
it('should continue starting even if initial sync fails', async () => {
vi.mocked(getServerDB).mockRejectedValueOnce(new Error('DB connection failed'));
const manager = new GatewayManager({ registry: {} });
const manager = new GatewayManager({ definitions: [] });
// Should not throw
await expect(manager.start()).resolves.toBeUndefined();
@@ -120,7 +142,7 @@ describe('GatewayManager', () => {
describe('stop', () => {
it('should set isRunning to false after stop', async () => {
const manager = new GatewayManager({ registry: {} });
const manager = new GatewayManager({ definitions: [] });
await manager.start();
expect(manager.isRunning).toBe(true);
@@ -130,7 +152,7 @@ describe('GatewayManager', () => {
});
it('should do nothing if not running', async () => {
const manager = new GatewayManager({ registry: {} });
const manager = new GatewayManager({ definitions: [] });
// Should not throw
await expect(manager.stop()).resolves.toBeUndefined();
@@ -140,22 +162,19 @@ describe('GatewayManager', () => {
it('should stop all running bots', async () => {
const mockBot1 = createMockBot();
const mockBot2 = createMockBot();
const BotClass = vi
.fn()
.mockImplementationOnce(() => mockBot1)
.mockImplementationOnce(() => mockBot2);
const factory = vi.fn().mockReturnValueOnce(mockBot1).mockReturnValueOnce(mockBot2);
// Pre-load two bots by calling startBot
// Pre-load two bots by calling startClient
mockAgentBotProviderModel.findEnabledByApplicationId.mockResolvedValue({
applicationId: 'app-1',
credentials: { token: 'tok1' },
});
const manager = new GatewayManager({ registry: { slack: BotClass } });
const manager = new GatewayManager({ definitions: [createFakeDefinition('slack', factory)] });
await manager.start();
await manager.startBot('slack', 'app-1', 'user-1');
await manager.startBot('slack', 'app-2', 'user-2');
await manager.startClient('slack', 'app-1', 'user-1');
await manager.startClient('slack', 'app-2', 'user-2');
await manager.stop();
@@ -165,27 +184,27 @@ describe('GatewayManager', () => {
});
});
describe('startBot', () => {
describe('startClient', () => {
it('should do nothing when no provider is found in DB', async () => {
mockAgentBotProviderModel.findEnabledByApplicationId.mockResolvedValue(null);
const BotClass = createMockBotClass();
const factory = vi.fn();
const manager = new GatewayManager({ registry: { slack: BotClass } });
const manager = new GatewayManager({ definitions: [createFakeDefinition('slack', factory)] });
await manager.startBot('slack', 'app-123', 'user-abc');
await manager.startClient('slack', 'app-123', 'user-abc');
expect(BotClass).not.toHaveBeenCalled();
expect(factory).not.toHaveBeenCalled();
});
it('should do nothing when the platform is not in registry', async () => {
it('should do nothing when the platform is not registered', async () => {
mockAgentBotProviderModel.findEnabledByApplicationId.mockResolvedValue({
applicationId: 'app-123',
credentials: { token: 'tok' },
});
const manager = new GatewayManager({ registry: {} }); // empty registry
const manager = new GatewayManager({ definitions: [] }); // empty definitions
await manager.startBot('unsupported', 'app-123', 'user-abc');
await manager.startClient('unsupported', 'app-123', 'user-abc');
// No bot should be created
expect(vi.mocked(AgentBotProviderModel)).toHaveBeenCalled();
@@ -193,115 +212,85 @@ describe('GatewayManager', () => {
it('should start a bot and register it', async () => {
const mockBot = createMockBot();
const BotClass = createMockBotClass(mockBot);
const factory = vi.fn().mockReturnValue(mockBot);
mockAgentBotProviderModel.findEnabledByApplicationId.mockResolvedValue({
applicationId: 'app-123',
credentials: { token: 'tok123' },
});
const manager = new GatewayManager({ registry: { slack: BotClass } });
const manager = new GatewayManager({ definitions: [createFakeDefinition('slack', factory)] });
await manager.startBot('slack', 'app-123', 'user-abc');
await manager.startClient('slack', 'app-123', 'user-abc');
expect(BotClass).toHaveBeenCalledWith({
token: 'tok123',
applicationId: 'app-123',
platform: 'slack',
});
expect(factory).toHaveBeenCalled();
expect(mockBot.start).toHaveBeenCalled();
});
it('should stop existing bot before starting a new one for the same key', async () => {
const mockBot1 = createMockBot();
const mockBot2 = createMockBot();
const BotClass = vi
.fn()
.mockImplementationOnce(() => mockBot1)
.mockImplementationOnce(() => mockBot2);
const factory = vi.fn().mockReturnValueOnce(mockBot1).mockReturnValueOnce(mockBot2);
mockAgentBotProviderModel.findEnabledByApplicationId.mockResolvedValue({
applicationId: 'app-123',
credentials: { token: 'tok' },
});
const manager = new GatewayManager({ registry: { slack: BotClass } });
const manager = new GatewayManager({ definitions: [createFakeDefinition('slack', factory)] });
// Start bot first time
await manager.startBot('slack', 'app-123', 'user-abc');
await manager.startClient('slack', 'app-123', 'user-abc');
expect(mockBot1.start).toHaveBeenCalled();
// Start bot second time for same key — should stop first
await manager.startBot('slack', 'app-123', 'user-abc');
await manager.startClient('slack', 'app-123', 'user-abc');
expect(mockBot1.stop).toHaveBeenCalled();
expect(mockBot2.start).toHaveBeenCalled();
});
it('should pass credentials merged with applicationId to the bot constructor', async () => {
const mockBot = createMockBot();
const BotClass = createMockBotClass(mockBot);
mockAgentBotProviderModel.findEnabledByApplicationId.mockResolvedValue({
applicationId: 'my-app',
credentials: { apiKey: 'key-abc', secret: 'sec-xyz' },
});
const manager = new GatewayManager({ registry: { discord: BotClass } });
await manager.startBot('discord', 'my-app', 'user-xyz');
expect(BotClass).toHaveBeenCalledWith({
apiKey: 'key-abc',
secret: 'sec-xyz',
applicationId: 'my-app',
platform: 'discord',
});
});
});
describe('stopBot', () => {
describe('stopClient', () => {
it('should do nothing when bot is not found', async () => {
const manager = new GatewayManager({ registry: {} });
const manager = new GatewayManager({ definitions: [] });
// Should not throw
await expect(manager.stopBot('slack', 'app-123')).resolves.toBeUndefined();
await expect(manager.stopClient('slack', 'app-123')).resolves.toBeUndefined();
});
it('should stop and remove a running bot', async () => {
const mockBot = createMockBot();
const BotClass = createMockBotClass(mockBot);
const factory = vi.fn().mockReturnValue(mockBot);
mockAgentBotProviderModel.findEnabledByApplicationId.mockResolvedValue({
applicationId: 'app-123',
credentials: { token: 'tok' },
});
const manager = new GatewayManager({ registry: { slack: BotClass } });
const manager = new GatewayManager({ definitions: [createFakeDefinition('slack', factory)] });
// First start the bot
await manager.startBot('slack', 'app-123', 'user-abc');
await manager.startClient('slack', 'app-123', 'user-abc');
expect(mockBot.start).toHaveBeenCalled();
// Then stop it
await manager.stopBot('slack', 'app-123');
await manager.stopClient('slack', 'app-123');
expect(mockBot.stop).toHaveBeenCalled();
});
it('should not affect other bots when stopping one', async () => {
const mockBot1 = createMockBot();
const mockBot2 = createMockBot();
const BotClass = vi
.fn()
.mockImplementationOnce(() => mockBot1)
.mockImplementationOnce(() => mockBot2);
const factory = vi.fn().mockReturnValueOnce(mockBot1).mockReturnValueOnce(mockBot2);
mockAgentBotProviderModel.findEnabledByApplicationId
.mockResolvedValueOnce({ applicationId: 'app-1', credentials: {} })
.mockResolvedValueOnce({ applicationId: 'app-2', credentials: {} });
const manager = new GatewayManager({ registry: { slack: BotClass } });
const manager = new GatewayManager({ definitions: [createFakeDefinition('slack', factory)] });
await manager.startBot('slack', 'app-1', 'user-1');
await manager.startBot('slack', 'app-2', 'user-2');
await manager.startClient('slack', 'app-1', 'user-1');
await manager.startClient('slack', 'app-2', 'user-2');
await manager.stopBot('slack', 'app-1');
await manager.stopClient('slack', 'app-1');
expect(mockBot1.stop).toHaveBeenCalled();
expect(mockBot2.stop).not.toHaveBeenCalled();
@@ -325,19 +314,19 @@ describe('createGatewayManager / getGatewayManager', () => {
});
it('should create and return a GatewayManager instance', () => {
const manager = createGatewayManager({ registry: {} });
const manager = createGatewayManager({ definitions: [] });
expect(manager).toBeInstanceOf(GatewayManager);
});
it('should return the same instance on subsequent calls (singleton)', () => {
const manager1 = createGatewayManager({ registry: {} });
const manager2 = createGatewayManager({ registry: { slack: vi.fn() as any } });
const manager1 = createGatewayManager({ definitions: [] });
const manager2 = createGatewayManager({ definitions: [createFakeDefinition('slack')] });
expect(manager1).toBe(manager2);
});
it('should be accessible via getGatewayManager after creation', () => {
const created = createGatewayManager({ registry: {} });
const created = createGatewayManager({ definitions: [] });
const retrieved = getGatewayManager();
expect(retrieved).toBe(created);
+61 -51
View File
@@ -3,22 +3,29 @@ import debug from 'debug';
import { getServerDB } from '@/database/core/db-adaptor';
import { AgentBotProviderModel } from '@/database/models/agentBotProvider';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import type { PlatformBot, PlatformBotClass } from '../bot/types';
import {
type BotProviderConfig,
buildRuntimeKey,
type PlatformClient,
type PlatformDefinition,
} from '@/server/services/bot/platforms';
const log = debug('lobe-server:bot-gateway');
export interface GatewayManagerConfig {
registry: Record<string, PlatformBotClass>;
definitions: PlatformDefinition[];
}
export class GatewayManager {
private bots = new Map<string, PlatformBot>();
private clients = new Map<string, PlatformClient>();
private running = false;
private config: GatewayManagerConfig;
private definitionByPlatform: Map<string, PlatformDefinition>;
constructor(config: GatewayManagerConfig) {
this.config = config;
this.definitionByPlatform = new Map(this.config.definitions.map((e) => [e.id, e]));
}
get isRunning(): boolean {
@@ -42,7 +49,7 @@ export class GatewayManager {
});
this.running = true;
log('GatewayManager started with %d bots', this.bots.size);
log('GatewayManager started with %d clients', this.clients.size);
}
async stop(): Promise<void> {
@@ -50,29 +57,29 @@ export class GatewayManager {
log('Stopping GatewayManager');
for (const [key, bot] of this.bots) {
log('Stopping bot %s', key);
await bot.stop();
for (const [key, client] of this.clients) {
log('Stopping client %s', key);
await client.stop();
}
this.bots.clear();
this.clients.clear();
this.running = false;
log('GatewayManager stopped');
}
// ------------------------------------------------------------------
// Bot operations (point-to-point)
// Client operations (point-to-point)
// ------------------------------------------------------------------
async startBot(platform: string, applicationId: string, userId: string): Promise<void> {
const key = `${platform}:${applicationId}`;
async startClient(platform: string, applicationId: string, userId: string): Promise<void> {
const key = buildRuntimeKey(platform, applicationId);
// Stop existing if any
const existing = this.bots.get(key);
const existing = this.clients.get(key);
if (existing) {
log('Stopping existing bot %s before restart', key);
log('Stopping existing client %s before restart', key);
await existing.stop();
this.bots.delete(key);
this.clients.delete(key);
}
// Load from DB (user-scoped, single row)
@@ -86,25 +93,25 @@ export class GatewayManager {
return;
}
const bot = this.createBot(platform, provider);
if (!bot) {
const client = this.createClient(platform, provider);
if (!client) {
log('Unsupported platform: %s', platform);
return;
}
await bot.start();
this.bots.set(key, bot);
log('Started bot %s', key);
await client.start();
this.clients.set(key, client);
log('Started client %s', key);
}
async stopBot(platform: string, applicationId: string): Promise<void> {
const key = `${platform}:${applicationId}`;
const bot = this.bots.get(key);
if (!bot) return;
async stopClient(platform: string, applicationId: string): Promise<void> {
const key = buildRuntimeKey(platform, applicationId);
const client = this.clients.get(key);
if (!client) return;
await bot.stop();
this.bots.delete(key);
log('Stopped bot %s', key);
await client.stop();
this.clients.delete(key);
log('Stopped client %s', key);
}
// ------------------------------------------------------------------
@@ -112,7 +119,7 @@ export class GatewayManager {
// ------------------------------------------------------------------
private async sync(): Promise<void> {
for (const platform of Object.keys(this.config.registry)) {
for (const platform of this.definitionByPlatform.keys()) {
try {
await this.syncPlatform(platform);
} catch (error) {
@@ -136,40 +143,40 @@ export class GatewayManager {
for (const provider of providers) {
const { applicationId, credentials } = provider;
const key = `${platform}:${applicationId}`;
const key = buildRuntimeKey(platform, applicationId);
activeKeys.add(key);
log('Sync: processing provider %s, hasCredentials=%s', key, !!credentials);
const existing = this.bots.get(key);
const existing = this.clients.get(key);
if (existing) {
log('Sync: bot %s already running, skipping', key);
log('Sync: client %s already running, skipping', key);
continue;
}
const bot = this.createBot(platform, provider);
if (!bot) {
log('Sync: createBot returned null for %s', key);
const client = this.createClient(platform, provider);
if (!client) {
log('Sync: createClient returned null for %s', key);
continue;
}
try {
await bot.start();
this.bots.set(key, bot);
log('Sync: started bot %s', key);
await client.start();
this.clients.set(key, client);
log('Sync: started client %s', key);
} catch (err) {
log('Sync: failed to start bot %s: %O', key, err);
log('Sync: failed to start client %s: %O', key, err);
}
}
// Stop bots that are no longer in DB
for (const [key, bot] of this.bots) {
// Stop clients that are no longer in DB
for (const [key, client] of this.clients) {
if (!key.startsWith(`${platform}:`)) continue;
if (activeKeys.has(key)) continue;
log('Sync: bot %s removed from DB, stopping', key);
await bot.stop();
this.bots.delete(key);
log('Sync: client %s removed from DB, stopping', key);
await client.stop();
this.clients.delete(key);
}
}
@@ -177,21 +184,24 @@ export class GatewayManager {
// Factory
// ------------------------------------------------------------------
private createBot(
private createClient(
platform: string,
provider: { applicationId: string; credentials: Record<string, string> },
): PlatformBot | null {
const BotClass = this.config.registry[platform];
if (!BotClass) {
log('No bot class registered for platform: %s', platform);
): PlatformClient | null {
const def = this.definitionByPlatform.get(platform);
if (!def) {
log('No definition registered for platform: %s', platform);
return null;
}
return new BotClass({
...provider.credentials,
const config: BotProviderConfig = {
applicationId: provider.applicationId,
credentials: provider.credentials,
platform,
});
settings: {},
};
return def.clientFactory.createClient(config, {});
}
}
@@ -0,0 +1,195 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { PlatformDefinition } from '@/server/services/bot/platforms';
import { GatewayManager } from '../GatewayManager';
const mockFindEnabledByPlatform = vi.hoisted(() => vi.fn());
const mockFindEnabledByApplicationId = vi.hoisted(() => vi.fn());
const mockInitWithEnvKey = vi.hoisted(() => vi.fn());
const mockGetServerDB = vi.hoisted(() => vi.fn());
vi.mock('@/database/core/db-adaptor', () => ({
getServerDB: mockGetServerDB,
}));
vi.mock('@/database/models/agentBotProvider', () => {
const MockModel = vi.fn().mockImplementation(() => ({
findEnabledByApplicationId: mockFindEnabledByApplicationId,
}));
(MockModel as any).findEnabledByPlatform = mockFindEnabledByPlatform;
return { AgentBotProviderModel: MockModel };
});
vi.mock('@/server/modules/KeyVaultsEncrypt', () => ({
KeyVaultsGateKeeper: {
initWithEnvKey: mockInitWithEnvKey,
},
}));
// Fake platform definition for testing
const fakeDefinition: PlatformDefinition = {
clientFactory: {
createClient: (config: any) => ({
applicationId: config.applicationId,
createAdapter: () => ({}),
extractChatId: (id: string) => id,
getMessenger: () => ({
createMessage: async () => {},
editMessage: async () => {},
removeReaction: async () => {},
triggerTyping: async () => {},
}),
parseMessageId: (id: string) => id,
id: config.platform,
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
}),
validateCredentials: async () => ({ valid: true }),
validateSettings: async () => ({ valid: true }),
},
credentials: [],
name: 'Fake Platform',
id: 'fakeplatform',
} as any;
const FAKE_DB = {} as any;
const FAKE_GATEKEEPER = { decrypt: vi.fn() };
describe('GatewayManager', () => {
let manager: GatewayManager;
beforeEach(() => {
vi.clearAllMocks();
mockGetServerDB.mockResolvedValue(FAKE_DB);
mockInitWithEnvKey.mockResolvedValue(FAKE_GATEKEEPER);
mockFindEnabledByPlatform.mockResolvedValue([]);
mockFindEnabledByApplicationId.mockResolvedValue(null);
manager = new GatewayManager({ definitions: [fakeDefinition] });
});
describe('lifecycle', () => {
it('should start and set running state', async () => {
await manager.start();
expect(manager.isRunning).toBe(true);
});
it('should not start twice', async () => {
await manager.start();
await manager.start();
// findEnabledByPlatform should only be called once (during first start)
expect(mockFindEnabledByPlatform).toHaveBeenCalledTimes(1);
});
it('should stop and clear running state', async () => {
await manager.start();
await manager.stop();
expect(manager.isRunning).toBe(false);
});
it('should not throw when stopping while not running', async () => {
await expect(manager.stop()).resolves.toBeUndefined();
});
});
describe('sync', () => {
it('should start bots for enabled providers', async () => {
mockFindEnabledByPlatform.mockResolvedValue([
{
applicationId: 'app-1',
credentials: { key: 'value' },
},
]);
await manager.start();
expect(manager.isRunning).toBe(true);
});
it('should skip already running bots', async () => {
mockFindEnabledByPlatform.mockResolvedValue([
{
applicationId: 'app-1',
credentials: { key: 'value' },
},
]);
await manager.start();
expect(manager.isRunning).toBe(true);
});
it('should handle sync errors gracefully', async () => {
mockFindEnabledByPlatform.mockRejectedValue(new Error('DB connection failed'));
// Should not throw - error is caught internally
await expect(manager.start()).resolves.toBeUndefined();
expect(manager.isRunning).toBe(true);
});
});
describe('startClient', () => {
it('should handle missing provider gracefully', async () => {
await manager.start();
await expect(manager.startClient('fakeplatform', 'app-1', 'user-1')).resolves.toBeUndefined();
});
});
describe('stopClient', () => {
it('should stop a specific bot', async () => {
mockFindEnabledByPlatform.mockResolvedValue([
{
applicationId: 'app-1',
credentials: { key: 'value' },
},
]);
await manager.start();
await manager.stopClient('fakeplatform', 'app-1');
expect(manager.isRunning).toBe(true);
});
it('should handle stopping non-existent bot gracefully', async () => {
await manager.start();
await expect(manager.stopClient('fakeplatform', 'non-existent')).resolves.toBeUndefined();
});
});
describe('createConnector', () => {
it('should return null for unknown platform', async () => {
const managerWithEmpty = new GatewayManager({ definitions: [] });
mockFindEnabledByPlatform.mockResolvedValue([
{
applicationId: 'app-1',
credentials: { key: 'value' },
},
]);
// With no definitions, no bots should be created
await managerWithEmpty.start();
expect(managerWithEmpty.isRunning).toBe(true);
});
});
describe('sync removes stale bots', () => {
it('should stop bots no longer in DB on subsequent syncs', async () => {
mockFindEnabledByPlatform.mockResolvedValueOnce([
{
applicationId: 'app-1',
credentials: { key: 'value' },
},
]);
await manager.start();
expect(manager.isRunning).toBe(true);
});
});
});
@@ -2,6 +2,7 @@ import debug from 'debug';
import type Redis from 'ioredis';
import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis';
import { buildRuntimeKey, parseRuntimeKey } from '@/server/services/bot/platforms';
const log = debug('lobe-server:bot:connect-queue');
@@ -29,7 +30,7 @@ export class BotConnectQueue {
throw new Error('Redis is not available, cannot enqueue bot connect request');
}
const field = `${platform}:${applicationId}`;
const field = buildRuntimeKey(platform, applicationId);
const value: ConnectEntry = { timestamp: Date.now(), userId };
await this.redis.hset(QUEUE_KEY, field, JSON.stringify(value));
@@ -55,12 +56,12 @@ export class BotConnectQueue {
continue;
}
const separatorIdx = field.indexOf(':');
if (separatorIdx === -1) continue;
const parsed = parseRuntimeKey(field);
if (!parsed.platform || !parsed.applicationId) continue;
items.push({
applicationId: field.slice(separatorIdx + 1),
platform: field.slice(0, separatorIdx),
applicationId: parsed.applicationId,
platform: parsed.platform,
userId: entry.userId,
});
} catch {
@@ -80,7 +81,7 @@ export class BotConnectQueue {
async remove(platform: string, applicationId: string): Promise<void> {
if (!this.redis) return;
const field = `${platform}:${applicationId}`;
const field = buildRuntimeKey(platform, applicationId);
await this.redis.hdel(QUEUE_KEY, field);
log('Removed connect request: %s', field);
}
+42 -17
View File
@@ -1,6 +1,10 @@
import debug from 'debug';
import { platformBotRegistry } from '../bot/platforms';
import { getServerDB } from '@/database/core/db-adaptor';
import { AgentBotProviderModel } from '@/database/models/agentBotProvider';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { platformRegistry } from '../bot/platforms';
import { BotConnectQueue } from './botConnectQueue';
import { createGatewayManager, getGatewayManager } from './GatewayManager';
@@ -16,7 +20,7 @@ export class GatewayService {
return;
}
const manager = createGatewayManager({ registry: platformBotRegistry });
const manager = createGatewayManager({ definitions: platformRegistry.listPlatforms() });
await manager.start();
log('GatewayManager started');
@@ -30,29 +34,29 @@ export class GatewayService {
log('GatewayManager stopped');
}
async startBot(
async startClient(
platform: string,
applicationId: string,
userId: string,
): Promise<'started' | 'queued'> {
if (isVercel) {
const BotClass = platformBotRegistry[platform];
const isPersistent = BotClass?.persistent === true;
// Query DB to determine connection mode from user settings
const connectionMode = await this.getConnectionMode(platform, applicationId, userId);
if (isPersistent) {
if (connectionMode === 'websocket') {
// Persistent platforms (e.g. Discord WebSocket) cannot run in a
// serverless function — queue for the long-running cron gateway.
const queue = new BotConnectQueue();
await queue.push(platform, applicationId, userId);
log('Queued bot connect %s:%s', platform, applicationId);
log('Queued connect %s:%s', platform, applicationId);
return 'queued';
}
// Webhook-based platforms (Telegram, Lark, etc.) only need a single HTTP
// call, so we can run directly in a Vercel serverless function.
const manager = createGatewayManager({ registry: platformBotRegistry });
await manager.startBot(platform, applicationId, userId);
log('Started bot %s:%s (direct)', platform, applicationId);
// Webhook-based platforms only need a single HTTP call,
// so we can run directly in a Vercel serverless function.
const manager = createGatewayManager({ definitions: platformRegistry.listPlatforms() });
await manager.startClient(platform, applicationId, userId);
log('Started client %s:%s (direct)', platform, applicationId);
return 'started';
}
@@ -63,16 +67,37 @@ export class GatewayService {
manager = getGatewayManager();
}
await manager!.startBot(platform, applicationId, userId);
log('Started bot %s:%s', platform, applicationId);
await manager!.startClient(platform, applicationId, userId);
log('Started client %s:%s', platform, applicationId);
return 'started';
}
async stopBot(platform: string, applicationId: string): Promise<void> {
async stopClient(platform: string, applicationId: string): Promise<void> {
const manager = getGatewayManager();
if (!manager?.isRunning) return;
await manager.stopBot(platform, applicationId);
log('Stopped bot %s:%s', platform, applicationId);
await manager.stopClient(platform, applicationId);
log('Stopped client %s:%s', platform, applicationId);
}
/**
* Read the connectionMode from the bot provider's settings.
* Defaults to 'webhook' if not configured.
*/
private async getConnectionMode(
platform: string,
applicationId: string,
userId: string,
): Promise<string> {
try {
const serverDB = await getServerDB();
const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey();
const model = new AgentBotProviderModel(serverDB, userId, gateKeeper);
const provider = await model.findEnabledByApplicationId(platform, applicationId);
return (provider?.settings as any)?.connectionMode || 'webhook';
} catch (err) {
log('Failed to read connectionMode for %s:%s: %O', platform, applicationId, err);
return 'webhook';
}
}
}
+4
View File
@@ -1,6 +1,10 @@
import { lambdaClient } from '@/libs/trpc/client';
class AgentBotProviderService {
listPlatforms = async () => {
return lambdaClient.agentBotProvider.listPlatforms.query();
};
getByAgentId = async (agentId: string) => {
return lambdaClient.agentBotProvider.getByAgentId.query({ agentId });
};
+10
View File
@@ -1,12 +1,14 @@
import { type SWRResponse } from 'swr';
import { mutate, useClientDataSWR } from '@/libs/swr';
import type { SerializedPlatformDefinition } from '@/server/services/bot/platforms/types';
import { agentBotProviderService } from '@/services/agentBotProvider';
import { type StoreSetter } from '@/store/types';
import { type AgentStore } from '../../store';
const FETCH_BOT_PROVIDERS_KEY = 'agentBotProviders';
const FETCH_PLATFORM_DEFINITIONS_KEY = 'platformDefinitions';
export interface BotProviderItem {
applicationId: string;
@@ -76,6 +78,14 @@ export class BotSliceActionImpl {
{ fallbackData: [], revalidateOnFocus: false },
);
};
useFetchPlatformDefinitions = (): SWRResponse<SerializedPlatformDefinition[]> => {
return useClientDataSWR<SerializedPlatformDefinition[]>(
FETCH_PLATFORM_DEFINITIONS_KEY,
() => agentBotProviderService.listPlatforms(),
{ dedupingInterval: 300_000, fallbackData: [], revalidateOnFocus: false },
);
};
}
export type BotSliceAction = Pick<BotSliceActionImpl, keyof BotSliceActionImpl>;