🔨 chore: bot architecture upgrade (#13096)

* chore: bot architecture upgrade

* chore: unify schema definition

* chore: adjust channel schema

* feat: add setting render page

* chore: add i18n files

* chore: tag use field.key

* chore: add i18n files

* chore: add dev mode

* chore: refactor body to header and footer with body

* chore: add dev portal dev

* chore: add showWebhookUrl config

* chore: optimize form render

* feat: add slack channel

* chore: add new bot platform docs

* chore: unify applicationId to replace appId

* chore: add instrumentation file logger

* fix: gateway client error

* feat: support usageStats

* fix: bot settings pass and add  invalidate

* chore: update delete modal title and description

* chore: adjust save and connect button

* chore: support canEdit function

* fix: platform specific config

* fix: enable logic reconnect

* feat: add connection mode

* chore: start  gateway service in local dev env

* chore: default add a thread in channel when on mention at discord

* chore: add necessary permissions for slack

* feat: support charLimt and debounceMS

* chore: add schema maximum and minimum

* chore: adjust debounceMs and charLimit default value

* feat: support reset to default settings

* chore: hide reset when collapse

* fix: create discord bot lost app url

* fix: registry test case

* fix: lint error
This commit is contained in:
Rdmclin2 2026-03-20 20:34:48 +08:00 committed by GitHub
parent a64f4bf7ab
commit e18855aa25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
105 changed files with 5209 additions and 3495 deletions

View file

@ -9,8 +9,8 @@ const SUPPORTED_PLATFORMS = ['discord', 'slack', 'telegram', 'lark', 'feishu'];
const PLATFORM_CREDENTIAL_FIELDS: Record<string, string[]> = {
discord: ['botToken', 'publicKey'],
feishu: ['appId', 'appSecret'],
lark: ['appId', 'appSecret'],
feishu: ['appSecret'],
lark: ['appSecret'],
slack: ['botToken', 'signingSecret'],
telegram: ['botToken'],
};
@ -26,11 +26,6 @@ function parseCredentials(
if (options.signingSecret) creds.signingSecret = options.signingSecret;
if (options.appSecret) creds.appSecret = options.appSecret;
// For lark/feishu, --app-id maps to credentials.appId (distinct from --app-id as applicationId)
if ((platform === 'lark' || platform === 'feishu') && options.appId) {
creds.appId = options.appId;
}
return creds;
}

View file

@ -0,0 +1,428 @@
---
title: Adding a New Bot Platform
description: >-
Learn how to add a new bot platform (e.g., Slack, WhatsApp) to LobeHub's
channel system, including schema definition, client implementation, and
platform registration.
tags:
- Bot Platform
- Message Channels
- Integration
- Development Guide
---
# Adding a New Bot Platform
This guide walks through the steps to add a new bot platform to LobeHub's channel system. The platform architecture is modular — each platform is a self-contained directory under `src/server/services/bot/platforms/`.
## Architecture Overview
```
src/server/services/bot/platforms/
├── types.ts # Core interfaces (FieldSchema, PlatformClient, ClientFactory, etc.)
├── registry.ts # PlatformRegistry class
├── index.ts # Singleton registry + platform registration
├── utils.ts # Shared utilities
├── discord/ # Example: Discord platform
│ ├── definition.ts # PlatformDefinition export
│ ├── schema.ts # FieldSchema[] for credentials & settings
│ ├── client.ts # ClientFactory + PlatformClient implementation
│ └── api.ts # Platform API helper class
└── <your-platform>/ # Your new platform
```
**Key concepts:**
- **FieldSchema** — Declarative schema that drives both server-side validation and frontend form auto-generation
- **PlatformClient** — Runtime interface for interacting with the platform (messaging, lifecycle)
- **ClientFactory** — Creates PlatformClient instances and validates credentials
- **PlatformDefinition** — Metadata + schema + factory, registered in the global registry
- **Chat SDK Adapter** — Bridges the platform's webhook/events into the unified Chat SDK
## Prerequisite: Chat SDK Adapter
Each platform requires a **Chat SDK adapter** that bridges the platform's webhook events into the unified [Vercel Chat SDK](https://github.com/vercel/chat) (`chat` npm package). Before implementing the platform, determine which adapter to use:
### Option A: Use an existing npm adapter
Some platforms have official adapters published under `@chat-adapter/*`:
- `@chat-adapter/discord` — Discord
- `@chat-adapter/slack` — Slack
- `@chat-adapter/telegram` — Telegram
Check npm with `npm view @chat-adapter/<platform>` to see if one exists.
### Option B: Develop a custom adapter in `packages/`
If no npm adapter exists, you need to create one as a workspace package. Reference the existing implementations:
- `packages/chat-adapter-feishu` — Feishu/Lark adapter (`@lobechat/chat-adapter-feishu`)
- `packages/chat-adapter-qq` — QQ adapter (`@lobechat/chat-adapter-qq`)
Each adapter package follows this structure:
```
packages/chat-adapter-<platform>/
├── package.json # name: @lobechat/chat-adapter-<platform>
├── tsconfig.json
├── tsup.config.ts
└── src/
├── index.ts # Public exports: createXxxAdapter, XxxApiClient, etc.
├── adapter.ts # Adapter class implementing chat SDK's Adapter interface
├── api.ts # Platform API client (webhook verification, message parsing)
├── crypto.ts # Request signature verification
├── format-converter.ts # Message format conversion (platform format ↔ chat SDK AST)
└── types.ts # Platform-specific type definitions
```
Key points for developing a custom adapter:
- The adapter must implement the `Adapter` interface from the `chat` package
- It handles webhook request verification, event parsing, and message format conversion
- The `createXxxAdapter(config)` factory function is what `PlatformClient.createAdapter()` will call
- Add `"chat": "^4.14.0"` as a dependency in `package.json`
## Step 1: Create the Platform Directory
```bash
mkdir src/server/services/bot/platforms/<platform-name>
```
You will create four files:
| File | Purpose |
| --------------- | ------------------------------------------------- |
| `schema.ts` | Credential and settings field definitions |
| `api.ts` | Lightweight API client for outbound messaging |
| `client.ts` | `ClientFactory` + `PlatformClient` implementation |
| `definition.ts` | `PlatformDefinition` export |
## Step 2: Define the Schema (`schema.ts`)
The schema is an array of `FieldSchema` objects with two top-level sections: `credentials` and `settings`.
```ts
import type { FieldSchema } from '../types';
export const schema: FieldSchema[] = [
{
key: 'credentials',
label: 'channel.credentials',
properties: [
{
key: 'applicationId',
description: 'channel.applicationIdHint',
label: 'channel.applicationId',
required: true,
type: 'string',
},
{
key: 'botToken',
description: 'channel.botTokenEncryptedHint',
label: 'channel.botToken',
required: true,
type: 'password', // Encrypted in storage, masked in UI
},
],
type: 'object',
},
{
key: 'settings',
label: 'channel.settings',
properties: [
{
key: 'charLimit',
default: 4000,
description: 'channel.charLimitHint',
label: 'channel.charLimit',
minimum: 100,
type: 'number',
},
// Add platform-specific settings...
],
type: 'object',
},
];
```
**Schema conventions:**
- `type: 'password'` fields are encrypted at rest and masked in the form
- Use existing i18n keys (e.g., `channel.botToken`, `channel.charLimit`) for shared fields
- Use `channel.<platform>.<key>` for platform-specific i18n keys
- `devOnly: true` fields only appear when `NODE_ENV === 'development'`
- Credentials must include a field that resolves to `applicationId` — either an explicit `applicationId` field, an `appId` field, or a `botToken` from which the ID is derived (see `resolveApplicationId` in the channel detail page)
## Step 3: Create the API Client (`api.ts`)
A lightweight class for outbound messaging operations used by the callback service (outside the Chat SDK adapter):
```ts
import debug from 'debug';
const log = debug('bot-platform:<platform>:client');
export const API_BASE = 'https://api.example.com';
export class PlatformApi {
private readonly token: string;
constructor(token: string) {
this.token = token;
}
async sendMessage(channelId: string, text: string): Promise<{ id: string }> {
log('sendMessage: channel=%s', channelId);
return this.call('messages.send', { channel: channelId, text });
}
async editMessage(channelId: string, messageId: string, text: string): Promise<void> {
log('editMessage: channel=%s, message=%s', channelId, messageId);
await this.call('messages.update', { channel: channelId, id: messageId, text });
}
// ... other operations (typing indicator, reactions, etc.)
private async call(method: string, body: Record<string, unknown>): Promise<any> {
const response = await fetch(`${API_BASE}/${method}`, {
body: JSON.stringify(body),
headers: {
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json',
},
method: 'POST',
});
if (!response.ok) {
const text = await response.text();
log('API error: method=%s, status=%d, body=%s', method, response.status, text);
throw new Error(`API ${method} failed: ${response.status} ${text}`);
}
return response.json();
}
}
```
## Step 4: Implement the Client (`client.ts`)
Implement `PlatformClient` and extend `ClientFactory`:
```ts
import { createPlatformAdapter } from '@chat-adapter/<platform>';
import debug from 'debug';
import {
type BotPlatformRuntimeContext,
type BotProviderConfig,
ClientFactory,
type PlatformClient,
type PlatformMessenger,
type ValidationResult,
} from '../types';
import { PlatformApi } from './api';
const log = debug('bot-platform:<platform>:bot');
class MyPlatformClient implements PlatformClient {
readonly id = '<platform>';
readonly applicationId: string;
private config: BotProviderConfig;
private context: BotPlatformRuntimeContext;
constructor(config: BotProviderConfig, context: BotPlatformRuntimeContext) {
this.config = config;
this.context = context;
this.applicationId = config.applicationId;
}
// --- Lifecycle ---
async start(): Promise<void> {
// Register webhook or start listening
// For webhook platforms: configure the webhook URL with the platform API
// For gateway platforms: open a persistent connection
}
async stop(): Promise<void> {
// Cleanup: remove webhook registration or close connection
}
// --- Runtime Operations ---
createAdapter(): Record<string, any> {
// Return a Chat SDK adapter instance for inbound message handling
return {
'<platform>': createPlatformAdapter({
botToken: this.config.credentials.botToken,
// ... adapter-specific config
}),
};
}
getMessenger(platformThreadId: string): PlatformMessenger {
const api = new PlatformApi(this.config.credentials.botToken);
const channelId = platformThreadId.split(':')[1];
return {
createMessage: (content) => api.sendMessage(channelId, content).then(() => {}),
editMessage: (messageId, content) => api.editMessage(channelId, messageId, content),
removeReaction: (messageId, emoji) => api.removeReaction(channelId, messageId, emoji),
triggerTyping: () => Promise.resolve(),
};
}
extractChatId(platformThreadId: string): string {
return platformThreadId.split(':')[1];
}
parseMessageId(compositeId: string): string {
return compositeId;
}
// --- Optional methods ---
// sanitizeUserInput(text: string): string { ... }
// shouldSubscribe(threadId: string): boolean { ... }
// formatReply(body: string, stats?: UsageStats): string { ... }
}
export class MyPlatformClientFactory extends ClientFactory {
createClient(config: BotProviderConfig, context: BotPlatformRuntimeContext): PlatformClient {
return new MyPlatformClient(config, context);
}
async validateCredentials(credentials: Record<string, string>): Promise<ValidationResult> {
// Call the platform API to verify the credentials are valid
try {
const res = await fetch('https://api.example.com/auth.test', {
headers: { Authorization: `Bearer ${credentials.botToken}` },
method: 'POST',
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return { valid: true };
} catch {
return {
errors: [{ field: 'botToken', message: 'Failed to authenticate' }],
valid: false,
};
}
}
}
```
**Key interfaces to implement:**
| Method | Purpose |
| --------------------- | ----------------------------------------------------------- |
| `start()` | Register webhook or start gateway listener |
| `stop()` | Clean up resources on shutdown |
| `createAdapter()` | Return Chat SDK adapter for inbound event handling |
| `getMessenger()` | Return outbound messaging interface for a thread |
| `extractChatId()` | Parse platform channel ID from composite thread ID |
| `parseMessageId()` | Convert composite message ID to platform-native format |
| `sanitizeUserInput()` | *(Optional)* Strip bot mention artifacts from user input |
| `shouldSubscribe()` | *(Optional)* Control thread auto-subscription behavior |
| `formatReply()` | *(Optional)* Append platform-specific formatting to replies |
## Step 5: Export the Definition (`definition.ts`)
```ts
import type { PlatformDefinition } from '../types';
import { MyPlatformClientFactory } from './client';
import { schema } from './schema';
export const myPlatform: PlatformDefinition = {
id: '<platform>',
name: 'Platform Name',
description: 'Connect a Platform bot',
documentation: {
portalUrl: 'https://developers.example.com',
setupGuideUrl: 'https://lobehub.com/docs/usage/channels/<platform>',
},
schema,
showWebhookUrl: true, // Set to true if users need to manually copy the webhook URL
clientFactory: new MyPlatformClientFactory(),
};
```
**`showWebhookUrl`:** Set to `true` for platforms where the user must manually paste a webhook URL (e.g., Slack, Feishu). Set to `false` (or omit) for platforms that auto-register webhooks via API (e.g., Telegram).
## Step 6: Register the Platform
Edit `src/server/services/bot/platforms/index.ts`:
```ts
import { myPlatform } from './<platform>/definition';
// Add to exports
export { myPlatform } from './<platform>/definition';
// Register
platformRegistry.register(myPlatform);
```
## Step 7: Add i18n Keys
### Default keys (`src/locales/default/agent.ts`)
Add platform-specific keys. Reuse generic keys where possible:
```ts
// Reusable (already exist):
// 'channel.botToken', 'channel.applicationId', 'channel.charLimit', etc.
// Platform-specific:
'channel.<platform>.description': 'Connect this assistant to Platform for ...',
'channel.<platform>.someFieldHint': 'Description of this field.',
```
### Translations (`locales/zh-CN/agent.json`, `locales/en-US/agent.json`)
Add corresponding translations for all new keys in both locale files.
## Step 8: Add User Documentation
Create setup guides in `docs/usage/channels/`:
- `<platform>.mdx` — English guide
- `<platform>.zh-CN.mdx` — Chinese guide
Follow the structure of existing docs (e.g., `discord.mdx`): Prerequisites → Create App → Configure in LobeHub → Configure Webhooks → Test Connection → Configuration Reference → Troubleshooting.
## Frontend: Automatic UI Generation
The frontend automatically generates the configuration form from the schema. No frontend code changes are needed unless your platform requires a custom icon. The icon resolution works by matching the platform `name` against known icons in `@lobehub/ui/icons`:
```
// src/routes/(main)/agent/channel/const.ts
const ICON_NAMES = ['Discord', 'GoogleChat', 'Lark', 'Slack', 'Telegram', ...];
```
If your platform's `name` matches an icon name (case-insensitive), the icon is used automatically. Otherwise, add an alias in `ICON_ALIASES`.
## Webhook URL Pattern
All platforms share the same webhook route:
```
POST /api/agent/webhooks/[platform]/[appId]
```
The `BotMessageRouter` handles routing, on-demand bot loading, and Chat SDK integration automatically.
## Checklist
- [ ] Ensure a Chat SDK adapter exists (`@chat-adapter/*` on npm or custom `packages/chat-adapter-<platform>`)
- [ ] Create `src/server/services/bot/platforms/<platform>/`
- [ ] `schema.ts` — Field definitions for credentials and settings
- [ ] `api.ts` — Outbound API client
- [ ] `client.ts` — `ClientFactory` + `PlatformClient`
- [ ] `definition.ts` — `PlatformDefinition` export
- [ ] Register in `src/server/services/bot/platforms/index.ts`
- [ ] Add i18n keys in `src/locales/default/agent.ts`
- [ ] Add translations in `locales/zh-CN/agent.json` and `locales/en-US/agent.json`
- [ ] Add setup docs in `docs/usage/channels/<platform>.mdx` (en + zh-CN)
- [ ] Verify icon resolves in `const.ts` (or add alias)

View file

@ -0,0 +1,426 @@
---
title: 添加新的 Bot 平台
description: >-
了解如何向 LobeHub 的渠道系统添加新的 Bot 平台(如 Slack、WhatsApp包括 Schema 定义、客户端实现和平台注册。
tags:
- Bot 平台
- 消息渠道
- 集成
- 开发指南
---
# 添加新的 Bot 平台
本指南介绍如何向 LobeHub 的渠道系统添加新的 Bot 平台。平台架构是模块化的 —— 每个平台是 `src/server/services/bot/platforms/` 下的一个独立目录。
## 架构概览
```
src/server/services/bot/platforms/
├── types.ts # 核心接口FieldSchema、PlatformClient、ClientFactory 等)
├── registry.ts # PlatformRegistry 类
├── index.ts # 单例注册表 + 平台注册
├── utils.ts # 共享工具函数
├── discord/ # 示例Discord 平台
│ ├── definition.ts # PlatformDefinition 导出
│ ├── schema.ts # 凭据和设置的 FieldSchema[]
│ ├── client.ts # ClientFactory + PlatformClient 实现
│ └── api.ts # 平台 API 辅助类
└── <your-platform>/ # 你的新平台
```
**核心概念:**
- **FieldSchema** — 声明式 Schema同时驱动服务端校验和前端表单自动生成
- **PlatformClient** — 与平台交互的运行时接口(消息收发、生命周期管理)
- **ClientFactory** — 创建 PlatformClient 实例并验证凭据
- **PlatformDefinition** — 元数据 + Schema + 工厂,注册到全局注册表
- **Chat SDK Adapter** — 将平台的 Webhook / 事件桥接到统一的 Chat SDK
## 前置条件Chat SDK Adapter
每个平台都需要一个 **Chat SDK Adapter**,用于将平台的 Webhook 事件桥接到统一的 [Vercel Chat SDK](https://github.com/vercel/chat)`chat` npm 包)。在实现平台之前,需要确定使用哪个 Adapter
### 方案 A使用已有的 npm Adapter
部分平台已有官方 Adapter 发布在 `@chat-adapter/*` 下:
- `@chat-adapter/discord` — Discord
- `@chat-adapter/slack` — Slack
- `@chat-adapter/telegram` — Telegram
可以通过 `npm view @chat-adapter/<platform>` 检查是否存在。
### 方案 B在 `packages/` 中开发自定义 Adapter
如果没有现成的 npm Adapter你需要在工作区中创建一个 Adapter 包。可参考现有实现:
- `packages/chat-adapter-feishu` — 飞书 / Lark Adapter`@lobechat/chat-adapter-feishu`
- `packages/chat-adapter-qq` — QQ Adapter`@lobechat/chat-adapter-qq`
每个 Adapter 包遵循以下结构:
```
packages/chat-adapter-<platform>/
├── package.json # name: @lobechat/chat-adapter-<platform>
├── tsconfig.json
├── tsup.config.ts
└── src/
├── index.ts # 公共导出createXxxAdapter、XxxApiClient 等
├── adapter.ts # 实现 chat SDK 的 Adapter 接口的适配器类
├── api.ts # 平台 API 客户端Webhook 验证、消息解析)
├── crypto.ts # 请求签名验证
├── format-converter.ts # 消息格式转换(平台格式 ↔ Chat SDK AST
└── types.ts # 平台特定的类型定义
```
开发自定义 Adapter 的要点:
- Adapter 必须实现 `chat` 包中的 `Adapter` 接口
- 需要处理 Webhook 请求验证、事件解析和消息格式转换
- `createXxxAdapter(config)` 工厂函数是 `PlatformClient.createAdapter()` 调用的入口
- 在 `package.json` 中添加 `"chat": "^4.14.0"` 作为依赖
## 第一步:创建平台目录
```bash
mkdir src/server/services/bot/platforms/<platform-name>
```
需要创建四个文件:
| 文件 | 用途 |
| --------------- | ------------------------------------- |
| `schema.ts` | 凭据和设置的字段定义 |
| `api.ts` | 用于出站消息的轻量 API 客户端 |
| `client.ts` | `ClientFactory` + `PlatformClient` 实现 |
| `definition.ts` | `PlatformDefinition` 导出 |
## 第二步:定义 Schema`schema.ts`
Schema 是一个 `FieldSchema` 对象数组,包含两个顶层部分:`credentials`(凭据)和 `settings`(设置)。
```ts
import type { FieldSchema } from '../types';
export const schema: FieldSchema[] = [
{
key: 'credentials',
label: 'channel.credentials',
properties: [
{
key: 'applicationId',
description: 'channel.applicationIdHint',
label: 'channel.applicationId',
required: true,
type: 'string',
},
{
key: 'botToken',
description: 'channel.botTokenEncryptedHint',
label: 'channel.botToken',
required: true,
type: 'password', // 存储时加密UI 中遮蔽显示
},
],
type: 'object',
},
{
key: 'settings',
label: 'channel.settings',
properties: [
{
key: 'charLimit',
default: 4000,
description: 'channel.charLimitHint',
label: 'channel.charLimit',
minimum: 100,
type: 'number',
},
// 添加平台特定设置...
],
type: 'object',
},
];
```
**Schema 约定:**
- `type: 'password'` 字段会被加密存储,在表单中以密码形式显示
- 共享字段使用已有的 i18n 键(如 `channel.botToken`、`channel.charLimit`
- 平台特有字段使用 `channel.<platform>.<key>` 命名
- `devOnly: true` 的字段仅在 `NODE_ENV === 'development'` 时显示
- 凭据中必须包含一个能解析为 `applicationId` 的字段 —— 可以是显式的 `applicationId` 字段、`appId` 字段,或从 `botToken` 中提取(参见渠道详情页的 `resolveApplicationId`
## 第三步:创建 API 客户端(`api.ts`
用于回调服务Chat SDK Adapter 之外)的出站消息操作的轻量类:
```ts
import debug from 'debug';
const log = debug('bot-platform:<platform>:client');
export const API_BASE = 'https://api.example.com';
export class PlatformApi {
private readonly token: string;
constructor(token: string) {
this.token = token;
}
async sendMessage(channelId: string, text: string): Promise<{ id: string }> {
log('sendMessage: channel=%s', channelId);
return this.call('messages.send', { channel: channelId, text });
}
async editMessage(channelId: string, messageId: string, text: string): Promise<void> {
log('editMessage: channel=%s, message=%s', channelId, messageId);
await this.call('messages.update', { channel: channelId, id: messageId, text });
}
// ... 其他操作(输入指示器、表情回应等)
private async call(method: string, body: Record<string, unknown>): Promise<any> {
const response = await fetch(`${API_BASE}/${method}`, {
body: JSON.stringify(body),
headers: {
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json',
},
method: 'POST',
});
if (!response.ok) {
const text = await response.text();
log('API error: method=%s, status=%d, body=%s', method, response.status, text);
throw new Error(`API ${method} failed: ${response.status} ${text}`);
}
return response.json();
}
}
```
## 第四步:实现客户端(`client.ts`
实现 `PlatformClient` 并继承 `ClientFactory`
```ts
import { createPlatformAdapter } from '@chat-adapter/<platform>';
import debug from 'debug';
import {
type BotPlatformRuntimeContext,
type BotProviderConfig,
ClientFactory,
type PlatformClient,
type PlatformMessenger,
type ValidationResult,
} from '../types';
import { PlatformApi } from './api';
const log = debug('bot-platform:<platform>:bot');
class MyPlatformClient implements PlatformClient {
readonly id = '<platform>';
readonly applicationId: string;
private config: BotProviderConfig;
private context: BotPlatformRuntimeContext;
constructor(config: BotProviderConfig, context: BotPlatformRuntimeContext) {
this.config = config;
this.context = context;
this.applicationId = config.applicationId;
}
// --- 生命周期 ---
async start(): Promise<void> {
// 注册 webhook 或开始监听
// Webhook 平台:通过平台 API 配置 webhook URL
// 网关平台:打开持久连接
}
async stop(): Promise<void> {
// 清理:移除 webhook 注册或关闭连接
}
// --- 运行时操作 ---
createAdapter(): Record<string, any> {
// 返回 Chat SDK adapter 实例用于入站消息处理
return {
'<platform>': createPlatformAdapter({
botToken: this.config.credentials.botToken,
// ... adapter 特定配置
}),
};
}
getMessenger(platformThreadId: string): PlatformMessenger {
const api = new PlatformApi(this.config.credentials.botToken);
const channelId = platformThreadId.split(':')[1];
return {
createMessage: (content) => api.sendMessage(channelId, content).then(() => {}),
editMessage: (messageId, content) => api.editMessage(channelId, messageId, content),
removeReaction: (messageId, emoji) => api.removeReaction(channelId, messageId, emoji),
triggerTyping: () => Promise.resolve(),
};
}
extractChatId(platformThreadId: string): string {
return platformThreadId.split(':')[1];
}
parseMessageId(compositeId: string): string {
return compositeId;
}
// --- 可选方法 ---
// sanitizeUserInput(text: string): string { ... }
// shouldSubscribe(threadId: string): boolean { ... }
// formatReply(body: string, stats?: UsageStats): string { ... }
}
export class MyPlatformClientFactory extends ClientFactory {
createClient(config: BotProviderConfig, context: BotPlatformRuntimeContext): PlatformClient {
return new MyPlatformClient(config, context);
}
async validateCredentials(credentials: Record<string, string>): Promise<ValidationResult> {
// 调用平台 API 验证凭据有效性
try {
const res = await fetch('https://api.example.com/auth.test', {
headers: { Authorization: `Bearer ${credentials.botToken}` },
method: 'POST',
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return { valid: true };
} catch {
return {
errors: [{ field: 'botToken', message: 'Failed to authenticate' }],
valid: false,
};
}
}
}
```
**需要实现的关键接口:**
| 方法 | 用途 |
| --------------------- | ---------------------------- |
| `start()` | 注册 webhook 或启动网关监听 |
| `stop()` | 关闭时清理资源 |
| `createAdapter()` | 返回 Chat SDK adapter 用于入站事件处理 |
| `getMessenger()` | 返回指定会话的出站消息接口 |
| `extractChatId()` | 从复合会话 ID 中解析平台频道 ID |
| `parseMessageId()` | 将复合消息 ID 转换为平台原生格式 |
| `sanitizeUserInput()` | \*(可选)\* 去除用户输入中的 Bot 提及标记 |
| `shouldSubscribe()` | \*(可选)\* 控制会话自动订阅行为 |
| `formatReply()` | \*(可选)\* 在回复中追加平台特定的格式化内容 |
## 第五步:导出定义(`definition.ts`
```ts
import type { PlatformDefinition } from '../types';
import { MyPlatformClientFactory } from './client';
import { schema } from './schema';
export const myPlatform: PlatformDefinition = {
id: '<platform>',
name: 'Platform Name',
description: 'Connect a Platform bot',
documentation: {
portalUrl: 'https://developers.example.com',
setupGuideUrl: 'https://lobehub.com/docs/usage/channels/<platform>',
},
schema,
showWebhookUrl: true, // 如果用户需要手动复制 webhook URL 则设为 true
clientFactory: new MyPlatformClientFactory(),
};
```
**`showWebhookUrl`** 对于需要用户手动粘贴 webhook URL 的平台(如 Slack、飞书设为 `true`。对于通过 API 自动注册 webhook 的平台(如 Telegram设为 `false` 或省略。
## 第六步:注册平台
编辑 `src/server/services/bot/platforms/index.ts`
```ts
import { myPlatform } from './<platform>/definition';
// 添加到导出
export { myPlatform } from './<platform>/definition';
// 注册
platformRegistry.register(myPlatform);
```
## 第七步:添加 i18n 键
### 默认键(`src/locales/default/agent.ts`
添加平台特有键。尽量复用通用键:
```ts
// 可复用(已存在):
// 'channel.botToken'、'channel.applicationId'、'channel.charLimit' 等
// 平台特有:
'channel.<platform>.description': 'Connect this assistant to Platform for ...',
'channel.<platform>.someFieldHint': 'Description of this field.',
```
### 翻译文件(`locales/zh-CN/agent.json`、`locales/en-US/agent.json`
在两个语言文件中添加所有新键的对应翻译。
## 第八步:添加用户文档
在 `docs/usage/channels/` 下创建配置教程:
- `<platform>.mdx` — 英文教程
- `<platform>.zh-CN.mdx` — 中文教程
参考现有文档的结构(如 `discord.mdx`):前置条件 → 创建应用 → 在 LobeHub 中配置 → 配置 Webhook → 测试连接 → 配置参考 → 故障排除。
## 前端:自动 UI 生成
前端会根据 Schema 自动生成配置表单,无需修改前端代码(除非你的平台需要自定义图标)。图标解析通过将平台 `name` 与 `@lobehub/ui/icons` 中的已知图标匹配来实现:
```
// src/routes/(main)/agent/channel/const.ts
const ICON_NAMES = ['Discord', 'GoogleChat', 'Lark', 'Slack', 'Telegram', ...];
```
如果你的平台 `name` 与图标名称匹配(不区分大小写),图标会自动使用。否则需要在 `ICON_ALIASES` 中添加别名。
## Webhook URL 模式
所有平台共享同一个 Webhook 路由:
```
POST /api/agent/webhooks/[platform]/[appId]
```
`BotMessageRouter` 会自动处理路由分发、按需加载 Bot 和 Chat SDK 集成。
## 检查清单
- [ ] 确保 Chat SDK Adapter 可用npm 上的 `@chat-adapter/*` 或自定义的 `packages/chat-adapter-<platform>`
- [ ] 创建 `src/server/services/bot/platforms/<platform>/`
- [ ] `schema.ts` — 凭据和设置的字段定义
- [ ] `api.ts` — 出站 API 客户端
- [ ] `client.ts` — `ClientFactory` + `PlatformClient`
- [ ] `definition.ts` — `PlatformDefinition` 导出
- [ ] 在 `src/server/services/bot/platforms/index.ts` 中注册
- [ ] 在 `src/locales/default/agent.ts` 中添加 i18n 键
- [ ] 在 `locales/zh-CN/agent.json` 和 `locales/en-US/agent.json` 中添加翻译
- [ ] 在 `docs/usage/channels/<platform>.mdx` 中添加配置教程(中英文)
- [ ] 验证图标在 `const.ts` 中能正确解析(或添加别名)

View file

@ -1,9 +1,9 @@
---
title: Connect LobeHub to Discord
description: >-
Learn how to create a Discord bot and connect it to your LobeHub agent as a
message channel, allowing your AI assistant to interact with users directly in
Discord servers and direct messages.
Learn how to create a Discord bot and connect it to your LobeHub agent as a message channel, allowing your AI assistant to interact with users directly in Discord servers and direct messages.
tags:
- Discord
- Message Channels
@ -14,7 +14,8 @@ tags:
# Connect LobeHub to Discord
<Callout type={'info'}>
This feature is currently in development and may not be fully stable. You can enable it by turning on **Developer Mode** in **Settings** → **Advanced Settings** → **Developer Mode**.
This feature is currently in development and may not be fully stable. You can enable it by turning
on **Developer Mode** in **Settings** → **Advanced Settings** → **Developer Mode**.
</Callout>
By connecting a Discord channel to your LobeHub agent, users can interact with the AI assistant directly through Discord server channels and direct messages.
@ -29,6 +30,8 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
<Steps>
### Go to the Discord Developer Portal
![](https://hub-apac-1.lobeobjects.space/docs/83f435317ea2c9c4a2adcbfd74301536.png)
Visit the [Discord Developer Portal](https://discord.com/developers/applications) and click **New Application**. Give your application a name (e.g., "LobeHub Assistant") and click **Create**.
### Create a Bot
@ -37,6 +40,8 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
### Enable Privileged Gateway Intents
![](https://hub-apac-1.lobeobjects.space/docs/6126baa4154be45eefdad73c576723d0.png)
On the Bot settings page, scroll down to **Privileged Gateway Intents** and enable:
- **Message Content Intent** — Required for the bot to read message content
@ -47,12 +52,16 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
### Copy the Bot Token
![](https://hub-apac-1.lobeobjects.space/docs/e76272de65ad8db8746b1dcafeafdce8.png)
On the **Bot** page, click **Reset Token** to generate your bot token. Copy and save it securely.
> **Important:** Treat your bot token like a password. Never share it publicly or commit it to version control.
### Copy the Application ID and Public Key
![](https://hub-apac-1.lobeobjects.space/docs/d42901c6eb84e3e335d9a8535f317a35.png)
Go to **General Information** in the left sidebar. Copy and save:
- **Application ID**
@ -70,6 +79,8 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
### Fill in the Credentials
![](https://hub-apac-1.lobeobjects.space/docs/c5ced26ea287ee215a9dc385367c1083.png)
Enter the following fields:
- **Application ID** — The Application ID from your Discord app's General Information page
@ -88,6 +99,8 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
<Steps>
### Generate an Invite URL
![](https://hub-apac-1.lobeobjects.space/docs/5e8a93f33e085a187deddb87704f0bd3.png)
In the Discord Developer Portal, go to **OAuth2** → **URL Generator**. Select the following scopes:
- `bot`
@ -104,6 +117,8 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
### Authorize the Bot
![](https://hub-apac-1.lobeobjects.space/docs/2e47836fe4ac988e76460534ee57efa4.png)
Copy the generated URL, open it in your browser, select the server you want to add the bot to, and click **Authorize**.
</Steps>

View file

@ -0,0 +1,145 @@
---
title: Connect LobeHub to Slack
description: >-
Learn how to create a Slack app and connect it to your LobeHub agent as a
message channel, enabling your AI assistant to interact with users in Slack
channels and direct messages.
tags:
- Slack
- Message Channels
- Bot Setup
- Integration
---
# Connect LobeHub to Slack
<Callout type={'info'}>
This feature is currently in development and may not be fully stable. You can enable it by turning
on **Developer Mode** in **Settings** → **Advanced Settings** → **Developer Mode**.
</Callout>
By connecting a Slack channel to your LobeHub agent, users can interact with the AI assistant directly through Slack channels and direct messages.
## Prerequisites
- A LobeHub account with an active subscription
- A Slack workspace where you have permission to install apps
## Step 1: Create a Slack App
<Steps>
### Go to the Slack API Dashboard
Visit [Slack API Apps](https://api.slack.com/apps) and click **Create New App**. Choose **From scratch**, give your app a name (e.g., "LobeHub Assistant"), select the workspace to install it in, and click **Create App**.
### Copy the App ID and Signing Secret
On the **Basic Information** page, copy and save:
- **App ID** — displayed at the top of the page
- **Signing Secret** — under the **App Credentials** section
### Add Bot Token Scopes
In the left sidebar, go to **OAuth & Permissions**. Scroll down to **Scopes** → **Bot Token Scopes** and add the following:
- `app_mentions:read` — Detect when the bot is mentioned
- `channels:history` — Read messages in public channels
- `channels:read` — Read channel info
- `chat:write` — Send messages
- `groups:history` — Read messages in private channels
- `groups:read` — Read private channel info
- `im:history` — Read direct messages
- `im:read` — Read DM channel info
- `mpim:history` — Read group DM messages
- `mpim:read` — Read group DM channel info
- `reactions:read` — Read reactions
- `reactions:write` — Add reactions
- `users:read` — Look up user info
**Optional scopes** (for Slack Assistants API support):
- `assistant:write` — Enable the Slack Assistants API features
### Install the App to Your Workspace
Still on the **OAuth & Permissions** page, click **Install to Workspace** and authorize the app. After installation, copy the **Bot User OAuth Token** (starts with `xoxb-`).
> **Important:** Treat your bot token like a password. Never share it publicly or commit it to version control.
</Steps>
## Step 2: Configure Slack in LobeHub
<Steps>
### Open Channel Settings
In LobeHub, navigate to your agent's settings, then select the **Channels** tab. Click **Slack** from the platform list.
### Fill in the Credentials
Enter the following fields:
- **Application ID** — The App ID from your Slack app's Basic Information page
- **Bot Token** — The Bot User OAuth Token (xoxb-...) from OAuth & Permissions
- **Signing Secret** — The Signing Secret from your Slack app's Basic Information page
Your token will be encrypted and stored securely.
### Save Configuration
Click **Save Configuration**. LobeHub will save your credentials and display a **Webhook URL**.
### Copy the Webhook URL
Copy the displayed Webhook URL — you will need it in the next step to configure Slack's Event Subscriptions.
</Steps>
## Step 3: Configure Event Subscriptions
<Steps>
### Enable Events
Back in the [Slack API Dashboard](https://api.slack.com/apps), go to **Event Subscriptions** and toggle **Enable Events** to **On**.
### Set the Request URL
Paste the **Webhook URL** you copied from LobeHub into the **Request URL** field. Slack will send a verification challenge — LobeHub will respond automatically.
### Subscribe to Bot Events
Under **Subscribe to bot events**, add:
- `app_mention` — Triggered when someone mentions the bot
- `message.channels` — Messages in public channels
- `message.groups` — Messages in private channels
- `message.im` — Direct messages to the bot
- `message.mpim` — Messages in group DMs
- `member_joined_channel` — When a user joins a channel
**Optional events** (for Slack Assistants API support):
- `assistant_thread_started` — When a user opens a new assistant thread
- `assistant_thread_context_changed` — When a user navigates to a different channel with the assistant panel open
### Save Changes
Click **Save Changes** at the bottom of the page.
</Steps>
## Step 4: Test the Connection
Back in LobeHub's channel settings for Slack, click **Test Connection** to verify the integration. Then go to your Slack workspace, invite the bot to a channel, and mention it with `@YourBotName` to confirm it responds.
## Configuration Reference
| Field | Required | Description |
| ------------------ | -------- | ------------------------------------------ |
| **Application ID** | Yes | Your Slack app's ID |
| **Bot Token** | Yes | Bot User OAuth Token (xoxb-...) |
| **Signing Secret** | Yes | Used to verify webhook requests from Slack |
## Troubleshooting
- **Bot not responding:** Confirm the bot has been invited to the channel and the Event Subscriptions are correctly configured with the right webhook URL.
- **Test Connection failed:** Double-check the Application ID and Bot Token are correct. Ensure the app is installed to the workspace.
- **Webhook verification failed:** Make sure the Signing Secret matches the one in your Slack app's Basic Information page.

View file

@ -0,0 +1,143 @@
---
title: 将 LobeHub 连接到 Slack
description: >-
了解如何创建一个 Slack 应用并将其连接到您的 LobeHub 代理作为消息渠道,使您的 AI 助手能够直接在 Slack
频道和私信中与用户互动。
tags:
- Slack
- 消息渠道
- 机器人设置
- 集成
---
# 将 LobeHub 连接到 Slack
<Callout type={'info'}>
此功能目前正在开发中,可能尚未完全稳定。您可以通过在 **设置** → **高级设置** → **开发者模式** 中启用 **开发者模式** 来使用此功能。
</Callout>
通过将 Slack 渠道连接到您的 LobeHub 代理,用户可以直接通过 Slack 频道和私信与 AI 助手互动。
## 前置条件
- 一个拥有有效订阅的 LobeHub 账户
- 一个拥有安装应用权限的 Slack 工作区
## 第一步:创建 Slack 应用
<Steps>
### 访问 Slack API 控制台
访问 [Slack API Apps](https://api.slack.com/apps),点击 **Create New App**。选择 **From scratch**,为您的应用命名(例如 "LobeHub 助手"),选择要安装到的工作区,然后点击 **Create App**。
### 复制 App ID 和 Signing Secret
在 **Basic Information** 页面,复制并保存:
- **App ID** — 显示在页面顶部
- **Signing Secret** — 在 **App Credentials** 部分下
### 添加 Bot Token 权限范围
在左侧菜单中,进入 **OAuth & Permissions**。向下滚动到 **Scopes** → **Bot Token Scopes**,添加以下权限:
- `app_mentions:read` — 检测机器人被提及
- `channels:history` — 读取公共频道中的消息
- `channels:read` — 读取频道信息
- `chat:write` — 发送消息
- `groups:history` — 读取私有频道中的消息
- `groups:read` — 读取私有频道信息
- `im:history` — 读取私信
- `im:read` — 读取私信频道信息
- `mpim:history` — 读取群组私信消息
- `mpim:read` — 读取群组私信信息
- `reactions:read` — 读取表情回应
- `reactions:write` — 添加表情回应
- `users:read` — 查询用户信息
**可选权限**(用于 Slack Assistants API
- `assistant:write` — 启用 Slack Assistants API 功能
### 安装应用到工作区
仍然在 **OAuth & Permissions** 页面,点击 **Install to Workspace** 并授权应用。安装完成后,复制 **Bot User OAuth Token**(以 `xoxb-` 开头)。
> **重要提示:** 请将您的 Bot Token 视为密码。切勿公开分享或提交到版本控制系统。
</Steps>
## 第二步:在 LobeHub 中配置 Slack
<Steps>
### 打开渠道设置
在 LobeHub 中,导航到您的代理设置,然后选择 **渠道** 标签。点击平台列表中的 **Slack**。
### 填写凭据
输入以下字段:
- **应用 ID** — 来自 Slack 应用 Basic Information 页面的 App ID
- **Bot Token** — 来自 OAuth & Permissions 页面的 Bot User OAuth Tokenxoxb-...
- **签名密钥** — 来自 Slack 应用 Basic Information 页面的 Signing Secret
您的令牌将被加密并安全存储。
### 保存配置
点击 **保存配置**。LobeHub 将保存您的凭据并显示一个 **Webhook URL**。
### 复制 Webhook URL
复制显示的 Webhook URL —— 您将在下一步中使用它来配置 Slack 的事件订阅。
</Steps>
## 第三步:配置事件订阅
<Steps>
### 启用事件
返回 [Slack API 控制台](https://api.slack.com/apps),进入 **Event Subscriptions**,将 **Enable Events** 切换为 **On**。
### 设置请求 URL
将您从 LobeHub 复制的 **Webhook URL** 粘贴到 **Request URL** 字段中。Slack 将发送一个验证请求 —— LobeHub 会自动响应。
### 订阅机器人事件
在 **Subscribe to bot events** 下,添加:
- `app_mention` — 当有人提及机器人时触发
- `message.channels` — 公共频道中的消息
- `message.groups` — 私有频道中的消息
- `message.im` — 发送给机器人的私信
- `message.mpim` — 群组私信中的消息
- `member_joined_channel` — 当用户加入频道时触发
**可选事件**(用于 Slack Assistants API
- `assistant_thread_started` — 当用户打开新的助手会话时触发
- `assistant_thread_context_changed` — 当用户在助手面板打开时切换到不同频道时触发
### 保存更改
点击页面底部的 **Save Changes**。
</Steps>
## 第四步:测试连接
返回 LobeHub 的 Slack 渠道设置,点击 **测试连接** 以验证集成是否正确。然后进入您的 Slack 工作区,将机器人邀请到一个频道,通过 `@你的机器人名称` 提及它,确认其是否响应。
## 配置参考
| 字段 | 是否必需 | 描述 |
| ------------- | ---- | ------------------------------ |
| **应用 ID** | 是 | 您的 Slack 应用的 ID |
| **Bot Token** | 是 | Bot User OAuth Tokenxoxb-... |
| **签名密钥** | 是 | 用于验证来自 Slack 的 Webhook 请求 |
## 故障排除
- **机器人未响应:** 确认机器人已被邀请到频道,且事件订阅已正确配置了正确的 Webhook URL。
- **测试连接失败:** 仔细检查应用 ID 和 Bot Token 是否正确。确保应用已安装到工作区。
- **Webhook 验证失败:** 确保签名密钥与 Slack 应用 Basic Information 页面中的一致。

View file

@ -1,5 +1,6 @@
{
"channel.appSecret": "App Secret",
"channel.appSecretHint": "The App Secret of your bot application. It will be encrypted and stored securely.",
"channel.appSecretPlaceholder": "Paste your app secret here",
"channel.applicationId": "Application ID / Bot Username",
"channel.applicationIdHint": "Unique identifier for your bot application.",
@ -9,14 +10,31 @@
"channel.botTokenHowToGet": "How to get?",
"channel.botTokenPlaceholderExisting": "Token is hidden for security",
"channel.botTokenPlaceholderNew": "Paste your bot token here",
"channel.charLimit": "Character Limit",
"channel.charLimitHint": "Maximum number of characters per message",
"channel.connectFailed": "Bot connection failed",
"channel.connectSuccess": "Bot connected successfully",
"channel.connecting": "Connecting...",
"channel.connectionConfig": "Connection Configuration",
"channel.copied": "Copied to clipboard",
"channel.copy": "Copy",
"channel.credentials": "Credentials",
"channel.debounceMs": "Message Merge Window (ms)",
"channel.debounceMsHint": "How long to wait for additional messages before dispatching to the agent (ms)",
"channel.deleteConfirm": "Are you sure you want to remove this channel?",
"channel.deleteConfirmDesc": "This action will permanently remove this message channel and its configuration. This cannot be undone.",
"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.",
"channel.dm": "Direct Messages",
"channel.dmEnabled": "Enable DMs",
"channel.dmEnabledHint": "Allow the bot to receive and respond to direct messages",
"channel.dmPolicy": "DM Policy",
"channel.dmPolicyAllowlist": "Allowlist",
"channel.dmPolicyDisabled": "Disabled",
"channel.dmPolicyHint": "Control who can send direct messages to the bot",
"channel.dmPolicyOpen": "Open",
"channel.documentation": "Documentation",
"channel.enabled": "Enabled",
"channel.encryptKey": "Encrypt Key",
@ -26,6 +44,7 @@
"channel.endpointUrlHint": "Please copy this URL and paste it into the <bold>{{fieldName}}</bold> field in the {{name}} Developer Portal.",
"channel.feishu.description": "Connect this assistant to Feishu for private and group chats.",
"channel.lark.description": "Connect this assistant to Lark for private and group chats.",
"channel.openPlatform": "Open Platform",
"channel.platforms": "Platforms",
"channel.publicKey": "Public Key",
"channel.publicKeyHint": "Optional. Used to verify interaction requests from Discord.",
@ -42,6 +61,16 @@
"channel.secretToken": "Webhook Secret Token",
"channel.secretTokenHint": "Optional. Used to verify webhook requests from Telegram.",
"channel.secretTokenPlaceholder": "Optional secret for webhook verification",
"channel.settings": "Advanced Settings",
"channel.settingsResetConfirm": "Are you sure you want to reset advanced settings to default?",
"channel.settingsResetDefault": "Reset to Default",
"channel.setupGuide": "Setup Guide",
"channel.showUsageStats": "Show Usage Stats",
"channel.showUsageStatsHint": "Show token usage, cost, and duration stats in bot replies",
"channel.signingSecret": "Signing Secret",
"channel.signingSecretHint": "Used to verify webhook requests.",
"channel.slack.appIdHint": "Your Slack App ID from the Slack API dashboard (starts with A).",
"channel.slack.description": "Connect this assistant to Slack for channel conversations and direct messages.",
"channel.telegram.description": "Connect this assistant to Telegram for private and group chats.",
"channel.testConnection": "Test Connection",
"channel.testFailed": "Connection test failed",

View file

@ -1,5 +1,6 @@
{
"channel.appSecret": "App Secret",
"channel.appSecretHint": "你的机器人应用的 App Secret将被加密存储。",
"channel.appSecretPlaceholder": "在此粘贴你的 App Secret",
"channel.applicationId": "应用 ID / Bot 用户名",
"channel.applicationIdHint": "您的机器人应用程序的唯一标识符。",
@ -9,14 +10,31 @@
"channel.botTokenHowToGet": "如何获取?",
"channel.botTokenPlaceholderExisting": "出于安全考虑Token 已隐藏",
"channel.botTokenPlaceholderNew": "在此粘贴你的 Bot Token",
"channel.charLimit": "字符限制",
"channel.charLimitHint": "单条消息的最大字符数",
"channel.connectFailed": "Bot 连接失败",
"channel.connectSuccess": "Bot 连接成功",
"channel.connecting": "连接中...",
"channel.connectionConfig": "连接配置",
"channel.copied": "已复制到剪贴板",
"channel.copy": "复制",
"channel.deleteConfirm": "确定要移除此集成吗?",
"channel.credentials": "凭证配置",
"channel.debounceMs": "消息合并窗口(毫秒)",
"channel.debounceMsHint": "在派发给 Agent 之前等待更多消息的时间(毫秒)",
"channel.deleteConfirm": "确定要移除此消息频道吗?",
"channel.deleteConfirmDesc": "此操作将永久移除该消息频道及其配置,且无法撤销。",
"channel.devWebhookProxyUrl": "HTTPS 隧道地址",
"channel.devWebhookProxyUrlHint": "可选。用于将 webhook 请求转发到本地开发服务器的 HTTPS 隧道 URL。",
"channel.disabled": "已禁用",
"channel.discord.description": "将助手连接到 Discord 服务器,支持频道聊天和私信。",
"channel.dm": "私信",
"channel.dmEnabled": "启用私信",
"channel.dmEnabledHint": "允许机器人接收和回复私信",
"channel.dmPolicy": "私信策略",
"channel.dmPolicyAllowlist": "白名单",
"channel.dmPolicyDisabled": "禁用",
"channel.dmPolicyHint": "控制谁可以向机器人发送私信",
"channel.dmPolicyOpen": "开放",
"channel.documentation": "文档",
"channel.enabled": "已启用",
"channel.encryptKey": "Encrypt Key",
@ -26,6 +44,7 @@
"channel.endpointUrlHint": "请复制此 URL 并粘贴到 {{name}} 开发者门户的 <bold>{{fieldName}}</bold> 字段中。",
"channel.feishu.description": "将助手连接到飞书,支持私聊和群聊。",
"channel.lark.description": "将助手连接到 Lark支持私聊和群聊。",
"channel.openPlatform": "开放平台",
"channel.platforms": "平台",
"channel.publicKey": "公钥",
"channel.publicKeyHint": "可选。用于验证来自 Discord 的交互请求。",
@ -42,6 +61,16 @@
"channel.secretToken": "Webhook 密钥",
"channel.secretTokenHint": "可选。用于验证来自 Telegram 的 Webhook 请求。",
"channel.secretTokenPlaceholder": "可选的 Webhook 验证密钥",
"channel.settings": "高级设置",
"channel.settingsResetConfirm": "确定要将高级设置恢复为默认配置吗?",
"channel.settingsResetDefault": "恢复默认配置",
"channel.setupGuide": "配置教程",
"channel.showUsageStats": "显示用量统计",
"channel.showUsageStatsHint": "在机器人回复中显示 Token 用量、费用和耗时统计",
"channel.signingSecret": "签名密钥",
"channel.signingSecretHint": "用于验证 Webhook 请求。",
"channel.slack.appIdHint": "你的 Slack 应用 ID可在 Slack API 控制台中找到(以 A 开头)。",
"channel.slack.description": "将助手连接到 Slack支持频道对话和私信。",
"channel.telegram.description": "将助手连接到 Telegram支持私聊和群聊。",
"channel.testConnection": "测试连接",
"channel.testFailed": "连接测试失败",

View file

@ -177,9 +177,10 @@
"@better-auth/expo": "1.4.6",
"@better-auth/passkey": "1.4.6",
"@cfworker/json-schema": "^4.1.1",
"@chat-adapter/discord": "^4.17.0",
"@chat-adapter/state-ioredis": "^4.17.0",
"@chat-adapter/telegram": "^4.17.0",
"@chat-adapter/discord": "^4.20.0",
"@chat-adapter/slack": "^4.20.2",
"@chat-adapter/state-ioredis": "^4.20.0",
"@chat-adapter/telegram": "^4.20.0",
"@codesandbox/sandpack-react": "^2.20.0",
"@discordjs/rest": "^2.6.0",
"@dnd-kit/core": "^6.3.1",
@ -197,8 +198,6 @@
"@khmyznikov/pwa-install": "0.3.9",
"@langchain/community": "^0.3.59",
"@lexical/utils": "^0.39.0",
"@lobechat/adapter-lark": "workspace:*",
"@lobechat/adapter-qq": "workspace:*",
"@lobechat/agent-runtime": "workspace:*",
"@lobechat/builtin-agents": "workspace:*",
"@lobechat/builtin-skills": "workspace:*",
@ -224,6 +223,8 @@
"@lobechat/builtin-tools": "workspace:*",
"@lobechat/business-config": "workspace:*",
"@lobechat/business-const": "workspace:*",
"@lobechat/chat-adapter-feishu": "workspace:*",
"@lobechat/chat-adapter-qq": "workspace:*",
"@lobechat/config": "workspace:*",
"@lobechat/const": "workspace:*",
"@lobechat/context-engine": "workspace:*",
@ -297,7 +298,7 @@
"better-auth-harmony": "^1.2.5",
"better-call": "1.1.8",
"brotli-wasm": "^3.0.1",
"chat": "^4.14.0",
"chat": "^4.20.0",
"chroma-js": "^3.2.0",
"class-variance-authority": "^0.7.1",
"cmdk": "^1.1.1",

View file

@ -1,5 +1,5 @@
{
"name": "@lobechat/adapter-lark",
"name": "@lobechat/chat-adapter-feishu",
"version": "0.1.0",
"description": "Lark/Feishu adapter for chat SDK",
"type": "module",

View file

@ -1,5 +1,5 @@
{
"name": "@lobechat/adapter-qq",
"name": "@lobechat/chat-adapter-qq",
"version": "0.1.0",
"description": "QQ Bot adapter for chat SDK",
"type": "module",

View file

@ -5,7 +5,7 @@ import { after } from 'next/server';
import { getServerDB } from '@/database/core/db-adaptor';
import { AgentBotProviderModel } from '@/database/models/agentBotProvider';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { Discord, type DiscordBotConfig } from '@/server/services/bot/platforms/discord';
import { type BotProviderConfig, discord } from '@/server/services/bot/platforms';
import { BotConnectQueue } from '@/server/services/gateway/botConnectQueue';
const log = debug('lobe-server:bot:gateway:cron:discord');
@ -15,6 +15,16 @@ const POLL_INTERVAL_MS = 30_000; // 30 seconds
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
function createDiscordBot(applicationId: string, credentials: Record<string, string>) {
const config: BotProviderConfig = {
applicationId,
credentials,
platform: 'discord',
settings: {},
};
return discord.clientFactory.createClient(config, { appUrl: process.env.APP_URL });
}
async function processConnectQueue(remainingMs: number): Promise<number> {
const queue = new BotConnectQueue();
const items = await queue.popAll();
@ -39,14 +49,11 @@ async function processConnectQueue(remainingMs: number): Promise<number> {
continue;
}
const bot = new Discord({
...provider.credentials,
applicationId: provider.applicationId,
} as DiscordBotConfig);
const bot = createDiscordBot(provider.applicationId, provider.credentials);
await bot.start({
durationMs: remainingMs,
waitUntil: (task) => {
waitUntil: (task: Promise<any>) => {
after(() => task);
},
});
@ -85,11 +92,11 @@ export async function GET(request: NextRequest) {
const { applicationId, credentials } = provider;
try {
const bot = new Discord({ ...credentials, applicationId } as DiscordBotConfig);
const bot = createDiscordBot(applicationId, credentials);
await bot.start({
durationMs: GATEWAY_DURATION_MS,
waitUntil: (task) => {
waitUntil: (task: Promise<any>) => {
after(() => task);
},
});

View file

@ -1,4 +1,23 @@
export async function register() {
// In local development, write debug logs to logs/server.log
if (process.env.NODE_ENV !== 'production' && process.env.NEXT_RUNTIME === 'nodejs') {
await import('./libs/debug-file-logger');
}
// Auto-start GatewayManager for non-Vercel environments so that
// persistent bots (e.g. Discord WebSocket) reconnect after server restart.
if (
process.env.NEXT_RUNTIME === 'nodejs' &&
!process.env.VERCEL_ENV &&
process.env.DATABASE_URL
) {
const { GatewayService } = await import('./server/services/gateway');
const service = new GatewayService();
service.ensureRunning().catch((err) => {
console.error('[Instrumentation] Failed to auto-start GatewayManager:', err);
});
}
if (process.env.NODE_ENV !== 'production' && !process.env.ENABLE_TELEMETRY_IN_DEV) {
return;
}

View file

@ -0,0 +1,79 @@
import { appendFileSync, existsSync, mkdirSync, openSync } from 'node:fs';
import { resolve } from 'node:path';
import { format } from 'node:util';
import debug from 'debug';
/**
* In local development, automatically write all server output to a log file.
*
* Captures:
* - process.stdout / process.stderr (Next.js request logs, etc.)
* - console.log / console.warn / console.error / console.info
* - debug package output
*
* - Controlled by `DEBUG_LOG_FILE=1` env var
* - Only active in non-production environment
* - Log files are split by date: `logs/2026-03-19.log`
*/
const shouldEnable = process.env.DEBUG_LOG_FILE === '1' && process.env.NODE_ENV !== 'production';
if (shouldEnable) {
const LOG_DIR = resolve(process.cwd(), 'logs');
if (!existsSync(LOG_DIR)) {
mkdirSync(LOG_DIR, { recursive: true });
}
// Use fd-based sync write to avoid re-entrance when intercepting stdout/stderr
let currentDate = '';
let fd: number | undefined;
const ensureFd = () => {
const date = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
if (date !== currentDate) {
currentDate = date;
fd = openSync(resolve(LOG_DIR, `${date}.log`), 'a');
}
return fd!;
};
// Strip ANSI escape codes (colors, cursor movement, etc.)
// eslint-disable-next-line no-control-regex, regexp/no-obscure-range
const ANSI_RE = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
const stripAnsi = (str: string) => str.replaceAll(ANSI_RE, '');
const appendToFile = (data: string) => {
try {
appendFileSync(ensureFd(), stripAnsi(data));
} catch {
// Silently ignore write errors to avoid breaking the server
}
};
// Intercept process.stdout and process.stderr
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
const originalStderrWrite = process.stderr.write.bind(process.stderr);
process.stdout.write = (chunk: any, ...rest: any[]) => {
appendToFile(typeof chunk === 'string' ? chunk : chunk.toString());
return (originalStdoutWrite as any)(chunk, ...rest);
};
process.stderr.write = (chunk: any, ...rest: any[]) => {
appendToFile(typeof chunk === 'string' ? chunk : chunk.toString());
return (originalStderrWrite as any)(chunk, ...rest);
};
// Intercept debug package output (writes to stderr, but may use custom format)
const originalDebugLog = debug.log;
debug.log = (...args: any[]) => {
if (originalDebugLog) {
originalDebugLog(...args);
} else {
process.stderr.write(format(...args) + '\n');
}
};
}

View file

@ -13,6 +13,8 @@ export default {
'channel.copied': 'Copied to clipboard',
'channel.copy': 'Copy',
'channel.deleteConfirm': 'Are you sure you want to remove this channel?',
'channel.deleteConfirmDesc':
'This action will permanently remove this message channel and its configuration. This cannot be undone.',
'channel.devWebhookProxyUrl': 'HTTPS Tunnel URL',
'channel.devWebhookProxyUrlHint':
'Optional. HTTPS tunnel URL for forwarding webhook requests to local dev server.',
@ -24,11 +26,15 @@ export default {
'channel.encryptKey': 'Encrypt Key',
'channel.encryptKeyHint': 'Optional. Used to decrypt encrypted event payloads.',
'channel.encryptKeyPlaceholder': 'Optional encryption key',
'channel.connectFailed': 'Bot connection failed',
'channel.connectSuccess': 'Bot connected successfully',
'channel.connecting': 'Connecting...',
'channel.endpointUrl': 'Webhook URL',
'channel.endpointUrlHint':
'Please copy this URL and paste it into the <bold>{{fieldName}}</bold> field in the {{name}} Developer Portal.',
'channel.feishu.description': 'Connect this assistant to Feishu for private and group chats.',
'channel.lark.description': 'Connect this assistant to Lark for private and group chats.',
'channel.openPlatform': 'Open Platform',
'channel.platforms': 'Platforms',
'channel.publicKey': 'Public Key',
'channel.publicKeyHint': 'Optional. Used to verify interaction requests from Discord.',
@ -39,10 +45,14 @@ export default {
'channel.removed': 'Channel removed',
'channel.removeFailed': 'Failed to remove channel',
'channel.save': 'Save Configuration',
'channel.setupGuide': 'Setup Guide',
'channel.saveFailed': 'Failed to save configuration',
'channel.saveFirstWarning': 'Please save configuration first',
'channel.saved': 'Configuration saved successfully',
'channel.secretToken': 'Webhook Secret Token',
'channel.slack.appIdHint': 'Your Slack App ID from the Slack API dashboard (starts with A).',
'channel.slack.description':
'Connect this assistant to Slack for channel conversations and direct messages.',
'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.',
@ -54,4 +64,28 @@ export default {
'channel.verificationToken': 'Verification Token',
'channel.verificationTokenHint': 'Optional. Used to verify webhook event source.',
'channel.verificationTokenPlaceholder': 'Paste your verification token here',
'channel.appSecretHint':
'The App Secret of your bot application. It will be encrypted and stored securely.',
'channel.charLimit': 'Character Limit',
'channel.charLimitHint': 'Maximum number of characters per message',
'channel.credentials': 'Credentials',
'channel.debounceMs': 'Message Merge Window (ms)',
'channel.debounceMsHint':
'How long to wait for additional messages before dispatching to the agent (ms)',
'channel.dm': 'Direct Messages',
'channel.dmEnabled': 'Enable DMs',
'channel.dmEnabledHint': 'Allow the bot to receive and respond to direct messages',
'channel.dmPolicy': 'DM Policy',
'channel.dmPolicyHint': 'Control who can send direct messages to the bot',
'channel.dmPolicyAllowlist': 'Allowlist',
'channel.dmPolicyDisabled': 'Disabled',
'channel.dmPolicyOpen': 'Open',
'channel.settings': 'Advanced Settings',
'channel.settingsResetConfirm': 'Are you sure you want to reset advanced settings to default?',
'channel.settingsResetDefault': 'Reset to Default',
'channel.signingSecret': 'Signing Secret',
'channel.signingSecretHint': 'Used to verify webhook requests.',
'channel.showUsageStats': 'Show Usage Stats',
'channel.showUsageStatsHint': 'Show token usage, cost, and duration stats in bot replies',
} as const;

View file

@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next';
import { isDesktop } from '@/const/version';
import { pluginRegistry } from '@/features/Electron/titlebar/RecentlyViewed/plugins';
import NavItem from '@/features/NavPanel/components/NavItem';
import { CHANNEL_PROVIDERS } from '@/routes/(main)/agent/channel/const';
import { getPlatformIcon } from '@/routes/(main)/agent/channel/const';
import { useAgentStore } from '@/store/agent';
import { useChatStore } from '@/store/chat';
import { operationSelectors } from '@/store/chat/selectors';
@ -206,9 +206,8 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
title={title}
icon={(() => {
if (metadata?.bot?.platform) {
const provider = CHANNEL_PROVIDERS.find((p) => p.id === metadata.bot!.platform);
if (provider) {
const ProviderIcon = provider.icon;
const ProviderIcon = getPlatformIcon(metadata.bot!.platform);
if (ProviderIcon) {
return <ProviderIcon color={cssVar.colorTextDescription} size={16} />;
}
}

View file

@ -1,110 +1,34 @@
import { Discord, Lark, QQ, Telegram } from '@lobehub/ui/icons';
import type { LucideIcon } from 'lucide-react';
import * as Icons from '@lobehub/ui/icons';
import type { FC } from 'react';
export interface ChannelProvider {
/** Lark-style auth: appId + appSecret instead of botToken */
authMode?: 'app-secret' | 'bot-token';
/** Whether applicationId can be auto-derived from the bot token */
autoAppId?: boolean;
color: string;
description: string;
docsLink: string;
fieldTags: {
appId: string;
appSecret?: string;
encryptKey?: string;
publicKey?: string;
secretToken?: string;
token?: string;
verificationToken?: string;
webhook?: string;
};
icon: FC<any> | LucideIcon;
id: string;
name: string;
/** 'manual' = user must copy endpoint URL to platform portal (Discord, Lark);
* 'auto' = webhook is set automatically via API (Telegram) */
webhookMode?: 'auto' | 'manual';
}
/** Known icon names from @lobehub/ui/icons that correspond to chat platforms. */
const ICON_NAMES = [
'Discord',
'GoogleChat',
'Lark',
'MicrosoftTeams',
'QQ',
'Slack',
'Telegram',
'WeChat',
'WhatsApp',
] as const;
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: Discord,
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: Telegram,
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: Lark,
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: Lark,
id: 'lark',
name: 'Lark',
},
{
authMode: 'app-secret',
color: '#12B7F5',
description: 'channel.qq.description',
docsLink: 'https://bot.q.qq.com/wiki/',
fieldTags: {
appId: 'App ID',
appSecret: 'App Secret',
webhook: 'Callback URL',
},
icon: QQ,
id: 'qq',
name: 'QQ',
webhookMode: 'manual',
},
];
/** Alias map for platforms whose display name differs from the icon name. */
const ICON_ALIASES: Record<string, string> = {
feishu: 'Lark',
};
/**
* Resolve icon component by matching against known icon names.
* Accepts either a platform display name (e.g. "Feishu / Lark") or id (e.g. "discord").
*/
export function getPlatformIcon(nameOrId: string): FC<any> | undefined {
const alias = ICON_ALIASES[nameOrId.toLowerCase()];
if (alias) return (Icons as Record<string, any>)[alias];
const name = ICON_NAMES.find(
(n) => nameOrId.includes(n) || nameOrId.toLowerCase() === n.toLowerCase(),
);
return name ? (Icons as Record<string, any>)[name] : undefined;
}

View file

@ -1,232 +1,273 @@
'use client';
import { Alert, Flexbox, Form, type FormGroupItemType, type FormItemProps, Tag } from '@lobehub/ui';
import { Button, Form as AntdForm, type FormInstance, Switch } from 'antd';
import { Flexbox, Form, FormGroup, FormItem, Tag } from '@lobehub/ui';
import { Button, type FormInstance, InputNumber, Popconfirm, Select, 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 { RotateCcw } from 'lucide-react';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppOrigin } from '@/hooks/useAppOrigin';
import { FormInput, FormPassword } from '@/components/FormInput';
import type {
FieldSchema,
SerializedPlatformDefinition,
} from '@/server/services/bot/platforms/types';
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 { getQQFormItems } from './platforms/qq';
import { getTelegramFormItems } from './platforms/telegram';
import type { ChannelFormValues } from './index';
const prefixCls = 'ant';
const styles = createStaticStyles(({ css, cssVar }) => ({
actionBar: css`
display: flex;
align-items: center;
justify-content: space-between;
padding-block-start: 16px;
`,
const styles = createStaticStyles(({ css }) => ({
form: css`
.${prefixCls}-form-item-control:has(.${prefixCls}-input, .${prefixCls}-select) {
.${prefixCls}-form-item-control:has(.${prefixCls}-input, .${prefixCls}-select, .${prefixCls}-input-number) {
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,
qq: getQQFormItems,
telegram: getTelegramFormItems,
};
// --------------- Validation rules builder ---------------
interface BodyProps {
currentConfig?: { enabled: boolean };
form: FormInstance<ChannelFormValues>;
hasConfig: boolean;
onCopied: () => void;
onDelete: () => void;
onSave: () => void;
onTestConnection: () => void;
onToggleEnable: (enabled: boolean) => void;
provider: ChannelProvider;
saveResult?: TestResult;
saving: boolean;
testing: boolean;
testResult?: TestResult;
function buildRules(field: FieldSchema, t: (key: string) => string) {
const rules: any[] = [];
if (field.required) {
rules.push({ message: t(field.label), required: true });
}
if (field.type === 'number' || field.type === 'integer') {
if (typeof field.minimum === 'number') {
rules.push({
message: `${t(field.label)}${field.minimum}`,
min: field.minimum,
type: 'number' as const,
});
}
if (typeof field.maximum === 'number') {
rules.push({
message: `${t(field.label)}${field.maximum}`,
max: field.maximum,
type: 'number' as const,
});
}
}
return rules.length > 0 ? rules : undefined;
}
const Body = memo<BodyProps>(
({
provider,
form,
hasConfig,
currentConfig,
saveResult,
saving,
testing,
testResult,
onSave,
onDelete,
onTestConnection,
onToggleEnable,
onCopied,
}) => {
const { t } = useTranslation('agent');
const origin = useAppOrigin();
const applicationId = AntdForm.useWatch('applicationId', form);
// --------------- Single field component (memo'd) ---------------
const webhookUrl = applicationId
? `${origin}/api/agent/webhooks/${provider.id}/${applicationId}`
: `${origin}/api/agent/webhooks/${provider.id}`;
interface SchemaFieldProps {
divider?: boolean;
field: FieldSchema;
parentKey: string;
}
const getItems = platformFormItemsMap[provider.id];
const configItems = getItems ? getItems(t, hasConfig, provider) : [];
const SchemaField = memo<SchemaFieldProps>(({ field, parentKey, divider }) => {
const { t: _t } = useTranslation('agent');
const t = _t as (key: string) => string;
const ColorIcon = 'Color' in provider.icon ? (provider.icon as any).Color : provider.icon;
const label = field.devOnly ? (
<Flexbox horizontal align="center" gap={8}>
{t(field.label)}
<Tag color="gold">Dev Only</Tag>
</Flexbox>
) : (
t(field.label)
);
const headerTitle = (
<Flexbox horizontal align="center" gap={8}>
<ColorIcon size={32} />
{provider.name}
</Flexbox>
);
const headerExtra = currentConfig ? (
<Switch checked={currentConfig.enabled} onChange={onToggleEnable} />
) : undefined;
const group: FormGroupItemType = {
children: configItems,
defaultActive: true,
extra: headerExtra,
title: headerTitle,
};
return (
<>
<Form
className={styles.form}
form={form}
itemMinWidth={'max(50%, 400px)'}
items={[group]}
requiredMark={false}
style={{ maxWidth: 1024, padding: 24, width: '100%' }}
variant={'borderless'}
let children: React.ReactNode;
switch (field.type) {
case 'password': {
children = <FormPassword autoComplete="new-password" placeholder={field.placeholder} />;
break;
}
case 'boolean': {
children = <Switch />;
break;
}
case 'number':
case 'integer': {
children = (
<InputNumber
max={field.maximum}
min={field.minimum}
placeholder={field.placeholder}
style={{ width: '100%' }}
/>
);
break;
}
case 'string': {
if (field.enum) {
children = (
<Select
placeholder={field.placeholder}
options={field.enum.map((value, i) => ({
label: field.enumLabels?.[i] ? t(field.enumLabels[i]) : value,
value,
}))}
/>
);
} else {
children = <FormInput placeholder={field.placeholder || t(field.label)} />;
}
break;
}
default: {
children = <FormInput placeholder={field.placeholder || t(field.label)} />;
}
}
<div className={styles.bottom}>
<div className={styles.actionBar}>
{hasConfig ? (
<Button danger icon={<Trash2 size={16} />} type="primary" onClick={onDelete}>
{t('channel.removeChannel')}
</Button>
) : (
<div />
)}
<Flexbox horizontal gap={12}>
{hasConfig && (
<Button icon={<RefreshCw size={16} />} loading={testing} onClick={onTestConnection}>
{t('channel.testConnection')}
return (
<FormItem
desc={field.description ? t(field.description) : undefined}
divider={divider}
initialValue={field.default}
label={label}
minWidth={'max(50%, 400px)'}
name={[parentKey, field.key]}
rules={buildRules(field, t)}
tag={field.key}
valuePropName={field.type === 'boolean' ? 'checked' : undefined}
variant="borderless"
>
{children}
</FormItem>
);
});
// --------------- ApplicationId field (standalone, not nested) ---------------
const ApplicationIdField = memo<{ field: FieldSchema }>(({ field }) => {
const { t: _t } = useTranslation('agent');
const t = _t as (key: string) => string;
return (
<FormItem
desc={field.description ? t(field.description) : undefined}
initialValue={field.default}
label={t(field.label)}
minWidth={'max(50%, 400px)'}
name="applicationId"
rules={field.required ? [{ message: t(field.label), required: true }] : undefined}
tag="applicationId"
variant="borderless"
>
<FormInput placeholder={field.placeholder || t(field.label)} />
</FormItem>
);
});
// --------------- Helper: flatten fields from schema ---------------
function getFields(schema: FieldSchema[], sectionKey: string): FieldSchema[] {
const section = schema.find((f) => f.key === sectionKey);
if (!section?.properties) return [];
return section.properties
.filter((f) => !f.devOnly || process.env.NODE_ENV === 'development')
.flatMap((f) => {
if (f.type === 'object' && f.properties) {
return f.properties.filter(
(child) => !child.devOnly || process.env.NODE_ENV === 'development',
);
}
return f;
});
}
// --------------- Settings group title (memo'd) ---------------
const SettingsTitle = memo<{ schema: FieldSchema[] }>(({ schema }) => {
const { t: _t } = useTranslation('agent');
const t = _t as (key: string) => string;
const settingsSchema = schema.find((f) => f.key === 'settings');
return <>{settingsSchema ? t(settingsSchema.label) : null}</>;
});
// --------------- Body component ---------------
interface BodyProps {
form: FormInstance<ChannelFormValues>;
platformDef: SerializedPlatformDefinition;
}
const Body = memo<BodyProps>(({ platformDef, form }) => {
const { t: _t } = useTranslation('agent');
const t = _t as (key: string) => string;
const applicationIdField = useMemo(
() => platformDef.schema.find((f) => f.key === 'applicationId'),
[platformDef.schema],
);
const credentialFields = useMemo(
() => getFields(platformDef.schema, 'credentials'),
[platformDef.schema],
);
const settingsFields = useMemo(
() => getFields(platformDef.schema, 'settings'),
[platformDef.schema],
);
const [settingsActive, setSettingsActive] = useState(false);
const handleResetSettings = useCallback(() => {
const defaults: Record<string, any> = {};
for (const field of settingsFields) {
if (field.default !== undefined) {
defaults[field.key] = field.default;
}
}
form.setFieldsValue({ settings: defaults });
}, [form, settingsFields]);
return (
<Form
className={styles.form}
form={form}
gap={0}
itemMinWidth={'max(50%, 400px)'}
requiredMark={false}
style={{ maxWidth: 1024, padding: '16px 0', width: '100%' }}
variant={'borderless'}
>
{applicationIdField && <ApplicationIdField field={applicationIdField} />}
{credentialFields.map((field, i) => (
<SchemaField
divider={applicationIdField ? true : i !== 0}
field={field}
key={field.key}
parentKey="credentials"
/>
))}
{settingsFields.length > 0 && (
<FormGroup
collapsible
defaultActive={false}
keyValue={`settings-${platformDef.id}`}
style={{ marginBlockStart: 16 }}
title={<SettingsTitle schema={platformDef.schema} />}
variant="borderless"
extra={
settingsActive ? (
<Popconfirm title={t('channel.settingsResetConfirm')} onConfirm={handleResetSettings}>
<Button icon={<RotateCcw size={14} />} size="small" type="default">
{t('channel.settingsResetDefault')}
</Button>
)}
<Button icon={<Save size={16} />} loading={saving} type="primary" onClick={onSave}>
{t('channel.save')}
</Button>
</Flexbox>
</div>
{saveResult && (
<Alert
closable
showIcon
description={saveResult.type === 'error' ? saveResult.errorDetail : undefined}
title={saveResult.type === 'success' ? t('channel.saved') : t('channel.saveFailed')}
type={saveResult.type}
/>
)}
{testResult && (
<Alert
closable
showIcon
description={testResult.type === 'error' ? testResult.errorDetail : undefined}
type={testResult.type}
title={
testResult.type === 'success' ? t('channel.testSuccess') : t('channel.testFailed')
}
/>
)}
{hasConfig && provider.webhookMode !== 'auto' && (
<Flexbox gap={8}>
<Flexbox horizontal align="center" gap={8}>
<span style={{ fontWeight: 600 }}>{t('channel.endpointUrl')}</span>
{provider.fieldTags.webhook && <Tag>{provider.fieldTags.webhook}</Tag>}
</Flexbox>
<Flexbox horizontal gap={8}>
<div className={styles.webhookBox}>{webhookUrl}</div>
<Button
onClick={() => {
navigator.clipboard.writeText(webhookUrl);
onCopied();
}}
>
{t('channel.copy')}
</Button>
</Flexbox>
<Alert
showIcon
type="info"
message={
<Trans
components={{ bold: <strong /> }}
i18nKey="channel.endpointUrlHint"
ns="agent"
values={{ fieldName: provider.fieldTags.webhook, name: provider.name }}
/>
}
/>
</Flexbox>
)}
</div>
</>
);
},
);
</Popconfirm>
) : undefined
}
onCollapse={setSettingsActive}
>
{settingsFields.map((field, i) => (
<SchemaField divider={i !== 0} field={field} key={field.key} parentKey="settings" />
))}
</FormGroup>
)}
</Form>
);
});
export default Body;

View file

@ -0,0 +1,201 @@
'use client';
import { Alert, Flexbox, Tag } from '@lobehub/ui';
import { Button, Form as AntdForm, type FormInstance } 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 { SerializedPlatformDefinition } from '@/server/services/bot/platforms/types';
import type { ChannelFormValues, TestResult } from './index';
const styles = createStaticStyles(({ css, cssVar }) => ({
actionBar: css`
display: flex;
align-items: center;
justify-content: space-between;
padding-block-start: 16px;
`,
bottom: css`
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
max-width: 1024px;
`,
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 FooterProps {
connecting: boolean;
connectResult?: TestResult;
form: FormInstance<ChannelFormValues>;
hasConfig: boolean;
onCopied: () => void;
onDelete: () => void;
onSave: () => void;
onTestConnection: () => void;
platformDef: SerializedPlatformDefinition;
saveResult?: TestResult;
saving: boolean;
testing: boolean;
testResult?: TestResult;
}
const Footer = memo<FooterProps>(
({
platformDef,
form,
hasConfig,
connectResult,
connecting,
saveResult,
saving,
testing,
testResult,
onSave,
onDelete,
onTestConnection,
onCopied,
}) => {
const { t } = useTranslation('agent');
const origin = useAppOrigin();
const platformId = platformDef.id;
const applicationId = AntdForm.useWatch('applicationId', form);
const webhookUrl = applicationId
? `${origin}/api/agent/webhooks/${platformId}/${applicationId}`
: `${origin}/api/agent/webhooks/${platformId}`;
return (
<div className={styles.bottom}>
<div className={styles.actionBar}>
{hasConfig ? (
<Button
danger
disabled={saving || connecting}
icon={<Trash2 size={16} />}
type="primary"
onClick={onDelete}
>
{t('channel.removeChannel')}
</Button>
) : (
<div />
)}
<Flexbox horizontal gap={12}>
{hasConfig && (
<Button
disabled={saving || connecting}
icon={<RefreshCw size={16} />}
loading={testing}
onClick={onTestConnection}
>
{t('channel.testConnection')}
</Button>
)}
<Button
icon={<Save size={16} />}
loading={saving || connecting}
type="primary"
onClick={onSave}
>
{connecting ? t('channel.connecting') : t('channel.save')}
</Button>
</Flexbox>
</div>
{saveResult && (
<Alert
closable
showIcon
description={saveResult.type === 'error' ? saveResult.errorDetail : undefined}
title={saveResult.type === 'success' ? t('channel.saved') : t('channel.saveFailed')}
type={saveResult.type}
/>
)}
{connectResult && (
<Alert
closable
showIcon
description={connectResult.type === 'error' ? connectResult.errorDetail : undefined}
type={connectResult.type}
title={
connectResult.type === 'success'
? t('channel.connectSuccess')
: t('channel.connectFailed')
}
/>
)}
{testResult && (
<Alert
closable
showIcon
description={testResult.type === 'error' ? testResult.errorDetail : undefined}
type={testResult.type}
title={
testResult.type === 'success' ? t('channel.testSuccess') : t('channel.testFailed')
}
/>
)}
{hasConfig && platformDef.showWebhookUrl && (
<Flexbox gap={8}>
<Flexbox horizontal align="center" gap={8}>
<span style={{ fontWeight: 600 }}>{t('channel.endpointUrl')}</span>
<Tag>{'Event Subscription URL'}</Tag>
</Flexbox>
<Flexbox horizontal gap={8}>
<div className={styles.webhookBox}>{webhookUrl}</div>
<Button
onClick={() => {
navigator.clipboard.writeText(webhookUrl);
onCopied();
}}
>
{t('channel.copy')}
</Button>
</Flexbox>
<Alert
showIcon
type="info"
message={
<Trans
components={{ bold: <strong /> }}
i18nKey="channel.endpointUrlHint"
ns="agent"
values={{ fieldName: 'Event Subscription URL', name: platformDef.name }}
/>
}
/>
</Flexbox>
)}
</div>
);
},
);
export default Footer;

View file

@ -0,0 +1,65 @@
'use client';
import { Flexbox } from '@lobehub/ui';
import { Button, Switch } from 'antd';
import { ExternalLink } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import InfoTooltip from '@/components/InfoTooltip';
import type { SerializedPlatformDefinition } from '@/server/services/bot/platforms/types';
import { getPlatformIcon } from '../const';
interface HeaderProps {
currentConfig?: { enabled: boolean };
onToggleEnable: (enabled: boolean) => void;
platformDef: SerializedPlatformDefinition;
}
const Header = memo<HeaderProps>(({ platformDef, currentConfig, onToggleEnable }) => {
const { t } = useTranslation('agent');
const PlatformIcon = getPlatformIcon(platformDef.name);
const ColorIcon =
PlatformIcon && 'Color' in PlatformIcon ? (PlatformIcon as any).Color : PlatformIcon;
return (
<Flexbox
horizontal
align="center"
justify="space-between"
style={{
borderBottom: '1px solid var(--ant-color-border)',
maxWidth: 1024,
padding: '16px 0',
width: '100%',
}}
>
<Flexbox horizontal align="center" gap={8}>
{ColorIcon && <ColorIcon size={32} />}
{platformDef.name}
{platformDef.documentation?.setupGuideUrl && (
<a
href={platformDef.documentation.setupGuideUrl}
rel="noopener noreferrer"
target="_blank"
>
<InfoTooltip title={t('channel.setupGuide')} />
</a>
)}
</Flexbox>
<Flexbox horizontal align="center" gap={8}>
{platformDef.documentation?.portalUrl && (
<a href={platformDef.documentation.portalUrl} rel="noopener noreferrer" target="_blank">
<Button icon={<ExternalLink size={14} />} size="small" type="link">
{t('channel.openPlatform')}
</Button>
</a>
)}
{currentConfig && <Switch checked={currentConfig.enabled} onChange={onToggleEnable} />}
</Flexbox>
</Flexbox>
);
});
export default Header;

View file

@ -5,10 +5,12 @@ import { createStaticStyles } from 'antd-style';
import { memo, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { SerializedPlatformDefinition } from '@/server/services/bot/platforms/types';
import { useAgentStore } from '@/store/agent';
import { type ChannelProvider } from '../const';
import Body from './Body';
import Footer from './Footer';
import Header from './Header';
const styles = createStaticStyles(({ css, cssVar }) => ({
main: css`
@ -20,6 +22,8 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
flex-direction: column;
align-items: center;
padding: 24px;
background: ${cssVar.colorBgContainer};
`,
}));
@ -33,59 +37,58 @@ interface CurrentConfig {
}
export interface ChannelFormValues {
applicationId: string;
appSecret?: string;
botToken: string;
encryptKey?: string;
publicKey: string;
secretToken?: string;
verificationToken?: string;
webhookProxyUrl?: string;
applicationId?: string;
credentials: Record<string, string>;
settings: Record<string, unknown>;
}
export interface TestResult {
errorDetail?: string;
type: 'success' | 'error';
type: 'error' | 'success';
}
interface PlatformDetailProps {
agentId: string;
currentConfig?: CurrentConfig;
provider: ChannelProvider;
platformDef: SerializedPlatformDefinition;
}
const PlatformDetail = memo<PlatformDetailProps>(({ provider, agentId, currentConfig }) => {
const PlatformDetail = memo<PlatformDetailProps>(({ platformDef, agentId, currentConfig }) => {
const { t } = useTranslation('agent');
const { message: msg, modal } = App.useApp();
const [form] = Form.useForm<ChannelFormValues>();
const [createBotProvider, deleteBotProvider, updateBotProvider, connectBot] = useAgentStore(
(s) => [s.createBotProvider, s.deleteBotProvider, s.updateBotProvider, s.connectBot],
);
const [createBotProvider, deleteBotProvider, updateBotProvider, connectBot, testConnection] =
useAgentStore((s) => [
s.createBotProvider,
s.deleteBotProvider,
s.updateBotProvider,
s.connectBot,
s.testConnection,
]);
const [saving, setSaving] = useState(false);
const [connecting, setConnecting] = useState(false);
const [saveResult, setSaveResult] = useState<TestResult>();
const [connectResult, setConnectResult] = useState<TestResult>();
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<TestResult>();
// Reset form when switching platforms
// Reset form and status when switching platforms
useEffect(() => {
form.resetFields();
}, [provider.id, form]);
setSaveResult(undefined);
setConnectResult(undefined);
setTestResult(undefined);
}, [platformDef.id, form]);
// Sync form with saved config
useEffect(() => {
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 || '',
});
credentials: currentConfig.credentials || {},
} as any);
}
}, [currentConfig, form]);
@ -95,78 +98,69 @@ const PlatformDetail = memo<PlatformDetailProps>(({ provider, agentId, currentCo
setSaving(true);
setSaveResult(undefined);
setConnectResult(undefined);
// Auto-derive applicationId from bot token for Telegram
let applicationId = values.applicationId;
if (provider.autoAppId && values.botToken) {
const colonIdx = values.botToken.indexOf(':');
if (colonIdx !== -1) {
applicationId = values.botToken.slice(0, colonIdx);
form.setFieldValue('applicationId', applicationId);
}
}
const {
applicationId: formAppId,
credentials: rawCredentials = {},
settings = {},
} = values as ChannelFormValues;
// Build platform-specific credentials
const credentials: Record<string, string> =
provider.authMode === 'app-secret'
? { appId: applicationId, appSecret: values.appSecret || '' }
: { botToken: values.botToken };
// Strip undefined values from credentials (optional fields left empty by antd form)
const credentials = Object.fromEntries(
Object.entries(rawCredentials).filter(([, v]) => v !== undefined && v !== ''),
);
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;
// Use explicit applicationId from form; fall back to deriving from botToken (Telegram)
let applicationId = formAppId || '';
if (!applicationId && (credentials as Record<string, string>).botToken) {
const colonIdx = (credentials as Record<string, string>).botToken.indexOf(':');
if (colonIdx !== -1)
applicationId = (credentials as Record<string, string>).botToken.slice(0, colonIdx);
}
if (currentConfig) {
await updateBotProvider(currentConfig.id, agentId, {
applicationId,
credentials,
settings,
});
} else {
await createBotProvider({
agentId,
applicationId,
credentials,
platform: provider.id,
platform: platformDef.id,
settings,
});
}
setSaveResult({ type: 'success' });
setSaving(false);
// Auto-connect bot after save
setConnecting(true);
try {
await connectBot({ applicationId, platform: platformDef.id });
setConnectResult({ type: 'success' });
} catch (e: any) {
setConnectResult({ errorDetail: e?.message || String(e), type: 'error' });
} finally {
setConnecting(false);
}
} catch (e: any) {
if (e?.errorFields) return;
console.error(e);
setSaveResult({ errorDetail: e?.message || String(e), type: 'error' });
} finally {
setSaving(false);
}
}, [
agentId,
provider.id,
provider.autoAppId,
provider.authMode,
provider.fieldTags,
provider.webhookMode,
form,
currentConfig,
createBotProvider,
updateBotProvider,
]);
}, [agentId, platformDef, form, currentConfig, createBotProvider, updateBotProvider, connectBot]);
const handleDelete = useCallback(async () => {
if (!currentConfig) return;
modal.confirm({
content: t('channel.deleteConfirmDesc'),
okButtonProps: { danger: true },
onOk: async () => {
try {
@ -186,11 +180,17 @@ const PlatformDetail = memo<PlatformDetailProps>(({ provider, agentId, currentCo
if (!currentConfig) return;
try {
await updateBotProvider(currentConfig.id, agentId, { enabled });
if (enabled) {
await connectBot({
applicationId: currentConfig.applicationId,
platform: currentConfig.platform,
});
}
} catch {
msg.error(t('channel.updateFailed'));
}
},
[currentConfig, agentId, updateBotProvider, msg, t],
[currentConfig, agentId, updateBotProvider, connectBot, msg, t],
);
const handleTestConnection = useCallback(async () => {
@ -202,9 +202,9 @@ const PlatformDetail = memo<PlatformDetailProps>(({ provider, agentId, currentCo
setTesting(true);
setTestResult(undefined);
try {
await connectBot({
await testConnection({
applicationId: currentConfig.applicationId,
platform: provider.id,
platform: platformDef.id,
});
setTestResult({ type: 'success' });
} catch (e: any) {
@ -215,15 +215,22 @@ const PlatformDetail = memo<PlatformDetailProps>(({ provider, agentId, currentCo
} finally {
setTesting(false);
}
}, [currentConfig, provider.id, connectBot, msg, t]);
}, [currentConfig, platformDef.id, testConnection, msg, t]);
return (
<main className={styles.main}>
<Body
<Header
currentConfig={currentConfig}
platformDef={platformDef}
onToggleEnable={handleToggleEnable}
/>
<Body form={form} platformDef={platformDef} />
<Footer
connectResult={connectResult}
connecting={connecting}
form={form}
hasConfig={!!currentConfig}
provider={provider}
platformDef={platformDef}
saveResult={saveResult}
saving={saving}
testResult={testResult}
@ -232,7 +239,6 @@ const PlatformDetail = memo<PlatformDetailProps>(({ provider, agentId, currentCo
onDelete={handleDelete}
onSave={handleSave}
onTestConnection={handleTestConnection}
onToggleEnable={handleToggleEnable}
/>
</main>
);

View file

@ -1,43 +0,0 @@
import type { FormItemProps } from '@lobehub/ui';
import type { TFunction } from 'i18next';
import { FormInput, FormPassword } from '@/components/FormInput';
import type { ChannelProvider } from '../../const';
export const getDiscordFormItems = (
t: TFunction<'agent'>,
hasConfig: boolean,
provider: ChannelProvider,
): FormItemProps[] => [
{
children: <FormInput placeholder={t('channel.applicationIdPlaceholder')} />,
desc: t('channel.applicationIdHint'),
label: t('channel.applicationId'),
name: 'applicationId',
rules: [{ required: true }],
tag: provider.fieldTags.appId,
},
{
children: (
<FormPassword
autoComplete="new-password"
placeholder={
hasConfig ? t('channel.botTokenPlaceholderExisting') : t('channel.botTokenPlaceholderNew')
}
/>
),
desc: t('channel.botTokenEncryptedHint'),
label: t('channel.botToken'),
name: 'botToken',
rules: [{ required: true }],
tag: provider.fieldTags.token,
},
{
children: <FormInput placeholder={t('channel.publicKeyPlaceholder')} />,
desc: t('channel.publicKeyHint'),
label: t('channel.publicKey'),
name: 'publicKey',
tag: provider.fieldTags.publicKey,
},
];

View file

@ -1,50 +0,0 @@
import type { FormItemProps } from '@lobehub/ui';
import type { TFunction } from 'i18next';
import { FormInput, FormPassword } from '@/components/FormInput';
import type { ChannelProvider } from '../../const';
export const getFeishuFormItems = (
t: TFunction<'agent'>,
hasConfig: boolean,
provider: ChannelProvider,
): FormItemProps[] => [
{
children: <FormInput placeholder={t('channel.applicationIdPlaceholder')} />,
desc: t('channel.applicationIdHint'),
label: t('channel.applicationId'),
name: 'applicationId',
rules: [{ required: true }],
tag: provider.fieldTags.appId,
},
{
children: (
<FormPassword
autoComplete="new-password"
placeholder={
hasConfig ? t('channel.botTokenPlaceholderExisting') : t('channel.appSecretPlaceholder')
}
/>
),
desc: t('channel.botTokenEncryptedHint'),
label: t('channel.appSecret'),
name: 'appSecret',
rules: [{ required: true }],
tag: provider.fieldTags.appSecret,
},
{
children: <FormInput placeholder={t('channel.verificationTokenPlaceholder')} />,
desc: t('channel.verificationTokenHint'),
label: t('channel.verificationToken'),
name: 'verificationToken',
tag: provider.fieldTags.verificationToken,
},
{
children: <FormPassword placeholder={t('channel.encryptKeyPlaceholder')} />,
desc: t('channel.encryptKeyHint'),
label: t('channel.encryptKey'),
name: 'encryptKey',
tag: provider.fieldTags.encryptKey,
},
];

View file

@ -1,50 +0,0 @@
import type { FormItemProps } from '@lobehub/ui';
import type { TFunction } from 'i18next';
import { FormInput, FormPassword } from '@/components/FormInput';
import type { ChannelProvider } from '../../const';
export const getLarkFormItems = (
t: TFunction<'agent'>,
hasConfig: boolean,
provider: ChannelProvider,
): FormItemProps[] => [
{
children: <FormInput placeholder={t('channel.applicationIdPlaceholder')} />,
desc: t('channel.applicationIdHint'),
label: t('channel.applicationId'),
name: 'applicationId',
rules: [{ required: true }],
tag: provider.fieldTags.appId,
},
{
children: (
<FormPassword
autoComplete="new-password"
placeholder={
hasConfig ? t('channel.botTokenPlaceholderExisting') : t('channel.appSecretPlaceholder')
}
/>
),
desc: t('channel.botTokenEncryptedHint'),
label: t('channel.appSecret'),
name: 'appSecret',
rules: [{ required: true }],
tag: provider.fieldTags.appSecret,
},
{
children: <FormInput placeholder={t('channel.verificationTokenPlaceholder')} />,
desc: t('channel.verificationTokenHint'),
label: t('channel.verificationToken'),
name: 'verificationToken',
tag: provider.fieldTags.verificationToken,
},
{
children: <FormPassword placeholder={t('channel.encryptKeyPlaceholder')} />,
desc: t('channel.encryptKeyHint'),
label: t('channel.encryptKey'),
name: 'encryptKey',
tag: provider.fieldTags.encryptKey,
},
];

View file

@ -1,36 +0,0 @@
import type { FormItemProps } from '@lobehub/ui';
import type { TFunction } from 'i18next';
import { FormInput, FormPassword } from '@/components/FormInput';
import type { ChannelProvider } from '../../const';
export const getQQFormItems = (
t: TFunction<'agent'>,
hasConfig: boolean,
provider: ChannelProvider,
): FormItemProps[] => [
{
children: <FormInput placeholder={t('channel.applicationIdPlaceholder')} />,
desc: t('channel.qq.appIdHint'),
label: t('channel.applicationId'),
name: 'applicationId',
rules: [{ required: true }],
tag: provider.fieldTags.appId,
},
{
children: (
<FormPassword
autoComplete="new-password"
placeholder={
hasConfig ? t('channel.botTokenPlaceholderExisting') : t('channel.appSecretPlaceholder')
}
/>
),
desc: t('channel.botTokenEncryptedHint'),
label: t('channel.appSecret'),
name: 'appSecret',
rules: [{ required: true }],
tag: provider.fieldTags.appSecret,
},
];

View file

@ -1,46 +0,0 @@
import type { FormItemProps } from '@lobehub/ui';
import type { TFunction } from 'i18next';
import { FormInput, FormPassword } from '@/components/FormInput';
import type { ChannelProvider } from '../../const';
export const getTelegramFormItems = (
t: TFunction<'agent'>,
hasConfig: boolean,
provider: ChannelProvider,
): FormItemProps[] => [
{
children: (
<FormPassword
autoComplete="new-password"
placeholder={
hasConfig ? t('channel.botTokenPlaceholderExisting') : t('channel.botTokenPlaceholderNew')
}
/>
),
desc: t('channel.botTokenEncryptedHint'),
label: t('channel.botToken'),
name: 'botToken',
rules: [{ required: true }],
tag: provider.fieldTags.token,
},
{
children: <FormPassword placeholder={t('channel.secretTokenPlaceholder')} />,
desc: t('channel.secretTokenHint'),
label: t('channel.secretToken'),
name: 'secretToken',
tag: provider.fieldTags.secretToken,
},
...(process.env.NODE_ENV === 'development'
? ([
{
children: <FormInput placeholder="https://xxx.trycloudflare.com" />,
desc: t('channel.devWebhookProxyUrlHint'),
label: t('channel.devWebhookProxyUrl'),
name: 'webhookProxyUrl',
rules: [{ type: 'url' as const }],
},
] as FormItemProps[])
: []),
];

View file

@ -9,7 +9,6 @@ import Loading from '@/components/Loading/BrandTextLoading';
import NavHeader from '@/features/NavHeader';
import { useAgentStore } from '@/store/agent';
import { CHANNEL_PROVIDERS } from './const';
import PlatformDetail from './detail';
import PlatformList from './list';
@ -26,23 +25,33 @@ const styles = createStaticStyles(({ css }) => ({
const ChannelPage = memo(() => {
const { aid } = useParams<{ aid?: string }>();
const [activeProviderId, setActiveProviderId] = useState(CHANNEL_PROVIDERS[0].id);
const [activeProviderId, setActiveProviderId] = useState<string>('');
const { data: providers, isLoading } = useAgentStore((s) => s.useFetchBotProviders(aid));
const { data: platforms, isLoading: platformsLoading } = useAgentStore((s) =>
s.useFetchPlatformDefinitions(),
);
const { data: providers, isLoading: providersLoading } = useAgentStore((s) =>
s.useFetchBotProviders(aid),
);
const isLoading = platformsLoading || providersLoading;
// Default to first platform once loaded
const effectiveActiveId = activeProviderId || platforms?.[0]?.id || '';
const connectedPlatforms = useMemo(
() => new Set(providers?.map((p) => p.platform) ?? []),
() => new Set(providers?.filter((p) => p.enabled).map((p) => p.platform) ?? []),
[providers],
);
const activeProvider = useMemo(
() => CHANNEL_PROVIDERS.find((p) => p.id === activeProviderId) || CHANNEL_PROVIDERS[0],
[activeProviderId],
const activePlatformDef = useMemo(
() => platforms?.find((p) => p.id === effectiveActiveId) || platforms?.[0],
[platforms, effectiveActiveId],
);
const currentConfig = useMemo(
() => providers?.find((p) => p.platform === activeProviderId),
[providers, activeProviderId],
() => providers?.find((p) => p.platform === effectiveActiveId),
[providers, effectiveActiveId],
);
if (!aid) return null;
@ -53,15 +62,19 @@ const ChannelPage = memo(() => {
<Flexbox flex={1} style={{ overflowY: 'auto' }}>
{isLoading && <Loading debugId="ChannelPage" />}
{!isLoading && (
{!isLoading && platforms && platforms.length > 0 && activePlatformDef && (
<div className={styles.container}>
<PlatformList
activeId={activeProviderId}
activeId={effectiveActiveId}
connectedPlatforms={connectedPlatforms}
providers={CHANNEL_PROVIDERS}
platforms={platforms}
onSelect={setActiveProviderId}
/>
<PlatformDetail agentId={aid} currentConfig={currentConfig} provider={activeProvider} />
<PlatformDetail
agentId={aid}
currentConfig={currentConfig}
platformDef={activePlatformDef}
/>
</div>
)}
</Flexbox>

View file

@ -6,17 +6,11 @@ import { Info } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { ChannelProvider } from './const';
import type { SerializedPlatformDefinition } from '@/server/services/bot/platforms/types';
import { getPlatformIcon } from './const';
const styles = createStaticStyles(({ css, cssVar }) => ({
root: css`
display: flex;
flex-direction: column;
flex-shrink: 0;
width: 260px;
border-inline-end: 1px solid ${cssVar.colorBorder};
`,
item: css`
cursor: pointer;
@ -58,6 +52,14 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
padding: 12px;
padding-block-start: 16px;
`,
root: css`
display: flex;
flex-direction: column;
flex-shrink: 0;
width: 260px;
border-inline-end: 1px solid ${cssVar.colorBorder};
`,
statusDot: css`
width: 8px;
height: 8px;
@ -78,11 +80,11 @@ interface PlatformListProps {
activeId: string;
connectedPlatforms: Set<string>;
onSelect: (id: string) => void;
providers: ChannelProvider[];
platforms: SerializedPlatformDefinition[];
}
const PlatformList = memo<PlatformListProps>(
({ providers, activeId, connectedPlatforms, onSelect }) => {
({ platforms, activeId, connectedPlatforms, onSelect }) => {
const { t } = useTranslation('agent');
const theme = useTheme();
@ -90,18 +92,19 @@ const PlatformList = memo<PlatformListProps>(
<aside className={styles.root}>
<div className={styles.list}>
<div className={styles.title}>{t('channel.platforms')}</div>
{providers.map((provider) => {
const ProviderIcon = provider.icon;
const ColorIcon = 'Color' in ProviderIcon ? (ProviderIcon as any).Color : ProviderIcon;
{platforms.map((platform) => {
const PlatformIcon = getPlatformIcon(platform.name);
const ColorIcon =
PlatformIcon && 'Color' in PlatformIcon ? (PlatformIcon as any).Color : PlatformIcon;
return (
<button
className={cx(styles.item, activeId === provider.id && 'active')}
key={provider.id}
onClick={() => onSelect(provider.id)}
className={cx(styles.item, activeId === platform.id && 'active')}
key={platform.id}
onClick={() => onSelect(platform.id)}
>
<ColorIcon size={20} />
<span style={{ flex: 1 }}>{provider.name}</span>
{connectedPlatforms.has(provider.id) && <div className={styles.statusDot} />}
{ColorIcon && <ColorIcon size={20} />}
<span style={{ flex: 1 }}>{platform.name}</span>
{connectedPlatforms.has(platform.id) && <div className={styles.statusDot} />}
</button>
);
})}

View file

@ -5,6 +5,8 @@ import { AgentBotProviderModel } from '@/database/models/agentBotProvider';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { getBotMessageRouter } from '@/server/services/bot/BotMessageRouter';
import { platformRegistry } from '@/server/services/bot/platforms';
import { GatewayService } from '@/server/services/gateway';
const agentBotProviderProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
@ -19,6 +21,10 @@ const agentBotProviderProcedure = authedProcedure.use(serverDatabase).use(async
});
export const agentBotProviderRouter = router({
listPlatforms: authedProcedure.query(() => {
return platformRegistry.listSerializedPlatforms();
}),
create: agentBotProviderProcedure
.input(
z.object({
@ -27,6 +33,7 @@ export const agentBotProviderRouter = router({
credentials: z.record(z.string()),
enabled: z.boolean().optional(),
platform: z.string(),
settings: z.record(z.unknown()).optional(),
}),
)
.mutation(async ({ input, ctx }) => {
@ -46,7 +53,19 @@ export const agentBotProviderRouter = router({
delete: agentBotProviderProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
return ctx.agentBotProviderModel.delete(input.id);
// Load record before delete to get platform + applicationId
const existing = await ctx.agentBotProviderModel.findById(input.id);
const result = await ctx.agentBotProviderModel.delete(input.id);
// Stop running client and invalidate cached bot
if (existing) {
const service = new GatewayService();
await service.stopClient(existing.platform, existing.applicationId);
await getBotMessageRouter().invalidateBot(existing.platform, existing.applicationId);
}
return result;
}),
getByAgentId: agentBotProviderProcedure
@ -72,11 +91,51 @@ export const agentBotProviderRouter = router({
.input(z.object({ applicationId: z.string(), platform: z.string() }))
.mutation(async ({ input, ctx }) => {
const service = new GatewayService();
const status = await service.startBot(input.platform, input.applicationId, ctx.userId);
const status = await service.startClient(input.platform, input.applicationId, ctx.userId);
return { status };
}),
testConnection: agentBotProviderProcedure
.input(z.object({ applicationId: z.string(), platform: z.string() }))
.mutation(async ({ input, ctx }) => {
const { platform, applicationId } = input;
// Load provider from DB
const provider = await ctx.agentBotProviderModel.findEnabledByApplicationId(
platform,
applicationId,
);
if (!provider) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `No enabled bot found for ${platform}/${applicationId}`,
});
}
// Validate credentials against the platform API
const entry = platformRegistry.getPlatform(platform);
if (!entry) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `Unsupported platform: ${platform}` });
}
const result = await entry.clientFactory.validateCredentials(
provider.credentials,
(provider.settings as Record<string, unknown>) || {},
applicationId,
);
if (!result.valid) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
result.errors?.map((e) => `${e.field}: ${e.message}`).join('; ') || 'Validation failed',
});
}
return { valid: true };
}),
update: agentBotProviderProcedure
.input(
z.object({
@ -85,10 +144,22 @@ export const agentBotProviderRouter = router({
enabled: z.boolean().optional(),
id: z.string(),
platform: z.string().optional(),
settings: z.record(z.unknown()).optional(),
}),
)
.mutation(async ({ input, ctx }) => {
const { id, ...value } = input;
return ctx.agentBotProviderModel.update(id, value);
// Load existing record to get platform + applicationId for cache invalidation
const existing = await ctx.agentBotProviderModel.findById(id);
const result = await ctx.agentBotProviderModel.update(id, value);
// Invalidate cached bot so it reloads with fresh config on next webhook
if (existing) {
await getBotMessageRouter().invalidateBot(existing.platform, existing.applicationId);
}
return result;
}),
});

View file

@ -14,7 +14,8 @@ import { isQueueAgentRuntimeEnabled } from '@/server/services/queue/impls';
import { SystemAgentService } from '@/server/services/systemAgent';
import { formatPrompt as formatPromptUtil } from './formatPrompt';
import { getPlatformDescriptor } from './platforms';
import type { PlatformClient } from './platforms';
import { DEFAULT_DEBOUNCE_MS } from './platforms/const';
import {
renderError,
renderFinalReply,
@ -68,21 +69,6 @@ async function safeReaction(fn: () => Promise<void>, label: string): Promise<voi
}
}
/**
* Extract the parent channel thread ID for reacting to the original mention message.
* In Discord, when a thread is created on a message, that message still belongs to
* the parent channel. To add/remove reactions on it, we need to use the parent channel ID.
*
* e.g. "discord:guild:parentChannel:thread" "discord:guild:parentChannel"
*/
function parentChannelThreadId(threadId: string): string {
const parts = threadId.split(':');
if (parts.length >= 4 && parts[0] === 'discord') {
return `discord:${parts[1]}:${parts[2]}`;
}
return threadId;
}
interface DiscordChannelContext {
channel: { id: string; name?: string; topic?: string; type?: number };
guild: { id: string };
@ -96,6 +82,9 @@ interface ThreadState {
interface BridgeHandlerOpts {
agentId: string;
botContext?: ChatTopicBotContext;
charLimit?: number;
client?: PlatformClient;
debounceMs?: number;
}
/**
@ -113,6 +102,99 @@ export class AgentBridgeService {
private timezone: string | undefined;
private timezoneLoaded = false;
/**
* Tracks threads that have an active agent execution in progress.
* In queue mode the Chat SDK lock is released before the agent finishes,
* so we need our own guard to prevent duplicate executions on the same thread.
*/
private static activeThreads = new Set<string>();
/**
* Debounce buffer for incoming messages per thread.
* Users often send multiple short messages in quick succession (e.g. "hello" + "how are you").
* Instead of triggering separate agent executions for each, we collect messages arriving
* within a short window and merge them into a single prompt.
*/
private static pendingMessages = new Map<
string,
{
messages: Message[];
resolve: () => void;
timer: ReturnType<typeof setTimeout>;
}
>();
/**
* Buffer a message and return a promise that resolves when the debounce window closes.
* Returns the collected messages if this call "wins" the debounce (is the first),
* or null if the message was appended to an existing pending batch.
*
* Messages with attachments flush immediately (no debounce) to avoid delaying
* file-heavy interactions.
*/
private static bufferMessage(
threadId: string,
message: Message,
debounceMs: number,
): Promise<Message[] | null> {
// Flush immediately if the message has attachments
const hasAttachments = !!(message as any).attachments?.length;
const existing = AgentBridgeService.pendingMessages.get(threadId);
if (existing) {
// Append to existing batch and reset the timer
existing.messages.push(message);
clearTimeout(existing.timer);
if (hasAttachments) {
// Flush now
existing.resolve();
} else {
existing.timer = setTimeout(() => existing.resolve(), debounceMs);
}
return Promise.resolve(null); // not the owner
}
// First message — create a new batch
if (hasAttachments) {
return Promise.resolve([message]); // no debounce
}
return new Promise<Message[]>((resolve) => {
const batch = {
messages: [message],
resolve: () => {
const entry = AgentBridgeService.pendingMessages.get(threadId);
AgentBridgeService.pendingMessages.delete(threadId);
resolve(entry?.messages ?? [message]);
},
timer: setTimeout(() => {
const entry = AgentBridgeService.pendingMessages.get(threadId);
if (entry) entry.resolve();
}, debounceMs),
};
AgentBridgeService.pendingMessages.set(threadId, batch);
});
}
/**
* Merge multiple messages into a single synthetic Message for the agent.
* Preserves the first message's metadata (author, raw, attachments) and
* concatenates all text with newlines.
*/
private static mergeMessages(messages: Message[]): Message {
if (messages.length === 1) return messages[0];
const first = messages[0];
const mergedText = messages.map((m) => m.text).join('\n');
return Object.assign(Object.create(Object.getPrototypeOf(first)), first, {
text: mergedText,
});
}
constructor(db: LobeChatDatabase, userId: string) {
this.db = db;
this.userId = userId;
@ -126,7 +208,7 @@ export class AgentBridgeService {
message: Message,
opts: BridgeHandlerOpts,
): Promise<void> {
const { agentId, botContext } = opts;
const { agentId, botContext, charLimit, debounceMs } = opts;
log(
'handleMention: agentId=%s, user=%s, text=%s',
@ -135,23 +217,44 @@ export class AgentBridgeService {
message.text.slice(0, 80),
);
// Skip if there's already an active execution for this thread
if (AgentBridgeService.activeThreads.has(thread.id)) {
log('handleMention: skipping, thread=%s already has an active execution', thread.id);
return;
}
// Debounce: buffer rapid-fire messages and merge them into one prompt.
// The first caller wins and drives the execution; subsequent callers
// append their message to the buffer and return immediately.
const batch = await AgentBridgeService.bufferMessage(
thread.id,
message,
debounceMs ?? DEFAULT_DEBOUNCE_MS,
);
if (!batch) {
log('handleMention: message buffered for thread=%s, waiting for debounce', thread.id);
return;
}
const mergedMessage = AgentBridgeService.mergeMessages(batch);
log(
'handleMention: debounce done, %d message(s) merged for thread=%s',
batch.length,
thread.id,
);
AgentBridgeService.activeThreads.add(thread.id);
// Immediate feedback: mark as received + show typing
// The mention message lives in the parent channel (not the thread), so we strip
// the thread segment from the ID to target the parent channel for reactions.
const { client } = opts;
await safeReaction(
() =>
thread.adapter.addReaction(parentChannelThreadId(thread.id), message.id, RECEIVED_EMOJI),
() => thread.adapter.addReaction(thread.id, message.id, RECEIVED_EMOJI),
'add eyes',
);
// Only subscribe to actual Discord threads, not regular channels.
// Subscribing to a regular channel would cause the bot to respond to ALL messages in it.
// Discord thread ID format: "discord:guild:channel[:thread]" — the 4th segment is present
// only when the message is inside a Discord thread.
const isDiscordTopLevelChannel =
botContext?.platform === 'discord' &&
!(thread.adapter.decodeThreadId(thread.id) as { threadId?: string }).threadId;
if (!isDiscordTopLevelChannel) {
// Auto-subscribe to thread (platforms can opt out, e.g. Discord top-level channels)
const subscribe = client?.shouldSubscribe?.(thread.id) ?? true;
if (subscribe) {
await thread.subscribe();
}
@ -170,17 +273,18 @@ export class AgentBridgeService {
try {
// executeWithCallback handles progress message (post + edit at each step)
// The final reply is edited into the progress message by onComplete
const { topicId } = await this.executeWithCallback(thread, message, {
const { topicId } = await this.executeWithCallback(thread, mergedMessage, {
agentId,
botContext,
channelContext,
reactionThreadId: parentChannelThreadId(thread.id),
charLimit,
client,
trigger: RequestTrigger.Bot,
});
// Persist topic mapping and channel context in thread state for follow-up messages
// Skip for non-threaded Discord channels (no subscribe = no follow-up)
if (topicId && !isDiscordTopLevelChannel) {
// Skip if the platform opted out of auto-subscribe (no subscribe = no follow-up)
if (topicId && subscribe) {
await thread.setState({ channelContext, topicId });
log('handleMention: stored topicId=%s in thread=%s state', topicId, thread.id);
}
@ -189,11 +293,11 @@ export class AgentBridgeService {
const msg = error instanceof Error ? error.message : String(error);
await thread.post(`**Agent Execution Failed**\n\`\`\`\n${msg}\n\`\`\``);
} finally {
AgentBridgeService.activeThreads.delete(thread.id);
clearInterval(typingInterval);
// In queue mode, reaction is removed by the bot-callback webhook on completion
if (!queueMode) {
// Mention message is in parent channel
await this.removeReceivedReaction(thread, message, parentChannelThreadId(thread.id));
await this.removeReceivedReaction(thread, message);
}
}
}
@ -206,7 +310,7 @@ export class AgentBridgeService {
message: Message,
opts: BridgeHandlerOpts,
): Promise<void> {
const { agentId, botContext } = opts;
const { agentId, botContext, charLimit, debounceMs } = opts;
const threadState = await thread.state;
const topicId = threadState?.topicId;
@ -214,9 +318,38 @@ export class AgentBridgeService {
if (!topicId) {
log('handleSubscribedMessage: no topicId in thread state, treating as new mention');
return this.handleMention(thread, message, { agentId, botContext });
return this.handleMention(thread, message, opts);
}
// Skip if there's already an active execution for this thread
if (AgentBridgeService.activeThreads.has(thread.id)) {
log(
'handleSubscribedMessage: skipping, thread=%s already has an active execution',
thread.id,
);
return;
}
// Debounce: same as handleMention — merge rapid-fire messages
const batch = await AgentBridgeService.bufferMessage(
thread.id,
message,
debounceMs ?? DEFAULT_DEBOUNCE_MS,
);
if (!batch) {
log('handleSubscribedMessage: message buffered for thread=%s', thread.id);
return;
}
const mergedMessage = AgentBridgeService.mergeMessages(batch);
log(
'handleSubscribedMessage: debounce done, %d message(s) merged for thread=%s',
batch.length,
thread.id,
);
AgentBridgeService.activeThreads.add(thread.id);
// Read cached channel context from thread state
const channelContext = threadState?.channelContext;
@ -237,10 +370,12 @@ export class AgentBridgeService {
try {
// executeWithCallback handles progress message (post + edit at each step)
await this.executeWithCallback(thread, message, {
await this.executeWithCallback(thread, mergedMessage, {
agentId,
botContext,
channelContext,
charLimit,
client: opts.client,
topicId,
trigger: RequestTrigger.Bot,
});
@ -254,12 +389,13 @@ export class AgentBridgeService {
topicId,
);
await thread.setState({ ...threadState, topicId: undefined });
return this.handleMention(thread, message, { agentId, botContext });
return this.handleMention(thread, message, opts);
}
log('handleSubscribedMessage error: %O', error);
await thread.post(`**Agent Execution Failed**. Details:\n\`\`\`\n${errMsg}\n\`\`\``);
} finally {
AgentBridgeService.activeThreads.delete(thread.id);
clearInterval(typingInterval);
// In queue mode, reaction is removed by the bot-callback webhook on completion
if (!queueMode) {
@ -278,8 +414,8 @@ export class AgentBridgeService {
agentId: string;
botContext?: ChatTopicBotContext;
channelContext?: DiscordChannelContext;
/** Thread ID to use for removing the user message reaction in queue mode */
reactionThreadId?: string;
charLimit?: number;
client?: PlatformClient;
topicId?: string;
trigger?: string;
},
@ -302,12 +438,12 @@ export class AgentBridgeService {
agentId: string;
botContext?: ChatTopicBotContext;
channelContext?: DiscordChannelContext;
reactionThreadId?: string;
client?: PlatformClient;
topicId?: string;
trigger?: string;
},
): Promise<{ reply: string; topicId: string }> {
const { agentId, botContext, channelContext, reactionThreadId, topicId, trigger } = opts;
const { agentId, botContext, channelContext, client, topicId, trigger } = opts;
const aiAgentService = new AiAgentService(this.db, this.userId);
const timezone = await this.loadTimezone();
@ -337,20 +473,15 @@ export class AgentBridgeService {
}
const callbackUrl = urlJoin(baseURL, '/api/agent/webhooks/bot-callback');
// Shared webhook body with bot context
// reactionChannelId: the Discord channel where the user message lives (for reaction removal).
// For mention messages this is the parent channel; for thread messages it's the thread itself.
const reactionChannelId = reactionThreadId ? reactionThreadId.split(':')[2] : undefined;
const webhookBody = {
applicationId: botContext?.applicationId,
platformThreadId: botContext?.platformThreadId,
progressMessageId,
reactionChannelId,
userMessageId: userMessage.id,
};
const files = this.extractFiles(userMessage);
const prompt = this.formatPrompt(userMessage, botContext);
const prompt = this.formatPrompt(userMessage, client);
log(
'executeWithWebhooks: agentId=%s, callbackUrl=%s, progressMessageId=%s, prompt=%s, files=%d',
@ -399,11 +530,13 @@ export class AgentBridgeService {
agentId: string;
botContext?: ChatTopicBotContext;
channelContext?: DiscordChannelContext;
charLimit?: number;
client?: PlatformClient;
topicId?: string;
trigger?: string;
},
): Promise<{ reply: string; topicId: string }> {
const { agentId, botContext, channelContext, topicId, trigger } = opts;
const { agentId, botContext, channelContext, charLimit, client, topicId, trigger } = opts;
const aiAgentService = new AiAgentService(this.db, this.userId);
const timezone = await this.loadTimezone();
@ -416,8 +549,6 @@ export class AgentBridgeService {
log('executeWithInMemoryCallbacks: failed to post progress message: %O', error);
}
const platform = botContext?.platform;
// Track the last LLM content and tool calls for showing during tool execution
let lastLLMContent = '';
let lastToolsCalling:
@ -437,7 +568,7 @@ export class AgentBridgeService {
const getElapsedMs = () => (operationStartTime > 0 ? Date.now() - operationStartTime : 0);
const files = this.extractFiles(userMessage);
const prompt = this.formatPrompt(userMessage, botContext);
const prompt = this.formatPrompt(userMessage, client);
log(
'executeWithInMemoryCallbacks: agentId=%s, prompt=%s, files=%d',
@ -465,15 +596,21 @@ export class AgentBridgeService {
if (toolsCalling) totalToolCalls += toolsCalling.length;
const progressText = renderStepProgress({
const msgBody = renderStepProgress({
...stepData,
elapsedMs: getElapsedMs(),
lastContent: lastLLMContent,
lastToolsCalling,
platform,
totalToolCalls,
});
const stats = {
elapsedMs: getElapsedMs(),
totalCost: stepData.totalCost ?? 0,
totalTokens: stepData.totalTokens ?? 0,
};
const progressText = client?.formatReply?.(msgBody, stats) ?? msgBody;
if (content) lastLLMContent = content;
if (toolsCalling) lastToolsCalling = toolsCalling;
@ -512,17 +649,16 @@ export class AgentBridgeService {
)?.content;
if (lastAssistantContent) {
const finalText = renderFinalReply(lastAssistantContent, {
const replyBody = renderFinalReply(lastAssistantContent);
const replyStats = {
elapsedMs: getElapsedMs(),
llmCalls: finalState.usage?.llm?.apiCalls ?? 0,
platform,
toolCalls: finalState.usage?.tools?.totalCalls ?? 0,
totalCost: finalState.cost?.total ?? 0,
totalTokens: finalState.usage?.llm?.tokens?.total ?? 0,
});
};
const finalText = client?.formatReply?.(replyBody, replyStats) ?? replyBody;
const descriptor = platform ? getPlatformDescriptor(platform) : undefined;
const charLimit = descriptor?.charLimit;
const chunks = splitMessage(finalText, charLimit);
if (progressMessage) {
@ -708,8 +844,10 @@ export class AgentBridgeService {
* Format user message into agent prompt.
* Delegates to the standalone formatPrompt utility.
*/
private formatPrompt(message: Message, botContext?: ChatTopicBotContext): string {
return formatPromptUtil(message as any, botContext);
private formatPrompt(message: Message, client?: PlatformClient): string {
return formatPromptUtil(message as any, {
sanitizeUserInput: client?.sanitizeUserInput?.bind(client),
});
}
/**
@ -734,18 +872,13 @@ export class AgentBridgeService {
/**
* Remove the received reaction from a user message (fire-and-forget).
* @param reactionThreadId - The thread ID to use for the reaction API call.
* For messages in parent channels (handleMention), use parentChannelThreadId(thread.id).
* For messages inside threads (handleSubscribedMessage), use thread.id directly.
*/
private async removeReceivedReaction(
thread: Thread<ThreadState>,
message: Message,
reactionThreadId?: string,
): Promise<void> {
await safeReaction(
() =>
thread.adapter.removeReaction(reactionThreadId ?? thread.id, message.id, RECEIVED_EMOJI),
() => thread.adapter.removeReaction(thread.id, message.id, RECEIVED_EMOJI),
'remove eyes',
);
}

View file

@ -6,10 +6,9 @@ import { type LobeChatDatabase } from '@/database/type';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { SystemAgentService } from '@/server/services/systemAgent';
import { getPlatformDescriptor } from './platforms';
import { DiscordRestApi } from './platforms/discord';
import type { BotProviderConfig, PlatformClient, PlatformMessenger, UsageStats } from './platforms';
import { platformRegistry } from './platforms';
import { renderError, renderFinalReply, renderStepProgress, splitMessage } from './replyTemplate';
import type { PlatformMessenger } from './types';
const log = debug('lobe-server:bot:callback');
@ -29,7 +28,6 @@ export interface BotCallbackBody {
llmCalls?: number;
platformThreadId: string;
progressMessageId: string;
reactionChannelId?: string;
reason?: string;
reasoning?: string;
shouldContinue?: boolean;
@ -64,17 +62,23 @@ export class BotCallbackService {
const { type, applicationId, platformThreadId, progressMessageId } = body;
const platform = platformThreadId.split(':')[0];
const { botToken, messenger, charLimit } = await this.createMessenger(
const { client, messenger, charLimit } = await this.createMessenger(
platform,
applicationId,
platformThreadId,
);
const entry = platformRegistry.getPlatform(platform);
const canEdit = entry?.supportsMessageEdit !== false;
if (type === 'step') {
await this.handleStep(body, messenger, progressMessageId, platform);
// Skip step progress updates for platforms that can't edit messages
if (canEdit) {
await this.handleStep(body, messenger, progressMessageId, client);
}
} else if (type === 'completion') {
await this.handleCompletion(body, messenger, progressMessageId, platform, charLimit);
await this.removeEyesReaction(body, messenger, botToken, platform, platformThreadId);
await this.handleCompletion(body, messenger, progressMessageId, client, charLimit, canEdit);
await this.removeEyesReaction(body, messenger);
this.summarizeTopicTitle(body, messenger);
}
}
@ -83,7 +87,7 @@ export class BotCallbackService {
platform: string,
applicationId: string,
platformThreadId: string,
): Promise<{ botToken: string; charLimit?: number; messenger: PlatformMessenger }> {
): Promise<{ charLimit?: number; messenger: PlatformMessenger; client: PlatformClient }> {
const row = await AgentBotProviderModel.findByPlatformAndAppId(
this.db,
platform,
@ -102,38 +106,41 @@ export class BotCallbackService {
credentials = JSON.parse(row.credentials);
}
const descriptor = getPlatformDescriptor(platform);
if (!descriptor) {
const entry = platformRegistry.getPlatform(platform);
if (!entry) {
throw new Error(`Unsupported platform: ${platform}`);
}
const missingCreds = descriptor.requiredCredentials.filter((k) => !credentials[k]);
if (missingCreds.length > 0) {
throw new Error(`Bot credentials incomplete for ${platform} appId=${applicationId}`);
}
const settings = (row as any).settings as Record<string, unknown> | undefined;
const charLimit = (settings?.charLimit as number) || undefined;
return {
botToken: credentials.botToken || credentials.appId,
charLimit: descriptor.charLimit,
messenger: descriptor.createMessenger(credentials, platformThreadId),
const config: BotProviderConfig = {
applicationId,
credentials,
platform,
settings: settings || {},
};
const client = entry.clientFactory.createClient(config, {});
const messenger = client.getMessenger(platformThreadId);
return { charLimit, messenger, client };
}
private async handleStep(
body: BotCallbackBody,
messenger: PlatformMessenger,
progressMessageId: string,
platform: string,
client: PlatformClient,
): Promise<void> {
if (!body.shouldContinue) return;
const progressText = renderStepProgress({
const msgBody = renderStepProgress({
content: body.content,
elapsedMs: body.elapsedMs,
executionTimeMs: body.executionTimeMs ?? 0,
lastContent: body.lastLLMContent,
lastToolsCalling: body.lastToolsCalling,
platform,
reasoning: body.reasoning,
stepType: body.stepType ?? ('call_llm' as const),
thinking: body.thinking ?? false,
@ -147,6 +154,14 @@ export class BotCallbackService {
totalToolCalls: body.totalToolCalls,
});
const stats: UsageStats = {
elapsedMs: body.elapsedMs,
totalCost: body.totalCost ?? 0,
totalTokens: body.totalTokens ?? 0,
};
const progressText = client.formatReply?.(msgBody, stats) ?? msgBody;
const isLlmFinalResponse =
body.stepType === 'call_llm' && !body.toolsCalling?.length && body.content;
@ -164,17 +179,22 @@ export class BotCallbackService {
body: BotCallbackBody,
messenger: PlatformMessenger,
progressMessageId: string,
platform: string,
client: PlatformClient,
charLimit?: number,
canEdit = true,
): Promise<void> {
const { reason, lastAssistantContent, errorMessage } = body;
if (reason === 'error') {
const errorText = renderError(errorMessage || 'Agent execution failed');
try {
await messenger.editMessage(progressMessageId, errorText);
if (canEdit) {
await messenger.editMessage(progressMessageId, errorText);
} else {
await messenger.createMessage(errorText);
}
} catch (error) {
log('handleCompletion: failed to edit error message: %O', error);
log('handleCompletion: failed to send error message: %O', error);
}
return;
}
@ -184,47 +204,45 @@ export class BotCallbackService {
return;
}
const finalText = renderFinalReply(lastAssistantContent, {
const msgBody = renderFinalReply(lastAssistantContent);
const stats: UsageStats = {
elapsedMs: body.duration,
llmCalls: body.llmCalls ?? 0,
platform,
toolCalls: body.toolCalls ?? 0,
totalCost: body.cost ?? 0,
totalTokens: body.totalTokens ?? 0,
});
};
const finalText = client.formatReply?.(msgBody, stats) ?? msgBody;
const chunks = splitMessage(finalText, charLimit);
try {
await messenger.editMessage(progressMessageId, chunks[0]);
for (let i = 1; i < chunks.length; i++) {
await messenger.createMessage(chunks[i]);
if (canEdit) {
await messenger.editMessage(progressMessageId, chunks[0]);
for (let i = 1; i < chunks.length; i++) {
await messenger.createMessage(chunks[i]);
}
} else {
// Platform doesn't support edit — send all chunks as new messages
for (const chunk of chunks) {
await messenger.createMessage(chunk);
}
}
} catch (error) {
log('handleCompletion: failed to edit/post final message: %O', error);
log('handleCompletion: failed to send final message: %O', error);
}
}
private async removeEyesReaction(
body: BotCallbackBody,
messenger: PlatformMessenger,
botToken: string,
platform: string,
platformThreadId: string,
): Promise<void> {
const { userMessageId, reactionChannelId } = body;
const { userMessageId } = body;
if (!userMessageId) return;
try {
if (platform === 'discord') {
// Use reactionChannelId (parent channel for mentions, thread for follow-ups)
const descriptor = getPlatformDescriptor(platform)!;
const discord = new DiscordRestApi(botToken);
const targetChannelId = reactionChannelId || descriptor.extractChatId(platformThreadId);
await discord.removeOwnReaction(targetChannelId, userMessageId, '👀');
} else {
await messenger.removeReaction(userMessageId, '👀');
}
await messenger.removeReaction(userMessageId, '👀');
} catch (error) {
log('removeEyesReaction: failed: %O', error);
}

View file

@ -3,13 +3,21 @@ import { Chat, ConsoleLogger } from 'chat';
import debug from 'debug';
import { getServerDB } from '@/database/core/db-adaptor';
import type { DecryptedBotProvider } from '@/database/models/agentBotProvider';
import { AgentBotProviderModel } from '@/database/models/agentBotProvider';
import type { LobeChatDatabase } from '@/database/type';
import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { AgentBridgeService } from './AgentBridgeService';
import { getPlatformDescriptor, platformDescriptors } from './platforms';
import {
type BotPlatformRuntimeContext,
type BotProviderConfig,
buildRuntimeKey,
type PlatformClient,
type PlatformDefinition,
platformRegistry,
} from './platforms';
const log = debug('lobe-server:bot:message-router');
@ -18,283 +26,235 @@ interface ResolvedAgentInfo {
userId: string;
}
interface StoredCredentials {
[key: string]: string;
interface RegisteredBot {
agentInfo: ResolvedAgentInfo;
chatBot: Chat<any>;
client: PlatformClient;
}
/**
* Routes incoming webhook events to the correct Chat SDK Bot instance
* and triggers message processing via AgentBridgeService.
*
* All platforms require appId in the webhook URL:
* POST /api/agent/webhooks/[platform]/[appId]
*
* Bots are loaded on-demand: only the bot targeted by the incoming webhook
* is created, not all bots across all platforms.
*/
export class BotMessageRouter {
/** botToken → Chat instance (for Discord webhook routing via x-discord-gateway-token) */
private botInstancesByToken = new Map<string, Chat<any>>();
/** "platform:applicationId" → registered bot */
private bots = new Map<string, RegisteredBot>();
/** "platform:applicationId" → { agentId, userId } */
private agentMap = new Map<string, ResolvedAgentInfo>();
/** "platform:applicationId" → Chat instance */
private botInstances = new Map<string, Chat<any>>();
/** "platform:applicationId" → credentials */
private credentialsByKey = new Map<string, StoredCredentials>();
/** Per-key init promises to avoid duplicate concurrent loading */
private loadingPromises = new Map<string, Promise<RegisteredBot | null>>();
// ------------------------------------------------------------------
// Public API
// ------------------------------------------------------------------
/**
* Get the webhook handler for a given platform.
* Get the webhook handler for a given platform + appId.
* Returns a function compatible with Next.js Route Handler: `(req: Request) => Promise<Response>`
*
* @param appId Optional application ID for direct bot lookup (e.g. Telegram bot-specific endpoints).
*/
getWebhookHandler(platform: string, appId?: string): (req: Request) => Promise<Response> {
return async (req: Request) => {
await this.ensureInitialized();
const descriptor = getPlatformDescriptor(platform);
if (!descriptor) {
const entry = platformRegistry.getPlatform(platform);
if (!entry) {
return new Response('No bot configured for this platform', { status: 404 });
}
// Discord has special routing via gateway token header and interaction payloads
if (platform === 'discord') {
return this.handleDiscordWebhook(req);
if (!appId) {
return new Response(`Missing appId for ${platform} webhook`, { status: 400 });
}
// All other platforms use direct lookup by appId with fallback iteration
return this.handleGenericWebhook(req, platform, appId);
return this.handleWebhook(req, platform, appId);
};
}
// ------------------------------------------------------------------
// Discord webhook routing (special: gateway token + interaction payload)
// ------------------------------------------------------------------
/**
* Invalidate a cached bot so it gets reloaded with fresh config on next webhook.
* Call this after settings or credentials are updated.
*/
async invalidateBot(platform: string, appId: string): Promise<void> {
const key = buildRuntimeKey(platform, appId);
const existing = this.bots.get(key);
if (!existing) return;
private async handleDiscordWebhook(req: Request): Promise<Response> {
const bodyBuffer = await req.arrayBuffer();
log('handleDiscordWebhook: method=%s, content-length=%d', req.method, bodyBuffer.byteLength);
// Check for forwarded Gateway event (from Gateway worker)
const gatewayToken = req.headers.get('x-discord-gateway-token');
if (gatewayToken) {
// Log forwarded event details
try {
const bodyText = new TextDecoder().decode(bodyBuffer);
const event = JSON.parse(bodyText);
if (event.type === 'GATEWAY_MESSAGE_CREATE') {
const d = event.data;
const mentions = d?.mentions?.map((m: any) => m.username).join(', ');
log(
'Gateway MESSAGE_CREATE: author=%s (bot=%s), mentions=[%s], content=%s',
d?.author?.username,
d?.author?.bot,
mentions || '',
d?.content?.slice(0, 100),
);
}
} catch {
// ignore parse errors
}
const bot = this.botInstancesByToken.get(gatewayToken);
if (bot?.webhooks && 'discord' in bot.webhooks) {
return bot.webhooks.discord(this.cloneRequest(req, bodyBuffer));
}
log('No matching bot for gateway token');
return new Response('No matching bot for gateway token', { status: 404 });
}
// HTTP Interactions — route by applicationId in the interaction payload
try {
const bodyText = new TextDecoder().decode(bodyBuffer);
const payload = JSON.parse(bodyText);
const appId = payload.application_id;
if (appId) {
const bot = this.botInstances.get(`discord:${appId}`);
if (bot?.webhooks && 'discord' in bot.webhooks) {
return bot.webhooks.discord(this.cloneRequest(req, bodyBuffer));
}
}
} catch {
// Not valid JSON — fall through
}
// Fallback: try all registered Discord bots
for (const [key, bot] of this.botInstances) {
if (!key.startsWith('discord:')) continue;
if (bot.webhooks && 'discord' in bot.webhooks) {
try {
const resp = await bot.webhooks.discord(this.cloneRequest(req, bodyBuffer));
if (resp.status !== 401) return resp;
} catch {
// signature mismatch — try next
}
}
}
return new Response('No bot configured for Discord', { status: 404 });
log('invalidateBot: removing cached bot %s', key);
this.bots.delete(key);
}
// ------------------------------------------------------------------
// Generic webhook routing (Telegram, Lark, Feishu, and future platforms)
// Webhook handling
// ------------------------------------------------------------------
private async handleGenericWebhook(
req: Request,
platform: string,
appId?: string,
): Promise<Response> {
log('handleGenericWebhook: platform=%s, appId=%s', platform, appId);
private async handleWebhook(req: Request, platform: string, appId: string): Promise<Response> {
log('handleWebhook: 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('handleGenericWebhook: no bot registered for %s', key);
const bot = await this.getOrCreateBot(platform, appId);
if (!bot) {
return new Response(`No bot configured for ${platform}`, { status: 404 });
}
// Fallback: try all registered bots for this platform
for (const [key, bot] of this.botInstances) {
if (!key.startsWith(`${platform}:`)) continue;
if (bot.webhooks && platform in bot.webhooks) {
try {
const resp = await (bot.webhooks as any)[platform](this.cloneRequest(req, bodyBuffer));
if (resp.status !== 401) return resp;
} catch {
// try next
}
}
if (bot.chatBot.webhooks && platform in bot.chatBot.webhooks) {
return (bot.chatBot.webhooks as any)[platform](req);
}
return new Response(`No bot configured for ${platform}`, { status: 404 });
}
private cloneRequest(req: Request, body: ArrayBuffer): Request {
return new Request(req.url, {
body,
headers: req.headers,
method: req.method,
});
}
// ------------------------------------------------------------------
// Initialisation
// On-demand bot loading
// ------------------------------------------------------------------
private static REFRESH_INTERVAL_MS = 5 * 60_000;
/**
* Get an existing bot or create one on-demand from DB.
* Concurrent calls for the same key are deduplicated.
*/
private async getOrCreateBot(platform: string, appId: string): Promise<RegisteredBot | null> {
const key = buildRuntimeKey(platform, appId);
private initPromise: Promise<void> | null = null;
private lastLoadedAt = 0;
private refreshPromise: Promise<void> | null = null;
// Return cached bot
const existing = this.bots.get(key);
if (existing) return existing;
private async ensureInitialized(): Promise<void> {
if (!this.initPromise) {
this.initPromise = this.initialize();
}
await this.initPromise;
// Deduplicate concurrent loads for the same key
const inflight = this.loadingPromises.get(key);
if (inflight) return inflight;
// Periodically refresh bot mappings in the background so newly added bots are discovered
if (
Date.now() - this.lastLoadedAt > BotMessageRouter.REFRESH_INTERVAL_MS &&
!this.refreshPromise
) {
this.refreshPromise = this.loadAgentBots().finally(() => {
this.refreshPromise = null;
});
}
}
const promise = this.loadBot(platform, appId);
this.loadingPromises.set(key, promise);
async initialize(): Promise<void> {
log('Initializing BotMessageRouter');
await this.loadAgentBots();
log('Initialized: %d agent bots', this.botInstances.size);
}
// ------------------------------------------------------------------
// Per-agent bots from DB
// ------------------------------------------------------------------
private async loadAgentBots(): Promise<void> {
try {
return await promise;
} finally {
this.loadingPromises.delete(key);
}
}
private async loadBot(platform: string, appId: string): Promise<RegisteredBot | null> {
const key = buildRuntimeKey(platform, appId);
try {
const entry = platformRegistry.getPlatform(platform);
if (!entry) {
log('No definition for platform: %s', platform);
return null;
}
const serverDB = await getServerDB();
const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey();
// Load all supported platforms from the descriptor registry
for (const platform of Object.keys(platformDescriptors)) {
const providers = await AgentBotProviderModel.findEnabledByPlatform(
serverDB,
platform,
gateKeeper,
);
// Find the specific provider — search across all users
const providers = await AgentBotProviderModel.findEnabledByPlatform(
serverDB,
platform,
gateKeeper,
);
const provider = providers.find((p) => p.applicationId === appId);
log('Found %d %s bot providers in DB', providers.length, platform);
for (const provider of providers) {
const { agentId, userId, applicationId, credentials } = provider;
const key = `${platform}:${applicationId}`;
if (this.agentMap.has(key)) {
log('Skipping provider %s: already registered', key);
continue;
}
const descriptor = getPlatformDescriptor(platform);
if (!descriptor) {
log('Unsupported platform: %s', platform);
continue;
}
const adapters = descriptor.createAdapter(credentials, applicationId);
const bot = this.createBot(adapters, `agent-${agentId}`);
this.registerHandlers(bot, serverDB, {
agentId,
applicationId,
platform,
userId,
});
await bot.initialize();
this.botInstances.set(key, bot);
this.agentMap.set(key, { agentId, userId });
this.credentialsByKey.set(key, credentials);
// Platform-specific post-registration hook
await descriptor.onBotRegistered?.({
applicationId,
credentials,
registerByToken: (token: string) => this.botInstancesByToken.set(token, bot),
});
log('Created %s bot for agent=%s, appId=%s', platform, agentId, applicationId);
}
if (!provider) {
log('No enabled provider found for %s', key);
return null;
}
this.lastLoadedAt = Date.now();
const registered = await this.createAndRegisterBot(entry, provider, serverDB);
log('Created %s bot on-demand for agent=%s, appId=%s', platform, provider.agentId, appId);
return registered;
} catch (error) {
log('Failed to load agent bots: %O', error);
log('Failed to load bot %s: %O', key, error);
return null;
}
}
private async createAndRegisterBot(
entry: PlatformDefinition,
provider: DecryptedBotProvider,
serverDB: LobeChatDatabase,
): Promise<RegisteredBot> {
const { agentId, userId, applicationId, credentials } = provider;
const platform = entry.id;
const key = buildRuntimeKey(platform, applicationId);
const providerConfig: BotProviderConfig = {
applicationId,
credentials,
platform,
settings: (provider.settings as Record<string, unknown>) || {},
};
const runtimeContext: BotPlatformRuntimeContext = {
appUrl: process.env.APP_URL,
redisClient: getAgentRuntimeRedisClient() as any,
};
const client = entry.clientFactory.createClient(providerConfig, runtimeContext);
const adapters = client.createAdapter();
const chatBot = this.createChatBot(adapters, `agent-${agentId}`);
this.registerHandlers(chatBot, serverDB, client, {
agentId,
applicationId,
platform,
settings: provider.settings as Record<string, any> | undefined,
userId,
});
await chatBot.initialize();
const registered: RegisteredBot = {
agentInfo: { agentId, userId },
chatBot,
client,
};
this.bots.set(key, registered);
return registered;
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
private createBot(adapters: Record<string, any>, label: string): Chat<any> {
/**
* A proxy around the shared Redis client that suppresses duplicate `on('error', ...)`
* registrations. Each `createIoRedisState()` call adds an error listener to the client;
* with many bot instances sharing one client this would trigger
* MaxListenersExceededWarning. The proxy lets the first error listener through and
* silently drops subsequent ones, so it scales to any number of bots.
*/
private sharedRedisProxy: ReturnType<typeof getAgentRuntimeRedisClient> | undefined;
private getSharedRedisProxy() {
if (this.sharedRedisProxy !== undefined) return this.sharedRedisProxy;
const redisClient = getAgentRuntimeRedisClient();
if (!redisClient) {
this.sharedRedisProxy = null;
return null;
}
let errorListenerRegistered = false;
this.sharedRedisProxy = new Proxy(redisClient, {
get(target, prop, receiver) {
if (prop === 'on') {
return (event: string, listener: (...args: any[]) => void) => {
if (event === 'error') {
if (errorListenerRegistered) return target;
errorListenerRegistered = true;
}
return target.on(event, listener);
};
}
return Reflect.get(target, prop, receiver);
},
});
return this.sharedRedisProxy;
}
private createChatBot(adapters: Record<string, any>, label: string): Chat<any> {
const config: any = {
adapters,
userName: `lobehub-bot-${label}`,
@ -315,10 +275,17 @@ export class BotMessageRouter {
private registerHandlers(
bot: Chat<any>,
serverDB: LobeChatDatabase,
info: ResolvedAgentInfo & { applicationId: string; platform: string },
client: PlatformClient,
info: ResolvedAgentInfo & {
applicationId: string;
platform: string;
settings?: Record<string, any>;
},
): void {
const { agentId, applicationId, platform, userId } = info;
const bridge = new AgentBridgeService(serverDB, userId);
const charLimit = (info.settings?.charLimit as number) || undefined;
const debounceMs = (info.settings?.debounceMs as number) || undefined;
bot.onNewMention(async (thread, message) => {
log(
@ -331,6 +298,9 @@ export class BotMessageRouter {
await bridge.handleMention(thread, message, {
agentId,
botContext: { applicationId, platform, platformThreadId: thread.id },
charLimit,
client,
debounceMs,
});
});
@ -348,12 +318,15 @@ export class BotMessageRouter {
await bridge.handleSubscribedMessage(thread, message, {
agentId,
botContext: { applicationId, platform, platformThreadId: thread.id },
charLimit,
client,
debounceMs,
});
});
// Register onNewMessage handler based on platform descriptor
const descriptor = getPlatformDescriptor(platform);
if (descriptor?.handleDirectMessages) {
// Register onNewMessage handler based on platform config
const dmEnabled = info.settings?.dm?.enabled ?? false;
if (dmEnabled) {
bot.onNewMessage(/./, async (thread, message) => {
if (message.author.isBot === true) return;
@ -369,6 +342,9 @@ export class BotMessageRouter {
await bridge.handleMention(thread, message, {
agentId,
botContext: { applicationId, platform, platformThreadId: thread.id },
charLimit,
client,
debounceMs,
});
});
}

View file

@ -1,6 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
// ==================== Import after mocks ====================
import type { BotCallbackBody } from '../BotCallbackService';
import { BotCallbackService } from '../BotCallbackService';
@ -13,18 +12,36 @@ const mockFindById = vi.hoisted(() => vi.fn());
const mockTopicUpdate = vi.hoisted(() => vi.fn());
const mockGenerateTopicTitle = vi.hoisted(() => vi.fn());
// Discord REST mock methods
const mockDiscordEditMessage = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockDiscordTriggerTyping = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockDiscordRemoveOwnReaction = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockDiscordCreateMessage = vi.hoisted(() => vi.fn().mockResolvedValue({ id: 'new-msg' }));
const mockDiscordUpdateChannelName = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
// Unified messenger mock methods (used by all platforms via PlatformClient)
const mockEditMessage = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockTriggerTyping = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockRemoveReaction = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockCreateMessage = vi.hoisted(() => vi.fn().mockResolvedValue({ id: 'new-msg' }));
const mockUpdateThreadName = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
// Telegram REST mock methods
const mockTelegramSendMessage = vi.hoisted(() => vi.fn().mockResolvedValue({ message_id: 12345 }));
const mockTelegramEditMessageText = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockTelegramRemoveMessageReaction = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockTelegramSendChatAction = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
// Mock PlatformClient's getMessenger
const mockGetMessenger = vi.hoisted(() =>
vi.fn().mockImplementation(() => ({
createMessage: mockCreateMessage,
editMessage: mockEditMessage,
removeReaction: mockRemoveReaction,
triggerTyping: mockTriggerTyping,
updateThreadName: mockUpdateThreadName,
})),
);
const mockCreateBot = vi.hoisted(() =>
vi.fn().mockImplementation(() => ({
applicationId: 'mock-app',
createAdapter: () => ({}),
extractChatId: (id: string) => id,
getMessenger: mockGetMessenger,
parseMessageId: (id: string) => id,
id: 'mock',
start: vi.fn(),
stop: vi.fn(),
})),
);
// ==================== vi.mock ====================
@ -53,23 +70,18 @@ vi.mock('@/server/services/systemAgent', () => ({
})),
}));
vi.mock('../platforms/discord/restApi', () => ({
DiscordRestApi: vi.fn().mockImplementation(() => ({
createMessage: mockDiscordCreateMessage,
editMessage: mockDiscordEditMessage,
removeOwnReaction: mockDiscordRemoveOwnReaction,
triggerTyping: mockDiscordTriggerTyping,
updateChannelName: mockDiscordUpdateChannelName,
})),
}));
vi.mock('../platforms/telegram/restApi', () => ({
TelegramRestApi: vi.fn().mockImplementation(() => ({
editMessageText: mockTelegramEditMessageText,
removeMessageReaction: mockTelegramRemoveMessageReaction,
sendChatAction: mockTelegramSendChatAction,
sendMessage: mockTelegramSendMessage,
})),
vi.mock('../platforms', () => ({
platformRegistry: {
getPlatform: vi.fn().mockImplementation((platform: string) => {
if (platform === 'unknown') return undefined;
return {
clientFactory: { createClient: mockCreateBot },
credentials: [],
name: platform,
id: platform,
};
}),
},
}));
// ==================== Helpers ====================
@ -78,8 +90,8 @@ const FAKE_DB = {} as any;
const FAKE_BOT_TOKEN = 'fake-bot-token-123';
const FAKE_CREDENTIALS = JSON.stringify({ botToken: FAKE_BOT_TOKEN });
function setupCredentials(credentials = FAKE_CREDENTIALS) {
mockFindByPlatformAndAppId.mockResolvedValue({ credentials });
function setupCredentials(credentials = FAKE_CREDENTIALS, extra?: Record<string, unknown>) {
mockFindByPlatformAndAppId.mockResolvedValue({ credentials, ...extra });
mockInitWithEnvKey.mockResolvedValue({ decrypt: mockDecrypt });
mockDecrypt.mockResolvedValue({ plaintext: credentials });
}
@ -110,6 +122,15 @@ describe('BotCallbackService', () => {
vi.clearAllMocks();
service = new BotCallbackService(FAKE_DB);
setupCredentials();
// Default: getMessenger returns the main messenger mock
mockGetMessenger.mockImplementation(() => ({
createMessage: mockCreateMessage,
editMessage: mockEditMessage,
removeReaction: mockRemoveReaction,
triggerTyping: mockTriggerTyping,
updateThreadName: mockUpdateThreadName,
}));
});
// ==================== Platform detection ====================
@ -153,17 +174,6 @@ describe('BotCallbackService', () => {
);
});
it('should throw when credentials have no botToken', async () => {
const noTokenCreds = JSON.stringify({ someOtherKey: 'value' });
setupCredentials(noTokenCreds);
const body = makeBody({ type: 'step' });
await expect(service.handleCallback(body)).rejects.toThrow(
'Bot credentials incomplete for discord appId=app-123',
);
});
it('should fall back to raw credentials when decryption fails', async () => {
mockFindByPlatformAndAppId.mockResolvedValue({ credentials: FAKE_CREDENTIALS });
mockInitWithEnvKey.mockResolvedValue({
@ -179,7 +189,7 @@ describe('BotCallbackService', () => {
// Should not throw because it falls back to raw JSON parse
await service.handleCallback(body);
expect(mockDiscordEditMessage).toHaveBeenCalled();
expect(mockEditMessage).toHaveBeenCalled();
});
});
@ -196,11 +206,7 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
expect(mockDiscordEditMessage).toHaveBeenCalledWith(
'channel-id',
'progress-msg-1',
expect.any(String),
);
expect(mockEditMessage).toHaveBeenCalledWith('progress-msg-1', expect.any(String));
});
it('should route completion type to handleCompletion', async () => {
@ -212,8 +218,7 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
expect(mockDiscordEditMessage).toHaveBeenCalledWith(
'channel-id',
expect(mockEditMessage).toHaveBeenCalledWith(
'progress-msg-1',
expect.stringContaining('Here is the answer.'),
);
@ -231,7 +236,7 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
expect(mockDiscordEditMessage).not.toHaveBeenCalled();
expect(mockEditMessage).not.toHaveBeenCalled();
});
it('should edit progress message and trigger typing for non-final LLM step', async () => {
@ -245,8 +250,8 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
expect(mockDiscordEditMessage).toHaveBeenCalledTimes(1);
expect(mockDiscordTriggerTyping).toHaveBeenCalledTimes(1);
expect(mockEditMessage).toHaveBeenCalledTimes(1);
expect(mockTriggerTyping).toHaveBeenCalledTimes(1);
});
it('should NOT trigger typing for final LLM response (no tool calls + has content)', async () => {
@ -260,8 +265,8 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
expect(mockDiscordEditMessage).toHaveBeenCalledTimes(1);
expect(mockDiscordTriggerTyping).not.toHaveBeenCalled();
expect(mockEditMessage).toHaveBeenCalledTimes(1);
expect(mockTriggerTyping).not.toHaveBeenCalled();
});
it('should handle tool step type', async () => {
@ -275,12 +280,12 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
expect(mockDiscordEditMessage).toHaveBeenCalledTimes(1);
expect(mockDiscordTriggerTyping).toHaveBeenCalledTimes(1);
expect(mockEditMessage).toHaveBeenCalledTimes(1);
expect(mockTriggerTyping).toHaveBeenCalledTimes(1);
});
it('should not throw when edit message fails during step', async () => {
mockDiscordEditMessage.mockRejectedValueOnce(new Error('Discord API error'));
mockEditMessage.mockRejectedValueOnce(new Error('API error'));
const body = makeBody({
content: 'Processing...',
@ -306,8 +311,7 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
expect(mockDiscordEditMessage).toHaveBeenCalledWith(
'channel-id',
expect(mockEditMessage).toHaveBeenCalledWith(
'progress-msg-1',
expect.stringContaining('Model quota exceeded'),
);
@ -321,8 +325,7 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
expect(mockDiscordEditMessage).toHaveBeenCalledWith(
'channel-id',
expect(mockEditMessage).toHaveBeenCalledWith(
'progress-msg-1',
expect.stringContaining('Agent execution failed'),
);
@ -336,7 +339,7 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
expect(mockDiscordEditMessage).not.toHaveBeenCalled();
expect(mockEditMessage).not.toHaveBeenCalled();
});
it('should edit progress message with final reply content', async () => {
@ -353,15 +356,14 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
expect(mockDiscordEditMessage).toHaveBeenCalledWith(
'channel-id',
expect(mockEditMessage).toHaveBeenCalledWith(
'progress-msg-1',
expect.stringContaining('The answer is 42.'),
);
});
it('should not throw when editing completion message fails', async () => {
mockDiscordEditMessage.mockRejectedValueOnce(new Error('Edit failed'));
mockEditMessage.mockRejectedValueOnce(new Error('Edit failed'));
const body = makeBody({
lastAssistantContent: 'Some response',
@ -376,8 +378,7 @@ describe('BotCallbackService', () => {
// ==================== Message splitting ====================
describe('message splitting', () => {
it('should split long Discord messages into multiple chunks', async () => {
// Default Discord limit is 1800 chars (from splitMessage default)
it('should split long messages into multiple chunks', async () => {
const longContent = 'A'.repeat(3000);
const body = makeBody({
@ -389,12 +390,14 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
// First chunk via editMessage, additional chunks via createMessage
expect(mockDiscordEditMessage).toHaveBeenCalledTimes(1);
expect(mockDiscordCreateMessage).toHaveBeenCalled();
expect(mockEditMessage).toHaveBeenCalledTimes(1);
expect(mockCreateMessage).toHaveBeenCalled();
});
it('should use Telegram char limit (4000) for Telegram platform', async () => {
// Content just over default 1800 but under 4000 should NOT split for Telegram
it('should use custom charLimit from provider settings', async () => {
setupCredentials(FAKE_CREDENTIALS, { settings: { charLimit: 4000 } });
// Content just over default 1800 but under 4000 should NOT split
const mediumContent = 'B'.repeat(2500);
const body = makeTelegramBody({
@ -406,11 +409,12 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
// Should be single message (4000 limit), so only editMessage
expect(mockTelegramEditMessageText).toHaveBeenCalledTimes(1);
expect(mockTelegramSendMessage).not.toHaveBeenCalled();
expect(mockEditMessage).toHaveBeenCalledTimes(1);
expect(mockCreateMessage).not.toHaveBeenCalled();
});
it('should split Telegram messages that exceed 4000 chars', async () => {
it('should split messages that exceed custom charLimit', async () => {
setupCredentials(FAKE_CREDENTIALS, { settings: { charLimit: 4000 } });
const longContent = 'C'.repeat(6000);
const body = makeTelegramBody({
@ -421,15 +425,15 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
expect(mockTelegramEditMessageText).toHaveBeenCalledTimes(1);
expect(mockTelegramSendMessage).toHaveBeenCalled();
expect(mockEditMessage).toHaveBeenCalledTimes(1);
expect(mockCreateMessage).toHaveBeenCalled();
});
});
// ==================== Eyes reaction removal ====================
describe('removeEyesReaction', () => {
it('should remove eyes reaction on completion for Discord', async () => {
it('should remove eyes reaction on completion', async () => {
const body = makeBody({
lastAssistantContent: 'Done.',
reason: 'completed',
@ -439,26 +443,7 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
// Discord uses a separate DiscordRestApi instance for reaction removal
expect(mockDiscordRemoveOwnReaction).toHaveBeenCalled();
});
it('should use reactionChannelId when provided for Discord', async () => {
const body = makeBody({
lastAssistantContent: 'Done.',
reactionChannelId: 'parent-channel-id',
reason: 'completed',
type: 'completion',
userMessageId: 'user-msg-1',
});
await service.handleCallback(body);
expect(mockDiscordRemoveOwnReaction).toHaveBeenCalledWith(
'parent-channel-id',
'user-msg-1',
'👀',
);
expect(mockRemoveReaction).toHaveBeenCalledWith('user-msg-1', '👀');
});
it('should skip reaction removal when no userMessageId', async () => {
@ -470,8 +455,7 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
// removeReaction should not be called
expect(mockDiscordRemoveOwnReaction).not.toHaveBeenCalled();
expect(mockRemoveReaction).not.toHaveBeenCalled();
});
it('should remove reaction for Telegram using messenger', async () => {
@ -484,12 +468,11 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
// Telegram uses messenger.removeReaction which calls removeMessageReaction
expect(mockTelegramRemoveMessageReaction).toHaveBeenCalledWith('chat-456', 789);
expect(mockRemoveReaction).toHaveBeenCalledWith('telegram:chat-456:789', '👀');
});
it('should not throw when reaction removal fails', async () => {
mockDiscordRemoveOwnReaction.mockRejectedValueOnce(new Error('Reaction not found'));
mockRemoveReaction.mockRejectedValueOnce(new Error('Reaction not found'));
const body = makeBody({
lastAssistantContent: 'Done.',
@ -521,7 +504,6 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
// summarizeTopicTitle is fire-and-forget; wait for promises to settle
await vi.waitFor(() => {
expect(mockFindById).toHaveBeenCalledWith('topic-1');
});
@ -609,7 +591,7 @@ describe('BotCallbackService', () => {
expect(mockFindById).not.toHaveBeenCalled();
});
it('should update thread name on Discord after generating title', async () => {
it('should update thread name after generating title', async () => {
mockFindById.mockResolvedValue({ title: null });
mockGenerateTopicTitle.mockResolvedValue('New Title');
mockTopicUpdate.mockResolvedValue(undefined);
@ -627,7 +609,7 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
await vi.waitFor(() => {
expect(mockDiscordUpdateChannelName).toHaveBeenCalledWith('thread-id', 'New Title');
expect(mockUpdateThreadName).toHaveBeenCalledWith('New Title');
});
});
@ -651,92 +633,7 @@ describe('BotCallbackService', () => {
// Wait for async chain
await new Promise((r) => setTimeout(r, 50));
expect(mockTopicUpdate).not.toHaveBeenCalled();
expect(mockDiscordUpdateChannelName).not.toHaveBeenCalled();
});
});
// ==================== Discord channel ID extraction ====================
describe('Discord channel ID extraction', () => {
it('should extract channel ID from 3-part platformThreadId (no thread)', async () => {
const body = makeBody({
platformThreadId: 'discord:guild:channel-123',
shouldContinue: true,
stepType: 'call_llm',
type: 'step',
});
await service.handleCallback(body);
expect(mockDiscordEditMessage).toHaveBeenCalledWith(
'channel-123',
expect.any(String),
expect.any(String),
);
});
it('should extract thread ID (4th part) as channel when thread exists', async () => {
const body = makeBody({
platformThreadId: 'discord:guild:parent-channel:thread-456',
shouldContinue: true,
stepType: 'call_llm',
type: 'step',
});
await service.handleCallback(body);
expect(mockDiscordEditMessage).toHaveBeenCalledWith(
'thread-456',
expect.any(String),
expect.any(String),
);
});
});
// ==================== Telegram chat ID and message ID ====================
describe('Telegram message handling', () => {
it('should extract chat ID from platformThreadId', async () => {
const body = makeTelegramBody({
shouldContinue: true,
stepType: 'call_llm',
type: 'step',
});
await service.handleCallback(body);
expect(mockTelegramEditMessageText).toHaveBeenCalledWith(
'chat-456',
expect.any(Number),
expect.any(String),
);
});
it('should parse composite message ID for Telegram', async () => {
const body = makeTelegramBody({
lastAssistantContent: 'Done.',
progressMessageId: 'telegram:chat-456:99',
reason: 'completed',
type: 'completion',
userMessageId: 'telegram:chat-456:100',
});
await service.handleCallback(body);
// editMessageText should receive parsed numeric message ID
expect(mockTelegramEditMessageText).toHaveBeenCalledWith('chat-456', 99, expect.any(String));
});
it('should trigger typing for Telegram steps', async () => {
const body = makeTelegramBody({
shouldContinue: true,
stepType: 'call_tool',
type: 'step',
});
await service.handleCallback(body);
expect(mockTelegramSendChatAction).toHaveBeenCalledWith('chat-456', 'typing');
expect(mockUpdateThreadName).not.toHaveBeenCalled();
});
});
@ -762,10 +659,10 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
// Completion: edit message
expect(mockDiscordEditMessage).toHaveBeenCalled();
expect(mockEditMessage).toHaveBeenCalled();
// Reaction removal
expect(mockDiscordRemoveOwnReaction).toHaveBeenCalled();
expect(mockRemoveReaction).toHaveBeenCalled();
// Topic summarization (async)
await vi.waitFor(() => {
@ -786,7 +683,7 @@ describe('BotCallbackService', () => {
await service.handleCallback(body);
expect(mockDiscordRemoveOwnReaction).not.toHaveBeenCalled();
expect(mockRemoveReaction).not.toHaveBeenCalled();
await new Promise((r) => setTimeout(r, 50));
expect(mockFindById).not.toHaveBeenCalled();
});

View file

@ -39,6 +39,7 @@ const mockOnSubscribedMessage = vi.hoisted(() => vi.fn());
const mockOnNewMessage = vi.hoisted(() => vi.fn());
vi.mock('chat', () => ({
BaseFormatConverter: class {},
Chat: vi.fn().mockImplementation(() => ({
initialize: mockInitialize,
onNewMention: mockOnNewMention,
@ -56,37 +57,64 @@ vi.mock('../AgentBridgeService', () => ({
})),
}));
// Mock platform descriptors
// Mock platform entries
const mockCreateAdapter = vi.hoisted(() =>
vi.fn().mockReturnValue({ testplatform: { type: 'mock-adapter' } }),
);
const mockOnBotRegistered = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
vi.mock('../platforms', () => ({
getPlatformDescriptor: vi.fn().mockImplementation((platform: string) => {
const mockGetPlatform = vi.hoisted(() =>
vi.fn().mockImplementation((platform: string) => {
if (platform === 'unknown') return undefined;
return {
charLimit: platform === 'telegram' ? 4000 : undefined,
createAdapter: mockCreateAdapter,
handleDirectMessages: platform === 'telegram' || platform === 'lark',
onBotRegistered: mockOnBotRegistered,
persistent: platform === 'discord',
platform,
clientFactory: {
createClient: vi.fn().mockReturnValue({
applicationId: 'mock-app',
createAdapter: mockCreateAdapter,
extractChatId: (id: string) => id.split(':')[1],
getMessenger: () => ({
createMessage: vi.fn(),
editMessage: vi.fn(),
removeReaction: vi.fn(),
triggerTyping: vi.fn(),
}),
id: platform,
parseMessageId: (id: string) => id,
start: vi.fn(),
stop: vi.fn(),
}),
},
credentials: [],
id: platform,
name: platform,
};
}),
platformDescriptors: {
discord: { platform: 'discord' },
lark: { platform: 'lark' },
telegram: { platform: 'telegram' },
);
vi.mock('../platforms', () => ({
buildRuntimeKey: (platform: string, appId: string) => `${platform}:${appId}`,
platformRegistry: {
getPlatform: mockGetPlatform,
},
}));
// ==================== Helpers ====================
const FAKE_DB = {} as any;
const FAKE_GATEKEEPER = { decrypt: vi.fn() };
function makeProvider(overrides: Record<string, any> = {}) {
return {
agentId: 'agent-1',
applicationId: 'app-123',
credentials: { botToken: 'token' },
userId: 'user-1',
...overrides,
};
}
// ==================== Tests ====================
describe('BotMessageRouter', () => {
const FAKE_DB = {} as any;
const FAKE_GATEKEEPER = { decrypt: vi.fn() };
beforeEach(() => {
vi.clearAllMocks();
mockGetServerDB.mockResolvedValue(FAKE_DB);
@ -114,142 +142,119 @@ describe('BotMessageRouter', () => {
});
});
describe('initialize', () => {
it('should load agent bots on initialization', async () => {
const router = new BotMessageRouter();
await router.initialize();
// Should query each platform in the descriptor registry
expect(mockFindEnabledByPlatform).toHaveBeenCalledTimes(3); // discord, lark, telegram
});
it('should create bots for enabled providers', async () => {
mockFindEnabledByPlatform.mockImplementation((_db: any, platform: string) => {
if (platform === 'telegram') {
return [
{
agentId: 'agent-1',
applicationId: 'tg-bot-123',
credentials: { botToken: 'tg-token' },
userId: 'user-1',
},
];
}
return [];
});
describe('on-demand loading', () => {
it('should load bot on first webhook request', async () => {
mockFindEnabledByPlatform.mockResolvedValue([makeProvider({ applicationId: 'tg-bot-123' })]);
const router = new BotMessageRouter();
await router.initialize();
const handler = router.getWebhookHandler('telegram', 'tg-bot-123');
const req = new Request('https://example.com/webhook', { body: '{}', method: 'POST' });
await handler(req);
// Should only query the specific platform, not all platforms
expect(mockFindEnabledByPlatform).toHaveBeenCalledTimes(1);
expect(mockFindEnabledByPlatform).toHaveBeenCalledWith(FAKE_DB, 'telegram', FAKE_GATEKEEPER);
// Chat SDK should be initialized
expect(mockInitialize).toHaveBeenCalled();
// Adapter should be created via descriptor
expect(mockCreateAdapter).toHaveBeenCalledWith({ botToken: 'tg-token' }, 'tg-bot-123');
// Post-registration hook should be called
expect(mockOnBotRegistered).toHaveBeenCalledWith(
expect.objectContaining({
applicationId: 'tg-bot-123',
credentials: { botToken: 'tg-token' },
}),
);
expect(mockCreateAdapter).toHaveBeenCalled();
});
it('should register onNewMessage for platforms with handleDirectMessages', async () => {
mockFindEnabledByPlatform.mockImplementation((_db: any, platform: string) => {
if (platform === 'telegram') {
return [
{
agentId: 'agent-1',
applicationId: 'tg-bot-123',
credentials: { botToken: 'tg-token' },
userId: 'user-1',
},
];
}
return [];
});
it('should return cached bot on subsequent requests', async () => {
mockFindEnabledByPlatform.mockResolvedValue([makeProvider({ applicationId: 'tg-bot-123' })]);
const router = new BotMessageRouter();
await router.initialize();
const handler = router.getWebhookHandler('telegram', 'tg-bot-123');
// Telegram should have onNewMessage registered
expect(mockOnNewMessage).toHaveBeenCalled();
const req1 = new Request('https://example.com/webhook', { body: '{}', method: 'POST' });
await handler(req1);
const req2 = new Request('https://example.com/webhook', { body: '{}', method: 'POST' });
await handler(req2);
// DB should only be queried once — second call uses cache
expect(mockFindEnabledByPlatform).toHaveBeenCalledTimes(1);
expect(mockInitialize).toHaveBeenCalledTimes(1);
});
it('should NOT register onNewMessage for Discord', async () => {
mockFindEnabledByPlatform.mockImplementation((_db: any, platform: string) => {
if (platform === 'discord') {
return [
{
agentId: 'agent-1',
applicationId: 'discord-app-123',
credentials: { botToken: 'dc-token', publicKey: 'key' },
userId: 'user-1',
},
];
}
return [];
});
it('should return 404 when no provider found in DB', async () => {
mockFindEnabledByPlatform.mockResolvedValue([]);
const router = new BotMessageRouter();
await router.initialize();
const handler = router.getWebhookHandler('telegram', 'non-existent');
// Discord should NOT have onNewMessage registered (handleDirectMessages = false)
expect(mockOnNewMessage).not.toHaveBeenCalled();
const req = new Request('https://example.com/webhook', { body: '{}', method: 'POST' });
const resp = await handler(req);
expect(resp.status).toBe(404);
});
it('should skip already registered bots on refresh', async () => {
mockFindEnabledByPlatform.mockResolvedValue([
{
agentId: 'agent-1',
applicationId: 'app-1',
credentials: { botToken: 'token' },
userId: 'user-1',
},
]);
it('should return 400 when appId is missing for generic platform', async () => {
const router = new BotMessageRouter();
await router.initialize();
const handler = router.getWebhookHandler('telegram');
const firstCallCount = mockInitialize.mock.calls.length;
const req = new Request('https://example.com/webhook', { body: '{}', method: 'POST' });
const resp = await handler(req);
// Force a second load
await (router as any).loadAgentBots();
// Should not create duplicate bots
expect(mockInitialize.mock.calls.length).toBe(firstCallCount);
expect(resp.status).toBe(400);
});
it('should handle DB errors gracefully during initialization', async () => {
it('should handle DB errors gracefully', async () => {
mockFindEnabledByPlatform.mockRejectedValue(new Error('DB connection failed'));
const router = new BotMessageRouter();
// Should not throw
await expect(router.initialize()).resolves.toBeUndefined();
const handler = router.getWebhookHandler('telegram', 'app-123');
const req = new Request('https://example.com/webhook', { body: '{}', method: 'POST' });
const resp = await handler(req);
// Should return 404, not throw
expect(resp.status).toBe(404);
});
});
describe('handler registration', () => {
it('should always register onNewMention and onSubscribedMessage', async () => {
mockFindEnabledByPlatform.mockImplementation((_db: any, platform: string) => {
if (platform === 'telegram') {
return [
{
agentId: 'agent-1',
applicationId: 'tg-123',
credentials: { botToken: 'token' },
userId: 'user-1',
},
];
}
return [];
});
mockFindEnabledByPlatform.mockResolvedValue([makeProvider({ applicationId: 'tg-123' })]);
const router = new BotMessageRouter();
await router.initialize();
const handler = router.getWebhookHandler('telegram', 'tg-123');
const req = new Request('https://example.com/webhook', { body: '{}', method: 'POST' });
await handler(req);
expect(mockOnNewMention).toHaveBeenCalled();
expect(mockOnSubscribedMessage).toHaveBeenCalled();
});
it('should register onNewMessage when dm.enabled is true', async () => {
mockFindEnabledByPlatform.mockResolvedValue([
makeProvider({
applicationId: 'tg-123',
settings: { dm: { enabled: true } },
}),
]);
const router = new BotMessageRouter();
const handler = router.getWebhookHandler('telegram', 'tg-123');
const req = new Request('https://example.com/webhook', { body: '{}', method: 'POST' });
await handler(req);
expect(mockOnNewMessage).toHaveBeenCalled();
});
it('should NOT register onNewMessage when dm is not enabled', async () => {
mockFindEnabledByPlatform.mockResolvedValue([makeProvider({ applicationId: 'app-123' })]);
const router = new BotMessageRouter();
const handler = router.getWebhookHandler('telegram', 'app-123');
const req = new Request('https://example.com/webhook', { body: '{}', method: 'POST' });
await handler(req);
expect(mockOnNewMessage).not.toHaveBeenCalled();
});
});
});

View file

@ -77,6 +77,8 @@ describe('formatPrompt', () => {
text: 'hello world',
};
const discordSanitize = (text: string) => text.replaceAll(/<@!?bot123>\s*/g, '').trim();
it('should format basic message with speaker tag', () => {
const result = formatPrompt(baseMessage);
@ -88,7 +90,7 @@ describe('formatPrompt', () => {
it('should strip bot @mention from text', () => {
const msg = { ...baseMessage, text: '<@bot123> hello world' };
const result = formatPrompt(msg, { applicationId: 'bot123' });
const result = formatPrompt(msg, { sanitizeUserInput: discordSanitize });
expect(result).toContain('hello world');
expect(result).not.toContain('<@bot123>');
@ -96,12 +98,19 @@ describe('formatPrompt', () => {
it('should strip bot @mention with ! format', () => {
const msg = { ...baseMessage, text: '<@!bot123> hello world' };
const result = formatPrompt(msg, { applicationId: 'bot123' });
const result = formatPrompt(msg, { sanitizeUserInput: discordSanitize });
expect(result).toContain('hello world');
expect(result).not.toContain('<@!bot123>');
});
it('should not strip mentions when no sanitizeUserInput provided', () => {
const msg = { ...baseMessage, text: '<@bot123> hello world' };
const result = formatPrompt(msg);
expect(result).toContain('<@bot123>');
});
it('should prepend referenced message before user text', () => {
const msg = {
...baseMessage,
@ -146,6 +155,8 @@ describe('formatPrompt', () => {
});
it('should handle both @mention stripping and referenced message together', () => {
const sanitize = (text: string) => text.replaceAll(/<@!?bot999>\s*/g, '').trim();
const msg = {
...baseMessage,
raw: {
@ -156,7 +167,7 @@ describe('formatPrompt', () => {
},
text: '<@bot999> yes we can',
};
const result = formatPrompt(msg, { applicationId: 'bot999' });
const result = formatPrompt(msg, { sanitizeUserInput: sanitize });
expect(result).not.toContain('<@bot999>');
expect(result).toContain('yes we can');

View file

@ -1,188 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LarkRestApi } from '../platforms/lark/restApi';
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe('LarkRestApi', () => {
let api: LarkRestApi;
beforeEach(() => {
vi.clearAllMocks();
api = new LarkRestApi('app-id', 'app-secret', 'lark');
});
afterEach(() => {
vi.restoreAllMocks();
});
function mockAuthSuccess(token = 'tenant-token-abc', expire = 7200) {
mockFetch.mockResolvedValueOnce({
json: () => Promise.resolve({ code: 0, expire, tenant_access_token: token }),
ok: true,
});
}
function mockApiSuccess(data: any = {}) {
mockFetch.mockResolvedValueOnce({
json: () => Promise.resolve({ code: 0, data }),
ok: true,
});
}
function mockApiError(code: number, msg: string) {
mockFetch.mockResolvedValueOnce({
json: () => Promise.resolve({ code, msg }),
ok: true,
});
}
describe('getTenantAccessToken', () => {
it('should fetch and cache tenant access token', async () => {
mockAuthSuccess('token-1');
const token = await api.getTenantAccessToken();
expect(token).toBe('token-1');
expect(mockFetch).toHaveBeenCalledWith(
'https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal',
expect.objectContaining({
body: JSON.stringify({ app_id: 'app-id', app_secret: 'app-secret' }),
method: 'POST',
}),
);
});
it('should return cached token on subsequent calls', async () => {
mockAuthSuccess('token-1');
await api.getTenantAccessToken();
const token2 = await api.getTenantAccessToken();
expect(token2).toBe('token-1');
expect(mockFetch).toHaveBeenCalledTimes(1); // Only 1 fetch call
});
it('should throw on auth failure', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 401,
text: () => Promise.resolve('Unauthorized'),
});
await expect(api.getTenantAccessToken()).rejects.toThrow('Lark auth failed: 401');
});
it('should throw on auth logical error', async () => {
mockFetch.mockResolvedValueOnce({
json: () => Promise.resolve({ code: 10003, msg: 'Invalid app_secret' }),
ok: true,
});
await expect(api.getTenantAccessToken()).rejects.toThrow(
'Lark auth error: 10003 Invalid app_secret',
);
});
});
describe('sendMessage', () => {
it('should send a text message', async () => {
mockAuthSuccess();
mockApiSuccess({ message_id: 'om_abc123' });
const result = await api.sendMessage('oc_chat1', 'Hello');
expect(result).toEqual({ messageId: 'om_abc123' });
// Second call should be the actual API call (first is auth)
const apiCall = mockFetch.mock.calls[1];
expect(apiCall[0]).toContain('/im/v1/messages');
const body = JSON.parse(apiCall[1].body);
expect(body.receive_id).toBe('oc_chat1');
expect(body.msg_type).toBe('text');
});
it('should truncate text exceeding 4000 characters', async () => {
mockAuthSuccess();
mockApiSuccess({ message_id: 'om_1' });
const longText = 'B'.repeat(5000);
await api.sendMessage('oc_chat1', longText);
const apiCall = mockFetch.mock.calls[1];
const body = JSON.parse(apiCall[1].body);
const content = JSON.parse(body.content);
expect(content.text.length).toBe(4000);
expect(content.text.endsWith('...')).toBe(true);
});
});
describe('editMessage', () => {
it('should edit a message', async () => {
mockAuthSuccess();
mockApiSuccess();
await api.editMessage('om_abc123', 'Updated text');
const apiCall = mockFetch.mock.calls[1];
expect(apiCall[0]).toContain('/im/v1/messages/om_abc123');
expect(apiCall[1].method).toBe('PUT');
});
});
describe('replyMessage', () => {
it('should reply to a message', async () => {
mockAuthSuccess();
mockApiSuccess({ message_id: 'om_reply1' });
const result = await api.replyMessage('om_abc123', 'Reply text');
expect(result).toEqual({ messageId: 'om_reply1' });
const apiCall = mockFetch.mock.calls[1];
expect(apiCall[0]).toContain('/im/v1/messages/om_abc123/reply');
});
});
describe('error handling', () => {
it('should throw on API HTTP error', async () => {
mockAuthSuccess();
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: () => Promise.resolve('Server Error'),
});
await expect(api.sendMessage('oc_1', 'test')).rejects.toThrow(
'Lark API POST /im/v1/messages?receive_id_type=chat_id failed: 500',
);
});
it('should throw on API logical error', async () => {
mockAuthSuccess();
mockApiError(99991, 'Permission denied');
await expect(api.sendMessage('oc_1', 'test')).rejects.toThrow(
'Lark API POST /im/v1/messages?receive_id_type=chat_id failed: 99991 Permission denied',
);
});
});
describe('feishu variant', () => {
it('should use feishu API base URL', async () => {
const feishuApi = new LarkRestApi('app-id', 'app-secret', 'feishu');
mockFetch.mockResolvedValueOnce({
json: () => Promise.resolve({ code: 0, expire: 7200, tenant_access_token: 'feishu-token' }),
ok: true,
});
await feishuApi.getTenantAccessToken();
expect(mockFetch).toHaveBeenCalledWith(
'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal',
expect.any(Object),
);
});
});
});

View file

@ -1,366 +0,0 @@
import { createDiscordAdapter } from '@chat-adapter/discord';
import { createTelegramAdapter } from '@chat-adapter/telegram';
import { createLarkAdapter } from '@lobechat/adapter-lark';
import { describe, expect, it, vi } from 'vitest';
import { getPlatformDescriptor, platformDescriptors } from '../platforms';
import { discordDescriptor } from '../platforms/discord';
import { feishuDescriptor, larkDescriptor } from '../platforms/lark';
import { qqDescriptor } from '../platforms/qq';
import { telegramDescriptor } from '../platforms/telegram';
// Mock external dependencies before importing
vi.mock('@chat-adapter/discord', () => ({
createDiscordAdapter: vi.fn().mockReturnValue({ type: 'discord-adapter' }),
}));
vi.mock('@chat-adapter/telegram', () => ({
createTelegramAdapter: vi.fn().mockReturnValue({ type: 'telegram-adapter' }),
}));
vi.mock('@lobechat/adapter-lark', () => ({
createLarkAdapter: vi.fn().mockReturnValue({ type: 'lark-adapter' }),
}));
vi.mock('@/envs/app', () => ({
appEnv: { APP_URL: 'https://app.example.com' },
}));
vi.mock('../platforms/discord/restApi', () => ({
DiscordRestApi: vi.fn().mockImplementation(() => ({
createMessage: vi.fn().mockResolvedValue({ id: 'msg-1' }),
editMessage: vi.fn().mockResolvedValue(undefined),
removeOwnReaction: vi.fn().mockResolvedValue(undefined),
triggerTyping: vi.fn().mockResolvedValue(undefined),
updateChannelName: vi.fn().mockResolvedValue(undefined),
})),
}));
vi.mock('../platforms/telegram/restApi', () => ({
TelegramRestApi: vi.fn().mockImplementation(() => ({
editMessageText: vi.fn().mockResolvedValue(undefined),
removeMessageReaction: vi.fn().mockResolvedValue(undefined),
sendChatAction: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn().mockResolvedValue({ message_id: 123 }),
})),
}));
vi.mock('../platforms/lark/restApi', () => ({
LarkRestApi: vi.fn().mockImplementation(() => ({
editMessage: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn().mockResolvedValue({ messageId: 'lark-msg-1' }),
})),
}));
vi.mock('@lobechat/adapter-qq', () => ({
createQQAdapter: vi.fn().mockReturnValue({ type: 'qq-adapter' }),
}));
vi.mock('../platforms/qq/restApi', () => ({
QQ_API_BASE: 'https://api.sgroup.qq.com',
QQRestApi: vi.fn().mockImplementation(() => ({
getAccessToken: vi.fn().mockResolvedValue('qq-token'),
sendAsEdit: vi.fn().mockResolvedValue({ id: 'qq-msg-edit' }),
sendMessage: vi.fn().mockResolvedValue({ id: 'qq-msg-1' }),
})),
}));
describe('platformDescriptors registry', () => {
it('should have all 5 platforms registered', () => {
expect(Object.keys(platformDescriptors)).toEqual(
expect.arrayContaining(['discord', 'telegram', 'lark', 'feishu', 'qq']),
);
});
it('getPlatformDescriptor should return descriptor for known platforms', () => {
expect(getPlatformDescriptor('discord')).toBe(discordDescriptor);
expect(getPlatformDescriptor('telegram')).toBe(telegramDescriptor);
expect(getPlatformDescriptor('lark')).toBe(larkDescriptor);
expect(getPlatformDescriptor('feishu')).toBe(feishuDescriptor);
expect(getPlatformDescriptor('qq')).toBe(qqDescriptor);
});
it('getPlatformDescriptor should return undefined for unknown platforms', () => {
expect(getPlatformDescriptor('whatsapp')).toBeUndefined();
expect(getPlatformDescriptor('')).toBeUndefined();
});
});
describe('discordDescriptor', () => {
it('should have correct platform properties', () => {
expect(discordDescriptor.platform).toBe('discord');
expect(discordDescriptor.persistent).toBe(true);
expect(discordDescriptor.handleDirectMessages).toBe(false);
expect(discordDescriptor.charLimit).toBeUndefined();
expect(discordDescriptor.requiredCredentials).toEqual(['botToken']);
});
describe('extractChatId', () => {
it('should extract channel ID from 3-part thread ID (no thread)', () => {
expect(discordDescriptor.extractChatId('discord:guild:channel-123')).toBe('channel-123');
});
it('should extract thread ID from 4-part thread ID', () => {
expect(discordDescriptor.extractChatId('discord:guild:parent:thread-456')).toBe('thread-456');
});
});
describe('parseMessageId', () => {
it('should return message ID as-is (string)', () => {
expect(discordDescriptor.parseMessageId('msg-abc-123')).toBe('msg-abc-123');
});
});
describe('createAdapter', () => {
it('should create Discord adapter with correct params', () => {
const credentials = { botToken: 'token-123', publicKey: 'key-abc' };
const result = discordDescriptor.createAdapter(credentials, 'app-id');
expect(result).toHaveProperty('discord');
expect(createDiscordAdapter).toHaveBeenCalledWith({
applicationId: 'app-id',
botToken: 'token-123',
publicKey: 'key-abc',
});
});
});
describe('createMessenger', () => {
it('should create a messenger with all required methods', () => {
const credentials = { botToken: 'token-123' };
const messenger = discordDescriptor.createMessenger(
credentials,
'discord:guild:channel:thread',
);
expect(messenger).toHaveProperty('createMessage');
expect(messenger).toHaveProperty('editMessage');
expect(messenger).toHaveProperty('removeReaction');
expect(messenger).toHaveProperty('triggerTyping');
expect(messenger).toHaveProperty('updateThreadName');
});
});
describe('onBotRegistered', () => {
it('should call registerByToken with botToken', async () => {
const registerByToken = vi.fn();
await discordDescriptor.onBotRegistered?.({
applicationId: 'app-1',
credentials: { botToken: 'my-token' },
registerByToken,
});
expect(registerByToken).toHaveBeenCalledWith('my-token');
});
it('should not call registerByToken when botToken is missing', async () => {
const registerByToken = vi.fn();
await discordDescriptor.onBotRegistered?.({
applicationId: 'app-1',
credentials: {},
registerByToken,
});
expect(registerByToken).not.toHaveBeenCalled();
});
});
});
describe('telegramDescriptor', () => {
it('should have correct platform properties', () => {
expect(telegramDescriptor.platform).toBe('telegram');
expect(telegramDescriptor.persistent).toBe(false);
expect(telegramDescriptor.handleDirectMessages).toBe(true);
expect(telegramDescriptor.charLimit).toBe(4000);
expect(telegramDescriptor.requiredCredentials).toEqual(['botToken']);
});
describe('extractChatId', () => {
it('should extract chat ID from platformThreadId', () => {
expect(telegramDescriptor.extractChatId('telegram:chat-456')).toBe('chat-456');
});
it('should extract chat ID from multi-part ID', () => {
expect(telegramDescriptor.extractChatId('telegram:chat-789:extra')).toBe('chat-789');
});
});
describe('parseMessageId', () => {
it('should parse numeric ID from composite string', () => {
expect(telegramDescriptor.parseMessageId('telegram:chat-456:99')).toBe(99);
});
it('should parse plain numeric string', () => {
expect(telegramDescriptor.parseMessageId('42')).toBe(42);
});
});
describe('createAdapter', () => {
it('should create Telegram adapter with correct params', () => {
const credentials = { botToken: 'bot-token', secretToken: 'secret' };
const result = telegramDescriptor.createAdapter(credentials, 'app-id');
expect(result).toHaveProperty('telegram');
expect(createTelegramAdapter).toHaveBeenCalledWith({
botToken: 'bot-token',
secretToken: 'secret',
});
});
});
describe('createMessenger', () => {
it('should create a messenger with all required methods', () => {
const credentials = { botToken: 'token-123' };
const messenger = telegramDescriptor.createMessenger(credentials, 'telegram:chat-456');
expect(messenger).toHaveProperty('createMessage');
expect(messenger).toHaveProperty('editMessage');
expect(messenger).toHaveProperty('removeReaction');
expect(messenger).toHaveProperty('triggerTyping');
});
});
});
describe('larkDescriptor', () => {
it('should have correct platform properties', () => {
expect(larkDescriptor.platform).toBe('lark');
expect(larkDescriptor.persistent).toBe(false);
expect(larkDescriptor.handleDirectMessages).toBe(true);
expect(larkDescriptor.charLimit).toBe(4000);
expect(larkDescriptor.requiredCredentials).toEqual(['appId', 'appSecret']);
});
describe('extractChatId', () => {
it('should extract chat ID from platformThreadId', () => {
expect(larkDescriptor.extractChatId('lark:oc_abc123')).toBe('oc_abc123');
});
});
describe('parseMessageId', () => {
it('should return message ID as-is (string)', () => {
expect(larkDescriptor.parseMessageId('om_abc123')).toBe('om_abc123');
});
});
describe('createAdapter', () => {
it('should create Lark adapter with correct params', () => {
const credentials = {
appId: 'cli_abc',
appSecret: 'secret',
encryptKey: 'enc-key',
verificationToken: 'verify-token',
};
const result = larkDescriptor.createAdapter(credentials, 'app-id');
expect(result).toHaveProperty('lark');
expect(createLarkAdapter).toHaveBeenCalledWith({
appId: 'cli_abc',
appSecret: 'secret',
encryptKey: 'enc-key',
platform: 'lark',
verificationToken: 'verify-token',
});
});
});
describe('createMessenger', () => {
it('should create a messenger with no-op reaction and typing', async () => {
const credentials = { appId: 'cli_abc', appSecret: 'secret' };
const messenger = larkDescriptor.createMessenger(credentials, 'lark:oc_abc123');
// Lark has no reaction/typing API
await expect(messenger.removeReaction('msg-1', '👀')).resolves.toBeUndefined();
await expect(messenger.triggerTyping()).resolves.toBeUndefined();
});
});
it('should not define onBotRegistered', () => {
expect(larkDescriptor.onBotRegistered).toBeUndefined();
});
});
describe('feishuDescriptor', () => {
it('should have correct platform properties', () => {
expect(feishuDescriptor.platform).toBe('feishu');
expect(feishuDescriptor.persistent).toBe(false);
expect(feishuDescriptor.handleDirectMessages).toBe(true);
expect(feishuDescriptor.charLimit).toBe(4000);
expect(feishuDescriptor.requiredCredentials).toEqual(['appId', 'appSecret']);
});
describe('createAdapter', () => {
it('should create adapter with feishu platform', () => {
const credentials = { appId: 'cli_abc', appSecret: 'secret' };
const result = feishuDescriptor.createAdapter(credentials, 'app-id');
expect(result).toHaveProperty('feishu');
expect(createLarkAdapter).toHaveBeenCalledWith(
expect.objectContaining({ platform: 'feishu' }),
);
});
});
});
describe('qqDescriptor', () => {
it('should have correct platform properties', () => {
expect(qqDescriptor.platform).toBe('qq');
expect(qqDescriptor.persistent).toBe(false);
expect(qqDescriptor.handleDirectMessages).toBe(true);
expect(qqDescriptor.charLimit).toBe(2000);
expect(qqDescriptor.requiredCredentials).toEqual(['appId', 'appSecret']);
});
describe('extractChatId', () => {
it('should extract target ID from qq thread ID', () => {
expect(qqDescriptor.extractChatId('qq:group:group-123')).toBe('group-123');
});
it('should extract target ID from c2c thread ID', () => {
expect(qqDescriptor.extractChatId('qq:c2c:user-456')).toBe('user-456');
});
it('should extract target ID from guild thread ID', () => {
expect(qqDescriptor.extractChatId('qq:guild:channel-789')).toBe('channel-789');
});
});
describe('parseMessageId', () => {
it('should return message ID as-is (string)', () => {
expect(qqDescriptor.parseMessageId('msg-abc-123')).toBe('msg-abc-123');
});
});
describe('createAdapter', () => {
it('should create QQ adapter with correct params', () => {
const credentials = { appId: 'app-123', appSecret: 'secret-456' };
const result = qqDescriptor.createAdapter(credentials, 'app-id');
expect(result).toHaveProperty('qq');
});
});
describe('createMessenger', () => {
it('should create a messenger with all required methods', () => {
const credentials = { appId: 'app-123', appSecret: 'secret-456' };
const messenger = qqDescriptor.createMessenger(credentials, 'qq:group:group-123');
expect(messenger).toHaveProperty('createMessage');
expect(messenger).toHaveProperty('editMessage');
expect(messenger).toHaveProperty('removeReaction');
expect(messenger).toHaveProperty('triggerTyping');
});
it('should have no-op reaction and typing', async () => {
const credentials = { appId: 'app-123', appSecret: 'secret-456' };
const messenger = qqDescriptor.createMessenger(credentials, 'qq:group:group-123');
// QQ has no reaction/typing API
await expect(messenger.removeReaction('msg-1', '👀')).resolves.toBeUndefined();
await expect(messenger.triggerTyping()).resolves.toBeUndefined();
});
});
it('should not define onBotRegistered', () => {
expect(qqDescriptor.onBotRegistered).toBeUndefined();
});
});

View file

@ -1,155 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { QQRestApi } from '../platforms/qq/restApi';
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe('QQRestApi', () => {
let api: QQRestApi;
beforeEach(() => {
vi.clearAllMocks();
api = new QQRestApi('app-id', 'client-secret');
});
afterEach(() => {
vi.restoreAllMocks();
});
function mockAuthSuccess(token = 'qq-access-token', expiresIn = 7200) {
mockFetch.mockResolvedValueOnce({
json: () => Promise.resolve({ access_token: token, expires_in: expiresIn }),
ok: true,
});
}
function mockApiSuccess(data: any = {}) {
mockFetch.mockResolvedValueOnce({
headers: new Headers({ 'content-type': 'application/json' }),
json: () => Promise.resolve(data),
ok: true,
});
}
describe('getAccessToken', () => {
it('should fetch and cache access token', async () => {
mockAuthSuccess('token-1');
const token = await api.getAccessToken();
expect(token).toBe('token-1');
expect(mockFetch).toHaveBeenCalledWith(
'https://bots.qq.com/app/getAppAccessToken',
expect.objectContaining({
body: JSON.stringify({ appId: 'app-id', clientSecret: 'client-secret' }),
method: 'POST',
}),
);
});
it('should return cached token on subsequent calls', async () => {
mockAuthSuccess('token-1');
await api.getAccessToken();
const token2 = await api.getAccessToken();
expect(token2).toBe('token-1');
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('should throw on auth failure', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 401,
text: () => Promise.resolve('Unauthorized'),
});
await expect(api.getAccessToken()).rejects.toThrow('QQ auth failed: 401');
});
});
describe('sendMessage', () => {
it('should send group message', async () => {
mockAuthSuccess();
mockApiSuccess({ id: 'msg-1' });
const result = await api.sendMessage('group', 'group-123', 'Hello');
expect(result).toEqual({ id: 'msg-1' });
const apiCall = mockFetch.mock.calls[1];
expect(apiCall[0]).toBe('https://api.sgroup.qq.com/v2/groups/group-123/messages');
expect(apiCall[1].headers.Authorization).toBe('QQBot qq-access-token');
});
it('should send guild channel message', async () => {
mockAuthSuccess();
mockApiSuccess({ id: 'msg-2' });
await api.sendMessage('guild', 'channel-456', 'Hello');
const apiCall = mockFetch.mock.calls[1];
expect(apiCall[0]).toBe('https://api.sgroup.qq.com/channels/channel-456/messages');
});
it('should send c2c message', async () => {
mockAuthSuccess();
mockApiSuccess({ id: 'msg-3' });
await api.sendMessage('c2c', 'user-789', 'Hello');
const apiCall = mockFetch.mock.calls[1];
expect(apiCall[0]).toBe('https://api.sgroup.qq.com/v2/users/user-789/messages');
});
it('should send dms message', async () => {
mockAuthSuccess();
mockApiSuccess({ id: 'msg-4' });
await api.sendMessage('dms', 'guild-abc', 'Hello');
const apiCall = mockFetch.mock.calls[1];
expect(apiCall[0]).toBe('https://api.sgroup.qq.com/dms/guild-abc/messages');
});
it('should truncate text exceeding 2000 characters', async () => {
mockAuthSuccess();
mockApiSuccess({ id: 'msg-5' });
const longText = 'A'.repeat(3000);
await api.sendMessage('group', 'group-123', longText);
const apiCall = mockFetch.mock.calls[1];
const body = JSON.parse(apiCall[1].body);
expect(body.content.length).toBe(2000);
expect(body.content.endsWith('...')).toBe(true);
});
});
describe('sendAsEdit', () => {
it('should send a new message as fallback (QQ has no edit support)', async () => {
mockAuthSuccess();
mockApiSuccess({ id: 'msg-new' });
const result = await api.sendAsEdit('group', 'group-123', 'Updated content');
expect(result).toEqual({ id: 'msg-new' });
});
});
describe('error handling', () => {
it('should throw on API HTTP error', async () => {
mockAuthSuccess();
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: () => Promise.resolve('Server Error'),
});
await expect(api.sendMessage('group', 'g-1', 'test')).rejects.toThrow(
'QQ API POST /v2/groups/g-1/messages failed: 500',
);
});
});
});

View file

@ -315,65 +315,12 @@ describe('replyTemplate', () => {
// ==================== renderFinalReply ====================
describe('renderFinalReply', () => {
it('should append usage footer with tokens, cost, and call counts', () => {
expect(
renderFinalReply('Here is the answer.', {
llmCalls: 5,
toolCalls: 4,
totalCost: 0.0312,
totalTokens: 1234,
}),
).toBe('Here is the answer.\n\n-# 1.2k tokens · $0.0312 | llm×5 | tools×4');
it('should return content body only (no stats)', () => {
expect(renderFinalReply('Here is the answer.')).toBe('Here is the answer.');
});
it('should hide call counts when llmCalls=1 and toolCalls=0', () => {
expect(
renderFinalReply('Simple answer.', {
llmCalls: 1,
toolCalls: 0,
totalCost: 0.001,
totalTokens: 500,
}),
).toBe('Simple answer.\n\n-# 500 tokens · $0.0010');
});
it('should show call counts when toolCalls > 0 even if llmCalls=1', () => {
expect(
renderFinalReply('Answer.', {
llmCalls: 1,
toolCalls: 2,
totalCost: 0.005,
totalTokens: 800,
}),
).toBe('Answer.\n\n-# 800 tokens · $0.0050 | llm×1 | tools×2');
});
it('should show call counts when llmCalls > 1 even if toolCalls=0', () => {
expect(
renderFinalReply('Answer.', {
llmCalls: 3,
toolCalls: 0,
totalCost: 0.01,
totalTokens: 2000,
}),
).toBe('Answer.\n\n-# 2.0k tokens · $0.0100 | llm×3 | tools×0');
});
it('should hide call counts for zero usage', () => {
expect(
renderFinalReply('Done.', { llmCalls: 0, toolCalls: 0, totalCost: 0, totalTokens: 0 }),
).toBe('Done.\n\n-# 0 tokens · $0.0000');
});
it('should format large token counts', () => {
expect(
renderFinalReply('Result', {
llmCalls: 10,
toolCalls: 20,
totalCost: 1.5,
totalTokens: 1_234_567,
}),
).toBe('Result\n\n-# 1.2m tokens · $1.5000 | llm×10 | tools×20');
it('should trim trailing whitespace', () => {
expect(renderFinalReply('Answer. \n\n')).toBe('Answer.');
});
});

View file

@ -1,146 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TelegramRestApi } from '../platforms/telegram/restApi';
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe('TelegramRestApi', () => {
let api: TelegramRestApi;
beforeEach(() => {
vi.clearAllMocks();
api = new TelegramRestApi('bot-token-123');
});
afterEach(() => {
vi.restoreAllMocks();
});
function mockSuccessResponse(result: any = {}) {
mockFetch.mockResolvedValueOnce({
json: () => Promise.resolve({ ok: true, result }),
ok: true,
});
}
function mockHttpError(status: number, text: string) {
mockFetch.mockResolvedValueOnce({
ok: false,
status,
text: () => Promise.resolve(text),
});
}
function mockLogicalError(errorCode: number, description: string) {
mockFetch.mockResolvedValueOnce({
json: () => Promise.resolve({ description, error_code: errorCode, ok: false }),
ok: true,
});
}
describe('sendMessage', () => {
it('should send a message and return message_id', async () => {
mockSuccessResponse({ message_id: 42 });
const result = await api.sendMessage('chat-1', 'Hello');
expect(result).toEqual({ message_id: 42 });
expect(mockFetch).toHaveBeenCalledWith(
'https://api.telegram.org/botbot-token-123/sendMessage',
expect.objectContaining({
body: expect.stringContaining('"chat_id":"chat-1"'),
method: 'POST',
}),
);
});
it('should truncate text exceeding 4096 characters', async () => {
mockSuccessResponse({ message_id: 1 });
const longText = 'A'.repeat(5000);
await api.sendMessage('chat-1', longText);
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(callBody.text.length).toBe(4096);
expect(callBody.text.endsWith('...')).toBe(true);
});
});
describe('editMessageText', () => {
it('should edit a message', async () => {
mockSuccessResponse();
await api.editMessageText('chat-1', 99, 'Updated text');
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(callBody.chat_id).toBe('chat-1');
expect(callBody.message_id).toBe(99);
expect(callBody.text).toBe('Updated text');
});
});
describe('sendChatAction', () => {
it('should send typing action', async () => {
mockSuccessResponse();
await api.sendChatAction('chat-1', 'typing');
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(callBody.action).toBe('typing');
});
});
describe('deleteMessage', () => {
it('should delete a message', async () => {
mockSuccessResponse();
await api.deleteMessage('chat-1', 100);
expect(mockFetch).toHaveBeenCalledWith(
'https://api.telegram.org/botbot-token-123/deleteMessage',
expect.any(Object),
);
});
});
describe('setMessageReaction', () => {
it('should set a reaction', async () => {
mockSuccessResponse();
await api.setMessageReaction('chat-1', 50, '👀');
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(callBody.reaction).toEqual([{ emoji: '👀', type: 'emoji' }]);
});
});
describe('removeMessageReaction', () => {
it('should remove reaction with empty array', async () => {
mockSuccessResponse();
await api.removeMessageReaction('chat-1', 50);
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(callBody.reaction).toEqual([]);
});
});
describe('error handling', () => {
it('should throw on HTTP error', async () => {
mockHttpError(500, 'Internal Server Error');
await expect(api.sendMessage('chat-1', 'test')).rejects.toThrow(
'Telegram API sendMessage failed: 500',
);
});
it('should throw on logical error (HTTP 200 with ok: false)', async () => {
mockLogicalError(400, 'Bad Request: message text is empty');
await expect(api.sendMessage('chat-1', 'test')).rejects.toThrow(
'Telegram API sendMessage failed: 400 Bad Request: message text is empty',
);
});
});
});

View file

@ -14,12 +14,13 @@ interface MessageLike {
text: string;
}
interface BotContext {
applicationId?: string;
interface FormatPromptOptions {
/** Strip platform-specific bot mention artifacts from user input. */
sanitizeUserInput?: (text: string) => string;
}
/**
* Extract referenced (replied-to) message from Discord raw payload
* Extract referenced (replied-to) message from raw payload
* and format it as an XML tag for the agent prompt.
*/
export const formatReferencedMessage = (
@ -35,15 +36,15 @@ export const formatReferencedMessage = (
/**
* Format user message into agent prompt:
* 1. Strip bot's own @mention (Discord format: <@botId>)
* 1. Strip platform-specific bot mentions via sanitizeUserInput
* 2. Prepend referenced (quoted/replied) message if present
* 3. Add speaker tag with user identity
*/
export const formatPrompt = (message: MessageLike, botContext?: BotContext): string => {
export const formatPrompt = (message: MessageLike, options?: FormatPromptOptions): string => {
let text = message.text;
if (botContext?.applicationId) {
text = text.replaceAll(new RegExp(`<@!?${botContext.applicationId}>\\s*`, 'g'), '').trim();
if (options?.sanitizeUserInput) {
text = options.sanitizeUserInput(text);
}
// Prepend referenced (quoted/replied) message if present

View file

@ -1,6 +1,3 @@
export { AgentBridgeService } from './AgentBridgeService';
export { BotMessageRouter, getBotMessageRouter } from './BotMessageRouter';
export { platformBotRegistry } from './platforms';
export { Discord, type DiscordBotConfig } from './platforms/discord';
export { Telegram, type TelegramBotConfig } from './platforms/telegram';
export type { PlatformBot, PlatformBotClass } from './types';
export type { PlatformClient, PlatformMessenger } from './types';

View file

@ -0,0 +1,5 @@
/** Default debounce window (ms) for message batching. */
export const DEFAULT_DEBOUNCE_MS = 2000;
/** Maximum debounce window (ms) allowed across all platforms. */
export const MAX_DEBOUNCE_MS = 30_000;

View file

@ -2,9 +2,9 @@ import { REST } from '@discordjs/rest';
import debug from 'debug';
import { type RESTPostAPIChannelMessageResult, Routes } from 'discord-api-types/v10';
const log = debug('lobe-server:bot:discord-rest');
const log = debug('bot-platform:discord:client');
export class DiscordRestApi {
export class DiscordApi {
private readonly rest: REST;
constructor(botToken: string) {

View file

@ -1,169 +0,0 @@
import type { DiscordAdapter } from '@chat-adapter/discord';
import { createDiscordAdapter } from '@chat-adapter/discord';
import { createIoRedisState } from '@chat-adapter/state-ioredis';
import { Chat, ConsoleLogger } from 'chat';
import debug from 'debug';
import { appEnv } from '@/envs/app';
import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis';
import type { PlatformBot, PlatformDescriptor, PlatformMessenger } from '../../types';
import { DiscordRestApi } from './restApi';
const log = debug('lobe-server:bot:gateway:discord');
const DEFAULT_DURATION_MS = 8 * 60 * 60 * 1000; // 8 hours
export interface DiscordBotConfig {
[key: string]: string;
applicationId: string;
botToken: string;
publicKey: string;
}
export interface GatewayListenerOptions {
durationMs?: number;
waitUntil?: (task: Promise<any>) => void;
}
export class Discord implements PlatformBot {
static readonly persistent = true;
readonly platform = 'discord';
readonly applicationId: string;
private abort = new AbortController();
private config: DiscordBotConfig;
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
private stopped = false;
constructor(config: DiscordBotConfig) {
this.config = config;
this.applicationId = config.applicationId;
}
async start(options?: GatewayListenerOptions): Promise<void> {
log('Starting DiscordBot appId=%s', this.applicationId);
this.stopped = false;
this.abort = new AbortController();
const adapter = createDiscordAdapter({
applicationId: this.config.applicationId,
botToken: this.config.botToken,
publicKey: this.config.publicKey,
});
const chatConfig: any = {
adapters: { discord: adapter },
userName: `lobehub-gateway-${this.applicationId}`,
};
const redisClient = getAgentRuntimeRedisClient();
if (redisClient) {
chatConfig.state = createIoRedisState({ client: redisClient, logger: new ConsoleLogger() });
}
const bot = new Chat(chatConfig);
await bot.initialize();
const discordAdapter = (bot as any).adapters.get('discord') as DiscordAdapter;
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
const waitUntil = options?.waitUntil ?? ((task: Promise<any>) => task.catch(() => {}));
const webhookUrl = `${(appEnv.APP_URL || '').trim()}/api/agent/webhooks/discord`;
await discordAdapter.startGatewayListener(
{ waitUntil },
durationMs,
this.abort.signal,
webhookUrl,
);
// Only schedule refresh timer in long-running mode (no custom options)
if (!options) {
this.refreshTimer = setTimeout(() => {
if (this.abort.signal.aborted || this.stopped) return;
log(
'DiscordBot appId=%s duration elapsed (%dh), refreshing...',
this.applicationId,
durationMs / 3_600_000,
);
this.abort.abort();
this.start().catch((err) => {
log('Failed to refresh DiscordBot appId=%s: %O', this.applicationId, err);
});
}, durationMs);
}
log('DiscordBot appId=%s started, webhookUrl=%s', this.applicationId, webhookUrl);
}
async stop(): Promise<void> {
log('Stopping DiscordBot appId=%s', this.applicationId);
this.stopped = true;
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
this.abort.abort();
}
}
// --------------- Platform Descriptor ---------------
function extractChannelId(platformThreadId: string): string {
const parts = platformThreadId.split(':');
return parts[3] || parts[2];
}
function createDiscordMessenger(
discord: DiscordRestApi,
channelId: string,
platformThreadId: string,
): PlatformMessenger {
return {
createMessage: (content) => discord.createMessage(channelId, content).then(() => {}),
editMessage: (messageId, content) => discord.editMessage(channelId, messageId, content),
removeReaction: (messageId, emoji) => discord.removeOwnReaction(channelId, messageId, emoji),
triggerTyping: () => discord.triggerTyping(channelId),
updateThreadName: (name) => {
const threadId = platformThreadId.split(':')[3];
return threadId ? discord.updateChannelName(threadId, name) : Promise.resolve();
},
};
}
export const discordDescriptor: PlatformDescriptor = {
platform: 'discord',
persistent: true,
handleDirectMessages: false,
requiredCredentials: ['botToken'],
extractChatId: extractChannelId,
parseMessageId: (compositeId) => compositeId,
createMessenger(credentials, platformThreadId) {
const discord = new DiscordRestApi(credentials.botToken);
const channelId = extractChannelId(platformThreadId);
return createDiscordMessenger(discord, channelId, platformThreadId);
},
createAdapter(credentials, applicationId) {
return {
discord: createDiscordAdapter({
applicationId,
botToken: credentials.botToken,
publicKey: credentials.publicKey,
}),
};
},
async onBotRegistered({ credentials, registerByToken }) {
if (credentials.botToken && registerByToken) {
registerByToken(credentials.botToken);
}
},
};

View file

@ -0,0 +1,195 @@
import type { DiscordAdapter } from '@chat-adapter/discord';
import { createDiscordAdapter } from '@chat-adapter/discord';
import debug from 'debug';
import {
type BotPlatformRuntimeContext,
type BotProviderConfig,
ClientFactory,
type PlatformClient,
type PlatformMessenger,
type UsageStats,
type ValidationResult,
} from '../types';
import { formatUsageStats } from '../utils';
import { DiscordApi } from './api';
const log = debug('bot-platform:discord:bot');
const DEFAULT_DURATION_MS = 8 * 60 * 60 * 1000; // 8 hours
export interface GatewayListenerOptions {
durationMs?: number;
waitUntil?: (task: Promise<any>) => void;
}
function extractChannelId(platformThreadId: string): string {
const parts = platformThreadId.split(':');
return parts[3] || parts[2];
}
class DiscordGatewayClient implements PlatformClient {
readonly id = 'discord';
readonly applicationId: string;
private abort = new AbortController();
private config: BotProviderConfig;
private context: BotPlatformRuntimeContext;
private discord: DiscordApi;
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
private stopped = false;
constructor(config: BotProviderConfig, context: BotPlatformRuntimeContext) {
this.config = config;
this.context = context;
this.applicationId = config.applicationId;
this.discord = new DiscordApi(config.credentials.botToken);
}
// --- Lifecycle ---
async start(options?: GatewayListenerOptions): Promise<void> {
log('Starting DiscordBot appId=%s', this.applicationId);
this.stopped = false;
this.abort = new AbortController();
const adapter = createDiscordAdapter({
applicationId: this.config.applicationId,
botToken: this.config.credentials.botToken,
publicKey: this.config.credentials.publicKey,
});
const { Chat, ConsoleLogger } = await import('chat');
const chatConfig: any = {
adapters: { discord: adapter },
userName: `lobehub-gateway-${this.applicationId}`,
};
if (this.context.redisClient) {
const { createIoRedisState } = await import('@chat-adapter/state-ioredis');
chatConfig.state = createIoRedisState({
client: this.context.redisClient as any,
logger: new ConsoleLogger(),
});
}
const bot = new Chat(chatConfig);
await bot.initialize();
const discordAdapter = (bot as any).adapters.get('discord') as DiscordAdapter;
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
const waitUntil = options?.waitUntil ?? ((task: Promise<any>) => task.catch(() => {}));
const webhookUrl = `${(this.context.appUrl || '').trim()}/api/agent/webhooks/discord/${this.applicationId}`;
await discordAdapter.startGatewayListener(
{ waitUntil },
durationMs,
this.abort.signal,
webhookUrl,
);
if (!options) {
this.refreshTimer = setTimeout(() => {
if (this.abort.signal.aborted || this.stopped) return;
log(
'DiscordBot appId=%s duration elapsed (%dh), refreshing...',
this.applicationId,
durationMs / 3_600_000,
);
this.abort.abort();
this.start().catch((err) => {
log('Failed to refresh DiscordBot appId=%s: %O', this.applicationId, err);
});
}, durationMs);
}
log('DiscordBot appId=%s started, webhookUrl=%s', this.applicationId, webhookUrl);
}
async stop(): Promise<void> {
log('Stopping DiscordBot appId=%s', this.applicationId);
this.stopped = true;
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
this.abort.abort();
}
// --- Runtime Operations ---
createAdapter(): Record<string, any> {
return {
discord: createDiscordAdapter({
applicationId: this.config.applicationId,
botToken: this.config.credentials.botToken,
publicKey: this.config.credentials.publicKey,
}),
};
}
getMessenger(platformThreadId: string): PlatformMessenger {
const channelId = extractChannelId(platformThreadId);
return {
createMessage: (content) => this.discord.createMessage(channelId, content).then(() => {}),
editMessage: (messageId, content) => this.discord.editMessage(channelId, messageId, content),
removeReaction: (messageId, emoji) =>
this.discord.removeOwnReaction(channelId, messageId, emoji),
triggerTyping: () => this.discord.triggerTyping(channelId),
updateThreadName: (name) => {
const threadId = platformThreadId.split(':')[3];
return threadId ? this.discord.updateChannelName(threadId, name) : Promise.resolve();
},
};
}
extractChatId(platformThreadId: string): string {
return extractChannelId(platformThreadId);
}
formatReply(body: string, stats?: UsageStats): string {
if (!stats || !this.config.settings?.showUsageStats) return body;
return `${body}\n\n-# ${formatUsageStats(stats)}`;
}
parseMessageId(compositeId: string): string {
return compositeId;
}
sanitizeUserInput(text: string): string {
return text.replaceAll(new RegExp(`<@!?${this.applicationId}>\\s*`, 'g'), '').trim();
}
}
export class DiscordClientFactory extends ClientFactory {
createClient(config: BotProviderConfig, context: BotPlatformRuntimeContext): PlatformClient {
return new DiscordGatewayClient(config, context);
}
async validateCredentials(credentials: Record<string, string>): Promise<ValidationResult> {
const errors: Array<{ field: string; message: string }> = [];
if (!credentials.botToken) errors.push({ field: 'botToken', message: 'Bot Token is required' });
if (!credentials.publicKey)
errors.push({ field: 'publicKey', message: 'Public Key is required' });
if (errors.length > 0) return { errors, valid: false };
try {
const res = await fetch('https://discord.com/api/v10/users/@me', {
headers: { Authorization: `Bot ${credentials.botToken}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return { valid: true };
} catch {
return {
errors: [{ field: 'botToken', message: 'Failed to authenticate with Discord API' }],
valid: false,
};
}
}
}

View file

@ -0,0 +1,16 @@
import type { PlatformDefinition } from '../types';
import { DiscordClientFactory } from './client';
import { schema } from './schema';
export const discord: PlatformDefinition = {
id: 'discord',
name: 'Discord',
connectionMode: 'websocket',
description: 'Connect a Discord bot',
documentation: {
portalUrl: 'https://discord.com/developers/applications',
setupGuideUrl: 'https://lobehub.com/docs/usage/channels/discord',
},
schema,
clientFactory: new DiscordClientFactory(),
};

View file

@ -1,2 +0,0 @@
export { Discord, type DiscordBotConfig, discordDescriptor } from './bot';
export { DiscordRestApi } from './restApi';

View file

@ -0,0 +1,94 @@
import { DEFAULT_DEBOUNCE_MS, MAX_DEBOUNCE_MS } from '../const';
import type { FieldSchema } from '../types';
export const schema: FieldSchema[] = [
{
key: 'applicationId',
description: 'channel.applicationIdHint',
label: 'channel.applicationId',
required: true,
type: 'string',
},
{
key: 'credentials',
label: 'channel.credentials',
properties: [
{
key: 'publicKey',
description: 'channel.publicKeyHint',
label: 'channel.publicKey',
required: true,
type: 'string',
},
{
key: 'botToken',
description: 'channel.botTokenEncryptedHint',
label: 'channel.botToken',
required: true,
type: 'password',
},
],
type: 'object',
},
{
key: 'settings',
label: 'channel.settings',
properties: [
{
key: 'charLimit',
default: 2000,
description: 'channel.charLimitHint',
label: 'channel.charLimit',
maximum: 2000,
minimum: 100,
type: 'number',
},
{
key: 'debounceMs',
default: DEFAULT_DEBOUNCE_MS,
description: 'channel.debounceMsHint',
label: 'channel.debounceMs',
maximum: MAX_DEBOUNCE_MS,
minimum: 0,
type: 'number',
},
{
key: 'showUsageStats',
default: false,
description: 'channel.showUsageStatsHint',
label: 'channel.showUsageStats',
type: 'boolean',
},
// TODO: DM schema - not implemented yet
// {
// key: 'dm',
// label: 'channel.dm',
// properties: [
// {
// key: 'enabled',
// default: false,
// description: 'channel.dmEnabledHint',
// label: 'channel.dmEnabled',
// type: 'boolean',
// },
// {
// key: 'policy',
// default: 'disabled',
// enum: ['open', 'allowlist', 'disabled'],
// enumLabels: [
// 'channel.dmPolicyOpen',
// 'channel.dmPolicyAllowlist',
// 'channel.dmPolicyDisabled',
// ],
// description: 'channel.dmPolicyHint',
// label: 'channel.dmPolicy',
// type: 'string',
// visibleWhen: { field: 'enabled', value: true },
// },
// ],
// type: 'object',
// },
],
type: 'object',
},
];

View file

@ -0,0 +1,132 @@
import { createLarkAdapter, LarkApiClient } from '@lobechat/chat-adapter-feishu';
import debug from 'debug';
import {
type BotPlatformRuntimeContext,
type BotProviderConfig,
ClientFactory,
type PlatformClient,
type PlatformMessenger,
type UsageStats,
type ValidationResult,
} from '../types';
import { formatUsageStats } from '../utils';
const log = debug('bot-platform:feishu:client');
function extractChatId(platformThreadId: string): string {
return platformThreadId.split(':')[1];
}
/** Resolve the Lark/Feishu domain from settings, defaulting to 'feishu'. */
function resolveDomain(settings: Record<string, unknown>): 'lark' | 'feishu' {
const domain = settings.domain;
return domain === 'lark' ? 'lark' : 'feishu';
}
class FeishuWebhookClient implements PlatformClient {
readonly id = 'feishu';
readonly applicationId: string;
private config: BotProviderConfig;
private domain: 'lark' | 'feishu';
constructor(config: BotProviderConfig, _context: BotPlatformRuntimeContext) {
this.config = config;
this.applicationId = config.applicationId;
this.domain = resolveDomain(config.settings);
}
// --- Lifecycle ---
async start(): Promise<void> {
log('Starting FeishuClient appId=%s domain=%s', this.applicationId, this.domain);
const api = new LarkApiClient(
this.config.applicationId,
this.config.credentials.appSecret,
this.domain,
);
await api.getTenantAccessToken();
log('FeishuClient appId=%s credentials verified', this.applicationId);
}
async stop(): Promise<void> {
log('Stopping FeishuClient appId=%s', this.applicationId);
}
// --- Runtime Operations ---
createAdapter(): Record<string, any> {
return {
feishu: createLarkAdapter({
appId: this.config.applicationId,
appSecret: this.config.credentials.appSecret,
encryptKey: this.config.credentials.encryptKey,
platform: this.domain,
verificationToken: this.config.credentials.verificationToken,
}),
};
}
getMessenger(platformThreadId: string): PlatformMessenger {
const api = new LarkApiClient(
this.config.applicationId,
this.config.credentials.appSecret,
this.domain,
);
const chatId = extractChatId(platformThreadId);
return {
createMessage: (content) => api.sendMessage(chatId, content).then(() => {}),
editMessage: (messageId, content) => api.editMessage(messageId, content).then(() => {}),
removeReaction: () => Promise.resolve(),
triggerTyping: () => Promise.resolve(),
};
}
extractChatId(platformThreadId: string): string {
return extractChatId(platformThreadId);
}
formatReply(body: string, stats?: UsageStats): string {
if (!stats || !this.config.settings?.showUsageStats) return body;
return `${body}\n\n${formatUsageStats(stats)}`;
}
parseMessageId(compositeId: string): string {
return compositeId;
}
}
export class FeishuClientFactory extends ClientFactory {
createClient(config: BotProviderConfig, context: BotPlatformRuntimeContext): PlatformClient {
return new FeishuWebhookClient(config, context);
}
async validateCredentials(
credentials: Record<string, string>,
_settings?: Record<string, unknown>,
applicationId?: string,
): Promise<ValidationResult> {
const errors: Array<{ field: string; message: string }> = [];
if (!applicationId) errors.push({ field: 'applicationId', message: 'App ID is required' });
if (!credentials.appSecret)
errors.push({ field: 'appSecret', message: 'App Secret is required' });
if (errors.length > 0) return { errors, valid: false };
try {
const domain = 'feishu'; // default domain for validation
const api = new LarkApiClient(applicationId!, credentials.appSecret, domain);
await api.getTenantAccessToken();
return { valid: true };
} catch {
return {
errors: [{ field: 'credentials', message: 'Failed to authenticate with Feishu API' }],
valid: false,
};
}
}
}

View file

@ -0,0 +1,16 @@
import type { PlatformDefinition } from '../../types';
import { sharedSchema } from './schema';
import { sharedClientFactory } from './shared';
export const feishu: PlatformDefinition = {
id: 'feishu',
name: 'Feishu',
description: 'Connect a Feishu bot',
documentation: {
portalUrl: 'https://open.feishu.cn/app',
setupGuideUrl: 'https://lobehub.com/docs/usage/channels/feishu',
},
schema: sharedSchema,
showWebhookUrl: true,
clientFactory: sharedClientFactory,
};

View file

@ -0,0 +1,16 @@
import type { PlatformDefinition } from '../../types';
import { sharedSchema } from './schema';
import { sharedClientFactory } from './shared';
export const lark: PlatformDefinition = {
id: 'lark',
name: 'Lark',
description: 'Connect a Lark bot',
documentation: {
portalUrl: 'https://open.larksuite.com/app',
setupGuideUrl: 'https://lobehub.com/docs/usage/channels/lark',
},
schema: sharedSchema,
showWebhookUrl: true,
clientFactory: sharedClientFactory,
};

View file

@ -0,0 +1,101 @@
import { DEFAULT_DEBOUNCE_MS, MAX_DEBOUNCE_MS } from '../../const';
import type { FieldSchema } from '../../types';
export const sharedSchema: FieldSchema[] = [
{
key: 'applicationId',
description: 'channel.applicationIdHint',
label: 'channel.applicationId',
required: true,
type: 'string',
},
{
key: 'credentials',
label: 'channel.credentials',
properties: [
{
key: 'appSecret',
description: 'channel.appSecretHint',
label: 'channel.appSecret',
required: true,
type: 'password',
},
{
key: 'verificationToken',
description: 'channel.verificationTokenHint',
label: 'channel.verificationToken',
required: false,
type: 'password',
},
{
key: 'encryptKey',
description: 'channel.encryptKeyHint',
label: 'channel.encryptKey',
required: false,
type: 'password',
},
],
type: 'object',
},
{
key: 'settings',
label: 'channel.settings',
properties: [
{
key: 'charLimit',
default: 4000,
description: 'channel.charLimitHint',
label: 'channel.charLimit',
maximum: 30_000,
minimum: 100,
type: 'number',
},
{
key: 'debounceMs',
default: DEFAULT_DEBOUNCE_MS,
description: 'channel.debounceMsHint',
label: 'channel.debounceMs',
maximum: MAX_DEBOUNCE_MS,
minimum: 0,
type: 'number',
},
{
key: 'showUsageStats',
default: false,
description: 'channel.showUsageStatsHint',
label: 'channel.showUsageStats',
type: 'boolean',
},
// TODO: DM schema - not implemented yet
// {
// key: 'dm',
// label: 'channel.dm',
// properties: [
// {
// key: 'enabled',
// default: true,
// description: 'channel.dmEnabledHint',
// label: 'channel.dmEnabled',
// type: 'boolean',
// },
// {
// key: 'policy',
// default: 'open',
// enum: ['open', 'allowlist', 'disabled'],
// enumLabels: [
// 'channel.dmPolicyOpen',
// 'channel.dmPolicyAllowlist',
// 'channel.dmPolicyDisabled',
// ],
// description: 'channel.dmPolicyHint',
// label: 'channel.dmPolicy',
// type: 'string',
// visibleWhen: { field: 'enabled', value: true },
// },
// ],
// type: 'object',
// },
],
type: 'object',
},
];

View file

@ -0,0 +1,3 @@
import { FeishuClientFactory } from '../client';
export const sharedClientFactory = new FeishuClientFactory();

View file

@ -1,25 +1,50 @@
import type { PlatformBotClass, PlatformDescriptor } from '../types';
import { Discord, discordDescriptor } from './discord';
import { feishuDescriptor, Lark, larkDescriptor } from './lark';
import { QQ, qqDescriptor } from './qq';
import { Telegram, telegramDescriptor } from './telegram';
// --------------- Core types & utilities ---------------
// --------------- Registry singleton ---------------
import { discord } from './discord/definition';
import { feishu } from './feishu/definitions/feishu';
import { lark } from './feishu/definitions/lark';
import { qq } from './qq/definition';
import { PlatformRegistry } from './registry';
import { slack } from './slack/definition';
import { telegram } from './telegram/definition';
export const platformBotRegistry: Record<string, PlatformBotClass> = {
discord: Discord,
feishu: Lark,
lark: Lark,
qq: QQ,
telegram: Telegram,
};
export { PlatformRegistry } from './registry';
export type {
BotPlatformRedisClient,
BotPlatformRuntimeContext,
BotProviderConfig,
FieldSchema,
PlatformClient,
PlatformDefinition,
PlatformDocumentation,
PlatformMessenger,
SerializedPlatformDefinition,
UsageStats,
ValidationResult,
} from './types';
export { ClientFactory } from './types';
export {
buildRuntimeKey,
extractDefaults,
formatDuration,
formatTokens,
formatUsageStats,
parseRuntimeKey,
} from './utils';
export const platformDescriptors: Record<string, PlatformDescriptor> = {
discord: discordDescriptor,
feishu: feishuDescriptor,
lark: larkDescriptor,
qq: qqDescriptor,
telegram: telegramDescriptor,
};
// --------------- Platform definitions ---------------
export { discord } from './discord/definition';
export { feishu } from './feishu/definitions/feishu';
export { lark } from './feishu/definitions/lark';
export { qq } from './qq/definition';
export { slack } from './slack/definition';
export { telegram } from './telegram/definition';
export function getPlatformDescriptor(platform: string): PlatformDescriptor | undefined {
return platformDescriptors[platform];
}
export const platformRegistry = new PlatformRegistry();
platformRegistry.register(discord);
platformRegistry.register(telegram);
platformRegistry.register(slack);
platformRegistry.register(feishu);
platformRegistry.register(lark);
platformRegistry.register(qq);

View file

@ -1,99 +0,0 @@
import { createLarkAdapter } from '@lobechat/adapter-lark';
import debug from 'debug';
import type { PlatformBot, PlatformDescriptor } from '../../types';
import { LarkRestApi } from './restApi';
const log = debug('lobe-server:bot:gateway:lark');
export interface LarkBotConfig {
[key: string]: string | undefined;
appId: string;
appSecret: string;
/** AES decrypt key for encrypted events (optional) */
encryptKey?: string;
/** 'lark' or 'feishu' — determines API base URL */
platform?: string;
/** Verification token for webhook event validation (optional) */
verificationToken?: string;
}
/**
* Lark/Feishu platform bot.
*
* Unlike Telegram, Lark does not support programmatic webhook registration.
* The user must configure the webhook URL manually in the Lark Developer Console.
* `start()` verifies credentials by fetching a tenant access token.
*/
export class Lark implements PlatformBot {
readonly platform: string;
readonly applicationId: string;
private config: LarkBotConfig;
constructor(config: LarkBotConfig) {
this.config = config;
this.applicationId = config.appId;
this.platform = config.platform || 'lark';
}
async start(): Promise<void> {
log('Starting LarkBot appId=%s platform=%s', this.applicationId, this.platform);
// Verify credentials by fetching a tenant access token
const api = new LarkRestApi(this.config.appId, this.config.appSecret, this.platform);
await api.getTenantAccessToken();
log('LarkBot appId=%s credentials verified', this.applicationId);
}
async stop(): Promise<void> {
log('Stopping LarkBot appId=%s', this.applicationId);
// No cleanup needed — webhook is managed in Lark Developer Console
}
}
// --------------- Platform Descriptor ---------------
function extractChatId(platformThreadId: string): string {
return platformThreadId.split(':')[1];
}
function createLarkDescriptorForPlatform(platform: 'lark' | 'feishu'): PlatformDescriptor {
return {
platform,
charLimit: 4000,
persistent: false,
handleDirectMessages: true,
requiredCredentials: ['appId', 'appSecret'],
extractChatId,
parseMessageId: (compositeId) => compositeId,
createMessenger(credentials, platformThreadId) {
const lark = new LarkRestApi(credentials.appId, credentials.appSecret, platform);
const chatId = extractChatId(platformThreadId);
return {
createMessage: (content) => lark.sendMessage(chatId, content).then(() => {}),
editMessage: (messageId, content) => lark.editMessage(messageId, content),
removeReaction: () => Promise.resolve(),
triggerTyping: () => Promise.resolve(),
};
},
createAdapter(credentials) {
return {
[platform]: createLarkAdapter({
appId: credentials.appId,
appSecret: credentials.appSecret,
encryptKey: credentials.encryptKey,
platform,
verificationToken: credentials.verificationToken,
}),
};
},
};
}
export const larkDescriptor = createLarkDescriptorForPlatform('lark');
export const feishuDescriptor = createLarkDescriptorForPlatform('feishu');

View file

@ -1,2 +0,0 @@
export { feishuDescriptor, Lark, larkDescriptor } from './bot';
export { LarkRestApi } from './restApi';

View file

@ -1,135 +0,0 @@
import debug from 'debug';
const log = debug('lobe-server:bot:lark-rest');
const BASE_URLS: Record<string, string> = {
feishu: 'https://open.feishu.cn/open-apis',
lark: 'https://open.larksuite.com/open-apis',
};
// Lark message limit is ~32KB for content, but we cap text at 4000 chars for readability
const MAX_TEXT_LENGTH = 4000;
/**
* Lightweight wrapper around the Lark/Feishu Open API.
* Used by bot-callback webhooks and BotMessageRouter to send/edit messages directly.
*
* Auth: app_id + app_secret tenant_access_token (cached, auto-refreshed).
*/
export class LarkRestApi {
private readonly appId: string;
private readonly appSecret: string;
private readonly baseUrl: string;
private cachedToken?: string;
private tokenExpiresAt = 0;
constructor(appId: string, appSecret: string, platform: string = 'lark') {
this.appId = appId;
this.appSecret = appSecret;
this.baseUrl = BASE_URLS[platform] || BASE_URLS.lark;
}
// ------------------------------------------------------------------
// Messages
// ------------------------------------------------------------------
async sendMessage(chatId: string, text: string): Promise<{ messageId: string }> {
log('sendMessage: chatId=%s', chatId);
const data = await this.call('POST', '/im/v1/messages?receive_id_type=chat_id', {
content: JSON.stringify({ text: this.truncateText(text) }),
msg_type: 'text',
receive_id: chatId,
});
return { messageId: data.data.message_id };
}
async editMessage(messageId: string, text: string): Promise<void> {
log('editMessage: messageId=%s', messageId);
await this.call('PUT', `/im/v1/messages/${messageId}`, {
content: JSON.stringify({ text: this.truncateText(text) }),
msg_type: 'text',
});
}
async replyMessage(messageId: string, text: string): Promise<{ messageId: string }> {
log('replyMessage: messageId=%s', messageId);
const data = await this.call('POST', `/im/v1/messages/${messageId}/reply`, {
content: JSON.stringify({ text: this.truncateText(text) }),
msg_type: 'text',
});
return { messageId: data.data.message_id };
}
// ------------------------------------------------------------------
// Auth
// ------------------------------------------------------------------
async getTenantAccessToken(): Promise<string> {
if (this.cachedToken && Date.now() < this.tokenExpiresAt) {
return this.cachedToken;
}
log('getTenantAccessToken: refreshing for appId=%s', this.appId);
const response = await fetch(`${this.baseUrl}/auth/v3/tenant_access_token/internal`, {
body: JSON.stringify({ app_id: this.appId, app_secret: this.appSecret }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Lark auth failed: ${response.status} ${text}`);
}
const data = await response.json();
if (data.code !== 0) {
throw new Error(`Lark auth error: ${data.code} ${data.msg}`);
}
this.cachedToken = data.tenant_access_token;
// Expire 5 minutes early to avoid edge cases
this.tokenExpiresAt = Date.now() + (data.expire - 300) * 1000;
return this.cachedToken!;
}
// ------------------------------------------------------------------
// Internal
// ------------------------------------------------------------------
private truncateText(text: string): string {
if (text.length > MAX_TEXT_LENGTH) return text.slice(0, MAX_TEXT_LENGTH - 3) + '...';
return text;
}
private async call(method: string, path: string, body: Record<string, unknown>): Promise<any> {
const token = await this.getTenantAccessToken();
const url = `${this.baseUrl}${path}`;
const response = await fetch(url, {
body: JSON.stringify(body),
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
method,
});
if (!response.ok) {
const text = await response.text();
log('Lark API error: %s %s, status=%d, body=%s', method, path, response.status, text);
throw new Error(`Lark API ${method} ${path} failed: ${response.status} ${text}`);
}
const data = await response.json();
if (data.code !== 0) {
log('Lark API logical error: %s %s, code=%d, msg=%s', method, path, data.code, data.msg);
throw new Error(`Lark API ${method} ${path} failed: ${data.code} ${data.msg}`);
}
return data;
}
}

View file

@ -1,95 +0,0 @@
import { createQQAdapter } from '@lobechat/adapter-qq';
import debug from 'debug';
import type { PlatformBot, PlatformDescriptor } from '../../types';
import { QQRestApi } from './restApi';
const log = debug('lobe-server:bot:gateway:qq');
export interface QQBotConfig {
[key: string]: string | undefined;
appId: string;
appSecret: string;
}
export class QQ implements PlatformBot {
readonly platform = 'qq';
readonly applicationId: string;
private config: QQBotConfig;
constructor(config: QQBotConfig) {
this.config = config;
this.applicationId = config.appId;
}
async start(): Promise<void> {
log('Starting QQBot appId=%s', this.applicationId);
// Verify credentials by fetching an access token
const api = new QQRestApi(this.config.appId, this.config.appSecret!);
await api.getAccessToken();
log('QQBot appId=%s credentials verified', this.applicationId);
}
async stop(): Promise<void> {
log('Stopping QQBot appId=%s', this.applicationId);
// No cleanup needed — webhook is configured in QQ Open Platform
}
}
// --------------- Platform Descriptor ---------------
/**
* Extract the target ID from a QQ platformThreadId.
*
* QQ thread ID format: "qq:<type>:<id>" or "qq:<type>:<id>:<guildId>"
* Returns the <id> portion used for sending messages.
*/
function extractChatId(platformThreadId: string): string {
return platformThreadId.split(':')[2];
}
/**
* Extract the thread type (group, guild, c2c, dms) from a QQ platformThreadId.
*/
function extractThreadType(platformThreadId: string): string {
return platformThreadId.split(':')[1] || 'group';
}
export const qqDescriptor: PlatformDescriptor = {
platform: 'qq',
charLimit: 2000,
persistent: false,
handleDirectMessages: true,
requiredCredentials: ['appId', 'appSecret'],
extractChatId,
parseMessageId: (compositeId) => compositeId,
createMessenger(credentials, platformThreadId) {
const api = new QQRestApi(credentials.appId, credentials.appSecret);
const targetId = extractChatId(platformThreadId);
const threadType = extractThreadType(platformThreadId);
return {
createMessage: (content) => api.sendMessage(threadType, targetId, content).then(() => {}),
editMessage: (_messageId, content) =>
// QQ does not support editing — send a new message as fallback
api.sendAsEdit(threadType, targetId, content).then(() => {}),
// QQ Bot API doesn't support reactions or typing
removeReaction: () => Promise.resolve(),
triggerTyping: () => Promise.resolve(),
};
},
createAdapter(credentials) {
return {
qq: createQQAdapter({
appId: credentials.appId,
clientSecret: credentials.appSecret,
}),
};
},
};

View file

@ -0,0 +1,151 @@
import { createQQAdapter, QQApiClient } from '@lobechat/chat-adapter-qq';
import debug from 'debug';
import {
type BotPlatformRuntimeContext,
type BotProviderConfig,
ClientFactory,
type PlatformClient,
type PlatformMessenger,
type UsageStats,
type ValidationResult,
} from '../types';
import { formatUsageStats } from '../utils';
const log = debug('bot-platform:qq:bot');
function extractChatId(platformThreadId: string): string {
return platformThreadId.split(':')[2];
}
function extractThreadType(platformThreadId: string): string {
return platformThreadId.split(':')[1] || 'group';
}
async function sendQQMessage(
api: QQApiClient,
threadType: string,
targetId: string,
content: string,
): Promise<void> {
switch (threadType) {
case 'group': {
await api.sendGroupMessage(targetId, content);
return;
}
case 'guild': {
await api.sendGuildMessage(targetId, content);
return;
}
case 'c2c': {
await api.sendC2CMessage(targetId, content);
return;
}
case 'dms': {
await api.sendDmsMessage(targetId, content);
return;
}
default: {
await api.sendGroupMessage(targetId, content);
}
}
}
class QQWebhookClient implements PlatformClient {
readonly id = 'qq';
readonly applicationId: string;
private config: BotProviderConfig;
constructor(config: BotProviderConfig, _context: BotPlatformRuntimeContext) {
this.config = config;
this.applicationId = config.applicationId;
}
// --- Lifecycle ---
async start(): Promise<void> {
log('Starting QQBot appId=%s', this.applicationId);
// Verify credentials by fetching an access token
const api = new QQApiClient(this.config.applicationId, this.config.credentials.appSecret);
await api.getAccessToken();
log('QQBot appId=%s credentials verified', this.applicationId);
}
async stop(): Promise<void> {
log('Stopping QQBot appId=%s', this.applicationId);
// No cleanup needed — webhook is configured in QQ Open Platform
}
// --- Runtime Operations ---
createAdapter(): Record<string, any> {
return {
qq: createQQAdapter({
appId: this.config.applicationId,
clientSecret: this.config.credentials.appSecret,
}),
};
}
getMessenger(platformThreadId: string): PlatformMessenger {
const api = new QQApiClient(this.config.applicationId, this.config.credentials.appSecret);
const targetId = extractChatId(platformThreadId);
const threadType = extractThreadType(platformThreadId);
return {
createMessage: (content) => sendQQMessage(api, threadType, targetId, content),
editMessage: (_messageId, content) =>
// QQ does not support editing — send a new message as fallback
sendQQMessage(api, threadType, targetId, content),
// QQ Bot API doesn't support reactions or typing
removeReaction: () => Promise.resolve(),
triggerTyping: () => Promise.resolve(),
};
}
extractChatId(platformThreadId: string): string {
return extractChatId(platformThreadId);
}
formatReply(body: string, stats?: UsageStats): string {
if (!stats || !this.config.settings?.showUsageStats) return body;
return `${body}\n\n${formatUsageStats(stats)}`;
}
parseMessageId(compositeId: string): string {
return compositeId;
}
}
export class QQClientFactory extends ClientFactory {
createClient(config: BotProviderConfig, context: BotPlatformRuntimeContext): PlatformClient {
return new QQWebhookClient(config, context);
}
async validateCredentials(
credentials: Record<string, string>,
_settings?: Record<string, unknown>,
applicationId?: string,
): Promise<ValidationResult> {
const errors: Array<{ field: string; message: string }> = [];
if (!applicationId) errors.push({ field: 'applicationId', message: 'App ID is required' });
if (!credentials.appSecret)
errors.push({ field: 'appSecret', message: 'App Secret is required' });
if (errors.length > 0) return { errors, valid: false };
try {
const api = new QQApiClient(applicationId!, credentials.appSecret);
await api.getAccessToken();
return { valid: true };
} catch {
return {
errors: [{ field: 'credentials', message: 'Failed to authenticate with QQ API' }],
valid: false,
};
}
}
}

View file

@ -0,0 +1,17 @@
import type { PlatformDefinition } from '../types';
import { QQClientFactory } from './client';
import { schema } from './schema';
export const qq: PlatformDefinition = {
id: 'qq',
name: 'QQ',
description: 'Connect a QQ bot',
documentation: {
portalUrl: 'https://q.qq.com/',
setupGuideUrl: 'https://lobehub.com/docs/usage/channels/qq',
},
schema,
showWebhookUrl: true,
supportsMessageEdit: false,
clientFactory: new QQClientFactory(),
};

View file

@ -1,2 +0,0 @@
export { QQ, qqDescriptor } from './bot';
export { QQ_API_BASE, QQRestApi } from './restApi';

View file

@ -1,142 +0,0 @@
import debug from 'debug';
const log = debug('lobe-server:bot:qq-rest');
const AUTH_URL = 'https://bots.qq.com/app/getAppAccessToken';
export const QQ_API_BASE = 'https://api.sgroup.qq.com';
const MAX_TEXT_LENGTH = 2000;
/**
* Lightweight wrapper around the QQ Bot API.
* Used by bot-callback webhooks to send messages directly.
*
* Auth: appId + clientSecret access_token (cached, auto-refreshed).
*/
export class QQRestApi {
private readonly appId: string;
private readonly clientSecret: string;
private cachedToken?: string;
private tokenExpiresAt = 0;
constructor(appId: string, clientSecret: string) {
this.appId = appId;
this.clientSecret = clientSecret;
}
// ------------------------------------------------------------------
// Messages
// ------------------------------------------------------------------
async sendMessage(
threadType: string,
targetId: string,
content: string,
): Promise<{ id: string }> {
log('sendMessage: type=%s, targetId=%s', threadType, targetId);
const path = this.getMessagePath(threadType, targetId);
const data = await this.call<{ id: string }>('POST', path, {
content: this.truncateText(content),
msg_type: 0, // TEXT
});
return { id: data.id };
}
/**
* QQ does not support editing messages.
* Fallback: send a new message instead.
*/
async sendAsEdit(threadType: string, targetId: string, content: string): Promise<{ id: string }> {
log('sendAsEdit (QQ no edit support): type=%s, targetId=%s', threadType, targetId);
return this.sendMessage(threadType, targetId, content);
}
// ------------------------------------------------------------------
// Auth
// ------------------------------------------------------------------
async getAccessToken(): Promise<string> {
if (this.cachedToken && Date.now() < this.tokenExpiresAt) {
return this.cachedToken;
}
log('getAccessToken: refreshing for appId=%s', this.appId);
const response = await fetch(AUTH_URL, {
body: JSON.stringify({ appId: this.appId, clientSecret: this.clientSecret }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
if (!response.ok) {
const text = await response.text();
throw new Error(`QQ auth failed: ${response.status} ${text}`);
}
const data = await response.json();
this.cachedToken = data.access_token;
// Expire 5 minutes early to avoid edge cases
this.tokenExpiresAt = Date.now() + (data.expires_in - 300) * 1000;
return this.cachedToken!;
}
// ------------------------------------------------------------------
// Internal
// ------------------------------------------------------------------
private getMessagePath(threadType: string, targetId: string): string {
switch (threadType) {
case 'group': {
return `/v2/groups/${targetId}/messages`;
}
case 'guild': {
return `/channels/${targetId}/messages`;
}
case 'c2c': {
return `/v2/users/${targetId}/messages`;
}
case 'dms': {
return `/dms/${targetId}/messages`;
}
default: {
return `/v2/groups/${targetId}/messages`;
}
}
}
private truncateText(text: string): string {
if (text.length > MAX_TEXT_LENGTH) return text.slice(0, MAX_TEXT_LENGTH - 3) + '...';
return text;
}
private async call<T>(method: string, path: string, body: Record<string, unknown>): Promise<T> {
const token = await this.getAccessToken();
const url = `${QQ_API_BASE}${path}`;
const response = await fetch(url, {
body: JSON.stringify(body),
headers: {
'Authorization': `QQBot ${token}`,
'Content-Type': 'application/json',
},
method,
});
if (!response.ok) {
const text = await response.text();
log('QQ API error: %s %s, status=%d, body=%s', method, path, response.status, text);
throw new Error(`QQ API ${method} ${path} failed: ${response.status} ${text}`);
}
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
return response.json() as Promise<T>;
}
return {} as T;
}
}

View file

@ -0,0 +1,87 @@
import { DEFAULT_DEBOUNCE_MS, MAX_DEBOUNCE_MS } from '../const';
import type { FieldSchema } from '../types';
export const schema: FieldSchema[] = [
{
key: 'applicationId',
description: 'channel.applicationIdHint',
label: 'channel.applicationId',
required: true,
type: 'string',
},
{
key: 'credentials',
label: 'channel.credentials',
properties: [
{
key: 'appSecret',
description: 'channel.appSecretHint',
label: 'channel.appSecret',
required: true,
type: 'password',
},
],
type: 'object',
},
{
key: 'settings',
label: 'channel.settings',
properties: [
{
key: 'charLimit',
default: 2000,
description: 'channel.charLimitHint',
label: 'channel.charLimit',
maximum: 2000,
minimum: 100,
type: 'number',
},
{
key: 'debounceMs',
default: DEFAULT_DEBOUNCE_MS,
description: 'channel.debounceMsHint',
label: 'channel.debounceMs',
maximum: MAX_DEBOUNCE_MS,
minimum: 0,
type: 'number',
},
{
key: 'showUsageStats',
default: false,
description: 'channel.showUsageStatsHint',
label: 'channel.showUsageStats',
type: 'boolean',
},
// TODO: DM schema - not implemented yet
// {
// key: 'dm',
// label: 'channel.dm',
// properties: [
// {
// key: 'enabled',
// default: true,
// description: 'channel.dmEnabledHint',
// label: 'channel.dmEnabled',
// type: 'boolean',
// },
// {
// key: 'policy',
// default: 'open',
// enum: ['open', 'allowlist', 'disabled'],
// enumLabels: [
// 'channel.dmPolicyOpen',
// 'channel.dmPolicyAllowlist',
// 'channel.dmPolicyDisabled',
// ],
// description: 'channel.dmPolicyHint',
// label: 'channel.dmPolicy',
// type: 'string',
// visibleWhen: { field: 'enabled', value: true },
// },
// ],
// type: 'object',
// },
],
type: 'object',
},
];

View file

@ -0,0 +1,119 @@
import { describe, expect, it, vi } from 'vitest';
import { PlatformRegistry } from './registry';
import { buildRuntimeKey, parseRuntimeKey } from './utils';
describe('PlatformRegistry', () => {
const fakeFactory = (overrides?: any) => ({
createClient: vi.fn(),
validateCredentials: vi.fn().mockResolvedValue({ valid: true }),
validateSettings: vi.fn().mockResolvedValue({ valid: true }),
...overrides,
});
const fakeDef = (id: string, overrides?: any) =>
({
clientFactory: fakeFactory(overrides?.clientFactory),
...overrides,
id,
}) as any;
describe('register / getPlatform', () => {
it('should register and retrieve a platform definition', () => {
const registry = new PlatformRegistry();
const def = fakeDef('test');
registry.register(def);
expect(registry.getPlatform('test')).toBe(def);
});
it('should throw on duplicate registration', () => {
const registry = new PlatformRegistry();
registry.register(fakeDef('test'));
expect(() => registry.register(fakeDef('test'))).toThrow('already registered');
});
it('should return undefined for unknown platform', () => {
const registry = new PlatformRegistry();
expect(registry.getPlatform('unknown')).toBeUndefined();
});
});
describe('listPlatforms', () => {
it('should list all registered definitions', () => {
const registry = new PlatformRegistry();
const a = fakeDef('a');
const b = fakeDef('b');
registry.register(a).register(b);
expect(registry.listPlatforms()).toEqual([a, b]);
});
});
describe('createClient', () => {
it('should delegate to definition.clientFactory.createClient', () => {
const mockClient = { id: 'test' };
const mockCreateClient = vi.fn().mockReturnValue(mockClient);
const registry = new PlatformRegistry();
registry.register(fakeDef('test', { clientFactory: { createClient: mockCreateClient } }));
const config = { applicationId: 'app-1', credentials: {}, platform: 'test', settings: {} };
const result = registry.createClient('test', config);
expect(result).toBe(mockClient);
expect(mockCreateClient).toHaveBeenCalledWith(config, {});
});
it('should throw for unknown platform', () => {
const registry = new PlatformRegistry();
const config = { applicationId: 'app-1', credentials: {}, platform: 'x', settings: {} };
expect(() => registry.createClient('x', config)).toThrow('not registered');
});
});
describe('validateCredentials', () => {
it('should delegate to definition.clientFactory.validateCredentials', async () => {
const mockValidate = vi.fn().mockResolvedValue({ valid: true });
const registry = new PlatformRegistry();
registry.register(fakeDef('test', { clientFactory: { validateCredentials: mockValidate } }));
const result = await registry.validateCredentials('test', { token: 'abc' });
expect(result).toEqual({ valid: true });
expect(mockValidate).toHaveBeenCalledWith({ token: 'abc' }, undefined, undefined);
});
it('should return error for unknown platform', async () => {
const registry = new PlatformRegistry();
const result = await registry.validateCredentials('unknown', {});
expect(result.valid).toBe(false);
});
});
});
describe('buildRuntimeKey', () => {
it('should build a runtime key from entry and applicationId', () => {
expect(buildRuntimeKey('telegram', 'bot-123')).toBe('telegram:bot-123');
});
});
describe('parseRuntimeKey', () => {
it('should parse a runtime key into components', () => {
expect(parseRuntimeKey('discord:app-456')).toEqual({
applicationId: 'app-456',
platform: 'discord',
});
});
it('should roundtrip with buildRuntimeKey', () => {
const key = buildRuntimeKey('feishu', 'my-app');
expect(parseRuntimeKey(key)).toEqual({
applicationId: 'my-app',
platform: 'feishu',
});
});
});

View file

@ -0,0 +1,81 @@
import type {
BotPlatformRuntimeContext,
BotProviderConfig,
PlatformClient,
PlatformDefinition,
SerializedPlatformDefinition,
ValidationResult,
} from './types';
/**
* Platform registry manages all platform definitions.
*
* Integrates with chat-sdk's Chat class by providing adapter creation
* and credential validation through the registered platform definitions.
*/
export class PlatformRegistry {
private platforms = new Map<string, PlatformDefinition>();
/** Register a platform definition. Throws if the platform ID is already registered. */
register(definition: PlatformDefinition): this {
if (this.platforms.has(definition.id)) {
throw new Error(`Platform "${definition.id}" is already registered`);
}
this.platforms.set(definition.id, definition);
return this;
}
/** Get a platform definition by ID. */
getPlatform(platform: string): PlatformDefinition | undefined {
return this.platforms.get(platform);
}
/** List all registered platform definitions. */
listPlatforms(): PlatformDefinition[] {
return [...this.platforms.values()];
}
/** List platform definitions serialized for frontend consumption. */
listSerializedPlatforms(): SerializedPlatformDefinition[] {
return this.listPlatforms().map(({ clientFactory, ...rest }) => rest);
}
/**
* Create a PlatformClient for a given platform.
*
* Looks up the platform definition and delegates to its createClient.
* Throws if the platform is not registered.
*/
createClient(
platform: string,
config: BotProviderConfig,
context?: BotPlatformRuntimeContext,
): PlatformClient {
const definition = this.platforms.get(platform);
if (!definition) {
throw new Error(`Platform "${platform}" is not registered`);
}
return definition.clientFactory.createClient(config, context ?? {});
}
/**
* Validate credentials for a given platform.
*
* Delegates to the platform's clientFactory.validateCredentials.
*/
async validateCredentials(
platform: string,
credentials: Record<string, string>,
settings?: Record<string, unknown>,
applicationId?: string,
): Promise<ValidationResult> {
const definition = this.platforms.get(platform);
if (!definition) {
return {
errors: [{ field: 'platform', message: `Platform "${platform}" is not registered` }],
valid: false,
};
}
return definition.clientFactory.validateCredentials(credentials, settings, applicationId);
}
}

View file

@ -0,0 +1,83 @@
import debug from 'debug';
const log = debug('bot-platform:slack:client');
export const SLACK_API_BASE = 'https://slack.com/api';
/**
* Lightweight Slack Web API client for outbound messaging operations
* used by callback and extension flows outside the Chat SDK adapter surface.
*/
export class SlackApi {
private readonly botToken: string;
constructor(botToken: string) {
this.botToken = botToken;
}
async postMessage(channel: string, text: string): Promise<{ ts: string }> {
log('postMessage: channel=%s', channel);
const data = await this.call('chat.postMessage', { channel, text: this.truncateText(text) });
return { ts: data.ts };
}
async postMessageInThread(
channel: string,
threadTs: string,
text: string,
): Promise<{ ts: string }> {
log('postMessageInThread: channel=%s, thread=%s', channel, threadTs);
const data = await this.call('chat.postMessage', {
channel,
text: this.truncateText(text),
thread_ts: threadTs,
});
return { ts: data.ts };
}
async updateMessage(channel: string, ts: string, text: string): Promise<void> {
log('updateMessage: channel=%s, ts=%s', channel, ts);
await this.call('chat.update', { channel, text: this.truncateText(text), ts });
}
async removeReaction(channel: string, timestamp: string, name: string): Promise<void> {
log('removeReaction: channel=%s, ts=%s, name=%s', channel, timestamp, name);
await this.call('reactions.remove', { channel, name, timestamp });
}
// ------------------------------------------------------------------
private truncateText(text: string): string {
// Slack message limit is ~40000, but we respect the user-configured charLimit
if (text.length > 40_000) return text.slice(0, 39_997) + '...';
return text;
}
private async call(method: string, body: Record<string, unknown>): Promise<any> {
const url = `${SLACK_API_BASE}/${method}`;
const response = await fetch(url, {
body: JSON.stringify(body),
headers: {
'Authorization': `Bearer ${this.botToken}`,
'Content-Type': 'application/json; charset=utf-8',
},
method: 'POST',
});
if (!response.ok) {
const text = await response.text();
log('Slack API error: method=%s, status=%d, body=%s', method, response.status, text);
throw new Error(`Slack API ${method} failed: ${response.status} ${text}`);
}
const data = await response.json();
if (!data.ok) {
log('Slack API logical error: method=%s, error=%s', method, data.error);
throw new Error(`Slack API ${method} failed: ${data.error}`);
}
return data;
}
}

View file

@ -0,0 +1,137 @@
import { createSlackAdapter } from '@chat-adapter/slack';
import debug from 'debug';
import {
type BotPlatformRuntimeContext,
type BotProviderConfig,
ClientFactory,
type PlatformClient,
type PlatformMessenger,
type UsageStats,
type ValidationResult,
} from '../types';
import { formatUsageStats } from '../utils';
import { SLACK_API_BASE, SlackApi } from './api';
const log = debug('bot-platform:slack:bot');
function extractChannelId(platformThreadId: string): string {
// Slack thread IDs from Chat SDK: "slack:<channel>:<threadTs>"
return platformThreadId.split(':')[1];
}
function extractThreadTs(platformThreadId: string): string | undefined {
return platformThreadId.split(':')[2];
}
class SlackWebhookClient implements PlatformClient {
readonly id = 'slack';
readonly applicationId: string;
private config: BotProviderConfig;
private context: BotPlatformRuntimeContext;
constructor(config: BotProviderConfig, context: BotPlatformRuntimeContext) {
this.config = config;
this.context = context;
this.applicationId = config.applicationId;
}
// --- Lifecycle ---
async start(): Promise<void> {
log('Starting SlackBot appId=%s', this.applicationId);
// Slack uses Events API with webhook — no explicit registration needed.
// The webhook URL is configured manually in Slack App settings.
log('SlackBot appId=%s started', this.applicationId);
}
async stop(): Promise<void> {
log('Stopping SlackBot appId=%s', this.applicationId);
// No cleanup needed for webhook mode
}
// --- Runtime Operations ---
createAdapter(): Record<string, any> {
return {
slack: createSlackAdapter({
botToken: this.config.credentials.botToken,
signingSecret: this.config.credentials.signingSecret,
}),
};
}
getMessenger(platformThreadId: string): PlatformMessenger {
const slack = new SlackApi(this.config.credentials.botToken);
const channelId = extractChannelId(platformThreadId);
const threadTs = extractThreadTs(platformThreadId);
return {
createMessage: (content) =>
threadTs
? slack.postMessageInThread(channelId, threadTs, content).then(() => {})
: slack.postMessage(channelId, content).then(() => {}),
editMessage: (messageId, content) => slack.updateMessage(channelId, messageId, content),
removeReaction: (messageId, emoji) => slack.removeReaction(channelId, messageId, emoji),
triggerTyping: () => Promise.resolve(), // Slack has no typing indicator API for bots
};
}
extractChatId(platformThreadId: string): string {
return extractChannelId(platformThreadId);
}
formatReply(body: string, stats?: UsageStats): string {
if (!stats || !this.config.settings?.showUsageStats) return body;
return `${body}\n\n${formatUsageStats(stats)}`;
}
parseMessageId(compositeId: string): string {
return compositeId;
}
sanitizeUserInput(text: string): string {
// Remove bot mention artifacts like <@U12345>
return text.replaceAll(/<@[A-Z\d]+>\s*/g, '').trim();
}
}
export class SlackClientFactory extends ClientFactory {
createClient(config: BotProviderConfig, context: BotPlatformRuntimeContext): PlatformClient {
return new SlackWebhookClient(config, context);
}
async validateCredentials(credentials: Record<string, string>): Promise<ValidationResult> {
if (!credentials.botToken) {
return { errors: [{ field: 'botToken', message: 'Bot Token is required' }], valid: false };
}
if (!credentials.signingSecret) {
return {
errors: [{ field: 'signingSecret', message: 'Signing Secret is required' }],
valid: false,
};
}
try {
const res = await fetch(`${SLACK_API_BASE}/auth.test`, {
headers: {
'Authorization': `Bearer ${credentials.botToken}`,
'Content-Type': 'application/json',
},
method: 'POST',
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as { ok: boolean; error?: string; bot_id?: string };
if (!data.ok) throw new Error(data.error || 'auth.test failed');
return { valid: true };
} catch {
return {
errors: [{ field: 'botToken', message: 'Failed to authenticate with Slack API' }],
valid: false,
};
}
}
}

View file

@ -0,0 +1,16 @@
import type { PlatformDefinition } from '../types';
import { SlackClientFactory } from './client';
import { schema } from './schema';
export const slack: PlatformDefinition = {
id: 'slack',
name: 'Slack',
description: 'Connect a Slack bot',
documentation: {
portalUrl: 'https://api.slack.com/apps',
setupGuideUrl: 'https://lobehub.com/docs/usage/channels/slack',
},
schema,
showWebhookUrl: true,
clientFactory: new SlackClientFactory(),
};

View file

@ -0,0 +1,94 @@
import { DEFAULT_DEBOUNCE_MS, MAX_DEBOUNCE_MS } from '../const';
import type { FieldSchema } from '../types';
export const schema: FieldSchema[] = [
{
key: 'applicationId',
description: 'channel.applicationIdHint',
label: 'channel.applicationId',
required: true,
type: 'string',
},
{
key: 'credentials',
label: 'channel.credentials',
properties: [
{
key: 'botToken',
description: 'channel.botTokenEncryptedHint',
label: 'channel.botToken',
required: true,
type: 'password',
},
{
key: 'signingSecret',
description: 'channel.signingSecretHint',
label: 'channel.signingSecret',
required: true,
type: 'password',
},
],
type: 'object',
},
{
key: 'settings',
label: 'channel.settings',
properties: [
{
key: 'charLimit',
default: 4000,
description: 'channel.charLimitHint',
label: 'channel.charLimit',
maximum: 40_000,
minimum: 100,
type: 'number',
},
{
key: 'debounceMs',
default: DEFAULT_DEBOUNCE_MS,
description: 'channel.debounceMsHint',
label: 'channel.debounceMs',
maximum: MAX_DEBOUNCE_MS,
minimum: 0,
type: 'number',
},
{
key: 'showUsageStats',
default: false,
description: 'channel.showUsageStatsHint',
label: 'channel.showUsageStats',
type: 'boolean',
},
// TODO: DM schema - not implemented yet
// {
// key: 'dm',
// label: 'channel.dm',
// properties: [
// {
// key: 'enabled',
// default: true,
// description: 'channel.dmEnabledHint',
// label: 'channel.dmEnabled',
// type: 'boolean',
// },
// {
// key: 'policy',
// default: 'open',
// enum: ['open', 'allowlist', 'disabled'],
// enumLabels: [
// 'channel.dmPolicyOpen',
// 'channel.dmPolicyAllowlist',
// 'channel.dmPolicyDisabled',
// ],
// description: 'channel.dmPolicyHint',
// label: 'channel.dmPolicy',
// type: 'string',
// visibleWhen: { field: 'enabled', value: true },
// },
// ],
// type: 'object',
// },
],
type: 'object',
},
];

View file

@ -1,15 +1,14 @@
import debug from 'debug';
const log = debug('lobe-server:bot:telegram-rest');
const log = debug('bot-platform:telegram:client');
export const TELEGRAM_API_BASE = 'https://api.telegram.org';
/**
* Lightweight wrapper around the Telegram Bot API.
* Used by bot-callback webhooks to update messages directly
* (bypassing the Chat SDK adapter).
* Lightweight platform client for Telegram Bot API operations used by
* callback and extension flows outside the Chat SDK adapter surface.
*/
export class TelegramRestApi {
export class TelegramApi {
private readonly botToken: string;
constructor(botToken: string) {
@ -27,11 +26,17 @@ export class TelegramRestApi {
async editMessageText(chatId: string | number, messageId: number, text: string): Promise<void> {
log('editMessageText: chatId=%s, messageId=%s', chatId, messageId);
await this.call('editMessageText', {
chat_id: chatId,
message_id: messageId,
text: this.truncateText(text),
});
try {
await this.call('editMessageText', {
chat_id: chatId,
message_id: messageId,
text: this.truncateText(text),
});
} catch (error: any) {
// Telegram returns 400 when the new content is identical to the current message — safe to ignore
if (error?.message?.includes('message is not modified')) return;
throw error;
}
}
async sendChatAction(chatId: string | number, action = 'typing'): Promise<void> {

View file

@ -1,165 +0,0 @@
import { createTelegramAdapter } from '@chat-adapter/telegram';
import debug from 'debug';
import { appEnv } from '@/envs/app';
import type { PlatformBot, PlatformDescriptor, PlatformMessenger } from '../../types';
import { TELEGRAM_API_BASE, TelegramRestApi } from './restApi';
const log = debug('lobe-server:bot:gateway:telegram');
export interface TelegramBotConfig {
[key: string]: string | undefined;
botToken: string;
secretToken?: string;
/** Optional HTTPS proxy URL for webhook (e.g. cloudflare tunnel for local dev) */
webhookProxyUrl?: string;
}
/**
* Extract the bot user ID from a Telegram bot token.
* Telegram bot tokens have the format: `<bot_id>:<secret>`.
*/
function extractBotId(botToken: string): string {
const colonIndex = botToken.indexOf(':');
if (colonIndex === -1) return botToken;
return botToken.slice(0, colonIndex);
}
/**
* Call Telegram setWebhook API. Idempotent safe to call on every startup.
*/
export async function setTelegramWebhook(
botToken: string,
url: string,
secretToken?: string,
): Promise<void> {
const params: Record<string, string> = { url };
if (secretToken) {
params.secret_token = secretToken;
}
const response = await fetch(`${TELEGRAM_API_BASE}/bot${botToken}/setWebhook`, {
body: JSON.stringify(params),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to set Telegram webhook: ${response.status} ${text}`);
}
}
export class Telegram implements PlatformBot {
readonly platform = 'telegram';
readonly applicationId: string;
private config: TelegramBotConfig;
constructor(config: TelegramBotConfig) {
this.config = config;
this.applicationId = extractBotId(config.botToken);
}
async start(): Promise<void> {
log('Starting TelegramBot appId=%s', this.applicationId);
// Set the webhook URL so Telegram pushes updates to us.
// Include applicationId in the path so the router can do a direct lookup
// without iterating all registered bots.
// Always call setWebhook (it's idempotent) to ensure Telegram-side
// secret_token stays in sync with the adapter config.
const baseUrl = (this.config.webhookProxyUrl || appEnv.APP_URL || '').trim().replace(/\/$/, '');
const webhookUrl = `${baseUrl}/api/agent/webhooks/telegram/${this.applicationId}`;
await this.setWebhookInternal(webhookUrl);
log('TelegramBot appId=%s started', this.applicationId);
}
async stop(): Promise<void> {
log('Stopping TelegramBot appId=%s', this.applicationId);
// Optionally remove the webhook on stop
try {
await this.deleteWebhook();
} catch (error) {
log('Failed to delete webhook for appId=%s: %O', this.applicationId, error);
}
}
private async setWebhookInternal(url: string): Promise<void> {
await setTelegramWebhook(this.config.botToken, url, this.config.secretToken);
log('TelegramBot appId=%s webhook set to %s', this.applicationId, url);
}
private async deleteWebhook(): Promise<void> {
const response = await fetch(`${TELEGRAM_API_BASE}/bot${this.config.botToken}/deleteWebhook`, {
method: 'POST',
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to delete Telegram webhook: ${response.status} ${text}`);
}
log('TelegramBot appId=%s webhook deleted', this.applicationId);
}
}
// --------------- Platform Descriptor ---------------
function extractChatId(platformThreadId: string): string {
return platformThreadId.split(':')[1];
}
function parseTelegramMessageId(compositeId: string): number {
const colonIdx = compositeId.lastIndexOf(':');
return colonIdx !== -1 ? Number(compositeId.slice(colonIdx + 1)) : Number(compositeId);
}
function createTelegramMessenger(telegram: TelegramRestApi, chatId: string): PlatformMessenger {
return {
createMessage: (content) => telegram.sendMessage(chatId, content).then(() => {}),
editMessage: (messageId, content) =>
telegram.editMessageText(chatId, parseTelegramMessageId(messageId), content),
removeReaction: (messageId) =>
telegram.removeMessageReaction(chatId, parseTelegramMessageId(messageId)),
triggerTyping: () => telegram.sendChatAction(chatId, 'typing'),
};
}
export const telegramDescriptor: PlatformDescriptor = {
platform: 'telegram',
charLimit: 4000,
persistent: false,
handleDirectMessages: true,
requiredCredentials: ['botToken'],
extractChatId,
parseMessageId: parseTelegramMessageId,
createMessenger(credentials, platformThreadId) {
const telegram = new TelegramRestApi(credentials.botToken);
const chatId = extractChatId(platformThreadId);
return createTelegramMessenger(telegram, chatId);
},
createAdapter(credentials) {
return {
telegram: createTelegramAdapter({
botToken: credentials.botToken,
secretToken: credentials.secretToken,
}),
};
},
async onBotRegistered({ applicationId, credentials }) {
const baseUrl = (credentials.webhookProxyUrl || appEnv.APP_URL || '').replace(/\/$/, '');
const webhookUrl = `${baseUrl}/api/agent/webhooks/telegram/${applicationId}`;
await setTelegramWebhook(credentials.botToken, webhookUrl, credentials.secretToken).catch(
(err) => {
log('Failed to set Telegram webhook for appId=%s: %O', applicationId, err);
},
);
},
};

View file

@ -0,0 +1,135 @@
import { createTelegramAdapter } from '@chat-adapter/telegram';
import debug from 'debug';
import {
type BotPlatformRuntimeContext,
type BotProviderConfig,
ClientFactory,
type PlatformClient,
type PlatformMessenger,
type UsageStats,
type ValidationResult,
} from '../types';
import { formatUsageStats } from '../utils';
import { TELEGRAM_API_BASE, TelegramApi } from './api';
import { extractBotId, setTelegramWebhook } from './helpers';
const log = debug('bot-platform:telegram:bot');
function extractChatId(platformThreadId: string): string {
return platformThreadId.split(':')[1];
}
function parseTelegramMessageId(compositeId: string): number {
const colonIdx = compositeId.lastIndexOf(':');
return colonIdx !== -1 ? Number(compositeId.slice(colonIdx + 1)) : Number(compositeId);
}
class TelegramWebhookClient implements PlatformClient {
readonly id = 'telegram';
readonly applicationId: string;
private config: BotProviderConfig;
private context: BotPlatformRuntimeContext;
constructor(config: BotProviderConfig, context: BotPlatformRuntimeContext) {
this.config = config;
this.context = context;
this.applicationId = extractBotId(config.credentials.botToken);
}
// --- Lifecycle ---
async start(): Promise<void> {
log('Starting TelegramBot appId=%s', this.applicationId);
const baseUrl = (this.config.credentials.webhookProxyUrl || this.context.appUrl || '')
.trim()
.replace(/\/$/, '');
const webhookUrl = `${baseUrl}/api/agent/webhooks/telegram/${this.applicationId}`;
await setTelegramWebhook(
this.config.credentials.botToken,
webhookUrl,
this.config.credentials.secretToken || undefined,
);
log('TelegramBot appId=%s started, webhook=%s', this.applicationId, webhookUrl);
}
async stop(): Promise<void> {
log('Stopping TelegramBot appId=%s', this.applicationId);
try {
const response = await fetch(
`${TELEGRAM_API_BASE}/bot${this.config.credentials.botToken}/deleteWebhook`,
{ method: 'POST' },
);
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to delete Telegram webhook: ${response.status} ${text}`);
}
log('TelegramBot appId=%s webhook deleted', this.applicationId);
} catch (error) {
log('Failed to delete webhook for appId=%s: %O', this.applicationId, error);
}
}
// --- Runtime Operations ---
createAdapter(): Record<string, any> {
return {
telegram: createTelegramAdapter({
botToken: this.config.credentials.botToken,
secretToken: this.config.credentials.secretToken || undefined,
}),
};
}
getMessenger(platformThreadId: string): PlatformMessenger {
const telegram = new TelegramApi(this.config.credentials.botToken);
const chatId = extractChatId(platformThreadId);
return {
createMessage: (content) => telegram.sendMessage(chatId, content).then(() => {}),
editMessage: (messageId, content) =>
telegram.editMessageText(chatId, parseTelegramMessageId(messageId), content),
removeReaction: (messageId) =>
telegram.removeMessageReaction(chatId, parseTelegramMessageId(messageId)),
triggerTyping: () => telegram.sendChatAction(chatId, 'typing'),
};
}
extractChatId(platformThreadId: string): string {
return extractChatId(platformThreadId);
}
formatReply(body: string, stats?: UsageStats): string {
if (!stats || !this.config.settings?.showUsageStats) return body;
return `${body}\n\n${formatUsageStats(stats)}`;
}
parseMessageId(compositeId: string): number {
return parseTelegramMessageId(compositeId);
}
}
export class TelegramClientFactory extends ClientFactory {
createClient(config: BotProviderConfig, context: BotPlatformRuntimeContext): PlatformClient {
return new TelegramWebhookClient(config, context);
}
async validateCredentials(credentials: Record<string, string>): Promise<ValidationResult> {
if (!credentials.botToken) {
return { errors: [{ field: 'botToken', message: 'Bot Token is required' }], valid: false };
}
try {
const res = await fetch(`${TELEGRAM_API_BASE}/bot${credentials.botToken}/getMe`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return { valid: true };
} catch {
return {
errors: [{ field: 'botToken', message: 'Failed to authenticate with Telegram API' }],
valid: false,
};
}
}
}

View file

@ -0,0 +1,15 @@
import type { PlatformDefinition } from '../types';
import { TelegramClientFactory } from './client';
import { schema } from './schema';
export const telegram: PlatformDefinition = {
id: 'telegram',
name: 'Telegram',
description: 'Connect a Telegram bot',
documentation: {
portalUrl: 'https://t.me/BotFather',
setupGuideUrl: 'https://lobehub.com/docs/usage/channels/telegram',
},
schema,
clientFactory: new TelegramClientFactory(),
};

View file

@ -0,0 +1,36 @@
import { TELEGRAM_API_BASE } from './api';
/**
* Extract the bot user ID from a Telegram bot token.
* Telegram bot tokens have the format: `<bot_id>:<secret>`.
*/
export function extractBotId(botToken: string): string {
const colonIndex = botToken.indexOf(':');
if (colonIndex === -1) return botToken;
return botToken.slice(0, colonIndex);
}
/**
* Call Telegram setWebhook API. Idempotent safe to call on every startup.
*/
export async function setTelegramWebhook(
botToken: string,
url: string,
secretToken?: string,
): Promise<void> {
const params: Record<string, string> = { url };
if (secretToken) {
params.secret_token = secretToken;
}
const response = await fetch(`${TELEGRAM_API_BASE}/bot${botToken}/setWebhook`, {
body: JSON.stringify(params),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to set Telegram webhook: ${response.status} ${text}`);
}
}

View file

@ -1,2 +0,0 @@
export { setTelegramWebhook, Telegram, type TelegramBotConfig, telegramDescriptor } from './bot';
export { TELEGRAM_API_BASE, TelegramRestApi } from './restApi';

View file

@ -0,0 +1,95 @@
import { DEFAULT_DEBOUNCE_MS, MAX_DEBOUNCE_MS } from '../const';
import type { FieldSchema } from '../types';
export const schema: FieldSchema[] = [
{
key: 'credentials',
label: 'channel.credentials',
properties: [
{
key: 'botToken',
description: 'channel.botTokenEncryptedHint',
label: 'channel.botToken',
required: true,
type: 'password',
},
{
key: 'secretToken',
description: 'channel.secretTokenHint',
label: 'channel.secretToken',
required: false,
type: 'password',
},
{
devOnly: true,
key: 'webhookProxyUrl',
description: 'channel.devWebhookProxyUrlHint',
label: 'channel.devWebhookProxyUrl',
required: false,
type: 'string',
},
],
type: 'object',
},
{
key: 'settings',
label: 'channel.settings',
properties: [
{
key: 'charLimit',
default: 4000,
description: 'channel.charLimitHint',
label: 'channel.charLimit',
maximum: 4096,
minimum: 100,
type: 'number',
},
{
key: 'debounceMs',
default: DEFAULT_DEBOUNCE_MS,
description: 'channel.debounceMsHint',
label: 'channel.debounceMs',
maximum: MAX_DEBOUNCE_MS,
minimum: 0,
type: 'number',
},
{
key: 'showUsageStats',
default: false,
description: 'channel.showUsageStatsHint',
label: 'channel.showUsageStats',
type: 'boolean',
},
// TODO: DM schema - not implemented yet
// {
// key: 'dm',
// label: 'channel.dm',
// properties: [
// {
// key: 'enabled',
// default: true,
// description: 'channel.dmEnabledHint',
// label: 'channel.dmEnabled',
// type: 'boolean',
// },
// {
// key: 'policy',
// default: 'open',
// enum: ['open', 'allowlist', 'disabled'],
// enumLabels: [
// 'channel.dmPolicyOpen',
// 'channel.dmPolicyAllowlist',
// 'channel.dmPolicyDisabled',
// ],
// description: 'channel.dmPolicyHint',
// label: 'channel.dmPolicy',
// type: 'string',
// visibleWhen: { field: 'enabled', value: true },
// },
// ],
// type: 'object',
// },
],
type: 'object',
},
];

View file

@ -0,0 +1,254 @@
// ============================================================================
// Bot Platform Core Types
// ============================================================================
// --------------- Field Schema ---------------
/**
* Unified field schema for both credentials and settings.
*
* Drives:
* - Server: validation + default value extraction
* - Frontend: auto-generated form (type component mapping)
*/
export interface FieldSchema {
/** Default value */
default?: unknown;
description?: string;
/** Only show in development environment */
devOnly?: boolean;
/** Enum options for select fields */
enum?: string[];
/** Display labels for enum options */
enumLabels?: string[];
/** Array item schema */
items?: FieldSchema;
/** Unique field identifier */
key: string;
/** Display label */
label: string;
maximum?: number;
minimum?: number;
placeholder?: string;
/** Nested fields (for type: 'object') */
properties?: FieldSchema[];
required?: boolean;
/**
* Field type, maps to UI component:
* - 'string' Input
* - 'password' Password input
* - 'number' / 'integer' NumberInput
* - 'boolean' Switch
* - 'object' nested group
* - 'array' list
*/
type: 'array' | 'boolean' | 'integer' | 'number' | 'object' | 'password' | 'string';
/** Conditional visibility: show only when another field matches a value */
visibleWhen?: { field: string; value: unknown };
}
// --------------- Platform Messenger ---------------
/**
* LobeHub-specific outbound capabilities used by callback and bridge services.
*/
export interface PlatformMessenger {
createMessage: (content: string) => Promise<void>;
editMessage: (messageId: string, content: string) => Promise<void>;
removeReaction: (messageId: string, emoji: string) => Promise<void>;
triggerTyping: () => Promise<void>;
updateThreadName?: (name: string) => Promise<void>;
}
// --------------- Usage Stats ---------------
/**
* Raw usage statistics for a bot response.
* Passed to `PlatformClient.formatReply` so each platform can decide
* whether and how to render usage information.
*/
export interface UsageStats {
elapsedMs?: number;
llmCalls?: number;
toolCalls?: number;
totalCost: number;
totalTokens: number;
}
// --------------- Platform Client ---------------
/**
* A client to a specific platform instance, holding credentials and runtime context.
*
* Server services interact with the platform through this interface only.
* All platform-specific operations are encapsulated here.
*/
export interface PlatformClient {
readonly applicationId: string;
/** Create a Chat SDK adapter config for inbound message handling. */
createAdapter: () => Record<string, any>;
/** Extract the chat/channel ID from a composite platformThreadId. */
extractChatId: (platformThreadId: string) => string;
/**
* Format the final outbound reply from body content and optional usage stats.
* Each platform decides whether to render the stats and how to format them
* (e.g. Discord uses `-# stats` when the user enables usage display).
* When not implemented, the caller returns body as-is (no stats).
*/
formatReply?: (body: string, stats?: UsageStats) => string;
/** Get a messenger for a specific thread (outbound messaging). */
getMessenger: (platformThreadId: string) => PlatformMessenger;
// --- Runtime Operations ---
readonly id: string;
/** Parse a composite message ID into the platform-native format. */
parseMessageId: (compositeId: string) => string | number;
/** Strip platform-specific bot mention artifacts from user input. */
sanitizeUserInput?: (text: string) => string;
/**
* Whether the bot should subscribe to a thread. Default: true.
* Discord: returns false for top-level channels (not threads).
*/
shouldSubscribe?: (threadId: string) => boolean;
// --- Lifecycle ---
start: (options?: any) => Promise<void>;
stop: () => Promise<void>;
}
// --------------- Provider Config ---------------
/**
* Represents a concrete bot provider configuration.
* Corresponds to a row in the `agentBotProviders` table.
*/
export interface BotProviderConfig {
applicationId: string;
credentials: Record<string, string>;
platform: string;
settings: Record<string, unknown>;
}
// --------------- Runtime Context ---------------
export interface BotPlatformRedisClient {
del: (key: string) => Promise<number>;
get: (key: string) => Promise<string | null>;
set: (key: string, value: string, options?: { ex?: number }) => Promise<string | null>;
subscribe?: (channel: string, callback: (message: string) => void) => Promise<void>;
}
export interface BotPlatformRuntimeContext {
appUrl?: string;
redisClient?: BotPlatformRedisClient;
registerByToken?: (token: string) => void;
}
// --------------- Validation ---------------
export interface ValidationResult {
errors?: Array<{ field: string; message: string }>;
valid: boolean;
}
// --------------- Platform Documentation ---------------
export interface PlatformDocumentation {
/** URL to the platform's developer portal / open platform console */
portalUrl?: string;
/** URL to the usage documentation (e.g. LobeHub docs for this platform) */
setupGuideUrl?: string;
}
// --------------- Client Factory ---------------
/**
* Abstract base class for creating PlatformClient instances.
*
* - `createClient` (abstract): instantiate a PlatformClient (e.g. based on connectionMode)
* - `validateCredentials`: verify credentials against the platform API called from UI flow only
* - `validateSettings`: validate platform-specific settings called from UI flow only
*/
export abstract class ClientFactory {
/** Create a PlatformClient instance. Fast and sync — no network calls. */
abstract createClient(
config: BotProviderConfig,
context: BotPlatformRuntimeContext,
): PlatformClient;
/**
* Verify credentials against the platform API.
* Called explicitly from the UI/API layer when the user saves credentials.
*/
async validateCredentials(
_credentials: Record<string, string>,
_settings?: Record<string, unknown>,
_applicationId?: string,
): Promise<ValidationResult> {
return { valid: true };
}
/**
* Validate platform-specific settings.
* Called explicitly from the UI/API layer when the user saves settings.
*/
async validateSettings(_settings: Record<string, unknown>): Promise<ValidationResult> {
return { valid: true };
}
}
// --------------- Platform Definition ---------------
/**
* A platform definition, uniquely identified by `id`.
*
* Contains metadata, factory, and validation. All runtime operations go through PlatformClient.
*/
export interface PlatformDefinition {
/** Factory for creating PlatformClient instances and validating credentials/settings. */
clientFactory: ClientFactory;
/**
* Connection mode: how the platform communicates with the server.
* - 'webhook': stateless HTTP callbacks (can run in serverless)
* - 'websocket': persistent connection (requires long-running process)
* Defaults to 'webhook'.
*/
connectionMode?: 'webhook' | 'websocket';
/** The description of the platform. */
description?: string;
/** Documentation links for the platform */
documentation?: PlatformDocumentation;
/** The unique identifier of the platform. */
id: string;
/** The name of the platform. */
name: string;
/** Field schema — top-level objects `credentials` and `settings` map to DB columns. */
schema: FieldSchema[];
/** Whether to show webhook URL for manual configuration. When true, the UI displays the webhook endpoint for the user to copy. */
showWebhookUrl?: boolean;
/**
* Whether the platform supports editing sent messages.
* When false, step progress updates are skipped and only the final reply is sent.
* Defaults to true.
*/
supportsMessageEdit?: boolean;
}
/** Serialized platform definition for frontend consumption (excludes runtime-only fields). */
export type SerializedPlatformDefinition = Omit<PlatformDefinition, 'clientFactory'>;

View file

@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import { formatDuration, formatTokens, formatUsageStats } from './utils';
describe('formatTokens', () => {
it('should return raw number for < 1000', () => {
expect(formatTokens(0)).toBe('0');
expect(formatTokens(999)).toBe('999');
});
it('should format thousands as k', () => {
expect(formatTokens(1000)).toBe('1.0k');
expect(formatTokens(1234)).toBe('1.2k');
expect(formatTokens(20_400)).toBe('20.4k');
});
it('should format millions as m', () => {
expect(formatTokens(1_000_000)).toBe('1.0m');
expect(formatTokens(1_234_567)).toBe('1.2m');
});
});
describe('formatDuration', () => {
it('should format seconds', () => {
expect(formatDuration(5000)).toBe('5s');
expect(formatDuration(0)).toBe('0s');
});
it('should format minutes and seconds', () => {
expect(formatDuration(65_000)).toBe('1m5s');
expect(formatDuration(120_000)).toBe('2m0s');
});
});
describe('formatUsageStats', () => {
it('should format basic stats', () => {
expect(formatUsageStats({ totalCost: 0.0312, totalTokens: 1234 })).toBe(
'1.2k tokens · $0.0312',
);
});
it('should include duration when provided', () => {
expect(formatUsageStats({ elapsedMs: 3000, totalCost: 0.01, totalTokens: 500 })).toBe(
'500 tokens · $0.0100 · 3s',
);
});
it('should include call counts when llmCalls > 1', () => {
expect(
formatUsageStats({ llmCalls: 3, toolCalls: 2, totalCost: 0.05, totalTokens: 2000 }),
).toBe('2.0k tokens · $0.0500 | llm×3 | tools×2');
});
it('should include call counts when toolCalls > 0', () => {
expect(formatUsageStats({ llmCalls: 1, toolCalls: 5, totalCost: 0.01, totalTokens: 800 })).toBe(
'800 tokens · $0.0100 | llm×1 | tools×5',
);
});
it('should hide call counts when llmCalls=1 and toolCalls=0', () => {
expect(
formatUsageStats({ llmCalls: 1, toolCalls: 0, totalCost: 0.001, totalTokens: 100 }),
).toBe('100 tokens · $0.0010');
});
});

View file

@ -0,0 +1,91 @@
import type { FieldSchema, UsageStats } from './types';
// --------------- Settings defaults ---------------
/**
* Recursively extract default values from a FieldSchema.
*/
function extractFieldDefault(field: FieldSchema): unknown {
if (field.type === 'object' && field.properties) {
const obj: Record<string, unknown> = {};
for (const child of field.properties) {
const value = extractFieldDefault(child);
if (value !== undefined) obj[child.key] = value;
}
return Object.keys(obj).length > 0 ? obj : undefined;
}
return field.default;
}
/**
* Extract defaults from a FieldSchema array.
*
* Recursively walks the fields and collects all `default` values.
* Use this to merge with user-provided settings at runtime:
*
* const settings = { ...extractDefaults(definition.settings), ...provider.settings };
*/
export function extractDefaults(fields?: FieldSchema[]): Record<string, unknown> {
if (!fields) return {};
const result: Record<string, unknown> = {};
for (const field of fields) {
const value = extractFieldDefault(field);
if (value !== undefined) result[field.key] = value;
}
return result;
}
// --------------- Runtime key helpers ---------------
/**
* Build a runtime key for a registered bot instance.
* Format: `platform:applicationId`
*/
export function buildRuntimeKey(platform: string, applicationId: string): string {
return `${platform}:${applicationId}`;
}
/**
* Parse a runtime key back into its components.
*/
export function parseRuntimeKey(key: string): {
applicationId: string;
platform: string;
} {
const idx = key.indexOf(':');
return {
applicationId: idx === -1 ? key : key.slice(idx + 1),
platform: idx === -1 ? '' : key.slice(0, idx),
};
}
// --------------- Formatting helpers ---------------
export function formatTokens(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}m`;
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`;
return String(tokens);
}
export function formatDuration(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes > 0) return `${minutes}m${seconds}s`;
return `${seconds}s`;
}
/**
* Format usage stats into a human-readable line.
* e.g. "1.2k tokens · $0.0312 · 3s | llm×5 | tools×4"
*/
export function formatUsageStats(stats: UsageStats): string {
const { totalTokens, totalCost, elapsedMs, llmCalls, toolCalls } = stats;
const time = elapsedMs && elapsedMs > 0 ? ` · ${formatDuration(elapsedMs)}` : '';
const calls =
(llmCalls && llmCalls > 1) || (toolCalls && toolCalls > 0)
? ` | llm×${llmCalls ?? 0} | tools×${toolCalls ?? 0}`
: '';
return `${formatTokens(totalTokens)} tokens · $${totalCost.toFixed(4)}${time}${calls}`;
}

View file

@ -1,11 +1,11 @@
import type { StepPresentationData } from '../agentRuntime/types';
import { getExtremeAck } from './ackPhrases';
import { formatDuration } from './platforms';
// Use raw Unicode emoji instead of Chat SDK emoji placeholders,
// because bot-callback webhooks send via DiscordRestApi directly
// because bot-callback webhooks send via DiscordPlatformClient directly
// (not through the Chat SDK adapter that resolves placeholders).
const EMOJI_THINKING = '💭';
const EMOJI_SUCCESS = '✅';
// ==================== Message Splitting ====================
@ -46,7 +46,6 @@ export interface RenderStepParams extends StepPresentationData {
elapsedMs?: number;
lastContent?: string;
lastToolsCalling?: ToolCallItem[];
platform?: string;
totalToolCalls?: number;
}
@ -107,44 +106,14 @@ function formatCompletedTools(
.join('\n');
}
export function formatTokens(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}m`;
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`;
return String(tokens);
}
export { formatDuration, formatTokens } from './platforms';
export function formatDuration(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
function renderProgressHeader(params: { elapsedMs?: number; totalToolCalls?: number }): string {
const { elapsedMs, totalToolCalls } = params;
if (!totalToolCalls || totalToolCalls <= 0) return '';
if (minutes > 0) return `${minutes}m${seconds}s`;
return `${seconds}s`;
}
function renderInlineStats(params: {
elapsedMs?: number;
platform?: string;
totalCost: number;
totalTokens: number;
totalToolCalls?: number;
}): { footer: string; header: string } {
const { elapsedMs, platform, totalToolCalls, totalTokens, totalCost } = params;
const time = elapsedMs && elapsedMs > 0 ? ` · ${formatDuration(elapsedMs)}` : '';
const header =
totalToolCalls && totalToolCalls > 0
? `> total **${totalToolCalls}** tools calling ${time}\n\n`
: '';
if (totalTokens <= 0) return { footer: '', header };
const stats = `${formatTokens(totalTokens)} tokens · $${totalCost.toFixed(4)}`;
// Discord uses -# for small text; other platforms render it as literal text
const useSmallText = !platform || platform === 'discord';
const footer = useSmallText ? `\n\n-# ${stats}` : `\n\n${stats}`;
return { footer, header };
return `> total **${totalToolCalls}** tools calling ${time}\n\n`;
}
// ==================== 1. Start ====================
@ -154,77 +123,44 @@ export const renderStart = getExtremeAck;
// ==================== 2. LLM Generating ====================
/**
* LLM step just finished. Three sub-states:
* - has reasoning (thinking)
* - pure text content
* - has tool calls (about to execute tools)
* LLM step just finished. Returns the message body (no usage stats).
* Stats are handled separately via `PlatformClient.formatReply`.
*/
export function renderLLMGenerating(params: RenderStepParams): string {
const {
content,
elapsedMs,
lastContent,
platform,
reasoning,
toolsCalling,
totalCost,
totalTokens,
totalToolCalls,
} = params;
const { content, elapsedMs, lastContent, reasoning, toolsCalling, totalToolCalls } = params;
const displayContent = (content || lastContent)?.trim();
const { header, footer } = renderInlineStats({
elapsedMs,
platform,
totalCost,
totalTokens,
totalToolCalls,
});
const header = renderProgressHeader({ elapsedMs, totalToolCalls });
// Sub-state: LLM decided to call tools → show content + pending tool calls (○)
if (toolsCalling && toolsCalling.length > 0) {
const toolsList = formatPendingTools(toolsCalling);
if (displayContent) return `${header}${displayContent}\n\n${toolsList}${footer}`;
return `${header}${toolsList}${footer}`;
if (displayContent) return `${header}${displayContent}\n\n${toolsList}`;
return `${header}${toolsList}`;
}
// Sub-state: has reasoning (thinking)
if (reasoning && !content) {
return `${header}${EMOJI_THINKING} ${reasoning?.trim()}${footer}`;
return `${header}${EMOJI_THINKING} ${reasoning?.trim()}`;
}
// Sub-state: pure text content (waiting for next step)
if (displayContent) {
return `${header}${displayContent}${footer}`;
return `${header}${displayContent}`;
}
return `${header}${EMOJI_THINKING} Processing...${footer}`;
return `${header}${EMOJI_THINKING} Processing...`;
}
// ==================== 3. Tool Executing ====================
/**
* Tool step just finished, LLM is next.
* Shows completed tools with results ().
* Returns the message body (no usage stats).
*/
export function renderToolExecuting(params: RenderStepParams): string {
const {
elapsedMs,
lastContent,
lastToolsCalling,
platform,
toolsResult,
totalCost,
totalTokens,
totalToolCalls,
} = params;
const { header, footer } = renderInlineStats({
elapsedMs,
platform,
totalCost,
totalTokens,
totalToolCalls,
});
const { elapsedMs, lastContent, lastToolsCalling, toolsResult, totalToolCalls } = params;
const header = renderProgressHeader({ elapsedMs, totalToolCalls });
const parts: string[] = [];
@ -239,30 +175,17 @@ export function renderToolExecuting(params: RenderStepParams): string {
parts.push(`${EMOJI_THINKING} Processing...`);
}
return parts.join('\n\n') + footer;
return parts.join('\n\n');
}
// ==================== 4. Final Output ====================
export function renderFinalReply(
content: string,
params: {
elapsedMs?: number;
llmCalls: number;
platform?: string;
toolCalls: number;
totalCost: number;
totalTokens: number;
},
): string {
const { totalTokens, totalCost, llmCalls, toolCalls, elapsedMs, platform } = params;
const time = elapsedMs && elapsedMs > 0 ? ` · ${formatDuration(elapsedMs)}` : '';
const calls = llmCalls > 1 || toolCalls > 0 ? ` | llm×${llmCalls} | tools×${toolCalls}` : '';
const stats = `${formatTokens(totalTokens)} tokens · $${totalCost.toFixed(4)}${time}${calls}`;
// Discord uses -# for small text; other platforms render it as literal text
const useSmallText = !platform || platform === 'discord';
const footer = useSmallText ? `-# ${stats}` : stats;
return `${content.trimEnd()}\n\n${footer}`;
/**
* Returns the final reply body (content only, no usage stats).
* Stats are handled separately via `PlatformClient.formatReply`.
*/
export function renderFinalReply(content: string): string {
return content.trimEnd();
}
export function renderError(errorMessage: string): string {
@ -273,13 +196,11 @@ export function renderError(errorMessage: string): string {
/**
* Dispatch to the correct template based on step state.
* Returns message body only caller handles stats via platform.
*/
export function renderStepProgress(params: RenderStepParams): string {
if (params.stepType === 'call_llm') {
// LLM step finished → about to execute tools
return renderLLMGenerating(params);
}
// Tool step finished → LLM is next
return renderToolExecuting(params);
}

View file

@ -1,92 +1,4 @@
// --------------- Platform Messenger ---------------
export interface PlatformMessenger {
createMessage: (content: string) => Promise<void>;
editMessage: (messageId: string, content: string) => Promise<void>;
removeReaction: (messageId: string, emoji: string) => Promise<void>;
triggerTyping: () => Promise<void>;
updateThreadName?: (name: string) => Promise<void>;
}
// --------------- Platform Bot (lifecycle) ---------------
export interface PlatformBot {
readonly applicationId: string;
readonly platform: string;
start: () => Promise<void>;
stop: () => Promise<void>;
}
export type PlatformBotClass = (new (config: any) => PlatformBot) & {
/** Whether instances require a persistent connection (e.g. WebSocket). */
persistent?: boolean;
};
// --------------- Platform Descriptor ---------------
/**
* Encapsulates all platform-specific behavior.
*
* Adding a new bot platform only requires:
* 1. Create a new file in `platforms/` implementing a descriptor + PlatformBot class.
* 2. Register in `platforms/index.ts`.
*
* No switch statements or conditionals needed in BotMessageRouter, BotCallbackService,
* or AgentBridgeService.
* Re-export core platform types.
*/
export interface PlatformDescriptor {
/** Maximum characters per message. Undefined = use default (1800). */
charLimit?: number;
/** Create a Chat SDK adapter config object keyed by adapter name. */
createAdapter: (
credentials: Record<string, string>,
applicationId: string,
) => Record<string, any>;
/** Create a PlatformMessenger for sending/editing messages via REST API. */
createMessenger: (
credentials: Record<string, string>,
platformThreadId: string,
) => PlatformMessenger;
/** Extract the chat/channel ID from a composite platformThreadId. */
extractChatId: (platformThreadId: string) => string;
// ---------- Thread/Message ID parsing ----------
/**
* Whether to register onNewMessage handler for direct messages.
* Telegram & Lark need this; Discord does not (would cause unsolicited replies).
*/
handleDirectMessages: boolean;
/**
* Called after a bot is registered in BotMessageRouter.loadAgentBots().
* Discord: indexes bot by token for gateway forwarding.
* Telegram: calls setWebhook API.
*/
onBotRegistered?: (context: {
applicationId: string;
credentials: Record<string, string>;
registerByToken?: (token: string) => void;
}) => Promise<void>;
// ---------- Credential validation ----------
/** Parse a composite message ID into the platform-native format. */
parseMessageId: (compositeId: string) => string | number;
// ---------- Factories ----------
/** Whether the platform uses persistent connections (WebSocket/Gateway). */
persistent: boolean;
/** Platform identifier (e.g., 'discord', 'telegram', 'lark'). */
platform: string;
// ---------- Lifecycle hooks ----------
/** Required credential field names for this platform. */
requiredCredentials: string[];
}
export type { PlatformClient, PlatformMessenger } from './platforms';

View file

@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getServerDB } from '@/database/core/db-adaptor';
import { AgentBotProviderModel } from '@/database/models/agentBotProvider';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import type { PlatformClient, PlatformDefinition } from '@/server/services/bot/platforms';
import { createGatewayManager, GatewayManager, getGatewayManager } from './GatewayManager';
@ -28,16 +29,38 @@ vi.mock('@/server/modules/KeyVaultsEncrypt', () => ({
},
}));
// Helper: create a mock PlatformBot instance
const createMockBot = () => ({
// Helper: create a mock PlatformClient instance
const createMockBot = (): PlatformClient => ({
applicationId: 'app-1',
createAdapter: () => ({}),
extractChatId: (id: string) => id,
getMessenger: () => ({
createMessage: async () => {},
editMessage: async () => {},
removeReaction: async () => {},
triggerTyping: async () => {},
}),
parseMessageId: (id: string) => id,
id: 'slack',
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
});
// Helper: create a mock PlatformBot class (constructor)
const createMockBotClass = (instance = createMockBot()) => {
return vi.fn().mockImplementation(() => instance);
};
// Helper: create a fake definition that returns the given bot
const createFakeDefinition = (
id: string,
factoryFn?: (...args: any[]) => PlatformClient,
): PlatformDefinition =>
({
clientFactory: {
createClient: factoryFn || (() => createMockBot()),
validateCredentials: async () => ({ valid: true }),
validateSettings: async () => ({ valid: true }),
},
credentials: [],
name: id,
id,
}) as any;
describe('GatewayManager', () => {
let mockDb: any;
@ -69,20 +92,19 @@ describe('GatewayManager', () => {
describe('constructor and isRunning', () => {
it('should initialize with isRunning = false', () => {
const manager = new GatewayManager({ registry: {} });
const manager = new GatewayManager({ definitions: [] });
expect(manager.isRunning).toBe(false);
});
it('should accept a registry configuration', () => {
const BotClass = createMockBotClass();
const manager = new GatewayManager({ registry: { slack: BotClass } });
it('should accept a definitions configuration', () => {
const manager = new GatewayManager({ definitions: [createFakeDefinition('slack')] });
expect(manager.isRunning).toBe(false);
});
});
describe('start', () => {
it('should set isRunning to true after start', async () => {
const manager = new GatewayManager({ registry: {} });
const manager = new GatewayManager({ definitions: [] });
await manager.start();
@ -90,7 +112,7 @@ describe('GatewayManager', () => {
});
it('should not start again if already running', async () => {
const manager = new GatewayManager({ registry: {} });
const manager = new GatewayManager({ definitions: [] });
await manager.start();
expect(manager.isRunning).toBe(true);
@ -110,7 +132,7 @@ describe('GatewayManager', () => {
it('should continue starting even if initial sync fails', async () => {
vi.mocked(getServerDB).mockRejectedValueOnce(new Error('DB connection failed'));
const manager = new GatewayManager({ registry: {} });
const manager = new GatewayManager({ definitions: [] });
// Should not throw
await expect(manager.start()).resolves.toBeUndefined();
@ -120,7 +142,7 @@ describe('GatewayManager', () => {
describe('stop', () => {
it('should set isRunning to false after stop', async () => {
const manager = new GatewayManager({ registry: {} });
const manager = new GatewayManager({ definitions: [] });
await manager.start();
expect(manager.isRunning).toBe(true);
@ -130,7 +152,7 @@ describe('GatewayManager', () => {
});
it('should do nothing if not running', async () => {
const manager = new GatewayManager({ registry: {} });
const manager = new GatewayManager({ definitions: [] });
// Should not throw
await expect(manager.stop()).resolves.toBeUndefined();
@ -140,22 +162,19 @@ describe('GatewayManager', () => {
it('should stop all running bots', async () => {
const mockBot1 = createMockBot();
const mockBot2 = createMockBot();
const BotClass = vi
.fn()
.mockImplementationOnce(() => mockBot1)
.mockImplementationOnce(() => mockBot2);
const factory = vi.fn().mockReturnValueOnce(mockBot1).mockReturnValueOnce(mockBot2);
// Pre-load two bots by calling startBot
// Pre-load two bots by calling startClient
mockAgentBotProviderModel.findEnabledByApplicationId.mockResolvedValue({
applicationId: 'app-1',
credentials: { token: 'tok1' },
});
const manager = new GatewayManager({ registry: { slack: BotClass } });
const manager = new GatewayManager({ definitions: [createFakeDefinition('slack', factory)] });
await manager.start();
await manager.startBot('slack', 'app-1', 'user-1');
await manager.startBot('slack', 'app-2', 'user-2');
await manager.startClient('slack', 'app-1', 'user-1');
await manager.startClient('slack', 'app-2', 'user-2');
await manager.stop();
@ -165,27 +184,27 @@ describe('GatewayManager', () => {
});
});
describe('startBot', () => {
describe('startClient', () => {
it('should do nothing when no provider is found in DB', async () => {
mockAgentBotProviderModel.findEnabledByApplicationId.mockResolvedValue(null);
const BotClass = createMockBotClass();
const factory = vi.fn();
const manager = new GatewayManager({ registry: { slack: BotClass } });
const manager = new GatewayManager({ definitions: [createFakeDefinition('slack', factory)] });
await manager.startBot('slack', 'app-123', 'user-abc');
await manager.startClient('slack', 'app-123', 'user-abc');
expect(BotClass).not.toHaveBeenCalled();
expect(factory).not.toHaveBeenCalled();
});
it('should do nothing when the platform is not in registry', async () => {
it('should do nothing when the platform is not registered', async () => {
mockAgentBotProviderModel.findEnabledByApplicationId.mockResolvedValue({
applicationId: 'app-123',
credentials: { token: 'tok' },
});
const manager = new GatewayManager({ registry: {} }); // empty registry
const manager = new GatewayManager({ definitions: [] }); // empty definitions
await manager.startBot('unsupported', 'app-123', 'user-abc');
await manager.startClient('unsupported', 'app-123', 'user-abc');
// No bot should be created
expect(vi.mocked(AgentBotProviderModel)).toHaveBeenCalled();
@ -193,115 +212,85 @@ describe('GatewayManager', () => {
it('should start a bot and register it', async () => {
const mockBot = createMockBot();
const BotClass = createMockBotClass(mockBot);
const factory = vi.fn().mockReturnValue(mockBot);
mockAgentBotProviderModel.findEnabledByApplicationId.mockResolvedValue({
applicationId: 'app-123',
credentials: { token: 'tok123' },
});
const manager = new GatewayManager({ registry: { slack: BotClass } });
const manager = new GatewayManager({ definitions: [createFakeDefinition('slack', factory)] });
await manager.startBot('slack', 'app-123', 'user-abc');
await manager.startClient('slack', 'app-123', 'user-abc');
expect(BotClass).toHaveBeenCalledWith({
token: 'tok123',
applicationId: 'app-123',
platform: 'slack',
});
expect(factory).toHaveBeenCalled();
expect(mockBot.start).toHaveBeenCalled();
});
it('should stop existing bot before starting a new one for the same key', async () => {
const mockBot1 = createMockBot();
const mockBot2 = createMockBot();
const BotClass = vi
.fn()
.mockImplementationOnce(() => mockBot1)
.mockImplementationOnce(() => mockBot2);
const factory = vi.fn().mockReturnValueOnce(mockBot1).mockReturnValueOnce(mockBot2);
mockAgentBotProviderModel.findEnabledByApplicationId.mockResolvedValue({
applicationId: 'app-123',
credentials: { token: 'tok' },
});
const manager = new GatewayManager({ registry: { slack: BotClass } });
const manager = new GatewayManager({ definitions: [createFakeDefinition('slack', factory)] });
// Start bot first time
await manager.startBot('slack', 'app-123', 'user-abc');
await manager.startClient('slack', 'app-123', 'user-abc');
expect(mockBot1.start).toHaveBeenCalled();
// Start bot second time for same key — should stop first
await manager.startBot('slack', 'app-123', 'user-abc');
await manager.startClient('slack', 'app-123', 'user-abc');
expect(mockBot1.stop).toHaveBeenCalled();
expect(mockBot2.start).toHaveBeenCalled();
});
it('should pass credentials merged with applicationId to the bot constructor', async () => {
const mockBot = createMockBot();
const BotClass = createMockBotClass(mockBot);
mockAgentBotProviderModel.findEnabledByApplicationId.mockResolvedValue({
applicationId: 'my-app',
credentials: { apiKey: 'key-abc', secret: 'sec-xyz' },
});
const manager = new GatewayManager({ registry: { discord: BotClass } });
await manager.startBot('discord', 'my-app', 'user-xyz');
expect(BotClass).toHaveBeenCalledWith({
apiKey: 'key-abc',
secret: 'sec-xyz',
applicationId: 'my-app',
platform: 'discord',
});
});
});
describe('stopBot', () => {
describe('stopClient', () => {
it('should do nothing when bot is not found', async () => {
const manager = new GatewayManager({ registry: {} });
const manager = new GatewayManager({ definitions: [] });
// Should not throw
await expect(manager.stopBot('slack', 'app-123')).resolves.toBeUndefined();
await expect(manager.stopClient('slack', 'app-123')).resolves.toBeUndefined();
});
it('should stop and remove a running bot', async () => {
const mockBot = createMockBot();
const BotClass = createMockBotClass(mockBot);
const factory = vi.fn().mockReturnValue(mockBot);
mockAgentBotProviderModel.findEnabledByApplicationId.mockResolvedValue({
applicationId: 'app-123',
credentials: { token: 'tok' },
});
const manager = new GatewayManager({ registry: { slack: BotClass } });
const manager = new GatewayManager({ definitions: [createFakeDefinition('slack', factory)] });
// First start the bot
await manager.startBot('slack', 'app-123', 'user-abc');
await manager.startClient('slack', 'app-123', 'user-abc');
expect(mockBot.start).toHaveBeenCalled();
// Then stop it
await manager.stopBot('slack', 'app-123');
await manager.stopClient('slack', 'app-123');
expect(mockBot.stop).toHaveBeenCalled();
});
it('should not affect other bots when stopping one', async () => {
const mockBot1 = createMockBot();
const mockBot2 = createMockBot();
const BotClass = vi
.fn()
.mockImplementationOnce(() => mockBot1)
.mockImplementationOnce(() => mockBot2);
const factory = vi.fn().mockReturnValueOnce(mockBot1).mockReturnValueOnce(mockBot2);
mockAgentBotProviderModel.findEnabledByApplicationId
.mockResolvedValueOnce({ applicationId: 'app-1', credentials: {} })
.mockResolvedValueOnce({ applicationId: 'app-2', credentials: {} });
const manager = new GatewayManager({ registry: { slack: BotClass } });
const manager = new GatewayManager({ definitions: [createFakeDefinition('slack', factory)] });
await manager.startBot('slack', 'app-1', 'user-1');
await manager.startBot('slack', 'app-2', 'user-2');
await manager.startClient('slack', 'app-1', 'user-1');
await manager.startClient('slack', 'app-2', 'user-2');
await manager.stopBot('slack', 'app-1');
await manager.stopClient('slack', 'app-1');
expect(mockBot1.stop).toHaveBeenCalled();
expect(mockBot2.stop).not.toHaveBeenCalled();
@ -325,19 +314,19 @@ describe('createGatewayManager / getGatewayManager', () => {
});
it('should create and return a GatewayManager instance', () => {
const manager = createGatewayManager({ registry: {} });
const manager = createGatewayManager({ definitions: [] });
expect(manager).toBeInstanceOf(GatewayManager);
});
it('should return the same instance on subsequent calls (singleton)', () => {
const manager1 = createGatewayManager({ registry: {} });
const manager2 = createGatewayManager({ registry: { slack: vi.fn() as any } });
const manager1 = createGatewayManager({ definitions: [] });
const manager2 = createGatewayManager({ definitions: [createFakeDefinition('slack')] });
expect(manager1).toBe(manager2);
});
it('should be accessible via getGatewayManager after creation', () => {
const created = createGatewayManager({ registry: {} });
const created = createGatewayManager({ definitions: [] });
const retrieved = getGatewayManager();
expect(retrieved).toBe(created);

View file

@ -2,23 +2,32 @@ import debug from 'debug';
import { getServerDB } from '@/database/core/db-adaptor';
import { AgentBotProviderModel } from '@/database/models/agentBotProvider';
import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import type { PlatformBot, PlatformBotClass } from '../bot/types';
import {
type BotPlatformRuntimeContext,
type BotProviderConfig,
buildRuntimeKey,
type PlatformClient,
type PlatformDefinition,
} from '@/server/services/bot/platforms';
const log = debug('lobe-server:bot-gateway');
export interface GatewayManagerConfig {
registry: Record<string, PlatformBotClass>;
definitions: PlatformDefinition[];
}
export class GatewayManager {
private bots = new Map<string, PlatformBot>();
private clients = new Map<string, PlatformClient>();
private running = false;
private config: GatewayManagerConfig;
private definitionByPlatform: Map<string, PlatformDefinition>;
constructor(config: GatewayManagerConfig) {
this.config = config;
this.definitionByPlatform = new Map(this.config.definitions.map((e) => [e.id, e]));
}
get isRunning(): boolean {
@ -42,7 +51,7 @@ export class GatewayManager {
});
this.running = true;
log('GatewayManager started with %d bots', this.bots.size);
log('GatewayManager started with %d clients', this.clients.size);
}
async stop(): Promise<void> {
@ -50,29 +59,29 @@ export class GatewayManager {
log('Stopping GatewayManager');
for (const [key, bot] of this.bots) {
log('Stopping bot %s', key);
await bot.stop();
for (const [key, client] of this.clients) {
log('Stopping client %s', key);
await client.stop();
}
this.bots.clear();
this.clients.clear();
this.running = false;
log('GatewayManager stopped');
}
// ------------------------------------------------------------------
// Bot operations (point-to-point)
// Client operations (point-to-point)
// ------------------------------------------------------------------
async startBot(platform: string, applicationId: string, userId: string): Promise<void> {
const key = `${platform}:${applicationId}`;
async startClient(platform: string, applicationId: string, userId: string): Promise<void> {
const key = buildRuntimeKey(platform, applicationId);
// Stop existing if any
const existing = this.bots.get(key);
const existing = this.clients.get(key);
if (existing) {
log('Stopping existing bot %s before restart', key);
log('Stopping existing client %s before restart', key);
await existing.stop();
this.bots.delete(key);
this.clients.delete(key);
}
// Load from DB (user-scoped, single row)
@ -86,25 +95,25 @@ export class GatewayManager {
return;
}
const bot = this.createBot(platform, provider);
if (!bot) {
const client = this.createClient(platform, provider);
if (!client) {
log('Unsupported platform: %s', platform);
return;
}
await bot.start();
this.bots.set(key, bot);
log('Started bot %s', key);
await client.start();
this.clients.set(key, client);
log('Started client %s', key);
}
async stopBot(platform: string, applicationId: string): Promise<void> {
const key = `${platform}:${applicationId}`;
const bot = this.bots.get(key);
if (!bot) return;
async stopClient(platform: string, applicationId: string): Promise<void> {
const key = buildRuntimeKey(platform, applicationId);
const client = this.clients.get(key);
if (!client) return;
await bot.stop();
this.bots.delete(key);
log('Stopped bot %s', key);
await client.stop();
this.clients.delete(key);
log('Stopped client %s', key);
}
// ------------------------------------------------------------------
@ -112,7 +121,7 @@ export class GatewayManager {
// ------------------------------------------------------------------
private async sync(): Promise<void> {
for (const platform of Object.keys(this.config.registry)) {
for (const platform of this.definitionByPlatform.keys()) {
try {
await this.syncPlatform(platform);
} catch (error) {
@ -136,40 +145,40 @@ export class GatewayManager {
for (const provider of providers) {
const { applicationId, credentials } = provider;
const key = `${platform}:${applicationId}`;
const key = buildRuntimeKey(platform, applicationId);
activeKeys.add(key);
log('Sync: processing provider %s, hasCredentials=%s', key, !!credentials);
const existing = this.bots.get(key);
const existing = this.clients.get(key);
if (existing) {
log('Sync: bot %s already running, skipping', key);
log('Sync: client %s already running, skipping', key);
continue;
}
const bot = this.createBot(platform, provider);
if (!bot) {
log('Sync: createBot returned null for %s', key);
const client = this.createClient(platform, provider);
if (!client) {
log('Sync: createClient returned null for %s', key);
continue;
}
try {
await bot.start();
this.bots.set(key, bot);
log('Sync: started bot %s', key);
await client.start();
this.clients.set(key, client);
log('Sync: started client %s', key);
} catch (err) {
log('Sync: failed to start bot %s: %O', key, err);
log('Sync: failed to start client %s: %O', key, err);
}
}
// Stop bots that are no longer in DB
for (const [key, bot] of this.bots) {
// Stop clients that are no longer in DB
for (const [key, client] of this.clients) {
if (!key.startsWith(`${platform}:`)) continue;
if (activeKeys.has(key)) continue;
log('Sync: bot %s removed from DB, stopping', key);
await bot.stop();
this.bots.delete(key);
log('Sync: client %s removed from DB, stopping', key);
await client.stop();
this.clients.delete(key);
}
}
@ -177,21 +186,33 @@ export class GatewayManager {
// Factory
// ------------------------------------------------------------------
private createBot(
private createClient(
platform: string,
provider: { applicationId: string; credentials: Record<string, string> },
): PlatformBot | null {
const BotClass = this.config.registry[platform];
if (!BotClass) {
log('No bot class registered for platform: %s', platform);
provider: {
applicationId: string;
credentials: Record<string, string>;
settings?: Record<string, unknown> | null;
},
): PlatformClient | null {
const def = this.definitionByPlatform.get(platform);
if (!def) {
log('No definition registered for platform: %s', platform);
return null;
}
return new BotClass({
...provider.credentials,
const config: BotProviderConfig = {
applicationId: provider.applicationId,
credentials: provider.credentials,
platform,
});
settings: (provider.settings as Record<string, unknown>) || {},
};
const context: BotPlatformRuntimeContext = {
appUrl: process.env.APP_URL,
redisClient: getAgentRuntimeRedisClient() as any,
};
return def.clientFactory.createClient(config, context);
}
}

Some files were not shown because too many files have changed in this diff Show more