src/telegram/registration.ts
Rama main · réplica de lectura del repositorio original.
/**
* Keeps the Telegram webhook pointed at this deployment, without anyone running a
* setup script.
*
* The old flow was three manual steps -- invent a secret, store it as a worker
* secret, run `pnpm setup:telegram --apply` -- and skipping any one of them left a
* bridge that looked configured and delivered nothing. Every input those steps
* needed is knowable at runtime: the secret is derived from the bot token, and the
* URL is the origin the worker is already being served on. So the cron reconciles
* it hourly, the same way it reconciles stale sends: compare what Telegram believes
* against what is true, and fix the difference.
*
* Comparing the URL alone was not enough. A webhook registered with a *stale
* secret* points at exactly the right URL, so the cron reported ok forever while
* every update was answered 401 -- the silent death this whole file exists to
* prevent. But our own record is not enough either: it cannot see a third party
* re-registering the bot elsewhere, nor Access blocking the route at the edge.
* So reconciliation combines what we recorded with what Telegram observes, and
* where they disagree the observation wins.
*/
import {
getRuntimeConfig,
getTelegramWebhookRegistration,
RUNTIME_CONFIG_KEYS,
setTelegramWebhookObservation,
setTelegramWebhookRegistration,
type TelegramWebhookObservation,
type TelegramWebhookRegistration,
} from "../db/runtime-config";
import { insertOpsEvent } from "../db/d1";
import {
deriveWebhookSecret,
deriveWebhookSecretFingerprint,
getWebhookInfo,
readTelegramConfig,
setWebhook,
TELEGRAM_ALLOWED_UPDATES,
} from "./api";
import { reconcileTelegramCommands } from "./commands";
import { ensureOwnerPairingCode } from "./operators";
/** Why the cron decided the live registration was not the one we want. */
export type WebhookRegistrationCause =
/** Telegram is delivering somewhere else entirely. */
| "url_drift"
/** No record of us ever registering -- includes every deployment that predates this record. */
| "registration_unrecorded"
/** The bot token changed, so the secret derived from it did too. */
| "secret_rotated"
/** Telegram is failing to deliver, and the failures are newer than our registration. */
| "delivery_errors"
/**
* The live subscription is missing an update kind this build acts on.
*
* Its own cause because nothing else can see it: allowed_updates is not part of
* the secret, so a deploy that starts handling a new kind of update leaves every
* existing deployment subscribed to the old list, pointed at the right URL, with
* the right secret, reporting ok forever while Telegram simply never sends the
* updates. Exactly the silent death this file exists to prevent, one field over.
*/
| "allowed_updates_drift";
export type WebhookReconciliation =
| { status: "skipped"; reason: "bridge_disabled" | "origin_unknown" }
| { status: "ok"; url: string }
| {
status: "registered";
url: string;
previousUrl: string;
cause: WebhookRegistrationCause;
}
| { status: "failed"; error: string };
export function telegramWebhookUrl(origin: string): string {
return `${origin.replace(/\/$/, "")}/telegram/webhook`;
}
/**
* Telegram stamps last_error_date with its own clock, we stamp registeredAt with
* ours. A couple of minutes of tolerance keeps a registration from being judged
* against an error that merely looks newer.
*/
const CLOCK_SKEW_MS = 120_000;
/**
* True when delivery failures are newer than the registration they would condemn.
*
* Only the *date* is ever compared. The message is stored and displayed, never
* branched on: "Wrong response from the webhook: 401 Unauthorized" is prose
* Telegram owns and can reword, and a bridge whose self-healing depends on that
* wording breaks the day it changes -- silently, which is the failure mode this
* whole mechanism exists to remove.
*
* The comparison also survives either behaviour of setWebhook, documented or not:
* if Telegram clears the error, there is nothing to compare; if it keeps it, the
* stale date falls behind the new registeredAt and the state returns to normal on
* its own. Only errors that keep happening keep the date moving forward.
*/
export function hasDeliveryErrorSinceRegistration(
registration: TelegramWebhookRegistration,
lastErrorAt: string | null,
): boolean {
if (!lastErrorAt) return false;
const errorMs = Date.parse(lastErrorAt);
if (!Number.isFinite(errorMs)) return false;
return errorMs > Date.parse(registration.registeredAt) + CLOCK_SKEW_MS;
}
/**
* True when the live subscription cannot deliver something this build handles.
*
* An absent list is Telegram's default, which is a superset of everything we ask
* for -- so absence is agreement, not drift, and treating it as drift would
* re-register on every single pass.
*/
export function hasAllowedUpdatesDrift(observed: string[] | undefined): boolean {
if (!observed) return false;
const live = new Set(observed);
return TELEGRAM_ALLOWED_UPDATES.some((update) => !live.has(update));
}
/** Null means the live registration is the one we want; leave it alone. */
function registrationCause(input: {
observedUrl: string;
wantedUrl: string;
registration: TelegramWebhookRegistration | null;
fingerprint: string;
lastErrorAt: string | null;
allowedUpdates: string[] | undefined;
}): WebhookRegistrationCause | null {
if (input.observedUrl !== input.wantedUrl) return "url_drift";
if (hasAllowedUpdatesDrift(input.allowedUpdates)) return "allowed_updates_drift";
// No record at all is the blind spot itself: every deployment registered before
// this record existed lands here once, and heals.
if (!input.registration) return "registration_unrecorded";
if (input.registration.fingerprint !== input.fingerprint) return "secret_rotated";
if (hasDeliveryErrorSinceRegistration(input.registration, input.lastErrorAt)) {
return "delivery_errors";
}
return null;
}
/**
* Never throws: this runs inside the cron sweep alongside backups and send
* reconciliation, and a Telegram outage must not abort the rest of it.
*/
export async function reconcileTelegramWebhook(env: Env): Promise<WebhookReconciliation> {
const config = readTelegramConfig(env);
if (!config) {
return { status: "skipped", reason: "bridge_disabled" };
}
// Before the origin check, and before anything that can bail out: a deployment
// with no operator is a deployment whose only way to get one is a /start the
// webhook has to be alive to receive, and the code that /start carries has to
// exist by the time the operator goes looking for it.
await ensureOwnerPairingCode(env);
// Reconciled from inside this function rather than beside it in the cron, and
// deliberately before the origin check: the "/" menu is state living in
// Telegram exactly like the webhook is, it does not depend on knowing our own
// hostname, and the cron's entry point is not ours to add a second call to.
// Best effort — a menu that failed to update must not stop the webhook that
// carries the mail from being repaired.
const commands = await reconcileTelegramCommands(env);
if (commands.status === "registered" || commands.status === "failed") {
await insertOpsEvent(env.INDEX_DB, {
id: crypto.randomUUID(),
event_type:
commands.status === "registered"
? "telegram.commands_registered"
: "telegram.commands_registration_failed",
severity: commands.status === "registered" ? "info" : "warning",
subject: "telegram",
payload_json: JSON.stringify(commands),
}).catch(() => undefined);
}
const origin = await getRuntimeConfig(env.INDEX_DB, RUNTIME_CONFIG_KEYS.deploymentOrigin);
if (!origin) {
// Nobody has loaded the authenticated UI yet, so the worker genuinely does
// not know its own public hostname. Registering against a guess would be
// worse than waiting.
return { status: "skipped", reason: "origin_unknown" };
}
const url = telegramWebhookUrl(origin);
try {
const info = await getWebhookInfo(config);
const lastErrorAt =
typeof info.last_error_date === "number" && Number.isFinite(info.last_error_date)
? new Date(info.last_error_date * 1000).toISOString()
: null;
const observation: TelegramWebhookObservation = {
url: info.url,
lastErrorAt,
lastErrorMessage: info.last_error_message ?? null,
pendingUpdateCount: info.pending_update_count ?? 0,
observedAt: new Date().toISOString(),
};
// Written on every pass, healthy ones included: /api/health answers from this
// row precisely so it never has to wait on api.telegram.org.
await setTelegramWebhookObservation(env.INDEX_DB, observation);
const [registration, fingerprint] = await Promise.all([
getTelegramWebhookRegistration(env.INDEX_DB),
deriveWebhookSecretFingerprint(config.botToken),
]);
const cause = registrationCause({
observedUrl: info.url,
wantedUrl: url,
registration,
fingerprint,
lastErrorAt,
allowedUpdates: info.allowed_updates,
});
if (!cause) {
return { status: "ok", url };
}
// Order matters: register first, record second. Recording first and then
// failing the setWebhook would write a claim that was never true, and the
// next pass would trust it -- recreating the blind spot with our own hands.
// Failing the other way round is harmless: setWebhook is idempotent, so a D1
// write that dies here just means the next pass re-registers.
await setWebhook(config, { url, secret: await deriveWebhookSecret(config.botToken) });
await setTelegramWebhookRegistration(env.INDEX_DB, {
fingerprint,
registeredAt: new Date().toISOString(),
url,
});
return { status: "registered", url, previousUrl: info.url, cause };
} catch (error) {
return { status: "failed", error: error instanceof Error ? error.message : String(error) };
}
}