(fix): webhook secret constant-time compare
(fix): observer rename, panic emits ErrorEvent (refactor): Runner.Once removed, Scene.PluginName unexported (doc): godoc hygiene pre-1.0.0
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user