From 35c4de7d083cf4326506d51b71f2c6fe192cbeb8 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 10 Oct 2026 14:05:41 +0200 Subject: [PATCH] messenger, telegram: a second tap on the recorded answer is said as recorded, and the fallback claims nothing (issue 372 review) The review found that a double tap while the phone's spinner turned would be answered 'That answer was not taken', reading as if the approval failed; it is now 'You already chose Approve at 13:30: recorded.' and does nothing more. The fallback toast no longer promises a change that may not come (a stranger's tap), the wait outlasts the router's 10 s wait for the controller's root word, the channel's own refusals stay in the chat too, and the store's error words stay in the journal. --- .../messenger/cmd/messenger/answers_test.go | 11 +++++- modules/messenger/cmd/messenger/asks.go | 19 +++++++++- .../messenger/cmd/messenger/feedback_test.go | 37 ++++++++++++++++++- modules/telegram/README.md | 2 +- modules/telegram/cmd/telegram/bot.go | 2 +- modules/telegram/cmd/telegram/channel.go | 16 +++++--- 6 files changed, 75 insertions(+), 12 deletions(-) diff --git a/modules/messenger/cmd/messenger/answers_test.go b/modules/messenger/cmd/messenger/answers_test.go index 4f667a10..459a230c 100644 --- a/modules/messenger/cmd/messenger/answers_test.go +++ b/modules/messenger/cmd/messenger/answers_test.go @@ -387,10 +387,17 @@ func TestAnApproveFromTheLinkedAccountIsAWarrantForTheAskerAlone(t *testing.T) { if out, _ := h.Chosen("telegram", choiceOf(release, "42", c.now())); out != "chosen" { t.Errorf("a redelivery of the same answer was acted on again: %s", out) } + // The same answer from the same account, as a new tap, is what was recorded: said so, nothing more done + // (issue 372). From another account it is refused. again := choiceOf(release, "42", c.now()) again.ID = "another" - if out, err := h.Chosen("telegram", again); err == nil || out != "refused" { - t.Errorf("a used ticket was taken: %s", out) + if out, err := h.Chosen("telegram", again); err != nil || out != "chosen" || + !strings.HasPrefix(tg.sends[len(tg.sends)-1].Title, "You already chose Release at ") { + t.Errorf("the same answer again was not said as recorded: %s %v %q", out, err, tg.sends[len(tg.sends)-1].Title) + } + stranger := choiceOf(release, "666", c.now()) + if out, err := h.Chosen("telegram", stranger); err == nil || out != "refused" { + t.Errorf("a used ticket was taken from another account: %s", out) } stop := ticketFor(tg.sends[0], "Stop") if out, err := h.Chosen("telegram", choiceOf(stop, "42", c.now())); err == nil || !strings.Contains(err.Error(), "already answered") { diff --git a/modules/messenger/cmd/messenger/asks.go b/modules/messenger/cmd/messenger/asks.go index 37c396fa..6670e369 100644 --- a/modules/messenger/cmd/messenger/asks.go +++ b/modules/messenger/cmd/messenger/asks.go @@ -755,6 +755,13 @@ func (h *Holder) confirmChoice(kind string, c asks.Choice, label, asker string, h.record(Sent{At: h.Now(), Channel: kind, Key: "(answer)", What: "confirmed " + label, Outcome: outcomeOf(err)}) } +// confirmAgain says, in reply to the same answer given again, that it was already recorded. +func (h *Holder) confirmAgain(kind string, c asks.Choice, label string, at time.Time) { + err := h.showOn(kind, Message{Title: "You already chose " + label + " at " + at.In(h.zone()).Format("15:04") + ": recorded.", + Quiet: true, Reply: c.ID}) + h.record(Sent{At: h.Now(), Channel: kind, Key: "(answer)", What: "confirmed again " + label, Outcome: outcomeOf(err)}) +} + // sweepAsks ends asks past their expiry, and tells again an asker not yet told. Called every minute. func (h *Holder) sweepAsks(now time.Time) { h.mu.Lock() @@ -865,6 +872,14 @@ func (h *Holder) judge(kind string, c asks.Choice, key string, root func(KindInf } now := h.Now() t, issued := rec.Tickets[c.Ticket] + if w := rec.Warrant; issued && rec.State == string(asks.OutcomeChosen) && w != nil && w.Option == t.Option && + w.By != nil && w.By.Kind == kind && w.By.Identity == c.Sender.Identity && (c.Sender.Authenticated || !h.Bench.Promises(kind, "verified-sender")) { + // The same answer again from the same account — a second tap while the first was being confirmed, or one + // redelivered after a restart: it is what was recorded, so it is said so, not refused as if it failed + // (issue 372). Nothing more is done. + h.confirmAgain(kind, c, w.Label, rec.Closed) + return "chosen", nil + } switch { case !issued: return h.refuseChoice(kind, c, "it belongs to no ask this router holds") @@ -948,7 +963,9 @@ func (h *Holder) judge(kind string, c asks.Choice, key string, root func(KindInf if errors.Is(err, ErrChanged) { return h.refuseChoice(kind, c, "the question was already answered") } - return h.refuseChoice(kind, c, "it could not be recorded: "+err.Error()) + // The store's own words stay in the journal: they are not the operator's to read on the phone. + h.Logf("[messenger] the answer to %s's ask %s could not be recorded: %v", rec.Asker, rec.Ask.ID, err) + return h.refuseChoice(kind, c, "it could not be recorded") } h.record(Sent{At: now, Channel: kind, Key: rec.Record, What: "answer " + o.Label, Outcome: "warrant to " + rec.Asker}) h.Logf("[messenger] %s: a warrant for %s's ask %s", w.Says(), rec.Asker, rec.Ask.ID) diff --git a/modules/messenger/cmd/messenger/feedback_test.go b/modules/messenger/cmd/messenger/feedback_test.go index 45851af6..044db8dd 100644 --- a/modules/messenger/cmd/messenger/feedback_test.go +++ b/modules/messenger/cmd/messenger/feedback_test.go @@ -136,10 +136,10 @@ func TestARefusedAnswerSaysWhyInReplyToTheTap(t *testing.T) { settle(h, c) release := ticketFor(tg.sends[0], "Release") _, _ = h.Chosen("telegram", choiceOf(release, "42", c.now())) - again := choiceOf(release, "42", c.now()) + again := choiceOf(ticketFor(tg.sends[0], "Stop"), "42", c.now()) again.ID = "tg-tap-again" if out, _ := h.Chosen("telegram", again); out != "refused" { - t.Fatalf("a used ticket was taken: %s", out) + t.Fatalf("a second answer to an answered question was taken: %s", out) } last := tg.sends[len(tg.sends)-1] if last.Reply != "tg-tap-again" || last.Title != "That answer was not taken" || @@ -150,3 +150,36 @@ func TestARefusedAnswerSaysWhyInReplyToTheTap(t *testing.T) { t.Error("the refusal carries what may not leave") } } + +// A second tap on the answer just recorded — the phone's spinner still turning, or a redelivery after a restart +// — is said as recorded, not refused as if it failed, and nothing more is done (issue 372). +func TestTheSameAnswerTappedTwiceIsSaidAsRecorded(t *testing.T) { + h, tg, _, c, store, w := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + submit(t, h, "mesh-delivery", heldAsk(c, "d-1")) + settle(h, c) + release := ticketFor(tg.sends[0], "Release") + first := choiceOf(release, "42", c.now()) + first.ID = "tg-tap-1" + if out, err := h.Chosen("telegram", first); err != nil || out != "chosen" { + t.Fatalf("%s %v", out, err) + } + edits := len(tg.edits) + second := choiceOf(release, "42", c.now()) + second.ID = "tg-tap-2" + if out, err := h.Chosen("telegram", second); err != nil || out != "chosen" { + t.Fatalf("the second tap: %s %v", out, err) + } + last := tg.sends[len(tg.sends)-1] + if last.Reply != "tg-tap-2" || last.Title != "You already chose Release at 12:00: recorded." { + t.Errorf("the second tap was answered %+v", last) + } + for _, m := range tg.sends { + if m.Title == "That answer was not taken" { + t.Errorf("a tap on the answer recorded was refused: %+v", m) + } + } + if len(w.of("mesh-delivery")) != 1 || len(tg.edits) != edits { + t.Error("the second tap did something more") + } +} diff --git a/modules/telegram/README.md b/modules/telegram/README.md index 46fc615e..8bd0a6ff 100644 --- a/modules/telegram/README.md +++ b/modules/telegram/README.md @@ -16,7 +16,7 @@ as the operator. 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 + not; without a word in 13 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. diff --git a/modules/telegram/cmd/telegram/bot.go b/modules/telegram/cmd/telegram/bot.go index 9a05a56a..757e629a 100644 --- a/modules/telegram/cmd/telegram/bot.go +++ b/modules/telegram/cmd/telegram/bot.go @@ -271,7 +271,7 @@ func (b *Bot) Edit(chat, id int64, text string, buttons [][2]string) error { return err } -// AnswerTap ends the phone's spinner for a tapped button, at once. +// AnswerTap ends the phone's spinner for a tapped button, showing words over the chat when there are any. func (b *Bot) AnswerTap(id, text string) error { body := map[string]any{"callback_query_id": id} if text != "" { diff --git a/modules/telegram/cmd/telegram/channel.go b/modules/telegram/cmd/telegram/channel.go index 31a97cf5..af5487c9 100644 --- a/modules/telegram/cmd/telegram/channel.go +++ b/modules/telegram/cmd/telegram/channel.go @@ -54,9 +54,11 @@ const ( 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 + // It is longer than the router may wait for the controller's word on root before it judges an answer + // (messenger's RootFreeWithin, 10 seconds), so a slow controller still leaves the word to the toast. + TapWait = 13 * 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." + TapPending = "Passed to the mesh; nothing is confirmed yet." // 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. @@ -324,7 +326,9 @@ func (c *Channel) Handle(u Update) { // the token changed, or one it never showed, answers nothing (the review of 2026-10-09). if want, err := c.Store.Shown(cb.Message.Chat.ID, cb.Message.MessageID); err != nil || want == "" || want != wordsDigest(cb.Message.Text) { - c.answerTap(cb.ID, "That answer was not taken: this message does not show what the mesh put there. Nothing was done.") + words := "That answer was not taken: this message does not show what the mesh put there. Nothing was done." + c.answerTap(cb.ID, words) + c.reply(cb.Message.Chat.ID, words) // a toast goes; the chat keeps why c.Logf("[telegram] a tap on a message not showing what this channel put there was refused") return } @@ -340,7 +344,9 @@ func (c *Channel) Handle(u Update) { 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.settleTap(id, "The mesh could not take this answer now. Nothing was done. Try again in a minute.") + words := "The mesh could not take this answer now. Nothing was done. Try again in a minute." + c.settleTap(id, words) + c.reply(cb.Message.Chat.ID, words) return } c.After(TapWait, func() { c.settleTap(id, TapPending) }) @@ -546,7 +552,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 with the router's word on it — what was recorded, or why not — or, without one in 8 seconds, with words that claim nothing", + "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 13 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",