feat: bot platform abstract & QQ bot intergration (#12941)

* chore: add bot platform abstract

* chore: refactor platform abstract

* feat: support QQ platform

* docs : add qq channel

* fix: crypto algorithm

* fix: discord metion thread

* fix: discord threadId bypass

* fix: edit messsage throw error

* chore: update memory tool icon

* chore: use lobe channel icon

* chore: update platfom icon color

* fix: lint error
This commit is contained in:
Rdmclin2
2026-03-12 21:25:15 +08:00
committed by GitHub
parent 04a064aaf3
commit afb6d8d3ca
48 changed files with 3202 additions and 413 deletions

139
docs/usage/channels/qq.mdx Normal file
View File

@@ -0,0 +1,139 @@
---
title: Connect LobeHub to QQ
description: >-
Learn how to create a QQ bot and connect it to your LobeHub agent as a
message channel, enabling your AI assistant to chat with users in QQ
group chats and direct messages.
tags:
- QQ
- Message Channels
- Bot Setup
- Integration
---
# Connect LobeHub to QQ
<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 QQ channel to your LobeHub agent, users can interact with the AI assistant through QQ group chats, guild channels, and direct messages.
## Prerequisites
- A LobeHub account with an active subscription
- A QQ account
## Step 1: Create a QQ Bot
<Steps>
### Open the QQ Open Platform
Visit [q.qq.com](https://q.qq.com) and sign in with your QQ account.
### Create an Application
In the QQ Open Platform dashboard, click **Create Bot**. Fill in the bot name, description, and avatar.
### Copy App Credentials
After the application is created, go to **Development Settings** and copy:
- **App ID** — Your bot's unique identifier
- **App Secret** — Your bot's secret key
> **Important:** Keep your App Secret confidential. Never share it publicly.
### Configure Webhook URL
In the QQ Open Platform, navigate to **Development Settings** → **Callback Configuration**. You will need to paste the LobeHub Callback URL here after completing Step 2.
</Steps>
## Step 2: Configure QQ in LobeHub
<Steps>
### Open Channel Settings
In LobeHub, navigate to your agent's settings, then select the **Channels** tab. Click **QQ** from the platform list.
### Enter App Credentials
Fill in the following fields:
- **Application ID** — The App ID from the QQ Open Platform
- **App Secret** — The App Secret from the QQ Open Platform
### Save and Copy the Callback URL
Click **Save Configuration**. After saving, a **Callback URL** will be displayed. Copy this URL.
Your credentials will be encrypted and stored securely.
</Steps>
## Step 3: Configure Callback in QQ Open Platform
<Steps>
### Paste the Callback URL
Go back to the QQ Open Platform, navigate to **Development Settings** → **Callback Configuration**. Paste the **Callback URL** you copied from LobeHub.
### Select Event Types
Subscribe to the message events your bot needs. Common events include:
- `GROUP_AT_MESSAGE_CREATE` — Triggered when the bot is @mentioned in a group
- `C2C_MESSAGE_CREATE` — Triggered when the bot receives a private message
- `AT_MESSAGE_CREATE` — Triggered when the bot is @mentioned in a guild channel
- `DIRECT_MESSAGE_CREATE` — Triggered for direct messages in a guild
### Verify the Callback
The QQ Open Platform will send a verification request to your Callback URL. LobeHub handles this automatically using Ed25519 signature verification.
</Steps>
## Step 4: Publish the Bot
<Steps>
### Submit for Review
In the QQ Open Platform, go to **Version Management** and create a new version. Submit the bot for review.
### Wait for Approval
QQ will review your bot. Once approved, the bot will be published and ready to use. For sandbox testing, you can add test users directly without publishing.
</Steps>
## Step 5: Test the Connection
Click **Test Connection** in LobeHub's channel settings to verify the integration. Then open QQ, find your bot, and send a message. The bot should respond through your LobeHub agent.
## Adding the Bot to Group Chats
To use the bot in QQ groups:
1. Add the bot to a QQ group
2. @mention the bot in a message to trigger a response
3. The bot will reply in the group conversation
## Configuration Reference
| Field | Required | Description |
| ------------------ | -------- | -------------------------------------------------------- |
| **Application ID** | Yes | Your bot's App ID from QQ Open Platform |
| **App Secret** | Yes | Your bot's App Secret from QQ Open Platform |
| **Callback URL** | — | Auto-generated after saving; paste into QQ Open Platform |
## Limitations
- **No message editing** — QQ Bot API does not support editing sent messages. Updated responses will be sent as new messages.
- **No reactions** — QQ Bot API does not support emoji reactions.
- **No typing indicator** — QQ Bot API does not provide typing indicator support for bots.
- **Message length limit** — Messages exceeding 2000 characters will be automatically truncated.
## Troubleshooting
- **Callback URL verification failed:** Ensure you saved the configuration in LobeHub first and the URL was copied correctly. LobeHub handles Ed25519 verification automatically.
- **Bot not responding:** Verify the App ID and App Secret are correct, the bot is published (or you are a sandbox test user), and the required message events are subscribed.
- **Group chat issues:** Make sure the bot has been added to the group. @mention the bot to trigger a response.
- **Test Connection failed:** Double-check the App ID and App Secret in LobeHub's channel settings.

View File

@@ -0,0 +1,136 @@
---
title: 将 LobeHub 连接到 QQ
description: 了解如何创建 QQ 机器人并将其连接到您的 LobeHub 代理作为消息渠道,使您的 AI 助手能够在 QQ 群聊和私聊中与用户互动。
tags:
- QQ
- 消息渠道
- 机器人设置
- 集成
---
# 将 LobeHub 连接到 QQ
<Callout type={'info'}>
此功能目前正在开发中,可能尚未完全稳定。您可以通过在 **设置** → **高级设置** → **开发者模式**
中启用 **开发者模式** 来使用此功能。
</Callout>
通过将 QQ 渠道连接到您的 LobeHub 代理,用户可以通过 QQ 群聊、频道和私聊与 AI 助手互动。
## 前置条件
- 一个拥有有效订阅的 LobeHub 账户
- 一个 QQ 账户
## 第一步:创建 QQ 机器人
<Steps>
### 打开 QQ 开放平台
访问 [q.qq.com](https://q.qq.com),使用您的 QQ 账号登录。
### 创建应用
在 QQ 开放平台控制台中,点击 **创建机器人**。填写机器人名称、描述和头像。
### 复制应用凭证
应用创建完成后,进入 **开发设置**,复制以下内容:
- **App ID** — 机器人的唯一标识符
- **App Secret** — 机器人的密钥
> **重要提示:** 请妥善保管您的 App Secret切勿公开分享。
### 配置回调地址
在 QQ 开放平台中,导航到 **开发设置** → **回调配置**。您需要在完成第二步后将 LobeHub 的回调地址粘贴到此处。
</Steps>
## 第二步:在 LobeHub 中配置 QQ
<Steps>
### 打开渠道设置
在 LobeHub 中,导航到您的代理设置,然后选择 **渠道** 标签页。从平台列表中点击 **QQ**。
### 输入应用凭证
填写以下字段:
- **应用 ID** — 来自 QQ 开放平台的 App ID
- **App Secret** — 来自 QQ 开放平台的 App Secret
### 保存并复制回调地址
点击 **保存配置**。保存后,将显示一个 **回调地址Callback URL**。复制此地址。
您的凭证将被加密并安全存储。
</Steps>
## 第三步:在 QQ 开放平台配置回调
<Steps>
### 粘贴回调地址
返回 QQ 开放平台,导航到 **开发设置** → **回调配置**。将您从 LobeHub 复制的 **回调地址** 粘贴到此处。
### 选择事件类型
订阅您的机器人需要的消息事件。常用事件包括:
- `GROUP_AT_MESSAGE_CREATE` — 在群聊中被 @提及时触发
- `C2C_MESSAGE_CREATE` — 收到私聊消息时触发
- `AT_MESSAGE_CREATE` — 在频道中被 @提及时触发
- `DIRECT_MESSAGE_CREATE` — 频道私信时触发
### 验证回调
QQ 开放平台将向您的回调地址发送验证请求。LobeHub 会通过 Ed25519 签名验证自动处理此请求。
</Steps>
## 第四步:发布机器人
<Steps>
### 提交审核
在 QQ 开放平台中,进入 **版本管理** 并创建一个新版本。提交机器人进行审核。
### 等待审核通过
QQ 会对您的机器人进行审核。审核通过后,机器人将发布并可投入使用。在沙盒测试阶段,您可以直接添加测试用户而无需发布。
</Steps>
## 第五步:测试连接
在 LobeHub 的渠道设置中点击 **测试连接** 以验证集成。然后打开 QQ找到您的机器人并发送消息。机器人应通过您的 LobeHub 代理进行响应。
## 将机器人添加到群聊
要在 QQ 群聊中使用机器人:
1. 将机器人添加到 QQ 群聊中
2. 在消息中 @提及机器人以触发响应
3. 机器人将在群聊中回复
## 配置参考
| 字段 | 是否必需 | 描述 |
| -------------- | ---- | ---------------------- |
| **应用 ID** | 是 | 来自 QQ 开放平台的 App ID |
| **App Secret** | 是 | 来自 QQ 开放平台的 App Secret |
| **回调地址** | — | 保存后自动生成;粘贴到 QQ 开放平台 |
## 功能限制
- **不支持消息编辑** — QQ 机器人 API 不支持编辑已发送的消息。更新的回复将作为新消息发送。
- **不支持表情回应** — QQ 机器人 API 不支持表情回应功能。
- **不支持输入状态提示** — QQ 机器人 API 不提供输入状态指示器功能。
- **消息长度限制** — 超过 2000 个字符的消息将被自动截断。
## 故障排除
- **回调地址验证失败:** 确保您已在 LobeHub 中保存配置,并正确复制了 URL。LobeHub 会自动处理 Ed25519 验证。
- **机器人未响应:** 验证 App ID 和 App Secret 是否正确,机器人是否已发布(或您是沙盒测试用户),以及是否订阅了所需的消息事件。
- **群聊问题:** 确保机器人已被添加到群聊中。@提及机器人以触发响应。
- **测试连接失败:** 仔细检查 LobeHub 渠道设置中的 App ID 和 App Secret。

View File

@@ -30,6 +30,8 @@
"channel.publicKey": "Public Key",
"channel.publicKeyHint": "Optional. Used to verify interaction requests from Discord.",
"channel.publicKeyPlaceholder": "Required for interaction verification",
"channel.qq.appIdHint": "Your QQ Bot App ID from QQ Open Platform",
"channel.qq.description": "Connect this assistant to QQ for group chats and direct messages.",
"channel.removeChannel": "Remove Channel",
"channel.removeFailed": "Failed to remove channel",
"channel.removed": "Channel removed",

View File

@@ -27,6 +27,8 @@
"channel.platforms": "平台",
"channel.publicKey": "公钥",
"channel.publicKeyPlaceholder": "用于交互验证",
"channel.qq.appIdHint": "您在 QQ 开放平台获取的机器人 App ID",
"channel.qq.description": "将助手连接到 QQ支持群聊和私聊。",
"channel.removeChannel": "移除频道",
"channel.removeFailed": "移除频道失败",
"channel.removed": "频道已移除",

View File

@@ -196,6 +196,7 @@
"@khmyznikov/pwa-install": "0.3.9",
"@langchain/community": "^0.3.59",
"@lobechat/adapter-lark": "workspace:*",
"@lobechat/adapter-qq": "workspace:*",
"@lobechat/agent-runtime": "workspace:*",
"@lobechat/builtin-agents": "workspace:*",
"@lobechat/builtin-skills": "workspace:*",

View File

@@ -0,0 +1,26 @@
{
"name": "@lobechat/adapter-qq",
"version": "0.1.0",
"description": "QQ Bot adapter for chat SDK",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"clean": "rm -rf dist",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"chat": "^4.14.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsup": "^8.3.5",
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,426 @@
import type {
Adapter,
AdapterPostableMessage,
Author,
ChatInstance,
EmojiValue,
FetchOptions,
FetchResult,
FormattedContent,
Logger,
RawMessage,
ThreadInfo,
WebhookOptions,
} from 'chat';
import { Message, parseMarkdown } from 'chat';
import { QQApiClient } from './api';
import { signWebhookResponse } from './crypto';
import { QQFormatConverter } from './format-converter';
import type {
QQAdapterConfig,
QQRawMessage,
QQThreadId,
QQWebhookEventData,
QQWebhookPayload,
} from './types';
import { QQ_EVENT_TYPES, QQ_OP_CODES } from './types';
export class QQAdapter implements Adapter<QQThreadId, QQRawMessage> {
readonly name = 'qq';
private readonly api: QQApiClient;
private readonly clientSecret: string;
private readonly formatConverter: QQFormatConverter;
private _userName: string;
private _botUserId?: string;
private chat!: ChatInstance;
private logger!: Logger;
get userName(): string {
return this._userName;
}
get botUserId(): string | undefined {
return this._botUserId;
}
constructor(config: QQAdapterConfig & { userName?: string }) {
this.api = new QQApiClient(config.appId, config.clientSecret);
this.clientSecret = config.clientSecret;
this.formatConverter = new QQFormatConverter();
this._userName = config.userName || 'qq-bot';
}
async initialize(chat: ChatInstance): Promise<void> {
this.chat = chat;
this.logger = chat.getLogger(this.name);
this._userName = chat.getUserName();
// Validate credentials by getting access token
await this.api.getAccessToken();
// Try to fetch bot info
try {
const botInfo = await this.api.getBotInfo();
if (botInfo) {
if (botInfo.username) this._userName = botInfo.username;
if (botInfo.id) this._botUserId = botInfo.id;
}
} catch {
// Bot info not critical
}
this.logger.info('Initialized QQ adapter (botUserId=%s)', this._botUserId);
}
// ------------------------------------------------------------------
// Webhook handling
// ------------------------------------------------------------------
async handleWebhook(request: Request, options?: WebhookOptions): Promise<Response> {
const bodyText = await request.text();
let payload: QQWebhookPayload;
try {
payload = JSON.parse(bodyText);
} catch {
return new Response('Invalid JSON', { status: 400 });
}
// Handle webhook verification (op: 13)
if (payload.op === QQ_OP_CODES.VERIFY) {
const verifyData = payload.d as { event_ts: string; plain_token: string };
if (verifyData.plain_token && verifyData.event_ts) {
const signature = signWebhookResponse(
verifyData.event_ts,
verifyData.plain_token,
this.clientSecret,
);
return Response.json({
plain_token: verifyData.plain_token,
signature,
});
}
return new Response('Missing verification data', { status: 400 });
}
// Handle dispatch events (op: 0)
if (payload.op !== QQ_OP_CODES.DISPATCH) {
return Response.json({ ok: true });
}
const eventType = payload.t;
const eventData = payload.d;
// Only handle message events
if (!this.isMessageEvent(eventType)) {
return Response.json({ ok: true });
}
// Extract message content
const content = eventData.content;
if (!content?.trim()) {
return Response.json({ ok: true });
}
// Build thread ID based on event type
const threadId = this.buildThreadId(eventType, eventData);
if (!threadId) {
return Response.json({ ok: true });
}
// Create message via factory
const messageFactory = () => this.parseRawEvent(eventData, threadId, eventType!);
// Delegate to Chat SDK pipeline
this.chat.processMessage(this, threadId, messageFactory, options);
return Response.json({ ok: true });
}
private isMessageEvent(eventType?: string): boolean {
if (!eventType) return false;
return (
eventType === QQ_EVENT_TYPES.GROUP_AT_MESSAGE_CREATE ||
eventType === QQ_EVENT_TYPES.C2C_MESSAGE_CREATE ||
eventType === QQ_EVENT_TYPES.AT_MESSAGE_CREATE ||
eventType === QQ_EVENT_TYPES.DIRECT_MESSAGE_CREATE
);
}
private buildThreadId(eventType: string | undefined, data: QQWebhookEventData): string | null {
if (!eventType) return null;
switch (eventType) {
case QQ_EVENT_TYPES.GROUP_AT_MESSAGE_CREATE: {
if (!data.group_openid) return null;
return this.encodeThreadId({ id: data.group_openid, type: 'group' });
}
case QQ_EVENT_TYPES.C2C_MESSAGE_CREATE: {
if (!data.author?.id) return null;
return this.encodeThreadId({ id: data.author.id, type: 'c2c' });
}
case QQ_EVENT_TYPES.AT_MESSAGE_CREATE: {
if (!data.channel_id) return null;
return this.encodeThreadId({
guildId: data.guild_id,
id: data.channel_id,
type: 'guild',
});
}
case QQ_EVENT_TYPES.DIRECT_MESSAGE_CREATE: {
if (!data.guild_id) return null;
return this.encodeThreadId({ id: data.guild_id, type: 'dms' });
}
default: {
return null;
}
}
}
// ------------------------------------------------------------------
// Message operations
// ------------------------------------------------------------------
async postMessage(
threadId: string,
message: AdapterPostableMessage,
): Promise<RawMessage<QQRawMessage>> {
const { type, id, guildId } = this.decodeThreadId(threadId);
const text = this.formatConverter.renderPostable(message);
let response;
switch (type) {
case 'group': {
response = await this.api.sendGroupMessage(id, text);
break;
}
case 'guild': {
response = await this.api.sendGuildMessage(id, text);
break;
}
case 'c2c': {
response = await this.api.sendC2CMessage(id, text);
break;
}
case 'dms': {
response = await this.api.sendDmsMessage(guildId || id, text);
break;
}
default: {
throw new Error(`Unknown thread type: ${type}`);
}
}
return {
id: response.id,
raw: {
author: { id: this._botUserId || '' },
content: text,
id: response.id,
timestamp: response.timestamp,
} as QQRawMessage,
threadId,
};
}
async editMessage(
threadId: string,
_messageId: string,
message: AdapterPostableMessage,
): Promise<RawMessage<QQRawMessage>> {
// QQ doesn't support editing — fall back to posting a new message
return this.postMessage(threadId, message);
}
async deleteMessage(_threadId: string, _messageId: string): Promise<void> {
// TODO: Implement message recall if QQ API supports it
this.logger.warn('Message deletion not implemented for QQ');
}
async fetchMessages(
_threadId: string,
_options?: FetchOptions,
): Promise<FetchResult<QQRawMessage>> {
// QQ doesn't provide message history API for bots
return {
messages: [],
nextCursor: undefined,
};
}
async fetchThread(threadId: string): Promise<ThreadInfo> {
const { type, id } = this.decodeThreadId(threadId);
return {
channelId: threadId,
id: threadId,
isDM: type === 'c2c' || type === 'dms',
metadata: { id, type },
};
}
// ------------------------------------------------------------------
// Message parsing
// ------------------------------------------------------------------
parseMessage(raw: QQRawMessage): Message<QQRawMessage> {
const cleanText = this.formatConverter.cleanMentions(raw.content || '');
const formatted = parseMarkdown(cleanText);
let threadId: string;
if (raw.group_openid) {
threadId = this.encodeThreadId({ id: raw.group_openid, type: 'group' });
} else if (raw.channel_id) {
threadId = this.encodeThreadId({
guildId: raw.guild_id,
id: raw.channel_id,
type: 'guild',
});
} else {
threadId = this.encodeThreadId({ id: raw.author.id, type: 'c2c' });
}
return new Message({
attachments: [],
author: {
fullName: 'Unknown',
isBot: false,
isMe: false,
userId: raw.author.id,
userName: 'unknown',
},
formatted,
id: raw.id,
metadata: {
dateSent: new Date(raw.timestamp),
edited: false,
},
raw,
text: cleanText,
threadId,
});
}
private async parseRawEvent(
data: QQWebhookEventData,
threadId: string,
_eventType: string,
): Promise<Message<QQRawMessage>> {
const content = data.content || '';
const cleanText = this.formatConverter.cleanMentions(content);
const formatted = parseMarkdown(cleanText);
const authorId = data.author?.id || 'unknown';
const isBot = false; // Webhook events are from users
const author: Author = {
fullName: authorId,
isBot,
isMe: isBot && authorId === this._botUserId,
userId: authorId,
userName: authorId,
};
const raw: QQRawMessage = {
author: data.author || { id: 'unknown' },
channel_id: data.channel_id,
content,
group_openid: data.group_openid,
guild_id: data.guild_id,
id: data.id || '',
timestamp: data.timestamp || new Date().toISOString(),
};
return new Message({
attachments: [],
author,
formatted,
id: data.id || '',
metadata: {
dateSent: new Date(data.timestamp || Date.now()),
edited: false,
},
raw,
text: cleanText,
threadId,
});
}
// ------------------------------------------------------------------
// Reactions (not supported by QQ Bot API)
// ------------------------------------------------------------------
async addReaction(
_threadId: string,
_messageId: string,
_emoji: EmojiValue | string,
): Promise<void> {
// QQ Bot API doesn't support reactions
}
async removeReaction(
_threadId: string,
_messageId: string,
_emoji: EmojiValue | string,
): Promise<void> {
// QQ Bot API doesn't support reactions
}
// ------------------------------------------------------------------
// Typing (not supported by QQ Bot API)
// ------------------------------------------------------------------
async startTyping(_threadId: string): Promise<void> {
// QQ has no typing indicator API for bots
}
// ------------------------------------------------------------------
// Thread ID encoding
// ------------------------------------------------------------------
encodeThreadId(data: QQThreadId): string {
if (data.guildId) {
return `qq:${data.type}:${data.id}:${data.guildId}`;
}
return `qq:${data.type}:${data.id}`;
}
decodeThreadId(threadId: string): QQThreadId {
const parts = threadId.split(':');
if (parts.length < 3 || parts[0] !== 'qq') {
// Fallback for malformed thread IDs
return { id: threadId, type: 'group' };
}
const type = parts[1] as QQThreadId['type'];
const id = parts[2];
const guildId = parts[3];
return { guildId, id, type };
}
channelIdFromThreadId(threadId: string): string {
return threadId;
}
isDM(threadId: string): boolean {
const { type } = this.decodeThreadId(threadId);
return type === 'c2c' || type === 'dms';
}
// ------------------------------------------------------------------
// Format rendering
// ------------------------------------------------------------------
renderFormatted(content: FormattedContent): string {
return this.formatConverter.fromAst(content);
}
}
/**
* Factory function to create a QQAdapter.
*/
export function createQQAdapter(config: QQAdapterConfig & { userName?: string }): QQAdapter {
return new QQAdapter(config);
}

View File

@@ -0,0 +1,198 @@
import type { QQAccessTokenResponse, QQSendMessageParams, QQSendMessageResponse } from './types';
import { QQ_MSG_TYPE } from './types';
const AUTH_URL = 'https://bots.qq.com/app/getAppAccessToken';
const API_BASE_URL = 'https://api.sgroup.qq.com';
const MAX_TEXT_LENGTH = 2000;
export class QQApiClient {
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;
}
async getAccessToken(): Promise<string> {
if (this.cachedToken && Date.now() < this.tokenExpiresAt) {
return this.cachedToken;
}
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()) as QQAccessTokenResponse;
this.cachedToken = data.access_token;
// Refresh 5 minutes before expiration
this.tokenExpiresAt = Date.now() + (data.expires_in - 300) * 1000;
return this.cachedToken;
}
private async call<T>(method: string, path: string, body?: Record<string, unknown>): Promise<T> {
const token = await this.getAccessToken();
const url = `${API_BASE_URL}${path}`;
const init: RequestInit = {
headers: {
'Authorization': `QQBot ${token}`,
'Content-Type': 'application/json',
},
method,
};
if (body && method !== 'GET' && method !== 'DELETE') {
init.body = JSON.stringify(body);
}
const response = await fetch(url, init);
if (!response.ok) {
const text = await response.text();
throw new Error(`QQ API ${method} ${path} failed: ${response.status} ${text}`);
}
// Some endpoints return empty response
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
return response.json() as Promise<T>;
}
return {} as T;
}
/**
* Send message to a QQ group
*/
async sendGroupMessage(
groupOpenId: string,
content: string,
options?: { eventId?: string; msgId?: string; msgSeq?: number },
): Promise<QQSendMessageResponse> {
const params: QQSendMessageParams = {
content: this.truncateText(content),
msg_type: QQ_MSG_TYPE.TEXT,
};
if (options?.msgId) {
params.msg_id = options.msgId;
}
if (options?.eventId) {
params.event_id = options.eventId;
}
if (options?.msgSeq !== undefined) {
params.msg_seq = options.msgSeq;
}
return this.call<QQSendMessageResponse>('POST', `/v2/groups/${groupOpenId}/messages`, params);
}
/**
* Send message to a QQ guild channel
*/
async sendGuildMessage(
channelId: string,
content: string,
options?: { eventId?: string; msgId?: string },
): Promise<QQSendMessageResponse> {
const params: QQSendMessageParams = {
content: this.truncateText(content),
msg_type: QQ_MSG_TYPE.TEXT,
};
if (options?.msgId) {
params.msg_id = options.msgId;
}
if (options?.eventId) {
params.event_id = options.eventId;
}
return this.call<QQSendMessageResponse>('POST', `/channels/${channelId}/messages`, params);
}
/**
* Send direct message to a user (C2C)
*/
async sendC2CMessage(
openId: string,
content: string,
options?: { eventId?: string; msgId?: string; msgSeq?: number },
): Promise<QQSendMessageResponse> {
const params: QQSendMessageParams = {
content: this.truncateText(content),
msg_type: QQ_MSG_TYPE.TEXT,
};
if (options?.msgId) {
params.msg_id = options.msgId;
}
if (options?.eventId) {
params.event_id = options.eventId;
}
if (options?.msgSeq !== undefined) {
params.msg_seq = options.msgSeq;
}
return this.call<QQSendMessageResponse>('POST', `/v2/users/${openId}/messages`, params);
}
/**
* Send direct message in a guild (DMS)
*/
async sendDmsMessage(
guildId: string,
content: string,
options?: { eventId?: string; msgId?: string },
): Promise<QQSendMessageResponse> {
const params: QQSendMessageParams = {
content: this.truncateText(content),
msg_type: QQ_MSG_TYPE.TEXT,
};
if (options?.msgId) {
params.msg_id = options.msgId;
}
if (options?.eventId) {
params.event_id = options.eventId;
}
return this.call<QQSendMessageResponse>('POST', `/dms/${guildId}/messages`, params);
}
/**
* Get bot information
*/
async getBotInfo(): Promise<{ avatar: string; id: string; username: string } | null> {
try {
const data = await this.call<{ avatar: string; id: string; username: string }>(
'GET',
'/users/@me',
);
return data;
} catch {
return null;
}
}
private truncateText(text: string): string {
if (text.length > MAX_TEXT_LENGTH) {
return text.slice(0, MAX_TEXT_LENGTH - 3) + '...';
}
return text;
}
}

View File

@@ -0,0 +1,45 @@
import { createPrivateKey, sign } from 'node:crypto';
/**
* PKCS8 DER prefix for Ed25519 private keys.
*
* ASN.1 structure:
* SEQUENCE {
* INTEGER 0 (version)
* SEQUENCE { OID 1.3.101.112 (Ed25519) }
* OCTET STRING { OCTET STRING { <32-byte seed> } }
* }
*/
const ED25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
/**
* Sign the webhook verification response using Ed25519.
*
* QQ Bot webhook verification requires:
* 1. Repeat the clientSecret until >= 32 bytes, then truncate to 32 as the seed
* 2. Create an Ed25519 private key from the seed
* 3. Sign the concatenated message (eventTs + plainToken)
* 4. Return the signature as a hex string
*/
export function signWebhookResponse(
eventTs: string,
plainToken: string,
clientSecret: string,
): string {
// QQ requires: repeat the secret string until length >= 32, then truncate to 32 bytes
let seedStr = clientSecret;
while (seedStr.length < 32) {
seedStr = seedStr.repeat(2);
}
const seed = Buffer.from(seedStr.slice(0, 32), 'utf8');
// Build PKCS8 DER key — Node.js derives the public key from the seed automatically
const pkcs8Der = Buffer.concat([ED25519_PKCS8_PREFIX, seed]);
const privateKey = createPrivateKey({ format: 'der', key: pkcs8Der, type: 'pkcs8' });
// Sign the message
const message = Buffer.from(eventTs + plainToken);
const signature = sign(null, message, privateKey);
return signature.toString('hex');
}

View File

@@ -0,0 +1,38 @@
import type { Root } from 'chat';
import { BaseFormatConverter, parseMarkdown, stringifyMarkdown } from 'chat';
export class QQFormatConverter extends BaseFormatConverter {
/**
* Convert mdast AST to QQ-compatible text.
* QQ supports basic text messages, we convert markdown to plain text for now.
*/
fromAst(ast: Root): string {
return stringifyMarkdown(ast);
}
/**
* Convert QQ message text to mdast AST.
* Clean up QQ @mention markers before parsing.
*/
toAst(text: string): Root {
// Clean QQ @mention markers (e.g., <@!user_id>, <@user_id>)
const cleaned = text
.replaceAll(/<@!?\d+>/g, '')
.replaceAll('<@everyone>', '')
.replaceAll(/<#\d+>/g, '')
.trim();
return parseMarkdown(cleaned);
}
/**
* Clean @mention markers from text
*/
cleanMentions(text: string): string {
return text
.replaceAll(/<@!?\d+>/g, '')
.replaceAll('<@everyone>', '')
.replaceAll(/<#\d+>/g, '')
.trim();
}
}

View File

@@ -0,0 +1,19 @@
export { createQQAdapter, QQAdapter } from './adapter';
export { QQApiClient } from './api';
export { signWebhookResponse } from './crypto';
export { QQFormatConverter } from './format-converter';
export type {
QQAccessTokenResponse,
QQAdapterConfig,
QQAttachment,
QQAuthor,
QQMessageType,
QQRawMessage,
QQSendMessageParams,
QQSendMessageResponse,
QQThreadId,
QQWebhookEventData,
QQWebhookPayload,
QQWebhookVerifyData,
} from './types';
export { QQ_EVENT_TYPES, QQ_MSG_TYPE, QQ_OP_CODES } from './types';

View File

@@ -0,0 +1,123 @@
export interface QQAdapterConfig {
appId: string;
clientSecret: string;
}
export interface QQThreadId {
/** For guild channels, the guild_id is needed for some operations */
guildId?: string;
id: string;
type: 'group' | 'guild' | 'c2c' | 'dms';
}
export interface QQAuthor {
id: string;
member_openid?: string;
union_openid?: string;
}
export interface QQAttachment {
content_type: string;
filename: string;
height?: number;
size: number;
url: string;
width?: number;
}
export interface QQMessageReference {
message_id: string;
}
export interface QQRawMessage {
attachments?: QQAttachment[];
author: QQAuthor;
channel_id?: string;
content: string;
group_openid?: string;
guild_id?: string;
id: string;
member?: {
joined_at: string;
roles?: string[];
};
mentions?: QQAuthor[];
message_reference?: QQMessageReference;
seq?: number;
seq_in_channel?: string;
timestamp: string;
}
export interface QQWebhookPayload {
d: QQWebhookEventData;
id: string;
op: number;
s?: number;
t?: string;
}
export interface QQWebhookEventData {
author?: QQAuthor;
channel_id?: string;
content?: string;
event_ts?: string;
group_openid?: string;
guild_id?: string;
id?: string;
member?: {
joined_at: string;
roles?: string[];
};
plain_token?: string;
timestamp?: string;
}
export interface QQWebhookVerifyData {
event_ts: string;
plain_token: string;
}
export interface QQAccessTokenResponse {
access_token: string;
expires_in: number;
}
export interface QQSendMessageParams {
[key: string]: unknown;
content?: string;
event_id?: string;
markdown?: {
content: string;
};
msg_id?: string;
msg_seq?: number;
msg_type: number;
}
export interface QQSendMessageResponse {
id: string;
timestamp: string;
}
export type QQMessageType = 'group' | 'guild' | 'c2c' | 'dms';
export const QQ_MSG_TYPE = {
ARK: 3,
EMBED: 4,
MARKDOWN: 2,
MEDIA: 7,
TEXT: 0,
} as const;
export const QQ_EVENT_TYPES = {
AT_MESSAGE_CREATE: 'AT_MESSAGE_CREATE',
C2C_MESSAGE_CREATE: 'C2C_MESSAGE_CREATE',
DIRECT_MESSAGE_CREATE: 'DIRECT_MESSAGE_CREATE',
GROUP_AT_MESSAGE_CREATE: 'GROUP_AT_MESSAGE_CREATE',
} as const;
export const QQ_OP_CODES = {
DISPATCH: 0,
HTTP_CALLBACK_ACK: 12,
VERIFY: 13,
} as const;

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"lib": ["ES2022"]
},
"exclude": ["node_modules", "dist"],
"include": ["src/**/*"]
}

View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'tsup';
export default defineConfig({
dts: true,
entry: ['src/index.ts'],
format: ['esm'],
sourcemap: true,
});

View File

@@ -1,9 +1,10 @@
import { type UserMemoryEffort } from '@lobechat/types';
import { Center, Flexbox, Icon } from '@lobehub/ui';
import { BrainOffIcon } from '@lobehub/ui/icons';
import { Divider } from 'antd';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { type LucideIcon } from 'lucide-react';
import { BrainCircuit, CircleOff } from 'lucide-react';
import { Brain } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -99,13 +100,13 @@ const Controls = memo(() => {
const toggleOptions: ToggleOption[] = [
{
description: t('memory.off.desc'),
icon: CircleOff,
icon: BrainOffIcon,
label: t('memory.off.title'),
value: 'off',
},
{
description: t('memory.on.desc'),
icon: BrainCircuit,
icon: Brain,
label: t('memory.on.title'),
value: 'on',
},

View File

@@ -1,5 +1,6 @@
import { BrainOffIcon } from '@lobehub/ui/icons';
import { cssVar } from 'antd-style';
import { Brain, BrainCircuit } from 'lucide-react';
import { Brain } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -27,7 +28,7 @@ const Memory = memo(() => {
return (
<Action
color={isEnabled ? cssVar.colorInfo : undefined}
icon={isEnabled ? BrainCircuit : Brain}
icon={isEnabled ? Brain : BrainOffIcon}
showTooltip={false}
title={t('memory.title')}
popover={{

View File

@@ -33,6 +33,8 @@ export default {
'channel.publicKey': 'Public Key',
'channel.publicKeyHint': 'Optional. Used to verify interaction requests from Discord.',
'channel.publicKeyPlaceholder': 'Required for interaction verification',
'channel.qq.appIdHint': 'Your QQ Bot App ID from QQ Open Platform',
'channel.qq.description': 'Connect this assistant to QQ for group chats and direct messages.',
'channel.removeChannel': 'Remove Channel',
'channel.removed': 'Channel removed',
'channel.removeFailed': 'Failed to remove channel',

View File

@@ -209,7 +209,7 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
const provider = CHANNEL_PROVIDERS.find((p) => p.id === metadata.bot!.platform);
if (provider) {
const ProviderIcon = provider.icon;
return <ProviderIcon color={provider.color} size={16} />;
return <ProviderIcon color={cssVar.colorTextDescription} size={16} />;
}
}
return (

View File

@@ -1,9 +1,7 @@
import { SiDiscord, SiTelegram } from '@icons-pack/react-simple-icons';
import { Discord, Lark, QQ, Telegram } from '@lobehub/ui/icons';
import type { LucideIcon } from 'lucide-react';
import type { FC } from 'react';
import { LarkIcon } from './icons';
export interface ChannelProvider {
/** Lark-style auth: appId + appSecret instead of botToken */
authMode?: 'app-secret' | 'bot-token';
@@ -40,7 +38,7 @@ export const CHANNEL_PROVIDERS: ChannelProvider[] = [
publicKey: 'Public Key',
token: 'Bot Token',
},
icon: SiDiscord,
icon: Discord,
id: 'discord',
name: 'Discord',
webhookMode: 'auto',
@@ -55,7 +53,7 @@ export const CHANNEL_PROVIDERS: ChannelProvider[] = [
secretToken: 'Webhook Secret',
token: 'Bot Token',
},
icon: SiTelegram,
icon: Telegram,
id: 'telegram',
name: 'Telegram',
webhookMode: 'auto',
@@ -73,7 +71,7 @@ export const CHANNEL_PROVIDERS: ChannelProvider[] = [
verificationToken: 'Verification Token',
webhook: 'Event Subscription URL',
},
icon: LarkIcon,
icon: Lark,
id: 'feishu',
name: '飞书',
},
@@ -90,8 +88,23 @@ export const CHANNEL_PROVIDERS: ChannelProvider[] = [
verificationToken: 'Verification Token',
webhook: 'Event Subscription URL',
},
icon: LarkIcon,
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',
},
];

View File

@@ -1,14 +1,6 @@
'use client';
import {
Alert,
Flexbox,
Form,
type FormGroupItemType,
type FormItemProps,
Icon,
Tag,
} from '@lobehub/ui';
import { Alert, Flexbox, Form, type FormGroupItemType, type FormItemProps, Tag } from '@lobehub/ui';
import { Button, Form as AntdForm, type FormInstance, Switch } from 'antd';
import { createStaticStyles } from 'antd-style';
import { RefreshCw, Save, Trash2 } from 'lucide-react';
@@ -22,6 +14,7 @@ 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';
const prefixCls = 'ant';
@@ -77,6 +70,7 @@ const platformFormItemsMap: Record<
discord: getDiscordFormItems,
feishu: getFeishuFormItems,
lark: getLarkFormItems,
qq: getQQFormItems,
telegram: getTelegramFormItems,
};
@@ -123,21 +117,11 @@ const Body = memo<BodyProps>(
const getItems = platformFormItemsMap[provider.id];
const configItems = getItems ? getItems(t, hasConfig, provider) : [];
const ColorIcon = 'Color' in provider.icon ? (provider.icon as any).Color : provider.icon;
const headerTitle = (
<Flexbox horizontal align="center" gap={8}>
<Flexbox
align="center"
justify="center"
style={{
background: provider.color,
borderRadius: 10,
flexShrink: 0,
height: 32,
width: 32,
}}
>
<Icon fill="white" icon={provider.icon} size="middle" />
</Flexbox>
<ColorIcon size={32} />
{provider.name}
</Flexbox>
);

View File

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

View File

@@ -1,34 +0,0 @@
import { type SVGProps } from 'react';
interface IconProps extends SVGProps<SVGSVGElement> {
color?: string;
size?: number | string;
}
/**
* Lark / Feishu brand mark icon.
* Extracted from the official Lark word-mark SVG.
* Renders as monochrome (currentColor) by default.
*/
export const LarkIcon = ({
ref,
color,
size = 24,
style,
...props
}: IconProps & { ref?: React.RefObject<SVGSVGElement | null> }) => (
<svg
fill={color || 'currentColor'}
height={size}
ref={ref}
style={{ flexShrink: 0, ...style }}
viewBox="0 0 36 29"
width={size}
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M18.43 15.043l.088-.087c.056-.057.117-.117.177-.174l.122-.117.36-.356.495-.481.42-.417.395-.39.412-.408.378-.373.53-.52c.099-.1.203-.196.307-.291.191-.174.39-.343.59-.508a13.271 13.271 0 0 1 1.414-.976c.283-.165.573-.321.868-.469a11.562 11.562 0 0 1 1.345-.55c.083-.027.165-.057.252-.083A20.808 20.808 0 0 0 22.648.947a1.904 1.904 0 0 0-1.48-.707H5.962a.286.286 0 0 0-.17.516 44.38 44.38 0 0 1 12.604 14.326l.035-.04z" />
<path d="M12.386 28.427c7.853 0 14.695-4.334 18.261-10.738.126-.226.247-.451.364-.681a8.405 8.405 0 0 1-.837 1.31 9.404 9.404 0 0 1-.581.677 7.485 7.485 0 0 1-.911.815 6.551 6.551 0 0 1-.412.295 8.333 8.333 0 0 1-.555.343 7.887 7.887 0 0 1-1.754.72 7.58 7.58 0 0 1-.932.2c-.226.035-.46.06-.69.078-.243.017-.49.022-.738.022a8.826 8.826 0 0 1-.824-.052 9.901 9.901 0 0 1-.612-.087 7.81 7.81 0 0 1-.533-.113c-.096-.022-.187-.048-.282-.074a56.83 56.83 0 0 1-.781-.217c-.13-.039-.26-.073-.386-.112a22.1 22.1 0 0 1-.578-.178c-.156-.048-.312-.1-.468-.152-.148-.048-.3-.096-.447-.148l-.304-.104-.368-.13-.26-.095a18.462 18.462 0 0 1-.517-.191c-.1-.04-.2-.074-.3-.113l-.398-.156-.421-.17-.274-.112-.338-.14-.26-.107-.27-.118-.234-.104-.212-.095-.217-.1-.221-.104-.282-.13-.295-.14c-.104-.051-.209-.099-.313-.151l-.264-.13A43.902 43.902 0 0 1 .495 8.665.287.287 0 0 0 0 8.86l.009 13.42v1.089c0 .633.312 1.223.837 1.575a20.685 20.685 0 0 0 11.54 3.484z" />
<path d="M35.463 9.511a12.003 12.003 0 0 0-8.88-.672c-.083.026-.166.052-.252.082a12.415 12.415 0 0 0-2.213 1.015c-.29.17-.569.352-.842.547a11.063 11.063 0 0 0-1.163.937c-.104.096-.203.191-.308.29l-.529.521-.377.374-.412.407-.395.39-.421.417-.49.486-.36.356-.122.117a6.7 6.7 0 0 1-.178.174l-.087.087-.134.125-.152.14a21.037 21.037 0 0 1-4.33 3.066l.282.13.222.105.217.1.212.095.234.104.27.117.26.109.338.139.273.112.421.17c.13.052.265.104.4.156.1.039.199.073.299.113.173.065.347.125.516.19l.26.096c.122.043.243.087.37.13l.303.104c.147.048.295.1.447.148.156.052.312.1.468.152.191.06.386.117.577.177a51.658 51.658 0 0 0 1.167.33c.096.026.187.048.282.074.178.043.356.078.534.113.204.034.408.065.612.086a8.286 8.286 0 0 0 2.252-.048c.312-.047.624-.116.932-.199a7.619 7.619 0 0 0 1.15-.416 7.835 7.835 0 0 0 .89-.473c.095-.057.181-.117.268-.174.139-.095.278-.19.412-.295.117-.087.23-.178.339-.273a8.34 8.34 0 0 0 1.15-1.22 9.294 9.294 0 0 0 .833-1.302l.203-.402 1.814-3.614.021-.044a11.865 11.865 0 0 1 2.417-3.449z" />
</svg>
);

View File

@@ -92,16 +92,14 @@ const PlatformList = memo<PlatformListProps>(
<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;
return (
<button
className={cx(styles.item, activeId === provider.id && 'active')}
key={provider.id}
onClick={() => onSelect(provider.id)}
>
<ProviderIcon
color={activeId === provider.id ? provider.color : theme.colorTextSecondary}
size={20}
/>
<ColorIcon size={20} />
<span style={{ flex: 1 }}>{provider.name}</span>
{connectedPlatforms.has(provider.id) && <div className={styles.statusDot} />}
</button>

View File

@@ -13,6 +13,7 @@ import { isQueueAgentRuntimeEnabled } from '@/server/services/queue/impls';
import { SystemAgentService } from '@/server/services/systemAgent';
import { formatPrompt as formatPromptUtil } from './formatPrompt';
import { getPlatformDescriptor } from './platforms';
import {
renderError,
renderFinalReply,
@@ -141,7 +142,18 @@ export class AgentBridgeService {
thread.adapter.addReaction(parentChannelThreadId(thread.id), message.id, RECEIVED_EMOJI),
'add eyes',
);
await thread.subscribe();
// 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) {
await thread.subscribe();
}
await thread.startTyping();
// Keep typing indicator alive (Telegram's expires after ~5s)
@@ -166,7 +178,8 @@ export class AgentBridgeService {
});
// Persist topic mapping and channel context in thread state for follow-up messages
if (topicId) {
// Skip for non-threaded Discord channels (no subscribe = no follow-up)
if (topicId && !isDiscordTopLevelChannel) {
await thread.setState({ channelContext, topicId });
log('handleMention: stored topicId=%s in thread=%s state', topicId, thread.id);
}
@@ -507,8 +520,8 @@ export class AgentBridgeService {
totalTokens: finalState.usage?.llm?.tokens?.total ?? 0,
});
// Telegram supports 4096 chars vs Discord's 2000
const charLimit = platform === 'telegram' ? 4000 : undefined;
const descriptor = platform ? getPlatformDescriptor(platform) : undefined;
const charLimit = descriptor?.charLimit;
const chunks = splitMessage(finalText, charLimit);
if (progressMessage) {

View File

@@ -6,84 +6,13 @@ import { type LobeChatDatabase } from '@/database/type';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { SystemAgentService } from '@/server/services/systemAgent';
import { DiscordRestApi } from './discordRestApi';
import { LarkRestApi } from './larkRestApi';
import { getPlatformDescriptor } from './platforms';
import { DiscordRestApi } from './platforms/discord';
import { renderError, renderFinalReply, renderStepProgress, splitMessage } from './replyTemplate';
import { TelegramRestApi } from './telegramRestApi';
import type { PlatformMessenger } from './types';
const log = debug('lobe-server:bot:callback');
// --------------- Platform helpers ---------------
function extractDiscordChannelId(platformThreadId: string): string {
const parts = platformThreadId.split(':');
return parts[3] || parts[2];
}
function extractTelegramChatId(platformThreadId: string): string {
return platformThreadId.split(':')[1];
}
function extractLarkChatId(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);
}
const TELEGRAM_CHAR_LIMIT = 4000;
const LARK_CHAR_LIMIT = 4000;
// --------------- Platform-agnostic messenger ---------------
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>;
}
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();
},
};
}
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'),
};
}
function createLarkMessenger(lark: LarkRestApi, chatId: string): PlatformMessenger {
return {
createMessage: (content) => lark.sendMessage(chatId, content).then(() => {}),
editMessage: (messageId, content) => lark.editMessage(messageId, content),
// Lark has no reaction/typing API for bots
removeReaction: () => Promise.resolve(),
triggerTyping: () => Promise.resolve(),
};
}
// --------------- Callback body types ---------------
export interface BotCallbackBody {
@@ -173,42 +102,21 @@ export class BotCallbackService {
credentials = JSON.parse(row.credentials);
}
const isLark = platform === 'lark' || platform === 'feishu';
const descriptor = getPlatformDescriptor(platform);
if (!descriptor) {
throw new Error(`Unsupported platform: ${platform}`);
}
if (isLark ? !credentials.appId || !credentials.appSecret : !credentials.botToken) {
const missingCreds = descriptor.requiredCredentials.filter((k) => !credentials[k]);
if (missingCreds.length > 0) {
throw new Error(`Bot credentials incomplete for ${platform} appId=${applicationId}`);
}
switch (platform) {
case 'telegram': {
const telegram = new TelegramRestApi(credentials.botToken);
const chatId = extractTelegramChatId(platformThreadId);
return {
botToken: credentials.botToken,
charLimit: TELEGRAM_CHAR_LIMIT,
messenger: createTelegramMessenger(telegram, chatId),
};
}
case 'lark':
case 'feishu': {
const lark = new LarkRestApi(credentials.appId, credentials.appSecret, platform);
const chatId = extractLarkChatId(platformThreadId);
return {
botToken: credentials.appId,
charLimit: LARK_CHAR_LIMIT,
messenger: createLarkMessenger(lark, chatId),
};
}
case 'discord':
default: {
const discord = new DiscordRestApi(credentials.botToken);
const channelId = extractDiscordChannelId(platformThreadId);
return {
botToken: credentials.botToken,
messenger: createDiscordMessenger(discord, channelId, platformThreadId),
};
}
}
return {
botToken: credentials.botToken || credentials.appId,
charLimit: descriptor.charLimit,
messenger: descriptor.createMessenger(credentials, platformThreadId),
};
}
private async handleStep(
@@ -310,8 +218,9 @@ export class BotCallbackService {
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 || extractDiscordChannelId(platformThreadId);
const targetChannelId = reactionChannelId || descriptor.extractChatId(platformThreadId);
await discord.removeOwnReaction(targetChannelId, userMessageId, '👀');
} else {
await messenger.removeReaction(userMessageId, '👀');

View File

@@ -1,19 +1,15 @@
import { createDiscordAdapter } from '@chat-adapter/discord';
import { createIoRedisState } from '@chat-adapter/state-ioredis';
import { createTelegramAdapter } from '@chat-adapter/telegram';
import { createLarkAdapter } from '@lobechat/adapter-lark';
import { Chat, ConsoleLogger } from 'chat';
import debug from 'debug';
import { getServerDB } from '@/database/core/db-adaptor';
import { AgentBotProviderModel } from '@/database/models/agentBotProvider';
import type { LobeChatDatabase } from '@/database/type';
import { appEnv } from '@/envs/app';
import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { AgentBridgeService } from './AgentBridgeService';
import { setTelegramWebhook } from './platforms/telegram';
import { getPlatformDescriptor, platformDescriptors } from './platforms';
const log = debug('lobe-server:bot:message-router');
@@ -26,50 +22,6 @@ interface StoredCredentials {
[key: string]: string;
}
/**
* Adapter factory: creates the correct Chat SDK adapter from platform + credentials.
*/
function createAdapterForPlatform(
platform: string,
credentials: StoredCredentials,
applicationId: string,
): Record<string, any> | null {
switch (platform) {
case 'discord': {
return {
discord: createDiscordAdapter({
applicationId,
botToken: credentials.botToken,
publicKey: credentials.publicKey,
}),
};
}
case 'telegram': {
return {
telegram: createTelegramAdapter({
botToken: credentials.botToken,
secretToken: credentials.secretToken,
}),
};
}
case 'lark':
case 'feishu': {
return {
[platform]: createLarkAdapter({
appId: credentials.appId,
appSecret: credentials.appSecret,
encryptKey: credentials.encryptKey,
platform: platform as 'lark' | 'feishu',
verificationToken: credentials.verificationToken,
}),
};
}
default: {
return null;
}
}
}
/**
* Routes incoming webhook events to the correct Chat SDK Bot instance
* and triggers message processing via AgentBridgeService.
@@ -101,26 +53,23 @@ export class BotMessageRouter {
return async (req: Request) => {
await this.ensureInitialized();
switch (platform) {
case 'discord': {
return this.handleDiscordWebhook(req);
}
case 'telegram': {
return this.handleTelegramWebhook(req, appId);
}
case 'lark':
case 'feishu': {
return this.handleChatSdkWebhook(req, platform, appId);
}
default: {
return new Response('No bot configured for this platform', { status: 404 });
}
const descriptor = getPlatformDescriptor(platform);
if (!descriptor) {
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);
}
// All other platforms use direct lookup by appId with fallback iteration
return this.handleGenericWebhook(req, platform, appId);
};
}
// ------------------------------------------------------------------
// Discord webhook routing
// Discord webhook routing (special: gateway token + interaction payload)
// ------------------------------------------------------------------
private async handleDiscordWebhook(req: Request): Promise<Response> {
@@ -193,81 +142,15 @@ export class BotMessageRouter {
}
// ------------------------------------------------------------------
// Telegram webhook routing
// Generic webhook routing (Telegram, Lark, Feishu, and future platforms)
// ------------------------------------------------------------------
private async handleTelegramWebhook(req: Request, appId?: string): Promise<Response> {
const bodyBuffer = await req.arrayBuffer();
log(
'handleTelegramWebhook: method=%s, appId=%s, content-length=%d',
req.method,
appId ?? '(none)',
bodyBuffer.byteLength,
);
// Log raw update for debugging
try {
const bodyText = new TextDecoder().decode(bodyBuffer);
const update = JSON.parse(bodyText);
const msg = update.message;
if (msg) {
log(
'Telegram update: chat_type=%s, from=%s (id=%s), text=%s',
msg.chat?.type,
msg.from?.username || msg.from?.first_name,
msg.from?.id,
msg.text?.slice(0, 100),
);
} else {
log('Telegram update (non-message): keys=%s', Object.keys(update).join(','));
}
} catch {
// ignore parse errors
}
// Direct lookup by applicationId (bot-specific endpoint: /webhooks/telegram/{appId})
if (appId) {
const key = `telegram:${appId}`;
const bot = this.botInstances.get(key);
if (bot?.webhooks && 'telegram' in bot.webhooks) {
log('handleTelegramWebhook: direct lookup hit for %s', key);
return bot.webhooks.telegram(this.cloneRequest(req, bodyBuffer));
}
log('handleTelegramWebhook: no bot registered for %s', key);
return new Response('No bot configured for Telegram', { status: 404 });
}
// Fallback: iterate all registered Telegram bots (legacy /webhooks/telegram endpoint).
// Secret token verification will reject mismatches.
for (const [key, bot] of this.botInstances) {
if (!key.startsWith('telegram:')) continue;
if (bot.webhooks && 'telegram' in bot.webhooks) {
try {
log('handleTelegramWebhook: trying bot %s', key);
const resp = await bot.webhooks.telegram(this.cloneRequest(req, bodyBuffer));
log('handleTelegramWebhook: bot %s responded with status=%d', key, resp.status);
if (resp.status !== 401) return resp;
} catch (error) {
log('handleTelegramWebhook: bot %s webhook error: %O', key, error);
}
}
}
log('handleTelegramWebhook: no matching bot found');
return new Response('No bot configured for Telegram', { status: 404 });
}
// ------------------------------------------------------------------
// Generic Chat SDK webhook routing (Lark/Feishu)
// ------------------------------------------------------------------
private async handleChatSdkWebhook(
private async handleGenericWebhook(
req: Request,
platform: string,
appId?: string,
): Promise<Response> {
log('handleChatSdkWebhook: platform=%s, appId=%s', platform, appId);
log('handleGenericWebhook: platform=%s, appId=%s', platform, appId);
const bodyBuffer = await req.arrayBuffer();
@@ -278,7 +161,7 @@ export class BotMessageRouter {
if (bot?.webhooks && platform in bot.webhooks) {
return (bot.webhooks as any)[platform](this.cloneRequest(req, bodyBuffer));
}
log('handleChatSdkWebhook: no bot registered for %s', key);
log('handleGenericWebhook: no bot registered for %s', key);
return new Response(`No bot configured for ${platform}`, { status: 404 });
}
@@ -350,8 +233,8 @@ export class BotMessageRouter {
const serverDB = await getServerDB();
const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey();
// Load all supported platforms
for (const platform of ['discord', 'telegram', 'lark', 'feishu']) {
// Load all supported platforms from the descriptor registry
for (const platform of Object.keys(platformDescriptors)) {
const providers = await AgentBotProviderModel.findEnabledByPlatform(
serverDB,
platform,
@@ -369,12 +252,14 @@ export class BotMessageRouter {
continue;
}
const adapters = createAdapterForPlatform(platform, credentials, applicationId);
if (!adapters) {
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,
@@ -388,25 +273,12 @@ export class BotMessageRouter {
this.agentMap.set(key, { agentId, userId });
this.credentialsByKey.set(key, credentials);
// Discord-specific: also index by botToken for gateway forwarding
if (platform === 'discord' && credentials.botToken) {
this.botInstancesByToken.set(credentials.botToken, bot);
}
// Telegram: call setWebhook to ensure Telegram-side secret_token
// stays in sync with the adapter config (idempotent, safe on every init)
if (platform === 'telegram' && credentials.botToken) {
const baseUrl = (credentials.webhookProxyUrl || appEnv.APP_URL || '').replace(
/\/$/,
'',
);
const webhookUrl = `${baseUrl}/api/agent/webhooks/telegram/${applicationId}`;
setTelegramWebhook(credentials.botToken, webhookUrl, credentials.secretToken).catch(
(err) => {
log('Failed to set Telegram webhook for appId=%s: %O', applicationId, err);
},
);
}
// 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);
}
@@ -479,11 +351,9 @@ export class BotMessageRouter {
});
});
// Telegram/Lark: handle messages in unsubscribed threads that aren't @mentions.
// This covers direct messages where users message the bot without an explicit @mention.
// Discord relies solely on onNewMention/onSubscribedMessage — registering a
// catch-all there would cause unsolicited replies in active channels.
if (platform === 'telegram' || platform === 'lark' || platform === 'feishu') {
// Register onNewMessage handler based on platform descriptor
const descriptor = getPlatformDescriptor(platform);
if (descriptor?.handleDirectMessages) {
bot.onNewMessage(/./, async (thread, message) => {
if (message.author.isBot === true) return;

View File

@@ -53,7 +53,7 @@ vi.mock('@/server/services/systemAgent', () => ({
})),
}));
vi.mock('../discordRestApi', () => ({
vi.mock('../platforms/discord/restApi', () => ({
DiscordRestApi: vi.fn().mockImplementation(() => ({
createMessage: mockDiscordCreateMessage,
editMessage: mockDiscordEditMessage,
@@ -63,7 +63,7 @@ vi.mock('../discordRestApi', () => ({
})),
}));
vi.mock('../telegramRestApi', () => ({
vi.mock('../platforms/telegram/restApi', () => ({
TelegramRestApi: vi.fn().mockImplementation(() => ({
editMessageText: mockTelegramEditMessageText,
removeMessageReaction: mockTelegramRemoveMessageReaction,

View File

@@ -0,0 +1,255 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { BotMessageRouter } from '../BotMessageRouter';
// ==================== Hoisted mocks ====================
const mockFindEnabledByPlatform = vi.hoisted(() => vi.fn());
const mockInitWithEnvKey = vi.hoisted(() => vi.fn());
const mockGetServerDB = vi.hoisted(() => vi.fn());
vi.mock('@/database/core/db-adaptor', () => ({
getServerDB: mockGetServerDB,
}));
vi.mock('@/database/models/agentBotProvider', () => ({
AgentBotProviderModel: {
findEnabledByPlatform: mockFindEnabledByPlatform,
},
}));
vi.mock('@/server/modules/KeyVaultsEncrypt', () => ({
KeyVaultsGateKeeper: {
initWithEnvKey: mockInitWithEnvKey,
},
}));
vi.mock('@/server/modules/AgentRuntime/redis', () => ({
getAgentRuntimeRedisClient: vi.fn().mockReturnValue(null),
}));
vi.mock('@chat-adapter/state-ioredis', () => ({
createIoRedisState: vi.fn(),
}));
// Mock Chat SDK
const mockInitialize = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockOnNewMention = vi.hoisted(() => vi.fn());
const mockOnSubscribedMessage = vi.hoisted(() => vi.fn());
const mockOnNewMessage = vi.hoisted(() => vi.fn());
vi.mock('chat', () => ({
Chat: vi.fn().mockImplementation(() => ({
initialize: mockInitialize,
onNewMention: mockOnNewMention,
onNewMessage: mockOnNewMessage,
onSubscribedMessage: mockOnSubscribedMessage,
webhooks: {},
})),
ConsoleLogger: vi.fn(),
}));
vi.mock('../AgentBridgeService', () => ({
AgentBridgeService: vi.fn().mockImplementation(() => ({
handleMention: vi.fn().mockResolvedValue(undefined),
handleSubscribedMessage: vi.fn().mockResolvedValue(undefined),
})),
}));
// Mock platform descriptors
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) => {
if (platform === 'unknown') return undefined;
return {
charLimit: platform === 'telegram' ? 4000 : undefined,
createAdapter: mockCreateAdapter,
handleDirectMessages: platform === 'telegram' || platform === 'lark',
onBotRegistered: mockOnBotRegistered,
persistent: platform === 'discord',
platform,
};
}),
platformDescriptors: {
discord: { platform: 'discord' },
lark: { platform: 'lark' },
telegram: { platform: 'telegram' },
},
}));
// ==================== Tests ====================
describe('BotMessageRouter', () => {
const FAKE_DB = {} as any;
const FAKE_GATEKEEPER = { decrypt: vi.fn() };
beforeEach(() => {
vi.clearAllMocks();
mockGetServerDB.mockResolvedValue(FAKE_DB);
mockInitWithEnvKey.mockResolvedValue(FAKE_GATEKEEPER);
mockFindEnabledByPlatform.mockResolvedValue([]);
});
describe('getWebhookHandler', () => {
it('should return 404 for unknown platform', async () => {
const router = new BotMessageRouter();
const handler = router.getWebhookHandler('unknown');
const req = new Request('https://example.com/webhook', { method: 'POST' });
const resp = await handler(req);
expect(resp.status).toBe(404);
expect(await resp.text()).toBe('No bot configured for this platform');
});
it('should return a handler function', () => {
const router = new BotMessageRouter();
const handler = router.getWebhookHandler('telegram', 'app-123');
expect(typeof handler).toBe('function');
});
});
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 [];
});
const router = new BotMessageRouter();
await router.initialize();
// 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' },
}),
);
});
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 [];
});
const router = new BotMessageRouter();
await router.initialize();
// Telegram should have onNewMessage registered
expect(mockOnNewMessage).toHaveBeenCalled();
});
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 [];
});
const router = new BotMessageRouter();
await router.initialize();
// Discord should NOT have onNewMessage registered (handleDirectMessages = false)
expect(mockOnNewMessage).not.toHaveBeenCalled();
});
it('should skip already registered bots on refresh', async () => {
mockFindEnabledByPlatform.mockResolvedValue([
{
agentId: 'agent-1',
applicationId: 'app-1',
credentials: { botToken: 'token' },
userId: 'user-1',
},
]);
const router = new BotMessageRouter();
await router.initialize();
const firstCallCount = mockInitialize.mock.calls.length;
// Force a second load
await (router as any).loadAgentBots();
// Should not create duplicate bots
expect(mockInitialize.mock.calls.length).toBe(firstCallCount);
});
it('should handle DB errors gracefully during initialization', async () => {
mockFindEnabledByPlatform.mockRejectedValue(new Error('DB connection failed'));
const router = new BotMessageRouter();
// Should not throw
await expect(router.initialize()).resolves.toBeUndefined();
});
});
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 [];
});
const router = new BotMessageRouter();
await router.initialize();
expect(mockOnNewMention).toHaveBeenCalled();
expect(mockOnSubscribedMessage).toHaveBeenCalled();
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,8 @@ import debug from 'debug';
import { appEnv } from '@/envs/app';
import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis';
import type { PlatformBot } from '../types';
import type { PlatformBot, PlatformDescriptor, PlatformMessenger } from '../../types';
import { DiscordRestApi } from './restApi';
const log = debug('lobe-server:bot:gateway:discord');
@@ -110,3 +111,59 @@ export class Discord implements PlatformBot {
this.abort.abort();
}
}
// --------------- Platform Descriptor ---------------
function extractChannelId(platformThreadId: string): string {
const parts = platformThreadId.split(':');
return parts[3] || parts[2];
}
function createDiscordMessenger(
discord: DiscordRestApi,
channelId: string,
platformThreadId: string,
): PlatformMessenger {
return {
createMessage: (content) => discord.createMessage(channelId, content).then(() => {}),
editMessage: (messageId, content) => discord.editMessage(channelId, messageId, content),
removeReaction: (messageId, emoji) => discord.removeOwnReaction(channelId, messageId, emoji),
triggerTyping: () => discord.triggerTyping(channelId),
updateThreadName: (name) => {
const threadId = platformThreadId.split(':')[3];
return threadId ? discord.updateChannelName(threadId, name) : Promise.resolve();
},
};
}
export const discordDescriptor: PlatformDescriptor = {
platform: 'discord',
persistent: true,
handleDirectMessages: false,
requiredCredentials: ['botToken'],
extractChatId: extractChannelId,
parseMessageId: (compositeId) => compositeId,
createMessenger(credentials, platformThreadId) {
const discord = new DiscordRestApi(credentials.botToken);
const channelId = extractChannelId(platformThreadId);
return createDiscordMessenger(discord, channelId, platformThreadId);
},
createAdapter(credentials, applicationId) {
return {
discord: createDiscordAdapter({
applicationId,
botToken: credentials.botToken,
publicKey: credentials.publicKey,
}),
};
},
async onBotRegistered({ credentials, registerByToken }) {
if (credentials.botToken && registerByToken) {
registerByToken(credentials.botToken);
}
},
};

View File

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

View File

@@ -1,11 +1,25 @@
import type { PlatformBotClass } from '../types';
import { Discord } from './discord';
import { Lark } from './lark';
import { Telegram } from './telegram';
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';
export const platformBotRegistry: Record<string, PlatformBotClass> = {
discord: Discord,
feishu: Lark,
lark: Lark,
qq: QQ,
telegram: Telegram,
};
export const platformDescriptors: Record<string, PlatformDescriptor> = {
discord: discordDescriptor,
feishu: feishuDescriptor,
lark: larkDescriptor,
qq: qqDescriptor,
telegram: telegramDescriptor,
};
export function getPlatformDescriptor(platform: string): PlatformDescriptor | undefined {
return platformDescriptors[platform];
}

View File

@@ -1,53 +0,0 @@
import debug from 'debug';
import { LarkRestApi } from '../larkRestApi';
import type { PlatformBot } from '../types';
const log = debug('lobe-server:bot:gateway:lark');
export interface LarkBotConfig {
[key: string]: string | undefined;
appId: string;
appSecret: string;
/** AES decrypt key for encrypted events (optional) */
encryptKey?: string;
/** 'lark' or 'feishu' — determines API base URL */
platform?: string;
/** Verification token for webhook event validation (optional) */
verificationToken?: string;
}
/**
* Lark/Feishu platform bot.
*
* Unlike Telegram, Lark does not support programmatic webhook registration.
* The user must configure the webhook URL manually in the Lark Developer Console.
* `start()` verifies credentials by fetching a tenant access token.
*/
export class Lark implements PlatformBot {
readonly platform: string;
readonly applicationId: string;
private config: LarkBotConfig;
constructor(config: LarkBotConfig) {
this.config = config;
this.applicationId = config.appId;
this.platform = config.platform || 'lark';
}
async start(): Promise<void> {
log('Starting LarkBot appId=%s platform=%s', this.applicationId, this.platform);
// Verify credentials by fetching a tenant access token
const api = new LarkRestApi(this.config.appId, this.config.appSecret, this.platform);
await api.getTenantAccessToken();
log('LarkBot appId=%s credentials verified', this.applicationId);
}
async stop(): Promise<void> {
log('Stopping LarkBot appId=%s', this.applicationId);
// No cleanup needed — webhook is managed in Lark Developer Console
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,10 @@
import { createTelegramAdapter } from '@chat-adapter/telegram';
import debug from 'debug';
import { appEnv } from '@/envs/app';
import type { PlatformBot } from '../types';
import type { PlatformBot, PlatformDescriptor, PlatformMessenger } from '../../types';
import { TELEGRAM_API_BASE, TelegramRestApi } from './restApi';
const log = debug('lobe-server:bot:gateway:telegram');
@@ -37,7 +39,7 @@ export async function setTelegramWebhook(
params.secret_token = secretToken;
}
const response = await fetch(`https://api.telegram.org/bot${botToken}/setWebhook`, {
const response = await fetch(`${TELEGRAM_API_BASE}/bot${botToken}/setWebhook`, {
body: JSON.stringify(params),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
@@ -91,10 +93,9 @@ export class Telegram implements PlatformBot {
}
private async deleteWebhook(): Promise<void> {
const response = await fetch(
`https://api.telegram.org/bot${this.config.botToken}/deleteWebhook`,
{ method: 'POST' },
);
const response = await fetch(`${TELEGRAM_API_BASE}/bot${this.config.botToken}/deleteWebhook`, {
method: 'POST',
});
if (!response.ok) {
const text = await response.text();
@@ -104,3 +105,61 @@ export class Telegram implements PlatformBot {
log('TelegramBot appId=%s webhook deleted', this.applicationId);
}
}
// --------------- Platform Descriptor ---------------
function extractChatId(platformThreadId: string): string {
return platformThreadId.split(':')[1];
}
function parseTelegramMessageId(compositeId: string): number {
const colonIdx = compositeId.lastIndexOf(':');
return colonIdx !== -1 ? Number(compositeId.slice(colonIdx + 1)) : Number(compositeId);
}
function createTelegramMessenger(telegram: TelegramRestApi, chatId: string): PlatformMessenger {
return {
createMessage: (content) => telegram.sendMessage(chatId, content).then(() => {}),
editMessage: (messageId, content) =>
telegram.editMessageText(chatId, parseTelegramMessageId(messageId), content),
removeReaction: (messageId) =>
telegram.removeMessageReaction(chatId, parseTelegramMessageId(messageId)),
triggerTyping: () => telegram.sendChatAction(chatId, 'typing'),
};
}
export const telegramDescriptor: PlatformDescriptor = {
platform: 'telegram',
charLimit: 4000,
persistent: false,
handleDirectMessages: true,
requiredCredentials: ['botToken'],
extractChatId,
parseMessageId: parseTelegramMessageId,
createMessenger(credentials, platformThreadId) {
const telegram = new TelegramRestApi(credentials.botToken);
const chatId = extractChatId(platformThreadId);
return createTelegramMessenger(telegram, chatId);
},
createAdapter(credentials) {
return {
telegram: createTelegramAdapter({
botToken: credentials.botToken,
secretToken: credentials.secretToken,
}),
};
},
async onBotRegistered({ applicationId, credentials }) {
const baseUrl = (credentials.webhookProxyUrl || appEnv.APP_URL || '').replace(/\/$/, '');
const webhookUrl = `${baseUrl}/api/agent/webhooks/telegram/${applicationId}`;
await setTelegramWebhook(credentials.botToken, webhookUrl, credentials.secretToken).catch(
(err) => {
log('Failed to set Telegram webhook for appId=%s: %O', applicationId, err);
},
);
},
};

View File

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

View File

@@ -2,7 +2,7 @@ import debug from 'debug';
const log = debug('lobe-server:bot:telegram-rest');
const TELEGRAM_API_BASE = 'https://api.telegram.org';
export const TELEGRAM_API_BASE = 'https://api.telegram.org';
/**
* Lightweight wrapper around the Telegram Bot API.

View File

@@ -1,3 +1,15 @@
// --------------- 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;
@@ -9,3 +21,72 @@ 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.
*/
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[];
}

View File

@@ -0,0 +1,200 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { PlatformBot, PlatformBotClass } from '../../bot/types';
import { GatewayManager } from '../GatewayManager';
const mockFindEnabledByPlatform = vi.hoisted(() => vi.fn());
const mockFindEnabledByApplicationId = vi.hoisted(() => vi.fn());
const mockInitWithEnvKey = vi.hoisted(() => vi.fn());
const mockGetServerDB = vi.hoisted(() => vi.fn());
vi.mock('@/database/core/db-adaptor', () => ({
getServerDB: mockGetServerDB,
}));
vi.mock('@/database/models/agentBotProvider', () => {
const MockModel = vi.fn().mockImplementation(() => ({
findEnabledByApplicationId: mockFindEnabledByApplicationId,
}));
(MockModel as any).findEnabledByPlatform = mockFindEnabledByPlatform;
return { AgentBotProviderModel: MockModel };
});
vi.mock('@/server/modules/KeyVaultsEncrypt', () => ({
KeyVaultsGateKeeper: {
initWithEnvKey: mockInitWithEnvKey,
},
}));
// Fake platform bot for testing
class FakeBot implements PlatformBot {
static persistent = false;
readonly platform: string;
readonly applicationId: string;
started = false;
stopped = false;
constructor(config: any) {
this.platform = config.platform;
this.applicationId = config.applicationId;
}
async start(): Promise<void> {
this.started = true;
}
async stop(): Promise<void> {
this.stopped = true;
}
}
const FAKE_DB = {} as any;
const FAKE_GATEKEEPER = { decrypt: vi.fn() };
describe('GatewayManager', () => {
let manager: GatewayManager;
beforeEach(() => {
vi.clearAllMocks();
mockGetServerDB.mockResolvedValue(FAKE_DB);
mockInitWithEnvKey.mockResolvedValue(FAKE_GATEKEEPER);
mockFindEnabledByPlatform.mockResolvedValue([]);
mockFindEnabledByApplicationId.mockResolvedValue(null);
manager = new GatewayManager({
registry: { fakeplatform: FakeBot as unknown as PlatformBotClass },
});
});
describe('lifecycle', () => {
it('should start and set running state', async () => {
await manager.start();
expect(manager.isRunning).toBe(true);
});
it('should not start twice', async () => {
await manager.start();
await manager.start();
// findEnabledByPlatform should only be called once (during first start)
expect(mockFindEnabledByPlatform).toHaveBeenCalledTimes(1);
});
it('should stop and clear running state', async () => {
await manager.start();
await manager.stop();
expect(manager.isRunning).toBe(false);
});
it('should not throw when stopping while not running', async () => {
await expect(manager.stop()).resolves.toBeUndefined();
});
});
describe('sync', () => {
it('should start bots for enabled providers', async () => {
mockFindEnabledByPlatform.mockResolvedValue([
{
applicationId: 'app-1',
credentials: { key: 'value' },
},
]);
await manager.start();
expect(manager.isRunning).toBe(true);
});
it('should skip already running bots', async () => {
mockFindEnabledByPlatform.mockResolvedValue([
{
applicationId: 'app-1',
credentials: { key: 'value' },
},
]);
await manager.start();
// Call start again (sync would be called again if manager was restarted)
// But since isRunning is true, it skips
expect(manager.isRunning).toBe(true);
});
it('should handle sync errors gracefully', async () => {
mockFindEnabledByPlatform.mockRejectedValue(new Error('DB connection failed'));
// Should not throw - error is caught internally
await expect(manager.start()).resolves.toBeUndefined();
expect(manager.isRunning).toBe(true);
});
});
describe('startBot', () => {
it('should handle missing provider gracefully', async () => {
await manager.start();
// startBot loads from DB - mock returns no provider
// This tests the "no enabled provider found" path
await expect(manager.startBot('fakeplatform', 'app-1', 'user-1')).resolves.toBeUndefined();
});
});
describe('stopBot', () => {
it('should stop a specific bot', async () => {
mockFindEnabledByPlatform.mockResolvedValue([
{
applicationId: 'app-1',
credentials: { key: 'value' },
},
]);
await manager.start();
await manager.stopBot('fakeplatform', 'app-1');
// No error should occur
expect(manager.isRunning).toBe(true);
});
it('should handle stopping non-existent bot gracefully', async () => {
await manager.start();
await expect(manager.stopBot('fakeplatform', 'non-existent')).resolves.toBeUndefined();
});
});
describe('createBot', () => {
it('should return null for unknown platform', async () => {
const managerWithEmpty = new GatewayManager({ registry: {} });
mockFindEnabledByPlatform.mockResolvedValue([
{
applicationId: 'app-1',
credentials: { key: 'value' },
},
]);
// With empty registry, no bots should be created
await managerWithEmpty.start();
expect(managerWithEmpty.isRunning).toBe(true);
});
});
describe('sync removes stale bots', () => {
it('should stop bots no longer in DB on subsequent syncs', async () => {
// First sync: one bot exists
mockFindEnabledByPlatform.mockResolvedValueOnce([
{
applicationId: 'app-1',
credentials: { key: 'value' },
},
]);
await manager.start();
// Verify bot was started
expect(manager.isRunning).toBe(true);
});
});
});