Команды ревью проверяли только наличие раздачи в qBittorrent, но не её готовность. Недокачанную задачу можно припарковать в deferred, затем «Распознать заново» → recognizing → авто-раскладка (Rerecognize/Refine/ SetType не ставят force_review) → хардлинки на неполные файлы. Даже ручной Apply не имел preflight завершённости. Вводим ensureSourceReady (classify(t.State)==classReady) вместо ensureSourcePresent во всех командах, которым нужен источник (Relink/ Rerecognize/Refine/SetType), и inline-проверку класса в Apply — последний рубеж перед хардлинками. Недокачанный источник → отдельный sentinel ErrNotReady (409) с actionable-текстом «торрент ещё качается» в web и Telegram, без reconcile (состояние deferred/review легитимно). Change review-readiness-preflight заархивирован, дельта влита в openspec/specs/review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
440 lines
15 KiB
Go
440 lines
15 KiB
Go
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"
|
|
|
|
"git.vakhrushev.me/av/jellybit/internal/ident"
|
|
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
|
)
|
|
|
|
// teleAPI — нужная боту часть клиента Telegram (его реализует
|
|
// *tgbotapi.BotAPI; в тестах подменяется фейком).
|
|
type teleAPI interface {
|
|
Send(c tgbotapi.Chattable) (tgbotapi.Message, error)
|
|
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).
|
|
type Ingestor interface {
|
|
Ingest(ctx context.Context, req ingest.Request) (ingest.Result, error)
|
|
}
|
|
|
|
// Reviewer — операции ревью (worker.Worker).
|
|
type Reviewer interface {
|
|
ReviewData(ctx context.Context, id string) (*worker.ReviewData, error)
|
|
Apply(ctx context.Context, id string) error
|
|
Refine(ctx context.Context, id string, hint string) error
|
|
SetType(ctx context.Context, id string, mediaType string) error
|
|
Defer(ctx context.Context, id string) error
|
|
Cancel(ctx context.Context, id string) error
|
|
Retry(ctx context.Context, id string) error
|
|
}
|
|
|
|
// Config — параметры бота.
|
|
type Config struct {
|
|
AllowedUserIDs []int64
|
|
WebBaseURL string // для deep-link «открыть в вебе» (опц.)
|
|
}
|
|
|
|
// Bot — Telegram-адаптер: приём загрузок и подтверждение раскладки.
|
|
type Bot struct {
|
|
api teleAPI
|
|
ingestor Ingestor
|
|
reviewer Reviewer
|
|
allowed map[int64]bool
|
|
webBase string
|
|
log *slog.Logger
|
|
|
|
mu sync.Mutex // защищает pending
|
|
pending map[int64]string // chatID → downloadID, ждущий подсказку
|
|
|
|
httpClient *http.Client // для скачивания .torrent-документов Telegram
|
|
}
|
|
|
|
// New собирает бота поверх клиента Telegram.
|
|
func New(client teleAPI, ing Ingestor, rev Reviewer, cfg Config, log *slog.Logger) *Bot {
|
|
allowed := make(map[int64]bool, len(cfg.AllowedUserIDs))
|
|
for _, id := range cfg.AllowedUserIDs {
|
|
allowed[id] = true
|
|
}
|
|
return &Bot{
|
|
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},
|
|
}
|
|
}
|
|
|
|
const pollTimeout = 30 // секунд long-poll
|
|
|
|
// Run крутит цикл обновлений до отмены ctx.
|
|
func (b *Bot) Run(ctx context.Context) {
|
|
b.log.Info("telegram bot started", "allowed_users", len(b.allowed))
|
|
cfg := tgbotapi.NewUpdate(0)
|
|
cfg.Timeout = pollTimeout
|
|
cfg.AllowedUpdates = []string{"message", "callback_query"}
|
|
|
|
updates := b.api.GetUpdatesChan(cfg)
|
|
defer b.api.StopReceivingUpdates()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
b.log.Info("telegram bot stopped")
|
|
return
|
|
case u, ok := <-updates:
|
|
if !ok {
|
|
return
|
|
}
|
|
b.handleUpdate(ctx, u)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (b *Bot) handleUpdate(ctx context.Context, u tgbotapi.Update) {
|
|
switch {
|
|
case u.Message != nil:
|
|
b.handleMessage(ctx, u.Message)
|
|
case u.CallbackQuery != nil:
|
|
b.handleCallback(ctx, u.CallbackQuery)
|
|
}
|
|
}
|
|
|
|
// --- Входящие сообщения ---
|
|
|
|
func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
|
|
if m.From == nil || m.Chat == nil {
|
|
return
|
|
}
|
|
if !b.allowed[m.From.ID] {
|
|
b.log.Warn("telegram denied user", "user_id", m.From.ID, "username", m.From.UserName)
|
|
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)
|
|
|
|
// Ждём подсказку для перераспознавания?
|
|
if id, ok := b.takePending(m.Chat.ID); ok && !strings.Contains(text, "magnet:") {
|
|
if err := b.reviewer.Refine(ctx, id, text); err != nil {
|
|
b.send(m.Chat.ID, opErr("Не удалось обработать подсказку", id), nil)
|
|
return
|
|
}
|
|
b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+id+"…", nil)
|
|
return
|
|
}
|
|
|
|
if text == "/start" || text == "/help" {
|
|
b.send(m.Chat.ID, helpText, nil)
|
|
return
|
|
}
|
|
|
|
source, context, ok := ParseMessage(text)
|
|
if !ok {
|
|
b.send(m.Chat.ID, "Не вижу magnet-ссылки. Перешлите сообщение торрент-бота, пришлите magnet или .torrent-файл.", nil)
|
|
return
|
|
}
|
|
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(chatID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
|
|
return
|
|
}
|
|
msg := fmt.Sprintf("Принято #%s — добавляю в qBittorrent.", res.DownloadID)
|
|
if res.Deduplicated {
|
|
msg = fmt.Sprintf("Уже в работе #%s.", res.DownloadID)
|
|
}
|
|
b.send(chatID, msg+"\nПозову, когда нужно подтверждение.", nil)
|
|
}
|
|
|
|
const helpText = `jellybit-бот: пришлите magnet-ссылку, .torrent-файл или перешлите сообщение торрент-бота — поставлю на закачку.
|
|
Когда раздача скачается и потребуется подтверждение раскладки, позову кнопками.`
|
|
|
|
// --- Колбэки (кнопки) ---
|
|
|
|
func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
|
if cq.From == nil || cq.Message == nil || cq.Message.Chat == nil {
|
|
return
|
|
}
|
|
if !b.allowed[cq.From.ID] {
|
|
b.answer(cq.ID, "Доступ запрещён")
|
|
return
|
|
}
|
|
|
|
action, id, val := parseCallback(cq.Data)
|
|
if id == "" {
|
|
// Пустой/невалидный id — в т.ч. старые числовые кнопки, оставшиеся в
|
|
// истории чата до перехода на ULID: отвечаем понятно, а не молчим.
|
|
b.answer(cq.ID, "Кнопка устарела — откройте задачу в вебе")
|
|
return
|
|
}
|
|
chatID := cq.Message.Chat.ID
|
|
msgID := cq.Message.MessageID
|
|
|
|
var note string
|
|
var err error
|
|
switch action {
|
|
case "apply":
|
|
err = b.reviewer.Apply(ctx, id)
|
|
note = "Применяю…"
|
|
case "defer":
|
|
err = b.reviewer.Defer(ctx, id)
|
|
note = "Отложено"
|
|
case "reject":
|
|
err = b.reviewer.Cancel(ctx, id)
|
|
note = "Отклонено"
|
|
case "retry":
|
|
err = b.reviewer.Retry(ctx, id)
|
|
note = "Повторяю…"
|
|
case "type":
|
|
err = b.reviewer.SetType(ctx, id, val)
|
|
note = "Меняю тип…"
|
|
case "refine":
|
|
b.setPending(chatID, id)
|
|
b.answer(cq.ID, "Жду подсказку")
|
|
b.send(chatID, "Ответьте сообщением с подсказкой для #"+id+".", nil)
|
|
return
|
|
default:
|
|
b.answer(cq.ID, "")
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
if errors.Is(err, worker.ErrNotReady) {
|
|
// Источник ещё качается — actionable причина, показываем конкретно.
|
|
b.answer(cq.ID, "Торрент ещё качается")
|
|
b.send(chatID, opErr("Торрент ещё качается — дождитесь докачки", id), nil)
|
|
return
|
|
}
|
|
b.answer(cq.ID, "Ошибка")
|
|
b.send(chatID, opErr("Не удалось выполнить действие", id), nil)
|
|
return
|
|
}
|
|
b.answer(cq.ID, note)
|
|
b.refreshCard(ctx, chatID, msgID, id)
|
|
}
|
|
|
|
// refreshCard перечитывает задачу и обновляет карточку на месте.
|
|
func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id string) {
|
|
rd, err := b.reviewer.ReviewData(ctx, id)
|
|
if err != nil {
|
|
b.log.Warn("telegram refresh card failed", "download_id", id, "error", err)
|
|
return
|
|
}
|
|
text, kb := b.renderCard(rd)
|
|
var edit tgbotapi.EditMessageTextConfig
|
|
if kb != nil {
|
|
edit = tgbotapi.NewEditMessageTextAndMarkup(chatID, msgID, text, *kb)
|
|
} else {
|
|
edit = tgbotapi.NewEditMessageText(chatID, msgID, text)
|
|
}
|
|
if _, err := b.api.Send(edit); err != nil {
|
|
b.log.Warn("telegram edit card failed", "download_id", id, "error", err)
|
|
}
|
|
}
|
|
|
|
// --- Notifier (worker.Notifier) ---
|
|
|
|
// Notify шлёт карточку подтверждения/готовности всем доверенным пользователям.
|
|
func (b *Bot) Notify(ctx context.Context, downloadID string, event worker.NotifyEvent) {
|
|
rd, err := b.reviewer.ReviewData(ctx, downloadID)
|
|
if err != nil {
|
|
b.log.Warn("telegram notify review data", "download_id", downloadID, "error", err)
|
|
return
|
|
}
|
|
var text string
|
|
var kb *tgbotapi.InlineKeyboardMarkup
|
|
switch event {
|
|
case worker.EventDone:
|
|
text = b.renderDone(rd)
|
|
case worker.EventTargetMissing, worker.EventOrphaned:
|
|
text, kb = b.renderDesync(rd, event), b.webOnly(downloadID)
|
|
case worker.EventFailed:
|
|
text, kb = b.renderFailed(rd)
|
|
default:
|
|
text, kb = b.renderCard(rd)
|
|
}
|
|
for chatID := range b.allowed {
|
|
b.send(chatID, text, kb)
|
|
}
|
|
}
|
|
|
|
// --- Отправка/хелперы ---
|
|
|
|
func (b *Bot) send(chatID int64, text string, kb *tgbotapi.InlineKeyboardMarkup) {
|
|
msg := tgbotapi.NewMessage(chatID, text)
|
|
msg.DisableWebPagePreview = true
|
|
if kb != nil {
|
|
msg.ReplyMarkup = *kb
|
|
}
|
|
if _, err := b.api.Send(msg); err != nil {
|
|
b.log.Warn("telegram send failed", "chat_id", chatID, "error", err)
|
|
}
|
|
}
|
|
|
|
func (b *Bot) answer(callbackID, text string) {
|
|
if _, err := b.api.Request(tgbotapi.NewCallback(callbackID, text)); err != nil {
|
|
b.log.Warn("telegram answer callback failed", "error", err)
|
|
}
|
|
}
|
|
|
|
func (b *Bot) setPending(chatID int64, id string) {
|
|
b.mu.Lock()
|
|
b.pending[chatID] = id
|
|
b.mu.Unlock()
|
|
}
|
|
|
|
func (b *Bot) takePending(chatID int64) (string, bool) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
id, ok := b.pending[chatID]
|
|
if ok {
|
|
delete(b.pending, chatID)
|
|
}
|
|
return id, ok
|
|
}
|
|
|
|
// opErr — сообщение публичного канала Telegram по доменной ошибке: нейтральный
|
|
// текст + download_id для корреляции с логами (полная ошибка уже там, на
|
|
// доменной границе). Сырой err.Error() пользователю не показываем. Если id
|
|
// операции ещё нет (downloadID == "") — дружелюбный текст без ключа.
|
|
func opErr(msg string, downloadID string) string {
|
|
if downloadID != "" {
|
|
return fmt.Sprintf("%s (download_id=%s).", msg, downloadID)
|
|
}
|
|
return msg + "."
|
|
}
|
|
|
|
// parseCallback разбирает "action[:id[:value]]". id валидируется как ULID
|
|
// (входная граница); невалидный/устаревший (числовой) → пустая строка.
|
|
func parseCallback(data string) (action string, id string, value string) {
|
|
parts := strings.Split(data, ":")
|
|
action = parts[0]
|
|
if len(parts) > 1 {
|
|
id, _ = ident.Parse(parts[1])
|
|
}
|
|
if len(parts) > 2 {
|
|
value = parts[2]
|
|
}
|
|
return action, id, value
|
|
}
|