]> git.giacomolli.uk - onechat/blob - telegram_test.go
Split long replies between Markdown blocks
[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 := newFakeResponses(t)
18         server.reply = func(w http.ResponseWriter, call fakeCall) {
19                 writeEvents(w, completedEvent([]any{messageItem("Hello from the agent.")}, 100, 0, 5))
20         }
21
22         telegram := newFakeBot(t)
23         bot, _ := startTestBot(t, telegram)
24         socket := startTestDaemonWithBot(t, server, bot)
25         terminal := startTestClient(t, socket)
26         terminal.WaitFor(t, "[connected to OneChat]")
27
28         telegram.Add(botUpdate{
29                 UpdateID: 1,
30                 Message:  ownerMessage("hi from the phone"),
31         })
32
33         terminal.WaitFor(t, "[telegram] hi from the phone\n[working]\nHello from the agent.\n[done]")
34         waitForSent(t, telegram, "Hello from the agent.")
35         require.True(t, slices.ContainsFunc(telegram.Calls(), func(call fakeBotCall) 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."}, waitForSent(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 := newFakeBot(t)
45         bot, logs := startTestBot(t, telegram)
46         socket := startTestDaemonWithBot(t, newFakeResponses(t), bot)
47         terminal := startTestClient(t, socket)
48         terminal.WaitFor(t, "[connected to OneChat]")
49
50         group := ownerMessage("secret in a group")
51         group.Chat = botChat{
52                 ID:   -100,
53                 Type: "group",
54         }
55
56         stranger := ownerMessage("secret of a stranger")
57         stranger.Chat.ID = 77
58         stranger.From.ID = 77
59
60         unsigned := ownerMessage("secret with no sender")
61         unsigned.From = nil
62
63         for id, message := range []*botMessage{group, stranger, unsigned, nil} {
64                 telegram.Add(botUpdate{
65                         UpdateID: int64(10 + id),
66                         Message:  message,
67                 })
68         }
69
70         require.Eventually(t, func() bool {
71                 offset, err := loadOffset(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 := newFakeBot(t)
83         bot, _ := startTestBot(t, telegram)
84         startTestDaemonWithBot(t, newFakeResponses(t), bot)
85
86         telegram.Add(botUpdate{
87                 UpdateID: 1,
88                 Message:  ownerMessage(""),
89         })
90
91         waitForSent(t, telegram, textOnly)
92 }
93
94 func TestStopFromTelegramInterruptsTheTurn(t *testing.T) {
95         server := newFakeResponses(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 fakeCall) {
103                 close(arrived)
104                 <-release
105         }
106
107         telegram := newFakeBot(t)
108         bot, _ := startTestBot(t, telegram)
109         startTestDaemonWithBot(t, server, bot)
110
111         telegram.Add(botUpdate{
112                 UpdateID: 1,
113                 Message:  ownerMessage("a long task"),
114         })
115
116         <-arrived
117         telegram.Add(botUpdate{
118                 UpdateID: 2,
119                 Message:  ownerMessage(stopCommand),
120         })
121
122         waitForSent(t, telegram, "Error: interrupted")
123
124         telegram.Add(botUpdate{
125                 UpdateID: 3,
126                 Message:  ownerMessage(stopCommand),
127         })
128
129         waitForSent(t, telegram, "Error: interrupted", "No turn runs.")
130 }
131
132 func TestBotStartsAtTheStoredOffset(t *testing.T) {
133         server := newFakeResponses(t)
134         server.reply = func(w http.ResponseWriter, call fakeCall) {
135                 writeEvents(w, completedEvent([]any{messageItem("ok")}, 100, 0, 5))
136         }
137
138         telegram := newFakeBot(t)
139         bot, _ := startTestBot(t, telegram)
140         require.NoError(t, saveOffset(bot.offsetPath, 10))
141
142         telegram.Add(botUpdate{
143                 UpdateID: 9,
144                 Message:  ownerMessage("handled before the restart"),
145         })
146
147         telegram.Add(botUpdate{
148                 UpdateID: 10,
149                 Message:  ownerMessage("new after the restart"),
150         })
151
152         socket := startTestDaemonWithBot(t, server, bot)
153         terminal := startTestClient(t, socket)
154         terminal.WaitFor(t, "[connected to OneChat]")
155         waitForSent(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 := loadOffset(bot.offsetPath)
162                 return err == nil && offset == 11
163         }, 10*time.Second, time.Millisecond)
164 }
165
166 func TestBotTriesAgainAfterFailures(t *testing.T) {
167         server := newFakeResponses(t)
168         server.reply = func(w http.ResponseWriter, call fakeCall) {
169                 writeEvents(w, completedEvent([]any{messageItem("ok")}, 100, 0, 5))
170         }
171
172         telegram := newFakeBot(t)
173         failures := map[string]int{
174                 "getUpdates":  2,
175                 "sendMessage": 1,
176         }
177
178         telegram.fail = func(call fakeBotCall) (int, string) {
179                 if failures[call.method] == 0 {
180                         return 0, ""
181                 }
182
183                 failures[call.method]--
184                 if call.method == "sendMessage" {
185                         return http.StatusTooManyRequests, "Too Many Requests: retry after 1"
186                 }
187
188                 return http.StatusBadGateway, "Bad Gateway"
189         }
190
191         bot, logs := startTestBot(t, telegram)
192         startTestDaemonWithBot(t, server, bot)
193
194         telegram.Add(botUpdate{
195                 UpdateID: 1,
196                 Message:  ownerMessage("hello"),
197         })
198
199         waitForSent(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(), testBotToken)
203 }
204
205 func TestDeliverTriesAgainAfterATimeout(t *testing.T) {
206         telegram := newFakeBot(t)
207         stalls := 1
208         telegram.stall = func(call fakeBotCall) bool {
209                 if call.method != "sendMessage" || stalls == 0 {
210                         return false
211                 }
212
213                 stalls--
214
215                 return true
216         }
217
218         bot, logs := startTestBot(t, telegram)
219         bot.api.sendTimeout = 50 * time.Millisecond
220         bot.deliver(t.Context(), botPart{
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(), testBotToken)
228 }
229
230 func TestOnlyRepliesGetEntities(t *testing.T) {
231         server := newFakeResponses(t)
232         server.reply = func(w http.ResponseWriter, call fakeCall) {
233                 writeEvents(w, completedEvent([]any{messageItem("**Done** with `x`.")}, 100, 0, 5))
234         }
235
236         telegram := newFakeBot(t)
237         bot, _ := startTestBot(t, telegram)
238         socket := startTestDaemonWithBot(t, server, bot)
239         terminal := startTestClient(t, socket)
240         terminal.WaitFor(t, "[connected to OneChat]")
241
242         terminal.Type("make **it** so")
243         waitForSent(t, telegram, "[terminal] make **it** so", "Done with x.")
244
245         telegram.Add(botUpdate{
246                 UpdateID: 1,
247                 Message:  ownerMessage(""),
248         })
249
250         waitForSent(t, telegram, textOnly)
251
252         entities := map[string]any{}
253         for _, call := range telegram.Calls() {
254                 if call.method == "sendMessage" {
255                         entities[call.params["text"].(string)] = call.params["entities"]
256                 }
257         }
258
259         require.Equal(t, map[string]any{
260                 "[terminal] make **it** so": nil,
261                 "Done with x.": []any{
262                         map[string]any{
263                                 "type":   "bold",
264                                 "offset": float64(0),
265                                 "length": float64(4),
266                         },
267                         map[string]any{
268                                 "type":   "code",
269                                 "offset": float64(10),
270                                 "length": float64(1),
271                         },
272                 },
273                 textOnly: nil,
274         }, entities)
275 }
276
277 func TestRefusedEntitiesGoAgainAsPlainText(t *testing.T) {
278         telegram := newFakeBot(t)
279         telegram.fail = func(call fakeBotCall) (int, string) {
280                 if call.params["entities"] == nil {
281                         return 0, ""
282                 }
283
284                 return http.StatusBadRequest, "Bad Request: can't parse entities of bot" + testBotToken
285         }
286
287         bot, logs := startTestBot(t, telegram)
288         text, entities := markdownEntities("a **secret** word")
289         bot.deliver(t.Context(), botPart{
290                 text:     text,
291                 entities: entities,
292         })
293
294         calls := telegram.Calls()
295         require.Len(t, calls, 2)
296         require.NotNil(t, calls[0].params["entities"])
297         require.Nil(t, calls[1].params["entities"])
298         require.Equal(t, []string{"a secret word"}, telegram.Sent())
299         require.Equal(t, 1, strings.Count(logs.String(), "\n"))
300         require.Contains(t, logs.String(), "entities refused")
301         require.NotContains(t, logs.String(), testBotToken)
302         require.NotContains(t, logs.String(), "secret word")
303 }
304
305 func TestBotLogsNoTokenWhenTheNetworkFails(t *testing.T) {
306         telegram := newFakeBot(t)
307         telegram.Close()
308
309         bot, logs := startTestBot(t, telegram)
310         startTestDaemonWithBot(t, newFakeResponses(t), bot)
311
312         require.Eventually(t, func() bool {
313                 return strings.Count(logs.String(), "no valid answer") >= 2
314         }, 10*time.Second, time.Millisecond)
315         require.NotContains(t, logs.String(), testBotToken)
316 }
317
318 func TestSplitMessage(t *testing.T) {
319         testCases := []struct {
320                 name  string
321                 text  string
322                 limit int
323                 parts []string
324         }{
325                 {
326                         name:  "short text stays whole",
327                         text:  "one two",
328                         limit: 10,
329                         parts: []string{"one two"},
330                 },
331                 {
332                         name:  "cut after the last line break",
333                         text:  "first line\nsecond line",
334                         limit: 16,
335                         parts: []string{"first line\n", "second line"},
336                 },
337                 {
338                         name:  "cut after the last space without a line break",
339                         text:  "aaaa bbbb cccc",
340                         limit: 12,
341                         parts: []string{"aaaa bbbb ", "cccc"},
342                 },
343                 {
344                         name:  "hard cut of one long word",
345                         text:  "abcdefghij",
346                         limit: 4,
347                         parts: []string{"abcd", "efgh", "ij"},
348                 },
349                 {
350                         name:  "a character outside the BMP counts two units and is not cut",
351                         text:  "ab\U0001F600cd",
352                         limit: 3,
353                         parts: []string{"ab", "\U0001F600c", "d"},
354                 },
355         }
356
357         for _, tc := range testCases {
358                 t.Run(tc.name, func(t *testing.T) {
359                         require.Equal(t, tc.parts, splitMessage(tc.text, tc.limit))
360                 })
361         }
362
363         long := strings.Repeat("word ", 2000)
364         parts := splitMessage(long, messageUnits)
365         require.Len(t, parts, 3)
366         require.Equal(t, long, strings.Join(parts, ""))
367 }
368
369 func TestSplitEntitiesCutsAnEntityAcrossASplit(t *testing.T) {
370         parts := splitEntities("aabbbbcc", []botEntity{
371                 {
372                         Type:   "bold",
373                         Offset: 2,
374                         Length: 4,
375                 },
376                 {
377                         Type:   "italic",
378                         Offset: 6,
379                         Length: 2,
380                 },
381         }, 5)
382
383         require.Equal(t, []botPart{
384                 {
385                         text: "aabbb",
386                         entities: []botEntity{
387                                 {
388                                         Type:   "bold",
389                                         Offset: 2,
390                                         Length: 3,
391                                 },
392                         },
393                 },
394                 {
395                         text: "bcc",
396                         entities: []botEntity{
397                                 {
398                                         Type:   "bold",
399                                         Offset: 0,
400                                         Length: 1,
401                                 },
402                                 {
403                                         Type:   "italic",
404                                         Offset: 1,
405                                         Length: 2,
406                                 },
407                         },
408                 },
409         }, parts)
410 }
411
412 func TestSplitRich(t *testing.T) {
413         testCases := []struct {
414                 name     string
415                 markdown string
416                 limit    int
417                 parts    []string
418         }{
419                 {
420                         name:     "short reply stays whole",
421                         markdown: "| a | b |",
422                         limit:    100,
423                         parts:    []string{"| a | b |"},
424                 },
425                 {
426                         name:     "cut after a blank line keeps a table whole",
427                         markdown: "Intro.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n",
428                         limit:    30,
429                         parts:    []string{"Intro.\n\n", "| a | b |\n|---|---|\n| 1 | 2 |\n"},
430                 },
431                 {
432                         name:     "blank line in a code block is not a cut",
433                         markdown: "Text.\n\n```go\nx\n\ny\n```\n\nEnd.\n",
434                         limit:    20,
435                         parts:    []string{"Text.\n\n", "```go\nx\n\ny\n```\n\n", "End.\n"},
436                 },
437                 {
438                         name:     "paragraph with no blank line is cut after a line",
439                         markdown: "aaa\nbbb\nccc\n",
440                         limit:    9,
441                         parts:    []string{"aaa\nbbb\n", "ccc\n"},
442                 },
443                 {
444                         name:     "long code block is closed and opened again",
445                         markdown: "```sh\none\ntwo\nthree\n```\n",
446                         limit:    16,
447                         parts:    []string{"```sh\none\n```\n", "```sh\ntwo\n```\n", "```sh\nthree\n```\n"},
448                 },
449                 {
450                         name:     "long line is cut at the limit and not inside a character",
451                         markdown: "ab\u00e9cd",
452                         limit:    3,
453                         parts:    []string{"ab", "\u00e9c", "d"},
454                 },
455         }
456
457         for _, tc := range testCases {
458                 t.Run(tc.name, func(t *testing.T) {
459                         require.Equal(t, tc.parts, splitRich(tc.markdown, tc.limit))
460                 })
461         }
462
463         long := strings.Repeat("A paragraph of the reply.\n\n", 2000)
464         parts := splitRich(long, richMessageBytes)
465         require.Len(t, parts, 2)
466         require.Len(t, parts[0], richMessageBytes-richMessageBytes%len("A paragraph of the reply.\n\n"))
467         require.Equal(t, long, strings.Join(parts, ""))
468 }
469
470 func TestLoadBotConfig(t *testing.T) {
471         testCases := []struct {
472                 name      string
473                 content   string
474                 mode      os.FileMode
475                 errorText string
476         }{
477                 {
478                         name:    "owner file is read",
479                         content: `{"token": "` + testBotToken + `", "owner": 4242}`,
480                         mode:    0o600,
481                 },
482                 {
483                         name:      "file that others can read is refused",
484                         content:   `{"token": "` + testBotToken + `", "owner": 4242}`,
485                         mode:      0o640,
486                         errorText: "mode must be 0600",
487                 },
488                 {
489                         name:      "token with a path character is refused",
490                         content:   `{"token": "123:abc/../x", "owner": 4242}`,
491                         mode:      0o600,
492                         errorText: "form of a bot token",
493                 },
494                 {
495                         name:      "missing owner is refused",
496                         content:   `{"token": "` + testBotToken + `"}`,
497                         mode:      0o600,
498                         errorText: "numeric Telegram user ID",
499                 },
500                 {
501                         name:      "unknown field is refused",
502                         content:   `{"token": "` + testBotToken + `", "owner": 4242, "chat": 1}`,
503                         mode:      0o600,
504                         errorText: "not valid JSON",
505                 },
506         }
507
508         for _, tc := range testCases {
509                 t.Run(tc.name, func(t *testing.T) {
510                         dir := t.TempDir()
511                         path := filepath.Join(dir, botConfigFileName)
512                         require.NoError(t, os.WriteFile(path, []byte(tc.content), tc.mode))
513                         require.NoError(t, os.Chmod(path, tc.mode))
514
515                         config, found, err := loadBotConfig(dir)
516                         if tc.errorText != "" {
517                                 require.ErrorContains(t, err, tc.errorText)
518                                 require.NotContains(t, err.Error(), testBotToken)
519
520                                 return
521                         }
522
523                         require.NoError(t, err)
524                         require.True(t, found)
525                         require.Equal(t, botConfig{
526                                 Token: testBotToken,
527                                 Owner: testOwner,
528                         }, config)
529                 })
530         }
531
532         _, found, err := loadBotConfig(t.TempDir())
533         require.NoError(t, err)
534         require.False(t, found)
535 }
536
537 // startTestBot returns a bot of testOwner on server, with its offset file in a
538 // temporary directory and short waits, and the buffer of its log.
539 func startTestBot(t *testing.T, server *fakeBot) (*telegramBot, *syncBuffer) {
540         t.Helper()
541
542         logs := &syncBuffer{}
543         bot := newTelegramBot(newBotAPI(server.URL, testBotToken), testOwner, filepath.Join(t.TempDir(), offsetFileName), log.New(logs, "", 0))
544         bot.minWait = time.Millisecond
545         bot.maxWait = 10 * time.Millisecond
546
547         return bot, logs
548 }
549
550 // waitForSent waits until the bot sent the texts in order, and returns them.
551 func waitForSent(t *testing.T, server *fakeBot, texts ...string) []string {
552         t.Helper()
553
554         require.Eventually(t, func() bool {
555                 sent := server.Sent()
556                 return len(sent) >= len(texts) && slices.Equal(sent[len(sent)-len(texts):], texts)
557         }, 10*time.Second, time.Millisecond, "sent so far: %q", server.Sent())
558
559         return server.Sent()
560 }