mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
🔨 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:
parent
a64f4bf7ab
commit
e18855aa25
105 changed files with 5209 additions and 3495 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
428
docs/development/basic/add-new-bot-platform.mdx
Normal file
428
docs/development/basic/add-new-bot-platform.mdx
Normal 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)
|
||||
426
docs/development/basic/add-new-bot-platform.zh-CN.mdx
Normal file
426
docs/development/basic/add-new-bot-platform.zh-CN.mdx
Normal 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` 中能正确解析(或添加别名)
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
---
|
||||
title: Connect LobeHub to Discord
|
||||
description: >-
|
||||
Learn how to create a Discord bot and connect it to your LobeHub agent as a
|
||||
message channel, allowing your AI assistant to interact with users directly in
|
||||
Discord servers and direct messages.
|
||||
Learn how to create a Discord bot and connect it to your LobeHub agent as a message channel, allowing your AI assistant to interact with users directly in Discord servers and direct messages.
|
||||
|
||||
|
||||
tags:
|
||||
- Discord
|
||||
- Message Channels
|
||||
|
|
@ -14,7 +14,8 @@ tags:
|
|||
# Connect LobeHub to Discord
|
||||
|
||||
<Callout type={'info'}>
|
||||
This feature is currently in development and may not be fully stable. You can enable it by turning on **Developer Mode** in **Settings** → **Advanced Settings** → **Developer Mode**.
|
||||
This feature is currently in development and may not be fully stable. You can enable it by turning
|
||||
on **Developer Mode** in **Settings** → **Advanced Settings** → **Developer Mode**.
|
||||
</Callout>
|
||||
|
||||
By connecting a Discord channel to your LobeHub agent, users can interact with the AI assistant directly through Discord server channels and direct messages.
|
||||
|
|
@ -29,6 +30,8 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
|
|||
<Steps>
|
||||
### Go to the Discord Developer Portal
|
||||
|
||||

|
||||
|
||||
Visit the [Discord Developer Portal](https://discord.com/developers/applications) and click **New Application**. Give your application a name (e.g., "LobeHub Assistant") and click **Create**.
|
||||
|
||||
### Create a Bot
|
||||
|
|
@ -37,6 +40,8 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
|
|||
|
||||
### Enable Privileged Gateway Intents
|
||||
|
||||

|
||||
|
||||
On the Bot settings page, scroll down to **Privileged Gateway Intents** and enable:
|
||||
|
||||
- **Message Content Intent** — Required for the bot to read message content
|
||||
|
|
@ -47,12 +52,16 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
|
|||
|
||||
### Copy the Bot Token
|
||||
|
||||

|
||||
|
||||
On the **Bot** page, click **Reset Token** to generate your bot token. Copy and save it securely.
|
||||
|
||||
> **Important:** Treat your bot token like a password. Never share it publicly or commit it to version control.
|
||||
|
||||
### Copy the Application ID and Public Key
|
||||
|
||||

|
||||
|
||||
Go to **General Information** in the left sidebar. Copy and save:
|
||||
|
||||
- **Application ID**
|
||||
|
|
@ -70,6 +79,8 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
|
|||
|
||||
### Fill in the Credentials
|
||||
|
||||

|
||||
|
||||
Enter the following fields:
|
||||
|
||||
- **Application ID** — The Application ID from your Discord app's General Information page
|
||||
|
|
@ -88,6 +99,8 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
|
|||
<Steps>
|
||||
### Generate an Invite URL
|
||||
|
||||

|
||||
|
||||
In the Discord Developer Portal, go to **OAuth2** → **URL Generator**. Select the following scopes:
|
||||
|
||||
- `bot`
|
||||
|
|
@ -104,6 +117,8 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
|
|||
|
||||
### Authorize the Bot
|
||||
|
||||

|
||||
|
||||
Copy the generated URL, open it in your browser, select the server you want to add the bot to, and click **Authorize**.
|
||||
</Steps>
|
||||
|
||||
|
|
|
|||
145
docs/usage/channels/slack.mdx
Normal file
145
docs/usage/channels/slack.mdx
Normal 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.
|
||||
143
docs/usage/channels/slack.zh-CN.mdx
Normal file
143
docs/usage/channels/slack.zh-CN.mdx
Normal 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 Token(xoxb-...)
|
||||
- **签名密钥** — 来自 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 Token(xoxb-...) |
|
||||
| **签名密钥** | 是 | 用于验证来自 Slack 的 Webhook 请求 |
|
||||
|
||||
## 故障排除
|
||||
|
||||
- **机器人未响应:** 确认机器人已被邀请到频道,且事件订阅已正确配置了正确的 Webhook URL。
|
||||
- **测试连接失败:** 仔细检查应用 ID 和 Bot Token 是否正确。确保应用已安装到工作区。
|
||||
- **Webhook 验证失败:** 确保签名密钥与 Slack 应用 Basic Information 页面中的一致。
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "连接测试失败",
|
||||
|
|
|
|||
13
package.json
13
package.json
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -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",
|
||||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
79
src/libs/debug-file-logger.ts
Normal file
79
src/libs/debug-file-logger.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
201
src/routes/(main)/agent/channel/detail/Footer.tsx
Normal file
201
src/routes/(main)/agent/channel/detail/Footer.tsx
Normal 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;
|
||||
65
src/routes/(main)/agent/channel/detail/Header.tsx
Normal file
65
src/routes/(main)/agent/channel/detail/Header.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
import type { FormItemProps } from '@lobehub/ui';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import { FormInput, FormPassword } from '@/components/FormInput';
|
||||
|
||||
import type { ChannelProvider } from '../../const';
|
||||
|
||||
export const getDiscordFormItems = (
|
||||
t: TFunction<'agent'>,
|
||||
hasConfig: boolean,
|
||||
provider: ChannelProvider,
|
||||
): FormItemProps[] => [
|
||||
{
|
||||
children: <FormInput placeholder={t('channel.applicationIdPlaceholder')} />,
|
||||
desc: t('channel.applicationIdHint'),
|
||||
label: t('channel.applicationId'),
|
||||
name: 'applicationId',
|
||||
rules: [{ required: true }],
|
||||
tag: provider.fieldTags.appId,
|
||||
},
|
||||
{
|
||||
children: (
|
||||
<FormPassword
|
||||
autoComplete="new-password"
|
||||
placeholder={
|
||||
hasConfig ? t('channel.botTokenPlaceholderExisting') : t('channel.botTokenPlaceholderNew')
|
||||
}
|
||||
/>
|
||||
),
|
||||
desc: t('channel.botTokenEncryptedHint'),
|
||||
label: t('channel.botToken'),
|
||||
name: 'botToken',
|
||||
rules: [{ required: true }],
|
||||
tag: provider.fieldTags.token,
|
||||
},
|
||||
{
|
||||
children: <FormInput placeholder={t('channel.publicKeyPlaceholder')} />,
|
||||
desc: t('channel.publicKeyHint'),
|
||||
label: t('channel.publicKey'),
|
||||
name: 'publicKey',
|
||||
tag: provider.fieldTags.publicKey,
|
||||
},
|
||||
];
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import type { FormItemProps } from '@lobehub/ui';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import { FormInput, FormPassword } from '@/components/FormInput';
|
||||
|
||||
import type { ChannelProvider } from '../../const';
|
||||
|
||||
export const getFeishuFormItems = (
|
||||
t: TFunction<'agent'>,
|
||||
hasConfig: boolean,
|
||||
provider: ChannelProvider,
|
||||
): FormItemProps[] => [
|
||||
{
|
||||
children: <FormInput placeholder={t('channel.applicationIdPlaceholder')} />,
|
||||
desc: t('channel.applicationIdHint'),
|
||||
label: t('channel.applicationId'),
|
||||
name: 'applicationId',
|
||||
rules: [{ required: true }],
|
||||
tag: provider.fieldTags.appId,
|
||||
},
|
||||
{
|
||||
children: (
|
||||
<FormPassword
|
||||
autoComplete="new-password"
|
||||
placeholder={
|
||||
hasConfig ? t('channel.botTokenPlaceholderExisting') : t('channel.appSecretPlaceholder')
|
||||
}
|
||||
/>
|
||||
),
|
||||
desc: t('channel.botTokenEncryptedHint'),
|
||||
label: t('channel.appSecret'),
|
||||
name: 'appSecret',
|
||||
rules: [{ required: true }],
|
||||
tag: provider.fieldTags.appSecret,
|
||||
},
|
||||
{
|
||||
children: <FormInput placeholder={t('channel.verificationTokenPlaceholder')} />,
|
||||
desc: t('channel.verificationTokenHint'),
|
||||
label: t('channel.verificationToken'),
|
||||
name: 'verificationToken',
|
||||
tag: provider.fieldTags.verificationToken,
|
||||
},
|
||||
{
|
||||
children: <FormPassword placeholder={t('channel.encryptKeyPlaceholder')} />,
|
||||
desc: t('channel.encryptKeyHint'),
|
||||
label: t('channel.encryptKey'),
|
||||
name: 'encryptKey',
|
||||
tag: provider.fieldTags.encryptKey,
|
||||
},
|
||||
];
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import type { FormItemProps } from '@lobehub/ui';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import { FormInput, FormPassword } from '@/components/FormInput';
|
||||
|
||||
import type { ChannelProvider } from '../../const';
|
||||
|
||||
export const getLarkFormItems = (
|
||||
t: TFunction<'agent'>,
|
||||
hasConfig: boolean,
|
||||
provider: ChannelProvider,
|
||||
): FormItemProps[] => [
|
||||
{
|
||||
children: <FormInput placeholder={t('channel.applicationIdPlaceholder')} />,
|
||||
desc: t('channel.applicationIdHint'),
|
||||
label: t('channel.applicationId'),
|
||||
name: 'applicationId',
|
||||
rules: [{ required: true }],
|
||||
tag: provider.fieldTags.appId,
|
||||
},
|
||||
{
|
||||
children: (
|
||||
<FormPassword
|
||||
autoComplete="new-password"
|
||||
placeholder={
|
||||
hasConfig ? t('channel.botTokenPlaceholderExisting') : t('channel.appSecretPlaceholder')
|
||||
}
|
||||
/>
|
||||
),
|
||||
desc: t('channel.botTokenEncryptedHint'),
|
||||
label: t('channel.appSecret'),
|
||||
name: 'appSecret',
|
||||
rules: [{ required: true }],
|
||||
tag: provider.fieldTags.appSecret,
|
||||
},
|
||||
{
|
||||
children: <FormInput placeholder={t('channel.verificationTokenPlaceholder')} />,
|
||||
desc: t('channel.verificationTokenHint'),
|
||||
label: t('channel.verificationToken'),
|
||||
name: 'verificationToken',
|
||||
tag: provider.fieldTags.verificationToken,
|
||||
},
|
||||
{
|
||||
children: <FormPassword placeholder={t('channel.encryptKeyPlaceholder')} />,
|
||||
desc: t('channel.encryptKeyHint'),
|
||||
label: t('channel.encryptKey'),
|
||||
name: 'encryptKey',
|
||||
tag: provider.fieldTags.encryptKey,
|
||||
},
|
||||
];
|
||||
|
|
@ -1,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,
|
||||
},
|
||||
];
|
||||
|
|
@ -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[])
|
||||
: []),
|
||||
];
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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.');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -14,12 +14,13 @@ interface MessageLike {
|
|||
text: string;
|
||||
}
|
||||
|
||||
interface BotContext {
|
||||
applicationId?: string;
|
||||
interface FormatPromptOptions {
|
||||
/** Strip platform-specific bot mention artifacts from user input. */
|
||||
sanitizeUserInput?: (text: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract referenced (replied-to) message from Discord raw payload
|
||||
* Extract referenced (replied-to) message from raw payload
|
||||
* and format it as an XML tag for the agent prompt.
|
||||
*/
|
||||
export const formatReferencedMessage = (
|
||||
|
|
@ -35,15 +36,15 @@ export const formatReferencedMessage = (
|
|||
|
||||
/**
|
||||
* Format user message into agent prompt:
|
||||
* 1. Strip bot's own @mention (Discord format: <@botId>)
|
||||
* 1. Strip platform-specific bot mentions via sanitizeUserInput
|
||||
* 2. Prepend referenced (quoted/replied) message if present
|
||||
* 3. Add speaker tag with user identity
|
||||
*/
|
||||
export const formatPrompt = (message: MessageLike, botContext?: BotContext): string => {
|
||||
export const formatPrompt = (message: MessageLike, options?: FormatPromptOptions): string => {
|
||||
let text = message.text;
|
||||
|
||||
if (botContext?.applicationId) {
|
||||
text = text.replaceAll(new RegExp(`<@!?${botContext.applicationId}>\\s*`, 'g'), '').trim();
|
||||
if (options?.sanitizeUserInput) {
|
||||
text = options.sanitizeUserInput(text);
|
||||
}
|
||||
|
||||
// Prepend referenced (quoted/replied) message if present
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
export { AgentBridgeService } from './AgentBridgeService';
|
||||
export { BotMessageRouter, getBotMessageRouter } from './BotMessageRouter';
|
||||
export { platformBotRegistry } from './platforms';
|
||||
export { Discord, type DiscordBotConfig } from './platforms/discord';
|
||||
export { Telegram, type TelegramBotConfig } from './platforms/telegram';
|
||||
export type { PlatformBot, PlatformBotClass } from './types';
|
||||
export type { PlatformClient, PlatformMessenger } from './types';
|
||||
|
|
|
|||
5
src/server/services/bot/platforms/const.ts
Normal file
5
src/server/services/bot/platforms/const.ts
Normal 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;
|
||||
|
|
@ -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) {
|
||||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
195
src/server/services/bot/platforms/discord/client.ts
Normal file
195
src/server/services/bot/platforms/discord/client.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/server/services/bot/platforms/discord/definition.ts
Normal file
16
src/server/services/bot/platforms/discord/definition.ts
Normal 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(),
|
||||
};
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { Discord, type DiscordBotConfig, discordDescriptor } from './bot';
|
||||
export { DiscordRestApi } from './restApi';
|
||||
94
src/server/services/bot/platforms/discord/schema.ts
Normal file
94
src/server/services/bot/platforms/discord/schema.ts
Normal 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',
|
||||
},
|
||||
];
|
||||
132
src/server/services/bot/platforms/feishu/client.ts
Normal file
132
src/server/services/bot/platforms/feishu/client.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
16
src/server/services/bot/platforms/feishu/definitions/lark.ts
Normal file
16
src/server/services/bot/platforms/feishu/definitions/lark.ts
Normal 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,
|
||||
};
|
||||
101
src/server/services/bot/platforms/feishu/definitions/schema.ts
Normal file
101
src/server/services/bot/platforms/feishu/definitions/schema.ts
Normal 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',
|
||||
},
|
||||
];
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import { FeishuClientFactory } from '../client';
|
||||
|
||||
export const sharedClientFactory = new FeishuClientFactory();
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { feishuDescriptor, Lark, larkDescriptor } from './bot';
|
||||
export { LarkRestApi } from './restApi';
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
import debug from 'debug';
|
||||
|
||||
const log = debug('lobe-server:bot:lark-rest');
|
||||
|
||||
const BASE_URLS: Record<string, string> = {
|
||||
feishu: 'https://open.feishu.cn/open-apis',
|
||||
lark: 'https://open.larksuite.com/open-apis',
|
||||
};
|
||||
|
||||
// Lark message limit is ~32KB for content, but we cap text at 4000 chars for readability
|
||||
const MAX_TEXT_LENGTH = 4000;
|
||||
|
||||
/**
|
||||
* Lightweight wrapper around the Lark/Feishu Open API.
|
||||
* Used by bot-callback webhooks and BotMessageRouter to send/edit messages directly.
|
||||
*
|
||||
* Auth: app_id + app_secret → tenant_access_token (cached, auto-refreshed).
|
||||
*/
|
||||
export class LarkRestApi {
|
||||
private readonly appId: string;
|
||||
private readonly appSecret: string;
|
||||
private readonly baseUrl: string;
|
||||
|
||||
private cachedToken?: string;
|
||||
private tokenExpiresAt = 0;
|
||||
|
||||
constructor(appId: string, appSecret: string, platform: string = 'lark') {
|
||||
this.appId = appId;
|
||||
this.appSecret = appSecret;
|
||||
this.baseUrl = BASE_URLS[platform] || BASE_URLS.lark;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Messages
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
async sendMessage(chatId: string, text: string): Promise<{ messageId: string }> {
|
||||
log('sendMessage: chatId=%s', chatId);
|
||||
const data = await this.call('POST', '/im/v1/messages?receive_id_type=chat_id', {
|
||||
content: JSON.stringify({ text: this.truncateText(text) }),
|
||||
msg_type: 'text',
|
||||
receive_id: chatId,
|
||||
});
|
||||
return { messageId: data.data.message_id };
|
||||
}
|
||||
|
||||
async editMessage(messageId: string, text: string): Promise<void> {
|
||||
log('editMessage: messageId=%s', messageId);
|
||||
await this.call('PUT', `/im/v1/messages/${messageId}`, {
|
||||
content: JSON.stringify({ text: this.truncateText(text) }),
|
||||
msg_type: 'text',
|
||||
});
|
||||
}
|
||||
|
||||
async replyMessage(messageId: string, text: string): Promise<{ messageId: string }> {
|
||||
log('replyMessage: messageId=%s', messageId);
|
||||
const data = await this.call('POST', `/im/v1/messages/${messageId}/reply`, {
|
||||
content: JSON.stringify({ text: this.truncateText(text) }),
|
||||
msg_type: 'text',
|
||||
});
|
||||
return { messageId: data.data.message_id };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Auth
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
async getTenantAccessToken(): Promise<string> {
|
||||
if (this.cachedToken && Date.now() < this.tokenExpiresAt) {
|
||||
return this.cachedToken;
|
||||
}
|
||||
|
||||
log('getTenantAccessToken: refreshing for appId=%s', this.appId);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/auth/v3/tenant_access_token/internal`, {
|
||||
body: JSON.stringify({ app_id: this.appId, app_secret: this.appSecret }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Lark auth failed: ${response.status} ${text}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.code !== 0) {
|
||||
throw new Error(`Lark auth error: ${data.code} ${data.msg}`);
|
||||
}
|
||||
|
||||
this.cachedToken = data.tenant_access_token;
|
||||
// Expire 5 minutes early to avoid edge cases
|
||||
this.tokenExpiresAt = Date.now() + (data.expire - 300) * 1000;
|
||||
|
||||
return this.cachedToken!;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Internal
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private truncateText(text: string): string {
|
||||
if (text.length > MAX_TEXT_LENGTH) return text.slice(0, MAX_TEXT_LENGTH - 3) + '...';
|
||||
return text;
|
||||
}
|
||||
|
||||
private async call(method: string, path: string, body: Record<string, unknown>): Promise<any> {
|
||||
const token = await this.getTenantAccessToken();
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
log('Lark API error: %s %s, status=%d, body=%s', method, path, response.status, text);
|
||||
throw new Error(`Lark API ${method} ${path} failed: ${response.status} ${text}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.code !== 0) {
|
||||
log('Lark API logical error: %s %s, code=%d, msg=%s', method, path, data.code, data.msg);
|
||||
throw new Error(`Lark API ${method} ${path} failed: ${data.code} ${data.msg}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,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,
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
151
src/server/services/bot/platforms/qq/client.ts
Normal file
151
src/server/services/bot/platforms/qq/client.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/server/services/bot/platforms/qq/definition.ts
Normal file
17
src/server/services/bot/platforms/qq/definition.ts
Normal 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(),
|
||||
};
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { QQ, qqDescriptor } from './bot';
|
||||
export { QQ_API_BASE, QQRestApi } from './restApi';
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
87
src/server/services/bot/platforms/qq/schema.ts
Normal file
87
src/server/services/bot/platforms/qq/schema.ts
Normal 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',
|
||||
},
|
||||
];
|
||||
119
src/server/services/bot/platforms/registry.test.ts
Normal file
119
src/server/services/bot/platforms/registry.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
81
src/server/services/bot/platforms/registry.ts
Normal file
81
src/server/services/bot/platforms/registry.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
83
src/server/services/bot/platforms/slack/api.ts
Normal file
83
src/server/services/bot/platforms/slack/api.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
137
src/server/services/bot/platforms/slack/client.ts
Normal file
137
src/server/services/bot/platforms/slack/client.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/server/services/bot/platforms/slack/definition.ts
Normal file
16
src/server/services/bot/platforms/slack/definition.ts
Normal 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(),
|
||||
};
|
||||
94
src/server/services/bot/platforms/slack/schema.ts
Normal file
94
src/server/services/bot/platforms/slack/schema.ts
Normal 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',
|
||||
},
|
||||
];
|
||||
|
|
@ -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> {
|
||||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
135
src/server/services/bot/platforms/telegram/client.ts
Normal file
135
src/server/services/bot/platforms/telegram/client.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
15
src/server/services/bot/platforms/telegram/definition.ts
Normal file
15
src/server/services/bot/platforms/telegram/definition.ts
Normal 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(),
|
||||
};
|
||||
36
src/server/services/bot/platforms/telegram/helpers.ts
Normal file
36
src/server/services/bot/platforms/telegram/helpers.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { setTelegramWebhook, Telegram, type TelegramBotConfig, telegramDescriptor } from './bot';
|
||||
export { TELEGRAM_API_BASE, TelegramRestApi } from './restApi';
|
||||
95
src/server/services/bot/platforms/telegram/schema.ts
Normal file
95
src/server/services/bot/platforms/telegram/schema.ts
Normal 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',
|
||||
},
|
||||
];
|
||||
254
src/server/services/bot/platforms/types.ts
Normal file
254
src/server/services/bot/platforms/types.ts
Normal 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'>;
|
||||
65
src/server/services/bot/platforms/utils.test.ts
Normal file
65
src/server/services/bot/platforms/utils.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
91
src/server/services/bot/platforms/utils.ts
Normal file
91
src/server/services/bot/platforms/utils.ts
Normal 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}`;
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import type { StepPresentationData } from '../agentRuntime/types';
|
||||
import { getExtremeAck } from './ackPhrases';
|
||||
import { formatDuration } from './platforms';
|
||||
|
||||
// Use raw Unicode emoji instead of Chat SDK emoji placeholders,
|
||||
// because bot-callback webhooks send via DiscordRestApi directly
|
||||
// because bot-callback webhooks send via DiscordPlatformClient directly
|
||||
// (not through the Chat SDK adapter that resolves placeholders).
|
||||
const EMOJI_THINKING = '💭';
|
||||
const EMOJI_SUCCESS = '✅';
|
||||
|
||||
// ==================== Message Splitting ====================
|
||||
|
||||
|
|
@ -46,7 +46,6 @@ export interface RenderStepParams extends StepPresentationData {
|
|||
elapsedMs?: number;
|
||||
lastContent?: string;
|
||||
lastToolsCalling?: ToolCallItem[];
|
||||
platform?: string;
|
||||
totalToolCalls?: number;
|
||||
}
|
||||
|
||||
|
|
@ -107,44 +106,14 @@ function formatCompletedTools(
|
|||
.join('\n');
|
||||
}
|
||||
|
||||
export function formatTokens(tokens: number): string {
|
||||
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}m`;
|
||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`;
|
||||
return String(tokens);
|
||||
}
|
||||
export { formatDuration, formatTokens } from './platforms';
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
function renderProgressHeader(params: { elapsedMs?: number; totalToolCalls?: number }): string {
|
||||
const { elapsedMs, totalToolCalls } = params;
|
||||
if (!totalToolCalls || totalToolCalls <= 0) return '';
|
||||
|
||||
if (minutes > 0) return `${minutes}m${seconds}s`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
function renderInlineStats(params: {
|
||||
elapsedMs?: number;
|
||||
platform?: string;
|
||||
totalCost: number;
|
||||
totalTokens: number;
|
||||
totalToolCalls?: number;
|
||||
}): { footer: string; header: string } {
|
||||
const { elapsedMs, platform, totalToolCalls, totalTokens, totalCost } = params;
|
||||
const time = elapsedMs && elapsedMs > 0 ? ` · ${formatDuration(elapsedMs)}` : '';
|
||||
|
||||
const header =
|
||||
totalToolCalls && totalToolCalls > 0
|
||||
? `> total **${totalToolCalls}** tools calling ${time}\n\n`
|
||||
: '';
|
||||
|
||||
if (totalTokens <= 0) return { footer: '', header };
|
||||
|
||||
const stats = `${formatTokens(totalTokens)} tokens · $${totalCost.toFixed(4)}`;
|
||||
// Discord uses -# for small text; other platforms render it as literal text
|
||||
const useSmallText = !platform || platform === 'discord';
|
||||
const footer = useSmallText ? `\n\n-# ${stats}` : `\n\n${stats}`;
|
||||
|
||||
return { footer, header };
|
||||
return `> total **${totalToolCalls}** tools calling ${time}\n\n`;
|
||||
}
|
||||
|
||||
// ==================== 1. Start ====================
|
||||
|
|
@ -154,77 +123,44 @@ export const renderStart = getExtremeAck;
|
|||
// ==================== 2. LLM Generating ====================
|
||||
|
||||
/**
|
||||
* LLM step just finished. Three sub-states:
|
||||
* - has reasoning (thinking)
|
||||
* - pure text content
|
||||
* - has tool calls (about to execute tools)
|
||||
* LLM step just finished. Returns the message body (no usage stats).
|
||||
* Stats are handled separately via `PlatformClient.formatReply`.
|
||||
*/
|
||||
export function renderLLMGenerating(params: RenderStepParams): string {
|
||||
const {
|
||||
content,
|
||||
elapsedMs,
|
||||
lastContent,
|
||||
platform,
|
||||
reasoning,
|
||||
toolsCalling,
|
||||
totalCost,
|
||||
totalTokens,
|
||||
totalToolCalls,
|
||||
} = params;
|
||||
const { content, elapsedMs, lastContent, reasoning, toolsCalling, totalToolCalls } = params;
|
||||
const displayContent = (content || lastContent)?.trim();
|
||||
const { header, footer } = renderInlineStats({
|
||||
elapsedMs,
|
||||
platform,
|
||||
totalCost,
|
||||
totalTokens,
|
||||
totalToolCalls,
|
||||
});
|
||||
const header = renderProgressHeader({ elapsedMs, totalToolCalls });
|
||||
|
||||
// Sub-state: LLM decided to call tools → show content + pending tool calls (○)
|
||||
if (toolsCalling && toolsCalling.length > 0) {
|
||||
const toolsList = formatPendingTools(toolsCalling);
|
||||
|
||||
if (displayContent) return `${header}${displayContent}\n\n${toolsList}${footer}`;
|
||||
return `${header}${toolsList}${footer}`;
|
||||
if (displayContent) return `${header}${displayContent}\n\n${toolsList}`;
|
||||
return `${header}${toolsList}`;
|
||||
}
|
||||
|
||||
// Sub-state: has reasoning (thinking)
|
||||
if (reasoning && !content) {
|
||||
return `${header}${EMOJI_THINKING} ${reasoning?.trim()}${footer}`;
|
||||
return `${header}${EMOJI_THINKING} ${reasoning?.trim()}`;
|
||||
}
|
||||
|
||||
// Sub-state: pure text content (waiting for next step)
|
||||
if (displayContent) {
|
||||
return `${header}${displayContent}${footer}`;
|
||||
return `${header}${displayContent}`;
|
||||
}
|
||||
|
||||
return `${header}${EMOJI_THINKING} Processing...${footer}`;
|
||||
return `${header}${EMOJI_THINKING} Processing...`;
|
||||
}
|
||||
|
||||
// ==================== 3. Tool Executing ====================
|
||||
|
||||
/**
|
||||
* Tool step just finished, LLM is next.
|
||||
* Shows completed tools with results (⏺).
|
||||
* Returns the message body (no usage stats).
|
||||
*/
|
||||
export function renderToolExecuting(params: RenderStepParams): string {
|
||||
const {
|
||||
elapsedMs,
|
||||
lastContent,
|
||||
lastToolsCalling,
|
||||
platform,
|
||||
toolsResult,
|
||||
totalCost,
|
||||
totalTokens,
|
||||
totalToolCalls,
|
||||
} = params;
|
||||
const { header, footer } = renderInlineStats({
|
||||
elapsedMs,
|
||||
platform,
|
||||
totalCost,
|
||||
totalTokens,
|
||||
totalToolCalls,
|
||||
});
|
||||
const { elapsedMs, lastContent, lastToolsCalling, toolsResult, totalToolCalls } = params;
|
||||
const header = renderProgressHeader({ elapsedMs, totalToolCalls });
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
|
|
@ -239,30 +175,17 @@ export function renderToolExecuting(params: RenderStepParams): string {
|
|||
parts.push(`${EMOJI_THINKING} Processing...`);
|
||||
}
|
||||
|
||||
return parts.join('\n\n') + footer;
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
// ==================== 4. Final Output ====================
|
||||
|
||||
export function renderFinalReply(
|
||||
content: string,
|
||||
params: {
|
||||
elapsedMs?: number;
|
||||
llmCalls: number;
|
||||
platform?: string;
|
||||
toolCalls: number;
|
||||
totalCost: number;
|
||||
totalTokens: number;
|
||||
},
|
||||
): string {
|
||||
const { totalTokens, totalCost, llmCalls, toolCalls, elapsedMs, platform } = params;
|
||||
const time = elapsedMs && elapsedMs > 0 ? ` · ${formatDuration(elapsedMs)}` : '';
|
||||
const calls = llmCalls > 1 || toolCalls > 0 ? ` | llm×${llmCalls} | tools×${toolCalls}` : '';
|
||||
const stats = `${formatTokens(totalTokens)} tokens · $${totalCost.toFixed(4)}${time}${calls}`;
|
||||
// Discord uses -# for small text; other platforms render it as literal text
|
||||
const useSmallText = !platform || platform === 'discord';
|
||||
const footer = useSmallText ? `-# ${stats}` : stats;
|
||||
return `${content.trimEnd()}\n\n${footer}`;
|
||||
/**
|
||||
* Returns the final reply body (content only, no usage stats).
|
||||
* Stats are handled separately via `PlatformClient.formatReply`.
|
||||
*/
|
||||
export function renderFinalReply(content: string): string {
|
||||
return content.trimEnd();
|
||||
}
|
||||
|
||||
export function renderError(errorMessage: string): string {
|
||||
|
|
@ -273,13 +196,11 @@ export function renderError(errorMessage: string): string {
|
|||
|
||||
/**
|
||||
* Dispatch to the correct template based on step state.
|
||||
* Returns message body only — caller handles stats via platform.
|
||||
*/
|
||||
export function renderStepProgress(params: RenderStepParams): string {
|
||||
if (params.stepType === 'call_llm') {
|
||||
// LLM step finished → about to execute tools
|
||||
return renderLLMGenerating(params);
|
||||
}
|
||||
|
||||
// Tool step finished → LLM is next
|
||||
return renderToolExecuting(params);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue