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.
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 card | Client-facing message | Personal page | |
|---|---|---|---|
| Goes to | a responder group chat | a customer group chat | one person's private chat |
Audience (telegram_message.audience) | internal | client | direct |
| Sent by | the notification worker, when paging arms | the notification worker | the notification chain, step by step |
| Shows | the full card — scope, analysis, timeline, raw payload | one curated sentence | the alert, then one line saying what became of it |
| Controls | per state | never | the chain's Ack buttons, until it is answered |
| Reconciled against | a telegram_group_binding | a telegram_group_binding | nothing — 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.
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.
| Bot | Token | What it does |
|---|---|---|
Ops bot (@proximaops2bot) | PROXIMA_OPS_TELEGRAM_BOT_TOKEN | Posts 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_TOKEN | The 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:
| Field | Meaning |
|---|---|
telegram_chat_id | The 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_at | NULL = 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.
| Property | Value | Why |
|---|---|---|
| Lifetime | 15 minutes | This 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. |
| Prefix | vfy_ | 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. |
| Store | telegram_verify:<token> in Valkey | A separate keyspace from telegram_link: for the same reason. |
| Reuse | Single-use | Consumed 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_verifiedas 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_linkedis deliberately not shown beside it: it can readtruewhile verification readsfalse, 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
/startlands. 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:writethat 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
| Check | Refusal | Why |
|---|---|---|
Caller is the ops bot's specific service account (PROXIMA_OPS_BOT_SA_ID) | 403 | The 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_ prefix | 400 | A 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_id | 400 | Console'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 Valkey | 401 | Consumed 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 redeemer | 403 | The 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.
Link and unlink reset the stamp
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.
| Mode | What happens to a Telegram step whose chat is not proven |
|---|---|
warn — default | It 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. |
enforce | The 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.
enforcewarn 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:
- The profile carries a
telegram_verified_atstamp, and telegram_chat_idstill equals the user's linkedtraits.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 file | Reported | Why |
|---|---|---|
| Stamp, nothing linked | false | A forwarded verify link can stamp an unlinked user's profile with the redeemer's chat. |
| Stamp naming a different chat | false | The identity moved; the proof names the previous account. |
Stamp, but traits.telegram_id is not a string | false | The 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_idis the only identifier in the WARN lines, and the counter carries no labels for the same reason.