// Package notify — фоновые задачи Telegram: напоминание клиенту о заявке, // которая простояла в статусе 'new' дольше порога, и длинный поллинг // ботов для привязки чата по одноразовому коду. Каким ботом слать — // решает Registry: собственный токен пользователя, иначе глобальный. // // Обе задачи живут до выхода процесса и зависят только от ctx — так же, // как чистка лимитов (ratelimit.RunCleanup) и чистка заявок в cmd/api. // Отправка fail-open: недоступный Telegram не роняет API, ошибка уходит // в warn-лог, а заявка остаётся кандидатом на следующий тик. package notify import ( "context" "errors" "log/slog" "strconv" "strings" "time" "building-mgmt/internal/repository" "building-mgmt/internal/telegram" ) const ( // batchLimit — сколько заявок за один тик; больше не нужно: // опоздавших единицы, и запас на всплеск после простоя API. batchLimit = 100 // pollTimeout — удержание соединения getUpdates: Telegram отвечает // сразу, если апдейтов нет, либо держит соединение до таймаута. pollTimeout = 30 * time.Second // minBackoff/maxBackoff — пауза между неудачными поллингами // (экспоненциально, чтобы не долбить API в минуты сбоя). minBackoff = time.Second maxBackoff = 30 * time.Second ) // Store — доступ к базе, нужный фоновым задачам (в тестах — фейк). type Store interface { StaleTickets(ctx context.Context, cutoff time.Time, limit int) ([]repository.StaleTicket, error) MarkNotified(ctx context.Context, ticketID int64) error ConsumeBindCode(ctx context.Context, codeHash string) (string, error) SetTarget(ctx context.Context, userID, target string) error CleanupBindCodes(ctx context.Context, before time.Time) (int64, error) } // Run — цикл уведомлений: сразу прогон, дальше тик каждые tick. // after — возраст заявки для первого напоминания; повтор уходит раз в // сутки (столько же отсчитывает StaleTickets от tg_notified_at) и // прекращается, как только заявку взяли. bots выбирает, через какого // бота слать: личный токен клиента или глобальный бот сервера. func Run(ctx context.Context, store Store, bots Bots, after, tick time.Duration, baseURL string, log *slog.Logger) { if tick <= 0 { tick = time.Minute } t := time.NewTicker(tick) defer t.Stop() for { tickOnce(ctx, store, bots, after, baseURL, log) select { case <-t.C: case <-ctx.Done(): return } } } // tickOnce — один проход: кандидаты → отправка → фиксация. Ошибка на // одной заявке не останавливает остальные, а неотправленные остаются // кандидатами (tg_notified_at не тронут) и вернутся на следующем тике. func tickOnce(ctx context.Context, store Store, bots Bots, after time.Duration, baseURL string, log *slog.Logger) { // Просроченные коды привязки чистим здесь же: тик раз в минуту, // лишняя пара DELETE никого не заденет, таблица остаётся маленькой. if _, err := store.CleanupBindCodes(ctx, time.Now().Add(-time.Hour)); err != nil { log.Warn("telegram bind codes cleanup failed", "err", err) } items, err := store.StaleTickets(ctx, time.Now().Add(-after), batchLimit) if err != nil { log.Warn("telegram notify: candidates failed", "err", err) return } for _, it := range items { if ctx.Err() != nil { return } // У каждого клиента может быть свой бот, а может и не быть — // тогда слать нечем. Не фиксируем: заявка дождётся подключения // бота и уведомление уйдёт, а не потеряется. bot := bots.BotFor(it.ClientID) if bot == nil || !bot.Enabled() { continue } err := bot.SendMessage(ctx, it.Target, message(it, after, baseURL)) switch { case err == nil: // Успех: фиксируем — со следующего тика заявка молчит сутки. case telegram.Permanent(err): // Чат недоступен (бота выгнали, человек не писал боту): // повтор не поможет, помечаем отправленным, чтобы не // долбить лог каждую минуту. log.Warn("telegram notify: chat unavailable", "ticket_id", it.ID, "err", err) default: log.Warn("telegram notify: send failed", "ticket_id", it.ID, "err", err) continue // без фиксации — попробуем на следующем тике } if err := store.MarkNotified(ctx, it.ID); err != nil && !isNotFound(err) { log.Warn("telegram notify: mark failed", "ticket_id", it.ID, "err", err) } } } // message — текст напоминания. HTML-режим, поэтому весь пользовательский // текст (адрес, категория) экранируется; ссылка — на страницу заявки. func message(t repository.StaleTicket, after time.Duration, baseURL string) string { link := baseURL + "/#/tickets/" + strconv.FormatInt(t.ID, 10) return "Заявка №" + strconv.FormatInt(t.ID, 10) + " — без движения более " + durationLabel(after) + "\n" + categoryLabel(t.Category) + " · " + htmlEscape(t.Address) + "\n" + "Приоритет: " + priorityLabel(t.Priority) + "\n" + `Открыть заявку` } // RecurringMessage — текст уведомления о заявке, которую фоновый // планировщик создал сам по правилу повторения («осмотр вентиляции // раз в 3 месяца»). От обычного создания отличается заголовком: // клиент заявку не создавал, ему важно понять, откуда она взялась. // Тот же HTML-режим и то же экранирование, что у message. func RecurringMessage(t repository.DueRun, baseURL string) string { link := baseURL + "/#/tickets/" + strconv.FormatInt(t.TicketID, 10) return "Создана заявка по плану: №" + strconv.FormatInt(t.TicketID, 10) + "\n" + categoryLabel(t.Category) + " · " + htmlEscape(t.Address) + "\n" + htmlEscape(t.Description) + "\n" + "Приоритет: " + priorityLabel(t.Priority) + "\n" + `Открыть заявку` } // durationLabel — «24 ч.» из настроенного порога; меньше часа — минуты. func durationLabel(d time.Duration) string { if h := int(d.Hours()); h > 0 { return strconv.Itoa(h) + " ч." } return strconv.Itoa(int(d.Minutes())) + " мин." } // categoryLabel / priorityLabel — подпись поля в сообщении; неизвестное // значение отдаём как есть, чтобы напоминание не потеряло информацию. func categoryLabel(v string) string { if s, ok := categoryName[v]; ok { return s } return v } func priorityLabel(v string) string { if s, ok := priorityName[v]; ok { return s } return v } // htmlEscape — экранирование для parse_mode=HTML: Telegram знает только // свой набор тегов (b/i/a/code), поэтому &, <, > и кавычки в // пользовательском тексте (адрес объекта) уходят сущностями. func htmlEscape(s string) string { return strings.NewReplacer( "&", "&", "<", "<", ">", ">", `"`, """, ).Replace(s) } // isNotFound — заявку успели удалить, пока мы готовили сообщение. func isNotFound(err error) bool { return errors.Is(err, repository.ErrNotFound) } // categoryName / priorityName — переводим коды полей в текст (тот же // словарь, что на фронте в DICT). var ( categoryName = map[string]string{ "electric": "электрика", "plumbing": "сантехника", "climate": "климат", "elevator": "лифт", "other": "прочее", } priorityName = map[string]string{ "low": "низкий", "normal": "обычный", "high": "высокий", } )