Initial commit: docker compose config
Release Docker multi arch / docker (push) Has been cancelled
Test Install Script / Test Script Syntax (push) Has been cancelled
Test Install Script / Test on almalinux-10 (default) (push) Has been cancelled
Test Install Script / Test on almalinux-10 (root) (push) Has been cancelled
Test Install Script / Test on almalinux-8 (default) (push) Has been cancelled
Test Install Script / Test on almalinux-8 (root) (push) Has been cancelled
Test Install Script / Test on almalinux-9 (default) (push) Has been cancelled
Test Install Script / Test on almalinux-9 (root) (push) Has been cancelled
Test Install Script / Test on amazonlinux-2 (default) (push) Has been cancelled
Test Install Script / Test on amazonlinux-2 (root) (push) Has been cancelled
Test Install Script / Test on debian-11 (default) (push) Has been cancelled
Test Install Script / Test on debian-11 (root) (push) Has been cancelled
Test Install Script / Test on debian-12 (default) (push) Has been cancelled
Test Install Script / Test on debian-12 (root) (push) Has been cancelled
Test Install Script / Test on debian-13 (default) (push) Has been cancelled
Test Install Script / Test on debian-13 (root) (push) Has been cancelled
Test Install Script / Test on fedora-latest (default) (push) Has been cancelled
Test Install Script / Test on fedora-latest (root) (push) Has been cancelled
Test Install Script / Test on rocky-10 (default) (push) Has been cancelled
Test Install Script / Test on rocky-10 (root) (push) Has been cancelled
Test Install Script / Test on rocky-8 (default) (push) Has been cancelled
Test Install Script / Test on rocky-8 (root) (push) Has been cancelled
Test Install Script / Test on rocky-9 (default) (push) Has been cancelled
Test Install Script / Test on rocky-9 (root) (push) Has been cancelled
Test Install Script / Test on ubuntu-22.04 (default) (push) Has been cancelled
Test Install Script / Test on ubuntu-22.04 (root) (push) Has been cancelled
Test Install Script / Test on ubuntu-24.04 (default) (push) Has been cancelled
Test Install Script / Test on ubuntu-24.04 (root) (push) Has been cancelled

This commit is contained in:
2026-05-30 12:07:11 +00:00
commit 616c6b1c62
381 changed files with 55145 additions and 0 deletions
+162
View File
@@ -0,0 +1,162 @@
# TorrServer Telegram Bot
[![GitHub License](https://img.shields.io/github/license/YouROK/TorrServer)](https://github.com/YouROK/TorrServer/blob/master/LICENSE)
[![TorrServer Integrated](https://img.shields.io/badge/TorrServer-integrated-blue)](https://github.com/YouROK/TorrServer)
## Introduction
Telegram bot for managing [TorrServer](https://github.com/YouROK/TorrServer) — add torrents, stream, search, and control the server directly from Telegram.
## Features
- Torrent management — add, remove, drop, list via magnet, hash, or `torrs://`
- Export & import — magnets list; import multiple from text
- Streaming — playback links, M3U playlists, preload
- Search — RuTor and Torznab with one-click add
- Inline mode — `@botname` in any chat: list torrents or search
- Status & snake — real-time status, cache visualization
- File operations — browse files, download to Telegram
- FFprobe — media metadata via `/ffp`
- Localization — Russian and English
- Admin — shutdown, settings, presets (whitelist users only)
## Getting Started
### Enable the Bot
Start TorrServer with a Telegram bot token:
```bash
TorrServer --tg YOUR_BOT_TOKEN
```
Or use `-T`:
```bash
TorrServer -T YOUR_BOT_TOKEN
```
Create a bot via [@BotFather](https://t.me/BotFather) to get the token.
### Configuration
Config file `tg.cfg` (JSON) in the TorrServer data directory:
| Field | Description |
|------------|-------------|
| `HostTG` | Telegram API URL (default: `https://api.telegram.org`) |
| `HostWeb` | Base URL for stream links (auto-detected if empty) |
| `Socks5` | Optional SOCKS5 for reaching Telegram (e.g. `127.0.0.1:1080`, `socks5://user:pass@host:port`) if direct access to `api.telegram.org` is blocked or times out |
| `WhiteIds` | Allowed user IDs (empty = allow all) |
| `BlackIds` | Blocked user IDs |
Example:
```json
{
"HostTG": "https://api.telegram.org",
"HostWeb": "http://192.168.1.100:8090",
"Socks5": "127.0.0.1:1080",
"WhiteIds": [123456789],
"BlackIds": []
}
```
If your network cannot connect to Telegrams API directly, run a local SOCKS5 proxy (for example [sing-box](https://github.com/SagerNet/sing-box), v2ray, or `ssh -D`) and set `Socks5` to its address.
## Commands
### Core
| Command | Description |
|---------|-------------|
| `/help`, `/start`, `/id` | Help and user ID |
| `/list [compact]` | List torrents with buttons |
| `/add <link>` | Add torrent (magnet, hash, torrs://) |
| `/clear` | Remove all (with confirmation) |
| `/hash [N]` | Show info hashes |
### Management
| Command | Description |
|---------|-------------|
| `/remove <hash\|N>` | Remove torrent |
| `/drop <hash\|N>` | Disconnect (keep in DB) |
| `/set <hash\|N> <title>` | Set title |
| `/status [hash\|N]` | Status with refresh/stop |
| `/cache <hash\|N>` | Cache stats |
| `/preload <hash\|N> <index>` | Preload file |
### Links & Playback
| Command | Description |
|---------|-------------|
| `/link`, `/play` | Stream URL |
| `/m3u`, `/m3uall` | M3U playlist |
### Search
| Command | Description |
|---------|-------------|
| `/search <query>` | RuTor + Torznab (all sources) |
| `/rutor <query>` | RuTor only |
| `/torznab <query> [index]` | Torznab indexers |
### Other
| Command | Description |
|---------|-------------|
| `/export`, `/import` | Export/import magnets |
| `/categories` | List categories |
| `/server`, `/stats`, `/stat` | Server info |
| `/viewed` | Viewed files |
| `/ffp <hash\|N> <id> [json]` | FFprobe metadata |
| `/speedtest [size]` | Download test (1100 MB) |
| `/snake [hash\|N] [cols] [rows]` | Cache visualization |
| `/lang [RU\|EN]` | Language |
### Admin Only
| Command | Description |
|---------|-------------|
| `/shutdown` | Shut down server |
| `/settings` | Interactive settings menu (sub-pages: Search, Network, Other, Cache, Paths, Storage) |
| `/preset <name>` | Apply named preset: `performance`, `storage`, `streaming`, `low`, `default` |
| `/preset <key> <value> ...` | Apply key-value pairs: `cache 256`, `preload 50`, `conn 100`, etc. |
**Preset examples:**
- `/preset performance` — max cache, high preload, no limits
- `/preset cache 256 preload 50` — set cache 256 MB and preload 50%
- `/preset cache 512 conn 100 down 0 up 0` — multiple values
**Preset keys:** `cache`, `preload`, `readahead`, `conn`, `timeout`, `port`, `down`, `up`, `retr`, `responsive`, `cachedrop`
## Inline Mode
Type `@YourBotName` in any chat:
- **Empty, "list", or "play"** — torrents with play links
- **2+ characters** — search RuTor + Torznab
## Text Input
Paste as plain message to add torrent:
- `magnet:?xt=urn:btih:...`
- `torrs://...`
- 40-char info hash
Reply to file list with `2-12` to download files 212 to Telegram.
## Security
- **Whitelist** — restrict to specific user IDs
- **Blacklist** — block user IDs
- **Admin** — when whitelist is used, admin = whitelisted users
- **Settings** — sensitive values masked in `/settings`
## Dependencies
- [telebot v4](https://gopkg.in/telebot.v4) — Telegram Bot API
- [go-humanize](https://github.com/dustin/go-humanize)
- [go-ffprobe](https://gopkg.in/vansante/go-ffprobe.v2)
+129
View File
@@ -0,0 +1,129 @@
package tgbot
import (
"errors"
"fmt"
"io"
"strings"
"github.com/anacrolix/torrent"
tele "gopkg.in/telebot.v4"
"server/log"
set "server/settings"
"server/torr"
"server/web/api/utils"
)
func addTorrentFromSpec(c tele.Context, torrSpec *torrent.TorrentSpec, displayLabel string) error {
msg, err := c.Bot().Send(c.Sender(), tr(c.Sender().ID, "connecting"))
if err != nil {
return err
}
tor, err := torr.AddTorrent(torrSpec, "", "", "", "")
if err != nil {
log.TLogln("tg add err", err)
_, _ = c.Bot().Edit(msg, fmt.Sprintf(tr(c.Sender().ID, "add_error"), err.Error()))
return err
}
if tor == nil {
_, _ = c.Bot().Edit(msg, tr(c.Sender().ID, "add_not_created"))
return errors.New("torrent not created")
}
if set.BTsets != nil && set.BTsets.EnableDebug {
if tor.Data != "" {
log.TLogln("tg add data", logSafeStr(tor.Data, 60))
}
if tor.Category != "" {
log.TLogln("tg add category", logSafeStr(tor.Category, 40))
}
}
_, _ = c.Bot().Edit(msg, tr(c.Sender().ID, "add_getting_meta"))
if !tor.GotInfo() {
log.TLogln("tg add err", "timeout get torrent info")
_, _ = c.Bot().Edit(msg, tr(c.Sender().ID, "add_timeout"))
return errors.New("timeout connection get torrent info")
}
if tor.Title == "" {
tor.Title = torrSpec.DisplayName
tor.Title = strings.ReplaceAll(tor.Title, "rutor.info", "")
tor.Title = strings.ReplaceAll(tor.Title, "_", " ")
tor.Title = strings.Trim(tor.Title, " ")
if tor.Title == "" {
tor.Title = tor.Name()
}
}
torr.SaveTorrentToDB(tor)
if len(displayLabel) > 80 {
displayLabel = displayLabel[:77] + "..."
}
_, _ = c.Bot().Edit(msg, fmt.Sprintf(tr(c.Sender().ID, "add_success"), displayLabel))
return nil
}
func addTorrent(c tele.Context, link string) error {
log.TLogln("tg add torrent", logHashOrTruncate(link))
link = strings.ReplaceAll(link, "&amp;", "&")
var torrSpec *torrent.TorrentSpec
var err error
if strings.HasPrefix(strings.ToLower(link), "torrs://") {
torrSpec, _, err = utils.ParseTorrsHash(link)
} else {
torrSpec, err = utils.ParseLink(link)
}
if err != nil {
log.TLogln("tg add parse err", err)
return err
}
return addTorrentFromSpec(c, torrSpec, link)
}
func addTorrentFromDocument(c tele.Context, doc *tele.Document) error {
if doc == nil || doc.FileID == "" {
return errors.New("no document")
}
reader, err := c.Bot().File(&doc.File)
if err != nil {
log.TLogln("tg add document getfile err", err)
return err
}
defer func() { _ = reader.Close() }()
data, err := io.ReadAll(reader)
if err != nil {
log.TLogln("tg add document read err", err)
return err
}
torrSpec, err := utils.ParseFromBytes(data)
if err != nil {
log.TLogln("tg add document parse err", err)
return err
}
displayLabel := doc.FileName
if displayLabel == "" {
displayLabel = ".torrent"
}
return addTorrentFromSpec(c, torrSpec, displayLabel)
}
func cmdAdd(c tele.Context) error {
uid := c.Sender().ID
args := c.Args()
if len(args) == 0 {
return c.Send(tr(uid, "add_usage"))
}
link := strings.TrimSpace(strings.Join(args, " "))
if link == "" {
return c.Send(tr(uid, "add_no_link"))
}
err := addTorrent(c, link)
if err != nil {
return err
}
return list(c)
}
+17
View File
@@ -0,0 +1,17 @@
package tgbot
import (
"server/tgbot/config"
)
func isAdmin(userID int64) bool {
if len(config.Cfg.WhiteIds) == 0 {
return false
}
for _, id := range config.Cfg.WhiteIds {
if id == userID {
return true
}
}
return false
}
+275
View File
@@ -0,0 +1,275 @@
package tgbot
import (
"strings"
"sync"
"time"
tele "gopkg.in/telebot.v4"
"server/dlna"
"server/rutor"
"server/settings"
"server/torr"
)
type pendingPreset struct {
Sets *settings.BTSets
Preset string // name for display
UserID int64
IsDef bool
CreatedAt time.Time
}
var (
pendingPresetMu sync.Mutex
pendingPresets = make(map[string]pendingPreset)
)
func init() {
go func() {
ticker := time.NewTicker(5 * time.Minute)
for range ticker.C {
pendingPresetMu.Lock()
now := time.Now()
for key, p := range pendingPresets {
if now.Sub(p.CreatedAt) > 30*time.Minute {
delete(pendingPresets, key)
}
}
pendingPresetMu.Unlock()
}
}()
}
func cmdPreset(c tele.Context) error {
uid := c.Sender().ID
if !isAdmin(uid) {
return c.Send(tr(uid, "admin_only"))
}
if settings.BTsets == nil {
return c.Send(tr(uid, "settings_not_loaded"))
}
if settings.ReadOnly {
return c.Send(tr(uid, "settings_readonly"))
}
args := strings.Fields(c.Text())
if len(args) < 2 {
return c.Send(tr(uid, "preset_usage"))
}
sets := new(settings.BTSets)
*sets = *settings.BTsets
first := strings.ToLower(args[1])
presetName := first
if len(args) == 2 {
if ok, _ := applyNamedPreset(sets, first, uid); ok {
return sendPresetConfirm(c, uid, sets, presetName, false)
}
if first == "default" || first == "def" || first == "сброс" {
return sendPresetConfirm(c, uid, nil, "default", true)
}
}
// Parse key-value pairs: cache 256 preload 50 conn 100
applied, errMsg := applyPresetKV(sets, args[1:], uid)
if !applied {
return c.Send(errMsg)
}
presetName = strings.Join(args[1:], " ")
return sendPresetConfirm(c, uid, sets, presetName, false)
}
func sendPresetConfirm(c tele.Context, uid int64, sets *settings.BTSets, presetName string, isDef bool) error {
btnYes := tele.InlineButton{Text: tr(uid, "btn_yes"), Unique: "fpreset", Data: "1"}
btnNo := tele.InlineButton{Text: tr(uid, "btn_no"), Unique: "fpreset", Data: "0"}
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{{btnYes, btnNo}}}
msg := tr(uid, "preset_confirm") + "\n\n<code>" + presetName + "</code>"
sent, err := c.Bot().Send(c.Chat(), msg, kbd, tele.ModeHTML)
if err != nil {
return err
}
pendingPresetMu.Lock()
pendingPresets[chatMsgKey(sent.Chat.ID, sent.ID)] = pendingPreset{
Sets: sets, Preset: presetName, UserID: uid, IsDef: isDef, CreatedAt: time.Now(),
}
pendingPresetMu.Unlock()
return nil
}
func presetConfirm(c tele.Context, confirm string) error {
uid := c.Sender().ID
if !isAdmin(uid) {
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "admin_only")})
}
key := chatMsgKey(c.Callback().Message.Chat.ID, c.Callback().Message.ID)
pendingPresetMu.Lock()
p, ok := pendingPresets[key]
delete(pendingPresets, key)
pendingPresetMu.Unlock()
if !ok || p.UserID != uid {
_ = c.Bot().Delete(c.Callback().Message)
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "canceled")})
}
if confirm != "1" {
_ = c.Bot().Delete(c.Callback().Message)
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "canceled")})
}
_ = c.Bot().Delete(c.Callback().Message)
if settings.ReadOnly {
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_readonly")})
}
if p.IsDef {
torr.SetDefSettings()
dlna.Stop()
rutor.Stop()
rutor.Start()
return c.Send(tr(uid, "settings_reset_done"))
}
if p.Sets == nil {
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "callback_unknown")})
}
torr.SetSettings(p.Sets)
dlna.Stop()
if p.Sets.EnableDLNA {
dlna.Start()
}
rutor.Stop()
rutor.Start()
return c.Send(tr(uid, "preset_applied") + p.Preset)
}
func applyNamedPreset(s *settings.BTSets, name string, uid int64) (bool, string) {
switch name {
case "performance", "perf", "производительность":
s.CacheSize = 512 * 1024 * 1024
s.PreloadCache = 95
s.ReaderReadAHead = 100
s.ConnectionsLimit = 100
s.TorrentDisconnectTimeout = 60
s.PeersListenPort = 0
s.DownloadRateLimit = 0
s.UploadRateLimit = 0
s.RetrackersMode = 1
s.ResponsiveMode = true
return true, tr(uid, "preset_applied") + " performance"
case "storage", "store", "хранение":
s.CacheSize = 64 * 1024 * 1024
s.PreloadCache = 25
s.ReaderReadAHead = 50
s.RemoveCacheOnDrop = true
return true, tr(uid, "preset_applied") + " storage"
case "streaming", "stream", "стриминг":
s.CacheSize = 256 * 1024 * 1024
s.PreloadCache = 75
s.ReaderReadAHead = 95
s.ConnectionsLimit = 50
s.ResponsiveMode = true
return true, tr(uid, "preset_applied") + " streaming"
case "low", "minimal", "минимум":
s.CacheSize = 64 * 1024 * 1024
s.PreloadCache = 25
s.ReaderReadAHead = 50
s.ConnectionsLimit = 25
s.TorrentDisconnectTimeout = 30
return true, tr(uid, "preset_applied") + " low"
case "default", "def", "сброс":
return false, "" // handled in cmdPreset
}
return false, ""
}
func applyPresetKV(s *settings.BTSets, args []string, uid int64) (bool, string) {
if len(args) < 2 {
return false, tr(uid, "preset_usage")
}
applied := false
for i := 0; i < len(args)-1; i += 2 {
key := strings.ToLower(args[i])
val := strings.ToLower(strings.TrimSpace(args[i+1]))
ok := false
switch key {
case "cache":
if v := parseInt(val); v > 0 {
s.CacheSize = int64(v) * 1024 * 1024
ok = true
}
case "preload":
if v := parseInt(val); v >= 0 && v <= 100 {
s.PreloadCache = v
ok = true
}
case "readahead":
if v := parseInt(val); v >= 5 && v <= 100 {
s.ReaderReadAHead = v
ok = true
}
case "conn", "connections":
if v := parseInt(val); v > 0 {
s.ConnectionsLimit = v
ok = true
}
case "timeout":
if v := parseInt(val); v > 0 {
s.TorrentDisconnectTimeout = v
ok = true
}
case "port":
v := parseInt(val)
if val == "auto" || val == "0" {
v = 0
}
if v >= 0 && (v == 0 || (v >= 1024 && v <= 65535)) {
s.PeersListenPort = v
ok = true
}
case "down", "download":
v := 0
if val != "inf" && val != "∞" && val != "0" {
v = parseInt(val)
}
s.DownloadRateLimit = v
ok = true
case "up", "upload":
v := 0
if val != "inf" && val != "∞" && val != "0" {
v = parseInt(val)
}
s.UploadRateLimit = v
ok = true
case "retr", "retrackers":
var v int
switch val {
case "off":
v = 0
case "add":
v = 1
case "rem", "remove":
v = 2
case "repl", "replace":
v = 3
default:
v = parseInt(val)
}
if v >= 0 && v <= 3 {
s.RetrackersMode = v
ok = true
}
case "responsive":
s.ResponsiveMode = val == "1" || val == "on" || val == "true" || val == "да" || val == "yes"
ok = true
case "cachedrop":
s.RemoveCacheOnDrop = val == "1" || val == "on" || val == "true" || val == "да" || val == "yes"
ok = true
}
if ok {
applied = true
}
}
if !applied {
return false, tr(uid, "preset_usage")
}
return true, ""
}
+654
View File
@@ -0,0 +1,654 @@
package tgbot
import (
"bytes"
"encoding/json"
"fmt"
"strings"
tele "gopkg.in/telebot.v4"
"server/dlna"
"server/rutor"
"server/settings"
"server/torr"
)
func cmdSettings(c tele.Context) error {
uid := c.Sender().ID
if settings.BTsets == nil {
return c.Send(tr(uid, "settings_not_loaded"))
}
return sendSettingsMenu(c, uid)
}
func sendSettingsMenu(c tele.Context, uid int64) error {
return sendSettingsMenuPage(c, uid, "1")
}
func sendSettingsMenuPage(c tele.Context, uid int64, page string) error {
msg := sendSettingsMenuText(c, uid, page)
kbd := sendSettingsMenuKbd(uid, page)
return c.Send(msg, kbd)
}
func sendSettingsMenuText(c tele.Context, uid int64, page string) string {
s := settings.BTsets
msg := "⚙️ <b>" + tr(uid, "settings_title") + "</b>"
switch page {
case "1":
msg += "\n\n"
msg += fmt.Sprintf("🔍 %s: RuTor %s · Torznab %s\n", tr(uid, "settings_section_search"), boolIcon(s.EnableRutorSearch), boolIcon(s.EnableTorznabSearch))
msg += fmt.Sprintf("📺 %s: DLNA %s · IPv6 %s · DHT %s · PEX %s · TCP %s · UTP %s\n", tr(uid, "settings_section_network"), boolIcon(s.EnableDLNA), boolIcon(s.EnableIPv6), boolIcon(!s.DisableDHT), boolIcon(!s.DisablePEX), boolIcon(!s.DisableTCP), boolIcon(!s.DisableUTP))
msg += fmt.Sprintf("📦 %s: CacheDrop %s · Proxy %s · UseDisk %s\n", tr(uid, "settings_section_other"), boolIcon(s.RemoveCacheOnDrop), boolIcon(s.EnableProxy), boolIcon(s.UseDisk))
case "1a":
msg += " — " + tr(uid, "settings_section_search")
msg += "\n\n"
msg += fmt.Sprintf("RuTor %s · Torznab %s", boolIcon(s.EnableRutorSearch), boolIcon(s.EnableTorznabSearch))
case "1b":
msg += " — " + tr(uid, "settings_section_network")
msg += "\n\n"
msg += fmt.Sprintf("DLNA %s · IPv6 %s · Upload %s · DHT %s · PEX %s\n", boolIcon(s.EnableDLNA), boolIcon(s.EnableIPv6), boolIcon(!s.DisableUpload), boolIcon(!s.DisableDHT), boolIcon(!s.DisablePEX))
msg += fmt.Sprintf("TCP %s · UTP %s · UPNP %s · Encrypt %s · Debug %s", boolIcon(!s.DisableTCP), boolIcon(!s.DisableUTP), boolIcon(!s.DisableUPNP), boolIcon(s.ForceEncrypt), boolIcon(s.EnableDebug))
case "1c":
msg += " — " + tr(uid, "settings_section_other")
msg += "\n\n"
msg += fmt.Sprintf("CacheDrop %s · Responsive %s · Proxy %s · UseDisk %s · FSActive %s", boolIcon(s.RemoveCacheOnDrop), boolIcon(s.ResponsiveMode), boolIcon(s.EnableProxy), boolIcon(s.UseDisk), boolIcon(s.ShowFSActiveTorr))
case "2":
msg += " — " + tr(uid, "settings_page2")
msg += "\n\n"
msg += fmt.Sprintf("💾 %s: %d MB · Preload %d%% · ReadAhead %d%%\n", tr(uid, "settings_limits_cache"), s.CacheSize/(1024*1024), s.PreloadCache, s.ReaderReadAHead)
msg += fmt.Sprintf("🔌 %s: %d · Port %s · Timeout %ds\n", tr(uid, "settings_limits_connections"), s.ConnectionsLimit, portStr(s.PeersListenPort), s.TorrentDisconnectTimeout)
msg += fmt.Sprintf("⬇️ %s: Down %s · Up %s · Retr %s\n", tr(uid, "settings_limits_speed"), rateStr(s.DownloadRateLimit), rateStr(s.UploadRateLimit), retrackersStr(s.RetrackersMode))
case "2a":
msg += " — " + tr(uid, "settings_page2") + " · " + tr(uid, "settings_limits_cache")
msg += "\n\n"
msg += fmt.Sprintf("Cache %d MB · Preload %d%% · ReadAhead %d%%", s.CacheSize/(1024*1024), s.PreloadCache, s.ReaderReadAHead)
case "2b":
msg += " — " + tr(uid, "settings_page2") + " · " + tr(uid, "settings_limits_connections")
msg += "\n\n"
msg += fmt.Sprintf("Connections %d · Port %s · Timeout %ds", s.ConnectionsLimit, portStr(s.PeersListenPort), s.TorrentDisconnectTimeout)
case "2c":
msg += " — " + tr(uid, "settings_page2") + " · " + tr(uid, "settings_limits_speed")
msg += "\n\n"
msg += fmt.Sprintf("Down %s · Up %s · Retrackers %s", rateStr(s.DownloadRateLimit), rateStr(s.UploadRateLimit), retrackersStr(s.RetrackersMode))
case "3":
msg += " — " + tr(uid, "settings_page3")
msg += "\n\n"
msg += fmt.Sprintf("📺 DLNA: %s · 💾 Path: %s\n", maskStr(s.FriendlyName, 25), maskVal(s.TorrentsSavePath))
msg += fmt.Sprintf("🔐 SSL: %s · 🔑 TMDB: %s · Torznab: %d\n", maskVal(s.SslCert), maskVal(s.TMDBSettings.APIKey), len(s.TorznabUrls))
msg += fmt.Sprintf("🌐 Proxy: %s", maskStr(strings.Join(s.ProxyHosts, ", "), 35))
case "4":
msg += " — " + tr(uid, "settings_page4")
msg += "\n\n"
msg += fmt.Sprintf("📄 %s: %s · 📺 %s: %s\n", tr(uid, "settings_storage_settings"), storageType(s.StoreSettingsInJson), tr(uid, "settings_storage_viewed"), storageType(s.StoreViewedInJson))
msg += fmt.Sprintf("🔑 TMDB: %s · 🖼 URL: %s", maskVal(s.TMDBSettings.APIKey), maskStr(s.TMDBSettings.ImageURL, 20))
}
return msg
}
func storageType(useJSON bool) string {
if useJSON {
return "json"
}
return "bbolt"
}
func rateStr(kb int) string {
if kb == 0 {
return "∞"
}
return fmt.Sprintf("%d", kb)
}
func portStr(port int) string {
if port == 0 {
return "auto"
}
return fmt.Sprintf("%d", port)
}
func maskStr(s string, maxLen int) string {
if s == "" {
return "—"
}
if len(s) > maxLen {
return s[:maxLen-3] + "..."
}
return s
}
func maskVal(s string) string {
if s == "" {
return "—"
}
return "***"
}
func retrackersStr(mode int) string {
switch mode {
case 0:
return "off"
case 1:
return "add"
case 2:
return "remove"
case 3:
return "replace"
default:
return "?"
}
}
func sendSettingsMenuKbd(uid int64, page string) *tele.ReplyMarkup {
s := settings.BTsets
var btns [][]tele.InlineButton
switch page {
case "1":
btns = [][]tele.InlineButton{
{
{Text: "🔍 " + tr(uid, "settings_section_search"), Unique: "fset", Data: "page|1a"},
{Text: "📺 " + tr(uid, "settings_section_network"), Unique: "fset", Data: "page|1b"},
{Text: "📦 " + tr(uid, "settings_section_other"), Unique: "fset", Data: "page|1c"},
},
{
{Text: "📥 " + tr(uid, "settings_export"), Unique: "fset", Data: "export"},
{Text: "📊 " + tr(uid, "settings_nav_cache"), Unique: "fset", Data: "page|2"},
{Text: "✏️ " + tr(uid, "settings_nav_paths"), Unique: "fset", Data: "page|3"},
{Text: "💾 " + tr(uid, "settings_nav_storage"), Unique: "fset", Data: "page|4"},
},
}
case "1a":
btns = [][]tele.InlineButton{
{
{Text: "◀️ " + tr(uid, "settings_back"), Unique: "fset", Data: "page|1"},
},
{
{Text: toggleBtn("RuTor", s.EnableRutorSearch), Unique: "fset", Data: "rutor|1a"},
{Text: toggleBtn("Torznab", s.EnableTorznabSearch), Unique: "fset", Data: "torznab|1a"},
},
}
case "1b":
btns = [][]tele.InlineButton{
{
{Text: "◀️ " + tr(uid, "settings_back"), Unique: "fset", Data: "page|1"},
},
{
{Text: toggleBtn("DLNA", s.EnableDLNA), Unique: "fset", Data: "dlna|1b"},
{Text: toggleBtn("IPv6", s.EnableIPv6), Unique: "fset", Data: "ipv6|1b"},
{Text: toggleBtn("Upload", !s.DisableUpload), Unique: "fset", Data: "upload|1b"},
},
{
{Text: toggleBtn("DHT", !s.DisableDHT), Unique: "fset", Data: "dht|1b"},
{Text: toggleBtn("PEX", !s.DisablePEX), Unique: "fset", Data: "pex|1b"},
{Text: toggleBtn("TCP", !s.DisableTCP), Unique: "fset", Data: "tcp|1b"},
{Text: toggleBtn("UTP", !s.DisableUTP), Unique: "fset", Data: "utp|1b"},
},
{
{Text: toggleBtn("UPNP", !s.DisableUPNP), Unique: "fset", Data: "upnp|1b"},
{Text: toggleBtn("Encrypt", s.ForceEncrypt), Unique: "fset", Data: "encrypt|1b"},
{Text: toggleBtn("Debug", s.EnableDebug), Unique: "fset", Data: "debug|1b"},
},
}
case "1c":
btns = [][]tele.InlineButton{
{
{Text: "◀️ " + tr(uid, "settings_back"), Unique: "fset", Data: "page|1"},
},
{
{Text: toggleBtn("CacheDrop", s.RemoveCacheOnDrop), Unique: "fset", Data: "cachedrop|1c"},
{Text: toggleBtn("Responsive", s.ResponsiveMode), Unique: "fset", Data: "responsive|1c"},
{Text: toggleBtn("Proxy", s.EnableProxy), Unique: "fset", Data: "proxy|1c"},
},
{
{Text: toggleBtn("UseDisk", s.UseDisk), Unique: "fset", Data: "usedisk|1c"},
{Text: toggleBtn("FSActive", s.ShowFSActiveTorr), Unique: "fset", Data: "fsactive|1c"},
},
}
case "2":
btns = [][]tele.InlineButton{
{
{Text: "💾 " + tr(uid, "settings_limits_cache"), Unique: "fset", Data: "page|2a"},
{Text: "🔌 " + tr(uid, "settings_limits_connections"), Unique: "fset", Data: "page|2b"},
{Text: "⬇️ " + tr(uid, "settings_limits_speed"), Unique: "fset", Data: "page|2c"},
},
{
{Text: "◀️ " + tr(uid, "settings_back"), Unique: "fset", Data: "page|1"},
{Text: "✏️ " + tr(uid, "settings_nav_paths"), Unique: "fset", Data: "page|3"},
{Text: "💾 " + tr(uid, "settings_nav_storage"), Unique: "fset", Data: "page|4"},
},
}
case "2a":
cacheMB := int(s.CacheSize / (1024 * 1024))
btns = [][]tele.InlineButton{
{
{Text: "◀️ " + tr(uid, "settings_back"), Unique: "fset", Data: "page|2"},
},
{
{Text: "💾 " + optBtn("64", cacheMB == 64), Unique: "fset", Data: "cache|64|2a"},
{Text: optBtn("128", cacheMB == 128), Unique: "fset", Data: "cache|128|2a"},
{Text: optBtn("256", cacheMB == 256), Unique: "fset", Data: "cache|256|2a"},
{Text: optBtn("512", cacheMB == 512), Unique: "fset", Data: "cache|512|2a"},
},
{
{Text: "📥 " + optBtn("25%", s.PreloadCache == 25), Unique: "fset", Data: "preload|25|2a"},
{Text: optBtn("50%", s.PreloadCache == 50), Unique: "fset", Data: "preload|50|2a"},
{Text: optBtn("75%", s.PreloadCache == 75), Unique: "fset", Data: "preload|75|2a"},
{Text: optBtn("95%", s.PreloadCache == 95), Unique: "fset", Data: "preload|95|2a"},
},
{
{Text: "📖 " + optBtn("50%", s.ReaderReadAHead == 50), Unique: "fset", Data: "readahead|50|2a"},
{Text: optBtn("75%", s.ReaderReadAHead == 75), Unique: "fset", Data: "readahead|75|2a"},
{Text: optBtn("95%", s.ReaderReadAHead == 95), Unique: "fset", Data: "readahead|95|2a"},
{Text: optBtn("100%", s.ReaderReadAHead == 100), Unique: "fset", Data: "readahead|100|2a"},
},
}
case "2b":
btns = [][]tele.InlineButton{
{
{Text: "◀️ " + tr(uid, "settings_back"), Unique: "fset", Data: "page|2"},
},
{
{Text: "🔌 " + optBtn("25", s.ConnectionsLimit == 25), Unique: "fset", Data: "conn|25|2b"},
{Text: optBtn("50", s.ConnectionsLimit == 50), Unique: "fset", Data: "conn|50|2b"},
{Text: optBtn("100", s.ConnectionsLimit == 100), Unique: "fset", Data: "conn|100|2b"},
},
{
{Text: "⏱ " + optBtn("15s", s.TorrentDisconnectTimeout == 15), Unique: "fset", Data: "timeout|15|2b"},
{Text: optBtn("30s", s.TorrentDisconnectTimeout == 30), Unique: "fset", Data: "timeout|30|2b"},
{Text: optBtn("60s", s.TorrentDisconnectTimeout == 60), Unique: "fset", Data: "timeout|60|2b"},
{Text: optBtn("120s", s.TorrentDisconnectTimeout == 120), Unique: "fset", Data: "timeout|120|2b"},
},
{
{Text: "🔌 " + optBtn("auto", s.PeersListenPort == 0), Unique: "fset", Data: "port|0|2b"},
{Text: optBtn("6881", s.PeersListenPort == 6881), Unique: "fset", Data: "port|6881|2b"},
{Text: optBtn("51413", s.PeersListenPort == 51413), Unique: "fset", Data: "port|51413|2b"},
},
}
case "2c":
btns = [][]tele.InlineButton{
{
{Text: "◀️ " + tr(uid, "settings_back"), Unique: "fset", Data: "page|2"},
},
{
{Text: "⬇️ " + optBtn("∞", s.DownloadRateLimit == 0), Unique: "fset", Data: "down|0|2c"},
{Text: optBtn("1M", s.DownloadRateLimit == 1024), Unique: "fset", Data: "down|1024|2c"},
{Text: optBtn("5M", s.DownloadRateLimit == 5120), Unique: "fset", Data: "down|5120|2c"},
{Text: optBtn("10M", s.DownloadRateLimit == 10240), Unique: "fset", Data: "down|10240|2c"},
},
{
{Text: "⬆️ " + optBtn("∞", s.UploadRateLimit == 0), Unique: "fset", Data: "up|0|2c"},
{Text: optBtn("1M", s.UploadRateLimit == 1024), Unique: "fset", Data: "up|1024|2c"},
{Text: optBtn("5M", s.UploadRateLimit == 5120), Unique: "fset", Data: "up|5120|2c"},
{Text: optBtn("10M", s.UploadRateLimit == 10240), Unique: "fset", Data: "up|10240|2c"},
},
{
{Text: "🔄 " + optBtn("off", s.RetrackersMode == 0), Unique: "fset", Data: "retr|0|2c"},
{Text: optBtn("add", s.RetrackersMode == 1), Unique: "fset", Data: "retr|1|2c"},
{Text: optBtn("rem", s.RetrackersMode == 2), Unique: "fset", Data: "retr|2|2c"},
{Text: optBtn("repl", s.RetrackersMode == 3), Unique: "fset", Data: "retr|3|2c"},
},
}
case "3":
btns = [][]tele.InlineButton{
{
{Text: "◀️ " + tr(uid, "settings_back"), Unique: "fset", Data: "page|1"},
{Text: "📊 " + tr(uid, "settings_nav_cache"), Unique: "fset", Data: "page|2"},
{Text: "💾 " + tr(uid, "settings_nav_storage"), Unique: "fset", Data: "page|4"},
},
{
{Text: "✏️ " + tr(uid, "settings_set_friendlyname"), Unique: "fset", Data: "ask|friendlyname"},
},
{
{Text: "✏️ " + tr(uid, "settings_set_path"), Unique: "fset", Data: "ask|torrentssavepath"},
},
{
{Text: "🔐 " + tr(uid, "settings_set_sslcert"), Unique: "fset", Data: "ask|sslcert"},
{Text: "🔑 " + tr(uid, "settings_set_sslkey"), Unique: "fset", Data: "ask|sslkey"},
},
{
{Text: "🎬 " + tr(uid, "settings_set_tmdbkey"), Unique: "fset", Data: "ask|tmdbkey"},
},
{
{Text: "🔍 " + tr(uid, "settings_torznab_test"), Unique: "fset", Data: "ask|torznab_test"},
{Text: " " + tr(uid, "settings_add_torznab"), Unique: "fset", Data: "ask|torznab_add"},
{Text: "🗑 " + tr(uid, "settings_clear_torznab"), Unique: "fset", Data: "torznab_clear"},
},
{
{Text: "✏️ " + tr(uid, "settings_set_proxyhosts"), Unique: "fset", Data: "ask|proxyhosts"},
},
}
case "4":
btns = [][]tele.InlineButton{
{
{Text: "◀️ " + tr(uid, "settings_back"), Unique: "fset", Data: "page|1"},
{Text: "📊 " + tr(uid, "settings_nav_cache"), Unique: "fset", Data: "page|2"},
{Text: "✏️ " + tr(uid, "settings_nav_paths"), Unique: "fset", Data: "page|3"},
},
{
{Text: "📄 " + optBtn("json", s.StoreSettingsInJson), Unique: "fset", Data: "storage_set|json"},
{Text: optBtn("bbolt", !s.StoreSettingsInJson), Unique: "fset", Data: "storage_set|bbolt"},
},
{
{Text: "📺 " + optBtn("json", s.StoreViewedInJson), Unique: "fset", Data: "storage_view|json"},
{Text: optBtn("bbolt", !s.StoreViewedInJson), Unique: "fset", Data: "storage_view|bbolt"},
},
{
{Text: "🔄 " + tr(uid, "settings_reset"), Unique: "fset", Data: "reset_confirm"},
},
}
}
return &tele.ReplyMarkup{InlineKeyboard: btns}
}
func boolIcon(v bool) string {
if v {
return "✅"
}
return "❌"
}
func toggleBtn(label string, on bool) string {
if on {
return label + " ✅"
}
return label + " ❌"
}
func optBtn(label string, isCurrent bool) string {
if isCurrent {
return label + " ✓"
}
return label
}
func settingsCallback(c tele.Context, action string) error {
uid := c.Sender().ID
if !isAdmin(uid) {
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "admin_only")})
}
if settings.BTsets == nil {
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_not_loaded")})
}
if action == "export" {
buf, err := json.MarshalIndent(settings.BTsets, "", " ")
if err != nil {
return c.Respond(&tele.CallbackResponse{Text: fmt.Sprintf(tr(uid, "settings_error"), err.Error())})
}
doc := &tele.Document{}
doc.FileName = "torrserver_settings.json"
doc.FileReader = bytes.NewReader(buf)
doc.Caption = "⚙️ " + tr(uid, "settings_export_caption")
_ = c.Send(doc)
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_exported")})
}
if action == "input_cancel" {
return cancelSettingsInput(c)
}
if action == "reset_confirm" {
btnYes := tele.InlineButton{Text: tr(uid, "btn_yes"), Unique: "fset", Data: "reset_def|1"}
btnNo := tele.InlineButton{Text: tr(uid, "btn_no"), Unique: "fset", Data: "reset_def|0"}
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{{btnYes, btnNo}}}
msg := sendSettingsMenuText(c, uid, "4") + "\n\n⚠️ " + tr(uid, "settings_reset_confirm")
if _, err := c.Bot().Edit(c.Callback().Message, msg, kbd, tele.ModeHTML); err != nil {
_ = c.Send(tr(uid, "settings_reset_confirm"), kbd)
}
return c.Respond(&tele.CallbackResponse{})
}
if len(action) > 9 && action[:9] == "reset_def|" {
if action[9:] != "1" {
msg := sendSettingsMenuText(c, uid, "4")
kbd := sendSettingsMenuKbd(uid, "4")
if _, err := c.Bot().Edit(c.Callback().Message, msg, kbd, tele.ModeHTML); err != nil {
_ = sendSettingsMenuPage(c, uid, "4")
}
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "canceled")})
}
if settings.ReadOnly {
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_readonly")})
}
torr.SetDefSettings()
dlna.Stop()
rutor.Stop()
rutor.Start()
msg := sendSettingsMenuText(c, uid, "4")
kbd := sendSettingsMenuKbd(uid, "4")
if _, err := c.Bot().Edit(c.Callback().Message, msg, kbd, tele.ModeHTML); err != nil {
_ = sendSettingsMenuPage(c, uid, "4")
}
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_reset_done")})
}
if len(action) > 12 && action[:12] == "storage_set|" {
if settings.ReadOnly {
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_readonly")})
}
val := action[12:]
prefs := map[string]interface{}{"settings": val}
if err := settings.SetStoragePreferences(prefs); err != nil {
return c.Respond(&tele.CallbackResponse{Text: fmt.Sprintf(tr(uid, "settings_error"), err.Error())})
}
page := "4"
msg := sendSettingsMenuText(c, uid, page)
kbd := sendSettingsMenuKbd(uid, page)
if _, err := c.Bot().Edit(c.Callback().Message, msg, kbd, tele.ModeHTML); err != nil {
_ = sendSettingsMenuPage(c, uid, page)
}
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_saved")})
}
if len(action) > 12 && action[:12] == "storage_view|" {
if settings.ReadOnly {
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_readonly")})
}
val := action[12:]
prefs := map[string]interface{}{"viewed": val}
if err := settings.SetStoragePreferences(prefs); err != nil {
return c.Respond(&tele.CallbackResponse{Text: fmt.Sprintf(tr(uid, "settings_error"), err.Error())})
}
page := "4"
msg := sendSettingsMenuText(c, uid, page)
kbd := sendSettingsMenuKbd(uid, page)
if _, err := c.Bot().Edit(c.Callback().Message, msg, kbd, tele.ModeHTML); err != nil {
_ = sendSettingsMenuPage(c, uid, page)
}
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_saved")})
}
if len(action) > 4 && action[:4] == "ask|" {
setting := action[4:]
var hint string
switch setting {
case "friendlyname":
hint = tr(uid, "settings_hint_friendlyname")
case "torrentssavepath":
hint = tr(uid, "settings_hint_path")
case "sslcert":
hint = tr(uid, "settings_hint_sslcert")
case "sslkey":
hint = tr(uid, "settings_hint_sslkey")
case "tmdbkey":
hint = tr(uid, "settings_hint_tmdbkey")
case "proxyhosts":
hint = tr(uid, "settings_hint_proxyhosts")
case "torznab_add":
hint = tr(uid, "settings_hint_torznab")
case "torznab_test":
hint = tr(uid, "settings_hint_torznab_test")
default:
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "callback_unknown")})
}
return sendSettingsInputPrompt(c, uid, setting, hint)
}
if len(action) > 5 && action[:5] == "page|" {
page := action[5:]
msg := sendSettingsMenuText(c, uid, page)
kbd := sendSettingsMenuKbd(uid, page)
if _, err := c.Bot().Edit(c.Callback().Message, msg, kbd, tele.ModeHTML); err != nil {
_ = sendSettingsMenuPage(c, uid, page)
}
return c.Respond(&tele.CallbackResponse{})
}
if settings.ReadOnly {
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_readonly")})
}
sets := new(settings.BTSets)
*sets = *settings.BTsets
page := "1"
// Extract return page from action (e.g. "rutor|1a" -> action "rutor", page "1a")
if idx := strings.Index(action, "|"); idx >= 0 {
suffix := action[idx+1:]
if suffix == "1a" || suffix == "1b" || suffix == "1c" {
page = suffix
action = action[:idx]
}
}
switch action {
case "rutor":
sets.EnableRutorSearch = !sets.EnableRutorSearch
case "torznab":
sets.EnableTorznabSearch = !sets.EnableTorznabSearch
case "dlna":
sets.EnableDLNA = !sets.EnableDLNA
case "ipv6":
sets.EnableIPv6 = !sets.EnableIPv6
case "upload":
sets.DisableUpload = !sets.DisableUpload
case "dht":
sets.DisableDHT = !sets.DisableDHT
case "pex":
sets.DisablePEX = !sets.DisablePEX
case "tcp":
sets.DisableTCP = !sets.DisableTCP
case "utp":
sets.DisableUTP = !sets.DisableUTP
case "upnp":
sets.DisableUPNP = !sets.DisableUPNP
case "encrypt":
sets.ForceEncrypt = !sets.ForceEncrypt
case "debug":
sets.EnableDebug = !sets.EnableDebug
case "cachedrop":
sets.RemoveCacheOnDrop = !sets.RemoveCacheOnDrop
case "responsive":
sets.ResponsiveMode = !sets.ResponsiveMode
case "proxy":
sets.EnableProxy = !sets.EnableProxy
case "usedisk":
sets.UseDisk = !sets.UseDisk
case "fsactive":
sets.ShowFSActiveTorr = !sets.ShowFSActiveTorr
case "storejson":
sets.StoreSettingsInJson = !sets.StoreSettingsInJson
case "viewedjson":
sets.StoreViewedInJson = !sets.StoreViewedInJson
case "torznab_clear":
if settings.ReadOnly {
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_readonly")})
}
sets.TorznabUrls = nil
page = "3"
torr.SetSettings(sets)
rutor.Stop()
rutor.Start()
msg := sendSettingsMenuText(c, uid, page)
kbd := sendSettingsMenuKbd(uid, page)
if _, err := c.Bot().Edit(c.Callback().Message, msg, kbd, tele.ModeHTML); err != nil {
_ = sendSettingsMenuPage(c, uid, page)
}
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_saved")})
default:
if parts := splitAction(action); len(parts) == 2 {
key, value := parts[0], parts[1]
page = "2"
if idx := strings.Index(value, "|"); idx >= 0 {
if ret := value[idx+1:]; ret == "2a" || ret == "2b" || ret == "2c" {
page = ret
}
value = value[:idx]
}
switch key {
case "cache":
if v := parseInt(value); v > 0 {
sets.CacheSize = int64(v) * 1024 * 1024
}
case "preload":
if v := parseInt(value); v >= 0 && v <= 100 {
sets.PreloadCache = v
}
case "readahead":
if v := parseInt(value); v >= 5 && v <= 100 {
sets.ReaderReadAHead = v
}
case "conn":
if v := parseInt(value); v > 0 {
sets.ConnectionsLimit = v
}
case "timeout":
if v := parseInt(value); v > 0 {
sets.TorrentDisconnectTimeout = v
}
case "port":
v := parseInt(value)
if v >= 0 && (v == 0 || (v >= 1024 && v <= 65535)) {
sets.PeersListenPort = v
}
case "down":
sets.DownloadRateLimit = parseInt(value)
case "up":
sets.UploadRateLimit = parseInt(value)
case "retr":
if v := parseInt(value); v >= 0 && v <= 3 {
sets.RetrackersMode = v
}
default:
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "callback_unknown")})
}
} else {
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "callback_unknown")})
}
}
torr.SetSettings(sets)
dlna.Stop()
if sets.EnableDLNA {
dlna.Start()
}
rutor.Stop()
rutor.Start()
msg := sendSettingsMenuText(c, uid, page)
kbd := sendSettingsMenuKbd(uid, page)
if _, err := c.Bot().Edit(c.Callback().Message, msg, kbd, tele.ModeHTML); err != nil {
_ = sendSettingsMenuPage(c, uid, page)
}
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_saved")})
}
func splitAction(action string) []string {
for i := 0; i < len(action); i++ {
if action[i] == '|' {
return []string{action[:i], action[i+1:]}
}
}
return nil
}
func parseInt(s string) int {
var n int
for _, c := range s {
if c >= '0' && c <= '9' {
n = n*10 + int(c-'0')
}
}
return n
}
+32
View File
@@ -0,0 +1,32 @@
package tgbot
import (
tele "gopkg.in/telebot.v4"
"server/torr"
)
func cmdShutdown(c tele.Context) error {
uid := c.Sender().ID
btnYes := tele.InlineButton{Text: tr(uid, "btn_yes"), Unique: "fshutdown", Data: "1"}
btnNo := tele.InlineButton{Text: tr(uid, "btn_no"), Unique: "fshutdown", Data: "0"}
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{{btnYes, btnNo}}}
return c.Send(tr(uid, "shutdown_confirm"), kbd)
}
func shutdownConfirm(c tele.Context, confirm string) error {
uid := c.Sender().ID
if !isAdmin(uid) {
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "admin_only")})
}
if confirm != "1" {
_ = c.Respond(&tele.CallbackResponse{Text: tr(uid, "canceled")})
return c.Bot().Delete(c.Callback().Message)
}
_ = c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "server_stopped")})
_ = c.Bot().Delete(c.Callback().Message)
_ = c.Send(tr(c.Sender().ID, "server_stopped"))
go func() {
torr.Shutdown()
}()
return nil
}
+363
View File
@@ -0,0 +1,363 @@
package tgbot
import (
"context"
"errors"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"golang.org/x/net/proxy"
tele "gopkg.in/telebot.v4"
"gopkg.in/telebot.v4/middleware"
"server/log"
"server/tgbot/config"
up "server/tgbot/upload"
)
func newTelegramHTTPClient() *http.Client {
const timeout = 5 * time.Minute
trimmed := strings.TrimSpace(config.Cfg.Socks5)
if trimmed == "" {
return &http.Client{Timeout: timeout}
}
raw := trimmed
if !strings.Contains(raw, "://") {
raw = "socks5://" + raw
}
u, err := url.Parse(raw)
if err != nil {
log.TLogln("tg cfg Socks5 parse err, using direct", err)
return &http.Client{Timeout: timeout}
}
if u.Scheme != "socks5" {
log.TLogln("tg cfg Socks5: only socks5 is supported, got", u.Scheme)
return &http.Client{Timeout: timeout}
}
proxyHost := u.Host
if proxyHost == "" {
log.TLogln("tg cfg Socks5: empty host, using direct")
return &http.Client{Timeout: timeout}
}
var auth *proxy.Auth
if u.User != nil {
pw, _ := u.User.Password()
auth = &proxy.Auth{User: u.User.Username(), Password: pw}
}
socksDial, err := proxy.SOCKS5("tcp", proxyHost, auth, proxy.Direct)
if err != nil {
log.TLogln("tg socks5 dialer err, using direct", err)
return &http.Client{Timeout: timeout}
}
log.TLogln("tg using SOCKS5 proxy", proxyHost)
transport := &http.Transport{
Proxy: nil, // respect explicit socks only, not HTTP_PROXY, for this client
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
_ = ctx
return socksDial.Dial(network, address)
},
}
return &http.Client{Transport: transport, Timeout: timeout}
}
func Start(token string) error {
config.LoadConfig()
loadUserLangs()
pref := tele.Settings{
URL: config.Cfg.HostTG,
Token: token,
Poller: &tele.LongPoller{Timeout: 5 * time.Minute},
ParseMode: tele.ModeHTML,
Client: newTelegramHTTPClient(),
}
log.TLogln("tg bot starting")
b, err := tele.NewBot(pref)
if err != nil {
log.TLogln("tg bot start err", err)
return err
}
up.TrFunc = tr
up.EscapeFunc = escapeHtml
if err := b.SetCommands([]tele.Command{
{Text: "help", Description: "Help and user ID"},
{Text: "start", Description: "Start bot"},
{Text: "list", Description: "List torrents"},
{Text: "add", Description: "Add torrent"},
{Text: "search", Description: "Search all (RuTor+Torznab)"},
{Text: "rutor", Description: "Search RuTor"},
{Text: "torznab", Description: "Search Torznab"},
{Text: "remove", Description: "Remove torrent"},
{Text: "status", Description: "Torrent status"},
{Text: "link", Description: "Stream link"},
{Text: "m3u", Description: "M3U playlist"},
{Text: "preload", Description: "Preload file"},
{Text: "queue", Description: "Upload queue status"},
{Text: "server", Description: "Server info"},
{Text: "stats", Description: "Summary statistics"},
{Text: "stat", Description: "Detailed status"},
{Text: "snake", Description: "Cache visualization"},
{Text: "clear", Description: "Remove all torrents"},
{Text: "hash", Description: "Show hashes"},
{Text: "export", Description: "Export torrents"},
{Text: "import", Description: "Import torrents"},
{Text: "categories", Description: "List categories"},
{Text: "lang", Description: "Set language RU|EN"},
}); err != nil {
log.TLogln("tg setcmd err", err)
}
if len(config.Cfg.WhiteIds) > 0 {
b.Use(middleware.Whitelist(config.Cfg.WhiteIds...))
}
if len(config.Cfg.BlackIds) > 0 {
b.Use(middleware.Blacklist(config.Cfg.BlackIds...))
}
b.Use(func(next tele.HandlerFunc) tele.HandlerFunc {
return func(c tele.Context) error {
if c.Sender() == nil {
return nil
}
if c.Message() != nil && c.Message().Text != "" {
cmd := logSafeStr(c.Message().Text, 60)
log.TLogln("tg cmd", logUser(c.Sender()), cmd)
}
err := next(c)
if err != nil {
log.TLogln("tg cmd err", logUser(c.Sender()), err)
}
return err
}
})
b.Handle("help", help)
b.Handle("Help", help)
b.Handle("/help", help)
b.Handle("/Help", help)
b.Handle("/start", help)
b.Handle("/id", help)
b.Handle("/list", list)
b.Handle("/clear", clear)
b.Handle("/add", cmdAdd)
b.Handle("/remove", cmdRemove)
b.Handle("/drop", cmdDrop)
b.Handle("/status", cmdStatus)
b.Handle("/server", cmdServer)
b.Handle("/link", cmdLink)
b.Handle("/play", cmdLink)
b.Handle("/cache", cmdCache)
b.Handle("/m3u", cmdM3u)
b.Handle("/m3uall", cmdM3uAll)
b.Handle("/search", cmdSearch)
b.Handle("/rutor", cmdSearchRutor)
b.Handle("/torznab", cmdTorznab)
b.Handle("/preload", cmdPreload)
b.Handle("/queue", up.ShowQueue)
b.Handle("/set", cmdSet)
b.Handle("/hash", cmdHash)
b.Handle("/export", cmdExport)
b.Handle("/import", cmdImport)
b.Handle("/categories", cmdCategories)
b.Handle("/echo", cmdEcho)
b.Handle("/db", cmdDb)
b.Handle("/viewed", cmdViewed)
b.Handle("/ffp", cmdFfp)
b.Handle("/speedtest", cmdSpeedtest)
b.Handle("/shutdown", adminOnly(cmdShutdown))
b.Handle("/settings", adminOnly(cmdSettings))
b.Handle("/preset", adminOnly(cmdPreset))
b.Handle("/lang", cmdLang)
b.Handle("/stats", cmdStats)
b.Handle("/stat", cmdStat)
b.Handle("/snake", cmdSnake)
b.Handle(tele.OnDocument, func(c tele.Context) error {
if c.Message() == nil {
return nil
}
doc := c.Message().Document
if doc == nil {
return nil
}
lowerName := strings.ToLower(doc.FileName)
isTorrent := strings.HasSuffix(lowerName, ".torrent") ||
strings.Contains(strings.ToLower(doc.MIME), "bittorrent")
if isTorrent {
err := addTorrentFromDocument(c, doc)
if err != nil {
return err
}
return list(c)
}
return nil
})
b.Handle(tele.OnText, func(c tele.Context) error {
txt := c.Text()
if handleSettingsInputReply(c) {
return nil
}
lower := strings.ToLower(txt)
if strings.HasPrefix(lower, "magnet:") || strings.HasPrefix(lower, "torrs://") ||
strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") ||
isHash(txt) {
err := addTorrent(c, txt)
if err != nil {
return err
}
return list(c)
} else if c.Message().ReplyTo != nil && c.Message().ReplyTo.ReplyMarkup != nil && len(c.Message().ReplyTo.ReplyMarkup.InlineKeyboard) > 0 {
var hash string
for _, row := range c.Message().ReplyTo.ReplyMarkup.InlineKeyboard {
for _, btn := range row {
if btn.Data == "" {
continue
}
if idx := strings.Index(btn.Data, "all|"); idx >= 0 {
h := btn.Data[idx+4:]
if len(h) >= 40 && isHash(h[:40]) {
hash = h[:40]
} else if isHash(h) {
hash = h
}
} else if isHash(btn.Data) {
hash = btn.Data
}
if hash != "" {
break
}
}
if hash != "" {
break
}
}
if hash != "" {
from, to, err := ParseRange(c.Sender().ID, c.Message().Text)
if err != nil {
_ = c.Send(tr(c.Sender().ID, "range_error"))
return err
}
up.AddRange(c, hash, from, to)
}
return nil
} else {
return c.Send(tr(c.Sender().ID, "add_magnet"))
}
})
b.Handle(tele.OnQuery, handleInlineQuery)
b.Handle(tele.OnCallback, func(c tele.Context) error {
args := c.Args()
if len(args) > 0 {
cbInfo := strings.TrimPrefix(args[0], "\f")
if len(args) >= 2 {
cbInfo += " " + args[1]
}
cbInfo = logSafeStr(cbInfo, 80)
log.TLogln("tg cb", logUser(c.Sender()), cbInfo)
}
err := handleCallback(c)
if err != nil && len(args) > 0 {
log.TLogln("tg cb err", logUser(c.Sender()), logSafeStr(args[0], 40), err)
}
return err
})
up.Start()
go b.Start()
return nil
}
func help(c tele.Context) error {
uid := c.Sender().ID
id := strconv.FormatInt(uid, 10)
var arr []string
if c.Sender().Username != "" {
arr = append(arr, c.Sender().Username)
}
if c.Sender().FirstName != "" {
arr = append(arr, c.Sender().FirstName)
}
if c.Sender().LastName != "" {
arr = append(arr, c.Sender().LastName)
}
msg := "🤖 <b>" + tr(uid, "help") + "</b>\n\n"
msg += "📋 <b>" + tr(uid, "help_main") + "</b>\n"
msg += " • /help — " + tr(uid, "help_help") + "\n"
msg += " • " + tr(uid, "help_list") + "\n"
msg += " • " + tr(uid, "help_clear") + "\n"
msg += " • " + tr(uid, "help_add") + "\n"
msg += " • " + tr(uid, "help_hash") + "\n"
msg += " • /stats, /stat — " + tr(uid, "help_stats") + ", " + tr(uid, "help_stat") + "\n\n"
msg += "🎛 <b>" + tr(uid, "help_manage") + "</b> " + tr(uid, "help_manage_desc") + "\n"
msg += " • " + tr(uid, "help_remove") + "\n"
msg += " • " + tr(uid, "help_links") + "\n\n"
msg += "🔍 <b>" + tr(uid, "help_search") + "</b> " + tr(uid, "help_search_desc") + "\n"
msg += " • " + tr(uid, "help_search_cmd") + "\n\n"
msg += "📦 <b>" + tr(uid, "help_export_import") + "</b>\n"
msg += " • " + tr(uid, "help_export") + "\n"
msg += " • " + tr(uid, "help_import") + "\n\n"
msg += "📁 <b>" + tr(uid, "help_categories_section") + "</b>\n"
msg += " • " + tr(uid, "help_categories") + "\n\n"
msg += "🖥 <b>" + tr(uid, "help_server") + "</b>\n"
msg += " • " + tr(uid, "help_server_cmd") + "\n"
msg += " • " + tr(uid, "help_echo") + "\n"
msg += " • " + tr(uid, "help_db") + "\n\n"
msg += "⚙️ <b>" + tr(uid, "help_other") + "</b>\n"
msg += " • " + tr(uid, "help_other_cmd") + "\n"
msg += " • " + tr(uid, "help_lang") + "\n"
msg += " • " + tr(uid, "help_admin") + "\n\n"
msg += "👤 " + tr(uid, "help_id") + ": <code>" + id + "</code>"
if len(arr) > 0 {
msg += " • " + strings.Join(arr, ", ")
}
return c.Send(msg)
}
func isHash(txt string) bool {
if len(txt) == 40 {
for _, c := range strings.ToLower(txt) {
switch c {
case 'a', 'b', 'c', 'd', 'e', 'f', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
default:
return false
}
}
return true
}
return false
}
func ParseRange(userID int64, rng string) (int, int, error) {
parts := strings.Split(rng, "-")
if len(parts) != 2 {
return -1, -1, errors.New(tr(userID, "parse_range_err"))
}
num1, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
if err1 != nil {
return -1, -1, err1
}
num2, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
if err2 != nil {
return -1, -1, err2
}
if num1 < 1 || num2 < 1 || num1 > num2 {
return -1, -1, errors.New(tr(userID, "parse_range_err"))
}
return num1, num2, nil
}
+39
View File
@@ -0,0 +1,39 @@
package tgbot
import (
"fmt"
"github.com/dustin/go-humanize"
tele "gopkg.in/telebot.v4"
"server/torr"
)
func cmdCache(c tele.Context) error {
arg := ""
if args := c.Args(); len(args) > 0 {
arg = args[0]
}
hash := resolveHash(c, arg)
if hash == "" {
return c.Send(tr(c.Sender().ID, "cache_usage"))
}
t := torr.GetTorrent(hash)
if t == nil {
return c.Send(tr(c.Sender().ID, "torrent_not_found") + ":\n<code>" + hash + "</code>")
}
st := t.CacheState()
if st == nil {
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "cache_unavailable"), hash))
}
uid := c.Sender().ID
txt := "💾 <b>" + escapeHtml(st.Torrent.Title) + "</b>\n\n"
txt += fmt.Sprintf("%s: %s\n", tr(uid, "cache_capacity"), humanize.IBytes(uint64(st.Capacity)))
txt += fmt.Sprintf("%s: %s\n", tr(uid, "cache_filled"), humanize.IBytes(uint64(st.Filled)))
txt += fmt.Sprintf("%s: %d\n", tr(uid, "cache_pieces"), st.PiecesCount)
txt += fmt.Sprintf("%s: %d\n", tr(uid, "cache_readers"), len(st.Readers))
txt += fmt.Sprintf("<code>%s</code>", hash)
return c.Send(txt)
}
+31
View File
@@ -0,0 +1,31 @@
package tgbot
import tele "gopkg.in/telebot.v4"
// handleCallback routes callback queries to appropriate handlers
func handleCallback(c tele.Context) error {
if c.Sender() == nil {
return nil
}
args := c.Args()
if len(args) == 0 {
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
}
switch args[0] {
case "\ffiles", "\fdelete", "\fupload", "\fuploadall", "\ffall", "\fcancel",
"\ffstatus", "\ffm3u", "\fflink", "\ffdrop", "\ffstatusrefresh", "\ffstatusstop",
"\fflist", "\ffrefresh", "\ffnop", "\ffpreload", "\ffitems", "\ffifresh",
"\ffsnakerefresh", "\ffsnakestop":
return handleCallbackTorrent(c, args)
case "\ffadd", "\ffmore":
return handleCallbackSearch(c, args)
case "\ffexport", "\ffexportrefresh", "\ffhash", "\ffhashrefresh",
"\ffstatusall", "\ffstatusallrefresh", "\ffdb", "\ffdbrefresh":
return handleCallbackExport(c, args)
case "\ffclear", "\ffshutdown", "\ffpreset", "\ffset":
return handleCallbackAdmin(c, args)
default:
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
}
}
+29
View File
@@ -0,0 +1,29 @@
package tgbot
import tele "gopkg.in/telebot.v4"
func handleCallbackAdmin(c tele.Context, args []string) error {
switch args[0] {
case "\ffclear":
if len(args) > 1 {
return clearConfirm(c, args[1])
}
case "\ffshutdown":
if len(args) > 1 {
return shutdownConfirm(c, args[1])
}
case "\ffpreset":
if len(args) > 1 {
return presetConfirm(c, args[1])
}
case "\ffset":
if len(args) > 1 {
action := args[1]
for i := 2; i < len(args); i++ {
action += "|" + args[i]
}
return settingsCallback(c, action)
}
}
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
}
+57
View File
@@ -0,0 +1,57 @@
package tgbot
import tele "gopkg.in/telebot.v4"
func handleCallbackExport(c tele.Context, args []string) error {
switch args[0] {
case "\ffexport":
data := ""
if len(args) > 1 {
data = args[1]
}
return callbackExportPage(c, data)
case "\ffexportrefresh":
data := ""
if len(args) > 1 {
data = args[1]
}
return callbackExportRefresh(c, data)
case "\ffhash":
data := ""
if len(args) > 1 {
data = args[1]
}
return callbackHashPage(c, data)
case "\ffhashrefresh":
data := ""
if len(args) > 1 {
data = args[1]
}
return callbackHashRefresh(c, data)
case "\ffstatusall":
data := ""
if len(args) > 1 {
data = args[1]
}
return callbackStatusAllPage(c, data)
case "\ffstatusallrefresh":
data := ""
if len(args) > 1 {
data = args[1]
}
return callbackStatusAllRefresh(c, data)
case "\ffdb":
data := ""
if len(args) > 1 {
data = args[1]
}
return callbackDbPage(c, data)
case "\ffdbrefresh":
data := ""
if len(args) > 1 {
data = args[1]
}
return callbackDbRefresh(c, data)
}
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
}
+17
View File
@@ -0,0 +1,17 @@
package tgbot
import tele "gopkg.in/telebot.v4"
func handleCallbackSearch(c tele.Context, args []string) error {
switch args[0] {
case "\ffadd":
if len(args) > 1 {
return callbackSearchAdd(c, args[1])
}
case "\ffmore":
if len(args) > 1 {
return callbackSearchMore(c, args[1])
}
}
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
}
+100
View File
@@ -0,0 +1,100 @@
package tgbot
import (
"strconv"
tele "gopkg.in/telebot.v4"
up "server/tgbot/upload"
)
func handleCallbackTorrent(c tele.Context, args []string) error {
switch args[0] {
case "\ffiles":
return files(c)
case "\fdelete":
if len(args) < 2 {
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
}
deleteTorrent(c)
_ = c.Bot().Delete(c.Callback().Message)
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "deleted")})
case "\fupload":
return upload(c)
case "\fuploadall", "\ffall":
return uploadall(c)
case "\fcancel":
if len(args) > 1 {
if num, err := strconv.Atoi(args[1]); err == nil {
up.Cancel(num)
_ = c.Bot().Delete(c.Callback().Message)
return c.Respond(&tele.CallbackResponse{})
}
}
return c.Respond(&tele.CallbackResponse{})
case "\ffstatus", "\ffm3u", "\fflink", "\ffdrop", "\ffstatusrefresh", "\ffstatusstop":
hash := ""
if len(args) >= 2 {
hash = args[1]
}
switch args[0] {
case "\ffstatus":
if hash == "" {
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
}
return callbackStatus(c, hash)
case "\ffstatusrefresh":
return callbackStatusRefresh(c, hash)
case "\ffstatusstop":
return callbackStatusStop(c, hash)
case "\ffm3u":
if hash == "" {
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
}
return callbackM3u(c, hash)
case "\fflink":
if len(args) < 2 {
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
}
return callbackLink(c, args[1])
case "\ffdrop":
if hash == "" {
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
}
return callbackDrop(c, hash)
}
case "\fflist":
if len(args) > 1 {
return callbackListPage(c, args[1])
}
case "\ffrefresh":
if len(args) > 1 {
return callbackListRefresh(c, args[1])
}
case "\ffitems":
if len(args) >= 3 {
return callbackFileListPage(c, args[1], args[2])
}
case "\ffifresh":
if len(args) >= 3 {
return callbackFileListRefresh(c, args[1], args[2])
}
case "\ffnop":
return c.Respond(&tele.CallbackResponse{})
case "\ffpreload":
if len(args) >= 3 {
return callbackPreload(c, args[1], args[2])
}
case "\ffsnakerefresh", "\ffsnakestop":
data := ""
if len(args) >= 2 {
data = args[1]
}
switch args[0] {
case "\ffsnakerefresh":
return callbackSnakeRefresh(c, data)
case "\ffsnakestop":
return callbackSnakeStop(c, data)
}
}
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
}
+37
View File
@@ -0,0 +1,37 @@
package tgbot
import (
"fmt"
"sort"
"strings"
tele "gopkg.in/telebot.v4"
"server/torr"
)
func cmdCategories(c tele.Context) error {
torrents := torr.ListTorrent()
if len(torrents) == 0 {
return c.Send(tr(c.Sender().ID, "no_torrents"))
}
uid := c.Sender().ID
catCount := make(map[string]int)
for _, t := range torrents {
cat := t.Category
if cat == "" {
cat = tr(uid, "categories_uncategorized")
}
catCount[cat]++
}
var cats []string
for c := range catCount {
cats = append(cats, c)
}
sort.Strings(cats)
var sb strings.Builder
fmt.Fprintf(&sb, "📁 <b>%s</b>\n\n", tr(uid, "categories_title"))
for _, cat := range cats {
fmt.Fprintf(&sb, "• %s: %d\n", escapeHtml(cat), catCount[cat])
}
return c.Send(strings.TrimSuffix(sb.String(), "\n"))
}
+52
View File
@@ -0,0 +1,52 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"server/log"
"server/settings"
)
type Config struct {
HostTG string
HostWeb string
Socks5 string
WhiteIds []int64
BlackIds []int64
}
var Cfg *Config
func LoadConfig() {
Cfg = &Config{}
fn := filepath.Join(settings.Path, "tg.cfg")
buf, err := os.ReadFile(fn)
if err != nil {
Cfg.WhiteIds = []int64{}
Cfg.BlackIds = []int64{}
Cfg.HostTG = "https://api.telegram.org"
buf, _ = json.MarshalIndent(Cfg, "", " ")
if buf != nil {
os.WriteFile(fn, buf, 0o600)
}
return
}
err = json.Unmarshal(buf, &Cfg)
if err != nil {
log.TLogln("tg config read err", err)
Cfg.WhiteIds = []int64{}
Cfg.BlackIds = []int64{}
}
if Cfg.HostTG == "" || (!strings.HasPrefix(Cfg.HostTG, "http://") && !strings.HasPrefix(Cfg.HostTG, "https://")) {
Cfg.HostTG = "https://api.telegram.org"
}
if Cfg.WhiteIds == nil {
Cfg.WhiteIds = []int64{}
}
if Cfg.BlackIds == nil {
Cfg.BlackIds = []int64{}
}
}
+98
View File
@@ -0,0 +1,98 @@
package tgbot
import (
"strconv"
"strings"
"github.com/dustin/go-humanize"
tele "gopkg.in/telebot.v4"
"server/log"
sets "server/settings"
)
const dbPageSize = 10
func cmdDb(c tele.Context) error {
return sendDbPage(c, 0)
}
func sendDbPage(c tele.Context, page int) error {
uid := c.Sender().ID
dbList := sets.ListTorrent()
if len(dbList) == 0 {
return c.Send(tr(uid, "db_empty"))
}
totalPages := (len(dbList) + dbPageSize - 1) / dbPageSize
if page < 0 {
page = 0
}
if page >= totalPages {
page = totalPages - 1
}
start := page * dbPageSize
end := start + dbPageSize
if end > len(dbList) {
end = len(dbList)
}
pageList := dbList[start:end]
var sb strings.Builder
sb.WriteString("📁 <b>" + tr(uid, "db_title") + "</b> (" + strconv.Itoa(len(dbList)) + ")\n\n")
for i, t := range pageList {
hash := t.InfoHash.HexString()
sb.WriteString(strconv.Itoa(start+i+1) + ". <b>" + escapeHtml(t.Title) + "</b>")
if t.Size > 0 {
sb.WriteString(" <i>" + humanize.IBytes(uint64(t.Size)) + "</i>")
}
sb.WriteString("\n<code>" + hash + "</code>\n\n")
}
msg := strings.TrimSuffix(sb.String(), "\n\n")
navRow := []tele.InlineButton{}
if totalPages > 1 {
if page > 0 {
navRow = append(navRow, tele.InlineButton{Text: "◀️", Unique: "fdb", 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: "fdb", Data: strconv.Itoa(page + 1)})
}
}
navRow = append(navRow, tele.InlineButton{Text: "🔄", Unique: "fdbrefresh", Data: strconv.Itoa(page)})
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{navRow}}
if err := c.Send(msg, kbd); err != nil {
log.TLogln("tg db send err", err)
return err
}
return nil
}
func callbackDbPage(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 sendDbPage(c, page)
}
func callbackDbRefresh(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 sendDbPage(c, page)
}
+49
View File
@@ -0,0 +1,49 @@
package tgbot
import (
"fmt"
tele "gopkg.in/telebot.v4"
"server/torr"
)
func deleteTorrent(c tele.Context) {
args := c.Args()
if len(args) < 2 {
return
}
hash := args[1]
if !isHash(hash) {
return
}
torr.RemTorrent(hash)
}
func clear(c tele.Context) error {
torrents := torr.ListTorrent()
count := len(torrents)
if count == 0 {
return c.Send(tr(c.Sender().ID, "no_torrents"))
}
uid := c.Sender().ID
btnYes := tele.InlineButton{Text: tr(uid, "btn_yes"), Unique: "fclear", Data: "1"}
btnNo := tele.InlineButton{Text: tr(uid, "btn_no"), Unique: "fclear", Data: "0"}
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{{btnYes, btnNo}}}
return c.Send(fmt.Sprintf(tr(uid, "clear_confirm"), count), kbd)
}
func clearConfirm(c tele.Context, confirm string) error {
uid := c.Sender().ID
if confirm != "1" {
_ = c.Respond(&tele.CallbackResponse{Text: tr(uid, "canceled")})
return c.Bot().Delete(c.Callback().Message)
}
torrents := torr.ListTorrent()
count := len(torrents)
for _, t := range torrents {
torr.RemTorrent(t.Hash().HexString())
}
_ = c.Respond(&tele.CallbackResponse{Text: tr(uid, "deleted")})
_ = c.Bot().Delete(c.Callback().Message)
return c.Send(fmt.Sprintf(tr(uid, "clear_done"), count))
}
+28
View File
@@ -0,0 +1,28 @@
package tgbot
import (
"fmt"
tele "gopkg.in/telebot.v4"
"server/torr"
)
func callbackDrop(c tele.Context, hash string) error {
torr.DropTorrent(hash)
_ = c.Bot().Delete(c.Callback().Message)
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "drop_done")})
}
func cmdDrop(c tele.Context) error {
arg := ""
if args := c.Args(); len(args) > 0 {
arg = args[0]
}
hash := resolveHash(c, arg)
if hash == "" {
return c.Send(tr(c.Sender().ID, "remove_usage"))
}
torr.DropTorrent(hash)
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "drop_done_hash"), hash))
}
+16
View File
@@ -0,0 +1,16 @@
package tgbot
import (
"fmt"
tele "gopkg.in/telebot.v4"
"server/version"
)
func cmdEcho(c tele.Context) error {
v := version.Version
if v == "" {
v = "unknown"
}
return c.Send(fmt.Sprintf("🔄 TorrServer %s", v))
}
+128
View File
@@ -0,0 +1,128 @@
package tgbot
import (
"bytes"
"fmt"
"net/url"
"strconv"
"strings"
tele "gopkg.in/telebot.v4"
"server/log"
"server/torr"
)
const exportPageSize = 10
func cmdExport(c tele.Context) error {
torrents := torr.ListTorrent()
if len(torrents) == 0 {
return c.Send(tr(c.Sender().ID, "no_torrents"))
}
uid := c.Sender().ID
var magnets strings.Builder
for _, t := range torrents {
hash := t.Hash().HexString()
title := t.Title
if title == "" {
title = t.Name()
}
magnet := fmt.Sprintf("magnet:?xt=urn:btih:%s", hash)
if title != "" {
magnet += "&dn=" + url.QueryEscape(title)
}
magnets.WriteString(magnet + "\n")
}
doc := &tele.Document{}
doc.FileName = "torrents.txt"
doc.FileReader = bytes.NewReader([]byte(strings.TrimSuffix(magnets.String(), "\n")))
doc.Caption = "📁 " + tr(uid, "export_file_caption")
if err := c.Send(doc); err != nil {
return err
}
return sendExportPage(c, 0)
}
func sendExportPage(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) + exportPageSize - 1) / exportPageSize
if page < 0 {
page = 0
}
if page >= totalPages {
page = totalPages - 1
}
start := page * exportPageSize
end := start + exportPageSize
if end > len(torrents) {
end = len(torrents)
}
pageTorrents := torrents[start:end]
uid := c.Sender().ID
var hashes strings.Builder
fmt.Fprintf(&hashes, "📁 <b>%s</b> (%d)\n\n", tr(uid, "export_title"), len(torrents))
for i, t := range pageTorrents {
hash := t.Hash().HexString()
title := t.Title
if title == "" {
title = t.Name()
}
fmt.Fprintf(&hashes, "%d. %s\n<code>%s</code>\n\n", start+i+1, escapeHtml(title), hash)
}
msg := strings.TrimSuffix(hashes.String(), "\n\n")
navRow := []tele.InlineButton{}
if totalPages > 1 {
if page > 0 {
navRow = append(navRow, tele.InlineButton{Text: "◀️", Unique: "fexport", 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: "fexport", Data: strconv.Itoa(page + 1)})
}
}
navRow = append(navRow, tele.InlineButton{Text: "🔄", Unique: "fexportrefresh", Data: strconv.Itoa(page)})
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{navRow}}
if err := c.Send(msg, kbd); err != nil {
log.TLogln("tg export send err", err)
return err
}
return nil
}
func callbackExportPage(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 sendExportPage(c, page)
}
func callbackExportRefresh(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 sendExportPage(c, page)
}
+209
View File
@@ -0,0 +1,209 @@
package tgbot
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"server/ffprobe"
"server/settings"
"server/torr"
"github.com/dustin/go-humanize"
tele "gopkg.in/telebot.v4"
ffp "gopkg.in/vansante/go-ffprobe.v2"
)
// TODO: Use internal API for ffp
func cmdFfp(c tele.Context) error {
uid := c.Sender().ID
args := c.Args()
if len(args) < 2 {
return c.Send(tr(uid, "ffp_usage"))
}
hash := resolveHash(c, args[0])
if hash == "" {
return c.Send(tr(uid, "invalid_hash"))
}
id, err := strconv.Atoi(args[1])
if err != nil || id < 1 {
return c.Send(tr(uid, "ffp_file_index"))
}
asJSON := false
if len(args) >= 3 {
last := strings.ToLower(strings.TrimSpace(args[len(args)-1]))
if last == "json" || last == "--json" || last == "-j" {
asJSON = true
}
}
t := torr.GetTorrent(hash)
if t == nil {
return c.Send(tr(uid, "torrent_not_found"))
}
proto := "http"
port := settings.Port
if settings.Ssl {
proto = "https"
port = settings.SslPort
}
link := fmt.Sprintf("%s://127.0.0.1:%s/play/%s/%d", proto, port, hash, id)
data, err := ffprobe.ProbeUrl(link)
if err != nil {
return c.Send(fmt.Sprintf(tr(uid, "ffp_error"), err.Error()))
}
var msg string
if asJSON {
buf, _ := json.MarshalIndent(data, "", " ")
msg = "<pre>" + strings.ReplaceAll(string(buf), "<", "&lt;") + "</pre>"
if len(msg) > 4000 {
msg = msg[:4000] + "\n...</pre>"
}
} else {
msg = formatFfpHuman(data, uid)
if len(msg) > 4000 {
msg = msg[:4000] + "\n..."
}
}
return c.Send(msg)
}
func formatFfpHuman(data *ffp.ProbeData, uid int64) string {
var sb strings.Builder
if data.Format != nil {
f := data.Format
sb.WriteString("<b>📁 " + tr(uid, "ffp_format") + "</b>\n")
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_container"), f.FormatLongName)
if f.DurationSeconds > 0 {
d := int(f.DurationSeconds)
h, m, s := d/3600, (d%3600)/60, d%60
fmt.Fprintf(&sb, " %s: %02d:%02d:%02d\n", tr(uid, "ffp_duration"), h, m, s)
}
if f.Size != "" {
if size, err := strconv.ParseInt(f.Size, 10, 64); err == nil {
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_size"), humanize.IBytes(uint64(size)))
} else {
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_size"), f.Size)
}
}
if f.BitRate != "" {
if br, err := strconv.ParseInt(f.BitRate, 10, 64); err == nil {
fmt.Fprintf(&sb, " %s: %s/s\n", tr(uid, "ffp_bitrate"), humanize.IBytes(uint64(br)))
} else {
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_bitrate"), f.BitRate)
}
}
sb.WriteString("\n")
}
sb.WriteString("<b>🎬 " + tr(uid, "ffp_streams") + "</b>\n\n")
for i, s := range data.Streams {
title := getTag(s.TagList, "title")
lang := getTag(s.TagList, "language")
if lang != "" {
lang = " [" + lang + "]"
}
switch s.CodecType {
case "video":
fmt.Fprintf(&sb, "<b>#%d %s</b>%s\n", i, tr(uid, "ffp_video"), lang)
fmt.Fprintf(&sb, " %s: %s", tr(uid, "ffp_codec"), s.CodecLongName)
if s.Profile != "" {
fmt.Fprintf(&sb, " (%s)", s.Profile)
}
sb.WriteString("\n")
if s.Width > 0 && s.Height > 0 {
fmt.Fprintf(&sb, " %s: %d×%d", tr(uid, "ffp_resolution"), s.Width, s.Height)
if s.DisplayAspectRatio != "" && s.DisplayAspectRatio != "0:0" {
fmt.Fprintf(&sb, " (%s)", s.DisplayAspectRatio)
}
sb.WriteString("\n")
}
if s.PixFmt != "" {
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_pixel"), s.PixFmt)
}
if s.RFrameRate != "" && s.RFrameRate != "0/0" {
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_fps"), s.RFrameRate)
}
if s.BitRate != "" {
if br, err := strconv.ParseInt(s.BitRate, 10, 64); err == nil {
fmt.Fprintf(&sb, " %s: %s/s\n", tr(uid, "ffp_bitrate"), humanize.IBytes(uint64(br)))
}
}
if s.ColorSpace != "" || s.ColorTransfer != "" {
fmt.Fprintf(&sb, " %s: %s / %s / %s\n", tr(uid, "ffp_color"), s.ColorSpace, s.ColorTransfer, s.ColorPrimaries)
}
if title != "" {
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_title"), escapeHtml(title))
}
case "audio":
fmt.Fprintf(&sb, "<b>#%d %s</b>%s\n", i, tr(uid, "ffp_audio"), lang)
fmt.Fprintf(&sb, " %s: %s", tr(uid, "ffp_codec"), s.CodecLongName)
if s.Profile != "" {
fmt.Fprintf(&sb, " (%s)", s.Profile)
}
sb.WriteString("\n")
if s.SampleRate != "" {
fmt.Fprintf(&sb, " %s: %s Hz\n", tr(uid, "ffp_samplerate"), s.SampleRate)
}
if s.Channels > 0 {
ch := s.ChannelLayout
if ch == "" {
ch = fmt.Sprintf("%d ch", s.Channels)
}
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_channels"), ch)
}
if s.BitRate != "" {
if br, err := strconv.ParseInt(s.BitRate, 10, 64); err == nil {
fmt.Fprintf(&sb, " %s: %s/s\n", tr(uid, "ffp_bitrate"), humanize.IBytes(uint64(br)))
}
}
if title != "" {
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_title"), escapeHtml(title))
}
case "subtitle":
fmt.Fprintf(&sb, "<b>#%d %s</b>%s\n", i, tr(uid, "ffp_subtitle"), lang)
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_codec"), s.CodecLongName)
if title != "" {
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_title"), escapeHtml(title))
}
default:
fmt.Fprintf(&sb, "<b>#%d %s</b>\n", i, s.CodecType)
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_codec"), s.CodecLongName)
}
sb.WriteString("\n")
}
return strings.TrimSuffix(sb.String(), "\n\n")
}
func getTag(tags ffp.Tags, key string) string {
if tags == nil {
return ""
}
if v, ok := tags[key]; ok && v != nil {
if s, ok := v.(string); ok {
return s
}
return fmt.Sprint(v)
}
for k, v := range tags {
if strings.HasPrefix(k, key+"-") && v != nil {
if s, ok := v.(string); ok {
return s
}
return fmt.Sprint(v)
}
}
return ""
}
+229
View File
@@ -0,0 +1,229 @@
package tgbot
import (
"fmt"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/dustin/go-humanize"
tele "gopkg.in/telebot.v4"
"server/log"
sets "server/settings"
"server/torr"
)
// Telegram limits the serialized reply_markup size; many file rows with long
// labels/URLs would exceed it (e.g. "reply markup is too long").
const filesPageSize = 5
// Inline button text is limited to 64 characters in the Bot API.
func truncateBtnText(s string) string {
const max = 64
r := []rune(s)
if len(r) <= max {
return s
}
if max <= 1 {
return string(r[:max])
}
return string(r[:max-1]) + "…"
}
func files(c tele.Context) error {
args := c.Args()
if len(args) < 2 {
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
}
hash := args[1]
if !isHash(hash) {
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
}
msg, err := c.Bot().Send(c.Sender(), tr(c.Sender().ID, "connecting"))
t := torr.GetTorrent(hash)
if t == nil {
if err == nil {
_, _ = c.Bot().Edit(msg, tr(c.Sender().ID, "torrent_not_found")+":\n<code>"+hash+"</code>")
}
return nil
}
if err == nil {
api := c.Bot()
recipient := c.Sender()
uid := c.Sender().ID
go sendFilesList(api, recipient, msg, hash, uid, 0)
}
return err
}
// sendFilesList shows one page of per-file actions; fitems / fifresh change the page in-place.
func sendFilesList(api tele.API, recipient tele.Recipient, statusMsg *tele.Message, hash string, uid int64, page int) {
t := torr.GetTorrent(hash)
for t != nil && !t.WaitInfo() {
time.Sleep(time.Second)
t = torr.GetTorrent(hash)
}
_ = api.Delete(statusMsg)
t = torr.GetTorrent(hash)
if t == nil {
return
}
ti := t.Status()
if ti == nil {
return
}
host := getHost()
txt, kbd := buildFilesListView(t, host, uid, page)
if kbd == nil {
return
}
if _, err := api.Send(recipient, txt, kbd, tele.ModeHTML); err != nil {
log.TLogln("tg files send err", err)
}
}
func buildFilesListView(t *torr.Torrent, host string, uid int64, page int) (string, *tele.ReplyMarkup) {
ti := t.Status()
if ti == nil {
return "", nil
}
hex := t.Hash().HexString()
n := len(ti.FileStats)
if n == 0 {
return "", nil
}
totalPages := (n + filesPageSize - 1) / filesPageSize
if page < 0 {
page = 0
}
if page >= totalPages {
page = totalPages - 1
}
start := page * filesPageSize
end := start + filesPageSize
if end > n {
end = n
}
pageFiles := ti.FileStats[start:end]
viewedSet := make(map[int]struct{})
for _, v := range sets.ListViewed(ti.Hash) {
viewedSet[v.FileIndex] = struct{}{}
}
txt := "📁 <b>" + escapeHtml(ti.Title) + "</b> " +
"<i>" + humanize.IBytes(uint64(ti.TorrentSize)) + "</i>\n\n" +
"<code>" + ti.Hash + "</code>"
if totalPages > 1 {
txt += "\n\n" + tr(uid, "page") + " " + strconv.Itoa(page+1) + "/" + strconv.Itoa(totalPages)
}
if n > 1 {
txt += "\n\n" + fmt.Sprintf(tr(uid, "files_range_hint"), n)
}
m := &tele.ReplyMarkup{}
var rows []tele.Row
for _, f := range pageFiles {
viewedMark := ""
if _, ok := viewedSet[f.Id]; ok {
viewedMark = "✓ "
}
baseName := filepath.Base(f.Path)
mline := viewedMark + "#" + strconv.Itoa(f.Id) + ": " + humanize.IBytes(uint64(f.Length)) + " — " + baseName
fileLabel := truncateBtnText(mline)
idStr := strconv.Itoa(f.Id)
streamURL := host + "/stream/" + filepath.Base(f.Path) + "?link=" + hex + "&index=" + idStr + "&play"
rows = append(rows, m.Row(
m.Data(fileLabel, "upload", ti.Hash, idStr),
m.URL(tr(uid, "files_link"), streamURL),
m.Data("⏳", "fpreload", ti.Hash, idStr),
))
}
if totalPages > 1 {
var nav []tele.Btn
if page > 0 {
nav = append(nav, m.Data("◀️", "fitems", strconv.Itoa(page-1), ti.Hash))
}
nav = append(nav, m.Data(strconv.Itoa(page+1)+"/"+strconv.Itoa(totalPages), "fnop"))
if page < totalPages-1 {
nav = append(nav, m.Data("▶️", "fitems", strconv.Itoa(page+1), ti.Hash))
}
nav = append(nav, m.Data("🔄", "fifresh", strconv.Itoa(page), ti.Hash))
rows = append(rows, m.Row(nav...))
} else {
rows = append(rows, m.Row(m.Data("🔄", "fifresh", strconv.Itoa(page), ti.Hash)))
}
if n > 1 {
rows = append(rows, m.Row(m.Data(tr(uid, "files_download_all"), "fall", "all", ti.Hash)))
}
m.Inline(rows...)
return txt, m
}
func callbackFileListPage(c tele.Context, pageStr, hash string) error {
page, err := strconv.Atoi(pageStr)
if err != nil {
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
}
if !isHash(hash) {
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
}
_ = c.Respond(&tele.CallbackResponse{})
return editFilesListMessage(c, hash, c.Sender().ID, page)
}
func callbackFileListRefresh(c tele.Context, pageStr, hash string) error {
if !isHash(hash) {
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
}
page, err := strconv.Atoi(pageStr)
if err != nil {
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
}
_ = c.Respond(&tele.CallbackResponse{Text: "🔄"})
return editFilesListMessage(c, hash, c.Sender().ID, page)
}
func editFilesListMessage(c tele.Context, hash string, uid int64, page int) error {
t := torr.GetTorrent(hash)
if t == nil {
_ = c.Send(tr(uid, "torrent_not_found") + ":\n<code>" + hash + "</code>")
return nil
}
for t != nil && !t.WaitInfo() {
time.Sleep(time.Second)
t = torr.GetTorrent(hash)
}
t = torr.GetTorrent(hash)
if t == nil {
_ = c.Send(tr(uid, "torrent_not_found") + ":\n<code>" + hash + "</code>")
return nil
}
host := getHost()
txt, kbd := buildFilesListView(t, host, uid, page)
if kbd == nil {
log.TLogln("tg files: empty kbd for hash", logSafeStr(hash, 20))
return nil
}
if c.Callback() == nil || c.Callback().Message == nil {
_, err := c.Bot().Send(c.Sender(), txt, kbd, tele.ModeHTML)
return err
}
_, err := c.Bot().Edit(c.Callback().Message, txt, kbd, tele.ModeHTML)
if err != nil {
if strings.Contains(err.Error(), "message is not modified") {
return nil
}
log.TLogln("tg files edit err", err)
_, _ = c.Bot().Send(c.Sender(), tr(uid, "error")+":\n"+escapeHtml(err.Error()), tele.ModeHTML)
return err
}
return nil
}
+156
View File
@@ -0,0 +1,156 @@
package tgbot
import (
"strconv"
"strings"
tele "gopkg.in/telebot.v4"
"server/log"
"server/torr"
)
// resolveHash returns hash from: 1) full hash string, 2) numeric index from list, 3) reply-to message
func resolveHash(c tele.Context, arg string) string {
arg = strings.TrimSpace(arg)
if arg == "" {
return extractHashFromReply(c)
}
if isHash(arg) {
return arg
}
if idx, err := strconv.Atoi(arg); err == nil && idx > 0 {
torrents := torr.ListTorrent()
if idx <= len(torrents) {
return torrents[idx-1].Hash().HexString()
}
}
return ""
}
func extractHashFromReply(c tele.Context) string {
if c.Message() == nil || c.Message().ReplyTo == nil {
return ""
}
reply := c.Message().ReplyTo
if reply.ReplyMarkup == nil || len(reply.ReplyMarkup.InlineKeyboard) == 0 {
return ""
}
for _, row := range reply.ReplyMarkup.InlineKeyboard {
for _, btn := range row {
if btn.Data == "" {
continue
}
if isHash(btn.Data) {
return btn.Data
}
if idx := strings.Index(btn.Data, "all|"); idx >= 0 {
h := btn.Data[idx+4:]
if len(h) >= 40 && isHash(h[:40]) {
return h[:40]
}
if isHash(h) {
return h
}
}
}
}
return ""
}
const hashPageSize = 10
func cmdHash(c tele.Context) error {
args := c.Args()
torrents := torr.ListTorrent()
if len(torrents) == 0 {
return c.Send(tr(c.Sender().ID, "no_torrents"))
}
if len(args) > 0 {
idx, err := strconv.Atoi(strings.TrimSpace(args[0]))
if err != nil || idx < 1 || idx > len(torrents) {
return c.Send(tr(c.Sender().ID, "invalid_index"))
}
hash := torrents[idx-1].Hash().HexString()
return c.Send("🔑 <code>" + hash + "</code>")
}
return sendHashPage(c, 0)
}
func sendHashPage(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) + hashPageSize - 1) / hashPageSize
if page < 0 {
page = 0
}
if page >= totalPages {
page = totalPages - 1
}
start := page * hashPageSize
end := start + hashPageSize
if end > len(torrents) {
end = len(torrents)
}
pageTorrents := torrents[start:end]
uid := c.Sender().ID
var sb strings.Builder
sb.WriteString("🔑 <b>" + tr(uid, "hash_title") + "</b> (" + strconv.Itoa(len(torrents)) + ")\n\n")
for i, t := range pageTorrents {
sb.WriteString(strconv.Itoa(start+i+1) + ". <code>" + t.Hash().HexString() + "</code>\n")
sb.WriteString(" " + escapeHtml(t.Title) + "\n\n")
}
msg := strings.TrimSuffix(sb.String(), "\n\n")
navRow := []tele.InlineButton{}
if totalPages > 1 {
if page > 0 {
navRow = append(navRow, tele.InlineButton{Text: "◀️", Unique: "fhash", 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: "fhash", Data: strconv.Itoa(page + 1)})
}
}
navRow = append(navRow, tele.InlineButton{Text: "🔄", Unique: "fhashrefresh", Data: strconv.Itoa(page)})
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{navRow}}
if err := c.Send(msg, kbd); err != nil {
log.TLogln("tg hash send err", err)
return err
}
return nil
}
func callbackHashPage(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 sendHashPage(c, page)
}
func callbackHashRefresh(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 sendHashPage(c, page)
}
+60
View File
@@ -0,0 +1,60 @@
package tgbot
import (
"fmt"
"regexp"
"strings"
tele "gopkg.in/telebot.v4"
)
var magnetRegex = regexp.MustCompile(`magnet:\?[^\s]+`)
var torrsRegex = regexp.MustCompile(`torrs://[^\s]+`)
var hashRegex = regexp.MustCompile(`\b([a-fA-F0-9]{40})\b`)
func cmdImport(c tele.Context) error {
text := ""
if c.Message() != nil && c.Message().Text != "" {
text = strings.TrimPrefix(strings.TrimSpace(c.Message().Text), "/import")
text = strings.TrimSpace(text)
}
if text == "" {
return c.Send(tr(c.Sender().ID, "import_usage"))
}
var links []string
seen := make(map[string]bool)
for _, m := range magnetRegex.FindAllString(text, -1) {
m = strings.TrimSpace(m)
if m != "" && !seen[m] {
seen[m] = true
links = append(links, m)
}
}
for _, m := range torrsRegex.FindAllString(text, -1) {
m = strings.TrimSpace(m)
if m != "" && !seen[m] {
seen[m] = true
links = append(links, m)
}
}
for _, m := range hashRegex.FindAllString(text, -1) {
h := strings.ToLower(strings.TrimSpace(m))
if h != "" && !seen[h] {
seen[h] = true
links = append(links, h)
}
}
if len(links) == 0 {
return c.Send(tr(c.Sender().ID, "import_no_links"))
}
uid := c.Sender().ID
added := 0
for _, link := range links {
if err := addTorrent(c, link); err != nil {
_ = c.Send(fmt.Sprintf(tr(uid, "add_error"), err.Error()))
continue
}
added++
}
return c.Send(fmt.Sprintf(tr(uid, "import_done"), added, len(links)))
}
+103
View File
@@ -0,0 +1,103 @@
package tgbot
import (
"fmt"
"strconv"
"strings"
tele "gopkg.in/telebot.v4"
"server/rutor"
"server/rutor/models"
sets "server/settings"
"server/torr"
"server/torznab"
)
const inlineMaxResults = 20
func handleInlineQuery(c tele.Context) error {
query := strings.TrimSpace(c.Query().Text)
uid := int64(0)
if c.Query().Sender != nil {
uid = c.Query().Sender.ID
}
var results tele.Results
id := 0
if query == "" || strings.ToLower(query) == "list" || strings.ToLower(query) == "play" {
torrents := torr.ListTorrent()
host := getHost()
for _, t := range torrents {
if id >= inlineMaxResults {
break
}
hash := t.Hash().HexString()
url := fmt.Sprintf("%s/play/%s/1", host, hash)
title := t.Title
if len(title) > 60 {
title = title[:57] + "..."
}
results = append(results, &tele.ArticleResult{
ResultBase: tele.ResultBase{ID: strconv.Itoa(id)},
Title: "▶ " + title,
Description: hash[:8] + "...",
URL: url,
Text: url,
})
id++
}
}
if len(query) >= 2 && sets.BTsets != nil && (sets.BTsets.EnableRutorSearch || sets.BTsets.EnableTorznabSearch) {
var list []*models.TorrentDetails
if sets.BTsets.EnableRutorSearch {
list = append(list, rutor.Search(query)...)
}
if sets.BTsets.EnableTorznabSearch {
list = append(list, torznab.Search(query, -1)...)
}
for _, item := range list {
if id >= inlineMaxResults {
break
}
link := item.Magnet
if link == "" {
link = item.Link
}
if link == "" {
continue
}
title := item.Title
if len(title) > 60 {
title = title[:57] + "..."
}
size := item.Size
if size == "" {
size = "?"
}
results = append(results, &tele.ArticleResult{
ResultBase: tele.ResultBase{ID: strconv.Itoa(id)},
Title: " " + title,
Description: fmt.Sprintf("%s S:%d P:%d", size, item.Seed, item.Peer),
Text: link,
})
id++
}
}
if len(results) == 0 {
results = append(results, &tele.ArticleResult{
ResultBase: tele.ResultBase{ID: "0"},
Title: tr(uid, "no_torrents"),
Description: tr(uid, "add_magnet"),
Text: "",
})
}
return c.Answer(&tele.QueryResponse{
Results: results,
CacheTime: 60,
IsPersonal: true,
})
}
+118
View File
@@ -0,0 +1,118 @@
package tgbot
import (
"encoding/json"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
tele "gopkg.in/telebot.v4"
"server/settings"
)
const (
LangRU = "ru"
LangEN = "en"
saveUserLangsWait = 2 * time.Second
)
var (
userLang = make(map[int64]string)
userLangMu sync.RWMutex
saveUserLangsMu sync.Mutex
saveUserLangsTimer *time.Timer
)
func getUserLang(userID int64) string {
userLangMu.RLock()
defer userLangMu.RUnlock()
if lang, ok := userLang[userID]; ok {
return lang
}
return LangRU
}
func setUserLang(userID int64, lang string) {
if lang != LangRU && lang != LangEN {
return
}
userLangMu.Lock()
userLang[userID] = lang
userLangMu.Unlock()
scheduleSaveUserLangs()
}
func scheduleSaveUserLangs() {
saveUserLangsMu.Lock()
defer saveUserLangsMu.Unlock()
if saveUserLangsTimer != nil {
saveUserLangsTimer.Stop()
}
saveUserLangsTimer = time.AfterFunc(saveUserLangsWait, func() {
saveUserLangsMu.Lock()
saveUserLangsTimer = nil
saveUserLangsMu.Unlock()
saveUserLangs()
})
}
func loadUserLangs() {
fn := filepath.Join(settings.Path, "tg_langs.json")
buf, err := os.ReadFile(fn)
if err != nil {
return
}
var m map[string]string
if err := json.Unmarshal(buf, &m); err != nil {
return
}
userLangMu.Lock()
for k, v := range m {
if v == LangRU || v == LangEN {
if id, parseErr := strconv.ParseInt(k, 10, 64); parseErr == nil {
userLang[id] = v
}
}
}
userLangMu.Unlock()
}
func saveUserLangs() {
userLangMu.RLock()
m := make(map[string]string)
for k, v := range userLang {
m[strconv.FormatInt(k, 10)] = v
}
userLangMu.RUnlock()
buf, err := json.Marshal(m)
if err != nil {
return
}
fn := filepath.Join(settings.Path, "tg_langs.json")
_ = os.WriteFile(fn, buf, 0o600)
}
func cmdLang(c tele.Context) error {
uid := c.Sender().ID
args := c.Args()
if len(args) == 0 {
lang := getUserLang(uid)
if lang == LangEN {
return c.Send(tr(uid, "lang_current_en") + "\n/lang RU — " + tr(uid, "lang_switch_ru"))
}
return c.Send(tr(uid, "lang_current_ru") + "\n/lang EN — " + tr(uid, "lang_switch_en"))
}
lang := strings.ToUpper(strings.TrimSpace(args[0]))
if lang == "EN" {
setUserLang(uid, LangEN)
return c.Send(tr(uid, "lang_set_en"))
}
if lang == "RU" || lang == "РУ" {
setUserLang(uid, LangRU)
return c.Send(tr(uid, "lang_set"))
}
return c.Send(tr(uid, "lang_usage"))
}
+76
View File
@@ -0,0 +1,76 @@
package tgbot
import (
"fmt"
"strconv"
"strings"
tele "gopkg.in/telebot.v4"
"server/torr"
)
func callbackLink(c tele.Context, data string) error {
uid := c.Sender().ID
index := 1
hash := data
if idx := strings.Index(data, "|"); idx >= 0 && idx+1 < len(data) {
if i, err := strconv.Atoi(data[idx+1:]); err == nil && i > 0 {
index = i
hash = data[:idx]
}
}
t := torr.GetTorrent(hash)
if t == nil {
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "torrent_not_found")})
}
if !strings.Contains(data, "|") && t.WaitInfo() {
st := t.Status()
if st != nil && len(st.FileStats) > 1 {
maxFiles := 5
if len(st.FileStats) < maxFiles {
maxFiles = len(st.FileStats)
}
var rows [][]tele.InlineButton
for i := 0; i < maxFiles; i++ {
f := st.FileStats[i]
btn := tele.InlineButton{Text: fmt.Sprintf("#%d", f.Id), Unique: "flink", Data: hash + "|" + strconv.Itoa(f.Id)}
rows = append(rows, []tele.InlineButton{btn})
}
kbd := &tele.ReplyMarkup{InlineKeyboard: rows}
_ = c.Respond(&tele.CallbackResponse{})
return c.Send("🔗 "+tr(uid, "btn_link")+":", kbd)
}
}
host := getHost()
url := fmt.Sprintf("%s/play/%s/%d", host, hash, index)
_ = c.Respond(&tele.CallbackResponse{})
return c.Send(fmt.Sprintf(tr(uid, "link_play"), url))
}
func cmdLink(c tele.Context) error {
args := c.Args()
arg := ""
if len(args) > 0 {
arg = args[0]
}
hash := resolveHash(c, arg)
if hash == "" {
return c.Send(tr(c.Sender().ID, "link_usage"))
}
index := 1
if len(args) > 1 {
if i, err := strconv.Atoi(args[1]); err == nil && i > 0 {
index = i
}
}
t := torr.GetTorrent(hash)
if t == nil {
return c.Send(tr(c.Sender().ID, "torrent_not_found") + ":\n<code>" + hash + "</code>")
}
host := getHost()
url := fmt.Sprintf("%s/play/%s/%d", host, hash, index)
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "link_play"), url))
}
+139
View File
@@ -0,0 +1,139 @@
package tgbot
import (
"strconv"
"strings"
"github.com/dustin/go-humanize"
tele "gopkg.in/telebot.v4"
"server/log"
"server/torr"
)
const listPageSize = 5
func list(c tele.Context) error {
args := c.Args()
compact := len(args) > 0 && strings.ToLower(args[0]) == "compact"
return sendListPage(c, 0, compact)
}
func sendListPage(c tele.Context, page int, compact bool) error {
torrents := torr.ListTorrent()
if len(torrents) == 0 {
return c.Send(tr(c.Sender().ID, "no_torrents"))
}
totalPages := (len(torrents) + listPageSize - 1) / listPageSize
if page < 0 {
page = 0
}
if page >= totalPages {
page = totalPages - 1
}
start := page * listPageSize
end := start + listPageSize
if end > len(torrents) {
end = len(torrents)
}
pageTorrents := torrents[start:end]
uid := c.Sender().ID
for _, t := range pageTorrents {
hash := t.Hash().HexString()
var rows [][]tele.InlineButton
if compact {
rows = [][]tele.InlineButton{
{
tele.InlineButton{Text: tr(uid, "btn_files"), Unique: "files", Data: hash},
tele.InlineButton{Text: tr(uid, "btn_status"), Unique: "fstatus", Data: hash},
tele.InlineButton{Text: tr(uid, "btn_delete"), Unique: "delete", Data: hash},
},
}
} else {
rows = [][]tele.InlineButton{
{
tele.InlineButton{Text: tr(uid, "btn_files"), Unique: "files", Data: hash},
tele.InlineButton{Text: tr(uid, "btn_delete"), Unique: "delete", Data: hash},
tele.InlineButton{Text: tr(uid, "btn_status"), Unique: "fstatus", Data: hash},
tele.InlineButton{Text: tr(uid, "btn_m3u"), Unique: "fm3u", Data: hash},
},
{
tele.InlineButton{Text: tr(uid, "btn_link"), Unique: "flink", Data: hash},
tele.InlineButton{Text: tr(uid, "btn_drop"), Unique: "fdrop", Data: hash},
},
}
}
torrKbd := &tele.ReplyMarkup{InlineKeyboard: rows}
msg := "<b>" + escapeHtml(t.Title) + "</b>"
if t.Size > 0 {
msg += " <i>" + humanize.IBytes(uint64(t.Size)) + "</i>"
}
msg += "\n<code>" + hash + "</code>"
if err := c.Send(msg, torrKbd); err != nil {
log.TLogln("tg list send err", err)
return err
}
}
compactStr := "0"
if compact {
compactStr = "1"
}
navRow := []tele.InlineButton{}
if totalPages > 1 {
if page > 0 {
navRow = append(navRow, tele.InlineButton{Text: "◀️", Unique: "flist", Data: strconv.Itoa(page-1) + "|" + compactStr})
}
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: "flist", Data: strconv.Itoa(page+1) + "|" + compactStr})
}
}
navRow = append(navRow, tele.InlineButton{Text: "🔄", Unique: "frefresh", Data: strconv.Itoa(page) + "|" + compactStr})
if len(navRow) > 1 || totalPages == 1 {
if err := c.Send(tr(uid, "page")+" "+strconv.Itoa(page+1)+"/"+strconv.Itoa(totalPages), &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{navRow}}); err != nil {
log.TLogln("tg list nav err", err)
return err
}
}
return nil
}
func callbackListPage(c tele.Context, data string) error {
parts := strings.Split(data, "|")
page := 0
compact := false
if len(parts) > 0 && parts[0] != "" {
if p, err := strconv.Atoi(parts[0]); err == nil {
page = p
}
}
if len(parts) > 1 && parts[1] == "1" {
compact = true
}
_ = c.Respond(&tele.CallbackResponse{})
if c.Callback().Message != nil {
_ = c.Bot().Delete(c.Callback().Message)
}
return sendListPage(c, page, compact)
}
func callbackListRefresh(c tele.Context, data string) error {
parts := strings.Split(data, "|")
page := 0
compact := false
if len(parts) > 0 && parts[0] != "" {
if p, err := strconv.Atoi(parts[0]); err == nil {
page = p
}
}
if len(parts) > 1 && parts[1] == "1" {
compact = true
}
_ = c.Respond(&tele.CallbackResponse{Text: "🔄"})
if c.Callback().Message != nil {
_ = c.Bot().Delete(c.Callback().Message)
}
return sendListPage(c, page, compact)
}
+14
View File
@@ -0,0 +1,14 @@
package tgbot
func tr(userID int64, key string) string {
lang := getUserLang(userID)
if lang == LangEN {
if s, ok := msgEN[key]; ok {
return s
}
}
if s, ok := msgRU[key]; ok {
return s
}
return key
}
+276
View File
@@ -0,0 +1,276 @@
package tgbot
var msgEN = map[string]string{
"help": "TorrServer management bot",
"help_main": "Main",
"help_manage": "Management",
"help_status": "Status & links",
"help_search": "Search",
"help_other": "Other",
"help_server": "Server",
"help_use_index": "Use number from /list: /remove 1, /status 2",
"help_reply": "Or reply to torrent message with command",
"help_id": "Your id",
"no_torrents": "📭 No torrents",
"torrent_not_found": "❌ Torrent not found",
"invalid_hash": "❌ Invalid hash. Use 40 chars (a-f, 0-9)",
"invalid_index": "❌ Invalid index. Use number from /list",
"connecting": "⏳ Connecting to torrent...",
"add_magnet": "️ Paste magnet/hash/torrs:// to add torrent",
"range_error": "❌ Error, use numbers, e.g. 2-12",
"lang_set": "🌐 Language set: Russian",
"lang_set_en": "🌐 Language set: English",
"lang_current_ru": "🌐 Current language: Russian",
"lang_current_en": "🌐 Current language: English",
"lang_switch_ru": "switch to Russian",
"lang_switch_en": "switch to English",
"lang_usage": "️ Usage: /lang RU | /lang EN",
"admin_only": "🔒 Admin only",
"server_stopped": "🛑 Server stopped",
"searching": "🔍 Searching...",
"search_not_found": "🔍 Nothing found for «%s» (%s)",
"search_disabled_rutor": "️ RuTor search disabled in settings",
"search_disabled_torznab": "️ Torznab search disabled in settings",
"search_usage": "️ Usage: /search &lt;query&gt;",
"rutor_usage": "️ Usage: /rutor &lt;query&gt;",
"torznab_usage": "️ Usage: /torznab &lt;query&gt; [index]",
"clear_confirm": "🗑 Delete all %d torrents?",
"clear_done": "🗑 Deleted torrents: %d",
"shutdown_confirm": "⚠️ Shut down server?",
"canceled": "👌 Canceled",
"deleted": "✅ Deleted",
"callback_unknown": "❌ Error: unknown button",
"stats_title": "Summary statistics",
"page": "📄 Page",
"btn_add": " Add",
"btn_files": "Files",
"btn_delete": "Delete",
"btn_status": "Status",
"btn_m3u": "M3U",
"btn_link": "Link",
"btn_drop": "Drop",
"btn_yes": "Yes",
"btn_no": "No",
"help_help": "This help",
"help_list": "/list [compact] - List (compact — fewer buttons)",
"help_clear": "/clear - Delete all torrents",
"help_add": "/add &lt;link&gt; - Add torrent",
"help_hash": "/hash [N] - Show torrent hashes",
"help_manage_desc": "(hash or number from /list)",
"help_remove": "/remove, /drop, /set, /status, /cache, /queue",
"help_links": "/link, /play, /m3u, /m3uall",
"help_server_cmd": "/server - Server info",
"help_echo": "/echo - Version",
"help_db": "/db - Torrents in DB",
"help_search_desc": "(with Add button)",
"help_search_cmd": "/search, /rutor, /torznab",
"help_other_cmd": "/viewed, /ffp, /speedtest, /preload, /snake",
"help_lang": "/lang RU|EN - Language",
"help_admin": "/shutdown, /settings, /preset - Admin",
"help_stats": "/stats - Summary statistics",
"help_stat": "/stat - Detailed status",
"help_export": "/export - Export magnet links",
"help_import": "/import &lt;text&gt; - Import from list",
"help_categories": "/categories - Torrent categories",
"help_rutor": "/rutor - Search RuTor",
"help_m3uall": "/m3uall - M3U of all torrents",
"help_play": "/play - Alias for /link",
"help_export_import": "Export / Import",
"help_categories_section": "Categories",
"settings_title": "Server settings",
"settings_error": "❌ Error: %s",
"settings_not_loaded": "❌ Settings not loaded",
"settings_export": "Export",
"settings_nav_cache": "Cache",
"settings_nav_paths": "Paths",
"settings_nav_storage": "Storage",
"settings_export_caption": "TorrServer settings",
"settings_exported": "✅ Settings exported",
"settings_saved": "✅ Saved",
"settings_readonly": "⚠️ Read-only mode",
"settings_more": "More",
"settings_back": "Back",
"settings_to_page2": "Cache",
"settings_page2": "Cache & limits",
"settings_page3": "Text parameters",
"settings_section_search": "Search",
"settings_section_network": "Network",
"settings_section_other": "Other",
"settings_section_limits": "Limits",
"settings_limits_cache": "Cache",
"settings_limits_connections": "Connections",
"settings_limits_speed": "Speed",
"settings_section_paths": "Paths & keys",
"settings_input_reply": "Reply to this message with new value",
"settings_input_done": "✅ %s: %s",
"settings_input_error": "❌ Error: %s",
"settings_input_torznab_usage": "Format: URL or URL|Key or URL|Key|Name",
"settings_input_torznab_added": "✅ Torznab added: %s",
"settings_set_friendlyname": "FriendlyName (DLNA)",
"settings_set_path": "TorrentsSavePath",
"settings_set_sslcert": "SslCert",
"settings_set_sslkey": "SslKey",
"settings_set_tmdbkey": "TMDB API Key",
"settings_add_torznab": "Add Torznab",
"settings_clear_torznab": "Clear Torznab",
"settings_set_proxyhosts": "ProxyHosts",
"settings_hint_friendlyname": "DLNA server name. clear — to clear",
"settings_hint_path": "Path to cache folder on server. clear — disable UseDisk",
"settings_hint_sslcert": "Path to SSL certificate. clear — to clear",
"settings_hint_sslkey": "Path to SSL key. clear — to clear",
"settings_hint_tmdbkey": "TMDB API Key. clear — to clear",
"settings_hint_proxyhosts": "Hosts comma-separated: host1, host2. clear — reset",
"settings_hint_torznab": "URL or URL|Key or URL|Key|Name",
"settings_page4": "Storage & TMDB",
"settings_section_storage": "Storage",
"settings_section_tmdb": "TMDB (read-only)",
"settings_storage_settings": "Settings",
"settings_storage_viewed": "Viewed",
"settings_torznab_test": "Test Torznab",
"settings_hint_torznab_test": "URL|Key — test indexer before adding",
"settings_torznab_test_ok": "✅ Torznab: connection successful",
"settings_torznab_test_fail": "❌ Torznab: %s",
"settings_reset": "Reset to defaults",
"settings_reset_confirm": "Reset to factory defaults?",
"settings_reset_done": "✅ Settings reset",
"preset_usage": "⚙️ /preset &lt;name&gt; or /preset &lt;key&gt; &lt;value&gt; ...\n\nNamed: performance, storage, streaming, low, default\n\nExamples:\n/preset performance\n/preset cache 256 preload 50\n/preset cache 512 conn 100 down 0",
"preset_confirm": "⚠️ Applying preset will reload TorrServer (torrents will be disconnected). Continue?",
"preset_applied": "✅ Preset applied: ",
"add_error": "❌ Connection error: %s",
"add_not_created": "❌ Error: torrent not created",
"add_timeout": "❌ Error adding torrent: timeout connection get torrent info",
"add_getting_meta": "⏳ Getting metadata...",
"add_success": "✅ Torrent added:\n<code>%s</code>",
"stats_torrents": "Torrents",
"stats_total_size": "Total size",
"stats_loaded": "Loaded",
"stats_peers": "Peers",
"stats_active": "active",
"stats_seeds": "seeders",
"stats_streams": "Streams",
"error": "❌ Error",
"search_expired": "️ Result expired, search again",
"search_more": "More",
"search_more_hint": "️ Showing %d of %d. Click for more results",
"search_no_link": "️ No link",
"search_adding": "⏳ Adding...",
"add_usage": "️ Usage: /add &lt;magnet|hash|torrs://|url&gt;\nPaste torrent link",
"add_no_link": "️ Specify torrent link",
"remove_usage": "️ Usage: /remove &lt;hash|number&gt;\nOr reply to torrent message",
"remove_done": "✅ Torrent removed:\n<code>%s</code>",
"status_waiting": "⏳ Waiting for torrent info...",
"status_stopped": "🛑 Auto-refresh stopped",
"status_stop_btn": "🛑 Stop",
"status_refresh_btn": "🔄 Refresh",
"status_auto_ended": "Auto-refresh ended",
"status_torrent_gone": "Torrent removed or disconnected",
"status_no_active": "📭 No active torrents",
"status_label": "Status",
"status_size": "Size",
"status_cache": "Cache",
"status_streams": "streams",
"status_download": "Download",
"status_upload": "Upload",
"status_peers": "Peers",
"speed_bps": "bps",
"speed_kbps": "kbps",
"speed_Mbps": "Mbps",
"speed_Gbps": "Gbps",
"speed_Tbps": "Tbps",
"link_usage": "️ Usage: /link &lt;hash|number&gt; [index]\nOr reply to torrent message",
"link_play": "🔗 Playback link:\n<code>%s</code>",
"server_title": "TorrServer",
"server_url": "URL",
"server_port": "Port",
"server_streams": "Active streams",
"m3u_usage": "️ Usage: /m3u &lt;hash|number&gt; [fromlast]\nOr reply to torrent message",
"m3u_playlist": "🎵 M3U playlist:\n<code>%s</code>",
"m3u_all": "🎵 All torrents M3U:\n<code>%s</code>",
"drop_done": "✅ Torrent disconnected",
"drop_done_hash": "✅ Torrent disconnected:\n<code>%s</code>",
"preload_usage": "️ Usage: /preload &lt;hash|number&gt; &lt;index&gt;\nOr reply to torrent message",
"preload_invalid": "❌ Specify valid file number (integer >= 1)",
"preload_started": "⏳ Preload started for file #%s",
"preload_btn": "Preload #%s",
"hash_title": "Torrent hashes",
"files_link": "Link",
"files_download_all": "Download all files",
"files_range_hint": "To download multiple files, reply with range, e.g. 2-12\n\nDownload all files? Total: %d",
"upload_queue_full": "⚠️ Queue full, try later\n\nItems in queue: %d",
"upload_connecting": "⏳ <b>Connecting to torrent</b>\n<code>%s</code>",
"upload_cancel": "Cancel",
"upload_queue_pos": "📋 Queue position %d",
"upload_error": "❌ Telegram upload error: %v",
"parse_range_err": "❌ Invalid format",
"cache_usage": "️ Usage: /cache &lt;hash|number&gt;\nOr reply to torrent message",
"cache_capacity": "Capacity",
"cache_filled": "Filled",
"cache_pieces": "Pieces",
"cache_readers": "Readers",
"cache_unavailable": "⚠️ Cache unavailable for torrent:\n<code>%s</code>",
"snake_usage": "️ Usage: /snake &lt;hash|number&gt; [cols] [rows]\n\nCache visualization. Position moves along snake.\nDefault: 20×3 (up to 50×15)",
"snake_cache": "Preload / Cache",
"snake_cached": "cached",
"snake_range": "buffer",
"snake_empty": "empty",
"snake_reader": "reader",
"snake_legend": "🟩cache 🟦buff 🔵pos ⬜empt",
"snake_pieces": "pieces",
"snake_no_data": "No cache data",
"set_done": "✅ Title updated:\n<code>%s</code>",
"set_usage": "️ Usage: /set &lt;hash|index&gt; &lt;title&gt;\nOr reply to torrent message",
"set_title_required": "❌ Specify new title",
"viewed_marked": "✅ Marked: <code>%s</code> file #%d",
"viewed_unmarked": "✅ Unmarked: <code>%s</code> file #%d",
"viewed_cleared": "✅ All marks cleared: <code>%s</code>",
"viewed_list": "📺 Viewed files",
"viewed_usage": "️ Usage:\n/viewed &lt;hash|index&gt; — list\n/viewed set &lt;hash|index&gt; &lt;file&gt; — mark\n/viewed rem &lt;hash|index&gt; [file] — unmark",
"viewed_usage_action": "️ Usage: /viewed %s &lt;hash|index&gt; [file]",
"viewed_usage_set": "️ Usage: /viewed set &lt;hash|index&gt; &lt;file&gt;",
"viewed_file_index": "❌ Specify file number (integer >= 1)",
"viewed_empty": "📭 No viewed files for this torrent",
"speedtest_msg": "⚡ Download test %d MB:\n<code>%s</code>\n\nDownload the file and measure speed",
"ffp_usage": "️ Usage: /ffp &lt;hash|number&gt; &lt;id&gt; [json]\nid — file number. json — raw output",
"ffp_file_index": "❌ Specify valid file number",
"ffp_error": "❌ FFprobe error: %s",
"ffp_format": "Format",
"ffp_container": "Container",
"ffp_duration": "Duration",
"ffp_size": "Size",
"ffp_bitrate": "Bitrate",
"ffp_streams": "Streams",
"ffp_video": "Video",
"ffp_audio": "Audio",
"ffp_subtitle": "Subtitle",
"ffp_codec": "Codec",
"ffp_resolution": "Resolution",
"ffp_pixel": "Pixel format",
"ffp_fps": "FPS",
"ffp_color": "Color",
"ffp_samplerate": "Sample rate",
"ffp_channels": "Channels",
"ffp_title": "Title",
"db_empty": "📭 Torrent database is empty",
"db_title": "Torrents in DB",
"export_title": "Export torrents",
"export_file_caption": "Magnet links in file",
"import_usage": "️ Usage: /import &lt;text with magnet/hash/torrs&gt;\nPaste multiple links separated by space or newline",
"import_no_links": "️ No links found. Paste magnet, hash or torrs://",
"import_done": "✅ Added: %d of %d",
"categories_title": "Categories",
"categories_uncategorized": "(uncategorized)",
"queue_empty": "📭 Queue empty",
"upload_working": "📥 Downloading",
"upload_in_queue": "📋 In queue",
"upload_stopping": "⏹ Stopping...",
"upload_title": "Downloading torrent",
"upload_hash": "Hash",
"upload_speed": "Speed",
"upload_remaining": "Remaining",
"upload_peers": "Peers",
"upload_progress": "Progress",
"upload_files": "Files",
"upload_finishing": "Finishing download, this may take a while",
"upload_file_too_large_2gb": "❌ File size must not exceed 2GB",
"upload_file_too_large_50mb": "❌ File size must not exceed 50MB. To upload files up to 2GB, specify host in tg.cfg to <a href='https://github.com/tdlib/telegram-bot-api'>telegram bot-api</a>",
}
+276
View File
@@ -0,0 +1,276 @@
package tgbot
var msgRU = map[string]string{
"help": "Бот для управления TorrServer",
"help_main": "Основные",
"help_manage": "Управление",
"help_status": "Статус и ссылки",
"help_search": "Поиск",
"help_other": "Прочее",
"help_server": "Сервер",
"help_use_index": "Можно использовать номер из /list: /remove 1, /status 2",
"help_reply": "Или ответьте на сообщение торрента командой",
"help_id": "Ваш id",
"no_torrents": "📭 Нет торрентов",
"torrent_not_found": "❌ Торрент не найден",
"invalid_hash": "❌ Некорректный хэш. Укажите 40 символов (a-f, 0-9)",
"invalid_index": "❌ Некорректный номер. Используйте число из /list",
"connecting": "⏳ Подключение к торренту...",
"add_magnet": "ℹ️ Вставьте магнет/хэш/torrs:// чтоб добавить торрент",
"range_error": "❌ Ошибка, нужно указывать числа, пример: 2-12",
"lang_set": "🌐 Язык установлен: Русский",
"lang_set_en": "🌐 Language set: English",
"lang_current_ru": "🌐 Текущий язык: Русский",
"lang_current_en": "🌐 Current language: English",
"lang_switch_ru": "переключить на русский",
"lang_switch_en": "switch to English",
"lang_usage": "ℹ️ Использование: /lang RU | /lang EN",
"admin_only": "🔒 Только для администратора",
"server_stopped": "🛑 Сервер остановлен",
"searching": "🔍 Поиск...",
"search_not_found": "🔍 По запросу «%s» ничего не найдено (%s)",
"search_disabled_rutor": "ℹ️ Поиск RuTor отключён в настройках",
"search_disabled_torznab": "️ Поиск Torznab отключён в настройках",
"search_usage": "ℹ️ Использование: /search &lt;запрос&gt;",
"rutor_usage": "ℹ️ Использование: /rutor &lt;запрос&gt;",
"torznab_usage": "ℹ️ Использование: /torznab &lt;запрос&gt; [индекс]",
"clear_confirm": "🗑 Удалить все %d торрентов?",
"clear_done": "🗑 Удалено торрентов: %d",
"shutdown_confirm": "⚠️ Остановить сервер?",
"canceled": "👌 Отменено",
"deleted": "✅ Удалено",
"callback_unknown": "❌ Ошибка: кнопка не распознана",
"stats_title": "Сводная статистика",
"page": "📄 Страница",
"btn_add": " Добавить",
"btn_files": "Файлы",
"btn_delete": "Удалить",
"btn_status": "Статус",
"btn_m3u": "M3U",
"btn_link": "Ссылка",
"btn_drop": "Отключить",
"btn_yes": "Да",
"btn_no": "Нет",
"help_help": "Эта справка",
"help_list": "/list [compact] - Список (compact — меньше кнопок)",
"help_clear": "/clear - Удалить все торренты",
"help_add": "/add &lt;ссылка&gt; - Добавить торрент",
"help_hash": "/hash [N] - Показать hash торрентов",
"help_manage_desc": "(hash или номер из /list)",
"help_remove": "/remove, /drop, /set, /status, /cache, /queue",
"help_links": "/link, /play, /m3u, /m3uall",
"help_server_cmd": "/server - Информация о сервере",
"help_echo": "/echo - Версия",
"help_db": "/db - Торренты в БД",
"help_search_desc": "(с кнопкой Добавить)",
"help_search_cmd": "/search, /rutor, /torznab",
"help_other_cmd": "/viewed, /ffp, /speedtest, /preload, /snake",
"help_lang": "/lang RU|EN - Язык",
"help_admin": "/shutdown, /settings, /preset - Админ",
"help_stats": "/stats - Сводная статистика",
"help_stat": "/stat - Детальный статус",
"help_export": "/export - Экспорт магнет-ссылок",
"help_import": "/import &lt;текст&gt; - Импорт из списка",
"help_categories": "/categories - Категории торрентов",
"help_rutor": "/rutor - Поиск RuTor",
"help_m3uall": "/m3uall - M3U всех торрентов",
"help_play": "/play - Алиас /link",
"help_export_import": "Экспорт / Импорт",
"help_categories_section": "Категории",
"settings_title": "Настройки сервера",
"settings_error": "❌ Ошибка: %s",
"settings_not_loaded": "❌ Настройки не загружены",
"settings_export": "Экспорт",
"settings_nav_cache": "Кэш",
"settings_nav_paths": "Пути",
"settings_nav_storage": "Хранилище",
"settings_export_caption": "Настройки TorrServer",
"settings_exported": "✅ Настройки экспортированы",
"settings_saved": "✅ Сохранено",
"settings_readonly": "⚠️ Режим только чтение",
"settings_more": "Ещё",
"settings_back": "Назад",
"settings_to_page2": "Кэш",
"settings_page2": "Кэш и лимиты",
"settings_page3": "Текстовые параметры",
"settings_section_search": "Поиск",
"settings_section_network": "Сеть",
"settings_section_other": "Прочее",
"settings_section_limits": "Лимиты",
"settings_limits_cache": "Кэш",
"settings_limits_connections": "Подключения",
"settings_limits_speed": "Скорость",
"settings_section_paths": "Пути и ключи",
"settings_input_reply": "Ответьте на это сообщение новым значением",
"settings_input_done": "✅ %s: %s",
"settings_input_error": "❌ Ошибка: %s",
"settings_input_torznab_usage": "Формат: URL или URL|Key или URL|Key|Name",
"settings_input_torznab_added": "✅ Torznab добавлен: %s",
"settings_set_friendlyname": "FriendlyName (DLNA)",
"settings_set_path": "TorrentsSavePath",
"settings_set_sslcert": "SslCert",
"settings_set_sslkey": "SslKey",
"settings_set_tmdbkey": "TMDB API Key",
"settings_add_torznab": "Добавить Torznab",
"settings_clear_torznab": "Очистить Torznab",
"settings_set_proxyhosts": "ProxyHosts",
"settings_hint_friendlyname": "Имя DLNA-сервера. clear — очистить",
"settings_hint_path": "Путь к папке кэша на сервере. clear — отключить UseDisk",
"settings_hint_sslcert": "Путь к SSL-сертификату. clear — очистить",
"settings_hint_sslkey": "Путь к SSL-ключу. clear — очистить",
"settings_hint_tmdbkey": "TMDB API Key. clear — очистить",
"settings_hint_proxyhosts": "Хосты через запятую: host1, host2. clear — сброс",
"settings_hint_torznab": "URL или URL|Key или URL|Key|Name",
"settings_page4": "Хранилище и TMDB",
"settings_section_storage": "Хранилище",
"settings_section_tmdb": "TMDB (только просмотр)",
"settings_storage_settings": "Настройки",
"settings_storage_viewed": "Просмотренные",
"settings_torznab_test": "Тест Torznab",
"settings_hint_torznab_test": "URL|Key — проверка индексера до добавления",
"settings_torznab_test_ok": "✅ Torznab: подключение успешно",
"settings_torznab_test_fail": "❌ Torznab: %s",
"settings_reset": "Сброс настроек",
"settings_reset_confirm": "Сбросить на заводские настройки?",
"settings_reset_done": "✅ Настройки сброшены",
"preset_usage": "⚙️ /preset &lt;имя&gt; или /preset &lt;ключ&gt; &lt;значение&gt; ...\n\nИменованные: performance, storage, streaming, low, default\n\nПримеры:\n/preset performance\n/preset cache 256 preload 50\n/preset cache 512 conn 100 down 0",
"preset_confirm": "⚠️ Применение пресета перезагрузит TorrServer (торренты будут отключены). Продолжить?",
"preset_applied": "✅ Пресет применён: ",
"add_error": "❌ Ошибка при подключении: %s",
"add_not_created": "❌ Ошибка: торрент не создан",
"add_timeout": "❌ Ошибка при добавлении торрента: timeout connection get torrent info",
"add_getting_meta": "⏳ Получение метаданных...",
"add_success": "✅ Торрент добавлен:\n<code>%s</code>",
"stats_torrents": "Торрентов",
"stats_total_size": "Общий размер",
"stats_loaded": "Загружено",
"stats_peers": "Пиры",
"stats_active": "активных",
"stats_seeds": "сидов",
"stats_streams": "Потоков",
"error": "❌ Ошибка",
"search_expired": "ℹ️ Результат устарел, повторите поиск",
"search_more": "Ещё",
"search_more_hint": "ℹ️ Показано %d из %d. Нажмите для следующих результатов",
"search_no_link": "ℹ️ Нет ссылки",
"search_adding": "⏳ Добавление...",
"add_usage": "ℹ️ Использование: /add &lt;magnet|hash|torrs://|url&gt;\nВставьте ссылку на торрент",
"add_no_link": "ℹ️ Укажите ссылку на торрент",
"remove_usage": "ℹ️ Использование: /remove &lt;hash|номер&gt;\nИли ответьте на сообщение торрента",
"remove_done": "✅ Торрент удалён:\n<code>%s</code>",
"status_waiting": "⏳ Ожидание информации о торренте...",
"status_stopped": "🛑 Автообновление остановлено",
"status_stop_btn": "🛑 Стоп",
"status_refresh_btn": "🔄 Обновить",
"status_auto_ended": "Автообновление завершено",
"status_torrent_gone": "Торрент удалён или отключён",
"status_no_active": "📭 Нет активных торрентов",
"status_label": "Статус",
"status_size": "Размер",
"status_cache": "Кэш",
"status_streams": "потоков",
"status_download": "Скачивание",
"status_upload": "Раздача",
"status_peers": "Пиры",
"speed_bps": "бит/c",
"speed_kbps": "кбит/с",
"speed_Mbps": "Мбит/c",
"speed_Gbps": "Гбит/с",
"speed_Tbps": "Тбит/с",
"link_usage": "ℹ️ Использование: /link &lt;hash|номер&gt; [index]\nИли ответьте на сообщение торрента",
"link_play": "🔗 Ссылка для воспроизведения:\n<code>%s</code>",
"server_title": "Сервер TorrServer",
"server_url": "URL",
"server_port": "Порт",
"server_streams": "Активных потоков",
"m3u_usage": "ℹ️ Использование: /m3u &lt;hash|номер&gt; [fromlast]\nИли ответьте на сообщение торрента",
"m3u_playlist": "🎵 M3U плейлист:\n<code>%s</code>",
"m3u_all": "🎵 M3U всех торрентов:\n<code>%s</code>",
"drop_done": "✅ Торрент отключён",
"drop_done_hash": "✅ Торрент отключён:\n<code>%s</code>",
"preload_usage": "ℹ️ Использование: /preload &lt;hash|номер&gt; &lt;index&gt;\nИли ответьте на сообщение торрента",
"preload_invalid": "❌ Укажите корректный номер файла (целое число >= 1)",
"preload_started": "⏳ Предзагрузка запущена для файла #%s",
"preload_btn": "Предзагрузка #%s",
"hash_title": "Hash торрентов",
"files_link": "Ссылка",
"files_download_all": "Скачать все файлы",
"files_range_hint": "Чтобы скачать несколько файлов, ответьте на это сообщение, с какого файла скачать по какой, пример: 2-12\n\nСкачать все файлы? Всего: %d",
"upload_queue_full": "⚠️ Очередь переполнена, попробуйте попозже\n\nЭлементов в очереди: %d",
"upload_connecting": "⏳ <b>Подключение к торренту</b>\n<code>%s</code>",
"upload_cancel": "Отмена",
"upload_queue_pos": "📋 Номер в очереди %d",
"upload_error": "❌ Ошибка загрузки в телеграм: %v",
"parse_range_err": "❌ Неверный формат строки",
"cache_usage": "ℹ️ Использование: /cache &lt;hash|номер&gt;\nИли ответьте на сообщение торрента",
"cache_capacity": "Ёмкость",
"cache_filled": "Заполнено",
"cache_pieces": "Пайсов",
"cache_readers": "Читателей",
"cache_unavailable": "⚠️ Кэш недоступен для торрента:\n<code>%s</code>",
"snake_usage": "ℹ️ Использование: /snake &lt;hash|номер&gt; [колонок] [строк]\n\nВизуализация кэша. Позиция движется по змейке.\nПо умолчанию: 20×3 (до 50×15)",
"snake_cache": "Предзагрузка / Кеш",
"snake_cached": "кэш",
"snake_range": "буфер",
"snake_empty": "пусто",
"snake_reader": "позиция",
"snake_legend": "🟩кэш 🟦буф 🔵поз ⬜пуст",
"snake_pieces": "пайсы",
"snake_no_data": "Нет данных кэша",
"set_done": "✅ Название обновлено:\n<code>%s</code>",
"set_usage": "ℹ️ Использование: /set &lt;hash|номер&gt; &lt;название&gt;\nИли ответьте на сообщение торрента",
"set_title_required": "❌ Укажите новое название",
"viewed_marked": "✅ Отмечено: <code>%s</code> файл #%d",
"viewed_unmarked": "✅ Снята отметка: <code>%s</code> файл #%d",
"viewed_cleared": "✅ Сняты все отметки: <code>%s</code>",
"viewed_list": "📺 Просмотренные файлы",
"viewed_usage": "ℹ️ Использование:\n/viewed &lt;hash|номер&gt; — список\n/viewed set &lt;hash|номер&gt; &lt;index&gt; — отметить\n/viewed rem &lt;hash|номер&gt; [index] — снять отметку",
"viewed_usage_action": "ℹ️ Использование: /viewed %s &lt;hash|номер&gt; [index]",
"viewed_usage_set": "ℹ️ Использование: /viewed set &lt;hash|номер&gt; &lt;index&gt;",
"viewed_file_index": "❌ Укажите номер файла (целое число >= 1)",
"viewed_empty": "📭 Нет просмотренных файлов для этого торрента",
"speedtest_msg": "⚡ Тест загрузки %d MB:\n<code>%s</code>\n\nСкачайте файл и замерьте скорость",
"ffp_usage": "ℹ️ Использование: /ffp &lt;hash|номер&gt; &lt;id&gt; [json]\nid — номер файла. json — сырой вывод",
"ffp_file_index": "❌ Укажите корректный номер файла",
"ffp_error": "❌ Ошибка ffprobe: %s",
"ffp_format": "Формат",
"ffp_container": "Контейнер",
"ffp_duration": "Длительность",
"ffp_size": "Размер",
"ffp_bitrate": "Битрейт",
"ffp_streams": "Дорожки",
"ffp_video": "Видео",
"ffp_audio": "Аудио",
"ffp_subtitle": "Субтитры",
"ffp_codec": "Кодек",
"ffp_resolution": "Разрешение",
"ffp_pixel": "Пиксели",
"ffp_fps": "FPS",
"ffp_color": "Цвет",
"ffp_samplerate": "Частота",
"ffp_channels": "Каналы",
"ffp_title": "Название",
"db_empty": "📭 База торрентов пуста",
"db_title": "Торренты в БД",
"export_title": "Экспорт торрентов",
"export_file_caption": "Магнет-ссылки в файле",
"import_usage": "ℹ️ Использование: /import &lt;текст с magnet/hash/torrs&gt;\nВставьте несколько ссылок через пробел или перенос строки",
"import_no_links": "ℹ️ Ссылки не найдены. Вставьте magnet, hash или torrs://",
"import_done": "✅ Добавлено: %d из %d",
"categories_title": "Категории",
"categories_uncategorized": "(без категории)",
"queue_empty": "📭 Очередь пуста",
"upload_working": "📥 Закачиваются",
"upload_in_queue": "📋 В очереди",
"upload_stopping": "⏹ Остановка...",
"upload_title": "Загрузка торрента",
"upload_hash": "Хэш",
"upload_speed": "Скорость",
"upload_remaining": "Осталось",
"upload_peers": "Пиры",
"upload_progress": "Загружено",
"upload_files": "Файлов",
"upload_finishing": "Завершение загрузки, это займёт некоторое время",
"upload_file_too_large_2gb": "❌ Размер файла не должен превышать 2GB",
"upload_file_too_large_50mb": "❌ Размер файла не должен превышать 50MB. Чтобы закачивать файлы до 2GB, укажите host в tg.cfg к <a href='https://github.com/tdlib/telegram-bot-api'>telegram bot-api</a>",
}
+51
View File
@@ -0,0 +1,51 @@
package tgbot
import (
"fmt"
"strings"
tele "gopkg.in/telebot.v4"
"server/torr"
)
func callbackM3u(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")})
}
host := getHost()
url := fmt.Sprintf("%s/playlist?hash=%s", host, hash)
_ = c.Respond(&tele.CallbackResponse{})
return c.Send(fmt.Sprintf(tr(uid, "m3u_playlist"), url))
}
func cmdM3u(c tele.Context) error {
args := c.Args()
arg := ""
if len(args) > 0 {
arg = args[0]
}
hash := resolveHash(c, arg)
if hash == "" {
return c.Send(tr(c.Sender().ID, "m3u_usage"))
}
t := torr.GetTorrent(hash)
if t == nil {
return c.Send(tr(c.Sender().ID, "torrent_not_found") + ":\n<code>" + hash + "</code>")
}
host := getHost()
url := fmt.Sprintf("%s/playlist?hash=%s", host, hash)
if len(args) > 1 && strings.ToLower(args[1]) == "fromlast" {
url += "&fromlast=1"
}
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "m3u_playlist"), url))
}
func cmdM3uAll(c tele.Context) error {
host := getHost()
url := host + "/playlistall/all.m3u"
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "m3u_all"), url))
}
+16
View File
@@ -0,0 +1,16 @@
package tgbot
import tele "gopkg.in/telebot.v4"
// adminOnly wraps a handler to allow only admin users (when whitelist is used)
func adminOnly(h tele.HandlerFunc) tele.HandlerFunc {
return func(c tele.Context) error {
if c.Sender() == nil {
return nil
}
if !isAdmin(c.Sender().ID) {
return c.Send(tr(c.Sender().ID, "admin_only"))
}
return h(c)
}
}
+48
View File
@@ -0,0 +1,48 @@
package tgbot
import (
"fmt"
"strconv"
"strings"
tele "gopkg.in/telebot.v4"
"server/torr"
)
func cmdPreload(c tele.Context) error {
args := c.Args()
if len(args) < 2 {
return c.Send(tr(c.Sender().ID, "preload_usage"))
}
uid := c.Sender().ID
hash := resolveHash(c, args[0])
if hash == "" {
return c.Send(tr(uid, "invalid_hash"))
}
index, err := strconv.Atoi(strings.TrimSpace(args[1]))
if err != nil || index < 1 {
return c.Send(tr(uid, "preload_invalid"))
}
t := torr.GetTorrent(hash)
if t == nil {
return c.Send(tr(uid, "torrent_not_found") + ":\n<code>" + hash + "</code>")
}
torr.Preload(t, index)
return c.Send(fmt.Sprintf(tr(uid, "preload_started"), args[1]))
}
func callbackPreload(c tele.Context, hash, indexStr string) error {
uid := c.Sender().ID
index, err := strconv.Atoi(indexStr)
if err != nil || index < 1 {
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "error")})
}
t := torr.GetTorrent(hash)
if t == nil {
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "torrent_not_found")})
}
torr.Preload(t, index)
return c.Respond(&tele.CallbackResponse{Text: fmt.Sprintf(tr(uid, "preload_btn"), indexStr)})
}
+34
View File
@@ -0,0 +1,34 @@
package tgbot
import (
"fmt"
tele "gopkg.in/telebot.v4"
"server/torr"
)
func cmdRemove(c tele.Context) error {
arg := ""
if args := c.Args(); len(args) > 0 {
arg = args[0]
}
hash := resolveHash(c, arg)
if hash == "" {
return c.Send(tr(c.Sender().ID, "remove_usage"))
}
torrents := torr.ListTorrent()
var found bool
for _, t := range torrents {
if t.Hash().HexString() == hash {
found = true
break
}
}
if !found {
return c.Send(tr(c.Sender().ID, "torrent_not_found") + ":\n<code>" + hash + "</code>")
}
torr.RemTorrent(hash)
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "remove_done"), hash))
}
+186
View File
@@ -0,0 +1,186 @@
package tgbot
import (
"fmt"
"strconv"
"strings"
tele "gopkg.in/telebot.v4"
"server/rutor"
"server/rutor/models"
sets "server/settings"
"server/torznab"
)
func cmdSearch(c tele.Context) error {
if sets.BTsets == nil || (!sets.BTsets.EnableRutorSearch && !sets.BTsets.EnableTorznabSearch) {
return c.Send(tr(c.Sender().ID, "search_disabled_rutor"))
}
args := c.Args()
if len(args) == 0 {
return c.Send(tr(c.Sender().ID, "search_usage"))
}
query := strings.Join(args, " ")
uid := c.Sender().ID
statusMsg, err := c.Bot().Send(c.Sender(), tr(uid, "searching"))
if err != nil {
return err
}
go func() {
var list []*models.TorrentDetails
if sets.BTsets != nil && sets.BTsets.EnableRutorSearch {
list = append(list, rutor.Search(query)...)
}
if sets.BTsets != nil && sets.BTsets.EnableTorznabSearch {
list = append(list, torznab.Search(query, -1)...)
}
source := "RuTor+Torznab"
sendSearchResultsAsync(c.Bot(), c.Sender(), statusMsg, uid, query, list, source)
}()
return nil
}
func cmdSearchRutor(c tele.Context) error {
if sets.BTsets == nil || !sets.BTsets.EnableRutorSearch {
return c.Send(tr(c.Sender().ID, "search_disabled_rutor"))
}
args := c.Args()
if len(args) == 0 {
return c.Send(tr(c.Sender().ID, "rutor_usage"))
}
query := strings.Join(args, " ")
uid := c.Sender().ID
statusMsg, err := c.Bot().Send(c.Sender(), tr(uid, "searching"))
if err != nil {
return err
}
go func() {
list := rutor.Search(query)
sendSearchResultsAsync(c.Bot(), c.Sender(), statusMsg, uid, query, list, "RuTor")
}()
return nil
}
func cmdTorznab(c tele.Context) error {
if sets.BTsets == nil || !sets.BTsets.EnableTorznabSearch {
return c.Send(tr(c.Sender().ID, "search_disabled_torznab"))
}
args := c.Args()
if len(args) == 0 {
return c.Send(tr(c.Sender().ID, "torznab_usage"))
}
query := strings.Join(args, " ")
index := -1
if len(args) > 1 {
if i, err := strconv.Atoi(args[len(args)-1]); err == nil && i >= 0 && i < 100 {
index = i
query = strings.Join(args[:len(args)-1], " ")
}
}
uid := c.Sender().ID
statusMsg, err := c.Bot().Send(c.Sender(), tr(uid, "searching"))
if err != nil {
return err
}
go func() {
list := torznab.Search(query, index)
sendSearchResultsAsync(c.Bot(), c.Sender(), statusMsg, uid, query, list, "Torznab")
}()
return nil
}
func sendSearchResultsAsync(api tele.API, recipient tele.Recipient, statusMsg *tele.Message, userID int64, query string, list []*models.TorrentDetails, source string) {
if len(list) == 0 {
_, _ = api.Edit(statusMsg, fmt.Sprintf(tr(userID, "search_not_found"), query, source))
return
}
_ = api.Delete(statusMsg)
_ = sendSearchResultsToRecipient(api, recipient, userID, 0, list, source)
}
func sendSearchResultsToRecipient(api tele.API, recipient tele.Recipient, userID int64, offset int, list []*models.TorrentDetails, source string) error {
const pageSize = 10
if offset == 0 {
storeSearchResults(userID, list)
}
start := offset
end := offset + pageSize
if end > len(list) {
end = len(list)
}
page := list[start:end]
for i, item := range page {
idx := offset + i
link := item.Magnet
if link == "" {
link = item.Link
}
if link == "" {
continue
}
size := item.Size
if size == "" {
size = "?"
}
txt := fmt.Sprintf("%d. <b>%s</b> (%s) S:%d P:%d", idx+1, escapeHtml(item.Title), size, item.Seed, item.Peer)
btnAdd := tele.InlineButton{Text: tr(userID, "btn_add"), Unique: "fadd", Data: strconv.Itoa(idx)}
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{{btnAdd}}}
_, _ = api.Send(recipient, txt, kbd)
}
if end < len(list) {
btnMore := tele.InlineButton{Text: "🔍 " + tr(userID, "search_more"), Unique: "fmore", Data: strconv.Itoa(end)}
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{{btnMore}}}
_, _ = api.Send(recipient, fmt.Sprintf(tr(userID, "search_more_hint"), end, len(list)), kbd)
}
return nil
}
func callbackSearchMore(c tele.Context, offsetStr string) error {
uid := c.Sender().ID
offset, err := strconv.Atoi(offsetStr)
if err != nil || offset < 0 {
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "error")})
}
slice, total := getSearchResultsSlice(uid, offset, 10)
if len(slice) == 0 {
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "search_expired")})
}
_ = c.Respond(&tele.CallbackResponse{})
if c.Callback().Message != nil {
_ = c.Bot().Delete(c.Callback().Message)
}
return sendSearchResultsPage(c.Bot(), c.Sender(), uid, offset, slice, total)
}
func sendSearchResultsPage(api tele.API, recipient tele.Recipient, userID int64, offset int, page []*models.TorrentDetails, total int) error {
for i, item := range page {
idx := offset + i
link := item.Magnet
if link == "" {
link = item.Link
}
if link == "" {
continue
}
size := item.Size
if size == "" {
size = "?"
}
txt := fmt.Sprintf("%d. <b>%s</b> (%s) S:%d P:%d", idx+1, escapeHtml(item.Title), size, item.Seed, item.Peer)
btnAdd := tele.InlineButton{Text: tr(userID, "btn_add"), Unique: "fadd", Data: strconv.Itoa(idx)}
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{{btnAdd}}}
_, _ = api.Send(recipient, txt, kbd)
}
nextOffset := offset + len(page)
if nextOffset < total {
btnMore := tele.InlineButton{Text: "🔍 " + tr(userID, "search_more"), Unique: "fmore", Data: strconv.Itoa(nextOffset)}
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{{btnMore}}}
_, _ = api.Send(recipient, fmt.Sprintf(tr(userID, "search_more_hint"), nextOffset, total), kbd)
}
return nil
}
+138
View File
@@ -0,0 +1,138 @@
package tgbot
import (
"fmt"
"strconv"
"sync"
"time"
tele "gopkg.in/telebot.v4"
"server/rutor/models"
)
type searchCacheEntry struct {
results []*models.TorrentDetails
expires time.Time
}
var (
searchCache = make(map[int64]*searchCacheEntry)
searchCacheMu sync.RWMutex
cacheTTL = 10 * time.Minute
cacheMaxSize = 1000
)
func init() {
go searchCacheCleanup()
}
func searchCacheCleanup() {
ticker := time.NewTicker(time.Minute)
for range ticker.C {
searchCacheMu.Lock()
now := time.Now()
for id, entry := range searchCache {
if entry == nil || now.After(entry.expires) {
delete(searchCache, id)
}
}
if len(searchCache) > cacheMaxSize {
evict := len(searchCache) - cacheMaxSize
if evict < len(searchCache)/10 {
evict = len(searchCache) / 10
}
if evict < 1 {
evict = 1
}
type kv struct {
id int64
exp time.Time
}
var entries []kv
for id, entry := range searchCache {
if entry != nil {
entries = append(entries, kv{id, entry.expires})
}
}
for evict > 0 && len(entries) > 0 {
oldest := 0
for j := 1; j < len(entries); j++ {
if entries[j].exp.Before(entries[oldest].exp) {
oldest = j
}
}
delete(searchCache, entries[oldest].id)
entries[oldest] = entries[len(entries)-1]
entries = entries[:len(entries)-1]
evict--
}
}
searchCacheMu.Unlock()
}
}
func storeSearchResults(userID int64, results []*models.TorrentDetails) {
searchCacheMu.Lock()
defer searchCacheMu.Unlock()
searchCache[userID] = &searchCacheEntry{
results: results,
expires: time.Now().Add(cacheTTL),
}
}
func getSearchResult(userID int64, index int) *models.TorrentDetails {
searchCacheMu.Lock()
defer searchCacheMu.Unlock()
entry, ok := searchCache[userID]
if !ok || entry == nil || time.Now().After(entry.expires) {
return nil
}
if index < 0 || index >= len(entry.results) {
return nil
}
return entry.results[index]
}
func getSearchResultsSlice(userID int64, offset, limit int) ([]*models.TorrentDetails, int) {
searchCacheMu.Lock()
defer searchCacheMu.Unlock()
entry, ok := searchCache[userID]
if !ok || entry == nil || time.Now().After(entry.expires) {
return nil, 0
}
total := len(entry.results)
if offset >= total {
return nil, total
}
end := offset + limit
if end > total {
end = total
}
slice := make([]*models.TorrentDetails, end-offset)
copy(slice, entry.results[offset:end])
return slice, total
}
func callbackSearchAdd(c tele.Context, indexStr string) error {
uid := c.Sender().ID
index, parseErr := strconv.Atoi(indexStr)
if parseErr != nil {
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "error")})
}
item := getSearchResult(uid, index)
if item == nil {
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "search_expired")})
}
link := item.Magnet
if link == "" {
link = item.Link
}
if link == "" {
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "search_no_link")})
}
_ = c.Respond(&tele.CallbackResponse{Text: tr(uid, "search_adding")})
if err := addTorrent(c, link); err != nil {
return c.Send(fmt.Sprintf(tr(uid, "add_error"), err.Error()))
}
return list(c)
}
+28
View File
@@ -0,0 +1,28 @@
package tgbot
import (
"fmt"
"strings"
tele "gopkg.in/telebot.v4"
"server/settings"
"server/torr"
)
func cmdServer(c tele.Context) error {
uid := c.Sender().ID
host := getHost()
torrents := torr.ListTorrent()
streams := torr.GetActiveStreams()
var sb strings.Builder
sb.WriteString("🖥 <b>" + tr(uid, "server_title") + "</b>\n\n")
fmt.Fprintf(&sb, "%s: <code>%s</code>\n", tr(uid, "server_url"), host)
fmt.Fprintf(&sb, "%s: %s\n", tr(uid, "server_port"), settings.Port)
if settings.Ssl {
fmt.Fprintf(&sb, "SSL %s: %s\n", tr(uid, "server_port"), settings.SslPort)
}
fmt.Fprintf(&sb, "%s: %d\n", tr(uid, "stats_torrents"), len(torrents))
fmt.Fprintf(&sb, "%s: %d\n", tr(uid, "server_streams"), streams)
return c.Send(sb.String())
}
+27
View File
@@ -0,0 +1,27 @@
package tgbot
import (
"fmt"
"strings"
tele "gopkg.in/telebot.v4"
"server/torr"
)
func cmdSet(c tele.Context) error {
args := c.Args()
if len(args) < 2 {
return c.Send(tr(c.Sender().ID, "set_usage"))
}
hash := resolveHash(c, args[0])
if hash == "" {
return c.Send(tr(c.Sender().ID, "invalid_hash"))
}
title := strings.TrimSpace(strings.Join(args[1:], " "))
if title == "" {
return c.Send(tr(c.Sender().ID, "set_title_required"))
}
torr.SetTorrent(hash, title, "", "", "")
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "set_done"), escapeHtml(title)))
}
+218
View File
@@ -0,0 +1,218 @@
package tgbot
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
tele "gopkg.in/telebot.v4"
"server/dlna"
"server/rutor"
"server/settings"
"server/torr"
"server/torznab"
)
const pendingInputTTL = 30 * time.Minute
type pendingInput struct {
Setting string
UserID int64
CreatedAt time.Time
}
var (
pendingInputMu sync.Mutex
pendingInputs = make(map[string]pendingInput)
)
func init() {
go pendingInputCleanup()
}
func pendingInputCleanup() {
ticker := time.NewTicker(5 * time.Minute)
for range ticker.C {
pendingInputMu.Lock()
now := time.Now()
for key, p := range pendingInputs {
if now.Sub(p.CreatedAt) > pendingInputTTL {
delete(pendingInputs, key)
}
}
pendingInputMu.Unlock()
}
}
func sendSettingsInputPrompt(c tele.Context, uid int64, setting, hint string) error {
msg := fmt.Sprintf("✏️ %s\n\n%s", tr(uid, "settings_input_reply"), hint)
btnCancel := tele.InlineButton{Text: "❌ " + tr(uid, "canceled"), Unique: "fset", Data: "input_cancel"}
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{{btnCancel}}}
sent, err := c.Bot().Send(c.Chat(), msg, kbd)
if err != nil {
return err
}
pendingInputMu.Lock()
pendingInputs[chatMsgKey(sent.Chat.ID, sent.ID)] = pendingInput{Setting: setting, UserID: uid, CreatedAt: time.Now()}
pendingInputMu.Unlock()
return c.Respond(&tele.CallbackResponse{})
}
func handleSettingsInputReply(c tele.Context) (handled bool) {
msg := c.Message()
if msg.ReplyTo == nil {
return false
}
key := chatMsgKey(msg.ReplyTo.Chat.ID, msg.ReplyTo.ID)
pendingInputMu.Lock()
pending, ok := pendingInputs[key]
delete(pendingInputs, key)
pendingInputMu.Unlock()
if !ok || pending.UserID != msg.Sender.ID {
return false
}
if time.Since(pending.CreatedAt) > pendingInputTTL {
_ = c.Send(tr(msg.Sender.ID, "canceled"))
return true
}
applySettingsInput(c, pending.Setting, strings.TrimSpace(msg.Text))
return true
}
func cancelSettingsInput(c tele.Context) error {
key := chatMsgKey(c.Callback().Message.Chat.ID, c.Callback().Message.ID)
pendingInputMu.Lock()
delete(pendingInputs, key)
pendingInputMu.Unlock()
_ = c.Bot().Delete(c.Callback().Message)
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "canceled")})
}
func applySettingsInput(c tele.Context, setting, value string) {
uid := c.Sender().ID
if !isAdmin(uid) {
_ = c.Send(tr(uid, "admin_only"))
return
}
if settings.ReadOnly {
_ = c.Send(tr(uid, "settings_readonly"))
return
}
if settings.BTsets == nil {
_ = c.Send(tr(uid, "settings_not_loaded"))
return
}
clear := strings.ToLower(value) == "clear" || strings.ToLower(value) == "очистить" || value == "-"
if clear {
value = ""
}
sets := new(settings.BTSets)
*sets = *settings.BTsets
switch setting {
case "friendlyname":
sets.FriendlyName = value
_ = c.Send(fmt.Sprintf(tr(uid, "settings_input_done"), "FriendlyName", valueOrClear(value)))
case "torrentssavepath":
if value != "" {
abs, err := filepath.Abs(value)
if err != nil {
abs = value
}
if _, err := os.Stat(abs); err != nil && !os.IsNotExist(err) {
_ = c.Send(fmt.Sprintf(tr(uid, "settings_input_error"), err.Error()))
return
}
sets.TorrentsSavePath = abs
sets.UseDisk = true
} else {
sets.TorrentsSavePath = ""
sets.UseDisk = false
}
_ = c.Send(fmt.Sprintf(tr(uid, "settings_input_done"), "TorrentsSavePath", valueOrClear(value)))
case "sslcert":
sets.SslCert = value
_ = c.Send(fmt.Sprintf(tr(uid, "settings_input_done"), "SslCert", valueOrClear(value)))
case "sslkey":
sets.SslKey = value
_ = c.Send(fmt.Sprintf(tr(uid, "settings_input_done"), "SslKey", valueOrClear(value)))
case "tmdbkey":
sets.TMDBSettings.APIKey = value
_ = c.Send(fmt.Sprintf(tr(uid, "settings_input_done"), "TMDB API Key", valueOrClear(value)))
case "proxyhosts":
if value == "" {
sets.ProxyHosts = nil
} else {
parts := strings.Split(value, ",")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
sets.ProxyHosts = parts
}
_ = c.Send(fmt.Sprintf(tr(uid, "settings_input_done"), "ProxyHosts", valueOrClear(value)))
case "torznab_add":
if value == "" {
_ = c.Send(tr(uid, "settings_input_torznab_usage"))
return
}
parts := strings.SplitN(value, "|", 3)
cfg := settings.TorznabConfig{Host: strings.TrimSpace(parts[0])}
if len(parts) > 1 {
cfg.Key = strings.TrimSpace(parts[1])
}
if len(parts) > 2 {
cfg.Name = strings.TrimSpace(parts[2])
}
if !strings.HasPrefix(cfg.Host, "http") {
cfg.Host = "https://" + cfg.Host
}
sets.TorznabUrls = append(sets.TorznabUrls, cfg)
_ = c.Send(fmt.Sprintf(tr(uid, "settings_input_torznab_added"), cfg.Host))
case "torznab_test":
if value == "" {
_ = c.Send(tr(uid, "settings_input_torznab_usage"))
return
}
parts := strings.SplitN(value, "|", 3)
host := strings.TrimSpace(parts[0])
key := ""
if len(parts) > 1 {
key = strings.TrimSpace(parts[1])
}
if !strings.HasPrefix(host, "http") {
host = "https://" + host
}
if err := torznab.Test(host, key); err != nil {
_ = c.Send(fmt.Sprintf(tr(uid, "settings_torznab_test_fail"), err.Error()))
return
}
_ = c.Send(tr(uid, "settings_torznab_test_ok"))
return
default:
_ = c.Send(tr(uid, "callback_unknown"))
return
}
torr.SetSettings(sets)
dlna.Stop()
if sets.EnableDLNA {
dlna.Start()
}
rutor.Stop()
rutor.Start()
}
func valueOrClear(v string) string {
if v == "" {
return "(cleared)"
}
if len(v) > 50 {
return v[:47] + "..."
}
return v
}
+435
View File
@@ -0,0 +1,435 @@
package tgbot
import (
"fmt"
"strconv"
"strings"
"sync"
"time"
"github.com/dustin/go-humanize"
tele "gopkg.in/telebot.v4"
"server/log"
"server/torr"
cacheSt "server/torr/storage/state"
)
var (
snakeStopChans = make(map[int]chan struct{})
snakeStopChansMu sync.Mutex
snakeWindowStart = make(map[string]int)
snakeWindowStartMu sync.Mutex
)
const (
snakeBlockFilled = "🟩"
snakeBlockEmpty = "⬜"
snakeBlockReader = "🔵"
snakeBlockInRange = "🟦"
snakeTitleMaxLen = 55
snakeHashDisplayLen = 8
)
func cmdSnake(c tele.Context) error {
args := c.Args()
hash := ""
cols, rows := 20, 3
if len(args) > 0 {
hash = resolveHash(c, args[0])
}
if len(args) > 1 {
if n, err := strconv.Atoi(args[1]); err == nil && n > 0 && n <= 50 {
cols = n
}
}
if len(args) > 2 {
if n, err := strconv.Atoi(args[2]); err == nil && n > 0 && n <= 15 {
rows = n
}
}
if hash == "" {
return c.Send(tr(c.Sender().ID, "snake_usage"))
}
t := torr.GetTorrent(hash)
if t == nil {
return c.Send(tr(c.Sender().ID, "torrent_not_found") + ":\n<code>" + hash + "</code>")
}
st := t.CacheState()
if st == nil {
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "cache_unavailable"), hash))
}
uid := c.Sender().ID
txt := formatSnake(uid, st, hash, cols, rows)
kbd := snakeKeyboard(uid, hash, cols, rows, true)
msg, err := c.Bot().Send(c.Sender(), txt, kbd)
if err != nil {
return err
}
log.TLogln("tg snake sent", logUserID(uid), logSafeStr(st.Torrent.Title, 40), hash)
go snakeRefreshLoop(c.Bot(), msg, hash, uid, cols, rows)
return nil
}
func formatSnake(uid int64, st *cacheSt.CacheState, hash string, cols, rows int) string {
totalBlocks := cols * rows
if totalBlocks <= 0 {
return tr(uid, "snake_no_data")
}
if st.PiecesCount <= 0 {
title := ""
if st.Torrent != nil {
title = escapeHtml(st.Torrent.Title)
}
txt := "📊 <b>" + title + "</b>\n"
txt += fmt.Sprintf("%s: %s / %s\n", tr(uid, "snake_cache"),
humanize.IBytes(uint64(st.Filled)), humanize.IBytes(uint64(st.Capacity)))
dispHash := st.Hash
if len(dispHash) > snakeHashDisplayLen {
dispHash = dispHash[:snakeHashDisplayLen]
}
txt += tr(uid, "snake_no_data") + " <code>" + dispHash + "</code>"
return txt
}
pieceFilled := make(map[int]bool)
for id, p := range st.Pieces {
if id >= 0 && id < st.PiecesCount && p.Size > 0 {
pieceFilled[id] = true
}
}
readerPositions := make(map[int]bool)
readerRanges := make(map[int]bool)
for _, r := range st.Readers {
readerPositions[r.Reader] = true
for p := r.Start; p < r.End && p < st.PiecesCount; p++ {
readerRanges[p] = true
}
}
cacheWindowPieces := int64(totalBlocks) * 2
if st.PiecesLength > 0 {
cacheWindowPieces = st.Capacity / st.PiecesLength
}
if cacheWindowPieces < int64(totalBlocks) {
cacheWindowPieces = int64(totalBlocks)
}
startPiece, endPiece := 0, st.PiecesCount
if len(st.Readers) > 0 {
minReader, maxReader := st.PiecesCount, 0
for _, r := range st.Readers {
if r.Reader < minReader {
minReader = r.Reader
}
if r.Reader > maxReader {
maxReader = r.Reader
}
}
windowSize := int(cacheWindowPieces)
snakeWindowStartMu.Lock()
lastStart := snakeWindowStart[hash]
scrollThreshold := windowSize * 3 / 4
if lastStart == 0 || minReader < lastStart {
lastStart = minReader
} else if minReader >= lastStart+scrollThreshold {
lastStart = minReader - windowSize/5
}
if lastStart < 0 {
lastStart = 0
}
snakeWindowStart[hash] = lastStart
snakeWindowStartMu.Unlock()
startPiece = lastStart
endPiece = startPiece + windowSize
if endPiece > st.PiecesCount {
endPiece = st.PiecesCount
startPiece = endPiece - windowSize
if startPiece < 0 {
startPiece = 0
}
}
} else if len(pieceFilled) > 0 {
minP, maxP := st.PiecesCount, 0
for id := range pieceFilled {
if id < minP {
minP = id
}
if id > maxP {
maxP = id
}
}
window := maxP - minP + 1
if window > int(cacheWindowPieces) {
window = int(cacheWindowPieces)
}
startPiece = minP
endPiece = minP + window
if endPiece > st.PiecesCount {
endPiece = st.PiecesCount
}
}
windowSize := endPiece - startPiece
if windowSize <= 0 {
windowSize = 1
}
blocks := make([]string, totalBlocks)
piecesPerBlock := (windowSize + totalBlocks - 1) / totalBlocks
if piecesPerBlock < 1 {
piecesPerBlock = 1
}
for i := 0; i < totalBlocks; i++ {
start := startPiece + i*piecesPerBlock
end := start + piecesPerBlock
if end > endPiece {
end = endPiece
}
if start >= end {
blocks[i] = snakeBlockEmpty
continue
}
blockFilled := false
blockHasReader := false
blockInRange := false
for p := start; p < end; p++ {
if pieceFilled[p] {
blockFilled = true
}
if readerPositions[p] {
blockHasReader = true
}
if readerRanges[p] {
blockInRange = true
}
}
switch {
case blockHasReader:
blocks[i] = snakeBlockReader
case blockFilled:
blocks[i] = snakeBlockFilled
case blockInRange:
blocks[i] = snakeBlockInRange
default:
blocks[i] = snakeBlockEmpty
}
}
var sb strings.Builder
title := ""
if st.Torrent != nil {
title = st.Torrent.Title
}
if len([]rune(title)) > snakeTitleMaxLen {
title = string([]rune(title)[:snakeTitleMaxLen]) + "…"
}
title = escapeHtml(title)
sb.WriteString("📊 <b>")
sb.WriteString(title)
sb.WriteString("</b>\n")
fmt.Fprintf(&sb, "%s: %s / %s",
tr(uid, "snake_cache"),
humanize.IBytes(uint64(st.Filled)),
humanize.IBytes(uint64(st.Capacity)))
if len(st.Readers) > 1 {
fmt.Fprintf(&sb, " · %d %s", len(st.Readers), tr(uid, "status_streams"))
}
if endPiece-startPiece < st.PiecesCount {
fmt.Fprintf(&sb, " · %s %d-%d", tr(uid, "snake_pieces"), startPiece+1, endPiece)
}
sb.WriteString("\n")
for r := 0; r < rows; r++ {
for c := 0; c < cols; c++ {
var idx int
if r%2 == 0 {
idx = r*cols + c
} else {
idx = r*cols + (cols - 1 - c)
}
if idx < len(blocks) {
sb.WriteString(blocks[idx])
}
}
sb.WriteString("\n")
}
dispHash := st.Hash
if len(dispHash) > snakeHashDisplayLen {
dispHash = dispHash[:snakeHashDisplayLen]
}
sb.WriteString(tr(uid, "snake_legend"))
sb.WriteString(" <code>")
sb.WriteString(dispHash)
sb.WriteString("</code>")
return sb.String()
}
func snakeData(hash string, cols, rows int) string {
return fmt.Sprintf("%s|%d|%d", hash, cols, rows)
}
func parseSnakeData(data string) (hash string, cols, rows int) {
cols, rows = 20, 3
parts := strings.Split(data, "|")
if len(parts) > 0 {
hash = parts[0]
}
if len(parts) > 1 {
if n, err := strconv.Atoi(parts[1]); err == nil && n > 0 {
cols = n
}
}
if len(parts) > 2 {
if n, err := strconv.Atoi(parts[2]); err == nil && n > 0 {
rows = n
}
}
return hash, cols, rows
}
func snakeKeyboard(uid int64, hash string, cols, rows int, active bool) *tele.ReplyMarkup {
data := snakeData(hash, cols, rows)
if active {
return &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{
{
{Text: "🔄", Unique: "fsnakerefresh", Data: data},
{Text: tr(uid, "status_stop_btn"), Unique: "fsnakestop", Data: data},
},
}}
}
return &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{
{{Text: tr(uid, "status_refresh_btn"), Unique: "fsnakerefresh", Data: data}},
}}
}
func snakeRefreshLoop(api tele.API, msg *tele.Message, hash string, uid int64, cols, rows int) {
const interval = 2 * time.Second
const duration = 2 * time.Minute
stopCh := make(chan struct{})
snakeStopChansMu.Lock()
snakeStopChans[msg.ID] = stopCh
snakeStopChansMu.Unlock()
defer func() {
snakeStopChansMu.Lock()
delete(snakeStopChans, msg.ID)
snakeStopChansMu.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)
if t != nil {
if st := t.CacheState(); st != nil {
txt := formatSnake(uid, st, hash, cols, rows) + "\n" + tr(uid, "status_auto_ended")
_, _ = api.Edit(msg, txt, snakeKeyboard(uid, hash, cols, rows, false), tele.ModeHTML)
}
}
return
}
t := torr.GetTorrent(hash)
if t == nil {
return
}
st := t.CacheState()
if st == nil {
return
}
txt := formatSnake(uid, st, hash, cols, rows)
if _, err := api.Edit(msg, txt, snakeKeyboard(uid, hash, cols, rows, 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 snake refresh err", err)
return
}
}
}
}
func stopSnakeRefresh(msgID int) {
snakeStopChansMu.Lock()
ch := snakeStopChans[msgID]
delete(snakeStopChans, msgID)
snakeStopChansMu.Unlock()
if ch != nil {
close(ch)
}
}
func callbackSnakeRefresh(c tele.Context, data string) error {
hash, cols, rows := parseSnakeData(data)
if hash == "" {
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
}
t := torr.GetTorrent(hash)
if t == nil {
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "torrent_not_found")})
}
st := t.CacheState()
if st == nil {
return c.Respond(&tele.CallbackResponse{Text: fmt.Sprintf(tr(c.Sender().ID, "cache_unavailable"), hash)})
}
if c.Callback().Message != nil {
stopSnakeRefresh(c.Callback().Message.ID)
_ = c.Bot().Delete(c.Callback().Message)
}
_ = c.Respond(&tele.CallbackResponse{})
uid := c.Sender().ID
txt := formatSnake(uid, st, hash, cols, rows)
kbd := snakeKeyboard(uid, hash, cols, rows, true)
msg, err := c.Bot().Send(c.Sender(), txt, kbd)
if err != nil {
return err
}
go snakeRefreshLoop(c.Bot(), msg, hash, uid, cols, rows)
return nil
}
func callbackSnakeStop(c tele.Context, data string) error {
uid := c.Sender().ID
hash, cols, rows := parseSnakeData(data)
if hash != "" {
if t := torr.GetTorrent(hash); t != nil {
log.TLogln("tg snake stop", logUserID(uid), logSafeStr(t.Title, 40), hash)
}
}
if c.Callback().Message != nil {
stopSnakeRefresh(c.Callback().Message.ID)
if hash != "" {
msg := c.Callback().Message
t := torr.GetTorrent(hash)
txt := ""
if t != nil {
if st := t.CacheState(); st != nil {
txt = formatSnake(uid, st, hash, cols, rows)
}
}
if txt == "" {
txt = "<code>" + hash + "</code>"
}
txt += "\n" + tr(uid, "status_stopped")
_, _ = c.Bot().Edit(msg, txt, snakeKeyboard(uid, hash, cols, rows, false), tele.ModeHTML)
}
}
return c.Respond(&tele.CallbackResponse{Text: "🛑"})
}
+23
View File
@@ -0,0 +1,23 @@
package tgbot
import (
"fmt"
"strconv"
"strings"
tele "gopkg.in/telebot.v4"
)
func cmdSpeedtest(c tele.Context) error {
args := c.Args()
size := 10
if len(args) > 0 {
if s, err := strconv.Atoi(strings.TrimSpace(args[0])); err == nil && s > 0 && s <= 100 {
size = s
}
}
host := getHost()
url := fmt.Sprintf("%s/download/%d", host, size)
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "speedtest_msg"), size, url))
}
+21
View File
@@ -0,0 +1,21 @@
package tgbot
import (
"bytes"
"strings"
tele "gopkg.in/telebot.v4"
"server/torr"
)
func cmdStat(c tele.Context) error {
var buf bytes.Buffer
torr.WriteStatus(&buf)
msg := buf.String()
msg = strings.ReplaceAll(msg, "<", "&lt;")
msg = strings.ReplaceAll(msg, ">", "&gt;")
if len(msg) > 4000 {
msg = msg[:4000] + "\n..."
}
return c.Send("📋 <b>" + tr(c.Sender().ID, "help_stat") + "</b>\n\n<pre>" + msg + "</pre>")
}
+50
View File
@@ -0,0 +1,50 @@
package tgbot
import (
"fmt"
"strings"
"github.com/dustin/go-humanize"
tele "gopkg.in/telebot.v4"
"server/torr"
)
func cmdStats(c tele.Context) error {
torrents := torr.ListTorrent()
if len(torrents) == 0 {
return c.Send(tr(c.Sender().ID, "no_torrents"))
}
var totalSize, loadedSize int64
var totalPeers, activePeers, seeders int
for _, t := range torrents {
st := t.Status()
if st != nil {
totalSize += st.TorrentSize
loadedSize += st.LoadedSize
totalPeers += st.TotalPeers
activePeers += st.ActivePeers
seeders += st.ConnectedSeeders
} else {
totalSize += t.Size
}
}
streams := torr.GetActiveStreams()
uid := c.Sender().ID
var sb strings.Builder
sb.WriteString("📊 <b>" + tr(uid, "stats_title") + "</b>\n\n")
fmt.Fprintf(&sb, "%s: %d\n", tr(uid, "stats_torrents"), len(torrents))
fmt.Fprintf(&sb, "%s: %s\n", tr(uid, "stats_total_size"), humanize.IBytes(uint64(totalSize)))
progress := 0.0
if totalSize > 0 {
progress = float64(loadedSize) / float64(totalSize) * 100
}
fmt.Fprintf(&sb, "%s: %s (%.1f%%)\n",
tr(uid, "stats_loaded"), humanize.IBytes(uint64(loadedSize)), progress)
fmt.Fprintf(&sb, "%s: %d %s, %d %s\n",
tr(uid, "stats_peers"), activePeers, tr(uid, "stats_active"), seeders, tr(uid, "stats_seeds"))
fmt.Fprintf(&sb, "%s: %d\n", tr(uid, "stats_streams"), streams)
return c.Send(sb.String())
}
+395
View File
@@ -0,0 +1,395 @@
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<code>" + hash + "</code>")
}
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 = "<b>" + escapeHtml(t.Title) + "</b>\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 = "<b>" + escapeHtml(t.Title) + "</b>\n" + tr(uid, "status_label") + ": " + t.Stat.String()
}
txt += "\n\n" + tr(uid, "status_auto_ended")
} else {
txt = "<code>" + hash + "</code>\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 := "<code>" + hash + "</code>\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 = "<b>" + escapeHtml(t.Title) + "</b>\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<code>"+hash+"</code>", 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<code>"+hash+"</code>", tele.ModeHTML)
return
}
txt := formatTorrentStatus(uid, t)
if txt == "" {
txt = "<b>" + escapeHtml(t.Title) + "</b>\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 = "<b>" + escapeHtml(t.Title) + "</b>\n" + tr(uid, "status_label") + ": " + t.Stat.String()
}
} else {
txt = "<code>" + hash + "</code>"
}
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 "<b>" + escapeHtml(t.Title) + "</b>\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("<b>%s</b>\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("<code>%s</code>", st.Hash)
return txt
}
+44
View File
@@ -0,0 +1,44 @@
package tgbot
import (
"strconv"
"strings"
tele "gopkg.in/telebot.v4"
up "server/tgbot/upload"
)
func upload(c tele.Context) error {
args := c.Args()
if len(args) < 3 {
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
}
hash := args[1]
if !isHash(hash) {
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
}
id, err := strconv.Atoi(args[2])
if err != nil {
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
}
up.AddRange(c, hash, id, id)
return nil
}
func uploadall(c tele.Context) error {
args := c.Args()
if len(args) < 2 {
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
}
hash := ""
if len(args) >= 3 && isHash(args[2]) {
hash = args[2]
} else {
hash = strings.TrimPrefix(args[1], "all|")
}
if !isHash(hash) {
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
}
up.AddRange(c, hash, 1, -1)
return nil
}
+288
View File
@@ -0,0 +1,288 @@
package upload
import (
"errors"
"fmt"
"math"
"path/filepath"
"strconv"
"sync"
"time"
tele "gopkg.in/telebot.v4"
"server/log"
"server/torr"
"server/torr/state"
)
// TrFunc is set by tgbot for localization (avoids circular import)
var TrFunc func(int64, string) string
// EscapeFunc is set by tgbot for HTML escaping (avoids circular import)
var EscapeFunc func(string) string
func tr(uid int64, key string) string {
if TrFunc != nil {
return TrFunc(uid, key)
}
return key
}
func escapeHtml(s string) string {
if EscapeFunc != nil {
return EscapeFunc(s)
}
return s
}
type Worker struct {
id int
c tele.Context
msg *tele.Message
torrentHash string
isCancelled bool
from int
to int
ti *state.TorrentStatus
}
type Manager struct {
queue []*Worker
working map[int]*Worker
ids int
wrkSync sync.Mutex
queueLock sync.Mutex
}
func (m *Manager) Start() {
m.working = make(map[int]*Worker)
go m.work()
}
func (m *Manager) AddRange(c tele.Context, hash string, from, to int) {
m.queueLock.Lock()
defer m.queueLock.Unlock()
if len(m.queue) > 50 {
c.Bot().Send(c.Recipient(), fmt.Sprintf(tr(c.Sender().ID, "upload_queue_full"), len(m.queue)))
return
}
m.ids++
if m.ids > math.MaxInt {
m.ids = 0
}
var msg *tele.Message
var err error
for i := 0; i < 20; i++ {
msg, err = c.Bot().Send(c.Recipient(), fmt.Sprintf(tr(c.Sender().ID, "upload_connecting"), hash))
if err == nil {
break
}
log.TLogln("tg upload retry", i+1, "/", 20)
if i < 19 {
backoff := time.Duration(1<<uint(i)) * 100 * time.Millisecond
if backoff > 5*time.Second {
backoff = 5 * time.Second
}
time.Sleep(backoff)
}
}
if err != nil {
log.TLogln("tg upload send err", err)
return
}
t := torr.GetTorrent(hash)
if t == nil {
c.Bot().Edit(msg, tr(c.Sender().ID, "torrent_not_found")+":\n<code>"+hash+"</code>")
return
}
t.WaitInfo()
for t.Status().Stat != state.TorrentWorking {
time.Sleep(time.Second)
t = torr.GetTorrent(hash)
if t == nil {
return
}
}
ti := t.Status()
if from == 1 && to == -1 {
to = len(ti.FileStats)
}
if from < 1 {
from = 1
}
if to > len(ti.FileStats) {
to = len(ti.FileStats)
}
if from > to {
from, to = to, from
}
if to > len(ti.FileStats) {
to = len(ti.FileStats)
}
w := &Worker{
id: m.ids,
c: c,
torrentHash: hash,
msg: msg,
ti: ti,
from: from,
to: to,
}
m.queue = append(m.queue, w)
}
func (m *Manager) Cancel(id int) {
m.queueLock.Lock()
defer m.queueLock.Unlock()
for i, w := range m.queue {
if w.id == id {
w.isCancelled = true
w.c.Bot().Delete(w.msg)
m.queue = append(m.queue[:i], m.queue[i+1:]...)
return
}
}
if wrk, ok := m.working[id]; ok {
wrk.isCancelled = true
return
}
}
func (m *Manager) work() {
for {
m.queueLock.Lock()
if len(m.working) > 0 {
m.queueLock.Unlock()
m.sendQueueStatus()
time.Sleep(time.Second)
continue
}
if len(m.queue) == 0 {
m.queueLock.Unlock()
time.Sleep(time.Second)
continue
}
wrk := m.queue[0]
m.queue = m.queue[1:]
m.working[wrk.id] = wrk
m.queueLock.Unlock()
m.sendQueueStatus()
loading(wrk)
m.queueLock.Lock()
delete(m.working, wrk.id)
m.queueLock.Unlock()
}
}
func (m *Manager) sendQueueStatus() {
m.queueLock.Lock()
defer m.queueLock.Unlock()
for i, wrk := range m.queue {
if wrk.msg == nil || wrk.c.Sender() == nil {
continue
}
torrKbd := &tele.ReplyMarkup{}
torrKbd.Inline([]tele.Row{torrKbd.Row(torrKbd.Data(tr(wrk.c.Sender().ID, "upload_cancel"), "cancel", strconv.Itoa(wrk.id)))}...)
msg := fmt.Sprintf(tr(wrk.c.Sender().ID, "upload_queue_pos"), i+1)
wrk.c.Bot().Edit(wrk.msg, msg, torrKbd)
}
}
func loading(wrk *Worker) {
iserr := false
t := torr.GetTorrent(wrk.torrentHash)
if t == nil {
wrk.c.Bot().Edit(wrk.msg, tr(wrk.c.Sender().ID, "torrent_not_found")+":\n<code>"+wrk.torrentHash+"</code>")
return
}
t.WaitInfo()
for t.Status().Stat != state.TorrentWorking {
time.Sleep(time.Second)
t = torr.GetTorrent(wrk.torrentHash)
if t == nil {
return
}
}
wrk.ti = t.Status()
for i := wrk.from - 1; i <= wrk.to-1; i++ {
file := wrk.ti.FileStats[i]
if wrk.isCancelled {
return
}
err := uploadFile(wrk, file, i+1, len(wrk.ti.FileStats))
if err != nil {
errstr := fmt.Sprintf(tr(wrk.c.Sender().ID, "upload_error"), err)
wrk.c.Bot().Edit(wrk.msg, errstr)
iserr = true
break
}
}
if !iserr {
wrk.c.Bot().Delete(wrk.msg)
}
}
func uploadFile(wrk *Worker, file *state.TorrentFileStat, fi, fc int) error {
caption := filepath.Base(file.Path)
torrFile, err := NewTorrFile(wrk, file)
if err != nil {
return err
}
var wa sync.WaitGroup
wa.Add(1)
complete := false
go func() {
for !complete {
updateLoadStatus(wrk, torrFile, fi, fc)
time.Sleep(1 * time.Second)
}
wa.Done()
}()
d := &tele.Document{}
d.FileName = file.Path
d.Caption = caption
d.File.FileReader = torrFile
for i := 0; i < 20; i++ {
err = wrk.c.Send(d)
if err == nil || errors.Is(err, ERR_STOPPED) {
break
}
log.TLogln("tg upload retry", i+1, "/", 20)
if i < 19 {
backoff := time.Duration(1<<uint(i)) * 100 * time.Millisecond
if backoff > 5*time.Second {
backoff = 5 * time.Second
}
time.Sleep(backoff)
}
}
complete = true
wa.Wait()
torrFile.Close()
if errors.Is(err, ERR_STOPPED) {
err = nil
}
return err
}
+130
View File
@@ -0,0 +1,130 @@
package upload
import (
"fmt"
"strconv"
"time"
"github.com/dustin/go-humanize"
tele "gopkg.in/telebot.v4"
"server/torr"
)
type DLQueue struct {
id int
c tele.Context
hash string
fileID string
fileName string
updateMsg *tele.Message
}
var manager = &Manager{}
func Start() {
manager.Start()
}
func ShowQueue(c tele.Context) error {
msg := ""
manager.queueLock.Lock()
defer manager.queueLock.Unlock()
if len(manager.queue) == 0 && len(manager.working) == 0 {
return c.Send(tr(c.Sender().ID, "queue_empty"))
}
if len(manager.working) > 0 {
msg += tr(c.Sender().ID, "upload_working") + ":\n"
i := 0
for _, dlQueue := range manager.working {
s := "#" + strconv.Itoa(i+1) + ": <code>" + dlQueue.torrentHash + "</code>\n"
if len(msg+s) > 1024 {
c.Send(msg)
msg = ""
}
msg += s
i++
}
if len(msg) > 0 {
c.Send(msg)
msg = ""
}
}
if len(manager.queue) > 0 {
msg = tr(c.Sender().ID, "upload_in_queue") + ":\n"
for i, dlQueue := range manager.queue {
s := "#" + strconv.Itoa(i+1) + ": <code>" + dlQueue.torrentHash + "</code>\n"
if len(msg+s) > 1024 {
c.Send(msg)
msg = ""
}
msg += s
}
if len(msg) > 0 {
c.Send(msg)
msg = ""
}
}
return nil
}
func AddRange(c tele.Context, hash string, from, to int) {
manager.AddRange(c, hash, from, to)
}
func Cancel(id int) {
manager.Cancel(id)
}
func updateLoadStatus(wrk *Worker, file *TorrFile, fi, fc int) {
if wrk.msg == nil {
return
}
t := torr.GetTorrent(wrk.torrentHash)
if t == nil {
return
}
ti := t.Status()
if wrk.isCancelled {
wrk.c.Bot().Edit(wrk.msg, tr(wrk.c.Sender().ID, "upload_stopping"))
} else {
wrk.c.Send(tele.UploadingVideo)
if ti.DownloadSpeed == 0 {
ti.DownloadSpeed = 1.0
}
wait := time.Duration(float64(file.Remaining())/ti.DownloadSpeed) * time.Second
speed := humanize.IBytes(uint64(ti.DownloadSpeed)) + "/sec"
peers := fmt.Sprintf("%v · %v/%v", ti.ConnectedSeeders, ti.ActivePeers, ti.TotalPeers)
prc := fmt.Sprintf("%.2f%% %v / %v", float64(file.offset)*100.0/float64(file.size), humanize.IBytes(uint64(file.offset)), humanize.IBytes(uint64(file.size)))
name := file.name
if name == ti.Title {
name = ""
}
uid := wrk.c.Sender().ID
msg := tr(uid, "upload_title") + ":\n" +
"<b>" + escapeHtml(ti.Title) + "</b>\n"
if name != "" {
msg += "<i>" + escapeHtml(name) + "</i>\n"
}
msg += "<b>" + tr(uid, "upload_hash") + ":</b> <code>" + file.hash + "</code>\n"
if file.offset < file.size {
msg += "<b>" + tr(uid, "upload_speed") + ": </b>" + speed + "\n" +
"<b>" + tr(uid, "upload_remaining") + ": </b>" + wait.String() + "\n" +
"<b>" + tr(uid, "upload_peers") + ": </b>" + peers + "\n" +
"<b>" + tr(uid, "upload_progress") + ": </b>" + prc
}
if fc > 1 {
msg += "\n<b>" + tr(uid, "upload_files") + ": </b>" + strconv.Itoa(fi) + "/" + strconv.Itoa(fc)
}
if file.offset >= file.size {
msg += "\n<b>" + tr(uid, "upload_finishing") + "</b>"
wrk.c.Bot().Edit(wrk.msg, msg)
return
}
torrKbd := &tele.ReplyMarkup{}
torrKbd.Inline([]tele.Row{torrKbd.Row(torrKbd.Data(tr(wrk.c.Sender().ID, "upload_cancel"), "cancel", strconv.Itoa(wrk.id)))}...)
wrk.c.Bot().Edit(wrk.msg, msg, torrKbd)
}
}
+98
View File
@@ -0,0 +1,98 @@
package upload
import (
"errors"
"fmt"
"path/filepath"
"github.com/anacrolix/torrent"
sets "server/settings"
"server/log"
"server/tgbot/config"
"server/torr"
"server/torr/state"
"server/torr/storage/torrstor"
)
var ERR_STOPPED = errors.New("stopped")
type TorrFile struct {
hash string
name string
wrk *Worker
offset int64
size int64
id int
reader *torrstor.Reader
}
func NewTorrFile(wrk *Worker, stFile *state.TorrentFileStat) (*TorrFile, error) {
uid := int64(0)
if wrk.c != nil && wrk.c.Sender() != nil {
uid = wrk.c.Sender().ID
}
if config.Cfg != nil && config.Cfg.HostTG != "" && stFile.Length > 2*1024*1024*1024 {
return nil, errors.New(tr(uid, "upload_file_too_large_2gb"))
}
if (config.Cfg == nil || config.Cfg.HostTG == "") && stFile.Length > 50*1024*1024 {
return nil, errors.New(tr(uid, "upload_file_too_large_50mb"))
}
tf := new(TorrFile)
tf.hash = wrk.torrentHash
tf.name = filepath.Base(stFile.Path)
tf.wrk = wrk
tf.size = stFile.Length
t := torr.GetTorrent(wrk.torrentHash)
t.WaitInfo()
files := t.Files()
var file *torrent.File
for _, tfile := range files {
if tfile.Path() == stFile.Path {
file = tfile
break
}
}
if file == nil {
return nil, fmt.Errorf("file with id %v not found", stFile.Id)
}
if int64(sets.MaxSize) > 0 && file.Length() > int64(sets.MaxSize) {
log.TLogln("tg upload err size", file.DisplayPath(), "max", sets.MaxSize)
return nil, fmt.Errorf("file size exceeded max allowed %d bytes", sets.MaxSize)
}
reader := t.NewReader(file)
if reader == nil {
return nil, errors.New("cannot create torrent reader")
}
if sets.BTsets != nil && sets.BTsets.ResponsiveMode {
reader.SetResponsive()
}
tf.reader = reader
return tf, nil
}
func (t *TorrFile) Read(p []byte) (n int, err error) {
if t.wrk.isCancelled {
return 0, ERR_STOPPED
}
n, err = t.reader.Read(p)
t.offset += int64(n)
return
}
func (t *TorrFile) Remaining() int64 {
return t.size - t.offset
}
func (t *TorrFile) Close() {
if t.reader != nil {
t.reader.Close()
t.reader = nil
}
}
+141
View File
@@ -0,0 +1,141 @@
package tgbot
import (
"fmt"
"strconv"
"strings"
"unicode"
tele "gopkg.in/telebot.v4"
"server/settings"
"server/tgbot/config"
"server/web"
)
func chatMsgKey(chatID int64, msgID int) string {
return fmt.Sprintf("%d_%d", chatID, msgID)
}
// escapeHtml escapes <, >, &, " for Telegram HTML parse mode to prevent "Unsupported start tag" errors
func escapeHtml(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
return s
}
// logSafeStr truncates by runes, strips emojis/symbols for clean laconic logs
func logSafeStr(s string, maxRunes int) string {
var b strings.Builder
n := 0
lastSpace := true
for _, r := range s {
if n >= maxRunes {
break
}
switch {
case r == '\n' || r == '\r' || r == '\t':
if !lastSpace {
b.WriteRune(' ')
n++
lastSpace = true
}
case r < 32 || r == 127:
case logIsEmojiOrSymbol(r):
case unicode.IsLetter(r) || unicode.IsNumber(r) || r == '/' || r == '-' || r == '_' || r == '|' || r == ':' || r == '.' || r == ',' || r == ' ' || r == '?' || r == '!' || r == '@':
b.WriteRune(r)
n++
lastSpace = (r == ' ')
default:
b.WriteRune(r)
n++
lastSpace = false
}
}
return strings.TrimSpace(b.String())
}
func logIsEmojiOrSymbol(r rune) bool {
if unicode.IsSymbol(r) {
return true
}
u := uint32(r)
return (u >= 0x1F300 && u <= 0x1F9FF) || (u >= 0x2600 && u <= 0x26FF) ||
(u >= 0x2700 && u <= 0x27BF) || (u >= 0x1F600 && u <= 0x1F64F) ||
(u >= 0x1F680 && u <= 0x1F6FF) || (u >= 0x1F1E0 && u <= 0x1F1FF) ||
(u >= 0xFE00 && u <= 0xFE0F) || (u >= 0x1F000 && u <= 0x1F02F)
}
// logUser formats uid and optional username for logs
func logUser(u *tele.User) string {
if u == nil {
return "uid=?"
}
return logUserID(u.ID) + logUsername(u.Username)
}
// logUserID formats uid for logs when User is not available
func logUserID(uid int64) string {
return "uid=" + strconv.FormatInt(uid, 10)
}
func logUsername(username string) string {
if username == "" {
return ""
}
return " @" + username
}
// logHashOrTruncate returns hash for logging if link is hash or magnet with btih, else truncated link
func logHashOrTruncate(link string) string {
if isHash(link) {
return link
}
if idx := strings.Index(link, "btih:"); idx >= 0 && idx+45 <= len(link) {
if h := link[idx+5 : idx+45]; isHash(h) {
return h
}
}
if strings.HasPrefix(strings.ToLower(link), "torrs://") && len(link) >= 48 {
if h := link[8:48]; isHash(h) {
return h
}
}
if len(link) > 64 {
return link[:64] + "..."
}
return link
}
// getHost returns the base URL for stream/play links (e.g. http://192.168.1.1:8090)
func getHost() string {
host := config.Cfg.HostWeb
if host == "" {
host = settings.PubIPv4
if host == "" {
ips := web.GetLocalIps()
if len(ips) == 0 {
host = "127.0.0.1"
} else {
host = ips[0]
}
}
}
if !strings.Contains(host, ":") {
if settings.Ssl {
host += ":" + settings.SslPort
} else {
host += ":" + settings.Port
}
}
if !strings.HasPrefix(host, "http") {
if settings.Ssl {
host = "https://" + host
} else {
host = "http://" + host
}
}
return host
}
+68
View File
@@ -0,0 +1,68 @@
package tgbot
import (
"fmt"
"strconv"
"strings"
tele "gopkg.in/telebot.v4"
sets "server/settings"
)
func cmdViewed(c tele.Context) error {
args := c.Args()
if len(args) == 0 {
return c.Send(tr(c.Sender().ID, "viewed_usage"))
}
action := strings.ToLower(args[0])
if action == "set" || action == "rem" {
if len(args) < 2 {
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "viewed_usage_action"), action))
}
hash := resolveHash(c, args[1])
if hash == "" {
return c.Send(tr(c.Sender().ID, "invalid_hash"))
}
if action == "set" {
if len(args) < 3 {
return c.Send(tr(c.Sender().ID, "viewed_usage_set"))
}
index, err := strconv.Atoi(args[2])
if err != nil || index < 1 {
return c.Send(tr(c.Sender().ID, "viewed_file_index"))
}
sets.SetViewed(&sets.Viewed{Hash: hash, FileIndex: index})
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "viewed_marked"), hash, index))
}
index := -1
if len(args) >= 3 {
if i, err := strconv.Atoi(args[2]); err == nil && i >= 1 {
index = i
}
}
sets.RemViewed(&sets.Viewed{Hash: hash, FileIndex: index})
if index >= 1 {
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "viewed_unmarked"), hash, index))
}
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "viewed_cleared"), hash))
}
hash := resolveHash(c, args[0])
if hash == "" {
return c.Send(tr(c.Sender().ID, "viewed_usage"))
}
list := sets.ListViewed(hash)
if len(list) == 0 {
return c.Send(tr(c.Sender().ID, "viewed_empty"))
}
var sb strings.Builder
sb.WriteString("<b>" + tr(c.Sender().ID, "viewed_list") + "</b>\n\n")
fmt.Fprintf(&sb, "<code>%s</code>\n\n", hash)
for _, v := range list {
fmt.Fprintf(&sb, " #%d\n", v.FileIndex)
}
return c.Send(sb.String())
}