v1.0.0 beta 21
This commit is contained in:
@@ -151,8 +151,8 @@ func encodeBase64Payload(d CallbackData) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
dst := make([]byte, base64.StdEncoding.EncodedLen(len([]byte(data))))
|
dst := make([]byte, base64.RawURLEncoding.EncodedLen(len([]byte(data))))
|
||||||
base64.StdEncoding.Encode(dst, []byte(data))
|
base64.RawURLEncoding.Encode(dst, []byte(data))
|
||||||
return string(dst), nil
|
return string(dst), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ func encodeBase64Payload(d CallbackData) (string, error) {
|
|||||||
// return "", ErrInvalidPayloadType
|
// return "", ErrInvalidPayloadType
|
||||||
// }
|
// }
|
||||||
func decodeBase64Payload(s string) (CallbackData, error) {
|
func decodeBase64Payload(s string) (CallbackData, error) {
|
||||||
b, err := base64.StdEncoding.DecodeString(s)
|
b, err := base64.RawURLEncoding.DecodeString(s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CallbackData{}, err
|
return CallbackData{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
24
keyboard.go
24
keyboard.go
@@ -109,16 +109,32 @@ type InlineKeyboard struct {
|
|||||||
payloadType BotPayloadType // Serialization format for callback data (JSON or Base64)
|
payloadType BotPayloadType // Serialization format for callback data (JSON or Base64)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewInlineKeyboard creates a new keyboard builder with the specified maximum
|
// NewInlineKeyboardJson creates a new keyboard builder with the specified maximum
|
||||||
// number of buttons per row.
|
// number of buttons per row.
|
||||||
//
|
//
|
||||||
// Example: NewInlineKeyboard(3) creates a keyboard with at most 3 buttons per line.
|
// Example: NewInlineKeyboardJson(3) creates a keyboard with at most 3 buttons per line.
|
||||||
func NewInlineKeyboard(maxRow int) *InlineKeyboard {
|
func NewInlineKeyboardJson(maxRow int) *InlineKeyboard {
|
||||||
|
return NewInlineKeyboard(BotPayloadJson, maxRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInlineKeyboardBase64 creates a new keyboard builder with the specified maximum
|
||||||
|
// number of buttons per row, using Base64 encoding for button payloads.
|
||||||
|
//
|
||||||
|
// Example: NewInlineKeyboardBase64(3) creates a keyboard with at most 3 buttons per line.
|
||||||
|
func NewInlineKeyboardBase64(maxRow int) *InlineKeyboard {
|
||||||
|
return NewInlineKeyboard(BotPayloadBase64, maxRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInlineKeyboard creates a new keyboard builder with the specified payload encoding
|
||||||
|
// type and maximum number of buttons per row.
|
||||||
|
//
|
||||||
|
// Use NewInlineKeyboardJson or NewInlineKeyboardBase64 for the common cases.
|
||||||
|
func NewInlineKeyboard(payloadType BotPayloadType, maxRow int) *InlineKeyboard {
|
||||||
return &InlineKeyboard{
|
return &InlineKeyboard{
|
||||||
CurrentLine: make(extypes.Slice[tgapi.InlineKeyboardButton], 0),
|
CurrentLine: make(extypes.Slice[tgapi.InlineKeyboardButton], 0),
|
||||||
Lines: make([][]tgapi.InlineKeyboardButton, 0),
|
Lines: make([][]tgapi.InlineKeyboardButton, 0),
|
||||||
maxRow: maxRow,
|
maxRow: maxRow,
|
||||||
payloadType: BotPayloadBase64,
|
payloadType: payloadType,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -391,6 +391,9 @@ func (ctx *MsgContext) NewDraft() *Draft {
|
|||||||
return ctx.newDraft(tgapi.ParseNone)
|
return ctx.newDraft(tgapi.ParseNone)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewDraftMarkdown creates a new message draft associated with the current chat,
|
||||||
|
// with Markdown V2 parse mode enabled.
|
||||||
|
// Uses the API limiter to avoid rate limiting.
|
||||||
func (ctx *MsgContext) NewDraftMarkdown() *Draft {
|
func (ctx *MsgContext) NewDraftMarkdown() *Draft {
|
||||||
return ctx.newDraft(tgapi.ParseMDV2)
|
return ctx.newDraft(tgapi.ParseMDV2)
|
||||||
}
|
}
|
||||||
@@ -404,3 +407,9 @@ func (ctx *MsgContext) Translate(key string) string {
|
|||||||
lang := Val(ctx.From.LanguageCode, ctx.l10n.GetFallbackLanguage())
|
lang := Val(ctx.From.LanguageCode, ctx.l10n.GetFallbackLanguage())
|
||||||
return ctx.l10n.Translate(lang, key)
|
return ctx.l10n.Translate(lang, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewInlineKeyboard creates a new keyboard builder with the context's payload
|
||||||
|
// encoding type and the specified maximum number of buttons per row.
|
||||||
|
func (ctx *MsgContext) NewInlineKeyboard(maxRow int) *InlineKeyboard {
|
||||||
|
return NewInlineKeyboard(ctx.payloadType, maxRow)
|
||||||
|
}
|
||||||
|
|||||||
52
tgapi/api.go
52
tgapi/api.go
@@ -161,27 +161,13 @@ type TelegramRequest[R, P any] struct {
|
|||||||
chatId int64
|
chatId int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRequest and NewRequestWithChatID are DEPRECATED.
|
// NewRequest creates an untyped TelegramRequest for the given method and params with no chat ID.
|
||||||
// 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.
|
|
||||||
func NewRequest[R, P any](method string, params P) TelegramRequest[R, P] {
|
func NewRequest[R, P any](method string, params P) TelegramRequest[R, P] {
|
||||||
return TelegramRequest[R, P]{method, params, 0}
|
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] {
|
func NewRequestWithChatID[R, P any](method string, params P, chatId int64) TelegramRequest[R, P] {
|
||||||
return TelegramRequest[R, P]{method, params, chatId}
|
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.
|
// Must be called within a worker pool context if using DoWithContext.
|
||||||
func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, error) {
|
func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, error) {
|
||||||
var zero R
|
var zero R
|
||||||
|
reqData, err := json.Marshal(r.params)
|
||||||
data, err := json.Marshal(r.params)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return zero, fmt.Errorf("failed to marshal request: %w", err)
|
return zero, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
}
|
}
|
||||||
buf := bytes.NewBuffer(data)
|
|
||||||
|
|
||||||
methodPrefix := ""
|
methodPrefix := ""
|
||||||
if api.useTestServer {
|
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)
|
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 {
|
if err != nil {
|
||||||
return zero, fmt.Errorf("failed to create request: %w", err)
|
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("Accept", "application/json")
|
||||||
req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString))
|
req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString))
|
||||||
req.Header.Set("Accept-Encoding", "gzip")
|
req.Header.Set("Accept-Encoding", "gzip")
|
||||||
req.ContentLength = int64(len(data))
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
// Apply rate limiting before making the request
|
// 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
|
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)
|
resp, err := api.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return zero, fmt.Errorf("HTTP request failed: %w", err)
|
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
|
_ = resp.Body.Close() // ensure body is closed
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return zero, fmt.Errorf("failed to read response body: %w", err)
|
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 {
|
if err != nil {
|
||||||
return zero, fmt.Errorf("failed to parse response: %w", err)
|
return zero, fmt.Errorf("failed to parse response: %w", err)
|
||||||
}
|
}
|
||||||
@@ -249,11 +235,13 @@ 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)
|
api.logger.Warnf("Rate limited by Telegram, retry after %d seconds (chat: %d)", after, r.chatId)
|
||||||
|
|
||||||
// Apply cooldown to global or chat-specific limiter
|
// Apply cooldown to global or chat-specific limiter
|
||||||
|
if api.Limiter != nil {
|
||||||
if r.chatId > 0 {
|
if r.chatId > 0 {
|
||||||
api.Limiter.SetChatLock(r.chatId, after)
|
api.Limiter.SetChatLock(r.chatId, after)
|
||||||
} else {
|
} else {
|
||||||
api.Limiter.SetGlobalLock(after)
|
api.Limiter.SetGlobalLock(after)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Wait and retry
|
// Wait and retry
|
||||||
select {
|
select {
|
||||||
@@ -311,21 +299,13 @@ func readBody(body io.ReadCloser) ([]byte, error) {
|
|||||||
return io.ReadAll(reader)
|
return io.ReadAll(reader)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseBody unmarshals Telegram API response and returns structured result.
|
// parseBody unmarshals a Telegram API response into a typed ApiResponse.
|
||||||
// Returns ErrRateLimit internally if error_code == 429 — caller must handle via response.Ok check.
|
// 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) {
|
func parseBody[R any](data []byte) (ApiResponse[R], error) {
|
||||||
var resp ApiResponse[R]
|
var resp ApiResponse[R]
|
||||||
err := json.Unmarshal(data, &resp)
|
err := json.Unmarshal(data, &resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resp, fmt.Errorf("failed to unmarshal JSON: %w", err)
|
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
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package tgapi
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -24,13 +23,18 @@ const (
|
|||||||
UploaderThumbnailType UploaderFileType = "thumbnail"
|
UploaderThumbnailType UploaderFileType = "thumbnail"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// UploaderFileType represents the Telegram form field name for a file upload.
|
||||||
type UploaderFileType string
|
type UploaderFileType string
|
||||||
|
|
||||||
|
// UploaderFile holds the data and metadata for a single file to be uploaded.
|
||||||
type UploaderFile struct {
|
type UploaderFile struct {
|
||||||
filename string
|
filename string
|
||||||
data []byte
|
data []byte
|
||||||
field UploaderFileType
|
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 {
|
func NewUploaderFile(name string, data []byte) UploaderFile {
|
||||||
t := uploaderTypeByExt(name)
|
t := uploaderTypeByExt(name)
|
||||||
return UploaderFile{filename: name, data: data, field: t}
|
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) Close() error { return u.logger.Close() }
|
||||||
func (u *Uploader) GetLogger() *slog.Logger { return u.logger }
|
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 {
|
type UploaderRequest[R, P any] struct {
|
||||||
method string
|
method string
|
||||||
files []UploaderFile
|
files []UploaderFile
|
||||||
@@ -63,25 +69,42 @@ type UploaderRequest[R, P any] struct {
|
|||||||
chatId int64
|
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] {
|
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}
|
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] {
|
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}
|
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) {
|
func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, error) {
|
||||||
var zero R
|
var zero R
|
||||||
|
|
||||||
buf, contentType, err := prepareMultipart(r.files, r.params)
|
|
||||||
if err != nil {
|
|
||||||
return zero, err
|
|
||||||
}
|
|
||||||
|
|
||||||
methodPrefix := ""
|
methodPrefix := ""
|
||||||
if up.api.useTestServer {
|
if up.api.useTestServer {
|
||||||
methodPrefix = "/test"
|
methodPrefix = "/test"
|
||||||
}
|
}
|
||||||
url := fmt.Sprintf("%s/bot%s%s/%s", up.api.apiUrl, up.api.token, methodPrefix, r.method)
|
url := fmt.Sprintf("%s/bot%s%s/%s", up.api.apiUrl, up.api.token, methodPrefix, r.method)
|
||||||
|
|
||||||
|
for {
|
||||||
|
if up.api.Limiter != nil {
|
||||||
|
if up.api.dropOverflowLimit {
|
||||||
|
if !up.api.Limiter.GlobalAllow() {
|
||||||
|
return zero, utils.ErrDropOverflow
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := up.api.Limiter.GlobalWait(ctx); err != nil {
|
||||||
|
return zero, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, contentType, err := prepareMultipart(r.files, r.params)
|
||||||
|
if err != nil {
|
||||||
|
return zero, err
|
||||||
|
}
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", url, buf)
|
req, err := http.NewRequestWithContext(ctx, "POST", url, buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return zero, err
|
return zero, err
|
||||||
@@ -92,19 +115,6 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R,
|
|||||||
req.Header.Set("Accept-Encoding", "gzip")
|
req.Header.Set("Accept-Encoding", "gzip")
|
||||||
req.ContentLength = int64(buf.Len())
|
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")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := up.api.Limiter.GlobalWait(ctx); err != nil {
|
|
||||||
return zero, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
up.logger.Debugln("UPLOADER REQ", r.method)
|
up.logger.Debugln("UPLOADER REQ", r.method)
|
||||||
resp, err := up.api.client.Do(req)
|
resp, err := up.api.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -127,11 +137,13 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R,
|
|||||||
if response.ErrorCode == 429 && response.Parameters != nil && response.Parameters.RetryAfter != nil {
|
if response.ErrorCode == 429 && response.Parameters != nil && response.Parameters.RetryAfter != nil {
|
||||||
after := *response.Parameters.RetryAfter
|
after := *response.Parameters.RetryAfter
|
||||||
up.logger.Warnf("Rate limited, retry after %d seconds (chat: %d)", after, r.chatId)
|
up.logger.Warnf("Rate limited, retry after %d seconds (chat: %d)", after, r.chatId)
|
||||||
|
if up.api.Limiter != nil {
|
||||||
if r.chatId > 0 {
|
if r.chatId > 0 {
|
||||||
up.api.Limiter.SetChatLock(r.chatId, after)
|
up.api.Limiter.SetChatLock(r.chatId, after)
|
||||||
} else {
|
} else {
|
||||||
up.api.Limiter.SetGlobalLock(after)
|
up.api.Limiter.SetGlobalLock(after)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -145,6 +157,9 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R,
|
|||||||
return response.Result, nil
|
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) {
|
func (r UploaderRequest[R, P]) DoWithContext(ctx context.Context, up *Uploader) (R, error) {
|
||||||
var zero R
|
var zero R
|
||||||
|
|
||||||
@@ -168,10 +183,15 @@ func (r UploaderRequest[R, P]) DoWithContext(ctx context.Context, up *Uploader)
|
|||||||
return zero, ErrPoolUnexpected
|
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) {
|
func (r UploaderRequest[R, P]) Do(up *Uploader) (R, error) {
|
||||||
return r.DoWithContext(context.Background(), up)
|
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) {
|
func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, string, error) {
|
||||||
buf := bytes.NewBuffer(nil)
|
buf := bytes.NewBuffer(nil)
|
||||||
w := multipart.NewWriter(buf)
|
w := multipart.NewWriter(buf)
|
||||||
@@ -204,6 +224,8 @@ func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, str
|
|||||||
return buf, w.FormDataContentType(), nil
|
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 {
|
func uploaderTypeByExt(filename string) UploaderFileType {
|
||||||
ext := filepath.Ext(filename)
|
ext := filepath.Ext(filename)
|
||||||
switch ext {
|
switch ext {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type RateLimiter struct {
|
|||||||
|
|
||||||
chatLocks map[int64]time.Time // per-chat cooldown timestamps
|
chatLocks map[int64]time.Time // per-chat cooldown timestamps
|
||||||
chatLimiters map[int64]*rate.Limiter // per-chat token buckets (1 req/sec)
|
chatLimiters map[int64]*rate.Limiter // per-chat token buckets (1 req/sec)
|
||||||
chatMu sync.Mutex // protects chatLocks and chatLimiters
|
chatMu sync.RWMutex // protects chatLocks and chatLimiters
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRateLimiter creates a new RateLimiter with default limits.
|
// NewRateLimiter creates a new RateLimiter with default limits.
|
||||||
@@ -107,9 +107,9 @@ func (rl *RateLimiter) Allow(chatID int64) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check chat cooldown
|
// Check chat cooldown
|
||||||
rl.chatMu.Lock()
|
rl.chatMu.RLock()
|
||||||
chatUntil, ok := rl.chatLocks[chatID]
|
chatUntil, ok := rl.chatLocks[chatID]
|
||||||
rl.chatMu.Unlock()
|
rl.chatMu.RUnlock()
|
||||||
if ok && !chatUntil.IsZero() && time.Now().Before(chatUntil) {
|
if ok && !chatUntil.IsZero() && time.Now().Before(chatUntil) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -135,12 +135,16 @@ func (rl *RateLimiter) Allow(chatID int64) bool {
|
|||||||
// chatID == 0 means no specific chat context (e.g., inline query, webhook without chat).
|
// chatID == 0 means no specific chat context (e.g., inline query, webhook without chat).
|
||||||
func (rl *RateLimiter) Check(ctx context.Context, dropOverflow bool, chatID int64) error {
|
func (rl *RateLimiter) Check(ctx context.Context, dropOverflow bool, chatID int64) error {
|
||||||
if dropOverflow {
|
if dropOverflow {
|
||||||
if chatID != 0 && !rl.Allow(chatID) {
|
if chatID != 0 {
|
||||||
|
if !rl.Allow(chatID) {
|
||||||
|
|
||||||
return ErrDropOverflow
|
return ErrDropOverflow
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
if !rl.GlobalAllow() {
|
if !rl.GlobalAllow() {
|
||||||
return ErrDropOverflow
|
return ErrDropOverflow
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else if chatID != 0 {
|
} else if chatID != 0 {
|
||||||
if err := rl.Wait(ctx, chatID); err != nil {
|
if err := rl.Wait(ctx, chatID); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -175,9 +179,9 @@ func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error {
|
|||||||
// waitForChatUnlock blocks until the specified chat's cooldown expires or context is done.
|
// waitForChatUnlock blocks until the specified chat's cooldown expires or context is done.
|
||||||
// Does not check token bucket — only cooldown.
|
// Does not check token bucket — only cooldown.
|
||||||
func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) error {
|
func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) error {
|
||||||
rl.chatMu.Lock()
|
rl.chatMu.RLock()
|
||||||
until, ok := rl.chatLocks[chatID]
|
until, ok := rl.chatLocks[chatID]
|
||||||
rl.chatMu.Unlock()
|
rl.chatMu.RUnlock()
|
||||||
|
|
||||||
if !ok || until.IsZero() || time.Now().After(until) {
|
if !ok || until.IsZero() || time.Now().After(until) {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -49,12 +49,10 @@ func Encode[T any](w *multipart.Writer, req T) error {
|
|||||||
|
|
||||||
switch field.Kind() {
|
switch field.Kind() {
|
||||||
case reflect.String:
|
case reflect.String:
|
||||||
if !isEmpty {
|
|
||||||
fw, err = w.CreateFormField(fieldName)
|
fw, err = w.CreateFormField(fieldName)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
_, err = fw.Write([]byte(field.String()))
|
_, err = fw.Write([]byte(field.String()))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
fw, err = w.CreateFormField(fieldName)
|
fw, err = w.CreateFormField(fieldName)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -65,11 +63,17 @@ func Encode[T any](w *multipart.Writer, req T) error {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
_, err = fw.Write([]byte(strconv.FormatUint(field.Uint(), 10)))
|
_, err = fw.Write([]byte(strconv.FormatUint(field.Uint(), 10)))
|
||||||
}
|
}
|
||||||
case reflect.Float32, reflect.Float64:
|
case reflect.Float32:
|
||||||
|
fw, err = w.CreateFormField(fieldName)
|
||||||
|
if err == nil {
|
||||||
|
_, err = fw.Write([]byte(strconv.FormatFloat(field.Float(), 'f', -1, 32)))
|
||||||
|
}
|
||||||
|
case reflect.Float64:
|
||||||
fw, err = w.CreateFormField(fieldName)
|
fw, err = w.CreateFormField(fieldName)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
_, err = fw.Write([]byte(strconv.FormatFloat(field.Float(), 'f', -1, 64)))
|
_, err = fw.Write([]byte(strconv.FormatFloat(field.Float(), 'f', -1, 64)))
|
||||||
}
|
}
|
||||||
|
|
||||||
case reflect.Bool:
|
case reflect.Bool:
|
||||||
fw, err = w.CreateFormField(fieldName)
|
fw, err = w.CreateFormField(fieldName)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -103,8 +107,12 @@ func Encode[T any](w *multipart.Writer, req T) error {
|
|||||||
_, err = fw.Write([]byte(strconv.FormatUint(elem.Uint(), 10)))
|
_, err = fw.Write([]byte(strconv.FormatUint(elem.Uint(), 10)))
|
||||||
case reflect.Bool:
|
case reflect.Bool:
|
||||||
_, err = fw.Write([]byte(strconv.FormatBool(elem.Bool())))
|
_, err = fw.Write([]byte(strconv.FormatBool(elem.Bool())))
|
||||||
case reflect.Float32, reflect.Float64:
|
case reflect.Float32:
|
||||||
|
_, err = fw.Write([]byte(strconv.FormatFloat(elem.Float(), 'f', -1, 32)))
|
||||||
|
case reflect.Float64:
|
||||||
_, err = fw.Write([]byte(strconv.FormatFloat(elem.Float(), 'f', -1, 64)))
|
_, err = fw.Write([]byte(strconv.FormatFloat(elem.Float(), 'f', -1, 64)))
|
||||||
|
default:
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package utils
|
package utils
|
||||||
|
|
||||||
const (
|
const (
|
||||||
VersionString = "1.0.0-beta.20"
|
VersionString = "1.0.0-beta.21"
|
||||||
VersionMajor = 1
|
VersionMajor = 1
|
||||||
VersionMinor = 0
|
VersionMinor = 0
|
||||||
VersionPatch = 0
|
VersionPatch = 0
|
||||||
VersionBeta = 20
|
VersionBeta = 21
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user