X-Git-Url: http://git.giacomolli.uk/onechat/blobdiff_plain/fb242538c1b012b0faf3cc4045a9b1c370f1ef42:/botapi.go..09cde69fdffe2721434392036f98dc241177f5a1:/telegram_api.go?ds=sidebyside 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"` }