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 } }