Добавил бот для Telegram
This commit is contained in:
@@ -10,6 +10,8 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"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/config"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/httpapi"
|
"git.vakhrushev.me/av/jellybit/internal/httpapi"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
"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/qbt"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/tgbot"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
"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)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
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)
|
go wrk.Run(ctx)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ auto_confidence_threshold = 0.85
|
|||||||
enabled = false
|
enabled = false
|
||||||
token = ""
|
token = ""
|
||||||
allowed_user_ids = [] # пусто = запрет всем (fail-closed)
|
allowed_user_ids = [] # пусто = запрет всем (fail-closed)
|
||||||
|
web_base_url = "" # напр. "http://umbar:8080" — для кнопки «открыть в вебе»
|
||||||
|
|
||||||
[http]
|
[http]
|
||||||
listen = ":8080"
|
listen = ":8080"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ go 1.26
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-chi/chi/v5 v5.1.0
|
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/jmoiron/sqlx v1.4.0
|
||||||
github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4
|
github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3
|
github.com/pelletier/go-toml/v2 v2.2.3
|
||||||
|
|||||||
@@ -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-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 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
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 h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
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=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ type Telegram struct {
|
|||||||
Enabled bool `toml:"enabled"`
|
Enabled bool `toml:"enabled"`
|
||||||
Token string `toml:"token"`
|
Token string `toml:"token"`
|
||||||
AllowedUserIDs []int64 `toml:"allowed_user_ids"`
|
AllowedUserIDs []int64 `toml:"allowed_user_ids"`
|
||||||
|
WebBaseURL string `toml:"web_base_url"` // для deep-link «открыть в вебе» (опц.)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTP — параметры веб-сервера.
|
// HTTP — параметры веб-сервера.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
// Package tgbot — Telegram-адаптер, парсер сообщений бота и исходящие пинги.
|
|
||||||
//
|
|
||||||
// Заглушка: реализация в фазе Ф5 (см. docs/specs/review-ux.md).
|
|
||||||
package tgbot
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||||
@@ -15,6 +16,61 @@ import (
|
|||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"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.
|
// memStore — полноценный in-memory store для тестов Ф3.
|
||||||
type memStore struct {
|
type memStore struct {
|
||||||
downloads map[int64]*store.Download
|
downloads map[int64]*store.Download
|
||||||
|
|||||||
@@ -63,6 +63,19 @@ type Layouter interface {
|
|||||||
Undo(ctx context.Context, links []layout.Link) (int, error)
|
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 — параметры воркера.
|
// Config — параметры воркера.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Category string
|
Category string
|
||||||
@@ -84,8 +97,12 @@ type Worker struct {
|
|||||||
mu sync.Mutex // сериализует переходы (поллинг + команды)
|
mu sync.Mutex // сериализует переходы (поллинг + команды)
|
||||||
now func() time.Time // подменяется в тестах
|
now func() time.Time // подменяется в тестах
|
||||||
newID func() string // генератор apply_batch_id (подменяется в тестах)
|
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-ступеней
|
// New собирает воркер. recognizer/layouter могут быть nil (Ф1 без Ф3-ступеней
|
||||||
// распознавания и раскладки) — тогда completed-задачи не двигаются дальше.
|
// распознавания и раскладки) — тогда completed-задачи не двигаются дальше.
|
||||||
func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log *slog.Logger) *Worker {
|
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",
|
w.log.Info("state transition",
|
||||||
"download_id", d.ID, "from", d.State, "to", state, "code", code)
|
"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 не трогаем — он продолжает
|
// Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает
|
||||||
|
|||||||
Reference in New Issue
Block a user