Telegram Agent Channel
service-desk/telegram-agent-channel
| Kind | backend |
|---|---|
| Domain | service-desk |
| Maturity | prod-dark |
| Reusable | extract |
| Stack | fastapi-0.118, sqlalchemy-2.0-sync, postgres, requests-2.32, pydantic-2.12, python-stdlib-threading |
| Depends on | _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/workspace-scoping, _shared/alembic-migrations, _shared/scheduler-and-background-work, _shared/test-layout |
| Frontends | service-desk/ui/ticket-detail |
| Reference implementation |
|
| Measured on | alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12 |
| Version | 1 |
Purpose
An agent pairs their personal Telegram chat to their Labs account once, by typing /start <code> for a code their own Settings page minted, and from then on a ticket ping that lands in that chat can be accepted, questioned or answered from the phone that ping arrived on. The invariant that makes it trustworthy: a reply is never read as "about whatever ticket this agent last touched" - it resolves to exactly the ticket whose ping carried the Telegram message_id the agent replied to, looked up on (user_id, provider_message_id) (handle_ticket_reply, telegram_bot.py 419-429), scoped to the chat the ping was actually delivered to, so a reply from the right person in the wrong chat, or the right chat with no pairing, both resolve to nothing rather than guessing.
When to use
Read it before touching DeskTelegramBot.handle_update/handle_command/handle_callback, handle_ticket_reply, handle_callback_accept/handle_callback_ask, handle_tickets_overview, employee_prefs.start_pairing/complete_pairing/find_user_by_chat_id, or parse_status_word; when someone asks "why didn't my Telegram reply do anything", "why did my question go to the wrong ticket", "how does /start pairing work", or "can an agent close a ticket from Telegram" (no, see Invariants). Trigger phrases: "desk telegram", "pairing code", "Rückfrage", "reply to ticket", "ticket ping", "desk reminders", "ask-client prompt", "provider_message_id".
Not this recipe: the fan-out that actually creates a ticket ping and its inline buttons (services/service_desk/desk_notification.py _deliver, 219-238, and DeskNotification.emit/its own delivery loop, 265-380) - this recipe only calls telegram_bot.send_message/ticket_buttons the same way that module does, it does not own when a ping fires or who it goes to; the ticket state machine itself (services/service_desk/ticket_service.py: accept_ticket, change_status, add_message, assign_ticket, extend_deadline) - this recipe is a caller, not the owner, of every transition; the desk email-reply channel (services/service_desk/config_service.py inbound IMAP path referenced by desk_email_intake, scheduled_jobs.py) - a separate reply surface with its own recipe; the accounting receipt Telegram bot (services/accounting/telegram_bot_service.py) - a different bot, a different token, started next to this one in the same scheduler block (scheduled_jobs.py 486-498) but otherwise unrelated; the employee notification-preferences UI itself (frontend/src/pages/settings/DeskNotificationSettings.tsx) - the surface that calls the pairing endpoints below, cited under Frontend, not authored here.
Stack assumptions
At 62ba4fc: fastapi==0.118.2, synchronous handlers on sqlalchemy.orm.Session (SQLAlchemy==2.0.43), requests==2.32.5 for every Telegram Bot API call, pydantic==2.12.0 (only for the HTTP-side schemas in service_desk_router.py; the bot module itself has no Pydantic models), psycopg2-binary==2.9.10, alembic==1.16.5. No FastAPI dependency inside telegram_bot.py's own request path - handle_update is called from a plain requests.get(...) long-poll loop, not from a route; HTTPException is imported only to catch it, inline, where handle_ticket_reply calls into ticket_service (390, 507). Foundations assumed: S4 (fastapi-project-layout) for the /api/desk mount (app_fastapi.py 829, mount_router(app, service_desk_router.router, prefix="/api/desk", ...)); S5 (auth-dependency-pattern) for VerifiedUser under the router's own DeskEmployee gate (_require_employee, service_desk_router.py 93-97); S6 (workspace-scoping)-shaped, but not the header-derived kind - see Security & tenancy for how tenancy is actually derived on the bot side; S8 (alembic-migrations) for the one chained migration under Deploy & ops; S12 (scheduler-and-background-work), whose own reference implementation already names telegram_bot.py (it is this module's own boot dependency, see Deploy & ops).
Environment, both in core/config.py: DESK_TELEGRAM_BOT_TOKEN (Optional[SecretStr], line 1047; unset makes is_configured() False everywhere and the pairing endpoint answer 503 rather than silently degrading - Security & tenancy); DESK_TELEGRAM_BOT_USERNAME (line 1048; unset makes deep_link() return None, so the Settings page has a code but no clickable link, /start <code> typed by hand still works). Fail behaviour: every outbound call (send_message, answer_callback_query, edit_message_reply_markup) is wrapped in its own try/except and returns None/does nothing on any network failure, never raises (123-191); every inbound handler (handle_command, handle_callback, handle_ticket_reply, handle_callback_accept, handle_callback_ask) catches its own exceptions, rolls back, and answers a generic message rather than crashing the poll loop (353-359, 315-322, 515-521, 560-566, 613-619), which itself also catches per-update (_poll_loop, 250-253) so one bad update never stops the thread.
Data model
Nothing new is modeled for this recipe alone; it reuses two existing tables.
user_preferences (models/memory_model.py 54-73), one row per (user_id, category), enforced by a pre-existing unique index (idx_user_preferences_unique, migrations/versions/20260227_add_memory_system.py 73-78, added long before this feature). This recipe's rows all carry category = 'desk_notifications' (employee_prefs.py CATEGORY, 23) and a JSONB preference shaped as:
{
"telegram": { "chat_id": "<string, Telegram's numeric chat id as text>",
"username": "<string or null>", "linked_at": "<ISO 8601>" } | null,
"pairing": { "code": "<8 chars, PAIRING_ALPHABET>", "expires_at": "<ISO 8601>" } | null,
// events / quiet_hours / email_fallback: out of this recipe's scope (DEFAULT_PREFS, employee_prefs.py 29-43)
}
chat_id belongs to exactly one user at a time: complete_pairing walks every other desk_notifications row and nulls telegram on any row that already carries the incoming chat_id before writing the new one (207-215). pairing is single-use: cleared on success (218) and on an expired attempt (200-205); nothing increments a failure counter (Gotchas).
desk_notification_deliveries (models/service_desk_model.py 371-410, unchanged by this recipe except the one column below): provider_message_id varchar(64) NULL (394), added by migrations/versions/20260905_0002_desk_telegram_reply.py (revision desk_telegram_reply_001) together with a partial index ix_desk_deliveries_provider_msg ON (user_id, provider_message_id) WHERE provider_message_id IS NOT NULL - this index and column are the entire mechanism a reply is resolved through (handle_ticket_reply's query, 419-427). A row this recipe writes itself: handle_callback_ask inserts one with event="ask_prompt", channel="telegram", provider_message_id=<the compose-prompt's own message id> (telegram_bot.py 606-610) so that the agent's next reply is read as the client-facing answer rather than an internal note (Application logic).
What is NOT stored anywhere in this recipe: the bot token (env var only, read once per outbound call via bot_token(), 101-105, never persisted or logged); the body of any Telegram message the agent sent (only Telegram's own message_id is kept, on the delivery row); a raw Telegram user id beyond the chat id string needed to send to that chat.
API surface
Five HTTP routes, all under /api/desk (app_fastapi.py 829), all gated by DeskEmployee (VerifiedUser narrowed by _require_employee, service_desk_router.py 93-97, 111 - any signed-in user who is an agent on at least one served workspace):
| Route | Codes | Notes |
|---|---|---|
GET /me/notifications (get_my_prefs, 939-944) | 200; 403 employee_required if served_workspace_ids is empty (96) | returns employee_prefs.public_view (142-155) - chat id and pairing code never serialize to the client, only linked: bool, username, linked_at |
PUT /me/notifications (put_my_prefs, 947-953) | 200 | merges an EmployeePrefsUpdate patch via employee_prefs.update_prefs (122-139); does not touch telegram/pairing |
POST /me/telegram/pairing (start_pairing, 956-963) | 200 {code, deep_link, expires_at}; 503 {"error": "telegram_not_configured"} if telegram_bot.is_configured() is False (959-960) | code minted by employee_prefs.start_pairing (167-173): 8 characters from PAIRING_ALPHABET (26, Base32 minus 0/O/1/I), 15-minute TTL (PAIRING_TTL, 27); deep_link built from DESK_TELEGRAM_BOT_USERNAME, None if that var is unset |
DELETE /me/telegram (unlink_telegram, 966-971) | 204 | employee_prefs.unlink_telegram (224-228) nulls both telegram and any open pairing |
POST /me/telegram/test (test_telegram, 974-1004) | 200 {sent, channel} (channel computed, response body not fully read in this recipe - see Reference implementation) | sends TEST_MESSAGE over Telegram if linked and configured; falls back to e-mail only when the Telegram send returned None (failure) and email_fallback is on (990-998); writes one DeskNotificationDelivery row with event="test" |
The long-poll dispatch surface a rebuild actually reimplements - not routes, the functions _poll_loop (236-258) and handle_update (262-286) call:
| Entry point | Reached when | Returns / does |
|---|---|---|
DeskTelegramBot.handle_command(chat_id, text, username, db) (327-362) | the update is a plain message with no reply_to_message, or one that starts with / even as a reply (280-283) | /tickets, /open, /queue → handle_tickets_overview; /start <code> → employee_prefs.complete_pairing, replies MSG_LINKED/MSG_BAD_CODE; /stop → employee_prefs.unlink_by_chat_id, replies MSG_STOPPED/MSG_NOT_LINKED; anything else → MSG_HELP |
handle_ticket_reply(chat_id, reply_to_message_id, text, db) (375-524) | the update is a reply and does not start with / | see Application logic |
DeskTelegramBot.handle_callback(cq, db) (288-325) | the update carries callback_query (an inline-button tap) | accept:<ticket_id> → handle_callback_accept; ask:<ticket_id> → handle_callback_ask; both answer_callback_query the tap and, for accept, strip the keyboard (edit_message_reply_markup, 308) |
handle_tickets_overview(chat_id, db) (625-674) | /tickets etc. | up to 50 open tickets (TERMINAL_STATUSES excluded) across every workspace roles.served_workspace_ids returns for the paired user, grouped by class_key, k1 first |
Application logic
handle_ticket_reply (375-524), in the order the function actually runs:
- Strip the body; a leading
#marks it internal and is stripped off (407-410); an empty result after stripping returnsMSG_REPLY_EMPTYimmediately (411-412), before any lookup. - Resolve the sender:
employee_prefs.find_user_by_chat_id(db, chat_id)(414);None→MSG_NOT_LINKED. - Resolve the ticket, scoped to this recipient: the newest
DeskNotificationDeliveryrow whereuser_id == <resolved user>ANDprovider_message_id == str(reply_to_message_id)(419-427). No row, or a row with noticket_id→MSG_REPLY_UNKNOWN(428-429). This is the one lookup the whole recipe exists to get right - it is keyed on the recipient, not merely on the message id, so a ping delivered to agent A cannot be actioned by agent B replying to a forwarded copy of it. - Role check:
roles.desk_role(db, user, row.workspace_id) != "agent"→MSG_REPLY_NOT_AGENT(433-434). - Terminal check:
ticket.status in TERMINAL_STATUSES(("done", "cancelled"),service_desk_model.py51) →MSG_REPLY_CLOSED(439-440), nothing else runs. - Grammar, checked in this order, first match wins, all skipped when
internalis True:extend/verlängern(+days) (_EXTEND_RE, 74, 457-460); a lone status word, typo-tolerant viadifflib.get_close_matches(cutoff=0.8)(parse_status_word, 87-98) →_apply_status_command;assign @name/zuweisen @name(_ASSIGN_RE, 71, 464-466) →_apply_assign. None of these three ever reach the client by mail. - Otherwise:
is_ask = (row.event == "ask_prompt" OR body ends with "?") AND not internal(467).- Not ask (internal, or plain text with no
?):ticket_service.add_message(..., is_internal=True), commit,_notify_mentions, replyMSG_NOTE_SAVED(470-476). No status change happens on this branch - see Gotchas, this contradicts the function's own docstring. - Ask: walks the ticket forward through exactly the transitions needed to reach
waiting_on_client, status-only (no client mail) until the message itself is sent -new→accept_ticket(480-483);accepted→change_status(to="in_progress")(484-489);in_progress→change_status(to="waiting_on_client", message=body), which is what actually emails the client, repliesMSG_ASKED(490-496); a ticket alreadywaiting_on_clientorclient_reviewinstead gets a plainadd_message(is_internal=False)with no status change, repliesMSG_ASKED_AGAIN(498-506,add_messagemails a question on a waiting ticket on its own).
- Not ask (internal, or plain text with no
- Any
HTTPExceptionfromticket_serviceduring step 6/7 is caught, rolled back; afields_incompletedetail becomesMSG_FIELDS_MISSINGnaming the missing fields, anything else becomesMSG_REPLY_FAILED(507-514). Any other exception rolls back and answersMSG_REPLY_UNKNOWN(515-521).
handle_callback_accept (527-569) and handle_callback_ask (572-622) both: resolve the sender the same way (find_user_by_chat_id), parse ticket_id as a UUID (catching ValueError/TypeError → MSG_CB_UNKNOWN, 542-544, 589-591), check desk_role == "agent", check TERMINAL_STATUSES. Accept then requires ticket.status == "new" specifically (else MSG_CB_ALREADY, 554-555) and calls ticket_service.accept_ticket - status only, no client mail (MSG_CB_ACCEPTED). Ask sends the compose prompt (MSG_ASK_PROMPT, force_reply=True) and records its own message_id as an ask_prompt delivery row (606-610) so the agent's next reply is read as ask-flow text regardless of what grammar it would otherwise match.
Invariants
- A reply resolves to exactly the ticket its own ping was about, scoped to the recipient's own chat, never to "whatever this agent last touched": proven by
test_a_reply_from_another_chat_resolves_to_nothing(a real agent's OTHER paired chat, and a wholly unpaired chat, both fail to resolve, on purpose, differently -MSG_REPLY_UNKNOWNvsMSG_NOT_LINKED). - Nothing reachable from Telegram can close, hand over, or cancel a ticket: no
STATUS_WORDSentry maps to"done"(78-83;"resolve"/"done"/"erledigt"all map to"solved"), andSTATUS_CHAIN(84) is only ever walked forward by_apply_status_command/the ask-flow, never to aTERMINAL_STATUSESvalue. A closed ticket refuses every action:test_a_reply_cannot_reopen_or_close_a_finished_ticket. - A command sent as a reply is still a command, never misread as ticket text:
handle_updatecheckstext.startswith("/")before routing tohandle_ticket_replyat all (280-283); proven bytest_a_command_sent_as_a_reply_is_still_a_command(/stopinside a reply still disconnects, sends no mail). - An empty or hash-only body sends nothing to anyone:
test_an_empty_or_hash_only_reply_sends_nothing. - Not proven at this commit: the function's own module docstring and the internal-note branch's surrounding comments both claim "any reply is taking the work on: started, timed, and the client is told" (376-388, 447-450) - true for the ask-flow (
test_bare_reply_accepts_the_ticket_and_asks_the_clientpasses) but reproducibly false for the#-prefixed internal-note branch, which calls onlyadd_messageand never touches ticket status:test_hash_reply_starts_the_ticket_but_the_note_stays_internalfails (assert 'new' == 'in_progress', see Tests and Gotchas). Treat the docstring's claim as intent, not as measured behavior, for that one branch.
Frontend
service-desk/ui/ticket-detail renders the effect of every action in this recipe: a ticket
accepted, questioned or noted from Telegram shows up here as
the same status/message-thread change an in-app action would
make, because this recipe never bypasses ticket_service. At
62ba4fc the real pages are frontend/src/pages/desk/DeskTicket.tsx
(210 lines) and frontend/src/pages/admin/desk/AdminDeskTicket.tsx
(373 lines); neither has been rebuilt as a module-kit rendering
by this recipe (no demo exists here, see AUTHORING.md's
demo_blocked_by convention if that module is later written as
kind: frontend)
The pairing UI itself, cited for completeness though it is not the required module id above: frontend/src/pages/settings/DeskNotificationSettings.tsx (285 lines) - calls POST /me/telegram/pairing, polls GET /me/notifications while a pairing is open until telegram.linked flips true or the code's expires_at passes (47-63), renders the deep_link as a button and the raw /start <code> as a fallback (160-167).
Minimum admin/debug surface (no dedicated route exists at this commit): the boot log lines DESK TELEGRAM: polling started (231) or DESK TELEGRAM: no DESK_TELEGRAM_BOT_TOKEN, pairing bot off (email fallback only) (224) tell you whether the thread is even running; SELECT user_id, preference->'telegram'->>'chat_id', preference->'telegram'->>'linked_at' FROM user_preferences WHERE category='desk_notifications' AND preference->'telegram' IS NOT NULL lists every paired agent; SELECT ticket_id, user_id, event, provider_message_id, created_at FROM desk_notification_deliveries WHERE channel='telegram' AND provider_message_id IS NOT NULL ORDER BY created_at DESC LIMIT 50 is the live reply-resolution table.
Security & tenancy
Auth dependency for the five HTTP routes: VerifiedUser (S5) narrowed to DeskEmployee via _require_employee (service_desk_router.py 93-97) - any signed-in user who is an agent on at least one workspace; there is no per-workspace check on these five routes because pairing is per-user_id, not per-workspace (one Telegram chat can carry pings from every workspace that user agents). Auth on the bot side is a different model entirely: there is no Labs session or signature at all, the credential is Telegram's own infrastructure - only the process holding DESK_TELEGRAM_BOT_TOKEN can getUpdates for that token (module docstring, 4-6), and Labs then trusts whichever chat_id Telegram attaches to an update. Identity of a specific human within that chat reduces to "is this chat_id present in some user's stored telegram.chat_id" (find_user_by_chat_id, 231-242) - a paired chat is only as secure as the phone it sits on (module docstring's own words, restated in handle_ticket_reply's docstring 386-388).
Workspace derivation: never from request input on the bot side. handle_ticket_reply takes workspace_id from the matched DeskNotificationDelivery.workspace_id (442), and the callback handlers take it from ticket.workspace_id after loading the ticket by id (549, 596) - both are values this process itself wrote or already trusts, never a header or a field the Telegram update carries.
Fail direction: every outbound Telegram call is gated by is_configured() and closes silently (no-ops) when unconfigured (138, 168, 182) - not observable to an agent as an error, only as "reminders never arrive"; the pairing HTTP route is the one place this fails loudly instead, 503 before a code is even minted (959-960). Every inbound handler catches its own exceptions, rolls back, and answers a generic message rather than raising into the poll loop (353-359, 315-322, 515-521, 560-566, 613-619), and _poll_loop itself catches per-update on top of that (250-253) - one bad update, or one bad ticket-service call, cannot stop the thread.
Secrets and PII: the token is read once per call via bot_token() (101-105, SecretStr.get_secret_value()), used only to build the API URL, never logged. chat_id and Telegram username are stored as plain JSONB text on user_preferences.preference (not confidential by this file's own classification, but a real external identifier); send_message's failure path is the one place a chat_id reaches the log, at WARNING ("DESK TELEGRAM: sendMessage %s → %s", chat_id, status_code, 152). Every value echoed back into an HTML-mode Telegram message that could contain attacker- or client-supplied text is passed through html.escape - confirmed at all four sites that interpolate one (t.ticket_key/t.status/t.title in handle_tickets_overview, 663-670; the mention ping's ticket key, mentioner name, and note body in _notify_mentions, 707-708) - no unescaped interpolation of attacker-controlled text into a Telegram HTML message was found elsewhere in this file.
Rate limits: none implemented in this file. Pairing-code brute force has no attempt counter or lockout beyond the 15-minute TTL and the code's own combinatorics (8 characters from a 32-symbol alphabet); a callback_data with a malformed UUID is refused cleanly (MSG_CB_UNKNOWN, 542-544, 589-591) rather than raising. Attacker-controlled inputs, all reachable by anyone who can message the bot or its chats: text, callback_data, from.username, reply_to_message.message_id - the reply id is only ever used as an opaque lookup key (step 3 above), never interpreted or executed.
Tests
Run 2026-09-12 in isolation against the scratch Postgres on :5434 (docker-compose.test.yml's postgres-test service, tests/conftest.py 44-48 default TEST_DATABASE_URL), from alchemy_labs/backend/.venv:
.venv/bin/python -m pytest tests/test_desk_telegram_reply.py -q --no-cov -o addopts="" -p no:cacheprovider
prints 1 failed, 5 passed (206 warnings, all pre-existing Pydantic v2 deprecation noise unrelated to this recipe). The one failure, reproduced three times in isolation: test_hash_reply_starts_the_ticket_but_the_note_stays_internal, AssertionError: assert 'new' == 'in_progress' at tests/test_desk_telegram_reply.py:148 - see Invariants and Gotchas, this is a real, currently-failing assertion against the code at this exact commit, not a flake. Caveat measured live: several other agents are authoring different recipes against this same shared scratch database concurrently while this file was written; a run contended by another agent's migration/teardown mid-flight throws unrelated psycopg2.errors.UndefinedTable: relation "users" does not exist errors on tests in this same file - that noise is environmental (confirmed by re-running in isolation and getting the clean 1 failed, 5 passed above), not this recipe's defect.
Hermetic: nothing here touches real Telegram. The telegram_log fixture (tests/conftest.py 213-250) monkeypatches settings.DESK_TELEGRAM_BOT_TOKEN/DESK_TELEGRAM_BOT_USERNAME to fake values and replaces telegram_bot.requests.post with a stub that returns ascending fake message_ids (1000 + len(calls)) so a test can reply to "the nth ping" deterministically. frozen_clock (254-268) pins services.service_desk.clock.now(). Every test in the file calls desk_telegram_bot.handle_update(...) directly with a synthetic update dict (_reply, test file 88-93) - _poll_loop itself (network, getUpdates) is explicitly excluded from coverage (# pragma: no cover - network, 236) and is not exercised by any test in this repo.
Known-answer vectors: parse_status_word is covered indirectly through the reply tests, not by a dedicated unit test file for the typo-tolerance cutoff (difflib.get_close_matches(cutoff=0.8)) - a rebuild wanting that guarantee locked down independently of a full ticket fixture would add one.
What a new app adds and this file lacks: a test that exercises _poll_loop itself (the actual getUpdates call, offset advance, and per-update exception isolation) rather than only handle_update in isolation - the offset-durability gap in Deploy & ops is consequently unverified by any test; a test asserting handle_callback_accept's status check (ticket.status != "new") actually prevents two concurrent taps from both accepting (no threaded-race test exists here, unlike the row-lock tests the billing-credits auto-topup recipe has for its own concurrency claim).
Deploy & ops
Config keys: DESK_TELEGRAM_BOT_TOKEN, DESK_TELEGRAM_BOT_USERNAME (core/config.py 1047-1048). Migration: migrations/versions/20260905_0002_desk_telegram_reply.py (revision desk_telegram_reply_001, down-revision desk_domain_login_001) adds desk_notification_deliveries.provider_message_id and its partial index; applies on its own, no chain of its own beyond the one down-revision. The pairing/chat-id storage needs no migration of its own - it reuses user_preferences, whose unique (user_id, category) index predates this feature by months (20260227_add_memory_system.py 73-78).
Not an APScheduler job: DeskTelegramBot.start_polling() spawns one daemon=True threading.Thread (229) that runs _poll_loop forever until stop_polling() sets self._stop_event. It is started exactly once, inline, inside _start_full_scheduler (scheduled_jobs.py 492-498), itself only reached by the one worker process that wins the Postgres session-level advisory lock (_acquire_scheduler_singleton_lock, 65-89, init_scheduler, 140-153) - this is deliberate: Telegram allows exactly one getUpdates consumer per bot token (module docstring, 4-6), so the same single-leader mechanism S12 documents for cron jobs is reused here for a completely different reason (a hard API constraint, not double-fire prevention). Both Telegram bots on this estate (this one and the unrelated accounting receipt bot) are started back to back in the same try/except block, independently - one failing to start does not stop the other (486-498).
No Argus-style automated check exists for this seam at this commit. The closest operational signal is the boot log itself: DESK TELEGRAM: polling started when configured, DESK TELEGRAM: no DESK_TELEGRAM_BOT_TOKEN, pairing bot off (email fallback only) when not (224, 231). Rollback: code plus the one migration; the migration's downgrade() drops the column and index cleanly (34-36); no ticket data is ever written by anything in this recipe, so a rollback cannot lose ticket state, only the ability to resolve a reply to its ticket going forward.
Gotchas
- 2026-09-12 (measured at 62ba4fc,
tests/test_desk_telegram_reply.py:148): the#-prefixed internal-note reply path does not accept or start the ticket, despite the function's own docstring and the test's own name claiming it does.test_hash_reply_starts_the_ticket_but_the_note_stays_internalfails withassert 'new' == 'in_progress'reproducibly in isolation. The internal-note branch (telegram_bot.py469-476) calls onlyticket_service.add_message(..., is_internal=True); it never callsaccept_ticketorchange_status, unlike the ask-flow branch a few lines below it, which does. A rebuild should decide, on Prima's side, whether the docstring's claim ("any reply takes the work on") or the code's actual behavior (only a client-facing reply does) is the intended contract before porting either one. - 2026-09-12 (measured at 62ba4fc,
telegram_bot.py236-258):_poll_loop'soffsetis a plain local variable, reset to0on every call. Nothing persists the last-acknowledged Telegram update id anywhere durable. A process restart (redeploy, crash, a new worker winning the advisory lock) can therefore cause Telegram to redeliver updates this process already handled before it died. Becausehandle_ticket_reply's branch depends on the ticket's current status rather than on any idempotency key stored per-update, a redelivered reply that the ticket has since moved past can take a different, live branch on replay (e.g. the "already waiting on client" branch, 498-506, re-sends the same question to the client) instead of a safe no-op. Not exercised by any test in this file - every test callshandle_updatedirectly, bypassing_poll_loopand this gap entirely. - 2026-09-12 (measured at 62ba4fc):
DESK_TELEGRAM_BOT_TOKENdoes not appear, even as a documented-but-unset example, in any of.env,.env.example,backend/.env.example,.env.docker.example,.env.alchemy,.env.demo, or.env.emdashin this repo. The feature is fully wired and fully tested but there is nothing in this repo confirming it has ever been turned on outside a test process, hencematurity: prod-darkrather thanprod. - 2026-09-12 (measured at 62ba4fc,
employee_prefs.py176-185):find_by_pairing_codehas no rate limit, no attempt counter, and no lockout - an 8-character code from a 32-symbol alphabet, live for 15 minutes, is guessable only as fast as Telegram's own API will let an attacker's client send/startattempts, a limit this file does nothing to enforce or even observe. - 2026-09-12 (measured live while authoring this recipe): the scratch Postgres this file's tests run against (
:5434) was under concurrent load from other agents authoring unrelated recipes in this same kit; a contended run throwspsycopg2.errors.UndefinedTable: relation "users" does not existon tests in this file that have nothing to do with theuserstable. Confirmed environmental, not a defect here, by re-running in isolation and getting the same clean1 failed, 5 passedeach time. Don't mistake that noise for a regression in this recipe.
Reference implementation
Paths relative to the repo root; all at main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.
alchemy_labs/backend/services/service_desk/telegram_bot.py(813 lines): module docstring (1-8); message constants (26-74);STATUS_WORDS/STATUS_CHAIN(78-84);parse_status_word(87-98);bot_token/is_configured/deep_link/_api_base(101-120);send_message(123-163);answer_callback_query(166-176);edit_message_reply_markup(179-191);ticket_buttons(194-212);DeskTelegramBot(215-362:start_polling/stop_polling222-234,_poll_loop236-258,handle_update262-286,handle_callback288-325,handle_command327-362);_commit_and_run(365-372);handle_ticket_reply(375-524);handle_callback_accept(527-569);handle_callback_ask(572-622);handle_tickets_overview(625-674);_notify_mentions(677-708);_match_agent(711-725);_apply_assign(728-745);_apply_extend(748-770);_apply_status_command(773-809); module-level singletondesk_telegram_bot(812).alchemy_labs/backend/api/routers/service_desk_router.py(1171 lines):DeskEmployee/_require_employee(93-97, 111); the five routes (939-1004, exact lines per API surface above).alchemy_labs/backend/services/service_desk/employee_prefs.py(260 lines, whole file in scope):CATEGORY/PAIRING_ALPHABET/PAIRING_TTL(23-27);DEFAULT_PREFS(29-43);get_prefs/save_prefs/update_prefs(105-139);public_view(142-155);new_pairing_code/start_pairing(163-173);find_by_pairing_code(176-185);complete_pairing(188-221);unlink_telegram(224-228);find_user_by_chat_id(231-242);unlink_by_chat_id(245-254);telegram_chat_id(257-260).alchemy_labs/backend/services/service_desk/desk_notification.py:EVENT_LABELS(35);_deliver(219-238, the ping this recipe's replies answer, and the model forticket_buttons/send_messageusage this recipe's own callback handlers follow);_class_label(99-116).alchemy_labs/backend/services/service_desk/notifications.py:admin_link(46-47, used byticket_buttons' "Open in desk" button).alchemy_labs/backend/services/service_desk/roles.py:TERMINAL_STATUSES(viamodels/service_desk_model.py51);desk_role(63-76);served_workspace_ids(79 onward);agent_user_ids(132 onward).alchemy_labs/backend/models/service_desk_model.py:TERMINAL_STATUSES = ("done", "cancelled")(51);DeskNotificationDelivery(371-410, both indexes).alchemy_labs/backend/models/memory_model.py:UserPreferences(54-73).alchemy_labs/backend/services/scheduled_jobs.py:_acquire_scheduler_singleton_lock(65-89);init_scheduler(140-153);_start_full_scheduler(191 onward, the two Telegram bot startups at 486-498).alchemy_labs/backend/migrations/versions/20260905_0002_desk_telegram_reply.py(36 lines, whole file in scope).alchemy_labs/backend/migrations/versions/20260227_add_memory_system.py:idx_user_preferences_unique(73-78, pre-existing foundation this recipe relies on, not owned by it).alchemy_labs/backend/core/config.py:DESK_TELEGRAM_BOT_TOKEN(1047);DESK_TELEGRAM_BOT_USERNAME(1048).alchemy_labs/backend/tests/test_desk_telegram_reply.py(201 lines, whole file in scope) and the fixtures it imports fromtests/desk_support.py(telegram_log213-250,frozen_clock253-268,make_desk_agents81,actors166,question_payload297,create_ticket316) andtests/conftest.py(TEST_DATABASE_URL44-48).- Frontend, cited not authored here:
frontend/src/pages/settings/DeskNotificationSettings.tsx(285 lines);frontend/src/pages/desk/DeskTicket.tsx(210 lines);frontend/src/pages/admin/desk/AdminDeskTicket.tsx(373 lines).
Deviations of the reference from an ideal implementation, all dated 2026-09-12: the internal-note reply path does not match its own docstring (Gotchas); the poll offset is not durably persisted, so a restart can redeliver already-handled updates into live ticket state rather than a safe no-op (Gotchas); pairing has no brute-force protection beyond a 15-minute TTL; no dedicated admin/debug route exists for the two SQL queries under Frontend; no test exercises _poll_loop itself.
Acceptance checks
Run from alchemy_labs/backend at 62ba4fc with the scratch Postgres on :5434 already up (docker-compose.test.yml's postgres-test service, migrated to head). Expected observations are exact, as measured 2026-09-12, in isolation (see Tests for the concurrent-agent caveat).
.venv/bin/python -m pytest tests/test_desk_telegram_reply.py -q --no-cov -o addopts="" -p no:cacheproviderprints1 failed, 5 passed.- The one failure is named
test_hash_reply_starts_the_ticket_but_the_note_stays_internal; re-running just that test (.venv/bin/python -m pytest tests/test_desk_telegram_reply.py::test_hash_reply_starts_the_ticket_but_the_note_stays_internal -q --no-cov -o addopts="" -p no:cacheprovider) prints the sameAssertionError: assert 'new' == 'in_progress'at line 148 each time. grep -n "^def parse_status_word" services/service_desk/telegram_bot.pyprints line87.grep -c "^def handle_" services/service_desk/telegram_bot.pyprints4(the four module-levelhandle_*functions;handle_update/handle_command/handle_callbackare methods ofDeskTelegramBotand are not counted by this pattern).grep -n "^def test_" tests/test_desk_telegram_reply.py | wc -lprints6.grep -n "revision = \"desk_telegram_reply_001\"" migrations/versions/20260905_0002_desk_telegram_reply.pymatches, confirming the migration under Deploy & ops is present at this commit.grep -q "^DESK_TELEGRAM_BOT_TOKEN=" .env .env.example backend/.env.example .env.docker.example .env.alchemy .env.demo .env.emdash 2>/dev/null; echo $?(run from the repo root) prints1for every file checked (no match anywhere), supporting thematurity: prod-darkclaim.
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.