(new): log formatter options
Golang lint / lint (push) Successful in 1m11s

(refactor): API initialism names

(tests): logger format coverage

(doc): updated public names
This commit is contained in:
2026-04-28 16:18:14 +03:00
parent a34734366d
commit fc4386df75
42 changed files with 544 additions and 462 deletions
+47 -27
View File
@@ -19,7 +19,10 @@ type APIOpts struct {
token string
client *http.Client
useTestServer bool
apiUrl string
apiURL string
logFormat utils.LogFormat
logFormatter *sneklog.Formatter
limiter *utils.RateLimiter
dropOverflowLimit bool
@@ -32,7 +35,7 @@ func NewAPIOpts(token string) *APIOpts {
token: token,
client: nil,
useTestServer: false,
apiUrl: "https://api.telegram.org",
apiURL: "https://api.telegram.org",
}
}
@@ -52,15 +55,24 @@ func (opts *APIOpts) UseTestServer(use bool) *APIOpts {
return opts
}
// SetAPIUrl overrides the default Telegram API URL.
// SetAPIURL overrides the default Telegram API URL.
// Useful for self-hosted bots or proxies.
func (opts *APIOpts) SetAPIUrl(apiUrl string) *APIOpts {
if apiUrl != "" {
opts.apiUrl = apiUrl
func (opts *APIOpts) SetAPIURL(apiURL string) *APIOpts {
if apiURL != "" {
opts.apiURL = apiURL
}
return opts
}
func (opts *APIOpts) SetLogFormat(format utils.LogFormat) *APIOpts {
opts.logFormat = format
return opts
}
func (opts *APIOpts) SetLogFormatter(formatter *sneklog.Formatter) *APIOpts {
opts.logFormatter = formatter
return opts
}
// SetLimiter sets a rate limiter to enforce Telegram's API limits.
// Recommended: use utils.NewRateLimiter() for correct per-chat and global throttling.
func (opts *APIOpts) SetLimiter(limiter *utils.RateLimiter) *APIOpts {
@@ -87,7 +99,10 @@ type API struct {
client *http.Client
logger *sneklog.Logger
useTestServer bool
apiUrl string
apiURL string
logFormat utils.LogFormat
logFormatter *sneklog.Formatter
pool *workerPool
Limiter *utils.RateLimiter
@@ -97,12 +112,13 @@ type API struct {
// NewAPI creates a new API client from options.
// Always call Close() when done to release resources.
func NewAPI(opts *APIOpts) *API {
l := utils.CreateLogger("API", utils.GetLoggerLevel())
if opts == nil {
l.Errorln("Set API options")
_ = l.Close()
return nil
}
logger := utils.CreateLogger(
"API", utils.GetLoggerLevel(),
opts.logFormat, opts.logFormatter,
)
client := opts.client
if client == nil {
@@ -113,11 +129,15 @@ func NewAPI(opts *APIOpts) *API {
pool.start()
return &API{
token: opts.token,
client: client,
logger: l,
useTestServer: opts.useTestServer,
apiUrl: opts.apiUrl,
token: opts.token,
client: client,
logger: logger,
useTestServer: opts.useTestServer,
apiURL: opts.apiURL,
logFormat: opts.logFormat,
logFormatter: opts.logFormatter,
pool: pool,
Limiter: opts.limiter,
dropOverflowLimit: opts.dropOverflowLimit,
@@ -147,9 +167,9 @@ type ResponseParameters struct {
RetryAfter *int `json:"retry_after,omitempty"`
}
// ApiResponse is the standard Telegram Bot API response structure.
// TelegramResponse is the standard Telegram Bot API response structure.
// Generic over Result type R.
type ApiResponse[R any] struct {
type TelegramResponse[R any] struct {
Ok bool `json:"ok"`
Description string `json:"description,omitempty"`
Result R `json:"result,omitempty"`
@@ -166,7 +186,7 @@ type ApiResponse[R any] struct {
type TelegramRequest[R, P any] struct {
method string
params P
chatId int64
chatID int64
}
// NewRequest creates a low-level TelegramRequest with no associated chat ID.
@@ -176,8 +196,8 @@ func NewRequest[R, P any](method string, params P) TelegramRequest[R, P] {
// NewRequestWithChatID creates a low-level 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}
func NewRequestWithChatID[R, P any](method string, params P, chatID int64) TelegramRequest[R, P] {
return TelegramRequest[R, P]{method, params, chatID}
}
func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, error) {
@@ -191,7 +211,7 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro
if api.useTestServer {
methodPrefix = "/test"
}
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, nil)
if err != nil {
return zero, fmt.Errorf("failed to create request: %w", err)
@@ -204,7 +224,7 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro
for {
// Apply rate limiting before making the request
if api.Limiter != nil {
if err := api.Limiter.Check(ctx, api.dropOverflowLimit, r.chatId); err != nil {
if err := api.Limiter.Check(ctx, api.dropOverflowLimit, r.chatID); err != nil {
return zero, err
}
}
@@ -235,12 +255,12 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro
// Handle rate limiting (429)
if response.ErrorCode == 429 && response.Parameters != nil && response.Parameters.RetryAfter != nil {
after := *response.Parameters.RetryAfter
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
if api.Limiter != nil {
if r.chatId > 0 {
api.Limiter.SetChatLock(r.chatId, after)
if r.chatID > 0 {
api.Limiter.SetChatLock(r.chatID, after)
} else {
api.Limiter.SetGlobalLock(after)
}
@@ -302,8 +322,8 @@ func readBody(body io.ReadCloser) ([]byte, error) {
}
// Internal helper that parses a typed Telegram API response body.
func parseBody[R any](data []byte) (ApiResponse[R], error) {
var resp ApiResponse[R]
func parseBody[R any](data []byte) (TelegramResponse[R], error) {
var resp TelegramResponse[R]
err := json.Unmarshal(data, &resp)
if err != nil {
return resp, fmt.Errorf("failed to unmarshal JSON: %w", err)
+2 -2
View File
@@ -40,7 +40,7 @@ func TestAPILeavesAcceptEncodingToHTTPTransport(t *testing.T) {
api := NewAPI(
NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetAPIURL("https://example.test").
SetHTTPClient(client),
)
defer func() {
@@ -77,7 +77,7 @@ func TestAPICloseClosesIdleConnections(t *testing.T) {
api := NewAPI(
NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetAPIURL("https://example.test").
SetHTTPClient(&http.Client{Transport: transport}),
)
-3
View File
@@ -2,9 +2,6 @@ package tgapi
import "errors"
// ErrRateLimit reports that a request exceeded the configured rate limiter.
var ErrRateLimit = errors.New("rate limit exceeded")
// ErrPoolUnexpected reports an unexpected result type returned from the worker pool.
var ErrPoolUnexpected = errors.New("unexpected response from pool")
+1 -1
View File
@@ -472,7 +472,7 @@ func (api *API) SendChatActionWithContext(ctx context.Context, params SendChatAc
// See https://core.telegram.org/bots/api#setmessagereaction
type SetMessageReaction struct {
ChatID int64 `json:"chat_id"`
MessageId int `json:"message_id"`
MessageID int `json:"message_id"`
Reaction []ReactionType `json:"reaction"`
IsBig bool `json:"is_big,omitempty"`
}
+5 -5
View File
@@ -103,7 +103,7 @@ type Message struct {
SenderBusinessBot *User `json:"sender_business_bot,omitempty"`
SenderTag string `json:"sender_tag,omitempty"`
Date int `json:"date"`
BusinessConnectionId string `json:"business_connection_id,omitempty"`
BusinessConnectionID string `json:"business_connection_id,omitempty"`
Chat *Chat `json:"chat,omitempty"`
ForwardOrigin *MessageOrigin `json:"forward_origin,omitempty"`
@@ -121,7 +121,7 @@ type Message struct {
HasProtectedContent bool `json:"has_protected_content,omitempty"`
IsFromOffline bool `json:"is_from_offline,omitempty"`
IsPaidPost bool `json:"is_paid_post,omitempty"`
MediaGroupId string `json:"media_group_id,omitempty"`
MediaGroupID string `json:"media_group_id,omitempty"`
AuthorSignature string `json:"author_signature,omitempty"`
PaidStarCount int `json:"paid_star_count,omitempty"`
@@ -316,8 +316,8 @@ const (
MessageEntityCashtag MessageEntityType = "cashtag"
// MessageEntityBotCommand identifies a bot command entity.
MessageEntityBotCommand MessageEntityType = "bot_command"
// MessageEntityUrl identifies a URL entity.
MessageEntityUrl MessageEntityType = "url"
// MessageEntityURL identifies a URL entity.
MessageEntityURL MessageEntityType = "url"
// MessageEntityEmail identifies an email entity.
MessageEntityEmail MessageEntityType = "email"
// MessageEntityPhoneNumber identifies a phone number entity.
@@ -537,7 +537,7 @@ const (
// ChatActionUploadVideoNote tells Telegram the bot is uploading a video note.
ChatActionUploadVideoNote ChatActionType = "upload_video_note"
// ChatActionUploadVideoNone is a deprecated alias for ChatActionUploadVideoNote.
ChatActionUploadVideoNone ChatActionType = ChatActionUploadVideoNote
ChatActionUploadVideoNone = ChatActionUploadVideoNote
)
// MessageReactionUpdated represents a change of a reaction on a message.
+1 -1
View File
@@ -256,7 +256,7 @@ func (api *API) openFileByLink(ctx context.Context, link string) (io.ReadCloser,
if api.useTestServer {
methodPrefix = "/test"
}
u := fmt.Sprintf("%s/file/bot%s%s/%s", api.apiUrl, api.token, methodPrefix, link)
u := fmt.Sprintf("%s/file/bot%s%s/%s", api.apiURL, api.token, methodPrefix, link)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
+5 -5
View File
@@ -23,7 +23,7 @@ func TestGetFileByLinkUsesConfiguredAPIURL(t *testing.T) {
api := NewAPI(
NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetAPIURL("https://example.test").
SetHTTPClient(client),
)
defer func() {
@@ -47,7 +47,7 @@ func TestGetFileByLinkUsesConfiguredAPIURL(t *testing.T) {
func TestOpenFileByLinkStreamsResponseBody(t *testing.T) {
api := NewAPI(
NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetAPIURL("https://example.test").
SetHTTPClient(&http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
@@ -94,7 +94,7 @@ func TestGetFileByLinkReturnsHTTPStatusError(t *testing.T) {
api := NewAPI(
NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetAPIURL("https://example.test").
SetHTTPClient(client),
)
defer func() {
@@ -131,7 +131,7 @@ func TestGetUpdatesOmitsAllowedUpdatesWhenEmpty(t *testing.T) {
api := NewAPI(
NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetAPIURL("https://example.test").
SetHTTPClient(client),
)
defer func() {
@@ -174,7 +174,7 @@ func TestSetChatMenuButtonSendsStructuredMenuButton(t *testing.T) {
api := NewAPI(
NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetAPIURL("https://example.test").
SetHTTPClient(client),
)
defer func() {
+13 -12
View File
@@ -70,12 +70,13 @@ type Uploader struct {
// NewUploader creates a multipart uploader bound to an API client.
func NewUploader(api *API) *Uploader {
logger := utils.CreateLogger("UPLOADER", utils.GetLoggerLevel())
if api == nil {
logger.Errorln("api is nil")
_ = logger.Close()
return nil
}
logger := utils.CreateLogger(
"UPLOADER", utils.GetLoggerLevel(),
api.logFormat, api.logFormatter,
)
return &Uploader{api, logger}
}
@@ -97,18 +98,18 @@ type UploaderRequest[R, P any] struct {
method string
files []UploaderFile
params P
chatId int64
chatID int64
}
// NewUploaderRequest creates a low-level 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}
return UploaderRequest[R, P]{method: method, files: files, params: params, chatID: 0}
}
// NewUploaderRequestWithChatID creates a low-level 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 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) {
@@ -118,11 +119,11 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R,
if up.api.useTestServer {
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 err := up.api.Limiter.Check(ctx, up.api.dropOverflowLimit, r.chatId); err != nil {
if err := up.api.Limiter.Check(ctx, up.api.dropOverflowLimit, r.chatID); err != nil {
return zero, err
}
}
@@ -161,10 +162,10 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R,
if !response.Ok {
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)
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)
if r.chatID > 0 {
up.api.Limiter.SetChatLock(r.chatID, after)
} else {
up.api.Limiter.SetGlobalLock(after)
}
+1 -1
View File
@@ -40,7 +40,7 @@ func TestUploaderEncodesJSONFieldsAndLeavesAcceptEncodingToHTTPTransport(t *test
api := NewAPI(
NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetAPIURL("https://example.test").
SetHTTPClient(client),
)
defer func() {