REPOSITORY / ScuroNeko/Laniakea

Pull Requests

PULL REQUESTS REPOSITORY

v1.0.0 #9

Merged
ScuroNeko merged 101 commits from dev into main 2026-05-20 13:43:34 +03:00
8 changed files with 333 additions and 7 deletions
Showing only changes of commit c9ec18ccea - Show all commits
+4
View File
@@ -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
+29
View File
@@ -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(...)`.
+29
View File
@@ -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(...)`.
-4
View File
@@ -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)
+153
View File
@@ -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)
})
}
+116
View File
@@ -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)
}
}
-1
View File
@@ -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
View File
@@ -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)
} }