$action) { if ($normalized === self::normalize((string)$keyword)) { // Clear any in-progress multi-step state so the command is a clean reset $m = ConversationContext::getMetadata($ctxId); $dirty = false; foreach (['collecting', '__foreach', '__cap', '__api_vars', '__ep_vars', '__ep_vars_flat'] as $stateKey) { if (isset($m[$stateKey])) { unset($m[$stateKey]); $dirty = true; } } if ($dirty) ConversationContext::updateMetadata($ctxId, $m); // "atrás" sube un nivel según el mapa 'back' del nodo actual. // Sin nodo o sin mapa (p.ej. tras un informe) cae al menú principal. if ($action === '__back') { $action = ($currentNode !== null ? ($flows[$currentNode]['back'] ?? null) : null) ?? 'show_main_menu'; } // "menu/inicio/volver" → prefer per-type greeting_menu over global show_main_menu $greetingMenuKey = $perType['greeting_menu'] ?? null; if ($action === 'show_main_menu' && $greetingMenuKey && isset($menus[$greetingMenuKey])) { $action = $greetingMenuKey; } ConversationContext::updateNode($ctxId, $action); // Commands that match a flow if (isset($flows[$action])) { return self::handleFlow($flows[$action], $context, $company, $ctxId, $menus, $action); } // Commands that match a menu if (isset($menus[$action])) { return self::buildMenuResponse($menus[$action], $context['from'], $company, $ctxId); } return null; } } // 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); } // 2b. collect_for_each: collecting a value for each item in a list if (!empty($meta['__foreach'])) { return self::handleForeachInput($meta, $input, $context, $company, $ctxId, $flows); } // 2c. collect_and_post: collecting sequential fields then POSTing if (!empty($meta['__cap'])) { return self::handleCapInput($meta, $input, $context, $company, $ctxId); } // 2d. api_vars: collecting URL variables before executing an api_report endpoint if (!empty($meta['__api_vars'])) { return self::handleApiVarsInput($meta, $input, $context, $company, $ctxId, $flows, $menus); } // 3. Active node — resume conversation // Menu-type nodes let NLU act on free text; only re-show if NLU doesn't handle it. $sentinels = ['collecting', '__greeted']; if ($currentNode !== null && !in_array($currentNode, $sentinels, true) && isset($flows[$currentNode])) { $isMenuNode = ($flows[$currentNode]['type'] ?? '') === 'menu'; if (!$isMenuNode) { return self::handleFlow($flows[$currentNode], $context, $company, $ctxId, $menus, $currentNode); } } // 4. Welcome message — shown once for brand-new sessions before the category menu $welcomeMenuKey = $config['welcome_menu'] ?? null; if ($welcomeMenuKey && $currentNode === null && isset($menus[$welcomeMenuKey])) { ConversationContext::updateNode($ctxId, '__greeted'); return self::buildMenuResponse($menus[$welcomeMenuKey], $context['from'], $company, $ctxId); } // 4b. Per-type greeting menu — only for brand-new sessions (currentNode === null) $greetingMenuKey = $perType['greeting_menu'] ?? null; if ($greetingMenuKey !== null && $currentNode === null && isset($menus[$greetingMenuKey])) { ConversationContext::updateNode($ctxId, '__greeted'); // sentinel: evita re-envío del menú return self::buildMenuResponse($menus[$greetingMenuKey], $context['from'], $company, $ctxId); } // 5. Greeting text $greeting = $perType['greeting'] ?? $config['greeting'] ?? null; if ($greeting !== null && $currentNode === null) { $greetingFlowId = $perType['greeting_flow'] ?? 'greeting'; if (isset($flows[$greetingFlowId])) { ConversationContext::updateNode($ctxId, $greetingFlowId); return self::handleFlow($flows[$greetingFlowId], $context, $company, $ctxId, $menus, $greetingFlowId); } $response = self::sendText($greeting, $context['from'], $company); ConversationContext::updateNode($ctxId, null); return $response; } // 6. NLU routing — si está habilitado, intenta mapear texto libre a un flow antes del fallback if (!$suppressFallback && ($config['nlu_enabled'] ?? false)) { $context['permission_type'] ??= (int)$permType; $route = AiBot::routeOrChat($company, $context, $input); if ($route['action'] === 'route' && isset($route['key'])) { $key = $route['key']; // reset_finca: limpiar finca del meta y relanzar ask_finca if ($key === 'reset_finca') { $m = ConversationContext::getMetadata($ctxId); unset($m['finca']); ConversationContext::updateMetadata($ctxId, $m); $key = 'ask_finca'; } // Inyectar entities para auto-select en dynamic_list. // Se guarda el mapa completo: los formularios las buscan por nombre // de campo, y aplanarlas dejaba ese pre-llenado sin funcionar. $m = ConversationContext::getMetadata($ctxId); unset($m['__nlu_entities']); // descartar sobras de un ruteo anterior if (!empty($route['entities'])) { $m['__nlu_entities'] = $route['entities']; } ConversationContext::updateMetadata($ctxId, $m); if (isset($flows[$key])) { ConversationContext::updateNode($ctxId, $key); return self::handleFlow($flows[$key], $context, $company, $ctxId, $menus, $key); } if (isset($menus[$key])) { ConversationContext::updateNode($ctxId, $key); return self::buildMenuResponse($menus[$key], $context['from'], $company, $ctxId); } } if ($route['action'] === 'chat' && ($route['text'] ?? '') !== '') { // Preserve node when inside a menu so state isn't lost $inMenu = $currentNode !== null && !in_array($currentNode, $sentinels, true) && isset($flows[$currentNode]) && ($flows[$currentNode]['type'] ?? '') === 'menu'; if (!$inMenu) ConversationContext::updateNode($ctxId, null); return self::sendText($route['text'], $context['from'], $company); } // El NLU no supo qué hacer. Sin este aviso, abajo aparece el menú // principal sin explicación y se siente como que ignoró el mensaje. $noEntendi = true; } if (!empty($noEntendi)) { WhatsAppSender::sendText( $context['from'], 'No estoy seguro de qué necesitas. ¿Alguna de estas opciones?', (string)($context['phone_number_id'] ?? '') ); } // Re-show current menu when NLU didn't route anything if ($currentNode !== null && !in_array($currentNode, $sentinels, true) && isset($flows[$currentNode])) { return self::handleFlow($flows[$currentNode], $context, $company, $ctxId, $menus, $currentNode); } // 7. 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, $fallbackFlowId); } // 8. Greeting menu as fallback (texto no reconocido después de estar en __greeted) if (!$suppressFallback && $greetingMenuKey !== null && isset($menus[$greetingMenuKey])) { ConversationContext::updateNode($ctxId, '__greeted'); return self::buildMenuResponse($menus[$greetingMenuKey], $context['from'], $company, $ctxId); } // 9. Static fallback text if (!$suppressFallback) { $fallback = $perType['fallback'] ?? $config['fallback'] ?? null; if ($fallback !== null) { ConversationContext::updateNode($ctxId, null); return self::sendText($fallback, $context['from'], $company); } } 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; $validIds = $col['__valid_ids'] ?? null; $labelMap = $col['__label_map'] ?? []; $resolved = trim($input); if ($validIds !== null && !in_array($resolved, $validIds, true)) { // Intento de match por label (útil cuando viene texto transcrito de audio) $inputNorm = self::normalize($resolved); $matched = null; foreach ($labelMap as $label => $id) { $lNorm = self::normalize((string)$label); if ($lNorm === $inputNorm || str_contains($inputNorm, $lNorm) || str_contains($lNorm, $inputNorm)) { $matched = (string)$id; break; } } if ($matched === null) { return self::sendText( '⚠️ Por favor selecciona una opción válida de la lista.', $context['from'], $company ); } $resolved = $matched; } // Si el valor resuelto es un redirect_id, navegar al flow sin guardar valor $redirectIds = $col['__redirect_ids'] ?? []; if (in_array($resolved, $redirectIds, true)) { unset($meta['collecting']); ConversationContext::updateMetadata($ctxId, $meta); ConversationContext::updateNode($ctxId, $resolved); if (isset($flows[$resolved])) { return self::handleFlow($flows[$resolved], $context, $company, $ctxId, $menus, $resolved); } return null; } // Se recupera la etiqueta del id elegido para poder mostrarla después $etiqueta = ''; foreach ($labelMap as $label => $id) { if ((string)$id === $resolved) { $etiqueta = (string)$label; break; } } $meta = self::guardarSeleccion($meta, $group, $key, $resolved, $etiqueta); 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, $nextNode); } if (isset($menus[$nextNode])) { return self::buildMenuResponse($menus[$nextNode], $context['from'], $company, $ctxId); } } 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 { $meta = ConversationContext::getMetadata($ctxId); $valueField = $flow['value_field'] ?? 'id'; $labelField = $flow['label_field'] ?? 'name'; $group = $flow['meta_group'] ?? ''; $key = $flow['meta_key'] ?? ''; // La intención pendiente manda sobre next_node: el usuario ya dijo a dónde iba. $nextNode = $meta['__after_select'] ?? $flow['next_node'] ?? null; // Ya está elegido → no volver a preguntar (para cambiarlo está reset_finca). if (!empty($flow['skip_if_set']) && $group !== '' && ($meta[$group][$key] ?? '') !== '') { return self::advanceAfterSelect($nextNode, $context, $company, $ctxId); } // Con entities del NLU se consulta el catálogo completo, no el top recortado: // "plateo" puede no estar entre los grupos más frecuentes. $srcKey = $flow['source_endpoint_key'] ?? ''; if (!empty($meta['__nlu_entities']) && !empty($flow['source_endpoint_key_all'])) { $srcKey = $flow['source_endpoint_key_all']; } $items = self::fetchDynamicList($srcKey, $company, $meta, $context['from'] ?? ''); 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 ); } // Mapa label → id para match fuzzy (usado tanto en auto-select como en collecting) $labelMap = []; foreach ($items as $item) { $labelMap[(string)($item[$labelField] ?? '')] = (string)($item[$valueField] ?? ''); } // Auto-select cuando hay exactamente 1 opción — no mostrar lista if (count($items) === 1) { $meta = self::guardarSeleccion($meta, $group, $key, (string)($items[0][$valueField] ?? ''), (string)($items[0][$labelField] ?? '')); ConversationContext::updateMetadata($ctxId, $meta); return self::advanceAfterSelect($nextNode, $context, $company, $ctxId); } // Si el NLU extrajo entities, intentar auto-seleccionar sin mostrar lista. // entity_key acota la búsqueda al campo propio: sin eso, una finca llamada // "reposo" se compara contra grupos de mantenimiento y se consume al pedo. $entities = $meta['__nlu_entities'] ?? []; $entityKey = $flow['entity_key'] ?? ''; if (!empty($entities)) { $propias = []; if ($entityKey !== '') { foreach ($entities as $ek => $ev) { if (self::normalize((string)$ek) === self::normalize($entityKey)) $propias[$ek] = $ev; } } else { $propias = $entities; // flow sin entity_key: comportamiento histórico } $matchedId = $matchedLabel = $matchedEk = null; foreach ($propias as $ek => $ev) { $evNorm = self::normalize((string)$ev); if ($evNorm === '') continue; foreach ($labelMap as $label => $id) { $lNorm = self::normalize($label); if ($lNorm === '') continue; if ($lNorm === $evNorm || str_contains($evNorm, $lNorm) || str_contains($lNorm, $evNorm)) { $matchedId = $id; $matchedLabel = $label; $matchedEk = $ek; break 2; } } } if ($matchedId !== null) { $meta = self::guardarSeleccion($meta, $group, $key, $matchedId, (string)$matchedLabel); unset($meta['__nlu_entities'][$matchedEk]); // solo la consumida if (empty($meta['__nlu_entities'])) unset($meta['__nlu_entities']); ConversationContext::updateMetadata($ctxId, $meta); return self::advanceAfterSelect($nextNode, $context, $company, $ctxId); } // Sin match: las entities de otro campo siguen sirviendo más adelante if ($entityKey === '') { unset($meta['__nlu_entities']); ConversationContext::updateMetadata($ctxId, $meta); } } // Opciones fijas que redirigen a otro flow en vez de guardar el valor $redirectIds = []; $appendOptions = $flow['append_options'] ?? []; foreach ($appendOptions as $opt) { $optId = (string)($opt['id'] ?? ''); if ($optId === '') continue; $labelMap[(string)($opt['title'] ?? $optId)] = $optId; $redirectIds[] = $optId; } // El destino ya quedó capturado en collecting.next_node; la intención se // consume acá para que no secuestre la siguiente lista. unset($meta['__after_select']); $meta['collecting'] = [ 'meta_group' => $group, 'meta_key' => $key, 'next_node' => $nextNode, '__valid_ids' => array_merge(array_map('strval', array_column($items, $valueField)), $redirectIds), '__label_map' => $labelMap, '__redirect_ids'=> $redirectIds, ]; ConversationContext::updateMetadata($ctxId, $meta); ConversationContext::updateNode($ctxId, 'collecting'); return self::buildDynamicListResponse($items, $flow, $context['from'], $company); } /** * Bienvenida una vez por día y por empresa. Se envía por fuera del retorno * de process() —igual que hace el selector multi-empresa— para no reemplazar * la respuesta que el usuario vino a buscar. */ private static function saludarUnaVezAlDia(array $config, array $context, array $company, int $ctxId, string $permType): void { $welcome = $config['welcome'] ?? []; if (empty($welcome['enabled'])) return; $texto = (string)($welcome['text'][$permType] ?? ''); if (trim($texto) === '') return; $meta = ConversationContext::getMetadata($ctxId); $hoy = date('Y-m-d'); // el punto de entrada fija America/Bogota if (($meta['__saludo'] ?? '') === $hoy) return; $meta['__saludo'] = $hoy; ConversationContext::updateMetadata($ctxId, $meta); $nombre = trim((string)($context['name'] ?? '')); $texto = strtr($texto, [ '{saludo}' => $nombre !== '' ? "Hola *{$nombre}*" : 'Hola', '{empresa}' => (string)($company['display_name'] ?: $company['name'] ?? ''), '{finca}' => (string)($meta['finca']['finca_label'] ?? ''), '{empresas}' => self::esMultiEmpresa($context['from'] ?? '') ? "\n *cambiar empresa* para consultar otra empresa" : '', ]); WhatsAppSender::sendText($context['from'], $texto, (string)($context['phone_number_id'] ?? '')); } /** Solo tiene sentido ofrecer "cambiar empresa" a quien pertenece a varias. */ private static function esMultiEmpresa(string $waNumber): bool { if ($waNumber === '') return false; try { $stmt = db()->prepare("SELECT companies_json FROM multi_company_sessions WHERE wa_number = ? LIMIT 1"); $stmt->execute([$waNumber]); $json = $stmt->fetchColumn(); return $json !== false && count(json_decode((string)$json, true) ?: []) > 1; } catch (\PDOException $e) { return false; } } /** * Perfil sincronizado del numero: con quien esta vinculado y que modulos * tiene habilitados. Tolera que las columnas aun no existan. */ private static function perfilDelNumero(array $company, string $waNumber): array { static $cache = []; $clave = $company['id'] . '|' . $waNumber; if (isset($cache[$clave])) return $cache[$clave]; $vacio = ['tercero_id' => null, 'modulos' => []]; if ($waNumber === '') return $vacio; try { $stmt = db()->prepare( "SELECT tercero_id, modulos_json FROM company_phones WHERE company_id = ? AND wa_number = ? LIMIT 1" ); $stmt->execute([(int)$company['id'], $waNumber]); $row = $stmt->fetch(); } catch (\PDOException $e) { return $vacio; // columnas aun sin crear } if (!$row) return $vacio; return $cache[$clave] = [ 'tercero_id' => $row['tercero_id'] ?: null, 'modulos' => json_decode((string)($row['modulos_json'] ?? ''), true) ?: [], ]; } /** * Quita del menu las filas de modulos que este numero no tiene habilitados. * Sin modulos declarados no se filtra nada: la ausencia significa "todos", * misma convencion que las fincas. */ private static function filtrarMenuPorModulos(array $menu, array $company, string $waNumber): array { $perfil = self::perfilDelNumero($company, $waNumber); if (!$perfil['modulos']) return $menu; $permitidos = array_merge( (array)($perfil['modulos']['descarga'] ?? []), (array)($perfil['modulos']['carga'] ?? []) ); foreach ($menu['sections'] ?? [] as $i => $sec) { $filas = array_values(array_filter($sec['rows'] ?? [], fn($r) => !isset($r['modulo']) || in_array($r['modulo'], $permitidos, true) )); $menu['sections'][$i]['rows'] = $filas; } $menu['sections'] = array_values(array_filter($menu['sections'] ?? [], fn($s) => !empty($s['rows']))); foreach ($menu['buttons'] ?? [] as $i => $btn) { if (isset($btn['modulo']) && !in_array($btn['modulo'], $permitidos, true)) unset($menu['buttons'][$i]); } if (isset($menu['buttons'])) $menu['buttons'] = array_values($menu['buttons']); return $menu; } /** * Guarda la opción elegida con su etiqueta, para poder mostrarle al usuario * en qué finca/grupo está parado sin tener que volver a consultar la API. */ private static function guardarSeleccion(array $meta, string $group, string $key, string $id, string $label): array { if ($group === '') return $meta; $datos = $meta[$group] ?? []; $datos[$key] = $id; if ($label !== '') $datos[$group . '_label'] = $label; $meta[$group] = $datos; return $meta; } /** * Continúa tras resolver una selección: consume la intención pendiente y * las entities, y ejecuta el destino. */ private static function advanceAfterSelect(?string $nextNode, array $context, array $company, int $ctxId): ?array { // Las entities que no consumió esta lista pueden ser de la siguiente // ("finca reposo, grupo plateo" resuelve las dos), así que no se borran acá. $meta = ConversationContext::getMetadata($ctxId); unset($meta['__after_select']); ConversationContext::updateMetadata($ctxId, $meta); if ($nextNode !== null) { [$flows, $menus] = self::resolveFlowsMenus($company); ConversationContext::updateNode($ctxId, $nextNode); if (isset($flows[$nextNode])) { return self::handleFlow($flows[$nextNode], $context, $company, $ctxId, $menus, $nextNode); } if (isset($menus[$nextNode])) { return self::buildMenuResponse($menus[$nextNode], $context['from'], $company, $ctxId); } } ConversationContext::updateNode($ctxId, null); return null; } // Reemplaza {variable} en la URL con valores del metadata acumulado private static function substituteUrlVars(string $url, array $meta): string { // Priority: __ep_vars_combined (vars collected/resolved for this endpoint call) $epVars = $meta['__ep_vars_combined'] ?? []; foreach ($epVars as $key => $value) { $url = str_replace('{' . $key . '}', urlencode((string)$value), $url); } // Then the rest of meta groups foreach ($meta as $group => $fields) { if (!is_array($fields) || str_starts_with($group, '__')) continue; foreach ($fields as $key => $value) { $url = str_replace('{' . $key . '}', urlencode((string)$value), $url); } } return $url; } private static function fetchDynamicList(string $endpointKey, array $company, array $meta = [], string $waNumber = ''): ?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'] ?? ''; $url = self::buildUrl(self::substituteUrlVars($ep['url'], $meta), $company); // Quién consulta: el ERP acota el catálogo a lo que ese número tiene // asignado. Mismo criterio que los informes. if ($waNumber !== '') { $url .= (str_contains($url, '?') ? '&' : '?') . 'wa=' . urlencode($waNumber); } $ch = curl_init($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 {data:[...]}, {datos:[...]}, or bare array if (isset($data['data']) && is_array($data['data'])) return $data['data']; if (isset($data['datos']) && is_array($data['datos'])) return $data['datos']; return $data; } /** * Lista que acumula: cada toque marca o desmarca, y "Listo" cierra. WhatsApp * no tiene casillas, así que el estado se muestra con ✅ en la etiqueta y la * lista se vuelve a dibujar en cada toque. * * De las 10 filas disponibles, dos se reservan para "Ver más" y "Listo". */ private static function buildMultiSelectResponse(array $items, array $cfg, array $elegidos, string $to, array $company): ?array { $vf = $cfg['value_field'] ?? 'id'; $lf = $cfg['label_field'] ?? 'label'; $pagina = max(0, (int)($cfg['__page'] ?? 0)); $porPagina = self::LISTA_MAX_FILAS - (empty($cfg['__ver_todos']) ? 2 : 3); $hayMas = count($items) > $porPagina; $visibles = $hayMas ? array_slice($items, $pagina * $porPagina, $porPagina) : $items; $rows = []; foreach ($visibles as $item) { $id = (string)($item[$vf] ?? ''); if ($id === '') continue; $marca = isset($elegidos[$id]) ? '✅ ' : ''; $rows[] = ['id' => $id, 'title' => mb_substr($marca . (string)($item[$lf] ?? $id), 0, 24)]; } $restantes = count($items) - (($pagina + 1) * $porPagina); if ($hayMas && $restantes > 0) { $rows[] = ['id' => '__mas', 'title' => '➕ Ver más', 'description' => "Faltan {$restantes}"]; } if (!empty($cfg['__ver_todos'])) { $rows[] = ['id' => '__todos', 'title' => '🔎 Otro lote', 'description' => 'Ver el listado completo']; } $n = count($elegidos); $rows[] = [ 'id' => '__listo', 'title' => $n > 0 ? "✔️ Listo ({$n})" : '✔️ Listo', 'description' => $n > 0 ? implode(', ', array_slice(array_values($elegidos), 0, 3)) : 'Elige al menos uno', ]; return self::enqueueInteractive($to, [ 'type' => 'list', 'header' => ['type' => 'text', 'text' => mb_substr($cfg['header'] ?? 'Selecciona', 0, 60)], 'body' => ['text' => mb_substr($cfg['body'] ?? 'Puedes elegir varios:', 0, 1024)], 'footer' => ['text' => ''], 'action' => ['button' => 'Ver opciones', 'sections' => [[ 'title' => mb_substr($cfg['section_title'] ?? 'Opciones', 0, 24), 'rows' => $rows, ]]], ], $company); } private static function buildDynamicListResponse(array $items, array $flow, string $to, array $company): ?array { $valueField = $flow['value_field'] ?? 'id'; $labelField = $flow['label_field'] ?? 'name'; // Auto-detect multi-section if items carry a 'section' key $sectionField = $flow['section_field'] ?? (isset($items[0]['section']) ? 'section' : null); if ($sectionField !== null) { $grouped = []; foreach ($items as $item) { $id = (string)($item[$valueField] ?? ''); if ($id === '') continue; $title = mb_substr((string)($item[$labelField] ?? $id), 0, 24); $sec = mb_substr((string)($item[$sectionField] ?? 'Opciones'), 0, 24); $grouped[$sec][] = ['id' => $id, 'title' => $title]; } $sections = []; foreach (array_slice($grouped, 0, 10, true) as $secTitle => $rows) { $sections[] = ['title' => $secTitle, 'rows' => array_slice($rows, 0, 10)]; } } else { // Con más opciones que filas disponibles se pagina: el último lugar // queda para "Ver más". Sin esto las sobrantes eran inalcanzables. $pagina = max(0, (int)($flow['__page'] ?? 0)); $hayMas = count($items) > self::LISTA_MAX_FILAS; $porPagina = $hayMas ? self::LISTA_MAX_FILAS - 1 : self::LISTA_MAX_FILAS; $visibles = array_slice($items, $pagina * $porPagina, $porPagina); $rows = []; foreach ($visibles as $item) { $id = (string)($item[$valueField] ?? ''); if ($id === '') continue; $rows[] = ['id' => $id, 'title' => mb_substr((string)($item[$labelField] ?? $id), 0, 24)]; } $restantes = count($items) - (($pagina + 1) * $porPagina); if ($hayMas && $restantes > 0) { $rows[] = [ 'id' => '__mas', 'title' => '➕ Ver más', 'description' => "Faltan {$restantes}", ]; } $sections = [['title' => mb_substr($flow['section_title'] ?? 'Opciones', 0, 24), 'rows' => $rows]]; } if (empty($sections)) return null; // Añadir opciones fijas al final de la última sección $appendOptions = $flow['append_options'] ?? []; if (!empty($appendOptions)) { $last = count($sections) - 1; foreach ($appendOptions as $opt) { $optId = (string)($opt['id'] ?? ''); if ($optId === '') continue; $sections[$last]['rows'][] = [ 'id' => $optId, 'title' => mb_substr((string)($opt['title'] ?? $optId), 0, 24), ]; } } return self::enqueueInteractive($to, [ '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' => $sections, ], ], $company); } // ── collect_for_each — pide un valor por cada ítem de una lista dinámica ── private static function handleCollectForEach(array $flow, array $context, array $company, int $ctxId): ?array { $meta = ConversationContext::getMetadata($ctxId); $items = self::fetchDynamicList($flow['source_endpoint_key'] ?? '', $company, $meta, $context['from'] ?? ''); // exclude_values descarta opciones que no son ítems reales, como el // "🌐 Todas las fincas" (id 0) que el catálogo antepone para los informes. $excluir = array_map('strval', $flow['exclude_values'] ?? []); if ($excluir && is_array($items)) { $vf = $flow['value_field'] ?? 'id'; $items = array_values(array_filter($items, fn($i) => !in_array((string)($i[$vf] ?? ''), $excluir, true))); } if (empty($items)) { ConversationContext::reset($ctxId); return self::sendText( '⚠️ No se pudo cargar el listado. Escribe *menu* para volver.', $context['from'], $company ); } // Si el NLU extrajo finca+valor, registrar solo ese ítem directamente $entities = $meta['__nlu_entities'] ?? []; unset($meta['__nlu_entities']); if (!empty($entities)) { $valueField = $flow['value_field'] ?? 'id'; $labelField = $flow['label_field'] ?? 'name'; $entityVal = null; $matchedId = null; $matchedLabel = null; // Buscar valor numérico en entities foreach ($entities as $ek => $ev) { if (in_array(self::normalize($ek), ['valor','value','mm','cantidad','qty'], true) && is_numeric(str_replace(',', '.', (string)$ev))) { $entityVal = str_replace(',', '.', (string)$ev); } } // Buscar finca que coincida en entities foreach ($entities as $ek => $ev) { if (in_array(self::normalize($ek), ['valor','value','mm','cantidad','qty'], true)) continue; $evNorm = self::normalize((string)$ev); foreach ($items as $item) { if (self::normalize((string)($item[$labelField] ?? '')) === $evNorm) { $matchedId = (string)($item[$valueField] ?? ''); $matchedLabel = (string)($item[$labelField] ?? ''); break 2; } } } if ($matchedId !== null && $entityVal !== null) { ConversationContext::updateMetadata($ctxId, $meta); ConversationContext::reset($ctxId); // POST directo para ese ítem $epKey = $flow['endpoint_key'] ?? ''; $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'], $epKey]); $ep = $stmt->fetch(); if ($ep && !empty($ep['url'])) { $url = self::buildUrl($ep['url'], $company); $apiKey = $company['api_key'] ?? ''; $body = json_encode([$valueField => $matchedId, 'valor' => $entityVal]); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => ['Content-Type: application/json', "Authorization: Bearer {$apiKey}", "X-API-Key: {$apiKey}"], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, ]); $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($code >= 200 && $code < 300) { $successText = $flow['success_text'] ?? '✅ Datos registrados correctamente.'; return self::sendText("✅ *{$matchedLabel}*: {$entityVal} registrado.\n{$successText}", $context['from'], $company); } } return self::sendText('⚠️ No se pudo registrar. Intenta de nuevo o escribe *menu*.', $context['from'], $company); } ConversationContext::updateMetadata($ctxId, $meta); } $meta['__foreach'] = [ 'items' => $items, 'index' => 0, 'collected' => [], 'value_field' => $flow['value_field'] ?? 'id', 'label_field' => $flow['label_field'] ?? 'name', 'question' => $flow['question'] ?? '¿Cuál es el valor?', 'header' => $flow['header'] ?? '📋 Ingreso de datos', 'endpoint_key'=> $flow['endpoint_key'] ?? '', 'success_text'=> $flow['success_text'] ?? '✅ Datos registrados correctamente.', 'items_key' => $flow['items_key'] ?? 'fincas', 'ask_date' => (bool)($flow['ask_date'] ?? false), 'fecha' => null, ]; ConversationContext::updateMetadata($ctxId, $meta); // El dato se registra contra una fecha; se pregunta antes del bucle // para no repetirla en cada ítem. if ($meta['__foreach']['ask_date']) { return self::foreachAskDate($flow, $context['from'], $company); } return self::foreachAskNext($meta['__foreach'], $context['from'], $company); } /** * Reemplaza {campo} con lo ya recolectado. Permite que una clave de endpoint * o una URL dependan de lo que el usuario eligió antes: los lotes a abrir * salen de un catálogo y los que se pueden cerrar, de otro. */ private static function resolverPorCampos(string $plantilla, array $recolectado): string { if ($plantilla === '' || !str_contains($plantilla, '{')) return $plantilla; foreach ($recolectado as $k => $v) { $plantilla = str_replace('{' . $k . '}', (string)$v, $plantilla); } return $plantilla; } /** Hoy / Ayer / Anteayer / Otra — mismo criterio que el campo date_quick. */ private static function foreachAskDate(array $flow, string $to, array $company): ?array { $hoy = date('Y-m-d'); return self::enqueueInteractive($to, [ 'type' => 'list', 'header' => ['type' => 'text', 'text' => mb_substr($flow['header'] ?? '📋 Registro', 0, 60)], 'body' => ['text' => $flow['date_question'] ?? '📅 ¿De qué fecha es el registro?'], 'footer' => ['text' => ''], 'action' => ['button' => 'Ver fechas', 'sections' => [[ 'title' => 'Fecha', 'rows' => [ ['id' => $hoy, 'title' => '📅 Hoy', 'description' => $hoy], ['id' => date('Y-m-d', strtotime('-1 day')), 'title' => '📅 Ayer', 'description' => date('Y-m-d', strtotime('-1 day'))], ['id' => date('Y-m-d', strtotime('-2 days')), 'title' => '📅 Anteayer', 'description' => date('Y-m-d', strtotime('-2 days'))], ['id' => '__fe_other', 'title' => '✏️ Otra fecha', 'description' => 'Escribe la fecha'], ], ]]], ], $company); } private static function handleForeachInput(array $meta, string $input, array $context, array $company, int $ctxId, array $flows): ?array { $fe = $meta['__foreach']; $items = $fe['items']; $idx = (int)$fe['index']; // ── Fecha del registro, antes de recorrer los ítems ────────────────── if (!empty($fe['ask_date']) && ($fe['fecha'] ?? null) === null) { $v = trim($input); if ($v === '__fe_other') { $fe['awaiting_date'] = true; $meta['__foreach'] = $fe; ConversationContext::updateMetadata($ctxId, $meta); return self::sendText('✏️ Escribe la fecha (YYYY-MM-DD, ej: ' . date('Y-m-d') . '):', $context['from'], $company); } if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $v) || !strtotime($v)) { return self::sendText('⚠️ Formato inválido. Escribe la fecha como YYYY-MM-DD (ej: ' . date('Y-m-d') . '):', $context['from'], $company); } $fe['fecha'] = $v; unset($fe['awaiting_date']); $meta['__foreach'] = $fe; ConversationContext::updateMetadata($ctxId, $meta); return self::foreachAskNext($fe, $context['from'], $company); } // Validate: must be a number if (!is_numeric(str_replace(',', '.', $input))) { $item = $items[$idx]; $label = $item[$fe['label_field']] ?? "Item " . ($idx + 1); return self::sendText( "⚠️ Ingresa solo un número.\n\n*{$label}*\n{$fe['question']}", $context['from'], $company ); } $item = $items[$idx]; $key = (string)($item[$fe['value_field']] ?? $idx); $label = (string)($item[$fe['label_field']] ?? $key); $value = str_replace(',', '.', trim($input)); $fe['collected'][$key] = ['label' => $label, 'valor' => $value]; $fe['index'] = $idx + 1; $meta['__foreach'] = $fe; ConversationContext::updateMetadata($ctxId, $meta); // More items to collect? if ($fe['index'] < count($items)) { return self::foreachAskNext($fe, $context['from'], $company); } // All collected — show summary and confirm/cancel buttons $summary = "*📊 Resumen — {$fe['header']}*\n"; if (!empty($fe['fecha'])) $summary .= "📅 {$fe['fecha']}\n"; $summary .= "\n"; foreach ($fe['collected'] as $row) { $summary .= "• {$row['label']}: *{$row['valor']}*\n"; } $summary .= "\n¿Confirmas el registro?"; return self::enqueueInteractive($context['from'], [ 'type' => 'button', 'body' => ['text' => $summary], 'action' => ['buttons' => [ ['type' => 'reply', 'reply' => ['id' => '__foreach_confirm', 'title' => '✅ Confirmar']], ['type' => 'reply', 'reply' => ['id' => '__foreach_cancel', 'title' => '❌ Cancelar']], ]], ], $company); } private static function foreachAskNext(array $fe, string $to, array $company): ?array { $idx = (int)$fe['index']; $item = $fe['items'][$idx]; $label = $item[$fe['label_field']] ?? "Item " . ($idx + 1); $total = count($fe['items']); $text = "*{$fe['header']}* ({$idx}/{$total})\n\n" . "📍 *{$label}*\n" . $fe['question']; return self::sendText($text, $to, $company); } private static function submitForeach(array $meta, array $context, array $company, int $ctxId): ?array { $fe = $meta['__foreach']; $endpointKey = $fe['endpoint_key'] ?? ''; $nav = "\n\nEscribe *menú* para volver al inicio, *atrás* para subir un nivel o *salir* para terminar."; // El ERP espera {fecha, : [{id, label, valor}]}; telefono y // nombre los lee el controller para dejar trazabilidad en bot_entrada. // (string) explícito: PHP convierte las claves numéricas del array en int // y el JSON saldría con tipos distintos según el id de la finca. $lecturas = []; foreach ($fe['collected'] as $id => $row) { $lecturas[] = ['id' => (string)$id, 'label' => $row['label'], 'valor' => $row['valor']]; } $body = [ 'fecha' => $fe['fecha'] ?? date('Y-m-d'), 'telefono' => $context['from'], 'nombre' => $context['name'] ?? '', ($fe['items_key'] ?? 'fincas') => $lecturas, ]; unset($meta['__foreach']); ConversationContext::updateMetadata($ctxId, $meta); ConversationContext::reset($ctxId); if ($endpointKey === '') { return self::sendText('⚠️ Endpoint de envío no configurado.' . $nav, $context['from'], $company); } $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 self::sendText('⚠️ Endpoint no configurado. Contacta al administrador.' . $nav, $context['from'], $company); } $apiKey = $company['api_key'] ?? ''; $ch = curl_init(self::buildUrl($ep['url'], $company)); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($body), CURLOPT_TIMEOUT => 15, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Authorization: Bearer ' . $apiKey, 'X-API-Key: ' . $apiKey, ], ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlErr = curl_error($ch); curl_close($ch); if ($curlErr || $httpCode >= 400) { self::log("collect_for_each POST error [{$endpointKey}] HTTP {$httpCode}: {$curlErr}"); return self::sendText('⚠️ Error al guardar los datos. Intenta de nuevo.' . $nav, $context['from'], $company); } return self::sendText($fe['success_text'] . $nav, $context['from'], $company); } // ── collect_and_post — pide campos definidos en config y hace POST ───────── private static function handleCollectAndPost(array $flow, array $context, array $company, int $ctxId): ?array { $fields = $flow['fields'] ?? []; // If no inline fields, auto-detect from endpoint's body_fields definition if (empty($fields)) { $epKey = $flow['endpoint_key'] ?? ''; if ($epKey !== '') { $stmt = db()->prepare("SELECT body_fields FROM company_endpoints WHERE company_id=? AND endpoint_key=? AND is_active=1 LIMIT 1"); $stmt->execute([(int)$company['id'], $epKey]); $row = $stmt->fetch(); if ($row && !empty($row['body_fields'])) { $fields = json_decode($row['body_fields'], true) ?: []; } } } if (empty($fields)) { ConversationContext::reset($ctxId); return self::sendText('⚠️ Flujo sin campos configurados. Define los campos en el endpoint. Escribe *menu* para volver.', $context['from'], $company); } $meta = ConversationContext::getMetadata($ctxId); $entities = $meta['__nlu_entities'] ?? []; unset($meta['__nlu_entities']); $collected = []; $startIdx = 0; // from_phone: el dato sale del perfil del numero, no se pregunta. Con un // tercero vinculado el trabajador reporta lo suyo y el bot no le pide // que se busque a si mismo; sin vinculo es supervisor y elige. $perfil = self::perfilDelNumero($company, $context['from'] ?? ''); foreach ($fields as $f) { $origen = $f['from_phone'] ?? ''; if ($origen !== '' && !empty($perfil[$origen])) { $collected[$f['key']] = (string)$perfil[$origen]; } } if (!empty($entities)) { foreach ($fields as $i => $field) { $fKey = $field['key'] ?? ''; $fLabel = $field['label'] ?? ''; foreach ($entities as $ek => $ev) { if (self::normalize($ek) === self::normalize($fKey) || self::normalize($ek) === self::normalize($fLabel)) { $collected[$fKey] = (string)$ev; break; } } } // Advance index past consecutive pre-filled leading fields while ($startIdx < count($fields) && isset($collected[$fields[$startIdx]['key'] ?? ''])) { $startIdx++; } } while ($startIdx < count($fields) && isset($collected[$fields[$startIdx]['key'] ?? ''])) { $startIdx++; } $meta['__cap'] = [ 'fields' => $fields, 'index' => $startIdx, 'collected' => $collected, 'prefilled' => $collected, 'endpoint_key' => $flow['endpoint_key'] ?? '', 'success_text' => $flow['success_text'] ?? '✅ Datos registrados correctamente.', 'confirm' => (bool)($flow['confirm'] ?? true), 'header' => $flow['header'] ?? '📋 Registro', ]; ConversationContext::updateMetadata($ctxId, $meta); return self::capAskNext($meta['__cap'], $context['from'], $company, $ctxId); } private static function handleCapInput(array $meta, string $input, array $context, array $company, int $ctxId): ?array { $cap = $meta['__cap']; $fields = $cap['fields']; $idx = (int)$cap['index']; $field = $fields[$idx]; $value = trim($input); // ── 1. Awaiting manual text after user picked "Otra opción" ────────── if (!empty($cap['__awaiting_other'])) { if (($field['other_validate'] ?? '') === 'date' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) { return self::sendText( '⚠️ Formato inválido. ' . ($field['other_prompt'] ?? 'Ingresa la fecha como YYYY-MM-DD (ej: ' . date('Y-m-d') . '):'), $context['from'], $company ); } if ($err = self::capValidar($field, $value, $cap['collected'])) { return self::sendText("*{$cap['header']}*\n\n{$err}", $context['from'], $company); } $cap['collected'][$field['key']] = $value; $cap['collected_labels'][$field['key']] = $value; unset($cap['__awaiting_other']); $cap['index'] = $idx + 1; while ($cap['index'] < count($fields) && self::capShouldSkip($fields[$cap['index']], $cap['collected'])) { $cap['index']++; } $meta['__cap'] = $cap; ConversationContext::updateMetadata($ctxId, $meta); if ($cap['index'] < count($fields)) { return self::capAskNext($cap, $context['from'], $company, $ctxId); } return self::capShowConfirmOrSubmit($cap, $meta, $context, $company, $ctxId); } // ── 1a. multi_select: marcar, desmarcar o terminar ─────────────────── if (($field['type'] ?? '') === 'multi_select' && $value !== '__mas') { $key = $field['key']; $elegidos = $cap['__multi'][$key] ?? []; if ($value === '__todos') { $cap['__todos'][$key] = true; $cap['__page'][$key] = 0; $meta['__cap'] = $cap; ConversationContext::updateMetadata($ctxId, $meta); return self::capAskNext($cap, $context['from'], $company, $ctxId); } if ($value === '__listo') { if (empty($elegidos)) { WhatsAppSender::sendText($context['from'], '⚠️ Elige al menos una opción.', (string)($context['phone_number_id'] ?? '')); return self::capAskNext($cap, $context['from'], $company, $ctxId); } // strval explícito: PHP convierte a int las claves numéricas $cap['collected'][$key] = array_map('strval', array_keys($elegidos)); $cap['collected_labels'][$key] = implode(', ', array_values($elegidos)); unset($cap['__multi'][$key], $cap['__page'][$key], $cap['__todos'][$key]); $cap['index'] = $idx + 1; while ($cap['index'] < count($fields) && self::capShouldSkip($fields[$cap['index']], $cap['collected'])) { $cap['index']++; } $meta['__cap'] = $cap; ConversationContext::updateMetadata($ctxId, $meta); return $cap['index'] < count($fields) ? self::capAskNext($cap, $context['from'], $company, $ctxId) : self::capShowConfirmOrSubmit($cap, $meta, $context, $company, $ctxId); } // Toque sobre una opción: alterna sin salir de la lista $catalogo = $cap['__opciones'][$key] ?? []; if (isset($catalogo[$value])) { if (isset($elegidos[$value])) unset($elegidos[$value]); else $elegidos[$value] = $catalogo[$value]; $cap['__multi'][$key] = $elegidos; $meta['__cap'] = $cap; ConversationContext::updateMetadata($ctxId, $meta); return self::capAskNext($cap, $context['from'], $company, $ctxId); } } // ── 1b. "Ver más": avanza la página y vuelve a mostrar la misma lista ─ if ($value === '__mas') { $cap['__page'][$field['key']] = (int)($cap['__page'][$field['key']] ?? 0) + 1; $meta['__cap'] = $cap; ConversationContext::updateMetadata($ctxId, $meta); return self::capAskNext($cap, $context['from'], $company, $ctxId); } // ── 2. User chose "Otra opción" — ask for manual text ──────────────── if ($value === '__cap_other') { $cap['__awaiting_other'] = true; $meta['__cap'] = $cap; ConversationContext::updateMetadata($ctxId, $meta); $prompt = $field['other_prompt'] ?? '✏️ Ingresa el valor manualmente:'; return self::sendText("*{$cap['header']}*\n\n{$prompt}", $context['from'], $company); } // ── 3a. Ya se mostraron varias coincidencias: el usuario eligió una ── if (!empty($cap['__lookup_options']) && isset($cap['__lookup_options'][$value])) { $cap['collected'][$field['key']] = $value; $cap['collected_labels'][$field['key']] = $cap['__lookup_options'][$value]; unset($cap['__lookup_options']); $cap['index'] = $idx + 1; while ($cap['index'] < count($fields) && self::capShouldSkip($fields[$cap['index']], $cap['collected'])) { $cap['index']++; } $meta['__cap'] = $cap; ConversationContext::updateMetadata($ctxId, $meta); return $cap['index'] < count($fields) ? self::capAskNext($cap, $context['from'], $company, $ctxId) : self::capShowConfirmOrSubmit($cap, $meta, $context, $company, $ctxId); } // ── 3. Lookup field — search entity by entered value (e.g. cédula) ─── if (($field['type'] ?? 'text') === 'lookup') { unset($cap['__lookup_options']); $result = self::callLookupEndpoint( $field['lookup_endpoint_key'] ?? '', $value, $field['lookup_param'] ?? 'q', $company ); $vf = $field['value_field'] ?? 'id'; $df = $field['display_field'] ?? 'nombre'; // Catálogos como /empleados devuelven una lista; /diagnostico_buscar, un objeto. $esLista = is_array($result) && array_is_list($result); if ($esLista) { $result = self::acotarCoincidencias($result, $vf, $df); if (count($result) > 1) { $opciones = []; foreach ($result as $r) $opciones[(string)($r[$vf] ?? '')] = (string)($r[$df] ?? ''); $cap['__lookup_options'] = $opciones; $meta['__cap'] = $cap; ConversationContext::updateMetadata($ctxId, $meta); return self::buildDynamicListResponse($result, [ 'value_field' => $vf, 'label_field' => $df, 'header' => $cap['header'], 'body' => 'Encontré varias coincidencias. ¿Cuál es?', 'button' => 'Ver coincidencias', 'section_title' => $field['label'] ?? 'Resultados', ], $context['from'], $company); } $result = $result[0] ?? null; } if (!$result) { $notFound = $field['not_found_text'] ?? '⚠️ No encontré resultados. Intenta de nuevo:'; return self::sendText("*{$cap['header']}*\n\n{$notFound}", $context['from'], $company); } $displayName = (string)($result[$df] ?? 'Desconocido'); $entityId = (string)($result[$vf] ?? ''); $cap['__lookup_pending'] = ['id' => $entityId, 'label' => $displayName]; $meta['__cap'] = $cap; ConversationContext::updateMetadata($ctxId, $meta); return self::enqueueInteractive($context['from'], [ 'type' => 'button', 'body' => ['text' => "¿Es *{$displayName}*?"], 'action' => ['buttons' => [ ['type' => 'reply', 'reply' => ['id' => '__cap_lookup_yes', 'title' => '✅ Sí']], ['type' => 'reply', 'reply' => ['id' => '__cap_lookup_no', 'title' => '❌ No, reintentar']], ]], ], $company); } // ── 4. Normal save ──────────────────────────────────────────────────── if ($err = self::capValidar($field, $value, $cap['collected'])) { // El aviso va aparte para poder volver a mostrar el selector debajo WhatsAppSender::sendText($context['from'], $err, (string)($context['phone_number_id'] ?? '')); return self::capAskNext($cap, $context['from'], $company, $ctxId); } $cap['collected'][$field['key']] = $value; if (($field['type'] ?? 'text') === 'select') { $cap['collected_labels'][$field['key']] = $cap['__selects'][$field['key']][$value] ?? $value; } $cap['index'] = $idx + 1; while ($cap['index'] < count($fields) && self::capShouldSkip($fields[$cap['index']], $cap['collected'])) { $cap['index']++; } $meta['__cap'] = $cap; ConversationContext::updateMetadata($ctxId, $meta); if ($cap['index'] < count($fields)) { return self::capAskNext($cap, $context['from'], $company, $ctxId); } return self::capShowConfirmOrSubmit($cap, $meta, $context, $company, $ctxId); } private static function capShowConfirmOrSubmit(array $cap, array $meta, array $context, array $company, int $ctxId): ?array { $fields = $cap['fields']; if ($cap['confirm']) { $summary = "*{$cap['header']}*\n\n"; foreach ($cap['collected'] as $k => $v) { $label = self::capFieldLabel($fields, $k); $display = $cap['collected_labels'][$k] ?? $v; $summary .= "• {$label}: *{$display}*\n"; } $summary .= "\n¿Confirmas el registro?"; return self::enqueueInteractive($context['from'], [ 'type' => 'button', 'body' => ['text' => $summary], 'action' => ['buttons' => [ ['type' => 'reply', 'reply' => ['id' => '__cap_confirm', 'title' => '✅ Confirmar']], ['type' => 'reply', 'reply' => ['id' => '__cap_cancel', 'title' => '❌ Cancelar']], ]], ], $company); } return self::submitCapPost($meta, $context, $company, $ctxId); } /** * Una lista de WhatsApp admite pocas filas, y con 40 homónimos elegir no es * viable. Se recortan a 10 y se descartan los que no traigan id o etiqueta. */ private static function acotarCoincidencias(array $items, string $valueField, string $labelField): array { $validos = array_values(array_filter($items, fn($r) => is_array($r) && ($r[$valueField] ?? '') !== '' && ($r[$labelField] ?? '') !== '' )); return array_slice($validos, 0, 10); } private static function callLookupEndpoint(string $epKey, string $value, string $param, array $company): ?array { if ($epKey === '' || $value === '') return null; $stmt = db()->prepare('SELECT url FROM company_endpoints WHERE company_id=? AND endpoint_key=? AND is_active=1 LIMIT 1'); $stmt->execute([(int)$company['id'], $epKey]); $ep = $stmt->fetch(); if (!$ep || empty($ep['url'])) return null; $url = self::buildUrl($ep['url'], $company) . '&' . urlencode($param) . '=' . urlencode($value); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . ($company['api_key'] ?? ''), 'X-API-Key: ' . ($company['api_key'] ?? ''), ], ]); $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); return (is_array($data) && ($data['status'] ?? '') === '1') ? ($data['datos'] ?? null) : null; } private static function capAskNext(array $cap, string $to, array $company, int $ctxId = 0): ?array { // Saltar campos ya pre-llenados (no contiguos) $prefilled = $cap['prefilled'] ?? []; while ((int)$cap['index'] < count($cap['fields'])) { $f = $cap['fields'][(int)$cap['index']]; if (isset($prefilled[$f['key'] ?? '']) && !isset($cap['collected'][$f['key'] ?? ''])) { $cap['collected'][$f['key']] = $prefilled[$f['key']]; $cap['index']++; if ($ctxId > 0) { $m = ConversationContext::getMetadata($ctxId); $m['__cap'] = $cap; ConversationContext::updateMetadata($ctxId, $m); } } else { break; } } $idx = (int)$cap['index']; $total = count($cap['fields']); $field = $cap['fields'][$idx]; $type = $field['type'] ?? 'text'; // ── multi_select: acumula varias opciones antes de continuar ───────── if ($type === 'multi_select') { // Por defecto llega una lista corta con los mas relevantes; "Otro" // cambia al catalogo completo sin perder lo ya marcado. $verTodos = !empty($cap['__todos'][$field['key']]); $claveSrc = $verTodos && !empty($field['full_endpoint_key']) ? $field['full_endpoint_key'] : ($field['source_endpoint_key'] ?? ''); $items = self::fetchDynamicList( self::resolverPorCampos($claveSrc, $cap['collected'] ?? []), $company, ['__ep_vars_combined' => $cap['collected'] ?? []], $to ); if (empty($items)) { return self::sendText( "*{$cap['header']}*\n\n" . ($field['empty_text'] ?? '⚠️ No hay opciones disponibles para esta combinación.'), $to, $company ); } if ($ctxId > 0) { $cap['__opciones'][$field['key']] = array_column( $items, $field['label_field'] ?? 'label', $field['value_field'] ?? 'id' ); $m = ConversationContext::getMetadata($ctxId); $m['__cap'] = $cap; ConversationContext::updateMetadata($ctxId, $m); } return self::buildMultiSelectResponse($items, [ 'value_field' => $field['value_field'] ?? 'id', 'label_field' => $field['label_field'] ?? 'label', 'header' => $cap['header'], 'body' => $field['prompt'] ?? 'Puedes elegir varios:', 'section_title' => $field['label'] ?? $field['key'], '__page' => (int)($cap['__page'][$field['key']] ?? 0), '__ver_todos' => !$verTodos && !empty($field['full_endpoint_key']), ], $cap['__multi'][$field['key']] ?? [], $to, $company); } // ── select: fetch from API ─────────────────────────────────────────── if ($type === 'select') { $vf = $field['value_field'] ?? 'id'; $lf = $field['label_field'] ?? 'label'; $items = self::fetchDynamicList($field['source_endpoint_key'] ?? '', $company, ['__ep_vars_combined' => $cap['collected'] ?? []], $to); if (!empty($items)) { if ($ctxId > 0) { $cap['__selects'][$field['key']] = array_column($items, $lf, $vf); $m = ConversationContext::getMetadata($ctxId); $m['__cap'] = $cap; ConversationContext::updateMetadata($ctxId, $m); } return self::buildDynamicListResponse($items, [ 'value_field' => $vf, 'label_field' => $lf, 'header' => $cap['header'], 'body' => $field['prompt'] ?? ('Selecciona ' . ($field['label'] ?? $field['key'])), 'button' => 'Ver opciones', 'section_title' => $field['label'] ?? $field['key'], '__page' => (int)($cap['__page'][$field['key']] ?? 0), ], $to, $company); } } // ── date_quick: Hoy / Ayer / Anteayer / Otra ──────────────────────── if ($type === 'date_quick') { $hoy = date('Y-m-d'); $ayer = date('Y-m-d', strtotime('-1 day')); $ant = date('Y-m-d', strtotime('-2 days')); return self::enqueueInteractive($to, [ 'type' => 'list', 'header' => ['type' => 'text', 'text' => mb_substr($cap['header'], 0, 60)], 'body' => ['text' => $field['prompt'] ?? '📅 ¿Cuál es la fecha?'], 'footer' => ['text' => ''], 'action' => ['button' => 'Ver fechas', 'sections' => [[ 'title' => 'Fecha', 'rows' => [ ['id' => $hoy, 'title' => '📅 Hoy', 'description' => $hoy], ['id' => $ayer, 'title' => '📅 Ayer', 'description' => $ayer], ['id' => $ant, 'title' => '📅 Anteayer', 'description' => $ant], ['id' => '__cap_other', 'title' => '✏️ Otra fecha', 'description' => 'Escribe la fecha'], ], ]]], ], $company); } // ── static_select: inline options ─────────────────────────────────── if ($type === 'static_select') { $options = $field['options'] ?? []; $rows = array_map(fn($o) => [ 'id' => (string)$o['id'], 'title' => mb_substr((string)$o['label'], 0, 24), ], $options); if (count($rows) <= 3) { return self::enqueueInteractive($to, [ 'type' => 'button', 'body' => ['text' => "*{$cap['header']}*\n\n" . ($field['prompt'] ?? 'Selecciona:')], 'action' => ['buttons' => array_map(fn($r) => [ 'type' => 'reply', 'reply' => ['id' => $r['id'], 'title' => $r['title']], ], $rows)], ], $company); } return self::enqueueInteractive($to, [ 'type' => 'list', 'header' => ['type' => 'text', 'text' => mb_substr($cap['header'], 0, 60)], 'body' => ['text' => $field['prompt'] ?? 'Selecciona una opción:'], 'footer' => ['text' => ''], 'action' => ['button' => 'Ver opciones', 'sections' => [[ 'title' => $field['label'] ?? 'Opciones', 'rows' => $rows, ]]], ], $company); } // ── lookup / text: plain text prompt ──────────────────────────────── $prompt = $field['prompt'] ?? ('Campo: ' . $field['key']); $text = "*{$cap['header']}* ({$idx}/{$total})\n\n{$prompt}"; return self::sendText($text, $to, $company); } /** * Validación entre campos. min_field evita rangos invertidos (una fecha * final anterior a la inicial), que el ERP acepta y recién detecta el * revisor. Las fechas van en YYYY-MM-DD, así que comparar como texto basta. */ private static function capValidar(array $field, string $value, array $collected): ?string { $min = $field['min_field'] ?? ''; if ($min === '' || ($collected[$min] ?? '') === '') return null; if ($value >= $collected[$min]) return null; return $field['min_error'] ?? "⚠️ No puede ser anterior a *{$collected[$min]}*. Elige una fecha igual o posterior:"; } // skip_if: {"field":"novedad_id","not_in":["35","41"]} → skip this field if collected[field] not in list private static function capShouldSkip(array $field, array $collected): bool { $si = $field['skip_if'] ?? null; if (!$si) return false; $val = (string)($collected[$si['field'] ?? ''] ?? ''); if (isset($si['not_in'])) return !in_array($val, array_map('strval', $si['not_in'])); if (isset($si['equals'])) return $val !== (string)$si['equals']; return false; } private static function capFieldLabel(array $fields, string $key): string { foreach ($fields as $f) { if (($f['key'] ?? '') === $key) return $f['label'] ?? $f['key']; } return $key; } private static function submitCapPost(array $meta, array $context, array $company, int $ctxId): ?array { $cap = $meta['__cap']; $endpointKey = $cap['endpoint_key'] ?? ''; $nav = "\n\nEscribe *menú* para volver al inicio, *atrás* para subir un nivel o *salir* para terminar."; unset($meta['__cap']); ConversationContext::updateMetadata($ctxId, $meta); ConversationContext::reset($ctxId); if ($endpointKey === '') { return self::sendText('⚠️ Endpoint no configurado.' . $nav, $context['from'], $company); } $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 self::sendText('⚠️ Endpoint no encontrado. Contacta al administrador.' . $nav, $context['from'], $company); } $apiKey = $company['api_key'] ?? ''; $payload = array_merge($cap['collected'], [ 'telefono' => $context['from'], 'nombre' => $context['name'] ?? '', ]); // La URL puede depender de lo elegido (?peticion=ciclos_{ciclo}_up), así // seis tipos de ciclo comparten un solo endpoint registrado. $ch = curl_init(self::buildUrl(self::resolverPorCampos($ep['url'], $cap['collected']), $company)); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($payload), CURLOPT_TIMEOUT => 15, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Authorization: Bearer ' . $apiKey, 'X-API-Key: ' . $apiKey, ], ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlErr = curl_error($ch); curl_close($ch); if ($curlErr || $httpCode >= 400) { self::log("collect_and_post error [{$endpointKey}] HTTP {$httpCode}: {$curlErr}"); return self::sendText('⚠️ Error al guardar los datos. Intenta de nuevo.' . $nav, $context['from'], $company); } return self::sendText($cap['success_text'] . $nav, $context['from'], $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]; $config = self::getConfig($company); return ($config['menus'] ?? [])[$menuRef] ?? []; } return is_array($menuRef) ? $menuRef : []; } /** * Flows y menús efectivos del usuario (globales + overrides de su categoría). * Centraliza el merge que antes se repetía —y en un caso se olvidaba— en varios puntos. */ private static function resolveFlowsMenus(array $company): array { $cfg = self::getConfig($company); $permType = (string)($company['_permission_type'] ?? 1); $pt = $cfg['per_type'][$permType] ?? []; return [ array_merge($cfg['flows'] ?? [], $pt['flows'] ?? []), array_merge($cfg['menus'] ?? [], $pt['menus'] ?? []), ]; } /** * Si el flow necesita un dato que aún no está en meta, guarda la intención y * devuelve el selector que lo resuelve. Al elegir, handleDynamicList retoma * el destino original en vez de su next_node. */ private static function divertToResolver(array $flow, ?string $flowKey, array $context, array $company, int $ctxId, array $flows, array $menus): ?array { $requires = $flow['requires'] ?? []; $resolver = $flow['resolver'] ?? ''; if (!$requires || $resolver === '' || $flowKey === null || !isset($flows[$resolver])) return null; $meta = ConversationContext::getMetadata($ctxId); foreach ($requires as $group => $key) { if (($meta[$group][$key] ?? '') !== '') continue; // Un solo intento por destino: si el resolver ya corrió y aun así falta // el dato (mal configurado), seguir de largo en vez de rebotar sin fin. if (($meta['__resolve_attempt'] ?? '') === $flowKey) { self::log("Resolver '{$resolver}' no dejó {$group}.{$key} para '{$flowKey}'"); return null; } $meta['__after_select'] = $flowKey; $meta['__resolve_attempt'] = $flowKey; ConversationContext::updateMetadata($ctxId, $meta); ConversationContext::updateNode($ctxId, $resolver); return self::handleFlow($flows[$resolver], $context, $company, $ctxId, $menus, $resolver); } // Requisitos cumplidos: se limpia la marca para el próximo informe if (isset($meta['__resolve_attempt'])) { unset($meta['__resolve_attempt']); ConversationContext::updateMetadata($ctxId, $meta); } return null; } private static function handleFlow(array $flow, array $context, array $company, int $ctxId, array $menus = [], ?string $flowKey = null): ?array { $type = $flow['type'] ?? 'text'; if (!empty($flow['requires'])) { [$allFlows, $allMenus] = self::resolveFlowsMenus($company); $diverted = self::divertToResolver($flow, $flowKey, $context, $company, $ctxId, $allFlows, $allMenus); if ($diverted !== null) return $diverted; } 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, $ctxId), '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), 'collect_for_each' => self::handleCollectForEach($flow, $context, $company, $ctxId), 'collect_and_post' => self::handleCollectAndPost($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) { 'reset' => (function () use ($ctxId, $context, $company) { ConversationContext::reset($ctxId); return self::sendText('¿En qué más puedo ayudarte?', $context['from'], $company); })(), 'goodbye' => (function () use ($ctxId, $context, $company) { ConversationContext::reset($ctxId); return self::sendText("Hasta luego 👋\n\nEscribe *menu* cuando quieras volver.", $context['from'], $company); })(), 'reset_finca' => self::reAsk($ctxId, $context, $company, 'finca', 'ask_finca', '¿En qué finca trabajas?'), 'reset_grupo_mant' => self::reAsk($ctxId, $context, $company, 'grupo_mant', 'ciclo_mantenimiento', '¿Qué grupo de mantenimiento?'), 'forward_to_ai' => null, 'api_report' => self::executeApiReport($params, $context, $company, $ctxId), default => null, }; } /** Olvida un valor ya elegido y vuelve a preguntarlo ("cambiar finca", "otro grupo"). */ private static function reAsk(int $ctxId, array $context, array $company, string $group, string $flowKey, string $fallbackText): ?array { $meta = ConversationContext::getMetadata($ctxId); unset($meta[$group], $meta['__after_select']); ConversationContext::updateMetadata($ctxId, $meta); [$flows, $menus] = self::resolveFlowsMenus($company); ConversationContext::updateNode($ctxId, $flowKey); return isset($flows[$flowKey]) ? self::handleFlow($flows[$flowKey], $context, $company, $ctxId, $menus, $flowKey) : self::sendText($fallbackText, $context['from'], $company); } // Reset manteniendo grupos de sesión (finca, etc.) para que el usuario // no tenga que re-seleccionar su finca después de cada reporte. private static function preservingReset(int $ctxId, array $meta): void { $keep = []; // finca y grupo_mant se conservan: los submenús ofrecen varios informes sobre // la misma selección, y perderla dejaba {finca_id}/{grupo_id} sin resolver. // __saludo también, si no se re-saluda después de cada informe. foreach (['finca', 'grupo_mant', '__saludo'] as $group) { if (isset($meta[$group])) $keep[$group] = $meta[$group]; } ConversationContext::reset($ctxId); if (!empty($keep)) { ConversationContext::updateMetadata($ctxId, $keep); } // Sesión ya iniciada, no nueva: con current_node = null los pasos de // bienvenida se redisparan y el texto libre nunca llega al NLU. ConversationContext::updateNode($ctxId, '__greeted'); } // ── Resolves fixed-mode var values ─────────────────────────────────────── private static function resolveFixedVar(string $value): string { return match ($value) { 'current_date' => date('Y-m-d'), 'month_start' => date('Y-m-01'), 'month_end' => date('Y-m-t'), 'last_7_start' => date('Y-m-d', strtotime('-7 days')), 'last_30_start' => date('Y-m-d', strtotime('-30 days')), 'year_start' => date('Y-01-01'), default => $value, }; } // ── Handles sequential collection of URL vars before calling endpoint ─── private static function handleApiVarsInput(array $meta, string $input, array $context, array $company, int $ctxId, array $flows, array $menus): ?array { $nav = "\n\nEscribe *menú* para volver al inicio, *atrás* para subir un nivel o *salir* para terminar."; $state = $meta['__api_vars']; $pending = $state['pending'] ?? []; $collected = $state['collected'] ?? []; $epKey = $state['endpoint_key'] ?? ''; $epParams = $state['ep_params'] ?? []; // Save the user's answer for the current question if (!empty($pending)) { $current = array_shift($pending); $collected[$current['key']] = trim($input); $state['pending'] = $pending; $state['collected'] = $collected; } // If more questions remain, ask the next one if (!empty($state['pending'])) { $next = $state['pending'][0]; $meta['__api_vars'] = $state; ConversationContext::updateMetadata($ctxId, $meta); return self::sendText($next['prompt'] . $nav, $context['from'], $company); } // All vars collected — inject into meta and execute unset($meta['__api_vars']); foreach ($collected as $k => $v) { $meta['__ep_vars'][$k] = $v; } ConversationContext::updateMetadata($ctxId, $meta); return self::executeApiReport(array_merge($epParams, ['endpoint_key' => $epKey]), $context, $company, $ctxId); } private static function executeApiReport(array $params, array $context, array $company, int $ctxId): ?array { $endpointKey = $params['endpoint_key'] ?? ''; $nav = "\n\nEscribe *menú* para volver al inicio, *atrás* para subir un nivel o *salir* para terminar."; if ($endpointKey === '') { self::preservingReset($ctxId, ConversationContext::getMetadata($ctxId)); return self::sendText('Error: endpoint no configurado.' . $nav, $context['from'], $company); } $stmt = db()->prepare("SELECT url, method, params 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'])) { self::log("API report: endpoint_key '{$endpointKey}' no configurado para company {$company['id']}"); self::preservingReset($ctxId, ConversationContext::getMetadata($ctxId)); return self::sendText('El informe solicitado no está configurado. Contacta al administrador.' . $nav, $context['from'], $company); } // ── Resolve URL vars from params config ────────────────────────────── $varConfigs = json_decode($ep['params'] ?? '[]', true) ?: []; $meta = ConversationContext::getMetadata($ctxId); // Inject any previously collected vars into meta for substituteUrlVars $epVars = $meta['__ep_vars'] ?? []; if (!empty($epVars)) { $meta['__ep_vars_flat'] = $epVars; } $askVars = []; foreach ($varConfigs as $vc) { $key = $vc['key'] ?? ''; $mode = $vc['mode'] ?? 'ask'; if ($key === '') continue; if ($mode === 'fixed') { // Inject fixed value directly into meta for substituteUrlVars $meta['__ep_vars_flat'][$key] = self::resolveFixedVar($vc['value'] ?? ''); } elseif ($mode === 'ask') { // Only ask if not already collected if (!isset($epVars[$key])) { $askVars[] = ['key' => $key, 'prompt' => $vc['prompt'] ?? "Ingresa el valor para {$key}:"]; } else { $meta['__ep_vars_flat'][$key] = $epVars[$key]; } } } // If there are vars to ask, start collection flow if (!empty($askVars)) { $first = array_shift($askVars); $meta['__api_vars'] = [ 'endpoint_key' => $endpointKey, 'ep_params' => $params, 'pending' => $askVars, 'collected' => [], ]; // Clean collected vars so they don't bleed into the next call unset($meta['__ep_vars']); ConversationContext::updateMetadata($ctxId, $meta); return self::sendText($first['prompt'] . $nav, $context['from'], $company); } // Flatten collected vars into meta for substituteUrlVars if (!empty($meta['__ep_vars_flat'])) { $meta['__ep_vars_combined'] = $meta['__ep_vars_flat']; } unset($meta['__ep_vars'], $meta['__ep_vars_flat']); ConversationContext::updateMetadata($ctxId, $meta); $url = self::buildUrl( self::substituteUrlVars($ep['url'], array_merge($meta, ['__ep_vars_combined' => $meta['__ep_vars_combined'] ?? []])), $company ); // Placeholder sin resolver → la API recibiría basura y devolvería un informe // vacío sin explicación. Mejor pedir el dato que falta. if (preg_match('/\{(\w+)\}/', $url, $ph)) { self::log("API report: falta '{$ph[1]}' para [{$endpointKey}] — url: {$url}"); self::preservingReset($ctxId, ConversationContext::getMetadata($ctxId)); return self::sendText('Falta seleccionar un dato para generar este informe.' . $nav, $context['from'], $company); } $method = strtoupper($ep['method'] ?? 'GET'); $apiKey = $company['api_key'] ?? ''; $extraQuery = $params['query'] ?? []; // Quién pregunta. La API autentica por token de empresa, así que sin esto // el ERP ve "Rosa Blanca pide producción" y no puede acotar la respuesta // a las fincas que ese número tiene asignadas. Va acá y no en cada URL // del seed porque los treinta informes pasan por esta función. if (($context['from'] ?? '') !== '') { $extraQuery['wa'] = $context['from']; } $dateMode = $params['date_mode'] ?? ''; if ($dateMode === 'today') { $extraQuery['fecha_desde'] = date('Y-m-d'); $extraQuery['fecha_hasta'] = date('Y-m-d'); } elseif ($dateMode === 'last_30') { $extraQuery['fecha_desde'] = date('Y-m-d', strtotime('-30 days')); $extraQuery['fecha_hasta'] = date('Y-m-d'); } elseif ($dateMode === 'current_month') { $extraQuery['fecha_desde'] = date('Y-m-01'); $extraQuery['fecha_hasta'] = date('Y-m-d'); } elseif ($dateMode === 'last_month') { $extraQuery['fecha_desde'] = date('Y-m-01', strtotime('first day of last month')); $extraQuery['fecha_hasta'] = date('Y-m-t', strtotime('last day of last month')); } elseif ($dateMode === 'last_7') { $extraQuery['fecha_desde'] = date('Y-m-d', strtotime('-7 days')); $extraQuery['fecha_hasta'] = date('Y-m-d'); } $extraQuery['telefono'] = $context['from']; $extraQuery['nombre'] = $context['name'] ?? ''; $glue = str_contains($url, '?') ? '&' : '?'; $url .= $glue . http_build_query($extraQuery); $ch = curl_init($url); $curlOpts = [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 15, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'X-API-Key: ' . $apiKey, 'User-Agent: bot-palmas360/1.0', ], ]; if ($method === 'POST') { $curlOpts[CURLOPT_POST] = true; $curlOpts[CURLOPT_POSTFIELDS] = '{}'; } curl_setopt_array($ch, $curlOpts); $content = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); $error = curl_error($ch); curl_close($ch); if ($error) { self::log("API report error [{$endpointKey}]: {$error}"); self::preservingReset($ctxId, $meta); return self::sendText('Error al obtener el reporte. Intenta de nuevo.' . $nav, $context['from'], $company); } if ($httpCode >= 400) { self::log("API report HTTP {$httpCode} [{$endpointKey}]: " . mb_substr($content, 0, 200)); self::preservingReset($ctxId, $meta); return self::sendText('Error al obtener el reporte. Intenta de nuevo.' . $nav, $context['from'], $company); } // Si el ERP devuelve JSON (ej: sin_datos o texto), informar al usuario en texto if (str_starts_with($contentType ?? '', 'application/json')) { $json = json_decode($content, true); $msg = $json['mensaje'] ?? ($json['message'] ?? 'No hay datos disponibles para el período solicitado.'); $nextKey = $params['next_endpoint_key'] ?? ''; if ($nextKey !== '' && ($json['status'] ?? '0') === '1') { // Enviar texto primero directamente, luego retornar el PDF/Excel $phoneId = $context['phone_number_id'] ?? ($company['phone_number_id'] ?? ''); WhatsAppSender::sendText($context['from'], $msg, $phoneId); $nextParams = array_merge($params, [ 'endpoint_key' => $nextKey, 'caption' => $params['next_caption'] ?? '', 'filename' => $params['next_filename'] ?? 'reporte.pdf', ]); unset($nextParams['next_endpoint_key'], $nextParams['next_caption'], $nextParams['next_filename']); return self::executeApiReport($nextParams, $context, $company, $ctxId); } self::preservingReset($ctxId, $meta); return self::sendText('ℹ️ ' . $msg . $nav, $context['from'], $company); } $mimeMap = [ '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'; $mime = 'application/pdf'; foreach ($mimeMap as $m => $info) { if (str_starts_with($contentType ?? '', $m)) { [$ext, $mime] = $info; break; } } $phoneNumberId = $company['phone_number_id'] ?? env('WHATSAPP_PHONE_NUMBER_ID', ''); if ($phoneNumberId === '') { return self::sendText('Error: canal de envío no configurado.', $context['from'], $company); } $tmpFile = sys_get_temp_dir() . '/report_' . bin2hex(random_bytes(8)) . '.' . $ext; file_put_contents($tmpFile, $content); $upload = WhatsAppSender::uploadMedia($tmpFile, $mime, $phoneNumberId); if (!$upload['success'] || !$upload['media_id']) { self::log("API report upload failed [{$endpointKey}]: " . ($upload['error'] ?? 'unknown')); self::preservingReset($ctxId, $meta); return self::sendText('Error al enviar el reporte. Intenta de nuevo.' . $nav, $context['from'], $company); } $reportName = $params['filename'] ?? ('reporte.' . $ext); $caption = $params['caption'] ?? 'Aquí tienes el reporte solicitado.'; WhatsAppSender::sendDocument($context['from'], $upload['media_id'], $phoneNumberId, $caption, $reportName); self::preservingReset($ctxId, $meta); return self::sendText('✅ Reporte generado.' . $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); } // collect_and_post: confirm/cancel, lookup confirm/reject, or list/button field pick if (!empty($meta['__cap'])) { if ($input === '__cap_confirm') { return self::submitCapPost($meta, $context, $company, $ctxId); } if ($input === '__cap_cancel') { $m = ConversationContext::getMetadata($ctxId); unset($m['__cap']); ConversationContext::updateMetadata($ctxId, $m); ConversationContext::reset($ctxId); return self::sendText("❌ Registro cancelado.\n\nEscribe *menu* para volver.", $context['from'], $company); } // Lookup confirmation buttons if (!empty($meta['__cap']['__lookup_pending'])) { if ($input === '__cap_lookup_yes') { $cap = $meta['__cap']; $fields = $cap['fields']; $idx = (int)$cap['index']; $field = $fields[$idx]; $pending = $cap['__lookup_pending']; $cap['collected'][$field['key']] = $pending['id']; $cap['collected_labels'][$field['key']] = $pending['label']; unset($cap['__lookup_pending']); $cap['index'] = $idx + 1; while ($cap['index'] < count($fields) && self::capShouldSkip($fields[$cap['index']], $cap['collected'])) { $cap['index']++; } $meta['__cap'] = $cap; ConversationContext::updateMetadata($ctxId, $meta); if ($cap['index'] < count($fields)) { return self::capAskNext($cap, $context['from'], $company, $ctxId); } return self::capShowConfirmOrSubmit($cap, $meta, $context, $company, $ctxId); } if ($input === '__cap_lookup_no') { $cap = $meta['__cap']; unset($cap['__lookup_pending']); $meta['__cap'] = $cap; ConversationContext::updateMetadata($ctxId, $meta); return self::capAskNext($cap, $context['from'], $company, $ctxId); } } // Interactive list/button response for select, date_quick, static_select fields return self::handleCapInput($meta, $input, $context, $company, $ctxId); } // collect_for_each confirmation (confirm/cancel button) if (!empty($meta['__foreach'])) { if ($input === '__foreach_confirm') { return self::submitForeach($meta, $context, $company, $ctxId); } if ($input === '__foreach_cancel') { $meta2 = ConversationContext::getMetadata($ctxId); unset($meta2['__foreach']); ConversationContext::updateMetadata($ctxId, $meta2); ConversationContext::reset($ctxId); return self::sendText("❌ Registro cancelado.\n\nEscribe *menu* para volver.", $context['from'], $company); } // La fecha previa al bucle se elige de una lista, así que llega por acá // y no como texto. Sin esto el flujo se quedaba mudo tras elegirla. $fe = $meta['__foreach']; if (!empty($fe['ask_date']) && ($fe['fecha'] ?? null) === null) { return self::handleForeachInput($meta, $input, $context, $company, $ctxId, $flows); } } // 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, $row['id']); } 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, $btn['id']); } return null; } } } // Direct flow match (button id == flow key) // Redirect show_main_menu → per-type greeting_menu if configured $resolvedInput = $input; $greetingMenuKey = $perType['greeting_menu'] ?? null; if ($resolvedInput === 'show_main_menu' && $greetingMenuKey && isset($menus[$greetingMenuKey])) { $resolvedInput = $greetingMenuKey; } if (isset($flows[$resolvedInput])) { ConversationContext::updateNode($ctxId, $resolvedInput); return self::handleFlow($flows[$resolvedInput], $context, $company, $ctxId, $menus, $resolvedInput); } if (isset($menus[$resolvedInput])) { ConversationContext::updateNode($ctxId, $resolvedInput); return self::buildMenuResponse($menus[$resolvedInput], $context['from'], $company, $ctxId); } return null; } // ── WhatsApp senders ───────────────────────────────────────────────────── /** * Resuelve {finca} en los textos del menú y deja la finca activa en el footer, * para que el usuario sepa siempre sobre qué está consultando. */ private static function aplicarVarsMenu(array $menu, ?int $ctxId, array $company): array { if ($ctxId === null) return $menu; $meta = ConversationContext::getMetadata($ctxId); $finca = (string)($meta['finca']['finca_label'] ?? ''); foreach (['header', 'body', 'footer'] as $campo) { if (isset($menu[$campo]) && is_string($menu[$campo])) { $menu[$campo] = str_replace('{finca}', $finca, $menu[$campo]); } } // Sin finca (cat 1 solo reporta) queda el footer de siempre if ($finca !== '' && !str_contains((string)($menu['footer'] ?? ''), $finca)) { $menu['footer'] = '📍 ' . $finca; } return $menu; } private static function buildMenuResponse(array $menu, string $to, array $company, ?int $ctxId = null): ?array { $menu = self::filtrarMenuPorModulos($menu, $company, $to); $menu = self::aplicarVarsMenu($menu, $ctxId, $company); $menuType = $menu['type'] ?? 'list'; if ($menuType === 'list') { $interactive = [ '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)], 'action' => [ 'button' => mb_substr($menu['button'] ?? 'Ver opciones', 0, 20), 'sections' => [], ], ]; foreach ($menu['sections'] ?? [] as $section) { $rows = []; foreach ($section['rows'] ?? [] as $row) { $rows[] = [ '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, ]; } return self::enqueueInteractive($to, $interactive, $company); } if ($menuType === 'button') { $buttons = []; foreach (array_slice($menu['buttons'] ?? [], 0, 3) as $btn) { $buttons[] = [ 'type' => 'reply', 'reply' => [ '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)], 'action' => ['buttons' => $buttons], ]; // Los submenús de ciclos son de botones: sin footer no se veía la finca if (($menu['footer'] ?? '') !== '') { $interactive['footer'] = ['text' => mb_substr($menu['footer'], 0, 60)]; } return self::enqueueInteractive($to, $interactive, $company); } 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])]; } 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])]; } private static function enqueueInteractive(string $to, array $interactive, array $company): array { return ['action' => 'send', 'type' => 'interactive', 'to' => $to, 'payload' => json_encode(['interactive' => $interactive])]; } private static function getConfig(array $company): array { $json = $company['config_json'] ?? ''; $config = $json !== '' ? json_decode($json, true) : null; return is_array($config) ? $config : []; } private static function buildUrl(string $relOrAbs, array $company): string { if ($relOrAbs === '' || str_starts_with($relOrAbs, 'http')) return $relOrAbs; return rtrim($company['api_base_url'] ?? '', '/') . '/' . ltrim($relOrAbs, '/'); } private static function normalize(string $input): string { $input = mb_strtolower(trim($input)); $input = str_replace(['á','é','í','ó','ú','ü','ñ'], ['a','e','i','o','u','u','n'], $input); return preg_replace('/[^a-z0-9\s]/', '', $input); } private static function log(string $msg): void { if (class_exists('WpWebhook')) WpWebhook::log('NORMALBOT', $msg); } }