Files
jellybit/internal/tgbot/bot.go
T
avandClaude Opus 4.8 0d263270cb Быстрый приём: сохранение в catched, добавление в qBittorrent — шаг worker'а
Приём (Ingest) стал быстрым: синхронно только парс magnet, синтез контекста из
полей ссылки, атомарный дедуп и запись загрузки в новое состояние `catched` —
ответ клиенту сразу. Медленный вывод имени (LLM) и добавление в qBittorrent
вынесены в асинхронный шаг машины состояний, который двигает worker.

- store: состояние `catched` (нетерминальное, активная группа); атомарный
  переход PromoteCatched (catched → downloading + display_name) с гардом
  state='catched' (ре-валидация после сетевых вызовов вне блокировки)
- ingest: убраны namer/qbt из пути приёма; пишем `catched`, отвечаем сразу
- worker.processCatched: вне w.mu выводит имя и qbt.Add, под w.mu — короткий
  переход; сбой add оставляет catched (ретрай тиком); предохранитель
  catch_timeout → failed(qbit_add)+notify; catched исключён из проверок пропажи
- config: worker.catch_timeout (дефолт 10m)
- веб-UI: бейдж catched, активная группа, самозавершающийся htmx-поллинг
  карточки/страницы до перехода в downloading; Telegram-текст без сырого catched
- OpenSpec: дельты ingest/download-tracking/web-ui влиты в спеки, change
  заархивирован

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 21:29:28 +03:00

326 lines
10 KiB
Go

package tgbot
import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
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()
}
// 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, ждущий подсказку
}
// 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{},
}
}
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, 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.", nil)
return
}
res, err := b.ingestor.Ingest(ctx, ingest.Request{Source: source, Context: context})
if err != nil {
// res.DownloadID непуст, если сбой после создания задачи (напр. qbit);
// при раннем разборе источника id ещё нет — даём дружелюбный текст без
// него (детали всё равно в логах на доменной границе).
b.send(m.Chat.ID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
return
}
msg := fmt.Sprintf("Принято #%s — добавляю в qBittorrent.", res.DownloadID)
if res.Deduplicated {
msg = fmt.Sprintf("Уже в работе #%s.", res.DownloadID)
}
b.send(m.Chat.ID, msg+"\nПозову, когда нужно подтверждение.", nil)
}
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 == "" {
// Пустой/невалидный 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 {
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
}