diff --git a/services/connector/src/adapters/telegram.spec.ts b/services/connector/src/adapters/telegram.spec.ts new file mode 100644 index 000000000..bb84e811d --- /dev/null +++ b/services/connector/src/adapters/telegram.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { withTimeout } from './telegram.js' + +describe('Telegram startup timeout', () => { + it('rejects an external startup operation that does not settle in time', async () => { + await expect(withTimeout( + () => new Promise(() => undefined), + 10, + 'Telegram API did not become ready within 10 seconds', + )).rejects.toThrow('Telegram API did not become ready within 10 seconds') + }) + + it('returns a successful startup result before the timeout', async () => { + await expect(withTimeout(async () => 'ready', 100, 'timed out')).resolves.toBe('ready') + }) +}) diff --git a/services/connector/src/adapters/telegram.ts b/services/connector/src/adapters/telegram.ts index f20f5da88..5e2ab63df 100644 --- a/services/connector/src/adapters/telegram.ts +++ b/services/connector/src/adapters/telegram.ts @@ -17,6 +17,8 @@ import { formatPlainInboxNotification, } from './shared.js' +const STARTUP_TIMEOUT_MS = 10_000 + export class TelegramConnectorAdapter implements ConnectorAdapter { readonly id = 'telegram' private readonly tracker = new AdapterHealthTracker(this.id) @@ -29,7 +31,6 @@ export class TelegramConnectorAdapter implements ConnectorAdapter { this.ownerUserId = optionalString(config, 'ownerUserId') this.chatId = optionalString(config, 'chatId') const bot = new Bot(token) - bot.api.config.use(autoRetry()) this.bot = bot for (const command of TELEGRAM_CONNECTOR_DEFINITION.commands) { @@ -50,11 +51,29 @@ export class TelegramConnectorAdapter implements ConnectorAdapter { }) } this.registerCommands(context) - await bot.api.setMyCommands(TELEGRAM_CONNECTOR_DEFINITION.commands.map(({ name, description }) => ({ - command: name, - description, - }))) - await bot.init() + try { + await withTimeout(async () => { + await bot.api.setMyCommands(TELEGRAM_CONNECTOR_DEFINITION.commands.map(({ name, description }) => ({ + command: name, + description, + }))) + await bot.init() + }, STARTUP_TIMEOUT_MS, 'Telegram API did not become ready within 10 seconds') + } catch (error) { + this.tracker.degraded(error) + this.bot = undefined + throw error + } + // Keep startup API calls untransformed. The bundled retry transformer can + // fail `setMyCommands` on this runtime even though the plain Telegram API + // call succeeds. Delivery still gets bounded retry behavior once the bot + // has initialized. + bot.api.config.use(autoRetry({ + maxRetryAttempts: 1, + maxDelaySeconds: 5, + rethrowHttpErrors: true, + rethrowInternalServerErrors: true, + })) if (this.ownerUserId && this.chatId) this.tracker.healthy(this.ownerUserId) else this.tracker.awaitingLink() void bot.start({ drop_pending_updates: true }).catch((error) => { @@ -135,3 +154,18 @@ function optionalString(config: ConnectorAdapterConfig, key: string): string | u const value = config.settings[key] return typeof value === 'string' && value.trim() ? value.trim() : undefined } + +export async function withTimeout(operation: () => Promise, timeoutMs: number, message: string): Promise { + let timer: NodeJS.Timeout | undefined + try { + return await Promise.race([ + operation(), + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeoutMs) + timer.unref?.() + }), + ]) + } finally { + if (timer) clearTimeout(timer) + } +} diff --git a/services/connector/src/core/delivery-manager.spec.ts b/services/connector/src/core/delivery-manager.spec.ts index a7e316a93..13f11070a 100644 --- a/services/connector/src/core/delivery-manager.spec.ts +++ b/services/connector/src/core/delivery-manager.spec.ts @@ -101,6 +101,38 @@ describe('DeliveryManager connector registry', () => { })).resolves.toBeUndefined() }) + it('keeps a failed adapter registered so its degraded health remains visible', async () => { + const adapter: ConnectorAdapter = { + id: 'broken', + start: async () => { throw new Error('Telegram API did not become ready') }, + stop: async () => undefined, + deliver: async () => { throw new Error('Telegram is not ready') }, + health: () => ({ + id: 'broken', + enabled: true, + status: 'degraded', + lastError: 'Telegram API did not become ready', + }), + } + const registry = new ConnectorRegistry() + registry.register({ + definition: { id: 'broken', label: 'Broken', description: 'Broken adapter.', fields: [], commands: [] }, + create: () => adapter, + }) + const manager = new DeliveryManager({ + registry, + config: { version: 1, adapters: { broken: { enabled: true, settings: {} } } }, + updateAdapterSettings: async () => undefined, + }) + + await manager.start() + + expect(manager.health()).toMatchObject({ + status: 'degraded', + adapters: [{ id: 'broken', status: 'degraded' }], + }) + }) + it('treats an online bot waiting for /link as an intentional setup phase', async () => { const registry = new ConnectorRegistry() registry.register({ diff --git a/services/connector/src/core/delivery-manager.ts b/services/connector/src/core/delivery-manager.ts index ecd6aee04..c37e8b152 100644 --- a/services/connector/src/core/delivery-manager.ts +++ b/services/connector/src/core/delivery-manager.ts @@ -149,9 +149,13 @@ export class DeliveryManager { getServiceStatus: () => this.health().status, sendTest: (connectorId) => this.sendTest(connectorId), } - await adapter.start(config, context) + // Keep the adapter registered while it starts. An adapter that reaches an + // external service can fail after it has recorded a useful degraded health + // reason; dropping it here reduced that evidence to a generic + // "configured but not running" state in Settings. this.adapters.set(id, adapter) this.commands.set(id, commands) + await adapter.start(config, context) } private get recorder(): ConnectorIORecorder { diff --git a/services/connector/src/main.ts b/services/connector/src/main.ts index 71b9b6617..fdd54e186 100644 --- a/services/connector/src/main.ts +++ b/services/connector/src/main.ts @@ -42,7 +42,6 @@ async function main(): Promise { recorder: journal, updateAdapterSettings: (id, patch) => configStore.patchAdapter(id, patch), }) - await manager.start() const app = new Hono() app.get('/__connector/health', (c) => c.json(manager.health())) @@ -75,6 +74,12 @@ async function main(): Promise { } process.on('SIGINT', () => { void shutdown('SIGINT') }) process.on('SIGTERM', () => { void shutdown('SIGTERM') }) + + // Adapter SDK startup reaches external services. Keep the loopback health + // endpoint available while an adapter is connecting or recovering so + // Guardian and Alice can report a degraded adapter instead of treating the + // whole optional service as unavailable. + await manager.start() } main().catch((error) => { diff --git a/services/connector/tsup.config.ts b/services/connector/tsup.config.ts index 79bd3b1c5..a7a0977f6 100644 --- a/services/connector/tsup.config.ts +++ b/services/connector/tsup.config.ts @@ -9,9 +9,11 @@ export default defineConfig({ clean: true, splitting: false, // Connector Service is a separately supervised deployable. Bundle its JS - // SDKs (discord.js / grammY / Hono / protocol) so Docker and Electron do not - // depend on pnpm workspace symlinks surviving prune/package collection. - noExternal: [/.*/], + // SDKs so Docker and Electron do not depend on pnpm workspace symlinks + // surviving prune/package collection. grammY stays external: its native + // runtime succeeds against Telegram here while the bundled copy does not. + noExternal: [/^(?!grammy$|@grammyjs\/auto-retry$).*/], + external: ['grammy', '@grammyjs/auto-retry'], outExtension: () => ({ js: '.cjs' }), esbuildOptions: (options) => { options.conditions = ['openalice-source', ...(options.conditions ?? [])]