$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'] as $stateKey) { if (isset($m[$stateKey])) { unset($m[$stateKey]); $dirty = true; } } if ($dirty) ConversationContext::updateMetadata($ctxId, $m); // "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); } // Commands that match a menu if (isset($menus[$action])) { return self::buildMenuResponse($menus[$action], $context['from'], $company); } 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 $sentinels = ['collecting', '__greeted']; if ($currentNode !== null && !in_array($currentNode, $sentinels, true) && isset($flows[$currentNode])) { return self::handleFlow($flows[$currentNode], $context, $company, $ctxId, $menus); } // 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); } // 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); } // 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); } $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); } // 7. 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); } // 8. 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; } $formData = $meta[$group] ?? []; $formData[$key] = $resolved; $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 { $meta = ConversationContext::getMetadata($ctxId); $items = self::fetchDynamicList($flow['source_endpoint_key'] ?? '', $company, $meta); 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 ); } $valueField = $flow['value_field'] ?? 'id'; $labelField = $flow['label_field'] ?? 'name'; $group = $flow['meta_group'] ?? ''; $key = $flow['meta_key'] ?? ''; $nextNode = $flow['next_node'] ?? null; // 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] ?? ''); } // Si el NLU extrajo entities, intentar auto-seleccionar sin mostrar lista $entities = $meta['__nlu_entities'] ?? []; unset($meta['__nlu_entities']); if (!empty($entities)) { $matchedId = null; foreach ($entities as $ev) { $evNorm = self::normalize((string)$ev); foreach ($labelMap as $label => $id) { $lNorm = self::normalize($label); if ($lNorm === $evNorm || str_contains($evNorm, $lNorm) || str_contains($lNorm, $evNorm)) { $matchedId = $id; break 2; } } } if ($matchedId !== null) { $formData = $meta[$group] ?? []; $formData[$key] = $matchedId; $meta[$group] = $formData; ConversationContext::updateMetadata($ctxId, $meta); if ($nextNode !== null) { ConversationContext::updateNode($ctxId, $nextNode); $config = json_decode($company['config_json'] ?? '{}', true); $flows = $config['flows'] ?? []; $menus = $config['menus'] ?? []; if (isset($flows[$nextNode])) { return self::handleFlow($flows[$nextNode], $context, $company, $ctxId, $menus); } } ConversationContext::updateNode($ctxId, null); return null; } // Sin match: continuar mostrando la lista normal ConversationContext::updateMetadata($ctxId, $meta); } $meta['collecting'] = [ 'meta_group' => $group, 'meta_key' => $key, 'next_node' => $nextNode, '__valid_ids' => array_column($items, $valueField), '__label_map' => $labelMap, ]; ConversationContext::updateMetadata($ctxId, $meta); ConversationContext::updateNode($ctxId, 'collecting'); return self::buildDynamicListResponse($items, $flow, $context['from'], $company); } // 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 = []): ?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); $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; } 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 { $rows = []; foreach (array_slice($items, 0, 10) as $item) { $id = (string)($item[$valueField] ?? ''); if ($id === '') continue; $rows[] = ['id' => $id, 'title' => mb_substr((string)($item[$labelField] ?? $id), 0, 24)]; } $sections = [['title' => mb_substr($flow['section_title'] ?? 'Opciones', 0, 24), 'rows' => $rows]]; } if (empty($sections)) return null; 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); 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.', ]; ConversationContext::updateMetadata($ctxId, $meta); return self::foreachAskNext($meta['__foreach'], $context['from'], $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']; // 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\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 *menu* para ver más opciones."; // Build POST body: array of {id, valor} objects $body = []; foreach ($fe['collected'] as $id => $row) { $body[] = ['id' => $id, 'label' => $row['label'], 'valor' => $row['valor']]; } 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; 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++; } } $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 ); } $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); } // ── 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); } // ── 3. Lookup field — search entity by entered value (e.g. cédula) ─── if (($field['type'] ?? 'text') === 'lookup') { $result = self::callLookupEndpoint( $field['lookup_endpoint_key'] ?? '', $value, $field['lookup_param'] ?? 'q', $company ); if ($result === null) { $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[$field['display_field'] ?? 'nombre'] ?? 'Desconocido'); $entityId = (string)($result[$field['value_field'] ?? 'id'] ?? ''); $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 ──────────────────────────────────────────────────── $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); } 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'; // ── 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'] ?? []]); 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'], ], $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); } // 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 *menu* para ver más opciones."; 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'] ?? '', ]); $ch = curl_init(self::buildUrl($ep['url'], $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 : []; } private static function handleFlow(array $flow, array $context, array $company, int $ctxId, array $menus = []): ?array { $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), '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); })(), 'forward_to_ai' => null, 'api_report' => self::executeApiReport($params, $context, $company, $ctxId), default => null, }; } // ── 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 *menu* para ver opciones 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 *menu* para ver más opciones o *salir* para terminar."; if ($endpointKey === '') { ConversationContext::reset($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']}"); ConversationContext::reset($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 ); $method = strtoupper($ep['method'] ?? 'GET'); $apiKey = $company['api_key'] ?? ''; $extraQuery = $params['query'] ?? []; $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')); } $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}"); ConversationContext::reset($ctxId); 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)); ConversationContext::reset($ctxId); return self::sendText('Error al obtener el reporte. Intenta de nuevo.' . $nav, $context['from'], $company); } // Si el ERP devuelve JSON (ej: sin_datos), 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.'); ConversationContext::reset($ctxId); 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')); ConversationContext::reset($ctxId); 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); ConversationContext::reset($ctxId); 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); } } // 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) // 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); } if (isset($menus[$resolvedInput])) { ConversationContext::updateNode($ctxId, $resolvedInput); return self::buildMenuResponse($menus[$resolvedInput], $context['from'], $company); } 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', '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], ]; 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); } }