X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/1e2df788bbb17653a6df44acd41da2edb109d403..48d7bce28b18ecef4c52c98821675605464cecb9:/telegram_api.go diff --git a/telegram_api.go b/telegram_api.go index 24f02ac..a04b2fa 100644 --- a/telegram_api.go +++ b/telegram_api.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "net/url" + "path" "strings" "time" ) @@ -40,10 +41,9 @@ const ( // 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, + base: base, + token: token, + http: &http.Client{}, } } @@ -52,11 +52,13 @@ func newTelegramAPI(base, token string) *TelegramAPI { // 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 } @@ -70,18 +72,16 @@ func telegramRetryable(err error) (bool, time.Duration) { // 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. +// so no error of TelegramAPI holds a URL, a net/http error, or the token. type TelegramAPI struct { - base string - token string - http *http.Client - sendTimeout time.Duration + base string + token string + http *http.Client } // GetUpdates waits for the updates from offset on, up to telegramPollSeconds, @@ -89,6 +89,7 @@ type TelegramAPI struct { // 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, @@ -98,6 +99,66 @@ func (b *TelegramAPI) GetUpdates(ctx context.Context, offset int64) ([]TelegramU 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, telegramSendTimeout, "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, telegramSendTimeout) + 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, telegramSendTimeout) + } + + defer func() { + closeErr := response.Body.Close() + if closeErr != nil { + err = errors.Join(err, b.networkError("close image download", closeErr, telegramSendTimeout)) + } + }() + + 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, telegramSendTimeout) + } + + 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 @@ -112,14 +173,14 @@ func (b *TelegramAPI) SendMessage(ctx context.Context, chat int64, text string, params["entities"] = entities } - return b.call(ctx, b.sendTimeout, "sendMessage", params, nil) + return b.call(ctx, telegramSendTimeout, "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{ + return b.call(ctx, telegramSendTimeout, "sendRichMessage", map[string]any{ "chat_id": chat, "rich_message": map[string]any{ "markdown": markdown, @@ -130,7 +191,7 @@ func (b *TelegramAPI) SendRichMessage(ctx context.Context, chat int64, markdown // 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{ + return b.call(ctx, telegramSendTimeout, "sendChatAction", map[string]any{ "chat_id": chat, "action": "typing", }, nil) @@ -155,6 +216,7 @@ func (b *TelegramAPI) call(ctx context.Context, timeout time.Duration, method st } request.Header.Set("Content-Type", "application/json") + response, err := b.http.Do(request) if err != nil { return b.networkError("no answer", err, timeout) @@ -163,10 +225,11 @@ func (b *TelegramAPI) call(ctx context.Context, timeout time.Duration, method st data, err := io.ReadAll(io.LimitReader(response.Body, telegramAnswerBytes)) err = errors.Join(err, response.Body.Close()) if err != nil { - return b.networkError("the answer broke off", err, timeout) + 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{ @@ -176,7 +239,7 @@ func (b *TelegramAPI) call(ctx context.Context, timeout time.Duration, method st } if err != nil { - return b.networkError("the answer is not valid JSON", err, timeout) + return b.networkError("invalid JSON", err, timeout) } if !answer.OK { @@ -194,7 +257,7 @@ func (b *TelegramAPI) call(ctx context.Context, timeout time.Duration, method st err = json.Unmarshal(answer.Result, result) if err != nil { - return b.networkError("the result is not valid", err, timeout) + return b.networkError("invalid result", err, timeout) } return nil @@ -221,6 +284,7 @@ func (b *TelegramAPI) networkError(what string, err error, timeout time.Duration // cause is kept. func (b *TelegramAPI) redact(err error) string { var urlError *url.Error + if errors.As(err, &urlError) { err = urlError.Err } @@ -240,10 +304,10 @@ func (e *TelegramNetworkError) Error() string { } // TelegramError is an answer of the Bot API that tells of a failure. Its text -// gives the HTTP status and the description, such as "Telegram answered 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. +// 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 @@ -252,7 +316,7 @@ type TelegramError struct { } func (e *TelegramError) Error() string { - return fmt.Sprintf("Telegram answered %d %s", e.Status, e.Description) + return fmt.Sprintf("%d %s", e.Status, e.Description) } // TelegramUpdate is one update of getUpdates. Message is nil for the kinds of @@ -284,12 +348,29 @@ type TelegramAnswer struct { } `json:"parameters"` } -// TelegramMessage is a message to the bot. Text is empty for a message that is -// not text, such as a photo. +// 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"` + 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