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