mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
Compare commits
15 Commits
dev
...
fix/bot-error
| Author | SHA1 | Date | |
|---|---|---|---|
| a35a69d1e2 | |||
| 0b3713d79a | |||
| b65c06a02f | |||
| 2027df3d30 | |||
| 54e443bd55 | |||
| 3de1a4e412 | |||
| 69ba6e8714 | |||
| 5e39345c8d | |||
| 185e598532 | |||
| e680dd9b7c | |||
| c2dae40303 | |||
| d43dd2d7e0 | |||
| 265b39615d | |||
| 2b46f65571 | |||
| 802a8aee64 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||

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

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

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

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

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

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

|
||||
|
||||
Copy the generated URL, open it in your browser, select the server you want to add the bot to, and click **Authorize**.
|
||||
</Steps>
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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。
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
@@ -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={{
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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' },
|
||||
};
|
||||
|
||||
@@ -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[])
|
||||
: []),
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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
-2
@@ -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();
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+5
-6
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user