{
  "name": "AutomatizaGr — Sandbox v10.8 SIN Twilio",
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "nodes": [
    {
      "id": "sandbox-note",
      "name": "Leer antes de activar",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        -240
      ],
      "parameters": {
        "content": "SANDBOX AISLADO. No llama a Twilio, OpenAI, Google Sheets ni producción. Configura Header Auth en el Webhook: X-Lab-Token con la misma clave que N8N_SANDBOX_TOKEN en Cloudflare. Activa este workflow separado y configura N8N_SANDBOX_URL con su URL de producción. El estado entra y sale en cada petición; no se comparte entre visitantes.",
        "width": 680,
        "height": 180
      }
    },
    {
      "id": "sandbox-webhook",
      "name": "Sandbox Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        0
      ],
      "webhookId": "automatizagr-lab-v106",
      "parameters": {
        "httpMethod": "POST",
        "path": "automatizagr-lab-v106",
        "authentication": "headerAuth",
        "responseMode": "responseNode",
        "options": {}
      }
    },
    {
      "id": "sandbox-engine",
      "name": "Motor v10.8 aislado",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        260,
        0
      ],
      "parameters": {
        "jsCode": "function parseCsv(csv) {\r\n  const rows = [];\r\n  let row = [];\r\n  let cell = '';\r\n  let quoted = false;\r\n\r\n  for (let i = 0; i < String(csv || '').length; i++) {\r\n    const ch = csv[i];\r\n    const next = csv[i + 1];\r\n    if (ch === '\"' && quoted && next === '\"') {\r\n      cell += '\"';\r\n      i++;\r\n      continue;\r\n    }\r\n    if (ch === '\"') {\r\n      quoted = !quoted;\r\n      continue;\r\n    }\r\n    if (ch === ',' && !quoted) {\r\n      row.push(cell);\r\n      cell = '';\r\n      continue;\r\n    }\r\n    if ((ch === '\\n' || ch === '\\r') && !quoted) {\r\n      if (ch === '\\r' && next === '\\n') i++;\r\n      row.push(cell);\r\n      rows.push(row);\r\n      row = [];\r\n      cell = '';\r\n      continue;\r\n    }\r\n    cell += ch;\r\n  }\r\n\r\n  if (cell || row.length) {\r\n    row.push(cell);\r\n    rows.push(row);\r\n  }\r\n\r\n  return rows.filter(r => r.some(c => String(c).trim() !== ''));\r\n}\r\n\r\nfunction rowsToObjects(rows) {\r\n  const headers = rows[0] || [];\r\n  return rows.slice(1).map(row => {\r\n    const obj = {};\r\n    headers.forEach((header, index) => {\r\n      obj[String(header || '').trim()] = row[index] || '';\r\n    });\r\n    return obj;\r\n  });\r\n}\r\n\r\nfunction normasToObjects(rows) {\r\n  return rows.map(row => ({\r\n    Concepto: String(row[0] || '').replace(/^Concepto\\s*/i, '').trim(),\r\n    Valor: String(row[1] || '').replace(/^Valor\\s*/i, '').trim(),\r\n    Notas: String(row[2] || '').trim(),\r\n  })).filter(row => row.Concepto || row.Valor || row.Notas);\r\n}\r\n\r\nfunction stripAccents(value) {\r\n  return String(value || '')\r\n    .toLowerCase()\r\n    .normalize('NFD')\r\n    .replace(/[\\u0300-\\u036f]/g, '');\r\n}\r\n\r\nfunction cleanPhone(value) {\r\n  return String(value || '').replace(/^whatsapp:/, '').replace(/\\D/g, '');\r\n}\r\n\r\nconst MENU_URL = '/carta.html';\r\nconst RESERVATION_CHAT_URL = '#reservar';\r\n\r\nfunction findNorm(normas, pattern) {\r\n  return normas.find(row => pattern.test(stripAccents(row.Concepto))) || {};\r\n}\r\n\r\nfunction buildContextFromCsv(csvs) {\r\n  let carta = rowsToObjects(parseCsv(csvs.carta || ''));\r\n  const normas = normasToObjects(parseCsv(csvs.normas || ''));\r\n  const eventos = rowsToObjects(parseCsv(csvs.eventos || ''));\r\n  const reservas = rowsToObjects(parseCsv(csvs.reservas || ''));\r\n\r\n  if (!carta.some(item => getProduct(item))) {\r\n    carta = fallbackCarta();\r\n  }\r\n\r\n  const quickFacts = {\r\n    horario: findNorm(normas, /horario|apertura/).Valor || 'Martes a Domingo: 09:00 a 16:00',\r\n    cocinaMediodia: findNorm(normas, /cocina.*mediodia/).Valor || '16:00',\r\n    cocinaNoche: findNorm(normas, /cocina.*noche/).Valor || '23:30',\r\n    minReserva: findNorm(normas, /minimo|reserva/).Valor || '6 personas',\r\n    terraza: findNorm(normas, /terra/).Valor || 'No se reserva',\r\n    mascotas: findNorm(normas, /mascota/).Valor || 'Solo en terraza excepto perros de servicio',\r\n  };\r\n\r\n  quickFacts.eventosResumen = eventos.slice(0, 8)\r\n    .map(evento => [\r\n      getEventDate(evento),\r\n      getEventTime(evento),\r\n      getEventName(evento),\r\n      getEventTv(evento),\r\n      getEventScreens(evento),\r\n    ].filter(Boolean).join(' - '))\r\n    .filter(Boolean)\r\n    .join('\\n');\r\n\r\n  return { carta, normas, eventos, reservas, quickFacts };\r\n}\r\n\r\nfunction fallbackCarta() {\r\n  return [\r\n    { Producto: 'Menú del día', Categoria: 'Menu', Precio: '11.50EUR', Alergenos: 'preguntar plato', Descripcion: 'Incluye primero, segundo y postre.' },\r\n    { Producto: 'Patatas bravas', Categoria: 'Racion', Precio: '7.50EUR', Alergenos: 'Gluten', Descripcion: 'Patatas con salsa casera.' },\r\n    { Producto: 'Croquetas', Categoria: 'Racion', Precio: '9.00EUR', Alergenos: 'Gluten, Lactosa', Descripcion: 'Croquetas caseras.' },\r\n    { Producto: 'Ravas', Categoria: 'Racion', Precio: '10.50EUR', Alergenos: 'Gluten, Moluscos', Descripcion: 'Tiras de calamares.' },\r\n    { Producto: 'Flamenquín cordobés', Categoria: 'Racion', Precio: '8.50EUR', Alergenos: 'Gluten, Huevo, Lactosa', Descripcion: 'Lomo relleno de jamón y queso, empanado.' },\r\n    { Producto: 'Pimientos de Padrón', Categoria: 'Racion', Precio: '6.00EUR', Alergenos: 'Ninguno', Descripcion: 'Pimientos fritos con sal.' },\r\n    { Producto: 'Ensaladilla Rusa', Categoria: 'Tapa', Precio: '0.00EUR', Alergenos: 'Huevo, Pescado', Descripcion: 'Patata, atún, variantes y mayonesa.' },\r\n    { Producto: 'Migas alpujarreñas', Categoria: 'Tapa', Precio: '0.00EUR', Alergenos: 'Gluten', Descripcion: 'Migas tradicionales.' },\r\n    { Producto: 'Carne en Salsa', Categoria: 'Tapa', Precio: '0.00EUR', Alergenos: 'Ninguno', Descripcion: 'Magro de cerdo con salsa de la casa.' },\r\n  ];\r\n}\r\n\r\nfunction getDescription(item) {\r\n  return item['Descripci\\u00f3n'] || item.Descripcion || item.Description || '';\r\n}\r\n\r\nfunction getProduct(item) {\r\n  return item.Producto || item.producto || item.Nombre || item.nombre || item.Plato || item.plato || '';\r\n}\r\n\r\nfunction getCategory(item) {\r\n  return item.Categoria || item['Categor\\u00eda'] || item.category || '';\r\n}\r\n\r\nfunction getPrice(item) {\r\n  return item.Precio || item.precio || item.Price || '';\r\n}\r\n\r\nfunction getAllergens(item) {\r\n  return item.Alergenos || item['Al\\u00e9rgenos'] || item.alergenos || '';\r\n}\r\n\r\nfunction getFriendly(item) {\r\n  return getField(item, ['Friendly', 'Apto', 'Tipo', 'Dieta', 'Preferencia']);\r\n}\r\n\r\nfunction normalizeImageUrl(value) {\r\n  const raw = String(value || '').trim();\r\n  if (!/^https?:\\/\\//i.test(raw)) return '';\r\n  const nestedMatch = raw.match(/[?&](?:imgurl|mediaurl)=([^&]+)/i);\r\n  if (nestedMatch) {\r\n    const decoded = decodeURIComponent(nestedMatch[1]);\r\n    if (/^https?:\\/\\//i.test(decoded)) return decoded;\r\n  }\r\n  try {\r\n    const url = new URL(raw);\r\n    const nestedImage = url.searchParams.get('imgurl') || url.searchParams.get('mediaurl');\r\n    if (/^https?:\\/\\//i.test(String(nestedImage || '').trim())) {\r\n      return String(nestedImage).trim();\r\n    }\r\n  } catch (_) {\r\n    // Keep the original URL if URL parsing fails but it still looks like HTTP media.\r\n  }\r\n  return raw;\r\n}\r\n\r\nfunction getImageUrl(item) {\r\n  const direct = getField(item, [\r\n    'Imagen', 'Imagen URL', 'URL Imagen', 'Url Imagen', 'Foto', 'Foto URL',\r\n    'Image', 'Image URL', 'Media', 'Media URL', 'Link Imagen', 'Enlace Imagen',\r\n  ]);\r\n  const normalizedDirect = normalizeImageUrl(direct);\r\n  if (normalizedDirect) return normalizedDirect;\r\n  for (const [key, value] of Object.entries(item || {})) {\r\n    const k = normalizeKey(key);\r\n    const v = normalizeImageUrl(value);\r\n    if ((k.includes('imagen') || k.includes('image') || k.includes('foto') || k.includes('media')) && v) {\r\n      return v;\r\n    }\r\n  }\r\n  return '';\r\n}\r\n\r\nfunction categoryMessage(category) {\r\n  const c = stripAccents(category);\r\n  if (c.includes('tapa')) return 'Es una tapa.';\r\n  if (c.includes('menu')) return 'Es un menú: incluye varios platos.';\r\n  if (c.includes('racion')) return 'Es una ración para compartir.';\r\n  return category ? 'Categoria: ' + category + '.' : '';\r\n}\r\n\r\nfunction compactText(value, max = 240) {\r\n  const clean = String(value || '').replace(/\\s+/g, ' ').trim();\r\n  return clean.length > max ? clean.slice(0, max - 1).trimEnd() + '...' : clean;\r\n}\r\n\r\nfunction buildMenuCard(item, reply) {\r\n  const product = getProduct(item);\r\n  const price = formatPrice(getPrice(item));\r\n  const category = getCategory(item);\r\n  const desc = getDescription(item);\r\n  const friendly = getFriendly(item);\r\n  const imageUrl = getImageUrl(item);\r\n  const subtitle = [\r\n    categoryMessage(category).replace(/\\.$/, ''),\r\n    price ? 'Precio: ' + price : '',\r\n    friendly && stripAccents(friendly) !== 'ninguno' ? friendly : '',\r\n  ].filter(Boolean).join(' · ');\r\n\r\n  return {\r\n    kind: 'product',\r\n    product,\r\n    category,\r\n    price,\r\n    description: desc,\r\n    friendly,\r\n    imageUrl,\r\n    menuUrl: MENU_URL,\r\n    reservationUrl: RESERVATION_CHAT_URL,\r\n    title: compactText(product, 80),\r\n    subtitle: compactText(subtitle || 'Carta de AutomatizaGr Bar', 120),\r\n    body: compactText(reply, 500),\r\n  };\r\n}\r\n\r\nfunction normalizeKey(value) {\r\n  return stripAccents(value).replace(/[^\\w]+/g, '');\r\n}\r\n\r\nfunction getField(row, names) {\r\n  const wanted = names.map(normalizeKey);\r\n  for (const [key, value] of Object.entries(row || {})) {\r\n    if (wanted.includes(normalizeKey(key))) return value || '';\r\n  }\r\n  return '';\r\n}\r\n\r\nfunction formatPrice(price) {\r\n  const raw=String(price??'').trim();\r\n  const amount=raw.match(/^(\\d+(?:[.,]\\d{1,2})?)\\s*(?:EUR|€)?$/i);\r\n  return amount ? new Intl.NumberFormat('es-ES',{style:'currency',currency:'EUR'}).format(Number(amount[1].replace(',','.'))) : raw;\r\n}\r\n\r\nfunction detectIntent(text, isOwner) {\r\n  const t = stripAccents(text);\r\n  if (isOwner && isExplicitOwnerCommand(text)) return 'owner_command';\r\n  if (/^(vale|ok|si|gracias|muchas gracias)[.! ]*$/.test(t.trim())) return 'faq';\r\n  if(/\\b(celiac[oa]s?|alergia|alergic[oa]s?|intolerancia|intolerantes?)\\b/.test(t))return 'handoff';\r\n  if (/\\b(queja|reclamacion|mal|problema|encargado|responsable|humano|persona|factura|perdido|chaqueta)\\b/.test(t)) return 'handoff';\r\n  if (/\\b(cambiar|modificar|anular|cancelar)\\b.*\\b(reserv\\w*|mesa)/.test(t)) return 'handoff';\r\n  const informationQuestion=/\\b(?:que horarios?|a que hora|cuando)\\b.*\\b(?:abri\\w*|abren|cerra\\w*|cierra\\w*)/.test(t);\r\n  if(!informationQuestion&&(/^\\s*(?:una\\s+)?reserva\\b/.test(t)||/\\b(?:quiero|quisiera|queria|necesito|podemos|podria|puedo)\\s+(?:hacer\\s+una\\s+)?reserv(?:ar|a)\\b/.test(t)))return 'reservation';\r\n  if (/\\b(partido|partidos|patido|patidos|evento|eventos|futbol|football|footbol|champions|madrid|barca|barsa|final|liga|emiten|emitir|televisan|televisado)/.test(t)) return 'events';\r\n  if (/\\b(horario\\w*|hora|abri(?!l\\b)\\w*|abrir|abren|cerra\\w*|cierra\\w*|cocina|terraza|mascota\\w*|perro|animal|tarde)\\b/.test(t)) return 'rules';\r\n  if (/\\b(a\\s+las|sobre\\s+las)\\s+\\d{1,2}/.test(t)) return 'reservation';\r\n  if (/\\b(reserv\\w*|mesa|comensal|persona|person|pax)\\b/.test(t) || /\\bpara\\s+\\d+\\b/.test(t)) return 'reservation';\r\n  if (/\\b(disponible|sitio|hueco)\\b/.test(t) && /\\b(reserv\\w*|mesa|cenar|comer)\\b/.test(t)) return 'reservation';\r\n  if (/\\b(menu|menus|meniu|carta|cartas|carteo|cartita|plato|platos|precio|vale|valen|cuesta|tapa|tapas|racion|raciones|alergeno|alergenos|gluten|lactosa|huevo|vegano|vegana|veganos|veganas|vegetariano|vegetariana|vegetarianos|vegetarianas|veggie|friendly|brava|bravas|croqueta|croquetas|flamenquin|ensaladilla|migas|bebida|comer|cenar)/.test(t)) return 'menu';\r\n  return 'faq';\r\n}\r\n\r\nfunction isExplicitOwnerCommand(text) {\r\n  const raw = String(text || '').trim();\r\n  const t = stripAccents(raw);\r\n  if (/^(lleno|libre|reservas)\\b/.test(t)) return true;\r\n  if (raw === 'HOY') return true;\r\n  if (/^aforo\\s+\\d+/.test(t)) return true;\r\n  if (/^(confirmar|cancelar)\\s+r?\\d+\\b/.test(t)) return true;\r\n  if (/^aceptar\\s+(reserva|r?\\d+)\\b/.test(t)) return true;\r\n  return false;\r\n}\r\n\r\nfunction matchMenuItem(carta, text) {\r\n  const t = stripAccents(text);\r\n  const stop = new Set(['cuanto', 'cuesta', 'valen', 'vale', 'teneis', 'tienen', 'precio', 'quiero', 'algo', 'menu', 'menus', 'meniu', 'carta', 'cartas', 'carteo', 'cartita', 'plato', 'platos']);\r\n  const words = t.split(/\\s+/).map(w => w.replace(/[^\\w]/g, '')).filter(w => w.length > 3 && !stop.has(w));\r\n\r\n  let best = null;\r\n  let bestScore = 0;\r\n  for (const item of carta) {\r\n    const name = stripAccents(getProduct(item));\r\n    const desc = stripAccents(getDescription(item));\r\n    let score = 0;\r\n    for (const word of words) {\r\n      if (name.includes(word)) score += 4;\r\n      if (desc.includes(word)) score += 1;\r\n      if (stripAccents(getFriendly(item)).includes(word)) score += 1;\r\n    }\r\n    if (score > bestScore) {\r\n      best = item;\r\n      bestScore = score;\r\n    }\r\n  }\r\n  return bestScore > 0 ? best : null;\r\n}\r\n\r\nfunction replyMenu(context, text) {\r\n  const t = stripAccents(text);\r\n  if(/\\b(?:sin|libre de|no quiero|no puedo(?: tomar|comer)?)\\s+(gluten|lactosa|huevo|alergenos)\\b/.test(t))return {reply:'El equipo debe revisar los ingredientes y la preparación para esas opciones. No puedo garantizar que un plato sea seguro para una alergia con estos datos. Te paso la consulta.',notifyOwner:true,ownerMessage:'Revisar consulta de ingredientes: '+text};\r\n  const carta = context.carta || [];\r\n  const quickFacts = context.quickFacts || {};\r\n\r\n  if (/\\b(vegano|vegana|veganos|veganas|vegetariano|vegetariana|vegetarianos|vegetarianas|veggie|friendly)\\b/.test(t)) {\r\n    const wanted = /vegano|vegana|veganos|veganas/.test(t) ? 'vegano'\r\n      : /vegetariano|vegetariana|vegetarianos|vegetarianas|veggie/.test(t) ? 'vegetariano'\r\n      : '';\r\n    const matches = carta.filter(item => {\r\n      const friendly = stripAccents(getFriendly(item));\r\n      if (!friendly || friendly === 'ninguno') return false;\r\n      return wanted ? friendly.includes(wanted) : true;\r\n    });\r\n    if (!matches.length) {\r\n      return wanted\r\n        ? 'Ahora mismo no veo platos marcados como ' + wanted + ' en la carta.'\r\n        : 'Ahora mismo no veo etiquetas Friendly en la carta.';\r\n    }\r\n    return 'Opciones ' + (wanted || 'friendly') + ': ' + matches\r\n      .map(item => getProduct(item) + (getFriendly(item) ? ' (' + getFriendly(item).trim() + ')' : ''))\r\n      .filter(Boolean)\r\n      .join(', ') + '.';\r\n  }\r\n\r\n  if (/\\b(gluten|lactosa|huevo|pescado|molusco|alerg)\\b/.test(t)) {\r\n    const allergen = /lactosa/.test(t) ? 'lactosa'\r\n      : /huevo/.test(t) ? 'huevo'\r\n      : /pescado/.test(t) ? 'pescado'\r\n      : /molusco/.test(t) ? 'molusco'\r\n      : /gluten/.test(t) ? 'gluten'\r\n      : '';\r\n    const matches = carta.filter(item => stripAccents(getAllergens(item)).includes(allergen));\r\n    if (!matches.length) {\r\n      return 'Ahora mismo no veo platos marcados con ' + allergen + ' en la carta. Si es alergia fuerte, mejor lo revisa el equipo antes de pedir.';\r\n    }\r\n    return 'Con ' + allergen + ' aparecen: ' + matches.map(item => getProduct(item)).filter(Boolean).join(', ') + '. Si tienes alergia fuerte, avísanos antes de pedir.';\r\n  }\r\n\r\n  const item = matchMenuItem(carta, text);\r\n  if (item) {\r\n    const product = getProduct(item);\r\n    const bits = ['*' + product + '*'];\r\n    const category = categoryMessage(getCategory(item));\r\n    if (category) bits.push(category);\r\n    bits.push('Precio: ' + (formatPrice(getPrice(item)) || 'no indicado') + '.');\r\n    const desc = getDescription(item);\r\n    if (desc) bits.push(desc);\r\n    if (getAllergens(item)) bits.push('Alérgenos: ' + getAllergens(item) + '.');\r\n    if (getFriendly(item) && stripAccents(getFriendly(item)) !== 'ninguno') bits.push('Friendly: ' + getFriendly(item).trim() + '.');\r\n    const reply = bits.join('\\n');\r\n    return {\r\n      reply,\r\n      menuCard: buildMenuCard(item, reply),\r\n      menuOverview: false,\r\n    };\r\n  }\r\n\r\n  const validItems = carta.filter(item => getProduct(item));\r\n  const tapas = validItems.filter(item => stripAccents(getCategory(item)).includes('tapa'));\r\n  if (/\\btapa/.test(t) && tapas.length) {\r\n    return 'De tapas tenemos: ' + tapas.map(item => getProduct(item)).join(', ') + '.';\r\n  }\r\n\r\n  if (!validItems.length) {\r\n    return 'Ahora mismo no puedo leer la carta correctamente. Te paso con el equipo del bar para revisarlo.';\r\n  }\r\n\r\n  return {\r\n    reply: '*Carta de AutomatizaGr Bar*\\n' +\r\n      'Te dejo la carta completa en el botón de abajo.\\n\\n' +\r\n      'Si quieres saber precio, alérgenos u opciones veganas, dime el plato o la dieta.',\r\n    menuOverview: {\r\n      kind: 'overview',\r\n      title: 'Carta de AutomatizaGr Bar',\r\n      body: 'Mira la carta completa o empieza una reserva en WhatsApp.',\r\n      menuUrl: MENU_URL,\r\n      reservationUrl: RESERVATION_CHAT_URL,\r\n    },\r\n  };\r\n}\r\n\r\nfunction replyRules(context, text) {\r\n  const t = stripAccents(text);\r\n  const q = context.quickFacts || {};\r\n  if (/terraza/.test(t)) {\r\n    return 'La terraza no se reserva. Puedes pasarte y te atendemos según disponibilidad.';\r\n  }\r\n  if (/mascota|perro|animal/.test(t)) {\r\n    return 'Mascotas solo en terraza, excepto perros de servicio. Dentro no se permiten animales.';\r\n  }\r\n  if (/cocina|comer|cenar/.test(t)) {\r\n    return 'La cocina cierra a las ' + q.cocinaMediodia + ' al mediodía y a las ' + q.cocinaNoche + ' por la noche.';\r\n  }\r\n  return 'Abrimos de ' + q.horario + '. La cocina cierra a las ' + q.cocinaMediodia + ' al mediodía y a las ' + q.cocinaNoche + ' por la noche.';\r\n}\r\n\r\nfunction replyEvents(context, text) {\r\n  const t = stripAccents(text);\r\n  const eventos = context.eventos || [];\r\n  const validEvents = eventos.filter(evento => getEventName(evento));\r\n  const matches = validEvents.filter(evento => {\r\n    const haystack = stripAccents([getEventName(evento), getEventDate(evento), getEventTime(evento), getEventScreens(evento)].join(' '));\r\n    if (/champions/.test(t)) return /champions/.test(haystack);\r\n    if (/madrid/.test(t)) return /madrid/.test(haystack);\r\n    if (/barca|barsa/.test(t)) return /barca|barsa/.test(haystack);\r\n    if (/hoy/.test(t)) return isEventToday(evento, context.now);\r\n    return true;\r\n  });\r\n  let source = matches.length ? matches : validEvents;\r\n  if (!source.length) return 'Ahora mismo no tengo partidos apuntados en la hoja.';\r\n  const prefix = /hoy/.test(t) && !matches.length\r\n    ? 'Hoy no tengo partidos apuntados. Proximos eventos:\\n'\r\n    : 'Partidos/eventos previstos:\\n';\r\n  return prefix + source.slice(0, 5).map(evento => {\r\n    const tv = getEventTv(evento);\r\n    const screens = getEventScreens(evento);\r\n    return [formatEventDate(getEventDate(evento)), getEventTime(evento), getEventName(evento), tv ? 'se televisa: ' + tv : '', screens].filter(Boolean).join(' - ');\r\n  }).join('\\n');\r\n}\r\n\r\nfunction getEventName(evento) {\r\n  return getField(evento, ['Partido', 'Evento', 'Nombre']);\r\n}\r\n\r\nfunction getEventDate(evento) {\r\n  return getField(evento, ['Fecha', 'Dia', 'Día']);\r\n}\r\n\r\nfunction getEventTime(evento) {\r\n  return getField(evento, ['Hora', 'Horario']);\r\n}\r\n\r\nfunction getEventTv(evento) {\r\n  return getField(evento, ['Se televisa?', '¿Se televisa?', 'Se televisa', 'Televisado']);\r\n}\r\n\r\nfunction getEventScreens(evento) {\r\n  return getField(evento, ['Pantallas (donde estan las teles)', 'Pantallas', 'Donde estan las teles', 'Dónde están las teles']);\r\n}\r\n\r\nfunction isEventToday(evento, now) {\r\n  const parts=madridDateParts(now),dd=String(parts.day).padStart(2,'0'),mm=String(parts.month).padStart(2,'0'),yyyy=parts.year;\r\n  const value=String(getEventDate(evento)||'').trim();\r\n  return value===`${dd}-${mm}-${yyyy}`||value===`${dd}/${mm}/${yyyy}`||value===`${yyyy}-${mm}-${dd}`;\r\n}\r\n\r\nfunction formatEventDate(value) {\r\n  const raw = String(value || '').trim();\r\n  const m = raw.match(/^(\\d{1,2})[\\/-](\\d{1,2})[\\/-](\\d{2,4})$/);\r\n  if (m) return String(Number(m[1])).padStart(2, '0') + '/' + String(Number(m[2])).padStart(2, '0') + '/' + (m[3].length === 2 ? '20' + m[3] : m[3]);\r\n  const iso = raw.match(/^(\\d{4})-(\\d{2})-(\\d{2})$/);\r\n  if (iso) return iso[3] + '/' + iso[2] + '/' + iso[1];\r\n  return raw;\r\n}\r\n\r\nfunction madridDateParts(now) {\r\n  const parts = new Intl.DateTimeFormat('en-CA', {\r\n    timeZone: 'Europe/Madrid',\r\n    year: 'numeric',\r\n    month: '2-digit',\r\n    day: '2-digit',\r\n  }).formatToParts(new Date(now || Date.now()));\r\n  const values = Object.fromEntries(parts.map(part => [part.type, part.value]));\r\n  return {\r\n    year: Number(values.year),\r\n    month: Number(values.month),\r\n    day: Number(values.day),\r\n  };\r\n}\r\n\r\nfunction calendarDate(parts) {\r\n  return new Date(Date.UTC(parts.year, parts.month - 1, parts.day, 12));\r\n}\r\n\r\nfunction isoCalendarDate(date) {\r\n  return [\r\n    date.getUTCFullYear(),\r\n    String(date.getUTCMonth() + 1).padStart(2, '0'),\r\n    String(date.getUTCDate()).padStart(2, '0'),\r\n  ].join('-');\r\n}\r\n\r\nfunction formatReservationDate(value) {\r\n  const raw = String(value || '').trim();\r\n  const iso = raw.match(/^(\\d{4})-(\\d{2})-(\\d{2})$/);\r\n  if (iso) return iso[3] + '/' + iso[2] + '/' + iso[1];\r\n  const dmy = raw.match(/^(\\d{1,2})[\\/-](\\d{1,2})[\\/-](\\d{2,4})$/);\r\n  if (dmy) return String(Number(dmy[1])).padStart(2, '0') + '/' + String(Number(dmy[2])).padStart(2, '0') + '/' + (dmy[3].length === 2 ? '20' + dmy[3] : dmy[3]);\r\n  return raw;\r\n}\r\n\r\nfunction normalizeReservationTime(hour, minute, meridiem, defaultPm) {\r\n  let h = Number(hour);\r\n  const m = Number(minute || 0);\r\n  if (!Number.isFinite(h) || !Number.isFinite(m) || h < 0 || h > 23 || m < 0 || m > 59) return '';\r\n  const mer = stripAccents(meridiem || '');\r\n  if (/noche/.test(mer) && h===12) h=0;\r\n  else if (/p\\.?m\\.?|tarde|noche/.test(mer) && h >= 1 && h <= 11) h += 12;\r\n  if (/a\\.?m\\.?|manana/.test(mer) && h === 12) h = 0;\r\n  if (!mer && defaultPm && h >= 1 && h <= 11) h += 12;\r\n  return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');\r\n}\r\n\r\nfunction parseReservationTime(text, period = '') {\r\n  let t = stripAccents(text);\r\n  const words = 'una|uno|dos|tres|cuatro|cinco|seis|siete|ocho|nueve|diez|once|doce';\r\n  t = t.replace(new RegExp('((?:a|para|sobre)\\\\s+las\\\\s+|\\\\blas\\\\s+)(' + words + ')\\\\b','g'),(_,prefix,word)=>prefix+spanishNumber(word));\r\n  const meridiem = /(?:por|de)\\s+la\\s+manana/.test(t) ? 'manana' : /(?:por|de)\\s+la\\s+noche/.test(t) ? 'noche' : /(?:por|de)\\s+la\\s+tarde/.test(t) ? 'tarde' : period === 'mañana' ? 'manana' : /noche|tarde/.test(period) ? period : '';\r\n  let m = t.match(/\\b(\\d{1,2})\\s*(?::|\\.|h)\\s*(\\d{2})\\s*(a\\.?m\\.?|p\\.?m\\.?|am|pm)?\\b/);\r\n  if (m) return normalizeReservationTime(m[1], m[2], m[3] || (/^0/.test(m[1])&&!/(?:por|de)\\s+la\\s+(?:manana|tarde|noche)/.test(t)?'':meridiem), false);\r\n  m = t.match(/\\b(\\d{1,2})\\s+(y|menos)\\s+(media|cuarto|\\d{1,2})\\b/);\r\n  if (m) {\r\n    let hour=Number(m[1]),minute=m[3]==='media'?30:m[3]==='cuarto'?15:Number(m[3]);\r\n    if(minute>59)return '';\r\n    if(m[2]==='menos'){hour=(hour+23)%24;minute=60-minute;}\r\n    return normalizeReservationTime(hour,minute,meridiem,true);\r\n  }\r\n  m = t.match(/\\b(?:(?:a|sobre|para)\\s+las|las|hora)\\s*(\\d{1,2})\\s*(a\\.?m\\.?|p\\.?m\\.?|am|pm)?\\b/);\r\n  if (m) return normalizeReservationTime(m[1],0,m[2] || meridiem,true);\r\n  m = t.match(/\\b(\\d{1,2})\\s*(a\\.?m\\.?|p\\.?m\\.?|am|pm)\\b/);\r\n  return m ? normalizeReservationTime(m[1],0,m[2],true) : '';\r\n}\r\n\r\nfunction relativeWeekdayDate(text, todayDate) {\r\n  const weekdays = {\r\n    domingo: 0,\r\n    lunes: 1,\r\n    martes: 2,\r\n    miercoles: 3,\r\n    jueves: 4,\r\n    viernes: 5,\r\n    sabado: 6,\r\n  };\r\n  const weekdayPattern = '(domingo|lunes|martes|miercoles|jueves|viernes|sabado)';\r\n  const nextWeek = text.match(new RegExp('\\\\b' + weekdayPattern + '\\\\s+de\\\\s+la\\\\s+(?:semana\\\\s+que\\\\s+viene|proxima\\\\s+semana)\\\\b'));\r\n  const coming = text.match(new RegExp('\\\\b(?:el\\\\s+)?' + weekdayPattern + '\\\\s+que\\\\s+viene\\\\b'))\r\n    || text.match(new RegExp('\\\\bproximo\\\\s+' + weekdayPattern + '\\\\b'));\r\n  const regular = text.match(new RegExp('\\\\b(?:este\\\\s+|el\\\\s+)?' + weekdayPattern + '\\\\b'));\r\n  const match = nextWeek || coming || regular;\r\n  if (!match) return null;\r\n\r\n  const targetDay = weekdays[match[1]];\r\n  const currentDay = todayDate.getUTCDay();\r\n  let offset;\r\n  if (nextWeek) {\r\n    const daysUntilNextMonday = ((8 - currentDay) % 7) || 7;\r\n    offset = daysUntilNextMonday + (targetDay + 6) % 7;\r\n  } else {\r\n    offset = (targetDay - currentDay + 7) % 7;\r\n    if (coming && offset === 0) offset = 7;\r\n  }\r\n\r\n  const result = new Date(todayDate);\r\n  result.setUTCDate(result.getUTCDate() + offset);\r\n  return isoCalendarDate(result);\r\n}\r\n\r\nfunction parseReservationDetails(text, now, period = '', allowContrast = true) {\r\n  if(allowContrast){\r\n    const contrast=String(text).match(/\\b(?:sino|mejor|perd[oó]n|en realidad)\\b\\s*[,;:]?\\s*/i);\r\n    const nameStart=String(text).match(/\\b(?:a nombre de|me llamo|mi nombre es|soy)\\s+/i);\r\n    const insideName=nameStart&&contrast&&nameStart.index<contrast.index&&!/[,;]/.test(text.slice(nameStart.index,contrast.index));\r\n    if(contrast&&contrast.index>0&&!insideName){\r\n      const before=parseReservationDetails(text.slice(0,contrast.index),now,period,false);\r\n      const tail=text.slice(contrast.index+contrast[0].length);\r\n      const after=parseReservationDetails(tail,now,before.franja||period,true);\r\n      const bare=stripAccents(tail).replace(/[.!]+$/,'').trim();\r\n      const shortNumber=/^\\d{1,3}$/.test(bare)?Number(bare):spanishNumber(bare);\r\n      if(shortNumber&&!Object.keys(after).length){\r\n        if(before.comensales!==undefined||before.comensalesError)after.comensales=shortNumber;\r\n        else if(before.hora||before.horaError){const time=parseReservationTime('a las '+bare,before.franja||period);if(isReservationHourAllowed(time))after.hora=time;else after.horaError=time||'no válida';}\r\n      }\r\n      if(before.nombre&&!Object.keys(after).length&&/^[\\p{L}][\\p{L}\\p{M} .'-]{1,59}$/u.test(tail.trim()))after.nombre=cleanReservationName(tail);\r\n      const combined={...before,...after};\r\n      if(after.hora)delete combined.horaError;\r\n      if(after.fecha)delete combined.fechaError;\r\n      if(after.comensales)delete combined.comensalesError;\r\n      return combined;\r\n    }\r\n  }\r\n  const t = stripAccents(text);\r\n  const details = {};\r\n  const people = t.match(/\\b(?:para\\s*)?(-?\\d+(?:[.,]\\d+)?)\\s*(?:personas?|personss?|comensales?|comensal|pax)\\b/)\r\n    || t.match(/\\b(?:con|para|pa|somos|seremos|seriamos)\\s+(-?\\d+(?:[.,]\\d+)?)\\s*(?:personas?|personss?|comensales?|pax)?\\b/);\r\n  if (people) details.comensales = Number(people[1].replace(',','.'));\r\n  if (!details.comensales) {\r\n    const wordPeople = t.match(/\\b(?:con|para|pa|somos|seremos|seriamos)\\s+(una|uno|dos|tres|cuatro|cinco|seis|siete|ocho|nueve|diez|once|doce|trece|catorce|quince|dieciseis|diecisiete|dieciocho|diecinueve|veinte)\\s*(?:personas?|personss?|comensales?|pax)?\\b/)\r\n      || t.match(/\\b(una|uno|dos|tres|cuatro|cinco|seis|siete|ocho|nueve|diez|once|doce|trece|catorce|quince|dieciseis|diecisiete|dieciocho|diecinueve|veinte)\\s*(?:personas?|personss?|comensales?|pax)\\b/);\r\n    if (wordPeople) details.comensales = spanishNumber(wordPeople[1]);\r\n  }\r\n\r\n  const franja = t.match(/(?:por|de)\\s+la\\s+(manana|tarde|noche)\\b/);\r\n  if(franja)details.franja=franja[1]==='manana'?'mañana':franja[1];\r\n  const parsedTime = parseReservationTime(text, details.franja || period);\r\n  if(!parsedTime && /\\b(?:(?:a|para|sobre)\\s+las\\s+\\d|\\d{1,2}[:.]\\d{1,2})/.test(t))details.horaError='no válida';\r\n  if (parsedTime) {\r\n    if (isReservationHourAllowed(parsedTime)) {details.hora = parsedTime;if(!franja&&/\\b\\d{1,2}:\\d{2}/.test(t))details.franja=Number(parsedTime.slice(0,2))<12?'mañana':Number(parsedTime.slice(0,2))<20?'tarde':'noche';}\r\n    else details.horaError = parsedTime;\r\n  }\r\n\r\n  const todayParts = madridDateParts(now);\r\n  const today = calendarDate(todayParts);\r\n  if (/\\bhoy\\b/.test(t)) details.fecha = isoCalendarDate(today);\r\n  if (/\\bmanana\\b/.test(t.replace(/(?:por|de)\\s+la\\s+manana/g,''))) {\r\n    const d = new Date(today);\r\n    d.setUTCDate(d.getUTCDate() + (/\\bpasado\\s+manana\\b/.test(t)?2:1));\r\n    details.fecha = isoCalendarDate(d);\r\n  }\r\n  const weekdayDate = relativeWeekdayDate(t, today);\r\n  if (weekdayDate) details.fecha = weekdayDate;\r\n  const relativeDays=t.match(/\\bdentro de\\s+(\\d{1,3}|uno|dos|tres|cuatro|cinco|seis|siete|ocho|nueve|diez)\\s+dias?\\b/);\r\n  if(relativeDays){const number=Number(relativeDays[1])||spanishNumber(relativeDays[1]);if(number){const d=new Date(today);d.setUTCDate(d.getUTCDate()+number);details.fecha=isoCalendarDate(d);}}\r\n  const months={enero:1,febrero:2,marzo:3,abril:4,mayo:5,junio:6,julio:7,agosto:8,septiembre:9,setiembre:9,octubre:10,noviembre:11,diciembre:12};\r\n  const writtenDate=t.match(/\\b(\\d{1,2})\\s+(?:de\\s+)?(enero|febrero|marzo|abril|mayo|junio|julio|agosto|septiembre|setiembre|octubre|noviembre|diciembre)(?:\\s+(?:de\\s+)?(\\d{4}))?\\b/);\r\n  const isoDate=t.match(/\\b(\\d{4})-(\\d{2})-(\\d{2})\\b/);\r\n  const explicit = writtenDate ? [writtenDate[0],writtenDate[1],String(months[writtenDate[2]]),writtenDate[3]] : isoDate ? [isoDate[0],isoDate[3],isoDate[2],isoDate[1]] : t.match(/\\b(\\d{1,2})[\\/-](\\d{1,2})(?:[\\/-](\\d{2,4}))?\\b/);\r\n  if (explicit) {\r\n    let year = explicit[3] ? Number(explicit[3].length === 2 ? '20' + explicit[3] : explicit[3]) : todayParts.year;\r\n    if (!explicit[3]) {\r\n      const candidate = new Date(Date.UTC(year, Number(explicit[2]) - 1, Number(explicit[1]), 12));\r\n      if (candidate < today) year += 1;\r\n    }\r\n    const candidate=new Date(Date.UTC(year,Number(explicit[2])-1,Number(explicit[1]),12));\r\n    if(candidate.getUTCMonth()+1!==Number(explicit[2])||candidate.getUTCDate()!==Number(explicit[1]))details.fechaError=explicit[0];\r\n    else details.fecha = year + '-' + String(Number(explicit[2])).padStart(2, '0') + '-' + String(Number(explicit[1])).padStart(2, '0');\r\n  }\r\n\r\n  const namePatterns = [\r\n    /\\b(?:mi\\s+nombre\\s+es|a\\s+nombre\\s+de|me\\s+llamo|nombre\\s+es|nombre|soy)\\s+([\\p{L}][\\p{L}\\p{M} .'-]{1,59}?)(?=\\s+(?:y\\s+)?(?:para|con|somos|seremos|a\\s+las|hoy|manana)\\b|[,;!?]|$)/iu,\r\n  ];\r\n  for (const pattern of namePatterns) {\r\n    const name = text.match(pattern);\r\n    if (name) {\r\n      details.nombre = cleanReservationName(name[1]);\r\n      break;\r\n    }\r\n  }\r\n\r\n  if(/^\\s*no\\b/.test(t)){\r\n    if(details.hora||details.horaError){delete details.hora;details.horaError='necesito otra hora';}\r\n    if(details.fecha){delete details.fecha;details.fechaError='necesito otra fecha';}\r\n    if(details.comensales!==undefined){delete details.comensales;details.comensalesError=true;}\r\n  }\r\n  if(details.fecha && details.fecha<isoCalendarDate(today)){details.fechaError='fecha pasada';delete details.fecha;}\r\n  return details;\r\n}\r\n\r\nfunction cleanReservationName(value) {\r\n  let name = String(value || '').trim();\r\n  name=name.replace(/(?:,?\\s+(?:por favor|muchas gracias|gracias))[.!]*$/i,'').trim();\r\n  name = name.replace(/^(?:es|soy|me\\s+llamo|mi\\s+nombre\\s+es|nombre\\s+es|a\\s+nombre\\s+de)\\s+/i, '');\r\n  name = name.replace(/\\s+(?:para|con|somos)\\s+\\d{1,2}\\b.*$/i, '');\r\n  name = name.replace(/\\s+a\\s+las\\s+\\d{1,2}\\b.*$/i, '');\r\n  name = name.replace(/\\s+(?:hoy|manana|mañana|este|esta|el)\\b.*$/i, '');\r\n  name = name.replace(/\\s+(?:lunes|martes|miercoles|miércoles|jueves|viernes|sabado|sábado|domingo)\\b.*$/i, '');\r\n  name = name.replace(/^(?:es\\s+)/i, '');\r\n  name = name.replace(/[.,;:]+$/g, '').trim();\r\n  return name;\r\n}\r\n\r\nfunction isReservationHourAllowed(value) {\r\n  const m = String(value || '').match(/^(\\d{2}):(\\d{2})$/);\r\n  if (!m) return false;\r\n  const minutes = Number(m[1]) * 60 + Number(m[2]);\r\n  return minutes >= 8 * 60 && minutes <= 23 * 60 + 30;\r\n}\r\n\r\nfunction spanishNumber(word) {\r\n  const map = {\r\n    una: 1, uno: 1, dos: 2, tres: 3, cuatro: 4, cinco: 5,\r\n    seis: 6, siete: 7, ocho: 8, nueve: 9, diez: 10,\r\n    once: 11, doce: 12, trece: 13, catorce: 14, quince: 15,\r\n    dieciseis: 16, diecisiete: 17, dieciocho: 18, diecinueve: 19,\r\n    veinte: 20,\r\n  };\r\n  return map[stripAccents(word)] || null;\r\n}\r\n\r\nfunction createState() {\r\n  return {\r\n    barFull: false,\r\n    capacity: 40,\r\n    conversations: {},\r\n    reservations: {},\r\n    nextReservationNumber: 1,\r\n  };\r\n}\r\n\r\nfunction sumReservedForDate(state, date) {\r\n  return Object.values(state.reservations || {})\r\n    .filter(r => r.fecha === date && !['Cancelada', 'Rechazada'].includes(r.estado))\r\n    .reduce((sum, r) => sum + Number(r.comensales || 0), 0);\r\n}\r\n\r\nfunction upsertPendingReservation(state, from, details) {\r\n  const existing = state.conversations[from]?.reservation || {};\r\n  return { ...existing, ...details };\r\n}\r\n\r\nfunction missingReservationFields(reservation) {\r\n  const missing = [];\r\n  if (!reservation.fecha) missing.push('fecha');\r\n  if (!reservation.hora) missing.push('hora');\r\n  if (!reservation.comensales) missing.push('comensales');\r\n  if (!reservation.nombre) missing.push('nombre');\r\n  return missing;\r\n}\r\n\r\nfunction askNextReservationQuestion(missing, reservation = {}) {\r\n  if(missing.includes('fecha')&&reservation.fechaError==='necesito otra fecha')return '¿Qué día prefieres entonces? Puedes indicar una fecha concreta.';\r\n  if(missing.includes('fecha')&&reservation.fechaError==='fecha pasada')return 'Esa fecha ya ha pasado. ¿Para qué otro día quieres reservar?';\r\n  if(missing.includes('comensales')&&reservation.comensalesError)return '¿Cuántas personas sois? Indica un número entero mayor que cero.';\r\n  if(missing.includes('hora')&&reservation.horaError==='ya pasada')return 'Esa hora ya ha pasado hoy. ¿A qué otra hora queréis venir, o preferís otro día?';\r\n  if(missing.includes('fecha')&&reservation.fechaError)return 'La fecha '+reservation.fechaError+' no existe en el calendario. ¿Qué otro día quieres?';\r\n  if (missing.includes('hora') && reservation.horaError) return 'Esa hora (' + reservation.horaError + ') está fuera del horario de reservas. ¿A qué otra hora queréis venir?';\r\n  if (missing.includes('fecha')) return '¿Para qué día quieres reservar?';\r\n  if (missing.includes('hora')) return reservation.franja ? 'Perfecto, por la ' + reservation.franja + '. ¿A qué hora os viene bien?' : 'Perfecto. ¿A qué hora queréis venir?';\r\n  if (missing.includes('comensales')) return '¿Para cuántas personas sería la reserva?';\r\n  if (missing.includes('nombre')) return '¿A nombre de quién la apunto?';\r\n  return '';\r\n}\r\n\r\nfunction isReservationConfirmText(text) {\r\n  const t=stripAccents(text).trim();\r\n  if(/[¿?]/.test(t))return false;\r\n  const clean=t.replace(/(?:,?\\s+(?:gracias|por favor))[.!]*$/,'').replace(/[.!]+$/,'').trim();\r\n  return /^(si|ok|vale|correcto|todo correcto|confirmo|confirmar|confirmar reserva|confirma mi reserva|adelante|si,?\\s+(?:confirmo|todo correcto))$/.test(clean);\r\n}\r\n\r\nfunction isReservationCorrectionText(text) {\r\n  return /^(no|corregir|cambiar|cancelar|cancelar reserva|esta mal|mal)$/i.test(stripAccents(text).trim());\r\n}\r\n\r\nfunction isReservationAbortText(text) {\r\n  const t = stripAccents(text).trim();\r\n  return /^(cancelar|cancela|anular|anula|dejalo|olvidalo|no gracias)[.!]*$/.test(t) || /^(no\\s+quiero\\s+reservar|no\\s+quiero\\s+reserva|ya\\s+no\\s+quiero\\s+reservar|olvida\\s+la\\s+reserva|deja\\s+la\\s+reserva|cancelar\\s+esta\\s+reserva|cancelar\\s+reserva|anular\\s+reserva)$/.test(t)\r\n    || /\\b(no\\s+quiero|ya\\s+no|olvida|deja|cancela|cancelar|anula|anular)\\b.*\\b(reserv\\w*|mesa)\\b/.test(t);\r\n}\r\n\r\nfunction isReservationDeclineText(text) {\r\n  const t = stripAccents(text).trim();\r\n  return /^(no\\s+quiero\\s+reservar|no\\s+quiero\\s+reserva|ya\\s+no\\s+quiero\\s+reservar)$/.test(t)\r\n    || /\\b(no\\s+quiero|ya\\s+no)\\b.*\\b(reserv\\w*|mesa)\\b/.test(t);\r\n}\r\n\r\nfunction reservationSummary(reservation) {\r\n  return reservation.comensales + ' personas el ' + formatReservationDate(reservation.fecha) + ' a las ' + reservation.hora + ', a nombre de ' + reservation.nombre;\r\n}\r\n\r\nfunction confirmationPrompt(reservation) {\r\n  return 'Tengo estos datos para la reserva:\\n' +\r\n    '- Día: ' + formatReservationDate(reservation.fecha) + '\\n' +\r\n    '- Hora: ' + reservation.hora + '\\n' +\r\n    '- Personas: ' + reservation.comensales + '\\n' +\r\n    '- Nombre: ' + reservation.nombre + '\\n\\n' +\r\n    'Responde CONFIRMAR RESERVA para enviarla al bar o CORREGIR para cambiarla.';\r\n}\r\n\r\nfunction applyActiveReservationHints(text, details, existing, askedField) {\r\n  const t = stripAccents(text).trim().replace(/[.!?]+$/,'');\r\n  const next = askedField || missingReservationFields(existing || {})[0];\r\n  const short = t.replace(/^(?:(?:pues|vale|mejor|entonces|seria|somos)\\s+)+/,'');\r\n  const number = /^\\d{1,2}$/.test(short) ? Number(short) : spanishNumber(short);\r\n  if (next === 'comensales' && number) {details.comensales=number;delete details.hora;}\r\n  if (next === 'hora' && number && !details.comensales) {\r\n    const value=normalizeReservationTime(number,0,existing.franja==='mañana'?'manana':existing.franja||'',true);\r\n    if(isReservationHourAllowed(value))details.hora=value;else details.horaError=value;\r\n  }\r\n  const candidate=cleanReservationName(text);\r\n  if(next==='nombre' && !Object.keys(details).length && /^[\\p{L}][\\p{L}\\p{M} .'-]{1,59}$/u.test(candidate) && !/\\b(gracias|vale|hola|perfecto|no|si|corregir|cambiar|mismo|igual|dia|hora|noche|tarde|quiero|que|horarios?|carta|precio|partidos?|real)\\b/.test(stripAccents(candidate)))details.nombre=candidate;\r\n  return details;\r\n}\r\n\r\nfunction validateReservationDraft(reservation, now) {\r\n  const today=isoCalendarDate(calendarDate(madridDateParts(now)));\r\n  if(reservation.fecha && reservation.fecha<today){delete reservation.fecha;reservation.fechaError='fecha pasada';}\r\n  if(reservation.comensales!==undefined&&(!Number.isInteger(reservation.comensales)||reservation.comensales<=0)){delete reservation.comensales;reservation.comensalesError=true;}\r\n  if(reservation.fecha===today && reservation.hora){\r\n    const parts=Object.fromEntries(new Intl.DateTimeFormat('en-GB',{timeZone:'Europe/Madrid',hour:'2-digit',minute:'2-digit',hourCycle:'h23'}).formatToParts(new Date(now||Date.now())).map(p=>[p.type,p.value]));\r\n    if(reservation.hora<=parts.hour+':'+parts.minute){delete reservation.hora;reservation.horaError='ya pasada';}\r\n  }\r\n  return reservation;\r\n}\r\n\r\nfunction savePendingReservation(input, context, state, reservation) {\r\n  const from = input.from;\r\n  validateReservationDraft(reservation,input.now);\r\n  const missing=missingReservationFields(reservation);\r\n  if(missing.length){state.conversations[from]={mode:'reservation',reservation,askedField:missing[0],updatedAt:new Date(input.now||Date.now()).toISOString()};return {intent:'reservation',reply:askNextReservationQuestion(missing,reservation),notifyOwner:false,sheetAction:'none',reservation};}\r\n  const minimum=Number(String(context.quickFacts?.minReserva||'6').match(/\\d+/)?.[0]||6);\r\n  if(reservation.comensales<minimum){state.conversations[from]={mode:'reservation',reservation,askedField:'comensales',updatedAt:new Date(input.now||Date.now()).toISOString()};return {intent:'reservation',reply:'El mínimo actual para reservar es de '+minimum+' personas. Mantengo los datos, pero necesito revisar el número de personas antes de enviar la solicitud.',notifyOwner:false,sheetAction:'none',reservation};}\r\n\r\n  if (state.barFull) {\r\n    state.conversations[from] = {};\r\n    return {\r\n      intent: 'reservation',\r\n      reply: 'Ahora mismo el bar está marcado como lleno. Paso la solicitud al equipo para que lo revise.',\r\n      notifyOwner: true,\r\n      ownerMessage: 'Reserva solicitada con bar lleno: +' + from + ' - ' + reservationSummary(reservation),\r\n      sheetAction: 'none',\r\n      reservation,\r\n    };\r\n  }\r\n\r\n  const alreadyReserved = sumReservedForDate(state, reservation.fecha);\r\n  const capacity = Number(state.capacity ?? 40);\r\n  if (alreadyReserved + Number(reservation.comensales) > capacity) {\r\n    state.conversations[from] = {};\r\n    return {\r\n      intent: 'reservation',\r\n      reply: 'Para ese día estamos cerca del aforo máximo. Paso la solicitud al equipo para confirmarlo manualmente.',\r\n      notifyOwner: true,\r\n      ownerMessage: 'Reserva supera aforo: +' + from + ' - ' + reservation.fecha + ' ' + reservation.hora + ' - ' + reservation.comensales + ' pax - aforo usado ' + alreadyReserved + '/' + capacity,\r\n      sheetAction: 'none',\r\n      reservation,\r\n    };\r\n  }\r\n\r\n  const id = 'R' + String(state.nextReservationNumber++).padStart(4, '0');\r\n  const saved = {\r\n    id,\r\n    telefono: from,\r\n    nombre: reservation.nombre,\r\n    fecha: reservation.fecha,\r\n    hora: reservation.hora,\r\n    comensales: Number(reservation.comensales),\r\n    estado: 'Pendiente',\r\n    notas: input.text,\r\n  };\r\n  state.reservations[id] = saved;\r\n  state.conversations[from] = { mode: 'await_owner_confirmation', reservationId: id, updatedAt:new Date(input.now||Date.now()).toISOString() };\r\n  return {\r\n    intent: 'reservation',\r\n    reply: 'Perfecto, dejo tu reserva pendiente de confirmación: ' + reservationSummary(saved) + '. El bar te la confirmará en breve.',\r\n    notifyOwner: true,\r\n    ownerMessage: 'Nueva reserva pendiente ' + id + ': +' + from + ' - ' + saved.fecha + ' ' + saved.hora + ' - ' + saved.comensales + ' pax - ' + saved.nombre + '. Responde CONFIRMAR ' + id + ' o CANCELAR ' + id + '.',\r\n    sheetAction: 'append_reservation',\r\n    reservationId: id,\r\n    reservation: saved,\r\n  };\r\n}\r\n\r\nfunction handleOwnerCommand(input, state) {\r\n  const raw = String(input.text || '').trim();\r\n  const text = stripAccents(input.text).trim();\r\n  if (text === 'lleno') {\r\n    state.barFull = true;\r\n    return { intent: 'owner_command', reply: 'Marcado: bar lleno. No confirmaré reservas automáticamente hasta que pongas LIBRE.', notifyOwner: false, sheetAction: 'none' };\r\n  }\r\n  if (text === 'libre') {\r\n    state.barFull = false;\r\n    return { intent: 'owner_command', reply: 'Marcado: bar libre. Vuelvo a aceptar solicitudes de reserva.', notifyOwner: false, sheetAction: 'none' };\r\n  }\r\n  const aforo = text.match(/^aforo\\s+(\\d+)$/);\r\n  if (aforo) {\r\n    const value=Number(aforo[1]);\r\n    if(!Number.isInteger(value)||value<0||value>10000)return {intent:'owner_command',reply:'Indica un aforo entero entre 0 y 10000. No he cambiado el aforo.',notifyOwner:false,sheetAction:'none'};\r\n    state.capacity = value;\r\n    return { intent: 'owner_command', reply: 'Aforo máximo actualizado a ' + state.capacity + ' personas.', notifyOwner: false, sheetAction: 'none' };\r\n  }\r\n  if (raw === 'HOY' || text === 'reservas') {\r\n    const today = isoCalendarDate(calendarDate(madridDateParts(input.now)));\r\n    const reservas = Object.values(state.reservations).filter(r => text === 'reservas' || r.fecha === today);\r\n    if (!reservas.length) return { intent: 'owner_command', reply: 'No hay reservas pendientes registradas' + (raw === 'HOY' ? ' para hoy.' : '.'), notifyOwner: false, sheetAction: 'none' };\r\n    return {\r\n      intent: 'owner_command',\r\n      reply: reservas.map(r => r.id + ' - ' + r.estado + ' - ' + r.fecha + ' ' + r.hora + ' - ' + r.comensales + ' pax - ' + r.nombre + ' - +' + r.telefono).join('\\n'),\r\n      notifyOwner: false,\r\n      sheetAction: 'none',\r\n    };\r\n  }\r\n  const acceptLatest = text.match(/^aceptar\\s+reserva\\b/);\r\n  if (acceptLatest) {\r\n    const latest = Object.values(state.reservations).filter(r => r.estado === 'Pendiente').pop();\r\n    if (!latest) {\r\n      return { intent: 'owner_command', reply: 'No encuentro ninguna reserva pendiente. Usa RESERVAS para verlas.', notifyOwner: false, sheetAction: 'none' };\r\n    }\r\n    return handleOwnerCommand({ ...input, text: 'confirmar ' + latest.id }, state);\r\n  }\r\n  const acceptById = text.match(/^aceptar\\s+(\\S+)/);\r\n  if (acceptById) {\r\n    return handleOwnerCommand({ ...input, text: 'confirmar ' + acceptById[1] }, state);\r\n  }\r\n  const confirm = text.match(/^(confirmar|cancelar)\\s+(\\S+)/);\r\n  if (confirm) {\r\n    const action = confirm[1];\r\n    const id = confirm[2].toUpperCase();\r\n    const reservation = state.reservations[id];\r\n    if (!reservation) {\r\n      return { intent: 'owner_command', reply: 'No encuentro la reserva ' + id + '. Usa RESERVAS para ver las pendientes.', notifyOwner: false, sheetAction: 'none' };\r\n    }\r\n    const target=action==='confirmar'?'Confirmada':'Cancelada';\r\n    if(reservation.estado===target)return {intent:'owner_command',reply:'La reserva '+id+' ya está '+target.toLowerCase()+'. No se ha enviado otro aviso.',notifyOwner:false,sheetAction:'none'};\r\n    if(action==='confirmar'&&reservation.estado==='Cancelada')return {intent:'owner_command',reply:'La reserva '+id+' está cancelada. Hace falta una solicitud nueva para volver a reservar.',notifyOwner:false,sheetAction:'none'};\r\n    reservation.estado = target;\r\n    if(state.conversations[reservation.telefono]?.reservationId===id)delete state.conversations[reservation.telefono];\r\n    const reply = action === 'confirmar'\r\n      ? 'Reserva confirmada en AutomatizaGr Bar para ' + reservation.comensales + ' personas el ' + formatReservationDate(reservation.fecha) + ' a las ' + reservation.hora + '. A nombre de ' + reservation.nombre + '.'\r\n      : 'Tu reserva en AutomatizaGr Bar para el ' + formatReservationDate(reservation.fecha) + ' a las ' + reservation.hora + ' ha sido cancelada.';\r\n    return {\r\n      intent: 'owner_command',\r\n      reply,\r\n      replyTo: 'whatsapp:+' + reservation.telefono,\r\n      notifyOwner: true,\r\n      ownerMessage: reservation.estado + ' ' + id + ' y avisado el cliente.',\r\n      sheetAction: action === 'confirmar' ? 'confirm' : 'cancel',\r\n      reservationId: id,\r\n      reservation,\r\n    };\r\n  }\r\n  return { intent: 'owner_command', reply: 'Comando no reconocido. Usa LLENO, LIBRE, AFORO 40, HOY, RESERVAS, CONFIRMAR ID o CANCELAR ID.', notifyOwner: false, sheetAction: 'none' };\r\n}\r\n\r\nfunction handleReservation(input, context, state) {\r\n  const from=input.from, active=state.conversations[from]||{};\r\n  const at=new Date(input.now || Date.now()).toISOString();\r\n  if(active.mode==='await_owner_confirmation'){\r\n    const saved=state.reservations[active.reservationId];\r\n    if(isReservationConfirmText(input.text))return {intent:'reservation',reply:saved?'La reserva '+saved.id+' está '+saved.estado.toLowerCase()+'. No he creado otra.':'La solicitud ya se ha enviado al bar.',notifyOwner:false,sheetAction:'none'};\r\n    delete state.conversations[from];\r\n    return {intent:'reservation',reply:'Esa solicitud ya está enviada. Paso el cambio al equipo para que lo revise; no crearé otra reserva.',notifyOwner:true,ownerMessage:'Cliente +'+from+' solicita revisar '+(saved?.id||'su reserva')+': '+input.text,sheetAction:'none'};\r\n  }\r\n  const existing=active.reservation||{};\r\n  const fieldText=stripAccents(input.text).trim().replace(/[¿?.!]/g,'');\r\n  const fieldMatch=fieldText.match(/^(?:(?:quiero\\s+)?(?:cambiar|corregir|modificar)\\s+)?(?:el|la|las|los)?\\s*(hora|dia|fecha|personas|comensales|nombre)$/);\r\n  if(active.mode && fieldMatch){\r\n    const field={dia:'fecha',personas:'comensales'}[fieldMatch[1]]||fieldMatch[1];\r\n    state.conversations[from]={mode:'correct_reservation',reservation:existing,askedField:field,updatedAt:at};\r\n    return {intent:'reservation',reply:askNextReservationQuestion([field],existing),notifyOwner:false,sheetAction:'none',reservation:existing};\r\n  }\r\n  if(isReservationCorrectionText(input.text)){\r\n    state.conversations[from]={mode:'correct_reservation',reservation:existing,updatedAt:at};\r\n    return {intent:'reservation',reply:'Claro. Mantengo los datos de tu reserva. ¿Qué cambiamos: el día, la hora, las personas o el nombre? Puedes decirme «el mismo día, pero para las 7».',notifyOwner:false,sheetAction:'none',reservation:existing};\r\n  }\r\n  if(active.mode==='confirm_reservation' && isReservationConfirmText(input.text))return savePendingReservation(input,context,state,existing);\r\n  const details=applyActiveReservationHints(input.text,parseReservationDetails(input.text,input.now,existing.franja),existing,active.askedField);\r\n  const reservation={...existing,...details};\r\n  if(details.fechaError)delete reservation.fecha;\r\n  if(details.comensalesError)delete reservation.comensales;\r\n  if(details.fecha)delete reservation.fechaError;\r\n  if(details.horaError)delete reservation.hora;\r\n  if(details.hora)delete reservation.horaError;\r\n  if(!reservation.nombre && input.clientName && !/^test/i.test(input.clientName))reservation.nombre=input.clientName;\r\n  if(details.comensales)delete reservation.comensalesError;\r\n  validateReservationDraft(reservation,input.now);\r\n  const minimum=Number(String(context.quickFacts?.minReserva||'6').match(/\\d+/)?.[0]||6);\r\n  if(reservation.comensales && reservation.comensales<minimum){\r\n    state.conversations[from]={mode:'reservation',reservation,askedField:'comensales',updatedAt:at};\r\n    return {intent:'reservation',reply:'Para reservar necesitamos un mínimo de '+minimum+' personas. Mantengo los demás datos por si cambia el grupo. Si sois menos, podéis venir sin reserva, según disponibilidad.',notifyOwner:false,sheetAction:'none',reservation};\r\n  }\r\n  const missing=missingReservationFields(reservation);\r\n  if(missing.length){\r\n    state.conversations[from]={mode:'reservation',reservation,askedField:missing[0],updatedAt:at};\r\n    return {intent:'reservation',reply:askNextReservationQuestion(missing,reservation),notifyOwner:false,sheetAction:'none',reservation};\r\n  }\r\n  state.conversations[from]={mode:'confirm_reservation',reservation,updatedAt:at};\r\n  const updated=active.mode==='correct_reservation'||active.mode==='confirm_reservation';\r\n  return {intent:'reservation',reply:(updated?'He actualizado esos datos.\\n\\n':'')+confirmationPrompt(reservation),notifyOwner:false,sheetAction:'none',reservation};\r\n}\r\n\r\nfunction decide(input, context, state) {\r\n  context.now = input.now || new Date();\r\n  const from = cleanPhone(input.from);\r\n  const isOwner = from === cleanPhone(input.ownerPhone || '34900000000');\r\n  const text = input.text || '';\r\n  let active = state.conversations[from];\r\n  const age=active?.updatedAt ? new Date(input.now||Date.now())-new Date(active.updatedAt) : 0;\r\n  if((active?.mode&&(!active.updatedAt||!Number.isFinite(age)||age<0||age>30*60*1000)) || /\\b(?:otra reserva|nueva reserva|otra mesa|nueva mesa|empezar de nuevo|desde cero)\\b/.test(stripAccents(text))){delete state.conversations[from];active=undefined;}\r\n  if(active?.mode==='await_owner_confirmation'&&!isReservationConfirmText(text)&&!isReservationCorrectionText(text)&&!isReservationAbortText(text)&&!/\\b(reserv\\w*|mesa|cambiar|corregir|modificar)\\b/.test(stripAccents(text))){delete state.conversations[from];active=undefined;}\r\n  if(/^(?:muchas\\s+)?gracias[!.\\s]*$/.test(stripAccents(text).trim())&&!active?.mode)return {intent:'faq',reply:'¡A ti! Si necesitas algo más, aquí estoy.',notifyOwner:false,sheetAction:'none',from,isOwner};\r\n  let detectedIntent = detectIntent(text, isOwner);\r\n  if(state.menuMemory?.[from] && /^(?:y\\s+)?(?:el precio|cuanto (?:cuesta|cuestan|vale|valen)|sus alergenos|que alergenos (?:tiene|tienen)|(?:lleva|llevan|tiene|tienen) (?:gluten|lactosa|huevo))\\s*[?¿!.]*$/.test(stripAccents(text).replace(/^[¿?]+/,'').trim()))detectedIntent='menu';\r\n  if(detectedIntent!=='menu' && state.menuMemory)delete state.menuMemory[from];\r\n  if(active?.mode && (isReservationConfirmText(text)||isReservationCorrectionText(text)||parseReservationTime(text,active.reservation?.franja)))detectedIntent='reservation';\r\n  if(active?.mode && !/\\b(horario|cocina|abrir|abris|abren|cerrar|carta|precio|partido|partidos)\\b/.test(stripAccents(text)) && (/^(?:(?:quiero\\s+)?(?:cambiar|corregir|modificar)\\s+)?(?:el|la|las|los)?\\s*(hora|dia|fecha|personas|comensales|nombre)[.!?]*$/.test(stripAccents(text).trim()) || parseReservationDetails(text,input.now).franja))detectedIntent='reservation';\r\n  if(active?.askedField==='nombre' && detectedIntent!=='handoff'){\r\n    const candidate=applyActiveReservationHints(text,parseReservationDetails(text,input.now),active.reservation||{},'nombre').nombre;\r\n    if(candidate && (parseReservationDetails(text,input.now).nombre || candidate.trim().split(/\\s+/).length>=2))detectedIntent='reservation';\r\n  }\r\n  const explicitOwnerCommand = isOwner && isExplicitOwnerCommand(text);\r\n  if(!explicitOwnerCommand&&active?.mode==='await_owner_confirmation'&&isReservationAbortText(text))return {...handleReservation({...input,from,isOwner},context,state),from,isOwner};\r\n  if (!explicitOwnerCommand && ((active?.mode && isReservationAbortText(text)) || (!active?.mode && isReservationDeclineText(text)))) {\r\n    delete state.conversations[from];\r\n    return {\r\n      intent: 'reservation',\r\n      reply: active?.mode\r\n        ? 'Sin problema, cancelo la reserva que estábamos preparando. Si necesitas otra cosa, dime.'\r\n        : 'Sin problema. No preparo ninguna reserva. Si necesitas horarios, carta o partidos, dime.',\r\n      notifyOwner: false,\r\n      sheetAction: 'none',\r\n      from,\r\n      isOwner,\r\n    };\r\n  }\r\n  let intent = active?.mode && !explicitOwnerCommand ? 'reservation' : detectedIntent;\r\n  if (active?.mode && !explicitOwnerCommand && ['menu', 'rules', 'events', 'handoff'].includes(detectedIntent)) {\r\n    delete state.conversations[from];\r\n    intent = detectedIntent;\r\n  }\r\n\r\n  if (explicitOwnerCommand || (isOwner && intent === 'owner_command')) {\r\n    return { ...handleOwnerCommand({ ...input, from }, state), from, isOwner };\r\n  }\r\n  if (intent === 'reservation') {\r\n    if(state.menuMemory)delete state.menuMemory[from];\r\n    return { ...handleReservation({ ...input, from, isOwner }, context, state), from, isOwner };\r\n  }\r\n  if (intent === 'rules') return { intent, reply: replyRules(context, text), notifyOwner: false, sheetAction: 'none', from, isOwner };\r\n  if (intent === 'menu') {\r\n    const memory=state.menuMemory?.[from];\r\n    const followup=/^(?:y\\s+)?(?:el precio|cuanto (?:cuesta|cuestan|vale|valen)|sus alergenos|que alergenos (?:tiene|tienen)|(?:lleva|llevan|tiene|tienen) (?:gluten|lactosa|huevo))\\s*[?¿!.]*$/.test(stripAccents(text).replace(/^[¿?]+/,'').trim());\r\n    const memoryAge=memory?new Date(input.now||Date.now())-new Date(memory.at):Infinity;\r\n    const remembered=memory&&memoryAge>=0&&memoryAge<5*60000&&followup ? (context.carta||[]).find(item=>getProduct(item)===memory.product):null;\r\n    const menuResult = replyMenu(remembered?{...context,carta:[remembered]}:context, remembered?text+' '+memory.product:text);\r\n    if(typeof menuResult==='object'&&menuResult.notifyOwner)menuResult.ownerMessage='Cliente +'+from+': '+menuResult.ownerMessage;\r\n    const selected=remembered||matchMenuItem(context.carta||[],text);\r\n    if(selected){state.menuMemory??={};state.menuMemory[from]={product:getProduct(selected),at:new Date(input.now||Date.now()).toISOString()};}\r\n    else if(state.menuMemory)delete state.menuMemory[from];\r\n    if (typeof menuResult === 'string') {\r\n      return { intent, reply: menuResult, notifyOwner: false, sheetAction: 'none', from, isOwner, actionButtons: true };\r\n    }\r\n    return { intent, notifyOwner: false, sheetAction: 'none', from, isOwner, actionButtons: true, ...menuResult };\r\n  }\r\n  if (intent === 'events') return { intent, reply: replyEvents(context, text), notifyOwner: false, sheetAction: 'none', from, isOwner };\r\n  if (intent === 'handoff') {\r\n    return {\r\n      intent,\r\n      reply: 'Te paso con el equipo del bar para que lo revise.',\r\n      notifyOwner: true,\r\n      ownerMessage: 'Cliente +' + from + ': ' + text,\r\n      sheetAction: 'none',\r\n      from,\r\n      isOwner,\r\n    };\r\n  }\r\n  return {\r\n    intent: 'faq',\r\n    reply: 'Puedo ayudarte con horarios, carta, alérgenos, partidos y reservas. ¿Qué necesitas?',\r\n    notifyOwner: false,\r\n    sheetAction: 'none',\r\n    from,\r\n    isOwner,\r\n  };\r\n}\r\n\r\n\n// Only synthetic envelope generation and the normalization observed in the local n8n export.\nfunction makeTwilioPayload(input) {\n  const from=String(input.from||'').replace(/\\D/g,'');\n  return {AccountSid:'AC00000000000000000000000000000000',ApiVersion:'2010-04-01',MessageSid:input.messageSid||'SM00000000000000000000000000000000',SmsMessageSid:input.messageSid||'SM00000000000000000000000000000000',SmsStatus:'received',From:'whatsapp:+'+from,To:'whatsapp:+34900000999',WaId:from,ProfileName:String(input.clientName||''),Body:String(input.text||''),NumMedia:'0',NumSegments:'1'};\n}\nfunction normalizeTwilioPayload(payload) {\n  if(!payload || typeof payload!=='object' || Array.isArray(payload))throw new Error('El webhook simulado debe ser un objeto JSON.');\n  // Matches Normalize Twilio WhatsApp Message from the recovered workflow.\n  const from=String(payload.From||payload.from||'').replace(/^whatsapp:/,'').replace(/\\D/g,'');\n  const text=String(payload.Body||payload.body||'').trim();\n  const clientName=String(payload.ProfileName||payload.profileName||'').trim();\n  return {from,text,clientName,ignore:!from||!text};\n}\nfunction buildTwiML(messages) {\n  const xml=value=>String(value||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\"/g,'&quot;').replace(/'/g,'&apos;');\n  return '<?xml version=\"1.0\" encoding=\"UTF-8\"?><Response>'+messages.map(message=>'<Message to=\"'+xml('whatsapp:+'+String(message.to).replace(/\\D/g,''))+'\"><Body>'+xml(message.text)+'</Body></Message>').join('')+'</Response>';\n}\n\nconst OWNER = '34900000000';\r\nfunction demoContext(now = new Date()) {\r\n  const context = buildContextFromCsv({normas:'Horario,Martes a Domingo: 09:00 a 16:00\\nCocina noche,23:30\\nMinimo reserva,6 personas',carta:''});\r\n  context.eventos = [{Fecha:new Intl.DateTimeFormat('en-CA',{timeZone:'Europe/Madrid',year:'numeric',month:'2-digit',day:'2-digit'}).format(new Date(now)),Hora:'21:00',Partido:'Partido de demostración — Madrid / Barcelona','Se televisa?':'Sí (dato ficticio)',Pantallas:'Salón y barra'}];\r\n  return context;\r\n}\r\nfunction simulate(body) {\r\n  if (!body || typeof body!=='object') throw new Error('Se requiere una petición de prueba.');\r\n  if (!body.payload && (typeof body.text!=='string' || !body.text.trim())) throw new Error('Escribe un mensaje de entre 1 y 4000 caracteres.');\r\n  const payload=body.payload||makeTwilioPayload(body);\r\n  const normalized=normalizeTwilioPayload(payload);\r\n  if (normalized.text.length>4000) throw new Error('Escribe un mensaje de entre 1 y 4000 caracteres.');\r\n  if (!normalized.ignore && !/^[0-9]{6,15}$/.test(normalized.from)) throw new Error('El identificador de prueba debe tener entre 6 y 15 dígitos.');\r\n  const state = body.state || createState();\r\n  if (!state.conversations || !state.reservations || !Number.isInteger(state.nextReservationNumber) || state.nextReservationNumber < 1 || !Number.isFinite(state.capacity) || state.capacity < 0 || state.capacity > 10000) throw new Error('Estado de pruebas no válido. Reinicia el laboratorio.');\r\n  const now = body.now ? new Date(body.now) : new Date();\r\n  if (Number.isNaN(now.getTime())) throw new Error('Fecha de prueba no válida.');\r\n  const envelope={engineVersion:'automatizagr-v10.8',mode:'sandbox',cost:{twilioCalls:0,openAiCalls:0},receivedAt:now.toISOString()};\r\n  if(normalized.ignore)return {...envelope,state,messages:[],ignored:true,decision:{intent:'ignored',reply:'',notifyOwner:false,sheetAction:'none'},protocol:{inbound:payload,normalized,twiml:buildTwiML([]),note:'El workflow recuperado descarta mensajes sin remitente o sin Body. No se simula una respuesta que el bot real no genera.'}};\r\n  const context = body.context || demoContext(now);\r\n  const input = {...normalized,ownerPhone:OWNER,clientName:normalized.clientName.slice(0,60),now};\r\n  const decision = decide(input, context, state);\r\n  const messages = [];\r\n  if (decision.reply) messages.push({to:String(decision.replyTo || input.from).replace(/\\D/g,''),text:decision.reply,kind:'reply'});\r\n  if (decision.notifyOwner && decision.ownerMessage && messages[0]?.to!==OWNER) messages.push({to:OWNER,text:decision.ownerMessage,kind:'owner_notification'});\r\n  return {...envelope,decision,state,messages,protocol:{inbound:payload,normalized,twiml:buildTwiML(messages),note:'TwiML de referencia del transporte de texto recuperado. Las tarjetas y botones se previsualizan en el laboratorio; su transporte real debe validarse con la versión de n8n recuperada.'}};\r\n}\r\n\nconst body = $json.body || $json;\nreturn [{json:simulate(body)}];"
      }
    },
    {
      "id": "sandbox-response",
      "name": "Respuesta JSON al laboratorio",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.4,
      "position": [
        520,
        0
      ],
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {
          "responseCode": 200
        }
      }
    }
  ],
  "connections": {
    "Sandbox Webhook": {
      "main": [
        [
          {
            "node": "Motor v10.8 aislado",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Motor v10.8 aislado": {
      "main": [
        [
          {
            "node": "Respuesta JSON al laboratorio",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}