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

@@ -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
} }

View File

@@ -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,
} }
} }

View File

@@ -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)
}

View File

@@ -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,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) 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 r.chatId > 0 { if api.Limiter != nil {
api.Limiter.SetChatLock(r.chatId, after) if r.chatId > 0 {
} else { api.Limiter.SetChatLock(r.chatId, after)
api.Limiter.SetGlobalLock(after) } else {
api.Limiter.SetGlobalLock(after)
}
} }
// Wait and retry // Wait and retry
@@ -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
} }

View File

@@ -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,40 +69,30 @@ 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)
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 { for {
if up.api.Limiter != nil { if up.api.Limiter != nil {
if up.api.dropOverflowLimit { if up.api.dropOverflowLimit {
if !up.api.Limiter.GlobalAllow() { if !up.api.Limiter.GlobalAllow() {
return zero, errors.New("rate limited") return zero, utils.ErrDropOverflow
} }
} else { } else {
if err := up.api.Limiter.GlobalWait(ctx); err != nil { 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) 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,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 { 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 r.chatId > 0 { if up.api.Limiter != nil {
up.api.Limiter.SetChatLock(r.chatId, after) if r.chatId > 0 {
} else { up.api.Limiter.SetChatLock(r.chatId, after)
up.api.Limiter.SetGlobalLock(after) } else {
up.api.Limiter.SetGlobalLock(after)
}
} }
select { select {
@@ -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 {

View File

@@ -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,11 +135,15 @@ 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 {
return ErrDropOverflow if !rl.Allow(chatID) {
}
if !rl.GlobalAllow() { return ErrDropOverflow
return ErrDropOverflow }
} else {
if !rl.GlobalAllow() {
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 {
@@ -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

View File

@@ -49,11 +49,9 @@ 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)
@@ -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

View File

@@ -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
) )