bot TG
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
package main
|
||||
|
||||
// bot_mercadopago.go — Mercado Pago PIX client + inbound webhook handler.
|
||||
// Built on net/http; no third-party dependency.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const mpAPIBase = "https://api.mercadopago.com"
|
||||
|
||||
type mpClient struct {
|
||||
accessToken string
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
func newMPClient(accessToken string) *mpClient {
|
||||
return &mpClient{accessToken: accessToken, hc: &http.Client{Timeout: 25 * time.Second}}
|
||||
}
|
||||
|
||||
// mpPixResult holds what the bot needs to show the buyer.
|
||||
type mpPixResult struct {
|
||||
PaymentID string
|
||||
QRCode string // copy-and-paste PIX string
|
||||
QRBase64 string // PNG image, base64 (no data: prefix)
|
||||
Status string
|
||||
}
|
||||
|
||||
// CreatePixPayment creates a PIX charge and returns the QR data.
|
||||
// amountCents is BRL cents; expiresAt bounds the QR validity.
|
||||
func (c *mpClient) CreatePixPayment(ctx context.Context, amountCents int, description, payerEmail, externalRef string, expiresAt time.Time, idempotencyKey string) (*mpPixResult, error) {
|
||||
if payerEmail == "" {
|
||||
payerEmail = "comprador@example.com"
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"transaction_amount": float64(amountCents) / 100.0,
|
||||
"description": description,
|
||||
"payment_method_id": "pix",
|
||||
"payer": map[string]interface{}{"email": payerEmail},
|
||||
"date_of_expiration": expiresAt.Format("2006-01-02T15:04:05.000-07:00"),
|
||||
"external_reference": externalRef,
|
||||
}
|
||||
raw, err := c.do(ctx, http.MethodPost, "/v1/payments", body, idempotencyKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
ID json.Number `json:"id"`
|
||||
Status string `json:"status"`
|
||||
PointOfInteraction struct {
|
||||
TransactionData struct {
|
||||
QRCode string `json:"qr_code"`
|
||||
QRCodeBase64 string `json:"qr_code_base64"`
|
||||
} `json:"transaction_data"`
|
||||
} `json:"point_of_interaction"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return nil, fmt.Errorf("mp create payment: parse: %w", err)
|
||||
}
|
||||
if resp.ID.String() == "" {
|
||||
return nil, fmt.Errorf("mp create payment: no id in response: %s", string(raw))
|
||||
}
|
||||
return &mpPixResult{
|
||||
PaymentID: resp.ID.String(),
|
||||
QRCode: resp.PointOfInteraction.TransactionData.QRCode,
|
||||
QRBase64: resp.PointOfInteraction.TransactionData.QRCodeBase64,
|
||||
Status: resp.Status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetPaymentStatus returns the current status of a payment (e.g. "approved").
|
||||
func (c *mpClient) GetPaymentStatus(ctx context.Context, paymentID string) (string, error) {
|
||||
raw, err := c.do(ctx, http.MethodGet, "/v1/payments/"+paymentID, nil, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var resp struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.Status, nil
|
||||
}
|
||||
|
||||
func (c *mpClient) do(ctx context.Context, method, path string, body interface{}, idempotencyKey string) ([]byte, error) {
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, mpAPIBase+path, rdr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if idempotencyKey != "" {
|
||||
req.Header.Set("X-Idempotency-Key", idempotencyKey)
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("mercado pago %s %s: http %d: %s", method, path, resp.StatusCode, string(data))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// verifyMPSignature validates the x-signature header per Mercado Pago's spec.
|
||||
// Manifest: "id:<dataID>;request-id:<x-request-id>;ts:<ts>;" HMAC-SHA256(secret).
|
||||
func verifyMPSignature(xSignature, xRequestID, dataID, secret string) bool {
|
||||
if secret == "" {
|
||||
return true // validation disabled
|
||||
}
|
||||
var ts, v1 string
|
||||
for _, part := range strings.Split(xSignature, ",") {
|
||||
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(kv[0]) {
|
||||
case "ts":
|
||||
ts = strings.TrimSpace(kv[1])
|
||||
case "v1":
|
||||
v1 = strings.TrimSpace(kv[1])
|
||||
}
|
||||
}
|
||||
if ts == "" || v1 == "" {
|
||||
return false
|
||||
}
|
||||
manifest := fmt.Sprintf("id:%s;request-id:%s;ts:%s;", strings.ToLower(dataID), xRequestID, ts)
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(manifest))
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
return hmac.Equal([]byte(expected), []byte(v1))
|
||||
}
|
||||
|
||||
// handleMPWebhook is the public endpoint Mercado Pago calls on payment events.
|
||||
// It never trusts the body: it re-fetches the payment and fulfills idempotently.
|
||||
func handleMPWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
b := currentBot()
|
||||
if b == nil {
|
||||
w.WriteHeader(http.StatusOK) // bot disabled; acknowledge to stop retries
|
||||
return
|
||||
}
|
||||
// Extract the payment id from body or query.
|
||||
dataID := r.URL.Query().Get("data.id")
|
||||
if dataID == "" {
|
||||
dataID = r.URL.Query().Get("id")
|
||||
}
|
||||
var payload struct {
|
||||
Type string `json:"type"`
|
||||
Action string `json:"action"`
|
||||
Data struct {
|
||||
ID json.Number `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if len(body) > 0 {
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
if dataID == "" {
|
||||
dataID = payload.Data.ID.String()
|
||||
}
|
||||
}
|
||||
if dataID == "" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
if !verifyMPSignature(r.Header.Get("x-signature"), r.Header.Get("x-request-id"), dataID, b.cfg.MPWebhookSecret) {
|
||||
log.Printf("[bot] MP webhook: invalid signature for payment %s", dataID)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Acknowledge immediately; process in the background so MP doesn't time out.
|
||||
go b.processPaymentByMPID(dataID)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// centsToBRL formats cents as "R$ 12,34".
|
||||
func centsToBRL(cents int) string {
|
||||
reais := cents / 100
|
||||
cent := cents % 100
|
||||
return "R$ " + strconv.Itoa(reais) + "," + fmt.Sprintf("%02d", cent)
|
||||
}
|
||||
Reference in New Issue
Block a user