feat: bot WhatsApp Business API — Palmas360 inicial

This commit is contained in:
Lizandro Guarnizo
2026-06-04 13:35:23 -05:00
commit 202a263e25
18 changed files with 2100 additions and 0 deletions
+590
View File
@@ -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 &mdash; 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">&#8592; Dashboard</a>
<span class="ubadge">&#128100; {$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 &mdash; actualizando cada 3s</span>
<button class="btn-pause" id="btnPause" onclick="togglePause()">&#9646;&#9646; 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>&#128230; Payload JSON</span><button class="mx" onclick="closeModal()">&#x2715;</button></div>
<div class="mb"><pre id="mpre">Cargando...</pre></div>
</div>
</div>
<script>
const ICONS = {text:'&#128172;',image:'&#128247;',audio:'&#127925;',video:'&#127916;',document:'&#128196;',sticker:'&#127774;',reaction:'&#128077;',location:'&#128205;',interactive:'&#128280;',button:'&#128306;',status:'&#128202;'};
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 ? '&#9654; Reanudar' : '&#9646;&#9646; 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] || '&#128225;';
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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 &mdash; 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 &nbsp;&middot;&nbsp; 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()">&#x2715;</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;
}
}
+128
View File
@@ -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 &mdash; 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">&copy; 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;
}
}
+376
View File
@@ -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;
}
}