From 08b707f6028a65931137a437249c6bb00d922ad7 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sun, 14 Jun 2026 15:55:33 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=20=D0=B4=D0=BB=D1=8F=20Telegram?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/jellybit/serve.go | 25 +++ config.example.toml | 1 + go.mod | 1 + go.sum | 2 + internal/config/config.go | 1 + internal/tgbot/bot.go | 299 +++++++++++++++++++++++++++++++++ internal/tgbot/bot_test.go | 264 +++++++++++++++++++++++++++++ internal/tgbot/doc.go | 4 - internal/tgbot/parse.go | 73 ++++++++ internal/tgbot/parse_test.go | 77 +++++++++ internal/tgbot/render.go | 182 ++++++++++++++++++++ internal/worker/review_test.go | 56 ++++++ internal/worker/worker.go | 34 +++- 13 files changed, 1012 insertions(+), 7 deletions(-) create mode 100644 internal/tgbot/bot.go create mode 100644 internal/tgbot/bot_test.go delete mode 100644 internal/tgbot/doc.go create mode 100644 internal/tgbot/parse.go create mode 100644 internal/tgbot/parse_test.go create mode 100644 internal/tgbot/render.go diff --git a/cmd/jellybit/serve.go b/cmd/jellybit/serve.go index 3785689..125c2b3 100644 --- a/cmd/jellybit/serve.go +++ b/cmd/jellybit/serve.go @@ -10,6 +10,8 @@ import ( "syscall" "time" + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "git.vakhrushev.me/av/jellybit/internal/config" "git.vakhrushev.me/av/jellybit/internal/httpapi" "git.vakhrushev.me/av/jellybit/internal/ingest" @@ -20,6 +22,7 @@ import ( "git.vakhrushev.me/av/jellybit/internal/qbt" "git.vakhrushev.me/av/jellybit/internal/recognize" "git.vakhrushev.me/av/jellybit/internal/store" + "git.vakhrushev.me/av/jellybit/internal/tgbot" "git.vakhrushev.me/av/jellybit/internal/worker" ) @@ -124,6 +127,28 @@ func runServe(args []string) error { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() + // Ф5: Telegram-транспорт + пинги. Доступ — по allowed_user_ids + // (пусто = запрет всем, fail-closed). Недоступность Telegram на старте не + // валит сервис — бот просто отключается. + if cfg.Telegram.Enabled { + if cfg.Telegram.Token == "" { + return fmt.Errorf("telegram enabled, but token is empty") + } + api, terr := tgbotapi.NewBotAPI(cfg.Telegram.Token) + if terr != nil { + logger.Error("telegram bot disabled: cannot connect", "err", terr) + } else { + bot := tgbot.New(api, ingestor, wrk, tgbot.Config{ + AllowedUserIDs: cfg.Telegram.AllowedUserIDs, + WebBaseURL: cfg.Telegram.WebBaseURL, + }, logger) + wrk.SetNotifier(bot) + go bot.Run(ctx) + logger.Info("telegram bot enabled", + "bot", api.Self.UserName, "allowed_users", len(cfg.Telegram.AllowedUserIDs)) + } + } + go wrk.Run(ctx) srv := &http.Server{ diff --git a/config.example.toml b/config.example.toml index 4ed981c..5b2bf56 100644 --- a/config.example.toml +++ b/config.example.toml @@ -57,6 +57,7 @@ auto_confidence_threshold = 0.85 enabled = false token = "" allowed_user_ids = [] # пусто = запрет всем (fail-closed) +web_base_url = "" # напр. "http://umbar:8080" — для кнопки «открыть в вебе» [http] listen = ":8080" diff --git a/go.mod b/go.mod index bd83fa6..5ec201f 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26 require ( github.com/go-chi/chi/v5 v5.1.0 + github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 github.com/jmoiron/sqlx v1.4.0 github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4 github.com/pelletier/go-toml/v2 v2.2.3 diff --git a/go.sum b/go.sum index af4b1fe..8963528 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= diff --git a/internal/config/config.go b/internal/config/config.go index 31bc0b0..74c5e16 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -90,6 +90,7 @@ type Telegram struct { Enabled bool `toml:"enabled"` Token string `toml:"token"` AllowedUserIDs []int64 `toml:"allowed_user_ids"` + WebBaseURL string `toml:"web_base_url"` // для deep-link «открыть в вебе» (опц.) } // HTTP — параметры веб-сервера. diff --git a/internal/tgbot/bot.go b/internal/tgbot/bot.go new file mode 100644 index 0000000..d5c8f57 --- /dev/null +++ b/internal/tgbot/bot.go @@ -0,0 +1,299 @@ +package tgbot + +import ( + "context" + "fmt" + "log/slog" + "strconv" + "strings" + "sync" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "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() +} + +// 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 int64) (*worker.ReviewData, error) + Apply(ctx context.Context, id int64) error + Refine(ctx context.Context, id int64, hint string) error + SetType(ctx context.Context, id int64, mediaType string) error + Defer(ctx context.Context, id int64) error + Cancel(ctx context.Context, id int64) 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]int64 // chatID → downloadID, ждущий подсказку +} + +// 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]int64{}, + } +} + +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 + } + 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, "Не удалось: "+err.Error(), nil) + return + } + b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+strconv.FormatInt(id, 10)+"…", 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.", nil) + return + } + res, err := b.ingestor.Ingest(ctx, ingest.Request{Source: source, Context: context}) + if err != nil { + b.send(m.Chat.ID, "Ошибка приёма: "+err.Error(), nil) + return + } + msg := fmt.Sprintf("Принято #%d — %s.", res.DownloadID, res.State) + if res.Deduplicated { + msg = fmt.Sprintf("Уже в работе #%d — %s.", res.DownloadID, res.State) + } + b.send(m.Chat.ID, msg+"\nПозову, когда нужно подтверждение.", nil) +} + +const helpText = `jellybit-бот: пришлите magnet-ссылку или перешлите сообщение торрент-бота — поставлю на закачку. +Когда раздача скачается и потребуется подтверждение раскладки, позову кнопками.` + +// --- Колбэки (кнопки) --- + +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 == 0 { + 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 "type": + err = b.reviewer.SetType(ctx, id, val) + note = "Меняю тип…" + case "refine": + b.setPending(chatID, id) + b.answer(cq.ID, "Жду подсказку") + b.send(chatID, "Ответьте сообщением с подсказкой для #"+strconv.FormatInt(id, 10)+".", nil) + return + default: + b.answer(cq.ID, "") + return + } + + if err != nil { + b.answer(cq.ID, "Ошибка") + b.send(chatID, "Не удалось: "+err.Error(), 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 int64) { + rd, err := b.reviewer.ReviewData(ctx, id) + if err != nil { + 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, "err", err) + } +} + +// --- Notifier (worker.Notifier) --- + +// Notify шлёт карточку подтверждения/готовности всем доверенным пользователям. +func (b *Bot) Notify(ctx context.Context, downloadID int64, event worker.NotifyEvent) { + rd, err := b.reviewer.ReviewData(ctx, downloadID) + if err != nil { + b.log.Warn("telegram: notify review data", "download_id", downloadID, "err", err) + return + } + var text string + var kb *tgbotapi.InlineKeyboardMarkup + switch event { + case worker.EventDone: + text = b.renderDone(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, "err", 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", "err", err) + } +} + +func (b *Bot) setPending(chatID, id int64) { + b.mu.Lock() + b.pending[chatID] = id + b.mu.Unlock() +} + +func (b *Bot) takePending(chatID int64) (int64, bool) { + b.mu.Lock() + defer b.mu.Unlock() + id, ok := b.pending[chatID] + if ok { + delete(b.pending, chatID) + } + return id, ok +} + +// parseCallback разбирает "action[:id[:value]]". +func parseCallback(data string) (action string, id int64, value string) { + parts := strings.Split(data, ":") + action = parts[0] + if len(parts) > 1 { + id, _ = strconv.ParseInt(parts[1], 10, 64) + } + if len(parts) > 2 { + value = parts[2] + } + return action, id, value +} diff --git a/internal/tgbot/bot_test.go b/internal/tgbot/bot_test.go new file mode 100644 index 0000000..cb4a395 --- /dev/null +++ b/internal/tgbot/bot_test.go @@ -0,0 +1,264 @@ +package tgbot + +import ( + "context" + "io" + "log/slog" + "strings" + "testing" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "git.vakhrushev.me/av/jellybit/internal/ingest" + "git.vakhrushev.me/av/jellybit/internal/layout" + "git.vakhrushev.me/av/jellybit/internal/recognize" + "git.vakhrushev.me/av/jellybit/internal/store" + "git.vakhrushev.me/av/jellybit/internal/worker" +) + +// fakeAPI записывает исходящие Chattable; обновления не нужны (хендлеры зовём +// напрямую). +type fakeAPI struct { + sent []sentMsg + edits []sentMsg + answers []string +} + +type sentMsg struct { + chatID int64 + text string + hasKB bool +} + +func (f *fakeAPI) Send(c tgbotapi.Chattable) (tgbotapi.Message, error) { + switch m := c.(type) { + case tgbotapi.MessageConfig: + f.sent = append(f.sent, sentMsg{m.ChatID, m.Text, m.ReplyMarkup != nil}) + case tgbotapi.EditMessageTextConfig: + f.edits = append(f.edits, sentMsg{m.ChatID, m.Text, m.ReplyMarkup != nil}) + } + return tgbotapi.Message{MessageID: 1}, nil +} +func (f *fakeAPI) Request(c tgbotapi.Chattable) (*tgbotapi.APIResponse, error) { + if cb, ok := c.(tgbotapi.CallbackConfig); ok { + f.answers = append(f.answers, cb.Text) + } + return &tgbotapi.APIResponse{Ok: true}, nil +} +func (f *fakeAPI) GetUpdatesChan(tgbotapi.UpdateConfig) tgbotapi.UpdatesChannel { return nil } +func (f *fakeAPI) StopReceivingUpdates() {} + +type fakeIngestor struct { + lastReq ingest.Request + res ingest.Result +} + +func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Result, error) { + f.lastReq = req + return f.res, nil +} + +type fakeReviewer struct { + data *worker.ReviewData + applied []int64 + refined map[int64]string + typed map[int64]string + deferred []int64 + canceled []int64 +} + +func (f *fakeReviewer) ReviewData(context.Context, int64) (*worker.ReviewData, error) { + return f.data, nil +} +func (f *fakeReviewer) Apply(_ context.Context, id int64) error { + f.applied = append(f.applied, id) + return nil +} +func (f *fakeReviewer) Refine(_ context.Context, id int64, hint string) error { + if f.refined == nil { + f.refined = map[int64]string{} + } + f.refined[id] = hint + return nil +} +func (f *fakeReviewer) SetType(_ context.Context, id int64, t string) error { + if f.typed == nil { + f.typed = map[int64]string{} + } + f.typed[id] = t + return nil +} +func (f *fakeReviewer) Defer(_ context.Context, id int64) error { + f.deferred = append(f.deferred, id) + return nil +} +func (f *fakeReviewer) Cancel(_ context.Context, id int64) error { + f.canceled = append(f.canceled, id) + return nil +} + +func reviewData(state store.State) *worker.ReviewData { + s, e := 2, 1 + return &worker.ReviewData{ + Download: store.Download{ID: 5, State: state, Context: "Фарго, второй сезон", SourceRef: "magnet:?x"}, + Recognition: &store.Recognition{ + Provider: store.NullString("tvdb"), ProviderID: store.NullString("269613"), + Reasons: `["неполный пак"]`, + }, + Plan: recognize.Plan{ + Type: recognize.MediaSeries, Title: "Фарго", Year: 2015, + Files: []recognize.PlanFile{{Src: "e1.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e}}, + }, + Preview: []layout.Link{ + {Src: "e1.mkv", Dst: "/srv/media/series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv"}, + }, + } +} + +func newTestBot(t *testing.T, allowed []int64) (*Bot, *fakeAPI, *fakeIngestor, *fakeReviewer) { + t.Helper() + api := &fakeAPI{} + ing := &fakeIngestor{res: ingest.Result{DownloadID: 5, State: store.StateDownloading}} + rev := &fakeReviewer{data: reviewData(store.StateReview)} + b := New(api, ing, rev, Config{AllowedUserIDs: allowed, WebBaseURL: "http://host:8080"}, + slog.New(slog.NewTextHandler(io.Discard, nil))) + return b, api, ing, rev +} + +func msgFrom(userID int64, text string) *tgbotapi.Message { + return &tgbotapi.Message{ + MessageID: 1, From: &tgbotapi.User{ID: userID}, Chat: &tgbotapi.Chat{ID: userID}, Text: text, + } +} + +func TestBot_IngestFromMagnet(t *testing.T) { + b, api, ing, _ := newTestBot(t, []int64{7}) + b.handleMessage(context.Background(), msgFrom(7, "крутой сериал\nmagnet:?xt=urn:btih:ABC")) + + if !strings.HasPrefix(ing.lastReq.Source, "magnet:?xt=urn:btih:ABC") { + t.Errorf("source = %q", ing.lastReq.Source) + } + if ing.lastReq.Context != "крутой сериал" { + t.Errorf("context = %q", ing.lastReq.Context) + } + if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Принято #5") { + t.Errorf("sent = %+v", api.sent) + } +} + +func TestBot_DeniesUnknownUser(t *testing.T) { + b, api, ing, _ := newTestBot(t, []int64{7}) + b.handleMessage(context.Background(), msgFrom(999, "magnet:?xt=urn:btih:ABC")) + + if len(ing.lastReq.Source) != 0 { + t.Error("ingest не должен вызываться для чужого пользователя") + } + if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Доступ запрещён") { + t.Errorf("sent = %+v", api.sent) + } +} + +func TestBot_NoMagnet(t *testing.T) { + b, api, _, _ := newTestBot(t, []int64{7}) + b.handleMessage(context.Background(), msgFrom(7, "привет")) + if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Не вижу magnet") { + t.Errorf("sent = %+v", api.sent) + } +} + +func TestBot_RefineViaReply(t *testing.T) { + b, _, _, rev := newTestBot(t, []int64{7}) + // Кнопка «Уточнить» поставила ожидание подсказки для чата 7. + b.setPending(7, 5) + b.handleMessage(context.Background(), msgFrom(7, "это второй сезон")) + + if rev.refined[5] != "это второй сезон" { + t.Errorf("refine = %v", rev.refined) + } +} + +func cbFrom(userID int64, data string) *tgbotapi.CallbackQuery { + return &tgbotapi.CallbackQuery{ + ID: "cb", From: &tgbotapi.User{ID: userID}, Data: data, + Message: &tgbotapi.Message{MessageID: 99, Chat: &tgbotapi.Chat{ID: userID}}, + } +} + +func TestBot_CallbackApply(t *testing.T) { + b, api, _, rev := newTestBot(t, []int64{7}) + b.handleCallback(context.Background(), cbFrom(7, "apply:5")) + + if len(rev.applied) != 1 || rev.applied[0] != 5 { + t.Errorf("applied = %v", rev.applied) + } + if len(api.answers) != 1 { + t.Errorf("answers = %v", api.answers) + } + if len(api.edits) != 1 { // карточка обновлена на месте + t.Errorf("edits = %v", api.edits) + } +} + +func TestBot_CallbackType(t *testing.T) { + b, _, _, rev := newTestBot(t, []int64{7}) + b.handleCallback(context.Background(), cbFrom(7, "type:5:movie")) + if rev.typed[5] != "movie" { + t.Errorf("typed = %v", rev.typed) + } +} + +func TestBot_CallbackRefineSetsPending(t *testing.T) { + b, api, _, _ := newTestBot(t, []int64{7}) + b.handleCallback(context.Background(), cbFrom(7, "refine:5")) + + if id, ok := b.takePending(7); !ok || id != 5 { + t.Errorf("pending = %d,%v", id, ok) + } + if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "подсказкой") { + t.Errorf("sent = %+v", api.sent) + } +} + +func TestBot_CallbackDeniesUnknown(t *testing.T) { + b, _, _, rev := newTestBot(t, []int64{7}) + b.handleCallback(context.Background(), cbFrom(999, "apply:5")) + if len(rev.applied) != 0 { + t.Error("чужой колбэк не должен исполняться") + } +} + +func TestBot_NotifyReview(t *testing.T) { + b, api, _, _ := newTestBot(t, []int64{7, 8}) + b.Notify(context.Background(), 5, worker.EventReview) + + if len(api.sent) != 2 { // обоим доверенным + t.Fatalf("sent to %d chats, want 2", len(api.sent)) + } + if !strings.Contains(api.sent[0].text, "Нужно подтверждение #5") { + t.Errorf("card text = %q", api.sent[0].text) + } + if !api.sent[0].hasKB { + t.Error("карточка ревью без клавиатуры") + } +} + +func TestBot_NotifyDone(t *testing.T) { + b, api, _, rev := newTestBot(t, []int64{7}) + rev.data = reviewData(store.StateDone) + b.Notify(context.Background(), 5, worker.EventDone) + + if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Готово") { + t.Errorf("sent = %+v", api.sent) + } +} + +func TestParseCallback(t *testing.T) { + a, id, v := parseCallback("type:5:series") + if a != "type" || id != 5 || v != "series" { + t.Errorf("got %q %d %q", a, id, v) + } + a, id, v = parseCallback("apply:9") + if a != "apply" || id != 9 || v != "" { + t.Errorf("got %q %d %q", a, id, v) + } +} diff --git a/internal/tgbot/doc.go b/internal/tgbot/doc.go deleted file mode 100644 index abb223d..0000000 --- a/internal/tgbot/doc.go +++ /dev/null @@ -1,4 +0,0 @@ -// Package tgbot — Telegram-адаптер, парсер сообщений бота и исходящие пинги. -// -// Заглушка: реализация в фазе Ф5 (см. docs/specs/review-ux.md). -package tgbot diff --git a/internal/tgbot/parse.go b/internal/tgbot/parse.go new file mode 100644 index 0000000..c314a41 --- /dev/null +++ b/internal/tgbot/parse.go @@ -0,0 +1,73 @@ +// Package tgbot — Telegram-адаптер: приём magnet/пересланных сообщений +// торрент-бота, подтверждение раскладки кнопками и исходящие пинги. +package tgbot + +import ( + "regexp" + "strings" +) + +var ( + magnetRe = regexp.MustCompile(`magnet:\?[^\s]+`) + // parenURL — markdown-хвост " (https://…)" в строках сообщения бота. + parenURL = regexp.MustCompile(`\s*\(https?://[^)]+\)`) +) + +// noisePrefixes — начала строк UI торрент-бота, которые в контекст не несём. +var noisePrefixes = []string{ + "Открыть magnet", "или получить .torrent", "Оценить", "Следить", + "В закладки", "Добавить в закладки", "cправка", "справка", + "[список файлов]", "[список файлов]", "⚡", "сохранённая копия", +} + +// ParseMessage извлекает из текста magnet-ссылку и человекочитаемый контекст +// (заголовок релиза без ссылок, команд и UI-мусора бота). ok=false, если +// magnet не найден. Текст может быть как «сырым» magnet, так и пересланным +// сообщением торрент-бота (формат — см. tmp/examples.md). +func ParseMessage(text string) (source, context string, ok bool) { + m := magnetRe.FindString(text) + if m == "" { + return "", "", false + } + return m, cleanContext(text, m), true +} + +// cleanContext оставляет содержательные строки (заголовок, метаданные), +// выкидывая ссылки, команды (/...) и UI-строки бота. +func cleanContext(text, magnet string) string { + var keep []string + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.Contains(line, magnet) { + continue + } + if isNoise(line) { + continue + } + line = strings.TrimSpace(parenURL.ReplaceAllString(line, "")) + if line != "" { + keep = append(keep, line) + } + } + return strings.Join(keep, "\n") +} + +func isNoise(line string) bool { + if strings.HasPrefix(line, "/") { + return true + } + // Строки-ссылки и строки рейтинга/команд бота (содержат /g_ /r_ /us_ …). + if strings.Contains(line, "hashurl.ru") || strings.Contains(line, "exfreedomist.com") { + return true + } + for _, p := range noisePrefixes { + if strings.HasPrefix(line, p) { + return true + } + } + // Строки вида "👍: /g_ad035f или 👎🏿: /r_ad035f". + if strings.Contains(line, ": /") { + return true + } + return false +} diff --git a/internal/tgbot/parse_test.go b/internal/tgbot/parse_test.go new file mode 100644 index 0000000..19a15bd --- /dev/null +++ b/internal/tgbot/parse_test.go @@ -0,0 +1,77 @@ +package tgbot + +import ( + "strings" + "testing" +) + +// botMessage — реальный блок из tmp/examples.md (Аватар, Книга 2). +const botMessage = `[3] #3117703 [rutracker], 2020-01-30 (https://hashurl.ru/eyJhbGc.abc): +Аватар: Легенда об Аанге / Avatar: The Last Airbender / Книга 2: Земля / Серии: 1-20 из 20 (Майкл Данте ДиМартино / Michael Dante DiMartino, Брайан Кониецко / Bryan Konietzko) [2006, США, приключения, фэнтези, DVDRip-AVC] Dub + Original + +✅ (проверено) | 4 GB +сохранённая копия описания раздачи (https://hashurl.ru/eyJ.def) + +magnet:?xt=urn:btih:7931AA3ED6666746012F5739D099B5BC64D72A16&tr=http%3A%2F%2Fbt2.t-ru.org%2Fann%3Fmagnet&dn=rutracker-topic-3117703 + +Открыть magnet в вашем клиенте (https://hashurl.ru/eyJ.ghi) +или получить .torrent: /tr_bc8ea + +Оценить: +👍: /g_ad035f или 👎🏿: /r_ad035f + +[список файлов] (https://download.exfreedomist.com/files/7931AA3E) + +Следить: /us_bc8ea +В закладки: /mka_46d1e + +cправка: /help, настройки: /settings` + +func TestParseMessage_BotForward(t *testing.T) { + src, ctx, ok := ParseMessage(botMessage) + if !ok { + t.Fatal("magnet не найден") + } + if !strings.HasPrefix(src, "magnet:?xt=urn:btih:7931AA3E") { + t.Errorf("magnet = %q", src) + } + // Контекст несёт заголовок релиза и метаданные. + for _, want := range []string{"Avatar: The Last Airbender", "Книга 2", "2006", "DVDRip-AVC", "✅ (проверено) | 4 GB"} { + if !strings.Contains(ctx, want) { + t.Errorf("контекст без %q:\n%s", want, ctx) + } + } + // Мусор и ссылки вычищены. + for _, bad := range []string{"magnet:", "hashurl.ru", "/tr_", "/g_ad035f", "Следить", "cправка", "список файлов"} { + if strings.Contains(ctx, bad) { + t.Errorf("контекст содержит мусор %q:\n%s", bad, ctx) + } + } +} + +func TestParseMessage_PlainMagnet(t *testing.T) { + src, ctx, ok := ParseMessage("magnet:?xt=urn:btih:ABC123&dn=x") + if !ok || src != "magnet:?xt=urn:btih:ABC123&dn=x" { + t.Errorf("src = %q, ok = %v", src, ok) + } + if ctx != "" { + t.Errorf("ожидался пустой контекст, got %q", ctx) + } +} + +func TestParseMessage_MagnetWithUserText(t *testing.T) { + text := "вот сериал, второй сезон\nmagnet:?xt=urn:btih:DEF456" + src, ctx, ok := ParseMessage(text) + if !ok || !strings.HasPrefix(src, "magnet:?xt=urn:btih:DEF456") { + t.Errorf("src = %q", src) + } + if ctx != "вот сериал, второй сезон" { + t.Errorf("context = %q", ctx) + } +} + +func TestParseMessage_NoMagnet(t *testing.T) { + if _, _, ok := ParseMessage("просто текст без ссылки"); ok { + t.Error("ожидалось ok=false без magnet") + } +} diff --git a/internal/tgbot/render.go b/internal/tgbot/render.go new file mode 100644 index 0000000..25ce6fb --- /dev/null +++ b/internal/tgbot/render.go @@ -0,0 +1,182 @@ +package tgbot + +import ( + "fmt" + "path/filepath" + "strconv" + "strings" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "git.vakhrushev.me/av/jellybit/internal/store" + "git.vakhrushev.me/av/jellybit/internal/worker" +) + +// renderCard строит текст и клавиатуру карточки по состоянию задачи. +func (b *Bot) renderCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboardMarkup) { + id := rd.Download.ID + state := rd.Download.State + + switch state { + case store.StateReview, store.StateDeferred: + return b.reviewCard(rd) + case store.StateRecognizing: + return "⏳ Распознаю #" + itoa(id) + "…", b.webOnly(id) + case store.StateLinking: + return "⏳ Раскладываю #" + itoa(id) + "…", nil + case store.StateDone: + return b.renderDone(rd), b.webOnly(id) + default: + text := fmt.Sprintf("Задача #%d — %s.", id, state) + if msg := rd.Download.ErrorMsg.String; msg != "" { + text += "\n" + msg + } + return text, b.webOnly(id) + } +} + +func (b *Bot) reviewCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboardMarkup) { + id := rd.Download.ID + var sb strings.Builder + + fmt.Fprintf(&sb, "🟡 Нужно подтверждение #%d\n", id) + if src := contextOrSource(rd); src != "" { + fmt.Fprintf(&sb, "Источник: %s\n", shorten(src, 80)) + } + fmt.Fprintf(&sb, "Похоже на: %s\n", guessLine(rd)) + if base := baseLine(rd.Recognition); base != "" { + fmt.Fprintf(&sb, "База: %s\n", base) + } + if reasons := rd.Recognition.ReasonList(); len(reasons) > 0 { + fmt.Fprintf(&sb, "Причины: %s\n", strings.Join(reasons, " · ")) + } + if n := len(rd.Preview); n > 0 { + fmt.Fprintf(&sb, "План: %d файлов → %s", n, tailPath(rd.Preview[0].Dst)) + } + + return strings.TrimRight(sb.String(), "\n"), b.reviewKeyboard(rd) +} + +func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMarkup { + id := rd.Download.ID + sid := itoa(id) + + var row1 []tgbotapi.InlineKeyboardButton + if len(rd.Preview) > 0 { + row1 = append(row1, tgbotapi.NewInlineKeyboardButtonData("✅ Применить", "apply:"+sid)) + } + row1 = append(row1, tgbotapi.NewInlineKeyboardButtonData("📺↔🎬 Тип", "type:"+sid+":"+oppositeType(string(rd.Plan.Type)))) + + row2 := tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("🔁 Уточнить", "refine:"+sid), + tgbotapi.NewInlineKeyboardButtonData("🕗 Позже", "defer:"+sid), + ) + + var row3 []tgbotapi.InlineKeyboardButton + if url := b.reviewURL(id); url != "" { + row3 = append(row3, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url)) + } + row3 = append(row3, tgbotapi.NewInlineKeyboardButtonData("❌ Отклонить", "reject:"+sid)) + + kb := tgbotapi.NewInlineKeyboardMarkup(row1, row2, row3) + return &kb +} + +// renderDone — короткое сообщение о готовности. +func (b *Bot) renderDone(rd *worker.ReviewData) string { + title := rd.Plan.Title + if title == "" { + title = "#" + itoa(rd.Download.ID) + } + n := len(rd.Preview) + if n == 0 { + return fmt.Sprintf("✅ Готово: «%s» разложен.", title) + } + return fmt.Sprintf("✅ Готово: «%s» — разложено файлов: %d.", title, n) +} + +func (b *Bot) webOnly(id int64) *tgbotapi.InlineKeyboardMarkup { + url := b.reviewURL(id) + if url == "" { + return nil + } + kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonURL("🌐 Открыть в вебе", url), + )) + return &kb +} + +func (b *Bot) reviewURL(id int64) string { + if b.webBase == "" { + return "" + } + return b.webBase + "/review/" + itoa(id) +} + +// --- мелкие хелперы --- + +func guessLine(rd *worker.ReviewData) string { + emoji, kind := "🎬", "фильм" + if rd.Plan.Type == "series" { + emoji, kind = "📺", "сериал" + } + title := rd.Plan.Title + if title == "" { + title = "не распознано" + } + s := fmt.Sprintf("%s %s «%s»", emoji, kind, title) + if rd.Plan.Year != 0 { + s += fmt.Sprintf(" (%d)", rd.Plan.Year) + } + return s +} + +func baseLine(rec *store.Recognition) string { + if rec == nil || !rec.Provider.Valid || rec.Provider.String == "" || rec.Provider.String == "none" { + return "нет матча" + } + if rec.ProviderID.Valid && rec.ProviderID.String != "" { + return rec.Provider.String + " " + rec.ProviderID.String + } + return rec.Provider.String +} + +func contextOrSource(rd *worker.ReviewData) string { + if c := strings.TrimSpace(rd.Download.Context); c != "" { + return firstLine(c) + } + return rd.Download.SourceRef +} + +func oppositeType(t string) string { + if t == "series" { + return "movie" + } + return "series" +} + +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} + +func tailPath(p string) string { + dir, file := filepath.Split(p) + parent := filepath.Base(strings.TrimRight(dir, "/")) + if parent == "." || parent == "/" || parent == "" { + return file + } + return parent + "/" + file +} + +func shorten(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" +} + +func itoa(n int64) string { return strconv.FormatInt(n, 10) } diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index ff9fe28..599e87a 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "testing" + "time" "git.vakhrushev.me/av/jellybit/internal/layout" "git.vakhrushev.me/av/jellybit/internal/qbt" @@ -15,6 +16,61 @@ import ( "git.vakhrushev.me/av/jellybit/internal/store" ) +// recordingNotifier ловит события пинга (Notify асинхронен — через канал). +type notifyEvent struct { + id int64 + ev NotifyEvent +} +type recordingNotifier struct{ ch chan notifyEvent } + +func (n *recordingNotifier) Notify(_ context.Context, id int64, ev NotifyEvent) { + n.ch <- notifyEvent{id, ev} +} + +func waitNotify(t *testing.T, n *recordingNotifier) notifyEvent { + t.Helper() + select { + case e := <-n.ch: + return e + case <-time.After(2 * time.Second): + t.Fatal("пинг не пришёл") + return notifyEvent{} + } +} + +func TestNotifier_FiresOnReview(t *testing.T) { + st := newMemStore() + st.put(completedDownload(1)) + qb := &fakeQbt{ + torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}, + files: []qbt.File{{Name: "Show/e1.mkv", Size: 1}}, + } + w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil) + n := &recordingNotifier{ch: make(chan notifyEvent, 4)} + w.SetNotifier(n) + + w.recognizeOne(context.Background(), 1) + + e := waitNotify(t, n) + if e.id != 1 || e.ev != EventReview { + t.Errorf("event = %+v, want {1 review}", e) + } +} + +func TestNotifier_FiresOnDone(t *testing.T) { + f := newApplyFixture(t, seriesResult().Plan) + n := &recordingNotifier{ch: make(chan notifyEvent, 4)} + f.w.SetNotifier(n) + + if err := f.w.Apply(context.Background(), 1); err != nil { + t.Fatalf("Apply: %v", err) + } + e := waitNotify(t, n) + if e.id != 1 || e.ev != EventDone { + t.Errorf("event = %+v, want {1 done}", e) + } +} + // memStore — полноценный in-memory store для тестов Ф3. type memStore struct { downloads map[int64]*store.Download diff --git a/internal/worker/worker.go b/internal/worker/worker.go index b5f0893..369242b 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -63,6 +63,19 @@ type Layouter interface { Undo(ctx context.Context, links []layout.Link) (int, error) } +// NotifyEvent — повод позвать пользователя. +type NotifyEvent string + +const ( + EventReview NotifyEvent = "review" // задача ждёт подтверждения + EventDone NotifyEvent = "done" // раскладка завершена +) + +// Notifier — исходящие пинги (Telegram). Вызывается неблокирующе. +type Notifier interface { + Notify(ctx context.Context, downloadID int64, event NotifyEvent) +} + // Config — параметры воркера. type Config struct { Category string @@ -81,11 +94,15 @@ type Worker struct { cfg Config log *slog.Logger - mu sync.Mutex // сериализует переходы (поллинг + команды) - now func() time.Time // подменяется в тестах - newID func() string // генератор apply_batch_id (подменяется в тестах) + mu sync.Mutex // сериализует переходы (поллинг + команды) + now func() time.Time // подменяется в тестах + newID func() string // генератор apply_batch_id (подменяется в тестах) + notifier Notifier // опц. исходящие пинги } +// SetNotifier подключает исходящие пинги (до запуска Run). +func (w *Worker) SetNotifier(n Notifier) { w.notifier = n } + // New собирает воркер. recognizer/layouter могут быть nil (Ф1 без Ф3-ступеней // распознавания и раскладки) — тогда completed-задачи не двигаются дальше. func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log *slog.Logger) *Worker { @@ -215,6 +232,17 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S } w.log.Info("state transition", "download_id", d.ID, "from", d.State, "to", state, "code", code) + + // Пинги — неблокирующе и в отдельном контексте: вызов уходит в сеть, а + // мы под w.mu (Notify читает состояние уже после освобождения замка). + if w.notifier != nil { + switch state { + case store.StateReview: + go w.notifier.Notify(context.Background(), d.ID, EventReview) + case store.StateDone: + go w.notifier.Notify(context.Background(), d.ID, EventDone) + } + } } // Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает