Приём: добавление загрузки по .torrent-файлу

Принимаем .torrent как загруженные байты — через файл-пикер в веб-форме и
Telegram-документ, наряду с magnet. Файл несёт полные метаданные: работает
там, где magnet не резолвится (закрытые трекеры, без DHT), и даёт максимум
контекста для распознавания без сети.

- internal/torrent: парсер поверх anacrolix/torrent/metainfo — инфохэш(и)
  (v1 SHA1 исходных байтов info; v2 BEP52 при наличии) + Context() из имени,
  дерева файлов, размера, трекеров. Извлечение файлов панико-безопасно
  (недоверенный вход).
- Персистентность байтов: таблица-спутник download_torrent (миграция 0009);
  пишется в транзакции создания загрузки, только на ветке создания (не при
  дедупе). Байты живут весь срок строки — нужны для повторного добавления
  при retry.
- ingest: Request.TorrentData/TorrentName, диспетч парсера; source_ref —
  человекочитаемый референс (имя раздачи/файла), не адрес добавления.
- worker: общий sourceAddParts ветвит по source_type в ОБОИХ add-путях —
  processCatched и Retry (torrent добавляется файлом, не magnet-хешем).
- Транспорты: multipart-форма с файл-пикером (деградация без JS) и приём
  Telegram-документа (скачивание с редактированием токена из ошибок — секрет
  не в логи; обработка до ветки pending/текста).

Разработка по OpenSpec (SDD): change torrent-file-ingest, два чекпоинта ревью
(дизайн до кода, код до архива) сабагентами; дельты влиты в спеки, change
архивирован. Ручная проверка на живом qBittorrent (7.3) — за деплоем.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-08 10:52:31 +03:00
co-authored by Claude Opus 4.8
parent 90fd8640ed
commit 14d615a7c2
38 changed files with 2519 additions and 100 deletions
+120 -12
View File
@@ -2,10 +2,15 @@ package tgbot
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
@@ -21,6 +26,9 @@ type teleAPI interface {
Request(c tgbotapi.Chattable) (*tgbotapi.APIResponse, error)
GetUpdatesChan(config tgbotapi.UpdateConfig) tgbotapi.UpdatesChannel
StopReceivingUpdates()
// GetFileDirectURL — прямой URL файла Telegram по file_id (для скачивания
// присланного .torrent-документа).
GetFileDirectURL(fileID string) (string, error)
}
// Ingestor принимает загрузку (ingest.Service).
@@ -56,6 +64,8 @@ type Bot struct {
mu sync.Mutex // защищает pending
pending map[int64]string // chatID → downloadID, ждущий подсказку
httpClient *http.Client // для скачивания .torrent-документов Telegram
}
// New собирает бота поверх клиента Telegram.
@@ -65,13 +75,14 @@ func New(client teleAPI, ing Ingestor, rev Reviewer, cfg Config, log *slog.Logge
allowed[id] = true
}
return &Bot{
api: client,
ingestor: ing,
reviewer: rev,
allowed: allowed,
webBase: strings.TrimRight(cfg.WebBaseURL, "/"),
log: log,
pending: map[int64]string{},
api: client,
ingestor: ing,
reviewer: rev,
allowed: allowed,
webBase: strings.TrimRight(cfg.WebBaseURL, "/"),
log: log,
pending: map[int64]string{},
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
@@ -121,6 +132,15 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
b.send(m.Chat.ID, "Доступ запрещён.", nil)
return
}
// Документ (.torrent) — до ветки pending/текста: у документа m.Text пуст,
// иначе pending-подсказка «съела» бы его как пустую строку. Контекст — из
// подписи (m.Caption).
if m.Document != nil {
b.handleDocument(ctx, m)
return
}
text := strings.TrimSpace(m.Text)
// Ждём подсказку для перераспознавания?
@@ -140,25 +160,113 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
source, context, ok := ParseMessage(text)
if !ok {
b.send(m.Chat.ID, "Не вижу magnet-ссылки. Перешлите сообщение торрент-бота или пришлите magnet.", nil)
b.send(m.Chat.ID, "Не вижу magnet-ссылки. Перешлите сообщение торрент-бота, пришлите magnet или .torrent-файл.", nil)
return
}
res, err := b.ingestor.Ingest(ctx, ingest.Request{Source: source, Context: context})
b.ingestAndReply(ctx, m.Chat.ID, ingest.Request{Source: source, Context: context})
}
// handleDocument принимает присланный .torrent-документ: проверяет тип/размер,
// скачивает байты через Bot API и подаёт в приём (подпись — контекстом).
func (b *Bot) handleDocument(ctx context.Context, m *tgbotapi.Message) {
doc := m.Document
if !isTorrentDoc(doc) {
b.send(m.Chat.ID, "Это не .torrent-файл. Пришлите magnet-ссылку или .torrent.", nil)
return
}
if doc.FileSize > 0 && doc.FileSize > ingest.MaxTorrentSize {
b.send(m.Chat.ID, "Файл слишком большой для .torrent.", nil)
return
}
data, err := b.downloadFile(ctx, doc.FileID)
if err != nil {
b.log.Warn("telegram document download failed", "error", err)
b.send(m.Chat.ID, "Не удалось скачать файл из Telegram, попробуйте ещё раз.", nil)
return
}
b.ingestAndReply(ctx, m.Chat.ID, ingest.Request{
TorrentData: data,
TorrentName: doc.FileName, // фолбек для source_ref, если у раздачи нет имени
Context: m.Caption,
})
}
// stripURL убирает URL из ошибки *url.Error (URL файла Telegram содержит токен
// бота), оставляя только первопричину — защита от утечки секрета в логи.
func stripURL(err error) error {
var ue *url.Error
if errors.As(err, &ue) {
return ue.Err
}
return err
}
// isTorrentDoc — документ выглядит как .torrent (по mime или расширению).
func isTorrentDoc(doc *tgbotapi.Document) bool {
if doc == nil {
return false
}
if doc.MimeType == "application/x-bittorrent" {
return true
}
return strings.HasSuffix(strings.ToLower(doc.FileName), ".torrent")
}
// downloadFile скачивает файл Telegram по file_id (прямой URL + HTTP GET),
// ограничивая объём MaxTorrentSize.
//
// ВАЖНО: прямой URL файла Telegram содержит токен бота
// (…/file/bot<TOKEN>/<path>). Ошибки транспорта (*url.Error) встраивают этот
// URL в текст — их нельзя возвращать/логировать как есть. stripURL оставляет
// только первопричину без URL, чтобы токен не утёк в логи (см. logging.md).
func (b *Bot) downloadFile(ctx context.Context, fileID string) ([]byte, error) {
fileURL, err := b.api.GetFileDirectURL(fileID)
if err != nil {
return nil, fmt.Errorf("file url: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
if err != nil {
return nil, fmt.Errorf("telegram file request: %w", stripURL(err))
}
resp, err := b.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("telegram file GET: %w", stripURL(err))
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("telegram file GET: status %d", resp.StatusCode)
}
// LimitReader на 1 байт больше лимита — чтобы отличить «ровно лимит» от
// «больше лимита».
data, err := io.ReadAll(io.LimitReader(resp.Body, ingest.MaxTorrentSize+1))
if err != nil {
return nil, err
}
if len(data) > ingest.MaxTorrentSize {
return nil, fmt.Errorf("telegram file exceeds %d bytes", ingest.MaxTorrentSize)
}
return data, nil
}
// ingestAndReply принимает загрузку и отвечает пользователю единообразно для
// всех источников (текст/magnet и .torrent-документ).
func (b *Bot) ingestAndReply(ctx context.Context, chatID int64, req ingest.Request) {
res, err := b.ingestor.Ingest(ctx, req)
if err != nil {
// res.DownloadID непуст, если сбой после создания задачи (напр. qbit);
// при раннем разборе источника id ещё нет — даём дружелюбный текст без
// него (детали всё равно в логах на доменной границе).
b.send(m.Chat.ID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
b.send(chatID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
return
}
msg := fmt.Sprintf("Принято #%s — добавляю в qBittorrent.", res.DownloadID)
if res.Deduplicated {
msg = fmt.Sprintf("Уже в работе #%s.", res.DownloadID)
}
b.send(m.Chat.ID, msg+"\nПозову, когда нужно подтверждение.", nil)
b.send(chatID, msg+"\nПозову, когда нужно подтверждение.", nil)
}
const helpText = `jellybit-бот: пришлите magnet-ссылку или перешлите сообщение торрент-бота — поставлю на закачку.
const helpText = `jellybit-бот: пришлите magnet-ссылку, .torrent-файл или перешлите сообщение торрент-бота — поставлю на закачку.
Когда раздача скачается и потребуется подтверждение раскладки, позову кнопками.`
// --- Колбэки (кнопки) ---