axe/internal/notify/poll.go

147 lines
5.4 KiB
Go
Raw Permalink Normal View History

2026-10-18 19:07:25 +00:00
package notify
import (
"context"
"log/slog"
"strconv"
"strings"
"time"
"building-mgmt/internal/telegram"
)
// Подсказки боту — по-русски, как и весь интерфейс приложения.
const (
helpText = "Привязка уведомлений: в приложении откройте раздел «Настройки», " +
"нажмите «Получить код» и пришлите его сюда."
boundText = "Готово — уведомления о заявках будут приходить в этот чат."
codeBadText = "Код не найден или истёк — запросите новый в разделе «Настройки»."
bindFailText = "Не удалось привязать — запросите код заново."
)
// RunPoller — длинный поллинг бота, пока жив контекст: очередь апдейтов
// разбирается на месте, offset сдвигается всегда. Один процесс — один
// поллер (в контейнере их один), иначе два инстанса драли бы одну
// очередь; на ошибках — экспоненциальный backoff, а не мгновенный
// повтор, чтобы не долбить API в минуты сбоя.
func RunPoller(ctx context.Context, store Store, bot telegram.API, log *slog.Logger) {
if !bot.Enabled() {
return
}
var offset int64
backoff := minBackoff
for ctx.Err() == nil {
updates, err := bot.GetUpdates(ctx, offset, pollTimeout)
if err != nil {
if ctx.Err() != nil {
return // остановка процесса, не сбой
}
log.Warn("telegram poll failed", "err", err, "retry_in", backoff)
if !sleep(ctx, backoff) {
return
}
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
continue
}
backoff = minBackoff
for _, u := range updates {
// offset двигаем до разбора: сообщение, которое мы не смогли
// обработать, не должно вечно стоять впереди очереди —
// иначе один битый апдейт остановит привязку навсегда.
offset = u.UpdateID + 1
if u.Message == nil {
continue
}
handleStart(ctx, store, bot, u.Message, log)
}
}
}
// handleStart — обработка сообщения боту. Единственное, на что он
// реагирует, — команда /start [код]: код гасится одним UPDATE (по нему
// и находится владелец), в его чат пишется chat_id, человеку отвечает
// подтверждение. Всё прочее (спор, случайные сообщения) молчим.
func handleStart(ctx context.Context, store Store, bot telegram.API, m *telegram.Message, log *slog.Logger) {
chat := strconv.FormatInt(m.Chat.ID, 10)
arg, isStart := startArg(m.Text)
if !isStart {
return
}
if arg == "" {
if err := bot.SendMessage(ctx, chat, helpText); err != nil {
log.Warn("telegram poll: help failed", "err", err)
}
return
}
// В базе лежит sha256 кода — по нему и ищем; сам код в логи не пишем.
userID, err := store.ConsumeBindCode(ctx, telegram.CodeHash(arg))
switch {
case err == nil:
case isNotFound(err):
if err := bot.SendMessage(ctx, chat, codeBadText); err != nil {
log.Warn("telegram poll: reply failed", "err", err)
}
return
default:
log.Warn("telegram poll: consume code failed", "err", err)
if err := bot.SendMessage(ctx, chat, bindFailText); err != nil {
log.Warn("telegram poll: reply failed", "err", err)
}
return
}
// Числовой chat_id (личка — положительный, группа — отрицательный)
// пишется напрямую: ручная валидация формата тут не проходит.
if err := store.SetTarget(ctx, userID, chat); err != nil {
// Код уже сгорел, поэтому подсказываем запросить новый, а не
// «повторите»: повторить то нечего.
log.Warn("telegram poll: save target failed", "err", err, "user_id", userID)
if err := bot.SendMessage(ctx, chat, bindFailText); err != nil {
log.Warn("telegram poll: reply failed", "err", err)
}
return
}
log.Info("telegram bound", "user_id", userID, "chat_id", chat)
if err := bot.SendMessage(ctx, chat, boundText); err != nil {
log.Warn("telegram poll: reply failed", "err", err)
}
}
// startArg — разбор «/start [код]»: вариант с @имя_бота (так приходит
// команда в группе) приводится к /start; не наша команда — false.
func startArg(text string) (arg string, ok bool) {
fields := strings.Fields(strings.TrimSpace(text))
if len(fields) == 0 {
return "", false
}
cmd := fields[0]
if i := strings.IndexByte(cmd, '@'); i >= 0 {
cmd = cmd[:i]
}
if !strings.EqualFold(cmd, "/start") {
return "", false
}
if len(fields) < 2 {
return "", true
}
return fields[1], true
}
// sleep — пауза, прерываемая остановкой контекста.
func sleep(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-t.C:
return true
case <-ctx.Done():
return false
}
}