Skip to main content

Personal Pages

When an escalation chain pages you, it sends a Telegram direct message — a private chat between you and the bot, not a room. That message is a page: it exists to wake one person and tell them what broke.

This page describes what happens to that DM after it is sent. The short version: it is now edited in place when the alert is acknowledged, silenced or resolved — by any route, including one that never touched Telegram.

The bug this closes

Before this, a personal page was written and forgotten. An operator could acknowledge an alert by pressing 2 on a phone keypad and the DM that woke them would still say firing — for as long as it sat in their chat. Every other Telegram message Console sends was already reconciled; the one sent to a person was the one that was not.

A page is not a card​

Console tracks three kinds of Telegram message against an alert group, and they are three because they answer three different questions.

Internal cardClient-facing messagePersonal page
Goes toa responder group chata customer group chatone person's private chat
Audience (telegram_message.audience)internalclientdirect
Sent bythe notification worker, when paging armsthe notification workerthe notification chain, step by step
Showsthe full card — scope, analysis, timeline, raw payloadone curated sentencethe alert, then one line saying what became of it
Controlsper stateneverthe chain's Ack buttons, until it is answered
Reconciled againsta telegram_group_bindinga telegram_group_bindingnothing — see below

A card is a shared surface a room reads for as long as the incident lasts, so it keeps its timeline and grows as the incident does. A page answered its question the moment it arrived. What is owed to the person it woke is the outcome, so that is all an edited page shows:

✅ Acknowledged by Aziz Karimov · 14:22 UTC
🟢 Resolved · 14:31 UTC
🔇 Silenced by Aziz Karimov · until 09:00 UTC
🔴 Firing · since 14:05 UTC

The last one is the un-answer: an acknowledgement the "still on it?" loop withdrew, or a resolved alert that reopened. The page is edited back because it is now claiming an outcome that has been undone.

It never says "by you"​

A chain pages several people in turn, and the one who acknowledges is routinely not the one reading a given DM. So a page names the person who acted, or names nobody at all — an unknown actor drops the clause rather than guessing. A firing page names nobody by design: acknowledged_by is never cleared, so on a re-page it would name the person who stopped responding.

The buttons go with the text​

An edit replaces the message's inline keyboard along with its text, and the edited page carries no keyboard. That is not cosmetic: a page that has already been acknowledged must not still offer Ack.

How a DM is found​

A card is found through its chat's binding: the reconciler walks the client's bound chats and edits the tracked message in each. A private chat is bound to nothing and never will be — the binding table maps a chat to a customer, and a person is not a customer.

So a personal page is found from the tracked row itself. telegram_message holds one row per (alert group, chat) with the message id to edit; for a DM, that row is the whole authority. The reconciler's sweep gained a third arm that joins no binding at all, and the syncer gained a second pass that asks for a group's direct rows directly.

Why this is a separate arm, not a wider join

The obvious shortcut — make the existing binding join a LEFT JOIN, so rows with no binding come through — is wrong twice over. It would let an internal card in an unbound chat reconcile: selected on every sweep forever, and never edited, because the syncer only ever visits bound chats. And it would let a DM match an unrelated binding that happened to share its chat id. The two arms stay separate, and a test asserts that an internal card with no binding is still not selected.

When a page is edited​

Personal pages ride the same machinery as cards, so everything documented under what updates a card applies unchanged:

  • every human state change and every worker-driven one asks for a sync, and a group is synced whole — its cards and its pages together;
  • the card reconciler is the floor under all of it: every 30 seconds it lists the groups whose tracked messages disagree with their status, and a stale DM is now one of them;
  • an unchanged state costs no Telegram call, requests are coalesced, a 429 is retried with backoff, and a permanent refusal is remembered rather than repeated.

Two differences are specific to pages.

A page is never force-edited. A forced sync exists for a change the alert's state does not describe — the ⚠️ STILL FIRING line a refused resolve puts on a card. A page's one-line status is a pure function of that state, so forcing one could only ever re-send the same text — with one exception, and the exception is harmful: a page that has not been edited yet still holds the chain's full alert text and its Ack button, and a forced run would replace both while the alert is still firing and still needs acknowledging.

A fresh page is not stale. telegram_message.state starts at investigating, and for a personal page that is read as the page as sent, nothing has happened yet. The chain fences every page on the group still being firing, checked immediately before the send, so a DM can only be posted for a firing alert — which is exactly what the default describes. Had a firing alert been recorded as firing (the card's vocabulary), every page would have been stale the instant it was posted, earning one redundant edit each and crowding the sweep's shared budget of 200 groups.

Unlike the client vocabulary, which folds acknowledged and silenced together into identified, a page keeps them apart. The reader is a responder, for whom "someone has taken it" and "paging is stopped" are two different facts.

Limits​

  • An untracked page is never edited. The chain records a DM only when the channel is Telegram and the send succeeded; a voice page is a phone call with no message to edit, and a failed send has no message either. Tracking is best-effort by contract — the page has already gone out by the time it runs — so a database blip costs a stale DM, never a failed page.
  • The reconciler's budget is shared. One sweep returns at most 200 groups across all three arms, most recently changed first. A group whose edit Telegram refuses for good ages toward the back, where the limit cuts it.
  • Editing is subject to Telegram's rules. If the person has blocked the bot, or deleted the chat, the edit fails permanently and is not retried — see when edits fail.

Which bot pages you​

Console runs two Telegram bots, and everything on this page turns on telling them apart.

BotTokenWhat it does
Ops bot (@proximaops2bot)PROXIMA_OPS_TELEGRAM_BOT_TOKENPosts alert cards, takes Ack/Resolve taps — and sends every per-user page. The notification chain's Telegram channel is built from this token.
Service Desk bot (@proximaservicedeskbot)PROXIMA_TELEGRAM_BOT_TOKENThe user-facing mini app: tickets, Telegram login, and account linking. It never pages anyone.

A Telegram bot may only message someone who has already started a private chat with that bot. So the private chat that has to be proven is the one with the ops bot, and the whole verify flow — the deep link (PROXIMA_OPS_TELEGRAM_BOT_USERNAME), the /start handler (in telegram/opsbot) and the redeem pin (PROXIMA_OPS_BOT_SA_ID) — names that bot on purpose.

Pointing any part of it at the Service Desk bot would produce a verification that is worse than none: a responder opens the link, taps Start, earns telegram_verified: true, and is still undeliverable by the bot that actually pages them — a false green with a badge on it. Under enforce it would be inverted twice over, un-paging reachable people and keeping unreachable ones.

Linked is not the same as reachable​

A Console user with a Telegram account linked is not necessarily someone the ops bot can send a DM to. Linking proves identity: traits.telegram_id is written only by a bot's own service account, from an id Telegram itself signed, so the account on the other end is genuinely theirs. It proves nothing about deliverability — a Telegram bot cannot open a conversation, so it can only DM a person who has already started a private chat with it, and linking happens through the Service Desk bot, which says nothing about whether the ops bot has ever been opened. A page addressed to that account is accepted by the API and goes nowhere.

The on-call profile therefore carries two more fields alongside the paging phone and its verification stamp:

FieldMeaning
telegram_chat_idThe private chat proven to exist. For a private chat this equals the person's Telegram id, so it is directly comparable with traits.telegram_id.
telegram_verified_atNULL = unverified. Non-NULL records when that private chat was proven.

A verification never outlives the identity it proves. Changing the stored Telegram identity resets telegram_verified_at to NULL — the same rule the paging phone has always followed, where editing the number drops its verification. Telegram needs it for a sharper reason: the paging target is read from traits.telegram_id, which the link flow rewrites on its own, so without the reset someone who re-links a different Telegram account would inherit the previous account's proof. Re-setting the same id keeps the stamp; unlinking clears both.

The paging resolver now reads both fields — see the verified gate below for exactly what it does with them.

Proving the private chat​

The proof has two halves, and they are deliberately not the same request. Console issues a link; Telegram delivers the proof.

POST /api/v1/telegram/verify/start mints a single-use token and returns the link to open:

{
"data": {
"deep_link": "https://t.me/<ops-bot>?start=vfy_XCtT…",
"expires_in": 900
}
}

Opening that link starts a private chat with the ops bot — the bot that sends the pages — and hands it the token as the start payload. That /start is the whole point: it can only arrive from a private chat that now exists, which is exactly the fact a telegram_id could never establish. Console cannot manufacture it, and neither can the mini app — only the person, on their own device, tapping the link.

The token is a bearer credential for someone's paging destination. Whoever redeems it becomes the chat that gets paged, so it is treated accordingly: 32 bytes of crypto/rand, returned in the response body exactly once, and never written to a log line. If a link is lost, issue another one rather than going looking for the old one.

PropertyValueWhy
Lifetime15 minutesThis is a live step at a keyboard, not a link riding an email. The mini-app link code's 24h exists because an onboarding mail may be read tomorrow; a verify link is opened by the next tap.
Prefixvfy_The marker the ops bot's /start branches on. A payload without it is never offered to the verify endpoint — and the two kinds of token must never be interchangeable: a link code binds an account (and is redeemed with /link, on either bot), a verify token proves reachability.
Storetelegram_verify:<token> in ValkeyA separate keyspace from telegram_link: for the same reason.
ReuseSingle-useConsumed on redemption; the TTL is only the backstop for a link nobody opens.

Who may issue one​

Self-service, with one exception: a user issues their own token, and only a super admin may issue one for somebody else. This is narrower than the rest of the paging-configuration surface, where an admin sharing a client may edit a colleague's phone — because a token bound to another person is a paging-redirect primitive. Redeem it and every page meant for them lands in your chat instead, and because ack is attributed to the paged contact, you can then answer their alerts as them. Holding users:write is not enough here.

The gate lives in the handler, not in route middleware. A users:write middleware would have locked ordinary users out of verifying their own Telegram, which is the entire purpose of the endpoint.

A super admin issuing for someone else is audited. The token binds whichever Telegram account redeems it to the target's Console account, so an admin could issue a link for a colleague, open it themselves, and become that colleague's verified paging contact — receiving their pages from then on. Silent and account-takeover-shaped, so it leaves a telegram_verify_issue audit entry naming the actor and the target, alongside telegram_link / telegram_unlink / telegram_login. The entry carries no part of the token: audit rows are exported as evidence and kept far longer than the fifteen minutes the token is worth anything. Issuing for yourself has no second party and records nothing — auditing it would bury the entries that matter.

From the Console profile​

The link no longer has to be issued with curl. Profile → On-call contact carries a Telegram paging row beside the phone, and the same row renders on a user's admin detail page:

  • The badge reads Verified or Not verified straight from telegram_verified as the API reports it. It is never re-derived in the browser — the backend computes that field from the paging resolver's own predicate, so a stamp that no longer matches the user's linked identity reads Not verified here exactly as it does in the resolver. A UI that inferred it from "they have a Telegram id" would look right in every screenshot and lie in the one case this feature exists to expose. telegram_linked is deliberately not shown beside it: it can read true while verification reads false, and the honest pair reads as a bug.
  • Verify Telegram issues the link and shows it as a QR code plus the URL itself, with a copy button and the fifteen-minute countdown. The token appears in the deep link and nowhere else — not in a toast, an error, or the Console page's own URL.
  • While the dialog is open the page polls the profile, so the badge flips and the dialog closes by itself the moment the /start lands. An expired link offers to issue another rather than leaving a dead QR on screen.
  • The button follows the endpoint's own gate — self, or a super admin — rather than the wider users:write that governs the phone field. An admin who could only ever receive a 403 is not offered the action.

When it refuses​

If PROXIMA_OPS_TELEGRAM_BOT_USERNAME is unset, or the token store is unreachable, the endpoint answers 503 and names the missing configuration — it does not return a link. Issuing a token nobody can redeem is worse than refusing: the person follows a dead link, believes they are verified, and finds out otherwise during an incident.

Redeeming: the one check the feature exists for​

The ops bot handles the /start (telegram/opsbot/internal/handler) and calls POST /api/v1/telegram/verify with {code, telegram_id, chat_id}. Before it calls anything, it asserts the message arrived in a private chat.

The command has to be registered on that bot, which is load-bearing rather than incidental: telebot delivers an unregistered command to the group listener, where a leading / is a hard trigger and the Investigator would answer the deep link with a 🤖 reply while verifying nobody. cmd/opsbot/wiring_test.go pins the registration for exactly that reason.

That single assertion is the feature. Take it away and this is the existing link flow wearing a different name: /start vfy_… typed in a group would mark somebody "verified" that the ops bot can never DM — the exact failure the whole phase exists to remove, now carrying a green badge. The token is not the proof. The token only says who; the proof is that the /start arrived in a private chat, because following the deep link is what creates that chat.

The comparison is exact equality against private, never a substring or a "not a group" test: Telegram's chat types include privatechannel, which contains the word and is not a private chat with the bot. Every non-private type is refused with a reply telling the person to open a private chat, and nothing is sent to Console.

What the backend checks​

CheckRefusalWhy
Caller is the ops bot's specific service account (PROXIMA_OPS_BOT_SA_ID)403The endpoint's whole value is that its caller saw a Telegram-signed update from a private chat. Anyone else is merely asserting it. Trusting any service account is not enough — every SA could then mark any user reachable at a chat of its choosing. No bot configured means every redemption is refused.
Payload carries the vfy_ prefix400A link code that reaches here is a routing bug. Looking it up would miss (different keyspace) and report "expired", sending the user to regenerate something that was never wrong.
chat_id is positive and equals telegram_id400Console's own half of the private-chat proof. It cannot see chat.Type, but Telegram gives a private chat the same id as the user, and every group / supergroup / channel id is negative. A second lock on the same door, not a replacement for the bot-side check.
Token exists in Valkey401Consumed with GETDEL, so used, expired and never-issued are one indistinguishable answer and nothing can be probed.
The user's linked traits.telegram_id, if any, matches the redeemer403The token says who should be verified, not who is holding it. If a different account redeems a forwarded link, honouring it would point that user's pages at the redeemer — a takeover that shows up in Console as a green badge. A user with nothing linked yet has nothing to contradict.

On success SetTelegramVerified writes the chat id and the stamp in one statement (so the two can never disagree), and a telegram_verify_redeem audit entry records it — actor the bot, resource the verified user, and no part of the token, which never reaches a log line either.

A refusal on any of the first three checks leaves the token intact, so an honest user can retry the same link after the deployment is fixed. A 401 or a mismatch has already consumed it.

Verification lives in user_oncall_profile; the paging identity lives in users.traits. The link and unlink endpoints move the second, so they now write the first through the same call that resets it. Without that, re-linking a different Telegram account inherits the previous account's proof, and Console pages the new id under a stamp that proves the old one — "linked is not pageable" reintroduced from the other side, and worse, because it looks verified.

The reset runs before the traits write. Both failure orders then leave a safe state: if the reset fails nothing changed at all, and if the traits write fails afterwards the identity is stale but unverified, so nobody is paged at an unproven chat. The reverse order has a failure mode with no safe reading.

The verified gate (PROXIMA_TELEGRAM_VERIFIED_GATE)​

The stamp is now read, on every Telegram step of every notification chain. What the resolver does with an unproven chat is one environment variable, and it has exactly two values.

ModeWhat happens to a Telegram step whose chat is not proven
warn — defaultIt is still paged, to the linked telegram_id, exactly as it was before the gate existed. Each such decision bumps proxima_telegram_unverified_page_total and logs a WARN (unverified private chat — telegram paging not guaranteed). Non-regressing — no page that used to go out is suppressed.
enforceThe Telegram step is skipped and escalation falls through to the chain's remaining channels. The drop is recorded against the alert with skip reason unverified, and the same counter is bumped.

Anything that is not exactly enforce — "", a typo, a differently-cased ENFORCE — is read as warn. The gate fails open, because the failure it exists to prevent is a page nobody receives, and a typo must never cause one.

Do not ship enforce

warn is the default and it must stay the default. Flipping a deployment to enforce before its users have verified does not "tighten" anything — it silently removes the Telegram leg from every one of them, which is the exact failure this whole feature exists to eliminate, arriving by way of the fix for it.

The order is: deploy in warn → watch proxima_telegram_unverified_page_total → run a verification drive until that counter is flat at zero → then flip. Same observe-then-flip pattern as the voice gate, and the two gates are independent: verifying a phone does nothing for Telegram, and enforcing one says nothing about the other.

What counts as proven​

Two things, and the and between them is the point:

  1. The profile carries a telegram_verified_at stamp, and
  2. telegram_chat_id still equals the user's linked traits.telegram_id.

The second is not redundant. For a private chat Telegram gives the chat the same id as the user, so a stamp that no longer matches the linked identity is positive evidence the identity moved. Paging the chat it names would reach the previous account holder — someone who is not on call, reading somebody else's incident, with the ack button under it. That is worse than not paging at all, so a mismatch reads as unverified and is treated like any other unverified contact: paged at the current identity under warn, skipped under enforce. The link and unlink paths already reset the stamp; this check is the read side of the same rule, and it holds even if a future writer forgets.

An empty traits.telegram_id is never vacuously equal. A user with nothing linked is unverified no matter what the profile says — a check that read "no linked id or a matching one" would treat a stamp with nothing to compare against as proof. That matters because redeeming deliberately permits an unlinked user to verify: a forwarded link can therefore write a redeemer's chat onto an unlinked user's profile, and reading that as proof would page the redeemer. It is not read as proof, and the resolver refuses such a user before it looks at the stamp at all.

What a page is addressed to​

Always the linked traits.telegram_id — never the stamped chat id. Under warn there may be no stamp at all, and where there is one it only counts if it equals the linked id, so "page the proven chat" and "page the linked identity" name the same string in every case where the chat is actually proven. Nothing is ever addressed to a chat the current identity does not match.

Seeing it: telegram_verified on the profile API​

GET /api/v1/users/{userID}/oncall-profile returns telegram_verified beside the existing phone_verified, and PUT echoes it back unchanged (setting a paging phone does not touch a Telegram proof). Both are behind the same self-or-admin + tenant gate as the phone: readiness is one more fact about where somebody's pages land, so a scoped admin who shares no client with the target gets a 403, not a badge.

It is not the telegram_verified_at stamp. It is the same predicate the resolver decides by — both conditions above, evaluated against the trait exactly as the resolver reads it. Three cases carry a stamp and still report false, and each one is a page that would not be delivered:

On fileReportedWhy
Stamp, nothing linkedfalseA forwarded verify link can stamp an unlinked user's profile with the redeemer's chat.
Stamp naming a different chatfalseThe identity moved; the proof names the previous account.
Stamp, but traits.telegram_id is not a stringfalseThe resolver reads that trait as absent and skips the user as no_telegram.

The last row is the reason the field reuses the resolver's own trait reading rather than a more tolerant one. Readers of traits.telegram_id elsewhere in the codebase accept numeric forms; if one of those ever writes a number, the person becomes unpageable and a stamp-based badge would keep saying "verified" right through the incident. Reconciling those readers changes who gets paged and is a separate change — until then, anything reporting on readiness answers with the resolver's rule, so the badge and the pager cannot disagree.

What it never does​

  • It never blocks authoring. A notification policy naming Telegram saves fine for someone who has not verified. Trapping people mid-configuration would be its own outage.
  • It never affects the group alert card. The gate is on the per-user DM path only; a client or internal chat binding is a different destination with a different proof.
  • It never logs the chat id or the telegram id. Both identify a person. user_id is the only identifier in the WARN lines, and the counter carries no labels for the same reason.