feat: bot WhatsApp Business API — Palmas360 inicial
This commit is contained in:
@@ -0,0 +1,590 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
class DashboardController
|
||||
{
|
||||
private const PER_PAGE = 25;
|
||||
|
||||
// ─── GET /admin/dashboard ─────────────────────────────────────────────────
|
||||
|
||||
public static function index(): void
|
||||
{
|
||||
SessionAuth::require();
|
||||
|
||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||
$filter = trim($_GET['type'] ?? '');
|
||||
$search = trim($_GET['search'] ?? '');
|
||||
$date = trim($_GET['date'] ?? date('Y-m-d'));
|
||||
$user = SessionAuth::user();
|
||||
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
$date = date('Y-m-d');
|
||||
}
|
||||
|
||||
try {
|
||||
$db = db();
|
||||
$stats = self::getStats($db);
|
||||
[$logs, $total] = self::getLogs($db, $page, $filter, $search, $date);
|
||||
} catch (\PDOException $e) {
|
||||
$stats = ['total' => 0, 'msgs' => 0, 'media' => 0, 'statuses' => 0];
|
||||
$logs = [];
|
||||
$total = 0;
|
||||
}
|
||||
|
||||
$pages = $total > 0 ? (int)ceil($total / self::PER_PAGE) : 1;
|
||||
self::render(compact('stats', 'logs', 'total', 'page', 'pages', 'filter', 'search', 'date', 'user'));
|
||||
}
|
||||
|
||||
// ─── GET /admin/live ────────────────────────────────────────────────────
|
||||
|
||||
public static function live(): void
|
||||
{
|
||||
SessionAuth::require();
|
||||
$user = SessionAuth::user();
|
||||
$userName = htmlspecialchars($user['name'] ?? 'Admin', ENT_QUOTES, 'UTF-8');
|
||||
|
||||
http_response_code(200);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
echo <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Live — Palmas360</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:'Segoe UI',Arial,sans-serif;background:#f0f2f5;color:#1c1e21;font-size:14px}
|
||||
.topbar{background:linear-gradient(135deg,#0b3d91,#1565c0);color:#fff;padding:13px 24px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100;box-shadow:0 2px 8px rgba(0,0,0,.15)}
|
||||
.topbar h1{font-size:17px;font-weight:700}
|
||||
.topbar-r{display:flex;align-items:center;gap:10px}
|
||||
.ubadge{background:rgba(255,255,255,.18);border-radius:20px;padding:3px 12px;font-size:12px}
|
||||
.btn-nav{background:rgba(255,255,255,.2);color:#fff;border:none;border-radius:8px;padding:6px 14px;font-size:13px;cursor:pointer;text-decoration:none}
|
||||
.btn-nav:hover{background:rgba(255,255,255,.32)}
|
||||
.toolbar{padding:14px 24px;display:flex;align-items:center;gap:12px;background:#fff;margin:14px 24px 0;border-radius:10px;box-shadow:0 1px 5px rgba(0,0,0,.06)}
|
||||
.dot{width:10px;height:10px;border-radius:50%;background:#e74c3c;animation:pulse 1.2s infinite}
|
||||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
|
||||
.dot.paused{background:#aaa;animation:none}
|
||||
#status{font-size:13px;color:#555}
|
||||
.btn-pause{background:#0b3d91;color:#fff;border:none;border-radius:7px;padding:6px 16px;font-size:13px;cursor:pointer}
|
||||
.btn-pause:hover{background:#1565c0}
|
||||
.counter{margin-left:auto;font-size:12px;color:#888}
|
||||
.feed{padding:0 24px 24px;margin-top:12px;display:flex;flex-direction:column;gap:6px}
|
||||
.card{background:#fff;border-radius:10px;padding:13px 16px;box-shadow:0 1px 4px rgba(0,0,0,.07);display:flex;align-items:flex-start;gap:12px;animation:slidein .3s ease}
|
||||
@keyframes slidein{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}}
|
||||
.card-icon{font-size:22px;line-height:1;flex-shrink:0;margin-top:2px}
|
||||
.card-body{flex:1;min-width:0}
|
||||
.card-top{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.card-phone{font-family:monospace;font-size:13px;font-weight:600;color:#0b3d91}
|
||||
.card-name{font-size:12px;color:#888}
|
||||
.card-time{margin-left:auto;font-size:11px;color:#bbb;white-space:nowrap}
|
||||
.card-preview{font-size:13px;color:#444;margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.badge{padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600}
|
||||
.b-text{background:#e8f4fd;color:#1565c0}
|
||||
.b-image{background:#fce8fd;color:#7b1fa2}
|
||||
.b-audio{background:#e8fdf0;color:#1b5e20}
|
||||
.b-video{background:#fdf3e8;color:#e65100}
|
||||
.b-document{background:#e8eaf6;color:#283593}
|
||||
.b-sticker{background:#fff8e1;color:#f57f17}
|
||||
.b-reaction{background:#fce4ec;color:#880e4f}
|
||||
.b-location{background:#e0f2f1;color:#004d40}
|
||||
.b-interactive{background:#ede7f6;color:#4527a0}
|
||||
.b-button{background:#e3f2fd;color:#0d47a1}
|
||||
.b-status{background:#f3f4f6;color:#6b7280}
|
||||
.b-other{background:#f5f5f5;color:#555}
|
||||
.empty{text-align:center;padding:60px;color:#ccc;font-size:15px}
|
||||
#modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:999;align-items:center;justify-content:center;padding:16px}
|
||||
.mc{background:#fff;border-radius:12px;width:100%;max-width:740px;max-height:88vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 8px 40px rgba(0,0,0,.25)}
|
||||
.mh{background:#0b3d91;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:center;font-weight:700}
|
||||
.mx{background:none;border:none;color:#fff;font-size:22px;cursor:pointer}
|
||||
.mb{padding:16px;overflow-y:auto;flex:1}
|
||||
pre{background:#1e1e2e;color:#cdd6f4;padding:16px;border-radius:8px;font-size:12px;line-height:1.7;overflow-x:auto;white-space:pre-wrap;word-break:break-all}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<h1><img src="https://app.palmas360.com/Test/imgLogin/logo.png" alt="Palmas360" style="height:32px;vertical-align:middle;margin-right:8px;filter:brightness(0) invert(1)"> Live Feed</h1>
|
||||
</div>
|
||||
<div class="topbar-r">
|
||||
<a href="/admin/dashboard" class="btn-nav">← Dashboard</a>
|
||||
<span class="ubadge">👤 {$userName}</span>
|
||||
<a href="/logout" class="btn-nav">Salir</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<span class="dot" id="dot"></span>
|
||||
<span id="status">Conectado — actualizando cada 3s</span>
|
||||
<button class="btn-pause" id="btnPause" onclick="togglePause()">▮▮ Pausar</button>
|
||||
<span class="counter" id="counter">0 eventos</span>
|
||||
</div>
|
||||
<div class="feed" id="feed"><p class="empty">Esperando eventos en tiempo real...</p></div>
|
||||
|
||||
<div id="modal" onclick="if(event.target===this)closeModal()">
|
||||
<div class="mc">
|
||||
<div class="mh"><span>📦 Payload JSON</span><button class="mx" onclick="closeModal()">✕</button></div>
|
||||
<div class="mb"><pre id="mpre">Cargando...</pre></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const ICONS = {text:'💬',image:'📷',audio:'🎵',video:'🎬',document:'📄',sticker:'🌞',reaction:'👍',location:'📍',interactive:'🔘',button:'🔲',status:'📊'};
|
||||
const BADGE = t => 'b-'+(ICONS[t]?t:'other');
|
||||
|
||||
let lastId = 0;
|
||||
let paused = false;
|
||||
let count = 0;
|
||||
let interval = null;
|
||||
|
||||
function togglePause() {
|
||||
paused = !paused;
|
||||
document.getElementById('btnPause').innerHTML = paused ? '▶ Reanudar' : '▮▮ Pausar';
|
||||
document.getElementById('dot').className = 'dot' + (paused ? ' paused' : '');
|
||||
document.getElementById('status').textContent = paused ? 'En pausa' : 'Conectado \u2014 actualizando cada 3s';
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (paused) return;
|
||||
try {
|
||||
const r = await fetch('/admin/webhook/stream?after=' + lastId);
|
||||
if (!r.ok) return;
|
||||
const rows = await r.json();
|
||||
if (!rows.length) return;
|
||||
lastId = rows[rows.length - 1].id;
|
||||
const feed = document.getElementById('feed');
|
||||
const empty = feed.querySelector('.empty');
|
||||
if (empty) empty.remove();
|
||||
rows.forEach(row => {
|
||||
count++;
|
||||
const icon = ICONS[row.message_type] || '📡';
|
||||
const badge = BADGE(row.message_type);
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
card.innerHTML = `
|
||||
<div class="card-icon">\${icon}</div>
|
||||
<div class="card-body">
|
||||
<div class="card-top">
|
||||
<span class="card-phone">\${esc(row.from_number)}</span>
|
||||
<span class="card-name">\${esc(row.contact_name)}</span>
|
||||
<span class="badge \${badge}">\${esc(row.message_type)}</span>
|
||||
<span class="card-time">\${esc(row.received_at.substr(11,8))}</span>
|
||||
<button onclick="showRaw(\${row.id})" style="margin-left:4px;background:#e8eaf6;color:#0b3d91;border:none;border-radius:5px;padding:2px 8px;font-size:11px;font-weight:600;cursor:pointer">JSON</button>
|
||||
</div>
|
||||
<div class="card-preview">\${esc(row.message_preview)}</div>
|
||||
</div>`;
|
||||
feed.insertBefore(card, feed.firstChild);
|
||||
});
|
||||
document.getElementById('counter').textContent = count + ' evento' + (count !== 1 ? 's' : '');
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
async function showRaw(id) {
|
||||
document.getElementById('mpre').textContent = 'Cargando...';
|
||||
document.getElementById('modal').style.display = 'flex';
|
||||
try {
|
||||
const r = await fetch('/admin/webhook/raw?id=' + id);
|
||||
const j = await r.json();
|
||||
document.getElementById('mpre').textContent = JSON.stringify(j, null, 2);
|
||||
} catch(e) { document.getElementById('mpre').textContent = 'Error.'; }
|
||||
}
|
||||
function closeModal() { document.getElementById('modal').style.display = 'none'; }
|
||||
document.addEventListener('keydown', e => { if(e.key==='Escape') closeModal(); });
|
||||
|
||||
// Arrancar: obtener el último ID y luego empezar a hacer poll
|
||||
fetch('/admin/webhook/stream?after=0&init=1').then(r=>r.json()).then(rows=>{
|
||||
if(rows.length) lastId = rows[rows.length-1].id;
|
||||
}).finally(() => { interval = setInterval(poll, 3000); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
exit;
|
||||
}
|
||||
|
||||
// ─── GET /admin/webhook/stream?after=N ───────────────────────────────────
|
||||
|
||||
public static function stream(): void
|
||||
{
|
||||
SessionAuth::require();
|
||||
$afterId = max(0, (int)($_GET['after'] ?? 0));
|
||||
$isInit = isset($_GET['init']);
|
||||
|
||||
try {
|
||||
if ($isInit) {
|
||||
// En init devolvemos solo el último ID (sin datos) para anclar
|
||||
$stmt = db()->query('SELECT id FROM webhook_logs ORDER BY id DESC LIMIT 1');
|
||||
$row = $stmt->fetch();
|
||||
$afterId = $row ? (int)$row['id'] : 0;
|
||||
// Devolvemos array vacío — solo queremos anclar lastId en el cliente
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode([]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = db()->prepare("
|
||||
SELECT id, event_field, from_number, contact_name,
|
||||
message_type, message_preview, received_at
|
||||
FROM webhook_logs
|
||||
WHERE id > ?
|
||||
ORDER BY id ASC
|
||||
LIMIT 50
|
||||
");
|
||||
$stmt->execute([$afterId]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (\PDOException $e) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo '[]';
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($rows, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ─── GET /admin/webhook/raw?id=N ─────────────────────────────────────────
|
||||
|
||||
public static function getRaw(): void
|
||||
{
|
||||
SessionAuth::require();
|
||||
$id = max(0, (int)($_GET['id'] ?? 0));
|
||||
if ($id === 0) {
|
||||
jsonResponse(400, ['error' => 'ID inválido']);
|
||||
}
|
||||
try {
|
||||
$stmt = db()->prepare('SELECT raw_payload FROM webhook_logs WHERE id = ? LIMIT 1');
|
||||
$stmt->execute([$id]);
|
||||
$row = $stmt->fetch();
|
||||
} catch (\PDOException $e) {
|
||||
jsonResponse(500, ['error' => 'Error de base de datos']);
|
||||
}
|
||||
if (!$row) {
|
||||
jsonResponse(404, ['error' => 'Registro no encontrado']);
|
||||
}
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo $row['raw_payload'];
|
||||
exit;
|
||||
}
|
||||
|
||||
// ─── Consultas ────────────────────────────────────────────────────────────
|
||||
|
||||
private static function getStats(PDO $db): array
|
||||
{
|
||||
$stmt = $db->query("
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COALESCE(SUM(event_field='messages' AND message_type='text'),0) AS msgs,
|
||||
COALESCE(SUM(event_field='messages' AND message_type<>'text'),0) AS media,
|
||||
COALESCE(SUM(event_field='statuses'),0) AS statuses
|
||||
FROM webhook_logs
|
||||
WHERE DATE(received_at) = CURDATE()
|
||||
");
|
||||
return $stmt->fetch() ?: ['total' => 0, 'msgs' => 0, 'media' => 0, 'statuses' => 0];
|
||||
}
|
||||
|
||||
private static function getLogs(PDO $db, int $page, string $filter, string $search, string $date): array
|
||||
{
|
||||
$where = ['DATE(received_at) = ?'];
|
||||
$params = [$date];
|
||||
|
||||
if ($filter !== '') {
|
||||
$where[] = 'event_field = ?';
|
||||
$params[] = $filter;
|
||||
}
|
||||
if ($search !== '') {
|
||||
$where[] = '(from_number LIKE ? OR contact_name LIKE ? OR message_preview LIKE ?)';
|
||||
$like = '%' . $search . '%';
|
||||
array_push($params, $like, $like, $like);
|
||||
}
|
||||
|
||||
$w = implode(' AND ', $where);
|
||||
|
||||
$cnt = $db->prepare("SELECT COUNT(*) FROM webhook_logs WHERE $w");
|
||||
$cnt->execute($params);
|
||||
$total = (int)$cnt->fetchColumn();
|
||||
|
||||
$limit = self::PER_PAGE;
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$stmt = $db->prepare("
|
||||
SELECT id, event_field, from_number, contact_name,
|
||||
message_type, message_preview, received_at
|
||||
FROM webhook_logs
|
||||
WHERE $w
|
||||
ORDER BY received_at DESC
|
||||
LIMIT $limit OFFSET $offset
|
||||
");
|
||||
$stmt->execute($params);
|
||||
return [$stmt->fetchAll(), $total];
|
||||
}
|
||||
|
||||
// ─── Helpers de vista ─────────────────────────────────────────────────────
|
||||
|
||||
private static function h(mixed $v): string
|
||||
{
|
||||
return htmlspecialchars((string)$v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
private static function typeLabel(string $field, string $type): string
|
||||
{
|
||||
if ($field === 'statuses') {
|
||||
$cls = match ($type) {
|
||||
'sent' => 'badge-blue',
|
||||
'delivered' => 'badge-green',
|
||||
'read' => 'badge-teal',
|
||||
'failed' => 'badge-red',
|
||||
default => 'badge-gray',
|
||||
};
|
||||
return '<span class="badge ' . $cls . '">' . self::h($type) . '</span>';
|
||||
}
|
||||
return match ($type) {
|
||||
'text' => '💬 Texto',
|
||||
'image' => '🖼️ Imagen',
|
||||
'audio' => '🎵 Audio',
|
||||
'video' => '🎥 Video',
|
||||
'document' => '📄 Documento',
|
||||
'location' => '📍 Ubicación',
|
||||
'interactive' => '🔘 Interactivo',
|
||||
'button' => '🔲 Botón',
|
||||
default => self::h($type),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static function render(array $v): void
|
||||
{
|
||||
http_response_code(200);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
|
||||
// Variables para el heredoc
|
||||
$userName = self::h($v['user']['name'] ?? 'Admin');
|
||||
$dateVal = self::h($v['date']);
|
||||
$maxDate = date('Y-m-d');
|
||||
$filterVal = self::h($v['filter']);
|
||||
$searchVal = self::h($v['search']);
|
||||
$totalStr = number_format((int)$v['total']);
|
||||
$selMessages = $v['filter'] === 'messages' ? 'selected' : '';
|
||||
$selStatuses = $v['filter'] === 'statuses' ? 'selected' : '';
|
||||
$sTotal = (int)$v['stats']['total'];
|
||||
$sMsgs = (int)$v['stats']['msgs'];
|
||||
$sMedia = (int)$v['stats']['media'];
|
||||
$sStatuses = (int)$v['stats']['statuses'];
|
||||
|
||||
// Filas de la tabla
|
||||
$rows = '';
|
||||
if (empty($v['logs'])) {
|
||||
$rows = '<tr><td colspan="7" class="empty">Sin eventos para esta fecha.</td></tr>';
|
||||
} else {
|
||||
foreach ($v['logs'] as $log) {
|
||||
$id = (int)$log['id'];
|
||||
$time = self::h(substr($log['received_at'] ?? '', 11, 8));
|
||||
$from = self::h($log['from_number'] ?? '—');
|
||||
$name = self::h($log['contact_name'] ?? '—');
|
||||
$type = self::typeLabel($log['event_field'] ?? '', $log['message_type'] ?? '');
|
||||
$preview = self::h(mb_substr($log['message_preview'] ?? '—', 0, 70));
|
||||
$rows .= "<tr>"
|
||||
. "<td class='id-col'>#{$id}</td>"
|
||||
. "<td>{$time}</td>"
|
||||
. "<td class='phone'>{$from}</td>"
|
||||
. "<td>{$name}</td>"
|
||||
. "<td>{$type}</td>"
|
||||
. "<td class='preview'>{$preview}</td>"
|
||||
. "<td><button class='btn-json' onclick='showRaw({$id})'>JSON</button></td>"
|
||||
. "</tr>\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Paginación
|
||||
$pager = '';
|
||||
if ($v['pages'] > 1) {
|
||||
$pager = '<div class="pager">';
|
||||
for ($i = 1; $i <= min((int)$v['pages'], 20); $i++) {
|
||||
$qs = '?page=' . $i
|
||||
. '&type=' . urlencode($v['filter'])
|
||||
. '&search=' . urlencode($v['search'])
|
||||
. '&date=' . urlencode($v['date']);
|
||||
$active = $i === (int)$v['page'] ? ' active' : '';
|
||||
$pager .= "<a href='{$qs}' class='pg{$active}'>{$i}</a>";
|
||||
}
|
||||
$pager .= '</div>';
|
||||
}
|
||||
|
||||
echo <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin — Palmas360</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:'Segoe UI',Arial,sans-serif;background:#f0f2f5;color:#1c1e21;font-size:14px}
|
||||
/* Topbar */
|
||||
.topbar{background:linear-gradient(135deg,#0b3d91,#1565c0);color:#fff;padding:13px 24px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100;box-shadow:0 2px 8px rgba(0,0,0,.15)}
|
||||
.topbar h1{font-size:17px;font-weight:700}
|
||||
.topbar small{font-size:11px;opacity:.8;display:block;margin-top:2px}
|
||||
.topbar-r{display:flex;align-items:center;gap:10px}
|
||||
.ubadge{background:rgba(255,255,255,.18);border-radius:20px;padding:3px 12px;font-size:12px}
|
||||
.btn-out{background:rgba(255,255,255,.2);color:#fff;border:none;border-radius:8px;padding:6px 14px;font-size:13px;cursor:pointer;text-decoration:none}
|
||||
.btn-out:hover{background:rgba(255,255,255,.32)}
|
||||
/* Stats */
|
||||
.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;padding:18px 24px 0}
|
||||
.sc{background:#fff;border-radius:10px;padding:16px;box-shadow:0 1px 5px rgba(0,0,0,.06);border-left:4px solid #1565c0}
|
||||
.sc .v{font-size:28px;font-weight:700;color:#0b3d91}
|
||||
.sc .l{font-size:12px;color:#888;margin-top:3px}
|
||||
.sc.g{border-color:#27ae60}.sc.g .v{color:#1e8449}
|
||||
.sc.o{border-color:#e67e22}.sc.o .v{color:#ca6f1e}
|
||||
.sc.s{border-color:#7f8c8d}.sc.s .v{color:#616a6b}
|
||||
/* Filters */
|
||||
.fbar{padding:14px 24px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;background:#fff;margin:14px 24px 0;border-radius:10px;box-shadow:0 1px 5px rgba(0,0,0,.06)}
|
||||
.fbar input,.fbar select{padding:7px 11px;border:1px solid #ddd;border-radius:7px;font-size:13px;outline:none}
|
||||
.fbar input:focus,.fbar select:focus{border-color:#1565c0}
|
||||
.bf{background:#0b3d91;color:#fff;border:none;border-radius:7px;padding:7px 16px;font-size:13px;cursor:pointer}
|
||||
.bf:hover{background:#1565c0}
|
||||
.rc{margin-left:auto;font-size:12px;color:#888}
|
||||
/* Table */
|
||||
.tw{margin:12px 24px 24px;background:#fff;border-radius:10px;box-shadow:0 1px 5px rgba(0,0,0,.06);overflow:hidden}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
thead{background:#f8f9fd}
|
||||
th{padding:11px 14px;text-align:left;font-size:11px;font-weight:700;color:#0b3d91;text-transform:uppercase;letter-spacing:.4px;border-bottom:2px solid #e8eaf6}
|
||||
td{padding:10px 14px;border-bottom:1px solid #f0f0f0;vertical-align:middle}
|
||||
tr:hover td{background:#fafbff}
|
||||
tr:last-child td{border-bottom:none}
|
||||
.id-col{color:#bbb;font-size:12px;white-space:nowrap}
|
||||
.phone{font-family:monospace;font-size:13px}
|
||||
.preview{color:#555;max-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.empty{text-align:center;padding:48px;color:#aaa;font-size:15px}
|
||||
/* Badges */
|
||||
.badge{padding:3px 9px;border-radius:12px;font-size:11px;font-weight:600}
|
||||
.badge-blue{background:#dbeafe;color:#1d4ed8}
|
||||
.badge-green{background:#dcfce7;color:#166534}
|
||||
.badge-teal{background:#ccfbf1;color:#0f766e}
|
||||
.badge-red{background:#fee2e2;color:#991b1b}
|
||||
.badge-gray{background:#f3f4f6;color:#6b7280}
|
||||
/* JSON button */
|
||||
.btn-json{background:#e8eaf6;color:#0b3d91;border:none;border-radius:5px;padding:4px 10px;font-size:11px;font-weight:600;cursor:pointer}
|
||||
.btn-json:hover{background:#c5cae9}
|
||||
/* Paginación */
|
||||
.pager{display:flex;justify-content:center;padding:14px;gap:5px;flex-wrap:wrap}
|
||||
.pg{padding:5px 12px;border:1px solid #ddd;border-radius:6px;text-decoration:none;color:#0b3d91;font-size:13px;background:#fff}
|
||||
.pg.active{background:#0b3d91;color:#fff;border-color:#0b3d91}
|
||||
.pg:hover:not(.active){background:#f0f2f5}
|
||||
/* Modal */
|
||||
.modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:999;align-items:center;justify-content:center;padding:16px}
|
||||
.mc{background:#fff;border-radius:12px;width:100%;max-width:740px;max-height:88vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 8px 40px rgba(0,0,0,.25)}
|
||||
.mh{background:#0b3d91;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:center;font-weight:700}
|
||||
.mx{background:none;border:none;color:#fff;font-size:22px;cursor:pointer;line-height:1;padding:0 4px}
|
||||
.mb{padding:16px;overflow-y:auto;flex:1}
|
||||
pre{background:#1e1e2e;color:#cdd6f4;padding:16px;border-radius:8px;font-size:12px;line-height:1.7;overflow-x:auto;white-space:pre-wrap;word-break:break-all}
|
||||
@media(max-width:768px){
|
||||
.stats{grid-template-columns:repeat(2,1fr)}
|
||||
.fbar,.stats,.tw{padding-left:12px;padding-right:12px;margin-left:0;margin-right:0}
|
||||
.tw{margin:8px 0 16px}
|
||||
th:nth-child(4),td:nth-child(4){display:none}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<h1><img src="https://app.palmas360.com/Test/imgLogin/logo.png" alt="Palmas360" style="height:52px;vertical-align:middle;margin-right:12px;filter:brightness(0) invert(1)"> Palmas360 · Admin</h1>
|
||||
<small>Somos19D</small>
|
||||
</div>
|
||||
<div class="topbar-r">
|
||||
<span class="ubadge">👤 {$userName}</span>
|
||||
<a href="/logout" class="btn-out">Salir</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="sc"> <div class="v">{$sTotal}</div> <div class="l">📡 Total hoy</div></div>
|
||||
<div class="sc g"><div class="v">{$sMsgs}</div> <div class="l">💬 Mensajes texto</div></div>
|
||||
<div class="sc o"><div class="v">{$sMedia}</div> <div class="l">📎 Multimedia</div></div>
|
||||
<div class="sc s"><div class="v">{$sStatuses}</div> <div class="l">📊 Estados</div></div>
|
||||
</div>
|
||||
|
||||
<div class="fbar">
|
||||
<form method="GET" action="/admin/dashboard" style="display:flex;gap:8px;flex-wrap:wrap;width:100%;align-items:center">
|
||||
<input type="date" name="date" value="{$dateVal}" max="{$maxDate}">
|
||||
<select name="type">
|
||||
<option value="">Todos los tipos</option>
|
||||
<option value="messages" {$selMessages}>Mensajes</option>
|
||||
<option value="statuses" {$selStatuses}>Estados</option>
|
||||
</select>
|
||||
<input type="text" name="search" value="{$searchVal}" placeholder="Número, nombre, texto..." style="min-width:185px">
|
||||
<button type="submit" class="bf">🔍 Filtrar</button>
|
||||
<span class="rc">{$totalStr} resultado(s)</span>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="tw">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Hora</th>
|
||||
<th>Número</th>
|
||||
<th>Nombre</th>
|
||||
<th>Tipo</th>
|
||||
<th>Preview</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{$rows}</tbody>
|
||||
</table>
|
||||
{$pager}
|
||||
</div>
|
||||
|
||||
<!-- Modal payload -->
|
||||
<div id="modal" class="modal" onclick="if(event.target===this)closeModal()">
|
||||
<div class="mc">
|
||||
<div class="mh">
|
||||
<span>📦 Payload JSON</span>
|
||||
<button class="mx" onclick="closeModal()">✕</button>
|
||||
</div>
|
||||
<div class="mb">
|
||||
<pre id="mpre">Cargando...</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function showRaw(id) {
|
||||
document.getElementById('mpre').textContent = 'Cargando...';
|
||||
document.getElementById('modal').style.display = 'flex';
|
||||
try {
|
||||
const r = await fetch('/admin/webhook/raw?id=' + id);
|
||||
const j = await r.json();
|
||||
document.getElementById('mpre').textContent = JSON.stringify(j, null, 2);
|
||||
} catch (e) {
|
||||
document.getElementById('mpre').textContent = 'Error al cargar el payload.';
|
||||
}
|
||||
}
|
||||
function closeModal() {
|
||||
document.getElementById('modal').style.display = 'none';
|
||||
}
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
|
||||
|
||||
(function () {
|
||||
const titleStyle = 'color: #1e88e5; font-size: 14px; font-weight: 700;';
|
||||
const subtitleStyle = 'color: #90caf9; font-size: 12px;';
|
||||
const warningStyle = 'color: #ffb300; font-size: 12px;';
|
||||
|
||||
console.log('%cDesarrollado por U-site', titleStyle);
|
||||
console.log('%cAlgo cool para tu consola: esto es un panel seguro y sólo para gestión.', subtitleStyle);
|
||||
console.log('%cNo deberías pegar scripts en esta parte del navegador, amigo. Mantén la consola limpia y segura.', warningStyle);
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
class LoginController
|
||||
{
|
||||
public static function showForm(): void
|
||||
{
|
||||
SessionAuth::start();
|
||||
if (!empty($_SESSION['user_id'])) {
|
||||
header('Location: /admin/dashboard');
|
||||
exit;
|
||||
}
|
||||
$token = SessionAuth::csrfToken();
|
||||
$error = $_SESSION['login_error'] ?? '';
|
||||
unset($_SESSION['login_error']);
|
||||
self::renderForm($token, $error);
|
||||
}
|
||||
|
||||
public static function authenticate(): void
|
||||
{
|
||||
SessionAuth::start();
|
||||
SessionAuth::validateCsrf();
|
||||
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
$password = trim($_POST['password'] ?? '');
|
||||
|
||||
if ($email === '' || $password === '') {
|
||||
$_SESSION['login_error'] = 'Completa todos los campos.';
|
||||
header('Location: /login');
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = db()->prepare('SELECT id, name, email, password FROM users WHERE email = ? LIMIT 1');
|
||||
$stmt->execute([$email]);
|
||||
$user = $stmt->fetch();
|
||||
} catch (\PDOException $e) {
|
||||
$_SESSION['login_error'] = 'Error de base de datos.';
|
||||
header('Location: /login');
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!$user || !password_verify($password, $user['password'])) {
|
||||
sleep(1); // freno básico a fuerza bruta
|
||||
$_SESSION['login_error'] = 'Correo o contraseña incorrectos.';
|
||||
header('Location: /login');
|
||||
exit;
|
||||
}
|
||||
|
||||
SessionAuth::login($user);
|
||||
header('Location: /admin/dashboard');
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function logout(): void
|
||||
{
|
||||
SessionAuth::logout();
|
||||
header('Location: /login');
|
||||
exit;
|
||||
}
|
||||
|
||||
// ─── Vista ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static function renderForm(string $token, string $error): void
|
||||
{
|
||||
http_response_code(200);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
$errorHtml = $error
|
||||
? '<div class="alert">' . htmlspecialchars($error, ENT_QUOTES, 'UTF-8') . '</div>'
|
||||
: '';
|
||||
echo <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Ingresar — Palmas360</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:'Segoe UI',Arial,sans-serif;background:#f0f2f5;display:flex;align-items:center;justify-content:center;min-height:100vh}
|
||||
.card{background:#fff;border-radius:14px;box-shadow:0 4px 24px rgba(0,0,0,.10);width:100%;max-width:400px;overflow:hidden}
|
||||
.ch{background:linear-gradient(135deg,#0b3d91 0%,#1565c0 100%);padding:40px 28px;color:#fff;text-align:center}
|
||||
.ch .ico{margin-bottom:16px;display:flex;justify-content:center}
|
||||
.ch .ico img{width:220px;max-width:100%;height:auto}
|
||||
.ch h1{font-size:22px;font-weight:700}
|
||||
.ch p{font-size:13px;opacity:.85;margin-top:5px}
|
||||
.cb{padding:28px}
|
||||
.alert{background:#fdecea;color:#c0392b;border-radius:8px;padding:10px 14px;font-size:13px;margin-bottom:16px}
|
||||
label{display:block;font-size:13px;font-weight:600;color:#444;margin-bottom:5px}
|
||||
input{width:100%;padding:10px 13px;border:1px solid #ddd;border-radius:8px;font-size:14px;outline:none;transition:border .2s;margin-bottom:16px}
|
||||
input:focus{border-color:#1565c0}
|
||||
button{width:100%;padding:12px;background:linear-gradient(135deg,#0b3d91,#1565c0);color:#fff;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer}
|
||||
button:hover{opacity:.92}
|
||||
.foot{text-align:center;padding:14px;font-size:11px;color:#aaa;border-top:1px solid #f0f0f0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="ch">
|
||||
<div class="ico"><img src="https://app.palmas360.com/Test/imgLogin/logo.png" alt="Palmas360"></div>
|
||||
<h1>Palmas360</h1>
|
||||
<p>Panel de administración — Somos19D</p>
|
||||
</div>
|
||||
<div class="cb">
|
||||
{$errorHtml}
|
||||
<form method="POST" action="/login">
|
||||
<input type="hidden" name="_token" value="{$token}">
|
||||
<label for="email">Correo electrónico</label>
|
||||
<input type="email" id="email" name="email" required autocomplete="email" placeholder="admin@palmas360.com">
|
||||
<label for="pwd">Contraseña</label>
|
||||
<input type="password" id="pwd" name="password" required autocomplete="current-password" placeholder="••••••••">
|
||||
<button type="submit">Ingresar al panel</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="foot">© 2025 Somos19D</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
console.log('%cDesarrollado por U-site', 'color:#1e88e5;font-size:14px;font-weight:700;');
|
||||
console.log('%cEste panel es solo para gestión segura.', 'color:#90caf9;font-size:12px;');
|
||||
console.log('%cNo pegues scripts en esta parte del navegador. Si alguien te lo pide, cierra esta ventana y repórtalo.', 'color:#ffb300;font-size:12px;');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Controlador del Webhook de WhatsApp Business API (Meta Cloud API v18+)
|
||||
*
|
||||
* GET /admin/v1/wp-webhook → Verificación del webhook por Meta
|
||||
* POST /admin/v1/wp-webhook → Recepción de eventos (mensajes, estados, etc.)
|
||||
*/
|
||||
class WpWebhook
|
||||
{
|
||||
/** Payload crudo del webhook actual, compartido entre métodos. */
|
||||
private static string $currentRaw = '';
|
||||
|
||||
// ─── GET: Verificación Meta ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Meta envía:
|
||||
* ?hub.mode=subscribe
|
||||
* &hub.verify_token=TU_TOKEN
|
||||
* &hub.challenge=CADENA_ALEATORIA
|
||||
*
|
||||
* Responde con hub.challenge si el token coincide.
|
||||
*/
|
||||
public static function verify(): void
|
||||
{
|
||||
$mode = $_GET['hub_mode'] ?? $_GET['hub.mode'] ?? '';
|
||||
$verifyToken = $_GET['hub_verify_token'] ?? $_GET['hub.verify_token'] ?? '';
|
||||
$challenge = $_GET['hub_challenge'] ?? $_GET['hub.challenge'] ?? '';
|
||||
|
||||
$expectedToken = env('WHATSAPP_VERIFY_TOKEN', '');
|
||||
|
||||
if ($mode === 'subscribe' && hash_equals($expectedToken, $verifyToken)) {
|
||||
http_response_code(200);
|
||||
header('Content-Type: text/plain');
|
||||
echo $challenge;
|
||||
self::log('INFO', 'Webhook verificado por Meta.');
|
||||
exit;
|
||||
}
|
||||
|
||||
self::log('WARN', "Verificación fallida. mode=$mode token=$verifyToken");
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['error' => 'Verificación fallida'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ─── POST: Recepción de eventos ───────────────────────────────────────────
|
||||
|
||||
public static function receive(): void
|
||||
{
|
||||
// 1. Leer body raw
|
||||
$rawBody = file_get_contents('php://input');
|
||||
if ($rawBody === false || $rawBody === '') {
|
||||
self::respond(400, ['error' => 'Body vacío']);
|
||||
}
|
||||
|
||||
// 2. Guardar raw body para los handlers
|
||||
self::$currentRaw = $rawBody;
|
||||
|
||||
// 3. Verificar firma HMAC-SHA256 de Meta
|
||||
self::verifySignature($rawBody);
|
||||
|
||||
// 4. Decodificar JSON
|
||||
$payload = json_decode($rawBody, true);
|
||||
if (!is_array($payload)) {
|
||||
self::respond(400, ['error' => 'JSON inválido']);
|
||||
}
|
||||
|
||||
// 5. Procesar ANTES de responder
|
||||
self::processEvent($payload, $rawBody);
|
||||
|
||||
// 6. Responder 200 a Meta (menos de 20 seg)
|
||||
self::respond(200, ['status' => 'received']);
|
||||
}
|
||||
|
||||
// ─── Procesamiento de eventos ─────────────────────────────────────────────
|
||||
|
||||
private static function processEvent(array $payload, string $raw): void
|
||||
{
|
||||
$object = $payload['object'] ?? '';
|
||||
|
||||
if ($object !== 'whatsapp_business_account') {
|
||||
self::log('WARN', "Objeto desconocido: $object");
|
||||
return;
|
||||
}
|
||||
|
||||
$entries = $payload['entry'] ?? [];
|
||||
foreach ($entries as $entry) {
|
||||
$changes = $entry['changes'] ?? [];
|
||||
foreach ($changes as $change) {
|
||||
$field = $change['field'] ?? '';
|
||||
$value = $change['value'] ?? [];
|
||||
|
||||
match ($field) {
|
||||
'messages', 'conversations' => self::handleMessages($value),
|
||||
'statuses' => self::handleStatuses($value),
|
||||
default => self::log('INFO', "Campo no manejado: $field"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Persistir evento crudo para auditoría
|
||||
self::saveRawEvent($raw);
|
||||
}
|
||||
|
||||
// ─── Mensajes entrantes ───────────────────────────────────────────────────
|
||||
|
||||
private static function handleMessages(array $value): void
|
||||
{
|
||||
$messages = $value['messages'] ?? [];
|
||||
$contacts = $value['contacts'] ?? [];
|
||||
$metadata = $value['metadata'] ?? [];
|
||||
|
||||
$phoneNumberId = $metadata['phone_number_id'] ?? '';
|
||||
$displayPhone = $metadata['display_phone_number'] ?? '';
|
||||
|
||||
foreach ($messages as $msg) {
|
||||
$from = $msg['from'] ?? ''; // Número del remitente
|
||||
$msgId = $msg['id'] ?? '';
|
||||
$type = $msg['type'] ?? 'unknown';
|
||||
$ts = $msg['timestamp'] ?? time();
|
||||
|
||||
// Nombre del contacto (si existe)
|
||||
$name = '';
|
||||
foreach ($contacts as $c) {
|
||||
if (($c['wa_id'] ?? '') === $from) {
|
||||
$name = $c['profile']['name'] ?? '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$context = [
|
||||
'from' => $from,
|
||||
'name' => $name,
|
||||
'message_id' => $msgId,
|
||||
'type' => $type,
|
||||
'timestamp' => $ts,
|
||||
'phone_number_id' => $phoneNumberId,
|
||||
'display_phone' => $displayPhone,
|
||||
];
|
||||
|
||||
match ($type) {
|
||||
'text' => self::handleText($msg, $context),
|
||||
'image' => self::handleMedia($msg, $context, 'image'),
|
||||
'audio' => self::handleMedia($msg, $context, 'audio'),
|
||||
'video' => self::handleMedia($msg, $context, 'video'),
|
||||
'document' => self::handleMedia($msg, $context, 'document'),
|
||||
'sticker' => self::handleMedia($msg, $context, 'sticker'),
|
||||
'location' => self::handleLocation($msg, $context),
|
||||
'interactive' => self::handleInteractive($msg, $context),
|
||||
'button' => self::handleButton($msg, $context),
|
||||
'reaction' => self::handleReaction($msg, $context),
|
||||
default => self::log('INFO', "Tipo de mensaje no manejado: $type | from=$from"),
|
||||
};
|
||||
}
|
||||
|
||||
// Estados que pueden venir dentro del mismo field 'messages'
|
||||
if (!empty($value['statuses'])) {
|
||||
self::handleStatuses($value);
|
||||
}
|
||||
}
|
||||
|
||||
private static function handleText(array $msg, array $ctx): void
|
||||
{
|
||||
$body = $msg['text']['body'] ?? '';
|
||||
self::log('MSG', "[TEXT] {$ctx['from']} ({$ctx['name']}): $body");
|
||||
self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'text', $body);
|
||||
self::saveConversation($ctx, $body);
|
||||
|
||||
// ─── Aquí conectas con tu lógica de bot ──────────────────────────────
|
||||
// Ejemplo: BotHandler::process($ctx, $body);
|
||||
}
|
||||
|
||||
private static function handleMedia(array $msg, array $ctx, string $type): void
|
||||
{
|
||||
$data = $msg[$type] ?? [];
|
||||
$mediaId = $data['id'] ?? '';
|
||||
$mime = $data['mime_type'] ?? '';
|
||||
$caption = $data['caption'] ?? '';
|
||||
$preview = $caption ?: "[$type id:$mediaId]";
|
||||
self::log('MSG', "[" . strtoupper($type) . "] {$ctx['from']} | id=$mediaId mime=$mime caption=$caption");
|
||||
self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], $type, $preview);
|
||||
self::saveConversation($ctx, $preview, $mediaId);
|
||||
}
|
||||
|
||||
private static function handleLocation(array $msg, array $ctx): void
|
||||
{
|
||||
$lat = $msg['location']['latitude'] ?? '';
|
||||
$lng = $msg['location']['longitude'] ?? '';
|
||||
$name = $msg['location']['name'] ?? '';
|
||||
$preview = "lat:$lat lng:$lng" . ($name ? " ($name)" : '');
|
||||
self::log('MSG', "[LOCATION] {$ctx['from']} | lat=$lat lng=$lng name=$name");
|
||||
self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'location', $preview);
|
||||
self::saveConversation($ctx, $preview);
|
||||
}
|
||||
|
||||
private static function handleInteractive(array $msg, array $ctx): void
|
||||
{
|
||||
$iType = $msg['interactive']['type'] ?? '';
|
||||
$reply = match ($iType) {
|
||||
'button_reply' => $msg['interactive']['button_reply'] ?? [],
|
||||
'list_reply' => $msg['interactive']['list_reply'] ?? [],
|
||||
default => [],
|
||||
};
|
||||
$preview = $iType . ': ' . json_encode($reply);
|
||||
self::log('MSG', "[INTERACTIVE/$iType] {$ctx['from']} | reply=" . json_encode($reply));
|
||||
self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'interactive', $preview);
|
||||
self::saveConversation($ctx, $preview);
|
||||
}
|
||||
|
||||
private static function handleButton(array $msg, array $ctx): void
|
||||
{
|
||||
$text = $msg['button']['text'] ?? '';
|
||||
$payload = $msg['button']['payload'] ?? '';
|
||||
$preview = "$text | $payload";
|
||||
self::log('MSG', "[BUTTON] {$ctx['from']} | text=$text payload=$payload");
|
||||
self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'button', $preview);
|
||||
self::saveConversation($ctx, $preview);
|
||||
}
|
||||
|
||||
private static function handleReaction(array $msg, array $ctx): void
|
||||
{
|
||||
$emoji = $msg['reaction']['emoji'] ?? '';
|
||||
$reactTo = $msg['reaction']['message_id'] ?? '';
|
||||
$preview = "emoji:$emoji replyTo:$reactTo";
|
||||
self::log('MSG', "[REACTION] {$ctx['from']} ({$ctx['name']}) | emoji=$emoji msg=$reactTo");
|
||||
self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'reaction', $preview);
|
||||
self::saveConversation($ctx, $preview);
|
||||
}
|
||||
|
||||
// ─── Estados de mensajes enviados ────────────────────────────────────────
|
||||
|
||||
private static function handleStatuses(array $value): void
|
||||
{
|
||||
$statuses = $value['statuses'] ?? [];
|
||||
foreach ($statuses as $s) {
|
||||
$id = $s['id'] ?? '';
|
||||
$status = $s['status'] ?? ''; // sent | delivered | read | failed
|
||||
$recipient = $s['recipient_id'] ?? '';
|
||||
$ts = $s['timestamp'] ?? '';
|
||||
|
||||
if ($status === 'failed') {
|
||||
$errors = $s['errors'] ?? [];
|
||||
self::log('ERROR', "[STATUS:failed] msg=$id recipient=$recipient errors=" . json_encode($errors));
|
||||
} else {
|
||||
self::log('INFO', "[STATUS:$status] msg=$id recipient=$recipient ts=$ts");
|
||||
}
|
||||
self::saveWebhookLog('statuses', $recipient, '', $status, "msg_id:$id");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Firma HMAC-SHA256 de Meta ────────────────────────────────────────────
|
||||
|
||||
private static function verifySignature(string $rawBody): void
|
||||
{
|
||||
$appSecret = env('WHATSAPP_APP_SECRET', '');
|
||||
if ($appSecret === '') {
|
||||
// En desarrollo puedes omitir esta validación; en producción es obligatoria
|
||||
self::log('WARN', 'WHATSAPP_APP_SECRET no configurado. Saltando verificación de firma.');
|
||||
return;
|
||||
}
|
||||
|
||||
$sigHeader = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
|
||||
if (!str_starts_with($sigHeader, 'sha256=')) {
|
||||
self::respond(401, ['error' => 'Firma ausente']);
|
||||
}
|
||||
|
||||
$received = substr($sigHeader, 7);
|
||||
$expected = hash_hmac('sha256', $rawBody, $appSecret);
|
||||
|
||||
if (!hash_equals($expected, $received)) {
|
||||
self::log('WARN', 'Firma HMAC inválida. Posible payload adulterado.');
|
||||
self::respond(401, ['error' => 'Firma inválida']);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Utilidades ───────────────────────────────────────────────────────────
|
||||
|
||||
// ─── Guardar en base de datos ─────────────────────────────────────────────
|
||||
|
||||
private static function saveConversation(array $ctx, string $content, ?string $mediaId = null): void
|
||||
{
|
||||
try {
|
||||
$stmt = db()->prepare("
|
||||
INSERT IGNORE INTO conversations
|
||||
(message_id, phone_number, contact_name, direction, message_type, content, media_id, timestamp)
|
||||
VALUES (?, ?, ?, 'inbound', ?, ?, ?, ?)
|
||||
");
|
||||
$stmt->execute([
|
||||
$ctx['message_id'],
|
||||
$ctx['from'],
|
||||
$ctx['name'],
|
||||
$ctx['type'],
|
||||
mb_substr($content, 0, 1000),
|
||||
$mediaId,
|
||||
$ctx['timestamp'],
|
||||
]);
|
||||
if ($stmt->rowCount() > 0) {
|
||||
$convId = (int) db()->lastInsertId();
|
||||
self::saveNotification($convId, $ctx['from'], $content);
|
||||
}
|
||||
} catch (\PDOException $e) {
|
||||
self::log('ERROR', 'DB saveConversation: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static function saveNotification(int $referenceId, string $phone, string $preview): void
|
||||
{
|
||||
try {
|
||||
$stmt = db()->prepare("
|
||||
INSERT INTO notifications (type, reference_id, phone_number, message)
|
||||
VALUES ('new_message', ?, ?, ?)
|
||||
");
|
||||
$stmt->execute([$referenceId, $phone, mb_substr($preview, 0, 255)]);
|
||||
} catch (\PDOException $e) {
|
||||
self::log('ERROR', 'DB saveNotification: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static function saveWebhookLog(
|
||||
string $field,
|
||||
string $from,
|
||||
string $name,
|
||||
string $type,
|
||||
string $preview
|
||||
): void {
|
||||
try {
|
||||
$stmt = db()->prepare("
|
||||
INSERT INTO webhook_logs
|
||||
(event_field, from_number, contact_name, message_type, message_preview, raw_payload)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$stmt->execute([$field, $from, $name, $type, mb_substr($preview, 0, 500), self::$currentRaw]);
|
||||
} catch (\PDOException $e) {
|
||||
self::log('ERROR', 'DB saveWebhookLog: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarda el evento crudo en storage/events/ para auditoría.
|
||||
*/
|
||||
private static function saveRawEvent(string $raw): void
|
||||
{
|
||||
$dir = dirname(__DIR__, 2) . '/storage/events';
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
$file = $dir . '/' . date('Y-m-d') . '.log';
|
||||
$line = '[' . date('Y-m-d H:i:s') . '] ' . $raw . PHP_EOL;
|
||||
file_put_contents($file, $line, FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
|
||||
private static function log(string $level, string $message): void
|
||||
{
|
||||
$dir = dirname(__DIR__, 2) . '/storage/logs';
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
$file = $dir . '/webhook-' . date('Y-m-d') . '.log';
|
||||
$line = '[' . date('Y-m-d H:i:s') . "] [$level] $message" . PHP_EOL;
|
||||
file_put_contents($file, $line, FILE_APPEND | LOCK_EX);
|
||||
// También a stderr en desarrollo
|
||||
if (env('APP_ENV', 'production') === 'local') {
|
||||
error_log($line);
|
||||
}
|
||||
}
|
||||
|
||||
private static function respond(int $code, array $body): never
|
||||
{
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($body, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user