{
  "name": "Spun helpdesk bridge: Jira Service Management",
  "nodes": [
    {
      "parameters": {
        "content": "## Spun helpdesk bridge: Jira Service Management\nWhatsApp conversations in Spun become Jira Service Management requests; public agent comments on the request 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 \"Jira API token\" (Basic Auth: the integration account's email as user, a classic API token as password). Select them on every SPUN: and JIRA: node. The token stays in this n8n; Spun never sees it.\n2. Settings node: replace the PASTE_... values (site URL, service desk id, request type id, project key, WhatsApp number field id, the integration account's accountId, resolve transition id, the Jira webhook secret; the reopen transition and resolution name are optional).\n3. Replace PASTE-A-LONG-RANDOM-SUFFIX in the \"Helpdesk reply\" path with a long random string (keep the jira-reply- prefix).\n4. Activate, create the Jira webhook (SETUP.md), then the Spun webhook subscription pointing at \"Spun events\".\n\nJira shapes come from ./API-NOTES.md (public docs, not yet live-tested).",
        "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. Who the request is from\nNo customer accounts: the integration account raises every request (reporter) and writes every comment, because the API cannot author a comment as someone else. The WhatsApp name and number go in the summary and the comment prefix, and the number (E.164) goes in the \"WhatsApp number\" field.",
        "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. Request (moves 2 and 3)\nOwn map (chat_id -> issue key) first. A known request is re-read once: when it is done, the optional reopen transition runs, otherwise a NEW request is opened. Fallback: JQL on the WhatsApp number field plus an exact compare, then create. First message = request description; later messages = public comments. Media arrives as a link (valid 24 h) or as \"still processing\".",
        "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 an INTERNAL comment (loop guard 1).\nconversation.closed / conversation.reopened: run the resolve or reopen transition on the mapped request, unless the reason is the one this workflow itself sends (loop guard on the close path). The map changes only when Jira answers 2xx.",
        "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 (Jira side)\nA Jira admin webhook (Comment: created, optional Issue: updated, JQL filter on the project) calls this webhook, which answers 200 at once. Jira signs the body: X-Hub-Signature = sha256=<hex HMAC-SHA256 of the raw body>. The Crypto node computes it, the adapter compares it in constant time and drops everything while the secret is still a placeholder.\nA comment passes when it is public (jsdPublic, or \"public\" from the JSM comment read when the payload lacks it), written by a licensed Atlassian account that is not the integration account, and does not start with the inbound prefix. POST /send-message uses \"jsm-\" + comment id as idempotency_key. A request missing from the map is resolved through its WhatsApp number field.",
        "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 JIRA: 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": "jira-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.\n// The settings object is copied onto every item so that later nodes read\n// $json.settings.<name> without depending on which trigger fired.\n// jira_phone_field_id is required: new requests carry the WhatsApp number there, and both the\n// fallback request lookup and the reply path for unmapped requests read it back.\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  \"jira_base\": \"https://PASTE_YOUR_SITE.atlassian.net\",\n  \"jira_service_desk_id\": \"PASTE_THE_SERVICE_DESK_ID\",\n  \"jira_request_type_id\": \"PASTE_THE_REQUEST_TYPE_ID\",\n  \"jira_project_key\": \"PASTE_THE_PROJECT_KEY\",\n  \"jira_phone_field_id\": \"PASTE_THE_WHATSAPP_NUMBER_FIELD_ID_LIKE_customfield_10050\",\n  // Optional \"Spun LID\" field id: the privacy id of a chat whose phone WhatsApp hides. Without\n  // it such a chat still gets a request, but a reset chat map cannot find that request again and\n  // an agent reply on an unmapped request cannot be routed (SETUP.md section 3).\n  \"jira_lid_field_id\": \"PASTE_OPTIONAL_SPUN_LID_FIELD_ID_LIKE_customfield_10051\",\n  \"jira_resolve_transition_id\": \"PASTE_THE_RESOLVE_TRANSITION_ID\",\n  \"jira_resolution_name\": \"PASTE_OPTIONAL_RESOLUTION_NAME_IF_THE_RESOLVE_SCREEN_ASKS_FOR_ONE\",\n  \"jira_reopen_transition_id\": \"PASTE_OPTIONAL_REOPEN_TRANSITION_ID\",\n  \"jira_webhook_secret\": \"PASTE_THE_SECRET_OF_THE_JIRA_WEBHOOK\",\n  \"desk_integration_user_id\": \"PASTE_THE_ACCOUNT_ID_OF_THE_INTEGRATION_ACCOUNT\",\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_jira\",\n  \"desk_reopen_reason\": \"reopened_in_jira\",\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}));\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": "jira-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\n// the fallback (search indexes lag by minutes on most desks).\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.contacts ??= {};\nstate.tickets ??= {};\nconst contact_id = state.contacts[j.phone] || null;\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, 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-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": {
        "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": "// 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": {},
      "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": {},
      "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": {},
      "id": "node-0204-0000-4000-8000-0000000000204",
      "name": "Nothing to reopen",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        4160,
        1540
      ]
    },
    {
      "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": {
        "jsCode": "// Ticket closed in the desk -> close the conversation in Spun (needs the manage_conversations\n// scope and a Spun server release that includes POST /conversations/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-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": {
        "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": {
        "action": "hmac",
        "type": "SHA256",
        "value": "={{ $json.rawBody }}",
        "dataPropertyName": "computedHmac",
        "secret": "={{ $json.settings.jira_webhook_secret }}",
        "encoding": "hex"
      },
      "id": "node-0234-0000-4000-8000-0000000000234",
      "name": "HMAC of reply body",
      "type": "n8n-nodes-base.crypto",
      "typeVersion": 1,
      "position": [
        1040,
        2200
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.settings.jira_base }}/rest/servicedeskapi/request/{{ encodeURIComponent($json.ticket_id) }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "json"
            }
          }
        }
      },
      "id": "node-0301-0000-4000-8000-0000000000301",
      "name": "JIRA: read request status",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2600,
        440
      ],
      "credentials": {
        "httpBasicAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Jira API token (Basic Auth: integration account email + API token)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// JSM request: currentStatus.statusCategory is UNDEFINED, NEW, INDETERMINATE or DONE. Compared\n// case-insensitively against \"done\", never on status names (they differ per workflow). A 404\n// (deleted, or moved to another project) counts as not open. Any other failure (429, 5xx, 401)\n// fails the execution instead of opening a duplicate request.\nconst r = $input.item.json;\nconst j = $('Open ticket known?').item.json;\nconst code = Number(r && r.statusCode);\nif (code !== 404 && (!(code >= 200 && code < 300) || !r.body || typeof r.body !== 'object')) {\n  throw new Error('Reading Jira request ' + j.ticket_id + ' failed: ' + JSON.stringify(r && r.body !== undefined ? r.body : r).slice(0, 500));\n}\nconst cat = code === 404 ? '' : String((r.body.currentStatus && r.body.currentStatus.statusCategory) || '').toLowerCase();\nconst still_open = code !== 404 && cat !== 'done';\nconst can_reopen = !still_open && code !== 404 && /^\\d+$/.test(String(j.settings.jira_reopen_transition_id || ''));\nreturn { json: { ...j, still_open, can_reopen, ticket_status: cat || null } };\n",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0302-0000-4000-8000-0000000000302",
      "name": "Request still open?",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2860,
        440
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0303-0000-4000-8000-0000000000303",
              "leftValue": "={{ $json.can_reopen }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0304-0000-4000-8000-0000000000304",
      "name": "Reopen on new message?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        3380,
        640
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.settings.jira_base }}/rest/api/3/issue/{{ encodeURIComponent($json.ticket_id) }}/transitions",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "text"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ transition: { id: String($json.settings.jira_reopen_transition_id) } }) }}"
      },
      "id": "node-0305-0000-4000-8000-0000000000305",
      "name": "JIRA: reopen request (new message)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3640,
        560
      ],
      "credentials": {
        "httpBasicAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Jira API token (Basic Auth: integration account email + API token)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// The transitions call answers 204 with an empty body. Anything else (400: transition not\n// available from the current status, 409, 413 per-issue limit) means a new request instead.\nconst r = $input.item.json;\nconst j = $('Request still open?').item.json;\nconst code = Number(r && r.statusCode);\nconst reopened = code >= 200 && code < 300;\nreturn { json: { ...j, reopened, ticket_known: reopened, desk_error: reopened ? null : { statusCode: code, body: r && r.body } } };\n",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0306-0000-4000-8000-0000000000306",
      "name": "Reopened for the new message?",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3900,
        560
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0307-0000-4000-8000-0000000000307",
              "leftValue": "={{ $json.reopened }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0308-0000-4000-8000-0000000000308",
      "name": "Reopen worked?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        4160,
        560
      ]
    },
    {
      "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": [
        3640,
        800
      ]
    },
    {
      "parameters": {
        "jsCode": "// Fallback lookup when the map has no open request for this chat: JQL on the WhatsApp number\n// field, bounded by the project. Text fields only support \"~\" (a tokenized contains), so \"Pick\n// open request\" re-checks the exact number. reconcileIssues makes the search see the request this\n// chat had before even when the search index lags.\n// A privacy-ID (LID) chat with no known phone searches the optional Spun LID field instead. With\n// that field unset there is nothing to search on, so the request is created (search_body null):\n// the chat map is the only dedupe for such a chat.\nconst j = $input.item.json;\nconst s = j.settings;\nconst field = String(s.jira_phone_field_id || '');\nconst m = field.match(/^customfield_(\\d+)$/);\nif (!m) throw new Error('Set jira_phone_field_id in the Settings node to the id of the WhatsApp number field, like customfield_10050 (SETUP.md section 3).');\nconst project = String(s.jira_project_key || '');\nif (!/^[A-Z][A-Z0-9_]+$/.test(project)) throw new Error('Set jira_project_key in the Settings node to the project key, like SUP (SETUP.md section 10).');\nconst lidField = String(s.jira_lid_field_id || '');\nconst lm = lidField.match(/^customfield_(\\d+)$/);\nif (!j.phone && !lm) return { json: { ...j, search_body: null, search_field: null, search_value: null } };\nconst search_field = j.phone ? field : lidField;\nconst search_value = j.phone ? String(j.phone).replace(/[^+\\d]/g, '') : String(j.lid || '').replace(/\\D/g, '');\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst prev = state.tickets[j.chat_id];\nconst search_body = {\n  jql: 'project = \"' + project + '\" AND cf[' + (j.phone ? m[1] : lm[1]) + '] ~ \"\\\\\"' + search_value + '\\\\\"\" AND statusCategory != Done ORDER BY created DESC',\n  fields: [search_field, 'status', 'created'],\n  maxResults: 20,\n};\nif (prev && /^\\d+$/.test(String(prev.issue_id || ''))) search_body.reconcileIssues = [Number(prev.issue_id)];\nreturn { json: { ...j, search_body, search_field, search_value } };\n",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0309-0000-4000-8000-0000000000309",
      "name": "Prepare request search",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3900,
        800
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.settings.jira_base }}/rest/api/3/search/jql",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.search_body) }}"
      },
      "id": "node-0310-0000-4000-8000-0000000000310",
      "name": "JIRA: find open request by phone",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4160,
        800
      ],
      "credentials": {
        "httpBasicAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Jira API token (Basic Auth: integration account email + API token)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Exact compare on the returned WhatsApp number field, or the Spun LID field for a privacy-ID\n// chat with no known phone (the JQL \"~\" is a contains search), not\n// done, newest first. A failed search fails the execution instead of opening a duplicate request.\nconst r = $input.item.json;\nconst j = { ...$('Prepare request search').item.json };\nconst field = String(j.search_field);\nconst want = String(j.search_value);\ndelete j.search_body;\ndelete j.search_field;\ndelete j.search_value;\nconst code = Number(r && r.statusCode);\nif (!(code >= 200 && code < 300) || !r.body || !Array.isArray(r.body.issues)) {\n  throw new Error('Jira request search failed: ' + JSON.stringify(r && r.body !== undefined ? r.body : r).slice(0, 500));\n}\nconst done = (i) => !!(i.fields.status && i.fields.status.statusCategory && String(i.fields.status.statusCategory.key || '').toLowerCase() === 'done');\nconst ours = r.body.issues\n  .filter((i) => i && i.fields && typeof i.fields[field] === 'string' && i.fields[field].trim() === want && !done(i))\n  .sort((a, b) => Number(b.id) - Number(a.id));\nconst hit = ours[0] || null;\nreturn { json: { ...j, ticket_id: hit ? String(hit.key) : null, issue_id: hit ? String(hit.id) : null, ticket_known: !!hit } };\n",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0311-0000-4000-8000-0000000000311",
      "name": "Pick open request",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4420,
        800
      ]
    },
    {
      "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": [
        4680,
        800
      ]
    },
    {
      "parameters": {
        "jsCode": "// Write the chat_id -> request map entry (status \"open\"). ticket_id is the issue key; issue_id\n// feeds reconcileIssues in a later fallback search.\nconst j = $input.item.json;\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nif (j.chat_id && j.ticket_id) {\n  const prev = state.tickets[j.chat_id];\n  const issue_id = j.issue_id || (prev && String(prev.ticket_id) === String(j.ticket_id) ? prev.issue_id : null) || null;\n  state.tickets[j.chat_id] = { ticket_id: String(j.ticket_id), issue_id: issue_id ? String(issue_id) : null, status: 'open', updated_at: Date.now() };\n}\nreturn { json: { ...j, ticket_known: true } };\n",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0179-0000-4000-8000-0000000000179",
      "name": "State: remember found ticket",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4940,
        560
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Open ticket known?').item.json.settings.jira_base }}/rest/servicedeskapi/request/{{ encodeURIComponent($json.ticket_id) }}/comment",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "options": {
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ body: String($('Open ticket known?').item.json.comment_text).slice(0, 32000), public: true }) }}"
      },
      "id": "node-0312-0000-4000-8000-0000000000312",
      "name": "JIRA: append message as comment",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        5200,
        440
      ],
      "credentials": {
        "httpBasicAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Jira API token (Basic Auth: integration account email + API token)"
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2500,
      "notes": "Retries twice, 2.5 s apart (Jira: 20 writes per request per 2 seconds). Fails the execution after the third try."
    },
    {
      "parameters": {
        "jsCode": "// Bookkeeping after an append: last activity on the request.\n// JSM answers 201 {id, body, public, author, ...}. The append node itself fails the execution after\n// its third try (429 from the per-issue limit, 403 when the integration account is not an agent on\n// the project); this check catches a 2xx without a comment id. Either way the message is still in Spun.\nconst j = $('Open ticket known?').item.json;\nconst r = $input.item.json;\nif (!r || !r.id) throw new Error('Jira did not accept the comment for chat ' + j.chat_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, desk_response: r } };\n",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0313-0000-4000-8000-0000000000313",
      "name": "State: remember message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5460,
        440
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Open ticket known?').item.json.settings.jira_base }}/rest/servicedeskapi/request",
        "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 v = { summary: String(j.subject).slice(0, 250), description: String(j.comment_text).slice(0, 32000) }; if (j.phone) v[String(s.jira_phone_field_id)] = String(j.phone); if (j.lid && /^customfield_\\d+$/.test(String(s.jira_lid_field_id || ''))) v[String(s.jira_lid_field_id)] = String(j.lid); return JSON.stringify({ serviceDeskId: String(s.jira_service_desk_id), requestTypeId: String(s.jira_request_type_id), requestFieldValues: v }); })() }}"
      },
      "id": "node-0314-0000-4000-8000-0000000000314",
      "name": "JIRA: create request",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4940,
        1000
      ],
      "credentials": {
        "httpBasicAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Jira API token (Basic Auth: integration account email + API token)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// JSM answers 201 {issueId, issueKey, currentStatus, _links{web, agent}, ...}.\nconst r = $input.item.json;\nconst j = $('Open ticket known?').item.json;\nconst ticket_id = r && r.issueKey ? String(r.issueKey) : null;\nif (!ticket_id) throw new Error('Jira request create failed: ' + JSON.stringify(r).slice(0, 500));\nreturn { json: { ...j, ticket_id, issue_id: r.issueId ? String(r.issueId) : null, ticket_url: (r._links && r._links.agent) || (j.settings.jira_base + '/browse/' + ticket_id) } };\n",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0315-0000-4000-8000-0000000000315",
      "name": "Read new request key",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5200,
        1000
      ]
    },
    {
      "parameters": {
        "jsCode": "// Write the chat_id -> request 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), issue_id: j.issue_id ? String(j.issue_id) : null, status: 'open', updated_at: Date.now() };\n}\nreturn { json: { ...j, ticket_known: true } };\n",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0183-0000-4000-8000-0000000000183",
      "name": "State: remember new ticket",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5460,
        1000
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.settings.jira_base }}/rest/servicedeskapi/request/{{ encodeURIComponent($json.ticket_id) }}/comment",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "options": {
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ body: String($json.note_text).slice(0, 32000), public: false }) }}"
      },
      "id": "node-0316-0000-4000-8000-0000000000316",
      "name": "JIRA: add internal note (sent from Spun)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2080,
        1400
      ],
      "credentials": {
        "httpBasicAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Jira API token (Basic Auth: integration account email + API token)"
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2500,
      "notes": "Retries twice, 2.5 s apart (Jira: 20 writes per request per 2 seconds). Fails the execution after the third try."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0194-0000-4000-8000-0000000000194",
              "leftValue": "={{ $json.close_in_desk && /^\\d+$/.test(String($json.settings.jira_resolve_transition_id || '')) }}",
              "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": "POST",
        "url": "={{ $json.settings.jira_base }}/rest/api/3/issue/{{ encodeURIComponent($json.ticket_id) }}/transitions",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "text"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ (() => { const s = $json.settings; const b = { transition: { id: String(s.jira_resolve_transition_id) } }; const n = String(s.jira_resolution_name || ''); if (n && !n.startsWith('PASTE')) b.fields = { resolution: { name: n } }; return JSON.stringify(b); })() }}"
      },
      "id": "node-0317-0000-4000-8000-0000000000317",
      "name": "JIRA: resolve request",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3120,
        1320
      ],
      "credentials": {
        "httpBasicAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Jira API token (Basic Auth: integration account email + API token)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const j = $('Close ticket in desk?').item.json;\n// Mark the mapped request \"closed\" only when Jira accepted the transition (204). A wrong id, a\n// transition not available from the current status (400) or a required field on the transition\n// screen leaves the request open in Jira and in the map.\nconst r = $input.item.json;\nconst code = Number(r && r.statusCode);\nconst solved = code >= 200 && code < 300;\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 : { statusCode: code, body: r && r.body }, ticket_known: !solved, ticket_id: solved ? null : j.ticket_id } };\n",
        "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": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0200-0000-4000-8000-0000000000200",
              "leftValue": "={{ $json.reopen_in_desk && /^\\d+$/.test(String($json.settings.jira_reopen_transition_id || '')) }}",
              "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": "POST",
        "url": "={{ $json.settings.jira_base }}/rest/api/3/issue/{{ encodeURIComponent($json.ticket_id) }}/transitions",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "text"
            }
          }
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ transition: { id: String($json.settings.jira_reopen_transition_id) } }) }}"
      },
      "id": "node-0318-0000-4000-8000-0000000000318",
      "name": "JIRA: reopen request",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4160,
        1320
      ],
      "credentials": {
        "httpBasicAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Jira API token (Basic Auth: integration account email + API token)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const j = $('Reopen ticket in desk?').item.json;\n// Mark the mapped request \"open\" only when Jira accepted the reopen transition (204). Otherwise\n// the map keeps it closed and the next WhatsApp message opens a new request.\nconst r = $input.item.json;\nconst code = Number(r && r.statusCode);\nconst reopened = code >= 200 && code < 300;\nconst state = $getWorkflowStaticData('global');\nstate.tickets ??= {};\nconst t = state.tickets[j.chat_id];\nif (t && reopened) { t.status = 'open'; t.updated_at = Date.now(); }\nreturn { json: { ...j, desk_reopened: reopened, desk_error: reopened ? null : { statusCode: code, body: r && r.body }, ticket_known: !!(t && reopened), ticket_id: (t && t.ticket_id) || j.ticket_id || null } };\n",
        "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": {
        "jsCode": "// Jira admin webhook (SETUP.md section 6): \"Comment: created\" and, for the optional desk-side\n// close, \"Issue: updated\", JQL filter \"project = <key>\".\n// Signature: X-Hub-Signature = \"sha256=\" + lowercase hex HMAC-SHA256 of the raw body with the\n// webhook secret. \"HMAC of reply body\" computed it.\n// Fail closed: nothing passes while jira_webhook_secret or desk_integration_user_id is a placeholder.\nconst set = (v) => v !== undefined && v !== null && String(v) !== '' && !String(v).startsWith('PASTE');\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 header = String(headers['x-hub-signature'] || '');\n  const expected = 'sha256=' + String(j.computedHmac || '');\n  let ok = set(settings.jira_webhook_secret) && header.length === expected.length && header.length > 7;\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 < expected.length; i++) diff |= header.charCodeAt(i) ^ expected.charCodeAt(i);\n    ok = diff === 0;\n  }\n  let body = null;\n  try { body = JSON.parse(j.rawBody || ''); } catch (e) { body = null; }\n  const ev = body && body.webhookEvent ? String(body.webhookEvent) : '';\n  const issue = body && body.issue && typeof body.issue === 'object' ? body.issue : null;\n  const fields = issue && issue.fields ? issue.fields : {};\n  const ticket_id = issue && issue.key ? String(issue.key) : '';\n  const project = fields.project && fields.project.key ? String(fields.project.key) : '';\n  const c = body && body.comment && typeof body.comment === 'object' ? body.comment : null;\n  const author = c && c.author ? c.author : {};\n  const delivery = String(headers['x-atlassian-webhook-identifier'] || '');\n  let kind = 'ignore';\n  let why = null;\n  let comment = { comment_id: null, jira_comment_id: null, text: '', is_public: false, author_type: 'system', author_id: null, needs_read: false };\n  if (!set(settings.jira_webhook_secret)) why = 'jira_webhook_secret is not set in Settings';\n  else if (!set(settings.desk_integration_user_id)) why = 'desk_integration_user_id is not set in Settings';\n  else if (!ok) why = 'bad or missing X-Hub-Signature';\n  else if (!body || !ticket_id) why = 'body is not a Jira issue or comment webhook';\n  else if (project && project !== String(settings.jira_project_key)) why = 'issue is in project ' + project + ', not ' + settings.jira_project_key;\n  else if (ev === 'comment_created') {\n    if (!c || !c.id) why = 'comment_created without a comment id';\n    else {\n      kind = 'agent_reply';\n      const bodyIsText = typeof c.body === 'string';\n      const visibilityKnown = typeof c.jsdPublic === 'boolean';\n      const type = String(author.accountType || '');\n      comment = {\n        comment_id: 'jsm-' + String(c.id),\n        jira_comment_id: String(c.id),\n        text: bodyIsText ? c.body.trim() : '',\n        is_public: c.jsdPublic === true,\n        // only licensed Atlassian accounts count as agents: \"customer\" is a portal or email reply\n        // by the customer, \"app\" is an app or automation actor\n        author_type: type === 'atlassian' ? 'agent' : (type || 'unknown'),\n        author_id: author.accountId ? String(author.accountId) : null,\n        // the payload body may be ADF and jsdPublic may be missing: read the JSM comment then\n        needs_read: !(bodyIsText && visibilityKnown),\n      };\n    }\n  } else if (ev === 'jira:issue_updated') {\n    const items = body.changelog && Array.isArray(body.changelog.items) ? body.changelog.items : [];\n    const statusChanged = items.some((x) => String((x && x.field) || '').toLowerCase() === 'status');\n    const cat = fields.status && fields.status.statusCategory ? String(fields.status.statusCategory.key || '').toLowerCase() : '';\n    if (!statusChanged) why = 'issue updated without a status change';\n    else if (!cat) why = 'status change without a status category';\n    else kind = cat === 'done' ? 'ticket_closed' : 'ticket_reopened';\n  } else why = 'unhandled webhook event: ' + (ev || 'none');\n  out.push({\n    json: {\n      settings,\n      kind,\n      why,\n      event_type: ev || null,\n      ticket_id,\n      issue_id: issue && issue.id ? String(issue.id) : null,\n      desk_event_id: delivery || (ticket_id + ':' + ev + ':' + String((body && body.timestamp) || '')),\n      ...comment,\n    },\n    pairedItem: { item: itemIndex },\n  });\n});\nreturn out;\n"
      },
      "id": "node-0319-0000-4000-8000-0000000000319",
      "name": "JIRA adapter: verify + read webhook body",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1300,
        2200
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0320-0000-4000-8000-0000000000320",
              "leftValue": "={{ $json.needs_read && $json.author_type === 'agent' && String($json.author_id) !== String($json.settings.desk_integration_user_id) }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0321-0000-4000-8000-0000000000321",
      "name": "Read the comment first?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1820,
        1980
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.settings.jira_base }}/rest/servicedeskapi/request/{{ encodeURIComponent($json.ticket_id) }}/comment/{{ encodeURIComponent($json.jira_comment_id) }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "json"
            }
          }
        }
      },
      "id": "node-0322-0000-4000-8000-0000000000322",
      "name": "JIRA: read comment",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2080,
        2120
      ],
      "credentials": {
        "httpBasicAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Jira API token (Basic Auth: integration account email + API token)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// JSM comment read: \"body\" is a string and \"public\" is the authoritative visibility flag.\n// A failed read keeps the comment out: a comment whose visibility is unknown is never forwarded.\nconst r = $input.item.json;\nconst j = $('Read the comment first?').item.json;\nconst code = Number(r && r.statusCode);\nconst c = code >= 200 && code < 300 && r.body && typeof r.body === 'object' ? r.body : null;\nif (!c) return { json: { ...j, is_public: false, text: '', why: 'reading the comment failed: ' + JSON.stringify(r && r.body !== undefined ? r.body : r).slice(0, 300) } };\nreturn { json: { ...j, text: typeof c.body === 'string' ? c.body.trim() : '', is_public: c.public === true } };\n",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0323-0000-4000-8000-0000000000323",
      "name": "JIRA adapter: comment details",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2340,
        2120
      ]
    },
    {
      "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": [
        2600,
        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": [
        2860,
        1980
      ]
    },
    {
      "parameters": {},
      "id": "node-0214-0000-4000-8000-0000000000214",
      "name": "Reply filtered out",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        3120,
        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": [
        3120,
        1900
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.settings.jira_base }}/rest/api/3/issue/{{ encodeURIComponent($json.ticket_id) }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "fields",
              "value": "={{ [$json.settings.jira_phone_field_id, /^customfield_\\d+$/.test(String($json.settings.jira_lid_field_id || '')) ? $json.settings.jira_lid_field_id : null, 'project'].filter(Boolean).join(',') }}"
            }
          ]
        }
      },
      "id": "node-0324-0000-4000-8000-0000000000324",
      "name": "JIRA: read issue (find chat id)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3380,
        2020
      ],
      "credentials": {
        "httpBasicAuth": {
          "id": "REPLACE_AFTER_IMPORT",
          "name": "Jira API token (Basic Auth: integration account email + API token)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// The map has no entry for this request (n8n static data was reset, or the request was opened\n// before this workflow ran). New requests carry the WhatsApp number (E.164) in the WhatsApp number\n// field, and Spun's send-message accepts that number as \"to\". A privacy-ID (LID) chat with no\n// known phone carries its digits in the optional Spun LID field instead; \"<digits>@lid\" is a\n// chat id send-message accepts as \"to\" as well. The map is not written here: the\n// next inbound WhatsApp message re-links the chat through the fallback lookup.\nconst r = $input.item.json;\nconst j = $('Chat id resolved?').item.json;\nconst field = String(j.settings.jira_phone_field_id || '');\nconst code = Number(r && r.statusCode);\nconst f = code >= 200 && code < 300 && r.body && r.body.fields ? r.body.fields : null;\nif (!f) throw new Error('Reading Jira issue ' + j.ticket_id + ' failed: ' + JSON.stringify(r && r.body !== undefined ? r.body : r).slice(0, 500));\nconst project = f.project && f.project.key ? String(f.project.key) : '';\nif (project !== String(j.settings.jira_project_key)) throw new Error('Jira issue ' + j.ticket_id + ' is in project ' + project + ', not ' + j.settings.jira_project_key);\nconst phone = typeof f[field] === 'string' ? f[field].trim() : '';\nconst lidField = String(j.settings.jira_lid_field_id || '');\nconst lidSet = /^customfield_\\d+$/.test(lidField);\nconst lid = lidSet && typeof f[lidField] === 'string' ? f[lidField].trim() : '';\nconst chat_id = /^\\+[1-9]\\d{6,14}$/.test(phone) ? phone : (/^\\d{5,20}$/.test(lid) ? lid + '@lid' : '');\nif (!chat_id) throw new Error('Jira issue ' + j.ticket_id + ' has no WhatsApp number (E.164) in ' + field + (lidSet ? ' and no privacy ID in ' + lidField : '') + '; was it created by this workflow?');\nreturn { json: { ...j, chat_id } };\n",
        "mode": "runOnceForEachItem"
      },
      "id": "node-0325-0000-4000-8000-0000000000325",
      "name": "Read chat id from issue",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3640,
        2020
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $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: $json.chat_id, text: $json.text, idempotency_key: String($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": [
        3900,
        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": [
        4160,
        1900
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-0901-0000-4000-8000-000000000901",
              "leftValue": "={{ !!$json.search_body }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "node-0901-0000-4000-8000-000000000901",
      "name": "Request search possible?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        4030,
        400
      ]
    }
  ],
  "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": "Open ticket known?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Open ticket known?": {
      "main": [
        [
          {
            "node": "JIRA: read request status",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare request search",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "JIRA: read request status": {
      "main": [
        [
          {
            "node": "Request still open?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Request still open?": {
      "main": [
        [
          {
            "node": "Still open?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Still open?": {
      "main": [
        [
          {
            "node": "JIRA: append message as comment",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Reopen on new message?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reopen on new message?": {
      "main": [
        [
          {
            "node": "JIRA: reopen request (new message)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "State: mark ticket closed (desk closed it)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "JIRA: reopen request (new message)": {
      "main": [
        [
          {
            "node": "Reopened for the new message?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reopened for the new message?": {
      "main": [
        [
          {
            "node": "Reopen worked?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reopen worked?": {
      "main": [
        [
          {
            "node": "State: remember found ticket",
            "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": "Prepare request search",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare request search": {
      "main": [
        [
          {
            "node": "Request search possible?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "JIRA: find open request by phone": {
      "main": [
        [
          {
            "node": "Pick open request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick open request": {
      "main": [
        [
          {
            "node": "Open ticket found?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Open ticket found?": {
      "main": [
        [
          {
            "node": "State: remember found ticket",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "JIRA: create request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "State: remember found ticket": {
      "main": [
        [
          {
            "node": "JIRA: append message as comment",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "JIRA: create request": {
      "main": [
        [
          {
            "node": "Read new request key",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read new request key": {
      "main": [
        [
          {
            "node": "State: remember new ticket",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "JIRA: append message as comment": {
      "main": [
        [
          {
            "node": "State: remember message",
            "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": "JIRA: add internal 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": "JIRA: resolve request",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Nothing to close",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "JIRA: resolve request": {
      "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": "JIRA: reopen request",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Nothing to reopen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "JIRA: reopen request": {
      "main": [
        [
          {
            "node": "State: mark ticket open (Spun reopened it)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reply raw body to text": {
      "main": [
        [
          {
            "node": "HMAC of reply body",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HMAC of reply body": {
      "main": [
        [
          {
            "node": "JIRA adapter: verify + read webhook body",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "JIRA adapter: verify + read webhook body": {
      "main": [
        [
          {
            "node": "Route by desk event",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by desk event": {
      "main": [
        [
          {
            "node": "Read the comment first?",
            "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
          }
        ]
      ]
    },
    "Read the comment first?": {
      "main": [
        [
          {
            "node": "JIRA: read comment",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Reply filter",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "JIRA: read comment": {
      "main": [
        [
          {
            "node": "JIRA adapter: comment details",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "JIRA adapter: comment details": {
      "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": "JIRA: read issue (find chat id)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "JIRA: read issue (find chat id)": {
      "main": [
        [
          {
            "node": "Read chat id from issue",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read chat id from issue": {
      "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
          }
        ]
      ]
    },
    "Request search possible?": {
      "main": [
        [
          {
            "node": "JIRA: find open request by phone",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "JIRA: create request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "saveManualExecutions": true,
    "saveExecutionProgress": true
  },
  "meta": {
    "templateCredsSetupCompleted": false,
    "instanceId": "REPLACED_ON_IMPORT"
  },
  "staticData": null,
  "active": false,
  "pinData": {}
}
