Приём: добавление загрузки по .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:
+120
-12
@@ -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-файл или перешлите сообщение торрент-бота — поставлю на закачку.
|
||||
Когда раздача скачается и потребуется подтверждение раскладки, позову кнопками.`
|
||||
|
||||
// --- Колбэки (кнопки) ---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user