axe/internal/notify/notify.go
2026-10-18 22:07:25 +03:00

198 lines
9.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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": "высокий",
}
)