0, 'msgs' => 0, 'media' => 0, 'statuses' => 0]; $pending = 0; $companies = []; $logs = []; $total = 0; } $pages = $total > 0 ? (int)ceil($total / self::PER_PAGE) : 1; $companyCount = count($companies); self::render(compact('stats', 'pending', 'companies', 'companyCount', 'companyId', '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 Layout::open('Live Feed', 'live', $user['name'] ?? 'Admin'); echo << .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} .feed-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} .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}
Conectando... 0 eventos

Esperando eventos en tiempo real...

HTML; exit; } // ─── GET /admin/webhook/stream/sse — Server-Sent Events ───────────────── public static function streamSse(): void { SessionAuth::require(); header('Content-Type: text/event-stream'); header('Cache-Control: no-cache'); header('X-Accel-Buffering: no'); // disable nginx buffering header('Connection: keep-alive'); set_time_limit(35); ignore_user_abort(false); $lastId = max(0, (int)($_GET['after'] ?? 0)); if ($lastId === 0) { try { $row = db()->query('SELECT id FROM webhook_logs ORDER BY id DESC LIMIT 1')->fetch(); $lastId = $row ? (int)$row['id'] : 0; } catch (\PDOException $e) {} } // Tell the client to reconnect after 3s if connection drops echo "retry: 3000\n\n"; ob_flush(); flush(); $deadline = time() + 28; // release worker after 28s, client auto-reconnects while (!connection_aborted() && time() < $deadline) { try { $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 20' ); $stmt->execute([$lastId]); $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC); if ($rows) { $lastId = (int)end($rows)['id']; echo 'data: ' . json_encode($rows, JSON_UNESCAPED_UNICODE) . "\n\n"; } else { echo ": heartbeat\n\n"; } } catch (\PDOException $e) { echo ": db-error\n\n"; } ob_flush(); flush(); // Sleep in 200ms chunks so connection_aborted() is checked frequently for ($i = 0; $i < 10 && !connection_aborted(); $i++) { usleep(200000); } } 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, ?int $companyId = null): array { if ($companyId !== null) { $stmt = $db->prepare(" 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() AND company_id = ? "); $stmt->execute([$companyId]); } else { $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, ?int $companyId = null): array { $where = ['DATE(received_at) = ?']; $params = [$date]; if ($companyId !== null) { $where[] = 'company_id = ?'; $params[] = $companyId; } 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 wh.id, wh.event_field, wh.from_number, wh.contact_name, wh.message_type, wh.message_preview, wh.received_at, c.name AS company_name FROM webhook_logs wh LEFT JOIN companies c ON c.id = wh.company_id WHERE $w ORDER BY wh.received_at DESC LIMIT $limit OFFSET $offset "); $stmt->execute($params); return [$stmt->fetchAll(), $total]; } // ─── Helpers de vista ───────────────────────────────────────────────────── private static function h($v): string { return htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8'); } private static function typeLabel(string $field, string $type): string { if ($field === 'statuses') { switch ($type) { case 'sent': $cls = 'badge-blue'; break; case 'delivered': $cls = 'badge-green'; break; case 'read': $cls = 'badge-teal'; break; case 'failed': $cls = 'badge-red'; break; default: $cls = 'badge-gray'; break; } return '' . self::h($type) . ''; } switch ($type) { case 'text': return '💬 Texto'; case 'image': return '📷 Imagen'; case 'audio': return '🎵 Audio'; case 'video': return '🎬 Video'; case 'document': return '📄 Documento'; case 'location': return '📍 Ubicación'; case 'interactive': return '🔘 Interactivo'; case 'button': return '🔲 Botón'; default: return 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']; $sPending = (int)($v['pending'] ?? 0); $companyCount = (int)($v['companyCount'] ?? 0); // Opciones del filtro de empresa $companyOptions = ''; foreach ($v['companies'] ?? [] as $c) { $sel = (int)($v['companyId'] ?? 0) === (int)$c['id'] ? 'selected' : ''; $companyOptions .= "'; } $showCompany = empty($v['companyId']); $companyTh = $showCompany ? 'Empresa' : ''; // Filas de la tabla $rows = ''; if (empty($v['logs'])) { $colspan = $showCompany ? 8 : 7; $rows = "Sin eventos para esta fecha."; } 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)); $company = self::h($log['company_name'] ?? '—'); $rows .= "" . "#{$id}" . "{$time}" . ($showCompany ? "{$company}" : '') . "{$from}" . "{$name}" . "{$type}" . "{$preview}" . "" . "\n"; } } // Paginación $pager = ''; if ($v['pages'] > 1) { $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 .= "{$i}"; } $pager .= '
'; } echo Layout::open('Admin', 'dashboard', $v['user']['name'] ?? 'Admin', $sPending); echo << /* Stats */ .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:14px;padding:20px 28px 0} .sc{background:#fff;border-radius:12px;padding:18px 20px;box-shadow:0 1px 4px rgba(0,0,0,.04),0 4px 16px rgba(0,0,0,.03);border-left:4px solid #1565c0;position:relative;overflow:hidden;text-decoration:none;display:block;transition:transform .15s,box-shadow .15s;cursor:pointer} .sc:hover{transform:translateY(-2px);box-shadow:0 4px 16px rgba(0,0,0,.08),0 8px 24px rgba(0,0,0,.05)} .sc::after{content:'';position:absolute;inset:0;opacity:.03;background:linear-gradient(135deg,#1565c0,transparent 60%)} .sc .v{font-size:28px;font-weight:800;color:#0b3d91;letter-spacing:-.03em;line-height:1.1} .sc .l{font-size:12px;color:#7a8291;margin-top:4px;font-weight:500} .sc.g{border-color:#27ae60}.sc.g::after{background:linear-gradient(135deg,#27ae60,transparent 60%)}.sc.g .v{color:#1a7a3a} .sc.o{border-color:#e67e22}.sc.o::after{background:linear-gradient(135deg,#e67e22,transparent 60%)}.sc.o .v{color:#b85e0e} .sc.s{border-color:#5f6b7a}.sc.s::after{background:linear-gradient(135deg,#5f6b7a,transparent 60%)}.sc.s .v{color:#3d4552} .sc.r{border-color:#e74c3c}.sc.r::after{background:linear-gradient(135deg,#e74c3c,transparent 60%)}.sc.r .v{color:#e74c3c} /* Filters */ .fbar{padding:14px 24px;display:flex;gap:10px;flex-wrap:wrap;align-items:center;background:#fff;margin:16px 28px 0;border-radius:12px;box-shadow:0 1px 4px rgba(0,0,0,.04),0 4px 16px rgba(0,0,0,.03)} .fbar input,.fbar select{padding:8px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px;outline:none;background:#fafbfc;transition:all .15s;color:#1a1d23} .fbar input:focus,.fbar select:focus{border-color:#1565c0;background:#fff;box-shadow:0 0 0 3px rgba(21,101,192,.08)} .bf{background:#0b3d91;color:#fff;border:none;border-radius:8px;padding:8px 18px;font-size:13px;font-weight:600;cursor:pointer;transition:all .15s} .bf:hover{background:#1565c0;box-shadow:0 2px 8px rgba(11,61,145,.2)} .rc{margin-left:auto;font-size:12px;color:#7a8291} /* Table */ .tw{margin:14px 28px 28px;background:#fff;border-radius:12px;box-shadow:0 1px 4px rgba(0,0,0,.04),0 4px 16px rgba(0,0,0,.03);overflow:hidden;border:1px solid #eef1f5} .company-cell{font-weight:500;color:#0b3d91;font-size:12px} .phone{font-family:'SF Mono',Monaco,monospace;font-size:13px;color:#3d4552} .preview{color:#5f6b7a;max-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .btn-json{background:#f0f4ff;color:#0b3d91;border:1px solid #d6e4ff;border-radius:6px;padding:4px 10px;font-size:11px;font-weight:600;cursor:pointer;transition:all .15s} .btn-json:hover{background:#dbeafe;border-color:#0b3d91} .pager{display:flex;justify-content:center;padding:16px;gap:4px;flex-wrap:wrap} .pg{padding:6px 14px;border:1px solid #e2e5ea;border-radius:8px;text-decoration:none;color:#0b3d91;font-size:13px;background:#fff;transition:all .15s;font-weight:500} .pg.active{background:#0b3d91;color:#fff;border-color:#0b3d91;box-shadow:0 2px 8px rgba(11,61,145,.2)} .pg:hover:not(.active){background:#f0f4ff;border-color:#0b3d91} .erp-bar{display:flex;align-items:center;gap:12px;padding:10px 28px;flex-wrap:wrap} .erp-label{font-size:11px;font-weight:700;color:#7a8291;text-transform:uppercase;letter-spacing:.5px} .erp-load{font-size:12px;color:#7a8291;display:flex;align-items:center;gap:6px;flex-wrap:wrap} .erp-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:3px;box-shadow:0 0 4px rgba(0,0,0,.1)} .erp-dot.up{background:#27ae60} .erp-dot.down{background:#e74c3c} .erp-dot.degraded{background:#e67e22} .erp-dot.unknown{background:#95a5a6} .erp-badge{display:inline-flex;align-items:center;gap:5px;padding:4px 10px;border-radius:20px;font-size:11px;font-weight:600;margin:2px 4px} .erp-badge.up{background:#e8f8f0;color:#1e8449;border:1px solid #a3e4bc} .erp-badge.down{background:#fdecea;color:#c0392b;border:1px solid #f5b7b1} .erp-badge.degraded{background:#fef5e7;color:#ca6f1e;border:1px solid #f9e79f} .erp-badge.unknown{background:#f3f4f6;color:#616a6b;border:1px solid #e2e5ea} /* Drawer cards */ .dp-card{background:#f8f9fd;border:1px solid #eef1f5;border-radius:10px;padding:14px;transition:opacity .3s} .dp-top{display:flex;align-items:center;gap:8px;margin-bottom:6px} .dp-phone{font-family:monospace;font-size:12px;font-weight:600;color:#3d4552} .dp-name{font-size:12px;color:#5f6b7a;flex:1} .dp-time{font-size:11px;color:#a0a8b8} .dp-in{font-size:12px;color:#5f6b7a;background:#fff;border-radius:6px;padding:6px 10px;margin-bottom:4px;border:1px solid #eef1f5;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .dp-resp{font-size:12px;color:#1a7a3a;background:#e8f8f0;border-radius:6px;padding:6px 10px;margin-bottom:10px;border:1px solid #a3e4bc;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .dp-actions{display:flex;gap:8px} .dp-approve{flex:1;background:#166534;color:#fff;border:none;border-radius:8px;padding:8px;font-size:12px;font-weight:700;cursor:pointer;transition:background .15s} .dp-approve:hover{background:#1a7a3a} .dp-reject{flex:1;background:#991b1b;color:#fff;border:none;border-radius:8px;padding:8px;font-size:12px;font-weight:700;cursor:pointer;transition:background .15s} .dp-reject:hover{background:#b91c1c} .dp-approve:disabled,.dp-reject:disabled{opacity:.5;cursor:not-allowed} @media(max-width:768px){ .stats{grid-template-columns:repeat(2,1fr);padding:14px 16px 0} .fbar,.stats,.tw{padding-left:12px;padding-right:12px;margin-left:0;margin-right:0} .tw{margin:8px 12px 16px} th:nth-child(4),td:nth-child(4){display:none} #drawer{width:100vw} }
{$sTotal}
📡 Total hoy
{$sMsgs}
💬 Mensajes texto
{$sMedia}
📎 Multimedia
{$sStatuses}
📊 Estados
{$sPending}
⏳ Pendientes (clic para ver)
{$companyCount}
🏢 Empresas
📡 Estado ERP Cargando...
{$totalStr} resultado(s)
{$companyTh} {$rows}
ID HoraNúmero Nombre Tipo Preview
{$pager}
HTML; exit; } // ─── GET /admin/pending ──────────────────────────────────────────────── public static function pendingList(): void { SessionAuth::require(); header('Content-Type: application/json'); $items = PendingApproval::findAll('pending'); echo json_encode(['items' => $items], JSON_UNESCAPED_UNICODE); exit; } public static function pending(): void { SessionAuth::require(); $user = SessionAuth::user(); $userName = htmlspecialchars($user['name'] ?? 'Admin', ENT_QUOTES, 'UTF-8'); $companyId = isset($_GET['company_id']) ? (int)$_GET['company_id'] : null; $items = $companyId ? PendingApproval::findByCompany($companyId, 'pending') : PendingApproval::findAll('pending'); $companies = CompanyRepository::findAll(); $sPending = PendingApproval::countPending(null); http_response_code(200); header('Content-Type: text/html; charset=utf-8'); echo Layout::open('Pendientes de Aprobación', 'pending', $user['name'] ?? 'Admin', $sPending); echo << .wrap{padding:24px;max-width:1000px;margin:0 auto} .badge-pending{background:#fef3c7;color:#92400e;border:1px solid #f9e79f} .fbar{display:flex;gap:12px;align-items:center;padding:16px 0;flex-wrap:wrap} .fbar select,.fbar button{padding:8px 14px;border-radius:8px;border:1px solid #e2e5ea;font-size:13px;outline:none} .fbar select{background:#fafbfc} .fbar select:focus{border-color:#1565c0;background:#fff;box-shadow:0 0 0 3px rgba(21,101,192,.08)} .fbar button{background:#0b3d91;color:#fff;border:none;cursor:pointer;font-weight:600;transition:all .15s} .fbar button:hover{background:#1565c0;box-shadow:0 2px 8px rgba(11,61,145,.2)} .preview{max-width:300px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#5f6b7a} .btn-sm.rej{background:#991b1b} .btn-sm.rej:hover{background:#b91c1c;box-shadow:0 2px 8px rgba(153,27,27,.2)} .msg{display:inline-block;padding:7px 14px;background:#f0f4ff;border-radius:10px;max-width:300px;font-size:13px;word-break:break-word;color:#1a1d23} @media(max-width:768px){.wrap{padding:12px;overflow-x:auto}}
HTML; // Endpoints $epRows = db()->prepare("SELECT endpoint_key, url, method, last_response, last_called_at FROM company_endpoints WHERE company_id=?"); $epRows->execute([$id]); $epMap = []; foreach ($epRows->fetchAll(\PDO::FETCH_ASSOC) as $r) $epMap[$r['endpoint_key']] = $r; $catalog = self::endpointCatalog(); $renderEpSection = function(string $dir, string $dirLabel, string $badge) use ($catalog, $epMap, $id): string { $html = "

{$badge} {$dirLabel}

"; foreach ($catalog[$dir] as $key => $label) { $saved = $epMap[$key] ?? []; $url = self::h($saved['url'] ?? ''); $meth = $saved['method'] ?? 'GET'; $lastAt = $saved['last_called_at'] ? '' . self::h($saved['last_called_at']) . '' : ''; $mSel = fn($v) => $meth === $v ? 'selected' : ''; $html .= <<
{$label} {$lastAt}
EP; } return $html; }; $epHtml = $renderEpSection('upload', 'Subir información (WhatsApp → ERP)', '↑ Upload') . '
' . $renderEpSection('download', 'Bajar información (ERP → WhatsApp)', '↓ Download'); } $tabsHtml = $isEdit ? << HTML : ''; echo << .tab-btn{background:none;border:none;border-bottom:2px solid transparent;padding:10px 18px;font-size:13px;font-weight:500;color:#6b7280;cursor:pointer;transition:color .12s,border-color .12s;margin-bottom:-1px} .tab-btn.active{color:#111827;border-bottom-color:#111827} .tab-panel{display:none}.tab-panel.active{display:block}

{$pageTitle}

← Empresas
{$tabsHtml}
HTML; if ($isEdit) echo ''; echo <<
ID del número WA Business API (para enviar mensajes)
Base URL del ERP de esta empresa
Define los límites de números WA. Se actualiza con sincronización ERP.
Cancelar
HTML; if ($isEdit) { echo <<
{$phonesHtml}
{$epHtml}
HTML; } echo ''; echo Layout::close(); } // ─── POST /admin/company/phone/save ────────────────────────────────────── public static function companyPhoneSave(): void { SessionAuth::require(); header('Content-Type: application/json; charset=utf-8'); $companyId = (int)($_POST['company_id'] ?? 0); $waNumber = preg_replace('/\D/', '', $_POST['wa_number'] ?? ''); $label = trim($_POST['label'] ?? ''); $permissionType = (int)($_POST['permission_type'] ?? 1); if ($companyId <= 0 || strlen($waNumber) < 7) { echo json_encode(['ok' => false, 'error' => 'Datos inválidos']); exit; } if (!in_array($permissionType, [1, 2, 3])) { echo json_encode(['ok' => false, 'error' => 'Tipo de permiso inválido']); exit; } $company = CompanyRepository::findById($companyId); if (!$company) { echo json_encode(['ok' => false, 'error' => 'Empresa no encontrada']); exit; } $erpUsers = (int)($company['erp_active_users'] ?? 0); $limitT1 = max(1, $erpUsers * 10); $limitT23 = max(1, $erpUsers); // Contar números actuales $counts = db()->prepare("SELECT permission_type, COUNT(*) as cnt FROM company_phones WHERE company_id=? AND is_active=1 GROUP BY permission_type"); $counts->execute([$companyId]); $cntMap = []; foreach ($counts->fetchAll(\PDO::FETCH_ASSOC) as $r) $cntMap[(int)$r['permission_type']] = (int)$r['cnt']; $cntT1 = $cntMap[1] ?? 0; $cntT23 = ($cntMap[2] ?? 0) + ($cntMap[3] ?? 0); if ($permissionType === 1 && $cntT1 >= $limitT1) { echo json_encode(['ok' => false, 'error' => "Límite alcanzado: máximo {$limitT1} números de tipo 1 (usuarios ERP × 10)"]); exit; } if ($permissionType !== 1 && $cntT23 >= $limitT23) { echo json_encode(['ok' => false, 'error' => "Límite alcanzado: máximo {$limitT23} números de tipo 2/3 (igual a usuarios ERP)"]); exit; } try { db()->prepare("INSERT INTO company_phones (company_id, wa_number, label, permission_type) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE label=VALUES(label), permission_type=VALUES(permission_type), is_active=1") ->execute([$companyId, $waNumber, $label, $permissionType]); echo json_encode(['ok' => true, 'message' => "Número +{$waNumber} agregado"]); } catch (\PDOException $e) { echo json_encode(['ok' => false, 'error' => 'Error DB: ' . $e->getMessage()]); } exit; } // ─── POST /admin/company/phone/delete ──────────────────────────────────── public static function companyPhoneDelete(): void { SessionAuth::require(); header('Content-Type: application/json; charset=utf-8'); $id = (int)($_POST['id'] ?? 0); if ($id <= 0) { echo json_encode(['ok' => false, 'error' => 'ID inválido']); exit; } db()->prepare("DELETE FROM company_phones WHERE id=?")->execute([$id]); echo json_encode(['ok' => true]); exit; } // ─── POST /admin/company/endpoint/save ─────────────────────────────────── public static function companyEndpointSave(): void { SessionAuth::require(); header('Content-Type: application/json; charset=utf-8'); $companyId = (int)($_POST['company_id'] ?? 0); $key = preg_replace('/[^a-z0-9_]/', '', $_POST['endpoint_key'] ?? ''); $direction = in_array($_POST['direction'] ?? '', ['upload','download']) ? $_POST['direction'] : null; $url = trim($_POST['url'] ?? ''); $method = in_array(strtoupper($_POST['method'] ?? 'GET'), ['GET','POST']) ? strtoupper($_POST['method']) : 'GET'; if ($companyId <= 0 || $key === '' || $direction === null) { echo json_encode(['ok' => false, 'error' => 'Datos inválidos']); exit; } try { db()->prepare("INSERT INTO company_endpoints (company_id, endpoint_key, direction, url, method) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE url=VALUES(url), method=VALUES(method)") ->execute([$companyId, $key, $direction, $url, $method]); echo json_encode(['ok' => true]); } catch (\PDOException $e) { echo json_encode(['ok' => false, 'error' => $e->getMessage()]); } exit; } // ─── POST /admin/company/endpoint/test ─────────────────────────────────── public static function companyEndpointTest(): void { SessionAuth::require(); header('Content-Type: application/json; charset=utf-8'); $companyId = (int)($_POST['company_id'] ?? 0); $key = preg_replace('/[^a-z0-9_]/', '', $_POST['endpoint_key'] ?? ''); $url = trim($_POST['url'] ?? ''); $method = in_array(strtoupper($_POST['method'] ?? 'GET'), ['GET','POST']) ? strtoupper($_POST['method']) : 'GET'; if ($url === '') { echo json_encode(['ok' => false, 'error' => 'URL vacía']); exit; } $company = CompanyRepository::findById($companyId); $apiKey = $company['api_key'] ?? ''; $ch = curl_init($url); $opts = [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_HTTPHEADER => ['Accept: application/json', 'Content-Type: application/json'], ]; if ($apiKey !== '') $opts[CURLOPT_HTTPHEADER][] = 'Authorization: Bearer ' . $apiKey; if ($method === 'POST') { $opts[CURLOPT_POST] = true; $opts[CURLOPT_POSTFIELDS] = '{}'; } curl_setopt_array($ch, $opts); $resp = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); if ($err) { echo json_encode(['ok' => false, 'error' => 'cURL: ' . $err]); exit; } $decoded = json_decode($resp, true); $preview = is_array($decoded) ? $decoded : (string)$resp; // Guardar última respuesta if ($key !== '') { db()->prepare("UPDATE company_endpoints SET last_response=?, last_called_at=NOW() WHERE company_id=? AND endpoint_key=?") ->execute([is_string($resp) ? substr($resp, 0, 2000) : '', $companyId, $key]); } if ($httpCode >= 200 && $httpCode < 300) { echo json_encode(['ok' => true, 'http_code' => $httpCode, 'response' => $preview]); } else { echo json_encode(['ok' => false, 'error' => "HTTP {$httpCode}", 'response' => $preview]); } exit; } // ─── GET /admin/sync-companies ────────────────────────────────────────── public static function syncCompanies(): void { SessionAuth::require(); $user = SessionAuth::user(); $userName = self::h($user['name'] ?? 'Admin'); $result = ErpSync::sync(); $hasError = isset($result['error']); $icon = $hasError ? '❌' : '✅'; $title = $hasError ? 'Error en Sincronización' : 'Sincronización Exitosa'; $titleClass = $hasError ? 'error' : 'success'; $resultJson = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); http_response_code(200); header('Content-Type: text/html; charset=utf-8'); echo Layout::open('Sincronizar Empresas', 'companies', $user['name'] ?? 'Admin'); echo << .wrap{padding:24px;max-width:800px;margin:0 auto} .btn-row{text-align:center;margin-top:24px}
{$icon}
{$title}
{$resultJson}
HTML; exit; } // ─── GET /admin/process-queue ─────────────────────────────────────────── public static function processQueue(): void { SessionAuth::require(); $user = SessionAuth::user(); $userName = self::h($user['name'] ?? 'Admin'); $result = OutboundWorker::processQueue(); $hasError = !empty($result['errors']); $icon = $hasError ? '⚠' : '✅'; $title = $hasError ? 'Cola Procesada con Advertencias' : 'Cola Procesada Exitosamente'; $titleClass = $hasError ? 'error' : 'success'; $resultJson = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); http_response_code(200); header('Content-Type: text/html; charset=utf-8'); echo Layout::open('Procesar Cola de Mensajes', 'dashboard', $user['name'] ?? 'Admin'); echo << .wrap{padding:24px;max-width:800px;margin:0 auto} .btn-row{text-align:center;margin-top:24px}
{$icon}
{$title}
{$resultJson}
HTML; exit; } // ─── GET /admin/settings ──────────────────────────────────────────────── public static function settings(): void { SessionAuth::require(); $user = SessionAuth::user(); $userName = self::h($user['name'] ?? 'Admin'); $settings = Settings::all(); $msg = $_GET['msg'] ?? ''; $toastHtml = ''; if ($msg === 'saved') { $toastHtml = '
Configuración guardada exitosamente.
'; } $fields = [ 'WhatsApp Cloud API' => [ ['key' => 'whatsapp_access_token', 'label' => 'Access Token', 'type' => 'password', 'placeholder' => 'EAAMzB...'], ['key' => 'whatsapp_app_secret', 'label' => 'App Secret', 'type' => 'password', 'placeholder' => 'a4f82c1d...'], ['key' => 'whatsapp_verify_token', 'label' => 'Verify Token', 'type' => 'text', 'placeholder' => 'PLM360_WH_...'], ['key' => 'whatsapp_business_account_id', 'label' => 'Business Account ID', 'type' => 'text', 'placeholder' => '920641783052817'], ['key' => 'whatsapp_default_phone_number_id', 'label' => 'Phone Number ID (default)', 'type' => 'text', 'placeholder' => '384710295638401'], ['key' => 'verify_hmac_signature', 'label' => 'Validar firma HMAC de Meta', 'type' => 'checkbox', 'placeholder' => ''], ], 'Inteligencia Artificial' => [ ['key' => 'ai_provider', 'label' => 'Proveedor', 'type' => 'select', 'options' => ['mock' => 'Mock (simulado)', 'openai' => 'OpenAI']], ['key' => 'openai_api_key', 'label' => 'OpenAI API Key', 'type' => 'password', 'placeholder' => 'sk-...'], ['key' => 'openai_model', 'label' => 'Modelo', 'type' => 'select', 'options' => ['gpt-4o-mini' => 'GPT-4o Mini', 'gpt-4o' => 'GPT-4o', 'gpt-3.5-turbo' => 'GPT-3.5 Turbo']], ['key' => 'ai_max_tokens', 'label' => 'Máximo de tokens', 'type' => 'number', 'placeholder' => '500'], ['key' => 'ai_default_prompt', 'label' => 'System Prompt por defecto', 'type' => 'textarea', 'placeholder' => 'Eres un asistente...'], ], ]; $versionHash = substr(sha1_file(__DIR__ . '/../.env'), 0, 8); http_response_code(200); header('Content-Type: text/html; charset=utf-8'); echo Layout::open('Configuración', 'settings', $user['name'] ?? 'Admin'); echo << .wrap{padding:24px;max-width:900px;margin:0 auto} .form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px} .fw{grid-column:1/-1} .lbl{display:block;font-size:12px;font-weight:600;color:#555;margin-bottom:4px} .inp{width:100%;padding:9px 12px;border:1px solid #ddd;border-radius:8px;font-size:13px;outline:none;transition:border .2s} .inp:focus{border-color:#1565c0} .inp-sel{padding:9px 12px;border:1px solid #ddd;border-radius:8px;font-size:13px;outline:none;background:#fff;cursor:pointer} textarea.inp{min-height:90px;resize:vertical;font-family:inherit} .btn-row{display:flex;justify-content:flex-end;gap:10px;margin-top:18px} .chk-lbl{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:500;color:#333;cursor:pointer;padding:8px 0} .chk-lbl input[type=checkbox]{width:18px;height:18px;accent-color:#0b3d91} .version-info{margin-top:16px;padding:12px;background:#f8f9fb;border-radius:8px;font-size:12px;color:#888;text-align:center} small{color:#888;font-size:11px;display:block;margin-top:2px} @media(max-width:640px){.form-grid{grid-template-columns:1fr}.wrap{padding:12px}}
{$toastHtml}
HTML; foreach ($fields as $sectionTitle => $sectionFields) { echo '
'; echo '
' . self::h($sectionTitle) . '
'; echo '
'; foreach ($sectionFields as $f) { $key = $f['key']; $val = self::h($settings[$key] ?? ''); $label = self::h($f['label']); $placeholder = self::h($f['placeholder'] ?? ''); $full = ($f['type'] === 'textarea') ? ' fw' : ''; echo "
"; echo ""; if ($f['type'] === 'select' && !empty($f['options'])) { echo ""; } elseif ($f['type'] === 'textarea') { echo ""; } elseif ($f['type'] === 'number') { echo ""; } elseif ($f['type'] === 'checkbox') { $checked = !empty($settings[$key]) ? ' checked' : ''; echo ""; } else { echo ""; } echo "
"; } echo '
'; } echo << Cancelar
Los cambios se aplican inmediatamente. Los valores se almacenan en la base de datos.
HTML; exit; } // ─── Chat ──────────────────────────────────────────────────────────────── public static function chat(): void { SessionAuth::require(); $user = SessionAuth::user(); $userName = self::h($user['name'] ?? 'Admin'); $companies = CompanyRepository::findAll(); http_response_code(200); header('Content-Type: text/html; charset=utf-8'); echo Layout::open('Chat', 'chat', $user['name'] ?? 'Admin'); echo << body{overflow:hidden;height:100vh} .chat-layout{display:flex;height:calc(100vh - 56px);overflow:hidden} .conv-sidebar{width:340px;min-width:340px;background:#fff;border-right:1px solid #eef1f5;display:flex;flex-direction:column;overflow:hidden} .conv-search{padding:14px 16px;border-bottom:1px solid #eef1f5} .conv-search input{width:100%;padding:9px 14px 9px 36px;border:1px solid #e2e5ea;border-radius:10px;font-size:13px;outline:none;background:#f5f7fb url('data:image/svg+xml,') 12px 50% no-repeat;background-size:16px;transition:all .15s} .conv-search input:focus{border-color:#1565c0;background-color:#fff;box-shadow:0 0 0 3px rgba(21,101,192,.08)} .conv-list{flex:1;overflow-y:auto;overflow-x:hidden} .conv-item{display:flex;align-items:center;gap:12px;padding:14px 16px;cursor:pointer;border-bottom:1px solid #f5f7fb;transition:all .15s} .conv-item:hover{background:#f8faff} .conv-item.active{background:#f0f4ff;border-left:3px solid #0b3d91;padding-left:13px} .conv-avatar{width:42px;height:42px;border-radius:50%;background:linear-gradient(135deg,#e8eaf6,#d6e4ff);display:flex;align-items:center;justify-content:center;font-size:16px;color:#0b3d91;font-weight:700;flex-shrink:0} .conv-info{flex:1;min-width:0} .conv-name{font-size:13px;font-weight:600;color:#1a1d23;display:flex;align-items:center;gap:6px} .conv-phone{font-size:11px;color:#7a8291;font-family:monospace;margin-top:1px} .conv-last{font-size:12px;color:#7a8291;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .conv-meta{text-align:right;flex-shrink:0} .conv-time{font-size:11px;color:#a0a8b8} .conv-unread{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;border-radius:9px;background:#0b3d91;color:#fff;font-size:10px;font-weight:700;padding:0 5px;margin-top:4px} .conv-company{font-size:10px;color:#0b3d91;background:#f0f4ff;padding:1px 6px;border-radius:4px;font-weight:600} .chat-main{flex:1;display:flex;flex-direction:column;background:#f5f7fb} .chat-header{display:none;padding:14px 22px;background:#fff;border-bottom:1px solid #eef1f5;align-items:center;gap:12px} .chat-header.show{display:flex} .chat-info{flex:1} .chat-info .name{font-size:14px;font-weight:600} .chat-info .phone{font-size:12px;color:#7a8291;font-family:monospace} .chat-company-tag{font-size:10px;color:#0b3d91;background:#f0f4ff;padding:2px 8px;border-radius:4px;font-weight:600} .chat-messages{flex:1;overflow-y:auto;padding:20px 22px;display:none;flex-direction:column;gap:6px} .chat-messages.show{display:flex} .msg-row{display:flex;margin-bottom:4px;animation:msgIn .2s ease} @keyframes msgIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}} .msg-row.inbound{justify-content:flex-start} .msg-row.outbound{justify-content:flex-end} .msg-bubble{max-width:75%;padding:10px 14px;border-radius:16px;font-size:13px;line-height:1.5;position:relative;word-wrap:break-word} .msg-row.inbound .msg-bubble{background:#fff;color:#1a1d23;border-bottom-left-radius:4px;box-shadow:0 1px 3px rgba(0,0,0,.06)} .msg-row.outbound .msg-bubble{background:#0b3d91;color:#fff;border-bottom-right-radius:4px;box-shadow:0 1px 3px rgba(0,0,0,.08)} .msg-meta{display:flex;align-items:center;gap:6px;margin-top:4px;font-size:10px} .msg-row.inbound .msg-meta{color:#a0a8b8} .msg-row.outbound .msg-meta{color:rgba(255,255,255,.65);justify-content:flex-end} .msg-status{font-size:11px} .msg-status .sent{color:rgba(255,255,255,.5)} .msg-status .delivered{color:#8fcbff} .msg-status .read{color:#5cb3ff} .msg-reactions{display:flex;gap:2px;margin-top:2px;font-size:16px} .msg-media{max-width:240px;border-radius:10px;overflow:hidden;margin-bottom:4px;cursor:pointer} .msg-media img{width:100%;display:block;border-radius:10px;border:1px solid rgba(0,0,0,.06)} .msg-media.doc{display:flex;align-items:center;gap:8px;padding:10px 14px;background:#f8f9fd;border:1px solid #e2e5ea;border-radius:8px;color:#0b3d91;font-size:12px;font-weight:600} .msg-media.doc .ico{font-size:20px} .chat-empty{flex:1;display:none;align-items:center;justify-content:center;color:#a0a8b8;flex-direction:column;gap:12px} .chat-empty.show{display:flex} .chat-empty .big{font-size:48px} .chat-input{display:none;padding:14px 22px 18px;background:#fff;border-top:1px solid #eef1f5;gap:10px;align-items:flex-end} .chat-input.show{display:flex} .chat-input textarea{flex:1;padding:10px 14px;border:1px solid #e2e5ea;border-radius:12px;font-size:13px;outline:none;resize:none;font-family:inherit;max-height:120px;min-height:42px;line-height:1.5;transition:border .15s} .chat-input textarea:focus{border-color:#1565c0;box-shadow:0 0 0 3px rgba(21,101,192,.08)} .chat-input .send-btn{width:42px;height:42px;border-radius:50%;background:#0b3d91;color:#fff;border:none;cursor:pointer;font-size:18px;display:flex;align-items:center;justify-content:center;transition:all .15s;flex-shrink:0} .chat-input .send-btn:hover{background:#1565c0;box-shadow:0 2px 8px rgba(11,61,145,.25);transform:scale(1.05)} .chat-input .send-btn:disabled{opacity:.4;cursor:not-allowed;transform:none} .msg-date-sep{text-align:center;font-size:11px;color:#a0a8b8;padding:12px 0 8px;font-weight:500} .typing{display:flex;gap:4px;padding:6px 0} .typing span{width:6px;height:6px;border-radius:50%;background:#a0a8b8;animation:typing 1.2s infinite} .typing span:nth-child(2){animation-delay:.2s} .typing span:nth-child(3){animation-delay:.4s} @keyframes typing{0%,60%,100%{opacity:.3}30%{opacity:1}} @media(max-width:768px){ .conv-sidebar{width:100%;min-width:auto} .chat-main{display:none} .chat-main.mobile-open{display:flex;position:fixed;inset:0;z-index:200;top:0} .conv-sidebar.mobile-hidden{display:none} .back-btn{display:flex} } .back-btn{display:none;background:none;border:none;color:#0b3d91;font-size:20px;cursor:pointer;padding:4px 8px;border-radius:6px;line-height:1} .back-btn:hover{background:#f0f4ff}
💬
Selecciona una conversación
HTML; exit; } // ─── API: lista de conversaciones ──────────────────────────────────────── public static function chatConversations(): void { SessionAuth::require(); $db = db(); // Get unique conversations with last message and unread count $rows = $db->query(" SELECT c.phone_number, c.contact_name, c.company_id, MAX(c.created_at) as last_time, (SELECT content FROM conversations c2 WHERE c2.phone_number = c.phone_number ORDER BY c2.id DESC LIMIT 1) as last_message, (SELECT COUNT(*) FROM conversations c3 WHERE c3.phone_number = c.phone_number AND c3.direction = 'inbound' AND c3.id > COALESCE((SELECT MAX(c4.id) FROM conversations c4 WHERE c4.phone_number = c.phone_number AND c4.direction = 'outbound'), 0)) as unread_count FROM conversations c GROUP BY c.phone_number, c.contact_name, c.company_id ORDER BY last_time DESC ")->fetchAll(); $companies = []; foreach (CompanyRepository::findAll() as $co) { $companies[(int)$co['id']] = $co['name']; } $conversations = []; foreach ($rows as $r) { $cid = (int)$r['company_id']; $conversations[] = [ 'phone' => $r['phone_number'], 'contact_name' => $r['contact_name'] ?? '', 'company_name' => $companies[$cid] ?? '', 'last_message' => mb_substr($r['last_message'] ?? '', 0, 80), 'last_time' => $r['last_time'], 'unread_count' => (int)$r['unread_count'], ]; } jsonResponse(200, ['conversations' => $conversations]); } // ─── API: mensajes de una conversación ─────────────────────────────────── public static function chatMessages(): void { SessionAuth::require(); $phone = trim($_GET['phone'] ?? ''); if ($phone === '') jsonResponse(400, ['error' => 'phone requerido']); $stmt = db()->prepare(" SELECT direction, message_type, content, media_id, status, created_at FROM conversations WHERE phone_number = ? ORDER BY id ASC "); $stmt->execute([$phone]); $messages = $stmt->fetchAll(); // Also include outbound_queue items for this phone $stmt2 = db()->prepare(" SELECT 'outbound' as direction, message_type, payload as content, NULL as media_id, status, created_at FROM outbound_queue WHERE to_number = ? AND status IN ('sent','delivered','failed') AND id > COALESCE((SELECT MAX(c.id) FROM conversations c WHERE c.phone_number = ?), 0) ORDER BY id ASC "); $stmt2->execute([$phone, $phone]); $queueMsgs = $stmt2->fetchAll(); foreach ($queueMsgs as &$qm) { $pl = json_decode($qm['content'], true); $qm['content'] = $pl['text'] ?? $pl['caption'] ?? $qm['content']; } $all = array_merge($messages, $queueMsgs); usort($all, fn($a, $b) => strcmp($a['created_at'] ?? '', $b['created_at'] ?? '')); jsonResponse(200, ['messages' => $all]); } // ─── API: enviar mensaje desde el chat ─────────────────────────────────── public static function chatSend(): void { SessionAuth::require(); $input = json_decode(file_get_contents('php://input'), true); $phone = trim($input['phone'] ?? ''); $text = trim($input['text'] ?? ''); if ($phone === '' || $text === '') { jsonResponse(400, ['error' => 'phone y text requeridos']); } // Find company for this phone (lookup by latest conversation) $stmt = db()->prepare("SELECT company_id FROM conversations WHERE phone_number = ? ORDER BY id DESC LIMIT 1"); $stmt->execute([$phone]); $conv = $stmt->fetch(); $companyId = $conv ? (int)$conv['company_id'] : 1; // Enqueue the message $db = db(); $payload = json_encode(['text' => $text]); $qStmt = $db->prepare("INSERT INTO outbound_queue (company_id, to_number, message_type, payload, status, created_at) VALUES (?, ?, 'text', ?, 'queued', NOW())"); $qStmt->execute([$companyId, $phone, $payload]); // Add to conversations $msgId = 'admin_' . time() . '_' . $phone; $cStmt = $db->prepare("INSERT INTO conversations (company_id, message_id, phone_number, direction, message_type, content, created_at) VALUES (?, ?, ?, 'outbound', 'text', ?, NOW())"); $cStmt->execute([$companyId, $msgId, $phone, $text]); jsonResponse(200, ['status' => 'sent']); } // ─── GET /admin/bot-config ────────────────────────────────────────────── public static function botConfig(): void { SessionAuth::require(); $user = SessionAuth::user(); $userName = self::h($user['name'] ?? 'Admin'); $companyId = (int)($_GET['id'] ?? 0); $companies = CompanyRepository::findAll(); $company = null; $config = ['commands' => [], 'menus' => [], 'flows' => [], 'ai_prompt' => '', 'fallback' => '', 'greeting' => '']; $msg = $_GET['msg'] ?? ''; $toastHtml = $msg === 'saved' ? '
Configuración guardada exitosamente.
' : ''; if ($companyId > 0) { $company = CompanyRepository::findById($companyId); if ($company) { $cj = $company['config_json'] ?? ''; if ($cj) { $parsed = json_decode($cj, true); if (is_array($parsed)) $config = $parsed; } } } $configJson = self::h(json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); http_response_code(200); header('Content-Type: text/html; charset=utf-8'); echo Layout::open('Configurar Bot', 'bot-config', $user['name'] ?? 'Admin'); echo << .wrap{padding:24px;max-width:1100px;margin:0 auto} .tabs{display:flex;gap:2px;margin-bottom:0;flex-wrap:wrap;position:sticky;top:56px;z-index:90;background:#f9fafb;padding:10px 0 0} .tab-btn{padding:10px 18px;border:none;border-radius:10px 10px 0 0;font-size:13px;font-weight:600;cursor:pointer;background:#e8ecf2;color:#5f6b7a;transition:all .15s} .tab-btn:hover{background:#dce1e8} .tab-btn.active{background:#fff;color:#0b3d91;box-shadow:0 -2px 4px rgba(0,0,0,.04)} .tab-content{display:none;background:#fff;border-radius:0 12px 12px 12px;box-shadow:0 1px 4px rgba(0,0,0,.04),0 4px 16px rgba(0,0,0,.03);padding:24px;border:1px solid #eef1f5;margin-bottom:24px} .tab-content.active{display:block} .item-card{background:#f8f9fd;border:1px solid #eef1f5;border-radius:10px;padding:16px;margin-bottom:12px} .item-card .item-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;font-weight:600;font-size:13px;color:#0b3d91} .item-card .item-head .del{cursor:pointer;color:#991b1b;font-size:12px;background:none;border:none;font-weight:600} .add-btn{background:none;border:2px dashed #d6e4ff;border-radius:8px;padding:10px;text-align:center;color:#0b3d91;font-size:13px;font-weight:600;cursor:pointer;width:100%;transition:all .15s} .add-btn:hover{background:#f0f4ff;border-color:#0b3d91} .inline-group{display:flex;gap:8px;align-items:center;margin-bottom:8px;flex-wrap:wrap} .inline-group input,.inline-group select{flex:1;min-width:100px} .inline-group .del{margin-left:auto} table.simple{width:100%;border-collapse:collapse;margin-bottom:12px} table.simple th{text-align:left;padding:8px 10px;font-size:11px;font-weight:700;color:#0b3d91;text-transform:uppercase;letter-spacing:.3px;border-bottom:2px solid #e8eaf6;background:#f8f9fd} table.simple td{padding:8px 10px;border-bottom:1px solid #f0f2f5;font-size:13px;vertical-align:middle} .company-select{display:flex;gap:12px;align-items:center;margin-bottom:20px;flex-wrap:wrap} .company-select select{padding:9px 14px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px;outline:none;background:#fff;min-width:250px} .company-select select:focus{border-color:#1565c0} @media(max-width:768px){.wrap{padding:12px}.tabs{top:auto}.tab-content{padding:16px}}
{$toastHtml}
'; if (!$company) { echo '
Selecciona una empresa para configurar su bot.
'; echo '
'; exit; } $c = $company; $cId = (int)$c['id']; $cName = self::h($c['name'] ?? ''); // Pre-fill form values from config $greetingVal = self::h($config['greeting'] ?? ''); $fallbackVal = self::h($config['fallback'] ?? ''); $aiPromptVal = self::h($config['ai_prompt'] ?? ''); $aiModelVal = self::h($config['ai_model'] ?? 'gpt-4o-mini'); $aiTempVal = self::h((string)($config['ai_temperature'] ?? 0.7)); $aiProviderVal = self::h($config['ai_provider'] ?? ''); $approvalWebhookVal = self::h($config['approval_webhook'] ?? ''); $ignoreVal = self::h(implode("\n", $config['ignore_prefixes'] ?? [])); $commands = $config['commands'] ?? []; $menus = $config['menus'] ?? []; $flows = $config['flows'] ?? []; $aiProviderOpenai = $aiProviderVal === 'openai' ? ' selected' : ''; $aiProviderMock = $aiProviderVal === 'mock' ? ' selected' : ''; $aiModelMini = $aiModelVal === 'gpt-4o-mini' ? ' selected' : ''; $aiModel4o = $aiModelVal === 'gpt-4o' ? ' selected' : ''; $aiModel35 = $aiModelVal === 'gpt-3.5-turbo' ? ' selected' : ''; echo <<
📋 Comandos

Palabras clave que el usuario escribe para ejecutar acciones. Ej: "menu", "info", "contacto"

HTML; $cmdIdx = 0; foreach ($commands as $keyword => $action) { echo ""; $cmdIdx++; } echo <<
Palabra claveAcción (menú o flujo)
📑 Menús Interactivos

Menús tipo lista o botones que se envían al usuario. Cada menú tiene un ID (ej: show_main) y puede tener secciones con filas.

🔀 Flujos

Acciones que se ejecutan cuando un comando o fila de menú es seleccionada.

HTML; $flowIdx = 0; foreach ($flows as $fk => $flow) { $ft = $flow['type'] ?? 'text'; $fMsg = self::h($flow['message'] ?? ''); $fFn = self::h($flow['function'] ?? 'forward_to_ai'); $fMenu = self::h($flow['menu'] ?? ''); echo "
ID:
"; $flowIdx++; } echo <<
🤖 Inteligencia Artificial
Si no se selecciona, usa el proveedor de Configuración General
Instrucciones que define el comportamiento de la IA
⚙️ General
Primer mensaje que recibe un usuario nuevo
Cuando el bot no entiende el mensaje
URL a la que se notificará cuando se requiera aprobación
Números que comiencen con estos prefijos no recibirán respuestas del bot
Vista previa
{$cName[0]}
{$cName}
en línea
{$greetingVal}
12:00
{$fallbackVal}
fallback
Vista previa aproximada
↩ Volver a empresas
HTML; exit; } public static function conversationFlow(): void { SessionAuth::require(); $user = SessionAuth::user(); $userName = self::h($user['name'] ?? 'Admin'); $companyId = (int)($_GET['id'] ?? 0); $companies = CompanyRepository::findAll(); $company = null; $config = ['commands' => [], 'menus' => [], 'flows' => [], 'ai_prompt' => '', 'fallback' => '', 'greeting' => '']; $msg = $_GET['msg'] ?? ''; $toastHtml = $msg === 'saved' ? '
Configuración guardada exitosamente.
' : ''; if ($companyId > 0) { $company = CompanyRepository::findById($companyId); if ($company) { $cj = $company['config_json'] ?? ''; if ($cj) { $parsed = json_decode($cj, true); if (is_array($parsed)) $config = $parsed; } } } http_response_code(200); header('Content-Type: text/html; charset=utf-8'); echo Layout::open('Hilo Conductor', 'conv-flow', $user['name'] ?? 'Admin'); echo << body{background:#f0f2f6} .wrap{padding:16px 24px 40px;max-width:1300px;margin:0 auto} .company-select{display:flex;gap:12px;align-items:center;margin-bottom:16px;flex-wrap:wrap} .company-select select{padding:9px 14px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px;outline:none;background:#fff;min-width:250px} .company-select select:focus{border-color:#1565c0} .company-select label{font-weight:600;font-size:13px} .toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px;align-items:center} .toolbar .btn-pri{background:#0b3d91;color:#fff;border:none;border-radius:8px;padding:9px 20px;font-size:13px;font-weight:600;cursor:pointer;transition:all .15s;text-decoration:none;display:inline-flex;align-items:center;gap:6px} .toolbar .btn-pri:hover{background:#1565c0;box-shadow:0 2px 8px rgba(11,61,145,.2)} .toolbar .btn-sec{background:#fff;color:#0b3d91;border:1px solid #d6e4ff;border-radius:8px;padding:8px 16px;font-size:13px;font-weight:600;cursor:pointer;transition:all .15s;text-decoration:none;display:inline-flex;align-items:center;gap:6px} .toolbar .btn-sec:hover{background:#f0f4ff} .legend{display:flex;gap:14px;flex-wrap:wrap;padding:10px 14px;background:#fff;border-radius:10px;border:1px solid #eef1f5;margin-bottom:16px;font-size:12px;align-items:center} .legend-item{display:flex;align-items:center;gap:6px} .legend-dot{width:12px;height:12px;border-radius:50%;display:inline-block} .legend-dot.cmd{background:#6366f1} .legend-dot.menu{background:#0ea5e9} .legend-dot.row-dot{background:#f59e0b} .legend-dot.flow{background:#8b5cf6} .legend-dot.sub{background:#10b981} .legend-dot.action{background:#ef4444} /* ─── TREE ─── */ .tree-canvas{background:#fff;border-radius:12px;border:1px solid #eef1f5;padding:24px;overflow-x:auto;min-height:300px;box-shadow:0 1px 4px rgba(0,0,0,.04)} .no-data{text-align:center;padding:60px 20px;color:#a0a8b8;font-size:15px} .root-node{display:flex;flex-direction:column;align-items:center} .entry-node{display:flex;flex-direction:column;align-items:center;position:relative;width:100%} .node-group{display:flex;flex-direction:column;align-items:center;width:100%;position:relative} .node-card{background:#fff;border-radius:10px;padding:12px 16px;min-width:220px;max-width:340px;position:relative;box-shadow:0 1px 3px rgba(0,0,0,.06),0 4px 12px rgba(0,0,0,.04);transition:all .2s;cursor:pointer} .node-card:hover{box-shadow:0 4px 12px rgba(0,0,0,.1),0 8px 24px rgba(0,0,0,.06);transform:translateY(-2px)} .node-card .tag{font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.4px;margin-bottom:4px;display:flex;align-items:center;gap:4px} .node-card .n-title{font-size:14px;font-weight:700;margin-bottom:2px;word-break:break-word} .node-card .n-sub{font-size:11px;color:#6b7280;margin-bottom:4px;word-break:break-word} .node-card .n-info{font-size:11px;color:#6b7280;display:flex;gap:4px;flex-wrap:wrap;margin-bottom:6px} .node-card .n-info span{background:#f3f4f6;padding:1px 6px;border-radius:4px} .node-card .actions{display:flex;gap:4px;margin-top:4px;flex-wrap:wrap} .node-card .actions button{font-size:10px;padding:2px 8px;border-radius:4px;border:none;cursor:pointer;font-weight:600;transition:all .12s} .node-card .actions .edit-btn{background:#e8f4fd;color:#0369a1} .node-card .actions .edit-btn:hover{background:#bae6fd} .node-card .actions .del-btn{background:#fef2f2;color:#991b1b} .node-card .actions .del-btn:hover{background:#fee2e2} .node-card .actions .add-btn-sm{background:#f0fdf4;color:#166534} .node-card .actions .add-btn-sm:hover{background:#dcfce7;} .cmd-card{border-left:4px solid #6366f1} .menu-card{border-left:4px solid #0ea5e9} .row-card{border-left:4px solid #f59e0b;min-width:200px} .flow-card{border-left:4px solid #8b5cf6} .sub-card{border-left:4px solid #10b981} .root-card{border-left:4px solid #374151} .row-list{display:flex;flex-direction:column;gap:3px;margin-top:6px;width:100%} .row-item{display:flex;align-items:center;gap:6px;background:#f9fafb;padding:5px 8px;border-radius:6px;font-size:12px;border:1px solid #f0f1f3;transition:all .12s;cursor:pointer} .row-item:hover{background:#f0f4ff;border-color:#d6e4ff} .row-item .r-icon{font-size:14px;flex-shrink:0} .row-item .r-title{font-weight:600;flex:1;color:#1f2937} .row-item .r-id{font-size:10px;color:#6b7280;font-family:monospace;background:#eef1f5;padding:1px 5px;border-radius:3px} .row-item .r-arrow{color:#9ca3af;font-size:12px} .row-item .r-target{font-size:11px;color:#6366f1;font-weight:500;font-family:monospace} .connector-v{width:2px;height:20px;background:#d1d5db;flex-shrink:0} .connector-v.thick{height:28px} .connector-branch{display:flex;gap:20px;position:relative;padding-top:4px;flex-wrap:wrap;justify-content:center} .connector-branch::before{content:'';position:absolute;top:0;left:50%;right:50%;height:2px;background:#d1d5db} .connector-branch::after{content:none} .branch-item{display:flex;flex-direction:column;align-items:center;position:relative;min-width:0} .branch-item::before{content:'';position:absolute;top:0;left:50%;width:2px;height:10px;background:#d1d5db} .branch-item:first-child::before{left:50%} .branch-item:last-child::before{left:50%} .empty-hint{padding:20px;text-align:center;color:#9ca3af;font-size:12px} .overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:1000;backdrop-filter:blur(2px)} .overlay.show{display:flex;align-items:center;justify-content:center} .modal{background:#fff;border-radius:14px;max-width:520px;width:90%;max-height:85vh;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,.15);padding:24px} .modal h2{font-size:16px;font-weight:700;color:#0b3d91;margin-bottom:16px;padding-bottom:10px;border-bottom:2px solid #eef1f5} .modal .form-group{margin-bottom:12px} .modal .form-group label{display:block;font-size:12px;font-weight:600;color:#3d4552;margin-bottom:4px} .modal .form-group input,.modal .form-group select,.modal .form-group textarea{width:100%;padding:8px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px;outline:none;font-family:inherit;background:#fafbfc;transition:all .15s;color:#1a1d23} .modal .form-group input:focus,.modal .form-group select:focus,.modal .form-group textarea:focus{border-color:#1565c0;background:#fff;box-shadow:0 0 0 3px rgba(21,101,192,.08)} .modal .form-group textarea{min-height:50px;resize:vertical} .modal .btn-row{display:flex;gap:8px;justify-content:flex-end;margin-top:16px} .modal .btn-row button{padding:8px 20px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;border:none;transition:all .12s} .modal .btn-row .btn-save{background:#0b3d91;color:#fff} .modal .btn-row .btn-save:hover{background:#1565c0} .modal .btn-row .btn-cancel{background:#f3f4f6;color:#4b5563} .modal .btn-row .btn-cancel:hover{background:#e5e7eb} .modal .btn-row .btn-del-modal{background:#fef2f2;color:#991b1b} .modal .btn-row .btn-del-modal:hover{background:#fee2e2} .badge-list{background:#e0f2fe;color:#0369a1} .badge-button{background:#fef3c7;color:#92400e} .badge-text{background:#f3e8ff;color:#6b21a8} .badge-func{background:#d1fae5;color:#065f46} .badge-menu{background:#dbeafe;color:#1e40af} .badge-ai{background:#fce7f3;color:#9d174d} .badge-agent{background:#fff7ed;color:#9a3412} .badge-webhook{background:#e0e7ff;color:#3730a3} .highlight-path .node-card{opacity:.4} .highlight-path .highlighted .node-card{opacity:1;box-shadow:0 0 0 2px #6366f1,0 4px 12px rgba(99,102,241,.15)} .row-item.highlighted{background:#eef2ff;border-color:#a5b4fc} @media(max-width:768px){.wrap{padding:12px}.tree-canvas{padding:12px}.connector-branch{gap:10px}.node-card{min-width:160px;max-width:260px}}
{$toastHtml}
'; if (!$company) { echo '
Selecciona una empresa para ver su hilo conductor.
'; echo '
'; exit; } $c = $company; $cName = self::h($c['name'] ?? ''); // ─── Build flows lookup ───────────────────────────────────────── $flows = $config['flows'] ?? []; $menus = $config['menus'] ?? []; $commands = $config['commands'] ?? []; $greeting = $config['greeting'] ?? ''; $fallback = $config['fallback'] ?? ''; echo << {$cName} ⚙️ Ir a Configuración Completa
Leyenda: Comando Menú Botón / Fila Flujo (acción) Sub-menú Acción final
HTML; // ─── Render the conversation tree ──────────────────────────────── self::renderConversationTree($config, $companyId); echo '
'; // ─── Inline JS for interactivity ───────────────────────────────── $configJson = json_encode($config, JSON_UNESCAPED_UNICODE); echo <<
HTML; exit; } private static function renderConversationTree(array $config, int $companyId): void { $flows = $config['flows'] ?? []; $menus = $config['menus'] ?? []; $commands = $config['commands'] ?? []; $greeting = $config['greeting'] ?? ''; $fallback = $config['fallback'] ?? ''; $hasAny = !empty($commands) || !empty($menus) || !empty($flows) || $greeting !== ''; if (!$hasAny) { echo '
🚀 No hay configuración aún. Crea comandos, menús y flujos para ver el hilo conductor.
'; return; } // ── Build a reverse map: menuKey -> list of commands that point to it ── $cmdToMenus = []; foreach ($commands as $kw => $action) { if (isset($menus[$action])) { $cmdToMenus[$action][] = $kw; } } // ── Render entry points ── echo '
'; // Greeting if ($greeting !== '') { echo '
'; echo '
'; echo '
👋 Entrada
'; echo '
Mensaje de Bienvenida
'; echo '
"' . self::h($greeting) . '"
'; echo '
'; echo '
'; echo '
'; } // ── Render commands (entry points) ── $displayedMenus = []; // Render commands that point to menus echo '
'; echo '
'; foreach ($commands as $kw => $action) { $targetType = isset($menus[$action]) ? 'menu' : (isset($flows[$action]) ? 'flow' : 'unknown'); $targetIcon = $targetType === 'menu' ? '📑' : ($targetType === 'flow' ? '🔀' : '❓'); echo '
'; echo '
'; echo '
⌨️ Comando
'; echo '
' . self::h($kw) . '
'; echo '
→ ' . $targetIcon . ' ' . self::h($action) . '
'; echo '
'; echo ''; echo '
'; echo '
'; // Connect to target echo '
'; // Show target menu or flow if ($targetType === 'menu' && isset($menus[$action])) { self::renderMenuNode($action, $menus[$action], $flows, $menus, $companyId); $displayedMenus[$action] = true; } elseif ($targetType === 'flow' && isset($flows[$action])) { self::renderFlowNode($action, $flows[$action], $menus, $companyId); } echo '
'; } echo '
'; echo '
'; // ── Render menus that are not linked by any command ── $orphanMenus = array_diff_key($menus, $displayedMenus); if (!empty($orphanMenus)) { echo '
'; echo '
📌 Menús sin comando de entrada
'; echo '
'; foreach ($orphanMenus as $mk => $menu) { echo '
'; echo ''; echo '
'; self::renderMenuNode($mk, $menu, $flows, $menus, $companyId); echo '
'; } echo '
'; } // ── Render orphan flows (not linked by any menu row) ── $linkedFlows = []; foreach ($menus as $mk => $menu) { foreach ($menu['sections'] ?? [] as $section) { foreach ($section['rows'] ?? [] as $row) { if (isset($flows[$row['id']])) { $linkedFlows[$row['id']] = true; } } } } foreach ($commands as $kw => $action) { if (isset($flows[$action])) $linkedFlows[$action] = true; } $orphanFlows = array_diff_key($flows, $linkedFlows); if (!empty($orphanFlows)) { echo '
'; echo '
📌 Flujos sin conectar
'; echo '
'; foreach ($orphanFlows as $fk => $flow) { echo '
'; echo '
'; echo '
🔀 Flujo
'; echo '
' . self::h($fk) . '
'; echo '
'; if ($flow['type'] === 'text') echo '📝 ' . self::h(mb_substr($flow['message'] ?? '', 0, 60)); elseif ($flow['type'] === 'function') echo '⚡ ' . self::h($flow['function'] ?? ''); elseif ($flow['type'] === 'menu') echo '📑 → ' . self::h($flow['menu'] ?? ''); echo '
'; echo '
'; echo ''; echo '
'; echo '
'; echo '
'; } echo '
'; } // ── Add buttons at bottom ── echo '
'; echo '
'; echo ''; echo ''; echo ''; echo '
'; echo '
'; echo ''; echo '
'; echo '
'; // root-node } private static function renderMenuNode(string $menuKey, array $menu, array $flows, array $allMenus, int $companyId): void { $typeLabel = ($menu['type'] ?? 'list') === 'list' ? 'Lista' : 'Botones'; $typeBadge = ($menu['type'] ?? 'list') === 'list' ? 'badge-list' : 'badge-button'; $hasRows = false; foreach ($menu['sections'] ?? [] as $section) { if (!empty($section['rows'])) { $hasRows = true; break; } } echo '
'; echo ''; // node-card // ── Show first sub-menu connection inline ── foreach ($menu['sections'] ?? [] as $section) { foreach ($section['rows'] ?? [] as $row) { $flow = $flows[$row['id']] ?? null; if ($flow && $flow['type'] === 'menu' && isset($flow['menu'])) { echo '
'; echo '
'; echo '
🔘 ' . self::h($row['title']) . ' →
'; self::renderFlowNode($row['id'], $flow, $allMenus, $companyId, true); echo '
'; break 2; } } } echo '
'; // flex container } private static function renderFlowNode(string $flowKey, array $flow, array $allMenus, int $companyId, bool $isSub = false): void { $icon = '🔀'; $typeLabel = 'Flujo'; $typeColor = '#8b5cf6'; $cardClass = 'flow-card'; if ($flow['type'] === 'text') { $icon = '📝'; $typeLabel = 'Respuesta'; $typeColor = '#8b5cf6'; } elseif ($flow['type'] === 'function') { $fn = $flow['function'] ?? 'forward_to_ai'; $fnLabels = ['forward_to_ai' => '🤖 IA', 'forward_to_agent' => '👤 Agente', 'webhook' => '🔗 Webhook']; $icon = $fn === 'forward_to_ai' ? '🤖' : ($fn === 'forward_to_agent' ? '👤' : '🔗'); $typeLabel = $fnLabels[$fn] ?? '⚡ Función'; $typeColor = $fn === 'forward_to_ai' ? '#ec4899' : ($fn === 'forward_to_agent' ? '#f97316' : '#6366f1'); $cardClass = 'flow-card action'; } elseif ($flow['type'] === 'menu') { $icon = '📑'; $typeLabel = 'Sub-menú'; $typeColor = '#10b981'; $cardClass = 'sub-card'; } $nodeId = 'flow-node-' . $flowKey; echo '
'; echo '
' . $icon . ' ' . $typeLabel . '
'; echo '
' . self::h($flowKey) . '
'; if ($flow['type'] === 'text') { echo '
' . self::h(mb_substr($flow['message'] ?? '', 0, 80)) . '
'; } elseif ($flow['type'] === 'menu' && isset($flow['menu']) && isset($allMenus[$flow['menu']])) { echo '
→ ' . self::h($flow['menu']) . '
'; } elseif ($flow['type'] === 'function') { echo '
' . ($flow['function'] ?? 'forward_to_ai') . '
'; } echo '
'; echo ''; echo '
'; echo '
'; // If flow is sub-menu, render the sub-menu below if ($flow['type'] === 'menu' && isset($flow['menu']) && isset($allMenus[$flow['menu']])) { $subKey = $flow['menu']; $subMenu = $allMenus[$subKey]; echo '
'; echo '
'; echo '
📑 Sub-menú: ' . self::h($subKey) . '
'; if ($subMenu['body']) echo '
' . self::h($subMenu['body']) . '
'; // Show sub-menu's rows $hasRows = false; foreach ($subMenu['sections'] ?? [] as $section) { if (!empty($section['rows'])) { $hasRows = true; break; } } if ($hasRows) { echo '
'; foreach ($subMenu['sections'] ?? [] as $section) { foreach ($section['rows'] ?? [] as $row) { echo '
'; echo '🔘'; echo '' . self::h($row['title']) . ''; echo '' . self::h($row['id']) . ''; echo '
'; } } echo '
'; } else { echo '
Sin botones
'; } echo '
'; echo ''; echo '
'; echo '
'; } } // ─── GET /admin/test-message ────────────────────────────────────────────── public static function testMessage(): void { SessionAuth::require(); $user = SessionAuth::user(); $companies = CompanyRepository::findAll(); $opts = ''; foreach ($companies as $c) { $opts .= ''; } echo Layout::open('Mensaje de prueba', 'test', $user['username'] ?? 'Admin'); echo <<

Mensaje de prueba

Simula un mensaje entrante de WhatsApp al webhook. El bot responde igual que en producción.

HTML; echo Layout::close(); } // ─── POST /admin/test-message/send ─────────────────────────────────────── public static function testMessageSend(): void { SessionAuth::require(); header('Content-Type: application/json; charset=utf-8'); $phoneNumberId = trim($_POST['phone_number_id'] ?? ''); $fromNumber = preg_replace('/\D/', '', $_POST['from_number'] ?? ''); $fromName = trim($_POST['from_name'] ?? 'Test'); $message = trim($_POST['message'] ?? ''); if ($phoneNumberId === '' || $fromNumber === '' || $message === '') { echo json_encode(['ok' => false, 'error' => 'Faltan campos requeridos']); exit; } $payload = [ 'object' => 'whatsapp_business_account', 'entry' => [[ 'id' => 'TEST_WABA', 'changes' => [[ 'field' => 'messages', 'value' => [ 'messaging_product' => 'whatsapp', 'metadata' => [ 'display_phone_number' => '0000000000', 'phone_number_id' => $phoneNumberId, ], 'contacts' => [[ 'profile' => ['name' => $fromName], 'wa_id' => $fromNumber, ]], 'messages' => [[ 'id' => 'wamid.TEST_' . time(), 'from' => $fromNumber, 'timestamp' => (string)time(), 'type' => 'text', 'text' => ['body' => $message], ]], ], ]], ]], ]; $json = json_encode($payload, JSON_UNESCAPED_UNICODE); $appSecret = env('WHATSAPP_APP_SECRET', ''); $sig = $appSecret !== '' ? 'sha256=' . hash_hmac('sha256', $json, $appSecret) : 'sha256=test'; // POST al webhook propio vía cURL $webhookUrl = 'https://admin.palmas360.com/admin/v1/wp-webhook'; $ch = curl_init($webhookUrl); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $json, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'X-Hub-Signature-256: ' . $sig, ], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, CURLOPT_SSL_VERIFYPEER => false, ]); $resp = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlErr = curl_error($ch); curl_close($ch); if ($curlErr) { echo json_encode(['ok' => false, 'error' => 'cURL: ' . $curlErr]); exit; } if ($httpCode === 200) { echo json_encode(['ok' => true, 'message' => 'Mensaje enviado al webhook. Revisa Live Feed y logs.']); } else { $body = is_string($resp) ? substr($resp, 0, 200) : ''; echo json_encode(['ok' => false, 'error' => "Webhook respondió HTTP $httpCode. $body"]); } exit; } }