The operator would not read the Telegram ask for ADR 0343: thirty plain lines, every clause on one, no bold, no summary. A settings proposal was the same wall of text, its values masked so the approval was blind. - messenger: an ask's message is the asker's headline and explanation alone; who asked and when, what each answer does, the expiry, where else it is answered and the asker's Details travel as the message's note (asks.Message.Note). Answered, the message is one line, "You chose Approve at 16:06 on Telegram · <headline>". An explanation that does not read at a glance (asks.Glance) is shown and logged. On a kind that proves who answers, an ask's values are shown whole (asks.Ask.Whole), held to the secret rule alone (from the earlier work on issue 383, #178). - telegram: every message in HTML, never reworded: the title bold, a key line monospace, the note apart in a collapsed blockquote; markup escaped. A tap is compared by its words, whitespace aside, and a message shown before is still compared by the digest it was kept under. - desk-channel: the note follows the body. - mesh-knowledge: a record that needs the operator carries a brief (headline, summary, at most three points), the 30th per-record rule; its ask shows the brief and never the clauses. An ask made before is composed again as it was asked (form), so its warrant still holds. - claude-code: an instructions proposal's explanation is one summary line (the proposer's, or the module's), its text in the Details; a question's context is at most 300 characters and its recommendation one point. Older proposals and questions keep their form. The SDK is pinned at mesh-sdk 9588871 (fix/639-an-ask-reads-at-a-glance), which adds Message.Note and asks.Glance.
714 lines
30 KiB
Go
714 lines
30 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
)
|
|
|
|
const goodToken = "123456789:AAEhBP0av28P4XFQnIuR-o-7Xnz1kkUzW3g"
|
|
|
|
// fakeAPI is the Bot API, answering each method as the test says and keeping what it was asked.
|
|
type fakeAPI struct {
|
|
mu sync.Mutex
|
|
calls []call
|
|
answer map[string]func(body map[string]any) (int, string)
|
|
next int64
|
|
}
|
|
|
|
type call struct {
|
|
Method string
|
|
Body map[string]any
|
|
}
|
|
|
|
func (f *fakeAPI) of(method string) []call {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var out []call
|
|
for _, c := range f.calls {
|
|
if c.Method == method {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func newAPI(t *testing.T) *fakeAPI {
|
|
t.Helper()
|
|
f := &fakeAPI{answer: map[string]func(map[string]any) (int, string){}}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
parts := strings.Split(r.URL.Path, "/")
|
|
method := parts[len(parts)-1]
|
|
if parts[1] != "bot"+goodToken {
|
|
w.WriteHeader(401)
|
|
_, _ = w.Write([]byte(`{"ok":false,"error_code":401,"description":"Unauthorized"}`))
|
|
return
|
|
}
|
|
var body map[string]any
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
f.mu.Lock()
|
|
f.calls = append(f.calls, call{method, body})
|
|
answer := f.answer[method]
|
|
f.next++
|
|
id := f.next
|
|
f.mu.Unlock()
|
|
if answer != nil {
|
|
code, out := answer(body)
|
|
w.WriteHeader(code)
|
|
_, _ = w.Write([]byte(out))
|
|
return
|
|
}
|
|
switch method {
|
|
case "sendMessage":
|
|
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":` + jsonInt(id) + `,"chat":{"id":42,"type":"private"}}}`))
|
|
case "getMe":
|
|
_, _ = w.Write([]byte(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"mesh_bot"}}`))
|
|
default:
|
|
_, _ = w.Write([]byte(`{"ok":true,"result":true}`))
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
was := API
|
|
API = srv.URL
|
|
t.Cleanup(func() { API = was })
|
|
return f
|
|
}
|
|
|
|
func jsonInt(n int64) string { b, _ := json.Marshal(n); return string(b) }
|
|
|
|
type memStore struct {
|
|
offset int64
|
|
placed map[string]Placed
|
|
shown map[[2]int64]string
|
|
}
|
|
|
|
func (m *memStore) Shown(chat, id int64) (string, error) { return m.shown[[2]int64{chat, id}], nil }
|
|
func (m *memStore) Show(chat, id int64, digest string) error {
|
|
if m.shown == nil {
|
|
m.shown = map[[2]int64]string{}
|
|
}
|
|
m.shown[[2]int64{chat, id}] = digest
|
|
return nil
|
|
}
|
|
|
|
func (m *memStore) Offset() (int64, error) { return m.offset, nil }
|
|
func (m *memStore) SetOffset(n int64) error { m.offset = n; return nil }
|
|
func (m *memStore) Place(h string, p Placed) error {
|
|
if m.placed == nil {
|
|
m.placed = map[string]Placed{}
|
|
}
|
|
m.placed[h] = p
|
|
return nil
|
|
}
|
|
func (m *memStore) Placed(h string) (*Placed, error) {
|
|
p, ok := m.placed[h]
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
return &p, nil
|
|
}
|
|
|
|
type said struct {
|
|
mu sync.Mutex
|
|
by map[string][]json.RawMessage
|
|
proved []json.RawMessage
|
|
later []func()
|
|
}
|
|
|
|
func (s *said) of(subject string) []json.RawMessage {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.by[subject]
|
|
}
|
|
|
|
func newChannel(t *testing.T, token string) (*Channel, *fakeAPI, *said, *memStore) {
|
|
t.Helper()
|
|
f := newAPI(t)
|
|
file := filepath.Join(t.TempDir(), "telegram-token")
|
|
if token != "" {
|
|
if err := os.WriteFile(file, []byte(token+"\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
s := &said{by: map[string][]json.RawMessage{}}
|
|
st := &memStore{}
|
|
now := time.Date(2026, 10, 8, 14, 0, 0, 0, time.UTC)
|
|
c := &Channel{Bot: &Bot{TokenFile: file}, Store: st, Now: func() time.Time { return now }, Logf: t.Logf,
|
|
Sleep: func(time.Duration) {},
|
|
Publish: func(subject string, body any, id string) error {
|
|
raw, _ := json.Marshal(body)
|
|
s.mu.Lock()
|
|
s.by[subject] = append(s.by[subject], raw)
|
|
s.mu.Unlock()
|
|
return nil
|
|
},
|
|
Prove: func(subject string, body any) (json.RawMessage, error) {
|
|
raw, _ := json.Marshal(body)
|
|
s.mu.Lock()
|
|
s.proved = append(s.proved, raw)
|
|
s.mu.Unlock()
|
|
if strings.Contains(string(raw), `"code":"123456"`) {
|
|
return json.RawMessage(`{"accepted":true,"words":"Linked."}`), nil
|
|
}
|
|
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
|
|
}
|
|
|
|
func TestAQuestionIsShownWithItsTicketsAndRingsAndAWarningIsSilent(t *testing.T) {
|
|
c, f, _, st := newChannel(t, goodToken)
|
|
subject, body := work(asks.Show, asks.Message{Handle: "m1", Title: "Delivery of hq held", Body: "Needs you: release it.",
|
|
To: "42", Buttons: []asks.Button{{Label: "Release", Ticket: "0123456789abcdef0123456789abcdef"}}})
|
|
if err := c.Take(subject, body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sent := f.of("sendMessage")
|
|
if len(sent) != 1 {
|
|
t.Fatalf("sent %d", len(sent))
|
|
}
|
|
b := sent[0].Body
|
|
kb, _ := json.Marshal(b["reply_markup"])
|
|
if b["chat_id"] != float64(42) || b["text"] != "<b>Delivery of hq held</b>\nNeeds you: release it." || b["disable_notification"] != false ||
|
|
string(kb) != `{"inline_keyboard":[[{"callback_data":"0123456789abcdef0123456789abcdef","text":"Release"}]]}` {
|
|
t.Errorf("sent %v %s", b, kb)
|
|
}
|
|
if _, deprecated := b["disable_web_page_preview"]; deprecated {
|
|
t.Error("the deprecated preview flag is still sent (D7)")
|
|
}
|
|
if st.placed["m1"].ID == 0 {
|
|
t.Error("where it is was not kept")
|
|
}
|
|
subject, body = work(asks.Send, asks.Message{Handle: "m2", Title: "Resolved: hq works again", To: "42"})
|
|
_ = c.Take(subject, body)
|
|
if f.of("sendMessage")[1].Body["disable_notification"] != true {
|
|
t.Error("a clearing rang (D4)")
|
|
}
|
|
subject, body = work(asks.Send, asks.Message{Handle: "m3", Title: "Urgent: ace silent", Urgent: true, To: "42"})
|
|
_ = c.Take(subject, body)
|
|
if f.of("sendMessage")[2].Body["disable_notification"] != false {
|
|
t.Error("an urgent message was silent (D4)")
|
|
}
|
|
}
|
|
|
|
func TestALongMessageIsCutAndTheCutSaid(t *testing.T) {
|
|
c, f, _, _ := newChannel(t, goodToken)
|
|
subject, body := work(asks.Send, asks.Message{Handle: "m1", Title: "t", Body: strings.Repeat("x", 5000), To: "42"})
|
|
if err := c.Take(subject, body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The bound is on the words, as Telegram's is on the text once its markup is read: the tags do not count.
|
|
got := strings.NewReplacer("<b>", "", "</b>", "").Replace(f.of("sendMessage")[0].Body["text"].(string))
|
|
got = strings.ReplaceAll(got, ">", ">")
|
|
if n := len([]rune(got)); n != MaxLength || !strings.HasSuffix(got, cutNote) {
|
|
t.Errorf("%d characters, ends %q (D1)", n, got[len(got)-40:])
|
|
}
|
|
}
|
|
|
|
// A message is shown formatted, never reworded (issues 383 and 639): the title bold, a key line in monospace, the
|
|
// note apart in a collapsed blockquote, and every '<', '>' and '&' escaped, so a message carrying one neither breaks
|
|
// nor injects. The tap on it is still compared by the plain words, which is what Telegram gives back.
|
|
func TestAMessageIsRenderedInHTMLWithItsMarkupEscaped(t *testing.T) {
|
|
c, f, s, _ := newChannel(t, goodToken)
|
|
subject, body := work(asks.Show, asks.Message{Handle: "q1", Title: "Set mounts on <shanks> & co?",
|
|
Body: "Changes two settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)",
|
|
Note: "Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc.",
|
|
To: "42", Buttons: []asks.Button{{Label: "Approve", Ticket: "tkt"}}})
|
|
if err := c.Take(subject, body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sent := f.of("sendMessage")
|
|
want := "<b>Set mounts on <shanks> & co?</b>\nChanges two settings.\n<code>+ shares: media=/storage/media</code>\n" +
|
|
"<code>~ sources: a=b (was: c)</code>\n\n<blockquote expandable>Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc.</blockquote>"
|
|
if len(sent) != 1 || sent[0].Body["text"] != want || sent[0].Body["parse_mode"] != "HTML" {
|
|
t.Fatalf("sent %v", sent)
|
|
}
|
|
f.mu.Lock()
|
|
shown := f.next
|
|
f.mu.Unlock()
|
|
// Telegram gives the words back plain, the space around the blockquote as it pleases: the tap on them is taken.
|
|
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: "Set mounts on <shanks> & co?\nChanges two settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)\n" +
|
|
"Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc."}}})
|
|
if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 {
|
|
t.Fatalf("the tap on the rendered message was not taken: %s", got)
|
|
}
|
|
// Words changed by another holder of the token are still told apart.
|
|
c.Handle(Update{UpdateID: 2, Callback: &Callback{ID: "cb2", Data: "tkt", From: User{ID: 42, FirstName: "Jo"},
|
|
Message: &Msg{MessageID: shown, Chat: Chat{ID: 42, Type: "private"},
|
|
Text: "Set mounts on <shanks> & co?\nChanges three settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)\n" +
|
|
"Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc."}}})
|
|
if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 {
|
|
t.Fatalf("a tap on changed words was taken: %s", got)
|
|
}
|
|
// The edit says what was chosen, one line with the headline, and is in the same mode.
|
|
subject, body = work(asks.Edit, asks.Message{Handle: "q1", Title: "You chose Approve at 15:06 on Telegram · Set mounts on <shanks> & co?",
|
|
Note: "Told to mesh-controller, which acts on it itself.", To: "42"})
|
|
if err := c.Take(subject, body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ed := f.of("editMessageText")
|
|
if len(ed) != 1 || ed[0].Body["parse_mode"] != "HTML" ||
|
|
ed[0].Body["text"] != "<b>You chose Approve at 15:06 on Telegram · Set mounts on <shanks> & co?</b>\n\n<blockquote expandable>Told to mesh-controller, which acts on it itself.</blockquote>" {
|
|
t.Errorf("edited %v", ed)
|
|
}
|
|
// A plain answer in the chat is escaped too.
|
|
c.reply(42, "a <b>tag</b> & more")
|
|
if last := f.of("sendMessage"); last[len(last)-1].Body["text"] != "a <b>tag</b> & more" {
|
|
t.Errorf("a reply was not escaped: %v", last[len(last)-1].Body["text"])
|
|
}
|
|
}
|
|
|
|
// A message shown before issue 639 was kept under the digest of its lines: a tap on it is still taken.
|
|
func TestATapOnAMessageShownBeforeIsStillTaken(t *testing.T) {
|
|
c, _, s, st := newChannel(t, goodToken)
|
|
words := "Delivery held\nRelease or Stop."
|
|
if err := st.Show(42, 9, linesDigest(words)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c.Handle(Update{UpdateID: 1, Callback: &Callback{ID: "cb1", Data: "tkt", From: User{ID: 42, FirstName: "Jo"},
|
|
Message: &Msg{MessageID: 9, Chat: Chat{ID: 42, Type: "private"}, Text: words}}})
|
|
if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 {
|
|
t.Fatalf("the tap on a message shown before was not taken: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestAnEditToTheSameTextIsASuccessAndAGoneMessageIsSentAgain(t *testing.T) {
|
|
c, f, s, st := newChannel(t, goodToken)
|
|
st.Place("m1", Placed{Chat: 42, ID: 7})
|
|
f.answer["editMessageText"] = func(map[string]any) (int, string) {
|
|
return 400, `{"ok":false,"error_code":400,"description":"Bad Request: message is not modified"}`
|
|
}
|
|
subject, body := work(asks.Edit, asks.Message{Handle: "m1", Title: "same", To: "42"})
|
|
if err := c.Take(subject, body); err != nil || len(f.of("sendMessage")) != 0 || len(s.of(asks.IntakeSubject(asks.FailedWhat, Kind))) != 0 {
|
|
t.Fatalf("an identical edit was not a success (D6): %v", err)
|
|
}
|
|
f.answer["editMessageText"] = func(map[string]any) (int, string) {
|
|
return 400, `{"ok":false,"error_code":400,"description":"Bad Request: message to edit not found"}`
|
|
}
|
|
_ = c.Take(subject, body)
|
|
if len(f.of("sendMessage")) != 1 {
|
|
t.Error("an edit of a message gone was not sent again")
|
|
}
|
|
if kb, _ := json.Marshal(f.of("editMessageText")[0].Body["reply_markup"]); string(kb) != `{"inline_keyboard":[]}` {
|
|
t.Errorf("an edit without buttons left them: %s", kb)
|
|
}
|
|
}
|
|
|
|
func TestARefusalIsPermanentARateLimitIsWaitedOutAndNeitherCarriesTheToken(t *testing.T) {
|
|
c, f, s, _ := newChannel(t, goodToken)
|
|
tries := 0
|
|
var slept []time.Duration
|
|
c.Sleep = func(d time.Duration) { slept = append(slept, d) }
|
|
f.answer["sendMessage"] = func(map[string]any) (int, string) {
|
|
tries++
|
|
if tries == 1 {
|
|
return 429, `{"ok":false,"error_code":429,"description":"Too Many Requests","parameters":{"retry_after":7}}`
|
|
}
|
|
return 403, `{"ok":false,"error_code":403,"description":"Forbidden: bot was blocked by the user ` + goodToken + `"}`
|
|
}
|
|
subject, body := work(asks.Send, asks.Message{Handle: "m1", Title: "x", To: "42"})
|
|
if err := c.Take(subject, body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(slept) != 1 || slept[0] != 7*time.Second || tries != 2 {
|
|
t.Errorf("the rate limit was not waited out as told (D5): slept %v, tries %d", slept, tries)
|
|
}
|
|
failed := s.of(asks.IntakeSubject(asks.FailedWhat, Kind))
|
|
if len(failed) != 1 || !strings.Contains(string(failed[0]), `"permanent":true`) || strings.Contains(string(failed[0]), goodToken) {
|
|
t.Fatalf("the refusal: %s (D6)", failed)
|
|
}
|
|
// No linked account and nothing to reply to: said as failed for good, never retried.
|
|
subject, body = work(asks.Send, asks.Message{Handle: "m2", Title: "x"})
|
|
_ = c.Take(subject, body)
|
|
if f := s.of(asks.IntakeSubject(asks.FailedWhat, Kind)); len(f) != 2 || !strings.Contains(string(f[1]), "no account is linked") {
|
|
t.Errorf("%s", f)
|
|
}
|
|
}
|
|
|
|
func TestATapIsAcknowledgedAtOnceAndSaidWithItsAuthenticatedSender(t *testing.T) {
|
|
c, f, s, _ := newChannel(t, goodToken)
|
|
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."}}})
|
|
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" ||
|
|
!reflect.DeepEqual(ch.Sender, asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}) {
|
|
t.Fatalf("said %s", got)
|
|
}
|
|
// In a group, or from another bot, nothing is said: it is nobody's own chat with the bot.
|
|
c.Handle(Update{UpdateID: 2, Callback: &Callback{ID: "cb2", Data: "tkt", From: User{ID: 42},
|
|
Message: &Msg{Chat: Chat{ID: -100, Type: "group"}}}})
|
|
c.Handle(Update{UpdateID: 3, Callback: &Callback{ID: "cb3", Data: "tkt", From: User{ID: 9, IsBot: true},
|
|
Message: &Msg{Chat: Chat{ID: 9, Type: "private"}}}})
|
|
if len(s.of(asks.IntakeSubject(asks.Chosen, Kind))) != 1 {
|
|
t.Error("a tap outside the account's own chat was said")
|
|
}
|
|
}
|
|
|
|
func TestStartAsksALinkAndACodeIsAProofDeletedFromTheChat(t *testing.T) {
|
|
c, f, s, _ := newChannel(t, goodToken)
|
|
jo := &User{ID: 42, FirstName: "Jo", Username: "jo"}
|
|
c.Handle(Update{UpdateID: 10, Message: &Msg{MessageID: 5, From: jo, Chat: Chat{ID: 42, Type: "private"}, Text: "/start"}})
|
|
var l asks.LinkAsked
|
|
got := s.of(asks.IntakeSubject(asks.Link, Kind))
|
|
if len(got) != 1 || json.Unmarshal(got[0], &l) != nil || l.Sender.Identity != "42" || l.Sender.Display != "Jo (@jo)" {
|
|
t.Fatalf("link %s", got)
|
|
}
|
|
// The router's answer to the link goes to the chat it came from, though nobody is linked yet.
|
|
subject, body := work(asks.Send, asks.Message{Handle: "r1", Title: "Send the code shown on your desk", Reply: l.ID})
|
|
if err := c.Take(subject, body); err != nil || len(f.of("sendMessage")) != 1 || f.of("sendMessage")[0].Body["chat_id"] != float64(42) {
|
|
t.Fatalf("the reply did not reach the chat: %v %v", err, f.of("sendMessage"))
|
|
}
|
|
c.Handle(Update{UpdateID: 11, Message: &Msg{MessageID: 6, From: jo, Chat: Chat{ID: 42, Type: "private"}, Text: "123 456"}})
|
|
if len(s.proved) != 1 || !strings.Contains(string(s.proved[0]), `"code":"123456"`) {
|
|
t.Fatalf("the code was not asked as a proof: %s", s.proved)
|
|
}
|
|
for subject, events := range s.by {
|
|
for _, e := range events {
|
|
if strings.Contains(string(e), "123456") {
|
|
t.Errorf("the code was said on %s", subject)
|
|
}
|
|
}
|
|
}
|
|
if d := f.of("deleteMessage"); len(d) != 1 || d[0].Body["message_id"] != float64(6) {
|
|
t.Error("the code was not deleted from the chat")
|
|
}
|
|
if last := f.of("sendMessage"); last[len(last)-1].Body["text"] != "Linked." {
|
|
t.Errorf("the router's answer was not said: %v", last[len(last)-1].Body)
|
|
}
|
|
c.Handle(Update{UpdateID: 12, Message: &Msg{MessageID: 7, From: jo, Chat: Chat{ID: 42, Type: "private"}, Text: "release it"}})
|
|
if last := f.of("sendMessage"); last[len(last)-1].Body["text"] != FixedLine {
|
|
t.Error("other words were not answered with the fixed line")
|
|
}
|
|
if c.Store.(*memStore).offset != 0 {
|
|
t.Error("the offset moved without a poll")
|
|
}
|
|
}
|
|
|
|
func TestStandingSaysWhatIsMissingAndNeverTheToken(t *testing.T) {
|
|
for token, want := range map[string]string{
|
|
"": "no bot token",
|
|
"a-random-value-x": "not a bot token",
|
|
"987654321:" + strings.Repeat("Z", 35): "does not work",
|
|
} {
|
|
c, _, _, _ := newChannel(t, token)
|
|
s := c.Standing()
|
|
if s.Ready || !strings.Contains(s.Why, want) || (token != "" && strings.Contains(s.Why, token)) {
|
|
t.Errorf("%q: %+v", token, s)
|
|
}
|
|
}
|
|
c, _, said, _ := newChannel(t, goodToken)
|
|
if s := c.Standing(); !s.Ready || !s.EditsSilently {
|
|
t.Errorf("%+v", s)
|
|
}
|
|
c.SayStanding()
|
|
c.SayStanding()
|
|
if n := len(said.of(asks.IntakeSubject(asks.StandingWhat, Kind))); n != 1 {
|
|
t.Errorf("standing said %d times without a change", n)
|
|
}
|
|
}
|
|
|
|
func TestPollTakesTheUpdatesAndKeepsTheOffsetAndSaysAConflict(t *testing.T) {
|
|
c, f, s, st := newChannel(t, goodToken)
|
|
f.answer["getUpdates"] = func(map[string]any) (int, string) {
|
|
return 200, `{"ok":true,"result":[{"update_id":41,"message":{"message_id":1,"from":{"id":42},"chat":{"id":42,"type":"private"},"text":"/start"}}]}`
|
|
}
|
|
if err := c.Poll(time.Second); err != nil || st.offset != 42 || len(s.of(asks.IntakeSubject(asks.Link, Kind))) != 1 {
|
|
t.Fatalf("%v offset %d", err, st.offset)
|
|
}
|
|
f.answer["getUpdates"] = func(map[string]any) (int, string) {
|
|
return 409, `{"ok":false,"error_code":409,"description":"Conflict: terminated by other getUpdates request"}`
|
|
}
|
|
if err := c.Poll(time.Second); err == nil || !strings.Contains(err.Error(), "another program takes this bot's updates") {
|
|
t.Errorf("%v", err)
|
|
}
|
|
if s := c.Standing(); s.Ready {
|
|
t.Error("ready while the updates do not arrive")
|
|
}
|
|
}
|
|
|
|
// showQuestion shows one question with a button to the linked account, and answers its message's id.
|
|
func showQuestion(t *testing.T, c *Channel, f *fakeAPI) int64 {
|
|
t.Helper()
|
|
subject, body := work(asks.Show, asks.Message{Handle: "q1", Title: "Release it?", Body: "The delivery is held.",
|
|
To: "42", Buttons: []asks.Button{{Label: "Release", Ticket: "tkt"}}})
|
|
if err := c.Take(subject, body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sent := f.of("sendMessage")
|
|
if len(sent) == 0 {
|
|
t.Fatal("the question was not sent")
|
|
}
|
|
f.mu.Lock()
|
|
id := f.next
|
|
f.mu.Unlock()
|
|
return id
|
|
}
|
|
|
|
// A tap is said only on a message showing exactly what this channel put there (the review of 2026-10-09): a
|
|
// message whose words were changed by another holder of the token — "Release" under other words — or one
|
|
// this channel never showed, is refused on the phone and never reaches the router.
|
|
func TestATapOnAMessageShowingOtherWordsIsRefused(t *testing.T) {
|
|
c, f, s, st := newChannel(t, goodToken)
|
|
shown := showQuestion(t, c, f)
|
|
tap := func(id string, msg int64, words string) {
|
|
c.Handle(Update{UpdateID: 1, Callback: &Callback{ID: id, Data: "tkt", From: User{ID: 42, FirstName: "Jo"},
|
|
Message: &Msg{MessageID: msg, Chat: Chat{ID: 42, Type: "private"}, Text: words}}})
|
|
}
|
|
tap("cb-other", shown, "Silence it for a week?\nNothing changes.")
|
|
tap("cb-unknown", shown+100, "Release it?\nThe delivery is held.")
|
|
if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 0 {
|
|
t.Fatalf("a tap on other words was said: %s", got)
|
|
}
|
|
answers := f.of("answerCallbackQuery")
|
|
if len(answers) != 2 || !strings.Contains(answers[0].Body["text"].(string), "not") {
|
|
t.Errorf("the phone was not told: %v", answers)
|
|
}
|
|
// The same words, as Telegram gives them back (its line endings, its outer spaces), are taken.
|
|
tap("cb-same", shown, " Release it?\r\nThe delivery is held. ")
|
|
if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 {
|
|
t.Fatalf("the tap on the words shown was not said: %s", got)
|
|
}
|
|
// An edit moves what was shown: the old words are then refused.
|
|
subject, body := work(asks.Edit, asks.Message{Handle: "q1", Title: "Released", Body: "by you", To: "42"})
|
|
if err := c.Take(subject, body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tap("cb-old", shown, "Release it?\nThe delivery is held.")
|
|
if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 {
|
|
t.Errorf("a tap on words since replaced was said")
|
|
}
|
|
if d, _ := st.Shown(42, shown); d == "" {
|
|
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"] != "<b>You chose Release: recorded.</b>" {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Issue 373: a reminder or an escalation is sent under the handle of the message it follows, and replaces it:
|
|
// the earlier one leaves the chat and the new one rings as its kind rings. One the service will not delete
|
|
// loses its buttons. A new handle is a new message, and nothing is deleted.
|
|
func TestAMessageSaidAgainUnderItsHandleReplacesTheEarlierOne(t *testing.T) {
|
|
c, f, _, st := newChannel(t, goodToken)
|
|
subject, body := work(asks.Show, asks.Message{Handle: "m1", Title: "Urgent: ace silent", Urgent: true, To: "42",
|
|
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "0123456789abcdef0123456789abcdef"}}})
|
|
if err := c.Take(subject, body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first := st.placed["m1"].ID
|
|
subject, body = work(asks.Send, asks.Message{Handle: "m2", Title: "another problem", To: "42"})
|
|
_ = c.Take(subject, body)
|
|
if len(f.of("deleteMessage")) != 0 {
|
|
t.Fatal("a new handle deleted a message")
|
|
}
|
|
subject, body = work(asks.Show, asks.Message{Handle: "m1", Title: "Still open: ace silent", Urgent: true, To: "42",
|
|
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "fedcba9876543210fedcba9876543210"}}})
|
|
if err := c.Take(subject, body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
del := f.of("deleteMessage")
|
|
if len(del) != 1 || del[0].Body["message_id"] != float64(first) {
|
|
t.Fatalf("the earlier message was not deleted: %+v", del)
|
|
}
|
|
sent := f.of("sendMessage")
|
|
if len(sent) != 3 || sent[2].Body["disable_notification"] != false || st.placed["m1"].ID == first {
|
|
t.Fatalf("the reminder was not sent ringing in its place: %d sends, placed %d", len(sent), st.placed["m1"].ID)
|
|
}
|
|
// The service refuses the delete: the earlier message loses its buttons instead.
|
|
f.answer["deleteMessage"] = func(map[string]any) (int, string) {
|
|
return 400, `{"ok":false,"error_code":400,"description":"Bad Request: message can't be deleted"}`
|
|
}
|
|
second := st.placed["m1"].ID
|
|
subject, body = work(asks.Send, asks.Message{Handle: "m1", Title: "Now urgent: ace silent", Urgent: true, To: "42"})
|
|
_ = c.Take(subject, body)
|
|
ed := f.of("editMessageText")
|
|
if len(ed) != 1 || ed[0].Body["message_id"] != float64(second) || !strings.Contains(ed[0].Body["text"].(string), "Replaced") {
|
|
t.Fatalf("an undeletable earlier message kept its buttons: %+v", ed)
|
|
}
|
|
if kb, _ := json.Marshal(ed[0].Body["reply_markup"]); string(kb) != `{"inline_keyboard":[]}` {
|
|
t.Errorf("its buttons stayed: %s", kb)
|
|
}
|
|
}
|
|
|
|
// The review of PR 176: the new message is sent before the earlier one is deleted, so a send that fails leaves
|
|
// the operator the earlier message rather than none.
|
|
func TestAReplacementThatCannotBeSentLeavesTheEarlierMessage(t *testing.T) {
|
|
c, f, _, st := newChannel(t, goodToken)
|
|
st.Place("m1", Placed{Chat: 42, ID: 7})
|
|
f.answer["sendMessage"] = func(map[string]any) (int, string) {
|
|
return 400, `{"ok":false,"error_code":400,"description":"Bad Request: chat not found"}`
|
|
}
|
|
subject, body := work(asks.Send, asks.Message{Handle: "m1", Title: "Still open: ace silent", Urgent: true, To: "42"})
|
|
_ = c.Take(subject, body)
|
|
if len(f.of("deleteMessage")) != 0 || st.placed["m1"].ID != 7 {
|
|
t.Fatalf("the earlier message was taken away though its replacement was not sent: %+v", f.of("deleteMessage"))
|
|
}
|
|
}
|