mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2d55e3433 | |||
| 9f0ae47eab | |||
| c59552757c | |||
| 647065402c | |||
| c67ef5f8d6 | |||
| 9760d0d458 | |||
| 1db7487afd | |||
| 7cc55dd1b7 | |||
| b80fa29bcd | |||
| 94f71160b8 | |||
| ca699bc58b | |||
| 5c6c3c9d10 | |||
| 20c2b2d75a | |||
| 5ad0c77264 | |||
| f887110b8c | |||
| 0f458a4504 | |||
| d3b9e9ddc2 | |||
| 79e25b458b | |||
| c35d606e7c | |||
| fdbfa71635 | |||
| 807cfd49a2 | |||
| dd653f936e | |||
| 2f5be753c2 | |||
| c0097ca036 | |||
| a9f1c8abce | |||
| 66ffbfda8b | |||
| c55a40c2ed | |||
| bd667b0677 | |||
| cb8b4d9f65 | |||
| 71e6fdacbc | |||
| 1935edae10 | |||
| d07587e61f | |||
| b1f565e62a | |||
| 28256b45bc | |||
| 42bc9edd63 | |||
| 4dc9863c31 | |||
| 5d315a1346 | |||
| fe525975c8 | |||
| 07f3928502 | |||
| 517a92b866 | |||
| 3287130eac | |||
| 456e7b8ed0 | |||
| b6587ec5d7 | |||
| e18437b2f9 | |||
| e4c90fc6f3 |
@@ -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.
|
||||
@@ -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、机器人令牌和公钥是否正确。
|
||||
@@ -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.
|
||||
@@ -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"(而不是 "飞书")。
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
| --------- | ------- | -------- | --------- |
|
||||
| 文本消息 | 是 | 是 | 是 |
|
||||
| 私人消息 | 是 | 是 | 是 |
|
||||
| 群组聊天 | 是 | 是 | 是 |
|
||||
| 表情反应 | 是 | 是 | 部分支持 |
|
||||
| 图片 / 文件附件 | 是 | 是 | 是 |
|
||||
@@ -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.
|
||||
@@ -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
|
||||
- 社区创作者
|
||||
|
||||
@@ -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 world’s largest human–agent 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 world’s largest human–agent co-evolving network.
|
||||
tags:
|
||||
- LobeHub
|
||||
- Getting Started
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
title: 简介
|
||||
description: >-
|
||||
LobeHub 是下一代 Agent harness,旨在让 AI 能力大众化。超越一次性、以任务为驱动的工具,构建能随着您一起成长的长期
|
||||
Agent 队友,加入全球最大的人与 Agent 共生网络。
|
||||
LobeHub 是下一代 Agent harness,旨在让 AI 能力大众化。超越一次性、以任务为驱动的工具,构建能随着您一起成长的长期 Agent
|
||||
队友,加入全球最大的人与 Agent 共生网络。
|
||||
tags:
|
||||
- LobeHub
|
||||
- 入门指南
|
||||
|
||||
@@ -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
|
||||
- 外观
|
||||
|
||||
@@ -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
|
||||
- 命令菜单
|
||||
|
||||
@@ -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
|
||||
- 快捷键
|
||||
|
||||
@@ -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,7 +1,6 @@
|
||||
---
|
||||
title: 数据统计
|
||||
description: >-
|
||||
追踪你的 LobeHub 使用情况——活跃天数、助理、对话、模型使用。可视化你的使用模式,并分享统计结果。
|
||||
description: 追踪你的 LobeHub 使用情况——活跃天数、助理、对话、模型使用。可视化你的使用模式,并分享统计结果。
|
||||
tags:
|
||||
- LobeHub
|
||||
- 数据统计
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -349,7 +349,7 @@
|
||||
"supervisor.todoList.allComplete": "所有任务已完成",
|
||||
"supervisor.todoList.title": "任务完成",
|
||||
"tab.groupProfile": "群组档案",
|
||||
"tab.integration": "集成",
|
||||
"tab.integration": "消息频道",
|
||||
"tab.profile": "助理档案",
|
||||
"tab.search": "搜索",
|
||||
"task.activity.calling": "正在调用技能…",
|
||||
|
||||
@@ -692,6 +692,7 @@
|
||||
"tab.addCustomMcp": "添加自定义 MCP 技能",
|
||||
"tab.addCustomMcp.desc": "手动配置自定义 MCP 服务器",
|
||||
"tab.addCustomSkill": "添加",
|
||||
"tab.advanced": "高级设置",
|
||||
"tab.agent": "助理服务",
|
||||
"tab.all": "全部",
|
||||
"tab.apikey": "API Key 管理",
|
||||
|
||||
@@ -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": "话题复制中…",
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"lib": ["ES2022"]
|
||||
},
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
dts: true,
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
sourcemap: true,
|
||||
});
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
+3
-4
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
+32
-17
@@ -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[])
|
||||
: []),
|
||||
];
|
||||
@@ -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 +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;
|
||||
+7
-5
@@ -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}
|
||||
|
||||
+2
-4
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -38,6 +38,7 @@ export enum GroupSettingsTabs {
|
||||
|
||||
export enum SettingsTabs {
|
||||
About = 'about',
|
||||
Advanced = 'advanced',
|
||||
Agent = 'agent',
|
||||
APIKey = 'apikey',
|
||||
Beta = 'beta',
|
||||
|
||||
Reference in New Issue
Block a user