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