diff --git a/services/NormalBot.php b/services/NormalBot.php index ad9237c..62f8582 100644 --- a/services/NormalBot.php +++ b/services/NormalBot.php @@ -40,6 +40,11 @@ class NormalBot 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); + } + // 3. Active node — resume conversation $sentinels = ['collecting', '__greeted']; if ($currentNode !== null && !in_array($currentNode, $sentinels, true) && isset($flows[$currentNode])) { @@ -233,6 +238,153 @@ class NormalBot return self::enqueueInteractive($to, $interactive, $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 + ); + } + + $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($ep['url']); + 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); + } + // ── Submit form (end of multi-step collection) ─────────────────────────── private static function handleSubmitForm(array $flow, array $context, array $company, int $ctxId): ?array @@ -271,14 +423,15 @@ class NormalBot $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), - default => null, + '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), + default => null, }; } @@ -442,6 +595,20 @@ class NormalBot return self::handleCollectingInput($meta, $input, 'interactive', $context, $company, $ctxId, $flows, $menus); } + // 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;