From b1b0cbdfbd74645aaeac2af26107022f609a24b2 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Thu, 12 Mar 2026 14:02:32 +0300 Subject: [PATCH] v1.0.0 beta 12 --- bot.go | 372 ++++++++++++++++++++++++++++++++++---- cmd_generator.go | 97 ++++++++-- drafts.go | 289 +++++++++++++++++++++++------ examples/basic/.env | 7 + examples/basic/example.go | 28 +++ examples/basic/go.mod | 16 ++ examples/basic/go.sum | 19 ++ go.mod | 6 +- go.sum | 12 +- keyboard.go | 127 +++++++++++-- l10n.go | 76 +++++++- msg_context.go | 291 ++++++++++++++++++++--------- plugins.go | 228 ++++++++++++++++++----- runners.go | 137 ++++++++++---- tgapi/methods.go | 1 + utils.go | 8 +- utils/version.go | 4 +- 17 files changed, 1410 insertions(+), 308 deletions(-) create mode 100644 examples/basic/.env create mode 100644 examples/basic/example.go create mode 100644 examples/basic/go.mod create mode 100644 examples/basic/go.sum diff --git a/bot.go b/bot.go index f7439de..c791eda 100644 --- a/bot.go +++ b/bot.go @@ -1,3 +1,34 @@ +// 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() +// <-ctx.Done() // wait for shutdown signal +// +// All methods are thread-safe except direct field access. Use provided accessors +// (e.g., GetDBContext, SetUpdateOffset) for safe concurrent access. package laniakea import ( @@ -8,6 +39,7 @@ import ( "strconv" "strings" "sync" + "time" "git.nix13.pw/scuroneko/extypes" "git.nix13.pw/scuroneko/laniakea/tgapi" @@ -16,30 +48,81 @@ 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 string + // 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 bool + // 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 []string - LoggerBasePath 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 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 string - RateLimit int + // 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 != "" { - rateLimit, _ = strconv.Atoi(rl) + if n, err := strconv.Atoi(rl); err == nil { + rateLimit = n + } } return &BotOpts{ @@ -50,6 +133,7 @@ func LoadOptsFromEnv() *BotOpts { 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", @@ -60,6 +144,9 @@ func LoadOptsFromEnv() *BotOpts { 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 { @@ -68,36 +155,75 @@ func LoadPrefixesFromEnv() []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(). +// +// Example: +// +// type MyDB struct { ... } +// bot := NewBot[MyDB](opts).DatabaseContext(&myDB) +// +// Use NoDB if no database is needed. type DbContext interface{} + +// NoDB is a placeholder type for bots that do not use a database. +// Use Bot[NoDB] to indicate no dependency injection is required. type NoDB struct{ DbContext } + +// Bot is the core Telegram bot instance. +// +// Manages: +// - API communication via tgapi +// - Update processing pipeline (middleware → plugins) +// - Background runners +// - Logging and rate limiting +// - Localization and draft message support +// +// All methods are safe for concurrent use. Direct field access is not recommended. type Bot[T DbContext] struct { token string debug bool errorTemplate string username string - logger *slog.Logger - RequestLogger *slog.Logger - extraLoggers extypes.Slice[*slog.Logger] + logger *slog.Logger // Main bot logger (JSON stdout + optional file) + RequestLogger *slog.Logger // Optional request-level API logging + extraLoggers extypes.Slice[*slog.Logger] // API, Uploader, and custom loggers - plugins []Plugin[T] - middlewares []Middleware[T] - prefixes []string - runners []Runner[T] + plugins []Plugin[T] // Command/event handlers + middlewares []Middleware[T] // Pre-processing filters (sorted by order) + prefixes []string // Command prefixes (e.g., "/", "!") + runners []Runner[T] // Background tasks (e.g., cleanup, cron) - api *tgapi.API - uploader *tgapi.Uploader - dbContext *T - l10n *L10n - draftProvider *DraftProvider + api *tgapi.API // Telegram API client + uploader *tgapi.Uploader // File uploader + dbContext *T // Injected database context + l10n *L10n // Localization manager + draftProvider *DraftProvider // Draft message builder updateOffsetMu sync.Mutex - updateOffset int - updateTypes []tgapi.UpdateType - updateQueue chan *tgapi.Update + updateOffset int // Last processed update ID + updateTypes []tgapi.UpdateType // Types of updates to fetch + updateQueue chan *tgapi.Update // Internal queue for processing updates } +// NewBot creates and initializes a new Bot instance using the provided BotOpts. +// +// Automatically: +// - Creates API and Uploader clients +// - Initializes structured logging (JSON stdout + optional file) +// - Fetches bot username via GetMe() +// - Sets up DraftProvider with random IDs +// - Adds API and Uploader loggers to extraLoggers +// +// Panics if: +// - Token is empty +// - GetMe() fails (invalid token or network error) func NewBot[T any](opts *BotOpts) *Bot[T] { + if opts.Token == "" { + panic("laniakea: BotOpts.Token is required") + } + updateQueue := make(chan *tgapi.Update, 512) var limiter *utils.RateLimiter @@ -105,7 +231,10 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { limiter = utils.NewRateLimiter() } - apiOpts := tgapi.NewAPIOpts(opts.Token).SetAPIUrl(opts.APIUrl).UseTestServer(opts.UseTestServer).SetLimiter(limiter) + apiOpts := tgapi.NewAPIOpts(opts.Token). + SetAPIUrl(opts.APIUrl). + UseTestServer(opts.UseTestServer). + SetLimiter(limiter) api := tgapi.NewAPI(apiOpts) uploader := tgapi.NewUploader(api) @@ -126,6 +255,8 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { l10n: &L10n{}, draftProvider: NewRandomDraftProvider(api), } + + // Add API and Uploader loggers to extraLoggers for unified output bot.extraLoggers = bot.extraLoggers.Push(api.GetLogger()).Push(uploader.GetLogger()) if len(opts.ErrorTemplate) > 0 { @@ -136,6 +267,7 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { } bot.initLoggers(opts) + // Fetch bot info to validate token and get username u, err := api.GetMe() if err != nil { _ = bot.Close() @@ -143,12 +275,22 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { } bot.username = Val(u.Username, "") if bot.username == "" { - bot.logger.Warn("Can't get bot username. Named command wouldn't work!") + bot.logger.Warn("Can't get bot username. Named command handlers won't work!") } - bot.logger.Infof("Authorized as %s\n", u.FirstName) + bot.logger.Infof("Authorized as %s (@%s)\n", u.FirstName, u.Username) return bot } + +// Close gracefully shuts down the bot. +// +// Closes: +// - Uploader (waits for pending uploads) +// - API client +// - RequestLogger (if enabled) +// - Main logger +// +// Returns the first error encountered, if any. func (bot *Bot[T]) Close() error { if err := bot.uploader.Close(); err != nil { bot.logger.Errorln(err) @@ -156,14 +298,22 @@ func (bot *Bot[T]) Close() error { if err := bot.api.CloseApi(); err != nil { bot.logger.Errorln(err) } - if err := bot.RequestLogger.Close(); err != nil { - bot.logger.Errorln(err) + if bot.RequestLogger != nil { + if err := bot.RequestLogger.Close(); err != nil { + bot.logger.Errorln(err) + } } if err := bot.logger.Close(); err != nil { return err } return nil } + +// initLoggers configures the main and optional request loggers. +// +// Uses DEBUG flag to set log level (DEBUG if true, FATAL otherwise). +// Writes to stdout in JSON format by default. +// If WriteToFile is true, writes to main.log and requests.log in LoggerBasePath. func (bot *Bot[T]) initLoggers(opts *BotOpts) { level := slog.FATAL if opts.Debug { @@ -195,27 +345,59 @@ func (bot *Bot[T]) initLoggers(opts *BotOpts) { } } +// GetUpdateOffset returns the current update offset (thread-safe). func (bot *Bot[T]) GetUpdateOffset() int { bot.updateOffsetMu.Lock() defer bot.updateOffsetMu.Unlock() return bot.updateOffset } + +// SetUpdateOffset sets the update offset for next GetUpdates call (thread-safe). func (bot *Bot[T]) SetUpdateOffset(offset int) { bot.updateOffsetMu.Lock() defer bot.updateOffsetMu.Unlock() bot.updateOffset = offset } + +// GetUpdateTypes returns the list of update types the bot is configured to receive. func (bot *Bot[T]) GetUpdateTypes() []tgapi.UpdateType { return bot.updateTypes } -func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger } -func (bot *Bot[T]) GetDBContext() *T { return bot.dbContext } -func (bot *Bot[T]) L10n(lang, key string) string { return bot.l10n.Translate(lang, key) } + +// GetLogger returns the main bot logger. +func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger } + +// GetDBContext returns the injected database context. +// Returns nil if not set via DatabaseContext(). +func (bot *Bot[T]) GetDBContext() *T { return bot.dbContext } + +// L10n translates a key in the given language. +// Returns empty string if translation not found. +func (bot *Bot[T]) L10n(lang, key string) string { + return bot.l10n.Translate(lang, key) +} + +// SetDraftProvider replaces the default DraftProvider with a custom one. +// Useful for using LinearDraftIdGenerator to persist draft IDs across restarts. func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] { bot.draftProvider = p 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) @@ -228,31 +410,51 @@ func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] { 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] { bot.dbContext = ctx return bot } + +// UpdateTypes sets the list of update types the bot will request from Telegram. +// Overwrites any previously set types. func (bot *Bot[T]) UpdateTypes(t ...tgapi.UpdateType) *Bot[T] { bot.updateTypes = make([]tgapi.UpdateType, 0) bot.updateTypes = append(bot.updateTypes, t...) return bot } + +// AddUpdateType adds one or more update types to the list. +// Does not overwrite existing types. func (bot *Bot[T]) AddUpdateType(t ...tgapi.UpdateType) *Bot[T] { bot.updateTypes = append(bot.updateTypes, t...) return bot } + +// AddPrefixes adds one or more command prefixes (e.g., "/", "!"). +// Must have at least one prefix before Run(). func (bot *Bot[T]) AddPrefixes(prefixes ...string) *Bot[T] { bot.prefixes = append(bot.prefixes, prefixes...) return bot } + +// ErrorTemplate sets the format string for error messages sent to users. +// Use "%s" to insert the error message. +// Example: "❌ Error: %s" → "❌ Error: Command not found" func (bot *Bot[T]) ErrorTemplate(s string) *Bot[T] { bot.errorTemplate = s return bot } + +// Debug enables or disables debug logging. func (bot *Bot[T]) Debug(debug bool) *Bot[T] { bot.debug = debug return bot } + +// AddPlugins registers one or more plugins. +// Plugins are executed in registration order unless filtered by middleware. func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] { for _, p := range plugin { bot.plugins = append(bot.plugins, *p) @@ -260,33 +462,96 @@ func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] { } return bot } + +// AddMiddleware registers one or more middleware handlers. +// +// Middleware are executed in order of increasing .order value before plugins. +// If two middleware have the same order, they are sorted lexicographically by name. +// +// Middleware can: +// - Modify or reject updates before they reach plugins +// - Inject context (e.g., user auth state, rate limit status) +// - Log, validate, or transform incoming data +// +// Example: +// +// bot.AddMiddleware(&authMiddleware, &rateLimitMiddleware) +// +// Panics if any middleware has a nil name. func (bot *Bot[T]) AddMiddleware(middleware ...Middleware[T]) *Bot[T] { - bot.middlewares = append(bot.middlewares, middleware...) for _, m := range middleware { + if m.name == "" { + panic("laniakea: middleware must have a non-empty name") + } + bot.middlewares = append(bot.middlewares, m) bot.logger.Debugln(fmt.Sprintf("middleware with name \"%s\" registered", m.name)) } + // Stable sort by order (ascending), then by name (lexicographic) sort.Slice(bot.middlewares, func(i, j int) bool { first := bot.middlewares[i] second := bot.middlewares[j] - if first.order == second.order { - return first.name < second.name + if first.order != second.order { + return first.order < second.order } - return first.order < second.order + return first.name < second.name }) return bot } + +// AddRunner registers a background runner to execute concurrently with the bot. +// +// Runners are goroutines that run independently of update processing. +// Common use cases: +// - Periodic cleanup (e.g., expiring drafts, clearing temp files) +// - Metrics collection or health checks +// - Scheduled tasks (e.g., daily announcements) +// +// Runners are started immediately after Bot.Run() is called. +// +// Example: +// +// bot.AddRunner(&cleanupRunner) +// +// Panics if runner has a nil name. func (bot *Bot[T]) AddRunner(runner Runner[T]) *Bot[T] { + if runner.name == "" { + panic("laniakea: runner must have a non-empty name") + } bot.runners = append(bot.runners, runner) bot.logger.Debugln(fmt.Sprintf("runner with name \"%s\" registered", runner.name)) return bot } + +// AddL10n sets the localization (i18n) provider for the bot. +// +// The L10n instance must be pre-populated with translations. +// Translations are accessed via Bot.L10n(lang, key). +// +// Example: +// +// l10n := l10n.New() +// l10n.Add("en", "hello", "Hello!") +// l10n.Add("es", "hello", "¡Hola!") +// bot.AddL10n(l10n) +// +// Replaces any previously set L10n instance. func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] { + if l == nil { + bot.logger.Warn("AddL10n called with nil L10n; localization will be disabled") + } bot.l10n = l return bot } +// enqueueUpdate attempts to add an update to the internal processing queue. +// +// Returns extypes.QueueFullErr if the queue is full and the update cannot be enqueued. +// This is non-blocking and used to implement rate-limiting behavior. +// +// When DropRLOverflow is enabled, this error is ignored and the update is dropped. +// Otherwise, the update is retried via the main update loop. func (bot *Bot[T]) enqueueUpdate(u *tgapi.Update) error { select { case bot.updateQueue <- u: @@ -295,6 +560,26 @@ func (bot *Bot[T]) enqueueUpdate(u *tgapi.Update) error { return extypes.QueueFullErr } } + +// 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) +// +// The context controls graceful shutdown. When canceled, the bot: +// - Stops polling for new updates +// - Finishes processing currently queued updates +// - Closes all resources (API, uploader, loggers) +// +// Example: +// +// ctx, cancel := context.WithCancel(context.Background()) +// go bot.RunWithContext(ctx) +// // ... later ... +// cancel() // triggers graceful shutdown func (bot *Bot[T]) RunWithContext(ctx context.Context) { if len(bot.prefixes) == 0 { bot.logger.Fatalln("no prefixes defined") @@ -309,6 +594,8 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) { bot.ExecRunners() bot.logger.Infoln("Bot running. Press CTRL+C to exit.") + + // Start update polling in a goroutine go func() { for { select { @@ -317,13 +604,14 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) { default: updates, err := bot.Updates() if err != nil { - bot.logger.Errorln(err) + bot.logger.Errorln("failed to fetch updates:", err) + time.Sleep(2 * time.Second) // exponential backoff continue } for _, u := range updates { select { - case bot.updateQueue <- new(u): + case bot.updateQueue <- u: case <-ctx.Done(): return } @@ -332,14 +620,22 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) { } }() + // Start worker pool for concurrent update handling pool := pond.NewPool(16) for update := range bot.updateQueue { - update := update + update := update // capture loop variable pool.Submit(func() { bot.handle(update) }) } } + +// Run starts the bot using a background context. +// +// Equivalent to RunWithContext(context.Background()). +// Use this for simple bots where graceful shutdown is not required. +// +// For production use, prefer RunWithContext to handle SIGINT/SIGTERM gracefully. func (bot *Bot[T]) Run() { bot.RunWithContext(context.Background()) } diff --git a/cmd_generator.go b/cmd_generator.go index 8723e51..ec7ce01 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -1,3 +1,12 @@ +// 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 ( @@ -8,11 +17,33 @@ import ( "git.nix13.pw/scuroneko/laniakea/tgapi" ) +// ErrTooManyCommands is returned when the total number of registered commands +// exceeds Telegram's limit of 100 bot commands per bot. +// +// Telegram Bot API enforces this limit strictly. If exceeded, SetMyCommands +// will fail with a 400 error. This error helps catch the issue early during +// bot initialization. +var ErrTooManyCommands = errors.New("too many commands. max 100") + +// generateBotCommand converts a Command[T] into a tgapi.BotCommand with a +// formatted description that includes usage instructions. +// +// The description is built as: +// +// ". Usage: / [] ..." +// +// Required arguments are shown as-is; optional arguments are wrapped in square brackets. +// +// Example: +// +// Command{command: "start", description: "Start the bot", args: []Arg{{text: "name", required: false}}} +// → Description: "Start the bot. Usage: /start [name]" func generateBotCommand[T any](cmd Command[T]) tgapi.BotCommand { desc := cmd.command if len(cmd.description) > 0 { desc = cmd.description } + var descArgs []string for _, a := range cmd.args { if a.required { @@ -21,10 +52,17 @@ func generateBotCommand[T any](cmd Command[T]) tgapi.BotCommand { descArgs = append(descArgs, fmt.Sprintf("[%s]", a.text)) } } + desc = fmt.Sprintf("%s. Usage: /%s %s", desc, cmd.command, strings.Join(descArgs, " ")) return tgapi.BotCommand{Command: cmd.command, Description: desc} } +// generateBotCommandForPlugin collects all non-skipped commands from a Plugin[T] +// and converts them into tgapi.BotCommand objects. +// +// Commands marked with skipAutoCmd = true are excluded from auto-registration. +// This allows plugins to opt out of automatic command generation (e.g., for +// internal or hidden commands). func generateBotCommandForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { commands := make([]tgapi.BotCommand, 0) for _, cmd := range pl.commands { @@ -36,37 +74,66 @@ func generateBotCommandForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { return commands } -var ErrTooManyCommands = errors.New("too many commands. max 100") - +// AutoGenerateCommands registers all plugin-defined commands with Telegram's Bot API +// across three scopes: +// - Private chats (users) +// - Group chats +// - Group administrators +// +// It first deletes existing commands to ensure a clean state, then sets the new +// set of commands for all scopes. This ensures consistency even if commands were +// previously modified manually via @BotFather. +// +// Returns ErrTooManyCommands if the total number of commands exceeds 100. +// Returns any API error from Telegram (e.g., network issues, invalid scope). +// +// Important: This method assumes the bot has been properly initialized and +// the API client is authenticated and ready. +// +// Usage: +// +// err := bot.AutoGenerateCommands() +// if err != nil { +// log.Fatal(err) +// } func (bot *Bot[T]) AutoGenerateCommands() error { + // Clear existing commands to avoid duplication or stale entries _, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommandsP{}) if err != nil { - return err + return fmt.Errorf("failed to delete existing commands: %w", err) } + // Collect all non-skipped commands from all plugins commands := make([]tgapi.BotCommand, 0) for _, pl := range bot.plugins { if pl.skipAutoCmd { continue } - commands = append(commands, generateBotCommandForPlugin(pl)...) + bot.logger.Debugf("Registered %d commands from plugin %s", len(pl.commands), pl.name) } + + // Enforce Telegram's 100-command limit if len(commands) > 100 { return ErrTooManyCommands } - privateChatsScope := &tgapi.BotCommandScope{Type: tgapi.BotCommandScopePrivateType} - groupChatsScope := &tgapi.BotCommandScope{Type: tgapi.BotCommandScopeGroupType} - chatAdminsScope := &tgapi.BotCommandScope{Type: tgapi.BotCommandScopeAllChatAdministratorsType} - _, err = bot.api.SetMyCommands(tgapi.SetMyCommandsP{Commands: commands, Scope: privateChatsScope}) - if err != nil { - return err + // Register commands for each scope + scopes := []*tgapi.BotCommandScope{ + {Type: tgapi.BotCommandScopePrivateType}, + {Type: tgapi.BotCommandScopeGroupType}, + {Type: tgapi.BotCommandScopeAllChatAdministratorsType}, } - _, err = bot.api.SetMyCommands(tgapi.SetMyCommandsP{Commands: commands, Scope: groupChatsScope}) - if err != nil { - return err + + for _, scope := range scopes { + _, 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) + } } - _, err = bot.api.SetMyCommands(tgapi.SetMyCommandsP{Commands: commands, Scope: chatAdminsScope}) - return err + + return nil } diff --git a/drafts.go b/drafts.go index 2362bdb..3de0784 100644 --- a/drafts.go +++ b/drafts.go @@ -1,3 +1,31 @@ +// 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) +// provider.SetChat(-1001234567890, 0).SetParseMode(tgapi.ParseModeHTML) +// +// draft := provider.NewDraft(tgapi.ParseModeMarkdown) +// 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 ( @@ -7,38 +35,137 @@ import ( "git.nix13.pw/scuroneko/laniakea/tgapi" ) +// draftIdGenerator defines an interface for generating unique draft IDs. type draftIdGenerator interface { + // Next returns the next unique draft ID. Next() uint64 } -type RandomDraftIdGenerator struct { - draftIdGenerator -} +// RandomDraftIdGenerator generates draft IDs using cryptographically secure random numbers. +// Suitable for distributed systems or when ID predictability is undesirable. +type RandomDraftIdGenerator struct{} +// Next returns a random 64-bit unsigned integer. func (g *RandomDraftIdGenerator) Next() uint64 { return rand.Uint64() } +// LinearDraftIdGenerator generates draft IDs using a monotonically increasing counter. +// Useful for debugging, persistence, or when drafts must be ordered. type LinearDraftIdGenerator struct { - draftIdGenerator lastId atomic.Uint64 } +// Next returns the next linear ID, atomically incremented. func (g *LinearDraftIdGenerator) Next() uint64 { return g.lastId.Add(1) } +// DraftProvider manages a collection of Drafts and provides methods to create and +// configure them. It holds shared configuration (chat, parse mode, entities) and +// a draft ID generator. +// +// DraftProvider is NOT thread-safe. Concurrent access from multiple goroutines +// requires external synchronization. type DraftProvider struct { - api *tgapi.API + api *tgapi.API + drafts map[uint64]*Draft + generator draftIdGenerator + // Internal defaults — not exposed directly to users. chatID int64 messageThreadID int parseMode tgapi.ParseMode entities []tgapi.MessageEntity - - drafts map[uint64]*Draft - generator draftIdGenerator } + +// NewRandomDraftProvider creates a new DraftProvider using random draft IDs. +// +// The provider will use cryptographically secure random numbers for draft IDs. +// All drafts created via this provider will have unpredictable, unique IDs. +func NewRandomDraftProvider(api *tgapi.API) *DraftProvider { + return &DraftProvider{ + api: api, generator: &RandomDraftIdGenerator{}, + drafts: make(map[uint64]*Draft), + } +} + +// NewLinearDraftProvider creates a new DraftProvider using linear (incrementing) draft IDs. +// +// startValue is the initial value for the counter. Use 0 for fresh start, or a known +// value to resume from persisted state. +// +// This is useful when you need to store draft IDs externally (e.g., in a database) +// and want to reconstruct drafts after restart. +func NewLinearDraftProvider(api *tgapi.API, startValue uint64) *DraftProvider { + g := &LinearDraftIdGenerator{} + g.lastId.Store(startValue) + return &DraftProvider{ + api: api, + generator: g, + drafts: make(map[uint64]*Draft), + } +} + +// 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. +// +// Returns the draft and true if found, or nil and false if not found. +func (p *DraftProvider) GetDraft(id uint64) (*Draft, bool) { + draft, ok := p.drafts[id] + return draft, ok +} + +// FlushAll sends all pending drafts as final messages and clears them. +// +// If any draft fails to send, FlushAll returns the error immediately and +// leaves other drafts unflushed. This allows for retry logic or logging. +// +// After successful flush, each draft is removed from the provider and cleared. +func (p *DraftProvider) FlushAll() error { + var lastErr error + for _, draft := range p.drafts { + if err := draft.Flush(); err != nil { + lastErr = err + break // Stop on first error to avoid partial state + } + } + return lastErr +} + +// Draft represents a single message draft that can be edited and flushed. +// +// Drafts are safe to use from a single goroutine. Multiple goroutines must +// synchronize access manually. +// +// Drafts are automatically removed from the provider's map when Flush() succeeds. type Draft struct { api *tgapi.API provider *DraftProvider @@ -52,69 +179,98 @@ type Draft struct { Message string } -func NewRandomDraftProvider(api *tgapi.API) *DraftProvider { - return &DraftProvider{ - api: api, generator: &RandomDraftIdGenerator{}, - parseMode: tgapi.ParseMDV2, - drafts: make(map[uint64]*Draft), +// NewDraft creates a new draft with the provided parse mode. +// +// The draft inherits the provider's chatID, messageThreadID, and entities. +// If parseMode is zero, the provider's default parseMode is used. +// +// Panics if chatID is zero — call SetChat() on the provider first. +func (p *DraftProvider) NewDraft(parseMode tgapi.ParseMode) *Draft { + if p.chatID == 0 { + panic("laniakea: DraftProvider.SetChat() must be called before NewDraft()") } -} -func NewLinearDraftProvider(api *tgapi.API, startValue uint64) *DraftProvider { - g := &LinearDraftIdGenerator{} - g.lastId.Store(startValue) - return &DraftProvider{ - api: api, - generator: g, - drafts: make(map[uint64]*Draft), - } -} -func (d *DraftProvider) NewDraft() *Draft { - id := d.generator.Next() - entitiesCopy := make([]tgapi.MessageEntity, 0) - copy(entitiesCopy, d.entities) + + id := p.generator.Next() draft := &Draft{ - api: d.api, - provider: d, - chatID: d.chatID, - messageThreadID: d.messageThreadID, - parseMode: d.parseMode, - entities: entitiesCopy, + api: p.api, + provider: p, + chatID: p.chatID, + messageThreadID: p.messageThreadID, + parseMode: parseMode, + entities: p.entities, // Shallow copy — caller must ensure immutability ID: id, Message: "", } - d.drafts[id] = draft + p.drafts[id] = draft return draft } -func (d *Draft) push(text string, escapeMd bool) error { - if escapeMd { - d.Message += EscapeMarkdownV2(text) - } else { - d.Message += text - } - params := tgapi.SendMessageDraftP{ - ChatID: d.chatID, - DraftID: d.ID, - Text: d.Message, - ParseMode: d.parseMode, - Entities: d.entities, - } - if d.messageThreadID > 0 { - params.MessageThreadID = d.messageThreadID - } - _, err := d.api.SendMessageDraft(params) - return err + +// SetChat overrides the draft's target chat and message thread. +// +// This is useful for sending a draft to a different chat than the provider's default. +func (d *Draft) SetChat(chatID int64, messageThreadID int) *Draft { + d.chatID = chatID + d.messageThreadID = messageThreadID + return d } +// SetEntities replaces the draft's message entities. +// +// Entities are stored by reference. If you plan to mutate the slice later, +// pass a copy: `SetEntities(append([]tgapi.MessageEntity{}, myEntities...))` +func (d *Draft) SetEntities(entities []tgapi.MessageEntity) *Draft { + d.entities = entities + return d +} + +// Push appends text to the draft and attempts to update the server-side draft. +// +// Returns an error if the Telegram API rejects the update (e.g., due to network issues). +// The draft's Message field is always updated, even if the API call fails. +// +// Use this method to build the message incrementally. func (d *Draft) Push(text string) error { - return d.push(text, true) -} -func (d *Draft) PushMarkdown(text string) error { - return d.push(text, false) + return d.push(text) } +// GetMessage returns the current content of the draft. +// +// Useful for inspection, logging, or validation before flushing. +func (d *Draft) GetMessage() string { + return d.Message +} + +// Clear resets the draft's message content to empty string. +// +// Does not affect server-side draft — use Flush() for that. func (d *Draft) Clear() { d.Message = "" } + +// Delete removes the draft from its provider and clears its content. +// +// This is an internal method used by Flush(). You may call it manually if you +// want to cancel a draft without sending it. +func (d *Draft) Delete() { + if d.provider != nil { + delete(d.provider.drafts, d.ID) + } + d.Clear() +} + +// Flush sends the draft as a final message and clears it locally. +// +// If successful: +// - The message is sent to Telegram. +// - The draft's content is cleared. +// - The draft is removed from the provider's map. +// +// If an error occurs: +// - The message is NOT sent. +// - The draft remains in the provider and retains its content. +// - You can call Flush() again to retry. +// +// If the draft is empty, Flush() returns nil without calling the API. func (d *Draft) Flush() error { if d.Message == "" { return nil @@ -129,10 +285,27 @@ func (d *Draft) Flush() error { if d.messageThreadID > 0 { params.MessageThreadID = d.messageThreadID } + _, err := d.api.SendMessage(params) if err == nil { - d.Clear() - delete(d.provider.drafts, d.ID) + d.Delete() } return err } + +// push is the internal helper for Push(). It updates the server draft via SendMessageDraft. +func (d *Draft) push(text string) error { + d.Message += text + params := tgapi.SendMessageDraftP{ + ChatID: d.chatID, + DraftID: d.ID, + Text: d.Message, + ParseMode: d.parseMode, + Entities: d.entities, + } + if d.messageThreadID > 0 { + params.MessageThreadID = d.messageThreadID + } + _, err := d.api.SendMessageDraft(params) + return err +} diff --git a/examples/basic/.env b/examples/basic/.env new file mode 100644 index 0000000..2c2ca0b --- /dev/null +++ b/examples/basic/.env @@ -0,0 +1,7 @@ +TG_TOKEN= +PREFIXES=/;! +DEBUG=true +USE_REQ_LOG=true +WRITE_TO_FILE=false +USE_TEST_SERVER=true +API_URL=http://127.0.0.1:8081 \ No newline at end of file diff --git a/examples/basic/example.go b/examples/basic/example.go new file mode 100644 index 0000000..e556d7a --- /dev/null +++ b/examples/basic/example.go @@ -0,0 +1,28 @@ +package main + +import ( + "log" + + "git.nix13.pw/scuroneko/laniakea" +) + +func pong(ctx *laniakea.MsgContext, db *laniakea.NoDB) { + ctx.Answer(ctx.Msg.Text) +} + +func main() { + bot := laniakea.NewBot[laniakea.NoDB](laniakea.LoadOptsFromEnv()) + defer bot.Close() + + p := laniakea.NewPlugin[laniakea.NoDB]("ping") + p.NewCommand(pong, "ping") + + bot = bot.ErrorTemplate( + "Error\n\n%s", + ).AddPlugins(p) + + if err := bot.AutoGenerateCommands(); err != nil { + log.Println(err) + } + bot.Run() +} diff --git a/examples/basic/go.mod b/examples/basic/go.mod new file mode 100644 index 0000000..d2f5d09 --- /dev/null +++ b/examples/basic/go.mod @@ -0,0 +1,16 @@ +module example/basic + +go 1.26.1 + +require git.nix13.pw/scuroneko/laniakea v1.0.0-beta.11 + +require ( + git.nix13.pw/scuroneko/extypes v1.2.1 // indirect + git.nix13.pw/scuroneko/slog v1.0.2 // indirect + github.com/alitto/pond/v2 v2.6.2 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/time v0.14.0 // indirect +) diff --git a/examples/basic/go.sum b/examples/basic/go.sum new file mode 100644 index 0000000..925a330 --- /dev/null +++ b/examples/basic/go.sum @@ -0,0 +1,19 @@ +git.nix13.pw/scuroneko/extypes v1.2.1 h1:IYrOjnWKL2EAuJYtYNa+luB1vBe6paE8VY/YD+5/RpQ= +git.nix13.pw/scuroneko/extypes v1.2.1/go.mod h1:uZVs8Yo3RrYAG9dMad6qR6lsYY67t+459D9c65QAYAw= +git.nix13.pw/scuroneko/laniakea v1.0.0-beta.11 h1:bf+5B8vUL/MEmbbX6pA0Wjf0N1eIZH5/WxoSApMcXD4= +git.nix13.pw/scuroneko/laniakea v1.0.0-beta.11/go.mod h1:DZgCqOazRzoa+f/GSNuKnTB2wIZ1eJD3cGf34Qya31U= +git.nix13.pw/scuroneko/slog v1.0.2 h1:vZyUROygxC2d5FJHUQM/30xFEHY1JT/aweDZXA4rm2g= +git.nix13.pw/scuroneko/slog v1.0.2/go.mod h1:3Qm2wzkR5KjwOponMfG7TcGSDjmYaFqRAmLvSPTuWJI= +github.com/alitto/pond/v2 v2.6.2 h1:Sphe40g0ILeM1pA2c2K+Th0DGU+pt0A/Kprr+WB24Pw= +github.com/alitto/pond/v2 v2.6.2/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= diff --git a/go.mod b/go.mod index 31c38e0..db3117a 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,13 @@ go 1.26 require ( git.nix13.pw/scuroneko/extypes v1.2.1 git.nix13.pw/scuroneko/slog v1.0.2 - github.com/alitto/pond/v2 v2.6.2 - golang.org/x/time v0.14.0 + github.com/alitto/pond/v2 v2.7.0 + golang.org/x/time v0.15.0 ) require ( github.com/fatih/color v1.18.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - golang.org/x/sys v0.41.0 // indirect + golang.org/x/sys v0.42.0 // indirect ) diff --git a/go.sum b/go.sum index 744f128..a1e3c21 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ git.nix13.pw/scuroneko/extypes v1.2.1 h1:IYrOjnWKL2EAuJYtYNa+luB1vBe6paE8VY/YD+5 git.nix13.pw/scuroneko/extypes v1.2.1/go.mod h1:uZVs8Yo3RrYAG9dMad6qR6lsYY67t+459D9c65QAYAw= git.nix13.pw/scuroneko/slog v1.0.2 h1:vZyUROygxC2d5FJHUQM/30xFEHY1JT/aweDZXA4rm2g= git.nix13.pw/scuroneko/slog v1.0.2/go.mod h1:3Qm2wzkR5KjwOponMfG7TcGSDjmYaFqRAmLvSPTuWJI= -github.com/alitto/pond/v2 v2.6.2 h1:Sphe40g0ILeM1pA2c2K+Th0DGU+pt0A/Kprr+WB24Pw= -github.com/alitto/pond/v2 v2.6.2/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE= +github.com/alitto/pond/v2 v2.7.0 h1:c76L+yN916m/DRXjGCeUBHHu92uWnh/g1bwVk4zyyXg= +github.com/alitto/pond/v2 v2.7.0/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -11,7 +11,7 @@ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stg github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= diff --git a/keyboard.go b/keyboard.go index 01b400e..7f8883c 100644 --- a/keyboard.go +++ b/keyboard.go @@ -1,3 +1,13 @@ +// 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 ( @@ -8,12 +18,26 @@ import ( "git.nix13.pw/scuroneko/laniakea/tgapi" ) +// ButtonStyleDanger, ButtonStyleSuccess, ButtonStylePrimary are predefined +// Telegram keyboard button styles for visual feedback. +// +// These values map directly to Telegram Bot API's InlineKeyboardButton style field. const ( ButtonStyleDanger tgapi.KeyboardButtonStyle = "danger" ButtonStyleSuccess tgapi.KeyboardButtonStyle = "success" ButtonStylePrimary tgapi.KeyboardButtonStyle = "primary" ) +// InlineKbButtonBuilder is a fluent builder for creating a single inline keyboard button. +// +// Use NewInlineKbButton() to start, then chain methods to configure: +// - SetIconCustomEmojiId() — adds a custom emoji icon +// - SetStyle() — sets visual style (danger/success/primary) +// - SetUrl() — makes button open a URL +// - SetCallbackData() — attaches structured command + args for bot handling +// +// Call build() to produce the final tgapi.InlineKeyboardButton. +// Builder methods are immutable — each returns a copy. type InlineKbButtonBuilder struct { text string iconCustomEmojiID string @@ -22,26 +46,48 @@ type InlineKbButtonBuilder struct { callbackData string } +// NewInlineKbButton creates a new button builder with the given display text. +// The button will have no URL, no style, and no callback data by default. func NewInlineKbButton(text string) InlineKbButtonBuilder { return InlineKbButtonBuilder{text: text} } + +// SetIconCustomEmojiId sets a custom emoji ID to display as the button's icon. +// This is a Telegram Bot API feature for custom emoji icons. func (b InlineKbButtonBuilder) SetIconCustomEmojiId(id string) InlineKbButtonBuilder { b.iconCustomEmojiID = id return b } + +// SetStyle sets the visual style of the button. +// Valid values: ButtonStyleDanger, ButtonStyleSuccess, ButtonStylePrimary. +// If not set, the button uses the default style. func (b InlineKbButtonBuilder) SetStyle(style tgapi.KeyboardButtonStyle) InlineKbButtonBuilder { b.style = style return b } + +// SetUrl sets a URL that will be opened when the button is pressed. +// If both URL and CallbackData are set, Telegram will prioritize URL. func (b InlineKbButtonBuilder) SetUrl(url string) InlineKbButtonBuilder { b.url = url return b } + +// SetCallbackData sets a structured callback payload that will be sent to the bot +// when the button is pressed. The command and arguments are serialized as JSON. +// +// Args are converted to strings using fmt.Sprint. Non-string types (e.g., int, bool) +// are safely serialized, but complex structs may not serialize usefully. +// +// Example: SetCallbackData("delete_user", 123, "confirm") → {"cmd":"delete_user","args":["123","confirm"]} func (b InlineKbButtonBuilder) SetCallbackData(cmd string, args ...any) InlineKbButtonBuilder { b.callbackData = NewCallbackData(cmd, args...).ToJson() return b } +// build converts the builder state into a tgapi.InlineKeyboardButton. +// This method is typically called internally by InlineKeyboard.AddButton(). func (b InlineKbButtonBuilder) build() tgapi.InlineKeyboardButton { return tgapi.InlineKeyboardButton{ Text: b.text, @@ -52,12 +98,22 @@ func (b InlineKbButtonBuilder) build() tgapi.InlineKeyboardButton { } } +// InlineKeyboard is a stateful builder for constructing Telegram inline keyboard layouts. +// +// Buttons are added row-by-row. When a row reaches maxRow, it is automatically flushed. +// Call AddLine() to manually end a row, or Get() to finalize and retrieve the markup. +// +// The keyboard is not thread-safe. Build it in a single goroutine. type InlineKeyboard struct { - CurrentLine extypes.Slice[tgapi.InlineKeyboardButton] - Lines [][]tgapi.InlineKeyboardButton - maxRow int + CurrentLine extypes.Slice[tgapi.InlineKeyboardButton] // Current row being built + Lines [][]tgapi.InlineKeyboardButton // Completed rows + maxRow int // Max buttons per row (e.g., 3 or 4) } +// NewInlineKeyboard 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 { return &InlineKeyboard{ CurrentLine: make(extypes.Slice[tgapi.InlineKeyboardButton], 0), @@ -66,6 +122,8 @@ func NewInlineKeyboard(maxRow int) *InlineKeyboard { } } +// append adds a button to the current line. If the line is full, it auto-flushes. +// This is an internal helper used by other builder methods. func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeyboard { if in.CurrentLine.Len() == in.maxRow { in.AddLine() @@ -74,27 +132,45 @@ func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeybo return in } +// AddUrlButton adds a button that opens a URL when pressed. +// No callback data is attached. func (in *InlineKeyboard) AddUrlButton(text, url string) *InlineKeyboard { return in.append(tgapi.InlineKeyboardButton{Text: text, URL: url}) } + +// AddUrlButtonStyle adds a button with a visual style that opens a URL. +// Style must be one of: ButtonStyleDanger, ButtonStyleSuccess, ButtonStylePrimary. func (in *InlineKeyboard) AddUrlButtonStyle(text string, style tgapi.KeyboardButtonStyle, url string) *InlineKeyboard { return in.append(tgapi.InlineKeyboardButton{Text: text, Style: style, URL: url}) } + +// AddCallbackButton adds a button that sends a structured callback payload to the bot. +// The command and args are serialized as JSON using NewCallbackData. func (in *InlineKeyboard) AddCallbackButton(text string, cmd string, args ...any) *InlineKeyboard { return in.append(tgapi.InlineKeyboardButton{ - Text: text, CallbackData: NewCallbackData(cmd, args...).ToJson(), - }) -} -func (in *InlineKeyboard) AddCallbackButtonStyle(text string, style tgapi.KeyboardButtonStyle, cmd string, args ...any) *InlineKeyboard { - return in.append(tgapi.InlineKeyboardButton{ - Text: text, Style: style, + Text: text, CallbackData: NewCallbackData(cmd, args...).ToJson(), }) } + +// AddCallbackButtonStyle adds a styled callback button. +// Style affects visual appearance; callback data is sent to bot on press. +func (in *InlineKeyboard) AddCallbackButtonStyle(text string, style tgapi.KeyboardButtonStyle, cmd string, args ...any) *InlineKeyboard { + return in.append(tgapi.InlineKeyboardButton{ + Text: text, + Style: style, + CallbackData: NewCallbackData(cmd, args...).ToJson(), + }) +} + +// AddButton adds a button pre-configured via InlineKbButtonBuilder. +// This is the most flexible way to create buttons with custom emoji, style, URL, and callback. func (in *InlineKeyboard) AddButton(b InlineKbButtonBuilder) *InlineKeyboard { return in.append(b.build()) } +// AddLine manually ends the current row and starts a new one. +// If the current row is empty, nothing happens. func (in *InlineKeyboard) AddLine() *InlineKeyboard { if in.CurrentLine.Len() == 0 { return in @@ -103,6 +179,11 @@ func (in *InlineKeyboard) AddLine() *InlineKeyboard { in.CurrentLine = make(extypes.Slice[tgapi.InlineKeyboardButton], 0) return in } + +// Get finalizes the keyboard and returns a tgapi.ReplyMarkup. +// Automatically flushes the current line if not empty. +// +// Returns a pointer to a ReplyMarkup suitable for use with tgapi.SendMessage. func (in *InlineKeyboard) Get() *tgapi.ReplyMarkup { if in.CurrentLine.Len() > 0 { in.Lines = append(in.Lines, in.CurrentLine) @@ -110,11 +191,26 @@ func (in *InlineKeyboard) Get() *tgapi.ReplyMarkup { return &tgapi.ReplyMarkup{InlineKeyboard: in.Lines} } +// CallbackData represents the structured payload sent when an inline button +// with callback data is pressed. +// +// This structure is serialized to JSON and sent to the bot as a string. +// The bot should parse this back to determine the command and arguments. +// +// Example: +// +// {"cmd":"delete_user","args":["123","confirm"]} type CallbackData struct { - Command string `json:"cmd"` - Args []string `json:"args"` + Command string `json:"cmd"` // The command name to route to + Args []string `json:"args"` // Arguments passed as strings } +// NewCallbackData creates a new CallbackData instance with the given command and args. +// +// All args are converted to strings using fmt.Sprint. This is safe for primitives +// (int, string, bool, float64) but may not serialize complex structs meaningfully. +// +// Use this to build callback payloads for bot command routing. func NewCallbackData(command string, args ...any) *CallbackData { stringArgs := make([]string, len(args)) for i, arg := range args { @@ -125,9 +221,18 @@ func NewCallbackData(command string, args ...any) *CallbackData { Args: stringArgs, } } + +// ToJson serializes the CallbackData to a JSON string. +// +// If serialization fails (e.g., due to unmarshalable fields), returns a fallback +// JSON object: {"cmd":""} to prevent breaking Telegram's API. +// +// This fallback ensures the bot receives a valid JSON payload even if internal +// errors occur — avoiding "invalid callback_data" errors from Telegram. func (d *CallbackData) ToJson() string { data, err := json.Marshal(d) if err != nil { + // Fallback: return minimal valid JSON to avoid Telegram API rejection return `{"cmd":""}` } return string(data) diff --git a/l10n.go b/l10n.go index 48d7fbb..917d4ea 100644 --- a/l10n.go +++ b/l10n.go @@ -1,26 +1,86 @@ +// 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 {key:{ru:123,en:123}} +// DictEntry represents a single localized entry with language-to-text mappings. +// Example: {"ru": "Привет", "en": "Hello"} type DictEntry map[string]string + +// L10n is a localization manager that maps keys to language-specific strings. type L10n struct { - entries map[string]DictEntry - fallbackLang string + entries map[string]DictEntry // Map of translation keys to language dictionaries + fallbackLang string // Language code to use when requested language is missing } +// NewL10n creates a new L10n instance with the specified fallback language. +// The fallback language is used when a requested language is not available +// for a given key. +// +// Example: NewL10n("en") will return "Hello" for key "greeting" if "ru" is requested +// but no "ru" entry exists. func NewL10n(fallbackLanguage string) *L10n { - return &L10n{make(map[string]DictEntry), fallbackLanguage} + return &L10n{ + entries: make(map[string]DictEntry), + fallbackLang: fallbackLanguage, + } } + +// AddDictEntry adds a new translation entry for the given key. +// The value must be a DictEntry mapping language codes (e.g., "en", "ru") to their translated strings. +// +// If a key already exists, it is overwritten. +// +// Returns the L10n instance for method chaining. func (l *L10n) AddDictEntry(key string, value DictEntry) *L10n { l.entries[key] = value return l } + +// GetFallbackLanguage returns the currently configured fallback language code. func (l *L10n) GetFallbackLanguage() string { return l.fallbackLang } + +// Translate retrieves the translation for the given key and language. +// +// Behavior: +// - If the key exists and the language has a translation → returns the translation +// - If the key exists but the language is missing → returns the fallback language's value +// - If the key does not exist → returns the key string itself (as fallback) +// +// Example: +// +// l.AddDictEntry("greeting", DictEntry{"en": "Hello", "ru": "Привет"}) +// l.Translate("en", "greeting") → "Hello" +// l.Translate("es", "greeting") → "Hello" (fallback to "en") +// l.Translate("en", "unknown") → "unknown" (key not found) +// +// This behavior ensures that missing translations do not break UI or logs — +// instead, the original key is displayed, making it easy to identify gaps. func (l *L10n) Translate(lang, key string) string { - s, ok := l.entries[key] - if !ok { - return key + entries, exists := l.entries[key] + if !exists { + return key // Return key as fallback when translation is missing } - return s[lang] + + // Try requested language + if translation, ok := entries[lang]; ok { + return translation + } + + // Fall back to configured fallback language + if fallback, ok := entries[l.fallbackLang]; ok { + return fallback + } + + // If fallback language is also missing, return the key + return key } diff --git a/msg_context.go b/msg_context.go index 2d2b3ac..0798deb 100644 --- a/msg_context.go +++ b/msg_context.go @@ -1,3 +1,22 @@ +// 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 ( @@ -8,9 +27,11 @@ import ( "git.nix13.pw/scuroneko/slog" ) +// MsgContext holds the context for handling a Telegram message or callback query. +// It provides methods to respond, edit, delete, and translate messages, as well as +// manage inline keyboards and message drafts. type MsgContext struct { - Api *tgapi.API - + Api *tgapi.API Msg *tgapi.Message Update tgapi.Update From *tgapi.User @@ -27,22 +48,23 @@ type MsgContext struct { draftProvider *DraftProvider } +// AnswerMessage represents a message sent or edited via MsgContext. +// It holds metadata to allow further editing or deletion. type AnswerMessage struct { MessageID int Text string IsMedia bool - ctx *MsgContext + ctx *MsgContext // internal back-reference } -func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard, escapeMd bool) *AnswerMessage { +// edit is an internal helper to edit a message's text with optional keyboard and parse mode. +// Used by Edit, EditMarkdown, EditCallback, etc. +func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { params := tgapi.EditMessageTextP{ MessageID: messageId, ChatID: ctx.Msg.Chat.ID, Text: text, - ParseMode: tgapi.ParseMDV2, - } - if escapeMd { - params.Text = EscapeMarkdownV2(text) + ParseMode: parseMode, } if keyboard != nil { params.ReplyMarkup = keyboard.Get() @@ -56,38 +78,67 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard MessageID: msg.MessageID, ctx: ctx, Text: text, IsMedia: false, } } + +// Edit replaces the text of the message without changing the keyboard or parse mode. +// Uses ParseNone (plain text). func (m *AnswerMessage) Edit(text string) *AnswerMessage { - return m.ctx.edit(m.MessageID, text, nil, true) + return m.ctx.edit(m.MessageID, text, nil, tgapi.ParseNone) } + +// EditMarkdown replaces the text of the message using MarkdownV2 formatting. +// +// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. +// Unescaped input may cause Telegram API errors or broken formatting. func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage { - return m.ctx.edit(m.MessageID, text, nil, false) + return m.ctx.edit(m.MessageID, text, nil, tgapi.ParseMDV2) } + +// editCallback is an internal helper to edit the message associated with a callback query. +// Returns nil if CallbackMsgId is 0 (not a callback context). +func (ctx *MsgContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { + if ctx.CallbackMsgId == 0 { + ctx.botLogger.Errorln("Can't edit non-callback update message") + return nil + } + return ctx.edit(ctx.CallbackMsgId, text, keyboard, parseMode) +} + +// EditCallback edits the callback message using plain text (ParseNone). func (ctx *MsgContext) EditCallback(text string, keyboard *InlineKeyboard) *AnswerMessage { - if ctx.CallbackMsgId == 0 { - ctx.botLogger.Errorln("Can't edit non-callback update message") - return nil - } - - return ctx.edit(ctx.CallbackMsgId, text, keyboard, true) + return ctx.editCallback(text, keyboard, tgapi.ParseNone) } + +// EditCallbackMarkdown edits the callback message using MarkdownV2. +// +// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. func (ctx *MsgContext) EditCallbackMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { - if ctx.CallbackMsgId == 0 { - ctx.botLogger.Errorln("Can't edit non-callback update message") + return ctx.editCallback(text, keyboard, tgapi.ParseMDV2) +} + +// EditCallbackf formats a string using fmt.Sprintf and edits the callback message with plain text. +func (ctx *MsgContext) EditCallbackf(format string, keyboard *InlineKeyboard, args ...any) *AnswerMessage { + return ctx.editCallback(fmt.Sprintf(format, args...), keyboard, tgapi.ParseNone) +} + +// EditCallbackfMarkdown formats a string using fmt.Sprintf and edits the callback message with MarkdownV2. +// +// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. +func (ctx *MsgContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyboard, args ...any) *AnswerMessage { + return ctx.editCallback(fmt.Sprintf(format, args...), keyboard, tgapi.ParseMDV2) +} + +// editPhotoText edits the caption of a photo/video message. +// Returns nil if messageId is 0. +func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { + if messageId == 0 { + ctx.botLogger.Errorln("Can't edit caption message, message ID zero") return nil } - - return ctx.edit(ctx.CallbackMsgId, text, keyboard, false) -} -func (ctx *MsgContext) EditCallbackf(format string, keyboard *InlineKeyboard, args ...any) *AnswerMessage { - return ctx.EditCallback(fmt.Sprintf(format, args...), keyboard) -} - -func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeyboard) *AnswerMessage { params := tgapi.EditMessageCaptionP{ ChatID: ctx.Msg.Chat.ID, MessageID: messageId, Caption: text, - ParseMode: tgapi.ParseMD, + ParseMode: parseMode, } if kb != nil { params.ReplyMarkup = kb.Get() @@ -101,25 +152,38 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo MessageID: msg.MessageID, ctx: ctx, Text: text, IsMedia: true, } } + +// EditCaption edits the caption of a media message using plain text. func (m *AnswerMessage) EditCaption(text string) *AnswerMessage { - if m.MessageID == 0 { - m.ctx.botLogger.Errorln("Can't edit caption message, message id is zero") - return m - } - return m.ctx.editPhotoText(m.MessageID, text, nil) -} -func (m *AnswerMessage) EditCaptionKeyboard(text string, kb *InlineKeyboard) *AnswerMessage { - return m.ctx.editPhotoText(m.MessageID, text, kb) + return m.ctx.editPhotoText(m.MessageID, text, nil, tgapi.ParseNone) } -func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, escapeMd bool) *AnswerMessage { +// EditCaptionMarkdown edits the caption of a media message using MarkdownV2. +// +// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. +func (m *AnswerMessage) EditCaptionMarkdown(text string) *AnswerMessage { + return m.ctx.editPhotoText(m.MessageID, text, nil, tgapi.ParseMDV2) +} + +// EditCaptionKeyboard edits the caption of a media message with a new inline keyboard (plain text). +func (m *AnswerMessage) EditCaptionKeyboard(text string, kb *InlineKeyboard) *AnswerMessage { + return m.ctx.editPhotoText(m.MessageID, text, kb, tgapi.ParseNone) +} + +// EditCaptionKeyboardMarkdown edits the caption of a media message with a new inline keyboard using MarkdownV2. +// +// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. +func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeyboard) *AnswerMessage { + return m.ctx.editPhotoText(m.MessageID, text, kb, tgapi.ParseMDV2) +} + +// answer sends a new message with optional keyboard and parse mode. +// Uses API limiter to respect Telegram rate limits per chat. +func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { params := tgapi.SendMessageP{ ChatID: ctx.Msg.Chat.ID, Text: text, - ParseMode: tgapi.ParseMDV2, - } - if escapeMd { - params.Text = EscapeMarkdownV2(text) + ParseMode: parseMode, } if keyboard != nil { params.ReplyMarkup = keyboard.Get() @@ -145,35 +209,51 @@ func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, escapeMd bo MessageID: msg.MessageID, ctx: ctx, IsMedia: false, Text: text, } } -func (ctx *MsgContext) AnswerMarkdown(text string) *AnswerMessage { - return ctx.answer(text, nil, false) -} + +// Answer sends a plain text message (ParseNone). func (ctx *MsgContext) Answer(text string) *AnswerMessage { - return ctx.answer(text, nil, true) -} -func (ctx *MsgContext) AnswerfMarkdown(template string, args ...any) *AnswerMessage { - return ctx.answer(fmt.Sprintf(template, args...), nil, false) -} -func (ctx *MsgContext) Answerf(template string, args ...any) *AnswerMessage { - return ctx.answer(fmt.Sprintf(template, args...), nil, true) -} -func (ctx *MsgContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { - return ctx.answer(text, keyboard, false) -} -func (ctx *MsgContext) Keyboard(text string, kb *InlineKeyboard) *AnswerMessage { - return ctx.answer(text, kb, true) + return ctx.answer(text, nil, tgapi.ParseNone) } -func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, escapeMd bool) *AnswerMessage { +// AnswerMarkdown sends a message using MarkdownV2 formatting. +// +// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. +func (ctx *MsgContext) AnswerMarkdown(text string) *AnswerMessage { + return ctx.answer(text, nil, tgapi.ParseMDV2) +} + +// Answerf formats a string using fmt.Sprintf and sends it as a plain text message. +func (ctx *MsgContext) Answerf(template string, args ...any) *AnswerMessage { + return ctx.answer(fmt.Sprintf(template, args...), nil, tgapi.ParseNone) +} + +// AnswerfMarkdown formats a string using fmt.Sprintf and sends it using MarkdownV2. +// +// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. +func (ctx *MsgContext) AnswerfMarkdown(template string, args ...any) *AnswerMessage { + return ctx.answer(fmt.Sprintf(template, args...), nil, tgapi.ParseMDV2) +} + +// Keyboard sends a message with an inline keyboard (plain text). +func (ctx *MsgContext) Keyboard(text string, kb *InlineKeyboard) *AnswerMessage { + return ctx.answer(text, kb, tgapi.ParseNone) +} + +// KeyboardMarkdown sends a message with an inline keyboard using MarkdownV2. +// +// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. +func (ctx *MsgContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage { + return ctx.answer(text, keyboard, tgapi.ParseMDV2) +} + +// answerPhoto sends a photo with optional caption and keyboard. +func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { params := tgapi.SendPhotoP{ ChatID: ctx.Msg.Chat.ID, Caption: text, - ParseMode: tgapi.ParseMDV2, + ParseMode: parseMode, Photo: photoId, } - if escapeMd { - params.Caption = EscapeMarkdownV2(text) - } if kb != nil { params.ReplyMarkup = kb.Get() } @@ -184,35 +264,50 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, esc msg, err := ctx.Api.SendPhoto(params) if err != nil { ctx.botLogger.Errorln(err) - return &AnswerMessage{ - ctx: ctx, Text: text, IsMedia: true, - } + return nil } return &AnswerMessage{ MessageID: msg.MessageID, ctx: ctx, Text: text, IsMedia: true, } } + +// AnswerPhoto sends a photo with plain text caption. func (ctx *MsgContext) AnswerPhoto(photoId, text string) *AnswerMessage { - return ctx.answerPhoto(photoId, text, nil, true) + return ctx.answerPhoto(photoId, text, nil, tgapi.ParseNone) } + +// AnswerPhotoMarkdown sends a photo with MarkdownV2 caption. +// +// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. func (ctx *MsgContext) AnswerPhotoMarkdown(photoId, text string) *AnswerMessage { - return ctx.answerPhoto(photoId, text, nil, false) + return ctx.answerPhoto(photoId, text, nil, tgapi.ParseMDV2) } +// AnswerPhotoKeyboard sends a photo with caption and inline keyboard (plain text). func (ctx *MsgContext) AnswerPhotoKeyboard(photoId, text string, kb *InlineKeyboard) *AnswerMessage { - return ctx.answerPhoto(photoId, text, kb, true) + return ctx.answerPhoto(photoId, text, kb, tgapi.ParseNone) } + +// AnswerPhotoKeyboardMarkdown sends a photo with caption and inline keyboard using MarkdownV2. +// +// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. func (ctx *MsgContext) AnswerPhotoKeyboardMarkdown(photoId, text string, kb *InlineKeyboard) *AnswerMessage { - return ctx.answerPhoto(photoId, text, kb, false) + return ctx.answerPhoto(photoId, text, kb, tgapi.ParseMDV2) } +// AnswerPhotof formats a string and sends it as a photo caption (plain text). func (ctx *MsgContext) AnswerPhotof(photoId, template string, args ...any) *AnswerMessage { - return ctx.answerPhoto(photoId, fmt.Sprintf(template, args...), nil, true) -} -func (ctx *MsgContext) AnswerPhotofMarkdown(photoId, template string, args ...any) *AnswerMessage { - return ctx.answerPhoto(photoId, fmt.Sprintf(template, args...), nil, false) + return ctx.answerPhoto(photoId, fmt.Sprintf(template, args...), nil, tgapi.ParseNone) } +// AnswerPhotofMarkdown formats a string and sends it as a photo caption using MarkdownV2. +// +// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. +func (ctx *MsgContext) AnswerPhotofMarkdown(photoId, template string, args ...any) *AnswerMessage { + return ctx.answerPhoto(photoId, fmt.Sprintf(template, args...), nil, tgapi.ParseMDV2) +} + +// delete removes a message by ID. func (ctx *MsgContext) delete(messageId int) { _, err := ctx.Api.DeleteMessage(tgapi.DeleteMessageP{ ChatID: ctx.Msg.Chat.ID, @@ -222,9 +317,15 @@ func (ctx *MsgContext) delete(messageId int) { ctx.botLogger.Errorln(err) } } -func (m *AnswerMessage) Delete() { m.ctx.delete(m.MessageID) } + +// Delete removes the message associated with this AnswerMessage. +func (m *AnswerMessage) Delete() { m.ctx.delete(m.MessageID) } + +// CallbackDelete deletes the message that triggered the callback query. func (ctx *MsgContext) CallbackDelete() { ctx.delete(ctx.CallbackMsgId) } +// answerCallbackQuery sends a response to a callback query (optional text/alert/url). +// Does nothing if CallbackQueryId is empty. func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) { if len(ctx.CallbackQueryId) == 0 { return @@ -237,11 +338,20 @@ func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) { ctx.botLogger.Errorln(err) } } -func (ctx *MsgContext) AnswerCbQuery() { ctx.answerCallbackQuery("", "", false) } -func (ctx *MsgContext) AnswerCbQueryText(text string) { ctx.answerCallbackQuery("", text, false) } -func (ctx *MsgContext) AnswerCbQueryAlert(text string) { ctx.answerCallbackQuery("", text, true) } -func (ctx *MsgContext) AnswerCbQueryUrl(u string) { ctx.answerCallbackQuery(u, "", false) } +// AnswerCbQuery answers the callback query with no text or alert. +func (ctx *MsgContext) AnswerCbQuery() { ctx.answerCallbackQuery("", "", false) } + +// AnswerCbQueryText answers the callback query with a text notification. +func (ctx *MsgContext) AnswerCbQueryText(text string) { ctx.answerCallbackQuery("", text, false) } + +// AnswerCbQueryAlert answers the callback query with a user-visible alert. +func (ctx *MsgContext) AnswerCbQueryAlert(text string) { ctx.answerCallbackQuery("", text, true) } + +// AnswerCbQueryUrl answers the callback query with a URL redirect. +func (ctx *MsgContext) AnswerCbQueryUrl(u string) { ctx.answerCallbackQuery(u, "", false) } + +// SendAction sends a chat action (typing, uploading_photo, etc.) to indicate bot activity. func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) { params := tgapi.SendChatActionP{ ChatID: ctx.Msg.Chat.ID, Action: action, @@ -255,30 +365,47 @@ func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) { } } +// error sends an error message to the user and logs it. +// Uses errorTemplate to format the message. +// For callbacks: sends as callback answer (no alert). +// For regular messages: sends as plain text. func (ctx *MsgContext) error(err error) { text := fmt.Sprintf(ctx.errorTemplate, err.Error()) if ctx.CallbackQueryId != "" { ctx.answerCallbackQuery("", text, false) } else { - ctx.answer(text, nil, true) + ctx.answer(text, nil, tgapi.ParseNone) } ctx.botLogger.Errorln(err) } + +// Error is an alias for error(). func (ctx *MsgContext) Error(err error) { ctx.error(err) } -func (ctx *MsgContext) NewDraft() *Draft { +func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft { c := context.Background() if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil { ctx.botLogger.Errorln(err) return nil } - draft := ctx.draftProvider.NewDraft() - draft.chatID = ctx.Msg.Chat.ID - draft.messageThreadID = ctx.Msg.MessageThreadID + draft := ctx.draftProvider.NewDraft(parseMode).SetChat(ctx.Msg.Chat.ID, ctx.Msg.MessageThreadID) return draft } + +// NewDraft creates a new message draft associated with the current chat. +// Uses the API limiter to avoid rate limiting. +func (ctx *MsgContext) NewDraft() *Draft { + return ctx.newDraft(tgapi.ParseNone) +} + +func (ctx *MsgContext) NewDraftMarkdown() *Draft { + return ctx.newDraft(tgapi.ParseMDV2) +} + +// Translate looks up a key in the current user's language. +// Falls back to the bot's default language if user's language is unknown or unsupported. func (ctx *MsgContext) Translate(key string) string { if ctx.From == nil { return key diff --git a/plugins.go b/plugins.go index daf6f7f..428970f 100644 --- a/plugins.go +++ b/plugins.go @@ -1,3 +1,15 @@ +// 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 ( @@ -7,83 +19,122 @@ import ( "git.nix13.pw/scuroneko/extypes" ) -const ( - CommandValueStringType CommandValueType = "string" - CommandValueIntType CommandValueType = "int" - CommandValueBoolType CommandValueType = "bool" - CommandValueAnyType CommandValueType = "any" -) - -var ( - CommandRegexInt = regexp.MustCompile(`\d+`) - CommandRegexString = regexp.MustCompile(".+") -) - -var ( - ErrCmdArgCountMismatch = errors.New("command arg count mismatch") - ErrCmdArgRegexpMismatch = errors.New("command arg regexp mismatch") -) - +// CommandValueType defines the expected type of a command argument. type CommandValueType string + +const ( + // CommandValueStringType expects any non-empty string. + CommandValueStringType CommandValueType = "string" + // CommandValueIntType expects a decimal integer (digits only). + CommandValueIntType CommandValueType = "int" + // CommandValueBoolType is reserved for future use (not implemented). + CommandValueBoolType CommandValueType = "bool" + // CommandValueAnyType accepts any input without validation. + CommandValueAnyType CommandValueType = "any" +) + +// CommandRegexInt matches one or more digits. +var CommandRegexInt = regexp.MustCompile(`\d+`) + +// CommandRegexString matches any non-empty string. +var CommandRegexString = regexp.MustCompile(".+") + +// ErrCmdArgCountMismatch is returned when the number of provided arguments +// is less than the number of required arguments. +var ErrCmdArgCountMismatch = errors.New("command arg count mismatch") + +// ErrCmdArgRegexpMismatch is returned when an argument fails regex validation. +var ErrCmdArgRegexpMismatch = errors.New("command arg regexp mismatch") + +// CommandArg defines a single argument for a command, including type, regex, +// and whether it is required. type CommandArg struct { - valueType CommandValueType - text string - regex *regexp.Regexp - required bool + valueType CommandValueType // Type of expected value + text string // Human-readable description (not used in validation) + regex *regexp.Regexp // Regex used to validate input + required bool // Whether this argument must be provided } +// NewCommandArg creates a new CommandArg with the given text and type. +// Uses a default regex based on the type (string or int). +// For CommandValueAnyType, no validation is performed. func NewCommandArg(text string, valueType CommandValueType) *CommandArg { regex := CommandRegexString switch valueType { case CommandValueIntType: regex = CommandRegexInt + case CommandValueAnyType: + regex = nil // Skip validation } return &CommandArg{valueType, text, regex, false} } + +// SetRequired marks this argument as required. +// Returns the receiver for method chaining. func (c *CommandArg) SetRequired() *CommandArg { c.required = true return c } +// CommandExecutor is the function type that executes a command. +// It receives the message context and a database context (generic). type CommandExecutor[T DbContext] func(ctx *MsgContext, dbContext *T) +// Command represents a bot command with arguments, description, and executor. +// Can be registered in a Plugin and optionally skipped from auto-generation. type Command[T DbContext] struct { - command string - description string - exec CommandExecutor[T] - args extypes.Slice[CommandArg] - middlewares extypes.Slice[Middleware[T]] - skipAutoCmd bool + command string // The command trigger (e.g., "/start") + description string // Human-readable description for help + exec CommandExecutor[T] // Function to execute when command is triggered + args extypes.Slice[CommandArg] // List of expected arguments + middlewares extypes.Slice[Middleware[T]] // Optional middleware chain + skipAutoCmd bool // If true, this command won't be auto-added to help menus } +// NewCommand creates a new Command with the given executor, command string, and arguments. +// The command string should not include the leading slash (e.g., "start", not "/start"). func NewCommand[T any](exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { - return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false} + return &Command[T]{command, "", exec, extypes.Slice[CommandArg](args), make(extypes.Slice[Middleware[T]], 0), false} } + +// Use adds a middleware to the command's execution chain. +// Middlewares are executed in the order they are added. func (c *Command[T]) Use(m Middleware[T]) *Command[T] { c.middlewares = c.middlewares.Push(m) return c } + +// SetDescription sets the human-readable description of the command. func (c *Command[T]) SetDescription(desc string) *Command[T] { c.description = desc return c } + +// SkipCommandAutoGen marks this command to be excluded from auto-generated help menus. func (c *Command[T]) SkipCommandAutoGen() *Command[T] { c.skipAutoCmd = true return c } + +// validateArgs checks if the provided arguments match the command's requirements. +// Returns ErrCmdArgCountMismatch if too few arguments are provided. +// Returns ErrCmdArgRegexpMismatch if any argument fails regex validation. func (c *Command[T]) validateArgs(args []string) error { - cmdArgs := c.args.Filter(func(e CommandArg) bool { return !e.required }) - if len(args) < cmdArgs.Len() { + // Count required args + requiredCount := c.args.Filter(func(a CommandArg) bool { return a.required }).Len() + if len(args) < requiredCount { return ErrCmdArgCountMismatch } + // Validate each argument against its regex for i, arg := range args { if i >= c.args.Len() { + // Extra arguments beyond defined args are ignored break } cmdArg := c.args.Get(i) if cmdArg.regex == nil { - continue + continue // Skip validation for CommandValueAnyType } if !cmdArg.regex.MatchString(arg) { return ErrCmdArgRegexpMismatch @@ -92,57 +143,123 @@ func (c *Command[T]) validateArgs(args []string) error { return nil } +// Plugin represents a collection of commands and payloads (e.g., callback handlers), +// with shared middleware and configuration. type Plugin[T DbContext] struct { - name string - commands map[string]Command[T] - payloads map[string]Command[T] - middlewares extypes.Slice[Middleware[T]] - skipAutoCmd bool + name string // Name of the plugin (e.g., "admin", "user") + commands map[string]Command[T] // Registered commands (triggered by message) + payloads map[string]Command[T] // Registered payloads (triggered by callback data) + middlewares extypes.Slice[Middleware[T]] // Shared middlewares for all commands/payloads + skipAutoCmd bool // If true, all commands in this plugin are excluded from auto-help } +// NewPlugin creates a new Plugin with the given name. func NewPlugin[T DbContext](name string) *Plugin[T] { return &Plugin[T]{ - name, map[string]Command[T]{}, - map[string]Command[T]{}, extypes.Slice[Middleware[T]]{}, false, + name, make(map[string]Command[T]), + make(map[string]Command[T]), extypes.Slice[Middleware[T]]{}, false, } } +// AddCommand registers a command in the plugin. +// The command's .command field is used as the key. func (p *Plugin[T]) AddCommand(command *Command[T]) *Plugin[T] { p.commands[command.command] = *command return p } + +// NewCommand creates and immediately adds a new command to the plugin. +// Returns the created command for further configuration. func (p *Plugin[T]) NewCommand(exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { - return NewCommand(exec, command, args...) + cmd := NewCommand(exec, command, args...) + p.AddCommand(cmd) + return cmd } + +// AddPayload registers a payload (e.g., callback query data) in the plugin. +// Payloads are triggered by inline button callback_data, not by message text. func (p *Plugin[T]) AddPayload(command *Command[T]) *Plugin[T] { p.payloads[command.command] = *command return p } + +// AddMiddleware adds a middleware to the plugin's global middleware chain. +// Middlewares are executed before any command or payload. func (p *Plugin[T]) AddMiddleware(middleware Middleware[T]) *Plugin[T] { p.middlewares = p.middlewares.Push(middleware) return p } + +// SkipCommandAutoGen marks the entire plugin to be excluded from auto-generated help menus. func (p *Plugin[T]) SkipCommandAutoGen() *Plugin[T] { p.skipAutoCmd = true return p } +// executeCmd finds and executes a command by its trigger string. +// Validates arguments and runs middlewares before executor. +// On error, sends an error message to the user via ctx.error(). func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, dbContext *T) { - command := p.commands[cmd] + command, exists := p.commands[cmd] + if !exists { + ctx.error(errors.New("command not found")) + return + } + if err := command.validateArgs(ctx.Args); err != nil { ctx.error(err) return } + + // Run plugin middlewares + if !p.executeMiddlewares(ctx, dbContext) { + return + } + + // Run command-specific middlewares + for _, m := range command.middlewares { + if !m.Execute(ctx, dbContext) { + return + } + } + + // Execute command command.exec(ctx, dbContext) } + +// executePayload finds and executes a payload by its callback_data string. +// Validates arguments and runs middlewares before executor. +// On error, sends an error message to the user via ctx.error(). func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, dbContext *T) { - pl := p.payloads[payload] - if err := pl.validateArgs(ctx.Args); err != nil { + command, exists := p.payloads[payload] + if !exists { + ctx.error(errors.New("payload not found")) + return + } + + if err := command.validateArgs(ctx.Args); err != nil { ctx.error(err) return } - pl.exec(ctx, dbContext) + + // Run plugin middlewares + if !p.executeMiddlewares(ctx, dbContext) { + return + } + + // Run command-specific middlewares + for _, m := range command.middlewares { + if !m.Execute(ctx, dbContext) { + return + } + } + + // Execute payload + command.exec(ctx, dbContext) } + +// executeMiddlewares runs all plugin middlewares in order. +// Returns false if any middleware returns false (blocks execution). func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db *T) bool { for _, m := range p.middlewares { if !m.Execute(ctx, db) { @@ -152,28 +269,41 @@ func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db *T) bool { return true } +// MiddlewareExecutor is the function type for middleware logic. +// Returns true to continue execution, false to block it. +// If async, return value is ignored. type MiddlewareExecutor[T DbContext] func(ctx *MsgContext, db *T) bool -// Middleware -// When async, returned value ignored +// Middleware represents a reusable execution interceptor. +// Can be synchronous (blocking) or asynchronous (non-blocking). type Middleware[T DbContext] struct { - name string - executor MiddlewareExecutor[T] - order int - async bool + name string // Human-readable name for logging/debugging + executor MiddlewareExecutor[T] // Function to execute + order int // Optional sort order (not used yet) + async bool // If true, runs in goroutine and doesn't block } +// NewMiddleware creates a new synchronous middleware. func NewMiddleware[T DbContext](name string, executor MiddlewareExecutor[T]) *Middleware[T] { return &Middleware[T]{name, executor, 0, false} } + +// SetOrder sets the execution order (currently ignored). func (m *Middleware[T]) SetOrder(order int) *Middleware[T] { m.order = order return m } + +// SetAsync marks the middleware to run asynchronously. +// Execution continues regardless of its return value. func (m *Middleware[T]) SetAsync(async bool) *Middleware[T] { m.async = async return m } + +// Execute runs the middleware. +// If async, runs in a goroutine and returns true immediately. +// Otherwise, returns the result of the executor. func (m *Middleware[T]) Execute(ctx *MsgContext, db *T) bool { if m.async { go m.executor(ctx, db) diff --git a/runners.go b/runners.go index 29c0ed5..0774a37 100644 --- a/runners.go +++ b/runners.go @@ -1,58 +1,123 @@ +// 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 ( "time" ) +// RunnerFn is the function type for a runner. It receives a pointer to +// the Bot and returns an error if execution fails. type RunnerFn[T DbContext] func(*Bot[T]) error + +// Runner represents a configurable background or one-time task to be +// executed by a Bot. +// +// Runners are configured using builder methods: Onetime(), Async(), Timeout(). +// Once Execute() is called, the Runner should not be modified. +// +// Execution semantics: +// - onetime=true, async=false: Run once synchronously (blocks). +// - onetime=true, async=true: Run once in a goroutine (non-blocking). +// - onetime=false, async=true: Run repeatedly in a goroutine with timeout. +// - onetime=false, async=false: Invalid configuration — ignored with warning. type Runner[T DbContext] struct { - name string - onetime bool - async bool - timeout time.Duration - fn RunnerFn[T] + name string // Human-readable name for logging + onetime bool // If true, runs once; if false, runs periodically + async bool // If true, runs in a goroutine; else, runs synchronously + timeout time.Duration // Duration to wait between periodic executions (ignored if onetime=true) + fn RunnerFn[T] // The function to execute } -// NewRunner creates a new Runner with async=true by default. -// Builder methods (Onetime, Async, Timeout) modify the Runner in-place. +// NewRunner creates a new Runner with the given name and function. +// By default, the Runner is configured as async=true (non-blocking). +// +// Builder methods (Onetime, Async, Timeout) can be chained to customize behavior. // DO NOT call builder methods concurrently or after Execute(). func NewRunner[T DbContext](name string, fn RunnerFn[T]) *Runner[T] { return &Runner[T]{ - name: name, fn: fn, async: true, + name: name, + fn: fn, + async: true, // Default: run asynchronously + timeout: 0, // Default: no timeout (ignored if onetime=true) } } -func (b *Runner[T]) Onetime(onetime bool) *Runner[T] { - b.onetime = onetime - return b -} -func (b *Runner[T]) Async(async bool) *Runner[T] { - b.async = async - return b -} -func (b *Runner[T]) Timeout(timeout time.Duration) *Runner[T] { - b.timeout = timeout - return b + +// Onetime sets whether the runner executes once or repeatedly. +// If true, the runner runs only once. +// If false, the runner runs in a loop with the configured timeout. +func (r *Runner[T]) Onetime(onetime bool) *Runner[T] { + r.onetime = onetime + return r } +// Async sets whether the runner executes synchronously or asynchronously. +// If true, the runner runs in a goroutine (non-blocking). +// If false, the runner blocks the caller during execution. +// +// Note: If onetime=false and async=false, the runner will be skipped with a warning. +func (r *Runner[T]) Async(async bool) *Runner[T] { + r.async = async + return r +} + +// Timeout sets the duration to wait between repeated executions for +// non-onetime runners. +// +// If onetime=true, this value is ignored. +// If onetime=false and async=true, this timeout determines the sleep interval +// between loop iterations. +// +// A zero value (time.Duration(0)) is allowed but may trigger a warning +// if used with a background (non-onetime) async runner. +func (r *Runner[T]) Timeout(timeout time.Duration) *Runner[T] { + r.timeout = timeout + return r +} + +// ExecRunners executes all runners registered on the Bot. +// +// It logs warnings for misconfigured runners: +// - Sync, non-onetime runners are skipped (invalid configuration). +// - Background (non-onetime, async) runners without a timeout trigger a warning. +// +// Execution logic: +// - onetime + async: Runs once in a goroutine. +// - onetime + sync: Runs once synchronously; warns if slower than 2 seconds. +// - !onetime + async: Runs in an infinite loop with timeout between iterations. +// - !onetime + sync: Skipped with warning. +// +// This method is typically called once during bot startup. func (bot *Bot[T]) ExecRunners() { bot.logger.Infoln("Executing runners...") for _, runner := range bot.runners { + // Validate configuration if !runner.onetime && !runner.async { - bot.logger.Warnf("Runner %s not onetime, but sync\n", runner.name) + bot.logger.Warnf("Runner %s not onetime, but sync — skipping\n", runner.name) continue } - if !runner.onetime && runner.async && runner.timeout == (time.Second*0) { - bot.logger.Warnf("Background runner \"%s\" should have timeout", runner.name) + if !runner.onetime && runner.async && runner.timeout == 0 { + bot.logger.Warnf("Background runner \"%s\" has no timeout — may cause tight loop\n", runner.name) } - if runner.async && runner.onetime { - go func() { - err := runner.fn(bot) + if runner.onetime && runner.async { + // One-time async: fire and forget + go func(r Runner[T]) { + err := r.fn(bot) if err != nil { - bot.logger.Warnf("Runner %s failed: %s\n", runner.name, err) + bot.logger.Warnf("Runner %s failed: %s\n", r.name, err) } - }() - } else if !runner.async && runner.onetime { + }(runner) + } else if runner.onetime && !runner.async { + // One-time sync: block until done t := time.Now() err := runner.fn(bot) if err != nil { @@ -60,18 +125,20 @@ func (bot *Bot[T]) ExecRunners() { } elapsed := time.Since(t) if elapsed > time.Second*2 { - bot.logger.Warnf("Runner %s too slow. Elapsed time %s>=2s", runner.name, elapsed) + bot.logger.Warnf("Runner %s too slow. Elapsed time %v >= 2s\n", runner.name, elapsed) } - } else if !runner.onetime { - go func() { + } else if !runner.onetime && runner.async { + // Background loop: periodic execution + go func(r Runner[T]) { for { - err := runner.fn(bot) + err := r.fn(bot) if err != nil { - bot.logger.Warnf("Runner %s failed: %s\n", runner.name, err) + bot.logger.Warnf("Runner %s failed: %s\n", r.name, err) } - time.Sleep(runner.timeout) + time.Sleep(r.timeout) } - }() + }(runner) } + // Note: !onetime && !async is already skipped above } } diff --git a/tgapi/methods.go b/tgapi/methods.go index 3be5f8e..c9f467e 100644 --- a/tgapi/methods.go +++ b/tgapi/methods.go @@ -12,6 +12,7 @@ const ( ParseMDV2 ParseMode = "MarkdownV2" ParseHTML ParseMode = "HTML" ParseMD ParseMode = "Markdown" + ParseNone ParseMode = "None" ) type EmptyParams struct{} diff --git a/utils.go b/utils.go index ed48cf6..96456eb 100644 --- a/utils.go +++ b/utils.go @@ -48,4 +48,10 @@ func EscapePunctuation(s string) string { return s } -const VersionString = utils.VersionString +const ( + VersionString = utils.VersionString + VersionMajor = utils.VersionMajor + VersionMinor = utils.VersionMinor + VersionPatch = utils.VersionPatch + VersionBeta = utils.VersionBeta +) diff --git a/utils/version.go b/utils/version.go index 50ea178..25891c6 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-beta.11" + VersionString = "1.0.0-beta.12" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - Beta = 11 + VersionBeta = 12 )