REPOSITORY / ScuroNeko/Laniakea
Compare commits
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e92a0d37f3 | ||
|
|
768dc859d7
|
||
|
|
d6da95394c
|
||
|
|
c9ec18ccea
|
||
|
|
aa18da73d5
|
||
|
|
2b64e8543f
|
@@ -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
|
||||||
@@ -1,5 +1,19 @@
|
|||||||
# Changelog
|
# 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
|
## v1.0.0-rc.14
|
||||||
|
|
||||||
### Bot API 9.6
|
### Bot API 9.6
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
@@ -41,7 +41,7 @@ func (bot *Bot[T]) initLoggers(opts *BotOpts) {
|
|||||||
level = slog.DEBUG
|
level = slog.DEBUG
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.logger = utils.CreateLogger("BOT", level)
|
bot.logger = utils.CreateLogger("BOT", level).AddReplacer(bot.token, "<TOKEN>")
|
||||||
if opts.WriteToFile {
|
if opts.WriteToFile {
|
||||||
path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/"))
|
path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/"))
|
||||||
logger, err := utils.CreateFileLogger("BOT", level, path)
|
logger, err := utils.CreateFileLogger("BOT", level, path)
|
||||||
@@ -53,7 +53,7 @@ func (bot *Bot[T]) initLoggers(opts *BotOpts) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if opts.UseRequestLogger {
|
if opts.UseRequestLogger {
|
||||||
bot.RequestLogger = utils.CreateLogger("REQUESTS", level)
|
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/requests.log", strings.TrimRight(opts.LoggerBasePath, "/"))
|
||||||
logger, err := utils.CreateFileLogger("REQUESTS", level, path)
|
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] {
|
func clonePlugin[T AppData](p *Plugin[T]) Plugin[T] {
|
||||||
cloned := Plugin[T]{
|
cloned := Plugin[T]{
|
||||||
name: p.name,
|
name: p.name,
|
||||||
commands: make(map[string]*Command[T], len(p.commands)),
|
commands: make(map[string]*Command[T], len(p.commands)),
|
||||||
payloads: make(map[string]*Command[T], len(p.payloads)),
|
payloads: make(map[string]*Command[T], len(p.payloads)),
|
||||||
scenes: make(map[string]*Scene[T], len(p.scenes)),
|
scenes: make(map[string]*Scene[T], len(p.scenes)),
|
||||||
middlewares: append(extypes.Slice[Middleware[T]](nil), p.middlewares...),
|
middlewares: append(extypes.Slice[Middleware[T]](nil), p.middlewares...),
|
||||||
skipAutoCmd: p.skipAutoCmd,
|
skipAutoCmd: p.skipAutoCmd,
|
||||||
logger: p.logger,
|
logger: p.logger,
|
||||||
handlers: make(map[tgapi.UpdateType]CommandExecutor[T]),
|
messageFallback: p.messageFallback,
|
||||||
onClose: p.onClose,
|
handlers: make(map[tgapi.UpdateType]CommandExecutor[T]),
|
||||||
|
onClose: p.onClose,
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, command := range p.commands {
|
for name, command := range p.commands {
|
||||||
|
|||||||
+2
-2
@@ -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 {
|
func (bot *Bot[T]) newWebHookMux(ctx context.Context, opts *BotWebHookOpts) *http.ServeMux {
|
||||||
r := http.NewServeMux()
|
r := http.NewServeMux()
|
||||||
if opts.UseStatusPath {
|
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
|
return r
|
||||||
}
|
}
|
||||||
func (bot *Bot[T]) runWebHook(ctx context.Context, opts *BotWebHookOpts) error {
|
func (bot *Bot[T]) runWebHook(ctx context.Context, opts *BotWebHookOpts) error {
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ 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.1.3
|
git.scuroneko.dev/scuroneko/slog v1.2.0
|
||||||
github.com/alitto/pond/v2 v2.7.0
|
github.com/alitto/pond/v2 v2.7.0
|
||||||
golang.org/x/time v0.15.0
|
golang.org/x/time v0.15.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
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.1.3 h1:vI4GZykn8gDb6OJ2xq+KLcEk38M7O4e/z1kzpeRHEHw=
|
git.scuroneko.dev/scuroneko/slog v1.2.0 h1:xbwzrMcmN0NG/zTgEn508mn2JVnfZN5z/Zsi3PREfDM=
|
||||||
git.scuroneko.dev/scuroneko/slog v1.1.3/go.mod h1:gnDap54sfZv3EuSyZd7fjOH46aLbDFpvtN2wgFcWkgE=
|
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 h1:c76L+yN916m/DRXjGCeUBHHu92uWnh/g1bwVk4zyyXg=
|
||||||
github.com/alitto/pond/v2 v2.7.0/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE=
|
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.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
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 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
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.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/sys v0.42.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=
|
||||||
|
|||||||
+167
-1
@@ -13,13 +13,16 @@ type recordingObserver struct {
|
|||||||
started []HandlerStartedEvent
|
started []HandlerStartedEvent
|
||||||
finished []HandlerFinishedEvent
|
finished []HandlerFinishedEvent
|
||||||
errors []ErrorEvent
|
errors []ErrorEvent
|
||||||
|
handled []UpdateHandledEvent
|
||||||
policies []PolicyCheckedEvent
|
policies []PolicyCheckedEvent
|
||||||
runners []RunnerFinishedEvent
|
runners []RunnerFinishedEvent
|
||||||
retries []PollingRetryEvent
|
retries []PollingRetryEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*recordingObserver) OnReceiveUpdate(context.Context, UpdateReceivedEvent) {}
|
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) {
|
func (o *recordingObserver) OnHandlerStarted(_ context.Context, ev HandlerStartedEvent) {
|
||||||
o.started = append(o.started, ev)
|
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) {
|
func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
|
||||||
called := false
|
called := false
|
||||||
plugin := NewPlugin[NoData]("test")
|
plugin := NewPlugin[NoData]("test")
|
||||||
|
|||||||
+95
-17
@@ -8,27 +8,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) bool {
|
func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) bool {
|
||||||
var msg *tgapi.Message
|
text, ok := messageText(update)
|
||||||
if update.Message != nil {
|
if !ok {
|
||||||
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 false
|
||||||
}
|
}
|
||||||
|
|
||||||
prefix, cmd, args := bot.parseCommand(text)
|
prefix, cmd, args := bot.parseCommand(text)
|
||||||
if cmd == "" {
|
if cmd == "" {
|
||||||
return false
|
return bot.handleFallback(update, ctx)
|
||||||
}
|
}
|
||||||
ctx.Prefix = prefix
|
ctx.Prefix = prefix
|
||||||
|
|
||||||
@@ -99,7 +86,98 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) bool {
|
|||||||
return true
|
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 {
|
func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) bool {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ type HandlerEventKind string
|
|||||||
const (
|
const (
|
||||||
// HandlerCommandKind identifies a command handler.
|
// HandlerCommandKind identifies a command handler.
|
||||||
HandlerCommandKind HandlerEventKind = "command"
|
HandlerCommandKind HandlerEventKind = "command"
|
||||||
|
// HandlerMessageKind identifies a message fallback handler.
|
||||||
|
HandlerMessageKind HandlerEventKind = "message"
|
||||||
// HandlerPayloadKind identifies a callback payload handler.
|
// HandlerPayloadKind identifies a callback payload handler.
|
||||||
HandlerPayloadKind HandlerEventKind = "payload"
|
HandlerPayloadKind HandlerEventKind = "payload"
|
||||||
// HandlerUpdateKind identifies a generic update handler.
|
// HandlerUpdateKind identifies a generic update handler.
|
||||||
|
|||||||
+15
-7
@@ -171,7 +171,8 @@ type Plugin[T AppData] struct {
|
|||||||
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 *slog.Logger
|
||||||
|
|
||||||
handlers map[tgapi.UpdateType]CommandExecutor[T]
|
messageFallback CommandExecutor[T]
|
||||||
|
handlers map[tgapi.UpdateType]CommandExecutor[T]
|
||||||
|
|
||||||
onClose func() error
|
onClose func() error
|
||||||
}
|
}
|
||||||
@@ -243,12 +244,6 @@ func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] {
|
|||||||
return p
|
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.
|
// NewScene creates, registers, and returns a new scene owned by the plugin.
|
||||||
func (p *Plugin[T]) NewScene(name string) *Scene[T] {
|
func (p *Plugin[T]) NewScene(name string) *Scene[T] {
|
||||||
scene := NewScene[T](name)
|
scene := NewScene[T](name)
|
||||||
@@ -257,6 +252,12 @@ func (p *Plugin[T]) NewScene(name string) *Scene[T] {
|
|||||||
return scene
|
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.
|
// AddUpdateHandler registers a handler for a non-command update type.
|
||||||
// Message, channel post, and callback query updates stay on the command/payload flow.
|
// 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] {
|
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
|
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
|
// Close releases plugin-owned resources such as its logger and optional
|
||||||
// OnClose callback.
|
// OnClose callback.
|
||||||
func (p *Plugin[T]) Close() error {
|
func (p *Plugin[T]) Close() error {
|
||||||
|
|||||||
+3
-3
@@ -6,7 +6,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestValidateArgsRequiresFullMatch(t *testing.T) {
|
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 {
|
if err := intCmd.validateArgs([]string{"123"}); err != nil {
|
||||||
t.Fatalf("expected valid integer argument, got %v", err)
|
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)
|
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 {
|
if err := boolCmd.validateArgs([]string{"false"}); err != nil {
|
||||||
t.Fatalf("expected valid bool argument, got %v", err)
|
t.Fatalf("expected valid bool argument, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,7 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) {
|
func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) {
|
||||||
cmd := NewCommand[NoData](
|
cmd := NewCommand(
|
||||||
func(ctx *MsgContext, db NoData) error { return nil },
|
func(ctx *MsgContext, db NoData) error { return nil },
|
||||||
"mixed",
|
"mixed",
|
||||||
NewCommandArg("optional"),
|
NewCommandArg("optional"),
|
||||||
|
|||||||
+9
-9
@@ -53,7 +53,7 @@ func TestRequirePolicyStopsExecutionOnDeniedPolicy(t *testing.T) {
|
|||||||
errorTemplate: "Error: %s",
|
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"))
|
return AsUserError(errors.New("blocked"))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ func TestRequireChatAdminUsesNormalizedIDs(t *testing.T) {
|
|||||||
|
|
||||||
func TestAllPoliciesReturnsFirstError(t *testing.T) {
|
func TestAllPoliciesReturnsFirstError(t *testing.T) {
|
||||||
want := AsUserError(errors.New("blocked"))
|
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 nil },
|
||||||
func(ctx *MsgContext, data NoData) error { return want },
|
func(ctx *MsgContext, data NoData) error { return want },
|
||||||
func(ctx *MsgContext, data NoData) error {
|
func(ctx *MsgContext, data NoData) error {
|
||||||
@@ -175,7 +175,7 @@ func TestAllPoliciesReturnsFirstError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAnyPolicyAllowsLaterSuccessAfterInternalError(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 AsInternalError(errors.New("temporary")) },
|
||||||
func(ctx *MsgContext, data NoData) error { return nil },
|
func(ctx *MsgContext, data NoData) error { return nil },
|
||||||
)
|
)
|
||||||
@@ -187,7 +187,7 @@ func TestAnyPolicyAllowsLaterSuccessAfterInternalError(t *testing.T) {
|
|||||||
|
|
||||||
func TestAnyPolicyReturnsInternalErrorWhenNonePass(t *testing.T) {
|
func TestAnyPolicyReturnsInternalErrorWhenNonePass(t *testing.T) {
|
||||||
internal := AsInternalError(errors.New("temporary"))
|
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 AsUserError(errors.New("denied")) },
|
||||||
func(ctx *MsgContext, data NoData) error { return internal },
|
func(ctx *MsgContext, data NoData) error { return internal },
|
||||||
)
|
)
|
||||||
@@ -200,7 +200,7 @@ func TestAnyPolicyReturnsInternalErrorWhenNonePass(t *testing.T) {
|
|||||||
|
|
||||||
func TestAnyPolicyReturnsFirstDenyWhenNoPolicyPasses(t *testing.T) {
|
func TestAnyPolicyReturnsFirstDenyWhenNoPolicyPasses(t *testing.T) {
|
||||||
first := AsUserError(errors.New("first deny"))
|
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 first },
|
||||||
func(ctx *MsgContext, data NoData) error { return AsUserError(errors.New("second deny")) },
|
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) {
|
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"))
|
return AsUserError(errors.New("denied"))
|
||||||
})
|
})
|
||||||
if err := inverted(&MsgContext{Logger: slog.CreateLogger()}, NoData{}); err != nil {
|
if err := inverted(&MsgContext{Logger: slog.CreateLogger()}, NoData{}); err != nil {
|
||||||
@@ -220,7 +220,7 @@ func TestNotPolicyInvertsUserDenyButPreservesInternalErrors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
internal := AsInternalError(errors.New("temporary"))
|
internal := AsInternalError(errors.New("temporary"))
|
||||||
preserve := NotPolicy[NoData](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: slog.CreateLogger()}, NoData{})
|
||||||
@@ -240,7 +240,7 @@ func TestRequirePolicyEmitsObserverEvents(t *testing.T) {
|
|||||||
ChatID: 20,
|
ChatID: 20,
|
||||||
}
|
}
|
||||||
|
|
||||||
mw := RequirePolicy[NoData]("allow", func(ctx *MsgContext, data NoData) error {
|
mw := RequirePolicy("allow", func(ctx *MsgContext, data NoData) error {
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -264,7 +264,7 @@ func TestRequirePolicyEmitsObserverEvents(t *testing.T) {
|
|||||||
errorTemplate: "%s",
|
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"))
|
return AsInternalError(errors.New("blocked"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ func (bot *Bot[T]) executeScene(ctx *SceneContext, scene *Scene[T]) (bool, error
|
|||||||
}
|
}
|
||||||
return ok, err
|
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.Text = text
|
||||||
ctx.Args = nil
|
ctx.Args = nil
|
||||||
|
|||||||
@@ -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) {
|
func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) {
|
||||||
fallbackCalled := false
|
fallbackCalled := false
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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.14"
|
VersionString = "1.0.0-rc.15"
|
||||||
// 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 = 14
|
VersionBeta = 15
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user