feat: add collect_for_each flow type for sequential per-item data entry
New flow type that fetches a dynamic list from an endpoint then asks
the user for a value for each item one by one. On completion shows a
summary with confirm/cancel buttons, then POSTs all collected data as
a JSON array to a configured upload endpoint with Bearer + X-API-Key auth.
Usage in flow config:
{
"type": "collect_for_each",
"source_endpoint_key": "fincas_list",
"endpoint_key": "pluvio_upload",
"value_field": "id",
"label_field": "name",
"question": "¿Cuál es el valor de pluviometría? (mm)",
"header": "🌧 Pluviometría",
"success_text": "✅ Pluviometría registrada."
}
POST payload: [{id, label, valor}, ...]
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
6dd9995b7f
commit
b45f0fbe51
+175
-8
@@ -40,6 +40,11 @@ class NormalBot
|
|||||||
return self::handleCollectingInput($meta, $input, 'text', $context, $company, $ctxId, $flows, $menus);
|
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
|
// 3. Active node — resume conversation
|
||||||
$sentinels = ['collecting', '__greeted'];
|
$sentinels = ['collecting', '__greeted'];
|
||||||
if ($currentNode !== null && !in_array($currentNode, $sentinels, true) && isset($flows[$currentNode])) {
|
if ($currentNode !== null && !in_array($currentNode, $sentinels, true) && isset($flows[$currentNode])) {
|
||||||
@@ -233,6 +238,153 @@ class NormalBot
|
|||||||
return self::enqueueInteractive($to, $interactive, $company);
|
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) ───────────────────────────
|
// ── Submit form (end of multi-step collection) ───────────────────────────
|
||||||
|
|
||||||
private static function handleSubmitForm(array $flow, array $context, array $company, int $ctxId): ?array
|
private static function handleSubmitForm(array $flow, array $context, array $company, int $ctxId): ?array
|
||||||
@@ -271,14 +423,15 @@ class NormalBot
|
|||||||
$type = $flow['type'] ?? 'text';
|
$type = $flow['type'] ?? 'text';
|
||||||
|
|
||||||
return match ($type) {
|
return match ($type) {
|
||||||
'text' => self::sendText($flow['message'] ?? '', $context['from'], $company),
|
'text' => self::sendText($flow['message'] ?? '', $context['from'], $company),
|
||||||
'image' => self::sendImage($flow['media_id'] ?? '', $flow['caption'] ?? null, $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),
|
'menu' => self::buildMenuResponse(self::resolveMenu($flow['menu'] ?? [], $company, $menus), $context['from'], $company),
|
||||||
'function' => self::executeFunction($flow['function'] ?? '', $flow['params'] ?? [], $context, $company, $ctxId),
|
'function' => self::executeFunction($flow['function'] ?? '', $flow['params'] ?? [], $context, $company, $ctxId),
|
||||||
'collect_input' => self::handleCollectInput($flow, $context, $company, $ctxId),
|
'collect_input' => self::handleCollectInput($flow, $context, $company, $ctxId),
|
||||||
'dynamic_list' => self::handleDynamicList($flow, $context, $company, $ctxId),
|
'dynamic_list' => self::handleDynamicList($flow, $context, $company, $ctxId),
|
||||||
'submit_form' => self::handleSubmitForm($flow, $context, $company, $ctxId),
|
'submit_form' => self::handleSubmitForm($flow, $context, $company, $ctxId),
|
||||||
default => null,
|
'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);
|
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
|
// Standard button/row resolution against static menus
|
||||||
foreach ($flows as $flowId => $flow) {
|
foreach ($flows as $flowId => $flow) {
|
||||||
if (($flow['type'] ?? '') !== 'menu') continue;
|
if (($flow['type'] ?? '') !== 'menu') continue;
|
||||||
|
|||||||
Reference in New Issue
Block a user