]> git.giacomolli.uk - onechat/blobdiff - telegram.go
Send the replies to Telegram with their formats
[onechat] / telegram.go
index dabf2e8a70be50b919fcd9006522e2cf8d6a709b..9a24a4e861d7436bcd0a109480b50fbbb538377e 100644 (file)
@@ -8,6 +8,7 @@ import (
        "fmt"
        "io"
        "log"
        "fmt"
        "io"
        "log"
+       "net/http"
        "os"
        "path/filepath"
        "strings"
        "os"
        "path/filepath"
        "strings"
@@ -50,9 +51,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
 // 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
 //
 // 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 +76,13 @@ type telegramBot struct {
        ready chan 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"`
 // offsetFile is the content of the offset file.
 type offsetFile struct {
        Offset int64 `json:"offset"`
@@ -156,7 +165,7 @@ func newTelegramBot(api *botAPI, owner int64, offsetPath string, logger *log.Log
 // stop the agent.
 func (b *telegramBot) Notify(event Event) {
        switch event.Kind {
 // 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
        case "user":
                if event.Source == telegramSource {
                        return
@@ -228,7 +237,7 @@ func (b *telegramBot) send(ctx context.Context) {
                case "idle":
                        busy = false
                default:
                case "idle":
                        busy = false
                default:
-                       for _, part := range splitMessage(botText(event), messageUnits) {
+                       for _, part := range botParts(event) {
                                b.deliver(ctx, part)
                        }
 
                                b.deliver(ctx, part)
                        }
 
@@ -260,16 +269,26 @@ func (b *telegramBot) typing(ctx context.Context) {
        _ = b.api.SendTyping(ctx, b.owner)
 }
 
        _ = 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 {
        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
                }
 
                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)
                retry, asked := retryable(err)
                if !retry {
                        b.logger.Printf("message dropped: %v", err)
@@ -379,7 +398,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{
 // 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,
        })
 }
                Text: text,
        })
 }
@@ -422,6 +441,23 @@ func saveOffset(path string, offset int64) error {
        return nil
 }
 
        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 {
 // 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 +471,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
 // 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