Synology Chat incoming webhook은 JSON body가 아닌
form-encoded payload={"text":"..."} 형식 필요.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
224 lines
9.5 KiB
JSON
224 lines
9.5 KiB
JSON
{
|
|
"id": "mPretrocapture001",
|
|
"name": "회고 캡처 파이프라인",
|
|
"nodes": [
|
|
{
|
|
"parameters": {
|
|
"httpMethod": "POST",
|
|
"path": "retrospect",
|
|
"responseMode": "responseNode",
|
|
"options": {}
|
|
},
|
|
"id": "r1000001-0000-0000-0000-000000000001",
|
|
"name": "Webhook",
|
|
"type": "n8n-nodes-base.webhook",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
0,
|
|
300
|
|
],
|
|
"webhookId": "retrospect"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const body = $input.first().json.body || $input.first().json;\nconst text = (body.text || '').trim();\nconst postId = body.post_id || 0;\nconst username = body.username || 'unknown';\nconst userId = body.user_id || 0;\nconst timestamp = body.timestamp || 0;\n\nconst valid = text.length > 0;\n\nreturn [{ json: { text, post_id: postId, username, user_id: userId, timestamp, valid } }];"
|
|
},
|
|
"id": "r1000001-0000-0000-0000-000000000002",
|
|
"name": "Validate",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 1,
|
|
"position": [
|
|
220,
|
|
300
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "strict"
|
|
},
|
|
"conditions": [
|
|
{
|
|
"id": "valid-check",
|
|
"leftValue": "={{ $json.valid }}",
|
|
"rightValue": true,
|
|
"operator": {
|
|
"type": "boolean",
|
|
"operation": "true"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
},
|
|
"id": "r1000001-0000-0000-0000-000000000003",
|
|
"name": "IF Valid?",
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.2,
|
|
"position": [
|
|
440,
|
|
300
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "function httpPost(url, body, { timeout = 15000, headers = {} } = {}) {\n return new Promise((resolve, reject) => {\n const data = JSON.stringify(body);\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const req = mod.request({\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }\n }, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => {\n if (res.statusCode >= 400) return reject(new Error(url + ' \\u2192 ' + res.statusCode + ': ' + body.slice(0, 200)));\n try { resolve(JSON.parse(body)); } catch(e) { reject(new Error('JSON parse error: ' + body.slice(0, 200))); }\n });\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error(url + ' \\u2192 timeout after ' + timeout + 'ms')); });\n req.write(data);\n req.end();\n });\n}\n\nconst item = $input.first().json;\nconst text = item.text;\n\nconst prompt = `다음 텍스트를 분류하세요. JSON만 출력.\n\n분류 기준:\n- domain: work (업무, 회의, 프로젝트) | health (건강, 운동, 식사, 수면) | finance (지출, 투자, 예산)\n- entry_type: event (일어난 일) | reflection (생각, 깨달음) | regret (후회, 아쉬움) | log (단순 기록)\n- sentiment: positive | neutral | negative\n- tags: 관련 키워드 배열 (2~5개, 한국어)\n- confidence: 0.0~1.0 (분류 확신도)\n\n출력 형식:\n{\"domain\": \"...\", \"entry_type\": \"...\", \"sentiment\": \"...\", \"tags\": [\"...\"], \"confidence\": 0.0}\n\n텍스트: ${text}`;\n\ntry {\n const r = await httpPost(`${$env.GPU_OLLAMA_URL}/api/generate`,\n { model: 'id-9b:latest', system: '/no_think', prompt, stream: false, format: 'json', think: false },\n { timeout: 15000 }\n );\n const cls = JSON.parse(r.response);\n const validDomains = ['work', 'health', 'finance'];\n const validTypes = ['event', 'reflection', 'regret', 'log'];\n const validSentiments = ['positive', 'neutral', 'negative'];\n return [{ json: {\n ...item,\n domain: validDomains.includes(cls.domain) ? cls.domain : 'work',\n entry_type: validTypes.includes(cls.entry_type) ? cls.entry_type : 'log',\n sentiment: validSentiments.includes(cls.sentiment) ? cls.sentiment : 'neutral',\n tags: Array.isArray(cls.tags) ? cls.tags.slice(0, 5) : [],\n confidence: typeof cls.confidence === 'number' ? Math.min(1, Math.max(0, cls.confidence)) : 0.5,\n gpu_ok: true\n } }];\n} catch(e) {\n // GPU 타임아웃/장애 시 기본값으로 저장 (엔트리 유실 금지)\n return [{ json: {\n ...item,\n domain: 'work',\n entry_type: 'log',\n sentiment: 'neutral',\n tags: [],\n confidence: 0.0,\n gpu_ok: false\n } }];\n}"
|
|
},
|
|
"id": "r1000001-0000-0000-0000-000000000004",
|
|
"name": "Qwen Classify",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 1,
|
|
"position": [
|
|
660,
|
|
200
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"operation": "executeQuery",
|
|
"query": "=INSERT INTO retrospect.entries (raw_text, domain, entry_type, sentiment, tags, confidence, source_post_id, username) VALUES ('{{ $json.text.replace(/'/g, \"''\") }}', '{{ $json.domain }}', '{{ $json.entry_type }}', '{{ $json.sentiment }}', '{{ \"{\" + ($json.tags || []).map(t => '\"' + t.replace(/\"/g, '') + '\"').join(',') + \"}\" }}', {{ $json.confidence }}, {{ $json.post_id || 'NULL' }}, '{{ ($json.username || \"unknown\").replace(/'/g, \"''\") }}') ON CONFLICT (source_post_id) WHERE source_post_id IS NOT NULL DO NOTHING",
|
|
"options": {}
|
|
},
|
|
"id": "r1000001-0000-0000-0000-000000000005",
|
|
"name": "PostgreSQL INSERT",
|
|
"type": "n8n-nodes-base.postgres",
|
|
"typeVersion": 2.5,
|
|
"position": [
|
|
880,
|
|
200
|
|
],
|
|
"credentials": {
|
|
"postgres": {
|
|
"id": "KaxU8iKtraFfsrTF",
|
|
"name": "bot-postgres"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "function httpPostForm(url, formBody, { timeout = 10000 } = {}) {\n return new Promise((resolve, reject) => {\n const u = require('url').parse(url);\n const mod = require(u.protocol === 'https:' ? 'https' : 'http');\n const options = {\n hostname: u.hostname, port: u.port, path: u.path,\n method: 'POST',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(formBody) },\n rejectUnauthorized: false\n };\n const req = mod.request(options, (res) => {\n let body = '';\n res.on('data', c => body += c);\n res.on('end', () => resolve({ statusCode: res.statusCode, body }));\n });\n req.on('error', reject);\n req.setTimeout(timeout, () => { req.destroy(); reject(new Error('timeout')); });\n req.write(formBody);\n req.end();\n });\n}\n\nconst item = $input.first().json;\nconst webhookUrl = $env.RETROSPECT_CHAT_WEBHOOK_URL || '';\n\nif (webhookUrl) {\n const tagsStr = (item.tags || []).map(t => '#' + t).join(' ');\n const msg = `\\u2713 [${item.domain}] ${tagsStr}`;\n try {\n const payload = JSON.stringify({ text: msg });\n const formBody = 'payload=' + encodeURIComponent(payload);\n await httpPostForm(webhookUrl, formBody, { timeout: 10000 });\n } catch(e) {\n console.log('Chat ack failed: ' + e.message);\n }\n}\n\nreturn [{ json: { ...item, ack_sent: true } }];"
|
|
},
|
|
"id": "r1000001-0000-0000-0000-000000000006",
|
|
"name": "Chat Confirm",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 1,
|
|
"position": [
|
|
1100,
|
|
200
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"respondWith": "json",
|
|
"responseBody": "={ \"status\": \"ok\" }",
|
|
"options": {}
|
|
},
|
|
"id": "r1000001-0000-0000-0000-000000000007",
|
|
"name": "Respond 200",
|
|
"type": "n8n-nodes-base.respondToWebhook",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
1320,
|
|
200
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"respondWith": "json",
|
|
"responseBody": "={ \"status\": \"skipped\" }",
|
|
"options": {}
|
|
},
|
|
"id": "r1000001-0000-0000-0000-000000000008",
|
|
"name": "Respond Skip",
|
|
"type": "n8n-nodes-base.respondToWebhook",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
660,
|
|
420
|
|
]
|
|
}
|
|
],
|
|
"connections": {
|
|
"Webhook": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Validate",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Validate": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "IF Valid?",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"IF Valid?": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Qwen Classify",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Respond Skip",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Qwen Classify": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "PostgreSQL INSERT",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"PostgreSQL INSERT": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Chat Confirm",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Chat Confirm": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Respond 200",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
}
|
|
},
|
|
"settings": {
|
|
"executionOrder": "v1"
|
|
}
|
|
}
|