+package main
+
+import (
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+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))
+ }
+
+ telegram := newFakeBot(t)
+ bot, _ := startTestBot(t, telegram)
+ socket := startTestDaemonWithBot(t, server, bot)
+ terminal := startTestClient(t, socket)
+ terminal.WaitFor(t, "[connected to OneChat]")
+
+ telegram.Add(botUpdate{
+ UpdateID: 1,
+ Message: ownerMessage("hi from the phone"),
+ })
+
+ 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 {
+ 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."))
+}
+
+func TestBotIgnoresEveryoneButTheOwner(t *testing.T) {
+ telegram := newFakeBot(t)
+ bot, logs := startTestBot(t, telegram)
+ socket := startTestDaemonWithBot(t, newFakeResponses(t), bot)
+ terminal := startTestClient(t, socket)
+ terminal.WaitFor(t, "[connected to OneChat]")
+
+ group := ownerMessage("secret in a group")
+ group.Chat = botChat{
+ ID: -100,
+ Type: "group",
+ }
+
+ stranger := ownerMessage("secret of a stranger")
+ stranger.Chat.ID = 77
+ stranger.From.ID = 77
+
+ unsigned := ownerMessage("secret with no sender")
+ unsigned.From = nil
+
+ for id, message := range []*botMessage{group, stranger, unsigned, nil} {
+ telegram.Add(botUpdate{
+ UpdateID: int64(10 + id),
+ Message: message,
+ })
+ }
+
+ require.Eventually(t, func() bool {
+ offset, err := loadOffset(bot.offsetPath)
+ return err == nil && offset == 14
+ }, 10*time.Second, time.Millisecond)
+
+ require.Empty(t, telegram.Sent())
+ require.NotContains(t, terminal.Output(), "secret")
+ require.NotContains(t, logs.String(), "secret")
+ require.Contains(t, logs.String(), "update ignored")
+}
+
+func TestBotAnswersThatOnlyTextIsSupported(t *testing.T) {
+ telegram := newFakeBot(t)
+ bot, _ := startTestBot(t, telegram)
+ startTestDaemonWithBot(t, newFakeResponses(t), bot)
+
+ telegram.Add(botUpdate{
+ UpdateID: 1,
+ Message: ownerMessage(""),
+ })
+
+ waitForSent(t, telegram, textOnly)
+}
+
+func TestStopFromTelegramInterruptsTheTurn(t *testing.T) {
+ server := newFakeResponses(t)
+ arrived := make(chan struct{})
+ release := make(chan struct{})
+ t.Cleanup(func() {
+ close(release)
+ })
+
+ server.reply = func(w http.ResponseWriter, call fakeCall) {
+ close(arrived)
+ <-release
+ }
+
+ telegram := newFakeBot(t)
+ bot, _ := startTestBot(t, telegram)
+ startTestDaemonWithBot(t, server, bot)
+
+ telegram.Add(botUpdate{
+ UpdateID: 1,
+ Message: ownerMessage("a long task"),
+ })
+
+ <-arrived
+ telegram.Add(botUpdate{
+ UpdateID: 2,
+ Message: ownerMessage(stopCommand),
+ })
+
+ waitForSent(t, telegram, "Error: interrupted")
+
+ telegram.Add(botUpdate{
+ UpdateID: 3,
+ Message: ownerMessage(stopCommand),
+ })
+
+ waitForSent(t, telegram, "Error: interrupted", "No turn runs.")
+}
+
+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))
+ }
+
+ telegram := newFakeBot(t)
+ bot, _ := startTestBot(t, telegram)
+ require.NoError(t, saveOffset(bot.offsetPath, 10))
+
+ telegram.Add(botUpdate{
+ UpdateID: 9,
+ Message: ownerMessage("handled before the restart"),
+ })
+
+ telegram.Add(botUpdate{
+ UpdateID: 10,
+ Message: ownerMessage("new after the restart"),
+ })
+
+ socket := startTestDaemonWithBot(t, server, bot)
+ terminal := startTestClient(t, socket)
+ terminal.WaitFor(t, "[connected to OneChat]")
+ waitForSent(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 {
+ offset, err := loadOffset(bot.offsetPath)
+ 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))
+ }
+
+ telegram := newFakeBot(t)
+ failures := map[string]int{
+ "getUpdates": 2,
+ "sendMessage": 1,
+ }
+
+ telegram.fail = func(call fakeBotCall) (int, string) {
+ if failures[call.method] == 0 {
+ return 0, ""
+ }
+
+ failures[call.method]--
+ if call.method == "sendMessage" {
+ return http.StatusTooManyRequests, "Too Many Requests: retry after 1"
+ }
+
+ return http.StatusBadGateway, "Bad Gateway"
+ }
+
+ bot, logs := startTestBot(t, telegram)
+ startTestDaemonWithBot(t, server, bot)
+
+ telegram.Add(botUpdate{
+ UpdateID: 1,
+ Message: ownerMessage("hello"),
+ })
+
+ waitForSent(t, telegram, "ok")
+ require.Contains(t, logs.String(), "answered 502")
+ require.Contains(t, logs.String(), "answered 429")
+ require.NotContains(t, logs.String(), testBotToken)
+}
+
+func TestBotLogsNoTokenWhenTheNetworkFails(t *testing.T) {
+ telegram := newFakeBot(t)
+ telegram.Close()
+
+ bot, logs := startTestBot(t, telegram)
+ startTestDaemonWithBot(t, newFakeResponses(t), bot)
+
+ 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)
+}
+
+func TestSplitMessage(t *testing.T) {
+ testCases := []struct {
+ name string
+ text string
+ limit int
+ parts []string
+ }{
+ {
+ name: "short text stays whole",
+ text: "one two",
+ limit: 10,
+ parts: []string{"one two"},
+ },
+ {
+ name: "cut after the last line break",
+ text: "first line\nsecond line",
+ limit: 16,
+ parts: []string{"first line\n", "second line"},
+ },
+ {
+ name: "cut after the last space without a line break",
+ text: "aaaa bbbb cccc",
+ limit: 12,
+ parts: []string{"aaaa bbbb ", "cccc"},
+ },
+ {
+ name: "hard cut of one long word",
+ text: "abcdefghij",
+ limit: 4,
+ parts: []string{"abcd", "efgh", "ij"},
+ },
+ {
+ name: "a character outside the BMP counts two units and is not cut",
+ text: "ab\U0001F600cd",
+ limit: 3,
+ parts: []string{"ab", "\U0001F600c", "d"},
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ require.Equal(t, tc.parts, splitMessage(tc.text, tc.limit))
+ })
+ }
+
+ long := strings.Repeat("word ", 2000)
+ parts := splitMessage(long, messageUnits)
+ require.Len(t, parts, 3)
+ require.Equal(t, long, strings.Join(parts, ""))
+}
+
+func TestLoadBotConfig(t *testing.T) {
+ testCases := []struct {
+ name string
+ content string
+ mode os.FileMode
+ errorText string
+ }{
+ {
+ name: "owner file is read",
+ content: `{"token": "` + testBotToken + `", "owner": 4242}`,
+ mode: 0o600,
+ },
+ {
+ name: "file that others can read is refused",
+ content: `{"token": "` + testBotToken + `", "owner": 4242}`,
+ mode: 0o640,
+ errorText: "mode must be 0600",
+ },
+ {
+ name: "token with a path character is refused",
+ content: `{"token": "123:abc/../x", "owner": 4242}`,
+ mode: 0o600,
+ errorText: "form of a bot token",
+ },
+ {
+ name: "missing owner is refused",
+ content: `{"token": "` + testBotToken + `"}`,
+ mode: 0o600,
+ errorText: "numeric Telegram user ID",
+ },
+ {
+ name: "unknown field is refused",
+ content: `{"token": "` + testBotToken + `", "owner": 4242, "chat": 1}`,
+ mode: 0o600,
+ errorText: "not valid JSON",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, botConfigFileName)
+ require.NoError(t, os.WriteFile(path, []byte(tc.content), tc.mode))
+ require.NoError(t, os.Chmod(path, tc.mode))
+
+ config, found, err := loadBotConfig(dir)
+ if tc.errorText != "" {
+ require.ErrorContains(t, err, tc.errorText)
+ require.NotContains(t, err.Error(), testBotToken)
+
+ return
+ }
+
+ require.NoError(t, err)
+ require.True(t, found)
+ require.Equal(t, botConfig{
+ Token: testBotToken,
+ Owner: testOwner,
+ }, config)
+ })
+ }
+
+ _, found, err := loadBotConfig(t.TempDir())
+ 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) {
+ t.Helper()
+
+ logs := &syncBuffer{}
+ bot := newTelegramBot(newBotAPI(server.URL, testBotToken), testOwner, filepath.Join(t.TempDir(), offsetFileName), log.New(logs, "", 0))
+ 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 {
+ t.Helper()
+
+ require.Eventually(t, func() bool {
+ sent := server.Sent()
+ return len(sent) >= len(texts) && slices.Equal(sent[len(sent)-len(texts):], texts)
+ }, 10*time.Second, time.Millisecond, "sent so far: %q", server.Sent())
+
+ return server.Sent()
+}