- resolveMenu ahora recibe los menus ya fusionados (global + per_type) para que flujos que referencian menús de otro per_type se resuelvan correctamente - handleFlow y processInteractive propagan el array de menus fusionados - Corrige variable indefinida $endpoint → $endpointKey en 3 logs de executeApiReport - buildMenuResponse cambia tipo de retorno a ?array para evitar TypeError en PHP 8 - BD: flow global show_main_menu apunta a menú show_main_menu (completo) en lugar de show_menu_cat2 (solo existía en per_type[2]), fix para usuarios permission_type=3 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
378 lines
15 KiB
PHP
378 lines
15 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
class NormalBot
|
|
{
|
|
public static function process(array $company, array $context, string $input): ?array
|
|
{
|
|
$config = self::getConfig($company);
|
|
$permType = (string)($company['_permission_type'] ?? 1);
|
|
$perType = $config['per_type'][$permType] ?? [];
|
|
|
|
$commands = $perType['commands'] ?? $config['commands'] ?? [];
|
|
$flows = array_merge($config['flows'] ?? [], $perType['flows'] ?? []);
|
|
$menus = array_merge($config['menus'] ?? [], $perType['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, $menus);
|
|
}
|
|
|
|
$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, $menus);
|
|
}
|
|
|
|
if (isset($menus[$matchedCommand])) {
|
|
return self::buildMenuResponse($menus[$matchedCommand], $context['from'], $company);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
$greeting = $perType['greeting'] ?? $config['greeting'] ?? null;
|
|
if ($greeting !== null && $currentNode === null) {
|
|
$greetingFlowId = $perType['greeting_flow'] ?? 'greeting';
|
|
if (isset($flows[$greetingFlowId])) {
|
|
ConversationContext::updateNode($ctxId, $greetingFlowId);
|
|
return self::handleFlow($flows[$greetingFlowId], $context, $company, $ctxId, $menus);
|
|
}
|
|
|
|
$response = self::sendText($greeting, $context['from'], $company);
|
|
ConversationContext::updateNode($ctxId, null);
|
|
return $response;
|
|
}
|
|
|
|
$fallbackFlowId = $perType['fallback_flow'] ?? $config['fallback_flow'] ?? null;
|
|
if ($fallbackFlowId !== null && isset($flows[$fallbackFlowId])) {
|
|
ConversationContext::updateNode($ctxId, $fallbackFlowId);
|
|
return self::handleFlow($flows[$fallbackFlowId], $context, $company, $ctxId, $menus);
|
|
}
|
|
|
|
$fallback = $perType['fallback'] ?? $config['fallback'] ?? null;
|
|
if ($fallback !== null) {
|
|
ConversationContext::updateNode($ctxId, null);
|
|
return self::sendText($fallback, $context['from'], $company);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static function resolveMenu($menuRef, array $company, array $allMenus = []): array
|
|
{
|
|
if (is_string($menuRef)) {
|
|
if (isset($allMenus[$menuRef])) {
|
|
return $allMenus[$menuRef];
|
|
}
|
|
$config = self::getConfig($company);
|
|
return ($config['menus'] ?? [])[$menuRef] ?? [];
|
|
}
|
|
return is_array($menuRef) ? $menuRef : [];
|
|
}
|
|
|
|
private static function handleFlow(array $flow, array $context, array $company, int $ctxId, array $menus = []): ?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(self::resolveMenu($flow['menu'] ?? [], $company, $menus), $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,
|
|
'api_report' => self::executeApiReport($params, $context, $company, $ctxId),
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
private static function executeApiReport(array $params, array $context, array $company, int $ctxId): ?array
|
|
{
|
|
$endpointKey = $params['endpoint_key'] ?? '';
|
|
if ($endpointKey === '') {
|
|
return self::sendText('Error: endpoint no configurado.', $context['from'], $company);
|
|
}
|
|
|
|
$stmt = db()->prepare("SELECT url, method FROM company_endpoints WHERE company_id = ? AND endpoint_key = ? AND is_active = 1 LIMIT 1");
|
|
$stmt->execute([(int)$company['id'], $endpointKey]);
|
|
$ep = $stmt->fetch();
|
|
|
|
if (!$ep || empty($ep['url'])) {
|
|
self::log("API report: endpoint_key '{$endpointKey}' no configurado para company {$company['id']}");
|
|
return self::sendText('El informe solicitado no está configurado. Contacta al administrador.', $context['from'], $company);
|
|
}
|
|
|
|
$url = $ep['url'];
|
|
$method = strtoupper($ep['method'] ?? 'GET');
|
|
$apiKey = $company['api_key'] ?? '';
|
|
|
|
$extraQuery = $params['query'] ?? [];
|
|
$dateMode = $params['date_mode'] ?? '';
|
|
if ($dateMode === 'today') {
|
|
$extraQuery['fecha'] = date('Y-m-d');
|
|
} elseif ($dateMode === 'last_30') {
|
|
$extraQuery['fecha_inicio'] = date('Y-m-d', strtotime('-30 days'));
|
|
$extraQuery['fecha_fin'] = date('Y-m-d');
|
|
}
|
|
$extraQuery['telefono'] = $context['from'];
|
|
$extraQuery['nombre'] = $context['name'] ?? '';
|
|
|
|
$glue = str_contains($url, '?') ? '&' : '?';
|
|
$url .= $glue . http_build_query($extraQuery);
|
|
|
|
$ch = curl_init($url);
|
|
$curlOpts = [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_FOLLOWLOCATION => true,
|
|
CURLOPT_TIMEOUT => 60,
|
|
CURLOPT_HTTPHEADER => [
|
|
'Authorization: Bearer ' . $apiKey,
|
|
'X-API-Key: ' . $apiKey,
|
|
'User-Agent: bot-palmas360/1.0',
|
|
],
|
|
];
|
|
if ($method === 'POST') {
|
|
$curlOpts[CURLOPT_POST] = true;
|
|
$curlOpts[CURLOPT_POSTFIELDS] = '{}';
|
|
}
|
|
curl_setopt_array($ch, $curlOpts);
|
|
$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 [{$endpointKey}]: {$error}");
|
|
return self::sendText('Error al obtener el reporte. Intenta de nuevo.', $context['from'], $company);
|
|
}
|
|
|
|
if ($httpCode >= 400) {
|
|
self::log("API report HTTP {$httpCode} [{$endpointKey}]: " . 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 [{$endpointKey}]: " . ($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';
|
|
|
|
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; // tipo de menú desconocido
|
|
}
|
|
|
|
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);
|
|
$permType = (string)($company['_permission_type'] ?? 1);
|
|
$perType = $config['per_type'][$permType] ?? [];
|
|
$flows = array_merge($config['flows'] ?? [], $perType['flows'] ?? []);
|
|
$menus = array_merge($config['menus'] ?? [], $perType['menus'] ?? []);
|
|
|
|
$botCtx = ConversationContext::getOrCreate((int)$company['id'], $context['from'], 'normal');
|
|
$ctxId = (int)$botCtx['id'];
|
|
|
|
foreach ($flows as $flowId => $flow) {
|
|
if (($flow['type'] ?? '') === 'menu') {
|
|
$menu = self::resolveMenu($flow['menu'] ?? [], $company, $menus);
|
|
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, $menus);
|
|
}
|
|
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, $menus);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|