Files
Homelab--Bratonein-Kontroll…/Kapitel 10/Stream Reminder Post.json

548 lines
19 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{
"name": "My workflow",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "minutes",
"minutesInterval": 30
}
]
}
},
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
640,
256
],
"id": "2d3ac9d8-0d79-40dd-8213-e14493a1e8b0",
"name": "Schedule Trigger"
},
{
"parameters": {
"method": "POST",
"url": "https://id.twitch.tv/oauth2/token",
"sendBody": true,
"contentType": "form-urlencoded",
"bodyParameters": {
"parameters": [
{
"name": "client_id",
"value": "qmy3gi7t40180kqmglyqqd2ayuokud"
},
{
"name": "client_secret",
"value": "rj4x8x1uyfadl67qeqr52ef4jfpzmv"
},
{
"name": "grant_type",
"value": "client_credentials"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1216,
160
],
"id": "dabbbc0c-b6f2-41b4-98f1-69f822b654b7",
"name": "Access Token"
},
{
"parameters": {
"url": "=https://api.twitch.tv/helix/schedule",
"authentication": "genericCredentialType",
"genericAuthType": "oAuth2Api",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "broadcaster_id",
"value": "468013650"
},
{
"name": "start_time",
"value": "={{ $now.toUTC().startOf('day').toISO() }}"
}
]
},
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Client-ID",
"value": "qmy3gi7t40180kqmglyqqd2ayuokud"
}
]
},
"options": {
"response": {
"response": {
"responseFormat": "text",
"outputPropertyName": "body"
}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1808,
256
],
"id": "235dd7fb-12f3-4afb-8eba-dbd81ded658e",
"name": "Termine laden",
"credentials": {
"httpBearerAuth": {
"id": "nIGvz1OpeNp0rRNd",
"name": "Bearer Auth account"
},
"oAuth2Api": {
"id": "yno3OtiN2TqTOGUP",
"name": "Twitch"
}
}
},
{
"parameters": {
"jsCode": "// Speichert neuen Token + Ablaufzeit in Workflow-Static-Data\nconst sd = $getWorkflowStaticData('global');\n\nconst access = $json.access_token;\nconst ttlSec = Number($json.expires_in || 0);\n\n// Puffer ist hier nicht nötig, weil wir täglich prüfen und bei <24h erneuern\nconst expISO = new Date(Date.now() + ttlSec*1000).toISOString();\n\nsd.twitch_token = access;\nsd.twitch_expires_at = expISO;\n\nconst msLeft = (new Date(expISO).getTime() - Date.now());\nconst hoursLeft = msLeft / (1000*60*60);\n\nreturn [{\n json: {\n token: access,\n expires_at: expISO,\n hoursLeft: Number(hoursLeft.toFixed(2))\n }\n}];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1392,
160
],
"id": "5d926795-c3be-4fe9-97e3-61374c47728e",
"name": "Token speichern"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "81523d9e-67f7-48b7-8a3a-2d8329f88e9c",
"name": "token",
"value": "={{$json.token}}",
"type": "string"
},
{
"id": "fac52dff-193d-49c0-af6a-cb6b51fa4fc7",
"name": "expires_at",
"value": "={{$json.expires_at}}",
"type": "string"
},
{
"id": "95285159-b09f-4c02-8790-355661d71a38",
"name": "hoursLeft",
"value": "={{$json.hoursLeft}}",
"type": "string"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1296,
336
],
"id": "c9c6995c-e5f6-4d28-9f92-8f866deb771e",
"name": "Token aus Cache"
},
{
"parameters": {
"jsCode": "// Liest gespeicherten Token aus Workflow-Static-Data und berechnet Restlaufzeit\nconst sd = $getWorkflowStaticData('global');\nconst token = sd.twitch_token || '';\nconst expISO = sd.twitch_expires_at || '';\nconst expMs = expISO ? new Date(expISO).getTime() : 0;\n\nconst now = Date.now();\nconst msLeft = expMs ? (expMs - now) : 0;\nconst hoursLeft = msLeft > 0 ? (msLeft / (1000*60*60)) : 0;\n\n// true, wenn es keinen Token gibt ODER weniger als 24h übrig sind\nconst needsRefresh = !token || !expMs || hoursLeft < 24;\n\nreturn [{\n json: {\n needsRefresh,\n token,\n expires_at: expISO,\n hoursLeft: Number(hoursLeft.toFixed(2))\n }\n}];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
832,
256
],
"id": "15a8c0da-fef0-4219-be31-d5ae047ce242",
"name": "Access Token prüfen"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "b542f5d2-789c-4a00-b8eb-3923727306f4",
"leftValue": "={{$json.needsRefresh}}",
"rightValue": "true",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "or"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1008,
256
],
"id": "ce16840f-c37d-4b0a-9f3d-c887c7acfb52",
"name": "Access Token erneunern"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
1600,
256
],
"id": "880bcf94-2c74-4f49-9a52-e7d86008021e",
"name": "Token bereitstellen"
},
{
"parameters": {
"jsCode": "/**\n * INPUT: items[0].json ODER items[0].json.body (String) vom HTTP Request /helix/schedule\n * OUTPUT: pro Segment ein Item in deinem Schema:\n * { uid, cancelled, title, description, startIso, endIso, tz, hash }\n */\n\nconst input = items[0]?.json ?? {};\nlet payload;\n\nif (typeof input.body === 'string') {\n // HTTP-Node hat \"Response Format: Text\" -> JSON-String in body\n try { payload = JSON.parse(input.body); }\n catch (e) { throw new Error('HTTP body ist kein gültiges JSON'); }\n} else if (input.data) {\n // HTTP-Node hat \"Response Format: JSON\"\n payload = input;\n} else if (typeof input === 'string') {\n payload = JSON.parse(input);\n} else {\n throw new Error('Unerwartete Eingabeform. Erwartet JSON oder body-String.');\n}\n\nconst segments = Array.isArray(payload.data?.segments) ? payload.data.segments : [];\n\n// Hash wie in deinem ICS-Code\nfunction hashHex(s) {\n let h = 5381;\n for (let i = 0; i < s.length; i++) h = ((h << 5) + h) ^ s.charCodeAt(i);\n return (h >>> 0).toString(16).padStart(8, '0');\n}\n\nfunction buildTitle() { return 'Streamtime'; }\n\nfunction buildDesc(seg) {\n const lines = [];\n if (seg.title) lines.push(`Originaltitel: ${seg.title}`);\n if (seg.category?.name) lines.push(`Kategorie/Game: ${seg.category.name}`);\n return lines.join('\\n');\n}\n\nconst tz = 'Europe/Berlin';\n\nconst out = segments.map(seg => {\n const startIso = seg.start_time; // ISO-UTC laut Twitch\n const endIso = seg.end_time;\n const title = buildTitle();\n const description = buildDesc(seg);\n const hash = hashHex(`${title}|${startIso}|${endIso}|${description}`);\n const kategorie = seg.category?.name;\n return {\n json: {\n uid: seg.id,\n cancelled: Boolean(seg.canceled_until),\n title, description,\n startIso, endIso, tz,\n hash, kategorie \n }\n };\n});\n\nreturn out;\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1984,
256
],
"id": "96fe4d28-a423-4bb2-863c-14e9111c94c7",
"name": "Parse ICS",
"alwaysOutputData": false
},
{
"parameters": {
"jsCode": "/**\n * INPUT: Array von Items aus \"Parse ICS\"\n * OUTPUT: nur das nächste bevorstehende Event\n */\n\nconst now = Date.now();\n\nconst upcoming = items\n .map(i => i.json)\n .filter(e => !e.cancelled && new Date(e.startIso).getTime() > now)\n .sort((a, b) => new Date(a.startIso) - new Date(b.startIso));\n\nif (upcoming.length === 0) {\n return [{ json: { message: 'Kein bevorstehender Stream gefunden' } }];\n}\n\nconst next = upcoming[0];\n\nreturn [{\n json: {\n uid: next.uid,\n title: next.title,\n kategroie: next.kategorie,\n description: next.description,\n startIso: next.startIso,\n endIso: next.endIso,\n tz: next.tz,\n hash: next.hash\n }\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2192,
256
],
"id": "b1aaf439-5c1f-495f-9d7e-a4d425588b0a",
"name": "Nächstes Event finden"
},
{
"parameters": {
"jsCode": "/**\n * Prüft:\n * 1. Startet der Stream in ≤ 30 Minuten?\n * 2. Wurde bereits ein Post erstellt?\n *\n * Zeitumwandlung: Twitch liefert UTC (\"2025-10-15T16:00:00Z\").\n * Wir rechnen in lokale Zeit (Europe/Berlin), um MEZ/MESZ korrekt zu berücksichtigen.\n */\n\nconst event = items[0]?.json ?? {};\nif (!event.startIso) {\n\treturn [{\n\t\tjson: {\n\t\t\tpostAllowed: false,\n\t\t\treason: 'Kein Termin gefunden'\n\t\t}\n\t}];\n}\n\n// Aktuelle Zeit + Startzeit in lokale Zeitzone überführen\nconst tz = 'Europe/Berlin';\nconst now = new Date(); // lokale Zeit des n8n-Servers\nconst startUtc = new Date(event.startIso);\n\n// Startzeit in lokales Format (inkl. Sommer-/Winterzeit)\nconst startLocal = new Date(startUtc.toLocaleString('en-US', { timeZone: tz }));\n\n// Differenz in Minuten\nconst diffMin = (startLocal - now) / 60000;\nconst within30 = diffMin > 0 && diffMin <= 30;\n\n// Zugriff auf global gespeicherte IDs\nconst sd = $getWorkflowStaticData('global');\nsd.postedIds = sd.postedIds || [];\n\nconst alreadyPosted = sd.postedIds.includes(event.uid);\n\n// Wenn innerhalb von 30 Minuten und noch kein Post → erlauben\nif (within30 && !alreadyPosted) {\n\tsd.postedIds.push(event.uid);\n\treturn [{\n\t\tjson: {\n\t\t\t...event,\n\t\t\tpostAllowed: true,\n\t\t\treason: 'Stream startet bald, noch kein Post vorhanden',\n\t\t\tlocalStart: startLocal.toISOString(),\n\t\t\tdiffMinutes: Math.round(diffMin)\n\t\t}\n\t}];\n}\n\n// Keine Aktion notwendig\nreturn [{\n\tjson: {\n\t\t...event,\n\t\tpostAllowed: false,\n\t\treason: alreadyPosted\n\t\t\t? 'Für diesen Stream wurde bereits gepostet'\n\t\t\t: 'Stream liegt außerhalb des 30-Minuten-Fensters',\n\t\tlocalStart: startLocal.toISOString(),\n\t\tdiffMinutes: Math.round(diffMin)\n\t}\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2400,
256
],
"id": "e86d62a4-9103-44a2-abe6-eef933c05d33",
"name": "Stream prüfen"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "3578de2a-a878-419d-9e1d-bf734a9f8096",
"leftValue": "={{ $json.postAllowed }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
2608,
256
],
"id": "81577108-fdab-4b7f-bf6a-2c4a75be1d3b",
"name": "If"
},
{
"parameters": {
"operation": "download",
"path": "/n8n/posts.txt"
},
"type": "n8n-nodes-base.nextCloud",
"typeVersion": 1,
"position": [
3296,
96
],
"id": "b3694213-54fb-4987-98b3-156b7d49119d",
"name": "Download a file",
"credentials": {
"nextCloudApi": {
"id": "j4cdlVf8Sw2OpA0m",
"name": "NextCloud account"
}
}
},
{
"parameters": {
"jsCode": "/**\n * Liest posts.txt direkt aus dem Binary-Feld \"data\" (vom Nextcloud-Node)\n * und wählt zufällig eine Zeile aus.\n * Wenn keine Datei oder kein Inhalt vorhanden ist, wird ein Fallback-Text aus einer Liste genutzt.\n * Zusätzlich wird der Startzeitpunkt des Streams in lokaler Zeit (MEZ/MESZ) angehängt.\n */\n\nlet content = '';\ntry {\n const buffer = items[0].binary?.data?.data;\n if (buffer) {\n content = Buffer.from(buffer, 'base64').toString('utf8');\n }\n} catch (err) {\n content = '';\n}\n\n// Inhalt in Zeilen aufteilen\nconst lines = content.split('\\n').map(l => l.trim()).filter(Boolean);\n\n// Fallback-Texte\nconst fallbackPosts = [\n 'Der Stream startet gleich holt euch Snacks und seid dabei! 🎮',\n 'Bald gehts los heute wird wieder gezockt! 🔥',\n 'Noch kurz durchatmen gleich gehts live auf Sendung! 🚀',\n 'In wenigen Minuten live Kaffee bereit? ☕',\n 'Los gehts der Chat wartet schon auf euch! 💬',\n 'Heute gibts wieder gute Laune und Gaming pur! 💜'\n];\n\n// Textauswahl\nlet postText;\nif (lines.length > 0) {\n const randomIndex = Math.floor(Math.random() * lines.length);\n postText = lines[randomIndex];\n} else {\n const randomIndex = Math.floor(Math.random() * fallbackPosts.length);\n postText = fallbackPosts[randomIndex];\n}\n\n// Startzeitpunkt des Streams aus vorherigem Node lesen (startIso)\nlet startIso = items[0].json?.startIso || null;\nlet startLocal = '';\n\nif (startIso) {\n try {\n const date = new Date(startIso);\n const formatter = new Intl.DateTimeFormat('de-DE', {\n weekday: 'short',\n hour: '2-digit',\n minute: '2-digit',\n timeZone: 'Europe/Berlin',\n hour12: false\n });\n startLocal = formatter.format(date);\n } catch (err) {\n startLocal = '';\n }\n}\n\nif (startLocal) {\n postText = `${postText} 🎬 Start: ${startLocal}`;\n}\n\n// Weitergabe an nächste Nodes\nreturn [{\n json: {\n ...items[0].json,\n postText\n }\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
3440,
96
],
"id": "10583c07-00f8-4b74-9397-a0fb8804ddb4",
"name": "Posttext auswählen"
},
{
"parameters": {
"text": "={{ $json.postText }}",
"additionalFields": {}
},
"type": "n8n-nodes-base.twitter",
"typeVersion": 2,
"position": [
3824,
240
],
"id": "14f6ea83-a1fc-4d9c-a8e4-49fea946ab8b",
"name": "X Post veröffentlichen",
"credentials": {
"twitterOAuth2Api": {
"id": "FBWhPuyEfuMJFdKU",
"name": "X account"
}
},
"disabled": true
},
{
"parameters": {
"jsCode": "return items.map(item => {\n if (item.binary && item.binary.data) {\n delete item.binary.data; // Entfernt die heruntergeladene Datei aus dem Speicher\n }\n return item;\n});"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
4000,
240
],
"id": "8dd18b30-a935-4bd3-8d6c-8abd11890de4",
"name": "Code"
}
],
"pinData": {},
"connections": {
"Schedule Trigger": {
"main": [
[
{
"node": "Access Token prüfen",
"type": "main",
"index": 0
}
]
]
},
"Access Token": {
"main": [
[
{
"node": "Token speichern",
"type": "main",
"index": 0
}
]
]
},
"Termine laden": {
"main": [
[
{
"node": "Parse ICS",
"type": "main",
"index": 0
}
]
]
},
"Token speichern": {
"main": [
[
{
"node": "Token bereitstellen",
"type": "main",
"index": 0
}
]
]
},
"Token aus Cache": {
"main": [
[
{
"node": "Token bereitstellen",
"type": "main",
"index": 1
}
]
]
},
"Access Token prüfen": {
"main": [
[
{
"node": "Access Token erneunern",
"type": "main",
"index": 0
}
]
]
},
"Access Token erneunern": {
"main": [
[
{
"node": "Access Token",
"type": "main",
"index": 0
}
],
[
{
"node": "Token aus Cache",
"type": "main",
"index": 0
}
]
]
},
"Token bereitstellen": {
"main": [
[
{
"node": "Termine laden",
"type": "main",
"index": 0
}
]
]
},
"Parse ICS": {
"main": [
[
{
"node": "Nächstes Event finden",
"type": "main",
"index": 0
}
]
]
},
"Nächstes Event finden": {
"main": [
[
{
"node": "Stream prüfen",
"type": "main",
"index": 0
}
]
]
},
"Stream prüfen": {
"main": [
[
{
"node": "If",
"type": "main",
"index": 0
}
]
]
},
"If": {
"main": [
[
{
"node": "Download a file",
"type": "main",
"index": 0
}
]
]
},
"Download a file": {
"main": [
[
{
"node": "Posttext auswählen",
"type": "main",
"index": 0
}
]
]
},
"Posttext auswählen": {
"main": [
[
{
"node": "X Post veröffentlichen",
"type": "main",
"index": 0
}
]
]
},
"X Post veröffentlichen": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1",
"timezone": "Europe/Berlin",
"callerPolicy": "workflowsFromSameOwner"
},
"versionId": "fb5ad769-df0b-4258-8d3e-80b8b9899ef2",
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "4edc03810a5a67ca6728ea68a9aec5a82547f97ec5ae65df9f22d521c302e706"
},
"id": "ITXQNyneGAVBMnoa",
"tags": []
}