["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 : []; } }