198 lines
9.1 KiB
Go
198 lines
9.1 KiB
Go
// 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(
|
||
"&", "&",
|
||
"<", "<",
|
||
">", ">",
|
||
`"`, """,
|
||
).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": "высокий",
|
||
}
|
||
)
|