From 8a3f2cedf24b1dea6520ee384f8cb44e87b720d9 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Wed, 20 May 2026 12:30:44 +0300 Subject: [PATCH] =?UTF-8?q?(new):=20webhook=20+=20payload=20error=20sentin?= =?UTF-8?q?els=20=20=20(fix):=20polling=20panic=20ErrorEvent,=20nil-logger?= =?UTF-8?q?=20guard=20=20=20(refactor):=20inline=20webhook=20errors=20?= =?UTF-8?q?=E2=86=92=20sentinels,=20dead=20Runner=20branch=20=20=20(doc):?= =?UTF-8?q?=20Runner=20godoc,=20Error=20godoc,=20drafts=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.go | 11 ++++++++ bot_webhook.go | 16 +++++------ drafts.go | 2 -- errors.go | 16 +++++++++++ handler.go | 15 +++++++---- msg_context.go | 9 +++++-- plugins.go | 3 +-- runners.go | 62 +++++++++++++++++-------------------------- tgapi/uploader_api.go | 6 ++--- 9 files changed, 81 insertions(+), 59 deletions(-) diff --git a/bot.go b/bot.go index 75dc0c4..31f2177 100644 --- a/bot.go +++ b/bot.go @@ -449,6 +449,17 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error { defer func() { if r := recover(); r != nil { bot.logger.Errorln(fmt.Sprintf("panic in update polling: %v", r)) + err, ok := r.(error) + if !ok { + err = fmt.Errorf("%v", r) + } + bot.safeEmitEvent(ctx, ErrorEvent{ + Plugin: "bot", + HandlerKind: HandlerPollingKind, + HandlerName: "getUpdates", + Err: err, + UserFacing: false, + }) } close(bot.updateQueue) }() diff --git a/bot_webhook.go b/bot_webhook.go index 5e0281f..74231ec 100644 --- a/bot_webhook.go +++ b/bot_webhook.go @@ -202,7 +202,7 @@ func (bot *Bot[T]) RunWebhookWithContext(ctx context.Context, opts *BotWebhookOp return err } if !ok { - return errors.New("failed to set webhook") + return ErrSetWebhookFailed } if len(tlsFiles) == 2 { @@ -227,7 +227,7 @@ func (bot *Bot[T]) RunWebhook(opts *BotWebhookOpts, tlsFiles ...string) error { func (bot *Bot[T]) CloseWebhook() error { var e []error if bot.api == nil { - e = append(e, errors.New("bot api nil")) + e = append(e, ErrBotAPINil) } else { if _, err := bot.api.DeleteWebhook(tgapi.DeleteWebhook{}); err != nil { if bot.webhookLogger != nil { @@ -423,16 +423,16 @@ func (bot *Bot[T]) runWebhookTLS(ctx context.Context, opts *BotWebhookOpts, key, } func validateWebhookPath(path string, useStatusPath bool) error { if path == "" { - return errors.New("empty BotWebhookOpts.Path") + return ErrBotWebhookOptsEmptyPath } if !strings.HasPrefix(path, "/") { - return errors.New("BotWebhookOpts.Path must start with '/'") + return ErrBotWebhookOptsPathNoSlash } if strings.Contains(path, "?") || strings.Contains(path, "#") { - return errors.New("BotWebhookOpts.Path must not contain query or fragment") + return ErrBotWebhookOptsPathHasQueryOrFragment } if useStatusPath && path == "/status" { - return errors.New("BotWebhookOpts.Path must not be '/status' when status path is enabled") + return ErrBotWebhookOptsPathCollidesStatus } return nil } @@ -442,8 +442,8 @@ func validateWebhookTLSFiles(tlsFiles []string) error { case 0, 2: return nil case 1: - return errors.New("you must specify both private and public keys") + return ErrBotWebhookTLSFilesIncomplete default: - return errors.New("too many files; you must specify only private and public keys") + return ErrBotWebhookTLSFilesTooMany } } diff --git a/drafts.go b/drafts.go index d0a9638..af1fe57 100644 --- a/drafts.go +++ b/drafts.go @@ -8,9 +8,7 @@ import ( "git.scuroneko.dev/scuroneko/laniakea/tgapi" ) -// Interface for generating unique draft IDs. type draftIDGenerator interface { - // Next returns the next unique draft ID. Next() uint64 } diff --git a/errors.go b/errors.go index c214aa1..8077c7f 100644 --- a/errors.go +++ b/errors.go @@ -73,6 +73,22 @@ var ( 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") + // ErrSetWebhookFailed reports that Telegram rejected the setWebhook request. + ErrSetWebhookFailed = errors.New("failed to set webhook") + // ErrBotAPINil reports that an operation requires an API client but none is set. + ErrBotAPINil = errors.New("bot api is nil") + // ErrBotWebhookOptsEmptyPath reports that BotWebhookOpts.Path is empty. + ErrBotWebhookOptsEmptyPath = errors.New("empty BotWebhookOpts.Path") + // ErrBotWebhookOptsPathNoSlash reports that BotWebhookOpts.Path does not start with '/'. + ErrBotWebhookOptsPathNoSlash = errors.New("BotWebhookOpts.Path must start with '/'") + // ErrBotWebhookOptsPathHasQueryOrFragment reports that BotWebhookOpts.Path contains a query or fragment. + ErrBotWebhookOptsPathHasQueryOrFragment = errors.New("BotWebhookOpts.Path must not contain query or fragment") + // ErrBotWebhookOptsPathCollidesStatus reports that BotWebhookOpts.Path collides with the reserved /status endpoint. + ErrBotWebhookOptsPathCollidesStatus = errors.New("BotWebhookOpts.Path must not be '/status' when status path is enabled") + // ErrBotWebhookTLSFilesIncomplete reports that only one of the two TLS files was provided. + ErrBotWebhookTLSFilesIncomplete = errors.New("you must specify both private and public keys") + // ErrBotWebhookTLSFilesTooMany reports that more than two TLS files were provided. + ErrBotWebhookTLSFilesTooMany = errors.New("too many files; you must specify only private and public keys") ) func validateMessageText(text string) error { diff --git a/handler.go b/handler.go index ce9012a..eefee82 100644 --- a/handler.go +++ b/handler.go @@ -15,14 +15,19 @@ import ( // ErrInvalidPayloadType is returned when callback payload encoding type is unknown. var ErrInvalidPayloadType = errors.New("invalid payload type") +// ErrInvalidPayload reports that a callback payload could not be decoded under the +// expected encoding (e.g. the compact format separator is missing). +var ErrInvalidPayload = errors.New("invalid payload") + func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) { defer func() { if r := recover(); r != nil { - bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r)) + if bot.logger != nil { + bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r)) + } - var err error - var ok bool - if err, ok = r.(error); !ok { + err, ok := r.(error) + if !ok { err = fmt.Errorf("%v", r) } bot.safeEmitEvent(parentCtx, ErrorEvent{ @@ -232,7 +237,7 @@ func decodeCompactPayload(s string) (CallbackData, error) { } } if sepIdx == -1 { - return CallbackData{}, errors.New("invalid payload") + return CallbackData{}, ErrInvalidPayload } cmd := decodeCompactPart(s[:sepIdx]) argsRaw := s[sepIdx+1:] diff --git a/msg_context.go b/msg_context.go index ea6cfcc..e95dc2b 100644 --- a/msg_context.go +++ b/msg_context.go @@ -496,7 +496,7 @@ func (ctx *MessageContext) AnswerCallbackURL(u string) { ctx.answerCallbackQuery // SendAction sends a chat action (typing, uploading_photo, etc.) to indicate bot activity. func (ctx *MessageContext) SendAction(action tgapi.ChatActionType) { if ctx.Msg == nil { - ctx.Logger.Errorln("Can't send action without chat message context") + ctx.Logger.Errorln(ErrMessageContextNil) return } params := tgapi.SendChatAction{ @@ -528,7 +528,12 @@ func (ctx *MessageContext) error(err error) { } } -// Error routes err through the centralized handler error path… +// Error routes err through the centralized handler error path. +// +// The error is logged via ctx.Logger. When IsUserError(err) is true, the +// formatted error template is delivered to the user — through an answer +// to the active callback query when one exists, otherwise as a chat reply. +// Internal errors are logged but not surfaced to the user. func (ctx *MessageContext) Error(err error) { ctx.error(err) } func (ctx *MessageContext) newDraft(parseMode tgapi.ParseMode) *Draft { diff --git a/plugins.go b/plugins.go index 95f477f..122a137 100644 --- a/plugins.go +++ b/plugins.go @@ -106,9 +106,8 @@ func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] { return p } scene.pluginName = p.name - scene.setPluginName(p.name) 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' already registered in plugin '%s'; overwriting", scene.name, p.name) } p.scenes[scene.name] = scene return p diff --git a/runners.go b/runners.go index b45ea16..f6ec4a1 100644 --- a/runners.go +++ b/runners.go @@ -12,32 +12,33 @@ type RunnerFn[T AppData] func(*Bot[T]) error // Runner represents a configurable background or one-time task to be // executed by a Bot. // -// Runners are configured using builder methods: Once(), Async(), Every(). -// Once Execute() is called, the Runner should not be modified. +// Runners are configured using builder methods Async and Every. Once the +// bot's runtime has started executing the runner, it should not be modified. // // Execution semantics: -// - every=0, async=false: Run once synchronously (blocks). -// - every=0, async=true: Run once in a goroutine (non-blocking). -// - every>0, async=true: Run repeatedly in a goroutine with timeout. -// - every>0, async=false: Invalid configuration — ignored with warning. +// - every=0, async=true: Run once in a goroutine (non-blocking, default). +// - every=0, async=false: Run once synchronously (blocks runtime startup). +// - every>0, async=true: Run repeatedly in a goroutine with the given interval. +// - every>0, async=false: Invalid configuration — skipped with a warning. type Runner[T AppData] struct { name string // Human-readable name for logging 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 // Interval between periodic executions; zero means one-shot fn RunnerFn[T] // The function to execute } // NewRunner creates a new Runner with the given name and function. -// 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. -// DO NOT call builder methods concurrently or after Execute(). +// The default configuration is async=true and every=0, i.e. a one-shot +// goroutine that fires once when the bot runtime starts. Use Async and Every +// to customize this. Do not call builder methods concurrently or after the +// bot runtime has begun executing runners. func NewRunner[T AppData](name string, fn RunnerFn[T]) Runner[T] { return Runner[T]{ name: name, fn: fn, - async: true, // Default: run asynchronously - every: 0, // Default: 0 - one time + async: true, + every: 0, } } @@ -45,21 +46,18 @@ func NewRunner[T AppData](name string, fn RunnerFn[T]) Runner[T] { // If true, the runner runs in a goroutine (non-blocking). // If false, the runner blocks the caller during execution. // -// Note: If once=false and async=false, the runner will be skipped with a warning. +// Note: periodic runners (Every > 0) require async=true and are skipped with +// a warning when async=false. func (r Runner[T]) Async(async bool) Runner[T] { r.async = async return r } -// Every sets the duration to wait between repeated executions for -// non-once runners. +// Every sets the interval between repeated executions of a periodic runner. // -// If once=true, this value is ignored. -// If once=false and async=true, this timeout determines the sleep interval -// between loop iterations. -// -// A zero value (time.Duration(0)) is allowed but may trigger a warning -// if used with a background (non-once) async runner. +// A zero value (the default) keeps the runner one-shot. A positive value +// schedules the runner to fire repeatedly with the given interval and +// requires async=true; periodic sync runners are skipped with a warning. func (r Runner[T]) Every(timeout time.Duration) Runner[T] { r.every = timeout return r @@ -67,15 +65,11 @@ func (r Runner[T]) Every(timeout time.Duration) Runner[T] { // ExecRunners executes all runners registered on the Bot with context-based lifecycle management. // -// It logs warnings for misconfigured runners: -// - Sync, non-once runners are skipped (invalid configuration). -// - Background (non-once, async) runners without a timeout trigger a warning. -// -// Execution logic: -// - once + async: Runs once in a goroutine. -// - once + sync: Runs once synchronously; warns if slower than 2 seconds. -// - !once + async: Runs in a loop with timeout between iterations until ctx.Done(). -// - !once + sync: Skipped with warning. +// Execution semantics by configuration: +// - every=0, async=true: Runs once in a goroutine (fire and forget). +// - every=0, async=false: Runs once synchronously; warns if slower than 2 seconds. +// - every>0, async=true: Runs in a loop with the configured interval until ctx.Done(). +// - every>0, async=false: Skipped with a warning (invalid configuration). // // Background runners listen for ctx.Done() and gracefully shut down when the context is canceled. // @@ -84,13 +78,8 @@ func (r Runner[T]) Every(timeout time.Duration) Runner[T] { func (bot *Bot[T]) ExecRunners(ctx context.Context) { bot.logger.Infoln("Executing runners...") for _, runner := range bot.runners { - // Validate configuration if runner.every > 0 && !runner.async { - bot.logger.Warnf("Runner %s not once, but sync — skipping\n", runner.name) - continue - } - 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("Runner %q is periodic but sync; skipping (use Async(true))\n", runner.name) continue } @@ -180,6 +169,5 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) { } }(runner) } - // Note: !once && !async is already skipped above } } diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index e02b3e5..cb93a41 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -229,7 +229,7 @@ func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, str for _, file := range files { fw, err := w.CreateFormFile(string(file.field), file.filename) if err != nil { - _ = w.Close() // Закрываем, чтобы не было утечки + _ = w.Close() return nil, "", err } @@ -240,13 +240,13 @@ func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, str } } - err := utils.Encode(w, params) // Предполагается, что это записывает в w + err := utils.Encode(w, params) if err != nil { _ = w.Close() return nil, "", err } - err = w.Close() // ✅ ОБЯЗАТЕЛЬНО вызвать в конце — иначе запрос битый! + err = w.Close() if err != nil { return nil, "", err }