{
  "name": "Feedmarkdown to Slack",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "feedmarkdown-slack",
        "responseMode": "responseNode",
        "options": {
          "rawBody": true
        }
      },
      "id": "webhook",
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {
        "operation": "text",
        "binaryPropertyName": "data",
        "destinationKey": "rawBody",
        "options": {}
      },
      "id": "read-raw-body",
      "name": "Read raw body",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1,
      "position": [
        220,
        0
      ]
    },
    {
      "parameters": {
        "action": "hmac",
        "type": "SHA256",
        "value": "={{ $json.rawBody }}",
        "dataPropertyName": "hmac",
        "secret": "YOUR_WEBHOOK_SECRET",
        "encoding": "hex"
      },
      "id": "compute-hmac-sha256",
      "name": "Compute HMAC-SHA256",
      "type": "n8n-nodes-base.crypto",
      "typeVersion": 1,
      "position": [
        440,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// The Crypto node put HMAC-SHA256(secret, raw body) in $json.hmac, as hex.\nconst want = 'sha256=' + $json.hmac;\nconst got = String($('Webhook').first().json.headers['x-feedmarkdown-signature'] || '');\n\n// Constant-time compare, so response time does not leak how much matched.\nlet diff = want.length ^ got.length;\nfor (let i = 0; i < want.length; i++) diff |= want.charCodeAt(i) ^ (got.charCodeAt(i) || 0);\nconst valid = diff === 0;\n\n// Parse only after the signature checks out.\nconst raw = $('Read raw body').first().json.rawBody;\nreturn [{ json: { valid, payload: valid ? JSON.parse(raw) : null } }];"
      },
      "id": "verify-signature",
      "name": "Verify signature",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        660,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "v1",
              "leftValue": "={{ $json.valid }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "signature-valid-",
      "name": "Signature valid?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        880,
        0
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ { ok: false, error: 'bad signature' } }}",
        "options": {
          "responseCode": 401
        }
      },
      "id": "reject-401-",
      "name": "Reject (401)",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1100,
        200
      ]
    },
    {
      "parameters": {
        "jsCode": "const seen = $getWorkflowStaticData('global').seen || [];\nconst p = $input.first().json.payload;\nreturn [{ json: { payload: p, isNew: !seen.includes(p.url) } }];"
      },
      "id": "seen-before-",
      "name": "Seen before?",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        -100
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "v2",
              "leftValue": "={{ $json.isNew }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "new-story-",
      "name": "New story?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1320,
        -100
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ { ok: true, posted: false, reason: 'already seen' } }}",
        "options": {
          "responseCode": 200
        }
      },
      "id": "skip-repeat-200-",
      "name": "Skip repeat (200)",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1540,
        100
      ]
    },
    {
      "parameters": {
        "jsCode": "const MAX = 700; // excerpt length, characters\n\nfunction toSlack(md) {\n  let t = md.replace(/\\r/g, '');\n  t = t.replace(/^#\\s.*\\n+/, '');                       // the first H1 repeats the title\n  t = t.replace(/!\\[[^\\]]*\\]\\([^)]*\\)/g, '');           // images\n  t = t.replace(/^\\s*([-*_])(\\s*\\1){2,}\\s*$/gm, '');    // horizontal rules\n  t = t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');\n  t = t.replace(/^&gt; ?/gm, '> ');                     // block quotes keep a real >\n  t = t.replace(/^#{1,6}\\s+(.+)$/gm, '\\u0001$1\\u0001'); // headings -> bold line\n  t = t.replace(/^\\s*[-*+]\\s+/gm, '• ');                // bullets\n  t = t.replace(/\\*\\*([^*\\n]+)\\*\\*/g, '\\u0001$1\\u0001');   // bold, parked\n  t = t.replace(/(^|[^*\\w])\\*([^*\\n]+)\\*(?![*\\w])/g, '$1_$2_'); // *italic* -> _italic_\n  t = t.replace(/\\u0001/g, '*');\n  t = t.replace(/\\[([^\\]]+)\\]\\(([^)\\s]+)(?:\\s+\"[^\"]*\")?\\)/g, (m, text, url) =>\n    /^https?:\\/\\//.test(url) ? `<${url}|${text.replace(/[|]/g, '')}>` : text); // relative links: keep the text\n  return t.replace(/\\n{3,}/g, '\\n\\n').trim();\n}\n\n// Menus and link rails are short paragraphs; article paragraphs are not.\nfunction bodyOnly(text) {\n  const visible = (s) => s.replace(/<[^|>]*\\|([^>]*)>/g, '$1').trim();\n  const kept = text.split(/\\n{2,}/).filter((p) => visible(p).length >= 80);\n  return kept.length ? kept.join('\\n\\n') : text;\n}\n\nfunction excerpt(text, max) {\n  if (text.length <= max) return text;\n  let cut = '';\n  for (const para of text.split(/\\n{2,}/)) {            // whole paragraphs first\n    const next = cut ? cut + '\\n\\n' + para : para;\n    if (next.length > max) break;\n    cut = next;\n  }\n  if (!cut) {                                            // first paragraph is too long\n    const head = text.slice(0, max);\n    const s = Math.max(head.lastIndexOf('. '), head.lastIndexOf('! '), head.lastIndexOf('? '));\n    cut = s > max * 0.4 ? head.slice(0, s + 1) : head.slice(0, head.lastIndexOf(' '));\n  }\n  for (;;) {                                             // never leave a half-open link or marker\n    const open = cut.lastIndexOf('<');\n    if (open > cut.lastIndexOf('>')) { cut = cut.slice(0, open).trimEnd(); continue; }\n    if ((cut.match(/\\*/g) || []).length % 2) { cut = cut.slice(0, cut.lastIndexOf('*')).trimEnd(); continue; }\n    if ((cut.match(/`/g) || []).length % 2) { cut = cut.slice(0, cut.lastIndexOf('`')).trimEnd(); continue; }\n    break;\n  }\n  return cut + ' …';\n}\n\nconst p = $input.first().json.payload;\nconst esc = (s) => String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');\nconst when = p.published_at ? new Date(p.published_at).toISOString().slice(0, 16).replace('T', ' ') + ' UTC' : '';\nconst byline = [p.source, p.author, when].filter(Boolean).map(esc).join(' · ');\nconst text = `*<${esc(p.url)}|${esc(p.title).replace(/\\|/g, '')}>*\\n${byline}\\n\\n${excerpt(bodyOnly(toSlack(p.markdown || '')), MAX)}`;\n\nreturn [{ json: { url: p.url, text, chars: text.length } }];"
      },
      "id": "format-for-slack",
      "name": "Format for Slack",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1540,
        -200
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://hooks.slack.com/services/YOUR/WEBHOOK/URL",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ { text: $json.text } }}",
        "options": {}
      },
      "id": "post-to-slack",
      "name": "Post to Slack",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1760,
        -200
      ]
    },
    {
      "parameters": {
        "jsCode": "const sd = $getWorkflowStaticData('global');\nsd.seen = (sd.seen || []).concat($('Format for Slack').first().json.url).slice(-500);\nreturn [{ json: { ok: true, posted: true } }];"
      },
      "id": "remember-url",
      "name": "Remember URL",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1980,
        -200
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {
          "responseCode": 200
        }
      },
      "id": "done-200-",
      "name": "Done (200)",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        2200,
        -200
      ]
    }
  ],
  "connections": {
    "Webhook": {
      "main": [
        [
          {
            "node": "Read raw body",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read raw body": {
      "main": [
        [
          {
            "node": "Compute HMAC-SHA256",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compute HMAC-SHA256": {
      "main": [
        [
          {
            "node": "Verify signature",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify signature": {
      "main": [
        [
          {
            "node": "Signature valid?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Signature valid?": {
      "main": [
        [
          {
            "node": "Seen before?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Reject (401)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Seen before?": {
      "main": [
        [
          {
            "node": "New story?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "New story?": {
      "main": [
        [
          {
            "node": "Format for Slack",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Skip repeat (200)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format for Slack": {
      "main": [
        [
          {
            "node": "Post to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Post to Slack": {
      "main": [
        [
          {
            "node": "Remember URL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Remember URL": {
      "main": [
        [
          {
            "node": "Done (200)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  }
}
