X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/add3cf6416e33c24331fd1e90e99284f44d2aae2..1a1e3835458e37b4b56897d6ff7804660798d60c:/telegram_api.go diff --git a/telegram_api.go b/telegram_api.go index 8f78387..6967c99 100644 --- a/telegram_api.go +++ b/telegram_api.go @@ -7,9 +7,11 @@ import ( "errors" "fmt" "io" + "mime/multipart" "net/http" "net/url" "path" + "strconv" "strings" "time" ) @@ -33,11 +35,12 @@ const ( // 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 is the longest wait for media file details. telegramFileLookupTimeout = 30 * time.Second - // telegramImageDownloadTimeout is the longest wait for image bytes. - telegramImageDownloadTimeout = 30 * time.Second + // telegramFileDownloadTimeout is the longest wait for downloaded media + // bytes. + telegramFileDownloadTimeout = 30 * time.Second // telegramTypingTimeout is the longest wait to show the typing action. telegramTypingTimeout = 30 * time.Second @@ -107,9 +110,10 @@ 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) { +// GetFile downloads bounded media through the authenticated file endpoint. +// Errors never include its token or URL, and the body is limited before +// storage. +func (b *TelegramAPI) GetFile(ctx context.Context, id string, limit int64, tooLarge error) (data []byte, err error) { var file struct { Path string `json:"file_path"` Size int64 `json:"file_size"` @@ -117,34 +121,34 @@ func (b *TelegramAPI) GetImage(ctx context.Context, id string) (data []byte, err 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) + return nil, fmt.Errorf("get media file: %w", err) } - if file.Size > imageMaxBytes { - return nil, errImageSize + if file.Size > limit { + return nil, tooLarge } 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") + return nil, errors.New("invalid media file path") } - ctx, cancel := context.WithTimeout(ctx, telegramImageDownloadTimeout) + ctx, cancel := context.WithTimeout(ctx, telegramFileDownloadTimeout) 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)) + return nil, fmt.Errorf("make media request: %s", b.redact(err)) } response, err := b.http.Do(request) if err != nil { - return nil, b.networkError("image download failed", err, telegramImageDownloadTimeout) + return nil, b.networkError("media download failed", err, telegramFileDownloadTimeout) } defer func() { closeErr := response.Body.Close() if closeErr != nil { - err = errors.Join(err, b.networkError("close image download", closeErr, telegramImageDownloadTimeout)) + err = errors.Join(err, b.networkError("close media download", closeErr, telegramFileDownloadTimeout)) } }() @@ -155,13 +159,13 @@ func (b *TelegramAPI) GetImage(ctx context.Context, id string) (data []byte, err } } - data, err = io.ReadAll(io.LimitReader(response.Body, imageMaxBytes+1)) + data, err = io.ReadAll(io.LimitReader(response.Body, limit+1)) if err != nil { - return nil, b.networkError("image cut off", err, telegramImageDownloadTimeout) + return nil, b.networkError("media cut off", err, telegramFileDownloadTimeout) } - if len(data) > imageMaxBytes { - return nil, errImageSize + if int64(len(data)) > limit { + return nil, tooLarge } return data, nil @@ -196,6 +200,64 @@ func (b *TelegramAPI) SendRichMessage(ctx context.Context, chat int64, markdown }, nil) } +// SendImage uploads an image to the owner as a photo, or as a document when the +// Bot API refuses the photo. Network failures do not trigger a second send. +func (b *TelegramAPI) SendImage(ctx context.Context, chat int64, name string, data []byte, caption string) error { + err := b.sendImage(ctx, "sendPhoto", "photo", chat, name, data, caption) + if telegramRejected(err) { + err = b.sendImage(ctx, "sendDocument", "document", chat, name, data, caption) + } + + return err +} + +func (b *TelegramAPI) sendImage(ctx context.Context, method, field string, chat int64, name string, data []byte, caption string) error { + ctx, cancel := context.WithTimeout(ctx, telegramSendTimeout) + defer cancel() + + var body bytes.Buffer + + writer := multipart.NewWriter(&body) + err := writer.WriteField("chat_id", strconv.FormatInt(chat, 10)) + if err != nil { + return fmt.Errorf("write image chat: %w", err) + } + + err = writer.WriteField("caption", caption) + if err != nil { + return fmt.Errorf("write image caption: %w", err) + } + + part, err := writer.CreateFormFile(field, name) + if err != nil { + return fmt.Errorf("make image part: %w", err) + } + + _, err = part.Write(data) + if err != nil { + return fmt.Errorf("write image part: %w", err) + } + + err = writer.Close() + if err != nil { + return fmt.Errorf("close image form: %w", err) + } + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, b.base+"/bot"+b.token+"/"+method, &body) + if err != nil { + return fmt.Errorf("make image upload: %s", b.redact(err)) + } + + request.Header.Set("Content-Type", writer.FormDataContentType()) + + err = b.answer(request, telegramSendTimeout, nil) + if err != nil { + return fmt.Errorf("upload image: %w", err) + } + + return nil +} + // SendTyping shows the typing action in chat for about five seconds. A call // that takes longer than telegramTypingTimeout fails with a // TelegramNetworkError. @@ -226,6 +288,11 @@ func (b *TelegramAPI) call(ctx context.Context, timeout time.Duration, method st request.Header.Set("Content-Type", "application/json") + return b.answer(request, timeout, result) +} + +// answer reads the bounded Bot API response without keeping its request URL. +func (b *TelegramAPI) answer(request *http.Request, timeout time.Duration, result any) error { response, err := b.http.Do(request) if err != nil { return b.networkError("no answer", err, timeout) @@ -357,7 +424,7 @@ type TelegramAnswer struct { } `json:"parameters"` } -// TelegramMessage holds the text, caption, and image sizes of a bot message. +// TelegramMessage holds text and supported media metadata of a bot message. type TelegramMessage struct { Chat TelegramChat `json:"chat"` From *TelegramUser `json:"from"` @@ -365,6 +432,13 @@ type TelegramMessage struct { Caption string `json:"caption"` Photo []TelegramPhoto `json:"photo"` Document *TelegramDocument `json:"document"` + Voice *TelegramVoice `json:"voice"` +} + +// TelegramVoice identifies a voice note in the authenticated file API. +type TelegramVoice struct { + FileID string `json:"file_id"` + FileSize int64 `json:"file_size"` } // TelegramPhoto is one supplied size of a photo.