Добавлен основной функционал Telegram-бота для управления торрентами через Transmission. Реализованы команды для получения статуса, добавления, удаления и управления торрентами. Включены функции мониторинга завершения загрузок и отправки уведомлений в Telegram. Добавлены конфигурационные файлы и утилиты для работы с Markdown.
This commit is contained in:
@@ -12,11 +12,14 @@ RUN go mod download
|
||||
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
|
||||
RUN apk --no-cache --no-scripts add ca-certificates
|
||||
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"]
|
||||
|
||||
87
README.md
87
README.md
@@ -71,6 +71,93 @@ telegram-transmission-bot:
|
||||
|
||||
Бот работает только с авторизованными пользователями (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** (рекомендуемый метод), который работает без дополнительной настройки.
|
||||
|
||||
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