328 lines
16 KiB
PHP
328 lines
16 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';
|
|
|
|
// 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']}");
|
|
}
|
|
}
|
|
} 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', fn() => DashboardController::stream()],
|
|
['GET', '/admin/webhook/raw', fn() => DashboardController::getRaw()],
|
|
['GET', '/admin/chat', fn() => DashboardController::chat()],
|
|
['GET', '/admin/chat/conversations', fn() => DashboardController::chatConversations()],
|
|
['GET', '/admin/chat/messages', fn() => DashboardController::chatMessages()],
|
|
['POST', '/admin/chat/send', fn() => DashboardController::chatSend()],
|
|
|
|
// ─── Páginas legales (requeridas por Meta/WhatsApp Business) ────────────
|
|
['GET', '/politicas', fn() => serveHtml('politicas.html')],
|
|
['GET', '/eliminacion-datos-usuario', fn() => serveHtml('eliminacion-datos-usuario.html')],
|
|
['GET', '/condiciones-servicio', fn() => serveHtml('condiciones-servicio.html')],
|
|
|
|
// ─── API de envío (outbound) — autenticada por API Key de la empresa ────
|
|
['POST', '/api/send', fn() => (function () {
|
|
$apiKey = $_SERVER['HTTP_X_API_KEY'] ?? '';
|
|
if ($apiKey === '') {
|
|
jsonResponse(401, ['error' => 'X-API-Key header requerido']);
|
|
}
|
|
$company = CompanyRepository::findByApiKey($apiKey);
|
|
if ($company === null) {
|
|
jsonResponse(403, ['error' => 'API Key inválida']);
|
|
}
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
if (!is_array($input)) {
|
|
jsonResponse(400, ['error' => 'JSON inválido']);
|
|
}
|
|
$to = trim($input['to'] ?? '');
|
|
$type = $input['type'] ?? 'text';
|
|
if ($to === '') {
|
|
jsonResponse(400, ['error' => 'Campo "to" requerido']);
|
|
}
|
|
|
|
$payload = match ($type) {
|
|
'text' => ['text' => $input['text'] ?? ''],
|
|
'image' => ['media_id' => $input['media_id'] ?? '', 'url' => $input['url'] ?? '', 'caption' => $input['caption'] ?? ''],
|
|
'template' => ['template_name' => $input['template_name'] ?? '', 'components' => $input['components'] ?? []],
|
|
'interactive' => ['interactive' => $input['interactive'] ?? []],
|
|
default => jsonResponse(400, ['error' => "Tipo no soportado: {$type}"]),
|
|
};
|
|
|
|
$db = db();
|
|
$stmt = $db->prepare("INSERT INTO outbound_queue (company_id, to_number, message_type, payload) VALUES (?, ?, ?, ?)");
|
|
$stmt->execute([$company['id'], $to, $type, json_encode($payload)]);
|
|
$queueId = (int)$db->lastInsertId();
|
|
|
|
jsonResponse(200, ['status' => 'queued', 'id' => $queueId]);
|
|
})()],
|
|
|
|
// ─── ERP: listar mensajes pendientes de aprobación ──────────────────────
|
|
['GET', '/api/pending', fn() => (function () {
|
|
$apiKey = $_SERVER['HTTP_X_API_KEY'] ?? '';
|
|
if ($apiKey === '') {
|
|
jsonResponse(401, ['error' => 'X-API-Key header requerido']);
|
|
}
|
|
$company = CompanyRepository::findByApiKey($apiKey);
|
|
if ($company === null) {
|
|
jsonResponse(403, ['error' => 'API Key inválida']);
|
|
}
|
|
$items = PendingApproval::findByCompany((int)$company['id'], 'pending');
|
|
jsonResponse(200, ['pending' => $items, 'total' => count($items)]);
|
|
})()],
|
|
|
|
// ─── ERP: aprobar o rechazar un mensaje pendiente ───────────────────────
|
|
['POST', '/api/approval', fn() => (function () {
|
|
$apiKey = $_SERVER['HTTP_X_API_KEY'] ?? '';
|
|
if ($apiKey === '') {
|
|
jsonResponse(401, ['error' => 'X-API-Key header requerido']);
|
|
}
|
|
$company = CompanyRepository::findByApiKey($apiKey);
|
|
if ($company === null) {
|
|
jsonResponse(403, ['error' => 'API Key inválida']);
|
|
}
|
|
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
if (!is_array($input)) {
|
|
jsonResponse(400, ['error' => 'JSON inválido']);
|
|
}
|
|
|
|
$pendingId = (int)($input['pending_id'] ?? 0);
|
|
$action = $input['action'] ?? ''; // approve | reject
|
|
$note = $input['note'] ?? null;
|
|
|
|
if ($pendingId === 0) {
|
|
jsonResponse(400, ['error' => 'pending_id requerido']);
|
|
}
|
|
|
|
if ($action === 'approve') {
|
|
$item = PendingApproval::approve($pendingId, 'ERP: ' . ($company['name'] ?? ''), $note);
|
|
if ($item === null) {
|
|
jsonResponse(404, ['error' => 'Item no encontrado o ya procesado']);
|
|
}
|
|
jsonResponse(200, ['status' => 'approved', 'pending_id' => $pendingId, 'message' => 'Respuesta aprobada y encolada para envío']);
|
|
} elseif ($action === 'reject') {
|
|
$item = PendingApproval::reject($pendingId, 'ERP: ' . ($company['name'] ?? ''), $note);
|
|
if ($item === null) {
|
|
jsonResponse(404, ['error' => 'Item no encontrado o ya procesado']);
|
|
}
|
|
jsonResponse(200, ['status' => 'rejected', 'pending_id' => $pendingId, 'message' => 'Mensaje rechazado']);
|
|
} else {
|
|
jsonResponse(400, ['error' => "Acción no soportada: {$action}. Usa 'approve' o 'reject'"]);
|
|
}
|
|
})()],
|
|
|
|
// ─── ERP: enviar mensaje directo (outbound legacy) ──────────────────────
|
|
['POST', '/api/send-direct', fn() => (function () {
|
|
$apiKey = $_SERVER['HTTP_X_API_KEY'] ?? '';
|
|
if ($apiKey === '') {
|
|
jsonResponse(401, ['error' => 'X-API-Key header requerido']);
|
|
}
|
|
$company = CompanyRepository::findByApiKey($apiKey);
|
|
if ($company === null) {
|
|
jsonResponse(403, ['error' => 'API Key inválida']);
|
|
}
|
|
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
if (!is_array($input)) {
|
|
jsonResponse(400, ['error' => 'JSON inválido']);
|
|
}
|
|
$to = trim($input['to'] ?? '');
|
|
$type = $input['type'] ?? 'text';
|
|
if ($to === '') {
|
|
jsonResponse(400, ['error' => 'Campo "to" requerido']);
|
|
}
|
|
|
|
$payload = match ($type) {
|
|
'text' => json_encode(['text' => $input['text'] ?? '']),
|
|
'image' => json_encode(['media_id' => $input['media_id'] ?? '', 'url' => $input['url'] ?? '', 'caption' => $input['caption'] ?? '']),
|
|
'template' => json_encode(['template_name' => $input['template_name'] ?? '', 'components' => $input['components'] ?? []]),
|
|
'interactive' => json_encode(['interactive' => $input['interactive'] ?? []]),
|
|
default => jsonResponse(400, ['error' => "Tipo no soportado: {$type}"]),
|
|
};
|
|
|
|
$db = db();
|
|
$stmt = $db->prepare("INSERT INTO outbound_queue (company_id, to_number, message_type, payload) VALUES (?, ?, ?, ?)");
|
|
$stmt->execute([$company['id'], $to, $type, $payload]);
|
|
jsonResponse(200, ['status' => 'queued', 'id' => (int)$db->lastInsertId()]);
|
|
})()],
|
|
|
|
// ─── Sincronizar empresas desde ERP (protegido) ─────────────────────────
|
|
['GET', '/admin/sync-companies', fn() => DashboardController::syncCompanies()],
|
|
|
|
// ─── Listar empresas (protegido) ────────────────────────────────────────
|
|
['GET', '/admin/companies', fn() => DashboardController::companies()],
|
|
|
|
// ─── Editar empresa (formulario) ───────────────────────────────────────
|
|
['GET', '/admin/company/edit', fn() => DashboardController::companyEdit()],
|
|
|
|
// ─── Guardar empresa (crear/actualizar) ─────────────────────────────────
|
|
['POST', '/admin/company/save', fn() => (function () {
|
|
SessionAuth::require();
|
|
$data = $_POST;
|
|
$id = CompanyRepository::save($data);
|
|
if ($id > 0) {
|
|
header('Location: /admin/companies?msg=' . ($data['id'] ?? 0 > 0 ? 'updated' : 'created'));
|
|
} else {
|
|
header('Location: /admin/companies?msg=error');
|
|
}
|
|
exit;
|
|
})()],
|
|
|
|
// ─── Eliminar empresa ──────────────────────────────────────────────────
|
|
['GET', '/admin/company/delete', fn() => (function () {
|
|
SessionAuth::require();
|
|
$id = (int)($_GET['id'] ?? 0);
|
|
if ($id > 0 && CompanyRepository::delete($id)) {
|
|
header('Location: /admin/companies?msg=deleted');
|
|
} else {
|
|
header('Location: /admin/companies?msg=error');
|
|
}
|
|
exit;
|
|
})()],
|
|
|
|
// ─── Procesar cola outbound (protegido o cron) ─────────────────────────
|
|
['GET', '/admin/process-queue', fn() => DashboardController::processQueue()],
|
|
|
|
// ─── Admin: listar pendientes de aprobación ─────────────────────────────
|
|
['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: 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', 'ai_max_tokens', 'ai_default_prompt',
|
|
];
|
|
$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}");
|
|
}
|
|
header('Location: /admin/settings?msg=saved');
|
|
exit;
|
|
})()],
|
|
|
|
// ─── 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]);
|