diff --git a/AGENTS.md b/AGENTS.md index 6dc01d2..a1af97c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 714e5fd..a4a826e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 7fcec12..501e882 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README_RU.md b/README_RU.md index b7d3797..85579d6 100644 --- a/README_RU.md +++ b/README_RU.md @@ -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. diff --git a/bot.go b/bot.go index 651bd05..b76ad37 100644 --- a/bot.go +++ b/bot.go @@ -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] { diff --git a/bot_opts.go b/bot_opts.go index 0ec97d6..d10aba8 100644 --- a/bot_opts.go +++ b/bot_opts.go @@ -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. // diff --git a/bot_opts_test.go b/bot_opts_test.go index 9f3c1be..4c5c7ed 100644 --- a/bot_opts_test.go +++ b/bot_opts_test.go @@ -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") + } +} diff --git a/bot_test.go b/bot_test.go index 45db995..8795daa 100644 --- a/bot_test.go +++ b/bot_test.go @@ -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") diff --git a/cmd_generator_test.go b/cmd_generator_test.go index 468316f..bbbca03 100644 --- a/cmd_generator_test.go +++ b/cmd_generator_test.go @@ -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")) diff --git a/drafts.go b/drafts.go index 2de42de..1e1c89a 100644 --- a/drafts.go +++ b/drafts.go @@ -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, diff --git a/drafts_test.go b/drafts_test.go index 5e10497..887e19a 100644 --- a/drafts_test.go +++ b/drafts_test.go @@ -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) + } +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..09d5425 --- /dev/null +++ b/errors.go @@ -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 +} diff --git a/handler.go b/handler.go index b328217..90f0126 100644 --- a/handler.go +++ b/handler.go @@ -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 } diff --git a/handler_test.go b/handler_test.go index 8d91e6b..485f656 100644 --- a/handler_test.go +++ b/handler_test.go @@ -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]{ diff --git a/keyboard.go b/keyboard.go index 1ce82ab..ab19c67 100644 --- a/keyboard.go +++ b/keyboard.go @@ -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 { diff --git a/keyboard_test.go b/keyboard_test.go index 3c39a95..c9157d6 100644 --- a/keyboard_test.go +++ b/keyboard_test.go @@ -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) + } +} diff --git a/msg_context.go b/msg_context.go index d3e6cf3..eeeae9f 100644 --- a/msg_context.go +++ b/msg_context.go @@ -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 } diff --git a/msg_context_test.go b/msg_context_test.go index 5c5c38d..299fa42 100644 --- a/msg_context_test.go +++ b/msg_context_test.go @@ -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) + } +} diff --git a/plugins.go b/plugins.go index 627ba03..656663c 100644 --- a/plugins.go +++ b/plugins.go @@ -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. diff --git a/plugins_test.go b/plugins_test.go index 8b95147..d2d44c5 100644 --- a/plugins_test.go +++ b/plugins_test.go @@ -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(), diff --git a/split.go b/split.go new file mode 100644 index 0000000..95896f6 --- /dev/null +++ b/split.go @@ -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 +} diff --git a/utils/version.go b/utils/version.go index 81a081e..fb41955 100644 --- a/utils/version.go +++ b/utils/version.go @@ -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 )