import type { ApprovalChoice, Gateway } from '@compass_agents/gateway' import { buildApprovalKeyboard, handleApprovalCallback, makeApprovalIdFactory, } from './approval-keyboard' import { splitMessage } from './chunking' import type { TelegramClient, TgUpdate } from './client' export interface TelegramListenerOpts { client: TelegramClient gateway: Gateway /** Static allowlist of Telegram user ids. Empty → rely on the gateway's pairing. */ allowedUserIds?: number[] /** Server-side long-poll seconds. Default 25. */ pollTimeout?: number /** How long to wait for an inline-keyboard approval before denying. Default 120s. */ approvalTimeoutMs?: number onError?: (err: unknown) => void /** Sleep impl (injectable for tests). */ sleep?: (ms: number) => Promise } const defaultSleep = (ms: number) => new Promise(r => setTimeout(r, ms)) /** * The Telegram surface: long-polls the Bot API, routes each message through the * gateway, and streams the reply back (chunked). Dangerous-tool approvals appear * as inline-keyboard buttons; a button press resolves the pending approval. */ export class TelegramListener { private readonly client: TelegramClient private readonly gateway: Gateway private readonly allowedUserIds: number[] private readonly pollTimeout: number private readonly approvalTimeoutMs: number private readonly onError: (err: unknown) => void private readonly sleep: (ms: number) => Promise private offset = 0 private running = false private readonly pending = new Map void>() private readonly nextApprovalId = makeApprovalIdFactory() /** In-flight message handling, serialized per chat so the poll loop never blocks. */ private readonly inflight = new Map>() constructor(opts: TelegramListenerOpts) { this.client = opts.client this.gateway = opts.gateway this.allowedUserIds = opts.allowedUserIds ?? [] this.pollTimeout = opts.pollTimeout ?? 25 this.approvalTimeoutMs = opts.approvalTimeoutMs ?? 120_000 this.onError = opts.onError ?? (() => {}) this.sleep = opts.sleep ?? defaultSleep } /** Run the long-poll loop until {@link stop}. */ async start(): Promise { this.running = true while (this.running) { let updates: TgUpdate[] try { updates = await this.client.getUpdates(this.offset, this.pollTimeout) } catch (err) { this.onError(err) await this.sleep(1000) continue } for (const u of updates) { this.offset = u.update_id + 1 // Never block the poll loop on a message. A dangerous-tool approval arrives // as a *separate* callback_query update — if we awaited the message handler // (which itself blocks on that approval) the loop could never fetch the button // press, and every dangerous action would dead-time-out into a deny. So we // dispatch off the loop: messages run on a per-chat queue, callbacks resolve // immediately and out-of-band. this.dispatch(u) } } } /** Route an update off the poll loop so approvals can flow back concurrently. */ private dispatch(update: TgUpdate): void { if (update.callback_query) { void this.handleUpdate(update).catch(err => this.onError(err)) return } const chatId = update.message ? String(update.message.chat.id) : 'unknown' const prev = this.inflight.get(chatId) ?? Promise.resolve() const next = prev.then(() => this.handleUpdate(update)).catch(err => this.onError(err)) this.inflight.set( chatId, next.finally(() => { if (this.inflight.get(chatId) === next) this.inflight.delete(chatId) }), ) } stop(): void { this.running = false } /** Process a single update (message or callback). Exposed for tests. */ async handleUpdate(update: TgUpdate): Promise { if (update.callback_query) return this.onCallback(update.callback_query) const msg = update.message if (!msg?.text) return const userId = msg.from?.id if ( this.allowedUserIds.length > 0 && (userId === undefined || !this.allowedUserIds.includes(userId)) ) { return // not on the static allowlist — drop silently } const chatId = String(msg.chat.id) await this.client.sendChatAction(chatId, 'typing').catch(() => {}) const reply = await this.gateway.handleMessage( { surface: 'telegram', chatId, text: msg.text }, { onStart: () => { this.client.sendChatAction(chatId, 'typing').catch(() => {}) }, approve: ask => new Promise(resolve => { const id = this.nextApprovalId() const timer = setTimeout(() => { this.pending.delete(id) resolve('deny') }, this.approvalTimeoutMs) this.pending.set(id, choice => { clearTimeout(timer) resolve(choice) }) this.client .sendMessage(ask.chatId, `🔐 Approve \`${ask.tool}\`?\n${ask.summary}`, { replyMarkup: buildApprovalKeyboard(id), }) .catch(err => { clearTimeout(timer) this.pending.delete(id) this.onError(err) resolve('deny') }) }), }, ) await this.sendReply(chatId, reply) } private async onCallback(cq: TgUpdate['callback_query']): Promise { if (!cq) return const outcome = handleApprovalCallback({ callbackData: cq.data, fromUserId: cq.from.id, allowedUserIds: this.allowedUserIds, pendingApprovals: this.pending, }) const ack: Record = { resolved: cq.data?.split(':')[1] === 'deny' ? 'Denied' : 'Approved', unauthorized: 'Not authorized', 'unknown-approval': 'This request expired', malformed: '', } await this.client.answerCallbackQuery(cq.id, ack[outcome.kind] || undefined).catch(() => {}) } private async sendReply(chatId: string, text: string): Promise { for (const chunk of splitMessage(text)) { await this.client.sendMessage(chatId, chunk).catch(err => this.onError(err)) } } }