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