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/AiLogger.php'; require_once __DIR__ . '/../services/AiBot.php'; require_once __DIR__ . '/../services/MediaTranscriber.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; $isNew = empty($data['id']) || (int)$data['id'] === 0; try { CompanyRepository::save($data); header('Location: /admin/companies?msg=' . ($isNew ? 'created' : 'updated')); } catch (\RuntimeException $e) { $errMsg = urlencode($e->getMessage()); $redirect = $isNew ? '/admin/company/edit?error=' . $errMsg : '/admin/company/edit?id=' . (int)$data['id'] . '&error=' . $errMsg; header('Location: ' . $redirect); } 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()], ['POST', '/admin/company/endpoint/delete', fn() => DashboardController::companyEndpointDelete()], ['POST', '/admin/company/endpoints/clone', fn() => DashboardController::companyEndpointsClone()], // ─── 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; } // Load existing DB config so keys not in the form (per_type, welcome_menu, etc.) are preserved $existingCompany = CompanyRepository::findById($companyId); $existingConfig = json_decode($existingCompany['config_json'] ?? '{}', true) ?: []; $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'] ?? []; $flowNluDescs = $_POST['flow_nlu_desc'] ?? []; $flowTypes = $_POST['flow_type'] ?? []; $flowMessages = $_POST['flow_message'] ?? []; $flowFunctions = $_POST['flow_function'] ?? []; $flowMenus = $_POST['flow_menu'] ?? []; $flowEpKeys = $_POST['flow_ep_key'] ?? []; $flowDateModes = $_POST['flow_date_mode'] ?? []; $flowFilenames = $_POST['flow_filename'] ?? []; $flowCaptions = $_POST['flow_caption'] ?? []; $flowPrompts = $_POST['flow_prompt'] ?? []; $flowMetaGrps = $_POST['flow_meta_group'] ?? []; $flowMetaKeys = $_POST['flow_meta_key'] ?? []; $flowNextNodes = $_POST['flow_next_node'] ?? []; $flowDlEps = $_POST['flow_dl_ep'] ?? []; $flowValFields = $_POST['flow_val_field'] ?? []; $flowLblFields = $_POST['flow_lbl_field'] ?? []; $flowHeaders = $_POST['flow_header'] ?? []; $flowBodies = $_POST['flow_body'] ?? []; $flowSecTitles = $_POST['flow_sec_title'] ?? []; $flowDlGroups = $_POST['flow_dl_meta_group']?? []; $flowDlKeys = $_POST['flow_dl_meta_key'] ?? []; $flowDlNexts = $_POST['flow_dl_next'] ?? []; $flowSfEps = $_POST['flow_sf_ep'] ?? []; $flowSfGroups = $_POST['flow_sf_meta_group']?? []; $flowSfFnames = $_POST['flow_sf_filename'] ?? []; $flowSfCaps = $_POST['flow_sf_caption'] ?? []; $flowFeSrcEps = $_POST['flow_fe_src_ep'] ?? []; $flowFeUpEps = $_POST['flow_fe_up_ep'] ?? []; $flowFeValFlds = $_POST['flow_fe_val_field'] ?? []; $flowFeLblFlds = $_POST['flow_fe_lbl_field'] ?? []; $flowFeHeaders = $_POST['flow_fe_header'] ?? []; $flowFeQs = $_POST['flow_fe_question'] ?? []; $flowFeSucTxts = $_POST['flow_fe_success'] ?? []; $flowCapEps = $_POST['flow_cap_ep'] ?? []; $flowCapHdrs = $_POST['flow_cap_header'] ?? []; $flowCapSuccs = $_POST['flow_cap_success'] ?? []; $flowCapCfmIdx = $_POST['flow_cap_confirm_idx'] ?? []; $capFieldKeys = $_POST['cap_field_key'] ?? []; $capFieldLbls = $_POST['cap_field_label'] ?? []; $capFieldPrmts = $_POST['cap_field_prompt'] ?? []; $flows = []; foreach ($flowKeys as $i => $fk) { $fk = trim($fk); $ftype = $flowTypes[$i] ?? 'text'; if ($fk === '') continue; $f = ['type' => $ftype]; $nluDesc = trim($flowNluDescs[$i] ?? ''); if ($nluDesc !== '') $f['nlu_description'] = $nluDesc; if ($ftype === 'text') { $f['message'] = trim($flowMessages[$i] ?? ''); } elseif ($ftype === 'menu') { $f['menu'] = trim($flowMenus[$i] ?? ''); } elseif ($ftype === 'function') { $fn = trim($flowFunctions[$i] ?? 'forward_to_ai'); $f['function'] = $fn; if ($fn === 'api_report') { $f['params'] = [ 'endpoint_key' => trim($flowEpKeys[$i] ?? ''), 'date_mode' => trim($flowDateModes[$i] ?? ''), 'caption' => trim($flowCaptions[$i] ?? ''), 'filename' => trim($flowFilenames[$i] ?? ''), ]; } } elseif ($ftype === 'collect_input') { $nn = trim($flowNextNodes[$i] ?? ''); $f['prompt'] = trim($flowPrompts[$i] ?? ''); $f['meta_group'] = trim($flowMetaGrps[$i] ?? ''); $f['meta_key'] = trim($flowMetaKeys[$i] ?? ''); $f['next_node'] = $nn !== '' ? $nn : null; } elseif ($ftype === 'dynamic_list') { $nn = trim($flowDlNexts[$i] ?? ''); $f['source_endpoint_key'] = trim($flowDlEps[$i] ?? ''); $f['value_field'] = trim($flowValFields[$i] ?? 'id'); $f['label_field'] = trim($flowLblFields[$i] ?? 'nombre'); $f['header'] = trim($flowHeaders[$i] ?? ''); $f['body'] = trim($flowBodies[$i] ?? ''); $f['section_title'] = trim($flowSecTitles[$i] ?? ''); $f['meta_group'] = trim($flowDlGroups[$i] ?? ''); $f['meta_key'] = trim($flowDlKeys[$i] ?? ''); $f['next_node'] = $nn !== '' ? $nn : null; } elseif ($ftype === 'submit_form') { $f['endpoint_key'] = trim($flowSfEps[$i] ?? ''); $f['meta_group'] = trim($flowSfGroups[$i] ?? ''); $f['filename'] = trim($flowSfFnames[$i] ?? ''); $f['caption'] = trim($flowSfCaps[$i] ?? ''); } elseif ($ftype === 'collect_for_each') { $f['source_endpoint_key'] = trim($flowFeSrcEps[$i] ?? ''); $f['endpoint_key'] = trim($flowFeUpEps[$i] ?? ''); $f['value_field'] = trim($flowFeValFlds[$i] ?? 'id'); $f['label_field'] = trim($flowFeLblFlds[$i] ?? 'nombre'); $f['header'] = trim($flowFeHeaders[$i] ?? ''); $f['question'] = trim($flowFeQs[$i] ?? ''); $f['success_text'] = trim($flowFeSucTxts[$i] ?? ''); } elseif ($ftype === 'collect_and_post') { $confirmIdx = $flowCapCfmIdx[$i] ?? (string)$i; $confirmKey = 'flow_cap_confirm_' . $confirmIdx; $fields = []; // cap_field_* arrays are flat across ALL cap flows — we read only // rows belonging to this card by matching the container's position. // Since PHP flattens them, we track by finding all entries in sequence. // Simple approach: take all submitted field rows (they are per-card // because the form only has one submit, and cap-field rows are inside // the card's container — browser sends them in DOM order). foreach ($capFieldKeys as $fi => $fkey) { $fkey = trim($fkey); if ($fkey === '') continue; $fields[] = [ 'key' => $fkey, 'label' => trim($capFieldLbls[$fi] ?? $fkey), 'prompt' => trim($capFieldPrmts[$fi] ?? ''), ]; } $f['endpoint_key'] = trim($flowCapEps[$i] ?? ''); $f['header'] = trim($flowCapHdrs[$i] ?? ''); $f['success_text'] = trim($flowCapSuccs[$i] ?? ''); $f['confirm'] = !empty($_POST[$confirmKey]); $f['fields'] = $fields; } $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; else unset($config['ai_provider']); $config['ai_for_media'] = isset($_POST['ai_for_media']); $config['nlu'] = isset($_POST['nlu']); $openaiKey = trim($_POST['openai_api_key'] ?? ''); if ($openaiKey !== '') $config['openai_api_key'] = $openaiKey; $geminiKey = trim($_POST['gemini_api_key'] ?? ''); if ($geminiKey !== '') $config['gemini_api_key'] = $geminiKey; $geminiModel = trim($_POST['gemini_model'] ?? ''); if ($geminiModel !== '') $config['gemini_model'] = $geminiModel; $claudeKey = trim($_POST['claude_api_key'] ?? ''); if ($claudeKey !== '') $config['claude_api_key'] = $claudeKey; $claudeModel = trim($_POST['claude_model'] ?? ''); if ($claudeModel !== '') $config['claude_model'] = $claudeModel; // per_type is managed exclusively via /admin/bot-config/save-per-type — copy as-is from DB if (isset($existingConfig['per_type'])) { $config['per_type'] = $existingConfig['per_type']; } // 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; $welcomeMenu = trim($_POST['welcome_menu'] ?? ''); if ($welcomeMenu !== '') $config['welcome_menu'] = $welcomeMenu; // Preserve any DB keys the form doesn't explicitly manage (welcome_menu, per_type, etc.) $formManagedKeys = ['commands', 'menus', 'flows', 'ai_prompt', 'ai_model', 'ai_temperature', 'ai_provider', 'openai_api_key', 'gemini_api_key', 'gemini_model', 'claude_api_key', 'claude_model', 'ai_for_media', 'nlu', 'greeting', 'fallback', 'approval_webhook', 'ignore_prefixes', 'welcome_menu', 'per_type']; foreach ($existingConfig as $k => $v) { if (!in_array($k, $formManagedKeys, true) && !isset($config[$k])) { $config[$k] = $v; } // Also preserve managed keys that POST didn't provide (e.g. truncated by max_input_vars) if (in_array($k, ['welcome_menu', 'per_type'], true) && !isset($config[$k])) { $config[$k] = $v; } } CompanyRepository::save(['id' => $companyId, 'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE)]); header('Location: /admin/bot-config?id=' . $companyId . '&msg=saved'); exit; })()], // ─── Admin: guardar solo campos IA (endpoint separado) ─────────────────── ['POST', '/admin/bot-config/save-ai', fn() => (function () { 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' => 'missing company_id']); exit; } $company = CompanyRepository::findById($companyId); if (!$company) { echo json_encode(['ok' => false, 'error' => 'not found']); exit; } $config = json_decode($company['config_json'] ?? '{}', true) ?: []; $aiProvider = trim($_POST['ai_provider'] ?? ''); if ($aiProvider !== '') $config['ai_provider'] = $aiProvider; else unset($config['ai_provider']); $aiModel = trim($_POST['ai_model'] ?? ''); if ($aiModel !== '') $config['ai_model'] = $aiModel; $aiTemp = trim($_POST['ai_temperature'] ?? ''); if ($aiTemp !== '') $config['ai_temperature'] = (float)$aiTemp; $aiPrompt = trim($_POST['ai_prompt'] ?? ''); $config['ai_prompt'] = $aiPrompt; $config['ai_for_media'] = isset($_POST['ai_for_media']); $config['nlu'] = isset($_POST['nlu']); $openaiKey = trim($_POST['openai_api_key'] ?? ''); if ($openaiKey !== '') $config['openai_api_key'] = $openaiKey; $geminiKey = trim($_POST['gemini_api_key'] ?? ''); if ($geminiKey !== '') $config['gemini_api_key'] = $geminiKey; $geminiModel = trim($_POST['gemini_model'] ?? ''); if ($geminiModel !== '') $config['gemini_model'] = $geminiModel; $claudeKey = trim($_POST['claude_api_key'] ?? ''); if ($claudeKey !== '') $config['claude_api_key'] = $claudeKey; $claudeModel = trim($_POST['claude_model'] ?? ''); if ($claudeModel !== '') $config['claude_model'] = $claudeModel; $whisperUrl = trim($_POST['whisper_url'] ?? ''); $config['whisper_url'] = $whisperUrl; $whisperUser = trim($_POST['whisper_user'] ?? ''); $config['whisper_user'] = $whisperUser; $whisperPass = trim($_POST['whisper_pass'] ?? ''); if ($whisperPass !== '') $config['whisper_pass'] = $whisperPass; CompanyRepository::save(['id' => $companyId, 'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE)]); echo json_encode(['ok' => true]); exit; })()], // ─── Admin: guardar per_type por categoría (endpoint separado) ─────────── ['POST', '/admin/bot-config/save-per-type', fn() => (function () { SessionAuth::require(); header('Content-Type: application/json; charset=utf-8'); $companyId = (int)($_POST['company_id'] ?? 0); if ($companyId === 0) { echo json_encode(['ok' => false]); exit; } $company = CompanyRepository::findById($companyId); if (!$company) { echo json_encode(['ok' => false]); exit; } $config = json_decode($company['config_json'] ?? '{}', true) ?: []; $rawPt = $config['per_type'] ?? []; $perType = (is_array($rawPt) && count(array_filter(array_keys($rawPt), 'is_string')) > 0) ? $rawPt : []; $perTypeMenus = $_POST['per_type_menu'] ?? []; foreach ([1, 2, 3] as $cat) { $mk = trim($perTypeMenus[$cat] ?? ''); $key = (string)$cat; if ($mk !== '') { $perType[$key] = array_merge($perType[$key] ?? [], ['greeting_menu' => $mk]); } else { unset($perType[$key]['greeting_menu']); if (empty($perType[$key])) unset($perType[$key]); } } $config['per_type'] = empty($perType) ? new stdClass() : $perType; CompanyRepository::save(['id' => $companyId, 'config_json' => json_encode($config, JSON_UNESCAPED_UNICODE)]); echo json_encode(['ok' => true]); exit; })()], // ─── Admin: configuración general ────────────────────────────────────── ['GET', '/admin/settings', fn() => DashboardController::settings()], ['GET', '/admin/ai-logs', fn() => DashboardController::aiLogs()], ['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', 'claude_api_key', 'claude_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(); // Accept provider/api_key/model from POST (live test), or fall back to saved settings $provider = trim($_POST['provider'] ?? '') ?: Settings::get('ai_provider', 'mock'); $apiKey = trim($_POST['api_key'] ?? ''); $model = trim($_POST['model'] ?? ''); if ($provider === 'gemini') { if ($apiKey === '') $apiKey = Settings::get('gemini_api_key', ''); if ($model === '') $model = Settings::get('gemini_model', 'gemini-2.5-flash'); if ($apiKey === '') { jsonResponse(200, ['ok' => false, 'provider' => 'gemini', 'error' => 'API Key vacía']); return; } $url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}"; $body = json_encode(['contents' => [['role' => 'user', 'parts' => [['text' => 'Responde solo: OK']]]]]); $ch = curl_init($url); curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, 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) { jsonResponse(200, ['ok' => false, 'provider' => 'gemini', 'error' => $err ?: (json_decode($resp, true)['error']['message'] ?? "HTTP {$code}")]); return; } jsonResponse(200, ['ok' => true, 'provider' => 'gemini', 'response' => trim(json_decode($resp, true)['candidates'][0]['content']['parts'][0]['text'] ?? '(sin respuesta)')]); } elseif ($provider === 'openai') { if ($apiKey === '') $apiKey = Settings::get('openai_api_key', ''); if ($model === '') $model = Settings::get('openai_model', 'gpt-4o-mini'); if ($apiKey === '') { jsonResponse(200, ['ok' => false, 'provider' => 'openai', 'error' => 'API Key vacía']); return; } $body = 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 => $body, 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) { jsonResponse(200, ['ok' => false, 'provider' => 'openai', 'error' => $err ?: (json_decode($resp, true)['error']['message'] ?? "HTTP {$code}")]); return; } jsonResponse(200, ['ok' => true, 'provider' => 'openai', 'response' => trim(json_decode($resp, true)['choices'][0]['message']['content'] ?? '(sin respuesta)')]); } elseif ($provider === 'claude') { if ($apiKey === '') $apiKey = Settings::get('claude_api_key', ''); if ($model === '') $model = Settings::get('claude_model', 'claude-haiku-4-5'); if ($apiKey === '') { jsonResponse(200, ['ok' => false, 'provider' => 'claude', 'error' => 'API Key vacía']); return; } $body = json_encode(['model' => $model, 'max_tokens' => 10, 'messages' => [['role' => 'user', 'content' => 'Responde solo: OK']]]); $ch = curl_init('https://api.anthropic.com/v1/messages'); curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'x-api-key: ' . $apiKey, 'anthropic-version: 2023-06-01'], 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) { jsonResponse(200, ['ok' => false, 'provider' => 'claude', 'error' => $err ?: (json_decode($resp, true)['error']['message'] ?? "HTTP {$code}")]); return; } jsonResponse(200, ['ok' => true, 'provider' => 'claude', 'response' => trim(json_decode($resp, true)['content'][0]['text'] ?? '(sin respuesta)')]); } else { jsonResponse(200, ['ok' => true, 'provider' => 'mock', 'response' => 'Mock activo.']); } })()], // ─── 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]);