messenger, telegram: show the operator at once what a tap did, and change the question to its outcome (hq issue 372)
mesh/merge-gate pass: builds messenger, telegram → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request

At 13:30 on 2026-10-10 the operator approved two questions on Telegram and saw
nothing happen: the channel acknowledged each tap with no words, before the
router had judged it, and the edit that removed the buttons kept the question's
title, adding only a line under it that rings nothing.

The router now answers a chosen option in reply to the tap, once the ask's end
is recorded and before the question is edited: "You chose Approve: recorded,
and claude-code was told." (or, when the bus did not take the warrant, that it
is told when it does). A refusal already replied with its reason. Telegram
holds a tap's acknowledgement for the router's word and shows it as the tap's
own toast; a word with a body (a refusal's reason, Details) also stays in the
chat. Without a word in 8 seconds the toast claims nothing, and the late word
goes to the chat. A secret is never a toast.

An ask's own message, once answered, carries its outcome as its title:
"You chose Approve at 13:30 on Telegram · <headline>", then whether the asker
was told. The asks protocol carries no result back from the asker, so nothing
more is claimed of what it did.
This commit is contained in:
jochen
2026-10-10 13:59:54 +02:00
parent 7fd1ceb310
commit 020c05fe65
9 changed files with 498 additions and 28 deletions
+7
View File
@@ -11,6 +11,13 @@ as the operator.
- **Says what the operator chose** on `intake` under its own kind: a tapped button (`choice`), with the
sender as Telegram authenticated it; `/start` (`link`); a message it could not deliver (`failed`); and
whether it can send (`standing`). Only a private chat with the account itself counts.
- **A tap is acknowledged on the phone with the router's word on it** (issue 372): the router answers a
choice with a message in reply to it — "You chose Approve: recorded, and claude-code was told.", or why it
was not taken — and this channel shows that as the tap's own acknowledgement, the short line Telegram
shows over the chat. A word with more to it (a refusal's reason, Details) is also said in the chat, where it
stays. Nothing is acknowledged before the router's word, so the phone never says "recorded" for what was
not; without a word in 8 seconds the tap is acknowledged with words that claim nothing, and the word when it
comes is said in the chat. A secret is never an acknowledgement.
- **A code** (six digits, the one shown on the desk when linking) is asked of the router as a proof, by
request and reply: never in an event, never kept, and deleted from the chat at once.
- **Judges nothing.** Whether the sender is the operator, and what an answer may do, is the router's.
+72 -7
View File
@@ -11,8 +11,10 @@ package main
// clearing arrives without sound, an urgent message or a question rings (D4). A rate limit is waited out
// as told (D5); a refusal is a permanent failure, said to the router, never retried; an edit to the same
// text is a success (D6). A message that cannot be delivered after three tries is said as failed.
// - **What arrives** is long-polled: outbound only. A tapped button is acknowledged at once (the phone's
// spinner stops) and said as a `choice` with the sender the service authenticated; only a private chat
// - **What arrives** is long-polled: outbound only. A tapped button is said as a `choice` with the sender
// the service authenticated, and acknowledged on the phone with the router's word on it — what was
// recorded, or why it was not — which comes as a reply to the tap; without one in TapWait, with words that
// claim nothing, and the word when it comes is said in the chat (issue 372); only a private chat
// with the account itself counts, and only on a message showing the words this channel put there (another
// holder of the token may have changed them). `/start` is said as a `link`; six digits are asked as a `code` proof by
// request and reply, never in an event, and deleted from the chat. Anything else is answered with one
@@ -50,6 +52,13 @@ const (
PollWait = 50 * time.Second
// StandingEvery is how often the channel says whether it can send, at least.
StandingEvery = 5 * time.Minute
// TapWait is how long a tap's acknowledgement waits for the router's word on it (issue 372): the phone
// shows its spinner meanwhile, and Telegram takes an acknowledgement only for a short while.
TapWait = 8 * time.Second
// TapPending is a tap's acknowledgement when the router said nothing within TapWait: it claims nothing.
TapPending = "Passed to the mesh, which has not decided yet. The question changes when it has."
// ToastLength is what a tap's acknowledgement shows (Telegram's own bound).
ToastLength = 200
// FixedLine is what anything the bot does not take is answered with.
FixedLine = "This bot carries the mesh's messages and takes their buttons. To link this account as the " +
"operator, send /start."
@@ -100,9 +109,12 @@ type Channel struct {
Now func() time.Time
Logf func(string, ...any)
Sleep func(time.Duration)
// After calls f once d has passed (time.AfterFunc); a test calls it when it says.
After func(d time.Duration, f func())
mu sync.Mutex
replyTo map[string]int64 // an arrival's id to the chat it came from, for the router's reply
replyTo map[string]int64 // an arrival's id to the chat it came from, for the router's reply
taps map[string]string // a tap's arrival id to its callback, until it is acknowledged
lastPoll time.Time
pollErr string
meAt time.Time
@@ -131,6 +143,12 @@ func (c *Channel) init() {
if c.Sleep == nil {
c.Sleep = time.Sleep
}
if c.taps == nil {
c.taps = map[string]string{}
}
if c.After == nil {
c.After = func(d time.Duration, f func()) { time.AfterFunc(d, f) }
}
}
// text is a message as the chat shows it, cut at MaxLength and the cut said (D1).
@@ -195,6 +213,15 @@ func (c *Channel) Take(subject string, body json.RawMessage) error {
c.Logf("[telegram] work on %s could not be read; taken and dropped", subject)
return nil
}
// The router's word on a tap, said where the tap was made: the tap's own acknowledgement (issue 372). A
// word of one line is that alone; one with more to it is also said in the chat, where it stays.
if verb == asks.Send && m.Reply != "" && !m.Secret && c.settleTap(m.Reply, toast(m)) && strings.TrimSpace(m.Body) == "" {
c.mu.Lock()
c.lastSent, c.lastErr = c.Now(), ""
c.delivered++
c.mu.Unlock()
return nil
}
chat, err := c.chatFor(m)
if err != nil {
c.fail(m.Handle, err, true)
@@ -301,15 +328,22 @@ func (c *Channel) Handle(u Update) {
c.Logf("[telegram] a tap on a message not showing what this channel put there was refused")
return
}
// At once, whatever comes of it: the phone shows a spinner until then.
c.answerTap(cb.ID, "")
// Acknowledged with the router's word on it, which comes as a reply to this tap (Take): what was recorded,
// or why it was refused. Never before: an acknowledgement claims nothing the router has not said. If the
// router says nothing within TapWait, the tap is acknowledged with words that claim nothing, and its word
// when it comes is said in the chat (issue 372).
id := "tg-tap-" + cb.ID
c.remember(id, cb.Message.Chat.ID)
c.mu.Lock()
c.taps[id] = cb.ID
c.mu.Unlock()
ch := asks.Choice{ID: id, Ticket: cb.Data, Sender: senderOf(cb.From), At: now}
if err := c.Publish(asks.IntakeSubject(asks.Chosen, Kind), ch, id); err != nil {
c.Logf("[telegram] a tap could not be said to the router: %v", err)
_ = c.Bot.AnswerTap(cb.ID, "The mesh could not take this answer now. Try again in a minute.")
c.settleTap(id, "The mesh could not take this answer now. Nothing was done. Try again in a minute.")
return
}
c.After(TapWait, func() { c.settleTap(id, TapPending) })
case u.Message != nil:
m := u.Message
if !private(m.From, m.Chat) {
@@ -345,6 +379,37 @@ func (c *Channel) Handle(u Update) {
}
}
// settleTap acknowledges a tap still waiting, once, with words: true when it was waiting and Telegram took
// the acknowledgement. A tap already acknowledged, or one Telegram no longer takes, is false: the words are
// then for the chat.
func (c *Channel) settleTap(arrival, words string) bool {
c.mu.Lock()
cb, waiting := c.taps[arrival]
delete(c.taps, arrival)
c.mu.Unlock()
if !waiting {
return false
}
if err := c.Bot.AnswerTap(cb, words); err != nil {
c.Logf("[telegram] a tap could not be answered: %v", err)
return false
}
return true
}
// toast is a message as a tap's acknowledgement shows it: its title and the first line of its body, cut at
// ToastLength.
func toast(m asks.Message) string {
t := strings.TrimSpace(m.Title)
if first, _, _ := strings.Cut(strings.TrimSpace(m.Body), "\n"); first != "" {
t += ": " + first
}
if utf8.RuneCountInString(t) > ToastLength {
t = string([]rune(t)[:ToastLength-1]) + "…"
}
return t
}
func (c *Channel) answerTap(id, words string) {
if err := c.Bot.AnswerTap(id, words); err != nil {
c.Logf("[telegram] a tap could not be answered: %v", err)
@@ -481,7 +546,7 @@ func (c *Channel) Status() map[string]any {
out["rules"] = []string{
"it shows what the router sends and says what was chosen; whether the sender is the operator is the router's to judge, from the account linked there (novox/hq ADR 0259)",
"a button carries only the router's one-time ticket",
"only a private chat with the account itself counts; a tap is acknowledged at once",
"only a private chat with the account itself counts; a tap is acknowledged with the router's word on it — what was recorded, or why not — or, without one in 8 seconds, with words that claim nothing",
"/start asks to link this account; the code shown on the desk is checked by request and reply, never in an event, and deleted from the chat",
"updates are long-polled: outbound only, no route into the mesh",
"up to 4096 characters arrive whole; longer is cut and the cut said",
+153 -3
View File
@@ -2,6 +2,8 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
@@ -11,6 +13,7 @@ import (
"sync"
"testing"
"time"
"unicode/utf8"
"git.novox.be/novox/mesh-sdk/go/asks"
)
@@ -121,6 +124,7 @@ type said struct {
mu sync.Mutex
by map[string][]json.RawMessage
proved []json.RawMessage
later []func()
}
func (s *said) of(subject string) []json.RawMessage {
@@ -160,10 +164,26 @@ func newChannel(t *testing.T, token string) (*Channel, *fakeAPI, *said, *memStor
}
return json.RawMessage(`{"accepted":false,"words":"That code is not right."}`), nil
}}
c.After = func(d time.Duration, fn func()) {
s.mu.Lock()
s.later = append(s.later, fn)
s.mu.Unlock()
}
c.init()
return c, f, s, st
}
// waited runs what was set to happen later: the router said nothing in time.
func (s *said) waited() {
s.mu.Lock()
later := s.later
s.later = nil
s.mu.Unlock()
for _, fn := range later {
fn()
}
}
func work(verb string, m asks.Message) (string, json.RawMessage) {
raw, _ := json.Marshal(m)
return asks.ChannelSubject(verb, Kind), raw
@@ -274,9 +294,6 @@ func TestATapIsAcknowledgedAtOnceAndSaidWithItsAuthenticatedSender(t *testing.T)
shown := showQuestion(t, c, f)
c.Handle(Update{UpdateID: 1, Callback: &Callback{ID: "cb1", Data: "tkt", From: User{ID: 42, FirstName: "Jo"},
Message: &Msg{MessageID: shown, Chat: Chat{ID: 42, Type: "private"}, Text: "Release it?\nThe delivery is held."}}})
if len(f.of("answerCallbackQuery")) != 1 {
t.Error("the tap was not acknowledged")
}
got := s.of(asks.IntakeSubject(asks.Chosen, Kind))
var ch asks.Choice
if len(got) != 1 || json.Unmarshal(got[0], &ch) != nil || ch.Ticket != "tkt" ||
@@ -430,3 +447,136 @@ func TestATapOnAMessageShowingOtherWordsIsRefused(t *testing.T) {
t.Error("what an edit showed was not kept")
}
}
// tapOn taps the question showQuestion put there, as the operator.
func tapOn(c *Channel, shown int64, id string) {
c.Handle(Update{UpdateID: 1, Callback: &Callback{ID: id, Data: "tkt", From: User{ID: 42, FirstName: "Jo"},
Message: &Msg{MessageID: shown, Chat: Chat{ID: 42, Type: "private"}, Text: "Release it?\nThe delivery is held."}}})
}
// The operator taps Approve and sees at once what the router recorded (issue 372): the tap is acknowledged
// with the router's word on it, which comes as a reply to the tap — never before it, so the phone never says
// "recorded" for what the router did not record — and a word of one line goes nowhere else.
func TestATapIsAcknowledgedWithTheRoutersWordOnIt(t *testing.T) {
c, f, s, _ := newChannel(t, goodToken)
shown := showQuestion(t, c, f)
tapOn(c, shown, "cb1")
if got := f.of("answerCallbackQuery"); len(got) != 0 {
t.Fatalf("the tap was acknowledged before the router said anything: %v", got)
}
sentBefore := len(f.of("sendMessage"))
subject, body := work(asks.Send, asks.Message{Handle: "r1", Title: "You chose Release: recorded, and mesh-delivery was told.",
Silent: true, Reply: "tg-tap-cb1", To: "42"})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
got := f.of("answerCallbackQuery")
if len(got) != 1 || got[0].Body["callback_query_id"] != "cb1" ||
got[0].Body["text"] != "You chose Release: recorded, and mesh-delivery was told." {
t.Fatalf("the tap was acknowledged with %v", got)
}
if len(f.of("sendMessage")) != sentBefore {
t.Error("a word of one line was also sent to the chat")
}
// The wait running out after the word changes nothing: a tap is acknowledged once.
s.waited()
if len(f.of("answerCallbackQuery")) != 1 {
t.Error("the tap was acknowledged twice")
}
// The same word again, once the tap is acknowledged, is said in the chat.
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
if len(f.of("sendMessage")) != sentBefore+1 {
t.Error("a word for a tap already acknowledged was lost")
}
}
// A refused tap says why on the phone, and the refusal stays in the chat (issue 372).
func TestARefusedTapSaysWhyOnThePhoneAndInTheChat(t *testing.T) {
c, f, _, _ := newChannel(t, goodToken)
shown := showQuestion(t, c, f)
tapOn(c, shown, "cb1")
sentBefore := len(f.of("sendMessage"))
subject, body := work(asks.Send, asks.Message{Handle: "r1", Title: "That answer was not taken",
Body: "The question was already answered or has ended. Nothing was done.", Silent: true, Reply: "tg-tap-cb1", To: "42"})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
got := f.of("answerCallbackQuery")
if len(got) != 1 || got[0].Body["text"] != "That answer was not taken: The question was already answered or has ended. Nothing was done." {
t.Fatalf("the tap was acknowledged with %v", got)
}
if len(f.of("sendMessage")) != sentBefore+1 {
t.Error("the refusal was not kept in the chat")
}
}
// Without the router's word in time, the tap is acknowledged with words that claim nothing, and the word when
// it comes is said in the chat. A tap Telegram no longer takes an acknowledgement for is the same.
func TestATapTheRouterIsSlowOnClaimsNothingAndItsWordComesInTheChat(t *testing.T) {
c, f, s, _ := newChannel(t, goodToken)
shown := showQuestion(t, c, f)
tapOn(c, shown, "cb1")
s.waited()
got := f.of("answerCallbackQuery")
if len(got) != 1 || got[0].Body["text"] != TapPending || strings.Contains(TapPending, "record") {
t.Fatalf("a slow router's tap was acknowledged with %v", got)
}
sentBefore := len(f.of("sendMessage"))
subject, body := work(asks.Send, asks.Message{Handle: "r1", Title: "You chose Release: recorded.", Reply: "tg-tap-cb1", To: "42"})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
if sent := f.of("sendMessage"); len(sent) != sentBefore+1 || sent[len(sent)-1].Body["text"] != "You chose Release: recorded." {
t.Errorf("the late word was not said in the chat: %v", sent)
}
// Telegram refuses an acknowledgement that came too late: the word goes to the chat instead.
f.answer["answerCallbackQuery"] = func(map[string]any) (int, string) {
return 400, `{"ok":false,"error_code":400,"description":"Bad Request: query is too old and response timeout expired or query ID is invalid"}`
}
tapOn(c, shown, "cb2")
subject, body = work(asks.Send, asks.Message{Handle: "r2", Title: "You chose Release: recorded.", Reply: "tg-tap-cb2", To: "42"})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
if len(f.of("sendMessage")) != sentBefore+2 {
t.Error("a word Telegram would not take as the acknowledgement was lost")
}
}
// A tap the router cannot be told of is acknowledged at once with that, claiming nothing done; a secret
// (a link's code) is never shown as an acknowledgement.
func TestATapNotSaidToTheRouterIsSaidSoAndASecretIsNeverAToast(t *testing.T) {
c, f, _, _ := newChannel(t, goodToken)
shown := showQuestion(t, c, f)
c.Publish = func(string, any, string) error { return errors.New("the bus is away") }
tapOn(c, shown, "cb1")
got := f.of("answerCallbackQuery")
if len(got) != 1 || !strings.Contains(got[0].Body["text"].(string), "Nothing was done") {
t.Fatalf("acknowledged with %v", got)
}
c.Publish = func(string, any, string) error { return nil }
tapOn(c, shown, "cb2")
subject, body := work(asks.Send, asks.Message{Handle: "r1", Title: "Your code", Body: "123456", Secret: true,
Reply: "tg-tap-cb2", To: "42"})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
for _, a := range f.of("answerCallbackQuery") {
if strings.Contains(fmt.Sprint(a.Body["text"]), "123456") {
t.Fatal("a secret was shown as a tap's acknowledgement")
}
}
}
// The toast is the title and the first line of the body, cut at Telegram's bound.
func TestAToastIsCutAtTelegramsBound(t *testing.T) {
long := toast(asks.Message{Title: strings.Repeat("a", 300)})
if utf8.RuneCountInString(long) != ToastLength || !strings.HasSuffix(long, "…") {
t.Errorf("a long toast is %d runes", utf8.RuneCountInString(long))
}
if got := toast(asks.Message{Title: "Details: x", Body: "first\nsecond"}); got != "Details: x: first" {
t.Errorf("toast %q", got)
}
}