diff --git a/admin/DashboardController.php b/admin/DashboardController.php
index 08ac264..82a5618 100644
--- a/admin/DashboardController.php
+++ b/admin/DashboardController.php
@@ -3142,6 +3142,7 @@ HTML;
$fallbackVal = self::h($config['fallback'] ?? '');
$aiPromptVal = self::h($config['ai_prompt'] ?? '');
$aiForMediaChecked = ($config['ai_for_media'] ?? true) ? 'checked' : '';
+ $nluChecked = !empty($config['nlu']) ? 'checked' : '';
$aiModelVal = self::h($config['ai_model'] ?? 'gpt-4o-mini');
$aiTempVal = self::h((string)($config['ai_temperature'] ?? 0.7));
$aiProviderVal = self::h($config['ai_provider'] ?? '');
@@ -3731,6 +3732,18 @@ HTML;
Si estΓ‘ desactivado, el bot muestra el menΓΊ de categorΓa directamente.
+
diff --git a/public/index.php b/public/index.php
index 5cb5ed3..b2e10d4 100644
--- a/public/index.php
+++ b/public/index.php
@@ -38,6 +38,7 @@ require_once __DIR__ . '/../services/ErpSync.php';
require_once __DIR__ . '/../services/ConversationContext.php';
require_once __DIR__ . '/../services/NormalBot.php';
require_once __DIR__ . '/../services/AiBot.php';
+require_once __DIR__ . '/../services/MediaTranscriber.php';
require_once __DIR__ . '/../services/BotRouter.php';
require_once __DIR__ . '/../services/PendingApproval.php';
require_once __DIR__ . '/../services/ErpMonitor.php';
@@ -534,6 +535,7 @@ $routes = [
$aiProvider = trim($_POST['ai_provider'] ?? '');
if ($aiProvider !== '') $config['ai_provider'] = $aiProvider; else unset($config['ai_provider']);
$config['ai_for_media'] = isset($_POST['ai_for_media']);
+ $config['nlu'] = isset($_POST['nlu']);
$openaiKey = trim($_POST['openai_api_key'] ?? '');
if ($openaiKey !== '') $config['openai_api_key'] = $openaiKey;
$geminiKey = trim($_POST['gemini_api_key'] ?? '');
@@ -568,7 +570,7 @@ $routes = [
$formManagedKeys = ['commands', 'menus', 'flows', 'ai_prompt', 'ai_model', 'ai_temperature',
'ai_provider', 'openai_api_key', 'gemini_api_key', 'gemini_model',
'claude_api_key', 'claude_model',
- 'ai_for_media', 'greeting', 'fallback', 'approval_webhook',
+ 'ai_for_media', 'nlu', 'greeting', 'fallback', 'approval_webhook',
'ignore_prefixes', 'welcome_menu', 'per_type'];
foreach ($existingConfig as $k => $v) {
if (!in_array($k, $formManagedKeys, true) && !isset($config[$k])) {
diff --git a/services/AiBot.php b/services/AiBot.php
index 2a751cd..f79b550 100644
--- a/services/AiBot.php
+++ b/services/AiBot.php
@@ -302,6 +302,141 @@ PROMPT;
. "Responde de forma breve y apropiada segΓΊn los permisos del usuario. Sin saludos largos.";
}
+ // ββ NLU: enrutamiento inteligente ββββββββββββββββββββββββββββββββββββββββ
+
+ /**
+ * Dado un mensaje de texto, decide si enrutar a un flow existente o responder en chat libre.
+ * Devuelve: ['action'=>'route','key'=>'flow_key'] | ['action'=>'chat','text'=>'...']
+ */
+ public static function routeOrChat(array $company, array $context, string $input): array
+ {
+ $config = self::getConfig($company);
+ $permType = (int)($context['permission_type'] ?? 1);
+ $catalog = self::buildFlowCatalog($config, $permType);
+
+ if (empty($catalog)) {
+ return ['action' => 'chat', 'text' => ''];
+ }
+
+ $systemPrompt = self::buildNluPrompt(
+ $company['display_name'] ?? $company['name'] ?? 'la empresa',
+ $catalog,
+ $permType
+ );
+
+ $raw = self::callLlmOnce($systemPrompt, $input, $company);
+ if ($raw === null) {
+ return ['action' => 'chat', 'text' => ''];
+ }
+
+ // Strip markdown fences if AI wrapped JSON
+ $clean = trim(preg_replace('/^```(?:json)?\s*/i', '', preg_replace('/\s*```$/m', '', $raw)));
+ $json = json_decode($clean, true);
+
+ if (!is_array($json) || !isset($json['action'])) {
+ return ['action' => 'chat', 'text' => $clean];
+ }
+
+ if ($json['action'] === 'route' && isset($json['key'])) {
+ // Validate key exists in flows
+ $allFlows = $config['flows'] ?? [];
+ foreach ($config['per_type'] ?? [] as $pt) {
+ $allFlows = array_merge($allFlows, $pt['flows'] ?? []);
+ }
+ if (isset($allFlows[$json['key']])) {
+ return ['action' => 'route', 'key' => $json['key']];
+ }
+ }
+
+ return ['action' => 'chat', 'text' => $json['text'] ?? $clean];
+ }
+
+ private static function buildFlowCatalog(array $config, int $permType): array
+ {
+ $flows = $config['flows'] ?? [];
+ $menus = $config['menus'] ?? [];
+
+ // Build human labels from menu rows/buttons
+ $labels = [];
+ foreach ($menus as $menu) {
+ foreach ($menu['sections'] ?? [] as $section) {
+ foreach ($section['rows'] ?? [] as $row) {
+ if (($row['id'] ?? '') !== '') $labels[$row['id']] = $row['title'] ?? $row['id'];
+ }
+ }
+ foreach ($menu['buttons'] ?? [] as $btn) {
+ if (($btn['id'] ?? '') !== '') $labels[$btn['id']] = $btn['title'] ?? $btn['id'];
+ }
+ }
+
+ $catalog = [];
+ foreach ($flows as $key => $flow) {
+ $type = $flow['type'] ?? 'text';
+ $fn = $flow['function'] ?? '';
+
+ $isUpload = in_array($type, ['collect_and_post', 'collect_for_each', 'submit_form'], true);
+ $isDownload = ($type === 'function' && $fn === 'api_report');
+
+ // Permission filter:
+ // type 1 = solo reporta (upload), type 2 = solo recibe (download), type 3 = ambos
+ if ($isUpload && !in_array($permType, [1, 3], true)) continue;
+ if ($isDownload && !in_array($permType, [2, 3], true)) continue;
+
+ // Skip purely internal/navigation flows
+ if (in_array($type, ['text', 'image'], true) && !$isUpload && !$isDownload) continue;
+
+ $label = $flow['nlu_description'] ?? $labels[$key] ?? $key;
+ $dir = $isUpload ? 'subida' : ($isDownload ? 'descarga' : '');
+
+ $catalog[] = ['key' => $key, 'label' => $label, 'dir' => $dir];
+ }
+
+ return $catalog;
+ }
+
+ private static function buildNluPrompt(string $company, array $catalog, int $permType): string
+ {
+ $permLabel = match ($permType) {
+ 1 => 'puede subir/reportar datos al sistema',
+ 2 => 'puede descargar reportes e informes',
+ 3 => 'puede subir datos y descargar reportes',
+ default => 'acceso bΓ‘sico',
+ };
+
+ $lines = array_map(fn($item) =>
+ '- ' . $item['key'] . ': "' . $item['label'] . '"' . ($item['dir'] !== '' ? ' [' . $item['dir'] . ']' : ''),
+ $catalog
+ );
+
+ return "Eres el asistente de {$company}. El usuario {$permLabel}.\n\n"
+ . "Opciones disponibles:\n" . implode("\n", $lines) . "\n\n"
+ . "Analiza el mensaje y determina si se refiere claramente a una opciΓ³n.\n\n"
+ . "Si sΓ β responde SOLO este JSON (sin markdown):\n"
+ . "{\"action\":\"route\",\"key\":\"\"}\n\n"
+ . "Si no estΓ‘ claro o no hay opciΓ³n correspondiente β responde SOLO:\n"
+ . "{\"action\":\"chat\",\"text\":\"\"}\n\n"
+ . "No inventes keys. Usa exactamente los keys de la lista.";
+ }
+
+ /** Llamada de 1 turno sin historial β para NLU routing. */
+ private static function callLlmOnce(string $systemPrompt, string $userMessage, array $company): ?string
+ {
+ $cfg = self::getConfig($company);
+ $provider = $cfg['ai_provider'] ?? env('AI_PROVIDER', 'openai');
+ $history = [['role' => 'user', 'content' => $userMessage]];
+
+ $result = match ($provider) {
+ 'openai' => self::callOpenAI($systemPrompt, $history, $company),
+ 'gemini' => self::callGemini($systemPrompt, $history, $company),
+ 'claude' => self::callClaude($systemPrompt, $history, $company),
+ default => null,
+ };
+
+ return $result['content'] ?? null;
+ }
+
+ // ββ Shared helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
private static function getConfig(array $company): array
{
$json = $company['config_json'] ?? '';
diff --git a/services/BotRouter.php b/services/BotRouter.php
index b7f8bab..a390b91 100644
--- a/services/BotRouter.php
+++ b/services/BotRouter.php
@@ -107,24 +107,75 @@ class BotRouter
private static function runHybridBot(array $company, array $context, string $input, string $inputType): ?array
{
- $config = self::getConfig($company);
+ $config = self::getConfig($company);
+ $nluEnabled = (bool)($config['nlu'] ?? false);
+ $aiEnabled = (bool)($config['ai_for_media'] ?? true);
- // Media (image/audio/video/document) β AI handles it if enabled
+ // ββ Media (audio/imagen) ββββββββββββββββββββββββββββββββββββββββββββββ
if (self::isMediaType($inputType)) {
- $aiEnabled = (bool)($config['ai_for_media'] ?? true);
if (!$aiEnabled) {
return self::categoryMenuFallback($company, $context, $config);
}
+
+ if ($nluEnabled && in_array($inputType, ['audio', 'image'], true)) {
+ $text = $inputType === 'audio'
+ ? MediaTranscriber::transcribeAudio($input, $company)
+ : MediaTranscriber::extractFromImage($input, $company);
+
+ if ($text !== null && trim($text) !== '') {
+ self::log("NLU media [{$inputType}]: texto extraΓdo β \"{$text}\"");
+ // Confirmar al usuario lo que entendiΓ³
+ $prefix = $inputType === 'audio' ? 'π€ TranscripciΓ³n' : 'πΌοΈ Imagen analizada';
+ WhatsAppSender::sendText(
+ $context['from'],
+ "{$prefix}: _{$text}_",
+ $context['phone_number_id'] ?? ''
+ );
+ return self::runNluOnText($company, $context, $text, $config);
+ }
+ }
+
return AiBot::processMedia($company, $context, $inputType, $input);
}
- // Text / interactive / button β NormalBot only, never AI
+ // ββ Texto / interactivo: NormalBot primero ββββββββββββββββββββββββββββ
$response = self::runNormalBot($company, $context, $input, $inputType);
if ($response !== null) {
return $response;
}
- // NormalBot returned null β show the category greeting menu as fallback
+ // NormalBot no reconociΓ³ β NLU si estΓ‘ activo, si no menΓΊ categorΓa
+ if ($nluEnabled && $inputType === 'text') {
+ return self::runNluOnText($company, $context, $input, $config);
+ }
+
+ return self::categoryMenuFallback($company, $context, $config);
+ }
+
+ private static function runNluOnText(array $company, array $context, string $input, array $config): ?array
+ {
+ $result = AiBot::routeOrChat($company, $context, $input);
+
+ if ($result['action'] === 'route') {
+ $key = $result['key'];
+ $botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'normal');
+ ConversationContext::updateNode((int)$botCtx['id'], $key);
+ self::log("NLU: {$context['from']} β flow [{$key}]");
+ // Ejecutar con input vacΓo β NormalBot retoma current_node
+ return NormalBot::process($company, $context, '');
+ }
+
+ $text = trim($result['text'] ?? '');
+ if ($text !== '') {
+ self::log("NLU: {$context['from']} β chat libre");
+ return [
+ 'action' => 'send',
+ 'type' => 'text',
+ 'to' => $context['from'],
+ 'payload' => json_encode(['text' => $text]),
+ ];
+ }
+
return self::categoryMenuFallback($company, $context, $config);
}
diff --git a/services/MediaTranscriber.php b/services/MediaTranscriber.php
new file mode 100644
index 0000000..060932f
--- /dev/null
+++ b/services/MediaTranscriber.php
@@ -0,0 +1,281 @@
+ ["Authorization: Bearer {$token}", "User-Agent: bot-palmas360/1.0"],
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 15,
+ ]);
+ $resp = curl_exec($ch);
+ $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($code !== 200 || !$resp) return null;
+
+ $data = json_decode($resp, true);
+ $url = $data['url'] ?? '';
+ $mime = $data['mime_type'] ?? 'application/octet-stream';
+ if ($url === '') return null;
+
+ // 2) Descargar binario
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_HTTPHEADER => ["Authorization: Bearer {$token}", "User-Agent: bot-palmas360/1.0"],
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_TIMEOUT => 45,
+ ]);
+ $bytes = curl_exec($ch);
+ $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($code !== 200 || !$bytes || strlen($bytes) < 100) return null;
+
+ return ['bytes' => $bytes, 'mime' => $mime];
+ }
+
+ // ββ Audio β texto βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ public static function transcribeAudio(string $mediaId, array $company): ?string
+ {
+ $cfg = self::getConfig($company);
+ $provider = $cfg['ai_provider'] ?? env('AI_PROVIDER', 'openai');
+
+ $media = self::downloadMedia($mediaId);
+ if ($media === null) return null;
+
+ return match ($provider) {
+ 'openai' => self::whisper($media['bytes'], $media['mime'], $cfg),
+ 'gemini' => self::geminiAudio($media['bytes'], $media['mime'], $cfg),
+ default => null, // Claude no soporta audio
+ };
+ }
+
+ private static function whisper(string $bytes, string $mime, array $cfg): ?string
+ {
+ $apiKey = $cfg['openai_api_key'] ?? env('OPENAI_API_KEY', '');
+ if ($apiKey === '') return null;
+
+ $ext = str_contains($mime, 'ogg') ? 'ogg' : (str_contains($mime, 'mp4') ? 'mp4' : 'ogg');
+ $tmpFile = tempnam(sys_get_temp_dir(), 'wa_audio_') . '.' . $ext;
+ file_put_contents($tmpFile, $bytes);
+
+ $ch = curl_init('https://api.openai.com/v1/audio/transcriptions');
+ curl_setopt_array($ch, [
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => [
+ 'file' => new CURLFile($tmpFile, $mime, 'audio.' . $ext),
+ 'model' => 'whisper-1',
+ 'language' => 'es',
+ ],
+ CURLOPT_HTTPHEADER => ["Authorization: Bearer {$apiKey}"],
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 60,
+ ]);
+ $resp = curl_exec($ch);
+ $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+ @unlink($tmpFile);
+
+ if ($code !== 200 || !$resp) return null;
+
+ $data = json_decode($resp, true);
+ return isset($data['text']) ? trim($data['text']) : null;
+ }
+
+ private static function geminiAudio(string $bytes, string $mime, array $cfg): ?string
+ {
+ $apiKey = $cfg['gemini_api_key'] ?? env('GEMINI_API_KEY', '');
+ $model = $cfg['gemini_model'] ?? env('GEMINI_MODEL', 'gemini-2.5-flash');
+ if ($apiKey === '') return null;
+
+ // Gemini acepta audio inline como base64
+ $payload = json_encode([
+ 'contents' => [[
+ 'parts' => [
+ ['inline_data' => ['mime_type' => $mime, 'data' => base64_encode($bytes)]],
+ ['text' => 'Transcribe este audio de WhatsApp en espaΓ±ol. Devuelve SOLO el texto transcrito, sin explicaciones ni puntuaciΓ³n extra.'],
+ ],
+ ]],
+ ]);
+
+ $url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}";
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => $payload,
+ CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 60,
+ ]);
+ $resp = curl_exec($ch);
+ $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($code !== 200 || !$resp) return null;
+
+ $data = json_decode($resp, true);
+ $text = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
+ return $text !== null ? trim($text) : null;
+ }
+
+ // ββ Imagen β intenciΓ³n/texto ββββββββββββββββββββββββββββββββββββββββββββββ
+
+ public static function extractFromImage(string $mediaId, array $company): ?string
+ {
+ $cfg = self::getConfig($company);
+ $provider = $cfg['ai_provider'] ?? env('AI_PROVIDER', 'openai');
+
+ $media = self::downloadMedia($mediaId);
+ if ($media === null) return null;
+
+ $b64 = base64_encode($media['bytes']);
+ $mime = $media['mime'];
+ $prompt = 'Analiza esta imagen de WhatsApp. Si contiene texto, extrΓ‘elo. '
+ . 'Describe en una oraciΓ³n quΓ© quiere hacer el usuario o quΓ© informaciΓ³n contiene. '
+ . 'Responde en espaΓ±ol, mΓ‘ximo 2 oraciones.';
+
+ return match ($provider) {
+ 'openai' => self::openaiVision($b64, $mime, $prompt, $cfg),
+ 'gemini' => self::geminiVision($b64, $mime, $prompt, $cfg),
+ 'claude' => self::claudeVision($b64, $mime, $prompt, $cfg),
+ default => null,
+ };
+ }
+
+ private static function openaiVision(string $b64, string $mime, string $prompt, array $cfg): ?string
+ {
+ $apiKey = $cfg['openai_api_key'] ?? env('OPENAI_API_KEY', '');
+ $model = $cfg['ai_model'] ?? env('OPENAI_MODEL', 'gpt-4o');
+ // Vision requires gpt-4o or gpt-4.1 β downgrade if mini is selected
+ if (str_contains($model, 'mini') || str_contains($model, '3.5')) $model = 'gpt-4o';
+ if ($apiKey === '') return null;
+
+ $payload = json_encode([
+ 'model' => $model,
+ 'max_tokens' => 200,
+ 'messages' => [[
+ 'role' => 'user',
+ 'content' => [
+ ['type' => 'image_url', 'image_url' => ['url' => "data:{$mime};base64,{$b64}"]],
+ ['type' => 'text', 'text' => $prompt],
+ ],
+ ]],
+ ]);
+
+ $ch = curl_init('https://api.openai.com/v1/chat/completions');
+ curl_setopt_array($ch, [
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => $payload,
+ CURLOPT_HTTPHEADER => ['Content-Type: application/json', "Authorization: Bearer {$apiKey}"],
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 30,
+ ]);
+ $resp = curl_exec($ch);
+ $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($code !== 200 || !$resp) return null;
+
+ $data = json_decode($resp, true);
+ $text = $data['choices'][0]['message']['content'] ?? null;
+ return $text !== null ? trim($text) : null;
+ }
+
+ private static function geminiVision(string $b64, string $mime, string $prompt, array $cfg): ?string
+ {
+ $apiKey = $cfg['gemini_api_key'] ?? env('GEMINI_API_KEY', '');
+ $model = $cfg['gemini_model'] ?? env('GEMINI_MODEL', 'gemini-2.5-flash');
+ if ($apiKey === '') return null;
+
+ $payload = json_encode([
+ 'contents' => [[
+ 'parts' => [
+ ['inline_data' => ['mime_type' => $mime, 'data' => $b64]],
+ ['text' => $prompt],
+ ],
+ ]],
+ ]);
+
+ $url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}";
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => $payload,
+ CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 30,
+ ]);
+ $resp = curl_exec($ch);
+ $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($code !== 200 || !$resp) return null;
+
+ $data = json_decode($resp, true);
+ $text = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
+ return $text !== null ? trim($text) : null;
+ }
+
+ private static function claudeVision(string $b64, string $mime, string $prompt, array $cfg): ?string
+ {
+ $apiKey = $cfg['claude_api_key'] ?? env('CLAUDE_API_KEY', '');
+ $model = $cfg['claude_model'] ?? env('CLAUDE_MODEL', 'claude-haiku-4-5');
+ if ($apiKey === '') return null;
+
+ $payload = json_encode([
+ 'model' => $model,
+ 'max_tokens' => 200,
+ 'messages' => [[
+ 'role' => 'user',
+ 'content' => [
+ ['type' => 'image', 'source' => ['type' => 'base64', 'media_type' => $mime, 'data' => $b64]],
+ ['type' => 'text', 'text' => $prompt],
+ ],
+ ]],
+ ]);
+
+ $ch = curl_init('https://api.anthropic.com/v1/messages');
+ curl_setopt_array($ch, [
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => $payload,
+ CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$apiKey}", 'anthropic-version: 2023-06-01'],
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 30,
+ ]);
+ $resp = curl_exec($ch);
+ $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($code !== 200 || !$resp) return null;
+
+ $data = json_decode($resp, true);
+ $text = $data['content'][0]['text'] ?? null;
+ return $text !== null ? trim($text) : null;
+ }
+
+ // ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ private static function getConfig(array $company): array
+ {
+ $json = $company['config_json'] ?? '';
+ if ($json === '') return [];
+ $cfg = json_decode($json, true);
+ return is_array($cfg) ? $cfg : [];
+ }
+}