{
  "name": "Spun helpdesk bridge: Freshdesk",
  "nodes": [
    {
      "parameters": {
        "content": "## Spun helpdesk bridge: Freshdesk\nWhatsApp conversations in Spun become Freshdesk tickets; public notes that agents add to the ticket go back to the customer on WhatsApp.\n\nImport, then follow SETUP.md in this folder. In short:\n1. Credentials: \"Spun API key\" (Header Auth: Authorization = Bearer wap_...) and \"Freshdesk API key\" (Basic Auth: the integration agent's API key as user name, X as password). Select them on every SPUN: and FRESHDESK: node.\n2. Settings node: replace the PASTE_... values (your Freshdesk URL, the integration agent's id, the API name of the Spun chat id ticket field, the shared secret for the X-Spun-Secret header; group and line field are optional).\n3. Replace PASTE-A-LONG-RANDOM-SUFFIX in the \"Helpdesk reply\" path with a long random string (keep the freshdesk-reply- prefix).\n4. Activate, create the Freshdesk automation rule that calls the reply URL (SETUP.md), then the Spun webhook subscription pointing at \"Spun events\".\n\nFreshdesk shapes come from ./API-NOTES.md (public docs, not yet live-tested). Every write to Freshdesk is made as the integration agent, so the reply path drops that agent's own notes.",
        "height": 520,
        "width": 760,
        "color": 4
      },
      "id": "note-0115-0000-4000-8000-0000000000115",
      "name": "Note 1",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -260,
        -660
      ]
    },
    {
      "parameters": {
        "content": "## 1. Receive and verify (Spun side)\nSpun POSTs the envelope {event_id, event, timestamp, org_id, data}. The Webhook node keeps the RAW body (Options > Raw Body) because the signature is an HMAC over the exact bytes.\nFlow: raw body to text > HMAC-SHA256 with the signing secret (Crypto node) > constant-time compare > answer 200 at once (or 401 on a bad signature). Everything after \"Ack 200\" runs after Spun already has its answer, so a slow desk API never causes a retry.",
        "height": 180,
        "width": 1040,
        "color": 5
      },
      "id": "note-0116-0000-4000-8000-0000000000116",
      "name": "Note 2",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        -260
      ]
    },
    {
      "parameters": {
        "content": "## 2. Dedupe and route\nRetries reuse event_id; the same WhatsApp message can also arrive twice. Both are dropped here. Then one branch per event: message.inbound, message.outbound, conversation.closed, conversation.reopened. Any other event is acknowledged and ignored.",
        "height": 150,
        "width": 760,
        "color": 5
      },
      "id": "note-0117-0000-4000-8000-0000000000117",
      "name": "Note 3",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1040,
        120
      ]
    },
    {
      "parameters": {
        "content": "## 3. Contact (move 1)\nOwn map first (phone -> contact id). Then the phone filter and the mobile filter (exact filters; a hit counts only on an exact digit match, oldest contact first), then create. The workflow writes and searches the same +<e164> string. Freshdesk phone numbers are not unique, so the workflow always looks before it creates. An adopted contact is never edited.",
        "height": 150,
        "width": 760,
        "color": 6
      },
      "id": "note-0118-0000-4000-8000-0000000000118",
      "name": "Note 4",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1560,
        560
      ]
    },
    {
      "parameters": {
        "content": "## 4. Ticket (moves 2 and 3)\nOwn map (chat_id -> ticket) first. A known ticket is re-read once so a Closed ticket (status 5) gets a NEW follow-up ticket instead of a note. Fallback lookup: the requester's tickets of the last freshdesk_lookup_days, matched on the chat id ticket field (and the tag spun when the list shows tags). First message = ticket description; later messages = public incoming notes, after which Pending or Resolved goes back to Open. Media arrives as a link (valid 24 h) or as \"still processing\" when media_pending is true.",
        "height": 170,
        "width": 900,
        "color": 6
      },
      "id": "note-0119-0000-4000-8000-0000000000119",
      "name": "Note 5",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2600,
        560
      ]
    },
    {
      "parameters": {
        "content": "## 5. Echoes, closes, reopens (Spun side)\nmessage.outbound: a reply this workflow sent is skipped (loop guard 2); anything else sent from the Spun inbox or the phone is added as a PRIVATE note (loop guard 1).\nconversation.closed / conversation.reopened: close or reopen the mapped ticket, unless the reason is the one this workflow itself sends (loop guard on the close path).",
        "height": 170,
        "width": 900,
        "color": 6
      },
      "id": "note-0120-0000-4000-8000-0000000000120",
      "name": "Note 6",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1560,
        1440
      ]
    },
    {
      "parameters": {
        "content": "## 6. Agent replies (Freshdesk side)\nA Freshdesk automation rule (Ticket Updates; performer Agent; events \"Note is added\" and \"Reply is sent\"; ticket tagged spun) calls the webhook with ids only: {kind, ticket_id}. The node answers 200 at once. Freshdesk does not sign webhooks, so the rule sends a shared secret in the X-Spun-Secret header; the adapter compares it in constant time and drops everything while the secret is still a placeholder.\nA note event then reads the ticket's conversations (100 per page) and keeps the public, non-incoming notes and replies whose author is not the integration agent, created since the workflow started tracking the ticket (at most dedupe_hours back), or within reply_window_minutes for a ticket it does not track. Two optional rules report Closed and reopened tickets for the desk-side close. The generic filter drops anything forwarded before, and POST /send-message uses freshdesk:<conversation id> as idempotency_key.",
        "height": 240,
        "width": 1560,
        "color": 3
      },
      "id": "note-0121-0000-4000-8000-0000000000121",
      "name": "Note 7",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        2100
      ]
    },
    {
      "parameters": {
        "content": "## Nodes marked FRESHDESK: are the only ones that talk to the helpdesk\nThey (and the small adapter Code node right after each) are what differs from the generic template. Nodes marked SPUN: call the Spun Actions API. Nodes marked State: read or write the workflow's own maps. Everything else is desk-agnostic.",
        "height": 130,
        "width": 760,
        "color": 7
      },
      "id": "note-0122-0000-4000-8000-0000000000122",
      "name": "Note 8",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -260,
        -120
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "spun-events",
        "responseMode": "responseNode",
        "options": {
          "rawBody": true
        }
      },
      "id": "node-0124-0000-4000-8000-0000000000124",
      "name": "Spun events",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        0
      ],
      "webhookId": "wh-0123-0000-4000-8000-0000000000123"
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "freshdesk-reply-PASTE-A-LONG-RANDOM-SUFFIX",
        "responseMode": "onReceived",
        "options": {
          "rawBody": true
        }
      },
      "id": "node-0126-0000-4000-8000-0000000000126",
      "name": "Helpdesk reply",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        2200
      ],
      "webhookId": "wh-0125-0000-4000-8000-0000000000125"
    },
    {
      "parameters": {
        "jsCode": "// SETTINGS - the only node you edit after import (besides credentials).\n// Every value below is a placeholder. Replace the PASTE_... values; keep the rest unless\n// SETUP.md says otherwise. Optional values that still start with PASTE are left out of the\n// ticket. The settings object is copied onto every item so that later nodes read\n// $json.settings.<name> without depending on which trigger fired.\n// freshdesk_tags must keep \"spun\": the automation rule and the fallback ticket lookup rely on it.\n// freshdesk_chat_id_field and freshdesk_line_field are API names read from GET /api/v2/ticket_fields\n// (they look like cf_spun_chat_id, sometimes with a number at the end; SETUP.md section 5).\n// freshdesk_use_unique_external_id = true also writes unique_external_id = whatsapp:+<e164> on the\n// contacts the workflow creates. It is a marker only: the contact list cannot filter on it. Needs\n// the Unique External ID contact field turned on (SETUP.md section 4).\nconst settings = {\n  \"spun_api_base\": \"https://api.spun.com/api/integrations\",\n  \"spun_webhook_secret\": \"PASTE_THE_SIGNING_SECRET_OF_YOUR_SPUN_WEBHOOK_SUBSCRIPTION\",\n  \"freshdesk_base\": \"https://PASTE_YOUR_DOMAIN.freshdesk.com\",\n  \"freshdesk_webhook_secret\": \"PASTE_A_LONG_RANDOM_SECRET_FOR_THE_X_SPUN_SECRET_HEADER\",\n  \"freshdesk_chat_id_field\": \"PASTE_THE_API_NAME_OF_THE_SPUN_CHAT_ID_TICKET_FIELD\",\n  \"freshdesk_line_field\": \"PASTE_OPTIONAL_API_NAME_OF_THE_SPUN_LINE_TICKET_FIELD\",\n  // Optional third ticket field \"Spun LID\": the privacy id of a chat whose phone WhatsApp hides\n  // (SETUP.md section 5). Left out of the ticket while it still starts with PASTE.\n  \"freshdesk_lid_field\": \"PASTE_OPTIONAL_API_NAME_OF_THE_SPUN_LID_TICKET_FIELD\",\n  \"freshdesk_group_id\": \"PASTE_OPTIONAL_GROUP_ID_FOR_NEW_TICKETS\",\n  \"freshdesk_tags\": [\"spun\", \"whatsapp\"],\n  \"freshdesk_source\": 7,\n  \"freshdesk_close_status\": 5,\n  \"freshdesk_reopen_statuses\": [3, 4],\n  \"freshdesk_use_unique_external_id\": false,\n  \"freshdesk_note_as_customer\": false,\n  \"freshdesk_lookup_days\": 365,\n  \"desk_integration_user_id\": \"PASTE_THE_FRESHDESK_AGENT_ID_OF_THE_INTEGRATION_AGENT\",\n  \"reply_window_minutes\": 15,\n  // Groups and newsletters are always skipped (no single person to make a contact for).\n  \"skip_groups\": true,\n  \"inbound_prefix\": \"[WhatsApp]\",\n  \"outbound_prefix\": \"[Sent from Spun]\",\n  \"desk_close_reason\": \"closed_in_freshdesk\",\n  \"desk_reopen_reason\": \"reopened_in_freshdesk\",\n  \"close_in_spun_when_desk_closes\": false,\n  \"dedupe_hours\": 24\n};\nreturn $input.all().map((item) => ({\n  json: { ...item.json, settings },\n  binary: item.binary,\n  pairedItem: item.pairedItem,\n}));"
      },
      "id": "node-0127-0000-4000-8000-0000000000127",
      "name": "Settings",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        260,
        1100
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0131-0000-4000-8000-0000000000131",
                    "leftValue": "={{ String($json.webhookUrl || '').split('/').pop() }}",
                    "rightValue": "spun-events",
                    "operator": {
                      "type": "string",
                      "operation": "startsWith"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "spun"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0132-0000-4000-8000-0000000000132",
                    "leftValue": "={{ String($json.webhookUrl || '').split('/').pop() }}",
                    "rightValue": "freshdesk-reply-",
                    "operator": {
                      "type": "string",
                      "operation": "startsWith"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "desk"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra"
        }
      },
      "id": "node-0130-0000-4000-8000-0000000000130",
      "name": "Route by trigger",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3,
      "position": [
        520,
        1100
      ]
    },
    {
      "parameters": {
        "operation": "text",
        "binaryPropertyName": "data",
        "destinationKey": "rawBody",
        "options": {}
      },
      "id": "node-0133-0000-4000-8000-0000000000133",
      "name": "Raw body to text",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1,
      "position": [
        780,
        0
      ]
    },
    {
      "parameters": {
        "action": "hmac",
        "type": "SHA256",
        "value": "={{ $json.rawBody }}",
        "dataPropertyName": "computedHmac",
        "secret": "={{ $json.settings.spun_webhook_secret }}",
        "encoding": "hex"
      },
      "id": "node-0134-0000-4000-8000-0000000000134",
      "name": "HMAC of raw body",
      "type": "n8n-nodes-base.crypto",
      "typeVersion": 1,
      "position": [
        1040,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Compare the Spun signature with the HMAC computed by the Crypto node, then parse the body.\n// X-Webhook-Signature is \"sha256=<lowercase hex HMAC-SHA256 of the exact raw body>\".\nconst j = $input.item.json;\nconst headers = j.headers || {};\nconst header = String(headers['x-webhook-signature'] || '');\nconst expected = 'sha256=' + String(j.computedHmac || '');\nlet ok = header.length === expected.length && header.length > 7;\nif (ok) {\n  // constant-time compare without a crypto import (the Code node has no require by default)\n  let diff = 0;\n  for (let i = 0; i < expected.length; i++) diff |= header.charCodeAt(i) ^ expected.charCodeAt(i);\n  ok = diff === 0;\n}\nlet envelope = {};\ntry { envelope = JSON.parse(j.rawBody || '{}'); } catch (e) { ok = false; }\nreturn {\n  json: {\n    signature_ok: ok,\n    event_id: envelope.event_id || headers['x-webhook-delivery'] || null,\n    event: envelope.event || headers['x-webhook-event'] || null,\n    timestamp: envelope.timestamp || null,\n    org_id: envelope.org_id || null,\n    data: envelope.data || {},\n    settings: j.settings,\n  },\n};",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0135-0000-4000-8000-0000000000135",
      "name": "Check signature + parse",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1300,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0136-0000-4000-8000-0000000000136",
              "leftValue": "={{ $json.signature_ok }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0137-0000-4000-8000-0000000000137",
      "name": "Signature valid?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1560,
        0
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ ok: true, event_id: $json.event_id }) }}",
        "options": {
          "responseCode": 200
        }
      },
      "id": "node-0138-0000-4000-8000-0000000000138",
      "name": "Ack 200",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1820,
        -60
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ error: 'invalid_signature' }) }}",
        "options": {
          "responseCode": 401
        }
      },
      "id": "node-0139-0000-4000-8000-0000000000139",
      "name": "Reject 401",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1820,
        100
      ]
    },
    {
      "parameters": {
        "jsCode": "// Dedupe on event_id (Spun retries reuse it) and, for message events, on data.message_id.\n// State lives in n8n workflow static data: it persists only for PRODUCTION executions of an\n// active workflow and is saved when the execution ends. Swap for a Data Table, Redis or a\n// database when you need durability or run several n8n workers (see README \"State\").\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.seenEvents ??= {};\nstate.seenMessages ??= {};\nconst now = Date.now();\nconst ttl = (Number(j.settings.dedupe_hours) || 24) * 3600 * 1000;\nfor (const key of Object.keys(state.seenEvents)) if (now - state.seenEvents[key] > ttl) delete state.seenEvents[key];\nfor (const key of Object.keys(state.seenMessages)) if (now - state.seenMessages[key] > ttl) delete state.seenMessages[key];\nlet duplicate = false;\nif (j.event_id) {\n  if (state.seenEvents[j.event_id]) duplicate = true;\n  state.seenEvents[j.event_id] = now;\n}\nconst messageId = j.data && j.data.message_id;\nif (!duplicate && messageId && String(j.event || '').startsWith('message.')) {\n  if (state.seenMessages[messageId]) duplicate = true;\n  state.seenMessages[messageId] = now;\n}\nreturn { json: { ...j, duplicate } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0140-0000-4000-8000-0000000000140",
      "name": "State: seen before?",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2080,
        -60
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0141-0000-4000-8000-0000000000141",
              "leftValue": "={{ !$json.duplicate }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0142-0000-4000-8000-0000000000142",
      "name": "New delivery?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2340,
        -60
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0143-0000-4000-8000-0000000000143",
                    "leftValue": "={{ $json.event }}",
                    "rightValue": "message.inbound",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "inbound"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0144-0000-4000-8000-0000000000144",
                    "leftValue": "={{ $json.event }}",
                    "rightValue": "message.outbound",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "outbound"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0145-0000-4000-8000-0000000000145",
                    "leftValue": "={{ $json.event }}",
                    "rightValue": "conversation.closed",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "closed"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0146-0000-4000-8000-0000000000146",
                    "leftValue": "={{ $json.event }}",
                    "rightValue": "conversation.reopened",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "reopened"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra"
        }
      },
      "id": "node-0147-0000-4000-8000-0000000000147",
      "name": "Route by event",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3,
      "position": [
        2600,
        -60
      ]
    },
    {
      "parameters": {},
      "id": "node-0148-0000-4000-8000-0000000000148",
      "name": "Duplicate, ignored",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2600,
        220
      ]
    },
    {
      "parameters": {},
      "id": "node-0149-0000-4000-8000-0000000000149",
      "name": "Other event, ignored",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2860,
        280
      ]
    },
    {
      "parameters": {
        "jsCode": "// Shape the Spun message.inbound payload for the helpdesk.\n// Fields used (all on data, added by Spun on 10/2026): is_group, peer_phone_e164, peer_lid,\n// identity_state, contact_id, contact_name, contact_email, caption, media_url (signed link,\n// valid 24 h), media_mime, media_filename, media_size, media_pending.\n// Privacy-ID (LID) chats: WhatsApp can hide a customer's number behind a privacy id\n// (\"<digits>@lid\"). Spun then sends peer_lid (the digits) and fills peer_phone_e164 only once it\n// knows the real number (identity_state lid_linked). Until then (lid_unlinked) the chat still\n// becomes a ticket; the contact is keyed by the privacy id instead of a phone.\nconst j = $input.item.json;\nconst d = j.data || {};\nconst s = j.settings;\nconst chatId = String(d.chat_id || '');\nconst phone = d.peer_phone_e164 || null;\nconst lidMatch = /^(\\d+)@lid$/.exec(chatId);\nconst lid = d.peer_lid || (lidMatch ? lidMatch[1] : null);\nconst identity_state = d.identity_state || (lid ? (phone ? 'lid_linked' : 'lid_unlinked') : 'phone');\nlet handle = true;\nlet skip_reason = null;\n// \"Skipped\" is reserved for chats with no single person behind them: groups and newsletters.\nif (d.is_group || /@(g\\.us|newsletter|broadcast)$/.test(chatId) || (!phone && !lid)) {\n  handle = false; skip_reason = 'group or newsletter chat (no single person to make a contact for)';\n}\nconst who = phone || 'privacy ID ' + lid;\nconst name = d.contact_name || d.sender_name || d.chat_name || who;\nconst type = d.type || 'text';\nconst lines = [];\nif (d.text) lines.push(String(d.text));\nif (type !== 'text') {\n  if (d.media_pending) {\n    lines.push('[' + type + '] Media is still being processed in Spun. Open the conversation in Spun to view it.');\n  } else if (d.media_url) {\n    const meta = [d.media_filename, d.media_mime, d.media_size ? Math.round(Number(d.media_size) / 1024) + ' KB' : null].filter(Boolean).join(', ');\n    lines.push('[' + type + '] ' + d.media_url + (meta ? ' (' + meta + ')' : '') + ' - link valid for 24 hours');\n  } else {\n    lines.push('[' + type + '] Open the conversation in Spun to view it.');\n  }\n  if (d.caption) lines.push(String(d.caption));\n}\nconst text = lines.join('\\n') || '[' + type + ']';\nconst whenIso = d.timestamp ? new Date(Number(d.timestamp) * 1000).toISOString() : (j.timestamp || new Date().toISOString());\nconst firstLine = (d.text || d.caption || '[' + type + ']').split('\\n')[0];\nreturn {\n  json: {\n    settings: s,\n    event_id: j.event_id,\n    event: j.event,\n    org_id: j.org_id,\n    handle,\n    skip_reason,\n    chat_id: d.chat_id,\n    message_id: d.message_id || null,\n    phone,\n    lid,\n    identity_state,\n    name,\n    email: d.contact_email || null,\n    spun_contact_id: d.contact_id || null,\n    line_id: d.integration_id || null,\n    type,\n    text,\n    comment_text: s.inbound_prefix + ' ' + name + ' (' + who + '): ' + text,\n    subject: ('WhatsApp: ' + firstLine).slice(0, 80),\n    when: whenIso,\n    media_url: d.media_url || null,\n    media_pending: !!d.media_pending,\n  },\n};",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0150-0000-4000-8000-0000000000150",
      "name": "Prepare message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1300,
        660
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0151-0000-4000-8000-0000000000151",
              "leftValue": "={{ $json.handle }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0152-0000-4000-8000-0000000000152",
      "name": "Handle this message?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1560,
        660
      ]
    },
    {
      "parameters": {},
      "id": "node-0153-0000-4000-8000-0000000000153",
      "name": "Skipped (group or newsletter)",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1820,
        880
      ]
    },
    {
      "parameters": {
        "jsCode": "// Read the template's own maps: phone -> contact id, chat_id -> ticket. Desk search is only the\n// fallback (search indexes lag by minutes on most desks).\n// A privacy-ID (LID) chat with no known phone is keyed \"lid:<digits>\"; when Spun later reports the\n// same chat with a phone (identity_state lid_linked), the same contact keeps being used.\n// contact_lane: phone searches phone, then mobile. A privacy-ID chat with no phone goes\n// straight to create: the contact list cannot filter on a custom field or on\n// unique_external_id, so this map is the only dedupe for it.\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.contacts ??= {};\nstate.tickets ??= {};\nconst lidKey = j.lid ? 'lid:' + j.lid : null;\nconst byPhone = j.phone ? state.contacts[j.phone] || null : null;\nconst byLid = lidKey ? state.contacts[lidKey] || null : null;\nconst contact_id = byPhone || byLid;\nconst contact_lane = j.phone ? 'phone' : 'create';\nconst ticket = state.tickets[j.chat_id] || null;\nconst ticket_known = !!(ticket && ticket.ticket_id && ticket.status !== 'closed');\nreturn { json: { ...j, contact_id, contact_known: !!contact_id, contact_lane, ticket_id: ticket_known ? ticket.ticket_id : null, ticket_known } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0154-0000-4000-8000-0000000000154",
      "name": "State: lookup contact + ticket",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1820,
        660
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0155-0000-4000-8000-0000000000155",
              "leftValue": "={{ $json.contact_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0156-0000-4000-8000-0000000000156",
      "name": "Contact known?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2080,
        660
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.settings.freshdesk_base }}/api/v2/contacts",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "phone",
              "value": "={{ $json.phone }}"
            }
          ]
        }
      },
      "id": "node-0161-0000-4000-8000-0000000000161",
      "name": "FRESHDESK: find contact by phone",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2340,
        880
      ],
      "credentials": {
        "httpBasicAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Freshdesk API key (Basic Auth: API key as user, X as password)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Find the contact. Freshdesk ?phone= is an exact filter and the workflow writes and searches the\n// same +<e164> string, but phone numbers are not unique, so accept a hit only on an exact digit\n// match, oldest contact first. The list endpoint answers an array; \"Include Response Headers and\n// Status\" keeps an empty answer as one item (body: []). A found contact is used as it is, never edited.\nconst r = $input.item.json;\nconst j = $('Contact known?').item.json;\nconst digits = (v) => String(v || '').replace(/\\D/g, '');\nconst want = digits(j.phone);\nconst list = (r && Array.isArray(r.body)) ? r.body : [];\nconst hit = list\n  .filter((c) => c.phone && digits(c.phone) === want)\n  .sort((a, b) => Number(a.id) - Number(b.id))[0];\nconst contact_id = hit ? String(hit.id) : null;\nreturn { json: { ...j, contact_id, contact_known: !!contact_id, searched: 'phone' } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0162-0000-4000-8000-0000000000162",
      "name": "Pick matching contact (phone)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2600,
        880
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0163-0000-4000-8000-0000000000163",
              "leftValue": "={{ $json.contact_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0164-0000-4000-8000-0000000000164",
      "name": "Contact found by phone?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2860,
        880
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.settings.freshdesk_base }}/api/v2/contacts",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "mobile",
              "value": "={{ $json.phone }}"
            }
          ]
        }
      },
      "id": "node-0237-0000-4000-8000-0000000000237",
      "name": "FRESHDESK: find contact by mobile",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3120,
        1100
      ],
      "credentials": {
        "httpBasicAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Freshdesk API key (Basic Auth: API key as user, X as password)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Find the contact. Freshdesk ?mobile= is an exact filter and the workflow writes and searches the\n// same +<e164> string, but phone numbers are not unique, so accept a hit only on an exact digit\n// match, oldest contact first. The list endpoint answers an array; \"Include Response Headers and\n// Status\" keeps an empty answer as one item (body: []). A found contact is used as it is, never edited.\nconst r = $input.item.json;\nconst j = $('Contact known?').item.json;\nconst digits = (v) => String(v || '').replace(/\\D/g, '');\nconst want = digits(j.phone);\nconst list = (r && Array.isArray(r.body)) ? r.body : [];\nconst hit = list\n  .filter((c) => c.mobile && digits(c.mobile) === want)\n  .sort((a, b) => Number(a.id) - Number(b.id))[0];\nconst contact_id = hit ? String(hit.id) : null;\nreturn { json: { ...j, contact_id, contact_known: !!contact_id, searched: 'mobile' } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0238-0000-4000-8000-0000000000238",
      "name": "Pick matching contact (mobile)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3380,
        1100
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0239-0000-4000-8000-0000000000239",
              "leftValue": "={{ $json.contact_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0240-0000-4000-8000-0000000000240",
      "name": "Contact found by mobile?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        3640,
        1100
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.settings.freshdesk_base }}/api/v2/contacts",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.phone ? Object.assign({ name: String($json.name).slice(0, 255), phone: $json.phone }, $json.settings.freshdesk_use_unique_external_id === true ? { unique_external_id: 'whatsapp:' + $json.phone } : {}) : { name: String($json.name).slice(0, 255), unique_external_id: 'whatsapp-lid:' + $json.lid }) }}"
      },
      "id": "node-0165-0000-4000-8000-0000000000165",
      "name": "FRESHDESK: create contact",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3900,
        1100
      ],
      "credentials": {
        "httpBasicAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Freshdesk API key (Basic Auth: API key as user, X as password)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// POST /contacts answers 201 with the contact object. A 409 duplicate_value on unique_external_id\n// means another contact already holds this marker, usually because its phone was edited since;\n// invalid_field on it means the field is not available on this account (SETUP.md section 4).\n// A privacy-ID (LID) chat with no phone is created with unique_external_id = whatsapp-lid:<digits>,\n// the only contact key Freshdesk accepts without a phone or email.\nconst r = $input.item.json;\nconst j = $('Contact to create').item.json;\nconst contact_id = r && r.id ? String(r.id) : null;\nif (!contact_id) {\n  const errs = r && Array.isArray(r.errors) ? r.errors : [];\n  const ext = errs.find((e) => e && e.field === 'unique_external_id');\n  let hint = '';\n  if (ext && !j.phone) {\n    hint = ext.code === 'duplicate_value'\n      ? ' A contact already has the external id whatsapp-lid:' + j.lid + ' (the workflow map was probably reset). Open that contact in Freshdesk and send the next message again, or clear its Unique External ID.'\n      : ' Privacy-ID (LID) chats need the Unique External ID contact field turned on (SETUP.md section 4).';\n    throw new Error('Freshdesk contact create failed.' + hint + ' ' + JSON.stringify(r).slice(0, 500));\n  }\n  if (ext && ext.code === 'duplicate_value') hint = ' A contact already has the external id whatsapp:' + j.phone + ' but not this phone number (probably edited). Fix that contact\\'s phone, clear its Unique External ID, or set freshdesk_use_unique_external_id to false.';\n  else if (ext) hint = ' Turn on the Unique External ID contact field, or set freshdesk_use_unique_external_id to false.';\n  throw new Error('Freshdesk contact create failed.' + hint + ' ' + JSON.stringify(r).slice(0, 500));\n}\nreturn { json: { ...j, contact_id, contact_known: true } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0166-0000-4000-8000-0000000000166",
      "name": "Read new contact id",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4160,
        1100
      ]
    },
    {
      "parameters": {
        "jsCode": "// Write the map entries: phone -> contact id, and \"lid:<digits>\" -> contact id for a privacy-ID\n// chat, so a later message (with or without a phone) finds the same contact.\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.contacts ??= {};\nif (j.phone && j.contact_id) state.contacts[j.phone] = j.contact_id;\nif (j.lid && j.contact_id) state.contacts['lid:' + j.lid] = j.contact_id;\nreturn { json: { ...j, contact_known: !!j.contact_id } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0167-0000-4000-8000-0000000000167",
      "name": "State: remember contact",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4160,
        880
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0168-0000-4000-8000-0000000000168",
              "leftValue": "={{ $json.ticket_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0169-0000-4000-8000-0000000000169",
      "name": "Open ticket known?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2340,
        440
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.settings.freshdesk_base }}/api/v2/tickets/{{ $json.ticket_id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        }
      },
      "id": "node-0170-0000-4000-8000-0000000000170",
      "name": "FRESHDESK: read ticket status",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2600,
        440
      ],
      "credentials": {
        "httpBasicAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Freshdesk API key (Basic Auth: API key as user, X as password)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Freshdesk: Closed (status 5) is final here, so a new message starts a follow-up ticket. Pending,\n// Resolved and custom statuses still take notes; \"Prepare note\" sets the statuses listed in\n// freshdesk_reopen_statuses back to Open. A 404, a deleted or a spam ticket counts as not open.\nconst r = $input.item.json;\nconst j = $('Open ticket known?').item.json;\nconst t = r && r.id ? r : null;\nconst status = t ? Number(t.status) : null;\nconst still_open = !!t && !t.deleted && !t.spam && status !== 5;\nreturn { json: { ...j, still_open, ticket_status: status, followup_source_id: t && status === 5 ? String(t.id) : null } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0171-0000-4000-8000-0000000000171",
      "name": "Ticket still open?",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2860,
        440
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0172-0000-4000-8000-0000000000172",
              "leftValue": "={{ $json.still_open }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0173-0000-4000-8000-0000000000173",
      "name": "Still open?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        3120,
        440
      ]
    },
    {
      "parameters": {
        "jsCode": "// Mark the mapped ticket \"closed\" without touching the desk.\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst t = state.tickets[j.chat_id];\nif (t) { t.status = 'closed'; t.updated_at = Date.now(); }\nreturn { json: { ...j, ticket_known: false, ticket_id: null } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0174-0000-4000-8000-0000000000174",
      "name": "State: mark ticket closed (desk closed it)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3380,
        660
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $('Open ticket known?').item.json.settings.freshdesk_base }}/api/v2/tickets",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "requester_id",
              "value": "={{ $('Open ticket known?').item.json.contact_id }}"
            },
            {
              "name": "updated_since",
              "value": "={{ new Date(Date.now() - (Number($('Open ticket known?').item.json.settings.freshdesk_lookup_days) || 365) * 86400000).toISOString().slice(0, 19) + 'Z' }}"
            },
            {
              "name": "order_by",
              "value": "updated_at"
            },
            {
              "name": "order_type",
              "value": "desc"
            },
            {
              "name": "per_page",
              "value": "100"
            }
          ]
        }
      },
      "id": "node-0175-0000-4000-8000-0000000000175",
      "name": "FRESHDESK: list tickets of requester",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3640,
        660
      ],
      "credentials": {
        "httpBasicAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Freshdesk API key (Basic Auth: API key as user, X as password)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Freshdesk fallback: GET /tickets?requester_id=... is not index-backed, so a ticket created seconds\n// ago is listed. It has no status filter and only looks back freshdesk_lookup_days (updated_since).\n// Take the newest ticket whose chat id field holds this chat id, that is not Closed; the \"spun\" tag\n// is checked only when the list object carries tags (the documented list sample has none). When\n// all are closed, the newest closed one becomes the follow-up source of the new ticket.\n// A failed list call fails the execution instead of creating a duplicate ticket.\nconst r = $input.item.json;\nconst j = $('Open ticket known?').item.json;\nconst field = String(j.settings.freshdesk_chat_id_field || '');\nif (!field || field.startsWith('PASTE')) throw new Error('Set freshdesk_chat_id_field in the Settings node to the API name of the Spun chat id ticket field (SETUP.md section 5).');\nif (!r || Number(r.statusCode) >= 400 || !Array.isArray(r.body)) throw new Error('Freshdesk ticket list failed: ' + JSON.stringify(r && r.body !== undefined ? r.body : r).slice(0, 500));\nlet earlier = null;\ntry { earlier = $('Ticket still open?').item.json.followup_source_id || null; } catch (e) { earlier = null; }\nconst ours = r.body\n  .filter((t) => t.custom_fields && String(t.custom_fields[field] || '') === String(j.chat_id) && (!Array.isArray(t.tags) || t.tags.map((x) => String(x).toLowerCase()).includes('spun')))\n  .sort((a, b) => Number(b.id) - Number(a.id));\nconst open = ours.find((t) => Number(t.status) !== 5 && !t.deleted && !t.spam) || null;\nconst lastClosed = ours.find((t) => Number(t.status) === 5) || null;\nconst ticket_id = open ? String(open.id) : null;\nconst followup_source_id = open ? null : (earlier || (lastClosed ? String(lastClosed.id) : null));\nif (ticket_id) {\n  // Agent notes count from the moment the workflow (re)starts tracking this ticket.\n  const state = $getWorkflowStaticData('global');\n  state.freshdeskSince ??= {};\n  state.freshdeskSince[ticket_id] ??= Date.now();\n}\nreturn { json: { ...j, ticket_id, ticket_known: !!ticket_id, ticket_status: open ? Number(open.status) : null, followup_source_id } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0176-0000-4000-8000-0000000000176",
      "name": "Pick open ticket",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3900,
        660
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0177-0000-4000-8000-0000000000177",
              "leftValue": "={{ $json.ticket_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0178-0000-4000-8000-0000000000178",
      "name": "Open ticket found?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        4160,
        660
      ]
    },
    {
      "parameters": {
        "jsCode": "// Write the chat_id -> ticket map entry (status \"open\").\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nif (j.chat_id && j.ticket_id) {\n  state.tickets[j.chat_id] = { ticket_id: String(j.ticket_id), status: 'open', updated_at: Date.now() };\n}\nreturn { json: { ...j, ticket_known: true } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0179-0000-4000-8000-0000000000179",
      "name": "State: remember found ticket",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4420,
        440
      ]
    },
    {
      "parameters": {
        "jsCode": "// Build the note for one inbound message: public (private: false) and incoming: true, so it shows\n// on the customer side of the thread. The integration agent writes it and the text starts with the\n// customer's name and number; freshdesk_note_as_customer = true sends user_id = requester instead\n// (not yet proven on a live account, SETUP.md section 10). Statuses listed in\n// freshdesk_reopen_statuses (Pending and Resolved by default) go back to Open after the note.\nconst j = $input.item.json;\nconst s = j.settings;\nconst html = (x) => String(x).slice(0, 20000).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;').replace(/'/g, '&#39;').replace(/https?:\\/\\/[^\\s<]+/g, (u) => { const m = u.match(/^(.*?)((?:&quot;|&#39;|[.,;:!?)])*)$/); return '<a href=\"' + m[1] + '\">' + m[1] + '</a>' + m[2]; }).replace(/\\n/g, '<br>');\nconst note_body = { body: html(j.comment_text), private: false, incoming: true };\nif (s.freshdesk_note_as_customer === true && j.contact_id) note_body.user_id = Number(j.contact_id);\nconst reopen = (Array.isArray(s.freshdesk_reopen_statuses) ? s.freshdesk_reopen_statuses : []).map(Number);\nconst reopen_after = j.ticket_status !== null && j.ticket_status !== undefined && reopen.includes(Number(j.ticket_status));\nreturn { json: { ...j, note_body, reopen_after } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0241-0000-4000-8000-0000000000241",
      "name": "Prepare note",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4680,
        440
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.settings.freshdesk_base }}/api/v2/tickets/{{ $json.ticket_id }}/notes",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.note_body) }}"
      },
      "id": "node-0180-0000-4000-8000-0000000000180",
      "name": "FRESHDESK: append message as note",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4940,
        440
      ],
      "credentials": {
        "httpBasicAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Freshdesk API key (Basic Auth: API key as user, X as password)"
        }
      }
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Open ticket known?').item.json.settings.freshdesk_base }}/api/v2/tickets",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ (() => { const j = $('Open ticket known?').item.json; const s = j.settings; const set = (v) => v !== undefined && v !== null && String(v) !== '' && !String(v).startsWith('PASTE'); const desc = String(j.comment_text) + ($json.followup_source_id ? '\\n\\nPrevious ticket: #' + $json.followup_source_id : ''); const cf = {}; cf[s.freshdesk_chat_id_field] = String(j.chat_id); if (set(s.freshdesk_line_field) && j.line_id) cf[s.freshdesk_line_field] = String(j.line_id); if (set(s.freshdesk_lid_field) && j.lid) cf[s.freshdesk_lid_field] = String(j.lid); const t = { requester_id: Number(j.contact_id), subject: String(j.subject).slice(0, 255), description: desc.slice(0, 20000).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;').replace(/'/g, '&#39;').replace(/https?:\\/\\/[^\\s<]+/g, (u) => { const m = u.match(/^(.*?)((?:&quot;|&#39;|[.,;:!?)])*)$/); return '<a href=\"' + m[1] + '\">' + m[1] + '</a>' + m[2]; }).replace(/\\n/g, '<br>'), source: Number(s.freshdesk_source) || 7, status: 2, priority: 1, tags: Array.from(new Set(['spun'].concat(s.freshdesk_tags || []))), custom_fields: cf }; if (set(s.freshdesk_group_id)) t.group_id = Number(s.freshdesk_group_id); return JSON.stringify(t); })() }}"
      },
      "id": "node-0181-0000-4000-8000-0000000000181",
      "name": "FRESHDESK: create ticket",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4420,
        880
      ],
      "credentials": {
        "httpBasicAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Freshdesk API key (Basic Auth: API key as user, X as password)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Freshdesk answers 201 with the ticket object; id is the ticket number.\nconst r = $input.item.json;\nconst j = $('Open ticket known?').item.json;\nconst ticket_id = r && r.id ? String(r.id) : null;\nif (!ticket_id) throw new Error('Freshdesk ticket create failed (invalid_field usually names a wrong field API name or group id in Settings): ' + JSON.stringify(r).slice(0, 500));\n// Agent notes count from the moment the ticket exists; drop entries of tickets no longer mapped.\nconst state = $getWorkflowStaticData('global');\nstate.freshdeskSince ??= {};\nstate.freshdeskSince[ticket_id] = Date.now();\nconst mapped = new Set(Object.values(state.tickets || {}).map((t) => String(t.ticket_id)));\nfor (const k of Object.keys(state.freshdeskSince)) if (k !== ticket_id && !mapped.has(k)) delete state.freshdeskSince[k];\nreturn { json: { ...j, ticket_id, ticket_url: String(j.settings.freshdesk_base).replace(/\\/+$/, '') + '/a/tickets/' + ticket_id } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0182-0000-4000-8000-0000000000182",
      "name": "Read new ticket id",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4680,
        880
      ]
    },
    {
      "parameters": {
        "jsCode": "// Write the chat_id -> ticket map entry (status \"open\").\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nif (j.chat_id && j.ticket_id) {\n  state.tickets[j.chat_id] = { ticket_id: String(j.ticket_id), status: 'open', updated_at: Date.now() };\n}\nreturn { json: { ...j, ticket_known: true } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0183-0000-4000-8000-0000000000183",
      "name": "State: remember new ticket",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4940,
        880
      ]
    },
    {
      "parameters": {
        "jsCode": "// Bookkeeping after an append: last activity on the ticket.\n// Freshdesk answers 201 with the note ({id, body, private, incoming, user_id, ...}). Anything else\n// (429 rate limit, 400 validation) fails this execution so it shows in the n8n execution list\n// instead of passing silently; the message itself is still in Spun.\nconst j = $('Prepare note').item.json;\nconst r = $input.item.json;\nif (!r || !r.id) throw new Error('Freshdesk did not accept the note on ticket ' + j.ticket_id + ': ' + JSON.stringify(r).slice(0, 500));\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst t = state.tickets[j.chat_id];\nif (t) t.updated_at = Date.now();\nreturn { json: { ...j, appended: true, note_id: String(r.id), desk_response: r } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0184-0000-4000-8000-0000000000184",
      "name": "State: remember message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5200,
        440
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0242-0000-4000-8000-0000000000242",
              "leftValue": "={{ $json.reopen_after }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0243-0000-4000-8000-0000000000243",
      "name": "Set ticket back to open?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        5460,
        440
      ]
    },
    {
      "parameters": {
        "method": "PUT",
        "url": "={{ $json.settings.freshdesk_base }}/api/v2/tickets/{{ $json.ticket_id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ status: 2 }) }}"
      },
      "id": "node-0244-0000-4000-8000-0000000000244",
      "name": "FRESHDESK: set ticket open",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        5720,
        360
      ],
      "credentials": {
        "httpBasicAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Freshdesk API key (Basic Auth: API key as user, X as password)"
        }
      }
    },
    {
      "parameters": {},
      "id": "node-0245-0000-4000-8000-0000000000245",
      "name": "Status unchanged",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        5720,
        560
      ]
    },
    {
      "parameters": {
        "jsCode": "// message.outbound: is this the echo of a reply this workflow sent (loop guard 2)?\nconst j = $input.item.json;\nconst d = j.data || {};\nconst state = $getWorkflowStaticData('global');\nstate.sentMessages ??= {};\nstate.tickets ??= {};\nconst is_echo = !!(d.message_id && state.sentMessages[d.message_id]);\nconst ticket = state.tickets[d.chat_id] || null;\nconst ticket_known = !!(ticket && ticket.status !== 'closed');\nconst type = d.type || 'text';\nlet text = d.text ? String(d.text) : '';\nif (type !== 'text') {\n  text = (text ? text + '\\n' : '') + '[' + type + '] ' + (d.media_pending ? 'media still processing in Spun' : (d.media_url || 'see the conversation in Spun'));\n  if (d.caption) text += '\\n' + d.caption;\n}\nreturn {\n  json: {\n    settings: j.settings, event_id: j.event_id, chat_id: d.chat_id, message_id: d.message_id || null,\n    is_echo, ticket_known, ticket_id: ticket_known ? ticket.ticket_id : null,\n    note_text: j.settings.outbound_prefix + ' ' + (d.sender_name || 'agent') + ': ' + (text || '[' + type + ']'),\n  },\n};",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0185-0000-4000-8000-0000000000185",
      "name": "Own send or ticket?",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1300,
        1320
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0186-0000-4000-8000-0000000000186",
              "leftValue": "={{ $json.is_echo }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0187-0000-4000-8000-0000000000187",
      "name": "Echo of our own reply?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1560,
        1320
      ]
    },
    {
      "parameters": {},
      "id": "node-0188-0000-4000-8000-0000000000188",
      "name": "Echo, nothing to add",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1820,
        1240
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0189-0000-4000-8000-0000000000189",
              "leftValue": "={{ $json.ticket_known }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0190-0000-4000-8000-0000000000190",
      "name": "Ticket known for this chat?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1820,
        1400
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.settings.freshdesk_base }}/api/v2/tickets/{{ $json.ticket_id }}/notes",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ body: String($json.note_text).slice(0, 20000).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;').replace(/'/g, '&#39;').replace(/https?:\\/\\/[^\\s<]+/g, (u) => { const m = u.match(/^(.*?)((?:&quot;|&#39;|[.,;:!?)])*)$/); return '<a href=\"' + m[1] + '\">' + m[1] + '</a>' + m[2]; }).replace(/\\n/g, '<br>'), private: true }) }}"
      },
      "id": "node-0191-0000-4000-8000-0000000000191",
      "name": "FRESHDESK: add private note (sent from Spun)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2080,
        1400
      ],
      "credentials": {
        "httpBasicAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Freshdesk API key (Basic Auth: API key as user, X as password)"
        }
      }
    },
    {
      "parameters": {},
      "id": "node-0192-0000-4000-8000-0000000000192",
      "name": "No ticket, ignored",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2080,
        1600
      ]
    },
    {
      "parameters": {
        "jsCode": "// conversation.closed from Spun -> close the mapped ticket. Loop guard: a close this workflow\n// itself requested (reason = settings.desk_close_reason) comes back as conversation.closed too.\nconst j = $input.item.json;\nconst d = j.data || {};\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst ticket = state.tickets[d.chat_id] || null;\nconst ours = d.reason === j.settings.desk_close_reason;\nconst close_in_desk = !!(ticket && ticket.ticket_id && ticket.status !== 'closed' && !ours);\nreturn { json: { settings: j.settings, event_id: j.event_id, chat_id: d.chat_id, ticket_id: ticket ? ticket.ticket_id : null, close_in_desk, closed_by: d.closed_by || null, reason: d.reason || null } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0193-0000-4000-8000-0000000000193",
      "name": "Prepare close",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2600,
        1320
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0194-0000-4000-8000-0000000000194",
              "leftValue": "={{ $json.close_in_desk }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0195-0000-4000-8000-0000000000195",
      "name": "Close ticket in desk?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2860,
        1320
      ]
    },
    {
      "parameters": {
        "method": "PUT",
        "url": "={{ $json.settings.freshdesk_base }}/api/v2/tickets/{{ $json.ticket_id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ status: Number($json.settings.freshdesk_close_status) || 5 }) }}"
      },
      "id": "node-0196-0000-4000-8000-0000000000196",
      "name": "FRESHDESK: close ticket",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3120,
        1320
      ],
      "credentials": {
        "httpBasicAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Freshdesk API key (Basic Auth: API key as user, X as password)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const j = $('Close ticket in desk?').item.json;\n// Mark the mapped ticket \"closed\" only when Freshdesk accepted the status change. A ticket field\n// marked \"required when closing\" makes Freshdesk refuse it; the ticket then stays open in Freshdesk\n// and in the map, and desk_error shows the reason.\nconst r = $input.item.json;\nconst solved = !!(r && r.id && [4, 5].includes(Number(r.status)));\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst t = state.tickets[j.chat_id];\nif (t && solved) { t.status = 'closed'; t.updated_at = Date.now(); }\nreturn { json: { ...j, desk_solved: solved, desk_error: solved ? null : r, ticket_known: !solved, ticket_id: solved ? null : j.ticket_id } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0197-0000-4000-8000-0000000000197",
      "name": "State: mark ticket closed (Spun closed it)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3380,
        1320
      ]
    },
    {
      "parameters": {},
      "id": "node-0198-0000-4000-8000-0000000000198",
      "name": "Nothing to close",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        3120,
        1540
      ]
    },
    {
      "parameters": {
        "jsCode": "// conversation.reopened from Spun -> reopen the mapped ticket (same loop guard as close).\nconst j = $input.item.json;\nconst d = j.data || {};\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst ticket = state.tickets[d.chat_id] || null;\nconst ours = d.reason === j.settings.desk_reopen_reason;\nconst reopen_in_desk = !!(ticket && ticket.ticket_id && ticket.status === 'closed' && !ours);\nreturn { json: { settings: j.settings, event_id: j.event_id, chat_id: d.chat_id, ticket_id: ticket ? ticket.ticket_id : null, reopen_in_desk, reason: d.reason || null } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0199-0000-4000-8000-0000000000199",
      "name": "Prepare reopen",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3640,
        1320
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0200-0000-4000-8000-0000000000200",
              "leftValue": "={{ $json.reopen_in_desk }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0201-0000-4000-8000-0000000000201",
      "name": "Reopen ticket in desk?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        3900,
        1320
      ]
    },
    {
      "parameters": {
        "method": "PUT",
        "url": "={{ $json.settings.freshdesk_base }}/api/v2/tickets/{{ $json.ticket_id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ status: 2 }) }}"
      },
      "id": "node-0202-0000-4000-8000-0000000000202",
      "name": "FRESHDESK: reopen ticket",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4160,
        1320
      ],
      "credentials": {
        "httpBasicAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Freshdesk API key (Basic Auth: API key as user, X as password)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const j = $('Reopen ticket in desk?').item.json;\n// Mark the mapped ticket \"open\" without touching the desk.\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst t = state.tickets[j.chat_id];\nif (t) { t.status = 'open'; t.updated_at = Date.now(); }\nreturn { json: { ...j, ticket_known: !!t, ticket_id: (t && t.ticket_id) || j.ticket_id || null } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0203-0000-4000-8000-0000000000203",
      "name": "State: mark ticket open (Spun reopened it)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4420,
        1320
      ]
    },
    {
      "parameters": {},
      "id": "node-0204-0000-4000-8000-0000000000204",
      "name": "Nothing to reopen",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        4160,
        1540
      ]
    },
    {
      "parameters": {
        "operation": "text",
        "binaryPropertyName": "data",
        "destinationKey": "rawBody",
        "options": {}
      },
      "id": "node-0233-0000-4000-8000-0000000000233",
      "name": "Reply raw body to text",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1,
      "position": [
        780,
        2200
      ]
    },
    {
      "parameters": {
        "jsCode": "// Freshdesk automation rule webhook (SETUP.md section 7). The body is the one typed into the rule:\n// {\"kind\": \"note\" | \"closed\" | \"reopened\", \"ticket_id\": \"{{ticket.id}}\"}.\n// Freshdesk does not sign webhooks. The rule sends a shared secret in a custom header,\n// X-Spun-Secret (n8n lowercases header names). Fail closed: nothing passes while\n// freshdesk_webhook_secret is still a placeholder.\nconst KINDS = { note: 'agent_reply', closed: 'ticket_closed', reopened: 'ticket_reopened' };\nconst out = [];\n$input.all().forEach((item, itemIndex) => {\n  const j = item.json;\n  const settings = j.settings;\n  const headers = j.headers || {};\n  const secret = String(settings.freshdesk_webhook_secret || '');\n  const secretSet = secret !== '' && !secret.startsWith('PASTE');\n  const header = String(headers['x-spun-secret'] || '');\n  let ok = secretSet && header.length === secret.length;\n  if (ok) {\n    // constant-time compare without a crypto import (the Code node has no require by default)\n    let diff = 0;\n    for (let i = 0; i < secret.length; i++) diff |= header.charCodeAt(i) ^ secret.charCodeAt(i);\n    ok = diff === 0;\n  }\n  let body = null;\n  try { body = JSON.parse(j.rawBody || ''); } catch (e) { body = null; }\n  const kindIn = body && body.kind ? String(body.kind) : '';\n  const ticket_id = body && /^\\d+$/.test(String(body.ticket_id || '').trim()) ? String(body.ticket_id).trim() : '';\n  let kind = 'ignore';\n  let why = null;\n  if (!secretSet) why = 'freshdesk_webhook_secret is not set in Settings';\n  else if (!ok) why = 'bad or missing X-Spun-Secret header';\n  else if (!body || !ticket_id) why = 'body is not the rule JSON from SETUP.md';\n  else if (!KINDS[kindIn]) why = 'unknown kind: ' + kindIn;\n  else kind = KINDS[kindIn];\n  out.push({\n    json: {\n      settings,\n      kind,\n      why,\n      event_type: kindIn || null,\n      ticket_id,\n      external_id: null,\n      updater_id: null,\n      // Freshdesk sends no delivery id; a retry 30 minutes later gets a new bucket, and the\n      // close/reopen path skips a ticket that is already in the target state.\n      desk_event_id: ticket_id + ':' + kindIn + ':' + Math.floor(Date.now() / 600000),\n      comment_id: null,\n      text: '',\n      is_public: false,\n      author_type: 'system',\n      author_id: null,\n    },\n    pairedItem: { item: itemIndex },\n  });\n});\nreturn out;"
      },
      "id": "node-0205-0000-4000-8000-0000000000205",
      "name": "FRESHDESK adapter: verify secret + read rule body",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1300,
        2200
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0206-0000-4000-8000-0000000000206",
                    "leftValue": "={{ $json.kind }}",
                    "rightValue": "agent_reply",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "reply"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0207-0000-4000-8000-0000000000207",
                    "leftValue": "={{ $json.kind }}",
                    "rightValue": "ticket_closed",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "closed"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0208-0000-4000-8000-0000000000208",
                    "leftValue": "={{ $json.kind }}",
                    "rightValue": "ticket_reopened",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "reopened"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra"
        }
      },
      "id": "node-0209-0000-4000-8000-0000000000209",
      "name": "Route by desk event",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3,
      "position": [
        1560,
        2200
      ]
    },
    {
      "parameters": {},
      "id": "node-0210-0000-4000-8000-0000000000210",
      "name": "Ignored desk event",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2340,
        2480
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.settings.freshdesk_base }}/api/v2/tickets/{{ $json.ticket_id }}/conversations",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          },
          "pagination": {
            "pagination": {
              "paginationMode": "updateAParameterInEachRequest",
              "parameters": {
                "parameters": [
                  {
                    "type": "qs",
                    "name": "page",
                    "value": "={{ $pageCount + 1 }}"
                  }
                ]
              },
              "paginationCompleteWhen": "other",
              "completeExpression": "={{ !Array.isArray($response.body) || $response.body.length < 100 }}",
              "limitPagesFetched": true,
              "maxRequests": 20
            }
          }
        },
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "per_page",
              "value": "100"
            }
          ]
        }
      },
      "id": "node-0235-0000-4000-8000-0000000000235",
      "name": "FRESHDESK: read conversations",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1820,
        1980
      ],
      "credentials": {
        "httpBasicAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Freshdesk API key (Basic Auth: API key as user, X as password)"
        }
      },
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "jsCode": "// Shared helper used by the desk adapters: HTML comment body -> WhatsApp text.\nfunction htmlToText(html) {\n  return String(html || '')\n    .replace(/<br\\s*\\/?>/gi, '\\n')\n    .replace(/<\\/(p|div|li|h[1-6]|tr)>/gi, '\\n')\n    .replace(/<a [^>]*href=\"([^\"]+)\"[^>]*>(.*?)<\\/a>/gi, (m, href, label) => (label && label !== href ? label + ' (' + href + ')' : href))\n    .replace(/<[^>]+>/g, '')\n    .replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '\"').replace(/&#39;/g, \"'\")\n    .replace(/\\n{3,}/g, '\\n\\n')\n    .trim();\n}\n// Freshdesk conversations of the ticket (notes and replies), read 100 per page from page 1 by the\n// node's pagination; the sort order is not documented, so sort here. The node may hand over one\n// item per conversation or one item per page; both are accepted. Keep the public, non-incoming\n// notes (source 2) and replies (source 0) whose author is not the integration agent, created since\n// the workflow started tracking this ticket (at most dedupe_hours back), or within\n// reply_window_minutes for a ticket it does not track. Oldest first. The Reply filter then drops\n// anything already forwarded, and the conversation id becomes the idempotency key.\nconst ev = $('FRESHDESK adapter: verify secret + read rule body').first().json;\nconst s = ev.settings;\nconst state = $getWorkflowStaticData('global');\nconst since = (state.freshdeskSince || {})[ev.ticket_id];\nconst now = Date.now();\nconst lower = since\n  ? Math.max(now - (Number(s.dedupe_hours) || 24) * 3600 * 1000, Number(since) - 2 * 60 * 1000)\n  : now - (Number(s.reply_window_minutes) || 15) * 60 * 1000;\nconst convs = [];\nconst failed = [];\nfor (const item of $input.all()) {\n  const r = item.json;\n  if (Array.isArray(r)) convs.push(...r);\n  else if (r && Array.isArray(r.body)) convs.push(...r.body);\n  else if (r && r.id !== undefined && typeof r.body === 'string') convs.push(r);\n  else if (r && Object.keys(r).length) failed.push(r);\n}\nconst seen = new Set();\nconst picked = convs\n  .filter((c) => {\n    const key = String(c.id);\n    if (seen.has(key)) return false;\n    seen.add(key);\n    return c.private === false && c.incoming === false && [0, 2].includes(Number(c.source))\n      && String(c.user_id) !== String(s.desk_integration_user_id) && Date.parse(c.created_at) >= lower;\n  })\n  .sort((a, b) => (Date.parse(a.created_at) - Date.parse(b.created_at)) || (Number(a.id) - Number(b.id)));\nconst out = picked.map((c) => ({ json: {\n  ...ev,\n  kind: 'agent_reply',\n  comment_id: 'freshdesk:' + String(c.id),\n  text: c.body ? htmlToText(c.body) : String(c.body_text || '').trim(),\n  is_public: true,\n  author_type: 'agent',\n  author_id: String(c.user_id),\n  desk_event_id: 'conversation:' + String(c.id),\n}, pairedItem: { item: 0 } }));\nif (!out.length) {\n  const why = failed.length && !convs.length ? 'reading conversations failed: ' + JSON.stringify(failed[0]).slice(0, 300) : 'no new public agent note in the window';\n  out.push({ json: { ...ev, kind: 'agent_reply', comment_id: null, text: '', is_public: true, author_type: 'system', why }, pairedItem: { item: 0 } });\n}\nreturn out;"
      },
      "id": "node-0236-0000-4000-8000-0000000000236",
      "name": "FRESHDESK adapter: pick agent notes",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2080,
        1980
      ]
    },
    {
      "parameters": {
        "jsCode": "// Loop guards 1 and 3 on the desk side, then resolve the chat id for the ticket.\n// Passes a reply only when: public, written by an agent, not by the integration user,\n// not starting with the inbound prefix (our own echo), and not forwarded before.\nconst j = $input.item.json;\nconst s = j.settings;\nconst state = $getWorkflowStaticData('global');\nstate.sentComments ??= {};\nstate.tickets ??= {};\nlet forward = true;\nlet reason = null;\nif (!j.is_public) { forward = false; reason = 'private note'; }\nelse if (j.author_type !== 'agent') { forward = false; reason = 'not an agent'; }\nelse if (s.desk_integration_user_id && String(j.author_id) === String(s.desk_integration_user_id)) { forward = false; reason = 'written by the integration user'; }\nelse if (String(j.text || '').trim().startsWith(s.inbound_prefix)) { forward = false; reason = 'echo of an inbound message'; }\nelse if (!j.comment_id) { forward = false; reason = 'no comment id'; }\nelse if (state.sentComments[j.comment_id]) { forward = false; reason = 'already forwarded'; }\nelse if (!String(j.text || '').trim()) { forward = false; reason = 'empty text'; }\nlet chat_id = null;\nfor (const [cid, t] of Object.entries(state.tickets)) if (String(t.ticket_id) === String(j.ticket_id)) chat_id = cid;\nreturn { json: { ...j, forward, filter_reason: reason, chat_id } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0211-0000-4000-8000-0000000000211",
      "name": "Reply filter",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2340,
        1980
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0212-0000-4000-8000-0000000000212",
              "leftValue": "={{ $json.forward }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0213-0000-4000-8000-0000000000213",
      "name": "Forward this reply?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2600,
        1980
      ]
    },
    {
      "parameters": {},
      "id": "node-0214-0000-4000-8000-0000000000214",
      "name": "Reply filtered out",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2860,
        2200
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0215-0000-4000-8000-0000000000215",
              "leftValue": "={{ !!$json.chat_id }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0216-0000-4000-8000-0000000000216",
      "name": "Chat id resolved?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2860,
        1900
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.settings.freshdesk_base }}/api/v2/tickets/{{ $json.ticket_id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        }
      },
      "id": "node-0217-0000-4000-8000-0000000000217",
      "name": "FRESHDESK: read ticket (find chat id)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3120,
        2020
      ],
      "credentials": {
        "httpBasicAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Freshdesk API key (Basic Auth: API key as user, X as password)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Freshdesk ticket object: the workflow stores the Spun chat id in the ticket field named by\n// freshdesk_chat_id_field and tags the ticket \"spun\". A ticket without both was not created by\n// this workflow.\nconst r = $input.item.json;\nconst j = $('Chat id resolved?').item.json;\nconst field = String(j.settings.freshdesk_chat_id_field || '');\nconst t = r && r.id ? r : null;\nconst tags = t && Array.isArray(t.tags) ? t.tags.map((x) => String(x).toLowerCase()) : [];\nconst cid = t && t.custom_fields && t.custom_fields[field] && tags.includes('spun') ? String(t.custom_fields[field]) : null;\nif (!cid) throw new Error('Freshdesk ticket ' + j.ticket_id + ' has no Spun chat id in ' + field + ' or no \"spun\" tag; was it created by this workflow?');\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nstate.tickets[cid] ??= { ticket_id: String(j.ticket_id), status: Number(t.status) === 5 ? 'closed' : 'open', updated_at: Date.now() };\nif (String(state.tickets[cid].ticket_id) === String(j.ticket_id)) {\n  // Agent notes on a ticket found again count from now, as for a ticket found on a customer message.\n  state.freshdeskSince ??= {};\n  state.freshdeskSince[j.ticket_id] ??= Date.now();\n}\nreturn { json: { ...j, chat_id: cid } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0218-0000-4000-8000-0000000000218",
      "name": "Read chat id from ticket",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3380,
        2020
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Chat id resolved?').item.json.settings.spun_api_base }}/send-message",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ to: $('Chat id resolved?').item.json.chat_id, text: $('Chat id resolved?').item.json.text, idempotency_key: String($('Chat id resolved?').item.json.comment_id).slice(0, 200) }) }}"
      },
      "id": "node-0219-0000-4000-8000-0000000000219",
      "name": "SPUN: send-message",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3640,
        1900
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Spun API key (Header Auth: Authorization = Bearer wap_...)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Remember our own send (loop guard 2) and the forwarded comment (loop guard 3).\nconst r = $input.item.json;\nconst j = $('Chat id resolved?').item.json;\nconst state = $getWorkflowStaticData('global');\nstate.sentMessages ??= {};\nstate.sentComments ??= {};\nconst now = Date.now();\nif (r.message_id) state.sentMessages[r.message_id] = now;\nif (j.comment_id) state.sentComments[j.comment_id] = now;\nconst ttl = (Number(j.settings.dedupe_hours) || 24) * 3600 * 1000;\nfor (const k of Object.keys(state.sentMessages)) if (now - state.sentMessages[k] > ttl) delete state.sentMessages[k];\nfor (const k of Object.keys(state.sentComments)) if (now - state.sentComments[k] > ttl) delete state.sentComments[k];\nreturn { json: { ...j, spun_response: r, sent: !!r.message_id, queued: !!r.queued, queue_id: r.queue_id || null, error: r.error || null } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0220-0000-4000-8000-0000000000220",
      "name": "State: remember sent message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3900,
        1900
      ]
    },
    {
      "parameters": {
        "jsCode": "// Ticket closed in the desk -> close the conversation in Spun (needs the manage_conversations\n// scope and close_in_spun_when_desk_closes = true in the Settings node).\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nlet chat_id = null;\nfor (const [cid, t] of Object.entries(state.tickets)) if (String(t.ticket_id) === String(j.ticket_id)) chat_id = cid;\nconst t = chat_id ? state.tickets[chat_id] : null;\nconst act = !!(chat_id && t && t.status !== 'closed' && j.settings.close_in_spun_when_desk_closes);\nreturn { json: { ...j, chat_id, act, idempotency_key: ('desk-close-' + j.desk_event_id).slice(0, 200) } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0221-0000-4000-8000-0000000000221",
      "name": "Prepare Spun close",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2340,
        2640
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0222-0000-4000-8000-0000000000222",
              "leftValue": "={{ $json.act }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0223-0000-4000-8000-0000000000223",
      "name": "Close in Spun?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2600,
        2640
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.settings.spun_api_base }}/conversations/close",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ chat_id: $json.chat_id, reason: $json.settings.desk_close_reason, idempotency_key: $json.idempotency_key }) }}"
      },
      "id": "node-0224-0000-4000-8000-0000000000224",
      "name": "SPUN: close conversation",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2860,
        2640
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Spun API key (Header Auth: Authorization = Bearer wap_...)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Mark the ticket \"closed\" after the Spun call (desk-initiated change).\nconst j = $('Close in Spun?').item.json;\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst t = j.chat_id ? state.tickets[j.chat_id] : null;\nif (t) { t.status = 'closed'; t.updated_at = Date.now(); }\nreturn { json: { ...j, spun_response: $input.item.json } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0225-0000-4000-8000-0000000000225",
      "name": "State: mark ticket closed (desk)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3120,
        2640
      ]
    },
    {
      "parameters": {},
      "id": "node-0226-0000-4000-8000-0000000000226",
      "name": "Not closing in Spun",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2860,
        2860
      ]
    },
    {
      "parameters": {
        "jsCode": "// Ticket reopened in the desk -> reopen the conversation in Spun (same requirements as close).\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nlet chat_id = null;\nfor (const [cid, t] of Object.entries(state.tickets)) if (String(t.ticket_id) === String(j.ticket_id)) chat_id = cid;\nconst t = chat_id ? state.tickets[chat_id] : null;\nconst act = !!(chat_id && t && t.status === 'closed' && j.settings.close_in_spun_when_desk_closes);\nreturn { json: { ...j, chat_id, act, idempotency_key: ('desk-reopen-' + j.desk_event_id).slice(0, 200) } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0227-0000-4000-8000-0000000000227",
      "name": "Prepare Spun reopen",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3380,
        2640
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0228-0000-4000-8000-0000000000228",
              "leftValue": "={{ $json.act }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0229-0000-4000-8000-0000000000229",
      "name": "Reopen in Spun?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        3640,
        2640
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.settings.spun_api_base }}/conversations/reopen",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ chat_id: $json.chat_id, reason: $json.settings.desk_reopen_reason, idempotency_key: $json.idempotency_key }) }}"
      },
      "id": "node-0230-0000-4000-8000-0000000000230",
      "name": "SPUN: reopen conversation",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3900,
        2640
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Spun API key (Header Auth: Authorization = Bearer wap_...)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Mark the ticket \"open\" after the Spun call (desk-initiated change).\nconst j = $('Reopen in Spun?').item.json;\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst t = j.chat_id ? state.tickets[j.chat_id] : null;\nif (t) { t.status = 'open'; t.updated_at = Date.now(); }\nreturn { json: { ...j, spun_response: $input.item.json } };",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0231-0000-4000-8000-0000000000231",
      "name": "State: mark ticket open (desk)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4160,
        2640
      ]
    },
    {
      "parameters": {},
      "id": "node-0232-0000-4000-8000-0000000000232",
      "name": "Not reopening in Spun",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        3900,
        2860
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0901-0000-4000-8000-000000000901",
                    "leftValue": "={{ $json.contact_lane }}",
                    "rightValue": "phone",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "phone"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "id": "cond-0901-0001-4000-8000-000000000901",
                    "leftValue": "={{ $json.contact_lane }}",
                    "rightValue": "create",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "create"
            }
          ]
        },
        "options": {}
      },
      "id": "node-0901-0000-4000-8000-000000000901",
      "name": "Contact search lane",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3,
      "position": [
        2210,
        1000
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Every lane that found nobody ends here, so the create call reads one node.\nreturn { json: $input.item.json };"
      },
      "id": "node-0902-0000-4000-8000-000000000902",
      "name": "Contact to create",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3770,
        1460
      ]
    }
  ],
  "connections": {
    "Spun events": {
      "main": [
        [
          {
            "node": "Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Helpdesk reply": {
      "main": [
        [
          {
            "node": "Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Settings": {
      "main": [
        [
          {
            "node": "Route by trigger",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by trigger": {
      "main": [
        [
          {
            "node": "Raw body to text",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Reply raw body to text",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Raw body to text": {
      "main": [
        [
          {
            "node": "HMAC of raw body",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HMAC of raw body": {
      "main": [
        [
          {
            "node": "Check signature + parse",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check signature + parse": {
      "main": [
        [
          {
            "node": "Signature valid?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Signature valid?": {
      "main": [
        [
          {
            "node": "Ack 200",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Reject 401",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ack 200": {
      "main": [
        [
          {
            "node": "State: seen before?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "State: seen before?": {
      "main": [
        [
          {
            "node": "New delivery?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "New delivery?": {
      "main": [
        [
          {
            "node": "Route by event",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Duplicate, ignored",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by event": {
      "main": [
        [
          {
            "node": "Prepare message",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Own send or ticket?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare close",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare reopen",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Other event, ignored",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare message": {
      "main": [
        [
          {
            "node": "Handle this message?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Handle this message?": {
      "main": [
        [
          {
            "node": "State: lookup contact + ticket",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Skipped (group or newsletter)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "State: lookup contact + ticket": {
      "main": [
        [
          {
            "node": "Contact known?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Contact known?": {
      "main": [
        [
          {
            "node": "Open ticket known?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Contact search lane",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "FRESHDESK: find contact by phone": {
      "main": [
        [
          {
            "node": "Pick matching contact (phone)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick matching contact (phone)": {
      "main": [
        [
          {
            "node": "Contact found by phone?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Contact found by phone?": {
      "main": [
        [
          {
            "node": "State: remember contact",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "FRESHDESK: find contact by mobile",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "FRESHDESK: find contact by mobile": {
      "main": [
        [
          {
            "node": "Pick matching contact (mobile)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick matching contact (mobile)": {
      "main": [
        [
          {
            "node": "Contact found by mobile?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Contact found by mobile?": {
      "main": [
        [
          {
            "node": "State: remember contact",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Contact to create",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "FRESHDESK: create contact": {
      "main": [
        [
          {
            "node": "Read new contact id",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read new contact id": {
      "main": [
        [
          {
            "node": "State: remember contact",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "State: remember contact": {
      "main": [
        [
          {
            "node": "Open ticket known?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Open ticket known?": {
      "main": [
        [
          {
            "node": "FRESHDESK: read ticket status",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "FRESHDESK: list tickets of requester",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "FRESHDESK: read ticket status": {
      "main": [
        [
          {
            "node": "Ticket still open?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ticket still open?": {
      "main": [
        [
          {
            "node": "Still open?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Still open?": {
      "main": [
        [
          {
            "node": "Prepare note",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "State: mark ticket closed (desk closed it)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "State: mark ticket closed (desk closed it)": {
      "main": [
        [
          {
            "node": "FRESHDESK: list tickets of requester",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "FRESHDESK: list tickets of requester": {
      "main": [
        [
          {
            "node": "Pick open ticket",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick open ticket": {
      "main": [
        [
          {
            "node": "Open ticket found?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Open ticket found?": {
      "main": [
        [
          {
            "node": "State: remember found ticket",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "FRESHDESK: create ticket",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "State: remember found ticket": {
      "main": [
        [
          {
            "node": "Prepare note",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare note": {
      "main": [
        [
          {
            "node": "FRESHDESK: append message as note",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "FRESHDESK: create ticket": {
      "main": [
        [
          {
            "node": "Read new ticket id",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read new ticket id": {
      "main": [
        [
          {
            "node": "State: remember new ticket",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "FRESHDESK: append message as note": {
      "main": [
        [
          {
            "node": "State: remember message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "State: remember message": {
      "main": [
        [
          {
            "node": "Set ticket back to open?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set ticket back to open?": {
      "main": [
        [
          {
            "node": "FRESHDESK: set ticket open",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Status unchanged",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Own send or ticket?": {
      "main": [
        [
          {
            "node": "Echo of our own reply?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Echo of our own reply?": {
      "main": [
        [
          {
            "node": "Echo, nothing to add",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Ticket known for this chat?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ticket known for this chat?": {
      "main": [
        [
          {
            "node": "FRESHDESK: add private note (sent from Spun)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "No ticket, ignored",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare close": {
      "main": [
        [
          {
            "node": "Close ticket in desk?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Close ticket in desk?": {
      "main": [
        [
          {
            "node": "FRESHDESK: close ticket",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Nothing to close",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "FRESHDESK: close ticket": {
      "main": [
        [
          {
            "node": "State: mark ticket closed (Spun closed it)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare reopen": {
      "main": [
        [
          {
            "node": "Reopen ticket in desk?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reopen ticket in desk?": {
      "main": [
        [
          {
            "node": "FRESHDESK: reopen ticket",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Nothing to reopen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "FRESHDESK: reopen ticket": {
      "main": [
        [
          {
            "node": "State: mark ticket open (Spun reopened it)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reply raw body to text": {
      "main": [
        [
          {
            "node": "FRESHDESK adapter: verify secret + read rule body",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "FRESHDESK adapter: verify secret + read rule body": {
      "main": [
        [
          {
            "node": "Route by desk event",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by desk event": {
      "main": [
        [
          {
            "node": "FRESHDESK: read conversations",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare Spun close",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare Spun reopen",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Ignored desk event",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "FRESHDESK: read conversations": {
      "main": [
        [
          {
            "node": "FRESHDESK adapter: pick agent notes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "FRESHDESK adapter: pick agent notes": {
      "main": [
        [
          {
            "node": "Reply filter",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reply filter": {
      "main": [
        [
          {
            "node": "Forward this reply?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Forward this reply?": {
      "main": [
        [
          {
            "node": "Chat id resolved?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Reply filtered out",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Chat id resolved?": {
      "main": [
        [
          {
            "node": "SPUN: send-message",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "FRESHDESK: read ticket (find chat id)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "FRESHDESK: read ticket (find chat id)": {
      "main": [
        [
          {
            "node": "Read chat id from ticket",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read chat id from ticket": {
      "main": [
        [
          {
            "node": "SPUN: send-message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SPUN: send-message": {
      "main": [
        [
          {
            "node": "State: remember sent message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Spun close": {
      "main": [
        [
          {
            "node": "Close in Spun?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Close in Spun?": {
      "main": [
        [
          {
            "node": "SPUN: close conversation",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Not closing in Spun",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SPUN: close conversation": {
      "main": [
        [
          {
            "node": "State: mark ticket closed (desk)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Spun reopen": {
      "main": [
        [
          {
            "node": "Reopen in Spun?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reopen in Spun?": {
      "main": [
        [
          {
            "node": "SPUN: reopen conversation",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Not reopening in Spun",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SPUN: reopen conversation": {
      "main": [
        [
          {
            "node": "State: mark ticket open (desk)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Contact search lane": {
      "main": [
        [
          {
            "node": "FRESHDESK: find contact by phone",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Contact to create",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Contact to create": {
      "main": [
        [
          {
            "node": "FRESHDESK: create contact",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "saveManualExecutions": true,
    "saveExecutionProgress": true
  },
  "staticData": null,
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false,
    "instanceId": "REPLACED_ON_IMPORT"
  }
}
