query("SELECT `key`, `value` FROM settings")->fetchAll(); foreach ($rows as $r) { if ($r['value'] !== null && $r['value'] !== '') { $_ENV[$r['key']] = $r['value']; putenv("{$r['key']}={$r['value']}"); // Also set uppercase version for env() lookups $upper = strtoupper($r['key']); if ($upper !== $r['key']) { $_ENV[$upper] = $r['value']; putenv("{$upper}={$r['value']}"); } } } } catch (\Throwable $e) { // DB not ready yet — fallback to .env only } })(); require_once __DIR__ . '/../admin/v1/WpWebhook.php'; require_once __DIR__ . '/../middleware/SessionAuth.php'; require_once __DIR__ . '/../admin/LoginController.php'; require_once __DIR__ . '/../admin/DashboardController.php'; require_once __DIR__ . '/../services/CompanyRepository.php'; require_once __DIR__ . '/../services/CompanyApiClient.php'; require_once __DIR__ . '/../services/WhatsAppSender.php'; require_once __DIR__ . '/../services/OutboundWorker.php'; require_once __DIR__ . '/../services/ErpSync.php'; require_once __DIR__ . '/../services/ConversationContext.php'; require_once __DIR__ . '/../services/NormalBot.php'; require_once __DIR__ . '/../services/AiBot.php'; require_once __DIR__ . '/../services/BotRouter.php'; require_once __DIR__ . '/../services/PendingApproval.php'; require_once __DIR__ . '/../services/ErpMonitor.php'; // ─── Helper de respuesta JSON ──────────────────────────────────────────────── function jsonResponse(int $status, array $body): void { http_response_code($status); header('Content-Type: application/json; charset=utf-8'); echo json_encode($body, JSON_UNESCAPED_UNICODE); exit; } function serveHtml(string $filename): void { $file = __DIR__ . '/' . $filename; if (!file_exists($file)) { jsonResponse(404, ['error' => 'Página no encontrada']); } http_response_code(200); header('Content-Type: text/html; charset=utf-8'); readfile($file); exit; } // ─── Router ────────────────────────────────────────────────────────────────── $method = $_SERVER['REQUEST_METHOD']; $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); // Normalizar: quitar trailing slash $path = rtrim($path, '/') ?: '/'; // ─── Tabla de rutas ────────────────────────────────────────────────────────── $routes = [ ['GET', '/', fn() => (header('Location: /login') ?: exit())], ['GET', '/health', fn() => jsonResponse(200, ['status' => 'ok', 'service' => 'bot-palmas360'])], // ─── WhatsApp webhook ──────────────────────────────────────────────────── // Seguridad: HMAC-SHA256 (X-Hub-Signature-256) verificado dentro del controlador ['GET', '/admin/v1/wp-webhook', fn() => WpWebhook::verify()], ['POST', '/admin/v1/wp-webhook', fn() => WpWebhook::receive()], // ─── Login / Logout ────────────────────────────────────────────────────── ['GET', '/login', fn() => LoginController::showForm()], ['POST', '/login', fn() => LoginController::authenticate()], ['GET', '/logout', fn() => LoginController::logout()], // ─── Dashboard protegido ───────────────────────────────────────────────── ['GET', '/admin/dashboard', fn() => DashboardController::index()], ['GET', '/admin/live', fn() => DashboardController::live()], ['GET', '/admin/webhook/stream/sse', fn() => DashboardController::streamSse()], ['GET', '/admin/webhook/stream', fn() => DashboardController::stream()], ['GET', '/admin/webhook/raw', fn() => DashboardController::getRaw()], ['GET', '/admin/chat', fn() => (function () { $api = $_GET['api'] ?? ''; if ($api === 'convs') { DashboardController::chatConversations(); return; } if ($api === 'msgs') { DashboardController::chatMessages(); return; } DashboardController::chat(); })()], ['POST', '/admin/chat', fn() => DashboardController::chatSend()], // ─── Páginas legales (requeridas por Meta/WhatsApp Business) ──────────── ['GET', '/politicas', fn() => serveHtml('politicas.html')], ['GET', '/eliminacion-datos-usuario', fn() => serveHtml('eliminacion-datos-usuario.html')], ['GET', '/condiciones-servicio', fn() => serveHtml('condiciones-servicio.html')], // ─── API de envío (outbound) — autenticada por API Key de la empresa ──── ['POST', '/api/send', fn() => (function () { $apiKey = $_SERVER['HTTP_X_API_KEY'] ?? ''; if ($apiKey === '') { jsonResponse(401, ['error' => 'X-API-Key header requerido']); } $company = CompanyRepository::findByApiKey($apiKey); if ($company === null) { jsonResponse(403, ['error' => 'API Key inválida']); } $input = json_decode(file_get_contents('php://input'), true); if (!is_array($input)) { jsonResponse(400, ['error' => 'JSON inválido']); } $to = trim($input['to'] ?? ''); $type = $input['type'] ?? 'text'; if ($to === '') { jsonResponse(400, ['error' => 'Campo "to" requerido']); } $payload = match ($type) { 'text' => ['text' => $input['text'] ?? ''], 'image' => ['media_id' => $input['media_id'] ?? '', 'url' => $input['url'] ?? '', 'caption' => $input['caption'] ?? ''], 'template' => ['template_name' => $input['template_name'] ?? '', 'components' => $input['components'] ?? []], 'interactive' => ['interactive' => $input['interactive'] ?? []], default => jsonResponse(400, ['error' => "Tipo no soportado: {$type}"]), }; $db = db(); $stmt = $db->prepare("INSERT INTO outbound_queue (company_id, to_number, message_type, payload) VALUES (?, ?, ?, ?)"); $stmt->execute([$company['id'], $to, $type, json_encode($payload)]); $queueId = (int)$db->lastInsertId(); jsonResponse(200, ['status' => 'queued', 'id' => $queueId]); })()], // ─── ERP: listar mensajes pendientes de aprobación ────────────────────── ['GET', '/api/pending', fn() => (function () { $apiKey = $_SERVER['HTTP_X_API_KEY'] ?? ''; if ($apiKey === '') { jsonResponse(401, ['error' => 'X-API-Key header requerido']); } $company = CompanyRepository::findByApiKey($apiKey); if ($company === null) { jsonResponse(403, ['error' => 'API Key inválida']); } $items = PendingApproval::findByCompany((int)$company['id'], 'pending'); jsonResponse(200, ['pending' => $items, 'total' => count($items)]); })()], // ─── ERP: aprobar o rechazar un mensaje pendiente ─────────────────────── ['POST', '/api/approval', fn() => (function () { $apiKey = $_SERVER['HTTP_X_API_KEY'] ?? ''; if ($apiKey === '') { jsonResponse(401, ['error' => 'X-API-Key header requerido']); } $company = CompanyRepository::findByApiKey($apiKey); if ($company === null) { jsonResponse(403, ['error' => 'API Key inválida']); } $input = json_decode(file_get_contents('php://input'), true); if (!is_array($input)) { jsonResponse(400, ['error' => 'JSON inválido']); } $pendingId = (int)($input['pending_id'] ?? 0); $action = $input['action'] ?? ''; // approve | reject $note = $input['note'] ?? null; if ($pendingId === 0) { jsonResponse(400, ['error' => 'pending_id requerido']); } if ($action === 'approve') { $item = PendingApproval::approve($pendingId, 'ERP: ' . ($company['name'] ?? ''), $note); if ($item === null) { jsonResponse(404, ['error' => 'Item no encontrado o ya procesado']); } jsonResponse(200, ['status' => 'approved', 'pending_id' => $pendingId, 'message' => 'Respuesta aprobada y encolada para envío']); } elseif ($action === 'reject') { $item = PendingApproval::reject($pendingId, 'ERP: ' . ($company['name'] ?? ''), $note); if ($item === null) { jsonResponse(404, ['error' => 'Item no encontrado o ya procesado']); } jsonResponse(200, ['status' => 'rejected', 'pending_id' => $pendingId, 'message' => 'Mensaje rechazado']); } else { jsonResponse(400, ['error' => "Acción no soportada: {$action}. Usa 'approve' o 'reject'"]); } })()], // ─── ERP: enviar mensaje directo (outbound legacy) ────────────────────── ['POST', '/api/send-direct', fn() => (function () { $apiKey = $_SERVER['HTTP_X_API_KEY'] ?? ''; if ($apiKey === '') { jsonResponse(401, ['error' => 'X-API-Key header requerido']); } $company = CompanyRepository::findByApiKey($apiKey); if ($company === null) { jsonResponse(403, ['error' => 'API Key inválida']); } $input = json_decode(file_get_contents('php://input'), true); if (!is_array($input)) { jsonResponse(400, ['error' => 'JSON inválido']); } $to = trim($input['to'] ?? ''); $type = $input['type'] ?? 'text'; if ($to === '') { jsonResponse(400, ['error' => 'Campo "to" requerido']); } $payload = match ($type) { 'text' => json_encode(['text' => $input['text'] ?? '']), 'image' => json_encode(['media_id' => $input['media_id'] ?? '', 'url' => $input['url'] ?? '', 'caption' => $input['caption'] ?? '']), 'template' => json_encode(['template_name' => $input['template_name'] ?? '', 'components' => $input['components'] ?? []]), 'interactive' => json_encode(['interactive' => $input['interactive'] ?? []]), default => jsonResponse(400, ['error' => "Tipo no soportado: {$type}"]), }; $db = db(); $stmt = $db->prepare("INSERT INTO outbound_queue (company_id, to_number, message_type, payload) VALUES (?, ?, ?, ?)"); $stmt->execute([$company['id'], $to, $type, $payload]); jsonResponse(200, ['status' => 'queued', 'id' => (int)$db->lastInsertId()]); })()], // ─── Sincronizar empresas desde ERP (protegido) ───────────────────────── ['GET', '/admin/sync-companies', fn() => DashboardController::syncCompanies()], // ─── Listar empresas (protegido) ──────────────────────────────────────── ['GET', '/admin/companies', fn() => DashboardController::companies()], // ─── Editar empresa (formulario) ─────────────────────────────────────── ['GET', '/admin/company/edit', fn() => DashboardController::companyEdit()], // ─── Guardar empresa (crear/actualizar) ───────────────────────────────── ['POST', '/admin/company/save', fn() => (function () { SessionAuth::require(); $data = $_POST; $id = CompanyRepository::save($data); if ($id > 0) { header('Location: /admin/companies?msg=' . ($data['id'] ?? 0 > 0 ? 'updated' : 'created')); } else { header('Location: /admin/companies?msg=error'); } exit; })()], // ─── Eliminar empresa ────────────────────────────────────────────────── ['GET', '/admin/company/delete', fn() => (function () { SessionAuth::require(); $id = (int)($_GET['id'] ?? 0); if ($id > 0 && CompanyRepository::delete($id)) { header('Location: /admin/companies?msg=deleted'); } else { header('Location: /admin/companies?msg=error'); } exit; })()], // ─── Procesar cola outbound (protegido o cron) ───────────────────────── ['GET', '/admin/process-queue', fn() => DashboardController::processQueue()], ['GET', '/admin/test-message', fn() => DashboardController::testMessage()], ['POST', '/admin/test-message/send', fn() => DashboardController::testMessageSend()], // ─── Números WhatsApp por empresa ────────────────────────────────────── ['POST', '/admin/company/phone/save', fn() => DashboardController::companyPhoneSave()], ['POST', '/admin/company/phone/delete', fn() => DashboardController::companyPhoneDelete()], ['POST', '/admin/company/phones/sync', fn() => DashboardController::companyPhonesSync()], // ─── Endpoints API por empresa ───────────────────────────────────────── ['POST', '/admin/company/endpoint/save', fn() => DashboardController::companyEndpointSave()], ['POST', '/admin/company/endpoint/test', fn() => DashboardController::companyEndpointTest()], // ─── Admin: listar pendientes de aprobación ───────────────────────────── ['GET', '/admin/pending-list', fn() => DashboardController::pendingList()], ['GET', '/admin/pending', fn() => DashboardController::pending()], // ─── Admin: aprobar pendiente ─────────────────────────────────────────── ['POST', '/admin/pending-approve', fn() => (function () { SessionAuth::require(); $user = SessionAuth::user(); $input = json_decode(file_get_contents('php://input'), true); $id = (int)($input['id'] ?? 0); if ($id === 0) jsonResponse(400, ['error' => 'ID requerido']); $item = PendingApproval::approve($id, $user['name'] ?? 'Admin', $input['note'] ?? null); if ($item === null) jsonResponse(404, ['error' => 'No encontrado o ya procesado']); jsonResponse(200, ['status' => 'approved', 'message' => 'Respuesta aprobada y encolada']); })()], // ─── Admin: rechazar pendiente ────────────────────────────────────────── ['POST', '/admin/pending-reject', fn() => (function () { SessionAuth::require(); $user = SessionAuth::user(); $input = json_decode(file_get_contents('php://input'), true); $id = (int)($input['id'] ?? 0); if ($id === 0) jsonResponse(400, ['error' => 'ID requerido']); $item = PendingApproval::reject($id, $user['name'] ?? 'Admin', $input['note'] ?? null); if ($item === null) jsonResponse(404, ['error' => 'No encontrado o ya procesado']); jsonResponse(200, ['status' => 'rejected', 'message' => 'Mensaje rechazado']); })()], // ─── Admin: configurador visual del bot ──────────────────────────────── ['GET', '/admin/bot-config', fn() => DashboardController::botConfig()], ['GET', '/admin/conversation-flow', fn() => DashboardController::conversationFlow()], ['POST', '/admin/bot-config/save', fn() => (function () { SessionAuth::require(); $companyId = (int)($_POST['company_id'] ?? 0); if ($companyId === 0) { header('Location: /admin/bot-config?msg=error'); exit; } $config = []; // Commands $keywords = $_POST['cmd_keyword'] ?? []; $actions = $_POST['cmd_action'] ?? []; $commands = []; foreach ($keywords as $i => $kw) { $kw = trim($kw); $act = trim($actions[$i] ?? ''); if ($kw !== '' && $act !== '') $commands[$kw] = $act; } if (!empty($commands)) $config['commands'] = $commands; // Menus $menuKeys = $_POST['menu_key'] ?? []; $menuTypes = $_POST['menu_type'] ?? []; $menuBodies = $_POST['menu_body'] ?? []; $menuHeaders = $_POST['menu_header'] ?? []; $menuFooters = $_POST['menu_footer'] ?? []; $menuBtnTrigger = $_POST['menu_button'] ?? []; $menuSections = $_POST['menu_sections'] ?? []; $menuBtnIds = $_POST['menu_btn_id'] ?? []; $menuBtnTitles = $_POST['menu_btn_title'] ?? []; $menus = []; foreach ($menuKeys as $i => $mk) { $mk = trim($mk); $type = $menuTypes[$i] ?? 'button'; if ($mk === '') continue; if ($type === 'button') { $buttons = []; foreach ($menuBtnIds[$i] ?? [] as $j => $btnId) { $btnId = trim($btnId); $btnTitle = mb_substr(trim($menuBtnTitles[$i][$j] ?? ''), 0, 20); if ($btnId !== '' && $btnTitle !== '') { $buttons[] = ['id' => $btnId, 'title' => $btnTitle]; } } $menus[$mk] = [ 'type' => 'button', 'body' => trim($menuBodies[$i] ?? ''), 'buttons' => $buttons, ]; } else { $sections = []; foreach ($menuSections[$i] ?? [] as $si => $section) { $title = trim($section['title'] ?? ''); if ($title === '') continue; $rows = []; foreach ($section['rows'] ?? [] as $row) { $rid = trim($row['id'] ?? ''); $rtitle = trim($row['title'] ?? ''); if ($rid === '' || $rtitle === '') continue; $rows[] = ['id' => $rid, 'title' => $rtitle, 'description' => trim($row['description'] ?? '')]; } $sections[] = ['title' => $title, 'rows' => $rows]; } $menus[$mk] = [ 'type' => 'list', 'header' => trim($menuHeaders[$i] ?? ''), 'body' => trim($menuBodies[$i] ?? ''), 'footer' => trim($menuFooters[$i] ?? ''), 'button' => trim($menuBtnTrigger[$i] ?? ''), 'sections' => $sections, ]; } } if (!empty($menus)) $config['menus'] = $menus; // Flows $flowKeys = $_POST['flow_key'] ?? []; $flowTypes = $_POST['flow_type'] ?? []; $flowMessages = $_POST['flow_message'] ?? []; $flowFunctions = $_POST['flow_function'] ?? []; $flowMenus = $_POST['flow_menu'] ?? []; $flows = []; foreach ($flowKeys as $i => $fk) { $fk = trim($fk); if ($fk === '') continue; $ftype = $flowTypes[$i] ?? 'text'; $f = ['type' => $ftype]; if ($ftype === 'text') $f['message'] = trim($flowMessages[$i] ?? ''); elseif ($ftype === 'function') $f['function'] = trim($flowFunctions[$i] ?? 'forward_to_ai'); elseif ($ftype === 'menu') $f['menu'] = trim($flowMenus[$i] ?? ''); $flows[$fk] = $f; } if (!empty($flows)) $config['flows'] = $flows; // AI $aiPrompt = trim($_POST['ai_prompt'] ?? ''); if ($aiPrompt !== '') $config['ai_prompt'] = $aiPrompt; $aiModel = trim($_POST['ai_model'] ?? ''); if ($aiModel !== '') $config['ai_model'] = $aiModel; $aiTemp = trim($_POST['ai_temperature'] ?? ''); if ($aiTemp !== '') $config['ai_temperature'] = (float)$aiTemp; $aiProvider = trim($_POST['ai_provider'] ?? ''); if ($aiProvider !== '') $config['ai_provider'] = $aiProvider; // General $greeting = trim($_POST['greeting'] ?? ''); if ($greeting !== '') $config['greeting'] = $greeting; $fallback = trim($_POST['fallback'] ?? ''); if ($fallback !== '') $config['fallback'] = $fallback; $ignorePrefixes = trim($_POST['ignore_prefixes'] ?? ''); if ($ignorePrefixes !== '') { $config['ignore_prefixes'] = array_map('trim', explode("\n", $ignorePrefixes)); } $approvalWebhook = trim($_POST['approval_webhook'] ?? ''); if ($approvalWebhook !== '') $config['approval_webhook'] = $approvalWebhook; CompanyRepository::save(['id' => $companyId, 'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE)]); header('Location: /admin/bot-config?id=' . $companyId . '&msg=saved'); exit; })()], // ─── Admin: configuración general ────────────────────────────────────── ['GET', '/admin/settings', fn() => DashboardController::settings()], ['POST', '/admin/settings/save', fn() => (function () { SessionAuth::require(); $allowed = [ 'whatsapp_access_token', 'whatsapp_app_secret', 'whatsapp_verify_token', 'whatsapp_business_account_id', 'whatsapp_default_phone_number_id', 'ai_provider', 'openai_api_key', 'openai_model', 'gemini_api_key', 'gemini_model', 'ai_max_tokens', 'ai_default_prompt', 'verify_hmac_signature', ]; // Checkbox: si no está presente, desactivar if (!isset($_POST['verify_hmac_signature'])) { $_POST['verify_hmac_signature'] = '0'; } $pairs = []; foreach ($allowed as $k) { if (isset($_POST[$k])) { $pairs[$k] = trim($_POST[$k]); } } Settings::setMany($pairs); // Force re-read into $_ENV foreach ($pairs as $k => $v) { $_ENV[$k] = $v; putenv("{$k}={$v}"); $upper = strtoupper($k); if ($upper !== $k) { $_ENV[$upper] = $v; putenv("{$upper}={$v}"); } } header('Location: /admin/settings?msg=saved'); exit; })()], ['POST', '/admin/settings/test-ai', fn() => (function () { SessionAuth::require(); $provider = Settings::get('ai_provider', 'mock'); if ($provider === 'gemini') { $apiKey = Settings::get('gemini_api_key', ''); $model = Settings::get('gemini_model', 'gemini-2.0-flash'); if ($apiKey === '') { jsonResponse(200, ['ok' => false, 'provider' => 'gemini', 'error' => 'API Key no configurada. Guarda primero.']); return; } $url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}"; $payload = json_encode(['contents' => [['role' => 'user', 'parts' => [['text' => 'Responde solo: OK']]]]]); $ch = curl_init($url); curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15]); $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); if ($code !== 200) { $msg = $err ?: (json_decode($resp, true)['error']['message'] ?? "HTTP {$code}"); jsonResponse(200, ['ok' => false, 'provider' => 'gemini', 'error' => $msg]); return; } $text = json_decode($resp, true)['candidates'][0]['content']['parts'][0]['text'] ?? '(sin respuesta)'; jsonResponse(200, ['ok' => true, 'provider' => 'gemini', 'response' => trim($text)]); } elseif ($provider === 'openai') { $apiKey = Settings::get('openai_api_key', ''); $model = Settings::get('openai_model', 'gpt-4o-mini'); if ($apiKey === '') { jsonResponse(200, ['ok' => false, 'provider' => 'openai', 'error' => 'API Key no configurada. Guarda primero.']); return; } $payload = json_encode(['model' => $model, 'messages' => [['role' => 'user', 'content' => 'Responde solo: OK']], 'max_tokens' => 5]); $ch = curl_init('https://api.openai.com/v1/chat/completions'); curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $apiKey], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15]); $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); if ($code !== 200) { $msg = $err ?: (json_decode($resp, true)['error']['message'] ?? "HTTP {$code}"); jsonResponse(200, ['ok' => false, 'provider' => 'openai', 'error' => $msg]); return; } $text = json_decode($resp, true)['choices'][0]['message']['content'] ?? '(sin respuesta)'; jsonResponse(200, ['ok' => true, 'provider' => 'openai', 'response' => trim($text)]); } else { jsonResponse(200, ['ok' => true, 'provider' => 'mock', 'response' => 'Mock activo — no hay proveedor real configurado.']); } })()], // ─── Debug: captura cruda de webhooks (loguea todo sin validar) ─────── ['GET', '/admin/v1/wp-webhook-debug', fn() => (function () { $log = '[' . date('Y-m-d H:i:s') . "] GET " . ($_SERVER['QUERY_STRING'] ?? '') . "\n"; file_put_contents('/tmp/wp-debug.log', $log, FILE_APPEND); jsonResponse(200, ['logged' => true, 'query' => $_GET]); })()], ['POST', '/admin/v1/wp-webhook-debug', fn() => (function () { $raw = file_get_contents('php://input'); $headers = getallheaders(); $log = '[' . date('Y-m-d H:i:s') . "] POST\n"; $log .= "Headers: " . json_encode($headers, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n"; $log .= "Body: {$raw}\n"; $log .= str_repeat('-', 60) . "\n"; file_put_contents('/tmp/wp-debug.log', $log, FILE_APPEND); $decoded = json_decode($raw, true); jsonResponse(200, ['logged' => true, 'headers' => $headers, 'body' => $decoded ?? $raw]); })()], // ─── Admin: estado de salud de los ERP ────────────────────────────────── ['GET', '/admin/erp-health', fn() => (function () { SessionAuth::require(); $result = ErpMonitor::summary(); jsonResponse(200, $result); })()], ]; // ─── Despacho ──────────────────────────────────────────────────────────────── foreach ($routes as [$routeMethod, $routePath, $handler]) { if ($method === $routeMethod && $path === $routePath) { $handler(); exit; } } jsonResponse(404, ['error' => 'Ruta no encontrada', 'path' => $path]);