+ telegramWaitForSent(t, telegram, "ok")
+ require.Contains(t, logs.String(), "<4>receive failed retry=1s: 502")
+ require.Contains(t, logs.String(), "<6>receive back after=")
+ require.Contains(t, logs.String(), "<4>send failed retry=1s: 429")
+
+ // The bot logs the end of the outage after the send returns, so the line
+ // can come after the fake server has the message.
+ require.Eventually(t, func() bool {
+ return strings.Contains(logs.String(), "<6>send back after=")
+ }, 10*time.Second, time.Millisecond)
+
+ require.NotContains(t, logs.String(), telegramTestToken)
+}
+
+func TestDeliverTriesAgainAfterATimeout(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ telegram := newTelegramLocalFake(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.http.Transport = telegram
+ 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(), "<4>send failed retry=1s: no answer in 30s\n")
+ require.Contains(t, logs.String(), "<6>send back after=1s\n")
+ require.Equal(t, 2, strings.Count(logs.String(), "\n"))
+ 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, _ 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
+ 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: "<4>reply sent with simple 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: "<4>reply sent with simple formatting",
+ },
+ {
+ name: "rate limit tries the rich message again",
+ status: http.StatusTooManyRequests,
+ methods: []string{"sendRichMessage", "sendRichMessage"},
+ sent: []string{"a **secret** word"},
+ lines: 2,
+ logText: "<4>send failed retry=1s: 429",
+ },
+ {
+ name: "server error tries the rich message again",
+ status: http.StatusInternalServerError,
+ methods: []string{"sendRichMessage", "sendRichMessage"},
+ sent: []string{"a **secret** word"},
+ lines: 2,
+ logText: "<4>send failed retry=1s: 500",
+ },
+ {
+ name: "other refusal drops the reply",
+ status: http.StatusForbidden,
+ always: true,
+ methods: []string{"sendRichMessage"},
+ lines: 1,
+ logText: "<3>reply lost: 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)
+
+ if tc.lines == 2 {
+ require.Contains(t, logs.String(), "<6>send back after=")
+ }
+
+ 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.Equal(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(), "<4>reply sent as plain text")
+ require.NotContains(t, logs.String(), telegramTestToken)
+ require.NotContains(t, logs.String(), "secret word")
+}
+
+func TestBotLogsALostPlainReplyAsAnError(t *testing.T) {
+ telegram := newTelegramFake(t)
+ telegram.fail = func(call TelegramFakeCall) (int, string) {
+ if call.method == "sendMessage" {
+ return http.StatusForbidden, "Forbidden"
+ }
+
+ return 0, ""
+ }
+
+ bot, logs := telegramStartTestBot(t, telegram)
+ bot.deliver(t.Context(), TelegramPart{text: "secret reply"})
+ require.Equal(t, "<3>reply lost: 403 Forbidden\n", logs.String())
+ require.Empty(t, telegram.Sent())