Files
aura-crm/production/backend/internal/maxbot/webhook.go
T

190 lines
7.0 KiB
Go
Raw 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 maxbot handles the inbound side of the client MAX channel — the
// webhook MAX calls when someone starts the bot or messages it (see
// dev.max.ru/docs-api). Mirrors internal/tgbot almost exactly: a client's
// max_chat_id gets linked either via a one-time deep-link token (carried as
// the bot_started update's `payload` field — MAX's equivalent of Telegram's
// "/start <token>") or by texting phone+order-number directly, reusing
// internal/tgbot.ExtractPhoneAndOrderNumber for that fallback path rather
// than duplicating the same anti-hijack logic.
package maxbot
import (
"bytes"
"context"
"crypto/subtle"
"encoding/json"
"log"
"net/http"
"time"
"production/internal/dbutil"
"production/internal/settings"
"production/internal/smsgw"
"production/internal/tgbot"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5/pgxpool"
)
const (
sendTimeout = 10 * time.Second
baseURL = "https://platform-api2.max.ru"
secretHeader = "X-Max-Bot-Api-Secret"
)
type Handler struct {
db *pgxpool.Pool
client *http.Client
}
func NewHandler(db *pgxpool.Pool) *Handler {
return &Handler{db: db, client: &http.Client{Timeout: sendTimeout}}
}
// Webhook is the public endpoint registered with MAX via POST /subscriptions
// (see setup.go). Always answers 200 once the payload is parseable — same
// "never make the platform retry forever" stance internal/tgbot's own
// Webhook takes.
func (h *Handler) Webhook(c *fiber.Ctx) error {
ctx := context.Background()
s, err := settings.Fetch(ctx, h.db)
if err != nil {
log.Printf("maxbot: settings fetch failed: %v", err)
return c.SendStatus(500)
}
if s.MaxWebhookSecret == "" || subtle.ConstantTimeCompare([]byte(c.Get(secretHeader)), []byte(s.MaxWebhookSecret)) != 1 {
return c.SendStatus(401)
}
upd, err := parseUpdate(c.Body())
if err != nil {
return c.SendStatus(200)
}
switch upd.Type {
case "bot_started":
if upd.Payload != "" {
h.linkByToken(ctx, upd.ChatID, upd.Payload, s)
}
return c.SendStatus(200)
case "bot_stopped":
h.unlink(ctx, upd.ChatID)
return c.SendStatus(200)
case "message_created":
if upd.Text == "/stop" {
h.unlink(ctx, upd.ChatID)
h.sendPlain(ctx, upd.ChatID, "Вы отписались от уведомлений. Чтобы снова их получать, перейдите по новой ссылке от сервисного центра.", s)
return c.SendStatus(200)
}
if phone, orderNumber, ok := tgbot.ExtractPhoneAndOrderNumber(upd.Text); ok {
h.linkByPhoneAndOrderNumber(ctx, upd.ChatID, phone, orderNumber, s)
return c.SendStatus(200)
}
h.sendPlain(ctx, upd.ChatID,
"Чтобы подключить уведомления о заказе, перейдите по ссылке из сообщения сервисного центра или со страницы отслеживания заказа. Либо отправьте одним сообщением номер телефона и номер заказа, например: +79991234567 Н00001",
s)
}
return c.SendStatus(200)
}
func (h *Handler) linkByToken(ctx context.Context, chatID, token string, s settings.Settings) {
var clientID string
err := h.db.QueryRow(ctx,
`SELECT client_id FROM client_notification_links
WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()`, token,
).Scan(&clientID)
if err != nil {
h.sendPlain(ctx, chatID, "Ссылка недействительна или уже истекла. Запросите новую у сервисного центра.", s)
return
}
if !h.linkClient(ctx, chatID, clientID, s) {
return
}
if _, err := h.db.Exec(ctx, `UPDATE client_notification_links SET used_at = NOW() WHERE token = $1`, token); err != nil {
log.Printf("maxbot: mark link used failed for token %s: %v", token, err)
}
}
// linkByPhoneAndOrderNumber mirrors internal/tgbot's own — same table,
// same UNION across orders/cartridge_batches, only the column written
// differs (max_chat_id vs tg_chat_id).
func (h *Handler) linkByPhoneAndOrderNumber(ctx context.Context, chatID, phone, orderNumber string, s settings.Settings) {
normalizedPhone, ok := smsgw.Normalize(phone)
if !ok {
h.sendPlain(ctx, chatID, "Не удалось распознать номер телефона. Отправьте одним сообщением телефон и номер заказа, например: +79991234567 Н00001", s)
return
}
var clientID string
err := h.db.QueryRow(ctx, `
SELECT c.id FROM clients c JOIN orders o ON o.client_id = c.id
WHERE c.phone_normalized = $1 AND o.order_number = $2
UNION
SELECT c.id FROM clients c JOIN cartridge_batches b ON b.client_id = c.id
WHERE c.phone_normalized = $1 AND b.order_number = $2
LIMIT 1`, normalizedPhone, orderNumber,
).Scan(&clientID)
if err != nil {
h.sendPlain(ctx, chatID, "Не нашли заказ с таким номером телефона и номером заказа. Проверьте данные или запросите ссылку у сервисного центра.", s)
return
}
h.linkClient(ctx, chatID, clientID, s)
}
func (h *Handler) linkClient(ctx context.Context, chatID, clientID string, s settings.Settings) bool {
_, err := h.db.Exec(ctx,
`UPDATE clients SET max_chat_id = $1, max_subscribed_at = NOW() WHERE id = $2::uuid`,
chatID, clientID)
if err != nil {
if dbutil.IsUniqueViolation(err) {
h.sendPlain(ctx, chatID, "Этот MAX уже подключён к другому клиенту.", s)
return false
}
log.Printf("maxbot: link failed for client %s: %v", clientID, err)
h.sendPlain(ctx, chatID, "Не удалось подключить уведомления, попробуйте позже.", s)
return false
}
h.sendPlain(ctx, chatID, "Готово! Теперь вы будете получать уведомления о статусе заказа здесь.", s)
return true
}
func (h *Handler) unlink(ctx context.Context, chatID string) {
if _, err := h.db.Exec(ctx,
`UPDATE clients SET max_chat_id = NULL, max_subscribed_at = NULL WHERE max_chat_id = $1`, chatID,
); err != nil {
log.Printf("maxbot: unlink failed for chat %s: %v", chatID, err)
}
}
// sendPlain is best-effort — its own failure is only logged, never
// propagated, since it fires from within a webhook handler that must
// answer MAX regardless. chat_id is a query param, not a body field (see
// dev.max.ru's /messages endpoint) — see clientnotify/max.go's own doc
// comment for why this differs from Telegram's sendMessage shape.
func (h *Handler) sendPlain(ctx context.Context, chatID, text string, s settings.Settings) {
if s.MaxBotToken == "" {
return
}
payload, err := json.Marshal(map[string]string{"text": text})
if err != nil {
return
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
baseURL+"/messages?chat_id="+chatID, bytes.NewReader(payload))
if err != nil {
return
}
req.Header.Set("Authorization", s.MaxBotToken)
req.Header.Set("Content-Type", "application/json")
resp, err := h.client.Do(req)
if err != nil {
log.Printf("maxbot: reply send failed: %v", err)
return
}
resp.Body.Close()
}