]> git.giacomolli.uk - onechat/blobdiff - telegram_test.go
Move whatsapp_find to its own file
[onechat] / telegram_test.go
index df35e0df6430490778a523cbbdace02a09a7eabb..5e50ec15755f1b8f3367a44f6120697dcddf6d9b 100644 (file)
@@ -14,61 +14,61 @@ import (
 )
 
 func TestOwnerTalksToTheAgentThroughTelegram(t *testing.T) {
 )
 
 func TestOwnerTalksToTheAgentThroughTelegram(t *testing.T) {
-       server := newFakeResponses(t)
-       server.reply = func(w http.ResponseWriter, call fakeCall) {
-               writeEvents(w, completedEvent([]any{messageItem("Hello from the agent.")}, 100, 0, 5))
+       server := newModelFake(t)
+       server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+               modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Hello from the agent.")}, 100, 0, 5))
        }
 
        }
 
-       telegram := newFakeBot(t)
-       bot, _ := startTestBot(t, telegram)
-       socket := startTestDaemonWithBot(t, server, bot)
-       terminal := startTestClient(t, socket)
+       telegram := newTelegramFake(t)
+       bot, _ := telegramStartTestBot(t, telegram)
+       socket := daemonStartTestWithBot(t, server, bot)
+       terminal := terminalStartTestClient(t, socket)
        terminal.WaitFor(t, "[connected to OneChat]")
 
        terminal.WaitFor(t, "[connected to OneChat]")
 
-       telegram.Add(botUpdate{
+       telegram.Add(TelegramUpdate{
                UpdateID: 1,
                UpdateID: 1,
-               Message:  ownerMessage("hi from the phone"),
+               Message:  telegramOwnerMessage("hi from the phone"),
        })
 
        terminal.WaitFor(t, "[telegram] hi from the phone\n[working]\nHello from the agent.\n[done]")
        })
 
        terminal.WaitFor(t, "[telegram] hi from the phone\n[working]\nHello from the agent.\n[done]")
-       waitForSent(t, telegram, "Hello from the agent.")
-       require.True(t, slices.ContainsFunc(telegram.Calls(), func(call fakeBotCall) bool {
+       telegramWaitForSent(t, telegram, "Hello from the agent.")
+       require.True(t, slices.ContainsFunc(telegram.Calls(), func(call TelegramFakeCall) bool {
                return call.method == "sendChatAction" && call.params["action"] == "typing"
        }))
 
        terminal.Type("hi from the desk")
                return call.method == "sendChatAction" && call.params["action"] == "typing"
        }))
 
        terminal.Type("hi from the desk")
-       require.Equal(t, []string{"Hello from the agent.", "[terminal] hi from the desk", "Hello from the agent."}, waitForSent(t, telegram, "Hello from the agent.", "[terminal] hi from the desk", "Hello from the agent."))
+       require.Equal(t, []string{"Hello from the agent.", "[terminal] hi from the desk", "Hello from the agent."}, telegramWaitForSent(t, telegram, "Hello from the agent.", "[terminal] hi from the desk", "Hello from the agent."))
 }
 
 func TestBotIgnoresEveryoneButTheOwner(t *testing.T) {
 }
 
 func TestBotIgnoresEveryoneButTheOwner(t *testing.T) {
-       telegram := newFakeBot(t)
-       bot, logs := startTestBot(t, telegram)
-       socket := startTestDaemonWithBot(t, newFakeResponses(t), bot)
-       terminal := startTestClient(t, socket)
+       telegram := newTelegramFake(t)
+       bot, logs := telegramStartTestBot(t, telegram)
+       socket := daemonStartTestWithBot(t, newModelFake(t), bot)
+       terminal := terminalStartTestClient(t, socket)
        terminal.WaitFor(t, "[connected to OneChat]")
 
        terminal.WaitFor(t, "[connected to OneChat]")
 
-       group := ownerMessage("secret in a group")
-       group.Chat = botChat{
+       group := telegramOwnerMessage("secret in a group")
+       group.Chat = TelegramChat{
                ID:   -100,
                Type: "group",
        }
 
                ID:   -100,
                Type: "group",
        }
 
-       stranger := ownerMessage("secret of a stranger")
+       stranger := telegramOwnerMessage("secret of a stranger")
        stranger.Chat.ID = 77
        stranger.From.ID = 77
 
        stranger.Chat.ID = 77
        stranger.From.ID = 77
 
-       unsigned := ownerMessage("secret with no sender")
+       unsigned := telegramOwnerMessage("secret with no sender")
        unsigned.From = nil
 
        unsigned.From = nil
 
-       for id, message := range []*botMessage{group, stranger, unsigned, nil} {
-               telegram.Add(botUpdate{
+       for id, message := range []*TelegramMessage{group, stranger, unsigned, nil} {
+               telegram.Add(TelegramUpdate{
                        UpdateID: int64(10 + id),
                        Message:  message,
                })
        }
 
        require.Eventually(t, func() bool {
                        UpdateID: int64(10 + id),
                        Message:  message,
                })
        }
 
        require.Eventually(t, func() bool {
-               offset, err := loadOffset(bot.offsetPath)
+               offset, err := telegramLoadOffset(bot.offsetPath)
                return err == nil && offset == 14
        }, 10*time.Second, time.Millisecond)
 
                return err == nil && offset == 14
        }, 10*time.Second, time.Millisecond)
 
@@ -79,103 +79,153 @@ func TestBotIgnoresEveryoneButTheOwner(t *testing.T) {
 }
 
 func TestBotAnswersThatOnlyTextIsSupported(t *testing.T) {
 }
 
 func TestBotAnswersThatOnlyTextIsSupported(t *testing.T) {
-       telegram := newFakeBot(t)
-       bot, _ := startTestBot(t, telegram)
-       startTestDaemonWithBot(t, newFakeResponses(t), bot)
+       telegram := newTelegramFake(t)
+       bot, _ := telegramStartTestBot(t, telegram)
+       daemonStartTestWithBot(t, newModelFake(t), bot)
 
 
-       telegram.Add(botUpdate{
+       telegram.Add(TelegramUpdate{
                UpdateID: 1,
                UpdateID: 1,
-               Message:  ownerMessage(""),
+               Message:  telegramOwnerMessage(""),
        })
 
        })
 
-       waitForSent(t, telegram, textOnly)
+       telegramWaitForSent(t, telegram, telegramTextOnly)
 }
 
 func TestStopFromTelegramInterruptsTheTurn(t *testing.T) {
 }
 
 func TestStopFromTelegramInterruptsTheTurn(t *testing.T) {
-       server := newFakeResponses(t)
+       server := newModelFake(t)
        arrived := make(chan struct{})
        release := make(chan struct{})
        t.Cleanup(func() {
                close(release)
        })
 
        arrived := make(chan struct{})
        release := make(chan struct{})
        t.Cleanup(func() {
                close(release)
        })
 
-       server.reply = func(w http.ResponseWriter, call fakeCall) {
+       server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
                close(arrived)
                <-release
        }
 
                close(arrived)
                <-release
        }
 
-       telegram := newFakeBot(t)
-       bot, _ := startTestBot(t, telegram)
-       startTestDaemonWithBot(t, server, bot)
+       telegram := newTelegramFake(t)
+       bot, _ := telegramStartTestBot(t, telegram)
+       daemonStartTestWithBot(t, server, bot)
 
 
-       telegram.Add(botUpdate{
+       telegram.Add(TelegramUpdate{
                UpdateID: 1,
                UpdateID: 1,
-               Message:  ownerMessage("a long task"),
+               Message:  telegramOwnerMessage("a long task"),
        })
 
        <-arrived
        })
 
        <-arrived
-       telegram.Add(botUpdate{
+       telegram.Add(TelegramUpdate{
                UpdateID: 2,
                UpdateID: 2,
-               Message:  ownerMessage(stopCommand),
+               Message:  telegramOwnerMessage(stopCommand),
        })
 
        })
 
-       waitForSent(t, telegram, "Error: interrupted")
+       telegramWaitForSent(t, telegram, "Error: interrupted")
 
 
-       telegram.Add(botUpdate{
+       telegram.Add(TelegramUpdate{
                UpdateID: 3,
                UpdateID: 3,
-               Message:  ownerMessage(stopCommand),
+               Message:  telegramOwnerMessage(stopCommand),
        })
 
        })
 
-       waitForSent(t, telegram, "Error: interrupted", "No turn runs.")
+       telegramWaitForSent(t, telegram, "Error: interrupted", "No turn runs.")
+
+       telegram.Add(TelegramUpdate{
+               UpdateID: 4,
+               Message:  telegramOwnerMessage(stopCommand + " BlueFox"),
+       })
+
+       telegramWaitForSent(t, telegram, "No turn runs.", "No subagent BlueFox runs.")
+}
+
+func TestBotShowsOnlyTheStartAndTheEndOfASubagent(t *testing.T) {
+       telegram := newTelegramFake(t)
+       server := newModelFake(t)
+       server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+               last := subagentTestLastItem(call)
+               switch {
+               case subagentTestCall(call):
+                       // The report comes after the reply of the turn of the spawn.
+                       require.Eventually(t, func() bool {
+                               return slices.Contains(telegram.Sent(), "Started.")
+                       }, 10*time.Second, time.Millisecond)
+                       modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("There are 3 files.")}, 100, 0, 5))
+               case last["type"] == "function_call_output":
+                       modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Started.")}, 100, 0, 5))
+               case strings.HasPrefix(subagentTestText(last), "["):
+                       modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("It counted 3 files.")}, 100, 0, 5))
+               default:
+                       modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "spawn", `{"tasks":["Count the files."],"effort":null}`)}, 100, 0, 5))
+               }
+       }
+
+       bot, _ := telegramStartTestBot(t, telegram)
+       daemonStartTestWithBot(t, server, bot)
+       telegram.Add(TelegramUpdate{
+               UpdateID: 1,
+               Message:  telegramOwnerMessage("Count the files in a subagent."),
+       })
+
+       require.Eventually(t, func() bool {
+               return slices.Contains(telegram.Sent(), "It counted 3 files.")
+       }, 10*time.Second, time.Millisecond)
+
+       sent := telegram.Sent()
+       name := strings.TrimSuffix(strings.TrimPrefix(sent[0], "["), "] started")
+       require.True(t, subagentNameValid(name), sent[0])
+       require.Equal(t, []string{
+               "[" + name + "] started",
+               "Started.",
+               "[" + name + "] finished",
+               "It counted 3 files.",
+       }, sent)
 }
 
 func TestBotStartsAtTheStoredOffset(t *testing.T) {
 }
 
 func TestBotStartsAtTheStoredOffset(t *testing.T) {
-       server := newFakeResponses(t)
-       server.reply = func(w http.ResponseWriter, call fakeCall) {
-               writeEvents(w, completedEvent([]any{messageItem("ok")}, 100, 0, 5))
+       server := newModelFake(t)
+       server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+               modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5))
        }
 
        }
 
-       telegram := newFakeBot(t)
-       bot, _ := startTestBot(t, telegram)
-       require.NoError(t, saveOffset(bot.offsetPath, 10))
+       telegram := newTelegramFake(t)
+       bot, _ := telegramStartTestBot(t, telegram)
+       require.NoError(t, telegramSaveOffset(bot.offsetPath, 10))
 
 
-       telegram.Add(botUpdate{
+       telegram.Add(TelegramUpdate{
                UpdateID: 9,
                UpdateID: 9,
-               Message:  ownerMessage("handled before the restart"),
+               Message:  telegramOwnerMessage("handled before the restart"),
        })
 
        })
 
-       telegram.Add(botUpdate{
+       telegram.Add(TelegramUpdate{
                UpdateID: 10,
                UpdateID: 10,
-               Message:  ownerMessage("new after the restart"),
+               Message:  telegramOwnerMessage("new after the restart"),
        })
 
        })
 
-       socket := startTestDaemonWithBot(t, server, bot)
-       terminal := startTestClient(t, socket)
+       socket := daemonStartTestWithBot(t, server, bot)
+       terminal := terminalStartTestClient(t, socket)
        terminal.WaitFor(t, "[connected to OneChat]")
        terminal.WaitFor(t, "[connected to OneChat]")
-       waitForSent(t, telegram, "ok")
+       telegramWaitForSent(t, telegram, "ok")
 
        require.Equal(t, float64(10), telegram.Calls()[0].params["offset"])
        require.NotContains(t, terminal.Output(), "handled before the restart")
 
        require.Eventually(t, func() bool {
 
        require.Equal(t, float64(10), telegram.Calls()[0].params["offset"])
        require.NotContains(t, terminal.Output(), "handled before the restart")
 
        require.Eventually(t, func() bool {
-               offset, err := loadOffset(bot.offsetPath)
+               offset, err := telegramLoadOffset(bot.offsetPath)
                return err == nil && offset == 11
        }, 10*time.Second, time.Millisecond)
 }
 
 func TestBotTriesAgainAfterFailures(t *testing.T) {
                return err == nil && offset == 11
        }, 10*time.Second, time.Millisecond)
 }
 
 func TestBotTriesAgainAfterFailures(t *testing.T) {
-       server := newFakeResponses(t)
-       server.reply = func(w http.ResponseWriter, call fakeCall) {
-               writeEvents(w, completedEvent([]any{messageItem("ok")}, 100, 0, 5))
+       server := newModelFake(t)
+       server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+               modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5))
        }
 
        }
 
-       telegram := newFakeBot(t)
+       telegram := newTelegramFake(t)
        failures := map[string]int{
                "getUpdates":      2,
                "sendRichMessage": 1,
        }
 
        failures := map[string]int{
                "getUpdates":      2,
                "sendRichMessage": 1,
        }
 
-       telegram.fail = func(call fakeBotCall) (int, string) {
+       telegram.fail = func(call TelegramFakeCall) (int, string) {
                if failures[call.method] == 0 {
                        return 0, ""
                }
                if failures[call.method] == 0 {
                        return 0, ""
                }
@@ -188,24 +238,24 @@ func TestBotTriesAgainAfterFailures(t *testing.T) {
                return http.StatusBadGateway, "Bad Gateway"
        }
 
                return http.StatusBadGateway, "Bad Gateway"
        }
 
-       bot, logs := startTestBot(t, telegram)
-       startTestDaemonWithBot(t, server, bot)
+       bot, logs := telegramStartTestBot(t, telegram)
+       daemonStartTestWithBot(t, server, bot)
 
 
-       telegram.Add(botUpdate{
+       telegram.Add(TelegramUpdate{
                UpdateID: 1,
                UpdateID: 1,
-               Message:  ownerMessage("hello"),
+               Message:  telegramOwnerMessage("hello"),
        })
 
        })
 
-       waitForSent(t, telegram, "ok")
+       telegramWaitForSent(t, telegram, "ok")
        require.Contains(t, logs.String(), "answered 502")
        require.Contains(t, logs.String(), "answered 429")
        require.Contains(t, logs.String(), "answered 502")
        require.Contains(t, logs.String(), "answered 429")
-       require.NotContains(t, logs.String(), testBotToken)
+       require.NotContains(t, logs.String(), telegramTestToken)
 }
 
 func TestDeliverTriesAgainAfterATimeout(t *testing.T) {
 }
 
 func TestDeliverTriesAgainAfterATimeout(t *testing.T) {
-       telegram := newFakeBot(t)
+       telegram := newTelegramFake(t)
        stalls := 1
        stalls := 1
-       telegram.stall = func(call fakeBotCall) bool {
+       telegram.stall = func(call TelegramFakeCall) bool {
                if call.method != "sendMessage" || stalls == 0 {
                        return false
                }
                if call.method != "sendMessage" || stalls == 0 {
                        return false
                }
@@ -215,53 +265,53 @@ func TestDeliverTriesAgainAfterATimeout(t *testing.T) {
                return true
        }
 
                return true
        }
 
-       bot, logs := startTestBot(t, telegram)
+       bot, logs := telegramStartTestBot(t, telegram)
        bot.api.sendTimeout = 50 * time.Millisecond
        bot.api.sendTimeout = 50 * time.Millisecond
-       bot.deliver(t.Context(), botPart{
+       bot.deliver(t.Context(), TelegramPart{
                text: "hello",
        })
 
        require.Equal(t, []string{"hello"}, telegram.Sent())
        require.Len(t, telegram.Calls(), 2)
        require.Contains(t, logs.String(), "no valid answer")
                text: "hello",
        })
 
        require.Equal(t, []string{"hello"}, telegram.Sent())
        require.Len(t, telegram.Calls(), 2)
        require.Contains(t, logs.String(), "no valid answer")
-       require.NotContains(t, logs.String(), testBotToken)
+       require.NotContains(t, logs.String(), telegramTestToken)
 }
 
 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```"
 }
 
 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(reply)}, 100, 0, 5))
+       server := newModelFake(t)
+       server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+               modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(reply)}, 100, 0, 5))
        }
 
        }
 
-       telegram := newFakeBot(t)
-       bot, _ := startTestBot(t, telegram)
-       socket := startTestDaemonWithBot(t, server, bot)
-       terminal := startTestClient(t, socket)
+       telegram := newTelegramFake(t)
+       bot, _ := telegramStartTestBot(t, telegram)
+       socket := daemonStartTestWithBot(t, server, bot)
+       terminal := terminalStartTestClient(t, socket)
        terminal.WaitFor(t, "[connected to OneChat]")
 
        terminal.Type("make **it** so")
        terminal.WaitFor(t, "[connected to OneChat]")
 
        terminal.Type("make **it** so")
-       waitForSent(t, telegram, "[terminal] make **it** so", reply)
+       telegramWaitForSent(t, telegram, "[terminal] make **it** so", reply)
 
 
-       telegram.Add(botUpdate{
+       telegram.Add(TelegramUpdate{
                UpdateID: 1,
                UpdateID: 1,
-               Message:  ownerMessage(""),
+               Message:  telegramOwnerMessage(""),
        })
 
        })
 
-       waitForSent(t, telegram, textOnly)
+       telegramWaitForSent(t, telegram, telegramTextOnly)
 
 
-       var sends []fakeBotCall
+       var sends []TelegramFakeCall
        for _, call := range telegram.Calls() {
                if call.method == "sendMessage" || call.method == "sendRichMessage" {
                        sends = append(sends, call)
                }
        }
 
        for _, call := range telegram.Calls() {
                if call.method == "sendMessage" || call.method == "sendRichMessage" {
                        sends = append(sends, call)
                }
        }
 
-       require.Equal(t, []fakeBotCall{
+       require.Equal(t, []TelegramFakeCall{
                {
                        method: "sendMessage",
                        params: map[string]any{
                {
                        method: "sendMessage",
                        params: map[string]any{
-                               "chat_id": float64(testOwner),
+                               "chat_id": float64(telegramTestOwner),
                                "text":    "[terminal] make **it** so",
                        },
                        ok: true,
                                "text":    "[terminal] make **it** so",
                        },
                        ok: true,
@@ -269,7 +319,7 @@ func TestRepliesGoAsRichMessages(t *testing.T) {
                {
                        method: "sendRichMessage",
                        params: map[string]any{
                {
                        method: "sendRichMessage",
                        params: map[string]any{
-                               "chat_id": float64(testOwner),
+                               "chat_id": float64(telegramTestOwner),
                                "rich_message": map[string]any{
                                        "markdown": reply,
                                },
                                "rich_message": map[string]any{
                                        "markdown": reply,
                                },
@@ -279,8 +329,8 @@ func TestRepliesGoAsRichMessages(t *testing.T) {
                {
                        method: "sendMessage",
                        params: map[string]any{
                {
                        method: "sendMessage",
                        params: map[string]any{
-                               "chat_id": float64(testOwner),
-                               "text":    textOnly,
+                               "chat_id": float64(telegramTestOwner),
+                               "text":    telegramTextOnly,
                        },
                        ok: true,
                },
                        },
                        ok: true,
                },
@@ -337,19 +387,19 @@ func TestRichMessageFailures(t *testing.T) {
 
        for _, tc := range testCases {
                t.Run(tc.name, func(t *testing.T) {
 
        for _, tc := range testCases {
                t.Run(tc.name, func(t *testing.T) {
-                       telegram := newFakeBot(t)
+                       telegram := newTelegramFake(t)
                        failed := false
                        failed := false
-                       telegram.fail = func(call fakeBotCall) (int, string) {
+                       telegram.fail = func(call TelegramFakeCall) (int, string) {
                                if call.method != "sendRichMessage" || failed && !tc.always {
                                        return 0, ""
                                }
 
                                failed = true
 
                                if call.method != "sendRichMessage" || failed && !tc.always {
                                        return 0, ""
                                }
 
                                failed = true
 
-                               return tc.status, "refused for bot" + testBotToken
+                               return tc.status, "refused for bot" + telegramTestToken
                        }
 
                        }
 
-                       bot, logs := startTestBot(t, telegram)
+                       bot, logs := telegramStartTestBot(t, telegram)
                        bot.post(t.Context(), Event{
                                Kind: "reply",
                                Text: "a **secret** word",
                        bot.post(t.Context(), Event{
                                Kind: "reply",
                                Text: "a **secret** word",
@@ -364,7 +414,7 @@ func TestRichMessageFailures(t *testing.T) {
                        require.Equal(t, tc.sent, telegram.Sent())
                        require.Equal(t, 1, strings.Count(logs.String(), "\n"))
                        require.Contains(t, logs.String(), tc.logText)
                        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(), telegramTestToken)
                        require.NotContains(t, logs.String(), "secret word")
 
                        calls := telegram.Calls()
                        require.NotContains(t, logs.String(), "secret word")
 
                        calls := telegram.Calls()
@@ -376,12 +426,12 @@ func TestRichMessageFailures(t *testing.T) {
 }
 
 func TestLongReplyGoesAsRichMessagesSplitBetweenBlocks(t *testing.T) {
 }
 
 func TestLongReplyGoesAsRichMessagesSplitBetweenBlocks(t *testing.T) {
-       paragraphs := strings.Repeat("A paragraph of the reply.\n\n", richMessageBytes/len("A paragraph of the reply.\n\n"))
+       paragraphs := strings.Repeat("A paragraph of the reply.\n\n", telegramRichBytes/len("A paragraph of the reply.\n\n"))
        table := "| a | b |\n|---|---|\n| 1 | 2 |\n"
        reply := paragraphs + table
 
        table := "| a | b |\n|---|---|\n| 1 | 2 |\n"
        reply := paragraphs + table
 
-       telegram := newFakeBot(t)
-       bot, _ := startTestBot(t, telegram)
+       telegram := newTelegramFake(t)
+       bot, _ := telegramStartTestBot(t, telegram)
        bot.post(t.Context(), Event{
                Kind: "reply",
                Text: reply,
        bot.post(t.Context(), Event{
                Kind: "reply",
                Text: reply,
@@ -390,19 +440,124 @@ func TestLongReplyGoesAsRichMessagesSplitBetweenBlocks(t *testing.T) {
        require.Equal(t, []string{paragraphs, table}, telegram.Sent())
 }
 
        require.Equal(t, []string{paragraphs, table}, telegram.Sent())
 }
 
+func TestReplyTagsGoAsText(t *testing.T) {
+       testCases := []struct {
+               name   string
+               status int
+               sent   []string
+       }{
+               {
+                       name: "rich message escapes the tag outside code",
+                       sent: []string{"a &lt;details> &amp; tag and `<b>`"},
+               },
+               {
+                       name:   "entity fallback shows the tag as written",
+                       status: http.StatusBadRequest,
+                       sent:   []string{"a <details> & tag and <b>"},
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       telegram := newTelegramFake(t)
+                       telegram.fail = func(call TelegramFakeCall) (int, string) {
+                               if call.method != "sendRichMessage" {
+                                       return 0, ""
+                               }
+
+                               return tc.status, "Bad Request"
+                       }
+
+                       bot, _ := telegramStartTestBot(t, telegram)
+                       bot.post(t.Context(), Event{
+                               Kind: "reply",
+                               Text: "a <details> & tag and `<b>`",
+                       })
+
+                       require.Equal(t, tc.sent, telegram.Sent())
+               })
+       }
+}
+
+func TestLongReplySplitCountsTheEscapedTags(t *testing.T) {
+       paragraph := "A <b> tag.\n\n"
+       count := telegramRichBytes / len(paragraph)
+       reply := strings.Repeat(paragraph, count)
+       require.LessOrEqual(t, len(reply), telegramRichBytes)
+
+       telegram := newTelegramFake(t)
+       bot, _ := telegramStartTestBot(t, telegram)
+       bot.post(t.Context(), Event{
+               Kind: "reply",
+               Text: reply,
+       })
+
+       sent := telegram.Sent()
+       require.Len(t, sent, 2)
+       require.Equal(t, telegramEscapeTags(reply), strings.Join(sent, ""))
+
+       for _, markdown := range sent {
+               require.LessOrEqual(t, len(markdown), telegramRichBytes)
+       }
+}
+
+func TestRefusedRichPartGoesWithTheTextAsWritten(t *testing.T) {
+       paragraph := "A <b> & c.\n\n"
+       reply := strings.Repeat(paragraph, telegramRichBytes/len(paragraph))
+
+       telegram := newTelegramFake(t)
+       refused := false
+       telegram.fail = func(call TelegramFakeCall) (int, string) {
+               if call.method != "sendRichMessage" || refused {
+                       return 0, ""
+               }
+
+               refused = true
+
+               return http.StatusBadRequest, "Bad Request"
+       }
+
+       bot, _ := telegramStartTestBot(t, telegram)
+       bot.post(t.Context(), Event{
+               Kind: "reply",
+               Text: reply,
+       })
+
+       var fallback, rich []string
+       for _, call := range telegram.Calls() {
+               switch {
+               case !call.ok:
+               case call.method == "sendMessage":
+                       fallback = append(fallback, call.params["text"].(string))
+               case call.method == "sendRichMessage":
+                       rich = append(rich, call.params["rich_message"].(map[string]any)["markdown"].(string))
+               }
+       }
+
+       require.Greater(t, len(fallback), 1)
+       require.Len(t, rich, 1)
+       require.True(t, strings.HasPrefix(reply, strings.Join(fallback, "")), "fallback text is not the reply as written")
+
+       for _, text := range fallback {
+               require.LessOrEqual(t, telegramUTF16Units(text), telegramMessageUnits)
+       }
+
+       require.True(t, reply == strings.Join(fallback, "")+telegramUnescapeTags(rich[0]), "reply is not sent once and whole")
+}
+
 func TestRefusedEntitiesGoAgainAsPlainText(t *testing.T) {
 func TestRefusedEntitiesGoAgainAsPlainText(t *testing.T) {
-       telegram := newFakeBot(t)
-       telegram.fail = func(call fakeBotCall) (int, string) {
+       telegram := newTelegramFake(t)
+       telegram.fail = func(call TelegramFakeCall) (int, string) {
                if call.params["entities"] == nil {
                        return 0, ""
                }
 
                if call.params["entities"] == nil {
                        return 0, ""
                }
 
-               return http.StatusBadRequest, "Bad Request: can't parse entities of bot" + testBotToken
+               return http.StatusBadRequest, "Bad Request: can't parse entities of bot" + telegramTestToken
        }
 
        }
 
-       bot, logs := startTestBot(t, telegram)
-       text, entities := markdownEntities("a **secret** word")
-       bot.deliver(t.Context(), botPart{
+       bot, logs := telegramStartTestBot(t, telegram)
+       text, entities := telegramMarkdownEntities("a **secret** word")
+       bot.deliver(t.Context(), TelegramPart{
                text:     text,
                entities: entities,
        })
                text:     text,
                entities: entities,
        })
@@ -414,21 +569,21 @@ func TestRefusedEntitiesGoAgainAsPlainText(t *testing.T) {
        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.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(), telegramTestToken)
        require.NotContains(t, logs.String(), "secret word")
 }
 
 func TestBotLogsNoTokenWhenTheNetworkFails(t *testing.T) {
        require.NotContains(t, logs.String(), "secret word")
 }
 
 func TestBotLogsNoTokenWhenTheNetworkFails(t *testing.T) {
-       telegram := newFakeBot(t)
+       telegram := newTelegramFake(t)
        telegram.Close()
 
        telegram.Close()
 
-       bot, logs := startTestBot(t, telegram)
-       startTestDaemonWithBot(t, newFakeResponses(t), bot)
+       bot, logs := telegramStartTestBot(t, telegram)
+       daemonStartTestWithBot(t, newModelFake(t), bot)
 
        require.Eventually(t, func() bool {
                return strings.Count(logs.String(), "no valid answer") >= 2
        }, 10*time.Second, time.Millisecond)
 
        require.Eventually(t, func() bool {
                return strings.Count(logs.String(), "no valid answer") >= 2
        }, 10*time.Second, time.Millisecond)
-       require.NotContains(t, logs.String(), testBotToken)
+       require.NotContains(t, logs.String(), telegramTestToken)
 }
 
 func TestSplitMessage(t *testing.T) {
 }
 
 func TestSplitMessage(t *testing.T) {
@@ -472,18 +627,18 @@ func TestSplitMessage(t *testing.T) {
 
        for _, tc := range testCases {
                t.Run(tc.name, func(t *testing.T) {
 
        for _, tc := range testCases {
                t.Run(tc.name, func(t *testing.T) {
-                       require.Equal(t, tc.parts, splitMessage(tc.text, tc.limit))
+                       require.Equal(t, tc.parts, telegramSplitMessage(tc.text, tc.limit))
                })
        }
 
        long := strings.Repeat("word ", 2000)
                })
        }
 
        long := strings.Repeat("word ", 2000)
-       parts := splitMessage(long, messageUnits)
+       parts := telegramSplitMessage(long, telegramMessageUnits)
        require.Len(t, parts, 3)
        require.Equal(t, long, strings.Join(parts, ""))
 }
 
 func TestSplitEntitiesCutsAnEntityAcrossASplit(t *testing.T) {
        require.Len(t, parts, 3)
        require.Equal(t, long, strings.Join(parts, ""))
 }
 
 func TestSplitEntitiesCutsAnEntityAcrossASplit(t *testing.T) {
-       parts := splitEntities("aabbbbcc", []botEntity{
+       parts := telegramSplitEntities("aabbbbcc", []TelegramEntity{
                {
                        Type:   "bold",
                        Offset: 2,
                {
                        Type:   "bold",
                        Offset: 2,
@@ -496,10 +651,10 @@ func TestSplitEntitiesCutsAnEntityAcrossASplit(t *testing.T) {
                },
        }, 5)
 
                },
        }, 5)
 
-       require.Equal(t, []botPart{
+       require.Equal(t, []TelegramPart{
                {
                        text: "aabbb",
                {
                        text: "aabbb",
-                       entities: []botEntity{
+                       entities: []TelegramEntity{
                                {
                                        Type:   "bold",
                                        Offset: 2,
                                {
                                        Type:   "bold",
                                        Offset: 2,
@@ -509,7 +664,7 @@ func TestSplitEntitiesCutsAnEntityAcrossASplit(t *testing.T) {
                },
                {
                        text: "bcc",
                },
                {
                        text: "bcc",
-                       entities: []botEntity{
+                       entities: []TelegramEntity{
                                {
                                        Type:   "bold",
                                        Offset: 0,
                                {
                                        Type:   "bold",
                                        Offset: 0,
@@ -568,18 +723,24 @@ func TestSplitRich(t *testing.T) {
                        limit:    3,
                        parts:    []string{"ab", "\u00e9c", "d"},
                },
                        limit:    3,
                        parts:    []string{"ab", "\u00e9c", "d"},
                },
+               {
+                       name:     "long line is not cut inside an entity",
+                       markdown: "abc&amp;def",
+                       limit:    6,
+                       parts:    []string{"abc", "&amp;d", "ef"},
+               },
        }
 
        for _, tc := range testCases {
                t.Run(tc.name, func(t *testing.T) {
        }
 
        for _, tc := range testCases {
                t.Run(tc.name, func(t *testing.T) {
-                       require.Equal(t, tc.parts, splitRich(tc.markdown, tc.limit))
+                       require.Equal(t, tc.parts, telegramSplitRich(tc.markdown, tc.limit))
                })
        }
 
        long := strings.Repeat("A paragraph of the reply.\n\n", 2000)
                })
        }
 
        long := strings.Repeat("A paragraph of the reply.\n\n", 2000)
-       parts := splitRich(long, richMessageBytes)
+       parts := telegramSplitRich(long, telegramRichBytes)
        require.Len(t, parts, 2)
        require.Len(t, parts, 2)
-       require.Len(t, parts[0], richMessageBytes-richMessageBytes%len("A paragraph of the reply.\n\n"))
+       require.Len(t, parts[0], telegramRichBytes-telegramRichBytes%len("A paragraph of the reply.\n\n"))
        require.Equal(t, long, strings.Join(parts, ""))
 }
 
        require.Equal(t, long, strings.Join(parts, ""))
 }
 
@@ -592,12 +753,12 @@ func TestLoadBotConfig(t *testing.T) {
        }{
                {
                        name:    "owner file is read",
        }{
                {
                        name:    "owner file is read",
-                       content: `{"token": "` + testBotToken + `", "owner": 4242}`,
+                       content: `{"token": "` + telegramTestToken + `", "owner": 4242}`,
                        mode:    0o600,
                },
                {
                        name:      "file that others can read is refused",
                        mode:    0o600,
                },
                {
                        name:      "file that others can read is refused",
-                       content:   `{"token": "` + testBotToken + `", "owner": 4242}`,
+                       content:   `{"token": "` + telegramTestToken + `", "owner": 4242}`,
                        mode:      0o640,
                        errorText: "mode must be 0600",
                },
                        mode:      0o640,
                        errorText: "mode must be 0600",
                },
@@ -609,13 +770,13 @@ func TestLoadBotConfig(t *testing.T) {
                },
                {
                        name:      "missing owner is refused",
                },
                {
                        name:      "missing owner is refused",
-                       content:   `{"token": "` + testBotToken + `"}`,
+                       content:   `{"token": "` + telegramTestToken + `"}`,
                        mode:      0o600,
                        errorText: "numeric Telegram user ID",
                },
                {
                        name:      "unknown field is refused",
                        mode:      0o600,
                        errorText: "numeric Telegram user ID",
                },
                {
                        name:      "unknown field is refused",
-                       content:   `{"token": "` + testBotToken + `", "owner": 4242, "chat": 1}`,
+                       content:   `{"token": "` + telegramTestToken + `", "owner": 4242, "chat": 1}`,
                        mode:      0o600,
                        errorText: "not valid JSON",
                },
                        mode:      0o600,
                        errorText: "not valid JSON",
                },
@@ -624,47 +785,49 @@ func TestLoadBotConfig(t *testing.T) {
        for _, tc := range testCases {
                t.Run(tc.name, func(t *testing.T) {
                        dir := t.TempDir()
        for _, tc := range testCases {
                t.Run(tc.name, func(t *testing.T) {
                        dir := t.TempDir()
-                       path := filepath.Join(dir, botConfigFileName)
+                       path := filepath.Join(dir, telegramConfigFileName)
                        require.NoError(t, os.WriteFile(path, []byte(tc.content), tc.mode))
                        require.NoError(t, os.Chmod(path, tc.mode))
 
                        require.NoError(t, os.WriteFile(path, []byte(tc.content), tc.mode))
                        require.NoError(t, os.Chmod(path, tc.mode))
 
-                       config, found, err := loadBotConfig(dir)
+                       config, found, err := loadTelegramConfig(dir)
                        if tc.errorText != "" {
                                require.ErrorContains(t, err, tc.errorText)
                        if tc.errorText != "" {
                                require.ErrorContains(t, err, tc.errorText)
-                               require.NotContains(t, err.Error(), testBotToken)
+                               require.NotContains(t, err.Error(), telegramTestToken)
 
                                return
                        }
 
                        require.NoError(t, err)
                        require.True(t, found)
 
                                return
                        }
 
                        require.NoError(t, err)
                        require.True(t, found)
-                       require.Equal(t, botConfig{
-                               Token: testBotToken,
-                               Owner: testOwner,
+                       require.Equal(t, TelegramConfig{
+                               Token: telegramTestToken,
+                               Owner: telegramTestOwner,
                        }, config)
                })
        }
 
                        }, config)
                })
        }
 
-       _, found, err := loadBotConfig(t.TempDir())
+       _, found, err := loadTelegramConfig(t.TempDir())
        require.NoError(t, err)
        require.False(t, found)
 }
 
        require.NoError(t, err)
        require.False(t, found)
 }
 
-// startTestBot returns a bot of testOwner on server, with its offset file in a
-// temporary directory and short waits, and the buffer of its log.
-func startTestBot(t *testing.T, server *fakeBot) (*telegramBot, *syncBuffer) {
+// telegramStartTestBot returns a bot of telegramTestOwner on server, with its
+// offset file in a temporary directory and short waits, and the buffer of its
+// log.
+func telegramStartTestBot(t *testing.T, server *TelegramFake) (*TelegramBot, *SyncBuffer) {
        t.Helper()
 
        t.Helper()
 
-       logs := &syncBuffer{}
-       bot := newTelegramBot(newBotAPI(server.URL, testBotToken), testOwner, filepath.Join(t.TempDir(), offsetFileName), log.New(logs, "", 0))
+       logs := &SyncBuffer{}
+       bot := newTelegramBot(newTelegramAPI(server.URL, telegramTestToken), telegramTestOwner, filepath.Join(t.TempDir(), telegramOffsetFileName), log.New(logs, "", 0))
        bot.minWait = time.Millisecond
        bot.maxWait = 10 * time.Millisecond
 
        return bot, logs
 }
 
        bot.minWait = time.Millisecond
        bot.maxWait = 10 * time.Millisecond
 
        return bot, logs
 }
 
-// waitForSent waits until the bot sent the texts in order, and returns them.
-func waitForSent(t *testing.T, server *fakeBot, texts ...string) []string {
+// telegramWaitForSent waits until the bot sent the texts in order, and returns
+// them.
+func telegramWaitForSent(t *testing.T, server *TelegramFake, texts ...string) []string {
        t.Helper()
 
        require.Eventually(t, func() bool {
        t.Helper()
 
        require.Eventually(t, func() bool {