{ "nodes": [ { "parameters": { "httpMethod": "POST", "path": "device-relay", "options": {} }, "id": "0880b116-1fd5-45c5-acc4-1437ba1a5791", "name": "Webhook", "type": "n8n-nodes-base.webhook", "typeVersion": 2.1, "position": [ 0, 0 ], "webhookId": "fe4a40f7-acfa-4380-99d1-72c4b7c7ca34" }, { "parameters": {}, "type": "n8n-nodes-base.merge", "typeVersion": 3.2, "position": [ 544, 160 ], "id": "caaf5c27-488f-4b8a-8c81-64465a4e9f84", "name": "Merge" }, { "parameters": { "mode": "raw", "jsonOutput": "{}", "options": {} }, "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [ 368, 160 ], "id": "16db132b-a22c-4ff5-b3b2-e0dc22a91878", "name": "Config" }, { "parameters": { "jsCode": "// n8n Code node (Run Once for All Items)\n\n// Toggle this to silence logs quickly\nconst DEBUG = true;\n\nconst log = (...args) => {\n if (DEBUG) console.log(\"[debug]\", ...args);\n};\n\nconst redact = (value) => {\n const SECRET_KEY_RE = /(token|secret|password|apiKey|key)/i;\n\n const walk = (v) => {\n if (v == null) return v;\n if (Array.isArray(v)) return v.map(walk);\n\n if (typeof v === \"object\") {\n const out = {};\n for (const [k, child] of Object.entries(v)) {\n if (SECRET_KEY_RE.test(k)) out[k] = \"***REDACTED***\";\n else out[k] = walk(child);\n }\n return out;\n }\n\n return v;\n };\n\n return walk(value);\n};\n\nlog(\"Input item count:\", $input.all().length);\n\nconst wrapper = $input.first().json;\nlog(\"Wrapper keys:\", Object.keys(wrapper ?? {}));\n\nconst config = wrapper.config;\nconst event = wrapper.event;\nconst recipientsResolved = wrapper.recipientsResolved;\n\nlog(\"Has config?\", !!config, \"Has event?\", !!event);\nlog(\"Config (redacted):\", redact(config));\nlog(\"Event keys:\", Object.keys(event ?? {}));\nlog(\"Event.body keys:\", Object.keys(event?.body ?? {}));\n\nif (!config) throw new Error(\"Missing config\");\nif (!event) throw new Error(\"Missing event\");\nif (!recipientsResolved) throw new Error(\"Missing RecipientsResolved\");\nif (!config?.recipients) throw new Error(\"Missing config.recipients (ConfigModel)\");\nif (!config?.endpoints) throw new Error(\"Missing config.endpoints (ConfigModel)\");\nif (!config?.templates) throw new Error(\"Missing config.templates (ConfigModel)\");\nif (!event?.body?.data?.type)\n throw new Error(\"Missing event.body.data.type (InboundDataModel discriminator)\");\nif (!event?.body?.device.id) throw new Error(\"Missing event.body.device.id\");\nif (!event?.body?.id) throw new Error(\"Missing event.body.id (eventId)\");\n\nconst deviceId = event.body.device.id;\nconst dataType = event.body.data.type;\nconst eventId = event.body.id;\n\nlog(\"deviceId:\", deviceId);\nlog(\"dataType:\", dataType);\nlog(\"eventId:\", eventId);\n\n// meta is what templates typically refer to as {meta.*}\nconst meta = {\n id: event.body.id,\n createdAt: event.body.createdAt,\n deviceId: event.body.deviceId,\n source: event.body.source,\n ...(event.body.meta ?? {}),\n};\nlog(\"meta:\", meta);\n\nconst ensureAllExist = (kind, ids, registry) => {\n for (const id of ids) {\n if (!registry[id]) {\n throw new Error(`Unknown ${kind} id \"${id}\" referenced by routing logic`);\n }\n }\n};\n\n// Build OutboundDispatchPlan from config + event\nconst dispatches = [];\n/**\n * @type {Array}\n */\nconst recipients = recipientsResolved;\n\nlog(\"recipient ids:\", recipients.map((r) => r.id).flat().join(\", \"));\n\nfor (const recipient of recipientsResolved) {\n const recipientId = recipient.id;\n\n if (!Array.isArray(recipient.rules)) continue;\n\n for (const rule of recipient.rules) {\n const endpointIds = rule.then?.endpointIds ?? [];\n const templateIds = rule.then?.templateIds ?? [];\n\n if (endpointIds.length === 0 || templateIds.length === 0) continue;\n\n ensureAllExist(\"endpoint\", endpointIds, config.endpoints);\n ensureAllExist(\"template\", templateIds, config.templates);\n\n for (const endpointId of endpointIds) {\n for (const templateId of templateIds) {\n dispatches.push({\n id: `${eventId}:${recipientId}:${rule.id}:${endpointId}:${templateId}`,\n recipientId,\n ruleId: rule.id,\n endpointId,\n templateId,\n matchedRuleIds: [rule.id],\n });\n }\n }\n }\n}\n\nconst outbound = {\n eventId,\n deviceId,\n dataType,\n configVersion: config.version,\n dispatches,\n};\n\nlog(\"dispatches count:\", dispatches.length);\nlog(\"outbound:\", redact(outbound));\n\n// Return RuntimeModel\nreturn [\n {\n json: {\n event,\n config,\n outbound,\n },\n },\n];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 864, 928 ], "id": "0025c174-18b9-48cf-b04a-8e018d9b467b", "name": "Form outbound", "alwaysOutputData": false }, { "parameters": { "jsCode": "// n8n Code node (Run Once for All Items)\n\n// Toggle this to silence logs quickly\nconst DEBUG = true;\n\nconst log = (...args) => {\n if (DEBUG) console.log(\"[debug]\", ...args);\n};\n\nconst redact = (value) => {\n const SECRET_KEY_RE = /(token|secret|password|apiKey|key)/i;\n\n const walk = (v) => {\n if (v == null) return v;\n if (Array.isArray(v)) return v.map(walk);\n\n if (typeof v === \"object\") {\n const out = {};\n for (const [k, child] of Object.entries(v)) {\n if (SECRET_KEY_RE.test(k)) out[k] = \"***REDACTED***\";\n else out[k] = walk(child);\n }\n return out;\n }\n\n return v;\n };\n\n return walk(value);\n};\n\nconst wrapper = $input.first().json;\n\nconst config = wrapper.config;\nconst event = wrapper.event;\nconst outbound = wrapper.outbound;\n\nlog(\"Has config?\", !!config, \"Has event?\", !!event, \"Has outbound?\", !!outbound);\nlog(\"Outbound (redacted):\", redact(outbound));\n\nif (!config) throw new Error(\"Missing config\");\nif (!event) throw new Error(\"Missing event\");\nif (!outbound) throw new Error(\"Missing outbound\");\nif (!config?.recipients) throw new Error(\"Missing config.recipients (ConfigModel)\");\nif (!config?.endpoints) throw new Error(\"Missing config.endpoints (ConfigModel)\");\nif (!config?.templates) throw new Error(\"Missing config.templates (ConfigModel)\");\nif (!Array.isArray(outbound?.dispatches)) throw new Error(\"Missing outbound.dispatches\");\nif (!event?.body?.data?.type) throw new Error(\"Missing event.body.data.type\");\nif (!event?.body?.device.id) throw new Error(\"Missing event.body.device.id\");\nif (!event?.body?.id) throw new Error(\"Missing event.body.id\");\n\nconst meta = {\n id: event.body.id,\n createdAt: event.body.createdAt,\n deviceId: event.body.device.id,\n source: event.body.source,\n ...(event.body.meta ?? {}),\n};\n\nconst mergeHeaders = (base, override) => ({\n ...(base || {}),\n ...(override || {}),\n});\n\nconst ensure = (kind, id, value) => {\n if (!value) throw new Error(`Unknown ${kind} id \"${id}\"`);\n return value;\n};\n\nconst defaultTimeoutMs = config?.defaults?.httpTimeoutMs;\nconst defaultHeaders = config?.defaults?.headers;\n\nlog(\"dispatch count:\", outbound.dispatches.length);\n\nconst items = outbound.dispatches.map((dispatch) => {\n const recipientId = dispatch.recipientId;\n const endpointId = dispatch.endpointId;\n const templateId = dispatch.templateId;\n\n const recipient = ensure(\"recipient\", recipientId, config.recipients[recipientId]);\n const endpoint = ensure(\"endpoint\", endpointId, config.endpoints[endpointId]);\n const template = ensure(\"template\", templateId, config.templates[templateId]);\n\n // IMPORTANT: no templating here. Leave placeholders as-is.\n // This node only adds metadata for later templating nodes.\n if (endpoint?.body?.type && endpoint.body.type !== \"json\") {\n throw new Error(\n `Endpoint \"${endpointId}\" body.type=\"${endpoint.body.type}\" is not supported by this node (expects \"json\")`\n );\n }\n\n return {\n json: {\n // metadata for later nodes that will do templating/rendering\n templateData: {\n meta, // typical {meta.*} namespace\n data: event.body.data, // typical {data.*} namespace\n device: event.body.device, // typical {data.*} namespace\n recipient: {\n id: recipientId,\n vars: recipient.vars, // typical {recipient.vars.*} namespace\n }\n },\n toTemplate: {\n template: {\n id: templateId,\n text: template.text, // where `{message}` (or similar) should come from later\n format: template.format,\n fields: template.fields,\n },\n endpoint: endpoint\n },\n },\n };\n});\n\nlog(\"Prepared items:\", items.length);\nlog(\"First item preview (redacted):\", redact(items[0]?.json));\n\nreturn items;" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1072, 928 ], "id": "23e3f252-81c6-4c34-9e20-b1f71eecee60", "name": "Compose messages", "alwaysOutputData": false }, { "parameters": { "jsCode": "// n8n Code node (Run Once for All Items)\n\n// Toggle this to silence logs quickly\nconst DEBUG = true;\n\nconst log = (...args) => {\n if (DEBUG) console.log('[debug]', ...args);\n};\n\nconst redact = (value) => {\n const SECRET_KEY_RE = /(token|secret|password|apiKey|key)/i;\n\n const walk = (v, path = '') => {\n if (v == null) return v;\n\n if (Array.isArray(v)) return v.map((x, i) => walk(x, `${path}[${i}]`));\n\n if (typeof v === 'object') {\n const out = {};\n for (const [k, child] of Object.entries(v)) {\n const full = path ? `${path}.${k}` : k;\n if (SECRET_KEY_RE.test(k)) out[k] = '***REDACTED***';\n else out[k] = walk(child, full);\n }\n return out;\n }\n\n return v;\n };\n\n return walk(value);\n};\n\nlog('Input item count:', $input.all().length);\n\nconst wrapper = $input.first().json;\nlog('Wrapper keys:', Object.keys(wrapper ?? {}));\n\n// Allow common shapes: {config, event}, {config, body}, or direct webhook body\nconst config = wrapper.config;\nconst event = wrapper.event;\nconst recipientsFilteredGlobally = wrapper.recipientsFilteredGlobally;\n\nlog('Has config?', !!config, 'Has event?', !!event);\nlog('Config (redacted):', redact(config));\nlog('Event keys:', Object.keys(event ?? {}));\nlog('Event.body keys:', Object.keys(event?.body ?? {}));\n\nif (!recipientsFilteredGlobally) throw new Error('Missing recipientsFilteredGlobally');\nif (!config?.recipients) throw new Error('Missing config.recipients (ConfigModel)');\nif (!event?.body?.data?.type) throw new Error('Missing event.body.data.type (IncomingDataModel discriminator)');\nif (!event?.body?.device.id) throw new Error('Missing event.body.device.id');\n\nconst deviceId = event.body.device.id;\nconst dataType = event.body.data.type;\n\nlog('deviceId:', deviceId);\nlog('dataType:', dataType);\n\n// meta is what templates typically refer to as {meta.*}\nconst meta = {\n id: event.body.id,\n createdAt: event.body.createdAt,\n deviceId: event.body.device.id,\n source: event.body.source,\n ...(event.body.meta ?? {}),\n};\nlog('meta:', meta);\n\nconst isPresent = (v) => v !== undefined && v !== null;\n\n/**\n * If the value is a string, convert it to lowercase.\n * Otherwise, return the value as is.\n * @param {unknown} v - The value to be checked and potentially converted.\n * @returns {unknown | string} - The lowercase string or the original value.\n */\nconst normalizeIfString = (v) => {\n if (typeof v === 'string') {\n return v.trim().toLowerCase();\n }\n return v;\n}\n\n/**\n * Convert a value to a comparable number if possible.\n * @param {unknown} v - The value to be converted.\n * @returns {number | undefined} - The comparable number or undefined if conversion is not possible.\n */\nconst toComparableNumber = (v) => {\n if (typeof v === 'number' && Number.isFinite(v)) return v;\n if (typeof v === 'string' && normalizeIfString(v) !== '') {\n const n = Number(v);\n return Number.isFinite(n) ? n : undefined;\n }\n return undefined;\n};\n\nconst normalizeEquals = (actual, expected) => {\n log('normalizeEquals:', { actual, expected, actualType: typeof actual, expectedType: typeof expected });\n\n if (!isPresent(actual) && !isPresent(expected)) return true;\n\n if (typeof actual === 'boolean') {\n if (typeof expected === 'boolean') return actual === expected;\n if (typeof expected === 'string') {\n const s = normalizeIfString(expected);\n if (s === 'true') return actual === true;\n if (s === 'false') return actual === false;\n }\n return false;\n }\n\n if (typeof actual === 'number') {\n const n = toComparableNumber(expected);\n return n !== undefined ? actual === n : false;\n }\n\n return normalizeIfString(String(actual)) === normalizeIfString(String(expected));\n};\n\nconst getByPath = (root, path) => {\n const parts = (path ? String(normalizeIfString(path)) : '')\n .split('.')\n .filter(Boolean);\n\n let cur = root;\n for (const part of parts) {\n if (cur == null) return undefined;\n cur = cur[part];\n }\n return cur;\n};\n\n// Uses \"operator\" (your current my.json)\nconst predicateMatches = (predicate) => {\n const op = predicate?.operator;\n const path = predicate?.path;\n const expected = predicate?.value;\n\n // Root lets your rules use paths like \"deviceId\" or \"data.body\" or \"meta.device.id\"\n const predicateRoot = { ...event.body, data: event.body.data, meta };\n const actual = getByPath(predicateRoot, path);\n\n log('predicate:', { path, op, expected, actual });\n\n switch (op) {\n case 'equals':\n return normalizeEquals(actual, expected);\n\n case 'not_equals':\n return !normalizeEquals(actual, expected);\n\n case 'includes': {\n if (!isPresent(actual)) return false;\n if (Array.isArray(actual)) return actual.includes(expected);\n return normalizeIfString(String(actual)).includes(normalizeIfString(String(expected ?? '')));\n }\n\n case 'not_includes': {\n if (!isPresent(actual)) return true;\n if (Array.isArray(actual)) return !actual.includes(expected);\n return !normalizeIfString(String(actual)).includes(normalizeIfString(String(expected ?? '')));\n }\n\n case 'starts_with':\n return isPresent(actual) && normalizeIfString(String(actual)).startsWith(normalizeIfString(String(expected ?? '')));\n\n case 'ends_with':\n return isPresent(actual) && normalizeIfString(String(actual)).endsWith(normalizeIfString(String(expected ?? '')));\n\n case 'matches_regex': {\n if (!isPresent(actual)) return false;\n try {\n const re = new RegExp(normalizeIfString(String(expected ?? '')));\n return re.test(normalizeIfString(String(actual)));\n } catch {\n throw new Error(`Invalid regex in predicate for path \"${path}\": ${normalizeIfString(String(expected))}`);\n }\n }\n\n case 'gt':\n case 'gte':\n case 'lt':\n case 'lte': {\n const a = toComparableNumber(actual);\n const b = toComparableNumber(expected);\n log('numeric compare:', { op, a, b });\n if (a === undefined || b === undefined) return false;\n if (op === 'gt') return a > b;\n if (op === 'gte') return a >= b;\n if (op === 'lt') return a < b;\n return a <= b;\n }\n\n case 'exists':\n return isPresent(actual);\n\n default:\n throw new Error(`Unsupported predicate operator: ${op}`);\n }\n};\n\nconst resolveRecipient = (recipientId, recipient) => {\n log('--- resolveRecipient ---', recipientId);\n log('recipient (redacted):', redact(recipient));\n\n const allowedDeviceIds = Array.isArray(recipient.allowedDeviceIds) ? recipient.allowedDeviceIds : [];\n log('allowedDeviceIds:', allowedDeviceIds);\n if (allowedDeviceIds.length > 0 && !allowedDeviceIds.includes(deviceId)) {\n log('SKIP: deviceId not allowed', { deviceId, allowedDeviceIds });\n return null;\n }\n\n const allowedDataTypes = Array.isArray(recipient.allowedDataTypes) ? recipient.allowedDataTypes : [];\n log('allowedDataTypes:', allowedDataTypes);\n if (allowedDataTypes.length > 0 && !allowedDataTypes.includes(dataType)) {\n log('SKIP: dataType not allowed', { dataType, allowedDataTypes });\n return null;\n }\n\n const baseEndpointIds = Array.isArray(recipient.endpointIds) ? recipient.endpointIds : [];\n const baseTemplateIds = Array.isArray(recipient.templateIds) ? recipient.templateIds : [];\n log('baseEndpointIds:', baseEndpointIds);\n log('baseTemplateIds:', baseTemplateIds);\n\n const rules = Array.isArray(recipient.rules) ? recipient.rules : [];\n log('rule count:', rules.length);\n\n if (rules.length === 0) {\n log('NO RULES: selecting recipient with base routing');\n return { id: recipientId, ...recipient, endpointIds: baseEndpointIds, templateIds: baseTemplateIds };\n }\n\n const matchedRules = [];\n\n for (const rule of rules) {\n log('checking rule:', { ruleId: rule?.id, type: rule?.type });\n\n if (rule?.type !== dataType) {\n log('rule type mismatch, skip:', { ruleType: rule?.type, dataType });\n continue;\n }\n\n const whens = Array.isArray(rule.when) ? rule.when : [];\n log('when predicates:', whens);\n\n const matched = whens.every((p, idx) => {\n const ok = predicateMatches(p);\n log(`predicate[${idx}] result:`, ok);\n return ok;\n });\n\n log('rule matched?', matched, 'ruleId:', rule?.id);\n\n if (!matched) continue;\n\n // IMPORTANT: keep track of matches (you were missing this)\n matchedRules.push(rule);\n }\n\n log('matchedRules ids:', matchedRules.map((r) => r.id));\n\n if (matchedRules.length === 0) {\n log('NO MATCHED RULES: dropping recipient');\n return null;\n }\n\n return { \n id: recipientId,\n vars: recipient.vars,\n rules: matchedRules.map((r) => {\n return { id: r.id, then: r.then };\n }),\n };\n};\n\nlog('recipient ids:', recipientsFilteredGlobally.join(\", \"));\n\nconst resolvedRecipients = recipientsFilteredGlobally\n .map((id) => resolveRecipient(id, config.recipients?.[id]))\n .filter(Boolean);\n\nlog('resolvedRecipients count:', resolvedRecipients.length);\nlog('resolvedRecipients (redacted):', redact(resolvedRecipients));\n\nreturn [\n {\n json: {\n event,\n config,\n recipientsResolved: resolvedRecipients\n }\n }\n];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 448, 928 ], "id": "a1f4e48b-aa25-4364-a133-dd1e28ae964b", "name": "Filter recipients by recipient rules", "alwaysOutputData": false }, { "parameters": { "jsCode": "// n8n Code node (Run Once for All Items)\n\n// Toggle this to silence logs quickly\nconst DEBUG = true;\n\nconst log = (...args) => {\n if (DEBUG) console.log(\"[debug]\", ...args);\n};\n\nconst redact = (value) => {\n const SECRET_KEY_RE = /(token|secret|password|apiKey|key)/i;\n\n const walk = (v) => {\n if (v == null) return v;\n if (Array.isArray(v)) return v.map(walk);\n\n if (typeof v === \"object\") {\n const out = {};\n for (const [k, child] of Object.entries(v)) {\n if (SECRET_KEY_RE.test(k)) out[k] = \"***REDACTED***\";\n else out[k] = walk(child);\n }\n return out;\n }\n return v;\n };\n\n return walk(value);\n};\n\nconst getByPath = (root, path) => {\n if (!path || typeof path !== \"string\") return undefined;\n\n // supports simple dot paths like: meta.createdAt, data.body, recipient.vars.botToken\n const parts = path.split(\".\").filter(Boolean);\n let cur = root;\n\n for (const part of parts) {\n if (cur == null) return undefined;\n cur = cur[part];\n }\n\n return cur;\n};\n\nconst templateString = (input, templateData, renderedText) => {\n if (typeof input !== \"string\") return input;\n\n return input.replace(/\\{([^{}]+)\\}/g, (match, rawToken) => {\n const token = String(rawToken).trim();\n\n // Special placeholder: {%text%} => already-rendered template.text\n if (token === \"%text%\") return String(renderedText ?? \"\");\n\n const value = getByPath(templateData, token);\n\n // If missing, keep placeholder as-is (non-strict)\n if (value === undefined || value === null) return match;\n\n // For non-primitives, stringify to avoid \"[object Object]\"\n if (typeof value === \"object\") return JSON.stringify(value);\n\n return String(value);\n });\n};\n\nconst templateDeepOnlyStrings = (value, templateData, renderedText) => {\n if (typeof value === \"string\") return templateString(value, templateData, renderedText);\n\n if (Array.isArray(value)) {\n return value.map((v) => templateDeepOnlyStrings(v, templateData, renderedText));\n }\n\n if (value && typeof value === \"object\") {\n const out = {};\n for (const [k, v] of Object.entries(value)) {\n out[k] = templateDeepOnlyStrings(v, templateData, renderedText);\n }\n return out;\n }\n\n return value;\n};\n\nconst items = $input.all().map((item) => {\n const wrapper = item.json;\n\n const templateData = wrapper.templateData;\n const toTemplate = wrapper.toTemplate;\n\n if (!templateData) throw new Error(\"Missing templateData\");\n if (!toTemplate) throw new Error(\"Missing toTemplate\");\n if (!toTemplate?.template?.text) throw new Error(\"Missing toTemplate.template.text\");\n if (!toTemplate?.endpoint) throw new Error(\"Missing toTemplate.endpoint\");\n\n // 1) Render template.text first (only uses templateData)\n const renderedText = templateString(toTemplate.template.text, templateData, \"\");\n\n // 2) Template ONLY within toTemplate (strings only), using templateData,\n // with {%text%} resolved from the rendered template.text above.\n const templatedEndpoint = templateDeepOnlyStrings(\n toTemplate.endpoint,\n templateData,\n renderedText\n );\n\n // 3) Build \"toSend\" as a flattened, templated endpoint (no nesting)\n // Keep everything else as-is; just add toSend.\n const toSend = {\n method: templatedEndpoint.method,\n url: templatedEndpoint.url,\n headers: templatedEndpoint.headers ?? {},\n searchParams: templatedEndpoint.searchParams ?? {},\n timeoutMs: templatedEndpoint.timeoutMs,\n body:\n templatedEndpoint?.body?.type === \"json\"\n ? (templatedEndpoint.body?.content ?? {})\n : templatedEndpoint?.body?.content ?? templatedEndpoint?.body,\n };\n\n log(\"Rendered text preview:\", renderedText);\n log(\"toSend (redacted):\", redact(toSend));\n\n return {\n json: {\n toSend,\n },\n };\n});\n\nreturn items;" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1280, 928 ], "id": "d4230566-9ef7-4151-9c01-1d381f7f5cb1", "name": "Template messages", "alwaysOutputData": false }, { "parameters": { "jsCode": "// n8n Code node (Run Once for All Items)\n// Filters recipients by allowedDeviceIds / allowedDataTypes ONLY when those props exist.\n\nconst wrapper = $input.first().json;\nconst config = wrapper.config;\nconst event = wrapper.event;\n\nif (!config?.recipients) throw new Error('Missing config.recipients');\nif (!event?.body?.device?.id) throw new Error('Missing event.body.device.id');\nif (!event?.body?.data?.type) throw new Error('Missing event.body.data.type');\n\nconst deviceId = event.body.device.id;\nconst dataType = event.body.data.type;\n\n\nconst isNotDisabled = (recipient) => {\n if (recipient.disabled === true) return false;\n return true;\n};\n\nconst isAllowed = (recipient) => {\n // If prop is missing/null => do NOT filter by it (recipient remains)\n if (Array.isArray(recipient.allowedDeviceIds) && recipient.allowedDeviceIds.length > 0) {\n if (!recipient.allowedDeviceIds.includes(deviceId)) return false;\n }\n\n if (Array.isArray(recipient.allowedDataTypes) && recipient.allowedDataTypes.length > 0) {\n if (!recipient.allowedDataTypes.includes(dataType)) return false;\n }\n\n return true;\n};\n\nconst out = Object.entries(config.recipients)\n .filter(([, recipient]) => isNotDisabled(recipient))\n .filter(([, recipient]) => isAllowed(recipient))\n .map(([id, recipient]) => (id));\n\nreturn [\n {\n json: {\n event,\n config,\n recipientsFilteredGlobally: out\n }\n }\n];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 240, 928 ], "id": "f57cda86-1fc8-4137-bba9-2bba889f00f7", "name": "Filter recipients by DeviceId and DataType filters" }, { "parameters": { "jsCode": "// n8n Code node (Run Once for All Items)\n\n// Toggle this to silence logs quickly\nconst DEBUG = true;\n\nconst log = (...args) => {\n if (DEBUG) console.log(\"[debug]\", ...args);\n};\n\nconst redact = (value) => {\n const SECRET_KEY_RE = /(token|secret|password|apiKey|key)/i;\n\n const walk = (v) => {\n if (v == null) return v;\n if (Array.isArray(v)) return v.map(walk);\n\n if (typeof v === \"object\") {\n const out = {};\n for (const [k, child] of Object.entries(v)) {\n if (SECRET_KEY_RE.test(k)) out[k] = \"***REDACTED***\";\n else out[k] = walk(child);\n }\n return out;\n }\n\n return v;\n };\n\n return walk(value);\n};\n\nlog(\"Input item count:\", $input.all().length);\n\nconst wrapper = $input.first().json;\nlog(\"Wrapper keys:\", Object.keys(wrapper ?? {}));\n\nconst config = wrapper.config;\nconst event = wrapper.event;\nconst recipientsResolved = wrapper.recipientsResolved;\n\nlog(\"Has config?\", !!config, \"Has event?\", !!event);\nlog(\"Config (redacted):\", redact(config));\nlog(\"Event keys:\", Object.keys(event ?? {}));\nlog(\"Event.body keys:\", Object.keys(event?.body ?? {}));\n\nif (!config) throw new Error(\"Missing config\");\nif (!event) throw new Error(\"Missing event\");\nif (!recipientsResolved) throw new Error(\"Missing RecipientsResolved\");\n\n// meta is what templates typically refer to as {meta.*}\nconst meta = {\n id: event.body.id,\n createdAt: event.body.createdAt,\n deviceId: event.body.device.id,\n source: event.body.source,\n ...(event.body.meta ?? {}),\n};\nlog(\"meta:\", meta);\n\n/**\n * @type {Array}\n */\nconst recipients = recipientsResolved;\n\nlog(\"recipient ids:\", recipients.map((r) => r.id).flat().join(\", \"));\n\nfor (const recipient of recipientsResolved) {\n\n const rulesFiltered = [...recipient.rules];\n\n /**\n * @type {Array}\n */\n const recipientEndpointIds = config.recipients[recipient.id]?.endpointIds ?? [];\n /**\n * @type {Array}\n */\n const recipientTemplateIds = config.recipients[recipient.id]?.templateIds ?? [];\n for (const rule of recipient.rules) {\n // filter rules by recipient.endpointIds (if any)\n /**\n * @type {Array}\n */\n const ruleEndpointIds = [...rule.then.endpointIds];\n\n if (recipientEndpointIds.length > 0 && ruleEndpointIds.length > 0) {\n if (recipientEndpointIds.some(item => ruleEndpointIds.includes(item))) {\n rule.then.endpointIds = ruleEndpointIds.filter(id => recipientEndpointIds.includes(id));\n log(`Found matching endpoints for rule ${rule.id}:`, rule.then.endpointIds);\n } else {\n const index = rulesFiltered.findIndex(r => r.id === rule.id);\n if (index > -1) {\n log(`No matching endpoints for rule ${rule.id}, removing it`);\n rulesFiltered.splice(index, 1);\n }\n }\n }\n\n // leave only unique\n rule.then.endpointIds.splice(0, rule.then.endpointIds.length, ...new Set(rule.then.endpointIds));\n \n // filter rules by recipient.templateIds (if any)\n /**\n * @type {Array}\n */\n const ruleTemplateIds = [...rule.then.templateIds];\n\n if (recipientTemplateIds.length > 0 && ruleTemplateIds.length > 0) {\n if (recipientTemplateIds.some(item => ruleTemplateIds.includes(item))) {\n rule.then.templateIds = ruleTemplateIds.filter(id => recipientTemplateIds.includes(id));\n log(`Found matching templates for rule ${rule.id}:`, rule.then.templateIds);\n } else {\n const index = rulesFiltered.findIndex(r => r.id === rule.id);\n if (index > -1) {\n log(`No matching templates for rule ${rule.id}, removing it`);\n rulesFiltered.splice(index, 1);\n }\n }\n }\n\n // leave only unique\n rule.then.templateIds.splice(0, rule.then.templateIds.length, ...new Set(rule.then.templateIds));\n\n log(`Recipient ${recipient.id} filtered rules:`, recipient.rules.length, \"->\", rulesFiltered.length);\n\n recipient.rules = rulesFiltered;\n }\n}\n\n// Return RuntimeModel\nreturn [\n {\n json: {\n event,\n config,\n recipientsResolved\n }\n }\n];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 656, 928 ], "id": "b051e281-e924-4893-8dc7-c954533eebd0", "name": "Filter rules by recipients endpointIds and templateIds", "alwaysOutputData": false }, { "parameters": { "rules": { "values": [ { "conditions": { "options": { "caseSensitive": false, "leftValue": "", "typeValidation": "strict", "version": 3 }, "conditions": [ { "leftValue": "={{ $json.toSend.method }}", "rightValue": "POST", "operator": { "type": "string", "operation": "equals" }, "id": "eda2323a-bf78-4eb5-be11-f7e3abe9b155" } ], "combinator": "and" } }, { "conditions": { "options": { "caseSensitive": false, "leftValue": "", "typeValidation": "strict", "version": 3 }, "conditions": [ { "id": "cd76dc0d-3f7a-4a11-8e58-ff83dcff3c6e", "leftValue": "={{ $json.toSend.method }}", "rightValue": "GET", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" } } ] }, "options": { "ignoreCase": true } }, "type": "n8n-nodes-base.switch", "typeVersion": 3.4, "position": [ 32, 1232 ], "id": "141dd7d9-a224-4d7b-953b-ad1aee19cdf3", "name": "Switch" }, { "parameters": { "method": "={{ $json.toSend.method }}", "url": "={{ $json.toSend.url }}", "sendQuery": true, "specifyQuery": "json", "jsonQuery": "={{ JSON.stringify($json.toSend.searchParams ?? {}) }}", "sendHeaders": true, "specifyHeaders": "json", "jsonHeaders": "={{ JSON.stringify($json.toSend.headers ?? {}) }}", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ $json.toSend.body }}", "options": {} }, "id": "9eeb6e84-522a-4508-b7e9-2199ace47148", "name": "Send to Telegram (POST)", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 240, 1152 ] }, { "parameters": { "method": "={{ $json.toSend.method }}", "url": "={{ $json.toSend.url }}", "sendQuery": true, "specifyQuery": "json", "jsonQuery": "={{ JSON.stringify($json.toSend.searchParams) }}", "sendHeaders": true, "specifyHeaders": "json", "jsonHeaders": "={{ JSON.stringify($json.toSend.headers) }}", "options": {} }, "id": "e4fa2b8d-ee23-4dc8-8a82-745f1de4779b", "name": "Send to Telegram (GET)", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.3, "position": [ 240, 1312 ] }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 3 }, "conditions": [ { "id": "4d08dd15-fd42-4c1d-9d7a-32f7a00f5a9f", "leftValue": "={{ $json.headers[\"x-n8n-device-relay\"] }}", "rightValue": "", "operator": { "type": "string", "operation": "exists", "singleValue": true } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ 176, 0 ], "id": "a3b15a99-0a7d-43c7-8483-66abb6822e8e", "name": "If" }, { "parameters": { "errorMessage": "No token specified!" }, "type": "n8n-nodes-base.stopAndError", "typeVersion": 1, "position": [ 544, 16 ], "id": "a82e3290-7597-4388-9b8c-3fec6e0766aa", "name": "Stop and Error" }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 3 }, "conditions": [ { "id": "4d08dd15-fd42-4c1d-9d7a-32f7a00f5a9f", "leftValue": "={{ $input.all().length }}", "rightValue": 2, "operator": { "type": "number", "operation": "equals" } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ 32, 576 ], "id": "ca63c49b-8dab-48a3-9a48-d11b7e368e06", "name": "Validate config presence" }, { "parameters": { "errorMessage": "No config or webhook data are present" }, "type": "n8n-nodes-base.stopAndError", "typeVersion": 1, "position": [ 240, 752 ], "id": "07c7bf06-5882-42e3-a69d-9251f3aad799", "name": "Stop and Error - config presence" }, { "parameters": { "errorMessage": "No tokens in config" }, "type": "n8n-nodes-base.stopAndError", "typeVersion": 1, "position": [ 448, 752 ], "id": "e03ba66b-b594-4280-b465-93a32d84d291", "name": "Stop and Error - config tokens length" }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 3 }, "conditions": [ { "id": "4d08dd15-fd42-4c1d-9d7a-32f7a00f5a9f", "leftValue": "={{ $input.all()[1].json.tokens.length\n}}", "rightValue": 0, "operator": { "type": "number", "operation": "gt" } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ 240, 576 ], "id": "a5963014-8404-4991-856f-9aa8b0aafd17", "name": "Validate config tokens length" }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 3 }, "conditions": [ { "id": "4d08dd15-fd42-4c1d-9d7a-32f7a00f5a9f", "leftValue": "={{ $input.all()[1].json.tokens.some(tokenObj =>\n tokenObj.key === $input.all()[0].json.headers[\"x-n8n-device-relay\"]\n) }}", "rightValue": 1, "operator": { "type": "boolean", "operation": "exists", "singleValue": true } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ 448, 576 ], "id": "81838256-1a62-40b3-b229-d2ab2da45d46", "name": "Validate config token" }, { "parameters": { "errorMessage": "Provided token does not exist" }, "type": "n8n-nodes-base.stopAndError", "typeVersion": 1, "position": [ 656, 752 ], "id": "2a461526-7952-44f6-9f0b-8c945bf1d559", "name": "Stop and Error - validate config token" }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 3 }, "conditions": [ { "id": "4d08dd15-fd42-4c1d-9d7a-32f7a00f5a9f", "leftValue": "={{ $input.all()[0].json.body.data.type }}", "rightValue": 1, "operator": { "type": "string", "operation": "exists", "singleValue": true } }, { "id": "c40abd2c-cc8f-4a9b-b111-161e8c200f76", "leftValue": "={{ $input.all()[0].json.body.data.type }}", "rightValue": "", "operator": { "type": "string", "operation": "notEmpty", "singleValue": true } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ 864, 576 ], "id": "02ad6982-2dbe-4d9d-bc58-e09864fee258", "name": "Validate config data type" }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 3 }, "conditions": [ { "id": "4d08dd15-fd42-4c1d-9d7a-32f7a00f5a9f", "leftValue": "={{ $input.all()[0].json.body.device.id }}", "rightValue": 1, "operator": { "type": "string", "operation": "exists", "singleValue": true } }, { "id": "c40abd2c-cc8f-4a9b-b111-161e8c200f76", "leftValue": "={{ $input.all()[0].json.body.device.id }}", "rightValue": "", "operator": { "type": "string", "operation": "notEmpty", "singleValue": true } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ 1072, 576 ], "id": "329336f9-c3d2-4d7e-86c4-d814bf6478b5", "name": "Validate config device id" }, { "parameters": { "errorMessage": "data type does not exist or empty" }, "type": "n8n-nodes-base.stopAndError", "typeVersion": 1, "position": [ 1072, 752 ], "id": "38ec3f3d-43ed-47a0-9a09-4f6846d3f77c", "name": "Stop and Error - validate data type in event" }, { "parameters": { "errorMessage": "No Device ID provided" }, "type": "n8n-nodes-base.stopAndError", "typeVersion": 1, "position": [ 1280, 752 ], "id": "f865a3a8-5384-4594-b232-436e7b300b7a", "name": "Stop and Error - validate device id in event" }, { "parameters": { "content": "## Edit the config variable there ->\n" }, "type": "n8n-nodes-base.stickyNote", "position": [ 32, 144 ], "typeVersion": 1, "id": "8a744118-d758-4538-b584-8a333c3ed8f8", "name": "Sticky Note" }, { "parameters": { "jsCode": "// 0) Validate webhook payload type\nconst all = $input.all();\n\nconst eventItem = all[0].json; // from Webhook\nconst configItem = all[1].json; // from config node\n\nreturn [\n {\n json: {\n event: eventItem,\n config: configItem\n }\n }\n];\n" }, "id": "be92875d-81a2-404f-8c3d-62406f8aa788", "name": "Remap objects", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 32, 928 ] }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 3 }, "conditions": [ { "id": "4d08dd15-fd42-4c1d-9d7a-32f7a00f5a9f", "leftValue": "={{ $input.all()[1].json.tokens.find(tokenObj =>\n tokenObj.name === $input.all()[0].json.body.device.id\n).key }}", "rightValue": "={{ $input.all()[0].json.headers[\"x-n8n-device-relay\"] }}", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ 656, 576 ], "id": "73f9faf8-6332-4d21-be39-7e5f7b133d08", "name": "Validate config token per device" }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 3 }, "conditions": [ { "id": "4d08dd15-fd42-4c1d-9d7a-32f7a00f5a9f", "leftValue": "={{ $input.all()[1].json.tokens.find(tokenObj =>\n tokenObj.name.toLowerCase() === \"debug\").key }}", "rightValue": "={{ $input.all()[0].json.headers[\"x-n8n-device-relay\"] }}", "operator": { "type": "string", "operation": "notEquals" } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ 656, 384 ], "id": "1171c17b-f6a3-4171-a1b8-47f65dd1e76f", "name": "Is not debug device token" }, { "parameters": { "errorMessage": "Token does not match with defined device" }, "type": "n8n-nodes-base.stopAndError", "typeVersion": 1, "position": [ 864, 752 ], "id": "56bb80bc-ee26-41b7-b6b0-8024f3dc2791", "name": "Stop and Error - validate config per device" } ], "connections": { "Webhook": { "main": [ [ { "node": "If", "type": "main", "index": 0 } ] ] }, "Merge": { "main": [ [ { "node": "Validate config presence", "type": "main", "index": 0 } ] ] }, "Config": { "main": [ [ { "node": "Merge", "type": "main", "index": 1 } ] ] }, "Form outbound": { "main": [ [ { "node": "Compose messages", "type": "main", "index": 0 } ] ] }, "Compose messages": { "main": [ [ { "node": "Template messages", "type": "main", "index": 0 } ] ] }, "Filter recipients by recipient rules": { "main": [ [ { "node": "Filter rules by recipients endpointIds and templateIds", "type": "main", "index": 0 } ] ] }, "Template messages": { "main": [ [ { "node": "Switch", "type": "main", "index": 0 } ] ] }, "Filter recipients by DeviceId and DataType filters": { "main": [ [ { "node": "Filter recipients by recipient rules", "type": "main", "index": 0 } ] ] }, "Filter rules by recipients endpointIds and templateIds": { "main": [ [ { "node": "Form outbound", "type": "main", "index": 0 } ] ] }, "Switch": { "main": [ [ { "node": "Send to Telegram (POST)", "type": "main", "index": 0 } ], [ { "node": "Send to Telegram (GET)", "type": "main", "index": 0 } ] ] }, "If": { "main": [ [ { "node": "Config", "type": "main", "index": 0 }, { "node": "Merge", "type": "main", "index": 0 } ], [ { "node": "Stop and Error", "type": "main", "index": 0 } ] ] }, "Validate config presence": { "main": [ [ { "node": "Validate config tokens length", "type": "main", "index": 0 } ], [ { "node": "Stop and Error - config presence", "type": "main", "index": 0 } ] ] }, "Validate config tokens length": { "main": [ [ { "node": "Validate config token", "type": "main", "index": 0 } ], [ { "node": "Stop and Error - config tokens length", "type": "main", "index": 0 } ] ] }, "Validate config token": { "main": [ [ { "node": "Is not debug device token", "type": "main", "index": 0 } ], [ { "node": "Stop and Error - validate config token", "type": "main", "index": 0 } ] ] }, "Validate config data type": { "main": [ [ { "node": "Validate config device id", "type": "main", "index": 0 } ], [ { "node": "Stop and Error - validate data type in event", "type": "main", "index": 0 } ] ] }, "Validate config device id": { "main": [ [ { "node": "Remap objects", "type": "main", "index": 0 } ], [ { "node": "Stop and Error - validate device id in event", "type": "main", "index": 0 } ] ] }, "Remap objects": { "main": [ [ { "node": "Filter recipients by DeviceId and DataType filters", "type": "main", "index": 0 } ] ] }, "Validate config token per device": { "main": [ [ { "node": "Validate config data type", "type": "main", "index": 0 } ], [ { "node": "Stop and Error - validate config per device", "type": "main", "index": 0 } ] ] }, "Is not debug device token": { "main": [ [ { "node": "Validate config token per device", "type": "main", "index": 0 } ], [ { "node": "Validate config data type", "type": "main", "index": 0 } ] ] } }, "pinData": {}, "meta": { "templateCredsSetupCompleted": true, "instanceId": "" } }