cambios importantes

This commit is contained in:
Lizandro Guarnizo
2026-06-12 12:18:21 -05:00
parent efebec3c1b
commit bc422e0a1c
20 changed files with 5348 additions and 99 deletions
+155
View File
@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
class AiBot
{
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),
'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 [
'content' => 'Lo siento, tengo problemas para procesar tu mensaje. Por favor intenta de nuevo más tarde.',
];
}
$data = json_decode($response, true);
$text = $data['choices'][0]['message']['content'] ?? null;
if ($text === null) {
return [
'content' => 'No pude generar una respuesta adecuada. ¿Puedes reformular tu pregunta?',
];
}
return ['content' => $text];
}
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 getConfig(array $company): array
{
$json = $company['config_json'] ?? '';
if ($json === '') return [];
$config = json_decode($json, true);
return is_array($config) ? $config : [];
}
}
+173
View File
@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
class BotRouter
{
public static function route(array $company, array $context, string $input, string $inputType = 'text'): void
{
if (self::shouldSkipBot($company, $context)) {
return;
}
$response = self::classifyAndProcess($company, $context, $input, $inputType);
if ($response === null) {
self::log("Bot no generó respuesta para {$context['from']}");
return;
}
if ((int)($company['requires_approval'] ?? 0) === 1) {
self::saveForApproval($company, $context, $input, $response);
return;
}
self::enqueueResponse($response, $company);
}
private static function saveForApproval(array $company, array $context, string $input, array $response): void
{
$botType = $company['bot_type'] ?? 'normal';
$result = PendingApproval::create(
companyId: (int)$company['id'],
phone: $context['from'],
name: $context['name'] ?? null,
incomingMsg: $input,
botResponse: $response,
botType: $botType,
context: $context,
);
self::log("Respuesta guardada para aprobación #{$result['id']} — empresa {$company['name']}");
self::notifyErp($company, $result['id']);
}
private static function notifyErp(array $company, int $pendingId): void
{
$config = self::getConfig($company);
$webhookUrl = $config['approval_webhook'] ?? '';
if ($webhookUrl === '') {
return;
}
$payload = json_encode([
'action' => 'pending',
'pending_id' => $pendingId,
'company_id' => (int)$company['id'],
'message' => 'Nuevo mensaje pendiente de aprobación',
]);
$ch = curl_init($webhookUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-API-Key: ' . ($company['api_key'] ?? ''),
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
curl_exec($ch);
curl_close($ch);
}
private static function classifyAndProcess(array $company, array $context, string $input, string $inputType): ?array
{
$botType = $company['bot_type'] ?? 'normal';
return match ($botType) {
'normal' => self::runNormalBot($company, $context, $input, $inputType),
'ai' => self::runAiBot($company, $context, $input),
'hybrid' => self::runHybridBot($company, $context, $input, $inputType),
default => null,
};
}
private static function runNormalBot(array $company, array $context, string $input, string $inputType): ?array
{
if ($inputType === 'interactive' || $inputType === 'button') {
return NormalBot::processInteractive($company, $context, $input);
}
return NormalBot::process($company, $context, $input);
}
private static function runAiBot(array $company, array $context, string $input): ?array
{
return AiBot::process($company, $context, $input);
}
private static function runHybridBot(array $company, array $context, string $input, string $inputType): ?array
{
$response = self::runNormalBot($company, $context, $input, $inputType);
if ($response !== null) {
return $response;
}
self::log("NormalBot no manejó '{$input}', escalando a IA");
return self::runAiBot($company, $context, $input);
}
private static function shouldSkipBot(array $company, array $context): bool
{
if (!(int)($company['is_active'] ?? 0)) {
return true;
}
$config = self::getConfig($company);
$ignorePrefixes = $config['ignore_prefixes'] ?? [];
foreach ($ignorePrefixes as $prefix) {
if (str_starts_with($context['from'] ?? '', $prefix)) {
return true;
}
}
return false;
}
private static function enqueueResponse(array $response, array $company): void
{
$to = $response['to'] ?? '';
$type = $response['type'] ?? 'text';
$payload = $response['payload'] ?? '';
if ($to === '' || $payload === '') {
return;
}
try {
$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}");
} catch (\PDOException $e) {
self::log('ERROR encolando respuesta: ' . $e->getMessage());
}
}
private static function getConfig(array $company): array
{
$json = $company['config_json'] ?? '';
if ($json === '') return [];
$config = json_decode($json, true);
return is_array($config) ? $config : [];
}
private static function log(string $message): void
{
$dir = dirname(__DIR__) . '/storage/logs';
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$file = $dir . '/bot-' . date('Y-m-d') . '.log';
$line = '[' . date('Y-m-d H:i:s') . '] [BOT] ' . $message . PHP_EOL;
file_put_contents($file, $line, FILE_APPEND | LOCK_EX);
if (env('APP_ENV', 'production') === 'local') {
error_log($line);
}
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
class CompanyApiClient
{
public static function forwardMessage(array $company, array $messageData): array
{
$endpoint = rtrim($company['api_base_url'], '/') . '/webhook/incoming';
$payload = [
'company_id' => (int)$company['id'],
'from' => $messageData['from'] ?? '',
'name' => $messageData['name'] ?? '',
'message_id' => $messageData['message_id'] ?? '',
'type' => $messageData['type'] ?? 'unknown',
'content' => $messageData['content'] ?? '',
'media_id' => $messageData['media_id'] ?? null,
'timestamp' => $messageData['timestamp'] ?? time(),
'phone_number_id' => $messageData['phone_number_id'] ?? '',
'display_phone' => $messageData['display_phone'] ?? '',
'raw_payload' => $messageData['raw_payload'] ?? null,
];
return self::post($endpoint, $company['api_key'] ?? '', $payload);
}
public static function forwardStatus(array $company, array $statusData): array
{
$endpoint = rtrim($company['api_base_url'], '/') . '/webhook/status';
$payload = [
'company_id' => (int)$company['id'],
'message_id' => $statusData['message_id'] ?? '',
'status' => $statusData['status'] ?? '',
'recipient' => $statusData['recipient'] ?? '',
'timestamp' => $statusData['timestamp'] ?? time(),
'errors' => $statusData['errors'] ?? null,
];
return self::post($endpoint, $company['api_key'] ?? '', $payload);
}
private static function post(string $url, string $apiKey, array $payload): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-API-Key: ' . $apiKey,
'User-Agent: bot-palmas360/1.0',
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
return [
'http_code' => $httpCode,
'response' => $response !== false ? json_decode($response, true) : null,
'error' => $error ?: null,
'success' => $httpCode >= 200 && $httpCode < 300,
];
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
class CompanyRepository
{
public static function findByPhoneNumberId(string $phoneNumberId): ?array
{
$stmt = db()->prepare("SELECT * FROM companies WHERE phone_number_id = ? AND is_active = 1 LIMIT 1");
$stmt->execute([$phoneNumberId]);
$row = $stmt->fetch();
return $row ?: null;
}
public static function findById(int $id): ?array
{
$stmt = db()->prepare("SELECT * FROM companies WHERE id = ? LIMIT 1");
$stmt->execute([$id]);
$row = $stmt->fetch();
return $row ?: null;
}
public static function findByApiKey(string $apiKey): ?array
{
$stmt = db()->prepare("SELECT * FROM companies WHERE api_key = ? AND is_active = 1 LIMIT 1");
$stmt->execute([$apiKey]);
$row = $stmt->fetch();
return $row ?: null;
}
public static function findAll(bool $includeInactive = false): array
{
$where = $includeInactive ? '1=1' : 'is_active = 1';
return db()->query("SELECT * FROM companies WHERE {$where} ORDER BY name")->fetchAll();
}
public static function save(array $data): int
{
$db = db();
$id = (int)($data['id'] ?? 0);
$fields = ['name', 'display_name', 'phone_number_id', 'display_phone', 'api_base_url', 'api_key', 'bot_type', 'requires_approval', 'is_active', 'config_json'];
$params = [];
$sets = [];
foreach ($fields as $f) {
if (array_key_exists($f, $data)) {
$sets[] = "{$f} = ?";
$params[] = is_array($data[$f]) || is_object($data[$f]) ? json_encode($data[$f]) : $data[$f];
}
}
if ($id > 0) {
$params[] = $id;
$stmt = $db->prepare("UPDATE companies SET " . implode(', ', $sets) . " WHERE id = ?");
$stmt->execute($params);
return $id;
} else {
// Ensure required fields
$required = ['name', 'phone_number_id', 'api_base_url'];
$insertData = [];
foreach ($required as $r) {
$insertData[$r] = $data[$r] ?? '';
}
foreach ($fields as $f) {
if (array_key_exists($f, $data)) {
$insertData[$f] = $data[$f];
}
}
$cols = implode(', ', array_keys($insertData));
$vals = implode(', ', array_fill(0, count($insertData), '?'));
$stmt = $db->prepare("INSERT INTO companies ({$cols}) VALUES ({$vals})");
$stmt->execute(array_values($insertData));
return (int)$db->lastInsertId();
}
}
public static function delete(int $id): bool
{
$stmt = db()->prepare("DELETE FROM companies WHERE id = ?");
$stmt->execute([$id]);
return $stmt->rowCount() > 0;
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
class ConversationContext
{
public static function getOrCreate(int $companyId, string $phoneNumber, string $botType = 'normal'): array
{
$stmt = db()->prepare("SELECT * FROM bot_context WHERE company_id = ? AND phone_number = ? LIMIT 1");
$stmt->execute([$companyId, $phoneNumber]);
$ctx = $stmt->fetch();
if ($ctx) {
return $ctx;
}
$stmt = db()->prepare("INSERT INTO bot_context (company_id, phone_number, bot_type, ai_history, metadata) VALUES (?, ?, ?, '[]', '{}')");
$stmt->execute([$companyId, $phoneNumber, $botType]);
return [
'id' => (int)db()->lastInsertId(),
'company_id' => $companyId,
'phone_number' => $phoneNumber,
'bot_type' => $botType,
'current_node' => null,
'ai_history' => '[]',
'metadata' => '{}',
];
}
public static function updateNode(int $id, ?string $node): void
{
$stmt = db()->prepare("UPDATE bot_context SET current_node = ?, updated_at = NOW() WHERE id = ?");
$stmt->execute([$node, $id]);
}
public static function addAiMessage(int $id, array $message): void
{
$stmt = db()->prepare("SELECT ai_history FROM bot_context WHERE id = ? LIMIT 1");
$stmt->execute([$id]);
$row = $stmt->fetch();
$history = $row ? json_decode($row['ai_history'], true) : [];
$history[] = $message;
if (count($history) > 50) {
array_shift($history);
}
$stmt = db()->prepare("UPDATE bot_context SET ai_history = ?, updated_at = NOW() WHERE id = ?");
$stmt->execute([json_encode($history), $id]);
}
public static function getAiHistory(int $id): array
{
$stmt = db()->prepare("SELECT ai_history FROM bot_context WHERE id = ? LIMIT 1");
$stmt->execute([$id]);
$row = $stmt->fetch();
return $row ? json_decode($row['ai_history'], true) ?? [] : [];
}
public static function updateMetadata(int $id, array $metadata): void
{
$stmt = db()->prepare("UPDATE bot_context SET metadata = ?, updated_at = NOW() WHERE id = ?");
$stmt->execute([json_encode($metadata), $id]);
}
public static function getMetadata(int $id): array
{
$stmt = db()->prepare("SELECT metadata FROM bot_context WHERE id = ? LIMIT 1");
$stmt->execute([$id]);
$row = $stmt->fetch();
return $row ? json_decode($row['metadata'], true) ?? [] : [];
}
public static function reset(int $id): void
{
$stmt = db()->prepare("UPDATE bot_context SET current_node = NULL, ai_history = '[]', metadata = '{}', updated_at = NOW() WHERE id = ?");
$stmt->execute([$id]);
}
}
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
class ErpMonitor
{
public static function checkAll(): array
{
$companies = CompanyRepository::findAll();
$results = [];
foreach ($companies as $company) {
$results[] = self::check($company);
}
return $results;
}
public static function check(array $company): array
{
$baseUrl = rtrim($company['api_base_url'] ?? '', '/');
$healthUrl = $baseUrl !== '' ? $baseUrl . '/health' : '';
$apiKey = $company['api_key'] ?? '';
if ($healthUrl === '') {
return [
'company_id' => (int)$company['id'],
'company_name' => $company['name'] ?? '',
'status' => 'unknown',
'latency_ms' => null,
'error' => 'Sin URL configurada',
'last_check' => date('Y-m-d H:i:s'),
];
}
$start = microtime(true);
$ch = curl_init($healthUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_HTTPHEADER => [
'X-API-Key: ' . $apiKey,
'User-Agent: bot-palmas360-monitor/1.0',
],
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
$latency = (int)((microtime(true) - $start) * 1000);
if ($error !== '') {
return [
'company_id' => (int)$company['id'],
'company_name' => $company['name'] ?? '',
'status' => 'down',
'latency_ms' => $latency,
'error' => $error,
'last_check' => date('Y-m-d H:i:s'),
];
}
if ($httpCode >= 200 && $httpCode < 400) {
return [
'company_id' => (int)$company['id'],
'company_name' => $company['name'] ?? '',
'status' => 'up',
'latency_ms' => $latency,
'error' => null,
'last_check' => date('Y-m-d H:i:s'),
];
}
return [
'company_id' => (int)$company['id'],
'company_name' => $company['name'] ?? '',
'status' => 'degraded',
'latency_ms' => $latency,
'error' => "HTTP {$httpCode}",
'last_check' => date('Y-m-d H:i:s'),
];
}
public static function checkById(int $companyId): ?array
{
$company = CompanyRepository::findById($companyId);
if ($company === null) return null;
return self::check($company);
}
public static function summary(): array
{
$results = self::checkAll();
$up = 0;
$down = 0;
$degraded = 0;
$unknown = 0;
foreach ($results as $r) {
match ($r['status']) {
'up' => $up++,
'down' => $down++,
'degraded' => $degraded++,
default => $unknown++,
};
}
return [
'total' => count($results),
'up' => $up,
'down' => $down,
'degraded' => $degraded,
'unknown' => $unknown,
'results' => $results,
];
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
class ErpSync
{
public static function sync(): array
{
$apiUrl = env('ERP_SYNC_API_URL', '');
$apiKey = env('ERP_SYNC_API_KEY', '');
if ($apiUrl === '') {
return ['error' => 'ERP_SYNC_API_URL no configurado'];
}
$httpHeader = ['Accept: application/json'];
if ($apiKey !== '') {
$httpHeader[] = 'Authorization: Bearer ' . $apiKey;
}
$ch = curl_init($apiUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $httpHeader,
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
return ['error' => "HTTP {$httpCode}", 'response' => mb_substr((string)$response, 0, 1000)];
}
$companies = json_decode($response, true);
if (!is_array($companies)) {
return ['error' => 'JSON inválido del ERP'];
}
$synced = 0;
$errors = [];
foreach ($companies as $company) {
try {
self::upsert($company);
$synced++;
} catch (\Exception $e) {
$errors[] = $e->getMessage();
}
}
return ['synced' => $synced, 'errors' => $errors];
}
private static function upsert(array $data): void
{
$stmt = db()->prepare("
INSERT INTO companies
(name, display_name, phone_number_id, display_phone, api_base_url, api_key, bot_type, requires_approval, is_active, config_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
display_name = VALUES(display_name),
api_base_url = VALUES(api_base_url),
api_key = VALUES(api_key),
bot_type = VALUES(bot_type),
requires_approval = VALUES(requires_approval),
is_active = VALUES(is_active),
config_json = VALUES(config_json),
updated_at = NOW()
");
$stmt->execute([
$data['name'] ?? '',
$data['display_name'] ?? $data['name'] ?? '',
$data['phone_number_id'] ?? '',
$data['display_phone'] ?? '',
$data['api_base_url'] ?? '',
$data['api_key'] ?? '',
$data['bot_type'] ?? 'normal',
(int)($data['requires_approval'] ?? 0),
(int)($data['is_active'] ?? 1),
isset($data['config_json']) ? (is_string($data['config_json']) ? $data['config_json'] : json_encode($data['config_json'])) : null,
]);
}
}
+240
View File
@@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
class NormalBot
{
public static function process(array $company, array $context, string $input): ?array
{
$config = self::getConfig($company);
$commands = $config['commands'] ?? [];
$flows = $config['flows'] ?? [];
$menus = $config['menus'] ?? [];
$ctxId = null;
$botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'normal');
$ctxId = (int)$botCtx['id'];
$currentNode = $botCtx['current_node'];
$normalized = self::normalize($input);
if ($currentNode !== null && isset($flows[$currentNode])) {
return self::handleFlow($flows[$currentNode], $context, $company, $ctxId);
}
$matchedCommand = null;
foreach ($commands as $keyword => $action) {
if ($normalized === self::normalize($keyword)) {
$matchedCommand = $action;
break;
}
}
if ($matchedCommand !== null) {
ConversationContext::updateNode($ctxId, $matchedCommand);
if (isset($flows[$matchedCommand])) {
return self::handleFlow($flows[$matchedCommand], $context, $company, $ctxId);
}
if (isset($menus[$matchedCommand])) {
return self::buildMenuResponse($menus[$matchedCommand], $context['from'], $company);
}
return null;
}
$greeting = $config['greeting'] ?? null;
if ($greeting !== null && $currentNode === null) {
ConversationContext::updateNode($ctxId, 'greeting');
if (isset($flows['greeting'])) {
return self::handleFlow($flows['greeting'], $context, $company, $ctxId);
}
return self::sendText($greeting, $context['from'], $company);
}
$fallback = $config['fallback'] ?? null;
if ($fallback !== null) {
return self::sendText($fallback, $context['from'], $company);
}
return null;
}
private static function handleFlow(array $flow, array $context, array $company, int $ctxId): ?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($flow['menu'] ?? [], $context['from'], $company),
'function' => self::executeFunction($flow['function'] ?? '', $flow['params'] ?? [], $context, $company, $ctxId),
default => null,
};
}
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);
})(),
'forward_to_ai' => null,
default => null,
};
}
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 === 'buttons') {
$buttons = [];
foreach ($menu['buttons'] ?? [] 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'] ?? '';
if ($json === '') {
return [];
}
$config = json_decode($json, true);
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);
}
public static function processInteractive(array $company, array $context, string $input): ?array
{
$config = self::getConfig($company);
$flows = $config['flows'] ?? [];
$botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'normal');
$ctxId = (int)$botCtx['id'];
foreach ($flows as $flowId => $flow) {
if (($flow['type'] ?? '') === 'menu') {
$menu = $flow['menu'] ?? [];
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);
}
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);
}
return null;
}
}
}
}
return null;
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
class OutboundWorker
{
private const BATCH_SIZE = 10;
public static function processQueue(): array
{
$processed = 0;
$errors = [];
$items = self::dequeue();
foreach ($items as $item) {
try {
$result = self::process($item);
if ($result['success']) {
$stmt = db()->prepare("UPDATE outbound_queue SET status = 'sent', wam_id = ?, updated_at = NOW() WHERE id = ?");
$stmt->execute([$result['wam_id'], $item['id']]);
$processed++;
} else {
$attempts = (int)$item['attempts'] + 1;
$max = (int)$item['max_attempts'];
if ($attempts >= $max) {
$stmt = db()->prepare("UPDATE outbound_queue SET status = 'failed', attempts = ?, last_error = ?, updated_at = NOW() WHERE id = ?");
$stmt->execute([$attempts, $result['error'], $item['id']]);
} else {
$stmt = db()->prepare("UPDATE outbound_queue SET status = 'queued', attempts = ?, last_error = ?, updated_at = NOW() WHERE id = ?");
$stmt->execute([$attempts, $result['error'], $item['id']]);
}
$errors[] = "msg#{$item['id']}: {$result['error']}";
}
} catch (\Exception $e) {
$errors[] = "msg#{$item['id']}: " . $e->getMessage();
}
}
return ['processed' => $processed, 'errors' => $errors];
}
private static function dequeue(): array
{
$db = db();
$db->beginTransaction();
try {
$stmt = $db->prepare("
SELECT q.*, c.phone_number_id
FROM outbound_queue q
JOIN companies c ON c.id = q.company_id
WHERE q.status = 'queued' AND c.is_active = 1
ORDER BY q.id ASC
LIMIT ?
FOR UPDATE SKIP LOCKED
");
$stmt->execute([self::BATCH_SIZE]);
$items = $stmt->fetchAll();
if (!empty($items)) {
$ids = array_column($items, 'id');
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$db->prepare("UPDATE outbound_queue SET status = 'sending', updated_at = NOW() WHERE id IN ({$placeholders})")->execute($ids);
}
$db->commit();
return $items;
} catch (\Exception $e) {
$db->rollBack();
throw $e;
}
}
private static function process(array $item): array
{
$payload = json_decode($item['payload'], true);
if (!is_array($payload)) {
return ['success' => false, 'error' => 'Payload inválido'];
}
$phoneNumberId = $item['phone_number_id'];
$type = $item['message_type'];
$to = $item['to_number'];
return match ($type) {
'text' => WhatsAppSender::sendText($to, $payload['text'] ?? '', $phoneNumberId),
'image' => WhatsAppSender::sendImage($to, $payload['media_id'] ?? $payload['url'] ?? '', $phoneNumberId, $payload['caption'] ?? null),
'template'=> WhatsAppSender::sendTemplate($to, $payload['template_name'] ?? '', $phoneNumberId, $payload['components'] ?? []),
'interactive' => WhatsAppSender::sendInteractive($to, $payload['interactive'] ?? [], $phoneNumberId),
default => ['success' => false, 'error' => "Tipo no soportado: {$type}"],
};
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
class PendingApproval
{
public static function create(int $companyId, string $phone, ?string $name, string $incomingMsg, ?array $botResponse, string $botType, array $context): array
{
$stmt = db()->prepare("
INSERT INTO pending_approval (company_id, phone_number, contact_name, incoming_msg, bot_response, bot_type, context_json)
VALUES (?, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([
$companyId,
$phone,
$name,
$incomingMsg,
$botResponse !== null ? json_encode($botResponse) : null,
$botType,
json_encode($context),
]);
return [
'id' => (int)db()->lastInsertId(),
'status' => 'pending',
];
}
public static function approve(int $id, ?string $reviewedBy = null, ?string $note = null): ?array
{
$stmt = db()->prepare("SELECT * FROM pending_approval WHERE id = ? AND status = 'pending' LIMIT 1");
$stmt->execute([$id]);
$item = $stmt->fetch();
if (!$item) {
return null;
}
$stmt = db()->prepare("UPDATE pending_approval SET status = 'approved', reviewed_by = ?, review_note = ?, reviewed_at = NOW() WHERE id = ?");
$stmt->execute([$reviewedBy, $note, $id]);
$botResponse = $item['bot_response'] ? json_decode($item['bot_response'], true) : null;
if ($botResponse !== null) {
$company = CompanyRepository::findById((int)$item['company_id']);
if ($company !== null) {
$stmt = db()->prepare("INSERT INTO outbound_queue (company_id, to_number, message_type, payload) VALUES (?, ?, ?, ?)");
$stmt->execute([
$company['id'],
$item['phone_number'],
$botResponse['type'] ?? 'text',
$botResponse['payload'] ?? json_encode(['text' => '']),
]);
}
}
return $item;
}
public static function reject(int $id, ?string $reviewedBy = null, ?string $note = null): ?array
{
$stmt = db()->prepare("SELECT * FROM pending_approval WHERE id = ? AND status = 'pending' LIMIT 1");
$stmt->execute([$id]);
$item = $stmt->fetch();
if (!$item) {
return null;
}
$stmt = db()->prepare("UPDATE pending_approval SET status = 'rejected', reviewed_by = ?, review_note = ?, reviewed_at = NOW() WHERE id = ?");
$stmt->execute([$reviewedBy, $note, $id]);
return $item;
}
public static function findByCompany(int $companyId, string $status = 'pending', int $limit = 50): array
{
$stmt = db()->prepare("
SELECT pa.*, c.name AS company_name, c.display_name AS company_display
FROM pending_approval pa
JOIN companies c ON c.id = pa.company_id
WHERE pa.company_id = ? AND pa.status = ?
ORDER BY pa.created_at DESC
LIMIT ?
");
$stmt->execute([$companyId, $status, $limit]);
return $stmt->fetchAll();
}
public static function findAll(string $status = 'pending', int $limit = 50): array
{
$stmt = db()->prepare("
SELECT pa.*, c.name AS company_name, c.display_name AS company_display
FROM pending_approval pa
JOIN companies c ON c.id = pa.company_id
WHERE pa.status = ?
ORDER BY pa.created_at DESC
LIMIT ?
");
$stmt->execute([$status, $limit]);
return $stmt->fetchAll();
}
public static function countPending(?int $companyId = null): int
{
if ($companyId !== null) {
$stmt = db()->prepare("SELECT COUNT(*) FROM pending_approval WHERE company_id = ? AND status = 'pending'");
$stmt->execute([$companyId]);
} else {
$stmt = db()->query("SELECT COUNT(*) FROM pending_approval WHERE status = 'pending'");
}
return (int)$stmt->fetchColumn();
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
class Settings
{
private static ?array $cache = null;
public static function all(): array
{
if (self::$cache !== null) return self::$cache;
$rows = db()->query("SELECT `key`, `value` FROM settings")->fetchAll();
$map = [];
foreach ($rows as $r) $map[$r['key']] = $r['value'];
self::$cache = $map;
return $map;
}
public static function get(string $key, string $default = ''): string
{
$all = self::all();
return $all[$key] ?? $default;
}
public static function set(string $key, string $value): void
{
$stmt = db()->prepare("INSERT INTO settings (`key`, `value`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `value` = ?");
$stmt->execute([$key, $value, $value]);
self::$cache = null;
}
public static function setMany(array $pairs): void
{
$db = db();
$stmt = $db->prepare("INSERT INTO settings (`key`, `value`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `value` = ?");
foreach ($pairs as $key => $value) {
$stmt->execute([$key, (string)$value, (string)$value]);
}
self::$cache = null;
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
class WhatsAppSender
{
private const API_VERSION = 'v18.0';
private const BASE_URL = 'https://graph.facebook.com';
public static function sendText(string $to, string $text, string $phoneNumberId): array
{
return self::callApi($phoneNumberId, [
'messaging_product' => 'whatsapp',
'recipient_type' => 'individual',
'to' => $to,
'type' => 'text',
'text' => ['body' => $text],
]);
}
public static function sendTemplate(string $to, string $templateName, string $phoneNumberId, array $components = []): array
{
$payload = [
'messaging_product' => 'whatsapp',
'recipient_type' => 'individual',
'to' => $to,
'type' => 'template',
'template' => [
'name' => $templateName,
'language' => ['code' => 'es'],
],
];
if (!empty($components)) {
$payload['template']['components'] = $components;
}
return self::callApi($phoneNumberId, $payload);
}
public static function sendImage(string $to, string $mediaIdOrUrl, string $phoneNumberId, ?string $caption = null): array
{
$payload = [
'messaging_product' => 'whatsapp',
'recipient_type' => 'individual',
'to' => $to,
'type' => 'image',
'image' => [
(str_starts_with($mediaIdOrUrl, 'http') ? 'link' : 'id') => $mediaIdOrUrl,
],
];
if ($caption !== null) {
$payload['image']['caption'] = $caption;
}
return self::callApi($phoneNumberId, $payload);
}
public static function sendInteractive(string $to, array $interactive, string $phoneNumberId): array
{
return self::callApi($phoneNumberId, [
'messaging_product' => 'whatsapp',
'recipient_type' => 'individual',
'to' => $to,
'type' => 'interactive',
'interactive' => $interactive,
]);
}
private static function callApi(string $phoneNumberId, array $payload): array
{
$url = self::BASE_URL . '/' . self::API_VERSION . '/' . $phoneNumberId . '/messages';
$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 => json_encode($payload),
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $token,
'Content-Type: application/json',
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
$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;
return [
'success' => $httpCode >= 200 && $httpCode < 300,
'http_code' => $httpCode,
'response' => $decoded ?? $response,
'error' => $error ?: ($decoded['error']['message'] ?? null),
'wam_id' => $decoded['messages'][0]['id'] ?? null,
];
}
}