diff --git a/src/server/services/bot/AgentBridgeService.ts b/src/server/services/bot/AgentBridgeService.ts index 7a11acc837..eb5f59ad7a 100644 --- a/src/server/services/bot/AgentBridgeService.ts +++ b/src/server/services/bot/AgentBridgeService.ts @@ -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) { diff --git a/src/server/services/bot/BotCallbackService.ts b/src/server/services/bot/BotCallbackService.ts index 2015afcf62..80f481e0b2 100644 --- a/src/server/services/bot/BotCallbackService.ts +++ b/src/server/services/bot/BotCallbackService.ts @@ -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; - editMessage: (messageId: string, content: string) => Promise; - removeReaction: (messageId: string, emoji: string) => Promise; - triggerTyping: () => Promise; - updateThreadName?: (name: string) => Promise; -} - -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, '👀'); diff --git a/src/server/services/bot/BotMessageRouter.ts b/src/server/services/bot/BotMessageRouter.ts index f76780a5ca..b6ff041cc1 100644 --- a/src/server/services/bot/BotMessageRouter.ts +++ b/src/server/services/bot/BotMessageRouter.ts @@ -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 | 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 { @@ -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 { - 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 { - 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; diff --git a/src/server/services/bot/__tests__/BotMessageRouter.test.ts b/src/server/services/bot/__tests__/BotMessageRouter.test.ts new file mode 100644 index 0000000000..df9578f1d7 --- /dev/null +++ b/src/server/services/bot/__tests__/BotMessageRouter.test.ts @@ -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(); + }); + }); +}); diff --git a/src/server/services/bot/__tests__/larkRestApi.test.ts b/src/server/services/bot/__tests__/larkRestApi.test.ts new file mode 100644 index 0000000000..eca954b517 --- /dev/null +++ b/src/server/services/bot/__tests__/larkRestApi.test.ts @@ -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), + ); + }); + }); +}); diff --git a/src/server/services/bot/__tests__/platformDescriptors.test.ts b/src/server/services/bot/__tests__/platformDescriptors.test.ts new file mode 100644 index 0000000000..8ab0853034 --- /dev/null +++ b/src/server/services/bot/__tests__/platformDescriptors.test.ts @@ -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' }), + ); + }); + }); +}); diff --git a/src/server/services/bot/__tests__/telegramRestApi.test.ts b/src/server/services/bot/__tests__/telegramRestApi.test.ts new file mode 100644 index 0000000000..8e6c314e3f --- /dev/null +++ b/src/server/services/bot/__tests__/telegramRestApi.test.ts @@ -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', + ); + }); + }); +}); diff --git a/src/server/services/bot/platforms/discord.ts b/src/server/services/bot/platforms/discord.ts index 5a911b2a42..84d9d41177 100644 --- a/src/server/services/bot/platforms/discord.ts +++ b/src/server/services/bot/platforms/discord.ts @@ -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); + } + }, +}; diff --git a/src/server/services/bot/platforms/index.ts b/src/server/services/bot/platforms/index.ts index f73ef94af7..895b729e3b 100644 --- a/src/server/services/bot/platforms/index.ts +++ b/src/server/services/bot/platforms/index.ts @@ -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 = { discord: Discord, @@ -9,3 +9,14 @@ export const platformBotRegistry: Record = { lark: Lark, telegram: Telegram, }; + +export const platformDescriptors: Record = { + discord: discordDescriptor, + feishu: feishuDescriptor, + lark: larkDescriptor, + telegram: telegramDescriptor, +}; + +export function getPlatformDescriptor(platform: string): PlatformDescriptor | undefined { + return platformDescriptors[platform]; +} diff --git a/src/server/services/bot/platforms/lark.ts b/src/server/services/bot/platforms/lark.ts index 329623596d..625cbe657d 100644 --- a/src/server/services/bot/platforms/lark.ts +++ b/src/server/services/bot/platforms/lark.ts @@ -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'); diff --git a/src/server/services/bot/platforms/telegram.ts b/src/server/services/bot/platforms/telegram.ts index 332f3126f0..ad5a1ade9c 100644 --- a/src/server/services/bot/platforms/telegram.ts +++ b/src/server/services/bot/platforms/telegram.ts @@ -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); + }, + ); + }, +}; diff --git a/src/server/services/bot/types.ts b/src/server/services/bot/types.ts index fc46b37ad6..7148582212 100644 --- a/src/server/services/bot/types.ts +++ b/src/server/services/bot/types.ts @@ -1,3 +1,15 @@ +// --------------- Platform Messenger --------------- + +export interface PlatformMessenger { + createMessage: (content: string) => Promise; + editMessage: (messageId: string, content: string) => Promise; + removeReaction: (messageId: string, emoji: string) => Promise; + triggerTyping: () => Promise; + updateThreadName?: (name: string) => Promise; +} + +// --------------- 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, + applicationId: string, + ) => Record; + + /** Create a PlatformMessenger for sending/editing messages via REST API. */ + createMessenger: ( + credentials: Record, + 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; + registerByToken?: (token: string) => void; + }) => Promise; + + // ---------- 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[]; +} diff --git a/src/server/services/gateway/__tests__/GatewayManager.test.ts b/src/server/services/gateway/__tests__/GatewayManager.test.ts new file mode 100644 index 0000000000..70d623a9d7 --- /dev/null +++ b/src/server/services/gateway/__tests__/GatewayManager.test.ts @@ -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 { + this.started = true; + } + + async stop(): Promise { + 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); + }); + }); +});