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