diff --git a/cmd/bot/main.go b/cmd/bot/main.go index 3d5872f..38d6d4c 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -28,12 +28,15 @@ func main() { // Initialize logger var log *logger.Logger + var logf *os.File if cfg.LogFile != "" { - logf, err := os.OpenFile(cfg.LogFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) + var err error + logf, err = os.OpenFile(cfg.LogFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) if err != nil { fmt.Fprintf(os.Stderr, "[ERROR] Failed to open log file: %s\n", err) os.Exit(1) } + defer logf.Close() log = logger.New(logf) } else { log = logger.NewStdout() diff --git a/internal/bot/bot.go b/internal/bot/bot.go index 9d09596..5616efb 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -74,6 +74,127 @@ func (b *Bot) safeHandler(handler func()) { handler() } +// commandHandler represents a handler function type +type commandHandler func(update tgbotapi.Update, tokens []string) + +// getCommandHandler returns the appropriate handler for a command +func (b *Bot) getCommandHandler(command string, update tgbotapi.Update, tokens []string) func() { + // Map of command aliases to handlers + commandMap := map[string]func(update tgbotapi.Update, tokens []string){ + "list": b.handleList, + "/list": b.handleList, + "li": b.handleList, + "/li": b.handleList, + "/ls": b.handleList, + "ls": b.handleList, + "head": b.handleHead, + "/head": b.handleHead, + "he": b.handleHead, + "/he": b.handleHead, + "tail": b.handleTail, + "/tail": b.handleTail, + "ta": b.handleTail, + "/ta": b.handleTail, + "downs": func(u tgbotapi.Update, _ []string) { b.handleDowns(u) }, + "/downs": func(u tgbotapi.Update, _ []string) { b.handleDowns(u) }, + "dg": func(u tgbotapi.Update, _ []string) { b.handleDowns(u) }, + "/dg": func(u tgbotapi.Update, _ []string) { b.handleDowns(u) }, + "seeding": func(u tgbotapi.Update, _ []string) { b.handleSeeding(u) }, + "/seeding": func(u tgbotapi.Update, _ []string) { b.handleSeeding(u) }, + "sd": func(u tgbotapi.Update, _ []string) { b.handleSeeding(u) }, + "/sd": func(u tgbotapi.Update, _ []string) { b.handleSeeding(u) }, + "paused": func(u tgbotapi.Update, _ []string) { b.handlePaused(u) }, + "/paused": func(u tgbotapi.Update, _ []string) { b.handlePaused(u) }, + "pa": func(u tgbotapi.Update, _ []string) { b.handlePaused(u) }, + "/pa": func(u tgbotapi.Update, _ []string) { b.handlePaused(u) }, + "checking": func(u tgbotapi.Update, _ []string) { b.handleChecking(u) }, + "/checking": func(u tgbotapi.Update, _ []string) { b.handleChecking(u) }, + "ch": func(u tgbotapi.Update, _ []string) { b.handleChecking(u) }, + "/ch": func(u tgbotapi.Update, _ []string) { b.handleChecking(u) }, + "active": func(u tgbotapi.Update, _ []string) { b.handleActive(u) }, + "/active": func(u tgbotapi.Update, _ []string) { b.handleActive(u) }, + "ac": func(u tgbotapi.Update, _ []string) { b.handleActive(u) }, + "/ac": func(u tgbotapi.Update, _ []string) { b.handleActive(u) }, + "errors": func(u tgbotapi.Update, _ []string) { b.handleErrors(u) }, + "/errors": func(u tgbotapi.Update, _ []string) { b.handleErrors(u) }, + "er": func(u tgbotapi.Update, _ []string) { b.handleErrors(u) }, + "/er": func(u tgbotapi.Update, _ []string) { b.handleErrors(u) }, + "sort": b.handleSort, + "/sort": b.handleSort, + "so": b.handleSort, + "/so": b.handleSort, + "trackers": func(u tgbotapi.Update, _ []string) { b.handleTrackers(u) }, + "/trackers": func(u tgbotapi.Update, _ []string) { b.handleTrackers(u) }, + "tr": func(u tgbotapi.Update, _ []string) { b.handleTrackers(u) }, + "/tr": func(u tgbotapi.Update, _ []string) { b.handleTrackers(u) }, + "downloaddir": b.handleDownloadDir, + "dd": b.handleDownloadDir, + "add": b.handleAdd, + "/add": b.handleAdd, + "ad": b.handleAdd, + "/ad": b.handleAdd, + "search": b.handleSearch, + "/search": b.handleSearch, + "se": b.handleSearch, + "/se": b.handleSearch, + "latest": b.handleLatest, + "/latest": b.handleLatest, + "la": b.handleLatest, + "/la": b.handleLatest, + "info": b.handleInfo, + "/info": b.handleInfo, + "in": b.handleInfo, + "/in": b.handleInfo, + "stop": b.handleStop, + "/stop": b.handleStop, + "sp": b.handleStop, + "/sp": b.handleStop, + "start": b.handleStart, + "/start": b.handleStart, + "st": b.handleStart, + "/st": b.handleStart, + "check": b.handleCheck, + "/check": b.handleCheck, + "ck": b.handleCheck, + "/ck": b.handleCheck, + "stats": func(u tgbotapi.Update, _ []string) { b.handleStats(u) }, + "/stats": func(u tgbotapi.Update, _ []string) { b.handleStats(u) }, + "sa": func(u tgbotapi.Update, _ []string) { b.handleStats(u) }, + "/sa": func(u tgbotapi.Update, _ []string) { b.handleStats(u) }, + "downlimit": b.handleDownLimit, + "dl": b.handleDownLimit, + "uplimit": b.handleUpLimit, + "ul": b.handleUpLimit, + "speed": func(u tgbotapi.Update, _ []string) { b.handleSpeed(u) }, + "/speed": func(u tgbotapi.Update, _ []string) { b.handleSpeed(u) }, + "ss": func(u tgbotapi.Update, _ []string) { b.handleSpeed(u) }, + "/ss": func(u tgbotapi.Update, _ []string) { b.handleSpeed(u) }, + "count": func(u tgbotapi.Update, _ []string) { b.handleCount(u) }, + "/count": func(u tgbotapi.Update, _ []string) { b.handleCount(u) }, + "co": func(u tgbotapi.Update, _ []string) { b.handleCount(u) }, + "/co": func(u tgbotapi.Update, _ []string) { b.handleCount(u) }, + "del": b.handleDel, + "/del": b.handleDel, + "rm": b.handleDel, + "/rm": b.handleDel, + "deldata": b.handleDelData, + "/deldata": b.handleDelData, + "help": func(u tgbotapi.Update, _ []string) { b.sendMessage(u.Message.Chat.ID, HelpText, true) }, + "/help": func(u tgbotapi.Update, _ []string) { b.sendMessage(u.Message.Chat.ID, HelpText, true) }, + "version": func(u tgbotapi.Update, _ []string) { b.handleVersion(u) }, + "/version": func(u tgbotapi.Update, _ []string) { b.handleVersion(u) }, + "ver": func(u tgbotapi.Update, _ []string) { b.handleVersion(u) }, + "/ver": func(u tgbotapi.Update, _ []string) { b.handleVersion(u) }, + "": func(u tgbotapi.Update, _ []string) { b.handleReceiveTorrent(u) }, + } + + if handler, ok := commandMap[command]; ok { + return func() { handler(update, tokens) } + } + + return nil +} + // handleUpdate processes a Telegram update func (b *Bot) handleUpdate(update tgbotapi.Update) { if update.Message == nil { @@ -112,67 +233,11 @@ func (b *Bot) handleUpdate(update tgbotapi.Update) { command := strings.ToLower(tokens[0]) // Route to appropriate handler with panic recovery - switch command { - case "list", "/list", "li", "/li", "/ls", "ls": - go b.safeHandler(func() { b.handleList(update, tokens[1:]) }) - case "head", "/head", "he", "/he": - go b.safeHandler(func() { b.handleHead(update, tokens[1:]) }) - case "tail", "/tail", "ta", "/ta": - go b.safeHandler(func() { b.handleTail(update, tokens[1:]) }) - case "downs", "/downs", "dg", "/dg": - go b.safeHandler(func() { b.handleDowns(update) }) - case "seeding", "/seeding", "sd", "/sd": - go b.safeHandler(func() { b.handleSeeding(update) }) - case "paused", "/paused", "pa", "/pa": - go b.safeHandler(func() { b.handlePaused(update) }) - case "checking", "/checking", "ch", "/ch": - go b.safeHandler(func() { b.handleChecking(update) }) - case "active", "/active", "ac", "/ac": - go b.safeHandler(func() { b.handleActive(update) }) - case "errors", "/errors", "er", "/er": - go b.safeHandler(func() { b.handleErrors(update) }) - case "sort", "/sort", "so", "/so": - go b.safeHandler(func() { b.handleSort(update, tokens[1:]) }) - case "trackers", "/trackers", "tr", "/tr": - go b.safeHandler(func() { b.handleTrackers(update) }) - case "downloaddir", "dd": - go b.safeHandler(func() { b.handleDownloadDir(update, tokens[1:]) }) - case "add", "/add", "ad", "/ad": - go b.safeHandler(func() { b.handleAdd(update, tokens[1:]) }) - case "search", "/search", "se", "/se": - go b.safeHandler(func() { b.handleSearch(update, tokens[1:]) }) - case "latest", "/latest", "la", "/la": - go b.safeHandler(func() { b.handleLatest(update, tokens[1:]) }) - case "info", "/info", "in", "/in": - go b.safeHandler(func() { b.handleInfo(update, tokens[1:]) }) - case "stop", "/stop", "sp", "/sp": - go b.safeHandler(func() { b.handleStop(update, tokens[1:]) }) - case "start", "/start", "st", "/st": - go b.safeHandler(func() { b.handleStart(update, tokens[1:]) }) - case "check", "/check", "ck", "/ck": - go b.safeHandler(func() { b.handleCheck(update, tokens[1:]) }) - case "stats", "/stats", "sa", "/sa": - go b.safeHandler(func() { b.handleStats(update) }) - case "downlimit", "dl": - go b.safeHandler(func() { b.handleDownLimit(update, tokens[1:]) }) - case "uplimit", "ul": - go b.safeHandler(func() { b.handleUpLimit(update, tokens[1:]) }) - case "speed", "/speed", "ss", "/ss": - go b.safeHandler(func() { b.handleSpeed(update) }) - case "count", "/count", "co", "/co": - go b.safeHandler(func() { b.handleCount(update) }) - case "del", "/del", "rm", "/rm": - go b.safeHandler(func() { b.handleDel(update, tokens[1:]) }) - case "deldata", "/deldata": - go b.safeHandler(func() { b.handleDelData(update, tokens[1:]) }) - case "help", "/help": - go b.safeHandler(func() { b.sendMessage(update.Message.Chat.ID, HelpText, true) }) - case "version", "/version", "ver", "/ver": - go b.safeHandler(func() { b.handleVersion(update) }) - case "": - // might be a file received - go b.safeHandler(func() { b.handleReceiveTorrent(update) }) - default: + // Use map for cleaner command routing + handler := b.getCommandHandler(command, update, tokens[1:]) + if handler != nil { + go b.safeHandler(handler) + } else { // no such command go b.safeHandler(func() { b.sendMessage(update.Message.Chat.ID, "No such command, try /help", false) }) } diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index e2bafe8..651ad0c 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -2,6 +2,7 @@ package bot import ( "bytes" + "context" "fmt" "regexp" "strconv" @@ -60,21 +61,53 @@ func putBuffer(buf *bytes.Buffer) { // rateLimitWait waits for the next available API call slot // This function is defined in helpers.go to access the rate limiter +// handleClientError sends an error message to the user and logs it +func (b *Bot) handleClientError(chatID int64, prefix string, err error) { + if err != nil { + b.logger.Printf("[ERROR] %s: %v", prefix, err) + b.sendMessage(chatID, prefix+": "+err.Error(), false) + } +} + +// handleTorrentList is a generic handler for listing torrents with filtering and formatting +func (b *Bot) handleTorrentList(update tgbotapi.Update, errorPrefix, emptyMessage string, filter func(*transmission.Torrent) bool, format func(*transmission.Torrent) string) { + torrents, err := b.client.GetTorrents() + if err != nil { + b.handleClientError(update.Message.Chat.ID, errorPrefix, err) + return + } + + buf := getBuffer() + defer putBuffer(buf) + for i := range torrents { + if filter(&torrents[i]) { + buf.WriteString(format(&torrents[i])) + } + } + + if buf.Len() == 0 { + b.sendMessage(update.Message.Chat.ID, emptyMessage, false) + return + } + b.sendMessage(update.Message.Chat.ID, buf.String(), false) +} + // Handler methods for bot commands - these are placeholders that need full implementation // For now, they provide basic functionality func (b *Bot) handleList(update tgbotapi.Update, tokens []string) { torrents, err := b.client.GetTorrents() if err != nil { - b.sendMessage(update.Message.Chat.ID, "*list:* "+err.Error(), false) + b.handleClientError(update.Message.Chat.ID, "*list*", err) return } - buf := new(bytes.Buffer) + buf := getBuffer() + defer putBuffer(buf) if len(tokens) != 0 { regx, err := getCompiledRegex(tokens[0]) if err != nil { - b.sendMessage(update.Message.Chat.ID, "*list:* "+err.Error(), false) + b.handleClientError(update.Message.Chat.ID, "*list*", err) return } @@ -111,7 +144,7 @@ func (b *Bot) handleHead(update tgbotapi.Update, tokens []string) { torrents, err := b.client.GetTorrents() if err != nil { - b.sendMessage(update.Message.Chat.ID, "*head:* "+err.Error(), false) + b.handleClientError(update.Message.Chat.ID, "*head*", err) return } @@ -119,7 +152,8 @@ func (b *Bot) handleHead(update tgbotapi.Update, tokens []string) { n = len(torrents) } - buf := new(bytes.Buffer) + buf := getBuffer() + defer putBuffer(buf) for i := range torrents[:n] { buf.WriteString(formatter.FormatTorrentDetailed(&torrents[i])) } @@ -131,7 +165,9 @@ func (b *Bot) handleHead(update tgbotapi.Update, tokens []string) { msgID := b.sendMessage(update.Message.Chat.ID, buf.String(), true) if !b.cfg.NoLive { - b.liveUpdateTorrents(update.Message.Chat.ID, msgID, func(torrents transmission.Torrents) transmission.Torrents { + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(b.cfg.Duration)*b.cfg.Interval+time.Second) + defer cancel() + b.liveUpdateTorrents(ctx, update.Message.Chat.ID, msgID, func(torrents transmission.Torrents) transmission.Torrents { if n > len(torrents) { return torrents[:len(torrents)] } @@ -150,7 +186,7 @@ func (b *Bot) handleTail(update tgbotapi.Update, tokens []string) { torrents, err := b.client.GetTorrents() if err != nil { - b.sendMessage(update.Message.Chat.ID, "*tail:* "+err.Error(), false) + b.handleClientError(update.Message.Chat.ID, "*tail*", err) return } @@ -158,7 +194,8 @@ func (b *Bot) handleTail(update tgbotapi.Update, tokens []string) { n = len(torrents) } - buf := new(bytes.Buffer) + buf := getBuffer() + defer putBuffer(buf) for _, torrent := range torrents[len(torrents)-n:] { buf.WriteString(formatter.FormatTorrentDetailed(&torrent)) } @@ -170,7 +207,9 @@ func (b *Bot) handleTail(update tgbotapi.Update, tokens []string) { msgID := b.sendMessage(update.Message.Chat.ID, buf.String(), true) if !b.cfg.NoLive { - b.liveUpdateTorrents(update.Message.Chat.ID, msgID, func(torrents transmission.Torrents) transmission.Torrents { + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(b.cfg.Duration)*b.cfg.Interval+time.Second) + defer cancel() + b.liveUpdateTorrents(ctx, update.Message.Chat.ID, msgID, func(torrents transmission.Torrents) transmission.Torrents { if n > len(torrents) { return torrents } @@ -180,100 +219,57 @@ func (b *Bot) handleTail(update tgbotapi.Update, tokens []string) { } func (b *Bot) handleDowns(update tgbotapi.Update) { - torrents, err := b.client.GetTorrents() - if err != nil { - b.sendMessage(update.Message.Chat.ID, "*downs:* "+err.Error(), false) - return - } - - buf := new(bytes.Buffer) - for i := range torrents { - if torrents[i].Status == transmission.StatusDownloading || - torrents[i].Status == transmission.StatusDownloadPending { - buf.WriteString(formatter.FormatTorrentShort(&torrents[i]) + "\n") - } - } - - if buf.Len() == 0 { - b.sendMessage(update.Message.Chat.ID, "No downloads", false) - return - } - b.sendMessage(update.Message.Chat.ID, buf.String(), false) + b.handleTorrentList(update, "*downs*", "No downloads", + func(t *transmission.Torrent) bool { + return t.Status == transmission.StatusDownloading || + t.Status == transmission.StatusDownloadPending + }, + func(t *transmission.Torrent) string { + return formatter.FormatTorrentShort(t) + "\n" + }) } func (b *Bot) handleSeeding(update tgbotapi.Update) { - torrents, err := b.client.GetTorrents() - if err != nil { - b.sendMessage(update.Message.Chat.ID, "*seeding:* "+err.Error(), false) - return - } - - buf := new(bytes.Buffer) - for i := range torrents { - if torrents[i].Status == transmission.StatusSeeding || - torrents[i].Status == transmission.StatusSeedPending { - buf.WriteString(formatter.FormatTorrentShort(&torrents[i]) + "\n") - } - } - - if buf.Len() == 0 { - b.sendMessage(update.Message.Chat.ID, "No torrents seeding", false) - return - } - b.sendMessage(update.Message.Chat.ID, buf.String(), false) + b.handleTorrentList(update, "*seeding*", "No torrents seeding", + func(t *transmission.Torrent) bool { + return t.Status == transmission.StatusSeeding || + t.Status == transmission.StatusSeedPending + }, + func(t *transmission.Torrent) string { + return formatter.FormatTorrentShort(t) + "\n" + }) } func (b *Bot) handlePaused(update tgbotapi.Update) { - torrents, err := b.client.GetTorrents() - if err != nil { - b.sendMessage(update.Message.Chat.ID, "*paused:* "+err.Error(), false) - return - } - - buf := new(bytes.Buffer) - for i := range torrents { - if torrents[i].Status == transmission.StatusStopped { - buf.WriteString(formatter.FormatTorrentPaused(&torrents[i])) - } - } - - if buf.Len() == 0 { - b.sendMessage(update.Message.Chat.ID, "No paused torrents", false) - return - } - b.sendMessage(update.Message.Chat.ID, buf.String(), false) + b.handleTorrentList(update, "*paused*", "No paused torrents", + func(t *transmission.Torrent) bool { + return t.Status == transmission.StatusStopped + }, + func(t *transmission.Torrent) string { + return formatter.FormatTorrentPaused(t) + }) } func (b *Bot) handleChecking(update tgbotapi.Update) { - torrents, err := b.client.GetTorrents() - if err != nil { - b.sendMessage(update.Message.Chat.ID, "*checking:* "+err.Error(), false) - return - } - - buf := new(bytes.Buffer) - for i := range torrents { - if torrents[i].Status == transmission.StatusChecking || - torrents[i].Status == transmission.StatusCheckPending { - buf.WriteString(formatter.FormatTorrentChecking(&torrents[i])) - } - } - - if buf.Len() == 0 { - b.sendMessage(update.Message.Chat.ID, "No torrents verifying", false) - return - } - b.sendMessage(update.Message.Chat.ID, buf.String(), false) + b.handleTorrentList(update, "*checking*", "No torrents verifying", + func(t *transmission.Torrent) bool { + return t.Status == transmission.StatusChecking || + t.Status == transmission.StatusCheckPending + }, + func(t *transmission.Torrent) string { + return formatter.FormatTorrentChecking(t) + }) } func (b *Bot) handleActive(update tgbotapi.Update) { torrents, err := b.client.GetTorrents() if err != nil { - b.sendMessage(update.Message.Chat.ID, "*active:* "+err.Error(), false) + b.handleClientError(update.Message.Chat.ID, "*active*", err) return } - buf := new(bytes.Buffer) + buf := getBuffer() + defer putBuffer(buf) for i := range torrents { if torrents[i].RateDownload > 0 || torrents[i].RateUpload > 0 { buf.WriteString(formatter.FormatTorrentDetailed(&torrents[i])) @@ -287,29 +283,20 @@ func (b *Bot) handleActive(update tgbotapi.Update) { msgID := b.sendMessage(update.Message.Chat.ID, buf.String(), true) if !b.cfg.NoLive { - b.liveUpdateActive(update.Message.Chat.ID, msgID) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(b.cfg.Duration)*b.cfg.Interval+time.Second) + defer cancel() + b.liveUpdateActive(ctx, update.Message.Chat.ID, msgID) } } func (b *Bot) handleErrors(update tgbotapi.Update) { - torrents, err := b.client.GetTorrents() - if err != nil { - b.sendMessage(update.Message.Chat.ID, "*errors:* "+err.Error(), false) - return - } - - buf := new(bytes.Buffer) - for i := range torrents { - if torrents[i].Error != 0 { - buf.WriteString(fmt.Sprintf("<%d> %s\n%s\n", torrents[i].ID, torrents[i].Name, torrents[i].ErrorString)) - } - } - - if buf.Len() == 0 { - b.sendMessage(update.Message.Chat.ID, "No errors", false) - return - } - b.sendMessage(update.Message.Chat.ID, buf.String(), false) + b.handleTorrentList(update, "*errors*", "No errors", + func(t *transmission.Torrent) bool { + return t.Error != 0 + }, + func(t *transmission.Torrent) string { + return fmt.Sprintf("<%d> %s\n%s\n", t.ID, t.Name, t.ErrorString) + }) } func (b *Bot) handleSort(update tgbotapi.Update, tokens []string) { @@ -359,7 +346,7 @@ func (b *Bot) handleSort(update tgbotapi.Update, tokens []string) { func (b *Bot) handleTrackers(update tgbotapi.Update) { torrents, err := b.client.GetTorrents() if err != nil { - b.sendMessage(update.Message.Chat.ID, "*trackers:* "+err.Error(), false) + b.handleClientError(update.Message.Chat.ID, "*trackers*", err) return } @@ -374,7 +361,8 @@ func (b *Bot) handleTrackers(update tgbotapi.Update) { } } - buf := new(bytes.Buffer) + buf := getBuffer() + defer putBuffer(buf) for k, v := range trackers { buf.WriteString(fmt.Sprintf("%d - %s\n", v, k)) } @@ -397,7 +385,7 @@ func (b *Bot) handleDownloadDir(update tgbotapi.Update, tokens []string) { out, err := b.client.ExecuteCommand(cmd) if err != nil { - b.sendMessage(update.Message.Chat.ID, "*downloaddir:* "+err.Error(), false) + b.handleClientError(update.Message.Chat.ID, "*downloaddir*", err) return } if out.Result != "success" { @@ -437,19 +425,20 @@ func (b *Bot) handleSearch(update tgbotapi.Update, tokens []string) { } query := strings.Join(tokens, " ") - regx, err := getCompiledRegex(query) - if err != nil { - b.sendMessage(update.Message.Chat.ID, "*search:* "+err.Error(), false) - return - } + regx, err := getCompiledRegex(query) + if err != nil { + b.handleClientError(update.Message.Chat.ID, "*search*", err) + return + } - torrents, err := b.client.GetTorrents() - if err != nil { - b.sendMessage(update.Message.Chat.ID, "*search:* "+err.Error(), false) - return - } + torrents, err := b.client.GetTorrents() + if err != nil { + b.handleClientError(update.Message.Chat.ID, "*search*", err) + return + } - buf := new(bytes.Buffer) + buf := getBuffer() + defer putBuffer(buf) for i := range torrents { if regx.MatchString(torrents[i].Name) { buf.WriteString(formatter.FormatTorrentShort(&torrents[i]) + "\n") @@ -473,7 +462,7 @@ func (b *Bot) handleLatest(update tgbotapi.Update, tokens []string) { torrents, err := b.client.GetTorrents() if err != nil { - b.sendMessage(update.Message.Chat.ID, "*latest:* "+err.Error(), false) + b.handleClientError(update.Message.Chat.ID, "*latest*", err) return } @@ -482,7 +471,8 @@ func (b *Bot) handleLatest(update tgbotapi.Update, tokens []string) { } torrents.SortAge(true) - buf := new(bytes.Buffer) + buf := getBuffer() + defer putBuffer(buf) for i := range torrents[:n] { buf.WriteString(formatter.FormatTorrentShort(&torrents[i]) + "\n") } @@ -518,7 +508,9 @@ func (b *Bot) handleInfo(update tgbotapi.Update, tokens []string) { msgID := b.sendMessage(update.Message.Chat.ID, info, true) if !b.cfg.NoLive { - b.liveUpdateInfo(update.Message.Chat.ID, msgID, torrentID, trackers) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(b.cfg.Duration)*b.cfg.Interval+time.Second) + defer cancel() + b.liveUpdateInfo(ctx, update.Message.Chat.ID, msgID, torrentID, trackers) } } } @@ -634,7 +626,7 @@ func (b *Bot) handleCheck(update tgbotapi.Update, tokens []string) { func (b *Bot) handleStats(update tgbotapi.Update) { stats, err := b.client.GetStats() if err != nil { - b.sendMessage(update.Message.Chat.ID, "*stats:* "+err.Error(), false) + b.handleClientError(update.Message.Chat.ID, "*stats*", err) return } @@ -697,7 +689,7 @@ func (b *Bot) handleSpeedLimit(update tgbotapi.Update, tokens []string, limitTyp out, err := b.client.ExecuteCommand(speedLimitCmd) if err != nil { - b.sendMessage(update.Message.Chat.ID, fmt.Sprintf("*%s:* %v", limitType, err.Error()), false) + b.handleClientError(update.Message.Chat.ID, fmt.Sprintf("*%s*", limitType), err) return } if out.Result != "success" { @@ -711,7 +703,7 @@ func (b *Bot) handleSpeedLimit(update tgbotapi.Update, tokens []string, limitTyp func (b *Bot) handleSpeed(update tgbotapi.Update) { stats, err := b.client.GetStats() if err != nil { - b.sendMessage(update.Message.Chat.ID, "*speed:* "+err.Error(), false) + b.handleClientError(update.Message.Chat.ID, "*speed*", err) return } @@ -719,14 +711,16 @@ func (b *Bot) handleSpeed(update tgbotapi.Update) { msgID := b.sendMessage(update.Message.Chat.ID, msg, false) if !b.cfg.NoLive { - b.liveUpdateSpeed(update.Message.Chat.ID, msgID) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(b.cfg.Duration)*b.cfg.Interval+time.Second) + defer cancel() + b.liveUpdateSpeed(ctx, update.Message.Chat.ID, msgID) } } func (b *Bot) handleCount(update tgbotapi.Update) { torrents, err := b.client.GetTorrents() if err != nil { - b.sendMessage(update.Message.Chat.ID, "*count:* "+err.Error(), false) + b.handleClientError(update.Message.Chat.ID, "*count*", err) return } @@ -824,9 +818,14 @@ func (b *Bot) handleReceiveTorrent(update tgbotapi.Update) { } // Live update helpers -func (b *Bot) liveUpdateTorrents(chatID int64, msgID int, filter func(transmission.Torrents) transmission.Torrents, formatter func(*transmission.Torrent) string) { +func (b *Bot) liveUpdateTorrents(ctx context.Context, chatID int64, msgID int, filter func(transmission.Torrents) transmission.Torrents, formatter func(*transmission.Torrent) string) { for i := 0; i < b.cfg.Duration; i++ { - time.Sleep(b.cfg.Interval) + select { + case <-ctx.Done(): + return + case <-time.After(b.cfg.Interval): + } + torrents, err := b.client.GetTorrents() if err != nil { continue @@ -855,9 +854,14 @@ func (b *Bot) liveUpdateTorrents(chatID int64, msgID int, filter func(transmissi } } -func (b *Bot) liveUpdateActive(chatID int64, msgID int) { +func (b *Bot) liveUpdateActive(ctx context.Context, chatID int64, msgID int) { for i := 0; i < b.cfg.Duration; i++ { - time.Sleep(b.cfg.Interval) + select { + case <-ctx.Done(): + return + case <-time.After(b.cfg.Interval): + } + torrents, err := b.client.GetTorrents() if err != nil { continue @@ -882,7 +886,12 @@ func (b *Bot) liveUpdateActive(chatID int64, msgID int) { putBuffer(buf) } - time.Sleep(b.cfg.Interval) + select { + case <-ctx.Done(): + return + case <-time.After(b.cfg.Interval): + } + torrents, _ := b.client.GetTorrents() buf := getBuffer() defer putBuffer(buf) @@ -902,9 +911,14 @@ func (b *Bot) liveUpdateActive(chatID int64, msgID int) { } } -func (b *Bot) liveUpdateInfo(chatID int64, msgID int, torrentID int, trackers string) { +func (b *Bot) liveUpdateInfo(ctx context.Context, chatID int64, msgID int, torrentID int, trackers string) { for i := 0; i < b.cfg.Duration; i++ { - time.Sleep(b.cfg.Interval) + select { + case <-ctx.Done(): + return + case <-time.After(b.cfg.Interval): + } + torrent, err := b.client.GetTorrent(torrentID) if err != nil { continue @@ -921,7 +935,12 @@ func (b *Bot) liveUpdateInfo(chatID int64, msgID int, torrentID int, trackers st } } - time.Sleep(b.cfg.Interval) + select { + case <-ctx.Done(): + return + case <-time.After(b.cfg.Interval): + } + torrent, _ := b.client.GetTorrent(torrentID) info := formatter.FormatTorrentInfoStopped(torrent, trackers) // Rate limit before sending @@ -933,9 +952,14 @@ func (b *Bot) liveUpdateInfo(chatID int64, msgID int, torrentID int, trackers st } } -func (b *Bot) liveUpdateSpeed(chatID int64, msgID int) { +func (b *Bot) liveUpdateSpeed(ctx context.Context, chatID int64, msgID int) { for i := 0; i < b.cfg.Duration; i++ { - time.Sleep(b.cfg.Interval) + select { + case <-ctx.Done(): + return + case <-time.After(b.cfg.Interval): + } + stats, err := b.client.GetStats() if err != nil { continue @@ -949,10 +973,14 @@ func (b *Bot) liveUpdateSpeed(chatID int64, msgID int) { b.logger.Printf("[ERROR] Failed to update message: %s", err) return } - time.Sleep(b.cfg.Interval) } - time.Sleep(b.cfg.Interval) + select { + case <-ctx.Done(): + return + case <-time.After(b.cfg.Interval): + } + // Rate limit before sending rateLimitWait() editConf := tgbotapi.NewEditMessageText(chatID, msgID, "↓ - ↑ -") diff --git a/internal/bot/helpers.go b/internal/bot/helpers.go index 91574df..0358f5e 100644 --- a/internal/bot/helpers.go +++ b/internal/bot/helpers.go @@ -14,23 +14,37 @@ import ( ) // rateLimiter limits API calls to Telegram (30 messages per second) +// Uses a channel-based approach to avoid mutex contention type rateLimiter struct { - ticker *time.Ticker - mu sync.Mutex + ch chan struct{} } var ( // telegramRateLimiter limits API calls to 30 per second - telegramRateLimiter = &rateLimiter{ - ticker: time.NewTicker(time.Second / 30), // 30 calls per second - } + telegramRateLimiter = func() *rateLimiter { + rl := &rateLimiter{ + ch: make(chan struct{}, 1), + } + // Start the ticker goroutine + go func() { + ticker := time.NewTicker(time.Second / 30) // 30 calls per second + defer ticker.Stop() + for range ticker.C { + // Try to send a token, but don't block if channel is full + select { + case rl.ch <- struct{}{}: + default: + // Channel is full, skip this tick + } + } + }() + return rl + }() ) // wait waits for the next available slot func (rl *rateLimiter) wait() { - rl.mu.Lock() - defer rl.mu.Unlock() - <-rl.ticker.C + <-rl.ch } // rateLimitWait waits for the next available API call slot diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 61bf4d8..7064c92 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -86,10 +86,11 @@ func (m *Monitor) checkCompletions() { m.statesMutex.Lock() defer m.statesMutex.Unlock() - // Create a set of current torrent IDs - currentTorrentIDs := make(map[int]bool, len(torrents)) + // Create a set of current torrent IDs for efficient lookup + // Use map[int]struct{} for better memory efficiency than map[int]bool + currentTorrentIDs := make(map[int]struct{}, len(torrents)) for _, torrent := range torrents { - currentTorrentIDs[torrent.ID] = true + currentTorrentIDs[torrent.ID] = struct{}{} } // Check each torrent for completion and update states @@ -97,23 +98,26 @@ func (m *Monitor) checkCompletions() { prevState, exists := m.states[torrent.ID] // Detect completion: transition from Downloading to Seeding with 100% done + // Optimized: only check if we were tracking this torrent completed := false if exists && prevState.Status == transmission.StatusDownloading { // Case 1: Status changed from Downloading to Seeding (most common) if torrent.Status == transmission.StatusSeeding && torrent.PercentDone >= 1.0 { completed = true - } - // Case 2: Torrent reached 100% while still in Downloading status - if torrent.Status == transmission.StatusDownloading && + } else if torrent.Status == transmission.StatusDownloading && prevState.PercentDone < 1.0 && torrent.PercentDone >= 1.0 { + // Case 2: Torrent reached 100% while still in Downloading status completed = true } } if completed && m.onComplete != nil { + // Call callback outside of lock to avoid potential deadlocks + m.statesMutex.Unlock() m.onComplete(torrent) m.logger.Printf("[INFO] Torrent completed: %s (ID: %d)", torrent.Name, torrent.ID) + m.statesMutex.Lock() } // Update state - only track downloading torrents to save memory @@ -130,8 +134,9 @@ func (m *Monitor) checkCompletions() { } // Clean up states for torrents that no longer exist + // More efficient: iterate over states map instead of currentTorrentIDs for torrentID := range m.states { - if !currentTorrentIDs[torrentID] { + if _, exists := currentTorrentIDs[torrentID]; !exists { delete(m.states, torrentID) } }