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
29 changed files with 191 additions and 138 deletions
Showing only changes of commit 61d0b1ebb8 - Show all commits
+49
View File
@@ -1,5 +1,54 @@
# TODO # TODO
## v1.0.0 pre-release review
Findings from the full-repo review against `AGENTS.md` priorities. Build, vet, tests, and lint are clean; items below are public-API and godoc hygiene before the stable tag.
### Major — close before 1.0.0 tag
- [X] **M1. `BotPayloadType*` are `var`, must be `const`**`bot.go:50-59`. Public sentinels are user-mutable globals. `KeyboardButtonStyle*` in `keyboard.go:10-17` already uses `const`; match the pattern.
- [X] **M2. `Observer` method naming asymmetry**`observer.go:147-157`. `OnReceiveUpdate``OnUpdateReceived`; `OnHandledUpdate``OnUpdateHandled` to match `UpdateReceivedEvent` / `UpdateHandledEvent` and the rest of the `OnX` pattern. Breaking after 1.0.
- [X] **M3. Uploader returns ad-hoc error string instead of `*ResponseError`**`tgapi/uploader_api.go:183`. `tgapi/api.go:258-292` returns `*ResponseError`; uploader must do the same so `errors.As(err, &tgapi.ResponseError{})` works for upload paths too.
- [X] **M4. `BotOptsFileJSON` is missing `PollTimeout`**`bot_opts_loader.go:35-46`, plus `FromBytes`/`ToBytes` mapping. File round-trip silently drops `PollTimeout`.
- [X] **M5. Stale `Bot.Updates` godoc**`methods.go:11-44`. Claims "30-second timeout" and "empty slice if none"; in reality timeout is `bot.pollTimeout` and the function returns `nil` on error.
- [X] **M6. Self-contradicting `NewRandomDraftProvider` godoc**`drafts.go:50-59`. Says "cryptographically secure random numbers" but uses `math/rand/v2` (the underlying generator type correctly notes it is not crypto-secure).
- [X] **M7. `Draft.Delete` godoc says "internal method"**`drafts.go:190-201`. Method is exported; either rewrite the godoc with a public-intent description or unexport.
- [X] **M8. Russian comments in production code**
- `msg_handler.go:28` — "Ищем команду по точному совпадению"
- `tgapi/uploader_api.go:181` — "Повторяем запрос"
- [X] **M9. `MessageContext.Error` godoc references unexported helper**`msg_context.go:540`. "Error is an alias for error()" — rewrite to describe the centralized handler error path and `IsUserError` gating.
- [X] **M10. `Scene` and `SceneSession` mix exported fields with setters**
- `Scene` exports `Name/Scope/Entry/PluginName` and also has `SetScope/SetEntry`; `PluginName` is framework-assigned but publicly mutable.
- `SceneSession` exports `Data []byte` and also has `Set/Get/HasData/ClearData/BindData/SaveData`.
- Pick one model per type before 1.0.0.
- [X] **M11. Constant-time compare for webhook secret**`bot_webhook.go:296` (update handler) and `bot_webhook.go:341` (`/status`). Use `subtle.ConstantTimeCompare`.
### Minor — can slip to 1.0.x
- [X] Strip `// Internal helper …` godoc from unexported funcs (~23 occurrences in repo); `AGENTS.md` explicitly forbids godoc-style comments on unexported declarations without a strong reason.
- [X] `Plugin.AddCommand` godoc references unexported field `.command``plugins.go:48-49`.
- [X] `Runner` builder naming: `runner.Once(true)`, `runner.Async(true)` read awkwardly; consider `SetOnce`/`SetAsync` to match `Set*` on other types, or zero-arg `Once()` + paired `Repeat(every)`.
- [X] Typo in webhook error string: `bot_webhook.go:143` — "MaxConnections must between 1 and 100" (missing `be`).
- [X] `RunWebhookWithContext` uses inline `errors.New(...)` instead of `Err*` sentinels (`bot_webhook.go:131-156`); rest of the package uses sentinels from `errors.go`.
- [X] `tgapi.UpdateTypeManagedBot` (`tgapi/types.go:61`) has no godoc.
- [X] `Bot.GetAPI`, `Bot.GetUploader`, `InlineKeyboard.GetMaxRow` have no godoc.
- [X] `Bot.L10n` godoc says "Returns empty string if translation not found"; actually returns the key (`l10n.go:48-59`).
- [X] `Bot.handle` panic recovery only logs — emit `ErrorEvent` so observers see panics (`handler.go:18-23`).
- [X] `handleCallback` vs `handleMessage` differ in plugin-logger assignment: callback assigns unconditionally then falls back to bot logger (`msg_handler.go:209-212`); message only assigns if non-nil (`msg_handler.go:35-37`). Align.
- [X] `SetCallbackData` godoc says "default payload type is JSON" — actually the zero `BotPayloadType` falls through to the `default` branch (which happens to be JSON). Either document the zero-value behavior explicitly or initialize the builder with the bot's default (`keyboard.go:106-122`).
- [X] `commands.go:62-66` — empty `case CommandValueAny:` next to `default: regex = nil` looks like an incomplete switch. Merge or add a one-line comment.
- [X] `Bot.SetDebug` does not call `configMutable` unlike sibling setters; if intentional, note it in godoc.
### Tests to add after the fixes
- `BotOptsFileJSON` round-trip for `PollTimeout` (after M4).
- Uploader 4xx/429 surfaces `*tgapi.ResponseError` (after M3).
- `Bot.handle` panic → observer receives `ErrorEvent` (after panic-recovery fix).
- Webhook `/status` with wrong `SecretToken` returns 403 / `403`-equivalent (after M11), incl. a constant-time-compare smoke.
- Table-driven `parseCommand` cases for `/cmd@botname` and stripping behavior.
---
The framework backlog has moved to the wiki. The framework backlog has moved to the wiki.
Primary page: Primary page:
+4 -2
View File
@@ -47,7 +47,7 @@ type AppDataLogger[T AppData] func(data T) sneklog.LoggerWriter
// BotPayloadType defines the serialization format for callback data payloads. // BotPayloadType defines the serialization format for callback data payloads.
type BotPayloadType string type BotPayloadType string
var ( const (
// BotPayloadBase64 encodes callback data as a Base64 string. // BotPayloadBase64 encodes callback data as a Base64 string.
BotPayloadBase64 BotPayloadType = "base64" BotPayloadBase64 BotPayloadType = "base64"
// BotPayloadJSON encodes callback data as a JSON string. // BotPayloadJSON encodes callback data as a JSON string.
@@ -275,8 +275,10 @@ func (bot *Bot[T]) SetWebhookLogger(l *sneklog.Logger) *Bot[T] {
return bot return bot
} }
// GetAPI returns the underlying Telegram Bot API client.
func (bot *Bot[T]) GetAPI() *tgapi.API { return bot.api } func (bot *Bot[T]) GetAPI() *tgapi.API { return bot.api }
// GetUploader returns the underlying file uploader client.
func (bot *Bot[T]) GetUploader() *tgapi.Uploader { return bot.uploader } func (bot *Bot[T]) GetUploader() *tgapi.Uploader { return bot.uploader }
// Close gracefully shuts down bot-owned resources. // Close gracefully shuts down bot-owned resources.
@@ -385,7 +387,7 @@ func (bot *Bot[T]) GetLoggerLevel() sneklog.LogLevel {
} }
// L10n translates a key in the given language. // L10n translates a key in the given language.
// Returns empty string if translation not found. // Returns key if translation not found.
func (bot *Bot[T]) L10n(lang, key string) string { func (bot *Bot[T]) L10n(lang, key string) string {
return bot.l10n.Translate(lang, key) return bot.l10n.Translate(lang, key)
} }
+5 -2
View File
@@ -125,7 +125,7 @@ func (bot *Bot[T]) GetAppData() T { return bot.appData }
// SetUpdateTypes sets the list of update types the bot will request from Telegram. // SetUpdateTypes sets the list of update types the bot will request from Telegram.
// Overwrites any previously set types. // Overwrites any previously set types.
func (bot *Bot[T]) SetUpdateTypes(t ...tgapi.UpdateType) *Bot[T] { func (bot *Bot[T]) SetUpdateTypes(t ...tgapi.UpdateType) *Bot[T] {
if !bot.configMutable("UpdateTypes") { if !bot.configMutable("SetUpdateTypes") {
return bot return bot
} }
bot.updateTypes = make([]tgapi.UpdateType, 0) bot.updateTypes = make([]tgapi.UpdateType, 0)
@@ -177,7 +177,7 @@ func (bot *Bot[T]) SetStrictPayloadType(strict bool) *Bot[T] {
// Use "%s" to insert the error message. // Use "%s" to insert the error message.
// Example: "❌ Error: %s" → "❌ Error: Command not found". // Example: "❌ Error: %s" → "❌ Error: Command not found".
func (bot *Bot[T]) SetErrorTemplate(s string) *Bot[T] { func (bot *Bot[T]) SetErrorTemplate(s string) *Bot[T] {
if !bot.configMutable("ErrorTemplate") { if !bot.configMutable("SetErrorTemplate") {
return bot return bot
} }
bot.errorTemplate = s bot.errorTemplate = s
@@ -186,6 +186,9 @@ func (bot *Bot[T]) SetErrorTemplate(s string) *Bot[T] {
// SetDebug enables or disables debug logging. // SetDebug enables or disables debug logging.
func (bot *Bot[T]) SetDebug(debug bool) *Bot[T] { func (bot *Bot[T]) SetDebug(debug bool) *Bot[T] {
if !bot.configMutable("SetDebug") {
return bot
}
bot.debug = debug bot.debug = debug
level := sneklog.FATAL level := sneklog.FATAL
if debug { if debug {
+3
View File
@@ -28,6 +28,7 @@ type botOptsFileJSONAPI struct {
UseTestServer bool `json:"use_test_server"` UseTestServer bool `json:"use_test_server"`
APIURL string `json:"url"` APIURL string `json:"url"`
RateLimit int `json:"rate_limit"` RateLimit int `json:"rate_limit"`
PollTimeout int `json:"poll_timeout"`
DropRLOverflow bool `json:"drop_overflow"` DropRLOverflow bool `json:"drop_overflow"`
} }
@@ -73,6 +74,7 @@ func (codec BotOptsFileJSONCodec) FromBytes(data []byte) (*BotOpts, error) {
UseTestServer: fileOpts.API.UseTestServer, UseTestServer: fileOpts.API.UseTestServer,
APIURL: fileOpts.API.APIURL, APIURL: fileOpts.API.APIURL,
RateLimit: fileOpts.API.RateLimit, RateLimit: fileOpts.API.RateLimit,
PollTimeout: fileOpts.API.PollTimeout,
DropRateLimitOverflow: fileOpts.API.DropRLOverflow, DropRateLimitOverflow: fileOpts.API.DropRLOverflow,
StrictPayloadType: fileOpts.StrictPayloadType, StrictPayloadType: fileOpts.StrictPayloadType,
@@ -102,6 +104,7 @@ func (codec BotOptsFileJSONCodec) ToBytes(opts *BotOpts) ([]byte, error) {
UseTestServer: opts.UseTestServer, UseTestServer: opts.UseTestServer,
APIURL: opts.APIURL, APIURL: opts.APIURL,
RateLimit: opts.RateLimit, RateLimit: opts.RateLimit,
PollTimeout: opts.PollTimeout,
DropRLOverflow: opts.DropRateLimitOverflow, DropRLOverflow: opts.DropRateLimitOverflow,
}, },
StrictPayloadType: opts.StrictPayloadType, StrictPayloadType: opts.StrictPayloadType,
+3 -3
View File
@@ -24,9 +24,9 @@ func (bot *Bot[T]) findScene(name string) (*sceneMeta, bool) {
} }
return &sceneMeta{ return &sceneMeta{
Name: scene.Name, Name: scene.name,
Scope: scene.Scope, Scope: scene.scope,
Entry: scene.Entry, Entry: scene.entry,
Steps: steps, Steps: steps,
}, true }, true
} }
+2 -2
View File
@@ -37,8 +37,8 @@ func (o *pollingRetryObserver) OnPollingRetry(ctx context.Context, ev PollingRet
type testObserver struct{} type testObserver struct{}
func (testObserver) OnReceiveUpdate(context.Context, UpdateReceivedEvent) {} func (testObserver) OnUpdateReceived(context.Context, UpdateReceivedEvent) {}
func (testObserver) OnHandledUpdate(context.Context, UpdateHandledEvent) {} func (testObserver) OnUpdateHandled(context.Context, UpdateHandledEvent) {}
func (testObserver) OnHandlerStarted(context.Context, HandlerStartedEvent) {} func (testObserver) OnHandlerStarted(context.Context, HandlerStartedEvent) {}
func (testObserver) OnHandlerFinished(context.Context, HandlerFinishedEvent) { func (testObserver) OnHandlerFinished(context.Context, HandlerFinishedEvent) {
} }
+16 -12
View File
@@ -2,6 +2,7 @@ package laniakea
import ( import (
"context" "context"
"crypto/subtle"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -128,7 +129,7 @@ func (opts *BotWebhookOpts) SetSecretToken(secretToken string) *BotWebhookOpts {
// argument order. // argument order.
func (bot *Bot[T]) RunWebhookWithContext(ctx context.Context, opts *BotWebhookOpts, tlsFiles ...string) error { func (bot *Bot[T]) RunWebhookWithContext(ctx context.Context, opts *BotWebhookOpts, tlsFiles ...string) error {
if opts == nil { if opts == nil {
return errors.New("nil BotWebhookOpts") return ErrNilBotWebhookOpts
} }
if len(bot.prefixes) == 0 { if len(bot.prefixes) == 0 {
return ErrNoPrefixes return ErrNoPrefixes
@@ -137,28 +138,28 @@ func (bot *Bot[T]) RunWebhookWithContext(ctx context.Context, opts *BotWebhookOp
return ErrNoPlugins return ErrNoPlugins
} }
if opts.URL == "" { if opts.URL == "" {
return errors.New("empty BotWebhookOpts.URL") return ErrNoBotWebhookOptsURL
} }
if opts.MaxConnections > 100 || opts.MaxConnections <= 0 { if opts.MaxConnections > 100 || opts.MaxConnections <= 0 {
return errors.New("BotWebhookOpts.MaxConnections must between 1 and 100") return ErrBotWebhookOptsMaxConnectionsRange
} }
if err := validateWebhookPath(opts.Path, opts.UseStatusPath); err != nil { if err := validateWebhookPath(opts.Path, opts.UseStatusPath); err != nil {
return err return err
} }
if opts.UseStatusPath && opts.SecretToken == "" { if opts.UseStatusPath && opts.SecretToken == "" {
return errors.New("BotWebhookOpts.SecretToken required when status path is enabled") return ErrStatusPathSecretRequired
} }
if err := validateWebhookTLSFiles(tlsFiles); err != nil { if err := validateWebhookTLSFiles(tlsFiles); err != nil {
return err return err
} }
if opts.Certificate != nil && bot.uploader == nil { if opts.Certificate != nil && bot.uploader == nil {
return errors.New("bot uploader nil, but certificate set") return ErrBotUploaderWhenCertificate
} }
return bot.runWebhookRuntime(ctx, func(runCtx context.Context) error { return bot.runWebhookRuntime(ctx, func(runCtx context.Context) error {
if opts.SecretToken == "" { if opts.SecretToken == "" {
bot.webhookLogger.Warnln("Bot webhook secret token empty. It's VERY recommended to set secret.") bot.webhookLogger.Warnln("Using webhook without secret is very dangerous. Anyone can simulate Telegram requests.")
} }
i, err := bot.api.GetWebhookInfoWithContext(runCtx) i, err := bot.api.GetWebhookInfoWithContext(runCtx)
@@ -284,7 +285,7 @@ func (bot *Bot[T]) runWebhookRuntime(ctx context.Context, run func(context.Conte
return runErr return runErr
} }
func updateHandler[T any](ctx context.Context, bot *Bot[T], secret string) http.HandlerFunc { func updateHandler[T any](ctx context.Context, bot *Bot[T], secret []byte) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
defer func() { defer func() {
_ = r.Body.Close() _ = r.Body.Close()
@@ -293,7 +294,9 @@ func updateHandler[T any](ctx context.Context, bot *Bot[T], secret string) http.
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)
return return
} }
if secret != "" && r.Header.Get("X-Telegram-Bot-Api-Secret-Token") != secret { provided := []byte(r.Header.Get("X-Telegram-Bot-Api-Secret-Token"))
if len(secret) > 0 && subtle.ConstantTimeCompare(secret, provided) != 1 {
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
return return
} }
@@ -330,7 +333,7 @@ func updateHandler[T any](ctx context.Context, bot *Bot[T], secret string) http.
} }
} }
func statusHandler[T any](bot *Bot[T], opts *BotWebhookOpts) http.HandlerFunc { func statusHandler[T any](bot *Bot[T], secret []byte) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
auth := "" auth := ""
if r.Header.Get("Authorization") != "" { if r.Header.Get("Authorization") != "" {
@@ -338,7 +341,7 @@ func statusHandler[T any](bot *Bot[T], opts *BotWebhookOpts) http.HandlerFunc {
} else if r.Header.Get("X-Telegram-Bot-Api-Secret-Token") != "" { } else if r.Header.Get("X-Telegram-Bot-Api-Secret-Token") != "" {
auth = r.Header.Get("X-Telegram-Bot-Api-Secret-Token") auth = r.Header.Get("X-Telegram-Bot-Api-Secret-Token")
} }
if auth != opts.SecretToken { if len(secret) > 0 && subtle.ConstantTimeCompare(secret, []byte(auth)) != 1 {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
return return
} }
@@ -363,11 +366,12 @@ 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 {
token := []byte(opts.SecretToken)
r := http.NewServeMux() r := http.NewServeMux()
if opts.UseStatusPath { if opts.UseStatusPath {
r.HandleFunc("/status", statusHandler(bot, opts)) r.HandleFunc("/status", statusHandler(bot, token))
} }
r.HandleFunc(opts.Path, updateHandler(ctx, bot, opts.SecretToken)) r.HandleFunc(opts.Path, updateHandler(ctx, bot, token))
return r return r
} }
func (bot *Bot[T]) baseRunWebhook(ctx context.Context, opts *BotWebhookOpts, runFunc func(*http.Server, chan error)) error { func (bot *Bot[T]) baseRunWebhook(ctx context.Context, opts *BotWebhookOpts, runFunc func(*http.Server, chan error)) error {
+5 -5
View File
@@ -46,7 +46,7 @@ func TestUpdateHandlerEnqueuesUpdate(t *testing.T) {
req.Header.Set("X-Telegram-Bot-Api-Secret-Token", "secret") req.Header.Set("X-Telegram-Bot-Api-Secret-Token", "secret")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
updateHandler(context.Background(), bot, "secret").ServeHTTP(rec, req) updateHandler(context.Background(), bot, []byte("secret")).ServeHTTP(rec, req)
if rec.Result().StatusCode != http.StatusOK { if rec.Result().StatusCode != http.StatusOK {
t.Fatalf("unexpected status: got %d want %d", rec.Result().StatusCode, http.StatusOK) t.Fatalf("unexpected status: got %d want %d", rec.Result().StatusCode, http.StatusOK)
@@ -94,7 +94,7 @@ func TestRunWebhookRuntimeExecutesRunners(t *testing.T) {
NewRunner("runner", func(bot *Bot[NoData]) error { NewRunner("runner", func(bot *Bot[NoData]) error {
calls.Add(1) calls.Add(1)
return nil return nil
}).Once(true).Async(false), }).Async(false),
}, },
} }
t.Cleanup(func() { t.Cleanup(func() {
@@ -157,7 +157,7 @@ func TestRunWebhookRuntimeProcessesEnqueuedUpdate(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"update_id":9,"message":{"message_id":1,"date":1,"chat":{"id":1,"type":"private"},"from":{"id":2,"is_bot":false,"first_name":"Test"},"text":"/start"}}`)) req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"update_id":9,"message":{"message_id":1,"date":1,"chat":{"id":1,"type":"private"},"from":{"id":2,"is_bot":false,"first_name":"Test"},"text":"/start"}}`))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
updateHandler(ctx, bot, "").ServeHTTP(rec, req) updateHandler(ctx, bot, []byte("")).ServeHTTP(rec, req)
if rec.Result().StatusCode != http.StatusOK { if rec.Result().StatusCode != http.StatusOK {
t.Fatalf("unexpected status: got %d want %d", rec.Result().StatusCode, http.StatusOK) t.Fatalf("unexpected status: got %d want %d", rec.Result().StatusCode, http.StatusOK)
} }
@@ -267,7 +267,7 @@ func TestUpdateHandlerRejectsOversizedBody(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("a", (256<<10)+1))) req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("a", (256<<10)+1)))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
updateHandler(context.Background(), bot, "").ServeHTTP(rec, req) updateHandler(context.Background(), bot, []byte("")).ServeHTTP(rec, req)
if rec.Result().StatusCode != http.StatusRequestEntityTooLarge { if rec.Result().StatusCode != http.StatusRequestEntityTooLarge {
t.Fatalf("unexpected status: got %d want %d", rec.Result().StatusCode, http.StatusRequestEntityTooLarge) t.Fatalf("unexpected status: got %d want %d", rec.Result().StatusCode, http.StatusRequestEntityTooLarge)
@@ -301,7 +301,7 @@ func TestStatusHandlerRequiresMatchingSecret(t *testing.T) {
_ = bot.webhookLogger.Close() _ = bot.webhookLogger.Close()
}) })
handler := statusHandler(bot, &BotWebhookOpts{SecretToken: "secret"}) handler := statusHandler(bot, []byte("secret"))
tests := []struct { tests := []struct {
name string name string
-4
View File
@@ -21,7 +21,6 @@ var cmdRegexp = regexp.MustCompile("^[_a-z0-9]{1,32}$")
// bot initialization. // bot initialization.
var ErrTooManyCommands = errors.New("too many commands. max 100") var ErrTooManyCommands = errors.New("too many commands. max 100")
// Internal helper to build a BotCommand description with generated usage text.
func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand { func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand {
desc := "" desc := ""
if len(cmd.description) > 0 { if len(cmd.description) > 0 {
@@ -45,10 +44,8 @@ func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand {
return tgapi.BotCommand{Command: cmd.command, Description: usage} return tgapi.BotCommand{Command: cmd.command, Description: usage}
} }
// Internal helper to validate Telegram command names.
func checkCmdRegex(cmd string) bool { return cmdRegexp.MatchString(cmd) } func checkCmdRegex(cmd string) bool { return cmdRegexp.MatchString(cmd) }
// Internal helper to collect non-skipped, valid commands from one plugin.
func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand {
commands := make([]tgapi.BotCommand, 0) commands := make([]tgapi.BotCommand, 0)
names := make([]string, 0, len(pl.commands)) names := make([]string, 0, len(pl.commands))
@@ -70,7 +67,6 @@ func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand {
return commands return commands
} }
// Internal helper to collect all auto-generated commands from registered plugins.
func gatherCommands[T any](bot *Bot[T]) []tgapi.BotCommand { func gatherCommands[T any](bot *Bot[T]) []tgapi.BotCommand {
commands := make([]tgapi.BotCommand, 0) commands := make([]tgapi.BotCommand, 0)
for _, pl := range bot.plugins { for _, pl := range bot.plugins {
-4
View File
@@ -66,9 +66,6 @@ func (c CommandArg) SetValueType(t CommandValueType) CommandArg {
regex = CommandRegexBool regex = CommandRegexBool
case CommandValueString: case CommandValueString:
regex = CommandRegexString regex = CommandRegexString
case CommandValueAny:
default:
regex = nil // Skip validation
} }
c.valueType = t c.valueType = t
c.regex = regex c.regex = regex
@@ -133,7 +130,6 @@ func (c *Command[T]) SkipCommandAutoGen() *Command[T] {
return c return c
} }
// Internal helper that validates provided command arguments.
func (c *Command[T]) validateArgs(args []string) error { func (c *Command[T]) validateArgs(args []string) error {
for i := range c.args.Len() { for i := range c.args.Len() {
if i >= len(args) && c.args.Get(i).required { if i >= len(args) && c.args.Get(i).required {
+2 -5
View File
@@ -49,7 +49,7 @@ type DraftProvider struct {
// NewRandomDraftProvider creates a new DraftProvider using random draft IDs. // NewRandomDraftProvider creates a new DraftProvider using random draft IDs.
// //
// The provider will use cryptographically secure random numbers for draft IDs. // The provider will use random numbers for draft IDs.
// All drafts created via this provider will have unpredictable, unique IDs. // All drafts created via this provider will have unpredictable, unique IDs.
func NewRandomDraftProvider(api *tgapi.API) *DraftProvider { func NewRandomDraftProvider(api *tgapi.API) *DraftProvider {
return &DraftProvider{ return &DraftProvider{
@@ -189,8 +189,7 @@ func (d *Draft) Clear() {
// Delete removes the draft from its provider and clears its content. // Delete removes the draft from its provider and clears its content.
// //
// This is an internal method used by Flush(). You may call it manually if you // You may call it manually if you want to cancel a draft without sending it.
// want to cancel a draft without sending it.
func (d *Draft) Delete() { func (d *Draft) Delete() {
if d.provider != nil { if d.provider != nil {
d.provider.mu.Lock() d.provider.mu.Lock()
@@ -241,8 +240,6 @@ func (d *Draft) Flush() error {
return err return err
} }
// Internal helper for Push that updates the server-side draft.
//
// The candidate Message (current content + new text) is validated before any // The candidate Message (current content + new text) is validated before any
// mutation, so a validation failure leaves the draft unchanged. After the // mutation, so a validation failure leaves the draft unchanged. After the
// validation passes, Message is committed locally regardless of whether the // validation passes, Message is committed locally regardless of whether the
+15
View File
@@ -38,6 +38,8 @@ var (
ErrAPIIsNil = errors.New("api is nil") ErrAPIIsNil = errors.New("api is nil")
// ErrMessageIDZero reports that an operation requires a non-zero message ID. // ErrMessageIDZero reports that an operation requires a non-zero message ID.
ErrMessageIDZero = errors.New("message ID is zero") ErrMessageIDZero = errors.New("message ID is zero")
)
var (
// ErrBindArgsTargetNotPointer reports that BindArgs received a nil or non-pointer destination. // ErrBindArgsTargetNotPointer reports that BindArgs received a nil or non-pointer destination.
ErrBindArgsTargetNotPointer = errors.New("bind args: dst must be a non-nil pointer") ErrBindArgsTargetNotPointer = errors.New("bind args: dst must be a non-nil pointer")
// ErrBindArgsTargetNotStruct reports that BindArgs received a pointer to a non-struct value. // ErrBindArgsTargetNotStruct reports that BindArgs received a pointer to a non-struct value.
@@ -60,6 +62,19 @@ var (
ErrSceneRuntimeNil = errors.New("scene runtime is nil") ErrSceneRuntimeNil = errors.New("scene runtime is nil")
) )
var (
// ErrNilBotWebhookOpts reports that a nil BotWebhookOpts was passed.
ErrNilBotWebhookOpts = errors.New("nil BotWebhookOpts")
// ErrNoBotWebhookOptsURL reports that BotWebhookOpts.URL is empty.
ErrNoBotWebhookOptsURL = errors.New("empty BotWebhookOpts.URL")
// ErrBotWebhookOptsMaxConnectionsRange reports that BotWebhookOpts.MaxConnections is out of range.
ErrBotWebhookOptsMaxConnectionsRange = errors.New("BotWebhookOpts.MaxConnections must be between 1 and 100")
// ErrBotUploaderWhenCertificate reports that a certificate was set without an uploader.
ErrBotUploaderWhenCertificate = errors.New("bot uploader nil, but certificate set")
// ErrStatusPathSecretRequired reports that UseStatusPath requires SecretToken to be set.
ErrStatusPathSecretRequired = errors.New("SecretToken required when UseStatusPath is enabled")
)
func validateMessageText(text string) error { func validateMessageText(text string) error {
length := utf8.RuneCountInString(text) length := utf8.RuneCountInString(text)
switch { switch {
+12
View File
@@ -19,6 +19,18 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r)) bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r))
var err error
var ok bool
if err, ok = r.(error); !ok {
err = fmt.Errorf("%v", r)
}
bot.safeEmitEvent(parentCtx, ErrorEvent{
UpdateID: u.UpdateID,
UpdateType: u.Type,
Err: err,
UserFacing: false,
})
} }
}() }()
startTime := time.Now() startTime := time.Now()
+2 -2
View File
@@ -20,10 +20,10 @@ type recordingObserver struct {
retries []PollingRetryEvent retries []PollingRetryEvent
} }
func (o *recordingObserver) OnReceiveUpdate(_ context.Context, ev UpdateReceivedEvent) { func (o *recordingObserver) OnUpdateReceived(_ context.Context, ev UpdateReceivedEvent) {
o.received = append(o.received, ev) o.received = append(o.received, ev)
} }
func (o *recordingObserver) OnHandledUpdate(_ context.Context, ev UpdateHandledEvent) { func (o *recordingObserver) OnUpdateHandled(_ context.Context, ev UpdateHandledEvent) {
o.handled = append(o.handled, ev) o.handled = append(o.handled, ev)
} }
func (o *recordingObserver) OnHandlerStarted(_ context.Context, ev HandlerStartedEvent) { func (o *recordingObserver) OnHandlerStarted(_ context.Context, ev HandlerStartedEvent) {
+1 -2
View File
@@ -121,7 +121,6 @@ func (b InlineKeyboardButtonBuilder) SetCallbackData(cmd string, args ...any) In
return b return b
} }
// Internal helper that converts the builder state into a Telegram button.
func (b InlineKeyboardButtonBuilder) build() tgapi.InlineKeyboardButton { func (b InlineKeyboardButtonBuilder) build() tgapi.InlineKeyboardButton {
return tgapi.InlineKeyboardButton{ return tgapi.InlineKeyboardButton{
Text: b.text, Text: b.text,
@@ -203,9 +202,9 @@ func (in *InlineKeyboard) SetMaxRow(maxRow int) *InlineKeyboard {
return in return in
} }
// GetMaxRow returns the maximum number of buttons per row.
func (in *InlineKeyboard) GetMaxRow() int { return in.maxRow } func (in *InlineKeyboard) GetMaxRow() int { return in.maxRow }
// Internal helper that appends a button and auto-flushes a full row.
func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeyboard { func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeyboard {
if in.CurrentLine.Len() == in.maxRow { if in.CurrentLine.Len() == in.maxRow {
in.AddLine() in.AddLine()
+10 -6
View File
@@ -22,15 +22,15 @@ import (
// //
// Behavior: // Behavior:
// 1. Uses the bot's current update offset (via GetUpdateOffset) // 1. Uses the bot's current update offset (via GetUpdateOffset)
// 2. Requests updates with 30-second timeout // 2. Requests updates with the timeout configured via PollTimeout
// 3. Filters updates by types specified in bot.GetUpdateTypes() // 3. Filters updates by types specified in bot.GetUpdateTypes()
// 4. Logs raw update JSON if RequestLogger is configured // 4. Logs raw update JSON if RequestLogger is configured
// 5. Automatically updates the offset to the last received update ID + 1 // 5. Automatically updates the offset to the last received update ID + 1
// 6. Returns all received updates (empty slice if none) // 6. Returns all received updates (empty slice if none)
// //
// Note: This is a blocking call that waits up to 30 seconds for new updates, // Note: This is a blocking call that waits up to the configured PollTimeout
// unless ctx is canceled earlier. For non-blocking behavior, consider using // for new updates, unless ctx is canceled earlier. For non-blocking behavior,
// webhooks instead. // consider using webhooks instead.
// //
// Example: // Example:
// //
@@ -50,9 +50,10 @@ func (bot *Bot[T]) Updates(ctx context.Context) ([]tgapi.Update, error) {
AllowedUpdates: bot.GetUpdateTypes(), AllowedUpdates: bot.GetUpdateTypes(),
} }
zero := make([]tgapi.Update, 0)
updates, err := bot.api.GetUpdatesWithContext(ctx, params) updates, err := bot.api.GetUpdatesWithContext(ctx, params)
if err != nil { if err != nil {
return nil, err return zero, err
} }
if bot.requestLogger != nil { if bot.requestLogger != nil {
@@ -67,7 +68,10 @@ func (bot *Bot[T]) Updates(ctx context.Context) ([]tgapi.Update, error) {
if len(updates) > 0 { if len(updates) > 0 {
bot.SetUpdateOffset(updates[len(updates)-1].UpdateID + 1) bot.SetUpdateOffset(updates[len(updates)-1].UpdateID + 1)
} }
return updates, err if updates == nil {
return zero, nil
}
return updates, nil
} }
// UpdatesIter fetches updates once and yields each update in order. // UpdatesIter fetches updates once and yields each update in order.
+1 -9
View File
@@ -95,7 +95,6 @@ type AnswerMessage struct {
ctx *MessageContext // internal back-reference ctx *MessageContext // internal back-reference
} }
// Internal helper for text edits with optional keyboard and parse mode.
func (ctx *MessageContext) edit(messageID int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { func (ctx *MessageContext) edit(messageID int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if err := validateMessageText(text); err != nil { if err := validateMessageText(text); err != nil {
ctx.Logger.Errorln(err) ctx.Logger.Errorln(err)
@@ -146,7 +145,6 @@ func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage {
return m.ctx.edit(m.MessageID, text, nil, tgapi.ParseMarkdownV2) return m.ctx.edit(m.MessageID, text, nil, tgapi.ParseMarkdownV2)
} }
// Internal helper for editing callback-linked messages.
func (ctx *MessageContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { func (ctx *MessageContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if ctx.CallbackMsgID == 0 && ctx.InlineMsgID == "" { if ctx.CallbackMsgID == 0 && ctx.InlineMsgID == "" {
ctx.Logger.Errorln(ErrCallbackMessageMissing) ctx.Logger.Errorln(ErrCallbackMessageMissing)
@@ -179,7 +177,6 @@ func (ctx *MessageContext) EditCallbackfMarkdown(format string, keyboard *Inline
return ctx.editCallback(fmt.Sprintf(format, args...), keyboard, tgapi.ParseMarkdownV2) return ctx.editCallback(fmt.Sprintf(format, args...), keyboard, tgapi.ParseMarkdownV2)
} }
// Internal helper for media-caption edits.
func (ctx *MessageContext) editPhotoText(messageID int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { func (ctx *MessageContext) editPhotoText(messageID int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if err := validateCaptionText(text); err != nil { if err := validateCaptionText(text); err != nil {
ctx.Logger.Errorln(err) ctx.Logger.Errorln(err)
@@ -241,7 +238,6 @@ func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeybo
return m.ctx.editPhotoText(m.MessageID, text, kb, tgapi.ParseMarkdownV2) return m.ctx.editPhotoText(m.MessageID, text, kb, tgapi.ParseMarkdownV2)
} }
// Internal helper for message replies with optional keyboard and parse mode.
func (ctx *MessageContext) answer(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { func (ctx *MessageContext) answer(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if ctx.Msg == nil { if ctx.Msg == nil {
ctx.Logger.Errorln(ErrMessageContextNil) ctx.Logger.Errorln(ErrMessageContextNil)
@@ -371,7 +367,6 @@ func (ctx *MessageContext) answerLong(text string, keyboard *InlineKeyboard, par
return messages return messages
} }
// Internal helper for photo replies with optional caption and keyboard.
func (ctx *MessageContext) answerPhoto(photoID, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { func (ctx *MessageContext) answerPhoto(photoID, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if ctx.Msg == nil { if ctx.Msg == nil {
ctx.Logger.Errorln(ErrMessageContextNil) ctx.Logger.Errorln(ErrMessageContextNil)
@@ -443,7 +438,6 @@ func (ctx *MessageContext) AnswerPhotofMarkdown(photoID, template string, args .
return ctx.answerPhoto(photoID, fmt.Sprintf(template, args...), nil, tgapi.ParseMarkdownV2) return ctx.answerPhoto(photoID, fmt.Sprintf(template, args...), nil, tgapi.ParseMarkdownV2)
} }
// Internal helper that deletes a message by ID.
func (ctx *MessageContext) delete(messageID int) { func (ctx *MessageContext) delete(messageID int) {
if messageID == 0 { if messageID == 0 {
ctx.Logger.Errorln(ErrMessageIDZero) ctx.Logger.Errorln(ErrMessageIDZero)
@@ -474,7 +468,6 @@ func (ctx *MessageContext) CallbackDelete() {
ctx.delete(ctx.CallbackMsgID) ctx.delete(ctx.CallbackMsgID)
} }
// Internal helper that answers a callback query with optional text, alert, or URL.
func (ctx *MessageContext) answerCallbackQuery(url, text string, showAlert bool) { func (ctx *MessageContext) answerCallbackQuery(url, text string, showAlert bool) {
if len(ctx.CallbackQueryID) == 0 { if len(ctx.CallbackQueryID) == 0 {
return return
@@ -518,7 +511,6 @@ func (ctx *MessageContext) SendAction(action tgapi.ChatActionType) {
} }
} }
// Internal helper that formats, sends, and logs an error.
func (ctx *MessageContext) error(err error) { func (ctx *MessageContext) error(err error) {
if err == nil { if err == nil {
return return
@@ -536,7 +528,7 @@ func (ctx *MessageContext) error(err error) {
} }
} }
// Error is an alias for error(). // Error routes err through the centralized handler error path…
func (ctx *MessageContext) Error(err error) { ctx.error(err) } func (ctx *MessageContext) Error(err error) { ctx.error(err) }
func (ctx *MessageContext) newDraft(parseMode tgapi.ParseMode) *Draft { func (ctx *MessageContext) newDraft(parseMode tgapi.ParseMode) *Draft {
+4 -3
View File
@@ -25,15 +25,16 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MessageContext) bool
cmd = cmd[:len(cmd)-len("@"+botUsername)] // remove @botname cmd = cmd[:len(cmd)-len("@"+botUsername)] // remove @botname
} }
} }
// Ищем команду по точному совпадению
for _, plugin := range bot.plugins { for _, plugin := range bot.plugins {
if _, exists := plugin.commands[cmd]; exists { if _, exists := plugin.commands[cmd]; exists {
ctx.Text = args ctx.Text = args
ctx.Args = strings.Fields(args) ctx.Args = strings.Fields(args)
if plugin.logger != nil {
ctx.Logger = plugin.logger ctx.Logger = plugin.logger
if ctx.Logger == nil {
ctx.Logger = bot.logger
} }
if !plugin.executeMiddlewares(ctx, bot.appData) { if !plugin.executeMiddlewares(ctx, bot.appData) {
return false return false
+4 -4
View File
@@ -145,8 +145,8 @@ func (ErrorEvent) isEvent() {}
// Observer receives best-effort runtime instrumentation events. // Observer receives best-effort runtime instrumentation events.
type Observer interface { type Observer interface {
OnReceiveUpdate(ctx context.Context, event UpdateReceivedEvent) OnUpdateReceived(ctx context.Context, event UpdateReceivedEvent)
OnHandledUpdate(ctx context.Context, event UpdateHandledEvent) OnUpdateHandled(ctx context.Context, event UpdateHandledEvent)
OnHandlerStarted(ctx context.Context, event HandlerStartedEvent) OnHandlerStarted(ctx context.Context, event HandlerStartedEvent)
OnHandlerFinished(ctx context.Context, event HandlerFinishedEvent) OnHandlerFinished(ctx context.Context, event HandlerFinishedEvent)
OnSceneTransition(ctx context.Context, event SceneTransitionEvent) OnSceneTransition(ctx context.Context, event SceneTransitionEvent)
@@ -167,9 +167,9 @@ func (bot *Bot[T]) safeEmitEvent(ctx context.Context, event Event) {
}() }()
switch e := event.(type) { switch e := event.(type) {
case UpdateReceivedEvent: case UpdateReceivedEvent:
bot.observer.OnReceiveUpdate(ctx, e) bot.observer.OnUpdateReceived(ctx, e)
case UpdateHandledEvent: case UpdateHandledEvent:
bot.observer.OnHandledUpdate(ctx, e) bot.observer.OnUpdateHandled(ctx, e)
case HandlerStartedEvent: case HandlerStartedEvent:
bot.observer.OnHandlerStarted(ctx, e) bot.observer.OnHandlerStarted(ctx, e)
case HandlerFinishedEvent: case HandlerFinishedEvent:
+5 -9
View File
@@ -46,7 +46,6 @@ func NewPlugin[T AppData](name string) *Plugin[T] {
} }
// AddCommand registers a command in the plugin. // AddCommand registers a command in the plugin.
// The command's .command field is used as the key.
func (p *Plugin[T]) AddCommand(command *Command[T]) *Plugin[T] { func (p *Plugin[T]) AddCommand(command *Command[T]) *Plugin[T] {
if command == nil { if command == nil {
if p.logger != nil { if p.logger != nil {
@@ -55,7 +54,7 @@ func (p *Plugin[T]) AddCommand(command *Command[T]) *Plugin[T] {
return p return p
} }
if _, exists := p.commands[command.command]; exists && p.logger != nil { if _, exists := p.commands[command.command]; exists && p.logger != nil {
p.logger.Warnf("command '%s' is already registered in plugin '%s'; overwriting", command.command, p.name) p.logger.Warnf("command '%s' already registered in plugin '%s'; overwriting", command.command, p.name)
} }
p.commands[command.command] = command p.commands[command.command] = command
return p return p
@@ -106,12 +105,12 @@ func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] {
if scene == nil { if scene == nil {
return p return p
} }
scene.PluginName = p.name scene.pluginName = p.name
scene.setPluginName(p.name) scene.setPluginName(p.name)
if _, exists := p.scenes[scene.Name]; exists && p.logger != nil { if _, exists := p.scenes[scene.name]; exists && p.logger != nil {
p.logger.Warnf("scene '%s' is already registered in plugin '%s'; overwriting", scene.Name, p.name) p.logger.Warnf("scene '%s' is already registered in plugin '%s'; overwriting", scene.name, p.name)
} }
p.scenes[scene.Name] = scene p.scenes[scene.name] = scene
return p return p
} }
@@ -238,7 +237,6 @@ func (p *Plugin[T]) Close() error {
return errors.Join(e...) return errors.Join(e...)
} }
// Internal helper that validates and executes a command handler.
func (p *Plugin[T]) executeCmd(cmd string, ctx *MessageContext, db T) error { func (p *Plugin[T]) executeCmd(cmd string, ctx *MessageContext, db T) error {
command, exists := p.commands[cmd] command, exists := p.commands[cmd]
if !exists { if !exists {
@@ -260,7 +258,6 @@ func (p *Plugin[T]) executeCmd(cmd string, ctx *MessageContext, db T) error {
return command.exec(ctx, db) return command.exec(ctx, db)
} }
// Internal helper that validates and executes a payload handler.
func (p *Plugin[T]) executePayload(payload string, ctx *MessageContext, db T) error { func (p *Plugin[T]) executePayload(payload string, ctx *MessageContext, db T) error {
command, exists := p.payloads[payload] command, exists := p.payloads[payload]
if !exists { if !exists {
@@ -282,7 +279,6 @@ func (p *Plugin[T]) executePayload(payload string, ctx *MessageContext, db T) er
return command.exec(ctx, db) return command.exec(ctx, db)
} }
// Internal helper that runs plugin middlewares in order.
func (p *Plugin[T]) executeMiddlewares(ctx *MessageContext, db T) bool { func (p *Plugin[T]) executeMiddlewares(ctx *MessageContext, db T) bool {
for _, m := range p.middlewares { for _, m := range p.middlewares {
if !m.Execute(ctx, db) { if !m.Execute(ctx, db) {
+11 -20
View File
@@ -16,20 +16,19 @@ type RunnerFn[T AppData] func(*Bot[T]) error
// Once Execute() is called, the Runner should not be modified. // Once Execute() is called, the Runner should not be modified.
// //
// Execution semantics: // Execution semantics:
// - once=true, async=false: Run once synchronously (blocks). // - every=0, async=false: Run once synchronously (blocks).
// - once=true, async=true: Run once in a goroutine (non-blocking). // - every=0, async=true: Run once in a goroutine (non-blocking).
// - once=false, async=true: Run repeatedly in a goroutine with timeout. // - every>0, async=true: Run repeatedly in a goroutine with timeout.
// - once=false, async=false: Invalid configuration — ignored with warning. // - every>0, async=false: Invalid configuration — ignored with warning.
type Runner[T AppData] struct { type Runner[T AppData] struct {
name string // Human-readable name for logging name string // Human-readable name for logging
once bool // If true, runs once; if false, runs periodically
async bool // If true, runs in a goroutine; else, runs synchronously async bool // If true, runs in a goroutine; else, runs synchronously
every time.Duration // Duration to wait between periodic executions (ignored if once=true) every time.Duration // Duration to wait between periodic executions (ignored if once=true)
fn RunnerFn[T] // The function to execute fn RunnerFn[T] // The function to execute
} }
// NewRunner creates a new Runner with the given name and function. // NewRunner creates a new Runner with the given name and function.
// By default, the Runner is configured as async=true (non-blocking). // By default, the Runner is configured as async=true (non-blocking), once=true/mo
// //
// Builder methods (Once, Async, Every) can be chained to customize behavior. // Builder methods (Once, Async, Every) can be chained to customize behavior.
// DO NOT call builder methods concurrently or after Execute(). // DO NOT call builder methods concurrently or after Execute().
@@ -38,18 +37,10 @@ func NewRunner[T AppData](name string, fn RunnerFn[T]) Runner[T] {
name: name, name: name,
fn: fn, fn: fn,
async: true, // Default: run asynchronously async: true, // Default: run asynchronously
every: 0, // Default: no timeout (ignored if once=true) every: 0, // Default: 0 - one time
} }
} }
// Once sets whether the runner executes once or repeatedly.
// If true, the runner runs only once.
// If false, the runner runs in a loop with the configured timeout.
func (r Runner[T]) Once(once bool) Runner[T] {
r.once = once
return r
}
// Async sets whether the runner executes synchronously or asynchronously. // Async sets whether the runner executes synchronously or asynchronously.
// If true, the runner runs in a goroutine (non-blocking). // If true, the runner runs in a goroutine (non-blocking).
// If false, the runner blocks the caller during execution. // If false, the runner blocks the caller during execution.
@@ -94,16 +85,16 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) {
bot.logger.Infoln("Executing runners...") bot.logger.Infoln("Executing runners...")
for _, runner := range bot.runners { for _, runner := range bot.runners {
// Validate configuration // Validate configuration
if !runner.once && !runner.async { if runner.every > 0 && !runner.async {
bot.logger.Warnf("Runner %s not once, but sync — skipping\n", runner.name) bot.logger.Warnf("Runner %s not once, but sync — skipping\n", runner.name)
continue continue
} }
if !runner.once && runner.async && runner.every == 0 { if runner.every > 0 && runner.async && runner.every == 0 {
bot.logger.Warnf("Background runner \"%s\" has no timeout — skipping\n", runner.name) bot.logger.Warnf("Background runner \"%s\" has no timeout — skipping\n", runner.name)
continue continue
} }
if runner.once && runner.async { if runner.every == 0 && runner.async {
// One-time async: fire and forget // One-time async: fire and forget
bot.runnerOnceWG.Add(1) bot.runnerOnceWG.Add(1)
go func(r Runner[T]) { go func(r Runner[T]) {
@@ -126,7 +117,7 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) {
bot.logger.Warnf("Runner %s failed: %s\n", r.name, err) bot.logger.Warnf("Runner %s failed: %s\n", r.name, err)
} }
}(runner) }(runner)
} else if runner.once && !runner.async { } else if runner.every == 0 && !runner.async {
// One-time sync: block until done // One-time sync: block until done
t := time.Now() t := time.Now()
err := runner.fn(bot) err := runner.fn(bot)
@@ -149,7 +140,7 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) {
if elapsed > time.Second*2 { if elapsed > time.Second*2 {
bot.logger.Warnf("Runner %s too slow. Elapsed time %v >= 2s\n", runner.name, elapsed) bot.logger.Warnf("Runner %s too slow. Elapsed time %v >= 2s\n", runner.name, elapsed)
} }
} else if !runner.once && runner.async { } else if runner.every > 0 && runner.async {
// Background loop: periodic execution with graceful shutdown // Background loop: periodic execution with graceful shutdown
bot.runnerBgWG.Add(1) bot.runnerBgWG.Add(1)
go func(r Runner[T]) { go func(r Runner[T]) {
+2 -2
View File
@@ -22,7 +22,7 @@ func TestExecRunnersRunsOnceSyncRunner(t *testing.T) {
NewRunner("sync-once", func(*Bot[NoData]) error { NewRunner("sync-once", func(*Bot[NoData]) error {
calls.Add(1) calls.Add(1)
return nil return nil
}).Once(true).Async(false), }).Async(false),
}, },
} }
@@ -76,7 +76,7 @@ func TestExecRunnersEmitObserverEvents(t *testing.T) {
runners: []Runner[NoData]{ runners: []Runner[NoData]{
NewRunner("sync-once", func(*Bot[NoData]) error { NewRunner("sync-once", func(*Bot[NoData]) error {
return wantErr return wantErr
}).Once(true).Async(false), }).Async(false),
}, },
} }
+19 -23
View File
@@ -11,14 +11,10 @@ type SceneHandler[T any] func(ctx *SceneContext, db T) (SceneResult, error)
// Scene defines a multi-step conversational flow. // Scene defines a multi-step conversational flow.
type Scene[T any] struct { type Scene[T any] struct {
// Name identifies the scene in plugin registration and session state. name string
Name string scope SceneScope
// Scope controls how active scene sessions are keyed and shared. entry string
Scope SceneScope pluginName string
// Entry names the first step used by MessageContext.EnterScene.
Entry string
// PluginName stores the owning plugin name for scene resolution.
PluginName string
steps map[string]SceneHandler[T] steps map[string]SceneHandler[T]
commands map[string]SceneHandler[T] commands map[string]SceneHandler[T]
@@ -29,9 +25,9 @@ type Scene[T any] struct {
// NewScene creates a new scene with user-chat scope by default. // NewScene creates a new scene with user-chat scope by default.
func NewScene[T any](name string) *Scene[T] { func NewScene[T any](name string) *Scene[T] {
return &Scene[T]{ return &Scene[T]{
Name: name, name: name,
Scope: SceneScopeUserChat, scope: SceneScopeUserChat,
Entry: "", entry: "",
steps: make(map[string]SceneHandler[T]), steps: make(map[string]SceneHandler[T]),
commands: make(map[string]SceneHandler[T]), commands: make(map[string]SceneHandler[T]),
payloads: make(map[string]SceneHandler[T]), payloads: make(map[string]SceneHandler[T]),
@@ -41,18 +37,18 @@ func NewScene[T any](name string) *Scene[T] {
// SetScope changes how scene sessions are keyed and shared. // SetScope changes how scene sessions are keyed and shared.
func (s *Scene[T]) SetScope(scope SceneScope) *Scene[T] { func (s *Scene[T]) SetScope(scope SceneScope) *Scene[T] {
s.Scope = scope s.scope = scope
return s return s
} }
// SetEntry sets the initial step entered by MessageContext.EnterScene. // SetEntry sets the initial step entered by MessageContext.EnterScene.
func (s *Scene[T]) SetEntry(step string) *Scene[T] { func (s *Scene[T]) SetEntry(step string) *Scene[T] {
s.Entry = step s.entry = step
return s return s
} }
func (s *Scene[T]) setPluginName(name string) *Scene[T] { func (s *Scene[T]) setPluginName(name string) *Scene[T] {
s.PluginName = name s.pluginName = name
return s return s
} }
@@ -135,36 +131,36 @@ type SceneSession struct {
Scene string Scene string
// Step is the current step name inside the active scene. // Step is the current step name inside the active scene.
Step string Step string
// Data stores opaque session payload bytes, typically JSON. // data stores opaque session payload bytes, typically JSON.
Data []byte data []byte
} }
// SetData stores arbitrary opaque session data. // SetData stores arbitrary opaque session data.
func (s *SceneSession) SetData(data []byte) { func (s *SceneSession) SetData(data []byte) {
s.Data = data s.data = data
} }
// GetData returns the raw session data payload. // GetData returns the raw session data payload.
func (s *SceneSession) GetData() []byte { func (s *SceneSession) GetData() []byte {
return s.Data return s.data
} }
// HasData reports whether the session has a non-empty data payload. // HasData reports whether the session has a non-empty data payload.
func (s *SceneSession) HasData() bool { func (s *SceneSession) HasData() bool {
return len(s.Data) > 0 return len(s.data) > 0
} }
// ClearData removes any stored session data. // ClearData removes any stored session data.
func (s *SceneSession) ClearData() { func (s *SceneSession) ClearData() {
s.Data = nil s.data = nil
} }
// BindData unmarshals the stored JSON payload into v. // BindData unmarshals the stored JSON payload into v.
func (s *SceneSession) BindData(v any) error { func (s *SceneSession) BindData(v any) error {
if len(s.Data) == 0 { if len(s.data) == 0 {
return nil return nil
} }
return json.Unmarshal(s.Data, v) return json.Unmarshal(s.data, v)
} }
// SaveData marshals v as JSON and stores it in the session. // SaveData marshals v as JSON and stores it in the session.
@@ -173,7 +169,7 @@ func (s *SceneSession) SaveData(v any) error {
if err != nil { if err != nil {
return err return err
} }
s.Data = data s.data = data
return nil return nil
} }
+6 -6
View File
@@ -24,7 +24,7 @@ func (bot *Bot[T]) tryHandleScene(ctx *MessageContext) (bool, error) {
if !ok { if !ok {
continue continue
} }
if scene.PluginName != "" && scene.PluginName != plugin.name { if scene.pluginName != "" && scene.pluginName != plugin.name {
continue continue
} }
if !plugin.executeMiddlewares(ctx, bot.appData) { if !plugin.executeMiddlewares(ctx, bot.appData) {
@@ -176,7 +176,7 @@ func (bot *Bot[T]) emitSceneStarted(ctx *SceneContext, scene *Scene[T], kind Han
bot.safeEmitEvent(ctx.Context(), HandlerStartedEvent{ bot.safeEmitEvent(ctx.Context(), HandlerStartedEvent{
UpdateID: ctx.Update.UpdateID, UpdateID: ctx.Update.UpdateID,
UpdateType: ctx.Update.Type, UpdateType: ctx.Update.Type,
Plugin: scene.PluginName, Plugin: scene.pluginName,
HandlerKind: kind, HandlerKind: kind,
HandlerName: name, HandlerName: name,
FromID: ctx.FromID, FromID: ctx.FromID,
@@ -188,7 +188,7 @@ func (bot *Bot[T]) emitSceneFinished(ctx *SceneContext, scene *Scene[T], kind Ha
bot.safeEmitEvent(ctx.Context(), HandlerFinishedEvent{ bot.safeEmitEvent(ctx.Context(), HandlerFinishedEvent{
UpdateID: ctx.Update.UpdateID, UpdateID: ctx.Update.UpdateID,
UpdateType: ctx.Update.Type, UpdateType: ctx.Update.Type,
Plugin: scene.PluginName, Plugin: scene.pluginName,
HandlerKind: kind, HandlerKind: kind,
HandlerName: name, HandlerName: name,
FromID: ctx.FromID, FromID: ctx.FromID,
@@ -203,7 +203,7 @@ func (bot *Bot[T]) emitSceneError(ctx *SceneContext, scene *Scene[T], kind Handl
bot.safeEmitEvent(ctx.Context(), ErrorEvent{ bot.safeEmitEvent(ctx.Context(), ErrorEvent{
UpdateID: ctx.Update.UpdateID, UpdateID: ctx.Update.UpdateID,
UpdateType: ctx.Update.Type, UpdateType: ctx.Update.Type,
Plugin: scene.PluginName, Plugin: scene.pluginName,
HandlerKind: kind, HandlerKind: kind,
HandlerName: name, HandlerName: name,
FromID: ctx.FromID, FromID: ctx.FromID,
@@ -229,8 +229,8 @@ func (bot *Bot[T]) emitSceneTransition(ctx *SceneContext, scene *Scene[T], from
} }
bot.safeEmitEvent(ctx.Context(), SceneTransitionEvent{ bot.safeEmitEvent(ctx.Context(), SceneTransitionEvent{
Plugin: scene.PluginName, Plugin: scene.pluginName,
Scene: scene.Name, Scene: scene.name,
From: from, From: from,
To: to, To: to,
Action: result.Action, Action: result.Action,
+3 -3
View File
@@ -36,8 +36,8 @@ func TestPluginAddSceneRegistersScene(t *testing.T) {
if got, ok := plugin.scenes["signup"]; !ok || got != scene { if got, ok := plugin.scenes["signup"]; !ok || got != scene {
t.Fatalf("scene was not registered in plugin: ok=%v got=%p want=%p", ok, got, scene) t.Fatalf("scene was not registered in plugin: ok=%v got=%p want=%p", ok, got, scene)
} }
if scene.PluginName != "wizard" { if scene.pluginName != "wizard" {
t.Fatalf("unexpected plugin name on scene: got %q want %q", scene.PluginName, "wizard") t.Fatalf("unexpected plugin name on scene: got %q want %q", scene.pluginName, "wizard")
} }
} }
@@ -401,7 +401,7 @@ func TestSceneMessageObserverEmitsLifecycleEvents(t *testing.T) {
if !ok { if !ok {
t.Fatal("expected scene key to be built") t.Fatal("expected scene key to be built")
} }
if err := bot.sessionStore.Set(key, SceneSession{Scene: scene.Name}); err != nil { if err := bot.sessionStore.Set(key, SceneSession{Scene: scene.name}); err != nil {
t.Fatalf("failed to seed scene session: %v", err) t.Fatalf("failed to seed scene session: %v", err)
} }
-2
View File
@@ -328,13 +328,11 @@ func (r TelegramRequest[R, P]) Do(api *API) (R, error) {
return r.DoWithContext(context.Background(), api) return r.DoWithContext(context.Background(), api)
} }
// Internal helper that reads and caps a Telegram response body.
func readBody(body io.ReadCloser) ([]byte, error) { func readBody(body io.ReadCloser) ([]byte, error) {
reader := io.LimitReader(body, 10<<20) // 10 MB reader := io.LimitReader(body, 10<<20) // 10 MB
return io.ReadAll(reader) return io.ReadAll(reader)
} }
// Internal helper that parses a typed Telegram API response body.
func parseBody[R any](data []byte) (TelegramResponse[R], error) { func parseBody[R any](data []byte) (TelegramResponse[R], error) {
var resp TelegramResponse[R] var resp TelegramResponse[R]
err := json.Unmarshal(data, &resp) err := json.Unmarshal(data, &resp)
+1
View File
@@ -58,6 +58,7 @@ const (
// UpdateTypeRemovedChatBoost is a removed chat boost update. // UpdateTypeRemovedChatBoost is a removed chat boost update.
UpdateTypeRemovedChatBoost UpdateType = "removed_chat_boost" UpdateTypeRemovedChatBoost UpdateType = "removed_chat_boost"
// UpdateTypeManagedBot is a managed bot update.
UpdateTypeManagedBot UpdateType = "managed_bot" UpdateTypeManagedBot UpdateType = "managed_bot"
// UpdateTypeGuestMessage is a guest message update. // UpdateTypeGuestMessage is a guest message update.
+6 -4
View File
@@ -177,10 +177,14 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R,
case <-ctx.Done(): case <-ctx.Done():
return zero, ctx.Err() return zero, ctx.Err()
case <-time.After(time.Duration(after) * time.Second): case <-time.After(time.Duration(after) * time.Second):
continue // Повторяем запрос continue
} }
} }
return zero, fmt.Errorf("[%d] %s", response.ErrorCode, response.Description) return zero, &ResponseError{
Code: response.ErrorCode,
Description: response.Description,
Parameters: response.Parameters,
}
} }
return response.Result, nil return response.Result, nil
} }
@@ -218,7 +222,6 @@ func (r UploaderRequest[R, P]) Do(up *Uploader) (R, error) {
return r.DoWithContext(context.Background(), up) return r.DoWithContext(context.Background(), up)
} }
// Internal helper that builds a finalized multipart body from files and params.
func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, string, error) { func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, string, error) {
buf := bytes.NewBuffer(nil) buf := bytes.NewBuffer(nil)
w := multipart.NewWriter(buf) w := multipart.NewWriter(buf)
@@ -251,7 +254,6 @@ func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, str
return buf, w.FormDataContentType(), nil return buf, w.FormDataContentType(), nil
} }
// Internal helper that infers an upload field name from a file extension.
func uploaderTypeByExt(filename string) UploaderFileType { func uploaderTypeByExt(filename string) UploaderFileType {
ext := strings.ToLower(filepath.Ext(filename)) ext := strings.ToLower(filepath.Ext(filename))
switch ext { switch ext {
-4
View File
@@ -134,7 +134,6 @@ func (rl *RateLimiter) Wait(ctx context.Context, chatID int64) error {
return chatLimiter.Wait(ctx) return chatLimiter.Wait(ctx)
} }
// Internal helper that returns the global limiter under read lock.
func (rl *RateLimiter) getGlobalLimiter() *rate.Limiter { func (rl *RateLimiter) getGlobalLimiter() *rate.Limiter {
rl.globalMu.RLock() rl.globalMu.RLock()
defer rl.globalMu.RUnlock() defer rl.globalMu.RUnlock()
@@ -222,7 +221,6 @@ func (rl *RateLimiter) Check(ctx context.Context, dropOverflow bool, chatID int6
return nil return nil
} }
// Internal helper that waits for the global cooldown to expire.
func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error { func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error {
rl.globalMu.RLock() rl.globalMu.RLock()
until := rl.globalLockUntil until := rl.globalLockUntil
@@ -240,7 +238,6 @@ func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error {
} }
} }
// Internal helper that waits for a chat-specific cooldown to expire.
func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) error { func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) error {
rl.chatMu.RLock() rl.chatMu.RLock()
until, ok := rl.chatLocks[chatID] until, ok := rl.chatLocks[chatID]
@@ -258,7 +255,6 @@ func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) erro
} }
} }
// Internal helper that returns or creates a per-chat limiter.
// Updates chatLastSeen so Cleanup can evict idle entries. // Updates chatLastSeen so Cleanup can evict idle entries.
func (rl *RateLimiter) getChatLimiter(chatID int64) *rate.Limiter { func (rl *RateLimiter) getChatLimiter(chatID int64) *rate.Limiter {
now := time.Now() now := time.Now()