REPOSITORY / ScuroNeko/Laniakea

Pull Requests

PULL REQUESTS REPOSITORY

v1.0.0 #9

Merged
ScuroNeko merged 101 commits from dev into main 2026-05-20 13:43:34 +03:00
34 changed files with 1227 additions and 385 deletions
Showing only changes of commit 071fc2375e - Show all commits
+13 -1
View File
@@ -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.
+18 -18
View File
@@ -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
View File
@@ -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-ы обрабатываются через плагины.
+23 -19
View File
@@ -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)
+8 -8
View File
@@ -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.
@@ -135,9 +135,9 @@ func LoadOptsFromEnv() *BotOpts {
UseTestServer: os.Getenv("USE_TEST_SERVER") == "true",
APIURL: os.Getenv("API_URL"),
RateLimit: rateLimit,
DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true",
RateLimit: rateLimit,
DropRateLimitOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true",
MaxWorkers: maxWorkers,
FileConfigVersion: 0,
@@ -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
}
+8 -5
View File
@@ -70,10 +70,10 @@ func (codec BotOptsFileJSONCodec) FromBytes(data []byte) (*BotOpts, error) {
WriteToFile: fileOpts.Logger.WriteToFile,
LogFormat: fileOpts.Logger.LogFormat,
UseTestServer: fileOpts.API.UseTestServer,
APIURL: fileOpts.API.APIURL,
RateLimit: fileOpts.API.RateLimit,
DropRLOverflow: fileOpts.API.DropRLOverflow,
UseTestServer: fileOpts.API.UseTestServer,
APIURL: fileOpts.API.APIURL,
RateLimit: fileOpts.API.RateLimit,
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)
}
+15 -15
View File
@@ -13,21 +13,21 @@ import (
func TestBotOptsFileJSONCodecRoundTrip(t *testing.T) {
codec := BotOptsFileJSONCodec{}
want := &BotOpts{
Token: "TOKEN",
UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery},
Debug: true,
ErrorTemplate: "Error: %s",
Prefixes: []string{"/", "!"},
LoggerBasePath: "/tmp/logs",
UseRequestLogger: true,
WriteToFile: true,
UseTestServer: true,
APIURL: "https://api.example.invalid",
RateLimit: 42,
DropRLOverflow: true,
StrictPayloadType: true,
MaxWorkers: 64,
FileConfigVersion: ConfigVersion,
Token: "TOKEN",
UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery},
Debug: true,
ErrorTemplate: "Error: %s",
Prefixes: []string{"/", "!"},
LoggerBasePath: "/tmp/logs",
UseRequestLogger: true,
WriteToFile: true,
UseTestServer: true,
APIURL: "https://api.example.invalid",
RateLimit: 42,
DropRateLimitOverflow: true,
StrictPayloadType: true,
MaxWorkers: 64,
FileConfigVersion: ConfigVersion,
}
data, err := codec.ToBytes(want)
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+4 -4
View File
@@ -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))
+25 -33
View File
@@ -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
@@ -167,9 +167,7 @@ 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
prefix 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
}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+41 -41
View File
@@ -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
async bool // If true, runs in a goroutine; else, runs synchronously
timeout time.Duration // Duration to wait between periodic executions (ignored if onetime=true)
fn RunnerFn[T] // The function to execute
name string // Human-readable name for logging
once bool // If true, runs once; if false, runs periodically
async bool // If true, runs in a goroutine; else, runs synchronously
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)
name: name,
fn: fn,
async: true, // Default: run asynchronously
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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+4 -4
View File
@@ -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)
}
+1 -1
View File
@@ -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 -4
View File
@@ -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 = ""
)
+1 -1
View File
@@ -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)
+2
View File
@@ -0,0 +1,2 @@
// Package tgmd provides small helpers for Telegram Markdown text.
package tgmd
+295
View File
@@ -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()
}
}
+416
View File
@@ -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)
}
})
}
}
+71
View File
@@ -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 "![" + text + "](tg://time?unix=" + strconv.FormatUint(unix, 10) + ")"
}
func WithTimeFormat(text string, unix uint64, format string) string {
return "![" + text + "](tg://time?unix=" +
strconv.FormatUint(unix, 10) +
"&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
}
+24
View File
@@ -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)
}
})
}
}