From: Anderson A. Giacomolli Date: Sat, 7 Nov 2026 16:20:24 +0000 (-0300) Subject: Name and order the Telegram bot code X-Git-Url: https://git.giacomolli.uk/onechat/commitdiff_plain/09cde69fdffe2721434392036f98dc241177f5a1?ds=sidebyside;hp=fb242538c1b012b0faf3cc4045a9b1c370f1ef42 Name and order the Telegram bot code 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 --- diff --git a/daemon.go b/daemon.go index b37e0cf..5486843 100644 --- 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() diff --git a/daemon_test.go b/daemon_test.go index 47fb9a5..4bcc05a 100644 --- a/daemon_test.go +++ b/daemon_test.go @@ -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() diff --git a/telegram.go b/telegram.go index dd62ef0..4c16211 100644 --- a/telegram.go +++ b/telegram.go @@ -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, }) diff --git a/botapi.go b/telegram_api.go similarity index 51% rename from botapi.go rename to telegram_api.go index 2da945e..558d006 100644 --- a/botapi.go +++ b/telegram_api.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"` } diff --git a/botapi_test.go b/telegram_api_test.go similarity index 68% rename from botapi_test.go rename to telegram_api_test.go index fb57b45..bbfd93e 100644 --- a/botapi_test.go +++ b/telegram_api_test.go @@ -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, } diff --git a/markdown.go b/telegram_markdown.go similarity index 52% rename from markdown.go rename to telegram_markdown.go index 1a2f4fe..54bfbcb 100644 --- a/markdown.go +++ b/telegram_markdown.go @@ -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)) } diff --git a/markdown_test.go b/telegram_markdown_test.go similarity index 89% rename from markdown_test.go rename to telegram_markdown_test.go index cca7db0..e2a2530 100644 --- a/markdown_test.go +++ b/telegram_markdown_test.go @@ -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) }) diff --git a/telegram_test.go b/telegram_test.go index df35e0d..9111d13 100644 --- a/telegram_test.go +++ b/telegram_test.go @@ -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 {