Files
goodbuddy/src/main/channels/telegram-channel-driver.ts
T
mesalogo b90ff50f2a fix: reduce supervisor stalls and preserve task delivery
Load Supervisor graphs only when opened, preserve source details on demand, reuse indexed story digests and window selection lists without losing keyboard or focus behavior. Route heavy UI reads through the existing worker, batch scoped queries and construct each publication snapshot once.

Keep remote cancellation responsive during model-route cleanup, remove late routes and retain reported usage after response-cap cancellation. Preserve Telegram pending replies through polling conflicts, show delivery errors and honor the final rate-limit cooldown.

Schema 62 adds lookup indexes without rewriting user content; older clients cannot open upgraded data. Large review publication still blocks Main and remains documented follow-up work. Record the future storage-service direction without performing the architecture migration.

Validation: exact staged code passed isolated full tests (6212 passed, 87 skipped), typecheck and lint. Three-round source performance comparisons, SQLite migration equivalence, native Electron interaction checks and current-source Linux x64 Host validation completed; eight external text-model requests.

Release note: Supervisor opens reviews without loading the entire graph, keeps long selection lists responsive and preserves details and keyboard navigation. Remote cancellation and Telegram reply recovery are more reliable; remote fixes require an Agent build containing this change.
2026-10-05 14:18:33 +08:00

363 lines
16 KiB
TypeScript

import { setTimeout as delay } from 'node:timers/promises'
import type { ChannelInboundText, ChannelResultMessage } from '../../shared/channel-contracts'
import type { ChannelRuntimeStatus } from '../../shared/channel-settings-contracts'
import type { ChannelDriver, ChannelInboundHandler } from './channel-driver'
import { redactChannelError } from './channel-service'
export type TelegramChannelDriverOptions = {
secret: string
allowedSenderIds: readonly string[]
fetch?: typeof globalThis.fetch
onStatus?: (status: ChannelRuntimeStatus) => void
}
type BotIdentity = { botId: string; botUsername?: string }
type TelegramUpdate = {
update_id: number
message?: {
message_id: number
date: number
from?: { id: number; is_bot?: boolean }
chat: { id: number; type: string }
text?: string
message_thread_id?: number
is_topic_message?: boolean
[key: string]: unknown
}
}
export class TelegramError extends Error {
constructor(readonly code: number, readonly retryAfter = 1) {
super(code === 401 ? 'Telegram Token is invalid. Replace the Bot Token.'
: code === 409 ? 'Telegram polling conflict. Stop the other poller or remove the existing webhook.'
: code === 403 ? 'Telegram access forbidden. The Bot may be blocked or disabled.'
: code === 429 ? 'Telegram rate limit. Waiting before retrying.'
: code === 0 ? 'Telegram network request failed. Check the network and OS proxy.'
: `Telegram request failed (HTTP ${code}).`)
}
get permanent(): boolean {
return this.code >= 400 && this.code < 500 && this.code !== 429
}
get terminal(): boolean {
return this.permanent
}
}
function cancelled(): Error {
const error = new Error('Telegram operation cancelled.')
error.name = 'AbortError'
return error
}
function checkSignal(signal: AbortSignal): void {
if (signal.aborted) throw cancelled()
}
async function wait(ms: number, signal: AbortSignal): Promise<void> {
try {
await delay(ms, undefined, { signal })
} catch {
throw cancelled()
}
}
function positiveId(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0
}
export class TelegramChannelDriver implements ChannelDriver {
readonly channel = 'telegram'
private allowed: ReadonlySet<string> = new Set()
private readonly fetchImpl: typeof globalThis.fetch
private lifecycle = new AbortController()
private readonly operations = new Set<Promise<unknown>>()
private readonly feedback = new Map<string, number>()
private readonly progress = new Map<string, { offset: number; terminal?: TelegramError }>()
private identity?: BotIdentity
private terminalFailure?: TelegramError
private readonly readinessWaiters = new Set<() => void>()
private offset = 0
private pollPromise?: Promise<void>
private stopPromise?: Promise<void>
private lastStatus?: string
constructor(private readonly options: TelegramChannelDriverOptions) {
if (!options.secret.trim() || /[\s/?#]/u.test(options.secret)) {
throw new Error('Telegram Bot Token is invalid.')
}
this.updateAllowedSenderIds(options.allowedSenderIds)
this.fetchImpl = options.fetch ?? (async (input, init) => {
const { net } = await import('electron')
return net.fetch(input instanceof Request ? input.url : String(input), init)
})
}
testConnection(): Promise<BotIdentity> {
return this.track((async () => {
const identity = await this.identify(this.lifecycle.signal)
await this.checkWebhook(this.lifecycle.signal)
return identity
})())
}
updateAllowedSenderIds(ids: readonly string[]): void {
if (ids.some((id) => !/^[1-9]\d*$/u.test(id))) {
throw new Error('Telegram whitelist must contain numeric user IDs.')
}
this.allowed = new Set(ids)
}
start(handler: ChannelInboundHandler): Promise<void> {
if (this.stopPromise) return Promise.reject(new Error('Telegram driver has stopped. Create a new driver to restart.'))
if (this.pollPromise) return Promise.resolve()
this.status({ state: 'starting' })
this.pollPromise = this.poll(handler, this.lifecycle.signal)
return Promise.resolve()
}
stop(): Promise<void> {
if (this.stopPromise) return this.stopPromise
this.lifecycle.abort()
this.stopPromise = (async () => {
await Promise.allSettled([...this.operations, this.pollPromise])
this.feedback.clear()
this.progress.clear()
this.status({ state: 'stopped' })
})()
return this.stopPromise
}
send(message: ChannelResultMessage, signal: AbortSignal): Promise<void> {
return this.track(this.sendResult(message, AbortSignal.any([signal, this.lifecycle.signal])))
}
private async sendResult(message: ChannelResultMessage, signal: AbortSignal): Promise<void> {
checkSignal(signal)
await this.waitForIdentity(signal)
const match = /^([1-9]\d*):([1-9]\d*)$/u.exec(message.conversationId)
if (!this.identity || message.channel !== this.channel || !match ||
match[1] !== this.identity.botId || match[2] !== message.recipientId) {
throw Object.assign(new Error('Telegram reply belongs to another Bot or has an invalid private conversation.'), { permanent: true, terminal: true })
}
const text = this.redact([
message.output?.trim(), message.error?.trim(),
message.attachments?.length ? 'Attachments are available in GoodBuddy on the desktop; no attachments were sent to Telegram.' : undefined
].filter(Boolean).join('\n\n') || 'Request completed.')
const key = `${message.conversationId}:${message.eventId}:${message.status}`
let record = this.progress.get(key)
if (!record) {
record = { offset: 0 }
this.progress.set(key, record)
if (this.progress.size > 128) this.progress.delete(this.progress.keys().next().value!)
}
if (record.terminal) throw record.terminal
while (record.offset < text.length) {
let end = Math.min(record.offset + 4096, text.length)
if (end < text.length && /[\uD800-\uDBFF]/u.test(text.charAt(end - 1)) && /[\uDC00-\uDFFF]/u.test(text.charAt(end))) end -= 1
try {
await this.request('sendMessage', { chat_id: match[2], text: text.slice(record.offset, end) }, signal, true)
record.offset = end
} catch (error) {
if (error instanceof TelegramError && error.permanent) record.terminal = error
throw error
}
}
this.progress.delete(key)
}
private async waitForIdentity(signal: AbortSignal): Promise<void> {
checkSignal(signal)
if (!this.identity && !this.terminalFailure) {
await new Promise<void>((resolve, reject) => {
const finish = () => {
this.readinessWaiters.delete(finish)
signal.removeEventListener('abort', finish)
if (signal.aborted) reject(cancelled())
else resolve()
}
this.readinessWaiters.add(finish)
signal.addEventListener('abort', finish, { once: true })
})
}
checkSignal(signal)
// A polling conflict does not prevent an identified Bot from sending replies.
if (this.terminalFailure && !(this.identity && this.terminalFailure.code === 409)) {
throw this.terminalFailure
}
}
private async identify(signal: AbortSignal): Promise<BotIdentity> {
const me = await this.request<{ id: number; is_bot: boolean; username?: string }>('getMe', {}, signal)
if (!positiveId(me?.id) || me.is_bot !== true) throw new Error('Telegram returned an invalid Bot identity.')
return { botId: String(me.id), ...(me.username ? { botUsername: me.username } : {}) }
}
private async checkWebhook(signal: AbortSignal): Promise<void> {
const webhook = await this.request<{ url: string }>('getWebhookInfo', {}, signal)
if (typeof webhook?.url !== 'string') throw new Error('Telegram returned invalid webhook information.')
if (webhook.url) throw new TelegramError(409)
}
private async poll(handler: ChannelInboundHandler, signal: AbortSignal): Promise<void> {
let failures = 0
let pollingReady = false
while (!signal.aborted) {
const started = Date.now()
try {
if (!this.identity) {
// Outgoing replies need the Bot identity, not permission to long-poll.
this.identity = await this.identify(signal)
checkSignal(signal)
}
if (!pollingReady) {
await this.checkWebhook(signal)
pollingReady = true
for (const notify of this.readinessWaiters) notify()
failures = 0
this.status({ state: 'running' })
}
const updates = await this.request<TelegramUpdate[]>('getUpdates', {
offset: this.offset, timeout: 30, limit: 100, allowed_updates: ['message']
}, signal)
if (!Array.isArray(updates)) throw new TelegramError(0)
for (const update of updates) {
checkSignal(signal)
if (!Number.isSafeInteger(update.update_id) || update.update_id < this.offset) continue
let acknowledged = false
await this.receive(update, handler, () => { acknowledged = true }, signal)
checkSignal(signal)
if (!acknowledged) throw new Error('Telegram inbound message was not acknowledged.')
this.offset = update.update_id + 1
}
failures = 0
this.status({ state: 'running' })
// A server/proxy returning immediately must not turn long polling into a busy loop.
await wait(Math.max(0, 250 - (Date.now() - started)), signal)
} catch (error) {
if (signal.aborted) break
const safe = this.safeError(error)
if (safe instanceof TelegramError && safe.permanent) {
this.terminalFailure = safe
for (const notify of this.readinessWaiters) notify()
this.status({ state: 'error', lastError: safe.message })
break
}
this.status({ state: 'starting', lastError: `${safe.message} Reconnecting.` })
const pause = safe instanceof TelegramError && safe.code === 429
? safe.retryAfter * 1000 : Math.min(30_000, 1000 * 2 ** Math.min(failures++, 5))
try { await wait(pause, signal) } catch { break }
}
}
}
private async receive(update: TelegramUpdate, handler: ChannelInboundHandler, acknowledge: () => void, signal: AbortSignal): Promise<void> {
const message = update.message
if (!message || message.chat?.type !== 'private' || !positiveId(message.chat.id) ||
!positiveId(message.from?.id) || message.from.is_bot !== false ||
message.message_thread_id !== undefined || message.is_topic_message) {
acknowledge()
return
}
const senderId = String(message.from.id)
const media = ['caption', 'photo', 'video', 'animation', 'audio', 'voice', 'document', 'sticker', 'video_note', 'contact', 'location', 'venue', 'poll', 'dice', 'paid_media'].some((key) => message[key] !== undefined)
const text = typeof message.text === 'string' && !media ? message.text.trim() : ''
const command = /^\/(start|whoami)(?:@([^\s]+))?(?:\s|$)/u.exec(text)
const help = command && (!command[2] || command[2].toLowerCase() === this.identity?.botUsername?.toLowerCase())
if (help || (media && this.allowed.has(senderId))) {
const now = Date.now()
const feedbackKey = `${senderId}:${help ? command[1] : 'media'}`
if (now - (this.feedback.get(feedbackKey) ?? -Infinity) >= 5000) {
this.feedback.delete(feedbackKey)
this.feedback.set(feedbackKey, now)
if (this.feedback.size > 128) this.feedback.delete(this.feedback.keys().next().value!)
const reply = help ? command[1] === 'whoami' ? senderId
: 'GoodBuddy accepts private text requests from authorized users. Send /whoami and add your numeric ID to the desktop whitelist. Offline messages retained by Telegram may run when reconnected. After a crash, check desktop task records and results before resending; tasks do not automatically resume.'
: 'Only text messages are supported. Please describe your request in text.'
try {
await this.request('sendMessage', { chat_id: String(message.chat.id), text: reply }, signal)
} catch (error) {
if (signal.aborted) throw error
this.status({ state: 'running', lastError: this.safeError(error).message })
}
}
acknowledge()
return
}
if (!text || !this.allowed.has(senderId) || !Number.isSafeInteger(message.date) || message.date < 0) {
acknowledge()
return
}
const inbound: ChannelInboundText = {
channel: this.channel, accountId: this.identity!.botId,
eventId: String(update.update_id), senderId,
conversationId: `${this.identity!.botId}:${message.chat.id}`,
conversationType: 'direct', text, mentioned: false, receivedAt: message.date * 1000
}
await handler(inbound, acknowledge)
checkSignal(signal)
// Help commands have separate cooldowns so /start does not suppress /whoami.
const typingKey = `${senderId}:typing`
if (Date.now() - (this.feedback.get(typingKey) ?? -Infinity) >= 5000) {
this.feedback.delete(typingKey)
this.feedback.set(typingKey, Date.now())
if (this.feedback.size > 128) this.feedback.delete(this.feedback.keys().next().value!)
try { await this.request('sendChatAction', { chat_id: String(message.chat.id), action: 'typing' }, signal) } catch { /* Feedback must not reject an already queued request. */ }
}
}
private async request<T = unknown>(method: string, body: object, signal: AbortSignal, retryRateLimit = false): Promise<T> {
for (let attempt = 0; ; attempt += 1) {
checkSignal(signal)
const timeout = new AbortController()
const timer = setTimeout(() => timeout.abort(), method === 'getUpdates' ? 40_000 : 15_000)
let failure: TelegramError
try {
const response = await this.fetchImpl(`https://api.telegram.org/bot${this.options.secret}/${method}`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
redirect: 'error', signal: AbortSignal.any([signal, timeout.signal])
})
const data = await response.json().catch(() => ({})) as { ok?: boolean; result?: T; error_code?: number; parameters?: { retry_after?: number } }
checkSignal(signal)
if (response.ok && data.ok === true) return data.result as T
const code = response.ok ? (Number.isSafeInteger(data.error_code) ? data.error_code! : 0) : response.status
const seconds = data.parameters?.retry_after
failure = new TelegramError(code, typeof seconds === 'number' && Number.isFinite(seconds) ? Math.max(1, Math.min(seconds, 86400)) : 1)
} catch (error) {
if (signal.aborted) throw cancelled()
failure = error instanceof TelegramError ? error : new TelegramError(0)
} finally {
clearTimeout(timer)
}
if (failure.code !== 429 || !retryRateLimit) throw failure
// The outbox may retry as soon as this call rejects, including the final attempt.
await wait(failure.retryAfter * 1000, signal)
if (attempt >= 3) throw failure
}
}
private redact(value: string): string {
return redactChannelError(value
.replace(/https?:\/\/api\.telegram\.org\/[^\s"'<>]+/giu, '[Telegram URL hidden]')
.split(this.options.secret).join('[Token hidden]'))
}
private safeError(error: unknown): Error {
return error instanceof TelegramError ? error : new Error('Telegram operation failed. Check the connection and inbound queue.')
}
private status(status: ChannelRuntimeStatus): void {
const key = JSON.stringify(status)
if (key === this.lastStatus) return
this.lastStatus = key
this.options.onStatus?.(status)
}
private track<T>(promise: Promise<T>): Promise<T> {
this.operations.add(promise)
void promise.finally(() => this.operations.delete(promise)).catch(() => undefined)
return promise
}
}