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