diff --git a/scripts/apply-menus.php b/scripts/apply-menus.php new file mode 100644 index 0000000..7cef5c8 --- /dev/null +++ b/scripts/apply-menus.php @@ -0,0 +1,137 @@ + [ + 'menu' => 'show_main_menu', + 'informes' => 'descargar_informes', + 'info' => 'recibir_info', + 'inicio' => 'show_main_menu', + 'volver' => 'show_main_menu', + ], + 'menus' => [ + 'show_main_menu' => [ + 'type' => 'list', + 'header' => 'Menú Principal', + 'body' => 'Selecciona una opción:', + 'footer' => 'Palmas360', + 'button' => 'Ver opciones', + 'sections' => [ + [ + 'title' => 'Opciones', + 'rows' => [ + ['id' => 'recibir_info', 'title' => 'Recibir Información', 'description' => 'Obtén información útil'], + ['id' => 'descargar_informes', 'title' => 'Descargar Informes', 'description' => 'Accede a tus informes'], + ['id' => 'menu_mixto', 'title' => 'Menú Mixto', 'description' => 'Combina ambas opciones'], + ], + ], + ], + ], + 'submenu_informes' => [ + 'type' => 'list', + 'header' => 'Informes', + 'body' => 'Selecciona el tipo de informe:', + 'footer' => 'Palmas360', + 'button' => 'Ver informes', + 'sections' => [ + [ + 'title' => 'Tipo de Informe', + 'rows' => [ + ['id' => 'submenu_ciclos', 'title' => 'Ciclos', 'description' => 'Informes de ciclos'], + ['id' => 'submenu_produccion', 'title' => 'Producción', 'description' => 'Informes de producción'], + ['id' => 'submenu_mantenimiento_fecha', 'title' => 'Ciclos Mtto. Fecha', 'description' => 'Ciclos de mantenimiento a la fecha'], + ], + ], + ], + ], + 'submenu_ciclos' => [ + 'type' => 'list', + 'header' => 'Ciclos', + 'body' => 'Selecciona tipo de ciclo:', + 'footer' => 'Palmas360', + 'button' => 'Ver ciclos', + 'sections' => [ + [ + 'title' => 'Ciclos', + 'rows' => [ + ['id' => 'submenu_ciclos_cosecha', 'title' => 'Ciclos Cosecha', 'description' => 'Ciclos de cosecha'], + ['id' => 'submenu_ciclos_sanidad', 'title' => 'Ciclos Sanidad', 'description' => 'Ciclos de sanidad'], + ['id' => 'submenu_ciclo_cosecha', 'title' => 'Ciclo de Cosecha','description' => 'Día o histórico de cosecha'], + ], + ], + ], + ], + 'submenu_ciclo_cosecha' => [ + 'type' => 'button', + 'body' => 'Selecciona una opción:', + 'buttons' => [ + ['id' => 'ciclo_cosecha_dia', 'title' => 'Día Ciclo Cosecha'], + ['id' => 'ciclo_cosecha_historico', 'title' => 'Histórico 30 Días'], + ], + ], + 'menu_mixto' => [ + 'type' => 'button', + 'body' => '¿Qué deseas hacer?', + 'buttons' => [ + ['id' => 'recibir_info', 'title' => 'Recibir Información'], + ['id' => 'descargar_informes', 'title' => 'Descargar Informes'], + ], + ], + ], + 'flows' => [ + 'show_main_menu' => ['type' => 'menu', 'menu' => 'show_main_menu'], + 'recibir_info' => ['type' => 'function', 'function' => 'forward_to_ai'], + 'descargar_informes' => ['type' => 'menu', 'menu' => 'submenu_informes'], + 'menu_mixto' => ['type' => 'menu', 'menu' => 'menu_mixto'], + 'submenu_ciclos' => ['type' => 'menu', 'menu' => 'submenu_ciclos'], + 'submenu_produccion' => ['type' => 'function', 'function' => 'api_report', 'params' => ['endpoint' => 'bot-api.php?reporte=produccion', 'filename' => 'reporte_produccion.xlsx', 'caption' => 'Reporte de producción']], + 'submenu_mantenimiento_fecha' => ['type' => 'function', 'function' => 'api_report', 'params' => ['endpoint' => 'bot-api.php?reporte=mantenimiento_fecha', 'filename' => 'reporte_mantenimiento.xlsx', 'caption' => 'Ciclos de mantenimiento a la fecha']], + 'submenu_ciclos_cosecha' => ['type' => 'function', 'function' => 'api_report', 'params' => ['endpoint' => 'bot-api.php?reporte=ciclos_cosecha', 'filename' => 'reporte_ciclos_cosecha.xlsx', 'caption' => 'Ciclos de cosecha']], + 'submenu_ciclos_sanidad' => ['type' => 'function', 'function' => 'api_report', 'params' => ['endpoint' => 'bot-api.php?reporte=ciclos_sanidad', 'filename' => 'reporte_ciclos_sanidad.xlsx', 'caption' => 'Ciclos de sanidad']], + 'submenu_ciclo_cosecha' => ['type' => 'menu', 'menu' => 'submenu_ciclo_cosecha'], + 'ciclo_cosecha_dia' => ['type' => 'function', 'function' => 'api_report', 'params' => ['endpoint' => 'bot-api.php?reporte=ciclos_cosecha_dia', 'filename' => 'ciclo_cosecha_hoy.xlsx', 'caption' => 'Ciclo de cosecha del día de hoy']], + 'ciclo_cosecha_historico' => ['type' => 'function', 'function' => 'api_report', 'params' => ['endpoint' => 'bot-api.php?reporte=ciclos_cosecha_historico', 'filename' => 'ciclo_cosecha_30dias.xlsx', 'caption' => 'Histórico ciclos de cosecha últimos 30 días']], + ], +]; + +$companies = CompanyRepository::findAll(true); +$count = 0; + +foreach ($companies as $company) { + $existing = []; + if (!empty($company['config_json'])) { + $decoded = json_decode($company['config_json'], true); + if (is_array($decoded)) { + $existing = $decoded; + } + } + + $preserveKeys = ['greeting', 'fallback', 'ai_prompt', 'ai_model', 'ai_temperature', 'ai_provider', 'approval_webhook', 'ignore_prefixes', 'ai_max_tokens']; + foreach ($preserveKeys as $key) { + if (array_key_exists($key, $existing)) { + $menuConfig[$key] = $existing[$key]; + } + } + + if (!isset($menuConfig['greeting'])) { + $menuConfig['greeting'] = '¡Bienvenido! Escribe *menu* para ver las opciones disponibles.'; + } + if (!isset($menuConfig['fallback'])) { + $menuConfig['fallback'] = 'No entendí. Escribe *menu* para ver las opciones disponibles.'; + } + + CompanyRepository::save([ + 'id' => (int)$company['id'], + 'config_json' => json_encode($menuConfig, JSON_UNESCAPED_UNICODE), + ]); + + $name = $company['display_name'] ?: $company['name']; + echo "✔ {$name} actualizada\n"; + $count++; +} + +echo "\n✅ {$count} empresas actualizadas con los nuevos menús.\n"; diff --git a/services/BotRouter.php b/services/BotRouter.php index 7dec8e6..e4ce684 100644 --- a/services/BotRouter.php +++ b/services/BotRouter.php @@ -143,6 +143,26 @@ class BotRouter $stmt = db()->prepare("INSERT INTO outbound_queue (company_id, to_number, message_type, payload) VALUES (?, ?, ?, ?)"); $stmt->execute([$company['id'], $to, $type, $payload]); self::log("Bot encoló respuesta {$type} para {$to}"); + + $messageId = 'bot_out_' . $company['id'] . '_' . time() . '_' . bin2hex(random_bytes(4)); + $content = $payload; + if ($type !== 'text') { + $decoded = json_decode($payload, true); + $content = $decoded['caption'] ?? $decoded['body'] ?? $decoded['text'] ?? $payload; + } + $stmt2 = db()->prepare(" + INSERT IGNORE INTO conversations + (company_id, message_id, phone_number, direction, message_type, content, timestamp) + VALUES (?, ?, ?, 'outbound', ?, ?, ?) + "); + $stmt2->execute([ + $company['id'], + $messageId, + $to, + $type, + mb_substr($content, 0, 1000), + time(), + ]); } catch (\PDOException $e) { self::log('ERROR encolando respuesta: ' . $e->getMessage()); } diff --git a/services/NormalBot.php b/services/NormalBot.php index b133e4f..098e7ee 100644 --- a/services/NormalBot.php +++ b/services/NormalBot.php @@ -62,6 +62,16 @@ class NormalBot return null; } + private static function resolveMenu($menuRef, array $company): array + { + if (is_string($menuRef)) { + $config = self::getConfig($company); + $menus = $config['menus'] ?? []; + return $menus[$menuRef] ?? []; + } + return is_array($menuRef) ? $menuRef : []; + } + private static function handleFlow(array $flow, array $context, array $company, int $ctxId): ?array { $type = $flow['type'] ?? 'text'; @@ -69,7 +79,7 @@ class NormalBot 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($flow['menu'] ?? [], $context['from'], $company), + 'menu' => self::buildMenuResponse(self::resolveMenu($flow['menu'] ?? [], $company), $context['from'], $company), 'function' => self::executeFunction($flow['function'] ?? '', $flow['params'] ?? [], $context, $company, $ctxId), default => null, }; @@ -83,10 +93,98 @@ class NormalBot return self::sendText('¿En qué más puedo ayudarte?', $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 + { + $endpoint = $params['endpoint'] ?? ''; + if ($endpoint === '') { + return self::sendText('Error: endpoint no configurado.', $context['from'], $company); + } + + $baseUrl = rtrim($company['api_base_url'] ?? '', '/'); + $url = $baseUrl . '/' . ltrim($endpoint, '/'); + $apiKey = $company['api_key'] ?? ''; + + $query = $params['query'] ?? []; + if (!empty($query)) { + $url .= '?' . http_build_query($query); + } + + $url .= (str_contains($url, '?') ? '&' : '?') . 'telefono=' . urlencode($context['from']) . '&nombre=' . urlencode($context['name'] ?? ''); + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 60, + CURLOPT_HTTPHEADER => [ + 'X-API-Key: ' . $apiKey, + 'User-Agent: bot-palmas360/1.0', + ], + ]); + $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 [{$endpoint}]: {$error}"); + return self::sendText('Error al obtener el reporte. Intenta de nuevo.', $context['from'], $company); + } + + if ($httpCode >= 400) { + self::log("API report HTTP {$httpCode} [{$endpoint}]: " . mb_substr($content, 0, 200)); + return self::sendText('Error al obtener el reporte. Intenta de nuevo.', $context['from'], $company); + } + + $mimeMap = [ + 'application/pdf' => ['pdf', 'pdf'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => ['xlsx', 'xlsx'], + 'application/vnd.ms-excel' => ['xls', 'xls'], + 'text/csv' => ['csv', 'csv'], + 'application/vnd.oasis.opendocument.spreadsheet' => ['ods', 'ods'], + ]; + + $ext = 'pdf'; + $mime = 'application/pdf'; + foreach ($mimeMap as $m => $info) { + if (str_starts_with($contentType ?? '', $m)) { + $ext = $info[0]; + $mime = $info[1] ?? $m; + 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 [{$endpoint}]: " . ($upload['error'] ?? 'unknown')); + return self::sendText('Error al enviar el reporte. Intenta de nuevo.', $context['from'], $company); + } + + $reportName = $params['filename'] ?? ('reporte.' . $ext); + $caption = $params['caption'] ?? 'Aquí tienes el reporte solicitado.'; + + $result = WhatsAppSender::sendDocument($context['from'], $upload['media_id'], $phoneNumberId, $caption, $reportName); + + ConversationContext::reset($ctxId); + + return null; + } + private static function buildMenuResponse(array $menu, string $to, array $company): array { $menuType = $menu['type'] ?? 'list'; @@ -211,7 +309,7 @@ class NormalBot foreach ($flows as $flowId => $flow) { if (($flow['type'] ?? '') === 'menu') { - $menu = $flow['menu'] ?? []; + $menu = self::resolveMenu($flow['menu'] ?? [], $company); foreach ($menu['sections'] ?? [] as $section) { foreach ($section['rows'] ?? [] as $row) { if (($row['id'] ?? '') === $input) { diff --git a/services/WhatsAppSender.php b/services/WhatsAppSender.php index c6d8129..5ad550c 100644 --- a/services/WhatsAppSender.php +++ b/services/WhatsAppSender.php @@ -67,6 +67,65 @@ class WhatsAppSender ]); } + public static function uploadMedia(string $filePath, string $mimeType, string $phoneNumberId): array + { + $url = self::BASE_URL . '/' . self::API_VERSION . '/' . $phoneNumberId . '/media'; + $token = env('WHATSAPP_ACCESS_TOKEN', ''); + + if ($token === '') { + return ['success' => false, 'error' => 'WHATSAPP_ACCESS_TOKEN no configurado']; + } + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => [ + 'messaging_product' => 'whatsapp', + 'file' => new \CURLFile($filePath, $mimeType, basename($filePath)), + ], + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $token, + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 60, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + $decoded = $response ? json_decode($response, true) : null; + unlink($filePath); + + return [ + 'success' => $httpCode >= 200 && $httpCode < 300, + 'http_code' => $httpCode, + 'media_id' => $decoded['id'] ?? null, + 'response' => $decoded ?? $response, + 'error' => $error ?: null, + ]; + } + + public static function sendDocument(string $to, string $mediaId, string $phoneNumberId, ?string $caption = null, ?string $filename = null): array + { + $document = ['id' => $mediaId]; + if ($filename !== null) { + $document['filename'] = $filename; + } + if ($caption !== null) { + $document['caption'] = $caption; + } + + return self::callApi($phoneNumberId, [ + 'messaging_product' => 'whatsapp', + 'recipient_type' => 'individual', + 'to' => $to, + 'type' => 'document', + 'document' => $document, + ]); + } + private static function callApi(string $phoneNumberId, array $payload): array { $url = self::BASE_URL . '/' . self::API_VERSION . '/' . $phoneNumberId . '/messages';