package main import ( "log" "net/http" "os" "path/filepath" "slices" "strings" "testing" "time" "github.com/stretchr/testify/require" ) func TestOwnerTalksToTheAgentThroughTelegram(t *testing.T) { server := newFakeResponses(t) server.reply = func(w http.ResponseWriter, call fakeCall) { writeEvents(w, completedEvent([]any{messageItem("Hello from the agent.")}, 100, 0, 5)) } telegram := newFakeBot(t) bot, _ := startTestBot(t, telegram) socket := startTestDaemonWithBot(t, server, bot) terminal := startTestClient(t, socket) terminal.WaitFor(t, "[connected to OneChat]") telegram.Add(botUpdate{ UpdateID: 1, Message: ownerMessage("hi from the phone"), }) terminal.WaitFor(t, "[telegram] hi from the phone\n[working]\nHello from the agent.\n[done]") waitForSent(t, telegram, "Hello from the agent.") require.True(t, slices.ContainsFunc(telegram.Calls(), func(call fakeBotCall) 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."}, waitForSent(t, telegram, "Hello from the agent.", "[terminal] hi from the desk", "Hello from the agent.")) } func TestBotIgnoresEveryoneButTheOwner(t *testing.T) { telegram := newFakeBot(t) bot, logs := startTestBot(t, telegram) socket := startTestDaemonWithBot(t, newFakeResponses(t), bot) terminal := startTestClient(t, socket) terminal.WaitFor(t, "[connected to OneChat]") group := ownerMessage("secret in a group") group.Chat = botChat{ ID: -100, Type: "group", } stranger := ownerMessage("secret of a stranger") stranger.Chat.ID = 77 stranger.From.ID = 77 unsigned := ownerMessage("secret with no sender") unsigned.From = nil for id, message := range []*botMessage{group, stranger, unsigned, nil} { telegram.Add(botUpdate{ UpdateID: int64(10 + id), Message: message, }) } require.Eventually(t, func() bool { offset, err := loadOffset(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 := newFakeBot(t) bot, _ := startTestBot(t, telegram) startTestDaemonWithBot(t, newFakeResponses(t), bot) telegram.Add(botUpdate{ UpdateID: 1, Message: ownerMessage(""), }) waitForSent(t, telegram, textOnly) } func TestStopFromTelegramInterruptsTheTurn(t *testing.T) { server := newFakeResponses(t) arrived := make(chan struct{}) release := make(chan struct{}) t.Cleanup(func() { close(release) }) server.reply = func(w http.ResponseWriter, call fakeCall) { close(arrived) <-release } telegram := newFakeBot(t) bot, _ := startTestBot(t, telegram) startTestDaemonWithBot(t, server, bot) telegram.Add(botUpdate{ UpdateID: 1, Message: ownerMessage("a long task"), }) <-arrived telegram.Add(botUpdate{ UpdateID: 2, Message: ownerMessage(stopCommand), }) waitForSent(t, telegram, "Error: interrupted") telegram.Add(botUpdate{ UpdateID: 3, Message: ownerMessage(stopCommand), }) waitForSent(t, telegram, "Error: interrupted", "No turn runs.") } func TestBotStartsAtTheStoredOffset(t *testing.T) { server := newFakeResponses(t) server.reply = func(w http.ResponseWriter, call fakeCall) { writeEvents(w, completedEvent([]any{messageItem("ok")}, 100, 0, 5)) } telegram := newFakeBot(t) bot, _ := startTestBot(t, telegram) require.NoError(t, saveOffset(bot.offsetPath, 10)) telegram.Add(botUpdate{ UpdateID: 9, Message: ownerMessage("handled before the restart"), }) telegram.Add(botUpdate{ UpdateID: 10, Message: ownerMessage("new after the restart"), }) socket := startTestDaemonWithBot(t, server, bot) terminal := startTestClient(t, socket) terminal.WaitFor(t, "[connected to OneChat]") waitForSent(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 := loadOffset(bot.offsetPath) return err == nil && offset == 11 }, 10*time.Second, time.Millisecond) } func TestBotTriesAgainAfterFailures(t *testing.T) { server := newFakeResponses(t) server.reply = func(w http.ResponseWriter, call fakeCall) { writeEvents(w, completedEvent([]any{messageItem("ok")}, 100, 0, 5)) } telegram := newFakeBot(t) failures := map[string]int{ "getUpdates": 2, "sendMessage": 1, } telegram.fail = func(call fakeBotCall) (int, string) { if failures[call.method] == 0 { return 0, "" } failures[call.method]-- if call.method == "sendMessage" { return http.StatusTooManyRequests, "Too Many Requests: retry after 1" } return http.StatusBadGateway, "Bad Gateway" } bot, logs := startTestBot(t, telegram) startTestDaemonWithBot(t, server, bot) telegram.Add(botUpdate{ UpdateID: 1, Message: ownerMessage("hello"), }) waitForSent(t, telegram, "ok") require.Contains(t, logs.String(), "answered 502") require.Contains(t, logs.String(), "answered 429") require.NotContains(t, logs.String(), testBotToken) } func TestDeliverTriesAgainAfterATimeout(t *testing.T) { telegram := newFakeBot(t) stalls := 1 telegram.stall = func(call fakeBotCall) bool { if call.method != "sendMessage" || stalls == 0 { return false } stalls-- return true } bot, logs := startTestBot(t, telegram) bot.api.sendTimeout = 50 * time.Millisecond bot.deliver(t.Context(), botPart{ 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(), testBotToken) } func TestOnlyRepliesGetEntities(t *testing.T) { server := newFakeResponses(t) server.reply = func(w http.ResponseWriter, call fakeCall) { writeEvents(w, completedEvent([]any{messageItem("**Done** with `x`.")}, 100, 0, 5)) } telegram := newFakeBot(t) bot, _ := startTestBot(t, telegram) socket := startTestDaemonWithBot(t, server, bot) terminal := startTestClient(t, socket) terminal.WaitFor(t, "[connected to OneChat]") terminal.Type("make **it** so") waitForSent(t, telegram, "[terminal] make **it** so", "Done with x.") telegram.Add(botUpdate{ UpdateID: 1, Message: ownerMessage(""), }) waitForSent(t, telegram, textOnly) entities := map[string]any{} for _, call := range telegram.Calls() { if call.method == "sendMessage" { entities[call.params["text"].(string)] = call.params["entities"] } } require.Equal(t, map[string]any{ "[terminal] make **it** so": nil, "Done with x.": []any{ map[string]any{ "type": "bold", "offset": float64(0), "length": float64(4), }, map[string]any{ "type": "code", "offset": float64(10), "length": float64(1), }, }, textOnly: nil, }, entities) } func TestRefusedEntitiesGoAgainAsPlainText(t *testing.T) { telegram := newFakeBot(t) telegram.fail = func(call fakeBotCall) (int, string) { if call.params["entities"] == nil { return 0, "" } return http.StatusBadRequest, "Bad Request: can't parse entities of bot" + testBotToken } bot, logs := startTestBot(t, telegram) text, entities := markdownEntities("a **secret** word") bot.deliver(t.Context(), botPart{ 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(), testBotToken) require.NotContains(t, logs.String(), "secret word") } func TestBotLogsNoTokenWhenTheNetworkFails(t *testing.T) { telegram := newFakeBot(t) telegram.Close() bot, logs := startTestBot(t, telegram) startTestDaemonWithBot(t, newFakeResponses(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(), testBotToken) } 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, splitMessage(tc.text, tc.limit)) }) } long := strings.Repeat("word ", 2000) parts := splitMessage(long, messageUnits) require.Len(t, parts, 3) require.Equal(t, long, strings.Join(parts, "")) } func TestSplitEntitiesCutsAnEntityAcrossASplit(t *testing.T) { parts := splitEntities("aabbbbcc", []botEntity{ { Type: "bold", Offset: 2, Length: 4, }, { Type: "italic", Offset: 6, Length: 2, }, }, 5) require.Equal(t, []botPart{ { text: "aabbb", entities: []botEntity{ { Type: "bold", Offset: 2, Length: 3, }, }, }, { text: "bcc", entities: []botEntity{ { 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"}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { require.Equal(t, tc.parts, splitRich(tc.markdown, tc.limit)) }) } long := strings.Repeat("A paragraph of the reply.\n\n", 2000) parts := splitRich(long, richMessageBytes) require.Len(t, parts, 2) require.Len(t, parts[0], richMessageBytes-richMessageBytes%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": "` + testBotToken + `", "owner": 4242}`, mode: 0o600, }, { name: "file that others can read is refused", content: `{"token": "` + testBotToken + `", "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": "` + testBotToken + `"}`, mode: 0o600, errorText: "numeric Telegram user ID", }, { name: "unknown field is refused", content: `{"token": "` + testBotToken + `", "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, botConfigFileName) require.NoError(t, os.WriteFile(path, []byte(tc.content), tc.mode)) require.NoError(t, os.Chmod(path, tc.mode)) config, found, err := loadBotConfig(dir) if tc.errorText != "" { require.ErrorContains(t, err, tc.errorText) require.NotContains(t, err.Error(), testBotToken) return } require.NoError(t, err) require.True(t, found) require.Equal(t, botConfig{ Token: testBotToken, Owner: testOwner, }, config) }) } _, found, err := loadBotConfig(t.TempDir()) require.NoError(t, err) require.False(t, found) } // startTestBot returns a bot of testOwner on server, with its offset file in a // temporary directory and short waits, and the buffer of its log. func startTestBot(t *testing.T, server *fakeBot) (*telegramBot, *syncBuffer) { t.Helper() logs := &syncBuffer{} bot := newTelegramBot(newBotAPI(server.URL, testBotToken), testOwner, filepath.Join(t.TempDir(), offsetFileName), log.New(logs, "", 0)) bot.minWait = time.Millisecond bot.maxWait = 10 * time.Millisecond return bot, logs } // waitForSent waits until the bot sent the texts in order, and returns them. func waitForSent(t *testing.T, server *fakeBot, 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() }