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-raw{background:#fef9c3;color:#854d0e} .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) { $row = db()->query('SELECT id FROM webhook_logs ORDER BY id DESC LIMIT 1')->fetch(); $lastId = $row ? (int)$row['id'] : 0; header('Content-Type: application/json; charset=utf-8'); echo json_encode(['rows' => [], 'last_id' => $lastId]); 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 json_encode(['rows' => [], 'last_id' => $afterId]); exit; } header('Content-Type: application/json; charset=utf-8'); echo json_encode(['rows' => $rows, 'last_id' => $rows ? (int)end($rows)['id'] : $afterId], 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 — CRUD dinámico $epStmt = db()->prepare("SELECT id,endpoint_key,name,params,direction,url,method,is_active,last_called_at,body_fields FROM company_endpoints WHERE company_id=? ORDER BY direction,endpoint_key"); $epStmt->execute([$id]); $allEps = $epStmt->fetchAll(\PDO::FETCH_ASSOC); $baseRaw = rtrim($company['api_base_url'] ?? '', '/'); $baseHtml = self::h($baseRaw); $epRows = ''; foreach ($allEps as $ep) { $epKey = self::h($ep['endpoint_key']); $epName = self::h($ep['name'] ?? ''); $epUrl = self::h($ep['url'] ?? ''); $epMeth = $ep['method'] ?? 'GET'; // Preview full URL $rawUrl = $ep['url'] ?? ''; if ($rawUrl === '' || str_starts_with($rawUrl, 'http')) { $previewUrl = self::h($rawUrl); } else { $sep = str_starts_with($rawUrl, '?') ? '' : '/'; $previewUrl = self::h($baseRaw . $sep . ltrim($rawUrl, '/')); } $epDir = $ep['direction'] ?? 'download'; $epParams = self::h($ep['params'] ?? ''); $epAct = $ep['is_active'] ? 'checked' : ''; $lastAt = $ep['last_called_at'] ? '' . self::h($ep['last_called_at']) . '' : ''; $mSel = fn($v) => $epMeth === $v ? 'selected' : ''; $dSel = fn($v) => $epDir === $v ? 'selected' : ''; $epId = (int)$ep['id']; // body_fields for collect_and_post $bfData = json_decode($ep['body_fields'] ?? '', true) ?: []; $bfRowsHtml = ''; foreach ($bfData as $bf) { $bk = self::h($bf['key'] ?? ''); $bl = self::h($bf['label'] ?? ''); $bp = self::h($bf['prompt'] ?? ''); $bfRowsHtml .= "
"; } $bfSection = $epDir === 'upload' ? '' : 'display:none'; // ── URL vars config ────────────────────────────────────────── $varConfigs = json_decode($ep['params'] ?? '[]', true) ?: []; // Detect {var} placeholders in URL preg_match_all('/\{([^}]+)\}/', $ep['url'] ?? '', $urlVarMatches); $urlVars = array_unique($urlVarMatches[1] ?? []); // Merge: URL vars not yet in config get added with default mode=ask $configuredKeys = array_column($varConfigs, 'key'); foreach ($urlVars as $uv) { if (!in_array($uv, $configuredKeys, true)) { $varConfigs[] = ['key' => $uv, 'mode' => 'ask', 'prompt' => "¿Ingresa {$uv}?", 'value' => '']; } } $varRowsHtml = ''; $fixedPresets = ['current_date'=>'Fecha actual','month_start'=>'Inicio mes actual','month_end'=>'Fin mes actual','last_7_start'=>'Hace 7 días','last_30_start'=>'Hace 30 días','year_start'=>'Inicio año']; foreach ($varConfigs as $vc) { $vk = self::h($vc['key'] ?? ''); $vm = $vc['mode'] ?? 'ask'; $vp = self::h($vc['prompt'] ?? ''); $vv = self::h($vc['value'] ?? ''); $mAsk = $vm === 'ask' ? 'selected' : ''; $mFix = $vm === 'fixed' ? 'selected' : ''; $askStyle = $vm === 'ask' ? '' : 'display:none'; $fixStyle = $vm === 'fixed' ? '' : 'display:none'; $fixOptions = ''; foreach ($fixedPresets as $pk => $pl) { $sel = $vv === $pk ? 'selected' : ''; $fixOptions .= ""; } // Custom literal option $isCustom = $vv !== '' && !array_key_exists($vv, $fixedPresets); $fixOptions .= ''; $customVal = $isCustom ? $vv : ''; $varRowsHtml .= << {{{$vk}}} VR; } $varsSection = !empty($urlVars) ? '' : 'display:none'; $epRows .= <<
{$previewUrl}
Usa {'{var}'} para variables. {$lastAt}
⚙️ Variables de URL — configura cómo se obtiene cada {'{var}'}
{$varRowsHtml}
📋 Campos del body (para "Pedir campos y enviar")
{$bfRowsHtml}
ROW; } if (!$epRows) $epRows = 'Sin endpoints. Agrega uno abajo.'; $epHtml = <<🔌 Endpoints API
🔗 URL base: {$baseHtml} cambiar en General → · Los endpoints usan rutas relativas a esta URL

Escribe solo la ruta o sufijo: ?peticion=produccion o /mi/ruta. Se combina con la URL base.
Usa {'{variable}'} para variables que el bot pedirá al usuario. Ejemplo: ?desde={'{desde}'}&hasta={'{hasta}'}

{$epRows}
KeyNombreDirecciónMétodoRuta / sufijoActivo
+ Agregar endpoint
EP; // Prepend clone panel $otherCompanies = array_filter(CompanyRepository::findAll(), fn($c) => (int)$c['id'] !== $id); $cloneOpts = ''; foreach ($otherCompanies as $oc) { $cloneOpts .= ''; } $clonePanel = <<
📋 Clonar endpoints desde otra empresa
CLONE; $epHtml = $clonePanel . $epHtml; } // For "copy from" dropdown on new company form $copyOpts = ''; if (!$isEdit) { foreach (CompanyRepository::findAll(true) as $co) { $cid = (int)$co['id']; $cn = self::h($co['name']); $copyOpts .= ""; } } $allCompaniesJson = !$isEdit ? json_encode( array_column(CompanyRepository::findAll(true), null, 'id'), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG ) : '{}'; $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
{$errorBanner} {$tabsHtml}
HTML; if ($isEdit) echo ''; if (!$isEdit && $copyOpts !== '') echo <<
Copia API URL, API Key, tipo de bot, configuración JSON y endpoints. Podrás editar cada campo antes de guardar.
COPY; echo <<
Identificador único, sin espacios
ID del número de WhatsApp Business desde el que se envían mensajes. Si todas las empresas comparten el mismo número, usa el mismo valor para todas.
Base URL del ERP de esta empresa
Define los límites de números WA.
Cancelar
HTML; if (!$isEdit) { echo << SCRIPT; } if ($isEdit) { echo <<
{$phonesHtml}
{$epHtml}
HTML; } echo ''; echo Layout::close(); } // ─── POST /admin/company/phones/sync ───────────────────────────────────── public static function companyPhonesSync(): void { SessionAuth::require(); header('Content-Type: application/json; charset=utf-8'); $companyId = (int)($_POST['company_id'] ?? 0); if ($companyId <= 0) { echo json_encode(['ok' => false, 'error' => 'ID de empresa inválido']); exit; } $company = CompanyRepository::findById($companyId); if (!$company) { echo json_encode(['ok' => false, 'error' => 'Empresa no encontrada']); exit; } // Buscar endpoint numeros_dn configurado para esta empresa $epRow = db()->prepare("SELECT url, method FROM company_endpoints WHERE company_id=? AND endpoint_key='numeros_dn' LIMIT 1"); $epRow->execute([$companyId]); $ep = $epRow->fetch(\PDO::FETCH_ASSOC); if (!$ep || empty($ep['url'])) { echo json_encode(['ok' => false, 'error' => 'El endpoint "Informe de números activos" no está configurado en la pestaña Endpoints API.']); exit; } // Llamar al endpoint — prepend base URL si es ruta relativa $epUrl = $ep['url']; if (!str_starts_with($epUrl, 'http')) { $base = rtrim($company['api_base_url'] ?? '', '/'); $sep = str_starts_with($epUrl, '?') ? '' : '/'; $epUrl = $base . $sep . ltrim($epUrl, '/'); } $apiKey = $company['api_key'] ?? ''; $ch = curl_init($epUrl); $opts = [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_HTTPHEADER => ['Accept: application/json'], ]; if ($apiKey !== '') $opts[CURLOPT_HTTPHEADER][] = 'Authorization: Bearer ' . $apiKey; if (strtoupper($ep['method'] ?? 'GET') === 'POST') { $opts[CURLOPT_POST] = true; $opts[CURLOPT_POSTFIELDS] = '{}'; $opts[CURLOPT_HTTPHEADER][] = 'Content-Type: application/json'; } curl_setopt_array($ch, $opts); $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' => 'No se pudo conectar al ERP.', 'detail' => $curlErr]); exit; } if ($httpCode !== 200) { echo json_encode(['ok' => false, 'error' => "El ERP respondió HTTP {$httpCode}.", 'detail' => substr((string)$resp, 0, 300)]); exit; } $data = json_decode($resp, true); if (!is_array($data)) { echo json_encode(['ok' => false, 'error' => 'La respuesta del ERP no es JSON válido.', 'detail' => substr((string)$resp, 0, 300)]); exit; } // Aceptar root array o {numeros:[...]} $numeros = isset($data['numeros']) ? $data['numeros'] : (array_values($data) && is_array($data[0] ?? null) ? $data : []); if (empty($numeros)) { echo json_encode(['ok' => false, 'error' => 'El JSON no contiene números. Espera un array en "numeros" o un array raíz.', 'detail' => substr($resp, 0, 300)]); exit; } $inserted = 0; $updated = 0; $skipped = 0; $pTypeMap = ['1' => 1, '2' => 2, '3' => 3, 1 => 1, 2 => 2, 3 => 3]; $stmt = 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 "); foreach ($numeros as $n) { $waNumber = preg_replace('/\D/', '', (string)($n['wa_number'] ?? $n['numero'] ?? $n['phone'] ?? '')); if (strlen($waNumber) < 7) { $skipped++; continue; } $label = trim((string)($n['nombre'] ?? $n['name'] ?? $n['label'] ?? '')); $permRaw = $n['permiso'] ?? $n['permission_type'] ?? $n['tipo'] ?? 3; $permission = $pTypeMap[(int)$permRaw] ?? 3; try { $before = db()->prepare("SELECT id FROM company_phones WHERE company_id=? AND wa_number=?"); $before->execute([$companyId, $waNumber]); $exists = $before->fetch(); $stmt->execute([$companyId, $waNumber, $label, $permission]); $exists ? $updated++ : $inserted++; } catch (\PDOException $e) { $skipped++; } } echo json_encode([ 'ok' => true, 'message' => "Sincronización completa: {$inserted} nuevos, {$updated} actualizados, {$skipped} omitidos.", ]); exit; } // ─── 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); $epId = (int)($_POST['ep_id'] ?? 0); $key = preg_replace('/[^a-z0-9_]/', '', strtolower($_POST['endpoint_key'] ?? '')); $name = trim($_POST['name'] ?? ''); $direction = in_array($_POST['direction'] ?? '', ['upload','download','list']) ? $_POST['direction'] : 'download'; $url = trim($_POST['url'] ?? ''); $method = in_array(strtoupper($_POST['method'] ?? 'GET'), ['GET','POST']) ? strtoupper($_POST['method']) : 'GET'; $isActive = ($_POST['is_active'] ?? '0') === '1' ? 1 : 0; $params = trim($_POST['params'] ?? ''); $bodyFieldsRaw = trim($_POST['body_fields'] ?? ''); // Validate JSON or null $bodyFields = null; if ($bodyFieldsRaw !== '' && $bodyFieldsRaw !== '[]') { $decoded = json_decode($bodyFieldsRaw, true); if (is_array($decoded) && !empty($decoded)) { $bodyFields = json_encode($decoded, JSON_UNESCAPED_UNICODE); } } if ($companyId <= 0 || $key === '') { echo json_encode(['ok' => false, 'error' => 'Datos inválidos']); exit; } try { if ($epId > 0) { db()->prepare("UPDATE company_endpoints SET endpoint_key=?,name=?,direction=?,url=?,method=?,is_active=?,params=?,body_fields=? WHERE id=? AND company_id=?") ->execute([$key, $name, $direction, $url, $method, $isActive, $params ?: null, $bodyFields, $epId, $companyId]); } else { db()->prepare("INSERT INTO company_endpoints (company_id,endpoint_key,name,direction,url,method,is_active,params,body_fields) VALUES (?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE name=VALUES(name),direction=VALUES(direction),url=VALUES(url),method=VALUES(method),is_active=VALUES(is_active),params=VALUES(params),body_fields=VALUES(body_fields)") ->execute([$companyId, $key, $name, $direction, $url, $method, $isActive, $params ?: null, $bodyFields]); } echo json_encode(['ok' => true]); } catch (\PDOException $e) { echo json_encode(['ok' => false, 'error' => $e->getMessage()]); } exit; } // ─── POST /admin/company/endpoint/delete ──────────────────────────────── public static function companyEndpointDelete(): void { SessionAuth::require(); header('Content-Type: application/json; charset=utf-8'); $companyId = (int)($_POST['company_id'] ?? 0); $epId = (int)($_POST['ep_id'] ?? 0); if ($companyId <= 0 || $epId <= 0) { echo json_encode(['ok'=>false,'error'=>'Datos inválidos']); exit; } try { db()->prepare("DELETE FROM company_endpoints WHERE id=? AND company_id=?")->execute([$epId, $companyId]); 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; } // Resolve {var} placeholders using params config $stmt = db()->prepare("SELECT params FROM company_endpoints WHERE company_id=? AND endpoint_key=?"); $stmt->execute([$companyId, $key]); $paramsJson = $stmt->fetchColumn(); $varConfigs = is_string($paramsJson) ? (json_decode($paramsJson, true) ?? []) : []; $presets = [ 'current_date' => date('Y-m-d'), 'month_start' => date('Y-m-01'), 'month_end' => date('Y-m-t'), 'last_7_start' => date('Y-m-d', strtotime('-7 days')), 'last_30_start' => date('Y-m-d', strtotime('-30 days')), 'year_start' => date('Y-01-01'), ]; foreach ($varConfigs as $cfg) { $k = $cfg['key'] ?? ''; if (!$k) continue; if (($cfg['mode'] ?? '') === 'fixed') { $val = $cfg['value'] ?? 'current_date'; $url = str_replace('{' . $k . '}', $presets[$val] ?? $val, $url); } else { // ponytail: ask-mode vars get today as test default $url = str_replace('{' . $k . '}', date('Y-m-d'), $url); } } // Replace any remaining unresolved {vars} with today $url = preg_replace('/\{[^}]+\}/', date('Y-m-d'), $url); $company = CompanyRepository::findById($companyId); $apiKey = $company['api_key'] ?? ''; // Prepend api_base_url for relative paths if ($url !== '' && !str_starts_with($url, 'http')) { $base = rtrim($company['api_base_url'] ?? '', '/'); $sep = str_starts_with($url, '?') ? '' : '/'; $url = $base . $sep . ltrim($url, '/'); } $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; } $isBinary = !empty($contentType) && !str_contains($contentType, 'json') && !str_contains($contentType, 'text'); $decoded = !$isBinary ? json_decode($resp, true) : null; if ($isBinary) { $preview = sprintf('Archivo recibido: %s — %s KB', explode(';', $contentType)[0], round(strlen($resp) / 1024, 1)); } elseif (is_array($decoded)) { $preview = $decoded; } else { $preview = mb_convert_encoding(substr((string)$resp, 0, 500), 'UTF-8', 'UTF-8'); } // Guardar última respuesta if ($key !== '') { $logVal = $isBinary ? "binary:{$contentType}:" . strlen($resp) . 'B' : substr((string)$resp, 0, 2000); db()->prepare("UPDATE company_endpoints SET last_response=?, last_called_at=NOW() WHERE company_id=? AND endpoint_key=?") ->execute([$logVal, $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; } // ─── POST /admin/company/endpoints/clone ───────────────────────────────── public static function companyEndpointsClone(): void { SessionAuth::require(); header('Content-Type: application/json; charset=utf-8'); $sourceId = (int)($_POST['source_company_id'] ?? 0); $targetId = (int)($_POST['target_company_id'] ?? 0); $mode = ($_POST['mode'] ?? '') === 'add_only' ? 'add_only' : 'overwrite'; if ($sourceId <= 0 || $targetId <= 0 || $sourceId === $targetId) { echo json_encode(['ok' => false, 'error' => 'IDs inválidos']); exit; } try { $stmt = db()->prepare("SELECT endpoint_key, direction, url, method FROM company_endpoints WHERE company_id = ?"); $stmt->execute([$sourceId]); $sourceEps = $stmt->fetchAll(\PDO::FETCH_ASSOC); if (empty($sourceEps)) { echo json_encode(['ok' => false, 'error' => 'La empresa origen no tiene endpoints configurados']); exit; } $existingKeys = []; if ($mode === 'add_only') { $ex = db()->prepare("SELECT endpoint_key FROM company_endpoints WHERE company_id = ? AND url IS NOT NULL AND url != ''"); $ex->execute([$targetId]); $existingKeys = array_column($ex->fetchAll(\PDO::FETCH_ASSOC), 'endpoint_key'); } $upsert = db()->prepare("INSERT INTO company_endpoints (company_id, endpoint_key, direction, url, method) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE url=VALUES(url), method=VALUES(method)"); $count = 0; foreach ($sourceEps as $ep) { if ($mode === 'add_only' && in_array($ep['endpoint_key'], $existingKeys, true)) continue; $upsert->execute([$targetId, $ep['endpoint_key'], $ep['direction'], $ep['url'], $ep['method']]); $count++; } $label = $mode === 'add_only' ? 'añadidos' : 'sobreescritos'; echo json_encode(['ok' => true, 'count' => $count, 'message' => "{$count} endpoint(s) {$label} correctamente"]); } catch (\PDOException $e) { echo json_encode(['ok' => false, 'error' => 'Error de base de datos: ' . $e->getMessage()]); } 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' => ''], ], ]; // ── Valores globales de IA para el panel custom ──────────────────────── $gAiProvider = $settings['ai_provider'] ?? 'openai'; $gOaiKey = self::h($settings['openai_api_key'] ?? ''); $gOaiModel = $settings['openai_model'] ?? 'gpt-4o-mini'; $gGemKey = self::h($settings['gemini_api_key'] ?? ''); $gGemModel = $settings['gemini_model'] ?? 'gemini-2.5-flash'; $gClaKey = self::h($settings['claude_api_key'] ?? ''); $gClaModel = $settings['claude_model'] ?? 'claude-haiku-4-5'; $gMaxTok = self::h($settings['ai_max_tokens'] ?? '500'); $gPrompt = self::h($settings['ai_default_prompt'] ?? ''); $gSelOai = $gAiProvider === 'openai' ? ' selected' : ''; $gSelGem = $gAiProvider === 'gemini' ? ' selected' : ''; $gSelCla = $gAiProvider === 'claude' ? ' selected' : ''; $gSelMock = $gAiProvider === 'mock' ? ' selected' : ''; $gShowOai = ($gAiProvider === 'openai' || $gAiProvider === '') ? '' : 'display:none'; $gShowGem = $gAiProvider === 'gemini' ? '' : 'display:none'; $gShowCla = $gAiProvider === 'claude' ? '' : 'display:none'; $sOaiMini = $gOaiModel === 'gpt-4o-mini' ? ' selected' : ''; $sOai41m = $gOaiModel === 'gpt-4.1-mini' ? ' selected' : ''; $sOai4o = $gOaiModel === 'gpt-4o' ? ' selected' : ''; $sOai41 = $gOaiModel === 'gpt-4.1' ? ' selected' : ''; $sOai35 = $gOaiModel === 'gpt-3.5-turbo' ? ' selected' : ''; $sGemF25 = $gGemModel === 'gemini-2.5-flash' ? ' selected' : ''; $sGemF20 = $gGemModel === 'gemini-2.0-flash' ? ' selected' : ''; $sGemF15 = $gGemModel === 'gemini-1.5-flash' ? ' selected' : ''; $sGemP15 = $gGemModel === 'gemini-1.5-pro' ? ' selected' : ''; $sClaH = $gClaModel === 'claude-haiku-4-5' ? ' selected' : ''; $sClaS = $gClaModel === 'claude-sonnet-5' ? ' selected' : ''; $sClaO = $gClaModel === 'claude-opus-4-8' ? ' selected' : ''; $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 <<
🤖 Inteligencia Artificial (Global)
🔵 OpenAI
🟢 Google Gemini
🟣 Anthropic Claude
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'); // Pre-load conversations for inline embedding (no extra HTTP round-trip) $companyMap = []; foreach (CompanyRepository::findAll() as $co) { $companyMap[(int)$co['id']] = $co['name']; } $convRows = db()->query(" SELECT c.phone_number, MAX(c.contact_name) as contact_name, MAX(c.company_id) as 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 message_type FROM conversations c2 WHERE c2.phone_number = c.phone_number ORDER BY c2.id DESC LIMIT 1) as last_type, (SELECT COUNT(*) FROM conversations c3 WHERE c3.phone_number = c.phone_number AND c3.direction = 'inbound' AND c3.created_at > COALESCE( (SELECT MAX(c4.created_at) FROM conversations c4 WHERE c4.phone_number = c.phone_number AND c4.direction = 'outbound'), '2000-01-01')) as unread_count FROM conversations c GROUP BY c.phone_number ORDER BY last_time DESC ")->fetchAll(); $initialConvs = []; foreach ($convRows as $r) { $cid = (int)$r['company_id']; $preview = mb_substr(self::extractContent($r['last_message'] ?? '', $r['last_type'] ?? 'text'), 0, 80); $initialConvs[] = [ 'phone' => $r['phone_number'], 'contact_name' => $r['contact_name'] ?? '', 'company_name' => $companyMap[$cid] ?? '', 'last_message' => $preview, 'last_time' => $r['last_time'], 'unread_count' => (int)$r['unread_count'], ]; } $initialConvsJson = json_encode($initialConvs, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT); 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; } // ─── Helper: extract human-readable text from raw conversation content ─── private static function extractContent(string $raw, string $type): string { if ($raw === '') return ''; // Inbound button_reply: "button_reply: {...}" if (str_starts_with($raw, 'button_reply: ')) { $j = json_decode(substr($raw, 14), true); return $j['title'] ?? $raw; } // Inbound list_reply: "list_reply: {...}" if (str_starts_with($raw, 'list_reply: ')) { $j = json_decode(substr($raw, 12), true); return $j['title'] ?? $raw; } // Try to parse as JSON $j = json_decode($raw, true); if (!is_array($j)) return $raw; // Outbound text: {"text": "..."} if (isset($j['text']) && is_string($j['text'])) return $j['text']; // Outbound interactive: {"interactive": {"body": {"text":"..."}, "action":{...}}} if (isset($j['interactive'])) { $body = $j['interactive']['body']['text'] ?? ''; $btns = []; foreach ($j['interactive']['action']['buttons'] ?? [] as $b) { $btns[] = $b['reply']['title'] ?? ''; } foreach ($j['interactive']['action']['sections'] ?? [] as $s) { foreach ($s['rows'] ?? [] as $row) { $btns[] = $row['title'] ?? ''; } } $hint = $btns ? ' [' . implode(' | ', array_filter($btns)) . ']' : ''; return $body . $hint; } // Outbound image/document if (isset($j['caption'])) return '📎 ' . $j['caption']; if (isset($j['link'])) return '📎 ' . $j['link']; return $raw; } // ─── 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, MAX(c.contact_name) as contact_name, MAX(c.company_id) as 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 message_type FROM conversations c2 WHERE c2.phone_number = c.phone_number ORDER BY c2.id DESC LIMIT 1) as last_type, (SELECT COUNT(*) FROM conversations c3 WHERE c3.phone_number = c.phone_number AND c3.direction = 'inbound' AND c3.created_at > COALESCE( (SELECT MAX(c4.created_at) FROM conversations c4 WHERE c4.phone_number = c.phone_number AND c4.direction = 'outbound'), '2000-01-01')) as unread_count FROM conversations c GROUP BY c.phone_number 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']; $raw = $r['last_message'] ?? ''; $preview = mb_substr(self::extractContent($raw, $r['last_type'] ?? 'text'), 0, 80); $conversations[] = [ 'phone' => $r['phone_number'], 'contact_name' => $r['contact_name'] ?? '', 'company_name' => $companies[$cid] ?? '', 'last_message' => $preview, '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(); // Extract readable text from raw WhatsApp payloads foreach ($messages as &$m) { $m['content'] = self::extractContent((string)($m['content'] ?? ''), (string)($m['message_type'] ?? 'text')); } unset($m); jsonResponse(200, ['messages' => $messages]); } // ─── 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; $db = db(); // Look up the WhatsApp phone_number_id for this company $company = CompanyRepository::findById($companyId); $phoneNumberId = $company['phone_number_id'] ?? env('WHATSAPP_PHONE_NUMBER_ID', ''); // Send directly — do NOT call processQueue() which would re-send ALL queued/failed messages $sendResult = WhatsAppSender::sendText($phone, $text, $phoneNumberId); // Log to conversations regardless of WhatsApp delivery result $msgId = 'admin_' . time() . '_' . $phone; $status = $sendResult['success'] ? 'sent' : 'failed'; $db->prepare("INSERT INTO conversations (company_id, message_id, phone_number, direction, message_type, content, created_at) VALUES (?, ?, ?, 'outbound', 'text', ?, NOW())") ->execute([$companyId, $msgId, $phone, $text]); // Also keep an outbound_queue record for auditing $db->prepare("INSERT INTO outbound_queue (company_id, to_number, message_type, payload, status, wam_id, created_at) VALUES (?, ?, 'text', ?, ?, ?, NOW())") ->execute([$companyId, $phone, json_encode(['text' => $text]), $status, $sendResult['wam_id'] ?? null]); if (!$sendResult['success']) { jsonResponse(500, ['error' => $sendResult['error'] ?? 'Error al enviar']); } 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.
' : ''; $companyEndpoints = []; 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; } $epStmt = db()->prepare('SELECT endpoint_key, name, url, method, direction FROM company_endpoints WHERE company_id=? AND is_active=1 ORDER BY endpoint_key'); $epStmt->execute([$companyId]); $companyEndpoints = $epStmt->fetchAll(PDO::FETCH_ASSOC); } } $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'] ?? ''); $aiForMediaChecked = ($config['ai_for_media'] ?? true) ? 'checked' : ''; $nluChecked = !empty($config['nlu']) ? 'checked' : ''; $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'] ?? [])); $welcomeMenuVal = self::h($config['welcome_menu'] ?? ''); $commands = $config['commands'] ?? []; $menus = $config['menus'] ?? []; $flows = $config['flows'] ?? []; $welcomeMenuOpts = ''; foreach (array_keys($menus) as $mk) { $s = ($config['welcome_menu'] ?? '') === $mk ? ' selected' : ''; $welcomeMenuOpts .= ''; } $aiProviderOpenai = $aiProviderVal === 'openai' ? ' selected' : ''; $aiProviderGemini = $aiProviderVal === 'gemini' ? ' selected' : ''; $aiProviderClaude = $aiProviderVal === 'claude' ? ' selected' : ''; $aiProviderMock = $aiProviderVal === 'mock' ? ' selected' : ''; $openaiApiKeyVal = self::h($config['openai_api_key'] ?? ''); $aiModelVal = $config['ai_model'] ?? 'gpt-4o-mini'; $aiModelMini = $aiModelVal === 'gpt-4o-mini' ? ' selected' : ''; $aiModel4o = $aiModelVal === 'gpt-4o' ? ' selected' : ''; $aiModel41 = $aiModelVal === 'gpt-4.1' ? ' selected' : ''; $aiModel41mini = $aiModelVal === 'gpt-4.1-mini' ? ' selected' : ''; $aiModel35 = $aiModelVal === 'gpt-3.5-turbo'? ' selected' : ''; $geminiApiKeyVal = self::h($config['gemini_api_key'] ?? ''); $geminiModelVal = $config['gemini_model'] ?? 'gemini-2.5-flash'; $geminiModelF25 = $geminiModelVal === 'gemini-2.5-flash' ? ' selected' : ''; $geminiModelF20 = $geminiModelVal === 'gemini-2.0-flash' ? ' selected' : ''; $geminiModelF15 = $geminiModelVal === 'gemini-1.5-flash' ? ' selected' : ''; $geminiModelP15 = $geminiModelVal === 'gemini-1.5-pro' ? ' selected' : ''; $claudeApiKeyVal = self::h($config['claude_api_key'] ?? ''); $claudeModelVal = $config['claude_model'] ?? 'claude-haiku-4-5'; $claudeModelH = $claudeModelVal === 'claude-haiku-4-5' ? ' selected' : ''; $claudeModelS = $claudeModelVal === 'claude-sonnet-5' ? ' selected' : ''; $claudeModelO = $claudeModelVal === 'claude-opus-4-8' ? ' selected' : ''; // Determine which provider panel to show initially $showOpenai = ($aiProviderVal === 'openai' || $aiProviderVal === '') ? 'display:flex;flex-direction:column;gap:10px' : 'display:none'; $showGemini = $aiProviderVal === 'gemini' ? 'display:flex;flex-direction:column;gap:10px' : 'display:none'; $showClaude = $aiProviderVal === 'claude' ? 'display:flex;flex-direction:column;gap:10px' : 'display:none'; // Per-category config HTML $perTypeConfig = $config['per_type'] ?? []; $menuKeysList = array_keys($menus); $catLabels = ['1' => 'Categoría 1 — Solo reporta', '2' => 'Categoría 2 — Solo recibe informes', '3' => 'Categoría 3 — Reporta y recibe']; $categoryTabHtml = ''; foreach ([1, 2, 3] as $cat) { $catCfg = $perTypeConfig[(string)$cat] ?? []; $selMenu = $catCfg['greeting_menu'] ?? ''; $opts = ''; foreach ($menuKeysList as $mk) { $s = $selMenu === $mk ? ' selected' : ''; $opts .= ''; } $label = $catLabels[(string)$cat]; $categoryTabHtml .= <<
{$label}
Menú que se muestra cuando el usuario escribe por primera vez o sin contexto activo
CATHTML; } $aiModelMini = $aiModelVal === 'gpt-4o-mini' ? ' selected' : ''; $aiModel4o = $aiModelVal === 'gpt-4o' ? ' selected' : ''; $aiModel35 = $aiModelVal === 'gpt-3.5-turbo' ? ' selected' : ''; // JS data for addFlowCard — needs CONFIG.menus/flows and ENDPOINTS $botConfigJson = json_encode(['menus' => $menus, 'flows' => $flows], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG); $botEndpointsJson = json_encode( array_column($companyEndpoints, null, 'endpoint_key'), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG ); echo <<
📋 Comandos

Palabras clave que el usuario escribe para ejecutar acciones directamente. El valor de acción debe coincidir con un ID de menú o flujo.

HTML; foreach (array_keys($menus) as $mk) echo "\n"; foreach (array_keys($config['flows'] ?? []) as $fk) echo "\n"; echo << HTML; $cmdIdx = 0; foreach ($commands as $keyword => $action) { echo ""; $cmdIdx++; } echo <<
Palabra claveAcción (ID de 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 botón de menú es seleccionado.

HTML; // Pre-build select options for menus and endpoints $menuOptHtml = ''; foreach (array_keys($menus) as $mk) { $menuOptHtml .= ''; } // Helper para etiqueta de endpoint: muestra nombre si existe, si no la key $epLabel = fn(array $ep): string => self::h( ($ep['name'] !== '' && $ep['name'] !== null) ? $ep['name'] : $ep['endpoint_key'] ); $epOptHtml = ''; foreach ($companyEndpoints as $ep) { $epOptHtml .= ''; } $flowKeys = array_keys($flows); $flowIdx = 0; foreach ($flows as $fk => $flow) { $ft = $flow['type'] ?? 'text'; $fNluDesc = self::h($flow['nlu_description'] ?? ''); $fMsg = self::h($flow['message'] ?? ''); $fFn = $flow['function'] ?? 'forward_to_ai'; $fMenu = $flow['menu'] ?? ''; $params = $flow['params'] ?? []; $epKey = self::h($params['endpoint_key'] ?? $flow['endpoint_key'] ?? ''); $dMode = self::h($params['date_mode'] ?? ''); $cap = self::h($params['caption'] ?? $flow['caption'] ?? ''); $fname = self::h($params['filename'] ?? $flow['filename'] ?? ''); $prompt = self::h($flow['prompt'] ?? ''); $mGroup = self::h($flow['meta_group'] ?? ''); $mKey = self::h($flow['meta_key'] ?? ''); $nNode = $flow['next_node'] ?? ''; $srcEp = self::h($flow['source_endpoint_key'] ?? ''); $valF = self::h($flow['value_field'] ?? 'id'); $lblF = self::h($flow['label_field'] ?? 'nombre'); $header = self::h($flow['header'] ?? ''); $body = self::h($flow['body'] ?? ''); $secTit = self::h($flow['section_title'] ?? ''); // Build menu select with current selection $menuSelHtml = ''; foreach (array_keys($menus) as $mk) { $sel = $fMenu === $mk ? ' selected' : ''; $menuSelHtml .= ''; } // Build endpoint select with current selection $epSelHtml = ''; foreach ($companyEndpoints as $ep) { $sel = $epKey === self::h($ep['endpoint_key']) ? ' selected' : ''; $epSelHtml .= ''; } // For dynamic_list source endpoint $dlEpSelHtml = ''; foreach ($companyEndpoints as $ep) { $sel = $srcEp === self::h($ep['endpoint_key']) ? ' selected' : ''; $dlEpSelHtml .= ''; } // For submit_form endpoint $sfEpSelHtml = ''; foreach ($companyEndpoints as $ep) { $sel = $epKey === self::h($ep['endpoint_key']) ? ' selected' : ''; $sfEpSelHtml .= ''; } // Next-node select $nnSelHtml = ''; foreach ($flowKeys as $ofk) { if ($ofk === $fk) continue; $sel = $nNode === $ofk ? ' selected' : ''; $nnSelHtml .= ''; } $showText = $ft === 'text' ? '' : 'display:none'; $showMenu = $ft === 'menu' ? '' : 'display:none'; $showFn = $ft === 'function' ? '' : 'display:none'; $showEp = ($ft === 'function' && $fFn === 'api_report') ? 'display:flex;flex-direction:column;gap:8px;margin-top:8px' : 'display:none'; $showCollect = $ft === 'collect_input' ? 'display:flex;flex-direction:column;gap:8px' : 'display:none'; $showDynList = $ft === 'dynamic_list' ? 'display:flex;flex-direction:column;gap:8px' : 'display:none'; $showSubmit = $ft === 'submit_form' ? 'display:flex;flex-direction:column;gap:8px' : 'display:none'; $showForEach = $ft === 'collect_for_each' ? 'display:flex;flex-direction:column;gap:8px' : 'display:none'; $showCap = $ft === 'collect_and_post' ? 'display:flex;flex-direction:column;gap:8px' : 'display:none'; // collect_and_post fields $capFields = $flow['fields'] ?? []; $capHeader = self::h($flow['header'] ?? ''); $capSucc = self::h($flow['success_text'] ?? ''); $capConfirm = !empty($flow['confirm']); $capEpKey = self::h($flow['endpoint_key'] ?? ''); $capEpSelHtml = ''; foreach ($companyEndpoints as $ep) { $sel = $capEpKey === self::h($ep['endpoint_key']) ? ' selected' : ''; $capEpSelHtml .= ''; } $capFieldsHtml = ''; foreach ($capFields as $cf) { $ck = self::h($cf['key'] ?? ''); $cl = self::h($cf['label'] ?? ''); $cp = self::h($cf['prompt'] ?? ''); $capFieldsHtml .= "
"; } $feEpSelHtml = ''; foreach ($companyEndpoints as $ep) { $sel = $srcEp === self::h($ep['endpoint_key']) ? ' selected' : ''; $feEpSelHtml .= ''; } $feUpEpSelHtml = ''; $feUpEpKey = self::h($flow['endpoint_key'] ?? ''); foreach ($companyEndpoints as $ep) { $sel = $feUpEpKey === self::h($ep['endpoint_key']) ? ' selected' : ''; $feUpEpSelHtml .= ''; } $feQuestion = self::h($flow['question'] ?? ''); $feSuccessText = self::h($flow['success_text'] ?? ''); $fnOpts = ''; foreach (['goodbye' => '↩️ Salir (goodbye)', 'api_report' => '📄 Descargar informe', 'forward_to_ai' => '🤖 Escalar a IA', 'forward_to_agent' => '👤 Escalar a agente'] as $fv => $fl) { $fnOpts .= ''; } $dmOpts = ''; foreach (['' => 'Sin fecha', 'today' => 'Hoy', 'last_30' => 'Últimos 30 días'] as $dv => $dl) { $dmOpts .= ''; } echo "
ID: 🧠 NLU:
{$capFieldsHtml}
"; $flowIdx++; } echo <<
👥 Configuración por Categoría

Define qué menú de bienvenida ve cada categoría de usuario cuando escribe por primera vez o sin contexto activo.

{$categoryTabHtml}
🤖 Inteligencia Artificial
Si no se selecciona, usa el proveedor de Configuración General
Instrucciones que define el comportamiento de la IA
Cuando el usuario envía imagen, audio o documento, la IA analiza el contenido según los permisos del usuario. Si está desactivado, el bot muestra el menú de categoría directamente.
Cuando el usuario escribe algo que el bot no reconoce, la IA lo ubica en el menú o flujo correcto según sus permisos.
Audio → transcripción automática (Whisper/Gemini) → enrutado.
Imagen → la IA extrae el contexto → enrutado.
Requiere bot_type = hybrid y proveedor de IA configurado.
⚙️ General
Menú que se muestra a TODOS los usuarios nuevos antes del menú principal
Primer mensaje de texto que recibe un usuario nuevo (alternativo al welcome_menu)
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); $endpointsJson = json_encode(array_column($companyEndpoints, null, 'endpoint_key'), 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 item) ── $linkedFlows = []; foreach ($menus as $mk => $menu) { if (($menu['type'] ?? 'button') === 'button') { foreach ($menu['buttons'] ?? [] as $btn) { if (isset($flows[$btn['id']])) $linkedFlows[$btn['id']] = true; } } else { 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 { $isButton = ($menu['type'] ?? 'list') === 'button'; $typeLabel = $isButton ? 'Botones' : 'Lista'; $typeBadge = $isButton ? 'badge-button' : 'badge-list'; // Unifica items: botones directos o filas de secciones $items = []; if ($isButton) { foreach ($menu['buttons'] ?? [] as $btn) { $items[] = ['id' => $btn['id'], 'title' => $btn['title'], 'description' => '']; } } else { foreach ($menu['sections'] ?? [] as $section) { foreach ($section['rows'] ?? [] as $row) { $items[] = ['id' => $row['id'], 'title' => $row['title'], 'description' => $row['description'] ?? '']; } } } echo '
'; echo ''; // node-card // Mostrar primer submenú en árbol foreach ($items as $item) { $flow = $flows[$item['id']] ?? null; if ($flow && $flow['type'] === 'menu' && isset($flow['menu'])) { echo '
'; echo '
'; echo '
🔲 ' . self::h($item['title']) . ' →
'; self::renderFlowNode($item['id'], $flow, $allMenus, $companyId, true); echo '
'; break; } } 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']) . '
'; // Mostrar items del sub-menú (buttons o sections[].rows) $subIsBtn = ($subMenu['type'] ?? 'list') === 'button'; $subItems = $subIsBtn ? ($subMenu['buttons'] ?? []) : array_merge(...array_map(fn($s) => $s['rows'] ?? [], $subMenu['sections'] ?? [])); if (!empty($subItems)) { echo '
'; foreach ($subItems as $item) { echo '
'; echo '' . ($subIsBtn ? '🔲' : '🔘') . ''; echo '' . self::h($item['title']) . ''; echo '' . self::h($item['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; } public static function aiLogs(): void { SessionAuth::require(); $page = max(1, (int)($_GET['page'] ?? 1)); $limit = 50; $offset = ($page - 1) * $limit; $provider = $_GET['provider'] ?? ''; $company = (int)($_GET['company'] ?? 0); $where = []; $params = []; if ($provider !== '') { $where[] = 'provider = ?'; $params[] = $provider; } if ($company > 0) { $where[] = 'company_id = ?'; $params[] = $company; } $wSql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; $total = (int)db()->prepare("SELECT COUNT(*) FROM ai_logs $wSql")->execute($params) ? db()->prepare("SELECT COUNT(*) FROM ai_logs $wSql")->execute($params) && 0 : 0; $stmt = db()->prepare("SELECT COUNT(*) FROM ai_logs $wSql"); $stmt->execute($params); $total = (int)$stmt->fetchColumn(); $stmt2 = db()->prepare("SELECT * FROM ai_logs $wSql ORDER BY id DESC LIMIT $limit OFFSET $offset"); $stmt2->execute($params); $rows = $stmt2->fetchAll(\PDO::FETCH_ASSOC); $companies = db()->query("SELECT id, name FROM companies ORDER BY name")->fetchAll(\PDO::FETCH_ASSOC); $companyMap = []; foreach ($companies as $c) $companyMap[(int)$c['id']] = self::h($c['name']); $pages = (int)ceil($total / $limit); $companyOptions = ''; foreach ($companies as $c) { $sel = ($company === (int)$c['id']) ? ' selected' : ''; $companyOptions .= ''; } $providerOptions = ''; foreach (['', 'openai', 'gemini', 'claude'] as $p) { $label = $p === '' ? 'Todos' : ucfirst($p); $sel = $provider === $p ? ' selected' : ''; $providerOptions .= ""; } $rows_html = ''; foreach ($rows as $r) { $cid = (int)($r['company_id'] ?? 0); $cname = $companyMap[$cid] ?? ''; $badge = match($r['provider'] ?? '') { 'openai' => 'background:#10a37f;color:#fff', 'gemini' => 'background:#4285f4;color:#fff', 'claude' => 'background:#d97706;color:#fff', default => 'background:#6b7280;color:#fff', }; $tok = ($r['tokens_in'] ?? null) !== null ? self::h($r['tokens_in']) . '+' . self::h($r['tokens_out']) : '—'; $rows_html .= '' . '' . self::h(substr($r['created_at'] ?? '', 0, 16)) . '' . '' . $cname . '' . '' . self::h($r['phone_number'] ?? '') . '' . '' . self::h($r['provider']) . '' . '' . self::h($r['model'] ?? '') . '' . '' . self::h($r['call_type'] ?? '') . '' . '' . self::h(mb_substr($r['input_preview'] ?? '', 0, 60)) . '' . '' . self::h(mb_substr($r['output_preview'] ?? '', 0, 60)) . '' . '' . $tok . '' . '' . number_format((int)($r['duration_ms'] ?? 0)) . 'ms' . ''; } $pager = ''; for ($i = 1; $i <= $pages; $i++) { $active = $i === $page ? ' style="font-weight:bold"' : ''; $url = '/admin/ai-logs?page=' . $i . ($provider ? '&provider=' . urlencode($provider) : '') . ($company ? '&company=' . $company : ''); $pager .= "{$i} "; } echo Layout::page('IA Logs', 'ai-logs', '

🤖 IA Logs (' . number_format($total) . ' registros)

' . ($rows_html ?: '') . '
FechaEmpresaTeléfonoProveedorModelo TipoEntradaSalidaTokensDuración
Sin registros
' . ($pages > 1 ? '
' . $pager . '
' : '') . '
'); } }