Files
bot_palmas/public/index.php
T
Lizandro GuarnizoandClaude Sonnet 4.6 bb4b5dab10 Fix AI tab save: dedicated AJAX endpoint /save-ai
Instead of relying on the native HTML form (which couldn't reliably
isolate AI fields), the IA tab now uses an AJAX call identical to
save-per-type. The new /admin/bot-config/save-ai endpoint only
reads and merges AI fields into config_json, preserving menus/flows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 08:28:41 -05:00

786 lines
42 KiB
PHP

<?php
declare(strict_types=1);
require_once __DIR__ . '/../config/env.php';
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../services/Settings.php';
require_once __DIR__ . '/../admin/Layout.php';
// Merge DB settings over .env defaults (so env() always returns the latest)
(function () {
try {
$rows = db()->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/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'] ?? [];
$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];
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;
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()],
['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]);