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