REPOSITORY / ScuroNeko/Laniakea
Compare commits
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
667fa3cc61
|
||
|
|
fc4386df75
|
||
|
|
a34734366d
|
||
|
|
3aee299869
|
||
|
|
b0882a46d5
|
||
|
|
7d4b150b0b |
@@ -9,4 +9,4 @@ jobs:
|
|||||||
- name: Checkout repository code
|
- name: Checkout repository code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
- name: Run golangci-lint
|
- name: Run golangci-lint
|
||||||
run: golangci-lint run
|
run: golangci-lint run
|
||||||
|
|||||||
@@ -123,9 +123,12 @@ Prefer the repository’s documented commands. If multiple choices exist, use th
|
|||||||
|
|
||||||
## Commit message format
|
## Commit message format
|
||||||
- When the user asks for a commit message, the agent must produce it in this format:
|
- When the user asks for a commit message, the agent must produce it in this format:
|
||||||
1. a short summary line;
|
1. one to four short lines;
|
||||||
2. up to three additional lines with only the most important changes;
|
2. each line must use the format `(<kind>): <text>`;
|
||||||
3. each additional line must start on its own new line.
|
3. `<kind>` must be a short change type such as `fix`, `new`, `tests`, `doc`, `refactor`, or `ci/cd`;
|
||||||
|
4. `<text>` must be a concise 1-5 word description of the change or function;
|
||||||
|
5. each line must start on its own new line;
|
||||||
|
6. when multiple lines are present, kinds must be ordered from top to bottom by this priority: `new`, `fix`, `refactor`, `ci/cd`, `tests`, `doc`.
|
||||||
- The agent must output the commit message as a plain multiline block that the user can copy directly.
|
- The agent must output the commit message as a plain multiline block that the user can copy directly.
|
||||||
- Do not collapse the lines into a paragraph, bullet list, or wrapped prose explanation.
|
- Do not collapse the lines into a paragraph, bullet list, or wrapped prose explanation.
|
||||||
- Keep commit text concise and high-signal.
|
- Keep commit text concise and high-signal.
|
||||||
|
|||||||
@@ -1,5 +1,39 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.0.0-rc.16
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
- Replaced `git.scuroneko.dev/scuroneko/slog` with `git.scuroneko.dev/scuroneko/sneklog/v2` across public logger APIs, including `AppDataLogger`, logger getters, and custom logger setters.
|
||||||
|
- Renamed exported `Json`, `Url`, and `Id` identifiers to idiomatic `JSON`, `URL`, and `ID` spellings, including `BotOpts.APIURL`, `BotOpts.SetAPIURL(...)`, `tgapi.APIOpts.SetAPIURL(...)`, `BotOptsFileJSONCodec`, `BotPayloadJSON`, and related README examples.
|
||||||
|
- Made the request logger field internal; use `Bot.SetRequestLogger(...)` and `Bot.GetRequestLogger()` instead of accessing `Bot.RequestLogger` directly.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added `Bot.UpdatesIter(...)` as an iterator wrapper around a single `Bot.Updates(...)` call, including error delivery through the iterator.
|
||||||
|
- Added scene-local callback payload handlers through `Scene.OnPayload(...)`, including observer lifecycle events for scene payload execution.
|
||||||
|
- Added configurable logger output through `BotOpts.LogFormat`, `BotOpts.SetLogFormat(...)`, `BotOpts.SetLogFormatter(...)`, `tgapi.APIOpts.SetLogFormat(...)`, and `tgapi.APIOpts.SetLogFormatter(...)`.
|
||||||
|
- Added JSON BotOpts file format versioning through `ConfigVersion`, `ErrConfigVersionMismatch`, and `BotOpts.FileConfigVersion`.
|
||||||
|
- Added `Bot.SetLogger(...)`, `Bot.SetRequestLogger(...)`, `Bot.SetWebHookLogger(...)`, `Bot.GetRequestLogger()`, and `Bot.GetWebHookLogger()` helpers for explicit logger customization.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated `pond/v2` to `v2.7.1`.
|
||||||
|
- `Bot.RunWithContext(...)` now closes an explicitly set request logger when `UseRequestLogger` is false and closes webhook loggers before long-polling startup.
|
||||||
|
- Bot loggers now apply the configured token replacer consistently across the main bot logger, request logger, internal API and uploader loggers, webhook logger, app-data logger writers, and auto-managed plugin loggers.
|
||||||
|
- 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`.
|
||||||
|
- `Bot.RunWithContext(...)` treats `context.DeadlineExceeded` like `context.Canceled` and exits polling without retry logging.
|
||||||
|
- README and README_RU now use the current `JSON`, `URL`, and `ID` public API names.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed the go-lint workflow file to end with a newline.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- Added regression coverage for `Bot.UpdatesIter(...)` error delivery and early iterator stop behavior.
|
||||||
|
- Added regression coverage proving `Bot.RunWithContext(...)` preserves polling retry attempts and backoff delays across repeated getUpdates failures.
|
||||||
|
- 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.
|
||||||
|
- Added regression coverage for JSON config version handling and scene-local payload routing, including observer lifecycle events and callback fallthrough behavior.
|
||||||
|
- Updated logger helper tests for the explicit log format and formatter parameters.
|
||||||
|
|
||||||
## v1.0.0-rc.15
|
## v1.0.0-rc.15
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -125,12 +125,12 @@ func main() {
|
|||||||
`BotOpts` can also be loaded from or saved to config files through the file codec API.
|
`BotOpts` can also be loaded from or saved to config files through the file codec API.
|
||||||
|
|
||||||
Built in:
|
Built in:
|
||||||
- `BotOptsFileJsonCodec` for JSON files.
|
- `BotOptsFileJSONCodec` for JSON files.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
codec := laniakea.BotOptsFileJsonCodec{}
|
codec := laniakea.BotOptsFileJSONCodec{}
|
||||||
opts, err := laniakea.LoadBotOptsFile(codec, "config.json")
|
opts, err := laniakea.LoadBotOptsFile(codec, "config.json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
@@ -145,7 +145,7 @@ if err != nil {
|
|||||||
Placeholders like `{{ TG_TOKEN }}` inside the file are expanded from environment variables before decoding.
|
Placeholders like `{{ TG_TOKEN }}` inside the file are expanded from environment variables before decoding.
|
||||||
|
|
||||||
You can also implement your own codec for other formats by satisfying `BotOptsFileCodec`.
|
You can also implement your own codec for other formats by satisfying `BotOptsFileCodec`.
|
||||||
Only JSON is supported out of the box right now. If you want another format such as TOML, use `BotOptsFileJsonCodec` as the reference implementation for your own codec.
|
Only JSON is supported out of the box right now. If you want another format such as TOML, use `BotOptsFileJSONCodec` as the reference implementation for your own codec.
|
||||||
|
|
||||||
See the full guide in the wiki: [Bot Options and Configuration](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Bot-Options-and-Configuration)
|
See the full guide in the wiki: [Bot Options and Configuration](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Bot-Options-and-Configuration)
|
||||||
|
|
||||||
@@ -198,12 +198,12 @@ Provides access to the incoming message and useful reply methods:
|
|||||||
- `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message with parse_mode none and inline keyboard.
|
- `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message with parse_mode none and inline keyboard.
|
||||||
- `KeyboardLong(text string, keyboard *InlineKeyboard) []*AnswerMessage`: Splits long plain text into multiple messages and attaches the keyboard to the final chunk.
|
- `KeyboardLong(text string, keyboard *InlineKeyboard) []*AnswerMessage`: Splits long plain text into multiple messages and attaches the keyboard to the final chunk.
|
||||||
- `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping) and inline keyboard.
|
- `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping) and inline keyboard.
|
||||||
- `AnswerPhoto(photoId, text string) *AnswerMessage`: Sends a message with photo with parse_mode none.
|
- `AnswerPhoto(photoID, text string) *AnswerMessage`: Sends a message with photo with parse_mode none.
|
||||||
- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Sends a photo with MarkdownV2 caption (you handle escaping).
|
- `AnswerPhotoMarkdown(photoID, text string) *AnswerMessage`: Sends a photo with MarkdownV2 caption (you handle escaping).
|
||||||
- `EditCallback(text string)`: Edits message with parse_mode none after clicking inline button.
|
- `EditCallback(text string)`: Edits message with parse_mode none after clicking inline button.
|
||||||
- `EditCallbackMarkdown(text string)`: Edits a message formatted with MarkdownV2 (you handle escaping) after clicking inline button.
|
- `EditCallbackMarkdown(text string)`: Edits a message formatted with MarkdownV2 (you handle escaping) after clicking inline button.
|
||||||
- `SendAction(action tgapi.ChatActionType)`: Sends a “typing”, “uploading photo”, etc., action.
|
- `SendAction(action tgapi.ChatActionType)`: Sends a “typing”, “uploading photo”, etc., action.
|
||||||
- Fields: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgId`, `CallbackQueryId`, etc.
|
- Fields: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgID`, `CallbackQueryID`, etc.
|
||||||
- And more methods and fields!
|
- And more methods and fields!
|
||||||
|
|
||||||
### tgapi: API and Uploader
|
### tgapi: API and Uploader
|
||||||
@@ -316,7 +316,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
|
|||||||
- Middleware can modify the MsgContext (e.g., add custom fields) before the command runs.
|
- Middleware can modify the MsgContext (e.g., add custom fields) before the command runs.
|
||||||
|
|
||||||
## ⚙️ Advanced Configuration
|
## ⚙️ Advanced Configuration
|
||||||
- **Inline Keyboards**: Build keyboards using `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64`, or `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` defines the default payload format, and `InlineKeyboard.SetPayloadType(...)` overrides it for one keyboard.
|
- **Inline Keyboards**: Build keyboards using `laniakea.NewInlineKeyboardJSON`, `laniakea.NewInlineKeyboardBase64`, or `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` defines the default payload format, and `InlineKeyboard.SetPayloadType(...)` overrides it for one keyboard.
|
||||||
- **Rate Limiting**: Pass a configured utils.RateLimiter via BotOpts to handle Telegram's rate limits gracefully.
|
- **Rate Limiting**: Pass a configured utils.RateLimiter via BotOpts to handle Telegram's rate limits gracefully.
|
||||||
- **Localization**: `L10n` is safe for concurrent use once attached to the bot.
|
- **Localization**: `L10n` is safe for concurrent use once attached to the bot.
|
||||||
- **Custom Update Handlers**: Use `plugin.AddUpdateHandler(...)` for Telegram update types that are not part of the command/payload flow.
|
- **Custom Update Handlers**: Use `plugin.AddUpdateHandler(...)` for Telegram update types that are not part of the command/payload flow.
|
||||||
|
|||||||
+7
-7
@@ -126,12 +126,12 @@ func main() {
|
|||||||
`BotOpts` можно не только собирать вручную или из environment, но и загружать и сохранять через file codec API.
|
`BotOpts` можно не только собирать вручную или из environment, но и загружать и сохранять через file codec API.
|
||||||
|
|
||||||
Из коробки доступно:
|
Из коробки доступно:
|
||||||
- `BotOptsFileJsonCodec` для JSON-файлов.
|
- `BotOptsFileJSONCodec` для JSON-файлов.
|
||||||
|
|
||||||
Пример:
|
Пример:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
codec := laniakea.BotOptsFileJsonCodec{}
|
codec := laniakea.BotOptsFileJSONCodec{}
|
||||||
opts, err := laniakea.LoadBotOptsFile(codec, "config.json")
|
opts, err := laniakea.LoadBotOptsFile(codec, "config.json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
@@ -146,7 +146,7 @@ if err != nil {
|
|||||||
Плейсхолдеры вида `{{ TG_TOKEN }}` внутри файла перед декодированием разворачиваются из переменных окружения.
|
Плейсхолдеры вида `{{ TG_TOKEN }}` внутри файла перед декодированием разворачиваются из переменных окружения.
|
||||||
|
|
||||||
Для других форматов можно реализовать собственный codec через интерфейс `BotOptsFileCodec`.
|
Для других форматов можно реализовать собственный codec через интерфейс `BotOptsFileCodec`.
|
||||||
Из коробки сейчас поддерживается только JSON. Если нужен другой формат, например TOML, используй `BotOptsFileJsonCodec` как эталонную реализацию собственного codec.
|
Из коробки сейчас поддерживается только JSON. Если нужен другой формат, например TOML, используй `BotOptsFileJSONCodec` как эталонную реализацию собственного codec.
|
||||||
|
|
||||||
Подробности есть в wiki: [Bot Options and Configuration RU](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Bot-Options-and-Configuration-RU)
|
Подробности есть в wiki: [Bot Options and Configuration RU](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Bot-Options-and-Configuration-RU)
|
||||||
|
|
||||||
@@ -198,12 +198,12 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) error {
|
|||||||
- `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение с parse_mode none и Inline клавиатурой.
|
- `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение с parse_mode none и Inline клавиатурой.
|
||||||
- `KeyboardLong(text string, keyboard *InlineKeyboard) []*AnswerMessage`: Разбивает длинный plain text на несколько сообщений и вешает клавиатуру на последний chunk.
|
- `KeyboardLong(text string, keyboard *InlineKeyboard) []*AnswerMessage`: Разбивает длинный plain text на несколько сообщений и вешает клавиатуру на последний chunk.
|
||||||
- `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне), и Inline клавиатурой.
|
- `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне), и Inline клавиатурой.
|
||||||
- `AnswerPhoto(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью и parse_mode none.
|
- `AnswerPhoto(photoID, text string) *AnswerMessage`: Отправляет фотографию с подписью и parse_mode none.
|
||||||
- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью, отформатированной MarkdownV2 (экранирование на вашей стороне).
|
- `AnswerPhotoMarkdown(photoID, text string) *AnswerMessage`: Отправляет фотографию с подписью, отформатированной MarkdownV2 (экранирование на вашей стороне).
|
||||||
- `EditCallback(text string)`: Редактирует сообщение с `parse_mode` none после нажатия inline-кнопки.
|
- `EditCallback(text string)`: Редактирует сообщение с `parse_mode` none после нажатия inline-кнопки.
|
||||||
- `EditCallbackMarkdown(text string)`: Редактирует сообщение в формате MarkdownV2 (экранирование на вашей стороне) после нажатия inline-кнопки.
|
- `EditCallbackMarkdown(text string)`: Редактирует сообщение в формате MarkdownV2 (экранирование на вашей стороне) после нажатия inline-кнопки.
|
||||||
- `SendAction(action tgapi.ChatActionType)`: Отправляет действие "печатает", "загружает фото" и т.д.
|
- `SendAction(action tgapi.ChatActionType)`: Отправляет действие "печатает", "загружает фото" и т.д.
|
||||||
- Поля: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgId`, `CallbackQueryId` и другие.
|
- Поля: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgID`, `CallbackQueryID` и другие.
|
||||||
- И много других методов и полей!
|
- И много других методов и полей!
|
||||||
|
|
||||||
### App Data
|
### App Data
|
||||||
@@ -313,7 +313,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
|
|||||||
- Middleware может изменять MsgContext (например, добавлять пользовательские поля) перед запуском команды.
|
- Middleware может изменять MsgContext (например, добавлять пользовательские поля) перед запуском команды.
|
||||||
|
|
||||||
## ⚙️ Расширенная настройка
|
## ⚙️ Расширенная настройка
|
||||||
- **Инлайн-клавиатуры**: Создавайте клавиатуры с помощью `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64` или `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` задаёт payload format по умолчанию, а `InlineKeyboard.SetPayloadType(...)` переопределяет его для конкретной клавиатуры.
|
- **Инлайн-клавиатуры**: Создавайте клавиатуры с помощью `laniakea.NewInlineKeyboardJSON`, `laniakea.NewInlineKeyboardBase64` или `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` задаёт payload format по умолчанию, а `InlineKeyboard.SetPayloadType(...)` переопределяет его для конкретной клавиатуры.
|
||||||
- **Ограничение запросов**: Передайте настроенный `utils.RateLimiter` через `BotOpts` для корректной обработки лимитов Telegram.
|
- **Ограничение запросов**: Передайте настроенный `utils.RateLimiter` через `BotOpts` для корректной обработки лимитов Telegram.
|
||||||
- **Локализация**: `L10n` безопасен для конкурентного использования после подключения к боту.
|
- **Локализация**: `L10n` безопасен для конкурентного использования после подключения к боту.
|
||||||
- **Пользовательские update handlers**: Используйте `plugin.AddUpdateHandler(...)` для Telegram update types вне command/payload flow.
|
- **Пользовательские update handlers**: Используйте `plugin.AddUpdateHandler(...)` для Telegram update types вне command/payload flow.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"git.scuroneko.dev/scuroneko/extypes"
|
"git.scuroneko.dev/scuroneko/extypes"
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/utils"
|
"git.scuroneko.dev/scuroneko/laniakea/utils"
|
||||||
"git.scuroneko.dev/scuroneko/slog"
|
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AppData is the generic shared application data type injected into bots,
|
// AppData is the generic shared application data type injected into bots,
|
||||||
@@ -38,11 +38,11 @@ type AppData any
|
|||||||
// Use Bot[NoData] to indicate no shared dependency injection is required.
|
// Use Bot[NoData] to indicate no shared dependency injection is required.
|
||||||
type NoData struct{ AppData }
|
type NoData struct{ AppData }
|
||||||
|
|
||||||
// AppDataLogger builds a slog.LoggerWriter from injected application data.
|
// AppDataLogger builds a sneklog.LoggerWriter from injected application data.
|
||||||
//
|
//
|
||||||
// Use it when shared application data exposes a log sink or adapter that should
|
// Use it when shared application data exposes a log sink or adapter that should
|
||||||
// receive framework logs.
|
// receive framework logs.
|
||||||
type AppDataLogger[T AppData] func(data T) slog.LoggerWriter
|
type AppDataLogger[T AppData] func(data T) sneklog.LoggerWriter
|
||||||
|
|
||||||
// BotPayloadType defines the serialization format for callback data payloads.
|
// BotPayloadType defines the serialization format for callback data payloads.
|
||||||
type BotPayloadType string
|
type BotPayloadType string
|
||||||
@@ -50,8 +50,8 @@ type BotPayloadType string
|
|||||||
var (
|
var (
|
||||||
// BotPayloadBase64 encodes callback data as a Base64 string.
|
// BotPayloadBase64 encodes callback data as a Base64 string.
|
||||||
BotPayloadBase64 BotPayloadType = "base64"
|
BotPayloadBase64 BotPayloadType = "base64"
|
||||||
// BotPayloadJson encodes callback data as a JSON string.
|
// BotPayloadJSON encodes callback data as a JSON string.
|
||||||
BotPayloadJson BotPayloadType = "json"
|
BotPayloadJSON BotPayloadType = "json"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -90,10 +90,13 @@ type Bot[T AppData] struct {
|
|||||||
strictPayloadType bool
|
strictPayloadType bool
|
||||||
maxWorkers int
|
maxWorkers int
|
||||||
|
|
||||||
logger *slog.Logger // Main bot logger (JSON stdout + optional file)
|
logFormat utils.LogFormat
|
||||||
RequestLogger *slog.Logger // Optional request-level API logging
|
logFormatter *sneklog.Formatter
|
||||||
webHookLogger *slog.Logger // Webhook logger. Available only after Bot.RunWebHookWithContext.
|
logger *sneklog.Logger // Main bot logger (JSON stdout + optional file)
|
||||||
extraLoggers extypes.Slice[*slog.Logger] // API, Uploader, and custom loggers
|
requestLogger *sneklog.Logger // Optional request-level API logging
|
||||||
|
useReqLogger bool
|
||||||
|
webHookLogger *sneklog.Logger // Webhook logger. Available only after Bot.RunWebHookWithContext.
|
||||||
|
extraLoggers extypes.Slice[*sneklog.Logger] // API, Uploader, and custom loggers
|
||||||
|
|
||||||
plugins []Plugin[T] // Command/event handlers
|
plugins []Plugin[T] // Command/event handlers
|
||||||
middlewares []Middleware[T] // Pre-processing filters (sorted by order)
|
middlewares []Middleware[T] // Pre-processing filters (sorted by order)
|
||||||
@@ -158,10 +161,12 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
|
|||||||
limiter.SetGlobalRate(opts.RateLimit)
|
limiter.SetGlobalRate(opts.RateLimit)
|
||||||
|
|
||||||
apiOpts := tgapi.NewAPIOpts(opts.Token).
|
apiOpts := tgapi.NewAPIOpts(opts.Token).
|
||||||
SetAPIUrl(opts.APIUrl).
|
SetAPIURL(opts.APIURL).
|
||||||
UseTestServer(opts.UseTestServer).
|
UseTestServer(opts.UseTestServer).
|
||||||
SetLimiter(limiter).
|
SetLimiter(limiter).
|
||||||
SetLimiterDrop(opts.DropRLOverflow)
|
SetLimiterDrop(opts.DropRLOverflow).
|
||||||
|
SetLogFormat(opts.LogFormat).
|
||||||
|
SetLogFormatter(opts.LogFormatter)
|
||||||
api := tgapi.NewAPI(apiOpts)
|
api := tgapi.NewAPI(apiOpts)
|
||||||
uploader := tgapi.NewUploader(api)
|
uploader := tgapi.NewUploader(api)
|
||||||
|
|
||||||
@@ -187,12 +192,16 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
|
|||||||
debug: opts.Debug,
|
debug: opts.Debug,
|
||||||
prefixes: prefixes,
|
prefixes: prefixes,
|
||||||
token: opts.Token,
|
token: opts.Token,
|
||||||
plugins: make([]Plugin[T], 0),
|
logFormat: opts.LogFormat,
|
||||||
updateTypes: append([]tgapi.UpdateType{}, opts.UpdateTypes...),
|
logFormatter: opts.LogFormatter,
|
||||||
runners: make([]Runner[T], 0),
|
useReqLogger: opts.UseRequestLogger,
|
||||||
extraLoggers: make([]*slog.Logger, 0),
|
|
||||||
l10n: &L10n{},
|
plugins: make([]Plugin[T], 0),
|
||||||
draftProvider: NewRandomDraftProvider(api),
|
updateTypes: append([]tgapi.UpdateType{}, opts.UpdateTypes...),
|
||||||
|
runners: make([]Runner[T], 0),
|
||||||
|
extraLoggers: make([]*sneklog.Logger, 0),
|
||||||
|
l10n: &L10n{},
|
||||||
|
draftProvider: NewRandomDraftProvider(api),
|
||||||
|
|
||||||
sessionStore: NewMemorySessionStore(),
|
sessionStore: NewMemorySessionStore(),
|
||||||
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
||||||
@@ -209,6 +218,16 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
|
|||||||
}
|
}
|
||||||
bot.initLoggers(opts)
|
bot.initLoggers(opts)
|
||||||
|
|
||||||
|
if opts.FileConfigVersion > 0 && opts.FileConfigVersion < ConfigVersion {
|
||||||
|
bot.logger.Warnln(
|
||||||
|
fmt.Sprintf(
|
||||||
|
"Config file version %d is older than library version %d; please update your config file to access new features and avoid compatibility issues",
|
||||||
|
opts.FileConfigVersion,
|
||||||
|
ConfigVersion,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch bot info to validate token and get username
|
// Fetch bot info to validate token and get username
|
||||||
u, err := api.GetMe()
|
u, err := api.GetMe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -220,10 +239,29 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
|
|||||||
bot.logger.Warn("Can't get bot username. Named command handlers won't work!")
|
bot.logger.Warn("Can't get bot username. Named command handlers won't work!")
|
||||||
}
|
}
|
||||||
bot.logger.Infoln(fmt.Sprintf("Authorized as %s (@%s)", u.FirstName, Val(u.Username, "unknown")))
|
bot.logger.Infoln(fmt.Sprintf("Authorized as %s (@%s)", u.FirstName, Val(u.Username, "unknown")))
|
||||||
|
bot.logger.Debugln("Bot initialized with configuration:", fmt.Sprintf("%+v", opts))
|
||||||
|
|
||||||
return bot, nil
|
return bot, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLogger replaces the main bot logger.
|
||||||
|
func (bot *Bot[T]) SetLogger(l *sneklog.Logger) *Bot[T] {
|
||||||
|
bot.logger = l
|
||||||
|
return bot
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRequestLogger replaces the request-level logger.
|
||||||
|
func (bot *Bot[T]) SetRequestLogger(l *sneklog.Logger) *Bot[T] {
|
||||||
|
bot.requestLogger = l
|
||||||
|
return bot
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWebHookLogger replaces the webhook logger.
|
||||||
|
func (bot *Bot[T]) SetWebHookLogger(l *sneklog.Logger) *Bot[T] {
|
||||||
|
bot.webHookLogger = l
|
||||||
|
return bot
|
||||||
|
}
|
||||||
|
|
||||||
// Close gracefully shuts down bot-owned resources.
|
// Close gracefully shuts down bot-owned resources.
|
||||||
//
|
//
|
||||||
// Close shuts down, in order:
|
// Close shuts down, in order:
|
||||||
@@ -272,8 +310,8 @@ func (bot *Bot[T]) Close() error {
|
|||||||
logCloseErr(err)
|
logCloseErr(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if bot.RequestLogger != nil {
|
if bot.requestLogger != nil {
|
||||||
if err := bot.RequestLogger.Close(); err != nil {
|
if err := bot.requestLogger.Close(); err != nil {
|
||||||
logCloseErr(err)
|
logCloseErr(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -311,14 +349,20 @@ func (bot *Bot[T]) SetUpdateOffset(offset int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetLogger returns the main bot logger.
|
// GetLogger returns the main bot logger.
|
||||||
func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger }
|
func (bot *Bot[T]) GetLogger() *sneklog.Logger { return bot.logger }
|
||||||
|
|
||||||
|
// GetRequestLogger returns the request-level logger, if configured.
|
||||||
|
func (bot *Bot[T]) GetRequestLogger() *sneklog.Logger { return bot.requestLogger }
|
||||||
|
|
||||||
|
// GetWebHookLogger returns the webhook logger, if configured.
|
||||||
|
func (bot *Bot[T]) GetWebHookLogger() *sneklog.Logger { return bot.webHookLogger }
|
||||||
|
|
||||||
// GetLoggerLevel returns the effective log level derived from the bot's debug
|
// GetLoggerLevel returns the effective log level derived from the bot's debug
|
||||||
// flag.
|
// flag.
|
||||||
func (bot *Bot[T]) GetLoggerLevel() slog.LogLevel {
|
func (bot *Bot[T]) GetLoggerLevel() sneklog.LogLevel {
|
||||||
level := slog.FATAL
|
level := sneklog.FATAL
|
||||||
if bot.debug {
|
if bot.debug {
|
||||||
level = slog.DEBUG
|
level = sneklog.DEBUG
|
||||||
}
|
}
|
||||||
return level
|
return level
|
||||||
}
|
}
|
||||||
@@ -362,6 +406,22 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer bot.finishRun()
|
defer bot.finishRun()
|
||||||
|
if !bot.useReqLogger && bot.requestLogger != nil {
|
||||||
|
bot.logger.Warnln("Opts#UseRequestLogger is false, but Bot#requestLogger present. Remove Bot#SetRequestLogger or set Opts#UseRequestLogger to true!")
|
||||||
|
err := bot.requestLogger.Close()
|
||||||
|
if err != nil {
|
||||||
|
bot.logger.Errorln(err)
|
||||||
|
}
|
||||||
|
bot.requestLogger = nil
|
||||||
|
}
|
||||||
|
if bot.webHookLogger != nil {
|
||||||
|
bot.logger.Warnln("Bot#webHookLogger present. You shouldn't set this, if ran in Long Polling mode!")
|
||||||
|
err := bot.webHookLogger.Close()
|
||||||
|
if err != nil {
|
||||||
|
bot.logger.Errorln(err)
|
||||||
|
}
|
||||||
|
bot.webHookLogger = nil
|
||||||
|
}
|
||||||
|
|
||||||
bot.ExecRunners(ctx)
|
bot.ExecRunners(ctx)
|
||||||
|
|
||||||
@@ -382,7 +442,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error {
|
|||||||
default:
|
default:
|
||||||
updates, err := bot.Updates(ctx)
|
updates, err := bot.Updates(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, context.Canceled) {
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
bot.logger.Errorln("failed to fetch updates:", err)
|
bot.logger.Errorln("failed to fetch updates:", err)
|
||||||
|
|||||||
+8
-8
@@ -5,7 +5,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
"git.scuroneko.dev/scuroneko/slog"
|
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AddPrefixes adds one or more command prefixes (e.g., "/", "!").
|
// AddPrefixes adds one or more command prefixes (e.g., "/", "!").
|
||||||
@@ -19,7 +19,7 @@ func (bot *Bot[T]) AddPrefixes(prefixes ...string) *Bot[T] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetDraftProvider replaces the default DraftProvider with a custom one.
|
// SetDraftProvider replaces the default DraftProvider with a custom one.
|
||||||
// Useful for using LinearDraftIdGenerator to persist draft IDs across restarts.
|
// Useful for using LinearDraftIDGenerator to persist draft IDs across restarts.
|
||||||
func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] {
|
func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] {
|
||||||
if !bot.configMutable("SetDraftProvider") {
|
if !bot.configMutable("SetDraftProvider") {
|
||||||
return bot
|
return bot
|
||||||
@@ -187,20 +187,20 @@ func (bot *Bot[T]) SetErrorTemplate(s string) *Bot[T] {
|
|||||||
// SetDebug enables or disables debug logging.
|
// SetDebug enables or disables debug logging.
|
||||||
func (bot *Bot[T]) SetDebug(debug bool) *Bot[T] {
|
func (bot *Bot[T]) SetDebug(debug bool) *Bot[T] {
|
||||||
bot.debug = debug
|
bot.debug = debug
|
||||||
level := slog.FATAL
|
level := sneklog.FATAL
|
||||||
if debug {
|
if debug {
|
||||||
level = slog.DEBUG
|
level = sneklog.DEBUG
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.logger.Level(level)
|
bot.logger.SetLevel(level)
|
||||||
if bot.RequestLogger != nil {
|
if bot.requestLogger != nil {
|
||||||
bot.RequestLogger.Level(level)
|
bot.requestLogger.SetLevel(level)
|
||||||
}
|
}
|
||||||
for _, p := range bot.plugins {
|
for _, p := range bot.plugins {
|
||||||
if p.logger == nil {
|
if p.logger == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
p.logger.Level(level)
|
p.logger.SetLevel(level)
|
||||||
}
|
}
|
||||||
return bot
|
return bot
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-7
@@ -6,6 +6,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
|
"git.scuroneko.dev/scuroneko/laniakea/utils"
|
||||||
|
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BotOpts holds configuration options for initializing a Bot.
|
// BotOpts holds configuration options for initializing a Bot.
|
||||||
@@ -45,8 +47,8 @@ type BotOpts struct {
|
|||||||
// UseTestServer uses Telegram's test server (https://api.test.telegram.org).
|
// UseTestServer uses Telegram's test server (https://api.test.telegram.org).
|
||||||
UseTestServer bool
|
UseTestServer bool
|
||||||
|
|
||||||
// APIUrl overrides the default Telegram API endpoint (useful for proxies or self-hosted).
|
// APIURL overrides the default Telegram API endpoint (useful for proxies or self-hosted).
|
||||||
APIUrl string
|
APIURL string
|
||||||
|
|
||||||
// RateLimit is the maximum number of API requests per second.
|
// RateLimit is the maximum number of API requests per second.
|
||||||
// Telegram allows up to 30 req/s for most bots. Defaults to 30.
|
// Telegram allows up to 30 req/s for most bots. Defaults to 30.
|
||||||
@@ -62,6 +64,15 @@ type BotOpts struct {
|
|||||||
|
|
||||||
// MaxWorkers is the maximum number of update handlers that may run concurrently.
|
// MaxWorkers is the maximum number of update handlers that may run concurrently.
|
||||||
MaxWorkers int
|
MaxWorkers int
|
||||||
|
|
||||||
|
// FileConfigVersion stores the version declared by the config file used to
|
||||||
|
// load these options.
|
||||||
|
//
|
||||||
|
// 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.
|
// LoadOptsFromEnv loads BotOpts from environment variables.
|
||||||
@@ -81,6 +92,7 @@ type BotOpts struct {
|
|||||||
// - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow
|
// - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow
|
||||||
// - STRICT_PAYLOAD_TYPE: "true" to reject callback payloads encoded in a different format
|
// - STRICT_PAYLOAD_TYPE: "true" to reject callback payloads encoded in a different format
|
||||||
// - MAX_WORKERS: maximum number of concurrent update handlers (default: 32)
|
// - MAX_WORKERS: maximum number of concurrent update handlers (default: 32)
|
||||||
|
// - JSON_LOG:
|
||||||
//
|
//
|
||||||
// Returns a populated BotOpts.
|
// Returns a populated BotOpts.
|
||||||
// NewBot validates required fields and returns ErrTokenRequired when TG_TOKEN is missing.
|
// NewBot validates required fields and returns ErrTokenRequired when TG_TOKEN is missing.
|
||||||
@@ -119,13 +131,15 @@ func LoadOptsFromEnv() *BotOpts {
|
|||||||
WriteToFile: os.Getenv("WRITE_TO_FILE") == "true",
|
WriteToFile: os.Getenv("WRITE_TO_FILE") == "true",
|
||||||
|
|
||||||
UseTestServer: os.Getenv("USE_TEST_SERVER") == "true",
|
UseTestServer: os.Getenv("USE_TEST_SERVER") == "true",
|
||||||
APIUrl: os.Getenv("API_URL"),
|
APIURL: os.Getenv("API_URL"),
|
||||||
|
|
||||||
RateLimit: rateLimit,
|
RateLimit: rateLimit,
|
||||||
DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
|
DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
|
||||||
StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true",
|
StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true",
|
||||||
|
|
||||||
MaxWorkers: maxWorkers,
|
MaxWorkers: maxWorkers,
|
||||||
|
FileConfigVersion: 0,
|
||||||
|
LogFormat: utils.LogFormat(os.Getenv("LOG_FORMAT")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,10 +207,10 @@ func (opts *BotOpts) SetUseTestServer(use bool) *BotOpts {
|
|||||||
return opts
|
return opts
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAPIUrl overrides the default Telegram API endpoint (useful for proxies or self-hosted).
|
// SetAPIURL overrides the default Telegram API endpoint (useful for proxies or self-hosted).
|
||||||
// If not set, defaults to "https://api.telegram.org".
|
// If not set, defaults to "https://api.telegram.org".
|
||||||
func (opts *BotOpts) SetAPIUrl(url string) *BotOpts {
|
func (opts *BotOpts) SetAPIURL(url string) *BotOpts {
|
||||||
opts.APIUrl = url
|
opts.APIURL = url
|
||||||
return opts
|
return opts
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +254,15 @@ func (opts *BotOpts) SetMaxWorkers(workers int) *BotOpts {
|
|||||||
return opts
|
return opts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (opts *BotOpts) SetLogFormat(format utils.LogFormat) *BotOpts {
|
||||||
|
opts.LogFormat = format
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
func (opts *BotOpts) SetLogFormatter(formatter *sneklog.Formatter) *BotOpts {
|
||||||
|
opts.LogFormatter = formatter
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
// LoadPrefixesFromEnv returns the PREFIXES environment variable split by semicolon.
|
// LoadPrefixesFromEnv returns the PREFIXES environment variable split by semicolon.
|
||||||
// Defaults to ["/"] if not set.
|
// Defaults to ["/"] if not set.
|
||||||
func LoadPrefixesFromEnv() []string {
|
func LoadPrefixesFromEnv() []string {
|
||||||
|
|||||||
+54
-44
@@ -2,45 +2,62 @@ package laniakea
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
|
"git.scuroneko.dev/scuroneko/laniakea/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BotOptsFileJson is the JSON file representation of BotOpts.
|
// ConfigVersion is the current version of the built-in JSON BotOpts file format.
|
||||||
type BotOptsFileJson struct {
|
const ConfigVersion = 1
|
||||||
Token string `json:"token"`
|
|
||||||
UpdateTypes []tgapi.UpdateType `json:"update_types"`
|
// ErrConfigVersionMismatch reports that a config file declares a newer version
|
||||||
Debug bool `json:"debug"`
|
// than this library knows how to decode.
|
||||||
ErrorTemplate string `json:"error_template"`
|
var ErrConfigVersionMismatch = fmt.Errorf("config version mismatch: expected %d", ConfigVersion)
|
||||||
Prefixes []string `json:"prefixes"`
|
|
||||||
Logger struct {
|
type botOptsFileJSONLogger struct {
|
||||||
LoggerBasePath string `json:"base_path"`
|
LoggerBasePath string `json:"base_path"`
|
||||||
UseRequestLogger bool `json:"use_request_logger"`
|
UseRequestLogger bool `json:"use_request_logger"`
|
||||||
WriteToFile bool `json:"write_to_file"`
|
WriteToFile bool `json:"write_to_file"`
|
||||||
} `json:"logger"`
|
LogFormat utils.LogFormat `json:"log_format"`
|
||||||
API struct {
|
}
|
||||||
UseTestServer bool `json:"use_test_server"`
|
type botOptsFileJSONAPI struct {
|
||||||
APIUrl string `json:"url"`
|
UseTestServer bool `json:"use_test_server"`
|
||||||
RateLimit int `json:"rate_limit"`
|
APIURL string `json:"url"`
|
||||||
DropRLOverflow bool `json:"drop_overflow"`
|
RateLimit int `json:"rate_limit"`
|
||||||
} `json:"api"`
|
DropRLOverflow bool `json:"drop_overflow"`
|
||||||
StrictPayloadType bool `json:"strict_payload_type"`
|
|
||||||
MaxWorkers int `json:"max_workers"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BotOptsFileJsonCodec encodes and decodes BotOpts using BotOptsFileJson.
|
// BotOptsFileJSON is the JSON file representation of BotOpts.
|
||||||
type BotOptsFileJsonCodec struct{}
|
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.
|
// FromBytes decodes BotOpts from JSON file bytes.
|
||||||
func (codec BotOptsFileJsonCodec) FromBytes(data []byte) (*BotOpts, error) {
|
func (codec BotOptsFileJSONCodec) FromBytes(data []byte) (*BotOpts, error) {
|
||||||
fileOpts := new(BotOptsFileJson)
|
fileOpts := new(BotOptsFileJSON)
|
||||||
err := json.Unmarshal(data, fileOpts)
|
err := json.Unmarshal(data, fileOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if fileOpts.Version > ConfigVersion {
|
||||||
|
return nil, ErrConfigVersionMismatch
|
||||||
|
}
|
||||||
opts := &BotOpts{
|
opts := &BotOpts{
|
||||||
Token: fileOpts.Token,
|
Token: fileOpts.Token,
|
||||||
UpdateTypes: fileOpts.UpdateTypes,
|
UpdateTypes: fileOpts.UpdateTypes,
|
||||||
@@ -51,49 +68,42 @@ func (codec BotOptsFileJsonCodec) FromBytes(data []byte) (*BotOpts, error) {
|
|||||||
LoggerBasePath: fileOpts.Logger.LoggerBasePath,
|
LoggerBasePath: fileOpts.Logger.LoggerBasePath,
|
||||||
UseRequestLogger: fileOpts.Logger.UseRequestLogger,
|
UseRequestLogger: fileOpts.Logger.UseRequestLogger,
|
||||||
WriteToFile: fileOpts.Logger.WriteToFile,
|
WriteToFile: fileOpts.Logger.WriteToFile,
|
||||||
|
LogFormat: fileOpts.Logger.LogFormat,
|
||||||
|
|
||||||
UseTestServer: fileOpts.API.UseTestServer,
|
UseTestServer: fileOpts.API.UseTestServer,
|
||||||
APIUrl: fileOpts.API.APIUrl,
|
APIURL: fileOpts.API.APIURL,
|
||||||
RateLimit: fileOpts.API.RateLimit,
|
RateLimit: fileOpts.API.RateLimit,
|
||||||
DropRLOverflow: fileOpts.API.DropRLOverflow,
|
DropRLOverflow: fileOpts.API.DropRLOverflow,
|
||||||
|
|
||||||
StrictPayloadType: fileOpts.StrictPayloadType,
|
StrictPayloadType: fileOpts.StrictPayloadType,
|
||||||
MaxWorkers: fileOpts.MaxWorkers,
|
MaxWorkers: fileOpts.MaxWorkers,
|
||||||
|
|
||||||
|
FileConfigVersion: fileOpts.Version,
|
||||||
}
|
}
|
||||||
return opts, nil
|
return opts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToBytes encodes BotOpts into JSON file bytes.
|
// ToBytes encodes BotOpts into JSON file bytes.
|
||||||
func (codec BotOptsFileJsonCodec) ToBytes(opts *BotOpts) ([]byte, error) {
|
func (codec BotOptsFileJSONCodec) ToBytes(opts *BotOpts) ([]byte, error) {
|
||||||
fileOpts := &BotOptsFileJson{
|
fileOpts := &BotOptsFileJSON{
|
||||||
|
Version: ConfigVersion,
|
||||||
Token: opts.Token,
|
Token: opts.Token,
|
||||||
UpdateTypes: opts.UpdateTypes,
|
UpdateTypes: opts.UpdateTypes,
|
||||||
Debug: opts.Debug,
|
Debug: opts.Debug,
|
||||||
ErrorTemplate: opts.ErrorTemplate,
|
ErrorTemplate: opts.ErrorTemplate,
|
||||||
Prefixes: opts.Prefixes,
|
Prefixes: opts.Prefixes,
|
||||||
|
Logger: botOptsFileJSONLogger{
|
||||||
Logger: struct {
|
|
||||||
LoggerBasePath string `json:"base_path"`
|
|
||||||
UseRequestLogger bool `json:"use_request_logger"`
|
|
||||||
WriteToFile bool `json:"write_to_file"`
|
|
||||||
}{
|
|
||||||
LoggerBasePath: opts.LoggerBasePath,
|
LoggerBasePath: opts.LoggerBasePath,
|
||||||
UseRequestLogger: opts.UseRequestLogger,
|
UseRequestLogger: opts.UseRequestLogger,
|
||||||
WriteToFile: opts.WriteToFile,
|
WriteToFile: opts.WriteToFile,
|
||||||
|
LogFormat: opts.LogFormat,
|
||||||
},
|
},
|
||||||
|
API: botOptsFileJSONAPI{
|
||||||
API: struct {
|
|
||||||
UseTestServer bool `json:"use_test_server"`
|
|
||||||
APIUrl string `json:"url"`
|
|
||||||
RateLimit int `json:"rate_limit"`
|
|
||||||
DropRLOverflow bool `json:"drop_overflow"`
|
|
||||||
}{
|
|
||||||
UseTestServer: opts.UseTestServer,
|
UseTestServer: opts.UseTestServer,
|
||||||
APIUrl: opts.APIUrl,
|
APIURL: opts.APIURL,
|
||||||
RateLimit: opts.RateLimit,
|
RateLimit: opts.RateLimit,
|
||||||
DropRLOverflow: opts.DropRLOverflow,
|
DropRLOverflow: opts.DropRLOverflow,
|
||||||
},
|
},
|
||||||
|
|
||||||
StrictPayloadType: opts.StrictPayloadType,
|
StrictPayloadType: opts.StrictPayloadType,
|
||||||
MaxWorkers: opts.MaxWorkers,
|
MaxWorkers: opts.MaxWorkers,
|
||||||
}
|
}
|
||||||
@@ -104,10 +114,10 @@ func (codec BotOptsFileJsonCodec) ToBytes(opts *BotOpts) ([]byte, error) {
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (codec BotOptsFileJsonCodec) Load(filename string) (*BotOpts, error) {
|
func (codec BotOptsFileJSONCodec) Load(filename string) (*BotOpts, error) {
|
||||||
return LoadBotOptsFile(codec, filename)
|
return LoadBotOptsFile(codec, filename)
|
||||||
}
|
}
|
||||||
func (codec BotOptsFileJsonCodec) Save(filename string, opts *BotOpts) error {
|
func (codec BotOptsFileJSONCodec) Save(filename string, opts *BotOpts) error {
|
||||||
return SaveBotOptsFile(codec, filename, opts)
|
return SaveBotOptsFile(codec, filename, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+39
-16
@@ -1,6 +1,7 @@
|
|||||||
package laniakea
|
package laniakea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -9,8 +10,8 @@ import (
|
|||||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBotOptsFileJsonCodecRoundTrip(t *testing.T) {
|
func TestBotOptsFileJSONCodecRoundTrip(t *testing.T) {
|
||||||
codec := BotOptsFileJsonCodec{}
|
codec := BotOptsFileJSONCodec{}
|
||||||
want := &BotOpts{
|
want := &BotOpts{
|
||||||
Token: "TOKEN",
|
Token: "TOKEN",
|
||||||
UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery},
|
UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery},
|
||||||
@@ -21,11 +22,12 @@ func TestBotOptsFileJsonCodecRoundTrip(t *testing.T) {
|
|||||||
UseRequestLogger: true,
|
UseRequestLogger: true,
|
||||||
WriteToFile: true,
|
WriteToFile: true,
|
||||||
UseTestServer: true,
|
UseTestServer: true,
|
||||||
APIUrl: "https://api.example.invalid",
|
APIURL: "https://api.example.invalid",
|
||||||
RateLimit: 42,
|
RateLimit: 42,
|
||||||
DropRLOverflow: true,
|
DropRLOverflow: true,
|
||||||
StrictPayloadType: true,
|
StrictPayloadType: true,
|
||||||
MaxWorkers: 64,
|
MaxWorkers: 64,
|
||||||
|
FileConfigVersion: ConfigVersion,
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := codec.ToBytes(want)
|
data, err := codec.ToBytes(want)
|
||||||
@@ -60,7 +62,7 @@ func TestLoadBotOptsFileExpandsEnvPlaceholders(t *testing.T) {
|
|||||||
t.Fatalf("WriteFile returned error: %v", err)
|
t.Fatalf("WriteFile returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := LoadBotOptsFile(BotOptsFileJsonCodec{}, filename)
|
got, err := LoadBotOptsFile(BotOptsFileJSONCodec{}, filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadBotOptsFile returned error: %v", err)
|
t.Fatalf("LoadBotOptsFile returned error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -68,12 +70,15 @@ func TestLoadBotOptsFileExpandsEnvPlaceholders(t *testing.T) {
|
|||||||
if got.Token != "TOKEN_FROM_ENV" {
|
if got.Token != "TOKEN_FROM_ENV" {
|
||||||
t.Fatalf("unexpected token: got %q want %q", got.Token, "TOKEN_FROM_ENV")
|
t.Fatalf("unexpected token: got %q want %q", got.Token, "TOKEN_FROM_ENV")
|
||||||
}
|
}
|
||||||
if got.APIUrl != "https://api.example.invalid" {
|
if got.APIURL != "https://api.example.invalid" {
|
||||||
t.Fatalf("unexpected api url: got %q want %q", got.APIUrl, "https://api.example.invalid")
|
t.Fatalf("unexpected api url: got %q want %q", got.APIURL, "https://api.example.invalid")
|
||||||
}
|
}
|
||||||
if got.ErrorTemplate != "Error: %s" {
|
if got.ErrorTemplate != "Error: %s" {
|
||||||
t.Fatalf("unexpected error template: got %q", got.ErrorTemplate)
|
t.Fatalf("unexpected error template: got %q", got.ErrorTemplate)
|
||||||
}
|
}
|
||||||
|
if got.FileConfigVersion != 0 {
|
||||||
|
t.Fatalf("unexpected file config version: got %d want 0", got.FileConfigVersion)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadBotOptsFileReturnsDecodeError(t *testing.T) {
|
func TestLoadBotOptsFileReturnsDecodeError(t *testing.T) {
|
||||||
@@ -83,7 +88,7 @@ func TestLoadBotOptsFileReturnsDecodeError(t *testing.T) {
|
|||||||
t.Fatalf("WriteFile returned error: %v", err)
|
t.Fatalf("WriteFile returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := LoadBotOptsFile(BotOptsFileJsonCodec{}, filename); err == nil {
|
if _, err := LoadBotOptsFile(BotOptsFileJSONCodec{}, filename); err == nil {
|
||||||
t.Fatal("expected decode error, got nil")
|
t.Fatal("expected decode error, got nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,20 +97,21 @@ func TestSaveBotOptsFileWritesEncodedData(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
filename := filepath.Join(dir, "config.json")
|
filename := filepath.Join(dir, "config.json")
|
||||||
want := &BotOpts{
|
want := &BotOpts{
|
||||||
Token: "TOKEN",
|
Token: "TOKEN",
|
||||||
UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage},
|
UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage},
|
||||||
ErrorTemplate: "Error: %s",
|
ErrorTemplate: "Error: %s",
|
||||||
Prefixes: []string{"/"},
|
Prefixes: []string{"/"},
|
||||||
APIUrl: "https://api.example.invalid",
|
APIURL: "https://api.example.invalid",
|
||||||
RateLimit: 30,
|
RateLimit: 30,
|
||||||
MaxWorkers: 32,
|
MaxWorkers: 32,
|
||||||
|
FileConfigVersion: ConfigVersion,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := SaveBotOptsFile(BotOptsFileJsonCodec{}, filename, want); err != nil {
|
if err := SaveBotOptsFile(BotOptsFileJSONCodec{}, filename, want); err != nil {
|
||||||
t.Fatalf("SaveBotOptsFile returned error: %v", err)
|
t.Fatalf("SaveBotOptsFile returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := LoadBotOptsFile(BotOptsFileJsonCodec{}, filename)
|
got, err := LoadBotOptsFile(BotOptsFileJSONCodec{}, filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadBotOptsFile returned error: %v", err)
|
t.Fatalf("LoadBotOptsFile returned error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -114,3 +120,20 @@ func TestSaveBotOptsFileWritesEncodedData(t *testing.T) {
|
|||||||
t.Fatalf("saved file mismatch:\n got: %#v\nwant: %#v", got, want)
|
t.Fatalf("saved file mismatch:\n got: %#v\nwant: %#v", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadBotOptsFileRejectsFutureConfigVersion(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filename := filepath.Join(dir, "config.json")
|
||||||
|
data := []byte(`{
|
||||||
|
"version": 2,
|
||||||
|
"token": "TOKEN"
|
||||||
|
}`)
|
||||||
|
if err := os.WriteFile(filename, data, 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := LoadBotOptsFile(BotOptsFileJSONCodec{}, filename)
|
||||||
|
if !errors.Is(err, ErrConfigVersionMismatch) {
|
||||||
|
t.Fatalf("expected ErrConfigVersionMismatch, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+11
-8
@@ -28,8 +28,9 @@ func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] {
|
|||||||
}
|
}
|
||||||
cloned := clonePlugin(p)
|
cloned := clonePlugin(p)
|
||||||
if cloned.logger == nil {
|
if cloned.logger == nil {
|
||||||
cloned.logger = utils.CreateLogger(cloned.name, level)
|
cloned.logger = utils.CreateLogger(cloned.name, level, bot.logFormat, bot.logFormatter)
|
||||||
}
|
}
|
||||||
|
bot.addTokenReplacer(cloned.logger)
|
||||||
bot.plugins = append(bot.plugins, cloned)
|
bot.plugins = append(bot.plugins, cloned)
|
||||||
if bot.logger != nil {
|
if bot.logger != nil {
|
||||||
bot.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", cloned.name))
|
bot.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", cloned.name))
|
||||||
@@ -128,7 +129,7 @@ func (bot *Bot[T]) AddRunner(runner Runner[T]) *Bot[T] {
|
|||||||
//
|
//
|
||||||
// Example:
|
// Example:
|
||||||
//
|
//
|
||||||
// bot.AddAppDataLoggerWriter(func(data *MyAppData) slog.LoggerWriter {
|
// bot.AddAppDataLoggerWriter(func(data *MyAppData) sneklog.LoggerWriter {
|
||||||
// return data.QueryLogger()
|
// return data.QueryLogger()
|
||||||
// })
|
// })
|
||||||
func (bot *Bot[T]) AddAppDataLoggerWriter(writer AppDataLogger[T]) *Bot[T] {
|
func (bot *Bot[T]) AddAppDataLoggerWriter(writer AppDataLogger[T]) *Bot[T] {
|
||||||
@@ -141,17 +142,19 @@ func (bot *Bot[T]) AddAppDataLoggerWriter(writer AppDataLogger[T]) *Bot[T] {
|
|||||||
return bot
|
return bot
|
||||||
}
|
}
|
||||||
w := writer(bot.appData)
|
w := writer(bot.appData)
|
||||||
bot.logger.AddWriter(w)
|
bot.logger.AddWriters(w)
|
||||||
if bot.RequestLogger != nil {
|
if bot.requestLogger != nil {
|
||||||
bot.RequestLogger.AddWriter(w)
|
bot.requestLogger.AddWriters(w)
|
||||||
}
|
}
|
||||||
for _, l := range bot.extraLoggers {
|
for _, l := range bot.managedExtraLoggers() {
|
||||||
l.AddWriter(w)
|
l.AddWriters(w)
|
||||||
}
|
}
|
||||||
for _, p := range bot.plugins {
|
for _, p := range bot.plugins {
|
||||||
if p.logger != nil {
|
if p.logger != nil {
|
||||||
p.logger.AddWriter(w)
|
p.logger.AddWriters(w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
bot.addTokenReplacer(bot.logger, bot.requestLogger)
|
||||||
|
bot.addTokenReplacer(bot.managedExtraLoggers()...)
|
||||||
return bot
|
return bot
|
||||||
}
|
}
|
||||||
|
|||||||
+221
-26
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -12,7 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
"git.scuroneko.dev/scuroneko/slog"
|
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type pollingRoundTripFunc func(*http.Request) (*http.Response, error)
|
type pollingRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||||
@@ -23,12 +24,13 @@ func (f pollingRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, erro
|
|||||||
|
|
||||||
type pollingRetryObserver struct {
|
type pollingRetryObserver struct {
|
||||||
recordingObserver
|
recordingObserver
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
|
cancelAfter int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *pollingRetryObserver) OnPollingRetry(ctx context.Context, ev PollingRetryEvent) {
|
func (o *pollingRetryObserver) OnPollingRetry(ctx context.Context, ev PollingRetryEvent) {
|
||||||
o.recordingObserver.OnPollingRetry(ctx, ev)
|
o.recordingObserver.OnPollingRetry(ctx, ev)
|
||||||
if o.cancel != nil {
|
if o.cancel != nil && (o.cancelAfter == 0 || len(o.retries) >= o.cancelAfter) {
|
||||||
o.cancel()
|
o.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,7 +60,7 @@ func TestGetUpdateTypesReturnsCopy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAddPluginsSnapshotsConfiguration(t *testing.T) {
|
func TestAddPluginsSnapshotsConfiguration(t *testing.T) {
|
||||||
bot := &Bot[NoData]{logger: slog.CreateLogger()}
|
bot := &Bot[NoData]{logger: sneklog.NewLogger()}
|
||||||
plugin := NewPlugin[NoData]("demo")
|
plugin := NewPlugin[NoData]("demo")
|
||||||
|
|
||||||
cmd := plugin.NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "start")
|
cmd := plugin.NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "start")
|
||||||
@@ -88,8 +90,8 @@ func TestBotPayloadTypeConfiguration(t *testing.T) {
|
|||||||
if got := bot.GetPayloadType(); got != BotPayloadBase64 {
|
if got := bot.GetPayloadType(); got != BotPayloadBase64 {
|
||||||
t.Fatalf("unexpected initial payload type: %q", got)
|
t.Fatalf("unexpected initial payload type: %q", got)
|
||||||
}
|
}
|
||||||
bot.SetPayloadType(BotPayloadJson)
|
bot.SetPayloadType(BotPayloadJSON)
|
||||||
if got := bot.GetPayloadType(); got != BotPayloadJson {
|
if got := bot.GetPayloadType(); got != BotPayloadJSON {
|
||||||
t.Fatalf("unexpected updated payload type: %q", got)
|
t.Fatalf("unexpected updated payload type: %q", got)
|
||||||
}
|
}
|
||||||
bot.SetStrictPayloadType(true)
|
bot.SetStrictPayloadType(true)
|
||||||
@@ -99,7 +101,7 @@ func TestBotPayloadTypeConfiguration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAddPluginsSkipsNilPlugin(t *testing.T) {
|
func TestAddPluginsSkipsNilPlugin(t *testing.T) {
|
||||||
bot := &Bot[NoData]{logger: slog.CreateLogger()}
|
bot := &Bot[NoData]{logger: sneklog.NewLogger()}
|
||||||
plugin := NewPlugin[NoData]("demo")
|
plugin := NewPlugin[NoData]("demo")
|
||||||
|
|
||||||
bot.AddPlugins(nil, plugin)
|
bot.AddPlugins(nil, plugin)
|
||||||
@@ -125,10 +127,10 @@ func TestInitLoggersFallsBackToStdoutLoggerOnFileError(t *testing.T) {
|
|||||||
if bot.logger == nil {
|
if bot.logger == nil {
|
||||||
t.Fatal("expected main logger fallback")
|
t.Fatal("expected main logger fallback")
|
||||||
}
|
}
|
||||||
if bot.RequestLogger == nil {
|
if bot.requestLogger == nil {
|
||||||
t.Fatal("expected request logger fallback")
|
t.Fatal("expected request logger fallback")
|
||||||
}
|
}
|
||||||
if err := bot.RequestLogger.Close(); err != nil {
|
if err := bot.requestLogger.Close(); err != nil {
|
||||||
t.Fatalf("failed to close request logger: %v", err)
|
t.Fatalf("failed to close request logger: %v", err)
|
||||||
}
|
}
|
||||||
if err := bot.logger.Close(); err != nil {
|
if err := bot.logger.Close(); err != nil {
|
||||||
@@ -136,6 +138,124 @@ func TestInitLoggersFallsBackToStdoutLoggerOnFileError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInitLoggersAppliesTokenReplacerToFileLoggers(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
api := tgapi.NewAPI(tgapi.NewAPIOpts("secret-token"))
|
||||||
|
uploader := tgapi.NewUploader(api)
|
||||||
|
bot := &Bot[NoData]{token: "secret-token", api: api, uploader: uploader}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := uploader.Close(); err != nil {
|
||||||
|
t.Fatalf("failed to close uploader: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := api.Close(); err != nil {
|
||||||
|
t.Fatalf("failed to close api: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
bot.initLoggers(&BotOpts{
|
||||||
|
Debug: true,
|
||||||
|
WriteToFile: true,
|
||||||
|
UseRequestLogger: true,
|
||||||
|
LoggerBasePath: tempDir,
|
||||||
|
})
|
||||||
|
|
||||||
|
apiPath := filepath.Join(tempDir, "api.log")
|
||||||
|
apiFile, err := os.OpenFile(apiPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open api log: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = apiFile.Close() }()
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open uploader log: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = uploaderFile.Close() }()
|
||||||
|
bot.uploader.GetLogger().AddWriters(bot.uploader.GetLogger().CreateTextWriter(uploaderFile))
|
||||||
|
|
||||||
|
bot.logger.Infoln("main secret-token")
|
||||||
|
bot.requestLogger.Infoln("request secret-token")
|
||||||
|
bot.api.GetLogger().Infoln("api secret-token")
|
||||||
|
bot.uploader.GetLogger().Infoln("uploader secret-token")
|
||||||
|
|
||||||
|
if err := bot.requestLogger.Close(); err != nil {
|
||||||
|
t.Fatalf("failed to close request logger: %v", err)
|
||||||
|
}
|
||||||
|
if err := bot.logger.Close(); err != nil {
|
||||||
|
t.Fatalf("failed to close main logger: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mainLog, err := os.ReadFile(filepath.Join(tempDir, "main.log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read main log: %v", err)
|
||||||
|
}
|
||||||
|
requestLog, err := os.ReadFile(filepath.Join(tempDir, "requests.log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read request log: %v", err)
|
||||||
|
}
|
||||||
|
apiLog, err := os.ReadFile(apiPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read api log: %v", err)
|
||||||
|
}
|
||||||
|
uploaderLog, err := os.ReadFile(uploaderPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read uploader log: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
data string
|
||||||
|
}{
|
||||||
|
{name: "main", data: string(mainLog)},
|
||||||
|
{name: "request", data: string(requestLog)},
|
||||||
|
{name: "api", data: string(apiLog)},
|
||||||
|
{name: "uploader", data: string(uploaderLog)},
|
||||||
|
} {
|
||||||
|
if strings.Contains(tt.data, "secret-token") {
|
||||||
|
t.Fatalf("%s log leaked raw token: %q", tt.name, tt.data)
|
||||||
|
}
|
||||||
|
if !strings.Contains(tt.data, "<TOKEN>") {
|
||||||
|
t.Fatalf("%s log did not contain masked token: %q", tt.name, tt.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddPluginsAppliesTokenReplacerToPluginLogger(t *testing.T) {
|
||||||
|
bot := &Bot[NoData]{
|
||||||
|
token: "secret-token",
|
||||||
|
logger: sneklog.NewLogger(),
|
||||||
|
}
|
||||||
|
defer func() { _ = bot.logger.Close() }()
|
||||||
|
|
||||||
|
plugin := NewPlugin[NoData]("demo")
|
||||||
|
bot.AddPlugins(plugin)
|
||||||
|
|
||||||
|
logPath := filepath.Join(t.TempDir(), "plugin.log")
|
||||||
|
file, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open plugin log: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
|
bot.plugins[0].logger.AddWriters(bot.plugins[0].logger.CreateTextWriter(file))
|
||||||
|
bot.plugins[0].logger.Infoln("plugin secret-token")
|
||||||
|
|
||||||
|
data, err := os.ReadFile(logPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read plugin log: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "secret-token") {
|
||||||
|
t.Fatalf("plugin log leaked raw token: %q", string(data))
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "<TOKEN>") {
|
||||||
|
t.Fatalf("plugin log did not contain masked token: %q", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNextPollRetryDelay(t *testing.T) {
|
func TestNextPollRetryDelay(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -157,10 +277,10 @@ func TestNextPollRetryDelay(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsUnset(t *testing.T) {
|
func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsUnset(t *testing.T) {
|
||||||
bot := &Bot[NoData]{logger: slog.CreateLogger()}
|
bot := &Bot[NoData]{logger: sneklog.NewLogger()}
|
||||||
called := false
|
called := false
|
||||||
|
|
||||||
bot.AddAppDataLoggerWriter(func(db NoData) slog.LoggerWriter {
|
bot.AddAppDataLoggerWriter(func(db NoData) sneklog.LoggerWriter {
|
||||||
called = true
|
called = true
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -173,12 +293,12 @@ func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsUnset(t *testing.T) {
|
|||||||
func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsNil(t *testing.T) {
|
func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsNil(t *testing.T) {
|
||||||
type testDB struct{}
|
type testDB struct{}
|
||||||
|
|
||||||
bot := &Bot[*testDB]{logger: slog.CreateLogger()}
|
bot := &Bot[*testDB]{logger: sneklog.NewLogger()}
|
||||||
var db *testDB
|
var db *testDB
|
||||||
bot.SetAppData(db)
|
bot.SetAppData(db)
|
||||||
|
|
||||||
called := false
|
called := false
|
||||||
bot.AddAppDataLoggerWriter(func(db *testDB) slog.LoggerWriter {
|
bot.AddAppDataLoggerWriter(func(db *testDB) sneklog.LoggerWriter {
|
||||||
called = true
|
called = true
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -217,13 +337,13 @@ func TestShouldWarnOnValueAppData(t *testing.T) {
|
|||||||
func TestSetAppDataMarksValueWarningOnce(t *testing.T) {
|
func TestSetAppDataMarksValueWarningOnce(t *testing.T) {
|
||||||
type testDB struct{}
|
type testDB struct{}
|
||||||
|
|
||||||
bot := &Bot[testDB]{logger: slog.CreateLogger()}
|
bot := &Bot[testDB]{logger: sneklog.NewLogger()}
|
||||||
bot.SetAppData(testDB{})
|
bot.SetAppData(testDB{})
|
||||||
if !bot.warnedValueData {
|
if !bot.warnedValueData {
|
||||||
t.Fatal("expected value-typed app data to mark warning state")
|
t.Fatal("expected value-typed app data to mark warning state")
|
||||||
}
|
}
|
||||||
|
|
||||||
ptrBot := &Bot[*testDB]{logger: slog.CreateLogger()}
|
ptrBot := &Bot[*testDB]{logger: sneklog.NewLogger()}
|
||||||
ptrBot.SetAppData(&testDB{})
|
ptrBot.SetAppData(&testDB{})
|
||||||
if ptrBot.warnedValueData {
|
if ptrBot.warnedValueData {
|
||||||
t.Fatal("did not expect pointer-typed app data to mark warning state")
|
t.Fatal("did not expect pointer-typed app data to mark warning state")
|
||||||
@@ -231,7 +351,7 @@ func TestSetAppDataMarksValueWarningOnce(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSetObserverAndGetObserver(t *testing.T) {
|
func TestSetObserverAndGetObserver(t *testing.T) {
|
||||||
bot := &Bot[NoData]{logger: slog.CreateLogger()}
|
bot := &Bot[NoData]{logger: sneklog.NewLogger()}
|
||||||
observer := testObserver{}
|
observer := testObserver{}
|
||||||
|
|
||||||
if got := bot.GetObserver(); got != nil {
|
if got := bot.GetObserver(); got != nil {
|
||||||
@@ -245,7 +365,7 @@ func TestSetObserverAndGetObserver(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSetObserverNilClearsObserver(t *testing.T) {
|
func TestSetObserverNilClearsObserver(t *testing.T) {
|
||||||
bot := &Bot[NoData]{logger: slog.CreateLogger()}
|
bot := &Bot[NoData]{logger: sneklog.NewLogger()}
|
||||||
bot.SetObserver(testObserver{})
|
bot.SetObserver(testObserver{})
|
||||||
|
|
||||||
if bot.GetObserver() == nil {
|
if bot.GetObserver() == nil {
|
||||||
@@ -263,7 +383,7 @@ func TestRunWithContextRejectsSecondRun(t *testing.T) {
|
|||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
prefixes: []string{"/"},
|
prefixes: []string{"/"},
|
||||||
plugins: []Plugin[NoData]{{name: "demo"}},
|
plugins: []Plugin[NoData]{{name: "demo"}},
|
||||||
updateQueue: make(chan *tgapi.Update, 1),
|
updateQueue: make(chan *tgapi.Update, 1),
|
||||||
@@ -278,6 +398,32 @@ func TestRunWithContextRejectsSecondRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunWithContextKeepsEnabledRequestLogger(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
requestLogger := sneklog.NewLogger()
|
||||||
|
bot := &Bot[NoData]{
|
||||||
|
logger: sneklog.NewLogger(),
|
||||||
|
requestLogger: requestLogger,
|
||||||
|
useReqLogger: true,
|
||||||
|
prefixes: []string{"/"},
|
||||||
|
plugins: []Plugin[NoData]{{name: "demo"}},
|
||||||
|
updateQueue: make(chan *tgapi.Update, 1),
|
||||||
|
maxWorkers: 1,
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = bot.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := bot.RunWithContext(ctx); err != nil {
|
||||||
|
t.Fatalf("RunWithContext returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got := bot.GetRequestLogger(); got != requestLogger {
|
||||||
|
t.Fatalf("expected enabled request logger to be preserved, got %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCloseDoesNotDeleteWebhook(t *testing.T) {
|
func TestCloseDoesNotDeleteWebhook(t *testing.T) {
|
||||||
requests := 0
|
requests := 0
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
@@ -293,14 +439,14 @@ func TestCloseDoesNotDeleteWebhook(t *testing.T) {
|
|||||||
|
|
||||||
api := tgapi.NewAPI(
|
api := tgapi.NewAPI(
|
||||||
tgapi.NewAPIOpts("token").
|
tgapi.NewAPIOpts("token").
|
||||||
SetAPIUrl("http://example.invalid").
|
SetAPIURL("http://example.invalid").
|
||||||
SetHTTPClient(client),
|
SetHTTPClient(client),
|
||||||
)
|
)
|
||||||
uploader := tgapi.NewUploader(api)
|
uploader := tgapi.NewUploader(api)
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
webHookLogger: slog.CreateLogger(),
|
webHookLogger: sneklog.NewLogger(),
|
||||||
api: api,
|
api: api,
|
||||||
uploader: uploader,
|
uploader: uploader,
|
||||||
}
|
}
|
||||||
@@ -330,7 +476,7 @@ func TestRunWithContextEmitsPollingRetryAndErrorEvents(t *testing.T) {
|
|||||||
|
|
||||||
api := tgapi.NewAPI(
|
api := tgapi.NewAPI(
|
||||||
tgapi.NewAPIOpts("token").
|
tgapi.NewAPIOpts("token").
|
||||||
SetAPIUrl("http://example.invalid").
|
SetAPIURL("http://example.invalid").
|
||||||
SetHTTPClient(client),
|
SetHTTPClient(client),
|
||||||
)
|
)
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -338,7 +484,7 @@ func TestRunWithContextEmitsPollingRetryAndErrorEvents(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
api: api,
|
api: api,
|
||||||
prefixes: []string{"/"},
|
prefixes: []string{"/"},
|
||||||
plugins: []Plugin[NoData]{{name: "demo"}},
|
plugins: []Plugin[NoData]{{name: "demo"}},
|
||||||
@@ -365,12 +511,61 @@ func TestRunWithContextEmitsPollingRetryAndErrorEvents(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunWithContextPreservesPollingRetryBackoff(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
observer := &pollingRetryObserver{cancel: cancel, cancelAfter: 2}
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: pollingRoundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{"ok":false,"error_code":500,"description":"boom"}`)),
|
||||||
|
}, nil
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
api := tgapi.NewAPI(
|
||||||
|
tgapi.NewAPIOpts("token").
|
||||||
|
SetAPIURL("http://example.invalid").
|
||||||
|
SetHTTPClient(client),
|
||||||
|
)
|
||||||
|
defer func() {
|
||||||
|
_ = api.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
bot := &Bot[NoData]{
|
||||||
|
logger: sneklog.NewLogger(),
|
||||||
|
api: api,
|
||||||
|
prefixes: []string{"/"},
|
||||||
|
plugins: []Plugin[NoData]{{name: "demo"}},
|
||||||
|
updateQueue: make(chan *tgapi.Update, 1),
|
||||||
|
maxWorkers: 1,
|
||||||
|
observer: observer,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bot.RunWithContext(ctx); err != nil {
|
||||||
|
t.Fatalf("RunWithContext returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(observer.retries) != 2 {
|
||||||
|
t.Fatalf("expected two polling retry events, got %d", len(observer.retries))
|
||||||
|
}
|
||||||
|
if got := observer.retries[0]; got.Attempt != 1 || got.Delay != time.Second {
|
||||||
|
t.Fatalf("unexpected first retry event: %#v", got)
|
||||||
|
}
|
||||||
|
if got := observer.retries[1]; got.Attempt != 2 || got.Delay != 2*time.Second {
|
||||||
|
t.Fatalf("unexpected second retry event: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) {
|
func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) {
|
||||||
type testDB struct{ Name string }
|
type testDB struct{ Name string }
|
||||||
|
|
||||||
makeBot := func() *Bot[*testDB] {
|
makeBot := func() *Bot[*testDB] {
|
||||||
return &Bot[*testDB]{
|
return &Bot[*testDB]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
prefixes: []string{"/"},
|
prefixes: []string{"/"},
|
||||||
updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage},
|
updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage},
|
||||||
payloadType: BotPayloadBase64,
|
payloadType: BotPayloadBase64,
|
||||||
@@ -442,7 +637,7 @@ func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Cleanup(bot.finishRun)
|
t.Cleanup(bot.finishRun)
|
||||||
|
|
||||||
bot.SetPayloadType(BotPayloadJson)
|
bot.SetPayloadType(BotPayloadJSON)
|
||||||
if bot.payloadType != BotPayloadBase64 {
|
if bot.payloadType != BotPayloadBase64 {
|
||||||
t.Fatalf("payloadType mutated after configuration freeze: got %q want %q", bot.payloadType, BotPayloadBase64)
|
t.Fatalf("payloadType mutated after configuration freeze: got %q want %q", bot.payloadType, BotPayloadBase64)
|
||||||
}
|
}
|
||||||
@@ -562,7 +757,7 @@ func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) {
|
|||||||
|
|
||||||
func TestAddPluginsAndRuntimeRegistrationsNoOpAfterRunStarts(t *testing.T) {
|
func TestAddPluginsAndRuntimeRegistrationsNoOpAfterRunStarts(t *testing.T) {
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
prefixes: []string{"/"},
|
prefixes: []string{"/"},
|
||||||
middlewares: []Middleware[NoData]{NewMiddleware("base", func(ctx *MsgContext, db NoData) bool { return true })},
|
middlewares: []Middleware[NoData]{NewMiddleware("base", func(ctx *MsgContext, db NoData) bool { return true })},
|
||||||
runners: []Runner[NoData]{NewRunner("base", func(bot *Bot[NoData]) error { return nil })},
|
runners: []Runner[NoData]{NewRunner("base", func(bot *Bot[NoData]) error { return nil })},
|
||||||
|
|||||||
+64
-25
@@ -5,16 +5,51 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/extypes"
|
"git.scuroneko.dev/scuroneko/extypes"
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/utils"
|
"git.scuroneko.dev/scuroneko/laniakea/utils"
|
||||||
"git.scuroneko.dev/scuroneko/slog"
|
|
||||||
|
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
||||||
"github.com/alitto/pond/v2"
|
"github.com/alitto/pond/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (bot *Bot[T]) addTokenReplacer(loggers ...*sneklog.Logger) {
|
||||||
|
if bot.token == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, logger := range loggers {
|
||||||
|
if logger == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logger.AddReplacer(bot.token, "<TOKEN>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendUniqueLogger(loggers []*sneklog.Logger, logger *sneklog.Logger) []*sneklog.Logger {
|
||||||
|
if logger == nil {
|
||||||
|
return loggers
|
||||||
|
}
|
||||||
|
if slices.Contains(loggers, logger) {
|
||||||
|
return loggers
|
||||||
|
}
|
||||||
|
return append(loggers, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bot *Bot[T]) managedExtraLoggers() []*sneklog.Logger {
|
||||||
|
loggers := append([]*sneklog.Logger(nil), bot.extraLoggers...)
|
||||||
|
if bot.api != nil {
|
||||||
|
loggers = appendUniqueLogger(loggers, bot.api.GetLogger())
|
||||||
|
}
|
||||||
|
if bot.uploader != nil {
|
||||||
|
loggers = appendUniqueLogger(loggers, bot.uploader.GetLogger())
|
||||||
|
}
|
||||||
|
return loggers
|
||||||
|
}
|
||||||
|
|
||||||
func (bot *Bot[T]) enqueueUpdate(ctx context.Context, update tgapi.Update) error {
|
func (bot *Bot[T]) enqueueUpdate(ctx context.Context, update tgapi.Update) error {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -36,34 +71,40 @@ func (bot *Bot[T]) startUpdateWorkers(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (bot *Bot[T]) initLoggers(opts *BotOpts) {
|
func (bot *Bot[T]) initLoggers(opts *BotOpts) {
|
||||||
level := slog.FATAL
|
level := sneklog.FATAL
|
||||||
if opts.Debug {
|
if opts.Debug {
|
||||||
level = slog.DEBUG
|
level = sneklog.DEBUG
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.logger = utils.CreateLogger("BOT", level).AddReplacer(bot.token, "<TOKEN>")
|
format, formatter := opts.LogFormat, opts.LogFormatter
|
||||||
if opts.WriteToFile {
|
if bot.logger == nil {
|
||||||
path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/"))
|
bot.logger = utils.CreateLogger("BOT", level, format, formatter)
|
||||||
logger, err := utils.CreateFileLogger("BOT", level, path)
|
|
||||||
if err != nil {
|
|
||||||
bot.logger.Errorln(err)
|
|
||||||
} else {
|
|
||||||
bot.logger = logger
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.UseRequestLogger {
|
|
||||||
bot.RequestLogger = utils.CreateLogger("REQUESTS", level).AddReplacer(bot.token, "<TOKEN>")
|
|
||||||
if opts.WriteToFile {
|
if opts.WriteToFile {
|
||||||
path := fmt.Sprintf("%s/requests.log", strings.TrimRight(opts.LoggerBasePath, "/"))
|
path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/"))
|
||||||
logger, err := utils.CreateFileLogger("REQUESTS", level, path)
|
logger, err := utils.CreateFileLogger("BOT", level, path, format, formatter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
bot.logger.Errorln(err)
|
bot.logger.Errorln(err)
|
||||||
} else {
|
} else {
|
||||||
bot.RequestLogger = logger
|
bot.logger = logger
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opts.UseRequestLogger && bot.requestLogger == nil {
|
||||||
|
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, format, formatter)
|
||||||
|
if err != nil {
|
||||||
|
bot.logger.Errorln(err)
|
||||||
|
} else {
|
||||||
|
bot.requestLogger = logger
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.addTokenReplacer(bot.logger, bot.requestLogger)
|
||||||
|
bot.addTokenReplacer(bot.managedExtraLoggers()...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bot *Bot[T]) beginRun() error {
|
func (bot *Bot[T]) beginRun() error {
|
||||||
@@ -167,13 +208,11 @@ func cloneScene[T AppData](scene *Scene[T]) *Scene[T] {
|
|||||||
cloned := *scene
|
cloned := *scene
|
||||||
cloned.steps = make(map[string]SceneHandler[T], len(scene.steps))
|
cloned.steps = make(map[string]SceneHandler[T], len(scene.steps))
|
||||||
cloned.commands = make(map[string]SceneHandler[T], len(scene.commands))
|
cloned.commands = make(map[string]SceneHandler[T], len(scene.commands))
|
||||||
|
cloned.payloads = make(map[string]SceneHandler[T], len(scene.payloads))
|
||||||
|
|
||||||
for name, handler := range scene.steps {
|
maps.Copy(cloned.steps, scene.steps)
|
||||||
cloned.steps[name] = handler
|
maps.Copy(cloned.commands, scene.commands)
|
||||||
}
|
maps.Copy(cloned.payloads, scene.payloads)
|
||||||
for name, handler := range scene.commands {
|
|
||||||
cloned.commands[name] = handler
|
|
||||||
}
|
|
||||||
|
|
||||||
return &cloned
|
return &cloned
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -152,7 +152,8 @@ func (bot *Bot[T]) RunWebHookWithContext(ctx context.Context, opts *BotWebHookOp
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.webHookLogger = utils.CreateLogger("WEBHOOK", bot.GetLoggerLevel())
|
bot.webHookLogger = utils.CreateLogger("WEBHOOK", bot.GetLoggerLevel(), bot.logFormat, bot.logFormatter)
|
||||||
|
bot.addTokenReplacer(bot.webHookLogger)
|
||||||
if opts.SecretToken == "" {
|
if opts.SecretToken == "" {
|
||||||
bot.webHookLogger.Warnln("Bot webhook secret token empty. It's VERY recommended to set secret.")
|
bot.webHookLogger.Warnln("Bot webhook secret token empty. It's VERY recommended to set secret.")
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-7
@@ -11,7 +11,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
"git.scuroneko.dev/scuroneko/slog"
|
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEnqueueUpdateCopiesValue(t *testing.T) {
|
func TestEnqueueUpdateCopiesValue(t *testing.T) {
|
||||||
@@ -35,7 +35,7 @@ func TestEnqueueUpdateCopiesValue(t *testing.T) {
|
|||||||
func TestUpdateHandlerEnqueuesUpdate(t *testing.T) {
|
func TestUpdateHandlerEnqueuesUpdate(t *testing.T) {
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
updateQueue: make(chan *tgapi.Update, 1),
|
updateQueue: make(chan *tgapi.Update, 1),
|
||||||
webHookLogger: slog.CreateLogger(),
|
webHookLogger: sneklog.NewLogger(),
|
||||||
}
|
}
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
_ = bot.webHookLogger.Close()
|
_ = bot.webHookLogger.Close()
|
||||||
@@ -66,7 +66,7 @@ func TestUpdateHandlerEnqueuesUpdate(t *testing.T) {
|
|||||||
|
|
||||||
func TestRunWebhookRuntimeRejectsSecondRun(t *testing.T) {
|
func TestRunWebhookRuntimeRejectsSecondRun(t *testing.T) {
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
updateQueue: make(chan *tgapi.Update, 1),
|
updateQueue: make(chan *tgapi.Update, 1),
|
||||||
maxWorkers: 1,
|
maxWorkers: 1,
|
||||||
}
|
}
|
||||||
@@ -86,7 +86,7 @@ func TestRunWebhookRuntimeExecutesRunners(t *testing.T) {
|
|||||||
var calls atomic.Int32
|
var calls atomic.Int32
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
updateQueue: make(chan *tgapi.Update, 1),
|
updateQueue: make(chan *tgapi.Update, 1),
|
||||||
maxWorkers: 1,
|
maxWorkers: 1,
|
||||||
runners: []Runner[NoData]{
|
runners: []Runner[NoData]{
|
||||||
@@ -185,7 +185,7 @@ func TestValidateWebhookTLSFiles(t *testing.T) {
|
|||||||
func TestUpdateHandlerRejectsOversizedBody(t *testing.T) {
|
func TestUpdateHandlerRejectsOversizedBody(t *testing.T) {
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
updateQueue: make(chan *tgapi.Update, 1),
|
updateQueue: make(chan *tgapi.Update, 1),
|
||||||
webHookLogger: slog.CreateLogger(),
|
webHookLogger: sneklog.NewLogger(),
|
||||||
}
|
}
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
_ = bot.webHookLogger.Close()
|
_ = bot.webHookLogger.Close()
|
||||||
@@ -213,7 +213,7 @@ func TestStatusHandlerRequiresMatchingSecret(t *testing.T) {
|
|||||||
}
|
}
|
||||||
api := tgapi.NewAPI(
|
api := tgapi.NewAPI(
|
||||||
tgapi.NewAPIOpts("token").
|
tgapi.NewAPIOpts("token").
|
||||||
SetAPIUrl("http://example.invalid").
|
SetAPIURL("http://example.invalid").
|
||||||
SetHTTPClient(client),
|
SetHTTPClient(client),
|
||||||
)
|
)
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -222,7 +222,7 @@ func TestStatusHandlerRequiresMatchingSecret(t *testing.T) {
|
|||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
api: api,
|
api: api,
|
||||||
webHookLogger: slog.CreateLogger(),
|
webHookLogger: sneklog.NewLogger(),
|
||||||
}
|
}
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
_ = bot.webHookLogger.Close()
|
_ = bot.webHookLogger.Close()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
"git.scuroneko.dev/scuroneko/slog"
|
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||||
@@ -34,7 +34,7 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
api := tgapi.NewAPI(
|
api := tgapi.NewAPI(
|
||||||
tgapi.NewAPIOpts("token").
|
tgapi.NewAPIOpts("token").
|
||||||
SetAPIUrl("https://example.test").
|
SetAPIURL("https://example.test").
|
||||||
SetHTTPClient(client),
|
SetHTTPClient(client),
|
||||||
)
|
)
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -51,7 +51,7 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) {
|
|||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
api: api,
|
api: api,
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
plugins: []Plugin[NoData]{*plugin},
|
plugins: []Plugin[NoData]{*plugin},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,29 +9,29 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Interface for generating unique draft IDs.
|
// Interface for generating unique draft IDs.
|
||||||
type draftIdGenerator interface {
|
type draftIDGenerator interface {
|
||||||
// Next returns the next unique draft ID.
|
// Next returns the next unique draft ID.
|
||||||
Next() uint64
|
Next() uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
// RandomDraftIdGenerator generates draft IDs using cryptographically secure random numbers.
|
// RandomDraftIDGenerator generates draft IDs using cryptographically secure random numbers.
|
||||||
// Suitable for distributed systems or when ID predictability is undesirable.
|
// Suitable for distributed systems or when ID predictability is undesirable.
|
||||||
type RandomDraftIdGenerator struct{}
|
type RandomDraftIDGenerator struct{}
|
||||||
|
|
||||||
// Next returns a random 64-bit unsigned integer.
|
// Next returns a random 64-bit unsigned integer.
|
||||||
func (g *RandomDraftIdGenerator) Next() uint64 {
|
func (g *RandomDraftIDGenerator) Next() uint64 {
|
||||||
return rand.Uint64()
|
return rand.Uint64()
|
||||||
}
|
}
|
||||||
|
|
||||||
// LinearDraftIdGenerator generates draft IDs using a monotonically increasing counter.
|
// LinearDraftIDGenerator generates draft IDs using a monotonically increasing counter.
|
||||||
// Useful for debugging, persistence, or when drafts must be ordered.
|
// Useful for debugging, persistence, or when drafts must be ordered.
|
||||||
type LinearDraftIdGenerator struct {
|
type LinearDraftIDGenerator struct {
|
||||||
lastId atomic.Uint64
|
lastID atomic.Uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next returns the next linear ID, atomically incremented.
|
// Next returns the next linear ID, atomically incremented.о
|
||||||
func (g *LinearDraftIdGenerator) Next() uint64 {
|
func (g *LinearDraftIDGenerator) Next() uint64 {
|
||||||
return g.lastId.Add(1)
|
return g.lastID.Add(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DraftProvider manages a collection of Drafts and a shared draft ID generator.
|
// DraftProvider manages a collection of Drafts and a shared draft ID generator.
|
||||||
@@ -41,7 +41,7 @@ type DraftProvider struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
api *tgapi.API
|
api *tgapi.API
|
||||||
drafts map[uint64]*Draft
|
drafts map[uint64]*Draft
|
||||||
generator draftIdGenerator
|
generator draftIDGenerator
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRandomDraftProvider creates a new DraftProvider using random draft IDs.
|
// NewRandomDraftProvider creates a new DraftProvider using random draft IDs.
|
||||||
@@ -50,7 +50,7 @@ type DraftProvider struct {
|
|||||||
// All drafts created via this provider will have unpredictable, unique IDs.
|
// All drafts created via this provider will have unpredictable, unique IDs.
|
||||||
func NewRandomDraftProvider(api *tgapi.API) *DraftProvider {
|
func NewRandomDraftProvider(api *tgapi.API) *DraftProvider {
|
||||||
return &DraftProvider{
|
return &DraftProvider{
|
||||||
api: api, generator: &RandomDraftIdGenerator{},
|
api: api, generator: &RandomDraftIDGenerator{},
|
||||||
drafts: make(map[uint64]*Draft),
|
drafts: make(map[uint64]*Draft),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,8 +63,8 @@ func NewRandomDraftProvider(api *tgapi.API) *DraftProvider {
|
|||||||
// This is useful when you need to store draft IDs externally (e.g., in a database)
|
// This is useful when you need to store draft IDs externally (e.g., in a database)
|
||||||
// and want to reconstruct drafts after restart.
|
// and want to reconstruct drafts after restart.
|
||||||
func NewLinearDraftProvider(api *tgapi.API, startValue uint64) *DraftProvider {
|
func NewLinearDraftProvider(api *tgapi.API, startValue uint64) *DraftProvider {
|
||||||
g := &LinearDraftIdGenerator{}
|
g := &LinearDraftIDGenerator{}
|
||||||
g.lastId.Store(startValue)
|
g.lastID.Store(startValue)
|
||||||
return &DraftProvider{
|
return &DraftProvider{
|
||||||
api: api,
|
api: api,
|
||||||
generator: g,
|
generator: g,
|
||||||
|
|||||||
+4
-4
@@ -6,25 +6,25 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
"git.scuroneko.dev/scuroneko/slog"
|
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDraftFlushRequiresChatID(t *testing.T) {
|
func TestDraftFlushRequiresChatID(t *testing.T) {
|
||||||
draft := NewRandomDraftProvider(&tgapi.API{}).NewDraft(tgapi.ParseNone)
|
draft := NewRandomDraftProvider(&tgapi.API{}).NewDraft(tgapi.ParseNone)
|
||||||
draft.Message = "hello"
|
draft.Message = "hello"
|
||||||
|
|
||||||
if err := draft.Flush(); err != ErrDraftChatIDZero {
|
if err := draft.Flush(); !errors.Is(err, ErrDraftChatIDZero) {
|
||||||
t.Fatalf("expected ErrDraftChatIDZero, got %v", err)
|
t.Fatalf("expected ErrDraftChatIDZero, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMsgContextNewDraftWorksWithoutLimiter(t *testing.T) {
|
func TestMsgContextNewDraftWorksWithoutLimiter(t *testing.T) {
|
||||||
ctx := &MsgContext{
|
ctx := &MsgContext{
|
||||||
Api: &tgapi.API{},
|
API: &tgapi.API{},
|
||||||
Msg: &tgapi.Message{
|
Msg: &tgapi.Message{
|
||||||
Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate},
|
Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate},
|
||||||
},
|
},
|
||||||
Logger: slog.CreateLogger(),
|
Logger: sneklog.NewLogger(),
|
||||||
draftProvider: NewRandomDraftProvider(&tgapi.API{}),
|
draftProvider: NewRandomDraftProvider(&tgapi.API{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,7 @@ retract v1.0.0-rc.5
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
git.scuroneko.dev/scuroneko/extypes v1.2.3
|
git.scuroneko.dev/scuroneko/extypes v1.2.3
|
||||||
git.scuroneko.dev/scuroneko/slog v1.2.0
|
git.scuroneko.dev/scuroneko/sneklog/v2 v2.3.0
|
||||||
github.com/alitto/pond/v2 v2.7.0
|
github.com/alitto/pond/v2 v2.7.1
|
||||||
golang.org/x/time v0.15.0
|
golang.org/x/time v0.15.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/fatih/color v1.19.0 // indirect
|
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
git.scuroneko.dev/scuroneko/extypes v1.2.3 h1:n7QsfTZEn9fJNZLXGH/LkNq4cADaRk+LTu6LNMv9y6s=
|
git.scuroneko.dev/scuroneko/extypes v1.2.3 h1:n7QsfTZEn9fJNZLXGH/LkNq4cADaRk+LTu6LNMv9y6s=
|
||||||
git.scuroneko.dev/scuroneko/extypes v1.2.3/go.mod h1:MhYpXC6sloLOpoM2guf64eSOrz+ET/QJZ8toobc3Ors=
|
git.scuroneko.dev/scuroneko/extypes v1.2.3/go.mod h1:MhYpXC6sloLOpoM2guf64eSOrz+ET/QJZ8toobc3Ors=
|
||||||
git.scuroneko.dev/scuroneko/slog v1.2.0 h1:xbwzrMcmN0NG/zTgEn508mn2JVnfZN5z/Zsi3PREfDM=
|
git.scuroneko.dev/scuroneko/sneklog/v2 v2.3.0 h1:gaPe5azwuDTh48jRB/P2FUgOs7f1ToNr0S+NBizKvY8=
|
||||||
git.scuroneko.dev/scuroneko/slog v1.2.0/go.mod h1:r+oz9NzvvdtWd9/PjeS+n5vQoNHL38BdcdLoBtJPvFU=
|
git.scuroneko.dev/scuroneko/sneklog/v2 v2.3.0/go.mod h1:q8XnLXzLdGjW0Jtcbh9/+G9WmfD68rsPQvLXEPxvum4=
|
||||||
github.com/alitto/pond/v2 v2.7.0 h1:c76L+yN916m/DRXjGCeUBHHu92uWnh/g1bwVk4zyyXg=
|
github.com/alitto/pond/v2 v2.7.1 h1:QxMbcfjcVTa0pyxX5Ib1226mM8u8D7gKUVkCUU4DYIw=
|
||||||
github.com/alitto/pond/v2 v2.7.0/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE=
|
github.com/alitto/pond/v2 v2.7.1/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE=
|
||||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
|
||||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
|
||||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
|
||||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
|
|||||||
+12
-12
@@ -26,7 +26,7 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
msgCtx := &MsgContext{
|
msgCtx := &MsgContext{
|
||||||
Update: *u, Api: bot.api,
|
Update: *u, API: bot.api,
|
||||||
Logger: bot.logger,
|
Logger: bot.logger,
|
||||||
errorTemplate: bot.errorTemplate,
|
errorTemplate: bot.errorTemplate,
|
||||||
l10n: bot.l10n,
|
l10n: bot.l10n,
|
||||||
@@ -113,7 +113,7 @@ func cloneMsgContext(src *MsgContext) *MsgContext {
|
|||||||
return &cloned
|
return &cloned
|
||||||
}
|
}
|
||||||
|
|
||||||
func encodeJsonPayload(d CallbackData) (string, error) {
|
func encodeJSONPayload(d CallbackData) (string, error) {
|
||||||
b, err := json.Marshal(d)
|
b, err := json.Marshal(d)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -121,14 +121,14 @@ func encodeJsonPayload(d CallbackData) (string, error) {
|
|||||||
return string(b), nil
|
return string(b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeJsonPayload(s string) (CallbackData, error) {
|
func decodeJSONPayload(s string) (CallbackData, error) {
|
||||||
var data CallbackData
|
var data CallbackData
|
||||||
err := json.Unmarshal([]byte(s), &data)
|
err := json.Unmarshal([]byte(s), &data)
|
||||||
return data, err
|
return data, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func encodeBase64Payload(d CallbackData) (string, error) {
|
func encodeBase64Payload(d CallbackData) (string, error) {
|
||||||
data, err := encodeJsonPayload(d)
|
data, err := encodeJSONPayload(d)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -142,7 +142,7 @@ func decodeBase64Payload(s string) (CallbackData, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return CallbackData{}, err
|
return CallbackData{}, err
|
||||||
}
|
}
|
||||||
return decodeJsonPayload(string(b))
|
return decodeJSONPayload(string(b))
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackData, BotPayloadType, error) {
|
func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackData, BotPayloadType, error) {
|
||||||
@@ -155,18 +155,18 @@ func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackD
|
|||||||
if strict {
|
if strict {
|
||||||
return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadBase64)
|
return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadBase64)
|
||||||
}
|
}
|
||||||
data, err = decodeJsonPayload(s)
|
data, err = decodeJSONPayload(s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CallbackData{}, "", err
|
return CallbackData{}, "", err
|
||||||
}
|
}
|
||||||
return data, BotPayloadJson, nil
|
return data, BotPayloadJSON, nil
|
||||||
case BotPayloadJson:
|
case BotPayloadJSON:
|
||||||
data, err := decodeJsonPayload(s)
|
data, err := decodeJSONPayload(s)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return data, BotPayloadJson, nil
|
return data, BotPayloadJSON, nil
|
||||||
}
|
}
|
||||||
if strict {
|
if strict {
|
||||||
return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadJson)
|
return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadJSON)
|
||||||
}
|
}
|
||||||
data, err = decodeBase64Payload(s)
|
data, err = decodeBase64Payload(s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -183,7 +183,7 @@ func (bot *Bot[T]) decodePayload(s string) (CallbackData, error) {
|
|||||||
return CallbackData{}, err
|
return CallbackData{}, err
|
||||||
}
|
}
|
||||||
if decodedType == BotPayloadBase64 && bot.debug && bot.logger != nil {
|
if decodedType == BotPayloadBase64 && bot.debug && bot.logger != nil {
|
||||||
bot.logger.Debugf("decoded callback payload base64->json: raw=%q json=%s", s, data.ToJson())
|
bot.logger.Debugf("decoded callback payload base64->json: raw=%q json=%s", s, data.ToJSON())
|
||||||
}
|
}
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-54
@@ -6,7 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
"git.scuroneko.dev/scuroneko/slog"
|
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type recordingObserver struct {
|
type recordingObserver struct {
|
||||||
@@ -55,7 +55,7 @@ func TestCheckPrefixesSkipsEmptyPrefixes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBotMiddlewareReceivesLogger(t *testing.T) {
|
func TestBotMiddlewareReceivesLogger(t *testing.T) {
|
||||||
logger := slog.CreateLogger()
|
logger := sneklog.NewLogger()
|
||||||
called := false
|
called := false
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
@@ -391,14 +391,14 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
|
|||||||
if ctx.ChatID != tt.wantChatID {
|
if ctx.ChatID != tt.wantChatID {
|
||||||
t.Fatalf("unexpected ChatID: got %d want %d", ctx.ChatID, tt.wantChatID)
|
t.Fatalf("unexpected ChatID: got %d want %d", ctx.ChatID, tt.wantChatID)
|
||||||
}
|
}
|
||||||
if ctx.CallbackQueryId != tt.wantCallbackID {
|
if ctx.CallbackQueryID != tt.wantCallbackID {
|
||||||
t.Fatalf("unexpected CallbackQueryId: got %q want %q", ctx.CallbackQueryId, tt.wantCallbackID)
|
t.Fatalf("unexpected CallbackQueryID: got %q want %q", ctx.CallbackQueryID, tt.wantCallbackID)
|
||||||
}
|
}
|
||||||
if ctx.CallbackMsgId != tt.wantCallbackMsgID {
|
if ctx.CallbackMsgID != tt.wantCallbackMsgID {
|
||||||
t.Fatalf("unexpected CallbackMsgId: got %d want %d", ctx.CallbackMsgId, tt.wantCallbackMsgID)
|
t.Fatalf("unexpected CallbackMsgID: got %d want %d", ctx.CallbackMsgID, tt.wantCallbackMsgID)
|
||||||
}
|
}
|
||||||
if ctx.InlineMsgId != tt.wantInlineMsgID {
|
if ctx.InlineMsgID != tt.wantInlineMsgID {
|
||||||
t.Fatalf("unexpected InlineMsgId: got %q want %q", ctx.InlineMsgId, tt.wantInlineMsgID)
|
t.Fatalf("unexpected InlineMsgID: got %q want %q", ctx.InlineMsgID, tt.wantInlineMsgID)
|
||||||
}
|
}
|
||||||
if ctx.Text != "" {
|
if ctx.Text != "" {
|
||||||
t.Fatalf("prepareUpdateCtx must not populate Text, got %q", ctx.Text)
|
t.Fatalf("prepareUpdateCtx must not populate Text, got %q", ctx.Text)
|
||||||
@@ -468,7 +468,7 @@ func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,7 +514,7 @@ func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
plugins: []Plugin[NoData]{
|
plugins: []Plugin[NoData]{
|
||||||
clonePlugin(first),
|
clonePlugin(first),
|
||||||
clonePlugin(second),
|
clonePlugin(second),
|
||||||
@@ -543,7 +543,7 @@ func TestHandleUpdateObserverEmitsUpdateErrors(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
||||||
observer: observer,
|
observer: observer,
|
||||||
}
|
}
|
||||||
@@ -607,7 +607,7 @@ func TestHandleMessageFallbackRunsAfterCommandMiss(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
prefixes: []string{"/"},
|
prefixes: []string{"/"},
|
||||||
observer: observer,
|
observer: observer,
|
||||||
}
|
}
|
||||||
@@ -658,7 +658,7 @@ func TestHandleMessageFallbackRunsForPlainText(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
prefixes: []string{"/"},
|
prefixes: []string{"/"},
|
||||||
}
|
}
|
||||||
bot.AddPlugins(plugin)
|
bot.AddPlugins(plugin)
|
||||||
@@ -691,7 +691,7 @@ func TestHandleMessageFallbackRespectsMiddleware(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
prefixes: []string{"/"},
|
prefixes: []string{"/"},
|
||||||
}
|
}
|
||||||
bot.AddPlugins(plugin)
|
bot.AddPlugins(plugin)
|
||||||
@@ -726,7 +726,7 @@ func TestHandleMessageFallbackDoesNotRunWhenCommandMatches(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
prefixes: []string{"/"},
|
prefixes: []string{"/"},
|
||||||
}
|
}
|
||||||
bot.AddPlugins(plugin)
|
bot.AddPlugins(plugin)
|
||||||
@@ -771,7 +771,7 @@ func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
|
|||||||
}, "ping")
|
}, "ping")
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
prefixes: []string{"/"},
|
prefixes: []string{"/"},
|
||||||
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
||||||
}
|
}
|
||||||
@@ -808,7 +808,7 @@ func TestCommandHandlerBindArgsEndToEnd(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
prefixes: []string{"/"},
|
prefixes: []string{"/"},
|
||||||
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
||||||
}
|
}
|
||||||
@@ -845,17 +845,17 @@ func TestPayloadHandlerBindArgsEndToEnd(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
payloadType: BotPayloadJson,
|
payloadType: BotPayloadJSON,
|
||||||
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := encodeJsonPayload(CallbackData{
|
data, err := encodeJSONPayload(CallbackData{
|
||||||
Command: "approve",
|
Command: "approve",
|
||||||
Args: []string{"7", "looks", "good"},
|
Args: []string{"7", "looks", "good"},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("encodeJsonPayload returned error: %v", err)
|
t.Fatalf("encodeJSONPayload returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.handle(context.Background(), &tgapi.Update{
|
bot.handle(context.Background(), &tgapi.Update{
|
||||||
@@ -898,7 +898,7 @@ func TestHandleEditedMessageStaysOutOfCommandFlow(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
prefixes: []string{"/"},
|
prefixes: []string{"/"},
|
||||||
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
||||||
}
|
}
|
||||||
@@ -940,7 +940,7 @@ func TestHandleEditedChannelPostStaysOutOfCommandFlow(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
prefixes: []string{"/"},
|
prefixes: []string{"/"},
|
||||||
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
||||||
}
|
}
|
||||||
@@ -968,14 +968,14 @@ func TestHandleCallbackPopulatesMessageTargets(t *testing.T) {
|
|||||||
plugin := NewPlugin[NoData]("test")
|
plugin := NewPlugin[NoData]("test")
|
||||||
plugin.NewPayload(func(ctx *MsgContext, db NoData) error {
|
plugin.NewPayload(func(ctx *MsgContext, db NoData) error {
|
||||||
called = true
|
called = true
|
||||||
if ctx.CallbackQueryId != "cb-msg" {
|
if ctx.CallbackQueryID != "cb-msg" {
|
||||||
t.Fatalf("unexpected CallbackQueryId: %q", ctx.CallbackQueryId)
|
t.Fatalf("unexpected CallbackQueryID: %q", ctx.CallbackQueryID)
|
||||||
}
|
}
|
||||||
if ctx.CallbackMsgId != 55 {
|
if ctx.CallbackMsgID != 55 {
|
||||||
t.Fatalf("unexpected CallbackMsgId: %d", ctx.CallbackMsgId)
|
t.Fatalf("unexpected CallbackMsgID: %d", ctx.CallbackMsgID)
|
||||||
}
|
}
|
||||||
if ctx.InlineMsgId != "" {
|
if ctx.InlineMsgID != "" {
|
||||||
t.Fatalf("did not expect InlineMsgId, got %q", ctx.InlineMsgId)
|
t.Fatalf("did not expect InlineMsgID, got %q", ctx.InlineMsgID)
|
||||||
}
|
}
|
||||||
if ctx.Msg == nil {
|
if ctx.Msg == nil {
|
||||||
t.Fatal("expected callback message context")
|
t.Fatal("expected callback message context")
|
||||||
@@ -993,14 +993,14 @@ func TestHandleCallbackPopulatesMessageTargets(t *testing.T) {
|
|||||||
}, "approve")
|
}, "approve")
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
payloadType: BotPayloadJson,
|
payloadType: BotPayloadJSON,
|
||||||
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := encodeJsonPayload(CallbackData{Command: "approve", Args: []string{"7", "ok"}})
|
data, err := encodeJSONPayload(CallbackData{Command: "approve", Args: []string{"7", "ok"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("encodeJsonPayload returned error: %v", err)
|
t.Fatalf("encodeJSONPayload returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.handle(context.Background(), &tgapi.Update{
|
bot.handle(context.Background(), &tgapi.Update{
|
||||||
@@ -1027,14 +1027,14 @@ func TestHandleCallbackPopulatesInlineTargets(t *testing.T) {
|
|||||||
plugin := NewPlugin[NoData]("test")
|
plugin := NewPlugin[NoData]("test")
|
||||||
plugin.NewPayload(func(ctx *MsgContext, db NoData) error {
|
plugin.NewPayload(func(ctx *MsgContext, db NoData) error {
|
||||||
called = true
|
called = true
|
||||||
if ctx.CallbackQueryId != "cb-inline" {
|
if ctx.CallbackQueryID != "cb-inline" {
|
||||||
t.Fatalf("unexpected CallbackQueryId: %q", ctx.CallbackQueryId)
|
t.Fatalf("unexpected CallbackQueryID: %q", ctx.CallbackQueryID)
|
||||||
}
|
}
|
||||||
if ctx.CallbackMsgId != 0 {
|
if ctx.CallbackMsgID != 0 {
|
||||||
t.Fatalf("did not expect CallbackMsgId, got %d", ctx.CallbackMsgId)
|
t.Fatalf("did not expect CallbackMsgID, got %d", ctx.CallbackMsgID)
|
||||||
}
|
}
|
||||||
if ctx.InlineMsgId != "inline-55" {
|
if ctx.InlineMsgID != "inline-55" {
|
||||||
t.Fatalf("unexpected InlineMsgId: %q", ctx.InlineMsgId)
|
t.Fatalf("unexpected InlineMsgID: %q", ctx.InlineMsgID)
|
||||||
}
|
}
|
||||||
if ctx.Msg != nil {
|
if ctx.Msg != nil {
|
||||||
t.Fatalf("did not expect callback chat message context, got %#v", ctx.Msg)
|
t.Fatalf("did not expect callback chat message context, got %#v", ctx.Msg)
|
||||||
@@ -1052,14 +1052,14 @@ func TestHandleCallbackPopulatesInlineTargets(t *testing.T) {
|
|||||||
}, "inline.approve")
|
}, "inline.approve")
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
payloadType: BotPayloadJson,
|
payloadType: BotPayloadJSON,
|
||||||
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := encodeJsonPayload(CallbackData{Command: "inline.approve", Args: []string{"9"}})
|
data, err := encodeJSONPayload(CallbackData{Command: "inline.approve", Args: []string{"9"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("encodeJsonPayload returned error: %v", err)
|
t.Fatalf("encodeJSONPayload returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.handle(context.Background(), &tgapi.Update{
|
bot.handle(context.Background(), &tgapi.Update{
|
||||||
@@ -1086,15 +1086,15 @@ func TestHandleCallbackObserverEmitsPayloadEvents(t *testing.T) {
|
|||||||
}, "approve")
|
}, "approve")
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
payloadType: BotPayloadJson,
|
payloadType: BotPayloadJSON,
|
||||||
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
||||||
observer: observer,
|
observer: observer,
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := encodeJsonPayload(CallbackData{Command: "approve", Args: []string{"7"}})
|
data, err := encodeJSONPayload(CallbackData{Command: "approve", Args: []string{"7"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("encodeJsonPayload returned error: %v", err)
|
t.Fatalf("encodeJSONPayload returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.handle(context.Background(), &tgapi.Update{
|
bot.handle(context.Background(), &tgapi.Update{
|
||||||
@@ -1137,15 +1137,15 @@ func TestHandleCallbackObserverEmitsPayloadErrors(t *testing.T) {
|
|||||||
}, "approve")
|
}, "approve")
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
payloadType: BotPayloadJson,
|
payloadType: BotPayloadJSON,
|
||||||
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
plugins: []Plugin[NoData]{clonePlugin(plugin)},
|
||||||
observer: observer,
|
observer: observer,
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := encodeJsonPayload(CallbackData{Command: "approve", Args: []string{"7"}})
|
data, err := encodeJSONPayload(CallbackData{Command: "approve", Args: []string{"7"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("encodeJsonPayload returned error: %v", err)
|
t.Fatalf("encodeJSONPayload returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.handle(context.Background(), &tgapi.Update{
|
bot.handle(context.Background(), &tgapi.Update{
|
||||||
@@ -1182,8 +1182,8 @@ func TestHandleCallbackObserverEmitsPayloadErrors(t *testing.T) {
|
|||||||
func TestHandleCallbackObserverEmitsDecodeErrors(t *testing.T) {
|
func TestHandleCallbackObserverEmitsDecodeErrors(t *testing.T) {
|
||||||
observer := &recordingObserver{}
|
observer := &recordingObserver{}
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
payloadType: BotPayloadJson,
|
payloadType: BotPayloadJSON,
|
||||||
observer: observer,
|
observer: observer,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1202,7 +1202,7 @@ func TestHandleCallbackObserverEmitsDecodeErrors(t *testing.T) {
|
|||||||
},
|
},
|
||||||
Logger: bot.logger,
|
Logger: bot.logger,
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
CallbackQueryId: "cb-bad",
|
CallbackQueryID: "cb-bad",
|
||||||
From: &tgapi.User{ID: 7},
|
From: &tgapi.User{ID: 7},
|
||||||
FromID: 7,
|
FromID: 7,
|
||||||
sceneRuntime: bot,
|
sceneRuntime: bot,
|
||||||
|
|||||||
+26
-26
@@ -19,10 +19,10 @@ const (
|
|||||||
// InlineKbButtonBuilder is a fluent builder for creating a single inline keyboard button.
|
// InlineKbButtonBuilder is a fluent builder for creating a single inline keyboard button.
|
||||||
//
|
//
|
||||||
// Use NewInlineKbButton() to start, then chain methods to configure:
|
// Use NewInlineKbButton() to start, then chain methods to configure:
|
||||||
// - SetIconCustomEmojiId() — adds a custom emoji icon
|
// - SetIconCustomEmojiID() — adds a custom emoji icon
|
||||||
// - SetStyle() — sets visual style (danger/success/primary)
|
// - SetStyle() — sets visual style (danger/success/primary)
|
||||||
// - SetUrl() — makes button open a URL
|
// - SetURL() — makes button open a URL
|
||||||
// - SetCallbackDataJson() — attaches structured command + args for bot handling
|
// - SetCallbackDataJSON() — attaches structured command + args for bot handling
|
||||||
//
|
//
|
||||||
// Call build() to produce the final tgapi.InlineKeyboardButton.
|
// Call build() to produce the final tgapi.InlineKeyboardButton.
|
||||||
// Builder methods are immutable — each returns a copy.
|
// Builder methods are immutable — each returns a copy.
|
||||||
@@ -40,9 +40,9 @@ func NewInlineKbButton(text string) InlineKbButtonBuilder {
|
|||||||
return InlineKbButtonBuilder{text: text}
|
return InlineKbButtonBuilder{text: text}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetIconCustomEmojiId sets a custom emoji ID to display as the button's icon.
|
// SetIconCustomEmojiID sets a custom emoji ID to display as the button's icon.
|
||||||
// This is a Telegram Bot API feature for custom emoji icons.
|
// This is a Telegram Bot API feature for custom emoji icons.
|
||||||
func (b InlineKbButtonBuilder) SetIconCustomEmojiId(id string) InlineKbButtonBuilder {
|
func (b InlineKbButtonBuilder) SetIconCustomEmojiID(id string) InlineKbButtonBuilder {
|
||||||
b.iconCustomEmojiID = id
|
b.iconCustomEmojiID = id
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
@@ -55,22 +55,22 @@ func (b InlineKbButtonBuilder) SetStyle(style tgapi.KeyboardButtonStyle) InlineK
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetUrl sets a URL that will be opened when the button is pressed.
|
// SetURL sets a URL that will be opened when the button is pressed.
|
||||||
// If both URL and CallbackData are set, Telegram will prioritize URL.
|
// If both URL and CallbackData are set, Telegram will prioritize URL.
|
||||||
func (b InlineKbButtonBuilder) SetUrl(url string) InlineKbButtonBuilder {
|
func (b InlineKbButtonBuilder) SetURL(url string) InlineKbButtonBuilder {
|
||||||
b.url = url
|
b.url = url
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetCallbackDataJson sets a structured callback payload that will be sent to the bot
|
// SetCallbackDataJSON sets a structured callback payload that will be sent to the bot
|
||||||
// when the button is pressed. The command and arguments are serialized as JSON.
|
// when the button is pressed. The command and arguments are serialized as JSON.
|
||||||
//
|
//
|
||||||
// Args are converted to strings using fmt.Sprint. Non-string types (e.g., int, bool)
|
// Args are converted to strings using fmt.Sprint. Non-string types (e.g., int, bool)
|
||||||
// are safely serialized, but complex structs may not serialize usefully.
|
// are safely serialized, but complex structs may not serialize usefully.
|
||||||
//
|
//
|
||||||
// Example: SetCallbackDataJson("delete_user", 123, "confirm") → {"cmd":"delete_user","args":["123","confirm"]}.
|
// Example: SetCallbackDataJSON("delete_user", 123, "confirm") → {"cmd":"delete_user","args":["123","confirm"]}.
|
||||||
func (b InlineKbButtonBuilder) SetCallbackDataJson(cmd string, args ...any) InlineKbButtonBuilder {
|
func (b InlineKbButtonBuilder) SetCallbackDataJSON(cmd string, args ...any) InlineKbButtonBuilder {
|
||||||
b.callbackData = NewCallbackData(cmd, args...).ToJson()
|
b.callbackData = NewCallbackData(cmd, args...).ToJSON()
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,12 +107,12 @@ type InlineKeyboard struct {
|
|||||||
payloadType BotPayloadType // Serialization format for callback data (JSON or Base64)
|
payloadType BotPayloadType // Serialization format for callback data (JSON or Base64)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewInlineKeyboardJson creates a new keyboard builder with the specified maximum
|
// NewInlineKeyboardJSON creates a new keyboard builder with the specified maximum
|
||||||
// number of buttons per row.
|
// number of buttons per row.
|
||||||
//
|
//
|
||||||
// Example: NewInlineKeyboardJson(3) creates a keyboard with at most 3 buttons per line.
|
// Example: NewInlineKeyboardJSON(3) creates a keyboard with at most 3 buttons per line.
|
||||||
func NewInlineKeyboardJson(maxRow int) *InlineKeyboard {
|
func NewInlineKeyboardJSON(maxRow int) *InlineKeyboard {
|
||||||
return NewInlineKeyboard(BotPayloadJson, maxRow)
|
return NewInlineKeyboard(BotPayloadJSON, maxRow)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewInlineKeyboardBase64 creates a new keyboard builder with the specified maximum
|
// NewInlineKeyboardBase64 creates a new keyboard builder with the specified maximum
|
||||||
@@ -126,7 +126,7 @@ func NewInlineKeyboardBase64(maxRow int) *InlineKeyboard {
|
|||||||
// NewInlineKeyboard creates a new keyboard builder with the specified payload encoding
|
// NewInlineKeyboard creates a new keyboard builder with the specified payload encoding
|
||||||
// type and maximum number of buttons per row.
|
// type and maximum number of buttons per row.
|
||||||
//
|
//
|
||||||
// Use NewInlineKeyboardJson or NewInlineKeyboardBase64 for the common cases.
|
// Use NewInlineKeyboardJSON or NewInlineKeyboardBase64 for the common cases.
|
||||||
func NewInlineKeyboard(payloadType BotPayloadType, maxRow int) *InlineKeyboard {
|
func NewInlineKeyboard(payloadType BotPayloadType, maxRow int) *InlineKeyboard {
|
||||||
return &InlineKeyboard{
|
return &InlineKeyboard{
|
||||||
CurrentLine: make(extypes.Slice[tgapi.InlineKeyboardButton], 0),
|
CurrentLine: make(extypes.Slice[tgapi.InlineKeyboardButton], 0),
|
||||||
@@ -163,15 +163,15 @@ func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeybo
|
|||||||
return in
|
return in
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddUrlButton adds a button that opens a URL when pressed.
|
// AddURLButton adds a button that opens a URL when pressed.
|
||||||
// No callback data is attached.
|
// No callback data is attached.
|
||||||
func (in *InlineKeyboard) AddUrlButton(text, url string) *InlineKeyboard {
|
func (in *InlineKeyboard) AddURLButton(text, url string) *InlineKeyboard {
|
||||||
return in.append(tgapi.InlineKeyboardButton{Text: text, URL: url})
|
return in.append(tgapi.InlineKeyboardButton{Text: text, URL: url})
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddUrlButtonStyle adds a button with a visual style that opens a URL.
|
// AddURLButtonStyle adds a button with a visual style that opens a URL.
|
||||||
// Style must be one of: ButtonStyleDanger, ButtonStyleSuccess, ButtonStylePrimary.
|
// Style must be one of: ButtonStyleDanger, ButtonStyleSuccess, ButtonStylePrimary.
|
||||||
func (in *InlineKeyboard) AddUrlButtonStyle(text string, style tgapi.KeyboardButtonStyle, url string) *InlineKeyboard {
|
func (in *InlineKeyboard) AddURLButtonStyle(text string, style tgapi.KeyboardButtonStyle, url string) *InlineKeyboard {
|
||||||
return in.append(tgapi.InlineKeyboardButton{Text: text, Style: style, URL: url})
|
return in.append(tgapi.InlineKeyboardButton{Text: text, Style: style, URL: url})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,15 +253,15 @@ func NewCallbackData(command string, args ...any) CallbackData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToJson serializes the CallbackData to a JSON string.
|
// ToJSON serializes the CallbackData to a JSON string.
|
||||||
//
|
//
|
||||||
// If serialization fails (e.g., due to unmarshalable fields), returns a fallback
|
// If serialization fails (e.g., due to unmarshalable fields), returns a fallback
|
||||||
// JSON object: {"cmd":""} to prevent breaking Telegram's API.
|
// JSON object: {"cmd":""} to prevent breaking Telegram's API.
|
||||||
//
|
//
|
||||||
// This fallback ensures the bot receives a valid JSON payload even if internal
|
// This fallback ensures the bot receives a valid JSON payload even if internal
|
||||||
// errors occur — avoiding "invalid callback_data" errors from Telegram.
|
// errors occur — avoiding "invalid callback_data" errors from Telegram.
|
||||||
func (d CallbackData) ToJson() string {
|
func (d CallbackData) ToJSON() string {
|
||||||
data, err := encodeJsonPayload(d)
|
data, err := encodeJSONPayload(d)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Fallback: return minimal valid JSON to avoid Telegram API rejection
|
// Fallback: return minimal valid JSON to avoid Telegram API rejection
|
||||||
return `{"cmd":""}`
|
return `{"cmd":""}`
|
||||||
@@ -280,14 +280,14 @@ func (d CallbackData) ToBase64() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Encode serializes the CallbackData according to the specified payload type.
|
// Encode serializes the CallbackData according to the specified payload type.
|
||||||
// Supported types: BotPayloadJson and BotPayloadBase64.
|
// Supported types: BotPayloadJSON and BotPayloadBase64.
|
||||||
// For unknown types, returns an empty string.
|
// For unknown types, returns an empty string.
|
||||||
func (d CallbackData) Encode(t BotPayloadType) string {
|
func (d CallbackData) Encode(t BotPayloadType) string {
|
||||||
switch t {
|
switch t {
|
||||||
case BotPayloadBase64:
|
case BotPayloadBase64:
|
||||||
return d.ToBase64()
|
return d.ToBase64()
|
||||||
case BotPayloadJson:
|
case BotPayloadJSON:
|
||||||
return d.ToJson()
|
return d.ToJSON()
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-7
@@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestInlineKeyboardWrapsRowsAndEncodesJSONPayloads(t *testing.T) {
|
func TestInlineKeyboardWrapsRowsAndEncodesJSONPayloads(t *testing.T) {
|
||||||
kb := NewInlineKeyboardJson(2).
|
kb := NewInlineKeyboardJSON(2).
|
||||||
AddCallbackButton("A", "cmd", 1).
|
AddCallbackButton("A", "cmd", 1).
|
||||||
AddCallbackButton("B", "cmd", 2).
|
AddCallbackButton("B", "cmd", 2).
|
||||||
AddCallbackButton("C", "cmd", 3)
|
AddCallbackButton("C", "cmd", 3)
|
||||||
@@ -33,7 +33,7 @@ func TestInlineKeyboardBuilderPreservesConfiguredButtonFields(t *testing.T) {
|
|||||||
AddButton(
|
AddButton(
|
||||||
NewInlineKbButton("Docs").
|
NewInlineKbButton("Docs").
|
||||||
SetStyle(ButtonStylePrimary).
|
SetStyle(ButtonStylePrimary).
|
||||||
SetUrl("https://example.test"),
|
SetURL("https://example.test"),
|
||||||
)
|
)
|
||||||
|
|
||||||
button := kb.Get().InlineKeyboard[0][0]
|
button := kb.Get().InlineKeyboard[0][0]
|
||||||
@@ -46,8 +46,8 @@ func TestInlineKeyboardBuilderPreservesConfiguredButtonFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestInlineKeyboardGetPayloadTypeReturnsLocalOverride(t *testing.T) {
|
func TestInlineKeyboardGetPayloadTypeReturnsLocalOverride(t *testing.T) {
|
||||||
kb := NewInlineKeyboardJson(2)
|
kb := NewInlineKeyboardJSON(2)
|
||||||
if got := kb.GetPayloadType(); got != BotPayloadJson {
|
if got := kb.GetPayloadType(); got != BotPayloadJSON {
|
||||||
t.Fatalf("unexpected initial payload type: %q", got)
|
t.Fatalf("unexpected initial payload type: %q", got)
|
||||||
}
|
}
|
||||||
kb.SetPayloadType(BotPayloadBase64)
|
kb.SetPayloadType(BotPayloadBase64)
|
||||||
@@ -60,7 +60,7 @@ func TestDecodePayloadAcceptsBase64KeyboardPayloadWhenBotPrefersJSON(t *testing.
|
|||||||
kb := NewInlineKeyboardBase64(1).
|
kb := NewInlineKeyboardBase64(1).
|
||||||
AddCallbackButton("A", "cmd", 1, "two")
|
AddCallbackButton("A", "cmd", 1, "two")
|
||||||
|
|
||||||
got, _, err := decodePayload(BotPayloadJson, kb.Get().InlineKeyboard[0][0].CallbackData, false)
|
got, _, err := decodePayload(BotPayloadJSON, kb.Get().InlineKeyboard[0][0].CallbackData, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("decodePayload returned error: %v", err)
|
t.Fatalf("decodePayload returned error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@ func TestDecodePayloadAcceptsBase64KeyboardPayloadWhenBotPrefersJSON(t *testing.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDecodePayloadAcceptsJSONKeyboardPayloadWhenBotPrefersBase64(t *testing.T) {
|
func TestDecodePayloadAcceptsJSONKeyboardPayloadWhenBotPrefersBase64(t *testing.T) {
|
||||||
kb := NewInlineKeyboardJson(1).
|
kb := NewInlineKeyboardJSON(1).
|
||||||
AddCallbackButton("A", "cmd", 1, "two")
|
AddCallbackButton("A", "cmd", 1, "two")
|
||||||
|
|
||||||
got, _, err := decodePayload(BotPayloadBase64, kb.Get().InlineKeyboard[0][0].CallbackData, false)
|
got, _, err := decodePayload(BotPayloadBase64, kb.Get().InlineKeyboard[0][0].CallbackData, false)
|
||||||
@@ -90,7 +90,7 @@ func TestDecodePayloadStrictRejectsMismatchedType(t *testing.T) {
|
|||||||
kb := NewInlineKeyboardBase64(1).
|
kb := NewInlineKeyboardBase64(1).
|
||||||
AddCallbackButton("A", "cmd", 1)
|
AddCallbackButton("A", "cmd", 1)
|
||||||
|
|
||||||
_, _, err := decodePayload(BotPayloadJson, kb.Get().InlineKeyboard[0][0].CallbackData, true)
|
_, _, err := decodePayload(BotPayloadJSON, kb.Get().InlineKeyboard[0][0].CallbackData, true)
|
||||||
if !errors.Is(err, ErrPayloadTypeMismatch) {
|
if !errors.Is(err, ErrPayloadTypeMismatch) {
|
||||||
t.Fatalf("expected ErrPayloadTypeMismatch, got %v", err)
|
t.Fatalf("expected ErrPayloadTypeMismatch, got %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-2
@@ -3,6 +3,7 @@ package laniakea
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"iter"
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
)
|
)
|
||||||
@@ -53,13 +54,13 @@ func (bot *Bot[T]) Updates(ctx context.Context) ([]tgapi.Update, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if bot.RequestLogger != nil {
|
if bot.requestLogger != nil {
|
||||||
for _, u := range updates {
|
for _, u := range updates {
|
||||||
j, err := json.Marshal(u)
|
j, err := json.Marshal(u)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
bot.GetLogger().Error(err)
|
bot.GetLogger().Error(err)
|
||||||
}
|
}
|
||||||
bot.RequestLogger.Debugf("UPDATE %s\n", j)
|
bot.requestLogger.Debugf("UPDATE %s\n", j)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(updates) > 0 {
|
if len(updates) > 0 {
|
||||||
@@ -67,3 +68,22 @@ func (bot *Bot[T]) Updates(ctx context.Context) ([]tgapi.Update, error) {
|
|||||||
}
|
}
|
||||||
return updates, err
|
return updates, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdatesIter fetches updates once and yields each update in order.
|
||||||
|
//
|
||||||
|
// If fetching updates fails, the iterator yields the error once with a zero
|
||||||
|
// update and then stops.
|
||||||
|
func (bot *Bot[T]) UpdatesIter(ctx context.Context) iter.Seq2[tgapi.Update, error] {
|
||||||
|
return func(yield func(tgapi.Update, error) bool) {
|
||||||
|
updates, err := bot.Updates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
yield(tgapi.Update{}, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, u := range updates {
|
||||||
|
if !yield(u, nil) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package laniakea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdatesIterYieldsFetchError(t *testing.T) {
|
||||||
|
bot := newUpdatesIterTestBot(t, `{"ok":false,"error_code":500,"description":"boom"}`)
|
||||||
|
|
||||||
|
var gotErr error
|
||||||
|
var gotUpdates int
|
||||||
|
bot.UpdatesIter(context.Background())(func(update tgapi.Update, err error) bool {
|
||||||
|
gotUpdates++
|
||||||
|
if update.UpdateID != 0 {
|
||||||
|
t.Fatalf("expected zero update on error, got %d", update.UpdateID)
|
||||||
|
}
|
||||||
|
gotErr = err
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if gotUpdates != 1 {
|
||||||
|
t.Fatalf("expected one yielded error, got %d yields", gotUpdates)
|
||||||
|
}
|
||||||
|
if gotErr == nil {
|
||||||
|
t.Fatal("expected fetch error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(gotErr.Error(), "boom") {
|
||||||
|
t.Fatalf("expected Telegram error description, got %v", gotErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatesIterStopsWhenYieldReturnsFalse(t *testing.T) {
|
||||||
|
bot := newUpdatesIterTestBot(t, `{"ok":true,"result":[{"update_id":11},{"update_id":12}]}`)
|
||||||
|
|
||||||
|
var gotIDs []int
|
||||||
|
bot.UpdatesIter(context.Background())(func(update tgapi.Update, err error) bool {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
gotIDs = append(gotIDs, update.UpdateID)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(gotIDs) != 1 || gotIDs[0] != 11 {
|
||||||
|
t.Fatalf("expected only first update, got %v", gotIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUpdatesIterTestBot(t *testing.T, response string) *Bot[NoData] {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
api := tgapi.NewAPI(
|
||||||
|
tgapi.NewAPIOpts("token").
|
||||||
|
SetAPIURL("https://example.test").
|
||||||
|
SetHTTPClient(&http.Client{
|
||||||
|
Transport: pollingRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||||
|
Body: io.NopCloser(strings.NewReader(response)),
|
||||||
|
}, nil
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := api.Close(); err != nil {
|
||||||
|
t.Fatalf("Close returned error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return &Bot[NoData]{api: api}
|
||||||
|
}
|
||||||
+59
-59
@@ -11,7 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
"git.scuroneko.dev/scuroneko/slog"
|
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MsgContext holds the normalized per-update context passed to command, payload,
|
// MsgContext holds the normalized per-update context passed to command, payload,
|
||||||
@@ -24,14 +24,14 @@ import (
|
|||||||
// - From and FromID are populated only when the update exposes a user identity.
|
// - From and FromID are populated only when the update exposes a user identity.
|
||||||
// - Chat and ChatID are populated only when the update exposes a chat identity.
|
// - Chat and ChatID are populated only when the update exposes a chat identity.
|
||||||
// - Text, Args, and Prefix are populated only by command or scene command routing.
|
// - Text, Args, and Prefix are populated only by command or scene command routing.
|
||||||
// - CallbackQueryId, CallbackMsgId, and InlineMsgId are populated only for
|
// - CallbackQueryID, CallbackMsgID, and InlineMsgID are populated only for
|
||||||
// callback query handling when the corresponding callback targets exist.
|
// callback query handling when the corresponding callback targets exist.
|
||||||
//
|
//
|
||||||
// Helper methods on MsgContext may require a message-backed context. For example,
|
// Helper methods on MsgContext may require a message-backed context. For example,
|
||||||
// reply helpers need Msg, while inline callback edit helpers can work through
|
// reply helpers need Msg, while inline callback edit helpers can work through
|
||||||
// InlineMsgId when there is no chat message.
|
// InlineMsgID when there is no chat message.
|
||||||
type MsgContext struct {
|
type MsgContext struct {
|
||||||
Api *tgapi.API
|
API *tgapi.API
|
||||||
Update tgapi.Update
|
Update tgapi.Update
|
||||||
|
|
||||||
// Msg is the normalized Telegram message for message-backed update kinds.
|
// Msg is the normalized Telegram message for message-backed update kinds.
|
||||||
@@ -46,17 +46,17 @@ type MsgContext struct {
|
|||||||
|
|
||||||
// Logger is the logger assigned by the matched plugin for the current handler call.
|
// Logger is the logger assigned by the matched plugin for the current handler call.
|
||||||
// It may fall back to the bot logger when the plugin has no dedicated logger.
|
// It may fall back to the bot logger when the plugin has no dedicated logger.
|
||||||
Logger *slog.Logger
|
Logger *sneklog.Logger
|
||||||
|
|
||||||
// InlineMsgId is the inline message identifier for callback queries that target
|
// InlineMsgID is the inline message identifier for callback queries that target
|
||||||
// an inline message instead of a chat message.
|
// an inline message instead of a chat message.
|
||||||
InlineMsgId string
|
InlineMsgID string
|
||||||
// CallbackMsgId is the message ID targeted by the current callback query when
|
// CallbackMsgID is the message ID targeted by the current callback query when
|
||||||
// the callback comes from a chat message.
|
// the callback comes from a chat message.
|
||||||
CallbackMsgId int
|
CallbackMsgID int
|
||||||
// CallbackQueryId is the Telegram callback query ID for payload handlers and
|
// CallbackQueryID is the Telegram callback query ID for payload handlers and
|
||||||
// callback-backed scene handlers.
|
// callback-backed scene handlers.
|
||||||
CallbackQueryId string
|
CallbackQueryID string
|
||||||
// FromID is the normalized sender ID when the current update exposes a user.
|
// FromID is the normalized sender ID when the current update exposes a user.
|
||||||
// It is zero when the update has no user identity.
|
// It is zero when the update has no user identity.
|
||||||
FromID int64
|
FromID int64
|
||||||
@@ -95,7 +95,7 @@ type AnswerMessage struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Internal helper for text edits with optional keyboard and parse mode.
|
// Internal helper for text edits with optional keyboard and parse mode.
|
||||||
func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
func (ctx *MsgContext) edit(messageID int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
||||||
if err := validateMessageText(text); err != nil {
|
if err := validateMessageText(text); err != nil {
|
||||||
ctx.Logger.Errorln(err)
|
ctx.Logger.Errorln(err)
|
||||||
return nil
|
return nil
|
||||||
@@ -105,11 +105,11 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard
|
|||||||
ParseMode: parseMode,
|
ParseMode: parseMode,
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case messageId > 0 && ctx.Msg != nil:
|
case messageID > 0 && ctx.Msg != nil:
|
||||||
params.MessageID = messageId
|
params.MessageID = messageID
|
||||||
params.ChatID = ctx.Msg.Chat.ID
|
params.ChatID = ctx.Msg.Chat.ID
|
||||||
case ctx.InlineMsgId != "":
|
case ctx.InlineMsgID != "":
|
||||||
params.InlineMessageID = ctx.InlineMsgId
|
params.InlineMessageID = ctx.InlineMsgID
|
||||||
default:
|
default:
|
||||||
ctx.Logger.Errorln(ErrEditTargetMissing)
|
ctx.Logger.Errorln(ErrEditTargetMissing)
|
||||||
return nil
|
return nil
|
||||||
@@ -117,12 +117,12 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard
|
|||||||
if keyboard != nil {
|
if keyboard != nil {
|
||||||
params.ReplyMarkup = keyboard.Get()
|
params.ReplyMarkup = keyboard.Get()
|
||||||
}
|
}
|
||||||
msg, _, err := ctx.Api.EditMessageTextWithContext(ctx.Context(), params)
|
msg, _, err := ctx.API.EditMessageTextWithContext(ctx.Context(), params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Logger.Errorln(err)
|
ctx.Logger.Errorln(err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
resultMessageID := messageId
|
resultMessageID := messageID
|
||||||
if msg.MessageID > 0 {
|
if msg.MessageID > 0 {
|
||||||
resultMessageID = msg.MessageID
|
resultMessageID = msg.MessageID
|
||||||
}
|
}
|
||||||
@@ -147,11 +147,11 @@ func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage {
|
|||||||
|
|
||||||
// Internal helper for editing callback-linked messages.
|
// Internal helper for editing callback-linked messages.
|
||||||
func (ctx *MsgContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
func (ctx *MsgContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
||||||
if ctx.CallbackMsgId == 0 && ctx.InlineMsgId == "" {
|
if ctx.CallbackMsgID == 0 && ctx.InlineMsgID == "" {
|
||||||
ctx.Logger.Errorln(ErrCallbackMessageMissing)
|
ctx.Logger.Errorln(ErrCallbackMessageMissing)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return ctx.edit(ctx.CallbackMsgId, text, keyboard, parseMode)
|
return ctx.edit(ctx.CallbackMsgID, text, keyboard, parseMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EditCallback edits the callback message using plain text (ParseNone).
|
// EditCallback edits the callback message using plain text (ParseNone).
|
||||||
@@ -179,7 +179,7 @@ func (ctx *MsgContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyb
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Internal helper for media-caption edits.
|
// Internal helper for media-caption edits.
|
||||||
func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
func (ctx *MsgContext) editPhotoText(messageID int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
||||||
if err := validateCaptionText(text); err != nil {
|
if err := validateCaptionText(text); err != nil {
|
||||||
ctx.Logger.Errorln(err)
|
ctx.Logger.Errorln(err)
|
||||||
return nil
|
return nil
|
||||||
@@ -189,11 +189,11 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo
|
|||||||
ParseMode: parseMode,
|
ParseMode: parseMode,
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case messageId > 0 && ctx.Msg != nil:
|
case messageID > 0 && ctx.Msg != nil:
|
||||||
params.ChatID = ctx.Msg.Chat.ID
|
params.ChatID = ctx.Msg.Chat.ID
|
||||||
params.MessageID = messageId
|
params.MessageID = messageID
|
||||||
case ctx.InlineMsgId != "":
|
case ctx.InlineMsgID != "":
|
||||||
params.InlineMessageID = ctx.InlineMsgId
|
params.InlineMessageID = ctx.InlineMsgID
|
||||||
default:
|
default:
|
||||||
ctx.Logger.Errorln(ErrEditTargetMissing)
|
ctx.Logger.Errorln(ErrEditTargetMissing)
|
||||||
return nil
|
return nil
|
||||||
@@ -202,12 +202,12 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo
|
|||||||
params.ReplyMarkup = kb.Get()
|
params.ReplyMarkup = kb.Get()
|
||||||
}
|
}
|
||||||
|
|
||||||
msg, _, err := ctx.Api.EditMessageCaptionWithContext(ctx.Context(), params)
|
msg, _, err := ctx.API.EditMessageCaptionWithContext(ctx.Context(), params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Logger.Errorln(err)
|
ctx.Logger.Errorln(err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
resultMessageID := messageId
|
resultMessageID := messageID
|
||||||
if msg.MessageID > 0 {
|
if msg.MessageID > 0 {
|
||||||
resultMessageID = msg.MessageID
|
resultMessageID = msg.MessageID
|
||||||
}
|
}
|
||||||
@@ -265,7 +265,7 @@ func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode t
|
|||||||
params.DirectMessagesTopicID = ctx.Msg.DirectMessageTopic.TopicID
|
params.DirectMessagesTopicID = ctx.Msg.DirectMessageTopic.TopicID
|
||||||
}
|
}
|
||||||
|
|
||||||
msg, err := ctx.Api.SendMessageWithContext(ctx.Context(), params)
|
msg, err := ctx.API.SendMessageWithContext(ctx.Context(), params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Logger.Errorln(err)
|
ctx.Logger.Errorln(err)
|
||||||
return nil
|
return nil
|
||||||
@@ -371,7 +371,7 @@ func (ctx *MsgContext) answerLong(text string, keyboard *InlineKeyboard, parseMo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Internal helper for photo replies with optional caption and keyboard.
|
// Internal helper for photo replies with optional caption and keyboard.
|
||||||
func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
func (ctx *MsgContext) answerPhoto(photoID, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
|
||||||
if ctx.Msg == nil {
|
if ctx.Msg == nil {
|
||||||
ctx.Logger.Errorln(ErrMessageContextNil)
|
ctx.Logger.Errorln(ErrMessageContextNil)
|
||||||
return nil
|
return nil
|
||||||
@@ -384,7 +384,7 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, par
|
|||||||
ChatID: ctx.Msg.Chat.ID,
|
ChatID: ctx.Msg.Chat.ID,
|
||||||
Caption: text,
|
Caption: text,
|
||||||
ParseMode: parseMode,
|
ParseMode: parseMode,
|
||||||
Photo: photoId,
|
Photo: photoID,
|
||||||
}
|
}
|
||||||
if kb != nil {
|
if kb != nil {
|
||||||
params.ReplyMarkup = kb.Get()
|
params.ReplyMarkup = kb.Get()
|
||||||
@@ -396,7 +396,7 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, par
|
|||||||
params.DirectMessagesTopicID = int(ctx.Msg.DirectMessageTopic.TopicID)
|
params.DirectMessagesTopicID = int(ctx.Msg.DirectMessageTopic.TopicID)
|
||||||
}
|
}
|
||||||
|
|
||||||
msg, err := ctx.Api.SendPhotoWithContext(ctx.Context(), params)
|
msg, err := ctx.API.SendPhotoWithContext(ctx.Context(), params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Logger.Errorln(err)
|
ctx.Logger.Errorln(err)
|
||||||
return nil
|
return nil
|
||||||
@@ -407,44 +407,44 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, par
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AnswerPhoto sends a photo with plain text caption.
|
// AnswerPhoto sends a photo with plain text caption.
|
||||||
func (ctx *MsgContext) AnswerPhoto(photoId, text string) *AnswerMessage {
|
func (ctx *MsgContext) AnswerPhoto(photoID, text string) *AnswerMessage {
|
||||||
return ctx.answerPhoto(photoId, text, nil, tgapi.ParseNone)
|
return ctx.answerPhoto(photoID, text, nil, tgapi.ParseNone)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnswerPhotoMarkdown sends a photo with MarkdownV2 caption.
|
// AnswerPhotoMarkdown sends a photo with MarkdownV2 caption.
|
||||||
//
|
//
|
||||||
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
||||||
func (ctx *MsgContext) AnswerPhotoMarkdown(photoId, text string) *AnswerMessage {
|
func (ctx *MsgContext) AnswerPhotoMarkdown(photoID, text string) *AnswerMessage {
|
||||||
return ctx.answerPhoto(photoId, text, nil, tgapi.ParseMDV2)
|
return ctx.answerPhoto(photoID, text, nil, tgapi.ParseMDV2)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnswerPhotoKeyboard sends a photo with caption and inline keyboard (plain text).
|
// AnswerPhotoKeyboard sends a photo with caption and inline keyboard (plain text).
|
||||||
func (ctx *MsgContext) AnswerPhotoKeyboard(photoId, text string, kb *InlineKeyboard) *AnswerMessage {
|
func (ctx *MsgContext) AnswerPhotoKeyboard(photoID, text string, kb *InlineKeyboard) *AnswerMessage {
|
||||||
return ctx.answerPhoto(photoId, text, kb, tgapi.ParseNone)
|
return ctx.answerPhoto(photoID, text, kb, tgapi.ParseNone)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnswerPhotoKeyboardMarkdown sends a photo with caption and inline keyboard using MarkdownV2.
|
// AnswerPhotoKeyboardMarkdown sends a photo with caption and inline keyboard using MarkdownV2.
|
||||||
//
|
//
|
||||||
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
||||||
func (ctx *MsgContext) AnswerPhotoKeyboardMarkdown(photoId, text string, kb *InlineKeyboard) *AnswerMessage {
|
func (ctx *MsgContext) AnswerPhotoKeyboardMarkdown(photoID, text string, kb *InlineKeyboard) *AnswerMessage {
|
||||||
return ctx.answerPhoto(photoId, text, kb, tgapi.ParseMDV2)
|
return ctx.answerPhoto(photoID, text, kb, tgapi.ParseMDV2)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnswerPhotof formats a string and sends it as a photo caption (plain text).
|
// AnswerPhotof formats a string and sends it as a photo caption (plain text).
|
||||||
func (ctx *MsgContext) AnswerPhotof(photoId, template string, args ...any) *AnswerMessage {
|
func (ctx *MsgContext) AnswerPhotof(photoID, template string, args ...any) *AnswerMessage {
|
||||||
return ctx.answerPhoto(photoId, fmt.Sprintf(template, args...), nil, tgapi.ParseNone)
|
return ctx.answerPhoto(photoID, fmt.Sprintf(template, args...), nil, tgapi.ParseNone)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnswerPhotofMarkdown formats a string and sends it as a photo caption using MarkdownV2.
|
// AnswerPhotofMarkdown formats a string and sends it as a photo caption using MarkdownV2.
|
||||||
//
|
//
|
||||||
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
|
||||||
func (ctx *MsgContext) AnswerPhotofMarkdown(photoId, template string, args ...any) *AnswerMessage {
|
func (ctx *MsgContext) AnswerPhotofMarkdown(photoID, template string, args ...any) *AnswerMessage {
|
||||||
return ctx.answerPhoto(photoId, fmt.Sprintf(template, args...), nil, tgapi.ParseMDV2)
|
return ctx.answerPhoto(photoID, fmt.Sprintf(template, args...), nil, tgapi.ParseMDV2)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Internal helper that deletes a message by ID.
|
// Internal helper that deletes a message by ID.
|
||||||
func (ctx *MsgContext) delete(messageId int) {
|
func (ctx *MsgContext) delete(messageID int) {
|
||||||
if messageId == 0 {
|
if messageID == 0 {
|
||||||
ctx.Logger.Errorln(ErrMessageIDZero)
|
ctx.Logger.Errorln(ErrMessageIDZero)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -452,9 +452,9 @@ func (ctx *MsgContext) delete(messageId int) {
|
|||||||
ctx.Logger.Errorln(ErrMessageContextNil)
|
ctx.Logger.Errorln(ErrMessageContextNil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, err := ctx.Api.DeleteMessageWithContext(ctx.Context(), tgapi.DeleteMessage{
|
_, err := ctx.API.DeleteMessageWithContext(ctx.Context(), tgapi.DeleteMessage{
|
||||||
ChatID: ctx.Msg.Chat.ID,
|
ChatID: ctx.Msg.Chat.ID,
|
||||||
MessageID: messageId,
|
MessageID: messageID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Logger.Errorln(err)
|
ctx.Logger.Errorln(err)
|
||||||
@@ -466,20 +466,20 @@ func (m *AnswerMessage) Delete() { m.ctx.delete(m.MessageID) }
|
|||||||
|
|
||||||
// CallbackDelete deletes the message that triggered the callback query.
|
// CallbackDelete deletes the message that triggered the callback query.
|
||||||
func (ctx *MsgContext) CallbackDelete() {
|
func (ctx *MsgContext) CallbackDelete() {
|
||||||
if ctx.CallbackMsgId == 0 {
|
if ctx.CallbackMsgID == 0 {
|
||||||
ctx.Logger.Errorln(ErrCallbackMessageMissing)
|
ctx.Logger.Errorln(ErrCallbackMessageMissing)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.delete(ctx.CallbackMsgId)
|
ctx.delete(ctx.CallbackMsgID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Internal helper that answers a callback query with optional text, alert, or URL.
|
// Internal helper that answers a callback query with optional text, alert, or URL.
|
||||||
func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) {
|
func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) {
|
||||||
if len(ctx.CallbackQueryId) == 0 {
|
if len(ctx.CallbackQueryID) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, err := ctx.Api.AnswerCallbackQueryWithContext(ctx.Context(), tgapi.AnswerCallbackQuery{
|
_, err := ctx.API.AnswerCallbackQueryWithContext(ctx.Context(), tgapi.AnswerCallbackQuery{
|
||||||
CallbackQueryID: ctx.CallbackQueryId,
|
CallbackQueryID: ctx.CallbackQueryID,
|
||||||
Text: text, ShowAlert: showAlert, URL: url,
|
Text: text, ShowAlert: showAlert, URL: url,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -496,8 +496,8 @@ func (ctx *MsgContext) AnswerCbQueryText(text string) { ctx.answerCallbackQuery(
|
|||||||
// AnswerCbQueryAlert answers the callback query with a user-visible alert.
|
// AnswerCbQueryAlert answers the callback query with a user-visible alert.
|
||||||
func (ctx *MsgContext) AnswerCbQueryAlert(text string) { ctx.answerCallbackQuery("", text, true) }
|
func (ctx *MsgContext) AnswerCbQueryAlert(text string) { ctx.answerCallbackQuery("", text, true) }
|
||||||
|
|
||||||
// AnswerCbQueryUrl answers the callback query with a URL redirect.
|
// AnswerCbQueryURL answers the callback query with a URL redirect.
|
||||||
func (ctx *MsgContext) AnswerCbQueryUrl(u string) { ctx.answerCallbackQuery(u, "", false) }
|
func (ctx *MsgContext) AnswerCbQueryURL(u string) { ctx.answerCallbackQuery(u, "", false) }
|
||||||
|
|
||||||
// SendAction sends a chat action (typing, uploading_photo, etc.) to indicate bot activity.
|
// SendAction sends a chat action (typing, uploading_photo, etc.) to indicate bot activity.
|
||||||
func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) {
|
func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) {
|
||||||
@@ -511,7 +511,7 @@ func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) {
|
|||||||
if ctx.Msg.MessageThreadID > 0 {
|
if ctx.Msg.MessageThreadID > 0 {
|
||||||
params.MessageThreadID = ctx.Msg.MessageThreadID
|
params.MessageThreadID = ctx.Msg.MessageThreadID
|
||||||
}
|
}
|
||||||
_, err := ctx.Api.SendChatActionWithContext(ctx.Context(), params)
|
_, err := ctx.API.SendChatActionWithContext(ctx.Context(), params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Logger.Errorln(err)
|
ctx.Logger.Errorln(err)
|
||||||
}
|
}
|
||||||
@@ -528,7 +528,7 @@ func (ctx *MsgContext) error(err error) {
|
|||||||
}
|
}
|
||||||
text := fmt.Sprintf(ctx.errorTemplate, err.Error())
|
text := fmt.Sprintf(ctx.errorTemplate, err.Error())
|
||||||
|
|
||||||
if ctx.CallbackQueryId != "" {
|
if ctx.CallbackQueryID != "" {
|
||||||
ctx.answerCallbackQuery("", text, false)
|
ctx.answerCallbackQuery("", text, false)
|
||||||
} else {
|
} else {
|
||||||
ctx.answer(text, nil, tgapi.ParseNone)
|
ctx.answer(text, nil, tgapi.ParseNone)
|
||||||
@@ -543,7 +543,7 @@ func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft {
|
|||||||
ctx.Logger.Errorln(ErrMessageContextNil)
|
ctx.Logger.Errorln(ErrMessageContextNil)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if ctx.Api == nil {
|
if ctx.API == nil {
|
||||||
ctx.Logger.Errorln(ErrAPIIsNil)
|
ctx.Logger.Errorln(ErrAPIIsNil)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -552,10 +552,10 @@ func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if ctx.Api.Limiter != nil {
|
if ctx.API.Limiter != nil {
|
||||||
c, cancel := context.WithTimeout(ctx.Context(), 5*time.Second)
|
c, cancel := context.WithTimeout(ctx.Context(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil {
|
if err := ctx.API.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil {
|
||||||
ctx.Logger.Errorln(err)
|
ctx.Logger.Errorln(err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-26
@@ -10,7 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
"git.scuroneko.dev/scuroneko/slog"
|
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
|
func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
|
||||||
@@ -35,7 +35,7 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
|
|||||||
|
|
||||||
api := tgapi.NewAPI(
|
api := tgapi.NewAPI(
|
||||||
tgapi.NewAPIOpts("token").
|
tgapi.NewAPIOpts("token").
|
||||||
SetAPIUrl("https://example.test").
|
SetAPIURL("https://example.test").
|
||||||
SetHTTPClient(client),
|
SetHTTPClient(client),
|
||||||
)
|
)
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -45,12 +45,12 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
ctx := &MsgContext{
|
ctx := &MsgContext{
|
||||||
Api: api,
|
API: api,
|
||||||
Msg: &tgapi.Message{
|
Msg: &tgapi.Message{
|
||||||
Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate},
|
Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate},
|
||||||
DirectMessageTopic: &tgapi.DirectMessageTopic{TopicID: 77},
|
DirectMessageTopic: &tgapi.DirectMessageTopic{TopicID: 77},
|
||||||
},
|
},
|
||||||
Logger: slog.CreateLogger(),
|
Logger: sneklog.NewLogger(),
|
||||||
}
|
}
|
||||||
|
|
||||||
answer := ctx.AnswerPhoto("photo-id", "caption")
|
answer := ctx.AnswerPhoto("photo-id", "caption")
|
||||||
@@ -190,7 +190,7 @@ func TestErrorDefaultRemainsUserVisibleForMessageFlow(t *testing.T) {
|
|||||||
|
|
||||||
api := tgapi.NewAPI(
|
api := tgapi.NewAPI(
|
||||||
tgapi.NewAPIOpts("token").
|
tgapi.NewAPIOpts("token").
|
||||||
SetAPIUrl("https://example.test").
|
SetAPIURL("https://example.test").
|
||||||
SetHTTPClient(client),
|
SetHTTPClient(client),
|
||||||
)
|
)
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -200,9 +200,9 @@ func TestErrorDefaultRemainsUserVisibleForMessageFlow(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
ctx := &MsgContext{
|
ctx := &MsgContext{
|
||||||
Api: api,
|
API: api,
|
||||||
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}},
|
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}},
|
||||||
Logger: slog.CreateLogger(),
|
Logger: sneklog.NewLogger(),
|
||||||
errorTemplate: "Error: %s",
|
errorTemplate: "Error: %s",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +226,7 @@ func TestErrorInternalSkipsUserReplyForMessageFlow(t *testing.T) {
|
|||||||
|
|
||||||
api := tgapi.NewAPI(
|
api := tgapi.NewAPI(
|
||||||
tgapi.NewAPIOpts("token").
|
tgapi.NewAPIOpts("token").
|
||||||
SetAPIUrl("https://example.test").
|
SetAPIURL("https://example.test").
|
||||||
SetHTTPClient(client),
|
SetHTTPClient(client),
|
||||||
)
|
)
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -236,9 +236,9 @@ func TestErrorInternalSkipsUserReplyForMessageFlow(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
ctx := &MsgContext{
|
ctx := &MsgContext{
|
||||||
Api: api,
|
API: api,
|
||||||
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}},
|
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}},
|
||||||
Logger: slog.CreateLogger(),
|
Logger: sneklog.NewLogger(),
|
||||||
errorTemplate: "Error: %s",
|
errorTemplate: "Error: %s",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +255,7 @@ func TestErrorInternalSkipsCallbackAnswer(t *testing.T) {
|
|||||||
|
|
||||||
api := tgapi.NewAPI(
|
api := tgapi.NewAPI(
|
||||||
tgapi.NewAPIOpts("token").
|
tgapi.NewAPIOpts("token").
|
||||||
SetAPIUrl("https://example.test").
|
SetAPIURL("https://example.test").
|
||||||
SetHTTPClient(client),
|
SetHTTPClient(client),
|
||||||
)
|
)
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -265,10 +265,10 @@ func TestErrorInternalSkipsCallbackAnswer(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
ctx := &MsgContext{
|
ctx := &MsgContext{
|
||||||
Api: api,
|
API: api,
|
||||||
Logger: slog.CreateLogger(),
|
Logger: sneklog.NewLogger(),
|
||||||
errorTemplate: "%s",
|
errorTemplate: "%s",
|
||||||
CallbackQueryId: "cb-1",
|
CallbackQueryID: "cb-1",
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.error(AsInternalError(errors.New("boom")))
|
ctx.error(AsInternalError(errors.New("boom")))
|
||||||
@@ -298,7 +298,7 @@ func TestErrorUserVisibleAnswersCallback(t *testing.T) {
|
|||||||
|
|
||||||
api := tgapi.NewAPI(
|
api := tgapi.NewAPI(
|
||||||
tgapi.NewAPIOpts("token").
|
tgapi.NewAPIOpts("token").
|
||||||
SetAPIUrl("https://example.test").
|
SetAPIURL("https://example.test").
|
||||||
SetHTTPClient(client),
|
SetHTTPClient(client),
|
||||||
)
|
)
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -308,10 +308,10 @@ func TestErrorUserVisibleAnswersCallback(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
ctx := &MsgContext{
|
ctx := &MsgContext{
|
||||||
Api: api,
|
API: api,
|
||||||
Logger: slog.CreateLogger(),
|
Logger: sneklog.NewLogger(),
|
||||||
errorTemplate: "Oops: %s",
|
errorTemplate: "Oops: %s",
|
||||||
CallbackQueryId: "cb-1",
|
CallbackQueryID: "cb-1",
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.error(AsUserError(errors.New("boom")))
|
ctx.error(AsUserError(errors.New("boom")))
|
||||||
@@ -327,7 +327,7 @@ func TestErrorUserVisibleAnswersCallback(t *testing.T) {
|
|||||||
func TestAnswerRejectsEmptyMessage(t *testing.T) {
|
func TestAnswerRejectsEmptyMessage(t *testing.T) {
|
||||||
ctx := &MsgContext{
|
ctx := &MsgContext{
|
||||||
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}},
|
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}},
|
||||||
Logger: slog.CreateLogger(),
|
Logger: sneklog.NewLogger(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if answer := ctx.Answer(""); answer != nil {
|
if answer := ctx.Answer(""); answer != nil {
|
||||||
@@ -345,7 +345,7 @@ func TestAnswerRejectsLongMessageWithoutSendingRequest(t *testing.T) {
|
|||||||
|
|
||||||
api := tgapi.NewAPI(
|
api := tgapi.NewAPI(
|
||||||
tgapi.NewAPIOpts("token").
|
tgapi.NewAPIOpts("token").
|
||||||
SetAPIUrl("https://example.test").
|
SetAPIURL("https://example.test").
|
||||||
SetHTTPClient(client),
|
SetHTTPClient(client),
|
||||||
)
|
)
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -355,9 +355,9 @@ func TestAnswerRejectsLongMessageWithoutSendingRequest(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
ctx := &MsgContext{
|
ctx := &MsgContext{
|
||||||
Api: api,
|
API: api,
|
||||||
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}},
|
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}},
|
||||||
Logger: slog.CreateLogger(),
|
Logger: sneklog.NewLogger(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if answer := ctx.Answer(strings.Repeat("a", maxMessageTextLen+1)); answer != nil {
|
if answer := ctx.Answer(strings.Repeat("a", maxMessageTextLen+1)); answer != nil {
|
||||||
@@ -429,7 +429,7 @@ func TestAnswerLongSplitsRequestsAndAttachesKeyboardToLastChunk(t *testing.T) {
|
|||||||
|
|
||||||
api := tgapi.NewAPI(
|
api := tgapi.NewAPI(
|
||||||
tgapi.NewAPIOpts("token").
|
tgapi.NewAPIOpts("token").
|
||||||
SetAPIUrl("https://example.test").
|
SetAPIURL("https://example.test").
|
||||||
SetHTTPClient(client),
|
SetHTTPClient(client),
|
||||||
)
|
)
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -439,11 +439,11 @@ func TestAnswerLongSplitsRequestsAndAttachesKeyboardToLastChunk(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
ctx := &MsgContext{
|
ctx := &MsgContext{
|
||||||
Api: api,
|
API: api,
|
||||||
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}},
|
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}},
|
||||||
Logger: slog.CreateLogger(),
|
Logger: sneklog.NewLogger(),
|
||||||
}
|
}
|
||||||
kb := NewInlineKeyboardJson(1).AddCallbackButton("A", "cmd")
|
kb := NewInlineKeyboardJSON(1).AddCallbackButton("A", "cmd")
|
||||||
text := strings.Repeat("a", maxMessageTextLen) + " " + strings.Repeat("b", 32)
|
text := strings.Repeat("a", maxMessageTextLen) + " " + strings.Repeat("b", 32)
|
||||||
|
|
||||||
messages := ctx.KeyboardLong(text, kb)
|
messages := ctx.KeyboardLong(text, kb)
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const (
|
|||||||
HandlerSceneStepKind HandlerEventKind = "scene_step"
|
HandlerSceneStepKind HandlerEventKind = "scene_step"
|
||||||
// HandlerSceneCommandKind identifies a scene-local command handler.
|
// HandlerSceneCommandKind identifies a scene-local command handler.
|
||||||
HandlerSceneCommandKind HandlerEventKind = "scene_command"
|
HandlerSceneCommandKind HandlerEventKind = "scene_command"
|
||||||
|
// HandlerScenePayloadKind identifies a scene-local callback payload handler.
|
||||||
|
HandlerScenePayloadKind HandlerEventKind = "scene_payload"
|
||||||
// HandlerSceneMessageKind identifies a scene message fallback handler.
|
// HandlerSceneMessageKind identifies a scene message fallback handler.
|
||||||
HandlerSceneMessageKind HandlerEventKind = "scene_message"
|
HandlerSceneMessageKind HandlerEventKind = "scene_message"
|
||||||
)
|
)
|
||||||
|
|||||||
+4
-4
@@ -7,7 +7,7 @@ import (
|
|||||||
"git.scuroneko.dev/scuroneko/extypes"
|
"git.scuroneko.dev/scuroneko/extypes"
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/utils"
|
"git.scuroneko.dev/scuroneko/laniakea/utils"
|
||||||
"git.scuroneko.dev/scuroneko/slog"
|
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CommandValueType defines the expected type of command argument.
|
// CommandValueType defines the expected type of command argument.
|
||||||
@@ -169,7 +169,7 @@ type Plugin[T AppData] struct {
|
|||||||
scenes map[string]*Scene[T] // Optional scenes for multi-step interactions
|
scenes map[string]*Scene[T] // Optional scenes for multi-step interactions
|
||||||
middlewares extypes.Slice[Middleware[T]] // Shared middlewares for all commands/payloads
|
middlewares extypes.Slice[Middleware[T]] // Shared middlewares for all commands/payloads
|
||||||
skipAutoCmd bool // If true, all commands in this plugin are excluded from auto-help
|
skipAutoCmd bool // If true, all commands in this plugin are excluded from auto-help
|
||||||
logger *slog.Logger
|
logger *sneklog.Logger
|
||||||
|
|
||||||
messageFallback CommandExecutor[T]
|
messageFallback CommandExecutor[T]
|
||||||
handlers map[tgapi.UpdateType]CommandExecutor[T]
|
handlers map[tgapi.UpdateType]CommandExecutor[T]
|
||||||
@@ -264,7 +264,7 @@ func (p *Plugin[T]) AddUpdateHandler(t tgapi.UpdateType, handler CommandExecutor
|
|||||||
switch t {
|
switch t {
|
||||||
case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost, tgapi.UpdateTypeCallbackQuery:
|
case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost, tgapi.UpdateTypeCallbackQuery:
|
||||||
if p.logger == nil {
|
if p.logger == nil {
|
||||||
logger := utils.CreateLogger(p.name, utils.GetLoggerLevel())
|
logger := utils.CreateLogger(p.name, utils.GetLoggerLevel(), utils.LogFormatText, nil)
|
||||||
logger.Warnf("%s can't be registred through AddUpdateHandler. Use AddPayload/NewPayload or AddCommand/NewCommand", t)
|
logger.Warnf("%s can't be registred through AddUpdateHandler. Use AddPayload/NewPayload or AddCommand/NewCommand", t)
|
||||||
_ = logger.Close()
|
_ = logger.Close()
|
||||||
return p
|
return p
|
||||||
@@ -293,7 +293,7 @@ func (p *Plugin[T]) SkipCommandAutoGen() *Plugin[T] {
|
|||||||
//
|
//
|
||||||
// Call this before Bot.AddPlugins. If the plugin is already registered, changing
|
// Call this before Bot.AddPlugins. If the plugin is already registered, changing
|
||||||
// the original *Plugin does not update the Bot's internal copy.
|
// the original *Plugin does not update the Bot's internal copy.
|
||||||
func (p *Plugin[T]) SetLogger(l *slog.Logger) *Plugin[T] {
|
func (p *Plugin[T]) SetLogger(l *sneklog.Logger) *Plugin[T] {
|
||||||
p.logger = l
|
p.logger = l
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ func RequireChatAdmin[T AppData]() Policy[T] {
|
|||||||
return AsInternalError(errors.New("chat-admin policy requires message chat context"))
|
return AsInternalError(errors.New("chat-admin policy requires message chat context"))
|
||||||
}
|
}
|
||||||
|
|
||||||
member, err := ctx.Api.GetChatMember(tgapi.GetChatMember{
|
member, err := ctx.API.GetChatMember(tgapi.GetChatMember{
|
||||||
ChatID: ctx.ChatID,
|
ChatID: ctx.ChatID,
|
||||||
UserID: ctx.FromID,
|
UserID: ctx.FromID,
|
||||||
})
|
})
|
||||||
@@ -166,7 +166,7 @@ func RequireChatCreator[T AppData]() Policy[T] {
|
|||||||
return AsInternalError(errors.New("chat-creator policy requires message chat context"))
|
return AsInternalError(errors.New("chat-creator policy requires message chat context"))
|
||||||
}
|
}
|
||||||
|
|
||||||
member, err := ctx.Api.GetChatMember(tgapi.GetChatMember{
|
member, err := ctx.API.GetChatMember(tgapi.GetChatMember{
|
||||||
ChatID: ctx.ChatID,
|
ChatID: ctx.ChatID,
|
||||||
UserID: ctx.FromID,
|
UserID: ctx.FromID,
|
||||||
})
|
})
|
||||||
@@ -189,12 +189,12 @@ func RequireBotAdmin[T AppData]() Policy[T] {
|
|||||||
return AsInternalError(errors.New("bot-admin policy requires message chat context"))
|
return AsInternalError(errors.New("bot-admin policy requires message chat context"))
|
||||||
}
|
}
|
||||||
|
|
||||||
bot, err := ctx.Api.GetMe()
|
bot, err := ctx.API.GetMe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AsInternalError(fmt.Errorf("failed to fetch bot info: %w", err))
|
return AsInternalError(fmt.Errorf("failed to fetch bot info: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
member, err := ctx.Api.GetChatMember(tgapi.GetChatMember{
|
member, err := ctx.API.GetChatMember(tgapi.GetChatMember{
|
||||||
ChatID: ctx.ChatID,
|
ChatID: ctx.ChatID,
|
||||||
UserID: bot.ID,
|
UserID: bot.ID,
|
||||||
})
|
})
|
||||||
|
|||||||
+17
-17
@@ -10,7 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
"git.scuroneko.dev/scuroneko/slog"
|
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRequirePolicyStopsExecutionOnDeniedPolicy(t *testing.T) {
|
func TestRequirePolicyStopsExecutionOnDeniedPolicy(t *testing.T) {
|
||||||
@@ -37,7 +37,7 @@ func TestRequirePolicyStopsExecutionOnDeniedPolicy(t *testing.T) {
|
|||||||
|
|
||||||
api := tgapi.NewAPI(
|
api := tgapi.NewAPI(
|
||||||
tgapi.NewAPIOpts("token").
|
tgapi.NewAPIOpts("token").
|
||||||
SetAPIUrl("https://example.test").
|
SetAPIURL("https://example.test").
|
||||||
SetHTTPClient(client),
|
SetHTTPClient(client),
|
||||||
)
|
)
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -47,9 +47,9 @@ func TestRequirePolicyStopsExecutionOnDeniedPolicy(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
ctx := &MsgContext{
|
ctx := &MsgContext{
|
||||||
Api: api,
|
API: api,
|
||||||
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}},
|
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}},
|
||||||
Logger: slog.CreateLogger(),
|
Logger: sneklog.NewLogger(),
|
||||||
errorTemplate: "Error: %s",
|
errorTemplate: "Error: %s",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ func TestRequirePrivateChatAllowsPrivateChat(t *testing.T) {
|
|||||||
Msg: &tgapi.Message{
|
Msg: &tgapi.Message{
|
||||||
Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate},
|
Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate},
|
||||||
},
|
},
|
||||||
Logger: slog.CreateLogger(),
|
Logger: sneklog.NewLogger(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := RequirePrivateChat[NoData]()(ctx, NoData{}); err != nil {
|
if err := RequirePrivateChat[NoData]()(ctx, NoData{}); err != nil {
|
||||||
@@ -86,7 +86,7 @@ func TestRequirePrivateChatDeniesNonPrivateChat(t *testing.T) {
|
|||||||
Msg: &tgapi.Message{
|
Msg: &tgapi.Message{
|
||||||
Chat: &tgapi.Chat{ID: -100, Type: tgapi.ChatTypeSupergroup},
|
Chat: &tgapi.Chat{ID: -100, Type: tgapi.ChatTypeSupergroup},
|
||||||
},
|
},
|
||||||
Logger: slog.CreateLogger(),
|
Logger: sneklog.NewLogger(),
|
||||||
}
|
}
|
||||||
|
|
||||||
err := RequirePrivateChat[NoData]()(ctx, NoData{})
|
err := RequirePrivateChat[NoData]()(ctx, NoData{})
|
||||||
@@ -127,7 +127,7 @@ func TestRequireChatAdminUsesNormalizedIDs(t *testing.T) {
|
|||||||
|
|
||||||
api := tgapi.NewAPI(
|
api := tgapi.NewAPI(
|
||||||
tgapi.NewAPIOpts("token").
|
tgapi.NewAPIOpts("token").
|
||||||
SetAPIUrl("https://example.test").
|
SetAPIURL("https://example.test").
|
||||||
SetHTTPClient(client),
|
SetHTTPClient(client),
|
||||||
)
|
)
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -137,10 +137,10 @@ func TestRequireChatAdminUsesNormalizedIDs(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
ctx := &MsgContext{
|
ctx := &MsgContext{
|
||||||
Api: api,
|
API: api,
|
||||||
ChatID: -2001,
|
ChatID: -2001,
|
||||||
FromID: 55,
|
FromID: 55,
|
||||||
Logger: slog.CreateLogger(),
|
Logger: sneklog.NewLogger(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := RequireChatAdmin[NoData]()(ctx, NoData{}); err != nil {
|
if err := RequireChatAdmin[NoData]()(ctx, NoData{}); err != nil {
|
||||||
@@ -168,7 +168,7 @@ func TestAllPoliciesReturnsFirstError(t *testing.T) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
err := policy(&MsgContext{Logger: slog.CreateLogger()}, NoData{})
|
err := policy(&MsgContext{Logger: sneklog.NewLogger()}, NoData{})
|
||||||
if !errors.Is(err, want) {
|
if !errors.Is(err, want) {
|
||||||
t.Fatalf("expected first policy error, got %v", err)
|
t.Fatalf("expected first policy error, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -180,7 +180,7 @@ func TestAnyPolicyAllowsLaterSuccessAfterInternalError(t *testing.T) {
|
|||||||
func(ctx *MsgContext, data NoData) error { return nil },
|
func(ctx *MsgContext, data NoData) error { return nil },
|
||||||
)
|
)
|
||||||
|
|
||||||
if err := policy(&MsgContext{Logger: slog.CreateLogger()}, NoData{}); err != nil {
|
if err := policy(&MsgContext{Logger: sneklog.NewLogger()}, NoData{}); err != nil {
|
||||||
t.Fatalf("expected later success to allow access, got %v", err)
|
t.Fatalf("expected later success to allow access, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,7 +192,7 @@ func TestAnyPolicyReturnsInternalErrorWhenNonePass(t *testing.T) {
|
|||||||
func(ctx *MsgContext, data NoData) error { return internal },
|
func(ctx *MsgContext, data NoData) error { return internal },
|
||||||
)
|
)
|
||||||
|
|
||||||
err := policy(&MsgContext{Logger: slog.CreateLogger()}, NoData{})
|
err := policy(&MsgContext{Logger: sneklog.NewLogger()}, NoData{})
|
||||||
if !errors.Is(err, internal) {
|
if !errors.Is(err, internal) {
|
||||||
t.Fatalf("expected internal error, got %v", err)
|
t.Fatalf("expected internal error, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -205,7 +205,7 @@ func TestAnyPolicyReturnsFirstDenyWhenNoPolicyPasses(t *testing.T) {
|
|||||||
func(ctx *MsgContext, data NoData) error { return AsUserError(errors.New("second deny")) },
|
func(ctx *MsgContext, data NoData) error { return AsUserError(errors.New("second deny")) },
|
||||||
)
|
)
|
||||||
|
|
||||||
err := policy(&MsgContext{Logger: slog.CreateLogger()}, NoData{})
|
err := policy(&MsgContext{Logger: sneklog.NewLogger()}, NoData{})
|
||||||
if !errors.Is(err, first) {
|
if !errors.Is(err, first) {
|
||||||
t.Fatalf("expected first deny error, got %v", err)
|
t.Fatalf("expected first deny error, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -215,7 +215,7 @@ func TestNotPolicyInvertsUserDenyButPreservesInternalErrors(t *testing.T) {
|
|||||||
inverted := NotPolicy(func(ctx *MsgContext, data NoData) error {
|
inverted := NotPolicy(func(ctx *MsgContext, data NoData) error {
|
||||||
return AsUserError(errors.New("denied"))
|
return AsUserError(errors.New("denied"))
|
||||||
})
|
})
|
||||||
if err := inverted(&MsgContext{Logger: slog.CreateLogger()}, NoData{}); err != nil {
|
if err := inverted(&MsgContext{Logger: sneklog.NewLogger()}, NoData{}); err != nil {
|
||||||
t.Fatalf("expected inverted deny to succeed, got %v", err)
|
t.Fatalf("expected inverted deny to succeed, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,7 +223,7 @@ func TestNotPolicyInvertsUserDenyButPreservesInternalErrors(t *testing.T) {
|
|||||||
preserve := NotPolicy(func(ctx *MsgContext, data NoData) error {
|
preserve := NotPolicy(func(ctx *MsgContext, data NoData) error {
|
||||||
return internal
|
return internal
|
||||||
})
|
})
|
||||||
err := preserve(&MsgContext{Logger: slog.CreateLogger()}, NoData{})
|
err := preserve(&MsgContext{Logger: sneklog.NewLogger()}, NoData{})
|
||||||
if !errors.Is(err, internal) {
|
if !errors.Is(err, internal) {
|
||||||
t.Fatalf("expected internal error to be preserved, got %v", err)
|
t.Fatalf("expected internal error to be preserved, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -233,7 +233,7 @@ func TestRequirePolicyEmitsObserverEvents(t *testing.T) {
|
|||||||
t.Run("allow", func(t *testing.T) {
|
t.Run("allow", func(t *testing.T) {
|
||||||
observer := &recordingObserver{}
|
observer := &recordingObserver{}
|
||||||
ctx := &MsgContext{
|
ctx := &MsgContext{
|
||||||
Logger: slog.CreateLogger(),
|
Logger: sneklog.NewLogger(),
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
observer: observer,
|
observer: observer,
|
||||||
FromID: 10,
|
FromID: 10,
|
||||||
@@ -258,7 +258,7 @@ func TestRequirePolicyEmitsObserverEvents(t *testing.T) {
|
|||||||
t.Run("deny", func(t *testing.T) {
|
t.Run("deny", func(t *testing.T) {
|
||||||
observer := &recordingObserver{}
|
observer := &recordingObserver{}
|
||||||
ctx := &MsgContext{
|
ctx := &MsgContext{
|
||||||
Logger: slog.CreateLogger(),
|
Logger: sneklog.NewLogger(),
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
observer: observer,
|
observer: observer,
|
||||||
errorTemplate: "%s",
|
errorTemplate: "%s",
|
||||||
|
|||||||
+4
-4
@@ -7,7 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/slog"
|
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type runnerObserver struct {
|
type runnerObserver struct {
|
||||||
@@ -17,7 +17,7 @@ type runnerObserver struct {
|
|||||||
func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) {
|
func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) {
|
||||||
var calls atomic.Int32
|
var calls atomic.Int32
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
runners: []Runner[NoData]{
|
runners: []Runner[NoData]{
|
||||||
NewRunner("sync-once", func(*Bot[NoData]) error {
|
NewRunner("sync-once", func(*Bot[NoData]) error {
|
||||||
calls.Add(1)
|
calls.Add(1)
|
||||||
@@ -39,7 +39,7 @@ func TestExecRunnersStopsBackgroundRunnerOnCancel(t *testing.T) {
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
runners: []Runner[NoData]{
|
runners: []Runner[NoData]{
|
||||||
NewRunner("background", func(*Bot[NoData]) error {
|
NewRunner("background", func(*Bot[NoData]) error {
|
||||||
if calls.Add(1) == 1 {
|
if calls.Add(1) == 1 {
|
||||||
@@ -71,7 +71,7 @@ func TestExecRunnersEmitObserverEvents(t *testing.T) {
|
|||||||
wantErr := errors.New("runner failed")
|
wantErr := errors.New("runner failed")
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
observer: observer,
|
observer: observer,
|
||||||
runners: []Runner[NoData]{
|
runners: []Runner[NoData]{
|
||||||
NewRunner("sync-once", func(*Bot[NoData]) error {
|
NewRunner("sync-once", func(*Bot[NoData]) error {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type Scene[T any] struct {
|
|||||||
|
|
||||||
steps map[string]SceneHandler[T]
|
steps map[string]SceneHandler[T]
|
||||||
commands map[string]SceneHandler[T]
|
commands map[string]SceneHandler[T]
|
||||||
|
payloads map[string]SceneHandler[T]
|
||||||
message SceneHandler[T]
|
message SceneHandler[T]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ func NewScene[T any](name string) *Scene[T] {
|
|||||||
Entry: "",
|
Entry: "",
|
||||||
steps: make(map[string]SceneHandler[T]),
|
steps: make(map[string]SceneHandler[T]),
|
||||||
commands: make(map[string]SceneHandler[T]),
|
commands: make(map[string]SceneHandler[T]),
|
||||||
|
payloads: make(map[string]SceneHandler[T]),
|
||||||
message: nil,
|
message: nil,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,6 +67,12 @@ func (s *Scene[T]) OnCommand(cmd string, handler SceneHandler[T]) *Scene[T] {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OnPayload registers a callback payload handler active while the scene is running.
|
||||||
|
func (s *Scene[T]) OnPayload(cmd string, handler SceneHandler[T]) *Scene[T] {
|
||||||
|
s.payloads[cmd] = handler
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// OnMessage registers a fallback handler used when no scene command or step matches.
|
// OnMessage registers a fallback handler used when no scene command or step matches.
|
||||||
func (s *Scene[T]) OnMessage(handler SceneHandler[T]) *Scene[T] {
|
func (s *Scene[T]) OnMessage(handler SceneHandler[T]) *Scene[T] {
|
||||||
s.message = handler
|
s.message = handler
|
||||||
@@ -79,6 +87,14 @@ func (s *Scene[T]) executeCommand(cmd string, ctx *SceneContext, db T) (SceneRes
|
|||||||
result, err := handler(ctx, db)
|
result, err := handler(ctx, db)
|
||||||
return result, true, err
|
return result, true, err
|
||||||
}
|
}
|
||||||
|
func (s *Scene[T]) executePayload(cmd string, ctx *SceneContext, db T) (SceneResult, bool, error) {
|
||||||
|
handler, ok := s.payloads[cmd]
|
||||||
|
if !ok {
|
||||||
|
return SceneResult{}, false, nil
|
||||||
|
}
|
||||||
|
result, err := handler(ctx, db)
|
||||||
|
return result, true, err
|
||||||
|
}
|
||||||
func (s *Scene[T]) executeStep(step string, ctx *SceneContext, db T) (SceneResult, bool, error) {
|
func (s *Scene[T]) executeStep(step string, ctx *SceneContext, db T) (SceneResult, bool, error) {
|
||||||
handler, ok := s.steps[step]
|
handler, ok := s.steps[step]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
+38
-1
@@ -86,6 +86,41 @@ func (bot *Bot[T]) executeScene(ctx *SceneContext, scene *Scene[T]) (bool, error
|
|||||||
// instead of also triggering the active scene step or fallback handler.
|
// instead of also triggering the active scene step or fallback handler.
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
query := ctx.Update.CallbackQuery
|
||||||
|
if query != nil {
|
||||||
|
data, err := bot.decodePayload(query.Data)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
ctx.Args = data.Args
|
||||||
|
cmd := data.Command
|
||||||
|
if _, ok := scene.payloads[cmd]; ok {
|
||||||
|
startTime := time.Now()
|
||||||
|
bot.emitSceneStarted(ctx, scene, HandlerScenePayloadKind, cmd)
|
||||||
|
res, _, err := scene.executePayload(cmd, ctx, bot.appData)
|
||||||
|
if err != nil {
|
||||||
|
bot.emitSceneFinished(ctx, scene, HandlerScenePayloadKind, cmd, startTime, err)
|
||||||
|
bot.emitSceneError(ctx, scene, HandlerScenePayloadKind, cmd, err)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
from := ctx.sess.Step
|
||||||
|
ok, err := bot.applySceneResult(scene, ctx, res)
|
||||||
|
bot.emitSceneFinished(ctx, scene, HandlerScenePayloadKind, cmd, startTime, err)
|
||||||
|
if err != nil {
|
||||||
|
bot.emitSceneError(ctx, scene, HandlerScenePayloadKind, cmd, err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
bot.emitSceneTransition(ctx, scene, from, res)
|
||||||
|
}
|
||||||
|
return ok, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmatched payloads should not trigger the active scene step or fallback handler.
|
||||||
|
// This allows using payloads for other bot features like pagination without interfering with active scenes.
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
ctx.Text = text
|
ctx.Text = text
|
||||||
ctx.Args = nil
|
ctx.Args = nil
|
||||||
ctx.Prefix = ""
|
ctx.Prefix = ""
|
||||||
@@ -183,12 +218,14 @@ func (bot *Bot[T]) emitSceneTransition(ctx *SceneContext, scene *Scene[T], from
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
to := from
|
var to string
|
||||||
switch result.Action {
|
switch result.Action {
|
||||||
case SceneActionNext:
|
case SceneActionNext:
|
||||||
to = result.Next
|
to = result.Next
|
||||||
case SceneActionExit:
|
case SceneActionExit:
|
||||||
to = ""
|
to = ""
|
||||||
|
default:
|
||||||
|
to = from
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.safeEmitEvent(ctx.Context(), SceneTransitionEvent{
|
bot.safeEmitEvent(ctx.Context(), SceneTransitionEvent{
|
||||||
|
|||||||
+215
-17
@@ -6,7 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
"git.scuroneko.dev/scuroneko/slog"
|
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type failingSessionStore struct {
|
type failingSessionStore struct {
|
||||||
@@ -15,15 +15,15 @@ type failingSessionStore struct {
|
|||||||
deleteErr error
|
deleteErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s failingSessionStore) Get(key string) (SceneSession, error) {
|
func (s failingSessionStore) Get(string) (SceneSession, error) {
|
||||||
return SceneSession{}, s.getErr
|
return SceneSession{}, s.getErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s failingSessionStore) Set(key string, session SceneSession) error {
|
func (s failingSessionStore) Set(string, SceneSession) error {
|
||||||
return s.setErr
|
return s.setErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s failingSessionStore) Delete(key string) error {
|
func (s failingSessionStore) Delete(string) error {
|
||||||
return s.deleteErr
|
return s.deleteErr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
prefixes: []string{"/"},
|
prefixes: []string{"/"},
|
||||||
sessionStore: NewMemorySessionStore(),
|
sessionStore: NewMemorySessionStore(),
|
||||||
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
||||||
@@ -149,7 +149,7 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) {
|
|||||||
plugin.NewScene("signup")
|
plugin.NewScene("signup")
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
sessionStore: NewMemorySessionStore(),
|
sessionStore: NewMemorySessionStore(),
|
||||||
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
||||||
}
|
}
|
||||||
@@ -172,7 +172,7 @@ func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) {
|
|||||||
plugin.NewScene("signup").SetEntry("start")
|
plugin.NewScene("signup").SetEntry("start")
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
sessionStore: NewMemorySessionStore(),
|
sessionStore: NewMemorySessionStore(),
|
||||||
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
||||||
}
|
}
|
||||||
@@ -231,7 +231,7 @@ func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
prefixes: []string{"/"},
|
prefixes: []string{"/"},
|
||||||
sessionStore: NewMemorySessionStore(),
|
sessionStore: NewMemorySessionStore(),
|
||||||
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
||||||
@@ -279,7 +279,7 @@ func TestSceneCommandObserverEmitsLifecycleEvents(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
prefixes: []string{"/"},
|
prefixes: []string{"/"},
|
||||||
sessionStore: NewMemorySessionStore(),
|
sessionStore: NewMemorySessionStore(),
|
||||||
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
||||||
@@ -331,7 +331,7 @@ func TestSceneStepObserverEmitsLifecycleEvents(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
prefixes: []string{"/"},
|
prefixes: []string{"/"},
|
||||||
sessionStore: NewMemorySessionStore(),
|
sessionStore: NewMemorySessionStore(),
|
||||||
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
||||||
@@ -386,7 +386,7 @@ func TestSceneMessageObserverEmitsLifecycleEvents(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
prefixes: []string{"/"},
|
prefixes: []string{"/"},
|
||||||
sessionStore: NewMemorySessionStore(),
|
sessionStore: NewMemorySessionStore(),
|
||||||
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
||||||
@@ -430,6 +430,204 @@ func TestSceneMessageObserverEmitsLifecycleEvents(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestScenePayloadHandlerRunsBeforeStep(t *testing.T) {
|
||||||
|
payloadCalled := false
|
||||||
|
stepCalled := false
|
||||||
|
|
||||||
|
plugin := NewPlugin[NoData]("wizard")
|
||||||
|
plugin.NewScene("signup").
|
||||||
|
SetEntry("start").
|
||||||
|
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
|
||||||
|
stepCalled = true
|
||||||
|
return ctx.Stay(), nil
|
||||||
|
}).
|
||||||
|
OnPayload("confirm", func(ctx *SceneContext, db NoData) (SceneResult, error) {
|
||||||
|
payloadCalled = true
|
||||||
|
if got, want := ctx.Args, []string{"7", "ok"}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||||
|
t.Fatalf("unexpected payload args: got %v want %v", got, want)
|
||||||
|
}
|
||||||
|
if ctx.Text != "" {
|
||||||
|
t.Fatalf("callback flow must not populate Text, got %q", ctx.Text)
|
||||||
|
}
|
||||||
|
return ctx.Exit(), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
bot := &Bot[NoData]{
|
||||||
|
logger: sneklog.NewLogger(),
|
||||||
|
payloadType: BotPayloadJSON,
|
||||||
|
sessionStore: NewMemorySessionStore(),
|
||||||
|
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
||||||
|
}
|
||||||
|
bot.AddPlugins(plugin)
|
||||||
|
|
||||||
|
enterCtx := &MsgContext{
|
||||||
|
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}},
|
||||||
|
FromID: 42,
|
||||||
|
sceneRuntime: bot,
|
||||||
|
}
|
||||||
|
if err := enterCtx.EnterScene("signup"); err != nil {
|
||||||
|
t.Fatalf("EnterScene returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := encodeJSONPayload(CallbackData{Command: "confirm", Args: []string{"7", "ok"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encodeJSONPayload returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.handle(context.Background(), &tgapi.Update{
|
||||||
|
UpdateID: 25,
|
||||||
|
Type: tgapi.UpdateTypeCallbackQuery,
|
||||||
|
CallbackQuery: &tgapi.CallbackQuery{
|
||||||
|
ID: "cb-scene",
|
||||||
|
Data: data,
|
||||||
|
From: tgapi.User{ID: 42},
|
||||||
|
Message: &tgapi.Message{
|
||||||
|
MessageID: 12,
|
||||||
|
Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if !payloadCalled {
|
||||||
|
t.Fatal("expected scene payload handler to be called")
|
||||||
|
}
|
||||||
|
if stepCalled {
|
||||||
|
t.Fatal("expected scene payload to short-circuit the active step")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScenePayloadObserverEmitsLifecycleEvents(t *testing.T) {
|
||||||
|
observer := &recordingObserver{}
|
||||||
|
plugin := NewPlugin[NoData]("wizard")
|
||||||
|
plugin.NewScene("signup").
|
||||||
|
SetEntry("start").
|
||||||
|
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
|
||||||
|
return ctx.Stay(), nil
|
||||||
|
}).
|
||||||
|
OnPayload("confirm", func(ctx *SceneContext, db NoData) (SceneResult, error) {
|
||||||
|
return ctx.Exit(), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
bot := &Bot[NoData]{
|
||||||
|
logger: sneklog.NewLogger(),
|
||||||
|
payloadType: BotPayloadJSON,
|
||||||
|
sessionStore: NewMemorySessionStore(),
|
||||||
|
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
||||||
|
observer: observer,
|
||||||
|
}
|
||||||
|
bot.AddPlugins(plugin)
|
||||||
|
|
||||||
|
enterCtx := &MsgContext{
|
||||||
|
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}},
|
||||||
|
FromID: 42,
|
||||||
|
sceneRuntime: bot,
|
||||||
|
}
|
||||||
|
if err := enterCtx.EnterScene("signup"); err != nil {
|
||||||
|
t.Fatalf("EnterScene returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := encodeJSONPayload(CallbackData{Command: "confirm"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encodeJSONPayload returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.handle(context.Background(), &tgapi.Update{
|
||||||
|
UpdateID: 26,
|
||||||
|
Type: tgapi.UpdateTypeCallbackQuery,
|
||||||
|
CallbackQuery: &tgapi.CallbackQuery{
|
||||||
|
ID: "cb-scene",
|
||||||
|
Data: data,
|
||||||
|
From: tgapi.User{ID: 42},
|
||||||
|
Message: &tgapi.Message{
|
||||||
|
MessageID: 13,
|
||||||
|
Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(observer.started) != 1 {
|
||||||
|
t.Fatalf("expected one scene started event, got %d", len(observer.started))
|
||||||
|
}
|
||||||
|
if got := observer.started[0]; got.HandlerKind != HandlerScenePayloadKind || got.HandlerName != "confirm" || got.Plugin != "wizard" {
|
||||||
|
t.Fatalf("unexpected scene payload started event: %#v", got)
|
||||||
|
}
|
||||||
|
if len(observer.finished) != 1 {
|
||||||
|
t.Fatalf("expected one scene finished event, got %d", len(observer.finished))
|
||||||
|
}
|
||||||
|
if got := observer.finished[0]; got.HandlerKind != HandlerScenePayloadKind || got.HandlerName != "confirm" || got.Plugin != "wizard" || got.Err != nil {
|
||||||
|
t.Fatalf("unexpected scene payload finished event: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSceneUnmatchedPayloadFallsThroughWithoutRunningStep(t *testing.T) {
|
||||||
|
stepCalled := false
|
||||||
|
|
||||||
|
plugin := NewPlugin[NoData]("wizard")
|
||||||
|
plugin.NewPayload(func(ctx *MsgContext, db NoData) error { return nil }, "ping")
|
||||||
|
plugin.NewScene("signup").
|
||||||
|
SetEntry("start").
|
||||||
|
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
|
||||||
|
stepCalled = true
|
||||||
|
return ctx.Stay(), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
bot := &Bot[NoData]{
|
||||||
|
logger: sneklog.NewLogger(),
|
||||||
|
payloadType: BotPayloadJSON,
|
||||||
|
sessionStore: NewMemorySessionStore(),
|
||||||
|
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
||||||
|
}
|
||||||
|
bot.AddPlugins(plugin)
|
||||||
|
|
||||||
|
enterCtx := &MsgContext{
|
||||||
|
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}},
|
||||||
|
FromID: 42,
|
||||||
|
sceneRuntime: bot,
|
||||||
|
}
|
||||||
|
if err := enterCtx.EnterScene("signup"); err != nil {
|
||||||
|
t.Fatalf("EnterScene returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{
|
||||||
|
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}},
|
||||||
|
FromID: 42,
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected scene key to be built")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := encodeJSONPayload(CallbackData{Command: "ping"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encodeJSONPayload returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.handle(context.Background(), &tgapi.Update{
|
||||||
|
UpdateID: 27,
|
||||||
|
Type: tgapi.UpdateTypeCallbackQuery,
|
||||||
|
CallbackQuery: &tgapi.CallbackQuery{
|
||||||
|
ID: "cb-global",
|
||||||
|
Data: data,
|
||||||
|
From: tgapi.User{ID: 42},
|
||||||
|
Message: &tgapi.Message{
|
||||||
|
MessageID: 14,
|
||||||
|
Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if stepCalled {
|
||||||
|
t.Fatal("scene step must not run for an unmatched payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
after, err := bot.sessionStore.Get(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get after handle returned error: %v", err)
|
||||||
|
}
|
||||||
|
if after.Scene != "signup" || after.Step != "start" {
|
||||||
|
t.Fatalf("unexpected session after payload fallback: %#v", after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestScenePassDoesNotPersistSessionData(t *testing.T) {
|
func TestScenePassDoesNotPersistSessionData(t *testing.T) {
|
||||||
commandCalled := false
|
commandCalled := false
|
||||||
|
|
||||||
@@ -450,7 +648,7 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
prefixes: []string{"/"},
|
prefixes: []string{"/"},
|
||||||
sessionStore: NewMemorySessionStore(),
|
sessionStore: NewMemorySessionStore(),
|
||||||
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
||||||
@@ -526,7 +724,7 @@ func TestSceneUnmatchedCommandFallsThroughWithoutRunningStep(t *testing.T) {
|
|||||||
}, "ping")
|
}, "ping")
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
prefixes: []string{"/"},
|
prefixes: []string{"/"},
|
||||||
sessionStore: NewMemorySessionStore(),
|
sessionStore: NewMemorySessionStore(),
|
||||||
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
||||||
@@ -595,7 +793,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
prefixes: []string{"/"},
|
prefixes: []string{"/"},
|
||||||
sessionStore: NewMemorySessionStore(),
|
sessionStore: NewMemorySessionStore(),
|
||||||
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
||||||
@@ -640,7 +838,7 @@ func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) {
|
|||||||
|
|
||||||
func TestFindSceneSessionSupportsUserScopeWithoutMessage(t *testing.T) {
|
func TestFindSceneSessionSupportsUserScopeWithoutMessage(t *testing.T) {
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
sessionStore: NewMemorySessionStore(),
|
sessionStore: NewMemorySessionStore(),
|
||||||
sceneScopePriority: []SceneScope{SceneScopeUser, SceneScopeChat, SceneScopeUserChat},
|
sceneScopePriority: []SceneScope{SceneScopeUser, SceneScopeChat, SceneScopeUserChat},
|
||||||
}
|
}
|
||||||
@@ -667,7 +865,7 @@ func TestSceneStoreErrorsPropagate(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("find scene session get error", func(t *testing.T) {
|
t.Run("find scene session get error", func(t *testing.T) {
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
sessionStore: failingSessionStore{getErr: getErr},
|
sessionStore: failingSessionStore{getErr: getErr},
|
||||||
sceneScopePriority: []SceneScope{SceneScopeUser},
|
sceneScopePriority: []SceneScope{SceneScopeUser},
|
||||||
}
|
}
|
||||||
@@ -683,7 +881,7 @@ func TestSceneStoreErrorsPropagate(t *testing.T) {
|
|||||||
return ctx.Stay(), nil
|
return ctx.Stay(), nil
|
||||||
})
|
})
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: slog.CreateLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
sessionStore: failingSessionStore{setErr: setErr},
|
sessionStore: failingSessionStore{setErr: setErr},
|
||||||
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-30
@@ -10,7 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/utils"
|
"git.scuroneko.dev/scuroneko/laniakea/utils"
|
||||||
"git.scuroneko.dev/scuroneko/slog"
|
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// APIOpts holds configuration options for initializing the Telegram API client.
|
// APIOpts holds configuration options for initializing the Telegram API client.
|
||||||
@@ -19,7 +19,10 @@ type APIOpts struct {
|
|||||||
token string
|
token string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
useTestServer bool
|
useTestServer bool
|
||||||
apiUrl string
|
apiURL string
|
||||||
|
|
||||||
|
logFormat utils.LogFormat
|
||||||
|
logFormatter *sneklog.Formatter
|
||||||
|
|
||||||
limiter *utils.RateLimiter
|
limiter *utils.RateLimiter
|
||||||
dropOverflowLimit bool
|
dropOverflowLimit bool
|
||||||
@@ -32,7 +35,7 @@ func NewAPIOpts(token string) *APIOpts {
|
|||||||
token: token,
|
token: token,
|
||||||
client: nil,
|
client: nil,
|
||||||
useTestServer: false,
|
useTestServer: false,
|
||||||
apiUrl: "https://api.telegram.org",
|
apiURL: "https://api.telegram.org",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,15 +55,24 @@ func (opts *APIOpts) UseTestServer(use bool) *APIOpts {
|
|||||||
return opts
|
return opts
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAPIUrl overrides the default Telegram API URL.
|
// SetAPIURL overrides the default Telegram API URL.
|
||||||
// Useful for self-hosted bots or proxies.
|
// Useful for self-hosted bots or proxies.
|
||||||
func (opts *APIOpts) SetAPIUrl(apiUrl string) *APIOpts {
|
func (opts *APIOpts) SetAPIURL(apiURL string) *APIOpts {
|
||||||
if apiUrl != "" {
|
if apiURL != "" {
|
||||||
opts.apiUrl = apiUrl
|
opts.apiURL = apiURL
|
||||||
}
|
}
|
||||||
return opts
|
return opts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (opts *APIOpts) SetLogFormat(format utils.LogFormat) *APIOpts {
|
||||||
|
opts.logFormat = format
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
func (opts *APIOpts) SetLogFormatter(formatter *sneklog.Formatter) *APIOpts {
|
||||||
|
opts.logFormatter = formatter
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
// SetLimiter sets a rate limiter to enforce Telegram's API limits.
|
// SetLimiter sets a rate limiter to enforce Telegram's API limits.
|
||||||
// Recommended: use utils.NewRateLimiter() for correct per-chat and global throttling.
|
// Recommended: use utils.NewRateLimiter() for correct per-chat and global throttling.
|
||||||
func (opts *APIOpts) SetLimiter(limiter *utils.RateLimiter) *APIOpts {
|
func (opts *APIOpts) SetLimiter(limiter *utils.RateLimiter) *APIOpts {
|
||||||
@@ -85,9 +97,12 @@ func (opts *APIOpts) SetLimiterDrop(b bool) *APIOpts {
|
|||||||
type API struct {
|
type API struct {
|
||||||
token string
|
token string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
logger *slog.Logger
|
logger *sneklog.Logger
|
||||||
useTestServer bool
|
useTestServer bool
|
||||||
apiUrl string
|
apiURL string
|
||||||
|
|
||||||
|
logFormat utils.LogFormat
|
||||||
|
logFormatter *sneklog.Formatter
|
||||||
|
|
||||||
pool *workerPool
|
pool *workerPool
|
||||||
Limiter *utils.RateLimiter
|
Limiter *utils.RateLimiter
|
||||||
@@ -97,12 +112,13 @@ type API struct {
|
|||||||
// NewAPI creates a new API client from options.
|
// NewAPI creates a new API client from options.
|
||||||
// Always call Close() when done to release resources.
|
// Always call Close() when done to release resources.
|
||||||
func NewAPI(opts *APIOpts) *API {
|
func NewAPI(opts *APIOpts) *API {
|
||||||
l := utils.CreateLogger("API", utils.GetLoggerLevel())
|
|
||||||
if opts == nil {
|
if opts == nil {
|
||||||
l.Errorln("Set API options")
|
|
||||||
_ = l.Close()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
logger := utils.CreateLogger(
|
||||||
|
"API", utils.GetLoggerLevel(),
|
||||||
|
opts.logFormat, opts.logFormatter,
|
||||||
|
)
|
||||||
|
|
||||||
client := opts.client
|
client := opts.client
|
||||||
if client == nil {
|
if client == nil {
|
||||||
@@ -113,11 +129,15 @@ func NewAPI(opts *APIOpts) *API {
|
|||||||
pool.start()
|
pool.start()
|
||||||
|
|
||||||
return &API{
|
return &API{
|
||||||
token: opts.token,
|
token: opts.token,
|
||||||
client: client,
|
client: client,
|
||||||
logger: l,
|
logger: logger,
|
||||||
useTestServer: opts.useTestServer,
|
useTestServer: opts.useTestServer,
|
||||||
apiUrl: opts.apiUrl,
|
apiURL: opts.apiURL,
|
||||||
|
|
||||||
|
logFormat: opts.logFormat,
|
||||||
|
logFormatter: opts.logFormatter,
|
||||||
|
|
||||||
pool: pool,
|
pool: pool,
|
||||||
Limiter: opts.limiter,
|
Limiter: opts.limiter,
|
||||||
dropOverflowLimit: opts.dropOverflowLimit,
|
dropOverflowLimit: opts.dropOverflowLimit,
|
||||||
@@ -137,7 +157,7 @@ func (api *API) Close() error {
|
|||||||
|
|
||||||
// GetLogger returns the internal logger for custom logging.
|
// GetLogger returns the internal logger for custom logging.
|
||||||
// See https://core.telegram.org/bots/api
|
// See https://core.telegram.org/bots/api
|
||||||
func (api *API) GetLogger() *slog.Logger {
|
func (api *API) GetLogger() *sneklog.Logger {
|
||||||
return api.logger
|
return api.logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,9 +167,9 @@ type ResponseParameters struct {
|
|||||||
RetryAfter *int `json:"retry_after,omitempty"`
|
RetryAfter *int `json:"retry_after,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApiResponse is the standard Telegram Bot API response structure.
|
// TelegramResponse is the standard Telegram Bot API response structure.
|
||||||
// Generic over Result type R.
|
// Generic over Result type R.
|
||||||
type ApiResponse[R any] struct {
|
type TelegramResponse[R any] struct {
|
||||||
Ok bool `json:"ok"`
|
Ok bool `json:"ok"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Result R `json:"result,omitempty"`
|
Result R `json:"result,omitempty"`
|
||||||
@@ -166,7 +186,7 @@ type ApiResponse[R any] struct {
|
|||||||
type TelegramRequest[R, P any] struct {
|
type TelegramRequest[R, P any] struct {
|
||||||
method string
|
method string
|
||||||
params P
|
params P
|
||||||
chatId int64
|
chatID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRequest creates a low-level TelegramRequest with no associated chat ID.
|
// NewRequest creates a low-level TelegramRequest with no associated chat ID.
|
||||||
@@ -176,8 +196,8 @@ func NewRequest[R, P any](method string, params P) TelegramRequest[R, P] {
|
|||||||
|
|
||||||
// NewRequestWithChatID creates a low-level TelegramRequest with an associated chat ID.
|
// NewRequestWithChatID creates a low-level TelegramRequest with an associated chat ID.
|
||||||
// The chat ID is used for per-chat rate limiting.
|
// The chat ID is used for per-chat rate limiting.
|
||||||
func NewRequestWithChatID[R, P any](method string, params P, chatId int64) TelegramRequest[R, P] {
|
func NewRequestWithChatID[R, P any](method string, params P, chatID int64) TelegramRequest[R, P] {
|
||||||
return TelegramRequest[R, P]{method, params, chatId}
|
return TelegramRequest[R, P]{method, params, chatID}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, error) {
|
func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, error) {
|
||||||
@@ -191,7 +211,7 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro
|
|||||||
if api.useTestServer {
|
if api.useTestServer {
|
||||||
methodPrefix = "/test"
|
methodPrefix = "/test"
|
||||||
}
|
}
|
||||||
url := fmt.Sprintf("%s/bot%s%s/%s", api.apiUrl, api.token, methodPrefix, r.method)
|
url := fmt.Sprintf("%s/bot%s%s/%s", api.apiURL, api.token, methodPrefix, r.method)
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", url, nil)
|
req, err := http.NewRequestWithContext(ctx, "POST", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return zero, fmt.Errorf("failed to create request: %w", err)
|
return zero, fmt.Errorf("failed to create request: %w", err)
|
||||||
@@ -204,7 +224,7 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro
|
|||||||
for {
|
for {
|
||||||
// Apply rate limiting before making the request
|
// Apply rate limiting before making the request
|
||||||
if api.Limiter != nil {
|
if api.Limiter != nil {
|
||||||
if err := api.Limiter.Check(ctx, api.dropOverflowLimit, r.chatId); err != nil {
|
if err := api.Limiter.Check(ctx, api.dropOverflowLimit, r.chatID); err != nil {
|
||||||
return zero, err
|
return zero, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,12 +255,12 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro
|
|||||||
// Handle rate limiting (429)
|
// Handle rate limiting (429)
|
||||||
if response.ErrorCode == 429 && response.Parameters != nil && response.Parameters.RetryAfter != nil {
|
if response.ErrorCode == 429 && response.Parameters != nil && response.Parameters.RetryAfter != nil {
|
||||||
after := *response.Parameters.RetryAfter
|
after := *response.Parameters.RetryAfter
|
||||||
api.logger.Warnf("Rate limited by Telegram, retry after %d seconds (chat: %d)", after, r.chatId)
|
api.logger.Warnf("Rate limited by Telegram, retry after %d seconds (chat: %d)", after, r.chatID)
|
||||||
|
|
||||||
// Apply cooldown to global or chat-specific limiter
|
// Apply cooldown to global or chat-specific limiter
|
||||||
if api.Limiter != nil {
|
if api.Limiter != nil {
|
||||||
if r.chatId > 0 {
|
if r.chatID > 0 {
|
||||||
api.Limiter.SetChatLock(r.chatId, after)
|
api.Limiter.SetChatLock(r.chatID, after)
|
||||||
} else {
|
} else {
|
||||||
api.Limiter.SetGlobalLock(after)
|
api.Limiter.SetGlobalLock(after)
|
||||||
}
|
}
|
||||||
@@ -302,8 +322,8 @@ func readBody(body io.ReadCloser) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Internal helper that parses a typed Telegram API response body.
|
// Internal helper that parses a typed Telegram API response body.
|
||||||
func parseBody[R any](data []byte) (ApiResponse[R], error) {
|
func parseBody[R any](data []byte) (TelegramResponse[R], error) {
|
||||||
var resp ApiResponse[R]
|
var resp TelegramResponse[R]
|
||||||
err := json.Unmarshal(data, &resp)
|
err := json.Unmarshal(data, &resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resp, fmt.Errorf("failed to unmarshal JSON: %w", err)
|
return resp, fmt.Errorf("failed to unmarshal JSON: %w", err)
|
||||||
|
|||||||
+2
-2
@@ -40,7 +40,7 @@ func TestAPILeavesAcceptEncodingToHTTPTransport(t *testing.T) {
|
|||||||
|
|
||||||
api := NewAPI(
|
api := NewAPI(
|
||||||
NewAPIOpts("token").
|
NewAPIOpts("token").
|
||||||
SetAPIUrl("https://example.test").
|
SetAPIURL("https://example.test").
|
||||||
SetHTTPClient(client),
|
SetHTTPClient(client),
|
||||||
)
|
)
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -77,7 +77,7 @@ func TestAPICloseClosesIdleConnections(t *testing.T) {
|
|||||||
|
|
||||||
api := NewAPI(
|
api := NewAPI(
|
||||||
NewAPIOpts("token").
|
NewAPIOpts("token").
|
||||||
SetAPIUrl("https://example.test").
|
SetAPIURL("https://example.test").
|
||||||
SetHTTPClient(&http.Client{Transport: transport}),
|
SetHTTPClient(&http.Client{Transport: transport}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,6 @@ package tgapi
|
|||||||
|
|
||||||
import "errors"
|
import "errors"
|
||||||
|
|
||||||
// ErrRateLimit reports that a request exceeded the configured rate limiter.
|
|
||||||
var ErrRateLimit = errors.New("rate limit exceeded")
|
|
||||||
|
|
||||||
// ErrPoolUnexpected reports an unexpected result type returned from the worker pool.
|
// ErrPoolUnexpected reports an unexpected result type returned from the worker pool.
|
||||||
var ErrPoolUnexpected = errors.New("unexpected response from pool")
|
var ErrPoolUnexpected = errors.New("unexpected response from pool")
|
||||||
|
|
||||||
|
|||||||
@@ -472,7 +472,7 @@ func (api *API) SendChatActionWithContext(ctx context.Context, params SendChatAc
|
|||||||
// See https://core.telegram.org/bots/api#setmessagereaction
|
// See https://core.telegram.org/bots/api#setmessagereaction
|
||||||
type SetMessageReaction struct {
|
type SetMessageReaction struct {
|
||||||
ChatID int64 `json:"chat_id"`
|
ChatID int64 `json:"chat_id"`
|
||||||
MessageId int `json:"message_id"`
|
MessageID int `json:"message_id"`
|
||||||
Reaction []ReactionType `json:"reaction"`
|
Reaction []ReactionType `json:"reaction"`
|
||||||
IsBig bool `json:"is_big,omitempty"`
|
IsBig bool `json:"is_big,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ type Message struct {
|
|||||||
SenderBusinessBot *User `json:"sender_business_bot,omitempty"`
|
SenderBusinessBot *User `json:"sender_business_bot,omitempty"`
|
||||||
SenderTag string `json:"sender_tag,omitempty"`
|
SenderTag string `json:"sender_tag,omitempty"`
|
||||||
Date int `json:"date"`
|
Date int `json:"date"`
|
||||||
BusinessConnectionId string `json:"business_connection_id,omitempty"`
|
BusinessConnectionID string `json:"business_connection_id,omitempty"`
|
||||||
Chat *Chat `json:"chat,omitempty"`
|
Chat *Chat `json:"chat,omitempty"`
|
||||||
ForwardOrigin *MessageOrigin `json:"forward_origin,omitempty"`
|
ForwardOrigin *MessageOrigin `json:"forward_origin,omitempty"`
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ type Message struct {
|
|||||||
HasProtectedContent bool `json:"has_protected_content,omitempty"`
|
HasProtectedContent bool `json:"has_protected_content,omitempty"`
|
||||||
IsFromOffline bool `json:"is_from_offline,omitempty"`
|
IsFromOffline bool `json:"is_from_offline,omitempty"`
|
||||||
IsPaidPost bool `json:"is_paid_post,omitempty"`
|
IsPaidPost bool `json:"is_paid_post,omitempty"`
|
||||||
MediaGroupId string `json:"media_group_id,omitempty"`
|
MediaGroupID string `json:"media_group_id,omitempty"`
|
||||||
AuthorSignature string `json:"author_signature,omitempty"`
|
AuthorSignature string `json:"author_signature,omitempty"`
|
||||||
PaidStarCount int `json:"paid_star_count,omitempty"`
|
PaidStarCount int `json:"paid_star_count,omitempty"`
|
||||||
|
|
||||||
@@ -316,8 +316,8 @@ const (
|
|||||||
MessageEntityCashtag MessageEntityType = "cashtag"
|
MessageEntityCashtag MessageEntityType = "cashtag"
|
||||||
// MessageEntityBotCommand identifies a bot command entity.
|
// MessageEntityBotCommand identifies a bot command entity.
|
||||||
MessageEntityBotCommand MessageEntityType = "bot_command"
|
MessageEntityBotCommand MessageEntityType = "bot_command"
|
||||||
// MessageEntityUrl identifies a URL entity.
|
// MessageEntityURL identifies a URL entity.
|
||||||
MessageEntityUrl MessageEntityType = "url"
|
MessageEntityURL MessageEntityType = "url"
|
||||||
// MessageEntityEmail identifies an email entity.
|
// MessageEntityEmail identifies an email entity.
|
||||||
MessageEntityEmail MessageEntityType = "email"
|
MessageEntityEmail MessageEntityType = "email"
|
||||||
// MessageEntityPhoneNumber identifies a phone number entity.
|
// MessageEntityPhoneNumber identifies a phone number entity.
|
||||||
@@ -537,7 +537,7 @@ const (
|
|||||||
// ChatActionUploadVideoNote tells Telegram the bot is uploading a video note.
|
// ChatActionUploadVideoNote tells Telegram the bot is uploading a video note.
|
||||||
ChatActionUploadVideoNote ChatActionType = "upload_video_note"
|
ChatActionUploadVideoNote ChatActionType = "upload_video_note"
|
||||||
// ChatActionUploadVideoNone is a deprecated alias for ChatActionUploadVideoNote.
|
// ChatActionUploadVideoNone is a deprecated alias for ChatActionUploadVideoNote.
|
||||||
ChatActionUploadVideoNone ChatActionType = ChatActionUploadVideoNote
|
ChatActionUploadVideoNone = ChatActionUploadVideoNote
|
||||||
)
|
)
|
||||||
|
|
||||||
// MessageReactionUpdated represents a change of a reaction on a message.
|
// MessageReactionUpdated represents a change of a reaction on a message.
|
||||||
|
|||||||
+1
-1
@@ -256,7 +256,7 @@ func (api *API) openFileByLink(ctx context.Context, link string) (io.ReadCloser,
|
|||||||
if api.useTestServer {
|
if api.useTestServer {
|
||||||
methodPrefix = "/test"
|
methodPrefix = "/test"
|
||||||
}
|
}
|
||||||
u := fmt.Sprintf("%s/file/bot%s%s/%s", api.apiUrl, api.token, methodPrefix, link)
|
u := fmt.Sprintf("%s/file/bot%s%s/%s", api.apiURL, api.token, methodPrefix, link)
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func TestGetFileByLinkUsesConfiguredAPIURL(t *testing.T) {
|
|||||||
|
|
||||||
api := NewAPI(
|
api := NewAPI(
|
||||||
NewAPIOpts("token").
|
NewAPIOpts("token").
|
||||||
SetAPIUrl("https://example.test").
|
SetAPIURL("https://example.test").
|
||||||
SetHTTPClient(client),
|
SetHTTPClient(client),
|
||||||
)
|
)
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -47,7 +47,7 @@ func TestGetFileByLinkUsesConfiguredAPIURL(t *testing.T) {
|
|||||||
func TestOpenFileByLinkStreamsResponseBody(t *testing.T) {
|
func TestOpenFileByLinkStreamsResponseBody(t *testing.T) {
|
||||||
api := NewAPI(
|
api := NewAPI(
|
||||||
NewAPIOpts("token").
|
NewAPIOpts("token").
|
||||||
SetAPIUrl("https://example.test").
|
SetAPIURL("https://example.test").
|
||||||
SetHTTPClient(&http.Client{
|
SetHTTPClient(&http.Client{
|
||||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
return &http.Response{
|
return &http.Response{
|
||||||
@@ -94,7 +94,7 @@ func TestGetFileByLinkReturnsHTTPStatusError(t *testing.T) {
|
|||||||
|
|
||||||
api := NewAPI(
|
api := NewAPI(
|
||||||
NewAPIOpts("token").
|
NewAPIOpts("token").
|
||||||
SetAPIUrl("https://example.test").
|
SetAPIURL("https://example.test").
|
||||||
SetHTTPClient(client),
|
SetHTTPClient(client),
|
||||||
)
|
)
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -131,7 +131,7 @@ func TestGetUpdatesOmitsAllowedUpdatesWhenEmpty(t *testing.T) {
|
|||||||
|
|
||||||
api := NewAPI(
|
api := NewAPI(
|
||||||
NewAPIOpts("token").
|
NewAPIOpts("token").
|
||||||
SetAPIUrl("https://example.test").
|
SetAPIURL("https://example.test").
|
||||||
SetHTTPClient(client),
|
SetHTTPClient(client),
|
||||||
)
|
)
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -174,7 +174,7 @@ func TestSetChatMenuButtonSendsStructuredMenuButton(t *testing.T) {
|
|||||||
|
|
||||||
api := NewAPI(
|
api := NewAPI(
|
||||||
NewAPIOpts("token").
|
NewAPIOpts("token").
|
||||||
SetAPIUrl("https://example.test").
|
SetAPIURL("https://example.test").
|
||||||
SetHTTPClient(client),
|
SetHTTPClient(client),
|
||||||
)
|
)
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|||||||
+16
-15
@@ -11,7 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/utils"
|
"git.scuroneko.dev/scuroneko/laniakea/utils"
|
||||||
"git.scuroneko.dev/scuroneko/slog"
|
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -65,17 +65,18 @@ func (f UploaderFile) SetType(t UploaderFileType) UploaderFile {
|
|||||||
// (InputFile/multipart). For JSON-only calls (file_id, URL, plain params), use API.
|
// (InputFile/multipart). For JSON-only calls (file_id, URL, plain params), use API.
|
||||||
type Uploader struct {
|
type Uploader struct {
|
||||||
api *API
|
api *API
|
||||||
logger *slog.Logger
|
logger *sneklog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUploader creates a multipart uploader bound to an API client.
|
// NewUploader creates a multipart uploader bound to an API client.
|
||||||
func NewUploader(api *API) *Uploader {
|
func NewUploader(api *API) *Uploader {
|
||||||
logger := utils.CreateLogger("UPLOADER", utils.GetLoggerLevel())
|
|
||||||
if api == nil {
|
if api == nil {
|
||||||
logger.Errorln("api is nil")
|
|
||||||
_ = logger.Close()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
logger := utils.CreateLogger(
|
||||||
|
"UPLOADER", utils.GetLoggerLevel(),
|
||||||
|
api.logFormat, api.logFormatter,
|
||||||
|
)
|
||||||
return &Uploader{api, logger}
|
return &Uploader{api, logger}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ func (u *Uploader) Close() error { return u.logger.Close() }
|
|||||||
|
|
||||||
// GetLogger returns uploader logger instance.
|
// GetLogger returns uploader logger instance.
|
||||||
// See https://core.telegram.org/bots/api
|
// See https://core.telegram.org/bots/api
|
||||||
func (u *Uploader) GetLogger() *slog.Logger { return u.logger }
|
func (u *Uploader) GetLogger() *sneklog.Logger { return u.logger }
|
||||||
|
|
||||||
// UploaderRequest is a low-level multipart upload request wrapper.
|
// UploaderRequest is a low-level multipart upload request wrapper.
|
||||||
//
|
//
|
||||||
@@ -97,18 +98,18 @@ type UploaderRequest[R, P any] struct {
|
|||||||
method string
|
method string
|
||||||
files []UploaderFile
|
files []UploaderFile
|
||||||
params P
|
params P
|
||||||
chatId int64
|
chatID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUploaderRequest creates a low-level multipart upload request with no associated chat ID.
|
// NewUploaderRequest creates a low-level multipart upload request with no associated chat ID.
|
||||||
func NewUploaderRequest[R, P any](method string, params P, files ...UploaderFile) UploaderRequest[R, P] {
|
func NewUploaderRequest[R, P any](method string, params P, files ...UploaderFile) UploaderRequest[R, P] {
|
||||||
return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: 0}
|
return UploaderRequest[R, P]{method: method, files: files, params: params, chatID: 0}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUploaderRequestWithChatID creates a low-level multipart upload request with an associated chat ID.
|
// NewUploaderRequestWithChatID creates a low-level multipart upload request with an associated chat ID.
|
||||||
// The chat ID is used for per-chat rate limiting.
|
// The chat ID is used for per-chat rate limiting.
|
||||||
func NewUploaderRequestWithChatID[R, P any](method string, params P, chatId int64, files ...UploaderFile) UploaderRequest[R, P] {
|
func NewUploaderRequestWithChatID[R, P any](method string, params P, chatID int64, files ...UploaderFile) UploaderRequest[R, P] {
|
||||||
return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: chatId}
|
return UploaderRequest[R, P]{method: method, files: files, params: params, chatID: chatID}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, error) {
|
func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, error) {
|
||||||
@@ -118,11 +119,11 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R,
|
|||||||
if up.api.useTestServer {
|
if up.api.useTestServer {
|
||||||
methodPrefix = "/test"
|
methodPrefix = "/test"
|
||||||
}
|
}
|
||||||
url := fmt.Sprintf("%s/bot%s%s/%s", up.api.apiUrl, up.api.token, methodPrefix, r.method)
|
url := fmt.Sprintf("%s/bot%s%s/%s", up.api.apiURL, up.api.token, methodPrefix, r.method)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
if up.api.Limiter != nil {
|
if up.api.Limiter != nil {
|
||||||
if err := up.api.Limiter.Check(ctx, up.api.dropOverflowLimit, r.chatId); err != nil {
|
if err := up.api.Limiter.Check(ctx, up.api.dropOverflowLimit, r.chatID); err != nil {
|
||||||
return zero, err
|
return zero, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,10 +162,10 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R,
|
|||||||
if !response.Ok {
|
if !response.Ok {
|
||||||
if response.ErrorCode == 429 && response.Parameters != nil && response.Parameters.RetryAfter != nil {
|
if response.ErrorCode == 429 && response.Parameters != nil && response.Parameters.RetryAfter != nil {
|
||||||
after := *response.Parameters.RetryAfter
|
after := *response.Parameters.RetryAfter
|
||||||
up.logger.Warnf("Rate limited, retry after %d seconds (chat: %d)", after, r.chatId)
|
up.logger.Warnf("Rate limited, retry after %d seconds (chat: %d)", after, r.chatID)
|
||||||
if up.api.Limiter != nil {
|
if up.api.Limiter != nil {
|
||||||
if r.chatId > 0 {
|
if r.chatID > 0 {
|
||||||
up.api.Limiter.SetChatLock(r.chatId, after)
|
up.api.Limiter.SetChatLock(r.chatID, after)
|
||||||
} else {
|
} else {
|
||||||
up.api.Limiter.SetGlobalLock(after)
|
up.api.Limiter.SetGlobalLock(after)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func TestUploaderEncodesJSONFieldsAndLeavesAcceptEncodingToHTTPTransport(t *test
|
|||||||
|
|
||||||
api := NewAPI(
|
api := NewAPI(
|
||||||
NewAPIOpts("token").
|
NewAPIOpts("token").
|
||||||
SetAPIUrl("https://example.test").
|
SetAPIURL("https://example.test").
|
||||||
SetHTTPClient(client),
|
SetHTTPClient(client),
|
||||||
)
|
)
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|||||||
+3
-3
@@ -142,15 +142,15 @@ func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MsgContext) {
|
|||||||
if u.CallbackQuery != nil {
|
if u.CallbackQuery != nil {
|
||||||
if u.CallbackQuery.Message != nil {
|
if u.CallbackQuery.Message != nil {
|
||||||
ctx.Msg = u.CallbackQuery.Message
|
ctx.Msg = u.CallbackQuery.Message
|
||||||
ctx.CallbackMsgId = u.CallbackQuery.Message.MessageID
|
ctx.CallbackMsgID = u.CallbackQuery.Message.MessageID
|
||||||
if u.CallbackQuery.Message.Chat != nil {
|
if u.CallbackQuery.Message.Chat != nil {
|
||||||
chat = u.CallbackQuery.Message.Chat
|
chat = u.CallbackQuery.Message.Chat
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if u.CallbackQuery.InlineMessageID != nil {
|
if u.CallbackQuery.InlineMessageID != nil {
|
||||||
ctx.InlineMsgId = *u.CallbackQuery.InlineMessageID
|
ctx.InlineMsgID = *u.CallbackQuery.InlineMessageID
|
||||||
}
|
}
|
||||||
ctx.CallbackQueryId = u.CallbackQuery.ID
|
ctx.CallbackQueryID = u.CallbackQuery.ID
|
||||||
from = &u.CallbackQuery.From
|
from = &u.CallbackQuery.From
|
||||||
}
|
}
|
||||||
case tgapi.UpdateTypeShippingQuery:
|
case tgapi.UpdateTypeShippingQuery:
|
||||||
|
|||||||
+57
-15
@@ -3,26 +3,49 @@ package utils
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/slog"
|
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LogFormat string
|
||||||
|
|
||||||
|
const (
|
||||||
|
LogFormatText LogFormat = "text"
|
||||||
|
LogFormatJSON LogFormat = "json"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetLoggerLevel returns DEBUG when DEBUG=true in env, otherwise FATAL.
|
// GetLoggerLevel returns DEBUG when DEBUG=true in env, otherwise FATAL.
|
||||||
func GetLoggerLevel() slog.LogLevel {
|
func GetLoggerLevel() sneklog.LogLevel {
|
||||||
level := slog.FATAL
|
level := sneklog.FATAL
|
||||||
if os.Getenv("DEBUG") == "true" {
|
if os.Getenv("DEBUG") == "true" {
|
||||||
level = slog.DEBUG
|
level = sneklog.DEBUG
|
||||||
}
|
}
|
||||||
return level
|
return level
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateLogger creates a logger with the shared default policy:
|
// CreateLogger creates a logger with the shared default policy:
|
||||||
// JSON stdout output, provided prefix, and provided level.
|
// JSON stdout output, provided prefix, and provided level.
|
||||||
func CreateLogger(prefix string, level slog.LogLevel) *slog.Logger {
|
func CreateLogger(
|
||||||
logger := slog.CreateLogger().Level(level)
|
name string, level sneklog.LogLevel,
|
||||||
if prefix != "" {
|
format LogFormat, formatter *sneklog.Formatter,
|
||||||
logger.Prefix(prefix)
|
) *sneklog.Logger {
|
||||||
|
logger := sneklog.NewLogger().SetLevel(level)
|
||||||
|
if name != "" {
|
||||||
|
logger.SetName(name)
|
||||||
|
}
|
||||||
|
switch format {
|
||||||
|
case LogFormatJSON:
|
||||||
|
writer := logger.CreateJsonStdoutWriter()
|
||||||
|
if formatter != nil {
|
||||||
|
writer.SetFormatter(formatter)
|
||||||
|
}
|
||||||
|
logger.AddWriters(writer)
|
||||||
|
default:
|
||||||
|
writer := logger.CreateTextStdoutWriter()
|
||||||
|
if formatter != nil {
|
||||||
|
writer.SetFormatter(formatter)
|
||||||
|
}
|
||||||
|
logger.AddWriters(writer)
|
||||||
}
|
}
|
||||||
logger.AddWriter(logger.CreateJsonStdoutWriter())
|
|
||||||
return logger
|
return logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,12 +54,31 @@ func CreateLogger(prefix string, level slog.LogLevel) *slog.Logger {
|
|||||||
//
|
//
|
||||||
// The returned logger is always non-nil. When file writer creation fails, the
|
// The returned logger is always non-nil. When file writer creation fails, the
|
||||||
// logger still writes to stdout and the error is returned to the caller.
|
// logger still writes to stdout and the error is returned to the caller.
|
||||||
func CreateFileLogger(prefix string, level slog.LogLevel, filePath string) (*slog.Logger, error) {
|
func CreateFileLogger(
|
||||||
logger := CreateLogger(prefix, level)
|
prefix string, level sneklog.LogLevel, filePath string,
|
||||||
fileWriter, err := logger.CreateTextFileWriter(filePath)
|
format LogFormat, formatter *sneklog.Formatter,
|
||||||
if err != nil {
|
) (*sneklog.Logger, error) {
|
||||||
return logger, err
|
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
|
return logger, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -6,13 +6,13 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/slog"
|
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCreateFileLoggerWritesToConfiguredFile(t *testing.T) {
|
func TestCreateFileLoggerWritesToConfiguredFile(t *testing.T) {
|
||||||
logPath := filepath.Join(t.TempDir(), "main.log")
|
logPath := filepath.Join(t.TempDir(), "main.log")
|
||||||
|
|
||||||
logger, err := CreateFileLogger("TEST", slog.DEBUG, logPath)
|
logger, err := CreateFileLogger("TEST", sneklog.DEBUG, logPath, LogFormatText, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateFileLogger returned error: %v", err)
|
t.Fatalf("CreateFileLogger returned error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -28,7 +28,7 @@ func TestCreateFileLoggerWritesToConfiguredFile(t *testing.T) {
|
|||||||
if !strings.Contains(string(data), "hello from file logger") {
|
if !strings.Contains(string(data), "hello from file logger") {
|
||||||
t.Fatalf("expected log message in file, got %q", string(data))
|
t.Fatalf("expected log message in file, got %q", string(data))
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(data), "[TEST]") {
|
if !strings.Contains(string(data), "TEST:") {
|
||||||
t.Fatalf("expected prefix in file, got %q", string(data))
|
t.Fatalf("expected prefix in file, got %q", string(data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@ package utils
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// VersionString is the module version string.
|
// VersionString is the module version string.
|
||||||
VersionString = "1.0.0-rc.15"
|
VersionString = "1.0.0-rc.16"
|
||||||
// VersionMajor is the module major version.
|
// VersionMajor is the module major version.
|
||||||
VersionMajor = 1
|
VersionMajor = 1
|
||||||
// VersionMinor is the module minor version.
|
// VersionMinor is the module minor version.
|
||||||
@@ -10,5 +10,5 @@ const (
|
|||||||
// VersionPatch is the module patch version.
|
// VersionPatch is the module patch version.
|
||||||
VersionPatch = 0
|
VersionPatch = 0
|
||||||
// VersionBeta is the prerelease counter for the current version.
|
// VersionBeta is the prerelease counter for the current version.
|
||||||
VersionBeta = 15
|
VersionBeta = 16
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user