Добавил бот для Telegram
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user