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
+24
View File
@@ -0,0 +1,24 @@
# ─── Aplicación ───────────────────────────────────────────────────────────────
APP_ENV=local
APP_URL=https://app.palmas360.com
# ─── JWT ───────────────────────────────────────────────────────────────────────
# Secreto usado para firmar y verificar los tokens Bearer
JWT_SECRET=cambia_esto_por_un_secreto_seguro_minimo_32_chars
# ─── WhatsApp Business API (Meta) ────────────────────────────────────────────
# Token de verificación del webhook (lo defines tú en Meta for Developers)
WHATSAPP_VERIFY_TOKEN=mi_token_de_verificacion_secreto
# App Secret de la aplicación en Meta for Developers
# (Usado para validar la firma HMAC-SHA256 de cada POST entrante)
WHATSAPP_APP_SECRET=app_secret_de_meta_for_developers
# Token de acceso permanente de WhatsApp Business API
WHATSAPP_ACCESS_TOKEN=EAAxxxxxxxxx...
# ID del número de teléfono de WhatsApp Business
WHATSAPP_PHONE_NUMBER_ID=1234567890
# ID de la cuenta de WhatsApp Business
WHATSAPP_BUSINESS_ACCOUNT_ID=0987654321
+4
View File
@@ -0,0 +1,4 @@
.env
storage/
*.log
.DS_Store
Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

+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;
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
function db(): PDO
{
static $pdo = null;
if ($pdo !== null) {
return $pdo;
}
$host = env('DB_HOST', '127.0.0.1');
$port = env('DB_PORT', '3306');
$name = env('DB_NAME', 'palmas360');
$user = env('DB_USER', 'root');
$pass = env('DB_PASS', '');
try {
$pdo = new PDO(
"mysql:host=$host;port=$port;dbname=$name;charset=utf8mb4",
$user,
$pass,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
} catch (\PDOException $e) {
http_response_code(503);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'Error de conexión a la base de datos.'], JSON_UNESCAPED_UNICODE);
exit;
}
return $pdo;
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
/**
* Cargador mínimo de .env sin dependencias externas.
* Lee el archivo .env de la raíz del proyecto y puebla $_ENV / getenv().
*/
(function () {
$file = dirname(__DIR__) . '/.env';
if (!file_exists($file)) {
return;
}
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$line = trim($line);
// Ignorar comentarios
if ($line === '' || str_starts_with($line, '#')) {
continue;
}
if (!str_contains($line, '=')) {
continue;
}
[$key, $value] = explode('=', $line, 2);
$key = trim($key);
$value = trim($value, " \t\n\r\0\x0B\"'");
if (!array_key_exists($key, $_ENV)) {
$_ENV[$key] = $value;
putenv("$key=$value");
}
}
})();
function env(string $key, mixed $default = null): mixed
{
return $_ENV[$key] ?? getenv($key) ?: $default;
}
+88
View File
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
/**
* Middleware de autenticación JWT HS256.
*
* Valida el header: Authorization: Bearer <token>
* El secreto se toma de la variable de entorno JWT_SECRET.
*/
class Auth
{
/**
* Verifica el Bearer token. Termina con 401 si es inválido.
* Devuelve el payload decodificado si es válido.
*/
public static function requireBearer(): array
{
$header = $_SERVER['HTTP_AUTHORIZATION']
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
?? '';
if (!str_starts_with($header, 'Bearer ')) {
self::abort(401, 'Token de autorización requerido.');
}
$token = substr($header, 7);
return self::verifyJwt($token);
}
// ─── JWT HS256 ────────────────────────────────────────────────────────────
private static function verifyJwt(string $token): array
{
$parts = explode('.', $token);
if (count($parts) !== 3) {
self::abort(401, 'Token malformado.');
}
[$b64Header, $b64Payload, $b64Sig] = $parts;
// Verificar firma
$secret = env('JWT_SECRET', '');
if ($secret === '') {
self::abort(500, 'JWT_SECRET no configurado.');
}
$data = "$b64Header.$b64Payload";
$expected = self::base64UrlEncode(
hash_hmac('sha256', $data, $secret, true)
);
// Comparación segura contra timing attacks
if (!hash_equals($expected, $b64Sig)) {
self::abort(401, 'Firma JWT inválida.');
}
// Decodificar payload
$payload = json_decode(self::base64UrlDecode($b64Payload), true);
if (!is_array($payload)) {
self::abort(401, 'Payload JWT inválido.');
}
// Validar expiración si existe
if (isset($payload['exp']) && $payload['exp'] < time()) {
self::abort(401, 'Token expirado.');
}
return $payload;
}
private static function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
private static function base64UrlDecode(string $data): string
{
return base64_decode(strtr($data, '-_', '+/'));
}
private static function abort(int $code, string $message): never
{
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => $message], JSON_UNESCAPED_UNICODE);
exit;
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
class SessionAuth
{
public static function start(): void
{
if (session_status() === PHP_SESSION_NONE) {
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => isset($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
}
}
/** Redirige a /login si no hay sesión activa. */
public static function require(): void
{
self::start();
if (empty($_SESSION['user_id'])) {
header('Location: /login');
exit;
}
}
public static function login(array $user): void
{
self::start();
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
$_SESSION['user'] = [
'id' => $user['id'],
'name' => $user['name'],
'email' => $user['email'],
];
}
public static function logout(): void
{
self::start();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy();
}
public static function user(): array
{
return $_SESSION['user'] ?? [];
}
public static function csrfToken(): string
{
self::start();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
public static function validateCsrf(): void
{
$token = $_POST['_token'] ?? '';
if (!isset($_SESSION['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $token)) {
http_response_code(403);
exit('Token CSRF inválido.');
}
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "bot-palmas360",
"version": "1.0.0",
"description": "WhatsApp Business API webhook — Somos19D / Palmas360",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"express": "^4.19.2",
"dotenv": "^16.4.5",
"jsonwebtoken": "^9.0.2",
"axios": "^1.7.2"
},
"devDependencies": {
"nodemon": "^3.1.4"
},
"engines": {
"node": ">=18.0.0"
}
}
+9
View File
@@ -0,0 +1,9 @@
Options -Indexes
RewriteEngine On
# Permitir acceso directo a archivos y carpetas reales
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
# Todo lo demás pasa por index.php
RewriteRule ^ index.php [QSA,L]
+170
View File
@@ -0,0 +1,170 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Condiciones del Servicio — Somos19D</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', Arial, sans-serif;
background: #f0f2f5;
color: #1c1e21;
font-size: 15px;
line-height: 1.65;
}
.whatsapp-bar {
background: #25D366;
color: #fff;
text-align: center;
padding: 10px 16px;
font-size: 13px;
font-weight: 600;
}
.card {
max-width: 680px;
margin: 24px auto;
background: #fff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}
.card-header {
background: linear-gradient(135deg, #0b3d91 0%, #1565c0 100%);
padding: 28px 24px 20px;
color: #fff;
}
.card-header .nit {
font-size: 12px;
background: rgba(255,255,255,0.15);
border-radius: 20px;
display: inline-block;
padding: 3px 10px;
margin-bottom: 10px;
}
.card-header h1 { font-size: 22px; font-weight: 700; margin-bottom: 6px; }
.card-header p { font-size: 13px; opacity: 0.88; }
.card-body { padding: 24px; }
.section { border-left: 3px solid #1565c0; padding: 12px 16px; background: #f8f9fd; border-radius: 0 8px 8px 0; margin-bottom: 18px; }
.section h2 { font-size: 14px; font-weight: 700; color: #0b3d91; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.4px; }
.section p, .section li { font-size: 14px; color: #333; }
.section ul { padding-left: 18px; margin-top: 6px; }
.section li { margin-bottom: 4px; }
.section a { color: #0b3d91; }
.highlight { background: #e8eaf6; border-radius: 8px; padding: 12px 14px; font-size: 13px; color: #0b3d91; margin-top: 10px; font-weight: 500; }
footer { text-align: center; padding: 16px; font-size: 12px; color: #888; background: #fafafa; border-top: 1px solid #eee; }
@media (max-width: 600px) {
.card { margin: 0; border-radius: 0; }
.card-body { padding: 16px; }
}
</style>
</head>
<body>
<div class="whatsapp-bar">📄 Condiciones Oficiales del Servicio · Somos19D</div>
<div class="card">
<div class="card-header">
<span class="nit">NIT: 901677603-5</span>
<h1>Condiciones del Servicio</h1>
<p>Somos19D — Empresa jurídicamente constituida en Colombia<br>Vigente desde el 1 de enero de 2025</p>
</div>
<div class="card-body">
<div class="section">
<h2>1. Identificación del prestador</h2>
<p><strong>Somos19D</strong>, empresa jurídicamente constituida en Colombia, con NIT <strong>901677603-5</strong>, es responsable de la prestación de los servicios descritos en estas condiciones. Nuestros canales de atención incluyen WhatsApp y plataformas digitales.</p>
</div>
<div class="section">
<h2>2. Aceptación de las condiciones</h2>
<p>Al usar nuestros servicios —ya sea a través de WhatsApp, nuestro sitio web u otros canales— aceptas íntegramente estas condiciones. Si no estás de acuerdo, debes abstenerte de utilizar el servicio.</p>
</div>
<div class="section">
<h2>3. WhatsApp Business Platform</h2>
<p>Somos19D utiliza la <strong>WhatsApp Business Platform</strong> de <strong>Meta Platforms, Inc.</strong> para prestar servicios de atención y comunicación. Al usar este canal:</p>
<ul>
<li>Aceptas las <a href="https://www.whatsapp.com/legal/business-terms" target="_blank" rel="noopener noreferrer">Condiciones del Servicio de WhatsApp Business</a> de Meta.</li>
<li>Los mensajes están sujetos a las políticas de uso aceptable de WhatsApp.</li>
<li>Queda prohibido el uso del canal para spam, mensajes masivos no autorizados o contenido ilegal.</li>
<li>Somos19D puede bloquear usuarios que infrinjan las políticas de Meta o las presentes condiciones.</li>
</ul>
<div class="highlight">📱 Al escribirnos por WhatsApp confirmas que has leído y aceptado estas condiciones.</div>
</div>
<div class="section">
<h2>4. Descripción del servicio</h2>
<p>Somos19D ofrece servicios digitales y de información a través de múltiples canales incluyendo WhatsApp Business, sitio web y otros medios digitales. Los detalles específicos de cada servicio serán informados al usuario antes de su contratación, incluyendo tarifas, plazos y condiciones particulares.</p>
</div>
<div class="section">
<h2>5. Obligaciones del usuario</h2>
<ul>
<li>Proporcionar información veraz, exacta y actualizada</li>
<li>Usar el servicio únicamente para fines lícitos</li>
<li>No suplantar la identidad de terceros</li>
<li>No interferir en el funcionamiento del servicio</li>
<li>Respetar los derechos de propiedad intelectual de Somos19D</li>
<li>Mantener la confidencialidad de sus credenciales de acceso</li>
</ul>
</div>
<div class="section">
<h2>6. Obligaciones de Somos19D</h2>
<ul>
<li>Prestar el servicio con la calidad y oportunidad acordada</li>
<li>Proteger los datos personales del usuario conforme a la ley</li>
<li>Informar oportunamente sobre cambios en el servicio o en estas condiciones</li>
<li>Atender las reclamaciones del usuario en tiempos razonables</li>
</ul>
</div>
<div class="section">
<h2>7. Tarifas y pagos</h2>
<p>Los precios del servicio serán comunicados de forma clara antes de cada transacción. Somos19D se reserva el derecho de modificar sus tarifas, notificando al usuario con antelación razonable. Los pagos realizados no son reembolsables salvo disposición expresa contraria.</p>
</div>
<div class="section">
<h2>8. Propiedad intelectual</h2>
<p>Todo el contenido, diseño, marca, textos y elementos digitales de Somos19D son propiedad exclusiva de la empresa. Está prohibida su reproducción, distribución o uso sin autorización escrita previa.</p>
</div>
<div class="section">
<h2>9. Limitación de responsabilidad</h2>
<p>Somos19D no será responsable por:</p>
<ul>
<li>Interrupciones del servicio por causas de fuerza mayor o fallas de terceros</li>
<li>Daños derivados del uso indebido del servicio por parte del usuario</li>
<li>Pérdidas de datos por causas ajenas a nuestra infraestructura</li>
<li>Acciones de terceros no autorizados</li>
</ul>
</div>
<div class="section">
<h2>10. Suspensión del servicio</h2>
<p>Somos19D podrá suspender o terminar el servicio a un usuario si este incumple estas condiciones, proporciona información falsa o realiza actividades que perjudiquen a la empresa o a otros usuarios. Se notificará al usuario antes de la suspensión cuando sea posible.</p>
</div>
<div class="section">
<h2>11. Legislación y jurisdicción</h2>
<p>Estas condiciones se rigen por las leyes de la <strong>República de Colombia</strong>. Cualquier controversia se resolverá conforme a la normativa colombiana vigente. Para consumidores aplican los mecanismos de protección al consumidor establecidos en la Ley 1480 de 2011.</p>
<div class="highlight">📌 Ante cualquier disputa, puedes contactar a la Superintendencia de Industria y Comercio (SIC) o a la Defensoría del Consumidor.</div>
</div>
<div class="section">
<h2>12. Modificaciones</h2>
<p>Podemos actualizar estas condiciones en cualquier momento. Los cambios se comunicarán por WhatsApp u otros canales oficiales con al menos 5 días de anticipación. El uso continuo del servicio implica la aceptación de las nuevas condiciones.</p>
</div>
<div class="section">
<h2>13. Contacto oficial</h2>
<p>Para dudas, reclamaciones o ejercicio de derechos, comunícate con <strong>Somos19D</strong> a través de nuestros canales oficiales de WhatsApp o correo electrónico.</p>
</div>
</div>
<footer>© 2025 Somos19D · NIT 901677603-5 · Todos los derechos reservados<br>Documento generado el 4 de junio de 2026</footer>
</div>
</body>
</html>
+179
View File
@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Eliminación de Datos — Somos19D</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', Arial, sans-serif;
background: #f0f2f5;
color: #1c1e21;
font-size: 15px;
line-height: 1.65;
}
.whatsapp-bar {
background: #25D366;
color: #fff;
text-align: center;
padding: 10px 16px;
font-size: 13px;
font-weight: 600;
}
.card {
max-width: 680px;
margin: 24px auto;
background: #fff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}
.card-header {
background: linear-gradient(135deg, #0b3d91 0%, #1565c0 100%);
padding: 28px 24px 20px;
color: #fff;
}
.card-header .nit {
font-size: 12px;
background: rgba(255,255,255,0.15);
border-radius: 20px;
display: inline-block;
padding: 3px 10px;
margin-bottom: 10px;
}
.card-header h1 { font-size: 22px; font-weight: 700; margin-bottom: 6px; }
.card-header p { font-size: 13px; opacity: 0.88; }
.card-body { padding: 24px; }
.section { border-left: 3px solid #1565c0; padding: 12px 16px; background: #f8f9fd; border-radius: 0 8px 8px 0; margin-bottom: 18px; }
.section h2 { font-size: 14px; font-weight: 700; color: #0b3d91; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.4px; }
.section p, .section li { font-size: 14px; color: #333; }
.section ul { padding-left: 18px; margin-top: 6px; }
.section li { margin-bottom: 4px; }
.steps { counter-reset: step; }
.step { display: flex; gap: 12px; align-items: flex-start; margin-bottom: 14px; }
.step-num {
background: #0b3d91; color: #fff;
border-radius: 50%; min-width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 13px; flex-shrink: 0;
}
.step-text { font-size: 14px; color: #333; padding-top: 4px; }
.step-text strong { color: #0b3d91; }
.badge-red { display: inline-block; background: #e8eaf6; color: #0b3d91; font-size: 12px; padding: 3px 10px; border-radius: 12px; margin-top: 8px; font-weight: 600; }
footer { text-align: center; padding: 16px; font-size: 12px; color: #888; background: #fafafa; border-top: 1px solid #eee; }
@media (max-width: 600px) {
.card { margin: 0; border-radius: 0; }
.card-body { padding: 16px; }
}
</style>
</head>
<body>
<div class="whatsapp-bar">🗑️ Solicitud de Eliminación de Datos · Somos19D</div>
<div class="card">
<div class="card-header">
<span class="nit">NIT: 901677603-5</span>
<h1>Eliminación de Datos del Usuario</h1>
<p>Somos19D — Tu privacidad es un derecho.<br>Ejercelo de forma sencilla y sin costo.</p>
</div>
<div class="card-body">
<div class="section">
<h2>¿Por qué existe este derecho?</h2>
<p>La Ley 1581 de 2012 (Colombia) y el Habeas Data te otorgan el derecho de solicitar la supresión de tus datos personales cuando ya no son necesarios, cuando retiras tu consentimiento o cuando el tratamiento infringe la ley. <strong>Somos19D</strong> (NIT 901677603-5) está obligada a respetar y tramitar esta solicitud.</p>
</div>
<div class="section">
<h2>¿Cuándo puedes pedirlo?</h2>
<ul>
<li>Cuando los datos ya no son necesarios para el servicio contratado</li>
<li>Cuando retiras tu consentimiento de tratamiento</li>
<li>Cuando consideras que el tratamiento viola la ley</li>
<li>Cuando el período de conservación acordado ha vencido</li>
<li>Cuando nunca autorizaste el uso de tus datos</li>
</ul>
</div>
<div class="section">
<h2>Cómo hacer tu solicitud — paso a paso</h2>
<div class="steps">
<div class="step">
<div class="step-num">1</div>
<div class="step-text">Escríbenos por nuestros <strong>canales oficiales de WhatsApp</strong> o correo electrónico de Somos19D.</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-text">Indica: <strong>"Solicito eliminación de mis datos personales"</strong> y proporciona tu nombre completo y documento de identidad.</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-text">Menciona qué <strong>datos específicos</strong> deseas eliminar o si quieres la eliminación total de tu historial.</div>
</div>
<div class="step">
<div class="step-num">4</div>
<div class="step-text">Confirmaremos la recepción de tu solicitud en <strong>máximo 2 días hábiles</strong>.</div>
</div>
<div class="step">
<div class="step-num">5</div>
<div class="step-text">La eliminación se ejecutará en un plazo máximo de <strong>15 días hábiles</strong> contados desde la confirmación.</div>
</div>
</div>
</div>
<div class="section">
<h2>Información necesaria en tu solicitud</h2>
<ul>
<li>Nombre completo</li>
<li>Número de cédula o documento de identidad</li>
<li>Correo electrónico o número de WhatsApp asociado a la cuenta</li>
<li>Descripción de los datos que deseas eliminar</li>
<li>Motivo de la solicitud (opcional, pero útil)</li>
</ul>
</div>
<div class="section">
<h2>Excepciones legales</h2>
<p>Existen casos en los que <strong>no podemos eliminar</strong> algunos datos de forma inmediata:</p>
<ul>
<li>Cuando la ley exige conservarlos (ej. datos tributarios o contables)</li>
<li>Cuando son necesarios para cumplir un contrato vigente contigo</li>
<li>Cuando exista un proceso legal en curso</li>
</ul>
<p style="margin-top:8px;">En estos casos, te informaremos el motivo y el plazo estimado de conservación.</p>
<span class="badge-red">⚠️ Siempre te informaremos antes de denegar una solicitud</span>
</div>
<div class="section">
<h2>¿Qué pasa después de la eliminación?</h2>
<ul>
<li>Tus datos serán borrados permanentemente de nuestras bases de datos activas</li>
<li>Las copias de respaldo se eliminarán en el ciclo de actualización más próximo</li>
<li>Recibirás confirmación escrita (por WhatsApp o correo) una vez completado el proceso</li>
</ul>
</div>
<div class="section">
<h2>Datos de WhatsApp y Meta</h2>
<p>Si interactuaste con <strong>Somos19D</strong> a través de <strong>WhatsApp Business API</strong>, ten en cuenta que:</p>
<ul>
<li>Los datos de la conversación en WhatsApp están también sujetos a las políticas de <strong>Meta Platforms, Inc.</strong></li>
<li>Para eliminar tus datos en Meta directamente, visita: <a href="https://www.facebook.com/help/contact/540977946302970" target="_blank" rel="noopener noreferrer" style="color:#c0392b;">Centro de ayuda de Meta</a>.</li>
<li>Somos19D eliminará los registros internos de tus conversaciones y datos asociados dentro del plazo indicado.</li>
<li>Esta página puede ser presentada ante Meta como URL de instrucciones de eliminación de datos.</li>
</ul>
<span class="badge-red">📋 Esta URL es válida como "Data Deletion Instructions URL" para Meta/WhatsApp Business</span>
</div>
<div class="section">
<h2>¿No recibes respuesta?</h2>
<p>Si en 15 días hábiles no has recibido respuesta, puedes radicar una queja ante la <strong>Superintendencia de Industria y Comercio (SIC)</strong> de Colombia en <a href="https://www.sic.gov.co" target="_blank" rel="noopener noreferrer" style="color:#1565c0;">www.sic.gov.co</a>.</p>
</div>
</div>
<footer>© 2025 Somos19D · NIT 901677603-5 · Todos los derechos reservados<br>Documento generado el 4 de junio de 2026</footer>
</div>
</body>
</html>
+72
View File
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../config/env.php';
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../admin/v1/WpWebhook.php';
require_once __DIR__ . '/../middleware/SessionAuth.php';
require_once __DIR__ . '/../admin/LoginController.php';
require_once __DIR__ . '/../admin/DashboardController.php';
// ─── Helper de respuesta JSON ────────────────────────────────────────────────
function jsonResponse(int $status, array $body): void
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($body, JSON_UNESCAPED_UNICODE);
exit;
}
function serveHtml(string $filename): void
{
$file = __DIR__ . '/' . $filename;
if (!file_exists($file)) {
jsonResponse(404, ['error' => 'Página no encontrada']);
}
http_response_code(200);
header('Content-Type: text/html; charset=utf-8');
readfile($file);
exit;
}
// ─── Router ──────────────────────────────────────────────────────────────────
$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
// Normalizar: quitar trailing slash
$path = rtrim($path, '/') ?: '/';
// ─── Tabla de rutas ──────────────────────────────────────────────────────────
$routes = [
['GET', '/health', fn() => jsonResponse(200, ['status' => 'ok', 'service' => 'bot-palmas360'])],
// ─── WhatsApp webhook ────────────────────────────────────────────────────
// Seguridad: HMAC-SHA256 (X-Hub-Signature-256) verificado dentro del controlador
['GET', '/admin/v1/wp-webhook', fn() => WpWebhook::verify()],
['POST', '/admin/v1/wp-webhook', fn() => WpWebhook::receive()],
// ─── Login / Logout ──────────────────────────────────────────────────────
['GET', '/login', fn() => LoginController::showForm()],
['POST', '/login', fn() => LoginController::authenticate()],
['GET', '/logout', fn() => LoginController::logout()],
// ─── Dashboard protegido ─────────────────────────────────────────────────
['GET', '/admin/dashboard', fn() => DashboardController::index()],
['GET', '/admin/live', fn() => DashboardController::live()],
['GET', '/admin/webhook/stream', fn() => DashboardController::stream()],
['GET', '/admin/webhook/raw', fn() => DashboardController::getRaw()],
// ─── Páginas legales (requeridas por Meta/WhatsApp Business) ────────────
['GET', '/politicas', fn() => serveHtml('politicas.html')],
['GET', '/eliminacion-datos-usuario', fn() => serveHtml('eliminacion-datos-usuario.html')],
['GET', '/condiciones-servicio', fn() => serveHtml('condiciones-servicio.html')],
];
// ─── Despacho ────────────────────────────────────────────────────────────────
foreach ($routes as [$routeMethod, $routePath, $handler]) {
if ($method === $routeMethod && $path === $routePath) {
$handler();
exit;
}
}
jsonResponse(404, ['error' => 'Ruta no encontrada', 'path' => $path]);
+165
View File
@@ -0,0 +1,165 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Políticas de Privacidad — Somos19D</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', Arial, sans-serif;
background: #f0f2f5;
color: #1c1e21;
font-size: 15px;
line-height: 1.65;
}
.whatsapp-bar {
background: #25D366;
color: #fff;
text-align: center;
padding: 10px 16px;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.3px;
}
.card {
max-width: 680px;
margin: 24px auto;
background: #fff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}
.card-header {
background: linear-gradient(135deg, #0b3d91 0%, #1565c0 100%);
padding: 28px 24px 20px;
color: #fff;
}
.card-header .nit {
font-size: 12px;
background: rgba(255,255,255,0.15);
border-radius: 20px;
display: inline-block;
padding: 3px 10px;
margin-bottom: 10px;
letter-spacing: 0.5px;
}
.card-header h1 { font-size: 22px; font-weight: 700; margin-bottom: 6px; }
.card-header p { font-size: 13px; opacity: 0.88; }
.card-body { padding: 24px; }
.section { border-left: 3px solid #1565c0; padding: 12px 16px; background: #f8f9fd; border-radius: 0 8px 8px 0; margin-bottom: 18px; }
.section h2 { font-size: 14px; font-weight: 700; color: #0b3d91; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.4px; }
.section p, .section li { font-size: 14px; color: #333; }
.section ul { padding-left: 18px; margin-top: 6px; }
.section li { margin-bottom: 4px; }
.badge { display: inline-block; background: #e8f5e9; color: #2e7d32; font-size: 12px; padding: 2px 8px; border-radius: 12px; margin-top: 8px; font-weight: 600; }
footer { text-align: center; padding: 16px; font-size: 12px; color: #888; background: #fafafa; border-top: 1px solid #eee; }
@media (max-width: 600px) {
.card { margin: 0; border-radius: 0; }
.card-body { padding: 16px; }
}
</style>
</head>
<body>
<div class="whatsapp-bar">📋 Documento oficial compartido por WhatsApp · Somos19D</div>
<div class="card">
<div class="card-header">
<span class="nit">NIT: 901677603-5</span>
<h1>Políticas de Privacidad</h1>
<p>Somos19D — Empresa jurídica legalmente constituida en Colombia<br>Vigente a partir del 1 de enero de 2025</p>
</div>
<div class="card-body">
<div class="section">
<h2>1. ¿Quiénes somos?</h2>
<p><strong>Somos19D</strong> es una empresa jurídicamente constituida en Colombia, identificada con NIT <strong>901677603-5</strong>. Operamos servicios digitales y de comunicación, incluyendo atención a través de WhatsApp y canales en línea.</p>
</div>
<div class="section">
<h2>2. Datos que recopilamos</h2>
<p>Para prestar nuestros servicios, podemos recopilar:</p>
<ul>
<li>Nombre completo y documento de identidad</li>
<li>Número de teléfono (incluyendo WhatsApp)</li>
<li>Correo electrónico y dirección</li>
<li>Historial de transacciones y servicios solicitados</li>
<li>Información del dispositivo y dirección IP</li>
</ul>
</div>
<div class="section">
<h2>3. ¿Para qué usamos tus datos?</h2>
<ul>
<li>Prestar y mejorar nuestros servicios</li>
<li>Comunicarnos contigo por WhatsApp u otros medios</li>
<li>Procesar pagos y gestionar pedidos</li>
<li>Cumplir obligaciones legales y tributarias</li>
<li>Enviarte información relevante sobre nuestros productos</li>
</ul>
<span class="badge">✅ Nunca vendemos tus datos a terceros</span>
</div>
<div class="section">
<h2>4. Seguridad de la información</h2>
<p>Implementamos medidas técnicas y administrativas para proteger tus datos frente a acceso no autorizado, pérdida o divulgación. El acceso a tu información está limitado al personal estrictamente necesario.</p>
</div>
<div class="section">
<h2>5. Tus derechos</h2>
<p>Como titular de tus datos tienes derecho a:</p>
<ul>
<li>Conocer, actualizar y corregir tu información</li>
<li>Solicitar prueba de tu autorización de tratamiento</li>
<li>Revocar tu consentimiento en cualquier momento</li>
<li>Solicitar la eliminación de tus datos (ver enlace dedicado)</li>
<li>Presentar quejas ante la Superintendencia de Industria y Comercio (SIC)</li>
</ul>
</div>
<div class="section">
<h2>6. WhatsApp Business API y Meta</h2>
<p>Somos19D utiliza la <strong>WhatsApp Business Platform</strong> operada por <strong>Meta Platforms, Inc.</strong> para comunicarse contigo. Al interactuar con nosotros por WhatsApp, aceptas que:</p>
<ul>
<li>Tu número de teléfono y los mensajes intercambiados son procesados por <strong>Meta</strong> conforme a sus propias <a href="https://www.whatsapp.com/legal/privacy-policy" target="_blank" rel="noopener noreferrer">Políticas de Privacidad</a>.</li>
<li>Meta actúa como <strong>sub-procesador</strong> de datos en la prestación del servicio de mensajería.</li>
<li>Los mensajes enviados a través de WhatsApp están sujetos al cifrado de extremo a extremo de Meta.</li>
<li>Somos19D no accede al contenido de los mensajes fuera de la conversación contigo.</li>
</ul>
<span class="badge">📱 Consulta la política de Meta: whatsapp.com/legal</span>
</div>
<div class="section">
<h2>7. Opt-in y Opt-out de mensajes WhatsApp</h2>
<p><strong>Opt-in (consentimiento):</strong> Al escribirnos por WhatsApp o facilitar tu número para recibir comunicaciones, otorgas tu consentimiento expreso para recibir mensajes de Somos19D por este canal.</p>
<p style="margin-top:8px;"><strong>Opt-out (cancelar suscripción):</strong> Puedes dejar de recibir mensajes en cualquier momento respondiendo <strong>"STOP"</strong>, <strong>"DETENER"</strong> o <strong>"NO DESEO RECIBIR MENSAJES"</strong>. Procesaremos tu solicitud en máximo 24 horas.</p>
<span class="badge">✅ Nunca recibirás mensajes sin tu consentimiento previo</span>
</div>
<div class="section">
<h2>8. Compartir información con terceros</h2>
<p>Somos19D puede compartir datos con proveedores de servicios que actúan en nuestro nombre (pasarelas de pago, plataformas de mensajería como Meta/WhatsApp), bajo estrictos acuerdos de confidencialidad y solo para los fines autorizados.</p>
</div>
<div class="section">
<h2>9. Cookies y tecnologías similares</h2>
<p>Nuestro sitio web puede usar cookies para mejorar la experiencia del usuario. Puedes configurar tu navegador para rechazarlas, aunque algunas funcionalidades pueden verse afectadas.</p>
</div>
<div class="section">
<h2>10. Cambios a esta política</h2>
<p>Podemos actualizar esta política en cualquier momento. Te notificaremos los cambios relevantes a través de nuestros canales oficiales, incluyendo WhatsApp.</p>
</div>
<div class="section">
<h2>11. Contacto</h2>
<p>Para ejercer tus derechos o resolver dudas sobre esta política, comunícate con nosotros a través de los canales oficiales de <strong>Somos19D</strong>.</p>
</div>
</div>
<footer>© 2025 Somos19D · NIT 901677603-5 · Todos los derechos reservados<br>Documento generado el 4 de junio de 2026</footer>
</div>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
require('dotenv').config();
const express = require('express');
const app = express();
// Parsear JSON raw para poder verificar firma HMAC de Meta
app.use(
express.json({
verify: (req, _res, buf) => {
req.rawBody = buf;
},
})
);
// ─── Rutas ───────────────────────────────────────────────────────────────────
const wpWebhook = require('./admin/v1/wp-webhook');
app.use('/admin/v1/wp-webhook', wpWebhook);
// Health check
app.get('/health', (_req, res) => res.json({ status: 'ok', service: 'bot-palmas360' }));
// 404
app.use((_req, res) => res.status(404).json({ error: 'Ruta no encontrada' }));
// ─── Inicio ───────────────────────────────────────────────────────────────────
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`✅ Servidor corriendo en puerto ${PORT}`);
console.log(`📡 Webhook WhatsApp → /admin/v1/wp-webhook`);
});
+94
View File
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/config/env.php';
require_once dirname(__DIR__) . '/config/db.php';
$db = db();
// ─── Tablas ───────────────────────────────────────────────────────────────────
$db->exec("
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(150) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
$db->exec("
CREATE TABLE IF NOT EXISTS webhook_logs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
event_field VARCHAR(50) NOT NULL DEFAULT 'messages',
from_number VARCHAR(30),
contact_name VARCHAR(150),
message_type VARCHAR(30),
message_preview TEXT,
raw_payload LONGTEXT,
received_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_received_at (received_at),
INDEX idx_from_number (from_number),
INDEX idx_event_field (event_field)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
$db->exec("
CREATE TABLE IF NOT EXISTS conversations (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
message_id VARCHAR(100) NOT NULL UNIQUE,
phone_number VARCHAR(30) NOT NULL,
contact_name VARCHAR(150),
direction ENUM('inbound','outbound') DEFAULT 'inbound',
message_type VARCHAR(30),
content TEXT,
media_id VARCHAR(150),
timestamp INT UNSIGNED,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_conv_phone (phone_number),
INDEX idx_conv_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
$db->exec("
CREATE TABLE IF NOT EXISTS notifications (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
type VARCHAR(50) NOT NULL DEFAULT 'new_message',
reference_id BIGINT,
phone_number VARCHAR(30),
message TEXT,
is_read TINYINT(1) DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_notif_read (is_read),
INDEX idx_notif_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
$db->exec("
CREATE TABLE IF NOT EXISTS media_queue (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
message_id VARCHAR(100) NOT NULL,
media_id VARCHAR(150) NOT NULL,
media_type VARCHAR(30),
attempts TINYINT DEFAULT 0,
processed TINYINT(1) DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_mq_processed (processed),
INDEX idx_mq_message_id (message_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
// ─── Usuario admin por defecto ────────────────────────────────────────────────
$email = 'admin@palmas360.com';
$password = 'Palmas360@Admin26';
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
$stmt = $db->prepare("INSERT IGNORE INTO users (name, email, password) VALUES (?, ?, ?)");
$stmt->execute(['Lizandro', $email, $hash]);
echo "\n✅ Tablas creadas correctamente.\n";
echo "👤 Usuario: {$email}\n";
echo "🔑 Contraseña: {$password}\n";
echo "⚠️ Cambia la contraseña después del primer login.\n\n";