Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions services/connector/src/adapters/telegram.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void>(() => 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')
})
})
46 changes: 40 additions & 6 deletions services/connector/src/adapters/telegram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) {
Expand All @@ -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) => {
Expand Down Expand Up @@ -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<T>(operation: () => Promise<T>, timeoutMs: number, message: string): Promise<T> {
let timer: NodeJS.Timeout | undefined
try {
return await Promise.race([
operation(),
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs)
timer.unref?.()
}),
])
} finally {
if (timer) clearTimeout(timer)
}
}
32 changes: 32 additions & 0 deletions services/connector/src/core/delivery-manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
6 changes: 5 additions & 1 deletion services/connector/src/core/delivery-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 6 additions & 1 deletion services/connector/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ async function main(): Promise<void> {
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()))
Expand Down Expand Up @@ -75,6 +74,12 @@ async function main(): Promise<void> {
}
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) => {
Expand Down
8 changes: 5 additions & 3 deletions services/connector/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? [])]
Expand Down