/** * ingress.ts, supervises the Telegram INBOUND path. * * Background: registering `POST /webhook/telegram` is not the same as having * inbound Telegram. Telegram pushes nothing until it is told a URL exists * (setWebhook) or is asked for updates (getUpdates). With neither call wired * up, configuring a bot token produced a surface that could send but never * receive, outbound replies worked, so it looked half-alive rather than * broken. This supervisor is the missing half. * * Mode is decided by `surfaces.telegram.mode`, which is an explicit, * operator-visible setting rather than something inferred: * * polling , long-poll getUpdates. Works on a laptop, behind NAT, with no * public hostname and no tunnel. This is the mode most people can * actually run. * webhook , Telegram POSTs to a public HTTPS URL. Lower latency, but it * requires an address Telegram's servers can reach. * * The two are mutually exclusive by construction, not by convention: Telegram * rejects getUpdates with 409 Conflict while a webhook is registered, so * polling deletes any registered webhook before its first poll, and webhook * mode never starts a loop. Exactly one path is armed per start(), and which * one, with the reason, is logged. */ import type { ConfigManager } from '../../config/manager.js'; import type { SecretsManager } from '../../config/secrets.js'; import type { ServiceRegistry } from '../../config/service-registry.js'; import type { SurfaceAdapterContext } from '../../adapters/types.js'; import { type ChannelIngressAlarm } from '../ingress-alarm.js'; import { TelegramBotApi, type TelegramBotIdentity } from './api.js'; export type TelegramIngressMode = 'polling' | 'webhook' | 'inactive'; export interface TelegramIngressStatus { readonly mode: TelegramIngressMode; /** Why this mode, and, when inactive, exactly what to fix. */ readonly reason: string; readonly running: boolean; /** * The most recent inbound message this node could not PROCESS, while the run * of failures lasts. Deliberately not folded into `running`: the loop can be * turning over perfectly while everything it hands on throws, which is the * defect this field exists for. See IngressProcessingHealth. */ readonly lastError?: string | undefined; } export interface TelegramIngressDeps { readonly configManager: ConfigManager; readonly secretsManager: SecretsManager; readonly serviceRegistry: ServiceRegistry; readonly buildSurfaceAdapterContext: () => SurfaceAdapterContext; /** Where the getUpdates cursor lives; surface-scoped by the composition root. */ readonly offsetFilePath: string; /** Test seam: swap in a client with an injected fetch. */ readonly createApi?: ((token: string) => TelegramBotApi) | undefined; /** Where a skipped message reaches the owner; absent still logs and degrades. */ readonly ingressAlarm?: ChannelIngressAlarm | undefined; /** * Telegram told us another process is already long-polling this bot token. * * The cluster coordinator listens on this to stand this node down and re-run * its election. The poll loop reports it and then KEEPS POLLING on a jittered * backoff: standing down permanently is what made inbound Telegram go dead on * a live machine, because with `cluster.enabled` off there is no election to * stand down to, and the competing consumer is frequently transient anyway. */ readonly onConcurrentConsumerConflict?: ((detail: string) => void) | undefined; /** * Where the conflict-retry jitter comes from: a fraction in [0, 1) scaled by * `CONFLICT_JITTER_MS`. Defaults to `Math.random`. * * A seam rather than a hidden `Math.random()` call because a test that has * to wait out an unobservable random delay is not testing recovery, it is * rolling dice: with a five-second spread and a five-second test timeout, * one run in five failed on the draw alone, and every one of those failures * looked exactly like a regression in recovery. */ readonly conflictJitterFraction?: (() => number) | undefined; } /** * Telegram only delivers webhooks to a public HTTPS address. A loopback or * private-range URL is the single most likely misconfiguration (the daemon's * own default base URL is loopback), and silently accepting it produces a * webhook that is registered but never fires, indistinguishable from the bug * this file fixes. Reject it up front with a message naming the fix. */ export declare function describeWebhookUrlProblem(rawUrl: string): string | null; export declare class TelegramIngressSupervisor { private readonly deps; private stopped; private abort; private loop; /** Who this bot is, per getMe, see resolveBotIdentity. */ private botIdentity; private currentStatus; /** Whether this node can PROCESS what it receives, see the class header. */ private readonly processing; constructor(deps: TelegramIngressDeps); /** * The fraction the conflict jitter is drawn from, clamped into [0, 1). * * Clamped rather than trusted: a seam that a caller can set to 5 would turn * a five-second stagger into a twenty-five-second one, and the point of the * jitter is to break lockstep, not to extend an outage. */ private conflictJitterFraction; get status(): TelegramIngressStatus; /** The resolved bot identity, or null when getMe has not succeeded yet. */ get identity(): TelegramBotIdentity | null; /** * Arm exactly one ingress path. Safe to call repeatedly: an already-running * supervisor is stopped first, so a config change re-decides cleanly rather * than layering a second loop on top of the first. */ start(): Promise; /** * Terminate the poll loop promptly and drop any in-flight long-poll. * * The AbortController is released only AFTER the loop has settled. Clearing * it first would strand a loop that was mid-await when stop() ran: its next * backoff would find no signal to listen on and sleep the full interval, * holding shutdown for up to a minute. */ stop(): Promise; private startWebhook; private startPolling; private runPollLoop; /** * Classify a poll failure. Returns a terminal reason when retrying cannot * possibly help, a revoked token or a webhook that will not clear, so the * loop stops with an actionable message instead of backing off forever * against something only the operator can fix. */ private handlePollError; /** * A 409 Conflict, handled so that it can never end the loop. * * ── What went wrong before ──────────────────────────────────────────────── * * A 409 was terminal down both of its branches, and inbound Telegram went * permanently dead on a live machine because of it: polling stopped at * 12:24 and stayed stopped until a human restarted the daemon, with every * message in between unread and nothing but a log line to say so. * * Worse, it died down the WRONG branch. Telegram uses 409 for two unrelated * situations, a registered webhook, and another process long-polling the * same token, and they were told apart by matching the error description * against "terminated by other getUpdates". Anything that did not match that * string fell through to the webhook branch, because `isWebhookConflict` was * defined as "409 and not concurrent". So webhook was the DEFAULT for every * 409 whose description was missing, reworded, or replaced by an * intermediary's own error body. A string that has to be exhaustive to be * safe is not a classification, it is a guess. * * The evidence on that machine settles which case it actually was: no * webhook was ever registered (`getWebhookInfo` reported none, and the logs * contain no `setWebhook` call), and deleteWebhook was called three times * without the 409 ever clearing. A 409 that survives a successful * deleteWebhook is, by construction, not a webhook conflict. * * ── What it does now ────────────────────────────────────────────────────── * * The cause is ESTABLISHED rather than guessed: `getWebhookInfo` is the * authority, because it answers the actual question. The description is used * only to enrich the message a person reads. * * And neither cause is fatal: * * - **A webhook really is registered.** Clear it and retry. If repeated * clears do not take, that is operator-actionable, so it is escalated to * an error that names the fix, and the loop KEEPS RETRYING on the backoff. * A registered webhook can be removed by a person at any moment, and when * it is, polling must resume by itself. * * - **Another consumer holds the token.** Report it, so a cluster * coordinator can stand this node down and re-run its election, then back * off and keep retrying. The other consumer is frequently transient, a * test daemon, a second checkout, a stale process, and standing down * forever means the owner's messages are lost until somebody notices. * With `cluster.enabled` off there is no election to stand down TO, which * is exactly how the live failure became permanent. * * Retrying is bounded and jittered rather than tight, so two consumers do * not spin terminating each other's long poll. */ private handleConflict; /** * Ask Telegram whether a webhook is actually registered. * * This is the authority for classifying a 409, replacing a regex over an * error description that only worked when the description said what we * expected. A failure to answer is reported as "no webhook": the alternative * default sent every unclassifiable conflict down the webhook path, which is * the branch that used to give up. */ private registeredWebhookUrl; /** * A surface that is up but not consuming has to say so where someone will * see it, not only in a log line nobody reads. * * `running` stays true on purpose: the loop is alive and still trying, and * reporting it as stopped would be the same lie in the other direction. * `reason` carries what is wrong and what to do about it, and the level is * `error` because an operator who switched this surface ON and is receiving * nothing has the most expensive failure in the system. */ private markBlocked; /** * Recover from a torn cursor by jumping to the newest update. A negative * offset asks Telegram for the tail of the queue; the newest update is still * processed (it is most likely the message the user is waiting on), and * everything older is confirmed away rather than replayed. */ private skipAhead; /** * Hand each update to the SAME processor the webhook route uses, then return * the confirmation offset. The offset advances past an update only after its * processing returned, so a crash mid-batch replays that batch instead of * losing it. */ private dispatchBatch; /** * Resolve the bot's own identity from its token and cache it in config. * * `surfaces.telegram.botUsername` being blank does NOT mean the bot has no * username, it means nobody typed one in. Telegram's getMe returns the * handle, id and display name for any valid token, so the daemon asks instead * of degrading: without a handle, @mentions in groups are not recognised, * `/goodvibes@thebot` is not stripped correctly, `/start@someotherbot` in a * shared group is answered as if it were ours, and route bindings from two * different bots collide on the literal surfaceId 'telegram'. * * Rules: * - An explicitly configured username WINS. A discovered value never * overwrites an operator's choice; it only fills a blank. * - The discovery is keyed to the token, so rotating the token re-discovers * rather than serving a stale handle. * - A failure never blocks startup. Ingress still arms, receiving messages * matters more than perfect mention matching, but it says at warn level * exactly what will not work until the call succeeds, and the next start() * retries. */ private resolveBotIdentity; private rememberDiscoveredToken; /** * Remove a webhook THIS deployment registered, and only that one. * * Checked against the configured public base URL first, so disabling the * surface never silently tears down a webhook pointing somewhere else, the * same bot token may legitimately be driven by another deployment, and * deleting its registration would break a system we do not own. */ private retractOwnWebhook; /** * The public numeric id of the bot this node can ACTUALLY read, or null when * no token resolves. * * Asked before the node is allowed to contest the Telegram surface in the * LAN election. Winning a surface with no token would starve the machine * that does have one: the loser stands down and the winner reads nothing. * * Only the id half of the token is returned, the secret half is never * returned, logged, or hashed. */ resolveServableBotId(): Promise; /** * Resolve the bot token through the secret-reference path, so config can hold * `goodvibes://secrets/...` rather than a literal token in a settings file. */ private resolveBotToken; private resolveWebhookSecret; private resolveConfigSecret; /** * Sleep that wakes immediately on stop() rather than pinning shutdown. * * The abort listener is removed on the normal timeout path too: backoff can * run many times against one AbortController, and listeners that only unbind * when they fire would pile up on a long-lived signal. */ private delay; private settle; } //# sourceMappingURL=ingress.d.ts.map