(new): log formatter options
Golang lint / lint (push) Successful in 1m11s

(refactor): API initialism names

(tests): logger format coverage

(doc): updated public names
This commit is contained in:
2026-04-28 16:18:14 +03:00
parent a34734366d
commit fc4386df75
42 changed files with 544 additions and 462 deletions
+2
View File
@@ -7,12 +7,14 @@
- Bot loggers now apply the configured token replacer consistently across the main bot logger, request logger, internal API and uploader loggers, webhook logger, and auto-managed plugin loggers, so bot tokens stay masked in both stdout and file-backed logs. - Bot loggers now apply the configured token replacer consistently across the main bot logger, request logger, internal API and uploader loggers, webhook logger, and auto-managed plugin loggers, so bot tokens stay masked in both stdout and file-backed logs.
- JSON `BotOpts` files now write `version`, reject newer unsupported config versions, keep older unversioned files loadable, and preserve the loaded file version in `BotOpts.FileConfigVersion`. - JSON `BotOpts` files now write `version`, reject newer unsupported config versions, keep older unversioned files loadable, and preserve the loaded file version in `BotOpts.FileConfigVersion`.
- Active scenes now support scene-local callback payload handlers through `Scene.OnPayload(...)`, including observer lifecycle events for scene payload execution. - Active scenes now support scene-local callback payload handlers through `Scene.OnPayload(...)`, including observer lifecycle events for scene payload execution.
- Updated Go initialism names for JSON, URL, ID, and API helpers.
### Tests ### Tests
- Added regression coverage proving polling startup preserves an enabled request logger. - Added regression coverage proving polling startup preserves an enabled request logger.
- Updated file logger regression coverage for the current `sneklog` text prefix format. - Updated file logger regression coverage for the current `sneklog` text prefix format.
- Added regression coverage proving token masking still applies after `initLoggers(...)` switches loggers to file-backed writers and that auto-managed plugin loggers inherit token masking as well. - Added regression coverage proving token masking still applies after `initLoggers(...)` switches loggers to file-backed writers and that auto-managed plugin loggers inherit token masking as well.
- Added regression coverage for JSON config version handling and scene-local payload routing, including observer lifecycle events and callback fallthrough behavior. - Added regression coverage for JSON config version handling and scene-local payload routing, including observer lifecycle events and callback fallthrough behavior.
- Updated logger helper tests for the explicit log format and formatter parameters.
## v1.0.0-rc.15 ## v1.0.0-rc.15
+7 -7
View File
@@ -125,12 +125,12 @@ func main() {
`BotOpts` can also be loaded from or saved to config files through the file codec API. `BotOpts` can also be loaded from or saved to config files through the file codec API.
Built in: Built in:
- `BotOptsFileJsonCodec` for JSON files. - `BotOptsFileJSONCodec` for JSON files.
Example: Example:
```go ```go
codec := laniakea.BotOptsFileJsonCodec{} codec := laniakea.BotOptsFileJSONCodec{}
opts, err := laniakea.LoadBotOptsFile(codec, "config.json") opts, err := laniakea.LoadBotOptsFile(codec, "config.json")
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@@ -145,7 +145,7 @@ if err != nil {
Placeholders like `{{ TG_TOKEN }}` inside the file are expanded from environment variables before decoding. Placeholders like `{{ TG_TOKEN }}` inside the file are expanded from environment variables before decoding.
You can also implement your own codec for other formats by satisfying `BotOptsFileCodec`. You can also implement your own codec for other formats by satisfying `BotOptsFileCodec`.
Only JSON is supported out of the box right now. If you want another format such as TOML, use `BotOptsFileJsonCodec` as the reference implementation for your own codec. Only JSON is supported out of the box right now. If you want another format such as TOML, use `BotOptsFileJSONCodec` as the reference implementation for your own codec.
See the full guide in the wiki: [Bot Options and Configuration](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Bot-Options-and-Configuration) See the full guide in the wiki: [Bot Options and Configuration](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Bot-Options-and-Configuration)
@@ -198,12 +198,12 @@ Provides access to the incoming message and useful reply methods:
- `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message with parse_mode none and inline keyboard. - `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message with parse_mode none and inline keyboard.
- `KeyboardLong(text string, keyboard *InlineKeyboard) []*AnswerMessage`: Splits long plain text into multiple messages and attaches the keyboard to the final chunk. - `KeyboardLong(text string, keyboard *InlineKeyboard) []*AnswerMessage`: Splits long plain text into multiple messages and attaches the keyboard to the final chunk.
- `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping) and inline keyboard. - `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping) and inline keyboard.
- `AnswerPhoto(photoId, text string) *AnswerMessage`: Sends a message with photo with parse_mode none. - `AnswerPhoto(photoID, text string) *AnswerMessage`: Sends a message with photo with parse_mode none.
- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Sends a photo with MarkdownV2 caption (you handle escaping). - `AnswerPhotoMarkdown(photoID, text string) *AnswerMessage`: Sends a photo with MarkdownV2 caption (you handle escaping).
- `EditCallback(text string)`: Edits message with parse_mode none after clicking inline button. - `EditCallback(text string)`: Edits message with parse_mode none after clicking inline button.
- `EditCallbackMarkdown(text string)`: Edits a message formatted with MarkdownV2 (you handle escaping) after clicking inline button. - `EditCallbackMarkdown(text string)`: Edits a message formatted with MarkdownV2 (you handle escaping) after clicking inline button.
- `SendAction(action tgapi.ChatActionType)`: Sends a “typing”, “uploading photo”, etc., action. - `SendAction(action tgapi.ChatActionType)`: Sends a “typing”, “uploading photo”, etc., action.
- Fields: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgId`, `CallbackQueryId`, etc. - Fields: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgID`, `CallbackQueryID`, etc.
- And more methods and fields! - And more methods and fields!
### tgapi: API and Uploader ### tgapi: API and Uploader
@@ -316,7 +316,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
- Middleware can modify the MsgContext (e.g., add custom fields) before the command runs. - Middleware can modify the MsgContext (e.g., add custom fields) before the command runs.
## ⚙️ Advanced Configuration ## ⚙️ Advanced Configuration
- **Inline Keyboards**: Build keyboards using `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64`, or `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` defines the default payload format, and `InlineKeyboard.SetPayloadType(...)` overrides it for one keyboard. - **Inline Keyboards**: Build keyboards using `laniakea.NewInlineKeyboardJSON`, `laniakea.NewInlineKeyboardBase64`, or `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` defines the default payload format, and `InlineKeyboard.SetPayloadType(...)` overrides it for one keyboard.
- **Rate Limiting**: Pass a configured utils.RateLimiter via BotOpts to handle Telegram's rate limits gracefully. - **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. - **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. - **Custom Update Handlers**: Use `plugin.AddUpdateHandler(...)` for Telegram update types that are not part of the command/payload flow.
+7 -7
View File
@@ -126,12 +126,12 @@ func main() {
`BotOpts` можно не только собирать вручную или из environment, но и загружать и сохранять через file codec API. `BotOpts` можно не только собирать вручную или из environment, но и загружать и сохранять через file codec API.
Из коробки доступно: Из коробки доступно:
- `BotOptsFileJsonCodec` для JSON-файлов. - `BotOptsFileJSONCodec` для JSON-файлов.
Пример: Пример:
```go ```go
codec := laniakea.BotOptsFileJsonCodec{} codec := laniakea.BotOptsFileJSONCodec{}
opts, err := laniakea.LoadBotOptsFile(codec, "config.json") opts, err := laniakea.LoadBotOptsFile(codec, "config.json")
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@@ -146,7 +146,7 @@ if err != nil {
Плейсхолдеры вида `{{ TG_TOKEN }}` внутри файла перед декодированием разворачиваются из переменных окружения. Плейсхолдеры вида `{{ TG_TOKEN }}` внутри файла перед декодированием разворачиваются из переменных окружения.
Для других форматов можно реализовать собственный codec через интерфейс `BotOptsFileCodec`. Для других форматов можно реализовать собственный codec через интерфейс `BotOptsFileCodec`.
Из коробки сейчас поддерживается только JSON. Если нужен другой формат, например TOML, используй `BotOptsFileJsonCodec` как эталонную реализацию собственного codec. Из коробки сейчас поддерживается только JSON. Если нужен другой формат, например TOML, используй `BotOptsFileJSONCodec` как эталонную реализацию собственного codec.
Подробности есть в wiki: [Bot Options and Configuration RU](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Bot-Options-and-Configuration-RU) Подробности есть в wiki: [Bot Options and Configuration RU](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Bot-Options-and-Configuration-RU)
@@ -198,12 +198,12 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) error {
- `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение с parse_mode none и Inline клавиатурой. - `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение с parse_mode none и Inline клавиатурой.
- `KeyboardLong(text string, keyboard *InlineKeyboard) []*AnswerMessage`: Разбивает длинный plain text на несколько сообщений и вешает клавиатуру на последний chunk. - `KeyboardLong(text string, keyboard *InlineKeyboard) []*AnswerMessage`: Разбивает длинный plain text на несколько сообщений и вешает клавиатуру на последний chunk.
- `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне), и Inline клавиатурой. - `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне), и Inline клавиатурой.
- `AnswerPhoto(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью и parse_mode none. - `AnswerPhoto(photoID, text string) *AnswerMessage`: Отправляет фотографию с подписью и parse_mode none.
- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью, отформатированной MarkdownV2 (экранирование на вашей стороне). - `AnswerPhotoMarkdown(photoID, text string) *AnswerMessage`: Отправляет фотографию с подписью, отформатированной MarkdownV2 (экранирование на вашей стороне).
- `EditCallback(text string)`: Редактирует сообщение с `parse_mode` none после нажатия inline-кнопки. - `EditCallback(text string)`: Редактирует сообщение с `parse_mode` none после нажатия inline-кнопки.
- `EditCallbackMarkdown(text string)`: Редактирует сообщение в формате MarkdownV2 (экранирование на вашей стороне) после нажатия inline-кнопки. - `EditCallbackMarkdown(text string)`: Редактирует сообщение в формате MarkdownV2 (экранирование на вашей стороне) после нажатия inline-кнопки.
- `SendAction(action tgapi.ChatActionType)`: Отправляет действие "печатает", "загружает фото" и т.д. - `SendAction(action tgapi.ChatActionType)`: Отправляет действие "печатает", "загружает фото" и т.д.
- Поля: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgId`, `CallbackQueryId` и другие. - Поля: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgID`, `CallbackQueryID` и другие.
- И много других методов и полей! - И много других методов и полей!
### App Data ### App Data
@@ -313,7 +313,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
- Middleware может изменять MsgContext (например, добавлять пользовательские поля) перед запуском команды. - Middleware может изменять MsgContext (например, добавлять пользовательские поля) перед запуском команды.
## ⚙️ Расширенная настройка ## ⚙️ Расширенная настройка
- **Инлайн-клавиатуры**: Создавайте клавиатуры с помощью `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64` или `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` задаёт payload format по умолчанию, а `InlineKeyboard.SetPayloadType(...)` переопределяет его для конкретной клавиатуры. - **Инлайн-клавиатуры**: Создавайте клавиатуры с помощью `laniakea.NewInlineKeyboardJSON`, `laniakea.NewInlineKeyboardBase64` или `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` задаёт payload format по умолчанию, а `InlineKeyboard.SetPayloadType(...)` переопределяет его для конкретной клавиатуры.
- **Ограничение запросов**: Передайте настроенный `utils.RateLimiter` через `BotOpts` для корректной обработки лимитов Telegram. - **Ограничение запросов**: Передайте настроенный `utils.RateLimiter` через `BotOpts` для корректной обработки лимитов Telegram.
- **Локализация**: `L10n` безопасен для конкурентного использования после подключения к боту. - **Локализация**: `L10n` безопасен для конкурентного использования после подключения к боту.
- **Пользовательские update handlers**: Используйте `plugin.AddUpdateHandler(...)` для Telegram update types вне command/payload flow. - **Пользовательские update handlers**: Используйте `plugin.AddUpdateHandler(...)` для Telegram update types вне command/payload flow.
+19 -13
View File
@@ -50,8 +50,8 @@ type BotPayloadType string
var ( var (
// BotPayloadBase64 encodes callback data as a Base64 string. // BotPayloadBase64 encodes callback data as a Base64 string.
BotPayloadBase64 BotPayloadType = "base64" BotPayloadBase64 BotPayloadType = "base64"
// BotPayloadJson encodes callback data as a JSON string. // BotPayloadJSON encodes callback data as a JSON string.
BotPayloadJson BotPayloadType = "json" BotPayloadJSON BotPayloadType = "json"
) )
var ( var (
@@ -90,6 +90,8 @@ type Bot[T AppData] struct {
strictPayloadType bool strictPayloadType bool
maxWorkers int maxWorkers int
logFormat utils.LogFormat
logFormatter *sneklog.Formatter
logger *sneklog.Logger // Main bot logger (JSON stdout + optional file) logger *sneklog.Logger // Main bot logger (JSON stdout + optional file)
requestLogger *sneklog.Logger // Optional request-level API logging requestLogger *sneklog.Logger // Optional request-level API logging
useReqLogger bool useReqLogger bool
@@ -159,10 +161,12 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
limiter.SetGlobalRate(opts.RateLimit) limiter.SetGlobalRate(opts.RateLimit)
apiOpts := tgapi.NewAPIOpts(opts.Token). apiOpts := tgapi.NewAPIOpts(opts.Token).
SetAPIUrl(opts.APIUrl). SetAPIURL(opts.APIURL).
UseTestServer(opts.UseTestServer). UseTestServer(opts.UseTestServer).
SetLimiter(limiter). SetLimiter(limiter).
SetLimiterDrop(opts.DropRLOverflow) SetLimiterDrop(opts.DropRLOverflow).
SetLogFormat(opts.LogFormat).
SetLogFormatter(opts.LogFormatter)
api := tgapi.NewAPI(apiOpts) api := tgapi.NewAPI(apiOpts)
uploader := tgapi.NewUploader(api) uploader := tgapi.NewUploader(api)
@@ -188,6 +192,8 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
debug: opts.Debug, debug: opts.Debug,
prefixes: prefixes, prefixes: prefixes,
token: opts.Token, token: opts.Token,
logFormat: opts.LogFormat,
logFormatter: opts.LogFormatter,
useReqLogger: opts.UseRequestLogger, useReqLogger: opts.UseRequestLogger,
plugins: make([]Plugin[T], 0), plugins: make([]Plugin[T], 0),
@@ -239,21 +245,21 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
} }
// SetLogger replaces the main bot logger. // SetLogger replaces the main bot logger.
func (b *Bot[T]) SetLogger(l *sneklog.Logger) *Bot[T] { func (bot *Bot[T]) SetLogger(l *sneklog.Logger) *Bot[T] {
b.logger = l bot.logger = l
return b return bot
} }
// SetRequestLogger replaces the request-level logger. // SetRequestLogger replaces the request-level logger.
func (b *Bot[T]) SetRequestLogger(l *sneklog.Logger) *Bot[T] { func (bot *Bot[T]) SetRequestLogger(l *sneklog.Logger) *Bot[T] {
b.requestLogger = l bot.requestLogger = l
return b return bot
} }
// SetWebHookLogger replaces the webhook logger. // SetWebHookLogger replaces the webhook logger.
func (b *Bot[T]) SetWebHookLogger(l *sneklog.Logger) *Bot[T] { func (bot *Bot[T]) SetWebHookLogger(l *sneklog.Logger) *Bot[T] {
b.webHookLogger = l bot.webHookLogger = l
return b return bot
} }
// Close gracefully shuts down bot-owned resources. // Close gracefully shuts down bot-owned resources.
+4 -4
View File
@@ -19,7 +19,7 @@ func (bot *Bot[T]) AddPrefixes(prefixes ...string) *Bot[T] {
} }
// SetDraftProvider replaces the default DraftProvider with a custom one. // SetDraftProvider replaces the default DraftProvider with a custom one.
// Useful for using LinearDraftIdGenerator to persist draft IDs across restarts. // Useful for using LinearDraftIDGenerator to persist draft IDs across restarts.
func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] { func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] {
if !bot.configMutable("SetDraftProvider") { if !bot.configMutable("SetDraftProvider") {
return bot return bot
@@ -192,15 +192,15 @@ func (bot *Bot[T]) SetDebug(debug bool) *Bot[T] {
level = sneklog.DEBUG level = sneklog.DEBUG
} }
bot.logger.Level(level) bot.logger.SetLevel(level)
if bot.requestLogger != nil { if bot.requestLogger != nil {
bot.requestLogger.Level(level) bot.requestLogger.SetLevel(level)
} }
for _, p := range bot.plugins { for _, p := range bot.plugins {
if p.logger == nil { if p.logger == nil {
continue continue
} }
p.logger.Level(level) p.logger.SetLevel(level)
} }
return bot return bot
} }
+23 -7
View File
@@ -6,6 +6,8 @@ import (
"strings" "strings"
"git.scuroneko.dev/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/utils"
"git.scuroneko.dev/scuroneko/sneklog/v2"
) )
// BotOpts holds configuration options for initializing a Bot. // BotOpts holds configuration options for initializing a Bot.
@@ -45,8 +47,8 @@ type BotOpts struct {
// UseTestServer uses Telegram's test server (https://api.test.telegram.org). // UseTestServer uses Telegram's test server (https://api.test.telegram.org).
UseTestServer bool UseTestServer bool
// APIUrl overrides the default Telegram API endpoint (useful for proxies or self-hosted). // APIURL overrides the default Telegram API endpoint (useful for proxies or self-hosted).
APIUrl string APIURL string
// RateLimit is the maximum number of API requests per second. // RateLimit is the maximum number of API requests per second.
// Telegram allows up to 30 req/s for most bots. Defaults to 30. // Telegram allows up to 30 req/s for most bots. Defaults to 30.
@@ -68,6 +70,9 @@ type BotOpts struct {
// //
// It is zero when the options were not loaded from a versioned file. // It is zero when the options were not loaded from a versioned file.
FileConfigVersion int FileConfigVersion int
LogFormat utils.LogFormat
LogFormatter *sneklog.Formatter
} }
// LoadOptsFromEnv loads BotOpts from environment variables. // LoadOptsFromEnv loads BotOpts from environment variables.
@@ -87,6 +92,7 @@ type BotOpts struct {
// - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow // - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow
// - STRICT_PAYLOAD_TYPE: "true" to reject callback payloads encoded in a different format // - STRICT_PAYLOAD_TYPE: "true" to reject callback payloads encoded in a different format
// - MAX_WORKERS: maximum number of concurrent update handlers (default: 32) // - MAX_WORKERS: maximum number of concurrent update handlers (default: 32)
// - JSON_LOG:
// //
// Returns a populated BotOpts. // Returns a populated BotOpts.
// NewBot validates required fields and returns ErrTokenRequired when TG_TOKEN is missing. // NewBot validates required fields and returns ErrTokenRequired when TG_TOKEN is missing.
@@ -125,14 +131,15 @@ func LoadOptsFromEnv() *BotOpts {
WriteToFile: os.Getenv("WRITE_TO_FILE") == "true", WriteToFile: os.Getenv("WRITE_TO_FILE") == "true",
UseTestServer: os.Getenv("USE_TEST_SERVER") == "true", UseTestServer: os.Getenv("USE_TEST_SERVER") == "true",
APIUrl: os.Getenv("API_URL"), APIURL: os.Getenv("API_URL"),
RateLimit: rateLimit, RateLimit: rateLimit,
DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true", DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true", StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true",
MaxWorkers: maxWorkers, MaxWorkers: maxWorkers,
FileConfigVersion: ConfigVersion, FileConfigVersion: 0,
LogFormat: utils.LogFormat(os.Getenv("LOG_FORMAT")),
} }
} }
@@ -200,10 +207,10 @@ func (opts *BotOpts) SetUseTestServer(use bool) *BotOpts {
return opts return opts
} }
// SetAPIUrl overrides the default Telegram API endpoint (useful for proxies or self-hosted). // SetAPIURL overrides the default Telegram API endpoint (useful for proxies or self-hosted).
// If not set, defaults to "https://api.telegram.org". // If not set, defaults to "https://api.telegram.org".
func (opts *BotOpts) SetAPIUrl(url string) *BotOpts { func (opts *BotOpts) SetAPIURL(url string) *BotOpts {
opts.APIUrl = url opts.APIURL = url
return opts return opts
} }
@@ -247,6 +254,15 @@ func (opts *BotOpts) SetMaxWorkers(workers int) *BotOpts {
return opts return opts
} }
func (opts *BotOpts) SetLogFormat(format utils.LogFormat) *BotOpts {
opts.LogFormat = format
return opts
}
func (opts *BotOpts) SetLogFormatter(formatter *sneklog.Formatter) *BotOpts {
opts.LogFormatter = formatter
return opts
}
// LoadPrefixesFromEnv returns the PREFIXES environment variable split by semicolon. // LoadPrefixesFromEnv returns the PREFIXES environment variable split by semicolon.
// Defaults to ["/"] if not set. // Defaults to ["/"] if not set.
func LoadPrefixesFromEnv() []string { func LoadPrefixesFromEnv() []string {
+32 -37
View File
@@ -8,6 +8,7 @@ import (
"regexp" "regexp"
"git.scuroneko.dev/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/utils"
) )
// ConfigVersion is the current version of the built-in JSON BotOpts file format. // ConfigVersion is the current version of the built-in JSON BotOpts file format.
@@ -17,35 +18,39 @@ const ConfigVersion = 1
// than this library knows how to decode. // than this library knows how to decode.
var ErrConfigVersionMismatch = fmt.Errorf("config version mismatch: expected %d", ConfigVersion) var ErrConfigVersionMismatch = fmt.Errorf("config version mismatch: expected %d", ConfigVersion)
// BotOptsFileJson is the JSON file representation of BotOpts. type botOptsFileJSONLogger struct {
type BotOptsFileJson struct { LoggerBasePath string `json:"base_path"`
UseRequestLogger bool `json:"use_request_logger"`
WriteToFile bool `json:"write_to_file"`
LogFormat utils.LogFormat `json:"log_format"`
}
type botOptsFileJSONAPI struct {
UseTestServer bool `json:"use_test_server"`
APIURL string `json:"url"`
RateLimit int `json:"rate_limit"`
DropRLOverflow bool `json:"drop_overflow"`
}
// BotOptsFileJSON is the JSON file representation of BotOpts.
type BotOptsFileJSON struct {
Version int `json:"version"` Version int `json:"version"`
Token string `json:"token"` Token string `json:"token"`
UpdateTypes []tgapi.UpdateType `json:"update_types"` UpdateTypes []tgapi.UpdateType `json:"update_types"`
Debug bool `json:"debug"` Debug bool `json:"debug"`
ErrorTemplate string `json:"error_template"` ErrorTemplate string `json:"error_template"`
Prefixes []string `json:"prefixes"` Prefixes []string `json:"prefixes"`
Logger struct { Logger botOptsFileJSONLogger `json:"logger"`
LoggerBasePath string `json:"base_path"` API botOptsFileJSONAPI `json:"api"`
UseRequestLogger bool `json:"use_request_logger"`
WriteToFile bool `json:"write_to_file"`
} `json:"logger"`
API struct {
UseTestServer bool `json:"use_test_server"`
APIUrl string `json:"url"`
RateLimit int `json:"rate_limit"`
DropRLOverflow bool `json:"drop_overflow"`
} `json:"api"`
StrictPayloadType bool `json:"strict_payload_type"` StrictPayloadType bool `json:"strict_payload_type"`
MaxWorkers int `json:"max_workers"` MaxWorkers int `json:"max_workers"`
} }
// BotOptsFileJsonCodec encodes and decodes BotOpts using BotOptsFileJson. // BotOptsFileJSONCodec encodes and decodes BotOpts using BotOptsFileJSON.
type BotOptsFileJsonCodec struct{} type BotOptsFileJSONCodec struct{}
// FromBytes decodes BotOpts from JSON file bytes. // FromBytes decodes BotOpts from JSON file bytes.
func (codec BotOptsFileJsonCodec) FromBytes(data []byte) (*BotOpts, error) { func (codec BotOptsFileJSONCodec) FromBytes(data []byte) (*BotOpts, error) {
fileOpts := new(BotOptsFileJson) fileOpts := new(BotOptsFileJSON)
err := json.Unmarshal(data, fileOpts) err := json.Unmarshal(data, fileOpts)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -63,9 +68,10 @@ func (codec BotOptsFileJsonCodec) FromBytes(data []byte) (*BotOpts, error) {
LoggerBasePath: fileOpts.Logger.LoggerBasePath, LoggerBasePath: fileOpts.Logger.LoggerBasePath,
UseRequestLogger: fileOpts.Logger.UseRequestLogger, UseRequestLogger: fileOpts.Logger.UseRequestLogger,
WriteToFile: fileOpts.Logger.WriteToFile, WriteToFile: fileOpts.Logger.WriteToFile,
LogFormat: fileOpts.Logger.LogFormat,
UseTestServer: fileOpts.API.UseTestServer, UseTestServer: fileOpts.API.UseTestServer,
APIUrl: fileOpts.API.APIUrl, APIURL: fileOpts.API.APIURL,
RateLimit: fileOpts.API.RateLimit, RateLimit: fileOpts.API.RateLimit,
DropRLOverflow: fileOpts.API.DropRLOverflow, DropRLOverflow: fileOpts.API.DropRLOverflow,
@@ -78,37 +84,26 @@ func (codec BotOptsFileJsonCodec) FromBytes(data []byte) (*BotOpts, error) {
} }
// ToBytes encodes BotOpts into JSON file bytes. // ToBytes encodes BotOpts into JSON file bytes.
func (codec BotOptsFileJsonCodec) ToBytes(opts *BotOpts) ([]byte, error) { func (codec BotOptsFileJSONCodec) ToBytes(opts *BotOpts) ([]byte, error) {
fileOpts := &BotOptsFileJson{ fileOpts := &BotOptsFileJSON{
Version: ConfigVersion, Version: ConfigVersion,
Token: opts.Token, Token: opts.Token,
UpdateTypes: opts.UpdateTypes, UpdateTypes: opts.UpdateTypes,
Debug: opts.Debug, Debug: opts.Debug,
ErrorTemplate: opts.ErrorTemplate, ErrorTemplate: opts.ErrorTemplate,
Prefixes: opts.Prefixes, Prefixes: opts.Prefixes,
Logger: botOptsFileJSONLogger{
Logger: struct {
LoggerBasePath string `json:"base_path"`
UseRequestLogger bool `json:"use_request_logger"`
WriteToFile bool `json:"write_to_file"`
}{
LoggerBasePath: opts.LoggerBasePath, LoggerBasePath: opts.LoggerBasePath,
UseRequestLogger: opts.UseRequestLogger, UseRequestLogger: opts.UseRequestLogger,
WriteToFile: opts.WriteToFile, WriteToFile: opts.WriteToFile,
LogFormat: opts.LogFormat,
}, },
API: botOptsFileJSONAPI{
API: struct {
UseTestServer bool `json:"use_test_server"`
APIUrl string `json:"url"`
RateLimit int `json:"rate_limit"`
DropRLOverflow bool `json:"drop_overflow"`
}{
UseTestServer: opts.UseTestServer, UseTestServer: opts.UseTestServer,
APIUrl: opts.APIUrl, APIURL: opts.APIURL,
RateLimit: opts.RateLimit, RateLimit: opts.RateLimit,
DropRLOverflow: opts.DropRLOverflow, DropRLOverflow: opts.DropRLOverflow,
}, },
StrictPayloadType: opts.StrictPayloadType, StrictPayloadType: opts.StrictPayloadType,
MaxWorkers: opts.MaxWorkers, MaxWorkers: opts.MaxWorkers,
} }
@@ -119,10 +114,10 @@ func (codec BotOptsFileJsonCodec) ToBytes(opts *BotOpts) ([]byte, error) {
return data, nil return data, nil
} }
func (codec BotOptsFileJsonCodec) Load(filename string) (*BotOpts, error) { func (codec BotOptsFileJSONCodec) Load(filename string) (*BotOpts, error) {
return LoadBotOptsFile(codec, filename) return LoadBotOptsFile(codec, filename)
} }
func (codec BotOptsFileJsonCodec) Save(filename string, opts *BotOpts) error { func (codec BotOptsFileJSONCodec) Save(filename string, opts *BotOpts) error {
return SaveBotOptsFile(codec, filename, opts) return SaveBotOptsFile(codec, filename, opts)
} }
+11 -11
View File
@@ -10,8 +10,8 @@ import (
"git.scuroneko.dev/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
) )
func TestBotOptsFileJsonCodecRoundTrip(t *testing.T) { func TestBotOptsFileJSONCodecRoundTrip(t *testing.T) {
codec := BotOptsFileJsonCodec{} codec := BotOptsFileJSONCodec{}
want := &BotOpts{ want := &BotOpts{
Token: "TOKEN", Token: "TOKEN",
UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery}, UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery},
@@ -22,7 +22,7 @@ func TestBotOptsFileJsonCodecRoundTrip(t *testing.T) {
UseRequestLogger: true, UseRequestLogger: true,
WriteToFile: true, WriteToFile: true,
UseTestServer: true, UseTestServer: true,
APIUrl: "https://api.example.invalid", APIURL: "https://api.example.invalid",
RateLimit: 42, RateLimit: 42,
DropRLOverflow: true, DropRLOverflow: true,
StrictPayloadType: true, StrictPayloadType: true,
@@ -62,7 +62,7 @@ func TestLoadBotOptsFileExpandsEnvPlaceholders(t *testing.T) {
t.Fatalf("WriteFile returned error: %v", err) t.Fatalf("WriteFile returned error: %v", err)
} }
got, err := LoadBotOptsFile(BotOptsFileJsonCodec{}, filename) got, err := LoadBotOptsFile(BotOptsFileJSONCodec{}, filename)
if err != nil { if err != nil {
t.Fatalf("LoadBotOptsFile returned error: %v", err) t.Fatalf("LoadBotOptsFile returned error: %v", err)
} }
@@ -70,8 +70,8 @@ func TestLoadBotOptsFileExpandsEnvPlaceholders(t *testing.T) {
if got.Token != "TOKEN_FROM_ENV" { if got.Token != "TOKEN_FROM_ENV" {
t.Fatalf("unexpected token: got %q want %q", got.Token, "TOKEN_FROM_ENV") t.Fatalf("unexpected token: got %q want %q", got.Token, "TOKEN_FROM_ENV")
} }
if got.APIUrl != "https://api.example.invalid" { if got.APIURL != "https://api.example.invalid" {
t.Fatalf("unexpected api url: got %q want %q", got.APIUrl, "https://api.example.invalid") t.Fatalf("unexpected api url: got %q want %q", got.APIURL, "https://api.example.invalid")
} }
if got.ErrorTemplate != "Error: %s" { if got.ErrorTemplate != "Error: %s" {
t.Fatalf("unexpected error template: got %q", got.ErrorTemplate) t.Fatalf("unexpected error template: got %q", got.ErrorTemplate)
@@ -88,7 +88,7 @@ func TestLoadBotOptsFileReturnsDecodeError(t *testing.T) {
t.Fatalf("WriteFile returned error: %v", err) t.Fatalf("WriteFile returned error: %v", err)
} }
if _, err := LoadBotOptsFile(BotOptsFileJsonCodec{}, filename); err == nil { if _, err := LoadBotOptsFile(BotOptsFileJSONCodec{}, filename); err == nil {
t.Fatal("expected decode error, got nil") t.Fatal("expected decode error, got nil")
} }
} }
@@ -101,17 +101,17 @@ func TestSaveBotOptsFileWritesEncodedData(t *testing.T) {
UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage}, UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage},
ErrorTemplate: "Error: %s", ErrorTemplate: "Error: %s",
Prefixes: []string{"/"}, Prefixes: []string{"/"},
APIUrl: "https://api.example.invalid", APIURL: "https://api.example.invalid",
RateLimit: 30, RateLimit: 30,
MaxWorkers: 32, MaxWorkers: 32,
FileConfigVersion: ConfigVersion, FileConfigVersion: ConfigVersion,
} }
if err := SaveBotOptsFile(BotOptsFileJsonCodec{}, filename, want); err != nil { if err := SaveBotOptsFile(BotOptsFileJSONCodec{}, filename, want); err != nil {
t.Fatalf("SaveBotOptsFile returned error: %v", err) t.Fatalf("SaveBotOptsFile returned error: %v", err)
} }
got, err := LoadBotOptsFile(BotOptsFileJsonCodec{}, filename) got, err := LoadBotOptsFile(BotOptsFileJSONCodec{}, filename)
if err != nil { if err != nil {
t.Fatalf("LoadBotOptsFile returned error: %v", err) t.Fatalf("LoadBotOptsFile returned error: %v", err)
} }
@@ -132,7 +132,7 @@ func TestLoadBotOptsFileRejectsFutureConfigVersion(t *testing.T) {
t.Fatalf("WriteFile returned error: %v", err) t.Fatalf("WriteFile returned error: %v", err)
} }
_, err := LoadBotOptsFile(BotOptsFileJsonCodec{}, filename) _, err := LoadBotOptsFile(BotOptsFileJSONCodec{}, filename)
if !errors.Is(err, ErrConfigVersionMismatch) { if !errors.Is(err, ErrConfigVersionMismatch) {
t.Fatalf("expected ErrConfigVersionMismatch, got %v", err) t.Fatalf("expected ErrConfigVersionMismatch, got %v", err)
} }
+5 -5
View File
@@ -28,7 +28,7 @@ func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] {
} }
cloned := clonePlugin(p) cloned := clonePlugin(p)
if cloned.logger == nil { if cloned.logger == nil {
cloned.logger = utils.CreateLogger(cloned.name, level) cloned.logger = utils.CreateLogger(cloned.name, level, bot.logFormat, bot.logFormatter)
} }
bot.addTokenReplacer(cloned.logger) bot.addTokenReplacer(cloned.logger)
bot.plugins = append(bot.plugins, cloned) bot.plugins = append(bot.plugins, cloned)
@@ -142,16 +142,16 @@ func (bot *Bot[T]) AddAppDataLoggerWriter(writer AppDataLogger[T]) *Bot[T] {
return bot return bot
} }
w := writer(bot.appData) w := writer(bot.appData)
bot.logger.AddWriter(w) bot.logger.AddWriters(w)
if bot.requestLogger != nil { if bot.requestLogger != nil {
bot.requestLogger.AddWriter(w) bot.requestLogger.AddWriters(w)
} }
for _, l := range bot.managedExtraLoggers() { for _, l := range bot.managedExtraLoggers() {
l.AddWriter(w) l.AddWriters(w)
} }
for _, p := range bot.plugins { for _, p := range bot.plugins {
if p.logger != nil { if p.logger != nil {
p.logger.AddWriter(w) p.logger.AddWriters(w)
} }
} }
bot.addTokenReplacer(bot.logger, bot.requestLogger) bot.addTokenReplacer(bot.logger, bot.requestLogger)
+25 -25
View File
@@ -59,7 +59,7 @@ func TestGetUpdateTypesReturnsCopy(t *testing.T) {
} }
func TestAddPluginsSnapshotsConfiguration(t *testing.T) { func TestAddPluginsSnapshotsConfiguration(t *testing.T) {
bot := &Bot[NoData]{logger: sneklog.CreateLogger()} bot := &Bot[NoData]{logger: sneklog.NewLogger()}
plugin := NewPlugin[NoData]("demo") plugin := NewPlugin[NoData]("demo")
cmd := plugin.NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "start") cmd := plugin.NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "start")
@@ -89,8 +89,8 @@ func TestBotPayloadTypeConfiguration(t *testing.T) {
if got := bot.GetPayloadType(); got != BotPayloadBase64 { if got := bot.GetPayloadType(); got != BotPayloadBase64 {
t.Fatalf("unexpected initial payload type: %q", got) t.Fatalf("unexpected initial payload type: %q", got)
} }
bot.SetPayloadType(BotPayloadJson) bot.SetPayloadType(BotPayloadJSON)
if got := bot.GetPayloadType(); got != BotPayloadJson { if got := bot.GetPayloadType(); got != BotPayloadJSON {
t.Fatalf("unexpected updated payload type: %q", got) t.Fatalf("unexpected updated payload type: %q", got)
} }
bot.SetStrictPayloadType(true) bot.SetStrictPayloadType(true)
@@ -100,7 +100,7 @@ func TestBotPayloadTypeConfiguration(t *testing.T) {
} }
func TestAddPluginsSkipsNilPlugin(t *testing.T) { func TestAddPluginsSkipsNilPlugin(t *testing.T) {
bot := &Bot[NoData]{logger: sneklog.CreateLogger()} bot := &Bot[NoData]{logger: sneklog.NewLogger()}
plugin := NewPlugin[NoData]("demo") plugin := NewPlugin[NoData]("demo")
bot.AddPlugins(nil, plugin) bot.AddPlugins(nil, plugin)
@@ -166,7 +166,7 @@ func TestInitLoggersAppliesTokenReplacerToFileLoggers(t *testing.T) {
t.Fatalf("failed to open api log: %v", err) t.Fatalf("failed to open api log: %v", err)
} }
defer func() { _ = apiFile.Close() }() defer func() { _ = apiFile.Close() }()
bot.api.GetLogger().AddWriter(bot.api.GetLogger().CreateTextWriter(apiFile)) bot.api.GetLogger().AddWriters(bot.api.GetLogger().CreateTextWriter(apiFile))
uploaderPath := filepath.Join(tempDir, "uploader.log") uploaderPath := filepath.Join(tempDir, "uploader.log")
uploaderFile, err := os.OpenFile(uploaderPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) uploaderFile, err := os.OpenFile(uploaderPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
@@ -174,7 +174,7 @@ func TestInitLoggersAppliesTokenReplacerToFileLoggers(t *testing.T) {
t.Fatalf("failed to open uploader log: %v", err) t.Fatalf("failed to open uploader log: %v", err)
} }
defer func() { _ = uploaderFile.Close() }() defer func() { _ = uploaderFile.Close() }()
bot.uploader.GetLogger().AddWriter(bot.uploader.GetLogger().CreateTextWriter(uploaderFile)) bot.uploader.GetLogger().AddWriters(bot.uploader.GetLogger().CreateTextWriter(uploaderFile))
bot.logger.Infoln("main secret-token") bot.logger.Infoln("main secret-token")
bot.requestLogger.Infoln("request secret-token") bot.requestLogger.Infoln("request secret-token")
@@ -226,7 +226,7 @@ func TestInitLoggersAppliesTokenReplacerToFileLoggers(t *testing.T) {
func TestAddPluginsAppliesTokenReplacerToPluginLogger(t *testing.T) { func TestAddPluginsAppliesTokenReplacerToPluginLogger(t *testing.T) {
bot := &Bot[NoData]{ bot := &Bot[NoData]{
token: "secret-token", token: "secret-token",
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
} }
defer func() { _ = bot.logger.Close() }() defer func() { _ = bot.logger.Close() }()
@@ -240,7 +240,7 @@ func TestAddPluginsAppliesTokenReplacerToPluginLogger(t *testing.T) {
} }
defer func() { _ = file.Close() }() defer func() { _ = file.Close() }()
bot.plugins[0].logger.AddWriter(bot.plugins[0].logger.CreateTextWriter(file)) bot.plugins[0].logger.AddWriters(bot.plugins[0].logger.CreateTextWriter(file))
bot.plugins[0].logger.Infoln("plugin secret-token") bot.plugins[0].logger.Infoln("plugin secret-token")
data, err := os.ReadFile(logPath) data, err := os.ReadFile(logPath)
@@ -276,7 +276,7 @@ func TestNextPollRetryDelay(t *testing.T) {
} }
func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsUnset(t *testing.T) { func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsUnset(t *testing.T) {
bot := &Bot[NoData]{logger: sneklog.CreateLogger()} bot := &Bot[NoData]{logger: sneklog.NewLogger()}
called := false called := false
bot.AddAppDataLoggerWriter(func(db NoData) sneklog.LoggerWriter { bot.AddAppDataLoggerWriter(func(db NoData) sneklog.LoggerWriter {
@@ -292,7 +292,7 @@ func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsUnset(t *testing.T) {
func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsNil(t *testing.T) { func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsNil(t *testing.T) {
type testDB struct{} type testDB struct{}
bot := &Bot[*testDB]{logger: sneklog.CreateLogger()} bot := &Bot[*testDB]{logger: sneklog.NewLogger()}
var db *testDB var db *testDB
bot.SetAppData(db) bot.SetAppData(db)
@@ -336,13 +336,13 @@ func TestShouldWarnOnValueAppData(t *testing.T) {
func TestSetAppDataMarksValueWarningOnce(t *testing.T) { func TestSetAppDataMarksValueWarningOnce(t *testing.T) {
type testDB struct{} type testDB struct{}
bot := &Bot[testDB]{logger: sneklog.CreateLogger()} bot := &Bot[testDB]{logger: sneklog.NewLogger()}
bot.SetAppData(testDB{}) bot.SetAppData(testDB{})
if !bot.warnedValueData { if !bot.warnedValueData {
t.Fatal("expected value-typed app data to mark warning state") t.Fatal("expected value-typed app data to mark warning state")
} }
ptrBot := &Bot[*testDB]{logger: sneklog.CreateLogger()} ptrBot := &Bot[*testDB]{logger: sneklog.NewLogger()}
ptrBot.SetAppData(&testDB{}) ptrBot.SetAppData(&testDB{})
if ptrBot.warnedValueData { if ptrBot.warnedValueData {
t.Fatal("did not expect pointer-typed app data to mark warning state") t.Fatal("did not expect pointer-typed app data to mark warning state")
@@ -350,7 +350,7 @@ func TestSetAppDataMarksValueWarningOnce(t *testing.T) {
} }
func TestSetObserverAndGetObserver(t *testing.T) { func TestSetObserverAndGetObserver(t *testing.T) {
bot := &Bot[NoData]{logger: sneklog.CreateLogger()} bot := &Bot[NoData]{logger: sneklog.NewLogger()}
observer := testObserver{} observer := testObserver{}
if got := bot.GetObserver(); got != nil { if got := bot.GetObserver(); got != nil {
@@ -364,7 +364,7 @@ func TestSetObserverAndGetObserver(t *testing.T) {
} }
func TestSetObserverNilClearsObserver(t *testing.T) { func TestSetObserverNilClearsObserver(t *testing.T) {
bot := &Bot[NoData]{logger: sneklog.CreateLogger()} bot := &Bot[NoData]{logger: sneklog.NewLogger()}
bot.SetObserver(testObserver{}) bot.SetObserver(testObserver{})
if bot.GetObserver() == nil { if bot.GetObserver() == nil {
@@ -382,7 +382,7 @@ func TestRunWithContextRejectsSecondRun(t *testing.T) {
cancel() cancel()
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
plugins: []Plugin[NoData]{{name: "demo"}}, plugins: []Plugin[NoData]{{name: "demo"}},
updateQueue: make(chan *tgapi.Update, 1), updateQueue: make(chan *tgapi.Update, 1),
@@ -401,9 +401,9 @@ func TestRunWithContextKeepsEnabledRequestLogger(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()
requestLogger := sneklog.CreateLogger() requestLogger := sneklog.NewLogger()
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
requestLogger: requestLogger, requestLogger: requestLogger,
useReqLogger: true, useReqLogger: true,
prefixes: []string{"/"}, prefixes: []string{"/"},
@@ -438,14 +438,14 @@ func TestCloseDoesNotDeleteWebhook(t *testing.T) {
api := tgapi.NewAPI( api := tgapi.NewAPI(
tgapi.NewAPIOpts("token"). tgapi.NewAPIOpts("token").
SetAPIUrl("http://example.invalid"). SetAPIURL("http://example.invalid").
SetHTTPClient(client), SetHTTPClient(client),
) )
uploader := tgapi.NewUploader(api) uploader := tgapi.NewUploader(api)
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
webHookLogger: sneklog.CreateLogger(), webHookLogger: sneklog.NewLogger(),
api: api, api: api,
uploader: uploader, uploader: uploader,
} }
@@ -475,7 +475,7 @@ func TestRunWithContextEmitsPollingRetryAndErrorEvents(t *testing.T) {
api := tgapi.NewAPI( api := tgapi.NewAPI(
tgapi.NewAPIOpts("token"). tgapi.NewAPIOpts("token").
SetAPIUrl("http://example.invalid"). SetAPIURL("http://example.invalid").
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
@@ -483,7 +483,7 @@ func TestRunWithContextEmitsPollingRetryAndErrorEvents(t *testing.T) {
}() }()
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
api: api, api: api,
prefixes: []string{"/"}, prefixes: []string{"/"},
plugins: []Plugin[NoData]{{name: "demo"}}, plugins: []Plugin[NoData]{{name: "demo"}},
@@ -515,7 +515,7 @@ func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) {
makeBot := func() *Bot[*testDB] { makeBot := func() *Bot[*testDB] {
return &Bot[*testDB]{ return &Bot[*testDB]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage}, updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage},
payloadType: BotPayloadBase64, payloadType: BotPayloadBase64,
@@ -587,7 +587,7 @@ func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) {
} }
t.Cleanup(bot.finishRun) t.Cleanup(bot.finishRun)
bot.SetPayloadType(BotPayloadJson) bot.SetPayloadType(BotPayloadJSON)
if bot.payloadType != BotPayloadBase64 { if bot.payloadType != BotPayloadBase64 {
t.Fatalf("payloadType mutated after configuration freeze: got %q want %q", bot.payloadType, BotPayloadBase64) t.Fatalf("payloadType mutated after configuration freeze: got %q want %q", bot.payloadType, BotPayloadBase64)
} }
@@ -707,7 +707,7 @@ func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) {
func TestAddPluginsAndRuntimeRegistrationsNoOpAfterRunStarts(t *testing.T) { func TestAddPluginsAndRuntimeRegistrationsNoOpAfterRunStarts(t *testing.T) {
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
middlewares: []Middleware[NoData]{NewMiddleware("base", func(ctx *MsgContext, db NoData) bool { return true })}, middlewares: []Middleware[NoData]{NewMiddleware("base", func(ctx *MsgContext, db NoData) bool { return true })},
runners: []Runner[NoData]{NewRunner("base", func(bot *Bot[NoData]) error { return nil })}, runners: []Runner[NoData]{NewRunner("base", func(bot *Bot[NoData]) error { return nil })},
+5 -4
View File
@@ -76,11 +76,12 @@ func (bot *Bot[T]) initLoggers(opts *BotOpts) {
level = sneklog.DEBUG level = sneklog.DEBUG
} }
format, formatter := opts.LogFormat, opts.LogFormatter
if bot.logger == nil { if bot.logger == nil {
bot.logger = utils.CreateLogger("BOT", level) bot.logger = utils.CreateLogger("BOT", level, format, formatter)
if opts.WriteToFile { if opts.WriteToFile {
path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/")) path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/"))
logger, err := utils.CreateFileLogger("BOT", level, path) logger, err := utils.CreateFileLogger("BOT", level, path, format, formatter)
if err != nil { if err != nil {
bot.logger.Errorln(err) bot.logger.Errorln(err)
} else { } else {
@@ -90,10 +91,10 @@ func (bot *Bot[T]) initLoggers(opts *BotOpts) {
} }
if opts.UseRequestLogger && bot.requestLogger == nil { if opts.UseRequestLogger && bot.requestLogger == nil {
bot.requestLogger = utils.CreateLogger("REQUESTS", level) bot.requestLogger = utils.CreateLogger("REQUESTS", level, format, formatter)
if opts.WriteToFile { if opts.WriteToFile {
path := fmt.Sprintf("%s/requests.log", strings.TrimRight(opts.LoggerBasePath, "/")) path := fmt.Sprintf("%s/requests.log", strings.TrimRight(opts.LoggerBasePath, "/"))
logger, err := utils.CreateFileLogger("REQUESTS", level, path) logger, err := utils.CreateFileLogger("REQUESTS", level, path, format, formatter)
if err != nil { if err != nil {
bot.logger.Errorln(err) bot.logger.Errorln(err)
} else { } else {
+1 -1
View File
@@ -152,7 +152,7 @@ func (bot *Bot[T]) RunWebHookWithContext(ctx context.Context, opts *BotWebHookOp
return err return err
} }
bot.webHookLogger = utils.CreateLogger("WEBHOOK", bot.GetLoggerLevel()) bot.webHookLogger = utils.CreateLogger("WEBHOOK", bot.GetLoggerLevel(), bot.logFormat, bot.logFormatter)
bot.addTokenReplacer(bot.webHookLogger) bot.addTokenReplacer(bot.webHookLogger)
if opts.SecretToken == "" { if opts.SecretToken == "" {
bot.webHookLogger.Warnln("Bot webhook secret token empty. It's VERY recommended to set secret.") bot.webHookLogger.Warnln("Bot webhook secret token empty. It's VERY recommended to set secret.")
+6 -6
View File
@@ -35,7 +35,7 @@ func TestEnqueueUpdateCopiesValue(t *testing.T) {
func TestUpdateHandlerEnqueuesUpdate(t *testing.T) { func TestUpdateHandlerEnqueuesUpdate(t *testing.T) {
bot := &Bot[NoData]{ bot := &Bot[NoData]{
updateQueue: make(chan *tgapi.Update, 1), updateQueue: make(chan *tgapi.Update, 1),
webHookLogger: sneklog.CreateLogger(), webHookLogger: sneklog.NewLogger(),
} }
t.Cleanup(func() { t.Cleanup(func() {
_ = bot.webHookLogger.Close() _ = bot.webHookLogger.Close()
@@ -66,7 +66,7 @@ func TestUpdateHandlerEnqueuesUpdate(t *testing.T) {
func TestRunWebhookRuntimeRejectsSecondRun(t *testing.T) { func TestRunWebhookRuntimeRejectsSecondRun(t *testing.T) {
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
updateQueue: make(chan *tgapi.Update, 1), updateQueue: make(chan *tgapi.Update, 1),
maxWorkers: 1, maxWorkers: 1,
} }
@@ -86,7 +86,7 @@ func TestRunWebhookRuntimeExecutesRunners(t *testing.T) {
var calls atomic.Int32 var calls atomic.Int32
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
updateQueue: make(chan *tgapi.Update, 1), updateQueue: make(chan *tgapi.Update, 1),
maxWorkers: 1, maxWorkers: 1,
runners: []Runner[NoData]{ runners: []Runner[NoData]{
@@ -185,7 +185,7 @@ func TestValidateWebhookTLSFiles(t *testing.T) {
func TestUpdateHandlerRejectsOversizedBody(t *testing.T) { func TestUpdateHandlerRejectsOversizedBody(t *testing.T) {
bot := &Bot[NoData]{ bot := &Bot[NoData]{
updateQueue: make(chan *tgapi.Update, 1), updateQueue: make(chan *tgapi.Update, 1),
webHookLogger: sneklog.CreateLogger(), webHookLogger: sneklog.NewLogger(),
} }
t.Cleanup(func() { t.Cleanup(func() {
_ = bot.webHookLogger.Close() _ = bot.webHookLogger.Close()
@@ -213,7 +213,7 @@ func TestStatusHandlerRequiresMatchingSecret(t *testing.T) {
} }
api := tgapi.NewAPI( api := tgapi.NewAPI(
tgapi.NewAPIOpts("token"). tgapi.NewAPIOpts("token").
SetAPIUrl("http://example.invalid"). SetAPIURL("http://example.invalid").
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
@@ -222,7 +222,7 @@ func TestStatusHandlerRequiresMatchingSecret(t *testing.T) {
bot := &Bot[NoData]{ bot := &Bot[NoData]{
api: api, api: api,
webHookLogger: sneklog.CreateLogger(), webHookLogger: sneklog.NewLogger(),
} }
t.Cleanup(func() { t.Cleanup(func() {
_ = bot.webHookLogger.Close() _ = bot.webHookLogger.Close()
+2 -2
View File
@@ -34,7 +34,7 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) {
} }
api := tgapi.NewAPI( api := tgapi.NewAPI(
tgapi.NewAPIOpts("token"). tgapi.NewAPIOpts("token").
SetAPIUrl("https://example.test"). SetAPIURL("https://example.test").
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
@@ -51,7 +51,7 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) {
bot := &Bot[NoData]{ bot := &Bot[NoData]{
api: api, api: api,
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
plugins: []Plugin[NoData]{*plugin}, plugins: []Plugin[NoData]{*plugin},
} }
+14 -14
View File
@@ -9,29 +9,29 @@ import (
) )
// Interface for generating unique draft IDs. // Interface for generating unique draft IDs.
type draftIdGenerator interface { type draftIDGenerator interface {
// Next returns the next unique draft ID. // Next returns the next unique draft ID.
Next() uint64 Next() uint64
} }
// RandomDraftIdGenerator generates draft IDs using cryptographically secure random numbers. // RandomDraftIDGenerator generates draft IDs using cryptographically secure random numbers.
// Suitable for distributed systems or when ID predictability is undesirable. // Suitable for distributed systems or when ID predictability is undesirable.
type RandomDraftIdGenerator struct{} type RandomDraftIDGenerator struct{}
// Next returns a random 64-bit unsigned integer. // Next returns a random 64-bit unsigned integer.
func (g *RandomDraftIdGenerator) Next() uint64 { func (g *RandomDraftIDGenerator) Next() uint64 {
return rand.Uint64() return rand.Uint64()
} }
// LinearDraftIdGenerator generates draft IDs using a monotonically increasing counter. // LinearDraftIDGenerator generates draft IDs using a monotonically increasing counter.
// Useful for debugging, persistence, or when drafts must be ordered. // Useful for debugging, persistence, or when drafts must be ordered.
type LinearDraftIdGenerator struct { type LinearDraftIDGenerator struct {
lastId atomic.Uint64 lastID atomic.Uint64
} }
// Next returns the next linear ID, atomically incremented. // Next returns the next linear ID, atomically incremented.о
func (g *LinearDraftIdGenerator) Next() uint64 { func (g *LinearDraftIDGenerator) Next() uint64 {
return g.lastId.Add(1) return g.lastID.Add(1)
} }
// DraftProvider manages a collection of Drafts and a shared draft ID generator. // DraftProvider manages a collection of Drafts and a shared draft ID generator.
@@ -41,7 +41,7 @@ type DraftProvider struct {
mu sync.RWMutex mu sync.RWMutex
api *tgapi.API api *tgapi.API
drafts map[uint64]*Draft drafts map[uint64]*Draft
generator draftIdGenerator generator draftIDGenerator
} }
// NewRandomDraftProvider creates a new DraftProvider using random draft IDs. // NewRandomDraftProvider creates a new DraftProvider using random draft IDs.
@@ -50,7 +50,7 @@ type DraftProvider struct {
// All drafts created via this provider will have unpredictable, unique IDs. // All drafts created via this provider will have unpredictable, unique IDs.
func NewRandomDraftProvider(api *tgapi.API) *DraftProvider { func NewRandomDraftProvider(api *tgapi.API) *DraftProvider {
return &DraftProvider{ return &DraftProvider{
api: api, generator: &RandomDraftIdGenerator{}, api: api, generator: &RandomDraftIDGenerator{},
drafts: make(map[uint64]*Draft), drafts: make(map[uint64]*Draft),
} }
} }
@@ -63,8 +63,8 @@ func NewRandomDraftProvider(api *tgapi.API) *DraftProvider {
// This is useful when you need to store draft IDs externally (e.g., in a database) // This is useful when you need to store draft IDs externally (e.g., in a database)
// and want to reconstruct drafts after restart. // and want to reconstruct drafts after restart.
func NewLinearDraftProvider(api *tgapi.API, startValue uint64) *DraftProvider { func NewLinearDraftProvider(api *tgapi.API, startValue uint64) *DraftProvider {
g := &LinearDraftIdGenerator{} g := &LinearDraftIDGenerator{}
g.lastId.Store(startValue) g.lastID.Store(startValue)
return &DraftProvider{ return &DraftProvider{
api: api, api: api,
generator: g, generator: g,
+3 -3
View File
@@ -13,18 +13,18 @@ func TestDraftFlushRequiresChatID(t *testing.T) {
draft := NewRandomDraftProvider(&tgapi.API{}).NewDraft(tgapi.ParseNone) draft := NewRandomDraftProvider(&tgapi.API{}).NewDraft(tgapi.ParseNone)
draft.Message = "hello" draft.Message = "hello"
if err := draft.Flush(); err != ErrDraftChatIDZero { if err := draft.Flush(); !errors.Is(err, ErrDraftChatIDZero) {
t.Fatalf("expected ErrDraftChatIDZero, got %v", err) t.Fatalf("expected ErrDraftChatIDZero, got %v", err)
} }
} }
func TestMsgContextNewDraftWorksWithoutLimiter(t *testing.T) { func TestMsgContextNewDraftWorksWithoutLimiter(t *testing.T) {
ctx := &MsgContext{ ctx := &MsgContext{
Api: &tgapi.API{}, API: &tgapi.API{},
Msg: &tgapi.Message{ Msg: &tgapi.Message{
Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate},
}, },
Logger: sneklog.CreateLogger(), Logger: sneklog.NewLogger(),
draftProvider: NewRandomDraftProvider(&tgapi.API{}), draftProvider: NewRandomDraftProvider(&tgapi.API{}),
} }
+1 -1
View File
@@ -6,7 +6,7 @@ retract v1.0.0-rc.5
require ( require (
git.scuroneko.dev/scuroneko/extypes v1.2.3 git.scuroneko.dev/scuroneko/extypes v1.2.3
git.scuroneko.dev/scuroneko/sneklog/v2 v2.0.1 git.scuroneko.dev/scuroneko/sneklog/v2 v2.3.0
github.com/alitto/pond/v2 v2.7.1 github.com/alitto/pond/v2 v2.7.1
golang.org/x/time v0.15.0 golang.org/x/time v0.15.0
) )
+2 -2
View File
@@ -1,7 +1,7 @@
git.scuroneko.dev/scuroneko/extypes v1.2.3 h1:n7QsfTZEn9fJNZLXGH/LkNq4cADaRk+LTu6LNMv9y6s= git.scuroneko.dev/scuroneko/extypes v1.2.3 h1:n7QsfTZEn9fJNZLXGH/LkNq4cADaRk+LTu6LNMv9y6s=
git.scuroneko.dev/scuroneko/extypes v1.2.3/go.mod h1:MhYpXC6sloLOpoM2guf64eSOrz+ET/QJZ8toobc3Ors= git.scuroneko.dev/scuroneko/extypes v1.2.3/go.mod h1:MhYpXC6sloLOpoM2guf64eSOrz+ET/QJZ8toobc3Ors=
git.scuroneko.dev/scuroneko/sneklog/v2 v2.0.1 h1:nDGLsvxbYoSxSk9wrGCmb+tn5jPM+KYlBX+3ciyC2Yo= git.scuroneko.dev/scuroneko/sneklog/v2 v2.3.0 h1:gaPe5azwuDTh48jRB/P2FUgOs7f1ToNr0S+NBizKvY8=
git.scuroneko.dev/scuroneko/sneklog/v2 v2.0.1/go.mod h1:q8XnLXzLdGjW0Jtcbh9/+G9WmfD68rsPQvLXEPxvum4= git.scuroneko.dev/scuroneko/sneklog/v2 v2.3.0/go.mod h1:q8XnLXzLdGjW0Jtcbh9/+G9WmfD68rsPQvLXEPxvum4=
github.com/alitto/pond/v2 v2.7.1 h1:QxMbcfjcVTa0pyxX5Ib1226mM8u8D7gKUVkCUU4DYIw= github.com/alitto/pond/v2 v2.7.1 h1:QxMbcfjcVTa0pyxX5Ib1226mM8u8D7gKUVkCUU4DYIw=
github.com/alitto/pond/v2 v2.7.1/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE= github.com/alitto/pond/v2 v2.7.1/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+12 -12
View File
@@ -26,7 +26,7 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) {
defer cancel() defer cancel()
msgCtx := &MsgContext{ msgCtx := &MsgContext{
Update: *u, Api: bot.api, Update: *u, API: bot.api,
Logger: bot.logger, Logger: bot.logger,
errorTemplate: bot.errorTemplate, errorTemplate: bot.errorTemplate,
l10n: bot.l10n, l10n: bot.l10n,
@@ -113,7 +113,7 @@ func cloneMsgContext(src *MsgContext) *MsgContext {
return &cloned return &cloned
} }
func encodeJsonPayload(d CallbackData) (string, error) { func encodeJSONPayload(d CallbackData) (string, error) {
b, err := json.Marshal(d) b, err := json.Marshal(d)
if err != nil { if err != nil {
return "", err return "", err
@@ -121,14 +121,14 @@ func encodeJsonPayload(d CallbackData) (string, error) {
return string(b), nil return string(b), nil
} }
func decodeJsonPayload(s string) (CallbackData, error) { func decodeJSONPayload(s string) (CallbackData, error) {
var data CallbackData var data CallbackData
err := json.Unmarshal([]byte(s), &data) err := json.Unmarshal([]byte(s), &data)
return data, err return data, err
} }
func encodeBase64Payload(d CallbackData) (string, error) { func encodeBase64Payload(d CallbackData) (string, error) {
data, err := encodeJsonPayload(d) data, err := encodeJSONPayload(d)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -142,7 +142,7 @@ func decodeBase64Payload(s string) (CallbackData, error) {
if err != nil { if err != nil {
return CallbackData{}, err return CallbackData{}, err
} }
return decodeJsonPayload(string(b)) return decodeJSONPayload(string(b))
} }
func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackData, BotPayloadType, error) { func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackData, BotPayloadType, error) {
@@ -155,18 +155,18 @@ func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackD
if strict { if strict {
return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadBase64) return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadBase64)
} }
data, err = decodeJsonPayload(s) data, err = decodeJSONPayload(s)
if err != nil { if err != nil {
return CallbackData{}, "", err return CallbackData{}, "", err
} }
return data, BotPayloadJson, nil return data, BotPayloadJSON, nil
case BotPayloadJson: case BotPayloadJSON:
data, err := decodeJsonPayload(s) data, err := decodeJSONPayload(s)
if err == nil { if err == nil {
return data, BotPayloadJson, nil return data, BotPayloadJSON, nil
} }
if strict { if strict {
return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadJson) return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadJSON)
} }
data, err = decodeBase64Payload(s) data, err = decodeBase64Payload(s)
if err != nil { if err != nil {
@@ -183,7 +183,7 @@ func (bot *Bot[T]) decodePayload(s string) (CallbackData, error) {
return CallbackData{}, err return CallbackData{}, err
} }
if decodedType == BotPayloadBase64 && bot.debug && bot.logger != nil { if decodedType == BotPayloadBase64 && bot.debug && bot.logger != nil {
bot.logger.Debugf("decoded callback payload base64->json: raw=%q json=%s", s, data.ToJson()) bot.logger.Debugf("decoded callback payload base64->json: raw=%q json=%s", s, data.ToJSON())
} }
return data, nil return data, nil
} }
+53 -53
View File
@@ -55,7 +55,7 @@ func TestCheckPrefixesSkipsEmptyPrefixes(t *testing.T) {
} }
func TestBotMiddlewareReceivesLogger(t *testing.T) { func TestBotMiddlewareReceivesLogger(t *testing.T) {
logger := sneklog.CreateLogger() logger := sneklog.NewLogger()
called := false called := false
bot := &Bot[NoData]{ bot := &Bot[NoData]{
@@ -391,14 +391,14 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
if ctx.ChatID != tt.wantChatID { if ctx.ChatID != tt.wantChatID {
t.Fatalf("unexpected ChatID: got %d want %d", ctx.ChatID, tt.wantChatID) t.Fatalf("unexpected ChatID: got %d want %d", ctx.ChatID, tt.wantChatID)
} }
if ctx.CallbackQueryId != tt.wantCallbackID { if ctx.CallbackQueryID != tt.wantCallbackID {
t.Fatalf("unexpected CallbackQueryId: got %q want %q", ctx.CallbackQueryId, tt.wantCallbackID) t.Fatalf("unexpected CallbackQueryID: got %q want %q", ctx.CallbackQueryID, tt.wantCallbackID)
} }
if ctx.CallbackMsgId != tt.wantCallbackMsgID { if ctx.CallbackMsgID != tt.wantCallbackMsgID {
t.Fatalf("unexpected CallbackMsgId: got %d want %d", ctx.CallbackMsgId, tt.wantCallbackMsgID) t.Fatalf("unexpected CallbackMsgID: got %d want %d", ctx.CallbackMsgID, tt.wantCallbackMsgID)
} }
if ctx.InlineMsgId != tt.wantInlineMsgID { if ctx.InlineMsgID != tt.wantInlineMsgID {
t.Fatalf("unexpected InlineMsgId: got %q want %q", ctx.InlineMsgId, tt.wantInlineMsgID) t.Fatalf("unexpected InlineMsgID: got %q want %q", ctx.InlineMsgID, tt.wantInlineMsgID)
} }
if ctx.Text != "" { if ctx.Text != "" {
t.Fatalf("prepareUpdateCtx must not populate Text, got %q", ctx.Text) t.Fatalf("prepareUpdateCtx must not populate Text, got %q", ctx.Text)
@@ -468,7 +468,7 @@ func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) {
}) })
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
plugins: []Plugin[NoData]{clonePlugin(plugin)}, plugins: []Plugin[NoData]{clonePlugin(plugin)},
} }
@@ -514,7 +514,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
}) })
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
plugins: []Plugin[NoData]{ plugins: []Plugin[NoData]{
clonePlugin(first), clonePlugin(first),
clonePlugin(second), clonePlugin(second),
@@ -543,7 +543,7 @@ func TestHandleUpdateObserverEmitsUpdateErrors(t *testing.T) {
}) })
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
plugins: []Plugin[NoData]{clonePlugin(plugin)}, plugins: []Plugin[NoData]{clonePlugin(plugin)},
observer: observer, observer: observer,
} }
@@ -607,7 +607,7 @@ func TestHandleMessageFallbackRunsAfterCommandMiss(t *testing.T) {
}) })
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
observer: observer, observer: observer,
} }
@@ -658,7 +658,7 @@ func TestHandleMessageFallbackRunsForPlainText(t *testing.T) {
}) })
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
} }
bot.AddPlugins(plugin) bot.AddPlugins(plugin)
@@ -691,7 +691,7 @@ func TestHandleMessageFallbackRespectsMiddleware(t *testing.T) {
}) })
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
} }
bot.AddPlugins(plugin) bot.AddPlugins(plugin)
@@ -726,7 +726,7 @@ func TestHandleMessageFallbackDoesNotRunWhenCommandMatches(t *testing.T) {
}) })
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
} }
bot.AddPlugins(plugin) bot.AddPlugins(plugin)
@@ -771,7 +771,7 @@ func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
}, "ping") }, "ping")
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
plugins: []Plugin[NoData]{clonePlugin(plugin)}, plugins: []Plugin[NoData]{clonePlugin(plugin)},
} }
@@ -808,7 +808,7 @@ func TestCommandHandlerBindArgsEndToEnd(t *testing.T) {
) )
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
plugins: []Plugin[NoData]{clonePlugin(plugin)}, plugins: []Plugin[NoData]{clonePlugin(plugin)},
} }
@@ -845,17 +845,17 @@ func TestPayloadHandlerBindArgsEndToEnd(t *testing.T) {
) )
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
payloadType: BotPayloadJson, payloadType: BotPayloadJSON,
plugins: []Plugin[NoData]{clonePlugin(plugin)}, plugins: []Plugin[NoData]{clonePlugin(plugin)},
} }
data, err := encodeJsonPayload(CallbackData{ data, err := encodeJSONPayload(CallbackData{
Command: "approve", Command: "approve",
Args: []string{"7", "looks", "good"}, Args: []string{"7", "looks", "good"},
}) })
if err != nil { if err != nil {
t.Fatalf("encodeJsonPayload returned error: %v", err) t.Fatalf("encodeJSONPayload returned error: %v", err)
} }
bot.handle(context.Background(), &tgapi.Update{ bot.handle(context.Background(), &tgapi.Update{
@@ -898,7 +898,7 @@ func TestHandleEditedMessageStaysOutOfCommandFlow(t *testing.T) {
}) })
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
plugins: []Plugin[NoData]{clonePlugin(plugin)}, plugins: []Plugin[NoData]{clonePlugin(plugin)},
} }
@@ -940,7 +940,7 @@ func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) {
}) })
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
plugins: []Plugin[NoData]{clonePlugin(plugin)}, plugins: []Plugin[NoData]{clonePlugin(plugin)},
} }
@@ -968,14 +968,14 @@ func TestHandleCallbackPopulatesMessageTargets(t *testing.T) {
plugin := NewPlugin[NoData]("test") plugin := NewPlugin[NoData]("test")
plugin.NewPayload(func(ctx *MsgContext, db NoData) error { plugin.NewPayload(func(ctx *MsgContext, db NoData) error {
called = true called = true
if ctx.CallbackQueryId != "cb-msg" { if ctx.CallbackQueryID != "cb-msg" {
t.Fatalf("unexpected CallbackQueryId: %q", ctx.CallbackQueryId) t.Fatalf("unexpected CallbackQueryID: %q", ctx.CallbackQueryID)
} }
if ctx.CallbackMsgId != 55 { if ctx.CallbackMsgID != 55 {
t.Fatalf("unexpected CallbackMsgId: %d", ctx.CallbackMsgId) t.Fatalf("unexpected CallbackMsgID: %d", ctx.CallbackMsgID)
} }
if ctx.InlineMsgId != "" { if ctx.InlineMsgID != "" {
t.Fatalf("did not expect InlineMsgId, got %q", ctx.InlineMsgId) t.Fatalf("did not expect InlineMsgID, got %q", ctx.InlineMsgID)
} }
if ctx.Msg == nil { if ctx.Msg == nil {
t.Fatal("expected callback message context") t.Fatal("expected callback message context")
@@ -993,14 +993,14 @@ func TestHandleCallbackPopulatesMessageTargets(t *testing.T) {
}, "approve") }, "approve")
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
payloadType: BotPayloadJson, payloadType: BotPayloadJSON,
plugins: []Plugin[NoData]{clonePlugin(plugin)}, plugins: []Plugin[NoData]{clonePlugin(plugin)},
} }
data, err := encodeJsonPayload(CallbackData{Command: "approve", Args: []string{"7", "ok"}}) data, err := encodeJSONPayload(CallbackData{Command: "approve", Args: []string{"7", "ok"}})
if err != nil { if err != nil {
t.Fatalf("encodeJsonPayload returned error: %v", err) t.Fatalf("encodeJSONPayload returned error: %v", err)
} }
bot.handle(context.Background(), &tgapi.Update{ bot.handle(context.Background(), &tgapi.Update{
@@ -1027,14 +1027,14 @@ func TestHandleCallbackPopulatesInlineTargets(t *testing.T) {
plugin := NewPlugin[NoData]("test") plugin := NewPlugin[NoData]("test")
plugin.NewPayload(func(ctx *MsgContext, db NoData) error { plugin.NewPayload(func(ctx *MsgContext, db NoData) error {
called = true called = true
if ctx.CallbackQueryId != "cb-inline" { if ctx.CallbackQueryID != "cb-inline" {
t.Fatalf("unexpected CallbackQueryId: %q", ctx.CallbackQueryId) t.Fatalf("unexpected CallbackQueryID: %q", ctx.CallbackQueryID)
} }
if ctx.CallbackMsgId != 0 { if ctx.CallbackMsgID != 0 {
t.Fatalf("did not expect CallbackMsgId, got %d", ctx.CallbackMsgId) t.Fatalf("did not expect CallbackMsgID, got %d", ctx.CallbackMsgID)
} }
if ctx.InlineMsgId != "inline-55" { if ctx.InlineMsgID != "inline-55" {
t.Fatalf("unexpected InlineMsgId: %q", ctx.InlineMsgId) t.Fatalf("unexpected InlineMsgID: %q", ctx.InlineMsgID)
} }
if ctx.Msg != nil { if ctx.Msg != nil {
t.Fatalf("did not expect callback chat message context, got %#v", ctx.Msg) t.Fatalf("did not expect callback chat message context, got %#v", ctx.Msg)
@@ -1052,14 +1052,14 @@ func TestHandleCallbackPopulatesInlineTargets(t *testing.T) {
}, "inline.approve") }, "inline.approve")
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
payloadType: BotPayloadJson, payloadType: BotPayloadJSON,
plugins: []Plugin[NoData]{clonePlugin(plugin)}, plugins: []Plugin[NoData]{clonePlugin(plugin)},
} }
data, err := encodeJsonPayload(CallbackData{Command: "inline.approve", Args: []string{"9"}}) data, err := encodeJSONPayload(CallbackData{Command: "inline.approve", Args: []string{"9"}})
if err != nil { if err != nil {
t.Fatalf("encodeJsonPayload returned error: %v", err) t.Fatalf("encodeJSONPayload returned error: %v", err)
} }
bot.handle(context.Background(), &tgapi.Update{ bot.handle(context.Background(), &tgapi.Update{
@@ -1086,15 +1086,15 @@ func TestHandleCallbackObserverEmitsPayloadEvents(t *testing.T) {
}, "approve") }, "approve")
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
payloadType: BotPayloadJson, payloadType: BotPayloadJSON,
plugins: []Plugin[NoData]{clonePlugin(plugin)}, plugins: []Plugin[NoData]{clonePlugin(plugin)},
observer: observer, observer: observer,
} }
data, err := encodeJsonPayload(CallbackData{Command: "approve", Args: []string{"7"}}) data, err := encodeJSONPayload(CallbackData{Command: "approve", Args: []string{"7"}})
if err != nil { if err != nil {
t.Fatalf("encodeJsonPayload returned error: %v", err) t.Fatalf("encodeJSONPayload returned error: %v", err)
} }
bot.handle(context.Background(), &tgapi.Update{ bot.handle(context.Background(), &tgapi.Update{
@@ -1137,15 +1137,15 @@ func TestHandleCallbackObserverEmitsPayloadErrors(t *testing.T) {
}, "approve") }, "approve")
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
payloadType: BotPayloadJson, payloadType: BotPayloadJSON,
plugins: []Plugin[NoData]{clonePlugin(plugin)}, plugins: []Plugin[NoData]{clonePlugin(plugin)},
observer: observer, observer: observer,
} }
data, err := encodeJsonPayload(CallbackData{Command: "approve", Args: []string{"7"}}) data, err := encodeJSONPayload(CallbackData{Command: "approve", Args: []string{"7"}})
if err != nil { if err != nil {
t.Fatalf("encodeJsonPayload returned error: %v", err) t.Fatalf("encodeJSONPayload returned error: %v", err)
} }
bot.handle(context.Background(), &tgapi.Update{ bot.handle(context.Background(), &tgapi.Update{
@@ -1182,8 +1182,8 @@ func TestHandleCallbackObserverEmitsPayloadErrors(t *testing.T) {
func TestHandleCallbackObserverEmitsDecodeErrors(t *testing.T) { func TestHandleCallbackObserverEmitsDecodeErrors(t *testing.T) {
observer := &recordingObserver{} observer := &recordingObserver{}
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
payloadType: BotPayloadJson, payloadType: BotPayloadJSON,
observer: observer, observer: observer,
} }
@@ -1202,7 +1202,7 @@ func TestHandleCallbackObserverEmitsDecodeErrors(t *testing.T) {
}, },
Logger: bot.logger, Logger: bot.logger,
ctx: context.Background(), ctx: context.Background(),
CallbackQueryId: "cb-bad", CallbackQueryID: "cb-bad",
From: &tgapi.User{ID: 7}, From: &tgapi.User{ID: 7},
FromID: 7, FromID: 7,
sceneRuntime: bot, sceneRuntime: bot,
+26 -26
View File
@@ -19,10 +19,10 @@ const (
// InlineKbButtonBuilder is a fluent builder for creating a single inline keyboard button. // InlineKbButtonBuilder is a fluent builder for creating a single inline keyboard button.
// //
// Use NewInlineKbButton() to start, then chain methods to configure: // Use NewInlineKbButton() to start, then chain methods to configure:
// - SetIconCustomEmojiId() — adds a custom emoji icon // - SetIconCustomEmojiID() — adds a custom emoji icon
// - SetStyle() — sets visual style (danger/success/primary) // - SetStyle() — sets visual style (danger/success/primary)
// - SetUrl() — makes button open a URL // - SetURL() — makes button open a URL
// - SetCallbackDataJson() — attaches structured command + args for bot handling // - SetCallbackDataJSON() — attaches structured command + args for bot handling
// //
// Call build() to produce the final tgapi.InlineKeyboardButton. // Call build() to produce the final tgapi.InlineKeyboardButton.
// Builder methods are immutable — each returns a copy. // Builder methods are immutable — each returns a copy.
@@ -40,9 +40,9 @@ func NewInlineKbButton(text string) InlineKbButtonBuilder {
return InlineKbButtonBuilder{text: text} return InlineKbButtonBuilder{text: text}
} }
// SetIconCustomEmojiId sets a custom emoji ID to display as the button's icon. // SetIconCustomEmojiID sets a custom emoji ID to display as the button's icon.
// This is a Telegram Bot API feature for custom emoji icons. // This is a Telegram Bot API feature for custom emoji icons.
func (b InlineKbButtonBuilder) SetIconCustomEmojiId(id string) InlineKbButtonBuilder { func (b InlineKbButtonBuilder) SetIconCustomEmojiID(id string) InlineKbButtonBuilder {
b.iconCustomEmojiID = id b.iconCustomEmojiID = id
return b return b
} }
@@ -55,22 +55,22 @@ func (b InlineKbButtonBuilder) SetStyle(style tgapi.KeyboardButtonStyle) InlineK
return b return b
} }
// SetUrl sets a URL that will be opened when the button is pressed. // SetURL sets a URL that will be opened when the button is pressed.
// If both URL and CallbackData are set, Telegram will prioritize URL. // If both URL and CallbackData are set, Telegram will prioritize URL.
func (b InlineKbButtonBuilder) SetUrl(url string) InlineKbButtonBuilder { func (b InlineKbButtonBuilder) SetURL(url string) InlineKbButtonBuilder {
b.url = url b.url = url
return b return b
} }
// SetCallbackDataJson sets a structured callback payload that will be sent to the bot // SetCallbackDataJSON sets a structured callback payload that will be sent to the bot
// when the button is pressed. The command and arguments are serialized as JSON. // when the button is pressed. The command and arguments are serialized as JSON.
// //
// Args are converted to strings using fmt.Sprint. Non-string types (e.g., int, bool) // Args are converted to strings using fmt.Sprint. Non-string types (e.g., int, bool)
// are safely serialized, but complex structs may not serialize usefully. // are safely serialized, but complex structs may not serialize usefully.
// //
// Example: SetCallbackDataJson("delete_user", 123, "confirm") → {"cmd":"delete_user","args":["123","confirm"]}. // Example: SetCallbackDataJSON("delete_user", 123, "confirm") → {"cmd":"delete_user","args":["123","confirm"]}.
func (b InlineKbButtonBuilder) SetCallbackDataJson(cmd string, args ...any) InlineKbButtonBuilder { func (b InlineKbButtonBuilder) SetCallbackDataJSON(cmd string, args ...any) InlineKbButtonBuilder {
b.callbackData = NewCallbackData(cmd, args...).ToJson() b.callbackData = NewCallbackData(cmd, args...).ToJSON()
return b return b
} }
@@ -107,12 +107,12 @@ type InlineKeyboard struct {
payloadType BotPayloadType // Serialization format for callback data (JSON or Base64) payloadType BotPayloadType // Serialization format for callback data (JSON or Base64)
} }
// NewInlineKeyboardJson creates a new keyboard builder with the specified maximum // NewInlineKeyboardJSON creates a new keyboard builder with the specified maximum
// number of buttons per row. // number of buttons per row.
// //
// Example: NewInlineKeyboardJson(3) creates a keyboard with at most 3 buttons per line. // Example: NewInlineKeyboardJSON(3) creates a keyboard with at most 3 buttons per line.
func NewInlineKeyboardJson(maxRow int) *InlineKeyboard { func NewInlineKeyboardJSON(maxRow int) *InlineKeyboard {
return NewInlineKeyboard(BotPayloadJson, maxRow) return NewInlineKeyboard(BotPayloadJSON, maxRow)
} }
// NewInlineKeyboardBase64 creates a new keyboard builder with the specified maximum // NewInlineKeyboardBase64 creates a new keyboard builder with the specified maximum
@@ -126,7 +126,7 @@ func NewInlineKeyboardBase64(maxRow int) *InlineKeyboard {
// NewInlineKeyboard creates a new keyboard builder with the specified payload encoding // NewInlineKeyboard creates a new keyboard builder with the specified payload encoding
// type and maximum number of buttons per row. // type and maximum number of buttons per row.
// //
// Use NewInlineKeyboardJson or NewInlineKeyboardBase64 for the common cases. // Use NewInlineKeyboardJSON or NewInlineKeyboardBase64 for the common cases.
func NewInlineKeyboard(payloadType BotPayloadType, maxRow int) *InlineKeyboard { func NewInlineKeyboard(payloadType BotPayloadType, maxRow int) *InlineKeyboard {
return &InlineKeyboard{ return &InlineKeyboard{
CurrentLine: make(extypes.Slice[tgapi.InlineKeyboardButton], 0), CurrentLine: make(extypes.Slice[tgapi.InlineKeyboardButton], 0),
@@ -163,15 +163,15 @@ func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeybo
return in return in
} }
// AddUrlButton adds a button that opens a URL when pressed. // AddURLButton adds a button that opens a URL when pressed.
// No callback data is attached. // No callback data is attached.
func (in *InlineKeyboard) AddUrlButton(text, url string) *InlineKeyboard { func (in *InlineKeyboard) AddURLButton(text, url string) *InlineKeyboard {
return in.append(tgapi.InlineKeyboardButton{Text: text, URL: url}) return in.append(tgapi.InlineKeyboardButton{Text: text, URL: url})
} }
// AddUrlButtonStyle adds a button with a visual style that opens a URL. // AddURLButtonStyle adds a button with a visual style that opens a URL.
// Style must be one of: ButtonStyleDanger, ButtonStyleSuccess, ButtonStylePrimary. // Style must be one of: ButtonStyleDanger, ButtonStyleSuccess, ButtonStylePrimary.
func (in *InlineKeyboard) AddUrlButtonStyle(text string, style tgapi.KeyboardButtonStyle, url string) *InlineKeyboard { func (in *InlineKeyboard) AddURLButtonStyle(text string, style tgapi.KeyboardButtonStyle, url string) *InlineKeyboard {
return in.append(tgapi.InlineKeyboardButton{Text: text, Style: style, URL: url}) return in.append(tgapi.InlineKeyboardButton{Text: text, Style: style, URL: url})
} }
@@ -253,15 +253,15 @@ func NewCallbackData(command string, args ...any) CallbackData {
} }
} }
// ToJson serializes the CallbackData to a JSON string. // ToJSON serializes the CallbackData to a JSON string.
// //
// If serialization fails (e.g., due to unmarshalable fields), returns a fallback // If serialization fails (e.g., due to unmarshalable fields), returns a fallback
// JSON object: {"cmd":""} to prevent breaking Telegram's API. // JSON object: {"cmd":""} to prevent breaking Telegram's API.
// //
// This fallback ensures the bot receives a valid JSON payload even if internal // This fallback ensures the bot receives a valid JSON payload even if internal
// errors occur — avoiding "invalid callback_data" errors from Telegram. // errors occur — avoiding "invalid callback_data" errors from Telegram.
func (d CallbackData) ToJson() string { func (d CallbackData) ToJSON() string {
data, err := encodeJsonPayload(d) data, err := encodeJSONPayload(d)
if err != nil { if err != nil {
// Fallback: return minimal valid JSON to avoid Telegram API rejection // Fallback: return minimal valid JSON to avoid Telegram API rejection
return `{"cmd":""}` return `{"cmd":""}`
@@ -280,14 +280,14 @@ func (d CallbackData) ToBase64() string {
} }
// Encode serializes the CallbackData according to the specified payload type. // Encode serializes the CallbackData according to the specified payload type.
// Supported types: BotPayloadJson and BotPayloadBase64. // Supported types: BotPayloadJSON and BotPayloadBase64.
// For unknown types, returns an empty string. // For unknown types, returns an empty string.
func (d CallbackData) Encode(t BotPayloadType) string { func (d CallbackData) Encode(t BotPayloadType) string {
switch t { switch t {
case BotPayloadBase64: case BotPayloadBase64:
return d.ToBase64() return d.ToBase64()
case BotPayloadJson: case BotPayloadJSON:
return d.ToJson() return d.ToJSON()
} }
return "" return ""
} }
+7 -7
View File
@@ -8,7 +8,7 @@ import (
) )
func TestInlineKeyboardWrapsRowsAndEncodesJSONPayloads(t *testing.T) { func TestInlineKeyboardWrapsRowsAndEncodesJSONPayloads(t *testing.T) {
kb := NewInlineKeyboardJson(2). kb := NewInlineKeyboardJSON(2).
AddCallbackButton("A", "cmd", 1). AddCallbackButton("A", "cmd", 1).
AddCallbackButton("B", "cmd", 2). AddCallbackButton("B", "cmd", 2).
AddCallbackButton("C", "cmd", 3) AddCallbackButton("C", "cmd", 3)
@@ -33,7 +33,7 @@ func TestInlineKeyboardBuilderPreservesConfiguredButtonFields(t *testing.T) {
AddButton( AddButton(
NewInlineKbButton("Docs"). NewInlineKbButton("Docs").
SetStyle(ButtonStylePrimary). SetStyle(ButtonStylePrimary).
SetUrl("https://example.test"), SetURL("https://example.test"),
) )
button := kb.Get().InlineKeyboard[0][0] button := kb.Get().InlineKeyboard[0][0]
@@ -46,8 +46,8 @@ func TestInlineKeyboardBuilderPreservesConfiguredButtonFields(t *testing.T) {
} }
func TestInlineKeyboardGetPayloadTypeReturnsLocalOverride(t *testing.T) { func TestInlineKeyboardGetPayloadTypeReturnsLocalOverride(t *testing.T) {
kb := NewInlineKeyboardJson(2) kb := NewInlineKeyboardJSON(2)
if got := kb.GetPayloadType(); got != BotPayloadJson { if got := kb.GetPayloadType(); got != BotPayloadJSON {
t.Fatalf("unexpected initial payload type: %q", got) t.Fatalf("unexpected initial payload type: %q", got)
} }
kb.SetPayloadType(BotPayloadBase64) kb.SetPayloadType(BotPayloadBase64)
@@ -60,7 +60,7 @@ func TestDecodePayloadAcceptsBase64KeyboardPayloadWhenBotPrefersJSON(t *testing.
kb := NewInlineKeyboardBase64(1). kb := NewInlineKeyboardBase64(1).
AddCallbackButton("A", "cmd", 1, "two") AddCallbackButton("A", "cmd", 1, "two")
got, _, err := decodePayload(BotPayloadJson, kb.Get().InlineKeyboard[0][0].CallbackData, false) got, _, err := decodePayload(BotPayloadJSON, kb.Get().InlineKeyboard[0][0].CallbackData, false)
if err != nil { if err != nil {
t.Fatalf("decodePayload returned error: %v", err) t.Fatalf("decodePayload returned error: %v", err)
} }
@@ -72,7 +72,7 @@ func TestDecodePayloadAcceptsBase64KeyboardPayloadWhenBotPrefersJSON(t *testing.
} }
func TestDecodePayloadAcceptsJSONKeyboardPayloadWhenBotPrefersBase64(t *testing.T) { func TestDecodePayloadAcceptsJSONKeyboardPayloadWhenBotPrefersBase64(t *testing.T) {
kb := NewInlineKeyboardJson(1). kb := NewInlineKeyboardJSON(1).
AddCallbackButton("A", "cmd", 1, "two") AddCallbackButton("A", "cmd", 1, "two")
got, _, err := decodePayload(BotPayloadBase64, kb.Get().InlineKeyboard[0][0].CallbackData, false) got, _, err := decodePayload(BotPayloadBase64, kb.Get().InlineKeyboard[0][0].CallbackData, false)
@@ -90,7 +90,7 @@ func TestDecodePayloadStrictRejectsMismatchedType(t *testing.T) {
kb := NewInlineKeyboardBase64(1). kb := NewInlineKeyboardBase64(1).
AddCallbackButton("A", "cmd", 1) AddCallbackButton("A", "cmd", 1)
_, _, err := decodePayload(BotPayloadJson, kb.Get().InlineKeyboard[0][0].CallbackData, true) _, _, err := decodePayload(BotPayloadJSON, kb.Get().InlineKeyboard[0][0].CallbackData, true)
if !errors.Is(err, ErrPayloadTypeMismatch) { if !errors.Is(err, ErrPayloadTypeMismatch) {
t.Fatalf("expected ErrPayloadTypeMismatch, got %v", err) t.Fatalf("expected ErrPayloadTypeMismatch, got %v", err)
} }
+57 -57
View File
@@ -24,14 +24,14 @@ import (
// - From and FromID are populated only when the update exposes a user identity. // - From and FromID are populated only when the update exposes a user identity.
// - Chat and ChatID are populated only when the update exposes a chat identity. // - Chat and ChatID are populated only when the update exposes a chat identity.
// - Text, Args, and Prefix are populated only by command or scene command routing. // - Text, Args, and Prefix are populated only by command or scene command routing.
// - CallbackQueryId, CallbackMsgId, and InlineMsgId are populated only for // - CallbackQueryID, CallbackMsgID, and InlineMsgID are populated only for
// callback query handling when the corresponding callback targets exist. // callback query handling when the corresponding callback targets exist.
// //
// Helper methods on MsgContext may require a message-backed context. For example, // Helper methods on MsgContext may require a message-backed context. For example,
// reply helpers need Msg, while inline callback edit helpers can work through // reply helpers need Msg, while inline callback edit helpers can work through
// InlineMsgId when there is no chat message. // InlineMsgID when there is no chat message.
type MsgContext struct { type MsgContext struct {
Api *tgapi.API API *tgapi.API
Update tgapi.Update Update tgapi.Update
// Msg is the normalized Telegram message for message-backed update kinds. // Msg is the normalized Telegram message for message-backed update kinds.
@@ -48,15 +48,15 @@ type MsgContext struct {
// It may fall back to the bot logger when the plugin has no dedicated logger. // It may fall back to the bot logger when the plugin has no dedicated logger.
Logger *sneklog.Logger Logger *sneklog.Logger
// InlineMsgId is the inline message identifier for callback queries that target // InlineMsgID is the inline message identifier for callback queries that target
// an inline message instead of a chat message. // an inline message instead of a chat message.
InlineMsgId string InlineMsgID string
// CallbackMsgId is the message ID targeted by the current callback query when // CallbackMsgID is the message ID targeted by the current callback query when
// the callback comes from a chat message. // the callback comes from a chat message.
CallbackMsgId int CallbackMsgID int
// CallbackQueryId is the Telegram callback query ID for payload handlers and // CallbackQueryID is the Telegram callback query ID for payload handlers and
// callback-backed scene handlers. // callback-backed scene handlers.
CallbackQueryId string CallbackQueryID string
// FromID is the normalized sender ID when the current update exposes a user. // FromID is the normalized sender ID when the current update exposes a user.
// It is zero when the update has no user identity. // It is zero when the update has no user identity.
FromID int64 FromID int64
@@ -95,7 +95,7 @@ type AnswerMessage struct {
} }
// Internal helper for text edits with optional keyboard and parse mode. // Internal helper for text edits with optional keyboard and parse mode.
func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { func (ctx *MsgContext) edit(messageID int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if err := validateMessageText(text); err != nil { if err := validateMessageText(text); err != nil {
ctx.Logger.Errorln(err) ctx.Logger.Errorln(err)
return nil return nil
@@ -105,11 +105,11 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard
ParseMode: parseMode, ParseMode: parseMode,
} }
switch { switch {
case messageId > 0 && ctx.Msg != nil: case messageID > 0 && ctx.Msg != nil:
params.MessageID = messageId params.MessageID = messageID
params.ChatID = ctx.Msg.Chat.ID params.ChatID = ctx.Msg.Chat.ID
case ctx.InlineMsgId != "": case ctx.InlineMsgID != "":
params.InlineMessageID = ctx.InlineMsgId params.InlineMessageID = ctx.InlineMsgID
default: default:
ctx.Logger.Errorln(ErrEditTargetMissing) ctx.Logger.Errorln(ErrEditTargetMissing)
return nil return nil
@@ -117,12 +117,12 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard
if keyboard != nil { if keyboard != nil {
params.ReplyMarkup = keyboard.Get() params.ReplyMarkup = keyboard.Get()
} }
msg, _, err := ctx.Api.EditMessageTextWithContext(ctx.Context(), params) msg, _, err := ctx.API.EditMessageTextWithContext(ctx.Context(), params)
if err != nil { if err != nil {
ctx.Logger.Errorln(err) ctx.Logger.Errorln(err)
return nil return nil
} }
resultMessageID := messageId resultMessageID := messageID
if msg.MessageID > 0 { if msg.MessageID > 0 {
resultMessageID = msg.MessageID resultMessageID = msg.MessageID
} }
@@ -147,11 +147,11 @@ func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage {
// Internal helper for editing callback-linked messages. // Internal helper for editing callback-linked messages.
func (ctx *MsgContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { func (ctx *MsgContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if ctx.CallbackMsgId == 0 && ctx.InlineMsgId == "" { if ctx.CallbackMsgID == 0 && ctx.InlineMsgID == "" {
ctx.Logger.Errorln(ErrCallbackMessageMissing) ctx.Logger.Errorln(ErrCallbackMessageMissing)
return nil return nil
} }
return ctx.edit(ctx.CallbackMsgId, text, keyboard, parseMode) return ctx.edit(ctx.CallbackMsgID, text, keyboard, parseMode)
} }
// EditCallback edits the callback message using plain text (ParseNone). // EditCallback edits the callback message using plain text (ParseNone).
@@ -179,7 +179,7 @@ func (ctx *MsgContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyb
} }
// Internal helper for media-caption edits. // Internal helper for media-caption edits.
func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { func (ctx *MsgContext) editPhotoText(messageID int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if err := validateCaptionText(text); err != nil { if err := validateCaptionText(text); err != nil {
ctx.Logger.Errorln(err) ctx.Logger.Errorln(err)
return nil return nil
@@ -189,11 +189,11 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo
ParseMode: parseMode, ParseMode: parseMode,
} }
switch { switch {
case messageId > 0 && ctx.Msg != nil: case messageID > 0 && ctx.Msg != nil:
params.ChatID = ctx.Msg.Chat.ID params.ChatID = ctx.Msg.Chat.ID
params.MessageID = messageId params.MessageID = messageID
case ctx.InlineMsgId != "": case ctx.InlineMsgID != "":
params.InlineMessageID = ctx.InlineMsgId params.InlineMessageID = ctx.InlineMsgID
default: default:
ctx.Logger.Errorln(ErrEditTargetMissing) ctx.Logger.Errorln(ErrEditTargetMissing)
return nil return nil
@@ -202,12 +202,12 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo
params.ReplyMarkup = kb.Get() params.ReplyMarkup = kb.Get()
} }
msg, _, err := ctx.Api.EditMessageCaptionWithContext(ctx.Context(), params) msg, _, err := ctx.API.EditMessageCaptionWithContext(ctx.Context(), params)
if err != nil { if err != nil {
ctx.Logger.Errorln(err) ctx.Logger.Errorln(err)
return nil return nil
} }
resultMessageID := messageId resultMessageID := messageID
if msg.MessageID > 0 { if msg.MessageID > 0 {
resultMessageID = msg.MessageID resultMessageID = msg.MessageID
} }
@@ -265,7 +265,7 @@ func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode t
params.DirectMessagesTopicID = ctx.Msg.DirectMessageTopic.TopicID params.DirectMessagesTopicID = ctx.Msg.DirectMessageTopic.TopicID
} }
msg, err := ctx.Api.SendMessageWithContext(ctx.Context(), params) msg, err := ctx.API.SendMessageWithContext(ctx.Context(), params)
if err != nil { if err != nil {
ctx.Logger.Errorln(err) ctx.Logger.Errorln(err)
return nil return nil
@@ -371,7 +371,7 @@ func (ctx *MsgContext) answerLong(text string, keyboard *InlineKeyboard, parseMo
} }
// Internal helper for photo replies with optional caption and keyboard. // Internal helper for photo replies with optional caption and keyboard.
func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { func (ctx *MsgContext) answerPhoto(photoID, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if ctx.Msg == nil { if ctx.Msg == nil {
ctx.Logger.Errorln(ErrMessageContextNil) ctx.Logger.Errorln(ErrMessageContextNil)
return nil return nil
@@ -384,7 +384,7 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, par
ChatID: ctx.Msg.Chat.ID, ChatID: ctx.Msg.Chat.ID,
Caption: text, Caption: text,
ParseMode: parseMode, ParseMode: parseMode,
Photo: photoId, Photo: photoID,
} }
if kb != nil { if kb != nil {
params.ReplyMarkup = kb.Get() params.ReplyMarkup = kb.Get()
@@ -396,7 +396,7 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, par
params.DirectMessagesTopicID = int(ctx.Msg.DirectMessageTopic.TopicID) params.DirectMessagesTopicID = int(ctx.Msg.DirectMessageTopic.TopicID)
} }
msg, err := ctx.Api.SendPhotoWithContext(ctx.Context(), params) msg, err := ctx.API.SendPhotoWithContext(ctx.Context(), params)
if err != nil { if err != nil {
ctx.Logger.Errorln(err) ctx.Logger.Errorln(err)
return nil return nil
@@ -407,44 +407,44 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, par
} }
// AnswerPhoto sends a photo with plain text caption. // AnswerPhoto sends a photo with plain text caption.
func (ctx *MsgContext) AnswerPhoto(photoId, text string) *AnswerMessage { func (ctx *MsgContext) AnswerPhoto(photoID, text string) *AnswerMessage {
return ctx.answerPhoto(photoId, text, nil, tgapi.ParseNone) return ctx.answerPhoto(photoID, text, nil, tgapi.ParseNone)
} }
// AnswerPhotoMarkdown sends a photo with MarkdownV2 caption. // AnswerPhotoMarkdown sends a photo with MarkdownV2 caption.
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
func (ctx *MsgContext) AnswerPhotoMarkdown(photoId, text string) *AnswerMessage { func (ctx *MsgContext) AnswerPhotoMarkdown(photoID, text string) *AnswerMessage {
return ctx.answerPhoto(photoId, text, nil, tgapi.ParseMDV2) return ctx.answerPhoto(photoID, text, nil, tgapi.ParseMDV2)
} }
// AnswerPhotoKeyboard sends a photo with caption and inline keyboard (plain text). // AnswerPhotoKeyboard sends a photo with caption and inline keyboard (plain text).
func (ctx *MsgContext) AnswerPhotoKeyboard(photoId, text string, kb *InlineKeyboard) *AnswerMessage { func (ctx *MsgContext) AnswerPhotoKeyboard(photoID, text string, kb *InlineKeyboard) *AnswerMessage {
return ctx.answerPhoto(photoId, text, kb, tgapi.ParseNone) return ctx.answerPhoto(photoID, text, kb, tgapi.ParseNone)
} }
// AnswerPhotoKeyboardMarkdown sends a photo with caption and inline keyboard using MarkdownV2. // AnswerPhotoKeyboardMarkdown sends a photo with caption and inline keyboard using MarkdownV2.
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
func (ctx *MsgContext) AnswerPhotoKeyboardMarkdown(photoId, text string, kb *InlineKeyboard) *AnswerMessage { 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.ParseMDV2)
} }
// AnswerPhotof formats a string and sends it as a photo caption (plain text). // AnswerPhotof formats a string and sends it as a photo caption (plain text).
func (ctx *MsgContext) AnswerPhotof(photoId, template string, args ...any) *AnswerMessage { func (ctx *MsgContext) AnswerPhotof(photoID, template string, args ...any) *AnswerMessage {
return ctx.answerPhoto(photoId, fmt.Sprintf(template, args...), nil, tgapi.ParseNone) return ctx.answerPhoto(photoID, fmt.Sprintf(template, args...), nil, tgapi.ParseNone)
} }
// AnswerPhotofMarkdown formats a string and sends it as a photo caption using MarkdownV2. // AnswerPhotofMarkdown formats a string and sends it as a photo caption using MarkdownV2.
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
func (ctx *MsgContext) AnswerPhotofMarkdown(photoId, template string, args ...any) *AnswerMessage { 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.ParseMDV2)
} }
// Internal helper that deletes a message by ID. // Internal helper that deletes a message by ID.
func (ctx *MsgContext) delete(messageId int) { func (ctx *MsgContext) delete(messageID int) {
if messageId == 0 { if messageID == 0 {
ctx.Logger.Errorln(ErrMessageIDZero) ctx.Logger.Errorln(ErrMessageIDZero)
return return
} }
@@ -452,9 +452,9 @@ func (ctx *MsgContext) delete(messageId int) {
ctx.Logger.Errorln(ErrMessageContextNil) ctx.Logger.Errorln(ErrMessageContextNil)
return return
} }
_, err := ctx.Api.DeleteMessageWithContext(ctx.Context(), tgapi.DeleteMessage{ _, err := ctx.API.DeleteMessageWithContext(ctx.Context(), tgapi.DeleteMessage{
ChatID: ctx.Msg.Chat.ID, ChatID: ctx.Msg.Chat.ID,
MessageID: messageId, MessageID: messageID,
}) })
if err != nil { if err != nil {
ctx.Logger.Errorln(err) ctx.Logger.Errorln(err)
@@ -466,20 +466,20 @@ func (m *AnswerMessage) Delete() { m.ctx.delete(m.MessageID) }
// CallbackDelete deletes the message that triggered the callback query. // CallbackDelete deletes the message that triggered the callback query.
func (ctx *MsgContext) CallbackDelete() { func (ctx *MsgContext) CallbackDelete() {
if ctx.CallbackMsgId == 0 { if ctx.CallbackMsgID == 0 {
ctx.Logger.Errorln(ErrCallbackMessageMissing) ctx.Logger.Errorln(ErrCallbackMessageMissing)
return return
} }
ctx.delete(ctx.CallbackMsgId) ctx.delete(ctx.CallbackMsgID)
} }
// Internal helper that answers a callback query with optional text, alert, or URL. // Internal helper that answers a callback query with optional text, alert, or URL.
func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) { func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) {
if len(ctx.CallbackQueryId) == 0 { if len(ctx.CallbackQueryID) == 0 {
return return
} }
_, err := ctx.Api.AnswerCallbackQueryWithContext(ctx.Context(), tgapi.AnswerCallbackQuery{ _, err := ctx.API.AnswerCallbackQueryWithContext(ctx.Context(), tgapi.AnswerCallbackQuery{
CallbackQueryID: ctx.CallbackQueryId, CallbackQueryID: ctx.CallbackQueryID,
Text: text, ShowAlert: showAlert, URL: url, Text: text, ShowAlert: showAlert, URL: url,
}) })
if err != nil { if err != nil {
@@ -496,8 +496,8 @@ func (ctx *MsgContext) AnswerCbQueryText(text string) { ctx.answerCallbackQuery(
// AnswerCbQueryAlert answers the callback query with a user-visible alert. // AnswerCbQueryAlert answers the callback query with a user-visible alert.
func (ctx *MsgContext) AnswerCbQueryAlert(text string) { ctx.answerCallbackQuery("", text, true) } func (ctx *MsgContext) AnswerCbQueryAlert(text string) { ctx.answerCallbackQuery("", text, true) }
// AnswerCbQueryUrl answers the callback query with a URL redirect. // AnswerCbQueryURL answers the callback query with a URL redirect.
func (ctx *MsgContext) AnswerCbQueryUrl(u string) { ctx.answerCallbackQuery(u, "", false) } func (ctx *MsgContext) AnswerCbQueryURL(u string) { ctx.answerCallbackQuery(u, "", false) }
// SendAction sends a chat action (typing, uploading_photo, etc.) to indicate bot activity. // SendAction sends a chat action (typing, uploading_photo, etc.) to indicate bot activity.
func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) { func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) {
@@ -511,7 +511,7 @@ func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) {
if ctx.Msg.MessageThreadID > 0 { if ctx.Msg.MessageThreadID > 0 {
params.MessageThreadID = ctx.Msg.MessageThreadID params.MessageThreadID = ctx.Msg.MessageThreadID
} }
_, err := ctx.Api.SendChatActionWithContext(ctx.Context(), params) _, err := ctx.API.SendChatActionWithContext(ctx.Context(), params)
if err != nil { if err != nil {
ctx.Logger.Errorln(err) ctx.Logger.Errorln(err)
} }
@@ -528,7 +528,7 @@ func (ctx *MsgContext) error(err error) {
} }
text := fmt.Sprintf(ctx.errorTemplate, err.Error()) text := fmt.Sprintf(ctx.errorTemplate, err.Error())
if ctx.CallbackQueryId != "" { if ctx.CallbackQueryID != "" {
ctx.answerCallbackQuery("", text, false) ctx.answerCallbackQuery("", text, false)
} else { } else {
ctx.answer(text, nil, tgapi.ParseNone) ctx.answer(text, nil, tgapi.ParseNone)
@@ -543,7 +543,7 @@ func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft {
ctx.Logger.Errorln(ErrMessageContextNil) ctx.Logger.Errorln(ErrMessageContextNil)
return nil return nil
} }
if ctx.Api == nil { if ctx.API == nil {
ctx.Logger.Errorln(ErrAPIIsNil) ctx.Logger.Errorln(ErrAPIIsNil)
return nil return nil
} }
@@ -552,10 +552,10 @@ func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft {
return nil return nil
} }
if ctx.Api.Limiter != nil { if ctx.API.Limiter != nil {
c, cancel := context.WithTimeout(ctx.Context(), 5*time.Second) c, cancel := context.WithTimeout(ctx.Context(), 5*time.Second)
defer cancel() defer cancel()
if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil { if err := ctx.API.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil {
ctx.Logger.Errorln(err) ctx.Logger.Errorln(err)
return nil return nil
} }
+25 -25
View File
@@ -35,7 +35,7 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
api := tgapi.NewAPI( api := tgapi.NewAPI(
tgapi.NewAPIOpts("token"). tgapi.NewAPIOpts("token").
SetAPIUrl("https://example.test"). SetAPIURL("https://example.test").
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
@@ -45,12 +45,12 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
}() }()
ctx := &MsgContext{ ctx := &MsgContext{
Api: api, API: api,
Msg: &tgapi.Message{ Msg: &tgapi.Message{
Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate},
DirectMessageTopic: &tgapi.DirectMessageTopic{TopicID: 77}, DirectMessageTopic: &tgapi.DirectMessageTopic{TopicID: 77},
}, },
Logger: sneklog.CreateLogger(), Logger: sneklog.NewLogger(),
} }
answer := ctx.AnswerPhoto("photo-id", "caption") answer := ctx.AnswerPhoto("photo-id", "caption")
@@ -190,7 +190,7 @@ func TestErrorDefaultRemainsUserVisibleForMessageFlow(t *testing.T) {
api := tgapi.NewAPI( api := tgapi.NewAPI(
tgapi.NewAPIOpts("token"). tgapi.NewAPIOpts("token").
SetAPIUrl("https://example.test"). SetAPIURL("https://example.test").
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
@@ -200,9 +200,9 @@ func TestErrorDefaultRemainsUserVisibleForMessageFlow(t *testing.T) {
}() }()
ctx := &MsgContext{ ctx := &MsgContext{
Api: api, API: api,
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}},
Logger: sneklog.CreateLogger(), Logger: sneklog.NewLogger(),
errorTemplate: "Error: %s", errorTemplate: "Error: %s",
} }
@@ -226,7 +226,7 @@ func TestErrorInternalSkipsUserReplyForMessageFlow(t *testing.T) {
api := tgapi.NewAPI( api := tgapi.NewAPI(
tgapi.NewAPIOpts("token"). tgapi.NewAPIOpts("token").
SetAPIUrl("https://example.test"). SetAPIURL("https://example.test").
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
@@ -236,9 +236,9 @@ func TestErrorInternalSkipsUserReplyForMessageFlow(t *testing.T) {
}() }()
ctx := &MsgContext{ ctx := &MsgContext{
Api: api, API: api,
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}},
Logger: sneklog.CreateLogger(), Logger: sneklog.NewLogger(),
errorTemplate: "Error: %s", errorTemplate: "Error: %s",
} }
@@ -255,7 +255,7 @@ func TestErrorInternalSkipsCallbackAnswer(t *testing.T) {
api := tgapi.NewAPI( api := tgapi.NewAPI(
tgapi.NewAPIOpts("token"). tgapi.NewAPIOpts("token").
SetAPIUrl("https://example.test"). SetAPIURL("https://example.test").
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
@@ -265,10 +265,10 @@ func TestErrorInternalSkipsCallbackAnswer(t *testing.T) {
}() }()
ctx := &MsgContext{ ctx := &MsgContext{
Api: api, API: api,
Logger: sneklog.CreateLogger(), Logger: sneklog.NewLogger(),
errorTemplate: "%s", errorTemplate: "%s",
CallbackQueryId: "cb-1", CallbackQueryID: "cb-1",
} }
ctx.error(AsInternalError(errors.New("boom"))) ctx.error(AsInternalError(errors.New("boom")))
@@ -298,7 +298,7 @@ func TestErrorUserVisibleAnswersCallback(t *testing.T) {
api := tgapi.NewAPI( api := tgapi.NewAPI(
tgapi.NewAPIOpts("token"). tgapi.NewAPIOpts("token").
SetAPIUrl("https://example.test"). SetAPIURL("https://example.test").
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
@@ -308,10 +308,10 @@ func TestErrorUserVisibleAnswersCallback(t *testing.T) {
}() }()
ctx := &MsgContext{ ctx := &MsgContext{
Api: api, API: api,
Logger: sneklog.CreateLogger(), Logger: sneklog.NewLogger(),
errorTemplate: "Oops: %s", errorTemplate: "Oops: %s",
CallbackQueryId: "cb-1", CallbackQueryID: "cb-1",
} }
ctx.error(AsUserError(errors.New("boom"))) ctx.error(AsUserError(errors.New("boom")))
@@ -327,7 +327,7 @@ func TestErrorUserVisibleAnswersCallback(t *testing.T) {
func TestAnswerRejectsEmptyMessage(t *testing.T) { func TestAnswerRejectsEmptyMessage(t *testing.T) {
ctx := &MsgContext{ ctx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}},
Logger: sneklog.CreateLogger(), Logger: sneklog.NewLogger(),
} }
if answer := ctx.Answer(""); answer != nil { if answer := ctx.Answer(""); answer != nil {
@@ -345,7 +345,7 @@ func TestAnswerRejectsLongMessageWithoutSendingRequest(t *testing.T) {
api := tgapi.NewAPI( api := tgapi.NewAPI(
tgapi.NewAPIOpts("token"). tgapi.NewAPIOpts("token").
SetAPIUrl("https://example.test"). SetAPIURL("https://example.test").
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
@@ -355,9 +355,9 @@ func TestAnswerRejectsLongMessageWithoutSendingRequest(t *testing.T) {
}() }()
ctx := &MsgContext{ ctx := &MsgContext{
Api: api, API: api,
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}},
Logger: sneklog.CreateLogger(), Logger: sneklog.NewLogger(),
} }
if answer := ctx.Answer(strings.Repeat("a", maxMessageTextLen+1)); answer != nil { if answer := ctx.Answer(strings.Repeat("a", maxMessageTextLen+1)); answer != nil {
@@ -429,7 +429,7 @@ func TestAnswerLongSplitsRequestsAndAttachesKeyboardToLastChunk(t *testing.T) {
api := tgapi.NewAPI( api := tgapi.NewAPI(
tgapi.NewAPIOpts("token"). tgapi.NewAPIOpts("token").
SetAPIUrl("https://example.test"). SetAPIURL("https://example.test").
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
@@ -439,11 +439,11 @@ func TestAnswerLongSplitsRequestsAndAttachesKeyboardToLastChunk(t *testing.T) {
}() }()
ctx := &MsgContext{ ctx := &MsgContext{
Api: api, API: api,
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}},
Logger: sneklog.CreateLogger(), Logger: sneklog.NewLogger(),
} }
kb := NewInlineKeyboardJson(1).AddCallbackButton("A", "cmd") kb := NewInlineKeyboardJSON(1).AddCallbackButton("A", "cmd")
text := strings.Repeat("a", maxMessageTextLen) + " " + strings.Repeat("b", 32) text := strings.Repeat("a", maxMessageTextLen) + " " + strings.Repeat("b", 32)
messages := ctx.KeyboardLong(text, kb) messages := ctx.KeyboardLong(text, kb)
+1 -1
View File
@@ -264,7 +264,7 @@ func (p *Plugin[T]) AddUpdateHandler(t tgapi.UpdateType, handler CommandExecutor
switch t { switch t {
case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost, tgapi.UpdateTypeCallbackQuery: case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost, tgapi.UpdateTypeCallbackQuery:
if p.logger == nil { if p.logger == nil {
logger := utils.CreateLogger(p.name, utils.GetLoggerLevel()) 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 registred through AddUpdateHandler. Use AddPayload/NewPayload or AddCommand/NewCommand", t)
_ = logger.Close() _ = logger.Close()
return p return p
+4 -4
View File
@@ -143,7 +143,7 @@ func RequireChatAdmin[T AppData]() Policy[T] {
return AsInternalError(errors.New("chat-admin policy requires message chat context")) return AsInternalError(errors.New("chat-admin policy requires message chat context"))
} }
member, err := ctx.Api.GetChatMember(tgapi.GetChatMember{ member, err := ctx.API.GetChatMember(tgapi.GetChatMember{
ChatID: ctx.ChatID, ChatID: ctx.ChatID,
UserID: ctx.FromID, UserID: ctx.FromID,
}) })
@@ -166,7 +166,7 @@ func RequireChatCreator[T AppData]() Policy[T] {
return AsInternalError(errors.New("chat-creator policy requires message chat context")) return AsInternalError(errors.New("chat-creator policy requires message chat context"))
} }
member, err := ctx.Api.GetChatMember(tgapi.GetChatMember{ member, err := ctx.API.GetChatMember(tgapi.GetChatMember{
ChatID: ctx.ChatID, ChatID: ctx.ChatID,
UserID: ctx.FromID, UserID: ctx.FromID,
}) })
@@ -189,12 +189,12 @@ func RequireBotAdmin[T AppData]() Policy[T] {
return AsInternalError(errors.New("bot-admin policy requires message chat context")) return AsInternalError(errors.New("bot-admin policy requires message chat context"))
} }
bot, err := ctx.Api.GetMe() bot, err := ctx.API.GetMe()
if err != nil { if err != nil {
return AsInternalError(fmt.Errorf("failed to fetch bot info: %w", err)) return AsInternalError(fmt.Errorf("failed to fetch bot info: %w", err))
} }
member, err := ctx.Api.GetChatMember(tgapi.GetChatMember{ member, err := ctx.API.GetChatMember(tgapi.GetChatMember{
ChatID: ctx.ChatID, ChatID: ctx.ChatID,
UserID: bot.ID, UserID: bot.ID,
}) })
+16 -16
View File
@@ -37,7 +37,7 @@ func TestRequirePolicyStopsExecutionOnDeniedPolicy(t *testing.T) {
api := tgapi.NewAPI( api := tgapi.NewAPI(
tgapi.NewAPIOpts("token"). tgapi.NewAPIOpts("token").
SetAPIUrl("https://example.test"). SetAPIURL("https://example.test").
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
@@ -47,9 +47,9 @@ func TestRequirePolicyStopsExecutionOnDeniedPolicy(t *testing.T) {
}() }()
ctx := &MsgContext{ ctx := &MsgContext{
Api: api, API: api,
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}},
Logger: sneklog.CreateLogger(), Logger: sneklog.NewLogger(),
errorTemplate: "Error: %s", errorTemplate: "Error: %s",
} }
@@ -73,7 +73,7 @@ func TestRequirePrivateChatAllowsPrivateChat(t *testing.T) {
Msg: &tgapi.Message{ Msg: &tgapi.Message{
Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate},
}, },
Logger: sneklog.CreateLogger(), Logger: sneklog.NewLogger(),
} }
if err := RequirePrivateChat[NoData]()(ctx, NoData{}); err != nil { if err := RequirePrivateChat[NoData]()(ctx, NoData{}); err != nil {
@@ -86,7 +86,7 @@ func TestRequirePrivateChatDeniesNonPrivateChat(t *testing.T) {
Msg: &tgapi.Message{ Msg: &tgapi.Message{
Chat: &tgapi.Chat{ID: -100, Type: tgapi.ChatTypeSupergroup}, Chat: &tgapi.Chat{ID: -100, Type: tgapi.ChatTypeSupergroup},
}, },
Logger: sneklog.CreateLogger(), Logger: sneklog.NewLogger(),
} }
err := RequirePrivateChat[NoData]()(ctx, NoData{}) err := RequirePrivateChat[NoData]()(ctx, NoData{})
@@ -127,7 +127,7 @@ func TestRequireChatAdminUsesNormalizedIDs(t *testing.T) {
api := tgapi.NewAPI( api := tgapi.NewAPI(
tgapi.NewAPIOpts("token"). tgapi.NewAPIOpts("token").
SetAPIUrl("https://example.test"). SetAPIURL("https://example.test").
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
@@ -137,10 +137,10 @@ func TestRequireChatAdminUsesNormalizedIDs(t *testing.T) {
}() }()
ctx := &MsgContext{ ctx := &MsgContext{
Api: api, API: api,
ChatID: -2001, ChatID: -2001,
FromID: 55, FromID: 55,
Logger: sneklog.CreateLogger(), Logger: sneklog.NewLogger(),
} }
if err := RequireChatAdmin[NoData]()(ctx, NoData{}); err != nil { if err := RequireChatAdmin[NoData]()(ctx, NoData{}); err != nil {
@@ -168,7 +168,7 @@ func TestAllPoliciesReturnsFirstError(t *testing.T) {
}, },
) )
err := policy(&MsgContext{Logger: sneklog.CreateLogger()}, NoData{}) err := policy(&MsgContext{Logger: sneklog.NewLogger()}, NoData{})
if !errors.Is(err, want) { if !errors.Is(err, want) {
t.Fatalf("expected first policy error, got %v", err) t.Fatalf("expected first policy error, got %v", err)
} }
@@ -180,7 +180,7 @@ func TestAnyPolicyAllowsLaterSuccessAfterInternalError(t *testing.T) {
func(ctx *MsgContext, data NoData) error { return nil }, func(ctx *MsgContext, data NoData) error { return nil },
) )
if err := policy(&MsgContext{Logger: sneklog.CreateLogger()}, NoData{}); err != nil { if err := policy(&MsgContext{Logger: sneklog.NewLogger()}, NoData{}); err != nil {
t.Fatalf("expected later success to allow access, got %v", err) t.Fatalf("expected later success to allow access, got %v", err)
} }
} }
@@ -192,7 +192,7 @@ func TestAnyPolicyReturnsInternalErrorWhenNonePass(t *testing.T) {
func(ctx *MsgContext, data NoData) error { return internal }, func(ctx *MsgContext, data NoData) error { return internal },
) )
err := policy(&MsgContext{Logger: sneklog.CreateLogger()}, NoData{}) err := policy(&MsgContext{Logger: sneklog.NewLogger()}, NoData{})
if !errors.Is(err, internal) { if !errors.Is(err, internal) {
t.Fatalf("expected internal error, got %v", err) t.Fatalf("expected internal error, got %v", err)
} }
@@ -205,7 +205,7 @@ func TestAnyPolicyReturnsFirstDenyWhenNoPolicyPasses(t *testing.T) {
func(ctx *MsgContext, data NoData) error { return AsUserError(errors.New("second deny")) }, func(ctx *MsgContext, data NoData) error { return AsUserError(errors.New("second deny")) },
) )
err := policy(&MsgContext{Logger: sneklog.CreateLogger()}, NoData{}) err := policy(&MsgContext{Logger: sneklog.NewLogger()}, NoData{})
if !errors.Is(err, first) { if !errors.Is(err, first) {
t.Fatalf("expected first deny error, got %v", err) t.Fatalf("expected first deny error, got %v", err)
} }
@@ -215,7 +215,7 @@ func TestNotPolicyInvertsUserDenyButPreservesInternalErrors(t *testing.T) {
inverted := NotPolicy(func(ctx *MsgContext, data NoData) error { inverted := NotPolicy(func(ctx *MsgContext, data NoData) error {
return AsUserError(errors.New("denied")) return AsUserError(errors.New("denied"))
}) })
if err := inverted(&MsgContext{Logger: sneklog.CreateLogger()}, NoData{}); err != nil { if err := inverted(&MsgContext{Logger: sneklog.NewLogger()}, NoData{}); err != nil {
t.Fatalf("expected inverted deny to succeed, got %v", err) t.Fatalf("expected inverted deny to succeed, got %v", err)
} }
@@ -223,7 +223,7 @@ func TestNotPolicyInvertsUserDenyButPreservesInternalErrors(t *testing.T) {
preserve := NotPolicy(func(ctx *MsgContext, data NoData) error { preserve := NotPolicy(func(ctx *MsgContext, data NoData) error {
return internal return internal
}) })
err := preserve(&MsgContext{Logger: sneklog.CreateLogger()}, NoData{}) err := preserve(&MsgContext{Logger: sneklog.NewLogger()}, NoData{})
if !errors.Is(err, internal) { if !errors.Is(err, internal) {
t.Fatalf("expected internal error to be preserved, got %v", err) t.Fatalf("expected internal error to be preserved, got %v", err)
} }
@@ -233,7 +233,7 @@ func TestRequirePolicyEmitsObserverEvents(t *testing.T) {
t.Run("allow", func(t *testing.T) { t.Run("allow", func(t *testing.T) {
observer := &recordingObserver{} observer := &recordingObserver{}
ctx := &MsgContext{ ctx := &MsgContext{
Logger: sneklog.CreateLogger(), Logger: sneklog.NewLogger(),
ctx: context.Background(), ctx: context.Background(),
observer: observer, observer: observer,
FromID: 10, FromID: 10,
@@ -258,7 +258,7 @@ func TestRequirePolicyEmitsObserverEvents(t *testing.T) {
t.Run("deny", func(t *testing.T) { t.Run("deny", func(t *testing.T) {
observer := &recordingObserver{} observer := &recordingObserver{}
ctx := &MsgContext{ ctx := &MsgContext{
Logger: sneklog.CreateLogger(), Logger: sneklog.NewLogger(),
ctx: context.Background(), ctx: context.Background(),
observer: observer, observer: observer,
errorTemplate: "%s", errorTemplate: "%s",
+3 -3
View File
@@ -17,7 +17,7 @@ type runnerObserver struct {
func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) { func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) {
var calls atomic.Int32 var calls atomic.Int32
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
runners: []Runner[NoData]{ runners: []Runner[NoData]{
NewRunner("sync-once", func(*Bot[NoData]) error { NewRunner("sync-once", func(*Bot[NoData]) error {
calls.Add(1) calls.Add(1)
@@ -39,7 +39,7 @@ func TestExecRunnersStopsBackgroundRunnerOnCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
runners: []Runner[NoData]{ runners: []Runner[NoData]{
NewRunner("background", func(*Bot[NoData]) error { NewRunner("background", func(*Bot[NoData]) error {
if calls.Add(1) == 1 { if calls.Add(1) == 1 {
@@ -71,7 +71,7 @@ func TestExecRunnersEmitObserverEvents(t *testing.T) {
wantErr := errors.New("runner failed") wantErr := errors.New("runner failed")
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
observer: observer, observer: observer,
runners: []Runner[NoData]{ runners: []Runner[NoData]{
NewRunner("sync-once", func(*Bot[NoData]) error { NewRunner("sync-once", func(*Bot[NoData]) error {
+3 -1
View File
@@ -218,12 +218,14 @@ func (bot *Bot[T]) emitSceneTransition(ctx *SceneContext, scene *Scene[T], from
return return
} }
to := from var to string
switch result.Action { switch result.Action {
case SceneActionNext: case SceneActionNext:
to = result.Next to = result.Next
case SceneActionExit: case SceneActionExit:
to = "" to = ""
default:
to = from
} }
bot.safeEmitEvent(ctx.Context(), SceneTransitionEvent{ bot.safeEmitEvent(ctx.Context(), SceneTransitionEvent{
+28 -28
View File
@@ -15,15 +15,15 @@ type failingSessionStore struct {
deleteErr error deleteErr error
} }
func (s failingSessionStore) Get(key string) (SceneSession, error) { func (s failingSessionStore) Get(string) (SceneSession, error) {
return SceneSession{}, s.getErr return SceneSession{}, s.getErr
} }
func (s failingSessionStore) Set(key string, session SceneSession) error { func (s failingSessionStore) Set(string, SceneSession) error {
return s.setErr return s.setErr
} }
func (s failingSessionStore) Delete(key string) error { func (s failingSessionStore) Delete(string) error {
return s.deleteErr return s.deleteErr
} }
@@ -56,7 +56,7 @@ func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) {
}) })
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
sessionStore: NewMemorySessionStore(), sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
@@ -149,7 +149,7 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) {
plugin.NewScene("signup") plugin.NewScene("signup")
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
sessionStore: NewMemorySessionStore(), sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
} }
@@ -172,7 +172,7 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) {
plugin.NewScene("signup").SetEntry("start") plugin.NewScene("signup").SetEntry("start")
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
sessionStore: NewMemorySessionStore(), sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
} }
@@ -231,7 +231,7 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) {
}) })
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
sessionStore: NewMemorySessionStore(), sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
@@ -279,7 +279,7 @@ func TestSceneCommandObserverEmitsLifecycleEvents(t *testing.T) {
}) })
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
sessionStore: NewMemorySessionStore(), sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
@@ -331,7 +331,7 @@ func TestSceneStepObserverEmitsLifecycleEvents(t *testing.T) {
}) })
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
sessionStore: NewMemorySessionStore(), sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
@@ -386,7 +386,7 @@ func TestSceneMessageObserverEmitsLifecycleEvents(t *testing.T) {
}) })
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
sessionStore: NewMemorySessionStore(), sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
@@ -453,8 +453,8 @@ func TestScenePayloadHandlerRunsBeforeStep(t *testing.T) {
}) })
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
payloadType: BotPayloadJson, payloadType: BotPayloadJSON,
sessionStore: NewMemorySessionStore(), sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
} }
@@ -469,9 +469,9 @@ func TestScenePayloadHandlerRunsBeforeStep(t *testing.T) {
t.Fatalf("EnterScene returned error: %v", err) t.Fatalf("EnterScene returned error: %v", err)
} }
data, err := encodeJsonPayload(CallbackData{Command: "confirm", Args: []string{"7", "ok"}}) data, err := encodeJSONPayload(CallbackData{Command: "confirm", Args: []string{"7", "ok"}})
if err != nil { if err != nil {
t.Fatalf("encodeJsonPayload returned error: %v", err) t.Fatalf("encodeJSONPayload returned error: %v", err)
} }
bot.handle(context.Background(), &tgapi.Update{ bot.handle(context.Background(), &tgapi.Update{
@@ -509,8 +509,8 @@ func TestScenePayloadObserverEmitsLifecycleEvents(t *testing.T) {
}) })
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
payloadType: BotPayloadJson, payloadType: BotPayloadJSON,
sessionStore: NewMemorySessionStore(), sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
observer: observer, observer: observer,
@@ -526,9 +526,9 @@ func TestScenePayloadObserverEmitsLifecycleEvents(t *testing.T) {
t.Fatalf("EnterScene returned error: %v", err) t.Fatalf("EnterScene returned error: %v", err)
} }
data, err := encodeJsonPayload(CallbackData{Command: "confirm"}) data, err := encodeJSONPayload(CallbackData{Command: "confirm"})
if err != nil { if err != nil {
t.Fatalf("encodeJsonPayload returned error: %v", err) t.Fatalf("encodeJSONPayload returned error: %v", err)
} }
bot.handle(context.Background(), &tgapi.Update{ bot.handle(context.Background(), &tgapi.Update{
@@ -572,8 +572,8 @@ func TestSceneUnmatchedPayloadFallsThroughWithoutRunningStep(t *testing.T) {
}) })
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
payloadType: BotPayloadJson, payloadType: BotPayloadJSON,
sessionStore: NewMemorySessionStore(), sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
} }
@@ -596,9 +596,9 @@ func TestSceneUnmatchedPayloadFallsThroughWithoutRunningStep(t *testing.T) {
t.Fatal("expected scene key to be built") t.Fatal("expected scene key to be built")
} }
data, err := encodeJsonPayload(CallbackData{Command: "ping"}) data, err := encodeJSONPayload(CallbackData{Command: "ping"})
if err != nil { if err != nil {
t.Fatalf("encodeJsonPayload returned error: %v", err) t.Fatalf("encodeJSONPayload returned error: %v", err)
} }
bot.handle(context.Background(), &tgapi.Update{ bot.handle(context.Background(), &tgapi.Update{
@@ -648,7 +648,7 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) {
}) })
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
sessionStore: NewMemorySessionStore(), sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
@@ -724,7 +724,7 @@ func TestSceneUnmatchedCommandFallsThroughWithoutRunningStep(t *testing.T) {
}, "ping") }, "ping")
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
sessionStore: NewMemorySessionStore(), sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
@@ -793,7 +793,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) {
}) })
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
prefixes: []string{"/"}, prefixes: []string{"/"},
sessionStore: NewMemorySessionStore(), sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
@@ -838,7 +838,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) {
func TestFindSceneSessionSupportsUserScopeWithoutMessage(t *testing.T) { func TestFindSceneSessionSupportsUserScopeWithoutMessage(t *testing.T) {
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
sessionStore: NewMemorySessionStore(), sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUser, SceneScopeChat, SceneScopeUserChat}, sceneScopePriority: []SceneScope{SceneScopeUser, SceneScopeChat, SceneScopeUserChat},
} }
@@ -865,7 +865,7 @@ func TestSceneStoreErrorsPropagate(t *testing.T) {
t.Run("find scene session get error", func(t *testing.T) { t.Run("find scene session get error", func(t *testing.T) {
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
sessionStore: failingSessionStore{getErr: getErr}, sessionStore: failingSessionStore{getErr: getErr},
sceneScopePriority: []SceneScope{SceneScopeUser}, sceneScopePriority: []SceneScope{SceneScopeUser},
} }
@@ -881,7 +881,7 @@ func TestSceneStoreErrorsPropagate(t *testing.T) {
return ctx.Stay(), nil return ctx.Stay(), nil
}) })
bot := &Bot[NoData]{ bot := &Bot[NoData]{
logger: sneklog.CreateLogger(), logger: sneklog.NewLogger(),
sessionStore: failingSessionStore{setErr: setErr}, sessionStore: failingSessionStore{setErr: setErr},
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
} }
+44 -24
View File
@@ -19,7 +19,10 @@ type APIOpts struct {
token string token string
client *http.Client client *http.Client
useTestServer bool useTestServer bool
apiUrl string apiURL string
logFormat utils.LogFormat
logFormatter *sneklog.Formatter
limiter *utils.RateLimiter limiter *utils.RateLimiter
dropOverflowLimit bool dropOverflowLimit bool
@@ -32,7 +35,7 @@ func NewAPIOpts(token string) *APIOpts {
token: token, token: token,
client: nil, client: nil,
useTestServer: false, useTestServer: false,
apiUrl: "https://api.telegram.org", apiURL: "https://api.telegram.org",
} }
} }
@@ -52,15 +55,24 @@ func (opts *APIOpts) UseTestServer(use bool) *APIOpts {
return opts return opts
} }
// SetAPIUrl overrides the default Telegram API URL. // SetAPIURL overrides the default Telegram API URL.
// Useful for self-hosted bots or proxies. // Useful for self-hosted bots or proxies.
func (opts *APIOpts) SetAPIUrl(apiUrl string) *APIOpts { func (opts *APIOpts) SetAPIURL(apiURL string) *APIOpts {
if apiUrl != "" { if apiURL != "" {
opts.apiUrl = apiUrl opts.apiURL = apiURL
} }
return opts return opts
} }
func (opts *APIOpts) SetLogFormat(format utils.LogFormat) *APIOpts {
opts.logFormat = format
return opts
}
func (opts *APIOpts) SetLogFormatter(formatter *sneklog.Formatter) *APIOpts {
opts.logFormatter = formatter
return opts
}
// SetLimiter sets a rate limiter to enforce Telegram's API limits. // SetLimiter sets a rate limiter to enforce Telegram's API limits.
// Recommended: use utils.NewRateLimiter() for correct per-chat and global throttling. // Recommended: use utils.NewRateLimiter() for correct per-chat and global throttling.
func (opts *APIOpts) SetLimiter(limiter *utils.RateLimiter) *APIOpts { func (opts *APIOpts) SetLimiter(limiter *utils.RateLimiter) *APIOpts {
@@ -87,7 +99,10 @@ type API struct {
client *http.Client client *http.Client
logger *sneklog.Logger logger *sneklog.Logger
useTestServer bool useTestServer bool
apiUrl string apiURL string
logFormat utils.LogFormat
logFormatter *sneklog.Formatter
pool *workerPool pool *workerPool
Limiter *utils.RateLimiter Limiter *utils.RateLimiter
@@ -97,12 +112,13 @@ type API struct {
// NewAPI creates a new API client from options. // NewAPI creates a new API client from options.
// Always call Close() when done to release resources. // Always call Close() when done to release resources.
func NewAPI(opts *APIOpts) *API { func NewAPI(opts *APIOpts) *API {
l := utils.CreateLogger("API", utils.GetLoggerLevel())
if opts == nil { if opts == nil {
l.Errorln("Set API options")
_ = l.Close()
return nil return nil
} }
logger := utils.CreateLogger(
"API", utils.GetLoggerLevel(),
opts.logFormat, opts.logFormatter,
)
client := opts.client client := opts.client
if client == nil { if client == nil {
@@ -115,9 +131,13 @@ func NewAPI(opts *APIOpts) *API {
return &API{ return &API{
token: opts.token, token: opts.token,
client: client, client: client,
logger: l, logger: logger,
useTestServer: opts.useTestServer, useTestServer: opts.useTestServer,
apiUrl: opts.apiUrl, apiURL: opts.apiURL,
logFormat: opts.logFormat,
logFormatter: opts.logFormatter,
pool: pool, pool: pool,
Limiter: opts.limiter, Limiter: opts.limiter,
dropOverflowLimit: opts.dropOverflowLimit, dropOverflowLimit: opts.dropOverflowLimit,
@@ -147,9 +167,9 @@ type ResponseParameters struct {
RetryAfter *int `json:"retry_after,omitempty"` RetryAfter *int `json:"retry_after,omitempty"`
} }
// ApiResponse is the standard Telegram Bot API response structure. // TelegramResponse is the standard Telegram Bot API response structure.
// Generic over Result type R. // Generic over Result type R.
type ApiResponse[R any] struct { type TelegramResponse[R any] struct {
Ok bool `json:"ok"` Ok bool `json:"ok"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Result R `json:"result,omitempty"` Result R `json:"result,omitempty"`
@@ -166,7 +186,7 @@ type ApiResponse[R any] struct {
type TelegramRequest[R, P any] struct { type TelegramRequest[R, P any] struct {
method string method string
params P params P
chatId int64 chatID int64
} }
// NewRequest creates a low-level TelegramRequest with no associated chat ID. // NewRequest creates a low-level TelegramRequest with no associated chat ID.
@@ -176,8 +196,8 @@ func NewRequest[R, P any](method string, params P) TelegramRequest[R, P] {
// NewRequestWithChatID creates a low-level TelegramRequest with an associated chat ID. // NewRequestWithChatID creates a low-level TelegramRequest with an associated chat ID.
// The chat ID is used for per-chat rate limiting. // The chat ID is used for per-chat rate limiting.
func NewRequestWithChatID[R, P any](method string, params P, chatId int64) TelegramRequest[R, P] { func NewRequestWithChatID[R, P any](method string, params P, chatID int64) TelegramRequest[R, P] {
return TelegramRequest[R, P]{method, params, chatId} return TelegramRequest[R, P]{method, params, chatID}
} }
func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, error) { func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, error) {
@@ -191,7 +211,7 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro
if api.useTestServer { if api.useTestServer {
methodPrefix = "/test" methodPrefix = "/test"
} }
url := fmt.Sprintf("%s/bot%s%s/%s", api.apiUrl, api.token, methodPrefix, r.method) url := fmt.Sprintf("%s/bot%s%s/%s", api.apiURL, api.token, methodPrefix, r.method)
req, err := http.NewRequestWithContext(ctx, "POST", url, nil) req, err := http.NewRequestWithContext(ctx, "POST", url, nil)
if err != nil { if err != nil {
return zero, fmt.Errorf("failed to create request: %w", err) return zero, fmt.Errorf("failed to create request: %w", err)
@@ -204,7 +224,7 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro
for { for {
// Apply rate limiting before making the request // Apply rate limiting before making the request
if api.Limiter != nil { if api.Limiter != nil {
if err := api.Limiter.Check(ctx, api.dropOverflowLimit, r.chatId); err != nil { if err := api.Limiter.Check(ctx, api.dropOverflowLimit, r.chatID); err != nil {
return zero, err return zero, err
} }
} }
@@ -235,12 +255,12 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro
// Handle rate limiting (429) // Handle rate limiting (429)
if response.ErrorCode == 429 && response.Parameters != nil && response.Parameters.RetryAfter != nil { if response.ErrorCode == 429 && response.Parameters != nil && response.Parameters.RetryAfter != nil {
after := *response.Parameters.RetryAfter after := *response.Parameters.RetryAfter
api.logger.Warnf("Rate limited by Telegram, retry after %d seconds (chat: %d)", after, r.chatId) api.logger.Warnf("Rate limited by Telegram, retry after %d seconds (chat: %d)", after, r.chatID)
// Apply cooldown to global or chat-specific limiter // Apply cooldown to global or chat-specific limiter
if api.Limiter != nil { if api.Limiter != nil {
if r.chatId > 0 { if r.chatID > 0 {
api.Limiter.SetChatLock(r.chatId, after) api.Limiter.SetChatLock(r.chatID, after)
} else { } else {
api.Limiter.SetGlobalLock(after) api.Limiter.SetGlobalLock(after)
} }
@@ -302,8 +322,8 @@ func readBody(body io.ReadCloser) ([]byte, error) {
} }
// Internal helper that parses a typed Telegram API response body. // Internal helper that parses a typed Telegram API response body.
func parseBody[R any](data []byte) (ApiResponse[R], error) { func parseBody[R any](data []byte) (TelegramResponse[R], error) {
var resp ApiResponse[R] var resp TelegramResponse[R]
err := json.Unmarshal(data, &resp) err := json.Unmarshal(data, &resp)
if err != nil { if err != nil {
return resp, fmt.Errorf("failed to unmarshal JSON: %w", err) return resp, fmt.Errorf("failed to unmarshal JSON: %w", err)
+2 -2
View File
@@ -40,7 +40,7 @@ func TestAPILeavesAcceptEncodingToHTTPTransport(t *testing.T) {
api := NewAPI( api := NewAPI(
NewAPIOpts("token"). NewAPIOpts("token").
SetAPIUrl("https://example.test"). SetAPIURL("https://example.test").
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
@@ -77,7 +77,7 @@ func TestAPICloseClosesIdleConnections(t *testing.T) {
api := NewAPI( api := NewAPI(
NewAPIOpts("token"). NewAPIOpts("token").
SetAPIUrl("https://example.test"). SetAPIURL("https://example.test").
SetHTTPClient(&http.Client{Transport: transport}), SetHTTPClient(&http.Client{Transport: transport}),
) )
-3
View File
@@ -2,9 +2,6 @@ package tgapi
import "errors" import "errors"
// ErrRateLimit reports that a request exceeded the configured rate limiter.
var ErrRateLimit = errors.New("rate limit exceeded")
// ErrPoolUnexpected reports an unexpected result type returned from the worker pool. // ErrPoolUnexpected reports an unexpected result type returned from the worker pool.
var ErrPoolUnexpected = errors.New("unexpected response from pool") var ErrPoolUnexpected = errors.New("unexpected response from pool")
+1 -1
View File
@@ -472,7 +472,7 @@ func (api *API) SendChatActionWithContext(ctx context.Context, params SendChatAc
// See https://core.telegram.org/bots/api#setmessagereaction // See https://core.telegram.org/bots/api#setmessagereaction
type SetMessageReaction struct { type SetMessageReaction struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageId int `json:"message_id"` MessageID int `json:"message_id"`
Reaction []ReactionType `json:"reaction"` Reaction []ReactionType `json:"reaction"`
IsBig bool `json:"is_big,omitempty"` IsBig bool `json:"is_big,omitempty"`
} }
+5 -5
View File
@@ -103,7 +103,7 @@ type Message struct {
SenderBusinessBot *User `json:"sender_business_bot,omitempty"` SenderBusinessBot *User `json:"sender_business_bot,omitempty"`
SenderTag string `json:"sender_tag,omitempty"` SenderTag string `json:"sender_tag,omitempty"`
Date int `json:"date"` Date int `json:"date"`
BusinessConnectionId string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
Chat *Chat `json:"chat,omitempty"` Chat *Chat `json:"chat,omitempty"`
ForwardOrigin *MessageOrigin `json:"forward_origin,omitempty"` ForwardOrigin *MessageOrigin `json:"forward_origin,omitempty"`
@@ -121,7 +121,7 @@ type Message struct {
HasProtectedContent bool `json:"has_protected_content,omitempty"` HasProtectedContent bool `json:"has_protected_content,omitempty"`
IsFromOffline bool `json:"is_from_offline,omitempty"` IsFromOffline bool `json:"is_from_offline,omitempty"`
IsPaidPost bool `json:"is_paid_post,omitempty"` IsPaidPost bool `json:"is_paid_post,omitempty"`
MediaGroupId string `json:"media_group_id,omitempty"` MediaGroupID string `json:"media_group_id,omitempty"`
AuthorSignature string `json:"author_signature,omitempty"` AuthorSignature string `json:"author_signature,omitempty"`
PaidStarCount int `json:"paid_star_count,omitempty"` PaidStarCount int `json:"paid_star_count,omitempty"`
@@ -316,8 +316,8 @@ const (
MessageEntityCashtag MessageEntityType = "cashtag" MessageEntityCashtag MessageEntityType = "cashtag"
// MessageEntityBotCommand identifies a bot command entity. // MessageEntityBotCommand identifies a bot command entity.
MessageEntityBotCommand MessageEntityType = "bot_command" MessageEntityBotCommand MessageEntityType = "bot_command"
// MessageEntityUrl identifies a URL entity. // MessageEntityURL identifies a URL entity.
MessageEntityUrl MessageEntityType = "url" MessageEntityURL MessageEntityType = "url"
// MessageEntityEmail identifies an email entity. // MessageEntityEmail identifies an email entity.
MessageEntityEmail MessageEntityType = "email" MessageEntityEmail MessageEntityType = "email"
// MessageEntityPhoneNumber identifies a phone number entity. // MessageEntityPhoneNumber identifies a phone number entity.
@@ -537,7 +537,7 @@ const (
// ChatActionUploadVideoNote tells Telegram the bot is uploading a video note. // ChatActionUploadVideoNote tells Telegram the bot is uploading a video note.
ChatActionUploadVideoNote ChatActionType = "upload_video_note" ChatActionUploadVideoNote ChatActionType = "upload_video_note"
// ChatActionUploadVideoNone is a deprecated alias for ChatActionUploadVideoNote. // ChatActionUploadVideoNone is a deprecated alias for ChatActionUploadVideoNote.
ChatActionUploadVideoNone ChatActionType = ChatActionUploadVideoNote ChatActionUploadVideoNone = ChatActionUploadVideoNote
) )
// MessageReactionUpdated represents a change of a reaction on a message. // MessageReactionUpdated represents a change of a reaction on a message.
+1 -1
View File
@@ -256,7 +256,7 @@ func (api *API) openFileByLink(ctx context.Context, link string) (io.ReadCloser,
if api.useTestServer { if api.useTestServer {
methodPrefix = "/test" methodPrefix = "/test"
} }
u := fmt.Sprintf("%s/file/bot%s%s/%s", api.apiUrl, api.token, methodPrefix, link) u := fmt.Sprintf("%s/file/bot%s%s/%s", api.apiURL, api.token, methodPrefix, link)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil { if err != nil {
+5 -5
View File
@@ -23,7 +23,7 @@ func TestGetFileByLinkUsesConfiguredAPIURL(t *testing.T) {
api := NewAPI( api := NewAPI(
NewAPIOpts("token"). NewAPIOpts("token").
SetAPIUrl("https://example.test"). SetAPIURL("https://example.test").
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
@@ -47,7 +47,7 @@ func TestGetFileByLinkUsesConfiguredAPIURL(t *testing.T) {
func TestOpenFileByLinkStreamsResponseBody(t *testing.T) { func TestOpenFileByLinkStreamsResponseBody(t *testing.T) {
api := NewAPI( api := NewAPI(
NewAPIOpts("token"). NewAPIOpts("token").
SetAPIUrl("https://example.test"). SetAPIURL("https://example.test").
SetHTTPClient(&http.Client{ SetHTTPClient(&http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{ return &http.Response{
@@ -94,7 +94,7 @@ func TestGetFileByLinkReturnsHTTPStatusError(t *testing.T) {
api := NewAPI( api := NewAPI(
NewAPIOpts("token"). NewAPIOpts("token").
SetAPIUrl("https://example.test"). SetAPIURL("https://example.test").
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
@@ -131,7 +131,7 @@ func TestGetUpdatesOmitsAllowedUpdatesWhenEmpty(t *testing.T) {
api := NewAPI( api := NewAPI(
NewAPIOpts("token"). NewAPIOpts("token").
SetAPIUrl("https://example.test"). SetAPIURL("https://example.test").
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
@@ -174,7 +174,7 @@ func TestSetChatMenuButtonSendsStructuredMenuButton(t *testing.T) {
api := NewAPI( api := NewAPI(
NewAPIOpts("token"). NewAPIOpts("token").
SetAPIUrl("https://example.test"). SetAPIURL("https://example.test").
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
+13 -12
View File
@@ -70,12 +70,13 @@ type Uploader struct {
// NewUploader creates a multipart uploader bound to an API client. // NewUploader creates a multipart uploader bound to an API client.
func NewUploader(api *API) *Uploader { func NewUploader(api *API) *Uploader {
logger := utils.CreateLogger("UPLOADER", utils.GetLoggerLevel())
if api == nil { if api == nil {
logger.Errorln("api is nil")
_ = logger.Close()
return nil return nil
} }
logger := utils.CreateLogger(
"UPLOADER", utils.GetLoggerLevel(),
api.logFormat, api.logFormatter,
)
return &Uploader{api, logger} return &Uploader{api, logger}
} }
@@ -97,18 +98,18 @@ type UploaderRequest[R, P any] struct {
method string method string
files []UploaderFile files []UploaderFile
params P params P
chatId int64 chatID int64
} }
// NewUploaderRequest creates a low-level multipart upload request with no associated chat ID. // NewUploaderRequest creates a low-level multipart upload request with no associated chat ID.
func NewUploaderRequest[R, P any](method string, params P, files ...UploaderFile) UploaderRequest[R, P] { func NewUploaderRequest[R, P any](method string, params P, files ...UploaderFile) UploaderRequest[R, P] {
return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: 0} return UploaderRequest[R, P]{method: method, files: files, params: params, chatID: 0}
} }
// NewUploaderRequestWithChatID creates a low-level multipart upload request with an associated chat ID. // NewUploaderRequestWithChatID creates a low-level multipart upload request with an associated chat ID.
// The chat ID is used for per-chat rate limiting. // The chat ID is used for per-chat rate limiting.
func NewUploaderRequestWithChatID[R, P any](method string, params P, chatId int64, files ...UploaderFile) UploaderRequest[R, P] { func NewUploaderRequestWithChatID[R, P any](method string, params P, chatID int64, files ...UploaderFile) UploaderRequest[R, P] {
return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: chatId} return UploaderRequest[R, P]{method: method, files: files, params: params, chatID: chatID}
} }
func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, error) { func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, error) {
@@ -118,11 +119,11 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R,
if up.api.useTestServer { if up.api.useTestServer {
methodPrefix = "/test" methodPrefix = "/test"
} }
url := fmt.Sprintf("%s/bot%s%s/%s", up.api.apiUrl, up.api.token, methodPrefix, r.method) url := fmt.Sprintf("%s/bot%s%s/%s", up.api.apiURL, up.api.token, methodPrefix, r.method)
for { for {
if up.api.Limiter != nil { if up.api.Limiter != nil {
if err := up.api.Limiter.Check(ctx, up.api.dropOverflowLimit, r.chatId); err != nil { if err := up.api.Limiter.Check(ctx, up.api.dropOverflowLimit, r.chatID); err != nil {
return zero, err return zero, err
} }
} }
@@ -161,10 +162,10 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R,
if !response.Ok { if !response.Ok {
if response.ErrorCode == 429 && response.Parameters != nil && response.Parameters.RetryAfter != nil { if response.ErrorCode == 429 && response.Parameters != nil && response.Parameters.RetryAfter != nil {
after := *response.Parameters.RetryAfter after := *response.Parameters.RetryAfter
up.logger.Warnf("Rate limited, retry after %d seconds (chat: %d)", after, r.chatId) up.logger.Warnf("Rate limited, retry after %d seconds (chat: %d)", after, r.chatID)
if up.api.Limiter != nil { if up.api.Limiter != nil {
if r.chatId > 0 { if r.chatID > 0 {
up.api.Limiter.SetChatLock(r.chatId, after) up.api.Limiter.SetChatLock(r.chatID, after)
} else { } else {
up.api.Limiter.SetGlobalLock(after) up.api.Limiter.SetGlobalLock(after)
} }
+1 -1
View File
@@ -40,7 +40,7 @@ func TestUploaderEncodesJSONFieldsAndLeavesAcceptEncodingToHTTPTransport(t *test
api := NewAPI( api := NewAPI(
NewAPIOpts("token"). NewAPIOpts("token").
SetAPIUrl("https://example.test"). SetAPIURL("https://example.test").
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
+3 -3
View File
@@ -142,15 +142,15 @@ func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MsgContext) {
if u.CallbackQuery != nil { if u.CallbackQuery != nil {
if u.CallbackQuery.Message != nil { if u.CallbackQuery.Message != nil {
ctx.Msg = u.CallbackQuery.Message ctx.Msg = u.CallbackQuery.Message
ctx.CallbackMsgId = u.CallbackQuery.Message.MessageID ctx.CallbackMsgID = u.CallbackQuery.Message.MessageID
if u.CallbackQuery.Message.Chat != nil { if u.CallbackQuery.Message.Chat != nil {
chat = u.CallbackQuery.Message.Chat chat = u.CallbackQuery.Message.Chat
} }
} }
if u.CallbackQuery.InlineMessageID != nil { if u.CallbackQuery.InlineMessageID != nil {
ctx.InlineMsgId = *u.CallbackQuery.InlineMessageID ctx.InlineMsgID = *u.CallbackQuery.InlineMessageID
} }
ctx.CallbackQueryId = u.CallbackQuery.ID ctx.CallbackQueryID = u.CallbackQuery.ID
from = &u.CallbackQuery.From from = &u.CallbackQuery.From
} }
case tgapi.UpdateTypeShippingQuery: case tgapi.UpdateTypeShippingQuery:
+51 -9
View File
@@ -6,6 +6,13 @@ import (
"git.scuroneko.dev/scuroneko/sneklog/v2" "git.scuroneko.dev/scuroneko/sneklog/v2"
) )
type LogFormat string
const (
LogFormatText LogFormat = "text"
LogFormatJSON LogFormat = "json"
)
// GetLoggerLevel returns DEBUG when DEBUG=true in env, otherwise FATAL. // GetLoggerLevel returns DEBUG when DEBUG=true in env, otherwise FATAL.
func GetLoggerLevel() sneklog.LogLevel { func GetLoggerLevel() sneklog.LogLevel {
level := sneklog.FATAL level := sneklog.FATAL
@@ -17,12 +24,28 @@ func GetLoggerLevel() sneklog.LogLevel {
// CreateLogger creates a logger with the shared default policy: // CreateLogger creates a logger with the shared default policy:
// JSON stdout output, provided prefix, and provided level. // JSON stdout output, provided prefix, and provided level.
func CreateLogger(prefix string, level sneklog.LogLevel) *sneklog.Logger { func CreateLogger(
logger := sneklog.CreateLogger().Level(level) name string, level sneklog.LogLevel,
if prefix != "" { format LogFormat, formatter *sneklog.Formatter,
logger.Prefix(prefix) ) *sneklog.Logger {
logger := sneklog.NewLogger().SetLevel(level)
if name != "" {
logger.SetName(name)
}
switch format {
case LogFormatJSON:
writer := logger.CreateJsonStdoutWriter()
if formatter != nil {
writer.SetFormatter(formatter)
}
logger.AddWriters(writer)
default:
writer := logger.CreateTextStdoutWriter()
if formatter != nil {
writer.SetFormatter(formatter)
}
logger.AddWriters(writer)
} }
logger.AddWriter(logger.CreateJsonStdoutWriter())
return logger return logger
} }
@@ -31,12 +54,31 @@ func CreateLogger(prefix string, level sneklog.LogLevel) *sneklog.Logger {
// //
// The returned logger is always non-nil. When file writer creation fails, the // The returned logger is always non-nil. When file writer creation fails, the
// logger still writes to stdout and the error is returned to the caller. // logger still writes to stdout and the error is returned to the caller.
func CreateFileLogger(prefix string, level sneklog.LogLevel, filePath string) (*sneklog.Logger, error) { func CreateFileLogger(
logger := CreateLogger(prefix, level) prefix string, level sneklog.LogLevel, filePath string,
fileWriter, err := logger.CreateTextFileWriter(filePath) format LogFormat, formatter *sneklog.Formatter,
) (*sneklog.Logger, error) {
logger := CreateLogger(prefix, level, format, formatter)
switch format {
case LogFormatJSON:
writer, err := logger.CreateJsonFileWriter(filePath)
if err != nil { if err != nil {
return logger, err return logger, err
} }
logger.AddWriter(fileWriter) if formatter != nil {
writer.SetFormatter(formatter)
}
logger.AddWriters(writer)
default:
writer, err := logger.CreateTextFileWriter(filePath)
if err != nil {
return logger, err
}
if formatter != nil {
writer.SetFormatter(formatter)
}
logger.AddWriters(writer)
}
return logger, nil return logger, nil
} }
+1 -1
View File
@@ -12,7 +12,7 @@ import (
func TestCreateFileLoggerWritesToConfiguredFile(t *testing.T) { func TestCreateFileLoggerWritesToConfiguredFile(t *testing.T) {
logPath := filepath.Join(t.TempDir(), "main.log") logPath := filepath.Join(t.TempDir(), "main.log")
logger, err := CreateFileLogger("TEST", sneklog.DEBUG, logPath) logger, err := CreateFileLogger("TEST", sneklog.DEBUG, logPath, LogFormatText, nil)
if err != nil { if err != nil {
t.Fatalf("CreateFileLogger returned error: %v", err) t.Fatalf("CreateFileLogger returned error: %v", err)
} }