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

431 lines
20 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 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: "<id>:<секрет>".
// Проверка до сети: мусор не доходит до 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
}