The review of 2026-10-09 (M4): another holder of the token can change a message's words and leave its buttons, so the operator taps Release under words that say something else. Each message's words are now kept as a digest, per chat and message, and a tap on a message whose words differ, or that this channel never showed, is refused on the phone and never said to the router. The README says, as the operator directed (H5), that an approval from Telegram is meaningful only where agents run under their own account without sudo, without the operator's X display, and with Telegram Desktop's data readable by the operator alone; until then, approve from the phone.
433 lines
16 KiB
Go
433 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"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
|
|
}
|
|
|
|
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.init()
|
|
return c, f, s, st
|
|
}
|
|
|
|
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)
|
|
}
|
|
got := f.of("sendMessage")[0].Body["text"].(string)
|
|
if n := len([]rune(got)); n != MaxLength || !strings.HasSuffix(got, cutNote) {
|
|
t.Errorf("%d characters, ends %q (D1)", n, got[len(got)-40:])
|
|
}
|
|
}
|
|
|
|
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."}}})
|
|
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" ||
|
|
!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")
|
|
}
|
|
}
|