- MediaTranscriber: if whisper_url set, POSTs to own server with Basic auth (audio_file field); falls back to OpenAI cloud Whisper - Admin AI tab: new section for whisper_url / whisper_user / whisper_pass - save-ai endpoint: saves the three whisper fields to config_json Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
799 lines
42 KiB
PHP
799 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/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]);
|