mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
✨ 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:
139
docs/usage/channels/qq.mdx
Normal file
139
docs/usage/channels/qq.mdx
Normal 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.
|
||||
136
docs/usage/channels/qq.zh-CN.mdx
Normal file
136
docs/usage/channels/qq.zh-CN.mdx
Normal 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。
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "频道已移除",
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
26
packages/adapter-qq/package.json
Normal file
26
packages/adapter-qq/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
426
packages/adapter-qq/src/adapter.ts
Normal file
426
packages/adapter-qq/src/adapter.ts
Normal 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);
|
||||
}
|
||||
198
packages/adapter-qq/src/api.ts
Normal file
198
packages/adapter-qq/src/api.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
45
packages/adapter-qq/src/crypto.ts
Normal file
45
packages/adapter-qq/src/crypto.ts
Normal 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');
|
||||
}
|
||||
38
packages/adapter-qq/src/format-converter.ts
Normal file
38
packages/adapter-qq/src/format-converter.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
19
packages/adapter-qq/src/index.ts
Normal file
19
packages/adapter-qq/src/index.ts
Normal 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';
|
||||
123
packages/adapter-qq/src/types.ts
Normal file
123
packages/adapter-qq/src/types.ts
Normal 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;
|
||||
21
packages/adapter-qq/tsconfig.json
Normal file
21
packages/adapter-qq/tsconfig.json
Normal 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/**/*"]
|
||||
}
|
||||
8
packages/adapter-qq/tsup.config.ts
Normal file
8
packages/adapter-qq/tsup.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
dts: true,
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
sourcemap: true,
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
36
src/routes/(main)/agent/channel/detail/platforms/qq.tsx
Normal file
36
src/routes/(main)/agent/channel/detail/platforms/qq.tsx
Normal 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,
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, '👀');
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
255
src/server/services/bot/__tests__/BotMessageRouter.test.ts
Normal file
255
src/server/services/bot/__tests__/BotMessageRouter.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
188
src/server/services/bot/__tests__/larkRestApi.test.ts
Normal file
188
src/server/services/bot/__tests__/larkRestApi.test.ts
Normal 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
366
src/server/services/bot/__tests__/platformDescriptors.test.ts
Normal file
366
src/server/services/bot/__tests__/platformDescriptors.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
155
src/server/services/bot/__tests__/qqRestApi.test.ts
Normal file
155
src/server/services/bot/__tests__/qqRestApi.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
146
src/server/services/bot/__tests__/telegramRestApi.test.ts
Normal file
146
src/server/services/bot/__tests__/telegramRestApi.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
2
src/server/services/bot/platforms/discord/index.ts
Normal file
2
src/server/services/bot/platforms/discord/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Discord, type DiscordBotConfig, discordDescriptor } from './bot';
|
||||
export { DiscordRestApi } from './restApi';
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
99
src/server/services/bot/platforms/lark/bot.ts
Normal file
99
src/server/services/bot/platforms/lark/bot.ts
Normal 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');
|
||||
2
src/server/services/bot/platforms/lark/index.ts
Normal file
2
src/server/services/bot/platforms/lark/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { feishuDescriptor, Lark, larkDescriptor } from './bot';
|
||||
export { LarkRestApi } from './restApi';
|
||||
95
src/server/services/bot/platforms/qq/bot.ts
Normal file
95
src/server/services/bot/platforms/qq/bot.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
2
src/server/services/bot/platforms/qq/index.ts
Normal file
2
src/server/services/bot/platforms/qq/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { QQ, qqDescriptor } from './bot';
|
||||
export { QQ_API_BASE, QQRestApi } from './restApi';
|
||||
142
src/server/services/bot/platforms/qq/restApi.ts
Normal file
142
src/server/services/bot/platforms/qq/restApi.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
2
src/server/services/bot/platforms/telegram/index.ts
Normal file
2
src/server/services/bot/platforms/telegram/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { setTelegramWebhook, Telegram, type TelegramBotConfig, telegramDescriptor } from './bot';
|
||||
export { TELEGRAM_API_BASE, TelegramRestApi } from './restApi';
|
||||
@@ -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.
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
200
src/server/services/gateway/__tests__/GatewayManager.test.ts
Normal file
200
src/server/services/gateway/__tests__/GatewayManager.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user