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 { b.log.Warn("telegram: refresh card failed", "download_id", id, "err", 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, "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 }