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
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:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user