fix(skill): simplify n8n action router
This commit is contained in:
@@ -11,3 +11,14 @@
|
|||||||
- Will clarified the primary host LAN IP to use/document is `192.168.153.113`.
|
- Will clarified the primary host LAN IP to use/document is `192.168.153.113`.
|
||||||
- Finished local skill `skills/n8n-webhook` for authenticated webhook-first n8n integration, including `scripts/call-webhook.sh`, `scripts/call-action.sh`, `scripts/validate-workflow.py`, an importable `assets/openclaw-action.workflow.json`, sample payloads, payload notes, and a successful package/validation run to `/tmp/n8n-skill-dist/n8n-webhook.skill`.
|
- Finished local skill `skills/n8n-webhook` for authenticated webhook-first n8n integration, including `scripts/call-webhook.sh`, `scripts/call-action.sh`, `scripts/validate-workflow.py`, an importable `assets/openclaw-action.workflow.json`, sample payloads, payload notes, and a successful package/validation run to `/tmp/n8n-skill-dist/n8n-webhook.skill`.
|
||||||
- The shipped `openclaw-action` workflow intentionally leaves Webhook authentication unset in export JSON; after import, bind local n8n Header Auth credentials manually using `x-openclaw-secret` so secrets are not embedded in the skill asset.
|
- The shipped `openclaw-action` workflow intentionally leaves Webhook authentication unset in export JSON; after import, bind local n8n Header Auth credentials manually using `x-openclaw-secret` so secrets are not embedded in the skill asset.
|
||||||
|
- Live n8n API access was confirmed and used on 2026-03-12 against `http://192.168.153.113:18808` (public API + existing webhook credential available in the instance).
|
||||||
|
- Created and activated live workflow `openclaw-action` via the n8n API.
|
||||||
|
- First live implementation matched the original asset shape (`Webhook -> Set -> Switch -> Respond`) but failed at runtime: executions errored in the `normalize-request` Set node with `invalid syntax` on its expressions.
|
||||||
|
- Fix: replaced the live router logic and shipped asset implementation with a simpler, working internal design: `Webhook -> Code -> Respond to Webhook`, while preserving the external contract (`append_log`, `notify`, normalized JSON success/failure responses).
|
||||||
|
- Important operational note: the workflow initially activated without a usable production route because the Webhook node lacked a `webhookId`; adding one and re-publishing was necessary for proper webhook registration.
|
||||||
|
- Current state before compaction: the live `openclaw-action` workflow exists in n8n, is active, and has been updated to the simpler Code-node implementation; post-update live response testing was still in progress at compaction time.
|
||||||
|
- After compaction, live verification succeeded against the production webhook:
|
||||||
|
- `append_log` returned `200` with normalized JSON success payload
|
||||||
|
- `notify` returned `200` with normalized JSON success payload
|
||||||
|
- unknown action returned `400` with `{ code: "unknown_action" }`
|
||||||
|
- The packaged skill artifact was refreshed after the router simplification at `/tmp/n8n-skill-dist/n8n-webhook.skill`.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"type": "n8n-nodes-base.webhook",
|
"type": "n8n-nodes-base.webhook",
|
||||||
"typeVersion": 2.1,
|
"typeVersion": 2.1,
|
||||||
"position": [
|
"position": [
|
||||||
-820,
|
-360,
|
||||||
0
|
0
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -18,252 +18,30 @@
|
|||||||
"options": {}
|
"options": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "normalize-request",
|
|
||||||
"name": "normalize-request",
|
|
||||||
"type": "n8n-nodes-base.set",
|
|
||||||
"typeVersion": 3.4,
|
|
||||||
"position": [
|
|
||||||
-560,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"mode": "manual",
|
|
||||||
"includeOtherFields": false,
|
|
||||||
"assignments": {
|
|
||||||
"assignments": [
|
|
||||||
{
|
|
||||||
"id": "action",
|
|
||||||
"name": "action",
|
|
||||||
"type": "string",
|
|
||||||
"value": "={{$json.body.action || ''}}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "args",
|
|
||||||
"name": "args",
|
|
||||||
"type": "object",
|
|
||||||
"value": "={{$json.body.args || {}}}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "request_id",
|
|
||||||
"name": "request_id",
|
|
||||||
"type": "string",
|
|
||||||
"value": "={{$json.body.request_id || ''}}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"dotNotation": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "route-action",
|
"id": "route-action",
|
||||||
"name": "route-action",
|
"name": "route-action",
|
||||||
"type": "n8n-nodes-base.switch",
|
"type": "n8n-nodes-base.code",
|
||||||
"typeVersion": 3.4,
|
"typeVersion": 2,
|
||||||
"position": [
|
"position": [
|
||||||
-300,
|
-40,
|
||||||
0
|
0
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"mode": "rules",
|
"mode": "runOnceForEachItem",
|
||||||
"rules": {
|
"language": "javaScript",
|
||||||
"values": [
|
"jsCode": "const body = $json.body ?? {};\nconst action = body.action ?? '';\nconst args = body.args ?? {};\nconst requestId = body.request_id ?? '';\n\nlet statusCode = 200;\nlet responseBody;\n\nif (action === 'append_log') {\n if (typeof args.text === 'string' && args.text.length > 0) {\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'append_log',\n status: 'accepted',\n preview: { text: args.text },\n },\n };\n } else {\n statusCode = 400;\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 === 'notify') {\n if (typeof args.message === 'string' && args.message.length > 0) {\n responseBody = {\n ok: true,\n request_id: requestId,\n result: {\n action: 'notify',\n status: 'accepted',\n preview: {\n title: typeof args.title === 'string' ? args.title : '',\n message: args.message,\n },\n },\n };\n } else {\n statusCode = 400;\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'invalid_request', message: 'required args are missing' },\n };\n }\n} else {\n statusCode = 400;\n responseBody = {\n ok: false,\n request_id: requestId,\n error: { code: 'unknown_action', message: 'action is not supported' },\n };\n}\n\nreturn {\n json: {\n status_code: statusCode,\n response_body: responseBody,\n },\n};"
|
||||||
{
|
|
||||||
"conditions": {
|
|
||||||
"options": {
|
|
||||||
"caseSensitive": true,
|
|
||||||
"typeValidation": "strict",
|
|
||||||
"version": 2
|
|
||||||
},
|
|
||||||
"conditions": [
|
|
||||||
{
|
|
||||||
"leftValue": "={{$json.action}}",
|
|
||||||
"rightValue": "append_log",
|
|
||||||
"operator": {
|
|
||||||
"type": "string",
|
|
||||||
"operation": "equals"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"combinator": "and"
|
|
||||||
},
|
|
||||||
"renameOutput": true,
|
|
||||||
"outputKey": "append_log"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"conditions": {
|
|
||||||
"options": {
|
|
||||||
"caseSensitive": true,
|
|
||||||
"typeValidation": "strict",
|
|
||||||
"version": 2
|
|
||||||
},
|
|
||||||
"conditions": [
|
|
||||||
{
|
|
||||||
"leftValue": "={{$json.action}}",
|
|
||||||
"rightValue": "notify",
|
|
||||||
"operator": {
|
|
||||||
"type": "string",
|
|
||||||
"operation": "equals"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"combinator": "and"
|
|
||||||
},
|
|
||||||
"renameOutput": true,
|
|
||||||
"outputKey": "notify"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"fallbackOutput": "extra",
|
|
||||||
"renameFallbackOutput": "unknown"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "append-log-response",
|
"id": "respond-openclaw-action",
|
||||||
"name": "append-log-response",
|
"name": "Respond to Webhook",
|
||||||
"type": "n8n-nodes-base.set",
|
|
||||||
"typeVersion": 3.4,
|
|
||||||
"position": [
|
|
||||||
-20,
|
|
||||||
-180
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"mode": "manual",
|
|
||||||
"includeOtherFields": false,
|
|
||||||
"assignments": {
|
|
||||||
"assignments": [
|
|
||||||
{
|
|
||||||
"id": "status_code",
|
|
||||||
"name": "status_code",
|
|
||||||
"type": "number",
|
|
||||||
"value": "={{$json.args.text ? 200 : 400}}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "response_body",
|
|
||||||
"name": "response_body",
|
|
||||||
"type": "object",
|
|
||||||
"value": "={{ $json.args.text ? { ok: true, request_id: $json.request_id || '', result: { action: 'append_log', status: 'accepted', preview: { text: $json.args.text } } } : { ok: false, request_id: $json.request_id || '', error: { code: 'invalid_request', message: 'required args are missing' } } }}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"dotNotation": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "respond-append-log",
|
|
||||||
"name": "respond-append-log",
|
|
||||||
"type": "n8n-nodes-base.respondToWebhook",
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
"typeVersion": 1.5,
|
"typeVersion": 1.5,
|
||||||
"position": [
|
"position": [
|
||||||
240,
|
260,
|
||||||
-180
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"respondWith": "json",
|
|
||||||
"responseBody": "={{$json.response_body}}",
|
|
||||||
"options": {
|
|
||||||
"responseCode": "={{$json.status_code}}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "notify-response",
|
|
||||||
"name": "notify-response",
|
|
||||||
"type": "n8n-nodes-base.set",
|
|
||||||
"typeVersion": 3.4,
|
|
||||||
"position": [
|
|
||||||
-20,
|
|
||||||
0
|
0
|
||||||
],
|
],
|
||||||
"parameters": {
|
|
||||||
"mode": "manual",
|
|
||||||
"includeOtherFields": false,
|
|
||||||
"assignments": {
|
|
||||||
"assignments": [
|
|
||||||
{
|
|
||||||
"id": "status_code",
|
|
||||||
"name": "status_code",
|
|
||||||
"type": "number",
|
|
||||||
"value": "={{$json.args.message ? 200 : 400}}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "response_body",
|
|
||||||
"name": "response_body",
|
|
||||||
"type": "object",
|
|
||||||
"value": "={{ $json.args.message ? { ok: true, request_id: $json.request_id || '', result: { action: 'notify', status: 'accepted', preview: { title: $json.args.title || '', message: $json.args.message } } } : { ok: false, request_id: $json.request_id || '', error: { code: 'invalid_request', message: 'required args are missing' } } }}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"dotNotation": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "respond-notify",
|
|
||||||
"name": "respond-notify",
|
|
||||||
"type": "n8n-nodes-base.respondToWebhook",
|
|
||||||
"typeVersion": 1.5,
|
|
||||||
"position": [
|
|
||||||
240,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"respondWith": "json",
|
|
||||||
"responseBody": "={{$json.response_body}}",
|
|
||||||
"options": {
|
|
||||||
"responseCode": "={{$json.status_code}}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "unknown-action-response",
|
|
||||||
"name": "unknown-action-response",
|
|
||||||
"type": "n8n-nodes-base.set",
|
|
||||||
"typeVersion": 3.4,
|
|
||||||
"position": [
|
|
||||||
-20,
|
|
||||||
180
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"mode": "manual",
|
|
||||||
"includeOtherFields": false,
|
|
||||||
"assignments": {
|
|
||||||
"assignments": [
|
|
||||||
{
|
|
||||||
"id": "status_code",
|
|
||||||
"name": "status_code",
|
|
||||||
"type": "number",
|
|
||||||
"value": 400
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "response_body",
|
|
||||||
"name": "response_body",
|
|
||||||
"type": "object",
|
|
||||||
"value": "={{ { ok: false, request_id: $json.request_id || '', error: { code: 'unknown_action', message: 'action is not supported' } } }}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"dotNotation": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "respond-unknown-action",
|
|
||||||
"name": "respond-unknown-action",
|
|
||||||
"type": "n8n-nodes-base.respondToWebhook",
|
|
||||||
"typeVersion": 1.5,
|
|
||||||
"position": [
|
|
||||||
240,
|
|
||||||
180
|
|
||||||
],
|
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"respondWith": "json",
|
"respondWith": "json",
|
||||||
"responseBody": "={{$json.response_body}}",
|
"responseBody": "={{$json.response_body}}",
|
||||||
@@ -275,17 +53,6 @@
|
|||||||
],
|
],
|
||||||
"connections": {
|
"connections": {
|
||||||
"Webhook": {
|
"Webhook": {
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "normalize-request",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"normalize-request": {
|
|
||||||
"main": [
|
"main": [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -300,54 +67,7 @@
|
|||||||
"main": [
|
"main": [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"node": "append-log-response",
|
"node": "Respond to Webhook",
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "notify-response",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "unknown-action-response",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"append-log-response": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "respond-append-log",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"notify-response": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "respond-notify",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"unknown-action-response": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "respond-unknown-action",
|
|
||||||
"type": "main",
|
"type": "main",
|
||||||
"index": 0
|
"index": 0
|
||||||
}
|
}
|
||||||
@@ -355,7 +75,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pinData": {},
|
|
||||||
"settings": {
|
"settings": {
|
||||||
"executionOrder": "v1"
|
"executionOrder": "v1"
|
||||||
},
|
},
|
||||||
@@ -365,5 +84,5 @@
|
|||||||
"note": "After import, set Webhook authentication to Header Auth and bind a local credential using x-openclaw-secret. Secrets are intentionally not embedded in the workflow export."
|
"note": "After import, set Webhook authentication to Header Auth and bind a local credential using x-openclaw-secret. Secrets are intentionally not embedded in the workflow export."
|
||||||
},
|
},
|
||||||
"active": false,
|
"active": false,
|
||||||
"versionId": "openclaw-action-v1"
|
"versionId": "openclaw-action-v2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,26 +5,14 @@ from pathlib import Path
|
|||||||
|
|
||||||
REQUIRED_NODE_NAMES = {
|
REQUIRED_NODE_NAMES = {
|
||||||
'Webhook',
|
'Webhook',
|
||||||
'normalize-request',
|
|
||||||
'route-action',
|
'route-action',
|
||||||
'append-log-response',
|
'Respond to Webhook',
|
||||||
'respond-append-log',
|
|
||||||
'notify-response',
|
|
||||||
'respond-notify',
|
|
||||||
'unknown-action-response',
|
|
||||||
'respond-unknown-action',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
EXPECTED_DIRECT_TYPES = {
|
EXPECTED_DIRECT_TYPES = {
|
||||||
'Webhook': 'n8n-nodes-base.webhook',
|
'Webhook': 'n8n-nodes-base.webhook',
|
||||||
'normalize-request': 'n8n-nodes-base.set',
|
'route-action': 'n8n-nodes-base.code',
|
||||||
'route-action': 'n8n-nodes-base.switch',
|
'Respond to Webhook': 'n8n-nodes-base.respondToWebhook',
|
||||||
'append-log-response': 'n8n-nodes-base.set',
|
|
||||||
'respond-append-log': 'n8n-nodes-base.respondToWebhook',
|
|
||||||
'notify-response': 'n8n-nodes-base.set',
|
|
||||||
'respond-notify': 'n8n-nodes-base.respondToWebhook',
|
|
||||||
'unknown-action-response': 'n8n-nodes-base.set',
|
|
||||||
'respond-unknown-action': 'n8n-nodes-base.respondToWebhook',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -81,30 +69,25 @@ def main():
|
|||||||
if webhook_params.get('responseMode') != 'responseNode':
|
if webhook_params.get('responseMode') != 'responseNode':
|
||||||
fail('Webhook.responseMode must be responseNode')
|
fail('Webhook.responseMode must be responseNode')
|
||||||
|
|
||||||
normalize = by_name['normalize-request'].get('parameters', {})
|
router = by_name['route-action'].get('parameters', {})
|
||||||
normalize_assignments = normalize.get('assignments', {}).get('assignments', [])
|
if router.get('mode') != 'runOnceForEachItem':
|
||||||
normalize_fields = {a.get('name') for a in normalize_assignments if isinstance(a, dict)}
|
fail('route-action code node must use runOnceForEachItem mode')
|
||||||
for field in ('action', 'args', 'request_id'):
|
if router.get('language') != 'javaScript':
|
||||||
if field not in normalize_fields:
|
fail('route-action code node must use javaScript language')
|
||||||
fail(f'normalize-request must assign {field!r}')
|
js_code = router.get('jsCode', '')
|
||||||
|
for snippet in ("append_log", "notify", "unknown_action", "invalid_request", "status_code", "response_body"):
|
||||||
route = by_name['route-action'].get('parameters', {})
|
if snippet not in js_code:
|
||||||
rule_values = route.get('rules', {}).get('values', [])
|
fail(f'route-action jsCode missing expected snippet: {snippet!r}')
|
||||||
if len(rule_values) < 2:
|
|
||||||
fail('route-action must define at least two routing rules')
|
|
||||||
rule_names = {rule.get('outputKey') for rule in rule_values if isinstance(rule, dict)}
|
|
||||||
for action in ('append_log', 'notify'):
|
|
||||||
if action not in rule_names:
|
|
||||||
fail(f'route-action must have a routing rule for {action!r}')
|
|
||||||
|
|
||||||
route_outputs = connections.get('route-action', {}).get('main', [])
|
route_outputs = connections.get('route-action', {}).get('main', [])
|
||||||
if len(route_outputs) < 3:
|
if len(route_outputs) < 1:
|
||||||
fail('route-action must expose append_log, notify, and fallback outputs')
|
fail('route-action must connect to Respond to Webhook')
|
||||||
|
|
||||||
for responder_name in ('respond-append-log', 'respond-notify', 'respond-unknown-action'):
|
responder = by_name['Respond to Webhook'].get('parameters', {})
|
||||||
params = by_name[responder_name].get('parameters', {})
|
if responder.get('respondWith') != 'json':
|
||||||
if params.get('respondWith') != 'json':
|
fail('Respond to Webhook must respondWith json')
|
||||||
fail(f'{responder_name} must respondWith json')
|
if responder.get('responseBody') != '={{$json.response_body}}':
|
||||||
|
fail('Respond to Webhook must use $json.response_body as responseBody')
|
||||||
|
|
||||||
sample_paths = [
|
sample_paths = [
|
||||||
path.parent / 'test-append-log.json',
|
path.parent / 'test-append-log.json',
|
||||||
@@ -120,7 +103,7 @@ def main():
|
|||||||
print('OK: workflow asset structure looks consistent')
|
print('OK: workflow asset structure looks consistent')
|
||||||
print(f'- workflow: {path}')
|
print(f'- workflow: {path}')
|
||||||
print(f'- nodes: {len(nodes)}')
|
print(f'- nodes: {len(nodes)}')
|
||||||
print(f'- rules: {len(rule_values)} + fallback')
|
print('- router: code node with append_log + notify + fallback')
|
||||||
print('- samples: test-append-log.json, test-notify.json')
|
print('- samples: test-append-log.json, test-notify.json')
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user