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