X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/496569ab5b179932c60b2924bf4c61176c8be004..b961d7cdc59213673c3e4f86d3dae99daf0a1935:/telegram_api.go?ds=sidebyside diff --git a/telegram_api.go b/telegram_api.go index 0d44852..ce4e0e0 100644 --- a/telegram_api.go +++ b/telegram_api.go @@ -173,6 +173,106 @@ func (b *TelegramAPI) GetUpdates(ctx context.Context, offset int64) ([]TelegramU return updates, err } +// call sends one call of method with the JSON params and decodes its result +// into result, when result is not nil. The call can take up to timeout. Its +// request setup errors name the method. Network and answer errors give a safe +// cause, so the caller can add the operation without exposing the request URL. +func (b *TelegramAPI) call(ctx context.Context, timeout time.Duration, method string, params, result any) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + body, err := json.Marshal(params) + if err != nil { + return fmt.Errorf("%s: encode request: %w", method, err) + } + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, b.base+"/bot"+b.token+"/"+method, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("%s: make request: %s", method, b.redact(err)) + } + + request.Header.Set("Content-Type", "application/json") + + return b.answer(request, timeout, result) +} + +// redact returns the text of err without a URL and without the token. A +// *url.Error holds the URL of the call, which holds the token, so only its +// cause is kept. +func (b *TelegramAPI) redact(err error) string { + var urlError *url.Error + + if errors.As(err, &urlError) { + err = urlError.Err + } + + return strings.ReplaceAll(err.Error(), b.token, "TOKEN") +} + +// 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) + } + + data, err := io.ReadAll(io.LimitReader(response.Body, telegramAnswerBytes)) + err = errors.Join(err, response.Body.Close()) + if err != nil { + 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{ + Status: response.StatusCode, + Description: http.StatusText(response.StatusCode), + } + } + + if err != nil { + return b.networkError("invalid JSON", err, timeout) + } + + if !answer.OK { + return &TelegramError{ + Status: response.StatusCode, + Description: b.redact(errors.New(answer.Description)), + RetryAfter: time.Duration(answer.Parameters.RetryAfter) * time.Second, + Refused: true, + } + } + + if result == nil { + return nil + } + + err = json.Unmarshal(answer.Result, result) + if err != nil { + return b.networkError("invalid result", err, timeout) + } + + return nil +} + +// networkError returns the TelegramNetworkError of err, which happened at the +// step that what names. When the call used all of its time timeout, the cause +// is "no answer in TIMEOUT", because the Go text of a deadline does not tell +// how long the wait was. +func (b *TelegramAPI) networkError(what string, err error, timeout time.Duration) error { + if errors.Is(err, context.DeadlineExceeded) { + return &TelegramNetworkError{ + Cause: "no answer in " + timeout.String(), + } + } + + return &TelegramNetworkError{ + Cause: what + ": " + b.redact(err), + } +} + // GetFile downloads bounded media through the authenticated file endpoint. // Errors never include its token or URL, and the body is limited before // storage. @@ -331,106 +431,6 @@ func (b *TelegramAPI) SendTyping(ctx context.Context, chat int64) error { }, nil) } -// call sends one call of method with the JSON params and decodes its result -// into result, when result is not nil. The call can take up to timeout. Its -// request setup errors name the method. Network and answer errors give a safe -// cause, so the caller can add the operation without exposing the request URL. -func (b *TelegramAPI) call(ctx context.Context, timeout time.Duration, method string, params, result any) error { - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - body, err := json.Marshal(params) - if err != nil { - return fmt.Errorf("%s: encode request: %w", method, err) - } - - request, err := http.NewRequestWithContext(ctx, http.MethodPost, b.base+"/bot"+b.token+"/"+method, bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("%s: make request: %s", method, b.redact(err)) - } - - 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) - } - - data, err := io.ReadAll(io.LimitReader(response.Body, telegramAnswerBytes)) - err = errors.Join(err, response.Body.Close()) - if err != nil { - 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{ - Status: response.StatusCode, - Description: http.StatusText(response.StatusCode), - } - } - - if err != nil { - return b.networkError("invalid JSON", err, timeout) - } - - if !answer.OK { - return &TelegramError{ - Status: response.StatusCode, - Description: b.redact(errors.New(answer.Description)), - RetryAfter: time.Duration(answer.Parameters.RetryAfter) * time.Second, - Refused: true, - } - } - - if result == nil { - return nil - } - - err = json.Unmarshal(answer.Result, result) - if err != nil { - return b.networkError("invalid result", err, timeout) - } - - return nil -} - -// networkError returns the TelegramNetworkError of err, which happened at the -// step that what names. When the call used all of its time timeout, the cause -// is "no answer in TIMEOUT", because the Go text of a deadline does not tell -// how long the wait was. -func (b *TelegramAPI) networkError(what string, err error, timeout time.Duration) error { - if errors.Is(err, context.DeadlineExceeded) { - return &TelegramNetworkError{ - Cause: "no answer in " + timeout.String(), - } - } - - return &TelegramNetworkError{ - Cause: what + ": " + b.redact(err), - } -} - -// redact returns the text of err without a URL and without the token. A -// *url.Error holds the URL of the call, which holds the token, so only its -// cause is kept. -func (b *TelegramAPI) redact(err error) string { - var urlError *url.Error - - if errors.As(err, &urlError) { - err = urlError.Err - } - - return strings.ReplaceAll(err.Error(), b.token, "TOKEN") -} - func (e *TelegramNetworkError) Error() string { return e.Cause } @@ -439,6 +439,16 @@ func (e *TelegramError) Error() string { return fmt.Sprintf("%d %s", e.Status, e.Description) } +// telegramRejected tells if the Bot API refused a call with ok false and HTTP +// 400 or 404. Then the call did nothing: 400 refuses the request, and 404 tells +// that the method is not there. An error page with no Bot API answer does not +// 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) +} + // telegramRetryable tells if a later try of a failed Bot API call can work: // after a network error, HTTP 429, or a server error. It returns the wait that // the Bot API asks for, or 0. @@ -457,13 +467,3 @@ func telegramRetryable(err error) (bool, time.Duration) { return false, 0 } - -// telegramRejected tells if the Bot API refused a call with ok false and HTTP -// 400 or 404. Then the call did nothing: 400 refuses the request, and 404 tells -// that the method is not there. An error page with no Bot API answer does not -// 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) -}