mirror of
https://github.com/lobehub/lobehub.git
synced 2026-04-12 06:08:39 +07:00
chore: add bot platform abstract
This commit is contained in:
@@ -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,
|
||||
@@ -507,8 +508,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) {
|
||||
|
||||
@@ -7,83 +7,12 @@ 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 { 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;
|
||||
|
||||
|
||||
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 '../larkRestApi';
|
||||
|
||||
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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
287
src/server/services/bot/__tests__/platformDescriptors.test.ts
Normal file
287
src/server/services/bot/__tests__/platformDescriptors.test.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
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 { 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('../discordRestApi', () => ({
|
||||
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('../telegramRestApi', () => ({
|
||||
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('../larkRestApi', () => ({
|
||||
LarkRestApi: vi.fn().mockImplementation(() => ({
|
||||
editMessage: vi.fn().mockResolvedValue(undefined),
|
||||
sendMessage: vi.fn().mockResolvedValue({ messageId: 'lark-msg-1' }),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('platformDescriptors registry', () => {
|
||||
it('should have all 4 platforms registered', () => {
|
||||
expect(Object.keys(platformDescriptors)).toEqual(
|
||||
expect.arrayContaining(['discord', 'telegram', 'lark', 'feishu']),
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
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 '../telegramRestApi';
|
||||
|
||||
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 { DiscordRestApi } from '../discordRestApi';
|
||||
import type { PlatformBot, PlatformDescriptor, PlatformMessenger } from '../types';
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { Telegram, telegramDescriptor } from './telegram';
|
||||
|
||||
export const platformBotRegistry: Record<string, PlatformBotClass> = {
|
||||
discord: Discord,
|
||||
@@ -9,3 +9,14 @@ export const platformBotRegistry: Record<string, PlatformBotClass> = {
|
||||
lark: Lark,
|
||||
telegram: Telegram,
|
||||
};
|
||||
|
||||
export const platformDescriptors: Record<string, PlatformDescriptor> = {
|
||||
discord: discordDescriptor,
|
||||
feishu: feishuDescriptor,
|
||||
lark: larkDescriptor,
|
||||
telegram: telegramDescriptor,
|
||||
};
|
||||
|
||||
export function getPlatformDescriptor(platform: string): PlatformDescriptor | undefined {
|
||||
return platformDescriptors[platform];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createLarkAdapter } from '@lobechat/adapter-lark';
|
||||
import debug from 'debug';
|
||||
|
||||
import { LarkRestApi } from '../larkRestApi';
|
||||
import type { PlatformBot } from '../types';
|
||||
import type { PlatformBot, PlatformDescriptor } from '../types';
|
||||
|
||||
const log = debug('lobe-server:bot:gateway:lark');
|
||||
|
||||
@@ -51,3 +52,48 @@ export class Lark implements PlatformBot {
|
||||
// No cleanup needed — webhook is managed in Lark Developer Console
|
||||
}
|
||||
}
|
||||
|
||||
// --------------- Platform Descriptor ---------------
|
||||
|
||||
function extractChatId(platformThreadId: string): string {
|
||||
return platformThreadId.split(':')[1];
|
||||
}
|
||||
|
||||
function createLarkDescriptorForPlatform(platform: 'lark' | 'feishu'): PlatformDescriptor {
|
||||
return {
|
||||
platform,
|
||||
charLimit: 4000,
|
||||
persistent: false,
|
||||
handleDirectMessages: true,
|
||||
requiredCredentials: ['appId', 'appSecret'],
|
||||
|
||||
extractChatId,
|
||||
parseMessageId: (compositeId) => compositeId,
|
||||
|
||||
createMessenger(credentials, platformThreadId) {
|
||||
const lark = new LarkRestApi(credentials.appId, credentials.appSecret, platform);
|
||||
const chatId = extractChatId(platformThreadId);
|
||||
return {
|
||||
createMessage: (content) => lark.sendMessage(chatId, content).then(() => {}),
|
||||
editMessage: (messageId, content) => lark.editMessage(messageId, content),
|
||||
removeReaction: () => Promise.resolve(),
|
||||
triggerTyping: () => Promise.resolve(),
|
||||
};
|
||||
},
|
||||
|
||||
createAdapter(credentials) {
|
||||
return {
|
||||
[platform]: createLarkAdapter({
|
||||
appId: credentials.appId,
|
||||
appSecret: credentials.appSecret,
|
||||
encryptKey: credentials.encryptKey,
|
||||
platform,
|
||||
verificationToken: credentials.verificationToken,
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const larkDescriptor = createLarkDescriptorForPlatform('lark');
|
||||
export const feishuDescriptor = createLarkDescriptorForPlatform('feishu');
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createTelegramAdapter } from '@chat-adapter/telegram';
|
||||
import debug from 'debug';
|
||||
|
||||
import { appEnv } from '@/envs/app';
|
||||
|
||||
import type { PlatformBot } from '../types';
|
||||
import { TelegramRestApi } from '../telegramRestApi';
|
||||
import type { PlatformBot, PlatformDescriptor, PlatformMessenger } from '../types';
|
||||
|
||||
const log = debug('lobe-server:bot:gateway:telegram');
|
||||
|
||||
@@ -104,3 +106,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);
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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