diff --git a/CHANGELOG.md b/CHANGELOG.md index afae59b..6f4cd31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. - 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. +- Updated Go initialism names for JSON, URL, ID, and API helpers. ### Tests - Added regression coverage proving polling startup preserves an enabled request logger. - 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 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 diff --git a/README.md b/README.md index 199eadd..09a8cd6 100644 --- a/README.md +++ b/README.md @@ -125,12 +125,12 @@ func main() { `BotOpts` can also be loaded from or saved to config files through the file codec API. Built in: -- `BotOptsFileJsonCodec` for JSON files. +- `BotOptsFileJSONCodec` for JSON files. Example: ```go -codec := laniakea.BotOptsFileJsonCodec{} +codec := laniakea.BotOptsFileJSONCodec{} opts, err := laniakea.LoadBotOptsFile(codec, "config.json") if err != nil { log.Fatal(err) @@ -145,7 +145,7 @@ if err != nil { 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`. -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) @@ -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. - `KeyboardLong(text string, keyboard *InlineKeyboard) []*AnswerMessage`: Splits long plain text into multiple messages and attaches the keyboard to the final chunk. - `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping) and inline keyboard. -- `AnswerPhoto(photoId, text string) *AnswerMessage`: Sends a message with photo with parse_mode none. -- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Sends a photo with MarkdownV2 caption (you handle escaping). +- `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). - `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. - `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! ### 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. ## ⚙️ 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. - **Localization**: `L10n` is safe for concurrent use once attached to the bot. - **Custom Update Handlers**: Use `plugin.AddUpdateHandler(...)` for Telegram update types that are not part of the command/payload flow. diff --git a/README_RU.md b/README_RU.md index 263340e..a9595f5 100644 --- a/README_RU.md +++ b/README_RU.md @@ -126,12 +126,12 @@ func main() { `BotOpts` можно не только собирать вручную или из environment, но и загружать и сохранять через file codec API. Из коробки доступно: -- `BotOptsFileJsonCodec` для JSON-файлов. +- `BotOptsFileJSONCodec` для JSON-файлов. Пример: ```go -codec := laniakea.BotOptsFileJsonCodec{} +codec := laniakea.BotOptsFileJSONCodec{} opts, err := laniakea.LoadBotOptsFile(codec, "config.json") if err != nil { log.Fatal(err) @@ -146,7 +146,7 @@ if err != nil { Плейсхолдеры вида `{{ TG_TOKEN }}` внутри файла перед декодированием разворачиваются из переменных окружения. Для других форматов можно реализовать собственный 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) @@ -198,12 +198,12 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) error { - `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение с parse_mode none и Inline клавиатурой. - `KeyboardLong(text string, keyboard *InlineKeyboard) []*AnswerMessage`: Разбивает длинный plain text на несколько сообщений и вешает клавиатуру на последний chunk. - `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне), и Inline клавиатурой. -- `AnswerPhoto(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью и parse_mode none. -- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью, отформатированной MarkdownV2 (экранирование на вашей стороне). +- `AnswerPhoto(photoID, text string) *AnswerMessage`: Отправляет фотографию с подписью и parse_mode none. +- `AnswerPhotoMarkdown(photoID, text string) *AnswerMessage`: Отправляет фотографию с подписью, отформатированной MarkdownV2 (экранирование на вашей стороне). - `EditCallback(text string)`: Редактирует сообщение с `parse_mode` none после нажатия inline-кнопки. - `EditCallbackMarkdown(text string)`: Редактирует сообщение в формате MarkdownV2 (экранирование на вашей стороне) после нажатия inline-кнопки. - `SendAction(action tgapi.ChatActionType)`: Отправляет действие "печатает", "загружает фото" и т.д. -- Поля: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgId`, `CallbackQueryId` и другие. +- Поля: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgID`, `CallbackQueryID` и другие. - И много других методов и полей! ### App Data @@ -313,7 +313,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { - 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. - **Локализация**: `L10n` безопасен для конкурентного использования после подключения к боту. - **Пользовательские update handlers**: Используйте `plugin.AddUpdateHandler(...)` для Telegram update types вне command/payload flow. diff --git a/bot.go b/bot.go index 7d2083b..afc9176 100644 --- a/bot.go +++ b/bot.go @@ -50,8 +50,8 @@ type BotPayloadType string var ( // BotPayloadBase64 encodes callback data as a Base64 string. BotPayloadBase64 BotPayloadType = "base64" - // BotPayloadJson encodes callback data as a JSON string. - BotPayloadJson BotPayloadType = "json" + // BotPayloadJSON encodes callback data as a JSON string. + BotPayloadJSON BotPayloadType = "json" ) var ( @@ -90,6 +90,8 @@ type Bot[T AppData] struct { strictPayloadType bool maxWorkers int + logFormat utils.LogFormat + logFormatter *sneklog.Formatter logger *sneklog.Logger // Main bot logger (JSON stdout + optional file) requestLogger *sneklog.Logger // Optional request-level API logging useReqLogger bool @@ -159,10 +161,12 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) { limiter.SetGlobalRate(opts.RateLimit) apiOpts := tgapi.NewAPIOpts(opts.Token). - SetAPIUrl(opts.APIUrl). + SetAPIURL(opts.APIURL). UseTestServer(opts.UseTestServer). SetLimiter(limiter). - SetLimiterDrop(opts.DropRLOverflow) + SetLimiterDrop(opts.DropRLOverflow). + SetLogFormat(opts.LogFormat). + SetLogFormatter(opts.LogFormatter) api := tgapi.NewAPI(apiOpts) uploader := tgapi.NewUploader(api) @@ -188,6 +192,8 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) { debug: opts.Debug, prefixes: prefixes, token: opts.Token, + logFormat: opts.LogFormat, + logFormatter: opts.LogFormatter, useReqLogger: opts.UseRequestLogger, plugins: make([]Plugin[T], 0), @@ -239,21 +245,21 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) { } // SetLogger replaces the main bot logger. -func (b *Bot[T]) SetLogger(l *sneklog.Logger) *Bot[T] { - b.logger = l - return b +func (bot *Bot[T]) SetLogger(l *sneklog.Logger) *Bot[T] { + bot.logger = l + return bot } // SetRequestLogger replaces the request-level logger. -func (b *Bot[T]) SetRequestLogger(l *sneklog.Logger) *Bot[T] { - b.requestLogger = l - return b +func (bot *Bot[T]) SetRequestLogger(l *sneklog.Logger) *Bot[T] { + bot.requestLogger = l + return bot } // SetWebHookLogger replaces the webhook logger. -func (b *Bot[T]) SetWebHookLogger(l *sneklog.Logger) *Bot[T] { - b.webHookLogger = l - return b +func (bot *Bot[T]) SetWebHookLogger(l *sneklog.Logger) *Bot[T] { + bot.webHookLogger = l + return bot } // Close gracefully shuts down bot-owned resources. diff --git a/bot_config.go b/bot_config.go index ad179b5..e7752ad 100644 --- a/bot_config.go +++ b/bot_config.go @@ -19,7 +19,7 @@ func (bot *Bot[T]) AddPrefixes(prefixes ...string) *Bot[T] { } // 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] { if !bot.configMutable("SetDraftProvider") { return bot @@ -192,15 +192,15 @@ func (bot *Bot[T]) SetDebug(debug bool) *Bot[T] { level = sneklog.DEBUG } - bot.logger.Level(level) + bot.logger.SetLevel(level) if bot.requestLogger != nil { - bot.requestLogger.Level(level) + bot.requestLogger.SetLevel(level) } for _, p := range bot.plugins { if p.logger == nil { continue } - p.logger.Level(level) + p.logger.SetLevel(level) } return bot } diff --git a/bot_opts.go b/bot_opts.go index 36bfaab..d03e1e7 100644 --- a/bot_opts.go +++ b/bot_opts.go @@ -6,6 +6,8 @@ import ( "strings" "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. @@ -45,8 +47,8 @@ type BotOpts struct { // UseTestServer uses Telegram's test server (https://api.test.telegram.org). UseTestServer bool - // APIUrl overrides the default Telegram API endpoint (useful for proxies or self-hosted). - APIUrl string + // APIURL overrides the default Telegram API endpoint (useful for proxies or self-hosted). + APIURL string // RateLimit is the maximum number of API requests per second. // 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. FileConfigVersion int + + LogFormat utils.LogFormat + LogFormatter *sneklog.Formatter } // LoadOptsFromEnv loads BotOpts from environment variables. @@ -87,6 +92,7 @@ type BotOpts struct { // - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow // - STRICT_PAYLOAD_TYPE: "true" to reject callback payloads encoded in a different format // - MAX_WORKERS: maximum number of concurrent update handlers (default: 32) +// - JSON_LOG: // // Returns a populated BotOpts. // 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", UseTestServer: os.Getenv("USE_TEST_SERVER") == "true", - APIUrl: os.Getenv("API_URL"), + APIURL: os.Getenv("API_URL"), RateLimit: rateLimit, DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true", StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true", 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 } -// 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". -func (opts *BotOpts) SetAPIUrl(url string) *BotOpts { - opts.APIUrl = url +func (opts *BotOpts) SetAPIURL(url string) *BotOpts { + opts.APIURL = url return opts } @@ -247,6 +254,15 @@ func (opts *BotOpts) SetMaxWorkers(workers int) *BotOpts { 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. // Defaults to ["/"] if not set. func LoadPrefixesFromEnv() []string { diff --git a/bot_opts_loader.go b/bot_opts_loader.go index 65e9856..99f4ba7 100644 --- a/bot_opts_loader.go +++ b/bot_opts_loader.go @@ -8,6 +8,7 @@ import ( "regexp" "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. @@ -17,35 +18,39 @@ const ConfigVersion = 1 // than this library knows how to decode. var ErrConfigVersionMismatch = fmt.Errorf("config version mismatch: expected %d", ConfigVersion) -// BotOptsFileJson is the JSON file representation of BotOpts. -type BotOptsFileJson struct { - Version int `json:"version"` - Token string `json:"token"` - UpdateTypes []tgapi.UpdateType `json:"update_types"` - Debug bool `json:"debug"` - ErrorTemplate string `json:"error_template"` - Prefixes []string `json:"prefixes"` - Logger struct { - LoggerBasePath string `json:"base_path"` - 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"` - MaxWorkers int `json:"max_workers"` +type botOptsFileJSONLogger 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"` } -// BotOptsFileJsonCodec encodes and decodes BotOpts using BotOptsFileJson. -type BotOptsFileJsonCodec struct{} +// BotOptsFileJSON is the JSON file representation of BotOpts. +type BotOptsFileJSON struct { + Version int `json:"version"` + Token string `json:"token"` + UpdateTypes []tgapi.UpdateType `json:"update_types"` + Debug bool `json:"debug"` + ErrorTemplate string `json:"error_template"` + Prefixes []string `json:"prefixes"` + Logger botOptsFileJSONLogger `json:"logger"` + API botOptsFileJSONAPI `json:"api"` + StrictPayloadType bool `json:"strict_payload_type"` + MaxWorkers int `json:"max_workers"` +} + +// BotOptsFileJSONCodec encodes and decodes BotOpts using BotOptsFileJSON. +type BotOptsFileJSONCodec struct{} // FromBytes decodes BotOpts from JSON file bytes. -func (codec BotOptsFileJsonCodec) FromBytes(data []byte) (*BotOpts, error) { - fileOpts := new(BotOptsFileJson) +func (codec BotOptsFileJSONCodec) FromBytes(data []byte) (*BotOpts, error) { + fileOpts := new(BotOptsFileJSON) err := json.Unmarshal(data, fileOpts) if err != nil { return nil, err @@ -63,9 +68,10 @@ func (codec BotOptsFileJsonCodec) FromBytes(data []byte) (*BotOpts, error) { LoggerBasePath: fileOpts.Logger.LoggerBasePath, UseRequestLogger: fileOpts.Logger.UseRequestLogger, WriteToFile: fileOpts.Logger.WriteToFile, + LogFormat: fileOpts.Logger.LogFormat, UseTestServer: fileOpts.API.UseTestServer, - APIUrl: fileOpts.API.APIUrl, + APIURL: fileOpts.API.APIURL, RateLimit: fileOpts.API.RateLimit, DropRLOverflow: fileOpts.API.DropRLOverflow, @@ -78,37 +84,26 @@ func (codec BotOptsFileJsonCodec) FromBytes(data []byte) (*BotOpts, error) { } // ToBytes encodes BotOpts into JSON file bytes. -func (codec BotOptsFileJsonCodec) ToBytes(opts *BotOpts) ([]byte, error) { - fileOpts := &BotOptsFileJson{ +func (codec BotOptsFileJSONCodec) ToBytes(opts *BotOpts) ([]byte, error) { + fileOpts := &BotOptsFileJSON{ Version: ConfigVersion, Token: opts.Token, UpdateTypes: opts.UpdateTypes, Debug: opts.Debug, ErrorTemplate: opts.ErrorTemplate, Prefixes: opts.Prefixes, - - Logger: struct { - LoggerBasePath string `json:"base_path"` - UseRequestLogger bool `json:"use_request_logger"` - WriteToFile bool `json:"write_to_file"` - }{ + Logger: botOptsFileJSONLogger{ LoggerBasePath: opts.LoggerBasePath, UseRequestLogger: opts.UseRequestLogger, WriteToFile: opts.WriteToFile, + LogFormat: opts.LogFormat, }, - - API: struct { - UseTestServer bool `json:"use_test_server"` - APIUrl string `json:"url"` - RateLimit int `json:"rate_limit"` - DropRLOverflow bool `json:"drop_overflow"` - }{ + API: botOptsFileJSONAPI{ UseTestServer: opts.UseTestServer, - APIUrl: opts.APIUrl, + APIURL: opts.APIURL, RateLimit: opts.RateLimit, DropRLOverflow: opts.DropRLOverflow, }, - StrictPayloadType: opts.StrictPayloadType, MaxWorkers: opts.MaxWorkers, } @@ -119,10 +114,10 @@ func (codec BotOptsFileJsonCodec) ToBytes(opts *BotOpts) ([]byte, error) { return data, nil } -func (codec BotOptsFileJsonCodec) Load(filename string) (*BotOpts, error) { +func (codec BotOptsFileJSONCodec) Load(filename string) (*BotOpts, error) { 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) } diff --git a/bot_opts_loader_test.go b/bot_opts_loader_test.go index 09922ce..bb8c82d 100644 --- a/bot_opts_loader_test.go +++ b/bot_opts_loader_test.go @@ -10,8 +10,8 @@ import ( "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) -func TestBotOptsFileJsonCodecRoundTrip(t *testing.T) { - codec := BotOptsFileJsonCodec{} +func TestBotOptsFileJSONCodecRoundTrip(t *testing.T) { + codec := BotOptsFileJSONCodec{} want := &BotOpts{ Token: "TOKEN", UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery}, @@ -22,7 +22,7 @@ func TestBotOptsFileJsonCodecRoundTrip(t *testing.T) { UseRequestLogger: true, WriteToFile: true, UseTestServer: true, - APIUrl: "https://api.example.invalid", + APIURL: "https://api.example.invalid", RateLimit: 42, DropRLOverflow: true, StrictPayloadType: true, @@ -62,7 +62,7 @@ func TestLoadBotOptsFileExpandsEnvPlaceholders(t *testing.T) { t.Fatalf("WriteFile returned error: %v", err) } - got, err := LoadBotOptsFile(BotOptsFileJsonCodec{}, filename) + got, err := LoadBotOptsFile(BotOptsFileJSONCodec{}, filename) if err != nil { t.Fatalf("LoadBotOptsFile returned error: %v", err) } @@ -70,8 +70,8 @@ func TestLoadBotOptsFileExpandsEnvPlaceholders(t *testing.T) { if 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" { - t.Fatalf("unexpected api url: got %q want %q", 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") } if got.ErrorTemplate != "Error: %s" { 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) } - if _, err := LoadBotOptsFile(BotOptsFileJsonCodec{}, filename); err == nil { + if _, err := LoadBotOptsFile(BotOptsFileJSONCodec{}, filename); err == nil { t.Fatal("expected decode error, got nil") } } @@ -101,17 +101,17 @@ func TestSaveBotOptsFileWritesEncodedData(t *testing.T) { UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage}, ErrorTemplate: "Error: %s", Prefixes: []string{"/"}, - APIUrl: "https://api.example.invalid", + APIURL: "https://api.example.invalid", RateLimit: 30, MaxWorkers: 32, 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) } - got, err := LoadBotOptsFile(BotOptsFileJsonCodec{}, filename) + got, err := LoadBotOptsFile(BotOptsFileJSONCodec{}, filename) if err != nil { t.Fatalf("LoadBotOptsFile returned error: %v", err) } @@ -132,7 +132,7 @@ func TestLoadBotOptsFileRejectsFutureConfigVersion(t *testing.T) { t.Fatalf("WriteFile returned error: %v", err) } - _, err := LoadBotOptsFile(BotOptsFileJsonCodec{}, filename) + _, err := LoadBotOptsFile(BotOptsFileJSONCodec{}, filename) if !errors.Is(err, ErrConfigVersionMismatch) { t.Fatalf("expected ErrConfigVersionMismatch, got %v", err) } diff --git a/bot_register.go b/bot_register.go index c71fbb1..f12c250 100644 --- a/bot_register.go +++ b/bot_register.go @@ -28,7 +28,7 @@ func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] { } cloned := clonePlugin(p) 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.plugins = append(bot.plugins, cloned) @@ -142,16 +142,16 @@ func (bot *Bot[T]) AddAppDataLoggerWriter(writer AppDataLogger[T]) *Bot[T] { return bot } w := writer(bot.appData) - bot.logger.AddWriter(w) + bot.logger.AddWriters(w) if bot.requestLogger != nil { - bot.requestLogger.AddWriter(w) + bot.requestLogger.AddWriters(w) } for _, l := range bot.managedExtraLoggers() { - l.AddWriter(w) + l.AddWriters(w) } for _, p := range bot.plugins { if p.logger != nil { - p.logger.AddWriter(w) + p.logger.AddWriters(w) } } bot.addTokenReplacer(bot.logger, bot.requestLogger) diff --git a/bot_test.go b/bot_test.go index 1551a8e..7104985 100644 --- a/bot_test.go +++ b/bot_test.go @@ -59,7 +59,7 @@ func TestGetUpdateTypesReturnsCopy(t *testing.T) { } func TestAddPluginsSnapshotsConfiguration(t *testing.T) { - bot := &Bot[NoData]{logger: sneklog.CreateLogger()} + bot := &Bot[NoData]{logger: sneklog.NewLogger()} plugin := NewPlugin[NoData]("demo") 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 { t.Fatalf("unexpected initial payload type: %q", got) } - bot.SetPayloadType(BotPayloadJson) - if got := bot.GetPayloadType(); got != BotPayloadJson { + bot.SetPayloadType(BotPayloadJSON) + if got := bot.GetPayloadType(); got != BotPayloadJSON { t.Fatalf("unexpected updated payload type: %q", got) } bot.SetStrictPayloadType(true) @@ -100,7 +100,7 @@ func TestBotPayloadTypeConfiguration(t *testing.T) { } func TestAddPluginsSkipsNilPlugin(t *testing.T) { - bot := &Bot[NoData]{logger: sneklog.CreateLogger()} + bot := &Bot[NoData]{logger: sneklog.NewLogger()} plugin := NewPlugin[NoData]("demo") bot.AddPlugins(nil, plugin) @@ -166,7 +166,7 @@ func TestInitLoggersAppliesTokenReplacerToFileLoggers(t *testing.T) { t.Fatalf("failed to open api log: %v", err) } 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") 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) } 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.requestLogger.Infoln("request secret-token") @@ -226,7 +226,7 @@ func TestInitLoggersAppliesTokenReplacerToFileLoggers(t *testing.T) { func TestAddPluginsAppliesTokenReplacerToPluginLogger(t *testing.T) { bot := &Bot[NoData]{ token: "secret-token", - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), } defer func() { _ = bot.logger.Close() }() @@ -240,7 +240,7 @@ func TestAddPluginsAppliesTokenReplacerToPluginLogger(t *testing.T) { } 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") data, err := os.ReadFile(logPath) @@ -276,7 +276,7 @@ func TestNextPollRetryDelay(t *testing.T) { } func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsUnset(t *testing.T) { - bot := &Bot[NoData]{logger: sneklog.CreateLogger()} + bot := &Bot[NoData]{logger: sneklog.NewLogger()} called := false bot.AddAppDataLoggerWriter(func(db NoData) sneklog.LoggerWriter { @@ -292,7 +292,7 @@ func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsUnset(t *testing.T) { func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsNil(t *testing.T) { type testDB struct{} - bot := &Bot[*testDB]{logger: sneklog.CreateLogger()} + bot := &Bot[*testDB]{logger: sneklog.NewLogger()} var db *testDB bot.SetAppData(db) @@ -336,13 +336,13 @@ func TestShouldWarnOnValueAppData(t *testing.T) { func TestSetAppDataMarksValueWarningOnce(t *testing.T) { type testDB struct{} - bot := &Bot[testDB]{logger: sneklog.CreateLogger()} + bot := &Bot[testDB]{logger: sneklog.NewLogger()} bot.SetAppData(testDB{}) if !bot.warnedValueData { 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{}) if ptrBot.warnedValueData { 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) { - bot := &Bot[NoData]{logger: sneklog.CreateLogger()} + bot := &Bot[NoData]{logger: sneklog.NewLogger()} observer := testObserver{} if got := bot.GetObserver(); got != nil { @@ -364,7 +364,7 @@ func TestSetObserverAndGetObserver(t *testing.T) { } func TestSetObserverNilClearsObserver(t *testing.T) { - bot := &Bot[NoData]{logger: sneklog.CreateLogger()} + bot := &Bot[NoData]{logger: sneklog.NewLogger()} bot.SetObserver(testObserver{}) if bot.GetObserver() == nil { @@ -382,7 +382,7 @@ func TestRunWithContextRejectsSecondRun(t *testing.T) { cancel() bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, plugins: []Plugin[NoData]{{name: "demo"}}, updateQueue: make(chan *tgapi.Update, 1), @@ -401,9 +401,9 @@ func TestRunWithContextKeepsEnabledRequestLogger(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - requestLogger := sneklog.CreateLogger() + requestLogger := sneklog.NewLogger() bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), requestLogger: requestLogger, useReqLogger: true, prefixes: []string{"/"}, @@ -438,14 +438,14 @@ func TestCloseDoesNotDeleteWebhook(t *testing.T) { api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). - SetAPIUrl("http://example.invalid"). + SetAPIURL("http://example.invalid"). SetHTTPClient(client), ) uploader := tgapi.NewUploader(api) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), - webHookLogger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), + webHookLogger: sneklog.NewLogger(), api: api, uploader: uploader, } @@ -475,7 +475,7 @@ func TestRunWithContextEmitsPollingRetryAndErrorEvents(t *testing.T) { api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). - SetAPIUrl("http://example.invalid"). + SetAPIURL("http://example.invalid"). SetHTTPClient(client), ) defer func() { @@ -483,7 +483,7 @@ func TestRunWithContextEmitsPollingRetryAndErrorEvents(t *testing.T) { }() bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), api: api, prefixes: []string{"/"}, plugins: []Plugin[NoData]{{name: "demo"}}, @@ -515,7 +515,7 @@ func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) { makeBot := func() *Bot[*testDB] { return &Bot[*testDB]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage}, payloadType: BotPayloadBase64, @@ -587,7 +587,7 @@ func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) { } t.Cleanup(bot.finishRun) - bot.SetPayloadType(BotPayloadJson) + bot.SetPayloadType(BotPayloadJSON) if 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) { bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, 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 })}, diff --git a/bot_utils.go b/bot_utils.go index 340db54..90b2c86 100644 --- a/bot_utils.go +++ b/bot_utils.go @@ -76,11 +76,12 @@ func (bot *Bot[T]) initLoggers(opts *BotOpts) { level = sneklog.DEBUG } + format, formatter := opts.LogFormat, opts.LogFormatter if bot.logger == nil { - bot.logger = utils.CreateLogger("BOT", level) + bot.logger = utils.CreateLogger("BOT", level, format, formatter) if opts.WriteToFile { 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 { bot.logger.Errorln(err) } else { @@ -90,10 +91,10 @@ func (bot *Bot[T]) initLoggers(opts *BotOpts) { } if opts.UseRequestLogger && bot.requestLogger == nil { - bot.requestLogger = utils.CreateLogger("REQUESTS", level) + bot.requestLogger = utils.CreateLogger("REQUESTS", level, format, formatter) if opts.WriteToFile { 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 { bot.logger.Errorln(err) } else { diff --git a/bot_webhook.go b/bot_webhook.go index 3198dae..72ea5cb 100644 --- a/bot_webhook.go +++ b/bot_webhook.go @@ -152,7 +152,7 @@ func (bot *Bot[T]) RunWebHookWithContext(ctx context.Context, opts *BotWebHookOp return err } - bot.webHookLogger = utils.CreateLogger("WEBHOOK", bot.GetLoggerLevel()) + bot.webHookLogger = utils.CreateLogger("WEBHOOK", bot.GetLoggerLevel(), bot.logFormat, bot.logFormatter) bot.addTokenReplacer(bot.webHookLogger) if opts.SecretToken == "" { bot.webHookLogger.Warnln("Bot webhook secret token empty. It's VERY recommended to set secret.") diff --git a/bot_webhook_test.go b/bot_webhook_test.go index 328df04..00c95e4 100644 --- a/bot_webhook_test.go +++ b/bot_webhook_test.go @@ -35,7 +35,7 @@ func TestEnqueueUpdateCopiesValue(t *testing.T) { func TestUpdateHandlerEnqueuesUpdate(t *testing.T) { bot := &Bot[NoData]{ updateQueue: make(chan *tgapi.Update, 1), - webHookLogger: sneklog.CreateLogger(), + webHookLogger: sneklog.NewLogger(), } t.Cleanup(func() { _ = bot.webHookLogger.Close() @@ -66,7 +66,7 @@ func TestUpdateHandlerEnqueuesUpdate(t *testing.T) { func TestRunWebhookRuntimeRejectsSecondRun(t *testing.T) { bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), updateQueue: make(chan *tgapi.Update, 1), maxWorkers: 1, } @@ -86,7 +86,7 @@ func TestRunWebhookRuntimeExecutesRunners(t *testing.T) { var calls atomic.Int32 bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), updateQueue: make(chan *tgapi.Update, 1), maxWorkers: 1, runners: []Runner[NoData]{ @@ -185,7 +185,7 @@ func TestValidateWebhookTLSFiles(t *testing.T) { func TestUpdateHandlerRejectsOversizedBody(t *testing.T) { bot := &Bot[NoData]{ updateQueue: make(chan *tgapi.Update, 1), - webHookLogger: sneklog.CreateLogger(), + webHookLogger: sneklog.NewLogger(), } t.Cleanup(func() { _ = bot.webHookLogger.Close() @@ -213,7 +213,7 @@ func TestStatusHandlerRequiresMatchingSecret(t *testing.T) { } api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). - SetAPIUrl("http://example.invalid"). + SetAPIURL("http://example.invalid"). SetHTTPClient(client), ) defer func() { @@ -222,7 +222,7 @@ func TestStatusHandlerRequiresMatchingSecret(t *testing.T) { bot := &Bot[NoData]{ api: api, - webHookLogger: sneklog.CreateLogger(), + webHookLogger: sneklog.NewLogger(), } t.Cleanup(func() { _ = bot.webHookLogger.Close() diff --git a/cmd_generator_test.go b/cmd_generator_test.go index 56157df..ee6bc7d 100644 --- a/cmd_generator_test.go +++ b/cmd_generator_test.go @@ -34,7 +34,7 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) { } api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -51,7 +51,7 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) { bot := &Bot[NoData]{ api: api, - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), plugins: []Plugin[NoData]{*plugin}, } diff --git a/drafts.go b/drafts.go index 033957a..c8968c4 100644 --- a/drafts.go +++ b/drafts.go @@ -9,29 +9,29 @@ import ( ) // Interface for generating unique draft IDs. -type draftIdGenerator interface { +type draftIDGenerator interface { // Next returns the next unique draft ID. 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. -type RandomDraftIdGenerator struct{} +type RandomDraftIDGenerator struct{} // Next returns a random 64-bit unsigned integer. -func (g *RandomDraftIdGenerator) Next() uint64 { +func (g *RandomDraftIDGenerator) Next() 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. -type LinearDraftIdGenerator struct { - lastId atomic.Uint64 +type LinearDraftIDGenerator struct { + lastID atomic.Uint64 } -// Next returns the next linear ID, atomically incremented. -func (g *LinearDraftIdGenerator) Next() uint64 { - return g.lastId.Add(1) +// Next returns the next linear ID, atomically incremented.о +func (g *LinearDraftIDGenerator) Next() uint64 { + return g.lastID.Add(1) } // DraftProvider manages a collection of Drafts and a shared draft ID generator. @@ -41,7 +41,7 @@ type DraftProvider struct { mu sync.RWMutex api *tgapi.API drafts map[uint64]*Draft - generator draftIdGenerator + generator draftIDGenerator } // 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. func NewRandomDraftProvider(api *tgapi.API) *DraftProvider { return &DraftProvider{ - api: api, generator: &RandomDraftIdGenerator{}, + api: api, generator: &RandomDraftIDGenerator{}, 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) // and want to reconstruct drafts after restart. func NewLinearDraftProvider(api *tgapi.API, startValue uint64) *DraftProvider { - g := &LinearDraftIdGenerator{} - g.lastId.Store(startValue) + g := &LinearDraftIDGenerator{} + g.lastID.Store(startValue) return &DraftProvider{ api: api, generator: g, diff --git a/drafts_test.go b/drafts_test.go index 230414b..1ad9de0 100644 --- a/drafts_test.go +++ b/drafts_test.go @@ -13,18 +13,18 @@ func TestDraftFlushRequiresChatID(t *testing.T) { draft := NewRandomDraftProvider(&tgapi.API{}).NewDraft(tgapi.ParseNone) draft.Message = "hello" - if err := draft.Flush(); err != ErrDraftChatIDZero { + if err := draft.Flush(); !errors.Is(err, ErrDraftChatIDZero) { t.Fatalf("expected ErrDraftChatIDZero, got %v", err) } } func TestMsgContextNewDraftWorksWithoutLimiter(t *testing.T) { ctx := &MsgContext{ - Api: &tgapi.API{}, + API: &tgapi.API{}, Msg: &tgapi.Message{ Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, }, - Logger: sneklog.CreateLogger(), + Logger: sneklog.NewLogger(), draftProvider: NewRandomDraftProvider(&tgapi.API{}), } diff --git a/go.mod b/go.mod index 48585f1..b1952c8 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ retract v1.0.0-rc.5 require ( 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 golang.org/x/time v0.15.0 ) diff --git a/go.sum b/go.sum index 8e3936a..c414d3e 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ 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/sneklog/v2 v2.0.1 h1:nDGLsvxbYoSxSk9wrGCmb+tn5jPM+KYlBX+3ciyC2Yo= -git.scuroneko.dev/scuroneko/sneklog/v2 v2.0.1/go.mod h1:q8XnLXzLdGjW0Jtcbh9/+G9WmfD68rsPQvLXEPxvum4= +git.scuroneko.dev/scuroneko/sneklog/v2 v2.3.0 h1:gaPe5azwuDTh48jRB/P2FUgOs7f1ToNr0S+NBizKvY8= +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/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= diff --git a/handler.go b/handler.go index 16046f2..07bc6ef 100644 --- a/handler.go +++ b/handler.go @@ -26,7 +26,7 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) { defer cancel() msgCtx := &MsgContext{ - Update: *u, Api: bot.api, + Update: *u, API: bot.api, Logger: bot.logger, errorTemplate: bot.errorTemplate, l10n: bot.l10n, @@ -113,7 +113,7 @@ func cloneMsgContext(src *MsgContext) *MsgContext { return &cloned } -func encodeJsonPayload(d CallbackData) (string, error) { +func encodeJSONPayload(d CallbackData) (string, error) { b, err := json.Marshal(d) if err != nil { return "", err @@ -121,14 +121,14 @@ func encodeJsonPayload(d CallbackData) (string, error) { return string(b), nil } -func decodeJsonPayload(s string) (CallbackData, error) { +func decodeJSONPayload(s string) (CallbackData, error) { var data CallbackData err := json.Unmarshal([]byte(s), &data) return data, err } func encodeBase64Payload(d CallbackData) (string, error) { - data, err := encodeJsonPayload(d) + data, err := encodeJSONPayload(d) if err != nil { return "", err } @@ -142,7 +142,7 @@ func decodeBase64Payload(s string) (CallbackData, error) { if err != nil { return CallbackData{}, err } - return decodeJsonPayload(string(b)) + return decodeJSONPayload(string(b)) } 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 { return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadBase64) } - data, err = decodeJsonPayload(s) + data, err = decodeJSONPayload(s) if err != nil { return CallbackData{}, "", err } - return data, BotPayloadJson, nil - case BotPayloadJson: - data, err := decodeJsonPayload(s) + return data, BotPayloadJSON, nil + case BotPayloadJSON: + data, err := decodeJSONPayload(s) if err == nil { - return data, BotPayloadJson, nil + return data, BotPayloadJSON, nil } if strict { - return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadJson) + return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadJSON) } data, err = decodeBase64Payload(s) if err != nil { @@ -183,7 +183,7 @@ func (bot *Bot[T]) decodePayload(s string) (CallbackData, error) { return CallbackData{}, err } if decodedType == BotPayloadBase64 && bot.debug && bot.logger != nil { - bot.logger.Debugf("decoded callback payload base64->json: raw=%q json=%s", s, data.ToJson()) + bot.logger.Debugf("decoded callback payload base64->json: raw=%q json=%s", s, data.ToJSON()) } return data, nil } diff --git a/handler_test.go b/handler_test.go index 47406dc..3b76ebb 100644 --- a/handler_test.go +++ b/handler_test.go @@ -55,7 +55,7 @@ func TestCheckPrefixesSkipsEmptyPrefixes(t *testing.T) { } func TestBotMiddlewareReceivesLogger(t *testing.T) { - logger := sneklog.CreateLogger() + logger := sneklog.NewLogger() called := false bot := &Bot[NoData]{ @@ -391,14 +391,14 @@ func TestPrepareUpdateCtxContract(t *testing.T) { if ctx.ChatID != tt.wantChatID { t.Fatalf("unexpected ChatID: got %d want %d", ctx.ChatID, tt.wantChatID) } - if ctx.CallbackQueryId != tt.wantCallbackID { - t.Fatalf("unexpected CallbackQueryId: got %q want %q", ctx.CallbackQueryId, tt.wantCallbackID) + if ctx.CallbackQueryID != tt.wantCallbackID { + t.Fatalf("unexpected CallbackQueryID: got %q want %q", ctx.CallbackQueryID, tt.wantCallbackID) } - if ctx.CallbackMsgId != tt.wantCallbackMsgID { - t.Fatalf("unexpected CallbackMsgId: got %d want %d", ctx.CallbackMsgId, tt.wantCallbackMsgID) + if ctx.CallbackMsgID != tt.wantCallbackMsgID { + t.Fatalf("unexpected CallbackMsgID: got %d want %d", ctx.CallbackMsgID, tt.wantCallbackMsgID) } - if ctx.InlineMsgId != tt.wantInlineMsgID { - t.Fatalf("unexpected InlineMsgId: got %q want %q", ctx.InlineMsgId, tt.wantInlineMsgID) + if ctx.InlineMsgID != tt.wantInlineMsgID { + t.Fatalf("unexpected InlineMsgID: got %q want %q", ctx.InlineMsgID, tt.wantInlineMsgID) } if 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]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), plugins: []Plugin[NoData]{clonePlugin(plugin)}, } @@ -514,7 +514,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), plugins: []Plugin[NoData]{ clonePlugin(first), clonePlugin(second), @@ -543,7 +543,7 @@ func TestHandleUpdateObserverEmitsUpdateErrors(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), plugins: []Plugin[NoData]{clonePlugin(plugin)}, observer: observer, } @@ -607,7 +607,7 @@ func TestHandleMessageFallbackRunsAfterCommandMiss(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, observer: observer, } @@ -658,7 +658,7 @@ func TestHandleMessageFallbackRunsForPlainText(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, } bot.AddPlugins(plugin) @@ -691,7 +691,7 @@ func TestHandleMessageFallbackRespectsMiddleware(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, } bot.AddPlugins(plugin) @@ -726,7 +726,7 @@ func TestHandleMessageFallbackDoesNotRunWhenCommandMatches(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, } bot.AddPlugins(plugin) @@ -771,7 +771,7 @@ func TestHandleChannelPostCommandWithSenderChat(t *testing.T) { }, "ping") bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, plugins: []Plugin[NoData]{clonePlugin(plugin)}, } @@ -808,7 +808,7 @@ func TestCommandHandlerBindArgsEndToEnd(t *testing.T) { ) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, plugins: []Plugin[NoData]{clonePlugin(plugin)}, } @@ -845,17 +845,17 @@ func TestPayloadHandlerBindArgsEndToEnd(t *testing.T) { ) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), - payloadType: BotPayloadJson, + logger: sneklog.NewLogger(), + payloadType: BotPayloadJSON, plugins: []Plugin[NoData]{clonePlugin(plugin)}, } - data, err := encodeJsonPayload(CallbackData{ + data, err := encodeJSONPayload(CallbackData{ Command: "approve", Args: []string{"7", "looks", "good"}, }) if err != nil { - t.Fatalf("encodeJsonPayload returned error: %v", err) + t.Fatalf("encodeJSONPayload returned error: %v", err) } bot.handle(context.Background(), &tgapi.Update{ @@ -898,7 +898,7 @@ func TestHandleEditedMessageStaysOutOfCommandFlow(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, plugins: []Plugin[NoData]{clonePlugin(plugin)}, } @@ -940,7 +940,7 @@ func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, plugins: []Plugin[NoData]{clonePlugin(plugin)}, } @@ -968,14 +968,14 @@ func TestHandleCallbackPopulatesMessageTargets(t *testing.T) { plugin := NewPlugin[NoData]("test") plugin.NewPayload(func(ctx *MsgContext, db NoData) error { called = true - if ctx.CallbackQueryId != "cb-msg" { - t.Fatalf("unexpected CallbackQueryId: %q", ctx.CallbackQueryId) + if ctx.CallbackQueryID != "cb-msg" { + t.Fatalf("unexpected CallbackQueryID: %q", ctx.CallbackQueryID) } - if ctx.CallbackMsgId != 55 { - t.Fatalf("unexpected CallbackMsgId: %d", ctx.CallbackMsgId) + if ctx.CallbackMsgID != 55 { + t.Fatalf("unexpected CallbackMsgID: %d", ctx.CallbackMsgID) } - if ctx.InlineMsgId != "" { - t.Fatalf("did not expect InlineMsgId, got %q", ctx.InlineMsgId) + if ctx.InlineMsgID != "" { + t.Fatalf("did not expect InlineMsgID, got %q", ctx.InlineMsgID) } if ctx.Msg == nil { t.Fatal("expected callback message context") @@ -993,14 +993,14 @@ func TestHandleCallbackPopulatesMessageTargets(t *testing.T) { }, "approve") bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), - payloadType: BotPayloadJson, + logger: sneklog.NewLogger(), + payloadType: BotPayloadJSON, 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 { - t.Fatalf("encodeJsonPayload returned error: %v", err) + t.Fatalf("encodeJSONPayload returned error: %v", err) } bot.handle(context.Background(), &tgapi.Update{ @@ -1027,14 +1027,14 @@ func TestHandleCallbackPopulatesInlineTargets(t *testing.T) { plugin := NewPlugin[NoData]("test") plugin.NewPayload(func(ctx *MsgContext, db NoData) error { called = true - if ctx.CallbackQueryId != "cb-inline" { - t.Fatalf("unexpected CallbackQueryId: %q", ctx.CallbackQueryId) + if ctx.CallbackQueryID != "cb-inline" { + t.Fatalf("unexpected CallbackQueryID: %q", ctx.CallbackQueryID) } - if ctx.CallbackMsgId != 0 { - t.Fatalf("did not expect CallbackMsgId, got %d", ctx.CallbackMsgId) + if ctx.CallbackMsgID != 0 { + t.Fatalf("did not expect CallbackMsgID, got %d", ctx.CallbackMsgID) } - if ctx.InlineMsgId != "inline-55" { - t.Fatalf("unexpected InlineMsgId: %q", ctx.InlineMsgId) + if ctx.InlineMsgID != "inline-55" { + t.Fatalf("unexpected InlineMsgID: %q", ctx.InlineMsgID) } if ctx.Msg != nil { t.Fatalf("did not expect callback chat message context, got %#v", ctx.Msg) @@ -1052,14 +1052,14 @@ func TestHandleCallbackPopulatesInlineTargets(t *testing.T) { }, "inline.approve") bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), - payloadType: BotPayloadJson, + logger: sneklog.NewLogger(), + payloadType: BotPayloadJSON, 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 { - t.Fatalf("encodeJsonPayload returned error: %v", err) + t.Fatalf("encodeJSONPayload returned error: %v", err) } bot.handle(context.Background(), &tgapi.Update{ @@ -1086,15 +1086,15 @@ func TestHandleCallbackObserverEmitsPayloadEvents(t *testing.T) { }, "approve") bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), - payloadType: BotPayloadJson, + logger: sneklog.NewLogger(), + payloadType: BotPayloadJSON, plugins: []Plugin[NoData]{clonePlugin(plugin)}, observer: observer, } - data, err := encodeJsonPayload(CallbackData{Command: "approve", Args: []string{"7"}}) + data, err := encodeJSONPayload(CallbackData{Command: "approve", Args: []string{"7"}}) if err != nil { - t.Fatalf("encodeJsonPayload returned error: %v", err) + t.Fatalf("encodeJSONPayload returned error: %v", err) } bot.handle(context.Background(), &tgapi.Update{ @@ -1137,15 +1137,15 @@ func TestHandleCallbackObserverEmitsPayloadErrors(t *testing.T) { }, "approve") bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), - payloadType: BotPayloadJson, + logger: sneklog.NewLogger(), + payloadType: BotPayloadJSON, plugins: []Plugin[NoData]{clonePlugin(plugin)}, observer: observer, } - data, err := encodeJsonPayload(CallbackData{Command: "approve", Args: []string{"7"}}) + data, err := encodeJSONPayload(CallbackData{Command: "approve", Args: []string{"7"}}) if err != nil { - t.Fatalf("encodeJsonPayload returned error: %v", err) + t.Fatalf("encodeJSONPayload returned error: %v", err) } bot.handle(context.Background(), &tgapi.Update{ @@ -1182,8 +1182,8 @@ func TestHandleCallbackObserverEmitsPayloadErrors(t *testing.T) { func TestHandleCallbackObserverEmitsDecodeErrors(t *testing.T) { observer := &recordingObserver{} bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), - payloadType: BotPayloadJson, + logger: sneklog.NewLogger(), + payloadType: BotPayloadJSON, observer: observer, } @@ -1202,7 +1202,7 @@ func TestHandleCallbackObserverEmitsDecodeErrors(t *testing.T) { }, Logger: bot.logger, ctx: context.Background(), - CallbackQueryId: "cb-bad", + CallbackQueryID: "cb-bad", From: &tgapi.User{ID: 7}, FromID: 7, sceneRuntime: bot, diff --git a/keyboard.go b/keyboard.go index e251ddc..309bef6 100644 --- a/keyboard.go +++ b/keyboard.go @@ -19,10 +19,10 @@ const ( // InlineKbButtonBuilder is a fluent builder for creating a single inline keyboard button. // // 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) -// - SetUrl() — makes button open a URL -// - SetCallbackDataJson() — attaches structured command + args for bot handling +// - SetURL() — makes button open a URL +// - SetCallbackDataJSON() — attaches structured command + args for bot handling // // Call build() to produce the final tgapi.InlineKeyboardButton. // Builder methods are immutable — each returns a copy. @@ -40,9 +40,9 @@ func NewInlineKbButton(text string) InlineKbButtonBuilder { 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. -func (b InlineKbButtonBuilder) SetIconCustomEmojiId(id string) InlineKbButtonBuilder { +func (b InlineKbButtonBuilder) SetIconCustomEmojiID(id string) InlineKbButtonBuilder { b.iconCustomEmojiID = id return b } @@ -55,22 +55,22 @@ func (b InlineKbButtonBuilder) SetStyle(style tgapi.KeyboardButtonStyle) InlineK 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. -func (b InlineKbButtonBuilder) SetUrl(url string) InlineKbButtonBuilder { +func (b InlineKbButtonBuilder) SetURL(url string) InlineKbButtonBuilder { b.url = url 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. // // 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. // -// Example: SetCallbackDataJson("delete_user", 123, "confirm") → {"cmd":"delete_user","args":["123","confirm"]}. -func (b InlineKbButtonBuilder) SetCallbackDataJson(cmd string, args ...any) InlineKbButtonBuilder { - b.callbackData = NewCallbackData(cmd, args...).ToJson() +// Example: SetCallbackDataJSON("delete_user", 123, "confirm") → {"cmd":"delete_user","args":["123","confirm"]}. +func (b InlineKbButtonBuilder) SetCallbackDataJSON(cmd string, args ...any) InlineKbButtonBuilder { + b.callbackData = NewCallbackData(cmd, args...).ToJSON() return b } @@ -107,12 +107,12 @@ type InlineKeyboard struct { 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. // -// Example: NewInlineKeyboardJson(3) creates a keyboard with at most 3 buttons per line. -func NewInlineKeyboardJson(maxRow int) *InlineKeyboard { - return NewInlineKeyboard(BotPayloadJson, maxRow) +// Example: NewInlineKeyboardJSON(3) creates a keyboard with at most 3 buttons per line. +func NewInlineKeyboardJSON(maxRow int) *InlineKeyboard { + return NewInlineKeyboard(BotPayloadJSON, maxRow) } // 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 // 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 { return &InlineKeyboard{ CurrentLine: make(extypes.Slice[tgapi.InlineKeyboardButton], 0), @@ -163,15 +163,15 @@ func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeybo 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. -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}) } -// 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. -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}) } @@ -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 // JSON object: {"cmd":""} to prevent breaking Telegram's API. // // This fallback ensures the bot receives a valid JSON payload even if internal // errors occur — avoiding "invalid callback_data" errors from Telegram. -func (d CallbackData) ToJson() string { - data, err := encodeJsonPayload(d) +func (d CallbackData) ToJSON() string { + data, err := encodeJSONPayload(d) if err != nil { // Fallback: return minimal valid JSON to avoid Telegram API rejection return `{"cmd":""}` @@ -280,14 +280,14 @@ func (d CallbackData) ToBase64() string { } // 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. func (d CallbackData) Encode(t BotPayloadType) string { switch t { case BotPayloadBase64: return d.ToBase64() - case BotPayloadJson: - return d.ToJson() + case BotPayloadJSON: + return d.ToJSON() } return "" } diff --git a/keyboard_test.go b/keyboard_test.go index c9157d6..401c256 100644 --- a/keyboard_test.go +++ b/keyboard_test.go @@ -8,7 +8,7 @@ import ( ) func TestInlineKeyboardWrapsRowsAndEncodesJSONPayloads(t *testing.T) { - kb := NewInlineKeyboardJson(2). + kb := NewInlineKeyboardJSON(2). AddCallbackButton("A", "cmd", 1). AddCallbackButton("B", "cmd", 2). AddCallbackButton("C", "cmd", 3) @@ -33,7 +33,7 @@ func TestInlineKeyboardBuilderPreservesConfiguredButtonFields(t *testing.T) { AddButton( NewInlineKbButton("Docs"). SetStyle(ButtonStylePrimary). - SetUrl("https://example.test"), + SetURL("https://example.test"), ) button := kb.Get().InlineKeyboard[0][0] @@ -46,8 +46,8 @@ func TestInlineKeyboardBuilderPreservesConfiguredButtonFields(t *testing.T) { } func TestInlineKeyboardGetPayloadTypeReturnsLocalOverride(t *testing.T) { - kb := NewInlineKeyboardJson(2) - if got := kb.GetPayloadType(); got != BotPayloadJson { + kb := NewInlineKeyboardJSON(2) + if got := kb.GetPayloadType(); got != BotPayloadJSON { t.Fatalf("unexpected initial payload type: %q", got) } kb.SetPayloadType(BotPayloadBase64) @@ -60,7 +60,7 @@ func TestDecodePayloadAcceptsBase64KeyboardPayloadWhenBotPrefersJSON(t *testing. kb := NewInlineKeyboardBase64(1). 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 { t.Fatalf("decodePayload returned error: %v", err) } @@ -72,7 +72,7 @@ func TestDecodePayloadAcceptsBase64KeyboardPayloadWhenBotPrefersJSON(t *testing. } func TestDecodePayloadAcceptsJSONKeyboardPayloadWhenBotPrefersBase64(t *testing.T) { - kb := NewInlineKeyboardJson(1). + kb := NewInlineKeyboardJSON(1). AddCallbackButton("A", "cmd", 1, "two") got, _, err := decodePayload(BotPayloadBase64, kb.Get().InlineKeyboard[0][0].CallbackData, false) @@ -90,7 +90,7 @@ func TestDecodePayloadStrictRejectsMismatchedType(t *testing.T) { kb := NewInlineKeyboardBase64(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) { t.Fatalf("expected ErrPayloadTypeMismatch, got %v", err) } diff --git a/msg_context.go b/msg_context.go index bf8ce89..841d7ec 100644 --- a/msg_context.go +++ b/msg_context.go @@ -24,14 +24,14 @@ import ( // - 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. // - 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. // // Helper methods on MsgContext may require a message-backed context. For example, // 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 { - Api *tgapi.API + API *tgapi.API Update tgapi.Update // 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. 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. - InlineMsgId string - // CallbackMsgId is the message ID targeted by the current callback query when + InlineMsgID string + // CallbackMsgID is the message ID targeted by the current callback query when // the callback comes from a chat message. - CallbackMsgId int - // CallbackQueryId is the Telegram callback query ID for payload handlers and + CallbackMsgID int + // CallbackQueryID is the Telegram callback query ID for payload handlers and // callback-backed scene handlers. - CallbackQueryId string + CallbackQueryID string // FromID is the normalized sender ID when the current update exposes a user. // It is zero when the update has no user identity. FromID int64 @@ -95,7 +95,7 @@ type AnswerMessage struct { } // Internal helper for text edits with optional keyboard and parse mode. -func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { +func (ctx *MsgContext) edit(messageID int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if err := validateMessageText(text); err != nil { ctx.Logger.Errorln(err) return nil @@ -105,11 +105,11 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard ParseMode: parseMode, } switch { - case messageId > 0 && ctx.Msg != nil: - params.MessageID = messageId + case messageID > 0 && ctx.Msg != nil: + params.MessageID = messageID params.ChatID = ctx.Msg.Chat.ID - case ctx.InlineMsgId != "": - params.InlineMessageID = ctx.InlineMsgId + case ctx.InlineMsgID != "": + params.InlineMessageID = ctx.InlineMsgID default: ctx.Logger.Errorln(ErrEditTargetMissing) return nil @@ -117,12 +117,12 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard if keyboard != nil { params.ReplyMarkup = keyboard.Get() } - msg, _, err := ctx.Api.EditMessageTextWithContext(ctx.Context(), params) + msg, _, err := ctx.API.EditMessageTextWithContext(ctx.Context(), params) if err != nil { ctx.Logger.Errorln(err) return nil } - resultMessageID := messageId + resultMessageID := messageID if msg.MessageID > 0 { resultMessageID = msg.MessageID } @@ -147,11 +147,11 @@ func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage { // Internal helper for editing callback-linked messages. func (ctx *MsgContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { - if ctx.CallbackMsgId == 0 && ctx.InlineMsgId == "" { + if ctx.CallbackMsgID == 0 && ctx.InlineMsgID == "" { ctx.Logger.Errorln(ErrCallbackMessageMissing) 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). @@ -179,7 +179,7 @@ func (ctx *MsgContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyb } // Internal helper for media-caption edits. -func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { +func (ctx *MsgContext) editPhotoText(messageID int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if err := validateCaptionText(text); err != nil { ctx.Logger.Errorln(err) return nil @@ -189,11 +189,11 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo ParseMode: parseMode, } switch { - case messageId > 0 && ctx.Msg != nil: + case messageID > 0 && ctx.Msg != nil: params.ChatID = ctx.Msg.Chat.ID - params.MessageID = messageId - case ctx.InlineMsgId != "": - params.InlineMessageID = ctx.InlineMsgId + params.MessageID = messageID + case ctx.InlineMsgID != "": + params.InlineMessageID = ctx.InlineMsgID default: ctx.Logger.Errorln(ErrEditTargetMissing) return nil @@ -202,12 +202,12 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo params.ReplyMarkup = kb.Get() } - msg, _, err := ctx.Api.EditMessageCaptionWithContext(ctx.Context(), params) + msg, _, err := ctx.API.EditMessageCaptionWithContext(ctx.Context(), params) if err != nil { ctx.Logger.Errorln(err) return nil } - resultMessageID := messageId + resultMessageID := messageID if msg.MessageID > 0 { resultMessageID = msg.MessageID } @@ -265,7 +265,7 @@ func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode t 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 { ctx.Logger.Errorln(err) 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. -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 { ctx.Logger.Errorln(ErrMessageContextNil) return nil @@ -384,7 +384,7 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, par ChatID: ctx.Msg.Chat.ID, Caption: text, ParseMode: parseMode, - Photo: photoId, + Photo: photoID, } if kb != nil { 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) } - msg, err := ctx.Api.SendPhotoWithContext(ctx.Context(), params) + msg, err := ctx.API.SendPhotoWithContext(ctx.Context(), params) if err != nil { ctx.Logger.Errorln(err) return nil @@ -407,44 +407,44 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, par } // AnswerPhoto sends a photo with plain text caption. -func (ctx *MsgContext) AnswerPhoto(photoId, text string) *AnswerMessage { - return ctx.answerPhoto(photoId, text, nil, tgapi.ParseNone) +func (ctx *MsgContext) AnswerPhoto(photoID, text string) *AnswerMessage { + return ctx.answerPhoto(photoID, text, nil, tgapi.ParseNone) } // AnswerPhotoMarkdown sends a photo with MarkdownV2 caption. // // ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. -func (ctx *MsgContext) AnswerPhotoMarkdown(photoId, text string) *AnswerMessage { - return ctx.answerPhoto(photoId, text, nil, tgapi.ParseMDV2) +func (ctx *MsgContext) AnswerPhotoMarkdown(photoID, text string) *AnswerMessage { + return ctx.answerPhoto(photoID, text, nil, tgapi.ParseMDV2) } // AnswerPhotoKeyboard sends a photo with caption and inline keyboard (plain text). -func (ctx *MsgContext) AnswerPhotoKeyboard(photoId, text string, kb *InlineKeyboard) *AnswerMessage { - return ctx.answerPhoto(photoId, text, kb, tgapi.ParseNone) +func (ctx *MsgContext) AnswerPhotoKeyboard(photoID, text string, kb *InlineKeyboard) *AnswerMessage { + return ctx.answerPhoto(photoID, text, kb, tgapi.ParseNone) } // AnswerPhotoKeyboardMarkdown sends a photo with caption and inline keyboard using MarkdownV2. // // ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. -func (ctx *MsgContext) AnswerPhotoKeyboardMarkdown(photoId, text string, kb *InlineKeyboard) *AnswerMessage { - return ctx.answerPhoto(photoId, text, kb, tgapi.ParseMDV2) +func (ctx *MsgContext) AnswerPhotoKeyboardMarkdown(photoID, text string, kb *InlineKeyboard) *AnswerMessage { + return ctx.answerPhoto(photoID, text, kb, tgapi.ParseMDV2) } // AnswerPhotof formats a string and sends it as a photo caption (plain text). -func (ctx *MsgContext) AnswerPhotof(photoId, template string, args ...any) *AnswerMessage { - return ctx.answerPhoto(photoId, fmt.Sprintf(template, args...), nil, tgapi.ParseNone) +func (ctx *MsgContext) AnswerPhotof(photoID, template string, args ...any) *AnswerMessage { + return ctx.answerPhoto(photoID, fmt.Sprintf(template, args...), nil, tgapi.ParseNone) } // 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. -func (ctx *MsgContext) AnswerPhotofMarkdown(photoId, template string, args ...any) *AnswerMessage { - return ctx.answerPhoto(photoId, fmt.Sprintf(template, args...), nil, tgapi.ParseMDV2) +func (ctx *MsgContext) AnswerPhotofMarkdown(photoID, template string, args ...any) *AnswerMessage { + return ctx.answerPhoto(photoID, fmt.Sprintf(template, args...), nil, tgapi.ParseMDV2) } // Internal helper that deletes a message by ID. -func (ctx *MsgContext) delete(messageId int) { - if messageId == 0 { +func (ctx *MsgContext) delete(messageID int) { + if messageID == 0 { ctx.Logger.Errorln(ErrMessageIDZero) return } @@ -452,9 +452,9 @@ func (ctx *MsgContext) delete(messageId int) { ctx.Logger.Errorln(ErrMessageContextNil) return } - _, err := ctx.Api.DeleteMessageWithContext(ctx.Context(), tgapi.DeleteMessage{ + _, err := ctx.API.DeleteMessageWithContext(ctx.Context(), tgapi.DeleteMessage{ ChatID: ctx.Msg.Chat.ID, - MessageID: messageId, + MessageID: messageID, }) if err != nil { 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. func (ctx *MsgContext) CallbackDelete() { - if ctx.CallbackMsgId == 0 { + if ctx.CallbackMsgID == 0 { ctx.Logger.Errorln(ErrCallbackMessageMissing) return } - ctx.delete(ctx.CallbackMsgId) + ctx.delete(ctx.CallbackMsgID) } // Internal helper that answers a callback query with optional text, alert, or URL. func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) { - if len(ctx.CallbackQueryId) == 0 { + if len(ctx.CallbackQueryID) == 0 { return } - _, err := ctx.Api.AnswerCallbackQueryWithContext(ctx.Context(), tgapi.AnswerCallbackQuery{ - CallbackQueryID: ctx.CallbackQueryId, + _, err := ctx.API.AnswerCallbackQueryWithContext(ctx.Context(), tgapi.AnswerCallbackQuery{ + CallbackQueryID: ctx.CallbackQueryID, Text: text, ShowAlert: showAlert, URL: url, }) 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. func (ctx *MsgContext) AnswerCbQueryAlert(text string) { ctx.answerCallbackQuery("", text, true) } -// AnswerCbQueryUrl answers the callback query with a URL redirect. -func (ctx *MsgContext) AnswerCbQueryUrl(u string) { ctx.answerCallbackQuery(u, "", false) } +// AnswerCbQueryURL answers the callback query with a URL redirect. +func (ctx *MsgContext) AnswerCbQueryURL(u string) { ctx.answerCallbackQuery(u, "", false) } // SendAction sends a chat action (typing, uploading_photo, etc.) to indicate bot activity. func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) { @@ -511,7 +511,7 @@ func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) { if ctx.Msg.MessageThreadID > 0 { params.MessageThreadID = ctx.Msg.MessageThreadID } - _, err := ctx.Api.SendChatActionWithContext(ctx.Context(), params) + _, err := ctx.API.SendChatActionWithContext(ctx.Context(), params) if err != nil { ctx.Logger.Errorln(err) } @@ -528,7 +528,7 @@ func (ctx *MsgContext) error(err error) { } text := fmt.Sprintf(ctx.errorTemplate, err.Error()) - if ctx.CallbackQueryId != "" { + if ctx.CallbackQueryID != "" { ctx.answerCallbackQuery("", text, false) } else { ctx.answer(text, nil, tgapi.ParseNone) @@ -543,7 +543,7 @@ func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft { ctx.Logger.Errorln(ErrMessageContextNil) return nil } - if ctx.Api == nil { + if ctx.API == nil { ctx.Logger.Errorln(ErrAPIIsNil) return nil } @@ -552,10 +552,10 @@ func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft { return nil } - if ctx.Api.Limiter != nil { + if ctx.API.Limiter != nil { c, cancel := context.WithTimeout(ctx.Context(), 5*time.Second) 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) return nil } diff --git a/msg_context_test.go b/msg_context_test.go index a6dee1e..79b43ef 100644 --- a/msg_context_test.go +++ b/msg_context_test.go @@ -35,7 +35,7 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) { api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -45,12 +45,12 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) { }() ctx := &MsgContext{ - Api: api, + API: api, Msg: &tgapi.Message{ Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, DirectMessageTopic: &tgapi.DirectMessageTopic{TopicID: 77}, }, - Logger: sneklog.CreateLogger(), + Logger: sneklog.NewLogger(), } answer := ctx.AnswerPhoto("photo-id", "caption") @@ -190,7 +190,7 @@ func TestErrorDefaultRemainsUserVisibleForMessageFlow(t *testing.T) { api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -200,9 +200,9 @@ func TestErrorDefaultRemainsUserVisibleForMessageFlow(t *testing.T) { }() ctx := &MsgContext{ - Api: api, + API: api, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, - Logger: sneklog.CreateLogger(), + Logger: sneklog.NewLogger(), errorTemplate: "Error: %s", } @@ -226,7 +226,7 @@ func TestErrorInternalSkipsUserReplyForMessageFlow(t *testing.T) { api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -236,9 +236,9 @@ func TestErrorInternalSkipsUserReplyForMessageFlow(t *testing.T) { }() ctx := &MsgContext{ - Api: api, + API: api, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, - Logger: sneklog.CreateLogger(), + Logger: sneklog.NewLogger(), errorTemplate: "Error: %s", } @@ -255,7 +255,7 @@ func TestErrorInternalSkipsCallbackAnswer(t *testing.T) { api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -265,10 +265,10 @@ func TestErrorInternalSkipsCallbackAnswer(t *testing.T) { }() ctx := &MsgContext{ - Api: api, - Logger: sneklog.CreateLogger(), + API: api, + Logger: sneklog.NewLogger(), errorTemplate: "%s", - CallbackQueryId: "cb-1", + CallbackQueryID: "cb-1", } ctx.error(AsInternalError(errors.New("boom"))) @@ -298,7 +298,7 @@ func TestErrorUserVisibleAnswersCallback(t *testing.T) { api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -308,10 +308,10 @@ func TestErrorUserVisibleAnswersCallback(t *testing.T) { }() ctx := &MsgContext{ - Api: api, - Logger: sneklog.CreateLogger(), + API: api, + Logger: sneklog.NewLogger(), errorTemplate: "Oops: %s", - CallbackQueryId: "cb-1", + CallbackQueryID: "cb-1", } ctx.error(AsUserError(errors.New("boom"))) @@ -327,7 +327,7 @@ func TestErrorUserVisibleAnswersCallback(t *testing.T) { func TestAnswerRejectsEmptyMessage(t *testing.T) { ctx := &MsgContext{ Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, - Logger: sneklog.CreateLogger(), + Logger: sneklog.NewLogger(), } if answer := ctx.Answer(""); answer != nil { @@ -345,7 +345,7 @@ func TestAnswerRejectsLongMessageWithoutSendingRequest(t *testing.T) { api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -355,9 +355,9 @@ func TestAnswerRejectsLongMessageWithoutSendingRequest(t *testing.T) { }() ctx := &MsgContext{ - Api: api, + API: api, 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 { @@ -429,7 +429,7 @@ func TestAnswerLongSplitsRequestsAndAttachesKeyboardToLastChunk(t *testing.T) { api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -439,11 +439,11 @@ func TestAnswerLongSplitsRequestsAndAttachesKeyboardToLastChunk(t *testing.T) { }() ctx := &MsgContext{ - Api: api, + API: api, 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) messages := ctx.KeyboardLong(text, kb) diff --git a/plugins.go b/plugins.go index 6190bf4..ac001c0 100644 --- a/plugins.go +++ b/plugins.go @@ -264,7 +264,7 @@ func (p *Plugin[T]) AddUpdateHandler(t tgapi.UpdateType, handler CommandExecutor switch t { case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost, tgapi.UpdateTypeCallbackQuery: 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.Close() return p diff --git a/policy.go b/policy.go index c331a99..9ea88a7 100644 --- a/policy.go +++ b/policy.go @@ -143,7 +143,7 @@ func RequireChatAdmin[T AppData]() Policy[T] { 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, UserID: ctx.FromID, }) @@ -166,7 +166,7 @@ func RequireChatCreator[T AppData]() Policy[T] { 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, UserID: ctx.FromID, }) @@ -189,12 +189,12 @@ func RequireBotAdmin[T AppData]() Policy[T] { return AsInternalError(errors.New("bot-admin policy requires message chat context")) } - bot, err := ctx.Api.GetMe() + bot, err := ctx.API.GetMe() if err != nil { 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, UserID: bot.ID, }) diff --git a/policy_test.go b/policy_test.go index d0925d4..d48d508 100644 --- a/policy_test.go +++ b/policy_test.go @@ -37,7 +37,7 @@ func TestRequirePolicyStopsExecutionOnDeniedPolicy(t *testing.T) { api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -47,9 +47,9 @@ func TestRequirePolicyStopsExecutionOnDeniedPolicy(t *testing.T) { }() ctx := &MsgContext{ - Api: api, + API: api, Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}}, - Logger: sneklog.CreateLogger(), + Logger: sneklog.NewLogger(), errorTemplate: "Error: %s", } @@ -73,7 +73,7 @@ func TestRequirePrivateChatAllowsPrivateChat(t *testing.T) { Msg: &tgapi.Message{ Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}, }, - Logger: sneklog.CreateLogger(), + Logger: sneklog.NewLogger(), } if err := RequirePrivateChat[NoData]()(ctx, NoData{}); err != nil { @@ -86,7 +86,7 @@ func TestRequirePrivateChatDeniesNonPrivateChat(t *testing.T) { Msg: &tgapi.Message{ Chat: &tgapi.Chat{ID: -100, Type: tgapi.ChatTypeSupergroup}, }, - Logger: sneklog.CreateLogger(), + Logger: sneklog.NewLogger(), } err := RequirePrivateChat[NoData]()(ctx, NoData{}) @@ -127,7 +127,7 @@ func TestRequireChatAdminUsesNormalizedIDs(t *testing.T) { api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -137,10 +137,10 @@ func TestRequireChatAdminUsesNormalizedIDs(t *testing.T) { }() ctx := &MsgContext{ - Api: api, + API: api, ChatID: -2001, FromID: 55, - Logger: sneklog.CreateLogger(), + Logger: sneklog.NewLogger(), } 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) { 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 }, ) - 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) } } @@ -192,7 +192,7 @@ func TestAnyPolicyReturnsInternalErrorWhenNonePass(t *testing.T) { 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) { 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")) }, ) - err := policy(&MsgContext{Logger: sneklog.CreateLogger()}, NoData{}) + err := policy(&MsgContext{Logger: sneklog.NewLogger()}, NoData{}) if !errors.Is(err, first) { 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 { 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) } @@ -223,7 +223,7 @@ func TestNotPolicyInvertsUserDenyButPreservesInternalErrors(t *testing.T) { preserve := NotPolicy(func(ctx *MsgContext, data NoData) error { return internal }) - err := preserve(&MsgContext{Logger: sneklog.CreateLogger()}, NoData{}) + err := preserve(&MsgContext{Logger: sneklog.NewLogger()}, NoData{}) if !errors.Is(err, internal) { 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) { observer := &recordingObserver{} ctx := &MsgContext{ - Logger: sneklog.CreateLogger(), + Logger: sneklog.NewLogger(), ctx: context.Background(), observer: observer, FromID: 10, @@ -258,7 +258,7 @@ func TestRequirePolicyEmitsObserverEvents(t *testing.T) { t.Run("deny", func(t *testing.T) { observer := &recordingObserver{} ctx := &MsgContext{ - Logger: sneklog.CreateLogger(), + Logger: sneklog.NewLogger(), ctx: context.Background(), observer: observer, errorTemplate: "%s", diff --git a/runners_test.go b/runners_test.go index 351150a..3d3f5b8 100644 --- a/runners_test.go +++ b/runners_test.go @@ -17,7 +17,7 @@ type runnerObserver struct { func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) { var calls atomic.Int32 bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), runners: []Runner[NoData]{ NewRunner("sync-once", func(*Bot[NoData]) error { calls.Add(1) @@ -39,7 +39,7 @@ func TestExecRunnersStopsBackgroundRunnerOnCancel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), runners: []Runner[NoData]{ NewRunner("background", func(*Bot[NoData]) error { if calls.Add(1) == 1 { @@ -71,7 +71,7 @@ func TestExecRunnersEmitObserverEvents(t *testing.T) { wantErr := errors.New("runner failed") bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), observer: observer, runners: []Runner[NoData]{ NewRunner("sync-once", func(*Bot[NoData]) error { diff --git a/scene_handler.go b/scene_handler.go index 161398d..b4c0c22 100644 --- a/scene_handler.go +++ b/scene_handler.go @@ -218,12 +218,14 @@ func (bot *Bot[T]) emitSceneTransition(ctx *SceneContext, scene *Scene[T], from return } - to := from + var to string switch result.Action { case SceneActionNext: to = result.Next case SceneActionExit: to = "" + default: + to = from } bot.safeEmitEvent(ctx.Context(), SceneTransitionEvent{ diff --git a/scene_test.go b/scene_test.go index a465386..d904431 100644 --- a/scene_test.go +++ b/scene_test.go @@ -15,15 +15,15 @@ type failingSessionStore struct { deleteErr error } -func (s failingSessionStore) Get(key string) (SceneSession, error) { +func (s failingSessionStore) Get(string) (SceneSession, error) { return SceneSession{}, s.getErr } -func (s failingSessionStore) Set(key string, session SceneSession) error { +func (s failingSessionStore) Set(string, SceneSession) error { return s.setErr } -func (s failingSessionStore) Delete(key string) error { +func (s failingSessionStore) Delete(string) error { return s.deleteErr } @@ -56,7 +56,7 @@ func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -149,7 +149,7 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { plugin.NewScene("signup") bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, } @@ -172,7 +172,7 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) { plugin.NewScene("signup").SetEntry("start") bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, } @@ -231,7 +231,7 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -279,7 +279,7 @@ func TestSceneCommandObserverEmitsLifecycleEvents(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -331,7 +331,7 @@ func TestSceneStepObserverEmitsLifecycleEvents(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -386,7 +386,7 @@ func TestSceneMessageObserverEmitsLifecycleEvents(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -453,8 +453,8 @@ func TestScenePayloadHandlerRunsBeforeStep(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), - payloadType: BotPayloadJson, + logger: sneklog.NewLogger(), + payloadType: BotPayloadJSON, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, } @@ -469,9 +469,9 @@ func TestScenePayloadHandlerRunsBeforeStep(t *testing.T) { 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 { - t.Fatalf("encodeJsonPayload returned error: %v", err) + t.Fatalf("encodeJSONPayload returned error: %v", err) } bot.handle(context.Background(), &tgapi.Update{ @@ -509,8 +509,8 @@ func TestScenePayloadObserverEmitsLifecycleEvents(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), - payloadType: BotPayloadJson, + logger: sneklog.NewLogger(), + payloadType: BotPayloadJSON, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, observer: observer, @@ -526,9 +526,9 @@ func TestScenePayloadObserverEmitsLifecycleEvents(t *testing.T) { t.Fatalf("EnterScene returned error: %v", err) } - data, err := encodeJsonPayload(CallbackData{Command: "confirm"}) + data, err := encodeJSONPayload(CallbackData{Command: "confirm"}) if err != nil { - t.Fatalf("encodeJsonPayload returned error: %v", err) + t.Fatalf("encodeJSONPayload returned error: %v", err) } bot.handle(context.Background(), &tgapi.Update{ @@ -572,8 +572,8 @@ func TestSceneUnmatchedPayloadFallsThroughWithoutRunningStep(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), - payloadType: BotPayloadJson, + logger: sneklog.NewLogger(), + payloadType: BotPayloadJSON, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, } @@ -596,9 +596,9 @@ func TestSceneUnmatchedPayloadFallsThroughWithoutRunningStep(t *testing.T) { t.Fatal("expected scene key to be built") } - data, err := encodeJsonPayload(CallbackData{Command: "ping"}) + data, err := encodeJSONPayload(CallbackData{Command: "ping"}) if err != nil { - t.Fatalf("encodeJsonPayload returned error: %v", err) + t.Fatalf("encodeJSONPayload returned error: %v", err) } bot.handle(context.Background(), &tgapi.Update{ @@ -648,7 +648,7 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -724,7 +724,7 @@ func TestSceneUnmatchedCommandFallsThroughWithoutRunningStep(t *testing.T) { }, "ping") bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -793,7 +793,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) { }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), prefixes: []string{"/"}, sessionStore: NewMemorySessionStore(), sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, @@ -838,7 +838,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) { func TestFindSceneSessionSupportsUserScopeWithoutMessage(t *testing.T) { bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), sessionStore: NewMemorySessionStore(), 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) { bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), sessionStore: failingSessionStore{getErr: getErr}, sceneScopePriority: []SceneScope{SceneScopeUser}, } @@ -881,7 +881,7 @@ func TestSceneStoreErrorsPropagate(t *testing.T) { return ctx.Stay(), nil }) bot := &Bot[NoData]{ - logger: sneklog.CreateLogger(), + logger: sneklog.NewLogger(), sessionStore: failingSessionStore{setErr: setErr}, sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser}, } diff --git a/tgapi/api.go b/tgapi/api.go index 9235ce4..ee09625 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -19,7 +19,10 @@ type APIOpts struct { token string client *http.Client useTestServer bool - apiUrl string + apiURL string + + logFormat utils.LogFormat + logFormatter *sneklog.Formatter limiter *utils.RateLimiter dropOverflowLimit bool @@ -32,7 +35,7 @@ func NewAPIOpts(token string) *APIOpts { token: token, client: nil, 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 } -// SetAPIUrl overrides the default Telegram API URL. +// SetAPIURL overrides the default Telegram API URL. // Useful for self-hosted bots or proxies. -func (opts *APIOpts) SetAPIUrl(apiUrl string) *APIOpts { - if apiUrl != "" { - opts.apiUrl = apiUrl +func (opts *APIOpts) SetAPIURL(apiURL string) *APIOpts { + if apiURL != "" { + opts.apiURL = apiURL } 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. // Recommended: use utils.NewRateLimiter() for correct per-chat and global throttling. func (opts *APIOpts) SetLimiter(limiter *utils.RateLimiter) *APIOpts { @@ -87,7 +99,10 @@ type API struct { client *http.Client logger *sneklog.Logger useTestServer bool - apiUrl string + apiURL string + + logFormat utils.LogFormat + logFormatter *sneklog.Formatter pool *workerPool Limiter *utils.RateLimiter @@ -97,12 +112,13 @@ type API struct { // NewAPI creates a new API client from options. // Always call Close() when done to release resources. func NewAPI(opts *APIOpts) *API { - l := utils.CreateLogger("API", utils.GetLoggerLevel()) if opts == nil { - l.Errorln("Set API options") - _ = l.Close() return nil } + logger := utils.CreateLogger( + "API", utils.GetLoggerLevel(), + opts.logFormat, opts.logFormatter, + ) client := opts.client if client == nil { @@ -113,11 +129,15 @@ func NewAPI(opts *APIOpts) *API { pool.start() return &API{ - token: opts.token, - client: client, - logger: l, - useTestServer: opts.useTestServer, - apiUrl: opts.apiUrl, + token: opts.token, + client: client, + logger: logger, + useTestServer: opts.useTestServer, + apiURL: opts.apiURL, + + logFormat: opts.logFormat, + logFormatter: opts.logFormatter, + pool: pool, Limiter: opts.limiter, dropOverflowLimit: opts.dropOverflowLimit, @@ -147,9 +167,9 @@ type ResponseParameters struct { 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. -type ApiResponse[R any] struct { +type TelegramResponse[R any] struct { Ok bool `json:"ok"` Description string `json:"description,omitempty"` Result R `json:"result,omitempty"` @@ -166,7 +186,7 @@ type ApiResponse[R any] struct { type TelegramRequest[R, P any] struct { method string params P - chatId int64 + chatID int64 } // 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. // The chat ID is used for per-chat rate limiting. -func NewRequestWithChatID[R, P any](method string, params P, chatId int64) TelegramRequest[R, P] { - return TelegramRequest[R, P]{method, params, chatId} +func NewRequestWithChatID[R, P any](method string, params P, chatID int64) TelegramRequest[R, P] { + return TelegramRequest[R, P]{method, params, chatID} } 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 { 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) if err != nil { 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 { // Apply rate limiting before making the request 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 } } @@ -235,12 +255,12 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro // Handle rate limiting (429) if response.ErrorCode == 429 && response.Parameters != nil && response.Parameters.RetryAfter != nil { 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 if api.Limiter != nil { - if r.chatId > 0 { - api.Limiter.SetChatLock(r.chatId, after) + if r.chatID > 0 { + api.Limiter.SetChatLock(r.chatID, after) } else { 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. -func parseBody[R any](data []byte) (ApiResponse[R], error) { - var resp ApiResponse[R] +func parseBody[R any](data []byte) (TelegramResponse[R], error) { + var resp TelegramResponse[R] err := json.Unmarshal(data, &resp) if err != nil { return resp, fmt.Errorf("failed to unmarshal JSON: %w", err) diff --git a/tgapi/api_test.go b/tgapi/api_test.go index 5873223..c7432aa 100644 --- a/tgapi/api_test.go +++ b/tgapi/api_test.go @@ -40,7 +40,7 @@ func TestAPILeavesAcceptEncodingToHTTPTransport(t *testing.T) { api := NewAPI( NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -77,7 +77,7 @@ func TestAPICloseClosesIdleConnections(t *testing.T) { api := NewAPI( NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(&http.Client{Transport: transport}), ) diff --git a/tgapi/errors.go b/tgapi/errors.go index 6c142ae..bbf1f04 100644 --- a/tgapi/errors.go +++ b/tgapi/errors.go @@ -2,9 +2,6 @@ package tgapi 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. var ErrPoolUnexpected = errors.New("unexpected response from pool") diff --git a/tgapi/messages_methods.go b/tgapi/messages_methods.go index bf1b786..7584e12 100644 --- a/tgapi/messages_methods.go +++ b/tgapi/messages_methods.go @@ -472,7 +472,7 @@ func (api *API) SendChatActionWithContext(ctx context.Context, params SendChatAc // See https://core.telegram.org/bots/api#setmessagereaction type SetMessageReaction struct { ChatID int64 `json:"chat_id"` - MessageId int `json:"message_id"` + MessageID int `json:"message_id"` Reaction []ReactionType `json:"reaction"` IsBig bool `json:"is_big,omitempty"` } diff --git a/tgapi/messages_types.go b/tgapi/messages_types.go index 964a3f5..4d12516 100644 --- a/tgapi/messages_types.go +++ b/tgapi/messages_types.go @@ -103,7 +103,7 @@ type Message struct { SenderBusinessBot *User `json:"sender_business_bot,omitempty"` SenderTag string `json:"sender_tag,omitempty"` Date int `json:"date"` - BusinessConnectionId string `json:"business_connection_id,omitempty"` + BusinessConnectionID string `json:"business_connection_id,omitempty"` Chat *Chat `json:"chat,omitempty"` ForwardOrigin *MessageOrigin `json:"forward_origin,omitempty"` @@ -121,7 +121,7 @@ type Message struct { HasProtectedContent bool `json:"has_protected_content,omitempty"` IsFromOffline bool `json:"is_from_offline,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"` PaidStarCount int `json:"paid_star_count,omitempty"` @@ -316,8 +316,8 @@ const ( MessageEntityCashtag MessageEntityType = "cashtag" // MessageEntityBotCommand identifies a bot command entity. MessageEntityBotCommand MessageEntityType = "bot_command" - // MessageEntityUrl identifies a URL entity. - MessageEntityUrl MessageEntityType = "url" + // MessageEntityURL identifies a URL entity. + MessageEntityURL MessageEntityType = "url" // MessageEntityEmail identifies an email entity. MessageEntityEmail MessageEntityType = "email" // MessageEntityPhoneNumber identifies a phone number entity. @@ -537,7 +537,7 @@ const ( // ChatActionUploadVideoNote tells Telegram the bot is uploading a video note. ChatActionUploadVideoNote ChatActionType = "upload_video_note" // ChatActionUploadVideoNone is a deprecated alias for ChatActionUploadVideoNote. - ChatActionUploadVideoNone ChatActionType = ChatActionUploadVideoNote + ChatActionUploadVideoNone = ChatActionUploadVideoNote ) // MessageReactionUpdated represents a change of a reaction on a message. diff --git a/tgapi/methods.go b/tgapi/methods.go index 1d06720..3e1d888 100644 --- a/tgapi/methods.go +++ b/tgapi/methods.go @@ -256,7 +256,7 @@ func (api *API) openFileByLink(ctx context.Context, link string) (io.ReadCloser, if api.useTestServer { 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) if err != nil { diff --git a/tgapi/methods_test.go b/tgapi/methods_test.go index 5769fd0..2b794fc 100644 --- a/tgapi/methods_test.go +++ b/tgapi/methods_test.go @@ -23,7 +23,7 @@ func TestGetFileByLinkUsesConfiguredAPIURL(t *testing.T) { api := NewAPI( NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -47,7 +47,7 @@ func TestGetFileByLinkUsesConfiguredAPIURL(t *testing.T) { func TestOpenFileByLinkStreamsResponseBody(t *testing.T) { api := NewAPI( NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(&http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { return &http.Response{ @@ -94,7 +94,7 @@ func TestGetFileByLinkReturnsHTTPStatusError(t *testing.T) { api := NewAPI( NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -131,7 +131,7 @@ func TestGetUpdatesOmitsAllowedUpdatesWhenEmpty(t *testing.T) { api := NewAPI( NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { @@ -174,7 +174,7 @@ func TestSetChatMenuButtonSendsStructuredMenuButton(t *testing.T) { api := NewAPI( NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index 04074d0..13694af 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -70,12 +70,13 @@ type Uploader struct { // NewUploader creates a multipart uploader bound to an API client. func NewUploader(api *API) *Uploader { - logger := utils.CreateLogger("UPLOADER", utils.GetLoggerLevel()) if api == nil { - logger.Errorln("api is nil") - _ = logger.Close() return nil } + logger := utils.CreateLogger( + "UPLOADER", utils.GetLoggerLevel(), + api.logFormat, api.logFormatter, + ) return &Uploader{api, logger} } @@ -97,18 +98,18 @@ type UploaderRequest[R, P any] struct { method string files []UploaderFile params P - chatId int64 + chatID int64 } // 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] { - 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. // 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] { - return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: chatId} +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} } 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 { 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 { 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 } } @@ -161,10 +162,10 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, if !response.Ok { if response.ErrorCode == 429 && response.Parameters != nil && response.Parameters.RetryAfter != nil { 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 r.chatId > 0 { - up.api.Limiter.SetChatLock(r.chatId, after) + if r.chatID > 0 { + up.api.Limiter.SetChatLock(r.chatID, after) } else { up.api.Limiter.SetGlobalLock(after) } diff --git a/tgapi/uploader_api_test.go b/tgapi/uploader_api_test.go index 88f328f..f5a2915 100644 --- a/tgapi/uploader_api_test.go +++ b/tgapi/uploader_api_test.go @@ -40,7 +40,7 @@ func TestUploaderEncodesJSONFieldsAndLeavesAcceptEncodingToHTTPTransport(t *test api := NewAPI( NewAPIOpts("token"). - SetAPIUrl("https://example.test"). + SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { diff --git a/update_context.go b/update_context.go index 150e7ac..cb2167b 100644 --- a/update_context.go +++ b/update_context.go @@ -142,15 +142,15 @@ func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MsgContext) { if u.CallbackQuery != nil { if u.CallbackQuery.Message != nil { ctx.Msg = u.CallbackQuery.Message - ctx.CallbackMsgId = u.CallbackQuery.Message.MessageID + ctx.CallbackMsgID = u.CallbackQuery.Message.MessageID if u.CallbackQuery.Message.Chat != nil { chat = u.CallbackQuery.Message.Chat } } 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 } case tgapi.UpdateTypeShippingQuery: diff --git a/utils/utils.go b/utils/utils.go index d9a554e..cc94244 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -6,6 +6,13 @@ import ( "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. func GetLoggerLevel() sneklog.LogLevel { level := sneklog.FATAL @@ -17,12 +24,28 @@ func GetLoggerLevel() sneklog.LogLevel { // CreateLogger creates a logger with the shared default policy: // JSON stdout output, provided prefix, and provided level. -func CreateLogger(prefix string, level sneklog.LogLevel) *sneklog.Logger { - logger := sneklog.CreateLogger().Level(level) - if prefix != "" { - logger.Prefix(prefix) +func CreateLogger( + name string, level sneklog.LogLevel, + format LogFormat, formatter *sneklog.Formatter, +) *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 } @@ -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 // 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) { - logger := CreateLogger(prefix, level) - fileWriter, err := logger.CreateTextFileWriter(filePath) - if err != nil { - return logger, err +func CreateFileLogger( + prefix string, level sneklog.LogLevel, filePath string, + 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 { + return logger, err + } + 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) } - logger.AddWriter(fileWriter) return logger, nil } diff --git a/utils/utils_test.go b/utils/utils_test.go index c69eacc..eae1aec 100644 --- a/utils/utils_test.go +++ b/utils/utils_test.go @@ -12,7 +12,7 @@ import ( func TestCreateFileLoggerWritesToConfiguredFile(t *testing.T) { 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 { t.Fatalf("CreateFileLogger returned error: %v", err) }