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"] != "Delivery of hq held\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("", "", "", "").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 & 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 := "Set mounts on <shanks> & co?\nChanges two settings.\n+ shares: media=/storage/media\n" + "~ sources: a=b (was: c)\n\n
Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc.
" 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 & 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 & 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 & 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"] != "You chose Approve at 15:06 on Telegram · Set mounts on <shanks> & co?\n\n
Told to mesh-controller, which acts on it itself.
" { t.Errorf("edited %v", ed) } // A plain answer in the chat is escaped too. c.reply(42, "a tag & 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"] != "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) } } // 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")) } }