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