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