feat: collect_and_post flow type — sequential field collection + POST

New flow type that asks the user for a configured list of fields one
by one, shows a summary with confirm/cancel, then POSTs all answers
as JSON to the configured endpoint with Bearer auth.

Config example:
  {
    "type": "collect_and_post",
    "endpoint_key": "subir_cosecha",
    "header": "🌿 Ciclos Cosecha",
    "confirm": true,
    "success_text": " Ciclo registrado.",
    "fields": [
      {"key":"fecha",    "label":"Fecha",    "prompt":"📅 ¿Fecha del ciclo? (YYYY-MM-DD)"},
      {"key":"cantidad", "label":"Racimos",  "prompt":"🔢 ¿Cantidad de racimos?"},
      {"key":"finca",    "label":"Finca",    "prompt":"🏡 ¿Nombre de la finca?"}
    ]
  }

POST payload: {fecha, cantidad, finca, telefono, nombre}

Also adds the UI panel in bot-config with dynamic add/remove field rows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-06-29 11:06:42 -05:00
co-authored by Claude Sonnet 4.6
parent 32f87ef27c
commit 3c7ea9fdf9
3 changed files with 268 additions and 0 deletions
+157
View File
@@ -45,6 +45,11 @@ class NormalBot
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);
}
// 3. Active node — resume conversation
$sentinels = ['collecting', '__greeted'];
if ($currentNode !== null && !in_array($currentNode, $sentinels, true) && isset($flows[$currentNode])) {
@@ -385,6 +390,143 @@ class NormalBot
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 (empty($fields)) {
ConversationContext::reset($ctxId);
return self::sendText('⚠️ Flujo sin campos configurados. Contacta al administrador.', $context['from'], $company);
}
$meta = ConversationContext::getMetadata($ctxId);
$meta['__cap'] = [
'fields' => $fields,
'index' => 0,
'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);
}
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];
// Save answer
$cap['collected'][$field['key']] = trim($input);
$cap['index'] = $idx + 1;
$meta['__cap'] = $cap;
ConversationContext::updateMetadata($ctxId, $meta);
// More fields?
if ($cap['index'] < count($fields)) {
return self::capAskNext($cap, $context['from'], $company);
}
// All done — confirm or submit directly
if ($cap['confirm']) {
$summary = "*{$cap['header']}*\n\n";
foreach ($cap['collected'] as $k => $v) {
$label = self::capFieldLabel($fields, $k);
$summary .= "{$label}: *{$v}*\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 capAskNext(array $cap, string $to, array $company): ?array
{
$idx = (int)$cap['index'];
$total = count($cap['fields']);
$field = $cap['fields'][$idx];
$prompt = $field['prompt'] ?? ('Campo: ' . $field['key']);
$text = "*{$cap['header']}* ({$idx}/{$total})\n\n{$prompt}";
return self::sendText($text, $to, $company);
}
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($ep['url']);
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
@@ -431,6 +573,7 @@ class NormalBot
'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,
};
}
@@ -595,6 +738,20 @@ class NormalBot
return self::handleCollectingInput($meta, $input, 'interactive', $context, $company, $ctxId, $flows, $menus);
}
// collect_and_post confirmation (confirm/cancel button)
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);
}
}
// collect_for_each confirmation (confirm/cancel button)
if (!empty($meta['__foreach'])) {
if ($input === '__foreach_confirm') {