Compare commits
1 Commits
v1.0.0-bet
...
v1.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
389ec9f9d7
|
@@ -151,8 +151,8 @@ func encodeBase64Payload(d CallbackData) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dst := make([]byte, base64.StdEncoding.EncodedLen(len([]byte(data))))
|
||||
base64.StdEncoding.Encode(dst, []byte(data))
|
||||
dst := make([]byte, base64.RawURLEncoding.EncodedLen(len([]byte(data))))
|
||||
base64.RawURLEncoding.Encode(dst, []byte(data))
|
||||
return string(dst), nil
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ func encodeBase64Payload(d CallbackData) (string, error) {
|
||||
// return "", ErrInvalidPayloadType
|
||||
// }
|
||||
func decodeBase64Payload(s string) (CallbackData, error) {
|
||||
b, err := base64.StdEncoding.DecodeString(s)
|
||||
b, err := base64.RawURLEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Example: NewInlineKeyboard(3) creates a keyboard with at most 3 buttons per line.
|
||||
func NewInlineKeyboard(maxRow int) *InlineKeyboard {
|
||||
// Example: NewInlineKeyboardJson(3) creates a keyboard with at most 3 buttons per line.
|
||||
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{
|
||||
CurrentLine: make(extypes.Slice[tgapi.InlineKeyboardButton], 0),
|
||||
Lines: make([][]tgapi.InlineKeyboardButton, 0),
|
||||
maxRow: maxRow,
|
||||
payloadType: BotPayloadBase64,
|
||||
payloadType: payloadType,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -391,6 +391,9 @@ func (ctx *MsgContext) NewDraft() *Draft {
|
||||
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 {
|
||||
return ctx.newDraft(tgapi.ParseMDV2)
|
||||
}
|
||||
@@ -404,3 +407,9 @@ func (ctx *MsgContext) Translate(key string) string {
|
||||
lang := Val(ctx.From.LanguageCode, ctx.l10n.GetFallbackLanguage())
|
||||
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
|
||||
}
|
||||
|
||||
// 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,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)
|
||||
|
||||
// Apply cooldown to global or chat-specific limiter
|
||||
if api.Limiter != nil {
|
||||
if r.chatId > 0 {
|
||||
api.Limiter.SetChatLock(r.chatId, after)
|
||||
} else {
|
||||
api.Limiter.SetGlobalLock(after)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait and retry
|
||||
select {
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,25 +69,42 @@ 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)
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
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.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)
|
||||
resp, err := up.api.client.Do(req)
|
||||
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 {
|
||||
after := *response.Parameters.RetryAfter
|
||||
up.logger.Warnf("Rate limited, retry after %d seconds (chat: %d)", after, r.chatId)
|
||||
if up.api.Limiter != nil {
|
||||
if r.chatId > 0 {
|
||||
up.api.Limiter.SetChatLock(r.chatId, after)
|
||||
} else {
|
||||
up.api.Limiter.SetGlobalLock(after)
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -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 {
|
||||
|
||||
@@ -22,7 +22,7 @@ type RateLimiter struct {
|
||||
|
||||
chatLocks map[int64]time.Time // per-chat cooldown timestamps
|
||||
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.
|
||||
@@ -107,9 +107,9 @@ func (rl *RateLimiter) Allow(chatID int64) bool {
|
||||
}
|
||||
|
||||
// Check chat cooldown
|
||||
rl.chatMu.Lock()
|
||||
rl.chatMu.RLock()
|
||||
chatUntil, ok := rl.chatLocks[chatID]
|
||||
rl.chatMu.Unlock()
|
||||
rl.chatMu.RUnlock()
|
||||
if ok && !chatUntil.IsZero() && time.Now().Before(chatUntil) {
|
||||
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).
|
||||
func (rl *RateLimiter) Check(ctx context.Context, dropOverflow bool, chatID int64) error {
|
||||
if dropOverflow {
|
||||
if chatID != 0 && !rl.Allow(chatID) {
|
||||
if chatID != 0 {
|
||||
if !rl.Allow(chatID) {
|
||||
|
||||
return ErrDropOverflow
|
||||
}
|
||||
} else {
|
||||
if !rl.GlobalAllow() {
|
||||
return ErrDropOverflow
|
||||
}
|
||||
}
|
||||
} else if chatID != 0 {
|
||||
if err := rl.Wait(ctx, chatID); err != nil {
|
||||
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.
|
||||
// Does not check token bucket — only cooldown.
|
||||
func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) error {
|
||||
rl.chatMu.Lock()
|
||||
rl.chatMu.RLock()
|
||||
until, ok := rl.chatLocks[chatID]
|
||||
rl.chatMu.Unlock()
|
||||
rl.chatMu.RUnlock()
|
||||
|
||||
if !ok || until.IsZero() || time.Now().After(until) {
|
||||
return nil
|
||||
|
||||
@@ -49,12 +49,10 @@ func Encode[T any](w *multipart.Writer, req T) error {
|
||||
|
||||
switch field.Kind() {
|
||||
case reflect.String:
|
||||
if !isEmpty {
|
||||
fw, err = w.CreateFormField(fieldName)
|
||||
if err == nil {
|
||||
_, err = fw.Write([]byte(field.String()))
|
||||
}
|
||||
}
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
fw, err = w.CreateFormField(fieldName)
|
||||
if err == nil {
|
||||
@@ -65,11 +63,17 @@ func Encode[T any](w *multipart.Writer, req T) error {
|
||||
if err == nil {
|
||||
_, 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)
|
||||
if err == nil {
|
||||
_, err = fw.Write([]byte(strconv.FormatFloat(field.Float(), 'f', -1, 64)))
|
||||
}
|
||||
|
||||
case reflect.Bool:
|
||||
fw, err = w.CreateFormField(fieldName)
|
||||
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)))
|
||||
case reflect.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)))
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package utils
|
||||
|
||||
const (
|
||||
VersionString = "1.0.0-beta.20"
|
||||
VersionString = "1.0.0-beta.21"
|
||||
VersionMajor = 1
|
||||
VersionMinor = 0
|
||||
VersionPatch = 0
|
||||
VersionBeta = 20
|
||||
VersionBeta = 21
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user