X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/522d7fd117285e4e4ce3ea5ec9b5a1722521b681..add3cf6416e33c24331fd1e90e99284f44d2aae2:/telegram_api.go?ds=sidebyside diff --git a/telegram_api.go b/telegram_api.go index 07d2bf1..8f78387 100644 --- a/telegram_api.go +++ b/telegram_api.go @@ -9,30 +9,39 @@ import ( "io" "net/http" "net/url" + "path" "strings" "time" ) const ( - // telegramPollSeconds is how long one getUpdates call waits for an - // update. - telegramPollSeconds = 50 + // telegramUpdateWaitTimeout is how long one getUpdates call waits for + // an update. + telegramUpdateWaitTimeout = 50 * time.Second - // telegramPollSlack is how much longer than telegramPollSeconds a + // telegramPollSlack is how much longer than telegramUpdateWaitTimeout 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. + telegramPollTimeout = telegramUpdateWaitTimeout + telegramPollSlack + + // telegramSendTimeout is how long a sendMessage or sendRichMessage 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 + // telegramFileLookupTimeout is the longest wait for image file details. + telegramFileLookupTimeout = 30 * time.Second + + // telegramImageDownloadTimeout is the longest wait for image bytes. + telegramImageDownloadTimeout = 30 * time.Second + + // telegramTypingTimeout is the longest wait to show the typing action. + telegramTypingTimeout = 30 * time.Second + // telegramAnswerBytes is the most bytes of one answer of the Bot API. telegramAnswerBytes = 16 << 20 ) @@ -40,10 +49,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{}, } } @@ -77,31 +85,88 @@ func telegramRejected(err error) bool { } // 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, -// and returns them. Telegram forgets each update before offset. Only new -// messages are asked for. +// GetUpdates waits for the updates from offset on, up to +// telegramUpdateWaitTimeout, 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, + "timeout": int(telegramUpdateWaitTimeout / time.Second), "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, telegramFileLookupTimeout, "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, telegramImageDownloadTimeout) + 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, telegramImageDownloadTimeout) + } + + defer func() { + closeErr := response.Body.Close() + if closeErr != nil { + err = errors.Join(err, b.networkError("close image download", closeErr, telegramImageDownloadTimeout)) + } + }() + + 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, telegramImageDownloadTimeout) + } + + 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 @@ -116,14 +181,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, @@ -132,9 +197,10 @@ 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. +// that takes longer than telegramTypingTimeout 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, telegramTypingTimeout, "sendChatAction", map[string]any{ "chat_id": chat, "action": "typing", }, nil) @@ -291,12 +357,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