Добавил бот для Telegram

This commit is contained in:
2026-06-14 15:55:33 +03:00
parent 7419bcb125
commit 08b707f602
13 changed files with 1012 additions and 7 deletions
+299
View File
@@ -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
}