]> git.giacomolli.uk - onechat/blob - telegram_test.go
Name package-level code for its feature
[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 }