This commit is contained in:
+13
-1
@@ -2,15 +2,27 @@
|
||||
|
||||
## v1.0.0
|
||||
|
||||
### Breaking Changes
|
||||
- Renamed final public APIs to idiomatic names before the stable release: `RunWebhookWithContext(...)`, `RunWebhook(...)`, `CloseWebhook()`, `BotWebhookOpts`, `NewBotWebhookOpts()`, `SetWebhookLogger(...)`, and `GetWebhookLogger()`.
|
||||
- Renamed plugin builder helpers from `NewCommand(...)`, `NewPayload(...)`, and `NewScene(...)` to `Command(...)`, `Payload(...)`, and `Scene(...)`; `NewCommand(...)` and `NewPayload(...)` now take the command string before the executor.
|
||||
- Renamed command argument value constants to `CommandValueString`, `CommandValueInt`, `CommandValueBool`, and `CommandValueAny`; `NewCommandArg(...)` now defaults to unvalidated `CommandValueAny`.
|
||||
- Renamed runner builders from `Onetime(...)` and `Timeout(...)` to `Once(...)` and `Every(...)`.
|
||||
- Renamed remaining public acronym/casing outliers including `AnswerCallback...`, `ParseMarkdownV2`, `ParseMarkdown`, `GetChatMemberCount`, `DropRateLimitOverflow`, `SetDropRateLimitOverflow`, and inline keyboard builder APIs.
|
||||
|
||||
### Added
|
||||
- Added `MsgContext.IsCallback()` and `MsgContext.HasPhoto()` helpers for callback-aware handler code.
|
||||
- Added `MsgContext.UpsertKeyboard(...)` and `MsgContext.UpsertKeyboardMarkdown(...)` helpers that edit callback messages, replace photo callback messages with a fresh chat message, and send a new chat message outside callback flow.
|
||||
- Added `CommandGroup`, `NewCommandGroup(...)`, `Plugin.CommandGroup(...)`, and `Plugin.AddCommandGroup(...)` helpers for registering prefixed command groups with shared middleware.
|
||||
- Added the `tgmd` package with Telegram Markdown formatting helpers and a message entity builder.
|
||||
|
||||
### Changed
|
||||
- Version metadata now reports the stable `v1.0.0` release instead of `v1.0.0-rc.16`.
|
||||
- Bot-level middleware blocks now emit a final `UpdateHandledEvent` with `Handled=false`, keeping observer update lifecycles balanced.
|
||||
- `BotOpts`, `tgapi.APIOpts`, and logger utility godoc now document `LOG_FORMAT`, `LogFormat`, and logger formatting options consistently.
|
||||
- `BotOpts`, `tgapi.APIOpts`, logger utilities, README, and wiki pages now document the final stable API names and configuration options consistently.
|
||||
|
||||
### Fixed
|
||||
- Fixed webhook startup so empty-secret warnings are logged only after the webhook logger is initialized.
|
||||
- Fixed webhook startup so a logger configured through `SetWebhookLogger(...)` is preserved.
|
||||
|
||||
### Tests
|
||||
- Added regression coverage proving bot-level middleware blocks still complete the observer update lifecycle.
|
||||
|
||||
@@ -23,7 +23,7 @@ A lightweight, easy-to-use, and performant Telegram Bot API wrapper for Go. It s
|
||||
* **Built-in Rate Limiting:** Protect your bot from hitting Telegram API limits (supports `retry_after` handling).
|
||||
* **Context-Aware:** Pass custom application data or state contexts to your handlers.
|
||||
* **Configurable API:** Mix `Set...` and `Add...` helpers to configure bots clearly (for example, `bot.SetErrorTemplate(...).AddPlugins(...)`).
|
||||
* **Polling and Webhook Runtime:** Run bots through long polling with `Run()` / `RunWithContext(...)` or through a bot-owned webhook server with `RunWebHookWithContext(...)`.
|
||||
* **Polling and Webhook Runtime:** Run bots through long polling with `Run()` / `RunWithContext(...)` or through a bot-owned webhook server with `RunWebhookWithContext(...)`.
|
||||
|
||||
---
|
||||
|
||||
@@ -80,15 +80,15 @@ func main() {
|
||||
p := laniakea.NewPlugin[laniakea.NoData]("ping")
|
||||
|
||||
// 4. Add a command to the plugin.
|
||||
// p.NewCommand(echo, "echo") creates a command that triggers the 'echo' function on the "/echo" command.
|
||||
p.AddCommand(p.NewCommand(echo, "echo"))
|
||||
// p.Command("echo", echo) creates a command that triggers the 'echo' function on the "/echo" command.
|
||||
p.Command("echo", echo)
|
||||
|
||||
// 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, data laniakea.NoData) error {
|
||||
p.Command("ping", func(ctx *laniakea.MsgContext, data laniakea.NoData) error {
|
||||
ctx.Answer("Pong")
|
||||
return nil
|
||||
}, "ping"))
|
||||
})
|
||||
|
||||
// 6. Configure the bot with a custom error template and add the plugin.
|
||||
// SetErrorTemplate sets a format string for errors (where %s will be replaced by the actual error).
|
||||
@@ -112,13 +112,13 @@ 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 custom shared application data (for example, *sql.DB or a service container) that will be available in all handlers. Use laniakea.NoData 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) error`), the second is the command name (without the slash).
|
||||
4. `Command`: Creates and registers a command. The first argument is the command name without the slash, the second is the handler function (`func(*MsgContext, T) error`).
|
||||
5. **Handler Functions**: Receive *MsgContext (message details, methods like Answer) and your custom application data T, and return an error for centralized error handling.
|
||||
6. `SetErrorTemplate`: 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.
|
||||
9. `RunWebHookWithContext(...)`: Starts the bot-owned webhook runtime when Telegram should deliver updates over HTTP instead of long polling.
|
||||
10. A `Bot` instance is single-use. After `Run()`, `RunWithContext()`, or `RunWebHookWithContext()` returns, create a new bot instance for the next session.
|
||||
9. `RunWebhookWithContext(...)`: Starts the bot-owned webhook runtime when Telegram should deliver updates over HTTP instead of long polling.
|
||||
10. A `Bot` instance is single-use. After `Run()`, `RunWithContext()`, or `RunWebhookWithContext()` returns, create a new bot instance for the next session.
|
||||
|
||||
## File-Based Config
|
||||
|
||||
@@ -151,7 +151,7 @@ See the full guide in the wiki: [Bot Options and Configuration](https://git.scur
|
||||
|
||||
## Webhook Runtime
|
||||
|
||||
Laniakea also supports a bot-owned webhook runtime through `RunWebHookWithContext(...)` and `RunWebHook(...)`.
|
||||
Laniakea also supports a bot-owned webhook runtime through `RunWebhookWithContext(...)` and `RunWebhook(...)`.
|
||||
|
||||
Use it when:
|
||||
- Telegram should push updates to your HTTP endpoint instead of your bot polling for them.
|
||||
@@ -159,11 +159,11 @@ Use it when:
|
||||
- You want Laniakea to register the webhook and own the local HTTP server.
|
||||
|
||||
Production notes:
|
||||
- Set `BotWebHookOpts.SecretToken` for request authentication.
|
||||
- `BotWebHookOpts.SecretToken` is required when `BotWebHookOpts.UseStatusPath` is enabled.
|
||||
- Keep `BotWebHookOpts.Path` specific instead of serving webhook traffic on `/`.
|
||||
- If you switch an existing deployment from webhook mode to long polling, delete the webhook first with `CloseWebHook()` or `tgapi.DeleteWebhook(...)`. Telegram keeps webhook delivery active until it is removed.
|
||||
- Use `RunWebHookWithContext(...)` with a cancelable context, then call `Close()` after runtime shutdown.
|
||||
- Set `BotWebhookOpts.SecretToken` for request authentication.
|
||||
- `BotWebhookOpts.SecretToken` is required when `BotWebhookOpts.UseStatusPath` is enabled.
|
||||
- Keep `BotWebhookOpts.Path` specific instead of serving webhook traffic on `/`.
|
||||
- If you switch an existing deployment from webhook mode to long polling, delete the webhook first with `CloseWebhook()` or `tgapi.DeleteWebhook(...)`. Telegram keeps webhook delivery active until it is removed.
|
||||
- Use `RunWebhookWithContext(...)` with a cancelable context, then call `Close()` after runtime shutdown.
|
||||
|
||||
See the full guide in the wiki: [Webhook Runtime](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Webhook-Runtime)
|
||||
|
||||
@@ -173,7 +173,7 @@ See the full guide in the wiki: [Webhook Runtime](https://git.scuroneko.dev/Scur
|
||||
Plugins are the main way to organize code. A plugin can have multiple commands and middlewares.
|
||||
```go
|
||||
plugin := laniakea.NewPlugin[*MyDB]("admin")
|
||||
plugin.AddCommand(plugin.NewCommand(banUser, "ban"))
|
||||
plugin.Command("ban", banUser)
|
||||
bot.AddPlugins(plugin)
|
||||
```
|
||||
|
||||
@@ -238,7 +238,7 @@ Scenes model multi-step conversations inside a plugin. Each active scene is stor
|
||||
```go
|
||||
plugin := laniakea.NewPlugin[MyDB]("signup")
|
||||
|
||||
plugin.NewScene("signup").
|
||||
plugin.Scene("signup").
|
||||
SetScope(laniakea.SceneScopeUserChat).
|
||||
SetEntry("ask_name").
|
||||
OnStep("ask_name", func(ctx *laniakea.SceneContext, db MyDB) (laniakea.SceneResult, error) {
|
||||
@@ -288,7 +288,7 @@ Use `AddMiddleware` on a plugin to add one or more shared middleware functions.
|
||||
plugin := laniakea.NewPlugin[*MyDB]("admin")
|
||||
plugin.AddMiddleware(laniakea.NewMiddleware("logging", loggingMiddleware))
|
||||
plugin.AddMiddleware(laniakea.NewMiddleware("admin-only", adminOnlyMiddleware))
|
||||
plugin.AddCommand(plugin.NewCommand(banUser, "ban"))
|
||||
plugin.Command("ban", banUser)
|
||||
```
|
||||
|
||||
### Example Middlewares
|
||||
@@ -320,7 +320,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
|
||||
- **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.
|
||||
- **Lifecycle**: `RunWithContext(...)` and `RunWebHookWithContext(...)` do not call `Close()` for you. Shut the bot down explicitly, and create a fresh `Bot` for the next run.
|
||||
- **Lifecycle**: `RunWithContext(...)` and `RunWebhookWithContext(...)` do not call `Close()` for you. Shut the bot down explicitly, and create a fresh `Bot` for the next run.
|
||||
|
||||
## Telegram Update Handling
|
||||
- Commands and payloads are handled through plugins.
|
||||
|
||||
+18
-18
@@ -24,7 +24,7 @@
|
||||
* **Встроенный ограничитель запросов (Rate Limiter):** Защитите бота от превышения лимитов Telegram API (с обработкой `retry_after`).
|
||||
* **Контекст данных:** Передавайте общие данные приложения или state в обработчики.
|
||||
* **Настраиваемый API:** Комбинируйте `Set...` и `Add...` helper-методы для понятной конфигурации, например `bot.SetErrorTemplate(...).AddPlugins(...)`.
|
||||
* **Polling и Webhook Runtime:** Запускайте бота через long polling с `Run()` / `RunWithContext(...)` или через webhook server, которым владеет сам бот, с `RunWebHookWithContext(...)`.
|
||||
* **Polling и Webhook Runtime:** Запускайте бота через long polling с `Run()` / `RunWithContext(...)` или через webhook server, которым владеет сам бот, с `RunWebhookWithContext(...)`.
|
||||
|
||||
---
|
||||
|
||||
@@ -81,15 +81,15 @@ func main() {
|
||||
p := laniakea.NewPlugin[laniakea.NoData]("ping")
|
||||
|
||||
// 4. Добавляем команду в плагин.
|
||||
// p.NewCommand(echo, "echo") создаёт команду, которая вызывает функцию 'echo' по команде "/echo".
|
||||
p.AddCommand(p.NewCommand(echo, "echo"))
|
||||
// p.Command("echo", echo) создаёт команду, которая вызывает функцию 'echo' по команде "/echo".
|
||||
p.Command("echo", echo)
|
||||
|
||||
// 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание).
|
||||
// Эта команда просто отвечает "Pong", когда пользователь отправляет "/ping".
|
||||
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, data laniakea.NoData) error {
|
||||
p.Command("ping", func(ctx *laniakea.MsgContext, data laniakea.NoData) error {
|
||||
ctx.Answer("Pong")
|
||||
return nil
|
||||
}, "ping"))
|
||||
})
|
||||
|
||||
// 6. Настраиваем бота: задаём шаблон ошибки и добавляем плагин.
|
||||
// SetErrorTemplate устанавливает формат для сообщений об ошибках (где %s будет заменён на текст ошибки).
|
||||
@@ -113,13 +113,13 @@ func main() {
|
||||
1. `BotOpts`: Содержит конфигурацию, например, токен API.
|
||||
2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать общие данные приложения (например, *sql.DB или контейнер сервисов), которые будут доступны во всех обработчиках. Используйте laniakea.NoData, если они не нужны.
|
||||
3. `NewPlugin`: Создаёт логическую группу для команд и Middleware.
|
||||
4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (`func(*MsgContext, T) error`), второй — имя команды (без слеша).
|
||||
4. `Command`: Создаёт и регистрирует команду. Первый аргумент — имя команды без слеша, второй — функция-обработчик (`func(*MsgContext, T) error`).
|
||||
5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваши данные приложения типа T, а ошибку возвращают для централизованной обработки.
|
||||
6. `SetErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки.
|
||||
7. `AutoGenerateCommands`: Регистрирует команды из плагинов в Telegram для поддерживаемых scope.
|
||||
8. `Run()`: Запускает цикл опроса обновлений бота и возвращает ошибку, если старт или polling завершился неуспешно.
|
||||
9. `RunWebHookWithContext(...)`: Запускает bot-owned webhook runtime, когда Telegram должен доставлять update по HTTP вместо long polling.
|
||||
10. Экземпляр `Bot` одноразовый. После завершения `Run()`, `RunWithContext()` или `RunWebHookWithContext()` для следующего запуска создавайте новый бот.
|
||||
9. `RunWebhookWithContext(...)`: Запускает bot-owned webhook runtime, когда Telegram должен доставлять update по HTTP вместо long polling.
|
||||
10. Экземпляр `Bot` одноразовый. После завершения `Run()`, `RunWithContext()` или `RunWebhookWithContext()` для следующего запуска создавайте новый бот.
|
||||
|
||||
## Конфиг из файла
|
||||
|
||||
@@ -152,7 +152,7 @@ if err != nil {
|
||||
|
||||
## Webhook Runtime
|
||||
|
||||
Laniakea также поддерживает bot-owned webhook runtime через `RunWebHookWithContext(...)` и `RunWebHook(...)`.
|
||||
Laniakea также поддерживает bot-owned webhook runtime через `RunWebhookWithContext(...)` и `RunWebhook(...)`.
|
||||
|
||||
Используй его, когда:
|
||||
- Telegram должен сам отправлять update на твой HTTP endpoint вместо polling.
|
||||
@@ -160,11 +160,11 @@ Laniakea также поддерживает bot-owned webhook runtime чере
|
||||
- Ты хочешь, чтобы Laniakea сама регистрировала webhook и владела локальным HTTP server.
|
||||
|
||||
Практические замечания:
|
||||
- Задавай `BotWebHookOpts.SecretToken` для аутентификации запросов.
|
||||
- Непустой `BotWebHookOpts.SecretToken` обязателен, если включён `BotWebHookOpts.UseStatusPath`.
|
||||
- Используй явный `BotWebHookOpts.Path`, а не `/`.
|
||||
- Если ты переводишь уже существующий deployment с webhook-режима на long polling, сначала удали webhook через `CloseWebHook()` или `tgapi.DeleteWebhook(...)`. Пока webhook не удалён, Telegram продолжает доставку через него.
|
||||
- Запускай `RunWebHookWithContext(...)` с cancelable context и после остановки runtime всё равно вызывай `Close()`.
|
||||
- Задавай `BotWebhookOpts.SecretToken` для аутентификации запросов.
|
||||
- Непустой `BotWebhookOpts.SecretToken` обязателен, если включён `BotWebhookOpts.UseStatusPath`.
|
||||
- Используй явный `BotWebhookOpts.Path`, а не `/`.
|
||||
- Если ты переводишь уже существующий deployment с webhook-режима на long polling, сначала удали webhook через `CloseWebhook()` или `tgapi.DeleteWebhook(...)`. Пока webhook не удалён, Telegram продолжает доставку через него.
|
||||
- Запускай `RunWebhookWithContext(...)` с cancelable context и после остановки runtime всё равно вызывай `Close()`.
|
||||
|
||||
Полное руководство есть в wiki: [Webhook Runtime](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Webhook-Runtime-RU)
|
||||
|
||||
@@ -174,7 +174,7 @@ Laniakea также поддерживает bot-owned webhook runtime чере
|
||||
|
||||
```go
|
||||
plugin := laniakea.NewPlugin[*MyDB]("admin")
|
||||
plugin.AddCommand(plugin.NewCommand(banUser, "ban"))
|
||||
plugin.Command("ban", banUser)
|
||||
bot.AddPlugins(plugin)
|
||||
```
|
||||
|
||||
@@ -226,7 +226,7 @@ bot.SetAppData(db)
|
||||
```go
|
||||
plugin := laniakea.NewPlugin[MyDB]("signup")
|
||||
|
||||
plugin.NewScene("signup").
|
||||
plugin.Scene("signup").
|
||||
SetScope(laniakea.SceneScopeUserChat).
|
||||
SetEntry("ask_name").
|
||||
OnStep("ask_name", func(ctx *laniakea.SceneContext, db MyDB) (laniakea.SceneResult, error) {
|
||||
@@ -285,7 +285,7 @@ func(ctx *MsgContext, db T) bool
|
||||
plugin := laniakea.NewPlugin[*MyDB]("admin")
|
||||
plugin.AddMiddleware(laniakea.NewMiddleware("logging", loggingMiddleware))
|
||||
plugin.AddMiddleware(laniakea.NewMiddleware("admin-only", adminOnlyMiddleware))
|
||||
plugin.AddCommand(plugin.NewCommand(banUser, "ban"))
|
||||
plugin.Command("ban", banUser)
|
||||
```
|
||||
|
||||
### Примеры middleware
|
||||
@@ -317,7 +317,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
|
||||
- **Ограничение запросов**: Передайте настроенный `utils.RateLimiter` через `BotOpts` для корректной обработки лимитов Telegram.
|
||||
- **Локализация**: `L10n` безопасен для конкурентного использования после подключения к боту.
|
||||
- **Пользовательские update handlers**: Используйте `plugin.AddUpdateHandler(...)` для Telegram update types вне command/payload flow.
|
||||
- **Жизненный цикл**: `RunWithContext(...)` и `RunWebHookWithContext(...)` не вызывают `Close()` автоматически. Завершайте бот явно и создавайте новый `Bot` для следующего запуска.
|
||||
- **Жизненный цикл**: `RunWithContext(...)` и `RunWebhookWithContext(...)` не вызывают `Close()` автоматически. Завершайте бот явно и создавайте новый `Bot` для следующего запуска.
|
||||
|
||||
## Обработка Telegram Updates
|
||||
- Команды и payload-ы обрабатываются через плагины.
|
||||
|
||||
@@ -59,7 +59,7 @@ var (
|
||||
ErrNoPrefixes = errors.New("no prefixes defined")
|
||||
// ErrNoPlugins reports that the bot was started without any registered plugins.
|
||||
ErrNoPlugins = errors.New("no plugins defined")
|
||||
// ErrBotAlreadyRun reports that Run, RunWithContext, or RunWebHookWithContext was called more than once.
|
||||
// ErrBotAlreadyRun reports that Run, RunWithContext, or RunWebhookWithContext was called more than once.
|
||||
ErrBotAlreadyRun = errors.New("bot can only be run once")
|
||||
|
||||
// ErrTokenRequired reports that BotOpts.Token was empty.
|
||||
@@ -78,8 +78,8 @@ var (
|
||||
// - Localization and draft message support
|
||||
//
|
||||
// Runtime accessors are safe for concurrent use. Configure the bot before Run,
|
||||
// RunWithContext, or RunWebHookWithContext.
|
||||
// A Bot is single-use: after Run, RunWithContext, or RunWebHookWithContext returns,
|
||||
// RunWithContext, or RunWebhookWithContext.
|
||||
// A Bot is single-use: after Run, RunWithContext, or RunWebhookWithContext returns,
|
||||
// create a new Bot for the next session.
|
||||
type Bot[T AppData] struct {
|
||||
token string
|
||||
@@ -95,7 +95,7 @@ type Bot[T AppData] struct {
|
||||
logger *sneklog.Logger // Main bot logger (JSON stdout + optional file)
|
||||
requestLogger *sneklog.Logger // Optional request-level API logging
|
||||
useReqLogger bool
|
||||
webHookLogger *sneklog.Logger // Webhook logger. Available only after Bot.RunWebHookWithContext.
|
||||
webhookLogger *sneklog.Logger // Webhook logger. Available only after Bot.RunWebhookWithContext.
|
||||
extraLoggers extypes.Slice[*sneklog.Logger] // API, Uploader, and custom loggers
|
||||
|
||||
plugins []Plugin[T] // Command/event handlers
|
||||
@@ -164,7 +164,7 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
|
||||
SetAPIURL(opts.APIURL).
|
||||
UseTestServer(opts.UseTestServer).
|
||||
SetLimiter(limiter).
|
||||
SetLimiterDrop(opts.DropRLOverflow).
|
||||
SetDropRateLimitOverflow(opts.DropRateLimitOverflow).
|
||||
SetLogFormat(opts.LogFormat).
|
||||
SetLogFormatter(opts.LogFormatter)
|
||||
api := tgapi.NewAPI(apiOpts)
|
||||
@@ -256,12 +256,16 @@ func (bot *Bot[T]) SetRequestLogger(l *sneklog.Logger) *Bot[T] {
|
||||
return bot
|
||||
}
|
||||
|
||||
// SetWebHookLogger replaces the webhook logger.
|
||||
func (bot *Bot[T]) SetWebHookLogger(l *sneklog.Logger) *Bot[T] {
|
||||
bot.webHookLogger = l
|
||||
// SetWebhookLogger replaces the webhook logger.
|
||||
func (bot *Bot[T]) SetWebhookLogger(l *sneklog.Logger) *Bot[T] {
|
||||
bot.webhookLogger = l
|
||||
return bot
|
||||
}
|
||||
|
||||
func (bot *Bot[T]) GetAPI() *tgapi.API { return bot.api }
|
||||
|
||||
func (bot *Bot[T]) GetUploader() *tgapi.Uploader { return bot.uploader }
|
||||
|
||||
// Close gracefully shuts down bot-owned resources.
|
||||
//
|
||||
// Close shuts down, in order:
|
||||
@@ -272,7 +276,7 @@ func (bot *Bot[T]) SetWebHookLogger(l *sneklog.Logger) *Bot[T] {
|
||||
// - RequestLogger (if enabled)
|
||||
// - Main logger
|
||||
//
|
||||
// RunWithContext and RunWebHookWithContext do not call Close automatically.
|
||||
// RunWithContext and RunWebhookWithContext do not call Close automatically.
|
||||
// The caller is responsible for invoking Close after runtime returns to release
|
||||
// these resources.
|
||||
//
|
||||
@@ -294,11 +298,11 @@ func (bot *Bot[T]) Close() error {
|
||||
e = append(e, err)
|
||||
}
|
||||
}
|
||||
if bot.webHookLogger != nil {
|
||||
if err := bot.webHookLogger.Close(); err != nil {
|
||||
if bot.webhookLogger != nil {
|
||||
if err := bot.webhookLogger.Close(); err != nil {
|
||||
logCloseErr(err)
|
||||
}
|
||||
bot.webHookLogger = nil
|
||||
bot.webhookLogger = nil
|
||||
}
|
||||
if bot.uploader != nil {
|
||||
if err := bot.uploader.Close(); err != nil {
|
||||
@@ -354,8 +358,8 @@ func (bot *Bot[T]) GetLogger() *sneklog.Logger { return bot.logger }
|
||||
// GetRequestLogger returns the request-level logger, if configured.
|
||||
func (bot *Bot[T]) GetRequestLogger() *sneklog.Logger { return bot.requestLogger }
|
||||
|
||||
// GetWebHookLogger returns the webhook logger, if configured.
|
||||
func (bot *Bot[T]) GetWebHookLogger() *sneklog.Logger { return bot.webHookLogger }
|
||||
// GetWebhookLogger returns the webhook logger, if configured.
|
||||
func (bot *Bot[T]) GetWebhookLogger() *sneklog.Logger { return bot.webhookLogger }
|
||||
|
||||
// GetLoggerLevel returns the effective log level derived from the bot's debug
|
||||
// flag.
|
||||
@@ -387,7 +391,7 @@ func (bot *Bot[T]) L10n(lang, key string) string {
|
||||
// - Waits for registered runners to exit
|
||||
//
|
||||
// If you are switching an existing deployment from webhook delivery to polling,
|
||||
// delete the current webhook first with CloseWebHook or tgapi.DeleteWebhook.
|
||||
// delete the current webhook first with CloseWebhook or tgapi.DeleteWebhook.
|
||||
// Telegram keeps webhook delivery active until the webhook is removed.
|
||||
//
|
||||
// RunWithContext does not close API, uploader, or logger resources on return.
|
||||
@@ -414,13 +418,13 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error {
|
||||
}
|
||||
bot.requestLogger = nil
|
||||
}
|
||||
if bot.webHookLogger != nil {
|
||||
bot.logger.Warnln("Bot#webHookLogger present. You shouldn't set this, if ran in Long Polling mode!")
|
||||
err := bot.webHookLogger.Close()
|
||||
if bot.webhookLogger != nil {
|
||||
bot.logger.Warnln("Bot#webhookLogger present. You shouldn't set this, if ran in Long Polling mode!")
|
||||
err := bot.webhookLogger.Close()
|
||||
if err != nil {
|
||||
bot.logger.Errorln(err)
|
||||
}
|
||||
bot.webHookLogger = nil
|
||||
bot.webhookLogger = nil
|
||||
}
|
||||
|
||||
bot.ExecRunners(ctx)
|
||||
|
||||
+6
-6
@@ -54,9 +54,9 @@ type BotOpts struct {
|
||||
// Telegram allows up to 30 req/s for most bots. Defaults to 30.
|
||||
RateLimit int
|
||||
|
||||
// DropRLOverflow drops incoming updates when rate limit is exceeded instead of queuing.
|
||||
// DropRateLimitOverflow drops incoming updates when rate limit is exceeded instead of queuing.
|
||||
// Use this to prioritize responsiveness over reliability.
|
||||
DropRLOverflow bool
|
||||
DropRateLimitOverflow bool
|
||||
|
||||
// StrictPayloadType disables callback payload fallback decoding.
|
||||
// When enabled, the bot accepts only the configured default payload type.
|
||||
@@ -136,7 +136,7 @@ func LoadOptsFromEnv() *BotOpts {
|
||||
APIURL: os.Getenv("API_URL"),
|
||||
|
||||
RateLimit: rateLimit,
|
||||
DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
|
||||
DropRateLimitOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
|
||||
StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true",
|
||||
|
||||
MaxWorkers: maxWorkers,
|
||||
@@ -223,10 +223,10 @@ func (opts *BotOpts) SetRateLimit(limit int) *BotOpts {
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetDropRLOverflow drops incoming updates when rate limit is exceeded instead of queuing.
|
||||
// SetDropRateLimitOverflow drops incoming updates when rate limit is exceeded instead of queuing.
|
||||
// Use this to prioritize responsiveness over reliability. Default is false.
|
||||
func (opts *BotOpts) SetDropRLOverflow(drop bool) *BotOpts {
|
||||
opts.DropRLOverflow = drop
|
||||
func (opts *BotOpts) SetDropRateLimitOverflow(drop bool) *BotOpts {
|
||||
opts.DropRateLimitOverflow = drop
|
||||
return opts
|
||||
}
|
||||
|
||||
|
||||
+5
-2
@@ -73,7 +73,7 @@ func (codec BotOptsFileJSONCodec) FromBytes(data []byte) (*BotOpts, error) {
|
||||
UseTestServer: fileOpts.API.UseTestServer,
|
||||
APIURL: fileOpts.API.APIURL,
|
||||
RateLimit: fileOpts.API.RateLimit,
|
||||
DropRLOverflow: fileOpts.API.DropRLOverflow,
|
||||
DropRateLimitOverflow: fileOpts.API.DropRLOverflow,
|
||||
|
||||
StrictPayloadType: fileOpts.StrictPayloadType,
|
||||
MaxWorkers: fileOpts.MaxWorkers,
|
||||
@@ -102,7 +102,7 @@ func (codec BotOptsFileJSONCodec) ToBytes(opts *BotOpts) ([]byte, error) {
|
||||
UseTestServer: opts.UseTestServer,
|
||||
APIURL: opts.APIURL,
|
||||
RateLimit: opts.RateLimit,
|
||||
DropRLOverflow: opts.DropRLOverflow,
|
||||
DropRLOverflow: opts.DropRateLimitOverflow,
|
||||
},
|
||||
StrictPayloadType: opts.StrictPayloadType,
|
||||
MaxWorkers: opts.MaxWorkers,
|
||||
@@ -114,9 +114,12 @@ func (codec BotOptsFileJSONCodec) ToBytes(opts *BotOpts) ([]byte, error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Load reads BotOpts from a JSON config file.
|
||||
func (codec BotOptsFileJSONCodec) Load(filename string) (*BotOpts, error) {
|
||||
return LoadBotOptsFile(codec, filename)
|
||||
}
|
||||
|
||||
// Save writes BotOpts to a JSON config file.
|
||||
func (codec BotOptsFileJSONCodec) Save(filename string, opts *BotOpts) error {
|
||||
return SaveBotOptsFile(codec, filename, opts)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestBotOptsFileJSONCodecRoundTrip(t *testing.T) {
|
||||
UseTestServer: true,
|
||||
APIURL: "https://api.example.invalid",
|
||||
RateLimit: 42,
|
||||
DropRLOverflow: true,
|
||||
DropRateLimitOverflow: true,
|
||||
StrictPayloadType: true,
|
||||
MaxWorkers: 64,
|
||||
FileConfigVersion: ConfigVersion,
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@ func (bot *Bot[T]) UsePolicy(name string, policy Policy[T]) *Bot[T] {
|
||||
// - Scheduled tasks (e.g., daily announcements)
|
||||
//
|
||||
// Runners start from the bot runtime entry points, immediately after
|
||||
// RunWithContext or RunWebHookWithContext begins.
|
||||
// RunWithContext or RunWebhookWithContext begins.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
|
||||
+3
-3
@@ -63,13 +63,13 @@ func TestAddPluginsSnapshotsConfiguration(t *testing.T) {
|
||||
bot := &Bot[NoData]{logger: sneklog.NewLogger()}
|
||||
plugin := NewPlugin[NoData]("demo")
|
||||
|
||||
cmd := plugin.NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "start")
|
||||
cmd := plugin.Command("start", func(ctx *MsgContext, db NoData) error { return nil })
|
||||
plugin.AddMiddleware(NewMiddleware("base", func(ctx *MsgContext, db NoData) bool { return true }))
|
||||
|
||||
bot.AddPlugins(plugin)
|
||||
|
||||
cmd.SetDescription("mutated after registration")
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "late")
|
||||
plugin.Command("late", func(ctx *MsgContext, db NoData) error { return nil })
|
||||
plugin.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoData) bool { return true }))
|
||||
|
||||
registered := bot.plugins[0]
|
||||
@@ -446,7 +446,7 @@ func TestCloseDoesNotDeleteWebhook(t *testing.T) {
|
||||
|
||||
bot := &Bot[NoData]{
|
||||
logger: sneklog.NewLogger(),
|
||||
webHookLogger: sneklog.NewLogger(),
|
||||
webhookLogger: sneklog.NewLogger(),
|
||||
api: api,
|
||||
uploader: uploader,
|
||||
}
|
||||
|
||||
+64
-62
@@ -15,8 +15,8 @@ import (
|
||||
"git.scuroneko.dev/scuroneko/laniakea/utils"
|
||||
)
|
||||
|
||||
// BotWebHookOpts configures Telegram webhook registration and the local HTTP server.
|
||||
type BotWebHookOpts struct {
|
||||
// BotWebhookOpts configures Telegram webhook registration and the local HTTP server.
|
||||
type BotWebhookOpts struct {
|
||||
Path string
|
||||
LocalPort int
|
||||
UseStatusPath bool
|
||||
@@ -30,9 +30,9 @@ type BotWebHookOpts struct {
|
||||
SecretToken string
|
||||
}
|
||||
|
||||
// NewBotWebHookOpts returns webhook options with the default path, local port, and max connections.
|
||||
func NewBotWebHookOpts() *BotWebHookOpts {
|
||||
return &BotWebHookOpts{
|
||||
// NewBotWebhookOpts returns webhook options with the default path, local port, and max connections.
|
||||
func NewBotWebhookOpts() *BotWebhookOpts {
|
||||
return &BotWebhookOpts{
|
||||
Path: "/",
|
||||
LocalPort: 8080,
|
||||
MaxConnections: 40,
|
||||
@@ -40,38 +40,38 @@ func NewBotWebHookOpts() *BotWebHookOpts {
|
||||
}
|
||||
|
||||
// SetPath sets the local HTTP path that receives Telegram webhook requests.
|
||||
func (opts *BotWebHookOpts) SetPath(path string) *BotWebHookOpts {
|
||||
func (opts *BotWebhookOpts) SetPath(path string) *BotWebhookOpts {
|
||||
opts.Path = path
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetLocalPort sets the local HTTP port used by the webhook server.
|
||||
func (opts *BotWebHookOpts) SetLocalPort(port int) *BotWebHookOpts {
|
||||
func (opts *BotWebhookOpts) SetLocalPort(port int) *BotWebhookOpts {
|
||||
opts.LocalPort = port
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetUseStatusPath enables or disables the optional /status endpoint.
|
||||
// A non-empty SecretToken is required when this endpoint is enabled.
|
||||
func (opts *BotWebHookOpts) SetUseStatusPath(use bool) *BotWebHookOpts {
|
||||
func (opts *BotWebhookOpts) SetUseStatusPath(use bool) *BotWebhookOpts {
|
||||
opts.UseStatusPath = use
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetURL sets the public base URL Telegram should call for incoming updates.
|
||||
func (opts *BotWebHookOpts) SetURL(url string) *BotWebHookOpts {
|
||||
func (opts *BotWebhookOpts) SetURL(url string) *BotWebhookOpts {
|
||||
opts.URL = url
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetCertificate sets the self-signed webhook certificate bytes to upload.
|
||||
func (opts *BotWebHookOpts) SetCertificate(certificate []byte) *BotWebHookOpts {
|
||||
func (opts *BotWebhookOpts) SetCertificate(certificate []byte) *BotWebhookOpts {
|
||||
opts.Certificate = certificate
|
||||
return opts
|
||||
}
|
||||
|
||||
// MustLoadCertificate loads a webhook certificate from disk and panics on failure.
|
||||
func (opts *BotWebHookOpts) MustLoadCertificate(filename string) *BotWebHookOpts {
|
||||
func (opts *BotWebhookOpts) MustLoadCertificate(filename string) *BotWebhookOpts {
|
||||
f, err := os.Open(filename)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@@ -87,37 +87,37 @@ func (opts *BotWebHookOpts) MustLoadCertificate(filename string) *BotWebHookOpts
|
||||
}
|
||||
|
||||
// SetIPAddress sets the fixed IP address Telegram should use for webhook delivery.
|
||||
func (opts *BotWebHookOpts) SetIPAddress(ip string) *BotWebHookOpts {
|
||||
func (opts *BotWebhookOpts) SetIPAddress(ip string) *BotWebhookOpts {
|
||||
opts.IPAddress = ip
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetMaxConnections sets Telegram's maximum number of simultaneous webhook connections.
|
||||
func (opts *BotWebHookOpts) SetMaxConnections(max int8) *BotWebHookOpts {
|
||||
func (opts *BotWebhookOpts) SetMaxConnections(max int8) *BotWebhookOpts {
|
||||
opts.MaxConnections = max
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetAllowedUpdates sets the Telegram update types that should be delivered to the webhook.
|
||||
func (opts *BotWebHookOpts) SetAllowedUpdates(updates ...tgapi.UpdateType) *BotWebHookOpts {
|
||||
func (opts *BotWebhookOpts) SetAllowedUpdates(updates ...tgapi.UpdateType) *BotWebhookOpts {
|
||||
opts.AllowedUpdates = append([]tgapi.UpdateType(nil), updates...)
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetDropPendingUpdates configures whether Telegram should drop pending updates while setting the webhook.
|
||||
func (opts *BotWebHookOpts) SetDropPendingUpdates(drop bool) *BotWebHookOpts {
|
||||
func (opts *BotWebhookOpts) SetDropPendingUpdates(drop bool) *BotWebhookOpts {
|
||||
opts.DropPendingUpdates = drop
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetSecretToken sets the secret token expected in Telegram webhook requests.
|
||||
// The same token is also required to access /status when that endpoint is enabled.
|
||||
func (opts *BotWebHookOpts) SetSecretToken(secretToken string) *BotWebHookOpts {
|
||||
func (opts *BotWebhookOpts) SetSecretToken(secretToken string) *BotWebhookOpts {
|
||||
opts.SecretToken = secretToken
|
||||
return opts
|
||||
}
|
||||
|
||||
// RunWebHookWithContext registers the webhook and serves incoming updates until ctx is canceled.
|
||||
// RunWebhookWithContext registers the webhook and serves incoming updates until ctx is canceled.
|
||||
//
|
||||
// The bot uses the same update queue, worker pool, runner startup, and single-use lifecycle
|
||||
// guarantees as RunWithContext. When opts.AllowedUpdates is empty, the bot-level update types
|
||||
@@ -126,9 +126,9 @@ func (opts *BotWebHookOpts) SetSecretToken(secretToken string) *BotWebHookOpts {
|
||||
//
|
||||
// When two TLS files are provided, the method serves HTTPS using the existing key-then-cert
|
||||
// argument order.
|
||||
func (bot *Bot[T]) RunWebHookWithContext(ctx context.Context, opts *BotWebHookOpts, tlsFiles ...string) error {
|
||||
func (bot *Bot[T]) RunWebhookWithContext(ctx context.Context, opts *BotWebhookOpts, tlsFiles ...string) error {
|
||||
if opts == nil {
|
||||
return errors.New("nil BotWebHookOpts")
|
||||
return errors.New("nil BotWebhookOpts")
|
||||
}
|
||||
if len(bot.prefixes) == 0 {
|
||||
return ErrNoPrefixes
|
||||
@@ -137,44 +137,42 @@ func (bot *Bot[T]) RunWebHookWithContext(ctx context.Context, opts *BotWebHookOp
|
||||
return ErrNoPlugins
|
||||
}
|
||||
if opts.URL == "" {
|
||||
return errors.New("empty BotWebHookOpts.URL")
|
||||
return errors.New("empty BotWebhookOpts.URL")
|
||||
}
|
||||
if opts.MaxConnections > 100 || opts.MaxConnections <= 0 {
|
||||
return errors.New("BotWebHookOpts.MaxConnections must between 1 and 100")
|
||||
return errors.New("BotWebhookOpts.MaxConnections must between 1 and 100")
|
||||
}
|
||||
if err := validateWebhookPath(opts.Path, opts.UseStatusPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.UseStatusPath && opts.SecretToken == "" {
|
||||
return errors.New("BotWebHookOpts.SecretToken required when status path is enabled")
|
||||
return errors.New("BotWebhookOpts.SecretToken required when status path is enabled")
|
||||
}
|
||||
if err := validateWebhookTLSFiles(tlsFiles); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bot.webHookLogger = utils.CreateLogger("WEBHOOK", bot.GetLoggerLevel(), bot.logFormat, bot.logFormatter)
|
||||
bot.addTokenReplacer(bot.webHookLogger)
|
||||
if opts.SecretToken == "" {
|
||||
bot.webHookLogger.Warnln("Bot webhook secret token empty. It's VERY recommended to set secret.")
|
||||
}
|
||||
|
||||
if opts.Certificate != nil && bot.uploader == nil {
|
||||
return errors.New("bot uploader nil, but certificate set")
|
||||
}
|
||||
|
||||
return bot.runWebhookRuntime(ctx, func(runCtx context.Context) error {
|
||||
if opts.SecretToken == "" {
|
||||
bot.webhookLogger.Warnln("Bot webhook secret token empty. It's VERY recommended to set secret.")
|
||||
}
|
||||
|
||||
i, err := bot.api.GetWebhookInfoWithContext(runCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if i.URL == "" {
|
||||
bot.webHookLogger.Warnln("API returned webhook info with empty URL. There may be a long-poll")
|
||||
bot.webhookLogger.Warnln("API returned webhook info with empty URL. There may be a long-poll")
|
||||
} else {
|
||||
_, err = bot.api.DeleteWebhookWithContext(runCtx, tgapi.DeleteWebhook{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bot.webHookLogger.Infof("Bot webhook deleted: %s", i.URL)
|
||||
bot.webhookLogger.Infof("Bot webhook deleted: %s", i.URL)
|
||||
}
|
||||
|
||||
allowedUpdates := bot.webhookAllowedUpdates(opts)
|
||||
@@ -207,48 +205,48 @@ func (bot *Bot[T]) RunWebHookWithContext(ctx context.Context, opts *BotWebHookOp
|
||||
}
|
||||
|
||||
if len(tlsFiles) == 2 {
|
||||
return bot.runWebHookTLS(runCtx, opts, tlsFiles[0], tlsFiles[1])
|
||||
return bot.runWebhookTLS(runCtx, opts, tlsFiles[0], tlsFiles[1])
|
||||
}
|
||||
|
||||
return bot.runWebHook(runCtx, opts)
|
||||
return bot.runWebhook(runCtx, opts)
|
||||
})
|
||||
}
|
||||
|
||||
// RunWebHook starts the webhook runtime with a background context.
|
||||
// RunWebhook starts the webhook runtime with a background context.
|
||||
//
|
||||
// It is shorthand for RunWebHookWithContext(context.Background(), opts, tlsFiles...).
|
||||
func (bot *Bot[T]) RunWebHook(opts *BotWebHookOpts, tlsFiles ...string) error {
|
||||
return bot.RunWebHookWithContext(context.Background(), opts, tlsFiles...)
|
||||
// It is shorthand for RunWebhookWithContext(context.Background(), opts, tlsFiles...).
|
||||
func (bot *Bot[T]) RunWebhook(opts *BotWebhookOpts, tlsFiles ...string) error {
|
||||
return bot.RunWebhookWithContext(context.Background(), opts, tlsFiles...)
|
||||
}
|
||||
|
||||
// CloseWebHook removes the current Telegram webhook registration.
|
||||
// CloseWebhook removes the current Telegram webhook registration.
|
||||
//
|
||||
// It is separate from Close, which only releases local resources.
|
||||
// Call it before switching a deployment from webhook delivery to polling.
|
||||
func (bot *Bot[T]) CloseWebHook() error {
|
||||
func (bot *Bot[T]) CloseWebhook() error {
|
||||
var e []error
|
||||
if bot.api == nil {
|
||||
e = append(e, errors.New("bot api nil"))
|
||||
} else {
|
||||
if _, err := bot.api.DeleteWebhook(tgapi.DeleteWebhook{}); err != nil {
|
||||
if bot.webHookLogger != nil {
|
||||
bot.webHookLogger.Errorf("Failed to close webhook: %s", err.Error())
|
||||
if bot.webhookLogger != nil {
|
||||
bot.webhookLogger.Errorf("Failed to close webhook: %s", err.Error())
|
||||
} else if bot.logger != nil {
|
||||
bot.logger.Errorf("Failed to close webhook: %s", err.Error())
|
||||
}
|
||||
e = append(e, err)
|
||||
}
|
||||
}
|
||||
if bot.webHookLogger != nil {
|
||||
if err := bot.webHookLogger.Close(); err != nil {
|
||||
if bot.webhookLogger != nil {
|
||||
if err := bot.webhookLogger.Close(); err != nil {
|
||||
e = append(e, err)
|
||||
}
|
||||
bot.webHookLogger = nil
|
||||
bot.webhookLogger = nil
|
||||
}
|
||||
return errors.Join(e...)
|
||||
}
|
||||
|
||||
func (bot *Bot[T]) webhookAllowedUpdates(opts *BotWebHookOpts) []tgapi.UpdateType {
|
||||
func (bot *Bot[T]) webhookAllowedUpdates(opts *BotWebhookOpts) []tgapi.UpdateType {
|
||||
if len(opts.AllowedUpdates) > 0 {
|
||||
return append([]tgapi.UpdateType(nil), opts.AllowedUpdates...)
|
||||
}
|
||||
@@ -264,6 +262,10 @@ func (bot *Bot[T]) runWebhookRuntime(ctx context.Context, run func(context.Conte
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
if bot.webhookLogger == nil {
|
||||
bot.webhookLogger = utils.CreateLogger("WEBHOOK", bot.GetLoggerLevel(), bot.logFormat, bot.logFormatter)
|
||||
}
|
||||
bot.addTokenReplacer(bot.webhookLogger)
|
||||
bot.ExecRunners(runCtx)
|
||||
|
||||
workersDone := make(chan struct{})
|
||||
@@ -315,12 +317,12 @@ func updateHandler[T any](ctx context.Context, bot *Bot[T], secret string) http.
|
||||
var up tgapi.Update
|
||||
if err := json.Unmarshal(data, &up); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
bot.webHookLogger.Errorln(err)
|
||||
bot.webhookLogger.Errorln(err)
|
||||
return
|
||||
}
|
||||
bot.webHookLogger.Debugf("UPDATE id=%d type=%s size=%d from=%s", up.UpdateID, up.Type, len(data), r.RemoteAddr)
|
||||
bot.webhookLogger.Debugf("UPDATE id=%d type=%s size=%d from=%s", up.UpdateID, up.Type, len(data), r.RemoteAddr)
|
||||
if err := bot.enqueueUpdate(ctx, up); err != nil {
|
||||
bot.webHookLogger.Errorln(err)
|
||||
bot.webhookLogger.Errorln(err)
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
@@ -328,7 +330,7 @@ func updateHandler[T any](ctx context.Context, bot *Bot[T], secret string) http.
|
||||
}
|
||||
}
|
||||
|
||||
func statusHandler[T any](bot *Bot[T], opts *BotWebHookOpts) http.HandlerFunc {
|
||||
func statusHandler[T any](bot *Bot[T], opts *BotWebhookOpts) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := ""
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
@@ -343,24 +345,24 @@ func statusHandler[T any](bot *Bot[T], opts *BotWebHookOpts) http.HandlerFunc {
|
||||
|
||||
i, err := bot.api.GetWebhookInfoWithContext(r.Context())
|
||||
if err != nil {
|
||||
bot.webHookLogger.Errorln(err)
|
||||
bot.webhookLogger.Errorln(err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data, err := json.MarshalIndent(i, "", " ")
|
||||
if err != nil {
|
||||
bot.webHookLogger.Errorln(err)
|
||||
bot.webhookLogger.Errorln(err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if _, err := fmt.Fprint(w, string(data)); err != nil {
|
||||
bot.webHookLogger.Errorln(err)
|
||||
bot.webhookLogger.Errorln(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (bot *Bot[T]) newWebHookMux(ctx context.Context, opts *BotWebHookOpts) *http.ServeMux {
|
||||
func (bot *Bot[T]) newWebhookMux(ctx context.Context, opts *BotWebhookOpts) *http.ServeMux {
|
||||
r := http.NewServeMux()
|
||||
if opts.UseStatusPath {
|
||||
r.HandleFunc("/status", statusHandler(bot, opts))
|
||||
@@ -368,10 +370,10 @@ func (bot *Bot[T]) newWebHookMux(ctx context.Context, opts *BotWebHookOpts) *htt
|
||||
r.HandleFunc(opts.Path, updateHandler(ctx, bot, opts.SecretToken))
|
||||
return r
|
||||
}
|
||||
func (bot *Bot[T]) runWebHook(ctx context.Context, opts *BotWebHookOpts) error {
|
||||
func (bot *Bot[T]) runWebhook(ctx context.Context, opts *BotWebhookOpts) error {
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", opts.LocalPort),
|
||||
Handler: bot.newWebHookMux(ctx, opts),
|
||||
Handler: bot.newWebhookMux(ctx, opts),
|
||||
}
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
@@ -384,7 +386,7 @@ func (bot *Bot[T]) runWebHook(ctx context.Context, opts *BotWebHookOpts) error {
|
||||
errCh <- nil
|
||||
}()
|
||||
|
||||
bot.webHookLogger.Infoln(fmt.Sprintf("Bot WebHook started at %s; waiting for updates at %s", srv.Addr, opts.URL))
|
||||
bot.webhookLogger.Infoln(fmt.Sprintf("Bot Webhook started at %s; waiting for updates at %s", srv.Addr, opts.URL))
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -401,10 +403,10 @@ func (bot *Bot[T]) runWebHook(ctx context.Context, opts *BotWebHookOpts) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
func (bot *Bot[T]) runWebHookTLS(ctx context.Context, opts *BotWebHookOpts, key, cert string) error {
|
||||
func (bot *Bot[T]) runWebhookTLS(ctx context.Context, opts *BotWebhookOpts, key, cert string) error {
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", opts.LocalPort),
|
||||
Handler: bot.newWebHookMux(ctx, opts),
|
||||
Handler: bot.newWebhookMux(ctx, opts),
|
||||
}
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
@@ -417,7 +419,7 @@ func (bot *Bot[T]) runWebHookTLS(ctx context.Context, opts *BotWebHookOpts, key,
|
||||
errCh <- nil
|
||||
}()
|
||||
|
||||
bot.webHookLogger.Infoln(fmt.Sprintf("Bot webhook started with TLS(%s, %s) at %s; waiting for updates at %s", key, cert, srv.Addr, opts.URL))
|
||||
bot.webhookLogger.Infoln(fmt.Sprintf("Bot webhook started with TLS(%s, %s) at %s; waiting for updates at %s", key, cert, srv.Addr, opts.URL))
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -436,16 +438,16 @@ func (bot *Bot[T]) runWebHookTLS(ctx context.Context, opts *BotWebHookOpts, key,
|
||||
}
|
||||
func validateWebhookPath(path string, useStatusPath bool) error {
|
||||
if path == "" {
|
||||
return errors.New("empty BotWebHookOpts.Path")
|
||||
return errors.New("empty BotWebhookOpts.Path")
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
return errors.New("BotWebHookOpts.Path must start with '/'")
|
||||
return errors.New("BotWebhookOpts.Path must start with '/'")
|
||||
}
|
||||
if strings.Contains(path, "?") || strings.Contains(path, "#") {
|
||||
return errors.New("BotWebHookOpts.Path must not contain query or fragment")
|
||||
return errors.New("BotWebhookOpts.Path must not contain query or fragment")
|
||||
}
|
||||
if useStatusPath && path == "/status" {
|
||||
return errors.New("BotWebHookOpts.Path must not be '/status' when status path is enabled")
|
||||
return errors.New("BotWebhookOpts.Path must not be '/status' when status path is enabled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+42
-19
@@ -36,10 +36,10 @@ func TestEnqueueUpdateCopiesValue(t *testing.T) {
|
||||
func TestUpdateHandlerEnqueuesUpdate(t *testing.T) {
|
||||
bot := &Bot[NoData]{
|
||||
updateQueue: make(chan *tgapi.Update, 1),
|
||||
webHookLogger: sneklog.NewLogger(),
|
||||
webhookLogger: sneklog.NewLogger(),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = bot.webHookLogger.Close()
|
||||
_ = bot.webhookLogger.Close()
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"update_id":7,"message":{"message_id":1,"date":1,"chat":{"id":1,"type":"private"},"text":"/start"}}`))
|
||||
@@ -94,7 +94,7 @@ func TestRunWebhookRuntimeExecutesRunners(t *testing.T) {
|
||||
NewRunner("runner", func(bot *Bot[NoData]) error {
|
||||
calls.Add(1)
|
||||
return nil
|
||||
}).Onetime(true).Async(false),
|
||||
}).Once(true).Async(false),
|
||||
},
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
@@ -109,17 +109,40 @@ func TestRunWebhookRuntimeExecutesRunners(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWebhookRuntimePreservesConfiguredWebhookLogger(t *testing.T) {
|
||||
webhookLogger := sneklog.NewLogger()
|
||||
bot := &Bot[NoData]{
|
||||
logger: sneklog.NewLogger(),
|
||||
webhookLogger: webhookLogger,
|
||||
updateQueue: make(chan *tgapi.Update, 1),
|
||||
maxWorkers: 1,
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = bot.logger.Close()
|
||||
if bot.webhookLogger != nil {
|
||||
_ = bot.webhookLogger.Close()
|
||||
}
|
||||
})
|
||||
|
||||
if err := bot.runWebhookRuntime(context.Background(), func(context.Context) error { return nil }); err != nil {
|
||||
t.Fatalf("runWebhookRuntime returned error: %v", err)
|
||||
}
|
||||
if bot.webhookLogger != webhookLogger {
|
||||
t.Fatal("expected runWebhookRuntime to preserve configured webhook logger")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWebhookRuntimeProcessesEnqueuedUpdate(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
plugin := NewPlugin[NoData]("demo")
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
|
||||
plugin.Command("start", func(ctx *MsgContext, db NoData) error {
|
||||
calls.Add(1)
|
||||
return nil
|
||||
}, "start")
|
||||
})
|
||||
|
||||
bot := &Bot[NoData]{
|
||||
logger: sneklog.NewLogger(),
|
||||
webHookLogger: sneklog.NewLogger(),
|
||||
webhookLogger: sneklog.NewLogger(),
|
||||
prefixes: []string{"/"},
|
||||
plugins: []Plugin[NoData]{*plugin},
|
||||
updateQueue: make(chan *tgapi.Update, 1),
|
||||
@@ -127,7 +150,7 @@ func TestRunWebhookRuntimeProcessesEnqueuedUpdate(t *testing.T) {
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = bot.logger.Close()
|
||||
_ = bot.webHookLogger.Close()
|
||||
_ = bot.webhookLogger.Close()
|
||||
})
|
||||
|
||||
err := bot.runWebhookRuntime(context.Background(), func(ctx context.Context) error {
|
||||
@@ -162,7 +185,7 @@ func TestWebhookAllowedUpdatesUsesBotUpdateTypesByDefault(t *testing.T) {
|
||||
bot := &Bot[NoData]{
|
||||
updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery},
|
||||
}
|
||||
opts := NewBotWebHookOpts()
|
||||
opts := NewBotWebhookOpts()
|
||||
|
||||
got := bot.webhookAllowedUpdates(opts)
|
||||
if len(got) != 2 {
|
||||
@@ -235,10 +258,10 @@ func TestValidateWebhookTLSFiles(t *testing.T) {
|
||||
func TestUpdateHandlerRejectsOversizedBody(t *testing.T) {
|
||||
bot := &Bot[NoData]{
|
||||
updateQueue: make(chan *tgapi.Update, 1),
|
||||
webHookLogger: sneklog.NewLogger(),
|
||||
webhookLogger: sneklog.NewLogger(),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = bot.webHookLogger.Close()
|
||||
_ = bot.webhookLogger.Close()
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("a", (256<<10)+1)))
|
||||
@@ -272,13 +295,13 @@ func TestStatusHandlerRequiresMatchingSecret(t *testing.T) {
|
||||
|
||||
bot := &Bot[NoData]{
|
||||
api: api,
|
||||
webHookLogger: sneklog.NewLogger(),
|
||||
webhookLogger: sneklog.NewLogger(),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = bot.webHookLogger.Close()
|
||||
_ = bot.webhookLogger.Close()
|
||||
})
|
||||
|
||||
handler := statusHandler(bot, &BotWebHookOpts{SecretToken: "secret"})
|
||||
handler := statusHandler(bot, &BotWebhookOpts{SecretToken: "secret"})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -308,14 +331,14 @@ func TestStatusHandlerRequiresMatchingSecret(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWebHookWithContextRejectsInvalidTLSFilesBeforeRemoteSetup(t *testing.T) {
|
||||
func TestRunWebhookWithContextRejectsInvalidTLSFilesBeforeRemoteSetup(t *testing.T) {
|
||||
bot := &Bot[NoData]{
|
||||
prefixes: []string{"/"},
|
||||
plugins: []Plugin[NoData]{{name: "demo"}},
|
||||
}
|
||||
opts := NewBotWebHookOpts().SetURL("https://bot.example.com")
|
||||
opts := NewBotWebhookOpts().SetURL("https://bot.example.com")
|
||||
|
||||
err := bot.RunWebHookWithContext(context.Background(), opts, "cert.pem")
|
||||
err := bot.RunWebhookWithContext(context.Background(), opts, "cert.pem")
|
||||
if err == nil {
|
||||
t.Fatal("expected tls validation error, got nil")
|
||||
}
|
||||
@@ -324,16 +347,16 @@ func TestRunWebHookWithContextRejectsInvalidTLSFilesBeforeRemoteSetup(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWebHookWithContextRequiresSecretWhenStatusPathEnabled(t *testing.T) {
|
||||
func TestRunWebhookWithContextRequiresSecretWhenStatusPathEnabled(t *testing.T) {
|
||||
bot := &Bot[NoData]{
|
||||
prefixes: []string{"/"},
|
||||
plugins: []Plugin[NoData]{{name: "demo"}},
|
||||
}
|
||||
opts := NewBotWebHookOpts().
|
||||
opts := NewBotWebhookOpts().
|
||||
SetURL("https://bot.example.com").
|
||||
SetUseStatusPath(true)
|
||||
|
||||
err := bot.RunWebHookWithContext(context.Background(), opts)
|
||||
err := bot.RunWebhookWithContext(context.Background(), opts)
|
||||
if err == nil {
|
||||
t.Fatal("expected status-path secret validation error, got nil")
|
||||
}
|
||||
|
||||
+3
-3
@@ -10,8 +10,8 @@ import (
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
)
|
||||
|
||||
// CmdRegexp matches command names allowed for Telegram command registration.
|
||||
var CmdRegexp = regexp.MustCompile("^[_a-z0-9]{1,32}$")
|
||||
// cmdRegexp matches command names allowed for Telegram command registration.
|
||||
var cmdRegexp = regexp.MustCompile("^[_a-z0-9]{1,32}$")
|
||||
|
||||
// ErrTooManyCommands is returned when the total number of registered commands
|
||||
// exceeds Telegram's limit of 100 bot commands per bot.
|
||||
@@ -46,7 +46,7 @@ func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand {
|
||||
}
|
||||
|
||||
// Internal helper to validate Telegram command names.
|
||||
func checkCmdRegex(cmd string) bool { return CmdRegexp.MatchString(cmd) }
|
||||
func checkCmdRegex(cmd string) bool { return cmdRegexp.MatchString(cmd) }
|
||||
|
||||
// Internal helper to collect non-skipped, valid commands from one plugin.
|
||||
func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand {
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) {
|
||||
plugin := NewPlugin[NoData]("overflow")
|
||||
exec := func(ctx *MsgContext, db NoData) error { return nil }
|
||||
for i := 0; i < 101; i++ {
|
||||
plugin.AddCommand(NewCommand(exec, "cmd"+strconv.Itoa(i)))
|
||||
plugin.Command("cmd"+strconv.Itoa(i), exec)
|
||||
}
|
||||
|
||||
bot := &Bot[NoData]{
|
||||
@@ -68,9 +68,9 @@ func TestGatherCommandsForPluginReturnsSortedCommands(t *testing.T) {
|
||||
plugin := NewPlugin[NoData]("sorted")
|
||||
exec := func(ctx *MsgContext, db NoData) error { return nil }
|
||||
|
||||
plugin.AddCommand(NewCommand(exec, "zeta"))
|
||||
plugin.AddCommand(NewCommand(exec, "alpha"))
|
||||
plugin.AddCommand(NewCommand(exec, "mid"))
|
||||
plugin.Command("zeta", exec)
|
||||
plugin.Command("alpha", exec)
|
||||
plugin.Command("mid", exec)
|
||||
|
||||
commands := gatherCommandsForPlugin(*plugin)
|
||||
got := make([]string, 0, len(commands))
|
||||
|
||||
+24
-32
@@ -2,7 +2,6 @@ package laniakea
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"git.scuroneko.dev/scuroneko/extypes"
|
||||
@@ -12,14 +11,14 @@ import (
|
||||
type CommandValueType string
|
||||
|
||||
const (
|
||||
// CommandValueStringType expects any non-empty string.
|
||||
CommandValueStringType CommandValueType = "string"
|
||||
// CommandValueIntType expects a decimal integer (digits only).
|
||||
CommandValueIntType CommandValueType = "int"
|
||||
// CommandValueBoolType expects a exact "true" or "false".
|
||||
CommandValueBoolType CommandValueType = "bool"
|
||||
// CommandValueAnyType accepts any input without validation.
|
||||
CommandValueAnyType CommandValueType = "any"
|
||||
// CommandValueString expects any non-empty string.
|
||||
CommandValueString CommandValueType = "string"
|
||||
// CommandValueInt expects a decimal integer (digits only).
|
||||
CommandValueInt CommandValueType = "int"
|
||||
// CommandValueBool expects an exact "true" or "false".
|
||||
CommandValueBool CommandValueType = "bool"
|
||||
// CommandValueAny accepts any input without validation.
|
||||
CommandValueAny CommandValueType = "any"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -52,22 +51,23 @@ type CommandArg struct {
|
||||
required bool // Whether this argument must be provided
|
||||
}
|
||||
|
||||
// NewCommandArg creates a new CommandArg with the given text and type.
|
||||
// Uses a default regex based on the type (string or int).
|
||||
// For CommandValueAnyType, no validation is performed.
|
||||
// NewCommandArg creates an optional argument without value validation.
|
||||
func NewCommandArg(text string) CommandArg {
|
||||
return CommandArg{CommandValueAnyType, text, CommandRegexString, false}
|
||||
return CommandArg{CommandValueAny, text, nil, false}
|
||||
}
|
||||
|
||||
// SetValueType sets expected value type and switches built-in validation regexp.
|
||||
func (c CommandArg) SetValueType(t CommandValueType) CommandArg {
|
||||
regex := CommandRegexString
|
||||
var regex *regexp.Regexp
|
||||
switch t {
|
||||
case CommandValueIntType:
|
||||
case CommandValueInt:
|
||||
regex = CommandRegexInt
|
||||
case CommandValueBoolType:
|
||||
case CommandValueBool:
|
||||
regex = CommandRegexBool
|
||||
case CommandValueAnyType:
|
||||
case CommandValueString:
|
||||
regex = CommandRegexString
|
||||
case CommandValueAny:
|
||||
default:
|
||||
regex = nil // Skip validation
|
||||
}
|
||||
c.valueType = t
|
||||
@@ -98,15 +98,15 @@ type Command[T AppData] struct {
|
||||
skipAutoCmd bool // If true, this command won't be auto-added to help menus
|
||||
}
|
||||
|
||||
// NewCommand creates a new Command with the given executor, command string, and arguments.
|
||||
// NewCommand creates a new Command with the given command string, executor, and arguments.
|
||||
// The command string should not include the leading slash (e.g., "start", not "/start").
|
||||
func NewCommand[T any](exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] {
|
||||
func NewCommand[T any](command string, exec CommandExecutor[T], args ...CommandArg) *Command[T] {
|
||||
return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false}
|
||||
}
|
||||
|
||||
// NewPayload creates a new Command with the given executor, command payload string, and arguments.
|
||||
// NewPayload creates a new callback payload handler command.
|
||||
// The command string can contain any symbols, but it is recommended to use only "_", "-", ".", a-z, A-Z, and 0-9.
|
||||
func NewPayload[T any](exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] {
|
||||
func NewPayload[T any](command string, exec CommandExecutor[T], args ...CommandArg) *Command[T] {
|
||||
return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false}
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ func (c *Command[T]) validateArgs(args []string) error {
|
||||
}
|
||||
cmdArg := c.args.Get(i)
|
||||
if cmdArg.regex == nil {
|
||||
continue // Skip validation for CommandValueAnyType
|
||||
continue // Skip validation for CommandValueAny.
|
||||
}
|
||||
if !cmdArg.regex.MatchString(arg) {
|
||||
return ErrCmdArgRegexpMismatch
|
||||
@@ -168,8 +168,6 @@ func (c *Command[T]) clone() *Command[T] {
|
||||
// CommandGroup builds a set of commands with a shared name prefix and middleware.
|
||||
type CommandGroup[T any] struct {
|
||||
prefix string
|
||||
separator string
|
||||
|
||||
middlewares extypes.Slice[Middleware[T]]
|
||||
commands extypes.Slice[*Command[T]]
|
||||
}
|
||||
@@ -177,19 +175,13 @@ type CommandGroup[T any] struct {
|
||||
// NewCommandGroup creates a command group that prefixes every added command.
|
||||
func NewCommandGroup[T any](prefix string) *CommandGroup[T] {
|
||||
return &CommandGroup[T]{
|
||||
prefix: prefix, separator: "",
|
||||
prefix: prefix,
|
||||
|
||||
middlewares: make([]Middleware[T], 0),
|
||||
commands: make([]*Command[T], 0),
|
||||
}
|
||||
}
|
||||
|
||||
// SetSeparator sets the text inserted between the group prefix and command name.
|
||||
func (g *CommandGroup[T]) SetSeparator(separator string) *CommandGroup[T] {
|
||||
g.separator = separator
|
||||
return g
|
||||
}
|
||||
|
||||
// Use adds middleware that runs before each command's own middleware.
|
||||
func (g *CommandGroup[T]) Use(m Middleware[T]) *CommandGroup[T] {
|
||||
g.middlewares = append(g.middlewares, m)
|
||||
@@ -202,7 +194,7 @@ func (g *CommandGroup[T]) AddCommand(cmd *Command[T]) *CommandGroup[T] {
|
||||
return g
|
||||
}
|
||||
newCmd := cmd.clone()
|
||||
newCmd.command = fmt.Sprintf("%s%s%s", g.prefix, g.separator, cmd.command)
|
||||
newCmd.command = g.prefix + cmd.command
|
||||
g.commands = g.commands.Push(newCmd)
|
||||
return g
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ Example usage:
|
||||
|
||||
return bot.Run()
|
||||
|
||||
Configure bots, plugins, and localization before starting Run, RunWithContext, or RunWebHookWithContext.
|
||||
Configure bots, plugins, and localization before starting Run, RunWithContext, or RunWebhookWithContext.
|
||||
Runtime accessors are safe for concurrent use unless stated otherwise.
|
||||
*/
|
||||
package laniakea
|
||||
|
||||
+22
-22
@@ -757,10 +757,10 @@ func TestHandleMessageFallbackDoesNotRunWhenCommandMatches(t *testing.T) {
|
||||
commandCalled := false
|
||||
fallbackCalled := false
|
||||
plugin := NewPlugin[NoData]("test")
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
|
||||
plugin.Command("start", func(ctx *MsgContext, db NoData) error {
|
||||
commandCalled = true
|
||||
return nil
|
||||
}, "start")
|
||||
})
|
||||
plugin.SetMessageFallback(func(ctx *MsgContext, db NoData) error {
|
||||
fallbackCalled = true
|
||||
return nil
|
||||
@@ -794,7 +794,7 @@ func TestHandleMessageFallbackDoesNotRunWhenCommandMatches(t *testing.T) {
|
||||
func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
|
||||
called := false
|
||||
plugin := NewPlugin[NoData]("test")
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
|
||||
plugin.Command("ping", func(ctx *MsgContext, db NoData) error {
|
||||
called = true
|
||||
if ctx.Msg == nil {
|
||||
t.Fatal("expected message context")
|
||||
@@ -809,7 +809,7 @@ func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
|
||||
t.Fatalf("expected zero FromID for sender_chat updates, got %d", ctx.FromID)
|
||||
}
|
||||
return nil
|
||||
}, "ping")
|
||||
})
|
||||
|
||||
bot := &Bot[NoData]{
|
||||
logger: sneklog.NewLogger(),
|
||||
@@ -841,10 +841,10 @@ func TestCommandHandlerBindArgsEndToEnd(t *testing.T) {
|
||||
|
||||
var got banInput
|
||||
plugin := NewPlugin[NoData]("test")
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
|
||||
plugin.Command("ban", func(ctx *MsgContext, db NoData) error {
|
||||
return ctx.BindArgs(&got)
|
||||
}, "ban",
|
||||
NewCommandArg("user_id").SetValueType(CommandValueIntType).SetRequired(),
|
||||
},
|
||||
NewCommandArg("user_id").SetValueType(CommandValueInt).SetRequired(),
|
||||
NewCommandArg("reason").SetRequired(),
|
||||
)
|
||||
|
||||
@@ -878,10 +878,10 @@ func TestPayloadHandlerBindArgsEndToEnd(t *testing.T) {
|
||||
|
||||
var got payloadInput
|
||||
plugin := NewPlugin[NoData]("test")
|
||||
plugin.NewPayload(func(ctx *MsgContext, db NoData) error {
|
||||
plugin.Payload("approve", func(ctx *MsgContext, db NoData) error {
|
||||
return ctx.BindArgs(&got)
|
||||
}, "approve",
|
||||
NewCommandArg("id").SetValueType(CommandValueIntType).SetRequired(),
|
||||
},
|
||||
NewCommandArg("id").SetValueType(CommandValueInt).SetRequired(),
|
||||
NewCommandArg("note").SetRequired(),
|
||||
)
|
||||
|
||||
@@ -920,10 +920,10 @@ func TestHandleEditedMessageStaysOutOfCommandFlow(t *testing.T) {
|
||||
updateCalled := false
|
||||
|
||||
plugin := NewPlugin[NoData]("test")
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
|
||||
plugin.Command("ping", func(ctx *MsgContext, db NoData) error {
|
||||
commandCalled = true
|
||||
return nil
|
||||
}, "ping")
|
||||
})
|
||||
plugin.AddUpdateHandler(tgapi.UpdateTypeEditedMessage, func(ctx *MsgContext, db NoData) error {
|
||||
updateCalled = true
|
||||
if ctx.Msg == nil {
|
||||
@@ -968,10 +968,10 @@ func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) {
|
||||
updateCalled := false
|
||||
|
||||
plugin := NewPlugin[NoData]("test")
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
|
||||
plugin.Command("ping", func(ctx *MsgContext, db NoData) error {
|
||||
commandCalled = true
|
||||
return nil
|
||||
}, "ping")
|
||||
})
|
||||
plugin.AddUpdateHandler(tgapi.UpdateTypeEditedChannelPost, func(ctx *MsgContext, db NoData) error {
|
||||
updateCalled = true
|
||||
if ctx.Msg == nil {
|
||||
@@ -1007,7 +1007,7 @@ func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) {
|
||||
func TestHandleCallbackPopulatesMessageTargets(t *testing.T) {
|
||||
called := false
|
||||
plugin := NewPlugin[NoData]("test")
|
||||
plugin.NewPayload(func(ctx *MsgContext, db NoData) error {
|
||||
plugin.Payload("approve", func(ctx *MsgContext, db NoData) error {
|
||||
called = true
|
||||
if ctx.CallbackQueryID != "cb-msg" {
|
||||
t.Fatalf("unexpected CallbackQueryID: %q", ctx.CallbackQueryID)
|
||||
@@ -1031,7 +1031,7 @@ func TestHandleCallbackPopulatesMessageTargets(t *testing.T) {
|
||||
t.Fatalf("unexpected callback args: got %v want %v", got, want)
|
||||
}
|
||||
return nil
|
||||
}, "approve")
|
||||
})
|
||||
|
||||
bot := &Bot[NoData]{
|
||||
logger: sneklog.NewLogger(),
|
||||
@@ -1066,7 +1066,7 @@ func TestHandleCallbackPopulatesMessageTargets(t *testing.T) {
|
||||
func TestHandleCallbackPopulatesInlineTargets(t *testing.T) {
|
||||
called := false
|
||||
plugin := NewPlugin[NoData]("test")
|
||||
plugin.NewPayload(func(ctx *MsgContext, db NoData) error {
|
||||
plugin.Payload("inline.approve", func(ctx *MsgContext, db NoData) error {
|
||||
called = true
|
||||
if ctx.CallbackQueryID != "cb-inline" {
|
||||
t.Fatalf("unexpected CallbackQueryID: %q", ctx.CallbackQueryID)
|
||||
@@ -1090,7 +1090,7 @@ func TestHandleCallbackPopulatesInlineTargets(t *testing.T) {
|
||||
t.Fatalf("unexpected callback args: got %v want %v", got, want)
|
||||
}
|
||||
return nil
|
||||
}, "inline.approve")
|
||||
})
|
||||
|
||||
bot := &Bot[NoData]{
|
||||
logger: sneklog.NewLogger(),
|
||||
@@ -1122,9 +1122,9 @@ func TestHandleCallbackPopulatesInlineTargets(t *testing.T) {
|
||||
func TestHandleCallbackObserverEmitsPayloadEvents(t *testing.T) {
|
||||
observer := &recordingObserver{}
|
||||
plugin := NewPlugin[NoData]("test")
|
||||
plugin.NewPayload(func(ctx *MsgContext, db NoData) error {
|
||||
plugin.Payload("approve", func(ctx *MsgContext, db NoData) error {
|
||||
return nil
|
||||
}, "approve")
|
||||
})
|
||||
|
||||
bot := &Bot[NoData]{
|
||||
logger: sneklog.NewLogger(),
|
||||
@@ -1173,9 +1173,9 @@ func TestHandleCallbackObserverEmitsPayloadErrors(t *testing.T) {
|
||||
observer := &recordingObserver{}
|
||||
plugin := NewPlugin[NoData]("test")
|
||||
wantErr := AsInternalError(errors.New("boom"))
|
||||
plugin.NewPayload(func(ctx *MsgContext, db NoData) error {
|
||||
plugin.Payload("approve", func(ctx *MsgContext, db NoData) error {
|
||||
return wantErr
|
||||
}, "approve")
|
||||
})
|
||||
|
||||
bot := &Bot[NoData]{
|
||||
logger: sneklog.NewLogger(),
|
||||
|
||||
+14
-14
@@ -16,9 +16,9 @@ const (
|
||||
ButtonStylePrimary tgapi.KeyboardButtonStyle = "primary"
|
||||
)
|
||||
|
||||
// InlineKbButtonBuilder is a fluent builder for creating a single inline keyboard button.
|
||||
// InlineKeyboardButtonBuilder is a fluent builder for creating a single inline keyboard button.
|
||||
//
|
||||
// Use NewInlineKbButton() to start, then chain methods to configure:
|
||||
// Use NewInlineKeyboardButton() to start, then chain methods to configure:
|
||||
// - SetIconCustomEmojiID() — adds a custom emoji icon
|
||||
// - SetStyle() — sets visual style (danger/success/primary)
|
||||
// - SetURL() — makes button open a URL
|
||||
@@ -26,7 +26,7 @@ const (
|
||||
//
|
||||
// Call build() to produce the final tgapi.InlineKeyboardButton.
|
||||
// Builder methods are immutable — each returns a copy.
|
||||
type InlineKbButtonBuilder struct {
|
||||
type InlineKeyboardButtonBuilder struct {
|
||||
text string
|
||||
iconCustomEmojiID string
|
||||
style tgapi.KeyboardButtonStyle
|
||||
@@ -34,15 +34,15 @@ type InlineKbButtonBuilder struct {
|
||||
callbackData string
|
||||
}
|
||||
|
||||
// NewInlineKbButton creates a new button builder with the given display text.
|
||||
// NewInlineKeyboardButton creates a new button builder with the given display text.
|
||||
// The button will have no URL, no style, and no callback data by default.
|
||||
func NewInlineKbButton(text string) InlineKbButtonBuilder {
|
||||
return InlineKbButtonBuilder{text: text}
|
||||
func NewInlineKeyboardButton(text string) InlineKeyboardButtonBuilder {
|
||||
return InlineKeyboardButtonBuilder{text: text}
|
||||
}
|
||||
|
||||
// SetIconCustomEmojiID sets a custom emoji ID to display as the button's icon.
|
||||
// This is a Telegram Bot API feature for custom emoji icons.
|
||||
func (b InlineKbButtonBuilder) SetIconCustomEmojiID(id string) InlineKbButtonBuilder {
|
||||
func (b InlineKeyboardButtonBuilder) SetIconCustomEmojiID(id string) InlineKeyboardButtonBuilder {
|
||||
b.iconCustomEmojiID = id
|
||||
return b
|
||||
}
|
||||
@@ -50,14 +50,14 @@ func (b InlineKbButtonBuilder) SetIconCustomEmojiID(id string) InlineKbButtonBui
|
||||
// SetStyle sets the visual style of the button.
|
||||
// Valid values: ButtonStyleDanger, ButtonStyleSuccess, ButtonStylePrimary.
|
||||
// If not set, the button uses the default style.
|
||||
func (b InlineKbButtonBuilder) SetStyle(style tgapi.KeyboardButtonStyle) InlineKbButtonBuilder {
|
||||
func (b InlineKeyboardButtonBuilder) SetStyle(style tgapi.KeyboardButtonStyle) InlineKeyboardButtonBuilder {
|
||||
b.style = style
|
||||
return b
|
||||
}
|
||||
|
||||
// SetURL sets a URL that will be opened when the button is pressed.
|
||||
// If both URL and CallbackData are set, Telegram will prioritize URL.
|
||||
func (b InlineKbButtonBuilder) SetURL(url string) InlineKbButtonBuilder {
|
||||
func (b InlineKeyboardButtonBuilder) SetURL(url string) InlineKeyboardButtonBuilder {
|
||||
b.url = url
|
||||
return b
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func (b InlineKbButtonBuilder) SetURL(url string) InlineKbButtonBuilder {
|
||||
// are safely serialized, but complex structs may not serialize usefully.
|
||||
//
|
||||
// Example: SetCallbackDataJSON("delete_user", 123, "confirm") → {"cmd":"delete_user","args":["123","confirm"]}.
|
||||
func (b InlineKbButtonBuilder) SetCallbackDataJSON(cmd string, args ...any) InlineKbButtonBuilder {
|
||||
func (b InlineKeyboardButtonBuilder) SetCallbackDataJSON(cmd string, args ...any) InlineKeyboardButtonBuilder {
|
||||
b.callbackData = NewCallbackData(cmd, args...).ToJSON()
|
||||
return b
|
||||
}
|
||||
@@ -77,13 +77,13 @@ func (b InlineKbButtonBuilder) SetCallbackDataJSON(cmd string, args ...any) Inli
|
||||
// SetCallbackDataBase64 sets a structured callback payload encoded as Base64.
|
||||
// This can be useful when the JSON payload exceeds Telegram's callback data length limit.
|
||||
// Args are converted to strings using fmt.Sprint.
|
||||
func (b InlineKbButtonBuilder) SetCallbackDataBase64(cmd string, args ...any) InlineKbButtonBuilder {
|
||||
func (b InlineKeyboardButtonBuilder) SetCallbackDataBase64(cmd string, args ...any) InlineKeyboardButtonBuilder {
|
||||
b.callbackData = NewCallbackData(cmd, args...).ToBase64()
|
||||
return b
|
||||
}
|
||||
|
||||
// Internal helper that converts the builder state into a Telegram button.
|
||||
func (b InlineKbButtonBuilder) build() tgapi.InlineKeyboardButton {
|
||||
func (b InlineKeyboardButtonBuilder) build() tgapi.InlineKeyboardButton {
|
||||
return tgapi.InlineKeyboardButton{
|
||||
Text: b.text,
|
||||
URL: b.url,
|
||||
@@ -194,9 +194,9 @@ func (in *InlineKeyboard) AddCallbackButtonStyle(text string, style tgapi.Keyboa
|
||||
})
|
||||
}
|
||||
|
||||
// AddButton adds a button pre-configured via InlineKbButtonBuilder.
|
||||
// AddButton adds a button pre-configured via InlineKeyboardButtonBuilder.
|
||||
// This is the most flexible way to create buttons with custom emoji, style, URL, and callback.
|
||||
func (in *InlineKeyboard) AddButton(b InlineKbButtonBuilder) *InlineKeyboard {
|
||||
func (in *InlineKeyboard) AddButton(b InlineKeyboardButtonBuilder) *InlineKeyboard {
|
||||
return in.append(b.build())
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ func TestInlineKeyboardWrapsRowsAndEncodesJSONPayloads(t *testing.T) {
|
||||
func TestInlineKeyboardBuilderPreservesConfiguredButtonFields(t *testing.T) {
|
||||
kb := NewInlineKeyboardBase64(3).
|
||||
AddButton(
|
||||
NewInlineKbButton("Docs").
|
||||
NewInlineKeyboardButton("Docs").
|
||||
SetStyle(ButtonStylePrimary).
|
||||
SetURL("https://example.test"),
|
||||
)
|
||||
|
||||
+21
-21
@@ -142,7 +142,7 @@ func (m *AnswerMessage) Edit(text string) *AnswerMessage {
|
||||
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
||||
// Unescaped input may cause Telegram API errors or broken formatting.
|
||||
func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage {
|
||||
return m.ctx.edit(m.MessageID, text, nil, tgapi.ParseMDV2)
|
||||
return m.ctx.edit(m.MessageID, text, nil, tgapi.ParseMarkdownV2)
|
||||
}
|
||||
|
||||
// Internal helper for editing callback-linked messages.
|
||||
@@ -163,7 +163,7 @@ func (ctx *MsgContext) EditCallback(text string, keyboard *InlineKeyboard) *Answ
|
||||
//
|
||||
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
||||
func (ctx *MsgContext) EditCallbackMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage {
|
||||
return ctx.editCallback(text, keyboard, tgapi.ParseMDV2)
|
||||
return ctx.editCallback(text, keyboard, tgapi.ParseMarkdownV2)
|
||||
}
|
||||
|
||||
// EditCallbackf formats a string using fmt.Sprintf and edits the callback message with plain text.
|
||||
@@ -175,7 +175,7 @@ func (ctx *MsgContext) EditCallbackf(format string, keyboard *InlineKeyboard, ar
|
||||
//
|
||||
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
||||
func (ctx *MsgContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyboard, args ...any) *AnswerMessage {
|
||||
return ctx.editCallback(fmt.Sprintf(format, args...), keyboard, tgapi.ParseMDV2)
|
||||
return ctx.editCallback(fmt.Sprintf(format, args...), keyboard, tgapi.ParseMarkdownV2)
|
||||
}
|
||||
|
||||
// Internal helper for media-caption edits.
|
||||
@@ -225,7 +225,7 @@ func (m *AnswerMessage) EditCaption(text string) *AnswerMessage {
|
||||
//
|
||||
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
||||
func (m *AnswerMessage) EditCaptionMarkdown(text string) *AnswerMessage {
|
||||
return m.ctx.editPhotoText(m.MessageID, text, nil, tgapi.ParseMDV2)
|
||||
return m.ctx.editPhotoText(m.MessageID, text, nil, tgapi.ParseMarkdownV2)
|
||||
}
|
||||
|
||||
// EditCaptionKeyboard edits the caption of a media message with a new inline keyboard (plain text).
|
||||
@@ -237,7 +237,7 @@ func (m *AnswerMessage) EditCaptionKeyboard(text string, kb *InlineKeyboard) *An
|
||||
//
|
||||
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
||||
func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeyboard) *AnswerMessage {
|
||||
return m.ctx.editPhotoText(m.MessageID, text, kb, tgapi.ParseMDV2)
|
||||
return m.ctx.editPhotoText(m.MessageID, text, kb, tgapi.ParseMarkdownV2)
|
||||
}
|
||||
|
||||
// Internal helper for message replies with optional keyboard and parse mode.
|
||||
@@ -292,7 +292,7 @@ func (ctx *MsgContext) AnswerLong(text string) []*AnswerMessage {
|
||||
//
|
||||
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
||||
func (ctx *MsgContext) AnswerMarkdown(text string) *AnswerMessage {
|
||||
return ctx.answer(text, nil, tgapi.ParseMDV2)
|
||||
return ctx.answer(text, nil, tgapi.ParseMarkdownV2)
|
||||
}
|
||||
|
||||
// Answerf formats a string using fmt.Sprintf and sends it as a plain text message.
|
||||
@@ -309,7 +309,7 @@ func (ctx *MsgContext) AnswerLongf(template string, args ...any) []*AnswerMessag
|
||||
//
|
||||
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
||||
func (ctx *MsgContext) AnswerfMarkdown(template string, args ...any) *AnswerMessage {
|
||||
return ctx.answer(fmt.Sprintf(template, args...), nil, tgapi.ParseMDV2)
|
||||
return ctx.answer(fmt.Sprintf(template, args...), nil, tgapi.ParseMarkdownV2)
|
||||
}
|
||||
|
||||
// Keyboard sends a message with an inline keyboard (plain text).
|
||||
@@ -328,7 +328,7 @@ func (ctx *MsgContext) KeyboardLong(text string, kb *InlineKeyboard) []*AnswerMe
|
||||
//
|
||||
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
||||
func (ctx *MsgContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage {
|
||||
return ctx.answer(text, keyboard, tgapi.ParseMDV2)
|
||||
return ctx.answer(text, keyboard, tgapi.ParseMarkdownV2)
|
||||
}
|
||||
|
||||
func (ctx *MsgContext) answerLong(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) []*AnswerMessage {
|
||||
@@ -415,7 +415,7 @@ func (ctx *MsgContext) AnswerPhoto(photoID, text string) *AnswerMessage {
|
||||
//
|
||||
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
||||
func (ctx *MsgContext) AnswerPhotoMarkdown(photoID, text string) *AnswerMessage {
|
||||
return ctx.answerPhoto(photoID, text, nil, tgapi.ParseMDV2)
|
||||
return ctx.answerPhoto(photoID, text, nil, tgapi.ParseMarkdownV2)
|
||||
}
|
||||
|
||||
// AnswerPhotoKeyboard sends a photo with caption and inline keyboard (plain text).
|
||||
@@ -427,7 +427,7 @@ func (ctx *MsgContext) AnswerPhotoKeyboard(photoID, text string, kb *InlineKeybo
|
||||
//
|
||||
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
||||
func (ctx *MsgContext) AnswerPhotoKeyboardMarkdown(photoID, text string, kb *InlineKeyboard) *AnswerMessage {
|
||||
return ctx.answerPhoto(photoID, text, kb, tgapi.ParseMDV2)
|
||||
return ctx.answerPhoto(photoID, text, kb, tgapi.ParseMarkdownV2)
|
||||
}
|
||||
|
||||
// AnswerPhotof formats a string and sends it as a photo caption (plain text).
|
||||
@@ -439,7 +439,7 @@ func (ctx *MsgContext) AnswerPhotof(photoID, template string, args ...any) *Answ
|
||||
//
|
||||
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
||||
func (ctx *MsgContext) AnswerPhotofMarkdown(photoID, template string, args ...any) *AnswerMessage {
|
||||
return ctx.answerPhoto(photoID, fmt.Sprintf(template, args...), nil, tgapi.ParseMDV2)
|
||||
return ctx.answerPhoto(photoID, fmt.Sprintf(template, args...), nil, tgapi.ParseMarkdownV2)
|
||||
}
|
||||
|
||||
// Internal helper that deletes a message by ID.
|
||||
@@ -487,17 +487,17 @@ func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// AnswerCbQuery answers the callback query with no text or alert.
|
||||
func (ctx *MsgContext) AnswerCbQuery() { ctx.answerCallbackQuery("", "", false) }
|
||||
// AnswerCallback answers the callback query with no text or alert.
|
||||
func (ctx *MsgContext) AnswerCallback() { ctx.answerCallbackQuery("", "", false) }
|
||||
|
||||
// AnswerCbQueryText answers the callback query with a text notification.
|
||||
func (ctx *MsgContext) AnswerCbQueryText(text string) { ctx.answerCallbackQuery("", text, false) }
|
||||
// AnswerCallbackText answers the callback query with a text notification.
|
||||
func (ctx *MsgContext) AnswerCallbackText(text string) { ctx.answerCallbackQuery("", text, false) }
|
||||
|
||||
// AnswerCbQueryAlert answers the callback query with a user-visible alert.
|
||||
func (ctx *MsgContext) AnswerCbQueryAlert(text string) { ctx.answerCallbackQuery("", text, true) }
|
||||
// AnswerCallbackAlert answers the callback query with a user-visible alert.
|
||||
func (ctx *MsgContext) AnswerCallbackAlert(text string) { ctx.answerCallbackQuery("", text, true) }
|
||||
|
||||
// AnswerCbQueryURL answers the callback query with a URL redirect.
|
||||
func (ctx *MsgContext) AnswerCbQueryURL(u string) { ctx.answerCallbackQuery(u, "", false) }
|
||||
// AnswerCallbackURL answers the callback query with a URL redirect.
|
||||
func (ctx *MsgContext) AnswerCallbackURL(u string) { ctx.answerCallbackQuery(u, "", false) }
|
||||
|
||||
// SendAction sends a chat action (typing, uploading_photo, etc.) to indicate bot activity.
|
||||
func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) {
|
||||
@@ -575,7 +575,7 @@ func (ctx *MsgContext) NewDraft() *Draft {
|
||||
// with Markdown V2 parse mode enabled.
|
||||
// Uses the API limiter to avoid rate limiting.
|
||||
func (ctx *MsgContext) NewDraftMarkdown() *Draft {
|
||||
return ctx.newDraft(tgapi.ParseMDV2)
|
||||
return ctx.newDraft(tgapi.ParseMarkdownV2)
|
||||
}
|
||||
|
||||
// Translate looks up a key in the current user's language.
|
||||
@@ -816,5 +816,5 @@ func (ctx *MsgContext) UpsertKeyboard(text string, keyboard *InlineKeyboard) *An
|
||||
|
||||
// UpsertKeyboardMarkdown edits a callback message or sends a new MarkdownV2 message with a keyboard.
|
||||
func (ctx *MsgContext) UpsertKeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage {
|
||||
return ctx.upsertKeyboard(text, keyboard, tgapi.ParseMDV2)
|
||||
return ctx.upsertKeyboard(text, keyboard, tgapi.ParseMarkdownV2)
|
||||
}
|
||||
|
||||
+32
-32
@@ -57,10 +57,10 @@ func (p *Plugin[T]) AddCommand(command *Command[T]) *Plugin[T] {
|
||||
return p
|
||||
}
|
||||
|
||||
// NewCommand creates and immediately adds a new command to the plugin.
|
||||
// Command creates and immediately adds a new command to the plugin.
|
||||
// Returns the created command for further configuration.
|
||||
func (p *Plugin[T]) NewCommand(exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] {
|
||||
cmd := NewCommand(exec, command, args...)
|
||||
func (p *Plugin[T]) Command(command string, exec CommandExecutor[T], args ...CommandArg) *Command[T] {
|
||||
cmd := NewCommand(command, exec, args...)
|
||||
p.AddCommand(cmd)
|
||||
return cmd
|
||||
}
|
||||
@@ -78,6 +78,33 @@ func (p *Plugin[T]) AddPayload(command *Command[T]) *Plugin[T] {
|
||||
return p
|
||||
}
|
||||
|
||||
// Payload creates and immediately adds a new payload command to the plugin.
|
||||
// Returns the created payload command for further configuration.
|
||||
func (p *Plugin[T]) Payload(command string, exec CommandExecutor[T], args ...CommandArg) *Command[T] {
|
||||
cmd := NewPayload(command, exec, args...)
|
||||
p.AddPayload(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// Scene creates, registers, and returns a new scene owned by the plugin.
|
||||
func (p *Plugin[T]) Scene(name string) *Scene[T] {
|
||||
scene := NewScene[T](name)
|
||||
scene.setPluginName(p.name)
|
||||
p.AddScene(scene)
|
||||
return scene
|
||||
}
|
||||
|
||||
// AddScene registers a multi-step scene in the plugin.
|
||||
func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] {
|
||||
if scene == nil {
|
||||
return p
|
||||
}
|
||||
scene.PluginName = p.name
|
||||
scene.setPluginName(p.name)
|
||||
p.scenes[scene.Name] = scene
|
||||
return p
|
||||
}
|
||||
|
||||
// CommandGroup configures and registers a prefixed command group.
|
||||
func (p *Plugin[T]) CommandGroup(prefix string, groupFunc func(group *CommandGroup[T])) *Plugin[T] {
|
||||
if groupFunc == nil {
|
||||
@@ -108,33 +135,6 @@ func (p *Plugin[T]) AddCommandGroup(group *CommandGroup[T]) *Plugin[T] {
|
||||
return p
|
||||
}
|
||||
|
||||
// NewPayload creates and immediately adds a new payload command to the plugin.
|
||||
// Returns the created payload command for further configuration.
|
||||
func (p *Plugin[T]) NewPayload(exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] {
|
||||
cmd := NewPayload(exec, command, args...)
|
||||
p.AddPayload(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// AddScene registers a multi-step scene in the plugin.
|
||||
func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] {
|
||||
if scene == nil {
|
||||
return p
|
||||
}
|
||||
scene.PluginName = p.name
|
||||
scene.setPluginName(p.name)
|
||||
p.scenes[scene.Name] = scene
|
||||
return p
|
||||
}
|
||||
|
||||
// NewScene creates, registers, and returns a new scene owned by the plugin.
|
||||
func (p *Plugin[T]) NewScene(name string) *Scene[T] {
|
||||
scene := NewScene[T](name)
|
||||
scene.setPluginName(p.name)
|
||||
p.AddScene(scene)
|
||||
return scene
|
||||
}
|
||||
|
||||
// UsePolicy registers a Policy as plugin middleware for all plugin handlers.
|
||||
func (p *Plugin[T]) UsePolicy(name string, policy Policy[T]) *Plugin[T] {
|
||||
mw := RequirePolicy(name, policy)
|
||||
@@ -148,11 +148,11 @@ func (p *Plugin[T]) AddUpdateHandler(t tgapi.UpdateType, handler CommandExecutor
|
||||
case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost, tgapi.UpdateTypeCallbackQuery:
|
||||
if p.logger == nil {
|
||||
logger := utils.CreateLogger(p.name, utils.GetLoggerLevel(), utils.LogFormatText, nil)
|
||||
logger.Warnf("%s can't be registred through AddUpdateHandler. Use AddPayload/NewPayload or AddCommand/NewCommand", t)
|
||||
logger.Warnf("%s can't be registered through AddUpdateHandler. Use AddPayload/Payload or AddCommand/Command", t)
|
||||
_ = logger.Close()
|
||||
return p
|
||||
}
|
||||
p.logger.Warnf("%s can't be registred through AddUpdateHandler. Use AddPayload/NewPayload or AddCommand/NewCommand", t)
|
||||
p.logger.Warnf("%s can't be registered through AddUpdateHandler. Use AddPayload/Payload or AddCommand/Command", t)
|
||||
return p
|
||||
}
|
||||
p.handlers[t] = handler
|
||||
|
||||
+8
-10
@@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
func TestValidateArgsRequiresFullMatch(t *testing.T) {
|
||||
intCmd := NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "int", NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired())
|
||||
intCmd := NewCommand("int", func(ctx *MsgContext, db NoData) error { return nil }, NewCommandArg("n").SetValueType(CommandValueInt).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(func(ctx *MsgContext, db NoData) error { return nil }, "bool", NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired())
|
||||
boolCmd := NewCommand("bool", func(ctx *MsgContext, db NoData) error { return nil }, NewCommandArg("flag").SetValueType(CommandValueBool).SetRequired())
|
||||
if err := boolCmd.validateArgs([]string{"false"}); err != nil {
|
||||
t.Fatalf("expected valid bool argument, got %v", err)
|
||||
}
|
||||
@@ -25,8 +25,8 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) {
|
||||
|
||||
func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) {
|
||||
cmd := NewCommand(
|
||||
func(ctx *MsgContext, db NoData) error { return nil },
|
||||
"mixed",
|
||||
func(ctx *MsgContext, db NoData) error { return nil },
|
||||
NewCommandArg("optional"),
|
||||
NewCommandArg("required").SetRequired(),
|
||||
)
|
||||
@@ -42,12 +42,11 @@ func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) {
|
||||
func TestCommandGroupBuildsPrefixedCommandsWithoutMutatingOriginal(t *testing.T) {
|
||||
groupMiddleware := NewMiddleware("group", func(ctx *MsgContext, db NoData) bool { return true })
|
||||
commandMiddleware := NewMiddleware("command", func(ctx *MsgContext, db NoData) bool { return true })
|
||||
cmd := NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "ban").
|
||||
cmd := NewCommand("ban", func(ctx *MsgContext, db NoData) error { return nil }).
|
||||
SetDescription("Ban user").
|
||||
Use(commandMiddleware)
|
||||
|
||||
group := NewCommandGroup[NoData]("admin").
|
||||
SetSeparator("_").
|
||||
group := NewCommandGroup[NoData]("admin_").
|
||||
Use(groupMiddleware).
|
||||
AddCommand(cmd)
|
||||
|
||||
@@ -80,7 +79,7 @@ func TestCommandGroupBuildsPrefixedCommandsWithoutMutatingOriginal(t *testing.T)
|
||||
func TestCommandGroupBuildIsRepeatable(t *testing.T) {
|
||||
group := NewCommandGroup[NoData]("admin").
|
||||
Use(NewMiddleware("group", func(ctx *MsgContext, db NoData) bool { return true })).
|
||||
AddCommand(NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "ban").
|
||||
AddCommand(NewCommand("ban", func(ctx *MsgContext, db NoData) error { return nil }).
|
||||
Use(NewMiddleware("command", func(ctx *MsgContext, db NoData) bool { return true })))
|
||||
|
||||
first := group.Build()
|
||||
@@ -103,9 +102,8 @@ func TestCommandGroupBuildIsRepeatable(t *testing.T) {
|
||||
func TestPluginCommandGroupRegistersBuiltCommands(t *testing.T) {
|
||||
plugin := NewPlugin[NoData]("admin")
|
||||
|
||||
plugin.CommandGroup("admin", func(group *CommandGroup[NoData]) {
|
||||
group.SetSeparator("_")
|
||||
group.AddCommand(NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "ban"))
|
||||
plugin.CommandGroup("admin_", func(group *CommandGroup[NoData]) {
|
||||
group.AddCommand(NewCommand("ban", func(ctx *MsgContext, db NoData) error { return nil }))
|
||||
})
|
||||
|
||||
if _, ok := plugin.commands["admin_ban"]; !ok {
|
||||
|
||||
+35
-35
@@ -12,41 +12,41 @@ type RunnerFn[T AppData] func(*Bot[T]) error
|
||||
// Runner represents a configurable background or one-time task to be
|
||||
// executed by a Bot.
|
||||
//
|
||||
// Runners are configured using builder methods: Onetime(), Async(), Timeout().
|
||||
// Runners are configured using builder methods: Once(), Async(), Every().
|
||||
// Once Execute() is called, the Runner should not be modified.
|
||||
//
|
||||
// Execution semantics:
|
||||
// - onetime=true, async=false: Run once synchronously (blocks).
|
||||
// - onetime=true, async=true: Run once in a goroutine (non-blocking).
|
||||
// - onetime=false, async=true: Run repeatedly in a goroutine with timeout.
|
||||
// - onetime=false, async=false: Invalid configuration — ignored with warning.
|
||||
// - once=true, async=false: Run once synchronously (blocks).
|
||||
// - once=true, async=true: Run once in a goroutine (non-blocking).
|
||||
// - once=false, async=true: Run repeatedly in a goroutine with timeout.
|
||||
// - once=false, async=false: Invalid configuration — ignored with warning.
|
||||
type Runner[T AppData] struct {
|
||||
name string // Human-readable name for logging
|
||||
onetime bool // If true, runs once; if false, runs periodically
|
||||
once bool // If true, runs once; if false, runs periodically
|
||||
async bool // If true, runs in a goroutine; else, runs synchronously
|
||||
timeout time.Duration // Duration to wait between periodic executions (ignored if onetime=true)
|
||||
every time.Duration // Duration to wait between periodic executions (ignored if once=true)
|
||||
fn RunnerFn[T] // The function to execute
|
||||
}
|
||||
|
||||
// NewRunner creates a new Runner with the given name and function.
|
||||
// By default, the Runner is configured as async=true (non-blocking).
|
||||
//
|
||||
// Builder methods (Onetime, Async, Timeout) can be chained to customize behavior.
|
||||
// Builder methods (Once, Async, Every) can be chained to customize behavior.
|
||||
// DO NOT call builder methods concurrently or after Execute().
|
||||
func NewRunner[T AppData](name string, fn RunnerFn[T]) Runner[T] {
|
||||
return Runner[T]{
|
||||
name: name,
|
||||
fn: fn,
|
||||
async: true, // Default: run asynchronously
|
||||
timeout: 0, // Default: no timeout (ignored if onetime=true)
|
||||
every: 0, // Default: no timeout (ignored if once=true)
|
||||
}
|
||||
}
|
||||
|
||||
// Onetime sets whether the runner executes once or repeatedly.
|
||||
// Once sets whether the runner executes once or repeatedly.
|
||||
// If true, the runner runs only once.
|
||||
// If false, the runner runs in a loop with the configured timeout.
|
||||
func (r Runner[T]) Onetime(onetime bool) Runner[T] {
|
||||
r.onetime = onetime
|
||||
func (r Runner[T]) Once(once bool) Runner[T] {
|
||||
r.once = once
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -54,56 +54,56 @@ func (r Runner[T]) Onetime(onetime bool) Runner[T] {
|
||||
// If true, the runner runs in a goroutine (non-blocking).
|
||||
// If false, the runner blocks the caller during execution.
|
||||
//
|
||||
// Note: If onetime=false and async=false, the runner will be skipped with a warning.
|
||||
// Note: If once=false and async=false, the runner will be skipped with a warning.
|
||||
func (r Runner[T]) Async(async bool) Runner[T] {
|
||||
r.async = async
|
||||
return r
|
||||
}
|
||||
|
||||
// Timeout sets the duration to wait between repeated executions for
|
||||
// non-onetime runners.
|
||||
// Every sets the duration to wait between repeated executions for
|
||||
// non-once runners.
|
||||
//
|
||||
// If onetime=true, this value is ignored.
|
||||
// If onetime=false and async=true, this timeout determines the sleep interval
|
||||
// If once=true, this value is ignored.
|
||||
// If once=false and async=true, this timeout determines the sleep interval
|
||||
// between loop iterations.
|
||||
//
|
||||
// A zero value (time.Duration(0)) is allowed but may trigger a warning
|
||||
// if used with a background (non-onetime) async runner.
|
||||
func (r Runner[T]) Timeout(timeout time.Duration) Runner[T] {
|
||||
r.timeout = timeout
|
||||
// if used with a background (non-once) async runner.
|
||||
func (r Runner[T]) Every(timeout time.Duration) Runner[T] {
|
||||
r.every = timeout
|
||||
return r
|
||||
}
|
||||
|
||||
// ExecRunners executes all runners registered on the Bot with context-based lifecycle management.
|
||||
//
|
||||
// It logs warnings for misconfigured runners:
|
||||
// - Sync, non-onetime runners are skipped (invalid configuration).
|
||||
// - Background (non-onetime, async) runners without a timeout trigger a warning.
|
||||
// - Sync, non-once runners are skipped (invalid configuration).
|
||||
// - Background (non-once, async) runners without a timeout trigger a warning.
|
||||
//
|
||||
// Execution logic:
|
||||
// - onetime + async: Runs once in a goroutine.
|
||||
// - onetime + sync: Runs once synchronously; warns if slower than 2 seconds.
|
||||
// - !onetime + async: Runs in a loop with timeout between iterations until ctx.Done().
|
||||
// - !onetime + sync: Skipped with warning.
|
||||
// - once + async: Runs once in a goroutine.
|
||||
// - once + sync: Runs once synchronously; warns if slower than 2 seconds.
|
||||
// - !once + async: Runs in a loop with timeout between iterations until ctx.Done().
|
||||
// - !once + sync: Skipped with warning.
|
||||
//
|
||||
// Background runners listen for ctx.Done() and gracefully shut down when the context is canceled.
|
||||
//
|
||||
// This method is typically called once during bot startup from RunWithContext or
|
||||
// RunWebHookWithContext.
|
||||
// RunWebhookWithContext.
|
||||
func (bot *Bot[T]) ExecRunners(ctx context.Context) {
|
||||
bot.logger.Infoln("Executing runners...")
|
||||
for _, runner := range bot.runners {
|
||||
// Validate configuration
|
||||
if !runner.onetime && !runner.async {
|
||||
bot.logger.Warnf("Runner %s not onetime, but sync — skipping\n", runner.name)
|
||||
if !runner.once && !runner.async {
|
||||
bot.logger.Warnf("Runner %s not once, but sync — skipping\n", runner.name)
|
||||
continue
|
||||
}
|
||||
if !runner.onetime && runner.async && runner.timeout == 0 {
|
||||
if !runner.once && runner.async && runner.every == 0 {
|
||||
bot.logger.Warnf("Background runner \"%s\" has no timeout — skipping\n", runner.name)
|
||||
continue
|
||||
}
|
||||
|
||||
if runner.onetime && runner.async {
|
||||
if runner.once && runner.async {
|
||||
// One-time async: fire and forget
|
||||
bot.runnerOnceWG.Add(1)
|
||||
go func(r Runner[T]) {
|
||||
@@ -126,7 +126,7 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) {
|
||||
bot.logger.Warnf("Runner %s failed: %s\n", r.name, err)
|
||||
}
|
||||
}(runner)
|
||||
} else if runner.onetime && !runner.async {
|
||||
} else if runner.once && !runner.async {
|
||||
// One-time sync: block until done
|
||||
t := time.Now()
|
||||
err := runner.fn(bot)
|
||||
@@ -149,12 +149,12 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) {
|
||||
if elapsed > time.Second*2 {
|
||||
bot.logger.Warnf("Runner %s too slow. Elapsed time %v >= 2s\n", runner.name, elapsed)
|
||||
}
|
||||
} else if !runner.onetime && runner.async {
|
||||
} else if !runner.once && runner.async {
|
||||
// Background loop: periodic execution with graceful shutdown
|
||||
bot.runnerBgWG.Add(1)
|
||||
go func(r Runner[T]) {
|
||||
defer bot.runnerBgWG.Done()
|
||||
ticker := time.NewTicker(r.timeout)
|
||||
ticker := time.NewTicker(r.every)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
@@ -182,6 +182,6 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) {
|
||||
}
|
||||
}(runner)
|
||||
}
|
||||
// Note: !onetime && !async is already skipped above
|
||||
// Note: !once && !async is already skipped above
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -14,7 +14,7 @@ type runnerObserver struct {
|
||||
recordingObserver
|
||||
}
|
||||
|
||||
func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) {
|
||||
func TestExecRunnersRunsOnceSyncRunner(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
bot := &Bot[NoData]{
|
||||
logger: sneklog.NewLogger(),
|
||||
@@ -22,7 +22,7 @@ func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) {
|
||||
NewRunner("sync-once", func(*Bot[NoData]) error {
|
||||
calls.Add(1)
|
||||
return nil
|
||||
}).Onetime(true).Async(false),
|
||||
}).Once(true).Async(false),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestExecRunnersStopsBackgroundRunnerOnCancel(t *testing.T) {
|
||||
triggered <- struct{}{}
|
||||
}
|
||||
return nil
|
||||
}).Timeout(5 * time.Millisecond),
|
||||
}).Every(5 * time.Millisecond),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ func TestExecRunnersEmitObserverEvents(t *testing.T) {
|
||||
runners: []Runner[NoData]{
|
||||
NewRunner("sync-once", func(*Bot[NoData]) error {
|
||||
return wantErr
|
||||
}).Onetime(true).Async(false),
|
||||
}).Once(true).Async(false),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+18
-18
@@ -45,7 +45,7 @@ func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) {
|
||||
called := false
|
||||
|
||||
plugin := NewPlugin[NoData]("wizard")
|
||||
plugin.NewScene("signup").
|
||||
plugin.Scene("signup").
|
||||
SetEntry("start").
|
||||
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
|
||||
called = true
|
||||
@@ -146,7 +146,7 @@ func TestBuildSceneKeyRejectsMissingContextFields(t *testing.T) {
|
||||
func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) {
|
||||
t.Run("empty entry", func(t *testing.T) {
|
||||
plugin := NewPlugin[NoData]("wizard")
|
||||
plugin.NewScene("signup")
|
||||
plugin.Scene("signup")
|
||||
|
||||
bot := &Bot[NoData]{
|
||||
logger: sneklog.NewLogger(),
|
||||
@@ -169,7 +169,7 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) {
|
||||
|
||||
t.Run("missing entry step", func(t *testing.T) {
|
||||
plugin := NewPlugin[NoData]("wizard")
|
||||
plugin.NewScene("signup").SetEntry("start")
|
||||
plugin.Scene("signup").SetEntry("start")
|
||||
|
||||
bot := &Bot[NoData]{
|
||||
logger: sneklog.NewLogger(),
|
||||
@@ -210,7 +210,7 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) {
|
||||
stepCalled := false
|
||||
|
||||
plugin := NewPlugin[NoData]("wizard")
|
||||
plugin.NewScene("signup").
|
||||
plugin.Scene("signup").
|
||||
SetEntry("start").
|
||||
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
|
||||
stepCalled = true
|
||||
@@ -269,7 +269,7 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) {
|
||||
func TestSceneCommandObserverEmitsLifecycleEvents(t *testing.T) {
|
||||
observer := &recordingObserver{}
|
||||
plugin := NewPlugin[NoData]("wizard")
|
||||
plugin.NewScene("signup").
|
||||
plugin.Scene("signup").
|
||||
SetEntry("start").
|
||||
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
|
||||
return ctx.Stay(), nil
|
||||
@@ -324,7 +324,7 @@ func TestSceneCommandObserverEmitsLifecycleEvents(t *testing.T) {
|
||||
func TestSceneStepObserverEmitsLifecycleEvents(t *testing.T) {
|
||||
observer := &recordingObserver{}
|
||||
plugin := NewPlugin[NoData]("wizard")
|
||||
plugin.NewScene("signup").
|
||||
plugin.Scene("signup").
|
||||
SetEntry("start").
|
||||
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
|
||||
return ctx.Stay(), nil
|
||||
@@ -376,7 +376,7 @@ func TestSceneStepObserverEmitsLifecycleEvents(t *testing.T) {
|
||||
func TestSceneMessageObserverEmitsLifecycleEvents(t *testing.T) {
|
||||
observer := &recordingObserver{}
|
||||
plugin := NewPlugin[NoData]("wizard")
|
||||
scene := plugin.NewScene("signup").
|
||||
scene := plugin.Scene("signup").
|
||||
SetEntry("start").
|
||||
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
|
||||
return ctx.Stay(), nil
|
||||
@@ -435,7 +435,7 @@ func TestScenePayloadHandlerRunsBeforeStep(t *testing.T) {
|
||||
stepCalled := false
|
||||
|
||||
plugin := NewPlugin[NoData]("wizard")
|
||||
plugin.NewScene("signup").
|
||||
plugin.Scene("signup").
|
||||
SetEntry("start").
|
||||
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
|
||||
stepCalled = true
|
||||
@@ -499,7 +499,7 @@ func TestScenePayloadHandlerRunsBeforeStep(t *testing.T) {
|
||||
func TestScenePayloadObserverEmitsLifecycleEvents(t *testing.T) {
|
||||
observer := &recordingObserver{}
|
||||
plugin := NewPlugin[NoData]("wizard")
|
||||
plugin.NewScene("signup").
|
||||
plugin.Scene("signup").
|
||||
SetEntry("start").
|
||||
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
|
||||
return ctx.Stay(), nil
|
||||
@@ -563,8 +563,8 @@ func TestSceneUnmatchedPayloadFallsThroughWithoutRunningStep(t *testing.T) {
|
||||
stepCalled := false
|
||||
|
||||
plugin := NewPlugin[NoData]("wizard")
|
||||
plugin.NewPayload(func(ctx *MsgContext, db NoData) error { return nil }, "ping")
|
||||
plugin.NewScene("signup").
|
||||
plugin.Payload("ping", func(ctx *MsgContext, db NoData) error { return nil })
|
||||
plugin.Scene("signup").
|
||||
SetEntry("start").
|
||||
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
|
||||
stepCalled = true
|
||||
@@ -632,11 +632,11 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) {
|
||||
commandCalled := false
|
||||
|
||||
plugin := NewPlugin[NoData]("wizard")
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
|
||||
plugin.Command("ping", func(ctx *MsgContext, db NoData) error {
|
||||
commandCalled = true
|
||||
return nil
|
||||
}, "ping")
|
||||
plugin.NewScene("signup").
|
||||
})
|
||||
plugin.Scene("signup").
|
||||
SetEntry("start").
|
||||
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
|
||||
if err := ctx.SaveData(struct {
|
||||
@@ -712,16 +712,16 @@ func TestSceneUnmatchedCommandFallsThroughWithoutRunningStep(t *testing.T) {
|
||||
stepCalled := false
|
||||
|
||||
plugin := NewPlugin[NoData]("wizard")
|
||||
plugin.NewScene("signup").
|
||||
plugin.Scene("signup").
|
||||
SetEntry("start").
|
||||
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
|
||||
stepCalled = true
|
||||
return ctx.Stay(), nil
|
||||
})
|
||||
plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
|
||||
plugin.Command("ping", func(ctx *MsgContext, db NoData) error {
|
||||
commandCalled = true
|
||||
return nil
|
||||
}, "ping")
|
||||
})
|
||||
|
||||
bot := &Bot[NoData]{
|
||||
logger: sneklog.NewLogger(),
|
||||
@@ -779,7 +779,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) {
|
||||
fallbackCalled := false
|
||||
|
||||
plugin := NewPlugin[NoData]("wizard")
|
||||
plugin.NewScene("signup").
|
||||
plugin.Scene("signup").
|
||||
SetEntry("start").
|
||||
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
|
||||
return ctx.Stay(), nil
|
||||
|
||||
+2
-2
@@ -83,10 +83,10 @@ func (opts *APIOpts) SetLimiter(limiter *utils.RateLimiter) *APIOpts {
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetLimiterDrop enables "drop mode" for rate limiting.
|
||||
// SetDropRateLimitOverflow enables "drop mode" for rate limiting.
|
||||
// If true, requests exceeding limits return ErrDropOverflow immediately.
|
||||
// If false, requests block until capacity is available.
|
||||
func (opts *APIOpts) SetLimiterDrop(b bool) *APIOpts {
|
||||
func (opts *APIOpts) SetDropRateLimitOverflow(b bool) *APIOpts {
|
||||
opts.dropOverflowLimit = b
|
||||
return opts
|
||||
}
|
||||
|
||||
@@ -652,15 +652,15 @@ func (api *API) GetChatAdministratorsWithContext(ctx context.Context, params Get
|
||||
return req.DoWithContext(ctx, api)
|
||||
}
|
||||
|
||||
// GetChatMembersCount holds parameters for the getChatMemberCount method.
|
||||
// GetChatMemberCount holds parameters for the getChatMemberCount method.
|
||||
// See https://core.telegram.org/bots/api#getchatmembercount
|
||||
type GetChatMembersCount struct {
|
||||
type GetChatMemberCount struct {
|
||||
ChatID int64 `json:"chat_id"`
|
||||
}
|
||||
|
||||
// GetChatMemberCount returns the number of members in a chat.
|
||||
// See https://core.telegram.org/bots/api#getchatmembercount
|
||||
func (api *API) GetChatMemberCount(params GetChatMembersCount) (int, error) {
|
||||
func (api *API) GetChatMemberCount(params GetChatMemberCount) (int, error) {
|
||||
req := NewRequestWithChatID[int]("getChatMemberCount", params, params.ChatID)
|
||||
return req.Do(api)
|
||||
}
|
||||
@@ -668,7 +668,7 @@ func (api *API) GetChatMemberCount(params GetChatMembersCount) (int, error) {
|
||||
// GetChatMemberCountWithContext is the context-aware variant of GetChatMemberCount.
|
||||
// It executes the same request but uses ctx for cancellation and deadlines.
|
||||
// See https://core.telegram.org/bots/api#getchatmembercount
|
||||
func (api *API) GetChatMemberCountWithContext(ctx context.Context, params GetChatMembersCount) (int, error) {
|
||||
func (api *API) GetChatMemberCountWithContext(ctx context.Context, params GetChatMemberCount) (int, error) {
|
||||
req := NewRequestWithChatID[int]("getChatMemberCount", params, params.ChatID)
|
||||
return req.DoWithContext(ctx, api)
|
||||
}
|
||||
|
||||
@@ -362,7 +362,7 @@ type MessageEntity struct {
|
||||
Language string `json:"language,omitempty"`
|
||||
CustomEmojiID string `json:"custom_emoji_id,omitempty"`
|
||||
|
||||
UnixTime int `json:"unix_time,omitempty"`
|
||||
UnixTime int64 `json:"unix_time,omitempty"`
|
||||
DateTimeFormat string `json:"date_time_format,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@ package tgapi
|
||||
type ParseMode string
|
||||
|
||||
const (
|
||||
// ParseMDV2 enables MarkdownV2 style parsing.
|
||||
ParseMDV2 ParseMode = "MarkdownV2"
|
||||
// ParseMarkdownV2 enables MarkdownV2 style parsing.
|
||||
ParseMarkdownV2 ParseMode = "MarkdownV2"
|
||||
// ParseHTML enables HTML style parsing.
|
||||
ParseHTML ParseMode = "HTML"
|
||||
// ParseMD enables legacy Markdown style parsing.
|
||||
ParseMD ParseMode = "Markdown"
|
||||
// ParseMarkdown enables legacy Markdown style parsing.
|
||||
ParseMarkdown ParseMode = "Markdown"
|
||||
// ParseNone disables parse_mode and leaves plain-text requests unannotated.
|
||||
ParseNone ParseMode = ""
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestParseModeStillSerializesExplicitModes(t *testing.T) {
|
||||
data, err := json.Marshal(SendMessage{
|
||||
ChatID: 42,
|
||||
Text: "hello",
|
||||
ParseMode: ParseMDV2,
|
||||
ParseMode: ParseMarkdownV2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal returned error: %v", err)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package tgmd provides small helpers for Telegram Markdown text.
|
||||
package tgmd
|
||||
@@ -0,0 +1,295 @@
|
||||
package tgmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.scuroneko.dev/scuroneko/extypes"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
)
|
||||
|
||||
//TODO GoDoc, tests. Maybe escape Markdown v2
|
||||
|
||||
// MessageBuilder builds Telegram message text with explicit message entities.
|
||||
// MessageBuilder is not safe for concurrent use.
|
||||
type MessageBuilder struct {
|
||||
str string
|
||||
offset int
|
||||
entities extypes.Slice[tgapi.MessageEntity]
|
||||
|
||||
entries extypes.Slice[*MessageBuilderEntry]
|
||||
isDirty bool
|
||||
}
|
||||
|
||||
// NewMessageBuilder returns an empty MessageBuilder.
|
||||
func NewMessageBuilder() *MessageBuilder {
|
||||
return &MessageBuilder{
|
||||
entities: make([]tgapi.MessageEntity, 0),
|
||||
entries: make(extypes.Slice[*MessageBuilderEntry], 0),
|
||||
isDirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the built message text.
|
||||
func (b *MessageBuilder) String() string {
|
||||
if b.isDirty {
|
||||
b.update()
|
||||
}
|
||||
return b.str
|
||||
}
|
||||
|
||||
// Entities returns a copy of the built message entities.
|
||||
func (b *MessageBuilder) Entities() []tgapi.MessageEntity {
|
||||
if b.isDirty {
|
||||
b.update()
|
||||
}
|
||||
return append([]tgapi.MessageEntity(nil), b.entities...)
|
||||
}
|
||||
|
||||
func (b *MessageBuilder) Build() (string, []tgapi.MessageEntity) {
|
||||
if b.isDirty {
|
||||
b.update()
|
||||
}
|
||||
return b.str, append([]tgapi.MessageEntity(nil), b.entities...)
|
||||
}
|
||||
|
||||
func (b *MessageBuilder) Reset() {
|
||||
b.str = ""
|
||||
b.offset = 0
|
||||
b.entities = b.entities[:0]
|
||||
b.entries = b.entries[:0]
|
||||
b.isDirty = false
|
||||
}
|
||||
|
||||
func (b *MessageBuilder) update() *MessageBuilder {
|
||||
b.offset = 0
|
||||
|
||||
var textLen int
|
||||
var entitiesLen int
|
||||
for _, e := range b.entries {
|
||||
textLen += len(e.text) // bytes, для Grow нормально
|
||||
entitiesLen += len(e.entities)
|
||||
}
|
||||
|
||||
b.entities = make(extypes.Slice[tgapi.MessageEntity], 0, entitiesLen)
|
||||
|
||||
var sb strings.Builder
|
||||
sb.Grow(textLen)
|
||||
|
||||
for _, e := range b.entries {
|
||||
sb.WriteString(e.text)
|
||||
|
||||
for _, entity := range e.entities {
|
||||
entity.Offset += b.offset
|
||||
b.entities = append(b.entities, entity)
|
||||
}
|
||||
|
||||
b.offset += e.length
|
||||
}
|
||||
|
||||
b.str = sb.String()
|
||||
b.isDirty = false
|
||||
return b
|
||||
}
|
||||
func (b *MessageBuilder) markDirty() {
|
||||
b.isDirty = true
|
||||
}
|
||||
|
||||
type MessageBuilderEntry struct {
|
||||
text string
|
||||
length int
|
||||
|
||||
b *MessageBuilder
|
||||
entities extypes.Slice[tgapi.MessageEntity]
|
||||
}
|
||||
|
||||
// Add appends plain text to the message and returns its entry for formatting.
|
||||
func (b *MessageBuilder) Add(text string) *MessageBuilderEntry {
|
||||
e := &MessageBuilderEntry{
|
||||
b: b,
|
||||
entities: make(extypes.Slice[tgapi.MessageEntity], 0),
|
||||
|
||||
text: text,
|
||||
length: telegramTextLen(text),
|
||||
}
|
||||
b.entries = b.entries.Push(e)
|
||||
b.markDirty()
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *MessageBuilderEntry) Mention() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityMention,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *MessageBuilderEntry) Hashtag() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityHashtag,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *MessageBuilderEntry) Cashtag() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityCashtag,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *MessageBuilderEntry) BotCommand() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityBotCommand,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *MessageBuilderEntry) Email() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityEmail,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *MessageBuilderEntry) Phone() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityPhoneNumber,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
func (e *MessageBuilderEntry) Bold() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityBold,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
func (e *MessageBuilderEntry) Italic() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityItalic,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
func (e *MessageBuilderEntry) Underline() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityUnderline,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
func (e *MessageBuilderEntry) Strikethrough() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityStrike,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
func (e *MessageBuilderEntry) Spoiler() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntitySpoiler,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
func (e *MessageBuilderEntry) Quote() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityBlockquote,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
func (e *MessageBuilderEntry) ExpandableQuote() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityExpandableBlockquote,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
func (e *MessageBuilderEntry) InlineCode() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityCode,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
func (e *MessageBuilderEntry) CodeBlock() *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityPre,
|
||||
Offset: 0, Length: e.length,
|
||||
})
|
||||
return e
|
||||
}
|
||||
func (e *MessageBuilderEntry) CodeBlockWithLanguage(lang string) *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityPre,
|
||||
Offset: 0, Length: e.length, Language: lang,
|
||||
})
|
||||
return e
|
||||
}
|
||||
func (e *MessageBuilderEntry) Link(url string) *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityTextLink,
|
||||
Offset: 0, Length: e.length, URL: url,
|
||||
})
|
||||
return e
|
||||
}
|
||||
func (e *MessageBuilderEntry) TextMention(user *tgapi.User) *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityTextMention,
|
||||
Offset: 0, Length: e.length, User: user,
|
||||
})
|
||||
return e
|
||||
}
|
||||
func (e *MessageBuilderEntry) CustomEmoji(emojiID string) *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityCustomEmoji,
|
||||
Offset: 0, Length: e.length, CustomEmojiID: emojiID,
|
||||
})
|
||||
return e
|
||||
}
|
||||
func (e *MessageBuilderEntry) DateTime(time time.Time) *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityDateTime,
|
||||
Offset: 0, Length: e.length, UnixTime: time.Unix(),
|
||||
})
|
||||
return e
|
||||
}
|
||||
func (e *MessageBuilderEntry) DateTimeFormat(time time.Time, format string) *MessageBuilderEntry {
|
||||
e.addEntity(tgapi.MessageEntity{
|
||||
Type: tgapi.MessageEntityDateTime,
|
||||
Offset: 0, Length: e.length,
|
||||
UnixTime: time.Unix(), DateTimeFormat: format,
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
func telegramTextLen(text string) int {
|
||||
n := 0
|
||||
for _, r := range text {
|
||||
if r <= 0xFFFF {
|
||||
n++
|
||||
} else {
|
||||
n += 2
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (e *MessageBuilderEntry) addEntity(entity tgapi.MessageEntity) {
|
||||
if entity.Length <= 0 {
|
||||
return
|
||||
}
|
||||
e.entities = append(e.entities, entity)
|
||||
if e.b != nil {
|
||||
e.b.markDirty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
package tgmd
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
)
|
||||
|
||||
func TestMessageBuilder_BuildPlainText(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
b.Add("Hello")
|
||||
b.Add(", ")
|
||||
b.Add("world")
|
||||
|
||||
text, entities := b.Build()
|
||||
|
||||
if text != "Hello, world" {
|
||||
t.Fatalf("text = %q, want %q", text, "Hello, world")
|
||||
}
|
||||
|
||||
if len(entities) != 0 {
|
||||
t.Fatalf("entities len = %d, want 0", len(entities))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBuilder_EntityOffsetsAreUTF16(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
b.Add("Hi ")
|
||||
b.Add("👋") // 2 UTF-16 code units
|
||||
b.Add(" ")
|
||||
b.Add("world").Bold()
|
||||
|
||||
text, entities := b.Build()
|
||||
|
||||
if text != "Hi 👋 world" {
|
||||
t.Fatalf("text = %q, want %q", text, "Hi 👋 world")
|
||||
}
|
||||
|
||||
want := []tgapi.MessageEntity{
|
||||
{
|
||||
Type: tgapi.MessageEntityBold,
|
||||
Offset: 6, // H i space = 3, 👋 = 2, space = 1
|
||||
Length: 5,
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(entities, want) {
|
||||
t.Fatalf("entities = %#v, want %#v", entities, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBuilder_EntityLengthIsUTF16(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
b.Add("👋").Bold()
|
||||
|
||||
text, entities := b.Build()
|
||||
|
||||
if text != "👋" {
|
||||
t.Fatalf("text = %q, want %q", text, "👋")
|
||||
}
|
||||
|
||||
want := []tgapi.MessageEntity{
|
||||
{
|
||||
Type: tgapi.MessageEntityBold,
|
||||
Offset: 0,
|
||||
Length: 2,
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(entities, want) {
|
||||
t.Fatalf("entities = %#v, want %#v", entities, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBuilder_MultipleEntitiesOnSameEntry(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
b.Add("hello").Bold().Italic()
|
||||
|
||||
_, entities := b.Build()
|
||||
|
||||
want := []tgapi.MessageEntity{
|
||||
{
|
||||
Type: tgapi.MessageEntityBold,
|
||||
Offset: 0,
|
||||
Length: 5,
|
||||
},
|
||||
{
|
||||
Type: tgapi.MessageEntityItalic,
|
||||
Offset: 0,
|
||||
Length: 5,
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(entities, want) {
|
||||
t.Fatalf("entities = %#v, want %#v", entities, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBuilder_DoesNotDuplicateAfterRepeatedReads(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
b.Add("hello").Bold()
|
||||
|
||||
text1 := b.String()
|
||||
entities1 := b.Entities()
|
||||
|
||||
text2 := b.String()
|
||||
entities2 := b.Entities()
|
||||
|
||||
if text1 != text2 {
|
||||
t.Fatalf("texts differ: %q != %q", text1, text2)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(entities1, entities2) {
|
||||
t.Fatalf("entities differ: %#v != %#v", entities1, entities2)
|
||||
}
|
||||
|
||||
want := []tgapi.MessageEntity{
|
||||
{
|
||||
Type: tgapi.MessageEntityBold,
|
||||
Offset: 0,
|
||||
Length: 5,
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(entities2, want) {
|
||||
t.Fatalf("entities = %#v, want %#v", entities2, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBuilder_AddEntityAfterStringMarksDirty(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
entry := b.Add("hello")
|
||||
|
||||
if got := b.String(); got != "hello" {
|
||||
t.Fatalf("String() = %q, want %q", got, "hello")
|
||||
}
|
||||
|
||||
entry.Bold()
|
||||
|
||||
entities := b.Entities()
|
||||
|
||||
want := []tgapi.MessageEntity{
|
||||
{
|
||||
Type: tgapi.MessageEntityBold,
|
||||
Offset: 0,
|
||||
Length: 5,
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(entities, want) {
|
||||
t.Fatalf("entities = %#v, want %#v", entities, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBuilder_EntitiesReturnsCopy(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
b.Add("hello").Bold()
|
||||
|
||||
entities1 := b.Entities()
|
||||
entities1[0].Offset = 999
|
||||
|
||||
entities2 := b.Entities()
|
||||
|
||||
if entities2[0].Offset != 0 {
|
||||
t.Fatalf("Entities() did not return copy: offset = %d, want 0", entities2[0].Offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBuilder_BuildReturnsEntitiesCopy(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
b.Add("hello").Bold()
|
||||
|
||||
_, entities1 := b.Build()
|
||||
entities1[0].Offset = 999
|
||||
|
||||
_, entities2 := b.Build()
|
||||
|
||||
if entities2[0].Offset != 0 {
|
||||
t.Fatalf("Build() did not return entities copy: offset = %d, want 0", entities2[0].Offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBuilder_Reset(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
b.Add("hello").Bold()
|
||||
|
||||
if got := b.String(); got != "hello" {
|
||||
t.Fatalf("String() before Reset = %q, want %q", got, "hello")
|
||||
}
|
||||
|
||||
b.Reset()
|
||||
|
||||
text, entities := b.Build()
|
||||
|
||||
if text != "" {
|
||||
t.Fatalf("text after Reset = %q, want empty", text)
|
||||
}
|
||||
|
||||
if len(entities) != 0 {
|
||||
t.Fatalf("entities len after Reset = %d, want 0", len(entities))
|
||||
}
|
||||
|
||||
b.Add("world").Italic()
|
||||
|
||||
text, entities = b.Build()
|
||||
|
||||
if text != "world" {
|
||||
t.Fatalf("text after reuse = %q, want %q", text, "world")
|
||||
}
|
||||
|
||||
want := []tgapi.MessageEntity{
|
||||
{
|
||||
Type: tgapi.MessageEntityItalic,
|
||||
Offset: 0,
|
||||
Length: 5,
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(entities, want) {
|
||||
t.Fatalf("entities after reuse = %#v, want %#v", entities, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBuilder_EmptyEntryDoesNotCreateEntity(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
b.Add("").Bold()
|
||||
b.Add("x")
|
||||
|
||||
text, entities := b.Build()
|
||||
|
||||
if text != "x" {
|
||||
t.Fatalf("text = %q, want %q", text, "x")
|
||||
}
|
||||
|
||||
if len(entities) != 0 {
|
||||
t.Fatalf("entities len = %d, want 0: %#v", len(entities), entities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBuilder_Link(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
b.Add("OpenAI").Link("https://openai.com")
|
||||
|
||||
_, entities := b.Build()
|
||||
|
||||
want := []tgapi.MessageEntity{
|
||||
{
|
||||
Type: tgapi.MessageEntityTextLink,
|
||||
Offset: 0,
|
||||
Length: 6,
|
||||
URL: "https://openai.com",
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(entities, want) {
|
||||
t.Fatalf("entities = %#v, want %#v", entities, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBuilder_CodeBlockWithLanguage(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
b.Add("fmt.Println(\"hi\")").CodeBlockWithLanguage("go")
|
||||
|
||||
_, entities := b.Build()
|
||||
|
||||
want := []tgapi.MessageEntity{
|
||||
{
|
||||
Type: tgapi.MessageEntityPre,
|
||||
Offset: 0,
|
||||
Length: 17,
|
||||
Language: "go",
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(entities, want) {
|
||||
t.Fatalf("entities = %#v, want %#v", entities, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBuilder_DateTimeFormat(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
ts := time.Unix(1772323200, 0)
|
||||
|
||||
b.Add("date").DateTimeFormat(ts, "MMMM d, yyyy")
|
||||
|
||||
_, entities := b.Build()
|
||||
|
||||
want := []tgapi.MessageEntity{
|
||||
{
|
||||
Type: tgapi.MessageEntityDateTime,
|
||||
Offset: 0,
|
||||
Length: 4,
|
||||
UnixTime: 1772323200,
|
||||
DateTimeFormat: "MMMM d, yyyy",
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(entities, want) {
|
||||
t.Fatalf("entities = %#v, want %#v", entities, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramTextLen(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "ascii",
|
||||
text: "hello",
|
||||
want: 5,
|
||||
},
|
||||
{
|
||||
name: "cyrillic",
|
||||
text: "привет",
|
||||
want: 6,
|
||||
},
|
||||
{
|
||||
name: "emoji",
|
||||
text: "👋",
|
||||
want: 2,
|
||||
},
|
||||
{
|
||||
name: "mixed",
|
||||
text: "a👋b",
|
||||
want: 4,
|
||||
},
|
||||
{
|
||||
name: "zwj sequence",
|
||||
text: "👨👩👧👦",
|
||||
want: 11,
|
||||
},
|
||||
{
|
||||
name: "flag",
|
||||
text: "🇫🇮",
|
||||
want: 4,
|
||||
},
|
||||
{
|
||||
name: "variation selector",
|
||||
text: "❤️",
|
||||
want: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := telegramTextLen(tt.text)
|
||||
if got != tt.want {
|
||||
t.Fatalf("telegramTextLen(%q) = %d, want %d", tt.text, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageBuilder_SimpleEntityTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
add func(*MessageBuilderEntry)
|
||||
want tgapi.MessageEntityType
|
||||
}{
|
||||
{"mention", func(e *MessageBuilderEntry) { e.Mention() }, tgapi.MessageEntityMention},
|
||||
{"hashtag", func(e *MessageBuilderEntry) { e.Hashtag() }, tgapi.MessageEntityHashtag},
|
||||
{"cashtag", func(e *MessageBuilderEntry) { e.Cashtag() }, tgapi.MessageEntityCashtag},
|
||||
{"bot command", func(e *MessageBuilderEntry) { e.BotCommand() }, tgapi.MessageEntityBotCommand},
|
||||
{"email", func(e *MessageBuilderEntry) { e.Email() }, tgapi.MessageEntityEmail},
|
||||
{"phone", func(e *MessageBuilderEntry) { e.Phone() }, tgapi.MessageEntityPhoneNumber},
|
||||
{"bold", func(e *MessageBuilderEntry) { e.Bold() }, tgapi.MessageEntityBold},
|
||||
{"italic", func(e *MessageBuilderEntry) { e.Italic() }, tgapi.MessageEntityItalic},
|
||||
{"underline", func(e *MessageBuilderEntry) { e.Underline() }, tgapi.MessageEntityUnderline},
|
||||
{"strikethrough", func(e *MessageBuilderEntry) { e.Strikethrough() }, tgapi.MessageEntityStrike},
|
||||
{"spoiler", func(e *MessageBuilderEntry) { e.Spoiler() }, tgapi.MessageEntitySpoiler},
|
||||
{"quote", func(e *MessageBuilderEntry) { e.Quote() }, tgapi.MessageEntityBlockquote},
|
||||
{"expandable quote", func(e *MessageBuilderEntry) { e.ExpandableQuote() }, tgapi.MessageEntityExpandableBlockquote},
|
||||
{"inline code", func(e *MessageBuilderEntry) { e.InlineCode() }, tgapi.MessageEntityCode},
|
||||
{"code block", func(e *MessageBuilderEntry) { e.CodeBlock() }, tgapi.MessageEntityPre},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
b := NewMessageBuilder()
|
||||
|
||||
e := b.Add("hello")
|
||||
tt.add(e)
|
||||
|
||||
_, entities := b.Build()
|
||||
|
||||
want := []tgapi.MessageEntity{
|
||||
{
|
||||
Type: tt.want,
|
||||
Offset: 0,
|
||||
Length: 5,
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(entities, want) {
|
||||
t.Fatalf("entities = %#v, want %#v", entities, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package tgmd
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.scuroneko.dev/scuroneko/laniakea"
|
||||
)
|
||||
|
||||
// Helpers in this file generate Telegram MarkdownV2.
|
||||
// All user-provided text is escaped.
|
||||
|
||||
// TODO Markdown v2 escaping. GoDoc and tests
|
||||
|
||||
// WithBold returns s wrapped as bold Telegram Markdown text.
|
||||
func WithBold(s string) string {
|
||||
return "*" + s + "*"
|
||||
}
|
||||
|
||||
// WithItalic returns s wrapped as italic Telegram Markdown text.
|
||||
func WithItalic(s string) string {
|
||||
return "_" + s + "_"
|
||||
}
|
||||
|
||||
func WithUnderline(s string) string {
|
||||
return "__" + s + "__"
|
||||
}
|
||||
func WithStrikethrough(s string) string {
|
||||
return "~" + s + "~"
|
||||
}
|
||||
func WithSpoiler(s string) string {
|
||||
return "||" + s + "||"
|
||||
}
|
||||
|
||||
// WithLink returns a Telegram Markdown link for text and URL.
|
||||
func WithLink(text, url string) string {
|
||||
return "[" + text + "](" + url + ")"
|
||||
}
|
||||
|
||||
func WithMention(text string, userID uint64) string {
|
||||
return "[" + text + "](tg://user?id=" + strconv.FormatUint(userID, 10) + ")"
|
||||
}
|
||||
func WithEmoji(text, emojiID string) string {
|
||||
return "[" + text + "](tg://emoji?id=" + emojiID + ")"
|
||||
}
|
||||
|
||||
func WithTime(text string, unix uint64) string {
|
||||
return " + ")"
|
||||
}
|
||||
func WithTimeFormat(text string, unix uint64, format string) string {
|
||||
return " +
|
||||
"&format=" + format + ")"
|
||||
}
|
||||
|
||||
// WithInlineCode returns s wrapped as inline code Telegram Markdown text.
|
||||
func WithInlineCode(s string) string {
|
||||
return "`" + s + "`"
|
||||
}
|
||||
func WithBlockCode(s string) string {
|
||||
return "```\n" + s + "\n```"
|
||||
}
|
||||
func WithBlockCodeLanguage(s, lang string) string {
|
||||
return "```" + lang + "\n" + s + "\n```"
|
||||
}
|
||||
func WithQuote(s string) string {
|
||||
return ">" + strings.ReplaceAll(laniakea.EscapeMarkdownV2(s), "\n", "\n>")
|
||||
}
|
||||
func WithQuoteExpandable(s string) string {
|
||||
return "**>" + s
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package tgmd
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFormattingHelpers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
got string
|
||||
want string
|
||||
}{
|
||||
{name: "bold", got: WithBold("text"), want: "*text*"},
|
||||
{name: "italic", got: WithItalic("text"), want: "_text_"},
|
||||
{name: "inline code", got: WithInlineCode("text"), want: "`text`"},
|
||||
{name: "link", got: WithLink("Laniakea", "https://example.test"), want: "[Laniakea](https://example.test)"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.got != tc.want {
|
||||
t.Fatalf("unexpected formatted text: got %q want %q", tc.got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user