]> git.giacomolli.uk - onechat/commitdiff
Send the Telegram replies as rich messages
authorAnderson A. Giacomolli <agiacomolli@gmail.com>
Sat, 7 Nov 2026 15:30:51 +0000 (12:30 -0300)
committerAnderson A. Giacomolli <agiacomolli@gmail.com>
Sat, 7 Nov 2026 15:30:51 +0000 (12:30 -0300)
The bot sends each model reply with sendRichMessage and the Markdown of
the reply unchanged, so Telegram renders the tables and the other blocks
itself. Message entities have no tables, so a table showed as lines of
pipes. A reply over the limit of a rich message goes as more than one
rich message, split between blocks with splitRich.

When the Bot API answers ok false with HTTP 400 or 404, the reply was
not sent, so the bot sends the same Markdown once with the entity path:
markdownEntities, splitEntities, and sendMessage. The fallback logs one
line with no text of the reply. Every other failure gives no fallback: a
network error, HTTP 429, and HTTP 5xx wait and try the rich message
again, and any other failure drops the reply with one log line. The send
timeout applies to each sendRichMessage call.

The notices of the bot, the errors, and the messages from the terminal
stay plain text with sendMessage. The log and the terminal client do not
change.

Issue: fec23f9

botapi_test.go
telegram.go
telegram_test.go

index 9dfbcad8a11d2734bb2b2ba16ea47ce5c633c846..fb57b45b468116c0687419123eedd7421c5fd738 100644 (file)
@@ -322,12 +322,17 @@ func (f *fakeBot) Calls() []fakeBotCall {
        return append([]fakeBotCall(nil), f.calls...)
 }
 
-// Sent returns the texts of the sendMessage calls that succeeded.
+// Sent returns the texts of the sendMessage calls and the Markdown of the
+// sendRichMessage calls that succeeded, in order.
 func (f *fakeBot) Sent() []string {
        var texts []string
        for _, call := range f.Calls() {
-               if call.method == "sendMessage" && call.ok {
+               switch {
+               case !call.ok:
+               case call.method == "sendMessage":
                        texts = append(texts, call.params["text"].(string))
+               case call.method == "sendRichMessage":
+                       texts = append(texts, call.params["rich_message"].(map[string]any)["markdown"].(string))
                }
        }
 
index 9dbe86e1cf24e6b629245bd71414aa9d2334bf58..dd62ef0221db72fc88a1893b1421ce301cab0cff 100644 (file)
@@ -60,8 +60,9 @@ type botConfig struct {
 // agent. It ignores every other update. Its sender sends the replies, the
 // errors, the notices of the bot, and the user messages of the other clients to
 // the chat, and shows the typing action while a turn runs. Only the replies
-// have a format: their Markdown goes as message entities. The rest is plain
-// text. Tool calls do not go to the chat.
+// have a format: their Markdown goes as rich messages, which Telegram renders
+// itself, and as message entities only when Telegram refuses a rich message.
+// The rest is plain text. Tool calls do not go to the chat.
 //
 // The offset file holds the ID of the next update. It moves past an update
 // only after the message of the update is durable in the log, so a restart
@@ -244,9 +245,7 @@ func (b *telegramBot) send(ctx context.Context) {
                case "idle":
                        busy = false
                default:
-                       for _, part := range botParts(event) {
-                               b.deliver(ctx, part)
-                       }
+                       b.post(ctx, event)
 
                        if busy {
                                b.typing(ctx)
@@ -276,6 +275,57 @@ func (b *telegramBot) typing(ctx context.Context) {
        _ = b.api.SendTyping(ctx, b.owner)
 }
 
+// post sends event to the chat. A reply goes as rich messages of its Markdown,
+// split with splitRich. Every other event goes as plain text, split with
+// splitMessage.
+func (b *telegramBot) post(ctx context.Context, event Event) {
+       if event.Kind != "reply" {
+               for _, text := range splitMessage(botText(event), messageUnits) {
+                       b.deliver(ctx, botPart{
+                               text: text,
+                       })
+               }
+
+               return
+       }
+
+       for _, markdown := range splitRich(event.Text, richMessageBytes) {
+               b.deliverRich(ctx, markdown)
+       }
+}
+
+// deliverRich sends markdown to the chat as a rich message. When the Bot API
+// refuses it with HTTP 400 or 404, the message was not sent, so deliverRich
+// sends the same Markdown once with message entities, and the reply is not
+// lost and does not repeat. It tries again after each failure that a later try
+// can fix, until ctx ends. It drops the message after any other failure.
+func (b *telegramBot) deliverRich(ctx context.Context, markdown string) {
+       wait := b.minWait
+       for {
+               err := b.api.SendRichMessage(ctx, b.owner, markdown)
+               if err == nil || ctx.Err() != nil {
+                       return
+               }
+
+               if rejected(err) {
+                       b.logger.Printf("rich message refused, the reply goes with entities: %v", err)
+                       for _, part := range replyParts(markdown) {
+                               b.deliver(ctx, part)
+                       }
+
+                       return
+               }
+
+               retry, asked := retryable(err)
+               if !retry {
+                       b.logger.Printf("message dropped: %v", err)
+                       return
+               }
+
+               wait = b.pause(ctx, err, wait, asked)
+       }
+}
+
 // deliver sends part to the chat. When Telegram refuses a part with entities
 // (HTTP 400), it sends the text of the part again once with no entities, so a
 // reply is not lost to a bad entity. It tries again after each failure that a
@@ -448,23 +498,6 @@ func saveOffset(path string, offset int64) error {
        return nil
 }
 
-// botParts returns the messages of event for the chat. The Markdown of a reply
-// becomes text with entities. Every other event is plain text. A reply that has
-// no text left after the conversion, such as an empty code block, goes as its
-// Markdown.
-func botParts(event Event) []botPart {
-       text, entities := botText(event), []botEntity(nil)
-       if event.Kind == "reply" {
-               text, entities = markdownEntities(event.Text)
-       }
-
-       if strings.TrimSpace(text) == "" {
-               text, entities = event.Text, nil
-       }
-
-       return splitEntities(text, entities, messageUnits)
-}
-
 // botText returns the text of event for the chat. A user message of another
 // client has its source as a mark.
 func botText(event Event) string {
@@ -478,38 +511,6 @@ func botText(event Event) string {
        }
 }
 
-// splitEntities cuts text into parts with splitMessage, and gives each part the
-// entities in it, with offsets from the start of the part. An entity that
-// crosses a cut becomes one entity in each part.
-func splitEntities(text string, entities []botEntity, limit int) []botPart {
-       var parts []botPart
-       start := 0
-       for _, part := range splitMessage(text, limit) {
-               end := start + utf16Units(part)
-               var kept []botEntity
-               for _, entity := range entities {
-                       from := max(entity.Offset, start)
-                       to := min(entity.Offset+entity.Length, end)
-                       if to <= from {
-                               continue
-                       }
-
-                       entity.Offset = from - start
-                       entity.Length = to - from
-                       kept = append(kept, entity)
-               }
-
-               parts = append(parts, botPart{
-                       text:     part,
-                       entities: kept,
-               })
-
-               start = end
-       }
-
-       return parts
-}
-
 // splitMessage cuts text into parts of at most limit UTF-16 code units, the
 // unit of the Telegram message limit. It cuts after the last line break or
 // space of a part when one is in its second half, and else at the limit. It
@@ -635,3 +636,47 @@ func richCut(markdown string, limit int) (int, string, string) {
 
        return cut, "", ""
 }
+
+// replyParts returns the messages of the Markdown of a reply with message
+// entities. A reply that has no text left after the conversion, such as an
+// empty code block, goes as its Markdown.
+func replyParts(markdown string) []botPart {
+       text, entities := markdownEntities(markdown)
+       if strings.TrimSpace(text) == "" {
+               text, entities = markdown, nil
+       }
+
+       return splitEntities(text, entities, messageUnits)
+}
+
+// splitEntities cuts text into parts with splitMessage, and gives each part the
+// entities in it, with offsets from the start of the part. An entity that
+// crosses a cut becomes one entity in each part.
+func splitEntities(text string, entities []botEntity, limit int) []botPart {
+       var parts []botPart
+       start := 0
+       for _, part := range splitMessage(text, limit) {
+               end := start + utf16Units(part)
+               var kept []botEntity
+               for _, entity := range entities {
+                       from := max(entity.Offset, start)
+                       to := min(entity.Offset+entity.Length, end)
+                       if to <= from {
+                               continue
+                       }
+
+                       entity.Offset = from - start
+                       entity.Length = to - from
+                       kept = append(kept, entity)
+               }
+
+               parts = append(parts, botPart{
+                       text:     part,
+                       entities: kept,
+               })
+
+               start = end
+       }
+
+       return parts
+}
index 30b2fa1bb686f730963c2426bdb88e3761949d5b..df35e0df6430490778a523cbbdace02a09a7eabb 100644 (file)
@@ -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"
                }
 
@@ -227,10 +227,11 @@ func TestDeliverTriesAgainAfterATimeout(t *testing.T) {
        require.NotContains(t, logs.String(), testBotToken)
 }
 
-func TestOnlyRepliesGetEntities(t *testing.T) {
+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("**Done** with `x`.")}, 100, 0, 5))
+               writeEvents(w, completedEvent([]any{messageItem(reply)}, 100, 0, 5))
        }
 
        telegram := newFakeBot(t)
@@ -240,7 +241,7 @@ func TestOnlyRepliesGetEntities(t *testing.T) {
        terminal.WaitFor(t, "[connected to OneChat]")
 
        terminal.Type("make **it** so")
-       waitForSent(t, telegram, "[terminal] make **it** so", "Done with x.")
+       waitForSent(t, telegram, "[terminal] make **it** so", reply)
 
        telegram.Add(botUpdate{
                UpdateID: 1,
@@ -249,29 +250,144 @@ func TestOnlyRepliesGetEntities(t *testing.T) {
 
        waitForSent(t, telegram, textOnly)
 
-       entities := map[string]any{}
+       var sends []fakeBotCall
        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, []fakeBotCall{
+               {
+                       method: "sendMessage",
+                       params: map[string]any{
+                               "chat_id": float64(testOwner),
+                               "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(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",
                },
-               textOnly: nil,
-       }, entities)
+               {
+                       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) {