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:
parent
778a2da4ef
commit
c6d219c51f
@ -26,6 +26,7 @@ import (
|
|||||||
"github.com/buhapp/backend/internal/redis"
|
"github.com/buhapp/backend/internal/redis"
|
||||||
"github.com/buhapp/backend/internal/reviews"
|
"github.com/buhapp/backend/internal/reviews"
|
||||||
"github.com/buhapp/backend/internal/storage"
|
"github.com/buhapp/backend/internal/storage"
|
||||||
|
"github.com/buhapp/backend/internal/telegram"
|
||||||
"github.com/buhapp/backend/internal/users"
|
"github.com/buhapp/backend/internal/users"
|
||||||
"github.com/buhapp/backend/internal/ws"
|
"github.com/buhapp/backend/internal/ws"
|
||||||
)
|
)
|
||||||
@ -78,10 +79,22 @@ func main() {
|
|||||||
authSvc := auth.NewService(cfg.JWTSecret, cfg.JwTAccessTTL(), cfg.JwtRefreshTTL())
|
authSvc := auth.NewService(cfg.JWTSecret, cfg.JwTAccessTTL(), cfg.JwtRefreshTTL())
|
||||||
hub := ws.NewHub()
|
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)
|
authH := handlers.NewAuthHandler(cfg, usersRepo, consentRepo, auditRepo, authSvc)
|
||||||
h := handlers.New(cfg, usersRepo, prefsRepo, locsRepo, consentRepo, auditRepo, authSvc)
|
h := handlers.New(cfg, usersRepo, prefsRepo, locsRepo, consentRepo, auditRepo, authSvc)
|
||||||
chatH := handlers.NewChatHandlers(chatRepo, auditRepo, hub)
|
chatH := handlers.NewChatHandlers(chatRepo, auditRepo, hub)
|
||||||
reviewH := handlers.NewReviewHandlers(reviewRepo, chatRepo, auditRepo)
|
reviewH := handlers.NewReviewHandlers(reviewRepo, chatRepo, auditRepo)
|
||||||
|
consentH := handlers.NewConsentHandlers(consentRepo, auditRepo)
|
||||||
|
|
||||||
_ = rdb
|
_ = rdb
|
||||||
_ = st
|
_ = st
|
||||||
@ -120,6 +133,11 @@ func main() {
|
|||||||
return c.JSON(fiber.Map{"version": cfg.DisclaimerVersion, "url": "/legal/DISCLAIMER.md"})
|
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 := app.Group("/api/v1")
|
||||||
api.Post("/auth/register", authH.Register)
|
api.Post("/auth/register", authH.Register)
|
||||||
api.Post("/auth/login", authH.Login)
|
api.Post("/auth/login", authH.Login)
|
||||||
@ -154,6 +172,9 @@ func main() {
|
|||||||
protected.Get("/users/:id/reviews", reviewH.ListForUser)
|
protected.Get("/users/:id/reviews", reviewH.ListForUser)
|
||||||
protected.Get("/users/:id/stats", reviewH.Stats)
|
protected.Get("/users/:id/stats", reviewH.Stats)
|
||||||
|
|
||||||
|
// Consents (повторное принятие)
|
||||||
|
protected.Post("/auth/consents", consentH.Accept)
|
||||||
|
|
||||||
// WebSocket — авторизация по ?token=
|
// WebSocket — авторизация по ?token=
|
||||||
app.Get("/ws", ws.AuthMiddleware(authSvc), ws.Upgrade, ws.Handler(hub))
|
app.Get("/ws", ws.AuthMiddleware(authSvc), ws.Upgrade, ws.Handler(hub))
|
||||||
|
|
||||||
@ -165,6 +186,16 @@ func main() {
|
|||||||
_ = app.ShutdownWithTimeout(10 * time.Second)
|
_ = 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)
|
addr := fmt.Sprintf(":%d", cfg.AppPort)
|
||||||
log.Printf("listening on %s", addr)
|
log.Printf("listening on %s", addr)
|
||||||
if err := app.Listen(addr); err != nil {
|
if err := app.Listen(addr); err != nil {
|
||||||
|
|||||||
@ -39,6 +39,9 @@ type Config struct {
|
|||||||
TermsVersion string
|
TermsVersion string
|
||||||
PrivacyVersion string
|
PrivacyVersion string
|
||||||
DisclaimerVersion string
|
DisclaimerVersion string
|
||||||
|
|
||||||
|
// Telegram
|
||||||
|
TelegramBotToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@ -74,6 +77,8 @@ func Load() (*Config, error) {
|
|||||||
TermsVersion: getEnv("TERMS_VERSION", "1.0"),
|
TermsVersion: getEnv("TERMS_VERSION", "1.0"),
|
||||||
PrivacyVersion: getEnv("PRIVACY_VERSION", "1.0"),
|
PrivacyVersion: getEnv("PRIVACY_VERSION", "1.0"),
|
||||||
DisclaimerVersion: getEnv("DISCLAIMER_VERSION", "1.0"),
|
DisclaimerVersion: getEnv("DISCLAIMER_VERSION", "1.0"),
|
||||||
|
|
||||||
|
TelegramBotToken: getEnv("TELEGRAM_BOT_TOKEN", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.JWTSecret == "change-me" {
|
if cfg.JWTSecret == "change-me" {
|
||||||
|
|||||||
66
internal/handlers/consent.go
Normal file
66
internal/handlers/consent.go
Normal 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()})
|
||||||
|
}
|
||||||
170
internal/handlers/telegram.go
Normal file
170
internal/handlers/telegram.go
Normal 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
155
internal/telegram/auth.go
Normal 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
180
internal/telegram/bot.go
Normal 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
|
||||||
|
}
|
||||||
@ -14,6 +14,7 @@ type User struct {
|
|||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
Email string `json:"email,omitempty"`
|
Email string `json:"email,omitempty"`
|
||||||
Phone string `json:"phone,omitempty"`
|
Phone string `json:"phone,omitempty"`
|
||||||
|
TelegramID *int64 `json:"telegram_id,omitempty"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Birthdate *time.Time `json:"birthdate,omitempty"`
|
Birthdate *time.Time `json:"birthdate,omitempty"`
|
||||||
Gender string `json:"gender"`
|
Gender string `json:"gender"`
|
||||||
@ -52,6 +53,51 @@ func (r *Repo) UpdateRaw(ctx context.Context, sql string, args ...interface{}) (
|
|||||||
return tag.RowsAffected(), nil
|
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) {
|
func (r *Repo) GetByID(ctx context.Context, id uuid.UUID) (*User, error) {
|
||||||
u := &User{}
|
u := &User{}
|
||||||
var email, phone, gender, city, bio, photo *string
|
var email, phone, gender, city, bio, photo *string
|
||||||
|
|||||||
2
migrations/0005_telegram.down.sql
Normal file
2
migrations/0005_telegram.down.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS tg_username;
|
||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS telegram_id;
|
||||||
6
migrations/0005_telegram.up.sql
Normal file
6
migrations/0005_telegram.up.sql
Normal 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;
|
||||||
Loading…
Reference in New Issue
Block a user