431 lines
20 KiB
Go
431 lines
20 KiB
Go
|
|
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
|
|||
|
|
}
|