190 lines
7.0 KiB
Go
190 lines
7.0 KiB
Go
// 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()
|
||
}
|