AutomationFlowsAI & RAG › W4.1 - Router (state + Voice)

W4.1 - Router (state + Voice)

W4.1 - ROUTER (State + Voice). Uses executeWorkflowTrigger, postgres, redis. Event-driven trigger; 30 nodes.

Event trigger★★★★★ complexity30 nodesExecute Workflow TriggerPostgresRedis
AI & RAG Trigger: Event Nodes: 30 Complexity: ★★★★★ Added:

This workflow follows the Execute Workflow Trigger → Postgres recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "name": "W4.1 - ROUTER (State + Voice)",
  "active": false,
  "settings": {
    "executionTimeout": 300,
    "saveExecutionProgress": true,
    "saveManualExecutions": true
  },
  "nodes": [
    {
      "parameters": {},
      "id": "a4b9f8d3-eddb-4b9f-93ba-ea4b00b520d6",
      "name": "IN - From Adapters",
      "type": "n8n-nodes-base.executeWorkflowTrigger",
      "typeVersion": 1,
      "position": [
        -2400,
        0
      ]
    },
    {
      "parameters": {
        "language": "javascript",
        "jsCode": "const e = $json;\nconst required = ['channel','tenantId','restaurantId','userId','conversationKey','message','metadata'];\nconst missing = required.filter(k => e[k] === undefined || e[k] === null);\nif (missing.length) {\n  return [{json: { ...e, _err: { code:'BAD_EVENT', missing } }}];\n}\n// Normalize message fields\ne.message = e.message || {};\ne.message.type = (e.message.type || 'text').toString();\ne.message.text = (e.message.text || '').toString();\ne.message.buttonId = (e.message.buttonId || '').toString();\nreturn [{json: e}];"
      },
      "id": "5594fe8a-92cd-4d2c-909d-c9286e1a26e2",
      "name": "C0 - Validate Event",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -2150,
        0
      ]
    },
    {
      "parameters": {
        "language": "javascript",
        "jsCode": "const crypto = require('crypto');\nconst e = $json;\nconst ctx = e.tenant_context || {};\nconst secret = ($env.TENANT_CONTEXT_SECRET || 'fallback-secret-6789').toString();\nconst seal = crypto.createHmac('sha256', secret).update(JSON.stringify(ctx)).digest('hex');\nif (e.tenant_context_seal && seal !== e.tenant_context_seal) {\n  throw new Error('TENANT_CONTEXT_TAMPERED');\n}\nreturn [{json:e}];"
      },
      "id": "verify-seal-router",
      "name": "B0 - Verify Tenant Context Seal",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -2075,
        150
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO public.restaurant_users (tenant_id, restaurant_id, channel, user_id, role)\nVALUES ($1, $2, $3, $4, 'customer')\nON CONFLICT (restaurant_id, channel, user_id) DO NOTHING;",
        "additionalFields": {
          "queryParams": "={{[$json.tenantId, $json.restaurantId, $json.channel, $json.userId]}}"
        }
      },
      "id": "e6a4b12c-3d4f-4e5a-8b6c-7d8e9f0a1b2c",
      "name": "Ensure Customer Profile",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2,
      "position": [
        -2035,
        0
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT\n  COALESCE((SELECT state_json FROM public.conversation_state WHERE conversation_key=$1), '{}'::jsonb) AS state_json,\n  COALESCE((SELECT cart_json  FROM public.carts WHERE conversation_key=$1),  '{\"items\":[]}'::jsonb) AS cart_json,\n  (SELECT locale FROM public.customer_preferences WHERE tenant_id=$2 AND phone=$3) AS pref_locale,\n  (SELECT row_to_json(sc) FROM public.system_configs sc LIMIT 1) AS system_config,\n  COALESCE((\n    SELECT jsonb_object_agg(x.key||'::'||x.locale, x.content)\n    FROM (\n      SELECT DISTINCT ON (key, locale)\n        key, locale, content\n      FROM public.message_templates\n      WHERE (tenant_id=$2 OR tenant_id='_GLOBAL')\n        AND locale IN ('fr','ar')\n      ORDER BY key, locale, CASE WHEN tenant_id=$2 THEN 0 ELSE 1 END\n    ) x\n  ), '{}'::jsonb) AS templates_json,\n  COALESCE((\n    SELECT jsonb_object_agg(x.key||'::'||x.locale, x.variables)\n    FROM (\n      SELECT DISTINCT ON (key, locale)\n        key, locale, variables\n      FROM public.message_templates\n      WHERE (tenant_id=$2 OR tenant_id='_GLOBAL')\n        AND locale IN ('fr','ar')\n      ORDER BY key, locale, CASE WHEN tenant_id=$2 THEN 0 ELSE 1 END\n    ) x\n  ), '{}'::jsonb) AS template_vars_json;\n",
        "additionalFields": {
          "queryParams": "={{[$json.conversationKey, $json.tenantId, $json.userId]}}"
        }
      },
      "id": "32592903-4875-4883-9115-b570fb587af0",
      "name": "C1 - Load State+Cart (DB)",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2,
      "position": [
        -1920,
        0
      ]
    },
    {
      "parameters": {
        "language": "javascript",
        "jsCode": "const event = $json;\nconst state = ($json.state_json && typeof $json.state_json === 'object') ? $json.state_json : {};\n\n// FORENSIC-FIX (SEC-009): Ghost Session Detection\n// If a state exists but belongs to a different user ID or tenant ID, it's a \"Ghost\" or Hijack attempt.\nif (state.userId && state.userId !== event.userId) {\n  state.stage = 'START'; // Force reset\n  state._ghost_detected = true;\n}\nif (state.tenantId && state.tenantId !== event.tenantId) {\n  state.stage = 'START'; // Force reset\n  state._tenant_collision_detected = true;\n}\nstate.userId = event.userId; // Sync current owner\nstate.tenantId = event.tenantId; // Sync current tenant\n\nconst cart = ($json.cart_json && typeof $json.cart_json === 'object') ? $json.cart_json : {items:[]};\nconst sys = $json.system_config || {};\n\nconst l10nEnabled = (($env.L10N_ENABLED || sys.l10n_enabled || 'false').toString().toLowerCase() === 'true');\nconst stickyArEnabled = (($env.L10N_STICKY_AR_ENABLED || 'false').toString().toLowerCase() === 'true');\nconst stickyArThreshold = parseInt(($env.L10N_STICKY_AR_THRESHOLD || '2').toString(), 10);\n\nconst prefLocaleDb = ($json.pref_locale || '').toString().trim().toLowerCase();\nconst text = (event?.message?.text || '').toString();\n\n// Arabic script detection (Unicode blocks)\nconst hasArabic = /[\\u0600-\\u06FF\\u0750-\\u077F\\u08A0-\\u08FF]/.test(text);\nconst detectedLocale = hasArabic ? 'ar' : 'fr';\n\n// Persisted preference (only set by LANG command); do not overwrite based on message script.\nconst localePref = (state.localePref || state.locale || prefLocaleDb || event.metadata?.locale || 'fr').toString().trim().toLowerCase();\n\n// Sticky Arabic session rule: if user sent Arabic script N times, allow Darija (Latin) to respond in AR\nconst prevArCount = parseInt((state.arabicScriptCount || 0).toString(), 10) || 0;\nconst newArCount = (l10nEnabled && stickyArEnabled && hasArabic) ? (prevArCount + 1) : prevArCount;\nconst stickyAr = !!state.stickyAr || (l10nEnabled && stickyArEnabled && (newArCount >= (isNaN(stickyArThreshold) ? 2 : stickyArThreshold)));\n\nconst merged = {\n  stage: state.stage || 'START',               // START | COLLECTING | CONFIRMING | PLACED\n  serviceMode: state.serviceMode || null,      // sur_place | a_emporter | livraison\n  localePref,\n  locale: localePref,\n  arabicScriptCount: newArCount,\n  stickyAr: stickyAr,\n  lastIntent: state.lastIntent || null,\n  lastReplyAt: state.lastReplyAt || null\n};\n\nconst safeCart = {\n  serviceMode: cart.serviceMode || merged.serviceMode,\n  items: Array.isArray(cart.items) ? cart.items : [],\n  note: (cart.note || '').toString()\n};\n\n// Attach templates loaded from DB\nconst templates = ($json.templates_json && typeof $json.templates_json === 'object') ? $json.templates_json : {};\nconst templateVars = ($json.template_vars_json && typeof $json.template_vars_json === 'object') ? $json.template_vars_json : {};\n\nreturn [{\n  json: {\n    ...event,\n    state: merged,\n    cart: safeCart,\n    l10n: {\n      enabled: l10nEnabled,\n      prefLocale: localePref,\n      detectedLocale,\n      hasArabic,\n      stickyArEnabled,\n      stickyArThreshold: (isNaN(stickyArThreshold) ? 2 : stickyArThreshold),\n    },\n    templates,\n    templateVars,\n    config: sys\n  }\n}];\n"
      },
      "id": "fe44ee07-cb63-4764-bf72-753efa6e4cce",
      "name": "C2 - Merge State Defaults",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -1700,
        0
      ]
    },
    {
      "parameters": {
        "language": "javascript",
        "jsCode": "const e = $json;\nconst cfg = e.config || {};\n\nfunction isIpLiteral(host) {\n  return /^\\d{1,3}(?:\\.\\d{1,3}){3}$/.test(host);\n}\nfunction isPrivateIp(ip) {\n  const parts = ip.split('.').map(x => Number(x));\n  if (parts.length !== 4 || parts.some(n => Number.isNaN(n) || n < 0 || n > 255)) return true;\n  const [a,b] = parts;\n  if (a === 10) return true;\n  if (a === 127) return true;\n  if (a === 0) return true;\n  if (a === 169 && b === 254) return true;\n  if (a === 192 && b === 168) return true;\n  if (a === 172 && b >= 16 && b <= 31) return true;\n  return false;\n}\nfunction normalizeAllowlist(v) {\n  return (v || '').toString()\n    .split(',')\n    .map(s => s.trim().toLowerCase())\n    .filter(Boolean);\n}\nfunction hostAllowed(host, allow) {\n  const h = host.toLowerCase();\n  return allow.some(a => h === a || h.endsWith('.' + a));\n}\nfunction validateAudioUrl(u, config = {}) {\n  try {\n    const url = new URL(u);\n    if (url.protocol !== 'https:') return { ok:false, reason:'https_only' };\n    if (url.username || url.password) return { ok:false, reason:'no_credentials' };\n    const host = url.hostname || '';\n    if (!host) return { ok:false, reason:'missing_host' };\n    if (host === 'localhost' || host.endsWith('.local')) return { ok:false, reason:'localhost_blocked' };\n    if (isIpLiteral(host)) return { ok:false, reason:'ip_literal_blocked', host };\n    if (host.includes(':')) return { ok:false, reason:'ipv6_literal_blocked', host };\n\n    const allow = normalizeAllowlist(config.allowed_audio_domains || $env.ALLOWED_AUDIO_DOMAINS || 'whatsapp.com,fbcdn.net');\n    if (!allow.length) return { ok:false, reason:'allowlist_empty' };\n    if (!hostAllowed(host, allow)) return { ok:false, reason:'domain_not_allowed', host, allow };\n    return { ok:true, host };\n  } catch (err) {\n    return { ok:false, reason:'invalid_url' };\n  }\n}\n\nif (e.message?.type !== 'audio' || !e.message?.audio?.url) {\n  return [{json: {...e, userText: (e.message?.type === 'button') ? e.message.buttonId : e.message.text }}];\n}\n\nconst sttUrl = (cfg.stt_api_url || $env.STT_API_URL || '').toString();\nif (!sttUrl) {\n  return [{json: {...e, userText: '', stt: {ok:false, reason:'STT_API_URL not set'} }}];\n}\n\nconst audioUrl = e.message.audio.url.toString();\nconst v = validateAudioUrl(audioUrl, cfg);\nif (!v.ok) {\n  return [{\n    json: {\n      ...e,\n      userText: '',\n      stt: { ok:false, reason:'AUDIO_URL_BLOCKED', details: v },\n      _sec: { ...(e._sec||{}), audioUrlBlocked:true, audioBlockReason: v.reason, audioHost: v.host || '' }\n    }\n  }];\n}\n\ntry {\n  const sttRes = await $httpRequest({\n    method: 'POST',\n    url: sttUrl,\n    body: { audioUrl, mime: e.message.audio.mime || 'audio/ogg' },\n    json: true,\n    timeout: 60000\n  });\n\n  const transcript = (sttRes.text || sttRes.transcript || '').toString().trim();\n  const confidence = Number(sttRes.confidence ?? 0);\n\n  e.stt = { ok:true, transcript, confidence, provider: sttRes.provider || 'stt' };\n  e.userText = transcript;\n\n  return [{json: e}];\n} catch (err) {\n  return [{json: {...e, userText: '', stt: {ok:false, error: (err && err.message) ? err.message : 'stt_failed'} }}];\n}\n"
      },
      "id": "3245f83b-5ca2-440a-8e4a-f51849433a47",
      "name": "C3 - Voice STT (optional)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -1480,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{$json._sec.audioUrlBlocked}}",
              "operation": "isTrue"
            }
          ]
        }
      },
      "id": "8b05a0d1-3841-4d30-91e8-93fa578e3d23",
      "name": "C3x - AudioUrl Blocked?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        420,
        160
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO security_events(tenant_id, restaurant_id, conversation_key, channel, user_id, event_type, severity, payload_json)\nVALUES ($1,$2,$3,$4,$5,'AUDIO_URL_BLOCKED','HIGH',\njsonb_build_object('reason',$6,'host',$7,'audio_url',$8,'trace_id',$9))\nRETURNING 1;",
        "additionalFields": {
          "queryParams": "={{[$json.tenantId, $json.restaurantId, $json.conversationKey, $json.channel, $json.userId, $json._sec.audioBlockReason, $json._sec.audioHost, $json.message.audio.url, $json.metadata.msgId]}}"
        }
      },
      "id": "ed8e840f-3f4c-4908-9d31-b230ff847d9f",
      "name": "C3y - Log SSRF Block (DB)",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2,
      "position": [
        640,
        80
      ]
    },
    {
      "id": "3c6591c1-0ab7-4c2b-a34c-4a59aec61ebf",
      "name": "C3b - Menu Cache Get",
      "type": "n8n-nodes-base.code",
      "typeVersion": 1,
      "position": [
        -1500,
        -80
      ],
      "parameters": {
        "language": "javascript",
        "jsCode": "const sd = this.getWorkflowStaticData('global');\nconst ttl = Number($env.MENU_CACHE_TTL_SEC || 300);\nconst rid = ($json.restaurantId || '').toString();\nconst key = `menu:${rid}`;\nconst now = Date.now();\nconst entry = sd[key];\nif (entry && entry.ts && (now - entry.ts) < (ttl*1000) && Array.isArray(entry.items) && Array.isArray(entry.options)) {\n  return [{ json: { ...$json, items: entry.items, options: entry.options, _menuCache: 'HIT' } }];\n}\nreturn [{ json: { ...$json, _menuCache: 'MISS' } }];\n"
      }
    },
    {
      "id": "66352fcb-b39c-4002-b3af-f462ebea0d91",
      "name": "C3c - Menu Cache Hit?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        -1280,
        -80
      ],
      "parameters": {
        "conditions": {
          "string": [
            {
              "value1": "={{$json._menuCache}}",
              "operation": "equal",
              "value2": "HIT"
            }
          ]
        }
      }
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT\n      $1::text AS restaurant_id,\n      COALESCE((\n        SELECT jsonb_agg(row_to_json(mi)) FROM (\n          SELECT item_code, label, price_cents, category\n          FROM menu_items\n          WHERE restaurant_id=$1 AND active=true\n          ORDER BY category, item_code\n        ) mi\n      ), '[]'::jsonb) AS items,\n      COALESCE((\n        SELECT jsonb_agg(row_to_json(mo)) FROM (\n          SELECT option_code, item_code, label, kind, price_delta_cents\n          FROM menu_item_options\n          WHERE restaurant_id=$1 AND active=true\n          ORDER BY item_code, option_code\n        ) mo\n      ), '[]'::jsonb) AS options;",
        "additionalFields": {
          "queryParams": "={{[$json.restaurantId]}}"
        }
      },
      "id": "8963c7d6-dd8e-49f3-964e-25e0a0ecb2a6",
      "name": "C4 - Load Menu Index (DB)",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2,
      "position": [
        -1260,
        0
      ]
    },
    {
      "id": "174c9d7e-0915-407e-bc8d-20f7a7861fe6",
      "name": "C4b - Menu Cache Set",
      "type": "n8n-nodes-base.code",
      "typeVersion": 1,
      "position": [
        -1040,
        0
      ],
      "parameters": {
        "language": "javascript",
        "jsCode": "const sd = this.getWorkflowStaticData('global');\nconst rid = ($json.restaurant_id || $json.restaurantId || '').toString();\nif (rid) {\n  sd[`menu:${rid}`] = { ts: Date.now(), items: ($json.items||[]), options: ($json.options||[]) };\n}\nreturn [{ json: { ...$json, _menuCache: 'SET' } }];\n"
      }
    },
    {
      "parameters": {
        "language": "javascript",
        "jsCode": "const e = $json;\nconst items = Array.isArray($json.items) ? $json.items : [];\nconst options = Array.isArray($json.options) ? $json.options : [];\n\nconst itemMap = {};\nfor (const it of items) itemMap[it.item_code] = it;\n\nconst optionMap = {};\nfor (const op of options) optionMap[op.option_code] = op;\n\n// grouped options by item\nconst optionsByItem = {};\nfor (const op of options) {\n  optionsByItem[op.item_code] = optionsByItem[op.item_code] || [];\n  optionsByItem[op.item_code].push(op);\n}\n\nreturn [{json: {...e, menu: {items, options, itemMap, optionMap, optionsByItem}}}];"
      },
      "id": "2ea6726a-ba2a-4a32-a70e-753d04865b63",
      "name": "C5 - Build Menu Maps",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -1040,
        0
      ]
    },
    {
      "parameters": {
        "language": "javascript",
        "jsCode": "const e = $json;\n\n// Raw text inputs\nconst textRaw = (e.userText || '').toString().trim();\nconst buttonId = (e.message?.buttonId || '').toString().trim();\nconst msgText = (e.message?.text || '').toString().trim();\nconst text = (e.message?.type === 'button') ? buttonId : (msgText || textRaw);\nconst isButton = (e.message?.type === 'button');\n\n// ---- L10N: respond AR if message contains Arabic script, else FR.\n// LANG FR / LANG AR still works and persists preference.\nconst l10nEnabled = !!(e.l10n && e.l10n.enabled);\nconst hasArabic = (e.l10n && typeof e.l10n.hasArabic === 'boolean') ? e.l10n.hasArabic : /[\u0600-\u06ff\u0750-\u077f\u08a0-\u08ff]/.test(msgText || text || '');\nlet responseLocale = hasArabic ? 'ar' : 'fr';\n// P2-01: Darija detection (Latin-script Moroccan Arabic)\nconst darijaPatterns = [\n  'chno kayn','chnou kayen','wach kayn','wesh kayn','ash kayn','fin menu',\n  'nchouf','warini','bghit','wakha','wah','iyeh','mzyan','zwina',\n  'kml','kammel','kmel','ncommandi','sift','salam','slm','labas'\n];\nconst isDarija = !hasArabic && darijaPatterns.some(p => lower.includes(p));\nif (isDarija && l10nEnabled) {\n  responseLocale = 'darija';\n}\n\n\n// Buttons carry no script; keep last response locale to avoid flipping language on UI actions\nif (l10nEnabled && isButton) {\n  const prev = ((e.state && (e.state.lastResponseLocale || e.state.localePref || e.state.locale)) || '').toString().trim().toLowerCase();\n  if (prev === 'ar' || prev === 'fr') responseLocale = prev;\n}\n\n// NO REGRESSION: if feature disabled, keep legacy locale selection\nif (!l10nEnabled) {\n  responseLocale = ((e.state && (e.state.localePref || e.state.locale)) || 'fr').toString().toLowerCase();\n}\n\n// Normalize\n// P2-01: Support fr, ar, darija locales\nresponseLocale = ['ar','darija'].includes(responseLocale) ? responseLocale : 'fr';\n\nconst langMatch = /^\\s*lang\\s+(fr|ar)\\s*$/i.exec((msgText || textRaw || '').toString());\nif (l10nEnabled && langMatch) {\n  responseLocale = langMatch[1].toLowerCase() === 'dz' ? 'darija' : langMatch[1].toLowerCase();\n  e.state = e.state || {};\n  e.state.localePref = responseLocale;\n  e.state.locale = responseLocale; // keep legacy field aligned\n  // Sticky AR session control (only if enabled)\n  if (e.l10n && e.l10n.stickyArEnabled && responseLocale === 'fr') {\n    e.state.arabicScriptCount = 0;\n    e.state.stickyAr = false;\n  } else if (e.l10n && e.l10n.stickyArEnabled && responseLocale === 'ar') {\n    const thr = (e.l10n && e.l10n.stickyArThreshold) ? e.l10n.stickyArThreshold : 2;\n    e.state.arabicScriptCount = Math.max(parseInt(e.state.arabicScriptCount || 0, 10) || 0, thr);\n    e.state.stickyAr = true;\n  }\n  e.l10nPersistLocale = true;\n}\n\n// Templates (loaded from DB) + safe renderer\nconst templates = (e.templates && typeof e.templates === 'object') ? e.templates : {};\nconst templateVars = (e.templateVars && typeof e.templateVars === 'object') ? e.templateVars : {};\n\nfunction renderTemplate(content, vars, allowed) {\n  const v = vars && typeof vars === 'object' ? vars : {};\n  const allow = Array.isArray(allowed) ? new Set(allowed.map(String)) : null;\n  return (content || '').replace(/\\{\\{\\s*([a-zA-Z0-9_]+)\\s*\\}\\}/g, (_, k) => {\n    const key = String(k);\n    if (allow && !allow.has(key)) return '';\n    const val = v[key];\n    return (val === undefined || val === null) ? '' : String(val);\n  }).replace(/\\{\\{[^}]+\\}\\}/g,'').trim();\n}\n\nfunction T(key, vars = {}, locale = responseLocale, fallback = '') {\n  const k = `${key}::${locale}`;\n  const kfr = `${key}::fr`;\n  const content = templates[k] || templates[kfr] || fallback || '';\n  const allowed = templateVars[k] || templateVars[kfr] || null;\n  return renderTemplate(content, vars, allowed);\n}\n\nfunction BTN(id, frTitle, arTitle, darijaTitle) {\n  // P2-01: Support darija in buttons\n  const title = responseLocale === 'ar' ? arTitle : (responseLocale === 'darija' ? (darijaTitle || frTitle) : frTitle);\n  return { id, title };\n}\n\n\nconst menuItems = e.menu?.items || [];\nconst itemMap = e.menu?.itemMap || {};\nconst optionMap = e.menu?.optionMap || {};\nconst optionsByItem = e.menu?.optionsByItem || {};\n\nconst riskFlags = [];\nconst lower = (text || '').toLowerCase();\n/**\n * Darija (translit Latin) shortcuts.\n * Note: default replies follow script rule (AR when Arabic script is used).\n * Extra: if sticky AR is active (after N Arabic-script messages), Darija (Latin) can also reply in AR.\n */\nconst darijaMenu = [\n  'chno kayn','chnou kayen','chno kayen','wach kayn','wesh kayn','wesh kayen','wach kayen',\n  'menu','menou','minou','mnu','lmenu','lmnu','carte','la carte','carte svp',\n  'nchouf menu','nchouf lmenu','nchoof menu','nchoof lmenu','bghit nchoof menu','bghit nchouf menu',\n  'show menu','show lmenu','affiche menu','voir menu','menu daba','menu m3ak','menu m3aya'\n];\nconst darijaCheckout = [\n  'kml','kammel','kmel','kmel commande','kml commande','kml daba','kmel daba',\n  'checkout','check out','tchekout','confirm','confirmer','valider','validation',\n  'bghit ncommandi','bghit ncommandi daba','bghit ncmdi','ncommandi','ncmdi','ndir commande','bghit ndir commande',\n  'order','commande'\n];\n\n// Arabic keywords (script) to map to intents\nconst arabicMenuTokens = ['\u0642\u0627\u0626\u0645\u0629','\u0627\u0644\u0642\u0627\u0626\u0645\u0629','\u0645\u0646\u064a\u0648','\u0627\u0644\u0645\u0646\u064a\u0648','menu','\u0645\u064a\u0646\u0648'];\nconst arabicCheckoutTokens = ['\u0643\u0645\u0644','\u0643\u0645\u0651\u0644','\u0627\u0643\u0645\u0644','\u0623\u0643\u0645\u0644','\u062a\u0623\u0643\u064a\u062f','\u062a\u0627\u0643\u064a\u062f','\u0623\u0643\u062f','\u0627\u0643\u0651\u062f','confirm','\u0627\u0637\u0644\u0628','\u0637\u0644\u0628'];\n\nlet normalizedLower = lower;\nlet darijaMatched = false;\n\nif (l10nEnabled && hasArabic) {\n  const t = (msgText || text || '').toString();\n  if (arabicMenuTokens.some(tok => t.includes(tok))) { normalizedLower = 'menu'; darijaMatched = true; }\n  if (arabicCheckoutTokens.some(tok => t.includes(tok))) { normalizedLower = 'checkout'; darijaMatched = true; }\n}\n\nif (darijaMenu.some(p => normalizedLower.includes(p))) { normalizedLower = 'menu'; darijaMatched = true; }\nif (darijaCheckout.some(p => normalizedLower.includes(p))) { normalizedLower = 'checkout'; darijaMatched = true; }\n\n// Sticky Arabic session rule: once user has sent Arabic script enough times (see C2), allow Darija (Latin) messages to be answered in AR for the rest of the session.\n// This is behind L10N_STICKY_AR_ENABLED.\nif (l10nEnabled && (e.l10n && e.l10n.stickyArEnabled) && !hasArabic && darijaMatched && (e.state && e.state.stickyAr)) {\n  responseLocale = 'ar';\n}\n\n\n\n// Prompt injection / jailbreak patterns\nconst inj = /(ignore (all|the) (previous|instructions)|system prompt|reveal|secret|token|api key|dump|sql|drop table|delete from|--|;)/i;\nif (inj.test(text || '')) riskFlags.push('PROMPT_INJECTION_SUSPECT');\n\n// If no reliable text (STT failed)\nif (!text || text.length < 2) {\n  const fallbackFR = \"Je n\u2019ai pas bien compris. Tu peux r\u00e9essayer en \u00e9crivant ou refaire un vocal plus clair ?\";\n  const fallbackAR = \"\u0644\u0645 \u0623\u0641\u0647\u0645 \u062c\u064a\u062f\u0627\u064b. \u062d\u0627\u0648\u0644 \u0643\u062a\u0627\u0628\u0629 \u0631\u0633\u0627\u0644\u062a\u0643 \u0645\u0631\u0629 \u0623\u062e\u0631\u0649 \u0623\u0648 \u0623\u0631\u0633\u0644 \u0631\u0633\u0627\u0644\u0629 \u0635\u0648\u062a\u064a\u0629 \u0623\u0648\u0636\u062d.\";\n  const msg = (responseLocale === 'ar')\n    ? T('CORE_CLARIFY', {}, 'ar', fallbackAR)\n    : T('CORE_CLARIFY', {}, 'fr', fallbackFR);\n\n  return [{json: {\n    ...e,\n    intent: 'CLARIFY',\n    response: {\n      replyText: msg,\n      buttons: [\n        BTN('HELP_MENU','\ud83d\udccb Voir le menu','\ud83d\udccb \u0639\u0631\u0636 \u0627\u0644\u0642\u0627\u0626\u0645\u0629'),\n        BTN('VOICE_RETRY','\ud83c\udfa4 Refaire un vocal','\ud83c\udfa4 \u0623\u0639\u062f \u0625\u0631\u0633\u0627\u0644 \u0631\u0633\u0627\u0644\u0629 \u0635\u0648\u062a\u064a\u0629')\n      ]\n    },\n    debug: {riskFlags, responseLocale}\n  }}];\n}\n\n// EPIC6 flags\nconst cfg = e.config || {};\nconst supportEnabled = (($env.SUPPORT_ENABLED || cfg.support_enabled || 'false').toString().toLowerCase() === 'true');\nconst faqEnabled = (($env.FAQ_ENABLED || cfg.faq_enabled || 'false').toString().toLowerCase() === 'true');\n\n\n\n// Support handoff (explicit)\nif (supportEnabled) {\n  const helpTokens = new Set(['help','aide','agent','support']);\n  const t = (lower || '').trim();\n  if (helpTokens.has(t)) {\n    return [{json:{\n      ...e,\n      intent:'HANDOFF_SUPPORT',\n      action:'SUPPORT_HANDOFF',\n      reason_code:'HELP',\n      response:{\n        replyText: T('SUPPORT_HANDOFF_ACK', {}, responseLocale, (responseLocale==='ar' ? '\ud83e\uddd1\u200d\ud83d\udcac \u0634\u0643\u0631\u0627\u064b. \u0633\u064a\u062a\u0648\u0627\u0635\u0644 \u0645\u0639\u0643 \u0623\u062d\u062f \u0627\u0644\u0645\u0648\u0638\u0641\u064a\u0646 \u0642\u0631\u064a\u0628\u0627\u064b.' : '\ud83e\uddd1\u200d\ud83d\udcac Merci. Un agent va vous contacter rapidement.')),\n        buttons:[ BTN('HELP_MENU','\ud83d\udccb Voir le menu','\ud83d\udccb \u0639\u0631\u0636 \u0627\u0644\u0642\u0627\u0626\u0645\u0629') ]\n      },\n      debug:{riskFlags, responseLocale}\n    }}];\n  }\n}\n\n// Owner/Admin shortcuts (RBAC handled by DB table later; here we just route)\nif (lower === 'admin' || lower.includes('kpi') || text === 'ADMIN_HOME') {\n  return [{json:{\n    ...e,\n    intent:'ADMIN_HOME',\n    response:{\n      replyText:'Menu admin :',\n      buttons:[\n        {id:'ADMIN_KPI_TODAY',title:'\ud83d\udcca KPI 24h'},\n        {id:'ADMIN_TOP_ITEMS',title:'\u2b50 Top ventes'},\n        {id:'ADMIN_ALERTS',title:'\ud83d\udea8 Alertes'}\n      ]\n    },\n    debug:{riskFlags}\n  }}];\n}\n\n// LANG SWITCH\nif (l10nEnabled && langMatch) {\n  const loc = responseLocale;\n  // P2-01: LANG DARIJA response\n  if (loc === 'darija') {\n    const reply = T('CORE_LANG_SET_DARIJA', {}, 'darija', '\u2705 Daba ghadi njawbek b Darija. Kteb \"menu\" bach tchouf la carte.');\n    return [{json:{\n      ...e,\n      intent:'LANG_SET',\n      response:{\n        replyText: reply,\n        buttons:[\n          BTN('HELP_MENU','\ud83d\udccb Voir le menu','\ud83d\udccb \u0639\u0631\u0636 \u0627\u0644\u0642\u0627\u0626\u0645\u0629','\ud83d\udccb Chouf menu')\n        ]\n      },\n      debug:{riskFlags, responseLocale},\n      l10nPersistLocale: true\n    }}];\n  }\n  const reply = (loc === 'ar')\n    ? T('CORE_LANG_SET_AR', {}, 'ar', '\u2705 \u062a\u0645 \u062a\u063a\u064a\u064a\u0631 \u0627\u0644\u0644\u063a\u0629 \u0625\u0644\u0649 \u0627\u0644\u0639\u0631\u0628\u064a\u0629. \u0627\u0643\u062a\u0628 \"menu\" \u0644\u0639\u0631\u0636 \u0627\u0644\u0642\u0627\u0626\u0645\u0629.')\n    : T('CORE_LANG_SET_FR', {}, 'fr', '\u2705 Langue d\u00e9finie sur Fran\u00e7ais. Tape \"menu\" pour voir la carte.');\n\n  return [{json:{\n    ...e,\n    intent:'LANG_SET',\n    response:{\n      replyText: reply,\n      buttons:[\n        BTN('HELP_MENU','\ud83d\udccb Voir le menu','\ud83d\udccb \u0639\u0631\u0636 \u0627\u0644\u0642\u0627\u0626\u0645\u0629')\n      ]\n    },\n    debug:{riskFlags, responseLocale},\n    l10nPersistLocale: true\n  }}];\n}\n\n// HELP / MENU\nif (normalizedLower === 'menu' || text === 'HELP_MENU' || lower === 'menu') {\n  const cats = {};\n  for (const it of menuItems) {\n    const cat = it.category || 'Autres';\n    cats[cat] = cats[cat] || [];\n    cats[cat].push(it);\n  }\n  let msg = T('CORE_MENU_HEADER', {}, responseLocale, (responseLocale==='ar' ? '\ud83d\udccb \u0627\u0644\u0642\u0627\u0626\u0645\u0629 (\u0627\u0633\u062a\u062e\u062f\u0645 \u0627\u0644\u0645\u0639\u0631\u0641\u0627\u062a \u0641\u064a \u0631\u0633\u0627\u0644\u062a\u0643)\\n' : '\ud83d\udccb Menu (IDs utilisables dans ton message)\\n'));\n  for (const [cat, arr] of Object.entries(cats)) {\n    msg += `\\n*${cat}*\\n`;\n    for (const it of arr.slice(0, 12)) {\n      msg += `- ${it.item_code} : ${it.label} (${(it.price_cents/100).toFixed(2)}\u20ac)\\n`;\n    }\n  }\n  // P1-UX: Attach Menu Image\n  const menuImg = (cfg.menu_image_url || $env.MENU_IMAGE_URL || '').toString().trim();\n  const attachments = [];\n  if (menuImg) {\n    attachments.push({ type: 'image', url: menuImg, mime: 'image/jpeg' });\n  }\n  return [{json:{...e, intent:'SHOW_MENU', response:{replyText:msg, attachments, buttons:[{id:'MODE_SUR_PLACE',title:'\ud83c\udf7d\ufe0f Sur place'},{id:'MODE_A_EMPORTER',title:'\ud83d\udecd\ufe0f \u00c0 emporter'},{id:'MODE_LIVRAISON',title:'\ud83d\udef5 Livraison'}]}, debug:{riskFlags}}}];\n}\n\n// Service mode selection (buttons or text)\nconst modeFromText = (() => {\n  if (lower.includes('sur place')) return 'sur_place';\n  if (lower.includes('emporter') || lower.includes('\u00e0 emporter') || lower.includes('a emporter')) return 'a_emporter';\n  if (lower.includes('livraison') || lower.includes('livrer')) return 'livraison';\n  return null;\n})();\n\nif (text === 'MODE_SUR_PLACE') e.state.serviceMode = 'sur_place';\nif (text === 'MODE_A_EMPORTER') e.state.serviceMode = 'a_emporter';\nif (text === 'MODE_LIVRAISON') e.state.serviceMode = 'livraison';\nif (modeFromText) e.state.serviceMode = modeFromText;\n\nif (!e.state.serviceMode) {\n  return [{json:{\n    ...e,\n    intent:'ASK_MODE',\n    response:{\n      replyText:\"Tu veux *sur place*, *\u00e0 emporter* ou *livraison* ?\",\n      buttons:[\n        {id:'MODE_SUR_PLACE',title:'\ud83c\udf7d\ufe0f Sur place'},\n        {id:'MODE_A_EMPORTER',title:'\ud83d\udecd\ufe0f \u00c0 emporter'},\n        {id:'MODE_LIVRAISON',title:'\ud83d\udef5 Livraison'}\n      ]\n    },\n    debug:{riskFlags}\n  }}];\n}\n\n// Parse item codes and qty: example \"P01 x2 +S01\"\nconst upper = text.toUpperCase();\nconst codeMatches = upper.match(/[A-Z]{1,3}\\d{2,4}/g) || [];\nconst uniqueCodes = [...new Set(codeMatches)];\n\n// Checkout intents\nconst isCheckout = (lower.includes('valider') || lower.includes('checkout') || lower.includes('commander') || text === 'CHECKOUT');\nconst isConfirmYes = (text === 'CONFIRM_YES' || lower === 'oui');\nconst isConfirmNo  = (text === 'CONFIRM_NO'  || lower === 'non');\n\n// Confirm stage handling\nif (e.state.stage === 'CONFIRMING') {\n  if (isConfirmYes) {\n    // Delivery: collect structured address before checkout\n    const deliveryEnabled = (($env.DELIVERY_ENABLED || cfg.delivery_enabled || 'false').toString() === 'true');\n    if (e.state.serviceMode === 'livraison') {\n      if (!deliveryEnabled) {\n        e.state.serviceMode = 'a_emporter';\n        e.state.stage = 'COLLECTING';\n        return [{json:{...e, intent:'DELIVERY_DISABLED', response:{replyText:\"\ud83d\udeab Livraison indisponible pour le moment. Je passe en \u00c0 emporter.\", buttons:[{id:'HELP_MENU',title:'\ud83d\udccb Menu'},{id:'CHECKOUT',title:'\u2705 Valider'}]}, debug:{riskFlags}}}];\n      }\n      const d = e.state.delivery || {};\n      const missing = [];\n      if (!d.wilaya) missing.push('wilaya');\n      if (!d.commune) missing.push('commune');\n      if (!d.details) missing.push('adresse');\n      if (!d.phone) missing.push('tel');\n      // persist last known total for quote\n      e.state.lastTotalCents = Number((e.debug && e.debug.totalCents) || e.state.lastTotalCents || 0);\n      if (missing.length) {\n        e.state.stage = 'DELIVERY_ADDRESS';\n        const t = \"\ud83d\udccd Pour la livraison, j\u2019ai besoin de : Wilaya, Commune, Adresse (rep\u00e8re), T\u00e9l\u00e9phone.\\n\\nFormat :\\nWilaya: <...>\nCommune: <...>\nAdresse: <...>\nTel: <...>\";\n        return [{json:{...e, intent:'DELIVERY_ADDRESS', action:'ADDRESS_AMBIGUOUS', missing_fields: missing, response:{replyText:t}, debug:{riskFlags}}}];\n      }\n      // Address already present\n      if (d.quote_shown) {\n        return [{json:{...e, intent:'CHECKOUT', action:'CHECKOUT', debug:{riskFlags}}}];\n      }\n      // compute quote/slots before final checkout\n      return [{json:{...e, intent:'DELIVERY_QUOTE', action:'DELIVERY_QUOTE', debug:{riskFlags}}}];\n    }\n    return [{json:{...e, intent:'CHECKOUT', action:'CHECKOUT', debug:{riskFlags}}}];\n  }\n  if (isConfirmNo) {\n    e.state.stage = 'COLLECTING';\n    return [{json:{...e, intent:'BACK_TO_CART', response:{replyText:\"Ok, on modifie le panier. Envoie les IDs (ex: P01 x2) ou tape MENU.\", buttons:[{id:'HELP_MENU',title:'\ud83d\udccb Menu'},{id:'CHECKOUT',title:'\u2705 Valider'}]}, debug:{riskFlags}}}];\n  }\n}\n\n\n// Delivery address clarification stage\nif (e.state.stage === 'DELIVERY_ADDRESS') {\n  const d = (e.state.delivery || {});\n  const lines = text.split(/\\n|,|;/).map(s => s.trim()).filter(Boolean);\n  for (const line of lines) {\n    const l = line.toLowerCase();\n    const val = line.split(':').slice(1).join(':').trim();\n    if (l.startsWith('wilaya') && val) d.wilaya = val;\n    else if (l.startsWith('commune') && val) d.commune = val;\n    else if ((l.startsWith('adresse') || l.startsWith('address') || l.startsWith('rue')) && val) d.details = val;\n    else if ((l.startsWith('tel') || l.startsWith('telephone') || l.startsWith('phone')) && val) d.phone = val;\n  }\n  // Fallback: if user sent 4 parts without labels\n  if (lines.length >= 4 && (!d.wilaya || !d.commune || !d.details || !d.phone)) {\n    const parts = lines.map(x => x.replace(/^[-\u2022]/,'').trim());\n    d.wilaya = d.wilaya || parts[0];\n    d.commune = d.commune || parts[1];\n    d.details = d.details || parts[2];\n    d.phone = d.phone || parts[3];\n  }\n  e.state.delivery = d;\n  const missing = [];\n  if (!d.wilaya) missing.push('wilaya');\n  if (!d.commune) missing.push('commune');\n  if (!d.details) missing.push('adresse');\n  if (!d.phone) missing.push('tel');\n  if (missing.length) {\n    const attempts = Number(e.state.delivery_attempts || 0) + 1;\n    e.state.delivery_attempts = attempts;\n    const maxA = Number(($env.DELIVERY_ADDRESS_MAX_ATTEMPTS || cfg.delivery_address_max_attempts || '3'));\n    if (($env.DELIVERY_ADDRESS_CLARIFY || cfg.delivery_address_clarify || 'true').toString() === 'true' && attempts > maxA) {\n      e.state.stage = 'HANDOFF';\n      return [{json:{...e, intent:'DELIVERY_HANDOFF', action:'ADDRESS_AMBIGUOUS', missing_fields: missing, response:{replyText:\"Je n'arrive pas \u00e0 clarifier l\u2019adresse. Je te mets en relation avec un humain.\"}, debug:{riskFlags}}}];\n    }\n    return [{json:{...e, intent:'DELIVERY_ADDRESS', action:'ADDRESS_AMBIGUOUS', missing_fields: missing, response:{replyText:\"Il manque : \"+missing.join(', ')+\". Reprends le format : Wilaya/Commune/Adresse/Tel.\"}, debug:{riskFlags}}}];\n  }\n  // Address OK \u2192 compute quote/slots\n  e.state.stage = 'CONFIRMING';\n  return [{json:{...e, intent:'DELIVERY_QUOTE', action:'DELIVERY_QUOTE', debug:{riskFlags}}}];\n}\n\n// Delivery slot pick stage\nif (e.state.stage === 'DELIVERY_SLOT_PICK') {\n  if (text.startsWith('SLOT_')) {\n    const slotId = text.slice('SLOT_'.length).trim();\n    e.state.delivery = e.state.delivery || {};\n    e.state.delivery.slot_id = slotId;\n    e.state.stage = 'CONFIRMING';\n    return [{json:{...e, intent:'SLOT_OK', response:{replyText:\"\u2705 Cr\u00e9neau enregistr\u00e9. Clique Valider pour confirmer la commande.\", buttons:[{id:'CHECKOUT',title:'\u2705 Valider'},{id:'CONFIRM_NO',title:'\u270f\ufe0f Modifier'}]}, debug:{riskFlags}}}];\n  }\n}\n// If user asks to checkout but cart empty\nif (isCheckout && (!e.cart.items || e.cart.items.length === 0)) {\n  return [{json:{...e, intent:'EMPTY_CART', response:{replyText:\"Ton panier est vide. Tape MENU pour choisir des plats (ex: P01 x2).\", buttons:[{id:'HELP_MENU',title:'\ud83d\udccb Menu'}]}, debug:{riskFlags}}}];\n}\n\n// If item codes present -> add/update cart\nif (uniqueCodes.length) {\n  const newItems = [...(e.cart.items || [])];\n\n  for (const code of uniqueCodes) {\n    if (!itemMap[code]) {\n      return [{json:{...e, intent:'UNKNOWN_ITEM', response:{replyText:`Je ne trouve pas l\u2019ID *${code}*. Tape MENU ou renvoie l\u2019ID exact.`, buttons:[{id:'HELP_MENU',title:'\ud83d\udccb Menu'}]}, debug:{riskFlags}}}];\n    }\n\n    // qty: try CODE xN or CODE N\n    let qty = 1;\n    const r1 = new RegExp(code + '\\\\s*x\\\\s*(\\\\d{1,2})','i');\n    const r2 = new RegExp(code + '\\\\s+(\\\\d{1,2})','i');\n    const m1 = upper.match(r1);\n    const m2 = upper.match(r2);\n    if (m1 && m1[1]) qty = Math.max(1, Math.min(20, parseInt(m1[1],10)));\n    else if (m2 && m2[1]) qty = Math.max(1, Math.min(20, parseInt(m2[1],10)));\n\n    // options: +OPT or -OPT (we accept OPT alone too)\n    const optMatches = upper.match(/[\\+\\-]?[A-Z]{1,3}\\d{2,4}/g) || [];\n    const options = [];\n    for (const raw of optMatches) {\n      const oc = raw.replace('+','').replace('-','');\n      if (optionMap[oc] && optionMap[oc].item_code === code) options.push(oc);\n    }\n\n    const existing = newItems.find(x => x.item === code);\n    if (existing) {\n      existing.qty = Math.max(1, Math.min(20, Number(existing.qty || 1) + qty));\n      const set = new Set([...(existing.options||[]), ...options]);\n      existing.options = [...set];\n    } else {\n      newItems.push({item: code, qty, options});\n    }\n  }\n\n  e.cart.items = newItems;\n  e.state.stage = 'COLLECTING';\n\n  // Build short cart recap\n  let recap = '\ud83e\uddfa Panier :\\n';\n  let total = 0;\n  for (const line of newItems) {\n    const it = itemMap[line.item];\n    const base = Number(it.price_cents||0);\n    let opt = 0;\n    const opts = Array.isArray(line.options)?line.options:[];\n    for (const oc of opts) opt += Number(optionMap[oc]?.price_delta_cents || 0);\n    const lineTotal = (base + opt) * Number(line.qty||1);\n    total += lineTotal;\n    recap += `- ${line.item} ${it.label} x${line.qty} = ${(lineTotal/100).toFixed(2)}\u20ac\\n`;\n  }\n  recap += `\\nTotal estim\u00e9 : ${(total/100).toFixed(2)}\u20ac\\n`;\n  recap += 'Tape *VALIDER* ou clique \u2705';\n\n  return [{json:{...e, intent:'CART_UPDATED', response:{replyText:recap, buttons:[{id:'HELP_MENU',title:'\u2795 Ajouter'},{id:'CHECKOUT',title:'\u2705 Valider'}]}, debug:{riskFlags, totalCents: total}}}];\n}\n\n// If checkout requested -> confirm recap\nif (isCheckout) {\n  e.state.stage = 'CONFIRMING';\n  let recap = `\u2705 R\u00e9cap commande (${e.state.serviceMode.replace('_',' ')}) :\\n`;\n  let total = 0;\n  for (const line of (e.cart.items||[])) {\n    const it = itemMap[line.item];\n    const base = Number(it.price_cents||0);\n    let opt = 0;\n    const opts = Array.isArray(line.options)?line.options:[];\n    for (const oc of opts) opt += Number(optionMap[oc]?.price_delta_cents || 0);\n    const lineTotal = (base + opt) * Number(line.qty||1);\n    total += lineTotal;\n    recap += `- ${line.item} ${it.label} x${line.qty} = ${(lineTotal/100).toFixed(2)}\u20ac\\n`;\n  }\n  recap += `\\nTotal : ${(total/100).toFixed(2)}\u20ac\\n`;\n  recap += (responseLocale === 'ar') ? '\u0647\u0644 \u062a\u0624\u0643\u062f \u0627\u0644\u0637\u0644\u0628\u061f' : 'Confirmer la commande ?';\n  return [{json:{...e, intent:'CONFIRM', response:{replyText:recap, buttons:[{id:'CONFIRM_YES',title:'\u2705 Oui'},{id:'CONFIRM_NO',title:'\u270f\ufe0f Modifier'}]}, debug:{riskFlags, totalCents: total}}}];\n}\n\n\n// FAQ (RAG light): detect question-like messages and route to DB full-text search\nif (faqEnabled) {\n  const looksLikeItem = /\\b[A-Z]{1,3}\\d{2,4}\\b/.test((upper || ''));\n  const faqHints = /(horaire|horaires|ouvert|ouvrir|ferme|fermeture|paiement|payment|carte|cash|esp[e\u00e8]ces|livraison|delivery|adresse|where|when|open|close)/i;\n  const isQuestion = /\\?/.test(text) || faqHints.test(text || '');\n  const isAdminWord = (lower === 'admin');\n  const isMenuWord = (normalizedLower === 'menu' || lower === 'menu');\n  if (isQuestion && !looksLikeItem && !isCheckout && !isAdminWord && !isMenuWord) {\n    return [{json:{\n      ...e,\n      intent:'FAQ_QUERY',\n      action:'FAQ_QUERY',\n      faq_question: (text || '').toString(),\n      debug:{riskFlags, responseLocale}\n    }}];\n  }\n}\n\n// Optional LLM fallback (only if enabled and no risk flags)\nconst llmUrl = (cfg.llm_api_url || $env.LLM_API_URL || '').toString();\nconst llmModel = (cfg.llm_model || $env.LLM_MODEL || 'llama3').toString();\nconst useLLM = (!!llmUrl) && (riskFlags.length === 0);\n\nif (useLLM) {\n  const menuCompact = menuItems.slice(0, 120).map(it => ({id:it.item_code, label:it.label, price_cents:it.price_cents, category:it.category}));\n  const optsCompact = (e.menu?.options || []).slice(0, 200).map(op => ({id:op.option_code, item:op.item_code, label:op.label, kind:op.kind, delta:op.price_delta_cents}));\n\n  const prompt = [\n    (cfg.system_prompt_strategy || 'Tu es un assistant commande restaurant. R\u00e9ponds UNIQUEMENT en JSON valide (pas de texte).'),\n    'Objectif: comprendre la demande du client et proposer une action.',\n    'Sch\u00e9ma: {\"action\":\"add|checkout|menu|clarify\",\"lines\":[{\"item\":\"ID\",\"qty\":1,\"options\":[\"OPT\"]}],\"reply\":\"...\"}',\n    'Si tu ne peux pas mapper pr\u00e9cis\u00e9ment \u00e0 des IDs, action=\"clarify\".',\n    'MenuItems=' + JSON.stringify(menuCompact),\n    'Options=' + JSON.stringify(optsCompact),\n    'UserMessage=' + JSON.stringify(text)\n  ].join('\\n');\n\n  try {\n    const llmRes = await $httpRequest({\n      method:'POST',\n      url: llmUrl,\n      body: { model: llmModel, prompt, stream: false },\n      json: true,\n      timeout: 120000\n    });\n\n    const raw = (llmRes.response || llmRes.text || llmRes.output || '').toString();\n    const m = raw.match(/\\{[\\s\\S]*\\}/);\n    if (m) {\n      const parsed = JSON.parse(m[0]);\n      if (parsed.action === 'menu') {\n        return [{json:{...e, intent:'SHOW_MENU', response:{replyText:'Tape MENU pour afficher le menu.', buttons:[{id:'HELP_MENU',title:'\ud83d\udccb Menu'}]}, debug:{riskFlags, llm:true}}}];\n      }\n      if (parsed.action === 'checkout') {\n        return [{json:{...e, intent:'CONFIRM', response:{replyText:'Clique \u2705 Valider pour confirmer.', buttons:[{id:'CHECKOUT',title:'\u2705 Valider'}]}, debug:{riskFlags, llm:true}}}];\n      }\n      if (parsed.action === 'add' && Array.isArray(parsed.lines) && parsed.lines.length) {\n        // Convert to same pipeline by simulating codes\n        const codes = parsed.lines.map(l => l.item).filter(Boolean);\n        if (!codes.length) throw new Error('LLM lines missing item');\n        // attach helper field for later handling by user: ask to resend codes (safe)\n        return [{json:{...e, intent:'CLARIFY', response:{replyText:'Pour \u00eatre s\u00fbr, peux-tu renvoyer les IDs comme ceci : ' + codes.join(' ') + ' (ex: P01 x2) ?', buttons:[{id:'HELP_MENU',title:'\ud83d\udccb Menu'}]}, debug:{riskFlags, llm:true}}}];\n      }\n    }\n  } catch (err) {\n    // ignore and fallback\n  }\n}\n\n// Default clarification\ne.state = e.state || {}; e.state.lastResponseLocale = responseLocale;\nreturn [{json:{...e, intent:'CLARIFY', response:{replyText:\"Je n\u2019ai pas compris. Envoie l\u2019ID du plat (ex: P01 x2) ou tape MENU.\", buttons:[{id:'HELP_MENU',title:'\ud83d\udccb Menu'}]}, debug:{riskFlags}}}];"
      },
      "id": "1b9e5bc6-421e-40cd-a92c-8f22fe73ba46",
      "name": "C6 - Router (safe, LLM optional)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -820,
        0
      ]
    },
    {
      "parameters": {
        "dataType": "string",
        "value1": "={{$json.intent}}",
        "rules": {
          "rules": [
            {
              "id": "faq_rule",
              "value2": "FAQ_QUERY",
              "output": 0
            },
            {
              "id": "support_rule",
              "value2": "HANDOFF_SUPPORT",
              "output": 0
            },
            {
              "id": "support_del_rule",
              "value2": "DELIVERY_HANDOFF",
              "output": 0
            },
            {
              "id": "cart1",
              "value2": "CART_UPDATED",
              "output": 1
            },
            {
              "id": "cart2",
              "value2": "CONFIRM",
              "output": 1
            },
            {
              "id": "cart3",
              "value2": "CHECKOUT",
              "output": 1
            },
            {
              "id": "cart4",
              "value2": "DELIVERY_ADDRESS",
              "output": 1
            },
            {
              "id": "cart5",
              "value2": "DELIVERY_QUOTE",
              "output": 1
            },
            {
              "id": "cart6",
              "value2": "SLOT_OK",
              "output": 1
            }
          ]
        },
        "fallbackOutput": 2
      },
      "id": "switch-intent",
      "name": "Switch Intent",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 1,
      "position": [
        100,
        0
      ]
    },
    {
      "parameters": {
        "workflowId": "W4.2_CART_MANAGER",
        "workflowInputs": {
          "mappingMode": "defineBelow",
          "value": {},
          "values": [
            {
              "name": "tenantId",
              "value": "={{ $json.tenantId }}"
            },
            {
              "name": "restaurantId",
              "value": "={{ $json.restaurantId }}"
            },
            {
              "name": "channel",
              "value": "={{ $json.channel }}"
            },
            {
              "name": "userId",
              "value": "={{ $json.userId }}"
            }
          ]
        },
        "options": {
          "waitTillFinished": true
        }
      },
      "id": "exec-cart",
      "name": "Call CART_MANAGER",
      "type": "n8n-nodes-base.executeWorkflow",
      "typeVersion": 1,
      "position": [
        400,
        100
      ]
    },
    {
      "parameters": {
        "workflowId": "W4.3_FAQ_AGENT",
        "workflowInputs": {
          "mappingMode": "defineBelow",
          "value": {},
          "values": [
            {
              "name": "tenantId",
              "value": "={{ $json.tenantId }}"
            },
            {
              "name": "restaurantId",
              "value": "={{ $json.restaurantId }}"
            },
            {
              "name": "channel",
              "value": "={{ $json.channel }}"
            },
            {
              "name": "userId",
              "value": "={{ $json.userId }}"
            }
          ]
        },
        "options": {
          "waitTillFinished": true
        }
      },
      "id": "exec-faq",
      "name": "Call FAQ_AGENT",
      "type": "n8n-nodes-base.executeWorkflow",
      "typeVersion": 1,
      "position": [
        400,
        -100
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH up_state AS (\n  INSERT INTO public.conversation_state (conversation_key, tenant_id, restaurant_id, channel, user_id, state_json)\n  VALUES ($1,$2,$3,$4,$5,$6::jsonb)\n  ON CONFLICT (conversation_key) DO UPDATE\n    SET state_json=EXCLUDED.state_json, updated_at=now()\n  RETURNING 1\n),\nup_cart AS (\n  INSERT INTO public.carts (conversation_key, cart_json)\n  VALUES ($1, $7::jsonb)\n  ON CONFLICT (conversation_key) DO UPDATE\n    SET cart_json=EXCLUDED.cart_json, updated_at=now()\n  RETURNING 1\n),\nup_pref AS (\n  INSERT INTO public.customer_preferences(tenant_id, phone, locale)\n  SELECT $2, $5, $9\n  WHERE COALESCE($8::boolean,false) = true\n    AND $9 IS NOT NULL\n    AND length(trim($9)) > 0\n  ON CONFLICT (tenant_id, phone) DO UPDATE\n    SET locale=EXCLUDED.locale, updated_at=now()\n  RETURNING 1\n)\nSELECT 1 AS ok;\n",
        "additionalFields": {
          "queryParams": "={{[$json.conversationKey,$json.tenantId,$json.restaurantId,$json.channel,$json.userId, JSON.stringify($json.state), JSON.stringify($json.cart), !!$json.l10nPersistLocale, ($json.state && $json.state.localePref) ? $json.state.localePref : null]}}"
        }
      },
      "id": "c627f006-396c-4b8c-a695-6bfd1389e349",
      "name": "C7 - Save State+Cart (DB)",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2,
      "position": [
        -600,
        0
      ]
    },
    {
      "parameters": {
        "language": "javascript",
        "jsCode": "const e = $json;\nconst resp = e.response || { replyText: 'OK', buttons: [] };\n\nconst out = {\n  channel: e.channel,\n  tenantId: e.tenantId || '',\n  restaurantId: e.restaurantId,\n  conversationKey: e.conversationKey || '',\n  userId: e.userId,\n  orderId: e.debug?.orderId || null,\n  replyText: resp.replyText || '',\n  buttons: Array.isArray(resp.buttons) ? resp.buttons : [],\n  attachments: Array.isArray(resp.attachments) ? resp.attachments : [],\n  actions: e.actions || { sendToKitchen:false },\n  debug: {\n    traceId: e.metadata?.msgId || '',\n    intent: e.intent || '',\n    riskFlags: e.debug?.riskFlags || []\n  }\n};\nreturn [{json: out}];\n"
      },
      "id": "561fff75-d491-4d33-8295-4d8e5b56425c",
      "name": "C11 - Finalize Response (default)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        300,
        0
      ]
    },
    {
      "parameters": {
        "language": "javascript",
        "jsCode": "const crypto = require('crypto');\nconst out = $json;\n\nconst ch = (out.channel || '').toString();\nconst orderId = out.orderId ? out.orderId.toString() : '';\nconst traceId = (out.debug?.traceId || '').toString();\n\nconst dedupeKey = orderId\n  ? `order:${orderId}:${ch}:reply`\n  : `msg:${ch}:${traceId || crypto.randomUUID()}`;\n\nconst payload = {\n  channel: ch,\n  to: out.userId,\n  restaurantId: out.restaurantId,\n  text: out.replyText,\n  buttons: out.buttons || [],\n  attachments: out.attachments || [],\n  meta: { intent: out.debug?.intent || '' }\n};\n\nreturn [{\n  json: {\n    ...out,\n    outbox_dedupe_key: dedupeKey,\n    outbox_template: 'reply',\n    outbox_payload: payload\n  }\n}];\n"
      },
      "id": "42e4a69d-d415-45d9-81e3-23c68d422d9b",
      "name": "C12 - Enqueue Outbox (P0)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        520,
        0
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO outbound_messages(dedupe_key, tenant_id, restaurant_id, conversation_key, channel, user_id, order_id, template, payload_json, status, next_retry_at, updated_at)\nVALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'PENDING', now(), now())\nON CONFLICT (dedupe_key) DO UPDATE\n  SET payload_json=EXCLUDED.payload_json,\n      status = CASE WHEN outbound_messages.status='SENT' THEN outbound_messages.status ELSE 'PENDING' END,\n      updated_at=now()\nRETURNING outbound_id, status;",
        "additionalFields": {
          "queryParams": "={{[$json.outbox_dedupe_key, $json.tenantId, $json.restaurantId, $json.conversationKey, $json.channel, $json.userId, $json.orderId, $json.outbox_template, $json.outbox_payload]}}"
        }
      },
      "id": "4c47eb41-69d2-48e4-a924-0f95623bbd65",
      "name": "C12b - Outbox Enqueue (DB)",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2,
      "position": [
        1260,
        420
      ]
    },
    {
      "parameters": {
        "language": "javascript",
        "jsCode": "const e = $json;\nreturn [{\n  json: {\n    ...e,\n    _send: {\n      ok: true,\n      queued: true,\n      outboundId: e.outbound_id || e.outboundid || e.outbound_id,\n      status: e.status || 'PENDING'\n    }\n  }\n}];"
      },
      "id": "91548ca5-94ad-4c09-8dc5-a2c6208636cd",
      "name": "C12c - Outbox Result",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1460,
        420
      ]
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{$json.l10nPersistLocale === true}}",
              "operation": "isTrue"
            }
          ]
        }
      },
      "id": "C14_IF_SYNC_DIALECT",
      "name": "C14 - Should Sync Dialect?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1660,
        420
      ]
    },
    {
      "parameters": {
        "workflowId": "W56 - Strapi Dialect Sync",
        "mode": "fireAndForget",
        "workflowInputs": {
          "mappingMode": "defineBelow",
          "value": {},
          "values": [
            {
              "name": "phone",
              "value": "={{$json.userId}}"
            },
            {
              "name": "locale",
              "value": "={{$json.state?.localePref || $json.state?.locale}}"
            }
          ]
        },
        "options": {
          "waitTillFinished": false
        }
      },
      "id": "C15_EXEC_SYNC_DIALECT",
      "name": "C15 - Execute Dialect Sync",
      "type": "n8n-nodes-base.executeWorkflow",
      "typeVersion": 1,
      "position": [
        1880,
        400
      ]
    },
    {
      "parameters": {
        "operation": "releaseLock",
        "resource": "={{$json.conversationKey}}"
      },
      "id": "release-lock",
      "name": "Release Lock",
      "type": "n8n-nodes-base.redis",
      "typeVersion": 1,
      "position": [
        2080,
        420
      ]
    },
    {
      "parameters": {
        "workflowId": "W_TRACKING_FUNNEL",
        "mode": "fireAndForget",
        "workflowInputs": {
          "mappingMode": "defineBelow",
          "value": {},
          "values": [
            {
              "name": "tenantId",
              "value": "={{$json.tenantId}}"
            },
            {
              "name": "restaurantId",
              "value": "={{$json.restaurantId}}"
            },
            {
              "name": "userId",
              "value": "={{$json.userId}}"
            },
            {
              "name": "channel",
              "value": "={{$json.channel}}"
            },
            {
              "name": "eventType",
              "value": "={{($json.intent === 'SHOW_MENU') ? 'menu_viewed' : 'intent_detected'}}"
            },
            {
              "name": "payload",
              "value": "={{JSON.stringify({ intent: $json.intent, text: $json.message.text, referral: $json.inbound_envelope?.meta?.referral })}}"
            }
          ]
        }
      },
      "id": "track-event",
      "name": "Track Event",
      "type": "n8n-nodes-base.executeWorkflow",
      "typeVersion": 1,
      "position": [
        -200,
        -200
      ]
    },
    {
      "parameters": {
        "operation": "set",
        "key": "=lock:session:{{ $json.conversationKey }}",
        "value": "locked",
        "expire": 15
      },
      "id": "acquire-lock",
      "name": "\ud83d\udd11 Acquire Session Lock",
      "type": "n8n-nodes-base.redis",
      "typeVersion": 1,
      "position": [
        -2150,
        200
      ],
      "credentials": {
        "redis": {
          "name": "<your credential>"
        }
      },
      "continueOnFail": true
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ $json.ok ?? true }}",
              "operation": "isTrue"
            }
          ]
        }
      },
      "id": "lock-success",
      "name": "Lock Success?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        -2000,
        200
      ]
    },
    {
      "parameters": {
        "language": "javascript",
        "jsCode": "return [{\n  json: {\n    ...$json,\n    intent: 'SYSTEM_BUSY',\n    response: {\n      replyText: \"\u23f3 Une op\u00e9ration est d\u00e9j\u00e0 en cours. Attends 2 secondes et r\u00e9essaie.\",\n      buttons: [{id:'HELP_MENU',title:'\ud83d\udccb Menu'}]\n    }\n  }\n}];"
      },
      "id": "lock-failed",
      "name": "Lock Failed",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -2000,
        400
      ]
    }
  ],
  "connections": {
    "IN - From Adapters": {
      "main": [
        [
          {
            "node": "C0 - Validate Event",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "C0 - Validate Event": {
      "main": [
        [
          {
            "node": "B0 - Verify Tenant Context Seal",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "B0 - Verify Tenant Context Seal": {
      "main": [
        [
          {
            "node": "\ud83d\udd11 Acquire Session Lock",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udd11 Acquire Session Lock": {
      "main": [
        [
          {
            "node": "Lock Success?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lock Success?": {
      "main": [
        [
          {
            "node": "Ensure Customer Profile",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Lock Failed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lock Failed": {
      "main": [
        [
          {
            "node": "C11 - Finalize Response (default)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ensure Customer Profile": {
      "main": [
        [
          {
            "node": "C1 - Load State+Cart (DB)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "C1 - Load State+Cart (DB)": {
      "main": [
        [
          {
            "node": "C2 - Merge State Defaults",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "C2 - Merge State Defaults": {
      "main": [
        [
          {
            "node": "C3 - Voice STT (optional)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "C3 - Voice STT (optional)": {
      "main": [
        [
          {
            "node": "C3x - AudioUrl Blocked?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "C3x - AudioUrl Blocked?": {
      "main": [
        [
          {
            "node": "C3y - Log SSRF Block (DB)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "C3b - Menu Cache Get",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "C3y - Log SSRF Block (DB)": {
      "main": [
        [
          {
            "node": "C3b - Menu Cache Get",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "C3b - Menu Cache Get": {
      "main": [
        [
          {
            "node": "C3c - Menu Cache Hit?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "C3c - Menu Cache Hit?": {
      "main": [
        [
          {
            "node": "C5 - Build Menu Maps",
            "type": "main",
            "index": 0
          }
        ],
        [
         

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

W4.1 - ROUTER (State + Voice). Uses executeWorkflowTrigger, postgres, redis. Event-driven trigger; 30 nodes.

Source: https://github.com/zerAda/RestaurantAgentAutomation/blob/41a4d42dcd66e57b1e87b4750c0fd5fbf7058f68/workflows/W4.1_ROUTER.json — original creator credit. Request a take-down →

More AI & RAG workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

AI & RAG

Template was created in n8n v1.90.2 Execute Sub-workflow Trigger node Chat Trigger node Redis node Postgres node AI Agent node If node, Switch node, Code node, Edit Fields (Set) Execute Sub-workflow T

Execute Workflow Trigger, Chat Trigger, Agent +7
AI & RAG

Template was created in n8n v1.90.2 Execute Sub-workflow Trigger node Chat Trigger node Redis node Postgres node AI Agent node Calculator node If node, Switch node, Code node, Edit Fields (Set) Execut

Execute Workflow Trigger, Chat Trigger, Agent +5
AI & RAG

W5 - OUT WhatsApp Sender (Meta Cloud API). Uses executeWorkflowTrigger, redis. Event-driven trigger; 14 nodes.

Execute Workflow Trigger, Redis
AI & RAG

W7 - OUT Messenger Sender (Meta Send API). Uses executeWorkflowTrigger, redis. Event-driven trigger; 14 nodes.

Execute Workflow Trigger, Redis
AI & RAG

Gmail-Calendar. Uses executeWorkflowTrigger, postgres, googleCalendar, httpRequest. Event-driven trigger; 12 nodes.

Execute Workflow Trigger, Postgres, Google Calendar +1