X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/48d7bce28b18ecef4c52c98821675605464cecb9..7b8d8e8f4b57bd51fd6d0f0d926b821f06b0e1f0:/telegram_api.go?ds=sidebyside diff --git a/telegram_api.go b/telegram_api.go index a04b2fa..508010e 100644 --- a/telegram_api.go +++ b/telegram_api.go @@ -7,33 +7,43 @@ import ( "errors" "fmt" "io" + "mime/multipart" "net/http" "net/url" "path" + "strconv" "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 ) @@ -84,15 +94,15 @@ type TelegramAPI struct { 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) @@ -107,7 +117,7 @@ func (b *TelegramAPI) GetImage(ctx context.Context, id string) (data []byte, err Size int64 `json:"file_size"` } - err = b.call(ctx, telegramSendTimeout, "getFile", map[string]any{"file_id": id}, &file) + 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) } @@ -120,7 +130,7 @@ func (b *TelegramAPI) GetImage(ctx context.Context, id string) (data []byte, err return nil, errors.New("invalid image file path") } - ctx, cancel := context.WithTimeout(ctx, telegramSendTimeout) + ctx, cancel := context.WithTimeout(ctx, telegramImageDownloadTimeout) defer cancel() request, err := http.NewRequestWithContext(ctx, http.MethodGet, b.base+"/file/bot"+b.token+"/"+file.Path, nil) @@ -130,13 +140,13 @@ func (b *TelegramAPI) GetImage(ctx context.Context, id string) (data []byte, err response, err := b.http.Do(request) if err != nil { - return nil, b.networkError("image download failed", err, telegramSendTimeout) + 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, telegramSendTimeout)) + err = errors.Join(err, b.networkError("close image download", closeErr, telegramImageDownloadTimeout)) } }() @@ -149,7 +159,7 @@ func (b *TelegramAPI) GetImage(ctx context.Context, id string) (data []byte, err data, err = io.ReadAll(io.LimitReader(response.Body, imageMaxBytes+1)) if err != nil { - return nil, b.networkError("image cut off", err, telegramSendTimeout) + return nil, b.networkError("image cut off", err, telegramImageDownloadTimeout) } if len(data) > imageMaxBytes { @@ -188,10 +198,69 @@ 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 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, telegramSendTimeout, "sendChatAction", map[string]any{ + return b.call(ctx, telegramTypingTimeout, "sendChatAction", map[string]any{ "chat_id": chat, "action": "typing", }, nil) @@ -217,6 +286,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)