Notification settings and the Telegram bot — design
Builds on the header bell (in-app only, fixed kinds, no settings). Two additions: each member chooses what reaches them and where, and Telegram is a second channel that can also act (Approve / Reject from the chat). Ported in spirit from Korre PM’s Telegram claim approvals (its D1–D6), re-cut for this ERP’s workflow engine and River queue.
Problem
Section titled “Problem”An approver only learns a claim waits for them if they open the web app and look at the bell. Approvers live in Telegram. And nobody can quiet a noisy kind, or be told only about what concerns them, because the bell has no settings at all.
As built (deviations from the design below, all deliberate)
Section titled “As built (deviations from the design below, all deliberate)”- Packages.
platform/telegram(bot client, settings, link, outbox writer, i18n, prune) links no record package, so the HTTP edge (modules/identity) and the/meroutes do not link every record package, the same reasonnotifyis apart fromnotifications.platform/telegram/relayholds what reads and moves records: the send worker and the webhook handler. The edge depends ontelegram.Botandtelegram.Hookonly. - Webhook.
POST /v1/telegram/webhook, authenticated by theX-Telegram-Bot-Api-Secret-Tokenheader alone (no secret in the path, so it never reaches an access log). It sits under/v1so the web proxy already forwards it, and is mounted bycmd/apionly whenTELEGRAM_PROVIDER=bot. Register it withgo run ./cmd/telegram-webhook set. - One Telegram account is one member, platform-wide (
telegram_link.telegram_user_idis unique across organizations), because the webhook receives only a Telegram user id and must find the organization from it. A person in two organizations connects a different Telegram account to each. - HTML, not MarkdownV2. Messages use
parse_mode=HTML; only& < >are escaped, so record data can never become markup. - Queue. The outbox row is the truth and a River job (
telegram_send, queuetelegram) its trigger, withMaxAttempts6; a 429 snoozes the job for exactlyretry_afterwithout using an attempt. There is noSECURITY DEFINERclaim function: the job carries the organization and the outbox id. - Approve from chat covers expense claims only; advances, top-ups and petty-cash spends get Open in app.
- Not built: telling the organization’s admins (the bell) when a group chat removes the bot (the binding is
just forgotten);
telegram_link.bot_idfor sharding across bots; a separatecmd/telegram-fake(the tests use an in-processFake, andTELEGRAM_PROVIDER=logwrites messages to the log); the Telegram-originated decision is recorded inaudit_logastransition_via_telegram, not as aviacolumn on the decision row. - Definer functions take the instant (
p_now) from the caller’s clock rather thannow(), so tests with a fixed clock and the webhook agree on what “expired” means.
What this is not
Section titled “What this is not”- Not a second workflow, not a new permission model. A Telegram tap calls the same
expenses.Transition(andpettycash/advancesequivalents) a web click does, as the linked member, so authority, segregation of duties, amount bands, posting and audit are the engine’s answer. An unlinked, unauthorised or stale tap changes nothing. Telegram is an input device, never an authority. - Not a replacement. The bell stays and is always written. Telegram is delivery beside it.
- Not push/email (still out). The channel table is generic so they can join later.
- No prose in the notification row (bell decision 1 stands). The message text is rendered at
send time from
kind+record_kind+ reference, in the recipient’s language.
Decisions
Section titled “Decisions”D1 — Preferences: kind × channel, per member, default-on
Section titled “D1 — Preferences: kind × channel, per member, default-on”Table notification_pref(org_id, member_id, kind, channel, enabled), PK
(member_id, kind, channel); channel ∈ inbox, telegram. Absent row = the default:
inbox on, Telegram on (once linked). Only changes are stored, so a new kind or new member needs no
backfill.
inboxcan be turned off for informational kinds (comment_added,request_approved,request_paid);awaiting_actionandrequest_rejected/request_returnedcan never be turned off for inbox (an approver must be able to find what waits on them; an owner must be told their claim came back). The API refuses it (422), the UI disables the switch.telegramcan be turned off for any kind. A kind not inTELEGRAM_KINDS(v1:awaiting_action,request_approved,request_rejected,request_returned,request_paid,comment_added) has no Telegram column.- Quiet hours (per member, optional):
quiet_from,quiet_to(local time) in the member’s timezone (notification_setting.timezone, default org timezone). Inside the window Telegram is held, not dropped: deliveries are scheduled for the window’s end and then collapsed into one digest (“5 things wait for you”) if more than 3.awaiting_actionolder than the window end that has since been retired (decision log moved on) is discarded, never sent stale. Inbox is unaffected. - Reads/writes:
GET /me/notification-settings→{kinds:[{kind, inbox, telegram, locked}], quiet, timezone, language, telegram:{linked, username, bot_username}};PUT /me/notification-settings(partial; same replay-safe shape as other/mewrites). Own-member only, no permission (like the bell routes,api/public_routes.txt). - Org-level: Settings → Organisation gets one switch
telegram.enabledand the list of kinds the org allows over Telegram (an org can say “no amounts in chat”:telegram.show_amountdefault on; when off the message omits amount and counterparty and shows reference + Open only). Permissionorg.notifications.manage.
D2 — Delivery is a queue, never inline (the scalability point)
Section titled “D2 — Delivery is a queue, never inline (the scalability point)”The event subscriber (notify) already writes notification rows in its own transaction. In that
same transaction it also inserts telegram_outbox rows (one per recipient that is linked,
enabled, and not muted) with a unique (notification_id), so a redelivered event writes nothing
and a crash never loses or double-sends. A River worker (telegram_send, queue notifications,
its own concurrency, so a Telegram outage cannot starve webhooks/einvoice) claims due rows
FOR UPDATE SKIP LOCKED in batches and sends.
- Rate limits obeyed, not hoped for: Telegram allows ~30 msgs/s globally and 1 msg/s per chat
(20/min per group). A shared token bucket (30/s, in-process per worker;
TELEGRAM_RATE_PER_SECwith the default 25 to leave headroom) plus a per-chat 1/s gate. 429 → honourretry_afterexactly, pause the whole worker, requeue the row (no attempt burned). - Retries: transient 5xx/network → 6 attempts on the webhook backoff schedule, then
failedwith the last error; the bell notification is unaffected.403 Forbidden: bot was blocked/chat not found→ mark the linkrevoked(reasonblocked), stop sending, show “Reconnect Telegram” in settings. Never retried. - Fan-out bounded: the existing caps (more than 20 eligible actors → nobody; 50 candidates)
apply before the outbox, so one event costs at most 20 rows. Outbox rows are pruned after 14 days
(sent/failed), by the existing maintenance periodic job pattern (
notification_prune). - Many orgs, one bot: the bot is platform-wide (one token,
TELEGRAM_BOT_TOKEN), a link is(member → telegram_user_id)and a member belongs to one org per link row, so 10 or 10,000 orgs cost nothing extra. Outbox rows are tenant-scoped (forced RLS); the worker claims across orgs through aSECURITY DEFINERclaim function (thenotification_prunepattern) and then sends each row under its org. - Digest/throttle per recipient: at most 1
comment_addedmessage per record per 5 minutes per recipient (extra comments fold into the bell only).
D3 — Linking: a one-time deep link, never a username
Section titled “D3 — Linking: a one-time deep link, never a username”POST /me/telegram/link (authenticated, 5/min/member) mints 32 random bytes, stores only the
SHA-256 in telegram_link_token(token_hash, member_id, org_id, expires_at, used_at) (10-minute
life, any earlier unused token of the member is invalidated), and returns
https://t.me/<bot>?start=<token> + expires_at. The web opens it in a new tab and shows a
countdown; staleTime: 0 on the status query so returning to the tab flips the card.
/start <token> (private chat only) consumes the token and writes
telegram_link(org_id, member_id, telegram_user_id, chat_id, username, language, status).
UNIQUE (telegram_user_id) per org and UNIQUE (member_id): one Telegram account ↔ one member
per org; linking a second member in the same org from the same Telegram account is refused with a
message (“this Telegram is already connected to X; disconnect there first”). A member’s
Telegram is never inferred from a username. DELETE /me/telegram/link and /stop in the bot both
unlink; a member who is deactivated or loses org access is unlinked by the existing member-lifecycle
event so their phone stops receiving. Linking is audited (audit.Write, no token, no chat id in
the payload).
D4 — The webhook is public, secret-verified, idempotent, fast
Section titled “D4 — The webhook is public, secret-verified, idempotent, fast”POST /v1/telegram/webhook (no session auth; outside the authenticated group and the API-key/rate-limit
chain, with its own body cap of 64 KiB).
- Authenticated by
X-Telegram-Bot-Api-Secret-Token(subtle.ConstantTimeCompare, ≥32 chars,TELEGRAM_WEBHOOK_SECRET); a mismatch is 401 and the supplied value is never logged. update_idinserted intotelegram_update(update_id PK, received_at)first; a unique violation → 200 and stop. This is what stops a retried Approve double-posting to the ledger.- Always 200 for handled-or-refused updates (a 500 makes Telegram retry a poison update forever); only a transient DB error is a 500.
- Handling is fast and inline (an Approve is one transaction); anything slow (sending the
reply) goes through the outbox like everything else, except
answerCallbackQuery, which must be sent directly within a few seconds so the button stops spinning. - Registered with
setWebhookbycmd/telegram-webhook set, withallowed_updates=["message","callback_query"]anddrop_pending_updatesoff.
D5 — Buttons carry a token, not the record; Approve is conservative
Section titled “D5 — Buttons carry a token, not the record; Approve is conservative”callback_data is 64 bytes and client-controlled on the way back. Each button is a
telegram_action(token, org_id, member_id, record_kind, record_id, from_state, to_state, kind, expires_at, used_at) row; only its 22-char token travels. Single-use, 48-hour life, and bound to
the recipient’s member id: a different Telegram user (a bystander in a shared chat) tapping it
is refused with a toast and nothing moves.
- Approve is offered only when, for that member at send time,
AvailableTransitionshas exactly one allowed forward edge that needs no reason, no payout channel/proof and no sign-off shares — i.e. the same “plain Approve” the web shows as one button. Anything else (a payout step, a share sign-off, several ways forward) gets only Open in app (URL button). It is re-checked at tap time with the current state:from_state≠ current → “already handled / moved on” and the message is edited to say so (the engine’sErrStaleStateis the truth, not a cache). - Reject never rejects directly: the bot sends a
force_replyprompt; the reply’s text (1–500 chars) is the reason, then the same engine call runs withReason. A reply is matched to its prompt byreply_to_message_id; the prompt expires in 15 minutes. - After any outcome the original message is edited in place (buttons removed, result line appended: “Approved by you · 14:02”) so a chat is a record, not a stack of stale buttons; the edit goes through the outbox rate gate.
- Every Telegram-originated move runs in one
db.InTx, asActor = member, with a deterministic idempotency keytg:<update_id>, underledger.MapError. The decision row’sviais recorded astelegramin the audit entry (audit.Write,Action: "approved_via_telegram") — last lock in the transaction, per the audit-lock rule. - Refusals (
RefusedError, stale, not readable) are answered with a short localised alert; the message never states anything about a record the tapper cannot read (R21: unreadable = “no longer available”).
D6 — Shared chat per approval group (optional)
Section titled “D6 — Shared chat per approval group (optional)”An org may bind an org unit (department) to one Telegram group chat, so that unit’s
awaiting_action posts once to the room instead of DMing every approver. Reasons for unit, not
role: a chat is a place; units are places; the engine’s pool for a step is not a chat’s
membership and cannot be made to be.
- Admin: Settings → Organisation → Notifications → “Telegram chats”: pick the unit, press
Connect → the page shows a one-time code
/connect <code>to send in the group (the bot must already be an admin-added member). The code bindschat_id(string everywhere, never a JSON number: supergroup ids are large and negative) to the unit and posts “Connected to”. - Routing: a claim’s
awaiting_actiongoes to the chat instead of DMs when the claim’s claimant sits in a bound unit (or a descendant: the sameorg_unit.pathprefix rule as project departments) and the step’s actors are all inside that unit’s subtree; otherwise DMs. A chat post names no recipient. Buttons still work and are still authorised per tapping user (D5: token bound to the actor set, validated at tap against the tapper’s own link). - A group chat message never shows an amount when
telegram.show_amountis off. my_chat_memberupdate with statuskicked/leftclears the binding and notifies the org admins (bell).- Out of scope for v1: forum topics, per-project chats, binding a role.
D7 — Provider indirection, safe in dev, loud in prod
Section titled “D7 — Provider indirection, safe in dev, loud in prod”TELEGRAM_PROVIDER=bot|log|off (the einvoice/MyInvois-fake shape): off = the feature is absent
(status reports bot_username: null, the Connect card renders nothing, POST .../link → 503);
log writes what would be sent (refused when APP_ENV=production); bot needs
TELEGRAM_BOT_TOKEN, TELEGRAM_BOT_USERNAME, TELEGRAM_WEBHOOK_SECRET, each missing key named
individually at boot, no secret valued in a log. A cmd/telegram-fake mock Bot API (like
cmd/myinvois-fake) backs the end-to-end tests.
Language
Section titled “Language”Message text is rendered in the recipient’s language: notification_setting.language
(en/ms/zh), set from the web’s active language when first linking/saving settings, editable in the
same page, and used by /start’s replies too. Strings live in the same i18n catalogue as the web
(the vocabgen drift tests cover the new keys). Messages are HTML-mode text in which every
record string is escaped (& < >), because a claim title is user input.
Data model (migrations 01140–01149, new owner range)
Section titled “Data model (migrations 01140–01149, new owner range)”| Table | Purpose | Class |
|---|---|---|
notification_setting(org_id, member_id PK, timezone, language, quiet_from, quiet_to, updated_at) |
per-member | operational |
notification_pref(org_id, member_id, kind, channel, enabled) PK (member_id, kind, channel) |
overrides only | operational |
org_notification_setting(org_id PK, telegram_enabled, show_amount, kinds jsonb) |
org switch | operational |
telegram_link(org_id, member_id UNIQUE, telegram_user_id, chat_id, username, language, status, linked_at) UNIQUE (org_id, telegram_user_id) |
the link | operational |
telegram_link_token(token_hash PK, org_id, member_id, expires_at, used_at) |
one-time deep link | operational, pruned 1 day |
telegram_chat(org_id, unit_id UNIQUE, chat_id, connected_by, connected_at) + telegram_chat_code |
shared chat | operational |
telegram_action(token PK, …) |
button tokens | operational, pruned 7 days |
telegram_update(update_id PK, received_at) |
webhook idempotency | operational, pruned 3 days |
telegram_outbox(id, org_id, notification_id UNIQUE, recipient link/chat, kind, due_at, status, attempts, last_error, sent_at) |
the queue | operational, pruned 14 days |
All carry org_id with forced RLS org_isolation and composite FKs to organization_member
(except telegram_update, which has no tenant and is touched only by the webhook’s definer
function); the definer functions (telegram_outbox_claim, telegram_prune) follow
notification_prune (refuse to run unless the owner bypasses RLS). telegram_link and tokens hold
no secret beyond the hash; the bot token lives only in env. Retention test: all
operational; REVOKE DELETE not applied (they are pruned). Audit on link/unlink, chat
connect/disconnect, org switch change, and Telegram-originated decisions.
API (openapi-first)
Section titled “API (openapi-first)”GET/PUT /me/notification-settings; POST/DELETE /me/telegram/link;
GET/PUT /org/notification-settings (org.notifications.manage);
GET/POST/DELETE /org/telegram/chats (same permission);
POST /telegram/webhook/{secret} (public, not in the generated client; documented in
api/public_routes.txt and a short docs/ note). make generate regenerates the client, the
routes_test permission map and vocab.
- Settings → Notifications (new tab in
SettingsLayout): a Telegram card (Connect / countdown / Connected as @handle · Disconnect · Reconnect whenrevoked; renders nothing whenbot_usernameis null), a kind × channel matrix (locked cells disabled with a lock and tooltip), a quiet-hours row (from/to + timezone), a language select, and a “Send test message” button (rate-limited 3/hour) so a member can prove it works. - Settings → Organisation → Notifications (permission-gated): Telegram on/off, show-amount switch, and the Telegram chats table (unit, chat title, Connect / Remove).
- The bell is unchanged except it hides kinds a member turned off for inbox (the row is still written for locked kinds and for audit-neutral reasons: rows are written for every kind, the preference only filters the read/unread badge and list, so turning a kind back on shows its history within the 90 days).
Tests (what “done” means)
Section titled “Tests (what “done” means)”- Go unit/property: preference resolution (default, override, locked refused), quiet-window maths across midnight and DST, digest collapse, MarkdownV2 escaping fuzz (no input yields an unparseable message), token bucket and per-chat gate (injected clock), deep-link token single-use/expiry/replacement, callback-token binding.
- Go integration (testcontainers, Telegram fake): event → exactly one outbox row per eligible
linked recipient and none for a muted one; redelivery writes nothing; outbox claim with two
concurrent workers never sends twice; 429 pauses and requeues without burning an attempt;
blocked-bot revokes the link; webhook: bad secret 401, duplicate
update_idis a no-op, Approve moves the claim and a replay of the same update does not move it twice, a stale Approve is refused and the message edited, a non-approver tapper is refused, Reject asks for a reason and the reply completes it, an Approve for a payout step is never offered; cross-org: a link in org A can never act on org B’s claim (RLS + token org binding); shared chat routing and kick handling. - Web (vitest): matrix locks, Connect countdown and focus refetch, quiet-hours form validation,
the card hidden when
bot_usernameis null, org page gating, ms/zh keys present. - Live proof (as the claim-export work did): demo users on the dev stack with the fake bot — submit a claim, see the Approve message, tap Approve, claim moves; mute a kind, see no message.
Scale and failure behaviour (explicit)
Section titled “Scale and failure behaviour (explicit)”- A Telegram outage or a throttle never slows or fails a business request: every send is post-commit, via the outbox, on its own River queue.
- Worst-case burst (an org’s month-end approvals): outbox grows, worker drains at ≤25/s; a recipient’s own quiet-hours digest means a 60-claim backlog becomes one message.
- The webhook does one indexed insert + at most one engine transaction per update; Telegram
retries are absorbed by
update_id. It is stateless, so more API replicas need no coordination. - One bot token, one webhook URL for all orgs. If the volume ever outgrows a single bot’s global
30 msg/s, shard by org to additional bots (
telegram_link.bot_idis reserved in the schema for this, unused in v1) — noted, not built.
Open questions / assumptions (defaulted, say if wrong)
Section titled “Open questions / assumptions (defaulted, say if wrong)”- Telegram DMs are not end-to-end encrypted, so claim amounts leave the system for a third
party: hence the org switch and
show_amount(default on, matching Korre PM). - v1 covers the four approvals kinds the bell already knows (
expense_claim,petty_cash_spend,float_topup,advance); Approve-from-chat is wired for expense claims and petty cash spends only, advances and top-ups get Open-in-app (their finance steps often need extra input). - No QR code (the link is on screen and copyable), as in Korre PM.
- The ERP’s web has no stored per-member language today; this spec adds it
(
notification_setting.language) rather than guessing from the browser at send time.