X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/a345df4e4bbf9dbaf3da1ce548896eef8f515de3..84fc815feb485b35eec53a8c7863c6770fc65e02:/telegram_test.go?ds=sidebyside diff --git a/telegram_test.go b/telegram_test.go index b1cd6ae..df35e0d 100644 --- a/telegram_test.go +++ b/telegram_test.go @@ -171,8 +171,8 @@ func TestBotTriesAgainAfterFailures(t *testing.T) { telegram := newFakeBot(t) failures := map[string]int{ - "getUpdates": 2, - "sendMessage": 1, + "getUpdates": 2, + "sendRichMessage": 1, } telegram.fail = func(call fakeBotCall) (int, string) { @@ -181,7 +181,7 @@ func TestBotTriesAgainAfterFailures(t *testing.T) { } failures[call.method]-- - if call.method == "sendMessage" { + if call.method == "sendRichMessage" { return http.StatusTooManyRequests, "Too Many Requests: retry after 1" } @@ -217,7 +217,9 @@ func TestDeliverTriesAgainAfterATimeout(t *testing.T) { bot, logs := startTestBot(t, telegram) bot.api.sendTimeout = 50 * time.Millisecond - bot.deliver(t.Context(), "hello") + bot.deliver(t.Context(), botPart{ + text: "hello", + }) require.Equal(t, []string{"hello"}, telegram.Sent()) require.Len(t, telegram.Calls(), 2) @@ -225,6 +227,197 @@ func TestDeliverTriesAgainAfterATimeout(t *testing.T) { require.NotContains(t, logs.String(), testBotToken) } +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 := newFakeResponses(t) + server.reply = func(w http.ResponseWriter, call fakeCall) { + writeEvents(w, completedEvent([]any{messageItem(reply)}, 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", reply) + + telegram.Add(botUpdate{ + UpdateID: 1, + Message: ownerMessage(""), + }) + + waitForSent(t, telegram, textOnly) + + var sends []fakeBotCall + for _, call := range telegram.Calls() { + if call.method == "sendMessage" || call.method == "sendRichMessage" { + sends = append(sends, call) + } + } + + require.Equal(t, []fakeBotCall{ + { + method: "sendMessage", + params: map[string]any{ + "chat_id": float64(testOwner), + "text": "[terminal] make **it** so", + }, + ok: true, + }, + { + method: "sendRichMessage", + params: map[string]any{ + "chat_id": float64(testOwner), + "rich_message": map[string]any{ + "markdown": reply, + }, + }, + ok: true, + }, + { + method: "sendMessage", + params: map[string]any{ + "chat_id": float64(testOwner), + "text": textOnly, + }, + 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 := newFakeBot(t) + failed := false + telegram.fail = func(call fakeBotCall) (int, string) { + if call.method != "sendRichMessage" || failed && !tc.always { + return 0, "" + } + + failed = true + + return tc.status, "refused for bot" + testBotToken + } + + bot, logs := startTestBot(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(), testBotToken) + 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", richMessageBytes/len("A paragraph of the reply.\n\n")) + table := "| a | b |\n|---|---|\n| 1 | 2 |\n" + reply := paragraphs + table + + telegram := newFakeBot(t) + bot, _ := startTestBot(t, telegram) + bot.post(t.Context(), Event{ + Kind: "reply", + Text: reply, + }) + + require.Equal(t, []string{paragraphs, table}, telegram.Sent()) +} + +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() @@ -289,6 +482,107 @@ func TestSplitMessage(t *testing.T) { 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