package main import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "path" "strings" "time" ) const ( // telegramPollSeconds is how long one getUpdates call waits for an // update. telegramPollSeconds = 50 // telegramPollSlack is how much longer than telegramPollSeconds a // getUpdates call can take before the bot gives it up. telegramPollSlack = 20 * time.Second // telegramPollTimeout is how long one getUpdates call can take. telegramPollTimeout = telegramPollSeconds*time.Second + telegramPollSlack // 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. telegramSendTimeout = 30 * time.Second // telegramAnswerBytes is the most bytes of one answer of the Bot API. telegramAnswerBytes = 16 << 20 ) // 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, } } // 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) { var network *TelegramNetworkError if errors.As(err, &network) { return true, 0 } var answer *TelegramError if errors.As(err, &answer) { return answer.Status == http.StatusTooManyRequests || answer.Status >= 500, answer.RetryAfter } return false, 0 } // 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) } // 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. Its // sendTimeout field 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 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) { var updates []TelegramUpdate err := b.call(ctx, telegramPollTimeout, "getUpdates", map[string]any{ "offset": offset, "timeout": telegramPollSeconds, "allowed_updates": []string{"message"}, }, &updates) return updates, err } // GetImage downloads a file through the authenticated file endpoint. Errors // never include its token or URL, and the body is limited before storage. func (b *TelegramAPI) GetImage(ctx context.Context, id string) (data []byte, err error) { var file struct { Path string `json:"file_path"` Size int64 `json:"file_size"` } err = b.call(ctx, b.sendTimeout, "getFile", map[string]any{"file_id": id}, &file) if err != nil { return nil, fmt.Errorf("get image file: %w", err) } if file.Size > imageMaxBytes { return nil, errImageSize } if file.Path == "" || path.Clean(file.Path) != file.Path || strings.HasPrefix(file.Path, "/") || strings.HasPrefix(file.Path, "../") || strings.ContainsAny(file.Path, "?%#\\") { return nil, errors.New("invalid image file path") } ctx, cancel := context.WithTimeout(ctx, b.sendTimeout) defer cancel() request, err := http.NewRequestWithContext(ctx, http.MethodGet, b.base+"/file/bot"+b.token+"/"+file.Path, nil) if err != nil { return nil, fmt.Errorf("make image request: %s", b.redact(err)) } response, err := b.http.Do(request) if err != nil { return nil, b.networkError("image download failed", err, b.sendTimeout) } defer func() { closeErr := response.Body.Close() if closeErr != nil { err = errors.Join(err, b.networkError("close image download", closeErr, b.sendTimeout)) } }() if response.StatusCode != http.StatusOK { return nil, &TelegramError{ Status: response.StatusCode, Description: http.StatusText(response.StatusCode), } } data, err = io.ReadAll(io.LimitReader(response.Body, imageMaxBytes+1)) if err != nil { return nil, b.networkError("image cut off", err, b.sendTimeout) } if len(data) > imageMaxBytes { return nil, errImageSize } return data, nil } // 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 telegramSendTimeout fails with a // TelegramNetworkError. func (b *TelegramAPI) SendMessage(ctx context.Context, chat int64, text string, entities []TelegramEntity) error { params := map[string]any{ "chat_id": chat, "text": text, } if len(entities) > 0 { params["entities"] = entities } return b.call(ctx, b.sendTimeout, "sendMessage", params, nil) } // SendRichMessage sends markdown to chat as a rich message, which Telegram // renders itself, tables included. A call that takes longer than // telegramSendTimeout fails with a TelegramNetworkError. func (b *TelegramAPI) SendRichMessage(ctx context.Context, chat int64, markdown string) error { return b.call(ctx, b.sendTimeout, "sendRichMessage", map[string]any{ "chat_id": chat, "rich_message": map[string]any{ "markdown": markdown, }, }, nil) } // SendTyping shows the typing action in chat for about five seconds. A call // that takes longer than telegramSendTimeout fails with a TelegramNetworkError. func (b *TelegramAPI) SendTyping(ctx context.Context, chat int64) error { return b.call(ctx, b.sendTimeout, "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. The call can take up to timeout. Its // errors tell the cause in plain words, with no method name, so that a log line // can say first what the call was for. func (b *TelegramAPI) call(ctx context.Context, timeout time.Duration, method string, params, result any) error { ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() 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 b.networkError("no answer", err, timeout) } data, err := io.ReadAll(io.LimitReader(response.Body, telegramAnswerBytes)) err = errors.Join(err, response.Body.Close()) if err != nil { return b.networkError("answer cut off", err, timeout) } var answer TelegramAnswer err = json.Unmarshal(data, &answer) if err != nil && response.StatusCode != http.StatusOK { return &TelegramError{ Status: response.StatusCode, Description: http.StatusText(response.StatusCode), } } if err != nil { return b.networkError("invalid JSON", err, timeout) } if !answer.OK { return &TelegramError{ Status: response.StatusCode, Description: b.redact(errors.New(answer.Description)), RetryAfter: time.Duration(answer.Parameters.RetryAfter) * time.Second, Refused: true, } } if result == nil { return nil } err = json.Unmarshal(answer.Result, result) if err != nil { return b.networkError("invalid result", err, timeout) } return nil } // networkError returns the TelegramNetworkError of err, which happened at the // step that what names. When the call used all of its time timeout, the cause // is "no answer in TIMEOUT", because the Go text of a deadline does not tell // how long the wait was. func (b *TelegramAPI) networkError(what string, err error, timeout time.Duration) error { if errors.Is(err, context.DeadlineExceeded) { return &TelegramNetworkError{ Cause: "no answer in " + timeout.String(), } } return &TelegramNetworkError{ Cause: what + ": " + b.redact(err), } } // 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 *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") } // TelegramNetworkError tells that a Bot API call got no valid answer, such as // after a network error. A later call can work. Cause tells why in plain words, // with no URL and no token. type TelegramNetworkError struct { Cause string } func (e *TelegramNetworkError) Error() string { return e.Cause } // TelegramError is an answer of the Bot API that tells of a failure. Its text // gives the HTTP status and the description, such as "502 Bad Gateway". // 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 { Status int Description string RetryAfter time.Duration Refused bool } func (e *TelegramError) Error() string { return fmt.Sprintf("%d %s", e.Status, e.Description) } // 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"` } // 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"` } // 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"` } // TelegramMessage holds the text, caption, and image sizes of a bot message. type TelegramMessage struct { Chat TelegramChat `json:"chat"` From *TelegramUser `json:"from"` Text string `json:"text"` Caption string `json:"caption"` Photo []TelegramPhoto `json:"photo"` Document *TelegramDocument `json:"document"` } // TelegramPhoto is one supplied size of a photo. type TelegramPhoto struct { FileID string `json:"file_id"` Width int `json:"width"` Height int `json:"height"` FileSize int64 `json:"file_size"` } // TelegramDocument is a file sent without photo compression. type TelegramDocument struct { FileID string `json:"file_id"` MimeType string `json:"mime_type"` FileSize int64 `json:"file_size"` } // 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"` }