REPOSITORY / ScuroNeko/Laniakea
Pull Requests
v1.0.0 #9
@@ -76,6 +76,30 @@ Before finalizing changes, run the relevant project checks when available:
|
||||
|
||||
Prefer the repository’s documented commands. If multiple choices exist, use the most standard and least destructive ones first.
|
||||
|
||||
## Versioning and changelog
|
||||
- After every code or documentation change, update `CHANGELOG.md`.
|
||||
- Add changes only to the section for the next version after the latest published git tag.
|
||||
- The agent must check the latest published tag, `CHANGELOG.md`, and `utils/version.go` before editing the changelog.
|
||||
- The agent must verify that the target changelog version matches the version declared in `utils/version.go`.
|
||||
- If the latest published tag is, for example, `v1.0.0`, and `CHANGELOG.md` does not yet contain the next version section, the agent must stop and ask the user which version the change belongs to:
|
||||
1. `v1.0.1`
|
||||
2. `v1.1.0`
|
||||
3. `v2.0.0`
|
||||
- The agent must not guess the next version when that section is missing.
|
||||
- If the user-selected version does not match `utils/version.go`, the agent must warn about the mismatch and require the version file to be updated before proceeding.
|
||||
- Changelog entries must describe all user-visible behavior changes made in the turn, including API additions, fixes, behavior changes, and breaking changes.
|
||||
|
||||
## Breaking changes policy
|
||||
- The agent must detect potential breaking changes before editing public APIs.
|
||||
- Breaking changes are forbidden unless the selected target version is a new major version.
|
||||
- If the requested change is breaking and the user did not bump the major version, the agent must stop and warn that the change is not allowed under the current version.
|
||||
- In that case, the agent must offer only these options:
|
||||
1. do not make the breaking change;
|
||||
2. introduce a backward-compatible alternative such as a new method, function, type, or struct, but only if that keeps the codebase reasonably small and clear;
|
||||
3. bump the major version and then apply the breaking change.
|
||||
- Prefer additive compatibility over signature changes when the additive option is small and maintainable.
|
||||
- Example: if a method like `ctx.answer(...)` needs an extra parameter, the agent must either require a major-version bump or add a new method that keeps the old method working.
|
||||
|
||||
## Output format
|
||||
For repo-wide review tasks, structure the result as:
|
||||
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## v1.0.0-rc.12
|
||||
|
||||
### Added
|
||||
- `AnswerLong(...)`, `AnswerLongf(...)`, `KeyboardLong(...)`, and `SplitMessageText(...)` for explicit plain-text splitting of long replies without changing the semantics of existing single-message helpers.
|
||||
- Centralized library-level validation errors in `errors.go`, including `ErrEmptyMessage`, `ErrMessageTooLong`, `ErrCaptionTooLong`, and context/target validation sentinels.
|
||||
- `Bot.GetPayloadType()`, `InlineKeyboard.GetPayloadType()`, and optional strict payload decoding via `BotOpts.StrictPayloadType` / `Bot.SetStrictPayloadType(...)`.
|
||||
|
||||
### Changed
|
||||
- `CommandExecutor` now returns `error`, and command, payload, and non-command update handlers now use centralized bot error handling for returned errors.
|
||||
- README and README_RU examples now use the new handler signature and document the long-message helpers.
|
||||
- `AGENTS.md` now requires every change to be recorded in `CHANGELOG.md`, enforces version alignment with `utils/version.go`, and blocks breaking changes without a major-version bump.
|
||||
- Payload-type comments and docs now distinguish between the bot's default payload type and keyboard-local overrides.
|
||||
- Version constants were bumped to `v1.0.0-rc.12`.
|
||||
|
||||
### Fixed
|
||||
- Message and caption validation now runs before Telegram API calls, rejecting empty messages, oversized message text, and oversized captions with stable sentinel errors.
|
||||
- Draft flushing and draft updates now reject oversized messages before sending invalid requests.
|
||||
- Callback payload decoding now optionally enforces strict type matching, while the default tolerant mode logs Base64-to-JSON decoding in debug mode and still accepts keyboard-local payload overrides.
|
||||
|
||||
### Breaking Changes
|
||||
- `CommandExecutor[T]` changed from `func(ctx *MsgContext, db T)` to `func(ctx *MsgContext, db T) error`.
|
||||
- `Plugin.NewCommand(...)`, `Plugin.NewPayload(...)`, and `Plugin.AddUpdateHandler(...)` now require handlers with the new error-returning signature.
|
||||
|
||||
## v1.0.0-rc.11
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -52,10 +52,11 @@ import (
|
||||
// It receives two parameters:
|
||||
// - ctx: the message context (contains info about the message, sender, chat, etc.)
|
||||
// - db: your custom database context (here we use NoDB, a placeholder for no database)
|
||||
func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) {
|
||||
func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
|
||||
// Answer the user with the text they sent, without any command prefix.
|
||||
// ctx.Text contains the user's message with the command part stripped off.
|
||||
ctx.Answer(ctx.Text) // User input WITHOUT command
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -81,8 +82,9 @@ func main() {
|
||||
|
||||
// 5. Add another command using an anonymous function (closure).
|
||||
// This command simply replies "Pong" when the user sends "/ping".
|
||||
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) {
|
||||
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
|
||||
ctx.Answer("Pong")
|
||||
return nil
|
||||
}, "ping"))
|
||||
|
||||
// 6. Configure the bot with a custom error template and add the plugin.
|
||||
@@ -107,8 +109,8 @@ func main() {
|
||||
1. `BotOpts`: Holds configuration like the API token.
|
||||
2. `NewBot[T]`: Creates a bot instance. The type parameter T allows you to pass a custom database context (e.g., *sql.DB) that will be available in all handlers. Use laniakea.NoDB if you don't need it.
|
||||
3. `NewPlugin`: Creates a logical group for commands and middlewares.
|
||||
4. `AddCommand`: Registers a command. The first argument is the handler function (func(*MsgContext, T)), the second is the command name (without the slash).
|
||||
5. **Handler Functions**: Receive *MsgContext (message details, methods like Answer) and your custom database context T.
|
||||
4. `AddCommand`: Registers a command. The first argument is the handler function (`func(*MsgContext, T) error`), the second is the command name (without the slash).
|
||||
5. **Handler Functions**: Receive *MsgContext (message details, methods like Answer) and your custom database context T, and return an error for centralized error handling.
|
||||
6. `ErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error.
|
||||
7. `AutoGenerateCommands`: Registers plugin-defined commands with Telegram across the supported scopes.
|
||||
8. `Run()`: Starts the bot's update polling loop and returns an error if startup or polling fails.
|
||||
@@ -128,9 +130,10 @@ bot.AddPlugins(plugin)
|
||||
|
||||
A command is a function that handles a specific bot command (e.g., /start).
|
||||
```go
|
||||
func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
|
||||
func myHandler(ctx *laniakea.MsgContext, db *MyDB) error {
|
||||
// Access command arguments via ctx.Args ([]string)
|
||||
// Reply to the user: ctx.Answer("some text")
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
@@ -139,8 +142,10 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
|
||||
Provides access to the incoming message and useful reply methods:
|
||||
|
||||
- `Answer(text string) *AnswerMessage`: Sends a message with parse_mode none.
|
||||
- `AnswerLong(text string) []*AnswerMessage`: Splits long plain text into multiple messages.
|
||||
- `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.
|
||||
- `KeyboardLong(text string, keyboard *InlineKeyboard) []*AnswerMessage`: Splits long plain text into multiple messages and attaches the keyboard to the final chunk.
|
||||
- `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 photo with MarkdownV2 caption (you handle escaping).
|
||||
@@ -223,7 +228,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
|
||||
- Middleware can modify the MsgContext (e.g., add custom fields) before the command runs.
|
||||
|
||||
## ⚙️ Advanced Configuration
|
||||
- **Inline Keyboards**: Build keyboards using `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64`, or `laniakea.NewInlineKeyboard`.
|
||||
- **Inline Keyboards**: Build keyboards using `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64`, or `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` defines the default payload format, and `InlineKeyboard.SetPayloadType(...)` overrides it for one keyboard.
|
||||
- **Rate Limiting**: Pass a configured utils.RateLimiter via BotOpts to handle Telegram's rate limits gracefully.
|
||||
- **Localization**: `L10n` is safe for concurrent use once attached to the bot.
|
||||
- **Custom Update Handlers**: Use `plugin.AddUpdateHandler(...)` for Telegram update types that are not part of the command/payload flow.
|
||||
|
||||
+11
-6
@@ -53,10 +53,11 @@ import (
|
||||
// Она получает два параметра:
|
||||
// - ctx: контекст сообщения (содержит информацию о сообщении, отправителе, чате и т.д.)
|
||||
// - db: ваш пользовательский контекст базы данных (здесь мы используем NoDB — заглушку)
|
||||
func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) {
|
||||
func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
|
||||
// Отвечаем пользователю текстом, который он прислал, без префикса команды.
|
||||
// ctx.Text содержит сообщение пользователя, из которого удалена часть с командой.
|
||||
ctx.Answer(ctx.Text) // Ввод пользователя БЕЗ команды
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -82,8 +83,9 @@ func main() {
|
||||
|
||||
// 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание).
|
||||
// Эта команда просто отвечает "Pong", когда пользователь отправляет "/ping".
|
||||
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) {
|
||||
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
|
||||
ctx.Answer("Pong")
|
||||
return nil
|
||||
}, "ping"))
|
||||
|
||||
// 6. Настраиваем бота: задаём шаблон ошибки и добавляем плагин.
|
||||
@@ -108,8 +110,8 @@ func main() {
|
||||
1. `BotOpts`: Содержит конфигурацию, например, токен API.
|
||||
2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать пользовательский контекст базы данных (например, *sql.DB), который будет доступен во всех обработчиках. Используйте laniakea.NoDB, если он не нужен.
|
||||
3. `NewPlugin`: Создаёт логическую группу для команд и Middleware.
|
||||
4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (func(*MsgContext, T)), второй — имя команды (без слеша).
|
||||
5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваш контекст базы данных T.
|
||||
4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (`func(*MsgContext, T) error`), второй — имя команды (без слеша).
|
||||
5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваш контекст базы данных T, а ошибку возвращают для централизованной обработки.
|
||||
6. `ErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки.
|
||||
7. `AutoGenerateCommands`: Регистрирует команды из плагинов в Telegram для поддерживаемых scope.
|
||||
8. `Run()`: Запускает цикл опроса обновлений бота и возвращает ошибку, если старт или polling завершился неуспешно.
|
||||
@@ -129,9 +131,10 @@ bot.AddPlugins(plugin)
|
||||
Команда — это функция, которая обрабатывает конкретную команду бота (например, /start).
|
||||
|
||||
```go
|
||||
func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
|
||||
func myHandler(ctx *laniakea.MsgContext, db *MyDB) error {
|
||||
// Доступ к аргументам команды через ctx.Args ([]string)
|
||||
// Ответ пользователю: ctx.Answer("какой-то текст")
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
@@ -139,8 +142,10 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
|
||||
Предоставляет доступ к входящему сообщению и полезные методы для ответа:
|
||||
|
||||
- `Answer(text string)`: Отправляет сообщение с parse_mode none.
|
||||
- `AnswerLong(text string) []*AnswerMessage`: Разбивает длинный plain text на несколько сообщений.
|
||||
- `AnswerMarkdown(text string)`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне).
|
||||
- `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение с parse_mode none и Inline клавиатурой.
|
||||
- `KeyboardLong(text string, keyboard *InlineKeyboard) []*AnswerMessage`: Разбивает длинный plain text на несколько сообщений и вешает клавиатуру на последний chunk.
|
||||
- `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне), и Inline клавиатурой.
|
||||
- `AnswerPhoto(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью и parse_mode none.
|
||||
- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью, отформатированной MarkdownV2 (экранирование на вашей стороне).
|
||||
@@ -220,7 +225,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
|
||||
- Middleware может изменять MsgContext (например, добавлять пользовательские поля) перед запуском команды.
|
||||
|
||||
## ⚙️ Расширенная настройка
|
||||
- **Инлайн-клавиатуры**: Создавайте клавиатуры с помощью `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64` или `laniakea.NewInlineKeyboard`.
|
||||
- **Инлайн-клавиатуры**: Создавайте клавиатуры с помощью `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64` или `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` задаёт payload format по умолчанию, а `InlineKeyboard.SetPayloadType(...)` переопределяет его для конкретной клавиатуры.
|
||||
- **Ограничение запросов**: Передайте настроенный `utils.RateLimiter` через `BotOpts` для корректной обработки лимитов Telegram.
|
||||
- **Локализация**: `L10n` безопасен для конкурентного использования после подключения к боту.
|
||||
- **Пользовательские update handlers**: Используйте `plugin.AddUpdateHandler(...)` для Telegram update types вне command/payload flow.
|
||||
|
||||
@@ -77,12 +77,13 @@ var (
|
||||
// Runtime accessors are safe for concurrent use. Configure the bot before Run.
|
||||
// A Bot is single-use: after Run or RunWithContext returns, create a new Bot for the next session.
|
||||
type Bot[T DbContext] struct {
|
||||
token string
|
||||
debug bool
|
||||
errorTemplate string
|
||||
username string
|
||||
payloadType BotPayloadType
|
||||
maxWorkers int
|
||||
token string
|
||||
debug bool
|
||||
errorTemplate string
|
||||
username string
|
||||
payloadType BotPayloadType
|
||||
strictPayloadType bool
|
||||
maxWorkers int
|
||||
|
||||
logger *slog.Logger // Main bot logger (JSON stdout + optional file)
|
||||
RequestLogger *slog.Logger // Optional request-level API logging
|
||||
@@ -156,22 +157,23 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
|
||||
}
|
||||
|
||||
bot := &Bot[T]{
|
||||
updateOffset: 0,
|
||||
errorTemplate: "%s",
|
||||
payloadType: BotPayloadBase64,
|
||||
maxWorkers: workers,
|
||||
updateQueue: updateQueue,
|
||||
api: api,
|
||||
uploader: uploader,
|
||||
debug: opts.Debug,
|
||||
prefixes: prefixes,
|
||||
token: opts.Token,
|
||||
plugins: make([]Plugin[T], 0),
|
||||
updateTypes: append([]tgapi.UpdateType{}, opts.UpdateTypes...),
|
||||
runners: make([]Runner[T], 0),
|
||||
extraLoggers: make([]*slog.Logger, 0),
|
||||
l10n: &L10n{},
|
||||
draftProvider: NewRandomDraftProvider(api),
|
||||
updateOffset: 0,
|
||||
errorTemplate: "%s",
|
||||
payloadType: BotPayloadBase64,
|
||||
strictPayloadType: opts.StrictPayloadType,
|
||||
maxWorkers: workers,
|
||||
updateQueue: updateQueue,
|
||||
api: api,
|
||||
uploader: uploader,
|
||||
debug: opts.Debug,
|
||||
prefixes: prefixes,
|
||||
token: opts.Token,
|
||||
plugins: make([]Plugin[T], 0),
|
||||
updateTypes: append([]tgapi.UpdateType{}, opts.UpdateTypes...),
|
||||
runners: make([]Runner[T], 0),
|
||||
extraLoggers: make([]*slog.Logger, 0),
|
||||
l10n: &L10n{},
|
||||
draftProvider: NewRandomDraftProvider(api),
|
||||
}
|
||||
|
||||
// Add API and Uploader loggers to extraLoggers for unified output
|
||||
@@ -356,14 +358,25 @@ func (bot *Bot[T]) UpdateTypes(t ...tgapi.UpdateType) *Bot[T] {
|
||||
return bot
|
||||
}
|
||||
|
||||
// SetPayloadType sets the payload encoding type used for callback data.
|
||||
// SetPayloadType sets the default payload encoding type used for callback data.
|
||||
// JSON stores payload as a string: `{"cmd":"command","args":[...]}`.
|
||||
// Base64 stores the same JSON encoded as a Base64URL string.
|
||||
// InlineKeyboard.SetPayloadType may override this value for an individual keyboard.
|
||||
func (bot *Bot[T]) SetPayloadType(t BotPayloadType) *Bot[T] {
|
||||
bot.payloadType = t
|
||||
return bot
|
||||
}
|
||||
|
||||
// GetPayloadType returns the bot's default callback payload encoding type.
|
||||
func (bot *Bot[T]) GetPayloadType() BotPayloadType { return bot.payloadType }
|
||||
|
||||
// SetStrictPayloadType enables or disables strict callback payload decoding.
|
||||
// When enabled, callback payloads must match the bot's default payload type.
|
||||
func (bot *Bot[T]) SetStrictPayloadType(strict bool) *Bot[T] {
|
||||
bot.strictPayloadType = strict
|
||||
return bot
|
||||
}
|
||||
|
||||
// AddUpdateType adds one or more update types to the list.
|
||||
// Does not overwrite existing types.
|
||||
func (bot *Bot[T]) AddUpdateType(t ...tgapi.UpdateType) *Bot[T] {
|
||||
|
||||
+15
-2
@@ -56,6 +56,10 @@ type BotOpts struct {
|
||||
// Use this to prioritize responsiveness over reliability.
|
||||
DropRLOverflow bool
|
||||
|
||||
// StrictPayloadType disables callback payload fallback decoding.
|
||||
// When enabled, the bot accepts only the configured default payload type.
|
||||
StrictPayloadType bool
|
||||
|
||||
// MaxWorkers is the maximum number of update handlers that may run concurrently.
|
||||
MaxWorkers int
|
||||
}
|
||||
@@ -75,6 +79,7 @@ type BotOpts struct {
|
||||
// - API_URL: custom API endpoint
|
||||
// - RATE_LIMIT: max requests per second (default: 30)
|
||||
// - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow
|
||||
// - STRICT_PAYLOAD_TYPE: "true" to reject callback payloads encoded in a different format
|
||||
// - MAX_WORKERS: maximum number of concurrent update handlers (default: 32)
|
||||
//
|
||||
// Returns a populated BotOpts.
|
||||
@@ -116,8 +121,9 @@ func LoadOptsFromEnv() *BotOpts {
|
||||
UseTestServer: os.Getenv("USE_TEST_SERVER") == "true",
|
||||
APIUrl: os.Getenv("API_URL"),
|
||||
|
||||
RateLimit: rateLimit,
|
||||
DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
|
||||
RateLimit: rateLimit,
|
||||
DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
|
||||
StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true",
|
||||
|
||||
MaxWorkers: maxWorkers,
|
||||
}
|
||||
@@ -208,6 +214,13 @@ func (opts *BotOpts) SetDropRLOverflow(drop bool) *BotOpts {
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetStrictPayloadType enables or disables strict callback payload decoding.
|
||||
// When enabled, the bot accepts only the configured default payload type.
|
||||
func (opts *BotOpts) SetStrictPayloadType(strict bool) *BotOpts {
|
||||
opts.StrictPayloadType = strict
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetMaxWorkers sets the maximum number of concurrent update handlers.
|
||||
// Must be called before NewBot, as the value is captured during bot creation.
|
||||
//
|
||||
|
||||
@@ -45,3 +45,12 @@ func TestLoadPrefixesFromEnvDropsEmptyValues(t *testing.T) {
|
||||
t.Fatalf("unexpected prefixes: got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOptsFromEnvReadsStrictPayloadType(t *testing.T) {
|
||||
t.Setenv("STRICT_PAYLOAD_TYPE", "true")
|
||||
|
||||
opts := LoadOptsFromEnv()
|
||||
if !opts.StrictPayloadType {
|
||||
t.Fatal("expected StrictPayloadType to be enabled")
|
||||
}
|
||||
}
|
||||
|
||||
+18
-2
@@ -27,13 +27,13 @@ func TestAddPluginsSnapshotsConfiguration(t *testing.T) {
|
||||
bot := &Bot[NoDB]{logger: slog.CreateLogger()}
|
||||
plugin := NewPlugin[NoDB]("demo")
|
||||
|
||||
cmd := plugin.NewCommand(func(ctx *MsgContext, db NoDB) {}, "start")
|
||||
cmd := plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { return nil }, "start")
|
||||
plugin.AddMiddleware(NewMiddleware("base", func(ctx *MsgContext, db NoDB) bool { return true }))
|
||||
|
||||
bot.AddPlugins(plugin)
|
||||
|
||||
cmd.SetDescription("mutated after registration")
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoDB) {}, "late")
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { return nil }, "late")
|
||||
plugin.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoDB) bool { return true }))
|
||||
|
||||
registered := bot.plugins[0]
|
||||
@@ -48,6 +48,22 @@ func TestAddPluginsSnapshotsConfiguration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotPayloadTypeConfiguration(t *testing.T) {
|
||||
bot := &Bot[NoDB]{payloadType: BotPayloadBase64}
|
||||
|
||||
if got := bot.GetPayloadType(); got != BotPayloadBase64 {
|
||||
t.Fatalf("unexpected initial payload type: %q", got)
|
||||
}
|
||||
bot.SetPayloadType(BotPayloadJson)
|
||||
if got := bot.GetPayloadType(); got != BotPayloadJson {
|
||||
t.Fatalf("unexpected updated payload type: %q", got)
|
||||
}
|
||||
bot.SetStrictPayloadType(true)
|
||||
if !bot.strictPayloadType {
|
||||
t.Fatal("expected strict payload type to be enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddPluginsSkipsNilPlugin(t *testing.T) {
|
||||
bot := &Bot[NoDB]{logger: slog.CreateLogger()}
|
||||
plugin := NewPlugin[NoDB]("demo")
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) {
|
||||
}()
|
||||
|
||||
plugin := NewPlugin[NoDB]("overflow")
|
||||
exec := func(ctx *MsgContext, db NoDB) {}
|
||||
exec := func(ctx *MsgContext, db NoDB) error { return nil }
|
||||
for i := 0; i < 101; i++ {
|
||||
plugin.AddCommand(NewCommand(exec, "cmd"+strconv.Itoa(i)))
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) {
|
||||
|
||||
func TestGatherCommandsForPluginReturnsSortedCommands(t *testing.T) {
|
||||
plugin := NewPlugin[NoDB]("sorted")
|
||||
exec := func(ctx *MsgContext, db NoDB) {}
|
||||
exec := func(ctx *MsgContext, db NoDB) error { return nil }
|
||||
|
||||
plugin.AddCommand(NewCommand(exec, "zeta"))
|
||||
plugin.AddCommand(NewCommand(exec, "alpha"))
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package laniakea
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/rand/v2"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -9,9 +8,6 @@ import (
|
||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||
)
|
||||
|
||||
// ErrDraftChatIDZero is returned when a draft is used without setting a chat ID.
|
||||
var ErrDraftChatIDZero = errors.New("zero draft chat ID")
|
||||
|
||||
// Interface for generating unique draft IDs.
|
||||
type draftIdGenerator interface {
|
||||
// Next returns the next unique draft ID.
|
||||
@@ -221,6 +217,9 @@ func (d *Draft) Flush() error {
|
||||
if d.chatID == 0 {
|
||||
return ErrDraftChatIDZero
|
||||
}
|
||||
if err := validateMessageText(d.Message); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := tgapi.SendMessageP{
|
||||
ChatID: d.chatID,
|
||||
@@ -245,6 +244,9 @@ func (d *Draft) push(text string) error {
|
||||
return ErrDraftChatIDZero
|
||||
}
|
||||
d.Message += text
|
||||
if err := validateMessageText(d.Message); err != nil {
|
||||
return err
|
||||
}
|
||||
params := tgapi.SendMessageDraftP{
|
||||
ChatID: d.chatID,
|
||||
DraftID: d.ID,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package laniakea
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.nix13.pw/scuroneko/laniakea/tgapi"
|
||||
@@ -34,3 +36,20 @@ func TestMsgContextNewDraftWorksWithoutLimiter(t *testing.T) {
|
||||
t.Fatalf("unexpected chat id: %d", draft.chatID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftFlushRejectsLongMessage(t *testing.T) {
|
||||
draft := NewRandomDraftProvider(&tgapi.API{}).NewDraft(tgapi.ParseNone).SetChat(42, 0)
|
||||
draft.Message = strings.Repeat("a", maxMessageTextLen+1)
|
||||
|
||||
if err := draft.Flush(); !errors.Is(err, ErrMessageTooLong) {
|
||||
t.Fatalf("expected ErrMessageTooLong, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftPushRejectsLongMessage(t *testing.T) {
|
||||
draft := NewRandomDraftProvider(&tgapi.API{}).NewDraft(tgapi.ParseNone).SetChat(42, 0)
|
||||
|
||||
if err := draft.Push(strings.Repeat("a", maxMessageTextLen+1)); !errors.Is(err, ErrMessageTooLong) {
|
||||
t.Fatalf("expected ErrMessageTooLong, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package laniakea
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
maxMessageTextLen = 4096
|
||||
maxMessageCaptionLen = 1024
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrEmptyMessage reports that a required message text is empty.
|
||||
ErrEmptyMessage = errors.New("empty message")
|
||||
// ErrMessageTooLong reports that a message exceeds Telegram's text limit.
|
||||
ErrMessageTooLong = errors.New("message too long")
|
||||
// ErrCaptionTooLong reports that a caption exceeds Telegram's caption limit.
|
||||
ErrCaptionTooLong = errors.New("caption too long")
|
||||
// ErrMessageSplitImpossible reports that automatic message splitting cannot preserve semantics.
|
||||
ErrMessageSplitImpossible = errors.New("message split is impossible")
|
||||
// ErrPayloadTypeMismatch reports that callback payload encoding does not match bot policy.
|
||||
ErrPayloadTypeMismatch = errors.New("payload type mismatch")
|
||||
// ErrDraftChatIDZero reports that a draft has no target chat ID.
|
||||
ErrDraftChatIDZero = errors.New("zero draft chat ID")
|
||||
// ErrMessageContextNil reports that an operation requires ctx.Msg but none is set.
|
||||
ErrMessageContextNil = errors.New("message context is nil")
|
||||
// ErrEditTargetMissing reports that an edit operation has no message target.
|
||||
ErrEditTargetMissing = errors.New("edit target is missing")
|
||||
// ErrCallbackMessageMissing reports that a callback operation has no callback message target.
|
||||
ErrCallbackMessageMissing = errors.New("callback message is missing")
|
||||
// ErrDraftProviderNil reports that draft creation was requested without a draft provider.
|
||||
ErrDraftProviderNil = errors.New("draft provider is nil")
|
||||
// ErrAPIIsNil reports that an operation requires an API client but none is set.
|
||||
ErrAPIIsNil = errors.New("api is nil")
|
||||
// ErrMessageIDZero reports that an operation requires a non-zero message ID.
|
||||
ErrMessageIDZero = errors.New("message ID is zero")
|
||||
)
|
||||
|
||||
func validateMessageText(text string) error {
|
||||
length := utf8.RuneCountInString(text)
|
||||
switch {
|
||||
case length == 0:
|
||||
return ErrEmptyMessage
|
||||
case length > maxMessageTextLen:
|
||||
return fmt.Errorf("%w: got %d, limit %d", ErrMessageTooLong, length, maxMessageTextLen)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func validateCaptionText(text string) error {
|
||||
length := utf8.RuneCountInString(text)
|
||||
if length > maxMessageCaptionLen {
|
||||
return fmt.Errorf("%w: got %d, limit %d", ErrCaptionTooLong, length, maxMessageCaptionLen)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+37
-6
@@ -156,7 +156,9 @@ func (bot *Bot[T]) handleUpdate(u *tgapi.Update, ctx *MsgContext) {
|
||||
if !plugin.executeMiddlewares(pluginCtx, bot.dbContext) {
|
||||
continue
|
||||
}
|
||||
handler(pluginCtx, bot.dbContext)
|
||||
if err := handler(pluginCtx, bot.dbContext); err != nil {
|
||||
pluginCtx.error(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,19 +322,48 @@ func decodeBase64Payload(s string) (CallbackData, error) {
|
||||
}
|
||||
return decodeJsonPayload(string(b))
|
||||
}
|
||||
func decodePayload(payloadType BotPayloadType, s string) (CallbackData, error) {
|
||||
func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackData, BotPayloadType, error) {
|
||||
switch payloadType {
|
||||
case BotPayloadBase64:
|
||||
return decodeBase64Payload(s)
|
||||
data, err := decodeBase64Payload(s)
|
||||
if err == nil {
|
||||
return data, BotPayloadBase64, nil
|
||||
}
|
||||
if strict {
|
||||
return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadBase64)
|
||||
}
|
||||
data, err = decodeJsonPayload(s)
|
||||
if err != nil {
|
||||
return CallbackData{}, "", err
|
||||
}
|
||||
return data, BotPayloadJson, nil
|
||||
case BotPayloadJson:
|
||||
return decodeJsonPayload(s)
|
||||
data, err := decodeJsonPayload(s)
|
||||
if err == nil {
|
||||
return data, BotPayloadJson, nil
|
||||
}
|
||||
if strict {
|
||||
return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadJson)
|
||||
}
|
||||
data, err = decodeBase64Payload(s)
|
||||
if err != nil {
|
||||
return CallbackData{}, "", err
|
||||
}
|
||||
return data, BotPayloadBase64, nil
|
||||
}
|
||||
return CallbackData{}, ErrInvalidPayloadType
|
||||
return CallbackData{}, "", ErrInvalidPayloadType
|
||||
}
|
||||
|
||||
// func (bot *Bot[T]) encodePayload(d CallbackData) (string, error) {
|
||||
// return encodePayload(bot.payloadType, d)
|
||||
// }
|
||||
func (bot *Bot[T]) decodePayload(s string) (CallbackData, error) {
|
||||
return decodePayload(bot.payloadType, s)
|
||||
data, decodedType, err := decodePayload(bot.payloadType, s, bot.strictPayloadType)
|
||||
if err != nil {
|
||||
return CallbackData{}, err
|
||||
}
|
||||
if decodedType == BotPayloadBase64 && bot.debug && bot.logger != nil {
|
||||
bot.logger.Debugf("decoded callback payload base64->json: raw=%q json=%s", s, data.ToJson())
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
+9
-5
@@ -51,7 +51,7 @@ func TestBotMiddlewareReceivesLogger(t *testing.T) {
|
||||
|
||||
func TestAddUpdateHandlerRejectsReservedUpdateTypes(t *testing.T) {
|
||||
plugin := NewPlugin[NoDB]("test")
|
||||
handler := func(ctx *MsgContext, db NoDB) {}
|
||||
handler := func(ctx *MsgContext, db NoDB) error { return nil }
|
||||
|
||||
for _, updateType := range []tgapi.UpdateType{
|
||||
tgapi.UpdateTypeMessage,
|
||||
@@ -110,7 +110,7 @@ func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
called := false
|
||||
plugin := NewPlugin[NoDB]("test").AddUpdateHandler(tt.update.Type, func(ctx *MsgContext, db NoDB) {
|
||||
plugin := NewPlugin[NoDB]("test").AddUpdateHandler(tt.update.Type, func(ctx *MsgContext, db NoDB) error {
|
||||
called = true
|
||||
if ctx.Update.UpdateID != tt.update.UpdateID {
|
||||
t.Fatalf("unexpected update in context: got %d want %d", ctx.Update.UpdateID, tt.update.UpdateID)
|
||||
@@ -127,6 +127,7 @@ func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) {
|
||||
if ctx.Msg != nil {
|
||||
t.Fatalf("did not expect message context for %s", tt.name)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
@@ -147,7 +148,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
|
||||
firstCalled := false
|
||||
secondCalled := false
|
||||
|
||||
first := NewPlugin[NoDB]("first").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) {
|
||||
first := NewPlugin[NoDB]("first").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) error {
|
||||
firstCalled = true
|
||||
if ctx.FromID != 41 {
|
||||
t.Fatalf("unexpected FromID in first handler: got %d want 41", ctx.FromID)
|
||||
@@ -156,8 +157,9 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
|
||||
ctx.FromID = 999
|
||||
ctx.Text = "mutated"
|
||||
ctx.Args = []string{"mutated"}
|
||||
return nil
|
||||
})
|
||||
second := NewPlugin[NoDB]("second").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) {
|
||||
second := NewPlugin[NoDB]("second").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) error {
|
||||
secondCalled = true
|
||||
if ctx.From == nil {
|
||||
t.Fatal("expected ctx.From to remain populated for second handler")
|
||||
@@ -171,6 +173,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
|
||||
if len(ctx.Args) != 0 {
|
||||
t.Fatalf("unexpected leaked Args in second handler: %v", ctx.Args)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
@@ -199,7 +202,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
|
||||
func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
|
||||
called := false
|
||||
plugin := NewPlugin[NoDB]("test")
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoDB) {
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoDB) error {
|
||||
called = true
|
||||
if ctx.Msg == nil {
|
||||
t.Fatal("expected message context")
|
||||
@@ -213,6 +216,7 @@ func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
|
||||
if ctx.FromID != 0 {
|
||||
t.Fatalf("expected zero FromID for sender_chat updates, got %d", ctx.FromID)
|
||||
}
|
||||
return nil
|
||||
}, "ping")
|
||||
|
||||
bot := &Bot[NoDB]{
|
||||
|
||||
+10
-2
@@ -136,14 +136,22 @@ func NewInlineKeyboard(payloadType BotPayloadType, maxRow int) *InlineKeyboard {
|
||||
}
|
||||
}
|
||||
|
||||
// SetPayloadType sets the serialization format for callback data added via
|
||||
// SetPayloadType sets the keyboard-local serialization format for callback data added via
|
||||
// AddCallbackButton and AddCallbackButtonStyle methods.
|
||||
// It should be one of BotPayloadJson or BotPayloadBase64.
|
||||
// It overrides the bot's default payload type for this keyboard only.
|
||||
func (in *InlineKeyboard) SetPayloadType(t BotPayloadType) *InlineKeyboard {
|
||||
in.payloadType = t
|
||||
return in
|
||||
}
|
||||
|
||||
// GetPayloadType returns the keyboard-local callback payload encoding type.
|
||||
func (in *InlineKeyboard) GetPayloadType() BotPayloadType { return in.payloadType }
|
||||
|
||||
func (in *InlineKeyboard) SetMaxRow(maxRow int) *InlineKeyboard {
|
||||
in.maxRow = maxRow
|
||||
return in
|
||||
}
|
||||
|
||||
// Internal helper that appends a button and auto-flushes a full row.
|
||||
func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeyboard {
|
||||
if in.CurrentLine.Len() == in.maxRow {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package laniakea
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -42,3 +44,54 @@ func TestInlineKeyboardBuilderPreservesConfiguredButtonFields(t *testing.T) {
|
||||
t.Fatalf("unexpected url: %q", button.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInlineKeyboardGetPayloadTypeReturnsLocalOverride(t *testing.T) {
|
||||
kb := NewInlineKeyboardJson(2)
|
||||
if got := kb.GetPayloadType(); got != BotPayloadJson {
|
||||
t.Fatalf("unexpected initial payload type: %q", got)
|
||||
}
|
||||
kb.SetPayloadType(BotPayloadBase64)
|
||||
if got := kb.GetPayloadType(); got != BotPayloadBase64 {
|
||||
t.Fatalf("unexpected updated payload type: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePayloadAcceptsBase64KeyboardPayloadWhenBotPrefersJSON(t *testing.T) {
|
||||
kb := NewInlineKeyboardBase64(1).
|
||||
AddCallbackButton("A", "cmd", 1, "two")
|
||||
|
||||
got, _, err := decodePayload(BotPayloadJson, kb.Get().InlineKeyboard[0][0].CallbackData, false)
|
||||
if err != nil {
|
||||
t.Fatalf("decodePayload returned error: %v", err)
|
||||
}
|
||||
|
||||
want := CallbackData{Command: "cmd", Args: []string{"1", "two"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("unexpected payload: got %#v want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePayloadAcceptsJSONKeyboardPayloadWhenBotPrefersBase64(t *testing.T) {
|
||||
kb := NewInlineKeyboardJson(1).
|
||||
AddCallbackButton("A", "cmd", 1, "two")
|
||||
|
||||
got, _, err := decodePayload(BotPayloadBase64, kb.Get().InlineKeyboard[0][0].CallbackData, false)
|
||||
if err != nil {
|
||||
t.Fatalf("decodePayload returned error: %v", err)
|
||||
}
|
||||
|
||||
want := CallbackData{Command: "cmd", Args: []string{"1", "two"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("unexpected payload: got %#v want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePayloadStrictRejectsMismatchedType(t *testing.T) {
|
||||
kb := NewInlineKeyboardBase64(1).
|
||||
AddCallbackButton("A", "cmd", 1)
|
||||
|
||||
_, _, err := decodePayload(BotPayloadJson, kb.Get().InlineKeyboard[0][0].CallbackData, true)
|
||||
if !errors.Is(err, ErrPayloadTypeMismatch) {
|
||||
t.Fatalf("expected ErrPayloadTypeMismatch, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+87
-11
@@ -2,6 +2,7 @@ package laniakea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -48,6 +49,10 @@ type AnswerMessage struct {
|
||||
|
||||
// Internal helper for text edits with optional keyboard and parse mode.
|
||||
func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
||||
if err := validateMessageText(text); err != nil {
|
||||
ctx.Logger.Errorln(err)
|
||||
return nil
|
||||
}
|
||||
params := tgapi.EditMessageTextP{
|
||||
Text: text,
|
||||
ParseMode: parseMode,
|
||||
@@ -59,7 +64,7 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard
|
||||
case ctx.InlineMsgId != "":
|
||||
params.InlineMessageID = ctx.InlineMsgId
|
||||
default:
|
||||
ctx.Logger.Errorln("Can't edit message: no valid message target")
|
||||
ctx.Logger.Errorln(ErrEditTargetMissing)
|
||||
return nil
|
||||
}
|
||||
if keyboard != nil {
|
||||
@@ -96,7 +101,7 @@ func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage {
|
||||
// Internal helper for editing callback-linked messages.
|
||||
func (ctx *MsgContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
||||
if ctx.CallbackMsgId == 0 && ctx.InlineMsgId == "" {
|
||||
ctx.Logger.Errorln("Can't edit non-callback update message")
|
||||
ctx.Logger.Errorln(ErrCallbackMessageMissing)
|
||||
return nil
|
||||
}
|
||||
return ctx.edit(ctx.CallbackMsgId, text, keyboard, parseMode)
|
||||
@@ -128,6 +133,10 @@ func (ctx *MsgContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyb
|
||||
|
||||
// Internal helper for media-caption edits.
|
||||
func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
||||
if err := validateCaptionText(text); err != nil {
|
||||
ctx.Logger.Errorln(err)
|
||||
return nil
|
||||
}
|
||||
params := tgapi.EditMessageCaptionP{
|
||||
Caption: text,
|
||||
ParseMode: parseMode,
|
||||
@@ -139,7 +148,7 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo
|
||||
case ctx.InlineMsgId != "":
|
||||
params.InlineMessageID = ctx.InlineMsgId
|
||||
default:
|
||||
ctx.Logger.Errorln("Can't edit caption: no valid message target")
|
||||
ctx.Logger.Errorln(ErrEditTargetMissing)
|
||||
return nil
|
||||
}
|
||||
if kb != nil {
|
||||
@@ -187,7 +196,11 @@ func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeybo
|
||||
// Internal helper for message replies with optional keyboard and parse mode.
|
||||
func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
||||
if ctx.Msg == nil {
|
||||
ctx.Logger.Errorln("Can't answer message without a message")
|
||||
ctx.Logger.Errorln(ErrMessageContextNil)
|
||||
return nil
|
||||
}
|
||||
if err := validateMessageText(text); err != nil {
|
||||
ctx.Logger.Errorln(err)
|
||||
return nil
|
||||
}
|
||||
params := tgapi.SendMessageP{
|
||||
@@ -220,6 +233,14 @@ func (ctx *MsgContext) Answer(text string) *AnswerMessage {
|
||||
return ctx.answer(text, nil, tgapi.ParseNone)
|
||||
}
|
||||
|
||||
// AnswerLong sends one or more plain-text messages if text exceeds Telegram's limit.
|
||||
//
|
||||
// The text is split into Telegram-safe chunks. Returned messages preserve send
|
||||
// order. If a chunk fails to send, already-sent messages are returned.
|
||||
func (ctx *MsgContext) AnswerLong(text string) []*AnswerMessage {
|
||||
return ctx.answerLong(text, nil, tgapi.ParseNone)
|
||||
}
|
||||
|
||||
// AnswerMarkdown sends a message using MarkdownV2 formatting.
|
||||
//
|
||||
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
||||
@@ -232,6 +253,11 @@ func (ctx *MsgContext) Answerf(template string, args ...any) *AnswerMessage {
|
||||
return ctx.answer(fmt.Sprintf(template, args...), nil, tgapi.ParseNone)
|
||||
}
|
||||
|
||||
// AnswerLongf formats a string using fmt.Sprintf and sends it as one or more plain-text messages.
|
||||
func (ctx *MsgContext) AnswerLongf(template string, args ...any) []*AnswerMessage {
|
||||
return ctx.answerLong(fmt.Sprintf(template, args...), nil, tgapi.ParseNone)
|
||||
}
|
||||
|
||||
// AnswerfMarkdown formats a string using fmt.Sprintf and sends it using MarkdownV2.
|
||||
//
|
||||
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
||||
@@ -244,6 +270,13 @@ func (ctx *MsgContext) Keyboard(text string, kb *InlineKeyboard) *AnswerMessage
|
||||
return ctx.answer(text, kb, tgapi.ParseNone)
|
||||
}
|
||||
|
||||
// KeyboardLong sends long plain text split across multiple messages.
|
||||
//
|
||||
// The inline keyboard is attached only to the final chunk.
|
||||
func (ctx *MsgContext) KeyboardLong(text string, kb *InlineKeyboard) []*AnswerMessage {
|
||||
return ctx.answerLong(text, kb, tgapi.ParseNone)
|
||||
}
|
||||
|
||||
// KeyboardMarkdown sends a message with an inline keyboard using MarkdownV2.
|
||||
//
|
||||
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
||||
@@ -251,10 +284,53 @@ func (ctx *MsgContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) *
|
||||
return ctx.answer(text, keyboard, tgapi.ParseMDV2)
|
||||
}
|
||||
|
||||
func (ctx *MsgContext) answerLong(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) []*AnswerMessage {
|
||||
if parseMode != tgapi.ParseNone {
|
||||
ctx.Logger.Errorln(ErrMessageSplitImpossible)
|
||||
return nil
|
||||
}
|
||||
if ctx.Msg == nil {
|
||||
ctx.Logger.Errorln(ErrMessageContextNil)
|
||||
return nil
|
||||
}
|
||||
if err := validateMessageText(text); err == nil {
|
||||
msg := ctx.answer(text, keyboard, parseMode)
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
return []*AnswerMessage{msg}
|
||||
} else if !errors.Is(err, ErrMessageTooLong) {
|
||||
ctx.Logger.Errorln(err)
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := SplitMessageText(text)
|
||||
messages := make([]*AnswerMessage, 0, len(parts))
|
||||
for i, part := range parts {
|
||||
partKeyboard := (*InlineKeyboard)(nil)
|
||||
if i == len(parts)-1 {
|
||||
partKeyboard = keyboard
|
||||
}
|
||||
msg := ctx.answer(part, partKeyboard, parseMode)
|
||||
if msg == nil {
|
||||
break
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
// Internal helper for photo replies with optional caption and keyboard.
|
||||
func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
||||
if ctx.Msg == nil {
|
||||
ctx.Logger.Errorln("Can't answer message without a message")
|
||||
ctx.Logger.Errorln(ErrMessageContextNil)
|
||||
return nil
|
||||
}
|
||||
if err := validateCaptionText(text); err != nil {
|
||||
ctx.Logger.Errorln(err)
|
||||
return nil
|
||||
}
|
||||
params := tgapi.SendPhotoP{
|
||||
@@ -322,11 +398,11 @@ func (ctx *MsgContext) AnswerPhotofMarkdown(photoId, template string, args ...an
|
||||
// Internal helper that deletes a message by ID.
|
||||
func (ctx *MsgContext) delete(messageId int) {
|
||||
if messageId == 0 {
|
||||
ctx.Logger.Errorln("Can't delete message: message ID zero")
|
||||
ctx.Logger.Errorln(ErrMessageIDZero)
|
||||
return
|
||||
}
|
||||
if ctx.Msg == nil {
|
||||
ctx.Logger.Errorln("Can't delete message: no chat message context")
|
||||
ctx.Logger.Errorln(ErrMessageContextNil)
|
||||
return
|
||||
}
|
||||
_, err := ctx.Api.DeleteMessage(tgapi.DeleteMessageP{
|
||||
@@ -344,7 +420,7 @@ func (m *AnswerMessage) Delete() { m.ctx.delete(m.MessageID) }
|
||||
// CallbackDelete deletes the message that triggered the callback query.
|
||||
func (ctx *MsgContext) CallbackDelete() {
|
||||
if ctx.CallbackMsgId == 0 {
|
||||
ctx.Logger.Errorln("Can't delete callback message: no callback message ID")
|
||||
ctx.Logger.Errorln(ErrCallbackMessageMissing)
|
||||
return
|
||||
}
|
||||
ctx.delete(ctx.CallbackMsgId)
|
||||
@@ -411,15 +487,15 @@ func (ctx *MsgContext) Error(err error) { ctx.error(err) }
|
||||
|
||||
func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft {
|
||||
if ctx.Msg == nil {
|
||||
ctx.Logger.Errorln("can't create draft: ctx.Msg is nil")
|
||||
ctx.Logger.Errorln(ErrMessageContextNil)
|
||||
return nil
|
||||
}
|
||||
if ctx.Api == nil {
|
||||
ctx.Logger.Errorln("can't create draft: ctx.Api is nil")
|
||||
ctx.Logger.Errorln(ErrAPIIsNil)
|
||||
return nil
|
||||
}
|
||||
if ctx.draftProvider == nil {
|
||||
ctx.Logger.Errorln("can't create draft: ctx.draftProvider is nil")
|
||||
ctx.Logger.Errorln(ErrDraftProviderNil)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ package laniakea
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -62,3 +64,146 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
|
||||
t.Fatalf("unexpected direct_messages_topic_id: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerRejectsEmptyMessage(t *testing.T) {
|
||||
ctx := &MsgContext{
|
||||
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}},
|
||||
Logger: slog.CreateLogger(),
|
||||
}
|
||||
|
||||
if answer := ctx.Answer(""); answer != nil {
|
||||
t.Fatal("expected nil answer for empty message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerRejectsLongMessageWithoutSendingRequest(t *testing.T) {
|
||||
client := &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
t.Fatal("unexpected HTTP request")
|
||||
return nil, nil
|
||||
}),
|
||||
}
|
||||
|
||||
api := tgapi.NewAPI(
|
||||
tgapi.NewAPIOpts("token").
|
||||
SetAPIUrl("https://example.test").
|
||||
SetHTTPClient(client),
|
||||
)
|
||||
defer func() {
|
||||
if err := api.Close(); err != nil {
|
||||
t.Fatalf("Close returned error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx := &MsgContext{
|
||||
Api: api,
|
||||
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}},
|
||||
Logger: slog.CreateLogger(),
|
||||
}
|
||||
|
||||
if answer := ctx.Answer(strings.Repeat("a", maxMessageTextLen+1)); answer != nil {
|
||||
t.Fatal("expected nil answer for long message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMessageText(t *testing.T) {
|
||||
if err := validateMessageText(""); !errors.Is(err, ErrEmptyMessage) {
|
||||
t.Fatalf("expected ErrEmptyMessage, got %v", err)
|
||||
}
|
||||
if err := validateMessageText(strings.Repeat("a", maxMessageTextLen+1)); !errors.Is(err, ErrMessageTooLong) {
|
||||
t.Fatalf("expected ErrMessageTooLong, got %v", err)
|
||||
}
|
||||
if err := validateMessageText("ok"); err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCaptionText(t *testing.T) {
|
||||
if err := validateCaptionText(strings.Repeat("a", maxMessageCaptionLen+1)); !errors.Is(err, ErrCaptionTooLong) {
|
||||
t.Fatalf("expected ErrCaptionTooLong, got %v", err)
|
||||
}
|
||||
if err := validateCaptionText(""); err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitMessageTextPreservesContent(t *testing.T) {
|
||||
text := "alpha beta\n" + strings.Repeat("x", maxMessageTextLen) + " omega"
|
||||
|
||||
parts := SplitMessageText(text)
|
||||
if len(parts) < 2 {
|
||||
t.Fatalf("expected multiple parts, got %d", len(parts))
|
||||
}
|
||||
|
||||
for i, part := range parts {
|
||||
if got := len([]rune(part)); got > maxMessageTextLen {
|
||||
t.Fatalf("part %d exceeded limit: %d", i, got)
|
||||
}
|
||||
}
|
||||
|
||||
if got := strings.Join(parts, ""); got != text {
|
||||
t.Fatalf("split/join mismatch: got %q want %q", got, text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerLongSplitsRequestsAndAttachesKeyboardToLastChunk(t *testing.T) {
|
||||
var requests []map[string]any
|
||||
|
||||
client := &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read request body: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("failed to decode request body: %v", err)
|
||||
}
|
||||
requests = append(requests, got)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"message_id":9,"date":1}}`)),
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
api := tgapi.NewAPI(
|
||||
tgapi.NewAPIOpts("token").
|
||||
SetAPIUrl("https://example.test").
|
||||
SetHTTPClient(client),
|
||||
)
|
||||
defer func() {
|
||||
if err := api.Close(); err != nil {
|
||||
t.Fatalf("Close returned error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx := &MsgContext{
|
||||
Api: api,
|
||||
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}},
|
||||
Logger: slog.CreateLogger(),
|
||||
}
|
||||
kb := NewInlineKeyboardJson(1).AddCallbackButton("A", "cmd")
|
||||
text := strings.Repeat("a", maxMessageTextLen) + " " + strings.Repeat("b", 32)
|
||||
|
||||
messages := ctx.KeyboardLong(text, kb)
|
||||
if got := len(messages); got != 2 {
|
||||
t.Fatalf("expected 2 sent messages, got %d", got)
|
||||
}
|
||||
if got := len(requests); got != 2 {
|
||||
t.Fatalf("expected 2 requests, got %d", got)
|
||||
}
|
||||
if _, ok := requests[0]["reply_markup"]; ok {
|
||||
t.Fatal("did not expect keyboard on first chunk")
|
||||
}
|
||||
if _, ok := requests[1]["reply_markup"]; !ok {
|
||||
t.Fatal("expected keyboard on final chunk")
|
||||
}
|
||||
|
||||
gotTexts := []string{requests[0]["text"].(string), requests[1]["text"].(string)}
|
||||
wantTexts := SplitMessageText(text)
|
||||
if !reflect.DeepEqual(gotTexts, wantTexts) {
|
||||
t.Fatalf("unexpected chunk texts: got %q want %q", gotTexts, wantTexts)
|
||||
}
|
||||
}
|
||||
|
||||
+8
-3
@@ -81,7 +81,8 @@ func (c CommandArg) SetRequired() CommandArg {
|
||||
|
||||
// CommandExecutor is the function type that executes a command.
|
||||
// It receives the message context and a database context (generic).
|
||||
type CommandExecutor[T DbContext] func(ctx *MsgContext, dbContext T)
|
||||
// Returning a non-nil error routes it through the bot's error handler.
|
||||
type CommandExecutor[T DbContext] func(ctx *MsgContext, dbContext T) error
|
||||
|
||||
// Command represents a bot command with arguments, description, and executor.
|
||||
// Can be registered in a Plugin and optionally skipped from auto-generation.
|
||||
@@ -309,7 +310,9 @@ func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, db T) {
|
||||
}
|
||||
|
||||
// Execute command
|
||||
command.exec(ctx, db)
|
||||
if err := command.exec(ctx, db); err != nil {
|
||||
ctx.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Internal helper that validates and executes a payload handler.
|
||||
@@ -333,7 +336,9 @@ func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, db T) {
|
||||
}
|
||||
|
||||
// Execute payload
|
||||
command.exec(ctx, db)
|
||||
if err := command.exec(ctx, db); err != nil {
|
||||
ctx.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Internal helper that runs plugin middlewares in order.
|
||||
|
||||
+3
-3
@@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
func TestValidateArgsRequiresFullMatch(t *testing.T) {
|
||||
intCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) {}, "int", NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired())
|
||||
intCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) error { return nil }, "int", NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired())
|
||||
if err := intCmd.validateArgs([]string{"123"}); err != nil {
|
||||
t.Fatalf("expected valid integer argument, got %v", err)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) {
|
||||
t.Fatalf("expected ErrCmdArgRegexpMismatch for partial int match, got %v", err)
|
||||
}
|
||||
|
||||
boolCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) {}, "bool", NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired())
|
||||
boolCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) error { return nil }, "bool", NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired())
|
||||
if err := boolCmd.validateArgs([]string{"false"}); err != nil {
|
||||
t.Fatalf("expected valid bool argument, got %v", err)
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) {
|
||||
|
||||
func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) {
|
||||
cmd := NewCommand[NoDB](
|
||||
func(ctx *MsgContext, db NoDB) {},
|
||||
func(ctx *MsgContext, db NoDB) error { return nil },
|
||||
"mixed",
|
||||
NewCommandArg("optional"),
|
||||
NewCommandArg("required").SetRequired(),
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package laniakea
|
||||
|
||||
// SplitMessageText splits plain text into Telegram-safe message chunks.
|
||||
//
|
||||
// The function preserves the original text exactly: concatenating all returned
|
||||
// chunks reconstructs text byte-for-byte. It prefers splitting at newlines or
|
||||
// spaces within the Telegram message limit and falls back to hard rune-based
|
||||
// splits when no separator is available.
|
||||
func SplitMessageText(text string) []string {
|
||||
return splitTextByLimit(text, maxMessageTextLen)
|
||||
}
|
||||
|
||||
func splitTextByLimit(text string, limit int) []string {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
runes := []rune(text)
|
||||
chunks := make([]string, 0, len(runes)/limit+1)
|
||||
|
||||
for start := 0; start < len(runes); {
|
||||
end := start + limit
|
||||
if end >= len(runes) {
|
||||
chunks = append(chunks, string(runes[start:]))
|
||||
break
|
||||
}
|
||||
|
||||
splitAt := -1
|
||||
for i := end - 1; i > start; i-- {
|
||||
if runes[i] == '\n' || runes[i] == ' ' {
|
||||
splitAt = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
if splitAt == -1 {
|
||||
splitAt = end
|
||||
}
|
||||
|
||||
chunks = append(chunks, string(runes[start:splitAt]))
|
||||
start = splitAt
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
+2
-2
@@ -2,7 +2,7 @@ package utils
|
||||
|
||||
const (
|
||||
// VersionString is the module version string.
|
||||
VersionString = "1.0.0-rc.11"
|
||||
VersionString = "1.0.0-rc.12"
|
||||
// VersionMajor is the module major version.
|
||||
VersionMajor = 1
|
||||
// VersionMinor is the module minor version.
|
||||
@@ -10,5 +10,5 @@ const (
|
||||
// VersionPatch is the module patch version.
|
||||
VersionPatch = 0
|
||||
// VersionBeta is the prerelease counter for the current version.
|
||||
VersionBeta = 11
|
||||
VersionBeta = 12
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user