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

147 lines
5.4 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
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
}
}