Files
bot_palmas/services/AiBot.php
T
Lizandro GuarnizoandClaude Sonnet 4.6 6f799c3829 feat: media via AI, text via NormalBot only, ai_for_media toggle
- Text/interactive/button messages → NormalBot only, never escalates to AI
- Image/audio/video/document → AiBot.processMedia() if ai_for_media enabled,
  else falls back to category greeting menu
- AiBot.processMedia() uses permission_type context so AI acknowledges uploads
  for perm 3 and redirects others to their menu
- WpWebhook.handleMedia now calls BotRouter so the bot responds to media
- Admin UI: checkbox toggle "Procesar imágenes y archivos con IA" in AI tab
- categoryMenuFallback() extracted in BotRouter as shared helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 13:28:56 -05:00

255 lines
9.3 KiB
PHP

<?php
declare(strict_types=1);
class AiBot
{
public static function processMedia(array $company, array $context, string $mediaType, string $caption): ?array
{
$permType = (int)($context['permission_type'] ?? 1);
$config = self::getConfig($company);
$mediaLabel = match ($mediaType) {
'image' => 'una imagen',
'audio' => 'un mensaje de audio',
'video' => 'un video',
'document' => 'un documento',
'sticker' => 'un sticker',
default => 'un archivo',
};
$canUpload = $permType === 3;
$permDesc = $canUpload
? 'El usuario tiene permisos para subir información y reportes.'
: 'El usuario solo puede recibir información o descargar informes, NO puede subir archivos.';
$captionNote = $caption !== '' && !str_starts_with($caption, '[') ? " con el texto: \"{$caption}\"" : '';
$basePrompt = $config['ai_prompt'] ?? self::defaultMediaPrompt($company);
$systemPrompt = $basePrompt . "\n\n{$permDesc}\n\nReglas:\n"
. "- Si el usuario puede subir: confirma recepción de {$mediaLabel} e indica que será procesado.\n"
. "- Si NO puede subir: explica brevemente y dile que use el menú de opciones.\n"
. "- Respuesta máx 2 oraciones. Sin saludo largo.";
$userMessage = "El usuario envió {$mediaLabel}{$captionNote}.";
$botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'ai');
$ctxId = (int)$botCtx['id'];
ConversationContext::addAiMessage($ctxId, ['role' => 'user', 'content' => $userMessage]);
$result = self::callLlm($systemPrompt, $ctxId, $company);
if ($result === null) {
return null;
}
ConversationContext::addAiMessage($ctxId, ['role' => 'assistant', 'content' => $result['content'] ?? '']);
return [
'action' => 'send',
'type' => 'text',
'to' => $context['from'],
'payload' => json_encode(['text' => $result['content'] ?? '']),
];
}
public static function process(array $company, array $context, string $input): ?array
{
$botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'ai');
$ctxId = (int)$botCtx['id'];
$config = self::getConfig($company);
$systemPrompt = $config['ai_prompt'] ?? self::defaultPrompt($company);
ConversationContext::addAiMessage($ctxId, ['role' => 'user', 'content' => $input]);
$result = self::callLlm($systemPrompt, $ctxId, $company);
if ($result === null) {
return null;
}
ConversationContext::addAiMessage($ctxId, ['role' => 'assistant', 'content' => $result['content'] ?? '']);
return [
'action' => 'send',
'type' => 'text',
'to' => $context['from'],
'payload' => json_encode(['text' => $result['content'] ?? '']),
];
}
private static function callLlm(string $systemPrompt, int $ctxId, array $company): ?array
{
$provider = env('AI_PROVIDER', 'openai');
$history = ConversationContext::getAiHistory($ctxId);
return match ($provider) {
'openai' => self::callOpenAI($systemPrompt, $history, $company),
'gemini' => self::callGemini($systemPrompt, $history),
'mock' => self::mockResponse($history),
default => self::callOpenAI($systemPrompt, $history, $company),
};
}
private static function callOpenAI(string $systemPrompt, array $history, array $company): ?array
{
$apiKey = env('OPENAI_API_KEY', '');
if ($apiKey === '') {
return null;
}
$model = env('OPENAI_MODEL', 'gpt-4o-mini');
$messages = [
['role' => 'system', 'content' => $systemPrompt],
];
foreach ($history as $msg) {
$messages[] = [
'role' => $msg['role'] ?? 'user',
'content' => $msg['content'] ?? '',
];
}
$payload = json_encode([
'model' => $model,
'messages' => $messages,
'max_tokens' => (int)env('AI_MAX_TOKENS', '500'),
'temperature' => 0.7,
]);
$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,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($httpCode !== 200 || $response === false) {
WpWebhook::log('ERROR', "OpenAI API error: {$error} HTTP:{$httpCode}");
return null;
}
$data = json_decode($response, true);
$text = $data['choices'][0]['message']['content'] ?? null;
return $text !== null ? ['content' => $text] : null;
}
private static function callGemini(string $systemPrompt, array $history): ?array
{
$apiKey = env('GEMINI_API_KEY', '');
if ($apiKey === '') return null;
$model = env('GEMINI_MODEL', 'gemini-2.0-flash');
$maxTokens = (int)env('AI_MAX_TOKENS', '500');
// Gemini usa "contents" con roles "user"/"model"
$contents = [];
foreach ($history as $msg) {
$role = ($msg['role'] ?? 'user') === 'assistant' ? 'model' : 'user';
$contents[] = ['role' => $role, 'parts' => [['text' => $msg['content'] ?? '']]];
}
// Si el historial está vacío o el último es del model, Gemini requiere que el último sea user
if (empty($contents)) {
$contents[] = ['role' => 'user', 'parts' => [['text' => '']]];
}
$payload = json_encode([
'system_instruction' => ['parts' => [['text' => $systemPrompt]]],
'contents' => $contents,
'generationConfig' => ['maxOutputTokens' => $maxTokens, 'temperature' => 0.7],
]);
$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,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($httpCode !== 200 || $response === false) {
WpWebhook::log('ERROR', "Gemini API error: {$error} HTTP:{$httpCode}");
return null;
}
$data = json_decode($response, true);
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
return $text !== null ? ['content' => $text] : null;
}
private static function mockResponse(array $history): array
{
$last = end($history);
$input = $last['content'] ?? '';
$responses = [
'hola' => '¡Hola! ¿En qué puedo ayudarte hoy?',
'gracias' => '¡De nada! Si necesitas algo más, estoy aquí.',
'adios' => '¡Hasta luego! Que tengas un excelente día.',
'default' => "Gracias por tu mensaje. He recibido: \"{$input}\". Un asesor se pondrá en contacto contigo pronto.",
];
$normalized = mb_strtolower(trim($input));
$found = $responses['default'];
foreach ($responses as $keyword => $response) {
if (str_contains($normalized, $keyword)) {
$found = $response;
break;
}
}
return ['content' => $found];
}
private static function defaultPrompt(array $company): string
{
$name = $company['display_name'] ?? $company['name'] ?? 'la empresa';
return <<<PROMPT
Eres un asistente virtual de {$name}. Tu rol es:
1. Responder preguntas sobre los servicios y productos de {$name}.
2. Ayudar a los clientes con información general.
3. Ser amable, profesional y responder siempre en español.
4. Si no sabes la respuesta, indica que un asesor se comunicará.
5. No inventes información. Si no sabes algo, dilo honestamente.
Mantén las respuestas concisas (máximo 3 párrafos).
PROMPT;
}
private static function defaultMediaPrompt(array $company): string
{
$name = $company['display_name'] ?? $company['name'] ?? 'la empresa';
return "Eres el asistente virtual de {$name}. El usuario te acaba de enviar un archivo multimedia. "
. "Responde de forma breve y apropiada según los permisos del usuario. Sin saludos largos.";
}
private static function getConfig(array $company): array
{
$json = $company['config_json'] ?? '';
if ($json === '') return [];
$config = json_decode($json, true);
return is_array($config) ? $config : [];
}
}