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