Идентичность на ULID: download_infohash, guarded-дедуп, миграция (ulid-identity)
Все сущности переехали с INTEGER AUTOINCREMENT на TEXT ULID (lowercase, internal/ident — единая точка генерации и разбора; oklog/ulid). Инфохэши загрузки — множество (download_infohash, v1/v2 гибридных торрентов): дедуп и сопоставление в поллинге по любому из хешей, magnet-парсер отдаёт оба хеша гибридной ссылки, усечённый v2-хеш v2-only раздач не хранится. Инвариант «не более одной активной загрузки на infohash» вместо снятого unique-индекса держат guarded-методы store в одной write-транзакции (_txlock=immediate): CreateDownloadIfNoActive (приём/adopt, с доносом недостающих хешей), ActivateIfNoOtherActive (retry/recovery/relink, отказ до побочных эффектов), guarded AddInfohashes; SetDownloadState отклоняет терминал→активное как механический бэкстоп. Миграция 0006 — первая Go-миграция goose: пересоздание таблиц при включённых FK, backfill ULID с timestamp из created_at (хронология id сохранена), разнос infohash, удаление idempotency_key. BREAKING: формат id в URL/логах/Telegram, REST-поля id (string) и infohashes (список). Новая конвенция docs/conventions/database.md (без числовых PK), корреляция в логах grep'ом по голому ULID, ER-схема обновлена. Спеки: новая capability identity, MODIFIED в state-reconciliation; change заархивирован. Пройдены ревью дизайна и кода (по 8 углов), все находки исправлены с регрессионными тестами. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+31
-28
@@ -4,12 +4,12 @@ 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/ident"
|
||||
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||
)
|
||||
@@ -30,13 +30,13 @@ type Ingestor interface {
|
||||
|
||||
// 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
|
||||
Retry(ctx context.Context, id int64) error
|
||||
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 — параметры бота.
|
||||
@@ -54,8 +54,8 @@ type Bot struct {
|
||||
webBase string
|
||||
log *slog.Logger
|
||||
|
||||
mu sync.Mutex // защищает pending
|
||||
pending map[int64]int64 // chatID → downloadID, ждущий подсказку
|
||||
mu sync.Mutex // защищает pending
|
||||
pending map[int64]string // chatID → downloadID, ждущий подсказку
|
||||
}
|
||||
|
||||
// New собирает бота поверх клиента Telegram.
|
||||
@@ -71,7 +71,7 @@ func New(client teleAPI, ing Ingestor, rev Reviewer, cfg Config, log *slog.Logge
|
||||
allowed: allowed,
|
||||
webBase: strings.TrimRight(cfg.WebBaseURL, "/"),
|
||||
log: log,
|
||||
pending: map[int64]int64{},
|
||||
pending: map[int64]string{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
|
||||
b.send(m.Chat.ID, opErr("Не удалось обработать подсказку", id), nil)
|
||||
return
|
||||
}
|
||||
b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+strconv.FormatInt(id, 10)+"…", nil)
|
||||
b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+id+"…", nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -151,9 +151,9 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
|
||||
b.send(m.Chat.ID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
|
||||
return
|
||||
}
|
||||
msg := fmt.Sprintf("Принято #%d — %s.", res.DownloadID, res.State)
|
||||
msg := fmt.Sprintf("Принято #%s — %s.", res.DownloadID, res.State)
|
||||
if res.Deduplicated {
|
||||
msg = fmt.Sprintf("Уже в работе #%d — %s.", res.DownloadID, res.State)
|
||||
msg = fmt.Sprintf("Уже в работе #%s — %s.", res.DownloadID, res.State)
|
||||
}
|
||||
b.send(m.Chat.ID, msg+"\nПозову, когда нужно подтверждение.", nil)
|
||||
}
|
||||
@@ -173,8 +173,10 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
||||
}
|
||||
|
||||
action, id, val := parseCallback(cq.Data)
|
||||
if id == 0 {
|
||||
b.answer(cq.ID, "")
|
||||
if id == "" {
|
||||
// Пустой/невалидный id — в т.ч. старые числовые кнопки, оставшиеся в
|
||||
// истории чата до перехода на ULID: отвечаем понятно, а не молчим.
|
||||
b.answer(cq.ID, "Кнопка устарела — откройте задачу в вебе")
|
||||
return
|
||||
}
|
||||
chatID := cq.Message.Chat.ID
|
||||
@@ -201,7 +203,7 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
||||
case "refine":
|
||||
b.setPending(chatID, id)
|
||||
b.answer(cq.ID, "Жду подсказку")
|
||||
b.send(chatID, "Ответьте сообщением с подсказкой для #"+strconv.FormatInt(id, 10)+".", nil)
|
||||
b.send(chatID, "Ответьте сообщением с подсказкой для #"+id+".", nil)
|
||||
return
|
||||
default:
|
||||
b.answer(cq.ID, "")
|
||||
@@ -218,7 +220,7 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
|
||||
}
|
||||
|
||||
// refreshCard перечитывает задачу и обновляет карточку на месте.
|
||||
func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id int64) {
|
||||
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)
|
||||
@@ -239,7 +241,7 @@ func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id int64
|
||||
// --- Notifier (worker.Notifier) ---
|
||||
|
||||
// Notify шлёт карточку подтверждения/готовности всем доверенным пользователям.
|
||||
func (b *Bot) Notify(ctx context.Context, downloadID int64, event worker.NotifyEvent) {
|
||||
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)
|
||||
@@ -281,13 +283,13 @@ func (b *Bot) answer(callbackID, text string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) setPending(chatID, id int64) {
|
||||
func (b *Bot) setPending(chatID int64, id string) {
|
||||
b.mu.Lock()
|
||||
b.pending[chatID] = id
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *Bot) takePending(chatID int64) (int64, bool) {
|
||||
func (b *Bot) takePending(chatID int64) (string, bool) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
id, ok := b.pending[chatID]
|
||||
@@ -300,20 +302,21 @@ func (b *Bot) takePending(chatID int64) (int64, bool) {
|
||||
// opErr — сообщение публичного канала Telegram по доменной ошибке: нейтральный
|
||||
// текст + download_id для корреляции с логами (полная ошибка уже там, на
|
||||
// доменной границе). Сырой err.Error() пользователю не показываем. Если id
|
||||
// операции ещё нет (downloadID == 0) — дружелюбный текст без ключа.
|
||||
func opErr(msg string, downloadID int64) string {
|
||||
if downloadID > 0 {
|
||||
return fmt.Sprintf("%s (download_id=%d).", msg, downloadID)
|
||||
// операции ещё нет (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]]".
|
||||
func parseCallback(data string) (action string, id int64, value string) {
|
||||
// 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, _ = strconv.ParseInt(parts[1], 10, 64)
|
||||
id, _ = ident.Parse(parts[1])
|
||||
}
|
||||
if len(parts) > 2 {
|
||||
value = parts[2]
|
||||
|
||||
+59
-40
@@ -60,52 +60,55 @@ func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Res
|
||||
|
||||
type fakeReviewer struct {
|
||||
data *worker.ReviewData
|
||||
applied []int64
|
||||
refined map[int64]string
|
||||
typed map[int64]string
|
||||
deferred []int64
|
||||
canceled []int64
|
||||
retried []int64
|
||||
applied []string
|
||||
refined map[string]string
|
||||
typed map[string]string
|
||||
deferred []string
|
||||
canceled []string
|
||||
retried []string
|
||||
}
|
||||
|
||||
func (f *fakeReviewer) ReviewData(context.Context, int64) (*worker.ReviewData, error) {
|
||||
func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
|
||||
return f.data, nil
|
||||
}
|
||||
func (f *fakeReviewer) Apply(_ context.Context, id int64) error {
|
||||
func (f *fakeReviewer) Apply(_ context.Context, id string) error {
|
||||
f.applied = append(f.applied, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) Refine(_ context.Context, id int64, hint string) error {
|
||||
func (f *fakeReviewer) Refine(_ context.Context, id string, hint string) error {
|
||||
if f.refined == nil {
|
||||
f.refined = map[int64]string{}
|
||||
f.refined = map[string]string{}
|
||||
}
|
||||
f.refined[id] = hint
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) SetType(_ context.Context, id int64, t string) error {
|
||||
func (f *fakeReviewer) SetType(_ context.Context, id string, t string) error {
|
||||
if f.typed == nil {
|
||||
f.typed = map[int64]string{}
|
||||
f.typed = map[string]string{}
|
||||
}
|
||||
f.typed[id] = t
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) Defer(_ context.Context, id int64) error {
|
||||
func (f *fakeReviewer) Defer(_ context.Context, id string) error {
|
||||
f.deferred = append(f.deferred, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) Cancel(_ context.Context, id int64) error {
|
||||
func (f *fakeReviewer) Cancel(_ context.Context, id string) error {
|
||||
f.canceled = append(f.canceled, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) Retry(_ context.Context, id int64) error {
|
||||
func (f *fakeReviewer) Retry(_ context.Context, id string) error {
|
||||
f.retried = append(f.retried, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// tid — валидный lowercase-ULID (callback-data валидируется как ULID).
|
||||
const tid = "01arz3ndektsv4rrffq69g5fav"
|
||||
|
||||
func reviewData(state store.State) *worker.ReviewData {
|
||||
s, e := 2, 1
|
||||
return &worker.ReviewData{
|
||||
Download: store.Download{ID: 5, State: state, Context: "Фарго, второй сезон", SourceRef: "magnet:?x"},
|
||||
Download: store.Download{ID: tid, State: state, Context: "Фарго, второй сезон", SourceRef: "magnet:?x"},
|
||||
Recognition: &store.Recognition{
|
||||
Provider: store.NullString("tvdb"), ProviderID: store.NullString("269613"),
|
||||
Reasons: `["неполный пак"]`,
|
||||
@@ -123,7 +126,7 @@ func reviewData(state store.State) *worker.ReviewData {
|
||||
func newTestBot(t *testing.T, allowed []int64) (*Bot, *fakeAPI, *fakeIngestor, *fakeReviewer) {
|
||||
t.Helper()
|
||||
api := &fakeAPI{}
|
||||
ing := &fakeIngestor{res: ingest.Result{DownloadID: 5, State: store.StateDownloading}}
|
||||
ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateDownloading}}
|
||||
rev := &fakeReviewer{data: reviewData(store.StateReview)}
|
||||
b := New(api, ing, rev, Config{AllowedUserIDs: allowed, WebBaseURL: "http://host:8080"},
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
@@ -146,7 +149,7 @@ func TestBot_IngestFromMagnet(t *testing.T) {
|
||||
if ing.lastReq.Context != "крутой сериал" {
|
||||
t.Errorf("context = %q", ing.lastReq.Context)
|
||||
}
|
||||
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Принято #5") {
|
||||
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Принято #"+tid) {
|
||||
t.Errorf("sent = %+v", api.sent)
|
||||
}
|
||||
}
|
||||
@@ -174,10 +177,10 @@ func TestBot_NoMagnet(t *testing.T) {
|
||||
func TestBot_RefineViaReply(t *testing.T) {
|
||||
b, _, _, rev := newTestBot(t, []int64{7})
|
||||
// Кнопка «Уточнить» поставила ожидание подсказки для чата 7.
|
||||
b.setPending(7, 5)
|
||||
b.setPending(7, tid)
|
||||
b.handleMessage(context.Background(), msgFrom(7, "это второй сезон"))
|
||||
|
||||
if rev.refined[5] != "это второй сезон" {
|
||||
if rev.refined[tid] != "это второй сезон" {
|
||||
t.Errorf("refine = %v", rev.refined)
|
||||
}
|
||||
}
|
||||
@@ -191,9 +194,9 @@ func cbFrom(userID int64, data string) *tgbotapi.CallbackQuery {
|
||||
|
||||
func TestBot_CallbackApply(t *testing.T) {
|
||||
b, api, _, rev := newTestBot(t, []int64{7})
|
||||
b.handleCallback(context.Background(), cbFrom(7, "apply:5"))
|
||||
b.handleCallback(context.Background(), cbFrom(7, "apply:"+tid))
|
||||
|
||||
if len(rev.applied) != 1 || rev.applied[0] != 5 {
|
||||
if len(rev.applied) != 1 || rev.applied[0] != tid {
|
||||
t.Errorf("applied = %v", rev.applied)
|
||||
}
|
||||
if len(api.answers) != 1 {
|
||||
@@ -206,18 +209,18 @@ func TestBot_CallbackApply(t *testing.T) {
|
||||
|
||||
func TestBot_CallbackType(t *testing.T) {
|
||||
b, _, _, rev := newTestBot(t, []int64{7})
|
||||
b.handleCallback(context.Background(), cbFrom(7, "type:5:movie"))
|
||||
if rev.typed[5] != "movie" {
|
||||
b.handleCallback(context.Background(), cbFrom(7, "type:"+tid+":movie"))
|
||||
if rev.typed[tid] != "movie" {
|
||||
t.Errorf("typed = %v", rev.typed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBot_CallbackRefineSetsPending(t *testing.T) {
|
||||
b, api, _, _ := newTestBot(t, []int64{7})
|
||||
b.handleCallback(context.Background(), cbFrom(7, "refine:5"))
|
||||
b.handleCallback(context.Background(), cbFrom(7, "refine:"+tid))
|
||||
|
||||
if id, ok := b.takePending(7); !ok || id != 5 {
|
||||
t.Errorf("pending = %d,%v", id, ok)
|
||||
if id, ok := b.takePending(7); !ok || id != tid {
|
||||
t.Errorf("pending = %s,%v", id, ok)
|
||||
}
|
||||
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "подсказкой") {
|
||||
t.Errorf("sent = %+v", api.sent)
|
||||
@@ -226,7 +229,7 @@ func TestBot_CallbackRefineSetsPending(t *testing.T) {
|
||||
|
||||
func TestBot_CallbackDeniesUnknown(t *testing.T) {
|
||||
b, _, _, rev := newTestBot(t, []int64{7})
|
||||
b.handleCallback(context.Background(), cbFrom(999, "apply:5"))
|
||||
b.handleCallback(context.Background(), cbFrom(999, "apply:"+tid))
|
||||
if len(rev.applied) != 0 {
|
||||
t.Error("чужой колбэк не должен исполняться")
|
||||
}
|
||||
@@ -234,12 +237,12 @@ func TestBot_CallbackDeniesUnknown(t *testing.T) {
|
||||
|
||||
func TestBot_NotifyReview(t *testing.T) {
|
||||
b, api, _, _ := newTestBot(t, []int64{7, 8})
|
||||
b.Notify(context.Background(), 5, worker.EventReview)
|
||||
b.Notify(context.Background(), tid, worker.EventReview)
|
||||
|
||||
if len(api.sent) != 2 { // обоим доверенным
|
||||
t.Fatalf("sent to %d chats, want 2", len(api.sent))
|
||||
}
|
||||
if !strings.Contains(api.sent[0].text, "Нужно подтверждение #5") {
|
||||
if !strings.Contains(api.sent[0].text, "Нужно подтверждение #"+tid) {
|
||||
t.Errorf("card text = %q", api.sent[0].text)
|
||||
}
|
||||
if !api.sent[0].hasKB {
|
||||
@@ -250,7 +253,7 @@ func TestBot_NotifyReview(t *testing.T) {
|
||||
func TestBot_NotifyDone(t *testing.T) {
|
||||
b, api, _, rev := newTestBot(t, []int64{7})
|
||||
rev.data = reviewData(store.StateDone)
|
||||
b.Notify(context.Background(), 5, worker.EventDone)
|
||||
b.Notify(context.Background(), tid, worker.EventDone)
|
||||
|
||||
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Готово") {
|
||||
t.Errorf("sent = %+v", api.sent)
|
||||
@@ -260,7 +263,7 @@ func TestBot_NotifyDone(t *testing.T) {
|
||||
func TestBot_NotifyFailed(t *testing.T) {
|
||||
b, api, _, rev := newTestBot(t, []int64{7})
|
||||
rev.data = reviewData(store.StateFailed)
|
||||
b.Notify(context.Background(), 5, worker.EventFailed)
|
||||
b.Notify(context.Background(), tid, worker.EventFailed)
|
||||
|
||||
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "не удалась") {
|
||||
t.Errorf("sent = %+v", api.sent)
|
||||
@@ -273,20 +276,36 @@ func TestBot_NotifyFailed(t *testing.T) {
|
||||
func TestBot_CallbackRetry(t *testing.T) {
|
||||
b, _, _, rev := newTestBot(t, []int64{7})
|
||||
rev.data = reviewData(store.StateFailed)
|
||||
b.handleCallback(context.Background(), cbFrom(7, "retry:5"))
|
||||
b.handleCallback(context.Background(), cbFrom(7, "retry:"+tid))
|
||||
|
||||
if len(rev.retried) != 1 || rev.retried[0] != 5 {
|
||||
if len(rev.retried) != 1 || rev.retried[0] != tid {
|
||||
t.Errorf("retried = %v", rev.retried)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCallback(t *testing.T) {
|
||||
a, id, v := parseCallback("type:5:series")
|
||||
if a != "type" || id != 5 || v != "series" {
|
||||
t.Errorf("got %q %d %q", a, id, v)
|
||||
a, id, v := parseCallback("type:" + tid + ":series")
|
||||
if a != "type" || id != tid || v != "series" {
|
||||
t.Errorf("got %q %q %q", a, id, v)
|
||||
}
|
||||
a, id, v = parseCallback("apply:9")
|
||||
if a != "apply" || id != 9 || v != "" {
|
||||
t.Errorf("got %q %d %q", a, id, v)
|
||||
a, id, v = parseCallback("apply:" + tid)
|
||||
if a != "apply" || id != tid || v != "" {
|
||||
t.Errorf("got %q %q %q", a, id, v)
|
||||
}
|
||||
// Устаревшая числовая кнопка (до перехода на ULID) → id пуст.
|
||||
if _, id, _ := parseCallback("apply:5"); id != "" {
|
||||
t.Errorf("legacy numeric id must be rejected, got %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
// Нажатие устаревшей кнопки со старым числовым id получает понятный ответ.
|
||||
func TestBot_CallbackStaleButton(t *testing.T) {
|
||||
b, api, _, rev := newTestBot(t, []int64{7})
|
||||
b.handleCallback(context.Background(), cbFrom(7, "apply:5"))
|
||||
if len(rev.applied) != 0 {
|
||||
t.Error("устаревшая кнопка не должна исполняться")
|
||||
}
|
||||
if len(api.answers) != 1 || !strings.Contains(api.answers[0], "устарела") {
|
||||
t.Errorf("answers = %v, want понятный ответ", api.answers)
|
||||
}
|
||||
}
|
||||
|
||||
+13
-16
@@ -3,7 +3,6 @@ package tgbot
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
@@ -21,13 +20,13 @@ func (b *Bot) renderCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
|
||||
case store.StateReview, store.StateDeferred:
|
||||
return b.reviewCard(rd)
|
||||
case store.StateRecognizing:
|
||||
return "⏳ Распознаю #" + itoa(id) + "…", b.webOnly(id)
|
||||
return "⏳ Распознаю #" + id + "…", b.webOnly(id)
|
||||
case store.StateLinking:
|
||||
return "⏳ Раскладываю #" + itoa(id) + "…", nil
|
||||
return "⏳ Раскладываю #" + id + "…", nil
|
||||
case store.StateDone:
|
||||
return b.renderDone(rd), b.webOnly(id)
|
||||
default:
|
||||
text := fmt.Sprintf("Задача #%d — %s.", id, state)
|
||||
text := fmt.Sprintf("Задача #%s — %s.", id, state)
|
||||
if msg := rd.Download.ErrorMsg.String; msg != "" {
|
||||
text += "\n" + msg
|
||||
}
|
||||
@@ -43,7 +42,7 @@ func (b *Bot) reviewCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
|
||||
id := rd.Download.ID
|
||||
var sb strings.Builder
|
||||
|
||||
fmt.Fprintf(&sb, "🟡 Нужно подтверждение #%d\n", id)
|
||||
fmt.Fprintf(&sb, "🟡 Нужно подтверждение #%s\n", id)
|
||||
if src := contextOrSource(rd); src != "" {
|
||||
fmt.Fprintf(&sb, "Источник: %s\n", shorten(src, 80))
|
||||
}
|
||||
@@ -63,7 +62,7 @@ func (b *Bot) reviewCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboar
|
||||
|
||||
func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMarkup {
|
||||
id := rd.Download.ID
|
||||
sid := itoa(id)
|
||||
sid := id
|
||||
|
||||
var row1 []tgbotapi.InlineKeyboardButton
|
||||
if len(rd.Preview) > 0 {
|
||||
@@ -90,7 +89,7 @@ func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMark
|
||||
func (b *Bot) renderDone(rd *worker.ReviewData) string {
|
||||
title := rd.Plan.Title
|
||||
if title == "" {
|
||||
title = "#" + itoa(rd.Download.ID)
|
||||
title = "#" + rd.Download.ID
|
||||
}
|
||||
n := len(rd.Preview)
|
||||
if n == 0 {
|
||||
@@ -103,7 +102,7 @@ func (b *Bot) renderDone(rd *worker.ReviewData) string {
|
||||
func (b *Bot) renderDesync(rd *worker.ReviewData, event worker.NotifyEvent) string {
|
||||
title := rd.Plan.Title
|
||||
if title == "" {
|
||||
title = "#" + itoa(rd.Download.ID)
|
||||
title = "#" + rd.Download.ID
|
||||
}
|
||||
switch event {
|
||||
case worker.EventTargetMissing:
|
||||
@@ -123,7 +122,7 @@ func (b *Bot) renderFailed(rd *worker.ReviewData) (string, *tgbotapi.InlineKeybo
|
||||
if rd.Download.State == store.StateStuck {
|
||||
verb = "зависла"
|
||||
}
|
||||
fmt.Fprintf(&sb, "❌ Задача #%d %s", id, verb)
|
||||
fmt.Fprintf(&sb, "❌ Задача #%s %s", id, verb)
|
||||
if code := rd.Download.ErrorCode.String; code != "" {
|
||||
fmt.Fprintf(&sb, " (%s)", code)
|
||||
}
|
||||
@@ -139,9 +138,9 @@ func (b *Bot) renderFailed(rd *worker.ReviewData) (string, *tgbotapi.InlineKeybo
|
||||
}
|
||||
|
||||
// retryKeyboard — клавиатура для failed/stuck: повтор + опц. ссылка в веб.
|
||||
func (b *Bot) retryKeyboard(id int64) *tgbotapi.InlineKeyboardMarkup {
|
||||
func (b *Bot) retryKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
|
||||
row := []tgbotapi.InlineKeyboardButton{
|
||||
tgbotapi.NewInlineKeyboardButtonData("🔄 Повторить", "retry:"+itoa(id)),
|
||||
tgbotapi.NewInlineKeyboardButtonData("🔄 Повторить", "retry:"+id),
|
||||
}
|
||||
if url := b.reviewURL(id); url != "" {
|
||||
row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
|
||||
@@ -150,7 +149,7 @@ func (b *Bot) retryKeyboard(id int64) *tgbotapi.InlineKeyboardMarkup {
|
||||
return &kb
|
||||
}
|
||||
|
||||
func (b *Bot) webOnly(id int64) *tgbotapi.InlineKeyboardMarkup {
|
||||
func (b *Bot) webOnly(id string) *tgbotapi.InlineKeyboardMarkup {
|
||||
url := b.reviewURL(id)
|
||||
if url == "" {
|
||||
return nil
|
||||
@@ -161,11 +160,11 @@ func (b *Bot) webOnly(id int64) *tgbotapi.InlineKeyboardMarkup {
|
||||
return &kb
|
||||
}
|
||||
|
||||
func (b *Bot) reviewURL(id int64) string {
|
||||
func (b *Bot) reviewURL(id string) string {
|
||||
if b.webBase == "" {
|
||||
return ""
|
||||
}
|
||||
return b.webBase + "/review/" + itoa(id)
|
||||
return b.webBase + "/review/" + id
|
||||
}
|
||||
|
||||
// --- мелкие хелперы ---
|
||||
@@ -233,5 +232,3 @@ func shorten(s string, n int) string {
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
|
||||
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
|
||||
|
||||
Reference in New Issue
Block a user