(new): expand runtime APIs
Golang lint / lint (push) Successful in 58s
Golang lint / lint (pull_request) Successful in 13m10s

(fix): harden concurrent lifecycle
(tests): add regression coverage
(doc): update v1.2 guidance
This commit is contained in:
2026-08-20 11:08:45 +03:00
parent 29b208eeec
commit 24040fe164
37 changed files with 1129 additions and 157 deletions
+96 -35
View File
@@ -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.