Files
swarm-zap/skills/n8n-webhook/assets/openclaw-action.workflow.json

219 lines
35 KiB
JSON
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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": "openclaw-action",
"nodes": [
{
"id": "webhook-openclaw-action",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
-700,
40
],
"parameters": {
"httpMethod": "POST",
"path": "openclaw-action",
"authentication": "none",
"responseMode": "responseNode",
"options": {}
}
},
{
"id": "route-action",
"name": "route-action",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-420,
40
],
"parameters": {
"mode": "runOnceForEachItem",
"language": "javaScript",
"jsCode": "return (async () => {\n const body = ($json.body && typeof $json.body === 'object') ? $json.body : $json;\n const action = typeof body.action === 'string' ? body.action : '';\n const args = (body.args && typeof body.args === 'object' && body.args !== null) ? body.args : {};\n const requestId = typeof body.request_id === 'string' ? body.request_id : '';\n const now = new Date().toISOString();\n const workflowStaticData = $getWorkflowStaticData('global');\n const maxLogEntries = 200;\n const maxQueueEntries = 200;\n const maxHistoryEntries = 200;\n const maxEventEntries = 200;\n const maxDedupEntries = 500;\n\n const clamp = (value, min, max, fallback) => {\n const num = Number(value);\n if (!Number.isFinite(num)) {\n return fallback;\n }\n return Math.max(min, Math.min(max, Math.trunc(num)));\n };\n\n const asString = (value, fallback = '') => (typeof value === 'string' ? value : fallback);\n\n const isPlainObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);\n\n const trimText = (value) => asString(value).trim();\n\n const previewText = (value, max = 160) => {\n const text = trimText(value).replace(/\\s+/g, ' ');\n if (text.length <= max) {\n return text;\n }\n return `${text.slice(0, Math.max(0, max - 1)).trimEnd()}…`;\n };\n\n const ensureStringArray = (value) => {\n if (Array.isArray(value)) {\n return value.map((entry) => trimText(entry)).filter(Boolean);\n }\n if (typeof value === 'string') {\n const trimmed = trimText(value);\n return trimmed ? [trimmed] : [];\n }\n return [];\n };\n\n const pushRetained = (list, item, maxEntries) => {\n const target = Array.isArray(list) ? list : [];\n target.push(item);\n if (target.length > maxEntries) {\n target.splice(0, target.length - maxEntries);\n }\n return target;\n };\n\n const makeId = (prefix) => {\n const rand = Math.random().toString(36).slice(2, 10);\n return `${prefix}-${Date.now().toString(36)}-${rand}`;\n };\n\n const htmlEntityDecode = (value) => {\n const text = asString(value)\n .replace(/&nbsp;/gi, ' ')\n .replace(/&amp;/gi, '&')\n .replace(/&lt;/gi, '<')\n .replace(/&gt;/gi, '>')\n .replace(/&quot;/gi, '\"')\n .replace(/&#39;/gi, \"'\");\n return text;\n };\n\n const stripHtml = (value) => htmlEntityDecode(\n asString(value)\n .replace(/<script[\\s\\S]*?<\\/script>/gi, ' ')\n .replace(/<style[\\s\\S]*?<\\/style>/gi, ' ')\n .replace(/<noscript[\\s\\S]*?<\\/noscript>/gi, ' ')\n .replace(/<[^>]+>/g, ' ')\n .replace(/\\s+/g, ' ')\n ).trim();\n\n const normalizeHeaders = (headers) => {\n if (!headers || typeof headers.entries !== 'function') {\n return {};\n }\n return Object.fromEntries(Array.from(headers.entries()).map(([key, value]) => [String(key).toLowerCase(), String(value)]));\n };\n\n const getActionLog = () => Array.isArray(workflowStaticData.actionLog) ? workflowStaticData.actionLog : [];\n const getApprovalQueue = () => Array.isArray(workflowStaticData.approvalQueue) ? workflowStaticData.approvalQueue : [];\n const getApprovalHistory = () => Array.isArray(workflowStaticData.approvalHistory) ? workflowStaticData.approvalHistory : [];\n const getInboundEvents = () => Array.isArray(workflowStaticData.inboundEvents) ? workflowStaticData.inboundEvents : [];\n const getEventDedup = () => Array.isArray(workflowStaticData.eventDedup) ? workflowStaticData.eventDedup : [];\n\n const persistActionLog = (entry) => {\n workflowStaticData.actionLog = pushRetained(getActionLog(), entry, maxLogEntries);\n };\n\n const logAction = (text, meta = undefined) => {\n persistActionLog({\n ts: now,\n source: 'openclaw-action',\n request_id: requestId,\n text,\n meta: isPlainObject(meta) ? meta : undefined,\n });\n };\n\n const enqueueApproval = ({ kind, summary, payload, tags = [] }) => {\n const item = {\n id: makeId('approval'),\n kind,\n status: 'pending',\n created_at: now,\n updated_at: now,\n request_id: requestId,\n summary,\n payload,\n tags: Array.isArray(tags) ? tags.filter(Boolean) : [],\n };\n workflowStaticData.approvalQueue = pushRetained(getApprovalQueue(), item, maxQueueEntries);\n return item;\n };\n\n const makeApprovalPolicy = (mutationLevel = 'none') => ({\n policy: 'approval_queue_resolve',\n required: mutationLevel !== 'none',\n mutation_level: mutationLevel,\n reason: mutationLevel !== 'none'\n ? 'requires explicit approval before host-side Google action'\n : 'read_only_action',\n });\n\n const normalizeFetchResponse = ({ inputUrl, response, rawBody, maxChars }) => {\n const headers = normalizeHeaders(response.headers);\n const contentType = headers['content-type'] || '';\n const isHtml = /text\\/html|application\\/xhtml\\+xml/i.test(contentType);\n const isJson = /application\\/json|application\\/ld\\+json|application\\/problem\\+json/i.test(contentType);\n const titleMatch = isHtml ? rawBody.match(/<title[^>]*>([\\s\\S]*?)<\\/title>/i) : null;\n const title = titleMatch ? previewText(stripHtml(titleMatch[1]), 200) : '';\n let bodyText = '';\n if (isHtml) {\n bodyText = stripHtml(rawBody);\n } else if (isJson) {\n try {\n bodyText = JSON.stringify(JSON.parse(rawBody), null, 2);\n } catch {\n bodyText = rawBody;\n }\n } else {\n bodyText = rawBody;\n }\n const normalized = bodyText.slice(0, maxChars);\n return {\n url: response.url || inputUrl,\n fetched_url: inputUrl,\n title,\n http_status: response.status,\n content_type: contentType || 'application/octet-stream',\n excerpt: previewText(normalized, 320),\n body_text: normalized,\n text_length: bodyText.length,\n truncated: bodyText.length > normalized.length,\n headers,\n };\n };\n\n const classifyInboundEvent = (eventArgs) => {\n const source = trimText(eventArgs.source || 'event');\n const type = trimText(eventArgs.type || 'event');\n const summary = previewText(eventArgs.summary || eventArgs.message || (isPlainObject(eventArgs.event) ? JSON.stringify(eventArgs.event) : ''), 280);\n const severity = trimText(eventArgs.severity || '').toLowerCase();\n const priority = trimText(eventArgs.priority || '').toLowerCase();\n const extraText = isPlainObject(eventArgs.event) ? JSON.stringify(eventArgs.event) : '';\n const haystack = `${summary} ${extraText}`.toLowerCase();\n let classification = 'watch';\n let shouldNotify = false;\n\n if (\n ['critical', 'urgent', 'sev1', 'sev2', 'high'].includes(severity) ||\n ['critical', 'urgent', 'p1', 'p2', 'high'].includes(priority) ||\n /(outage|down|failed|failure|critical|urgent|blocked|sev1|sev2|database unavailable|cannot reach|immediately|asap)/.test(haystack)\n ) {\n classification = 'urgent';\n shouldNotify = true;\n } else if (\n /(delivery|delivered|package|submission|alert|invoice|payment|review requested|build failed|incident|ticket)/.test(haystack)\n ) {\n classification = 'important';\n shouldNotify = true;\n }\n\n return { source, type, summary, severity, priority, classification, shouldNotify };\n };\n\n let route = 'respond';\n let statusCode = 400;\n let responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'unknown_action', message: 'action is not supported' },\n };\n let notifyText = '';\n\n if (action === 'append_log') {\n if (typeof args.text === 'string' && trimText(args.text).length > 0) {\n statusCode = 200;\n const record = {\n ts: now,\n source: trimText(args.source || 'openclaw-action'),\n request_id: requestId,\n text: trimText(args.text),\n meta: isPlainObject(args.meta) ? args.meta : undefined,\n };\n persistActionLog(record);\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'append_log',\n status: 'logged',\n preview: { text: record.text },\n sink: {\n type: 'workflow-static-data',\n key: 'actionLog',\n retained_entries: maxLogEntries,\n },\n },\n };\n } else {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'invalid_request', message: 'required args are missing' },\n };\n }\n } else if (action === 'get_logs') {\n const actionLog = getActionLog();\n const limit = clamp(args.limit, 1, 50, 20);\n const entries = actionLog.slice(-limit).reverse();\n statusCode = 200;\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'get_logs',\n status: 'ok',\n count: entries.length,\n total_retained: actionLog.length,\n retained_entries: maxLogEntries,\n entries,\n },\n };\n } else if (action === 'send_notification_draft') {\n const title = trimText(args.title || '');\n const message = trimText(args.message || '');\n if (message) {\n statusCode = 200;\n const item = enqueueApproval({\n kind: 'notification',\n summary: title ? `Notification draft — ${title}` : `Notification draft — ${previewText(message, 80)}`,\n payload: {\n title,\n message,\n },\n tags: ['notify', 'approval'],\n });\n logAction(`queued notification draft ${item.id}`, { pending_id: item.id, title_present: Boolean(title) });\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'send_notification_draft',\n status: 'queued_for_approval',\n pending_id: item.id,\n approval_status: item.status,\n approval: makeApprovalPolicy('high'),\n preview: {\n title,\n message,\n },\n sink: {\n type: 'workflow-static-data',\n key: 'approvalQueue',\n retained_entries: maxQueueEntries,\n },\n },\n };\n } else {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'invalid_request', message: 'send_notification_draft requires message' },\n };\n }\n } else if (action === 'notify') {\n if (typeof args.message === 'string' && trimText(args.message).length > 0) {\n route = 'notify';\n statusCode = 200;\n const title = trimText(args.title || '');\n notifyText = title ? `🔔 ${title}\\n${trimText(args.message)}` : `🔔 ${trimText(args.message)}`;\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'notify',\n status: 'sent',\n preview: { title, message: trimText(args.message) },\n targets: ['telegram', 'discord'],\n },\n };\n logAction(`notify: ${previewText(`${title} ${args.message}`, 180)}`);\n } else {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'invalid_request', message: 'required args are missing' },\n };\n }\n } else if (action === 'send_email_draft') {\n const to = ensureStringArray(args.to);\n const cc = ensureStringArray(args.cc);\n const bcc = ensureStringArray(args.bcc);\n const subject = trimText(args.subject || '');\n const bodyText = trimText(args.body_text || '');\n const bodyHtml = trimText(args.body_html || '');\n\n if (to.length > 0 && subject && (bodyText || bodyHtml)) {\n statusCode = 200;\n const item = enqueueApproval({\n kind: 'email_draft',\n summary: `Email draft to ${to.join(', ')} — ${subject}`,\n payload: {\n to,\n cc,\n bcc,\n subject,\n body_text: bodyText,\n body_html: bodyHtml,\n },\n tags: ['email', 'approval'],\n });\n logAction(`queued email draft ${item.id}`, { pending_id: item.id, subject, to_count: to.length });\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'send_email_draft',\n status: 'queued_for_approval',\n pending_id: item.id,\n approval_status: item.status,\n approval: makeApprovalPolicy('high'),\n preview: {\n to,\n cc,\n bcc,\n subject,\n body_excerpt: previewText(bodyText || stripHtml(bodyHtml), 240),\n },\n sink: {\n type: 'workflow-static-data',\n key: 'approvalQueue',\n retained_entries: maxQueueEntries,\n },\n },\n };\n } else {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: {\n code: 'invalid_request',\n message: 'send_email_draft requires to, subject, and body_text or body_html',\n },\n };\n }\n } else if (action === 'list_email_drafts') {\n const max = clamp(args.max, 1, 100, 20);\n const page = trimText(args.page || '');\n const all = args.all === true;\n const failEmpty = args.fail_empty === true;\n\n statusCode = 200;\n const item = enqueueApproval({\n kind: 'email_list_drafts',\n summary: all ? 'List Gmail drafts (all pages)' : `List Gmail drafts (max ${max})`,\n payload: {\n max,\n page,\n all,\n fail_empty: failEmpty,\n },\n tags: ['email', 'read', 'approval'],\n });\n logAction(`queued list_email_drafts ${item.id}`, { pending_id: item.id, max, all });\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'list_email_drafts',\n status: 'queued_for_approval',\n pending_id: item.id,\n approval_status: item.status,\n approval: makeApprovalPolicy('low'),\n preview: {\n max,\n page,\n all,\n fail_empty: failEmpty,\n },\n sink: {\n type: 'workflow-static-data',\n key: 'approvalQueue',\n retained_entries: maxQueueEntries,\n },\n },\n };\n } else if (action === 'delete_email_draft') {\n const draftId = trimText(args.draft_id || args.id || '');\n\n if (draftId) {\n statusCode = 200;\n const item = enqueueApproval({\n kind: 'email_draft_delete',\n summary: `Delete Gmail draft ${draftId}`,\n payload: {\n draft_id: draftId,\n },\n tags: ['email', 'approval', 'mutating', 'destructive'],\n });\n logAction(`queued delete_email_draft ${item.id}`, { pending_id: item.id, draft_id: draftId });\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'delete_email_draft',\n status: 'queued_for_approval',\n pending_id: item.id,\n approval_status: item.status,\n approval: makeApprovalPolicy('high'),\n preview: {\n draft_id: draftId,\n },\n sink: {\n type: 'workflow-static-data',\n key: 'approvalQueue',\n retained_entries: maxQueueEntries,\n },\n },\n };\n } else {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: {\n code: 'invalid_request',\n message: 'delete_email_draft requires draft_id',\n },\n };\n }\n } else if (action === 'send_gmail_draft' || action === 'send_approved_email') {\n const requestedAction = action;\n const draftId = trimText(args.draft_id || args.id || '');\n\n if (draftId) {\n statusCode = 200;\n const item = enqueueApproval({\n kind: 'email_draft_send',\n summary: `Send Gmail draft ${draftId}`,\n payload: {\n draft_id: draftId,\n requested_action: requestedAction,\n },\n tags: ['email', 'approval', 'mutating'],\n });\n logAction(`queued ${requestedAction} ${item.id}`, { pending_id: item.id, draft_id: draftId });\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'send_gmail_draft',\n requested_action: requestedAction,\n status: 'queued_for_approval',\n pending_id: item.id,\n approval_status: item.status,\n approval: makeApprovalPolicy('high'),\n preview: {\n draft_id: draftId,\n },\n sink: {\n type: 'workflow-static-data',\n key: 'approvalQueue',\n retained_entries: maxQueueEntries,\n },\n },\n };\n } else {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: {\n code: 'invalid_request',\n message: 'send_gmail_draft requires draft_id',\n },\n };\n }\n } else if (action === 'create_calendar_event') {\n const title = trimText(args.title || '');\n const start = trimText(args.start || '');\n const end = trimText(args.end || '');\n const startDate = start ? new Date(start) : null;\n const endDate = end ? new Date(end) : null;\n\n if (title && startDate && endDate && Number.isFinite(startDate.getTime()) && Number.isFinite(endDate.getTime()) && endDate > startDate) {\n statusCode = 200;\n const attendees = ensureStringArray(args.attendees);\n const item = enqueueApproval({\n kind: 'calendar_event',\n summary: `Calendar event ${title} @ ${startDate.toISOString()}`,\n payload: {\n calendar: trimText(args.calendar || 'primary') || 'primary',\n title,\n start: startDate.toISOString(),\n end: endDate.toISOString(),\n location: trimText(args.location || ''),\n description: trimText(args.description || ''),\n attendees,\n },\n tags: ['calendar', 'approval'],\n });\n logAction(`queued calendar event ${item.id}`, { pending_id: item.id, title, attendee_count: attendees.length });\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'create_calendar_event',\n status: 'queued_for_approval',\n pending_id: item.id,\n approval_status: item.status,\n approval: makeApprovalPolicy('high'),\n preview: {\n calendar: item.payload.calendar,\n title,\n start: item.payload.start,\n end: item.payload.end,\n location: item.payload.location,\n attendees,\n },\n sink: {\n type: 'workflow-static-data',\n key: 'approvalQueue',\n retained_entries: maxQueueEntries,\n },\n },\n };\n } else {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: {\n code: 'invalid_request',\n message: 'create_calendar_event requires title, start, and end with end after start',\n },\n };\n }\n } else if (action === 'approval_queue_add') {\n const kind = trimText(args.kind || 'manual');\n const summary = trimText(args.summary || '');\n if (summary) {\n statusCode = 200;\n const item = enqueueApproval({\n kind,\n summary,\n payload: isPlainObject(args.payload) ? args.payload : {},\n tags: ensureStringArray(args.tags),\n });\n logAction(`queued approval item ${item.id}`, { pending_id: item.id, kind });\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'approval_queue_add',\n status: 'queued',\n pending_id: item.id,\n item,\n },\n };\n } else {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'invalid_request', message: 'approval_queue_add requires summary' },\n };\n }\n } else if (action === 'approval_queue_list') {\n const limit = clamp(args.limit, 1, 50, 20);\n const includeHistory = args.include_history === true;\n const pending = getApprovalQueue().slice(-limit).reverse();\n const history = includeHistory ? getApprovalHistory().slice(-limit).reverse() : [];\n statusCode = 200;\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'approval_queue_list',\n status: 'ok',\n pending_count: getApprovalQueue().length,\n history_count: getApprovalHistory().length,\n pending,\n history,\n },\n };\n } else if (action === 'approval_queue_resolve') {\n const id = trimText(args.id || '');\n const decision = trimText(args.decision || '').toLowerCase();\n const queue = getApprovalQueue();\n const index = queue.findIndex((item) => item && item.id === id);\n\n if (!id || !['approve', 'reject'].includes(decision)) {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'invalid_request', message: 'approval_queue_resolve requires id and decision=approve|reject' },\n };\n } else if (index === -1) {\n statusCode = 404;\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'not_found', message: 'approval queue item was not found' },\n };\n } else {\n statusCode = 200;\n const [item] = queue.splice(index, 1);\n workflowStaticData.approvalQueue = queue;\n const resolved = {\n ...item,\n status: decision === 'approve' ? 'approved' : 'rejected',\n resolved_at: now,\n resolution_note: trimText(args.note || ''),\n };\n\n let executed = false;\n let executed_action = '';\n if (decision === 'approve' && resolved.kind === 'notification' && isPlainObject(resolved.payload)) {\n const title = trimText(resolved.payload.title || '');\n const message = trimText(resolved.payload.message || '');\n if (message) {\n route = 'notify';\n notifyText = title ? `🔔 ${title}\n${message}` : `🔔 ${message}`;\n executed = true;\n executed_action = 'notify';\n resolved.execution = {\n action: 'notify',\n status: 'sent',\n executed_at: now,\n };\n }\n }\n\n workflowStaticData.approvalHistory = pushRetained(getApprovalHistory(), resolved, maxHistoryEntries);\n logAction(`resolved approval item ${id}`, { pending_id: id, status: resolved.status, executed, executed_action });\n if (!executed && args.notify_on_resolve === true) {\n route = 'notify';\n const emoji = resolved.status === 'approved' ? '✅' : '🛑';\n notifyText = `${emoji} Approval ${resolved.status}\n${resolved.summary}`;\n }\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'approval_queue_resolve',\n status: resolved.status,\n executed,\n executed_action,\n item: resolved,\n },\n };\n }\n } else if (action === 'approval_history_attach_execution') {\n const id = trimText(args.id || '');\n const execution = isPlainObject(args.execution) ? args.execution : null;\n const history = getApprovalHistory();\n const index = history.findIndex((item) => item && item.id === id);\n\n if (!id || !execution) {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'invalid_request', message: 'approval_history_attach_execution requires id and execution object' },\n };\n } else if (index === -1) {\n statusCode = 404;\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'not_found', message: 'approval history item was not found' },\n };\n } else {\n statusCode = 200;\n const existing = history[index];\n const updated = {\n ...existing,\n updated_at: now,\n execution: {\n ...(isPlainObject(existing.execution) ? existing.execution : {}),\n ...execution,\n updated_at: now,\n },\n };\n history[index] = updated;\n workflowStaticData.approvalHistory = history;\n logAction(`attached execution for approval item ${id}`, { pending_id: id, execution_status: updated.execution.status || '' });\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'approval_history_attach_execution',\n status: 'updated',\n item: updated,\n },\n };\n }\n } else if (action === 'fetch_and_normalize_url') {\n const url = trimText(args.url || '');\n const maxChars = clamp(args.max_chars, 500, 20000, 8000);\n const timeoutMs = clamp(args.timeout_ms, 1000, 30000, 10000);\n\n if (!/^https?:\\/\\//i.test(url)) {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'invalid_request', message: 'fetch_and_normalize_url requires an http or https url' },\n };\n } else {\n try {\n if (!this || !this.helpers || typeof this.helpers.httpRequest !== 'function') {\n throw new Error('this.helpers.httpRequest is not available in this n8n Code runtime');\n }\n const extraHeaders = isPlainObject(args.headers) ? args.headers : {};\n const requestHeaders = Object.fromEntries(\n Object.entries(extraHeaders)\n .filter(([key, value]) => typeof key === 'string' && typeof value === 'string' && key.trim() && value.trim())\n .map(([key, value]) => [key.trim(), value.trim()])\n );\n\n const skipSslCertificateValidation = args.skip_ssl_certificate_validation === true;\n const upstream = await this.helpers.httpRequest({\n url,\n method: 'GET',\n headers: requestHeaders,\n timeout: timeoutMs,\n json: false,\n encoding: 'text',\n returnFullResponse: true,\n skipSslCertificateValidation,\n });\n\n const headersObj = Object.fromEntries(\n Object.entries(upstream.headers || {}).map(([key, value]) => [\n String(key).toLowerCase(),\n Array.isArray(value) ? value.join(', ') : String(value ?? ''),\n ])\n );\n const response = {\n ok: (upstream.statusCode || 0) >= 200 && (upstream.statusCode || 0) < 300,\n status: upstream.statusCode || 0,\n url: upstream.request?.uri?.href || upstream.request?.href || upstream.url || url,\n headers: { entries: () => Object.entries(headersObj) },\n };\n const rawBody = typeof upstream.body === 'string'\n ? upstream.body\n : JSON.stringify(upstream.body ?? '', null, 2);\n const normalized = normalizeFetchResponse({ inputUrl: url, response, rawBody, maxChars });\n if (!response.ok) {\n statusCode = 502;\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'upstream_http_error', message: `upstream returned ${response.status}` },\n result: {\n action: 'fetch_and_normalize_url',\n upstream: normalized,\n },\n };\n } else {\n statusCode = 200;\n logAction(`fetched url ${normalized.url}`, { url: normalized.url, content_type: normalized.content_type, http_status: normalized.http_status });\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'fetch_and_normalize_url',\n status: 'ok',\n ...normalized,\n },\n };\n }\n } catch (error) {\n statusCode = 502;\n responseBody = {\n ok: false,\n request_id: requestId,\n error: {\n code: error && error.name === 'AbortError' ? 'upstream_timeout' : 'fetch_failed',\n message: error && error.message ? String(error.message) : 'fetch failed',\n },\n };\n }\n }\n } else if (action === 'inbound_event_filter') {\n const classified = classifyInboundEvent(args);\n if (!classified.summary) {\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'invalid_request', message: 'inbound_event_filter requires summary or message' },\n };\n } else {\n const dedupeKey = trimText(args.dedupe_key || `${classified.source}|${classified.type}|${classified.summary}`.toLowerCase().replace(/\\s+/g, ' '));\n const dedupList = getEventDedup();\n const duplicate = dedupList.includes(dedupeKey);\n if (!duplicate) {\n workflowStaticData.eventDedup = pushRetained(dedupList, dedupeKey, maxDedupEntries);\n }\n const eventRecord = {\n ts: now,\n source: classified.source,\n type: classified.type,\n summary: classified.summary,\n severity: classified.severity,\n priority: classified.priority,\n classification: duplicate ? 'deduped' : classified.classification,\n dedupe_key: dedupeKey,\n duplicate,\n event: isPlainObject(args.event) ? args.event : undefined,\n };\n workflowStaticData.inboundEvents = pushRetained(getInboundEvents(), eventRecord, maxEventEntries);\n const notifyAllowed = args.notify !== false;\n const shouldNotify = classified.shouldNotify && !duplicate && notifyAllowed;\n if (shouldNotify) {\n route = 'notify';\n const emoji = classified.classification === 'urgent' ? '🚨' : '';\n notifyText = `${emoji} ${classified.source}: ${classified.summary}`;\n }\n logAction(`ingested inbound event ${classified.source}`, { classification: eventRecord.classification, duplicate });\n statusCode = 200;\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'inbound_event_filter',\n status: duplicate ? 'deduped' : 'stored',\n classification: eventRecord.classification,\n duplicate,\n notified: shouldNotify,\n event: eventRecord,\n sink: {\n type: 'workflow-static-data',\n key: 'inboundEvents',\n retained_entries: maxEventEntries,\n },\n },\n };\n }\n }\n\n return {\n json: {\n route,\n status_code: statusCode,\n response_body: responseBody,\n notify_text: notifyText,\n },\n };\n})();\n"
}
},
{
"id": "route-dispatch",
"name": "route-dispatch",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.4,
"position": [
-120,
40
],
"parameters": {
"mode": "rules",
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": true,
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"leftValue": "={{$json.route}}",
"rightValue": "notify",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "notify"
}
]
},
"options": {
"fallbackOutput": "extra",
"renameFallbackOutput": "respond"
}
}
},
{
"id": "send-telegram-notification",
"name": "Send Telegram Notification",
"type": "n8n-nodes-base.telegram",
"typeVersion": 1.2,
"position": [
160,
40
],
"parameters": {
"chatId": "8367012007",
"text": "={{$json.notify_text}}",
"additionalFields": {}
},
"credentials": {
"telegramApi": {
"id": "aox4dyIWVSRdcH5z",
"name": "Telegram Bot (OpenClaw)"
}
}
},
{
"id": "send-discord-notification",
"name": "Send Discord Notification",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
460,
40
],
"parameters": {
"authentication": "predefinedCredentialType",
"nodeCredentialType": "httpHeaderAuth",
"method": "POST",
"url": "https://discord.com/api/v10/channels/425781661268049931/messages",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ { content: $node[\"route-action\"].json[\"notify_text\"] } }}",
"options": {}
},
"credentials": {
"httpHeaderAuth": {
"id": "UgPqYcoCNNIgr55m",
"name": "Discord Bot Auth"
}
}
},
{
"id": "respond-openclaw-action",
"name": "Respond to Webhook",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [
760,
40
],
"parameters": {
"respondWith": "json",
"responseBody": "={{$node[\"route-action\"].json[\"response_body\"]}}",
"options": {
"responseCode": "={{$node[\"route-action\"].json[\"status_code\"]}}"
}
}
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "route-action",
"type": "main",
"index": 0
}
]
]
},
"route-action": {
"main": [
[
{
"node": "route-dispatch",
"type": "main",
"index": 0
}
]
]
},
"route-dispatch": {
"main": [
[
{
"node": "Send Telegram Notification",
"type": "main",
"index": 0
}
],
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
},
"Send Telegram Notification": {
"main": [
[
{
"node": "Send Discord Notification",
"type": "main",
"index": 0
}
]
]
},
"Send Discord Notification": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"meta": {
"templateCredsSetupCompleted": false,
"note": "After import, set Webhook authentication to Header Auth and bind a local credential using x-openclaw-secret. This asset ships append_log + get_logs via workflow static data plus Telegram/Discord notify fan-out."
},
"active": false,
"versionId": "openclaw-action-v7"
}