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 →
{
"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.
redis
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 →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
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
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
W5 - OUT WhatsApp Sender (Meta Cloud API). Uses executeWorkflowTrigger, redis. Event-driven trigger; 14 nodes.
W7 - OUT Messenger Sender (Meta Send API). Uses executeWorkflowTrigger, redis. Event-driven trigger; 14 nodes.
Gmail-Calendar. Uses executeWorkflowTrigger, postgres, googleCalendar, httpRequest. Event-driven trigger; 12 nodes.