$action) { if ($normalized === self::normalize((string)$keyword)) { ConversationContext::updateNode($ctxId, $action); // Commands that match a flow if (isset($flows[$action])) { return self::handleFlow($flows[$action], $context, $company, $ctxId, $menus); } // Commands that match a menu if (isset($menus[$action])) { return self::buildMenuResponse($menus[$action], $context['from'], $company); } return null; } } // 2. Multi-step form: user is currently filling in fields $meta = ConversationContext::getMetadata($ctxId); if (!empty($meta['collecting'])) { return self::handleCollectingInput($meta, $input, 'text', $context, $company, $ctxId, $flows, $menus); } // 3. Active node — resume conversation $sentinels = ['collecting', '__greeted']; if ($currentNode !== null && !in_array($currentNode, $sentinels, true) && isset($flows[$currentNode])) { return self::handleFlow($flows[$currentNode], $context, $company, $ctxId, $menus); } // 4. 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 ($greetingMenuKey !== null && isset($menus[$greetingMenuKey])) { ConversationContext::updateNode($ctxId, '__greeted'); return self::buildMenuResponse($menus[$greetingMenuKey], $context['from'], $company); } // 8. Static fallback text $fallback = $perType['fallback'] ?? $config['fallback'] ?? null; if ($fallback !== null) { ConversationContext::updateNode($ctxId, null); 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; $formData = $meta[$group] ?? []; $formData[$key] = trim($input); $meta[$group] = $formData; unset($meta['collecting']); ConversationContext::updateMetadata($ctxId, $meta); if ($nextNode !== null) { ConversationContext::updateNode($ctxId, $nextNode); if (isset($flows[$nextNode])) { return self::handleFlow($flows[$nextNode], $context, $company, $ctxId, $menus); } } else { ConversationContext::updateNode($ctxId, null); } return null; } private static function handleCollectInput(array $flow, array $context, array $company, int $ctxId): ?array { $meta = ConversationContext::getMetadata($ctxId); $meta['collecting'] = [ 'meta_group' => $flow['meta_group'] ?? '', 'meta_key' => $flow['meta_key'] ?? '', 'next_node' => $flow['next_node'] ?? null, ]; ConversationContext::updateMetadata($ctxId, $meta); ConversationContext::updateNode($ctxId, 'collecting'); return self::sendText($flow['prompt'] ?? '¿Cuál es el valor?', $context['from'], $company); } // ── Dynamic list (from endpoint) ───────────────────────────────────────── private static function handleDynamicList(array $flow, array $context, array $company, int $ctxId): ?array { $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 ); } $meta['collecting'] = [ 'meta_group' => $flow['meta_group'] ?? '', 'meta_key' => $flow['meta_key'] ?? '', 'next_node' => $flow['next_node'] ?? null, ]; ConversationContext::updateMetadata($ctxId, $meta); ConversationContext::updateNode($ctxId, 'collecting'); return self::buildDynamicListResponse($items, $flow, $context['from'], $company); } // Reemplaza {variable} en la URL con valores del metadata acumulado private static function substituteUrlVars(string $url, array $meta): string { foreach ($meta as $group => $fields) { if (!is_array($fields)) 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::substituteUrlVars($ep['url'], $meta); $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 both top-level array and {data: [...]} return isset($data['data']) && is_array($data['data']) ? $data['data'] : $data; } private static function buildDynamicListResponse(array $items, array $flow, string $to, array $company): ?array { $valueField = $flow['value_field'] ?? 'id'; $labelField = $flow['label_field'] ?? 'name'; $rows = []; foreach (array_slice($items, 0, 10) as $item) { $id = (string)($item[$valueField] ?? ''); $title = mb_substr((string)($item[$labelField] ?? $id), 0, 24); if ($id === '') continue; $rows[] = ['id' => $id, 'title' => $title]; } if (empty($rows)) return null; $interactive = [ 'type' => 'list', 'header' => ['type' => 'text', 'text' => mb_substr($flow['header'] ?? 'Selecciona', 0, 60)], 'body' => ['text' => mb_substr($flow['body'] ?? 'Elige una opción:', 0, 1024)], 'footer' => ['text' => mb_substr($company['display_name'] ?? '', 0, 60)], 'action' => [ 'button' => mb_substr($flow['button'] ?? 'Ver opciones', 0, 20), 'sections' => [[ 'title' => mb_substr($flow['section_title'] ?? 'Opciones', 0, 24), 'rows' => $rows, ]], ], ]; return self::enqueueInteractive($to, $interactive, $company); } // ── Submit form (end of multi-step collection) ─────────────────────────── private static function handleSubmitForm(array $flow, array $context, array $company, int $ctxId): ?array { $meta = ConversationContext::getMetadata($ctxId); $group = $flow['meta_group'] ?? ''; $formData = $meta[$group] ?? []; unset($meta[$group], $meta['collecting']); ConversationContext::updateMetadata($ctxId, $meta); $params = [ 'endpoint_key' => $flow['endpoint_key'] ?? '', 'caption' => $flow['caption'] ?? 'Informe generado.', 'filename' => $flow['filename'] ?? 'reporte.pdf', 'query' => $formData, // form fields become URL query params ]; return self::executeApiReport($params, $context, $company, $ctxId); } // ── Flow dispatcher ────────────────────────────────────────────────────── private static function resolveMenu($menuRef, array $company, array $allMenus = []): array { if (is_string($menuRef)) { if (isset($allMenus[$menuRef])) return $allMenus[$menuRef]; $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), 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, }; } 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 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); } $meta = ConversationContext::getMetadata($ctxId); $url = self::substituteUrlVars($ep['url'], $meta); // reemplaza {var} con valores del formulario $method = strtoupper($ep['method'] ?? 'GET'); $apiKey = $company['api_key'] ?? ''; $extraQuery = $params['query'] ?? []; $dateMode = $params['date_mode'] ?? ''; if ($dateMode === 'today') { $extraQuery['fecha'] = date('Y-m-d'); } elseif ($dateMode === 'last_30') { $extraQuery['fecha_inicio'] = date('Y-m-d', strtotime('-30 days')); $extraQuery['fecha_fin'] = date('Y-m-d'); } $extraQuery['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); } $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 enviado.' . $nav, $context['from'], $company); } // ── Public helpers ─────────────────────────────────────────────────────── public static function buildGreetingMenu(array $menu, string $to, array $company): ?array { return self::buildMenuResponse($menu, $to, $company); } // ── Interactive messages ───────────────────────────────────────────────── public static function processInteractive(array $company, array $context, string $input): ?array { $config = self::getConfig($company); $permType = (string)($company['_permission_type'] ?? 1); $perType = $config['per_type'][$permType] ?? []; $flows = array_merge($config['flows'] ?? [], $perType['flows'] ?? []); $menus = array_merge($config['menus'] ?? [], $perType['menus'] ?? []); $botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'normal'); $ctxId = (int)$botCtx['id']; // Multi-step form: interactive selection is the collected value $meta = ConversationContext::getMetadata($ctxId); if (!empty($meta['collecting'])) { return self::handleCollectingInput($meta, $input, 'interactive', $context, $company, $ctxId, $flows, $menus); } // Standard button/row resolution against static menus foreach ($flows as $flowId => $flow) { if (($flow['type'] ?? '') !== 'menu') continue; $menu = self::resolveMenu($flow['menu'] ?? [], $company, $menus); foreach ($menu['sections'] ?? [] as $section) { foreach ($section['rows'] ?? [] as $row) { if (($row['id'] ?? '') === $input) { ConversationContext::updateNode($ctxId, $row['id']); if (isset($flows[$row['id']])) { return self::handleFlow($flows[$row['id']], $context, $company, $ctxId, $menus); } return null; } } } foreach ($menu['buttons'] ?? [] as $btn) { if (($btn['id'] ?? '') === $input) { ConversationContext::updateNode($ctxId, $btn['id']); if (isset($flows[$btn['id']])) { return self::handleFlow($flows[$btn['id']], $context, $company, $ctxId, $menus); } return null; } } } // Direct flow match (button id == flow key) if (isset($flows[$input])) { ConversationContext::updateNode($ctxId, $input); return self::handleFlow($flows[$input], $context, $company, $ctxId, $menus); } return null; } // ── WhatsApp senders ───────────────────────────────────────────────────── private static function buildMenuResponse(array $menu, string $to, array $company): ?array { $menuType = $menu['type'] ?? 'list'; if ($menuType === 'list') { $interactive = [ 'type' => 'list', '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 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); } }