Cuando el next_node de un collecting está en menus (no flows), handleCollectingInput retornaba null sin respuesta. Ahora intenta buildMenuResponse como fallback, lo que corrige el flujo de selección de grupo en mantenimiento. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1587 lines
73 KiB
PHP
1587 lines
73 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
class NormalBot
|
||
{
|
||
public static function process(array $company, array $context, string $input, bool $suppressFallback = false): ?array
|
||
{
|
||
$config = self::getConfig($company);
|
||
$permType = (string)($company['_permission_type'] ?? 1);
|
||
$perType = $config['per_type'][$permType] ?? [];
|
||
|
||
// Per-type commands EXTEND globals (not replace), so salir/exit/etc. always work.
|
||
$commands = array_merge($config['commands'] ?? [], $perType['commands'] ?? []);
|
||
$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'];
|
||
$currentNode = $botCtx['current_node'];
|
||
$normalized = self::normalize($input);
|
||
|
||
// 1. Commands always win — escape from any state (salir, menu, etc.)
|
||
foreach ($commands as $keyword => $action) {
|
||
if ($normalized === self::normalize((string)$keyword)) {
|
||
// Clear any in-progress multi-step state so the command is a clean reset
|
||
$m = ConversationContext::getMetadata($ctxId);
|
||
$dirty = false;
|
||
foreach (['collecting', '__foreach', '__cap'] as $stateKey) {
|
||
if (isset($m[$stateKey])) { unset($m[$stateKey]); $dirty = true; }
|
||
}
|
||
if ($dirty) ConversationContext::updateMetadata($ctxId, $m);
|
||
|
||
// "menu/inicio/volver" → prefer per-type greeting_menu over global show_main_menu
|
||
$greetingMenuKey = $perType['greeting_menu'] ?? null;
|
||
if ($action === 'show_main_menu' && $greetingMenuKey && isset($menus[$greetingMenuKey])) {
|
||
$action = $greetingMenuKey;
|
||
}
|
||
|
||
ConversationContext::updateNode($ctxId, $action);
|
||
// Commands that match a flow
|
||
if (isset($flows[$action])) {
|
||
return self::handleFlow($flows[$action], $context, $company, $ctxId, $menus);
|
||
}
|
||
// Commands that match a menu
|
||
if (isset($menus[$action])) {
|
||
return self::buildMenuResponse($menus[$action], $context['from'], $company);
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// 2. Multi-step form: user is currently filling in fields
|
||
$meta = ConversationContext::getMetadata($ctxId);
|
||
if (!empty($meta['collecting'])) {
|
||
return self::handleCollectingInput($meta, $input, 'text', $context, $company, $ctxId, $flows, $menus);
|
||
}
|
||
|
||
// 2b. collect_for_each: collecting a value for each item in a list
|
||
if (!empty($meta['__foreach'])) {
|
||
return self::handleForeachInput($meta, $input, $context, $company, $ctxId, $flows);
|
||
}
|
||
|
||
// 2c. collect_and_post: collecting sequential fields then POSTing
|
||
if (!empty($meta['__cap'])) {
|
||
return self::handleCapInput($meta, $input, $context, $company, $ctxId);
|
||
}
|
||
|
||
// 2d. api_vars: collecting URL variables before executing an api_report endpoint
|
||
if (!empty($meta['__api_vars'])) {
|
||
return self::handleApiVarsInput($meta, $input, $context, $company, $ctxId, $flows, $menus);
|
||
}
|
||
|
||
// 3. Active node — resume conversation
|
||
$sentinels = ['collecting', '__greeted'];
|
||
if ($currentNode !== null && !in_array($currentNode, $sentinels, true) && isset($flows[$currentNode])) {
|
||
return self::handleFlow($flows[$currentNode], $context, $company, $ctxId, $menus);
|
||
}
|
||
|
||
// 4. Welcome message — shown once for brand-new sessions before the category menu
|
||
$welcomeMenuKey = $config['welcome_menu'] ?? null;
|
||
if ($welcomeMenuKey && $currentNode === null && isset($menus[$welcomeMenuKey])) {
|
||
ConversationContext::updateNode($ctxId, '__greeted');
|
||
return self::buildMenuResponse($menus[$welcomeMenuKey], $context['from'], $company);
|
||
}
|
||
|
||
// 4b. Per-type greeting menu — only for brand-new sessions (currentNode === null)
|
||
$greetingMenuKey = $perType['greeting_menu'] ?? null;
|
||
if ($greetingMenuKey !== null && $currentNode === null && isset($menus[$greetingMenuKey])) {
|
||
ConversationContext::updateNode($ctxId, '__greeted'); // sentinel: evita re-envío del menú
|
||
return self::buildMenuResponse($menus[$greetingMenuKey], $context['from'], $company);
|
||
}
|
||
|
||
// 5. Greeting text
|
||
$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;
|
||
}
|
||
|
||
// 6. Fallback flow
|
||
$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);
|
||
}
|
||
|
||
// 7. Greeting menu as fallback (texto no reconocido después de estar en __greeted)
|
||
if (!$suppressFallback && $greetingMenuKey !== null && isset($menus[$greetingMenuKey])) {
|
||
ConversationContext::updateNode($ctxId, '__greeted');
|
||
return self::buildMenuResponse($menus[$greetingMenuKey], $context['from'], $company);
|
||
}
|
||
|
||
// 8. Static fallback text
|
||
if (!$suppressFallback) {
|
||
$fallback = $perType['fallback'] ?? $config['fallback'] ?? null;
|
||
if ($fallback !== null) {
|
||
ConversationContext::updateNode($ctxId, null);
|
||
return self::sendText($fallback, $context['from'], $company);
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// ── Multi-step collecting ────────────────────────────────────────────────
|
||
|
||
private static function handleCollectingInput(
|
||
array $meta, string $input, string $inputType,
|
||
array $context, array $company, int $ctxId, array $flows, array $menus
|
||
): ?array {
|
||
$col = $meta['collecting'];
|
||
$group = $col['meta_group'] ?? '';
|
||
$key = $col['meta_key'] ?? '';
|
||
$nextNode = $col['next_node'] ?? null;
|
||
$validIds = $col['__valid_ids'] ?? null;
|
||
$labelMap = $col['__label_map'] ?? [];
|
||
|
||
$resolved = trim($input);
|
||
if ($validIds !== null && !in_array($resolved, $validIds, true)) {
|
||
// Intento de match por label (útil cuando viene texto transcrito de audio)
|
||
$inputNorm = self::normalize($resolved);
|
||
$matched = null;
|
||
foreach ($labelMap as $label => $id) {
|
||
$lNorm = self::normalize((string)$label);
|
||
if ($lNorm === $inputNorm || str_contains($inputNorm, $lNorm) || str_contains($lNorm, $inputNorm)) {
|
||
$matched = (string)$id;
|
||
break;
|
||
}
|
||
}
|
||
if ($matched === null) {
|
||
return self::sendText(
|
||
'⚠️ Por favor selecciona una opción válida de la lista.',
|
||
$context['from'], $company
|
||
);
|
||
}
|
||
$resolved = $matched;
|
||
}
|
||
|
||
// Si el valor resuelto es un redirect_id, navegar al flow sin guardar valor
|
||
$redirectIds = $col['__redirect_ids'] ?? [];
|
||
if (in_array($resolved, $redirectIds, true)) {
|
||
unset($meta['collecting']);
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
ConversationContext::updateNode($ctxId, $resolved);
|
||
if (isset($flows[$resolved])) {
|
||
return self::handleFlow($flows[$resolved], $context, $company, $ctxId, $menus);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
$formData = $meta[$group] ?? [];
|
||
$formData[$key] = $resolved;
|
||
$meta[$group] = $formData;
|
||
unset($meta['collecting']);
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
|
||
if ($nextNode !== null) {
|
||
ConversationContext::updateNode($ctxId, $nextNode);
|
||
if (isset($flows[$nextNode])) {
|
||
return self::handleFlow($flows[$nextNode], $context, $company, $ctxId, $menus);
|
||
}
|
||
if (isset($menus[$nextNode])) {
|
||
return self::buildMenuResponse($menus[$nextNode], $context['from'], $company);
|
||
}
|
||
} else {
|
||
ConversationContext::updateNode($ctxId, null);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private static function handleCollectInput(array $flow, array $context, array $company, int $ctxId): ?array
|
||
{
|
||
$meta = ConversationContext::getMetadata($ctxId);
|
||
$meta['collecting'] = [
|
||
'meta_group' => $flow['meta_group'] ?? '',
|
||
'meta_key' => $flow['meta_key'] ?? '',
|
||
'next_node' => $flow['next_node'] ?? null,
|
||
];
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
ConversationContext::updateNode($ctxId, 'collecting');
|
||
return self::sendText($flow['prompt'] ?? '¿Cuál es el valor?', $context['from'], $company);
|
||
}
|
||
|
||
// ── Dynamic list (from endpoint) ─────────────────────────────────────────
|
||
|
||
private static function handleDynamicList(array $flow, array $context, array $company, int $ctxId): ?array
|
||
{
|
||
$meta = ConversationContext::getMetadata($ctxId);
|
||
$items = self::fetchDynamicList($flow['source_endpoint_key'] ?? '', $company, $meta);
|
||
|
||
if ($items === null || count($items) === 0) {
|
||
ConversationContext::reset($ctxId);
|
||
return self::sendText(
|
||
'No se pudieron cargar las opciones ahora. Escribe *menu* para volver.',
|
||
$context['from'], $company
|
||
);
|
||
}
|
||
|
||
$valueField = $flow['value_field'] ?? 'id';
|
||
$labelField = $flow['label_field'] ?? 'name';
|
||
$group = $flow['meta_group'] ?? '';
|
||
$key = $flow['meta_key'] ?? '';
|
||
$nextNode = $flow['next_node'] ?? null;
|
||
|
||
// Mapa label → id para match fuzzy (usado tanto en auto-select como en collecting)
|
||
$labelMap = [];
|
||
foreach ($items as $item) {
|
||
$labelMap[(string)($item[$labelField] ?? '')] = (string)($item[$valueField] ?? '');
|
||
}
|
||
|
||
// Auto-select cuando hay exactamente 1 opción — no mostrar lista
|
||
if (count($items) === 1) {
|
||
$item = $items[0];
|
||
$formData = $meta[$group] ?? [];
|
||
$formData[$key] = (string)($item[$valueField] ?? '');
|
||
$meta[$group] = $formData;
|
||
unset($meta['__nlu_entities']);
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
if ($nextNode !== null) {
|
||
ConversationContext::updateNode($ctxId, $nextNode);
|
||
$cfg = self::getConfig($company);
|
||
$permType = (string)($company['_permission_type'] ?? 1);
|
||
$pt = $cfg['per_type'][$permType] ?? [];
|
||
$allFlows = array_merge($cfg['flows'] ?? [], $pt['flows'] ?? []);
|
||
$allMenus = array_merge($cfg['menus'] ?? [], $pt['menus'] ?? []);
|
||
if (isset($allFlows[$nextNode])) {
|
||
return self::handleFlow($allFlows[$nextNode], $context, $company, $ctxId, $allMenus);
|
||
}
|
||
}
|
||
ConversationContext::updateNode($ctxId, null);
|
||
return null;
|
||
}
|
||
|
||
// Si el NLU extrajo entities, intentar auto-seleccionar sin mostrar lista
|
||
$entities = $meta['__nlu_entities'] ?? [];
|
||
unset($meta['__nlu_entities']);
|
||
if (!empty($entities)) {
|
||
$matchedId = null;
|
||
foreach ($entities as $ev) {
|
||
$evNorm = self::normalize((string)$ev);
|
||
foreach ($labelMap as $label => $id) {
|
||
$lNorm = self::normalize($label);
|
||
if ($lNorm === $evNorm || str_contains($evNorm, $lNorm) || str_contains($lNorm, $evNorm)) {
|
||
$matchedId = $id;
|
||
break 2;
|
||
}
|
||
}
|
||
}
|
||
if ($matchedId !== null) {
|
||
$formData = $meta[$group] ?? [];
|
||
$formData[$key] = $matchedId;
|
||
$meta[$group] = $formData;
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
if ($nextNode !== null) {
|
||
ConversationContext::updateNode($ctxId, $nextNode);
|
||
$config = json_decode($company['config_json'] ?? '{}', true);
|
||
$flows = $config['flows'] ?? [];
|
||
$menus = $config['menus'] ?? [];
|
||
if (isset($flows[$nextNode])) {
|
||
return self::handleFlow($flows[$nextNode], $context, $company, $ctxId, $menus);
|
||
}
|
||
}
|
||
ConversationContext::updateNode($ctxId, null);
|
||
return null;
|
||
}
|
||
// Sin match: continuar mostrando la lista normal
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
}
|
||
|
||
// Opciones fijas que redirigen a otro flow en vez de guardar el valor
|
||
$redirectIds = [];
|
||
$appendOptions = $flow['append_options'] ?? [];
|
||
foreach ($appendOptions as $opt) {
|
||
$optId = (string)($opt['id'] ?? '');
|
||
if ($optId === '') continue;
|
||
$labelMap[(string)($opt['title'] ?? $optId)] = $optId;
|
||
$redirectIds[] = $optId;
|
||
}
|
||
|
||
$meta['collecting'] = [
|
||
'meta_group' => $group,
|
||
'meta_key' => $key,
|
||
'next_node' => $nextNode,
|
||
'__valid_ids' => array_merge(array_column($items, $valueField), $redirectIds),
|
||
'__label_map' => $labelMap,
|
||
'__redirect_ids'=> $redirectIds,
|
||
];
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
ConversationContext::updateNode($ctxId, 'collecting');
|
||
|
||
return self::buildDynamicListResponse($items, $flow, $context['from'], $company);
|
||
}
|
||
|
||
// Reemplaza {variable} en la URL con valores del metadata acumulado
|
||
private static function substituteUrlVars(string $url, array $meta): string
|
||
{
|
||
// Priority: __ep_vars_combined (vars collected/resolved for this endpoint call)
|
||
$epVars = $meta['__ep_vars_combined'] ?? [];
|
||
foreach ($epVars as $key => $value) {
|
||
$url = str_replace('{' . $key . '}', urlencode((string)$value), $url);
|
||
}
|
||
// Then the rest of meta groups
|
||
foreach ($meta as $group => $fields) {
|
||
if (!is_array($fields) || str_starts_with($group, '__')) continue;
|
||
foreach ($fields as $key => $value) {
|
||
$url = str_replace('{' . $key . '}', urlencode((string)$value), $url);
|
||
}
|
||
}
|
||
return $url;
|
||
}
|
||
|
||
private static function fetchDynamicList(string $endpointKey, array $company, array $meta = []): ?array
|
||
{
|
||
if ($endpointKey === '') return null;
|
||
|
||
$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'])) return null;
|
||
|
||
$apiKey = $company['api_key'] ?? '';
|
||
$url = self::buildUrl(self::substituteUrlVars($ep['url'], $meta), $company);
|
||
$ch = curl_init($url);
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_TIMEOUT => 10,
|
||
CURLOPT_HTTPHEADER => [
|
||
'Authorization: Bearer ' . $apiKey,
|
||
'X-API-Key: ' . $apiKey,
|
||
],
|
||
]);
|
||
$body = curl_exec($ch);
|
||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
curl_close($ch);
|
||
|
||
if ($code !== 200 || !$body) return null;
|
||
$data = json_decode($body, true);
|
||
if (!is_array($data)) return null;
|
||
// Accept {data:[...]}, {datos:[...]}, or bare array
|
||
if (isset($data['data']) && is_array($data['data'])) return $data['data'];
|
||
if (isset($data['datos']) && is_array($data['datos'])) return $data['datos'];
|
||
return $data;
|
||
}
|
||
|
||
private static function buildDynamicListResponse(array $items, array $flow, string $to, array $company): ?array
|
||
{
|
||
$valueField = $flow['value_field'] ?? 'id';
|
||
$labelField = $flow['label_field'] ?? 'name';
|
||
// Auto-detect multi-section if items carry a 'section' key
|
||
$sectionField = $flow['section_field'] ?? (isset($items[0]['section']) ? 'section' : null);
|
||
|
||
if ($sectionField !== null) {
|
||
$grouped = [];
|
||
foreach ($items as $item) {
|
||
$id = (string)($item[$valueField] ?? '');
|
||
if ($id === '') continue;
|
||
$title = mb_substr((string)($item[$labelField] ?? $id), 0, 24);
|
||
$sec = mb_substr((string)($item[$sectionField] ?? 'Opciones'), 0, 24);
|
||
$grouped[$sec][] = ['id' => $id, 'title' => $title];
|
||
}
|
||
$sections = [];
|
||
foreach (array_slice($grouped, 0, 10, true) as $secTitle => $rows) {
|
||
$sections[] = ['title' => $secTitle, 'rows' => array_slice($rows, 0, 10)];
|
||
}
|
||
} else {
|
||
$rows = [];
|
||
foreach (array_slice($items, 0, 10) as $item) {
|
||
$id = (string)($item[$valueField] ?? '');
|
||
if ($id === '') continue;
|
||
$rows[] = ['id' => $id, 'title' => mb_substr((string)($item[$labelField] ?? $id), 0, 24)];
|
||
}
|
||
$sections = [['title' => mb_substr($flow['section_title'] ?? 'Opciones', 0, 24), 'rows' => $rows]];
|
||
}
|
||
|
||
if (empty($sections)) return null;
|
||
|
||
// Añadir opciones fijas al final de la última sección
|
||
$appendOptions = $flow['append_options'] ?? [];
|
||
if (!empty($appendOptions)) {
|
||
$last = count($sections) - 1;
|
||
foreach ($appendOptions as $opt) {
|
||
$optId = (string)($opt['id'] ?? '');
|
||
if ($optId === '') continue;
|
||
$sections[$last]['rows'][] = [
|
||
'id' => $optId,
|
||
'title' => mb_substr((string)($opt['title'] ?? $optId), 0, 24),
|
||
];
|
||
}
|
||
}
|
||
|
||
return self::enqueueInteractive($to, [
|
||
'type' => 'list',
|
||
'header' => ['type' => 'text', 'text' => mb_substr($flow['header'] ?? 'Selecciona', 0, 60)],
|
||
'body' => ['text' => mb_substr($flow['body'] ?: 'Elige una opción:', 0, 1024)],
|
||
'footer' => ['text' => mb_substr($company['display_name'] ?? '', 0, 60)],
|
||
'action' => [
|
||
'button' => mb_substr($flow['button'] ?? 'Ver opciones', 0, 20),
|
||
'sections' => $sections,
|
||
],
|
||
], $company);
|
||
}
|
||
|
||
// ── collect_for_each — pide un valor por cada ítem de una lista dinámica ──
|
||
|
||
private static function handleCollectForEach(array $flow, array $context, array $company, int $ctxId): ?array
|
||
{
|
||
$meta = ConversationContext::getMetadata($ctxId);
|
||
$items = self::fetchDynamicList($flow['source_endpoint_key'] ?? '', $company, $meta);
|
||
|
||
if (empty($items)) {
|
||
ConversationContext::reset($ctxId);
|
||
return self::sendText(
|
||
'⚠️ No se pudo cargar el listado. Escribe *menu* para volver.',
|
||
$context['from'], $company
|
||
);
|
||
}
|
||
|
||
// Si el NLU extrajo finca+valor, registrar solo ese ítem directamente
|
||
$entities = $meta['__nlu_entities'] ?? [];
|
||
unset($meta['__nlu_entities']);
|
||
if (!empty($entities)) {
|
||
$valueField = $flow['value_field'] ?? 'id';
|
||
$labelField = $flow['label_field'] ?? 'name';
|
||
$entityVal = null;
|
||
$matchedId = null;
|
||
$matchedLabel = null;
|
||
|
||
// Buscar valor numérico en entities
|
||
foreach ($entities as $ek => $ev) {
|
||
if (in_array(self::normalize($ek), ['valor','value','mm','cantidad','qty'], true) && is_numeric(str_replace(',', '.', (string)$ev))) {
|
||
$entityVal = str_replace(',', '.', (string)$ev);
|
||
}
|
||
}
|
||
|
||
// Buscar finca que coincida en entities
|
||
foreach ($entities as $ek => $ev) {
|
||
if (in_array(self::normalize($ek), ['valor','value','mm','cantidad','qty'], true)) continue;
|
||
$evNorm = self::normalize((string)$ev);
|
||
foreach ($items as $item) {
|
||
if (self::normalize((string)($item[$labelField] ?? '')) === $evNorm) {
|
||
$matchedId = (string)($item[$valueField] ?? '');
|
||
$matchedLabel = (string)($item[$labelField] ?? '');
|
||
break 2;
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($matchedId !== null && $entityVal !== null) {
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
ConversationContext::reset($ctxId);
|
||
// POST directo para ese ítem
|
||
$epKey = $flow['endpoint_key'] ?? '';
|
||
$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'], $epKey]);
|
||
$ep = $stmt->fetch();
|
||
if ($ep && !empty($ep['url'])) {
|
||
$url = self::buildUrl($ep['url'], $company);
|
||
$apiKey = $company['api_key'] ?? '';
|
||
$body = json_encode([$valueField => $matchedId, 'valor' => $entityVal]);
|
||
$ch = curl_init($url);
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_POST => true,
|
||
CURLOPT_POSTFIELDS => $body,
|
||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "Authorization: Bearer {$apiKey}", "X-API-Key: {$apiKey}"],
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_TIMEOUT => 15,
|
||
]);
|
||
$resp = curl_exec($ch);
|
||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
curl_close($ch);
|
||
if ($code >= 200 && $code < 300) {
|
||
$successText = $flow['success_text'] ?? '✅ Datos registrados correctamente.';
|
||
return self::sendText("✅ *{$matchedLabel}*: {$entityVal} registrado.\n{$successText}", $context['from'], $company);
|
||
}
|
||
}
|
||
return self::sendText('⚠️ No se pudo registrar. Intenta de nuevo o escribe *menu*.', $context['from'], $company);
|
||
}
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
}
|
||
|
||
$meta['__foreach'] = [
|
||
'items' => $items,
|
||
'index' => 0,
|
||
'collected' => [],
|
||
'value_field' => $flow['value_field'] ?? 'id',
|
||
'label_field' => $flow['label_field'] ?? 'name',
|
||
'question' => $flow['question'] ?? '¿Cuál es el valor?',
|
||
'header' => $flow['header'] ?? '📋 Ingreso de datos',
|
||
'endpoint_key'=> $flow['endpoint_key'] ?? '',
|
||
'success_text'=> $flow['success_text'] ?? '✅ Datos registrados correctamente.',
|
||
];
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
|
||
return self::foreachAskNext($meta['__foreach'], $context['from'], $company);
|
||
}
|
||
|
||
private static function handleForeachInput(array $meta, string $input, array $context, array $company, int $ctxId, array $flows): ?array
|
||
{
|
||
$fe = $meta['__foreach'];
|
||
$items = $fe['items'];
|
||
$idx = (int)$fe['index'];
|
||
|
||
// Validate: must be a number
|
||
if (!is_numeric(str_replace(',', '.', $input))) {
|
||
$item = $items[$idx];
|
||
$label = $item[$fe['label_field']] ?? "Item " . ($idx + 1);
|
||
return self::sendText(
|
||
"⚠️ Ingresa solo un número.\n\n*{$label}*\n{$fe['question']}",
|
||
$context['from'], $company
|
||
);
|
||
}
|
||
|
||
$item = $items[$idx];
|
||
$key = (string)($item[$fe['value_field']] ?? $idx);
|
||
$label = (string)($item[$fe['label_field']] ?? $key);
|
||
$value = str_replace(',', '.', trim($input));
|
||
|
||
$fe['collected'][$key] = ['label' => $label, 'valor' => $value];
|
||
$fe['index'] = $idx + 1;
|
||
$meta['__foreach'] = $fe;
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
|
||
// More items to collect?
|
||
if ($fe['index'] < count($items)) {
|
||
return self::foreachAskNext($fe, $context['from'], $company);
|
||
}
|
||
|
||
// All collected — show summary and confirm/cancel buttons
|
||
$summary = "*📊 Resumen — {$fe['header']}*\n\n";
|
||
foreach ($fe['collected'] as $row) {
|
||
$summary .= "• {$row['label']}: *{$row['valor']}*\n";
|
||
}
|
||
$summary .= "\n¿Confirmas el registro?";
|
||
|
||
return self::enqueueInteractive($context['from'], [
|
||
'type' => 'button',
|
||
'body' => ['text' => $summary],
|
||
'action' => ['buttons' => [
|
||
['type' => 'reply', 'reply' => ['id' => '__foreach_confirm', 'title' => '✅ Confirmar']],
|
||
['type' => 'reply', 'reply' => ['id' => '__foreach_cancel', 'title' => '❌ Cancelar']],
|
||
]],
|
||
], $company);
|
||
}
|
||
|
||
private static function foreachAskNext(array $fe, string $to, array $company): ?array
|
||
{
|
||
$idx = (int)$fe['index'];
|
||
$item = $fe['items'][$idx];
|
||
$label = $item[$fe['label_field']] ?? "Item " . ($idx + 1);
|
||
$total = count($fe['items']);
|
||
|
||
$text = "*{$fe['header']}* ({$idx}/{$total})\n\n"
|
||
. "📍 *{$label}*\n"
|
||
. $fe['question'];
|
||
|
||
return self::sendText($text, $to, $company);
|
||
}
|
||
|
||
private static function submitForeach(array $meta, array $context, array $company, int $ctxId): ?array
|
||
{
|
||
$fe = $meta['__foreach'];
|
||
$endpointKey = $fe['endpoint_key'] ?? '';
|
||
$nav = "\n\nEscribe *menu* para ver más opciones.";
|
||
|
||
// Build POST body: array of {id, valor} objects
|
||
$body = [];
|
||
foreach ($fe['collected'] as $id => $row) {
|
||
$body[] = ['id' => $id, 'label' => $row['label'], 'valor' => $row['valor']];
|
||
}
|
||
|
||
unset($meta['__foreach']);
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
ConversationContext::reset($ctxId);
|
||
|
||
if ($endpointKey === '') {
|
||
return self::sendText('⚠️ Endpoint de envío no configurado.' . $nav, $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'])) {
|
||
return self::sendText('⚠️ Endpoint no configurado. Contacta al administrador.' . $nav, $context['from'], $company);
|
||
}
|
||
|
||
$apiKey = $company['api_key'] ?? '';
|
||
$ch = curl_init(self::buildUrl($ep['url'], $company));
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_POST => true,
|
||
CURLOPT_POSTFIELDS => json_encode($body),
|
||
CURLOPT_TIMEOUT => 15,
|
||
CURLOPT_HTTPHEADER => [
|
||
'Content-Type: application/json',
|
||
'Authorization: Bearer ' . $apiKey,
|
||
'X-API-Key: ' . $apiKey,
|
||
],
|
||
]);
|
||
$response = curl_exec($ch);
|
||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
$curlErr = curl_error($ch);
|
||
curl_close($ch);
|
||
|
||
if ($curlErr || $httpCode >= 400) {
|
||
self::log("collect_for_each POST error [{$endpointKey}] HTTP {$httpCode}: {$curlErr}");
|
||
return self::sendText('⚠️ Error al guardar los datos. Intenta de nuevo.' . $nav, $context['from'], $company);
|
||
}
|
||
|
||
return self::sendText($fe['success_text'] . $nav, $context['from'], $company);
|
||
}
|
||
|
||
// ── collect_and_post — pide campos definidos en config y hace POST ─────────
|
||
|
||
private static function handleCollectAndPost(array $flow, array $context, array $company, int $ctxId): ?array
|
||
{
|
||
$fields = $flow['fields'] ?? [];
|
||
|
||
// If no inline fields, auto-detect from endpoint's body_fields definition
|
||
if (empty($fields)) {
|
||
$epKey = $flow['endpoint_key'] ?? '';
|
||
if ($epKey !== '') {
|
||
$stmt = db()->prepare("SELECT body_fields FROM company_endpoints WHERE company_id=? AND endpoint_key=? AND is_active=1 LIMIT 1");
|
||
$stmt->execute([(int)$company['id'], $epKey]);
|
||
$row = $stmt->fetch();
|
||
if ($row && !empty($row['body_fields'])) {
|
||
$fields = json_decode($row['body_fields'], true) ?: [];
|
||
}
|
||
}
|
||
}
|
||
|
||
if (empty($fields)) {
|
||
ConversationContext::reset($ctxId);
|
||
return self::sendText('⚠️ Flujo sin campos configurados. Define los campos en el endpoint. Escribe *menu* para volver.', $context['from'], $company);
|
||
}
|
||
|
||
$meta = ConversationContext::getMetadata($ctxId);
|
||
$entities = $meta['__nlu_entities'] ?? [];
|
||
unset($meta['__nlu_entities']);
|
||
|
||
$collected = [];
|
||
$startIdx = 0;
|
||
if (!empty($entities)) {
|
||
foreach ($fields as $i => $field) {
|
||
$fKey = $field['key'] ?? '';
|
||
$fLabel = $field['label'] ?? '';
|
||
foreach ($entities as $ek => $ev) {
|
||
if (self::normalize($ek) === self::normalize($fKey) ||
|
||
self::normalize($ek) === self::normalize($fLabel)) {
|
||
$collected[$fKey] = (string)$ev;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
// Advance index past consecutive pre-filled leading fields
|
||
while ($startIdx < count($fields) && isset($collected[$fields[$startIdx]['key'] ?? ''])) {
|
||
$startIdx++;
|
||
}
|
||
}
|
||
|
||
$meta['__cap'] = [
|
||
'fields' => $fields,
|
||
'index' => $startIdx,
|
||
'collected' => $collected,
|
||
'prefilled' => $collected,
|
||
'endpoint_key' => $flow['endpoint_key'] ?? '',
|
||
'success_text' => $flow['success_text'] ?? '✅ Datos registrados correctamente.',
|
||
'confirm' => (bool)($flow['confirm'] ?? true),
|
||
'header' => $flow['header'] ?? '📋 Registro',
|
||
];
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
|
||
return self::capAskNext($meta['__cap'], $context['from'], $company, $ctxId);
|
||
}
|
||
|
||
private static function handleCapInput(array $meta, string $input, array $context, array $company, int $ctxId): ?array
|
||
{
|
||
$cap = $meta['__cap'];
|
||
$fields = $cap['fields'];
|
||
$idx = (int)$cap['index'];
|
||
$field = $fields[$idx];
|
||
$value = trim($input);
|
||
|
||
// ── 1. Awaiting manual text after user picked "Otra opción" ──────────
|
||
if (!empty($cap['__awaiting_other'])) {
|
||
if (($field['other_validate'] ?? '') === 'date'
|
||
&& !preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
|
||
return self::sendText(
|
||
'⚠️ Formato inválido. ' . ($field['other_prompt'] ?? 'Ingresa la fecha como YYYY-MM-DD (ej: ' . date('Y-m-d') . '):'),
|
||
$context['from'], $company
|
||
);
|
||
}
|
||
$cap['collected'][$field['key']] = $value;
|
||
$cap['collected_labels'][$field['key']] = $value;
|
||
unset($cap['__awaiting_other']);
|
||
$cap['index'] = $idx + 1;
|
||
while ($cap['index'] < count($fields) && self::capShouldSkip($fields[$cap['index']], $cap['collected'])) { $cap['index']++; }
|
||
$meta['__cap'] = $cap;
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
if ($cap['index'] < count($fields)) {
|
||
return self::capAskNext($cap, $context['from'], $company, $ctxId);
|
||
}
|
||
return self::capShowConfirmOrSubmit($cap, $meta, $context, $company, $ctxId);
|
||
}
|
||
|
||
// ── 2. User chose "Otra opción" — ask for manual text ────────────────
|
||
if ($value === '__cap_other') {
|
||
$cap['__awaiting_other'] = true;
|
||
$meta['__cap'] = $cap;
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
$prompt = $field['other_prompt'] ?? '✏️ Ingresa el valor manualmente:';
|
||
return self::sendText("*{$cap['header']}*\n\n{$prompt}", $context['from'], $company);
|
||
}
|
||
|
||
// ── 3. Lookup field — search entity by entered value (e.g. cédula) ───
|
||
if (($field['type'] ?? 'text') === 'lookup') {
|
||
$result = self::callLookupEndpoint(
|
||
$field['lookup_endpoint_key'] ?? '',
|
||
$value,
|
||
$field['lookup_param'] ?? 'q',
|
||
$company
|
||
);
|
||
|
||
if ($result === null) {
|
||
$notFound = $field['not_found_text'] ?? '⚠️ No encontré resultados. Intenta de nuevo:';
|
||
return self::sendText("*{$cap['header']}*\n\n{$notFound}", $context['from'], $company);
|
||
}
|
||
|
||
$displayName = (string)($result[$field['display_field'] ?? 'nombre'] ?? 'Desconocido');
|
||
$entityId = (string)($result[$field['value_field'] ?? 'id'] ?? '');
|
||
|
||
$cap['__lookup_pending'] = ['id' => $entityId, 'label' => $displayName];
|
||
$meta['__cap'] = $cap;
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
|
||
return self::enqueueInteractive($context['from'], [
|
||
'type' => 'button',
|
||
'body' => ['text' => "¿Es *{$displayName}*?"],
|
||
'action' => ['buttons' => [
|
||
['type' => 'reply', 'reply' => ['id' => '__cap_lookup_yes', 'title' => '✅ Sí']],
|
||
['type' => 'reply', 'reply' => ['id' => '__cap_lookup_no', 'title' => '❌ No, reintentar']],
|
||
]],
|
||
], $company);
|
||
}
|
||
|
||
// ── 4. Normal save ────────────────────────────────────────────────────
|
||
$cap['collected'][$field['key']] = $value;
|
||
|
||
if (($field['type'] ?? 'text') === 'select') {
|
||
$cap['collected_labels'][$field['key']] =
|
||
$cap['__selects'][$field['key']][$value] ?? $value;
|
||
}
|
||
|
||
$cap['index'] = $idx + 1;
|
||
while ($cap['index'] < count($fields) && self::capShouldSkip($fields[$cap['index']], $cap['collected'])) { $cap['index']++; }
|
||
$meta['__cap'] = $cap;
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
|
||
if ($cap['index'] < count($fields)) {
|
||
return self::capAskNext($cap, $context['from'], $company, $ctxId);
|
||
}
|
||
return self::capShowConfirmOrSubmit($cap, $meta, $context, $company, $ctxId);
|
||
}
|
||
|
||
private static function capShowConfirmOrSubmit(array $cap, array $meta, array $context, array $company, int $ctxId): ?array
|
||
{
|
||
$fields = $cap['fields'];
|
||
if ($cap['confirm']) {
|
||
$summary = "*{$cap['header']}*\n\n";
|
||
foreach ($cap['collected'] as $k => $v) {
|
||
$label = self::capFieldLabel($fields, $k);
|
||
$display = $cap['collected_labels'][$k] ?? $v;
|
||
$summary .= "• {$label}: *{$display}*\n";
|
||
}
|
||
$summary .= "\n¿Confirmas el registro?";
|
||
return self::enqueueInteractive($context['from'], [
|
||
'type' => 'button',
|
||
'body' => ['text' => $summary],
|
||
'action' => ['buttons' => [
|
||
['type' => 'reply', 'reply' => ['id' => '__cap_confirm', 'title' => '✅ Confirmar']],
|
||
['type' => 'reply', 'reply' => ['id' => '__cap_cancel', 'title' => '❌ Cancelar']],
|
||
]],
|
||
], $company);
|
||
}
|
||
return self::submitCapPost($meta, $context, $company, $ctxId);
|
||
}
|
||
|
||
private static function callLookupEndpoint(string $epKey, string $value, string $param, array $company): ?array
|
||
{
|
||
if ($epKey === '' || $value === '') return null;
|
||
$stmt = db()->prepare('SELECT url FROM company_endpoints WHERE company_id=? AND endpoint_key=? AND is_active=1 LIMIT 1');
|
||
$stmt->execute([(int)$company['id'], $epKey]);
|
||
$ep = $stmt->fetch();
|
||
if (!$ep || empty($ep['url'])) return null;
|
||
|
||
$url = self::buildUrl($ep['url'], $company) . '&' . urlencode($param) . '=' . urlencode($value);
|
||
$ch = curl_init($url);
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_TIMEOUT => 10,
|
||
CURLOPT_HTTPHEADER => [
|
||
'Authorization: Bearer ' . ($company['api_key'] ?? ''),
|
||
'X-API-Key: ' . ($company['api_key'] ?? ''),
|
||
],
|
||
]);
|
||
$body = curl_exec($ch);
|
||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
curl_close($ch);
|
||
|
||
if ($code !== 200 || !$body) return null;
|
||
$data = json_decode($body, true);
|
||
return (is_array($data) && ($data['status'] ?? '') === '1') ? ($data['datos'] ?? null) : null;
|
||
}
|
||
|
||
private static function capAskNext(array $cap, string $to, array $company, int $ctxId = 0): ?array
|
||
{
|
||
// Saltar campos ya pre-llenados (no contiguos)
|
||
$prefilled = $cap['prefilled'] ?? [];
|
||
while ((int)$cap['index'] < count($cap['fields'])) {
|
||
$f = $cap['fields'][(int)$cap['index']];
|
||
if (isset($prefilled[$f['key'] ?? '']) && !isset($cap['collected'][$f['key'] ?? ''])) {
|
||
$cap['collected'][$f['key']] = $prefilled[$f['key']];
|
||
$cap['index']++;
|
||
if ($ctxId > 0) {
|
||
$m = ConversationContext::getMetadata($ctxId);
|
||
$m['__cap'] = $cap;
|
||
ConversationContext::updateMetadata($ctxId, $m);
|
||
}
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
|
||
$idx = (int)$cap['index'];
|
||
$total = count($cap['fields']);
|
||
$field = $cap['fields'][$idx];
|
||
$type = $field['type'] ?? 'text';
|
||
|
||
// ── select: fetch from API ───────────────────────────────────────────
|
||
if ($type === 'select') {
|
||
$vf = $field['value_field'] ?? 'id';
|
||
$lf = $field['label_field'] ?? 'label';
|
||
$items = self::fetchDynamicList($field['source_endpoint_key'] ?? '', $company, ['__ep_vars_combined' => $cap['collected'] ?? []]);
|
||
if (!empty($items)) {
|
||
if ($ctxId > 0) {
|
||
$cap['__selects'][$field['key']] = array_column($items, $lf, $vf);
|
||
$m = ConversationContext::getMetadata($ctxId);
|
||
$m['__cap'] = $cap;
|
||
ConversationContext::updateMetadata($ctxId, $m);
|
||
}
|
||
return self::buildDynamicListResponse($items, [
|
||
'value_field' => $vf,
|
||
'label_field' => $lf,
|
||
'header' => $cap['header'],
|
||
'body' => $field['prompt'] ?? ('Selecciona ' . ($field['label'] ?? $field['key'])),
|
||
'button' => 'Ver opciones',
|
||
'section_title' => $field['label'] ?? $field['key'],
|
||
], $to, $company);
|
||
}
|
||
}
|
||
|
||
// ── date_quick: Hoy / Ayer / Anteayer / Otra ────────────────────────
|
||
if ($type === 'date_quick') {
|
||
$hoy = date('Y-m-d');
|
||
$ayer = date('Y-m-d', strtotime('-1 day'));
|
||
$ant = date('Y-m-d', strtotime('-2 days'));
|
||
return self::enqueueInteractive($to, [
|
||
'type' => 'list',
|
||
'header' => ['type' => 'text', 'text' => mb_substr($cap['header'], 0, 60)],
|
||
'body' => ['text' => $field['prompt'] ?? '📅 ¿Cuál es la fecha?'],
|
||
'footer' => ['text' => ''],
|
||
'action' => ['button' => 'Ver fechas', 'sections' => [[
|
||
'title' => 'Fecha',
|
||
'rows' => [
|
||
['id' => $hoy, 'title' => '📅 Hoy', 'description' => $hoy],
|
||
['id' => $ayer, 'title' => '📅 Ayer', 'description' => $ayer],
|
||
['id' => $ant, 'title' => '📅 Anteayer', 'description' => $ant],
|
||
['id' => '__cap_other', 'title' => '✏️ Otra fecha', 'description' => 'Escribe la fecha'],
|
||
],
|
||
]]],
|
||
], $company);
|
||
}
|
||
|
||
// ── static_select: inline options ───────────────────────────────────
|
||
if ($type === 'static_select') {
|
||
$options = $field['options'] ?? [];
|
||
$rows = array_map(fn($o) => [
|
||
'id' => (string)$o['id'],
|
||
'title' => mb_substr((string)$o['label'], 0, 24),
|
||
], $options);
|
||
|
||
if (count($rows) <= 3) {
|
||
return self::enqueueInteractive($to, [
|
||
'type' => 'button',
|
||
'body' => ['text' => "*{$cap['header']}*\n\n" . ($field['prompt'] ?? 'Selecciona:')],
|
||
'action' => ['buttons' => array_map(fn($r) => [
|
||
'type' => 'reply', 'reply' => ['id' => $r['id'], 'title' => $r['title']],
|
||
], $rows)],
|
||
], $company);
|
||
}
|
||
|
||
return self::enqueueInteractive($to, [
|
||
'type' => 'list',
|
||
'header' => ['type' => 'text', 'text' => mb_substr($cap['header'], 0, 60)],
|
||
'body' => ['text' => $field['prompt'] ?? 'Selecciona una opción:'],
|
||
'footer' => ['text' => ''],
|
||
'action' => ['button' => 'Ver opciones', 'sections' => [[
|
||
'title' => $field['label'] ?? 'Opciones',
|
||
'rows' => $rows,
|
||
]]],
|
||
], $company);
|
||
}
|
||
|
||
// ── lookup / text: plain text prompt ────────────────────────────────
|
||
$prompt = $field['prompt'] ?? ('Campo: ' . $field['key']);
|
||
$text = "*{$cap['header']}* ({$idx}/{$total})\n\n{$prompt}";
|
||
return self::sendText($text, $to, $company);
|
||
}
|
||
|
||
// skip_if: {"field":"novedad_id","not_in":["35","41"]} → skip this field if collected[field] not in list
|
||
private static function capShouldSkip(array $field, array $collected): bool
|
||
{
|
||
$si = $field['skip_if'] ?? null;
|
||
if (!$si) return false;
|
||
$val = (string)($collected[$si['field'] ?? ''] ?? '');
|
||
if (isset($si['not_in'])) return !in_array($val, array_map('strval', $si['not_in']));
|
||
if (isset($si['equals'])) return $val !== (string)$si['equals'];
|
||
return false;
|
||
}
|
||
|
||
private static function capFieldLabel(array $fields, string $key): string
|
||
{
|
||
foreach ($fields as $f) {
|
||
if (($f['key'] ?? '') === $key) return $f['label'] ?? $f['key'];
|
||
}
|
||
return $key;
|
||
}
|
||
|
||
private static function submitCapPost(array $meta, array $context, array $company, int $ctxId): ?array
|
||
{
|
||
$cap = $meta['__cap'];
|
||
$endpointKey = $cap['endpoint_key'] ?? '';
|
||
$nav = "\n\nEscribe *menu* para ver más opciones.";
|
||
|
||
unset($meta['__cap']);
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
ConversationContext::reset($ctxId);
|
||
|
||
if ($endpointKey === '') {
|
||
return self::sendText('⚠️ Endpoint no configurado.' . $nav, $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'])) {
|
||
return self::sendText('⚠️ Endpoint no encontrado. Contacta al administrador.' . $nav, $context['from'], $company);
|
||
}
|
||
|
||
$apiKey = $company['api_key'] ?? '';
|
||
$payload = array_merge($cap['collected'], [
|
||
'telefono' => $context['from'],
|
||
'nombre' => $context['name'] ?? '',
|
||
]);
|
||
|
||
$ch = curl_init(self::buildUrl($ep['url'], $company));
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_POST => true,
|
||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||
CURLOPT_TIMEOUT => 15,
|
||
CURLOPT_HTTPHEADER => [
|
||
'Content-Type: application/json',
|
||
'Authorization: Bearer ' . $apiKey,
|
||
'X-API-Key: ' . $apiKey,
|
||
],
|
||
]);
|
||
$response = curl_exec($ch);
|
||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
$curlErr = curl_error($ch);
|
||
curl_close($ch);
|
||
|
||
if ($curlErr || $httpCode >= 400) {
|
||
self::log("collect_and_post error [{$endpointKey}] HTTP {$httpCode}: {$curlErr}");
|
||
return self::sendText('⚠️ Error al guardar los datos. Intenta de nuevo.' . $nav, $context['from'], $company);
|
||
}
|
||
|
||
return self::sendText($cap['success_text'] . $nav, $context['from'], $company);
|
||
}
|
||
|
||
// ── Submit form (end of multi-step collection) ───────────────────────────
|
||
|
||
private static function handleSubmitForm(array $flow, array $context, array $company, int $ctxId): ?array
|
||
{
|
||
$meta = ConversationContext::getMetadata($ctxId);
|
||
$group = $flow['meta_group'] ?? '';
|
||
|
||
$formData = $meta[$group] ?? [];
|
||
unset($meta[$group], $meta['collecting']);
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
|
||
$params = [
|
||
'endpoint_key' => $flow['endpoint_key'] ?? '',
|
||
'caption' => $flow['caption'] ?? 'Informe generado.',
|
||
'filename' => $flow['filename'] ?? 'reporte.pdf',
|
||
'query' => $formData, // form fields become URL query params
|
||
];
|
||
|
||
return self::executeApiReport($params, $context, $company, $ctxId);
|
||
}
|
||
|
||
// ── Flow dispatcher ──────────────────────────────────────────────────────
|
||
|
||
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),
|
||
'collect_input' => self::handleCollectInput($flow, $context, $company, $ctxId),
|
||
'dynamic_list' => self::handleDynamicList($flow, $context, $company, $ctxId),
|
||
'submit_form' => self::handleSubmitForm($flow, $context, $company, $ctxId),
|
||
'collect_for_each' => self::handleCollectForEach($flow, $context, $company, $ctxId),
|
||
'collect_and_post' => self::handleCollectAndPost($flow, $context, $company, $ctxId),
|
||
default => null,
|
||
};
|
||
}
|
||
|
||
// ── Functions ────────────────────────────────────────────────────────────
|
||
|
||
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);
|
||
})(),
|
||
'goodbye' => (function () use ($ctxId, $context, $company) {
|
||
ConversationContext::reset($ctxId);
|
||
return self::sendText("Hasta luego 👋\n\nEscribe *menu* cuando quieras volver.", $context['from'], $company);
|
||
})(),
|
||
'forward_to_ai' => null,
|
||
'api_report' => self::executeApiReport($params, $context, $company, $ctxId),
|
||
default => null,
|
||
};
|
||
}
|
||
|
||
// Reset manteniendo grupos de sesión (finca, etc.) para que el usuario
|
||
// no tenga que re-seleccionar su finca después de cada reporte.
|
||
private static function preservingReset(int $ctxId, array $meta): void
|
||
{
|
||
$keep = [];
|
||
foreach (['finca'] as $group) {
|
||
if (isset($meta[$group])) $keep[$group] = $meta[$group];
|
||
}
|
||
ConversationContext::reset($ctxId);
|
||
if (!empty($keep)) {
|
||
ConversationContext::updateMetadata($ctxId, $keep);
|
||
}
|
||
}
|
||
|
||
// ── Resolves fixed-mode var values ───────────────────────────────────────
|
||
private static function resolveFixedVar(string $value): string
|
||
{
|
||
return match ($value) {
|
||
'current_date' => date('Y-m-d'),
|
||
'month_start' => date('Y-m-01'),
|
||
'month_end' => date('Y-m-t'),
|
||
'last_7_start' => date('Y-m-d', strtotime('-7 days')),
|
||
'last_30_start' => date('Y-m-d', strtotime('-30 days')),
|
||
'year_start' => date('Y-01-01'),
|
||
default => $value,
|
||
};
|
||
}
|
||
|
||
// ── Handles sequential collection of URL vars before calling endpoint ───
|
||
private static function handleApiVarsInput(array $meta, string $input, array $context, array $company, int $ctxId, array $flows, array $menus): ?array
|
||
{
|
||
$nav = "\n\nEscribe *menu* para ver opciones o *salir* para terminar.";
|
||
$state = $meta['__api_vars'];
|
||
|
||
$pending = $state['pending'] ?? [];
|
||
$collected = $state['collected'] ?? [];
|
||
$epKey = $state['endpoint_key'] ?? '';
|
||
$epParams = $state['ep_params'] ?? [];
|
||
|
||
// Save the user's answer for the current question
|
||
if (!empty($pending)) {
|
||
$current = array_shift($pending);
|
||
$collected[$current['key']] = trim($input);
|
||
$state['pending'] = $pending;
|
||
$state['collected'] = $collected;
|
||
}
|
||
|
||
// If more questions remain, ask the next one
|
||
if (!empty($state['pending'])) {
|
||
$next = $state['pending'][0];
|
||
$meta['__api_vars'] = $state;
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
return self::sendText($next['prompt'] . $nav, $context['from'], $company);
|
||
}
|
||
|
||
// All vars collected — inject into meta and execute
|
||
unset($meta['__api_vars']);
|
||
foreach ($collected as $k => $v) {
|
||
$meta['__ep_vars'][$k] = $v;
|
||
}
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
|
||
return self::executeApiReport(array_merge($epParams, ['endpoint_key' => $epKey]), $context, $company, $ctxId);
|
||
}
|
||
|
||
private static function executeApiReport(array $params, array $context, array $company, int $ctxId): ?array
|
||
{
|
||
$endpointKey = $params['endpoint_key'] ?? '';
|
||
$nav = "\n\nEscribe *menu* para ver más opciones o *salir* para terminar.";
|
||
|
||
if ($endpointKey === '') {
|
||
self::preservingReset($ctxId, ConversationContext::getMetadata($ctxId));
|
||
return self::sendText('Error: endpoint no configurado.' . $nav, $context['from'], $company);
|
||
}
|
||
|
||
$stmt = db()->prepare("SELECT url, method, params 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']}");
|
||
self::preservingReset($ctxId, ConversationContext::getMetadata($ctxId));
|
||
return self::sendText('El informe solicitado no está configurado. Contacta al administrador.' . $nav, $context['from'], $company);
|
||
}
|
||
|
||
// ── Resolve URL vars from params config ──────────────────────────────
|
||
$varConfigs = json_decode($ep['params'] ?? '[]', true) ?: [];
|
||
$meta = ConversationContext::getMetadata($ctxId);
|
||
|
||
// Inject any previously collected vars into meta for substituteUrlVars
|
||
$epVars = $meta['__ep_vars'] ?? [];
|
||
if (!empty($epVars)) {
|
||
$meta['__ep_vars_flat'] = $epVars;
|
||
}
|
||
|
||
$askVars = [];
|
||
foreach ($varConfigs as $vc) {
|
||
$key = $vc['key'] ?? '';
|
||
$mode = $vc['mode'] ?? 'ask';
|
||
if ($key === '') continue;
|
||
|
||
if ($mode === 'fixed') {
|
||
// Inject fixed value directly into meta for substituteUrlVars
|
||
$meta['__ep_vars_flat'][$key] = self::resolveFixedVar($vc['value'] ?? '');
|
||
} elseif ($mode === 'ask') {
|
||
// Only ask if not already collected
|
||
if (!isset($epVars[$key])) {
|
||
$askVars[] = ['key' => $key, 'prompt' => $vc['prompt'] ?? "Ingresa el valor para {$key}:"];
|
||
} else {
|
||
$meta['__ep_vars_flat'][$key] = $epVars[$key];
|
||
}
|
||
}
|
||
}
|
||
|
||
// If there are vars to ask, start collection flow
|
||
if (!empty($askVars)) {
|
||
$first = array_shift($askVars);
|
||
$meta['__api_vars'] = [
|
||
'endpoint_key' => $endpointKey,
|
||
'ep_params' => $params,
|
||
'pending' => $askVars,
|
||
'collected' => [],
|
||
];
|
||
// Clean collected vars so they don't bleed into the next call
|
||
unset($meta['__ep_vars']);
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
return self::sendText($first['prompt'] . $nav, $context['from'], $company);
|
||
}
|
||
|
||
// Flatten collected vars into meta for substituteUrlVars
|
||
if (!empty($meta['__ep_vars_flat'])) {
|
||
$meta['__ep_vars_combined'] = $meta['__ep_vars_flat'];
|
||
}
|
||
unset($meta['__ep_vars'], $meta['__ep_vars_flat']);
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
|
||
$url = self::buildUrl(
|
||
self::substituteUrlVars($ep['url'], array_merge($meta, ['__ep_vars_combined' => $meta['__ep_vars_combined'] ?? []])),
|
||
$company
|
||
);
|
||
$method = strtoupper($ep['method'] ?? 'GET');
|
||
$apiKey = $company['api_key'] ?? '';
|
||
|
||
$extraQuery = $params['query'] ?? [];
|
||
$dateMode = $params['date_mode'] ?? '';
|
||
if ($dateMode === 'today') {
|
||
$extraQuery['fecha_desde'] = date('Y-m-d');
|
||
$extraQuery['fecha_hasta'] = date('Y-m-d');
|
||
} elseif ($dateMode === 'last_30') {
|
||
$extraQuery['fecha_desde'] = date('Y-m-d', strtotime('-30 days'));
|
||
$extraQuery['fecha_hasta'] = date('Y-m-d');
|
||
} elseif ($dateMode === 'current_month') {
|
||
$extraQuery['fecha_desde'] = date('Y-m-01');
|
||
$extraQuery['fecha_hasta'] = date('Y-m-d');
|
||
} elseif ($dateMode === 'last_month') {
|
||
$extraQuery['fecha_desde'] = date('Y-m-01', strtotime('first day of last month'));
|
||
$extraQuery['fecha_hasta'] = date('Y-m-t', strtotime('last day of last month'));
|
||
} elseif ($dateMode === 'last_7') {
|
||
$extraQuery['fecha_desde'] = date('Y-m-d', strtotime('-7 days'));
|
||
$extraQuery['fecha_hasta'] = 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 => 15,
|
||
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}");
|
||
self::preservingReset($ctxId, $meta);
|
||
return self::sendText('Error al obtener el reporte. Intenta de nuevo.' . $nav, $context['from'], $company);
|
||
}
|
||
|
||
if ($httpCode >= 400) {
|
||
self::log("API report HTTP {$httpCode} [{$endpointKey}]: " . mb_substr($content, 0, 200));
|
||
self::preservingReset($ctxId, $meta);
|
||
return self::sendText('Error al obtener el reporte. Intenta de nuevo.' . $nav, $context['from'], $company);
|
||
}
|
||
|
||
// Si el ERP devuelve JSON (ej: sin_datos o texto), informar al usuario en texto
|
||
if (str_starts_with($contentType ?? '', 'application/json')) {
|
||
$json = json_decode($content, true);
|
||
$msg = $json['mensaje'] ?? ($json['message'] ?? 'No hay datos disponibles para el período solicitado.');
|
||
$nextKey = $params['next_endpoint_key'] ?? '';
|
||
if ($nextKey !== '' && ($json['status'] ?? '0') === '1') {
|
||
// Enviar texto primero directamente, luego retornar el PDF/Excel
|
||
$phoneId = $context['phone_number_id'] ?? ($company['phone_number_id'] ?? '');
|
||
WhatsAppSender::sendText($context['from'], $msg, $phoneId);
|
||
$nextParams = array_merge($params, [
|
||
'endpoint_key' => $nextKey,
|
||
'caption' => $params['next_caption'] ?? '',
|
||
'filename' => $params['next_filename'] ?? 'reporte.pdf',
|
||
]);
|
||
unset($nextParams['next_endpoint_key'], $nextParams['next_caption'], $nextParams['next_filename']);
|
||
return self::executeApiReport($nextParams, $context, $company, $ctxId);
|
||
}
|
||
self::preservingReset($ctxId, $meta);
|
||
return self::sendText('ℹ️ ' . $msg . $nav, $context['from'], $company);
|
||
}
|
||
|
||
$mimeMap = [
|
||
'application/pdf' => ['pdf', 'application/pdf'],
|
||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => ['xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
|
||
'application/vnd.ms-excel' => ['xls', 'application/vnd.ms-excel'],
|
||
'text/csv' => ['csv', 'text/csv'],
|
||
'application/vnd.oasis.opendocument.spreadsheet' => ['ods', 'application/vnd.oasis.opendocument.spreadsheet'],
|
||
];
|
||
|
||
$ext = 'pdf';
|
||
$mime = 'application/pdf';
|
||
foreach ($mimeMap as $m => $info) {
|
||
if (str_starts_with($contentType ?? '', $m)) {
|
||
[$ext, $mime] = $info;
|
||
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'));
|
||
self::preservingReset($ctxId, $meta);
|
||
return self::sendText('Error al enviar el reporte. Intenta de nuevo.' . $nav, $context['from'], $company);
|
||
}
|
||
|
||
$reportName = $params['filename'] ?? ('reporte.' . $ext);
|
||
$caption = $params['caption'] ?? 'Aquí tienes el reporte solicitado.';
|
||
|
||
WhatsAppSender::sendDocument($context['from'], $upload['media_id'], $phoneNumberId, $caption, $reportName);
|
||
self::preservingReset($ctxId, $meta);
|
||
|
||
return self::sendText('✅ Reporte generado.' . $nav, $context['from'], $company);
|
||
}
|
||
|
||
// ── Public helpers ───────────────────────────────────────────────────────
|
||
|
||
public static function buildGreetingMenu(array $menu, string $to, array $company): ?array
|
||
{
|
||
return self::buildMenuResponse($menu, $to, $company);
|
||
}
|
||
|
||
// ── Interactive messages ─────────────────────────────────────────────────
|
||
|
||
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'];
|
||
|
||
// Multi-step form: interactive selection is the collected value
|
||
$meta = ConversationContext::getMetadata($ctxId);
|
||
if (!empty($meta['collecting'])) {
|
||
return self::handleCollectingInput($meta, $input, 'interactive', $context, $company, $ctxId, $flows, $menus);
|
||
}
|
||
|
||
// collect_and_post: confirm/cancel, lookup confirm/reject, or list/button field pick
|
||
if (!empty($meta['__cap'])) {
|
||
if ($input === '__cap_confirm') {
|
||
return self::submitCapPost($meta, $context, $company, $ctxId);
|
||
}
|
||
if ($input === '__cap_cancel') {
|
||
$m = ConversationContext::getMetadata($ctxId);
|
||
unset($m['__cap']);
|
||
ConversationContext::updateMetadata($ctxId, $m);
|
||
ConversationContext::reset($ctxId);
|
||
return self::sendText("❌ Registro cancelado.\n\nEscribe *menu* para volver.", $context['from'], $company);
|
||
}
|
||
// Lookup confirmation buttons
|
||
if (!empty($meta['__cap']['__lookup_pending'])) {
|
||
if ($input === '__cap_lookup_yes') {
|
||
$cap = $meta['__cap'];
|
||
$fields = $cap['fields'];
|
||
$idx = (int)$cap['index'];
|
||
$field = $fields[$idx];
|
||
$pending = $cap['__lookup_pending'];
|
||
$cap['collected'][$field['key']] = $pending['id'];
|
||
$cap['collected_labels'][$field['key']] = $pending['label'];
|
||
unset($cap['__lookup_pending']);
|
||
$cap['index'] = $idx + 1;
|
||
while ($cap['index'] < count($fields) && self::capShouldSkip($fields[$cap['index']], $cap['collected'])) { $cap['index']++; }
|
||
$meta['__cap'] = $cap;
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
if ($cap['index'] < count($fields)) {
|
||
return self::capAskNext($cap, $context['from'], $company, $ctxId);
|
||
}
|
||
return self::capShowConfirmOrSubmit($cap, $meta, $context, $company, $ctxId);
|
||
}
|
||
if ($input === '__cap_lookup_no') {
|
||
$cap = $meta['__cap'];
|
||
unset($cap['__lookup_pending']);
|
||
$meta['__cap'] = $cap;
|
||
ConversationContext::updateMetadata($ctxId, $meta);
|
||
return self::capAskNext($cap, $context['from'], $company, $ctxId);
|
||
}
|
||
}
|
||
// Interactive list/button response for select, date_quick, static_select fields
|
||
return self::handleCapInput($meta, $input, $context, $company, $ctxId);
|
||
}
|
||
|
||
// collect_for_each confirmation (confirm/cancel button)
|
||
if (!empty($meta['__foreach'])) {
|
||
if ($input === '__foreach_confirm') {
|
||
return self::submitForeach($meta, $context, $company, $ctxId);
|
||
}
|
||
if ($input === '__foreach_cancel') {
|
||
$meta2 = ConversationContext::getMetadata($ctxId);
|
||
unset($meta2['__foreach']);
|
||
ConversationContext::updateMetadata($ctxId, $meta2);
|
||
ConversationContext::reset($ctxId);
|
||
return self::sendText("❌ Registro cancelado.\n\nEscribe *menu* para volver.", $context['from'], $company);
|
||
}
|
||
}
|
||
|
||
// Standard button/row resolution against static menus
|
||
foreach ($flows as $flowId => $flow) {
|
||
if (($flow['type'] ?? '') !== 'menu') continue;
|
||
|
||
$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;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Direct flow match (button id == flow key)
|
||
// Redirect show_main_menu → per-type greeting_menu if configured
|
||
$resolvedInput = $input;
|
||
$greetingMenuKey = $perType['greeting_menu'] ?? null;
|
||
if ($resolvedInput === 'show_main_menu' && $greetingMenuKey && isset($menus[$greetingMenuKey])) {
|
||
$resolvedInput = $greetingMenuKey;
|
||
}
|
||
if (isset($flows[$resolvedInput])) {
|
||
ConversationContext::updateNode($ctxId, $resolvedInput);
|
||
return self::handleFlow($flows[$resolvedInput], $context, $company, $ctxId, $menus);
|
||
}
|
||
if (isset($menus[$resolvedInput])) {
|
||
ConversationContext::updateNode($ctxId, $resolvedInput);
|
||
return self::buildMenuResponse($menus[$resolvedInput], $context['from'], $company);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// ── WhatsApp senders ─────────────────────────────────────────────────────
|
||
|
||
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 === 'button') {
|
||
$buttons = [];
|
||
foreach (array_slice($menu['buttons'] ?? [], 0, 3) 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'] ?? '';
|
||
$config = $json !== '' ? json_decode($json, true) : null;
|
||
return is_array($config) ? $config : [];
|
||
}
|
||
|
||
private static function buildUrl(string $relOrAbs, array $company): string
|
||
{
|
||
if ($relOrAbs === '' || str_starts_with($relOrAbs, 'http')) return $relOrAbs;
|
||
return rtrim($company['api_base_url'] ?? '', '/') . '/' . ltrim($relOrAbs, '/');
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
private static function log(string $msg): void
|
||
{
|
||
if (class_exists('WpWebhook')) WpWebhook::log('NORMALBOT', $msg);
|
||
}
|
||
}
|