Compare commits
5 Commits
v1.0.0-bet
...
v1.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
fb81bb91bd
|
|||
|
589e11b22d
|
|||
|
5976fcd0b8
|
|||
|
6ba8520bb7
|
|||
|
e4203e8fc0
|
21
README.md
21
README.md
@@ -124,15 +124,21 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
|
|||||||
|
|
||||||
Provides access to the incoming message and useful reply methods:
|
Provides access to the incoming message and useful reply methods:
|
||||||
|
|
||||||
- `Answer(text string)`: Sends a plain text message, automatically escaping MarkdownV2.
|
- `Answer(text string) *AnswerMessage`: Sends a message with parse_mode none.
|
||||||
- `AnswerMarkdown(text string)`: Sends a message formatted with MarkdownV2 (you handle escaping).
|
- `AnswerMarkdown(text string) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping).
|
||||||
- `AnswerText(text string)`: Sends a message with no parse_mode.
|
- `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message with parse_mode none and inline keyboard.
|
||||||
- `SendChatAction(action string)`: Sends a "typing", "uploading photo", etc., action.
|
- `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.
|
- Fields: `Text`, `Args`, `From`, `Chat`, `Msg`, etc.
|
||||||
|
- And more methods and fields!
|
||||||
|
|
||||||
### Database Context
|
### 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
|
```go
|
||||||
type MyDB struct { /* ... */ }
|
type MyDB struct { /* ... */ }
|
||||||
@@ -185,16 +191,15 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
|
|||||||
|
|
||||||
### Important Notes
|
### Important Notes
|
||||||
- Middleware can modify the MsgContext (e.g., add custom fields) before the command runs.
|
- 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
|
## ⚙️ 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.
|
- **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.
|
- **Custom HTTP Client**: Provide your own http.Client in BotOpts for fine-tuned control.
|
||||||
|
|
||||||
## 📝 License
|
## 📝 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
|
## 📚 Learn More
|
||||||
[GoDoc](https://pkg.go.dev/git.nix13.pw/scuroneko/laniakea)
|
[GoDoc](https://pkg.go.dev/git.nix13.pw/scuroneko/laniakea)
|
||||||
|
|||||||
13
README_RU.md
13
README_RU.md
@@ -124,11 +124,17 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
|
|||||||
### Контекст сообщения (MsgContext)
|
### Контекст сообщения (MsgContext)
|
||||||
Предоставляет доступ к входящему сообщению и полезные методы для ответа:
|
Предоставляет доступ к входящему сообщению и полезные методы для ответа:
|
||||||
|
|
||||||
- `Answer(text string)`: Отправляет обычный текст, автоматически экранируя MarkdownV2.
|
- `Answer(text string)`: Отправляет сообщение с parse_mode none.
|
||||||
- `AnswerMarkdown(text string)`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне).
|
- `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)`: Отправляет действие "печатает", "загружает фото" и т.д.
|
- `SendChatAction(action string)`: Отправляет действие "печатает", "загружает фото" и т.д.
|
||||||
- Поля: `Text`, `Args`, `From`, `Chat`, `Msg` и другие.
|
- Поля: `Text`, `Args`, `From`, `Chat`, `Msg` и другие.
|
||||||
|
- И много других методов и полей!
|
||||||
|
|
||||||
### Контекст базы данных (Database Context)
|
### Контекст базы данных (Database Context)
|
||||||
Параметр типа `T` в `NewBot[T]` — мощная функция. Вы можете передать любой тип (например, пул соединений с БД), и он будет доступен в каждом обработчике команды и中间件.
|
Параметр типа `T` в `NewBot[T]` — мощная функция. Вы можете передать любой тип (например, пул соединений с БД), и он будет доступен в каждом обработчике команды и中间件.
|
||||||
@@ -184,10 +190,9 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
|
|||||||
|
|
||||||
### Важные замечания
|
### Важные замечания
|
||||||
- Middleware может изменять MsgContext (например, добавлять пользовательские поля) перед запуском команды.
|
- Middleware может изменять MsgContext (например, добавлять пользовательские поля) перед запуском команды.
|
||||||
- Если нужно выполнить код после команды, это можно сделать внутри самой команды или использовать отложенный вызов (defer) в middleware, который оборачивает следующий вызов (более продвинутый подход).
|
|
||||||
|
|
||||||
## ⚙️ Расширенная настройка
|
## ⚙️ Расширенная настройка
|
||||||
**Инлайн-клавиатуры**: Создавайте клавиатуры с помощью laniakea.NewKeyboard() и AddRow().
|
**Инлайн-клавиатуры**: Создавайте клавиатуры с помощью laniakea.NewKeyboard().
|
||||||
**Ограничение запросов**: Передайте настроенный utils.RateLimiter через BotOpts для корректной обработки лимитов Telegram.
|
**Ограничение запросов**: Передайте настроенный utils.RateLimiter через BotOpts для корректной обработки лимитов Telegram.
|
||||||
**Пользовательский HTTP-клиент**: Предоставьте свой http.Client в BotOpts для точного контроля.
|
**Пользовательский HTTP-клиент**: Предоставьте свой http.Client в BotOpts для точного контроля.
|
||||||
|
|
||||||
|
|||||||
222
bot.go
222
bot.go
@@ -1,41 +1,9 @@
|
|||||||
// Package laniakea provides a modular, extensible framework for building scalable
|
|
||||||
// Telegram bots with support for plugins, middleware, localization, draft messages,
|
|
||||||
// rate limiting, structured logging, and dependency injection.
|
|
||||||
//
|
|
||||||
// The framework is designed around a fluent API for configuration and separation of concerns:
|
|
||||||
//
|
|
||||||
// - Plugins: Handle specific commands or events (e.g., /start, /help)
|
|
||||||
// - Middleware: Intercept and modify updates before plugins run (auth, logging, validation)
|
|
||||||
// - Runners: Background goroutines for cleanup, cron jobs, or monitoring
|
|
||||||
// - DraftProvider: Safely build and resume multi-step messages
|
|
||||||
// - L10n: Multi-language support via key-based translation
|
|
||||||
// - RateLimiter: Enforces Telegram API limits to avoid bans
|
|
||||||
// - Structured Logging: JSON stdout + optional file output with request-level tracing
|
|
||||||
// - Dependency Injection: Inject custom database contexts (e.g., *gorm.DB, *sql.DB)
|
|
||||||
//
|
|
||||||
// Example usage:
|
|
||||||
//
|
|
||||||
// bot := laniakea.NewBot[mydb.DBContext](laniakea.LoadOptsFromEnv()).
|
|
||||||
// DatabaseContext(&myDB).
|
|
||||||
// AddUpdateType(tgapi.UpdateTypeMessage).
|
|
||||||
// AddPrefixes("/", "!").
|
|
||||||
// AddPlugins(&startPlugin, &helpPlugin).
|
|
||||||
// AddMiddleware(&authMiddleware, &logMiddleware).
|
|
||||||
// AddRunner(&cleanupRunner).
|
|
||||||
// AddL10n(l10n.New())
|
|
||||||
//
|
|
||||||
// go bot.Run()
|
|
||||||
//
|
|
||||||
// All methods are thread-safe except direct field access. Use provided accessors
|
|
||||||
// (e.g., GetDBContext, SetUpdateOffset) for safe concurrent access.
|
|
||||||
package laniakea
|
package laniakea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -47,113 +15,6 @@ import (
|
|||||||
"github.com/alitto/pond/v2"
|
"github.com/alitto/pond/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BotOpts holds configuration options for initializing a Bot.
|
|
||||||
//
|
|
||||||
// Values are loaded from environment variables via LoadOptsFromEnv().
|
|
||||||
// Use NewOpts() to create a zero-value struct and set fields manually.
|
|
||||||
type BotOpts struct {
|
|
||||||
// Token is the Telegram bot token (required).
|
|
||||||
Token string
|
|
||||||
|
|
||||||
// UpdateTypes is a semicolon-separated list of update types to listen for.
|
|
||||||
// Example: "message;edited_message;callback_query"
|
|
||||||
// Defaults to empty (Telegram will return all types).
|
|
||||||
UpdateTypes []string
|
|
||||||
|
|
||||||
// Debug enables debug-level logging.
|
|
||||||
Debug bool
|
|
||||||
|
|
||||||
// ErrorTemplate is the format string used to wrap error messages sent to users.
|
|
||||||
// Use "%s" to insert the actual error. Example: "❌ Error: %s"
|
|
||||||
ErrorTemplate string
|
|
||||||
|
|
||||||
// Prefixes is a list of command prefixes (e.g., ["/", "!"]).
|
|
||||||
// Defaults to ["/"] if not set via environment.
|
|
||||||
Prefixes []string
|
|
||||||
|
|
||||||
// LoggerBasePath is the directory where log files are written.
|
|
||||||
// Defaults to "./".
|
|
||||||
LoggerBasePath string
|
|
||||||
|
|
||||||
// UseRequestLogger enables detailed logging of all Telegram API requests.
|
|
||||||
UseRequestLogger bool
|
|
||||||
|
|
||||||
// WriteToFile enables writing logs to files (main.log and requests.log).
|
|
||||||
WriteToFile bool
|
|
||||||
|
|
||||||
// UseTestServer uses Telegram's test server (https://api.test.telegram.org).
|
|
||||||
UseTestServer bool
|
|
||||||
|
|
||||||
// APIUrl overrides the default Telegram API endpoint (useful for proxies or self-hosted).
|
|
||||||
APIUrl string
|
|
||||||
|
|
||||||
// RateLimit is the maximum number of API requests per second.
|
|
||||||
// Telegram allows up to 30 req/s for most bots. Defaults to 30.
|
|
||||||
RateLimit int
|
|
||||||
|
|
||||||
// DropRLOverflow drops incoming updates when rate limit is exceeded instead of queuing.
|
|
||||||
// Use this to prioritize responsiveness over reliability.
|
|
||||||
DropRLOverflow bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewOpts returns a new BotOpts with zero values.
|
|
||||||
func NewOpts() *BotOpts { return new(BotOpts) }
|
|
||||||
|
|
||||||
// LoadOptsFromEnv loads BotOpts from environment variables.
|
|
||||||
//
|
|
||||||
// Environment variables:
|
|
||||||
// - TG_TOKEN: Bot token (required)
|
|
||||||
// - UPDATE_TYPES: semicolon-separated update types (e.g., "message;callback_query")
|
|
||||||
// - DEBUG: "true" to enable debug logging
|
|
||||||
// - ERROR_TEMPLATE: format string for error messages (e.g., "❌ %s")
|
|
||||||
// - PREFIXES: semicolon-separated prefixes (e.g., "/;!bot")
|
|
||||||
// - LOGGER_BASE_PATH: directory for log files (default: "./")
|
|
||||||
// - USE_REQ_LOG: "true" to enable request logging
|
|
||||||
// - WRITE_TO_FILE: "true" to write logs to files
|
|
||||||
// - USE_TEST_SERVER: "true" to use Telegram test server
|
|
||||||
// - API_URL: custom API endpoint
|
|
||||||
// - RATE_LIMIT: max requests per second (default: 30)
|
|
||||||
// - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow
|
|
||||||
//
|
|
||||||
// Returns a populated BotOpts. If TG_TOKEN is missing, behavior is undefined.
|
|
||||||
func LoadOptsFromEnv() *BotOpts {
|
|
||||||
rateLimit := 30
|
|
||||||
if rl := os.Getenv("RATE_LIMIT"); rl != "" {
|
|
||||||
if n, err := strconv.Atoi(rl); err == nil {
|
|
||||||
rateLimit = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &BotOpts{
|
|
||||||
Token: os.Getenv("TG_TOKEN"),
|
|
||||||
UpdateTypes: strings.Split(os.Getenv("UPDATE_TYPES"), ";"),
|
|
||||||
|
|
||||||
Debug: os.Getenv("DEBUG") == "true",
|
|
||||||
ErrorTemplate: os.Getenv("ERROR_TEMPLATE"),
|
|
||||||
Prefixes: LoadPrefixesFromEnv(),
|
|
||||||
|
|
||||||
LoggerBasePath: os.Getenv("LOGGER_BASE_PATH"),
|
|
||||||
UseRequestLogger: os.Getenv("USE_REQ_LOG") == "true",
|
|
||||||
WriteToFile: os.Getenv("WRITE_TO_FILE") == "true",
|
|
||||||
|
|
||||||
UseTestServer: os.Getenv("USE_TEST_SERVER") == "true",
|
|
||||||
APIUrl: os.Getenv("API_URL"),
|
|
||||||
|
|
||||||
RateLimit: rateLimit,
|
|
||||||
DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadPrefixesFromEnv returns the PREFIXES environment variable split by semicolon.
|
|
||||||
// Defaults to ["/"] if not set.
|
|
||||||
func LoadPrefixesFromEnv() []string {
|
|
||||||
prefixesS, exists := os.LookupEnv("PREFIXES")
|
|
||||||
if !exists {
|
|
||||||
return []string{"/"}
|
|
||||||
}
|
|
||||||
return strings.Split(prefixesS, ";")
|
|
||||||
}
|
|
||||||
|
|
||||||
// DbContext is an interface representing the application's database context.
|
// DbContext is an interface representing the application's database context.
|
||||||
// It is injected into plugins and middleware via Bot.DatabaseContext().
|
// It is injected into plugins and middleware via Bot.DatabaseContext().
|
||||||
//
|
//
|
||||||
@@ -163,12 +24,16 @@ func LoadPrefixesFromEnv() []string {
|
|||||||
// bot := NewBot[MyDB](opts).DatabaseContext(&myDB)
|
// bot := NewBot[MyDB](opts).DatabaseContext(&myDB)
|
||||||
//
|
//
|
||||||
// Use NoDB if no database is needed.
|
// Use NoDB if no database is needed.
|
||||||
type DbContext interface{}
|
type DbContext any
|
||||||
|
|
||||||
// NoDB is a placeholder type for bots that do not use a database.
|
// NoDB is a placeholder type for bots that do not use a database.
|
||||||
// Use Bot[NoDB] to indicate no dependency injection is required.
|
// Use Bot[NoDB] to indicate no dependency injection is required.
|
||||||
type NoDB struct{ DbContext }
|
type NoDB struct{ DbContext }
|
||||||
|
|
||||||
|
// DbLogger is a function type that returns a slog.LoggerWriter for database logging.
|
||||||
|
// Used to inject database-specific log output (e.g., SQL queries, ORM events).
|
||||||
|
type DbLogger[T DbContext] func(db *T) slog.LoggerWriter
|
||||||
|
|
||||||
// BotPayloadType defines the serialization format for callback data payloads.
|
// BotPayloadType defines the serialization format for callback data payloads.
|
||||||
type BotPayloadType string
|
type BotPayloadType string
|
||||||
|
|
||||||
@@ -195,6 +60,7 @@ type Bot[T DbContext] struct {
|
|||||||
errorTemplate string
|
errorTemplate string
|
||||||
username string
|
username string
|
||||||
payloadType BotPayloadType
|
payloadType BotPayloadType
|
||||||
|
maxWorkers int
|
||||||
|
|
||||||
logger *slog.Logger // Main bot logger (JSON stdout + optional file)
|
logger *slog.Logger // Main bot logger (JSON stdout + optional file)
|
||||||
RequestLogger *slog.Logger // Optional request-level API logging
|
RequestLogger *slog.Logger // Optional request-level API logging
|
||||||
@@ -254,10 +120,16 @@ func NewBot[T any](opts *BotOpts) *Bot[T] {
|
|||||||
prefixes = []string{"/"}
|
prefixes = []string{"/"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
workers := 32
|
||||||
|
if opts.MaxWorkers > 0 {
|
||||||
|
workers = opts.MaxWorkers
|
||||||
|
}
|
||||||
|
|
||||||
bot := &Bot[T]{
|
bot := &Bot[T]{
|
||||||
updateOffset: 0,
|
updateOffset: 0,
|
||||||
errorTemplate: "%s",
|
errorTemplate: "%s",
|
||||||
payloadType: BotPayloadBase64,
|
payloadType: BotPayloadBase64,
|
||||||
|
maxWorkers: workers,
|
||||||
updateQueue: updateQueue,
|
updateQueue: updateQueue,
|
||||||
api: api,
|
api: api,
|
||||||
uploader: uploader,
|
uploader: uploader,
|
||||||
@@ -398,34 +270,6 @@ func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] {
|
|||||||
return bot
|
return bot
|
||||||
}
|
}
|
||||||
|
|
||||||
// DbLogger is a function type that returns a slog.LoggerWriter for database logging.
|
|
||||||
// Used to inject database-specific log output (e.g., SQL queries, ORM events).
|
|
||||||
type DbLogger[T DbContext] func(db *T) slog.LoggerWriter
|
|
||||||
|
|
||||||
// AddDatabaseLoggerWriter adds a database logger writer to all loggers.
|
|
||||||
//
|
|
||||||
// The writer will receive logs from:
|
|
||||||
// - Main bot logger
|
|
||||||
// - Request logger (if enabled)
|
|
||||||
// - API and Uploader loggers
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// bot.AddDatabaseLoggerWriter(func(db *MyDB) slog.LoggerWriter {
|
|
||||||
// return db.QueryLogger()
|
|
||||||
// })
|
|
||||||
func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] {
|
|
||||||
w := writer(bot.dbContext)
|
|
||||||
bot.logger.AddWriter(w)
|
|
||||||
if bot.RequestLogger != nil {
|
|
||||||
bot.RequestLogger.AddWriter(w)
|
|
||||||
}
|
|
||||||
for _, l := range bot.extraLoggers {
|
|
||||||
l.AddWriter(w)
|
|
||||||
}
|
|
||||||
return bot
|
|
||||||
}
|
|
||||||
|
|
||||||
// DatabaseContext injects a database context into the bot.
|
// DatabaseContext injects a database context into the bot.
|
||||||
// This context is accessible to plugins and middleware via GetDBContext().
|
// This context is accessible to plugins and middleware via GetDBContext().
|
||||||
func (bot *Bot[T]) DatabaseContext(ctx *T) *Bot[T] {
|
func (bot *Bot[T]) DatabaseContext(ctx *T) *Bot[T] {
|
||||||
@@ -569,13 +413,37 @@ func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] {
|
|||||||
return bot
|
return bot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddDatabaseLoggerWriter adds a database logger writer to all loggers.
|
||||||
|
//
|
||||||
|
// The writer will receive logs from:
|
||||||
|
// - Main bot logger
|
||||||
|
// - Request logger (if enabled)
|
||||||
|
// - API and Uploader loggers
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// bot.AddDatabaseLoggerWriter(func(db *MyDB) slog.LoggerWriter {
|
||||||
|
// return db.QueryLogger()
|
||||||
|
// })
|
||||||
|
func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] {
|
||||||
|
w := writer(bot.dbContext)
|
||||||
|
bot.logger.AddWriter(w)
|
||||||
|
if bot.RequestLogger != nil {
|
||||||
|
bot.RequestLogger.AddWriter(w)
|
||||||
|
}
|
||||||
|
for _, l := range bot.extraLoggers {
|
||||||
|
l.AddWriter(w)
|
||||||
|
}
|
||||||
|
return bot
|
||||||
|
}
|
||||||
|
|
||||||
// RunWithContext starts the bot with a given context for graceful shutdown.
|
// RunWithContext starts the bot with a given context for graceful shutdown.
|
||||||
//
|
//
|
||||||
// This is the main entry point for bot execution. It:
|
// This is the main entry point for bot execution. It:
|
||||||
// - Validates required configuration (prefixes, plugins)
|
// - Validates required configuration (prefixes, plugins)
|
||||||
// - Starts all registered runners as background goroutines
|
// - Starts all registered runners as background goroutines
|
||||||
// - Begins polling for updates via Telegram's GetUpdates API
|
// - Begins polling for updates via Telegram's GetUpdates API
|
||||||
// - Processes updates concurrently using a worker pool (16 goroutines)
|
// - Processes updates concurrently using a worker pool with size configurable via BotOpts.MaxWorkers
|
||||||
//
|
//
|
||||||
// The context controls graceful shutdown. When canceled, the bot:
|
// The context controls graceful shutdown. When canceled, the bot:
|
||||||
// - Stops polling for new updates
|
// - Stops polling for new updates
|
||||||
@@ -599,12 +467,18 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.ExecRunners()
|
bot.ExecRunners(ctx)
|
||||||
|
|
||||||
bot.logger.Infoln("Bot running. Press CTRL+C to exit.")
|
bot.logger.Infoln("Bot running. Press CTRL+C to exit.")
|
||||||
|
|
||||||
// Start update polling in a goroutine
|
// Start update polling in a goroutine
|
||||||
go func() {
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
bot.logger.Errorln(fmt.Sprintf("panic in update polling: %v", r))
|
||||||
|
}
|
||||||
|
close(bot.updateQueue)
|
||||||
|
}()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -618,6 +492,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, u := range updates {
|
for _, u := range updates {
|
||||||
|
u := u // copy loop variable to avoid race condition
|
||||||
select {
|
select {
|
||||||
case bot.updateQueue <- &u:
|
case bot.updateQueue <- &u:
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -629,13 +504,14 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
// Start worker pool for concurrent update handling
|
// Start worker pool for concurrent update handling
|
||||||
pool := pond.NewPool(16)
|
pool := pond.NewPool(bot.maxWorkers)
|
||||||
for update := range bot.updateQueue {
|
for update := range bot.updateQueue {
|
||||||
update := update // capture loop variable
|
u := update // capture loop variable
|
||||||
pool.Submit(func() {
|
pool.Submit(func() {
|
||||||
bot.handle(update)
|
bot.handle(u)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
pool.Stop() // Wait for all tasks to complete and stop the pool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the bot using a background context.
|
// Run starts the bot using a background context.
|
||||||
|
|||||||
226
bot_opts.go
Normal file
226
bot_opts.go
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
package laniakea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BotOpts holds configuration options for initializing a Bot.
|
||||||
|
//
|
||||||
|
// Values are loaded from environment variables via LoadOptsFromEnv().
|
||||||
|
// Use NewOpts() to create a zero-value struct and set fields manually.
|
||||||
|
type BotOpts struct {
|
||||||
|
// Token is the Telegram bot token (required).
|
||||||
|
Token string
|
||||||
|
|
||||||
|
// UpdateTypes is a list of update types to listen for.
|
||||||
|
// Example: "["message", "edited_message", "callback_query"]"
|
||||||
|
// Defaults to empty (Telegram will return all types).
|
||||||
|
UpdateTypes []tgapi.UpdateType
|
||||||
|
|
||||||
|
// Debug enables debug-level logging.
|
||||||
|
Debug bool
|
||||||
|
|
||||||
|
// ErrorTemplate is the format string used to wrap error messages sent to users.
|
||||||
|
// Use "%s" to insert the actual error. Example: "❌ Error: %s"
|
||||||
|
ErrorTemplate string
|
||||||
|
|
||||||
|
// Prefixes is a list of command prefixes (e.g., ["/", "!"]).
|
||||||
|
// Defaults to ["/"] if not set via environment.
|
||||||
|
Prefixes []string
|
||||||
|
|
||||||
|
// LoggerBasePath is the directory where log files are written.
|
||||||
|
// Defaults to "./".
|
||||||
|
LoggerBasePath string
|
||||||
|
|
||||||
|
// UseRequestLogger enables detailed logging of all Telegram API requests.
|
||||||
|
UseRequestLogger bool
|
||||||
|
|
||||||
|
// WriteToFile enables writing logs to files (main.log and requests.log).
|
||||||
|
WriteToFile bool
|
||||||
|
|
||||||
|
// UseTestServer uses Telegram's test server (https://api.test.telegram.org).
|
||||||
|
UseTestServer bool
|
||||||
|
|
||||||
|
// APIUrl overrides the default Telegram API endpoint (useful for proxies or self-hosted).
|
||||||
|
APIUrl string
|
||||||
|
|
||||||
|
// RateLimit is the maximum number of API requests per second.
|
||||||
|
// Telegram allows up to 30 req/s for most bots. Defaults to 30.
|
||||||
|
RateLimit int
|
||||||
|
|
||||||
|
// DropRLOverflow drops incoming updates when rate limit is exceeded instead of queuing.
|
||||||
|
// Use this to prioritize responsiveness over reliability.
|
||||||
|
DropRLOverflow bool
|
||||||
|
|
||||||
|
// MaxWorkers is the maximum number of concurrency running update handlers.
|
||||||
|
MaxWorkers int
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadOptsFromEnv loads BotOpts from environment variables.
|
||||||
|
//
|
||||||
|
// Environment variables:
|
||||||
|
// - TG_TOKEN: Bot token (required)
|
||||||
|
// - UPDATE_TYPES: semicolon-separated update types (e.g., "message;callback_query")
|
||||||
|
// - DEBUG: "true" to enable debug logging
|
||||||
|
// - ERROR_TEMPLATE: format string for error messages (e.g., "❌ %s")
|
||||||
|
// - PREFIXES: semicolon-separated prefixes (e.g., "/;!bot")
|
||||||
|
// - LOGGER_BASE_PATH: directory for log files (default: "./")
|
||||||
|
// - USE_REQ_LOG: "true" to enable request logging
|
||||||
|
// - WRITE_TO_FILE: "true" to write logs to files
|
||||||
|
// - USE_TEST_SERVER: "true" to use Telegram test server
|
||||||
|
// - API_URL: custom API endpoint
|
||||||
|
// - RATE_LIMIT: max requests per second (default: 30)
|
||||||
|
// - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow
|
||||||
|
//
|
||||||
|
// Returns a populated BotOpts. If TG_TOKEN is missing, behavior is undefined.
|
||||||
|
func LoadOptsFromEnv() *BotOpts {
|
||||||
|
rateLimit := 30
|
||||||
|
if rl := os.Getenv("RATE_LIMIT"); rl != "" {
|
||||||
|
if n, err := strconv.Atoi(rl); err == nil {
|
||||||
|
rateLimit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stringUpdateTypes := strings.Split(os.Getenv("UPDATE_TYPES"), ";")
|
||||||
|
updateTypes := make([]tgapi.UpdateType, len(stringUpdateTypes))
|
||||||
|
for i, updateType := range stringUpdateTypes {
|
||||||
|
updateTypes[i] = tgapi.UpdateType(updateType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &BotOpts{
|
||||||
|
Token: os.Getenv("TG_TOKEN"),
|
||||||
|
UpdateTypes: updateTypes,
|
||||||
|
|
||||||
|
Debug: os.Getenv("DEBUG") == "true",
|
||||||
|
ErrorTemplate: os.Getenv("ERROR_TEMPLATE"),
|
||||||
|
Prefixes: LoadPrefixesFromEnv(),
|
||||||
|
|
||||||
|
LoggerBasePath: os.Getenv("LOGGER_BASE_PATH"),
|
||||||
|
UseRequestLogger: os.Getenv("USE_REQ_LOG") == "true",
|
||||||
|
WriteToFile: os.Getenv("WRITE_TO_FILE") == "true",
|
||||||
|
|
||||||
|
UseTestServer: os.Getenv("USE_TEST_SERVER") == "true",
|
||||||
|
APIUrl: os.Getenv("API_URL"),
|
||||||
|
|
||||||
|
RateLimit: rateLimit,
|
||||||
|
DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetToken sets the Telegram bot token (required).
|
||||||
|
func (opts *BotOpts) SetToken(token string) *BotOpts {
|
||||||
|
opts.Token = token
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUpdateTypes sets the list of update types to listen for.
|
||||||
|
// If empty (default), Telegram will return all update types.
|
||||||
|
// Example: opts.SetUpdateTypes("message", "callback_query")
|
||||||
|
func (opts *BotOpts) SetUpdateTypes(types ...tgapi.UpdateType) *BotOpts {
|
||||||
|
opts.UpdateTypes = types
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDebug enables or disables debug-level logging.
|
||||||
|
// Default is false.
|
||||||
|
func (opts *BotOpts) SetDebug(debug bool) *BotOpts {
|
||||||
|
opts.Debug = debug
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetErrorTemplate sets the format string for error messages sent to users.
|
||||||
|
// Use "%s" to insert the actual error. Example: "❌ Error: %s"
|
||||||
|
// If not set, defaults to "%s".
|
||||||
|
func (opts *BotOpts) SetErrorTemplate(tpl string) *BotOpts {
|
||||||
|
opts.ErrorTemplate = tpl
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPrefixes sets the command prefixes (e.g., "/", "!").
|
||||||
|
// If not set via environment, defaults to ["/"].
|
||||||
|
func (opts *BotOpts) SetPrefixes(prefixes ...string) *BotOpts {
|
||||||
|
opts.Prefixes = prefixes
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLoggerBasePath sets the directory where log files are written.
|
||||||
|
// Defaults to "./".
|
||||||
|
func (opts *BotOpts) SetLoggerBasePath(path string) *BotOpts {
|
||||||
|
opts.LoggerBasePath = path
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUseRequestLogger enables detailed logging of all Telegram API requests.
|
||||||
|
// Default is false.
|
||||||
|
func (opts *BotOpts) SetUseRequestLogger(use bool) *BotOpts {
|
||||||
|
opts.UseRequestLogger = use
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWriteToFile enables writing logs to files (main.log and requests.log).
|
||||||
|
// Default is false.
|
||||||
|
func (opts *BotOpts) SetWriteToFile(write bool) *BotOpts {
|
||||||
|
opts.WriteToFile = write
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUseTestServer enables using Telegram's test server (https://api.telegram.org/bot<token>/test).
|
||||||
|
// Default is false.
|
||||||
|
func (opts *BotOpts) SetUseTestServer(use bool) *BotOpts {
|
||||||
|
opts.UseTestServer = use
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAPIUrl overrides the default Telegram API endpoint (useful for proxies or self-hosted).
|
||||||
|
// If not set, defaults to "https://api.telegram.org".
|
||||||
|
func (opts *BotOpts) SetAPIUrl(url string) *BotOpts {
|
||||||
|
opts.APIUrl = url
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRateLimit sets the maximum number of API requests per second.
|
||||||
|
// Telegram allows up to 30 req/s for most bots. Defaults to 30.
|
||||||
|
func (opts *BotOpts) SetRateLimit(limit int) *BotOpts {
|
||||||
|
opts.RateLimit = limit
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDropRLOverflow drops incoming updates when rate limit is exceeded instead of queuing.
|
||||||
|
// Use this to prioritize responsiveness over reliability. Default is false.
|
||||||
|
func (opts *BotOpts) SetDropRLOverflow(drop bool) *BotOpts {
|
||||||
|
opts.DropRLOverflow = drop
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMaxWorkers sets the maximum number of concurrent update handlers.
|
||||||
|
// Must be called before NewBot, as the value is captured during bot creation.
|
||||||
|
//
|
||||||
|
// The optimal value depends on your bot's workload:
|
||||||
|
// - For I/O-bound handlers (e.g., database queries, external API calls), you may
|
||||||
|
// need more workers, but be mindful of downstream service limits.
|
||||||
|
// - For CPU-bound handlers, keep workers close to the number of CPU cores.
|
||||||
|
//
|
||||||
|
// Recommended starting points (adjust based on profiling and monitoring):
|
||||||
|
// - Small to medium bots with fast handlers: 16–32
|
||||||
|
// - Medium to large bots with fast handlers: 32–64
|
||||||
|
// - Large bots with heavy I/O: 64–128 (ensure your infrastructure can handle it)
|
||||||
|
//
|
||||||
|
// The default is 32. Monitor queue length and processing latency to fine-tune.
|
||||||
|
func (opts *BotOpts) SetMaxWorkers(workers int) *BotOpts {
|
||||||
|
opts.MaxWorkers = workers
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadPrefixesFromEnv returns the PREFIXES environment variable split by semicolon.
|
||||||
|
// Defaults to ["/"] if not set.
|
||||||
|
func LoadPrefixesFromEnv() []string {
|
||||||
|
prefixesS, exists := os.LookupEnv("PREFIXES")
|
||||||
|
if !exists {
|
||||||
|
return []string{"/"}
|
||||||
|
}
|
||||||
|
return strings.Split(prefixesS, ";")
|
||||||
|
}
|
||||||
@@ -1,12 +1,3 @@
|
|||||||
// Package laniakea provides a framework for building Telegram bots with plugin-based
|
|
||||||
// command registration and automatic command scope management.
|
|
||||||
//
|
|
||||||
// This module automatically generates and registers bot commands across different
|
|
||||||
// chat scopes (private, group, admin) based on plugin-defined commands.
|
|
||||||
//
|
|
||||||
// Commands are derived from Plugin and Command structs, with optional descriptions
|
|
||||||
// and argument formatting. Automatic registration avoids manual command setup and
|
|
||||||
// ensures consistency across chat types.
|
|
||||||
package laniakea
|
package laniakea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -41,6 +32,8 @@ var ErrTooManyCommands = errors.New("too many commands. max 100")
|
|||||||
//
|
//
|
||||||
// Command{command: "start", description: "Start the bot", args: []Arg{{text: "name", required: false}}}
|
// Command{command: "start", description: "Start the bot", args: []Arg{{text: "name", required: false}}}
|
||||||
// → Description: "Start the bot. Usage: /start [name]"
|
// → Description: "Start the bot. Usage: /start [name]"
|
||||||
|
// Command{command: "echo", description: "Echo user input", args: []Arg{{text: "name", required: true}}}
|
||||||
|
// → Description: "Echo user input. Usage: /echo <input>"
|
||||||
func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand {
|
func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand {
|
||||||
desc := ""
|
desc := ""
|
||||||
if len(cmd.description) > 0 {
|
if len(cmd.description) > 0 {
|
||||||
@@ -50,33 +43,31 @@ func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand {
|
|||||||
var descArgs []string
|
var descArgs []string
|
||||||
for _, a := range cmd.args {
|
for _, a := range cmd.args {
|
||||||
if a.required {
|
if a.required {
|
||||||
descArgs = append(descArgs, a.text)
|
descArgs = append(descArgs, fmt.Sprintf("<%s>", a.text))
|
||||||
} else {
|
} else {
|
||||||
descArgs = append(descArgs, fmt.Sprintf("[%s]", a.text))
|
descArgs = append(descArgs, fmt.Sprintf("[%s]", a.text))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
usage := fmt.Sprintf("Usage: /%s %s", cmd.command, strings.Join(descArgs, " "))
|
||||||
if desc != "" {
|
if desc != "" {
|
||||||
desc = fmt.Sprintf("%s. Usage: /%s %s", desc, cmd.command, strings.Join(descArgs, " "))
|
desc = fmt.Sprintf("%s. %s", desc, usage)
|
||||||
} else {
|
return tgapi.BotCommand{Command: cmd.command, Description: desc}
|
||||||
desc = fmt.Sprintf("Usage: /%s %s", cmd.command, strings.Join(descArgs, " "))
|
|
||||||
}
|
}
|
||||||
return tgapi.BotCommand{Command: cmd.command, Description: desc}
|
return tgapi.BotCommand{Command: cmd.command, Description: usage}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkCmdRegex check if command satisfy regexp [a-zA-Z0-9]+
|
// checkCmdRegex check if command satisfy regexp [a-zA-Z0-9]+
|
||||||
// Return true if satisfy, else false.
|
// Return true if satisfied, else false.
|
||||||
func checkCmdRegex(cmd string) bool {
|
func checkCmdRegex(cmd string) bool { return CmdRegexp.MatchString(cmd) }
|
||||||
return CmdRegexp.MatchString(cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateBotCommandForPlugin collects all non-skipped commands from a Plugin[T]
|
// gatherCommandsForPlugin collects all non-skipped commands from a Plugin[T]
|
||||||
// and converts them into tgapi.BotCommand objects.
|
// and converts them into tgapi.BotCommand objects.
|
||||||
//
|
//
|
||||||
// Commands marked with skipAutoCmd = true are excluded from auto-registration.
|
// Commands marked with skipAutoCmd = true are excluded from auto-registration.
|
||||||
// This allows plugins to opt out of automatic command generation (e.g., for
|
// This allows plugins to opt out of automatic command generation (e.g., for
|
||||||
// internal or hidden commands).
|
// internal or hidden commands).
|
||||||
func generateBotCommandForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand {
|
func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand {
|
||||||
commands := make([]tgapi.BotCommand, 0)
|
commands := make([]tgapi.BotCommand, 0)
|
||||||
for _, cmd := range pl.commands {
|
for _, cmd := range pl.commands {
|
||||||
if cmd.skipAutoCmd {
|
if cmd.skipAutoCmd {
|
||||||
@@ -90,6 +81,21 @@ func generateBotCommandForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand {
|
|||||||
return commands
|
return commands
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// gatherCommands collects all commands from all plugins
|
||||||
|
// and converts them into tgapi.BotCommand objects.
|
||||||
|
// See gatherCommandsForPlugin
|
||||||
|
func gatherCommands[T any](bot *Bot[T]) []tgapi.BotCommand {
|
||||||
|
commands := make([]tgapi.BotCommand, 0)
|
||||||
|
for _, pl := range bot.plugins {
|
||||||
|
if pl.skipAutoCmd {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
commands = append(commands, gatherCommandsForPlugin(pl)...)
|
||||||
|
bot.logger.Debugln(fmt.Sprintf("Registered %d commands from plugin %s", len(pl.commands), pl.name))
|
||||||
|
}
|
||||||
|
return commands
|
||||||
|
}
|
||||||
|
|
||||||
// AutoGenerateCommands registers all plugin-defined commands with Telegram's Bot API
|
// AutoGenerateCommands registers all plugin-defined commands with Telegram's Bot API
|
||||||
// across three scopes:
|
// across three scopes:
|
||||||
// - Private chats (users)
|
// - Private chats (users)
|
||||||
@@ -119,17 +125,7 @@ func (bot *Bot[T]) AutoGenerateCommands() error {
|
|||||||
return fmt.Errorf("failed to delete existing commands: %w", err)
|
return fmt.Errorf("failed to delete existing commands: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect all non-skipped commands from all plugins
|
commands := gatherCommands(bot)
|
||||||
commands := make([]tgapi.BotCommand, 0)
|
|
||||||
for _, pl := range bot.plugins {
|
|
||||||
if pl.skipAutoCmd {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
commands = append(commands, generateBotCommandForPlugin(pl)...)
|
|
||||||
bot.logger.Debugln(fmt.Sprintf("Registered %d commands from plugin %s", len(pl.commands), pl.name))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enforce Telegram's 100-command limit
|
|
||||||
if len(commands) > 100 {
|
if len(commands) > 100 {
|
||||||
return ErrTooManyCommands
|
return ErrTooManyCommands
|
||||||
}
|
}
|
||||||
@@ -153,3 +149,39 @@ func (bot *Bot[T]) AutoGenerateCommands() error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AutoGenerateCommandsForScope registers all plugin-defined commands with Telegram's Bot API
|
||||||
|
// for the specified command scope. It first deletes any existing commands in that scope
|
||||||
|
// to ensure a clean state, then sets the new set of commands.
|
||||||
|
//
|
||||||
|
// The scope parameter defines where the commands should be available (e.g., private chats,
|
||||||
|
// group chats, chat administrators). See tgapi.BotCommandScope and its predefined types.
|
||||||
|
//
|
||||||
|
// Returns ErrTooManyCommands if the total number of commands exceeds 100.
|
||||||
|
// Returns any API error from Telegram (e.g., network issues, invalid scope).
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// privateScope := &tgapi.BotCommandScope{Type: tgapi.BotCommandScopePrivateType}
|
||||||
|
// if err := bot.AutoGenerateCommandsForScope(privateScope); err != nil {
|
||||||
|
// log.Fatal(err)
|
||||||
|
// }
|
||||||
|
func (bot *Bot[T]) AutoGenerateCommandsForScope(scope *tgapi.BotCommandScope) error {
|
||||||
|
_, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommandsP{Scope: scope})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete existing commands: %w", err)
|
||||||
|
}
|
||||||
|
commands := gatherCommands(bot)
|
||||||
|
if len(commands) > 100 {
|
||||||
|
return ErrTooManyCommands
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = bot.api.SetMyCommands(tgapi.SetMyCommandsP{
|
||||||
|
Commands: commands,
|
||||||
|
Scope: scope,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to set commands for scope %q: %w", scope.Type, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
55
doc.go
Normal file
55
doc.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
Package laniakea provides a modular, extensible framework for building scalable Telegram bots.
|
||||||
|
|
||||||
|
It offers a fluent API for configuration and separates concerns through several core concepts:
|
||||||
|
|
||||||
|
- Bot: The central instance managing API communication, update processing, logging,
|
||||||
|
rate limiting, and dependency injection. Created via NewBot[T].
|
||||||
|
|
||||||
|
- Plugins: Organize commands and payloads into reusable units.
|
||||||
|
A plugin can have multiple commands and shared middlewares.
|
||||||
|
|
||||||
|
- Commands: Named bot commands with descriptions, argument validation, and
|
||||||
|
execution logic. Automatically registrable across different chat scopes.
|
||||||
|
|
||||||
|
- Middleware: Functions that intercept and modify updates before they reach plugins.
|
||||||
|
Useful for authentication, logging, validation, etc. Return false to stop processing.
|
||||||
|
|
||||||
|
- MsgContext: Provides access to the incoming update and convenient methods for
|
||||||
|
responding, editing, deleting, and translating messages. Includes built-in rate limiting
|
||||||
|
and error handling. ⚠️ MarkdownV2 methods require manual escaping via EscapeMarkdownV2().
|
||||||
|
|
||||||
|
- InlineKeyboard: A fluent builder for constructing inline keyboards with styled buttons,
|
||||||
|
icons, URLs, and structured callback data (JSON or Base64).
|
||||||
|
|
||||||
|
- DraftProvider: Manages ephemeral, multi-step message drafts with automatic ID generation
|
||||||
|
(random or linear). Drafts can be built incrementally and flushed atomically.
|
||||||
|
|
||||||
|
- L10n: Simple key-based localization system with fallback language support.
|
||||||
|
|
||||||
|
- Runners: Background goroutines for periodic tasks or one‑off initialization,
|
||||||
|
with configurable timeouts and async execution.
|
||||||
|
|
||||||
|
- RateLimiting & Logging: Built‑in rate limiter (respects Telegram's retry_after)
|
||||||
|
and structured logging (JSON stdout + optional file output) with request‑level tracing.
|
||||||
|
|
||||||
|
- Dependency Injection: Pass any custom database context (e.g., *sql.DB) to all handlers
|
||||||
|
via the type parameter T in Bot[T].
|
||||||
|
|
||||||
|
Example usage:
|
||||||
|
|
||||||
|
bot := laniakea.NewBot[mydb.DBContext](laniakea.LoadOptsFromEnv()).
|
||||||
|
DatabaseContext(&myDB).
|
||||||
|
AddUpdateType(tgapi.UpdateTypeMessage).
|
||||||
|
AddPrefixes("/", "!").
|
||||||
|
AddPlugins(&startPlugin, &helpPlugin).
|
||||||
|
AddMiddleware(&authMiddleware, &logMiddleware).
|
||||||
|
AddRunner(&cleanupRunner).
|
||||||
|
AddL10n(l10n.New())
|
||||||
|
|
||||||
|
bot.Run()
|
||||||
|
|
||||||
|
All public methods are safe for concurrent use unless stated otherwise.
|
||||||
|
Direct field access is not recommended; use provided accessors (e.g., GetDBContext, SetUpdateOffset).
|
||||||
|
*/
|
||||||
|
package laniakea
|
||||||
103
drafts.go
103
drafts.go
@@ -1,40 +1,16 @@
|
|||||||
// Package laniakea provides a safe, high-level interface for managing Telegram
|
|
||||||
// message drafts using the tgapi library. It allows creating, editing, and
|
|
||||||
// flushing drafts with automatic ID generation and optional bulk flushing.
|
|
||||||
//
|
|
||||||
// Drafts are designed to be ephemeral, mutable buffers that can be built up
|
|
||||||
// incrementally and then sent as final messages. The package ensures safe
|
|
||||||
// state management by copying entities and isolating draft contexts.
|
|
||||||
//
|
|
||||||
// Two draft ID generation strategies are supported:
|
|
||||||
// - Random: Cryptographically secure random IDs (default). Ideal for distributed systems.
|
|
||||||
// - Linear: Monotonically increasing IDs. Useful for persistence, debugging, or recovery.
|
|
||||||
//
|
|
||||||
// Example usage:
|
|
||||||
//
|
|
||||||
// provider := laniakea.NewRandomDraftProvider(api)
|
|
||||||
//
|
|
||||||
// draft := provider.NewDraft(tgapi.ParseModeMarkdown)
|
|
||||||
// draft.SetChat(-1001234567890, 0)
|
|
||||||
// draft.Push("*Hello*").Push(" **world**!")
|
|
||||||
// err := draft.Flush() // Sends message and deletes draft
|
|
||||||
// if err != nil {
|
|
||||||
// log.Printf("Failed to send draft: %v", err)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // Or flush all pending drafts at once:
|
|
||||||
// err = provider.FlushAll() // Sends all drafts and clears them
|
|
||||||
//
|
|
||||||
// Note: Drafts are NOT thread-safe. Concurrent access requires external synchronization.
|
|
||||||
package laniakea
|
package laniakea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ErrDraftChatIDZero = errors.New("zero draft chat ID")
|
||||||
|
|
||||||
// draftIdGenerator defines an interface for generating unique draft IDs.
|
// draftIdGenerator defines an interface for generating unique draft IDs.
|
||||||
type draftIdGenerator interface {
|
type draftIdGenerator interface {
|
||||||
// Next returns the next unique draft ID.
|
// Next returns the next unique draft ID.
|
||||||
@@ -68,15 +44,10 @@ func (g *LinearDraftIdGenerator) Next() uint64 {
|
|||||||
// DraftProvider is NOT thread-safe. Concurrent access from multiple goroutines
|
// DraftProvider is NOT thread-safe. Concurrent access from multiple goroutines
|
||||||
// requires external synchronization.
|
// requires external synchronization.
|
||||||
type DraftProvider struct {
|
type DraftProvider struct {
|
||||||
|
mu sync.RWMutex
|
||||||
api *tgapi.API
|
api *tgapi.API
|
||||||
drafts map[uint64]*Draft
|
drafts map[uint64]*Draft
|
||||||
generator draftIdGenerator
|
generator draftIdGenerator
|
||||||
|
|
||||||
// Internal defaults — not exposed directly to users.
|
|
||||||
chatID int64
|
|
||||||
messageThreadID int
|
|
||||||
parseMode tgapi.ParseMode
|
|
||||||
entities []tgapi.MessageEntity
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRandomDraftProvider creates a new DraftProvider using random draft IDs.
|
// NewRandomDraftProvider creates a new DraftProvider using random draft IDs.
|
||||||
@@ -107,38 +78,12 @@ func NewLinearDraftProvider(api *tgapi.API, startValue uint64) *DraftProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetChat sets the target chat and optional message thread for all drafts created
|
|
||||||
// by this provider. Must be called before NewDraft().
|
|
||||||
//
|
|
||||||
// If not set, NewDraft() will create drafts with zero chatID, which will cause
|
|
||||||
// SendMessageDraft to fail. Use this method to avoid runtime errors.
|
|
||||||
func (p *DraftProvider) SetChat(chatID int64, messageThreadID int) *DraftProvider {
|
|
||||||
p.chatID = chatID
|
|
||||||
p.messageThreadID = messageThreadID
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetParseMode sets the default parse mode for all new drafts.
|
|
||||||
// Overrides the parse mode passed to NewDraft() only if not specified there.
|
|
||||||
func (p *DraftProvider) SetParseMode(mode tgapi.ParseMode) *DraftProvider {
|
|
||||||
p.parseMode = mode
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetEntities sets the default message entities (e.g., bold, links, mentions)
|
|
||||||
// to be copied into every new draft.
|
|
||||||
//
|
|
||||||
// Entities are shallow-copied — if you mutate the slice later, it will affect
|
|
||||||
// future drafts. For safety, pass a copy if needed.
|
|
||||||
func (p *DraftProvider) SetEntities(entities []tgapi.MessageEntity) *DraftProvider {
|
|
||||||
p.entities = entities
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDraft retrieves a draft by its ID.
|
// GetDraft retrieves a draft by its ID.
|
||||||
//
|
//
|
||||||
// Returns the draft and true if found, or nil and false if not found.
|
// Returns the draft and true if found, or nil and false if not found.
|
||||||
func (p *DraftProvider) GetDraft(id uint64) (*Draft, bool) {
|
func (p *DraftProvider) GetDraft(id uint64) (*Draft, bool) {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
draft, ok := p.drafts[id]
|
draft, ok := p.drafts[id]
|
||||||
return draft, ok
|
return draft, ok
|
||||||
}
|
}
|
||||||
@@ -150,8 +95,16 @@ func (p *DraftProvider) GetDraft(id uint64) (*Draft, bool) {
|
|||||||
//
|
//
|
||||||
// After successful flush, each draft is removed from the provider and cleared.
|
// After successful flush, each draft is removed from the provider and cleared.
|
||||||
func (p *DraftProvider) FlushAll() error {
|
func (p *DraftProvider) FlushAll() error {
|
||||||
var lastErr error
|
p.mu.Lock()
|
||||||
|
drafts := make([]*Draft, 0, len(p.drafts))
|
||||||
for _, draft := range p.drafts {
|
for _, draft := range p.drafts {
|
||||||
|
drafts = append(drafts, draft)
|
||||||
|
}
|
||||||
|
p.drafts = make(map[uint64]*Draft)
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for _, draft := range drafts {
|
||||||
if err := draft.Flush(); err != nil {
|
if err := draft.Flush(); err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
break // Stop on first error to avoid partial state
|
break // Stop on first error to avoid partial state
|
||||||
@@ -186,22 +139,17 @@ type Draft struct {
|
|||||||
//
|
//
|
||||||
// Panics if chatID is zero — call SetChat() on the provider first.
|
// Panics if chatID is zero — call SetChat() on the provider first.
|
||||||
func (p *DraftProvider) NewDraft(parseMode tgapi.ParseMode) *Draft {
|
func (p *DraftProvider) NewDraft(parseMode tgapi.ParseMode) *Draft {
|
||||||
if p.chatID == 0 {
|
|
||||||
panic("laniakea: DraftProvider.SetChat() must be called before NewDraft()")
|
|
||||||
}
|
|
||||||
|
|
||||||
id := p.generator.Next()
|
id := p.generator.Next()
|
||||||
draft := &Draft{
|
draft := &Draft{
|
||||||
api: p.api,
|
api: p.api,
|
||||||
provider: p,
|
provider: p,
|
||||||
chatID: p.chatID,
|
parseMode: parseMode,
|
||||||
messageThreadID: p.messageThreadID,
|
ID: id,
|
||||||
parseMode: parseMode,
|
Message: "",
|
||||||
entities: p.entities, // Shallow copy — caller must ensure immutability
|
|
||||||
ID: id,
|
|
||||||
Message: "",
|
|
||||||
}
|
}
|
||||||
|
p.mu.Lock()
|
||||||
p.drafts[id] = draft
|
p.drafts[id] = draft
|
||||||
|
p.mu.Unlock()
|
||||||
return draft
|
return draft
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +201,9 @@ func (d *Draft) Clear() {
|
|||||||
// want to cancel a draft without sending it.
|
// want to cancel a draft without sending it.
|
||||||
func (d *Draft) Delete() {
|
func (d *Draft) Delete() {
|
||||||
if d.provider != nil {
|
if d.provider != nil {
|
||||||
|
d.provider.mu.Lock()
|
||||||
delete(d.provider.drafts, d.ID)
|
delete(d.provider.drafts, d.ID)
|
||||||
|
d.provider.mu.Unlock()
|
||||||
}
|
}
|
||||||
d.Clear()
|
d.Clear()
|
||||||
}
|
}
|
||||||
@@ -295,6 +245,9 @@ func (d *Draft) Flush() error {
|
|||||||
|
|
||||||
// push is the internal helper for Push(). It updates the server draft via SendMessageDraft.
|
// push is the internal helper for Push(). It updates the server draft via SendMessageDraft.
|
||||||
func (d *Draft) push(text string) error {
|
func (d *Draft) push(text string) error {
|
||||||
|
if d.chatID == 0 {
|
||||||
|
return ErrDraftChatIDZero
|
||||||
|
}
|
||||||
d.Message += text
|
d.Message += text
|
||||||
params := tgapi.SendMessageDraftP{
|
params := tgapi.SendMessageDraftP{
|
||||||
ChatID: d.chatID,
|
ChatID: d.chatID,
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
@@ -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=
|
|
||||||
11
handler.go
11
handler.go
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||||
@@ -12,6 +13,12 @@ import (
|
|||||||
var ErrInvalidPayloadType = errors.New("invalid payload type")
|
var ErrInvalidPayloadType = errors.New("invalid payload type")
|
||||||
|
|
||||||
func (bot *Bot[T]) handle(u *tgapi.Update) {
|
func (bot *Bot[T]) handle(u *tgapi.Update) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
ctx := &MsgContext{
|
ctx := &MsgContext{
|
||||||
Update: *u, Api: bot.api,
|
Update: *u, Api: bot.api,
|
||||||
botLogger: bot.logger,
|
botLogger: bot.logger,
|
||||||
@@ -84,7 +91,7 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) {
|
|||||||
if !plugin.executeMiddlewares(ctx, bot.dbContext) {
|
if !plugin.executeMiddlewares(ctx, bot.dbContext) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
go plugin.executeCmd(cmd, ctx, bot.dbContext)
|
plugin.executeCmd(cmd, ctx, bot.dbContext)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,7 +120,7 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) {
|
|||||||
if !plugin.executeMiddlewares(ctx, bot.dbContext) {
|
if !plugin.executeMiddlewares(ctx, bot.dbContext) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
go plugin.executePayload(data.Command, ctx, bot.dbContext)
|
plugin.executePayload(data.Command, ctx, bot.dbContext)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
keyboard.go
10
keyboard.go
@@ -1,13 +1,3 @@
|
|||||||
// Package laniakea provides a fluent builder system for constructing Telegram
|
|
||||||
// inline keyboards with callback data and custom styling.
|
|
||||||
//
|
|
||||||
// This package supports:
|
|
||||||
// - Button builders with style (danger/success/primary), icons, URLs, and callbacks
|
|
||||||
// - Line-based keyboard layout with configurable max row size
|
|
||||||
// - Structured, JSON-serialized callback data for bot command routing
|
|
||||||
//
|
|
||||||
// Keyboard construction is stateful and builder-style: methods return the receiver
|
|
||||||
// to enable chaining. Call Get() to finalize and retrieve the tgapi.ReplyMarkup.
|
|
||||||
package laniakea
|
package laniakea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
10
l10n.go
10
l10n.go
@@ -1,13 +1,3 @@
|
|||||||
// Package laniakea provides a simple, key-based localization system for
|
|
||||||
// multi-language text translation.
|
|
||||||
//
|
|
||||||
// The system supports:
|
|
||||||
// - Multiple language entries per key (e.g., "ru", "en", "es")
|
|
||||||
// - Fallback language for missing translations
|
|
||||||
// - Key-as-fallback behavior: if a key or language is not found, returns the key itself
|
|
||||||
//
|
|
||||||
// This is designed for lightweight, static localization in bots or services
|
|
||||||
// where dynamic translation services are unnecessary.
|
|
||||||
package laniakea
|
package laniakea
|
||||||
|
|
||||||
// DictEntry represents a single localized entry with language-to-text mappings.
|
// DictEntry represents a single localized entry with language-to-text mappings.
|
||||||
|
|||||||
32
methods.go
32
methods.go
@@ -6,6 +6,38 @@ import (
|
|||||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Updates fetches new updates from Telegram API using long polling.
|
||||||
|
// It respects the bot's current update offset and automatically advances it
|
||||||
|
// after successful retrieval. The method supports selective update types
|
||||||
|
// through AllowedUpdates and includes optional request logging.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - None (uses bot's internal state for offset and allowed updates)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - []tgapi.Update: slice of received updates (empty if none available)
|
||||||
|
// - error: any error encountered during the API call
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// 1. Uses the bot's current update offset (via GetUpdateOffset)
|
||||||
|
// 2. Requests updates with 30-second timeout
|
||||||
|
// 3. Filters updates by types specified in bot.GetUpdateTypes()
|
||||||
|
// 4. Logs raw update JSON if RequestLogger is configured
|
||||||
|
// 5. Automatically updates the offset to the last received update ID + 1
|
||||||
|
// 6. Returns all received updates (empty slice if none)
|
||||||
|
//
|
||||||
|
// Note: This is a blocking call that waits up to 30 seconds for new updates.
|
||||||
|
// For non-blocking behavior, consider using webhooks instead.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// updates, err := bot.Updates()
|
||||||
|
// if err != nil {
|
||||||
|
// log.Fatal(err)
|
||||||
|
// }
|
||||||
|
// for _, update := range updates {
|
||||||
|
// // process update
|
||||||
|
// }
|
||||||
func (bot *Bot[T]) Updates() ([]tgapi.Update, error) {
|
func (bot *Bot[T]) Updates() ([]tgapi.Update, error) {
|
||||||
offset := bot.GetUpdateOffset()
|
offset := bot.GetUpdateOffset()
|
||||||
params := tgapi.UpdateParams{
|
params := tgapi.UpdateParams{
|
||||||
|
|||||||
@@ -1,27 +1,9 @@
|
|||||||
// Package laniakea provides a high-level context-based API for handling Telegram
|
|
||||||
// bot interactions, including message responses, callback queries, inline keyboards,
|
|
||||||
// localization, and message drafting. It wraps tgapi and adds convenience methods
|
|
||||||
// with built-in rate limiting, error handling, and i18n support.
|
|
||||||
//
|
|
||||||
// The core type is MsgContext, which encapsulates the state of a Telegram update
|
|
||||||
// and provides methods to respond, edit, delete, and translate messages.
|
|
||||||
//
|
|
||||||
// # Markdown Safety Warning
|
|
||||||
//
|
|
||||||
// All methods that accept MarkdownV2 formatting (e.g., AnswerMarkdown, EditCallbackfMarkdown)
|
|
||||||
// require that user-provided text be escaped using laniakea.EscapeMarkdownV2().
|
|
||||||
// Failure to escape user input may result in Telegram API errors, malformed messages,
|
|
||||||
// or security issues.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
//
|
|
||||||
// text := laniakea.EscapeMarkdownV2(userInput)
|
|
||||||
// ctx.AnswerMarkdown("You said: " + text)
|
|
||||||
package laniakea
|
package laniakea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||||
"git.nix13.pw/scuroneko/slog"
|
"git.nix13.pw/scuroneko/slog"
|
||||||
@@ -31,10 +13,12 @@ import (
|
|||||||
// It provides methods to respond, edit, delete, and translate messages, as well as
|
// It provides methods to respond, edit, delete, and translate messages, as well as
|
||||||
// manage inline keyboards and message drafts.
|
// manage inline keyboards and message drafts.
|
||||||
type MsgContext struct {
|
type MsgContext struct {
|
||||||
Api *tgapi.API
|
Api *tgapi.API
|
||||||
Msg *tgapi.Message
|
Update tgapi.Update
|
||||||
Update tgapi.Update
|
|
||||||
From *tgapi.User
|
Msg *tgapi.Message
|
||||||
|
From *tgapi.User
|
||||||
|
|
||||||
CallbackMsgId int
|
CallbackMsgId int
|
||||||
CallbackQueryId string
|
CallbackQueryId string
|
||||||
FromID int
|
FromID int
|
||||||
@@ -385,7 +369,13 @@ func (ctx *MsgContext) error(err error) {
|
|||||||
func (ctx *MsgContext) Error(err error) { ctx.error(err) }
|
func (ctx *MsgContext) Error(err error) { ctx.error(err) }
|
||||||
|
|
||||||
func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft {
|
func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft {
|
||||||
c := context.Background()
|
if ctx.Msg == nil {
|
||||||
|
ctx.botLogger.Errorln("can't create draft: ctx.Msg is nil")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
c, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil {
|
if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil {
|
||||||
ctx.botLogger.Errorln(err)
|
ctx.botLogger.Errorln(err)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
43
plugins.go
43
plugins.go
@@ -1,15 +1,3 @@
|
|||||||
// Package laniakea provides a structured system for defining and executing
|
|
||||||
// bot commands and payloads with middleware support, argument validation,
|
|
||||||
// and plugin-based organization.
|
|
||||||
//
|
|
||||||
// The core concepts are:
|
|
||||||
// - Command: A named bot command with arguments, description, and executor.
|
|
||||||
// - Plugin: A collection of commands and payloads, with shared middlewares.
|
|
||||||
// - Middleware: Interceptors that can validate, modify, or block execution.
|
|
||||||
// - CommandArg: Type-safe argument definitions with regex validation.
|
|
||||||
//
|
|
||||||
// This system is designed to be used with MsgContext from the laniakea package
|
|
||||||
// to handle Telegram bot interactions in a modular, type-safe way.
|
|
||||||
package laniakea
|
package laniakea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -33,11 +21,14 @@ const (
|
|||||||
CommandValueAnyType CommandValueType = "any"
|
CommandValueAnyType CommandValueType = "any"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CommandRegexInt matches one or more digits.
|
var (
|
||||||
var CommandRegexInt = regexp.MustCompile(`\d+`)
|
// CommandRegexInt matches one or more digits.
|
||||||
|
CommandRegexInt = regexp.MustCompile(`\d+`)
|
||||||
// CommandRegexString matches any non-empty string.
|
// CommandRegexString matches any non-empty string.
|
||||||
var CommandRegexString = regexp.MustCompile(".+")
|
CommandRegexString = regexp.MustCompile(`.+`)
|
||||||
|
// CommandRegexBool matches true or false
|
||||||
|
CommandRegexBool = regexp.MustCompile(`true|false`)
|
||||||
|
)
|
||||||
|
|
||||||
// ErrCmdArgCountMismatch is returned when the number of provided arguments
|
// ErrCmdArgCountMismatch is returned when the number of provided arguments
|
||||||
// is less than the number of required arguments.
|
// is less than the number of required arguments.
|
||||||
@@ -58,15 +49,22 @@ type CommandArg struct {
|
|||||||
// NewCommandArg creates a new CommandArg with the given text and type.
|
// NewCommandArg creates a new CommandArg with the given text and type.
|
||||||
// Uses a default regex based on the type (string or int).
|
// Uses a default regex based on the type (string or int).
|
||||||
// For CommandValueAnyType, no validation is performed.
|
// For CommandValueAnyType, no validation is performed.
|
||||||
func NewCommandArg(text string, valueType CommandValueType) *CommandArg {
|
func NewCommandArg(text string) *CommandArg {
|
||||||
|
return &CommandArg{CommandValueAnyType, text, CommandRegexString, false}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CommandArg) SetValueType(t CommandValueType) *CommandArg {
|
||||||
regex := CommandRegexString
|
regex := CommandRegexString
|
||||||
switch valueType {
|
switch t {
|
||||||
case CommandValueIntType:
|
case CommandValueIntType:
|
||||||
regex = CommandRegexInt
|
regex = CommandRegexInt
|
||||||
|
case CommandValueBoolType:
|
||||||
|
regex = CommandRegexBool
|
||||||
case CommandValueAnyType:
|
case CommandValueAnyType:
|
||||||
regex = nil // Skip validation
|
regex = nil // Skip validation
|
||||||
}
|
}
|
||||||
return &CommandArg{valueType, text, regex, false}
|
c.regex = regex
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetRequired marks this argument as required.
|
// SetRequired marks this argument as required.
|
||||||
@@ -320,7 +318,10 @@ func (m *Middleware[T]) SetAsync(async bool) *Middleware[T] {
|
|||||||
// Otherwise, returns the result of the executor.
|
// Otherwise, returns the result of the executor.
|
||||||
func (m *Middleware[T]) Execute(ctx *MsgContext, db *T) bool {
|
func (m *Middleware[T]) Execute(ctx *MsgContext, db *T) bool {
|
||||||
if m.async {
|
if m.async {
|
||||||
go m.executor(ctx, db)
|
ctx := *ctx // copy context to avoid race condition
|
||||||
|
go func(ctx MsgContext) {
|
||||||
|
m.executor(&ctx, db)
|
||||||
|
}(ctx)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return m.executor(ctx, db)
|
return m.executor(ctx, db)
|
||||||
|
|||||||
37
runners.go
37
runners.go
@@ -1,16 +1,7 @@
|
|||||||
// Package laniakea provides a system for managing background and one-time
|
|
||||||
// runner functions that operate on a Bot instance, with support for
|
|
||||||
// asynchronous execution, timeouts, and lifecycle control.
|
|
||||||
//
|
|
||||||
// Runners are used for periodic tasks (e.g., cleanup, stats updates) or
|
|
||||||
// one-time initialization logic. They are executed via Bot.ExecRunners().
|
|
||||||
//
|
|
||||||
// Important: Runners are not thread-safe for concurrent modification.
|
|
||||||
// Builder methods (Onetime, Async, Timeout) must be called sequentially
|
|
||||||
// and only before Execute().
|
|
||||||
package laniakea
|
package laniakea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -83,7 +74,7 @@ func (r *Runner[T]) Timeout(timeout time.Duration) *Runner[T] {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExecRunners executes all runners registered on the Bot.
|
// ExecRunners executes all runners registered on the Bot with context-based lifecycle management.
|
||||||
//
|
//
|
||||||
// It logs warnings for misconfigured runners:
|
// It logs warnings for misconfigured runners:
|
||||||
// - Sync, non-onetime runners are skipped (invalid configuration).
|
// - Sync, non-onetime runners are skipped (invalid configuration).
|
||||||
@@ -92,11 +83,13 @@ func (r *Runner[T]) Timeout(timeout time.Duration) *Runner[T] {
|
|||||||
// Execution logic:
|
// Execution logic:
|
||||||
// - onetime + async: Runs once in a goroutine.
|
// - onetime + async: Runs once in a goroutine.
|
||||||
// - onetime + sync: Runs once synchronously; warns if slower than 2 seconds.
|
// - onetime + sync: Runs once synchronously; warns if slower than 2 seconds.
|
||||||
// - !onetime + async: Runs in an infinite loop with timeout between iterations.
|
// - !onetime + async: Runs in a loop with timeout between iterations until ctx.Done().
|
||||||
// - !onetime + sync: Skipped with warning.
|
// - !onetime + sync: Skipped with warning.
|
||||||
//
|
//
|
||||||
// This method is typically called once during bot startup.
|
// Background runners listen for ctx.Done() and gracefully shut down when the context is canceled.
|
||||||
func (bot *Bot[T]) ExecRunners() {
|
//
|
||||||
|
// This method is typically called once during bot startup in RunWithContext.
|
||||||
|
func (bot *Bot[T]) ExecRunners(ctx context.Context) {
|
||||||
bot.logger.Infoln("Executing runners...")
|
bot.logger.Infoln("Executing runners...")
|
||||||
for _, runner := range bot.runners {
|
for _, runner := range bot.runners {
|
||||||
// Validate configuration
|
// Validate configuration
|
||||||
@@ -128,14 +121,20 @@ func (bot *Bot[T]) ExecRunners() {
|
|||||||
bot.logger.Warnf("Runner %s too slow. Elapsed time %v >= 2s\n", runner.name, elapsed)
|
bot.logger.Warnf("Runner %s too slow. Elapsed time %v >= 2s\n", runner.name, elapsed)
|
||||||
}
|
}
|
||||||
} else if !runner.onetime && runner.async {
|
} else if !runner.onetime && runner.async {
|
||||||
// Background loop: periodic execution
|
// Background loop: periodic execution with graceful shutdown
|
||||||
go func(r Runner[T]) {
|
go func(r Runner[T]) {
|
||||||
|
ticker := time.NewTicker(r.timeout)
|
||||||
|
defer ticker.Stop()
|
||||||
for {
|
for {
|
||||||
err := r.fn(bot)
|
select {
|
||||||
if err != nil {
|
case <-ctx.Done():
|
||||||
bot.logger.Warnf("Runner %s failed: %s\n", r.name, err)
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
err := r.fn(bot)
|
||||||
|
if err != nil {
|
||||||
|
bot.logger.Warnf("Runner %s failed: %s\n", r.name, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
time.Sleep(r.timeout)
|
|
||||||
}
|
}
|
||||||
}(runner)
|
}(runner)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package tgapi
|
package tgapi
|
||||||
|
|
||||||
// UpdateType represents the type of an incoming update.
|
// UpdateType represents the type of incoming update.
|
||||||
type UpdateType string
|
type UpdateType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -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.
|
// getChatLimiter returns the rate limiter for the given chat, creating it if needed.
|
||||||
// Uses 1 request per second with burst of 1 — conservative for per-user limits.
|
// Uses 1 request per second with burst of 1 — conservative for per-user limits.
|
||||||
// Must be called with rl.chatMu held.
|
|
||||||
func (rl *RateLimiter) getChatLimiter(chatID int64) *rate.Limiter {
|
func (rl *RateLimiter) getChatLimiter(chatID int64) *rate.Limiter {
|
||||||
|
rl.chatMu.Lock()
|
||||||
|
defer rl.chatMu.Unlock()
|
||||||
|
|
||||||
if lim, ok := rl.chatLimiters[chatID]; ok {
|
if lim, ok := rl.chatLimiters[chatID]; ok {
|
||||||
return lim
|
return lim
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package utils
|
package utils
|
||||||
|
|
||||||
const (
|
const (
|
||||||
VersionString = "1.0.0-beta.16"
|
VersionString = "1.0.0-beta.20"
|
||||||
VersionMajor = 1
|
VersionMajor = 1
|
||||||
VersionMinor = 0
|
VersionMinor = 0
|
||||||
VersionPatch = 0
|
VersionPatch = 0
|
||||||
VersionBeta = 16
|
VersionBeta = 20
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user