diff --git a/memory/2026-03-12.md b/memory/2026-03-12.md index d0a826a..c15c626 100644 --- a/memory/2026-03-12.md +++ b/memory/2026-03-12.md @@ -40,3 +40,14 @@ - `notify` → success - Refreshed packaged skill artifact again at `/tmp/n8n-skill-dist/n8n-webhook.skill`. - Will clarified a standing operating preference: treat local n8n as an assistant tool to use proactively when appropriate, not as something needing separate approval each time. +- Extended the shipped `skills/n8n-webhook` router asset beyond the original live trio (`append_log`, `get_logs`, `notify`) to add: + - `send_email_draft` + - `create_calendar_event` + - `approval_queue_add` + - `approval_queue_list` + - `approval_queue_resolve` + - `fetch_and_normalize_url` + - `inbound_event_filter` +- Design choice for the new actions: keep the starter workflow immediately usable without new provider credentials by using n8n workflow static data for approval queue/history/event state, while leaving room to wire provider-backed email/calendar executors later. +- Updated local docs, validator, and sample payloads for the expanded action bus and re-ran local structural validation successfully. +- Live n8n re-import/update was not completed in this pass because the current session did not have a verified safe path into the already-running instance (no confirmed admin/browser path and no confirmed current webhook secret for live test calls). diff --git a/skills/n8n-webhook/assets/openclaw-action.workflow.json b/skills/n8n-webhook/assets/openclaw-action.workflow.json index bb985b6..21318e6 100644 --- a/skills/n8n-webhook/assets/openclaw-action.workflow.json +++ b/skills/n8n-webhook/assets/openclaw-action.workflow.json @@ -30,7 +30,7 @@ "parameters": { "mode": "runOnceForEachItem", "language": "javaScript", - "jsCode": "const body = $json.body ?? {};\nconst action = body.action ?? '';\nconst args = body.args ?? {};\nconst requestId = body.request_id ?? '';\nconst now = new Date().toISOString();\nconst workflowStaticData = $getWorkflowStaticData('global');\nconst maxLogEntries = 200;\n\nlet route = 'respond';\nlet statusCode = 400;\nlet responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'unknown_action', message: 'action is not supported' },\n};\nlet notifyText = '';\n\nif (action === 'append_log') {\n if (typeof args.text === 'string' && args.text.length > 0) {\n statusCode = 200;\n const record = {\n ts: now,\n source: 'openclaw-action',\n request_id: requestId,\n text: args.text,\n meta: typeof args.meta === 'object' && args.meta !== null ? args.meta : undefined,\n };\n const actionLog = Array.isArray(workflowStaticData.actionLog) ? workflowStaticData.actionLog : [];\n actionLog.push(record);\n if (actionLog.length > maxLogEntries) {\n actionLog.splice(0, actionLog.length - maxLogEntries);\n }\n workflowStaticData.actionLog = actionLog;\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'append_log',\n status: 'logged',\n preview: { text: args.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 = Array.isArray(workflowStaticData.actionLog) ? workflowStaticData.actionLog : [];\n const rawLimit = Number.isFinite(Number(args.limit)) ? Number(args.limit) : 20;\n const limit = Math.max(1, Math.min(50, Math.trunc(rawLimit) || 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 === 'notify') {\n if (typeof args.message === 'string' && args.message.length > 0) {\n route = 'notify';\n statusCode = 200;\n const title = typeof args.title === 'string' ? args.title : '';\n notifyText = title ? `🔔 ${title}\\n${args.message}` : `🔔 ${args.message}`;\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'notify',\n status: 'sent',\n preview: { title, message: args.message },\n targets: ['telegram', 'discord'],\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}\n\nreturn {\n json: {\n route,\n status_code: statusCode,\n response_body: responseBody,\n notify_text: notifyText,\n },\n};" + "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()}\u2026`;\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(/ /gi, ' ')\n .replace(/&/gi, '&')\n .replace(/</gi, '<')\n .replace(/>/gi, '>')\n .replace(/"/gi, '\"')\n .replace(/'/gi, \"'\");\n return text;\n };\n\n const stripHtml = (value) => htmlEntityDecode(\n asString(value)\n .replace(//gi, ' ')\n .replace(//gi, ' ')\n .replace(//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 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(/]*>([\\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 === '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 ? `\ud83d\udd14 ${title}\\n${trimText(args.message)}` : `\ud83d\udd14 ${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(', ')} \u2014 ${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 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 === '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 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 workflowStaticData.approvalHistory = pushRetained(getApprovalHistory(), resolved, maxHistoryEntries);\n logAction(`resolved approval item ${id}`, { pending_id: id, status: resolved.status });\n if (args.notify_on_resolve === true) {\n route = 'notify';\n const emoji = resolved.status === 'approved' ? '\u2705' : '\ud83d\uded1';\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 item: resolved,\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 const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;\n const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;\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 const response = await fetch(url, {\n method: 'GET',\n headers: requestHeaders,\n redirect: 'follow',\n signal: controller ? controller.signal : undefined,\n });\n if (timer) {\n clearTimeout(timer);\n }\n const rawBody = await response.text();\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' ? '\ud83d\udea8' : '\u2139\ufe0f';\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" } }, { diff --git a/skills/n8n-webhook/assets/test-append-log.json b/skills/n8n-webhook/assets/test-append-log.json index 23213b8..7bf5502 100644 --- a/skills/n8n-webhook/assets/test-append-log.json +++ b/skills/n8n-webhook/assets/test-append-log.json @@ -1,7 +1,10 @@ { "action": "append_log", + "request_id": "test-append-log-001", "args": { - "text": "backup complete" - }, - "request_id": "test-append-log-001" + "text": "backup complete", + "meta": { + "source": "backup-job" + } + } } diff --git a/skills/n8n-webhook/assets/test-approval-queue-list.json b/skills/n8n-webhook/assets/test-approval-queue-list.json new file mode 100644 index 0000000..b07fcf6 --- /dev/null +++ b/skills/n8n-webhook/assets/test-approval-queue-list.json @@ -0,0 +1,8 @@ +{ + "action": "approval_queue_list", + "request_id": "test-approval-list-001", + "args": { + "limit": 10, + "include_history": true + } +} diff --git a/skills/n8n-webhook/assets/test-create-calendar-event.json b/skills/n8n-webhook/assets/test-create-calendar-event.json new file mode 100644 index 0000000..e92f6bb --- /dev/null +++ b/skills/n8n-webhook/assets/test-create-calendar-event.json @@ -0,0 +1,11 @@ +{ + "action": "create_calendar_event", + "request_id": "test-calendar-event-001", + "args": { + "calendar": "primary", + "title": "Call with vendor", + "start": "2026-03-13T18:00:00Z", + "end": "2026-03-13T18:30:00Z", + "description": "Drafted from OpenClaw action bus." + } +} diff --git a/skills/n8n-webhook/assets/test-fetch-and-normalize-url.json b/skills/n8n-webhook/assets/test-fetch-and-normalize-url.json new file mode 100644 index 0000000..a8c3678 --- /dev/null +++ b/skills/n8n-webhook/assets/test-fetch-and-normalize-url.json @@ -0,0 +1,8 @@ +{ + "action": "fetch_and_normalize_url", + "request_id": "test-fetch-001", + "args": { + "url": "https://example.com", + "max_chars": 2000 + } +} diff --git a/skills/n8n-webhook/assets/test-inbound-event-filter.json b/skills/n8n-webhook/assets/test-inbound-event-filter.json new file mode 100644 index 0000000..0af22ca --- /dev/null +++ b/skills/n8n-webhook/assets/test-inbound-event-filter.json @@ -0,0 +1,11 @@ +{ + "action": "inbound_event_filter", + "request_id": "test-inbound-001", + "args": { + "source": "homelab", + "type": "alert", + "severity": "critical", + "summary": "Build failed on swarm cluster", + "notify": true + } +} diff --git a/skills/n8n-webhook/assets/test-notify.json b/skills/n8n-webhook/assets/test-notify.json index 838050e..eca7c09 100644 --- a/skills/n8n-webhook/assets/test-notify.json +++ b/skills/n8n-webhook/assets/test-notify.json @@ -1,8 +1,8 @@ { "action": "notify", + "request_id": "test-notify-001", "args": { "title": "Workflow finished", "message": "n8n router test" - }, - "request_id": "test-notify-001" + } } diff --git a/skills/n8n-webhook/assets/test-send-email-draft.json b/skills/n8n-webhook/assets/test-send-email-draft.json new file mode 100644 index 0000000..8daa984 --- /dev/null +++ b/skills/n8n-webhook/assets/test-send-email-draft.json @@ -0,0 +1,11 @@ +{ + "action": "send_email_draft", + "request_id": "test-email-draft-001", + "args": { + "to": [ + "will@example.com" + ], + "subject": "Draft daily brief", + "body_text": "Here is a draft daily brief for review." + } +} diff --git a/skills/n8n-webhook/references/openclaw-action.md b/skills/n8n-webhook/references/openclaw-action.md index 052273d..b215426 100644 --- a/skills/n8n-webhook/references/openclaw-action.md +++ b/skills/n8n-webhook/references/openclaw-action.md @@ -10,10 +10,17 @@ It implements a real local OpenClaw → n8n router. - accepts `POST /webhook/openclaw-action` - normalizes incoming JSON into an action contract -- supports three live actions: +- supports these actions in the shipped asset: - `append_log` - `get_logs` - `notify` + - `send_email_draft` + - `create_calendar_event` + - `approval_queue_add` + - `approval_queue_list` + - `approval_queue_resolve` + - `fetch_and_normalize_url` + - `inbound_event_filter` - returns normalized JSON responses - returns `400` for unknown actions - returns `400` when required args are missing @@ -33,13 +40,35 @@ Example stored record: {"ts":"2026-03-12T07:00:00Z","source":"openclaw-action","request_id":"abc","text":"backup complete"} ``` -### `get_logs` +### `send_email_draft` and `create_calendar_event` -- reads from workflow static data key: - - `actionLog` -- returns newest-first -- default `limit` is `20` -- clamps `limit` to `1..50` +- queue approval-gated proposals into workflow static data under key: + - `approvalQueue` +- keep the most recent `200` pending entries +- do **not** send email or create provider-side calendar events in the shipped starter workflow +- are designed to become safe provider-backed executors later once instance-local creds are bound in n8n + +### `approval_queue_resolve` + +- removes one item from `approvalQueue` +- appends the resolved entry into: + - `approvalHistory` +- supports optional notification on approval/rejection + +### `fetch_and_normalize_url` + +- fetches a remote `http` or `https` URL from inside n8n +- normalizes HTML/text/JSON into a single response shape +- returns title/excerpt/body text suitable for downstream summarization or logging + +### `inbound_event_filter` + +- classifies inbound events as `urgent`, `important`, `watch`, or `deduped` +- stores recent events in: + - `inboundEvents` +- stores recent dedupe keys in: + - `eventDedup` +- can fan out a notification for urgent/important non-duplicate events ### `notify` @@ -49,18 +78,19 @@ Example stored record: - `Discord Bot Auth` - current targets mirror the already-working reminder workflow -## Why workflow static data for logs +## Why workflow static data first Why this first: - built-in, no extra credentials - persists without guessing writable filesystem paths -- better fit than MinIO for small, recent operational breadcrumbs +- good fit for queues, recent breadcrumbs, and small operational state +- lets us implement safe approval-gated patterns immediately -When to use MinIO later: -- long retention -- rotated archives -- large/batched exports -- sharing logs outside n8n +When to add provider-backed steps later: +- email draft creation in Gmail/Outlook +- calendar writes in Google Calendar +- Airtable/Sheets append pipelines +- long-retention logs or external archival ## Intentional security choice @@ -97,6 +127,11 @@ After import, set this manually in n8n: - `assets/test-append-log.json` - `assets/test-notify.json` +- `assets/test-send-email-draft.json` +- `assets/test-create-calendar-event.json` +- `assets/test-fetch-and-normalize-url.json` +- `assets/test-approval-queue-list.json` +- `assets/test-inbound-event-filter.json` ## Example tests @@ -105,69 +140,73 @@ export N8N_WEBHOOK_SECRET='YOUR_SECRET_HERE' scripts/call-action.sh append_log --args '{"text":"backup complete"}' --pretty scripts/call-action.sh get_logs --args '{"limit":5}' --pretty scripts/call-action.sh notify --args '{"title":"Workflow finished","message":"n8n router test"}' --pretty +scripts/call-action.sh send_email_draft --args-file assets/test-send-email-draft.json --pretty +scripts/call-action.sh create_calendar_event --args-file assets/test-create-calendar-event.json --pretty +scripts/call-action.sh fetch_and_normalize_url --args '{"url":"https://example.com"}' --pretty +scripts/call-action.sh approval_queue_list --args '{"limit":10,"include_history":true}' --pretty +scripts/call-action.sh inbound_event_filter --args-file assets/test-inbound-event-filter.json --pretty ``` ## Expected success examples -### append_log +### send_email_draft ```json { "ok": true, - "request_id": "test-append-log-001", + "request_id": "test-email-draft-001", "result": { - "action": "append_log", - "status": "logged", - "preview": { - "text": "backup complete" - }, - "sink": { - "type": "workflow-static-data", - "key": "actionLog", - "retained_entries": 200 - } + "action": "send_email_draft", + "status": "queued_for_approval", + "pending_id": "approval-abc123", + "approval_status": "pending" } } ``` -### get_logs +### create_calendar_event ```json { "ok": true, - "request_id": "", + "request_id": "test-calendar-event-001", "result": { - "action": "get_logs", + "action": "create_calendar_event", + "status": "queued_for_approval", + "pending_id": "approval-def456", + "approval_status": "pending" + } +} +``` + +### fetch_and_normalize_url + +```json +{ + "ok": true, + "request_id": "test-fetch-001", + "result": { + "action": "fetch_and_normalize_url", "status": "ok", - "count": 1, - "total_retained": 1, - "retained_entries": 200, - "entries": [ - { - "ts": "2026-03-12T08:42:37.615Z", - "source": "openclaw-action", - "request_id": "live-log-003", - "text": "n8n append_log static-data verification" - } - ] + "url": "https://example.com/", + "title": "Example Domain", + "content_type": "text/html; charset=UTF-8" } } ``` -### notify +### inbound_event_filter ```json { "ok": true, - "request_id": "test-notify-001", + "request_id": "test-inbound-001", "result": { - "action": "notify", - "status": "sent", - "preview": { - "title": "Workflow finished", - "message": "n8n router test" - }, - "targets": ["telegram", "discord"] + "action": "inbound_event_filter", + "status": "stored", + "classification": "urgent", + "duplicate": false, + "notified": true } } ``` diff --git a/skills/n8n-webhook/references/payloads.md b/skills/n8n-webhook/references/payloads.md index 50d91c8..3bbc9d2 100644 --- a/skills/n8n-webhook/references/payloads.md +++ b/skills/n8n-webhook/references/payloads.md @@ -48,7 +48,7 @@ Recommended request shape: } ``` -## Live actions +## Live actions in the shipped workflow asset ### `append_log` @@ -96,23 +96,6 @@ Behavior: - max limit: `50` - entries are returned newest-first -Success shape: - -```json -{ - "ok": true, - "request_id": "optional-uuid", - "result": { - "action": "get_logs", - "status": "ok", - "count": 2, - "total_retained": 7, - "retained_entries": 200, - "entries": [] - } -} -``` - ### `notify` Request: @@ -130,21 +113,171 @@ Request: Purpose: - send the message through the currently configured Telegram + Discord notification targets -Success shape: +### `send_email_draft` + +Request: ```json { - "ok": true, - "request_id": "optional-uuid", - "result": { - "action": "notify", - "status": "sent", - "targets": ["telegram", "discord"] + "action": "send_email_draft", + "args": { + "to": ["will@example.com"], + "subject": "Draft daily brief", + "body_text": "Here is a draft daily brief for review." } } ``` -## Failure shape +Purpose: +- queue an email draft proposal for approval +- does **not** send mail directly in the shipped starter workflow + +Sink: +- type: `workflow-static-data` +- key: `approvalQueue` +- retained entries: `200` + +### `create_calendar_event` + +Request: + +```json +{ + "action": "create_calendar_event", + "args": { + "calendar": "primary", + "title": "Call with vendor", + "start": "2026-03-13T18:00:00Z", + "end": "2026-03-13T18:30:00Z", + "description": "Drafted from OpenClaw action bus." + } +} +``` + +Purpose: +- queue a calendar event proposal for approval +- does **not** write to a calendar provider directly in the shipped starter workflow + +Sink: +- type: `workflow-static-data` +- key: `approvalQueue` +- retained entries: `200` + +### `approval_queue_add` + +Request: + +```json +{ + "action": "approval_queue_add", + "args": { + "kind": "manual", + "summary": "Review outbound customer reply", + "payload": { + "channel": "email" + }, + "tags": ["approval", "customer"] + } +} +``` + +Purpose: +- add a generic pending approval item to the queue + +### `approval_queue_list` + +Request: + +```json +{ + "action": "approval_queue_list", + "args": { + "limit": 10, + "include_history": true + } +} +``` + +Purpose: +- inspect pending approval items +- optionally include recent resolved history + +### `approval_queue_resolve` + +Request: + +```json +{ + "action": "approval_queue_resolve", + "args": { + "id": "approval-abc123", + "decision": "approve", + "note": "Looks good", + "notify_on_resolve": true + } +} +``` + +Purpose: +- approve or reject a pending item +- moves resolved entries into `approvalHistory` + +### `fetch_and_normalize_url` + +Request: + +```json +{ + "action": "fetch_and_normalize_url", + "args": { + "url": "https://example.com/article", + "max_chars": 8000, + "timeout_ms": 10000 + } +} +``` + +Purpose: +- fetch a URL inside n8n +- normalize content into a predictable summary-ready shape + +Success shape includes: +- `url` +- `title` +- `content_type` +- `http_status` +- `excerpt` +- `body_text` +- `text_length` +- `truncated` + +### `inbound_event_filter` + +Request: + +```json +{ + "action": "inbound_event_filter", + "args": { + "source": "homelab", + "type": "alert", + "severity": "critical", + "summary": "Build failed on swarm cluster", + "notify": true + } +} +``` + +Purpose: +- dedupe and classify inbound events +- store recent events in workflow static data +- optionally notify on urgent/important events + +Sinks: +- `inboundEvents` +- `eventDedup` + +## Common failure shape ```json { @@ -160,6 +293,6 @@ Success shape: ## Naming guidance - Use lowercase kebab-case for webhook paths. -- Use lowercase snake_case or kebab-case consistently for action names; prefer snake_case for JSON actions if using switch/router logic. -- Keep names explicit: `openclaw-ping`, `openclaw-action`, `append_log`, `get_logs`, `notify`. +- Use lowercase snake_case for JSON action names. +- Keep names explicit: `openclaw-ping`, `openclaw-action`, `append_log`, `approval_queue_resolve`. - Avoid generic names like `run`, `task`, or `webhook1`. diff --git a/skills/n8n-webhook/scripts/call-action.sh b/skills/n8n-webhook/scripts/call-action.sh index 10531b1..4cbfed2 100755 --- a/skills/n8n-webhook/scripts/call-action.sh +++ b/skills/n8n-webhook/scripts/call-action.sh @@ -4,7 +4,13 @@ set -euo pipefail usage() { cat <<'EOF' Usage: - scripts/call-action.sh [--args '{"k":"v"}'] [--args-file args.json] [--request-id ] [--path openclaw-action] [--test] [--pretty] [--dry-run] + scripts/call-action.sh [action] [--args '{"k":"v"}'] [--args-file args.json] [--request-id ] [--path openclaw-action] [--test] [--pretty] [--dry-run] + +Notes: + - `action` is optional when --args/--args-file contains a full payload with top-level `action` + `args`. + - `--args` / `--args-file` may contain either: + 1) a plain args object, or + 2) a full payload object: {"action":"...","args":{...},"request_id":"..."} Environment: N8N_ACTION_PATH Default router webhook path (default: openclaw-action) @@ -14,6 +20,7 @@ Environment: Examples: scripts/call-action.sh append_log --args '{"text":"backup complete"}' --request-id auto + scripts/call-action.sh --args-file assets/test-send-email-draft.json --pretty scripts/call-action.sh notify --args-file notify.json --test --pretty EOF } @@ -84,11 +91,6 @@ while [[ $# -gt 0 ]]; do esac done -if [[ -z "$ACTION" ]]; then - usage >&2 - exit 2 -fi - if [[ ${#EXTRA_ARGS[@]} -gt 0 ]]; then echo "Unexpected extra arguments: ${EXTRA_ARGS[*]}" >&2 exit 2 @@ -110,13 +112,32 @@ PAYLOAD="$({ python3 - <<'PY' "$ACTION" "$ARGS" "$REQUEST_ID" import json, sys -action = sys.argv[1] -args = json.loads(sys.argv[2]) -request_id = sys.argv[3] +cli_action = sys.argv[1] +raw = json.loads(sys.argv[2]) +cli_request_id = sys.argv[3] -if not isinstance(args, dict): +if not isinstance(raw, dict): raise SystemExit('Action args must decode to a JSON object.') +if 'action' in raw and 'args' in raw: + file_action = raw.get('action', '') + file_args = raw.get('args', {}) + file_request_id = raw.get('request_id', '') + if not isinstance(file_args, dict): + raise SystemExit('Full payload args must decode to a JSON object.') + if cli_action and file_action and cli_action != file_action: + raise SystemExit(f'CLI action {cli_action!r} does not match payload action {file_action!r}.') + action = cli_action or file_action + args = file_args + request_id = cli_request_id or file_request_id +else: + action = cli_action + args = raw + request_id = cli_request_id + +if not action: + raise SystemExit('Action is required unless provided inside --args/--args-file full payload.') + payload = { 'action': action, 'args': args, diff --git a/skills/n8n-webhook/scripts/validate-workflow.py b/skills/n8n-webhook/scripts/validate-workflow.py index 5d34a2d..3beacaa 100755 --- a/skills/n8n-webhook/scripts/validate-workflow.py +++ b/skills/n8n-webhook/scripts/validate-workflow.py @@ -21,6 +21,38 @@ EXPECTED_TYPES = { 'Respond to Webhook': 'n8n-nodes-base.respondToWebhook', } +SAMPLE_FILES = [ + 'test-append-log.json', + 'test-notify.json', + 'test-send-email-draft.json', + 'test-create-calendar-event.json', + 'test-fetch-and-normalize-url.json', + 'test-approval-queue-list.json', + 'test-inbound-event-filter.json', +] + +ROUTER_SNIPPETS = [ + 'append_log', + 'get_logs', + 'notify', + 'send_email_draft', + 'create_calendar_event', + 'approval_queue_add', + 'approval_queue_list', + 'approval_queue_resolve', + 'fetch_and_normalize_url', + 'inbound_event_filter', + 'unknown_action', + 'invalid_request', + '$getWorkflowStaticData', + 'approvalQueue', + 'approvalHistory', + 'inboundEvents', + 'eventDedup', + 'notify_text', + 'fetch(', +] + def fail(msg: str): print(f'ERROR: {msg}', file=sys.stderr) @@ -72,7 +104,7 @@ def main(): router = by_name['route-action'].get('parameters', {}) js_code = router.get('jsCode', '') - for snippet in ('append_log', 'get_logs', 'notify', 'unknown_action', 'invalid_request', '$getWorkflowStaticData', 'actionLog', 'retained_entries', 'notify_text', 'entries.length', 'Math.min(50'): + for snippet in ROUTER_SNIPPETS: if snippet not in js_code: fail(f'route-action jsCode missing expected snippet: {snippet!r}') @@ -94,7 +126,8 @@ def main(): if responder.get('respondWith') != 'json': fail('Respond to Webhook must respondWith json') - for sample in (path.parent / 'test-append-log.json', path.parent / 'test-notify.json'): + for sample_name in SAMPLE_FILES: + sample = path.parent / sample_name sample_data = load_json(sample) if not isinstance(sample_data, dict) or 'action' not in sample_data or 'args' not in sample_data: fail(f'sample payload missing action/args: {sample}') @@ -102,8 +135,8 @@ def main(): print('OK: workflow asset structure looks consistent') print(f'- workflow: {path}') print(f'- nodes: {len(nodes)}') - print('- routes: append_log + get_logs via workflow static data, notify via Telegram + Discord, fallback -> JSON error') - print('- samples: test-append-log.json, test-notify.json') + print('- routes: notify via Telegram + Discord; queue/log/fetch/filter handled in route-action code') + print('- samples: ' + ', '.join(SAMPLE_FILES)) if __name__ == '__main__':