return err
}
- botSettings, hasBot, err := loadBotConfig(dir)
+ botSettings, hasBot, err := loadTelegramConfig(dir)
if err != nil {
return err
}
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())
// 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()
}
// 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()
)
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)
}
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")
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":
// 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()
}
// 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
case "busy":
busy = true
b.typing(ctx)
- ticker.Reset(typingInterval)
+ ticker.Reset(telegramTypingInterval)
case "idle":
busy = false
default:
}
}
+// 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()
// 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,
})
}
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)
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")
switch {
case message.Text == "":
- b.notice(textOnly)
+ b.notice(telegramTextOnly)
case strings.TrimSpace(message.Text) == stopCommand:
if !l.Interrupt() {
b.notice("No turn runs.")
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
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)
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 {
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)
}
}
}
-// 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:]
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; {
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
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)
kept = append(kept, entity)
}
- parts = append(parts, botPart{
+ parts = append(parts, TelegramPart{
text: part,
entities: kept,
})
)
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)
// 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()
}
// 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()
}
// 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()
// 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)
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),
}
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)),
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
// 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
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"`
}
)
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)
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,
}
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,
},
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
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",
}))
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()
}
// 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 {
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
return
}
- call := fakeBotCall{
+ call := TelegramFakeCall{
method: method,
params: params,
}
}
// record keeps call.
-func (f *fakeBot) record(call fakeBotCall) {
+func (f *TelegramFake) record(call TelegramFakeCall) {
f.mutex.Lock()
defer f.mutex.Unlock()
// 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 {
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,
}
"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
//
// 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 {
}
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,
case heading != "":
start := out.units
out.inline(heading)
- out.entity(botEntity{
+ out.entity(TelegramEntity{
Type: "bold",
Offset: start,
Length: out.units - start,
}
}
- 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
}
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)
}
// 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
continue
}
- span, found := markSpan(line, index)
+ span, found := telegramMarkSpan(line, index)
if !found {
index++
continue
m.inline(span.content)
}
- m.entity(botEntity{
+ m.entity(TelegramEntity{
Type: span.kind,
Offset: start,
Length: m.units - start,
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
}
}
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 ""
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 {
}
next += search
- length := backticks(line[next:])
+ length := telegramBackticks(line[next:])
if length != run {
search = next + length
continue
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] {
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,
}, 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++ {
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
}
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))
}
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,
name: "double underscores give bold",
markdown: "__bold__",
text: "bold",
- entities: []botEntity{
+ entities: []TelegramEntity{
{
Type: "bold",
Offset: 0,
name: "single asterisk gives italic",
markdown: "an *odd* word",
text: "an odd word",
- entities: []botEntity{
+ entities: []TelegramEntity{
{
Type: "italic",
Offset: 3,
name: "single underscore gives italic",
markdown: "an _odd_ word",
text: "an odd word",
- entities: []botEntity{
+ entities: []TelegramEntity{
{
Type: "italic",
Offset: 3,
name: "double tildes give strikethrough",
markdown: "~~old~~ new",
text: "old new",
- entities: []botEntity{
+ entities: []TelegramEntity{
{
Type: "strikethrough",
Offset: 0,
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,
name: "double backticks hold a backtick",
markdown: "``a ` b``",
text: "a ` b",
- entities: []botEntity{
+ entities: []TelegramEntity{
{
Type: "code",
Offset: 0,
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,
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,
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,
name: "code block without a language gives pre",
markdown: "```\nls -l\n```",
text: "ls -l",
- entities: []botEntity{
+ entities: []TelegramEntity{
{
Type: "pre",
Offset: 0,
name: "heading becomes bold with its inline marks",
markdown: "## The `plan`\nnext",
text: "The plan\nnext",
- entities: []botEntity{
+ entities: []TelegramEntity{
{
Type: "bold",
Offset: 0,
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,
name: "character outside the BMP moves the offset by two",
markdown: "\U0001F600 **b**",
text: "\U0001F600 b",
- entities: []botEntity{
+ entities: []TelegramEntity{
{
Type: "bold",
Offset: 3,
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)
})
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)
}
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) {
<-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) {
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)
}
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, ""
}
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
}
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) {
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,
{
method: "sendRichMessage",
params: map[string]any{
- "chat_id": float64(testOwner),
+ "chat_id": float64(telegramTestOwner),
"rich_message": map[string]any{
"markdown": reply,
},
{
method: "sendMessage",
params: map[string]any{
- "chat_id": float64(testOwner),
- "text": textOnly,
+ "chat_id": float64(telegramTestOwner),
+ "text": telegramTextOnly,
},
ok: true,
},
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",
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()
}
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,
}
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,
})
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) {
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,
},
}, 5)
- require.Equal(t, []botPart{
+ require.Equal(t, []TelegramPart{
{
text: "aabbb",
- entities: []botEntity{
+ entities: []TelegramEntity{
{
Type: "bold",
Offset: 2,
},
{
text: "bcc",
- entities: []botEntity{
+ entities: []TelegramEntity{
{
Type: "bold",
Offset: 0,
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, ""))
}
}{
{
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",
},
},
{
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",
},
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 {