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))
}
}
// 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
case "idle":
busy = false
default:
- for _, part := range botParts(event) {
- b.deliver(ctx, part)
- }
+ b.post(ctx, event)
if busy {
b.typing(ctx)
_ = 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
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 {
}
}
-// 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
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
+}
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"
}
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)
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,
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) {