The bot reads only the private chat of the owner and ignores all other
updates. Each text message goes to the agent as a user message, and the
-line ``/stop`` interrupts the running turn. The bot sends the replies as
-plain text, in parts of at most 4096 characters, and each error as a
-short message. It shows the typing action while a turn runs. It does not
-send the tool calls. It sends each message that the user types in the
-terminal client, with the mark ``[terminal]``.
+line ``/stop`` interrupts the running turn. The bot sends the replies in
+parts of at most 4096 characters, with their Markdown as Telegram
+formats: bold, italic, strikethrough, code, code blocks, links, and
+headings as bold text. When Telegram refuses the formats of a part, the
+bot sends the part again as plain text. Each error goes as a short
+message in plain text. It shows the typing action while a turn runs. It
+does not send the tool calls. It sends each message that the user types
+in the terminal client, with the mark ``[terminal]``.
``agent/telegram-offset.json`` holds the ID of the next update. The bot
moves it past an update only after the message is durable in the log, so
return updates, err
}
-// SendMessage sends text to chat as plain text, with no parse mode. A call that
-// takes longer than sendTimeout fails with errBotNetwork.
-func (b *botAPI) SendMessage(ctx context.Context, chat int64, text string) error {
+// SendMessage sends text to chat with the format of entities, with no parse
+// mode, so the text needs no escaping. With no entities, the text is plain. A
+// call that takes longer than sendTimeout fails with errBotNetwork.
+func (b *botAPI) SendMessage(ctx context.Context, chat int64, text string, entities []botEntity) error {
ctx, cancel := context.WithTimeout(ctx, b.sendTimeout)
defer cancel()
- return b.call(ctx, "sendMessage", map[string]any{
+ params := map[string]any{
"chat_id": chat,
"text": text,
- }, nil)
+ }
+
+ if len(entities) > 0 {
+ params["entities"] = entities
+ }
+
+ return b.call(ctx, "sendMessage", params, nil)
}
// SendTyping shows the typing action in chat for about five seconds. A call
func TestSendMessageSendsPlainText(t *testing.T) {
server := newFakeBot(t)
- err := newBotAPI(server.URL, testBotToken).SendMessage(t.Context(), testOwner, "*not bold*")
+ err := newBotAPI(server.URL, testBotToken).SendMessage(t.Context(), testOwner, "*not bold*", nil)
require.NoError(t, err)
require.Equal(t, map[string]any{
}, server.Calls()[0].params)
}
+func TestSendMessageSendsTheEntities(t *testing.T) {
+ server := newFakeBot(t)
+ err := newBotAPI(server.URL, testBotToken).SendMessage(t.Context(), testOwner, "a link", []botEntity{
+ {
+ Type: "text_link",
+ Offset: 2,
+ Length: 4,
+ URL: "https://example.com",
+ },
+ })
+ require.NoError(t, err)
+
+ require.Equal(t, []any{
+ map[string]any{
+ "type": "text_link",
+ "offset": float64(2),
+ "length": float64(4),
+ "url": "https://example.com",
+ },
+ }, server.Calls()[0].params["entities"])
+}
+
func TestSendMessageTimesOutOnAStalledConnection(t *testing.T) {
server := newFakeBot(t)
server.stall = func(call fakeBotCall) bool {
api := newBotAPI(server.URL, testBotToken)
api.sendTimeout = 50 * time.Millisecond
start := time.Now()
- err := api.SendMessage(t.Context(), testOwner, "hi")
+ err := api.SendMessage(t.Context(), testOwner, "hi", nil)
require.ErrorIs(t, err, errBotNetwork)
require.Less(t, time.Since(start), 5*time.Second)
require.NotContains(t, err.Error(), testBotToken)
ctx, cancel := context.WithCancel(t.Context())
time.AfterFunc(50*time.Millisecond, cancel)
start = time.Now()
- err = api.SendMessage(ctx, testOwner, "hi")
+ err = api.SendMessage(ctx, testOwner, "hi", nil)
require.Error(t, err)
require.Less(t, time.Since(start), 5*time.Second)
}
}))
t.Cleanup(server.Close)
- err := newBotAPI(server.URL, testBotToken).SendMessage(t.Context(), testOwner, "hi")
+ err := newBotAPI(server.URL, testBotToken).SendMessage(t.Context(), testOwner, "hi", nil)
require.ErrorContains(t, err, tc.errorText)
require.NotContains(t, err.Error(), testBotToken)
require.NotContains(t, err.Error(), server.URL)
"fmt"
"io"
"log"
+ "net/http"
"os"
"path/filepath"
"strings"
// 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
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"`
// 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 "idle":
busy = false
default:
- for _, part := range splitMessage(botText(event), messageUnits) {
+ for _, part := range botParts(event) {
b.deliver(ctx, part)
}
_ = 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)
// 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,
})
}
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
bot, logs := startTestBot(t, telegram)
bot.api.sendTimeout = 50 * time.Millisecond
- bot.deliver(t.Context(), "hello")
+ bot.deliver(t.Context(), botPart{
+ text: "hello",
+ })
require.Equal(t, []string{"hello"}, telegram.Sent())
require.Len(t, telegram.Calls(), 2)
require.NotContains(t, logs.String(), testBotToken)
}
+func TestOnlyRepliesGetEntities(t *testing.T) {
+ server := newFakeResponses(t)
+ server.reply = func(w http.ResponseWriter, call fakeCall) {
+ writeEvents(w, completedEvent([]any{messageItem("**Done** with `x`.")}, 100, 0, 5))
+ }
+
+ telegram := newFakeBot(t)
+ bot, _ := startTestBot(t, telegram)
+ socket := startTestDaemonWithBot(t, server, bot)
+ terminal := startTestClient(t, socket)
+ terminal.WaitFor(t, "[connected to OneChat]")
+
+ terminal.Type("make **it** so")
+ waitForSent(t, telegram, "[terminal] make **it** so", "Done with x.")
+
+ telegram.Add(botUpdate{
+ UpdateID: 1,
+ Message: ownerMessage(""),
+ })
+
+ waitForSent(t, telegram, textOnly)
+
+ entities := map[string]any{}
+ for _, call := range telegram.Calls() {
+ if call.method == "sendMessage" {
+ entities[call.params["text"].(string)] = call.params["entities"]
+ }
+ }
+
+ 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),
+ },
+ map[string]any{
+ "type": "code",
+ "offset": float64(10),
+ "length": float64(1),
+ },
+ },
+ textOnly: nil,
+ }, entities)
+}
+
+func TestRefusedEntitiesGoAgainAsPlainText(t *testing.T) {
+ telegram := newFakeBot(t)
+ telegram.fail = func(call fakeBotCall) (int, string) {
+ if call.params["entities"] == nil {
+ return 0, ""
+ }
+
+ return http.StatusBadRequest, "Bad Request: can't parse entities of bot" + testBotToken
+ }
+
+ bot, logs := startTestBot(t, telegram)
+ text, entities := markdownEntities("a **secret** word")
+ bot.deliver(t.Context(), botPart{
+ text: text,
+ entities: entities,
+ })
+
+ calls := telegram.Calls()
+ require.Len(t, calls, 2)
+ require.NotNil(t, calls[0].params["entities"])
+ require.Nil(t, calls[1].params["entities"])
+ require.Equal(t, []string{"a secret word"}, telegram.Sent())
+ require.Equal(t, 1, strings.Count(logs.String(), "\n"))
+ require.Contains(t, logs.String(), "entities refused")
+ require.NotContains(t, logs.String(), testBotToken)
+ require.NotContains(t, logs.String(), "secret word")
+}
+
func TestBotLogsNoTokenWhenTheNetworkFails(t *testing.T) {
telegram := newFakeBot(t)
telegram.Close()
require.Equal(t, long, strings.Join(parts, ""))
}
+func TestSplitEntitiesCutsAnEntityAcrossASplit(t *testing.T) {
+ parts := splitEntities("aabbbbcc", []botEntity{
+ {
+ Type: "bold",
+ Offset: 2,
+ Length: 4,
+ },
+ {
+ Type: "italic",
+ Offset: 6,
+ Length: 2,
+ },
+ }, 5)
+
+ require.Equal(t, []botPart{
+ {
+ text: "aabbb",
+ entities: []botEntity{
+ {
+ Type: "bold",
+ Offset: 2,
+ Length: 3,
+ },
+ },
+ },
+ {
+ text: "bcc",
+ entities: []botEntity{
+ {
+ Type: "bold",
+ Offset: 0,
+ Length: 1,
+ },
+ {
+ Type: "italic",
+ Offset: 1,
+ Length: 2,
+ },
+ },
+ },
+ }, parts)
+}
+
func TestLoadBotConfig(t *testing.T) {
testCases := []struct {
name string