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