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
+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)
}
}