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