package tgbot import ( "fmt" "math" "strconv" "strings" "sync" "time" "github.com/dustin/go-humanize" tele "gopkg.in/telebot.v4" "server/log" "server/torr" ) // humanizeSpeedBits formats bytes/s as bits/s (bps, kbps, Mbps, Gbps, Tbps) — same as web mode. func humanizeSpeedBits(uid int64, bytesPerSec float64) string { if bytesPerSec <= 0 { return "0 " + tr(uid, "speed_bps") } bits := bytesPerSec * 8 i := int(math.Floor(math.Log(bits) / math.Log(1000))) if i < 0 { i = 0 } units := []string{"speed_bps", "speed_kbps", "speed_Mbps", "speed_Gbps", "speed_Tbps"} if i >= len(units) { i = len(units) - 1 } val := bits / math.Pow(1000, float64(i)) return fmt.Sprintf("%.0f %s", val, tr(uid, units[i])) } var ( statusStopChans = make(map[int]chan struct{}) statusStopChansMu sync.Mutex ) func cmdStatus(c tele.Context) error { arg := "" if args := c.Args(); len(args) > 0 { arg = args[0] } hash := resolveHash(c, arg) torrents := torr.ListTorrent() if len(torrents) == 0 { return c.Send(tr(c.Sender().ID, "no_torrents")) } if hash != "" { t := torr.GetTorrent(hash) if t == nil { return c.Send(tr(c.Sender().ID, "torrent_not_found") + ":\n" + hash + "") } log.TLogln("tg status cmd", logUser(c.Sender()), logSafeStr(t.Title, 40), hash) if !t.WaitInfo() { msg, err := c.Bot().Send(c.Sender(), tr(c.Sender().ID, "status_waiting")) if err != nil { return err } go waitForInfoAndUpdateStatus(c.Bot(), msg, hash, c.Sender().ID) return nil } return sendStatus(c, t) } return sendStatusAllPage(c, 0) } const statusAllPageSize = 5 func sendStatusAllPage(c tele.Context, page int) error { torrents := torr.ListTorrent() if len(torrents) == 0 { return c.Send(tr(c.Sender().ID, "no_torrents")) } totalPages := (len(torrents) + statusAllPageSize - 1) / statusAllPageSize if page < 0 { page = 0 } if page >= totalPages { page = totalPages - 1 } start := page * statusAllPageSize end := start + statusAllPageSize if end > len(torrents) { end = len(torrents) } pageTorrents := torrents[start:end] uid := c.Sender().ID var sb strings.Builder for _, t := range pageTorrents { txt := formatTorrentStatus(uid, t) if txt != "" { sb.WriteString(txt) sb.WriteString("\n\n") } } if sb.Len() == 0 { return c.Send(tr(uid, "status_no_active")) } msg := strings.TrimSuffix(sb.String(), "\n\n") navRow := []tele.InlineButton{} if totalPages > 1 { if page > 0 { navRow = append(navRow, tele.InlineButton{Text: "◀️", Unique: "fstatusall", Data: strconv.Itoa(page - 1)}) } navRow = append(navRow, tele.InlineButton{Text: strconv.Itoa(page+1) + "/" + strconv.Itoa(totalPages), Unique: "fnop", Data: ""}) if page < totalPages-1 { navRow = append(navRow, tele.InlineButton{Text: "▶️", Unique: "fstatusall", Data: strconv.Itoa(page + 1)}) } } navRow = append(navRow, tele.InlineButton{Text: "🔄", Unique: "fstatusallrefresh", Data: strconv.Itoa(page)}) kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{navRow}} if err := c.Send(msg, kbd); err != nil { log.TLogln("tg status all send err", err) return err } return nil } func callbackStatusAllPage(c tele.Context, data string) error { page := 0 if data != "" { if p, err := strconv.Atoi(data); err == nil { page = p } } _ = c.Respond(&tele.CallbackResponse{}) if c.Callback().Message != nil { _ = c.Bot().Delete(c.Callback().Message) } return sendStatusAllPage(c, page) } func callbackStatusAllRefresh(c tele.Context, data string) error { page := 0 if data != "" { if p, err := strconv.Atoi(data); err == nil { page = p } } _ = c.Respond(&tele.CallbackResponse{Text: "🔄"}) if c.Callback().Message != nil { _ = c.Bot().Delete(c.Callback().Message) } return sendStatusAllPage(c, page) } func sendStatus(c tele.Context, t *torr.Torrent) error { uid := c.Sender().ID txt := formatTorrentStatus(uid, t) if txt == "" && t != nil { txt = "" + escapeHtml(t.Title) + "\n" + tr(uid, "status_label") + ": " + t.Stat.String() } hash := "" if t != nil { hash = t.Hash().HexString() } kbd := statusKeyboard(uid, hash, true) msg, err := c.Bot().Send(c.Sender(), txt, kbd) if err != nil { return err } if t != nil { log.TLogln("tg status sent", logUserID(uid), logSafeStr(t.Title, 40), hash) go refreshStatusLoop(c.Bot(), msg, hash, uid) } return nil } func statusKeyboard(uid int64, hash string, active bool) *tele.ReplyMarkup { if active { return &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{ { {Text: "🔄", Unique: "fstatusrefresh", Data: hash}, {Text: tr(uid, "status_stop_btn"), Unique: "fstatusstop", Data: hash}, }, }} } return &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{ {{Text: tr(uid, "status_refresh_btn"), Unique: "fstatusrefresh", Data: hash}}, }} } func refreshStatusLoop(api tele.API, msg *tele.Message, hash string, uid int64) { const interval = 5 * time.Second const duration = 2 * time.Minute stopCh := make(chan struct{}) statusStopChansMu.Lock() statusStopChans[msg.ID] = stopCh statusStopChansMu.Unlock() defer func() { statusStopChansMu.Lock() delete(statusStopChans, msg.ID) statusStopChansMu.Unlock() }() ticker := time.NewTicker(interval) defer ticker.Stop() deadline := time.Now().Add(duration) for { select { case <-stopCh: return case <-ticker.C: if time.Now().After(deadline) { t := torr.GetTorrent(hash) txt := "" if t != nil { txt = formatTorrentStatus(uid, t) if txt == "" { txt = "" + escapeHtml(t.Title) + "\n" + tr(uid, "status_label") + ": " + t.Stat.String() } txt += "\n\n" + tr(uid, "status_auto_ended") } else { txt = "" + hash + "\n\n" + tr(uid, "status_torrent_gone") } _, _ = api.Edit(msg, txt, statusKeyboard(uid, hash, false), tele.ModeHTML) return } t := torr.GetTorrent(hash) if t == nil { txt := "" + hash + "\n\n" + tr(uid, "status_torrent_gone") _, _ = api.Edit(msg, txt, statusKeyboard(uid, hash, false), tele.ModeHTML) return } txt := formatTorrentStatus(uid, t) if txt == "" { txt = "" + escapeHtml(t.Title) + "\n" + tr(uid, "status_label") + ": " + t.Stat.String() } if _, err := api.Edit(msg, txt, statusKeyboard(uid, hash, true), tele.ModeHTML); err != nil { errStr := err.Error() if strings.Contains(errStr, "message is not modified") { continue } if strings.Contains(errStr, "message to edit not found") { return } log.TLogln("tg status refresh err", err) return } } } } func stopStatusRefresh(msgID int) { statusStopChansMu.Lock() ch := statusStopChans[msgID] delete(statusStopChans, msgID) statusStopChansMu.Unlock() if ch != nil { close(ch) } } const waitForInfoTimeout = 2 * time.Minute func waitForInfoAndUpdateStatus(api tele.API, msg *tele.Message, hash string, uid int64) { deadline := time.Now().Add(waitForInfoTimeout) for { t := torr.GetTorrent(hash) if t == nil { _, _ = api.Edit(msg, tr(uid, "torrent_not_found")+":\n"+hash+"", tele.ModeHTML) return } if t.WaitInfo() { break } if time.Now().After(deadline) { _, _ = api.Edit(msg, tr(uid, "status_waiting")+"\n\n"+tr(uid, "status_auto_ended"), tele.ModeHTML) return } time.Sleep(time.Second) } t := torr.GetTorrent(hash) if t == nil { _, _ = api.Edit(msg, tr(uid, "torrent_not_found")+":\n"+hash+"", tele.ModeHTML) return } txt := formatTorrentStatus(uid, t) if txt == "" { txt = "" + escapeHtml(t.Title) + "\n" + tr(uid, "status_label") + ": " + t.Stat.String() } if _, err := api.Edit(msg, txt, statusKeyboard(uid, hash, true), tele.ModeHTML); err != nil { log.TLogln("tg status wait edit err", err) return } go refreshStatusLoop(api, msg, hash, uid) } func callbackStatusRefresh(c tele.Context, hash string) error { uid := c.Sender().ID if hash == "" { return c.Respond(&tele.CallbackResponse{Text: tr(uid, "callback_unknown")}) } t := torr.GetTorrent(hash) if t != nil { log.TLogln("tg status refresh", logUserID(uid), logSafeStr(t.Title, 40), hash) } if t == nil { return c.Respond(&tele.CallbackResponse{Text: tr(uid, "torrent_not_found")}) } if c.Callback().Message != nil { stopStatusRefresh(c.Callback().Message.ID) _ = c.Bot().Delete(c.Callback().Message) } _ = c.Respond(&tele.CallbackResponse{}) return sendStatus(c, t) } func callbackStatusStop(c tele.Context, hash string) error { uid := c.Sender().ID if hash != "" { if t := torr.GetTorrent(hash); t != nil { log.TLogln("tg status stop", logUserID(uid), logSafeStr(t.Title, 40), hash) } } if c.Callback().Message != nil { stopStatusRefresh(c.Callback().Message.ID) if hash != "" { msg := c.Callback().Message t := torr.GetTorrent(hash) txt := "" if t != nil { txt = formatTorrentStatus(uid, t) if txt == "" { txt = "" + escapeHtml(t.Title) + "\n" + tr(uid, "status_label") + ": " + t.Stat.String() } } else { txt = "" + hash + "" } txt += "\n\n" + tr(uid, "status_stopped") _, _ = c.Bot().Edit(msg, txt, statusKeyboard(uid, hash, false), tele.ModeHTML) } } return c.Respond(&tele.CallbackResponse{Text: "🛑"}) } func callbackStatus(c tele.Context, hash string) error { uid := c.Sender().ID t := torr.GetTorrent(hash) if t == nil { return c.Respond(&tele.CallbackResponse{Text: tr(uid, "torrent_not_found")}) } _ = c.Respond(&tele.CallbackResponse{}) if !t.WaitInfo() { msg, err := c.Bot().Send(c.Sender(), tr(uid, "status_waiting")) if err != nil { return err } go waitForInfoAndUpdateStatus(c.Bot(), msg, hash, uid) return nil } return sendStatus(c, t) } func formatTorrentStatus(uid int64, t *torr.Torrent) string { if t == nil { return "" } st := t.Status() if st == nil { return "" + escapeHtml(t.Title) + "\n" + tr(uid, "status_label") + ": " + t.Stat.String() } // For streaming: size + cache info (progress is misleading — we stream, not download sequentially) sizeLine := fmt.Sprintf("%s: %s", tr(uid, "status_size"), humanize.IBytes(uint64(st.TorrentSize))) if cache := t.CacheState(); cache != nil { sizeLine += fmt.Sprintf(" | %s: %s / %s · %d %s", tr(uid, "status_cache"), humanize.IBytes(uint64(cache.Filled)), humanize.IBytes(uint64(cache.Capacity)), len(cache.Readers), tr(uid, "status_streams")) } txt := fmt.Sprintf("%s\n", escapeHtml(st.Title)) txt += fmt.Sprintf("%s: %s\n", tr(uid, "status_label"), st.StatString) txt += sizeLine + "\n" txt += fmt.Sprintf("%s: %s | %s: %s\n", tr(uid, "status_download"), humanizeSpeedBits(uid, st.DownloadSpeed), tr(uid, "status_upload"), humanizeSpeedBits(uid, st.UploadSpeed)) txt += fmt.Sprintf("%s: %d %s, %d %s\n", tr(uid, "stats_peers"), st.ActivePeers, tr(uid, "stats_active"), st.ConnectedSeeders, tr(uid, "stats_seeds")) txt += fmt.Sprintf("%s", st.Hash) return txt }