Telegram WebApp support + consent endpoint

- Telegram bot (long polling)
- /api/v1/auth/telegram (validate initData, auto-create user)
- /api/v1/auth/consents (record acceptances)
- users.telegram_id column, GetByTelegramID/CreateWithTelegramID
- ValidateInitData (HMAC-SHA256 with WebAppData secret)
This commit is contained in:
ga 2026-08-20 17:43:20 +00:00
parent 778a2da4ef
commit c6d219c51f
9 changed files with 661 additions and 0 deletions

View File

@ -26,6 +26,7 @@ import (
"github.com/buhapp/backend/internal/redis"
"github.com/buhapp/backend/internal/reviews"
"github.com/buhapp/backend/internal/storage"
"github.com/buhapp/backend/internal/telegram"
"github.com/buhapp/backend/internal/users"
"github.com/buhapp/backend/internal/ws"
)
@ -78,10 +79,22 @@ func main() {
authSvc := auth.NewService(cfg.JWTSecret, cfg.JwTAccessTTL(), cfg.JwtRefreshTTL())
hub := ws.NewHub()
// Telegram bot (опционально)
var bot *telegram.Bot
var tgH *handlers.TGHandler
if cfg.TelegramBotToken != "" {
bot = telegram.NewBot(cfg.TelegramBotToken)
tgH = handlers.NewTGHandler(bot, usersRepo, authSvc, consentRepo, auditRepo, []byte(cfg.JWTSecret))
log.Println("telegram bot: configured")
} else {
log.Println("telegram bot: NOT configured (set TELEGRAM_BOT_TOKEN)")
}
authH := handlers.NewAuthHandler(cfg, usersRepo, consentRepo, auditRepo, authSvc)
h := handlers.New(cfg, usersRepo, prefsRepo, locsRepo, consentRepo, auditRepo, authSvc)
chatH := handlers.NewChatHandlers(chatRepo, auditRepo, hub)
reviewH := handlers.NewReviewHandlers(reviewRepo, chatRepo, auditRepo)
consentH := handlers.NewConsentHandlers(consentRepo, auditRepo)
_ = rdb
_ = st
@ -120,6 +133,11 @@ func main() {
return c.JSON(fiber.Map{"version": cfg.DisclaimerVersion, "url": "/legal/DISCLAIMER.md"})
})
// Telegram WebApp auth (публичный)
if tgH != nil {
api.Post("/auth/telegram", tgH.TGAuth)
}
api := app.Group("/api/v1")
api.Post("/auth/register", authH.Register)
api.Post("/auth/login", authH.Login)
@ -154,6 +172,9 @@ func main() {
protected.Get("/users/:id/reviews", reviewH.ListForUser)
protected.Get("/users/:id/stats", reviewH.Stats)
// Consents (повторное принятие)
protected.Post("/auth/consents", consentH.Accept)
// WebSocket — авторизация по ?token=
app.Get("/ws", ws.AuthMiddleware(authSvc), ws.Upgrade, ws.Handler(hub))
@ -165,6 +186,16 @@ func main() {
_ = app.ShutdownWithTimeout(10 * time.Second)
}()
// Запуск Telegram-бота (long polling) — если настроен
if bot != nil && tgH != nil {
go func() {
log.Println("telegram bot: starting long-poll loop")
bot.RunLoop(func(u telegram.Update) {
tgH.ProcessUpdate(u)
})
}()
}
addr := fmt.Sprintf(":%d", cfg.AppPort)
log.Printf("listening on %s", addr)
if err := app.Listen(addr); err != nil {

View File

@ -39,6 +39,9 @@ type Config struct {
TermsVersion string
PrivacyVersion string
DisclaimerVersion string
// Telegram
TelegramBotToken string
}
func Load() (*Config, error) {
@ -74,6 +77,8 @@ func Load() (*Config, error) {
TermsVersion: getEnv("TERMS_VERSION", "1.0"),
PrivacyVersion: getEnv("PRIVACY_VERSION", "1.0"),
DisclaimerVersion: getEnv("DISCLAIMER_VERSION", "1.0"),
TelegramBotToken: getEnv("TELEGRAM_BOT_TOKEN", ""),
}
if cfg.JWTSecret == "change-me" {

View File

@ -0,0 +1,66 @@
package handlers
import (
"time"
"github.com/gofiber/fiber/v2"
"github.com/buhapp/backend/internal/audit"
"github.com/buhapp/backend/internal/consent"
)
type ConsentHandlers struct {
Consent *consent.Repo
Audit *audit.Repo
}
func NewConsentHandlers(c *consent.Repo, a *audit.Repo) *ConsentHandlers {
return &ConsentHandlers{Consent: c, Audit: a}
}
type consentRequest struct {
Adult bool `json:"adult"`
Terms bool `json:"terms"`
Privacy bool `json:"privacy"`
Disclaimer bool `json:"disclaimer"`
}
func (h *ConsentHandlers) Accept(c *fiber.Ctx) error {
me, err := userID(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no user"})
}
var req consentRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON"})
}
if !req.Adult || !req.Terms || !req.Privacy || !req.Disclaimer {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "all consents required"})
}
docs := []struct {
Type consent.DocType
}{
{consent.DocTerms},
{consent.DocPrivacy},
{consent.DocDisclaimer},
{consent.DocAdult},
}
for _, d := range docs {
err := h.Consent.Record(c.UserContext(), &consent.Consent{
UserID: me,
DocType: d.Type,
DocVersion: "1.0",
IP: c.IP(),
UserAgent: c.Get("User-Agent"),
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
}
}
_ = h.Audit.Log(c.UserContext(), &audit.Event{
UserID: &me, Action: "consents.accept",
IP: c.IP(), UserAgent: c.Get("User-Agent"),
})
return c.JSON(fiber.Map{"ok": true, "accepted_at": time.Now()})
}

View File

@ -0,0 +1,170 @@
package handlers
import (
"net/http"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"github.com/buhapp/backend/internal/audit"
"github.com/buhapp/backend/internal/auth"
"github.com/buhapp/backend/internal/consent"
"github.com/buhapp/backend/internal/telegram"
"github.com/buhapp/backend/internal/users"
)
// TGHandler — обработчики для Telegram WebApp
type TGHandler struct {
Bot *telegram.Bot
Users *users.Repo
Auth *auth.Service
Consent *consent.Repo
Audit *audit.Repo
JWTSecret []byte
}
func NewTGHandler(bot *telegram.Bot, u *users.Repo, a *auth.Service, c *consent.Repo, au *audit.Repo, secret []byte) *TGHandler {
return &TGHandler{Bot: bot, Users: u, Auth: a, Consent: c, Audit: au, JWTSecret: secret}
}
type tgAuthRequest struct {
InitData string `json:"init_data"`
}
// TGAuth — логин/регистрация через Telegram WebApp initData
func (h *TGHandler) TGAuth(c *fiber.Ctx) error {
var req tgAuthRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON"})
}
if h.Bot == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "telegram not configured"})
}
// Проверяем подпись
tgData, err := telegram.ValidateInitData(h.Bot.Token, req.InitData, 5*time.Minute)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid init data: " + err.Error()})
}
if tgData.User == nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "no user in init data"})
}
tgID := tgData.User.ID
uid := telegram.UserIDFromTelegram(tgID)
// Получаем или создаём пользователя
u, err := h.Users.GetByTelegramID(c.UserContext(), tgID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
}
if u == nil {
// Регистрируем нового
name := strings.TrimSpace(tgData.User.FirstName)
if tgData.User.LastName != "" {
name += " " + tgData.User.LastName
}
if name == "" {
name = "User"
}
u = &users.User{
ID: uid,
Name: name,
IsVerified: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := h.Users.CreateWithTelegramID(c.UserContext(), u, tgID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "create user failed: " + err.Error()})
}
// аудит
_ = h.Audit.Log(c.UserContext(), &audit.Event{
UserID: &uid, Action: "user.register_telegram",
IP: c.IP(), UserAgent: c.Get("User-Agent"),
})
} else {
// обновить last_seen_at
_ = h.Users.TouchLastSeen(c.UserContext(), u.ID)
}
// Выпускаем JWT
tokens, err := h.Auth.Generate(u.ID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "token generation failed"})
}
return c.JSON(fiber.Map{
"user": u,
"tokens": tokens,
"tg": fiber.Map{
"id": tgData.User.ID,
"username": tgData.User.Username,
"first_name": tgData.User.FirstName,
"photo_url": tgData.User.PhotoURL,
},
})
}
// Webhook для бота — если будешь использовать webhook
func (h *TGHandler) BotWebhook(c *fiber.Ctx) error {
var u telegram.Update
if err := c.BodyParser(&u); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid"})
}
h.handleUpdate(c.UserContext(), u)
return c.JSON(fiber.Map{"ok": true})
}
// ProcessUpdate — обработчик апдейтов (вызывается из bot loop)
func (h *TGHandler) ProcessUpdate(u telegram.Update) {
h.handleUpdate(nil, u)
}
func (h *TGHandler) handleUpdate(ctx interface{}, u telegram.Update) {
if u.Message != nil {
h.handleMessage(u.Message)
}
if u.Callback != nil {
h.handleCallback(u.Callback)
}
}
func (h *TGHandler) handleMessage(m *telegram.Message) {
if m.Text == "" || m.Chat == nil {
return
}
switch m.Text {
case "/start":
name := ""
if m.From != nil {
name = m.From.FirstName
}
h.Bot.SendMessage(m.Chat.ID,
"Привет"+ifNonEmpty(name, ", "+name)+"!\n\nBuhApp — приложение для поиска компании.\n\nНажми кнопку ниже, чтобы открыть:",
func(opts *telegram.MessageOptions) {
opts.ParseMode = "HTML"
opts.ReplyMarkup = telegram.InlineKeyboard([][]telegram.InlineButton{{
{Text: "🚀 Открыть BuhApp", WebApp: &telegram.WebAppInfo{URL: "https://app.buhapp.mygoodservice.ru"}},
}})
},
)
}
}
func (h *TGHandler) handleCallback(cb *telegram.Callback) {
_ = h.Bot.AnswerCallback(cb.ID, "Открывай Mini App!")
}
func ifNonEmpty(s, prefix string) string {
if s == "" {
return ""
}
return prefix
}
// bcrypt не используется здесь, оставлю для импорта
var _ = bcrypt.MinCost
var _ = uuid.Nil

155
internal/telegram/auth.go Normal file
View File

@ -0,0 +1,155 @@
package telegram
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"sort"
"strconv"
"strings"
"time"
"github.com/google/uuid"
)
// WebAppUser — пользователь из Telegram WebApp
type WebAppUser struct {
ID int64 `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name,omitempty"`
Username string `json:"username,omitempty"`
LanguageCode string `json:"language_code,omitempty"`
IsBot bool `json:"is_bot,omitempty"`
IsPremium bool `json:"is_premium,omitempty"`
PhotoURL string `json:"photo_url,omitempty"`
}
// InitData — сырой initData от Telegram WebApp
type InitData struct {
QueryID string `json:"query_id,omitempty"`
User *WebAppUser `json:"user,omitempty"`
Receiver *WebAppUser `json:"receiver,omitempty"`
Chat *Chat `json:"chat,omitempty"`
ChatType string `json:"chat_type,omitempty"`
ChatInst string `json:"chat_instance,omitempty"`
StartParam string `json:"start_param,omitempty"`
CanSendAfter int `json:"can_send_after,omitempty"`
AuthDate int64 `json:"auth_date"`
Hash string `json:"hash"`
Signature string `json:"signature,omitempty"`
}
type Chat struct {
ID int64 `json:"id"`
Type string `json:"type"`
Title string `json:"title,omitempty"`
Username string `json:"username,omitempty"`
}
// ValidateInitData — проверка подписи Telegram
func ValidateInitData(botToken string, initData string, maxAge time.Duration) (*InitData, error) {
parts := strings.Split(initData, "&")
if len(parts) == 0 {
return nil, errors.New("empty initData")
}
m := make(map[string]string)
for _, p := range parts {
kv := strings.SplitN(p, "=", 2)
if len(kv) != 2 {
continue
}
m[kv[0]] = kv[1]
}
hash, ok := m["hash"]
if !ok {
return nil, errors.New("no hash")
}
delete(m, "hash")
// сортируем ключи
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
// собираем data-check-string
var b strings.Builder
for i, k := range keys {
if i > 0 {
b.WriteByte('\n')
}
b.WriteString(k)
b.WriteByte('=')
b.WriteString(m[k])
}
checkString := b.String()
// secret = HMAC-SHA256(bot_token, "WebAppData")
secret := hmac.New(sha256.New, []byte("WebAppData"))
secret.Write([]byte(botToken))
secretBytes := secret.Sum(nil)
// hash = HMAC-SHA256(check_string, secret)
h := hmac.New(sha256.New, secretBytes)
h.Write([]byte(checkString))
computed := hex.EncodeToString(h.Sum(nil))
if !hmac.Equal([]byte(computed), []byte(hash)) {
return nil, errors.New("invalid signature")
}
// парсим initData
var data InitData
data.Hash = hash
if v, ok := m["auth_date"]; ok {
ts, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return nil, errors.New("bad auth_date")
}
data.AuthDate = ts
// проверка свежести
if maxAge > 0 {
if time.Now().Unix()-ts > int64(maxAge.Seconds()) {
return nil, errors.New("initData expired")
}
}
}
if v, ok := m["query_id"]; ok { data.QueryID = v }
if v, ok := m["chat_type"]; ok { data.ChatType = v }
if v, ok := m["chat_instance"]; ok { data.ChatInst = v }
if v, ok := m["start_param"]; ok { data.StartParam = v }
if v, ok := m["can_send_after"]; ok {
n, _ := strconv.Atoi(v)
data.CanSendAfter = n
}
if v, ok := m["user"]; ok {
var u WebAppUser
if err := json.Unmarshal([]byte(v), &u); err == nil {
data.User = &u
}
}
if v, ok := m["receiver"]; ok {
var u WebAppUser
if err := json.Unmarshal([]byte(v), &u); err == nil {
data.Receiver = &u
}
}
if v, ok := m["chat"]; ok {
var c Chat
if err := json.Unmarshal([]byte(v), &c); err == nil {
data.Chat = &c
}
}
return &data, nil
}
// UserIDFromTelegram — стабильный UUID из tg_id
func UserIDFromTelegram(tgID int64) uuid.UUID {
ns := uuid.MustParse("1d4a3f50-2a8a-4f55-b1a3-b7e0c1f4e6a0")
return uuid.NewSHA1(ns, []byte(strconv.FormatInt(tgID, 10)))
}

180
internal/telegram/bot.go Normal file
View File

@ -0,0 +1,180 @@
package telegram
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"time"
)
const apiBase = "https://api.telegram.org/bot"
type Bot struct {
Token string
BaseURL string
hc *http.Client
}
func NewBot(token string) *Bot {
return &Bot{
Token: token,
BaseURL: apiBase + token,
hc: &http.Client{Timeout: 15 * time.Second},
}
}
type Update struct {
UpdateID int64 `json:"update_id"`
Message *Message `json:"message,omitempty"`
Callback *Callback `json:"callback_query,omitempty"`
}
type Message struct {
MessageID int64 `json:"message_id"`
From *User `json:"from,omitempty"`
Chat *Chat `json:"chat"`
Text string `json:"text,omitempty"`
Date int64 `json:"date"`
}
type Callback struct {
ID string `json:"id"`
From *User `json:"from,omitempty"`
Message *Message `json:"message,omitempty"`
Data string `json:"data,omitempty"`
}
type User struct {
ID int64 `json:"id"`
IsBot bool `json:"is_bot"`
FirstName string `json:"first_name"`
LastName string `json:"last_name,omitempty"`
Username string `json:"username,omitempty"`
}
// call — обёртка для вызова Telegram Bot API
func (b *Bot) call(method string, params any, result any) error {
body, err := json.Marshal(params)
if err != nil {
return err
}
r, err := b.hc.Post(b.BaseURL+"/"+method, "application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer r.Body.Close()
if r.StatusCode != 200 {
buf, _ := io.ReadAll(r.Body)
return fmt.Errorf("telegram %s: HTTP %d: %s", method, r.StatusCode, string(buf))
}
var resp struct {
OK bool `json:"ok"`
Result json.RawMessage `json:"result"`
Error string `json:"description,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&resp); err != nil {
return err
}
if !resp.OK {
return fmt.Errorf("telegram error: %s", resp.Error)
}
if result != nil && len(resp.Result) > 0 {
return json.Unmarshal(resp.Result, result)
}
return nil
}
// GetUpdates — long polling (для простого бота без webhook)
func (b *Bot) GetUpdates(offset int64, timeoutSec int) ([]Update, error) {
var updates []Update
err := b.call("getUpdates", map[string]any{
"offset": offset,
"timeout": timeoutSec,
"allowed_updates": []string{"message", "callback_query"},
}, &updates)
return updates, err
}
// SendMessage — отправить текстовое сообщение
func (b *Bot) SendMessage(chatID int64, text string, opts ...func(m *MessageOptions)) error {
m := &MessageOptions{}
for _, o := range opts {
o(m)
}
params := map[string]any{
"chat_id": chatID,
"text": text,
}
if m.ParseMode != "" {
params["parse_mode"] = m.ParseMode
}
if m.ReplyMarkup != nil {
params["reply_markup"] = m.ReplyMarkup
}
return b.call("sendMessage", params, nil)
}
// AnswerCallback — ответ на callback_query
func (b *Bot) AnswerCallback(callbackID, text string) error {
return b.call("answerCallbackQuery", map[string]any{
"callback_query_id": callbackID,
"text": text,
}, nil)
}
type MessageOptions struct {
ParseMode string
ReplyMarkup any
}
// ReplyMarkupInline — кнопка-ссылка на WebApp
type ReplyMarkupInline struct {
InlineKeyboard [][]InlineButton `json:"inline_keyboard"`
}
type InlineButton struct {
Text string `json:"text"`
URL string `json:"url,omitempty"`
// WebApp URL — открывает Mini App
WebApp *WebAppInfo `json:"web_app,omitempty"`
}
type WebAppInfo struct {
URL string `json:"url"`
}
func InlineKeyboard(rows ...[]InlineButton) *ReplyMarkupInline {
return &ReplyMarkupInline{InlineKeyboard: rows}
}
// RunLoop — простой long-poll loop с обработчиком update
func (b *Bot) RunLoop(handler func(u Update)) {
var offset int64
for {
updates, err := b.GetUpdates(offset, 30)
if err != nil {
log.Printf("telegram getUpdates: %v", err)
time.Sleep(3 * time.Second)
continue
}
for _, u := range updates {
offset = u.UpdateID + 1
handler(u)
}
}
}
// RunOnce — однократная обработка (для теста)
func (b *Bot) RunOnce(handler func(u Update)) error {
updates, err := b.GetUpdates(0, 0)
if err != nil {
return err
}
for _, u := range updates {
handler(u)
}
return nil
}

View File

@ -14,6 +14,7 @@ type User struct {
ID uuid.UUID `json:"id"`
Email string `json:"email,omitempty"`
Phone string `json:"phone,omitempty"`
TelegramID *int64 `json:"telegram_id,omitempty"`
Name string `json:"name"`
Birthdate *time.Time `json:"birthdate,omitempty"`
Gender string `json:"gender"`
@ -52,6 +53,51 @@ func (r *Repo) UpdateRaw(ctx context.Context, sql string, args ...interface{}) (
return tag.RowsAffected(), nil
}
// CreateWithTelegramID — создать пользователя с привязкой к Telegram
func (r *Repo) CreateWithTelegramID(ctx context.Context, u *User, tgID int64) error {
return r.pool.QueryRow(ctx, `
INSERT INTO users (id, telegram_id, name, password_hash)
VALUES ($1, $2, $3, '')
ON CONFLICT (telegram_id) DO UPDATE
SET name = EXCLUDED.name, updated_at = NOW()
RETURNING id, created_at, updated_at`,
u.ID, tgID, u.Name,
).Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt)
}
func (r *Repo) GetByTelegramID(ctx context.Context, tgID int64) (*User, error) {
u := &User{}
var email, phone, gender, city, bio, photo *string
var birth *time.Time
var telegramID *int64
err := r.pool.QueryRow(ctx, `
SELECT id, email, phone, telegram_id, name, birthdate, gender, city, bio, photo_url,
is_verified, is_blocked, created_at, updated_at, last_seen_at
FROM users WHERE telegram_id=$1`, tgID,
).Scan(&u.ID, &email, &phone, &telegramID, &u.Name, &birth, &gender, &city, &bio, &photo,
&u.IsVerified, &u.IsBlocked, &u.CreatedAt, &u.UpdatedAt, &u.LastSeenAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
if email != nil { u.Email = *email }
if phone != nil { u.Phone = *phone }
if telegramID != nil { u.TelegramID = telegramID }
if gender != nil { u.Gender = *gender }
if city != nil { u.City = *city }
if bio != nil { u.Bio = *bio }
if photo != nil { u.PhotoURL = *photo }
if birth != nil { u.Birthdate = birth }
return u, nil
}
func (r *Repo) TouchLastSeen(ctx context.Context, id uuid.UUID) error {
_, err := r.pool.Exec(ctx, `UPDATE users SET last_seen_at=NOW() WHERE id=$1`, id)
return err
}
func (r *Repo) GetByID(ctx context.Context, id uuid.UUID) (*User, error) {
u := &User{}
var email, phone, gender, city, bio, photo *string

View File

@ -0,0 +1,2 @@
ALTER TABLE users DROP COLUMN IF EXISTS tg_username;
ALTER TABLE users DROP COLUMN IF EXISTS telegram_id;

View File

@ -0,0 +1,6 @@
-- Telegram ID
ALTER TABLE users ADD COLUMN IF NOT EXISTS telegram_id BIGINT UNIQUE;
CREATE INDEX IF NOT EXISTS idx_users_telegram ON users(telegram_id) WHERE telegram_id IS NOT NULL;
-- username в Telegram (опционально)
ALTER TABLE users ADD COLUMN IF NOT EXISTS tg_username TEXT;