{ "name": "HC-WA: LINEWORKS応答受信", "nodes": [ { "id": "webhook-lineworks-response", "name": "Webhook", "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [ 200, 400 ], "webhookId": "healthcheck-lineworks-response", "parameters": { "httpMethod": "POST", "path": "healthcheck-lineworks-response", "responseMode": "responseNode", "options": { "rawBody": true } } }, { "id": "dt-get-config", "name": "設定値一括取得", "type": "n8n-nodes-base.dataTable", "typeVersion": 1, "position": [ 420, 400 ], "parameters": { "operation": "get", "dataTableId": { "__rl": true, "mode": "id", "value": "bNkadTyDgDepYx2p" }, "returnAll": true } }, { "id": "code-verify-and-identify", "name": "署名検証・対象レコード特定", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 640, 400 ], "parameters": { "jsCode": "const crypto = require(\"crypto\");\nfunction normalizeSignature(value) {\n return String(value || \"\").trim().replace(/^sha256=/i, \"\");\n}\nfunction safeEqual(a, b) {\n const ab = Buffer.from(String(a), \"utf8\");\n const bb = Buffer.from(String(b), \"utf8\");\n if (ab.length !== bb.length) return false;\n return crypto.timingSafeEqual(ab, bb);\n}\nfunction verifySignature(rawBody, headerSignature, botSecret) {\n const headerSig = normalizeSignature(headerSignature);\n if (!headerSig || !botSecret) return false;\n const payload = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), \"utf8\");\n const expected = crypto.createHmac(\"sha256\", botSecret).update(payload).digest(\"base64\");\n return safeEqual(headerSig, expected);\n}\n\nconst configRows = $('設定値一括取得').all().map((item) => item.json);\nconst config = Object.fromEntries(configRows.map((row) => [row.configKey, row.configValue]));\n\nconst item = $('Webhook').first();\nconst headers = item.json.headers || {};\nconst rawBody = item.binary && item.binary.data\n ? Buffer.from(item.binary.data.data, \"base64\")\n : Buffer.from(JSON.stringify(item.json.body || {}), \"utf8\");\n\nif (!verifySignature(rawBody, headers[\"x-works-signature\"], config.LINEWORKS_BOT_SECRET)) {\n throw new Error(\"Unauthorized: signature mismatch\");\n}\n\nconst body = item.json.body || {};\nconst source = body.source || {};\nconst content = body.content || {};\nconst targetEmail = source.userId;\n\nasync function pleasanterPost(path, reqBody) {\n return this.helpers.httpRequest({\n method: \"POST\",\n url: `${config.PLEASANTER_BASE_URL_PROD}${path}`,\n body: { ApiVersion: 1.1, ApiKey: config.PLEASANTER_API_KEY_PROD, ...reqBody },\n json: true,\n });\n}\n\nconst userRes = await pleasanterPost.call(this, \"api/users/get\", {\n View: { ApiGetMailAddresses: true },\n Where: { MailAddress: targetEmail },\n});\nconst userId = userRes.Response.Data[0]?.UserId;\nif (!userId) throw new Error(`対象者が見つかりません: ${targetEmail}`);\n\nconst recordRes = await pleasanterPost.call(this, `api/items/${config.HEALTHCHECK_SITE_ID}/get`, {\n View: {\n ColumnFilterHash: { ClassC: String(userId) },\n ColumnFilterSearchTypes: { ClassC: \"ExactMatch\" },\n },\n});\nconst candidates = (recordRes.Response.Data || []).filter((r) => r.Status !== 900 && r.Status !== 910);\nif (candidates.length !== 1) {\n throw new Error(`対象レコードを一意に特定できません: ${candidates.length}件`);\n}\nconst record = candidates[0];\n\nreturn [{\n json: {\n config,\n resultId: record.ResultId,\n currentStatus: record.Status,\n contentType: content.type,\n text: content.type === \"text\" ? content.text : null,\n fileId: content.type === \"file\" ? content.fileId : null,\n },\n}];" } }, { "id": "dt-get-state", "name": "待機状態取得", "type": "n8n-nodes-base.dataTable", "typeVersion": 1, "position": [ 860, 400 ], "parameters": { "operation": "get", "dataTableId": { "__rl": true, "mode": "id", "value": "jqMDa2YZTI4f0iQ7" }, "filters": { "conditions": [ { "keyName": "resultId", "keyValue": "={{ $json.resultId }}" } ] }, "returnAll": true, "alwaysOutputData": true } }, { "id": "code-decide-action", "name": "分岐処理・アクション決定", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1080, 400 ], "parameters": { "jsCode": "function matchProcessByLabel(processes, text) {\n const trimmed = String(text ?? \"\").trim();\n return processes.find((p) => (p.label || p.DisplayName || p.Name) === trimmed) || null;\n}\nfunction getValidationColumnNames(process) {\n if (!Array.isArray(process.ValidateInputs)) return [];\n return process.ValidateInputs.map((v) => v.ColumnName).filter(Boolean);\n}\nfunction classifyAwaitInput(process) {\n const columnNames = getValidationColumnNames(process);\n if (columnNames.length === 0) return { awaitInput: \"none\", column: null };\n const column = columnNames[0];\n if (column.startsWith(\"Date\")) return { awaitInput: \"date\", column };\n if (column.startsWith(\"Attachments\")) return { awaitInput: \"file\", column };\n return { awaitInput: \"none\", column: null };\n}\n\nconst ERA_INFO = { \"令和\": 2018, \"平成\": 1988, \"昭和\": 1925, \"大正\": 1911 };\nconst ERA_ALIASES = { R: \"令和\", H: \"平成\", S: \"昭和\", T: \"大正\" };\nfunction pad2(value) { return String(value).padStart(2, \"0\"); }\nfunction finalizeDateParts(year, month, day) {\n if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {\n throw new Error(\"日付形式で回答してください(例: 2026-03-01)\");\n }\n const date = new Date(year, month - 1, day);\n if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {\n throw new Error(\"存在しない日付です\");\n }\n return `${year}-${pad2(month)}-${pad2(day)}`;\n}\nfunction tryParseEraDate(compact) {\n const match = compact.match(/^(令和|平成|昭和|大正|R|H|S|T)(.+)$/i);\n if (!match) return null;\n let era = match[1];\n if (/^[RHST]$/i.test(era)) era = ERA_ALIASES[era.toUpperCase()] || era;\n if (!ERA_INFO[era]) return null;\n const normalized = match[2].replace(/年/g, \"-\").replace(/月/g, \"-\").replace(/日/g, \"\").replace(/[.\\/]/g, \"-\");\n const parts = normalized.split(\"-\").filter((part) => part.length > 0);\n if (parts.length < 3) throw new Error(\"月と日まで入力してください(例: 令和6年3月1日)\");\n const eraYear = Number(parts[0]);\n const month = Number(parts[1]);\n const day = Number(parts[2]);\n if (!Number.isFinite(eraYear) || !Number.isFinite(month) || !Number.isFinite(day)) {\n throw new Error(\"日付形式で回答してください(例: 令和6年3月1日)\");\n }\n return finalizeDateParts(ERA_INFO[era] + eraYear, month, day);\n}\nfunction tryParseMonthDay(compact) {\n const match = compact.match(/^(\\d{1,2})(?:月|\\/|-|\\.)(\\d{1,2})(?:日)?$/);\n if (!match) return null;\n const currentYear = new Date().getFullYear();\n return finalizeDateParts(currentYear, Number(match[1]), Number(match[2]));\n}\nfunction parseDateInput(value) {\n const trimmed = String(value ?? \"\").trim();\n if (!trimmed) throw new Error(\"日付が空です\");\n const compact = trimmed.replace(/\\s+/g, \"\");\n const eraResult = tryParseEraDate(compact);\n if (eraResult) return eraResult;\n const monthDayResult = tryParseMonthDay(compact);\n if (monthDayResult) return monthDayResult;\n const normalized = compact.replace(/年/g, \"-\").replace(/月/g, \"-\").replace(/日/g, \"\").replace(/[.\\/]/g, \"-\");\n const isoParts = normalized.split(\"-\").filter((part) => part.length > 0);\n if (isoParts.length === 3 && isoParts[0].length >= 4) {\n return finalizeDateParts(Number(isoParts[0]), Number(isoParts[1]), Number(isoParts[2]));\n }\n const parsed = new Date(trimmed);\n if (Number.isNaN(parsed.getTime())) {\n throw new Error(\"日付形式で回答してください(例: 2026-03-01 や 令和6年3月1日)\");\n }\n return finalizeDateParts(parsed.getFullYear(), parsed.getMonth() + 1, parsed.getDate());\n}\n\nconst identity = $('署名検証・対象レコード特定').item.json;\nconst { config, resultId, contentType, text } = identity;\n\nconst stateRows = $('待機状態取得').all().map((item) => item.json);\nconst state = stateRows[0] || null;\n\nasync function pleasanterPost(path, body) {\n return this.helpers.httpRequest({\n method: \"POST\",\n url: `${config.PLEASANTER_BASE_URL_PROD}${path}`,\n body: { ApiVersion: 1.1, ApiKey: config.PLEASANTER_API_KEY_PROD, ...body },\n json: true,\n });\n}\n\nfunction buildResult(action, extra, nextState) {\n const fallbackNext = state\n ? { awaitInput: state.awaitInput, awaitProcessId: state.awaitProcessId, awaitColumn: state.awaitColumn }\n : { awaitInput: \"none\", awaitProcessId: \"\", awaitColumn: \"\" };\n const next = nextState || fallbackNext;\n return [{\n json: {\n action,\n resultId,\n config,\n targetEmail: state ? state.targetEmail : null,\n processId: null,\n messageText: null,\n ...extra,\n nextAwaitInput: next.awaitInput,\n nextAwaitProcessId: String(next.awaitProcessId ?? \"\"),\n nextAwaitColumn: next.column !== undefined ? (next.column || \"\") : (next.awaitColumn || \"\"),\n },\n }];\n}\n\nif (!state || ![\"none\", \"date\", \"file\"].includes(state.awaitInput)) {\n return buildResult(\"error\", {\n messageText: `待機状態が不正です(resultId=${resultId})。healthcheck_bot_stateの該当行を確認してください。`,\n }, { awaitInput: \"none\", awaitProcessId: \"\", awaitColumn: \"\" });\n}\n\nif (state.awaitInput === \"none\") {\n const pendingProcesses = JSON.parse(state.pendingProcesses || \"[]\");\n const matched = matchProcessByLabel(pendingProcesses, text);\n\n if (!matched) {\n // 不一致 → フォールバック再送(HC-SUBが現在Statusの選択肢案内を再送する)\n return buildResult(\"execute\", { processId: null }, { awaitInput: \"none\", awaitProcessId: \"\", awaitColumn: \"\" });\n }\n\n // HC-SUBが保存するpendingProcessesは{processId, label, tooltip}のみでValidateInputsを\n // 持たないため、追加入力の要否判定にはサイト設定(getsite)からフルのProcess定義を\n // 引き直す必要がある(README.md記載の設計注記のとおり)。\n const siteRes = await pleasanterPost.call(this, `api/items/${config.HEALTHCHECK_SITE_ID}/getsite`, {});\n const siteSettings = siteRes.Response.Data.SiteSettings;\n const processes = siteSettings.Processes || [];\n const fullProcess = processes.find((p) => p.Id === matched.processId);\n if (!fullProcess) {\n throw new Error(`ProcessId ${matched.processId} がサイト設定内に見つかりません`);\n }\n const classification = classifyAwaitInput(fullProcess);\n\n if (classification.awaitInput === \"none\") {\n return buildResult(\"execute\", { processId: matched.processId }, { awaitInput: \"none\", awaitProcessId: \"\", awaitColumn: \"\" });\n }\n\n const promptText = classification.awaitInput === \"date\"\n ? \"日付を入力してください(例: 2026-03-01)\"\n : \"ファイルを送信してください\";\n return buildResult(\"send\", { messageText: promptText }, {\n awaitInput: classification.awaitInput,\n awaitProcessId: String(matched.processId),\n column: classification.column || \"\",\n });\n}\n\nif (state.awaitInput === \"date\") {\n try {\n const parsedDate = parseDateInput(text);\n await pleasanterPost.call(this, `api/items/${resultId}/update`, {\n DateHash: { [state.awaitColumn]: parsedDate },\n });\n return buildResult(\"execute\", { processId: Number(state.awaitProcessId) }, { awaitInput: \"none\", awaitProcessId: \"\", awaitColumn: \"\" });\n } catch (err) {\n return buildResult(\"send\", { messageText: err.message }, {\n awaitInput: \"date\", awaitProcessId: state.awaitProcessId, column: state.awaitColumn,\n });\n }\n}\n\n// state.awaitInput === \"file\"\nif (contentType !== \"file\") {\n return buildResult(\"send\", { messageText: \"ファイルを送信してください\" }, {\n awaitInput: \"file\", awaitProcessId: state.awaitProcessId, column: state.awaitColumn,\n });\n}\n\n// TODO(要フォローアップ、未実装): LINEWORKSファイル添付のダウンロード\n// (GET https://www.worksapis.com/v1.0/bots/{botId}/attachments/{fileId})には\n// LINEWORKS Bot APIのアクセストークン(JWTアサーション経由)が必要だが、プロジェクトルールにより\n// JWT署名は専用のJWTノード(このワークフローではaction:\"send\"経路のノードのみに存在し、\n// このCodeノードより後段に位置する)でしか行えない。そのため本Codeノード内では\n// トークンを取得する手段がなく、ダウンロード〜Pleasanter添付アップロード\n// (Pleasanter公式マニュアルapi-attachment系、具体的なエンドポイントも実装時要確認)は未実装。\n// フォローアップタスクでグラフ構成の見直し(ファイル受信専用のJWT/Tokenペアを追加する等)を検討すること。\nreturn buildResult(\"send\", {\n messageText: \"現在、ファイル添付の処理は準備中です。しばらくお待ちいただくか、担当者にご連絡ください。\",\n}, { awaitInput: \"file\", awaitProcessId: state.awaitProcessId, column: state.awaitColumn });" } }, { "id": "dt-update-state", "name": "状態更新", "type": "n8n-nodes-base.dataTable", "typeVersion": 1, "position": [ 1300, 400 ], "parameters": { "operation": "update", "dataTableId": { "__rl": true, "mode": "id", "value": "jqMDa2YZTI4f0iQ7" }, "filters": { "conditions": [ { "keyName": "resultId", "keyValue": "={{ $json.resultId }}" } ] }, "columns": { "mappingMode": "defineBelow", "value": { "awaitInput": "={{ $json.nextAwaitInput }}", "awaitProcessId": "={{ $json.nextAwaitProcessId }}", "awaitColumn": "={{ $json.nextAwaitColumn }}" } } } }, { "id": "if-action-execute", "name": "IF: action==execute", "type": "n8n-nodes-base.if", "typeVersion": 2, "position": [ 1520, 400 ], "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "loose", "version": 2 }, "conditions": [ { "id": "cond-execute", "leftValue": "={{ $('分岐処理・アクション決定').item.json.action === 'execute' }}", "rightValue": true, "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} } }, { "id": "execute-hc-sub", "name": "HC-SUB実行", "type": "n8n-nodes-base.executeWorkflow", "typeVersion": 1.2, "position": [ 1740, 260 ], "parameters": { "workflowId": { "__rl": true, "mode": "id", "value": "XRqcykbG2LuAjGG2" }, "workflowInputs": { "mappingMode": "defineBelow", "value": { "resultId": "={{ $('分岐処理・アクション決定').item.json.resultId }}", "processId": "={{ $('分岐処理・アクション決定').item.json.processId }}" } } } }, { "id": "if-action-send", "name": "IF: action==send", "type": "n8n-nodes-base.if", "typeVersion": 2, "position": [ 1740, 540 ], "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "loose", "version": 2 }, "conditions": [ { "id": "cond-send", "leftValue": "={{ $('分岐処理・アクション決定').item.json.action === 'send' }}", "rightValue": true, "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} } }, { "id": "code-jwt-claims", "name": "JWTクレーム組み立て", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1960, 460 ], "parameters": { "jsCode": "const decision = $('分岐処理・アクション決定').item.json;\nconst config = decision.config;\nconst now = Math.floor(Date.now() / 1000);\n\nconst jwtClaims = JSON.stringify({\n iss: config.LW_BOT_CLIENT_ID,\n sub: config.LW_BOT_SERVICE_ACCOUNT,\n iat: now,\n exp: now + 3600,\n aud: 'https://auth.worksmobile.com/oauth2/v2.0/token',\n});\n\nasync function pleasanterPost(path, body) {\n return this.helpers.httpRequest({\n method: 'POST',\n url: `${config.PLEASANTER_BASE_URL_PROD}${path}`,\n body: { ApiVersion: 1.1, ApiKey: config.PLEASANTER_API_KEY_PROD, ...body },\n json: true,\n });\n}\n\n// TODO(Task 12で要確認・HC-SUBと同一の暫定実装): 484184(LINEWORKS_BOT_MASTER_SITE_ID)の\n// 実データ構造が未調査のため、SiteSettings.BotIds または Processes[].BotId のいずれかを想定する。\nconst botMasterRes = await pleasanterPost.call(this, `api/items/${config.LINEWORKS_BOT_MASTER_SITE_ID}/getsite`, {});\nconst botMasterSiteSettings = botMasterRes.Response.Data.SiteSettings;\nconst candidateBotIds = botMasterSiteSettings.BotIds\n || (botMasterSiteSettings.Processes || []).map((p) => p.BotId).filter(Boolean);\nconst botId = candidateBotIds && candidateBotIds[0];\nif (!botId) {\n throw new Error('BotIdを484184(LINEWORKS_BOT_MASTER_SITE_ID)から解決できませんでした。');\n}\n\nconst apiUrl = `https://www.worksapis.com/v1.0/bots/${botId}/users/${decision.targetEmail}/messages`;\n\nconst messageContent = {\n type: 'text',\n text: decision.messageText || '',\n};\n\nreturn [{\n json: {\n jwtClaims,\n apiUrl,\n messageContent,\n config,\n resultId: decision.resultId,\n },\n}];" } }, { "id": "jwt-sign", "name": "Sign JWT", "type": "n8n-nodes-base.jwt", "typeVersion": 1, "position": [ 2180, 460 ], "parameters": { "operation": "sign", "useJson": true, "claimsJson": "={{ $json.jwtClaims }}", "options": { "algorithm": "RS256" } }, "credentials": { "jwtAuth": { "id": "Hw0qlEaGfLPnQWp1", "name": "LINEWORKS Bot Private Key (v4)" } } }, { "id": "code-lineworks-send", "name": "アクセストークン取得・LINEWORKS送信", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2400, 460 ], "parameters": { "jsCode": "const claims = $('JWTクレーム組み立て').item.json;\nconst config = claims.config;\nconst assertion = $json.token;\n\n// LINEWORKS tokenエンドポイントはx-www-form-urlencodedのみ受け付ける。\n// this.helpers.httpRequestはbodyにURLSearchParamsインスタンスを渡すと\n// application/x-www-form-urlencodedへの変換とContent-Type設定を自動で行うため、\n// 素のURLSearchParamsを渡す(.toString()しない、ヘッダーも手動指定しない)。\nconst tokenBody = new URLSearchParams({\n assertion,\n grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',\n client_id: config.LW_BOT_CLIENT_ID,\n client_secret: config.LW_BOT_CLIENT_SECRET,\n scope: 'bot',\n});\n\nconst tokenRes = await this.helpers.httpRequest({\n method: 'POST',\n url: 'https://auth.worksmobile.com/oauth2/v2.0/token',\n body: tokenBody,\n});\nconst tokenJson = typeof tokenRes === 'string' ? JSON.parse(tokenRes) : tokenRes;\nconst accessToken = tokenJson.access_token;\nif (!accessToken) {\n throw new Error('LINEWORKSアクセストークンの取得に失敗しました。');\n}\n\nconst sendRes = await this.helpers.httpRequest({\n method: 'POST',\n url: claims.apiUrl,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n 'Content-Type': 'application/json;charset=UTF-8',\n },\n body: { content: claims.messageContent },\n json: true,\n});\n\nreturn [{ json: { result: 'ok', resultId: claims.resultId, lineworksResponse: sendRes } }];" } }, { "id": "respond-ok", "name": "Respond to Webhook(成功)", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.1, "position": [ 2620, 320 ], "parameters": { "respondWith": "json", "responseBody": "={{ JSON.stringify({ result: \"ok\" }) }}", "options": {} } }, { "id": "respond-error", "name": "Respond to Webhook(異常系)", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.1, "position": [ 1960, 680 ], "parameters": { "respondWith": "json", "responseBody": "={{ JSON.stringify({ result: \"error\", message: $('分岐処理・アクション決定').item.json.messageText || \"unknown error\" }) }}", "options": {} } } ], "connections": { "Webhook": { "main": [ [ { "node": "設定値一括取得", "type": "main", "index": 0 } ] ] }, "設定値一括取得": { "main": [ [ { "node": "署名検証・対象レコード特定", "type": "main", "index": 0 } ] ] }, "署名検証・対象レコード特定": { "main": [ [ { "node": "待機状態取得", "type": "main", "index": 0 } ] ] }, "待機状態取得": { "main": [ [ { "node": "分岐処理・アクション決定", "type": "main", "index": 0 } ] ] }, "分岐処理・アクション決定": { "main": [ [ { "node": "状態更新", "type": "main", "index": 0 } ] ] }, "状態更新": { "main": [ [ { "node": "IF: action==execute", "type": "main", "index": 0 } ] ] }, "IF: action==execute": { "main": [ [ { "node": "HC-SUB実行", "type": "main", "index": 0 } ], [ { "node": "IF: action==send", "type": "main", "index": 0 } ] ] }, "HC-SUB実行": { "main": [ [ { "node": "Respond to Webhook(成功)", "type": "main", "index": 0 } ] ] }, "IF: action==send": { "main": [ [ { "node": "JWTクレーム組み立て", "type": "main", "index": 0 } ], [ { "node": "Respond to Webhook(異常系)", "type": "main", "index": 0 } ] ] }, "JWTクレーム組み立て": { "main": [ [ { "node": "Sign JWT", "type": "main", "index": 0 } ] ] }, "Sign JWT": { "main": [ [ { "node": "アクセストークン取得・LINEWORKS送信", "type": "main", "index": 0 } ] ] }, "アクセストークン取得・LINEWORKS送信": { "main": [ [ { "node": "Respond to Webhook(成功)", "type": "main", "index": 0 } ] ] } }, "settings": { "executionOrder": "v1" } }