package handlers import ( "context" "crypto/rand" "errors" "log/slog" "net/http" "regexp" "strings" "time" "github.com/gin-gonic/gin" "building-mgmt/internal/crypt" "building-mgmt/internal/ratelimit" "building-mgmt/internal/repository" "building-mgmt/internal/telegram" ) const ( // bindCodeTTL — срок жизни кода привязки: ровно столько, чтобы // успеть перейти по ссылке и написать боту, не дольше — код, // лежащий в чате, бесполезен через сутки. bindCodeTTL = 15 * time.Minute // bindCodeLen / bindCodeAlphabet — 8 знаков из 32 (~40 бит) без // похожих символов (0/O, 1/I): код переносят в бота руками, // опечатка должна быть заметна. bindCodeLen = 8 bindCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" ) // targetPattern — единственный формат ручной цели: @имя (группа или // канал, куда бот добавлен) либо числовой chat_id. Личный @ник сюда // не проходит намеренно: бот не видит человека, пока тот сам ему не // напишет, — поэтому личная привязка идёт отдельно, через код /start. var targetPattern = regexp.MustCompile(`^(@[A-Za-z0-9_]{5,32}|-?[0-9]{5,20})$`) // botTokenPattern — формат токена Bot API от @BotFather: ":<секрет>". // Проверка до сети: мусор не доходит до getMe, а у реального токена // формат стабилен уже много лет. Непонятное значение → 400. var botTokenPattern = regexp.MustCompile(`^\d{5,40}:[A-Za-z0-9_-]{10,200}$`) // BotRegistry — выбор бота для пользователя и управление его поллером. // Реализация — notify.Registry: собственный токен пользователя // приоритетнее глобального TELEGRAM_BOT_TOKEN. type BotRegistry interface { // BotFor — личный бот пользователя, иначе глобальный (может быть // выключенным — проверяет Enabled()). BotFor(userID string) telegram.API // Register поднимает поллер личного бота (замена токена гасит прежний). Register(userID string, bot telegram.API) // Unregister гасит поллер и убирает бота из реестра. Unregister(userID string) } // NewBot — фабрика клиента Bot API по токену: сеть поднимается отдельно // (Init), здесь создаётся только клиент (в cmd/api — telegram.New). type NewBot func(token string) telegram.API // TelegramConfig — зависимости Telegram-хендлера. type TelegramConfig struct { Repo *repository.TelegramRepo // Bots — реестр: чей бот использовать (личный или глобальный) и // как его поллер остановить. Bots BotRegistry // Cipher шифрует токены перед записью в БД; nil — ввод токена на // сервере выключен (нет DATA_ENCRYPTION_KEY), ответ 400. Cipher *crypt.Cipher // NewBot — фабрика для проверки введённого токена. NewBot NewBot // CodeLimiter — лимит выдачи кодов привязки (ключ — user_id). CodeLimiter ratelimit.Allower // TokenLimiter — лимит сохранения токена: там сетевой getMe. TokenLimiter ratelimit.Allower } // TelegramHandler — уведомления в Telegram: собственный бот пользователя, // привязка чата (одноразовый код), ручной ввод группы/канала, // отключение. type TelegramHandler struct { repo *repository.TelegramRepo bots BotRegistry cipher *crypt.Cipher newBot NewBot codeLimiter ratelimit.Allower tokenLimiter ratelimit.Allower } // NewTelegramHandler собирает хендлер. func NewTelegramHandler(cfg TelegramConfig) *TelegramHandler { return &TelegramHandler{ repo: cfg.Repo, bots: cfg.Bots, cipher: cfg.Cipher, newBot: cfg.NewBot, codeLimiter: cfg.CodeLimiter, tokenLimiter: cfg.TokenLimiter, } } // TelegramStatusResponse — состояние уведомлений текущего пользователя. // Токен сюда не входит никогда: по статусу видно только, есть ли он. type TelegramStatusResponse struct { // Enabled — может ли этот пользователь получать уведомления вообще: // есть свой токен бота либо настроен глобальный бот сервера. Enabled bool `json:"enabled"` // Bound — есть ли уже цель уведомлений. Bound bool `json:"bound"` // Target — текущая цель: числовой chat_id либо @группа. Target string `json:"target,omitempty"` // BotUsername — @имя бота, через который придут уведомления и // ссылка привязки; пусто, пока getMe не удался. BotUsername string `json:"bot_username,omitempty"` // HasBotToken — подключён ли пользователь свой бот (токен в БД). HasBotToken bool `json:"has_bot_token"` // TokenConfigurable — можно ли ввести токен прямо сейчас: false, // если на сервере не задан DATA_ENCRYPTION_KEY. TokenConfigurable bool `json:"token_configurable"` } // Status — GET /auth/telegram. // // @Summary Статус уведомлений в Telegram // @Description Куда сейчас приходят уведомления о заявках: цель (чат или группа), имя бота (свой или глобальный), подключён ли собственный токен бота (has_bot_token) и можно ли его ввести (token_configurable — зависит от DATA_ENCRYPTION_KEY на сервере). Сам токен наружу не отдаётся. // @Tags auth // @Produce json // @Success 200 {object} handlers.TelegramStatusResponse // @Failure 401 {object} handlers.ErrorResponse "нет входа" // @Failure 404 {object} handlers.ErrorResponse "профиль не найден" // @Router /auth/telegram [get] func (h *TelegramHandler) Status(c *gin.Context) { ctx := c.Request.Context() userID := c.GetString("user_id") target, err := h.repo.Target(ctx, userID) if err != nil { respondError(c, err) return } hasToken, storedName, err := h.repo.BotTokenInfo(ctx, userID) if err != nil { respondError(c, err) return } // Имя бота: живое из реестра (getMe при сохранении или на старте), // иначе то, что записали в базу. bot := h.botFor(userID) username := bot.Username() if username == "" { username = storedName } c.JSON(http.StatusOK, TelegramStatusResponse{ Enabled: bot.Enabled(), Bound: target != "", Target: target, BotUsername: username, HasBotToken: hasToken, TokenConfigurable: h.cipher != nil, }) } // BindCodeResponse — ответ на выпуск кода: сам код (его можно набрать // в боте руками) и ссылка, открывающая бота с кодом уже подставленным. type BindCodeResponse struct { Code string `json:"code"` Link string `json:"link"` ExpiresAt time.Time `json:"expires_at"` } // IssueCode — POST /auth/telegram/code. Код одноразовый: бот получает // его как аргумент /start, поллер гасит и привязывает чат к аккаунту. // Код выдаёт бот пользователя — свой токен либо глобальный. // // @Summary Выдать код привязки Telegram // @Description Одноразовый код и deep-link t.me/{bot}?start={code}: откройте ссылку в Telegram и отправьте боту код — этот чат начнёт получать уведомления о заявках. Бот — ваш собственный токен, а если его нет — глобальный бот сервера. Код живёт 15 минут, прежние коды гаснут. Лимит: 5 выпусков за 5 минут на пользователя. // @Tags auth // @Accept json // @Produce json // @Success 200 {object} handlers.BindCodeResponse // @Failure 400 {object} handlers.ErrorResponse "ни свой бот, ни глобальный не настроены" // @Failure 404 {object} handlers.ErrorResponse "профиль не найден" // @Failure 429 {object} handlers.ErrorResponse "слишком много кодов" // @Router /auth/telegram/code [post] func (h *TelegramHandler) IssueCode(c *gin.Context) { ctx := c.Request.Context() userID := c.GetString("user_id") bot := h.botFor(userID) if !bot.Enabled() { c.JSON(http.StatusBadRequest, gin.H{"error": "telegram not configured"}) return } if !h.codeLimiter.Allow("tg_code|" + userID) { c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"}) return } // Имя бота для ссылки: на старте getMe мог не пройти (сеть), // поэтому даём ему второй шанс именно здесь, у редкого запроса. // Проверяем раньше базы — инфраструктурная проблема не должна // выглядеть как «профиль не найден». if h.username(ctx, bot) == "" { c.JSON(http.StatusInternalServerError, gin.H{"error": "telegram unavailable"}) return } // Сессия могла пережить удаление профиля: код без владельца // привязать не к кому, а отдельного «приветствия» тут не будет. if _, err := h.repo.Target(ctx, userID); errors.Is(err, repository.ErrNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } else if err != nil { respondError(c, err) return } code, err := randomCode() if err != nil { slog.Error("telegram bind code generation failed", "err", err, "user_id", userID) c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) return } expires, err := h.repo.CreateBindCode(ctx, userID, telegram.CodeHash(code), bindCodeTTL) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, BindCodeResponse{ Code: code, Link: telegram.Link(bot.Username(), code), ExpiresAt: expires.UTC(), }) } type setTelegramReq struct { // Target — @группа/@канал или числовой chat_id; пусто — отключить. Target string `json:"target"` } // Set — PUT /auth/telegram. Ручной ввод цели (обычно группа или канал) // и очистка её пустым значением. Чат проверяет бот пользователя: чужая // группа недоступна для его бота так же, как для глобального. // // @Summary Указать цель уведомлений вручную // @Description Принимает @имя группы/канала (бот обязан быть в нём) или числовой chat_id; пустое значение отключает уведомления. Сначала проверяется формат, затем бот проверяет чат через getChat — чужой или недоступный чат не сохраняется. // @Tags auth // @Accept json // @Produce json // @Param body body setTelegramReq true "Цель: @группа, chat_id или пусто" // @Success 200 {object} handlers.OKResponse // @Failure 400 {object} handlers.ErrorResponse "invalid telegram target" // @Failure 401 {object} handlers.ErrorResponse "нет входа" // @Router /auth/telegram [put] func (h *TelegramHandler) Set(c *gin.Context) { var req setTelegramReq if err := c.ShouldBindJSON(&req); err != nil { bindingError(c, err) return } target := strings.TrimSpace(req.Target) if target != "" && !targetPattern.MatchString(target) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid telegram target"}) return } // Без включённого бота формат не с чем сверить — проверяем только // его (иначе локальная разработка без бота ломается на своих же данных). bot := h.botFor(c.GetString("user_id")) if target != "" && bot.Enabled() { if err := bot.GetChat(c.Request.Context(), target); err != nil { // Причина не различается наружу (нет чата / бота выгнали / // нет прав — это одно «цель не работает»), детали — в лог. slog.Warn("telegram getChat failed", "err", err, "target", target) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid telegram target"}) return } } if err := h.repo.SetTarget(c.Request.Context(), c.GetString("user_id"), target); err != nil { respondError(c, err) return } c.JSON(http.StatusOK, OKResponse{OK: true}) } // Unbind — DELETE /auth/telegram. Убирает цель: уведомления прекращаются. // // @Summary Отключить уведомления в Telegram // @Description Очищает цель уведомлений текущего пользователя (то же, что PUT с пустым значением). Токен бота при этом не трогается — он живёт на своём эндпоинте. // @Tags auth // @Produce json // @Success 200 {object} handlers.OKResponse // @Failure 401 {object} handlers.ErrorResponse "нет входа" // @Router /auth/telegram [delete] func (h *TelegramHandler) Unbind(c *gin.Context) { if err := h.repo.SetTarget(c.Request.Context(), c.GetString("user_id"), ""); err != nil { respondError(c, err) return } c.JSON(http.StatusOK, OKResponse{OK: true}) } type setBotReq struct { // Token — токен бота от @BotFather; пусто невалидно (нужен явный ввод). Token string `json:"token"` } // SetBot — POST /auth/telegram/bot. Сохраняет собственного бота // пользователя: проверка токена getMe, шифрование, запись в БД и // запуск его поллера. // // @Summary Подключить своего бота // @Description Принимает токен Bot API (формат «id:secret» от @BotFather), проверяет его через getMe, шифрует и сохраняет. Дальше уведомления пользователя идут через этого бота, его поллер поднимается сразу. Лимит: 5 попыток за 5 минут. Без DATA_ENCRYPTION_KEY на сервере — 400. // @Tags auth // @Accept json // @Produce json // @Param body body setBotReq true "Токен бота" // @Success 200 {object} handlers.OKResponse // @Failure 400 {object} handlers.ErrorResponse "токен не сохранён (формат, getMe или фича выключена)" // @Failure 401 {object} handlers.ErrorResponse "нет входа" // @Failure 404 {object} handlers.ErrorResponse "профиль не найден" // @Failure 429 {object} handlers.ErrorResponse "слишком много попыток" // @Router /auth/telegram/bot [post] func (h *TelegramHandler) SetBot(c *gin.Context) { ctx := c.Request.Context() userID := c.GetString("user_id") // Шифрования нет — токен сохранить некуда (секрет лежал бы открытым). if h.cipher == nil || h.newBot == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "telegram bot token not configured"}) return } var req setBotReq if err := c.ShouldBindJSON(&req); err != nil { bindingError(c, err) return } token := strings.TrimSpace(req.Token) // Формат раньше сети: мусор не доходит до getMe. if !botTokenPattern.MatchString(token) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot token"}) return } if !h.tokenLimiter.Allow("tg_token|" + userID) { c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"}) return } // Проверка токена: неверный или отозванный не сохраняем, чтобы // пользователь не получил молча нерабочего бота. bot := h.newBot(token) if err := bot.Init(ctx); err != nil { slog.Warn("telegram bot token rejected", "err", err, "user_id", userID) c.JSON(http.StatusBadRequest, gin.H{"error": "bot token rejected"}) return } enc, err := h.cipher.Seal(token) if err != nil { slog.Error("telegram token seal failed", "err", err, "user_id", userID) c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) return } if err := h.repo.SetBotToken(ctx, userID, enc, bot.Username()); err != nil { respondError(c, err) return } // Реестр поднимает поллер бота (и гасит прежний, если токен меняли). h.bots.Register(userID, bot) c.JSON(http.StatusOK, OKResponse{OK: true}) } // DeleteBot — DELETE /auth/telegram/bot. Убирает собственного бота: // поллер гасится, уведомления (если цель осталась) пойдут через // глобальный бот сервера либо перестанут уходить, когда его нет. // // @Summary Отключить своего бота // @Description Стирает сохранённый токен текущего пользователя и останавливает его поллер. Повторный вызов безопасен — ответ тот же 200. // @Tags auth // @Produce json // @Success 200 {object} handlers.OKResponse // @Failure 401 {object} handlers.ErrorResponse "нет входа" // @Failure 404 {object} handlers.ErrorResponse "профиль не найден" // @Router /auth/telegram/bot [delete] func (h *TelegramHandler) DeleteBot(c *gin.Context) { ctx := c.Request.Context() userID := c.GetString("user_id") if err := h.repo.ClearBotToken(ctx, userID); err != nil { respondError(c, err) return } h.bots.Unregister(userID) c.JSON(http.StatusOK, OKResponse{OK: true}) } // botFor — бот пользователя: собственный токен, иначе глобальный. // nil-реестр не бывает: конструктор его всегда получает. func (h *TelegramHandler) botFor(userID string) telegram.API { return h.bots.BotFor(userID) } // username — @имя бота, с повторной попыткой getMe, если на старте // не вышло. Пустая строка означает, что ссылку привязки не собрать. func (h *TelegramHandler) username(ctx context.Context, bot telegram.API) string { if bot == nil || !bot.Enabled() { return "" } if name := bot.Username(); name != "" { return name } if err := bot.Init(ctx); err != nil { slog.Warn("telegram getMe failed", "err", err) return "" } return bot.Username() } // randomCode — код привязки: crypto/rand по алфавиту без похожих знаков. // Алфавит 32 символа — 256 делится на 32 ровно, смещения в байте нет. func randomCode() (string, error) { raw := make([]byte, bindCodeLen) if _, err := rand.Read(raw); err != nil { return "", err } for i, b := range raw { raw[i] = bindCodeAlphabet[int(b)%len(bindCodeAlphabet)] } return string(raw), nil }