REPOSITORY / ScuroNeko/Laniakea

Compare commits

DIFF REPOSITORY

Compare commits

..
Author SHA1 Message Date
ScuroNeko e92a0d37f3 (ci/cd): some style changes in workflow
Golang lint / lint (push) Successful in 2m34s
2026-04-23 22:24:11 +03:00
ScuroNeko 768dc859d7 (new): logger replacer for token
Golang lint / lint (push) Successful in 1m19s
(fix): scene command routing
2026-04-23 21:50:00 +03:00
ScuroNeko d6da95394c (dev): actions
Gitea Actions Demo / Explore-Gitea-Actions (push) Failing after 7s
2026-04-23 12:23:58 +03:00
ScuroNeko c9ec18ccea (new): bot opts file loader 2026-04-23 12:12:57 +03:00
ScuroNeko aa18da73d5 Add plugin message fallback
Route unmatched messages through plugin fallback handlers

Add observer coverage and bump version to rc.15
2026-04-13 16:27:53 +03:00
ScuroNeko 2b64e8543f tests cleanup 2026-04-13 10:11:13 +03:00
22 changed files with 755 additions and 72 deletions
+12
View File
@@ -0,0 +1,12 @@
name: Golang lint
run-name: Linting code
on: [push]
jobs:
lint:
runs-on: go-latest
steps:
- name: Checkout repository code
uses: actions/checkout@v6
- name: Run golangci-lint
run: golangci-lint run
+14
View File
@@ -1,5 +1,19 @@
# Changelog
## 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.
- Active scenes now let unmatched slash-commands continue into normal bot command routing instead of also executing the current scene step or scene message fallback.
### 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 proving unmatched slash-commands do not trigger active scene step handlers before normal bot command routing.
## v1.0.0-rc.14
### Bot API 9.6
+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.
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(...)`.
+29
View File
@@ -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(...)`.
-4
View File
@@ -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)
+162
View File
@@ -0,0 +1,162 @@
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
}
func (codec BotOptsFileJsonCodec) Load(filename string) (*BotOpts, error) {
return LoadBotOptsFile(codec, filename)
}
func (codec BotOptsFileJsonCodec) Save(filename string, opts *BotOpts) error {
return SaveBotOptsFile(codec, filename, opts)
}
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)
Load(filename string) (*BotOpts, error)
Save(filename string, opts *BotOpts) 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)
}
}
+12 -11
View File
@@ -41,7 +41,7 @@ func (bot *Bot[T]) initLoggers(opts *BotOpts) {
level = slog.DEBUG
}
bot.logger = utils.CreateLogger("BOT", level)
bot.logger = utils.CreateLogger("BOT", level).AddReplacer(bot.token, "<TOKEN>")
if opts.WriteToFile {
path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/"))
logger, err := utils.CreateFileLogger("BOT", level, path)
@@ -53,7 +53,7 @@ func (bot *Bot[T]) initLoggers(opts *BotOpts) {
}
if opts.UseRequestLogger {
bot.RequestLogger = utils.CreateLogger("REQUESTS", level)
bot.RequestLogger = utils.CreateLogger("REQUESTS", level).AddReplacer(bot.token, "<TOKEN>")
if opts.WriteToFile {
path := fmt.Sprintf("%s/requests.log", strings.TrimRight(opts.LoggerBasePath, "/"))
logger, err := utils.CreateFileLogger("REQUESTS", level, path)
@@ -122,15 +122,16 @@ func shouldWarnOnValueAppData[T any]() bool {
func clonePlugin[T AppData](p *Plugin[T]) Plugin[T] {
cloned := Plugin[T]{
name: p.name,
commands: make(map[string]*Command[T], len(p.commands)),
payloads: make(map[string]*Command[T], len(p.payloads)),
scenes: make(map[string]*Scene[T], len(p.scenes)),
middlewares: append(extypes.Slice[Middleware[T]](nil), p.middlewares...),
skipAutoCmd: p.skipAutoCmd,
logger: p.logger,
handlers: make(map[tgapi.UpdateType]CommandExecutor[T]),
onClose: p.onClose,
name: p.name,
commands: make(map[string]*Command[T], len(p.commands)),
payloads: make(map[string]*Command[T], len(p.payloads)),
scenes: make(map[string]*Scene[T], len(p.scenes)),
middlewares: append(extypes.Slice[Middleware[T]](nil), p.middlewares...),
skipAutoCmd: p.skipAutoCmd,
logger: p.logger,
messageFallback: p.messageFallback,
handlers: make(map[tgapi.UpdateType]CommandExecutor[T]),
onClose: p.onClose,
}
for name, command := range p.commands {
+2 -2
View File
@@ -362,9 +362,9 @@ func statusHandler[T any](bot *Bot[T], opts *BotWebHookOpts) http.HandlerFunc {
func (bot *Bot[T]) newWebHookMux(ctx context.Context, opts *BotWebHookOpts) *http.ServeMux {
r := http.NewServeMux()
if opts.UseStatusPath {
r.HandleFunc("/status", statusHandler[T](bot, opts))
r.HandleFunc("/status", statusHandler(bot, opts))
}
r.HandleFunc(opts.Path, updateHandler[T](ctx, bot, opts.SecretToken))
r.HandleFunc(opts.Path, updateHandler(ctx, bot, opts.SecretToken))
return r
}
func (bot *Bot[T]) runWebHook(ctx context.Context, opts *BotWebHookOpts) error {
+4 -4
View File
@@ -6,14 +6,14 @@ retract v1.0.0-rc.5
require (
git.scuroneko.dev/scuroneko/extypes v1.2.3
git.scuroneko.dev/scuroneko/slog v1.1.3
git.scuroneko.dev/scuroneko/slog v1.2.0
github.com/alitto/pond/v2 v2.7.0
golang.org/x/time v0.15.0
)
require (
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
golang.org/x/sys v0.42.0 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
golang.org/x/sys v0.43.0 // indirect
)
+8 -9
View File
@@ -1,17 +1,16 @@
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/slog v1.1.3 h1:vI4GZykn8gDb6OJ2xq+KLcEk38M7O4e/z1kzpeRHEHw=
git.scuroneko.dev/scuroneko/slog v1.1.3/go.mod h1:gnDap54sfZv3EuSyZd7fjOH46aLbDFpvtN2wgFcWkgE=
git.scuroneko.dev/scuroneko/slog v1.2.0 h1:xbwzrMcmN0NG/zTgEn508mn2JVnfZN5z/Zsi3PREfDM=
git.scuroneko.dev/scuroneko/slog v1.2.0/go.mod h1:r+oz9NzvvdtWd9/PjeS+n5vQoNHL38BdcdLoBtJPvFU=
github.com/alitto/pond/v2 v2.7.0 h1:c76L+yN916m/DRXjGCeUBHHu92uWnh/g1bwVk4zyyXg=
github.com/alitto/pond/v2 v2.7.0/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+167 -1
View File
@@ -13,13 +13,16 @@ type recordingObserver struct {
started []HandlerStartedEvent
finished []HandlerFinishedEvent
errors []ErrorEvent
handled []UpdateHandledEvent
policies []PolicyCheckedEvent
runners []RunnerFinishedEvent
retries []PollingRetryEvent
}
func (*recordingObserver) OnReceiveUpdate(context.Context, UpdateReceivedEvent) {}
func (*recordingObserver) OnHandledUpdate(context.Context, UpdateHandledEvent) {}
func (o *recordingObserver) OnHandledUpdate(_ context.Context, ev UpdateHandledEvent) {
o.handled = append(o.handled, ev)
}
func (o *recordingObserver) OnHandlerStarted(_ context.Context, ev HandlerStartedEvent) {
o.started = append(o.started, ev)
}
@@ -584,6 +587,169 @@ func TestHandleUpdateObserverEmitsUpdateErrors(t *testing.T) {
}
}
func TestHandleMessageFallbackRunsAfterCommandMiss(t *testing.T) {
observer := &recordingObserver{}
called := false
plugin := NewPlugin[NoData]("test")
plugin.SetMessageFallback(func(ctx *MsgContext, db NoData) error {
called = true
if ctx.Text != "/missing hello world" {
t.Fatalf("unexpected fallback text: got %q", ctx.Text)
}
if ctx.Prefix != "/" {
t.Fatalf("unexpected fallback prefix: got %q", ctx.Prefix)
}
wantArgs := []string{"/missing", "hello", "world"}
if len(ctx.Args) != len(wantArgs) || ctx.Args[0] != wantArgs[0] || ctx.Args[1] != wantArgs[1] || ctx.Args[2] != wantArgs[2] {
t.Fatalf("unexpected fallback args: got %v want %v", ctx.Args, wantArgs)
}
return nil
})
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
observer: observer,
}
bot.AddPlugins(plugin)
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 5,
Type: tgapi.UpdateTypeMessage,
Message: &tgapi.Message{
MessageID: 1,
Text: "/missing hello world",
From: &tgapi.User{ID: 41},
Chat: &tgapi.Chat{ID: 99},
},
})
if !called {
t.Fatal("expected message fallback to be called")
}
if len(observer.started) != 1 {
t.Fatalf("expected one started event, got %d", len(observer.started))
}
if got := observer.started[0]; got.HandlerKind != HandlerMessageKind || got.HandlerName != "message_fallback" || got.Plugin != "test" {
t.Fatalf("unexpected started event: %#v", got)
}
if len(observer.finished) != 1 {
t.Fatalf("expected one finished event, got %d", len(observer.finished))
}
if got := observer.finished[0]; got.HandlerKind != HandlerMessageKind || got.HandlerName != "message_fallback" || got.Plugin != "test" || got.Err != nil {
t.Fatalf("unexpected finished event: %#v", got)
}
if len(observer.handled) != 1 || !observer.handled[0].Handled {
t.Fatalf("expected handled update event, got %#v", observer.handled)
}
}
func TestHandleMessageFallbackRunsForPlainText(t *testing.T) {
called := false
plugin := NewPlugin[NoData]("test").SetMessageFallback(func(ctx *MsgContext, db NoData) error {
called = true
if ctx.Text != "hello fallback" {
t.Fatalf("unexpected fallback text: got %q", ctx.Text)
}
if ctx.Prefix != "" {
t.Fatalf("unexpected fallback prefix: got %q", ctx.Prefix)
}
return nil
})
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
}
bot.AddPlugins(plugin)
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 6,
Type: tgapi.UpdateTypeMessage,
Message: &tgapi.Message{
MessageID: 1,
Text: "hello fallback",
From: &tgapi.User{ID: 41},
Chat: &tgapi.Chat{ID: 99},
},
})
if !called {
t.Fatal("expected message fallback to be called")
}
}
func TestHandleMessageFallbackRespectsMiddleware(t *testing.T) {
called := false
plugin := NewPlugin[NoData]("test")
plugin.AddMiddleware(NewMiddleware("block", func(ctx *MsgContext, db NoData) bool {
return false
}))
plugin.SetMessageFallback(func(ctx *MsgContext, db NoData) error {
called = true
return nil
})
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
}
bot.AddPlugins(plugin)
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 7,
Type: tgapi.UpdateTypeMessage,
Message: &tgapi.Message{
MessageID: 1,
Text: "blocked",
From: &tgapi.User{ID: 41},
Chat: &tgapi.Chat{ID: 99},
},
})
if called {
t.Fatal("message fallback must not run when plugin middleware blocks")
}
}
func TestHandleMessageFallbackDoesNotRunWhenCommandMatches(t *testing.T) {
commandCalled := false
fallbackCalled := false
plugin := NewPlugin[NoData]("test")
plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
commandCalled = true
return nil
}, "start")
plugin.SetMessageFallback(func(ctx *MsgContext, db NoData) error {
fallbackCalled = true
return nil
})
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
}
bot.AddPlugins(plugin)
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 8,
Type: tgapi.UpdateTypeMessage,
Message: &tgapi.Message{
MessageID: 1,
Text: "/start",
From: &tgapi.User{ID: 41},
Chat: &tgapi.Chat{ID: 99},
},
})
if !commandCalled {
t.Fatal("expected command handler to be called")
}
if fallbackCalled {
t.Fatal("message fallback must not run when command matches")
}
}
func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
called := false
plugin := NewPlugin[NoData]("test")
+95 -17
View File
@@ -8,27 +8,14 @@ import (
)
func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) bool {
var msg *tgapi.Message
if update.Message != nil {
msg = update.Message
} else if update.ChannelPost != nil {
msg = update.ChannelPost
} else {
return false
}
var text string
if len(msg.Text) > 0 {
text = msg.Text
} else if len(msg.Caption) > 0 {
text = msg.Caption
} else {
text, ok := messageText(update)
if !ok {
return false
}
prefix, cmd, args := bot.parseCommand(text)
if cmd == "" {
return false
return bot.handleFallback(update, ctx)
}
ctx.Prefix = prefix
@@ -99,7 +86,98 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) bool {
return true
}
}
return false
return bot.handleFallback(update, ctx)
}
func (bot *Bot[T]) handleFallback(update *tgapi.Update, ctx *MsgContext) bool {
text, ok := messageText(update)
if !ok {
return false
}
prefix, _, _ := bot.parseCommand(text)
handled := false
for _, plugin := range bot.plugins {
if plugin.messageFallback == nil {
continue
}
pluginCtx := cloneMsgContext(ctx)
pluginCtx.Prefix = prefix
pluginCtx.Text = text
pluginCtx.Args = strings.Fields(text)
if plugin.logger != nil {
pluginCtx.Logger = plugin.logger
}
if !plugin.executeMiddlewares(pluginCtx, bot.appData) {
continue
}
startTime := time.Now()
bot.safeEmitEvent(pluginCtx.Context(), HandlerStartedEvent{
UpdateID: update.UpdateID,
UpdateType: update.Type,
Plugin: plugin.name,
HandlerKind: HandlerMessageKind,
HandlerName: "message_fallback",
FromID: pluginCtx.FromID,
ChatID: pluginCtx.ChatID,
})
err := plugin.messageFallback(pluginCtx, bot.appData)
endEvent := HandlerFinishedEvent{
UpdateID: update.UpdateID,
UpdateType: update.Type,
Plugin: plugin.name,
HandlerKind: HandlerMessageKind,
HandlerName: "message_fallback",
FromID: pluginCtx.FromID,
ChatID: pluginCtx.ChatID,
Duration: time.Since(startTime),
}
if err != nil {
endEvent.Err = err
endEvent.UserFacing = IsUserError(err)
}
bot.safeEmitEvent(pluginCtx.Context(), endEvent)
if err != nil {
pluginCtx.error(err)
bot.safeEmitEvent(pluginCtx.Context(), ErrorEvent{
UpdateID: update.UpdateID,
UpdateType: update.Type,
Plugin: plugin.name,
HandlerKind: HandlerMessageKind,
HandlerName: "message_fallback",
FromID: pluginCtx.FromID,
ChatID: pluginCtx.ChatID,
Err: err,
UserFacing: IsUserError(err),
})
}
handled = true
}
return handled
}
func messageText(update *tgapi.Update) (string, bool) {
var msg *tgapi.Message
if update.Message != nil {
msg = update.Message
} else if update.ChannelPost != nil {
msg = update.ChannelPost
} else {
return "", false
}
var text string
if len(msg.Text) > 0 {
text = msg.Text
} else if len(msg.Caption) > 0 {
text = msg.Caption
} else {
return "", false
}
return text, true
}
func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) bool {
+2
View File
@@ -14,6 +14,8 @@ type HandlerEventKind string
const (
// HandlerCommandKind identifies a command handler.
HandlerCommandKind HandlerEventKind = "command"
// HandlerMessageKind identifies a message fallback handler.
HandlerMessageKind HandlerEventKind = "message"
// HandlerPayloadKind identifies a callback payload handler.
HandlerPayloadKind HandlerEventKind = "payload"
// HandlerUpdateKind identifies a generic update handler.
+15 -7
View File
@@ -171,7 +171,8 @@ type Plugin[T AppData] struct {
skipAutoCmd bool // If true, all commands in this plugin are excluded from auto-help
logger *slog.Logger
handlers map[tgapi.UpdateType]CommandExecutor[T]
messageFallback CommandExecutor[T]
handlers map[tgapi.UpdateType]CommandExecutor[T]
onClose func() error
}
@@ -243,12 +244,6 @@ func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] {
return p
}
// UsePolicy registers a Policy as plugin middleware for all plugin handlers.
func (p *Plugin[T]) UsePolicy(name string, policy Policy[T]) *Plugin[T] {
mw := RequirePolicy(name, policy)
return p.AddMiddleware(mw)
}
// NewScene creates, registers, and returns a new scene owned by the plugin.
func (p *Plugin[T]) NewScene(name string) *Scene[T] {
scene := NewScene[T](name)
@@ -257,6 +252,12 @@ func (p *Plugin[T]) NewScene(name string) *Scene[T] {
return scene
}
// UsePolicy registers a Policy as plugin middleware for all plugin handlers.
func (p *Plugin[T]) UsePolicy(name string, policy Policy[T]) *Plugin[T] {
mw := RequirePolicy(name, policy)
return p.AddMiddleware(mw)
}
// AddUpdateHandler registers a handler for a non-command update type.
// Message, channel post, and callback query updates stay on the command/payload flow.
func (p *Plugin[T]) AddUpdateHandler(t tgapi.UpdateType, handler CommandExecutor[T]) *Plugin[T] {
@@ -316,6 +317,13 @@ func (p *Plugin[T]) SetOnClose(f func() error) *Plugin[T] {
return p
}
// SetMessageFallback registers a fallback handler for messages that do not
// match a command.
func (p *Plugin[T]) SetMessageFallback(handler CommandExecutor[T]) *Plugin[T] {
p.messageFallback = handler
return p
}
// Close releases plugin-owned resources such as its logger and optional
// OnClose callback.
func (p *Plugin[T]) Close() error {
+3 -3
View File
@@ -6,7 +6,7 @@ import (
)
func TestValidateArgsRequiresFullMatch(t *testing.T) {
intCmd := NewCommand[NoData](func(ctx *MsgContext, db NoData) error { return nil }, "int", NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired())
intCmd := NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "int", NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired())
if err := intCmd.validateArgs([]string{"123"}); err != nil {
t.Fatalf("expected valid integer argument, got %v", err)
}
@@ -14,7 +14,7 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) {
t.Fatalf("expected ErrCmdArgRegexpMismatch for partial int match, got %v", err)
}
boolCmd := NewCommand[NoData](func(ctx *MsgContext, db NoData) error { return nil }, "bool", NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired())
boolCmd := NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "bool", NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired())
if err := boolCmd.validateArgs([]string{"false"}); err != nil {
t.Fatalf("expected valid bool argument, got %v", err)
}
@@ -24,7 +24,7 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) {
}
func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) {
cmd := NewCommand[NoData](
cmd := NewCommand(
func(ctx *MsgContext, db NoData) error { return nil },
"mixed",
NewCommandArg("optional"),
+9 -9
View File
@@ -53,7 +53,7 @@ func TestRequirePolicyStopsExecutionOnDeniedPolicy(t *testing.T) {
errorTemplate: "Error: %s",
}
mw := RequirePolicy[NoData]("deny", func(ctx *MsgContext, data NoData) error {
mw := RequirePolicy("deny", func(ctx *MsgContext, data NoData) error {
return AsUserError(errors.New("blocked"))
})
@@ -159,7 +159,7 @@ func TestRequireChatAdminUsesNormalizedIDs(t *testing.T) {
func TestAllPoliciesReturnsFirstError(t *testing.T) {
want := AsUserError(errors.New("blocked"))
policy := AllPolicies[NoData](
policy := AllPolicies(
func(ctx *MsgContext, data NoData) error { return nil },
func(ctx *MsgContext, data NoData) error { return want },
func(ctx *MsgContext, data NoData) error {
@@ -175,7 +175,7 @@ func TestAllPoliciesReturnsFirstError(t *testing.T) {
}
func TestAnyPolicyAllowsLaterSuccessAfterInternalError(t *testing.T) {
policy := AnyPolicy[NoData](
policy := AnyPolicy(
func(ctx *MsgContext, data NoData) error { return AsInternalError(errors.New("temporary")) },
func(ctx *MsgContext, data NoData) error { return nil },
)
@@ -187,7 +187,7 @@ func TestAnyPolicyAllowsLaterSuccessAfterInternalError(t *testing.T) {
func TestAnyPolicyReturnsInternalErrorWhenNonePass(t *testing.T) {
internal := AsInternalError(errors.New("temporary"))
policy := AnyPolicy[NoData](
policy := AnyPolicy(
func(ctx *MsgContext, data NoData) error { return AsUserError(errors.New("denied")) },
func(ctx *MsgContext, data NoData) error { return internal },
)
@@ -200,7 +200,7 @@ func TestAnyPolicyReturnsInternalErrorWhenNonePass(t *testing.T) {
func TestAnyPolicyReturnsFirstDenyWhenNoPolicyPasses(t *testing.T) {
first := AsUserError(errors.New("first deny"))
policy := AnyPolicy[NoData](
policy := AnyPolicy(
func(ctx *MsgContext, data NoData) error { return first },
func(ctx *MsgContext, data NoData) error { return AsUserError(errors.New("second deny")) },
)
@@ -212,7 +212,7 @@ func TestAnyPolicyReturnsFirstDenyWhenNoPolicyPasses(t *testing.T) {
}
func TestNotPolicyInvertsUserDenyButPreservesInternalErrors(t *testing.T) {
inverted := NotPolicy[NoData](func(ctx *MsgContext, data NoData) error {
inverted := NotPolicy(func(ctx *MsgContext, data NoData) error {
return AsUserError(errors.New("denied"))
})
if err := inverted(&MsgContext{Logger: slog.CreateLogger()}, NoData{}); err != nil {
@@ -220,7 +220,7 @@ func TestNotPolicyInvertsUserDenyButPreservesInternalErrors(t *testing.T) {
}
internal := AsInternalError(errors.New("temporary"))
preserve := NotPolicy[NoData](func(ctx *MsgContext, data NoData) error {
preserve := NotPolicy(func(ctx *MsgContext, data NoData) error {
return internal
})
err := preserve(&MsgContext{Logger: slog.CreateLogger()}, NoData{})
@@ -240,7 +240,7 @@ func TestRequirePolicyEmitsObserverEvents(t *testing.T) {
ChatID: 20,
}
mw := RequirePolicy[NoData]("allow", func(ctx *MsgContext, data NoData) error {
mw := RequirePolicy("allow", func(ctx *MsgContext, data NoData) error {
return nil
})
@@ -264,7 +264,7 @@ func TestRequirePolicyEmitsObserverEvents(t *testing.T) {
errorTemplate: "%s",
}
mw := RequirePolicy[NoData]("deny", func(ctx *MsgContext, data NoData) error {
mw := RequirePolicy("deny", func(ctx *MsgContext, data NoData) error {
return AsInternalError(errors.New("blocked"))
})
+4
View File
@@ -81,6 +81,10 @@ func (bot *Bot[T]) executeScene(ctx *SceneContext, scene *Scene[T]) (bool, error
}
return ok, err
}
// Unmatched slash-commands should continue through normal bot command routing
// instead of also triggering the active scene step or fallback handler.
return false, nil
}
ctx.Text = text
ctx.Args = nil
+68
View File
@@ -509,6 +509,74 @@ func TestScenePassDoesNotPersistSessionData(t *testing.T) {
}
}
func TestSceneUnmatchedCommandFallsThroughWithoutRunningStep(t *testing.T) {
commandCalled := 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
})
plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
commandCalled = true
return nil
}, "ping")
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
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")
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 5,
Type: tgapi.UpdateTypeMessage,
Message: &tgapi.Message{
MessageID: 10,
Text: "/ping",
Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate},
From: &tgapi.User{ID: 42},
},
})
if !commandCalled {
t.Fatal("expected normal command routing to handle /ping")
}
if stepCalled {
t.Fatal("scene step must not run for an unmatched slash-command")
}
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 command fallback: %#v", after)
}
}
func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) {
fallbackCalled := false
-1
View File
@@ -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)
+2 -2
View File
@@ -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)
}
+2 -2
View File
@@ -2,7 +2,7 @@ package utils
const (
// VersionString is the module version string.
VersionString = "1.0.0-rc.14"
VersionString = "1.0.0-rc.15"
// VersionMajor is the module major version.
VersionMajor = 1
// VersionMinor is the module minor version.
@@ -10,5 +10,5 @@ const (
// VersionPatch is the module patch version.
VersionPatch = 0
// VersionBeta is the prerelease counter for the current version.
VersionBeta = 14
VersionBeta = 15
)