X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/97729fc59a2fbd73e3f8a424cae4c825f5ba885b..84fc815feb485b35eec53a8c7863c6770fc65e02:/telegram.go 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 +}