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