Приём: добавление загрузки по .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-файл или перешлите сообщение торрент-бота — поставлю на закачку.
Когда раздача скачается и потребуется подтверждение раскладки, позову кнопками.`
// --- Колбэки (кнопки) ---
+3
View File
@@ -22,6 +22,8 @@ type fakeAPI struct {
sent []sentMsg
edits []sentMsg
answers []string
fileURL string // GetFileDirectURL возвращает это (для .torrent-документов)
fileErr error
}
type sentMsg struct {
@@ -47,6 +49,7 @@ func (f *fakeAPI) Request(c tgbotapi.Chattable) (*tgbotapi.APIResponse, error) {
}
func (f *fakeAPI) GetUpdatesChan(tgbotapi.UpdateConfig) tgbotapi.UpdatesChannel { return nil }
func (f *fakeAPI) StopReceivingUpdates() {}
func (f *fakeAPI) GetFileDirectURL(string) (string, error) { return f.fileURL, f.fileErr }
type fakeIngestor struct {
lastReq ingest.Request
+115
View File
@@ -0,0 +1,115 @@
package tgbot
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
func docMsg(userID int64, doc *tgbotapi.Document, caption string) *tgbotapi.Message {
return &tgbotapi.Message{
MessageID: 1, From: &tgbotapi.User{ID: userID}, Chat: &tgbotapi.Chat{ID: userID},
Document: doc, Caption: caption,
}
}
// .torrent-документ: скачиваем байты и подаём в приём (подпись — контекстом).
func TestBot_IngestFromDocument(t *testing.T) {
torrentBytes := []byte("d8:announce…bytes")
fileSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(torrentBytes)
}))
defer fileSrv.Close()
b, api, ing, _ := newTestBot(t, []int64{7})
api.fileURL = fileSrv.URL + "/file.torrent"
doc := &tgbotapi.Document{FileID: "abc", FileName: "dune.torrent", MimeType: "application/x-bittorrent"}
b.handleMessage(context.Background(), docMsg(7, doc, "Дюна 2"))
if string(ing.lastReq.TorrentData) != string(torrentBytes) {
t.Errorf("TorrentData = %q, want %q", ing.lastReq.TorrentData, torrentBytes)
}
if ing.lastReq.Context != "Дюна 2" {
t.Errorf("context (подпись) = %q", ing.lastReq.Context)
}
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Принято #"+tid) {
t.Errorf("sent = %+v", api.sent)
}
}
// Документ по расширению .torrent (mime может отсутствовать) тоже принимается.
func TestBot_DocumentByExtension(t *testing.T) {
fileSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("bytes"))
}))
defer fileSrv.Close()
b, api, ing, _ := newTestBot(t, []int64{7})
api.fileURL = fileSrv.URL
doc := &tgbotapi.Document{FileID: "x", FileName: "Release.TORRENT"} // без mime, регистр
b.handleMessage(context.Background(), docMsg(7, doc, ""))
if len(ing.lastReq.TorrentData) == 0 {
t.Error("документ .torrent по расширению не принят")
}
_ = api
}
// Ошибка скачивания не должна утекать токен бота (URL файла Telegram содержит
// …/bot<TOKEN>/…). Транспортная ошибка *url.Error встраивает URL — проверяем,
// что stripURL его убрал.
func TestBot_DownloadErrorNoTokenLeak(t *testing.T) {
b, api, _, _ := newTestBot(t, []int64{7})
// «Токен» в URL, указывающем на закрытый порт → ошибка транспорта.
api.fileURL = "http://127.0.0.1:1/file/botSECRET123:AAtoken/x"
_, err := b.downloadFile(context.Background(), "fid")
if err == nil {
t.Fatal("ожидалась ошибка транспорта")
}
if strings.Contains(err.Error(), "SECRET123") || strings.Contains(err.Error(), "AAtoken") {
t.Errorf("токен утёк в текст ошибки: %v", err)
}
}
// Не-.torrent документ → подсказка, приёма нет.
func TestBot_NonTorrentDocumentRejected(t *testing.T) {
b, api, ing, _ := newTestBot(t, []int64{7})
doc := &tgbotapi.Document{FileID: "x", FileName: "photo.jpg", MimeType: "image/jpeg"}
b.handleMessage(context.Background(), docMsg(7, doc, ""))
if len(ing.lastReq.TorrentData) != 0 || ing.lastReq.Source != "" {
t.Errorf("не-.torrent не должен идти в приём: %+v", ing.lastReq)
}
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "не .torrent") {
t.Errorf("нет подсказки об ошибке: %+v", api.sent)
}
}
// Документ приходит ДО ветки pending-подсказки: ожидающая refine-подсказка не
// «съедает» документ (у документа m.Text пуст).
func TestBot_DocumentBeforePendingHint(t *testing.T) {
fileSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("bytes"))
}))
defer fileSrv.Close()
b, api, ing, rev := newTestBot(t, []int64{7})
api.fileURL = fileSrv.URL
b.setPending(7, tid) // ждём подсказку для перераспознавания
doc := &tgbotapi.Document{FileID: "x", FileName: "a.torrent", MimeType: "application/x-bittorrent"}
b.handleMessage(context.Background(), docMsg(7, doc, ""))
if len(rev.refined) != 0 {
t.Errorf("документ ошибочно принят как refine-подсказка: %v", rev.refined)
}
if len(ing.lastReq.TorrentData) == 0 {
t.Error("документ не принят как .torrent")
}
}