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