(new): expand runtime APIs
(fix): harden concurrent lifecycle (tests): add regression coverage (doc): update v1.2 guidance
This commit is contained in:
@@ -97,13 +97,17 @@ type Bot[T AppData] struct {
|
||||
maxWorkers int
|
||||
pollTimeout int // Long-polling timeout in seconds for getUpdates
|
||||
|
||||
logFormat utils.LogFormat
|
||||
logFormatter *sneklog.Formatter
|
||||
logger *sneklog.Logger // Main bot logger (JSON stdout + optional file)
|
||||
requestLogger *sneklog.Logger // Optional request-level API logging
|
||||
useReqLogger bool
|
||||
webhookLogger *sneklog.Logger // Webhook logger. Available only after Bot.RunWebhookWithContext.
|
||||
extraLoggers extypes.Slice[*sneklog.Logger] // API, Uploader, and custom loggers
|
||||
logFormat utils.LogFormat
|
||||
logFormatter *sneklog.Formatter
|
||||
logger *sneklog.Logger // Main bot logger (JSON stdout + optional file)
|
||||
requestLogger *sneklog.Logger // Optional request-level API logging
|
||||
useReqLogger bool
|
||||
webhookLogger *sneklog.Logger // Webhook logger. Available only after Bot.RunWebhookWithContext.
|
||||
loggerOwned bool
|
||||
requestLoggerOwned bool
|
||||
webhookLoggerOwned bool
|
||||
detachedOwnedLoggers []*sneklog.Logger
|
||||
extraLoggers extypes.Slice[*sneklog.Logger] // API, Uploader, and custom loggers
|
||||
|
||||
plugins []Plugin[T] // Command/event handlers
|
||||
middlewares []Middleware[T] // Pre-processing filters (sorted by order)
|
||||
@@ -131,6 +135,7 @@ type Bot[T AppData] struct {
|
||||
updateQueue chan *tgapi.Update // Internal queue for processing updates
|
||||
runnerOnceWG sync.WaitGroup // Tracks one-time async runners
|
||||
runnerBgWG sync.WaitGroup // Tracks background async runners
|
||||
middlewareWG sync.WaitGroup // Tracks asynchronous middleware callbacks
|
||||
runStateMu sync.Mutex
|
||||
running bool
|
||||
ran bool
|
||||
@@ -157,6 +162,20 @@ func (bot *Bot[T]) configMutable(method string) bool {
|
||||
// - Sets up DraftProvider with random IDs
|
||||
// - Adds API and Uploader loggers to extraLoggers
|
||||
func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
|
||||
return newBot[T](opts, nil)
|
||||
}
|
||||
|
||||
// NewBotWithAPI creates a Bot using a preconfigured API client.
|
||||
// The Bot takes ownership of api and closes it from Bot.Close. API transport,
|
||||
// retry, and rate-limit fields in opts do not reconfigure the supplied client.
|
||||
func NewBotWithAPI[T any](opts *BotOpts, api *tgapi.API) (*Bot[T], error) {
|
||||
if api == nil {
|
||||
return nil, ErrAPIIsNil
|
||||
}
|
||||
return newBot[T](opts, api)
|
||||
}
|
||||
|
||||
func newBot[T any](opts *BotOpts, api *tgapi.API) (*Bot[T], error) {
|
||||
if opts == nil {
|
||||
return nil, ErrOptsIsNil
|
||||
}
|
||||
@@ -182,15 +201,17 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
|
||||
// HTTP client timeout must exceed pollTimeout to avoid spurious deadline
|
||||
// errors that the polling loop would misinterpret as context cancellation.
|
||||
httpTimeout := time.Duration(pollTimeout)*time.Second + 60*time.Second
|
||||
apiOpts := tgapi.NewAPIOpts(opts.Token).
|
||||
SetAPIURL(opts.APIURL).
|
||||
UseTestServer(opts.UseTestServer).
|
||||
SetLimiter(limiter).
|
||||
SetDropRateLimitOverflow(opts.DropRateLimitOverflow).
|
||||
SetLogFormat(opts.LogFormat).
|
||||
SetLogFormatter(opts.LogFormatter).
|
||||
SetHTTPClient(&http.Client{Timeout: httpTimeout})
|
||||
api := tgapi.NewAPI(apiOpts)
|
||||
if api == nil {
|
||||
apiOpts := tgapi.NewAPIOpts(opts.Token).
|
||||
SetAPIURL(opts.APIURL).
|
||||
UseTestServer(opts.UseTestServer).
|
||||
SetLimiter(limiter).
|
||||
SetDropRateLimitOverflow(opts.DropRateLimitOverflow).
|
||||
SetLogFormat(opts.LogFormat).
|
||||
SetLogFormatter(opts.LogFormatter).
|
||||
SetHTTPClient(&http.Client{Timeout: httpTimeout})
|
||||
api = tgapi.NewAPI(apiOpts)
|
||||
}
|
||||
uploader := tgapi.NewUploader(api)
|
||||
|
||||
prefixes := append([]string(nil), opts.Prefixes...)
|
||||
@@ -276,8 +297,13 @@ func (bot *Bot[T]) SetLogger(l *sneklog.Logger) *Bot[T] {
|
||||
}
|
||||
return bot
|
||||
}
|
||||
if l == bot.logger {
|
||||
return bot
|
||||
}
|
||||
bot.addTokenReplacer(l)
|
||||
bot.closeReplacedLogger(bot.logger, bot.loggerOwned, l, bot.requestLogger, bot.webhookLogger)
|
||||
bot.logger = l
|
||||
bot.loggerOwned = false
|
||||
return bot
|
||||
}
|
||||
|
||||
@@ -292,8 +318,13 @@ func (bot *Bot[T]) SetRequestLogger(l *sneklog.Logger) *Bot[T] {
|
||||
}
|
||||
return bot
|
||||
}
|
||||
if l == bot.requestLogger {
|
||||
return bot
|
||||
}
|
||||
bot.addTokenReplacer(l)
|
||||
bot.closeReplacedLogger(bot.requestLogger, bot.requestLoggerOwned, l, bot.logger, bot.webhookLogger)
|
||||
bot.requestLogger = l
|
||||
bot.requestLoggerOwned = false
|
||||
return bot
|
||||
}
|
||||
|
||||
@@ -308,8 +339,13 @@ func (bot *Bot[T]) SetWebhookLogger(l *sneklog.Logger) *Bot[T] {
|
||||
}
|
||||
return bot
|
||||
}
|
||||
if l == bot.webhookLogger {
|
||||
return bot
|
||||
}
|
||||
bot.addTokenReplacer(l)
|
||||
bot.closeReplacedLogger(bot.webhookLogger, bot.webhookLoggerOwned, l, bot.logger, bot.requestLogger)
|
||||
bot.webhookLogger = l
|
||||
bot.webhookLoggerOwned = false
|
||||
return bot
|
||||
}
|
||||
|
||||
@@ -325,8 +361,8 @@ func (bot *Bot[T]) GetUploader() *tgapi.Uploader { return bot.uploader }
|
||||
// - The asynchronous observer dispatcher, after draining queued events
|
||||
// - Registered plugins via Plugin.Close
|
||||
// - Webhook logger (if initialized)
|
||||
// - Uploader (waits for pending uploads)
|
||||
// - API client internals
|
||||
// - Uploader logger resources
|
||||
// - API client internals, after pending API and upload requests complete
|
||||
// - RequestLogger (if enabled)
|
||||
// - Main logger
|
||||
//
|
||||
@@ -346,18 +382,34 @@ func (bot *Bot[T]) Close() error {
|
||||
}
|
||||
e = append(e, err)
|
||||
}
|
||||
bot.stopObserverDispatcher()
|
||||
observerCtx, observerCancel := context.WithTimeout(context.Background(), observerShutdownTimeout)
|
||||
logCloseErr(bot.stopObserverDispatcher(observerCtx))
|
||||
observerCancel()
|
||||
|
||||
for _, p := range bot.plugins {
|
||||
if err := p.Close(); err != nil {
|
||||
e = append(e, err)
|
||||
}
|
||||
}
|
||||
if bot.webhookLogger != nil {
|
||||
if err := bot.webhookLogger.Close(); err != nil {
|
||||
logCloseErr(err)
|
||||
closedLoggers := make(map[*sneklog.Logger]struct{}, 3)
|
||||
closeOwnedLogger := func(logger *sneklog.Logger, owned bool) {
|
||||
if logger == nil || !owned {
|
||||
return
|
||||
}
|
||||
if _, exists := closedLoggers[logger]; exists {
|
||||
return
|
||||
}
|
||||
closedLoggers[logger] = struct{}{}
|
||||
logCloseErr(logger.Close())
|
||||
}
|
||||
for _, logger := range bot.detachedOwnedLoggers {
|
||||
closeOwnedLogger(logger, true)
|
||||
}
|
||||
bot.detachedOwnedLoggers = nil
|
||||
if bot.webhookLogger != nil {
|
||||
closeOwnedLogger(bot.webhookLogger, bot.webhookLoggerOwned)
|
||||
bot.webhookLogger = nil
|
||||
bot.webhookLoggerOwned = false
|
||||
}
|
||||
if bot.uploader != nil {
|
||||
if err := bot.uploader.Close(); err != nil {
|
||||
@@ -370,14 +422,14 @@ func (bot *Bot[T]) Close() error {
|
||||
}
|
||||
}
|
||||
if bot.requestLogger != nil {
|
||||
if err := bot.requestLogger.Close(); err != nil {
|
||||
logCloseErr(err)
|
||||
}
|
||||
closeOwnedLogger(bot.requestLogger, bot.requestLoggerOwned)
|
||||
bot.requestLogger = nil
|
||||
bot.requestLoggerOwned = false
|
||||
}
|
||||
if bot.logger != nil {
|
||||
if err := bot.logger.Close(); err != nil {
|
||||
e = append(e, err)
|
||||
}
|
||||
closeOwnedLogger(bot.logger, bot.loggerOwned)
|
||||
bot.logger = nil
|
||||
bot.loggerOwned = false
|
||||
}
|
||||
return errors.Join(e...)
|
||||
}
|
||||
@@ -467,25 +519,31 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error {
|
||||
defer bot.finishRun()
|
||||
if !bot.useReqLogger && bot.requestLogger != nil {
|
||||
bot.logger.Warnln("Opts#UseRequestLogger is false, but Bot#requestLogger present. Remove Bot#SetRequestLogger or set Opts#UseRequestLogger to true!")
|
||||
err := bot.requestLogger.Close()
|
||||
if err != nil {
|
||||
bot.logger.Errorln(err)
|
||||
if bot.requestLoggerOwned {
|
||||
if err := bot.requestLogger.Close(); err != nil {
|
||||
bot.logger.Errorln(err)
|
||||
}
|
||||
}
|
||||
bot.requestLogger = nil
|
||||
bot.requestLoggerOwned = false
|
||||
}
|
||||
if bot.webhookLogger != nil {
|
||||
bot.logger.Warnln("Bot#webhookLogger present. You shouldn't set this, if ran in Long Polling mode!")
|
||||
err := bot.webhookLogger.Close()
|
||||
if err != nil {
|
||||
bot.logger.Errorln(err)
|
||||
if bot.webhookLoggerOwned {
|
||||
if err := bot.webhookLogger.Close(); err != nil {
|
||||
bot.logger.Errorln(err)
|
||||
}
|
||||
}
|
||||
bot.webhookLogger = nil
|
||||
bot.webhookLoggerOwned = false
|
||||
}
|
||||
|
||||
bot.ExecRunners(ctx)
|
||||
|
||||
// Start update polling in a goroutine
|
||||
pollDone := make(chan error, 1)
|
||||
go func() {
|
||||
var terminalErr error
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
bot.logger.Errorln(fmt.Sprintf("panic in update polling: %v", r))
|
||||
@@ -493,6 +551,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error {
|
||||
if !ok {
|
||||
err = fmt.Errorf("%v", r)
|
||||
}
|
||||
terminalErr = fmt.Errorf("update polling: %w: %v", ErrHandlerPanic, err)
|
||||
bot.safeEmitEvent(ctx, ErrorEvent{
|
||||
Plugin: "bot",
|
||||
HandlerKind: HandlerPollingKind,
|
||||
@@ -502,6 +561,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error {
|
||||
})
|
||||
}
|
||||
close(bot.updateQueue)
|
||||
pollDone <- terminalErr
|
||||
}()
|
||||
backoffDelay := time.Duration(0)
|
||||
retryCount := 0
|
||||
@@ -566,7 +626,8 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error {
|
||||
|
||||
bot.runnerOnceWG.Wait()
|
||||
bot.runnerBgWG.Wait()
|
||||
return nil
|
||||
bot.middlewareWG.Wait()
|
||||
return <-pollDone
|
||||
}
|
||||
|
||||
// Run starts the bot using a background context.
|
||||
|
||||
Reference in New Issue
Block a user