]> git.giacomolli.uk - onechat/blob - telegram_test.go
Render Markdown with plain terminal styles
[onechat] / telegram_test.go
1 package main
2
3 import (
4         "net/http"
5         "os"
6         "path/filepath"
7         "slices"
8         "strconv"
9         "strings"
10         "testing"
11         "time"
12
13         "github.com/stretchr/testify/require"
14 )
15
16 func TestOwnerTalksToTheAgentThroughTelegram(t *testing.T) {
17         server := newModelFake(t)
18         server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
19                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Hello from the agent.")}, 100, 0, 5))
20         }
21
22         telegram := newTelegramFake(t)
23         bot, _ := telegramStartTestBot(t, telegram)
24         socket := daemonStartTestWithBot(t, server, bot)
25         terminal := terminalStartTestClient(t, socket)
26         terminal.WaitFor(t, "[connected to OneChat]")
27
28         telegram.Add(TelegramUpdate{
29                 UpdateID: 1,
30                 Message:  telegramOwnerMessage("hi from the phone"),
31         })
32
33         terminal.WaitFor(t, "[telegram] hi from the phone\n[working]\nHello from the agent.\n[done]")
34         telegramWaitForSent(t, telegram, "Hello from the agent.")
35         require.True(t, slices.ContainsFunc(telegram.Calls(), func(call TelegramFakeCall) bool {
36                 return call.method == "sendChatAction" && call.params["action"] == "typing"
37         }))
38
39         terminal.Type("hi from the desk")
40         require.Equal(t, []string{"Hello from the agent.", "[terminal] hi from the desk", "Hello from the agent."}, telegramWaitForSent(t, telegram, "Hello from the agent.", "[terminal] hi from the desk", "Hello from the agent."))
41 }
42
43 func TestBotIgnoresEveryoneButTheOwner(t *testing.T) {
44         telegram := newTelegramFake(t)
45         bot, logs := telegramStartTestBot(t, telegram)
46         socket := daemonStartTestWithBot(t, newModelFake(t), bot)
47         terminal := terminalStartTestClient(t, socket)
48         terminal.WaitFor(t, "[connected to OneChat]")
49
50         group := telegramOwnerMessage("secret in a group")
51         group.Chat = TelegramChat{
52                 ID:   -100,
53                 Type: "group",
54         }
55
56         stranger := telegramOwnerMessage("secret of a stranger")
57         stranger.Chat.ID = 77
58         stranger.From.ID = 77
59
60         unsigned := telegramOwnerMessage("secret with no sender")
61         unsigned.From = nil
62
63         for id, message := range []*TelegramMessage{group, stranger, unsigned, nil} {
64                 telegram.Add(TelegramUpdate{
65                         UpdateID: int64(10 + id),
66                         Message:  message,
67                 })
68         }
69
70         require.Eventually(t, func() bool {
71                 offset, err := telegramLoadOffset(bot.offsetPath)
72                 return err == nil && offset == 14
73         }, 10*time.Second, time.Millisecond)
74
75         require.Empty(t, telegram.Sent())
76         require.NotContains(t, terminal.Output(), "secret")
77         require.NotContains(t, logs.String(), "secret")
78         require.Contains(t, logs.String(), "<6>message ignored")
79 }
80
81 func TestBotAnswersThatOnlyTextIsSupported(t *testing.T) {
82         telegram := newTelegramFake(t)
83         bot, _ := telegramStartTestBot(t, telegram)
84         daemonStartTestWithBot(t, newModelFake(t), bot)
85
86         telegram.Add(TelegramUpdate{
87                 UpdateID: 1,
88                 Message:  telegramOwnerMessage(""),
89         })
90
91         telegramWaitForSent(t, telegram, telegramTextOnly)
92 }
93
94 func TestStopFromTelegramInterruptsTheTurn(t *testing.T) {
95         server := newModelFake(t)
96         arrived := make(chan struct{})
97         release := make(chan struct{})
98         t.Cleanup(func() {
99                 close(release)
100         })
101
102         server.reply = func(_ http.ResponseWriter, _ ModelFakeCall) {
103                 close(arrived)
104                 <-release
105         }
106
107         telegram := newTelegramFake(t)
108         bot, _ := telegramStartTestBot(t, telegram)
109         daemonStartTestWithBot(t, server, bot)
110
111         telegram.Add(TelegramUpdate{
112                 UpdateID: 1,
113                 Message:  telegramOwnerMessage("a long task"),
114         })
115
116         <-arrived
117         telegram.Add(TelegramUpdate{
118                 UpdateID: 2,
119                 Message:  telegramOwnerMessage(stopCommand),
120         })
121
122         telegramWaitForSent(t, telegram, "Error: interrupted")
123
124         telegram.Add(TelegramUpdate{
125                 UpdateID: 3,
126                 Message:  telegramOwnerMessage(stopCommand),
127         })
128
129         telegramWaitForSent(t, telegram, "Error: interrupted", "No turn runs.")
130
131         telegram.Add(TelegramUpdate{
132                 UpdateID: 4,
133                 Message:  telegramOwnerMessage(stopCommand + " BlueFox"),
134         })
135
136         telegramWaitForSent(t, telegram, "No turn runs.", "No subagent BlueFox runs.")
137 }
138
139 func TestBotShowsOnlyTheStartAndTheEndOfASubagent(t *testing.T) {
140         telegram := newTelegramFake(t)
141         server := newModelFake(t)
142         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
143                 last := subagentTestLastItem(call)
144                 switch {
145                 case subagentTestCall(call):
146                         // The report comes after the reply of the turn of the spawn.
147                         require.Eventually(t, func() bool {
148                                 return slices.Contains(telegram.Sent(), "Started.")
149                         }, 10*time.Second, time.Millisecond)
150                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("There are 3 files.")}, 100, 0, 5))
151                 case last["type"] == "function_call_output":
152                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Started.")}, 100, 0, 5))
153                 case strings.HasPrefix(subagentTestText(last), "["):
154                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("It counted 3 files.")}, 100, 0, 5))
155                 default:
156                         modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "spawn", `{"tasks":["Count the files."],"effort":null}`)}, 100, 0, 5))
157                 }
158         }
159
160         bot, _ := telegramStartTestBot(t, telegram)
161         daemonStartTestWithBot(t, server, bot)
162         telegram.Add(TelegramUpdate{
163                 UpdateID: 1,
164                 Message:  telegramOwnerMessage("Count the files in a subagent."),
165         })
166
167         require.Eventually(t, func() bool {
168                 return slices.Contains(telegram.Sent(), "It counted 3 files.")
169         }, 10*time.Second, time.Millisecond)
170
171         sent := telegram.Sent()
172         name := strings.TrimSuffix(strings.TrimPrefix(sent[0], "["), "] started")
173         require.True(t, subagentNameValid(name), sent[0])
174         require.Equal(t, []string{
175                 "[" + name + "] started",
176                 "Started.",
177                 "[" + name + "] finished",
178                 "It counted 3 files.",
179         }, sent)
180 }
181
182 func TestBotSkipsTheWorkOfWhatsAppAndTheSchedules(t *testing.T) {
183         bot := &TelegramBot{
184                 ready: make(chan struct{}, 1),
185         }
186
187         bot.Notify(Event{
188                 Kind:   "work",
189                 Source: whatsappSource,
190                 Text:   "[WhatsApp] Ana Souza",
191         })
192         bot.Notify(Event{
193                 Kind:   "work",
194                 Source: scheduleSource,
195                 Text:   "[Schedule 1] Sunday 2026-11-08 20:05 -03:00, once",
196         })
197         require.Empty(t, bot.queue)
198 }
199
200 func TestBotStartsAtTheStoredOffset(t *testing.T) {
201         server := newModelFake(t)
202         server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
203                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5))
204         }
205
206         telegram := newTelegramFake(t)
207         bot, _ := telegramStartTestBot(t, telegram)
208         require.NoError(t, telegramSaveOffset(bot.offsetPath, 10))
209
210         telegram.Add(TelegramUpdate{
211                 UpdateID: 9,
212                 Message:  telegramOwnerMessage("handled before the restart"),
213         })
214
215         telegram.Add(TelegramUpdate{
216                 UpdateID: 10,
217                 Message:  telegramOwnerMessage("new after the restart"),
218         })
219
220         socket := daemonStartTestWithBot(t, server, bot)
221         terminal := terminalStartTestClient(t, socket)
222         terminal.WaitFor(t, "[connected to OneChat]")
223         telegramWaitForSent(t, telegram, "ok")
224
225         require.InDelta(t, float64(10), telegram.Calls()[0].params["offset"], 0)
226         require.NotContains(t, terminal.Output(), "handled before the restart")
227
228         require.Eventually(t, func() bool {
229                 offset, err := telegramLoadOffset(bot.offsetPath)
230                 return err == nil && offset == 11
231         }, 10*time.Second, time.Millisecond)
232 }
233
234 func TestBotTriesAgainAfterFailures(t *testing.T) {
235         server := newModelFake(t)
236         server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
237                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5))
238         }
239
240         telegram := newTelegramFake(t)
241         failures := map[string]int{
242                 "getUpdates":      2,
243                 "sendRichMessage": 1,
244         }
245
246         telegram.fail = func(call TelegramFakeCall) (int, string) {
247                 if failures[call.method] == 0 {
248                         return 0, ""
249                 }
250
251                 failures[call.method]--
252                 if call.method == "sendRichMessage" {
253                         return http.StatusTooManyRequests, "Too Many Requests: retry after 1"
254                 }
255
256                 return http.StatusBadGateway, "Bad Gateway"
257         }
258
259         bot, logs := telegramStartTestBot(t, telegram)
260         daemonStartTestWithBot(t, server, bot)
261
262         telegram.Add(TelegramUpdate{
263                 UpdateID: 1,
264                 Message:  telegramOwnerMessage("hello"),
265         })
266
267         telegramWaitForSent(t, telegram, "ok")
268         require.Contains(t, logs.String(), "<4>receive failed retry=1ms: 502")
269         require.Contains(t, logs.String(), "<6>receive back after=")
270         require.Contains(t, logs.String(), "<4>send failed retry=1s: 429")
271         require.Contains(t, logs.String(), "<6>send back after=")
272         require.NotContains(t, logs.String(), telegramTestToken)
273 }
274
275 func TestDeliverTriesAgainAfterATimeout(t *testing.T) {
276         telegram := newTelegramFake(t)
277         stalls := 1
278         telegram.stall = func(call TelegramFakeCall) bool {
279                 if call.method != "sendMessage" || stalls == 0 {
280                         return false
281                 }
282
283                 stalls--
284
285                 return true
286         }
287
288         bot, logs := telegramStartTestBot(t, telegram)
289         bot.api.sendTimeout = 50 * time.Millisecond
290         bot.deliver(t.Context(), TelegramPart{
291                 text: "hello",
292         })
293
294         require.Equal(t, []string{"hello"}, telegram.Sent())
295         require.Len(t, telegram.Calls(), 2)
296         require.Contains(t, logs.String(), "<4>send failed retry=1ms: no answer in 50ms\n")
297         require.Contains(t, logs.String(), "<6>send back after=")
298         require.Equal(t, 2, strings.Count(logs.String(), "\n"))
299         require.NotContains(t, logs.String(), telegramTestToken)
300 }
301
302 func TestRepliesGoAsRichMessages(t *testing.T) {
303         reply := "**Done.** The table:\n\n| Name | Size |\n|:-----|-----:|\n| `a.go` | 10 |\n\n- one\n- two\n\n```go\nx := 1\n```"
304         server := newModelFake(t)
305         server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
306                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(reply)}, 100, 0, 5))
307         }
308
309         telegram := newTelegramFake(t)
310         bot, _ := telegramStartTestBot(t, telegram)
311         socket := daemonStartTestWithBot(t, server, bot)
312         terminal := terminalStartTestClient(t, socket)
313         terminal.WaitFor(t, "[connected to OneChat]")
314
315         terminal.Type("make **it** so")
316         telegramWaitForSent(t, telegram, "[terminal] make **it** so", reply)
317
318         telegram.Add(TelegramUpdate{
319                 UpdateID: 1,
320                 Message:  telegramOwnerMessage(""),
321         })
322
323         telegramWaitForSent(t, telegram, telegramTextOnly)
324
325         var sends []TelegramFakeCall
326
327         for _, call := range telegram.Calls() {
328                 if call.method == "sendMessage" || call.method == "sendRichMessage" {
329                         sends = append(sends, call)
330                 }
331         }
332
333         require.Equal(t, []TelegramFakeCall{
334                 {
335                         method: "sendMessage",
336                         params: map[string]any{
337                                 "chat_id": float64(telegramTestOwner),
338                                 "text":    "[terminal] make **it** so",
339                         },
340                         ok: true,
341                 },
342                 {
343                         method: "sendRichMessage",
344                         params: map[string]any{
345                                 "chat_id": float64(telegramTestOwner),
346                                 "rich_message": map[string]any{
347                                         "markdown": reply,
348                                 },
349                         },
350                         ok: true,
351                 },
352                 {
353                         method: "sendMessage",
354                         params: map[string]any{
355                                 "chat_id": float64(telegramTestOwner),
356                                 "text":    telegramTextOnly,
357                         },
358                         ok: true,
359                 },
360         }, sends)
361 }
362
363 func TestRichMessageFailures(t *testing.T) {
364         testCases := []struct {
365                 name    string
366                 status  int
367                 always  bool
368                 methods []string
369                 sent    []string
370                 lines   int
371                 logText string
372         }{
373                 {
374                         name:    "bad request sends the reply once with entities",
375                         status:  http.StatusBadRequest,
376                         always:  true,
377                         methods: []string{"sendRichMessage", "sendMessage"},
378                         sent:    []string{"a secret word"},
379                         lines:   1,
380                         logText: "<4>reply sent with simple formatting",
381                 },
382                 {
383                         name:    "unknown method sends the reply once with entities",
384                         status:  http.StatusNotFound,
385                         always:  true,
386                         methods: []string{"sendRichMessage", "sendMessage"},
387                         sent:    []string{"a secret word"},
388                         lines:   1,
389                         logText: "<4>reply sent with simple formatting",
390                 },
391                 {
392                         name:    "rate limit tries the rich message again",
393                         status:  http.StatusTooManyRequests,
394                         methods: []string{"sendRichMessage", "sendRichMessage"},
395                         sent:    []string{"a **secret** word"},
396                         lines:   2,
397                         logText: "<4>send failed retry=1s: 429",
398                 },
399                 {
400                         name:    "server error tries the rich message again",
401                         status:  http.StatusInternalServerError,
402                         methods: []string{"sendRichMessage", "sendRichMessage"},
403                         sent:    []string{"a **secret** word"},
404                         lines:   2,
405                         logText: "<4>send failed retry=1ms: 500",
406                 },
407                 {
408                         name:    "other refusal drops the reply",
409                         status:  http.StatusForbidden,
410                         always:  true,
411                         methods: []string{"sendRichMessage"},
412                         lines:   1,
413                         logText: "<3>reply lost: 403",
414                 },
415         }
416
417         for _, tc := range testCases {
418                 t.Run(tc.name, func(t *testing.T) {
419                         telegram := newTelegramFake(t)
420                         failed := false
421                         telegram.fail = func(call TelegramFakeCall) (int, string) {
422                                 if call.method != "sendRichMessage" || failed && !tc.always {
423                                         return 0, ""
424                                 }
425
426                                 failed = true
427
428                                 return tc.status, "refused for bot" + telegramTestToken
429                         }
430
431                         bot, logs := telegramStartTestBot(t, telegram)
432                         bot.post(t.Context(), Event{
433                                 Kind: "reply",
434                                 Text: "a **secret** word",
435                         })
436
437                         var methods []string
438
439                         for _, call := range telegram.Calls() {
440                                 methods = append(methods, call.method)
441                         }
442
443                         require.Equal(t, tc.methods, methods)
444                         require.Equal(t, tc.sent, telegram.Sent())
445                         require.Equal(t, tc.lines, strings.Count(logs.String(), "\n"))
446                         require.Contains(t, logs.String(), tc.logText)
447
448                         if tc.lines == 2 {
449                                 require.Contains(t, logs.String(), "<6>send back after=")
450                         }
451
452                         require.NotContains(t, logs.String(), telegramTestToken)
453                         require.NotContains(t, logs.String(), "secret word")
454
455                         calls := telegram.Calls()
456                         if calls[len(calls)-1].method == "sendMessage" {
457                                 require.NotNil(t, calls[len(calls)-1].params["entities"])
458                         }
459                 })
460         }
461 }
462
463 func TestLongReplyGoesAsRichMessagesSplitBetweenBlocks(t *testing.T) {
464         paragraphs := strings.Repeat("A paragraph of the reply.\n\n", telegramRichBytes/len("A paragraph of the reply.\n\n"))
465         table := "| a | b |\n|---|---|\n| 1 | 2 |\n"
466         reply := paragraphs + table
467
468         telegram := newTelegramFake(t)
469         bot, _ := telegramStartTestBot(t, telegram)
470         bot.post(t.Context(), Event{
471                 Kind: "reply",
472                 Text: reply,
473         })
474
475         require.Equal(t, []string{paragraphs, table}, telegram.Sent())
476 }
477
478 func TestReplyTagsGoAsText(t *testing.T) {
479         testCases := []struct {
480                 name   string
481                 status int
482                 sent   []string
483         }{
484                 {
485                         name: "rich message escapes the tag outside code",
486                         sent: []string{"a &lt;details> &amp; tag and `<b>`"},
487                 },
488                 {
489                         name:   "entity fallback shows the tag as written",
490                         status: http.StatusBadRequest,
491                         sent:   []string{"a <details> & tag and <b>"},
492                 },
493         }
494
495         for _, tc := range testCases {
496                 t.Run(tc.name, func(t *testing.T) {
497                         telegram := newTelegramFake(t)
498                         telegram.fail = func(call TelegramFakeCall) (int, string) {
499                                 if call.method != "sendRichMessage" {
500                                         return 0, ""
501                                 }
502
503                                 return tc.status, "Bad Request"
504                         }
505
506                         bot, _ := telegramStartTestBot(t, telegram)
507                         bot.post(t.Context(), Event{
508                                 Kind: "reply",
509                                 Text: "a <details> & tag and `<b>`",
510                         })
511
512                         require.Equal(t, tc.sent, telegram.Sent())
513                 })
514         }
515 }
516
517 func TestLongReplySplitCountsTheEscapedTags(t *testing.T) {
518         paragraph := "A <b> tag.\n\n"
519         count := telegramRichBytes / len(paragraph)
520         reply := strings.Repeat(paragraph, count)
521         require.LessOrEqual(t, len(reply), telegramRichBytes)
522
523         telegram := newTelegramFake(t)
524         bot, _ := telegramStartTestBot(t, telegram)
525         bot.post(t.Context(), Event{
526                 Kind: "reply",
527                 Text: reply,
528         })
529
530         sent := telegram.Sent()
531         require.Len(t, sent, 2)
532         require.Equal(t, telegramEscapeTags(reply), strings.Join(sent, ""))
533
534         for _, markdown := range sent {
535                 require.LessOrEqual(t, len(markdown), telegramRichBytes)
536         }
537 }
538
539 func TestRefusedRichPartGoesWithTheTextAsWritten(t *testing.T) {
540         paragraph := "A <b> & c.\n\n"
541         reply := strings.Repeat(paragraph, telegramRichBytes/len(paragraph))
542
543         telegram := newTelegramFake(t)
544         refused := false
545         telegram.fail = func(call TelegramFakeCall) (int, string) {
546                 if call.method != "sendRichMessage" || refused {
547                         return 0, ""
548                 }
549
550                 refused = true
551
552                 return http.StatusBadRequest, "Bad Request"
553         }
554
555         bot, _ := telegramStartTestBot(t, telegram)
556         bot.post(t.Context(), Event{
557                 Kind: "reply",
558                 Text: reply,
559         })
560
561         var fallback, rich []string
562
563         for _, call := range telegram.Calls() {
564                 switch {
565                 case !call.ok:
566                 case call.method == "sendMessage":
567                         fallback = append(fallback, call.params["text"].(string))
568                 case call.method == "sendRichMessage":
569                         rich = append(rich, call.params["rich_message"].(map[string]any)["markdown"].(string))
570                 }
571         }
572
573         require.Greater(t, len(fallback), 1)
574         require.Len(t, rich, 1)
575         require.True(t, strings.HasPrefix(reply, strings.Join(fallback, "")), "fallback text is not the reply as written")
576
577         for _, text := range fallback {
578                 require.LessOrEqual(t, telegramUTF16Units(text), telegramMessageUnits)
579         }
580
581         require.Equal(t, reply, strings.Join(fallback, "")+telegramUnescapeTags(rich[0]), "reply is not sent once and whole")
582 }
583
584 func TestRefusedEntitiesGoAgainAsPlainText(t *testing.T) {
585         telegram := newTelegramFake(t)
586         telegram.fail = func(call TelegramFakeCall) (int, string) {
587                 if call.params["entities"] == nil {
588                         return 0, ""
589                 }
590
591                 return http.StatusBadRequest, "Bad Request: can't parse entities of bot" + telegramTestToken
592         }
593
594         bot, logs := telegramStartTestBot(t, telegram)
595         text, entities := telegramMarkdownEntities("a **secret** word")
596         bot.deliver(t.Context(), TelegramPart{
597                 text:     text,
598                 entities: entities,
599         })
600
601         calls := telegram.Calls()
602         require.Len(t, calls, 2)
603         require.NotNil(t, calls[0].params["entities"])
604         require.Nil(t, calls[1].params["entities"])
605         require.Equal(t, []string{"a secret word"}, telegram.Sent())
606         require.Equal(t, 1, strings.Count(logs.String(), "\n"))
607         require.Contains(t, logs.String(), "<4>reply sent as plain text")
608         require.NotContains(t, logs.String(), telegramTestToken)
609         require.NotContains(t, logs.String(), "secret word")
610 }
611
612 func TestBotLogsALostPlainReplyAsAnError(t *testing.T) {
613         telegram := newTelegramFake(t)
614         telegram.fail = func(call TelegramFakeCall) (int, string) {
615                 if call.method == "sendMessage" {
616                         return http.StatusForbidden, "Forbidden"
617                 }
618
619                 return 0, ""
620         }
621
622         bot, logs := telegramStartTestBot(t, telegram)
623         bot.deliver(t.Context(), TelegramPart{text: "secret reply"})
624         require.Equal(t, "<3>reply lost: 403 Forbidden\n", logs.String())
625         require.Empty(t, telegram.Sent())
626 }
627
628 func TestBotLogsNoTokenWhenTheNetworkFails(t *testing.T) {
629         telegram := newTelegramFake(t)
630         telegram.Close()
631
632         bot, logs := telegramStartTestBot(t, telegram)
633         daemonStartTestWithBot(t, newModelFake(t), bot)
634
635         require.Eventually(t, func() bool {
636                 return strings.Contains(logs.String(), "<4>receive failed retry=1ms: no answer: ")
637         }, 10*time.Second, time.Millisecond)
638         require.NotContains(t, logs.String(), telegramTestToken)
639 }
640
641 func TestBotLogsAnOutageOnlyAtItsStartAndEnd(t *testing.T) {
642         server := newModelFake(t)
643         server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
644                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5))
645         }
646
647         telegram := newTelegramFake(t)
648         failures := 5
649         telegram.fail = func(call TelegramFakeCall) (int, string) {
650                 if call.method != "getUpdates" || failures == 0 {
651                         return 0, ""
652                 }
653
654                 failures--
655
656                 return http.StatusBadGateway, "Bad Gateway"
657         }
658
659         bot, logs := telegramStartTestBot(t, telegram)
660         daemonStartTestWithBot(t, server, bot)
661
662         telegram.Add(TelegramUpdate{
663                 UpdateID: 1,
664                 Message:  telegramOwnerMessage("a secret word"),
665         })
666
667         telegramWaitForSent(t, telegram, "ok")
668
669         lines := strings.Split(strings.TrimSuffix(logs.String(), "\n"), "\n")
670         require.Len(t, lines, 2)
671         require.Equal(t, "<4>receive failed retry=1ms: 502 Bad Gateway", lines[0])
672         require.Regexp(t, `^<6>receive back after=[0-9]+m?s$`, lines[1])
673         require.NotContains(t, logs.String(), "secret word")
674         require.NotContains(t, logs.String(), strconv.FormatInt(telegramTestOwner, 10))
675 }
676
677 func TestSplitMessage(t *testing.T) {
678         testCases := []struct {
679                 name  string
680                 text  string
681                 limit int
682                 parts []string
683         }{
684                 {
685                         name:  "short text stays whole",
686                         text:  "one two",
687                         limit: 10,
688                         parts: []string{"one two"},
689                 },
690                 {
691                         name:  "cut after the last line break",
692                         text:  "first line\nsecond line",
693                         limit: 16,
694                         parts: []string{"first line\n", "second line"},
695                 },
696                 {
697                         name:  "cut after the last space without a line break",
698                         text:  "aaaa bbbb cccc",
699                         limit: 12,
700                         parts: []string{"aaaa bbbb ", "cccc"},
701                 },
702                 {
703                         name:  "hard cut of one long word",
704                         text:  "abcdefghij",
705                         limit: 4,
706                         parts: []string{"abcd", "efgh", "ij"},
707                 },
708                 {
709                         name:  "a character outside the BMP counts two units and is not cut",
710                         text:  "ab\U0001F600cd",
711                         limit: 3,
712                         parts: []string{"ab", "\U0001F600c", "d"},
713                 },
714         }
715
716         for _, tc := range testCases {
717                 t.Run(tc.name, func(t *testing.T) {
718                         require.Equal(t, tc.parts, telegramSplitMessage(tc.text, tc.limit))
719                 })
720         }
721
722         long := strings.Repeat("word ", 2000)
723         parts := telegramSplitMessage(long, telegramMessageUnits)
724         require.Len(t, parts, 3)
725         require.Equal(t, long, strings.Join(parts, ""))
726 }
727
728 func TestSplitEntitiesCutsAnEntityAcrossASplit(t *testing.T) {
729         parts := telegramSplitEntities("aabbbbcc", []TelegramEntity{
730                 {
731                         Type:   "bold",
732                         Offset: 2,
733                         Length: 4,
734                 },
735                 {
736                         Type:   "italic",
737                         Offset: 6,
738                         Length: 2,
739                 },
740         }, 5)
741
742         require.Equal(t, []TelegramPart{
743                 {
744                         text: "aabbb",
745                         entities: []TelegramEntity{
746                                 {
747                                         Type:   "bold",
748                                         Offset: 2,
749                                         Length: 3,
750                                 },
751                         },
752                 },
753                 {
754                         text: "bcc",
755                         entities: []TelegramEntity{
756                                 {
757                                         Type:   "bold",
758                                         Offset: 0,
759                                         Length: 1,
760                                 },
761                                 {
762                                         Type:   "italic",
763                                         Offset: 1,
764                                         Length: 2,
765                                 },
766                         },
767                 },
768         }, parts)
769 }
770
771 func TestSplitRich(t *testing.T) {
772         testCases := []struct {
773                 name     string
774                 markdown string
775                 limit    int
776                 parts    []string
777         }{
778                 {
779                         name:     "short reply stays whole",
780                         markdown: "| a | b |",
781                         limit:    100,
782                         parts:    []string{"| a | b |"},
783                 },
784                 {
785                         name:     "cut after a blank line keeps a table whole",
786                         markdown: "Intro.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n",
787                         limit:    30,
788                         parts:    []string{"Intro.\n\n", "| a | b |\n|---|---|\n| 1 | 2 |\n"},
789                 },
790                 {
791                         name:     "blank line in a code block is not a cut",
792                         markdown: "Text.\n\n```go\nx\n\ny\n```\n\nEnd.\n",
793                         limit:    20,
794                         parts:    []string{"Text.\n\n", "```go\nx\n\ny\n```\n\n", "End.\n"},
795                 },
796                 {
797                         name:     "paragraph with no blank line is cut after a line",
798                         markdown: "aaa\nbbb\nccc\n",
799                         limit:    9,
800                         parts:    []string{"aaa\nbbb\n", "ccc\n"},
801                 },
802                 {
803                         name:     "long code block is closed and opened again",
804                         markdown: "```sh\none\ntwo\nthree\n```\n",
805                         limit:    16,
806                         parts:    []string{"```sh\none\n```\n", "```sh\ntwo\n```\n", "```sh\nthree\n```\n"},
807                 },
808                 {
809                         name:     "long line is cut at the limit and not inside a character",
810                         markdown: "ab\u00e9cd",
811                         limit:    3,
812                         parts:    []string{"ab", "\u00e9c", "d"},
813                 },
814                 {
815                         name:     "long line is not cut inside an entity",
816                         markdown: "abc&amp;def",
817                         limit:    6,
818                         parts:    []string{"abc", "&amp;d", "ef"},
819                 },
820         }
821
822         for _, tc := range testCases {
823                 t.Run(tc.name, func(t *testing.T) {
824                         require.Equal(t, tc.parts, telegramSplitRich(tc.markdown, tc.limit))
825                 })
826         }
827
828         long := strings.Repeat("A paragraph of the reply.\n\n", 2000)
829         parts := telegramSplitRich(long, telegramRichBytes)
830         require.Len(t, parts, 2)
831         require.Len(t, parts[0], telegramRichBytes-telegramRichBytes%len("A paragraph of the reply.\n\n"))
832         require.Equal(t, long, strings.Join(parts, ""))
833 }
834
835 func TestLoadBotConfig(t *testing.T) {
836         testCases := []struct {
837                 name      string
838                 content   string
839                 mode      os.FileMode
840                 errorText string
841         }{
842                 {
843                         name:    "owner file is read",
844                         content: `{"token": "` + telegramTestToken + `", "owner": 4242}`,
845                         mode:    0o600,
846                 },
847                 {
848                         name:      "file that others can read is refused",
849                         content:   `{"token": "` + telegramTestToken + `", "owner": 4242}`,
850                         mode:      0o640,
851                         errorText: "mode must be 0600",
852                 },
853                 {
854                         name:      "token with a path character is refused",
855                         content:   `{"token": "123:abc/../x", "owner": 4242}`,
856                         mode:      0o600,
857                         errorText: "form of a bot token",
858                 },
859                 {
860                         name:      "missing owner is refused",
861                         content:   `{"token": "` + telegramTestToken + `"}`,
862                         mode:      0o600,
863                         errorText: "numeric Telegram user ID",
864                 },
865                 {
866                         name:      "unknown field is refused",
867                         content:   `{"token": "` + telegramTestToken + `", "owner": 4242, "chat": 1}`,
868                         mode:      0o600,
869                         errorText: "not valid JSON",
870                 },
871         }
872
873         for _, tc := range testCases {
874                 t.Run(tc.name, func(t *testing.T) {
875                         dir := t.TempDir()
876                         path := filepath.Join(dir, telegramConfigFileName)
877                         require.NoError(t, os.WriteFile(path, []byte(tc.content), tc.mode))
878                         require.NoError(t, os.Chmod(path, tc.mode))
879
880                         config, found, err := loadTelegramConfig(dir)
881                         if tc.errorText != "" {
882                                 require.ErrorContains(t, err, tc.errorText)
883                                 require.NotContains(t, err.Error(), telegramTestToken)
884
885                                 return
886                         }
887
888                         require.NoError(t, err)
889                         require.True(t, found)
890                         require.Equal(t, TelegramConfig{
891                                 Token: telegramTestToken,
892                                 Owner: telegramTestOwner,
893                         }, config)
894                 })
895         }
896
897         _, found, err := loadTelegramConfig(t.TempDir())
898         require.NoError(t, err)
899         require.False(t, found)
900 }
901
902 // telegramStartTestBot returns a bot of telegramTestOwner on server, with its
903 // offset file in a temporary directory and short waits, and the buffer of its
904 // log.
905 func telegramStartTestBot(t *testing.T, server *TelegramFake) (*TelegramBot, *SyncBuffer) {
906         t.Helper()
907
908         logs := &SyncBuffer{}
909         bot := newTelegramBot(newTelegramAPI(server.URL, telegramTestToken), telegramTestOwner, filepath.Join(t.TempDir(), telegramOffsetFileName), daemonTestLogger(logs))
910         bot.minWait = time.Millisecond
911         bot.maxWait = 10 * time.Millisecond
912
913         return bot, logs
914 }
915
916 // telegramWaitForSent waits until the bot sent the texts in order, and returns
917 // them.
918 func telegramWaitForSent(t *testing.T, server *TelegramFake, texts ...string) []string {
919         t.Helper()
920
921         require.Eventually(t, func() bool {
922                 sent := server.Sent()
923                 return len(sent) >= len(texts) && slices.Equal(sent[len(sent)-len(texts):], texts)
924         }, 10*time.Second, time.Millisecond, "sent so far: %q", server.Sent())
925
926         return server.Sent()
927 }