+// 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.
+func (b *TelegramAPI) GetFile(ctx context.Context, id string, limit int64, tooLarge error) (data []byte, err error) {