feat: menu navegable con submenus y descarga de reportes via api_report
- NormalBot: nueva funcion api_report para descargar PDF/Excel del ERP y enviarlo por WhatsApp - NormalBot: resolveMenu() para referenciar menus por nombre en los flujos - WhatsAppSender: uploadMedia() y sendDocument() para envio de documentos - BotRouter: guarda respuestas en conversations para visibilidad en el chat - Script apply-menus.php con estructura completa de menus y endpoints
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../config/env.php';
|
||||
require_once __DIR__ . '/../config/db.php';
|
||||
require_once __DIR__ . '/../services/CompanyRepository.php';
|
||||
|
||||
$menuConfig = [
|
||||
'commands' => [
|
||||
'menu' => 'show_main_menu',
|
||||
'informes' => 'descargar_informes',
|
||||
'info' => 'recibir_info',
|
||||
'inicio' => 'show_main_menu',
|
||||
'volver' => 'show_main_menu',
|
||||
],
|
||||
'menus' => [
|
||||
'show_main_menu' => [
|
||||
'type' => 'list',
|
||||
'header' => 'Menú Principal',
|
||||
'body' => 'Selecciona una opción:',
|
||||
'footer' => 'Palmas360',
|
||||
'button' => 'Ver opciones',
|
||||
'sections' => [
|
||||
[
|
||||
'title' => 'Opciones',
|
||||
'rows' => [
|
||||
['id' => 'recibir_info', 'title' => 'Recibir Información', 'description' => 'Obtén información útil'],
|
||||
['id' => 'descargar_informes', 'title' => 'Descargar Informes', 'description' => 'Accede a tus informes'],
|
||||
['id' => 'menu_mixto', 'title' => 'Menú Mixto', 'description' => 'Combina ambas opciones'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'submenu_informes' => [
|
||||
'type' => 'list',
|
||||
'header' => 'Informes',
|
||||
'body' => 'Selecciona el tipo de informe:',
|
||||
'footer' => 'Palmas360',
|
||||
'button' => 'Ver informes',
|
||||
'sections' => [
|
||||
[
|
||||
'title' => 'Tipo de Informe',
|
||||
'rows' => [
|
||||
['id' => 'submenu_ciclos', 'title' => 'Ciclos', 'description' => 'Informes de ciclos'],
|
||||
['id' => 'submenu_produccion', 'title' => 'Producción', 'description' => 'Informes de producción'],
|
||||
['id' => 'submenu_mantenimiento_fecha', 'title' => 'Ciclos Mtto. Fecha', 'description' => 'Ciclos de mantenimiento a la fecha'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'submenu_ciclos' => [
|
||||
'type' => 'list',
|
||||
'header' => 'Ciclos',
|
||||
'body' => 'Selecciona tipo de ciclo:',
|
||||
'footer' => 'Palmas360',
|
||||
'button' => 'Ver ciclos',
|
||||
'sections' => [
|
||||
[
|
||||
'title' => 'Ciclos',
|
||||
'rows' => [
|
||||
['id' => 'submenu_ciclos_cosecha', 'title' => 'Ciclos Cosecha', 'description' => 'Ciclos de cosecha'],
|
||||
['id' => 'submenu_ciclos_sanidad', 'title' => 'Ciclos Sanidad', 'description' => 'Ciclos de sanidad'],
|
||||
['id' => 'submenu_ciclo_cosecha', 'title' => 'Ciclo de Cosecha','description' => 'Día o histórico de cosecha'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'submenu_ciclo_cosecha' => [
|
||||
'type' => 'button',
|
||||
'body' => 'Selecciona una opción:',
|
||||
'buttons' => [
|
||||
['id' => 'ciclo_cosecha_dia', 'title' => 'Día Ciclo Cosecha'],
|
||||
['id' => 'ciclo_cosecha_historico', 'title' => 'Histórico 30 Días'],
|
||||
],
|
||||
],
|
||||
'menu_mixto' => [
|
||||
'type' => 'button',
|
||||
'body' => '¿Qué deseas hacer?',
|
||||
'buttons' => [
|
||||
['id' => 'recibir_info', 'title' => 'Recibir Información'],
|
||||
['id' => 'descargar_informes', 'title' => 'Descargar Informes'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'flows' => [
|
||||
'show_main_menu' => ['type' => 'menu', 'menu' => 'show_main_menu'],
|
||||
'recibir_info' => ['type' => 'function', 'function' => 'forward_to_ai'],
|
||||
'descargar_informes' => ['type' => 'menu', 'menu' => 'submenu_informes'],
|
||||
'menu_mixto' => ['type' => 'menu', 'menu' => 'menu_mixto'],
|
||||
'submenu_ciclos' => ['type' => 'menu', 'menu' => 'submenu_ciclos'],
|
||||
'submenu_produccion' => ['type' => 'function', 'function' => 'api_report', 'params' => ['endpoint' => 'bot-api.php?reporte=produccion', 'filename' => 'reporte_produccion.xlsx', 'caption' => 'Reporte de producción']],
|
||||
'submenu_mantenimiento_fecha' => ['type' => 'function', 'function' => 'api_report', 'params' => ['endpoint' => 'bot-api.php?reporte=mantenimiento_fecha', 'filename' => 'reporte_mantenimiento.xlsx', 'caption' => 'Ciclos de mantenimiento a la fecha']],
|
||||
'submenu_ciclos_cosecha' => ['type' => 'function', 'function' => 'api_report', 'params' => ['endpoint' => 'bot-api.php?reporte=ciclos_cosecha', 'filename' => 'reporte_ciclos_cosecha.xlsx', 'caption' => 'Ciclos de cosecha']],
|
||||
'submenu_ciclos_sanidad' => ['type' => 'function', 'function' => 'api_report', 'params' => ['endpoint' => 'bot-api.php?reporte=ciclos_sanidad', 'filename' => 'reporte_ciclos_sanidad.xlsx', 'caption' => 'Ciclos de sanidad']],
|
||||
'submenu_ciclo_cosecha' => ['type' => 'menu', 'menu' => 'submenu_ciclo_cosecha'],
|
||||
'ciclo_cosecha_dia' => ['type' => 'function', 'function' => 'api_report', 'params' => ['endpoint' => 'bot-api.php?reporte=ciclos_cosecha_dia', 'filename' => 'ciclo_cosecha_hoy.xlsx', 'caption' => 'Ciclo de cosecha del día de hoy']],
|
||||
'ciclo_cosecha_historico' => ['type' => 'function', 'function' => 'api_report', 'params' => ['endpoint' => 'bot-api.php?reporte=ciclos_cosecha_historico', 'filename' => 'ciclo_cosecha_30dias.xlsx', 'caption' => 'Histórico ciclos de cosecha últimos 30 días']],
|
||||
],
|
||||
];
|
||||
|
||||
$companies = CompanyRepository::findAll(true);
|
||||
$count = 0;
|
||||
|
||||
foreach ($companies as $company) {
|
||||
$existing = [];
|
||||
if (!empty($company['config_json'])) {
|
||||
$decoded = json_decode($company['config_json'], true);
|
||||
if (is_array($decoded)) {
|
||||
$existing = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
$preserveKeys = ['greeting', 'fallback', 'ai_prompt', 'ai_model', 'ai_temperature', 'ai_provider', 'approval_webhook', 'ignore_prefixes', 'ai_max_tokens'];
|
||||
foreach ($preserveKeys as $key) {
|
||||
if (array_key_exists($key, $existing)) {
|
||||
$menuConfig[$key] = $existing[$key];
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($menuConfig['greeting'])) {
|
||||
$menuConfig['greeting'] = '¡Bienvenido! Escribe *menu* para ver las opciones disponibles.';
|
||||
}
|
||||
if (!isset($menuConfig['fallback'])) {
|
||||
$menuConfig['fallback'] = 'No entendí. Escribe *menu* para ver las opciones disponibles.';
|
||||
}
|
||||
|
||||
CompanyRepository::save([
|
||||
'id' => (int)$company['id'],
|
||||
'config_json' => json_encode($menuConfig, JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
|
||||
$name = $company['display_name'] ?: $company['name'];
|
||||
echo "✔ {$name} actualizada\n";
|
||||
$count++;
|
||||
}
|
||||
|
||||
echo "\n✅ {$count} empresas actualizadas con los nuevos menús.\n";
|
||||
@@ -143,6 +143,26 @@ class BotRouter
|
||||
$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}");
|
||||
|
||||
$messageId = 'bot_out_' . $company['id'] . '_' . time() . '_' . bin2hex(random_bytes(4));
|
||||
$content = $payload;
|
||||
if ($type !== 'text') {
|
||||
$decoded = json_decode($payload, true);
|
||||
$content = $decoded['caption'] ?? $decoded['body'] ?? $decoded['text'] ?? $payload;
|
||||
}
|
||||
$stmt2 = db()->prepare("
|
||||
INSERT IGNORE INTO conversations
|
||||
(company_id, message_id, phone_number, direction, message_type, content, timestamp)
|
||||
VALUES (?, ?, ?, 'outbound', ?, ?, ?)
|
||||
");
|
||||
$stmt2->execute([
|
||||
$company['id'],
|
||||
$messageId,
|
||||
$to,
|
||||
$type,
|
||||
mb_substr($content, 0, 1000),
|
||||
time(),
|
||||
]);
|
||||
} catch (\PDOException $e) {
|
||||
self::log('ERROR encolando respuesta: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
+100
-2
@@ -62,6 +62,16 @@ class NormalBot
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function resolveMenu($menuRef, array $company): array
|
||||
{
|
||||
if (is_string($menuRef)) {
|
||||
$config = self::getConfig($company);
|
||||
$menus = $config['menus'] ?? [];
|
||||
return $menus[$menuRef] ?? [];
|
||||
}
|
||||
return is_array($menuRef) ? $menuRef : [];
|
||||
}
|
||||
|
||||
private static function handleFlow(array $flow, array $context, array $company, int $ctxId): ?array
|
||||
{
|
||||
$type = $flow['type'] ?? 'text';
|
||||
@@ -69,7 +79,7 @@ class NormalBot
|
||||
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),
|
||||
'menu' => self::buildMenuResponse(self::resolveMenu($flow['menu'] ?? [], $company), $context['from'], $company),
|
||||
'function' => self::executeFunction($flow['function'] ?? '', $flow['params'] ?? [], $context, $company, $ctxId),
|
||||
default => null,
|
||||
};
|
||||
@@ -83,10 +93,98 @@ class NormalBot
|
||||
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
|
||||
{
|
||||
$endpoint = $params['endpoint'] ?? '';
|
||||
if ($endpoint === '') {
|
||||
return self::sendText('Error: endpoint no configurado.', $context['from'], $company);
|
||||
}
|
||||
|
||||
$baseUrl = rtrim($company['api_base_url'] ?? '', '/');
|
||||
$url = $baseUrl . '/' . ltrim($endpoint, '/');
|
||||
$apiKey = $company['api_key'] ?? '';
|
||||
|
||||
$query = $params['query'] ?? [];
|
||||
if (!empty($query)) {
|
||||
$url .= '?' . http_build_query($query);
|
||||
}
|
||||
|
||||
$url .= (str_contains($url, '?') ? '&' : '?') . 'telefono=' . urlencode($context['from']) . '&nombre=' . urlencode($context['name'] ?? '');
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => 60,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'X-API-Key: ' . $apiKey,
|
||||
'User-Agent: bot-palmas360/1.0',
|
||||
],
|
||||
]);
|
||||
$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 [{$endpoint}]: {$error}");
|
||||
return self::sendText('Error al obtener el reporte. Intenta de nuevo.', $context['from'], $company);
|
||||
}
|
||||
|
||||
if ($httpCode >= 400) {
|
||||
self::log("API report HTTP {$httpCode} [{$endpoint}]: " . 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 [{$endpoint}]: " . ($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';
|
||||
@@ -211,7 +309,7 @@ class NormalBot
|
||||
|
||||
foreach ($flows as $flowId => $flow) {
|
||||
if (($flow['type'] ?? '') === 'menu') {
|
||||
$menu = $flow['menu'] ?? [];
|
||||
$menu = self::resolveMenu($flow['menu'] ?? [], $company);
|
||||
foreach ($menu['sections'] ?? [] as $section) {
|
||||
foreach ($section['rows'] ?? [] as $row) {
|
||||
if (($row['id'] ?? '') === $input) {
|
||||
|
||||
@@ -67,6 +67,65 @@ class WhatsAppSender
|
||||
]);
|
||||
}
|
||||
|
||||
public static function uploadMedia(string $filePath, string $mimeType, string $phoneNumberId): array
|
||||
{
|
||||
$url = self::BASE_URL . '/' . self::API_VERSION . '/' . $phoneNumberId . '/media';
|
||||
$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 => [
|
||||
'messaging_product' => 'whatsapp',
|
||||
'file' => new \CURLFile($filePath, $mimeType, basename($filePath)),
|
||||
],
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Bearer ' . $token,
|
||||
],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 60,
|
||||
]);
|
||||
|
||||
$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;
|
||||
unlink($filePath);
|
||||
|
||||
return [
|
||||
'success' => $httpCode >= 200 && $httpCode < 300,
|
||||
'http_code' => $httpCode,
|
||||
'media_id' => $decoded['id'] ?? null,
|
||||
'response' => $decoded ?? $response,
|
||||
'error' => $error ?: null,
|
||||
];
|
||||
}
|
||||
|
||||
public static function sendDocument(string $to, string $mediaId, string $phoneNumberId, ?string $caption = null, ?string $filename = null): array
|
||||
{
|
||||
$document = ['id' => $mediaId];
|
||||
if ($filename !== null) {
|
||||
$document['filename'] = $filename;
|
||||
}
|
||||
if ($caption !== null) {
|
||||
$document['caption'] = $caption;
|
||||
}
|
||||
|
||||
return self::callApi($phoneNumberId, [
|
||||
'messaging_product' => 'whatsapp',
|
||||
'recipient_type' => 'individual',
|
||||
'to' => $to,
|
||||
'type' => 'document',
|
||||
'document' => $document,
|
||||
]);
|
||||
}
|
||||
|
||||
private static function callApi(string $phoneNumberId, array $payload): array
|
||||
{
|
||||
$url = self::BASE_URL . '/' . self::API_VERSION . '/' . $phoneNumberId . '/messages';
|
||||
|
||||
Reference in New Issue
Block a user