"errors"
"fmt"
"io"
+ "mime/multipart"
"net/http"
"net/url"
"path"
+ "strconv"
"strings"
"time"
)
// 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 image bytes.
+ telegramFileDownloadTimeout = 30 * time.Second
// telegramTypingTimeout is the longest wait to show the typing action.
telegramTypingTimeout = 30 * time.Second
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"`
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))
}
}()
}
}
- 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
}, 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.
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)
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.