"os"
"path/filepath"
"slices"
+ "strconv"
"strings"
"testing"
"time"
)
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))
+ server := newModelFake(t)
+ server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+ modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Hello from the agent.")}, 100, 0, 5))
}
- telegram := newFakeBot(t)
- bot, _ := startTestBot(t, telegram)
- socket := startTestDaemonWithBot(t, server, bot)
- terminal := startTestClient(t, socket)
+ telegram := newTelegramFake(t)
+ bot, _ := telegramStartTestBot(t, telegram)
+ socket := daemonStartTestWithBot(t, server, bot)
+ terminal := terminalStartTestClient(t, socket)
terminal.WaitFor(t, "[connected to OneChat]")
- telegram.Add(botUpdate{
+ telegram.Add(TelegramUpdate{
UpdateID: 1,
- Message: ownerMessage("hi from the phone"),
+ Message: telegramOwnerMessage("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 {
+ 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."}, waitForSent(t, telegram, "Hello from the agent.", "[terminal] hi from the desk", "Hello from the agent."))
+ 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 := newFakeBot(t)
- bot, logs := startTestBot(t, telegram)
- socket := startTestDaemonWithBot(t, newFakeResponses(t), bot)
- terminal := startTestClient(t, socket)
+ 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 := ownerMessage("secret in a group")
- group.Chat = botChat{
+ group := telegramOwnerMessage("secret in a group")
+ group.Chat = TelegramChat{
ID: -100,
Type: "group",
}
- stranger := ownerMessage("secret of a stranger")
+ stranger := telegramOwnerMessage("secret of a stranger")
stranger.Chat.ID = 77
stranger.From.ID = 77
- unsigned := ownerMessage("secret with no sender")
+ unsigned := telegramOwnerMessage("secret with no sender")
unsigned.From = nil
- for id, message := range []*botMessage{group, stranger, unsigned, nil} {
- telegram.Add(botUpdate{
+ 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 := loadOffset(bot.offsetPath)
+ 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")
+ require.Contains(t, logs.String(), "a Telegram message is ignored")
}
func TestBotAnswersThatOnlyTextIsSupported(t *testing.T) {
- telegram := newFakeBot(t)
- bot, _ := startTestBot(t, telegram)
- startTestDaemonWithBot(t, newFakeResponses(t), bot)
+ telegram := newTelegramFake(t)
+ bot, _ := telegramStartTestBot(t, telegram)
+ daemonStartTestWithBot(t, newModelFake(t), bot)
- telegram.Add(botUpdate{
+ telegram.Add(TelegramUpdate{
UpdateID: 1,
- Message: ownerMessage(""),
+ Message: telegramOwnerMessage(""),
})
- waitForSent(t, telegram, textOnly)
+ telegramWaitForSent(t, telegram, telegramTextOnly)
}
func TestStopFromTelegramInterruptsTheTurn(t *testing.T) {
- server := newFakeResponses(t)
+ server := newModelFake(t)
arrived := make(chan struct{})
release := make(chan struct{})
t.Cleanup(func() {
close(release)
})
- server.reply = func(w http.ResponseWriter, call fakeCall) {
+ server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
close(arrived)
<-release
}
- telegram := newFakeBot(t)
- bot, _ := startTestBot(t, telegram)
- startTestDaemonWithBot(t, server, bot)
+ telegram := newTelegramFake(t)
+ bot, _ := telegramStartTestBot(t, telegram)
+ daemonStartTestWithBot(t, server, bot)
- telegram.Add(botUpdate{
+ telegram.Add(TelegramUpdate{
UpdateID: 1,
- Message: ownerMessage("a long task"),
+ Message: telegramOwnerMessage("a long task"),
})
<-arrived
- telegram.Add(botUpdate{
+ telegram.Add(TelegramUpdate{
UpdateID: 2,
- Message: ownerMessage(stopCommand),
+ Message: telegramOwnerMessage(stopCommand),
})
- waitForSent(t, telegram, "Error: interrupted")
+ telegramWaitForSent(t, telegram, "Error: interrupted")
- telegram.Add(botUpdate{
+ telegram.Add(TelegramUpdate{
UpdateID: 3,
- Message: ownerMessage(stopCommand),
+ Message: telegramOwnerMessage(stopCommand),
})
- waitForSent(t, telegram, "Error: interrupted", "No turn runs.")
+ telegramWaitForSent(t, telegram, "Error: interrupted", "No turn runs.")
+
+ telegram.Add(TelegramUpdate{
+ UpdateID: 4,
+ Message: telegramOwnerMessage(stopCommand + " BlueFox"),
+ })
+
+ telegramWaitForSent(t, telegram, "No turn runs.", "No subagent BlueFox runs.")
+}
+
+func TestBotShowsOnlyTheStartAndTheEndOfASubagent(t *testing.T) {
+ telegram := newTelegramFake(t)
+ server := newModelFake(t)
+ server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+ last := subagentTestLastItem(call)
+ switch {
+ case subagentTestCall(call):
+ // The report comes after the reply of the turn of the spawn.
+ require.Eventually(t, func() bool {
+ return slices.Contains(telegram.Sent(), "Started.")
+ }, 10*time.Second, time.Millisecond)
+ modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("There are 3 files.")}, 100, 0, 5))
+ case last["type"] == "function_call_output":
+ modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Started.")}, 100, 0, 5))
+ case strings.HasPrefix(subagentTestText(last), "["):
+ modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("It counted 3 files.")}, 100, 0, 5))
+ default:
+ modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "spawn", `{"tasks":["Count the files."],"effort":null}`)}, 100, 0, 5))
+ }
+ }
+
+ bot, _ := telegramStartTestBot(t, telegram)
+ daemonStartTestWithBot(t, server, bot)
+ telegram.Add(TelegramUpdate{
+ UpdateID: 1,
+ Message: telegramOwnerMessage("Count the files in a subagent."),
+ })
+
+ require.Eventually(t, func() bool {
+ return slices.Contains(telegram.Sent(), "It counted 3 files.")
+ }, 10*time.Second, time.Millisecond)
+
+ sent := telegram.Sent()
+ name := strings.TrimSuffix(strings.TrimPrefix(sent[0], "["), "] started")
+ require.True(t, subagentNameValid(name), sent[0])
+ require.Equal(t, []string{
+ "[" + name + "] started",
+ "Started.",
+ "[" + name + "] finished",
+ "It counted 3 files.",
+ }, sent)
+}
+
+func TestBotSkipsTheWorkOfWhatsAppAndTheSchedules(t *testing.T) {
+ bot := &TelegramBot{
+ ready: make(chan struct{}, 1),
+ }
+
+ bot.Notify(Event{
+ Kind: "work",
+ Source: whatsappSource,
+ Text: "[WhatsApp] Ana Souza",
+ })
+ bot.Notify(Event{
+ Kind: "work",
+ Source: scheduleSource,
+ Text: "[Schedule 1] Sunday 2026-11-08 20:05 -03:00, once",
+ })
+ require.Empty(t, bot.queue)
}
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))
+ server := newModelFake(t)
+ server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+ modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5))
}
- telegram := newFakeBot(t)
- bot, _ := startTestBot(t, telegram)
- require.NoError(t, saveOffset(bot.offsetPath, 10))
+ telegram := newTelegramFake(t)
+ bot, _ := telegramStartTestBot(t, telegram)
+ require.NoError(t, telegramSaveOffset(bot.offsetPath, 10))
- telegram.Add(botUpdate{
+ telegram.Add(TelegramUpdate{
UpdateID: 9,
- Message: ownerMessage("handled before the restart"),
+ Message: telegramOwnerMessage("handled before the restart"),
})
- telegram.Add(botUpdate{
+ telegram.Add(TelegramUpdate{
UpdateID: 10,
- Message: ownerMessage("new after the restart"),
+ Message: telegramOwnerMessage("new after the restart"),
})
- socket := startTestDaemonWithBot(t, server, bot)
- terminal := startTestClient(t, socket)
+ socket := daemonStartTestWithBot(t, server, bot)
+ terminal := terminalStartTestClient(t, socket)
terminal.WaitFor(t, "[connected to OneChat]")
- waitForSent(t, telegram, "ok")
+ 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 := loadOffset(bot.offsetPath)
+ offset, err := telegramLoadOffset(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))
+ server := newModelFake(t)
+ server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+ modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5))
}
- telegram := newFakeBot(t)
+ telegram := newTelegramFake(t)
failures := map[string]int{
- "getUpdates": 2,
- "sendMessage": 1,
+ "getUpdates": 2,
+ "sendRichMessage": 1,
}
- telegram.fail = func(call fakeBotCall) (int, string) {
+ telegram.fail = func(call TelegramFakeCall) (int, string) {
if failures[call.method] == 0 {
return 0, ""
}
failures[call.method]--
- if call.method == "sendMessage" {
+ if call.method == "sendRichMessage" {
return http.StatusTooManyRequests, "Too Many Requests: retry after 1"
}
return http.StatusBadGateway, "Bad Gateway"
}
- bot, logs := startTestBot(t, telegram)
- startTestDaemonWithBot(t, server, bot)
+ bot, logs := telegramStartTestBot(t, telegram)
+ daemonStartTestWithBot(t, server, bot)
- telegram.Add(botUpdate{
+ telegram.Add(TelegramUpdate{
UpdateID: 1,
- Message: ownerMessage("hello"),
+ Message: telegramOwnerMessage("hello"),
})
- waitForSent(t, telegram, "ok")
+ telegramWaitForSent(t, telegram, "ok")
require.Contains(t, logs.String(), "answered 502")
require.Contains(t, logs.String(), "answered 429")
- require.NotContains(t, logs.String(), testBotToken)
+ require.NotContains(t, logs.String(), telegramTestToken)
}
func TestDeliverTriesAgainAfterATimeout(t *testing.T) {
- telegram := newFakeBot(t)
+ telegram := newTelegramFake(t)
stalls := 1
- telegram.stall = func(call fakeBotCall) bool {
+ telegram.stall = func(call TelegramFakeCall) bool {
if call.method != "sendMessage" || stalls == 0 {
return false
}
return true
}
- bot, logs := startTestBot(t, telegram)
+ bot, logs := telegramStartTestBot(t, telegram)
bot.api.sendTimeout = 50 * time.Millisecond
- bot.deliver(t.Context(), botPart{
+ 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(), testBotToken)
+ require.Contains(t, logs.String(), "cannot send replies to Telegram: no answer in 50ms (next try in 1ms)\n")
+ require.Contains(t, logs.String(), "replies go to Telegram again, after ")
+ require.Equal(t, 2, strings.Count(logs.String(), "\n"))
+ require.NotContains(t, logs.String(), telegramTestToken)
}
-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))
+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 := newFakeBot(t)
- bot, _ := startTestBot(t, telegram)
- socket := startTestDaemonWithBot(t, server, bot)
- terminal := startTestClient(t, socket)
+ 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")
- waitForSent(t, telegram, "[terminal] make **it** so", "Done with x.")
+ telegramWaitForSent(t, telegram, "[terminal] make **it** so", reply)
- telegram.Add(botUpdate{
+ telegram.Add(TelegramUpdate{
UpdateID: 1,
- Message: ownerMessage(""),
+ Message: telegramOwnerMessage(""),
})
- waitForSent(t, telegram, textOnly)
+ telegramWaitForSent(t, telegram, telegramTextOnly)
- entities := map[string]any{}
+ var sends []TelegramFakeCall
for _, call := range telegram.Calls() {
- if call.method == "sendMessage" {
- entities[call.params["text"].(string)] = call.params["entities"]
+ if call.method == "sendMessage" || call.method == "sendRichMessage" {
+ sends = append(sends, call)
}
}
- 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),
+ require.Equal(t, []TelegramFakeCall{
+ {
+ method: "sendMessage",
+ params: map[string]any{
+ "chat_id": float64(telegramTestOwner),
+ "text": "[terminal] make **it** so",
},
- map[string]any{
- "type": "code",
- "offset": float64(10),
- "length": float64(1),
+ ok: true,
+ },
+ {
+ method: "sendRichMessage",
+ params: map[string]any{
+ "chat_id": float64(telegramTestOwner),
+ "rich_message": map[string]any{
+ "markdown": reply,
+ },
},
+ ok: true,
},
- textOnly: nil,
- }, entities)
+ {
+ 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
+ lines int
+ 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"},
+ lines: 1,
+ logText: "Telegram refused its full formatting",
+ },
+ {
+ name: "unknown method sends the reply once with entities",
+ status: http.StatusNotFound,
+ always: true,
+ methods: []string{"sendRichMessage", "sendMessage"},
+ sent: []string{"a secret word"},
+ lines: 1,
+ logText: "Telegram refused its full formatting",
+ },
+ {
+ name: "rate limit tries the rich message again",
+ status: http.StatusTooManyRequests,
+ methods: []string{"sendRichMessage", "sendRichMessage"},
+ sent: []string{"a **secret** word"},
+ lines: 2,
+ logText: "cannot send replies to Telegram: Telegram answered 429",
+ },
+ {
+ name: "server error tries the rich message again",
+ status: http.StatusInternalServerError,
+ methods: []string{"sendRichMessage", "sendRichMessage"},
+ sent: []string{"a **secret** word"},
+ lines: 2,
+ logText: "cannot send replies to Telegram: Telegram answered 500",
+ },
+ {
+ name: "other refusal drops the reply",
+ status: http.StatusForbidden,
+ always: true,
+ methods: []string{"sendRichMessage"},
+ lines: 1,
+ logText: "a reply to Telegram is lost: Telegram answered 403",
+ },
+ }
+
+ 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, tc.lines, 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 `<b>`"},
+ },
+ {
+ name: "entity fallback shows the tag as written",
+ status: http.StatusBadRequest,
+ sent: []string{"a <details> & tag and <b>"},
+ },
+ }
+
+ 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 <details> & tag and `<b>`",
+ })
+
+ require.Equal(t, tc.sent, telegram.Sent())
+ })
+ }
+}
+
+func TestLongReplySplitCountsTheEscapedTags(t *testing.T) {
+ paragraph := "A <b> 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 <b> & 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 := newFakeBot(t)
- telegram.fail = func(call fakeBotCall) (int, string) {
+ 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" + testBotToken
+ return http.StatusBadRequest, "Bad Request: can't parse entities of bot" + telegramTestToken
}
- bot, logs := startTestBot(t, telegram)
- text, entities := markdownEntities("a **secret** word")
- bot.deliver(t.Context(), botPart{
+ bot, logs := telegramStartTestBot(t, telegram)
+ text, entities := telegramMarkdownEntities("a **secret** word")
+ bot.deliver(t.Context(), TelegramPart{
text: text,
entities: 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.Contains(t, logs.String(), "a reply goes as plain text, because Telegram refused its formatting")
+ require.NotContains(t, logs.String(), telegramTestToken)
require.NotContains(t, logs.String(), "secret word")
}
func TestBotLogsNoTokenWhenTheNetworkFails(t *testing.T) {
- telegram := newFakeBot(t)
+ telegram := newTelegramFake(t)
telegram.Close()
- bot, logs := startTestBot(t, telegram)
- startTestDaemonWithBot(t, newFakeResponses(t), bot)
+ bot, logs := telegramStartTestBot(t, telegram)
+ daemonStartTestWithBot(t, newModelFake(t), bot)
require.Eventually(t, func() bool {
- return strings.Count(logs.String(), "no valid answer") >= 2
+ return strings.Contains(logs.String(), "cannot get messages from Telegram: no answer: ")
}, 10*time.Second, time.Millisecond)
- require.NotContains(t, logs.String(), testBotToken)
+ require.NotContains(t, logs.String(), telegramTestToken)
+}
+
+func TestBotLogsAnOutageOnlyAtItsStartAndEnd(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 := 5
+ telegram.fail = func(call TelegramFakeCall) (int, string) {
+ if call.method != "getUpdates" || failures == 0 {
+ return 0, ""
+ }
+
+ failures--
+
+ return http.StatusBadGateway, "Bad Gateway"
+ }
+
+ bot, logs := telegramStartTestBot(t, telegram)
+ daemonStartTestWithBot(t, server, bot)
+
+ telegram.Add(TelegramUpdate{
+ UpdateID: 1,
+ Message: telegramOwnerMessage("a secret word"),
+ })
+
+ telegramWaitForSent(t, telegram, "ok")
+ lines := strings.Split(strings.TrimSuffix(logs.String(), "\n"), "\n")
+ require.Len(t, lines, 2)
+ require.Equal(t, "cannot get messages from Telegram: Telegram answered 502 Bad Gateway (next try in 1ms)", lines[0])
+ require.Regexp(t, `^messages from Telegram come again, after [0-9]+m?s$`, lines[1])
+ require.NotContains(t, logs.String(), "secret word")
+ require.NotContains(t, logs.String(), strconv.FormatInt(telegramTestOwner, 10))
}
func TestSplitMessage(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
- require.Equal(t, tc.parts, splitMessage(tc.text, tc.limit))
+ require.Equal(t, tc.parts, telegramSplitMessage(tc.text, tc.limit))
})
}
long := strings.Repeat("word ", 2000)
- parts := splitMessage(long, messageUnits)
+ parts := telegramSplitMessage(long, telegramMessageUnits)
require.Len(t, parts, 3)
require.Equal(t, long, strings.Join(parts, ""))
}
func TestSplitEntitiesCutsAnEntityAcrossASplit(t *testing.T) {
- parts := splitEntities("aabbbbcc", []botEntity{
+ parts := telegramSplitEntities("aabbbbcc", []TelegramEntity{
{
Type: "bold",
Offset: 2,
},
}, 5)
- require.Equal(t, []botPart{
+ require.Equal(t, []TelegramPart{
{
text: "aabbb",
- entities: []botEntity{
+ entities: []TelegramEntity{
{
Type: "bold",
Offset: 2,
},
{
text: "bcc",
- entities: []botEntity{
+ entities: []TelegramEntity{
{
Type: "bold",
Offset: 0,
}, 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
}{
{
name: "owner file is read",
- content: `{"token": "` + testBotToken + `", "owner": 4242}`,
+ content: `{"token": "` + telegramTestToken + `", "owner": 4242}`,
mode: 0o600,
},
{
name: "file that others can read is refused",
- content: `{"token": "` + testBotToken + `", "owner": 4242}`,
+ content: `{"token": "` + telegramTestToken + `", "owner": 4242}`,
mode: 0o640,
errorText: "mode must be 0600",
},
},
{
name: "missing owner is refused",
- content: `{"token": "` + testBotToken + `"}`,
+ content: `{"token": "` + telegramTestToken + `"}`,
mode: 0o600,
errorText: "numeric Telegram user ID",
},
{
name: "unknown field is refused",
- content: `{"token": "` + testBotToken + `", "owner": 4242, "chat": 1}`,
+ 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, botConfigFileName)
+ 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 := loadBotConfig(dir)
+ config, found, err := loadTelegramConfig(dir)
if tc.errorText != "" {
require.ErrorContains(t, err, tc.errorText)
- require.NotContains(t, err.Error(), testBotToken)
+ require.NotContains(t, err.Error(), telegramTestToken)
return
}
require.NoError(t, err)
require.True(t, found)
- require.Equal(t, botConfig{
- Token: testBotToken,
- Owner: testOwner,
+ require.Equal(t, TelegramConfig{
+ Token: telegramTestToken,
+ Owner: telegramTestOwner,
}, config)
})
}
- _, found, err := loadBotConfig(t.TempDir())
+ _, found, err := loadTelegramConfig(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) {
+// 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(newBotAPI(server.URL, testBotToken), testOwner, filepath.Join(t.TempDir(), offsetFileName), log.New(logs, "", 0))
+ 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
}
-// waitForSent waits until the bot sent the texts in order, and returns them.
-func waitForSent(t *testing.T, server *fakeBot, texts ...string) []string {
+// 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 {