301 lines
8.6 KiB
Go
301 lines
8.6 KiB
Go
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
|
|
}
|