diff --git a/admin/DashboardController.php b/admin/DashboardController.php
index f2fb370..e11b413 100644
--- a/admin/DashboardController.php
+++ b/admin/DashboardController.php
@@ -2582,7 +2582,7 @@ HTML;
}
echo <<
-
+
@@ -3298,95 +3298,264 @@ function deleteMenu(key) {
}
// ─── Edit Flow ───
+function buildFlowModal(key, flow) {
+ const isNew = !key;
+ const menuOpts = Object.keys(CONFIG.menus).map(m =>
+ ''
+ ).join('');
+
+ const allTargets = [
+ ...Object.keys(CONFIG.menus).map(m => ''),
+ ].join('');
+
+ const epOpts = Object.keys(ENDPOINTS).map(k =>
+ ''
+ ).join('');
+
+ // Dynamic-list source endpoint
+ const dlEpOpts = Object.keys(ENDPOINTS).map(k =>
+ ''
+ ).join('');
+
+ // Next-node options (other flows)
+ const flowOpts = Object.keys(CONFIG.flows).filter(f => f !== key).map(f =>
+ ''
+ ).join('');
+
+ const t = flow.type || 'text';
+ const fn = flow.function || '';
+ const dateMode = (flow.params||{}).date_mode || '';
+ const caption = (flow.params||{}).caption || '';
+ const filename = (flow.params||{}).filename || '';
+ const metaGroup = flow.meta_group || '';
+ const metaKey = flow.meta_key || '';
+ const nextNode = flow.next_node || '';
+ const prompt = flow.prompt || '';
+ const header = flow.header || '';
+ const body = flow.body || '';
+ const secTitle = flow.section_title || '';
+ const valField = flow.value_field || 'id';
+ const lblField = flow.label_field || 'nombre';
+
+ const showFn = t === 'function';
+ const showEp = t === 'function' && fn === 'api_report';
+ const showCollect = t === 'collect_input';
+ const showDynList = t === 'dynamic_list';
+ const showSubmit = t === 'submit_form';
+
+ return \`
+
\${isNew ? '➕ Nuevo Flujo' : '🔀 Editar Flujo: ' + key}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ \${isNew ? '' : ''}
+
+
+
\`;
+}
+
function editFlow(key) {
const flow = CONFIG.flows[key];
if (!flow) return;
- const menuOpts = Object.keys(CONFIG.menus).map(m => '').join('');
- const epOpts = Object.keys(ENDPOINTS).map(k => '').join('');
- const isApiReport = flow.function === 'api_report';
- const dateMode = (flow.params||{}).date_mode || '';
- const caption = (flow.params||{}).caption || '';
- const filename = (flow.params||{}).filename || '';
- showModal(\`
- 🔀 Editar Flujo: \${key}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\`);
+ document.getElementById('modalContent').innerHTML = buildFlowModal(key, flow);
+ document.getElementById('modalOverlay').classList.add('show');
+ // Restore next-node selection for collect_input
+ const nn = flow.next_node || '';
+ const nnSel = document.getElementById('editFlowNextNode');
+ if (nnSel) { for (const o of nnSel.options) if (o.value === nn) { o.selected = true; break; } }
+ const dnSel = document.getElementById('editFlowDlNext');
+ if (dnSel) { for (const o of dnSel.options) if (o.value === flow.next_node||'') { o.selected = true; break; } }
+}
+
+function addNewFlow() {
+ document.getElementById('modalContent').innerHTML = buildFlowModal('', {type:'text'});
+ document.getElementById('modalOverlay').classList.add('show');
}
function flowTypeToggle() {
const t = document.getElementById('editFlowType').value;
- document.getElementById('flowTextGroup').style.display = t === 'text' ? '' : 'none';
- document.getElementById('flowFuncGroup').style.display = t === 'function' ? '' : 'none';
- document.getElementById('flowMenuGroup').style.display = t === 'menu' ? '' : 'none';
- if (t !== 'function') document.getElementById('flowEpGroup').style.display = 'none';
+ document.getElementById('fgText').style.display = t === 'text' ? '' : 'none';
+ document.getElementById('fgMenu').style.display = t === 'menu' ? '' : 'none';
+ document.getElementById('fgFunc').style.display = t === 'function' ? '' : 'none';
+ document.getElementById('fgCollect').style.display = t === 'collect_input' ? '' : 'none';
+ document.getElementById('fgDynList').style.display = t === 'dynamic_list' ? '' : 'none';
+ document.getElementById('fgSubmit').style.display = t === 'submit_form' ? '' : 'none';
+ if (t !== 'function') document.getElementById('fgEp').style.display = 'none';
}
function flowFuncToggle() {
const fn = document.getElementById('editFlowFunction').value;
- document.getElementById('flowEpGroup').style.display = fn === 'api_report' ? '' : 'none';
+ document.getElementById('fgEp').style.display = fn === 'api_report' ? '' : 'none';
}
function saveFlow(oldKey) {
const newKey = document.getElementById('editFlowKey').value.trim();
- const type = document.getElementById('editFlowType').value;
if (!newKey) { alert('El ID del flujo es requerido'); return; }
+ const type = document.getElementById('editFlowType').value;
const flow = { type };
- if (type === 'text') flow.message = document.getElementById('editFlowMessage').value.trim();
- else if (type === 'function') {
+
+ if (type === 'text') {
+ flow.message = document.getElementById('editFlowMessage').value.trim();
+
+ } else if (type === 'menu') {
+ flow.menu = document.getElementById('editFlowMenu').value;
+
+ } else if (type === 'function') {
flow.function = document.getElementById('editFlowFunction').value;
if (flow.function === 'api_report') {
flow.params = {
@@ -3396,10 +3565,44 @@ function saveFlow(oldKey) {
filename: document.getElementById('editFlowFilename').value.trim(),
};
}
+
+ } else if (type === 'collect_input') {
+ flow.prompt = document.getElementById('editFlowPrompt').value.trim();
+ flow.meta_group = document.getElementById('editFlowMetaGroup').value.trim();
+ flow.meta_key = document.getElementById('editFlowMetaKey').value.trim();
+ flow.next_node = document.getElementById('editFlowNextNode').value || null;
+
+ } else if (type === 'dynamic_list') {
+ flow.source_endpoint_key = document.getElementById('editFlowDlEp').value;
+ flow.value_field = document.getElementById('editFlowValField').value.trim() || 'id';
+ flow.label_field = document.getElementById('editFlowLblField').value.trim() || 'nombre';
+ flow.header = document.getElementById('editFlowHeader').value.trim();
+ flow.body = document.getElementById('editFlowBody').value.trim();
+ flow.section_title = document.getElementById('editFlowSecTitle').value.trim();
+ flow.meta_group = document.getElementById('editFlowDlMetaGroup').value.trim();
+ flow.meta_key = document.getElementById('editFlowDlMetaKey').value.trim();
+ flow.next_node = document.getElementById('editFlowDlNext').value || null;
+
+ } else if (type === 'submit_form') {
+ flow.endpoint_key = document.getElementById('editFlowSubmitEp').value;
+ flow.meta_group = document.getElementById('editFlowSubmitGroup').value.trim();
+ flow.filename = document.getElementById('editFlowSubmitFilename').value.trim();
+ flow.caption = document.getElementById('editFlowSubmitCaption').value.trim();
}
- else if (type === 'menu') flow.menu = document.getElementById('editFlowMenu').value;
- delete CONFIG.flows[oldKey];
+
+ if (oldKey && oldKey !== newKey) delete CONFIG.flows[oldKey];
CONFIG.flows[newKey] = flow;
+ // Sync menu row IDs if key changed
+ for (const mk in CONFIG.menus) {
+ const menu = CONFIG.menus[mk];
+ for (const s of (menu.sections || [])) {
+ for (const r of (s.rows || [])) {
+ if (r.id === oldKey && oldKey !== newKey) r.id = newKey;
+ }
+ }
+ }
+ saveConfig();
+}
// Update menu rows that reference this flow
for (const mk in CONFIG.menus) {
const menu = CONFIG.menus[mk];
diff --git a/services/NormalBot.php b/services/NormalBot.php
index 8b9f7cd..2af5602 100644
--- a/services/NormalBot.php
+++ b/services/NormalBot.php
@@ -5,7 +5,7 @@ class NormalBot
{
public static function process(array $company, array $context, string $input): ?array
{
- $config = self::getConfig($company);
+ $config = self::getConfig($company);
$permType = (string)($company['_permission_type'] ?? 1);
$perType = $config['per_type'][$permType] ?? [];
@@ -13,46 +13,46 @@ class NormalBot
$flows = array_merge($config['flows'] ?? [], $perType['flows'] ?? []);
$menus = array_merge($config['menus'] ?? [], $perType['menus'] ?? []);
- $ctxId = null;
- $botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'normal');
- $ctxId = (int)$botCtx['id'];
+ $botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'normal');
+ $ctxId = (int)$botCtx['id'];
$currentNode = $botCtx['current_node'];
+ $normalized = self::normalize($input);
- $normalized = self::normalize($input);
+ // 1. Commands always win — escape from any state (salir, menu, etc.)
+ foreach ($commands as $keyword => $action) {
+ if ($normalized === self::normalize((string)$keyword)) {
+ ConversationContext::updateNode($ctxId, $action);
+ // Commands that match a flow
+ if (isset($flows[$action])) {
+ return self::handleFlow($flows[$action], $context, $company, $ctxId, $menus);
+ }
+ // Commands that match a menu
+ if (isset($menus[$action])) {
+ return self::buildMenuResponse($menus[$action], $context['from'], $company);
+ }
+ return null;
+ }
+ }
- if ($currentNode !== null && isset($flows[$currentNode])) {
+ // 2. Multi-step form: user is currently filling in fields
+ $meta = ConversationContext::getMetadata($ctxId);
+ if (!empty($meta['collecting'])) {
+ return self::handleCollectingInput($meta, $input, 'text', $context, $company, $ctxId, $flows, $menus);
+ }
+
+ // 3. Active node — resume conversation
+ if ($currentNode !== null && $currentNode !== 'collecting' && isset($flows[$currentNode])) {
return self::handleFlow($flows[$currentNode], $context, $company, $ctxId, $menus);
}
- $matchedCommand = null;
- foreach ($commands as $keyword => $action) {
- if ($normalized === self::normalize($keyword)) {
- $matchedCommand = $action;
- break;
- }
- }
-
- if ($matchedCommand !== null) {
- ConversationContext::updateNode($ctxId, $matchedCommand);
-
- if (isset($flows[$matchedCommand])) {
- return self::handleFlow($flows[$matchedCommand], $context, $company, $ctxId, $menus);
- }
-
- if (isset($menus[$matchedCommand])) {
- return self::buildMenuResponse($menus[$matchedCommand], $context['from'], $company);
- }
-
- return null;
- }
-
- // Per-type greeting_menu: direct menu for this category (greeting + fallback in one)
+ // 4. Per-type greeting menu (new user / no active context)
$greetingMenuKey = $perType['greeting_menu'] ?? null;
if ($greetingMenuKey !== null && $currentNode === null && isset($menus[$greetingMenuKey])) {
ConversationContext::updateNode($ctxId, null);
return self::buildMenuResponse($menus[$greetingMenuKey], $context['from'], $company);
}
+ // 5. Greeting text
$greeting = $perType['greeting'] ?? $config['greeting'] ?? null;
if ($greeting !== null && $currentNode === null) {
$greetingFlowId = $perType['greeting_flow'] ?? 'greeting';
@@ -60,24 +60,25 @@ class NormalBot
ConversationContext::updateNode($ctxId, $greetingFlowId);
return self::handleFlow($flows[$greetingFlowId], $context, $company, $ctxId, $menus);
}
-
$response = self::sendText($greeting, $context['from'], $company);
ConversationContext::updateNode($ctxId, null);
return $response;
}
+ // 6. Fallback flow
$fallbackFlowId = $perType['fallback_flow'] ?? $config['fallback_flow'] ?? null;
if ($fallbackFlowId !== null && isset($flows[$fallbackFlowId])) {
ConversationContext::updateNode($ctxId, $fallbackFlowId);
return self::handleFlow($flows[$fallbackFlowId], $context, $company, $ctxId, $menus);
}
- // greeting_menu also serves as fallback when nothing else matched
+ // 7. Greeting menu as fallback
if ($greetingMenuKey !== null && isset($menus[$greetingMenuKey])) {
ConversationContext::updateNode($ctxId, null);
return self::buildMenuResponse($menus[$greetingMenuKey], $context['from'], $company);
}
+ // 8. Static fallback text
$fallback = $perType['fallback'] ?? $config['fallback'] ?? null;
if ($fallback !== null) {
ConversationContext::updateNode($ctxId, null);
@@ -87,12 +88,164 @@ class NormalBot
return null;
}
+ // ── Multi-step collecting ────────────────────────────────────────────────
+
+ private static function handleCollectingInput(
+ array $meta, string $input, string $inputType,
+ array $context, array $company, int $ctxId, array $flows, array $menus
+ ): ?array {
+ $col = $meta['collecting'];
+ $group = $col['meta_group'] ?? '';
+ $key = $col['meta_key'] ?? '';
+ $nextNode = $col['next_node'] ?? null;
+
+ $formData = $meta[$group] ?? [];
+ $formData[$key] = trim($input);
+ $meta[$group] = $formData;
+ unset($meta['collecting']);
+ ConversationContext::updateMetadata($ctxId, $meta);
+
+ if ($nextNode !== null) {
+ ConversationContext::updateNode($ctxId, $nextNode);
+ if (isset($flows[$nextNode])) {
+ return self::handleFlow($flows[$nextNode], $context, $company, $ctxId, $menus);
+ }
+ } else {
+ ConversationContext::updateNode($ctxId, null);
+ }
+ return null;
+ }
+
+ private static function handleCollectInput(array $flow, array $context, array $company, int $ctxId): ?array
+ {
+ $meta = ConversationContext::getMetadata($ctxId);
+ $meta['collecting'] = [
+ 'meta_group' => $flow['meta_group'] ?? '',
+ 'meta_key' => $flow['meta_key'] ?? '',
+ 'next_node' => $flow['next_node'] ?? null,
+ ];
+ ConversationContext::updateMetadata($ctxId, $meta);
+ ConversationContext::updateNode($ctxId, 'collecting');
+ return self::sendText($flow['prompt'] ?? '¿Cuál es el valor?', $context['from'], $company);
+ }
+
+ // ── Dynamic list (from endpoint) ─────────────────────────────────────────
+
+ private static function handleDynamicList(array $flow, array $context, array $company, int $ctxId): ?array
+ {
+ $items = self::fetchDynamicList($flow['source_endpoint_key'] ?? '', $company);
+
+ if ($items === null || count($items) === 0) {
+ ConversationContext::reset($ctxId);
+ return self::sendText(
+ 'No se pudieron cargar las opciones ahora. Escribe *menu* para volver.',
+ $context['from'], $company
+ );
+ }
+
+ $meta = ConversationContext::getMetadata($ctxId);
+ $meta['collecting'] = [
+ 'meta_group' => $flow['meta_group'] ?? '',
+ 'meta_key' => $flow['meta_key'] ?? '',
+ 'next_node' => $flow['next_node'] ?? null,
+ ];
+ ConversationContext::updateMetadata($ctxId, $meta);
+ ConversationContext::updateNode($ctxId, 'collecting');
+
+ return self::buildDynamicListResponse($items, $flow, $context['from'], $company);
+ }
+
+ private static function fetchDynamicList(string $endpointKey, array $company): ?array
+ {
+ if ($endpointKey === '') return null;
+
+ $stmt = db()->prepare(
+ 'SELECT url, method FROM company_endpoints WHERE company_id=? AND endpoint_key=? AND is_active=1 LIMIT 1'
+ );
+ $stmt->execute([(int)$company['id'], $endpointKey]);
+ $ep = $stmt->fetch();
+ if (!$ep || empty($ep['url'])) return null;
+
+ $apiKey = $company['api_key'] ?? '';
+ $ch = curl_init($ep['url']);
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 10,
+ CURLOPT_HTTPHEADER => [
+ 'Authorization: Bearer ' . $apiKey,
+ 'X-API-Key: ' . $apiKey,
+ ],
+ ]);
+ $body = curl_exec($ch);
+ $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($code !== 200 || !$body) return null;
+ $data = json_decode($body, true);
+ if (!is_array($data)) return null;
+ // Accept both top-level array and {data: [...]}
+ return isset($data['data']) && is_array($data['data']) ? $data['data'] : $data;
+ }
+
+ private static function buildDynamicListResponse(array $items, array $flow, string $to, array $company): ?array
+ {
+ $valueField = $flow['value_field'] ?? 'id';
+ $labelField = $flow['label_field'] ?? 'name';
+
+ $rows = [];
+ foreach (array_slice($items, 0, 10) as $item) {
+ $id = (string)($item[$valueField] ?? '');
+ $title = mb_substr((string)($item[$labelField] ?? $id), 0, 24);
+ if ($id === '') continue;
+ $rows[] = ['id' => $id, 'title' => $title];
+ }
+
+ if (empty($rows)) return null;
+
+ $interactive = [
+ 'type' => 'list',
+ 'header' => ['type' => 'text', 'text' => mb_substr($flow['header'] ?? 'Selecciona', 0, 60)],
+ 'body' => ['text' => mb_substr($flow['body'] ?? 'Elige una opción:', 0, 1024)],
+ 'footer' => ['text' => mb_substr($company['display_name'] ?? '', 0, 60)],
+ 'action' => [
+ 'button' => mb_substr($flow['button'] ?? 'Ver opciones', 0, 20),
+ 'sections' => [[
+ 'title' => mb_substr($flow['section_title'] ?? 'Opciones', 0, 24),
+ 'rows' => $rows,
+ ]],
+ ],
+ ];
+
+ return self::enqueueInteractive($to, $interactive, $company);
+ }
+
+ // ── Submit form (end of multi-step collection) ───────────────────────────
+
+ private static function handleSubmitForm(array $flow, array $context, array $company, int $ctxId): ?array
+ {
+ $meta = ConversationContext::getMetadata($ctxId);
+ $group = $flow['meta_group'] ?? '';
+
+ $formData = $meta[$group] ?? [];
+ unset($meta[$group], $meta['collecting']);
+ ConversationContext::updateMetadata($ctxId, $meta);
+
+ $params = [
+ 'endpoint_key' => $flow['endpoint_key'] ?? '',
+ 'caption' => $flow['caption'] ?? 'Informe generado.',
+ 'filename' => $flow['filename'] ?? 'reporte.pdf',
+ 'query' => $formData, // form fields become URL query params
+ ];
+
+ return self::executeApiReport($params, $context, $company, $ctxId);
+ }
+
+ // ── Flow dispatcher ──────────────────────────────────────────────────────
+
private static function resolveMenu($menuRef, array $company, array $allMenus = []): array
{
if (is_string($menuRef)) {
- if (isset($allMenus[$menuRef])) {
- return $allMenus[$menuRef];
- }
+ if (isset($allMenus[$menuRef])) return $allMenus[$menuRef];
$config = self::getConfig($company);
return ($config['menus'] ?? [])[$menuRef] ?? [];
}
@@ -104,14 +257,19 @@ class NormalBot
$type = $flow['type'] ?? 'text';
return match ($type) {
- 'text' => self::sendText($flow['message'] ?? '', $context['from'], $company),
- 'image' => self::sendImage($flow['media_id'] ?? '', $flow['caption'] ?? null, $context['from'], $company),
- 'menu' => self::buildMenuResponse(self::resolveMenu($flow['menu'] ?? [], $company, $menus), $context['from'], $company),
- 'function' => self::executeFunction($flow['function'] ?? '', $flow['params'] ?? [], $context, $company, $ctxId),
- default => null,
+ 'text' => self::sendText($flow['message'] ?? '', $context['from'], $company),
+ 'image' => self::sendImage($flow['media_id'] ?? '', $flow['caption'] ?? null, $context['from'], $company),
+ 'menu' => self::buildMenuResponse(self::resolveMenu($flow['menu'] ?? [], $company, $menus), $context['from'], $company),
+ 'function' => self::executeFunction($flow['function'] ?? '', $flow['params'] ?? [], $context, $company, $ctxId),
+ 'collect_input' => self::handleCollectInput($flow, $context, $company, $ctxId),
+ 'dynamic_list' => self::handleDynamicList($flow, $context, $company, $ctxId),
+ 'submit_form' => self::handleSubmitForm($flow, $context, $company, $ctxId),
+ default => null,
};
}
+ // ── Functions ────────────────────────────────────────────────────────────
+
private static function executeFunction(string $function, array $params, array $context, array $company, int $ctxId): ?array
{
return match ($function) {
@@ -124,8 +282,8 @@ class NormalBot
return self::sendText("Hasta luego 👋\n\nEscribe *menu* cuando quieras volver.", $context['from'], $company);
})(),
'forward_to_ai' => null,
- 'api_report' => self::executeApiReport($params, $context, $company, $ctxId),
- default => null,
+ 'api_report' => self::executeApiReport($params, $context, $company, $ctxId),
+ default => null,
};
}
@@ -149,23 +307,23 @@ class NormalBot
return self::sendText('El informe solicitado no está configurado. Contacta al administrador.' . $nav, $context['from'], $company);
}
- $url = $ep['url'];
+ $url = $ep['url'];
$method = strtoupper($ep['method'] ?? 'GET');
$apiKey = $company['api_key'] ?? '';
$extraQuery = $params['query'] ?? [];
- $dateMode = $params['date_mode'] ?? '';
+ $dateMode = $params['date_mode'] ?? '';
if ($dateMode === 'today') {
$extraQuery['fecha'] = date('Y-m-d');
} elseif ($dateMode === 'last_30') {
$extraQuery['fecha_inicio'] = date('Y-m-d', strtotime('-30 days'));
- $extraQuery['fecha_fin'] = date('Y-m-d');
+ $extraQuery['fecha_fin'] = date('Y-m-d');
}
$extraQuery['telefono'] = $context['from'];
- $extraQuery['nombre'] = $context['name'] ?? '';
+ $extraQuery['nombre'] = $context['name'] ?? '';
- $glue = str_contains($url, '?') ? '&' : '?';
- $url .= $glue . http_build_query($extraQuery);
+ $glue = str_contains($url, '?') ? '&' : '?';
+ $url .= $glue . http_build_query($extraQuery);
$ch = curl_init($url);
$curlOpts = [
@@ -179,14 +337,14 @@ class NormalBot
],
];
if ($method === 'POST') {
- $curlOpts[CURLOPT_POST] = true;
+ $curlOpts[CURLOPT_POST] = true;
$curlOpts[CURLOPT_POSTFIELDS] = '{}';
}
curl_setopt_array($ch, $curlOpts);
- $content = curl_exec($ch);
- $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $content = curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
- $error = curl_error($ch);
+ $error = curl_error($ch);
curl_close($ch);
if ($error) {
@@ -202,19 +360,18 @@ class NormalBot
}
$mimeMap = [
- 'application/pdf' => ['pdf', 'pdf'],
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => ['xlsx', 'xlsx'],
- 'application/vnd.ms-excel' => ['xls', 'xls'],
- 'text/csv' => ['csv', 'csv'],
- 'application/vnd.oasis.opendocument.spreadsheet' => ['ods', 'ods'],
+ 'application/pdf' => ['pdf', 'application/pdf'],
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => ['xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
+ 'application/vnd.ms-excel' => ['xls', 'application/vnd.ms-excel'],
+ 'text/csv' => ['csv', 'text/csv'],
+ 'application/vnd.oasis.opendocument.spreadsheet' => ['ods', 'application/vnd.oasis.opendocument.spreadsheet'],
];
- $ext = 'pdf';
+ $ext = 'pdf';
$mime = 'application/pdf';
foreach ($mimeMap as $m => $info) {
if (str_starts_with($contentType ?? '', $m)) {
- $ext = $info[0];
- $mime = $info[1] ?? $m;
+ [$ext, $mime] = $info;
break;
}
}
@@ -236,39 +393,93 @@ class NormalBot
}
$reportName = $params['filename'] ?? ('reporte.' . $ext);
- $caption = $params['caption'] ?? 'Aquí tienes el reporte solicitado.';
+ $caption = $params['caption'] ?? 'Aquí tienes el reporte solicitado.';
WhatsAppSender::sendDocument($context['from'], $upload['media_id'], $phoneNumberId, $caption, $reportName);
-
ConversationContext::reset($ctxId);
return self::sendText('✅ Reporte enviado.' . $nav, $context['from'], $company);
}
+ // ── Public helpers ───────────────────────────────────────────────────────
+
public static function buildGreetingMenu(array $menu, string $to, array $company): ?array
{
return self::buildMenuResponse($menu, $to, $company);
}
+ // ── Interactive messages ─────────────────────────────────────────────────
+
+ public static function processInteractive(array $company, array $context, string $input): ?array
+ {
+ $config = self::getConfig($company);
+ $permType = (string)($company['_permission_type'] ?? 1);
+ $perType = $config['per_type'][$permType] ?? [];
+ $flows = array_merge($config['flows'] ?? [], $perType['flows'] ?? []);
+ $menus = array_merge($config['menus'] ?? [], $perType['menus'] ?? []);
+
+ $botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'normal');
+ $ctxId = (int)$botCtx['id'];
+
+ // Multi-step form: interactive selection is the collected value
+ $meta = ConversationContext::getMetadata($ctxId);
+ if (!empty($meta['collecting'])) {
+ return self::handleCollectingInput($meta, $input, 'interactive', $context, $company, $ctxId, $flows, $menus);
+ }
+
+ // Standard button/row resolution against static menus
+ foreach ($flows as $flowId => $flow) {
+ if (($flow['type'] ?? '') !== 'menu') continue;
+
+ $menu = self::resolveMenu($flow['menu'] ?? [], $company, $menus);
+ foreach ($menu['sections'] ?? [] as $section) {
+ foreach ($section['rows'] ?? [] as $row) {
+ if (($row['id'] ?? '') === $input) {
+ ConversationContext::updateNode($ctxId, $row['id']);
+ if (isset($flows[$row['id']])) {
+ return self::handleFlow($flows[$row['id']], $context, $company, $ctxId, $menus);
+ }
+ return null;
+ }
+ }
+ }
+ foreach ($menu['buttons'] ?? [] as $btn) {
+ if (($btn['id'] ?? '') === $input) {
+ ConversationContext::updateNode($ctxId, $btn['id']);
+ if (isset($flows[$btn['id']])) {
+ return self::handleFlow($flows[$btn['id']], $context, $company, $ctxId, $menus);
+ }
+ return null;
+ }
+ }
+ }
+
+ // Direct flow match (button id == flow key)
+ if (isset($flows[$input])) {
+ ConversationContext::updateNode($ctxId, $input);
+ return self::handleFlow($flows[$input], $context, $company, $ctxId, $menus);
+ }
+
+ return null;
+ }
+
+ // ── WhatsApp senders ─────────────────────────────────────────────────────
+
private static function buildMenuResponse(array $menu, string $to, array $company): ?array
{
$menuType = $menu['type'] ?? 'list';
if ($menuType === 'list') {
$interactive = [
- 'type' => 'list',
+ 'type' => 'list',
'header' => [
'type' => 'text',
'text' => mb_substr($menu['header'] ?? 'Menú', 0, 60),
],
- 'body' => [
- 'text' => mb_substr($menu['body'] ?? 'Selecciona una opción:', 0, 1024),
- ],
- 'footer' => [
- 'text' => mb_substr($menu['footer'] ?? $company['display_name'] ?? '', 0, 60),
- ],
+ 'body' => ['text' => mb_substr($menu['body'] ?? 'Selecciona una opción:', 0, 1024)],
+ 'footer' => ['text' => mb_substr($menu['footer'] ?? $company['display_name'] ?? '', 0, 60)],
'action' => [
- 'button' => mb_substr($menu['button'] ?? 'Ver opciones', 0, 20),
+ 'button' => mb_substr($menu['button'] ?? 'Ver opciones', 0, 20),
'sections' => [],
],
];
@@ -277,14 +488,14 @@ class NormalBot
$rows = [];
foreach ($section['rows'] ?? [] as $row) {
$rows[] = [
- 'id' => mb_substr($row['id'] ?? '', 0, 200),
- 'title' => mb_substr($row['title'] ?? '', 0, 24),
+ 'id' => mb_substr($row['id'] ?? '', 0, 200),
+ 'title' => mb_substr($row['title'] ?? '', 0, 24),
'description' => isset($row['description']) ? mb_substr($row['description'], 0, 72) : null,
];
}
$interactive['action']['sections'][] = [
'title' => mb_substr($section['title'] ?? '', 0, 24),
- 'rows' => $rows,
+ 'rows' => $rows,
];
}
@@ -295,112 +506,57 @@ class NormalBot
$buttons = [];
foreach (array_slice($menu['buttons'] ?? [], 0, 3) as $btn) {
$buttons[] = [
- 'type' => 'reply',
+ 'type' => 'reply',
'reply' => [
- 'id' => mb_substr($btn['id'] ?? '', 0, 256),
+ 'id' => mb_substr($btn['id'] ?? '', 0, 256),
'title' => mb_substr($btn['title'] ?? '', 0, 20),
],
];
}
$interactive = [
- 'type' => 'button',
- 'body' => [
- 'text' => mb_substr($menu['body'] ?? 'Selecciona:', 0, 1024),
- ],
+ 'type' => 'button',
+ 'body' => ['text' => mb_substr($menu['body'] ?? 'Selecciona:', 0, 1024)],
'action' => ['buttons' => $buttons],
];
return self::enqueueInteractive($to, $interactive, $company);
}
- return null; // tipo de menú desconocido
+ return null;
}
private static function sendText(string $text, string $to, array $company): array
{
- return [
- 'action' => 'send',
- 'type' => 'text',
- 'to' => $to,
- 'payload' => json_encode(['text' => $text]),
- ];
+ return ['action' => 'send', 'type' => 'text', 'to' => $to, 'payload' => json_encode(['text' => $text])];
}
private static function sendImage(string $mediaId, ?string $caption, string $to, array $company): array
{
- return [
- 'action' => 'send',
- 'type' => 'image',
- 'to' => $to,
- 'payload' => json_encode(['media_id' => $mediaId, 'caption' => $caption]),
- ];
+ return ['action' => 'send', 'type' => 'image', 'to' => $to, 'payload' => json_encode(['media_id' => $mediaId, 'caption' => $caption])];
}
private static function enqueueInteractive(string $to, array $interactive, array $company): array
{
- return [
- 'action' => 'send',
- 'type' => 'interactive',
- 'to' => $to,
- 'payload' => json_encode(['interactive' => $interactive]),
- ];
+ return ['action' => 'send', 'type' => 'interactive', 'to' => $to, 'payload' => json_encode(['interactive' => $interactive])];
}
private static function getConfig(array $company): array
{
- $json = $company['config_json'] ?? '';
- if ($json === '') {
- return [];
- }
- $config = json_decode($json, true);
+ $json = $company['config_json'] ?? '';
+ $config = $json !== '' ? json_decode($json, true) : null;
return is_array($config) ? $config : [];
}
private static function normalize(string $input): string
{
$input = mb_strtolower(trim($input));
- $input = str_replace(['á', 'é', 'í', 'ó', 'ú', 'ü', 'ñ'], ['a', 'e', 'i', 'o', 'u', 'u', 'n'], $input);
+ $input = str_replace(['á','é','í','ó','ú','ü','ñ'], ['a','e','i','o','u','u','n'], $input);
return preg_replace('/[^a-z0-9\s]/', '', $input);
}
- public static function processInteractive(array $company, array $context, string $input): ?array
+ private static function log(string $msg): void
{
- $config = self::getConfig($company);
- $permType = (string)($company['_permission_type'] ?? 1);
- $perType = $config['per_type'][$permType] ?? [];
- $flows = array_merge($config['flows'] ?? [], $perType['flows'] ?? []);
- $menus = array_merge($config['menus'] ?? [], $perType['menus'] ?? []);
-
- $botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'normal');
- $ctxId = (int)$botCtx['id'];
-
- foreach ($flows as $flowId => $flow) {
- if (($flow['type'] ?? '') === 'menu') {
- $menu = self::resolveMenu($flow['menu'] ?? [], $company, $menus);
- foreach ($menu['sections'] ?? [] as $section) {
- foreach ($section['rows'] ?? [] as $row) {
- if (($row['id'] ?? '') === $input) {
- ConversationContext::updateNode($ctxId, $row['id']);
- if (isset($flows[$row['id']])) {
- return self::handleFlow($flows[$row['id']], $context, $company, $ctxId, $menus);
- }
- return null;
- }
- }
- }
- foreach ($menu['buttons'] ?? [] as $btn) {
- if (($btn['id'] ?? '') === $input) {
- ConversationContext::updateNode($ctxId, $btn['id']);
- if (isset($flows[$btn['id']])) {
- return self::handleFlow($flows[$btn['id']], $context, $company, $ctxId, $menus);
- }
- return null;
- }
- }
- }
- }
-
- return null;
+ if (class_exists('WpWebhook')) WpWebhook::log('NORMALBOT', $msg);
}
}