package main import ( "log" "net/http" "os" "path/filepath" "slices" "strings" "testing" "time" "github.com/stretchr/testify/require" ) func TestOwnerTalksToTheAgentThroughTelegram(t *testing.T) { server := newModelFake(t) server.reply = func(w http.ResponseWriter, call ModelFakeCall) { modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Hello from the agent.")}, 100, 0, 5)) } telegram := newTelegramFake(t) bot, _ := telegramStartTestBot(t, telegram) socket := daemonStartTestWithBot(t, server, bot) terminal := terminalStartTestClient(t, socket) terminal.WaitFor(t, "[connected to OneChat]") telegram.Add(TelegramUpdate{ UpdateID: 1, Message: telegramOwnerMessage("hi from the phone"), }) terminal.WaitFor(t, "[telegram] hi from the phone\n[working]\nHello from the agent.\n[done]") telegramWaitForSent(t, telegram, "Hello from the agent.") require.True(t, slices.ContainsFunc(telegram.Calls(), func(call TelegramFakeCall) bool { return call.method == "sendChatAction" && call.params["action"] == "typing" })) terminal.Type("hi from the desk") 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.")) } func TestBotIgnoresEveryoneButTheOwner(t *testing.T) { telegram := newTelegramFake(t) bot, logs := telegramStartTestBot(t, telegram) socket := daemonStartTestWithBot(t, newModelFake(t), bot) terminal := terminalStartTestClient(t, socket) terminal.WaitFor(t, "[connected to OneChat]") group := telegramOwnerMessage("secret in a group") group.Chat = TelegramChat{ ID: -100, Type: "group", } stranger := telegramOwnerMessage("secret of a stranger") stranger.Chat.ID = 77 stranger.From.ID = 77 unsigned := telegramOwnerMessage("secret with no sender") unsigned.From = nil for id, message := range []*TelegramMessage{group, stranger, unsigned, nil} { telegram.Add(TelegramUpdate{ UpdateID: int64(10 + id), Message: message, }) } require.Eventually(t, func() bool { offset, err := telegramLoadOffset(bot.offsetPath) return err == nil && offset == 14 }, 10*time.Second, time.Millisecond) require.Empty(t, telegram.Sent()) require.NotContains(t, terminal.Output(), "secret") require.NotContains(t, logs.String(), "secret") require.Contains(t, logs.String(), "update ignored") } func TestBotAnswersThatOnlyTextIsSupported(t *testing.T) { telegram := newTelegramFake(t) bot, _ := telegramStartTestBot(t, telegram) daemonStartTestWithBot(t, newModelFake(t), bot) telegram.Add(TelegramUpdate{ UpdateID: 1, Message: telegramOwnerMessage(""), }) telegramWaitForSent(t, telegram, telegramTextOnly) } func TestStopFromTelegramInterruptsTheTurn(t *testing.T) { server := newModelFake(t) arrived := make(chan struct{}) release := make(chan struct{}) t.Cleanup(func() { close(release) }) server.reply = func(w http.ResponseWriter, call ModelFakeCall) { close(arrived) <-release } telegram := newTelegramFake(t) bot, _ := telegramStartTestBot(t, telegram) daemonStartTestWithBot(t, server, bot) telegram.Add(TelegramUpdate{ UpdateID: 1, Message: telegramOwnerMessage("a long task"), }) <-arrived telegram.Add(TelegramUpdate{ UpdateID: 2, Message: telegramOwnerMessage(stopCommand), }) telegramWaitForSent(t, telegram, "Error: interrupted") telegram.Add(TelegramUpdate{ UpdateID: 3, Message: telegramOwnerMessage(stopCommand), }) telegramWaitForSent(t, telegram, "Error: interrupted", "No turn runs.") } func TestBotStartsAtTheStoredOffset(t *testing.T) { server := newModelFake(t) server.reply = func(w http.ResponseWriter, call ModelFakeCall) { modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5)) } telegram := newTelegramFake(t) bot, _ := telegramStartTestBot(t, telegram) require.NoError(t, telegramSaveOffset(bot.offsetPath, 10)) telegram.Add(TelegramUpdate{ UpdateID: 9, Message: telegramOwnerMessage("handled before the restart"), }) telegram.Add(TelegramUpdate{ UpdateID: 10, Message: telegramOwnerMessage("new after the restart"), }) socket := daemonStartTestWithBot(t, server, bot) terminal := terminalStartTestClient(t, socket) terminal.WaitFor(t, "[connected to OneChat]") telegramWaitForSent(t, telegram, "ok") require.Equal(t, float64(10), telegram.Calls()[0].params["offset"]) require.NotContains(t, terminal.Output(), "handled before the restart") require.Eventually(t, func() bool { offset, err := telegramLoadOffset(bot.offsetPath) return err == nil && offset == 11 }, 10*time.Second, time.Millisecond) } func TestBotTriesAgainAfterFailures(t *testing.T) { server := newModelFake(t) server.reply = func(w http.ResponseWriter, call ModelFakeCall) { modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5)) } telegram := newTelegramFake(t) failures := map[string]int{ "getUpdates": 2, "sendRichMessage": 1, } telegram.fail = func(call TelegramFakeCall) (int, string) { if failures[call.method] == 0 { return 0, "" } failures[call.method]-- if call.method == "sendRichMessage" { return http.StatusTooManyRequests, "Too Many Requests: retry after 1" } return http.StatusBadGateway, "Bad Gateway" } bot, logs := telegramStartTestBot(t, telegram) daemonStartTestWithBot(t, server, bot) telegram.Add(TelegramUpdate{ UpdateID: 1, Message: telegramOwnerMessage("hello"), }) telegramWaitForSent(t, telegram, "ok") require.Contains(t, logs.String(), "answered 502") require.Contains(t, logs.String(), "answered 429") require.NotContains(t, logs.String(), telegramTestToken) } func TestDeliverTriesAgainAfterATimeout(t *testing.T) { telegram := newTelegramFake(t) stalls := 1 telegram.stall = func(call TelegramFakeCall) bool { if call.method != "sendMessage" || stalls == 0 { return false } stalls-- return true } bot, logs := telegramStartTestBot(t, telegram) bot.api.sendTimeout = 50 * time.Millisecond bot.deliver(t.Context(), TelegramPart{ text: "hello", }) require.Equal(t, []string{"hello"}, telegram.Sent()) require.Len(t, telegram.Calls(), 2) require.Contains(t, logs.String(), "no valid answer") require.NotContains(t, logs.String(), telegramTestToken) } func TestRepliesGoAsRichMessages(t *testing.T) { reply := "**Done.** The table:\n\n| Name | Size |\n|:-----|-----:|\n| `a.go` | 10 |\n\n- one\n- two\n\n```go\nx := 1\n```" server := newModelFake(t) server.reply = func(w http.ResponseWriter, call ModelFakeCall) { modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(reply)}, 100, 0, 5)) } telegram := newTelegramFake(t) bot, _ := telegramStartTestBot(t, telegram) socket := daemonStartTestWithBot(t, server, bot) terminal := terminalStartTestClient(t, socket) terminal.WaitFor(t, "[connected to OneChat]") terminal.Type("make **it** so") telegramWaitForSent(t, telegram, "[terminal] make **it** so", reply) telegram.Add(TelegramUpdate{ UpdateID: 1, Message: telegramOwnerMessage(""), }) telegramWaitForSent(t, telegram, telegramTextOnly) var sends []TelegramFakeCall for _, call := range telegram.Calls() { if call.method == "sendMessage" || call.method == "sendRichMessage" { sends = append(sends, call) } } require.Equal(t, []TelegramFakeCall{ { method: "sendMessage", params: map[string]any{ "chat_id": float64(telegramTestOwner), "text": "[terminal] make **it** so", }, ok: true, }, { method: "sendRichMessage", params: map[string]any{ "chat_id": float64(telegramTestOwner), "rich_message": map[string]any{ "markdown": reply, }, }, ok: true, }, { method: "sendMessage", params: map[string]any{ "chat_id": float64(telegramTestOwner), "text": telegramTextOnly, }, ok: true, }, }, sends) } func TestRichMessageFailures(t *testing.T) { testCases := []struct { name string status int always bool methods []string sent []string logText string }{ { name: "bad request sends the reply once with entities", status: http.StatusBadRequest, always: true, methods: []string{"sendRichMessage", "sendMessage"}, sent: []string{"a secret word"}, logText: "rich message refused", }, { name: "unknown method sends the reply once with entities", status: http.StatusNotFound, always: true, methods: []string{"sendRichMessage", "sendMessage"}, sent: []string{"a secret word"}, logText: "rich message refused", }, { name: "rate limit tries the rich message again", status: http.StatusTooManyRequests, methods: []string{"sendRichMessage", "sendRichMessage"}, sent: []string{"a **secret** word"}, logText: "answered 429", }, { name: "server error tries the rich message again", status: http.StatusInternalServerError, methods: []string{"sendRichMessage", "sendRichMessage"}, sent: []string{"a **secret** word"}, logText: "answered 500", }, { name: "other refusal drops the reply", status: http.StatusForbidden, always: true, methods: []string{"sendRichMessage"}, logText: "message dropped", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { telegram := newTelegramFake(t) failed := false telegram.fail = func(call TelegramFakeCall) (int, string) { if call.method != "sendRichMessage" || failed && !tc.always { return 0, "" } failed = true return tc.status, "refused for bot" + telegramTestToken } bot, logs := telegramStartTestBot(t, telegram) bot.post(t.Context(), Event{ Kind: "reply", Text: "a **secret** word", }) var methods []string for _, call := range telegram.Calls() { methods = append(methods, call.method) } require.Equal(t, tc.methods, methods) require.Equal(t, tc.sent, telegram.Sent()) require.Equal(t, 1, strings.Count(logs.String(), "\n")) require.Contains(t, logs.String(), tc.logText) require.NotContains(t, logs.String(), telegramTestToken) require.NotContains(t, logs.String(), "secret word") calls := telegram.Calls() if calls[len(calls)-1].method == "sendMessage" { require.NotNil(t, calls[len(calls)-1].params["entities"]) } }) } } func TestLongReplyGoesAsRichMessagesSplitBetweenBlocks(t *testing.T) { paragraphs := strings.Repeat("A paragraph of the reply.\n\n", telegramRichBytes/len("A paragraph of the reply.\n\n")) table := "| a | b |\n|---|---|\n| 1 | 2 |\n" reply := paragraphs + table telegram := newTelegramFake(t) bot, _ := telegramStartTestBot(t, telegram) bot.post(t.Context(), Event{ Kind: "reply", Text: reply, }) require.Equal(t, []string{paragraphs, table}, telegram.Sent()) } func TestReplyTagsGoAsText(t *testing.T) { testCases := []struct { name string status int sent []string }{ { name: "rich message escapes the tag outside code", sent: []string{"a <details> & tag and ``"}, }, { name: "entity fallback shows the tag as written", status: http.StatusBadRequest, sent: []string{"a
& tag and "}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { telegram := newTelegramFake(t) telegram.fail = func(call TelegramFakeCall) (int, string) { if call.method != "sendRichMessage" { return 0, "" } return tc.status, "Bad Request" } bot, _ := telegramStartTestBot(t, telegram) bot.post(t.Context(), Event{ Kind: "reply", Text: "a
& tag and ``", }) require.Equal(t, tc.sent, telegram.Sent()) }) } } func TestLongReplySplitCountsTheEscapedTags(t *testing.T) { paragraph := "A tag.\n\n" count := telegramRichBytes / len(paragraph) reply := strings.Repeat(paragraph, count) require.LessOrEqual(t, len(reply), telegramRichBytes) telegram := newTelegramFake(t) bot, _ := telegramStartTestBot(t, telegram) bot.post(t.Context(), Event{ Kind: "reply", Text: reply, }) sent := telegram.Sent() require.Len(t, sent, 2) require.Equal(t, telegramEscapeTags(reply), strings.Join(sent, "")) for _, markdown := range sent { require.LessOrEqual(t, len(markdown), telegramRichBytes) } } func TestRefusedRichPartGoesWithTheTextAsWritten(t *testing.T) { paragraph := "A & c.\n\n" reply := strings.Repeat(paragraph, telegramRichBytes/len(paragraph)) telegram := newTelegramFake(t) refused := false telegram.fail = func(call TelegramFakeCall) (int, string) { if call.method != "sendRichMessage" || refused { return 0, "" } refused = true return http.StatusBadRequest, "Bad Request" } bot, _ := telegramStartTestBot(t, telegram) bot.post(t.Context(), Event{ Kind: "reply", Text: reply, }) var fallback, rich []string for _, call := range telegram.Calls() { switch { case !call.ok: case call.method == "sendMessage": fallback = append(fallback, call.params["text"].(string)) case call.method == "sendRichMessage": rich = append(rich, call.params["rich_message"].(map[string]any)["markdown"].(string)) } } require.Greater(t, len(fallback), 1) require.Len(t, rich, 1) require.True(t, strings.HasPrefix(reply, strings.Join(fallback, "")), "fallback text is not the reply as written") for _, text := range fallback { require.LessOrEqual(t, telegramUTF16Units(text), telegramMessageUnits) } require.True(t, reply == strings.Join(fallback, "")+telegramUnescapeTags(rich[0]), "reply is not sent once and whole") } func TestRefusedEntitiesGoAgainAsPlainText(t *testing.T) { telegram := newTelegramFake(t) telegram.fail = func(call TelegramFakeCall) (int, string) { if call.params["entities"] == nil { return 0, "" } return http.StatusBadRequest, "Bad Request: can't parse entities of bot" + telegramTestToken } bot, logs := telegramStartTestBot(t, telegram) text, entities := telegramMarkdownEntities("a **secret** word") bot.deliver(t.Context(), TelegramPart{ text: text, entities: entities, }) calls := telegram.Calls() require.Len(t, calls, 2) require.NotNil(t, calls[0].params["entities"]) require.Nil(t, calls[1].params["entities"]) require.Equal(t, []string{"a secret word"}, telegram.Sent()) require.Equal(t, 1, strings.Count(logs.String(), "\n")) require.Contains(t, logs.String(), "entities refused") require.NotContains(t, logs.String(), telegramTestToken) require.NotContains(t, logs.String(), "secret word") } func TestBotLogsNoTokenWhenTheNetworkFails(t *testing.T) { telegram := newTelegramFake(t) telegram.Close() bot, logs := telegramStartTestBot(t, telegram) daemonStartTestWithBot(t, newModelFake(t), bot) require.Eventually(t, func() bool { return strings.Count(logs.String(), "no valid answer") >= 2 }, 10*time.Second, time.Millisecond) require.NotContains(t, logs.String(), telegramTestToken) } func TestSplitMessage(t *testing.T) { testCases := []struct { name string text string limit int parts []string }{ { name: "short text stays whole", text: "one two", limit: 10, parts: []string{"one two"}, }, { name: "cut after the last line break", text: "first line\nsecond line", limit: 16, parts: []string{"first line\n", "second line"}, }, { name: "cut after the last space without a line break", text: "aaaa bbbb cccc", limit: 12, parts: []string{"aaaa bbbb ", "cccc"}, }, { name: "hard cut of one long word", text: "abcdefghij", limit: 4, parts: []string{"abcd", "efgh", "ij"}, }, { name: "a character outside the BMP counts two units and is not cut", text: "ab\U0001F600cd", limit: 3, parts: []string{"ab", "\U0001F600c", "d"}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { require.Equal(t, tc.parts, telegramSplitMessage(tc.text, tc.limit)) }) } long := strings.Repeat("word ", 2000) parts := telegramSplitMessage(long, telegramMessageUnits) require.Len(t, parts, 3) require.Equal(t, long, strings.Join(parts, "")) } func TestSplitEntitiesCutsAnEntityAcrossASplit(t *testing.T) { parts := telegramSplitEntities("aabbbbcc", []TelegramEntity{ { Type: "bold", Offset: 2, Length: 4, }, { Type: "italic", Offset: 6, Length: 2, }, }, 5) require.Equal(t, []TelegramPart{ { text: "aabbb", entities: []TelegramEntity{ { Type: "bold", Offset: 2, Length: 3, }, }, }, { text: "bcc", entities: []TelegramEntity{ { Type: "bold", Offset: 0, Length: 1, }, { Type: "italic", Offset: 1, Length: 2, }, }, }, }, parts) } func TestSplitRich(t *testing.T) { testCases := []struct { name string markdown string limit int parts []string }{ { name: "short reply stays whole", markdown: "| a | b |", limit: 100, parts: []string{"| a | b |"}, }, { name: "cut after a blank line keeps a table whole", markdown: "Intro.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n", limit: 30, parts: []string{"Intro.\n\n", "| a | b |\n|---|---|\n| 1 | 2 |\n"}, }, { name: "blank line in a code block is not a cut", markdown: "Text.\n\n```go\nx\n\ny\n```\n\nEnd.\n", limit: 20, parts: []string{"Text.\n\n", "```go\nx\n\ny\n```\n\n", "End.\n"}, }, { name: "paragraph with no blank line is cut after a line", markdown: "aaa\nbbb\nccc\n", limit: 9, parts: []string{"aaa\nbbb\n", "ccc\n"}, }, { name: "long code block is closed and opened again", markdown: "```sh\none\ntwo\nthree\n```\n", limit: 16, parts: []string{"```sh\none\n```\n", "```sh\ntwo\n```\n", "```sh\nthree\n```\n"}, }, { name: "long line is cut at the limit and not inside a character", markdown: "ab\u00e9cd", limit: 3, parts: []string{"ab", "\u00e9c", "d"}, }, { name: "long line is not cut inside an entity", markdown: "abc&def", limit: 6, parts: []string{"abc", "&d", "ef"}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { require.Equal(t, tc.parts, telegramSplitRich(tc.markdown, tc.limit)) }) } long := strings.Repeat("A paragraph of the reply.\n\n", 2000) parts := telegramSplitRich(long, telegramRichBytes) require.Len(t, parts, 2) require.Len(t, parts[0], telegramRichBytes-telegramRichBytes%len("A paragraph of the reply.\n\n")) require.Equal(t, long, strings.Join(parts, "")) } func TestLoadBotConfig(t *testing.T) { testCases := []struct { name string content string mode os.FileMode errorText string }{ { name: "owner file is read", content: `{"token": "` + telegramTestToken + `", "owner": 4242}`, mode: 0o600, }, { name: "file that others can read is refused", content: `{"token": "` + telegramTestToken + `", "owner": 4242}`, mode: 0o640, errorText: "mode must be 0600", }, { name: "token with a path character is refused", content: `{"token": "123:abc/../x", "owner": 4242}`, mode: 0o600, errorText: "form of a bot token", }, { name: "missing owner is refused", content: `{"token": "` + telegramTestToken + `"}`, mode: 0o600, errorText: "numeric Telegram user ID", }, { name: "unknown field is refused", content: `{"token": "` + telegramTestToken + `", "owner": 4242, "chat": 1}`, mode: 0o600, errorText: "not valid JSON", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, telegramConfigFileName) require.NoError(t, os.WriteFile(path, []byte(tc.content), tc.mode)) require.NoError(t, os.Chmod(path, tc.mode)) config, found, err := loadTelegramConfig(dir) if tc.errorText != "" { require.ErrorContains(t, err, tc.errorText) require.NotContains(t, err.Error(), telegramTestToken) return } require.NoError(t, err) require.True(t, found) require.Equal(t, TelegramConfig{ Token: telegramTestToken, Owner: telegramTestOwner, }, config) }) } _, found, err := loadTelegramConfig(t.TempDir()) require.NoError(t, err) require.False(t, found) } // telegramStartTestBot returns a bot of telegramTestOwner on server, with its // offset file in a temporary directory and short waits, and the buffer of its // log. func telegramStartTestBot(t *testing.T, server *TelegramFake) (*TelegramBot, *SyncBuffer) { t.Helper() logs := &SyncBuffer{} bot := newTelegramBot(newTelegramAPI(server.URL, telegramTestToken), telegramTestOwner, filepath.Join(t.TempDir(), telegramOffsetFileName), log.New(logs, "", 0)) bot.minWait = time.Millisecond bot.maxWait = 10 * time.Millisecond return bot, logs } // telegramWaitForSent waits until the bot sent the texts in order, and returns // them. func telegramWaitForSent(t *testing.T, server *TelegramFake, texts ...string) []string { t.Helper() require.Eventually(t, func() bool { sent := server.Sent() return len(sent) >= len(texts) && slices.Equal(sent[len(sent)-len(texts):], texts) }, 10*time.Second, time.Millisecond, "sent so far: %q", server.Sent()) return server.Sent() }