(new): bot opts file loader
This commit is contained in:
@@ -3,9 +3,13 @@
|
|||||||
## v1.0.0-rc.15
|
## v1.0.0-rc.15
|
||||||
|
|
||||||
### Changed
|
### 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 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
|
### 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.
|
- Added regression coverage for plugin message fallback routing, observer lifecycle events, command precedence, and middleware blocking.
|
||||||
|
|
||||||
## v1.0.0-rc.14
|
## v1.0.0-rc.14
|
||||||
|
|||||||
@@ -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.
|
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.
|
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
|
## Webhook Runtime
|
||||||
|
|
||||||
Laniakea also supports a bot-owned webhook runtime through `RunWebHookWithContext(...)` and `RunWebHook(...)`.
|
Laniakea also supports a bot-owned webhook runtime through `RunWebHookWithContext(...)` and `RunWebHook(...)`.
|
||||||
|
|||||||
@@ -121,6 +121,35 @@ func main() {
|
|||||||
9. `RunWebHookWithContext(...)`: Запускает bot-owned webhook runtime, когда Telegram должен доставлять update по HTTP вместо long polling.
|
9. `RunWebHookWithContext(...)`: Запускает bot-owned webhook runtime, когда Telegram должен доставлять update по HTTP вместо long polling.
|
||||||
10. Экземпляр `Bot` одноразовый. После завершения `Run()`, `RunWithContext()` или `RunWebHookWithContext()` для следующего запуска создавайте новый бот.
|
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
|
## Webhook Runtime
|
||||||
|
|
||||||
Laniakea также поддерживает bot-owned webhook runtime через `RunWebHookWithContext(...)` и `RunWebHook(...)`.
|
Laniakea также поддерживает bot-owned webhook runtime через `RunWebHookWithContext(...)` и `RunWebHook(...)`.
|
||||||
|
|||||||
@@ -154,10 +154,6 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
|
|||||||
|
|
||||||
updateQueue := make(chan *tgapi.Update, 512)
|
updateQueue := make(chan *tgapi.Update, 512)
|
||||||
|
|
||||||
//var limiter *utils.RateLimiter
|
|
||||||
//if opts.RateLimit > 0 {
|
|
||||||
// limiter = utils.NewRateLimiter()
|
|
||||||
//}
|
|
||||||
limiter := utils.NewRateLimiter()
|
limiter := utils.NewRateLimiter()
|
||||||
limiter.SetGlobalRate(opts.RateLimit)
|
limiter.SetGlobalRate(opts.RateLimit)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -192,7 +192,6 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro
|
|||||||
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)
|
||||||
|
|||||||
+2
-2
@@ -21,7 +21,7 @@ type UpdateParams struct {
|
|||||||
// GetMe returns basic information about the bot.
|
// GetMe returns basic information about the bot.
|
||||||
// See https://core.telegram.org/bots/api#getme
|
// See https://core.telegram.org/bots/api#getme
|
||||||
func (api *API) GetMe() (User, error) {
|
func (api *API) GetMe() (User, error) {
|
||||||
req := NewRequest[User, EmptyParams]("getMe", NoParams)
|
req := NewRequest[User]("getMe", NoParams)
|
||||||
return req.Do(api)
|
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.
|
// It executes the same request but uses ctx for cancellation and deadlines.
|
||||||
// See https://core.telegram.org/bots/api#getme
|
// See https://core.telegram.org/bots/api#getme
|
||||||
func (api *API) GetMeWithContext(ctx context.Context) (User, error) {
|
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)
|
return req.DoWithContext(ctx, api)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user