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}

+
+ + +
+ +
+ + +
+ + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+
+ +
+
+
+ +
+
+ +
+
+
+ +
+
+
+ + +
+
+ +
+
+
+ +
+
+ +
+
+
+ +
+
+ + +
+
+ +
Debe devolver un array JSON: [{id, nombre}, ...]
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+ +
+
+
+ +
+
+ +
+
+
+ +
+
+ + +
+
+ +
+
+ +
+
+
+ +
+
+ +
+
+
+ +
+ \${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}

-
-
- -
-
- - -
-
- - -
-
-
- - -
Configura los endpoints en la pestaña "Endpoints API" de la empresa
-
-
-
- -
-
- -
-
-
- - -
-
-
- - -
-
- - - -
\`); + 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); } }