From: Anderson A. Giacomolli Date: Sat, 7 Nov 2026 15:30:51 +0000 (-0300) Subject: Send the Telegram replies as rich messages X-Git-Url: http://git.giacomolli.uk/onechat/commitdiff_plain/84fc815feb485b35eec53a8c7863c6770fc65e02?ds=sidebyside Send the Telegram replies as rich messages 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 --- diff --git a/botapi_test.go b/botapi_test.go index 9dfbcad..fb57b45 100644 --- a/botapi_test.go +++ b/botapi_test.go @@ -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)) } } diff --git a/telegram.go b/telegram.go index 9dbe86e..dd62ef0 100644 --- a/telegram.go +++ b/telegram.go @@ -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 +} diff --git a/telegram_test.go b/telegram_test.go index 30b2fa1..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" } @@ -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) {