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