Compare commits
3 Commits
v1.0.0-bet
...
v1.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
389ec9f9d7
|
|||
|
fb81bb91bd
|
|||
|
589e11b22d
|
206
bot.go
206
bot.go
@@ -1,41 +1,9 @@
|
||||
// Package laniakea provides a modular, extensible framework for building scalable
|
||||
// Telegram bots with support for plugins, middleware, localization, draft messages,
|
||||
// rate limiting, structured logging, and dependency injection.
|
||||
//
|
||||
// The framework is designed around a fluent API for configuration and separation of concerns:
|
||||
//
|
||||
// - Plugins: Handle specific commands or events (e.g., /start, /help)
|
||||
// - Middleware: Intercept and modify updates before plugins run (auth, logging, validation)
|
||||
// - Runners: Background goroutines for cleanup, cron jobs, or monitoring
|
||||
// - DraftProvider: Safely build and resume multi-step messages
|
||||
// - L10n: Multi-language support via key-based translation
|
||||
// - RateLimiter: Enforces Telegram API limits to avoid bans
|
||||
// - Structured Logging: JSON stdout + optional file output with request-level tracing
|
||||
// - Dependency Injection: Inject custom database contexts (e.g., *gorm.DB, *sql.DB)
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// bot := laniakea.NewBot[mydb.DBContext](laniakea.LoadOptsFromEnv()).
|
||||
// DatabaseContext(&myDB).
|
||||
// AddUpdateType(tgapi.UpdateTypeMessage).
|
||||
// AddPrefixes("/", "!").
|
||||
// AddPlugins(&startPlugin, &helpPlugin).
|
||||
// AddMiddleware(&authMiddleware, &logMiddleware).
|
||||
// AddRunner(&cleanupRunner).
|
||||
// AddL10n(l10n.New())
|
||||
//
|
||||
// go bot.Run()
|
||||
//
|
||||
// All methods are thread-safe except direct field access. Use provided accessors
|
||||
// (e.g., GetDBContext, SetUpdateOffset) for safe concurrent access.
|
||||
package laniakea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -47,113 +15,6 @@ import (
|
||||
"github.com/alitto/pond/v2"
|
||||
)
|
||||
|
||||
// BotOpts holds configuration options for initializing a Bot.
|
||||
//
|
||||
// Values are loaded from environment variables via LoadOptsFromEnv().
|
||||
// Use NewOpts() to create a zero-value struct and set fields manually.
|
||||
type BotOpts struct {
|
||||
// Token is the Telegram bot token (required).
|
||||
Token string
|
||||
|
||||
// UpdateTypes is a semicolon-separated list of update types to listen for.
|
||||
// Example: "message;edited_message;callback_query"
|
||||
// Defaults to empty (Telegram will return all types).
|
||||
UpdateTypes []string
|
||||
|
||||
// Debug enables debug-level logging.
|
||||
Debug bool
|
||||
|
||||
// ErrorTemplate is the format string used to wrap error messages sent to users.
|
||||
// Use "%s" to insert the actual error. Example: "❌ Error: %s"
|
||||
ErrorTemplate string
|
||||
|
||||
// Prefixes is a list of command prefixes (e.g., ["/", "!"]).
|
||||
// Defaults to ["/"] if not set via environment.
|
||||
Prefixes []string
|
||||
|
||||
// LoggerBasePath is the directory where log files are written.
|
||||
// Defaults to "./".
|
||||
LoggerBasePath string
|
||||
|
||||
// UseRequestLogger enables detailed logging of all Telegram API requests.
|
||||
UseRequestLogger bool
|
||||
|
||||
// WriteToFile enables writing logs to files (main.log and requests.log).
|
||||
WriteToFile bool
|
||||
|
||||
// UseTestServer uses Telegram's test server (https://api.test.telegram.org).
|
||||
UseTestServer bool
|
||||
|
||||
// APIUrl overrides the default Telegram API endpoint (useful for proxies or self-hosted).
|
||||
APIUrl string
|
||||
|
||||
// RateLimit is the maximum number of API requests per second.
|
||||
// Telegram allows up to 30 req/s for most bots. Defaults to 30.
|
||||
RateLimit int
|
||||
|
||||
// DropRLOverflow drops incoming updates when rate limit is exceeded instead of queuing.
|
||||
// Use this to prioritize responsiveness over reliability.
|
||||
DropRLOverflow bool
|
||||
}
|
||||
|
||||
// NewOpts returns a new BotOpts with zero values.
|
||||
func NewOpts() *BotOpts { return new(BotOpts) }
|
||||
|
||||
// LoadOptsFromEnv loads BotOpts from environment variables.
|
||||
//
|
||||
// Environment variables:
|
||||
// - TG_TOKEN: Bot token (required)
|
||||
// - UPDATE_TYPES: semicolon-separated update types (e.g., "message;callback_query")
|
||||
// - DEBUG: "true" to enable debug logging
|
||||
// - ERROR_TEMPLATE: format string for error messages (e.g., "❌ %s")
|
||||
// - PREFIXES: semicolon-separated prefixes (e.g., "/;!bot")
|
||||
// - LOGGER_BASE_PATH: directory for log files (default: "./")
|
||||
// - USE_REQ_LOG: "true" to enable request logging
|
||||
// - WRITE_TO_FILE: "true" to write logs to files
|
||||
// - USE_TEST_SERVER: "true" to use Telegram test server
|
||||
// - API_URL: custom API endpoint
|
||||
// - RATE_LIMIT: max requests per second (default: 30)
|
||||
// - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow
|
||||
//
|
||||
// Returns a populated BotOpts. If TG_TOKEN is missing, behavior is undefined.
|
||||
func LoadOptsFromEnv() *BotOpts {
|
||||
rateLimit := 30
|
||||
if rl := os.Getenv("RATE_LIMIT"); rl != "" {
|
||||
if n, err := strconv.Atoi(rl); err == nil {
|
||||
rateLimit = n
|
||||
}
|
||||
}
|
||||
|
||||
return &BotOpts{
|
||||
Token: os.Getenv("TG_TOKEN"),
|
||||
UpdateTypes: strings.Split(os.Getenv("UPDATE_TYPES"), ";"),
|
||||
|
||||
Debug: os.Getenv("DEBUG") == "true",
|
||||
ErrorTemplate: os.Getenv("ERROR_TEMPLATE"),
|
||||
Prefixes: LoadPrefixesFromEnv(),
|
||||
|
||||
LoggerBasePath: os.Getenv("LOGGER_BASE_PATH"),
|
||||
UseRequestLogger: os.Getenv("USE_REQ_LOG") == "true",
|
||||
WriteToFile: os.Getenv("WRITE_TO_FILE") == "true",
|
||||
|
||||
UseTestServer: os.Getenv("USE_TEST_SERVER") == "true",
|
||||
APIUrl: os.Getenv("API_URL"),
|
||||
|
||||
RateLimit: rateLimit,
|
||||
DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
|
||||
}
|
||||
}
|
||||
|
||||
// LoadPrefixesFromEnv returns the PREFIXES environment variable split by semicolon.
|
||||
// Defaults to ["/"] if not set.
|
||||
func LoadPrefixesFromEnv() []string {
|
||||
prefixesS, exists := os.LookupEnv("PREFIXES")
|
||||
if !exists {
|
||||
return []string{"/"}
|
||||
}
|
||||
return strings.Split(prefixesS, ";")
|
||||
}
|
||||
|
||||
// DbContext is an interface representing the application's database context.
|
||||
// It is injected into plugins and middleware via Bot.DatabaseContext().
|
||||
//
|
||||
@@ -169,6 +30,10 @@ type DbContext any
|
||||
// Use Bot[NoDB] to indicate no dependency injection is required.
|
||||
type NoDB struct{ DbContext }
|
||||
|
||||
// DbLogger is a function type that returns a slog.LoggerWriter for database logging.
|
||||
// Used to inject database-specific log output (e.g., SQL queries, ORM events).
|
||||
type DbLogger[T DbContext] func(db *T) slog.LoggerWriter
|
||||
|
||||
// BotPayloadType defines the serialization format for callback data payloads.
|
||||
type BotPayloadType string
|
||||
|
||||
@@ -195,6 +60,7 @@ type Bot[T DbContext] struct {
|
||||
errorTemplate string
|
||||
username string
|
||||
payloadType BotPayloadType
|
||||
maxWorkers int
|
||||
|
||||
logger *slog.Logger // Main bot logger (JSON stdout + optional file)
|
||||
RequestLogger *slog.Logger // Optional request-level API logging
|
||||
@@ -254,10 +120,16 @@ func NewBot[T any](opts *BotOpts) *Bot[T] {
|
||||
prefixes = []string{"/"}
|
||||
}
|
||||
|
||||
workers := 32
|
||||
if opts.MaxWorkers > 0 {
|
||||
workers = opts.MaxWorkers
|
||||
}
|
||||
|
||||
bot := &Bot[T]{
|
||||
updateOffset: 0,
|
||||
errorTemplate: "%s",
|
||||
payloadType: BotPayloadBase64,
|
||||
maxWorkers: workers,
|
||||
updateQueue: updateQueue,
|
||||
api: api,
|
||||
uploader: uploader,
|
||||
@@ -398,34 +270,6 @@ func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] {
|
||||
return bot
|
||||
}
|
||||
|
||||
// DbLogger is a function type that returns a slog.LoggerWriter for database logging.
|
||||
// Used to inject database-specific log output (e.g., SQL queries, ORM events).
|
||||
type DbLogger[T DbContext] func(db *T) slog.LoggerWriter
|
||||
|
||||
// AddDatabaseLoggerWriter adds a database logger writer to all loggers.
|
||||
//
|
||||
// The writer will receive logs from:
|
||||
// - Main bot logger
|
||||
// - Request logger (if enabled)
|
||||
// - API and Uploader loggers
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// bot.AddDatabaseLoggerWriter(func(db *MyDB) slog.LoggerWriter {
|
||||
// return db.QueryLogger()
|
||||
// })
|
||||
func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] {
|
||||
w := writer(bot.dbContext)
|
||||
bot.logger.AddWriter(w)
|
||||
if bot.RequestLogger != nil {
|
||||
bot.RequestLogger.AddWriter(w)
|
||||
}
|
||||
for _, l := range bot.extraLoggers {
|
||||
l.AddWriter(w)
|
||||
}
|
||||
return bot
|
||||
}
|
||||
|
||||
// DatabaseContext injects a database context into the bot.
|
||||
// This context is accessible to plugins and middleware via GetDBContext().
|
||||
func (bot *Bot[T]) DatabaseContext(ctx *T) *Bot[T] {
|
||||
@@ -569,13 +413,37 @@ func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] {
|
||||
return bot
|
||||
}
|
||||
|
||||
// AddDatabaseLoggerWriter adds a database logger writer to all loggers.
|
||||
//
|
||||
// The writer will receive logs from:
|
||||
// - Main bot logger
|
||||
// - Request logger (if enabled)
|
||||
// - API and Uploader loggers
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// bot.AddDatabaseLoggerWriter(func(db *MyDB) slog.LoggerWriter {
|
||||
// return db.QueryLogger()
|
||||
// })
|
||||
func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] {
|
||||
w := writer(bot.dbContext)
|
||||
bot.logger.AddWriter(w)
|
||||
if bot.RequestLogger != nil {
|
||||
bot.RequestLogger.AddWriter(w)
|
||||
}
|
||||
for _, l := range bot.extraLoggers {
|
||||
l.AddWriter(w)
|
||||
}
|
||||
return bot
|
||||
}
|
||||
|
||||
// RunWithContext starts the bot with a given context for graceful shutdown.
|
||||
//
|
||||
// This is the main entry point for bot execution. It:
|
||||
// - Validates required configuration (prefixes, plugins)
|
||||
// - Starts all registered runners as background goroutines
|
||||
// - Begins polling for updates via Telegram's GetUpdates API
|
||||
// - Processes updates concurrently using a worker pool (16 goroutines)
|
||||
// - Processes updates concurrently using a worker pool with size configurable via BotOpts.MaxWorkers
|
||||
//
|
||||
// The context controls graceful shutdown. When canceled, the bot:
|
||||
// - Stops polling for new updates
|
||||
@@ -636,7 +504,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
|
||||
}()
|
||||
|
||||
// Start worker pool for concurrent update handling
|
||||
pool := pond.NewPool(16)
|
||||
pool := pond.NewPool(bot.maxWorkers)
|
||||
for update := range bot.updateQueue {
|
||||
u := update // capture loop variable
|
||||
pool.Submit(func() {
|
||||
|
||||
226
bot_opts.go
Normal file
226
bot_opts.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package laniakea
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||
)
|
||||
|
||||
// BotOpts holds configuration options for initializing a Bot.
|
||||
//
|
||||
// Values are loaded from environment variables via LoadOptsFromEnv().
|
||||
// Use NewOpts() to create a zero-value struct and set fields manually.
|
||||
type BotOpts struct {
|
||||
// Token is the Telegram bot token (required).
|
||||
Token string
|
||||
|
||||
// UpdateTypes is a list of update types to listen for.
|
||||
// Example: "["message", "edited_message", "callback_query"]"
|
||||
// Defaults to empty (Telegram will return all types).
|
||||
UpdateTypes []tgapi.UpdateType
|
||||
|
||||
// Debug enables debug-level logging.
|
||||
Debug bool
|
||||
|
||||
// ErrorTemplate is the format string used to wrap error messages sent to users.
|
||||
// Use "%s" to insert the actual error. Example: "❌ Error: %s"
|
||||
ErrorTemplate string
|
||||
|
||||
// Prefixes is a list of command prefixes (e.g., ["/", "!"]).
|
||||
// Defaults to ["/"] if not set via environment.
|
||||
Prefixes []string
|
||||
|
||||
// LoggerBasePath is the directory where log files are written.
|
||||
// Defaults to "./".
|
||||
LoggerBasePath string
|
||||
|
||||
// UseRequestLogger enables detailed logging of all Telegram API requests.
|
||||
UseRequestLogger bool
|
||||
|
||||
// WriteToFile enables writing logs to files (main.log and requests.log).
|
||||
WriteToFile bool
|
||||
|
||||
// UseTestServer uses Telegram's test server (https://api.test.telegram.org).
|
||||
UseTestServer bool
|
||||
|
||||
// APIUrl overrides the default Telegram API endpoint (useful for proxies or self-hosted).
|
||||
APIUrl string
|
||||
|
||||
// RateLimit is the maximum number of API requests per second.
|
||||
// Telegram allows up to 30 req/s for most bots. Defaults to 30.
|
||||
RateLimit int
|
||||
|
||||
// DropRLOverflow drops incoming updates when rate limit is exceeded instead of queuing.
|
||||
// Use this to prioritize responsiveness over reliability.
|
||||
DropRLOverflow bool
|
||||
|
||||
// MaxWorkers is the maximum number of concurrency running update handlers.
|
||||
MaxWorkers int
|
||||
}
|
||||
|
||||
// LoadOptsFromEnv loads BotOpts from environment variables.
|
||||
//
|
||||
// Environment variables:
|
||||
// - TG_TOKEN: Bot token (required)
|
||||
// - UPDATE_TYPES: semicolon-separated update types (e.g., "message;callback_query")
|
||||
// - DEBUG: "true" to enable debug logging
|
||||
// - ERROR_TEMPLATE: format string for error messages (e.g., "❌ %s")
|
||||
// - PREFIXES: semicolon-separated prefixes (e.g., "/;!bot")
|
||||
// - LOGGER_BASE_PATH: directory for log files (default: "./")
|
||||
// - USE_REQ_LOG: "true" to enable request logging
|
||||
// - WRITE_TO_FILE: "true" to write logs to files
|
||||
// - USE_TEST_SERVER: "true" to use Telegram test server
|
||||
// - API_URL: custom API endpoint
|
||||
// - RATE_LIMIT: max requests per second (default: 30)
|
||||
// - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow
|
||||
//
|
||||
// Returns a populated BotOpts. If TG_TOKEN is missing, behavior is undefined.
|
||||
func LoadOptsFromEnv() *BotOpts {
|
||||
rateLimit := 30
|
||||
if rl := os.Getenv("RATE_LIMIT"); rl != "" {
|
||||
if n, err := strconv.Atoi(rl); err == nil {
|
||||
rateLimit = n
|
||||
}
|
||||
}
|
||||
|
||||
stringUpdateTypes := strings.Split(os.Getenv("UPDATE_TYPES"), ";")
|
||||
updateTypes := make([]tgapi.UpdateType, len(stringUpdateTypes))
|
||||
for i, updateType := range stringUpdateTypes {
|
||||
updateTypes[i] = tgapi.UpdateType(updateType)
|
||||
}
|
||||
|
||||
return &BotOpts{
|
||||
Token: os.Getenv("TG_TOKEN"),
|
||||
UpdateTypes: updateTypes,
|
||||
|
||||
Debug: os.Getenv("DEBUG") == "true",
|
||||
ErrorTemplate: os.Getenv("ERROR_TEMPLATE"),
|
||||
Prefixes: LoadPrefixesFromEnv(),
|
||||
|
||||
LoggerBasePath: os.Getenv("LOGGER_BASE_PATH"),
|
||||
UseRequestLogger: os.Getenv("USE_REQ_LOG") == "true",
|
||||
WriteToFile: os.Getenv("WRITE_TO_FILE") == "true",
|
||||
|
||||
UseTestServer: os.Getenv("USE_TEST_SERVER") == "true",
|
||||
APIUrl: os.Getenv("API_URL"),
|
||||
|
||||
RateLimit: rateLimit,
|
||||
DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
|
||||
}
|
||||
}
|
||||
|
||||
// SetToken sets the Telegram bot token (required).
|
||||
func (opts *BotOpts) SetToken(token string) *BotOpts {
|
||||
opts.Token = token
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetUpdateTypes sets the list of update types to listen for.
|
||||
// If empty (default), Telegram will return all update types.
|
||||
// Example: opts.SetUpdateTypes("message", "callback_query")
|
||||
func (opts *BotOpts) SetUpdateTypes(types ...tgapi.UpdateType) *BotOpts {
|
||||
opts.UpdateTypes = types
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetDebug enables or disables debug-level logging.
|
||||
// Default is false.
|
||||
func (opts *BotOpts) SetDebug(debug bool) *BotOpts {
|
||||
opts.Debug = debug
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetErrorTemplate sets the format string for error messages sent to users.
|
||||
// Use "%s" to insert the actual error. Example: "❌ Error: %s"
|
||||
// If not set, defaults to "%s".
|
||||
func (opts *BotOpts) SetErrorTemplate(tpl string) *BotOpts {
|
||||
opts.ErrorTemplate = tpl
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetPrefixes sets the command prefixes (e.g., "/", "!").
|
||||
// If not set via environment, defaults to ["/"].
|
||||
func (opts *BotOpts) SetPrefixes(prefixes ...string) *BotOpts {
|
||||
opts.Prefixes = prefixes
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetLoggerBasePath sets the directory where log files are written.
|
||||
// Defaults to "./".
|
||||
func (opts *BotOpts) SetLoggerBasePath(path string) *BotOpts {
|
||||
opts.LoggerBasePath = path
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetUseRequestLogger enables detailed logging of all Telegram API requests.
|
||||
// Default is false.
|
||||
func (opts *BotOpts) SetUseRequestLogger(use bool) *BotOpts {
|
||||
opts.UseRequestLogger = use
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetWriteToFile enables writing logs to files (main.log and requests.log).
|
||||
// Default is false.
|
||||
func (opts *BotOpts) SetWriteToFile(write bool) *BotOpts {
|
||||
opts.WriteToFile = write
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetUseTestServer enables using Telegram's test server (https://api.telegram.org/bot<token>/test).
|
||||
// Default is false.
|
||||
func (opts *BotOpts) SetUseTestServer(use bool) *BotOpts {
|
||||
opts.UseTestServer = use
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetAPIUrl overrides the default Telegram API endpoint (useful for proxies or self-hosted).
|
||||
// If not set, defaults to "https://api.telegram.org".
|
||||
func (opts *BotOpts) SetAPIUrl(url string) *BotOpts {
|
||||
opts.APIUrl = url
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetRateLimit sets the maximum number of API requests per second.
|
||||
// Telegram allows up to 30 req/s for most bots. Defaults to 30.
|
||||
func (opts *BotOpts) SetRateLimit(limit int) *BotOpts {
|
||||
opts.RateLimit = limit
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetDropRLOverflow drops incoming updates when rate limit is exceeded instead of queuing.
|
||||
// Use this to prioritize responsiveness over reliability. Default is false.
|
||||
func (opts *BotOpts) SetDropRLOverflow(drop bool) *BotOpts {
|
||||
opts.DropRLOverflow = drop
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetMaxWorkers sets the maximum number of concurrent update handlers.
|
||||
// Must be called before NewBot, as the value is captured during bot creation.
|
||||
//
|
||||
// The optimal value depends on your bot's workload:
|
||||
// - For I/O-bound handlers (e.g., database queries, external API calls), you may
|
||||
// need more workers, but be mindful of downstream service limits.
|
||||
// - For CPU-bound handlers, keep workers close to the number of CPU cores.
|
||||
//
|
||||
// Recommended starting points (adjust based on profiling and monitoring):
|
||||
// - Small to medium bots with fast handlers: 16–32
|
||||
// - Medium to large bots with fast handlers: 32–64
|
||||
// - Large bots with heavy I/O: 64–128 (ensure your infrastructure can handle it)
|
||||
//
|
||||
// The default is 32. Monitor queue length and processing latency to fine-tune.
|
||||
func (opts *BotOpts) SetMaxWorkers(workers int) *BotOpts {
|
||||
opts.MaxWorkers = workers
|
||||
return opts
|
||||
}
|
||||
|
||||
// LoadPrefixesFromEnv returns the PREFIXES environment variable split by semicolon.
|
||||
// Defaults to ["/"] if not set.
|
||||
func LoadPrefixesFromEnv() []string {
|
||||
prefixesS, exists := os.LookupEnv("PREFIXES")
|
||||
if !exists {
|
||||
return []string{"/"}
|
||||
}
|
||||
return strings.Split(prefixesS, ";")
|
||||
}
|
||||
@@ -1,12 +1,3 @@
|
||||
// Package laniakea provides a framework for building Telegram bots with plugin-based
|
||||
// command registration and automatic command scope management.
|
||||
//
|
||||
// This module automatically generates and registers bot commands across different
|
||||
// chat scopes (private, group, admin) based on plugin-defined commands.
|
||||
//
|
||||
// Commands are derived from Plugin and Command structs, with optional descriptions
|
||||
// and argument formatting. Automatic registration avoids manual command setup and
|
||||
// ensures consistency across chat types.
|
||||
package laniakea
|
||||
|
||||
import (
|
||||
|
||||
55
doc.go
Normal file
55
doc.go
Normal file
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
Package laniakea provides a modular, extensible framework for building scalable Telegram bots.
|
||||
|
||||
It offers a fluent API for configuration and separates concerns through several core concepts:
|
||||
|
||||
- Bot: The central instance managing API communication, update processing, logging,
|
||||
rate limiting, and dependency injection. Created via NewBot[T].
|
||||
|
||||
- Plugins: Organize commands and payloads into reusable units.
|
||||
A plugin can have multiple commands and shared middlewares.
|
||||
|
||||
- Commands: Named bot commands with descriptions, argument validation, and
|
||||
execution logic. Automatically registrable across different chat scopes.
|
||||
|
||||
- Middleware: Functions that intercept and modify updates before they reach plugins.
|
||||
Useful for authentication, logging, validation, etc. Return false to stop processing.
|
||||
|
||||
- MsgContext: Provides access to the incoming update and convenient methods for
|
||||
responding, editing, deleting, and translating messages. Includes built-in rate limiting
|
||||
and error handling. ⚠️ MarkdownV2 methods require manual escaping via EscapeMarkdownV2().
|
||||
|
||||
- InlineKeyboard: A fluent builder for constructing inline keyboards with styled buttons,
|
||||
icons, URLs, and structured callback data (JSON or Base64).
|
||||
|
||||
- DraftProvider: Manages ephemeral, multi-step message drafts with automatic ID generation
|
||||
(random or linear). Drafts can be built incrementally and flushed atomically.
|
||||
|
||||
- L10n: Simple key-based localization system with fallback language support.
|
||||
|
||||
- Runners: Background goroutines for periodic tasks or one‑off initialization,
|
||||
with configurable timeouts and async execution.
|
||||
|
||||
- RateLimiting & Logging: Built‑in rate limiter (respects Telegram's retry_after)
|
||||
and structured logging (JSON stdout + optional file output) with request‑level tracing.
|
||||
|
||||
- Dependency Injection: Pass any custom database context (e.g., *sql.DB) to all handlers
|
||||
via the type parameter T in Bot[T].
|
||||
|
||||
Example usage:
|
||||
|
||||
bot := laniakea.NewBot[mydb.DBContext](laniakea.LoadOptsFromEnv()).
|
||||
DatabaseContext(&myDB).
|
||||
AddUpdateType(tgapi.UpdateTypeMessage).
|
||||
AddPrefixes("/", "!").
|
||||
AddPlugins(&startPlugin, &helpPlugin).
|
||||
AddMiddleware(&authMiddleware, &logMiddleware).
|
||||
AddRunner(&cleanupRunner).
|
||||
AddL10n(l10n.New())
|
||||
|
||||
bot.Run()
|
||||
|
||||
All public methods are safe for concurrent use unless stated otherwise.
|
||||
Direct field access is not recommended; use provided accessors (e.g., GetDBContext, SetUpdateOffset).
|
||||
*/
|
||||
package laniakea
|
||||
28
drafts.go
28
drafts.go
@@ -1,31 +1,3 @@
|
||||
// Package laniakea provides a safe, high-level interface for managing Telegram
|
||||
// message drafts using the tgapi library. It allows creating, editing, and
|
||||
// flushing drafts with automatic ID generation and optional bulk flushing.
|
||||
//
|
||||
// Drafts are designed to be ephemeral, mutable buffers that can be built up
|
||||
// incrementally and then sent as final messages. The package ensures safe
|
||||
// state management by copying entities and isolating draft contexts.
|
||||
//
|
||||
// Two draft ID generation strategies are supported:
|
||||
// - Random: Cryptographically secure random IDs (default). Ideal for distributed systems.
|
||||
// - Linear: Monotonically increasing IDs. Useful for persistence, debugging, or recovery.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// provider := laniakea.NewRandomDraftProvider(api)
|
||||
//
|
||||
// draft := provider.NewDraft(tgapi.ParseModeMarkdown)
|
||||
// draft.SetChat(-1001234567890, 0)
|
||||
// draft.Push("*Hello*").Push(" **world**!")
|
||||
// err := draft.Flush() // Sends message and deletes draft
|
||||
// if err != nil {
|
||||
// log.Printf("Failed to send draft: %v", err)
|
||||
// }
|
||||
//
|
||||
// // Or flush all pending drafts at once:
|
||||
// err = provider.FlushAll() // Sends all drafts and clears them
|
||||
//
|
||||
// Note: Drafts are NOT thread-safe. Concurrent access requires external synchronization.
|
||||
package laniakea
|
||||
|
||||
import (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
34
keyboard.go
34
keyboard.go
@@ -1,13 +1,3 @@
|
||||
// Package laniakea provides a fluent builder system for constructing Telegram
|
||||
// inline keyboards with callback data and custom styling.
|
||||
//
|
||||
// This package supports:
|
||||
// - Button builders with style (danger/success/primary), icons, URLs, and callbacks
|
||||
// - Line-based keyboard layout with configurable max row size
|
||||
// - Structured, JSON-serialized callback data for bot command routing
|
||||
//
|
||||
// Keyboard construction is stateful and builder-style: methods return the receiver
|
||||
// to enable chaining. Call Get() to finalize and retrieve the tgapi.ReplyMarkup.
|
||||
package laniakea
|
||||
|
||||
import (
|
||||
@@ -119,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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
10
l10n.go
10
l10n.go
@@ -1,13 +1,3 @@
|
||||
// Package laniakea provides a simple, key-based localization system for
|
||||
// multi-language text translation.
|
||||
//
|
||||
// The system supports:
|
||||
// - Multiple language entries per key (e.g., "ru", "en", "es")
|
||||
// - Fallback language for missing translations
|
||||
// - Key-as-fallback behavior: if a key or language is not found, returns the key itself
|
||||
//
|
||||
// This is designed for lightweight, static localization in bots or services
|
||||
// where dynamic translation services are unnecessary.
|
||||
package laniakea
|
||||
|
||||
// DictEntry represents a single localized entry with language-to-text mappings.
|
||||
|
||||
@@ -1,22 +1,3 @@
|
||||
// Package laniakea provides a high-level context-based API for handling Telegram
|
||||
// bot interactions, including message responses, callback queries, inline keyboards,
|
||||
// localization, and message drafting. It wraps tgapi and adds convenience methods
|
||||
// with built-in rate limiting, error handling, and i18n support.
|
||||
//
|
||||
// The core type is MsgContext, which encapsulates the state of a Telegram update
|
||||
// and provides methods to respond, edit, delete, and translate messages.
|
||||
//
|
||||
// # Markdown Safety Warning
|
||||
//
|
||||
// All methods that accept MarkdownV2 formatting (e.g., AnswerMarkdown, EditCallbackfMarkdown)
|
||||
// require that user-provided text be escaped using laniakea.EscapeMarkdownV2().
|
||||
// Failure to escape user input may result in Telegram API errors, malformed messages,
|
||||
// or security issues.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// text := laniakea.EscapeMarkdownV2(userInput)
|
||||
// ctx.AnswerMarkdown("You said: " + text)
|
||||
package laniakea
|
||||
|
||||
import (
|
||||
@@ -410,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)
|
||||
}
|
||||
@@ -423,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)
|
||||
}
|
||||
|
||||
12
plugins.go
12
plugins.go
@@ -1,15 +1,3 @@
|
||||
// Package laniakea provides a structured system for defining and executing
|
||||
// bot commands and payloads with middleware support, argument validation,
|
||||
// and plugin-based organization.
|
||||
//
|
||||
// The core concepts are:
|
||||
// - Command: A named bot command with arguments, description, and executor.
|
||||
// - Plugin: A collection of commands and payloads, with shared middlewares.
|
||||
// - Middleware: Interceptors that can validate, modify, or block execution.
|
||||
// - CommandArg: Type-safe argument definitions with regex validation.
|
||||
//
|
||||
// This system is designed to be used with MsgContext from the laniakea package
|
||||
// to handle Telegram bot interactions in a modular, type-safe way.
|
||||
package laniakea
|
||||
|
||||
import (
|
||||
|
||||
10
runners.go
10
runners.go
@@ -1,13 +1,3 @@
|
||||
// Package laniakea provides a system for managing background and one-time
|
||||
// runner functions that operate on a Bot instance, with support for
|
||||
// asynchronous execution, timeouts, and lifecycle control.
|
||||
//
|
||||
// Runners are used for periodic tasks (e.g., cleanup, stats updates) or
|
||||
// one-time initialization logic. They are executed via Bot.ExecRunners().
|
||||
//
|
||||
// Important: Runners are not thread-safe for concurrent modification.
|
||||
// Builder methods (Onetime, Async, Timeout) must be called sequentially
|
||||
// and only before Execute().
|
||||
package laniakea
|
||||
|
||||
import (
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package tgapi
|
||||
|
||||
// UpdateType represents the type of an incoming update.
|
||||
// UpdateType represents the type of incoming update.
|
||||
type UpdateType string
|
||||
|
||||
const (
|
||||
|
||||
@@ -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.19"
|
||||
VersionString = "1.0.0-beta.21"
|
||||
VersionMajor = 1
|
||||
VersionMinor = 0
|
||||
VersionPatch = 0
|
||||
VersionBeta = 19
|
||||
VersionBeta = 21
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user