From 3f9c23e7b49a98e6c3158b1ed88393a0553889ed Mon Sep 17 00:00:00 2001 From: Rdmclin2 Date: Sun, 8 Mar 2026 19:18:06 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20support=20lark=20and=20feis?= =?UTF-8?q?hu=20bot=20(#12712)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: support lark and feishu * chore: change integration to channel * chore: rename from integration to channel * fix: channel router * feat: add topic list channel provider icon * chore: update webhook url * chore: channel form refact * chore: update i18n keys to channel * chore: update form item description * style: hide required mark * feat: add lark chat adapter * chore: clean speaker tag & add username api adapter * chore: adjust topic channel icon * chore: move developer mode to advanced setting * chore: add lark icon * fix: detail style * fix: token check logic * fix: encrpted risk * fix: vercel function appId * chore: remove webhook mode for discord * chore: add doc link * chore: add channel docs * chore: remove unused import * fix: create bot with wrong platform * chore: update intergration to channel * fix: udpate variable import * fix: tsgo error * chore: optimize webhook url trim * chore: update copy text * fix: telegram webhook not set * chore: add persist logic * docs: update feishu doc * chore: update feishu and lark tenant * chore: update docs * chore: make verfication code required * chore: update feishu docs * chore: update verfication comment * chore: update docs permission list * chore: verificationToken optional * chore: update feishu and lark color * chore: use test id --- docs/usage/channels/discord.mdx | 125 +++++ docs/usage/channels/discord.zh-CN.mdx | 124 +++++ docs/usage/channels/feishu.mdx | 185 +++++++ docs/usage/channels/feishu.zh-CN.mdx | 177 +++++++ docs/usage/channels/overview.mdx | 60 +++ docs/usage/channels/overview.zh-CN.mdx | 57 +++ docs/usage/channels/telegram.mdx | 97 ++++ docs/usage/channels/telegram.zh-CN.mdx | 95 ++++ .../community/become-a-creator.zh-CN.mdx | 3 +- docs/usage/start.mdx | 7 +- docs/usage/start.zh-CN.mdx | 4 +- docs/usage/user-interface/appearance.mdx | 3 +- .../usage/user-interface/appearance.zh-CN.mdx | 3 +- docs/usage/user-interface/command-menu.mdx | 3 +- .../user-interface/command-menu.zh-CN.mdx | 3 +- docs/usage/user-interface/shortcuts.mdx | 3 +- docs/usage/user-interface/shortcuts.zh-CN.mdx | 3 +- docs/usage/user-interface/stats.mdx | 3 +- docs/usage/user-interface/stats.zh-CN.mdx | 3 +- .../steps/agent/conversation-mgmt.steps.ts | 43 +- locales/en-US/agent.json | 85 ++-- locales/en-US/chat.json | 2 +- locales/en-US/setting.json | 1 + locales/en-US/topic.json | 2 + locales/zh-CN/agent.json | 87 ++-- locales/zh-CN/chat.json | 2 +- locales/zh-CN/setting.json | 1 + locales/zh-CN/topic.json | 2 + package.json | 1 + packages/adapter-lark/package.json | 26 + packages/adapter-lark/src/adapter.ts | 460 ++++++++++++++++++ packages/adapter-lark/src/api.ts | 198 ++++++++ packages/adapter-lark/src/crypto.ts | 16 + packages/adapter-lark/src/format-converter.ts | 31 ++ packages/adapter-lark/src/index.ts | 15 + packages/adapter-lark/src/types.ts | 96 ++++ packages/adapter-lark/tsconfig.json | 21 + packages/adapter-lark/tsup.config.ts | 8 + .../database/src/models/agentBotProvider.ts | 2 +- .../webhooks/[platform]/[[...appId]]/route.ts | 30 ++ .../webhooks/[platform]/[appId]/route.ts | 27 - .../api/agent/webhooks/[platform]/route.ts | 26 - .../api/agent/webhooks/bot-callback/route.ts | 41 +- .../User/components/MessageContent.tsx | 6 +- src/locales/default/agent.ts | 91 ++-- src/locales/default/chat.ts | 2 +- src/locales/default/setting.ts | 1 + src/locales/default/topic.ts | 2 + .../agent/_layout/Sidebar/Header/Nav.tsx | 8 +- .../Sidebar/Topic/AllTopicsDrawer/Content.tsx | 1 + .../_layout/Sidebar/Topic/List/Item/index.tsx | 39 +- .../Topic/List/Item/useDropdownMenu.tsx | 23 +- .../TopicListContent/ByTimeMode/GroupItem.tsx | 7 +- .../Topic/TopicListContent/FlatMode/index.tsx | 1 + .../TopicListContent/SearchResult/index.tsx | 1 + src/routes/(main)/agent/channel/const.ts | 97 ++++ .../(main)/agent/channel/detail/Body.tsx | 248 ++++++++++ .../detail}/index.tsx | 49 +- .../channel/detail/platforms/discord.tsx | 43 ++ .../agent/channel/detail/platforms/feishu.tsx | 50 ++ .../agent/channel/detail/platforms/lark.tsx | 50 ++ .../channel/detail/platforms/telegram.tsx | 46 ++ src/routes/(main)/agent/channel/icons.tsx | 34 ++ .../agent/{integration => channel}/index.tsx | 18 +- .../PlatformList.tsx => channel/list.tsx} | 12 +- .../agent/integration/PlatformDetail/Body.tsx | 364 -------------- .../integration/PlatformDetail/Header.tsx | 98 ---- src/routes/(main)/agent/integration/const.ts | 56 --- .../_layout/Sidebar/Topic/List/Item/index.tsx | 18 +- .../TopicListContent/ByTimeMode/GroupItem.tsx | 6 +- src/routes/(main)/settings/advanced/index.tsx | 60 +++ .../common/features/Common/Common.tsx | 8 - .../(main)/settings/features/componentMap.ts | 3 + .../(main)/settings/hooks/useCategory.tsx | 6 + src/server/services/bot/BotMessageRouter.ts | 70 ++- src/server/services/bot/larkRestApi.ts | 135 +++++ src/server/services/bot/platforms/discord.ts | 4 +- src/server/services/bot/platforms/index.ts | 3 + src/server/services/bot/platforms/lark.ts | 53 ++ src/server/services/bot/platforms/telegram.ts | 2 +- src/server/services/bot/replyTemplate.ts | 10 +- src/server/services/bot/types.ts | 5 +- src/server/services/gateway/GatewayManager.ts | 1 + src/server/services/gateway/index.ts | 22 +- src/spa/router/desktopRouter.config.tsx | 6 +- src/store/chat/utils/cleanSpeakerTag.test.ts | 18 +- src/store/chat/utils/cleanSpeakerTag.ts | 12 +- src/store/global/initialState.ts | 1 + 88 files changed, 3104 insertions(+), 867 deletions(-) create mode 100644 docs/usage/channels/discord.mdx create mode 100644 docs/usage/channels/discord.zh-CN.mdx create mode 100644 docs/usage/channels/feishu.mdx create mode 100644 docs/usage/channels/feishu.zh-CN.mdx create mode 100644 docs/usage/channels/overview.mdx create mode 100644 docs/usage/channels/overview.zh-CN.mdx create mode 100644 docs/usage/channels/telegram.mdx create mode 100644 docs/usage/channels/telegram.zh-CN.mdx create mode 100644 packages/adapter-lark/package.json create mode 100644 packages/adapter-lark/src/adapter.ts create mode 100644 packages/adapter-lark/src/api.ts create mode 100644 packages/adapter-lark/src/crypto.ts create mode 100644 packages/adapter-lark/src/format-converter.ts create mode 100644 packages/adapter-lark/src/index.ts create mode 100644 packages/adapter-lark/src/types.ts create mode 100644 packages/adapter-lark/tsconfig.json create mode 100644 packages/adapter-lark/tsup.config.ts create mode 100644 src/app/(backend)/api/agent/webhooks/[platform]/[[...appId]]/route.ts delete mode 100644 src/app/(backend)/api/agent/webhooks/[platform]/[appId]/route.ts delete mode 100644 src/app/(backend)/api/agent/webhooks/[platform]/route.ts create mode 100644 src/routes/(main)/agent/channel/const.ts create mode 100644 src/routes/(main)/agent/channel/detail/Body.tsx rename src/routes/(main)/agent/{integration/PlatformDetail => channel/detail}/index.tsx (81%) create mode 100644 src/routes/(main)/agent/channel/detail/platforms/discord.tsx create mode 100644 src/routes/(main)/agent/channel/detail/platforms/feishu.tsx create mode 100644 src/routes/(main)/agent/channel/detail/platforms/lark.tsx create mode 100644 src/routes/(main)/agent/channel/detail/platforms/telegram.tsx create mode 100644 src/routes/(main)/agent/channel/icons.tsx rename src/routes/(main)/agent/{integration => channel}/index.tsx (75%) rename src/routes/(main)/agent/{integration/PlatformList.tsx => channel/list.tsx} (89%) delete mode 100644 src/routes/(main)/agent/integration/PlatformDetail/Body.tsx delete mode 100644 src/routes/(main)/agent/integration/PlatformDetail/Header.tsx delete mode 100644 src/routes/(main)/agent/integration/const.ts create mode 100644 src/routes/(main)/settings/advanced/index.tsx create mode 100644 src/server/services/bot/larkRestApi.ts create mode 100644 src/server/services/bot/platforms/lark.ts diff --git a/docs/usage/channels/discord.mdx b/docs/usage/channels/discord.mdx new file mode 100644 index 0000000000..c0124ed505 --- /dev/null +++ b/docs/usage/channels/discord.mdx @@ -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 + + + 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**. + + +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 + + + ### 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. + + +## Step 2: Configure Discord in LobeHub + + + ### 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. + + +## Step 3: Invite the Bot to Your Server + + + ### 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**. + + +## 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. diff --git a/docs/usage/channels/discord.zh-CN.mdx b/docs/usage/channels/discord.zh-CN.mdx new file mode 100644 index 0000000000..9a103ef525 --- /dev/null +++ b/docs/usage/channels/discord.zh-CN.mdx @@ -0,0 +1,124 @@ +--- +title: 将 LobeHub 连接到 Discord +description: >- + 了解如何创建一个 Discord 机器人并将其连接到您的 LobeHub 代理作为消息渠道,使您的 AI 助手能够直接在 Discord + 服务器和私信中与用户互动。 +tags: + - Discord + - 消息渠道 + - 机器人设置 + - 集成 +--- + +# 将 LobeHub 连接到 Discord + + + 此功能目前正在开发中,可能尚未完全稳定。您可以通过在 **设置** → **高级设置** → **开发者模式** 中启用 **开发者模式** 来使用此功能。 + + +通过将 Discord 渠道连接到您的 LobeHub 代理,用户可以直接通过 Discord 服务器频道和私信与 AI 助手互动。 + +## 前置条件 + +- 一个拥有有效订阅的 LobeHub 账户 +- 一个拥有目标服务器 **管理服务器** 权限的 Discord 账户 + +## 第一步:创建 Discord 应用程序和机器人 + + + ### 访问 Discord 开发者门户 + + 访问 [Discord 开发者门户](https://discord.com/developers/applications),点击 **新建应用程序**。为您的应用程序命名(例如,“LobeHub 助手”),然后点击 **创建**。 + + ### 创建机器人 + + 在左侧菜单中,点击 **机器人**。根据需要自定义机器人的用户名和头像。 + + ### 启用特权网关意图 + + 在机器人设置页面,向下滚动到 **特权网关意图** 并启用以下选项: + + - **消息内容意图** — 允许机器人读取消息内容(必需) + - **服务器成员意图** — 推荐启用,用于用户识别 + - **在线状态意图** — 可选;如果希望机器人访问用户的在线 / 离线状态,请启用 + + 点击 **保存更改**。 + + ### 复制机器人令牌 + + 在 **机器人** 页面,点击 **重置令牌** 以生成您的机器人令牌。复制并安全保存该令牌。 + + > **重要提示:** 请将您的机器人令牌视为密码。切勿公开分享或提交到版本控制系统。 + + ### 复制应用程序 ID 和公钥 + + 在左侧菜单中,转到 **常规信息**。复制并保存以下内容: + + - **应用程序 ID** + - **公钥** + + 您将在下一步中需要这三个值(机器人令牌、应用程序 ID、公钥)。 + + +## 第二步:在 LobeHub 中配置 Discord + + + ### 打开渠道设置 + + 在 LobeHub 中,导航到您的代理设置,然后选择 **渠道** 标签。点击平台列表中的 **Discord**。 + + ### 填写凭据 + + 输入以下字段: + + - **应用程序 ID** — 来自 Discord 应用程序常规信息页面的应用程序 ID + - **机器人令牌** — 您之前生成的机器人令牌 + - **公钥** — 来自 Discord 应用程序的公钥,用于交互验证 + + 您的令牌将被加密并安全存储。 + + ### 保存配置 + + 点击 **保存配置**。您的凭据将被保存,LobeHub 将开始监听 Discord 事件。 + + +## 第三步:邀请机器人加入您的服务器 + + + ### 生成邀请链接 + + 在 Discord 开发者门户中,转到 **OAuth2** → **URL 生成器**。选择以下范围: + + - `bot` + - `applications.commands` + + 在 **机器人权限** 下选择: + + - 查看频道 + - 发送消息 + - 读取消息历史 + - 嵌入链接 + - 附加文件 + - 添加反应(可选) + + ### 授权机器人 + + 复制生成的链接,在浏览器中打开,选择您希望添加机器人的服务器,然后点击 **授权**。 + + +## 第四步:测试连接 + +返回 LobeHub 的 Discord 渠道设置,点击 **测试连接** 以验证配置是否正确。然后在 Discord 中向您的机器人发送消息,确认其是否响应。 + +## 配置参考 + +| 字段 | 是否必需 | 描述 | +| ----------- | ---- | -------------------- | +| **应用程序 ID** | 是 | 您的 Discord 应用程序的 ID | +| **机器人令牌** | 是 | 您的 Discord 机器人的认证令牌 | +| **公钥** | 是 | 用于验证来自 Discord 的交互请求 | + +## 故障排除 + +- **机器人未在服务器中响应:** 确认机器人已被邀请到服务器并拥有正确的权限,同时启用了消息内容意图。 +- **测试连接失败:** 仔细检查应用程序 ID、机器人令牌和公钥是否正确。 diff --git a/docs/usage/channels/feishu.mdx b/docs/usage/channels/feishu.mdx new file mode 100644 index 0000000000..8f5f248bc3 --- /dev/null +++ b/docs/usage/channels/feishu.mdx @@ -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 + + + 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**. + + +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 + + + ### 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. + + +## Step 2: Configure App Permissions and Bot + + + ### 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" + ] + } + } + ``` + + + 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. + + + ### Enable Bot Capability + + Go to **App Capability** → **Bot**. Toggle the bot capability on and set your preferred bot name. + + +## Step 3: Configure Feishu / Lark in LobeHub + + + ### 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. + + +## Step 4: Set Up Event Subscription in Feishu / Lark + + + ### 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. + + +## Step 5: Publish the App + + + ### 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. + + +## 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. diff --git a/docs/usage/channels/feishu.zh-CN.mdx b/docs/usage/channels/feishu.zh-CN.mdx new file mode 100644 index 0000000000..8c0d188643 --- /dev/null +++ b/docs/usage/channels/feishu.zh-CN.mdx @@ -0,0 +1,177 @@ +--- +title: 将 LobeHub 连接到飞书 / Lark +description: 了解如何创建飞书(Lark)自定义应用并将其连接到您的 LobeHub 代理作为消息渠道,使您的 AI 助手能够在飞书或 Lark 聊天中与团队成员互动。 +tags: + - 飞书 + - Lark + - 消息渠道 + - 机器人设置 + - 集成 +--- + +# 将 LobeHub 连接到飞书 / Lark + + + 此功能目前正在开发中,可能尚未完全稳定。您可以通过在 **设置** → **高级设置** → **开发者模式** + 中启用 **开发者模式** 来使用此功能。 + + +通过将飞书(或 Lark)渠道连接到您的 LobeHub 代理,团队成员可以直接在飞书的私聊和群组对话中与 AI 助手互动。 + +> 飞书是中国版本,Lark 是国际版本。设置过程完全相同 —— 只需使用对应的平台门户即可。 + +## 前置条件 + +- 一个拥有有效订阅的 LobeHub 账户 +- 一个拥有创建企业应用权限的飞书或 Lark 账户 + +## 第一步:创建飞书 / Lark 应用 + + + ### 打开开发者门户 + + - **飞书:** 访问 [open.feishu.cn/app](https://open.feishu.cn/app) + - **Lark:** 访问 [open.larksuite.com/app](https://open.larksuite.com/app) + + 使用您的账户登录。 + + ### 创建企业应用 + + 点击 **创建企业应用**。填写应用名称(例如 "LobeHub 助手")、描述和图标,然后提交表单。 + + ### 复制应用凭证 + + 进入 **凭证与基本信息**,复制以下内容: + + - **应用 ID**(格式:`cli_xxx`) + - **应用密钥** + + > **重要提示:** 请妥善保管您的应用密钥。切勿公开分享。 + + +## 第二步:配置应用权限和机器人功能 + + + ### 导入所需权限 + + 在您的应用设置中,进入 **权限与范围**,点击 **批量导入**,然后粘贴以下 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" + ] + } + } + ``` + + + 以上 JSON 适用于**飞书**。如果您使用的是 **Lark(国际版)**,部分权限码可能不可用(如 `aily:*`、`corehr:*`、`im:chat.access_event.bot_p2p_chat:read`)。请移除批量导入时提示无效的权限码。 + + + ### 启用机器人功能 + + 进入 **应用能力** → **机器人**。开启机器人功能并设置您喜欢的机器人名称。 + + +## 第三步:在 LobeHub 中配置飞书 / Lark + + + ### 打开渠道设置 + + 在 LobeHub 中,导航到您的代理设置,然后选择 **渠道** 标签。点击平台列表中的 **飞书** 或 **Lark**。 + + ### 填写应用凭证 + + 输入以下字段: + + - **应用 ID** — 来自飞书 / Lark 应用的应用 ID + - **应用密钥** — 来自飞书 / Lark 应用的应用密钥 + - **Verification Token** — 用于验证 webhook 事件是否来自飞书 / Lark + + 您还可以选择配置以下内容: + + - **Encrypt Key** — 用于解密飞书 / Lark 的加密事件负载 + + > Verification Token 和 Encrypt Key 可以在飞书 / Lark 开发者门户的 **事件订阅** → **加密策略** 中找到(位于页面顶部)。如果您还没有打开过事件订阅页面,可以在完成第四步后再回来填写 Verification Token。 + + ### 保存并复制 Webhook URL + + 点击 **保存配置**。保存后,将显示一个 **事件订阅 URL**。复制此 URL—— 您将在下一步中需要它。 + + +## 第四步:在飞书 / Lark 中设置事件订阅 + + + ### 打开事件订阅设置 + + 返回飞书 / Lark 开发者门户中的应用。导航到 **事件订阅**。 + + ### 配置请求 URL + + 将您从 LobeHub 复制的 **事件订阅 URL** 粘贴到 **请求 URL** 字段中。平台会自动验证端点。 + + ### 添加消息事件 + + 添加以下事件: + + - `im.message.receive_v1` — 当收到消息时触发 + + 这将使您的应用能够接收消息并将其转发到 LobeHub。 + + +## 第五步:发布应用 + + + ### 创建版本 + + 在您的应用设置中,进入 **版本管理与发布**。创建一个新版本并填写发布说明。 + + ### 提交审核 + + 提交版本进行审核并发布。对于企业自管理应用,通常会自动批准。 + + +## 第六步:测试连接 + +回到 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"(而不是 "飞书")。 diff --git a/docs/usage/channels/overview.mdx b/docs/usage/channels/overview.mdx new file mode 100644 index 0000000000..2453a51966 --- /dev/null +++ b/docs/usage/channels/overview.mdx @@ -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 + + + 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**. + + +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 | diff --git a/docs/usage/channels/overview.zh-CN.mdx b/docs/usage/channels/overview.zh-CN.mdx new file mode 100644 index 0000000000..e5f63521c5 --- /dev/null +++ b/docs/usage/channels/overview.zh-CN.mdx @@ -0,0 +1,57 @@ +--- +title: 渠道概览 +description: 将 LobeHub 代理连接到外部消息平台,如 Discord、Telegram 和飞书/Lark,让用户可以直接在他们喜欢的聊天应用中与 AI 助手互动。 +tags: + - 渠道 + - 消息渠道 + - 集成 + - Discord + - Telegram + - 飞书 + - Lark +--- + +# 渠道 + + + 此功能目前正在开发中,可能尚未完全稳定。您可以通过在 **设置** → **高级设置** → **开发者模式** 中启用 **开发者模式** 来开启此功能。 + + +渠道功能允许您将 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 | +| --------- | ------- | -------- | --------- | +| 文本消息 | 是 | 是 | 是 | +| 私人消息 | 是 | 是 | 是 | +| 群组聊天 | 是 | 是 | 是 | +| 表情反应 | 是 | 是 | 部分支持 | +| 图片 / 文件附件 | 是 | 是 | 是 | diff --git a/docs/usage/channels/telegram.mdx b/docs/usage/channels/telegram.mdx new file mode 100644 index 0000000000..c7d5580b9b --- /dev/null +++ b/docs/usage/channels/telegram.mdx @@ -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 + + + 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**. + + +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 + + + ### 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. + + +## Step 2: Configure Telegram in LobeHub + + + ### 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. + + +## 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. diff --git a/docs/usage/channels/telegram.zh-CN.mdx b/docs/usage/channels/telegram.zh-CN.mdx new file mode 100644 index 0000000000..ae2aea0b0b --- /dev/null +++ b/docs/usage/channels/telegram.zh-CN.mdx @@ -0,0 +1,95 @@ +--- +title: 将 LobeHub 连接到 Telegram +description: >- + 学习如何创建一个 Telegram 机器人并将其连接到 LobeHub 代理作为消息渠道,使您的 AI 助手能够在 Telegram + 私聊和群组对话中与用户互动。 +tags: + - Telegram + - 消息渠道 + - 机器人设置 + - 集成 +--- + +# 将 LobeHub 连接到 Telegram + + + 此功能目前正在开发中,可能尚未完全稳定。您可以通过在 **设置** → **高级设置** → **开发者模式** 中启用 **开发者模式** 来使用此功能。 + + +通过将 Telegram 渠道连接到您的 LobeHub 代理,用户可以通过 Telegram 私聊和群组对话与 AI 助手互动。 + +## 前置条件 + +- 一个拥有有效订阅的 LobeHub 账户 +- 一个 Telegram 账户 + +## 第一步:创建 Telegram 机器人 + + + ### 打开 BotFather + + 打开 Telegram 并搜索 **@BotFather** —— 这是用于管理机器人的官方 Telegram 机器人。开始对话并发送 `/newbot` 命令。 + + ### 设置机器人名称和用户名 + + BotFather 会要求您: + + 1. 为您的机器人选择一个 **显示名称**(例如,“LobeHub 助手”) + 2. 选择一个 **用户名** —— 必须以 `bot` 结尾(例如,`lobehub_assistant_bot`) + + ### 复制机器人令牌 + + 创建机器人后,BotFather 会发送给您一个 **API 令牌**(格式:`123456789:ABCdefGhIjKlmNoPQRsTuVwXyZ`)。复制并保存此令牌。 + + > **重要提示:** 您的机器人令牌是一个机密凭证,请勿公开分享。 + + +## 第二步:在 LobeHub 中配置 Telegram + + + ### 打开渠道设置 + + 在 LobeHub 中,导航到您的代理设置,然后选择 **渠道** 标签页。从平台列表中点击 **Telegram**。 + + ### 输入机器人令牌 + + 将您从 BotFather 收到的机器人令牌粘贴到 **机器人令牌** 字段中。 + + **机器人用户 ID** 将根据您的令牌自动生成,无需手动输入。 + + ### 可选:设置 Webhook 密钥 + + 您可以选择输入一个 **Webhook 密钥令牌** 以增加安全性。此密钥用于验证来自 Telegram 的入站 Webhook 请求。 + + ### 保存配置 + + 点击 **保存配置**。LobeHub 将自动向 Telegram 注册 Webhook URL,无需手动复制 URL。 + + 您的令牌将被加密并安全存储。 + + +## 第三步:测试连接 + +在 LobeHub 的渠道设置中点击 **测试连接** 以验证集成。然后打开 Telegram,搜索您的机器人用户名并发送消息。机器人应通过您的 LobeHub 代理进行响应。 + +## 将机器人添加到群组聊天 + +要在 Telegram 群组中使用机器人: + +1. 将机器人添加为群组成员 +2. 默认情况下,机器人在被 `@your_bot_username` 提及时会响应 +3. 发送一条提及机器人的消息以开始互动 + +## 配置参考 + +| 字段 | 是否必需 | 描述 | +| ---------------- | ---- | --------------------- | +| **机器人令牌** | 是 | 来自 BotFather 的 API 令牌 | +| **机器人用户 ID** | 自动 | 根据机器人令牌自动生成 | +| **Webhook 密钥令牌** | 否 | 用于验证 Webhook 请求的可选密钥 | + +## 故障排除 + +- **机器人未响应:** 验证机器人令牌是否正确并确保配置已保存。点击 **测试连接** 进行诊断。 +- **Webhook 注册失败:** 确保您的 LobeHub 订阅处于活动状态。Telegram 要求 Webhook 使用 HTTPS 端点,LobeHub 会自动提供。 +- **群组聊天问题:** 确保机器人已被添加到群组并具有读取消息的权限。使用 `@username` 提及机器人以触发响应。 diff --git a/docs/usage/community/become-a-creator.zh-CN.mdx b/docs/usage/community/become-a-creator.zh-CN.mdx index a2625dce7c..22a9271e36 100644 --- a/docs/usage/community/become-a-creator.zh-CN.mdx +++ b/docs/usage/community/become-a-creator.zh-CN.mdx @@ -1,7 +1,6 @@ --- title: 社区创作者 -description: >- - 加入 LobeHub 社区成为创作者——发布助理、分享工作流,打造帮助千万用户提效的工具。 +description: 加入 LobeHub 社区成为创作者——发布助理、分享工作流,打造帮助千万用户提效的工具。 tags: - LobeHub - 社区创作者 diff --git a/docs/usage/start.mdx b/docs/usage/start.mdx index 912e941b07..d15b631d6b 100644 --- a/docs/usage/start.mdx +++ b/docs/usage/start.mdx @@ -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 diff --git a/docs/usage/start.zh-CN.mdx b/docs/usage/start.zh-CN.mdx index e9da126a14..5f009af929 100644 --- a/docs/usage/start.zh-CN.mdx +++ b/docs/usage/start.zh-CN.mdx @@ -1,8 +1,8 @@ --- title: 简介 description: >- - LobeHub 是下一代 Agent harness,旨在让 AI 能力大众化。超越一次性、以任务为驱动的工具,构建能随着您一起成长的长期 - Agent 队友,加入全球最大的人与 Agent 共生网络。 + LobeHub 是下一代 Agent harness,旨在让 AI 能力大众化。超越一次性、以任务为驱动的工具,构建能随着您一起成长的长期 Agent + 队友,加入全球最大的人与 Agent 共生网络。 tags: - LobeHub - 入门指南 diff --git a/docs/usage/user-interface/appearance.mdx b/docs/usage/user-interface/appearance.mdx index da74810a45..59a494b499 100644 --- a/docs/usage/user-interface/appearance.mdx +++ b/docs/usage/user-interface/appearance.mdx @@ -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 diff --git a/docs/usage/user-interface/appearance.zh-CN.mdx b/docs/usage/user-interface/appearance.zh-CN.mdx index dffb3dca8a..4ec7f8e5f7 100644 --- a/docs/usage/user-interface/appearance.zh-CN.mdx +++ b/docs/usage/user-interface/appearance.zh-CN.mdx @@ -1,7 +1,6 @@ --- title: 界面外观 -description: >- - 自定义 LobeHub 的外观——主题、色彩、语言、代码高亮与 Mermaid 图表。打造属于你的界面。 +description: 自定义 LobeHub 的外观——主题、色彩、语言、代码高亮与 Mermaid 图表。打造属于你的界面。 tags: - LobeHub - 外观 diff --git a/docs/usage/user-interface/command-menu.mdx b/docs/usage/user-interface/command-menu.mdx index be29d92b6a..b2c738b1d9 100644 --- a/docs/usage/user-interface/command-menu.mdx +++ b/docs/usage/user-interface/command-menu.mdx @@ -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 diff --git a/docs/usage/user-interface/command-menu.zh-CN.mdx b/docs/usage/user-interface/command-menu.zh-CN.mdx index 34bb76e132..164e56352a 100644 --- a/docs/usage/user-interface/command-menu.zh-CN.mdx +++ b/docs/usage/user-interface/command-menu.zh-CN.mdx @@ -1,7 +1,6 @@ --- title: 命令菜单 -description: >- - LobeHub 的快捷操作中心——搜索助理、话题、设置,用几个按键跳转到任意位置。 +description: LobeHub 的快捷操作中心——搜索助理、话题、设置,用几个按键跳转到任意位置。 tags: - LobeHub - 命令菜单 diff --git a/docs/usage/user-interface/shortcuts.mdx b/docs/usage/user-interface/shortcuts.mdx index 1e0baa268f..8be5387b93 100644 --- a/docs/usage/user-interface/shortcuts.mdx +++ b/docs/usage/user-interface/shortcuts.mdx @@ -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 diff --git a/docs/usage/user-interface/shortcuts.zh-CN.mdx b/docs/usage/user-interface/shortcuts.zh-CN.mdx index 6e65b6368d..9b00aa2c07 100644 --- a/docs/usage/user-interface/shortcuts.zh-CN.mdx +++ b/docs/usage/user-interface/shortcuts.zh-CN.mdx @@ -1,7 +1,6 @@ --- title: 快捷键 -description: >- - 用快捷键掌控 LobeHub——命令面板、助理切换、专注模式等。按你的习惯自定义。 +description: 用快捷键掌控 LobeHub——命令面板、助理切换、专注模式等。按你的习惯自定义。 tags: - LobeHub - 快捷键 diff --git a/docs/usage/user-interface/stats.mdx b/docs/usage/user-interface/stats.mdx index 7623da72ab..dc56b070cf 100644 --- a/docs/usage/user-interface/stats.mdx +++ b/docs/usage/user-interface/stats.mdx @@ -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 diff --git a/docs/usage/user-interface/stats.zh-CN.mdx b/docs/usage/user-interface/stats.zh-CN.mdx index 2ee0a462ae..96fc53eadb 100644 --- a/docs/usage/user-interface/stats.zh-CN.mdx +++ b/docs/usage/user-interface/stats.zh-CN.mdx @@ -1,7 +1,6 @@ --- title: 数据统计 -description: >- - 追踪你的 LobeHub 使用情况——活跃天数、助理、对话、模型使用。可视化你的使用模式,并分享统计结果。 +description: 追踪你的 LobeHub 使用情况——活跃天数、助理、对话、模型使用。可视化你的使用模式,并分享统计结果。 tags: - LobeHub - 数据统计 diff --git a/e2e/src/steps/agent/conversation-mgmt.steps.ts b/e2e/src/steps/agent/conversation-mgmt.steps.ts index 347053f00c..d7aa86c035 100644 --- a/e2e/src/steps/agent/conversation-mgmt.steps.ts +++ b/e2e/src/steps/agent/conversation-mgmt.steps.ts @@ -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) { diff --git a/locales/en-US/agent.json b/locales/en-US/agent.json index 3e50eaf2db..42d288f3a0 100644 --- a/locales/en-US/agent.json +++ b/locales/en-US/agent.json @@ -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 \"Interactions Endpoint URL\" 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 {{fieldName}} 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" } diff --git a/locales/en-US/chat.json b/locales/en-US/chat.json index b2ad3d0488..0589f524f9 100644 --- a/locales/en-US/chat.json +++ b/locales/en-US/chat.json @@ -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...", diff --git a/locales/en-US/setting.json b/locales/en-US/setting.json index ddfdc03d4a..c23466ec7f 100644 --- a/locales/en-US/setting.json +++ b/locales/en-US/setting.json @@ -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", diff --git a/locales/en-US/topic.json b/locales/en-US/topic.json index f3c9c11333..7ce44730f4 100644 --- a/locales/en-US/topic.json +++ b/locales/en-US/topic.json @@ -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...", diff --git a/locales/zh-CN/agent.json b/locales/zh-CN/agent.json index a826cbed8c..e294542d34 100644 --- a/locales/zh-CN/agent.json +++ b/locales/zh-CN/agent.json @@ -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}} 开发者门户的 \"Interactions Endpoint URL\" 字段中。", - "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}} 开发者门户的 {{fieldName}} 字段中。", + "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" } diff --git a/locales/zh-CN/chat.json b/locales/zh-CN/chat.json index d570287599..97c42d8a95 100644 --- a/locales/zh-CN/chat.json +++ b/locales/zh-CN/chat.json @@ -349,7 +349,7 @@ "supervisor.todoList.allComplete": "所有任务已完成", "supervisor.todoList.title": "任务完成", "tab.groupProfile": "群组档案", - "tab.integration": "集成", + "tab.integration": "消息频道", "tab.profile": "助理档案", "tab.search": "搜索", "task.activity.calling": "正在调用技能…", diff --git a/locales/zh-CN/setting.json b/locales/zh-CN/setting.json index b53692f635..494667186e 100644 --- a/locales/zh-CN/setting.json +++ b/locales/zh-CN/setting.json @@ -692,6 +692,7 @@ "tab.addCustomMcp": "添加自定义 MCP 技能", "tab.addCustomMcp.desc": "手动配置自定义 MCP 服务器", "tab.addCustomSkill": "添加", + "tab.advanced": "高级设置", "tab.agent": "助理服务", "tab.all": "全部", "tab.apikey": "API Key 管理", diff --git a/locales/zh-CN/topic.json b/locales/zh-CN/topic.json index a792be3722..5e0b12b414 100644 --- a/locales/zh-CN/topic.json +++ b/locales/zh-CN/topic.json @@ -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": "话题复制中…", diff --git a/package.json b/package.json index d264f02874..f6864bce39 100644 --- a/package.json +++ b/package.json @@ -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:*", diff --git a/packages/adapter-lark/package.json b/packages/adapter-lark/package.json new file mode 100644 index 0000000000..96f321b9e9 --- /dev/null +++ b/packages/adapter-lark/package.json @@ -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" + } +} diff --git a/packages/adapter-lark/src/adapter.ts b/packages/adapter-lark/src/adapter.ts new file mode 100644 index 0000000000..417b29f658 --- /dev/null +++ b/packages/adapter-lark/src/adapter.ts @@ -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 { + 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(); + 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 { + 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 { + 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> { + 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> { + 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 { + await this.api.deleteMessage(messageId); + } + + async fetchMessages( + threadId: string, + options?: FetchOptions, + ): Promise> { + 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 { + 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 { + 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 { + 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 { + // Lark's remove reaction requires a reaction ID, which we don't track. + // No-op for now. + } + + // ------------------------------------------------------------------ + // Typing + // ------------------------------------------------------------------ + + async startTyping(_threadId: string): Promise { + // 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> { + 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 { + // 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); +} diff --git a/packages/adapter-lark/src/api.ts b/packages/adapter-lark/src/api.ts new file mode 100644 index 0000000000..1e23251637 --- /dev/null +++ b/packages/adapter-lark/src/api.ts @@ -0,0 +1,198 @@ +const BASE_URLS: Record = { + 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 { + await this.call('DELETE', `/im/v1/messages/${messageId}`, {}); + } + + async getMessage(messageId: string): Promise { + 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 { + await this.call('POST', `/im/v1/messages/${messageId}/reactions`, { + reaction_type: { emoji_type: emojiType }, + }); + } + + async removeReaction(messageId: string, reactionId: string): Promise { + await this.call('DELETE', `/im/v1/messages/${messageId}/reactions/${reactionId}`, {}); + } + + // ------------------------------------------------------------------ + // Chat info + // ------------------------------------------------------------------ + + async getChatInfo(chatId: string): Promise { + const data = await this.call('GET', `/im/v1/chats/${chatId}`, {}); + return data.data; + } + + async getBotInfo(): Promise { + 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 { + 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): Promise { + 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; + } +} diff --git a/packages/adapter-lark/src/crypto.ts b/packages/adapter-lark/src/crypto.ts new file mode 100644 index 0000000000..1295a70c9b --- /dev/null +++ b/packages/adapter-lark/src/crypto.ts @@ -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; +} diff --git a/packages/adapter-lark/src/format-converter.ts b/packages/adapter-lark/src/format-converter.ts new file mode 100644 index 0000000000..085a0d9265 --- /dev/null +++ b/packages/adapter-lark/src/format-converter.ts @@ -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); + } +} diff --git a/packages/adapter-lark/src/index.ts b/packages/adapter-lark/src/index.ts new file mode 100644 index 0000000000..1385ad7e67 --- /dev/null +++ b/packages/adapter-lark/src/index.ts @@ -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'; diff --git a/packages/adapter-lark/src/types.ts b/packages/adapter-lark/src/types.ts new file mode 100644 index 0000000000..e635b893ce --- /dev/null +++ b/packages/adapter-lark/src/types.ts @@ -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; diff --git a/packages/adapter-lark/tsconfig.json b/packages/adapter-lark/tsconfig.json new file mode 100644 index 0000000000..b988fa8e24 --- /dev/null +++ b/packages/adapter-lark/tsconfig.json @@ -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"] +} diff --git a/packages/adapter-lark/tsup.config.ts b/packages/adapter-lark/tsup.config.ts new file mode 100644 index 0000000000..d4c69d1df6 --- /dev/null +++ b/packages/adapter-lark/tsup.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + dts: true, + entry: ['src/index.ts'], + format: ['esm'], + sourcemap: true, +}); diff --git a/packages/database/src/models/agentBotProvider.ts b/packages/database/src/models/agentBotProvider.ts index c9cd087869..ae0679a4f6 100644 --- a/packages/database/src/models/agentBotProvider.ts +++ b/packages/database/src/models/agentBotProvider.ts @@ -172,7 +172,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 { diff --git a/src/app/(backend)/api/agent/webhooks/[platform]/[[...appId]]/route.ts b/src/app/(backend)/api/agent/webhooks/[platform]/[[...appId]]/route.ts new file mode 100644 index 0000000000..d21f075b99 --- /dev/null +++ b/src/app/(backend)/api/agent/webhooks/[platform]/[[...appId]]/route.ts @@ -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 => { + 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); +}; diff --git a/src/app/(backend)/api/agent/webhooks/[platform]/[appId]/route.ts b/src/app/(backend)/api/agent/webhooks/[platform]/[appId]/route.ts deleted file mode 100644 index ed394dd1e2..0000000000 --- a/src/app/(backend)/api/agent/webhooks/[platform]/[appId]/route.ts +++ /dev/null @@ -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 => { - 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); -}; diff --git a/src/app/(backend)/api/agent/webhooks/[platform]/route.ts b/src/app/(backend)/api/agent/webhooks/[platform]/route.ts deleted file mode 100644 index 90324f2c45..0000000000 --- a/src/app/(backend)/api/agent/webhooks/[platform]/route.ts +++ /dev/null @@ -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 => { - 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); -}; diff --git a/src/app/(backend)/api/agent/webhooks/bot-callback/route.ts b/src/app/(backend)/api/agent/webhooks/bot-callback/route.ts index 8062c96020..5890d4ec8c 100644 --- a/src/app/(backend)/api/agent/webhooks/bot-callback/route.ts +++ b/src/app/(backend)/api/agent/webhooks/bot-callback/route.ts @@ -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. * @@ -178,10 +198,11 @@ export async function POST(request: Request): Promise { 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 { 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; diff --git a/src/features/Conversation/Messages/User/components/MessageContent.tsx b/src/features/Conversation/Messages/User/components/MessageContent.tsx index 7b86bb4682..3f427dbb63 100644 --- a/src/features/Conversation/Messages/User/components/MessageContent.tsx +++ b/src/features/Conversation/Messages/User/components/MessageContent.tsx @@ -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( ({ id, content, imageList, videoList, fileList, metadata }) => { const markdownProps = useMarkdown(id); const pageSelections = metadata?.pageSelections; + const displayContent = useMemo(() => (content ? cleanSpeakerTag(content) : content), [content]); return ( {pageSelections && pageSelections.length > 0 && ( )} - {content && {content}} + {displayContent && {displayContent}} {imageList && imageList?.length > 0 && } {videoList && videoList?.length > 0 && } {fileList && fileList?.length > 0 && } diff --git a/src/locales/default/agent.ts b/src/locales/default/agent.ts index 69a3f59180..720ade049d 100644 --- a/src/locales/default/agent.ts +++ b/src/locales/default/agent.ts @@ -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 "Interactions Endpoint URL" 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 {{fieldName}} 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; diff --git a/src/locales/default/chat.ts b/src/locales/default/chat.ts index 8f160636f5..9338b9fc99 100644 --- a/src/locales/default/chat.ts +++ b/src/locales/default/chat.ts @@ -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...', diff --git a/src/locales/default/setting.ts b/src/locales/default/setting.ts index 8882eac77d..afbee88afc 100644 --- a/src/locales/default/setting.ts +++ b/src/locales/default/setting.ts @@ -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', diff --git a/src/locales/default/topic.ts b/src/locales/default/topic.ts index 0e643297e0..00ac28a367 100644 --- a/src/locales/default/topic.ts +++ b/src/locales/default/topic.ts @@ -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', diff --git a/src/routes/(main)/agent/_layout/Sidebar/Header/Nav.tsx b/src/routes/(main)/agent/_layout/Sidebar/Header/Nav.tsx index cee0604261..364fc7566b 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Header/Nav.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Header/Nav.tsx @@ -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 && ( { switchTopic(null, { skipRefreshMessage: true }); - router.push(urlJoin('/agent', agentId!, 'integration')); + router.push(urlJoin('/agent', agentId!, 'channel')); }} /> )} diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/AllTopicsDrawer/Content.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/AllTopicsDrawer/Content.tsx index b10b8037e1..f943366f66 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/AllTopicsDrawer/Content.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/AllTopicsDrawer/Content.tsx @@ -164,6 +164,7 @@ const Content = memo(({ open, searchKeyword }) => { active={activeTopicId === topic.id} fav={topic.favorite} id={topic.id} + metadata={topic.metadata} threadId={activeThreadId} title={topic.title} /> diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx index a92e0e872d..f13ba37d9b 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx @@ -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(({ id, title, fav, active, threadId }) => { +const TopicItem = memo(({ 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(({ 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(({ id, title, fav, active, threadId }) => }, [id, activeAgentId, addTab, navigateToTopic]); const dropdownMenu = useTopicItemDropdownMenu({ + fav, id, toggleEditing, }); @@ -193,7 +195,7 @@ const TopicItem = memo(({ id, title, fav, active, threadId }) => } return ( - + } active={active && !threadId && !isInAgentSubRoute} @@ -202,19 +204,18 @@ const TopicItem = memo(({ id, title, fav, active, threadId }) => href={href} loading={isLoading} title={title} - icon={ - { - 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 ; + } + } + return ( + + ); + })()} slots={{ iconPostfix: unreadNode, }} diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx index 061f5b25cf..74c8e037f9 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx @@ -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: , + key: 'favorite', + label: fav ? t('actions.unfavorite') : t('actions.favorite'), + onClick: () => { + favoriteTopic(id, !fav); + }, + }, + { + type: 'divider' as const, + }, { icon: , key: 'autoRename', @@ -82,9 +96,6 @@ export const useTopicItemDropdownMenu = ({ }, ] : []), - { - type: 'divider' as const, - }, { icon: , key: 'duplicate', @@ -115,9 +126,11 @@ export const useTopicItemDropdownMenu = ({ ].filter(Boolean) as MenuProps['items']; }, [ id, + fav, activeAgentId, autoRenameTopicTitle, duplicateTopic, + favoriteTopic, removeTopic, openTopicInNewWindow, addTab, diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByTimeMode/GroupItem.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByTimeMode/GroupItem.tsx index 90ec7fa81a..4d49e6c60d 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByTimeMode/GroupItem.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByTimeMode/GroupItem.tsx @@ -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(({ group, activeTopicId, activeThreadId } paddingInline={'8px 4px'} title={ - {title || timeTitle} @@ -44,6 +42,7 @@ const GroupItem = memo(({ group, activeTopicId, activeThreadId } fav={topic.favorite} id={topic.id} key={topic.id} + metadata={topic.metadata} threadId={activeThreadId} title={topic.title} /> diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/FlatMode/index.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/FlatMode/index.tsx index 9576dfa5b1..211062696f 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/FlatMode/index.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/FlatMode/index.tsx @@ -41,6 +41,7 @@ const FlatMode = memo(() => { fav={topic.favorite} id={topic.id} key={topic.id} + metadata={topic.metadata} threadId={activeThreadId} title={topic.title} /> diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/SearchResult/index.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/SearchResult/index.tsx index 3d06b33a71..2f7deab808 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/SearchResult/index.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/SearchResult/index.tsx @@ -36,6 +36,7 @@ const SearchResult = memo(() => { fav={topic.favorite} id={topic.id} key={topic.id} + metadata={topic.metadata} title={topic.title} /> ))} diff --git a/src/routes/(main)/agent/channel/const.ts b/src/routes/(main)/agent/channel/const.ts new file mode 100644 index 0000000000..6222f7931d --- /dev/null +++ b/src/routes/(main)/agent/channel/const.ts @@ -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 | 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', + }, +]; diff --git a/src/routes/(main)/agent/channel/detail/Body.tsx b/src/routes/(main)/agent/channel/detail/Body.tsx new file mode 100644 index 0000000000..d7b180e15f --- /dev/null +++ b/src/routes/(main)/agent/channel/detail/Body.tsx @@ -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; + 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( + ({ + 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 = ( + + + + + {provider.name} + + ); + + const headerExtra = currentConfig ? ( + + ) : undefined; + + const group: FormGroupItemType = { + children: configItems, + defaultActive: true, + extra: headerExtra, + title: headerTitle, + }; + + return ( + <> +
+ +
+
+ {hasConfig ? ( + + ) : ( +
+ )} + + {hasConfig && ( + + )} + + +
+ + {saveResult && ( + + )} + + {testResult && ( + + )} + + {hasConfig && provider.webhookMode !== 'auto' && ( + + + {t('channel.endpointUrl')} + {provider.fieldTags.webhook && {provider.fieldTags.webhook}} + + +
{webhookUrl}
+ +
+ }} + i18nKey="channel.endpointUrlHint" + ns="agent" + values={{ fieldName: provider.fieldTags.webhook, name: provider.name }} + /> + } + /> +
+ )} +
+ + ); + }, +); + +export default Body; diff --git a/src/routes/(main)/agent/integration/PlatformDetail/index.tsx b/src/routes/(main)/agent/channel/detail/index.tsx similarity index 81% rename from src/routes/(main)/agent/integration/PlatformDetail/index.tsx rename to src/routes/(main)/agent/channel/detail/index.tsx index 67fef12e99..0c58b5ba0d 100644 --- a/src/routes/(main)/agent/integration/PlatformDetail/index.tsx +++ b/src/routes/(main)/agent/channel/detail/index.tsx @@ -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(({ provider, agentId, currentConfig }) => { const { t } = useTranslation('agent'); const { message: msg, modal } = App.useApp(); - const [form] = Form.useForm(); + const [form] = Form.useForm(); const [createBotProvider, deleteBotProvider, updateBotProvider, connectBot] = useAgentStore( (s) => [s.createBotProvider, s.deleteBotProvider, s.updateBotProvider, s.connectBot], @@ -75,9 +78,12 @@ const PlatformDetail = memo(({ 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(({ provider, agentId, currentCo } // Build platform-specific credentials - const credentials: Record = { botToken: values.botToken }; + const credentials: Record = + 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(({ provider, agentId, currentCo agentId, provider.id, provider.autoAppId, + provider.authMode, provider.fieldTags, + provider.webhookMode, form, currentConfig, createBotProvider, @@ -153,13 +171,13 @@ const PlatformDetail = memo(({ 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(({ 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(({ 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(({ provider, agentId, currentCo return (
-
(({ 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} />
); diff --git a/src/routes/(main)/agent/channel/detail/platforms/discord.tsx b/src/routes/(main)/agent/channel/detail/platforms/discord.tsx new file mode 100644 index 0000000000..ac65f77574 --- /dev/null +++ b/src/routes/(main)/agent/channel/detail/platforms/discord.tsx @@ -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: , + desc: t('channel.applicationIdHint'), + label: t('channel.applicationId'), + name: 'applicationId', + rules: [{ required: true }], + tag: provider.fieldTags.appId, + }, + { + children: ( + + ), + desc: t('channel.botTokenEncryptedHint'), + label: t('channel.botToken'), + name: 'botToken', + rules: [{ required: true }], + tag: provider.fieldTags.token, + }, + { + children: , + desc: t('channel.publicKeyHint'), + label: t('channel.publicKey'), + name: 'publicKey', + tag: provider.fieldTags.publicKey, + }, +]; diff --git a/src/routes/(main)/agent/channel/detail/platforms/feishu.tsx b/src/routes/(main)/agent/channel/detail/platforms/feishu.tsx new file mode 100644 index 0000000000..3eed6981ab --- /dev/null +++ b/src/routes/(main)/agent/channel/detail/platforms/feishu.tsx @@ -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: , + desc: t('channel.applicationIdHint'), + label: t('channel.applicationId'), + name: 'applicationId', + rules: [{ required: true }], + tag: provider.fieldTags.appId, + }, + { + children: ( + + ), + desc: t('channel.botTokenEncryptedHint'), + label: t('channel.appSecret'), + name: 'appSecret', + rules: [{ required: true }], + tag: provider.fieldTags.appSecret, + }, + { + children: , + desc: t('channel.verificationTokenHint'), + label: t('channel.verificationToken'), + name: 'verificationToken', + tag: provider.fieldTags.verificationToken, + }, + { + children: , + desc: t('channel.encryptKeyHint'), + label: t('channel.encryptKey'), + name: 'encryptKey', + tag: provider.fieldTags.encryptKey, + }, +]; diff --git a/src/routes/(main)/agent/channel/detail/platforms/lark.tsx b/src/routes/(main)/agent/channel/detail/platforms/lark.tsx new file mode 100644 index 0000000000..e88417eab4 --- /dev/null +++ b/src/routes/(main)/agent/channel/detail/platforms/lark.tsx @@ -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: , + desc: t('channel.applicationIdHint'), + label: t('channel.applicationId'), + name: 'applicationId', + rules: [{ required: true }], + tag: provider.fieldTags.appId, + }, + { + children: ( + + ), + desc: t('channel.botTokenEncryptedHint'), + label: t('channel.appSecret'), + name: 'appSecret', + rules: [{ required: true }], + tag: provider.fieldTags.appSecret, + }, + { + children: , + desc: t('channel.verificationTokenHint'), + label: t('channel.verificationToken'), + name: 'verificationToken', + tag: provider.fieldTags.verificationToken, + }, + { + children: , + desc: t('channel.encryptKeyHint'), + label: t('channel.encryptKey'), + name: 'encryptKey', + tag: provider.fieldTags.encryptKey, + }, +]; diff --git a/src/routes/(main)/agent/channel/detail/platforms/telegram.tsx b/src/routes/(main)/agent/channel/detail/platforms/telegram.tsx new file mode 100644 index 0000000000..0ae41decf4 --- /dev/null +++ b/src/routes/(main)/agent/channel/detail/platforms/telegram.tsx @@ -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: ( + + ), + desc: t('channel.botTokenEncryptedHint'), + label: t('channel.botToken'), + name: 'botToken', + rules: [{ required: true }], + tag: provider.fieldTags.token, + }, + { + children: , + desc: t('channel.secretTokenHint'), + label: t('channel.secretToken'), + name: 'secretToken', + tag: provider.fieldTags.secretToken, + }, + ...(process.env.NODE_ENV === 'development' + ? ([ + { + children: , + desc: t('channel.devWebhookProxyUrlHint'), + label: t('channel.devWebhookProxyUrl'), + name: 'webhookProxyUrl', + rules: [{ type: 'url' as const }], + }, + ] as FormItemProps[]) + : []), +]; diff --git a/src/routes/(main)/agent/channel/icons.tsx b/src/routes/(main)/agent/channel/icons.tsx new file mode 100644 index 0000000000..a423b16b74 --- /dev/null +++ b/src/routes/(main)/agent/channel/icons.tsx @@ -0,0 +1,34 @@ +import { type SVGProps } from 'react'; + +interface IconProps extends SVGProps { + 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 }) => ( + + + + + +); diff --git a/src/routes/(main)/agent/integration/index.tsx b/src/routes/(main)/agent/channel/index.tsx similarity index 75% rename from src/routes/(main)/agent/integration/index.tsx rename to src/routes/(main)/agent/channel/index.tsx index e0703f47f6..ced9403040 100644 --- a/src/routes/(main)/agent/integration/index.tsx +++ b/src/routes/(main)/agent/channel/index.tsx @@ -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(() => { - {isLoading && } + {isLoading && } {!isLoading && (
@@ -69,4 +69,4 @@ const IntegrationPage = memo(() => { ); }); -export default IntegrationPage; +export default ChannelPage; diff --git a/src/routes/(main)/agent/integration/PlatformList.tsx b/src/routes/(main)/agent/channel/list.tsx similarity index 89% rename from src/routes/(main)/agent/integration/PlatformList.tsx rename to src/routes/(main)/agent/channel/list.tsx index 85eb7f4afd..e413f2dec7 100644 --- a/src/routes/(main)/agent/integration/PlatformList.tsx +++ b/src/routes/(main)/agent/channel/list.tsx @@ -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; onSelect: (id: string) => void; - providers: IntegrationProvider[]; + providers: ChannelProvider[]; } const PlatformList = memo( @@ -89,7 +89,7 @@ const PlatformList = memo( return ( diff --git a/src/routes/(main)/agent/integration/PlatformDetail/Body.tsx b/src/routes/(main)/agent/integration/PlatformDetail/Body.tsx deleted file mode 100644 index 71f0ace516..0000000000 --- a/src/routes/(main)/agent/integration/PlatformDetail/Body.tsx +++ /dev/null @@ -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; - hasConfig: boolean; - onCopied: () => void; - onDelete: () => void; - onSave: () => void; - onTestConnection: () => void; - provider: IntegrationProvider; - saveResult?: TestResult; - saving: boolean; - testing: boolean; - testResult?: TestResult; -} - -const Body = memo( - ({ - provider, - form, - hasConfig, - saveResult, - saving, - testing, - testResult, - onSave, - onDelete, - onTestConnection, - onCopied, - }) => { - const { t } = useTranslation('agent'); - const origin = useAppOrigin(); - - return ( - -
- {/* Connection Config */} -
-
{t('integration.connectionConfig')}
- - {!provider.autoAppId && ( -
-
-
- {t('integration.applicationId')} - {provider.fieldTags.appId && {provider.fieldTags.appId}} -
-
- - - -
- )} - -
-
-
- {t('integration.botToken')} - {provider.fieldTags.token && {provider.fieldTags.token}} -
- - {t('integration.botTokenHowToGet')} - -
- - - - - {t('integration.botTokenEncryptedHint')} - -
- - {provider.fieldTags.publicKey && ( -
-
-
- {t('integration.publicKey')} - {provider.fieldTags.publicKey} -
-
- - - -
- )} - - {provider.fieldTags.secretToken && ( -
-
-
- {t('integration.secretToken')} - {provider.fieldTags.secretToken} -
-
- - - - - {t('integration.secretTokenHint')} - -
- )} - {/* Dev-only: HTTPS tunnel URL for Telegram webhook */} - {provider.webhookMode === 'auto' && process.env.NODE_ENV === 'development' && ( -
-
-
{t('integration.devWebhookProxyUrl')}
-
- - - - - {t('integration.devWebhookProxyUrlHint')} - -
- )} -
- - {/* Action Bar */} -
- {hasConfig ? ( - - ) : ( -
- )} - - - {hasConfig && ( - - )} - - -
- - {saveResult && ( - - )} - - {testResult && ( - - )} - - {/* Endpoint URL - platform-specific rendering */} - {hasConfig && provider.webhookMode !== 'auto' && ( -
-
-
- {t('integration.endpointUrl')} - {provider.fieldTags.webhook && {provider.fieldTags.webhook}} -
-
- -
- {`${origin}/api/agent/webhooks/${provider.id}`} -
- -
- }} - i18nKey="integration.endpointUrlHint" - ns="agent" - values={{ name: provider.name }} - /> - } - /> -
- )} -
- - ); - }, -); - -export default Body; diff --git a/src/routes/(main)/agent/integration/PlatformDetail/Header.tsx b/src/routes/(main)/agent/integration/PlatformDetail/Header.tsx deleted file mode 100644 index 59facca5a1..0000000000 --- a/src/routes/(main)/agent/integration/PlatformDetail/Header.tsx +++ /dev/null @@ -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(({ provider, currentConfig, onToggleEnable }) => { - const { t } = useTranslation('agent'); - const ProviderIcon = provider.icon; - - return ( -
-
- -
- -
-
- - - {provider.name} - - - {provider.description} - - -
-
- - {currentConfig && ( - - - {currentConfig.enabled ? t('integration.enabled') : t('integration.disabled')} - - - - )} -
-
- ); -}); - -export default Header; diff --git a/src/routes/(main)/agent/integration/const.ts b/src/routes/(main)/agent/integration/const.ts deleted file mode 100644 index cab6a9a07f..0000000000 --- a/src/routes/(main)/agent/integration/const.ts +++ /dev/null @@ -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 | 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', - }, -]; diff --git a/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/index.tsx b/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/index.tsx index 2936f3900f..3f207fdc34 100644 --- a/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/index.tsx +++ b/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/index.tsx @@ -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(({ 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(({ id, title, fav, active, threadId }) => loading={isLoading} title={title} icon={ - { - e.preventDefault(); - e.stopPropagation(); - favoriteTopic(id, !fav); - }} - /> + } onClick={handleClick} onDoubleClick={handleDoubleClick} diff --git a/src/routes/(main)/group/_layout/Sidebar/Topic/TopicListContent/ByTimeMode/GroupItem.tsx b/src/routes/(main)/group/_layout/Sidebar/Topic/TopicListContent/ByTimeMode/GroupItem.tsx index 90ec7fa81a..76a350dfc8 100644 --- a/src/routes/(main)/group/_layout/Sidebar/Topic/TopicListContent/ByTimeMode/GroupItem.tsx +++ b/src/routes/(main)/group/_layout/Sidebar/Topic/TopicListContent/ByTimeMode/GroupItem.tsx @@ -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(({ group, activeTopicId, activeThreadId } paddingInline={'8px 4px'} title={ - {title || timeTitle} diff --git a/src/routes/(main)/settings/advanced/index.tsx b/src/routes/(main)/settings/advanced/index.tsx new file mode 100644 index 0000000000..2d932aca81 --- /dev/null +++ b/src/routes/(main)/settings/advanced/index.tsx @@ -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 ; + + const advancedGroup: FormGroupItemType = { + children: [ + { + children: , + desc: t('settingCommon.devMode.desc'), + label: t('settingCommon.devMode.title'), + minWidth: undefined, + name: 'isDevMode', + valuePropName: 'checked', + }, + ], + extra: loading && , + title: t('tab.advanced'), + }; + + return ( + <> + +
{ + setLoading(true); + await setSettings({ general: v }); + setLoading(false); + }} + {...FORM_STYLE} + /> + + ); +}); + +export default Page; diff --git a/src/routes/(main)/settings/common/features/Common/Common.tsx b/src/routes/(main)/settings/common/features/Common/Common.tsx index 6683e05573..77d5c352e2 100644 --- a/src/routes/(main)/settings/common/features/Common/Common.tsx +++ b/src/routes/(main)/settings/common/features/Common/Common.tsx @@ -171,14 +171,6 @@ const Common = memo(() => { name: 'isLiteMode', valuePropName: 'checked', }, - { - children: , - desc: t('settingCommon.devMode.desc'), - label: t('settingCommon.devMode.title'), - minWidth: undefined, - name: 'isDevMode', - valuePropName: 'checked', - }, ], extra: loading && , title: t('settingCommon.title'), diff --git a/src/routes/(main)/settings/features/componentMap.ts b/src/routes/(main)/settings/features/componentMap.ts index 6d70ea5df9..4bd67c652e 100644 --- a/src/routes/(main)/settings/features/componentMap.ts +++ b/src/routes/(main)/settings/features/componentMap.ts @@ -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'), }), diff --git a/src/routes/(main)/settings/hooks/useCategory.tsx b/src/routes/(main)/settings/hooks/useCategory.tsx index acaf8bda55..8b6b36184b 100644 --- a/src/routes/(main)/settings/hooks/useCategory.tsx +++ b/src/routes/(main)/settings/hooks/useCategory.tsx @@ -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, diff --git a/src/server/services/bot/BotMessageRouter.ts b/src/server/services/bot/BotMessageRouter.ts index 4fb74dadab..f76780a5ca 100644 --- a/src/server/services/bot/BotMessageRouter.ts +++ b/src/server/services/bot/BotMessageRouter.ts @@ -1,6 +1,7 @@ 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'; @@ -51,6 +52,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; } @@ -95,6 +108,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 }); } @@ -241,6 +258,46 @@ export class BotMessageRouter { 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 { + log('handleChatSdkWebhook: platform=%s, appId=%s', platform, appId); + + const bodyBuffer = await req.arrayBuffer(); + + // Direct lookup by applicationId + if (appId) { + const key = `${platform}:${appId}`; + const bot = this.botInstances.get(key); + if (bot?.webhooks && platform in bot.webhooks) { + return (bot.webhooks as any)[platform](this.cloneRequest(req, bodyBuffer)); + } + log('handleChatSdkWebhook: no bot registered for %s', key); + return new Response(`No bot configured for ${platform}`, { status: 404 }); + } + + // Fallback: try all registered bots for this platform + for (const [key, bot] of this.botInstances) { + if (!key.startsWith(`${platform}:`)) continue; + if (bot.webhooks && platform in bot.webhooks) { + try { + const resp = await (bot.webhooks as any)[platform](this.cloneRequest(req, bodyBuffer)); + if (resp.status !== 401) return resp; + } catch { + // try next + } + } + } + + return new Response(`No bot configured for ${platform}`, { status: 404 }); + } + private cloneRequest(req: Request, body: ArrayBuffer): Request { return new Request(req.url, { body, @@ -294,7 +351,7 @@ export class BotMessageRouter { const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey(); // Load all supported platforms - for (const platform of ['discord', 'telegram']) { + for (const platform of ['discord', 'telegram', 'lark', 'feishu']) { const providers = await AgentBotProviderModel.findEnabledByPlatform( serverDB, platform, @@ -307,7 +364,7 @@ export class BotMessageRouter { const { agentId, userId, applicationId, credentials } = provider; const key = `${platform}:${applicationId}`; - if (this.botInstances.has(key)) { + if (this.agentMap.has(key)) { log('Skipping provider %s: already registered', key); continue; } @@ -422,16 +479,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, diff --git a/src/server/services/bot/larkRestApi.ts b/src/server/services/bot/larkRestApi.ts new file mode 100644 index 0000000000..2897454c52 --- /dev/null +++ b/src/server/services/bot/larkRestApi.ts @@ -0,0 +1,135 @@ +import debug from 'debug'; + +const log = debug('lobe-server:bot:lark-rest'); + +const BASE_URLS: Record = { + 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 { + 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 { + 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): Promise { + 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; + } +} diff --git a/src/server/services/bot/platforms/discord.ts b/src/server/services/bot/platforms/discord.ts index 115051d79a..5a911b2a42 100644 --- a/src/server/services/bot/platforms/discord.ts +++ b/src/server/services/bot/platforms/discord.ts @@ -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) => 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 }, diff --git a/src/server/services/bot/platforms/index.ts b/src/server/services/bot/platforms/index.ts index ed49fa8a55..f73ef94af7 100644 --- a/src/server/services/bot/platforms/index.ts +++ b/src/server/services/bot/platforms/index.ts @@ -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 = { discord: Discord, + feishu: Lark, + lark: Lark, telegram: Telegram, }; diff --git a/src/server/services/bot/platforms/lark.ts b/src/server/services/bot/platforms/lark.ts new file mode 100644 index 0000000000..329623596d --- /dev/null +++ b/src/server/services/bot/platforms/lark.ts @@ -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 { + 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 { + log('Stopping LarkBot appId=%s', this.applicationId); + // No cleanup needed — webhook is managed in Lark Developer Console + } +} diff --git a/src/server/services/bot/platforms/telegram.ts b/src/server/services/bot/platforms/telegram.ts index b4dd79a704..332f3126f0 100644 --- a/src/server/services/bot/platforms/telegram.ts +++ b/src/server/services/bot/platforms/telegram.ts @@ -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); diff --git a/src/server/services/bot/replyTemplate.ts b/src/server/services/bot/replyTemplate.ts index 4de142b203..5cbd2e87b7 100644 --- a/src/server/services/bot/replyTemplate.ts +++ b/src/server/services/bot/replyTemplate.ts @@ -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}`; } diff --git a/src/server/services/bot/types.ts b/src/server/services/bot/types.ts index 2672e39465..fc46b37ad6 100644 --- a/src/server/services/bot/types.ts +++ b/src/server/services/bot/types.ts @@ -5,4 +5,7 @@ export interface PlatformBot { stop: () => Promise; } -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; +}; diff --git a/src/server/services/gateway/GatewayManager.ts b/src/server/services/gateway/GatewayManager.ts index fb069f4285..5cbbd2fa20 100644 --- a/src/server/services/gateway/GatewayManager.ts +++ b/src/server/services/gateway/GatewayManager.ts @@ -190,6 +190,7 @@ export class GatewayManager { return new BotClass({ ...provider.credentials, applicationId: provider.applicationId, + platform, }); } } diff --git a/src/server/services/gateway/index.ts b/src/server/services/gateway/index.ts index c24e887e78..30c59d9503 100644 --- a/src/server/services/gateway/index.ts +++ b/src/server/services/gateway/index.ts @@ -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(); diff --git a/src/spa/router/desktopRouter.config.tsx b/src/spa/router/desktopRouter.config.tsx index f265061072..84d23a0c97 100644 --- a/src/spa/router/desktopRouter.config.tsx +++ b/src/spa/router/desktopRouter.config.tsx @@ -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( diff --git a/src/store/chat/utils/cleanSpeakerTag.test.ts b/src/store/chat/utils/cleanSpeakerTag.test.ts index 8f69fb9a58..41dd71f039 100644 --- a/src/store/chat/utils/cleanSpeakerTag.test.ts +++ b/src/store/chat/utils/cleanSpeakerTag.test.ts @@ -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 = '\nhello'; + const expected = 'hello'; + expect(cleanSpeakerTag(input)).toBe(expected); + }); + + it('should remove IM bot speaker tag with avatar', () => { + const input = '\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 = '\nContent'; expect(cleanSpeakerTag(input1)).toBe(input1); - // Missing name attribute + // No attributes at all const input2 = '\nContent'; expect(cleanSpeakerTag(input2)).toBe(input2); - - // Wrong attribute name - const input3 = '\nContent'; - expect(cleanSpeakerTag(input3)).toBe(input3); }); it('should handle content that is only the speaker tag', () => { diff --git a/src/store/chat/utils/cleanSpeakerTag.ts b/src/store/chat/utils/cleanSpeakerTag.ts index 26de0e55bc..1a00bd2271 100644 --- a/src/store/chat/utils/cleanSpeakerTag.ts +++ b/src/store/chat/utils/cleanSpeakerTag.ts @@ -1,12 +1,14 @@ /** * Regex to match speaker tag at the beginning of content - * Format: * - * 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: + * 2. IM bot: + * + * 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 = /^\n?/; +const SPEAKER_TAG_REGEX = /^]*\/>\n?/; /** * Remove speaker tag from the beginning of assistant message content. diff --git a/src/store/global/initialState.ts b/src/store/global/initialState.ts index 4d4a1bcbb2..89249731aa 100644 --- a/src/store/global/initialState.ts +++ b/src/store/global/initialState.ts @@ -38,6 +38,7 @@ export enum GroupSettingsTabs { export enum SettingsTabs { About = 'about', + Advanced = 'advanced', Agent = 'agent', APIKey = 'apikey', Beta = 'beta',