Compare commits
5 Commits
v1.0.0-bet
...
v1.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
389ec9f9d7
|
|||
|
fb81bb91bd
|
|||
|
589e11b22d
|
|||
|
5976fcd0b8
|
|||
|
6ba8520bb7
|
222
bot.go
222
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
|
package laniakea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -47,113 +15,6 @@ import (
|
|||||||
"github.com/alitto/pond/v2"
|
"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.
|
// DbContext is an interface representing the application's database context.
|
||||||
// It is injected into plugins and middleware via Bot.DatabaseContext().
|
// It is injected into plugins and middleware via Bot.DatabaseContext().
|
||||||
//
|
//
|
||||||
@@ -163,12 +24,16 @@ func LoadPrefixesFromEnv() []string {
|
|||||||
// bot := NewBot[MyDB](opts).DatabaseContext(&myDB)
|
// bot := NewBot[MyDB](opts).DatabaseContext(&myDB)
|
||||||
//
|
//
|
||||||
// Use NoDB if no database is needed.
|
// Use NoDB if no database is needed.
|
||||||
type DbContext interface{}
|
type DbContext any
|
||||||
|
|
||||||
// NoDB is a placeholder type for bots that do not use a database.
|
// NoDB is a placeholder type for bots that do not use a database.
|
||||||
// Use Bot[NoDB] to indicate no dependency injection is required.
|
// Use Bot[NoDB] to indicate no dependency injection is required.
|
||||||
type NoDB struct{ DbContext }
|
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.
|
// BotPayloadType defines the serialization format for callback data payloads.
|
||||||
type BotPayloadType string
|
type BotPayloadType string
|
||||||
|
|
||||||
@@ -195,6 +60,7 @@ type Bot[T DbContext] struct {
|
|||||||
errorTemplate string
|
errorTemplate string
|
||||||
username string
|
username string
|
||||||
payloadType BotPayloadType
|
payloadType BotPayloadType
|
||||||
|
maxWorkers int
|
||||||
|
|
||||||
logger *slog.Logger // Main bot logger (JSON stdout + optional file)
|
logger *slog.Logger // Main bot logger (JSON stdout + optional file)
|
||||||
RequestLogger *slog.Logger // Optional request-level API logging
|
RequestLogger *slog.Logger // Optional request-level API logging
|
||||||
@@ -254,10 +120,16 @@ func NewBot[T any](opts *BotOpts) *Bot[T] {
|
|||||||
prefixes = []string{"/"}
|
prefixes = []string{"/"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
workers := 32
|
||||||
|
if opts.MaxWorkers > 0 {
|
||||||
|
workers = opts.MaxWorkers
|
||||||
|
}
|
||||||
|
|
||||||
bot := &Bot[T]{
|
bot := &Bot[T]{
|
||||||
updateOffset: 0,
|
updateOffset: 0,
|
||||||
errorTemplate: "%s",
|
errorTemplate: "%s",
|
||||||
payloadType: BotPayloadBase64,
|
payloadType: BotPayloadBase64,
|
||||||
|
maxWorkers: workers,
|
||||||
updateQueue: updateQueue,
|
updateQueue: updateQueue,
|
||||||
api: api,
|
api: api,
|
||||||
uploader: uploader,
|
uploader: uploader,
|
||||||
@@ -398,34 +270,6 @@ func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] {
|
|||||||
return bot
|
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.
|
// DatabaseContext injects a database context into the bot.
|
||||||
// This context is accessible to plugins and middleware via GetDBContext().
|
// This context is accessible to plugins and middleware via GetDBContext().
|
||||||
func (bot *Bot[T]) DatabaseContext(ctx *T) *Bot[T] {
|
func (bot *Bot[T]) DatabaseContext(ctx *T) *Bot[T] {
|
||||||
@@ -569,13 +413,37 @@ func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] {
|
|||||||
return bot
|
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.
|
// RunWithContext starts the bot with a given context for graceful shutdown.
|
||||||
//
|
//
|
||||||
// This is the main entry point for bot execution. It:
|
// This is the main entry point for bot execution. It:
|
||||||
// - Validates required configuration (prefixes, plugins)
|
// - Validates required configuration (prefixes, plugins)
|
||||||
// - Starts all registered runners as background goroutines
|
// - Starts all registered runners as background goroutines
|
||||||
// - Begins polling for updates via Telegram's GetUpdates API
|
// - 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:
|
// The context controls graceful shutdown. When canceled, the bot:
|
||||||
// - Stops polling for new updates
|
// - Stops polling for new updates
|
||||||
@@ -599,12 +467,18 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.ExecRunners()
|
bot.ExecRunners(ctx)
|
||||||
|
|
||||||
bot.logger.Infoln("Bot running. Press CTRL+C to exit.")
|
bot.logger.Infoln("Bot running. Press CTRL+C to exit.")
|
||||||
|
|
||||||
// Start update polling in a goroutine
|
// Start update polling in a goroutine
|
||||||
go func() {
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
bot.logger.Errorln(fmt.Sprintf("panic in update polling: %v", r))
|
||||||
|
}
|
||||||
|
close(bot.updateQueue)
|
||||||
|
}()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -618,6 +492,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, u := range updates {
|
for _, u := range updates {
|
||||||
|
u := u // copy loop variable to avoid race condition
|
||||||
select {
|
select {
|
||||||
case bot.updateQueue <- &u:
|
case bot.updateQueue <- &u:
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -629,13 +504,14 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
// Start worker pool for concurrent update handling
|
// Start worker pool for concurrent update handling
|
||||||
pool := pond.NewPool(16)
|
pool := pond.NewPool(bot.maxWorkers)
|
||||||
for update := range bot.updateQueue {
|
for update := range bot.updateQueue {
|
||||||
update := update // capture loop variable
|
u := update // capture loop variable
|
||||||
pool.Submit(func() {
|
pool.Submit(func() {
|
||||||
bot.handle(update)
|
bot.handle(u)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
pool.Stop() // Wait for all tasks to complete and stop the pool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the bot using a background context.
|
// Run starts the bot using a background context.
|
||||||
|
|||||||
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
|
package laniakea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -41,6 +32,8 @@ var ErrTooManyCommands = errors.New("too many commands. max 100")
|
|||||||
//
|
//
|
||||||
// Command{command: "start", description: "Start the bot", args: []Arg{{text: "name", required: false}}}
|
// Command{command: "start", description: "Start the bot", args: []Arg{{text: "name", required: false}}}
|
||||||
// → Description: "Start the bot. Usage: /start [name]"
|
// → Description: "Start the bot. Usage: /start [name]"
|
||||||
|
// Command{command: "echo", description: "Echo user input", args: []Arg{{text: "name", required: true}}}
|
||||||
|
// → Description: "Echo user input. Usage: /echo <input>"
|
||||||
func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand {
|
func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand {
|
||||||
desc := ""
|
desc := ""
|
||||||
if len(cmd.description) > 0 {
|
if len(cmd.description) > 0 {
|
||||||
@@ -50,33 +43,31 @@ func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand {
|
|||||||
var descArgs []string
|
var descArgs []string
|
||||||
for _, a := range cmd.args {
|
for _, a := range cmd.args {
|
||||||
if a.required {
|
if a.required {
|
||||||
descArgs = append(descArgs, a.text)
|
descArgs = append(descArgs, fmt.Sprintf("<%s>", a.text))
|
||||||
} else {
|
} else {
|
||||||
descArgs = append(descArgs, fmt.Sprintf("[%s]", a.text))
|
descArgs = append(descArgs, fmt.Sprintf("[%s]", a.text))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
usage := fmt.Sprintf("Usage: /%s %s", cmd.command, strings.Join(descArgs, " "))
|
||||||
if desc != "" {
|
if desc != "" {
|
||||||
desc = fmt.Sprintf("%s. Usage: /%s %s", desc, cmd.command, strings.Join(descArgs, " "))
|
desc = fmt.Sprintf("%s. %s", desc, usage)
|
||||||
} else {
|
return tgapi.BotCommand{Command: cmd.command, Description: desc}
|
||||||
desc = fmt.Sprintf("Usage: /%s %s", cmd.command, strings.Join(descArgs, " "))
|
|
||||||
}
|
}
|
||||||
return tgapi.BotCommand{Command: cmd.command, Description: desc}
|
return tgapi.BotCommand{Command: cmd.command, Description: usage}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkCmdRegex check if command satisfy regexp [a-zA-Z0-9]+
|
// checkCmdRegex check if command satisfy regexp [a-zA-Z0-9]+
|
||||||
// Return true if satisfy, else false.
|
// Return true if satisfied, else false.
|
||||||
func checkCmdRegex(cmd string) bool {
|
func checkCmdRegex(cmd string) bool { return CmdRegexp.MatchString(cmd) }
|
||||||
return CmdRegexp.MatchString(cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateBotCommandForPlugin collects all non-skipped commands from a Plugin[T]
|
// gatherCommandsForPlugin collects all non-skipped commands from a Plugin[T]
|
||||||
// and converts them into tgapi.BotCommand objects.
|
// and converts them into tgapi.BotCommand objects.
|
||||||
//
|
//
|
||||||
// Commands marked with skipAutoCmd = true are excluded from auto-registration.
|
// Commands marked with skipAutoCmd = true are excluded from auto-registration.
|
||||||
// This allows plugins to opt out of automatic command generation (e.g., for
|
// This allows plugins to opt out of automatic command generation (e.g., for
|
||||||
// internal or hidden commands).
|
// internal or hidden commands).
|
||||||
func generateBotCommandForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand {
|
func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand {
|
||||||
commands := make([]tgapi.BotCommand, 0)
|
commands := make([]tgapi.BotCommand, 0)
|
||||||
for _, cmd := range pl.commands {
|
for _, cmd := range pl.commands {
|
||||||
if cmd.skipAutoCmd {
|
if cmd.skipAutoCmd {
|
||||||
@@ -90,6 +81,21 @@ func generateBotCommandForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand {
|
|||||||
return commands
|
return commands
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// gatherCommands collects all commands from all plugins
|
||||||
|
// and converts them into tgapi.BotCommand objects.
|
||||||
|
// See gatherCommandsForPlugin
|
||||||
|
func gatherCommands[T any](bot *Bot[T]) []tgapi.BotCommand {
|
||||||
|
commands := make([]tgapi.BotCommand, 0)
|
||||||
|
for _, pl := range bot.plugins {
|
||||||
|
if pl.skipAutoCmd {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
commands = append(commands, gatherCommandsForPlugin(pl)...)
|
||||||
|
bot.logger.Debugln(fmt.Sprintf("Registered %d commands from plugin %s", len(pl.commands), pl.name))
|
||||||
|
}
|
||||||
|
return commands
|
||||||
|
}
|
||||||
|
|
||||||
// AutoGenerateCommands registers all plugin-defined commands with Telegram's Bot API
|
// AutoGenerateCommands registers all plugin-defined commands with Telegram's Bot API
|
||||||
// across three scopes:
|
// across three scopes:
|
||||||
// - Private chats (users)
|
// - Private chats (users)
|
||||||
@@ -119,17 +125,7 @@ func (bot *Bot[T]) AutoGenerateCommands() error {
|
|||||||
return fmt.Errorf("failed to delete existing commands: %w", err)
|
return fmt.Errorf("failed to delete existing commands: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect all non-skipped commands from all plugins
|
commands := gatherCommands(bot)
|
||||||
commands := make([]tgapi.BotCommand, 0)
|
|
||||||
for _, pl := range bot.plugins {
|
|
||||||
if pl.skipAutoCmd {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
commands = append(commands, generateBotCommandForPlugin(pl)...)
|
|
||||||
bot.logger.Debugln(fmt.Sprintf("Registered %d commands from plugin %s", len(pl.commands), pl.name))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enforce Telegram's 100-command limit
|
|
||||||
if len(commands) > 100 {
|
if len(commands) > 100 {
|
||||||
return ErrTooManyCommands
|
return ErrTooManyCommands
|
||||||
}
|
}
|
||||||
@@ -153,3 +149,39 @@ func (bot *Bot[T]) AutoGenerateCommands() error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AutoGenerateCommandsForScope registers all plugin-defined commands with Telegram's Bot API
|
||||||
|
// for the specified command scope. It first deletes any existing commands in that scope
|
||||||
|
// to ensure a clean state, then sets the new set of commands.
|
||||||
|
//
|
||||||
|
// The scope parameter defines where the commands should be available (e.g., private chats,
|
||||||
|
// group chats, chat administrators). See tgapi.BotCommandScope and its predefined types.
|
||||||
|
//
|
||||||
|
// Returns ErrTooManyCommands if the total number of commands exceeds 100.
|
||||||
|
// Returns any API error from Telegram (e.g., network issues, invalid scope).
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// privateScope := &tgapi.BotCommandScope{Type: tgapi.BotCommandScopePrivateType}
|
||||||
|
// if err := bot.AutoGenerateCommandsForScope(privateScope); err != nil {
|
||||||
|
// log.Fatal(err)
|
||||||
|
// }
|
||||||
|
func (bot *Bot[T]) AutoGenerateCommandsForScope(scope *tgapi.BotCommandScope) error {
|
||||||
|
_, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommandsP{Scope: scope})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete existing commands: %w", err)
|
||||||
|
}
|
||||||
|
commands := gatherCommands(bot)
|
||||||
|
if len(commands) > 100 {
|
||||||
|
return ErrTooManyCommands
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = bot.api.SetMyCommands(tgapi.SetMyCommandsP{
|
||||||
|
Commands: commands,
|
||||||
|
Scope: scope,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to set commands for scope %q: %w", scope.Type, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
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
|
||||||
103
drafts.go
103
drafts.go
@@ -1,40 +1,16 @@
|
|||||||
// 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
|
package laniakea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ErrDraftChatIDZero = errors.New("zero draft chat ID")
|
||||||
|
|
||||||
// draftIdGenerator defines an interface for generating unique draft IDs.
|
// draftIdGenerator defines an interface for generating unique draft IDs.
|
||||||
type draftIdGenerator interface {
|
type draftIdGenerator interface {
|
||||||
// Next returns the next unique draft ID.
|
// Next returns the next unique draft ID.
|
||||||
@@ -68,15 +44,10 @@ func (g *LinearDraftIdGenerator) Next() uint64 {
|
|||||||
// DraftProvider is NOT thread-safe. Concurrent access from multiple goroutines
|
// DraftProvider is NOT thread-safe. Concurrent access from multiple goroutines
|
||||||
// requires external synchronization.
|
// requires external synchronization.
|
||||||
type DraftProvider struct {
|
type DraftProvider struct {
|
||||||
|
mu sync.RWMutex
|
||||||
api *tgapi.API
|
api *tgapi.API
|
||||||
drafts map[uint64]*Draft
|
drafts map[uint64]*Draft
|
||||||
generator draftIdGenerator
|
generator draftIdGenerator
|
||||||
|
|
||||||
// Internal defaults — not exposed directly to users.
|
|
||||||
chatID int64
|
|
||||||
messageThreadID int
|
|
||||||
parseMode tgapi.ParseMode
|
|
||||||
entities []tgapi.MessageEntity
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRandomDraftProvider creates a new DraftProvider using random draft IDs.
|
// NewRandomDraftProvider creates a new DraftProvider using random draft IDs.
|
||||||
@@ -107,38 +78,12 @@ func NewLinearDraftProvider(api *tgapi.API, startValue uint64) *DraftProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetChat sets the target chat and optional message thread for all drafts created
|
|
||||||
// by this provider. Must be called before NewDraft().
|
|
||||||
//
|
|
||||||
// If not set, NewDraft() will create drafts with zero chatID, which will cause
|
|
||||||
// SendMessageDraft to fail. Use this method to avoid runtime errors.
|
|
||||||
func (p *DraftProvider) SetChat(chatID int64, messageThreadID int) *DraftProvider {
|
|
||||||
p.chatID = chatID
|
|
||||||
p.messageThreadID = messageThreadID
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetParseMode sets the default parse mode for all new drafts.
|
|
||||||
// Overrides the parse mode passed to NewDraft() only if not specified there.
|
|
||||||
func (p *DraftProvider) SetParseMode(mode tgapi.ParseMode) *DraftProvider {
|
|
||||||
p.parseMode = mode
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetEntities sets the default message entities (e.g., bold, links, mentions)
|
|
||||||
// to be copied into every new draft.
|
|
||||||
//
|
|
||||||
// Entities are shallow-copied — if you mutate the slice later, it will affect
|
|
||||||
// future drafts. For safety, pass a copy if needed.
|
|
||||||
func (p *DraftProvider) SetEntities(entities []tgapi.MessageEntity) *DraftProvider {
|
|
||||||
p.entities = entities
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDraft retrieves a draft by its ID.
|
// GetDraft retrieves a draft by its ID.
|
||||||
//
|
//
|
||||||
// Returns the draft and true if found, or nil and false if not found.
|
// Returns the draft and true if found, or nil and false if not found.
|
||||||
func (p *DraftProvider) GetDraft(id uint64) (*Draft, bool) {
|
func (p *DraftProvider) GetDraft(id uint64) (*Draft, bool) {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
draft, ok := p.drafts[id]
|
draft, ok := p.drafts[id]
|
||||||
return draft, ok
|
return draft, ok
|
||||||
}
|
}
|
||||||
@@ -150,8 +95,16 @@ func (p *DraftProvider) GetDraft(id uint64) (*Draft, bool) {
|
|||||||
//
|
//
|
||||||
// After successful flush, each draft is removed from the provider and cleared.
|
// After successful flush, each draft is removed from the provider and cleared.
|
||||||
func (p *DraftProvider) FlushAll() error {
|
func (p *DraftProvider) FlushAll() error {
|
||||||
var lastErr error
|
p.mu.Lock()
|
||||||
|
drafts := make([]*Draft, 0, len(p.drafts))
|
||||||
for _, draft := range p.drafts {
|
for _, draft := range p.drafts {
|
||||||
|
drafts = append(drafts, draft)
|
||||||
|
}
|
||||||
|
p.drafts = make(map[uint64]*Draft)
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for _, draft := range drafts {
|
||||||
if err := draft.Flush(); err != nil {
|
if err := draft.Flush(); err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
break // Stop on first error to avoid partial state
|
break // Stop on first error to avoid partial state
|
||||||
@@ -186,22 +139,17 @@ type Draft struct {
|
|||||||
//
|
//
|
||||||
// Panics if chatID is zero — call SetChat() on the provider first.
|
// Panics if chatID is zero — call SetChat() on the provider first.
|
||||||
func (p *DraftProvider) NewDraft(parseMode tgapi.ParseMode) *Draft {
|
func (p *DraftProvider) NewDraft(parseMode tgapi.ParseMode) *Draft {
|
||||||
if p.chatID == 0 {
|
|
||||||
panic("laniakea: DraftProvider.SetChat() must be called before NewDraft()")
|
|
||||||
}
|
|
||||||
|
|
||||||
id := p.generator.Next()
|
id := p.generator.Next()
|
||||||
draft := &Draft{
|
draft := &Draft{
|
||||||
api: p.api,
|
api: p.api,
|
||||||
provider: p,
|
provider: p,
|
||||||
chatID: p.chatID,
|
parseMode: parseMode,
|
||||||
messageThreadID: p.messageThreadID,
|
ID: id,
|
||||||
parseMode: parseMode,
|
Message: "",
|
||||||
entities: p.entities, // Shallow copy — caller must ensure immutability
|
|
||||||
ID: id,
|
|
||||||
Message: "",
|
|
||||||
}
|
}
|
||||||
|
p.mu.Lock()
|
||||||
p.drafts[id] = draft
|
p.drafts[id] = draft
|
||||||
|
p.mu.Unlock()
|
||||||
return draft
|
return draft
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +201,9 @@ func (d *Draft) Clear() {
|
|||||||
// want to cancel a draft without sending it.
|
// want to cancel a draft without sending it.
|
||||||
func (d *Draft) Delete() {
|
func (d *Draft) Delete() {
|
||||||
if d.provider != nil {
|
if d.provider != nil {
|
||||||
|
d.provider.mu.Lock()
|
||||||
delete(d.provider.drafts, d.ID)
|
delete(d.provider.drafts, d.ID)
|
||||||
|
d.provider.mu.Unlock()
|
||||||
}
|
}
|
||||||
d.Clear()
|
d.Clear()
|
||||||
}
|
}
|
||||||
@@ -295,6 +245,9 @@ func (d *Draft) Flush() error {
|
|||||||
|
|
||||||
// push is the internal helper for Push(). It updates the server draft via SendMessageDraft.
|
// push is the internal helper for Push(). It updates the server draft via SendMessageDraft.
|
||||||
func (d *Draft) push(text string) error {
|
func (d *Draft) push(text string) error {
|
||||||
|
if d.chatID == 0 {
|
||||||
|
return ErrDraftChatIDZero
|
||||||
|
}
|
||||||
d.Message += text
|
d.Message += text
|
||||||
params := tgapi.SendMessageDraftP{
|
params := tgapi.SendMessageDraftP{
|
||||||
ChatID: d.chatID,
|
ChatID: d.chatID,
|
||||||
|
|||||||
17
handler.go
17
handler.go
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||||
@@ -12,6 +13,12 @@ import (
|
|||||||
var ErrInvalidPayloadType = errors.New("invalid payload type")
|
var ErrInvalidPayloadType = errors.New("invalid payload type")
|
||||||
|
|
||||||
func (bot *Bot[T]) handle(u *tgapi.Update) {
|
func (bot *Bot[T]) handle(u *tgapi.Update) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
ctx := &MsgContext{
|
ctx := &MsgContext{
|
||||||
Update: *u, Api: bot.api,
|
Update: *u, Api: bot.api,
|
||||||
botLogger: bot.logger,
|
botLogger: bot.logger,
|
||||||
@@ -84,7 +91,7 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) {
|
|||||||
if !plugin.executeMiddlewares(ctx, bot.dbContext) {
|
if !plugin.executeMiddlewares(ctx, bot.dbContext) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
go plugin.executeCmd(cmd, ctx, bot.dbContext)
|
plugin.executeCmd(cmd, ctx, bot.dbContext)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,7 +120,7 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) {
|
|||||||
if !plugin.executeMiddlewares(ctx, bot.dbContext) {
|
if !plugin.executeMiddlewares(ctx, bot.dbContext) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
go plugin.executePayload(data.Command, ctx, bot.dbContext)
|
plugin.executePayload(data.Command, ctx, bot.dbContext)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,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
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
package laniakea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -119,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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
|
package laniakea
|
||||||
|
|
||||||
// DictEntry represents a single localized entry with language-to-text mappings.
|
// DictEntry represents a single localized entry with language-to-text mappings.
|
||||||
|
|||||||
32
methods.go
32
methods.go
@@ -6,6 +6,38 @@ import (
|
|||||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Updates fetches new updates from Telegram API using long polling.
|
||||||
|
// It respects the bot's current update offset and automatically advances it
|
||||||
|
// after successful retrieval. The method supports selective update types
|
||||||
|
// through AllowedUpdates and includes optional request logging.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - None (uses bot's internal state for offset and allowed updates)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - []tgapi.Update: slice of received updates (empty if none available)
|
||||||
|
// - error: any error encountered during the API call
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// 1. Uses the bot's current update offset (via GetUpdateOffset)
|
||||||
|
// 2. Requests updates with 30-second timeout
|
||||||
|
// 3. Filters updates by types specified in bot.GetUpdateTypes()
|
||||||
|
// 4. Logs raw update JSON if RequestLogger is configured
|
||||||
|
// 5. Automatically updates the offset to the last received update ID + 1
|
||||||
|
// 6. Returns all received updates (empty slice if none)
|
||||||
|
//
|
||||||
|
// Note: This is a blocking call that waits up to 30 seconds for new updates.
|
||||||
|
// For non-blocking behavior, consider using webhooks instead.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// updates, err := bot.Updates()
|
||||||
|
// if err != nil {
|
||||||
|
// log.Fatal(err)
|
||||||
|
// }
|
||||||
|
// for _, update := range updates {
|
||||||
|
// // process update
|
||||||
|
// }
|
||||||
func (bot *Bot[T]) Updates() ([]tgapi.Update, error) {
|
func (bot *Bot[T]) Updates() ([]tgapi.Update, error) {
|
||||||
offset := bot.GetUpdateOffset()
|
offset := bot.GetUpdateOffset()
|
||||||
params := tgapi.UpdateParams{
|
params := tgapi.UpdateParams{
|
||||||
|
|||||||
@@ -1,27 +1,9 @@
|
|||||||
// 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
|
package laniakea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||||
"git.nix13.pw/scuroneko/slog"
|
"git.nix13.pw/scuroneko/slog"
|
||||||
@@ -31,10 +13,12 @@ import (
|
|||||||
// It provides methods to respond, edit, delete, and translate messages, as well as
|
// It provides methods to respond, edit, delete, and translate messages, as well as
|
||||||
// manage inline keyboards and message drafts.
|
// manage inline keyboards and message drafts.
|
||||||
type MsgContext struct {
|
type MsgContext struct {
|
||||||
Api *tgapi.API
|
Api *tgapi.API
|
||||||
Msg *tgapi.Message
|
Update tgapi.Update
|
||||||
Update tgapi.Update
|
|
||||||
From *tgapi.User
|
Msg *tgapi.Message
|
||||||
|
From *tgapi.User
|
||||||
|
|
||||||
CallbackMsgId int
|
CallbackMsgId int
|
||||||
CallbackQueryId string
|
CallbackQueryId string
|
||||||
FromID int
|
FromID int
|
||||||
@@ -385,7 +369,13 @@ func (ctx *MsgContext) error(err error) {
|
|||||||
func (ctx *MsgContext) Error(err error) { ctx.error(err) }
|
func (ctx *MsgContext) Error(err error) { ctx.error(err) }
|
||||||
|
|
||||||
func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft {
|
func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft {
|
||||||
c := context.Background()
|
if ctx.Msg == nil {
|
||||||
|
ctx.botLogger.Errorln("can't create draft: ctx.Msg is nil")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
c, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil {
|
if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil {
|
||||||
ctx.botLogger.Errorln(err)
|
ctx.botLogger.Errorln(err)
|
||||||
return nil
|
return nil
|
||||||
@@ -401,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)
|
||||||
}
|
}
|
||||||
@@ -414,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)
|
||||||
|
}
|
||||||
|
|||||||
43
plugins.go
43
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
|
package laniakea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -33,11 +21,14 @@ const (
|
|||||||
CommandValueAnyType CommandValueType = "any"
|
CommandValueAnyType CommandValueType = "any"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CommandRegexInt matches one or more digits.
|
var (
|
||||||
var CommandRegexInt = regexp.MustCompile(`\d+`)
|
// CommandRegexInt matches one or more digits.
|
||||||
|
CommandRegexInt = regexp.MustCompile(`\d+`)
|
||||||
// CommandRegexString matches any non-empty string.
|
// CommandRegexString matches any non-empty string.
|
||||||
var CommandRegexString = regexp.MustCompile(".+")
|
CommandRegexString = regexp.MustCompile(`.+`)
|
||||||
|
// CommandRegexBool matches true or false
|
||||||
|
CommandRegexBool = regexp.MustCompile(`true|false`)
|
||||||
|
)
|
||||||
|
|
||||||
// ErrCmdArgCountMismatch is returned when the number of provided arguments
|
// ErrCmdArgCountMismatch is returned when the number of provided arguments
|
||||||
// is less than the number of required arguments.
|
// is less than the number of required arguments.
|
||||||
@@ -58,15 +49,22 @@ type CommandArg struct {
|
|||||||
// NewCommandArg creates a new CommandArg with the given text and type.
|
// NewCommandArg creates a new CommandArg with the given text and type.
|
||||||
// Uses a default regex based on the type (string or int).
|
// Uses a default regex based on the type (string or int).
|
||||||
// For CommandValueAnyType, no validation is performed.
|
// For CommandValueAnyType, no validation is performed.
|
||||||
func NewCommandArg(text string, valueType CommandValueType) *CommandArg {
|
func NewCommandArg(text string) *CommandArg {
|
||||||
|
return &CommandArg{CommandValueAnyType, text, CommandRegexString, false}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CommandArg) SetValueType(t CommandValueType) *CommandArg {
|
||||||
regex := CommandRegexString
|
regex := CommandRegexString
|
||||||
switch valueType {
|
switch t {
|
||||||
case CommandValueIntType:
|
case CommandValueIntType:
|
||||||
regex = CommandRegexInt
|
regex = CommandRegexInt
|
||||||
|
case CommandValueBoolType:
|
||||||
|
regex = CommandRegexBool
|
||||||
case CommandValueAnyType:
|
case CommandValueAnyType:
|
||||||
regex = nil // Skip validation
|
regex = nil // Skip validation
|
||||||
}
|
}
|
||||||
return &CommandArg{valueType, text, regex, false}
|
c.regex = regex
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetRequired marks this argument as required.
|
// SetRequired marks this argument as required.
|
||||||
@@ -320,7 +318,10 @@ func (m *Middleware[T]) SetAsync(async bool) *Middleware[T] {
|
|||||||
// Otherwise, returns the result of the executor.
|
// Otherwise, returns the result of the executor.
|
||||||
func (m *Middleware[T]) Execute(ctx *MsgContext, db *T) bool {
|
func (m *Middleware[T]) Execute(ctx *MsgContext, db *T) bool {
|
||||||
if m.async {
|
if m.async {
|
||||||
go m.executor(ctx, db)
|
ctx := *ctx // copy context to avoid race condition
|
||||||
|
go func(ctx MsgContext) {
|
||||||
|
m.executor(&ctx, db)
|
||||||
|
}(ctx)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return m.executor(ctx, db)
|
return m.executor(ctx, db)
|
||||||
|
|||||||
37
runners.go
37
runners.go
@@ -1,16 +1,7 @@
|
|||||||
// 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
|
package laniakea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -83,7 +74,7 @@ func (r *Runner[T]) Timeout(timeout time.Duration) *Runner[T] {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExecRunners executes all runners registered on the Bot.
|
// ExecRunners executes all runners registered on the Bot with context-based lifecycle management.
|
||||||
//
|
//
|
||||||
// It logs warnings for misconfigured runners:
|
// It logs warnings for misconfigured runners:
|
||||||
// - Sync, non-onetime runners are skipped (invalid configuration).
|
// - Sync, non-onetime runners are skipped (invalid configuration).
|
||||||
@@ -92,11 +83,13 @@ func (r *Runner[T]) Timeout(timeout time.Duration) *Runner[T] {
|
|||||||
// Execution logic:
|
// Execution logic:
|
||||||
// - onetime + async: Runs once in a goroutine.
|
// - onetime + async: Runs once in a goroutine.
|
||||||
// - onetime + sync: Runs once synchronously; warns if slower than 2 seconds.
|
// - onetime + sync: Runs once synchronously; warns if slower than 2 seconds.
|
||||||
// - !onetime + async: Runs in an infinite loop with timeout between iterations.
|
// - !onetime + async: Runs in a loop with timeout between iterations until ctx.Done().
|
||||||
// - !onetime + sync: Skipped with warning.
|
// - !onetime + sync: Skipped with warning.
|
||||||
//
|
//
|
||||||
// This method is typically called once during bot startup.
|
// Background runners listen for ctx.Done() and gracefully shut down when the context is canceled.
|
||||||
func (bot *Bot[T]) ExecRunners() {
|
//
|
||||||
|
// This method is typically called once during bot startup in RunWithContext.
|
||||||
|
func (bot *Bot[T]) ExecRunners(ctx context.Context) {
|
||||||
bot.logger.Infoln("Executing runners...")
|
bot.logger.Infoln("Executing runners...")
|
||||||
for _, runner := range bot.runners {
|
for _, runner := range bot.runners {
|
||||||
// Validate configuration
|
// Validate configuration
|
||||||
@@ -128,14 +121,20 @@ func (bot *Bot[T]) ExecRunners() {
|
|||||||
bot.logger.Warnf("Runner %s too slow. Elapsed time %v >= 2s\n", runner.name, elapsed)
|
bot.logger.Warnf("Runner %s too slow. Elapsed time %v >= 2s\n", runner.name, elapsed)
|
||||||
}
|
}
|
||||||
} else if !runner.onetime && runner.async {
|
} else if !runner.onetime && runner.async {
|
||||||
// Background loop: periodic execution
|
// Background loop: periodic execution with graceful shutdown
|
||||||
go func(r Runner[T]) {
|
go func(r Runner[T]) {
|
||||||
|
ticker := time.NewTicker(r.timeout)
|
||||||
|
defer ticker.Stop()
|
||||||
for {
|
for {
|
||||||
err := r.fn(bot)
|
select {
|
||||||
if err != nil {
|
case <-ctx.Done():
|
||||||
bot.logger.Warnf("Runner %s failed: %s\n", r.name, err)
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
err := r.fn(bot)
|
||||||
|
if err != nil {
|
||||||
|
bot.logger.Warnf("Runner %s failed: %s\n", r.name, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
time.Sleep(r.timeout)
|
|
||||||
}
|
}
|
||||||
}(runner)
|
}(runner)
|
||||||
}
|
}
|
||||||
|
|||||||
60
tgapi/api.go
60
tgapi/api.go
@@ -161,27 +161,13 @@ type TelegramRequest[R, P any] struct {
|
|||||||
chatId int64
|
chatId int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRequest and NewRequestWithChatID are DEPRECATED.
|
// NewRequest creates an untyped TelegramRequest for the given method and params with no chat ID.
|
||||||
// They encourage unsafe, untyped usage and bypass Go's type safety.
|
|
||||||
// Instead, define explicit, type-safe methods for each Telegram API endpoint.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// func (api *API) SendMessage(ctx context.Context, chatID int64, text string) (Message, error) { ... }
|
|
||||||
//
|
|
||||||
// This provides:
|
|
||||||
//
|
|
||||||
// ✅ Compile-time validation
|
|
||||||
// ✅ IDE autocompletion
|
|
||||||
// ✅ Clear API surface
|
|
||||||
// ✅ Better error messages
|
|
||||||
//
|
|
||||||
// DO NOT use these constructors in production code.
|
|
||||||
// This can be used ONLY for testing or if you NEED method, that wasn't added as function.
|
|
||||||
func NewRequest[R, P any](method string, params P) TelegramRequest[R, P] {
|
func NewRequest[R, P any](method string, params P) TelegramRequest[R, P] {
|
||||||
return TelegramRequest[R, P]{method, params, 0}
|
return TelegramRequest[R, P]{method, params, 0}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewRequestWithChatID creates an untyped TelegramRequest with an associated chat ID.
|
||||||
|
// The chat ID is used for per-chat rate limiting.
|
||||||
func NewRequestWithChatID[R, P any](method string, params P, chatId int64) TelegramRequest[R, P] {
|
func NewRequestWithChatID[R, P any](method string, params P, chatId int64) TelegramRequest[R, P] {
|
||||||
return TelegramRequest[R, P]{method, params, chatId}
|
return TelegramRequest[R, P]{method, params, chatId}
|
||||||
}
|
}
|
||||||
@@ -191,12 +177,10 @@ func NewRequestWithChatID[R, P any](method string, params P, chatId int64) Teleg
|
|||||||
// Must be called within a worker pool context if using DoWithContext.
|
// Must be called within a worker pool context if using DoWithContext.
|
||||||
func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, error) {
|
func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, error) {
|
||||||
var zero R
|
var zero R
|
||||||
|
reqData, err := json.Marshal(r.params)
|
||||||
data, err := json.Marshal(r.params)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return zero, fmt.Errorf("failed to marshal request: %w", err)
|
return zero, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
}
|
}
|
||||||
buf := bytes.NewBuffer(data)
|
|
||||||
|
|
||||||
methodPrefix := ""
|
methodPrefix := ""
|
||||||
if api.useTestServer {
|
if api.useTestServer {
|
||||||
@@ -204,7 +188,7 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro
|
|||||||
}
|
}
|
||||||
url := fmt.Sprintf("%s/bot%s%s/%s", api.apiUrl, api.token, methodPrefix, r.method)
|
url := fmt.Sprintf("%s/bot%s%s/%s", api.apiUrl, api.token, methodPrefix, r.method)
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", url, buf)
|
req, err := http.NewRequestWithContext(ctx, "POST", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return zero, fmt.Errorf("failed to create request: %w", err)
|
return zero, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
@@ -213,7 +197,6 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro
|
|||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString))
|
req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString))
|
||||||
req.Header.Set("Accept-Encoding", "gzip")
|
req.Header.Set("Accept-Encoding", "gzip")
|
||||||
req.ContentLength = int64(len(data))
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
// Apply rate limiting before making the request
|
// Apply rate limiting before making the request
|
||||||
@@ -222,22 +205,25 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro
|
|||||||
return zero, err
|
return zero, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
buf := bytes.NewBuffer(reqData)
|
||||||
|
req.Body = io.NopCloser(buf)
|
||||||
|
req.ContentLength = int64(len(reqData))
|
||||||
|
|
||||||
api.logger.Debugln("REQ", url, string(data))
|
api.logger.Debugln("REQ", url, string(reqData))
|
||||||
resp, err := api.client.Do(req)
|
resp, err := api.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return zero, fmt.Errorf("HTTP request failed: %w", err)
|
return zero, fmt.Errorf("HTTP request failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err = readBody(resp.Body)
|
respData, err := readBody(resp.Body)
|
||||||
_ = resp.Body.Close() // ensure body is closed
|
_ = resp.Body.Close() // ensure body is closed
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return zero, fmt.Errorf("failed to read response body: %w", err)
|
return zero, fmt.Errorf("failed to read response body: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
api.logger.Debugln("RES", r.method, string(data))
|
api.logger.Debugln("RES", r.method, string(respData))
|
||||||
|
|
||||||
response, err := parseBody[R](data)
|
response, err := parseBody[R](respData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return zero, fmt.Errorf("failed to parse response: %w", err)
|
return zero, fmt.Errorf("failed to parse response: %w", err)
|
||||||
}
|
}
|
||||||
@@ -249,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package tgapi
|
package tgapi
|
||||||
|
|
||||||
// UpdateType represents the type of an incoming update.
|
// UpdateType represents the type of incoming update.
|
||||||
type UpdateType string
|
type UpdateType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -193,8 +197,10 @@ func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) erro
|
|||||||
|
|
||||||
// getChatLimiter returns the rate limiter for the given chat, creating it if needed.
|
// getChatLimiter returns the rate limiter for the given chat, creating it if needed.
|
||||||
// Uses 1 request per second with burst of 1 — conservative for per-user limits.
|
// Uses 1 request per second with burst of 1 — conservative for per-user limits.
|
||||||
// Must be called with rl.chatMu held.
|
|
||||||
func (rl *RateLimiter) getChatLimiter(chatID int64) *rate.Limiter {
|
func (rl *RateLimiter) getChatLimiter(chatID int64) *rate.Limiter {
|
||||||
|
rl.chatMu.Lock()
|
||||||
|
defer rl.chatMu.Unlock()
|
||||||
|
|
||||||
if lim, ok := rl.chatLimiters[chatID]; ok {
|
if lim, ok := rl.chatLimiters[chatID]; ok {
|
||||||
return lim
|
return lim
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package utils
|
package utils
|
||||||
|
|
||||||
const (
|
const (
|
||||||
VersionString = "1.0.0-beta.17"
|
VersionString = "1.0.0-beta.21"
|
||||||
VersionMajor = 1
|
VersionMajor = 1
|
||||||
VersionMinor = 0
|
VersionMinor = 0
|
||||||
VersionPatch = 0
|
VersionPatch = 0
|
||||||
VersionBeta = 17
|
VersionBeta = 21
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user