0, 'msgs' => 0, 'media' => 0, 'statuses' => 0];
$pending = 0;
$companies = [];
$logs = [];
$total = 0;
}
$pages = $total > 0 ? (int)ceil($total / self::PER_PAGE) : 1;
$companyCount = count($companies);
self::render(compact('stats', 'pending', 'companies', 'companyCount', 'companyId', 'logs', 'total', 'page', 'pages', 'filter', 'search', 'date', 'user'));
}
// ─── GET /admin/live ────────────────────────────────────────────────────
public static function live(): void
{
SessionAuth::require();
$user = SessionAuth::user();
$userName = htmlspecialchars($user['name'] ?? 'Admin', ENT_QUOTES, 'UTF-8');
http_response_code(200);
header('Content-Type: text/html; charset=utf-8');
echo <<
Live — Palmas360
Live Feed
📊 Dashboard
🔴 Live Feed
⏳ Pendientes
💬 Chat
🏢 Empresas
🔄 Sincronizar
📨 Procesar Cola
🤖 Bot Config
⚙️ Configuración
Conectado — actualizando cada 3s
▮▮ Pausar
0 eventos
Esperando eventos en tiempo real...
HTML;
exit;
}
// ─── GET /admin/webhook/stream?after=N ───────────────────────────────────
public static function stream(): void
{
SessionAuth::require();
$afterId = max(0, (int)($_GET['after'] ?? 0));
$isInit = isset($_GET['init']);
try {
if ($isInit) {
// En init devolvemos solo el último ID (sin datos) para anclar
$stmt = db()->query('SELECT id FROM webhook_logs ORDER BY id DESC LIMIT 1');
$row = $stmt->fetch();
$afterId = $row ? (int)$row['id'] : 0;
// Devolvemos array vacío — solo queremos anclar lastId en el cliente
header('Content-Type: application/json; charset=utf-8');
echo json_encode([]);
exit;
}
$stmt = db()->prepare("
SELECT id, event_field, from_number, contact_name,
message_type, message_preview, received_at
FROM webhook_logs
WHERE id > ?
ORDER BY id ASC
LIMIT 50
");
$stmt->execute([$afterId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (\PDOException $e) {
header('Content-Type: application/json; charset=utf-8');
echo '[]';
exit;
}
header('Content-Type: application/json; charset=utf-8');
echo json_encode($rows, JSON_UNESCAPED_UNICODE);
exit;
}
// ─── GET /admin/webhook/raw?id=N ─────────────────────────────────────────
public static function getRaw(): void
{
SessionAuth::require();
$id = max(0, (int)($_GET['id'] ?? 0));
if ($id === 0) {
jsonResponse(400, ['error' => 'ID inválido']);
}
try {
$stmt = db()->prepare('SELECT raw_payload FROM webhook_logs WHERE id = ? LIMIT 1');
$stmt->execute([$id]);
$row = $stmt->fetch();
} catch (\PDOException $e) {
jsonResponse(500, ['error' => 'Error de base de datos']);
}
if (!$row) {
jsonResponse(404, ['error' => 'Registro no encontrado']);
}
header('Content-Type: application/json; charset=utf-8');
echo $row['raw_payload'];
exit;
}
// ─── Consultas ────────────────────────────────────────────────────────────
private static function getStats(PDO $db, ?int $companyId = null): array
{
if ($companyId !== null) {
$stmt = $db->prepare("
SELECT
COUNT(*) AS total,
COALESCE(SUM(event_field='messages' AND message_type='text'),0) AS msgs,
COALESCE(SUM(event_field='messages' AND message_type<>'text'),0) AS media,
COALESCE(SUM(event_field='statuses'),0) AS statuses
FROM webhook_logs
WHERE DATE(received_at) = CURDATE() AND company_id = ?
");
$stmt->execute([$companyId]);
} else {
$stmt = $db->query("
SELECT
COUNT(*) AS total,
COALESCE(SUM(event_field='messages' AND message_type='text'),0) AS msgs,
COALESCE(SUM(event_field='messages' AND message_type<>'text'),0) AS media,
COALESCE(SUM(event_field='statuses'),0) AS statuses
FROM webhook_logs
WHERE DATE(received_at) = CURDATE()
");
}
return $stmt->fetch() ?: ['total' => 0, 'msgs' => 0, 'media' => 0, 'statuses' => 0];
}
private static function getLogs(PDO $db, int $page, string $filter, string $search, string $date, ?int $companyId = null): array
{
$where = ['DATE(received_at) = ?'];
$params = [$date];
if ($companyId !== null) {
$where[] = 'company_id = ?';
$params[] = $companyId;
}
if ($filter !== '') {
$where[] = 'event_field = ?';
$params[] = $filter;
}
if ($search !== '') {
$where[] = '(from_number LIKE ? OR contact_name LIKE ? OR message_preview LIKE ?)';
$like = '%' . $search . '%';
array_push($params, $like, $like, $like);
}
$w = implode(' AND ', $where);
$cnt = $db->prepare("SELECT COUNT(*) FROM webhook_logs WHERE $w");
$cnt->execute($params);
$total = (int)$cnt->fetchColumn();
$limit = self::PER_PAGE;
$offset = ($page - 1) * $limit;
$stmt = $db->prepare("
SELECT wh.id, wh.event_field, wh.from_number, wh.contact_name,
wh.message_type, wh.message_preview, wh.received_at,
c.name AS company_name
FROM webhook_logs wh
LEFT JOIN companies c ON c.id = wh.company_id
WHERE $w
ORDER BY wh.received_at DESC
LIMIT $limit OFFSET $offset
");
$stmt->execute($params);
return [$stmt->fetchAll(), $total];
}
// ─── Helpers de vista ─────────────────────────────────────────────────────
private static function h($v): string
{
return htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8');
}
private static function typeLabel(string $field, string $type): string
{
if ($field === 'statuses') {
switch ($type) {
case 'sent': $cls = 'badge-blue'; break;
case 'delivered': $cls = 'badge-green'; break;
case 'read': $cls = 'badge-teal'; break;
case 'failed': $cls = 'badge-red'; break;
default: $cls = 'badge-gray'; break;
}
return '' . self::h($type) . ' ';
}
switch ($type) {
case 'text': return '💬 Texto';
case 'image': return '📷 Imagen';
case 'audio': return '🎵 Audio';
case 'video': return '🎬 Video';
case 'document': return '📄 Documento';
case 'location': return '📍 Ubicación';
case 'interactive': return '🔘 Interactivo';
case 'button': return '🔲 Botón';
default: return self::h($type);
}
}
// ─── Render ───────────────────────────────────────────────────────────────
private static function render(array $v): void
{
http_response_code(200);
header('Content-Type: text/html; charset=utf-8');
// Variables para el heredoc
$userName = self::h($v['user']['name'] ?? 'Admin');
$dateVal = self::h($v['date']);
$maxDate = date('Y-m-d');
$filterVal = self::h($v['filter']);
$searchVal = self::h($v['search']);
$totalStr = number_format((int)$v['total']);
$selMessages = $v['filter'] === 'messages' ? 'selected' : '';
$selStatuses = $v['filter'] === 'statuses' ? 'selected' : '';
$sTotal = (int)$v['stats']['total'];
$sMsgs = (int)$v['stats']['msgs'];
$sMedia = (int)$v['stats']['media'];
$sStatuses = (int)$v['stats']['statuses'];
$sPending = (int)($v['pending'] ?? 0);
$companyCount = (int)($v['companyCount'] ?? 0);
// Opciones del filtro de empresa
$companyOptions = 'Todas las empresas ';
foreach ($v['companies'] ?? [] as $c) {
$sel = (int)($v['companyId'] ?? 0) === (int)$c['id'] ? 'selected' : '';
$companyOptions .= "" . self::h($c['name'] ?? $c['display_name'] ?? '') . ' ';
}
$showCompany = empty($v['companyId']);
$companyTh = $showCompany ? 'Empresa ' : '';
// Active nav link
$navDashboard = 'active';
$navLive = '';
$navPending = '';
$navCompanies = '';
// Filas de la tabla
$rows = '';
if (empty($v['logs'])) {
$colspan = $showCompany ? 8 : 7;
$rows = "Sin eventos para esta fecha. ";
} else {
foreach ($v['logs'] as $log) {
$id = (int)$log['id'];
$time = self::h(substr($log['received_at'] ?? '', 11, 8));
$from = self::h($log['from_number'] ?? '—');
$name = self::h($log['contact_name'] ?? '—');
$type = self::typeLabel($log['event_field'] ?? '', $log['message_type'] ?? '');
$preview = self::h(mb_substr($log['message_preview'] ?? '—', 0, 70));
$company = self::h($log['company_name'] ?? '—');
$rows .= ""
. "#{$id} "
. "{$time} "
. ($showCompany ? "{$company} " : '')
. "{$from} "
. "{$name} "
. "{$type} "
. "{$preview} "
. "JSON "
. " \n";
}
}
// Paginación
$pager = '';
if ($v['pages'] > 1) {
$pager = '';
}
echo <<
Admin — Palmas360
Palmas360
Somos19D
📊 Dashboard
🔴 Live Feed
⏳ Pendientes ({$sPending})
🏢 Empresas
🔄 Sincronizar
📨 Procesar Cola
🤖 Bot Config
⚙️ Configuración
{$sMsgs}
💬 Mensajes texto
{$sPending}
⏳ Pendientes aprobación
{$companyCount}
🏢 Empresas
📡 Estado ERP
Cargando...
ID
Hora
{$companyTh}
Número
Nombre
Tipo
Preview
{$rows}
{$pager}
HTML;
exit;
}
// ─── GET /admin/pending ────────────────────────────────────────────────
public static function pending(): void
{
SessionAuth::require();
$user = SessionAuth::user();
$userName = htmlspecialchars($user['name'] ?? 'Admin', ENT_QUOTES, 'UTF-8');
$companyId = isset($_GET['company_id']) ? (int)$_GET['company_id'] : null;
$items = $companyId ? PendingApproval::findByCompany($companyId, 'pending') : PendingApproval::findAll('pending');
$companies = CompanyRepository::findAll();
http_response_code(200);
header('Content-Type: text/html; charset=utf-8');
echo <<
Pendientes — Palmas360
Pendientes de Aprobación
📊 Dashboard
🔴 Live Feed
⏳ Pendientes
💬 Chat
🏢 Empresas
🔄 Sincronizar
📨 Procesar Cola
🤖 Bot Config
⚙️ Configuración
';
if (empty($items)) {
echo '
✅ No hay mensajes pendientes de aprobación.
';
} else {
echo '
ID Empresa De Mensaje recibido Respuesta Estado Acción
';
foreach ($items as $item) {
$id = (int)$item['id'];
$company = htmlspecialchars($item['company_name'] ?? '-', ENT_QUOTES, 'UTF-8');
$from = htmlspecialchars($item['from_phone'] ?? '-', ENT_QUOTES, 'UTF-8');
$inMsg = htmlspecialchars(mb_substr($item['incoming_message'] ?? '', 0, 60), ENT_QUOTES, 'UTF-8');
$reply = htmlspecialchars(mb_substr($item['reply_body'] ?? '', 0, 60), ENT_QUOTES, 'UTF-8');
$status = $item['status'] ?? 'pending';
echo "
{$id}
{$company}
{$from}
{$inMsg}
{$reply}
{$status}
✓ Aprobar
✗ Rechazar
";
}
echo '
';
}
echo <<
HTML;
exit;
}
// ─── GET /admin/companies ──────────────────────────────────────────────
public static function companies(): void
{
SessionAuth::require();
$user = SessionAuth::user();
$userName = self::h($user['name'] ?? 'Admin');
$companies = CompanyRepository::findAll(true);
$companyCount = count($companies);
$msg = $_GET['msg'] ?? '';
$toastHtml = '';
if ($msg !== '') {
$map = [
'created' => 'Empresa creada exitosamente.',
'updated' => 'Empresa actualizada exitosamente.',
'deleted' => 'Empresa eliminada.',
'error' => 'Ocurrió un error. Intente de nuevo.',
];
$text = self::h($map[$msg] ?? '');
$cls = in_array($msg, ['created', 'updated']) ? 'toast-success' : 'toast-error';
if ($text !== '') {
$toastHtml = '
' . $text . '
';
}
}
http_response_code(200);
header('Content-Type: text/html; charset=utf-8');
echo <<
Empresas — Palmas360
Empresas
📊 Dashboard
🔴 Live Feed
⏳ Pendientes
💬 Chat
🏢 Empresas
🔄 Sincronizar
📨 Procesar Cola
🤖 Bot Config
⚙️ Configuración
{$toastHtml}
HTML;
if (empty($companies)) {
echo '
';
} else {
echo '
ID Nombre WhatsApp Phone ID Teléfono Bot Type Aprueba Activo API URL Acciones
';
foreach ($companies as $c) {
$id = (int)$c['id'];
$name = self::h($c['name'] ?? '');
$dname = self::h($c['display_name'] ?? '');
$pid = self::h((string)($c['phone_number_id'] ?? ''));
$phone = self::h($c['display_phone'] ?? '');
$bot = $c['bot_type'] ?? 'normal';
$apr = !empty($c['requires_approval']);
$act = !empty($c['is_active']);
$url = self::h($c['api_base_url'] ?? '');
$btag = match($bot) { 'hybrid' => 'hybrid', 'ai' => 'ai', default => 'normal' };
$eName = self::h($c['name'] ?? '');
echo "
{$id}
{$name} {$dname}
{$pid}
{$phone}
{$bot}
' . ($apr ? 'Sí' : 'No') . "
' . ($act ? 'Sí' : 'No') . "
{$url}
✎ Editar
🗑 Eliminar
";
}
echo '
';
}
echo <<
Total: {$companyCount} empresa(s)
HTML;
exit;
}
// ─── GET /admin/company/edit ─────────────────────────────────────────────
public static function companyEdit(): void
{
SessionAuth::require();
$user = SessionAuth::user();
$userName = self::h($user['name'] ?? 'Admin');
$id = (int)($_GET['id'] ?? 0);
$isEdit = $id > 0;
$company = $isEdit ? CompanyRepository::findById($id) : null;
$cv = fn(string $k, string $d = '') => self::h($company[$k] ?? $d);
$name = $cv('name');
$display_name = $cv('display_name');
$phone_number_id = $cv('phone_number_id');
$display_phone = $cv('display_phone');
$api_base_url = $cv('api_base_url');
$api_key = $cv('api_key');
$config_json = $cv('config_json');
$bot_type = $company['bot_type'] ?? 'normal';
$requires_approval = !empty($company['requires_approval']);
$is_active = !empty($company['is_active']);
$reqAprChecked = $requires_approval ? 'checked' : '';
$isActChecked = $is_active ? 'checked' : '';
$botOptions = '';
foreach (['normal' => 'Normal', 'ai' => 'AI', 'hybrid' => 'Híbrido'] as $val => $label) {
$sel = $bot_type === $val ? 'selected' : '';
$botOptions .= "
{$label} \n";
}
$pageTitle = $isEdit ? 'Editar Empresa' : 'Nueva Empresa';
http_response_code(200);
header('Content-Type: text/html; charset=utf-8');
echo <<
{$pageTitle} — Palmas360
{$pageTitle}
📊 Dashboard
🔴 Live Feed
⏳ Pendientes
💬 Chat
🏢 Empresas
🔄 Sincronizar
📨 Procesar Cola
🤖 Bot Config
⚙️ Configuración
HTML;
exit;
}
// ─── GET /admin/sync-companies ──────────────────────────────────────────
public static function syncCompanies(): void
{
SessionAuth::require();
$user = SessionAuth::user();
$userName = self::h($user['name'] ?? 'Admin');
$result = ErpSync::sync();
$hasError = isset($result['error']);
$icon = $hasError ? '❌' : '✅';
$title = $hasError ? 'Error en Sincronización' : 'Sincronización Exitosa';
$titleClass = $hasError ? 'error' : 'success';
$resultJson = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
http_response_code(200);
header('Content-Type: text/html; charset=utf-8');
echo <<
Sincronizar — Palmas360
Sincronizar Empresas
📊 Dashboard
🔴 Live Feed
⏳ Pendientes
💬 Chat
🏢 Empresas
🔄 Sincronizar
📨 Procesar Cola
🤖 Bot Config
⚙️ Configuración
{$icon}
{$title}
{$resultJson}
HTML;
exit;
}
// ─── GET /admin/process-queue ───────────────────────────────────────────
public static function processQueue(): void
{
SessionAuth::require();
$user = SessionAuth::user();
$userName = self::h($user['name'] ?? 'Admin');
$result = OutboundWorker::processQueue();
$hasError = !empty($result['errors']);
$icon = $hasError ? '⚠' : '✅';
$title = $hasError ? 'Cola Procesada con Advertencias' : 'Cola Procesada Exitosamente';
$titleClass = $hasError ? 'error' : 'success';
$resultJson = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
http_response_code(200);
header('Content-Type: text/html; charset=utf-8');
echo <<
Procesar Cola — Palmas360
Procesar Cola de Mensajes
📊 Dashboard
🔴 Live Feed
⏳ Pendientes
💬 Chat
🏢 Empresas
🔄 Sincronizar
📨 Procesar Cola
🤖 Bot Config
⚙️ Configuración
{$icon}
{$title}
{$resultJson}
HTML;
exit;
}
// ─── GET /admin/settings ────────────────────────────────────────────────
public static function settings(): void
{
SessionAuth::require();
$user = SessionAuth::user();
$userName = self::h($user['name'] ?? 'Admin');
$settings = Settings::all();
$msg = $_GET['msg'] ?? '';
$toastHtml = '';
if ($msg === 'saved') {
$toastHtml = '
Configuración guardada exitosamente.
';
}
$fields = [
'WhatsApp Cloud API' => [
['key' => 'whatsapp_access_token', 'label' => 'Access Token', 'type' => 'password', 'placeholder' => 'EAAMzB...'],
['key' => 'whatsapp_app_secret', 'label' => 'App Secret', 'type' => 'password', 'placeholder' => 'a4f82c1d...'],
['key' => 'whatsapp_verify_token', 'label' => 'Verify Token', 'type' => 'text', 'placeholder' => 'PLM360_WH_...'],
['key' => 'whatsapp_business_account_id', 'label' => 'Business Account ID', 'type' => 'text', 'placeholder' => '920641783052817'],
['key' => 'whatsapp_default_phone_number_id', 'label' => 'Phone Number ID (default)', 'type' => 'text', 'placeholder' => '384710295638401'],
['key' => 'verify_hmac_signature', 'label' => 'Validar firma HMAC de Meta', 'type' => 'checkbox', 'placeholder' => ''],
],
'Inteligencia Artificial' => [
['key' => 'ai_provider', 'label' => 'Proveedor', 'type' => 'select', 'options' => ['mock' => 'Mock (simulado)', 'openai' => 'OpenAI']],
['key' => 'openai_api_key', 'label' => 'OpenAI API Key', 'type' => 'password', 'placeholder' => 'sk-...'],
['key' => 'openai_model', 'label' => 'Modelo', 'type' => 'select', 'options' => ['gpt-4o-mini' => 'GPT-4o Mini', 'gpt-4o' => 'GPT-4o', 'gpt-3.5-turbo' => 'GPT-3.5 Turbo']],
['key' => 'ai_max_tokens', 'label' => 'Máximo de tokens', 'type' => 'number', 'placeholder' => '500'],
['key' => 'ai_default_prompt', 'label' => 'System Prompt por defecto', 'type' => 'textarea', 'placeholder' => 'Eres un asistente...'],
],
];
$versionHash = substr(sha1_file(__DIR__ . '/../.env'), 0, 8);
http_response_code(200);
header('Content-Type: text/html; charset=utf-8');
echo <<
Configuración — Palmas360
Configuración
📊 Dashboard
🔴 Live Feed
⏳ Pendientes
💬 Chat
🏢 Empresas
🤖 Bot Config
⚙️ Configuración
🔄 Sincronizar
📨 Procesar Cola
⚙️ Configuración
Los cambios se aplican inmediatamente. Los valores se almacenan en la base de datos.
HTML;
exit;
}
// ─── Chat ────────────────────────────────────────────────────────────────
public static function chat(): void
{
SessionAuth::require();
$user = SessionAuth::user();
$userName = self::h($user['name'] ?? 'Admin');
$companies = CompanyRepository::findAll();
http_response_code(200);
header('Content-Type: text/html; charset=utf-8');
echo <<
Chat — Palmas360
Chat
📊 Dashboard
🔴 Live Feed
⏳ Pendientes
💬 Chat
🏢 Empresas
🤖 Bot Config
⚙️ Configuración
🔄 Sincronizar
📨 Procesar Cola
💬
Selecciona una conversación
➤
HTML;
exit;
}
// ─── API: lista de conversaciones ────────────────────────────────────────
public static function chatConversations(): void
{
SessionAuth::require();
$db = db();
// Get unique conversations with last message and unread count
$rows = $db->query("
SELECT c.phone_number, c.contact_name, c.company_id,
MAX(c.created_at) as last_time,
(SELECT content FROM conversations c2 WHERE c2.phone_number = c.phone_number ORDER BY c2.id DESC LIMIT 1) as last_message,
(SELECT COUNT(*) FROM conversations c3 WHERE c3.phone_number = c.phone_number AND c3.direction = 'inbound' AND c3.id > COALESCE((SELECT MAX(c4.id) FROM conversations c4 WHERE c4.phone_number = c.phone_number AND c4.direction = 'outbound'), 0)) as unread_count
FROM conversations c
GROUP BY c.phone_number, c.contact_name, c.company_id
ORDER BY last_time DESC
")->fetchAll();
$companies = [];
foreach (CompanyRepository::findAll() as $co) {
$companies[(int)$co['id']] = $co['name'];
}
$conversations = [];
foreach ($rows as $r) {
$cid = (int)$r['company_id'];
$conversations[] = [
'phone' => $r['phone_number'],
'contact_name' => $r['contact_name'] ?? '',
'company_name' => $companies[$cid] ?? '',
'last_message' => mb_substr($r['last_message'] ?? '', 0, 80),
'last_time' => $r['last_time'],
'unread_count' => (int)$r['unread_count'],
];
}
jsonResponse(200, ['conversations' => $conversations]);
}
// ─── API: mensajes de una conversación ───────────────────────────────────
public static function chatMessages(): void
{
SessionAuth::require();
$phone = trim($_GET['phone'] ?? '');
if ($phone === '') jsonResponse(400, ['error' => 'phone requerido']);
$stmt = db()->prepare("
SELECT direction, message_type, content, media_id, status, created_at
FROM conversations
WHERE phone_number = ?
ORDER BY id ASC
");
$stmt->execute([$phone]);
$messages = $stmt->fetchAll();
// Also include outbound_queue items for this phone
$stmt2 = db()->prepare("
SELECT 'outbound' as direction, message_type, payload as content, NULL as media_id, status, created_at
FROM outbound_queue
WHERE to_number = ? AND status IN ('sent','delivered','failed')
AND id > COALESCE((SELECT MAX(c.id) FROM conversations c WHERE c.phone_number = ?), 0)
ORDER BY id ASC
");
$stmt2->execute([$phone, $phone]);
$queueMsgs = $stmt2->fetchAll();
foreach ($queueMsgs as &$qm) {
$pl = json_decode($qm['content'], true);
$qm['content'] = $pl['text'] ?? $pl['caption'] ?? $qm['content'];
}
$all = array_merge($messages, $queueMsgs);
usort($all, fn($a, $b) => strcmp($a['created_at'] ?? '', $b['created_at'] ?? ''));
jsonResponse(200, ['messages' => $all]);
}
// ─── API: enviar mensaje desde el chat ───────────────────────────────────
public static function chatSend(): void
{
SessionAuth::require();
$input = json_decode(file_get_contents('php://input'), true);
$phone = trim($input['phone'] ?? '');
$text = trim($input['text'] ?? '');
if ($phone === '' || $text === '') {
jsonResponse(400, ['error' => 'phone y text requeridos']);
}
// Find company for this phone (lookup by latest conversation)
$stmt = db()->prepare("SELECT company_id FROM conversations WHERE phone_number = ? ORDER BY id DESC LIMIT 1");
$stmt->execute([$phone]);
$conv = $stmt->fetch();
$companyId = $conv ? (int)$conv['company_id'] : 1;
// Enqueue the message
$db = db();
$payload = json_encode(['text' => $text]);
$qStmt = $db->prepare("INSERT INTO outbound_queue (company_id, to_number, message_type, payload, status, created_at) VALUES (?, ?, 'text', ?, 'queued', NOW())");
$qStmt->execute([$companyId, $phone, $payload]);
// Add to conversations
$msgId = 'admin_' . time() . '_' . $phone;
$cStmt = $db->prepare("INSERT INTO conversations (company_id, message_id, phone_number, direction, message_type, content, created_at) VALUES (?, ?, ?, 'outbound', 'text', ?, NOW())");
$cStmt->execute([$companyId, $msgId, $phone, $text]);
jsonResponse(200, ['status' => 'sent']);
}
// ─── GET /admin/bot-config ──────────────────────────────────────────────
public static function botConfig(): void
{
SessionAuth::require();
$user = SessionAuth::user();
$userName = self::h($user['name'] ?? 'Admin');
$companyId = (int)($_GET['id'] ?? 0);
$companies = CompanyRepository::findAll();
$company = null;
$config = ['commands' => [], 'menus' => [], 'flows' => [], 'ai_prompt' => '', 'fallback' => '', 'greeting' => ''];
$msg = $_GET['msg'] ?? '';
$toastHtml = $msg === 'saved' ? 'Configuración guardada exitosamente.
' : '';
if ($companyId > 0) {
$company = CompanyRepository::findById($companyId);
if ($company) {
$cj = $company['config_json'] ?? '';
if ($cj) {
$parsed = json_decode($cj, true);
if (is_array($parsed)) $config = $parsed;
}
}
}
$configJson = self::h(json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
http_response_code(200);
header('Content-Type: text/html; charset=utf-8');
echo <<
Configurar Bot — Palmas360
Configurar Bot
📊 Dashboard
🔴 Live Feed
⏳ Pendientes
💬 Chat
🏢 Empresas
🤖 Bot Config
🔗 Hilo Conductor
⚙️ Configuración
🔄 Sincronizar
📨 Procesar Cola
{$toastHtml}
Seleccionar empresa:
— Selecciona —
HTML;
foreach ($companies as $c) {
$sel = $companyId === (int)$c['id'] ? 'selected' : '';
echo "" . self::h($c['name'] . ' — ' . ($c['display_name'] ?? '')) . " ";
}
echo '
';
if (!$company) {
echo '
Selecciona una empresa para configurar su bot.
';
echo '
';
exit;
}
$c = $company;
$cId = (int)$c['id'];
$cName = self::h($c['name'] ?? '');
// Pre-fill form values from config
$greetingVal = self::h($config['greeting'] ?? '');
$fallbackVal = self::h($config['fallback'] ?? '');
$aiPromptVal = self::h($config['ai_prompt'] ?? '');
$aiModelVal = self::h($config['ai_model'] ?? 'gpt-4o-mini');
$aiTempVal = self::h((string)($config['ai_temperature'] ?? 0.7));
$aiProviderVal = self::h($config['ai_provider'] ?? '');
$approvalWebhookVal = self::h($config['approval_webhook'] ?? '');
$ignoreVal = self::h(implode("\n", $config['ignore_prefixes'] ?? []));
$commands = $config['commands'] ?? [];
$menus = $config['menus'] ?? [];
$flows = $config['flows'] ?? [];
$aiProviderOpenai = $aiProviderVal === 'openai' ? ' selected' : '';
$aiProviderMock = $aiProviderVal === 'mock' ? ' selected' : '';
$aiModelMini = $aiModelVal === 'gpt-4o-mini' ? ' selected' : '';
$aiModel4o = $aiModelVal === 'gpt-4o' ? ' selected' : '';
$aiModel35 = $aiModelVal === 'gpt-3.5-turbo' ? ' selected' : '';
echo <<
📋 Comandos
📑 Menús
🔀 Flujos
🤖 IA
⚙️ General
📋 Comandos
Palabras clave que el usuario escribe para ejecutar acciones. Ej: "menu", "info", "contacto"
+ Agregar comando
Leyenda:
Comando
Menú
Botón / Fila
Flujo (acción)
Sub-menú
Acción final
HTML;
// ─── Render the conversation tree ────────────────────────────────
self::renderConversationTree($config, $companyId);
echo '
';
// ─── Inline JS for interactivity ─────────────────────────────────
$configJson = json_encode($config, JSON_UNESCAPED_UNICODE);
echo <<
HTML;
exit;
}
private static function renderConversationTree(array $config, int $companyId): void
{
$flows = $config['flows'] ?? [];
$menus = $config['menus'] ?? [];
$commands = $config['commands'] ?? [];
$greeting = $config['greeting'] ?? '';
$fallback = $config['fallback'] ?? '';
$hasAny = !empty($commands) || !empty($menus) || !empty($flows) || $greeting !== '';
if (!$hasAny) {
echo '';
return;
}
// ── Build a reverse map: menuKey -> list of commands that point to it ──
$cmdToMenus = [];
foreach ($commands as $kw => $action) {
if (isset($menus[$action])) {
$cmdToMenus[$action][] = $kw;
}
}
// ── Render entry points ──
echo '';
// Greeting
if ($greeting !== '') {
echo '
';
echo '
';
echo '
👋 Entrada
';
echo '
Mensaje de Bienvenida
';
echo '
"' . self::h($greeting) . '"
';
echo '
';
echo '
';
echo '
';
}
// ── Render commands (entry points) ──
$displayedMenus = [];
// Render commands that point to menus
echo '
';
echo '
';
foreach ($commands as $kw => $action) {
$targetType = isset($menus[$action]) ? 'menu' : (isset($flows[$action]) ? 'flow' : 'unknown');
$targetIcon = $targetType === 'menu' ? '📑' : ($targetType === 'flow' ? '🔀' : '❓');
echo '
';
echo '
';
echo '
⌨️ Comando
';
echo '
' . self::h($kw) . '
';
echo '
→ ' . $targetIcon . ' ' . self::h($action) . '
';
echo '
';
echo '✎ Editar ';
echo '
';
echo '
';
// Connect to target
echo '
';
// Show target menu or flow
if ($targetType === 'menu' && isset($menus[$action])) {
self::renderMenuNode($action, $menus[$action], $flows, $menus, $companyId);
$displayedMenus[$action] = true;
} elseif ($targetType === 'flow' && isset($flows[$action])) {
self::renderFlowNode($action, $flows[$action], $menus, $companyId);
}
echo '
';
}
echo '
';
echo '
';
// ── Render menus that are not linked by any command ──
$orphanMenus = array_diff_key($menus, $displayedMenus);
if (!empty($orphanMenus)) {
echo '
';
echo '
📌 Menús sin comando de entrada
';
echo '
';
foreach ($orphanMenus as $mk => $menu) {
echo '
';
echo '';
echo '
';
self::renderMenuNode($mk, $menu, $flows, $menus, $companyId);
echo '
';
}
echo '
';
}
// ── Render orphan flows (not linked by any menu row) ──
$linkedFlows = [];
foreach ($menus as $mk => $menu) {
foreach ($menu['sections'] ?? [] as $section) {
foreach ($section['rows'] ?? [] as $row) {
if (isset($flows[$row['id']])) {
$linkedFlows[$row['id']] = true;
}
}
}
}
foreach ($commands as $kw => $action) {
if (isset($flows[$action])) $linkedFlows[$action] = true;
}
$orphanFlows = array_diff_key($flows, $linkedFlows);
if (!empty($orphanFlows)) {
echo '
';
echo '
📌 Flujos sin conectar
';
echo '
';
foreach ($orphanFlows as $fk => $flow) {
echo '
';
echo '
';
echo '
🔀 Flujo
';
echo '
' . self::h($fk) . '
';
echo '
';
if ($flow['type'] === 'text') echo '📝 ' . self::h(mb_substr($flow['message'] ?? '', 0, 60));
elseif ($flow['type'] === 'function') echo '⚡ ' . self::h($flow['function'] ?? '');
elseif ($flow['type'] === 'menu') echo '📑 → ' . self::h($flow['menu'] ?? '');
echo '
';
echo '
';
echo '✎ Editar ';
echo '
';
echo '
';
echo '
';
}
echo '
';
}
// ── Add buttons at bottom ──
echo '
';
echo '
';
echo '+ Comando ';
echo '+ Menú ';
echo '+ Flujo ';
echo '
';
echo '
';
echo '💾 Guardar Todo ';
echo '
';
echo '
'; // root-node
}
private static function renderMenuNode(string $menuKey, array $menu, array $flows, array $allMenus, int $companyId): void
{
$typeLabel = ($menu['type'] ?? 'list') === 'list' ? 'Lista' : 'Botones';
$typeBadge = ($menu['type'] ?? 'list') === 'list' ? 'badge-list' : 'badge-button';
$hasRows = false;
foreach ($menu['sections'] ?? [] as $section) {
if (!empty($section['rows'])) { $hasRows = true; break; }
}
echo '';
echo ''; // node-card
// ── Show first sub-menu connection inline ──
foreach ($menu['sections'] ?? [] as $section) {
foreach ($section['rows'] ?? [] as $row) {
$flow = $flows[$row['id']] ?? null;
if ($flow && $flow['type'] === 'menu' && isset($flow['menu'])) {
echo '
';
echo '
';
echo '
🔘 ' . self::h($row['title']) . ' →
';
self::renderFlowNode($row['id'], $flow, $allMenus, $companyId, true);
echo '
';
break 2;
}
}
}
echo '
'; // flex container
}
private static function renderFlowNode(string $flowKey, array $flow, array $allMenus, int $companyId, bool $isSub = false): void
{
$icon = '🔀';
$typeLabel = 'Flujo';
$typeColor = '#8b5cf6';
$cardClass = 'flow-card';
if ($flow['type'] === 'text') {
$icon = '📝';
$typeLabel = 'Respuesta';
$typeColor = '#8b5cf6';
} elseif ($flow['type'] === 'function') {
$fn = $flow['function'] ?? 'forward_to_ai';
$fnLabels = ['forward_to_ai' => '🤖 IA', 'forward_to_agent' => '👤 Agente', 'webhook' => '🔗 Webhook'];
$icon = $fn === 'forward_to_ai' ? '🤖' : ($fn === 'forward_to_agent' ? '👤' : '🔗');
$typeLabel = $fnLabels[$fn] ?? '⚡ Función';
$typeColor = $fn === 'forward_to_ai' ? '#ec4899' : ($fn === 'forward_to_agent' ? '#f97316' : '#6366f1');
$cardClass = 'flow-card action';
} elseif ($flow['type'] === 'menu') {
$icon = '📑';
$typeLabel = 'Sub-menú';
$typeColor = '#10b981';
$cardClass = 'sub-card';
}
$nodeId = 'flow-node-' . $flowKey;
echo '';
echo '
' . $icon . ' ' . $typeLabel . '
';
echo '
' . self::h($flowKey) . '
';
if ($flow['type'] === 'text') {
echo '
' . self::h(mb_substr($flow['message'] ?? '', 0, 80)) . '
';
} elseif ($flow['type'] === 'menu' && isset($flow['menu']) && isset($allMenus[$flow['menu']])) {
echo '
→ ' . self::h($flow['menu']) . '
';
} elseif ($flow['type'] === 'function') {
echo '
' . ($flow['function'] ?? 'forward_to_ai') . '
';
}
echo '
';
echo '✎ Editar ';
echo '
';
echo '
';
// If flow is sub-menu, render the sub-menu below
if ($flow['type'] === 'menu' && isset($flow['menu']) && isset($allMenus[$flow['menu']])) {
$subKey = $flow['menu'];
$subMenu = $allMenus[$subKey];
echo '
';
echo '';
echo '
📑 Sub-menú: ' . self::h($subKey) . '
';
if ($subMenu['body']) echo '
' . self::h($subMenu['body']) . '
';
// Show sub-menu's rows
$hasRows = false;
foreach ($subMenu['sections'] ?? [] as $section) {
if (!empty($section['rows'])) { $hasRows = true; break; }
}
if ($hasRows) {
echo '
';
foreach ($subMenu['sections'] ?? [] as $section) {
foreach ($section['rows'] ?? [] as $row) {
echo '
';
echo '🔘 ';
echo '' . self::h($row['title']) . ' ';
echo '' . self::h($row['id']) . ' ';
echo '
';
}
}
echo '
';
} else {
echo '
Sin botones
';
}
echo '
';
echo '✎ Editar sub-menú ';
echo '
';
echo '
';
}
}
}