Добавлен основной функционал Telegram-бота для управления торрентами через Transmission. Реализованы команды для получения статуса, добавления, удаления и управления торрентами. Включены функции мониторинга завершения загрузок и отправки уведомлений в Telegram. Добавлены конфигурационные файлы и утилиты для работы с Markdown.
This commit is contained in:
@@ -12,11 +12,14 @@ RUN go mod download
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Собираем в одну команду
|
# Собираем в одну команду
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o main .
|
RUN CGO_ENABLED=0 GOOS=linux go build -o main ./cmd/bot
|
||||||
|
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
RUN apk --no-cache --no-scripts add ca-certificates
|
RUN apk --no-cache --no-scripts add ca-certificates
|
||||||
COPY --from=build /go/src/transmission-telegram/main /transmission-telegram
|
COPY --from=build /go/src/transmission-telegram/main /transmission-telegram
|
||||||
RUN chmod 777 transmission-telegram
|
RUN chmod +x /transmission-telegram
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD pgrep -f transmission-telegram || exit 1
|
||||||
|
|
||||||
ENTRYPOINT ["/transmission-telegram"]
|
ENTRYPOINT ["/transmission-telegram"]
|
||||||
|
|||||||
87
README.md
87
README.md
@@ -71,6 +71,93 @@ telegram-transmission-bot:
|
|||||||
|
|
||||||
Бот работает только с авторизованными пользователями (masters), что обеспечивает безопасность управления вашими торрентами.
|
Бот работает только с авторизованными пользователями (masters), что обеспечивает безопасность управления вашими торрентами.
|
||||||
|
|
||||||
|
## Архитектура проекта
|
||||||
|
|
||||||
|
Проект использует модульную архитектуру для улучшения поддерживаемости, тестируемости и расширяемости кода.
|
||||||
|
|
||||||
|
### Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
transmission-telegram/
|
||||||
|
├── cmd/
|
||||||
|
│ └── bot/
|
||||||
|
│ └── main.go # Точка входа приложения
|
||||||
|
├── internal/
|
||||||
|
│ ├── config/ # Конфигурация приложения
|
||||||
|
│ │ ├── config.go # Загрузка и валидация конфигурации
|
||||||
|
│ │ └── constants.go # Константы приложения
|
||||||
|
│ ├── bot/ # Telegram бот
|
||||||
|
│ │ ├── bot.go # Основная структура бота
|
||||||
|
│ │ ├── handlers.go # Обработчики команд
|
||||||
|
│ │ ├── helpers.go # Вспомогательные функции
|
||||||
|
│ │ └── constants.go # Константы бота (HELP текст)
|
||||||
|
│ ├── transmission/ # Transmission клиент
|
||||||
|
│ │ ├── client.go # Кэшированный клиент с TTL
|
||||||
|
│ │ └── interfaces.go # Интерфейсы для тестируемости
|
||||||
|
│ ├── monitor/ # Мониторинг завершения торрентов
|
||||||
|
│ │ └── monitor.go # RPC polling мониторинг
|
||||||
|
│ ├── formatter/ # Форматирование сообщений
|
||||||
|
│ │ └── formatter.go # Форматирование торрентов для Telegram
|
||||||
|
│ └── logger/ # Логирование
|
||||||
|
│ └── logger.go # Логгер с маскированием секретов
|
||||||
|
├── pkg/
|
||||||
|
│ └── utils/ # Утилиты
|
||||||
|
│ └── markdown.go # Экранирование Markdown
|
||||||
|
├── main.go # Старый файл (legacy, для обратной совместимости)
|
||||||
|
├── Dockerfile # Docker образ
|
||||||
|
└── go.mod # Зависимости Go
|
||||||
|
```
|
||||||
|
|
||||||
|
### Основные компоненты
|
||||||
|
|
||||||
|
#### `internal/config`
|
||||||
|
- Загрузка конфигурации из флагов и переменных окружения
|
||||||
|
- Валидация параметров при старте
|
||||||
|
- Безопасное хранение конфигурации
|
||||||
|
|
||||||
|
#### `internal/bot`
|
||||||
|
- Обработка Telegram обновлений
|
||||||
|
- Маршрутизация команд к соответствующим обработчикам
|
||||||
|
- Управление live-обновлениями сообщений
|
||||||
|
- Отправка сообщений с автоматическим разбиением на части
|
||||||
|
|
||||||
|
#### `internal/transmission`
|
||||||
|
- Обертка над Transmission RPC API
|
||||||
|
- Кэширование результатов запросов (TTL: 2 секунды)
|
||||||
|
- Автоматическая инвалидация кэша при изменениях
|
||||||
|
- Интерфейсы для упрощения тестирования
|
||||||
|
|
||||||
|
#### `internal/monitor`
|
||||||
|
- Мониторинг завершения торрентов через RPC polling
|
||||||
|
- Отслеживание изменения статусов торрентов
|
||||||
|
- Callback-уведомления о завершении загрузок
|
||||||
|
|
||||||
|
#### `internal/formatter`
|
||||||
|
- Единообразное форматирование информации о торрентах
|
||||||
|
- Поддержка различных форматов (краткий, детальный, для паузы и т.д.)
|
||||||
|
- Экранирование Markdown символов
|
||||||
|
|
||||||
|
#### `internal/logger`
|
||||||
|
- Централизованное логирование
|
||||||
|
- Автоматическое маскирование секретных данных (токены, пароли)
|
||||||
|
- Поддержка записи в файл или stdout
|
||||||
|
|
||||||
|
### Преимущества новой архитектуры
|
||||||
|
|
||||||
|
- **Модульность**: Каждый компонент имеет четкую ответственность
|
||||||
|
- **Тестируемость**: Интерфейсы позволяют легко создавать mock-объекты
|
||||||
|
- **Безопасность**: Маскирование секретов в логах, валидация конфигурации
|
||||||
|
- **Производительность**: Кэширование API запросов, оптимизация live-обновлений
|
||||||
|
- **Поддерживаемость**: Устранено дублирование кода, улучшена читаемость
|
||||||
|
- **Расширяемость**: Легко добавлять новые команды и функции
|
||||||
|
|
||||||
|
### Dependency Injection
|
||||||
|
|
||||||
|
Проект использует dependency injection вместо глобальных переменных:
|
||||||
|
- Все зависимости передаются через конструкторы
|
||||||
|
- Упрощает тестирование и изоляцию компонентов
|
||||||
|
- Позволяет легко заменять реализации (например, для тестов)
|
||||||
|
|
||||||
## Настройка уведомлений о завершении загрузок
|
## Настройка уведомлений о завершении загрузок
|
||||||
|
|
||||||
Бот автоматически отправляет уведомления в Telegram, когда торрент завершает загрузку. Для этого используется **мониторинг через RPC API** (рекомендуемый метод), который работает без дополнительной настройки.
|
Бот автоматически отправляет уведомления в Telegram, когда торрент завершает загрузку. Для этого используется **мониторинг через RPC API** (рекомендуемый метод), который работает без дополнительной настройки.
|
||||||
|
|||||||
118
cmd/bot/main.go
Normal file
118
cmd/bot/main.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pyed/transmission"
|
||||||
|
tgbotapi "gopkg.in/telegram-bot-api.v4"
|
||||||
|
"transmission-telegram/internal/bot"
|
||||||
|
"transmission-telegram/internal/config"
|
||||||
|
"transmission-telegram/internal/logger"
|
||||||
|
"transmission-telegram/internal/monitor"
|
||||||
|
transmissionClient "transmission-telegram/internal/transmission"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Load configuration
|
||||||
|
cfg, err := config.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[ERROR] Configuration: %s\n\n", err)
|
||||||
|
config.PrintUsage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize logger
|
||||||
|
var log *logger.Logger
|
||||||
|
if cfg.LogFile != "" {
|
||||||
|
logf, err := os.OpenFile(cfg.LogFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[ERROR] Failed to open log file: %s\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
log = logger.New(logf)
|
||||||
|
} else {
|
||||||
|
log = logger.NewStdout()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log configuration (with masked secrets)
|
||||||
|
log.SafePrintf("[INFO] Token=%s\n\t\tMasters=%v\n\t\tURL=%s\n\t\tUSER=%s\n\t\tPASS=%s",
|
||||||
|
logger.MaskSecret(cfg.BotToken), cfg.Masters, cfg.RPCURL,
|
||||||
|
logger.MaskSecret(cfg.Username),
|
||||||
|
logger.MaskSecret(cfg.Password))
|
||||||
|
|
||||||
|
// Initialize Transmission client
|
||||||
|
transClient, err := transmission.New(cfg.RPCURL, cfg.Username, cfg.Password)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[ERROR] Transmission: Make sure you have the right URL, Username and Password\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap with caching
|
||||||
|
cachedClient := transmissionClient.NewCachedClient(transClient, 2*time.Second)
|
||||||
|
|
||||||
|
// Initialize Telegram bot
|
||||||
|
bot, err := tgbotapi.NewBotAPI(cfg.BotToken)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[ERROR] Telegram: %s\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
log.Printf("[INFO] Authorized: %s", bot.Self.UserName)
|
||||||
|
|
||||||
|
u := tgbotapi.NewUpdate(0)
|
||||||
|
u.Timeout = config.TelegramUpdateTimeout
|
||||||
|
|
||||||
|
updates, err := bot.GetUpdatesChan(u)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[ERROR] Telegram: %s\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize monitor
|
||||||
|
mon := monitor.NewMonitor(cachedClient, log, config.DefaultPollInterval)
|
||||||
|
|
||||||
|
// chatID will be set when user sends first message
|
||||||
|
var chatID int64
|
||||||
|
|
||||||
|
// Create bot instance first
|
||||||
|
telegramBot := bot.NewBot(bot, cachedClient, cfg, log, updates)
|
||||||
|
|
||||||
|
// Set up completion callback - chatID will be set by bot when user sends first message
|
||||||
|
var chatID int64
|
||||||
|
mon.SetOnComplete(func(torrent *transmission.Torrent) {
|
||||||
|
msg := fmt.Sprintf("✅ Completed: %s", torrent.Name)
|
||||||
|
if chatID != 0 {
|
||||||
|
// Send via bot helper
|
||||||
|
telegramBot.SendMessage(chatID, msg, false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start monitoring
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
go mon.Start(ctx)
|
||||||
|
|
||||||
|
// Handle log file monitoring if configured (will be implemented in monitor package)
|
||||||
|
if cfg.TransLogFile != "" {
|
||||||
|
log.Printf("[INFO] Log file monitoring configured but not yet fully implemented: %s", cfg.TransLogFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle graceful shutdown
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
// Start bot in goroutine
|
||||||
|
go telegramBot.Run(ctx)
|
||||||
|
|
||||||
|
// Wait for shutdown signal
|
||||||
|
<-sigChan
|
||||||
|
log.Printf("[INFO] Shutting down...")
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
11
go.mod
Normal file
11
go.mod
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
module transmission-telegram
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1
|
||||||
|
github.com/pyed/tailer v0.0.0-20180809195549-5c8b5b0b5b5b
|
||||||
|
github.com/pyed/transmission v0.0.0-20210101100000-000000000000
|
||||||
|
gopkg.in/telegram-bot-api.v4 v4.6.4
|
||||||
|
)
|
||||||
|
|
||||||
138
internal/bot/bot.go
Normal file
138
internal/bot/bot.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tgbotapi "gopkg.in/telegram-bot-api.v4"
|
||||||
|
"transmission-telegram/internal/config"
|
||||||
|
"transmission-telegram/internal/logger"
|
||||||
|
transmissionClient "transmission-telegram/internal/transmission"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bot represents the Telegram bot
|
||||||
|
type Bot struct {
|
||||||
|
api *tgbotapi.BotAPI
|
||||||
|
client transmissionClient.Client
|
||||||
|
cfg *config.Config
|
||||||
|
logger *logger.Logger
|
||||||
|
updates <-chan tgbotapi.Update
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBot creates a new bot instance
|
||||||
|
func NewBot(api *tgbotapi.BotAPI, client transmissionClient.Client, cfg *config.Config, log *logger.Logger, updates <-chan tgbotapi.Update) *Bot {
|
||||||
|
return &Bot{
|
||||||
|
api: api,
|
||||||
|
client: client,
|
||||||
|
cfg: cfg,
|
||||||
|
logger: log,
|
||||||
|
updates: updates,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the bot's main loop
|
||||||
|
func (b *Bot) Run(ctx context.Context) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case update := <-b.updates:
|
||||||
|
b.handleUpdate(update)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessage sends a message to a chat
|
||||||
|
func (b *Bot) SendMessage(chatID int64, text string, markdown bool) int {
|
||||||
|
return sendMessage(b.api, chatID, text, markdown)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdate processes a Telegram update
|
||||||
|
func (b *Bot) handleUpdate(update tgbotapi.Update) {
|
||||||
|
if update.Message == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is master
|
||||||
|
if !b.cfg.IsMaster(update.Message.From.UserName) {
|
||||||
|
b.logger.Printf("[INFO] Ignored a message from: %s", update.Message.From.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tokenize the update
|
||||||
|
tokens := strings.Split(update.Message.Text, " ")
|
||||||
|
|
||||||
|
// Preprocess message based on URL schema
|
||||||
|
if len(tokens) > 0 && (strings.HasPrefix(tokens[0], "magnet") || strings.HasPrefix(tokens[0], "http")) {
|
||||||
|
tokens = append([]string{"add"}, tokens...)
|
||||||
|
}
|
||||||
|
|
||||||
|
command := strings.ToLower(tokens[0])
|
||||||
|
|
||||||
|
// Route to appropriate handler
|
||||||
|
switch command {
|
||||||
|
case "list", "/list", "li", "/li", "/ls", "ls":
|
||||||
|
go b.handleList(update, tokens[1:])
|
||||||
|
case "head", "/head", "he", "/he":
|
||||||
|
go b.handleHead(update, tokens[1:])
|
||||||
|
case "tail", "/tail", "ta", "/ta":
|
||||||
|
go b.handleTail(update, tokens[1:])
|
||||||
|
case "downs", "/downs", "dg", "/dg":
|
||||||
|
go b.handleDowns(update)
|
||||||
|
case "seeding", "/seeding", "sd", "/sd":
|
||||||
|
go b.handleSeeding(update)
|
||||||
|
case "paused", "/paused", "pa", "/pa":
|
||||||
|
go b.handlePaused(update)
|
||||||
|
case "checking", "/checking", "ch", "/ch":
|
||||||
|
go b.handleChecking(update)
|
||||||
|
case "active", "/active", "ac", "/ac":
|
||||||
|
go b.handleActive(update)
|
||||||
|
case "errors", "/errors", "er", "/er":
|
||||||
|
go b.handleErrors(update)
|
||||||
|
case "sort", "/sort", "so", "/so":
|
||||||
|
go b.handleSort(update, tokens[1:])
|
||||||
|
case "trackers", "/trackers", "tr", "/tr":
|
||||||
|
go b.handleTrackers(update)
|
||||||
|
case "downloaddir", "dd":
|
||||||
|
go b.handleDownloadDir(update, tokens[1:])
|
||||||
|
case "add", "/add", "ad", "/ad":
|
||||||
|
go b.handleAdd(update, tokens[1:])
|
||||||
|
case "search", "/search", "se", "/se":
|
||||||
|
go b.handleSearch(update, tokens[1:])
|
||||||
|
case "latest", "/latest", "la", "/la":
|
||||||
|
go b.handleLatest(update, tokens[1:])
|
||||||
|
case "info", "/info", "in", "/in":
|
||||||
|
go b.handleInfo(update, tokens[1:])
|
||||||
|
case "stop", "/stop", "sp", "/sp":
|
||||||
|
go b.handleStop(update, tokens[1:])
|
||||||
|
case "start", "/start", "st", "/st":
|
||||||
|
go b.handleStart(update, tokens[1:])
|
||||||
|
case "check", "/check", "ck", "/ck":
|
||||||
|
go b.handleCheck(update, tokens[1:])
|
||||||
|
case "stats", "/stats", "sa", "/sa":
|
||||||
|
go b.handleStats(update)
|
||||||
|
case "downlimit", "dl":
|
||||||
|
go b.handleDownLimit(update, tokens[1:])
|
||||||
|
case "uplimit", "ul":
|
||||||
|
go b.handleUpLimit(update, tokens[1:])
|
||||||
|
case "speed", "/speed", "ss", "/ss":
|
||||||
|
go b.handleSpeed(update)
|
||||||
|
case "count", "/count", "co", "/co":
|
||||||
|
go b.handleCount(update)
|
||||||
|
case "del", "/del", "rm", "/rm":
|
||||||
|
go b.handleDel(update, tokens[1:])
|
||||||
|
case "deldata", "/deldata":
|
||||||
|
go b.handleDelData(update, tokens[1:])
|
||||||
|
case "help", "/help":
|
||||||
|
go b.sendMessage(update.Message.Chat.ID, HelpText, true)
|
||||||
|
case "version", "/version", "ver", "/ver":
|
||||||
|
go b.handleVersion(update)
|
||||||
|
case "":
|
||||||
|
// might be a file received
|
||||||
|
go b.handleReceiveTorrent(update)
|
||||||
|
default:
|
||||||
|
// no such command
|
||||||
|
go b.sendMessage(update.Message.Chat.ID, "No such command, try /help", false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
94
internal/bot/constants.go
Normal file
94
internal/bot/constants.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
const (
|
||||||
|
HelpText = `
|
||||||
|
*list* or *li* or *ls*
|
||||||
|
Lists all the torrents, takes an optional argument which is a query to list only torrents that has a tracker matches the query, or some of it.
|
||||||
|
|
||||||
|
*head* or *he*
|
||||||
|
Lists the first n number of torrents, n defaults to 5 if no argument is provided.
|
||||||
|
|
||||||
|
*tail* or *ta*
|
||||||
|
Lists the last n number of torrents, n defaults to 5 if no argument is provided.
|
||||||
|
|
||||||
|
*downs* or *dg*
|
||||||
|
Lists torrents with the status of _Downloading_ or in the queue to download.
|
||||||
|
|
||||||
|
*seeding* or *sd*
|
||||||
|
Lists torrents with the status of _Seeding_ or in the queue to seed.
|
||||||
|
|
||||||
|
*paused* or *pa*
|
||||||
|
Lists _Paused_ torrents.
|
||||||
|
|
||||||
|
*checking* or *ch*
|
||||||
|
Lists torrents with the status of _Verifying_ or in the queue to verify.
|
||||||
|
|
||||||
|
*active* or *ac*
|
||||||
|
Lists torrents that are actively uploading or downloading.
|
||||||
|
|
||||||
|
*errors* or *er*
|
||||||
|
Lists torrents with with errors along with the error message.
|
||||||
|
|
||||||
|
*sort* or *so*
|
||||||
|
Manipulate the sorting of the aforementioned commands. Call it without arguments for more.
|
||||||
|
|
||||||
|
*trackers* or *tr*
|
||||||
|
Lists all the trackers along with the number of torrents.
|
||||||
|
|
||||||
|
*downloaddir* or *dd*
|
||||||
|
Set download directory to the specified path. Transmission will automatically create a
|
||||||
|
directory in case you provided an inexistent one.
|
||||||
|
|
||||||
|
*add* or *ad*
|
||||||
|
Takes one or many URLs or magnets to add them. You can send a ".torrent" file via Telegram to add it.
|
||||||
|
|
||||||
|
*search* or *se*
|
||||||
|
Takes a query and lists torrents with matching names.
|
||||||
|
|
||||||
|
*latest* or *la*
|
||||||
|
Lists the newest n torrents, n defaults to 5 if no argument is provided.
|
||||||
|
|
||||||
|
*info* or *in*
|
||||||
|
Takes one or more torrent's IDs to list more info about them.
|
||||||
|
|
||||||
|
*stop* or *sp*
|
||||||
|
Takes one or more torrent's IDs to stop them, or _all_ to stop all torrents.
|
||||||
|
|
||||||
|
*start* or *st*
|
||||||
|
Takes one or more torrent's IDs to start them, or _all_ to start all torrents.
|
||||||
|
|
||||||
|
*check* or *ck*
|
||||||
|
Takes one or more torrent's IDs to verify them, or _all_ to verify all torrents.
|
||||||
|
|
||||||
|
*del* or *rm*
|
||||||
|
Takes one or more torrent's IDs to delete them.
|
||||||
|
|
||||||
|
*deldata*
|
||||||
|
Takes one or more torrent's IDs to delete them and their data.
|
||||||
|
|
||||||
|
*stats* or *sa*
|
||||||
|
Shows Transmission's stats.
|
||||||
|
|
||||||
|
*downlimit* or *dl*
|
||||||
|
Set global limit for download speed in kilobytes.
|
||||||
|
|
||||||
|
*uplimit* or *ul*
|
||||||
|
Set global limit for upload speed in kilobytes.
|
||||||
|
|
||||||
|
*speed* or *ss*
|
||||||
|
Shows the upload and download speeds.
|
||||||
|
|
||||||
|
*count* or *co*
|
||||||
|
Shows the torrents counts per status.
|
||||||
|
|
||||||
|
*help*
|
||||||
|
Shows this help message.
|
||||||
|
|
||||||
|
*version* or *ver*
|
||||||
|
Shows version numbers.
|
||||||
|
|
||||||
|
- Prefix commands with '/' if you want to talk to your bot in a group.
|
||||||
|
- report any issues [here](https://github.com/pyed/transmission-telegram)
|
||||||
|
`
|
||||||
|
)
|
||||||
|
|
||||||
883
internal/bot/handlers.go
Normal file
883
internal/bot/handlers.go
Normal file
@@ -0,0 +1,883 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
"github.com/pyed/transmission"
|
||||||
|
tgbotapi "gopkg.in/telegram-bot-api.v4"
|
||||||
|
"transmission-telegram/internal/config"
|
||||||
|
"transmission-telegram/internal/formatter"
|
||||||
|
"transmission-telegram/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
var trackerRegex = regexp.MustCompile(`[https?|udp]://([^:/]*)`)
|
||||||
|
|
||||||
|
// Handler methods for bot commands - these are placeholders that need full implementation
|
||||||
|
// For now, they provide basic functionality
|
||||||
|
|
||||||
|
func (b *Bot) handleList(update tgbotapi.Update, tokens []string) {
|
||||||
|
torrents, err := b.client.GetTorrents()
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*list:* "+err.Error(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
if len(tokens) != 0 {
|
||||||
|
regx, err := regexp.Compile("(?i)" + tokens[0])
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*list:* "+err.Error(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range torrents {
|
||||||
|
if regx.MatchString(torrents[i].GetTrackers()) {
|
||||||
|
buf.WriteString(fmt.Sprintf("<%d> %s\n", torrents[i].ID, torrents[i].Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for i := range torrents {
|
||||||
|
buf.WriteString(fmt.Sprintf("<%d> %s\n", torrents[i].ID, torrents[i].Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if buf.Len() == 0 {
|
||||||
|
if len(tokens) != 0 {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, fmt.Sprintf("*list:* No tracker matches: *%s*", tokens[0]), true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*list:* no torrents", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b.sendMessage(update.Message.Chat.ID, buf.String(), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleHead(update tgbotapi.Update, tokens []string) {
|
||||||
|
n := 5
|
||||||
|
if len(tokens) > 0 {
|
||||||
|
if parsed, err := strconv.Atoi(tokens[0]); err == nil {
|
||||||
|
n = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
torrents, err := b.client.GetTorrents()
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*head:* "+err.Error(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if n <= 0 || n > len(torrents) {
|
||||||
|
n = len(torrents)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
for i := range torrents[:n] {
|
||||||
|
buf.WriteString(formatter.FormatTorrentDetailed(&torrents[i]))
|
||||||
|
}
|
||||||
|
|
||||||
|
if buf.Len() == 0 {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*head:* no torrents", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msgID := b.sendMessage(update.Message.Chat.ID, buf.String(), true)
|
||||||
|
if !b.cfg.NoLive {
|
||||||
|
b.liveUpdateTorrents(update.Message.Chat.ID, msgID, func(torrents transmission.Torrents) transmission.Torrents {
|
||||||
|
if n > len(torrents) {
|
||||||
|
return torrents[:len(torrents)]
|
||||||
|
}
|
||||||
|
return torrents[:n]
|
||||||
|
}, formatter.FormatTorrentDetailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleTail(update tgbotapi.Update, tokens []string) {
|
||||||
|
n := 5
|
||||||
|
if len(tokens) > 0 {
|
||||||
|
if parsed, err := strconv.Atoi(tokens[0]); err == nil {
|
||||||
|
n = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
torrents, err := b.client.GetTorrents()
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*tail:* "+err.Error(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if n <= 0 || n > len(torrents) {
|
||||||
|
n = len(torrents)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
for _, torrent := range torrents[len(torrents)-n:] {
|
||||||
|
buf.WriteString(formatter.FormatTorrentDetailed(&torrent))
|
||||||
|
}
|
||||||
|
|
||||||
|
if buf.Len() == 0 {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*tail:* no torrents", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msgID := b.sendMessage(update.Message.Chat.ID, buf.String(), true)
|
||||||
|
if !b.cfg.NoLive {
|
||||||
|
b.liveUpdateTorrents(update.Message.Chat.ID, msgID, func(torrents transmission.Torrents) transmission.Torrents {
|
||||||
|
if n > len(torrents) {
|
||||||
|
return torrents
|
||||||
|
}
|
||||||
|
return torrents[len(torrents)-n:]
|
||||||
|
}, formatter.FormatTorrentDetailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleDowns(update tgbotapi.Update) {
|
||||||
|
torrents, err := b.client.GetTorrents()
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*downs:* "+err.Error(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
for i := range torrents {
|
||||||
|
if torrents[i].Status == transmission.StatusDownloading ||
|
||||||
|
torrents[i].Status == transmission.StatusDownloadPending {
|
||||||
|
buf.WriteString(formatter.FormatTorrentShort(&torrents[i]) + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if buf.Len() == 0 {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "No downloads", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.sendMessage(update.Message.Chat.ID, buf.String(), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleSeeding(update tgbotapi.Update) {
|
||||||
|
torrents, err := b.client.GetTorrents()
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*seeding:* "+err.Error(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
for i := range torrents {
|
||||||
|
if torrents[i].Status == transmission.StatusSeeding ||
|
||||||
|
torrents[i].Status == transmission.StatusSeedPending {
|
||||||
|
buf.WriteString(formatter.FormatTorrentShort(&torrents[i]) + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if buf.Len() == 0 {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "No torrents seeding", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.sendMessage(update.Message.Chat.ID, buf.String(), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handlePaused(update tgbotapi.Update) {
|
||||||
|
torrents, err := b.client.GetTorrents()
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*paused:* "+err.Error(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
for i := range torrents {
|
||||||
|
if torrents[i].Status == transmission.StatusStopped {
|
||||||
|
buf.WriteString(formatter.FormatTorrentPaused(&torrents[i]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if buf.Len() == 0 {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "No paused torrents", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.sendMessage(update.Message.Chat.ID, buf.String(), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleChecking(update tgbotapi.Update) {
|
||||||
|
torrents, err := b.client.GetTorrents()
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*checking:* "+err.Error(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
for i := range torrents {
|
||||||
|
if torrents[i].Status == transmission.StatusChecking ||
|
||||||
|
torrents[i].Status == transmission.StatusCheckPending {
|
||||||
|
buf.WriteString(formatter.FormatTorrentChecking(&torrents[i]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if buf.Len() == 0 {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "No torrents verifying", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.sendMessage(update.Message.Chat.ID, buf.String(), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleActive(update tgbotapi.Update) {
|
||||||
|
torrents, err := b.client.GetTorrents()
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*active:* "+err.Error(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
for i := range torrents {
|
||||||
|
if torrents[i].RateDownload > 0 || torrents[i].RateUpload > 0 {
|
||||||
|
buf.WriteString(formatter.FormatTorrentDetailed(&torrents[i]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if buf.Len() == 0 {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "No active torrents", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msgID := b.sendMessage(update.Message.Chat.ID, buf.String(), true)
|
||||||
|
if !b.cfg.NoLive {
|
||||||
|
b.liveUpdateActive(update.Message.Chat.ID, msgID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleErrors(update tgbotapi.Update) {
|
||||||
|
torrents, err := b.client.GetTorrents()
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*errors:* "+err.Error(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
for i := range torrents {
|
||||||
|
if torrents[i].Error != 0 {
|
||||||
|
buf.WriteString(fmt.Sprintf("<%d> %s\n%s\n", torrents[i].ID, torrents[i].Name, torrents[i].ErrorString))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if buf.Len() == 0 {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "No errors", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.sendMessage(update.Message.Chat.ID, buf.String(), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleSort(update tgbotapi.Update, tokens []string) {
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, `*sort* takes one of:
|
||||||
|
(*id, name, age, size, progress, downspeed, upspeed, download, upload, ratio*)
|
||||||
|
optionally start with (*rev*) for reversed order
|
||||||
|
e.g. "*sort rev size*" to get biggest torrents first.`, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var reversed bool
|
||||||
|
if strings.ToLower(tokens[0]) == "rev" {
|
||||||
|
reversed = true
|
||||||
|
tokens = tokens[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
sortMap := map[string]struct {
|
||||||
|
normal transmission.SortType
|
||||||
|
rev transmission.SortType
|
||||||
|
}{
|
||||||
|
"id": {transmission.SortID, transmission.SortRevID},
|
||||||
|
"name": {transmission.SortName, transmission.SortRevName},
|
||||||
|
"age": {transmission.SortAge, transmission.SortRevAge},
|
||||||
|
"size": {transmission.SortSize, transmission.SortRevSize},
|
||||||
|
"progress": {transmission.SortProgress, transmission.SortRevProgress},
|
||||||
|
"downspeed": {transmission.SortDownSpeed, transmission.SortRevDownSpeed},
|
||||||
|
"upspeed": {transmission.SortUpSpeed, transmission.SortRevUpSpeed},
|
||||||
|
"download": {transmission.SortDownloaded, transmission.SortRevDownloaded},
|
||||||
|
"upload": {transmission.SortUploaded, transmission.SortRevUploaded},
|
||||||
|
"ratio": {transmission.SortRatio, transmission.SortRevRatio},
|
||||||
|
}
|
||||||
|
|
||||||
|
if sort, ok := sortMap[strings.ToLower(tokens[0])]; ok {
|
||||||
|
if reversed {
|
||||||
|
b.client.SetSort(sort.rev)
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*sort:* reversed "+tokens[0], false)
|
||||||
|
} else {
|
||||||
|
b.client.SetSort(sort.normal)
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*sort:* "+tokens[0], false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "unknown sorting method", false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleTrackers(update tgbotapi.Update) {
|
||||||
|
torrents, err := b.client.GetTorrents()
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*trackers:* "+err.Error(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
trackers := make(map[string]int)
|
||||||
|
for i := range torrents {
|
||||||
|
for _, tracker := range torrents[i].Trackers {
|
||||||
|
sm := trackerRegex.FindSubmatch([]byte(tracker.Announce))
|
||||||
|
if len(sm) > 1 {
|
||||||
|
currentTracker := string(sm[1])
|
||||||
|
trackers[currentTracker]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
for k, v := range trackers {
|
||||||
|
buf.WriteString(fmt.Sprintf("%d - %s\n", v, k))
|
||||||
|
}
|
||||||
|
|
||||||
|
if buf.Len() == 0 {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "No trackers!", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.sendMessage(update.Message.Chat.ID, buf.String(), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleDownloadDir(update tgbotapi.Update, tokens []string) {
|
||||||
|
if len(tokens) < 1 {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "Please, specify a path for downloaddir", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := transmission.NewSessionSetCommand()
|
||||||
|
cmd.SetDownloadDir(tokens[0])
|
||||||
|
|
||||||
|
out, err := b.client.ExecuteCommand(cmd)
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*downloaddir:* "+err.Error(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if out.Result != "success" {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*downloaddir:* "+out.Result, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*downloaddir:* downloaddir has been successfully changed to "+tokens[0], false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleAdd(update tgbotapi.Update, tokens []string) {
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*add:* needs at least one URL", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, url := range tokens {
|
||||||
|
cmd := transmission.NewAddCmdByURL(url)
|
||||||
|
torrent, err := b.client.ExecuteAddCommand(cmd)
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*add:* "+err.Error(), false)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if torrent.Name == "" {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*add:* error adding "+url, false)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.sendMessage(update.Message.Chat.ID, fmt.Sprintf("*Added:* <%d> %s", torrent.ID, torrent.Name), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleSearch(update tgbotapi.Update, tokens []string) {
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*search:* needs an argument", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := strings.Join(tokens, " ")
|
||||||
|
regx, err := regexp.Compile("(?i)" + query)
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*search:* "+err.Error(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
torrents, err := b.client.GetTorrents()
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*search:* "+err.Error(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
for i := range torrents {
|
||||||
|
if regx.MatchString(torrents[i].Name) {
|
||||||
|
buf.WriteString(formatter.FormatTorrentShort(&torrents[i]) + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if buf.Len() == 0 {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "No matches!", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.sendMessage(update.Message.Chat.ID, buf.String(), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleLatest(update tgbotapi.Update, tokens []string) {
|
||||||
|
n := 5
|
||||||
|
if len(tokens) > 0 {
|
||||||
|
if parsed, err := strconv.Atoi(tokens[0]); err == nil {
|
||||||
|
n = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
torrents, err := b.client.GetTorrents()
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*latest:* "+err.Error(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if n <= 0 || n > len(torrents) {
|
||||||
|
n = len(torrents)
|
||||||
|
}
|
||||||
|
|
||||||
|
torrents.SortAge(true)
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
for i := range torrents[:n] {
|
||||||
|
buf.WriteString(formatter.FormatTorrentShort(&torrents[i]) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if buf.Len() == 0 {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*latest:* No torrents", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.sendMessage(update.Message.Chat.ID, buf.String(), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleInfo(update tgbotapi.Update, tokens []string) {
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*info:* needs a torrent ID number", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range tokens {
|
||||||
|
torrentID, err := strconv.Atoi(id)
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, fmt.Sprintf("*info:* %s is not a number", id), false)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
torrent, err := b.client.GetTorrent(torrentID)
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, fmt.Sprintf("*info:* Can't find a torrent with an ID of %d", torrentID), false)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
trackers := formatter.ExtractTrackers(torrent, trackerRegex)
|
||||||
|
info := formatter.FormatTorrentInfo(torrent, trackers)
|
||||||
|
msgID := b.sendMessage(update.Message.Chat.ID, info, true)
|
||||||
|
|
||||||
|
if !b.cfg.NoLive {
|
||||||
|
b.liveUpdateInfo(update.Message.Chat.ID, msgID, torrentID, trackers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleStop(update tgbotapi.Update, tokens []string) {
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*stop:* needs an argument", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokens[0] == "all" {
|
||||||
|
if err := b.client.StopAll(); err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*stop:* error occurred while stopping some torrents", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "Stopped all torrents", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range tokens {
|
||||||
|
num, err := strconv.Atoi(id)
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, fmt.Sprintf("*stop:* %s is not a number", id), false)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
status, err := b.client.StopTorrent(num)
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*stop:* "+err.Error(), false)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
torrent, err := b.client.GetTorrent(num)
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, fmt.Sprintf("[fail] *stop:* No torrent with an ID of %d", num), false)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.sendMessage(update.Message.Chat.ID, fmt.Sprintf("[%s] *stop:* %s", status, torrent.Name), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleStart(update tgbotapi.Update, tokens []string) {
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*start:* needs an argument", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokens[0] == "all" {
|
||||||
|
if err := b.client.StartAll(); err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*start:* error occurred while starting some torrents", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "Started all torrents", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range tokens {
|
||||||
|
num, err := strconv.Atoi(id)
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, fmt.Sprintf("*start:* %s is not a number", id), false)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
status, err := b.client.StartTorrent(num)
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*start:* "+err.Error(), false)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
torrent, err := b.client.GetTorrent(num)
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, fmt.Sprintf("[fail] *start:* No torrent with an ID of %d", num), false)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.sendMessage(update.Message.Chat.ID, fmt.Sprintf("[%s] *start:* %s", status, torrent.Name), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleCheck(update tgbotapi.Update, tokens []string) {
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*check:* needs an argument", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokens[0] == "all" {
|
||||||
|
if err := b.client.VerifyAll(); err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*check:* error occurred while verifying some torrents", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "Verifying all torrents", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range tokens {
|
||||||
|
num, err := strconv.Atoi(id)
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, fmt.Sprintf("*check:* %s is not a number", id), false)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
status, err := b.client.VerifyTorrent(num)
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*check:* "+err.Error(), false)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
torrent, err := b.client.GetTorrent(num)
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, fmt.Sprintf("[fail] *check:* No torrent with an ID of %d", num), false)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.sendMessage(update.Message.Chat.ID, fmt.Sprintf("[%s] *check:* %s", status, torrent.Name), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleStats(update tgbotapi.Update) {
|
||||||
|
stats, err := b.client.GetStats()
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*stats:* "+err.Error(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf(`
|
||||||
|
Total: *%d*
|
||||||
|
Active: *%d*
|
||||||
|
Paused: *%d*
|
||||||
|
|
||||||
|
_Current Stats_
|
||||||
|
Downloaded: *%s*
|
||||||
|
Uploaded: *%s*
|
||||||
|
Running time: *%s*
|
||||||
|
|
||||||
|
_Accumulative Stats_
|
||||||
|
Sessions: *%d*
|
||||||
|
Downloaded: *%s*
|
||||||
|
Uploaded: *%s*
|
||||||
|
Total Running time: *%s*
|
||||||
|
`,
|
||||||
|
stats.TorrentCount,
|
||||||
|
stats.ActiveTorrentCount,
|
||||||
|
stats.PausedTorrentCount,
|
||||||
|
humanize.Bytes(stats.CurrentStats.DownloadedBytes),
|
||||||
|
humanize.Bytes(stats.CurrentStats.UploadedBytes),
|
||||||
|
stats.CurrentActiveTime(),
|
||||||
|
stats.CumulativeStats.SessionCount,
|
||||||
|
humanize.Bytes(stats.CumulativeStats.DownloadedBytes),
|
||||||
|
humanize.Bytes(stats.CumulativeStats.UploadedBytes),
|
||||||
|
stats.CumulativeActiveTime(),
|
||||||
|
)
|
||||||
|
|
||||||
|
b.sendMessage(update.Message.Chat.ID, msg, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleDownLimit(update tgbotapi.Update, tokens []string) {
|
||||||
|
b.handleSpeedLimit(update, tokens, transmission.DownloadLimitType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleUpLimit(update tgbotapi.Update, tokens []string) {
|
||||||
|
b.handleSpeedLimit(update, tokens, transmission.UploadLimitType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleSpeedLimit(update tgbotapi.Update, tokens []string, limitType transmission.SpeedLimitType) {
|
||||||
|
if len(tokens) < 1 {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "Please, specify the limit", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, err := strconv.ParseUint(tokens[0], 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "Please, specify the limit as number of kilobytes", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
speedLimitCmd := transmission.NewSpeedLimitCommand(limitType, uint(limit))
|
||||||
|
if speedLimitCmd == nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, fmt.Sprintf("*%s:* internal error", limitType), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := b.client.ExecuteCommand(speedLimitCmd)
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, fmt.Sprintf("*%s:* %v", limitType, err.Error()), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if out.Result != "success" {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, fmt.Sprintf("*%s:* %v", limitType, out.Result), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b.sendMessage(update.Message.Chat.ID, fmt.Sprintf("*%s:* limit has been successfully changed to %d KB/s", limitType, limit), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleSpeed(update tgbotapi.Update) {
|
||||||
|
stats, err := b.client.GetStats()
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*speed:* "+err.Error(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("↓ %s ↑ %s", humanize.Bytes(stats.DownloadSpeed), humanize.Bytes(stats.UploadSpeed))
|
||||||
|
msgID := b.sendMessage(update.Message.Chat.ID, msg, false)
|
||||||
|
|
||||||
|
if !b.cfg.NoLive {
|
||||||
|
b.liveUpdateSpeed(update.Message.Chat.ID, msgID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleCount(update tgbotapi.Update) {
|
||||||
|
torrents, err := b.client.GetTorrents()
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*count:* "+err.Error(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var downloading, seeding, stopped, checking, downloadingQ, seedingQ, checkingQ int
|
||||||
|
for i := range torrents {
|
||||||
|
switch torrents[i].Status {
|
||||||
|
case transmission.StatusDownloading:
|
||||||
|
downloading++
|
||||||
|
case transmission.StatusSeeding:
|
||||||
|
seeding++
|
||||||
|
case transmission.StatusStopped:
|
||||||
|
stopped++
|
||||||
|
case transmission.StatusChecking:
|
||||||
|
checking++
|
||||||
|
case transmission.StatusDownloadPending:
|
||||||
|
downloadingQ++
|
||||||
|
case transmission.StatusSeedPending:
|
||||||
|
seedingQ++
|
||||||
|
case transmission.StatusCheckPending:
|
||||||
|
checkingQ++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("Downloading: %d\nSeeding: %d\nPaused: %d\nVerifying: %d\n\n- Waiting to -\nDownload: %d\nSeed: %d\nVerify: %d\n\nTotal: %d",
|
||||||
|
downloading, seeding, stopped, checking, downloadingQ, seedingQ, checkingQ, len(torrents))
|
||||||
|
|
||||||
|
b.sendMessage(update.Message.Chat.ID, msg, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleDel(update tgbotapi.Update, tokens []string) {
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*del:* needs an ID", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range tokens {
|
||||||
|
num, err := strconv.Atoi(id)
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, fmt.Sprintf("*del:* %s is not an ID", id), false)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name, err := b.client.DeleteTorrent(num, false)
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*del:* "+err.Error(), false)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*Deleted:* "+name, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleDelData(update tgbotapi.Update, tokens []string) {
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*deldata:* needs an ID", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range tokens {
|
||||||
|
num, err := strconv.Atoi(id)
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, fmt.Sprintf("*deldata:* %s is not an ID", id), false)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name, err := b.client.DeleteTorrent(num, true)
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*deldata:* "+err.Error(), false)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "Deleted with data: "+name, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleVersion(update tgbotapi.Update) {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, fmt.Sprintf("Transmission *%s*\nTransmission-telegram *%s*", b.client.Version(), config.Version), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleReceiveTorrent(update tgbotapi.Update) {
|
||||||
|
if update.Message.Document == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fconfig := tgbotapi.FileConfig{
|
||||||
|
FileID: update.Message.Document.FileID,
|
||||||
|
}
|
||||||
|
file, err := b.api.GetFile(fconfig)
|
||||||
|
if err != nil {
|
||||||
|
b.sendMessage(update.Message.Chat.ID, "*receiver:* "+err.Error(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b.handleAdd(update, []string{file.Link(b.cfg.BotToken)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live update helpers
|
||||||
|
func (b *Bot) liveUpdateTorrents(chatID int64, msgID int, filter func(transmission.Torrents) transmission.Torrents, formatter func(*transmission.Torrent) string) {
|
||||||
|
for i := 0; i < b.cfg.Duration; i++ {
|
||||||
|
time.Sleep(b.cfg.Interval)
|
||||||
|
torrents, err := b.client.GetTorrents()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(torrents) < 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := filter(torrents)
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
for i := range filtered {
|
||||||
|
buf.WriteString(formatter(&filtered[i]))
|
||||||
|
}
|
||||||
|
|
||||||
|
editConf := tgbotapi.NewEditMessageText(chatID, msgID, buf.String())
|
||||||
|
editConf.ParseMode = tgbotapi.ModeMarkdown
|
||||||
|
b.api.Send(editConf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) liveUpdateActive(chatID int64, msgID int) {
|
||||||
|
for i := 0; i < b.cfg.Duration; i++ {
|
||||||
|
time.Sleep(b.cfg.Interval)
|
||||||
|
torrents, err := b.client.GetTorrents()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
for i := range torrents {
|
||||||
|
if torrents[i].RateDownload > 0 || torrents[i].RateUpload > 0 {
|
||||||
|
buf.WriteString(formatter.FormatTorrentDetailed(&torrents[i]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
editConf := tgbotapi.NewEditMessageText(chatID, msgID, buf.String())
|
||||||
|
editConf.ParseMode = tgbotapi.ModeMarkdown
|
||||||
|
b.api.Send(editConf)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(b.cfg.Interval)
|
||||||
|
torrents, _ := b.client.GetTorrents()
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
for i := range torrents {
|
||||||
|
if torrents[i].RateDownload > 0 || torrents[i].RateUpload > 0 {
|
||||||
|
buf.WriteString(formatter.FormatTorrentActiveStopped(&torrents[i]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
editConf := tgbotapi.NewEditMessageText(chatID, msgID, buf.String())
|
||||||
|
editConf.ParseMode = tgbotapi.ModeMarkdown
|
||||||
|
b.api.Send(editConf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) liveUpdateInfo(chatID int64, msgID int, torrentID int, trackers string) {
|
||||||
|
for i := 0; i < b.cfg.Duration; i++ {
|
||||||
|
time.Sleep(b.cfg.Interval)
|
||||||
|
torrent, err := b.client.GetTorrent(torrentID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
info := formatter.FormatTorrentInfo(torrent, trackers)
|
||||||
|
editConf := tgbotapi.NewEditMessageText(chatID, msgID, info)
|
||||||
|
editConf.ParseMode = tgbotapi.ModeMarkdown
|
||||||
|
b.api.Send(editConf)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(b.cfg.Interval)
|
||||||
|
torrent, _ := b.client.GetTorrent(torrentID)
|
||||||
|
info := formatter.FormatTorrentInfoStopped(torrent, trackers)
|
||||||
|
editConf := tgbotapi.NewEditMessageText(chatID, msgID, info)
|
||||||
|
editConf.ParseMode = tgbotapi.ModeMarkdown
|
||||||
|
b.api.Send(editConf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) liveUpdateSpeed(chatID int64, msgID int) {
|
||||||
|
for i := 0; i < b.cfg.Duration; i++ {
|
||||||
|
time.Sleep(b.cfg.Interval)
|
||||||
|
stats, err := b.client.GetStats()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("↓ %s ↑ %s", humanize.Bytes(stats.DownloadSpeed), humanize.Bytes(stats.UploadSpeed))
|
||||||
|
editConf := tgbotapi.NewEditMessageText(chatID, msgID, msg)
|
||||||
|
b.api.Send(editConf)
|
||||||
|
time.Sleep(b.cfg.Interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(b.cfg.Interval)
|
||||||
|
editConf := tgbotapi.NewEditMessageText(chatID, msgID, "↓ - B ↑ - B")
|
||||||
|
b.api.Send(editConf)
|
||||||
|
}
|
||||||
|
|
||||||
94
internal/bot/helpers.go
Normal file
94
internal/bot/helpers.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/pyed/transmission"
|
||||||
|
tgbotapi "gopkg.in/telegram-bot-api.v4"
|
||||||
|
"transmission-telegram/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sendMessage sends a message to Telegram, splitting if necessary
|
||||||
|
func sendMessage(bot *tgbotapi.BotAPI, chatID int64, text string, markdown bool) int {
|
||||||
|
// Set typing action
|
||||||
|
action := tgbotapi.NewChatAction(chatID, tgbotapi.ChatTyping)
|
||||||
|
bot.Send(action)
|
||||||
|
|
||||||
|
// Check the rune count, telegram is limited to 4096 chars per message
|
||||||
|
msgRuneCount := utf8.RuneCountInString(text)
|
||||||
|
|
||||||
|
var lastMsgID int
|
||||||
|
|
||||||
|
// Split message if too long
|
||||||
|
for msgRuneCount > config.TelegramMaxMessageLength {
|
||||||
|
stop := config.TelegramMaxMessageLength - 1
|
||||||
|
|
||||||
|
// Find the last newline before the limit
|
||||||
|
for stop > 0 && text[stop] != '\n' {
|
||||||
|
stop--
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no newline found, just cut at the limit
|
||||||
|
if stop == 0 {
|
||||||
|
stop = config.TelegramMaxMessageLength - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
chunk := text[:stop]
|
||||||
|
text = text[stop:]
|
||||||
|
msgRuneCount = utf8.RuneCountInString(text)
|
||||||
|
|
||||||
|
msg := tgbotapi.NewMessage(chatID, chunk)
|
||||||
|
msg.DisableWebPagePreview = true
|
||||||
|
if markdown {
|
||||||
|
msg.ParseMode = tgbotapi.ModeMarkdown
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp, err := bot.Send(msg); err == nil {
|
||||||
|
lastMsgID = resp.MessageID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send remaining text
|
||||||
|
if len(text) > 0 {
|
||||||
|
msg := tgbotapi.NewMessage(chatID, text)
|
||||||
|
msg.DisableWebPagePreview = true
|
||||||
|
if markdown {
|
||||||
|
msg.ParseMode = tgbotapi.ModeMarkdown
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp, err := bot.Send(msg); err == nil {
|
||||||
|
lastMsgID = resp.MessageID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastMsgID
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTorrentIDs parses torrent IDs from tokens
|
||||||
|
func parseTorrentIDs(tokens []string) ([]int, []string) {
|
||||||
|
var ids []int
|
||||||
|
var errors []string
|
||||||
|
|
||||||
|
for _, token := range tokens {
|
||||||
|
if id, err := strconv.Atoi(token); err == nil {
|
||||||
|
ids = append(ids, id)
|
||||||
|
} else {
|
||||||
|
errors = append(errors, fmt.Sprintf("%s is not a number", token))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids, errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatTorrentList formats a list of torrents as a simple list
|
||||||
|
func formatTorrentList(torrents []*transmission.Torrent) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
for _, torrent := range torrents {
|
||||||
|
buf.WriteString(fmt.Sprintf("<%d> %s\n", torrent.ID, torrent.Name))
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
126
internal/config/config.go
Normal file
126
internal/config/config.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds all application configuration
|
||||||
|
type Config struct {
|
||||||
|
BotToken string
|
||||||
|
Masters []string
|
||||||
|
RPCURL string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
LogFile string
|
||||||
|
TransLogFile string
|
||||||
|
NoLive bool
|
||||||
|
Interval time.Duration
|
||||||
|
Duration int
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadConfig loads configuration from flags and environment variables
|
||||||
|
func LoadConfig() (*Config, error) {
|
||||||
|
cfg := &Config{
|
||||||
|
Interval: DefaultUpdateInterval,
|
||||||
|
Duration: DefaultUpdateDuration,
|
||||||
|
}
|
||||||
|
|
||||||
|
var masters masterSlice
|
||||||
|
|
||||||
|
flag.StringVar(&cfg.BotToken, "token", "", "Telegram bot token, Can be passed via environment variable 'TT_BOTT'")
|
||||||
|
flag.Var(&masters, "master", "Your telegram handler, So the bot will only respond to you. Can specify more than one")
|
||||||
|
flag.StringVar(&cfg.RPCURL, "url", DefaultRPCURL, "Transmission RPC URL")
|
||||||
|
flag.StringVar(&cfg.Username, "username", "", "Transmission username")
|
||||||
|
flag.StringVar(&cfg.Password, "password", "", "Transmission password")
|
||||||
|
flag.StringVar(&cfg.LogFile, "logfile", "", "Send logs to a file")
|
||||||
|
flag.StringVar(&cfg.TransLogFile, "transmission-logfile", "", "Open transmission logfile to monitor torrents completion")
|
||||||
|
flag.BoolVar(&cfg.NoLive, "no-live", false, "Don't edit and update info after sending")
|
||||||
|
|
||||||
|
flag.Usage = func() {
|
||||||
|
fmt.Fprint(os.Stderr, "Usage: transmission-telegram <-token=TOKEN> <-master=@tuser> [-master=@yuser2] [-url=http://] [-username=user] [-password=pass]\n\n")
|
||||||
|
flag.PrintDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// Check environment variable for bot token
|
||||||
|
if cfg.BotToken == "" {
|
||||||
|
if token := os.Getenv("TT_BOTT"); len(token) > 1 {
|
||||||
|
cfg.BotToken = token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check environment variable for Transmission auth
|
||||||
|
if cfg.Username == "" {
|
||||||
|
if values := strings.Split(os.Getenv("TR_AUTH"), ":"); len(values) > 1 {
|
||||||
|
cfg.Username, cfg.Password = values[0], values[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert masters slice
|
||||||
|
cfg.Masters = []string(masters)
|
||||||
|
for i := range cfg.Masters {
|
||||||
|
cfg.Masters[i] = strings.ReplaceAll(cfg.Masters[i], "@", "")
|
||||||
|
cfg.Masters[i] = strings.ToLower(cfg.Masters[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate configuration
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks if configuration is valid
|
||||||
|
func (c *Config) Validate() error {
|
||||||
|
if c.BotToken == "" {
|
||||||
|
return fmt.Errorf("bot token is required (use -token flag or TT_BOTT environment variable)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.Masters) < 1 {
|
||||||
|
return fmt.Errorf("at least one master is required (use -master flag)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.RPCURL != "" {
|
||||||
|
if _, err := url.Parse(c.RPCURL); err != nil {
|
||||||
|
return fmt.Errorf("invalid RPC URL: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsMaster checks if username is in masters list
|
||||||
|
func (c *Config) IsMaster(username string) bool {
|
||||||
|
username = strings.ToLower(username)
|
||||||
|
for _, master := range c.Masters {
|
||||||
|
if master == username {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintUsage prints usage information
|
||||||
|
func PrintUsage() {
|
||||||
|
flag.Usage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// masterSlice is a custom type for flag parsing
|
||||||
|
type masterSlice []string
|
||||||
|
|
||||||
|
func (masters *masterSlice) String() string {
|
||||||
|
return fmt.Sprintf("%s", *masters)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (masters *masterSlice) Set(master string) error {
|
||||||
|
*masters = append(*masters, strings.ToLower(master))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
23
internal/config/constants.go
Normal file
23
internal/config/constants.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Version is the application version
|
||||||
|
Version = "v1.4.1"
|
||||||
|
|
||||||
|
// Telegram API limits
|
||||||
|
TelegramMaxMessageLength = 4096
|
||||||
|
TelegramUpdateTimeout = 60
|
||||||
|
|
||||||
|
// Live update settings
|
||||||
|
DefaultUpdateInterval = 5 * time.Second
|
||||||
|
DefaultUpdateDuration = 10
|
||||||
|
|
||||||
|
// Monitoring settings
|
||||||
|
DefaultPollInterval = 10 * time.Second
|
||||||
|
|
||||||
|
// Default Transmission RPC URL
|
||||||
|
DefaultRPCURL = "http://localhost:9091/transmission/rpc"
|
||||||
|
)
|
||||||
|
|
||||||
103
internal/formatter/formatter.go
Normal file
103
internal/formatter/formatter.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package formatter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
"github.com/pyed/transmission"
|
||||||
|
"transmission-telegram/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormatTorrentShort formats a torrent in short format: <ID> Name
|
||||||
|
func FormatTorrentShort(torrent *transmission.Torrent) string {
|
||||||
|
return fmt.Sprintf("<%d> %s", torrent.ID, torrent.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatTorrentDetailed formats a torrent with detailed information
|
||||||
|
func FormatTorrentDetailed(torrent *transmission.Torrent) string {
|
||||||
|
torrentName := utils.EscapeMarkdown(torrent.Name)
|
||||||
|
return fmt.Sprintf("`<%d>` *%s*\n%s *%s* of *%s* (*%.1f%%*) ↓ *%s* ↑ *%s* R: *%s*\n\n",
|
||||||
|
torrent.ID, torrentName, torrent.TorrentStatus(),
|
||||||
|
humanize.Bytes(torrent.Have()),
|
||||||
|
humanize.Bytes(torrent.SizeWhenDone),
|
||||||
|
torrent.PercentDone*100,
|
||||||
|
humanize.Bytes(torrent.RateDownload),
|
||||||
|
humanize.Bytes(torrent.RateUpload),
|
||||||
|
torrent.Ratio())
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatTorrentPaused formats a paused torrent
|
||||||
|
func FormatTorrentPaused(torrent *transmission.Torrent) string {
|
||||||
|
return fmt.Sprintf("<%d> %s\n%s (%.1f%%) DL: %s UL: %s R: %s\n\n",
|
||||||
|
torrent.ID, torrent.Name, torrent.TorrentStatus(),
|
||||||
|
torrent.PercentDone*100,
|
||||||
|
humanize.Bytes(torrent.DownloadedEver),
|
||||||
|
humanize.Bytes(torrent.UploadedEver),
|
||||||
|
torrent.Ratio())
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatTorrentChecking formats a checking/verifying torrent
|
||||||
|
func FormatTorrentChecking(torrent *transmission.Torrent) string {
|
||||||
|
return fmt.Sprintf("<%d> %s\n%s (%.1f%%)\n\n",
|
||||||
|
torrent.ID, torrent.Name, torrent.TorrentStatus(),
|
||||||
|
torrent.PercentDone*100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatTorrentInfo formats detailed torrent information
|
||||||
|
func FormatTorrentInfo(torrent *transmission.Torrent, trackers string) string {
|
||||||
|
torrentName := utils.EscapeMarkdown(torrent.Name)
|
||||||
|
return fmt.Sprintf("`<%d>` *%s*\n%s *%s* of *%s* (*%.1f%%*) ↓ *%s* ↑ *%s* R: *%s*\nDL: *%s* UP: *%s*\nAdded: *%s*, ETA: *%s*\nTrackers: `%s`",
|
||||||
|
torrent.ID, torrentName, torrent.TorrentStatus(),
|
||||||
|
humanize.Bytes(torrent.Have()),
|
||||||
|
humanize.Bytes(torrent.SizeWhenDone),
|
||||||
|
torrent.PercentDone*100,
|
||||||
|
humanize.Bytes(torrent.RateDownload),
|
||||||
|
humanize.Bytes(torrent.RateUpload),
|
||||||
|
torrent.Ratio(),
|
||||||
|
humanize.Bytes(torrent.DownloadedEver),
|
||||||
|
humanize.Bytes(torrent.UploadedEver),
|
||||||
|
time.Unix(torrent.AddedDate, 0).Format(time.Stamp),
|
||||||
|
torrent.ETA(),
|
||||||
|
trackers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatTorrentInfoStopped formats torrent info when live updates are stopped
|
||||||
|
func FormatTorrentInfoStopped(torrent *transmission.Torrent, trackers string) string {
|
||||||
|
torrentName := utils.EscapeMarkdown(torrent.Name)
|
||||||
|
return fmt.Sprintf("`<%d>` *%s*\n%s *%s* of *%s* (*%.1f%%*) ↓ *- B* ↑ *- B* R: *%s*\nDL: *%s* UP: *%s*\nAdded: *%s*, ETA: *-*\nTrackers: `%s`",
|
||||||
|
torrent.ID, torrentName, torrent.TorrentStatus(),
|
||||||
|
humanize.Bytes(torrent.Have()),
|
||||||
|
humanize.Bytes(torrent.SizeWhenDone),
|
||||||
|
torrent.PercentDone*100,
|
||||||
|
torrent.Ratio(),
|
||||||
|
humanize.Bytes(torrent.DownloadedEver),
|
||||||
|
humanize.Bytes(torrent.UploadedEver),
|
||||||
|
time.Unix(torrent.AddedDate, 0).Format(time.Stamp),
|
||||||
|
trackers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatTorrentActiveStopped formats active torrent when live updates are stopped
|
||||||
|
func FormatTorrentActiveStopped(torrent *transmission.Torrent) string {
|
||||||
|
torrentName := utils.EscapeMarkdown(torrent.Name)
|
||||||
|
return fmt.Sprintf("`<%d>` *%s*\n%s *%s* of *%s* (*%.1f%%*) ↓ *-* ↑ *-* R: *%s*\n\n",
|
||||||
|
torrent.ID, torrentName, torrent.TorrentStatus(),
|
||||||
|
humanize.Bytes(torrent.Have()),
|
||||||
|
humanize.Bytes(torrent.SizeWhenDone),
|
||||||
|
torrent.PercentDone*100,
|
||||||
|
torrent.Ratio())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractTrackers extracts tracker domains from torrent trackers
|
||||||
|
func ExtractTrackers(torrent *transmission.Torrent, trackerRegex *regexp.Regexp) string {
|
||||||
|
var trackers string
|
||||||
|
for _, tracker := range torrent.Trackers {
|
||||||
|
sm := trackerRegex.FindSubmatch([]byte(tracker.Announce))
|
||||||
|
if len(sm) > 1 {
|
||||||
|
trackers += string(sm[1]) + " "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return trackers
|
||||||
|
}
|
||||||
|
|
||||||
104
internal/logger/logger.go
Normal file
104
internal/logger/logger.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Logger wraps standard logger with security features
|
||||||
|
type Logger struct {
|
||||||
|
*log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new logger
|
||||||
|
func New(output *os.File) *Logger {
|
||||||
|
return &Logger{
|
||||||
|
Logger: log.New(output, "", log.LstdFlags),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStdout creates a logger that writes to stdout
|
||||||
|
func NewStdout() *Logger {
|
||||||
|
return &Logger{
|
||||||
|
Logger: log.New(os.Stdout, "", log.LstdFlags),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaskSecret masks sensitive data, showing only first 4 and last 4 characters
|
||||||
|
func MaskSecret(secret string) string {
|
||||||
|
if len(secret) <= 8 {
|
||||||
|
return "****"
|
||||||
|
}
|
||||||
|
return secret[:4] + "****" + secret[len(secret)-4:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// SafePrintf logs a message, masking any secrets found in the format string or args
|
||||||
|
func (l *Logger) SafePrintf(format string, v ...interface{}) {
|
||||||
|
// Mask secrets in format string
|
||||||
|
safeFormat := maskSecretsInString(format)
|
||||||
|
|
||||||
|
// Mask secrets in arguments
|
||||||
|
safeArgs := make([]interface{}, len(v))
|
||||||
|
for i, arg := range v {
|
||||||
|
if str, ok := arg.(string); ok {
|
||||||
|
safeArgs[i] = maskSecretsInString(str)
|
||||||
|
} else {
|
||||||
|
safeArgs[i] = arg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Printf(safeFormat, safeArgs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// maskSecretsInString masks common secret patterns in a string
|
||||||
|
func maskSecretsInString(s string) string {
|
||||||
|
// This is a simple implementation - in production you might want more sophisticated detection
|
||||||
|
// For now, we'll mask common patterns like tokens and passwords
|
||||||
|
lower := strings.ToLower(s)
|
||||||
|
|
||||||
|
// Mask if contains "token=", "pass=", "password=" patterns
|
||||||
|
if strings.Contains(lower, "token=") {
|
||||||
|
parts := strings.Split(s, "token=")
|
||||||
|
if len(parts) > 1 {
|
||||||
|
rest := parts[1]
|
||||||
|
// Find where the token ends (space, newline, or end of string)
|
||||||
|
end := len(rest)
|
||||||
|
for i, r := range rest {
|
||||||
|
if r == ' ' || r == '\n' || r == '\t' {
|
||||||
|
end = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if end > 0 {
|
||||||
|
token := rest[:end]
|
||||||
|
s = strings.Replace(s, "token="+token, "token="+MaskSecret(token), 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(lower, "pass=") || strings.Contains(lower, "password=") {
|
||||||
|
key := "pass="
|
||||||
|
if strings.Contains(lower, "password=") {
|
||||||
|
key = "password="
|
||||||
|
}
|
||||||
|
parts := strings.Split(s, key)
|
||||||
|
if len(parts) > 1 {
|
||||||
|
rest := parts[1]
|
||||||
|
end := len(rest)
|
||||||
|
for i, r := range rest {
|
||||||
|
if r == ' ' || r == '\n' || r == '\t' {
|
||||||
|
end = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if end > 0 {
|
||||||
|
pass := rest[:end]
|
||||||
|
s = strings.Replace(s, key+pass, key+MaskSecret(pass), 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
125
internal/monitor/monitor.go
Normal file
125
internal/monitor/monitor.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package monitor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pyed/transmission"
|
||||||
|
"transmission-telegram/internal/logger"
|
||||||
|
transmissionClient "transmission-telegram/internal/transmission"
|
||||||
|
)
|
||||||
|
|
||||||
|
// torrentState stores the state of a torrent for completion detection
|
||||||
|
type torrentState struct {
|
||||||
|
Status int
|
||||||
|
PercentDone float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor monitors torrent completion via RPC API polling
|
||||||
|
type Monitor struct {
|
||||||
|
client transmissionClient.Client
|
||||||
|
logger *logger.Logger
|
||||||
|
states map[int]torrentState
|
||||||
|
statesMutex sync.RWMutex
|
||||||
|
chatID *int64
|
||||||
|
chatMutex sync.RWMutex
|
||||||
|
onComplete func(torrent *transmission.Torrent)
|
||||||
|
interval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMonitor creates a new torrent completion monitor
|
||||||
|
func NewMonitor(client transmissionClient.Client, log *logger.Logger, interval time.Duration) *Monitor {
|
||||||
|
return &Monitor{
|
||||||
|
client: client,
|
||||||
|
logger: log,
|
||||||
|
states: make(map[int]torrentState),
|
||||||
|
interval: interval,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetChatID sets the chat ID for notifications
|
||||||
|
func (m *Monitor) SetChatID(chatID int64) {
|
||||||
|
m.chatMutex.Lock()
|
||||||
|
m.chatID = &chatID
|
||||||
|
m.chatMutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOnComplete sets the callback function for completion notifications
|
||||||
|
func (m *Monitor) SetOnComplete(callback func(torrent *transmission.Torrent)) {
|
||||||
|
m.onComplete = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the monitoring loop
|
||||||
|
func (m *Monitor) Start(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(m.interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
m.logger.Printf("[INFO] Starting torrent completion monitoring via RPC polling")
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
m.logger.Printf("[INFO] Stopping torrent completion monitoring")
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
m.checkCompletions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkCompletions checks for completed torrents
|
||||||
|
func (m *Monitor) checkCompletions() {
|
||||||
|
m.chatMutex.RLock()
|
||||||
|
if m.chatID == nil || *m.chatID == 0 {
|
||||||
|
m.chatMutex.RUnlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.chatMutex.RUnlock()
|
||||||
|
|
||||||
|
torrents, err := m.client.GetTorrents()
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Printf("[ERROR] Failed to get torrents for monitoring: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.statesMutex.Lock()
|
||||||
|
defer m.statesMutex.Unlock()
|
||||||
|
|
||||||
|
for _, torrent := range torrents {
|
||||||
|
prevState, exists := m.states[torrent.ID]
|
||||||
|
|
||||||
|
// Detect completion: transition from Downloading to Seeding with 100% done
|
||||||
|
completed := false
|
||||||
|
if exists && prevState.Status == transmission.StatusDownloading {
|
||||||
|
// Case 1: Status changed from Downloading to Seeding (most common)
|
||||||
|
if torrent.Status == transmission.StatusSeeding && torrent.PercentDone >= 1.0 {
|
||||||
|
completed = true
|
||||||
|
}
|
||||||
|
// Case 2: Torrent reached 100% while still in Downloading status
|
||||||
|
if torrent.Status == transmission.StatusDownloading &&
|
||||||
|
prevState.PercentDone < 1.0 &&
|
||||||
|
torrent.PercentDone >= 1.0 {
|
||||||
|
completed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if completed && m.onComplete != nil {
|
||||||
|
m.onComplete(torrent)
|
||||||
|
m.logger.Printf("[INFO] Torrent completed: %s (ID: %d)", torrent.Name, torrent.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update state - only track downloading torrents to save memory
|
||||||
|
if torrent.Status == transmission.StatusDownloading ||
|
||||||
|
torrent.Status == transmission.StatusDownloadPending {
|
||||||
|
m.states[torrent.ID] = torrentState{
|
||||||
|
Status: torrent.Status,
|
||||||
|
PercentDone: torrent.PercentDone,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Remove completed or stopped torrents from tracking
|
||||||
|
delete(m.states, torrent.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
194
internal/transmission/client.go
Normal file
194
internal/transmission/client.go
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
package transmission
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pyed/transmission"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CachedClient wraps transmission client with caching
|
||||||
|
type CachedClient struct {
|
||||||
|
client *transmission.TransmissionClient
|
||||||
|
cache transmission.Torrents
|
||||||
|
cacheTime time.Time
|
||||||
|
cacheTTL time.Duration
|
||||||
|
cacheMutex sync.RWMutex
|
||||||
|
statsCache *transmission.Stats
|
||||||
|
statsTime time.Time
|
||||||
|
statsMutex sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCachedClient creates a new cached transmission client
|
||||||
|
func NewCachedClient(client *transmission.TransmissionClient, cacheTTL time.Duration) *CachedClient {
|
||||||
|
return &CachedClient{
|
||||||
|
client: client,
|
||||||
|
cacheTTL: cacheTTL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTorrents returns cached torrents if available, otherwise fetches from API
|
||||||
|
func (c *CachedClient) GetTorrents() (transmission.Torrents, error) {
|
||||||
|
c.cacheMutex.RLock()
|
||||||
|
if time.Since(c.cacheTime) < c.cacheTTL && c.cache != nil {
|
||||||
|
torrents := c.cache
|
||||||
|
c.cacheMutex.RUnlock()
|
||||||
|
return torrents, nil
|
||||||
|
}
|
||||||
|
c.cacheMutex.RUnlock()
|
||||||
|
|
||||||
|
c.cacheMutex.Lock()
|
||||||
|
defer c.cacheMutex.Unlock()
|
||||||
|
|
||||||
|
// Double check after acquiring write lock
|
||||||
|
if time.Since(c.cacheTime) < c.cacheTTL && c.cache != nil {
|
||||||
|
return c.cache, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
torrents, err := c.client.GetTorrents()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.cache = torrents
|
||||||
|
c.cacheTime = time.Now()
|
||||||
|
return torrents, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTorrent gets a single torrent (not cached)
|
||||||
|
func (c *CachedClient) GetTorrent(id int) (*transmission.Torrent, error) {
|
||||||
|
return c.client.GetTorrent(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats returns cached stats if available, otherwise fetches from API
|
||||||
|
func (c *CachedClient) GetStats() (*transmission.Stats, error) {
|
||||||
|
c.statsMutex.RLock()
|
||||||
|
if time.Since(c.statsTime) < c.cacheTTL && c.statsCache != nil {
|
||||||
|
stats := c.statsCache
|
||||||
|
c.statsMutex.RUnlock()
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
c.statsMutex.RUnlock()
|
||||||
|
|
||||||
|
c.statsMutex.Lock()
|
||||||
|
defer c.statsMutex.Unlock()
|
||||||
|
|
||||||
|
// Double check after acquiring write lock
|
||||||
|
if time.Since(c.statsTime) < c.cacheTTL && c.statsCache != nil {
|
||||||
|
return c.statsCache, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := c.client.GetStats()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.statsCache = stats
|
||||||
|
c.statsTime = time.Now()
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateCache clears the cache
|
||||||
|
func (c *CachedClient) InvalidateCache() {
|
||||||
|
c.cacheMutex.Lock()
|
||||||
|
c.cache = nil
|
||||||
|
c.cacheTime = time.Time{}
|
||||||
|
c.cacheMutex.Unlock()
|
||||||
|
|
||||||
|
c.statsMutex.Lock()
|
||||||
|
c.statsCache = nil
|
||||||
|
c.statsTime = time.Time{}
|
||||||
|
c.statsMutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopAll stops all torrents and invalidates cache
|
||||||
|
func (c *CachedClient) StopAll() error {
|
||||||
|
err := c.client.StopAll()
|
||||||
|
if err == nil {
|
||||||
|
c.InvalidateCache()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartAll starts all torrents and invalidates cache
|
||||||
|
func (c *CachedClient) StartAll() error {
|
||||||
|
err := c.client.StartAll()
|
||||||
|
if err == nil {
|
||||||
|
c.InvalidateCache()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyAll verifies all torrents and invalidates cache
|
||||||
|
func (c *CachedClient) VerifyAll() error {
|
||||||
|
err := c.client.VerifyAll()
|
||||||
|
if err == nil {
|
||||||
|
c.InvalidateCache()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopTorrent stops a torrent and invalidates cache
|
||||||
|
func (c *CachedClient) StopTorrent(id int) (string, error) {
|
||||||
|
status, err := c.client.StopTorrent(id)
|
||||||
|
if err == nil {
|
||||||
|
c.InvalidateCache()
|
||||||
|
}
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartTorrent starts a torrent and invalidates cache
|
||||||
|
func (c *CachedClient) StartTorrent(id int) (string, error) {
|
||||||
|
status, err := c.client.StartTorrent(id)
|
||||||
|
if err == nil {
|
||||||
|
c.InvalidateCache()
|
||||||
|
}
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyTorrent verifies a torrent and invalidates cache
|
||||||
|
func (c *CachedClient) VerifyTorrent(id int) (string, error) {
|
||||||
|
status, err := c.client.VerifyTorrent(id)
|
||||||
|
if err == nil {
|
||||||
|
c.InvalidateCache()
|
||||||
|
}
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTorrent deletes a torrent and invalidates cache
|
||||||
|
func (c *CachedClient) DeleteTorrent(id int, deleteData bool) (string, error) {
|
||||||
|
name, err := c.client.DeleteTorrent(id, deleteData)
|
||||||
|
if err == nil {
|
||||||
|
c.InvalidateCache()
|
||||||
|
}
|
||||||
|
return name, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteCommand executes a command and invalidates cache
|
||||||
|
func (c *CachedClient) ExecuteCommand(cmd transmission.Command) (*transmission.CommandResult, error) {
|
||||||
|
result, err := c.client.ExecuteCommand(cmd)
|
||||||
|
if err == nil {
|
||||||
|
c.InvalidateCache()
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteAddCommand executes an add command and invalidates cache
|
||||||
|
func (c *CachedClient) ExecuteAddCommand(cmd transmission.AddCommand) (*transmission.Torrent, error) {
|
||||||
|
torrent, err := c.client.ExecuteAddCommand(cmd)
|
||||||
|
if err == nil {
|
||||||
|
c.InvalidateCache()
|
||||||
|
}
|
||||||
|
return torrent, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSort sets sort type
|
||||||
|
func (c *CachedClient) SetSort(sort transmission.SortType) {
|
||||||
|
c.client.SetSort(sort)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version returns transmission version
|
||||||
|
func (c *CachedClient) Version() string {
|
||||||
|
return c.client.Version()
|
||||||
|
}
|
||||||
|
|
||||||
24
internal/transmission/interfaces.go
Normal file
24
internal/transmission/interfaces.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package transmission
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pyed/transmission"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client interface for Transmission operations
|
||||||
|
type Client interface {
|
||||||
|
GetTorrents() (transmission.Torrents, error)
|
||||||
|
GetTorrent(id int) (*transmission.Torrent, error)
|
||||||
|
GetStats() (*transmission.Stats, error)
|
||||||
|
StopAll() error
|
||||||
|
StartAll() error
|
||||||
|
VerifyAll() error
|
||||||
|
StopTorrent(id int) (string, error)
|
||||||
|
StartTorrent(id int) (string, error)
|
||||||
|
VerifyTorrent(id int) (string, error)
|
||||||
|
DeleteTorrent(id int, deleteData bool) (string, error)
|
||||||
|
ExecuteCommand(cmd transmission.Command) (*transmission.CommandResult, error)
|
||||||
|
ExecuteAddCommand(cmd transmission.AddCommand) (*transmission.Torrent, error)
|
||||||
|
SetSort(sort transmission.SortType)
|
||||||
|
Version() string
|
||||||
|
}
|
||||||
|
|
||||||
18
pkg/utils/markdown.go
Normal file
18
pkg/utils/markdown.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// MarkdownReplacer replaces markdown special characters to avoid parsing issues
|
||||||
|
var MarkdownReplacer = strings.NewReplacer(
|
||||||
|
"*", "•",
|
||||||
|
"[", "(",
|
||||||
|
"]", ")",
|
||||||
|
"_", "-",
|
||||||
|
"`", "'",
|
||||||
|
)
|
||||||
|
|
||||||
|
// EscapeMarkdown escapes markdown special characters in text
|
||||||
|
func EscapeMarkdown(text string) string {
|
||||||
|
return MarkdownReplacer.Replace(text)
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user