(new): webhook + payload error sentinels
(fix): polling panic ErrorEvent, nil-logger guard (refactor): inline webhook errors → sentinels, dead Runner branch (doc): Runner godoc, Error godoc, drafts cleanup
This commit is contained in:
@@ -449,6 +449,17 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error {
|
|||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
bot.logger.Errorln(fmt.Sprintf("panic in update polling: %v", r))
|
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)
|
close(bot.updateQueue)
|
||||||
}()
|
}()
|
||||||
|
|||||||
+8
-8
@@ -202,7 +202,7 @@ func (bot *Bot[T]) RunWebhookWithContext(ctx context.Context, opts *BotWebhookOp
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
return errors.New("failed to set webhook")
|
return ErrSetWebhookFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(tlsFiles) == 2 {
|
if len(tlsFiles) == 2 {
|
||||||
@@ -227,7 +227,7 @@ func (bot *Bot[T]) RunWebhook(opts *BotWebhookOpts, tlsFiles ...string) error {
|
|||||||
func (bot *Bot[T]) CloseWebhook() error {
|
func (bot *Bot[T]) CloseWebhook() error {
|
||||||
var e []error
|
var e []error
|
||||||
if bot.api == nil {
|
if bot.api == nil {
|
||||||
e = append(e, errors.New("bot api nil"))
|
e = append(e, ErrBotAPINil)
|
||||||
} else {
|
} else {
|
||||||
if _, err := bot.api.DeleteWebhook(tgapi.DeleteWebhook{}); err != nil {
|
if _, err := bot.api.DeleteWebhook(tgapi.DeleteWebhook{}); err != nil {
|
||||||
if bot.webhookLogger != 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 {
|
func validateWebhookPath(path string, useStatusPath bool) error {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return errors.New("empty BotWebhookOpts.Path")
|
return ErrBotWebhookOptsEmptyPath
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(path, "/") {
|
if !strings.HasPrefix(path, "/") {
|
||||||
return errors.New("BotWebhookOpts.Path must start with '/'")
|
return ErrBotWebhookOptsPathNoSlash
|
||||||
}
|
}
|
||||||
if strings.Contains(path, "?") || strings.Contains(path, "#") {
|
if strings.Contains(path, "?") || strings.Contains(path, "#") {
|
||||||
return errors.New("BotWebhookOpts.Path must not contain query or fragment")
|
return ErrBotWebhookOptsPathHasQueryOrFragment
|
||||||
}
|
}
|
||||||
if useStatusPath && path == "/status" {
|
if useStatusPath && path == "/status" {
|
||||||
return errors.New("BotWebhookOpts.Path must not be '/status' when status path is enabled")
|
return ErrBotWebhookOptsPathCollidesStatus
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -442,8 +442,8 @@ func validateWebhookTLSFiles(tlsFiles []string) error {
|
|||||||
case 0, 2:
|
case 0, 2:
|
||||||
return nil
|
return nil
|
||||||
case 1:
|
case 1:
|
||||||
return errors.New("you must specify both private and public keys")
|
return ErrBotWebhookTLSFilesIncomplete
|
||||||
default:
|
default:
|
||||||
return errors.New("too many files; you must specify only private and public keys")
|
return ErrBotWebhookTLSFilesTooMany
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ import (
|
|||||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Interface for generating unique draft IDs.
|
|
||||||
type draftIDGenerator interface {
|
type draftIDGenerator interface {
|
||||||
// Next returns the next unique draft ID.
|
|
||||||
Next() uint64
|
Next() uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,22 @@ var (
|
|||||||
ErrBotUploaderWhenCertificate = errors.New("bot uploader nil, but certificate set")
|
ErrBotUploaderWhenCertificate = errors.New("bot uploader nil, but certificate set")
|
||||||
// ErrStatusPathSecretRequired reports that UseStatusPath requires SecretToken to be set.
|
// ErrStatusPathSecretRequired reports that UseStatusPath requires SecretToken to be set.
|
||||||
ErrStatusPathSecretRequired = errors.New("SecretToken required when UseStatusPath is enabled")
|
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 {
|
func validateMessageText(text string) error {
|
||||||
|
|||||||
+10
-5
@@ -15,14 +15,19 @@ import (
|
|||||||
// ErrInvalidPayloadType is returned when callback payload encoding type is unknown.
|
// ErrInvalidPayloadType is returned when callback payload encoding type is unknown.
|
||||||
var ErrInvalidPayloadType = errors.New("invalid payload type")
|
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) {
|
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))
|
if bot.logger != nil {
|
||||||
|
bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r))
|
||||||
|
}
|
||||||
|
|
||||||
var err error
|
err, ok := r.(error)
|
||||||
var ok bool
|
if !ok {
|
||||||
if err, ok = r.(error); !ok {
|
|
||||||
err = fmt.Errorf("%v", r)
|
err = fmt.Errorf("%v", r)
|
||||||
}
|
}
|
||||||
bot.safeEmitEvent(parentCtx, ErrorEvent{
|
bot.safeEmitEvent(parentCtx, ErrorEvent{
|
||||||
@@ -232,7 +237,7 @@ func decodeCompactPayload(s string) (CallbackData, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if sepIdx == -1 {
|
if sepIdx == -1 {
|
||||||
return CallbackData{}, errors.New("invalid payload")
|
return CallbackData{}, ErrInvalidPayload
|
||||||
}
|
}
|
||||||
cmd := decodeCompactPart(s[:sepIdx])
|
cmd := decodeCompactPart(s[:sepIdx])
|
||||||
argsRaw := s[sepIdx+1:]
|
argsRaw := s[sepIdx+1:]
|
||||||
|
|||||||
+7
-2
@@ -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.
|
// SendAction sends a chat action (typing, uploading_photo, etc.) to indicate bot activity.
|
||||||
func (ctx *MessageContext) SendAction(action tgapi.ChatActionType) {
|
func (ctx *MessageContext) SendAction(action tgapi.ChatActionType) {
|
||||||
if ctx.Msg == nil {
|
if ctx.Msg == nil {
|
||||||
ctx.Logger.Errorln("Can't send action without chat message context")
|
ctx.Logger.Errorln(ErrMessageContextNil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
params := tgapi.SendChatAction{
|
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) Error(err error) { ctx.error(err) }
|
||||||
|
|
||||||
func (ctx *MessageContext) newDraft(parseMode tgapi.ParseMode) *Draft {
|
func (ctx *MessageContext) newDraft(parseMode tgapi.ParseMode) *Draft {
|
||||||
|
|||||||
+1
-2
@@ -106,9 +106,8 @@ func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] {
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
scene.pluginName = p.name
|
scene.pluginName = 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' already registered in plugin '%s'; overwriting", scene.name, p.name)
|
||||||
}
|
}
|
||||||
p.scenes[scene.name] = scene
|
p.scenes[scene.name] = scene
|
||||||
return p
|
return p
|
||||||
|
|||||||
+25
-37
@@ -12,32 +12,33 @@ type RunnerFn[T AppData] func(*Bot[T]) error
|
|||||||
// Runner represents a configurable background or one-time task to be
|
// Runner represents a configurable background or one-time task to be
|
||||||
// executed by a Bot.
|
// executed by a Bot.
|
||||||
//
|
//
|
||||||
// Runners are configured using builder methods: Once(), Async(), Every().
|
// Runners are configured using builder methods Async and Every. Once the
|
||||||
// Once Execute() is called, the Runner should not be modified.
|
// bot's runtime has started executing the runner, it should not be modified.
|
||||||
//
|
//
|
||||||
// Execution semantics:
|
// Execution semantics:
|
||||||
// - every=0, async=false: Run once synchronously (blocks).
|
// - every=0, async=true: Run once in a goroutine (non-blocking, default).
|
||||||
// - every=0, async=true: Run once in a goroutine (non-blocking).
|
// - every=0, async=false: Run once synchronously (blocks runtime startup).
|
||||||
// - every>0, async=true: Run repeatedly in a goroutine with timeout.
|
// - every>0, async=true: Run repeatedly in a goroutine with the given interval.
|
||||||
// - every>0, async=false: Invalid configuration — ignored with warning.
|
// - every>0, async=false: Invalid configuration — skipped with a 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
|
||||||
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 // Interval between periodic executions; zero means one-shot
|
||||||
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), once=true/mo
|
|
||||||
//
|
//
|
||||||
// Builder methods (Once, Async, Every) can be chained to customize behavior.
|
// The default configuration is async=true and every=0, i.e. a one-shot
|
||||||
// DO NOT call builder methods concurrently or after Execute().
|
// 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] {
|
func NewRunner[T AppData](name string, fn RunnerFn[T]) Runner[T] {
|
||||||
return Runner[T]{
|
return Runner[T]{
|
||||||
name: name,
|
name: name,
|
||||||
fn: fn,
|
fn: fn,
|
||||||
async: true, // Default: run asynchronously
|
async: true,
|
||||||
every: 0, // Default: 0 - one time
|
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 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.
|
||||||
//
|
//
|
||||||
// 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] {
|
func (r Runner[T]) Async(async bool) Runner[T] {
|
||||||
r.async = async
|
r.async = async
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every sets the duration to wait between repeated executions for
|
// Every sets the interval between repeated executions of a periodic runner.
|
||||||
// non-once runners.
|
|
||||||
//
|
//
|
||||||
// If once=true, this value is ignored.
|
// A zero value (the default) keeps the runner one-shot. A positive value
|
||||||
// If once=false and async=true, this timeout determines the sleep interval
|
// schedules the runner to fire repeatedly with the given interval and
|
||||||
// between loop iterations.
|
// requires async=true; periodic sync runners are skipped with a warning.
|
||||||
//
|
|
||||||
// A zero value (time.Duration(0)) is allowed but may trigger a warning
|
|
||||||
// if used with a background (non-once) async runner.
|
|
||||||
func (r Runner[T]) Every(timeout time.Duration) Runner[T] {
|
func (r Runner[T]) Every(timeout time.Duration) Runner[T] {
|
||||||
r.every = timeout
|
r.every = timeout
|
||||||
return r
|
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.
|
// ExecRunners executes all runners registered on the Bot with context-based lifecycle management.
|
||||||
//
|
//
|
||||||
// It logs warnings for misconfigured runners:
|
// Execution semantics by configuration:
|
||||||
// - Sync, non-once runners are skipped (invalid configuration).
|
// - every=0, async=true: Runs once in a goroutine (fire and forget).
|
||||||
// - Background (non-once, async) runners without a timeout trigger a warning.
|
// - 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().
|
||||||
// Execution logic:
|
// - every>0, async=false: Skipped with a warning (invalid configuration).
|
||||||
// - 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.
|
|
||||||
//
|
//
|
||||||
// Background runners listen for ctx.Done() and gracefully shut down when the context is canceled.
|
// 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) {
|
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
|
|
||||||
if runner.every > 0 && !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 %q is periodic but sync; skipping (use Async(true))\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)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +169,5 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}(runner)
|
}(runner)
|
||||||
}
|
}
|
||||||
// Note: !once && !async is already skipped above
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, str
|
|||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
fw, err := w.CreateFormFile(string(file.field), file.filename)
|
fw, err := w.CreateFormFile(string(file.field), file.filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = w.Close() // Закрываем, чтобы не было утечки
|
_ = w.Close()
|
||||||
return nil, "", err
|
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 {
|
if err != nil {
|
||||||
_ = w.Close()
|
_ = w.Close()
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = w.Close() // ✅ ОБЯЗАТЕЛЬНО вызвать в конце — иначе запрос битый!
|
err = w.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user