axe/internal/notify/notify.go

198 lines
9.1 KiB
Go
Raw Permalink Normal View History

2026-10-18 19:07:25 +00:00
// 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 "<b>Заявка №" + strconv.FormatInt(t.ID, 10) +
" — без движения более " + durationLabel(after) + "</b>\n" +
categoryLabel(t.Category) + " · " + htmlEscape(t.Address) + "\n" +
"Приоритет: " + priorityLabel(t.Priority) + "\n" +
`<a href="` + htmlEscape(link) + `">Открыть заявку</a>`
}
// RecurringMessage — текст уведомления о заявке, которую фоновый
// планировщик создал сам по правилу повторения («осмотр вентиляции
// раз в 3 месяца»). От обычного создания отличается заголовком:
// клиент заявку не создавал, ему важно понять, откуда она взялась.
// Тот же HTML-режим и то же экранирование, что у message.
func RecurringMessage(t repository.DueRun, baseURL string) string {
link := baseURL + "/#/tickets/" + strconv.FormatInt(t.TicketID, 10)
return "<b>Создана заявка по плану: №" + strconv.FormatInt(t.TicketID, 10) + "</b>\n" +
categoryLabel(t.Category) + " · " + htmlEscape(t.Address) + "\n" +
htmlEscape(t.Description) + "\n" +
"Приоритет: " + priorityLabel(t.Priority) + "\n" +
`<a href="` + htmlEscape(link) + `">Открыть заявку</a>`
}
// 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(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
).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": "высокий",
}
)