From 400653810e6ce6b91c5f278ac29e17491d180c67 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 20:59:03 +0400 Subject: [PATCH] telegram is no longer outbound only (V-637) The correction gesture now reaches all three surfaces, and CLAUDE.md said only /chat had it. Doc 23 carries the decisions: long-poll rather than a webhook, the backlog dropped on start, one accepted sender, and the two-tap keyboard. --- CLAUDE.md | 17 ++++++-- docs/plans/23-inbound-telegram.md | 70 +++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 docs/plans/23-inbound-telegram.md diff --git a/CLAUDE.md b/CLAUDE.md index f65026b..141db1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -305,9 +305,20 @@ not a transcript. The transcript still expires. The gesture that writes one is two buttons beside the reply on `/chat`, reached over `ipc.CorrectTurn` and the trace id that now rides back on `ipc.ChatReply`. A turn marked wrong with no target is a usable negative, so naming the intent is never required. The target -is one of the seven intents and never free text. Only `/chat` offers it: the wire -op assumes no browser, but telegram and voice do not call it yet, and -`docs/plans/22-correcting-a-turn.md` says why voice is the hard one. Adding a rung to the ladder +is one of the seven intents and never free text. **All three reaches offer it as +of 06-08-2026**, and this section used to say only `/chat` did. Voice is the +`repair` rung, which has read spoken corrections since V-455 and now writes the +durable label beside the classifier seed it always wrote; a spoken negative with +no target is its own rung, `repair-negative` (V-636, `docs/plans/22-correcting-a-turn.md`). +Telegram is an inline keyboard under the reply, and it needed the chat to become +readable first — **telegram is no longer outbound only** (V-637, +`docs/plans/23-inbound-telegram.md`). The poller is dark unless the `telegram` +block says `intake`, it long-polls because the box takes no inbound connections, +it accepts `chat_id` and no other sender, and it drops whatever queued while the +daemon was down. It reaches the daemon through `ipc.CoreAPI` alone, so a chat +turn takes the path `POST /api/chat` takes. Note that the turn source is still +`tap:text` for both, so provenance cannot tell a chat turn from a typed one. +Adding a rung to the ladder in `runTurn` means adding its name to `preRouteLadder` in `cmd/mavend/decisiontrace.go`, or that rung is silently missing from the record. diff --git a/docs/plans/23-inbound-telegram.md b/docs/plans/23-inbound-telegram.md new file mode 100644 index 0000000..e5c3cee --- /dev/null +++ b/docs/plans/23-inbound-telegram.md @@ -0,0 +1,70 @@ +# Inbound telegram + +Last verified: 06-08-2026 @ c61b0b3 + +V-637, under V-628. Reads with `22-correcting-a-turn.md`. + +## What was missing + +Telegram was a reach and nothing else. `telegramsink` pushed an away message and the chat +had no way to answer, so the correction gesture reached the web and voice only. + +That skews the labels. V-546 fits routing heads on them, and a sample drawn from wherever +the owner happens to be sitting is the wrong sample. + +## Long-poll, not a webhook + +The box takes no inbound connections and reaches api.telegram.org through a relay, so the +connection has to open outward. `getUpdates` with a 25 second hold, one goroutine in the +daemon's WaitGroup. + +A failed poll waits 15 seconds and retries without escalating. The relay going down is the +normal cause and it comes back on its own. + +## The backlog is dropped on start + +Telegram keeps undelivered updates for 24 hours. A daemon that was down overnight would +otherwise wake and answer every queued message in order. + +That is worse than missing them. A question asked eight hours ago has been answered +already. A reminder set from it lands at the wrong time. So the first call moves the offset +past whatever is queued and acts on none of it. + +## One chat + +`ChatID` is the only accepted sender, and it is the same chat the push half already sends +to. A message from anywhere else is dropped with no reply, because a reply confirms the bot +exists and whose it is. + +Chat ids are not guessable. They are also not secret, since they travel in every forwarded +message. So this is the whole authorisation and it is an allowlist of one. + +## The gesture + +Two taps at most. The reply carries one button, `не то`. Tapping it writes nothing and opens +the seven intents plus `просто неверно`. The untargeted negative stays reachable, because he +may have opened the row without meaning to name anything. + +Callback data carries the trace id and the target, under telegram's 64 byte cap. It comes +off the wire. So an id that will not parse is dropped, and so is a target that is not one of +the seven. A label nothing can score is worse than no label. + +A failed write says so on the button and leaves the keyboard up. A successful one takes the +keyboard off, because a live keyboard on an answered turn invites correcting it twice. + +## The seam + +`NewPoller` takes two functions and no daemon type. `cmd/mavend/telegramintake.go` fills +them from `ipc.CoreAPI`: `Chat` returns the reply and the trace id it collected off the +context, and `CorrectTurn` writes the label. So a chat turn takes the path +`POST /api/chat` already takes, and nothing in `internal/delivery` knows what a handler is. + +## What is not done + +The turn source is still `tap:text`, which telegram shares with the web. Provenance cannot +tell a chat turn from a typed one, so a label's `source` column cannot either. +That matters the first time someone asks whether corrections given in the chat differ from +corrections given at the desk. + +Voice messages are ignored. The poller reads `message.text` and nothing else, so a voice +note in the chat does not reach `mavsttd`.