diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c260b3..aa97221 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,13 @@ ## v1.0.0-rc.15 ### Changed +- Added file-based `BotOpts` loading and saving through `LoadBotOptsFile(...)`, `SaveBotOptsFile(...)`, and the `BotOptsFileCodec` API, with built-in JSON support. - Added plugin-level message fallback handlers for text messages and channel posts that do not match commands. +- Added godoc for the exported `BotOpts` file codec and load/save helpers. +- README, README_RU, and bot-configuration wiki pages now document file-based `BotOpts` loading, built-in JSON support, env placeholder expansion, and custom codec usage including the TOML example. ### Tests +- Added regression coverage for JSON `BotOpts` file codecs, file load/save helpers, decode failures, and env placeholder expansion. - Added regression coverage for plugin message fallback routing, observer lifecycle events, command precedence, and middleware blocking. ## v1.0.0-rc.14 diff --git a/README.md b/README.md index 69b3a1c..199eadd 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,35 @@ func main() { 9. `RunWebHookWithContext(...)`: Starts the bot-owned webhook runtime when Telegram should deliver updates over HTTP instead of long polling. 10. A `Bot` instance is single-use. After `Run()`, `RunWithContext()`, or `RunWebHookWithContext()` returns, create a new bot instance for the next session. +## File-Based Config + +`BotOpts` can also be loaded from or saved to config files through the file codec API. + +Built in: +- `BotOptsFileJsonCodec` for JSON files. + +Example: + +```go +codec := laniakea.BotOptsFileJsonCodec{} +opts, err := laniakea.LoadBotOptsFile(codec, "config.json") +if err != nil { + log.Fatal(err) +} + +bot, err := laniakea.NewBot[laniakea.NoData](opts) +if err != nil { + log.Fatal(err) +} +``` + +Placeholders like `{{ TG_TOKEN }}` inside the file are expanded from environment variables before decoding. + +You can also implement your own codec for other formats by satisfying `BotOptsFileCodec`. +Only JSON is supported out of the box right now. If you want another format such as TOML, use `BotOptsFileJsonCodec` as the reference implementation for your own codec. + +See the full guide in the wiki: [Bot Options and Configuration](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Bot-Options-and-Configuration) + ## Webhook Runtime Laniakea also supports a bot-owned webhook runtime through `RunWebHookWithContext(...)` and `RunWebHook(...)`. diff --git a/README_RU.md b/README_RU.md index 16852df..263340e 100644 --- a/README_RU.md +++ b/README_RU.md @@ -121,6 +121,35 @@ func main() { 9. `RunWebHookWithContext(...)`: Запускает bot-owned webhook runtime, когда Telegram должен доставлять update по HTTP вместо long polling. 10. Экземпляр `Bot` одноразовый. После завершения `Run()`, `RunWithContext()` или `RunWebHookWithContext()` для следующего запуска создавайте новый бот. +## Конфиг из файла + +`BotOpts` можно не только собирать вручную или из environment, но и загружать и сохранять через file codec API. + +Из коробки доступно: +- `BotOptsFileJsonCodec` для JSON-файлов. + +Пример: + +```go +codec := laniakea.BotOptsFileJsonCodec{} +opts, err := laniakea.LoadBotOptsFile(codec, "config.json") +if err != nil { + log.Fatal(err) +} + +bot, err := laniakea.NewBot[laniakea.NoData](opts) +if err != nil { + log.Fatal(err) +} +``` + +Плейсхолдеры вида `{{ TG_TOKEN }}` внутри файла перед декодированием разворачиваются из переменных окружения. + +Для других форматов можно реализовать собственный codec через интерфейс `BotOptsFileCodec`. +Из коробки сейчас поддерживается только JSON. Если нужен другой формат, например TOML, используй `BotOptsFileJsonCodec` как эталонную реализацию собственного codec. + +Подробности есть в wiki: [Bot Options and Configuration RU](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Bot-Options-and-Configuration-RU) + ## Webhook Runtime Laniakea также поддерживает bot-owned webhook runtime через `RunWebHookWithContext(...)` и `RunWebHook(...)`. diff --git a/bot.go b/bot.go index 86d3a00..184c959 100644 --- a/bot.go +++ b/bot.go @@ -154,10 +154,6 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) { updateQueue := make(chan *tgapi.Update, 512) - //var limiter *utils.RateLimiter - //if opts.RateLimit > 0 { - // limiter = utils.NewRateLimiter() - //} limiter := utils.NewRateLimiter() limiter.SetGlobalRate(opts.RateLimit) diff --git a/bot_opts_loader.go b/bot_opts_loader.go new file mode 100644 index 0000000..f06128e --- /dev/null +++ b/bot_opts_loader.go @@ -0,0 +1,153 @@ +package laniakea + +import ( + "encoding/json" + "io" + "os" + "regexp" + + "git.scuroneko.dev/scuroneko/laniakea/tgapi" +) + +// BotOptsFileJson is the JSON file representation of BotOpts. +type BotOptsFileJson struct { + Token string `json:"token"` + UpdateTypes []tgapi.UpdateType `json:"update_types"` + Debug bool `json:"debug"` + ErrorTemplate string `json:"error_template"` + Prefixes []string `json:"prefixes"` + Logger struct { + LoggerBasePath string `json:"base_path"` + UseRequestLogger bool `json:"use_request_logger"` + WriteToFile bool `json:"write_to_file"` + } `json:"logger"` + API struct { + UseTestServer bool `json:"use_test_server"` + APIUrl string `json:"url"` + RateLimit int `json:"rate_limit"` + DropRLOverflow bool `json:"drop_overflow"` + } `json:"api"` + StrictPayloadType bool `json:"strict_payload_type"` + MaxWorkers int `json:"max_workers"` +} + +// BotOptsFileJsonCodec encodes and decodes BotOpts using BotOptsFileJson. +type BotOptsFileJsonCodec struct{} + +// FromBytes decodes BotOpts from JSON file bytes. +func (codec BotOptsFileJsonCodec) FromBytes(data []byte) (*BotOpts, error) { + fileOpts := new(BotOptsFileJson) + err := json.Unmarshal(data, fileOpts) + if err != nil { + return nil, err + } + opts := &BotOpts{ + Token: fileOpts.Token, + UpdateTypes: fileOpts.UpdateTypes, + Debug: fileOpts.Debug, + ErrorTemplate: fileOpts.ErrorTemplate, + Prefixes: fileOpts.Prefixes, + + LoggerBasePath: fileOpts.Logger.LoggerBasePath, + UseRequestLogger: fileOpts.Logger.UseRequestLogger, + WriteToFile: fileOpts.Logger.WriteToFile, + + UseTestServer: fileOpts.API.UseTestServer, + APIUrl: fileOpts.API.APIUrl, + RateLimit: fileOpts.API.RateLimit, + DropRLOverflow: fileOpts.API.DropRLOverflow, + + StrictPayloadType: fileOpts.StrictPayloadType, + MaxWorkers: fileOpts.MaxWorkers, + } + return opts, nil +} + +// ToBytes encodes BotOpts into JSON file bytes. +func (codec BotOptsFileJsonCodec) ToBytes(opts *BotOpts) ([]byte, error) { + fileOpts := &BotOptsFileJson{ + Token: opts.Token, + UpdateTypes: opts.UpdateTypes, + Debug: opts.Debug, + ErrorTemplate: opts.ErrorTemplate, + Prefixes: opts.Prefixes, + + Logger: struct { + LoggerBasePath string `json:"base_path"` + UseRequestLogger bool `json:"use_request_logger"` + WriteToFile bool `json:"write_to_file"` + }{ + LoggerBasePath: opts.LoggerBasePath, + UseRequestLogger: opts.UseRequestLogger, + WriteToFile: opts.WriteToFile, + }, + + 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, + APIUrl: opts.APIUrl, + RateLimit: opts.RateLimit, + DropRLOverflow: opts.DropRLOverflow, + }, + + StrictPayloadType: opts.StrictPayloadType, + MaxWorkers: opts.MaxWorkers, + } + data, err := json.Marshal(fileOpts) + if err != nil { + return nil, err + } + return data, nil +} + +var envParameterRegex = regexp.MustCompile(`\{\{\s*(\w+)\s*\}\}`) + +// BotOptsFileCodec decodes and encodes BotOpts file formats. +type BotOptsFileCodec interface { + FromBytes([]byte) (*BotOpts, error) + ToBytes(*BotOpts) ([]byte, error) +} + +// LoadBotOptsFile reads a config file, expands env placeholders, and decodes BotOpts. +func LoadBotOptsFile(codec BotOptsFileCodec, filename string) (*BotOpts, error) { + f, err := os.Open(filename) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + data, err := io.ReadAll(f) + if err != nil { + return nil, err + } + data = expandEnvPlaceholdersInFile(data) + return codec.FromBytes(data) +} + +// SaveBotOptsFile encodes BotOpts with codec and writes the result to filename. +func SaveBotOptsFile(codec BotOptsFileCodec, filename string, opts *BotOpts) error { + data, err := codec.ToBytes(opts) + if err != nil { + return err + } + err = os.WriteFile(filename, data, 0644) + if err != nil { + return err + } + return nil +} + +func expandEnvPlaceholdersInFile(data []byte) []byte { + return envParameterRegex.ReplaceAllFunc(data, func(match []byte) []byte { + group := envParameterRegex.FindSubmatch(match) + if len(group) != 2 { + return match + } + key := group[1] + value := os.Getenv(string(key)) + return []byte(value) + }) +} diff --git a/bot_opts_loader_test.go b/bot_opts_loader_test.go new file mode 100644 index 0000000..fc76b7b --- /dev/null +++ b/bot_opts_loader_test.go @@ -0,0 +1,116 @@ +package laniakea + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "git.scuroneko.dev/scuroneko/laniakea/tgapi" +) + +func TestBotOptsFileJsonCodecRoundTrip(t *testing.T) { + codec := BotOptsFileJsonCodec{} + want := &BotOpts{ + Token: "TOKEN", + UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery}, + Debug: true, + ErrorTemplate: "Error: %s", + Prefixes: []string{"/", "!"}, + LoggerBasePath: "/tmp/logs", + UseRequestLogger: true, + WriteToFile: true, + UseTestServer: true, + APIUrl: "https://api.example.invalid", + RateLimit: 42, + DropRLOverflow: true, + StrictPayloadType: true, + MaxWorkers: 64, + } + + data, err := codec.ToBytes(want) + if err != nil { + t.Fatalf("ToBytes returned error: %v", err) + } + + got, err := codec.FromBytes(data) + if err != nil { + t.Fatalf("FromBytes returned error: %v", err) + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("round-trip mismatch:\n got: %#v\nwant: %#v", got, want) + } +} + +func TestLoadBotOptsFileExpandsEnvPlaceholders(t *testing.T) { + t.Setenv("TG_TOKEN", "TOKEN_FROM_ENV") + t.Setenv("BOT_API_URL", "https://api.example.invalid") + + dir := t.TempDir() + filename := filepath.Join(dir, "config.json") + data := []byte(`{ + "token": "{{ TG_TOKEN }}", + "api": { + "url": "{{BOT_API_URL}}" + }, + "error_template": "Error: %s" + }`) + if err := os.WriteFile(filename, data, 0o644); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + + got, err := LoadBotOptsFile(BotOptsFileJsonCodec{}, filename) + if err != nil { + t.Fatalf("LoadBotOptsFile returned error: %v", err) + } + + if got.Token != "TOKEN_FROM_ENV" { + t.Fatalf("unexpected token: got %q want %q", got.Token, "TOKEN_FROM_ENV") + } + if got.APIUrl != "https://api.example.invalid" { + t.Fatalf("unexpected api url: got %q want %q", got.APIUrl, "https://api.example.invalid") + } + if got.ErrorTemplate != "Error: %s" { + t.Fatalf("unexpected error template: got %q", got.ErrorTemplate) + } +} + +func TestLoadBotOptsFileReturnsDecodeError(t *testing.T) { + dir := t.TempDir() + filename := filepath.Join(dir, "config.json") + if err := os.WriteFile(filename, []byte(`{"token":`), 0o644); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + + if _, err := LoadBotOptsFile(BotOptsFileJsonCodec{}, filename); err == nil { + t.Fatal("expected decode error, got nil") + } +} + +func TestSaveBotOptsFileWritesEncodedData(t *testing.T) { + dir := t.TempDir() + filename := filepath.Join(dir, "config.json") + want := &BotOpts{ + Token: "TOKEN", + UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage}, + ErrorTemplate: "Error: %s", + Prefixes: []string{"/"}, + APIUrl: "https://api.example.invalid", + RateLimit: 30, + MaxWorkers: 32, + } + + if err := SaveBotOptsFile(BotOptsFileJsonCodec{}, filename, want); err != nil { + t.Fatalf("SaveBotOptsFile returned error: %v", err) + } + + got, err := LoadBotOptsFile(BotOptsFileJsonCodec{}, filename) + if err != nil { + t.Fatalf("LoadBotOptsFile returned error: %v", err) + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("saved file mismatch:\n got: %#v\nwant: %#v", got, want) + } +} diff --git a/tgapi/api.go b/tgapi/api.go index 79ceddd..34c10b8 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -192,7 +192,6 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro methodPrefix = "/test" } url := fmt.Sprintf("%s/bot%s%s/%s", api.apiUrl, api.token, methodPrefix, r.method) - req, err := http.NewRequestWithContext(ctx, "POST", url, nil) if err != nil { return zero, fmt.Errorf("failed to create request: %w", err) diff --git a/tgapi/methods.go b/tgapi/methods.go index 37b664b..1d06720 100644 --- a/tgapi/methods.go +++ b/tgapi/methods.go @@ -21,7 +21,7 @@ type UpdateParams struct { // GetMe returns basic information about the bot. // See https://core.telegram.org/bots/api#getme func (api *API) GetMe() (User, error) { - req := NewRequest[User, EmptyParams]("getMe", NoParams) + req := NewRequest[User]("getMe", NoParams) return req.Do(api) } @@ -29,7 +29,7 @@ func (api *API) GetMe() (User, error) { // It executes the same request but uses ctx for cancellation and deadlines. // See https://core.telegram.org/bots/api#getme func (api *API) GetMeWithContext(ctx context.Context) (User, error) { - req := NewRequest[User, EmptyParams]("getMe", NoParams) + req := NewRequest[User]("getMe", NoParams) return req.DoWithContext(ctx, api) }