]> git.giacomolli.uk - onechat/blob - telegram_test.go
Convert WhatsApp events with pure functions
[onechat] / telegram_test.go
1 package main
2
3 import (
4         "log"
5         "net/http"
6         "os"
7         "path/filepath"
8         "slices"
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, call 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(), "update 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(w http.ResponseWriter, call 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 TestBotStartsAtTheStoredOffset(t *testing.T) {
183         server := newModelFake(t)
184         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
185                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5))
186         }
187
188         telegram := newTelegramFake(t)
189         bot, _ := telegramStartTestBot(t, telegram)
190         require.NoError(t, telegramSaveOffset(bot.offsetPath, 10))
191
192         telegram.Add(TelegramUpdate{
193                 UpdateID: 9,
194                 Message:  telegramOwnerMessage("handled before the restart"),
195         })
196
197         telegram.Add(TelegramUpdate{
198                 UpdateID: 10,
199                 Message:  telegramOwnerMessage("new after the restart"),
200         })
201
202         socket := daemonStartTestWithBot(t, server, bot)
203         terminal := terminalStartTestClient(t, socket)
204         terminal.WaitFor(t, "[connected to OneChat]")
205         telegramWaitForSent(t, telegram, "ok")
206
207         require.Equal(t, float64(10), telegram.Calls()[0].params["offset"])
208         require.NotContains(t, terminal.Output(), "handled before the restart")
209
210         require.Eventually(t, func() bool {
211                 offset, err := telegramLoadOffset(bot.offsetPath)
212                 return err == nil && offset == 11
213         }, 10*time.Second, time.Millisecond)
214 }
215
216 func TestBotTriesAgainAfterFailures(t *testing.T) {
217         server := newModelFake(t)
218         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
219                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5))
220         }
221
222         telegram := newTelegramFake(t)
223         failures := map[string]int{
224                 "getUpdates":      2,
225                 "sendRichMessage": 1,
226         }
227
228         telegram.fail = func(call TelegramFakeCall) (int, string) {
229                 if failures[call.method] == 0 {
230                         return 0, ""
231                 }
232
233                 failures[call.method]--
234                 if call.method == "sendRichMessage" {
235                         return http.StatusTooManyRequests, "Too Many Requests: retry after 1"
236                 }
237
238                 return http.StatusBadGateway, "Bad Gateway"
239         }
240
241         bot, logs := telegramStartTestBot(t, telegram)
242         daemonStartTestWithBot(t, server, bot)
243
244         telegram.Add(TelegramUpdate{
245                 UpdateID: 1,
246                 Message:  telegramOwnerMessage("hello"),
247         })
248
249         telegramWaitForSent(t, telegram, "ok")
250         require.Contains(t, logs.String(), "answered 502")
251         require.Contains(t, logs.String(), "answered 429")
252         require.NotContains(t, logs.String(), telegramTestToken)
253 }
254
255 func TestDeliverTriesAgainAfterATimeout(t *testing.T) {
256         telegram := newTelegramFake(t)
257         stalls := 1
258         telegram.stall = func(call TelegramFakeCall) bool {
259                 if call.method != "sendMessage" || stalls == 0 {
260                         return false
261                 }
262
263                 stalls--
264
265                 return true
266         }
267
268         bot, logs := telegramStartTestBot(t, telegram)
269         bot.api.sendTimeout = 50 * time.Millisecond
270         bot.deliver(t.Context(), TelegramPart{
271                 text: "hello",
272         })
273
274         require.Equal(t, []string{"hello"}, telegram.Sent())
275         require.Len(t, telegram.Calls(), 2)
276         require.Contains(t, logs.String(), "no valid answer")
277         require.NotContains(t, logs.String(), telegramTestToken)
278 }
279
280 func TestRepliesGoAsRichMessages(t *testing.T) {
281         reply := "**Done.** The table:\n\n| Name | Size |\n|:-----|-----:|\n| `a.go` | 10 |\n\n- one\n- two\n\n```go\nx := 1\n```"
282         server := newModelFake(t)
283         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
284                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(reply)}, 100, 0, 5))
285         }
286
287         telegram := newTelegramFake(t)
288         bot, _ := telegramStartTestBot(t, telegram)
289         socket := daemonStartTestWithBot(t, server, bot)
290         terminal := terminalStartTestClient(t, socket)
291         terminal.WaitFor(t, "[connected to OneChat]")
292
293         terminal.Type("make **it** so")
294         telegramWaitForSent(t, telegram, "[terminal] make **it** so", reply)
295
296         telegram.Add(TelegramUpdate{
297                 UpdateID: 1,
298                 Message:  telegramOwnerMessage(""),
299         })
300
301         telegramWaitForSent(t, telegram, telegramTextOnly)
302
303         var sends []TelegramFakeCall
304         for _, call := range telegram.Calls() {
305                 if call.method == "sendMessage" || call.method == "sendRichMessage" {
306                         sends = append(sends, call)
307                 }
308         }
309
310         require.Equal(t, []TelegramFakeCall{
311                 {
312                         method: "sendMessage",
313                         params: map[string]any{
314                                 "chat_id": float64(telegramTestOwner),
315                                 "text":    "[terminal] make **it** so",
316                         },
317                         ok: true,
318                 },
319                 {
320                         method: "sendRichMessage",
321                         params: map[string]any{
322                                 "chat_id": float64(telegramTestOwner),
323                                 "rich_message": map[string]any{
324                                         "markdown": reply,
325                                 },
326                         },
327                         ok: true,
328                 },
329                 {
330                         method: "sendMessage",
331                         params: map[string]any{
332                                 "chat_id": float64(telegramTestOwner),
333                                 "text":    telegramTextOnly,
334                         },
335                         ok: true,
336                 },
337         }, sends)
338 }
339
340 func TestRichMessageFailures(t *testing.T) {
341         testCases := []struct {
342                 name    string
343                 status  int
344                 always  bool
345                 methods []string
346                 sent    []string
347                 logText string
348         }{
349                 {
350                         name:    "bad request sends the reply once with entities",
351                         status:  http.StatusBadRequest,
352                         always:  true,
353                         methods: []string{"sendRichMessage", "sendMessage"},
354                         sent:    []string{"a secret word"},
355                         logText: "rich message refused",
356                 },
357                 {
358                         name:    "unknown method sends the reply once with entities",
359                         status:  http.StatusNotFound,
360                         always:  true,
361                         methods: []string{"sendRichMessage", "sendMessage"},
362                         sent:    []string{"a secret word"},
363                         logText: "rich message refused",
364                 },
365                 {
366                         name:    "rate limit tries the rich message again",
367                         status:  http.StatusTooManyRequests,
368                         methods: []string{"sendRichMessage", "sendRichMessage"},
369                         sent:    []string{"a **secret** word"},
370                         logText: "answered 429",
371                 },
372                 {
373                         name:    "server error tries the rich message again",
374                         status:  http.StatusInternalServerError,
375                         methods: []string{"sendRichMessage", "sendRichMessage"},
376                         sent:    []string{"a **secret** word"},
377                         logText: "answered 500",
378                 },
379                 {
380                         name:    "other refusal drops the reply",
381                         status:  http.StatusForbidden,
382                         always:  true,
383                         methods: []string{"sendRichMessage"},
384                         logText: "message dropped",
385                 },
386         }
387
388         for _, tc := range testCases {
389                 t.Run(tc.name, func(t *testing.T) {
390                         telegram := newTelegramFake(t)
391                         failed := false
392                         telegram.fail = func(call TelegramFakeCall) (int, string) {
393                                 if call.method != "sendRichMessage" || failed && !tc.always {
394                                         return 0, ""
395                                 }
396
397                                 failed = true
398
399                                 return tc.status, "refused for bot" + telegramTestToken
400                         }
401
402                         bot, logs := telegramStartTestBot(t, telegram)
403                         bot.post(t.Context(), Event{
404                                 Kind: "reply",
405                                 Text: "a **secret** word",
406                         })
407
408                         var methods []string
409                         for _, call := range telegram.Calls() {
410                                 methods = append(methods, call.method)
411                         }
412
413                         require.Equal(t, tc.methods, methods)
414                         require.Equal(t, tc.sent, telegram.Sent())
415                         require.Equal(t, 1, strings.Count(logs.String(), "\n"))
416                         require.Contains(t, logs.String(), tc.logText)
417                         require.NotContains(t, logs.String(), telegramTestToken)
418                         require.NotContains(t, logs.String(), "secret word")
419
420                         calls := telegram.Calls()
421                         if calls[len(calls)-1].method == "sendMessage" {
422                                 require.NotNil(t, calls[len(calls)-1].params["entities"])
423                         }
424                 })
425         }
426 }
427
428 func TestLongReplyGoesAsRichMessagesSplitBetweenBlocks(t *testing.T) {
429         paragraphs := strings.Repeat("A paragraph of the reply.\n\n", telegramRichBytes/len("A paragraph of the reply.\n\n"))
430         table := "| a | b |\n|---|---|\n| 1 | 2 |\n"
431         reply := paragraphs + table
432
433         telegram := newTelegramFake(t)
434         bot, _ := telegramStartTestBot(t, telegram)
435         bot.post(t.Context(), Event{
436                 Kind: "reply",
437                 Text: reply,
438         })
439
440         require.Equal(t, []string{paragraphs, table}, telegram.Sent())
441 }
442
443 func TestReplyTagsGoAsText(t *testing.T) {
444         testCases := []struct {
445                 name   string
446                 status int
447                 sent   []string
448         }{
449                 {
450                         name: "rich message escapes the tag outside code",
451                         sent: []string{"a &lt;details> &amp; tag and `<b>`"},
452                 },
453                 {
454                         name:   "entity fallback shows the tag as written",
455                         status: http.StatusBadRequest,
456                         sent:   []string{"a <details> & tag and <b>"},
457                 },
458         }
459
460         for _, tc := range testCases {
461                 t.Run(tc.name, func(t *testing.T) {
462                         telegram := newTelegramFake(t)
463                         telegram.fail = func(call TelegramFakeCall) (int, string) {
464                                 if call.method != "sendRichMessage" {
465                                         return 0, ""
466                                 }
467
468                                 return tc.status, "Bad Request"
469                         }
470
471                         bot, _ := telegramStartTestBot(t, telegram)
472                         bot.post(t.Context(), Event{
473                                 Kind: "reply",
474                                 Text: "a <details> & tag and `<b>`",
475                         })
476
477                         require.Equal(t, tc.sent, telegram.Sent())
478                 })
479         }
480 }
481
482 func TestLongReplySplitCountsTheEscapedTags(t *testing.T) {
483         paragraph := "A <b> tag.\n\n"
484         count := telegramRichBytes / len(paragraph)
485         reply := strings.Repeat(paragraph, count)
486         require.LessOrEqual(t, len(reply), telegramRichBytes)
487
488         telegram := newTelegramFake(t)
489         bot, _ := telegramStartTestBot(t, telegram)
490         bot.post(t.Context(), Event{
491                 Kind: "reply",
492                 Text: reply,
493         })
494
495         sent := telegram.Sent()
496         require.Len(t, sent, 2)
497         require.Equal(t, telegramEscapeTags(reply), strings.Join(sent, ""))
498
499         for _, markdown := range sent {
500                 require.LessOrEqual(t, len(markdown), telegramRichBytes)
501         }
502 }
503
504 func TestRefusedRichPartGoesWithTheTextAsWritten(t *testing.T) {
505         paragraph := "A <b> & c.\n\n"
506         reply := strings.Repeat(paragraph, telegramRichBytes/len(paragraph))
507
508         telegram := newTelegramFake(t)
509         refused := false
510         telegram.fail = func(call TelegramFakeCall) (int, string) {
511                 if call.method != "sendRichMessage" || refused {
512                         return 0, ""
513                 }
514
515                 refused = true
516
517                 return http.StatusBadRequest, "Bad Request"
518         }
519
520         bot, _ := telegramStartTestBot(t, telegram)
521         bot.post(t.Context(), Event{
522                 Kind: "reply",
523                 Text: reply,
524         })
525
526         var fallback, rich []string
527         for _, call := range telegram.Calls() {
528                 switch {
529                 case !call.ok:
530                 case call.method == "sendMessage":
531                         fallback = append(fallback, call.params["text"].(string))
532                 case call.method == "sendRichMessage":
533                         rich = append(rich, call.params["rich_message"].(map[string]any)["markdown"].(string))
534                 }
535         }
536
537         require.Greater(t, len(fallback), 1)
538         require.Len(t, rich, 1)
539         require.True(t, strings.HasPrefix(reply, strings.Join(fallback, "")), "fallback text is not the reply as written")
540
541         for _, text := range fallback {
542                 require.LessOrEqual(t, telegramUTF16Units(text), telegramMessageUnits)
543         }
544
545         require.True(t, reply == strings.Join(fallback, "")+telegramUnescapeTags(rich[0]), "reply is not sent once and whole")
546 }
547
548 func TestRefusedEntitiesGoAgainAsPlainText(t *testing.T) {
549         telegram := newTelegramFake(t)
550         telegram.fail = func(call TelegramFakeCall) (int, string) {
551                 if call.params["entities"] == nil {
552                         return 0, ""
553                 }
554
555                 return http.StatusBadRequest, "Bad Request: can't parse entities of bot" + telegramTestToken
556         }
557
558         bot, logs := telegramStartTestBot(t, telegram)
559         text, entities := telegramMarkdownEntities("a **secret** word")
560         bot.deliver(t.Context(), TelegramPart{
561                 text:     text,
562                 entities: entities,
563         })
564
565         calls := telegram.Calls()
566         require.Len(t, calls, 2)
567         require.NotNil(t, calls[0].params["entities"])
568         require.Nil(t, calls[1].params["entities"])
569         require.Equal(t, []string{"a secret word"}, telegram.Sent())
570         require.Equal(t, 1, strings.Count(logs.String(), "\n"))
571         require.Contains(t, logs.String(), "entities refused")
572         require.NotContains(t, logs.String(), telegramTestToken)
573         require.NotContains(t, logs.String(), "secret word")
574 }
575
576 func TestBotLogsNoTokenWhenTheNetworkFails(t *testing.T) {
577         telegram := newTelegramFake(t)
578         telegram.Close()
579
580         bot, logs := telegramStartTestBot(t, telegram)
581         daemonStartTestWithBot(t, newModelFake(t), bot)
582
583         require.Eventually(t, func() bool {
584                 return strings.Count(logs.String(), "no valid answer") >= 2
585         }, 10*time.Second, time.Millisecond)
586         require.NotContains(t, logs.String(), telegramTestToken)
587 }
588
589 func TestSplitMessage(t *testing.T) {
590         testCases := []struct {
591                 name  string
592                 text  string
593                 limit int
594                 parts []string
595         }{
596                 {
597                         name:  "short text stays whole",
598                         text:  "one two",
599                         limit: 10,
600                         parts: []string{"one two"},
601                 },
602                 {
603                         name:  "cut after the last line break",
604                         text:  "first line\nsecond line",
605                         limit: 16,
606                         parts: []string{"first line\n", "second line"},
607                 },
608                 {
609                         name:  "cut after the last space without a line break",
610                         text:  "aaaa bbbb cccc",
611                         limit: 12,
612                         parts: []string{"aaaa bbbb ", "cccc"},
613                 },
614                 {
615                         name:  "hard cut of one long word",
616                         text:  "abcdefghij",
617                         limit: 4,
618                         parts: []string{"abcd", "efgh", "ij"},
619                 },
620                 {
621                         name:  "a character outside the BMP counts two units and is not cut",
622                         text:  "ab\U0001F600cd",
623                         limit: 3,
624                         parts: []string{"ab", "\U0001F600c", "d"},
625                 },
626         }
627
628         for _, tc := range testCases {
629                 t.Run(tc.name, func(t *testing.T) {
630                         require.Equal(t, tc.parts, telegramSplitMessage(tc.text, tc.limit))
631                 })
632         }
633
634         long := strings.Repeat("word ", 2000)
635         parts := telegramSplitMessage(long, telegramMessageUnits)
636         require.Len(t, parts, 3)
637         require.Equal(t, long, strings.Join(parts, ""))
638 }
639
640 func TestSplitEntitiesCutsAnEntityAcrossASplit(t *testing.T) {
641         parts := telegramSplitEntities("aabbbbcc", []TelegramEntity{
642                 {
643                         Type:   "bold",
644                         Offset: 2,
645                         Length: 4,
646                 },
647                 {
648                         Type:   "italic",
649                         Offset: 6,
650                         Length: 2,
651                 },
652         }, 5)
653
654         require.Equal(t, []TelegramPart{
655                 {
656                         text: "aabbb",
657                         entities: []TelegramEntity{
658                                 {
659                                         Type:   "bold",
660                                         Offset: 2,
661                                         Length: 3,
662                                 },
663                         },
664                 },
665                 {
666                         text: "bcc",
667                         entities: []TelegramEntity{
668                                 {
669                                         Type:   "bold",
670                                         Offset: 0,
671                                         Length: 1,
672                                 },
673                                 {
674                                         Type:   "italic",
675                                         Offset: 1,
676                                         Length: 2,
677                                 },
678                         },
679                 },
680         }, parts)
681 }
682
683 func TestSplitRich(t *testing.T) {
684         testCases := []struct {
685                 name     string
686                 markdown string
687                 limit    int
688                 parts    []string
689         }{
690                 {
691                         name:     "short reply stays whole",
692                         markdown: "| a | b |",
693                         limit:    100,
694                         parts:    []string{"| a | b |"},
695                 },
696                 {
697                         name:     "cut after a blank line keeps a table whole",
698                         markdown: "Intro.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n",
699                         limit:    30,
700                         parts:    []string{"Intro.\n\n", "| a | b |\n|---|---|\n| 1 | 2 |\n"},
701                 },
702                 {
703                         name:     "blank line in a code block is not a cut",
704                         markdown: "Text.\n\n```go\nx\n\ny\n```\n\nEnd.\n",
705                         limit:    20,
706                         parts:    []string{"Text.\n\n", "```go\nx\n\ny\n```\n\n", "End.\n"},
707                 },
708                 {
709                         name:     "paragraph with no blank line is cut after a line",
710                         markdown: "aaa\nbbb\nccc\n",
711                         limit:    9,
712                         parts:    []string{"aaa\nbbb\n", "ccc\n"},
713                 },
714                 {
715                         name:     "long code block is closed and opened again",
716                         markdown: "```sh\none\ntwo\nthree\n```\n",
717                         limit:    16,
718                         parts:    []string{"```sh\none\n```\n", "```sh\ntwo\n```\n", "```sh\nthree\n```\n"},
719                 },
720                 {
721                         name:     "long line is cut at the limit and not inside a character",
722                         markdown: "ab\u00e9cd",
723                         limit:    3,
724                         parts:    []string{"ab", "\u00e9c", "d"},
725                 },
726                 {
727                         name:     "long line is not cut inside an entity",
728                         markdown: "abc&amp;def",
729                         limit:    6,
730                         parts:    []string{"abc", "&amp;d", "ef"},
731                 },
732         }
733
734         for _, tc := range testCases {
735                 t.Run(tc.name, func(t *testing.T) {
736                         require.Equal(t, tc.parts, telegramSplitRich(tc.markdown, tc.limit))
737                 })
738         }
739
740         long := strings.Repeat("A paragraph of the reply.\n\n", 2000)
741         parts := telegramSplitRich(long, telegramRichBytes)
742         require.Len(t, parts, 2)
743         require.Len(t, parts[0], telegramRichBytes-telegramRichBytes%len("A paragraph of the reply.\n\n"))
744         require.Equal(t, long, strings.Join(parts, ""))
745 }
746
747 func TestLoadBotConfig(t *testing.T) {
748         testCases := []struct {
749                 name      string
750                 content   string
751                 mode      os.FileMode
752                 errorText string
753         }{
754                 {
755                         name:    "owner file is read",
756                         content: `{"token": "` + telegramTestToken + `", "owner": 4242}`,
757                         mode:    0o600,
758                 },
759                 {
760                         name:      "file that others can read is refused",
761                         content:   `{"token": "` + telegramTestToken + `", "owner": 4242}`,
762                         mode:      0o640,
763                         errorText: "mode must be 0600",
764                 },
765                 {
766                         name:      "token with a path character is refused",
767                         content:   `{"token": "123:abc/../x", "owner": 4242}`,
768                         mode:      0o600,
769                         errorText: "form of a bot token",
770                 },
771                 {
772                         name:      "missing owner is refused",
773                         content:   `{"token": "` + telegramTestToken + `"}`,
774                         mode:      0o600,
775                         errorText: "numeric Telegram user ID",
776                 },
777                 {
778                         name:      "unknown field is refused",
779                         content:   `{"token": "` + telegramTestToken + `", "owner": 4242, "chat": 1}`,
780                         mode:      0o600,
781                         errorText: "not valid JSON",
782                 },
783         }
784
785         for _, tc := range testCases {
786                 t.Run(tc.name, func(t *testing.T) {
787                         dir := t.TempDir()
788                         path := filepath.Join(dir, telegramConfigFileName)
789                         require.NoError(t, os.WriteFile(path, []byte(tc.content), tc.mode))
790                         require.NoError(t, os.Chmod(path, tc.mode))
791
792                         config, found, err := loadTelegramConfig(dir)
793                         if tc.errorText != "" {
794                                 require.ErrorContains(t, err, tc.errorText)
795                                 require.NotContains(t, err.Error(), telegramTestToken)
796
797                                 return
798                         }
799
800                         require.NoError(t, err)
801                         require.True(t, found)
802                         require.Equal(t, TelegramConfig{
803                                 Token: telegramTestToken,
804                                 Owner: telegramTestOwner,
805                         }, config)
806                 })
807         }
808
809         _, found, err := loadTelegramConfig(t.TempDir())
810         require.NoError(t, err)
811         require.False(t, found)
812 }
813
814 // telegramStartTestBot returns a bot of telegramTestOwner on server, with its
815 // offset file in a temporary directory and short waits, and the buffer of its
816 // log.
817 func telegramStartTestBot(t *testing.T, server *TelegramFake) (*TelegramBot, *SyncBuffer) {
818         t.Helper()
819
820         logs := &SyncBuffer{}
821         bot := newTelegramBot(newTelegramAPI(server.URL, telegramTestToken), telegramTestOwner, filepath.Join(t.TempDir(), telegramOffsetFileName), log.New(logs, "", 0))
822         bot.minWait = time.Millisecond
823         bot.maxWait = 10 * time.Millisecond
824
825         return bot, logs
826 }
827
828 // telegramWaitForSent waits until the bot sent the texts in order, and returns
829 // them.
830 func telegramWaitForSent(t *testing.T, server *TelegramFake, texts ...string) []string {
831         t.Helper()
832
833         require.Eventually(t, func() bool {
834                 sent := server.Sent()
835                 return len(sent) >= len(texts) && slices.Equal(sent[len(sent)-len(texts):], texts)
836         }, 10*time.Second, time.Millisecond, "sent so far: %q", server.Sent())
837
838         return server.Sent()
839 }