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
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:
@@ -0,0 +1,162 @@
|
||||
# TorrServer Telegram Bot
|
||||
|
||||
[](https://github.com/YouROK/TorrServer/blob/master/LICENSE)
|
||||
[](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 Telegram’s 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 (1–100 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 2–12 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)
|
||||
@@ -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, "&", "&")
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")})
|
||||
}
|
||||
}
|
||||
@@ -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")})
|
||||
}
|
||||
@@ -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")})
|
||||
}
|
||||
@@ -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")})
|
||||
}
|
||||
@@ -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")})
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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), "<", "<") + "</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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <query>",
|
||||
"rutor_usage": "ℹ️ Usage: /rutor <query>",
|
||||
"torznab_usage": "ℹ️ Usage: /torznab <query> [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 <link> - 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 <text> - 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 <name> or /preset <key> <value> ...\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 <magnet|hash|torrs://|url>\nPaste torrent link",
|
||||
"add_no_link": "ℹ️ Specify torrent link",
|
||||
"remove_usage": "ℹ️ Usage: /remove <hash|number>\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 <hash|number> [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 <hash|number> [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 <hash|number> <index>\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 <hash|number>\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 <hash|number> [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 <hash|index> <title>\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 <hash|index> — list\n/viewed set <hash|index> <file> — mark\n/viewed rem <hash|index> [file] — unmark",
|
||||
"viewed_usage_action": "ℹ️ Usage: /viewed %s <hash|index> [file]",
|
||||
"viewed_usage_set": "ℹ️ Usage: /viewed set <hash|index> <file>",
|
||||
"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 <hash|number> <id> [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 <text with magnet/hash/torrs>\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>",
|
||||
}
|
||||
@@ -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 <запрос>",
|
||||
"rutor_usage": "ℹ️ Использование: /rutor <запрос>",
|
||||
"torznab_usage": "ℹ️ Использование: /torznab <запрос> [индекс]",
|
||||
"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 <ссылка> - Добавить торрент",
|
||||
"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 <текст> - Импорт из списка",
|
||||
"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 <имя> или /preset <ключ> <значение> ...\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 <magnet|hash|torrs://|url>\nВставьте ссылку на торрент",
|
||||
"add_no_link": "ℹ️ Укажите ссылку на торрент",
|
||||
"remove_usage": "ℹ️ Использование: /remove <hash|номер>\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 <hash|номер> [index]\nИли ответьте на сообщение торрента",
|
||||
"link_play": "🔗 Ссылка для воспроизведения:\n<code>%s</code>",
|
||||
"server_title": "Сервер TorrServer",
|
||||
"server_url": "URL",
|
||||
"server_port": "Порт",
|
||||
"server_streams": "Активных потоков",
|
||||
"m3u_usage": "ℹ️ Использование: /m3u <hash|номер> [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 <hash|номер> <index>\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 <hash|номер>\nИли ответьте на сообщение торрента",
|
||||
"cache_capacity": "Ёмкость",
|
||||
"cache_filled": "Заполнено",
|
||||
"cache_pieces": "Пайсов",
|
||||
"cache_readers": "Читателей",
|
||||
"cache_unavailable": "⚠️ Кэш недоступен для торрента:\n<code>%s</code>",
|
||||
"snake_usage": "ℹ️ Использование: /snake <hash|номер> [колонок] [строк]\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 <hash|номер> <название>\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 <hash|номер> — список\n/viewed set <hash|номер> <index> — отметить\n/viewed rem <hash|номер> [index] — снять отметку",
|
||||
"viewed_usage_action": "ℹ️ Использование: /viewed %s <hash|номер> [index]",
|
||||
"viewed_usage_set": "ℹ️ Использование: /viewed set <hash|номер> <index>",
|
||||
"viewed_file_index": "❌ Укажите номер файла (целое число >= 1)",
|
||||
"viewed_empty": "📭 Нет просмотренных файлов для этого торрента",
|
||||
"speedtest_msg": "⚡ Тест загрузки %d MB:\n<code>%s</code>\n\nСкачайте файл и замерьте скорость",
|
||||
"ffp_usage": "ℹ️ Использование: /ffp <hash|номер> <id> [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 <текст с magnet/hash/torrs>\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>",
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: "🛑"})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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, "<", "<")
|
||||
msg = strings.ReplaceAll(msg, ">", ">")
|
||||
if len(msg) > 4000 {
|
||||
msg = msg[:4000] + "\n..."
|
||||
}
|
||||
return c.Send("📋 <b>" + tr(c.Sender().ID, "help_stat") + "</b>\n\n<pre>" + msg + "</pre>")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, "\"", """)
|
||||
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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user