v1.0.0 beta 21

This commit is contained in:
2026-03-16 10:39:33 +03:00
parent fb81bb91bd
commit 389ec9f9d7
8 changed files with 125 additions and 86 deletions

View File

@@ -161,27 +161,13 @@ type TelegramRequest[R, P any] struct {
chatId int64
}
// NewRequest and NewRequestWithChatID are DEPRECATED.
// They encourage unsafe, untyped usage and bypass Go's type safety.
// Instead, define explicit, type-safe methods for each Telegram API endpoint.
//
// Example:
//
// func (api *API) SendMessage(ctx context.Context, chatID int64, text string) (Message, error) { ... }
//
// This provides:
//
// ✅ Compile-time validation
// ✅ IDE autocompletion
// ✅ Clear API surface
// ✅ Better error messages
//
// DO NOT use these constructors in production code.
// This can be used ONLY for testing or if you NEED method, that wasn't added as function.
// NewRequest creates an untyped TelegramRequest for the given method and params with no chat ID.
func NewRequest[R, P any](method string, params P) TelegramRequest[R, P] {
return TelegramRequest[R, P]{method, params, 0}
}
// NewRequestWithChatID creates an untyped TelegramRequest with an associated chat ID.
// The chat ID is used for per-chat rate limiting.
func NewRequestWithChatID[R, P any](method string, params P, chatId int64) TelegramRequest[R, P] {
return TelegramRequest[R, P]{method, params, chatId}
}
@@ -191,12 +177,10 @@ func NewRequestWithChatID[R, P any](method string, params P, chatId int64) Teleg
// Must be called within a worker pool context if using DoWithContext.
func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, error) {
var zero R
data, err := json.Marshal(r.params)
reqData, err := json.Marshal(r.params)
if err != nil {
return zero, fmt.Errorf("failed to marshal request: %w", err)
}
buf := bytes.NewBuffer(data)
methodPrefix := ""
if api.useTestServer {
@@ -204,7 +188,7 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro
}
url := fmt.Sprintf("%s/bot%s%s/%s", api.apiUrl, api.token, methodPrefix, r.method)
req, err := http.NewRequestWithContext(ctx, "POST", url, buf)
req, err := http.NewRequestWithContext(ctx, "POST", url, nil)
if err != nil {
return zero, fmt.Errorf("failed to create request: %w", err)
}
@@ -213,7 +197,6 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString))
req.Header.Set("Accept-Encoding", "gzip")
req.ContentLength = int64(len(data))
for {
// Apply rate limiting before making the request
@@ -222,22 +205,25 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro
return zero, err
}
}
buf := bytes.NewBuffer(reqData)
req.Body = io.NopCloser(buf)
req.ContentLength = int64(len(reqData))
api.logger.Debugln("REQ", url, string(data))
api.logger.Debugln("REQ", url, string(reqData))
resp, err := api.client.Do(req)
if err != nil {
return zero, fmt.Errorf("HTTP request failed: %w", err)
}
data, err = readBody(resp.Body)
respData, err := readBody(resp.Body)
_ = resp.Body.Close() // ensure body is closed
if err != nil {
return zero, fmt.Errorf("failed to read response body: %w", err)
}
api.logger.Debugln("RES", r.method, string(data))
api.logger.Debugln("RES", r.method, string(respData))
response, err := parseBody[R](data)
response, err := parseBody[R](respData)
if err != nil {
return zero, fmt.Errorf("failed to parse response: %w", err)
}
@@ -249,10 +235,12 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro
api.logger.Warnf("Rate limited by Telegram, retry after %d seconds (chat: %d)", after, r.chatId)
// Apply cooldown to global or chat-specific limiter
if r.chatId > 0 {
api.Limiter.SetChatLock(r.chatId, after)
} else {
api.Limiter.SetGlobalLock(after)
if api.Limiter != nil {
if r.chatId > 0 {
api.Limiter.SetChatLock(r.chatId, after)
} else {
api.Limiter.SetGlobalLock(after)
}
}
// Wait and retry
@@ -311,21 +299,13 @@ func readBody(body io.ReadCloser) ([]byte, error) {
return io.ReadAll(reader)
}
// parseBody unmarshals Telegram API response and returns structured result.
// Returns ErrRateLimit internally if error_code == 429 — caller must handle via response.Ok check.
// parseBody unmarshals a Telegram API response into a typed ApiResponse.
// Only returns an error on malformed JSON; non-OK responses are left for the caller to handle.
func parseBody[R any](data []byte) (ApiResponse[R], error) {
var resp ApiResponse[R]
err := json.Unmarshal(data, &resp)
if err != nil {
return resp, fmt.Errorf("failed to unmarshal JSON: %w", err)
}
if !resp.Ok {
if resp.ErrorCode == 429 {
return resp, ErrRateLimit // internal use only
}
return resp, fmt.Errorf("[%d] %s", resp.ErrorCode, resp.Description)
}
return resp, nil
}

View File

@@ -3,7 +3,6 @@ package tgapi
import (
"bytes"
"context"
"errors"
"fmt"
"mime/multipart"
"net/http"
@@ -24,13 +23,18 @@ const (
UploaderThumbnailType UploaderFileType = "thumbnail"
)
// UploaderFileType represents the Telegram form field name for a file upload.
type UploaderFileType string
// UploaderFile holds the data and metadata for a single file to be uploaded.
type UploaderFile struct {
filename string
data []byte
field UploaderFileType
}
// NewUploaderFile creates a new UploaderFile, auto-detecting the field type from the file extension.
// If detection is incorrect, use SetType to override.
func NewUploaderFile(name string, data []byte) UploaderFile {
t := uploaderTypeByExt(name)
return UploaderFile{filename: name, data: data, field: t}
@@ -56,6 +60,8 @@ func NewUploader(api *API) *Uploader {
func (u *Uploader) Close() error { return u.logger.Close() }
func (u *Uploader) GetLogger() *slog.Logger { return u.logger }
// UploaderRequest is a multipart file upload request to the Telegram API.
// Use NewUploaderRequest or NewUploaderRequestWithChatID to construct one.
type UploaderRequest[R, P any] struct {
method string
files []UploaderFile
@@ -63,40 +69,30 @@ type UploaderRequest[R, P any] struct {
chatId int64
}
// NewUploaderRequest creates a new multipart upload request with no associated chat ID.
func NewUploaderRequest[R, P any](method string, params P, files ...UploaderFile) UploaderRequest[R, P] {
return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: 0}
}
// NewUploaderRequestWithChatID creates a new multipart upload request with an associated chat ID.
// The chat ID is used for per-chat rate limiting.
func NewUploaderRequestWithChatID[R, P any](method string, params P, chatId int64, files ...UploaderFile) UploaderRequest[R, P] {
return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: chatId}
}
func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, error) {
var zero R
buf, contentType, err := prepareMultipart(r.files, r.params)
if err != nil {
return zero, err
}
methodPrefix := ""
if up.api.useTestServer {
methodPrefix = "/test"
}
url := fmt.Sprintf("%s/bot%s%s/%s", up.api.apiUrl, up.api.token, methodPrefix, r.method)
req, err := http.NewRequestWithContext(ctx, "POST", url, buf)
if err != nil {
return zero, err
}
req.Header.Set("Content-Type", contentType)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString))
req.Header.Set("Accept-Encoding", "gzip")
req.ContentLength = int64(buf.Len())
for {
if up.api.Limiter != nil {
if up.api.dropOverflowLimit {
if !up.api.Limiter.GlobalAllow() {
return zero, errors.New("rate limited")
return zero, utils.ErrDropOverflow
}
} else {
if err := up.api.Limiter.GlobalWait(ctx); err != nil {
@@ -105,6 +101,20 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R,
}
}
buf, contentType, err := prepareMultipart(r.files, r.params)
if err != nil {
return zero, err
}
req, err := http.NewRequestWithContext(ctx, "POST", url, buf)
if err != nil {
return zero, err
}
req.Header.Set("Content-Type", contentType)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString))
req.Header.Set("Accept-Encoding", "gzip")
req.ContentLength = int64(buf.Len())
up.logger.Debugln("UPLOADER REQ", r.method)
resp, err := up.api.client.Do(req)
if err != nil {
@@ -127,10 +137,12 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R,
if response.ErrorCode == 429 && response.Parameters != nil && response.Parameters.RetryAfter != nil {
after := *response.Parameters.RetryAfter
up.logger.Warnf("Rate limited, retry after %d seconds (chat: %d)", after, r.chatId)
if r.chatId > 0 {
up.api.Limiter.SetChatLock(r.chatId, after)
} else {
up.api.Limiter.SetGlobalLock(after)
if up.api.Limiter != nil {
if r.chatId > 0 {
up.api.Limiter.SetChatLock(r.chatId, after)
} else {
up.api.Limiter.SetGlobalLock(after)
}
}
select {
@@ -145,6 +157,9 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R,
return response.Result, nil
}
}
// DoWithContext executes the upload request asynchronously via the worker pool.
// Returns the result or error. Respects context cancellation.
func (r UploaderRequest[R, P]) DoWithContext(ctx context.Context, up *Uploader) (R, error) {
var zero R
@@ -168,10 +183,15 @@ func (r UploaderRequest[R, P]) DoWithContext(ctx context.Context, up *Uploader)
return zero, ErrPoolUnexpected
}
}
// Do executes the upload request synchronously with a background context.
// Use only for simple, non-critical uploads.
func (r UploaderRequest[R, P]) Do(up *Uploader) (R, error) {
return r.DoWithContext(context.Background(), up)
}
// prepareMultipart builds a multipart form body from the given files and params.
// Params are encoded via utils.Encode. The writer boundary is finalized before returning.
func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, string, error) {
buf := bytes.NewBuffer(nil)
w := multipart.NewWriter(buf)
@@ -204,6 +224,8 @@ func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, str
return buf, w.FormDataContentType(), nil
}
// uploaderTypeByExt infers the Telegram upload field name from a file extension.
// Falls back to UploaderDocumentType for unrecognized extensions.
func uploaderTypeByExt(filename string) UploaderFileType {
ext := filepath.Ext(filename)
switch ext {