chore: add bot platform abstract

This commit is contained in:
rdmclin2
2026-03-10 22:18:02 +08:00
parent 4065dc0565
commit 802a8aee64
13 changed files with 1388 additions and 277 deletions

View File

@@ -13,6 +13,7 @@ import { isQueueAgentRuntimeEnabled } from '@/server/services/queue/impls';
import { SystemAgentService } from '@/server/services/systemAgent';
import { formatPrompt as formatPromptUtil } from './formatPrompt';
import { getPlatformDescriptor } from './platforms';
import {
renderError,
renderFinalReply,
@@ -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) {

View File

@@ -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, '👀');

View File

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

View File

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

View File

@@ -0,0 +1,188 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LarkRestApi } from '../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),
);
});
});
});

View 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' }),
);
});
});
});

View 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',
);
});
});
});

View File

@@ -7,7 +7,8 @@ import debug from 'debug';
import { appEnv } from '@/envs/app';
import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis';
import type { PlatformBot } from '../types';
import { 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);
}
},
};

View File

@@ -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];
}

View File

@@ -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');

View File

@@ -1,8 +1,10 @@
import { createTelegramAdapter } from '@chat-adapter/telegram';
import debug from 'debug';
import { appEnv } from '@/envs/app';
import type { PlatformBot } from '../types';
import { 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);
},
);
},
};

View File

@@ -1,3 +1,15 @@
// --------------- Platform Messenger ---------------
export interface PlatformMessenger {
createMessage: (content: string) => Promise<void>;
editMessage: (messageId: string, content: string) => Promise<void>;
removeReaction: (messageId: string, emoji: string) => Promise<void>;
triggerTyping: () => Promise<void>;
updateThreadName?: (name: string) => Promise<void>;
}
// --------------- Platform Bot (lifecycle) ---------------
export interface PlatformBot {
readonly applicationId: string;
readonly platform: string;
@@ -9,3 +21,72 @@ export type PlatformBotClass = (new (config: any) => PlatformBot) & {
/** Whether instances require a persistent connection (e.g. WebSocket). */
persistent?: boolean;
};
// --------------- Platform Descriptor ---------------
/**
* Encapsulates all platform-specific behavior.
*
* Adding a new bot platform only requires:
* 1. Create a new file in `platforms/` implementing a descriptor + PlatformBot class.
* 2. Register in `platforms/index.ts`.
*
* No switch statements or conditionals needed in BotMessageRouter, BotCallbackService,
* or AgentBridgeService.
*/
export interface PlatformDescriptor {
/** Maximum characters per message. Undefined = use default (1800). */
charLimit?: number;
/** Create a Chat SDK adapter config object keyed by adapter name. */
createAdapter: (
credentials: Record<string, string>,
applicationId: string,
) => Record<string, any>;
/** Create a PlatformMessenger for sending/editing messages via REST API. */
createMessenger: (
credentials: Record<string, string>,
platformThreadId: string,
) => PlatformMessenger;
/** Extract the chat/channel ID from a composite platformThreadId. */
extractChatId: (platformThreadId: string) => string;
// ---------- Thread/Message ID parsing ----------
/**
* Whether to register onNewMessage handler for direct messages.
* Telegram & Lark need this; Discord does not (would cause unsolicited replies).
*/
handleDirectMessages: boolean;
/**
* Called after a bot is registered in BotMessageRouter.loadAgentBots().
* Discord: indexes bot by token for gateway forwarding.
* Telegram: calls setWebhook API.
*/
onBotRegistered?: (context: {
applicationId: string;
credentials: Record<string, string>;
registerByToken?: (token: string) => void;
}) => Promise<void>;
// ---------- Credential validation ----------
/** Parse a composite message ID into the platform-native format. */
parseMessageId: (compositeId: string) => string | number;
// ---------- Factories ----------
/** Whether the platform uses persistent connections (WebSocket/Gateway). */
persistent: boolean;
/** Platform identifier (e.g., 'discord', 'telegram', 'lark'). */
platform: string;
// ---------- Lifecycle hooks ----------
/** Required credential field names for this platform. */
requiredCredentials: string[];
}

View File

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