Compare commits

...

45 Commits

Author SHA1 Message Date
rdmclin2 e2d55e3433 chore: avoid loading all platform bots with signle webhook 2026-03-09 00:43:18 +08:00
rdmclin2 9f0ae47eab chore: add chat sdk debug 2026-03-09 00:03:56 +08:00
rdmclin2 c59552757c chore: add debug module 2026-03-08 22:56:33 +08:00
rdmclin2 647065402c chore: adjust discord form 2026-03-08 20:12:27 +08:00
rdmclin2 c67ef5f8d6 chore: use test id 2026-03-08 18:58:34 +08:00
rdmclin2 9760d0d458 chore: update feishu and lark color 2026-03-08 15:57:40 +08:00
rdmclin2 1db7487afd chore: verificationToken optional 2026-03-08 15:47:25 +08:00
rdmclin2 7cc55dd1b7 chore: update docs permission list 2026-03-08 15:19:50 +08:00
rdmclin2 b80fa29bcd chore: update verfication comment 2026-03-08 15:06:12 +08:00
rdmclin2 94f71160b8 chore: update feishu docs 2026-03-08 15:02:56 +08:00
rdmclin2 ca699bc58b chore: make verfication code required 2026-03-08 14:56:41 +08:00
rdmclin2 5c6c3c9d10 chore: update docs 2026-03-08 14:17:32 +08:00
rdmclin2 20c2b2d75a chore: update feishu and lark tenant 2026-03-08 14:12:31 +08:00
rdmclin2 5ad0c77264 docs: update feishu doc 2026-03-08 13:45:32 +08:00
rdmclin2 f887110b8c chore: add persist logic 2026-03-08 12:54:58 +08:00
rdmclin2 0f458a4504 fix: telegram webhook not set 2026-03-08 12:32:58 +08:00
rdmclin2 d3b9e9ddc2 chore: update copy text 2026-03-08 12:19:52 +08:00
rdmclin2 79e25b458b chore: optimize webhook url trim 2026-03-08 11:58:16 +08:00
rdmclin2 c35d606e7c fix: tsgo error 2026-03-08 01:15:04 +08:00
rdmclin2 fdbfa71635 fix: udpate variable import 2026-03-08 00:17:18 +08:00
rdmclin2 807cfd49a2 chore: update intergration to channel 2026-03-07 18:07:57 +08:00
rdmclin2 dd653f936e fix: create bot with wrong platform 2026-03-07 12:34:05 +08:00
rdmclin2 2f5be753c2 chore: remove unused import 2026-03-07 12:28:56 +08:00
rdmclin2 c0097ca036 chore: add channel docs 2026-03-07 12:15:24 +08:00
rdmclin2 a9f1c8abce chore: add doc link 2026-03-07 12:15:23 +08:00
rdmclin2 66ffbfda8b chore: remove webhook mode for discord 2026-03-07 12:15:23 +08:00
rdmclin2 c55a40c2ed fix: vercel function appId 2026-03-07 12:15:23 +08:00
rdmclin2 bd667b0677 fix: encrpted risk 2026-03-07 12:15:23 +08:00
rdmclin2 cb8b4d9f65 fix: token check logic 2026-03-07 12:15:23 +08:00
rdmclin2 71e6fdacbc fix: detail style 2026-03-07 12:15:23 +08:00
rdmclin2 1935edae10 chore: add lark icon 2026-03-07 12:15:23 +08:00
rdmclin2 d07587e61f chore: move developer mode to advanced setting 2026-03-07 12:15:23 +08:00
rdmclin2 b1f565e62a chore: adjust topic channel icon 2026-03-07 12:15:21 +08:00
rdmclin2 28256b45bc chore: clean speaker tag & add username api adapter 2026-03-07 12:13:17 +08:00
rdmclin2 42bc9edd63 feat: add lark chat adapter 2026-03-07 12:13:17 +08:00
rdmclin2 4dc9863c31 style: hide required mark 2026-03-07 12:13:17 +08:00
rdmclin2 5d315a1346 chore: update form item description 2026-03-07 12:13:17 +08:00
rdmclin2 fe525975c8 chore: update i18n keys to channel 2026-03-07 12:13:12 +08:00
rdmclin2 07f3928502 chore: channel form refact 2026-03-07 12:13:03 +08:00
rdmclin2 517a92b866 chore: update webhook url 2026-03-07 12:13:03 +08:00
rdmclin2 3287130eac feat: add topic list channel provider icon 2026-03-07 12:12:59 +08:00
rdmclin2 456e7b8ed0 fix: channel router 2026-03-07 12:07:56 +08:00
rdmclin2 b6587ec5d7 chore: rename from integration to channel 2026-03-07 12:07:56 +08:00
rdmclin2 e18437b2f9 chore: change integration to channel 2026-03-07 12:07:56 +08:00
rdmclin2 e4c90fc6f3 feat: support lark and feishu 2026-03-07 12:07:56 +08:00
89 changed files with 3333 additions and 995 deletions
+125
View File
@@ -0,0 +1,125 @@
---
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.
tags:
- Discord
- Message Channels
- Bot Setup
- Integration
---
# 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**.
</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.
## Prerequisites
- A LobeHub account with an active subscription
- A Discord account with **Manage Server** permission on the target server
## Step 1: Create a Discord Application and Bot
<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
In the left sidebar, click **Bot**. Customize the bot's username and avatar as needed.
### 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
- **Server Members Intent** — Recommended for user identification
- **Presence Intent** — Optional; enable if you want the bot to access user online/offline status
Click **Save Changes**.
### 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**
- **Public Key**
You will need all three values (Bot Token, Application ID, Public Key) in the next step.
</Steps>
## Step 2: Configure Discord in LobeHub
<Steps>
### Open Channel Settings
In LobeHub, navigate to your agent's settings, then select the **Channels** tab. Click **Discord** from the platform list.
### Fill in the Credentials
Enter the following fields:
- **Application ID** — The Application ID from your Discord app's General Information page
- **Bot Token** — The bot token you generated earlier
- **Public Key** — The Public Key from your Discord app, used for interaction verification
Your token will be encrypted and stored securely.
### Save Configuration
Click **Save Configuration**. Your credentials will be saved and LobeHub will start listening for Discord events.
</Steps>
## Step 3: Invite the Bot to Your Server
<Steps>
### Generate an Invite URL
In the Discord Developer Portal, go to **OAuth2** → **URL Generator**. Select the following scopes:
- `bot`
- `applications.commands`
Under **Bot Permissions**, select:
- View Channels
- Send Messages
- Read Message History
- Embed Links
- Attach Files
- Add Reactions (optional)
### 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>
## Step 4: Test the Connection
Back in LobeHub's channel settings for Discord, click **Test Connection** to verify everything is configured correctly. Then send a message to your bot in Discord to confirm it responds.
## Configuration Reference
| Field | Required | Description |
| ------------------ | -------- | ------------------------------------------------ |
| **Application ID** | Yes | Your Discord application's ID |
| **Bot Token** | Yes | Authentication token for your Discord bot |
| **Public Key** | Yes | Used to verify interaction requests from Discord |
## Troubleshooting
- **Bot not responding in server:** Confirm the bot has been invited to the server with the correct permissions, and Message Content Intent is enabled.
- **Test Connection failed:** Double-check the Application ID, Bot Token, and Public Key are correct.
+124
View File
@@ -0,0 +1,124 @@
---
title: 将 LobeHub 连接到 Discord
description: >-
了解如何创建一个 Discord 机器人并将其连接到您的 LobeHub 代理作为消息渠道,使您的 AI 助手能够直接在 Discord
服务器和私信中与用户互动。
tags:
- Discord
- 消息渠道
- 机器人设置
- 集成
---
# 将 LobeHub 连接到 Discord
<Callout type={'info'}>
此功能目前正在开发中,可能尚未完全稳定。您可以通过在 **设置** → **高级设置** → **开发者模式** 中启用 **开发者模式** 来使用此功能。
</Callout>
通过将 Discord 渠道连接到您的 LobeHub 代理,用户可以直接通过 Discord 服务器频道和私信与 AI 助手互动。
## 前置条件
- 一个拥有有效订阅的 LobeHub 账户
- 一个拥有目标服务器 **管理服务器** 权限的 Discord 账户
## 第一步:创建 Discord 应用程序和机器人
<Steps>
### 访问 Discord 开发者门户
访问 [Discord 开发者门户](https://discord.com/developers/applications),点击 **新建应用程序**。为您的应用程序命名(例如,“LobeHub 助手”),然后点击 **创建**。
### 创建机器人
在左侧菜单中,点击 **机器人**。根据需要自定义机器人的用户名和头像。
### 启用特权网关意图
在机器人设置页面,向下滚动到 **特权网关意图** 并启用以下选项:
- **消息内容意图** — 允许机器人读取消息内容(必需)
- **服务器成员意图** — 推荐启用,用于用户识别
- **在线状态意图** — 可选;如果希望机器人访问用户的在线 / 离线状态,请启用
点击 **保存更改**。
### 复制机器人令牌
在 **机器人** 页面,点击 **重置令牌** 以生成您的机器人令牌。复制并安全保存该令牌。
> **重要提示:** 请将您的机器人令牌视为密码。切勿公开分享或提交到版本控制系统。
### 复制应用程序 ID 和公钥
在左侧菜单中,转到 **常规信息**。复制并保存以下内容:
- **应用程序 ID**
- **公钥**
您将在下一步中需要这三个值(机器人令牌、应用程序 ID、公钥)。
</Steps>
## 第二步:在 LobeHub 中配置 Discord
<Steps>
### 打开渠道设置
在 LobeHub 中,导航到您的代理设置,然后选择 **渠道** 标签。点击平台列表中的 **Discord**。
### 填写凭据
输入以下字段:
- **应用程序 ID** — 来自 Discord 应用程序常规信息页面的应用程序 ID
- **机器人令牌** — 您之前生成的机器人令牌
- **公钥** — 来自 Discord 应用程序的公钥,用于交互验证
您的令牌将被加密并安全存储。
### 保存配置
点击 **保存配置**。您的凭据将被保存,LobeHub 将开始监听 Discord 事件。
</Steps>
## 第三步:邀请机器人加入您的服务器
<Steps>
### 生成邀请链接
在 Discord 开发者门户中,转到 **OAuth2** → **URL 生成器**。选择以下范围:
- `bot`
- `applications.commands`
在 **机器人权限** 下选择:
- 查看频道
- 发送消息
- 读取消息历史
- 嵌入链接
- 附加文件
- 添加反应(可选)
### 授权机器人
复制生成的链接,在浏览器中打开,选择您希望添加机器人的服务器,然后点击 **授权**。
</Steps>
## 第四步:测试连接
返回 LobeHub 的 Discord 渠道设置,点击 **测试连接** 以验证配置是否正确。然后在 Discord 中向您的机器人发送消息,确认其是否响应。
## 配置参考
| 字段 | 是否必需 | 描述 |
| ----------- | ---- | -------------------- |
| **应用程序 ID** | 是 | 您的 Discord 应用程序的 ID |
| **机器人令牌** | 是 | 您的 Discord 机器人的认证令牌 |
| **公钥** | 是 | 用于验证来自 Discord 的交互请求 |
## 故障排除
- **机器人未在服务器中响应:** 确认机器人已被邀请到服务器并拥有正确的权限,同时启用了消息内容意图。
- **测试连接失败:** 仔细检查应用程序 ID、机器人令牌和公钥是否正确。
+185
View File
@@ -0,0 +1,185 @@
---
title: Connect LobeHub to Feishu / Lark
description: >-
Learn how to create a Feishu (Lark) custom app and connect it to your LobeHub
agent as a message channel, enabling your AI assistant to interact with team
members in Feishu or Lark chats.
tags:
- Feishu
- Lark
- Message Channels
- Bot Setup
- Integration
---
# Connect LobeHub to Feishu / Lark
<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 Feishu (or Lark) channel to your LobeHub agent, team members can interact with the AI assistant directly in Feishu private chats and group conversations.
> Feishu is the Chinese version, and Lark is the international version. The setup process is identical — just use the corresponding platform portal.
## Prerequisites
- A LobeHub account with an active subscription
- A Feishu or Lark account with permissions to create enterprise apps
## Step 1: Create a Feishu / Lark App
<Steps>
### Open the Developer Portal
- **Feishu:** Visit [open.feishu.cn/app](https://open.feishu.cn/app)
- **Lark:** Visit [open.larksuite.com/app](https://open.larksuite.com/app)
Sign in with your account.
### Create an Enterprise App
Click **Create Enterprise App**. Fill in the app name (e.g., "LobeHub Assistant"), description, and icon, then submit the form.
### Copy App Credentials
Go to **Credentials & Basic Info** and copy:
- **App ID** (format: `cli_xxx`)
- **App Secret**
> **Important:** Keep your App Secret confidential. Never share it publicly.
</Steps>
## Step 2: Configure App Permissions and Bot
<Steps>
### Import Required Permissions
In your app settings, go to **Permissions & Scopes**, click **Batch Import**, and paste the JSON below to grant the bot all necessary permissions.
```json
{
"scopes": {
"tenant": [
"aily:file:read",
"aily:file:write",
"application:application.app_message_stats.overview:readonly",
"application:application:self_manage",
"application:bot.menu:write",
"cardkit:card:read",
"cardkit:card:write",
"contact:user.employee_id:readonly",
"corehr:file:download",
"event:ip_list",
"im:chat.access_event.bot_p2p_chat:read",
"im:chat.members:bot_access",
"im:message",
"im:message.group_at_msg:readonly",
"im:message.p2p_msg:readonly",
"im:message:readonly",
"im:message:send_as_bot",
"im:resource"
],
"user": [
"aily:file:read",
"aily:file:write",
"im:chat.access_event.bot_p2p_chat:read"
]
}
}
```
<Callout type={'warning'}>
The JSON above is for **Feishu (飞书)**. If you are using **Lark (international)**, some scopes may not be available (e.g. `aily:*`, `corehr:*`, `im:chat.access_event.bot_p2p_chat:read`). Remove any scopes that the batch import rejects.
</Callout>
### Enable Bot Capability
Go to **App Capability** → **Bot**. Toggle the bot capability on and set your preferred bot name.
</Steps>
## Step 3: Configure Feishu / Lark in LobeHub
<Steps>
### Open Channel Settings
In LobeHub, navigate to your agent's settings, then select the **Channels** tab. Click **飞书** (Feishu) or **Lark** from the platform list.
### Fill in App Credentials
Enter the following fields:
- **App ID** — The App ID from your Feishu/Lark app
- **App Secret** — The App Secret from your Feishu/Lark app
> You don't need to fill in **Verification Token** or **Encrypt Key** at this point — you can set them up after configuring the Event Subscription in Step 4.
### Save and Copy the Webhook URL
Click **Save Configuration**. After saving, an **Event Subscription URL** will be displayed. Copy this URL — you will need it in the next step.
</Steps>
## Step 4: Set Up Event Subscription in Feishu / Lark
<Steps>
### Open Event Subscription Settings
Go back to your app in the Feishu/Lark Developer Portal. Navigate to **Event Subscription**.
### Configure the Request URL
Paste the **Event Subscription URL** you copied from LobeHub into the **Request URL** field. The platform will verify the endpoint automatically.
### Add the Message Event
Add the following event:
- `im.message.receive_v1` — Triggered when a message is received
This allows your app to receive messages and forward them to LobeHub.
### (Recommended) Fill in Verification Token and Encrypt Key
After configuring Event Subscription, you can find the **Verification Token** and **Encrypt Key** at the top of the Event Subscription page under **Encryption Strategy**.
Go back to LobeHub's channel settings and fill in:
- **Verification Token** — Used to verify that webhook events originate from Feishu/Lark
- **Encrypt Key** (optional) — Used to decrypt encrypted event payloads
Click **Save Configuration** again to apply.
</Steps>
## Step 5: Publish the App
<Steps>
### Create a Version
In your app settings, go to **Version Management & Release**. Create a new version with release notes.
### Submit for Review
Submit the version for review and publish. For enterprise self-managed apps, approval is typically automatic.
</Steps>
## Step 6: Test the Connection
Back in LobeHub's channel settings, click **Test Connection** to verify the credentials. Then find your bot in Feishu/Lark by searching its name and send it a message to confirm it responds.
## Configuration Reference
| Field | Required | Description |
| -------------------------- | -------- | -------------------------------------------------------------------- |
| **App ID** | Yes | Your Feishu/Lark app's App ID (`cli_xxx`) |
| **App Secret** | Yes | Your Feishu/Lark app's App Secret |
| **Verification Token** | No | Verifies webhook event source (recommended) |
| **Encrypt Key** | No | Decrypts encrypted event payloads |
| **Event Subscription URL** | — | Auto-generated after saving; paste into Feishu/Lark Developer Portal |
## Troubleshooting
- **Event Subscription URL verification failed:** Ensure you saved the configuration in LobeHub first, and the URL was copied correctly.
- **Bot not responding:** Verify the app is published and approved, the bot capability is enabled, and the `im.message.receive_v1` event is subscribed.
- **Permission errors:** Confirm all required permissions are added and approved in the Developer Portal.
- **Test Connection failed:** Double-check the App ID and App Secret. For Lark, ensure you selected "Lark" (not "飞书") in LobeHub's channel settings.
+177
View File
@@ -0,0 +1,177 @@
---
title: 将 LobeHub 连接到飞书 / Lark
description: 了解如何创建飞书(Lark)自定义应用并将其连接到您的 LobeHub 代理作为消息渠道,使您的 AI 助手能够在飞书或 Lark 聊天中与团队成员互动。
tags:
- 飞书
- Lark
- 消息渠道
- 机器人设置
- 集成
---
# 将 LobeHub 连接到飞书 / Lark
<Callout type={'info'}>
此功能目前正在开发中,可能尚未完全稳定。您可以通过在 **设置** → **高级设置** → **开发者模式**
中启用 **开发者模式** 来使用此功能。
</Callout>
通过将飞书(或 Lark)渠道连接到您的 LobeHub 代理,团队成员可以直接在飞书的私聊和群组对话中与 AI 助手互动。
> 飞书是中国版本,Lark 是国际版本。设置过程完全相同 —— 只需使用对应的平台门户即可。
## 前置条件
- 一个拥有有效订阅的 LobeHub 账户
- 一个拥有创建企业应用权限的飞书或 Lark 账户
## 第一步:创建飞书 / Lark 应用
<Steps>
### 打开开发者门户
- **飞书:** 访问 [open.feishu.cn/app](https://open.feishu.cn/app)
- **Lark** 访问 [open.larksuite.com/app](https://open.larksuite.com/app)
使用您的账户登录。
### 创建企业应用
点击 **创建企业应用**。填写应用名称(例如 "LobeHub 助手")、描述和图标,然后提交表单。
### 复制应用凭证
进入 **凭证与基本信息**,复制以下内容:
- **应用 ID**(格式:`cli_xxx`
- **应用密钥**
> **重要提示:** 请妥善保管您的应用密钥。切勿公开分享。
</Steps>
## 第二步:配置应用权限和机器人功能
<Steps>
### 导入所需权限
在您的应用设置中,进入 **权限与范围**,点击 **批量导入**,然后粘贴以下 JSON 以授予机器人所需的所有权限。
```json
{
"scopes": {
"tenant": [
"aily:file:read",
"aily:file:write",
"application:application.app_message_stats.overview:readonly",
"application:application:self_manage",
"application:bot.menu:write",
"cardkit:card:read",
"cardkit:card:write",
"contact:user.employee_id:readonly",
"corehr:file:download",
"event:ip_list",
"im:chat.access_event.bot_p2p_chat:read",
"im:chat.members:bot_access",
"im:message",
"im:message.group_at_msg:readonly",
"im:message.p2p_msg:readonly",
"im:message:readonly",
"im:message:send_as_bot",
"im:resource"
],
"user": [
"aily:file:read",
"aily:file:write",
"im:chat.access_event.bot_p2p_chat:read"
]
}
}
```
<Callout type={'warning'}>
以上 JSON 适用于**飞书**。如果您使用的是 **Lark(国际版)**,部分权限码可能不可用(如 `aily:*`、`corehr:*`、`im:chat.access_event.bot_p2p_chat:read`)。请移除批量导入时提示无效的权限码。
</Callout>
### 启用机器人功能
进入 **应用能力** → **机器人**。开启机器人功能并设置您喜欢的机器人名称。
</Steps>
## 第三步:在 LobeHub 中配置飞书 / Lark
<Steps>
### 打开渠道设置
在 LobeHub 中,导航到您的代理设置,然后选择 **渠道** 标签。点击平台列表中的 **飞书** 或 **Lark**。
### 填写应用凭证
输入以下字段:
- **应用 ID** — 来自飞书 / Lark 应用的应用 ID
- **应用密钥** — 来自飞书 / Lark 应用的应用密钥
- **Verification Token** — 用于验证 webhook 事件是否来自飞书 / Lark
您还可以选择配置以下内容:
- **Encrypt Key** — 用于解密飞书 / Lark 的加密事件负载
> Verification Token 和 Encrypt Key 可以在飞书 / Lark 开发者门户的 **事件订阅** → **加密策略** 中找到(位于页面顶部)。如果您还没有打开过事件订阅页面,可以在完成第四步后再回来填写 Verification Token。
### 保存并复制 Webhook URL
点击 **保存配置**。保存后,将显示一个 **事件订阅 URL**。复制此 URL—— 您将在下一步中需要它。
</Steps>
## 第四步:在飞书 / Lark 中设置事件订阅
<Steps>
### 打开事件订阅设置
返回飞书 / Lark 开发者门户中的应用。导航到 **事件订阅**。
### 配置请求 URL
将您从 LobeHub 复制的 **事件订阅 URL** 粘贴到 **请求 URL** 字段中。平台会自动验证端点。
### 添加消息事件
添加以下事件:
- `im.message.receive_v1` — 当收到消息时触发
这将使您的应用能够接收消息并将其转发到 LobeHub。
</Steps>
## 第五步:发布应用
<Steps>
### 创建版本
在您的应用设置中,进入 **版本管理与发布**。创建一个新版本并填写发布说明。
### 提交审核
提交版本进行审核并发布。对于企业自管理应用,通常会自动批准。
</Steps>
## 第六步:测试连接
回到 LobeHub 的渠道设置,点击 **测试连接** 以验证凭证。然后在飞书 / Lark 中搜索您的机器人名称并发送消息,确认其是否响应。
## 配置参考
| 字段 | 是否必需 | 描述 |
| ---------------------- | ---- | ------------------------------- |
| **应用 ID** | 是 | 您的飞书 / Lark 应用的应用 ID`cli_xxx` |
| **应用密钥** | 是 | 您的飞书 / Lark 应用的应用密钥 |
| **Verification Token** | 是 | 验证 webhook 事件来源 |
| **Encrypt Key** | 否 | 解密加密事件负载 |
| **事件订阅 URL** | — | 保存后自动生成;粘贴到飞书 / Lark 开发者门户 |
## 故障排除
- **事件订阅 URL 验证失败:** 确保您已在 LobeHub 中保存配置,并正确复制了 URL。
- **机器人未响应:** 验证应用已发布并获得批准,机器人功能已启用,并订阅了 `im.message.receive_v1` 事件。
- **权限错误:** 确保所有所需权限已在开发者门户中添加并获得批准。
- **测试连接失败:** 仔细检查应用 ID 和应用密钥。对于 Lark,请确保您在 LobeHub 的渠道设置中选择了 "Lark"(而不是 "飞书")。
+60
View File
@@ -0,0 +1,60 @@
---
title: Channels Overview
description: >-
Connect your LobeHub agents to external messaging platforms like Discord,
Telegram, and Feishu/Lark, allowing users to interact with AI assistants
directly in their favorite chat apps.
tags:
- Channels
- Message Channels
- Integration
- Discord
- Telegram
- Feishu
- Lark
---
# Channels
<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>
Channels allow you to connect your LobeHub agents to external messaging platforms. Once connected, users can interact with your AI assistant directly in the chat apps they already use — no need to visit LobeHub.
## Supported Platforms
| Platform | Description |
| -------------------------------------------- | --------------------------------------------------------------- |
| [Discord](/docs/usage/channels/discord) | Connect to Discord servers for channel chat and direct messages |
| [Telegram](/docs/usage/channels/telegram) | Connect to Telegram for private and group conversations |
| [Feishu / Lark](/docs/usage/channels/feishu) | Connect to Feishu (飞书) or Lark for team collaboration |
## How It Works
Each channel integration works by linking a bot account on the target platform to a LobeHub agent. When a user sends a message to the bot, LobeHub processes it through the agent and sends the response back to the same conversation.
- **Per-agent configuration** — Each agent can have its own set of channel connections, so different agents can serve different platforms or communities.
- **Multiple channels simultaneously** — A single agent can be connected to Discord, Telegram, and Feishu/Lark at the same time. LobeHub routes messages to the correct agent automatically.
- **Secure credential storage** — All bot tokens and app secrets are encrypted before being stored.
## Getting Started
1. Enable **Developer Mode** in LobeHub: **Settings** → **Advanced Settings** → **Developer Mode**
2. Navigate to your agent's settings and select the **Channels** tab
3. Choose a platform and follow the setup guide:
- [Discord](/docs/usage/channels/discord)
- [Telegram](/docs/usage/channels/telegram)
- [Feishu / Lark](/docs/usage/channels/feishu)
## Feature Support
Text messages are supported across all platforms. Some features vary by platform:
| Feature | Discord | Telegram | Feishu / Lark |
| ---------------------- | ------- | -------- | ------------- |
| Text messages | Yes | Yes | Yes |
| Direct messages | Yes | Yes | Yes |
| Group chats | Yes | Yes | Yes |
| Reactions | Yes | Yes | Partial |
| Image/file attachments | Yes | Yes | Yes |
+57
View File
@@ -0,0 +1,57 @@
---
title: 渠道概览
description: 将 LobeHub 代理连接到外部消息平台,如 Discord、Telegram 和飞书/Lark,让用户可以直接在他们喜欢的聊天应用中与 AI 助手互动。
tags:
- 渠道
- 消息渠道
- 集成
- Discord
- Telegram
- 飞书
- Lark
---
# 渠道
<Callout type={'info'}>
此功能目前正在开发中,可能尚未完全稳定。您可以通过在 **设置** → **高级设置** → **开发者模式** 中启用 **开发者模式** 来开启此功能。
</Callout>
渠道功能允许您将 LobeHub 代理连接到外部消息平台。一旦连接,用户可以直接在他们已经使用的聊天应用中与您的 AI 助手互动,无需访问 LobeHub。
## 支持的平台
| 平台 | 描述 |
| ----------------------------------------- | -------------------------- |
| [Discord](/docs/usage/channels/discord) | 连接到 Discord 服务器,用于频道聊天和私信 |
| [Telegram](/docs/usage/channels/telegram) | 连接到 Telegram,用于私人和群组对话 |
| [飞书 / Lark](/docs/usage/channels/feishu) | 连接到飞书(Feishu)或 Lark,用于团队协作 |
## 工作原理
每个渠道集成都通过将目标平台上的机器人账户与 LobeHub 代理连接来实现。当用户向机器人发送消息时,LobeHub 会通过代理处理消息并将响应发送回同一对话。
- **按代理配置** — 每个代理可以拥有自己的一组渠道连接,因此不同的代理可以服务于不同的平台或社区。
- **同时支持多个渠道** — 单个代理可以同时连接到 Discord、Telegram 和飞书 / Lark。LobeHub 会自动将消息路由到正确的代理。
- **安全的凭据存储** — 所有机器人令牌和应用密钥在存储前都会被加密。
## 快速开始
1. 在 LobeHub 中启用 **开发者模式****设置** → **高级设置** → **开发者模式**
2. 前往您的代理设置页面,选择 **渠道** 标签
3. 选择一个平台并按照设置指南操作:
- [Discord](/docs/usage/channels/discord)
- [Telegram](/docs/usage/channels/telegram)
- [飞书 / Lark](/docs/usage/channels/feishu)
## 功能支持
所有平台均支持文本消息。某些功能因平台而异:
| 功能 | Discord | Telegram | 飞书 / Lark |
| --------- | ------- | -------- | --------- |
| 文本消息 | 是 | 是 | 是 |
| 私人消息 | 是 | 是 | 是 |
| 群组聊天 | 是 | 是 | 是 |
| 表情反应 | 是 | 是 | 部分支持 |
| 图片 / 文件附件 | 是 | 是 | 是 |
+97
View File
@@ -0,0 +1,97 @@
---
title: Connect LobeHub to Telegram
description: >-
Learn how to create a Telegram bot and connect it to your LobeHub agent as a
message channel, enabling your AI assistant to chat with users in Telegram
private and group conversations.
tags:
- Telegram
- Message Channels
- Bot Setup
- Integration
---
# Connect LobeHub to Telegram
<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 Telegram channel to your LobeHub agent, users can interact with the AI assistant through Telegram private chats and group conversations.
## Prerequisites
- A LobeHub account with an active subscription
- A Telegram account
## Step 1: Create a Telegram Bot
<Steps>
### Open BotFather
Open Telegram and search for **@BotFather** — the official Telegram bot for managing bots. Start a conversation and send the `/newbot` command.
### Set Bot Name and Username
BotFather will ask you to:
1. Choose a **display name** for your bot (e.g., "LobeHub Assistant")
2. Choose a **username** — it must end with `bot` (e.g., `lobehub_assistant_bot`)
### Copy the Bot Token
After creating the bot, BotFather will send you an **API token** (format: `123456789:ABCdefGhIjKlmNoPQRsTuVwXyZ`). Copy and save this token.
> **Important:** Your bot token is a secret credential. Never share it publicly.
</Steps>
## Step 2: Configure Telegram in LobeHub
<Steps>
### Open Channel Settings
In LobeHub, navigate to your agent's settings, then select the **Channels** tab. Click **Telegram** from the platform list.
### Enter the Bot Token
Paste the bot token you received from BotFather into the **Bot Token** field.
The **Bot User ID** will be automatically derived from your token — no need to enter it manually.
### Optional: Set a Webhook Secret
You can optionally enter a **Webhook Secret Token** for additional security. This is used to verify that incoming webhook requests originate from Telegram.
### Save Configuration
Click **Save Configuration**. LobeHub will automatically register the webhook URL with Telegram — no manual URL copying is required.
Your token will be encrypted and stored securely.
</Steps>
## Step 3: Test the Connection
Click **Test Connection** in LobeHub's channel settings to verify the integration. Then open Telegram, find your bot by searching its username, and send a message. The bot should respond through your LobeHub agent.
## Adding the Bot to Group Chats
To use the bot in Telegram groups:
1. Add the bot as a member of the group
2. By default, the bot responds when mentioned with `@your_bot_username`
3. Send a message mentioning the bot to start interacting
## Configuration Reference
| Field | Required | Description |
| ------------------------ | -------- | ---------------------------------------------- |
| **Bot Token** | Yes | API token from BotFather |
| **Bot User ID** | Auto | Automatically derived from the bot token |
| **Webhook Secret Token** | No | Optional secret for verifying webhook requests |
## Troubleshooting
- **Bot not responding:** Verify the bot token is correct and the configuration is saved. Click **Test Connection** to diagnose.
- **Webhook registration failed:** Ensure your LobeHub subscription is active. Telegram requires HTTPS endpoints for webhooks, which LobeHub provides automatically.
- **Group chat issues:** Make sure the bot has been added to the group and has permission to read messages. Mention the bot with `@username` to trigger a response.
+95
View File
@@ -0,0 +1,95 @@
---
title: 将 LobeHub 连接到 Telegram
description: >-
学习如何创建一个 Telegram 机器人并将其连接到 LobeHub 代理作为消息渠道,使您的 AI 助手能够在 Telegram
私聊和群组对话中与用户互动。
tags:
- Telegram
- 消息渠道
- 机器人设置
- 集成
---
# 将 LobeHub 连接到 Telegram
<Callout type={'info'}>
此功能目前正在开发中,可能尚未完全稳定。您可以通过在 **设置** → **高级设置** → **开发者模式** 中启用 **开发者模式** 来使用此功能。
</Callout>
通过将 Telegram 渠道连接到您的 LobeHub 代理,用户可以通过 Telegram 私聊和群组对话与 AI 助手互动。
## 前置条件
- 一个拥有有效订阅的 LobeHub 账户
- 一个 Telegram 账户
## 第一步:创建 Telegram 机器人
<Steps>
### 打开 BotFather
打开 Telegram 并搜索 **@BotFather** —— 这是用于管理机器人的官方 Telegram 机器人。开始对话并发送 `/newbot` 命令。
### 设置机器人名称和用户名
BotFather 会要求您:
1. 为您的机器人选择一个 **显示名称**(例如,“LobeHub 助手”)
2. 选择一个 **用户名** —— 必须以 `bot` 结尾(例如,`lobehub_assistant_bot`
### 复制机器人令牌
创建机器人后,BotFather 会发送给您一个 **API 令牌**(格式:`123456789:ABCdefGhIjKlmNoPQRsTuVwXyZ`)。复制并保存此令牌。
> **重要提示:** 您的机器人令牌是一个机密凭证,请勿公开分享。
</Steps>
## 第二步:在 LobeHub 中配置 Telegram
<Steps>
### 打开渠道设置
在 LobeHub 中,导航到您的代理设置,然后选择 **渠道** 标签页。从平台列表中点击 **Telegram**。
### 输入机器人令牌
将您从 BotFather 收到的机器人令牌粘贴到 **机器人令牌** 字段中。
**机器人用户 ID** 将根据您的令牌自动生成,无需手动输入。
### 可选:设置 Webhook 密钥
您可以选择输入一个 **Webhook 密钥令牌** 以增加安全性。此密钥用于验证来自 Telegram 的入站 Webhook 请求。
### 保存配置
点击 **保存配置**。LobeHub 将自动向 Telegram 注册 Webhook URL,无需手动复制 URL。
您的令牌将被加密并安全存储。
</Steps>
## 第三步:测试连接
在 LobeHub 的渠道设置中点击 **测试连接** 以验证集成。然后打开 Telegram,搜索您的机器人用户名并发送消息。机器人应通过您的 LobeHub 代理进行响应。
## 将机器人添加到群组聊天
要在 Telegram 群组中使用机器人:
1. 将机器人添加为群组成员
2. 默认情况下,机器人在被 `@your_bot_username` 提及时会响应
3. 发送一条提及机器人的消息以开始互动
## 配置参考
| 字段 | 是否必需 | 描述 |
| ---------------- | ---- | --------------------- |
| **机器人令牌** | 是 | 来自 BotFather 的 API 令牌 |
| **机器人用户 ID** | 自动 | 根据机器人令牌自动生成 |
| **Webhook 密钥令牌** | 否 | 用于验证 Webhook 请求的可选密钥 |
## 故障排除
- **机器人未响应:** 验证机器人令牌是否正确并确保配置已保存。点击 **测试连接** 进行诊断。
- **Webhook 注册失败:** 确保您的 LobeHub 订阅处于活动状态。Telegram 要求 Webhook 使用 HTTPS 端点,LobeHub 会自动提供。
- **群组聊天问题:** 确保机器人已被添加到群组并具有读取消息的权限。使用 `@username` 提及机器人以触发响应。
@@ -1,7 +1,6 @@
---
title: 社区创作者
description: >-
加入 LobeHub 社区成为创作者——发布助理、分享工作流,打造帮助千万用户提效的工具。
description: 加入 LobeHub 社区成为创作者——发布助理、分享工作流,打造帮助千万用户提效的工具。
tags:
- LobeHub
- 社区创作者
+3 -4
View File
@@ -1,10 +1,9 @@
---
title: Introduction
description: >-
LobeHub is the next-generation agent harness designed to democratize AI
power. Move beyond one-off, task-driven tools and build long-term agent
teammates that grow with you in the worlds largest humanagent co-evolving
network.
LobeHub is the next-generation agent harness designed to democratize AI power.
Move beyond one-off, task-driven tools and build long-term agent teammates
that grow with you in the worlds largest humanagent co-evolving network.
tags:
- LobeHub
- Getting Started
+2 -2
View File
@@ -1,8 +1,8 @@
---
title: 简介
description: >-
LobeHub 是下一代 Agent harness,旨在让 AI 能力大众化。超越一次性、以任务为驱动的工具,构建能随着您一起成长的长期
Agent 队友,加入全球最大的人与 Agent 共生网络。
LobeHub 是下一代 Agent harness,旨在让 AI 能力大众化。超越一次性、以任务为驱动的工具,构建能随着您一起成长的长期 Agent
队友,加入全球最大的人与 Agent 共生网络。
tags:
- LobeHub
- 入门指南
+2 -1
View File
@@ -1,7 +1,8 @@
---
title: Interface Appearance
description: >-
Customize LobeHub's look — theme, colors, language, code highlighting, and Mermaid diagrams. Make it yours.
Customize LobeHub's look — theme, colors, language, code highlighting, and
Mermaid diagrams. Make it yours.
tags:
- LobeHub
- Appearance
@@ -1,7 +1,6 @@
---
title: 界面外观
description: >-
自定义 LobeHub 的外观——主题、色彩、语言、代码高亮与 Mermaid 图表。打造属于你的界面。
description: 自定义 LobeHub 的外观——主题、色彩、语言、代码高亮与 Mermaid 图表。打造属于你的界面。
tags:
- LobeHub
- 外观
+2 -1
View File
@@ -1,7 +1,8 @@
---
title: Command Menu
description: >-
The quick action center of LobeHub — search Agents, Topics, settings, and jump anywhere with a few keystrokes.
The quick action center of LobeHub — search Agents, Topics, settings, and jump
anywhere with a few keystrokes.
tags:
- LobeHub
- Command Menu
@@ -1,7 +1,6 @@
---
title: 命令菜单
description: >-
LobeHub 的快捷操作中心——搜索助理、话题、设置,用几个按键跳转到任意位置。
description: LobeHub 的快捷操作中心——搜索助理、话题、设置,用几个按键跳转到任意位置。
tags:
- LobeHub
- 命令菜单
+2 -1
View File
@@ -1,7 +1,8 @@
---
title: Keyboard Shortcuts
description: >-
Master LobeHub with keyboard shortcuts — command palette, Agent switching, focus mode, and more. Customize to fit your workflow.
Master LobeHub with keyboard shortcuts — command palette, Agent switching,
focus mode, and more. Customize to fit your workflow.
tags:
- LobeHub
- Keyboard Shortcuts
@@ -1,7 +1,6 @@
---
title: 快捷键
description: >-
用快捷键掌控 LobeHub——命令面板、助理切换、专注模式等。按你的习惯自定义。
description: 用快捷键掌控 LobeHub——命令面板、助理切换、专注模式等。按你的习惯自定义。
tags:
- LobeHub
- 快捷键
+2 -1
View File
@@ -1,7 +1,8 @@
---
title: Data Analytics
description: >-
Track your LobeHub usage — days active, Agents, conversations, model usage. Visualize patterns and share your stats.
Track your LobeHub usage — days active, Agents, conversations, model usage.
Visualize patterns and share your stats.
tags:
- LobeHub
- Data Analytics
+1 -2
View File
@@ -1,7 +1,6 @@
---
title: 数据统计
description: >-
追踪你的 LobeHub 使用情况——活跃天数、助理、对话、模型使用。可视化你的使用模式,并分享统计结果。
description: 追踪你的 LobeHub 使用情况——活跃天数、助理、对话、模型使用。可视化你的使用模式,并分享统计结果。
tags:
- LobeHub
- 数据统计
+12 -31
View File
@@ -11,7 +11,7 @@
import { Given, Then, When } from '@cucumber/cucumber';
import { expect } from '@playwright/test';
import { CustomWorld } from '../../support/world';
import type { CustomWorld } from '../../support/world';
// ============================================
// Given Steps
@@ -158,25 +158,9 @@ When('用户点击另一个对话', async function (this: CustomWorld) {
}
// Fallback: try to find topic items in the sidebar
// Topics are displayed with star icons (lucide-star) in the left sidebar
const sidebarTopics = this.page.locator('svg.lucide-star').locator('..').locator('..');
let topicCount = await sidebarTopics.count();
console.log(` 📍 Found ${topicCount} topics with star icons`);
// If not found by star, try finding by topic list structure
if (topicCount < 2) {
// Topics might be in a list container - look for items in sidebar with specific text
const topicItems = this.page.locator('[class*="nav-item"], [class*="NavItem"]');
topicCount = await topicItems.count();
console.log(` 📍 Found ${topicCount} nav items`);
if (topicCount >= 2) {
await topicItems.nth(1).click();
console.log(' ✅ 已点击另一个对话');
await this.page.waitForTimeout(500);
return;
}
}
const sidebarTopics = this.page.locator('[data-testid="topic-item"]');
const topicCount = await sidebarTopics.count();
console.log(` 📍 Found ${topicCount} topic items`);
// Click the second topic (first one is current/active)
if (topicCount >= 2) {
@@ -192,13 +176,11 @@ When('用户点击另一个对话', async function (this: CustomWorld) {
When('用户右键点击对话', async function (this: CustomWorld) {
console.log(' 📍 Step: 右键点击对话...');
// Find topic items by their star icon - each saved topic has a star
const sidebarTopics = this.page.locator('svg.lucide-star').locator('..').locator('..');
let topicCount = await sidebarTopics.count();
console.log(` 📍 Found ${topicCount} topics with star icons`);
const sidebarTopics = this.page.locator('[data-testid="topic-item"]');
const topicCount = await sidebarTopics.count();
console.log(` 📍 Found ${topicCount} topic items`);
if (topicCount > 0) {
// Right-click the first saved topic
await sidebarTopics.first().click({ button: 'right' });
console.log(' ✅ 已右键点击对话');
} else {
@@ -211,10 +193,9 @@ When('用户右键点击对话', async function (this: CustomWorld) {
When('用户右键点击一个对话', async function (this: CustomWorld) {
console.log(' 📍 Step: 右键点击一个对话...');
// Find topic items by their star icon
const sidebarTopics = this.page.locator('svg.lucide-star').locator('..').locator('..');
let topicCount = await sidebarTopics.count();
console.log(` 📍 Found ${topicCount} topics with star icons`);
const sidebarTopics = this.page.locator('[data-testid="topic-item"]');
const topicCount = await sidebarTopics.count();
console.log(` 📍 Found ${topicCount} topic items`);
// Store the topic text for later verification
if (topicCount > 0) {
@@ -238,7 +219,7 @@ When('用户选择重命名选项', async function (this: CustomWorld) {
// Instead of using right-click context menu, use the "..." dropdown menu
// which appears when hovering over a topic item
const topicItems = this.page.locator('svg.lucide-star').locator('..').locator('..');
const topicItems = this.page.locator('[data-testid="topic-item"]');
const topicCount = await topicItems.count();
console.log(` 📍 Found ${topicCount} topic items`);
@@ -253,7 +234,7 @@ When('用户选择重命名选项', async function (this: CustomWorld) {
// Important: we must find the icon WITHIN the hovered topic, not the global one
// The topic item has a specific structure with nav-item-actions
const moreButtonInTopic = firstTopic.locator('svg.lucide-ellipsis, svg.lucide-more-horizontal');
let moreButtonCount = await moreButtonInTopic.count();
const moreButtonCount = await moreButtonInTopic.count();
console.log(` 📍 Found ${moreButtonCount} more buttons inside topic`);
if (moreButtonCount > 0) {
+48 -37
View File
@@ -1,39 +1,50 @@
{
"integration.applicationId": "Application ID / Bot Username",
"integration.applicationIdPlaceholder": "e.g. 1234567890",
"integration.botToken": "Bot Token / API Key",
"integration.botTokenEncryptedHint": "Token will be encrypted and stored securely.",
"integration.botTokenHowToGet": "How to get?",
"integration.botTokenPlaceholderExisting": "Token is hidden for security",
"integration.botTokenPlaceholderNew": "Paste your bot token here",
"integration.connectionConfig": "Connection Configuration",
"integration.copied": "Copied to clipboard",
"integration.copy": "Copy",
"integration.deleteConfirm": "Are you sure you want to remove this integration?",
"integration.devWebhookProxyUrl": "HTTPS Tunnel URL",
"integration.devWebhookProxyUrlHint": "Telegram requires HTTPS for webhooks. Paste your tunnel URL (e.g. from cloudflared or ngrok) to forward webhook requests to your local dev server.",
"integration.disabled": "Disabled",
"integration.discord.description": "Connect this assistant to Discord server for channel chat and direct messages.",
"integration.documentation": "Documentation",
"integration.enabled": "Enabled",
"integration.endpointUrl": "Interaction Endpoint URL",
"integration.endpointUrlHint": "Please copy this URL and paste it into the <bold>\"Interactions Endpoint URL\"</bold> field in the {{name}} Developer Portal.",
"integration.platforms": "Platforms",
"integration.publicKey": "Public Key",
"integration.publicKeyPlaceholder": "Required for interaction verification",
"integration.removeFailed": "Failed to remove integration",
"integration.removeIntegration": "Remove Integration",
"integration.removed": "Integration removed",
"integration.save": "Save Configuration",
"integration.saveFailed": "Failed to save configuration",
"integration.saveFirstWarning": "Please save configuration first",
"integration.saved": "Configuration saved successfully",
"integration.secretToken": "Webhook Secret Token",
"integration.secretTokenHint": "Optional. Used to verify webhook requests from Telegram.",
"integration.secretTokenPlaceholder": "Optional secret for webhook verification",
"integration.testConnection": "Test Connection",
"integration.testFailed": "Connection test failed",
"integration.testSuccess": "Connection test passed",
"integration.updateFailed": "Failed to update status",
"integration.validationError": "Please fill in Application ID and Token"
"channel.appSecret": "App Secret",
"channel.appSecretPlaceholder": "Paste your app secret here",
"channel.applicationId": "Application ID / Bot Username",
"channel.applicationIdPlaceholder": "e.g. 1234567890",
"channel.botToken": "Bot Token / API Key",
"channel.botTokenEncryptedHint": "Token will be encrypted and stored securely.",
"channel.botTokenHowToGet": "How to get?",
"channel.botTokenPlaceholderExisting": "Token is hidden for security",
"channel.botTokenPlaceholderNew": "Paste your bot token here",
"channel.connectionConfig": "Connection Configuration",
"channel.copied": "Copied to clipboard",
"channel.copy": "Copy",
"channel.deleteConfirm": "Are you sure you want to remove this channel?",
"channel.devWebhookProxyUrl": "HTTPS Tunnel URL",
"channel.devWebhookProxyUrlHint": "Telegram requires HTTPS for webhooks. Paste your tunnel URL (e.g. from cloudflared or ngrok) to forward webhook requests to your local dev server.",
"channel.disabled": "Disabled",
"channel.discord.description": "Connect this assistant to Discord server for channel chat and direct messages.",
"channel.documentation": "Documentation",
"channel.enabled": "Enabled",
"channel.encryptKey": "Encrypt Key",
"channel.encryptKeyHint": "Optional. Used to decrypt encrypted event payloads.",
"channel.encryptKeyPlaceholder": "Optional encryption key",
"channel.endpointUrl": "Webhook URL",
"channel.endpointUrlHint": "Please copy this URL and paste it into the <bold>{{fieldName}}</bold> field in the {{name}} Developer Portal.",
"channel.feishu.description": "Connect this assistant to Feishu for private and group chats.",
"channel.lark.description": "Connect this assistant to Lark for private and group chats.",
"channel.platforms": "Platforms",
"channel.publicKey": "Public Key",
"channel.publicKeyPlaceholder": "Required for interaction verification",
"channel.removeChannel": "Remove Channel",
"channel.removeFailed": "Failed to remove channel",
"channel.removed": "Channel removed",
"channel.save": "Save Configuration",
"channel.saveFailed": "Failed to save configuration",
"channel.saveFirstWarning": "Please save configuration first",
"channel.saved": "Configuration saved successfully",
"channel.secretToken": "Webhook Secret Token",
"channel.secretTokenHint": "Optional. Used to verify webhook requests from Telegram.",
"channel.secretTokenPlaceholder": "Optional secret for webhook verification",
"channel.telegram.description": "Connect this assistant to Telegram for private and group chats.",
"channel.testConnection": "Test Connection",
"channel.testFailed": "Connection test failed",
"channel.testSuccess": "Connection test passed",
"channel.updateFailed": "Failed to update status",
"channel.validationError": "Please fill in Application ID and Token",
"channel.verificationToken": "Verification Token",
"channel.verificationTokenHint": "Optional. Used to verify webhook event source.",
"channel.verificationTokenPlaceholder": "Paste your verification token here"
}
+1 -1
View File
@@ -349,7 +349,7 @@
"supervisor.todoList.allComplete": "All tasks completed",
"supervisor.todoList.title": "Tasks Completed",
"tab.groupProfile": "Group Profile",
"tab.integration": "Integration",
"tab.integration": "Channels",
"tab.profile": "Agent Profile",
"tab.search": "Search",
"task.activity.calling": "Calling Skill...",
+1
View File
@@ -692,6 +692,7 @@
"tab.addCustomMcp": "Add Custom MCP Skill",
"tab.addCustomMcp.desc": "Manually configure a custom MCP server",
"tab.addCustomSkill": "Add",
"tab.advanced": "Advanced",
"tab.agent": "Agent Service",
"tab.all": "All",
"tab.apikey": "API Key Management",
+2
View File
@@ -6,11 +6,13 @@
"actions.confirmRemoveUnstarred": "You are about to delete unstarred topics. This action cannot be undone.",
"actions.duplicate": "Duplicate",
"actions.export": "Export Topics",
"actions.favorite": "Favorite",
"actions.import": "Import Conversation",
"actions.openInNewTab": "Open in New Tab",
"actions.openInNewWindow": "Open in a new window",
"actions.removeAll": "Delete All Topics",
"actions.removeUnstarred": "Delete Unstarred Topics",
"actions.unfavorite": "Unfavorite",
"defaultTitle": "Default Topic",
"displayItems": "Display Items",
"duplicateLoading": "Copying Topic...",
+50 -37
View File
@@ -1,39 +1,52 @@
{
"integration.applicationId": "应用 ID / Bot 用户名",
"integration.applicationIdPlaceholder": "例如 1234567890",
"integration.botToken": "Bot Token / API Key",
"integration.botTokenEncryptedHint": "Token 将被加密安全存储。",
"integration.botTokenHowToGet": "如何获取?",
"integration.botTokenPlaceholderExisting": "出于安全考虑,Token 已隐藏",
"integration.botTokenPlaceholderNew": "在此粘贴你的 Bot Token",
"integration.connectionConfig": "连接配置",
"integration.copied": "已复制到剪贴板",
"integration.copy": "复制",
"integration.deleteConfirm": "确定要移除此集成吗?",
"integration.devWebhookProxyUrl": "HTTPS 隧道地址",
"integration.devWebhookProxyUrlHint": "Telegram Webhook 需要 HTTPS。请粘贴隧道地址(如 cloudflared 或 ngrok 生成的 URL),将 Webhook 请求转发到本地开发服务器。",
"integration.disabled": "已禁用",
"integration.discord.description": "将助手连接到 Discord 服务器,支持频道聊天和私信。",
"integration.documentation": "文档",
"integration.enabled": "已用",
"integration.endpointUrl": "交互端点 URL",
"integration.endpointUrlHint": "请复制此 URL 并粘贴到 {{name}} 开发者门户的 <bold>\"Interactions Endpoint URL\"</bold> 字段中。",
"integration.platforms": "平台",
"integration.publicKey": "公钥",
"integration.publicKeyPlaceholder": "用于交互验证",
"integration.removeFailed": "移除集成失败",
"integration.removeIntegration": "移除集成",
"integration.removed": "集成已移除",
"integration.save": "保存配置",
"integration.saveFailed": "保存配置失败",
"integration.saveFirstWarning": "请先保存配置",
"integration.saved": "配置保存成功",
"integration.secretToken": "Webhook 密钥",
"integration.secretTokenHint": "可选。用于验证来自 Telegram 的 Webhook 请求。",
"integration.secretTokenPlaceholder": "可选的 Webhook 验证密钥",
"integration.testConnection": "测试连接",
"integration.testFailed": "连接测试失败",
"integration.testSuccess": "连接测试通过",
"integration.updateFailed": "更新状态失败",
"integration.validationError": "请填写应用 ID 和 Token"
"channel.appSecret": "App Secret",
"channel.appSecretPlaceholder": "在此粘贴你的 App Secret",
"channel.applicationId": "应用 ID / Bot 用户名",
"channel.applicationIdHint": "Bot 应用的唯一标识符。",
"channel.applicationIdPlaceholder": "例如 1234567890",
"channel.botToken": "Bot Token / API Key",
"channel.botTokenEncryptedHint": "Token 将被加密安全存储。",
"channel.botTokenHowToGet": "如何获取?",
"channel.botTokenPlaceholderExisting": "出于安全考虑,Token 已隐藏",
"channel.botTokenPlaceholderNew": "在此粘贴你的 Bot Token",
"channel.connectionConfig": "连接配置",
"channel.copied": "已复制到剪贴板",
"channel.copy": "复制",
"channel.deleteConfirm": "确定要移除此集成吗?",
"channel.devWebhookProxyUrl": "HTTPS 隧道地址",
"channel.devWebhookProxyUrlHint": "可选。用于将 Webhook 请求转发到本地开发服务器的 HTTPS 隧道地址。",
"channel.disabled": "已用",
"channel.discord.description": "将助手连接到 Discord 服务器,支持频道聊天和私信。",
"channel.documentation": "文档",
"channel.enabled": "已启用",
"channel.encryptKey": "Encrypt Key",
"channel.encryptKeyHint": "可选。用于解密加密的事件推送。",
"channel.encryptKeyPlaceholder": "可选的加密密钥",
"channel.endpointUrl": "Webhook URL",
"channel.endpointUrlHint": "请复制此 URL 并粘贴到 {{name}} 开发者门户的 <bold>{{fieldName}}</bold> 字段中。",
"channel.feishu.description": "将助手连接到飞书,支持私聊和群聊。",
"channel.lark.description": "将助手连接到 Lark,支持私聊和群聊。",
"channel.platforms": "平台",
"channel.publicKey": "公钥",
"channel.publicKeyHint": "可选。用于验证来自 Discord 的交互请求。",
"channel.publicKeyPlaceholder": "用于交互验证",
"channel.removeChannel": "移除频道",
"channel.removeFailed": "移除频道失败",
"channel.removed": "频道已移除",
"channel.save": "保存配置",
"channel.saveFailed": "保存配置失败",
"channel.saveFirstWarning": "请先保存配置",
"channel.saved": "配置保存成功",
"channel.secretToken": "Webhook 密钥",
"channel.secretTokenHint": "可选。用于验证来自 Telegram 的 Webhook 请求。",
"channel.secretTokenPlaceholder": "可选的 Webhook 验证密钥",
"channel.telegram.description": "将助手连接到 Telegram,支持私聊和群聊。",
"channel.testConnection": "测试连接",
"channel.testFailed": "连接测试失败",
"channel.testSuccess": "连接测试通过",
"channel.updateFailed": "更新状态失败",
"channel.validationError": "请填写应用 ID 和 Token",
"channel.verificationToken": "Verification Token",
"channel.verificationTokenHint": "可选。用于验证事件推送来源。",
"channel.verificationTokenPlaceholder": "在此粘贴你的 Verification Token"
}
+1 -1
View File
@@ -349,7 +349,7 @@
"supervisor.todoList.allComplete": "所有任务已完成",
"supervisor.todoList.title": "任务完成",
"tab.groupProfile": "群组档案",
"tab.integration": "集成",
"tab.integration": "消息频道",
"tab.profile": "助理档案",
"tab.search": "搜索",
"task.activity.calling": "正在调用技能…",
+1
View File
@@ -692,6 +692,7 @@
"tab.addCustomMcp": "添加自定义 MCP 技能",
"tab.addCustomMcp.desc": "手动配置自定义 MCP 服务器",
"tab.addCustomSkill": "添加",
"tab.advanced": "高级设置",
"tab.agent": "助理服务",
"tab.all": "全部",
"tab.apikey": "API Key 管理",
+2
View File
@@ -6,11 +6,13 @@
"actions.confirmRemoveUnstarred": "您即将删除未加星标的话题,此操作无法撤销。",
"actions.duplicate": "复制",
"actions.export": "导出话题",
"actions.favorite": "收藏",
"actions.import": "导入对话",
"actions.openInNewTab": "在新标签页中打开",
"actions.openInNewWindow": "打开独立窗口",
"actions.removeAll": "删除全部话题",
"actions.removeUnstarred": "删除未收藏话题",
"actions.unfavorite": "取消收藏",
"defaultTitle": "默认话题",
"displayItems": "显示条目",
"duplicateLoading": "话题复制中…",
+1
View File
@@ -187,6 +187,7 @@
"@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:*",
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@lobechat/adapter-lark",
"version": "0.1.0",
"description": "Lark/Feishu 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"
}
}
+460
View File
@@ -0,0 +1,460 @@
import type {
Adapter,
AdapterPostableMessage,
Author,
ChatInstance,
EmojiValue,
FetchOptions,
FetchResult,
FormattedContent,
Logger,
RawMessage,
ThreadInfo,
WebhookOptions,
} from 'chat';
import { Message, parseMarkdown } from 'chat';
import { LarkApiClient } from './api';
import { decryptLarkEvent } from './crypto';
import { LarkFormatConverter } from './format-converter';
import type {
LarkAdapterConfig,
LarkMessageBody,
LarkRawMessage,
LarkThreadId,
LarkWebhookPayload,
} from './types';
export class LarkAdapter implements Adapter<LarkThreadId, LarkRawMessage> {
readonly name: string;
private readonly api: LarkApiClient;
private readonly encryptKey?: string;
private readonly verificationToken?: string;
private readonly platform: 'lark' | 'feishu';
private readonly formatConverter: LarkFormatConverter;
private _userName: string;
private _botUserId?: string;
private chat!: ChatInstance;
private logger!: Logger;
private static SENDER_NAME_TTL_MS = 10 * 60_000;
private senderNameCache = new Map<string, { expireAt: number; name: string }>();
private senderNamePermissionDenied = false;
get userName(): string {
return this._userName;
}
get botUserId(): string | undefined {
return this._botUserId;
}
constructor(config: LarkAdapterConfig & { logger?: Logger; userName?: string }) {
this.platform = config.platform || 'lark';
this.name = this.platform;
this.api = new LarkApiClient(config.appId, config.appSecret, this.platform);
this.encryptKey = config.encryptKey;
this.verificationToken = config.verificationToken;
this.formatConverter = new LarkFormatConverter();
this._userName = config.userName || 'lark-bot';
}
async initialize(chat: ChatInstance): Promise<void> {
this.chat = chat;
this.logger = chat.getLogger(this.name);
this._userName = chat.getUserName();
// Validate credentials
await this.api.getTenantAccessToken();
// Try to fetch bot info for userName/botUserId
try {
const botInfo = await this.api.getBotInfo();
if (botInfo) {
if (botInfo.app_name) this._userName = botInfo.app_name;
if (botInfo.open_id) this._botUserId = botInfo.open_id;
}
} catch {
// Bot info not critical — continue
}
this.logger.info('Initialized %s adapter (botUserId=%s)', this.name, this._botUserId);
}
// ------------------------------------------------------------------
// Webhook handling
// ------------------------------------------------------------------
async handleWebhook(request: Request, options?: WebhookOptions): Promise<Response> {
const bodyText = await request.text();
let body: LarkWebhookPayload;
try {
body = JSON.parse(bodyText);
} catch {
return new Response('Invalid JSON', { status: 400 });
}
// Decrypt encrypted events if needed
if (body.encrypt) {
if (!this.encryptKey) {
return new Response('Encrypted event but no encrypt key configured', { status: 401 });
}
try {
const decrypted = decryptLarkEvent(body.encrypt, this.encryptKey);
body = JSON.parse(decrypted);
} catch {
this.logger.error('Event decryption failed');
return new Response('Decryption failed', { status: 401 });
}
}
// Verify token (skip when no verification token is configured).
// Token location varies: v2 events use header.token, url_verification uses body.token.
if (this.verificationToken) {
const token = body.header?.token ?? body.token;
if (this.verificationToken !== token) {
this.logger.error(
'Verification token mismatch (configured=%s, received=%s)',
'***',
token ? '***' : '(empty)',
);
return new Response('Invalid verification token', { status: 401 });
}
}
// URL verification challenge (after token check)
if (body.type === 'url_verification') {
return Response.json({ challenge: body.challenge });
}
// Only handle message events
const eventType = body.header?.event_type;
if (eventType !== 'im.message.receive_v1') {
return Response.json({ ok: true });
}
const event = body.event;
const message = event?.message;
const sender = event?.sender;
if (!message || !sender) {
return Response.json({ ok: true });
}
// Only handle text messages for now
if (message.message_type !== 'text') {
return Response.json({ ok: true });
}
// Extract text content
let messageText = '';
try {
const content = JSON.parse(message.content);
messageText = content.text || '';
} catch {
// malformed content
}
if (!messageText.trim()) {
return Response.json({ ok: true });
}
// Build thread ID
const threadId = this.encodeThreadId({
chatId: message.chat_id,
platform: this.platform,
});
// Create message lazily via factory
const messageFactory = () => this.parseRawEvent(message, sender, threadId, messageText);
// Delegate to Chat SDK pipeline
this.chat.processMessage(this, threadId, messageFactory, options);
return Response.json({ ok: true });
}
// ------------------------------------------------------------------
// Message operations
// ------------------------------------------------------------------
async postMessage(
threadId: string,
message: AdapterPostableMessage,
): Promise<RawMessage<LarkRawMessage>> {
const { chatId } = this.decodeThreadId(threadId);
const text = this.formatConverter.renderPostable(message);
const { messageId, raw } = await this.api.sendMessage(chatId, text);
return {
id: messageId,
raw: raw as LarkRawMessage,
threadId,
};
}
async editMessage(
threadId: string,
messageId: string,
message: AdapterPostableMessage,
): Promise<RawMessage<LarkRawMessage>> {
const text = this.formatConverter.renderPostable(message);
const { raw } = await this.api.editMessage(messageId, text);
return {
id: messageId,
raw: raw as LarkRawMessage,
threadId,
};
}
async deleteMessage(_threadId: string, messageId: string): Promise<void> {
await this.api.deleteMessage(messageId);
}
async fetchMessages(
threadId: string,
options?: FetchOptions,
): Promise<FetchResult<LarkRawMessage>> {
const { chatId } = this.decodeThreadId(threadId);
const result = await this.api.listMessages(chatId, {
pageSize: options?.limit || 50,
pageToken: options?.cursor,
});
const messages = result.items.map((item: any) => this.parseMessage(item));
return {
messages,
nextCursor: result.hasMore ? result.pageToken : undefined,
};
}
async fetchThread(threadId: string): Promise<ThreadInfo> {
const { chatId } = this.decodeThreadId(threadId);
try {
const info = await this.api.getChatInfo(chatId);
return {
channelId: threadId,
channelName: info?.name,
id: threadId,
isDM: info?.chat_mode === 'p2p',
metadata: info || {},
};
} catch {
return {
channelId: threadId,
id: threadId,
metadata: {},
};
}
}
// ------------------------------------------------------------------
// Message parsing
// ------------------------------------------------------------------
parseMessage(raw: LarkRawMessage): Message<LarkRawMessage> {
let text = '';
try {
const content = JSON.parse(raw.content);
text = content.text || '';
} catch {
// malformed
}
// Strip @mention markers
const cleanText = text
.replaceAll(/@_user_\d+/g, '')
.replaceAll('@_all', '')
.trim();
const formatted = parseMarkdown(cleanText);
const threadId = this.encodeThreadId({
chatId: raw.chat_id,
platform: this.platform,
});
return new Message({
attachments: [],
author: {
fullName: 'Unknown',
isBot: false,
isMe: false,
userId: 'unknown',
userName: 'unknown',
},
formatted,
id: raw.message_id,
metadata: {
dateSent: new Date(Number(raw.create_time)),
edited: false,
},
raw,
text: cleanText,
threadId,
});
}
// ------------------------------------------------------------------
// Reactions
// ------------------------------------------------------------------
async addReaction(
_threadId: string,
messageId: string,
emoji: EmojiValue | string,
): Promise<void> {
const emojiType = this.toEmojiType(emoji);
try {
await this.api.addReaction(messageId, emojiType);
} catch {
// Reactions may not be supported in all chat types
}
}
async removeReaction(
_threadId: string,
_messageId: string,
_emoji: EmojiValue | string,
): Promise<void> {
// Lark's remove reaction requires a reaction ID, which we don't track.
// No-op for now.
}
// ------------------------------------------------------------------
// Typing
// ------------------------------------------------------------------
async startTyping(_threadId: string): Promise<void> {
// Lark has no typing indicator API for bots
}
// ------------------------------------------------------------------
// Thread ID encoding
// ------------------------------------------------------------------
encodeThreadId(data: LarkThreadId): string {
return `${data.platform}:${data.chatId}`;
}
decodeThreadId(threadId: string): LarkThreadId {
const colonIdx = threadId.indexOf(':');
if (colonIdx === -1) {
return { chatId: threadId, platform: this.platform };
}
const prefix = threadId.slice(0, colonIdx);
const chatId = threadId.slice(colonIdx + 1);
const platform = prefix === 'lark' || prefix === 'feishu' ? prefix : this.platform;
return { chatId, platform };
}
channelIdFromThreadId(threadId: string): string {
return threadId;
}
isDM(threadId: string): boolean {
// Can't determine from threadId alone; default false
return false;
}
// ------------------------------------------------------------------
// Format rendering
// ------------------------------------------------------------------
renderFormatted(content: FormattedContent): string {
return this.formatConverter.fromAst(content);
}
// ------------------------------------------------------------------
// Private helpers
// ------------------------------------------------------------------
private async parseRawEvent(
message: LarkMessageBody,
sender: { sender_id: { open_id: string }; sender_type: string },
threadId: string,
messageText: string,
): Promise<Message<LarkRawMessage>> {
const cleanText = messageText
.replaceAll(/@_user_\d+/g, '')
.replaceAll('@_all', '')
.trim();
const formatted = parseMarkdown(cleanText);
const openId = sender.sender_id.open_id;
const isBot = sender.sender_type === 'bot';
// Resolve user display name via contact API (cached, graceful degradation)
const displayName = (await this.resolveSenderName(openId)) || openId;
const author: Author = {
fullName: displayName,
isBot,
isMe: isBot && openId === this._botUserId,
userId: openId,
userName: displayName,
};
return new Message({
attachments: [],
author,
formatted,
id: message.message_id,
metadata: {
dateSent: new Date(Number(message.create_time)),
edited: false,
},
raw: message,
text: cleanText,
threadId,
});
}
private async resolveSenderName(openId: string): Promise<string | undefined> {
// Skip API calls if we already know permission is denied
if (this.senderNamePermissionDenied) return undefined;
const now = Date.now();
const cached = this.senderNameCache.get(openId);
if (cached && cached.expireAt > now) return cached.name;
try {
const info = await this.api.getUserInfo(openId);
if (info?.name) {
this.senderNameCache.set(openId, {
expireAt: now + LarkAdapter.SENDER_NAME_TTL_MS,
name: info.name,
});
return info.name;
}
return undefined;
} catch (err) {
const msg = String(err);
// Mark permission denied to avoid repeated failing calls
if (msg.includes('99991672') || msg.includes('Access denied')) {
this.senderNamePermissionDenied = true;
console.warn('[adapter-lark] sender name resolution disabled: missing contact permission');
}
return undefined;
}
}
private toEmojiType(emoji: EmojiValue | string): string {
if (typeof emoji === 'string') return emoji;
// EmojiValue is a symbol-like; use its string form
return String(emoji);
}
}
/**
* Factory function to create a LarkAdapter.
*/
export function createLarkAdapter(
config: LarkAdapterConfig & { logger?: Logger; userName?: string },
): LarkAdapter {
return new LarkAdapter(config);
}
+198
View File
@@ -0,0 +1,198 @@
const BASE_URLS: Record<string, string> = {
feishu: 'https://open.feishu.cn/open-apis',
lark: 'https://open.larksuite.com/open-apis',
};
const MAX_TEXT_LENGTH = 4000;
/**
* Lightweight wrapper around the Lark/Feishu Open API.
*
* Auth: app_id + app_secret -> tenant_access_token (cached, auto-refreshed).
*/
export class LarkApiClient {
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; raw: any }> {
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, raw: data.data };
}
async editMessage(messageId: string, text: string): Promise<{ raw: any }> {
const data = await this.call('PUT', `/im/v1/messages/${messageId}`, {
content: JSON.stringify({ text: this.truncateText(text) }),
msg_type: 'text',
});
return { raw: data.data };
}
async deleteMessage(messageId: string): Promise<void> {
await this.call('DELETE', `/im/v1/messages/${messageId}`, {});
}
async getMessage(messageId: string): Promise<any> {
const data = await this.call('GET', `/im/v1/messages/${messageId}`, {});
return data.data;
}
async listMessages(
chatId: string,
options?: { pageSize?: number; pageToken?: string; startTime?: string; endTime?: string },
): Promise<{ items: any[]; hasMore: boolean; pageToken?: string }> {
const params = new URLSearchParams({ container_id_type: 'chat', container_id: chatId });
if (options?.pageSize) params.set('page_size', String(options.pageSize));
if (options?.pageToken) params.set('page_token', options.pageToken);
if (options?.startTime) params.set('start_time', options.startTime);
if (options?.endTime) params.set('end_time', options.endTime);
const data = await this.call('GET', `/im/v1/messages?${params.toString()}`, {});
return {
hasMore: data.data.has_more,
items: data.data.items || [],
pageToken: data.data.page_token,
};
}
async replyMessage(messageId: string, text: string): Promise<{ messageId: string; raw: any }> {
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, raw: data.data };
}
async addReaction(messageId: string, emojiType: string): Promise<void> {
await this.call('POST', `/im/v1/messages/${messageId}/reactions`, {
reaction_type: { emoji_type: emojiType },
});
}
async removeReaction(messageId: string, reactionId: string): Promise<void> {
await this.call('DELETE', `/im/v1/messages/${messageId}/reactions/${reactionId}`, {});
}
// ------------------------------------------------------------------
// Chat info
// ------------------------------------------------------------------
async getChatInfo(chatId: string): Promise<any> {
const data = await this.call('GET', `/im/v1/chats/${chatId}`, {});
return data.data;
}
async getBotInfo(): Promise<any> {
const data = await this.call('GET', '/bot/v3/info', {});
return data.bot;
}
async getUserInfo(openId: string): Promise<{ name?: string } | null> {
const userIdType = openId.startsWith('ou_')
? 'open_id'
: openId.startsWith('on_')
? 'union_id'
: 'user_id';
const data = await this.call(
'GET',
`/contact/v3/users/${openId}?user_id_type=${userIdType}`,
{},
);
const user = data.data?.user;
if (!user) return null;
const name = user.name || user.display_name || user.nickname || user.en_name;
return name ? { name } : null;
}
// ------------------------------------------------------------------
// Auth
// ------------------------------------------------------------------
async getTenantAccessToken(): Promise<string> {
if (this.cachedToken && Date.now() < this.tokenExpiresAt) {
return this.cachedToken;
}
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: any = 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 init: RequestInit = {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
method,
};
if (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(`Lark API ${method} ${path} failed: ${response.status} ${text}`);
}
const data: any = await response.json();
if (data.code !== 0) {
throw new Error(`Lark API ${method} ${path} failed: ${data.code} ${data.msg}`);
}
return data;
}
}
+16
View File
@@ -0,0 +1,16 @@
import { createDecipheriv, createHash } from 'node:crypto';
/**
* Decrypt Lark event body encrypted with AES-256-CBC.
* @see https://open.larksuite.com/document/server-docs/event-subscription/event-subscription-configure-/encrypt-key-encryption-configuration-case
*/
export function decryptLarkEvent(encrypted: string, encryptKey: string): string {
const key = createHash('sha256').update(encryptKey).digest();
const encryptedBuffer = Buffer.from(encrypted, 'base64');
const iv = encryptedBuffer.subarray(0, 16);
const ciphertext = encryptedBuffer.subarray(16);
const decipher = createDecipheriv('aes-256-cbc', key, iv);
let decrypted = decipher.update(ciphertext, undefined, 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
@@ -0,0 +1,31 @@
import type { Root } from 'chat';
import { BaseFormatConverter, parseMarkdown, stringifyMarkdown } from 'chat';
/**
* Format converter for Lark/Feishu.
*
* Lark text messages support basic markdown-like formatting.
* We use plain markdown as the interchange format — no special escaping needed.
*/
export class LarkFormatConverter extends BaseFormatConverter {
/**
* Convert mdast AST to Lark-compatible text.
* Lark displays markdown reasonably well, so we stringify directly.
*/
fromAst(ast: Root): string {
return stringifyMarkdown(ast);
}
/**
* Convert Lark text to mdast AST.
* Strip Lark @mention markers (@_user_N) before parsing.
*/
toAst(text: string): Root {
// Strip Lark @mention markers like @_user_1, @_all
const cleaned = text
.replaceAll(/@_user_\d+/g, '')
.replaceAll('@_all', '')
.trim();
return parseMarkdown(cleaned);
}
}
+15
View File
@@ -0,0 +1,15 @@
export { createLarkAdapter, LarkAdapter } from './adapter';
export { LarkApiClient } from './api';
export { decryptLarkEvent } from './crypto';
export { LarkFormatConverter } from './format-converter';
export type {
LarkAdapterConfig,
LarkEventHeader,
LarkMention,
LarkMessageBody,
LarkMessageEvent,
LarkRawMessage,
LarkSender,
LarkThreadId,
LarkWebhookPayload,
} from './types';
+96
View File
@@ -0,0 +1,96 @@
/**
* Lark/Feishu adapter configuration.
*/
export interface LarkAdapterConfig {
/** Lark app ID */
appId: string;
/** Lark app secret */
appSecret: string;
/** AES decrypt key for encrypted events (optional) */
encryptKey?: string;
/** 'lark' (international) or 'feishu' (China) — determines API base URL */
platform?: 'lark' | 'feishu';
/** Bot display name override */
userName?: string;
/** Verification token for webhook event validation (optional — skip verification when unset) */
verificationToken?: string;
}
/**
* Lark thread ID components.
*/
export interface LarkThreadId {
/** Lark chat ID (group or P2P) */
chatId: string;
/** Platform variant */
platform: 'lark' | 'feishu';
}
/**
* Lark sender info from im.message.receive_v1 event.
*/
export interface LarkSender {
sender_id: {
open_id: string;
union_id?: string;
user_id?: string;
};
sender_type: string;
tenant_key?: string;
}
/**
* Lark message body from im.message.receive_v1 event.
*/
export interface LarkMessageBody {
chat_id: string;
chat_type?: string;
content: string;
create_time: string;
mentions?: LarkMention[];
message_id: string;
message_type: string;
}
export interface LarkMention {
id: { open_id: string; union_id?: string; user_id?: string };
key: string;
name: string;
tenant_key?: string;
}
/**
* Lark event header.
*/
export interface LarkEventHeader {
app_id: string;
create_time: string;
event_id: string;
event_type: string;
tenant_key: string;
token: string;
}
/**
* Lark im.message.receive_v1 event body.
*/
export interface LarkMessageEvent {
message: LarkMessageBody;
sender: LarkSender;
}
/**
* Full Lark webhook payload (Event Subscription v2).
*/
export interface LarkWebhookPayload {
challenge?: string;
encrypt?: string;
event?: LarkMessageEvent;
header?: LarkEventHeader;
/** Verification token — present at top level in url_verification events */
token?: string;
type?: string;
}
/** Raw message type for the adapter generic */
export type LarkRawMessage = LarkMessageBody;
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"lib": ["ES2022"]
},
"exclude": ["node_modules", "dist"],
"include": ["src"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'tsup';
export default defineConfig({
dts: true,
entry: ['src/index.ts'],
format: ['esm'],
sourcemap: true,
});
@@ -152,6 +152,39 @@ export class AgentBotProviderModel {
// --------------- System-wide static methods ---------------
static findEnabledByPlatformAndAppId = async (
db: LobeChatDatabase,
platform: string,
applicationId: string,
gateKeeper?: GateKeeper,
): Promise<DecryptedBotProvider | null> => {
const [result] = await db
.select()
.from(agentBotProviders)
.where(
and(
eq(agentBotProviders.platform, platform),
eq(agentBotProviders.applicationId, applicationId),
eq(agentBotProviders.enabled, true),
),
)
.limit(1);
if (!result?.credentials) return null;
try {
const credentials = gateKeeper
? JSON.parse((await gateKeeper.decrypt(result.credentials)).plaintext)
: JSON.parse(result.credentials);
if (!credentials.botToken && !credentials.appSecret) return null;
return { ...result, credentials };
} catch {
return null;
}
};
static findEnabledByPlatform = async (
db: LobeChatDatabase,
platform: string,
@@ -172,7 +205,7 @@ export class AgentBotProviderModel {
? JSON.parse((await gateKeeper.decrypt(r.credentials)).plaintext)
: JSON.parse(r.credentials);
if (!credentials.botToken) continue;
if (!credentials.botToken && !credentials.appSecret) continue;
decrypted.push({ ...r, credentials });
} catch {
@@ -0,0 +1,30 @@
import debug from 'debug';
import { getBotMessageRouter } from '@/server/services/bot';
const log = debug('lobe-server:bot:webhook-route');
/**
* Unified webhook endpoint for Chat SDK bot platforms.
*
* Handles both generic and bot-specific webhook URLs:
* - POST /api/agent/webhooks/[platform]
* - POST /api/agent/webhooks/[platform]/[appId]
*
* Using an optional catch-all `[[...appId]]` ensures both patterns are served
* by a single serverless function, avoiding deployment issues with nested
* dynamic segments on Vercel.
*/
export const POST = async (
req: Request,
{ params }: { params: Promise<{ appId?: string[]; platform: string }> },
): Promise<Response> => {
const { platform, appId: appIdSegments } = await params;
const appId = appIdSegments?.[0];
log('Received webhook: platform=%s, appId=%s, url=%s', platform, appId ?? '(none)', req.url);
const router = getBotMessageRouter();
const handler = router.getWebhookHandler(platform, appId);
return handler(req);
};
@@ -1,27 +0,0 @@
import debug from 'debug';
import { getBotMessageRouter } from '@/server/services/bot';
const log = debug('lobe-server:bot:webhook-route');
/**
* Bot-specific webhook endpoint.
*
* Telegram bots register webhooks as `/api/agent/webhooks/telegram/{appId}`
* so the router can look up the correct Chat SDK bot instance directly
* without iterating all registered bots.
*
* Route: POST /api/agent/webhooks/[platform]/[appId]
*/
export const POST = async (
req: Request,
{ params }: { params: Promise<{ appId: string; platform: string }> },
): Promise<Response> => {
const { platform, appId } = await params;
log('Received webhook: platform=%s, appId=%s, url=%s', platform, appId, req.url);
const router = getBotMessageRouter();
const handler = router.getWebhookHandler(platform, appId);
return handler(req);
};
@@ -1,26 +0,0 @@
import debug from 'debug';
import { getBotMessageRouter } from '@/server/services/bot';
const log = debug('lobe-server:bot:webhook-route');
/**
* Unified webhook endpoint for Chat SDK bot platforms (Discord, Slack, etc.).
*
* Each platform adapter handles its own signature verification and event parsing.
* The BotMessageRouter routes the request to the correct Chat SDK bot instance.
*
* Route: POST /api/agent/webhooks/[platform]
*/
export const POST = async (
req: Request,
{ params }: { params: Promise<{ platform: string }> },
): Promise<Response> => {
const { platform } = await params;
log('Received webhook: platform=%s, url=%s', platform, req.url);
const router = getBotMessageRouter();
const handler = router.getWebhookHandler(platform);
return handler(req);
};
@@ -7,6 +7,7 @@ import { TopicModel } from '@/database/models/topic';
import { verifyQStashSignature } from '@/libs/qstash';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { DiscordRestApi } from '@/server/services/bot/discordRestApi';
import { LarkRestApi } from '@/server/services/bot/larkRestApi';
import {
renderError,
renderFinalReply,
@@ -48,8 +49,17 @@ function detectPlatform(platformThreadId: string): string {
return platformThreadId.split(':')[0];
}
/**
* Extract chat ID from Lark platformThreadId (e.g. "lark:oc_xxx" or "feishu:oc_xxx").
*/
function extractLarkChatId(platformThreadId: string): string {
const parts = platformThreadId.split(':');
return parts[1];
}
/** Telegram has a 4096 char limit vs Discord's 2000 */
const TELEGRAM_CHAR_LIMIT = 4000;
const LARK_CHAR_LIMIT = 4000;
// --------------- Platform-agnostic message interface ---------------
@@ -106,6 +116,16 @@ function createTelegramMessenger(telegram: TelegramRestApi, chatId: string): Pla
};
}
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(),
};
}
/**
* Bot callback endpoint for agent step/completion webhooks.
*
@@ -136,10 +156,10 @@ export async function POST(request: Request): Promise<Response> {
progressMessageId,
);
if (!type || !applicationId || !platformThreadId || !progressMessageId) {
if (!type || !applicationId || !platformThreadId) {
return NextResponse.json(
{
error: 'Missing required fields: type, applicationId, platformThreadId, progressMessageId',
error: 'Missing required fields: type, applicationId, platformThreadId',
},
{ status: 400 },
);
@@ -178,10 +198,11 @@ export async function POST(request: Request): Promise<Response> {
credentials = JSON.parse(row.credentials);
}
const botToken = credentials.botToken;
if (!botToken) {
log('bot-callback: no botToken in credentials for %s appId=%s', platform, applicationId);
return NextResponse.json({ error: 'Bot token not found' }, { status: 500 });
// Validate required credentials exist for the platform
const isLark = platform === 'lark' || platform === 'feishu';
if (isLark ? !credentials.appId || !credentials.appSecret : !credentials.botToken) {
log('bot-callback: missing credentials for %s appId=%s', platform, applicationId);
return NextResponse.json({ error: 'Bot credentials incomplete' }, { status: 500 });
}
// Create platform-specific messenger
@@ -190,15 +211,23 @@ export async function POST(request: Request): Promise<Response> {
switch (platform) {
case 'telegram': {
const telegram = new TelegramRestApi(botToken);
const telegram = new TelegramRestApi(credentials.botToken);
const chatId = extractTelegramChatId(platformThreadId);
messenger = createTelegramMessenger(telegram, chatId);
charLimit = TELEGRAM_CHAR_LIMIT;
break;
}
case 'lark':
case 'feishu': {
const lark = new LarkRestApi(credentials.appId, credentials.appSecret, platform);
const chatId = extractLarkChatId(platformThreadId);
messenger = createLarkMessenger(lark, chatId);
charLimit = LARK_CHAR_LIMIT;
break;
}
case 'discord':
default: {
const discord = new DiscordRestApi(botToken);
const discord = new DiscordRestApi(credentials.botToken);
const channelId = extractDiscordChannelId(platformThreadId);
messenger = createDiscordMessenger(discord, channelId, platformThreadId);
break;
@@ -266,11 +295,11 @@ export async function POST(request: Request): Promise<Response> {
async function handleStepCallback(
body: Record<string, any>,
messenger: PlatformMessenger,
progressMessageId: string,
progressMessageId: string | undefined,
platform?: string,
): Promise<void> {
const { shouldContinue } = body;
if (!shouldContinue) return;
if (!shouldContinue || !progressMessageId) return;
const progressText = renderStepProgress({
content: body.content,
@@ -309,7 +338,7 @@ async function handleStepCallback(
async function handleCompletionCallback(
body: Record<string, any>,
messenger: PlatformMessenger,
progressMessageId: string,
progressMessageId: string | undefined,
platform?: string,
charLimit?: number,
): Promise<void> {
@@ -318,9 +347,13 @@ async function handleCompletionCallback(
if (reason === 'error') {
const errorText = renderError(errorMessage || 'Agent execution failed');
try {
await messenger.editMessage(progressMessageId, errorText);
if (progressMessageId) {
await messenger.editMessage(progressMessageId, errorText);
} else {
await messenger.createMessage(errorText);
}
} catch (error) {
log('handleCompletionCallback: failed to edit error message: %O', error);
log('handleCompletionCallback: failed to post error message: %O', error);
}
return;
}
@@ -342,7 +375,11 @@ async function handleCompletionCallback(
const chunks = splitMessage(finalText, charLimit);
try {
await messenger.editMessage(progressMessageId, chunks[0]);
if (progressMessageId) {
await messenger.editMessage(progressMessageId, chunks[0]);
} else {
await messenger.createMessage(chunks[0]);
}
// Post overflow chunks as follow-up messages
for (let i = 1; i < chunks.length; i++) {
@@ -1,7 +1,8 @@
import { Flexbox } from '@lobehub/ui';
import { memo } from 'react';
import { memo, useMemo } from 'react';
import MarkdownMessage from '@/features/Conversation/Markdown';
import { cleanSpeakerTag } from '@/store/chat/utils/cleanSpeakerTag';
import { type UIChatMessage } from '@/types/index';
import { useMarkdown } from '../useMarkdown';
@@ -14,13 +15,14 @@ const UserMessageContent = memo<UIChatMessage>(
({ id, content, imageList, videoList, fileList, metadata }) => {
const markdownProps = useMarkdown(id);
const pageSelections = metadata?.pageSelections;
const displayContent = useMemo(() => (content ? cleanSpeakerTag(content) : content), [content]);
return (
<Flexbox gap={8} id={id}>
{pageSelections && pageSelections.length > 0 && (
<PageSelections selections={pageSelections} />
)}
{content && <MarkdownMessage {...markdownProps}>{content}</MarkdownMessage>}
{displayContent && <MarkdownMessage {...markdownProps}>{displayContent}</MarkdownMessage>}
{imageList && imageList?.length > 0 && <ImageFileListViewer items={imageList} />}
{videoList && videoList?.length > 0 && <VideoFileListViewer items={videoList} />}
{fileList && fileList?.length > 0 && <FileListViewer items={fileList} />}
+52 -39
View File
@@ -1,42 +1,55 @@
export default {
'integration.applicationId': 'Application ID / Bot Username',
'integration.applicationIdPlaceholder': 'e.g. 1234567890',
'integration.botToken': 'Bot Token / API Key',
'integration.botTokenEncryptedHint': 'Token will be encrypted and stored securely.',
'integration.botTokenHowToGet': 'How to get?',
'integration.botTokenPlaceholderExisting': 'Token is hidden for security',
'integration.botTokenPlaceholderNew': 'Paste your bot token here',
'integration.connectionConfig': 'Connection Configuration',
'integration.copied': 'Copied to clipboard',
'integration.copy': 'Copy',
'integration.deleteConfirm': 'Are you sure you want to remove this integration?',
'integration.disabled': 'Disabled',
'integration.discord.description':
'channel.applicationId': 'Application ID / Bot Username',
'channel.applicationIdHint': 'Unique identifier for your bot application.',
'channel.applicationIdPlaceholder': 'e.g. 1234567890',
'channel.appSecret': 'App Secret',
'channel.appSecretPlaceholder': 'Paste your app secret here',
'channel.botToken': 'Bot Token / API Key',
'channel.botTokenEncryptedHint': 'Token will be encrypted and stored securely.',
'channel.botTokenHowToGet': 'How to get?',
'channel.botTokenPlaceholderExisting': 'Token is hidden for security',
'channel.botTokenPlaceholderNew': 'Paste your bot token here',
'channel.connectionConfig': 'Connection Configuration',
'channel.copied': 'Copied to clipboard',
'channel.copy': 'Copy',
'channel.deleteConfirm': 'Are you sure you want to remove this channel?',
'channel.devWebhookProxyUrl': 'HTTPS Tunnel URL',
'channel.devWebhookProxyUrlHint':
'Optional. HTTPS tunnel URL for forwarding webhook requests to local dev server.',
'channel.disabled': 'Disabled',
'channel.discord.description':
'Connect this assistant to Discord server for channel chat and direct messages.',
'integration.documentation': 'Documentation',
'integration.enabled': 'Enabled',
'integration.endpointUrl': 'Interaction Endpoint URL',
'integration.endpointUrlHint':
'Please copy this URL and paste it into the <bold>"Interactions Endpoint URL"</bold> field in the {{name}} Developer Portal.',
'integration.platforms': 'Platforms',
'integration.publicKey': 'Public Key',
'integration.publicKeyPlaceholder': 'Required for interaction verification',
'integration.removeIntegration': 'Remove Integration',
'integration.removed': 'Integration removed',
'integration.removeFailed': 'Failed to remove integration',
'integration.save': 'Save Configuration',
'integration.secretToken': 'Webhook Secret Token',
'integration.secretTokenHint': 'Optional. Used to verify webhook requests from Telegram.',
'integration.secretTokenPlaceholder': 'Optional secret for webhook verification',
'integration.saveFailed': 'Failed to save configuration',
'integration.saveFirstWarning': 'Please save configuration first',
'integration.saved': 'Configuration saved successfully',
'integration.testConnection': 'Test Connection',
'integration.testFailed': 'Connection test failed',
'integration.testSuccess': 'Connection test passed',
'integration.updateFailed': 'Failed to update status',
'integration.validationError': 'Please fill in Application ID and Token',
'integration.devWebhookProxyUrl': 'HTTPS Tunnel URL',
'integration.devWebhookProxyUrlHint':
'Telegram requires HTTPS for webhooks. Paste your tunnel URL (e.g. from cloudflared or ngrok) to forward webhook requests to your local dev server.',
'channel.documentation': 'Documentation',
'channel.enabled': 'Enabled',
'channel.encryptKey': 'Encrypt Key',
'channel.encryptKeyHint': 'Optional. Used to decrypt encrypted event payloads.',
'channel.encryptKeyPlaceholder': 'Optional encryption key',
'channel.endpointUrl': 'Webhook URL',
'channel.endpointUrlHint':
'Please copy this URL and paste it into the <bold>{{fieldName}}</bold> field in the {{name}} Developer Portal.',
'channel.feishu.description': 'Connect this assistant to Feishu for private and group chats.',
'channel.lark.description': 'Connect this assistant to Lark for private and group chats.',
'channel.platforms': 'Platforms',
'channel.publicKey': 'Public Key',
'channel.publicKeyHint': 'Optional. Used to verify interaction requests from Discord.',
'channel.publicKeyPlaceholder': 'Required for interaction verification',
'channel.removeChannel': 'Remove Channel',
'channel.removed': 'Channel removed',
'channel.removeFailed': 'Failed to remove channel',
'channel.save': 'Save Configuration',
'channel.saveFailed': 'Failed to save configuration',
'channel.saveFirstWarning': 'Please save configuration first',
'channel.saved': 'Configuration saved successfully',
'channel.secretToken': 'Webhook Secret Token',
'channel.secretTokenHint': 'Optional. Used to verify webhook requests from Telegram.',
'channel.secretTokenPlaceholder': 'Optional secret for webhook verification',
'channel.telegram.description': 'Connect this assistant to Telegram for private and group chats.',
'channel.testConnection': 'Test Connection',
'channel.testFailed': 'Connection test failed',
'channel.testSuccess': 'Connection test passed',
'channel.updateFailed': 'Failed to update status',
'channel.validationError': 'Please fill in Application ID and Token',
'channel.verificationToken': 'Verification Token',
'channel.verificationTokenHint': 'Optional. Used to verify webhook event source.',
'channel.verificationTokenPlaceholder': 'Paste your verification token here',
} as const;
+1 -1
View File
@@ -383,7 +383,7 @@ export default {
'supervisor.todoList.allComplete': 'All tasks completed',
'supervisor.todoList.title': 'Tasks Completed',
'tab.groupProfile': 'Group Profile',
'tab.integration': 'Integration',
'tab.integration': 'Channels',
'tab.profile': 'Agent Profile',
'tab.search': 'Search',
'task.activity.calling': 'Calling Skill...',
+1
View File
@@ -794,6 +794,7 @@ When I am ___, I need ___
'systemAgent.translation.modelDesc': 'Specify the model used for translation',
'systemAgent.translation.title': 'Message Translation Agent',
'tab.about': 'About',
'tab.advanced': 'Advanced',
'tab.addAgentSkill': 'Add Agent Skill',
'tab.beta': 'Beta',
'tab.beta.updateChannel.canary': 'Canary',
+2
View File
@@ -6,6 +6,8 @@ export default {
'actions.confirmRemoveUnstarred':
'You are about to delete unstarred topics. This action cannot be undone.',
'actions.duplicate': 'Duplicate',
'actions.favorite': 'Favorite',
'actions.unfavorite': 'Unfavorite',
'actions.export': 'Export Topics',
'actions.import': 'Import Conversation',
'actions.openInNewTab': 'Open in New Tab',
@@ -2,7 +2,7 @@
import { Flexbox } from '@lobehub/ui';
import { BotPromptIcon } from '@lobehub/ui/icons';
import { BlocksIcon, MessageSquarePlusIcon, SearchIcon } from 'lucide-react';
import { MessageSquarePlusIcon, RadioTowerIcon, SearchIcon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
@@ -25,7 +25,7 @@ const Nav = memo(() => {
const agentId = params.aid;
const pathname = usePathname();
const isProfileActive = pathname.includes('/profile');
const isIntegrationActive = pathname.includes('/integration');
const isIntegrationActive = pathname.includes('/channel');
const router = useQueryRoute();
const { isAgentEditable } = useServerConfigStore(featureFlagsSelectors);
const toggleCommandMenu = useGlobalStore((s) => s.toggleCommandMenu);
@@ -64,11 +64,11 @@ const Nav = memo(() => {
{!hideProfile && isDevMode && (
<NavItem
active={isIntegrationActive}
icon={BlocksIcon}
icon={RadioTowerIcon}
title={t('tab.integration')}
onClick={() => {
switchTopic(null, { skipRefreshMessage: true });
router.push(urlJoin('/agent', agentId!, 'integration'));
router.push(urlJoin('/agent', agentId!, 'channel'));
}}
/>
)}
@@ -164,6 +164,7 @@ const Content = memo<ContentProps>(({ open, searchKeyword }) => {
active={activeTopicId === topic.id}
fav={topic.favorite}
id={topic.id}
metadata={topic.metadata}
threadId={activeThreadId}
title={topic.title}
/>
@@ -1,6 +1,6 @@
import { ActionIcon, Flexbox, Icon, Skeleton, Tag } from '@lobehub/ui';
import { Flexbox, Icon, Skeleton, Tag } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { MessageSquareDashed, Star } from 'lucide-react';
import { HashIcon, MessageSquareDashed } from 'lucide-react';
import { AnimatePresence, m as motion } from 'motion/react';
import { memo, Suspense, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
@@ -8,10 +8,12 @@ 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 { useAgentStore } from '@/store/agent';
import { useChatStore } from '@/store/chat';
import { operationSelectors } from '@/store/chat/selectors';
import { useElectronStore } from '@/store/electron';
import type { ChatTopicMetadata } from '@/types/topic';
import { useTopicNavigation } from '../../hooks/useTopicNavigation';
import ThreadList from '../../TopicListContent/ThreadList';
@@ -49,11 +51,12 @@ interface TopicItemProps {
active?: boolean;
fav?: boolean;
id?: string;
metadata?: ChatTopicMetadata;
threadId?: string;
title: string;
}
const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId }) => {
const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, metadata }) => {
const { t } = useTranslation('topic');
const activeAgentId = useAgentStore((s) => s.activeAgentId);
const addTab = useElectronStore((s) => s.addTab);
@@ -73,8 +76,6 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId }) =>
id ? operationSelectors.isTopicUnreadCompleted(id) : () => false,
);
const [favoriteTopic] = useChatStore((s) => [s.favoriteTopic]);
const { navigateToTopic, isInAgentSubRoute } = useTopicNavigation();
const toggleEditing = useCallback(
@@ -112,6 +113,7 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId }) =>
}, [id, activeAgentId, addTab, navigateToTopic]);
const dropdownMenu = useTopicItemDropdownMenu({
fav,
id,
toggleEditing,
});
@@ -193,7 +195,7 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId }) =>
}
return (
<Flexbox style={{ position: 'relative' }}>
<Flexbox data-testid="topic-item" style={{ position: 'relative' }}>
<NavItem
actions={<Actions dropdownMenu={dropdownMenu} />}
active={active && !threadId && !isInAgentSubRoute}
@@ -202,19 +204,18 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId }) =>
href={href}
loading={isLoading}
title={title}
icon={
<ActionIcon
color={fav ? cssVar.colorWarning : undefined}
fill={fav ? cssVar.colorWarning : 'transparent'}
icon={Star}
size={'small'}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
favoriteTopic(id, !fav);
}}
/>
}
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} />;
}
}
return (
<Icon icon={HashIcon} size={'small'} style={{ color: cssVar.colorTextDescription }} />
);
})()}
slots={{
iconPostfix: unreadNode,
}}
@@ -1,7 +1,7 @@
import { type MenuProps } from '@lobehub/ui';
import { Icon } from '@lobehub/ui';
import { App } from 'antd';
import { ExternalLink, LucideCopy, PanelTop, PencilLine, Trash, Wand2 } from 'lucide-react';
import { ExternalLink, LucideCopy, PanelTop, PencilLine, Star, Trash, Wand2 } from 'lucide-react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
@@ -14,11 +14,13 @@ import { useElectronStore } from '@/store/electron';
import { useGlobalStore } from '@/store/global';
interface TopicItemDropdownMenuProps {
fav?: boolean;
id?: string;
toggleEditing: (visible?: boolean) => void;
}
export const useTopicItemDropdownMenu = ({
fav,
id,
toggleEditing,
}: TopicItemDropdownMenuProps): (() => MenuProps['items']) => {
@@ -30,16 +32,28 @@ export const useTopicItemDropdownMenu = ({
const activeAgentId = useAgentStore((s) => s.activeAgentId);
const addTab = useElectronStore((s) => s.addTab);
const [autoRenameTopicTitle, duplicateTopic, removeTopic] = useChatStore((s) => [
const [autoRenameTopicTitle, duplicateTopic, removeTopic, favoriteTopic] = useChatStore((s) => [
s.autoRenameTopicTitle,
s.duplicateTopic,
s.removeTopic,
s.favoriteTopic,
]);
return useCallback(() => {
if (!id) return [];
return [
{
icon: <Icon icon={Star} />,
key: 'favorite',
label: fav ? t('actions.unfavorite') : t('actions.favorite'),
onClick: () => {
favoriteTopic(id, !fav);
},
},
{
type: 'divider' as const,
},
{
icon: <Icon icon={Wand2} />,
key: 'autoRename',
@@ -82,9 +96,6 @@ export const useTopicItemDropdownMenu = ({
},
]
: []),
{
type: 'divider' as const,
},
{
icon: <Icon icon={LucideCopy} />,
key: 'duplicate',
@@ -115,9 +126,11 @@ export const useTopicItemDropdownMenu = ({
].filter(Boolean) as MenuProps['items'];
}, [
id,
fav,
activeAgentId,
autoRenameTopicTitle,
duplicateTopic,
favoriteTopic,
removeTopic,
openTopicInNewWindow,
addTab,
@@ -1,7 +1,6 @@
import { AccordionItem, Flexbox, Icon, Text } from '@lobehub/ui';
import { AccordionItem, Flexbox, Text } from '@lobehub/ui';
import dayjs from 'dayjs';
import { HashIcon } from 'lucide-react';
import React, { memo, useMemo } from 'react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { type GroupedTopic } from '@/types/topic';
@@ -30,7 +29,6 @@ const GroupItem = memo<GroupItemProps>(({ group, activeTopicId, activeThreadId }
paddingInline={'8px 4px'}
title={
<Flexbox horizontal align="center" gap={6} height={24} style={{ overflow: 'hidden' }}>
<Icon icon={HashIcon} style={{ opacity: 0.5 }} />
<Text ellipsis fontSize={12} style={{ flex: 1 }} type={'secondary'} weight={500}>
{title || timeTitle}
</Text>
@@ -44,6 +42,7 @@ const GroupItem = memo<GroupItemProps>(({ group, activeTopicId, activeThreadId }
fav={topic.favorite}
id={topic.id}
key={topic.id}
metadata={topic.metadata}
threadId={activeThreadId}
title={topic.title}
/>
@@ -41,6 +41,7 @@ const FlatMode = memo(() => {
fav={topic.favorite}
id={topic.id}
key={topic.id}
metadata={topic.metadata}
threadId={activeThreadId}
title={topic.title}
/>
@@ -36,6 +36,7 @@ const SearchResult = memo(() => {
fav={topic.favorite}
id={topic.id}
key={topic.id}
metadata={topic.metadata}
title={topic.title}
/>
))}
+97
View File
@@ -0,0 +1,97 @@
import { SiDiscord, SiTelegram } from '@icons-pack/react-simple-icons';
import type { LucideIcon } from 'lucide-react';
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) */
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',
},
];
@@ -0,0 +1,248 @@
'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 { createStaticStyles } from 'antd-style';
import { RefreshCw, Save, Trash2 } from 'lucide-react';
import { memo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useAppOrigin } from '@/hooks/useAppOrigin';
import { type ChannelProvider } 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';
const styles = createStaticStyles(({ css, cssVar }) => ({
actionBar: css`
display: flex;
align-items: center;
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;
gap: 16px;
width: 100%;
max-width: 1024px;
margin-block: 0;
margin-inline: auto;
padding-block: 0 24px;
padding-inline: 24px;
`,
webhookBox: css`
overflow: hidden;
flex: 1;
height: ${cssVar.controlHeight};
padding-inline: 12px;
border: 1px solid ${cssVar.colorBorder};
border-radius: ${cssVar.borderRadius};
font-family: monospace;
font-size: 13px;
line-height: ${cssVar.controlHeight};
color: ${cssVar.colorTextSecondary};
text-overflow: ellipsis;
white-space: nowrap;
background: ${cssVar.colorFillQuaternary};
`,
}));
const platformFormItemsMap: Record<
string,
(t: any, hasConfig: boolean, provider: ChannelProvider) => FormItemProps[]
> = {
discord: getDiscordFormItems,
feishu: getFeishuFormItems,
lark: getLarkFormItems,
telegram: getTelegramFormItems,
};
interface BodyProps {
currentConfig?: { enabled: boolean };
form: FormInstance<ChannelFormValues>;
hasConfig: boolean;
onCopied: () => void;
onDelete: () => void;
onSave: () => void;
onTestConnection: () => void;
onToggleEnable: (enabled: boolean) => void;
provider: ChannelProvider;
saveResult?: TestResult;
saving: boolean;
testing: boolean;
testResult?: TestResult;
}
const Body = memo<BodyProps>(
({
provider,
form,
hasConfig,
currentConfig,
saveResult,
saving,
testing,
testResult,
onSave,
onDelete,
onTestConnection,
onToggleEnable,
onCopied,
}) => {
const { t } = useTranslation('agent');
const origin = useAppOrigin();
const applicationId = AntdForm.useWatch('applicationId', form);
const webhookUrl = applicationId
? `${origin}/api/agent/webhooks/${provider.id}/${applicationId}`
: `${origin}/api/agent/webhooks/${provider.id}`;
const getItems = platformFormItemsMap[provider.id];
const configItems = getItems ? getItems(t, hasConfig, provider) : [];
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}
</Flexbox>
);
const headerExtra = currentConfig ? (
<Switch checked={currentConfig.enabled} onChange={onToggleEnable} />
) : undefined;
const group: FormGroupItemType = {
children: configItems,
defaultActive: true,
extra: headerExtra,
title: headerTitle,
};
return (
<>
<Form
className={styles.form}
form={form}
itemMinWidth={'max(50%, 400px)'}
items={[group]}
requiredMark={false}
style={{ maxWidth: 1024, padding: 24, width: '100%' }}
variant={'borderless'}
/>
<div className={styles.bottom}>
<div className={styles.actionBar}>
{hasConfig ? (
<Button danger icon={<Trash2 size={16} />} type="primary" onClick={onDelete}>
{t('channel.removeChannel')}
</Button>
) : (
<div />
)}
<Flexbox horizontal gap={12}>
{hasConfig && (
<Button icon={<RefreshCw size={16} />} loading={testing} onClick={onTestConnection}>
{t('channel.testConnection')}
</Button>
)}
<Button icon={<Save size={16} />} loading={saving} type="primary" onClick={onSave}>
{t('channel.save')}
</Button>
</Flexbox>
</div>
{saveResult && (
<Alert
closable
showIcon
description={saveResult.type === 'error' ? saveResult.errorDetail : undefined}
title={saveResult.type === 'success' ? t('channel.saved') : t('channel.saveFailed')}
type={saveResult.type}
/>
)}
{testResult && (
<Alert
closable
showIcon
description={testResult.type === 'error' ? testResult.errorDetail : undefined}
type={testResult.type}
title={
testResult.type === 'success' ? t('channel.testSuccess') : t('channel.testFailed')
}
/>
)}
{hasConfig && provider.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>}
</Flexbox>
<Flexbox horizontal gap={8}>
<div className={styles.webhookBox}>{webhookUrl}</div>
<Button
onClick={() => {
navigator.clipboard.writeText(webhookUrl);
onCopied();
}}
>
{t('channel.copy')}
</Button>
</Flexbox>
<Alert
showIcon
type="info"
message={
<Trans
components={{ bold: <strong /> }}
i18nKey="channel.endpointUrlHint"
ns="agent"
values={{ fieldName: provider.fieldTags.webhook, name: provider.name }}
/>
}
/>
</Flexbox>
)}
</div>
</>
);
},
);
export default Body;
@@ -7,9 +7,8 @@ import { useTranslation } from 'react-i18next';
import { useAgentStore } from '@/store/agent';
import { type IntegrationProvider } from '../const';
import { type ChannelProvider } from '../const';
import Body from './Body';
import Header from './Header';
const styles = createStaticStyles(({ css, cssVar }) => ({
main: css`
@@ -19,6 +18,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
background: ${cssVar.colorBgContainer};
`,
@@ -32,11 +32,14 @@ interface CurrentConfig {
platform: string;
}
export interface IntegrationFormValues {
export interface ChannelFormValues {
applicationId: string;
appSecret?: string;
botToken: string;
encryptKey?: string;
publicKey: string;
secretToken?: string;
verificationToken?: string;
webhookProxyUrl?: string;
}
@@ -48,13 +51,13 @@ export interface TestResult {
interface PlatformDetailProps {
agentId: string;
currentConfig?: CurrentConfig;
provider: IntegrationProvider;
provider: ChannelProvider;
}
const PlatformDetail = memo<PlatformDetailProps>(({ provider, agentId, currentConfig }) => {
const { t } = useTranslation('agent');
const { message: msg, modal } = App.useApp();
const [form] = Form.useForm<IntegrationFormValues>();
const [form] = Form.useForm<ChannelFormValues>();
const [createBotProvider, deleteBotProvider, updateBotProvider, connectBot] = useAgentStore(
(s) => [s.createBotProvider, s.deleteBotProvider, s.updateBotProvider, s.connectBot],
@@ -75,9 +78,12 @@ const PlatformDetail = memo<PlatformDetailProps>(({ provider, agentId, currentCo
if (currentConfig) {
form.setFieldsValue({
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 || '',
});
}
@@ -101,13 +107,23 @@ const PlatformDetail = memo<PlatformDetailProps>(({ provider, agentId, currentCo
}
// Build platform-specific credentials
const credentials: Record<string, string> = { botToken: values.botToken };
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;
}
@@ -138,7 +154,9 @@ const PlatformDetail = memo<PlatformDetailProps>(({ provider, agentId, currentCo
agentId,
provider.id,
provider.autoAppId,
provider.authMode,
provider.fieldTags,
provider.webhookMode,
form,
currentConfig,
createBotProvider,
@@ -153,13 +171,13 @@ const PlatformDetail = memo<PlatformDetailProps>(({ provider, agentId, currentCo
onOk: async () => {
try {
await deleteBotProvider(currentConfig.id, agentId);
msg.success(t('integration.removed'));
msg.success(t('channel.removed'));
form.resetFields();
} catch {
msg.error(t('integration.removeFailed'));
msg.error(t('channel.removeFailed'));
}
},
title: t('integration.deleteConfirm'),
title: t('channel.deleteConfirm'),
});
}, [currentConfig, agentId, deleteBotProvider, msg, t, modal, form]);
@@ -169,7 +187,7 @@ const PlatformDetail = memo<PlatformDetailProps>(({ provider, agentId, currentCo
try {
await updateBotProvider(currentConfig.id, agentId, { enabled });
} catch {
msg.error(t('integration.updateFailed'));
msg.error(t('channel.updateFailed'));
}
},
[currentConfig, agentId, updateBotProvider, msg, t],
@@ -177,7 +195,7 @@ const PlatformDetail = memo<PlatformDetailProps>(({ provider, agentId, currentCo
const handleTestConnection = useCallback(async () => {
if (!currentConfig) {
msg.warning(t('integration.saveFirstWarning'));
msg.warning(t('channel.saveFirstWarning'));
return;
}
@@ -201,12 +219,8 @@ const PlatformDetail = memo<PlatformDetailProps>(({ provider, agentId, currentCo
return (
<main className={styles.main}>
<Header
currentConfig={currentConfig}
provider={provider}
onToggleEnable={handleToggleEnable}
/>
<Body
currentConfig={currentConfig}
form={form}
hasConfig={!!currentConfig}
provider={provider}
@@ -214,10 +228,11 @@ const PlatformDetail = memo<PlatformDetailProps>(({ provider, agentId, currentCo
saving={saving}
testResult={testResult}
testing={testing}
onCopied={() => msg.success(t('integration.copied'))}
onCopied={() => msg.success(t('channel.copied'))}
onDelete={handleDelete}
onSave={handleSave}
onTestConnection={handleTestConnection}
onToggleEnable={handleToggleEnable}
/>
</main>
);
@@ -0,0 +1,43 @@
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: <FormInput placeholder={t('channel.publicKeyPlaceholder')} />,
desc: t('channel.publicKeyHint'),
label: t('channel.publicKey'),
name: 'publicKey',
tag: provider.fieldTags.publicKey,
},
{
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,
},
];
@@ -0,0 +1,50 @@
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,
},
];
@@ -0,0 +1,50 @@
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,
},
];
@@ -0,0 +1,46 @@
import type { FormItemProps } from '@lobehub/ui';
import type { TFunction } from 'i18next';
import { FormInput, FormPassword } from '@/components/FormInput';
import type { ChannelProvider } from '../../const';
export const getTelegramFormItems = (
t: TFunction<'agent'>,
hasConfig: boolean,
provider: ChannelProvider,
): FormItemProps[] => [
{
children: (
<FormPassword
autoComplete="new-password"
placeholder={
hasConfig ? t('channel.botTokenPlaceholderExisting') : t('channel.botTokenPlaceholderNew')
}
/>
),
desc: t('channel.botTokenEncryptedHint'),
label: t('channel.botToken'),
name: 'botToken',
rules: [{ required: true }],
tag: provider.fieldTags.token,
},
{
children: <FormPassword placeholder={t('channel.secretTokenPlaceholder')} />,
desc: t('channel.secretTokenHint'),
label: t('channel.secretToken'),
name: 'secretToken',
tag: provider.fieldTags.secretToken,
},
...(process.env.NODE_ENV === 'development'
? ([
{
children: <FormInput placeholder="https://xxx.trycloudflare.com" />,
desc: t('channel.devWebhookProxyUrlHint'),
label: t('channel.devWebhookProxyUrl'),
name: 'webhookProxyUrl',
rules: [{ type: 'url' as const }],
},
] as FormItemProps[])
: []),
];
+34
View File
@@ -0,0 +1,34 @@
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,9 +9,9 @@ import Loading from '@/components/Loading/BrandTextLoading';
import NavHeader from '@/features/NavHeader';
import { useAgentStore } from '@/store/agent';
import { INTEGRATION_PROVIDERS } from './const';
import PlatformDetail from './PlatformDetail';
import PlatformList from './PlatformList';
import { CHANNEL_PROVIDERS } from './const';
import PlatformDetail from './detail';
import PlatformList from './list';
const styles = createStaticStyles(({ css }) => ({
container: css`
@@ -24,9 +24,9 @@ const styles = createStaticStyles(({ css }) => ({
`,
}));
const IntegrationPage = memo(() => {
const ChannelPage = memo(() => {
const { aid } = useParams<{ aid?: string }>();
const [activeProviderId, setActiveProviderId] = useState(INTEGRATION_PROVIDERS[0].id);
const [activeProviderId, setActiveProviderId] = useState(CHANNEL_PROVIDERS[0].id);
const { data: providers, isLoading } = useAgentStore((s) => s.useFetchBotProviders(aid));
@@ -36,7 +36,7 @@ const IntegrationPage = memo(() => {
);
const activeProvider = useMemo(
() => INTEGRATION_PROVIDERS.find((p) => p.id === activeProviderId) || INTEGRATION_PROVIDERS[0],
() => CHANNEL_PROVIDERS.find((p) => p.id === activeProviderId) || CHANNEL_PROVIDERS[0],
[activeProviderId],
);
@@ -51,14 +51,14 @@ const IntegrationPage = memo(() => {
<Flexbox flex={1} height={'100%'}>
<NavHeader />
<Flexbox flex={1} style={{ overflowY: 'auto' }}>
{isLoading && <Loading debugId="IntegrationPage" />}
{isLoading && <Loading debugId="ChannelPage" />}
{!isLoading && (
<div className={styles.container}>
<PlatformList
activeId={activeProviderId}
connectedPlatforms={connectedPlatforms}
providers={INTEGRATION_PROVIDERS}
providers={CHANNEL_PROVIDERS}
onSelect={setActiveProviderId}
/>
<PlatformDetail agentId={aid} currentConfig={currentConfig} provider={activeProvider} />
@@ -69,4 +69,4 @@ const IntegrationPage = memo(() => {
);
});
export default IntegrationPage;
export default ChannelPage;
@@ -6,7 +6,7 @@ import { Info } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { IntegrationProvider } from './const';
import type { ChannelProvider } from './const';
const styles = createStaticStyles(({ css, cssVar }) => ({
root: css`
@@ -78,7 +78,7 @@ interface PlatformListProps {
activeId: string;
connectedPlatforms: Set<string>;
onSelect: (id: string) => void;
providers: IntegrationProvider[];
providers: ChannelProvider[];
}
const PlatformList = memo<PlatformListProps>(
@@ -89,7 +89,7 @@ const PlatformList = memo<PlatformListProps>(
return (
<aside className={styles.root}>
<div className={styles.list}>
<div className={styles.title}>{t('integration.platforms')}</div>
<div className={styles.title}>{t('channel.platforms')}</div>
{providers.map((provider) => {
const ProviderIcon = provider.icon;
return (
@@ -110,7 +110,9 @@ const PlatformList = memo<PlatformListProps>(
</div>
<div style={{ borderTop: `1px solid ${theme.colorBorder}`, padding: 12 }}>
<a
href="#"
href="https://lobehub.com/docs/usage/channels/overview"
rel="noopener noreferrer"
target="_blank"
style={{
alignItems: 'center',
color: theme.colorTextSecondary,
@@ -119,7 +121,7 @@ const PlatformList = memo<PlatformListProps>(
gap: 4,
}}
>
<Icon icon={Info} size={'small'} /> {t('integration.documentation')}
<Icon icon={Info} size={'small'} /> {t('channel.documentation')}
</a>
</div>
</aside>
@@ -1,364 +0,0 @@
'use client';
import { Alert, Flexbox, Icon, Tag, Text } from '@lobehub/ui';
import { Button, Form, type FormInstance, Input } from 'antd';
import { createStaticStyles } from 'antd-style';
import { ExternalLink, Info, RefreshCw, Save, Trash2 } from 'lucide-react';
import { memo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useAppOrigin } from '@/hooks/useAppOrigin';
import { type IntegrationProvider } from '../const';
import type { IntegrationFormValues, TestResult } from './index';
const styles = createStaticStyles(({ css, cssVar }) => ({
actionBar: css`
display: flex;
align-items: center;
justify-content: space-between;
padding-block-start: 32px;
`,
content: css`
display: flex;
flex-direction: column;
gap: 24px;
width: 100%;
max-width: 800px;
margin-block: 0;
margin-inline: auto;
padding: 24px;
`,
field: css`
display: flex;
flex-direction: column;
gap: 8px;
`,
helperLink: css`
cursor: pointer;
display: flex;
gap: 4px;
align-items: center;
font-size: 12px;
color: ${cssVar.colorPrimary};
text-decoration: none;
&:hover {
text-decoration: underline;
}
`,
label: css`
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
font-weight: 600;
color: ${cssVar.colorText};
`,
labelLeft: css`
display: flex;
gap: 8px;
align-items: center;
`,
section: css`
display: flex;
flex-direction: column;
gap: 24px;
`,
sectionTitle: css`
display: flex;
gap: 8px;
align-items: center;
font-size: 12px;
font-weight: 700;
color: ${cssVar.colorTextQuaternary};
&::before {
content: '';
display: block;
width: 6px;
height: 6px;
border-radius: 50%;
background: ${cssVar.colorPrimary};
}
`,
webhookBox: css`
overflow: hidden;
flex: 1;
height: ${cssVar.controlHeight};
padding-inline: 12px;
border: 1px solid ${cssVar.colorBorder};
border-radius: ${cssVar.borderRadius};
font-family: monospace;
font-size: 13px;
line-height: ${cssVar.controlHeight};
color: ${cssVar.colorTextSecondary};
text-overflow: ellipsis;
white-space: nowrap;
background: ${cssVar.colorFillQuaternary};
`,
}));
interface BodyProps {
form: FormInstance<IntegrationFormValues>;
hasConfig: boolean;
onCopied: () => void;
onDelete: () => void;
onSave: () => void;
onTestConnection: () => void;
provider: IntegrationProvider;
saveResult?: TestResult;
saving: boolean;
testing: boolean;
testResult?: TestResult;
}
const Body = memo<BodyProps>(
({
provider,
form,
hasConfig,
saveResult,
saving,
testing,
testResult,
onSave,
onDelete,
onTestConnection,
onCopied,
}) => {
const { t } = useTranslation('agent');
const origin = useAppOrigin();
return (
<Form component={false} form={form}>
<div className={styles.content}>
{/* Connection Config */}
<div className={styles.section}>
<div className={styles.sectionTitle}>{t('integration.connectionConfig')}</div>
{!provider.autoAppId && (
<div className={styles.field}>
<div className={styles.label}>
<div className={styles.labelLeft}>
{t('integration.applicationId')}
{provider.fieldTags.appId && <Tag>{provider.fieldTags.appId}</Tag>}
</div>
</div>
<Form.Item noStyle name="applicationId" rules={[{ required: true }]}>
<Input placeholder={t('integration.applicationIdPlaceholder')} />
</Form.Item>
</div>
)}
<div className={styles.field}>
<div className={styles.label}>
<div className={styles.labelLeft}>
{t('integration.botToken')}
{provider.fieldTags.token && <Tag>{provider.fieldTags.token}</Tag>}
</div>
<a
className={styles.helperLink}
href={provider.docsLink}
rel="noreferrer"
target="_blank"
>
{t('integration.botTokenHowToGet')} <Icon icon={ExternalLink} size={'small'} />
</a>
</div>
<Form.Item noStyle name="botToken" rules={[{ required: true }]}>
<Input.Password
style={{ fontFamily: 'monospace' }}
placeholder={
hasConfig
? t('integration.botTokenPlaceholderExisting')
: t('integration.botTokenPlaceholderNew')
}
/>
</Form.Item>
<Text
type="secondary"
style={{
alignItems: 'center',
display: 'flex',
fontSize: 12,
gap: 4,
}}
>
<Icon icon={Info} size={'small'} /> {t('integration.botTokenEncryptedHint')}
</Text>
</div>
{provider.fieldTags.publicKey && (
<div className={styles.field}>
<div className={styles.label}>
<div className={styles.labelLeft}>
{t('integration.publicKey')}
<Tag>{provider.fieldTags.publicKey}</Tag>
</div>
</div>
<Form.Item noStyle name="publicKey">
<Input
placeholder={t('integration.publicKeyPlaceholder')}
style={{ fontFamily: 'monospace' }}
/>
</Form.Item>
</div>
)}
{provider.fieldTags.secretToken && (
<div className={styles.field}>
<div className={styles.label}>
<div className={styles.labelLeft}>
{t('integration.secretToken')}
<Tag>{provider.fieldTags.secretToken}</Tag>
</div>
</div>
<Form.Item noStyle name="secretToken">
<Input.Password
placeholder={t('integration.secretTokenPlaceholder')}
style={{ fontFamily: 'monospace' }}
/>
</Form.Item>
<Text
type="secondary"
style={{
alignItems: 'center',
display: 'flex',
fontSize: 12,
gap: 4,
}}
>
<Icon icon={Info} size={'small'} /> {t('integration.secretTokenHint')}
</Text>
</div>
)}
{/* Dev-only: HTTPS tunnel URL for Telegram webhook */}
{provider.webhookMode === 'auto' && process.env.NODE_ENV === 'development' && (
<div className={styles.field}>
<div className={styles.label}>
<div className={styles.labelLeft}>{t('integration.devWebhookProxyUrl')}</div>
</div>
<Form.Item noStyle name="webhookProxyUrl">
<Input
placeholder="https://xxx.trycloudflare.com"
style={{ fontFamily: 'monospace' }}
/>
</Form.Item>
<Text
type="secondary"
style={{
alignItems: 'center',
display: 'flex',
fontSize: 12,
gap: 4,
}}
>
<Icon icon={Info} size={'small'} /> {t('integration.devWebhookProxyUrlHint')}
</Text>
</div>
)}
</div>
{/* Action Bar */}
<div className={styles.actionBar}>
{hasConfig ? (
<Button danger icon={<Trash2 size={16} />} type="primary" onClick={onDelete}>
{t('integration.removeIntegration')}
</Button>
) : (
<div />
)}
<Flexbox horizontal gap={12}>
{hasConfig && (
<Button icon={<RefreshCw size={16} />} loading={testing} onClick={onTestConnection}>
{t('integration.testConnection')}
</Button>
)}
<Button icon={<Save size={16} />} loading={saving} type="primary" onClick={onSave}>
{t('integration.save')}
</Button>
</Flexbox>
</div>
{saveResult && (
<Alert
closable
showIcon
description={saveResult.type === 'error' ? saveResult.errorDetail : undefined}
type={saveResult.type}
title={
saveResult.type === 'success' ? t('integration.saved') : t('integration.saveFailed')
}
/>
)}
{testResult && (
<Alert
closable
showIcon
description={testResult.type === 'error' ? testResult.errorDetail : undefined}
type={testResult.type}
title={
testResult.type === 'success'
? t('integration.testSuccess')
: t('integration.testFailed')
}
/>
)}
{/* Endpoint URL - platform-specific rendering */}
{hasConfig && provider.webhookMode !== 'auto' && (
<div className={styles.field}>
<div className={styles.label}>
<div className={styles.labelLeft}>
{t('integration.endpointUrl')}
{provider.fieldTags.webhook && <Tag>{provider.fieldTags.webhook}</Tag>}
</div>
</div>
<Flexbox horizontal gap={8}>
<div className={styles.webhookBox}>
{`${origin}/api/agent/webhooks/${provider.id}`}
</div>
<Button
onClick={() => {
navigator.clipboard.writeText(`${origin}/api/agent/webhooks/${provider.id}`);
onCopied();
}}
>
{t('integration.copy')}
</Button>
</Flexbox>
<Alert
showIcon
type="info"
message={
<Trans
components={{ bold: <strong /> }}
i18nKey="integration.endpointUrlHint"
ns="agent"
values={{ name: provider.name }}
/>
}
/>
</div>
)}
</div>
</Form>
);
},
);
export default Body;
@@ -1,98 +0,0 @@
'use client';
import { Flexbox, Icon } from '@lobehub/ui';
import { Switch, Typography } from 'antd';
import { createStaticStyles } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { type IntegrationProvider } from '../const';
const { Title, Text } = Typography;
const styles = createStaticStyles(({ css, cssVar }) => ({
header: css`
position: sticky;
z-index: 10;
inset-block-start: 0;
display: flex;
justify-content: center;
width: 100%;
padding-block: 16px;
padding-inline: 0;
background: ${cssVar.colorBgContainer};
`,
headerContent: css`
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
max-width: 800px;
padding-block: 0;
padding-inline: 24px;
`,
headerIcon: css`
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 10px;
color: ${cssVar.colorText};
fill: white;
`,
}));
interface HeaderProps {
currentConfig?: {
enabled: boolean;
};
onToggleEnable: (enabled: boolean) => void;
provider: IntegrationProvider;
}
const Header = memo<HeaderProps>(({ provider, currentConfig, onToggleEnable }) => {
const { t } = useTranslation('agent');
const ProviderIcon = provider.icon;
return (
<header className={styles.header}>
<div className={styles.headerContent}>
<Flexbox horizontal align="center" gap={12}>
<div className={styles.headerIcon} style={{ background: provider.color }}>
<Icon fill={'white'} icon={ProviderIcon} size={'large'} />
</div>
<div>
<Flexbox align="flex-start">
<Title level={5} style={{ margin: 0 }}>
{provider.name}
</Title>
<Text style={{ fontSize: 12 }} type="secondary">
{provider.description}
</Text>
</Flexbox>
</div>
</Flexbox>
{currentConfig && (
<Flexbox horizontal align="center" gap={12}>
<Text strong>
{currentConfig.enabled ? t('integration.enabled') : t('integration.disabled')}
</Text>
<Switch checked={currentConfig.enabled} onChange={onToggleEnable} />
</Flexbox>
)}
</div>
</header>
);
});
export default Header;
@@ -1,56 +0,0 @@
import { SiDiscord, SiTelegram } from '@icons-pack/react-simple-icons';
import type { LucideIcon } from 'lucide-react';
import type { FC } from 'react';
export interface IntegrationProvider {
/** Whether applicationId can be auto-derived from the bot token */
autoAppId?: boolean;
color: string;
description: string;
docsLink: string;
fieldTags: {
appId: string;
publicKey?: string;
secretToken?: string;
token: string;
webhook?: string;
};
icon: FC<any> | LucideIcon;
id: string;
name: string;
/** 'manual' = user must copy endpoint URL to platform portal (Discord);
* 'auto' = webhook is set automatically via API (Telegram) */
webhookMode?: 'auto' | 'manual';
}
export const INTEGRATION_PROVIDERS: IntegrationProvider[] = [
{
color: '#5865F2',
description: 'Connect this assistant to Discord server for channel chat and direct messages.',
docsLink: 'https://discord.com/developers/docs/intro',
fieldTags: {
appId: 'Application ID',
publicKey: 'Public Key',
token: 'Bot Token',
webhook: 'Interactions Endpoint URL',
},
icon: SiDiscord,
id: 'discord',
name: 'Discord',
},
{
autoAppId: true,
color: '#26A5E4',
description: 'Connect this assistant to Telegram for private and group chats.',
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',
},
];
@@ -1,6 +1,6 @@
import { ActionIcon, Flexbox, Icon, Skeleton, Tag } from '@lobehub/ui';
import { Flexbox, Icon, Skeleton, Tag } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { MessageSquareDashed, Star } from 'lucide-react';
import { HashIcon, MessageSquareDashed } from 'lucide-react';
import { memo, Suspense, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
@@ -42,8 +42,6 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId }) =>
id ? s.topicLoadingIds.includes(id) : false,
]);
const [favoriteTopic] = useChatStore((s) => [s.favoriteTopic]);
const toggleEditing = useCallback(
(visible?: boolean) => {
useChatStore.setState({ topicRenamingId: visible && id ? id : '' });
@@ -125,17 +123,7 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId }) =>
loading={isLoading}
title={title}
icon={
<ActionIcon
color={fav ? cssVar.colorWarning : undefined}
fill={fav ? cssVar.colorWarning : 'transparent'}
icon={Star}
size={'small'}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
favoriteTopic(id, !fav);
}}
/>
<Icon icon={HashIcon} size={'small'} style={{ color: cssVar.colorTextDescription }} />
}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
@@ -1,7 +1,6 @@
import { AccordionItem, Flexbox, Icon, Text } from '@lobehub/ui';
import { AccordionItem, Flexbox, Text } from '@lobehub/ui';
import dayjs from 'dayjs';
import { HashIcon } from 'lucide-react';
import React, { memo, useMemo } from 'react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { type GroupedTopic } from '@/types/topic';
@@ -30,7 +29,6 @@ const GroupItem = memo<GroupItemProps>(({ group, activeTopicId, activeThreadId }
paddingInline={'8px 4px'}
title={
<Flexbox horizontal align="center" gap={6} height={24} style={{ overflow: 'hidden' }}>
<Icon icon={HashIcon} style={{ opacity: 0.5 }} />
<Text ellipsis fontSize={12} style={{ flex: 1 }} type={'secondary'} weight={500}>
{title || timeTitle}
</Text>
@@ -0,0 +1,60 @@
'use client';
import { type FormGroupItemType } from '@lobehub/ui';
import { Form, Icon, Skeleton } from '@lobehub/ui';
import { Switch } from '@lobehub/ui/base-ui';
import isEqual from 'fast-deep-equal';
import { Loader2Icon } from 'lucide-react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FORM_STYLE } from '@/const/layoutTokens';
import SettingHeader from '@/routes/(main)/settings/features/SettingHeader';
import { useUserStore } from '@/store/user';
import { settingsSelectors } from '@/store/user/selectors';
const Page = memo(() => {
const { t } = useTranslation('setting');
const general = useUserStore((s) => settingsSelectors.currentSettings(s).general, isEqual);
const [setSettings, isUserStateInit] = useUserStore((s) => [s.setSettings, s.isUserStateInit]);
const [loading, setLoading] = useState(false);
if (!isUserStateInit) return <Skeleton active paragraph={{ rows: 5 }} title={false} />;
const advancedGroup: FormGroupItemType = {
children: [
{
children: <Switch />,
desc: t('settingCommon.devMode.desc'),
label: t('settingCommon.devMode.title'),
minWidth: undefined,
name: 'isDevMode',
valuePropName: 'checked',
},
],
extra: loading && <Icon spin icon={Loader2Icon} size={16} style={{ opacity: 0.5 }} />,
title: t('tab.advanced'),
};
return (
<>
<SettingHeader title={t('tab.advanced')} />
<Form
collapsible={false}
initialValues={general}
items={[advancedGroup]}
itemsType={'group'}
variant={'filled'}
onValuesChange={async (v) => {
setLoading(true);
await setSettings({ general: v });
setLoading(false);
}}
{...FORM_STYLE}
/>
</>
);
});
export default Page;
@@ -171,14 +171,6 @@ const Common = memo(() => {
name: 'isLiteMode',
valuePropName: 'checked',
},
{
children: <Switch />,
desc: t('settingCommon.devMode.desc'),
label: t('settingCommon.devMode.title'),
minWidth: undefined,
name: 'isDevMode',
valuePropName: 'checked',
},
],
extra: loading && <Icon spin icon={Loader2Icon} size={16} style={{ opacity: 0.5 }} />,
title: t('settingCommon.title'),
@@ -7,6 +7,9 @@ import { SettingsTabs } from '@/store/global/initialState';
const loading = (debugId: string) => () => createElement(Loading, { debugId });
export const componentMap = {
[SettingsTabs.Advanced]: dynamic(() => import('../advanced'), {
loading: loading('Settings > Advanced'),
}),
[SettingsTabs.Common]: dynamic(() => import('../common'), {
loading: loading('Settings > Common'),
}),
@@ -8,6 +8,7 @@ import {
Coins,
CreditCard,
Database,
EllipsisIcon,
EthernetPort,
FlaskConical,
Gift,
@@ -231,6 +232,11 @@ export const useCategory = () => {
key: SettingsTabs.Storage,
label: t('tab.storage'),
},
{
icon: EllipsisIcon,
key: SettingsTabs.Advanced,
label: t('tab.advanced'),
},
!hideDocs && {
icon: Info,
key: SettingsTabs.About,
@@ -307,17 +307,20 @@ export class AgentBridgeService {
try {
progressMessage = await thread.post(renderStart(userMessage.text, { timezone }));
} catch (error) {
console.error('[AgentBridge] executeWithWebhooks: failed to post progress message:', error);
log('executeWithWebhooks: failed to post progress message: %O', error);
}
const progressMessageId = progressMessage?.id;
if (!progressMessageId) {
throw new Error('Failed to post initial progress message');
log('executeWithWebhooks: no progressMessageId, will proceed without progress updates');
}
// Refresh typing indicator after posting the ack message,
// so typing stays active until the first step webhook arrives
await thread.startTyping();
if (progressMessage) {
await thread.startTyping();
}
// Build webhook URL for bot-callback endpoint
// Prefer INTERNAL_APP_URL for server-to-server calls (bypasses CDN/proxy)
+239 -124
View File
@@ -1,10 +1,12 @@
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';
@@ -51,6 +53,18 @@ function createAdapterForPlatform(
}),
};
}
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;
}
@@ -60,6 +74,9 @@ function createAdapterForPlatform(
/**
* Routes incoming webhook events to the correct Chat SDK Bot instance
* and triggers message processing via AgentBridgeService.
*
* Uses lazy per-bot loading: only the bot needed for an incoming webhook
* is loaded from DB, rather than eagerly loading all bots at startup.
*/
export class BotMessageRouter {
/** botToken → Chat instance (for Discord webhook routing via x-discord-gateway-token) */
@@ -74,6 +91,17 @@ export class BotMessageRouter {
/** "platform:applicationId" → credentials */
private credentialsByKey = new Map<string, StoredCredentials>();
/** Dedup concurrent loads for the same bot key */
private loadingPromises = new Map<string, Promise<Chat<any> | null>>();
/** Dedup concurrent loadPlatformBots calls */
private platformLoadPromises = new Map<string, Promise<void>>();
/** Lazily resolved shared dependencies */
private serverDB: LobeChatDatabase | null = null;
private gateKeeper: KeyVaultsGateKeeper | null = null;
private infraPromise: Promise<void> | null = null;
// ------------------------------------------------------------------
// Public API
// ------------------------------------------------------------------
@@ -86,7 +114,7 @@ export class BotMessageRouter {
*/
getWebhookHandler(platform: string, appId?: string): (req: Request) => Promise<Response> {
return async (req: Request) => {
await this.ensureInitialized();
await this.ensureInfra();
switch (platform) {
case 'discord': {
@@ -95,6 +123,10 @@ export class BotMessageRouter {
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 });
}
@@ -134,7 +166,15 @@ export class BotMessageRouter {
// ignore parse errors
}
const bot = this.botInstancesByToken.get(gatewayToken);
// Try cached token lookup first
let bot = this.botInstancesByToken.get(gatewayToken);
if (bot?.webhooks && 'discord' in bot.webhooks) {
return bot.webhooks.discord(this.cloneRequest(req, bodyBuffer));
}
// Fallback: load all Discord bots to find the one matching this token
await this.loadPlatformBots('discord');
bot = this.botInstancesByToken.get(gatewayToken);
if (bot?.webhooks && 'discord' in bot.webhooks) {
return bot.webhooks.discord(this.cloneRequest(req, bodyBuffer));
}
@@ -150,7 +190,7 @@ export class BotMessageRouter {
const appId = payload.application_id;
if (appId) {
const bot = this.botInstances.get(`discord:${appId}`);
const bot = await this.ensureBotLoaded('discord', appId);
if (bot?.webhooks && 'discord' in bot.webhooks) {
return bot.webhooks.discord(this.cloneRequest(req, bodyBuffer));
}
@@ -159,19 +199,6 @@ export class BotMessageRouter {
// 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 });
}
@@ -211,34 +238,43 @@ export class BotMessageRouter {
// Direct lookup by applicationId (bot-specific endpoint: /webhooks/telegram/{appId})
if (appId) {
const key = `telegram:${appId}`;
const bot = this.botInstances.get(key);
const bot = await this.ensureBotLoaded('telegram', appId);
if (bot?.webhooks && 'telegram' in bot.webhooks) {
log('handleTelegramWebhook: direct lookup hit for %s', key);
log('handleTelegramWebhook: direct lookup hit for telegram:%s', appId);
return bot.webhooks.telegram(this.cloneRequest(req, bodyBuffer));
}
log('handleTelegramWebhook: no bot registered for %s', key);
log('handleTelegramWebhook: no bot registered for telegram:%s', appId);
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 appId provided, cannot route');
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 bot = await this.ensureBotLoaded(platform, appId);
if (bot?.webhooks && platform in bot.webhooks) {
return (bot.webhooks as any)[platform](this.cloneRequest(req, bodyBuffer));
}
log('handleChatSdkWebhook: no bot registered for %s:%s', platform, appId);
return new Response(`No bot configured for ${platform}`, { status: 404 });
}
log('handleTelegramWebhook: no matching bot found');
return new Response('No bot configured for Telegram', { status: 404 });
return new Response(`No bot configured for ${platform}`, { status: 404 });
}
private cloneRequest(req: Request, body: ArrayBuffer): Request {
@@ -250,117 +286,194 @@ export class BotMessageRouter {
}
// ------------------------------------------------------------------
// Initialisation
// Lazy loading infrastructure
// ------------------------------------------------------------------
private static REFRESH_INTERVAL_MS = 5 * 60_000;
private initPromise: Promise<void> | null = null;
private lastLoadedAt = 0;
private refreshPromise: Promise<void> | null = null;
private async ensureInitialized(): Promise<void> {
if (!this.initPromise) {
this.initPromise = this.initialize();
/**
* Ensure DB and gateKeeper are ready. Called once per webhook request.
* Also handles periodic cache invalidation so newly added bots are discovered.
*/
private async ensureInfra(): Promise<void> {
if (!this.infraPromise) {
this.infraPromise = this.initInfra();
}
await this.initPromise;
await this.infraPromise;
// Periodically refresh bot mappings in the background so newly added bots are discovered
// Periodically clear cache so newly added/changed bots are discovered on next request
if (
Date.now() - this.lastLoadedAt > BotMessageRouter.REFRESH_INTERVAL_MS &&
!this.refreshPromise
this.lastLoadedAt > 0 &&
Date.now() - this.lastLoadedAt > BotMessageRouter.REFRESH_INTERVAL_MS
) {
this.refreshPromise = this.loadAgentBots().finally(() => {
this.refreshPromise = null;
});
log('Cache expired, clearing bot instances for lazy reload');
this.botInstances.clear();
this.agentMap.clear();
this.credentialsByKey.clear();
this.botInstancesByToken.clear();
this.loadingPromises.clear();
this.platformLoadPromises.clear();
this.lastLoadedAt = 0;
}
}
async initialize(): Promise<void> {
log('Initializing BotMessageRouter');
await this.loadAgentBots();
log('Initialized: %d agent bots', this.botInstances.size);
private async initInfra(): Promise<void> {
log('Initializing BotMessageRouter infrastructure');
this.serverDB = await getServerDB();
this.gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey();
log('Infrastructure ready');
}
// ------------------------------------------------------------------
// Per-agent bots from DB
// Lazy per-bot loading with dedup
// ------------------------------------------------------------------
private async loadAgentBots(): Promise<void> {
/**
* Ensure a single bot is loaded and cached. Returns the Chat instance or null.
* Deduplicates concurrent loads for the same platform:appId.
*/
private async ensureBotLoaded(platform: string, appId: string): Promise<Chat<any> | null> {
const key = `${platform}:${appId}`;
// Already cached
const existing = this.botInstances.get(key);
if (existing) return existing;
// Dedup: another request is already loading this bot
const pending = this.loadingPromises.get(key);
if (pending) return pending;
// Load from DB
const promise = this.loadBot(platform, appId).finally(() => {
this.loadingPromises.delete(key);
});
this.loadingPromises.set(key, promise);
return promise;
}
/**
* Load a single bot from DB, create Chat instance, register handlers, initialize.
*/
private async loadBot(platform: string, appId: string): Promise<Chat<any> | null> {
const key = `${platform}:${appId}`;
log('loadBot: loading %s', key);
try {
const serverDB = await getServerDB();
const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey();
const provider = await AgentBotProviderModel.findEnabledByPlatformAndAppId(
this.serverDB!,
platform,
appId,
this.gateKeeper ?? undefined,
);
// Load all supported platforms
for (const platform of ['discord', 'telegram']) {
const providers = await AgentBotProviderModel.findEnabledByPlatform(
serverDB,
platform,
gateKeeper,
);
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.botInstances.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('loadBot: no enabled provider found for %s', key);
return null;
}
this.lastLoadedAt = Date.now();
return this.initializeBot(platform, provider);
} catch (error) {
log('Failed to load agent bots: %O', error);
log('loadBot: failed to load %s: %O', key, error);
return null;
}
}
/**
* Load all bots for a given platform with concurrent dedup.
* Used for Discord gateway token routing where we don't know the appId upfront.
*/
private async loadPlatformBots(platform: string): Promise<void> {
const pending = this.platformLoadPromises.get(platform);
if (pending) return pending;
const promise = this.doLoadPlatformBots(platform).finally(() => {
this.platformLoadPromises.delete(platform);
});
this.platformLoadPromises.set(platform, promise);
return promise;
}
private async doLoadPlatformBots(platform: string): Promise<void> {
log('loadPlatformBots: loading all %s bots', platform);
try {
const providers = await AgentBotProviderModel.findEnabledByPlatform(
this.serverDB!,
platform,
this.gateKeeper ?? undefined,
);
log('loadPlatformBots: found %d %s providers', providers.length, platform);
for (const provider of providers) {
const key = `${platform}:${provider.applicationId}`;
if (this.botInstances.has(key)) continue;
await this.initializeBot(platform, provider);
}
} catch (error) {
log('loadPlatformBots: failed for %s: %O', platform, error);
}
}
/**
* Shared bot initialization: create adapter, Chat instance, register handlers,
* populate caches, set webhooks.
*/
private async initializeBot(
platform: string,
provider: DecryptedBotProvider,
): Promise<Chat<any> | null> {
const { agentId, userId, applicationId, credentials } = provider;
const key = `${platform}:${applicationId}`;
// Double-check: might have been loaded by a concurrent call
const existing = this.botInstances.get(key);
if (existing) return existing;
const adapters = createAdapterForPlatform(platform, credentials, applicationId);
if (!adapters) {
log('initializeBot: unsupported platform %s', platform);
return null;
}
const bot = this.createBot(adapters, `agent-${agentId}`);
this.registerHandlers(bot, this.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);
});
}
if (!this.lastLoadedAt) this.lastLoadedAt = Date.now();
log('Created %s bot for agent=%s, appId=%s', platform, agentId, applicationId);
return bot;
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
@@ -368,6 +481,7 @@ export class BotMessageRouter {
private createBot(adapters: Record<string, any>, label: string): Chat<any> {
const config: any = {
adapters,
logger: 'debug',
userName: `lobehub-bot-${label}`,
};
@@ -422,16 +536,17 @@ export class BotMessageRouter {
});
});
// Telegram-only: handle messages in unsubscribed threads that aren't @mentions.
// This covers Telegram private chats where users message the bot directly.
// 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') {
if (platform === 'telegram' || platform === 'lark' || platform === 'feishu') {
bot.onNewMessage(/./, async (thread, message) => {
if (message.author.isBot === true) return;
log(
'onNewMessage (telegram catch-all): agent=%s, author=%s, thread=%s, text=%s',
'onNewMessage (%s catch-all): agent=%s, author=%s, thread=%s, text=%s',
platform,
agentId,
message.author.userName,
thread.id,
+135
View File
@@ -0,0 +1,135 @@
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;
}
}
+3 -1
View File
@@ -26,6 +26,8 @@ export interface GatewayListenerOptions {
}
export class Discord implements PlatformBot {
static readonly persistent = true;
readonly platform = 'discord';
readonly applicationId: string;
@@ -69,7 +71,7 @@ export class Discord implements PlatformBot {
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
const waitUntil = options?.waitUntil ?? ((task: Promise<any>) => task.catch(() => {}));
const webhookUrl = `${appEnv.APP_URL}/api/agent/webhooks/discord`;
const webhookUrl = `${(appEnv.APP_URL || '').trim()}/api/agent/webhooks/discord`;
await discordAdapter.startGatewayListener(
{ waitUntil },
@@ -1,8 +1,11 @@
import type { PlatformBotClass } from '../types';
import { Discord } from './discord';
import { Lark } from './lark';
import { Telegram } from './telegram';
export const platformBotRegistry: Record<string, PlatformBotClass> = {
discord: Discord,
feishu: Lark,
lark: Lark,
telegram: Telegram,
};
+53
View File
@@ -0,0 +1,53 @@
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
}
}
@@ -68,7 +68,7 @@ export class Telegram implements PlatformBot {
// 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 || '').replace(/\/$/, '');
const baseUrl = (this.config.webhookProxyUrl || appEnv.APP_URL || '').trim().replace(/\/$/, '');
const webhookUrl = `${baseUrl}/api/agent/webhooks/telegram/${this.applicationId}`;
await this.setWebhookInternal(webhookUrl);
+6 -4
View File
@@ -140,8 +140,9 @@ function renderInlineStats(params: {
if (totalTokens <= 0) return { footer: '', header };
const stats = `${formatTokens(totalTokens)} tokens · $${totalCost.toFixed(4)}`;
// Discord uses -# for small text; Telegram renders it as literal text
const footer = platform === 'telegram' ? `\n\n${stats}` : `\n\n-# ${stats}`;
// 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 };
}
@@ -258,8 +259,9 @@ export function renderFinalReply(
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; Telegram renders it as literal text
const footer = platform === 'telegram' ? stats : `-# ${stats}`;
// 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}`;
}
+4 -1
View File
@@ -5,4 +5,7 @@ export interface PlatformBot {
stop: () => Promise<void>;
}
export type PlatformBotClass = new (config: any) => PlatformBot;
export type PlatformBotClass = (new (config: any) => PlatformBot) & {
/** Whether instances require a persistent connection (e.g. WebSocket). */
persistent?: boolean;
};
@@ -190,6 +190,7 @@ export class GatewayManager {
return new BotClass({
...provider.credentials,
applicationId: provider.applicationId,
platform,
});
}
}
+18 -4
View File
@@ -36,10 +36,24 @@ export class GatewayService {
userId: string,
): Promise<'started' | 'queued'> {
if (isVercel) {
const queue = new BotConnectQueue();
await queue.push(platform, applicationId, userId);
log('Queued bot connect %s:%s', platform, applicationId);
return 'queued';
const BotClass = platformBotRegistry[platform];
const isPersistent = BotClass?.persistent === true;
if (isPersistent) {
// 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);
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);
return 'started';
}
let manager = getGatewayManager();
+3 -3
View File
@@ -41,10 +41,10 @@ export const desktopRoutes: RouteObject[] = [
},
{
element: dynamicElement(
() => import('@/routes/(main)/agent/integration'),
'Desktop > Chat > Integration',
() => import('@/routes/(main)/agent/channel'),
'Desktop > Chat > Channel',
),
path: 'integration',
path: 'channel',
},
],
element: dynamicLayout(
+13 -5
View File
@@ -33,6 +33,18 @@ describe('cleanSpeakerTag', () => {
const expected = 'Content';
expect(cleanSpeakerTag(input)).toBe(expected);
});
it('should remove IM bot speaker tag with id/username/nickname', () => {
const input = '<speaker id="ou_abc123" username="ou_abc123" nickname="ou_abc123" />\nhello';
const expected = 'hello';
expect(cleanSpeakerTag(input)).toBe(expected);
});
it('should remove IM bot speaker tag with avatar', () => {
const input = '<speaker id="123" username="john" nickname="John Doe" avatar="abc" />\nHello!';
const expected = 'Hello!';
expect(cleanSpeakerTag(input)).toBe(expected);
});
});
describe('should not modify content without speaker tag', () => {
@@ -74,13 +86,9 @@ describe('cleanSpeakerTag', () => {
const input1 = '<speaker name="Agent">\nContent';
expect(cleanSpeakerTag(input1)).toBe(input1);
// Missing name attribute
// No attributes at all
const input2 = '<speaker />\nContent';
expect(cleanSpeakerTag(input2)).toBe(input2);
// Wrong attribute name
const input3 = '<speaker title="Agent" />\nContent';
expect(cleanSpeakerTag(input3)).toBe(input3);
});
it('should handle content that is only the speaker tag', () => {
+7 -5
View File
@@ -1,12 +1,14 @@
/**
* Regex to match speaker tag at the beginning of content
* Format: <speaker name="Agent Name" />
*
* This tag is injected by GroupMessageSenderProcessor to identify message senders
* in group chat scenarios. Models may accidentally reproduce this tag in their output,
* so we need to filter it out during streaming.
* Two formats exist:
* 1. Group chat: <speaker name="Agent Name" />
* 2. IM bot: <speaker id="..." username="..." nickname="..." />
*
* These tags are injected to identify message senders. Models may accidentally
* reproduce them in output, and they should be stripped for UI display.
*/
const SPEAKER_TAG_REGEX = /^<speaker\s+name="[^"]*"\s*\/>\n?/;
const SPEAKER_TAG_REGEX = /^<speaker\s+\S[^>]*\/>\n?/;
/**
* Remove speaker tag from the beginning of assistant message content.
+1
View File
@@ -38,6 +38,7 @@ export enum GroupSettingsTabs {
export enum SettingsTabs {
About = 'about',
Advanced = 'advanced',
Agent = 'agent',
APIKey = 'apikey',
Beta = 'beta',