X-Git-Url: http://git.giacomolli.uk/onechat/blobdiff_plain/4b063ca25798c78cc03d1d07f67b02c94c95248d..97729fc59a2fbd73e3f8a424cae4c825f5ba885b:/telegram.go diff --git a/telegram.go b/telegram.go index dabf2e8..9dbe86e 100644 --- a/telegram.go +++ b/telegram.go @@ -8,11 +8,13 @@ import ( "fmt" "io" "log" + "net/http" "os" "path/filepath" "strings" "sync" "time" + "unicode/utf8" ) const ( @@ -32,6 +34,12 @@ const ( // messageUnits is the most UTF-16 code units of one Telegram message. messageUnits = 4096 + // richMessageBytes is the most bytes of the Markdown of one rich + // message. The Bot API allows 32768 characters of rendered text, and + // the Markdown of a text has at least as many bytes as the text has + // characters. + richMessageBytes = 32768 + // typingInterval is how often the bot shows the typing action while a // turn runs. Telegram shows the action for about five seconds. typingInterval = 4 * time.Second @@ -50,9 +58,10 @@ type botConfig struct { // telegramBot connects the private chat of the owner with the agent. It polls // the Bot API for new messages and sends each text message of the owner to the // agent. It ignores every other update. Its sender sends the replies, the -// errors, and the user messages of the other clients to the chat as plain text, -// and shows the typing action while a turn runs. Tool calls do not go to the -// chat. +// 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. // // 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 @@ -74,6 +83,13 @@ type telegramBot struct { ready chan struct{} } +// botPart is the text of one message to the chat, at most messageUnits long, +// and its entities. +type botPart struct { + text string + entities []botEntity +} + // offsetFile is the content of the offset file. type offsetFile struct { Offset int64 `json:"offset"` @@ -156,7 +172,7 @@ func newTelegramBot(api *botAPI, owner int64, offsetPath string, logger *log.Log // stop the agent. func (b *telegramBot) Notify(event Event) { switch event.Kind { - case "busy", "idle", "reply", "error": + case "busy", "idle", "reply", "error", "notice": case "user": if event.Source == telegramSource { return @@ -228,7 +244,7 @@ func (b *telegramBot) send(ctx context.Context) { case "idle": busy = false default: - for _, part := range splitMessage(botText(event), messageUnits) { + for _, part := range botParts(event) { b.deliver(ctx, part) } @@ -260,16 +276,26 @@ func (b *telegramBot) typing(ctx context.Context) { _ = b.api.SendTyping(ctx, b.owner) } -// deliver sends text to the chat. It tries again after each failure that a -// later try can fix, until ctx ends. It drops the text after any other failure. -func (b *telegramBot) deliver(ctx context.Context, text string) { +// 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 +// later try can fix, until ctx ends. It drops the part after any other failure. +func (b *telegramBot) deliver(ctx context.Context, part botPart) { wait := b.minWait for { - err := b.api.SendMessage(ctx, b.owner, text) + err := b.api.SendMessage(ctx, b.owner, part.text, part.entities) if err == nil || ctx.Err() != nil { return } + var answer *botError + if len(part.entities) > 0 && errors.As(err, &answer) && answer.Status == http.StatusBadRequest { + b.logger.Printf("entities refused, the message goes again as plain text: %v", err) + part.entities = nil + + continue + } + retry, asked := retryable(err) if !retry { b.logger.Printf("message dropped: %v", err) @@ -379,7 +405,7 @@ func (b *telegramBot) handle(ctx context.Context, l *loop, update botUpdate) err // notice queues a short answer of the bot for the chat. func (b *telegramBot) notice(text string) { b.Notify(Event{ - Kind: "reply", + Kind: "notice", Text: text, }) } @@ -422,6 +448,23 @@ 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 { @@ -435,6 +478,38 @@ 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 @@ -480,3 +555,83 @@ func prefixEnd(text string, limit int) int { return len(text) } + +// splitRich cuts the Markdown of a reply into parts of at most limit bytes. It +// cuts after a blank line outside a code block, so a table, a code block, or a +// paragraph stays whole. When no such cut fits in a part, it cuts after the +// last line break outside a code block, then after the last line break in a +// code block, which it closes at the end of the part and opens again in the +// next part with the same fence line. A line longer than limit is cut at the +// limit. It never cuts a character. +func splitRich(markdown string, limit int) []string { + var parts []string + for len(markdown) > limit { + cut, fence, opening := richCut(markdown, limit) + if fence == "" { + parts = append(parts, markdown[:cut]) + markdown = markdown[cut:] + + continue + } + + parts = append(parts, markdown[:cut]+fence+"\n") + markdown = opening + "\n" + markdown[cut:] + } + + return append(parts, markdown) +} + +// richCut returns where splitRich cuts markdown, which is longer than limit. +// For a cut in a code block, it also returns the fence that closes the block +// and the line that opens it. Each part before a cut holds text other than +// space, and a cut in a code block leaves room for the fence, so each cut makes +// the rest shorter. +func richCut(markdown string, limit int) (int, string, string) { + blank, line, code := 0, 0, 0 + var fence, opening, inFence, inOpening string + for start := 0; start < limit; { + end := strings.IndexByte(markdown[start:], '\n') + if end < 0 || start+end+1 > limit { + break + } + + end += start + 1 + text := strings.TrimRight(markdown[start:end], "\r\n") + _, opens := codeFence(text) + switch { + case fence != "": + if closesFence(text, fence) { + fence = "" + } else if end > len(opening)+1 && end+len(fence)+1 <= limit { + code, inFence, inOpening = end, fence, opening + } + case opens != "": + fence, opening = opens, text + case strings.TrimSpace(markdown[:end]) == "": + case strings.TrimSpace(text) == "": + blank = end + } + + if fence == "" && strings.TrimSpace(markdown[:end]) != "" { + line = end + } + + start = end + } + + switch { + case blank > 0: + return blank, "", "" + case line > 0: + return line, "", "" + case code > 0: + return code, inFence, inOpening + } + + cut := limit + for !utf8.RuneStart(markdown[cut]) { + cut-- + } + + return cut, "", "" +}