Files
bot_palmas/public/index.php
T
Lizandro GuarnizoandClaude Sonnet 4.6 bfbc70451d feat: clonar endpoints desde otra empresa en pestaña Endpoints API
Agrega panel de clonación en la pestaña Endpoints API del editor de empresa.
Permite copiar los endpoints de otra empresa con dos modos: sobreescribir todo
o solo añadir los endpoints que aún no tienen URL configurada.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 13:50:39 -05:00

719 lines
38 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/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']);
$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;
// 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',
'ai_for_media', '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 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', '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]);