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