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