Кнопка «Тип» была только в Telegram (доменная SetType = жёсткий override media_type + хинт + перераспознавание), в вебе её нет. Это создавало расхождение поверхностей и внутреннюю противоречивость спеки review. Решение: смена типа — редкий случай, для него достаточно «Уточнить» (перераспознавание с явным указанием типа). Явный переключатель не нужен ни на одной поверхности. Снято: Telegram-кнопка и callback type:, worker.SetType, ставший мёртвым override-плумбинг media_type (ovrMediaType, ветка applyOverrides, хелпер oppositeType) и стейл-хвосты в тестах httpapi. Спека review — три MODIFIED требования (запрет на все поверхности, «фиксация типа» убрана из команд и из быстрых действий Telegram, иллюстрация override заменена на закрепление источника). Синхронизирован docs/specs/review-ux.md. Change заархивирован: openspec/changes/archive/2026-07-18-review-remove-type-switch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
543 lines
22 KiB
Go
543 lines
22 KiB
Go
package tgbot
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
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/layout"
|
||
"git.vakhrushev.me/av/jellybit/internal/logging"
|
||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||
"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()
|
||
// GetFileDirectURL — прямой URL файла Telegram по file_id (для скачивания
|
||
// присланного .torrent-документа).
|
||
GetFileDirectURL(fileID string) (string, error)
|
||
}
|
||
|
||
// 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
|
||
ChooseCandidate(ctx context.Context, id, candidateID string) error
|
||
Defer(ctx context.Context, id string) error
|
||
Cancel(ctx context.Context, id string) error
|
||
Retry(ctx context.Context, id string) error
|
||
Delete(ctx context.Context, id string) error
|
||
Dismiss(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, ждущий подсказку
|
||
|
||
httpClient *http.Client // для скачивания .torrent-документов Telegram
|
||
}
|
||
|
||
// 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{},
|
||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||
}
|
||
}
|
||
|
||
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 {
|
||
// Канал закрыт не по нашей отмене (ctx ещё жив) — библиотека
|
||
// прекратила приём обновлений: иначе бот молча перестал бы
|
||
// реагировать без следа в логах.
|
||
if ctx.Err() == nil {
|
||
b.log.Error("telegram updates channel closed unexpectedly")
|
||
}
|
||
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
|
||
}
|
||
|
||
// Документ (.torrent) — до ветки pending/текста: у документа m.Text пуст,
|
||
// иначе pending-подсказка «съела» бы его как пустую строку. Контекст — из
|
||
// подписи (m.Caption).
|
||
if m.Document != nil {
|
||
b.handleDocument(ctx, m)
|
||
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 {
|
||
// Ошибку логирует доменная граница (worker.Refine); транспорт лишь
|
||
// показывает пользователю (logging.md: «транспорты не логируют»).
|
||
b.send(m.Chat.ID, opErr("Не удалось обработать подсказку", id), nil)
|
||
return
|
||
}
|
||
b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+idCode(id)+"…", nil)
|
||
return
|
||
}
|
||
|
||
if text == "/start" || text == "/help" {
|
||
b.send(m.Chat.ID, helpText, nil)
|
||
return
|
||
}
|
||
|
||
source, context, ok := ParseMessage(text)
|
||
if !ok {
|
||
// Невалидный ввод — норма (пользователь получит отказ): DEBUG, чтобы
|
||
// при разборе «почему не приняло» отказ был виден в логах.
|
||
b.log.Debug("telegram source not recognized", "chat_id", m.Chat.ID)
|
||
b.send(m.Chat.ID, "Не вижу magnet-ссылки. Перешлите сообщение торрент-бота, пришлите magnet или .torrent-файл.", nil)
|
||
return
|
||
}
|
||
b.ingestAndReply(ctx, m.Chat.ID, ingest.Request{Source: source, Context: context})
|
||
}
|
||
|
||
// handleDocument принимает присланный .torrent-документ: проверяет тип/размер,
|
||
// скачивает байты через Bot API и подаёт в приём (подпись — контекстом).
|
||
func (b *Bot) handleDocument(ctx context.Context, m *tgbotapi.Message) {
|
||
doc := m.Document
|
||
if !isTorrentDoc(doc) {
|
||
b.log.Debug("telegram document rejected", "chat_id", m.Chat.ID, "reason", "not-torrent", "mime", doc.MimeType)
|
||
b.send(m.Chat.ID, "Это не .torrent-файл. Пришлите magnet-ссылку или .torrent.", nil)
|
||
return
|
||
}
|
||
if doc.FileSize > 0 && doc.FileSize > ingest.MaxTorrentSize {
|
||
b.log.Debug("telegram document rejected", "chat_id", m.Chat.ID, "reason", "too-large", "size", doc.FileSize)
|
||
b.send(m.Chat.ID, "Файл слишком большой для .torrent.", nil)
|
||
return
|
||
}
|
||
data, err := b.downloadFile(ctx, doc.FileID)
|
||
if err != nil {
|
||
b.log.Warn("telegram document download failed", "error", err)
|
||
b.send(m.Chat.ID, "Не удалось скачать файл из Telegram, попробуйте ещё раз.", nil)
|
||
return
|
||
}
|
||
b.ingestAndReply(ctx, m.Chat.ID, ingest.Request{
|
||
TorrentData: data,
|
||
TorrentName: doc.FileName, // фолбек для source_ref, если у раздачи нет имени
|
||
Context: m.Caption,
|
||
})
|
||
}
|
||
|
||
// isTorrentDoc — документ выглядит как .torrent (по mime или расширению).
|
||
func isTorrentDoc(doc *tgbotapi.Document) bool {
|
||
if doc == nil {
|
||
return false
|
||
}
|
||
if doc.MimeType == "application/x-bittorrent" {
|
||
return true
|
||
}
|
||
return strings.HasSuffix(strings.ToLower(doc.FileName), ".torrent")
|
||
}
|
||
|
||
// downloadFile скачивает файл Telegram по file_id (прямой URL + HTTP GET),
|
||
// ограничивая объём MaxTorrentSize.
|
||
//
|
||
// ВАЖНО: прямой URL файла Telegram содержит токен бота
|
||
// (…/file/bot<TOKEN>/<path>). Ошибки транспорта (*url.Error) встраивают этот
|
||
// URL в текст — их нельзя возвращать/логировать как есть. logging.SanitizeErr
|
||
// оставляет только первопричину без URL, чтобы токен не утёк в логи (см.
|
||
// logging.md). GetFileDirectURL тоже ходит в Bot API (…/bot<TOKEN>/getFile) —
|
||
// его ошибку санитизируем так же.
|
||
func (b *Bot) downloadFile(ctx context.Context, fileID string) ([]byte, error) {
|
||
fileURL, err := b.api.GetFileDirectURL(fileID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("file url: %w", logging.SanitizeErr(err))
|
||
}
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("telegram file request: %w", logging.SanitizeErr(err))
|
||
}
|
||
resp, err := b.httpClient.Do(req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("telegram file GET: %w", logging.SanitizeErr(err))
|
||
}
|
||
defer func() { _ = resp.Body.Close() }()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("telegram file GET: status %d", resp.StatusCode)
|
||
}
|
||
// LimitReader на 1 байт больше лимита — чтобы отличить «ровно лимит» от
|
||
// «больше лимита».
|
||
data, err := io.ReadAll(io.LimitReader(resp.Body, ingest.MaxTorrentSize+1))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(data) > ingest.MaxTorrentSize {
|
||
return nil, fmt.Errorf("telegram file exceeds %d bytes", ingest.MaxTorrentSize)
|
||
}
|
||
return data, nil
|
||
}
|
||
|
||
// ingestAndReply принимает загрузку и отвечает пользователю единообразно для
|
||
// всех источников (текст/magnet и .torrent-документ).
|
||
func (b *Bot) ingestAndReply(ctx context.Context, chatID int64, req ingest.Request) {
|
||
res, err := b.ingestor.Ingest(ctx, req)
|
||
if err != nil {
|
||
// res.DownloadID непуст, если сбой после создания задачи (напр. qbit);
|
||
// при раннем разборе источника id ещё нет — даём дружелюбный текст без
|
||
// него (детали всё равно в логах на доменной границе).
|
||
b.send(chatID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
|
||
return
|
||
}
|
||
if res.Deduplicated {
|
||
// Дубль: новую загрузку не заводим. Различаем активную задачу и «спящую»
|
||
// desync-запись (target_missing/orphaned) — у последней действие вперёд
|
||
// не «ждите», а «привяжите заново или закройте».
|
||
switch res.State {
|
||
case store.StateTargetMissing:
|
||
// Источник жив, цель удалена — из target_missing доступна перепривязка.
|
||
b.send(chatID, fmt.Sprintf("♻️ Этот торрент уже есть как запись #%s без цели — привяжите заново или закройте её.", idCode(res.DownloadID)), nil)
|
||
case store.StateOrphaned:
|
||
// Источник пропал: relink из orphaned нет, рабочий путь — закрыть и
|
||
// добавить заново (тогда приём заведёт свежую загрузку).
|
||
b.send(chatID, fmt.Sprintf("♻️ Этот торрент уже есть как осиротевшая запись #%s — закройте её, затем добавьте заново.", idCode(res.DownloadID)), nil)
|
||
default:
|
||
b.send(chatID, fmt.Sprintf("♻️ Дубль уже активной загрузки #%s — добавление отменено.", idCode(res.DownloadID)), nil)
|
||
}
|
||
return
|
||
}
|
||
b.send(chatID, fmt.Sprintf("Принято #%s — добавляю в qBittorrent.\nПозову, когда нужно подтверждение.", idCode(res.DownloadID)), nil)
|
||
}
|
||
|
||
const helpText = `jellybit-бот: пришлите magnet-ссылку, .torrent-файл или перешлите сообщение торрент-бота — поставлю на закачку.
|
||
Когда раздача скачается и потребуется подтверждение раскладки, позову кнопками.`
|
||
|
||
// --- Колбэки (кнопки) ---
|
||
|
||
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 "delete":
|
||
// Первый шаг: показываем подтверждение (необратимое действие — снос
|
||
// раздачи+файлов и хардлинков). Домен не трогаем, меняем клавиатуру.
|
||
b.answer(cq.ID, "")
|
||
b.editMarkup(chatID, msgID, b.deleteConfirmKeyboard(id))
|
||
return
|
||
case "delete_cancel":
|
||
// Отмена подтверждения — возвращаем обычную карточку.
|
||
b.answer(cq.ID, "Отменено")
|
||
b.refreshCard(ctx, chatID, msgID, id)
|
||
return
|
||
case "delete_confirm":
|
||
err = b.reviewer.Delete(ctx, id)
|
||
note = "Удаляю…"
|
||
case "dismiss":
|
||
// Первый шаг: подтверждение. Стоп-кран лишь меняет статус (файлы/раздачу
|
||
// не трогает), но убирает запись из активной — подтверждаем сознательно.
|
||
b.answer(cq.ID, "")
|
||
b.editMarkup(chatID, msgID, b.dismissConfirmKeyboard(id))
|
||
return
|
||
case "dismiss_cancel":
|
||
b.answer(cq.ID, "Отменено")
|
||
b.refreshCard(ctx, chatID, msgID, id)
|
||
return
|
||
case "dismiss_confirm":
|
||
err = b.reviewer.Dismiss(ctx, id)
|
||
note = "Закрываю…"
|
||
case "refine":
|
||
b.setPending(chatID, id)
|
||
b.answer(cq.ID, "Жду подсказку")
|
||
b.send(chatID, "Ответьте сообщением с подсказкой для #"+idCode(id)+".", nil)
|
||
return
|
||
case "sources":
|
||
// Первый шаг выбора базы: перечитываем задачу ради свежего списка
|
||
// кандидатов и разворачиваем клавиатуру списка. Домен не трогаем.
|
||
rd, rerr := b.reviewer.ReviewData(ctx, id)
|
||
if rerr != nil {
|
||
b.log.Warn("telegram sources review data failed", "download_id", id, "error", rerr)
|
||
b.answer(cq.ID, "Ошибка")
|
||
return
|
||
}
|
||
b.answer(cq.ID, "")
|
||
b.editMarkup(chatID, msgID, b.candidatesKeyboard(rd))
|
||
return
|
||
case "srcback":
|
||
// Возврат из списка кандидатов к обычной карточке (домен не трогаем).
|
||
b.answer(cq.ID, "Отменено")
|
||
b.refreshCard(ctx, chatID, msgID, id)
|
||
return
|
||
case "pick":
|
||
// Выбор кандидата: val — id кандидата из callback_data (недоверенный
|
||
// вход), валидируем как ULID на границе до доменного вызова.
|
||
candidateID, perr := ident.Parse(val)
|
||
if perr != nil {
|
||
b.answer(cq.ID, "Кнопка устарела — откройте задачу в вебе")
|
||
return
|
||
}
|
||
err = b.reviewer.ChooseCandidate(ctx, id, candidateID)
|
||
note = "Источник выбран"
|
||
default:
|
||
b.answer(cq.ID, "")
|
||
return
|
||
}
|
||
|
||
if err != nil {
|
||
if errors.Is(err, worker.ErrNotReady) {
|
||
// Источник ещё качается — actionable причина, показываем конкретно.
|
||
b.answer(cq.ID, "Торрент ещё качается")
|
||
b.send(chatID, opErr("Торрент ещё качается — дождитесь докачки", id), nil)
|
||
return
|
||
}
|
||
if errors.Is(err, layout.ErrCollision) {
|
||
// Коллизия цели: задача штатно ушла в review с причиной — показываем
|
||
// конкретно и обновляем карточку (в ней теперь причина коллизии).
|
||
b.answer(cq.ID, "Коллизия цели")
|
||
b.send(chatID, opErr("Целевой файл уже существует — задача отправлена в ревью", id), nil)
|
||
b.refreshCard(ctx, chatID, msgID, id)
|
||
return
|
||
}
|
||
// Ошибку логирует доменная граница (соответствующая команда worker);
|
||
// транспорт лишь переводит её в ответ пользователю (logging.md).
|
||
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)
|
||
}
|
||
// Тот же HTML parse mode, что и в send(): текст карточки содержит <code>-id и
|
||
// экранированные внешние фрагменты — правка на месте должна их так же трактовать.
|
||
edit.ParseMode = tgbotapi.ModeHTML
|
||
if _, err := b.api.Send(edit); err != nil {
|
||
b.log.Warn("telegram edit card failed", "download_id", id, "error", logging.SanitizeErr(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)
|
||
// HTML parse mode — ради моноширинного <code> у download id (tap-to-copy).
|
||
// Разметка становится значимой для ВСЕХ сообщений: внешний текст (имена,
|
||
// пути, причины, ошибки) обязан быть экранирован через esc() при рендере.
|
||
msg.ParseMode = tgbotapi.ModeHTML
|
||
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", logging.SanitizeErr(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", logging.SanitizeErr(err))
|
||
}
|
||
}
|
||
|
||
// editMarkup подменяет только клавиатуру сообщения на месте (текст не трогаем).
|
||
// Используется для шага подтверждения удаления.
|
||
func (b *Bot) editMarkup(chatID int64, msgID int, kb *tgbotapi.InlineKeyboardMarkup) {
|
||
if kb == nil {
|
||
return
|
||
}
|
||
if _, err := b.api.Send(tgbotapi.NewEditMessageReplyMarkup(chatID, msgID, *kb)); err != nil {
|
||
b.log.Warn("telegram edit markup failed", "chat_id", chatID, "error", logging.SanitizeErr(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 != "" {
|
||
// download_id моноширинным для tap-to-copy; msg — контролируемый литерал
|
||
// вызывающего (без спецсимволов разметки), потому не экранируется.
|
||
return fmt.Sprintf("%s (download_id=%s).", msg, idCode(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
|
||
}
|