axe/internal/handlers/telegram.go

431 lines
20 KiB
Go
Raw Permalink Normal View History

2026-10-18 19:07:25 +00:00
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
}