Compare commits

..

6 Commits

Author SHA1 Message Date
fb81bb91bd v1.0.0 beta 20 2026-03-13 13:37:20 +03:00
589e11b22d docs fix 2026-03-13 13:25:26 +03:00
5976fcd0b8 v1.0.0 beta 19 2026-03-13 12:25:53 +03:00
6ba8520bb7 v1.0.0 beta 18 2026-03-13 11:24:13 +03:00
e4203e8fc0 v1.0.0 beta 17; removed examples 2026-03-13 09:58:06 +03:00
c179a3f5f0 v1.0.0 beta 16 2026-03-12 18:04:46 +03:00
21 changed files with 552 additions and 464 deletions

View File

@@ -124,15 +124,21 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
Provides access to the incoming message and useful reply methods:
- `Answer(text string)`: Sends a plain text message, automatically escaping MarkdownV2.
- `AnswerMarkdown(text string)`: Sends a message formatted with MarkdownV2 (you handle escaping).
- `AnswerText(text string)`: Sends a message with no parse_mode.
- `SendChatAction(action string)`: Sends a "typing", "uploading photo", etc., action.
- `Answer(text string) *AnswerMessage`: Sends a message with parse_mode none.
- `AnswerMarkdown(text string) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping).
- `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message with parse_mode none and inline keyboard.
- `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping) and inline keyboard.
- `AnswerPhoto(photoId, text string) *AnswerMessage`: Sends a message with photo with parse_mode none.
- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping) with.
- `EditCallback(text string)`: Edits message with parse_mode none after clicking inline button.
- `EditCallbackMarkdown(text string)`: Edits a message formatted with MarkdownV2 (you handle escaping) after clicking inline button.
- `SendChatAction(action string)`: Sends a “typing”, “uploading photo”, etc., action.
- Fields: `Text`, `Args`, `From`, `Chat`, `Msg`, etc.
- And more methods and fields!
### Database Context
The `T` in `NewBot[T]` is a powerful feature. You can pass any type (like a database connection pool) and it will be available in every command and middleware handler.
The `T` in `NewBot[T]` is a powerful feature. You can pass any type (like a database connection pool), and it will be available in every command and middleware handler.
```go
type MyDB struct { /* ... */ }
@@ -185,16 +191,15 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
### Important Notes
- Middleware can modify the MsgContext (e.g., add custom fields) before the command runs.
- If you need to run code after a command, you can call it from within the command itself or use a defer statement inside the middleware that wraps the next call (more advanced).
## ⚙️ Advanced Configuration
- **Inline Keyboards**: Build keyboards using laniakea.NewKeyboard() and AddRow().
- **Inline Keyboards**: Build keyboards using laniakea.NewKeyboard().
- **Rate Limiting**: Pass a configured utils.RateLimiter via BotOpts to handle Telegram's rate limits gracefully.
- **Custom HTTP Client**: Provide your own http.Client in BotOpts for fine-tuned control.
## 📝 License
This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details.
This project is licensed under the GNU General Public License v3.0 see the [LICENSE](LICENSE) file for details.
## 📚 Learn More
[GoDoc](https://pkg.go.dev/git.nix13.pw/scuroneko/laniakea)

View File

@@ -124,11 +124,17 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
### Контекст сообщения (MsgContext)
Предоставляет доступ к входящему сообщению и полезные методы для ответа:
- `Answer(text string)`: Отправляет обычный текст, автоматически экранируя MarkdownV2.
- `Answer(text string)`: Отправляет сообщение с parse_mode none.
- `AnswerMarkdown(text string)`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне).
- `AnswerText(text string)`: Отправляет сообщение без parse_mode.
- `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение с parse_mode none и Inline клавиатурой.
- `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне), и Inline клавиатурой.
- `AnswerPhoto(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью и parse_mode none.
- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью, отформатированной MarkdownV2 (экранирование на вашей стороне).
- `EditCallback(text string)`: Редактирует сообщение, форматируя его в MarkdownV2 (экранирование на вашей стороне), после нажатия Inline кнопки.
- `EditCallbackMarkdown(text string)`: Редактирует сообщение с parse_mode none после нажатия Inline кнопки.
- `SendChatAction(action string)`: Отправляет действие "печатает", "загружает фото" и т.д.
- Поля: `Text`, `Args`, `From`, `Chat`, `Msg` и другие.
- И много других методов и полей!
### Контекст базы данных (Database Context)
Параметр типа `T` в `NewBot[T]` — мощная функция. Вы можете передать любой тип (например, пул соединений с БД), и он будет доступен в каждом обработчике команды и中间件.
@@ -184,10 +190,9 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
### Важные замечания
- Middleware может изменять MsgContext (например, добавлять пользовательские поля) перед запуском команды.
- Если нужно выполнить код после команды, это можно сделать внутри самой команды или использовать отложенный вызов (defer) в middleware, который оборачивает следующий вызов (более продвинутый подход).
## ⚙️ Расширенная настройка
**Инлайн-клавиатуры**: Создавайте клавиатуры с помощью laniakea.NewKeyboard() и AddRow().
**Инлайн-клавиатуры**: Создавайте клавиатуры с помощью laniakea.NewKeyboard().
**Ограничение запросов**: Передайте настроенный utils.RateLimiter через BotOpts для корректной обработки лимитов Telegram.
**Пользовательский HTTP-клиент**: Предоставьте свой http.Client в BotOpts для точного контроля.

223
bot.go
View File

@@ -1,41 +1,9 @@
// Package laniakea provides a modular, extensible framework for building scalable
// Telegram bots with support for plugins, middleware, localization, draft messages,
// rate limiting, structured logging, and dependency injection.
//
// The framework is designed around a fluent API for configuration and separation of concerns:
//
// - Plugins: Handle specific commands or events (e.g., /start, /help)
// - Middleware: Intercept and modify updates before plugins run (auth, logging, validation)
// - Runners: Background goroutines for cleanup, cron jobs, or monitoring
// - DraftProvider: Safely build and resume multi-step messages
// - L10n: Multi-language support via key-based translation
// - RateLimiter: Enforces Telegram API limits to avoid bans
// - Structured Logging: JSON stdout + optional file output with request-level tracing
// - Dependency Injection: Inject custom database contexts (e.g., *gorm.DB, *sql.DB)
//
// Example usage:
//
// bot := laniakea.NewBot[mydb.DBContext](laniakea.LoadOptsFromEnv()).
// DatabaseContext(&myDB).
// AddUpdateType(tgapi.UpdateTypeMessage).
// AddPrefixes("/", "!").
// AddPlugins(&startPlugin, &helpPlugin).
// AddMiddleware(&authMiddleware, &logMiddleware).
// AddRunner(&cleanupRunner).
// AddL10n(l10n.New())
//
// go bot.Run()
//
// All methods are thread-safe except direct field access. Use provided accessors
// (e.g., GetDBContext, SetUpdateOffset) for safe concurrent access.
package laniakea
import (
"context"
"fmt"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -47,113 +15,6 @@ import (
"github.com/alitto/pond/v2"
)
// BotOpts holds configuration options for initializing a Bot.
//
// Values are loaded from environment variables via LoadOptsFromEnv().
// Use NewOpts() to create a zero-value struct and set fields manually.
type BotOpts struct {
// Token is the Telegram bot token (required).
Token string
// UpdateTypes is a semicolon-separated list of update types to listen for.
// Example: "message;edited_message;callback_query"
// Defaults to empty (Telegram will return all types).
UpdateTypes []string
// Debug enables debug-level logging.
Debug bool
// ErrorTemplate is the format string used to wrap error messages sent to users.
// Use "%s" to insert the actual error. Example: "❌ Error: %s"
ErrorTemplate string
// Prefixes is a list of command prefixes (e.g., ["/", "!"]).
// Defaults to ["/"] if not set via environment.
Prefixes []string
// LoggerBasePath is the directory where log files are written.
// Defaults to "./".
LoggerBasePath string
// UseRequestLogger enables detailed logging of all Telegram API requests.
UseRequestLogger bool
// WriteToFile enables writing logs to files (main.log and requests.log).
WriteToFile bool
// UseTestServer uses Telegram's test server (https://api.test.telegram.org).
UseTestServer bool
// APIUrl overrides the default Telegram API endpoint (useful for proxies or self-hosted).
APIUrl string
// RateLimit is the maximum number of API requests per second.
// Telegram allows up to 30 req/s for most bots. Defaults to 30.
RateLimit int
// DropRLOverflow drops incoming updates when rate limit is exceeded instead of queuing.
// Use this to prioritize responsiveness over reliability.
DropRLOverflow bool
}
// NewOpts returns a new BotOpts with zero values.
func NewOpts() *BotOpts { return new(BotOpts) }
// LoadOptsFromEnv loads BotOpts from environment variables.
//
// Environment variables:
// - TG_TOKEN: Bot token (required)
// - UPDATE_TYPES: semicolon-separated update types (e.g., "message;callback_query")
// - DEBUG: "true" to enable debug logging
// - ERROR_TEMPLATE: format string for error messages (e.g., "❌ %s")
// - PREFIXES: semicolon-separated prefixes (e.g., "/;!bot")
// - LOGGER_BASE_PATH: directory for log files (default: "./")
// - USE_REQ_LOG: "true" to enable request logging
// - WRITE_TO_FILE: "true" to write logs to files
// - USE_TEST_SERVER: "true" to use Telegram test server
// - API_URL: custom API endpoint
// - RATE_LIMIT: max requests per second (default: 30)
// - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow
//
// Returns a populated BotOpts. If TG_TOKEN is missing, behavior is undefined.
func LoadOptsFromEnv() *BotOpts {
rateLimit := 30
if rl := os.Getenv("RATE_LIMIT"); rl != "" {
if n, err := strconv.Atoi(rl); err == nil {
rateLimit = n
}
}
return &BotOpts{
Token: os.Getenv("TG_TOKEN"),
UpdateTypes: strings.Split(os.Getenv("UPDATE_TYPES"), ";"),
Debug: os.Getenv("DEBUG") == "true",
ErrorTemplate: os.Getenv("ERROR_TEMPLATE"),
Prefixes: LoadPrefixesFromEnv(),
LoggerBasePath: os.Getenv("LOGGER_BASE_PATH"),
UseRequestLogger: os.Getenv("USE_REQ_LOG") == "true",
WriteToFile: os.Getenv("WRITE_TO_FILE") == "true",
UseTestServer: os.Getenv("USE_TEST_SERVER") == "true",
APIUrl: os.Getenv("API_URL"),
RateLimit: rateLimit,
DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
}
}
// LoadPrefixesFromEnv returns the PREFIXES environment variable split by semicolon.
// Defaults to ["/"] if not set.
func LoadPrefixesFromEnv() []string {
prefixesS, exists := os.LookupEnv("PREFIXES")
if !exists {
return []string{"/"}
}
return strings.Split(prefixesS, ";")
}
// DbContext is an interface representing the application's database context.
// It is injected into plugins and middleware via Bot.DatabaseContext().
//
@@ -163,12 +24,16 @@ func LoadPrefixesFromEnv() []string {
// bot := NewBot[MyDB](opts).DatabaseContext(&myDB)
//
// Use NoDB if no database is needed.
type DbContext interface{}
type DbContext any
// NoDB is a placeholder type for bots that do not use a database.
// Use Bot[NoDB] to indicate no dependency injection is required.
type NoDB struct{ DbContext }
// DbLogger is a function type that returns a slog.LoggerWriter for database logging.
// Used to inject database-specific log output (e.g., SQL queries, ORM events).
type DbLogger[T DbContext] func(db *T) slog.LoggerWriter
// BotPayloadType defines the serialization format for callback data payloads.
type BotPayloadType string
@@ -195,6 +60,7 @@ type Bot[T DbContext] struct {
errorTemplate string
username string
payloadType BotPayloadType
maxWorkers int
logger *slog.Logger // Main bot logger (JSON stdout + optional file)
RequestLogger *slog.Logger // Optional request-level API logging
@@ -254,9 +120,16 @@ func NewBot[T any](opts *BotOpts) *Bot[T] {
prefixes = []string{"/"}
}
workers := 32
if opts.MaxWorkers > 0 {
workers = opts.MaxWorkers
}
bot := &Bot[T]{
updateOffset: 0,
errorTemplate: "%s",
payloadType: BotPayloadBase64,
maxWorkers: workers,
updateQueue: updateQueue,
api: api,
uploader: uploader,
@@ -397,34 +270,6 @@ func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] {
return bot
}
// DbLogger is a function type that returns a slog.LoggerWriter for database logging.
// Used to inject database-specific log output (e.g., SQL queries, ORM events).
type DbLogger[T DbContext] func(db *T) slog.LoggerWriter
// AddDatabaseLoggerWriter adds a database logger writer to all loggers.
//
// The writer will receive logs from:
// - Main bot logger
// - Request logger (if enabled)
// - API and Uploader loggers
//
// Example:
//
// bot.AddDatabaseLoggerWriter(func(db *MyDB) slog.LoggerWriter {
// return db.QueryLogger()
// })
func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] {
w := writer(bot.dbContext)
bot.logger.AddWriter(w)
if bot.RequestLogger != nil {
bot.RequestLogger.AddWriter(w)
}
for _, l := range bot.extraLoggers {
l.AddWriter(w)
}
return bot
}
// DatabaseContext injects a database context into the bot.
// This context is accessible to plugins and middleware via GetDBContext().
func (bot *Bot[T]) DatabaseContext(ctx *T) *Bot[T] {
@@ -568,13 +413,37 @@ func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] {
return bot
}
// AddDatabaseLoggerWriter adds a database logger writer to all loggers.
//
// The writer will receive logs from:
// - Main bot logger
// - Request logger (if enabled)
// - API and Uploader loggers
//
// Example:
//
// bot.AddDatabaseLoggerWriter(func(db *MyDB) slog.LoggerWriter {
// return db.QueryLogger()
// })
func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] {
w := writer(bot.dbContext)
bot.logger.AddWriter(w)
if bot.RequestLogger != nil {
bot.RequestLogger.AddWriter(w)
}
for _, l := range bot.extraLoggers {
l.AddWriter(w)
}
return bot
}
// RunWithContext starts the bot with a given context for graceful shutdown.
//
// This is the main entry point for bot execution. It:
// - Validates required configuration (prefixes, plugins)
// - Starts all registered runners as background goroutines
// - Begins polling for updates via Telegram's GetUpdates API
// - Processes updates concurrently using a worker pool (16 goroutines)
// - Processes updates concurrently using a worker pool with size configurable via BotOpts.MaxWorkers
//
// The context controls graceful shutdown. When canceled, the bot:
// - Stops polling for new updates
@@ -598,12 +467,18 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
return
}
bot.ExecRunners()
bot.ExecRunners(ctx)
bot.logger.Infoln("Bot running. Press CTRL+C to exit.")
// Start update polling in a goroutine
go func() {
defer func() {
if r := recover(); r != nil {
bot.logger.Errorln(fmt.Sprintf("panic in update polling: %v", r))
}
close(bot.updateQueue)
}()
for {
select {
case <-ctx.Done():
@@ -617,6 +492,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
}
for _, u := range updates {
u := u // copy loop variable to avoid race condition
select {
case bot.updateQueue <- &u:
case <-ctx.Done():
@@ -628,13 +504,14 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
}()
// Start worker pool for concurrent update handling
pool := pond.NewPool(16)
pool := pond.NewPool(bot.maxWorkers)
for update := range bot.updateQueue {
update := update // capture loop variable
u := update // capture loop variable
pool.Submit(func() {
bot.handle(update)
bot.handle(u)
})
}
pool.Stop() // Wait for all tasks to complete and stop the pool
}
// Run starts the bot using a background context.

226
bot_opts.go Normal file
View File

@@ -0,0 +1,226 @@
package laniakea
import (
"os"
"strconv"
"strings"
"git.nix13.pw/scuroneko/laniakea/tgapi"
)
// BotOpts holds configuration options for initializing a Bot.
//
// Values are loaded from environment variables via LoadOptsFromEnv().
// Use NewOpts() to create a zero-value struct and set fields manually.
type BotOpts struct {
// Token is the Telegram bot token (required).
Token string
// UpdateTypes is a list of update types to listen for.
// Example: "["message", "edited_message", "callback_query"]"
// Defaults to empty (Telegram will return all types).
UpdateTypes []tgapi.UpdateType
// Debug enables debug-level logging.
Debug bool
// ErrorTemplate is the format string used to wrap error messages sent to users.
// Use "%s" to insert the actual error. Example: "❌ Error: %s"
ErrorTemplate string
// Prefixes is a list of command prefixes (e.g., ["/", "!"]).
// Defaults to ["/"] if not set via environment.
Prefixes []string
// LoggerBasePath is the directory where log files are written.
// Defaults to "./".
LoggerBasePath string
// UseRequestLogger enables detailed logging of all Telegram API requests.
UseRequestLogger bool
// WriteToFile enables writing logs to files (main.log and requests.log).
WriteToFile bool
// UseTestServer uses Telegram's test server (https://api.test.telegram.org).
UseTestServer bool
// APIUrl overrides the default Telegram API endpoint (useful for proxies or self-hosted).
APIUrl string
// RateLimit is the maximum number of API requests per second.
// Telegram allows up to 30 req/s for most bots. Defaults to 30.
RateLimit int
// DropRLOverflow drops incoming updates when rate limit is exceeded instead of queuing.
// Use this to prioritize responsiveness over reliability.
DropRLOverflow bool
// MaxWorkers is the maximum number of concurrency running update handlers.
MaxWorkers int
}
// LoadOptsFromEnv loads BotOpts from environment variables.
//
// Environment variables:
// - TG_TOKEN: Bot token (required)
// - UPDATE_TYPES: semicolon-separated update types (e.g., "message;callback_query")
// - DEBUG: "true" to enable debug logging
// - ERROR_TEMPLATE: format string for error messages (e.g., "❌ %s")
// - PREFIXES: semicolon-separated prefixes (e.g., "/;!bot")
// - LOGGER_BASE_PATH: directory for log files (default: "./")
// - USE_REQ_LOG: "true" to enable request logging
// - WRITE_TO_FILE: "true" to write logs to files
// - USE_TEST_SERVER: "true" to use Telegram test server
// - API_URL: custom API endpoint
// - RATE_LIMIT: max requests per second (default: 30)
// - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow
//
// Returns a populated BotOpts. If TG_TOKEN is missing, behavior is undefined.
func LoadOptsFromEnv() *BotOpts {
rateLimit := 30
if rl := os.Getenv("RATE_LIMIT"); rl != "" {
if n, err := strconv.Atoi(rl); err == nil {
rateLimit = n
}
}
stringUpdateTypes := strings.Split(os.Getenv("UPDATE_TYPES"), ";")
updateTypes := make([]tgapi.UpdateType, len(stringUpdateTypes))
for i, updateType := range stringUpdateTypes {
updateTypes[i] = tgapi.UpdateType(updateType)
}
return &BotOpts{
Token: os.Getenv("TG_TOKEN"),
UpdateTypes: updateTypes,
Debug: os.Getenv("DEBUG") == "true",
ErrorTemplate: os.Getenv("ERROR_TEMPLATE"),
Prefixes: LoadPrefixesFromEnv(),
LoggerBasePath: os.Getenv("LOGGER_BASE_PATH"),
UseRequestLogger: os.Getenv("USE_REQ_LOG") == "true",
WriteToFile: os.Getenv("WRITE_TO_FILE") == "true",
UseTestServer: os.Getenv("USE_TEST_SERVER") == "true",
APIUrl: os.Getenv("API_URL"),
RateLimit: rateLimit,
DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
}
}
// SetToken sets the Telegram bot token (required).
func (opts *BotOpts) SetToken(token string) *BotOpts {
opts.Token = token
return opts
}
// SetUpdateTypes sets the list of update types to listen for.
// If empty (default), Telegram will return all update types.
// Example: opts.SetUpdateTypes("message", "callback_query")
func (opts *BotOpts) SetUpdateTypes(types ...tgapi.UpdateType) *BotOpts {
opts.UpdateTypes = types
return opts
}
// SetDebug enables or disables debug-level logging.
// Default is false.
func (opts *BotOpts) SetDebug(debug bool) *BotOpts {
opts.Debug = debug
return opts
}
// SetErrorTemplate sets the format string for error messages sent to users.
// Use "%s" to insert the actual error. Example: "❌ Error: %s"
// If not set, defaults to "%s".
func (opts *BotOpts) SetErrorTemplate(tpl string) *BotOpts {
opts.ErrorTemplate = tpl
return opts
}
// SetPrefixes sets the command prefixes (e.g., "/", "!").
// If not set via environment, defaults to ["/"].
func (opts *BotOpts) SetPrefixes(prefixes ...string) *BotOpts {
opts.Prefixes = prefixes
return opts
}
// SetLoggerBasePath sets the directory where log files are written.
// Defaults to "./".
func (opts *BotOpts) SetLoggerBasePath(path string) *BotOpts {
opts.LoggerBasePath = path
return opts
}
// SetUseRequestLogger enables detailed logging of all Telegram API requests.
// Default is false.
func (opts *BotOpts) SetUseRequestLogger(use bool) *BotOpts {
opts.UseRequestLogger = use
return opts
}
// SetWriteToFile enables writing logs to files (main.log and requests.log).
// Default is false.
func (opts *BotOpts) SetWriteToFile(write bool) *BotOpts {
opts.WriteToFile = write
return opts
}
// SetUseTestServer enables using Telegram's test server (https://api.telegram.org/bot<token>/test).
// Default is false.
func (opts *BotOpts) SetUseTestServer(use bool) *BotOpts {
opts.UseTestServer = use
return opts
}
// SetAPIUrl overrides the default Telegram API endpoint (useful for proxies or self-hosted).
// If not set, defaults to "https://api.telegram.org".
func (opts *BotOpts) SetAPIUrl(url string) *BotOpts {
opts.APIUrl = url
return opts
}
// SetRateLimit sets the maximum number of API requests per second.
// Telegram allows up to 30 req/s for most bots. Defaults to 30.
func (opts *BotOpts) SetRateLimit(limit int) *BotOpts {
opts.RateLimit = limit
return opts
}
// SetDropRLOverflow drops incoming updates when rate limit is exceeded instead of queuing.
// Use this to prioritize responsiveness over reliability. Default is false.
func (opts *BotOpts) SetDropRLOverflow(drop bool) *BotOpts {
opts.DropRLOverflow = drop
return opts
}
// SetMaxWorkers sets the maximum number of concurrent update handlers.
// Must be called before NewBot, as the value is captured during bot creation.
//
// The optimal value depends on your bot's workload:
// - For I/O-bound handlers (e.g., database queries, external API calls), you may
// need more workers, but be mindful of downstream service limits.
// - For CPU-bound handlers, keep workers close to the number of CPU cores.
//
// Recommended starting points (adjust based on profiling and monitoring):
// - Small to medium bots with fast handlers: 1632
// - Medium to large bots with fast handlers: 3264
// - Large bots with heavy I/O: 64128 (ensure your infrastructure can handle it)
//
// The default is 32. Monitor queue length and processing latency to fine-tune.
func (opts *BotOpts) SetMaxWorkers(workers int) *BotOpts {
opts.MaxWorkers = workers
return opts
}
// LoadPrefixesFromEnv returns the PREFIXES environment variable split by semicolon.
// Defaults to ["/"] if not set.
func LoadPrefixesFromEnv() []string {
prefixesS, exists := os.LookupEnv("PREFIXES")
if !exists {
return []string{"/"}
}
return strings.Split(prefixesS, ";")
}

View File

@@ -1,12 +1,3 @@
// Package laniakea provides a framework for building Telegram bots with plugin-based
// command registration and automatic command scope management.
//
// This module automatically generates and registers bot commands across different
// chat scopes (private, group, admin) based on plugin-defined commands.
//
// Commands are derived from Plugin and Command structs, with optional descriptions
// and argument formatting. Automatic registration avoids manual command setup and
// ensures consistency across chat types.
package laniakea
import (
@@ -41,6 +32,8 @@ var ErrTooManyCommands = errors.New("too many commands. max 100")
//
// Command{command: "start", description: "Start the bot", args: []Arg{{text: "name", required: false}}}
// → Description: "Start the bot. Usage: /start [name]"
// Command{command: "echo", description: "Echo user input", args: []Arg{{text: "name", required: true}}}
// → Description: "Echo user input. Usage: /echo <input>"
func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand {
desc := ""
if len(cmd.description) > 0 {
@@ -50,33 +43,31 @@ func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand {
var descArgs []string
for _, a := range cmd.args {
if a.required {
descArgs = append(descArgs, a.text)
descArgs = append(descArgs, fmt.Sprintf("<%s>", a.text))
} else {
descArgs = append(descArgs, fmt.Sprintf("[%s]", a.text))
}
}
usage := fmt.Sprintf("Usage: /%s %s", cmd.command, strings.Join(descArgs, " "))
if desc != "" {
desc = fmt.Sprintf("%s. Usage: /%s %s", desc, cmd.command, strings.Join(descArgs, " "))
} else {
desc = fmt.Sprintf("Usage: /%s %s", cmd.command, strings.Join(descArgs, " "))
desc = fmt.Sprintf("%s. %s", desc, usage)
return tgapi.BotCommand{Command: cmd.command, Description: desc}
}
return tgapi.BotCommand{Command: cmd.command, Description: desc}
return tgapi.BotCommand{Command: cmd.command, Description: usage}
}
// checkCmdRegex check if command satisfy regexp [a-zA-Z0-9]+
// Return true if satisfy, else false.
func checkCmdRegex(cmd string) bool {
return CmdRegexp.MatchString(cmd)
}
// Return true if satisfied, else false.
func checkCmdRegex(cmd string) bool { return CmdRegexp.MatchString(cmd) }
// generateBotCommandForPlugin collects all non-skipped commands from a Plugin[T]
// gatherCommandsForPlugin collects all non-skipped commands from a Plugin[T]
// and converts them into tgapi.BotCommand objects.
//
// 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 {
func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand {
commands := make([]tgapi.BotCommand, 0)
for _, cmd := range pl.commands {
if cmd.skipAutoCmd {
@@ -90,6 +81,21 @@ func generateBotCommandForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand {
return commands
}
// gatherCommands collects all commands from all plugins
// and converts them into tgapi.BotCommand objects.
// See gatherCommandsForPlugin
func gatherCommands[T any](bot *Bot[T]) []tgapi.BotCommand {
commands := make([]tgapi.BotCommand, 0)
for _, pl := range bot.plugins {
if pl.skipAutoCmd {
continue
}
commands = append(commands, gatherCommandsForPlugin(pl)...)
bot.logger.Debugln(fmt.Sprintf("Registered %d commands from plugin %s", len(pl.commands), pl.name))
}
return commands
}
// AutoGenerateCommands registers all plugin-defined commands with Telegram's Bot API
// across three scopes:
// - Private chats (users)
@@ -119,17 +125,7 @@ func (bot *Bot[T]) AutoGenerateCommands() error {
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.Debugln(fmt.Sprintf("Registered %d commands from plugin %s", len(pl.commands), pl.name))
}
// Enforce Telegram's 100-command limit
commands := gatherCommands(bot)
if len(commands) > 100 {
return ErrTooManyCommands
}
@@ -153,3 +149,39 @@ func (bot *Bot[T]) AutoGenerateCommands() error {
return nil
}
// AutoGenerateCommandsForScope registers all plugin-defined commands with Telegram's Bot API
// for the specified command scope. It first deletes any existing commands in that scope
// to ensure a clean state, then sets the new set of commands.
//
// The scope parameter defines where the commands should be available (e.g., private chats,
// group chats, chat administrators). See tgapi.BotCommandScope and its predefined types.
//
// Returns ErrTooManyCommands if the total number of commands exceeds 100.
// Returns any API error from Telegram (e.g., network issues, invalid scope).
//
// Usage:
//
// privateScope := &tgapi.BotCommandScope{Type: tgapi.BotCommandScopePrivateType}
// if err := bot.AutoGenerateCommandsForScope(privateScope); err != nil {
// log.Fatal(err)
// }
func (bot *Bot[T]) AutoGenerateCommandsForScope(scope *tgapi.BotCommandScope) error {
_, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommandsP{Scope: scope})
if err != nil {
return fmt.Errorf("failed to delete existing commands: %w", err)
}
commands := gatherCommands(bot)
if len(commands) > 100 {
return ErrTooManyCommands
}
_, err = bot.api.SetMyCommands(tgapi.SetMyCommandsP{
Commands: commands,
Scope: scope,
})
if err != nil {
return fmt.Errorf("failed to set commands for scope %q: %w", scope.Type, err)
}
return nil
}

55
doc.go Normal file
View File

@@ -0,0 +1,55 @@
/*
Package laniakea provides a modular, extensible framework for building scalable Telegram bots.
It offers a fluent API for configuration and separates concerns through several core concepts:
- Bot: The central instance managing API communication, update processing, logging,
rate limiting, and dependency injection. Created via NewBot[T].
- Plugins: Organize commands and payloads into reusable units.
A plugin can have multiple commands and shared middlewares.
- Commands: Named bot commands with descriptions, argument validation, and
execution logic. Automatically registrable across different chat scopes.
- Middleware: Functions that intercept and modify updates before they reach plugins.
Useful for authentication, logging, validation, etc. Return false to stop processing.
- MsgContext: Provides access to the incoming update and convenient methods for
responding, editing, deleting, and translating messages. Includes built-in rate limiting
and error handling. ⚠️ MarkdownV2 methods require manual escaping via EscapeMarkdownV2().
- InlineKeyboard: A fluent builder for constructing inline keyboards with styled buttons,
icons, URLs, and structured callback data (JSON or Base64).
- DraftProvider: Manages ephemeral, multi-step message drafts with automatic ID generation
(random or linear). Drafts can be built incrementally and flushed atomically.
- L10n: Simple key-based localization system with fallback language support.
- Runners: Background goroutines for periodic tasks or oneoff initialization,
with configurable timeouts and async execution.
- RateLimiting & Logging: Builtin rate limiter (respects Telegram's retry_after)
and structured logging (JSON stdout + optional file output) with requestlevel tracing.
- Dependency Injection: Pass any custom database context (e.g., *sql.DB) to all handlers
via the type parameter T in Bot[T].
Example usage:
bot := laniakea.NewBot[mydb.DBContext](laniakea.LoadOptsFromEnv()).
DatabaseContext(&myDB).
AddUpdateType(tgapi.UpdateTypeMessage).
AddPrefixes("/", "!").
AddPlugins(&startPlugin, &helpPlugin).
AddMiddleware(&authMiddleware, &logMiddleware).
AddRunner(&cleanupRunner).
AddL10n(l10n.New())
bot.Run()
All public methods are safe for concurrent use unless stated otherwise.
Direct field access is not recommended; use provided accessors (e.g., GetDBContext, SetUpdateOffset).
*/
package laniakea

103
drafts.go
View File

@@ -1,40 +1,16 @@
// Package laniakea provides a safe, high-level interface for managing Telegram
// message drafts using the tgapi library. It allows creating, editing, and
// flushing drafts with automatic ID generation and optional bulk flushing.
//
// Drafts are designed to be ephemeral, mutable buffers that can be built up
// incrementally and then sent as final messages. The package ensures safe
// state management by copying entities and isolating draft contexts.
//
// Two draft ID generation strategies are supported:
// - Random: Cryptographically secure random IDs (default). Ideal for distributed systems.
// - Linear: Monotonically increasing IDs. Useful for persistence, debugging, or recovery.
//
// Example usage:
//
// provider := laniakea.NewRandomDraftProvider(api)
//
// draft := provider.NewDraft(tgapi.ParseModeMarkdown)
// draft.SetChat(-1001234567890, 0)
// draft.Push("*Hello*").Push(" **world**!")
// err := draft.Flush() // Sends message and deletes draft
// if err != nil {
// log.Printf("Failed to send draft: %v", err)
// }
//
// // Or flush all pending drafts at once:
// err = provider.FlushAll() // Sends all drafts and clears them
//
// Note: Drafts are NOT thread-safe. Concurrent access requires external synchronization.
package laniakea
import (
"errors"
"math/rand/v2"
"sync"
"sync/atomic"
"git.nix13.pw/scuroneko/laniakea/tgapi"
)
var ErrDraftChatIDZero = errors.New("zero draft chat ID")
// draftIdGenerator defines an interface for generating unique draft IDs.
type draftIdGenerator interface {
// Next returns the next unique draft ID.
@@ -68,15 +44,10 @@ func (g *LinearDraftIdGenerator) Next() uint64 {
// DraftProvider is NOT thread-safe. Concurrent access from multiple goroutines
// requires external synchronization.
type DraftProvider struct {
mu sync.RWMutex
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
}
// NewRandomDraftProvider creates a new DraftProvider using random draft IDs.
@@ -107,38 +78,12 @@ func NewLinearDraftProvider(api *tgapi.API, startValue uint64) *DraftProvider {
}
}
// SetChat sets the target chat and optional message thread for all drafts created
// by this provider. Must be called before NewDraft().
//
// If not set, NewDraft() will create drafts with zero chatID, which will cause
// SendMessageDraft to fail. Use this method to avoid runtime errors.
func (p *DraftProvider) SetChat(chatID int64, messageThreadID int) *DraftProvider {
p.chatID = chatID
p.messageThreadID = messageThreadID
return p
}
// SetParseMode sets the default parse mode for all new drafts.
// Overrides the parse mode passed to NewDraft() only if not specified there.
func (p *DraftProvider) SetParseMode(mode tgapi.ParseMode) *DraftProvider {
p.parseMode = mode
return p
}
// SetEntities sets the default message entities (e.g., bold, links, mentions)
// to be copied into every new draft.
//
// Entities are shallow-copied — if you mutate the slice later, it will affect
// future drafts. For safety, pass a copy if needed.
func (p *DraftProvider) SetEntities(entities []tgapi.MessageEntity) *DraftProvider {
p.entities = entities
return p
}
// GetDraft retrieves a draft by its ID.
//
// Returns the draft and true if found, or nil and false if not found.
func (p *DraftProvider) GetDraft(id uint64) (*Draft, bool) {
p.mu.RLock()
defer p.mu.RUnlock()
draft, ok := p.drafts[id]
return draft, ok
}
@@ -150,8 +95,16 @@ func (p *DraftProvider) GetDraft(id uint64) (*Draft, bool) {
//
// After successful flush, each draft is removed from the provider and cleared.
func (p *DraftProvider) FlushAll() error {
var lastErr error
p.mu.Lock()
drafts := make([]*Draft, 0, len(p.drafts))
for _, draft := range p.drafts {
drafts = append(drafts, draft)
}
p.drafts = make(map[uint64]*Draft)
p.mu.Unlock()
var lastErr error
for _, draft := range drafts {
if err := draft.Flush(); err != nil {
lastErr = err
break // Stop on first error to avoid partial state
@@ -186,22 +139,17 @@ type Draft struct {
//
// Panics if chatID is zero — call SetChat() on the provider first.
func (p *DraftProvider) NewDraft(parseMode tgapi.ParseMode) *Draft {
if p.chatID == 0 {
panic("laniakea: DraftProvider.SetChat() must be called before NewDraft()")
}
id := p.generator.Next()
draft := &Draft{
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: "",
api: p.api,
provider: p,
parseMode: parseMode,
ID: id,
Message: "",
}
p.mu.Lock()
p.drafts[id] = draft
p.mu.Unlock()
return draft
}
@@ -253,7 +201,9 @@ func (d *Draft) Clear() {
// want to cancel a draft without sending it.
func (d *Draft) Delete() {
if d.provider != nil {
d.provider.mu.Lock()
delete(d.provider.drafts, d.ID)
d.provider.mu.Unlock()
}
d.Clear()
}
@@ -295,6 +245,9 @@ func (d *Draft) Flush() error {
// push is the internal helper for Push(). It updates the server draft via SendMessageDraft.
func (d *Draft) push(text string) error {
if d.chatID == 0 {
return ErrDraftChatIDZero
}
d.Message += text
params := tgapi.SendMessageDraftP{
ChatID: d.chatID,

View File

@@ -1,7 +0,0 @@
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

View File

@@ -1,30 +0,0 @@
package main
import (
"log"
"git.nix13.pw/scuroneko/laniakea"
)
func echo(ctx *laniakea.MsgContext, db *laniakea.NoDB) {
ctx.Answer(ctx.Text) // User input WITHOUT command
}
func main() {
opts := &laniakea.BotOpts{Token: "TOKEN"}
bot := laniakea.NewBot[laniakea.NoDB](opts)
defer bot.Close()
p := laniakea.NewPlugin[laniakea.NoDB]("ping")
p.AddCommand(p.NewCommand(echo, "echo"))
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db *laniakea.NoDB) {
ctx.Answer("Pong")
}, "ping"))
bot = bot.ErrorTemplate("Error\n\n%s").AddPlugins(p)
if err := bot.AutoGenerateCommands(); err != nil {
log.Println(err)
}
bot.Run()
}

View File

@@ -1,20 +0,0 @@
module example/basic
go 1.26.1
require git.nix13.pw/scuroneko/laniakea v1.0.0-beta.14
replace (
git.nix13.pw/scuroneko/laniakea v1.0.0-beta.14 => ../../
)
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.7.0 // 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.42.0 // indirect
golang.org/x/time v0.15.0 // indirect
)

View File

@@ -1,19 +0,0 @@
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.13 h1:mRVxYh7CNrm8ccob+u6XxLzZRbs1fLNRg/nXaXY78yw=
git.nix13.pw/scuroneko/laniakea v1.0.0-beta.13/go.mod h1:M8jwm195hzAl9bj9Bkl95WfHmWvuBX6micsdtOs/gmE=
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.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=
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.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=

View File

@@ -4,6 +4,7 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"git.nix13.pw/scuroneko/laniakea/tgapi"
@@ -12,6 +13,12 @@ import (
var ErrInvalidPayloadType = errors.New("invalid payload type")
func (bot *Bot[T]) handle(u *tgapi.Update) {
defer func() {
if r := recover(); r != nil {
bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r))
}
}()
ctx := &MsgContext{
Update: *u, Api: bot.api,
botLogger: bot.logger,
@@ -84,7 +91,7 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) {
if !plugin.executeMiddlewares(ctx, bot.dbContext) {
return
}
go plugin.executeCmd(cmd, ctx, bot.dbContext)
plugin.executeCmd(cmd, ctx, bot.dbContext)
return
}
}
@@ -113,7 +120,7 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) {
if !plugin.executeMiddlewares(ctx, bot.dbContext) {
return
}
go plugin.executePayload(data.Command, ctx, bot.dbContext)
plugin.executePayload(data.Command, ctx, bot.dbContext)
return
}
}
@@ -148,13 +155,6 @@ func encodeBase64Payload(d CallbackData) (string, error) {
base64.StdEncoding.Encode(dst, []byte(data))
return string(dst), nil
}
func decodeBase64Payload(s string) (CallbackData, error) {
b, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return CallbackData{}, err
}
return decodeJsonPayload(string(b))
}
// func encodePayload(payloadType BotPayloadType, d CallbackData) (string, error) {
// switch payloadType {
@@ -165,6 +165,13 @@ func decodeBase64Payload(s string) (CallbackData, error) {
// }
// return "", ErrInvalidPayloadType
// }
func decodeBase64Payload(s string) (CallbackData, error) {
b, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return CallbackData{}, err
}
return decodeJsonPayload(string(b))
}
func decodePayload(payloadType BotPayloadType, s string) (CallbackData, error) {
switch payloadType {
case BotPayloadBase64:

View File

@@ -1,13 +1,3 @@
// Package laniakea provides a fluent builder system for constructing Telegram
// inline keyboards with callback data and custom styling.
//
// This package supports:
// - Button builders with style (danger/success/primary), icons, URLs, and callbacks
// - Line-based keyboard layout with configurable max row size
// - Structured, JSON-serialized callback data for bot command routing
//
// Keyboard construction is stateful and builder-style: methods return the receiver
// to enable chaining. Call Get() to finalize and retrieve the tgapi.ReplyMarkup.
package laniakea
import (

10
l10n.go
View File

@@ -1,13 +1,3 @@
// Package laniakea provides a simple, key-based localization system for
// multi-language text translation.
//
// The system supports:
// - Multiple language entries per key (e.g., "ru", "en", "es")
// - Fallback language for missing translations
// - Key-as-fallback behavior: if a key or language is not found, returns the key itself
//
// This is designed for lightweight, static localization in bots or services
// where dynamic translation services are unnecessary.
package laniakea
// DictEntry represents a single localized entry with language-to-text mappings.

View File

@@ -6,6 +6,38 @@ import (
"git.nix13.pw/scuroneko/laniakea/tgapi"
)
// Updates fetches new updates from Telegram API using long polling.
// It respects the bot's current update offset and automatically advances it
// after successful retrieval. The method supports selective update types
// through AllowedUpdates and includes optional request logging.
//
// Parameters:
// - None (uses bot's internal state for offset and allowed updates)
//
// Returns:
// - []tgapi.Update: slice of received updates (empty if none available)
// - error: any error encountered during the API call
//
// Behavior:
// 1. Uses the bot's current update offset (via GetUpdateOffset)
// 2. Requests updates with 30-second timeout
// 3. Filters updates by types specified in bot.GetUpdateTypes()
// 4. Logs raw update JSON if RequestLogger is configured
// 5. Automatically updates the offset to the last received update ID + 1
// 6. Returns all received updates (empty slice if none)
//
// Note: This is a blocking call that waits up to 30 seconds for new updates.
// For non-blocking behavior, consider using webhooks instead.
//
// Example:
//
// updates, err := bot.Updates()
// if err != nil {
// log.Fatal(err)
// }
// for _, update := range updates {
// // process update
// }
func (bot *Bot[T]) Updates() ([]tgapi.Update, error) {
offset := bot.GetUpdateOffset()
params := tgapi.UpdateParams{

View File

@@ -1,27 +1,9 @@
// Package laniakea provides a high-level context-based API for handling Telegram
// bot interactions, including message responses, callback queries, inline keyboards,
// localization, and message drafting. It wraps tgapi and adds convenience methods
// with built-in rate limiting, error handling, and i18n support.
//
// The core type is MsgContext, which encapsulates the state of a Telegram update
// and provides methods to respond, edit, delete, and translate messages.
//
// # Markdown Safety Warning
//
// All methods that accept MarkdownV2 formatting (e.g., AnswerMarkdown, EditCallbackfMarkdown)
// require that user-provided text be escaped using laniakea.EscapeMarkdownV2().
// Failure to escape user input may result in Telegram API errors, malformed messages,
// or security issues.
//
// Example:
//
// text := laniakea.EscapeMarkdownV2(userInput)
// ctx.AnswerMarkdown("You said: " + text)
package laniakea
import (
"context"
"fmt"
"time"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog"
@@ -31,10 +13,12 @@ import (
// 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
Msg *tgapi.Message
Update tgapi.Update
From *tgapi.User
Api *tgapi.API
Update tgapi.Update
Msg *tgapi.Message
From *tgapi.User
CallbackMsgId int
CallbackQueryId string
FromID int
@@ -385,7 +369,13 @@ func (ctx *MsgContext) error(err error) {
func (ctx *MsgContext) Error(err error) { ctx.error(err) }
func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft {
c := context.Background()
if ctx.Msg == nil {
ctx.botLogger.Errorln("can't create draft: ctx.Msg is nil")
return nil
}
c, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil {
ctx.botLogger.Errorln(err)
return nil

View File

@@ -1,15 +1,3 @@
// Package laniakea provides a structured system for defining and executing
// bot commands and payloads with middleware support, argument validation,
// and plugin-based organization.
//
// The core concepts are:
// - Command: A named bot command with arguments, description, and executor.
// - Plugin: A collection of commands and payloads, with shared middlewares.
// - Middleware: Interceptors that can validate, modify, or block execution.
// - CommandArg: Type-safe argument definitions with regex validation.
//
// This system is designed to be used with MsgContext from the laniakea package
// to handle Telegram bot interactions in a modular, type-safe way.
package laniakea
import (
@@ -33,11 +21,14 @@ const (
CommandValueAnyType CommandValueType = "any"
)
// CommandRegexInt matches one or more digits.
var CommandRegexInt = regexp.MustCompile(`\d+`)
// CommandRegexString matches any non-empty string.
var CommandRegexString = regexp.MustCompile(".+")
var (
// CommandRegexInt matches one or more digits.
CommandRegexInt = regexp.MustCompile(`\d+`)
// CommandRegexString matches any non-empty string.
CommandRegexString = regexp.MustCompile(`.+`)
// CommandRegexBool matches true or false
CommandRegexBool = regexp.MustCompile(`true|false`)
)
// ErrCmdArgCountMismatch is returned when the number of provided arguments
// is less than the number of required arguments.
@@ -58,15 +49,22 @@ type CommandArg struct {
// NewCommandArg creates a new CommandArg with the given text and type.
// Uses a default regex based on the type (string or int).
// For CommandValueAnyType, no validation is performed.
func NewCommandArg(text string, valueType CommandValueType) *CommandArg {
func NewCommandArg(text string) *CommandArg {
return &CommandArg{CommandValueAnyType, text, CommandRegexString, false}
}
func (c *CommandArg) SetValueType(t CommandValueType) *CommandArg {
regex := CommandRegexString
switch valueType {
switch t {
case CommandValueIntType:
regex = CommandRegexInt
case CommandValueBoolType:
regex = CommandRegexBool
case CommandValueAnyType:
regex = nil // Skip validation
}
return &CommandArg{valueType, text, regex, false}
c.regex = regex
return c
}
// SetRequired marks this argument as required.
@@ -320,7 +318,10 @@ func (m *Middleware[T]) SetAsync(async bool) *Middleware[T] {
// Otherwise, returns the result of the executor.
func (m *Middleware[T]) Execute(ctx *MsgContext, db *T) bool {
if m.async {
go m.executor(ctx, db)
ctx := *ctx // copy context to avoid race condition
go func(ctx MsgContext) {
m.executor(&ctx, db)
}(ctx)
return true
}
return m.executor(ctx, db)

View File

@@ -1,16 +1,7 @@
// Package laniakea provides a system for managing background and one-time
// runner functions that operate on a Bot instance, with support for
// asynchronous execution, timeouts, and lifecycle control.
//
// Runners are used for periodic tasks (e.g., cleanup, stats updates) or
// one-time initialization logic. They are executed via Bot.ExecRunners().
//
// Important: Runners are not thread-safe for concurrent modification.
// Builder methods (Onetime, Async, Timeout) must be called sequentially
// and only before Execute().
package laniakea
import (
"context"
"time"
)
@@ -83,7 +74,7 @@ func (r *Runner[T]) Timeout(timeout time.Duration) *Runner[T] {
return r
}
// ExecRunners executes all runners registered on the Bot.
// ExecRunners executes all runners registered on the Bot with context-based lifecycle management.
//
// It logs warnings for misconfigured runners:
// - Sync, non-onetime runners are skipped (invalid configuration).
@@ -92,11 +83,13 @@ func (r *Runner[T]) Timeout(timeout time.Duration) *Runner[T] {
// 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 + async: Runs in a loop with timeout between iterations until ctx.Done().
// - !onetime + sync: Skipped with warning.
//
// This method is typically called once during bot startup.
func (bot *Bot[T]) ExecRunners() {
// Background runners listen for ctx.Done() and gracefully shut down when the context is canceled.
//
// This method is typically called once during bot startup in RunWithContext.
func (bot *Bot[T]) ExecRunners(ctx context.Context) {
bot.logger.Infoln("Executing runners...")
for _, runner := range bot.runners {
// Validate configuration
@@ -128,14 +121,20 @@ func (bot *Bot[T]) ExecRunners() {
bot.logger.Warnf("Runner %s too slow. Elapsed time %v >= 2s\n", runner.name, elapsed)
}
} else if !runner.onetime && runner.async {
// Background loop: periodic execution
// Background loop: periodic execution with graceful shutdown
go func(r Runner[T]) {
ticker := time.NewTicker(r.timeout)
defer ticker.Stop()
for {
err := r.fn(bot)
if err != nil {
bot.logger.Warnf("Runner %s failed: %s\n", r.name, err)
select {
case <-ctx.Done():
return
case <-ticker.C:
err := r.fn(bot)
if err != nil {
bot.logger.Warnf("Runner %s failed: %s\n", r.name, err)
}
}
time.Sleep(r.timeout)
}
}(runner)
}

View File

@@ -1,6 +1,6 @@
package tgapi
// UpdateType represents the type of an incoming update.
// UpdateType represents the type of incoming update.
type UpdateType string
const (

View File

@@ -193,8 +193,10 @@ func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) erro
// getChatLimiter returns the rate limiter for the given chat, creating it if needed.
// Uses 1 request per second with burst of 1 — conservative for per-user limits.
// Must be called with rl.chatMu held.
func (rl *RateLimiter) getChatLimiter(chatID int64) *rate.Limiter {
rl.chatMu.Lock()
defer rl.chatMu.Unlock()
if lim, ok := rl.chatLimiters[chatID]; ok {
return lim
}

View File

@@ -1,9 +1,9 @@
package utils
const (
VersionString = "1.0.0-beta.15"
VersionString = "1.0.0-beta.20"
VersionMajor = 1
VersionMinor = 0
VersionPatch = 0
VersionBeta = 15
VersionBeta = 20
)