Compare commits

..

2 Commits

Author SHA1 Message Date
5976fcd0b8 v1.0.0 beta 19 2026-03-13 12:25:53 +03:00
6ba8520bb7 v1.0.0 beta 18 2026-03-13 11:24:13 +03:00
10 changed files with 203 additions and 101 deletions

16
bot.go
View File

@@ -163,7 +163,7 @@ func LoadPrefixesFromEnv() []string {
// bot := NewBot[MyDB](opts).DatabaseContext(&myDB) // bot := NewBot[MyDB](opts).DatabaseContext(&myDB)
// //
// Use NoDB if no database is needed. // Use NoDB if no database is needed.
type DbContext interface{} type DbContext any
// NoDB is a placeholder type for bots that do not use a database. // NoDB is a placeholder type for bots that do not use a database.
// Use Bot[NoDB] to indicate no dependency injection is required. // Use Bot[NoDB] to indicate no dependency injection is required.
@@ -599,12 +599,18 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
return return
} }
bot.ExecRunners() bot.ExecRunners(ctx)
bot.logger.Infoln("Bot running. Press CTRL+C to exit.") bot.logger.Infoln("Bot running. Press CTRL+C to exit.")
// Start update polling in a goroutine // Start update polling in a goroutine
go func() { go func() {
defer func() {
if r := recover(); r != nil {
bot.logger.Errorln(fmt.Sprintf("panic in update polling: %v", r))
}
close(bot.updateQueue)
}()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -618,6 +624,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
} }
for _, u := range updates { for _, u := range updates {
u := u // copy loop variable to avoid race condition
select { select {
case bot.updateQueue <- &u: case bot.updateQueue <- &u:
case <-ctx.Done(): case <-ctx.Done():
@@ -631,11 +638,12 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
// Start worker pool for concurrent update handling // Start worker pool for concurrent update handling
pool := pond.NewPool(16) pool := pond.NewPool(16)
for update := range bot.updateQueue { for update := range bot.updateQueue {
update := update // capture loop variable u := update // capture loop variable
pool.Submit(func() { pool.Submit(func() {
bot.handle(update) bot.handle(u)
}) })
} }
pool.Stop() // Wait for all tasks to complete and stop the pool
} }
// Run starts the bot using a background context. // Run starts the bot using a background context.

View File

@@ -41,6 +41,8 @@ var ErrTooManyCommands = errors.New("too many commands. max 100")
// //
// Command{command: "start", description: "Start the bot", args: []Arg{{text: "name", required: false}}} // Command{command: "start", description: "Start the bot", args: []Arg{{text: "name", required: false}}}
// → Description: "Start the bot. Usage: /start [name]" // → Description: "Start the bot. Usage: /start [name]"
// Command{command: "echo", description: "Echo user input", args: []Arg{{text: "name", required: true}}}
// → Description: "Echo user input. Usage: /echo <input>"
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 {
@@ -50,33 +52,31 @@ func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand {
var descArgs []string var descArgs []string
for _, a := range cmd.args { for _, a := range cmd.args {
if a.required { if a.required {
descArgs = append(descArgs, a.text) descArgs = append(descArgs, fmt.Sprintf("<%s>", a.text))
} else { } else {
descArgs = append(descArgs, fmt.Sprintf("[%s]", a.text)) descArgs = append(descArgs, fmt.Sprintf("[%s]", a.text))
} }
} }
usage := fmt.Sprintf("Usage: /%s %s", cmd.command, strings.Join(descArgs, " "))
if desc != "" { if desc != "" {
desc = fmt.Sprintf("%s. Usage: /%s %s", desc, cmd.command, strings.Join(descArgs, " ")) desc = fmt.Sprintf("%s. %s", desc, usage)
} else {
desc = fmt.Sprintf("Usage: /%s %s", cmd.command, strings.Join(descArgs, " "))
}
return tgapi.BotCommand{Command: cmd.command, Description: desc} return tgapi.BotCommand{Command: cmd.command, Description: desc}
}
return tgapi.BotCommand{Command: cmd.command, Description: usage}
} }
// checkCmdRegex check if command satisfy regexp [a-zA-Z0-9]+ // checkCmdRegex check if command satisfy regexp [a-zA-Z0-9]+
// Return true if satisfy, else false. // Return true if satisfied, else false.
func checkCmdRegex(cmd string) bool { func checkCmdRegex(cmd string) bool { return CmdRegexp.MatchString(cmd) }
return CmdRegexp.MatchString(cmd)
}
// generateBotCommandForPlugin collects all non-skipped commands from a Plugin[T] // gatherCommandsForPlugin collects all non-skipped commands from a Plugin[T]
// and converts them into tgapi.BotCommand objects. // and converts them into tgapi.BotCommand objects.
// //
// Commands marked with skipAutoCmd = true are excluded from auto-registration. // Commands marked with skipAutoCmd = true are excluded from auto-registration.
// This allows plugins to opt out of automatic command generation (e.g., for // This allows plugins to opt out of automatic command generation (e.g., for
// internal or hidden commands). // internal or hidden commands).
func generateBotCommandForPlugin[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)
for _, cmd := range pl.commands { for _, cmd := range pl.commands {
if cmd.skipAutoCmd { if cmd.skipAutoCmd {
@@ -90,6 +90,21 @@ func generateBotCommandForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand {
return commands return commands
} }
// gatherCommands collects all commands from all plugins
// and converts them into tgapi.BotCommand objects.
// See gatherCommandsForPlugin
func gatherCommands[T any](bot *Bot[T]) []tgapi.BotCommand {
commands := make([]tgapi.BotCommand, 0)
for _, pl := range bot.plugins {
if pl.skipAutoCmd {
continue
}
commands = append(commands, gatherCommandsForPlugin(pl)...)
bot.logger.Debugln(fmt.Sprintf("Registered %d commands from plugin %s", len(pl.commands), pl.name))
}
return commands
}
// AutoGenerateCommands registers all plugin-defined commands with Telegram's Bot API // AutoGenerateCommands registers all plugin-defined commands with Telegram's Bot API
// across three scopes: // across three scopes:
// - Private chats (users) // - Private chats (users)
@@ -119,17 +134,7 @@ func (bot *Bot[T]) AutoGenerateCommands() error {
return fmt.Errorf("failed to delete existing commands: %w", err) return fmt.Errorf("failed to delete existing commands: %w", err)
} }
// Collect all non-skipped commands from all plugins commands := gatherCommands(bot)
commands := make([]tgapi.BotCommand, 0)
for _, pl := range bot.plugins {
if pl.skipAutoCmd {
continue
}
commands = append(commands, generateBotCommandForPlugin(pl)...)
bot.logger.Debugln(fmt.Sprintf("Registered %d commands from plugin %s", len(pl.commands), pl.name))
}
// Enforce Telegram's 100-command limit
if len(commands) > 100 { if len(commands) > 100 {
return ErrTooManyCommands return ErrTooManyCommands
} }
@@ -153,3 +158,39 @@ func (bot *Bot[T]) AutoGenerateCommands() error {
return nil return nil
} }
// AutoGenerateCommandsForScope registers all plugin-defined commands with Telegram's Bot API
// for the specified command scope. It first deletes any existing commands in that scope
// to ensure a clean state, then sets the new set of commands.
//
// The scope parameter defines where the commands should be available (e.g., private chats,
// group chats, chat administrators). See tgapi.BotCommandScope and its predefined types.
//
// Returns ErrTooManyCommands if the total number of commands exceeds 100.
// Returns any API error from Telegram (e.g., network issues, invalid scope).
//
// Usage:
//
// privateScope := &tgapi.BotCommandScope{Type: tgapi.BotCommandScopePrivateType}
// if err := bot.AutoGenerateCommandsForScope(privateScope); err != nil {
// log.Fatal(err)
// }
func (bot *Bot[T]) AutoGenerateCommandsForScope(scope *tgapi.BotCommandScope) error {
_, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommandsP{Scope: scope})
if err != nil {
return fmt.Errorf("failed to delete existing commands: %w", err)
}
commands := gatherCommands(bot)
if len(commands) > 100 {
return ErrTooManyCommands
}
_, err = bot.api.SetMyCommands(tgapi.SetMyCommandsP{
Commands: commands,
Scope: scope,
})
if err != nil {
return fmt.Errorf("failed to set commands for scope %q: %w", scope.Type, err)
}
return nil
}

View File

@@ -29,12 +29,16 @@
package laniakea package laniakea
import ( import (
"errors"
"math/rand/v2" "math/rand/v2"
"sync"
"sync/atomic" "sync/atomic"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.nix13.pw/scuroneko/laniakea/tgapi"
) )
var ErrDraftChatIDZero = errors.New("zero draft chat ID")
// draftIdGenerator defines an interface for generating unique draft IDs. // draftIdGenerator defines an interface for generating unique draft IDs.
type draftIdGenerator interface { type draftIdGenerator interface {
// Next returns the next unique draft ID. // Next returns the next unique draft ID.
@@ -68,15 +72,10 @@ func (g *LinearDraftIdGenerator) Next() uint64 {
// DraftProvider is NOT thread-safe. Concurrent access from multiple goroutines // DraftProvider is NOT thread-safe. Concurrent access from multiple goroutines
// requires external synchronization. // requires external synchronization.
type DraftProvider struct { type DraftProvider struct {
mu sync.RWMutex
api *tgapi.API api *tgapi.API
drafts map[uint64]*Draft drafts map[uint64]*Draft
generator draftIdGenerator generator draftIdGenerator
// Internal defaults — not exposed directly to users.
chatID int64
messageThreadID int
parseMode tgapi.ParseMode
entities []tgapi.MessageEntity
} }
// NewRandomDraftProvider creates a new DraftProvider using random draft IDs. // NewRandomDraftProvider creates a new DraftProvider using random draft IDs.
@@ -107,38 +106,12 @@ func NewLinearDraftProvider(api *tgapi.API, startValue uint64) *DraftProvider {
} }
} }
// SetChat sets the target chat and optional message thread for all drafts created
// by this provider. Must be called before NewDraft().
//
// If not set, NewDraft() will create drafts with zero chatID, which will cause
// SendMessageDraft to fail. Use this method to avoid runtime errors.
func (p *DraftProvider) SetChat(chatID int64, messageThreadID int) *DraftProvider {
p.chatID = chatID
p.messageThreadID = messageThreadID
return p
}
// SetParseMode sets the default parse mode for all new drafts.
// Overrides the parse mode passed to NewDraft() only if not specified there.
func (p *DraftProvider) SetParseMode(mode tgapi.ParseMode) *DraftProvider {
p.parseMode = mode
return p
}
// SetEntities sets the default message entities (e.g., bold, links, mentions)
// to be copied into every new draft.
//
// Entities are shallow-copied — if you mutate the slice later, it will affect
// future drafts. For safety, pass a copy if needed.
func (p *DraftProvider) SetEntities(entities []tgapi.MessageEntity) *DraftProvider {
p.entities = entities
return p
}
// GetDraft retrieves a draft by its ID. // GetDraft retrieves a draft by its ID.
// //
// Returns the draft and true if found, or nil and false if not found. // Returns the draft and true if found, or nil and false if not found.
func (p *DraftProvider) GetDraft(id uint64) (*Draft, bool) { func (p *DraftProvider) GetDraft(id uint64) (*Draft, bool) {
p.mu.RLock()
defer p.mu.RUnlock()
draft, ok := p.drafts[id] draft, ok := p.drafts[id]
return draft, ok return draft, ok
} }
@@ -150,8 +123,16 @@ func (p *DraftProvider) GetDraft(id uint64) (*Draft, bool) {
// //
// After successful flush, each draft is removed from the provider and cleared. // After successful flush, each draft is removed from the provider and cleared.
func (p *DraftProvider) FlushAll() error { func (p *DraftProvider) FlushAll() error {
var lastErr error p.mu.Lock()
drafts := make([]*Draft, 0, len(p.drafts))
for _, draft := range p.drafts { for _, draft := range p.drafts {
drafts = append(drafts, draft)
}
p.drafts = make(map[uint64]*Draft)
p.mu.Unlock()
var lastErr error
for _, draft := range drafts {
if err := draft.Flush(); err != nil { if err := draft.Flush(); err != nil {
lastErr = err lastErr = err
break // Stop on first error to avoid partial state break // Stop on first error to avoid partial state
@@ -186,22 +167,17 @@ type Draft struct {
// //
// Panics if chatID is zero — call SetChat() on the provider first. // Panics if chatID is zero — call SetChat() on the provider first.
func (p *DraftProvider) NewDraft(parseMode tgapi.ParseMode) *Draft { func (p *DraftProvider) NewDraft(parseMode tgapi.ParseMode) *Draft {
if p.chatID == 0 {
panic("laniakea: DraftProvider.SetChat() must be called before NewDraft()")
}
id := p.generator.Next() id := p.generator.Next()
draft := &Draft{ draft := &Draft{
api: p.api, api: p.api,
provider: p, provider: p,
chatID: p.chatID,
messageThreadID: p.messageThreadID,
parseMode: parseMode, parseMode: parseMode,
entities: p.entities, // Shallow copy — caller must ensure immutability
ID: id, ID: id,
Message: "", Message: "",
} }
p.mu.Lock()
p.drafts[id] = draft p.drafts[id] = draft
p.mu.Unlock()
return draft return draft
} }
@@ -253,7 +229,9 @@ func (d *Draft) Clear() {
// 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()
delete(d.provider.drafts, d.ID) delete(d.provider.drafts, d.ID)
d.provider.mu.Unlock()
} }
d.Clear() d.Clear()
} }
@@ -295,6 +273,9 @@ func (d *Draft) Flush() error {
// push is the internal helper for Push(). It updates the server draft via SendMessageDraft. // push is the internal helper for Push(). It updates the server draft via SendMessageDraft.
func (d *Draft) push(text string) error { func (d *Draft) push(text string) error {
if d.chatID == 0 {
return ErrDraftChatIDZero
}
d.Message += text d.Message += text
params := tgapi.SendMessageDraftP{ params := tgapi.SendMessageDraftP{
ChatID: d.chatID, ChatID: d.chatID,

View File

@@ -4,6 +4,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"strings" "strings"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.nix13.pw/scuroneko/laniakea/tgapi"
@@ -12,6 +13,12 @@ import (
var ErrInvalidPayloadType = errors.New("invalid payload type") var ErrInvalidPayloadType = errors.New("invalid payload type")
func (bot *Bot[T]) handle(u *tgapi.Update) { func (bot *Bot[T]) handle(u *tgapi.Update) {
defer func() {
if r := recover(); r != nil {
bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r))
}
}()
ctx := &MsgContext{ ctx := &MsgContext{
Update: *u, Api: bot.api, Update: *u, Api: bot.api,
botLogger: bot.logger, botLogger: bot.logger,
@@ -84,7 +91,7 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) {
if !plugin.executeMiddlewares(ctx, bot.dbContext) { if !plugin.executeMiddlewares(ctx, bot.dbContext) {
return return
} }
go plugin.executeCmd(cmd, ctx, bot.dbContext) plugin.executeCmd(cmd, ctx, bot.dbContext)
return return
} }
} }
@@ -113,7 +120,7 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) {
if !plugin.executeMiddlewares(ctx, bot.dbContext) { if !plugin.executeMiddlewares(ctx, bot.dbContext) {
return return
} }
go plugin.executePayload(data.Command, ctx, bot.dbContext) plugin.executePayload(data.Command, ctx, bot.dbContext)
return return
} }
} }

View File

@@ -6,6 +6,38 @@ import (
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.nix13.pw/scuroneko/laniakea/tgapi"
) )
// Updates fetches new updates from Telegram API using long polling.
// It respects the bot's current update offset and automatically advances it
// after successful retrieval. The method supports selective update types
// through AllowedUpdates and includes optional request logging.
//
// Parameters:
// - None (uses bot's internal state for offset and allowed updates)
//
// Returns:
// - []tgapi.Update: slice of received updates (empty if none available)
// - error: any error encountered during the API call
//
// Behavior:
// 1. Uses the bot's current update offset (via GetUpdateOffset)
// 2. Requests updates with 30-second timeout
// 3. Filters updates by types specified in bot.GetUpdateTypes()
// 4. Logs raw update JSON if RequestLogger is configured
// 5. Automatically updates the offset to the last received update ID + 1
// 6. Returns all received updates (empty slice if none)
//
// Note: This is a blocking call that waits up to 30 seconds for new updates.
// For non-blocking behavior, consider using webhooks instead.
//
// Example:
//
// updates, err := bot.Updates()
// if err != nil {
// log.Fatal(err)
// }
// for _, update := range updates {
// // process update
// }
func (bot *Bot[T]) Updates() ([]tgapi.Update, error) { func (bot *Bot[T]) Updates() ([]tgapi.Update, error) {
offset := bot.GetUpdateOffset() offset := bot.GetUpdateOffset()
params := tgapi.UpdateParams{ params := tgapi.UpdateParams{

View File

@@ -22,6 +22,7 @@ package laniakea
import ( import (
"context" "context"
"fmt" "fmt"
"time"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.nix13.pw/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog" "git.nix13.pw/scuroneko/slog"
@@ -32,9 +33,11 @@ import (
// manage inline keyboards and message drafts. // manage inline keyboards and message drafts.
type MsgContext struct { type MsgContext struct {
Api *tgapi.API Api *tgapi.API
Msg *tgapi.Message
Update tgapi.Update Update tgapi.Update
Msg *tgapi.Message
From *tgapi.User From *tgapi.User
CallbackMsgId int CallbackMsgId int
CallbackQueryId string CallbackQueryId string
FromID int FromID int
@@ -385,7 +388,13 @@ func (ctx *MsgContext) error(err error) {
func (ctx *MsgContext) Error(err error) { ctx.error(err) } func (ctx *MsgContext) Error(err error) { ctx.error(err) }
func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft { func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft {
c := context.Background() if ctx.Msg == nil {
ctx.botLogger.Errorln("can't create draft: ctx.Msg is nil")
return nil
}
c, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil { if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil {
ctx.botLogger.Errorln(err) ctx.botLogger.Errorln(err)
return nil return nil

View File

@@ -33,11 +33,14 @@ const (
CommandValueAnyType CommandValueType = "any" CommandValueAnyType CommandValueType = "any"
) )
// CommandRegexInt matches one or more digits. var (
var CommandRegexInt = regexp.MustCompile(`\d+`) // CommandRegexInt matches one or more digits.
CommandRegexInt = regexp.MustCompile(`\d+`)
// CommandRegexString matches any non-empty string. // CommandRegexString matches any non-empty string.
var CommandRegexString = regexp.MustCompile(".+") CommandRegexString = regexp.MustCompile(`.+`)
// CommandRegexBool matches true or false
CommandRegexBool = regexp.MustCompile(`true|false`)
)
// ErrCmdArgCountMismatch is returned when the number of provided arguments // ErrCmdArgCountMismatch is returned when the number of provided arguments
// is less than the number of required arguments. // is less than the number of required arguments.
@@ -58,15 +61,22 @@ type CommandArg struct {
// NewCommandArg creates a new CommandArg with the given text and type. // NewCommandArg creates a new CommandArg with the given text and type.
// Uses a default regex based on the type (string or int). // Uses a default regex based on the type (string or int).
// For CommandValueAnyType, no validation is performed. // For CommandValueAnyType, no validation is performed.
func NewCommandArg(text string, valueType CommandValueType) *CommandArg { func NewCommandArg(text string) *CommandArg {
return &CommandArg{CommandValueAnyType, text, CommandRegexString, false}
}
func (c *CommandArg) SetValueType(t CommandValueType) *CommandArg {
regex := CommandRegexString regex := CommandRegexString
switch valueType { switch t {
case CommandValueIntType: case CommandValueIntType:
regex = CommandRegexInt regex = CommandRegexInt
case CommandValueBoolType:
regex = CommandRegexBool
case CommandValueAnyType: case CommandValueAnyType:
regex = nil // Skip validation regex = nil // Skip validation
} }
return &CommandArg{valueType, text, regex, false} c.regex = regex
return c
} }
// SetRequired marks this argument as required. // SetRequired marks this argument as required.
@@ -320,7 +330,10 @@ func (m *Middleware[T]) SetAsync(async bool) *Middleware[T] {
// Otherwise, returns the result of the executor. // Otherwise, returns the result of the executor.
func (m *Middleware[T]) Execute(ctx *MsgContext, db *T) bool { func (m *Middleware[T]) Execute(ctx *MsgContext, db *T) bool {
if m.async { if m.async {
go m.executor(ctx, db) ctx := *ctx // copy context to avoid race condition
go func(ctx MsgContext) {
m.executor(&ctx, db)
}(ctx)
return true return true
} }
return m.executor(ctx, db) return m.executor(ctx, db)

View File

@@ -11,6 +11,7 @@
package laniakea package laniakea
import ( import (
"context"
"time" "time"
) )
@@ -83,7 +84,7 @@ func (r *Runner[T]) Timeout(timeout time.Duration) *Runner[T] {
return r return r
} }
// ExecRunners executes all runners registered on the Bot. // ExecRunners executes all runners registered on the Bot with context-based lifecycle management.
// //
// It logs warnings for misconfigured runners: // It logs warnings for misconfigured runners:
// - Sync, non-onetime runners are skipped (invalid configuration). // - Sync, non-onetime runners are skipped (invalid configuration).
@@ -92,11 +93,13 @@ func (r *Runner[T]) Timeout(timeout time.Duration) *Runner[T] {
// Execution logic: // Execution logic:
// - onetime + async: Runs once in a goroutine. // - onetime + async: Runs once in a goroutine.
// - onetime + sync: Runs once synchronously; warns if slower than 2 seconds. // - onetime + sync: Runs once synchronously; warns if slower than 2 seconds.
// - !onetime + async: Runs in an infinite loop with timeout between iterations. // - !onetime + async: Runs in a loop with timeout between iterations until ctx.Done().
// - !onetime + sync: Skipped with warning. // - !onetime + sync: Skipped with warning.
// //
// This method is typically called once during bot startup. // Background runners listen for ctx.Done() and gracefully shut down when the context is canceled.
func (bot *Bot[T]) ExecRunners() { //
// This method is typically called once during bot startup in RunWithContext.
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
@@ -128,14 +131,20 @@ func (bot *Bot[T]) ExecRunners() {
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.onetime && runner.async { } else if !runner.onetime && runner.async {
// Background loop: periodic execution // Background loop: periodic execution with graceful shutdown
go func(r Runner[T]) { go func(r Runner[T]) {
ticker := time.NewTicker(r.timeout)
defer ticker.Stop()
for { for {
select {
case <-ctx.Done():
return
case <-ticker.C:
err := r.fn(bot) err := r.fn(bot)
if err != nil { if err != nil {
bot.logger.Warnf("Runner %s failed: %s\n", r.name, err) bot.logger.Warnf("Runner %s failed: %s\n", r.name, err)
} }
time.Sleep(r.timeout) }
} }
}(runner) }(runner)
} }

View File

@@ -193,8 +193,10 @@ func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) erro
// getChatLimiter returns the rate limiter for the given chat, creating it if needed. // getChatLimiter returns the rate limiter for the given chat, creating it if needed.
// Uses 1 request per second with burst of 1 — conservative for per-user limits. // Uses 1 request per second with burst of 1 — conservative for per-user limits.
// Must be called with rl.chatMu held.
func (rl *RateLimiter) getChatLimiter(chatID int64) *rate.Limiter { func (rl *RateLimiter) getChatLimiter(chatID int64) *rate.Limiter {
rl.chatMu.Lock()
defer rl.chatMu.Unlock()
if lim, ok := rl.chatLimiters[chatID]; ok { if lim, ok := rl.chatLimiters[chatID]; ok {
return lim return lim
} }

View File

@@ -1,9 +1,9 @@
package utils package utils
const ( const (
VersionString = "1.0.0-beta.17" VersionString = "1.0.0-beta.19"
VersionMajor = 1 VersionMajor = 1
VersionMinor = 0 VersionMinor = 0
VersionPatch = 0 VersionPatch = 0
VersionBeta = 17 VersionBeta = 19
) )