]> git.giacomolli.uk - onechat/commitdiff
Name and order the Telegram bot code
authorAnderson A. Giacomolli <redacted>
Sat, 7 Nov 2026 16:20:24 +0000 (13:20 -0300)
committerAnderson A. Giacomolli <redacted>
Sat, 7 Nov 2026 16:30:22 +0000 (13:30 -0300)
Every package-level name of the Telegram bot starts with telegram, and
its files start with the same name: the Bot API client is in
telegram_api.go and the Markdown conversion in telegram_markdown.go. So
the names of the bot do not read as names of the model client or of the
login, which also have a client, an API, and a poll. Type names start
with a capital letter, so that a variable can have the name of its type.

The declarations of the Telegram files follow the order of the go-code
skill: entry points, then each type with its methods, then the utility
functions, each group in the order of first use. The behavior does not
change.

Issue: 947bddb

daemon.go
daemon_test.go
telegram.go
telegram_api.go [moved from botapi.go with 51% similarity]
telegram_api_test.go [moved from botapi_test.go with 68% similarity]
telegram_markdown.go [moved from markdown.go with 52% similarity]
telegram_markdown_test.go [moved from markdown_test.go with 89% similarity]
telegram_test.go

index b37e0cf7c29a45fa7d2f3d37a34060b58d4af79b..5486843771dbb1a3f92ba2be473a5120d6aae530 100644 (file)
--- a/daemon.go
+++ b/daemon.go
@@ -74,7 +74,7 @@ func daemon(ctx context.Context, out io.Writer) (err error) {
                return err
        }
 
-       botSettings, hasBot, err := loadBotConfig(dir)
+       botSettings, hasBot, err := loadTelegramConfig(dir)
        if err != nil {
                return err
        }
@@ -118,9 +118,9 @@ func daemon(ctx context.Context, out io.Writer) (err error) {
                return errors.Join(err, listener.Close())
        }
 
-       var telegram *telegramBot
+       var telegram *TelegramBot
        if hasBot {
-               telegram = newTelegramBot(newBotAPI(telegramAPI, botSettings.Token), botSettings.Owner, filepath.Join(agent.dir, offsetFileName), log.New(out, "telegram: ", log.LstdFlags))
+               telegram = newTelegramBot(newTelegramAPI(telegramAPIURL, botSettings.Token), botSettings.Owner, filepath.Join(agent.dir, telegramOffsetFileName), log.New(out, "telegram: ", log.LstdFlags))
                _, err = fmt.Fprintln(out, "onechat: the Telegram bot waits for messages of its owner")
                if err != nil {
                        return errors.Join(err, listener.Close())
@@ -135,7 +135,7 @@ func daemon(ctx context.Context, out io.Writer) (err error) {
 // serveAgent runs the loop that start makes and serves the clients of
 // listener until ctx ends or the loop fails. It closes listener. When bot is
 // not nil, it runs the bot too, and a failure of the bot stops the agent.
-func serveAgent(ctx context.Context, listener net.Listener, bot *telegramBot, start func(notify func(Event)) (*loop, error)) error {
+func serveAgent(ctx context.Context, listener net.Listener, bot *TelegramBot, start func(notify func(Event)) (*loop, error)) error {
        ctx, cancel := context.WithCancel(ctx)
        defer cancel()
 
index 47fb9a5efa072c2bb8313486f5de68dd29f458fc..4bcc05a50c2343690a1263f37b89ce1153c22625 100644 (file)
@@ -246,7 +246,7 @@ func startTestDaemon(t *testing.T, server *fakeResponses) string {
 }
 
 // startTestDaemonWithBot is startTestDaemon with bot, which can be nil.
-func startTestDaemonWithBot(t *testing.T, server *fakeResponses, bot *telegramBot) string {
+func startTestDaemonWithBot(t *testing.T, server *fakeResponses, bot *TelegramBot) string {
        t.Helper()
 
        dir := t.TempDir()
index dd62ef0221db72fc88a1893b1421ce301cab0cff..4c16211c85dd4311d499593598b39a7649186c05 100644 (file)
@@ -18,107 +18,61 @@ import (
 )
 
 const (
-       // telegramAPI is the Telegram Bot API service.
-       telegramAPI = "https://api.telegram.org"
+       // telegramAPIURL is the Telegram Bot API service.
+       telegramAPIURL = "https://api.telegram.org"
 
-       // botConfigFileName is the bot config in the data directory. The daemon
-       // runs the bot only when the file exists.
-       botConfigFileName = "telegram.json"
+       // telegramConfigFileName is the bot config in the data directory. The
+       // daemon runs the bot only when the file exists.
+       telegramConfigFileName = "telegram.json"
 
-       // offsetFileName is the update offset of the bot in the agent directory.
-       offsetFileName = "telegram-offset.json"
+       // telegramOffsetFileName is the update offset of the bot in the agent
+       // directory.
+       telegramOffsetFileName = "telegram-offset.json"
 
        // telegramSource is the source of the user messages from Telegram.
        telegramSource = "telegram"
 
-       // messageUnits is the most UTF-16 code units of one Telegram message.
-       messageUnits = 4096
+       // telegramMessageUnits is the most UTF-16 code units of one Telegram
+       // message.
+       telegramMessageUnits = 4096
 
-       // richMessageBytes is the most bytes of the Markdown of one rich
+       // telegramRichBytes 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
+       telegramRichBytes = 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
+       // telegramTypingInterval is how often the bot shows the typing action
+       // while a turn runs. Telegram shows the action for about five seconds.
+       telegramTypingInterval = 4 * time.Second
 
-       // textOnly is the answer to a message that is not text.
-       textOnly = "Only text messages are supported."
+       // telegramTextOnly is the answer to a message that is not text.
+       telegramTextOnly = "Only text messages are supported."
 )
 
-// botConfig holds the settings of telegram.json: the bot token and the user ID
-// of the owner. The token is a secret, so the file must have mode 0600.
-type botConfig struct {
-       Token string `json:"token"`
-       Owner int64  `json:"owner"`
-}
-
-// 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, 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 rich messages, which Telegram renders
-// itself, and as message entities only when Telegram refuses a rich message.
-// 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
-// does not lose a message. A crash between the flush and the offset write
-// makes Telegram send the update again, and the message repeats once.
-//
-// A failed Bot API call waits and tries again, from minWait up to maxWait, or
-// as long as the Bot API asks. It never stops the agent.
-type telegramBot struct {
-       api        *botAPI
-       owner      int64
-       offsetPath string
-       logger     *log.Logger
-       minWait    time.Duration
-       maxWait    time.Duration
-
-       mutex sync.Mutex
-       queue []Event
-       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"`
-}
-
-// loadBotConfig reads telegram.json in dir. It returns false when the file
+// loadTelegramConfig reads telegram.json in dir. It returns false when the file
 // does not exist. An error never holds the token.
-func loadBotConfig(dir string) (botConfig, bool, error) {
-       path := filepath.Join(dir, botConfigFileName)
+func loadTelegramConfig(dir string) (TelegramConfig, bool, error) {
+       path := filepath.Join(dir, telegramConfigFileName)
        info, err := os.Stat(path)
        if errors.Is(err, os.ErrNotExist) {
-               return botConfig{}, false, nil
+               return TelegramConfig{}, false, nil
        }
 
        if err != nil {
-               return botConfig{}, false, fmt.Errorf("read bot config: %w", err)
+               return TelegramConfig{}, false, fmt.Errorf("read bot config: %w", err)
        }
 
        if info.Mode().Perm()&0o077 != 0 {
-               return botConfig{}, false, fmt.Errorf("%s holds the bot token, so its mode must be 0600", botConfigFileName)
+               return TelegramConfig{}, false, fmt.Errorf("%s holds the bot token, so its mode must be 0600", telegramConfigFileName)
        }
 
        data, err := os.ReadFile(path)
        if err != nil {
-               return botConfig{}, false, fmt.Errorf("read bot config: %w", err)
+               return TelegramConfig{}, false, fmt.Errorf("read bot config: %w", err)
        }
 
-       var config botConfig
+       var config TelegramConfig
        decoder := json.NewDecoder(bytes.NewReader(data))
        decoder.DisallowUnknownFields()
        err = decoder.Decode(&config)
@@ -127,21 +81,43 @@ func loadBotConfig(dir string) (botConfig, bool, error) {
        }
 
        if err != nil {
-               return botConfig{}, false, fmt.Errorf("%s is not valid JSON with the fields token and owner", botConfigFileName)
+               return TelegramConfig{}, false, fmt.Errorf("%s is not valid JSON with the fields token and owner", telegramConfigFileName)
        }
 
        err = config.validate()
        if err != nil {
-               return botConfig{}, false, fmt.Errorf("%s: %w", botConfigFileName, err)
+               return TelegramConfig{}, false, fmt.Errorf("%s: %w", telegramConfigFileName, err)
        }
 
        return config, true, nil
 }
 
+// newTelegramBot returns the bot of owner on api. The offset file is at
+// offsetPath, and logger gets the failures. No log line holds the text of a
+// message.
+func newTelegramBot(api *TelegramAPI, owner int64, offsetPath string, logger *log.Logger) *TelegramBot {
+       return &TelegramBot{
+               api:        api,
+               owner:      owner,
+               offsetPath: offsetPath,
+               logger:     logger,
+               minWait:    time.Second,
+               maxWait:    time.Minute,
+               ready:      make(chan struct{}, 1),
+       }
+}
+
+// TelegramConfig holds the settings of telegram.json: the bot token and the
+// user ID of the owner. The token is a secret, so the file must have mode 0600.
+type TelegramConfig struct {
+       Token string `json:"token"`
+       Owner int64  `json:"owner"`
+}
+
 // validate makes sure that the token has the form of a bot token and that the
 // owner is a user ID. The token goes into a URL path, so it can only hold the
 // characters of a bot token.
-func (c botConfig) validate() error {
+func (c TelegramConfig) validate() error {
        id, secret, found := strings.Cut(c.Token, ":")
        if !found || id == "" || secret == "" || strings.Trim(id, "0123456789") != "" || strings.Trim(secret, "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-") != "" {
                return errors.New("token does not have the form of a bot token from BotFather")
@@ -154,24 +130,38 @@ func (c botConfig) validate() error {
        return nil
 }
 
-// newTelegramBot returns the bot of owner on api. The offset file is at
-// offsetPath, and logger gets the failures. No log line holds the text of a
-// message.
-func newTelegramBot(api *botAPI, owner int64, offsetPath string, logger *log.Logger) *telegramBot {
-       return &telegramBot{
-               api:        api,
-               owner:      owner,
-               offsetPath: offsetPath,
-               logger:     logger,
-               minWait:    time.Second,
-               maxWait:    time.Minute,
-               ready:      make(chan struct{}, 1),
-       }
+// 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, 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 rich messages, which Telegram renders
+// itself, and as message entities only when Telegram refuses a rich message.
+// 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
+// does not lose a message. A crash between the flush and the offset write
+// makes Telegram send the update again, and the message repeats once.
+//
+// A failed Bot API call waits and tries again, from minWait up to maxWait, or
+// as long as the Bot API asks. It never stops the agent.
+type TelegramBot struct {
+       api        *TelegramAPI
+       owner      int64
+       offsetPath string
+       logger     *log.Logger
+       minWait    time.Duration
+       maxWait    time.Duration
+
+       mutex sync.Mutex
+       queue []Event
+       ready chan struct{}
 }
 
 // Notify queues event for the chat. It never blocks, so a slow network does not
 // stop the agent.
-func (b *telegramBot) Notify(event Event) {
+func (b *TelegramBot) Notify(event Event) {
        switch event.Kind {
        case "busy", "idle", "reply", "error", "notice":
        case "user":
@@ -195,7 +185,7 @@ func (b *telegramBot) Notify(event Event) {
 // Run polls for the messages of the owner and sends the queued events to the
 // chat until ctx ends. It returns an error only when a message or the offset
 // cannot be written, because then the bot would lose or repeat messages.
-func (b *telegramBot) Run(ctx context.Context, l *loop) error {
+func (b *TelegramBot) Run(ctx context.Context, l *loop) error {
        ctx, cancel := context.WithCancel(ctx)
        defer cancel()
 
@@ -215,10 +205,10 @@ func (b *telegramBot) Run(ctx context.Context, l *loop) error {
 }
 
 // send sends each queued event to the chat in order until ctx ends. While a
-// turn runs, it shows the typing action every typingInterval and after each
-// message, because a message ends the action.
-func (b *telegramBot) send(ctx context.Context) {
-       ticker := time.NewTicker(typingInterval)
+// turn runs, it shows the typing action every telegramTypingInterval and after
+// each message, because a message ends the action.
+func (b *TelegramBot) send(ctx context.Context) {
+       ticker := time.NewTicker(telegramTypingInterval)
        defer ticker.Stop()
 
        busy := false
@@ -241,7 +231,7 @@ func (b *telegramBot) send(ctx context.Context) {
                case "busy":
                        busy = true
                        b.typing(ctx)
-                       ticker.Reset(typingInterval)
+                       ticker.Reset(telegramTypingInterval)
                case "idle":
                        busy = false
                default:
@@ -254,8 +244,53 @@ func (b *telegramBot) send(ctx context.Context) {
        }
 }
 
+// poll gets the updates from the stored offset on and handles each one, until
+// ctx ends. Each update is handled to its end also when ctx ends during it, so
+// a stop does not repeat its message.
+func (b *TelegramBot) poll(ctx context.Context, l *loop) error {
+       offset, err := telegramLoadOffset(b.offsetPath)
+       if err != nil {
+               return err
+       }
+
+       wait := b.minWait
+       for ctx.Err() == nil {
+               updates, err := b.api.GetUpdates(ctx, offset)
+               if ctx.Err() != nil {
+                       return nil
+               }
+
+               if err != nil {
+                       _, asked := telegramRetryable(err)
+                       wait = b.pause(ctx, err, wait, asked)
+
+                       continue
+               }
+
+               wait = b.minWait
+               for _, update := range updates {
+                       if ctx.Err() != nil {
+                               return nil
+                       }
+
+                       err = b.handle(context.WithoutCancel(ctx), l, update)
+                       if err != nil {
+                               return err
+                       }
+
+                       offset = update.UpdateID + 1
+                       err = telegramSaveOffset(b.offsetPath, offset)
+                       if err != nil {
+                               return err
+                       }
+               }
+       }
+
+       return nil
+}
+
 // next removes the first queued event and returns it.
-func (b *telegramBot) next() (Event, bool) {
+func (b *TelegramBot) next() (Event, bool) {
        b.mutex.Lock()
        defer b.mutex.Unlock()
 
@@ -271,17 +306,17 @@ func (b *telegramBot) next() (Event, bool) {
 
 // typing shows the typing action once. A failure is not logged, because the
 // next message or poll logs the state of the network.
-func (b *telegramBot) typing(ctx context.Context) {
+func (b *TelegramBot) typing(ctx context.Context) {
        _ = b.api.SendTyping(ctx, b.owner)
 }
 
 // post sends event to the chat. A reply goes as rich messages of its Markdown,
-// split with splitRich. Every other event goes as plain text, split with
-// splitMessage.
-func (b *telegramBot) post(ctx context.Context, event Event) {
+// split with telegramSplitRich. Every other event goes as plain text, split
+// with telegramSplitMessage.
+func (b *TelegramBot) post(ctx context.Context, event Event) {
        if event.Kind != "reply" {
-               for _, text := range splitMessage(botText(event), messageUnits) {
-                       b.deliver(ctx, botPart{
+               for _, text := range telegramSplitMessage(telegramText(event), telegramMessageUnits) {
+                       b.deliver(ctx, TelegramPart{
                                text: text,
                        })
                }
@@ -289,76 +324,14 @@ func (b *telegramBot) post(ctx context.Context, event Event) {
                return
        }
 
-       for _, markdown := range splitRich(event.Text, richMessageBytes) {
+       for _, markdown := range telegramSplitRich(event.Text, telegramRichBytes) {
                b.deliverRich(ctx, markdown)
        }
 }
 
-// deliverRich sends markdown to the chat as a rich message. When the Bot API
-// refuses it with HTTP 400 or 404, the message was not sent, so deliverRich
-// sends the same Markdown once with message entities, and the reply is not
-// lost and does not repeat. It tries again after each failure that a later try
-// can fix, until ctx ends. It drops the message after any other failure.
-func (b *telegramBot) deliverRich(ctx context.Context, markdown string) {
-       wait := b.minWait
-       for {
-               err := b.api.SendRichMessage(ctx, b.owner, markdown)
-               if err == nil || ctx.Err() != nil {
-                       return
-               }
-
-               if rejected(err) {
-                       b.logger.Printf("rich message refused, the reply goes with entities: %v", err)
-                       for _, part := range replyParts(markdown) {
-                               b.deliver(ctx, part)
-                       }
-
-                       return
-               }
-
-               retry, asked := retryable(err)
-               if !retry {
-                       b.logger.Printf("message dropped: %v", err)
-                       return
-               }
-
-               wait = b.pause(ctx, err, wait, asked)
-       }
-}
-
-// 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, 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)
-                       return
-               }
-
-               wait = b.pause(ctx, err, wait, asked)
-       }
-}
-
 // pause logs err and waits the longer of wait and asked, or until ctx ends. It
 // returns the wait of the next failure: twice wait, up to maxWait.
-func (b *telegramBot) pause(ctx context.Context, err error, wait, asked time.Duration) time.Duration {
+func (b *TelegramBot) pause(ctx context.Context, err error, wait, asked time.Duration) time.Duration {
        delay := max(wait, asked)
        b.logger.Printf("%v (next try in %s)", err, delay)
 
@@ -373,57 +346,12 @@ func (b *telegramBot) pause(ctx context.Context, err error, wait, asked time.Dur
        return min(2*wait, b.maxWait)
 }
 
-// poll gets the updates from the stored offset on and handles each one, until
-// ctx ends. Each update is handled to its end also when ctx ends during it, so
-// a stop does not repeat its message.
-func (b *telegramBot) poll(ctx context.Context, l *loop) error {
-       offset, err := loadOffset(b.offsetPath)
-       if err != nil {
-               return err
-       }
-
-       wait := b.minWait
-       for ctx.Err() == nil {
-               updates, err := b.api.GetUpdates(ctx, offset)
-               if ctx.Err() != nil {
-                       return nil
-               }
-
-               if err != nil {
-                       _, asked := retryable(err)
-                       wait = b.pause(ctx, err, wait, asked)
-
-                       continue
-               }
-
-               wait = b.minWait
-               for _, update := range updates {
-                       if ctx.Err() != nil {
-                               return nil
-                       }
-
-                       err = b.handle(context.WithoutCancel(ctx), l, update)
-                       if err != nil {
-                               return err
-                       }
-
-                       offset = update.UpdateID + 1
-                       err = saveOffset(b.offsetPath, offset)
-                       if err != nil {
-                               return err
-                       }
-               }
-       }
-
-       return nil
-}
-
 // handle runs one update. A text message of the owner in the private chat goes
 // to the agent, and handle returns when it is durable in the log. The line
 // /stop interrupts the turn. Any other message of the owner gets the answer
 // that only text is supported. Every other update is ignored, and its content
 // is not logged.
-func (b *telegramBot) handle(ctx context.Context, l *loop, update botUpdate) error {
+func (b *TelegramBot) handle(ctx context.Context, l *loop, update TelegramUpdate) error {
        message := update.Message
        if message == nil || message.Chat.Type != "private" || message.Chat.ID != b.owner || message.From == nil || message.From.ID != b.owner {
                b.logger.Print("update ignored: not a message of the owner in the private chat")
@@ -432,7 +360,7 @@ func (b *telegramBot) handle(ctx context.Context, l *loop, update botUpdate) err
 
        switch {
        case message.Text == "":
-               b.notice(textOnly)
+               b.notice(telegramTextOnly)
        case strings.TrimSpace(message.Text) == stopCommand:
                if !l.Interrupt() {
                        b.notice("No turn runs.")
@@ -452,17 +380,91 @@ func (b *telegramBot) handle(ctx context.Context, l *loop, update botUpdate) err
        return nil
 }
 
+// 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 TelegramPart) {
+       wait := b.minWait
+       for {
+               err := b.api.SendMessage(ctx, b.owner, part.text, part.entities)
+               if err == nil || ctx.Err() != nil {
+                       return
+               }
+
+               var answer *TelegramError
+               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 := telegramRetryable(err)
+               if !retry {
+                       b.logger.Printf("message dropped: %v", err)
+                       return
+               }
+
+               wait = b.pause(ctx, err, wait, asked)
+       }
+}
+
+// deliverRich sends markdown to the chat as a rich message. When the Bot API
+// refuses it with HTTP 400 or 404, the message was not sent, so deliverRich
+// sends the same Markdown once with message entities, and the reply is not
+// lost and does not repeat. It tries again after each failure that a later try
+// can fix, until ctx ends. It drops the message after any other failure.
+func (b *TelegramBot) deliverRich(ctx context.Context, markdown string) {
+       wait := b.minWait
+       for {
+               err := b.api.SendRichMessage(ctx, b.owner, markdown)
+               if err == nil || ctx.Err() != nil {
+                       return
+               }
+
+               if telegramRejected(err) {
+                       b.logger.Printf("rich message refused, the reply goes with entities: %v", err)
+                       for _, part := range telegramReplyParts(markdown) {
+                               b.deliver(ctx, part)
+                       }
+
+                       return
+               }
+
+               retry, asked := telegramRetryable(err)
+               if !retry {
+                       b.logger.Printf("message dropped: %v", err)
+                       return
+               }
+
+               wait = b.pause(ctx, err, wait, asked)
+       }
+}
+
 // notice queues a short answer of the bot for the chat.
-func (b *telegramBot) notice(text string) {
+func (b *TelegramBot) notice(text string) {
        b.Notify(Event{
                Kind: "notice",
                Text: text,
        })
 }
 
-// loadOffset reads the update offset at path. A missing file gives 0, which
-// asks for every update that Telegram keeps.
-func loadOffset(path string) (int64, error) {
+// TelegramPart is the text of one message to the chat, at most
+// telegramMessageUnits long, and its entities.
+type TelegramPart struct {
+       text     string
+       entities []TelegramEntity
+}
+
+// TelegramOffsetFile is the content of the offset file.
+type TelegramOffsetFile struct {
+       Offset int64 `json:"offset"`
+}
+
+// telegramLoadOffset reads the update offset at path. A missing file gives 0,
+// which asks for every update that Telegram keeps.
+func telegramLoadOffset(path string) (int64, error) {
        data, err := os.ReadFile(path)
        if errors.Is(err, os.ErrNotExist) {
                return 0, nil
@@ -472,7 +474,7 @@ func loadOffset(path string) (int64, error) {
                return 0, fmt.Errorf("read update offset: %w", err)
        }
 
-       var file offsetFile
+       var file TelegramOffsetFile
        err = json.Unmarshal(data, &file)
        if err != nil {
                return 0, fmt.Errorf("%s: %w", path, err)
@@ -481,9 +483,9 @@ func loadOffset(path string) (int64, error) {
        return file.Offset, nil
 }
 
-// saveOffset replaces the update offset at path.
-func saveOffset(path string, offset int64) error {
-       data, err := json.Marshal(offsetFile{
+// telegramSaveOffset replaces the update offset at path.
+func telegramSaveOffset(path string, offset int64) error {
+       data, err := json.Marshal(TelegramOffsetFile{
                Offset: offset,
        })
        if err != nil {
@@ -498,27 +500,14 @@ func saveOffset(path string, offset int64) error {
        return nil
 }
 
-// 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 {
-       switch event.Kind {
-       case "user":
-               return "[" + event.Source + "] " + event.Text
-       case "error":
-               return "Error: " + event.Text
-       default:
-               return event.Text
-       }
-}
-
-// 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
+// telegramSplitMessage 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
 // never cuts a character.
-func splitMessage(text string, limit int) []string {
+func telegramSplitMessage(text string, limit int) []string {
        var parts []string
        for {
-               end := prefixEnd(text, limit)
+               end := telegramPrefixEnd(text, limit)
                if end == len(text) {
                        return append(parts, text)
                }
@@ -537,37 +526,30 @@ func splitMessage(text string, limit int) []string {
        }
 }
 
-// prefixEnd returns the byte length of the longest prefix of text with at most
-// limit UTF-16 code units.
-func prefixEnd(text string, limit int) int {
-       units := 0
-       for index, char := range text {
-               size := 1
-               if char > 0xFFFF {
-                       size = 2
-               }
-
-               if units+size > limit {
-                       return index
-               }
-
-               units += size
+// telegramText returns the text of event for the chat. A user message of
+// another client has its source as a mark.
+func telegramText(event Event) string {
+       switch event.Kind {
+       case "user":
+               return "[" + event.Source + "] " + event.Text
+       case "error":
+               return "Error: " + event.Text
+       default:
+               return event.Text
        }
-
-       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 {
+// telegramSplitRich 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 telegramSplitRich(markdown string, limit int) []string {
        var parts []string
        for len(markdown) > limit {
-               cut, fence, opening := richCut(markdown, limit)
+               cut, fence, opening := telegramRichCut(markdown, limit)
                if fence == "" {
                        parts = append(parts, markdown[:cut])
                        markdown = markdown[cut:]
@@ -582,12 +564,44 @@ func splitRich(markdown string, limit int) []string {
        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) {
+// telegramReplyParts returns the messages of the Markdown of a reply with
+// message entities. A reply that has no text left after the conversion, such as
+// an empty code block, goes as its Markdown.
+func telegramReplyParts(markdown string) []TelegramPart {
+       text, entities := telegramMarkdownEntities(markdown)
+       if strings.TrimSpace(text) == "" {
+               text, entities = markdown, nil
+       }
+
+       return telegramSplitEntities(text, entities, telegramMessageUnits)
+}
+
+// telegramPrefixEnd returns the byte length of the longest prefix of text with
+// at most limit UTF-16 code units.
+func telegramPrefixEnd(text string, limit int) int {
+       units := 0
+       for index, char := range text {
+               size := 1
+               if char > 0xFFFF {
+                       size = 2
+               }
+
+               if units+size > limit {
+                       return index
+               }
+
+               units += size
+       }
+
+       return len(text)
+}
+
+// telegramRichCut returns where telegramSplitRich 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 telegramRichCut(markdown string, limit int) (int, string, string) {
        blank, line, code := 0, 0, 0
        var fence, opening, inFence, inOpening string
        for start := 0; start < limit; {
@@ -598,10 +612,10 @@ func richCut(markdown string, limit int) (int, string, string) {
 
                end += start + 1
                text := strings.TrimRight(markdown[start:end], "\r\n")
-               _, opens := codeFence(text)
+               _, opens := telegramCodeFence(text)
                switch {
                case fence != "":
-                       if closesFence(text, fence) {
+                       if telegramClosesFence(text, fence) {
                                fence = ""
                        } else if end > len(opening)+1 && end+len(fence)+1 <= limit {
                                code, inFence, inOpening = end, fence, opening
@@ -637,27 +651,15 @@ func richCut(markdown string, limit int) (int, string, string) {
        return cut, "", ""
 }
 
-// replyParts returns the messages of the Markdown of a reply with message
-// entities. A reply that has no text left after the conversion, such as an
-// empty code block, goes as its Markdown.
-func replyParts(markdown string) []botPart {
-       text, entities := markdownEntities(markdown)
-       if strings.TrimSpace(text) == "" {
-               text, entities = markdown, nil
-       }
-
-       return splitEntities(text, entities, messageUnits)
-}
-
-// 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
+// telegramSplitEntities cuts text into parts with telegramSplitMessage, 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 telegramSplitEntities(text string, entities []TelegramEntity, limit int) []TelegramPart {
+       var parts []TelegramPart
        start := 0
-       for _, part := range splitMessage(text, limit) {
-               end := start + utf16Units(part)
-               var kept []botEntity
+       for _, part := range telegramSplitMessage(text, limit) {
+               end := start + telegramUTF16Units(part)
+               var kept []TelegramEntity
                for _, entity := range entities {
                        from := max(entity.Offset, start)
                        to := min(entity.Offset+entity.Length, end)
@@ -670,7 +672,7 @@ func splitEntities(text string, entities []botEntity, limit int) []botPart {
                        kept = append(kept, entity)
                }
 
-               parts = append(parts, botPart{
+               parts = append(parts, TelegramPart{
                        text:     part,
                        entities: kept,
                })
similarity index 51%
rename from botapi.go
rename to telegram_api.go
index 2da945e9dc1b283d9b3c4dda8b7c54becb6f0419..558d006cd1a1b04905afbe0fd94b6d89e71a1e5e 100644 (file)
--- a/botapi.go
@@ -14,120 +14,87 @@ import (
 )
 
 const (
-       // pollSeconds is how long one getUpdates call waits for an update.
-       pollSeconds = 50
+       // telegramPollSeconds is how long one getUpdates call waits for an
+       // update.
+       telegramPollSeconds = 50
 
-       // pollSlack is how much longer than pollSeconds a getUpdates call can
-       // take before the bot gives it up.
-       pollSlack = 20 * time.Second
+       // telegramPollSlack is how much longer than telegramPollSeconds a
+       // getUpdates call can take before the bot gives it up.
+       telegramPollSlack = 20 * time.Second
 
-       // sendTimeout is how long a sendMessage, sendRichMessage, or
+       // telegramSendTimeout is how long a sendMessage, sendRichMessage, or
        // sendChatAction call can take. A connection that stops without a
        // close, such as after a network change, then fails the call, and the
        // sender tries again, instead of a wait until TCP gives up. The client
        // has no timeout of its own, because the long poll of getUpdates needs
        // a longer limit.
-       sendTimeout = 30 * time.Second
+       telegramSendTimeout = 30 * time.Second
 
-       // botAnswerBytes is the most bytes of one answer of the Bot API.
-       botAnswerBytes = 16 << 20
+       // telegramAnswerBytes is the most bytes of one answer of the Bot API.
+       telegramAnswerBytes = 16 << 20
 )
 
-// errBotNetwork tells that a Bot API call got no valid answer, such as after a
-// network error. A later call can work.
-var errBotNetwork = errors.New("no valid answer from the Bot API")
+// errTelegramNetwork tells that a Bot API call got no valid answer, such as
+// after a network error. A later call can work.
+var errTelegramNetwork = errors.New("no valid answer from the Bot API")
 
-// botAPI calls the Telegram Bot API. The bot token is a part of each URL, so no
-// error of botAPI holds a URL, a net/http error, or the token. SendTimeout
-// limits each sendMessage, sendRichMessage, and sendChatAction call.
-type botAPI struct {
-       base        string
-       token       string
-       http        *http.Client
-       sendTimeout time.Duration
-}
-
-// botError is an answer of the Bot API that tells of a failure. RetryAfter is
-// the wait that the Bot API asks for after HTTP 429. Refused tells that the
-// answer is a Bot API answer with ok false, not a page of a proxy or of a
-// server error.
-type botError struct {
-       Method      string
-       Status      int
-       Description string
-       RetryAfter  time.Duration
-       Refused     bool
-}
-
-// botAnswer is the envelope of each answer of the Bot API.
-type botAnswer struct {
-       OK          bool            `json:"ok"`
-       Result      json.RawMessage `json:"result"`
-       ErrorCode   int             `json:"error_code"`
-       Description string          `json:"description"`
-       Parameters  struct {
-               RetryAfter int `json:"retry_after"`
-       } `json:"parameters"`
-}
-
-// botUpdate is one update of getUpdates. Message is nil for the kinds of
-// updates that are not a new message.
-type botUpdate struct {
-       UpdateID int64       `json:"update_id"`
-       Message  *botMessage `json:"message"`
+// newTelegramAPI returns the client of the Bot API at base with token.
+func newTelegramAPI(base, token string) *TelegramAPI {
+       return &TelegramAPI{
+               base:        base,
+               token:       token,
+               http:        &http.Client{},
+               sendTimeout: telegramSendTimeout,
+       }
 }
 
-// botMessage is a message to the bot. Text is empty for a message that is not
-// text, such as a photo.
-type botMessage struct {
-       Chat botChat  `json:"chat"`
-       From *botUser `json:"from"`
-       Text string   `json:"text"`
-}
+// telegramRetryable tells if a later try of a failed Bot API call can work:
+// after a network error, HTTP 429, or a server error. It returns the wait that
+// the Bot API asks for, or 0.
+func telegramRetryable(err error) (bool, time.Duration) {
+       if errors.Is(err, errTelegramNetwork) {
+               return true, 0
+       }
 
-// botChat is the chat of a message. Type is private for a chat with one user.
-type botChat struct {
-       ID   int64  `json:"id"`
-       Type string `json:"type"`
-}
+       var answer *TelegramError
+       if errors.As(err, &answer) {
+               return answer.Status == http.StatusTooManyRequests || answer.Status >= 500, answer.RetryAfter
+       }
 
-// botEntity is a message entity of sendMessage: a part of the text with a
-// format, such as bold or a link. Offset and Length count UTF-16 code units.
-// URL is the target of a text_link, and Language the language of a pre.
-type botEntity struct {
-       Type     string `json:"type"`
-       Offset   int    `json:"offset"`
-       Length   int    `json:"length"`
-       URL      string `json:"url,omitempty"`
-       Language string `json:"language,omitempty"`
+       return false, 0
 }
 
-// botUser is the sender of a message.
-type botUser struct {
-       ID int64 `json:"id"`
+// telegramRejected tells if the Bot API refused a call with ok false and HTTP
+// 400 or 404. Then the call did nothing: 400 refuses the request, and 404 tells
+// that the method is not there. An error page with no Bot API answer does not
+// tell this, so it is not a refusal.
+func telegramRejected(err error) bool {
+       var answer *TelegramError
+       return errors.As(err, &answer) && answer.Refused && (answer.Status == http.StatusBadRequest || answer.Status == http.StatusNotFound)
 }
 
-// newBotAPI returns the client of the Bot API at base with token.
-func newBotAPI(base, token string) *botAPI {
-       return &botAPI{
-               base:        base,
-               token:       token,
-               http:        &http.Client{},
-               sendTimeout: sendTimeout,
-       }
+// TelegramAPI calls the Telegram Bot API. The bot token is a part of each URL,
+// so no error of TelegramAPI holds a URL, a net/http error, or the token.
+// SendTimeout limits each sendMessage, sendRichMessage, and sendChatAction
+// call.
+type TelegramAPI struct {
+       base        string
+       token       string
+       http        *http.Client
+       sendTimeout time.Duration
 }
 
-// GetUpdates waits for the updates from offset on, up to pollSeconds, and
-// returns them. Telegram forgets each update before offset. Only new messages
-// are asked for.
-func (b *botAPI) GetUpdates(ctx context.Context, offset int64) ([]botUpdate, error) {
-       ctx, cancel := context.WithTimeout(ctx, pollSeconds*time.Second+pollSlack)
+// GetUpdates waits for the updates from offset on, up to telegramPollSeconds,
+// and returns them. Telegram forgets each update before offset. Only new
+// messages are asked for.
+func (b *TelegramAPI) GetUpdates(ctx context.Context, offset int64) ([]TelegramUpdate, error) {
+       ctx, cancel := context.WithTimeout(ctx, telegramPollSeconds*time.Second+telegramPollSlack)
        defer cancel()
 
-       var updates []botUpdate
+       var updates []TelegramUpdate
        err := b.call(ctx, "getUpdates", map[string]any{
                "offset":          offset,
-               "timeout":         pollSeconds,
+               "timeout":         telegramPollSeconds,
                "allowed_updates": []string{"message"},
        }, &updates)
 
@@ -136,8 +103,9 @@ func (b *botAPI) GetUpdates(ctx context.Context, offset int64) ([]botUpdate, err
 
 // 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 {
+// call that takes longer than telegramSendTimeout fails with
+// errTelegramNetwork.
+func (b *TelegramAPI) SendMessage(ctx context.Context, chat int64, text string, entities []TelegramEntity) error {
        ctx, cancel := context.WithTimeout(ctx, b.sendTimeout)
        defer cancel()
 
@@ -154,9 +122,9 @@ func (b *botAPI) SendMessage(ctx context.Context, chat int64, text string, entit
 }
 
 // SendRichMessage sends markdown to chat as a rich message, which Telegram
-// renders itself, tables included. A call that takes longer than sendTimeout
-// fails with errBotNetwork.
-func (b *botAPI) SendRichMessage(ctx context.Context, chat int64, markdown string) error {
+// renders itself, tables included. A call that takes longer than
+// telegramSendTimeout fails with errTelegramNetwork.
+func (b *TelegramAPI) SendRichMessage(ctx context.Context, chat int64, markdown string) error {
        ctx, cancel := context.WithTimeout(ctx, b.sendTimeout)
        defer cancel()
 
@@ -169,8 +137,8 @@ func (b *botAPI) SendRichMessage(ctx context.Context, chat int64, markdown strin
 }
 
 // SendTyping shows the typing action in chat for about five seconds. A call
-// that takes longer than sendTimeout fails with errBotNetwork.
-func (b *botAPI) SendTyping(ctx context.Context, chat int64) error {
+// that takes longer than telegramSendTimeout fails with errTelegramNetwork.
+func (b *TelegramAPI) SendTyping(ctx context.Context, chat int64) error {
        ctx, cancel := context.WithTimeout(ctx, b.sendTimeout)
        defer cancel()
 
@@ -182,7 +150,7 @@ func (b *botAPI) SendTyping(ctx context.Context, chat int64) error {
 
 // call sends one call of method with the JSON params and decodes its result
 // into result, when result is not nil.
-func (b *botAPI) call(ctx context.Context, method string, params, result any) error {
+func (b *TelegramAPI) call(ctx context.Context, method string, params, result any) error {
        body, err := json.Marshal(params)
        if err != nil {
                return fmt.Errorf("%s: encode request: %w", method, err)
@@ -196,19 +164,19 @@ func (b *botAPI) call(ctx context.Context, method string, params, result any) er
        request.Header.Set("Content-Type", "application/json")
        response, err := b.http.Do(request)
        if err != nil {
-               return fmt.Errorf("%s: %w: %s", method, errBotNetwork, b.redact(err))
+               return fmt.Errorf("%s: %w: %s", method, errTelegramNetwork, b.redact(err))
        }
 
-       data, err := io.ReadAll(io.LimitReader(response.Body, botAnswerBytes))
+       data, err := io.ReadAll(io.LimitReader(response.Body, telegramAnswerBytes))
        err = errors.Join(err, response.Body.Close())
        if err != nil {
-               return fmt.Errorf("%s: %w: read answer: %s", method, errBotNetwork, b.redact(err))
+               return fmt.Errorf("%s: %w: read answer: %s", method, errTelegramNetwork, b.redact(err))
        }
 
-       var answer botAnswer
+       var answer TelegramAnswer
        err = json.Unmarshal(data, &answer)
        if err != nil && response.StatusCode != http.StatusOK {
-               return &botError{
+               return &TelegramError{
                        Method:      method,
                        Status:      response.StatusCode,
                        Description: http.StatusText(response.StatusCode),
@@ -216,11 +184,11 @@ func (b *botAPI) call(ctx context.Context, method string, params, result any) er
        }
 
        if err != nil {
-               return fmt.Errorf("%s: %w: decode answer: %s", method, errBotNetwork, b.redact(err))
+               return fmt.Errorf("%s: %w: decode answer: %s", method, errTelegramNetwork, b.redact(err))
        }
 
        if !answer.OK {
-               return &botError{
+               return &TelegramError{
                        Method:      method,
                        Status:      response.StatusCode,
                        Description: b.redact(errors.New(answer.Description)),
@@ -235,7 +203,7 @@ func (b *botAPI) call(ctx context.Context, method string, params, result any) er
 
        err = json.Unmarshal(answer.Result, result)
        if err != nil {
-               return fmt.Errorf("%s: %w: decode result: %s", method, errBotNetwork, b.redact(err))
+               return fmt.Errorf("%s: %w: decode result: %s", method, errTelegramNetwork, b.redact(err))
        }
 
        return nil
@@ -244,7 +212,7 @@ func (b *botAPI) call(ctx context.Context, method string, params, result any) er
 // redact returns the text of err without a URL and without the token. A
 // *url.Error holds the URL of the call, which holds the token, so only its
 // cause is kept.
-func (b *botAPI) redact(err error) string {
+func (b *TelegramAPI) redact(err error) string {
        var urlError *url.Error
        if errors.As(err, &urlError) {
                err = urlError.Err
@@ -253,31 +221,67 @@ func (b *botAPI) redact(err error) string {
        return strings.ReplaceAll(err.Error(), b.token, "TOKEN")
 }
 
-func (e *botError) Error() string {
+// TelegramError is an answer of the Bot API that tells of a failure. RetryAfter
+// is the wait that the Bot API asks for after HTTP 429. Refused tells that the
+// answer is a Bot API answer with ok false, not a page of a proxy or of a
+// server error.
+type TelegramError struct {
+       Method      string
+       Status      int
+       Description string
+       RetryAfter  time.Duration
+       Refused     bool
+}
+
+func (e *TelegramError) Error() string {
        return fmt.Sprintf("%s: Bot API answered %d: %s", e.Method, e.Status, e.Description)
 }
 
-// retryable tells if a later try of a failed Bot API call can work: after a
-// network error, HTTP 429, or a server error. It returns the wait that the Bot
-// API asks for, or 0.
-func retryable(err error) (bool, time.Duration) {
-       if errors.Is(err, errBotNetwork) {
-               return true, 0
-       }
+// TelegramUpdate is one update of getUpdates. Message is nil for the kinds of
+// updates that are not a new message.
+type TelegramUpdate struct {
+       UpdateID int64            `json:"update_id"`
+       Message  *TelegramMessage `json:"message"`
+}
 
-       var answer *botError
-       if errors.As(err, &answer) {
-               return answer.Status == http.StatusTooManyRequests || answer.Status >= 500, answer.RetryAfter
-       }
+// TelegramEntity is a message entity of sendMessage: a part of the text with a
+// format, such as bold or a link. Offset and Length count UTF-16 code units.
+// URL is the target of a text_link, and Language the language of a pre.
+type TelegramEntity struct {
+       Type     string `json:"type"`
+       Offset   int    `json:"offset"`
+       Length   int    `json:"length"`
+       URL      string `json:"url,omitempty"`
+       Language string `json:"language,omitempty"`
+}
 
-       return false, 0
+// TelegramAnswer is the envelope of each answer of the Bot API.
+type TelegramAnswer struct {
+       OK          bool            `json:"ok"`
+       Result      json.RawMessage `json:"result"`
+       ErrorCode   int             `json:"error_code"`
+       Description string          `json:"description"`
+       Parameters  struct {
+               RetryAfter int `json:"retry_after"`
+       } `json:"parameters"`
 }
 
-// rejected tells if the Bot API refused a call with ok false and HTTP 400 or
-// 404. Then the call did nothing: 400 refuses the request, and 404 tells that
-// the method is not there. An error page with no Bot API answer does not tell
-// this, so it is not a refusal.
-func rejected(err error) bool {
-       var answer *botError
-       return errors.As(err, &answer) && answer.Refused && (answer.Status == http.StatusBadRequest || answer.Status == http.StatusNotFound)
+// TelegramMessage is a message to the bot. Text is empty for a message that is
+// not text, such as a photo.
+type TelegramMessage struct {
+       Chat TelegramChat  `json:"chat"`
+       From *TelegramUser `json:"from"`
+       Text string        `json:"text"`
+}
+
+// TelegramChat is the chat of a message. Type is private for a chat with one
+// user.
+type TelegramChat struct {
+       ID   int64  `json:"id"`
+       Type string `json:"type"`
+}
+
+// TelegramUser is the sender of a message.
+type TelegramUser struct {
+       ID int64 `json:"id"`
 }
similarity index 68%
rename from botapi_test.go
rename to telegram_api_test.go
index fb57b45b468116c0687419123eedd7421c5fd738..bbfd93e8614d742ec15c057c3fbab2d1b77d5134 100644 (file)
@@ -16,22 +16,22 @@ import (
 )
 
 const (
-       // testBotToken is the bot token of the tests. It has the form of a real
-       // token.
-       testBotToken = "123456:TEST-secret_token"
+       // telegramTestToken is the bot token of the tests. It has the form of a
+       // real token.
+       telegramTestToken = "123456:TEST-secret_token"
 
-       // testOwner is the user ID of the owner of the test bot.
-       testOwner = 4242
+       // telegramTestOwner is the user ID of the owner of the test bot.
+       telegramTestOwner = 4242
 )
 
 func TestGetUpdatesAsksForMessagesFromTheOffset(t *testing.T) {
-       server := newFakeBot(t)
-       server.Add(botUpdate{
+       server := newTelegramFake(t)
+       server.Add(TelegramUpdate{
                UpdateID: 7,
-               Message:  ownerMessage("hello"),
+               Message:  telegramOwnerMessage("hello"),
        })
 
-       updates, err := newBotAPI(server.URL, testBotToken).GetUpdates(t.Context(), 7)
+       updates, err := newTelegramAPI(server.URL, telegramTestToken).GetUpdates(t.Context(), 7)
        require.NoError(t, err)
        require.Len(t, updates, 1)
        require.Equal(t, int64(7), updates[0].UpdateID)
@@ -41,25 +41,25 @@ func TestGetUpdatesAsksForMessagesFromTheOffset(t *testing.T) {
        require.Equal(t, "getUpdates", call.method)
        require.Equal(t, map[string]any{
                "offset":          float64(7),
-               "timeout":         float64(pollSeconds),
+               "timeout":         float64(telegramPollSeconds),
                "allowed_updates": []any{"message"},
        }, call.params)
 }
 
 func TestSendMessageSendsPlainText(t *testing.T) {
-       server := newFakeBot(t)
-       err := newBotAPI(server.URL, testBotToken).SendMessage(t.Context(), testOwner, "*not bold*", nil)
+       server := newTelegramFake(t)
+       err := newTelegramAPI(server.URL, telegramTestToken).SendMessage(t.Context(), telegramTestOwner, "*not bold*", nil)
        require.NoError(t, err)
 
        require.Equal(t, map[string]any{
-               "chat_id": float64(testOwner),
+               "chat_id": float64(telegramTestOwner),
                "text":    "*not bold*",
        }, server.Calls()[0].params)
 }
 
 func TestSendMessageSendsTheEntities(t *testing.T) {
-       server := newFakeBot(t)
-       err := newBotAPI(server.URL, testBotToken).SendMessage(t.Context(), testOwner, "a link", []botEntity{
+       server := newTelegramFake(t)
+       err := newTelegramAPI(server.URL, telegramTestToken).SendMessage(t.Context(), telegramTestOwner, "a link", []TelegramEntity{
                {
                        Type:   "text_link",
                        Offset: 2,
@@ -80,15 +80,15 @@ func TestSendMessageSendsTheEntities(t *testing.T) {
 }
 
 func TestSendRichMessageSendsTheMarkdownUnchanged(t *testing.T) {
-       server := newFakeBot(t)
+       server := newTelegramFake(t)
        markdown := "| a | b |\n|---|---|\n| **1** | `2` |"
-       err := newBotAPI(server.URL, testBotToken).SendRichMessage(t.Context(), testOwner, markdown)
+       err := newTelegramAPI(server.URL, telegramTestToken).SendRichMessage(t.Context(), telegramTestOwner, markdown)
        require.NoError(t, err)
 
        call := server.Calls()[0]
        require.Equal(t, "sendRichMessage", call.method)
        require.Equal(t, map[string]any{
-               "chat_id": float64(testOwner),
+               "chat_id": float64(telegramTestOwner),
                "rich_message": map[string]any{
                        "markdown": markdown,
                },
@@ -98,39 +98,39 @@ func TestSendRichMessageSendsTheMarkdownUnchanged(t *testing.T) {
 func TestSendCallsTimeOutOnAStalledConnection(t *testing.T) {
        testCases := []struct {
                method string
-               send   func(ctx context.Context, api *botAPI) error
+               send   func(ctx context.Context, api *TelegramAPI) error
        }{
                {
                        method: "sendMessage",
-                       send: func(ctx context.Context, api *botAPI) error {
-                               return api.SendMessage(ctx, testOwner, "hi", nil)
+                       send: func(ctx context.Context, api *TelegramAPI) error {
+                               return api.SendMessage(ctx, telegramTestOwner, "hi", nil)
                        },
                },
                {
                        method: "sendRichMessage",
-                       send: func(ctx context.Context, api *botAPI) error {
-                               return api.SendRichMessage(ctx, testOwner, "hi")
+                       send: func(ctx context.Context, api *TelegramAPI) error {
+                               return api.SendRichMessage(ctx, telegramTestOwner, "hi")
                        },
                },
        }
 
        for _, tc := range testCases {
                t.Run(tc.method, func(t *testing.T) {
-                       server := newFakeBot(t)
-                       server.stall = func(call fakeBotCall) bool {
+                       server := newTelegramFake(t)
+                       server.stall = func(call TelegramFakeCall) bool {
                                return call.method == tc.method
                        }
 
-                       api := newBotAPI(server.URL, testBotToken)
+                       api := newTelegramAPI(server.URL, telegramTestToken)
                        api.sendTimeout = 50 * time.Millisecond
                        start := time.Now()
                        err := tc.send(t.Context(), api)
-                       require.ErrorIs(t, err, errBotNetwork)
+                       require.ErrorIs(t, err, errTelegramNetwork)
                        require.Less(t, time.Since(start), 5*time.Second)
-                       require.NotContains(t, err.Error(), testBotToken)
+                       require.NotContains(t, err.Error(), telegramTestToken)
                        require.NotContains(t, err.Error(), server.URL)
 
-                       retry, _ := retryable(err)
+                       retry, _ := telegramRetryable(err)
                        require.True(t, retry)
 
                        api.sendTimeout = time.Hour
@@ -187,7 +187,7 @@ func TestBotErrorsDoNotHoldTheToken(t *testing.T) {
                        name: "token echoed in a description is hidden",
                        reply: func(w http.ResponseWriter) {
                                w.WriteHeader(http.StatusUnauthorized)
-                               _, _ = io.WriteString(w, `{"ok":false,"error_code":401,"description":"Unauthorized: bot`+testBotToken+`"}`)
+                               _, _ = io.WriteString(w, `{"ok":false,"error_code":401,"description":"Unauthorized: bot`+telegramTestToken+`"}`)
                        },
                        retry:     false,
                        errorText: "Unauthorized: botTOKEN",
@@ -238,72 +238,54 @@ func TestBotErrorsDoNotHoldTheToken(t *testing.T) {
                        }))
                        t.Cleanup(server.Close)
 
-                       err := newBotAPI(server.URL, testBotToken).SendMessage(t.Context(), testOwner, "hi", nil)
+                       err := newTelegramAPI(server.URL, telegramTestToken).SendMessage(t.Context(), telegramTestOwner, "hi", nil)
                        require.ErrorContains(t, err, tc.errorText)
-                       require.NotContains(t, err.Error(), testBotToken)
+                       require.NotContains(t, err.Error(), telegramTestToken)
                        require.NotContains(t, err.Error(), server.URL)
 
-                       retry, wait := retryable(err)
+                       retry, wait := telegramRetryable(err)
                        require.Equal(t, tc.retry, retry)
                        require.Equal(t, tc.wait, wait)
-                       require.Equal(t, tc.rejected, rejected(err))
+                       require.Equal(t, tc.rejected, telegramRejected(err))
                })
        }
 
        server := httptest.NewServer(http.NotFoundHandler())
        server.Close()
 
-       _, err := newBotAPI(server.URL, testBotToken).GetUpdates(t.Context(), 0)
-       require.ErrorIs(t, err, errBotNetwork)
-       require.NotContains(t, err.Error(), testBotToken)
+       _, err := newTelegramAPI(server.URL, telegramTestToken).GetUpdates(t.Context(), 0)
+       require.ErrorIs(t, err, errTelegramNetwork)
+       require.NotContains(t, err.Error(), telegramTestToken)
 }
 
-// fakeBot is a fake Bot API server for testBotToken. getUpdates returns the
-// added updates from the offset on, or waits a short time when there are none.
-// The other methods succeed, unless fail gives an error answer for a call. An
-// answer with HTTP 429 asks for a wait of one second. When stall is true for a
-// call, the call gets no answer until the client gives it up or the test ends.
-// Fail and stall run with the mutex held.
-type fakeBot struct {
-       *httptest.Server
-
-       mutex   sync.Mutex
-       updates []botUpdate
-       calls   []fakeBotCall
-       fail    func(call fakeBotCall) (int, string)
-       stall   func(call fakeBotCall) bool
-       added   chan struct{}
-       stopped chan struct{}
-}
-
-// fakeBotCall is one call of the fake Bot API. OK tells if the call got a
+// TelegramFakeCall is one call of the fake Bot API. OK tells if the call got a
 // success answer.
-type fakeBotCall struct {
+type TelegramFakeCall struct {
        method string
        params map[string]any
        ok     bool
 }
 
-// newFakeBot starts a fake Bot API server that stops at the end of the test.
-func newFakeBot(t *testing.T) *fakeBot {
-       t.Helper()
-
-       f := &fakeBot{
-               added:   make(chan struct{}, 1),
-               stopped: make(chan struct{}),
-       }
-
-       f.Server = httptest.NewServer(http.HandlerFunc(f.serve))
-       t.Cleanup(f.Close)
-       t.Cleanup(func() {
-               close(f.stopped)
-       })
+// TelegramFake is a fake Bot API server for telegramTestToken. getUpdates
+// returns the added updates from the offset on, or waits a short time when
+// there are none. The other methods succeed, unless fail gives an error answer
+// for a call. An answer with HTTP 429 asks for a wait of one second. When stall
+// is true for a call, the call gets no answer until the client gives it up or
+// the test ends. Fail and stall run with the mutex held.
+type TelegramFake struct {
+       *httptest.Server
 
-       return f
+       mutex   sync.Mutex
+       updates []TelegramUpdate
+       calls   []TelegramFakeCall
+       fail    func(call TelegramFakeCall) (int, string)
+       stall   func(call TelegramFakeCall) bool
+       added   chan struct{}
+       stopped chan struct{}
 }
 
 // Add makes update available to getUpdates.
-func (f *fakeBot) Add(update botUpdate) {
+func (f *TelegramFake) Add(update TelegramUpdate) {
        f.mutex.Lock()
        f.updates = append(f.updates, update)
        f.mutex.Unlock()
@@ -315,16 +297,16 @@ func (f *fakeBot) Add(update botUpdate) {
 }
 
 // Calls returns the calls so far.
-func (f *fakeBot) Calls() []fakeBotCall {
+func (f *TelegramFake) Calls() []TelegramFakeCall {
        f.mutex.Lock()
        defer f.mutex.Unlock()
 
-       return append([]fakeBotCall(nil), f.calls...)
+       return append([]TelegramFakeCall(nil), f.calls...)
 }
 
 // Sent returns the texts of the sendMessage calls and the Markdown of the
 // sendRichMessage calls that succeeded, in order.
-func (f *fakeBot) Sent() []string {
+func (f *TelegramFake) Sent() []string {
        var texts []string
        for _, call := range f.Calls() {
                switch {
@@ -339,8 +321,8 @@ func (f *fakeBot) Sent() []string {
        return texts
 }
 
-func (f *fakeBot) serve(w http.ResponseWriter, r *http.Request) {
-       method, found := strings.CutPrefix(r.URL.Path, "/bot"+testBotToken+"/")
+func (f *TelegramFake) serve(w http.ResponseWriter, r *http.Request) {
+       method, found := strings.CutPrefix(r.URL.Path, "/bot"+telegramTestToken+"/")
        if !found {
                http.Error(w, `{"ok":false,"error_code":404,"description":"Not Found"}`, http.StatusNotFound)
                return
@@ -353,7 +335,7 @@ func (f *fakeBot) serve(w http.ResponseWriter, r *http.Request) {
                return
        }
 
-       call := fakeBotCall{
+       call := TelegramFakeCall{
                method: method,
                params: params,
        }
@@ -416,7 +398,7 @@ func (f *fakeBot) serve(w http.ResponseWriter, r *http.Request) {
 }
 
 // record keeps call.
-func (f *fakeBot) record(call fakeBotCall) {
+func (f *TelegramFake) record(call TelegramFakeCall) {
        f.mutex.Lock()
        defer f.mutex.Unlock()
 
@@ -425,11 +407,11 @@ func (f *fakeBot) record(call fakeBotCall) {
 
 // from returns the updates from offset on, and forgets the ones before it, as
 // Telegram does.
-func (f *fakeBot) from(offset int64) []botUpdate {
+func (f *TelegramFake) from(offset int64) []TelegramUpdate {
        f.mutex.Lock()
        defer f.mutex.Unlock()
 
-       var updates []botUpdate
+       var updates []TelegramUpdate
        kept := f.updates[:0]
        for _, update := range f.updates {
                if update.UpdateID >= offset {
@@ -443,15 +425,34 @@ func (f *fakeBot) from(offset int64) []botUpdate {
        return updates
 }
 
-// ownerMessage returns a text message of the owner in the private chat.
-func ownerMessage(text string) *botMessage {
-       return &botMessage{
-               Chat: botChat{
-                       ID:   testOwner,
+// newTelegramFake starts a fake Bot API server that stops at the end of the
+// test.
+func newTelegramFake(t *testing.T) *TelegramFake {
+       t.Helper()
+
+       f := &TelegramFake{
+               added:   make(chan struct{}, 1),
+               stopped: make(chan struct{}),
+       }
+
+       f.Server = httptest.NewServer(http.HandlerFunc(f.serve))
+       t.Cleanup(f.Close)
+       t.Cleanup(func() {
+               close(f.stopped)
+       })
+
+       return f
+}
+
+// telegramOwnerMessage returns a text message of the owner in the private chat.
+func telegramOwnerMessage(text string) *TelegramMessage {
+       return &TelegramMessage{
+               Chat: TelegramChat{
+                       ID:   telegramTestOwner,
                        Type: "private",
                },
-               From: &botUser{
-                       ID: testOwner,
+               From: &TelegramUser{
+                       ID: telegramTestOwner,
                },
                Text: text,
        }
similarity index 52%
rename from markdown.go
rename to telegram_markdown.go
index 1a2f4feb1343723a704a874c2c586e78a0fee8d6..54bfbcb36031565e89fa67621ceaf2f78e480d92 100644 (file)
@@ -7,29 +7,12 @@ import (
        "unicode/utf8"
 )
 
-// markdownPunctuation holds the ASCII characters that a backslash makes plain.
-const markdownPunctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
+// telegramMarkdownPunctuation holds the ASCII characters that a backslash makes
+// plain.
+const telegramMarkdownPunctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
 
-// markdownText is the plain text of a Markdown reply and its Telegram entities,
-// while the conversion builds it. Units counts the UTF-16 code units of text,
-// the unit of the entity offsets and lengths.
-type markdownText struct {
-       text     strings.Builder
-       units    int
-       entities []botEntity
-}
-
-// markdownSpan is an inline mark of a line: the entity type, the content inside
-// the marks, the index after the closing mark, and the URL of a link.
-type markdownSpan struct {
-       kind    string
-       content string
-       end     int
-       url     string
-}
-
-// markdownEntities converts the Markdown of a reply to plain text and Telegram
-// message entities. It knows bold (** and __), italic (* and _),
+// telegramMarkdownEntities converts the Markdown of a reply to plain text and
+// Telegram message entities. It knows bold (** and __), italic (* and _),
 // strikethrough (~~), inline code, fenced code blocks with an optional
 // language, links [text](url), and ATX headings, which become bold. Lists,
 // quotes, and tables stay as their text. A mark that does not close stays as
@@ -37,8 +20,8 @@ type markdownSpan struct {
 //
 // Inline marks do not cross a line break, so a mark that does not close cannot
 // change the rest of the reply.
-func markdownEntities(markdown string) (string, []botEntity) {
-       var out markdownText
+func telegramMarkdownEntities(markdown string) (string, []TelegramEntity) {
+       var out TelegramMarkdownText
        lines := strings.Split(markdown, "\n")
        for index := 0; index < len(lines); index++ {
                if index > 0 {
@@ -46,14 +29,14 @@ func markdownEntities(markdown string) (string, []botEntity) {
                }
 
                line := lines[index]
-               language, fence := codeFence(line)
-               end := closingFence(lines, index, fence)
-               heading := headingText(line)
+               language, fence := telegramCodeFence(line)
+               end := telegramClosingFence(lines, index, fence)
+               heading := telegramHeadingText(line)
                switch {
                case end > index:
                        start := out.units
                        out.add(strings.Join(lines[index+1:end], "\n"))
-                       out.entity(botEntity{
+                       out.entity(TelegramEntity{
                                Type:     "pre",
                                Offset:   start,
                                Length:   out.units - start,
@@ -64,7 +47,7 @@ func markdownEntities(markdown string) (string, []botEntity) {
                case heading != "":
                        start := out.units
                        out.inline(heading)
-                       out.entity(botEntity{
+                       out.entity(TelegramEntity{
                                Type:   "bold",
                                Offset: start,
                                Length: out.units - start,
@@ -74,7 +57,7 @@ func markdownEntities(markdown string) (string, []botEntity) {
                }
        }
 
-       slices.SortStableFunc(out.entities, func(a, b botEntity) int {
+       slices.SortStableFunc(out.entities, func(a, b TelegramEntity) int {
                if a.Offset != b.Offset {
                        return a.Offset - b.Offset
                }
@@ -85,15 +68,62 @@ func markdownEntities(markdown string) (string, []botEntity) {
        return out.text.String(), out.entities
 }
 
+// telegramCodeFence returns the language of a line that opens a fenced code
+// block, and the fence. The fence is "" when line does not open a block.
+func telegramCodeFence(line string) (string, string) {
+       trimmed := strings.TrimLeft(line, " ")
+       if len(line)-len(trimmed) > 3 || !strings.HasPrefix(trimmed, "```") {
+               return "", ""
+       }
+
+       run := len(trimmed) - len(strings.TrimLeft(trimmed, "`"))
+       info := strings.TrimSpace(trimmed[run:])
+       if strings.Contains(info, "`") {
+               return "", ""
+       }
+
+       language, _, _ := strings.Cut(info, " ")
+
+       return language, trimmed[:run]
+}
+
+// telegramClosesFence tells if line closes the code block of fence.
+func telegramClosesFence(line, fence string) bool {
+       trimmed := strings.TrimSpace(line)
+       return strings.HasPrefix(trimmed, fence) && strings.Trim(trimmed, "`") == ""
+}
+
+// telegramUTF16Units returns the number of UTF-16 code units of text.
+func telegramUTF16Units(text string) int {
+       units := 0
+       for _, char := range text {
+               units++
+               if char > 0xFFFF {
+                       units++
+               }
+       }
+
+       return units
+}
+
+// TelegramMarkdownText is the plain text of a Markdown reply and its Telegram
+// entities, while the conversion builds it. Units counts the UTF-16 code units
+// of text, the unit of the entity offsets and lengths.
+type TelegramMarkdownText struct {
+       text     strings.Builder
+       units    int
+       entities []TelegramEntity
+}
+
 // add appends text.
-func (m *markdownText) add(text string) {
+func (m *TelegramMarkdownText) add(text string) {
        m.text.WriteString(text)
-       m.units += utf16Units(text)
+       m.units += telegramUTF16Units(text)
 }
 
 // entity keeps entity, unless it is empty, because Telegram refuses an entity
 // of length 0.
-func (m *markdownText) entity(entity botEntity) {
+func (m *TelegramMarkdownText) entity(entity TelegramEntity) {
        if entity.Length > 0 {
                m.entities = append(m.entities, entity)
        }
@@ -102,11 +132,11 @@ func (m *markdownText) entity(entity botEntity) {
 // inline appends one line of Markdown with its inline marks as entities. A
 // backslash before an ASCII punctuation character makes the character plain
 // text.
-func (m *markdownText) inline(line string) {
+func (m *TelegramMarkdownText) inline(line string) {
        plain := 0
        index := 0
        for index < len(line) {
-               if line[index] == '\\' && index+1 < len(line) && strings.IndexByte(markdownPunctuation, line[index+1]) >= 0 {
+               if line[index] == '\\' && index+1 < len(line) && strings.IndexByte(telegramMarkdownPunctuation, line[index+1]) >= 0 {
                        m.add(line[plain:index])
                        plain = index + 1
                        index += 2
@@ -114,7 +144,7 @@ func (m *markdownText) inline(line string) {
                        continue
                }
 
-               span, found := markSpan(line, index)
+               span, found := telegramMarkSpan(line, index)
                if !found {
                        index++
                        continue
@@ -128,7 +158,7 @@ func (m *markdownText) inline(line string) {
                        m.inline(span.content)
                }
 
-               m.entity(botEntity{
+               m.entity(TelegramEntity{
                        Type:   span.kind,
                        Offset: start,
                        Length: m.units - start,
@@ -142,34 +172,25 @@ func (m *markdownText) inline(line string) {
        m.add(line[plain:])
 }
 
-// codeFence returns the language of a line that opens a fenced code block, and
-// the fence. The fence is "" when line does not open a block.
-func codeFence(line string) (string, string) {
-       trimmed := strings.TrimLeft(line, " ")
-       if len(line)-len(trimmed) > 3 || !strings.HasPrefix(trimmed, "```") {
-               return "", ""
-       }
-
-       run := len(trimmed) - len(strings.TrimLeft(trimmed, "`"))
-       info := strings.TrimSpace(trimmed[run:])
-       if strings.Contains(info, "`") {
-               return "", ""
-       }
-
-       language, _, _ := strings.Cut(info, " ")
-
-       return language, trimmed[:run]
+// TelegramMarkdownSpan is an inline mark of a line: the entity type, the
+// content inside the marks, the index after the closing mark, and the URL of a
+// link.
+type TelegramMarkdownSpan struct {
+       kind    string
+       content string
+       end     int
+       url     string
 }
 
-// closingFence returns the index of the line after index that closes the code
-// block of fence, or -1 when fence is "" or no line closes it.
-func closingFence(lines []string, index int, fence string) int {
+// telegramClosingFence returns the index of the line after index that closes
+// the code block of fence, or -1 when fence is "" or no line closes it.
+func telegramClosingFence(lines []string, index int, fence string) int {
        if fence == "" {
                return -1
        }
 
        for end := index + 1; end < len(lines); end++ {
-               if closesFence(lines[end], fence) {
+               if telegramClosesFence(lines[end], fence) {
                        return end
                }
        }
@@ -177,15 +198,9 @@ func closingFence(lines []string, index int, fence string) int {
        return -1
 }
 
-// closesFence tells if line closes the code block of fence.
-func closesFence(line, fence string) bool {
-       trimmed := strings.TrimSpace(line)
-       return strings.HasPrefix(trimmed, fence) && strings.Trim(trimmed, "`") == ""
-}
-
-// headingText returns the text of an ATX heading line without its marks, or ""
-// when line is not a heading or has no text.
-func headingText(line string) string {
+// telegramHeadingText returns the text of an ATX heading line without its
+// marks, or "" when line is not a heading or has no text.
+func telegramHeadingText(line string) string {
        trimmed := strings.TrimLeft(line, " ")
        if len(line)-len(trimmed) > 3 {
                return ""
@@ -210,48 +225,36 @@ func headingText(line string) string {
        return rest
 }
 
-// utf16Units returns the number of UTF-16 code units of text.
-func utf16Units(text string) int {
-       units := 0
-       for _, char := range text {
-               units++
-               if char > 0xFFFF {
-                       units++
-               }
-       }
-
-       return units
-}
-
-// markSpan returns the inline mark that starts at index of line and closes in
-// line. It returns false when no mark starts there or the mark does not close.
-func markSpan(line string, index int) (markdownSpan, bool) {
+// telegramMarkSpan returns the inline mark that starts at index of line and
+// closes in line. It returns false when no mark starts there or the mark does
+// not close.
+func telegramMarkSpan(line string, index int) (TelegramMarkdownSpan, bool) {
        switch line[index] {
        case '`':
-               return codeSpan(line, index)
+               return telegramCodeSpan(line, index)
        case '[':
-               return linkSpan(line, index)
+               return telegramLinkSpan(line, index)
        case '~':
-               return delimited(line, index, "~~", "strikethrough")
+               return telegramDelimited(line, index, "~~", "strikethrough")
        case '*', '_':
                double := line[index : index+1]
                double += double
-               span, found := delimited(line, index, double, "bold")
+               span, found := telegramDelimited(line, index, double, "bold")
                if found {
                        return span, true
                }
 
-               return delimited(line, index, line[index:index+1], "italic")
+               return telegramDelimited(line, index, line[index:index+1], "italic")
        default:
-               return markdownSpan{}, false
+               return TelegramMarkdownSpan{}, false
        }
 }
 
-// codeSpan returns the inline code that starts at index: a run of backticks,
-// the code, and a run of the same length. One space on each side of the code
-// is left out, as in Markdown.
-func codeSpan(line string, index int) (markdownSpan, bool) {
-       run := backticks(line[index:])
+// telegramCodeSpan returns the inline code that starts at index: a run of
+// backticks, the code, and a run of the same length. One space on each side of
+// the code is left out, as in Markdown.
+func telegramCodeSpan(line string, index int) (TelegramMarkdownSpan, bool) {
+       run := telegramBackticks(line[index:])
        for search := index + run; search < len(line); {
                next := strings.IndexByte(line[search:], '`')
                if next < 0 {
@@ -259,7 +262,7 @@ func codeSpan(line string, index int) (markdownSpan, bool) {
                }
 
                next += search
-               length := backticks(line[next:])
+               length := telegramBackticks(line[next:])
                if length != run {
                        search = next + length
                        continue
@@ -274,19 +277,19 @@ func codeSpan(line string, index int) (markdownSpan, bool) {
                        content = content[1 : len(content)-1]
                }
 
-               return markdownSpan{
+               return TelegramMarkdownSpan{
                        kind:    "code",
                        content: content,
                        end:     next + run,
                }, true
        }
 
-       return markdownSpan{}, false
+       return TelegramMarkdownSpan{}, false
 }
 
-// linkSpan returns the link [text](url) that starts at index. The text can
-// hold brackets in pairs, and the URL holds no space.
-func linkSpan(line string, index int) (markdownSpan, bool) {
+// telegramLinkSpan returns the link [text](url) that starts at index. The text
+// can hold brackets in pairs, and the URL holds no space.
+func telegramLinkSpan(line string, index int) (TelegramMarkdownSpan, bool) {
        depth := 0
        for closing := index; closing < len(line); closing++ {
                switch line[closing] {
@@ -304,15 +307,15 @@ func linkSpan(line string, index int) (markdownSpan, bool) {
 
                rest := line[closing+1:]
                if closing == index+1 || !strings.HasPrefix(rest, "(") {
-                       return markdownSpan{}, false
+                       return TelegramMarkdownSpan{}, false
                }
 
                url, _, found := strings.Cut(rest[1:], ")")
                if !found || url == "" || strings.ContainsAny(url, " \t") {
-                       return markdownSpan{}, false
+                       return TelegramMarkdownSpan{}, false
                }
 
-               return markdownSpan{
+               return TelegramMarkdownSpan{
                        kind:    "text_link",
                        content: line[index+1 : closing],
                        end:     closing + 2 + len(url) + 1,
@@ -320,27 +323,27 @@ func linkSpan(line string, index int) (markdownSpan, bool) {
                }, true
        }
 
-       return markdownSpan{}, false
+       return TelegramMarkdownSpan{}, false
 }
 
-// delimited returns the span of kind between the mark at index and the same
-// mark later in line. An opening mark is followed by a character that is not a
-// space, and a closing mark follows one. A single mark is not a part of a
+// telegramDelimited returns the span of kind between the mark at index and the
+// same mark later in line. An opening mark is followed by a character that is
+// not a space, and a closing mark follows one. A single mark is not a part of a
 // double one. A mark of underscores does not open or close inside a word, so
 // snake_case stays as text. A code span inside the content is skipped whole.
-func delimited(line string, index int, mark, kind string) (markdownSpan, bool) {
+func telegramDelimited(line string, index int, mark, kind string) (TelegramMarkdownSpan, bool) {
        char := mark[0]
        after := index + len(mark)
-       if !strings.HasPrefix(line[index:], mark) || after >= len(line) || isSpace(line[after]) {
-               return markdownSpan{}, false
+       if !strings.HasPrefix(line[index:], mark) || after >= len(line) || telegramIsSpace(line[after]) {
+               return TelegramMarkdownSpan{}, false
        }
 
        if len(mark) == 1 && (line[after] == char || index > 0 && line[index-1] == char) {
-               return markdownSpan{}, false
+               return TelegramMarkdownSpan{}, false
        }
 
-       if char == '_' && index > 0 && wordCharBefore(line[:index]) {
-               return markdownSpan{}, false
+       if char == '_' && index > 0 && telegramWordCharBefore(line[:index]) {
+               return TelegramMarkdownSpan{}, false
        }
 
        for closing := after + 1; closing < len(line); closing++ {
@@ -349,13 +352,13 @@ func delimited(line string, index int, mark, kind string) (markdownSpan, bool) {
                        closing++
                        continue
                case line[closing] == '`':
-                       span, found := codeSpan(line, closing)
+                       span, found := telegramCodeSpan(line, closing)
                        if found {
                                closing = span.end - 1
                        }
 
                        continue
-               case !strings.HasPrefix(line[closing:], mark) || isSpace(line[closing-1]):
+               case !strings.HasPrefix(line[closing:], mark) || telegramIsSpace(line[closing-1]):
                        continue
                }
 
@@ -364,39 +367,41 @@ func delimited(line string, index int, mark, kind string) (markdownSpan, bool) {
                        continue
                }
 
-               if char == '_' && wordChar(line[end:]) {
+               if char == '_' && telegramWordChar(line[end:]) {
                        continue
                }
 
-               return markdownSpan{
+               return TelegramMarkdownSpan{
                        kind:    kind,
                        content: line[after:closing],
                        end:     end,
                }, true
        }
 
-       return markdownSpan{}, false
+       return TelegramMarkdownSpan{}, false
 }
 
-// backticks returns the length of the run of backticks at the start of text.
-func backticks(text string) int {
+// telegramBackticks returns the length of the run of backticks at the start of
+// text.
+func telegramBackticks(text string) int {
        return len(text) - len(strings.TrimLeft(text, "`"))
 }
 
-// isSpace tells if char is a space, a tab, or a line break.
-func isSpace(char byte) bool {
+// telegramIsSpace tells if char is a space, a tab, or a line break.
+func telegramIsSpace(char byte) bool {
        return char == ' ' || char == '\t' || char == '\n' || char == '\r'
 }
 
-// wordCharBefore tells if the character that ends text is a letter or a digit.
-func wordCharBefore(text string) bool {
+// telegramWordCharBefore tells if the character that ends text is a letter or a
+// digit.
+func telegramWordCharBefore(text string) bool {
        char, _ := utf8.DecodeLastRuneInString(text)
        return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char))
 }
 
-// wordChar tells if the character that starts text is a letter or a digit. An
-// underscore next to one is a part of a word, such as in snake_case.
-func wordChar(text string) bool {
+// telegramWordChar tells if the character that starts text is a letter or a
+// digit. An underscore next to one is a part of a word, such as in snake_case.
+func telegramWordChar(text string) bool {
        char, _ := utf8.DecodeRuneInString(text)
        return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char))
 }
similarity index 89%
rename from markdown_test.go
rename to telegram_markdown_test.go
index cca7db00fa44a9208a4fce8eaee68f7b3bd94467..e2a253056e251b8877c6972c229d7b808f85061e 100644 (file)
@@ -11,13 +11,13 @@ func TestMarkdownEntities(t *testing.T) {
                name     string
                markdown string
                text     string
-               entities []botEntity
+               entities []TelegramEntity
        }{
                {
                        name:     "double asterisks give bold",
                        markdown: "a **b** c",
                        text:     "a b c",
-                       entities: []botEntity{
+                       entities: []TelegramEntity{
                                {
                                        Type:   "bold",
                                        Offset: 2,
@@ -29,7 +29,7 @@ func TestMarkdownEntities(t *testing.T) {
                        name:     "double underscores give bold",
                        markdown: "__bold__",
                        text:     "bold",
-                       entities: []botEntity{
+                       entities: []TelegramEntity{
                                {
                                        Type:   "bold",
                                        Offset: 0,
@@ -41,7 +41,7 @@ func TestMarkdownEntities(t *testing.T) {
                        name:     "single asterisk gives italic",
                        markdown: "an *odd* word",
                        text:     "an odd word",
-                       entities: []botEntity{
+                       entities: []TelegramEntity{
                                {
                                        Type:   "italic",
                                        Offset: 3,
@@ -53,7 +53,7 @@ func TestMarkdownEntities(t *testing.T) {
                        name:     "single underscore gives italic",
                        markdown: "an _odd_ word",
                        text:     "an odd word",
-                       entities: []botEntity{
+                       entities: []TelegramEntity{
                                {
                                        Type:   "italic",
                                        Offset: 3,
@@ -65,7 +65,7 @@ func TestMarkdownEntities(t *testing.T) {
                        name:     "double tildes give strikethrough",
                        markdown: "~~old~~ new",
                        text:     "old new",
-                       entities: []botEntity{
+                       entities: []TelegramEntity{
                                {
                                        Type:   "strikethrough",
                                        Offset: 0,
@@ -77,7 +77,7 @@ func TestMarkdownEntities(t *testing.T) {
                        name:     "backticks give inline code with no entity inside",
                        markdown: "run `go **test**` now",
                        text:     "run go **test** now",
-                       entities: []botEntity{
+                       entities: []TelegramEntity{
                                {
                                        Type:   "code",
                                        Offset: 4,
@@ -89,7 +89,7 @@ func TestMarkdownEntities(t *testing.T) {
                        name:     "double backticks hold a backtick",
                        markdown: "``a ` b``",
                        text:     "a ` b",
-                       entities: []botEntity{
+                       entities: []TelegramEntity{
                                {
                                        Type:   "code",
                                        Offset: 0,
@@ -101,7 +101,7 @@ func TestMarkdownEntities(t *testing.T) {
                        name:     "link gives a text link",
                        markdown: "see [the docs](https://example.com/a_b) here",
                        text:     "see the docs here",
-                       entities: []botEntity{
+                       entities: []TelegramEntity{
                                {
                                        Type:   "text_link",
                                        Offset: 4,
@@ -114,7 +114,7 @@ func TestMarkdownEntities(t *testing.T) {
                        name:     "bold inside a link nests",
                        markdown: "[a **b** c](https://example.com)",
                        text:     "a b c",
-                       entities: []botEntity{
+                       entities: []TelegramEntity{
                                {
                                        Type:   "text_link",
                                        Offset: 0,
@@ -132,7 +132,7 @@ func TestMarkdownEntities(t *testing.T) {
                        name:     "code block with a language gives pre with the language",
                        markdown: "Commit:\n```text\nFix the **bug**\n\nBody\n```\nDone.",
                        text:     "Commit:\nFix the **bug**\n\nBody\nDone.",
-                       entities: []botEntity{
+                       entities: []TelegramEntity{
                                {
                                        Type:     "pre",
                                        Offset:   8,
@@ -145,7 +145,7 @@ func TestMarkdownEntities(t *testing.T) {
                        name:     "code block without a language gives pre",
                        markdown: "```\nls -l\n```",
                        text:     "ls -l",
-                       entities: []botEntity{
+                       entities: []TelegramEntity{
                                {
                                        Type:   "pre",
                                        Offset: 0,
@@ -157,7 +157,7 @@ func TestMarkdownEntities(t *testing.T) {
                        name:     "heading becomes bold with its inline marks",
                        markdown: "## The `plan`\nnext",
                        text:     "The plan\nnext",
-                       entities: []botEntity{
+                       entities: []TelegramEntity{
                                {
                                        Type:   "bold",
                                        Offset: 0,
@@ -179,7 +179,7 @@ func TestMarkdownEntities(t *testing.T) {
                        name:     "asterisk of a list item with italic later stays",
                        markdown: "* item with *stress*",
                        text:     "* item with stress",
-                       entities: []botEntity{
+                       entities: []TelegramEntity{
                                {
                                        Type:   "italic",
                                        Offset: 12,
@@ -221,7 +221,7 @@ func TestMarkdownEntities(t *testing.T) {
                        name:     "character outside the BMP moves the offset by two",
                        markdown: "\U0001F600 **b**",
                        text:     "\U0001F600 b",
-                       entities: []botEntity{
+                       entities: []TelegramEntity{
                                {
                                        Type:   "bold",
                                        Offset: 3,
@@ -233,7 +233,7 @@ func TestMarkdownEntities(t *testing.T) {
 
        for _, tc := range testCases {
                t.Run(tc.name, func(t *testing.T) {
-                       text, entities := markdownEntities(tc.markdown)
+                       text, entities := telegramMarkdownEntities(tc.markdown)
                        require.Equal(t, tc.text, text)
                        require.Equal(t, tc.entities, entities)
                })
index df35e0df6430490778a523cbbdace02a09a7eabb..9111d13400be5716efa0a3c7dedf590afdd7b6e5 100644 (file)
@@ -19,56 +19,56 @@ func TestOwnerTalksToTheAgentThroughTelegram(t *testing.T) {
                writeEvents(w, completedEvent([]any{messageItem("Hello from the agent.")}, 100, 0, 5))
        }
 
-       telegram := newFakeBot(t)
-       bot, _ := startTestBot(t, telegram)
+       telegram := newTelegramFake(t)
+       bot, _ := telegramStartTestBot(t, telegram)
        socket := startTestDaemonWithBot(t, server, bot)
        terminal := startTestClient(t, socket)
        terminal.WaitFor(t, "[connected to OneChat]")
 
-       telegram.Add(botUpdate{
+       telegram.Add(TelegramUpdate{
                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]")
-       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")
-       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) {
-       telegram := newFakeBot(t)
-       bot, logs := startTestBot(t, telegram)
+       telegram := newTelegramFake(t)
+       bot, logs := telegramStartTestBot(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{
+       group := telegramOwnerMessage("secret in a group")
+       group.Chat = TelegramChat{
                ID:   -100,
                Type: "group",
        }
 
-       stranger := ownerMessage("secret of a stranger")
+       stranger := telegramOwnerMessage("secret of a stranger")
        stranger.Chat.ID = 77
        stranger.From.ID = 77
 
-       unsigned := ownerMessage("secret with no sender")
+       unsigned := telegramOwnerMessage("secret with no sender")
        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 {
-               offset, err := loadOffset(bot.offsetPath)
+               offset, err := telegramLoadOffset(bot.offsetPath)
                return err == nil && offset == 14
        }, 10*time.Second, time.Millisecond)
 
@@ -79,16 +79,16 @@ func TestBotIgnoresEveryoneButTheOwner(t *testing.T) {
 }
 
 func TestBotAnswersThatOnlyTextIsSupported(t *testing.T) {
-       telegram := newFakeBot(t)
-       bot, _ := startTestBot(t, telegram)
+       telegram := newTelegramFake(t)
+       bot, _ := telegramStartTestBot(t, telegram)
        startTestDaemonWithBot(t, newFakeResponses(t), bot)
 
-       telegram.Add(botUpdate{
+       telegram.Add(TelegramUpdate{
                UpdateID: 1,
-               Message:  ownerMessage(""),
+               Message:  telegramOwnerMessage(""),
        })
 
-       waitForSent(t, telegram, textOnly)
+       telegramWaitForSent(t, telegram, telegramTextOnly)
 }
 
 func TestStopFromTelegramInterruptsTheTurn(t *testing.T) {
@@ -104,29 +104,29 @@ func TestStopFromTelegramInterruptsTheTurn(t *testing.T) {
                <-release
        }
 
-       telegram := newFakeBot(t)
-       bot, _ := startTestBot(t, telegram)
+       telegram := newTelegramFake(t)
+       bot, _ := telegramStartTestBot(t, telegram)
        startTestDaemonWithBot(t, server, bot)
 
-       telegram.Add(botUpdate{
+       telegram.Add(TelegramUpdate{
                UpdateID: 1,
-               Message:  ownerMessage("a long task"),
+               Message:  telegramOwnerMessage("a long task"),
        })
 
        <-arrived
-       telegram.Add(botUpdate{
+       telegram.Add(TelegramUpdate{
                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,
-               Message:  ownerMessage(stopCommand),
+               Message:  telegramOwnerMessage(stopCommand),
        })
 
-       waitForSent(t, telegram, "Error: interrupted", "No turn runs.")
+       telegramWaitForSent(t, telegram, "Error: interrupted", "No turn runs.")
 }
 
 func TestBotStartsAtTheStoredOffset(t *testing.T) {
@@ -135,30 +135,30 @@ func TestBotStartsAtTheStoredOffset(t *testing.T) {
                writeEvents(w, completedEvent([]any{messageItem("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,
-               Message:  ownerMessage("handled before the restart"),
+               Message:  telegramOwnerMessage("handled before the restart"),
        })
 
-       telegram.Add(botUpdate{
+       telegram.Add(TelegramUpdate{
                UpdateID: 10,
-               Message:  ownerMessage("new after the restart"),
+               Message:  telegramOwnerMessage("new after the restart"),
        })
 
        socket := startTestDaemonWithBot(t, server, bot)
        terminal := startTestClient(t, socket)
        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 {
-               offset, err := loadOffset(bot.offsetPath)
+               offset, err := telegramLoadOffset(bot.offsetPath)
                return err == nil && offset == 11
        }, 10*time.Second, time.Millisecond)
 }
@@ -169,13 +169,13 @@ func TestBotTriesAgainAfterFailures(t *testing.T) {
                writeEvents(w, completedEvent([]any{messageItem("ok")}, 100, 0, 5))
        }
 
-       telegram := newFakeBot(t)
+       telegram := newTelegramFake(t)
        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, ""
                }
@@ -188,24 +188,24 @@ func TestBotTriesAgainAfterFailures(t *testing.T) {
                return http.StatusBadGateway, "Bad Gateway"
        }
 
-       bot, logs := startTestBot(t, telegram)
+       bot, logs := telegramStartTestBot(t, telegram)
        startTestDaemonWithBot(t, server, bot)
 
-       telegram.Add(botUpdate{
+       telegram.Add(TelegramUpdate{
                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.NotContains(t, logs.String(), testBotToken)
+       require.NotContains(t, logs.String(), telegramTestToken)
 }
 
 func TestDeliverTriesAgainAfterATimeout(t *testing.T) {
-       telegram := newFakeBot(t)
+       telegram := newTelegramFake(t)
        stalls := 1
-       telegram.stall = func(call fakeBotCall) bool {
+       telegram.stall = func(call TelegramFakeCall) bool {
                if call.method != "sendMessage" || stalls == 0 {
                        return false
                }
@@ -215,16 +215,16 @@ func TestDeliverTriesAgainAfterATimeout(t *testing.T) {
                return true
        }
 
-       bot, logs := startTestBot(t, telegram)
+       bot, logs := telegramStartTestBot(t, telegram)
        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")
-       require.NotContains(t, logs.String(), testBotToken)
+       require.NotContains(t, logs.String(), telegramTestToken)
 }
 
 func TestRepliesGoAsRichMessages(t *testing.T) {
@@ -234,34 +234,34 @@ func TestRepliesGoAsRichMessages(t *testing.T) {
                writeEvents(w, completedEvent([]any{messageItem(reply)}, 100, 0, 5))
        }
 
-       telegram := newFakeBot(t)
-       bot, _ := startTestBot(t, telegram)
+       telegram := newTelegramFake(t)
+       bot, _ := telegramStartTestBot(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", reply)
+       telegramWaitForSent(t, telegram, "[terminal] make **it** so", reply)
 
-       telegram.Add(botUpdate{
+       telegram.Add(TelegramUpdate{
                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)
                }
        }
 
-       require.Equal(t, []fakeBotCall{
+       require.Equal(t, []TelegramFakeCall{
                {
                        method: "sendMessage",
                        params: map[string]any{
-                               "chat_id": float64(testOwner),
+                               "chat_id": float64(telegramTestOwner),
                                "text":    "[terminal] make **it** so",
                        },
                        ok: true,
@@ -269,7 +269,7 @@ func TestRepliesGoAsRichMessages(t *testing.T) {
                {
                        method: "sendRichMessage",
                        params: map[string]any{
-                               "chat_id": float64(testOwner),
+                               "chat_id": float64(telegramTestOwner),
                                "rich_message": map[string]any{
                                        "markdown": reply,
                                },
@@ -279,8 +279,8 @@ func TestRepliesGoAsRichMessages(t *testing.T) {
                {
                        method: "sendMessage",
                        params: map[string]any{
-                               "chat_id": float64(testOwner),
-                               "text":    textOnly,
+                               "chat_id": float64(telegramTestOwner),
+                               "text":    telegramTextOnly,
                        },
                        ok: true,
                },
@@ -337,19 +337,19 @@ func TestRichMessageFailures(t *testing.T) {
 
        for _, tc := range testCases {
                t.Run(tc.name, func(t *testing.T) {
-                       telegram := newFakeBot(t)
+                       telegram := newTelegramFake(t)
                        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
 
-                               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",
@@ -364,7 +364,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.NotContains(t, logs.String(), testBotToken)
+                       require.NotContains(t, logs.String(), telegramTestToken)
                        require.NotContains(t, logs.String(), "secret word")
 
                        calls := telegram.Calls()
@@ -376,12 +376,12 @@ func TestRichMessageFailures(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
 
-       telegram := newFakeBot(t)
-       bot, _ := startTestBot(t, telegram)
+       telegram := newTelegramFake(t)
+       bot, _ := telegramStartTestBot(t, telegram)
        bot.post(t.Context(), Event{
                Kind: "reply",
                Text: reply,
@@ -391,18 +391,18 @@ func TestLongReplyGoesAsRichMessagesSplitBetweenBlocks(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, ""
                }
 
-               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,
        })
@@ -414,21 +414,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.NotContains(t, logs.String(), testBotToken)
+       require.NotContains(t, logs.String(), telegramTestToken)
        require.NotContains(t, logs.String(), "secret word")
 }
 
 func TestBotLogsNoTokenWhenTheNetworkFails(t *testing.T) {
-       telegram := newFakeBot(t)
+       telegram := newTelegramFake(t)
        telegram.Close()
 
-       bot, logs := startTestBot(t, telegram)
+       bot, logs := telegramStartTestBot(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)
+       require.NotContains(t, logs.String(), telegramTestToken)
 }
 
 func TestSplitMessage(t *testing.T) {
@@ -472,18 +472,18 @@ func TestSplitMessage(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)
-       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) {
-       parts := splitEntities("aabbbbcc", []botEntity{
+       parts := telegramSplitEntities("aabbbbcc", []TelegramEntity{
                {
                        Type:   "bold",
                        Offset: 2,
@@ -496,10 +496,10 @@ func TestSplitEntitiesCutsAnEntityAcrossASplit(t *testing.T) {
                },
        }, 5)
 
-       require.Equal(t, []botPart{
+       require.Equal(t, []TelegramPart{
                {
                        text: "aabbb",
-                       entities: []botEntity{
+                       entities: []TelegramEntity{
                                {
                                        Type:   "bold",
                                        Offset: 2,
@@ -509,7 +509,7 @@ func TestSplitEntitiesCutsAnEntityAcrossASplit(t *testing.T) {
                },
                {
                        text: "bcc",
-                       entities: []botEntity{
+                       entities: []TelegramEntity{
                                {
                                        Type:   "bold",
                                        Offset: 0,
@@ -572,14 +572,14 @@ func TestSplitRich(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)
-       parts := splitRich(long, richMessageBytes)
+       parts := telegramSplitRich(long, telegramRichBytes)
        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, ""))
 }
 
@@ -592,12 +592,12 @@ func TestLoadBotConfig(t *testing.T) {
        }{
                {
                        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",
-                       content:   `{"token": "` + testBotToken + `", "owner": 4242}`,
+                       content:   `{"token": "` + telegramTestToken + `", "owner": 4242}`,
                        mode:      0o640,
                        errorText: "mode must be 0600",
                },
@@ -609,13 +609,13 @@ func TestLoadBotConfig(t *testing.T) {
                },
                {
                        name:      "missing owner is refused",
-                       content:   `{"token": "` + testBotToken + `"}`,
+                       content:   `{"token": "` + telegramTestToken + `"}`,
                        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",
                },
@@ -624,47 +624,49 @@ func TestLoadBotConfig(t *testing.T) {
        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))
 
-                       config, found, err := loadBotConfig(dir)
+                       config, found, err := loadTelegramConfig(dir)
                        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)
-                       require.Equal(t, botConfig{
-                               Token: testBotToken,
-                               Owner: testOwner,
+                       require.Equal(t, TelegramConfig{
+                               Token: telegramTestToken,
+                               Owner: telegramTestOwner,
                        }, config)
                })
        }
 
-       _, found, err := loadBotConfig(t.TempDir())
+       _, found, err := loadTelegramConfig(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) {
+// 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()
 
        logs := &syncBuffer{}
-       bot := newTelegramBot(newBotAPI(server.URL, testBotToken), testOwner, filepath.Join(t.TempDir(), offsetFileName), log.New(logs, "", 0))
+       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
 }
 
-// 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 {