telegram := newFakeBot(t)
failures := map[string]int{
- "getUpdates": 2,
- "sendMessage": 1,
+ "getUpdates": 2,
+ "sendRichMessage": 1,
}
telegram.fail = func(call fakeBotCall) (int, string) {
}
failures[call.method]--
- if call.method == "sendMessage" {
+ if call.method == "sendRichMessage" {
return http.StatusTooManyRequests, "Too Many Requests: retry after 1"
}
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)
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()
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