package main import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" ) const ( // pollSeconds is how long one getUpdates call waits for an update. pollSeconds = 50 // pollSlack is how much longer than pollSeconds a getUpdates call can // take before the bot gives it up. pollSlack = 20 * time.Second // sendTimeout is how long a sendMessage 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 // botAnswerBytes is the most bytes of one answer of the Bot API. botAnswerBytes = 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") // 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 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. type botError struct { Method string Status int Description string RetryAfter time.Duration } // 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"` } // 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"` } // 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"` } // 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"` } // botUser is the sender of a message. type botUser struct { ID int64 `json:"id"` } // 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, } } // 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) defer cancel() var updates []botUpdate err := b.call(ctx, "getUpdates", map[string]any{ "offset": offset, "timeout": pollSeconds, "allowed_updates": []string{"message"}, }, &updates) return updates, err } // SendMessage sends text to chat as plain text, with no parse mode. A call that // takes longer than sendTimeout fails with errBotNetwork. func (b *botAPI) SendMessage(ctx context.Context, chat int64, text string) error { ctx, cancel := context.WithTimeout(ctx, b.sendTimeout) defer cancel() return b.call(ctx, "sendMessage", map[string]any{ "chat_id": chat, "text": text, }, nil) } // 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 { ctx, cancel := context.WithTimeout(ctx, b.sendTimeout) defer cancel() return b.call(ctx, "sendChatAction", map[string]any{ "chat_id": chat, "action": "typing", }, nil) } // 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 { body, err := json.Marshal(params) if err != nil { return fmt.Errorf("%s: encode request: %w", method, err) } request, err := http.NewRequestWithContext(ctx, http.MethodPost, b.base+"/bot"+b.token+"/"+method, bytes.NewReader(body)) if err != nil { return fmt.Errorf("%s: make request: %s", method, b.redact(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)) } data, err := io.ReadAll(io.LimitReader(response.Body, botAnswerBytes)) err = errors.Join(err, response.Body.Close()) if err != nil { return fmt.Errorf("%s: %w: read answer: %s", method, errBotNetwork, b.redact(err)) } var answer botAnswer err = json.Unmarshal(data, &answer) if err != nil && response.StatusCode != http.StatusOK { return &botError{ 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)) } if !answer.OK { return &botError{ Method: method, Status: response.StatusCode, Description: b.redact(errors.New(answer.Description)), RetryAfter: time.Duration(answer.Parameters.RetryAfter) * time.Second, } } if result == nil { return nil } err = json.Unmarshal(answer.Result, result) if err != nil { return fmt.Errorf("%s: %w: decode result: %s", method, errBotNetwork, 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 { 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 { 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 } var answer *botError if errors.As(err, &answer) { return answer.Status == http.StatusTooManyRequests || answer.Status >= 500, answer.RetryAfter } return false, 0 }