diff --git a/NodeSrv/apps/healthcheck-survey-bot/README.md b/NodeSrv/apps/healthcheck-survey-bot/README.md index d1903e12..2f8d79a7 100644 --- a/NodeSrv/apps/healthcheck-survey-bot/README.md +++ b/NodeSrv/apps/healthcheck-survey-bot/README.md @@ -58,8 +58,29 @@ Task 5(processFlow.js)の`getValidationColumnNames`はこの構造(`proces - デプロイ・活性化はサブエージェントのBashサンドボックスが実APIキー使用を一律ブロックしたためコントローラーが直接実行した(詳細: `task-10-report.md`) - ワークフロー `HC-WA: LINEWORKS応答受信`: **ローカルJSON作成のみ、n8n未デプロイ**(id未採番) - - ファイル: `workflows/hc-wa-lineworks-response.json`(45ノード、Webhook `healthcheck-lineworks-response` 唯一の受信口、`awaitInput`(none/date/file)3分岐のSwitch構成) + - ファイル: `workflows/hc-wa-lineworks-response.json` + - **プロジェクト全体のn8nワークフロー構築ルール(2026-09-05改訂)に合わせて14ノード構成で全面リビルド。旧60ノード版(個別キーごとのData Table/HTTP Requestノードを積み上げ、awaitInput分岐をSwitch+各分岐に案内送信一式を重複配置した構成)はこのタスクでの再構築に伴い置き換えた。**IF・JWT・Execute Workflow・Data Tableは各ルールに従い専用ノードのままだが、それらは機械的に必要な最小限(IF×2, JWT×1, Execute Workflow×1, Data Table×3)であり、本来Codeノードに集約可能な分岐ロジック・Pleasanter/LINEWORKS API呼び出しはすべて4つのCodeノードに集約した(ブリーフの目安「11ノード」に対し実際は14ノードだが、これはIF/JWT/Execute Workflowを専用ノードに保つというルール自体が要求する固定オーバーヘッドであり、60ノードからの削減という趣旨は達成している) - 本タスクの担当範囲はワークフローJSONのローカル作成・検証までで、n8n Public APIの呼び出し(デプロイ・activate)は一切実施していない。コントローラー側での`node scripts/deploy-workflow.js workflows/hc-wa-lineworks-response.json`実行後、実際のワークフローIDをこの行に追記すること - - 秘密情報・SiteId等はすべて`workflow_config_values`(`bNkadTyDgDepYx2p`)から`Data Table`ノードで取得し式参照。ワークフローJSONに秘密値のハードコードなし - - 未検証の暫定実装あり(詳細は`task-11-report.md`): ①Data Table `update`操作のパラメータ形状(get/insertは実機確認済みだが、updateは本タスクで初採用のため未検証)、②Switchノード(typeVersion 3)の`rules.values[].outputKey`スキーマ、③`/api/users/get`をWhere無しで呼んだ場合のレスポンス形状(配列/`.Value`/単一オブジェクトいずれにも対応する防御的実装)、④`api/items/{SiteId}/get`(ColumnFilterHash検索)のレスポンス形状、⑤LINEWORKSファイル添付のダウンロード/Pleasanterアップロードのエンドポイント(ブリーフ記載どおり実装時にPleasanter公式マニュアルapi-attachment系で要確認のプレースホルダー) + - ノード構成(14ノード): + 1. `Webhook`(`healthcheck-lineworks-response`, `rawBody:true`) + 2. `Data Table`「設定値一括取得」: `workflow_config_values`を`filters`無し・`returnAll:true`で一括取得 + 3. `Code`「署名検証・対象レコード特定」: HMAC-SHA256署名検証、対象者メール→PleasanterUserId解決、508971の対象レコード検索(`ColumnFilterHash`)までを1ノードに集約。0件/複数件はthrow(自動判定しない) + 4. `Data Table`「待機状態取得」: `healthcheck_bot_state`から該当resultIdの行を取得 + 5. `Code`「分岐処理・アクション決定」: `awaitInput`(none/date/file、または待機状態自体が無い場合)に応じた分岐ロジック全体を1ノードに集約。`action`(execute/send/error)と次の待機状態(`nextAwaitInput`/`nextAwaitProcessId`/`nextAwaitColumn`)を1つのJSONオブジェクトとして出力する + 6. `Data Table`「状態更新」: 5の出力を使い`healthcheck_bot_state`を無条件で`update` + 7. `IF`「action==execute」: true→8、false→9 + 8. `Execute Workflow`「HC-SUB実行」(Task 9, `XRqcykbG2LuAjGG2`)→13 + 9. `IF`「action==send」: true→10、false→14 + 10. `Code`「JWTクレーム組み立て」: 484184からのBotId解決とJWTクレーム・LINEWORKSメッセージ内容の組み立てを1ノードに集約 + 11. `JWT`「Sign JWT」(`operation:sign`, `RS256`, Credential: 「LINEWORKS Bot Private Key (v4)」`Hw0qlEaGfLPnQWp1`) + 12. `Code`「アクセストークン取得・LINEWORKS送信」: LINEWORKSアクセストークン取得とメッセージ送信を1ノードに集約→13 + 13. `Respond to Webhook(成功)`: 8と12の両方から接続される共有の成功応答ノード + 14. `Respond to Webhook(異常系)`: 9のfalse分岐(action=="error")専用 + - **分岐ノードの出力の流れ方(設計上の重要ポイント)**: ノード5は「今回どうするか(action/processId/messageText)」と「次に書き込む待機状態(next*)」を1つの出力オブジェクトにまとめて返す。ノード6(状態更新)はノード5の直接の後続ノードなのでそのまま`$json.next*`を参照して無条件更新するが、ノード6通過後の`$json`はData Table Updateの返り値(更新後の行データ)に置き換わり、ノード5の`action`/`processId`/`messageText`等は失われる。そのため、ノード7以降(IF・Execute Workflow・JWTクレーム組み立て・Respond to Webhook(異常系))はすべて裸の`$json`ではなく`$('分岐処理・アクション決定').item.json...`とノード名を明示して参照している(HC-SUBの`$('Execute Workflow Trigger').item.json`と同じ規約) + - 秘密情報・SiteId等はすべて`workflow_config_values`(`bNkadTyDgDepYx2p`)から「設定値一括取得」1ノードで取得し、直後のCodeノードで`configKey`→`configValue`のMapに変換して参照。ワークフローJSONに秘密値のハードコードなし + - 未検証・要判断の暫定実装・既知の未実装あり(詳細は`task-11-rebuild-report.md`): + ①Data Table `update`操作のパラメータ形状(get/insertは実機確認済みだが、updateは本タスクで初採用のため未検証、ブリーフ記載のサンプル形状をそのまま採用) + ②`/api/users/get`・`api/items/{SiteId}/get`(ColumnFilterHash検索)のレスポンス形状はHC-SUB・旧WA版と同じ想定を踏襲 + ③484184(`LINEWORKS_BOT_MASTER_SITE_ID`)からのBotId解決ロジックはHC-SUBと同じ暫定実装(`SiteSettings.BotIds`または`Processes[].BotId`を仮定) + ④**LINEWORKSファイル添付(`awaitInput:"file"`分岐)の実処理は未実装。** ファイルダウンロード(`GET /v1.0/bots/{botId}/attachments/{fileId}`)にはLINEWORKS Bot APIのアクセストークンが必要だが、JWT署名は専用のJWTノード(ノード11、action=="send"経路にのみ存在しノード5より後段)でしか行えないため、ノード5内ではトークンを取得する手段がない。現状はこのケースを検出すると`action:"send"`でユーザーに「準備中」メッセージを返し、待機状態は`file`のまま維持する(ユーザーが詰まらないための安全側実装)。実装するにはグラフ構成の見直し(ファイル受信専用のJWT/Tokenペアを追加する等)が必要で、フォローアップタスクで対応すること - 設計上の注記: HC-SUB(Task 9)が`healthcheck_bot_state`に保存する`pendingProcesses`は`{processId, label, tooltip}`のみで`ValidateInputs`を持たないため、none分岐での`classifyAwaitInput`実行時はHC-WA内で`サイト設定取得`(getsite)を再実行し、ProcessId一致でフルのProcess定義を引き直す設計にした(Task 9側のファイルは変更していない) diff --git a/NodeSrv/apps/healthcheck-survey-bot/workflows/hc-wa-lineworks-response.json b/NodeSrv/apps/healthcheck-survey-bot/workflows/hc-wa-lineworks-response.json index 530dd3fb..fb7f80c6 100644 --- a/NodeSrv/apps/healthcheck-survey-bot/workflows/hc-wa-lineworks-response.json +++ b/NodeSrv/apps/healthcheck-survey-bot/workflows/hc-wa-lineworks-response.json @@ -8,7 +8,7 @@ "typeVersion": 2, "position": [ 200, - 500 + 400 ], "webhookId": "healthcheck-lineworks-response", "parameters": { @@ -21,13 +21,13 @@ } }, { - "id": "dt-get-lineworks_bot_secret", - "name": "LINEWORKS_BOT_SECRET取得", + "id": "dt-get-config", + "name": "設定値一括取得", "type": "n8n-nodes-base.dataTable", "typeVersion": 1, "position": [ 420, - 500 + 400 ], "parameters": { "operation": "get", @@ -36,181 +36,30 @@ "mode": "id", "value": "bNkadTyDgDepYx2p" }, - "filters": { - "conditions": [ - { - "keyName": "configKey", - "keyValue": "LINEWORKS_BOT_SECRET" - } - ] - }, "returnAll": true } }, { - "id": "code-verify-signature", - "name": "署名検証・送信者解決", + "id": "code-verify-and-identify", + "name": "署名検証・対象レコード特定", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 640, - 500 + 400 ], "parameters": { - "jsCode": "// Codeノード「署名検証・送信者解決」の中身\nconst 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 LINEWORKS_BOT_SECRET = $('LINEWORKS_BOT_SECRET取得').item.json.configValue;\n\nconst item = $input.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\"], 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 || {};\n\nreturn [{\n json: {\n targetEmail: source.userId,\n contentType: content.type,\n text: content.type === \"text\" ? content.text : null,\n fileId: content.type === \"file\" ? content.fileId : null,\n },\n}];" + "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 = $input.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-pleasanter_base_url_prod", - "name": "PLEASANTER_BASE_URL取得", - "type": "n8n-nodes-base.dataTable", - "typeVersion": 1, - "position": [ - 860, - 500 - ], - "parameters": { - "operation": "get", - "dataTableId": { - "__rl": true, - "mode": "id", - "value": "bNkadTyDgDepYx2p" - }, - "filters": { - "conditions": [ - { - "keyName": "configKey", - "keyValue": "PLEASANTER_BASE_URL_PROD" - } - ] - }, - "returnAll": true - } - }, - { - "id": "dt-get-pleasanter_api_key_prod", - "name": "PLEASANTER_API_KEY取得", - "type": "n8n-nodes-base.dataTable", - "typeVersion": 1, - "position": [ - 1080, - 500 - ], - "parameters": { - "operation": "get", - "dataTableId": { - "__rl": true, - "mode": "id", - "value": "bNkadTyDgDepYx2p" - }, - "filters": { - "conditions": [ - { - "keyName": "configKey", - "keyValue": "PLEASANTER_API_KEY_PROD" - } - ] - }, - "returnAll": true - } - }, - { - "id": "dt-get-healthcheck_site_id", - "name": "HEALTHCHECK_SITE_ID取得", - "type": "n8n-nodes-base.dataTable", - "typeVersion": 1, - "position": [ - 1300, - 500 - ], - "parameters": { - "operation": "get", - "dataTableId": { - "__rl": true, - "mode": "id", - "value": "bNkadTyDgDepYx2p" - }, - "filters": { - "conditions": [ - { - "keyName": "configKey", - "keyValue": "HEALTHCHECK_SITE_ID" - } - ] - }, - "returnAll": true - } - }, - { - "id": "http-target-userid-resolve", - "name": "対象者UserId解決", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 1520, - 500 - ], - "parameters": { - "method": "POST", - "url": "={{ $('PLEASANTER_BASE_URL取得').item.json.configValue }}api/users/get", - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ JSON.stringify({ ApiVersion: 1.1, ApiKey: $('PLEASANTER_API_KEY取得').item.json.configValue, View: { ApiGetMailAddresses: true } }) }}", - "options": {} - } - }, - { - "id": "code-identify-user", - "name": "対象者UserId特定", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 1740, - 500 - ], - "parameters": { - "jsCode": "function extractMailAddresses(user) {\n if (!user) return [];\n if (user.MailAddress) return [user.MailAddress];\n if (Array.isArray(user.MailAddresses)) return user.MailAddresses;\n return [];\n}\n\nconst targetEmail = $('署名検証・送信者解決').item.json.targetEmail;\nconst responseData = $('対象者UserId解決').item.json.Response.Data;\n// 実機未検証: /api/users/get をWhere無しで呼んだ場合のレスポンス形状(配列直下 / .Value配下 / 単一オブジェクト)を防御的に吸収する\nconst candidates = Array.isArray(responseData)\n ? responseData\n : (responseData && Array.isArray(responseData.Value) ? responseData.Value : [responseData]);\n\nconst matchedUser = candidates.find((u) => extractMailAddresses(u).includes(targetEmail));\nif (!matchedUser) {\n throw new Error(\"Pleasanterユーザーが見つかりません: \" + targetEmail);\n}\n\nreturn [{\n json: {\n ...$('署名検証・送信者解決').item.json,\n pleasanterUserId: matchedUser.UserId,\n },\n}];" - } - }, - { - "id": "http-target-record-search", - "name": "対象レコード検索", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 1960, - 500 - ], - "parameters": { - "method": "POST", - "url": "={{ $('PLEASANTER_BASE_URL取得').item.json.configValue }}api/items/{{ $('HEALTHCHECK_SITE_ID取得').item.json.configValue }}/get", - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ JSON.stringify({ ApiVersion: 1.1, ApiKey: $('PLEASANTER_API_KEY取得').item.json.configValue, View: { ColumnFilterHash: { ClassC: String($('対象者UserId特定').item.json.pleasanterUserId) }, ColumnFilterSearchTypes: { ClassC: 'ExactMatch' } } }) }}", - "options": {} - } - }, - { - "id": "code-pick-record", - "name": "対象レコード選定", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 2180, - 500 - ], - "parameters": { - "jsCode": "const responseData = $('対象レコード検索').item.json.Response.Data;\n// 実機未検証: SiteIdでの/get呼び出しレスポンスは.Value配下に配列が入る想定(公式マニュアルのサイト一覧系レスポンス形状)。単一オブジェクトの場合も防御的に吸収する\nconst records = Array.isArray(responseData)\n ? responseData\n : (responseData && Array.isArray(responseData.Value) ? responseData.Value : []);\n\nconst filtered = records.filter((r) => r.Status !== 900 && r.Status !== 910);\nconst sorted = filtered.slice().sort((a, b) => new Date(b.UpdatedTime) - new Date(a.UpdatedTime));\n\nif (sorted.length !== 1) {\n throw new Error(\"対象レコードが一意に定まりません(候補\" + sorted.length + \"件)。手動確認が必要です。\");\n}\n\nconst record = sorted[0];\n\nreturn [{\n json: {\n ...$('対象者UserId特定').item.json,\n resultId: record.ResultId,\n currentStatus: record.Status,\n },\n}];" - } - }, - { - "id": "dt-get-bot-state", + "id": "dt-get-state", "name": "待機状態取得", "type": "n8n-nodes-base.dataTable", "typeVersion": 1, "position": [ - 2400, - 500 + 860, + 400 ], "parameters": { "operation": "get", @@ -223,155 +72,149 @@ "conditions": [ { "keyName": "resultId", - "keyValue": "={{ String($('対象レコード選定').item.json.resultId) }}" + "keyValue": "={{ $json.resultId }}" } ] }, "returnAll": true - }, - "alwaysOutputData": true + } }, { - "id": "http-site-settings-get", - "name": "サイト設定取得", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, + "id": "code-decide-action", + "name": "分岐処理・アクション決定", + "type": "n8n-nodes-base.code", + "typeVersion": 2, "position": [ - 2620, - 500 + 1080, + 400 ], "parameters": { - "method": "POST", - "url": "={{ $('PLEASANTER_BASE_URL取得').item.json.configValue }}api/items/{{ $('HEALTHCHECK_SITE_ID取得').item.json.configValue }}/getsite", - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ JSON.stringify({ ApiVersion: 1.1, ApiKey: $('PLEASANTER_API_KEY取得').item.json.configValue }) }}", + "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 const classification = fullProcess ? classifyAwaitInput(fullProcess) : { awaitInput: \"none\", column: null };\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": "dt-get-lw_bot_client_id", - "name": "LW_BOT_CLIENT_ID取得", - "type": "n8n-nodes-base.dataTable", - "typeVersion": 1, + "id": "execute-hc-sub", + "name": "HC-SUB実行", + "type": "n8n-nodes-base.executeWorkflow", + "typeVersion": 1.2, "position": [ - 2840, - 500 + 1740, + 260 ], "parameters": { - "operation": "get", - "dataTableId": { + "workflowId": { "__rl": true, "mode": "id", - "value": "bNkadTyDgDepYx2p" + "value": "XRqcykbG2LuAjGG2" }, - "filters": { - "conditions": [ - { - "keyName": "configKey", - "keyValue": "LW_BOT_CLIENT_ID" - } - ] - }, - "returnAll": true + "workflowInputs": { + "mappingMode": "defineBelow", + "value": { + "resultId": "={{ $('分岐処理・アクション決定').item.json.resultId }}", + "processId": "={{ $('分岐処理・アクション決定').item.json.processId }}" + } + } } }, { - "id": "dt-get-lw_bot_client_secret", - "name": "LW_BOT_CLIENT_SECRET取得", - "type": "n8n-nodes-base.dataTable", - "typeVersion": 1, + "id": "if-action-send", + "name": "IF: action==send", + "type": "n8n-nodes-base.if", + "typeVersion": 2, "position": [ - 3060, - 500 + 1740, + 540 ], "parameters": { - "operation": "get", - "dataTableId": { - "__rl": true, - "mode": "id", - "value": "bNkadTyDgDepYx2p" - }, - "filters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, "conditions": [ { - "keyName": "configKey", - "keyValue": "LW_BOT_CLIENT_SECRET" + "id": "cond-send", + "leftValue": "={{ $('分岐処理・アクション決定').item.json.action === 'send' }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } } - ] + ], + "combinator": "and" }, - "returnAll": true - } - }, - { - "id": "dt-get-lw_bot_service_account", - "name": "LW_BOT_SERVICE_ACCOUNT取得", - "type": "n8n-nodes-base.dataTable", - "typeVersion": 1, - "position": [ - 3280, - 500 - ], - "parameters": { - "operation": "get", - "dataTableId": { - "__rl": true, - "mode": "id", - "value": "bNkadTyDgDepYx2p" - }, - "filters": { - "conditions": [ - { - "keyName": "configKey", - "keyValue": "LW_BOT_SERVICE_ACCOUNT" - } - ] - }, - "returnAll": true - } - }, - { - "id": "dt-get-lineworks_bot_master_site_id", - "name": "LINEWORKS_BOT_MASTER_SITE_ID取得", - "type": "n8n-nodes-base.dataTable", - "typeVersion": 1, - "position": [ - 3500, - 500 - ], - "parameters": { - "operation": "get", - "dataTableId": { - "__rl": true, - "mode": "id", - "value": "bNkadTyDgDepYx2p" - }, - "filters": { - "conditions": [ - { - "keyName": "configKey", - "keyValue": "LINEWORKS_BOT_MASTER_SITE_ID" - } - ] - }, - "returnAll": true - } - }, - { - "id": "http-bot-master-get", - "name": "Botマスタ取得", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 6140, - 170 - ], - "parameters": { - "method": "POST", - "url": "={{ $('PLEASANTER_BASE_URL取得').item.json.configValue }}api/items/{{ $('LINEWORKS_BOT_MASTER_SITE_ID取得').item.json.configValue }}/getsite", - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ JSON.stringify({ ApiVersion: 1.1, ApiKey: $('PLEASANTER_API_KEY取得').item.json.configValue }) }}", "options": {} } }, @@ -381,11 +224,11 @@ "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 6360, - 170 + 1960, + 460 ], "parameters": { - "jsCode": "const now = Math.floor(Date.now() / 1000);\nconst jwtClaims = JSON.stringify({\n iss: $('LW_BOT_CLIENT_ID取得').item.json.configValue,\n sub: $('LW_BOT_SERVICE_ACCOUNT取得').item.json.configValue,\n iat: now,\n exp: now + 3600,\n aud: 'https://auth.worksmobile.com/oauth2/v2.0/token',\n});\n\n// --- Bot ID解決 ---\n// TODO(Task 12): 484184(LINEWORKS_BOT_MASTER_SITE_ID)の実データ構造は未調査。Task 9(HC-SUB)と同じ暫定実装を踏襲する。\nconst botMasterSiteSettings = $('Botマスタ取得').item.json.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)から解決できませんでした。Task 12でBot選択ロジックを実装してください。');\n}\n\nconst targetEmail = $('署名検証・送信者解決').item.json.targetEmail;\nconst apiUrl = 'https://www.worksapis.com/v1.0/bots/' + botId + '/users/' + targetEmail + '/messages';\n\nreturn [{\n json: {\n jwtClaims,\n apiUrl,\n botId,\n targetEmail,\n },\n}];" + "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}];" } }, { @@ -394,8 +237,8 @@ "type": "n8n-nodes-base.jwt", "typeVersion": 1, "position": [ - 6580, - 170 + 2180, + 460 ], "parameters": { "operation": "sign", @@ -413,771 +256,26 @@ } }, { - "id": "http-access-token", - "name": "アクセストークン取得", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 6800, - 170 - ], - "parameters": { - "method": "POST", - "url": "https://auth.worksmobile.com/oauth2/v2.0/token", - "sendBody": true, - "contentType": "form-urlencoded", - "bodyParameters": { - "parameters": [ - { - "name": "assertion", - "value": "={{ $json.token }}" - }, - { - "name": "grant_type", - "value": "urn:ietf:params:oauth:grant-type:jwt-bearer" - }, - { - "name": "client_id", - "value": "={{ $('LW_BOT_CLIENT_ID取得').item.json.configValue }}" - }, - { - "name": "client_secret", - "value": "={{ $('LW_BOT_CLIENT_SECRET取得').item.json.configValue }}" - }, - { - "name": "scope", - "value": "bot" - } - ] - }, - "options": {} - } - }, - { - "id": "switch-await-input", - "name": "awaitInput分岐", - "type": "n8n-nodes-base.switch", - "typeVersion": 3, - "position": [ - 4600, - 500 - ], - "parameters": { - "rules": { - "values": [ - { - "conditions": { - "conditions": [ - { - "leftValue": "={{ $('待機状態取得').item.json.awaitInput }}", - "rightValue": "none", - "operator": { - "type": "string", - "operation": "equals" - } - } - ] - }, - "outputKey": "none" - }, - { - "conditions": { - "conditions": [ - { - "leftValue": "={{ $('待機状態取得').item.json.awaitInput }}", - "rightValue": "date", - "operator": { - "type": "string", - "operation": "equals" - } - } - ] - }, - "outputKey": "date" - }, - { - "conditions": { - "conditions": [ - { - "leftValue": "={{ $('待機状態取得').item.json.awaitInput }}", - "rightValue": "file", - "operator": { - "type": "string", - "operation": "equals" - } - } - ] - }, - "outputKey": "file" - } - ] - }, - "options": { - "fallbackOutput": "extra" - } - } - }, - { - "id": "code-match-text", - "name": "テキスト照合", + "id": "code-lineworks-send", + "name": "アクセストークン取得・LINEWORKS送信", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 4820, - 200 + 2400, + 460 ], "parameters": { - "jsCode": "const stateItem = $('待機状態取得').item.json;\nconst text = $('署名検証・送信者解決').item.json.text;\nlet pendingProcesses = [];\ntry {\n pendingProcesses = JSON.parse(stateItem.pendingProcesses || '[]');\n} catch (e) {\n pendingProcesses = [];\n}\n\nconst trimmed = String(text ?? '').trim();\n// Task 5 matchProcessByLabel相当(pendingProcessesの保存形状に合わせ p.label で照合)\nconst matched = pendingProcesses.find((p) => p.label === trimmed) || null;\n\nreturn [{\n json: {\n resultId: stateItem.resultId,\n matched,\n },\n}];" - } - }, - { - "id": "if-text-matched", - "name": "テキスト一致判定", - "type": "n8n-nodes-base.if", - "typeVersion": 2, - "position": [ - 5040, - 200 - ], - "parameters": { - "conditions": { - "options": { - "caseSensitive": true, - "leftValue": "", - "typeValidation": "loose", - "version": 2 - }, - "conditions": [ - { - "id": "cond-1", - "leftValue": "={{ !!$('テキスト照合').item.json.matched }}", - "rightValue": true, - "operator": { - "type": "boolean", - "operation": "true", - "singleValue": true - } - } - ], - "combinator": "and" - }, - "options": {} - } - }, - { - "id": "code-classify-await-input", - "name": "入力待ち種別判定", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 5260, - 100 - ], - "parameters": { - "jsCode": "function 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) {\n return { awaitInput: \"none\", column: null };\n }\n const column = columnNames[0];\n if (column.startsWith(\"Date\")) {\n return { awaitInput: \"date\", column };\n }\n if (column.startsWith(\"Attachments\")) {\n return { awaitInput: \"file\", column };\n }\n return { awaitInput: \"none\", column: null };\n}\n\nconst matched = $('テキスト照合').item.json.matched;\nconst resultId = $('テキスト照合').item.json.resultId;\n\n// 注意: HC-SUBの「選択肢組み立て」がData Tableに保存するpendingProcessesは\n// {processId, label, tooltip}のみでValidateInputsを持たないため、ここではサイト設定を\n// 再取得してProcessId一致でValidateInputsを引き直す(Task9側の保存形状を変更しない前提の対応)\nconst siteSettings = $('サイト設定取得').item.json.Response.Data.SiteSettings;\nconst processes = siteSettings.Processes || [];\nconst fullProcess = processes.find((p) => p.Id === matched.processId);\nif (!fullProcess) {\n throw new Error('ProcessId ' + matched.processId + ' がサイト設定内に見つかりません');\n}\n\nconst classification = classifyAwaitInput(fullProcess);\n\nreturn [{\n json: {\n resultId,\n processId: matched.processId,\n awaitInput: classification.awaitInput,\n awaitColumn: classification.column,\n },\n}];" - } - }, - { - "id": "if-immediate-run", - "name": "即時実行判定", - "type": "n8n-nodes-base.if", - "typeVersion": 2, - "position": [ - 5480, - 100 - ], - "parameters": { - "conditions": { - "options": { - "caseSensitive": true, - "leftValue": "", - "typeValidation": "loose", - "version": 2 - }, - "conditions": [ - { - "id": "cond-1", - "leftValue": "={{ $('入力待ち種別判定').item.json.awaitInput === 'none' }}", - "rightValue": true, - "operator": { - "type": "boolean", - "operation": "true", - "singleValue": true - } - } - ], - "combinator": "and" - }, - "options": {} - } - }, - { - "id": "exec-hc-sub-immediate", - "name": "Process実行(即時)", - "type": "n8n-nodes-base.executeWorkflow", - "typeVersion": 1.2, - "position": [ - 5700, - 30 - ], - "parameters": { - "workflowId": { - "__rl": true, - "mode": "id", - "value": "XRqcykbG2LuAjGG2" - }, - "workflowInputs": { - "mappingMode": "defineBelow", - "value": { - "resultId": "={{ $('入力待ち種別判定').item.json.resultId }}", - "processId": "={{ $('入力待ち種別判定').item.json.processId }}" - } - } - } - }, - { - "id": "dt-update-state-await", - "name": "待機状態保存(入力待ち)", - "type": "n8n-nodes-base.dataTable", - "typeVersion": 1, - "position": [ - 5700, - 170 - ], - "parameters": { - "operation": "update", - "dataTableId": { - "__rl": true, - "mode": "id", - "value": "jqMDa2YZTI4f0iQ7" - }, - "filters": { - "conditions": [ - { - "keyName": "resultId", - "keyValue": "={{ String($('入力待ち種別判定').item.json.resultId) }}" - } - ] - }, - "columns": { - "mappingMode": "defineBelow", - "value": { - "awaitInput": "={{ $('入力待ち種別判定').item.json.awaitInput }}", - "awaitProcessId": "={{ String($('入力待ち種別判定').item.json.processId) }}", - "awaitColumn": "={{ $('入力待ち種別判定').item.json.awaitColumn }}" - }, - "schema": [ - { - "id": "awaitInput", - "displayName": "awaitInput", - "required": false, - "type": "string", - "canBeUsedToMatch": true - }, - { - "id": "awaitProcessId", - "displayName": "awaitProcessId", - "required": false, - "type": "string", - "canBeUsedToMatch": true - }, - { - "id": "awaitColumn", - "displayName": "awaitColumn", - "required": false, - "type": "string", - "canBeUsedToMatch": true - } - ] - } - } - }, - { - "id": "code-prompt-message", - "name": "追加案内メッセージ組み立て", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 5920, - 170 - ], - "parameters": { - "jsCode": "const awaitInput = $('入力待ち種別判定').item.json.awaitInput;\nconst text = awaitInput === 'date'\n ? '日付を入力してください(例: 2026-03-01 や 令和6年3月1日)'\n : 'ファイルを送信してください';\n\nreturn [{ json: { messageContent: { type: 'text', text } } }];" - } - }, - { - "id": "http-prompt-send", - "name": "追加案内送信", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 7020, - 170 - ], - "parameters": { - "method": "POST", - "url": "={{ $('JWTクレーム組み立て').item.json.apiUrl }}", - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "Authorization", - "value": "=Bearer {{ $('アクセストークン取得').item.json.access_token }}" - }, - { - "name": "Content-Type", - "value": "application/json;charset=UTF-8" - } - ] - }, - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ JSON.stringify({ content: $('追加案内メッセージ組み立て').item.json.messageContent }) }}", - "options": {} - } - }, - { - "id": "exec-hc-sub-fallback", - "name": "Process実行(フォールバック)", - "type": "n8n-nodes-base.executeWorkflow", - "typeVersion": 1.2, - "position": [ - 5480, - 350 - ], - "parameters": { - "workflowId": { - "__rl": true, - "mode": "id", - "value": "XRqcykbG2LuAjGG2" - }, - "workflowInputs": { - "mappingMode": "defineBelow", - "value": { - "resultId": "={{ $('テキスト照合').item.json.resultId }}", - "processId": "" - } - } - } - }, - { - "id": "code-date-parse", - "name": "日付パース", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 4820, - 500 - ], - "parameters": { - "jsCode": "const ERA_INFO = {\n \"令和\": 2018,\n \"平成\": 1988,\n \"昭和\": 1925,\n \"大正\": 1911,\n};\n\nconst ERA_ALIASES = { R: \"令和\", H: \"平成\", S: \"昭和\", T: \"大正\" };\n\nfunction pad2(value) {\n return String(value).padStart(2, \"0\");\n}\n\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}\n\nfunction tryParseEraDate(compact) {\n const match = compact.match(/^(令和|平成|昭和|大正|R|H|S|T)(.+)$/i);\n if (!match) return null;\n\n let era = match[1];\n if (/^[RHST]$/i.test(era)) {\n era = ERA_ALIASES[era.toUpperCase()] || era;\n }\n if (!ERA_INFO[era]) return null;\n\n const normalized = match[2]\n .replace(/年/g, \"-\")\n .replace(/月/g, \"-\")\n .replace(/日/g, \"\")\n .replace(/[.\\/]/g, \"-\");\n const parts = normalized.split(\"-\").filter((part) => part.length > 0);\n if (parts.length < 3) {\n throw new Error(\"月と日まで入力してください(例: 令和6年3月1日)\");\n }\n\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\n return finalizeDateParts(ERA_INFO[era] + eraYear, month, day);\n}\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}\n\nfunction parseDateInput(value) {\n const trimmed = String(value ?? \"\").trim();\n if (!trimmed) {\n throw new Error(\"日付が空です\");\n }\n const compact = trimmed.replace(/\\s+/g, \"\");\n\n const eraResult = tryParseEraDate(compact);\n if (eraResult) return eraResult;\n\n const monthDayResult = tryParseMonthDay(compact);\n if (monthDayResult) return monthDayResult;\n\n const normalized = compact\n .replace(/年/g, \"-\")\n .replace(/月/g, \"-\")\n .replace(/日/g, \"\")\n .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\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 text = $('署名検証・送信者解決').item.json.text;\nconst stateItem = $('待機状態取得').item.json;\nlet result;\ntry {\n result = { success: true, parsedDate: parseDateInput(text) };\n} catch (err) {\n result = { success: false, errorMessage: err.message };\n}\n\nreturn [{\n json: {\n resultId: stateItem.resultId,\n awaitProcessId: stateItem.awaitProcessId,\n awaitColumn: stateItem.awaitColumn,\n ...result,\n },\n}];" - } - }, - { - "id": "if-date-parse-ok", - "name": "日付パース成否判定", - "type": "n8n-nodes-base.if", - "typeVersion": 2, - "position": [ - 5040, - 500 - ], - "parameters": { - "conditions": { - "options": { - "caseSensitive": true, - "leftValue": "", - "typeValidation": "loose", - "version": 2 - }, - "conditions": [ - { - "id": "cond-1", - "leftValue": "={{ $('日付パース').item.json.success }}", - "rightValue": true, - "operator": { - "type": "boolean", - "operation": "true", - "singleValue": true - } - } - ], - "combinator": "and" - }, - "options": {} - } - }, - { - "id": "http-date-column-update", - "name": "該当列update", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 5260, - 420 - ], - "parameters": { - "method": "POST", - "url": "={{ $('PLEASANTER_BASE_URL取得').item.json.configValue }}api/items/{{ $('日付パース').item.json.resultId }}/update", - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ JSON.stringify({ ApiVersion: 1.1, ApiKey: $('PLEASANTER_API_KEY取得').item.json.configValue, DateHash: { [$('日付パース').item.json.awaitColumn]: $('日付パース').item.json.parsedDate } }) }}", - "options": {} - } - }, - { - "id": "dt-update-state-reset-date", - "name": "待機状態リセット(日付)", - "type": "n8n-nodes-base.dataTable", - "typeVersion": 1, - "position": [ - 5480, - 420 - ], - "parameters": { - "operation": "update", - "dataTableId": { - "__rl": true, - "mode": "id", - "value": "jqMDa2YZTI4f0iQ7" - }, - "filters": { - "conditions": [ - { - "keyName": "resultId", - "keyValue": "={{ String($('日付パース').item.json.resultId) }}" - } - ] - }, - "columns": { - "mappingMode": "defineBelow", - "value": { - "awaitInput": "none", - "awaitProcessId": "", - "awaitColumn": "" - }, - "schema": [ - { - "id": "awaitInput", - "displayName": "awaitInput", - "required": false, - "type": "string", - "canBeUsedToMatch": true - }, - { - "id": "awaitProcessId", - "displayName": "awaitProcessId", - "required": false, - "type": "string", - "canBeUsedToMatch": true - }, - { - "id": "awaitColumn", - "displayName": "awaitColumn", - "required": false, - "type": "string", - "canBeUsedToMatch": true - } - ] - } - } - }, - { - "id": "exec-hc-sub-date-done", - "name": "Process実行(日付完了)", - "type": "n8n-nodes-base.executeWorkflow", - "typeVersion": 1.2, - "position": [ - 5700, - 420 - ], - "parameters": { - "workflowId": { - "__rl": true, - "mode": "id", - "value": "XRqcykbG2LuAjGG2" - }, - "workflowInputs": { - "mappingMode": "defineBelow", - "value": { - "resultId": "={{ $('日付パース').item.json.resultId }}", - "processId": "={{ $('日付パース').item.json.awaitProcessId }}" - } - } - } - }, - { - "id": "code-date-error-message", - "name": "日付エラーメッセージ組み立て", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 5260, - 600 - ], - "parameters": { - "jsCode": "const errorMessage = $('日付パース').item.json.errorMessage;\nreturn [{ json: { messageContent: { type: 'text', text: errorMessage } } }];" - } - }, - { - "id": "http-date-error-send", - "name": "日付エラー送信", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 6600, - 600 - ], - "parameters": { - "method": "POST", - "url": "={{ $('JWTクレーム組み立て(日付エラー)').item.json.apiUrl }}", - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "Authorization", - "value": "=Bearer {{ $('アクセストークン取得(日付エラー)').item.json.access_token }}" - }, - { - "name": "Content-Type", - "value": "application/json;charset=UTF-8" - } - ] - }, - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ JSON.stringify({ content: $('日付エラーメッセージ組み立て').item.json.messageContent }) }}", - "options": {} - } - }, - { - "id": "if-content-is-file", - "name": "ファイル判定", - "type": "n8n-nodes-base.if", - "typeVersion": 2, - "position": [ - 4820, - 800 - ], - "parameters": { - "conditions": { - "options": { - "caseSensitive": true, - "leftValue": "", - "typeValidation": "loose", - "version": 2 - }, - "conditions": [ - { - "id": "cond-1", - "leftValue": "={{ $('署名検証・送信者解決').item.json.contentType === 'file' }}", - "rightValue": true, - "operator": { - "type": "boolean", - "operation": "true", - "singleValue": true - } - } - ], - "combinator": "and" - }, - "options": {} - } - }, - { - "id": "http-lineworks-file-download", - "name": "LINEWORKSファイルダウンロード", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 5920, - 720 - ], - "parameters": { - "method": "GET", - "url": "={{ 'https://www.worksapis.com/v1.0/bots/' + $('JWTクレーム組み立て(ファイルDL)').item.json.botId + '/attachments/' + $('署名検証・送信者解決').item.json.fileId }}", - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "Authorization", - "value": "=Bearer {{ $('アクセストークン取得(ファイルDL)').item.json.access_token }}" - } - ] - }, - "options": { - "response": { - "response": { - "responseFormat": "file" - } - } - } - } - }, - { - "id": "http-pleasanter-attachment-upload", - "name": "Pleasanter添付アップロード", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 6140, - 720 - ], - "parameters": { - "method": "POST", - "url": "={{ $('PLEASANTER_BASE_URL取得').item.json.configValue }}api/binaries/upload", - "sendBody": true, - "contentType": "multipart-form-data", - "bodyParameters": { - "parameters": [ - { - "parameterType": "formData", - "name": "ApiKey", - "value": "={{ $('PLEASANTER_API_KEY取得').item.json.configValue }}" - }, - { - "parameterType": "formBinaryData", - "name": "file", - "inputDataFieldName": "data" - } - ] - }, - "options": {} - } - }, - { - "id": "dt-update-state-reset-file", - "name": "待機状態リセット(ファイル)", - "type": "n8n-nodes-base.dataTable", - "typeVersion": 1, - "position": [ - 6360, - 720 - ], - "parameters": { - "operation": "update", - "dataTableId": { - "__rl": true, - "mode": "id", - "value": "jqMDa2YZTI4f0iQ7" - }, - "filters": { - "conditions": [ - { - "keyName": "resultId", - "keyValue": "={{ String($('待機状態取得').item.json.resultId) }}" - } - ] - }, - "columns": { - "mappingMode": "defineBelow", - "value": { - "awaitInput": "none", - "awaitProcessId": "", - "awaitColumn": "" - }, - "schema": [ - { - "id": "awaitInput", - "displayName": "awaitInput", - "required": false, - "type": "string", - "canBeUsedToMatch": true - }, - { - "id": "awaitProcessId", - "displayName": "awaitProcessId", - "required": false, - "type": "string", - "canBeUsedToMatch": true - }, - { - "id": "awaitColumn", - "displayName": "awaitColumn", - "required": false, - "type": "string", - "canBeUsedToMatch": true - } - ] - } - } - }, - { - "id": "exec-hc-sub-file-done", - "name": "Process実行(ファイル完了)", - "type": "n8n-nodes-base.executeWorkflow", - "typeVersion": 1.2, - "position": [ - 6580, - 720 - ], - "parameters": { - "workflowId": { - "__rl": true, - "mode": "id", - "value": "XRqcykbG2LuAjGG2" - }, - "workflowInputs": { - "mappingMode": "defineBelow", - "value": { - "resultId": "={{ $('待機状態取得').item.json.resultId }}", - "processId": "={{ $('待機状態取得').item.json.awaitProcessId }}" - } - } - } - }, - { - "id": "code-file-missing-message", - "name": "ファイル未着メッセージ組み立て", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 5040, - 900 - ], - "parameters": { - "jsCode": "return [{ json: { messageContent: { type: 'text', text: 'ファイルを送信してください(テキストではなくファイルを添付して送信してください)' } } }];" - } - }, - { - "id": "http-file-missing-send", - "name": "ファイル未着送信", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 6380, - 950 - ], - "parameters": { - "method": "POST", - "url": "={{ $('JWTクレーム組み立て(ファイル未着)').item.json.apiUrl }}", - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "Authorization", - "value": "=Bearer {{ $('アクセストークン取得(ファイル未着)').item.json.access_token }}" - }, - { - "name": "Content-Type", - "value": "application/json;charset=UTF-8" - } - ] - }, - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ JSON.stringify({ content: $('ファイル未着メッセージ組み立て').item.json.messageContent }) }}", - "options": {} + "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", + "name": "Respond to Webhook(成功)", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.1, "position": [ - 8200, - 500 + 2620, + 320 ], "parameters": { "respondWith": "json", @@ -1186,354 +284,17 @@ } }, { - "id": "http-bot-master-get-dateerr", - "name": "Botマスタ取得(日付エラー)", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, + "id": "respond-error", + "name": "Respond to Webhook(異常系)", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, "position": [ - 5480, - 600 - ], - "parameters": { - "method": "POST", - "url": "={{ $('PLEASANTER_BASE_URL取得').item.json.configValue }}api/items/{{ $('LINEWORKS_BOT_MASTER_SITE_ID取得').item.json.configValue }}/getsite", - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ JSON.stringify({ ApiVersion: 1.1, ApiKey: $('PLEASANTER_API_KEY取得').item.json.configValue }) }}", - "options": {} - } - }, - { - "id": "code-jwt-claims-dateerr", - "name": "JWTクレーム組み立て(日付エラー)", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 5700, - 600 - ], - "parameters": { - "jsCode": "const now = Math.floor(Date.now() / 1000);\nconst jwtClaims = JSON.stringify({\n iss: $('LW_BOT_CLIENT_ID取得').item.json.configValue,\n sub: $('LW_BOT_SERVICE_ACCOUNT取得').item.json.configValue,\n iat: now,\n exp: now + 3600,\n aud: 'https://auth.worksmobile.com/oauth2/v2.0/token',\n});\n\n// --- Bot ID解決 ---\n// TODO(Task 12): 484184(LINEWORKS_BOT_MASTER_SITE_ID)の実データ構造は未調査。Task 9(HC-SUB)と同じ暫定実装を踏襲する。\nconst botMasterSiteSettings = $('Botマスタ取得(日付エラー)').item.json.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)から解決できませんでした。Task 12でBot選択ロジックを実装してください。');\n}\n\nconst targetEmail = $('署名検証・送信者解決').item.json.targetEmail;\nconst apiUrl = 'https://www.worksapis.com/v1.0/bots/' + botId + '/users/' + targetEmail + '/messages';\n\nreturn [{\n json: {\n jwtClaims,\n apiUrl,\n botId,\n targetEmail,\n },\n}];" - } - }, - { - "id": "jwt-sign-dateerr", - "name": "Sign JWT(日付エラー)", - "type": "n8n-nodes-base.jwt", - "typeVersion": 1, - "position": [ - 5920, - 600 - ], - "parameters": { - "operation": "sign", - "useJson": true, - "claimsJson": "={{ $json.jwtClaims }}", - "options": { - "algorithm": "RS256" - } - }, - "credentials": { - "jwtAuth": { - "id": "Hw0qlEaGfLPnQWp1", - "name": "LINEWORKS Bot Private Key (v4)" - } - } - }, - { - "id": "http-access-token-dateerr", - "name": "アクセストークン取得(日付エラー)", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 6140, - 600 - ], - "parameters": { - "method": "POST", - "url": "https://auth.worksmobile.com/oauth2/v2.0/token", - "sendBody": true, - "contentType": "form-urlencoded", - "bodyParameters": { - "parameters": [ - { - "name": "assertion", - "value": "={{ $json.token }}" - }, - { - "name": "grant_type", - "value": "urn:ietf:params:oauth:grant-type:jwt-bearer" - }, - { - "name": "client_id", - "value": "={{ $('LW_BOT_CLIENT_ID取得').item.json.configValue }}" - }, - { - "name": "client_secret", - "value": "={{ $('LW_BOT_CLIENT_SECRET取得').item.json.configValue }}" - }, - { - "name": "scope", - "value": "bot" - } - ] - }, - "options": {} - } - }, - { - "id": "http-bot-master-get-filemiss", - "name": "Botマスタ取得(ファイル未着)", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 5260, - 950 - ], - "parameters": { - "method": "POST", - "url": "={{ $('PLEASANTER_BASE_URL取得').item.json.configValue }}api/items/{{ $('LINEWORKS_BOT_MASTER_SITE_ID取得').item.json.configValue }}/getsite", - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ JSON.stringify({ ApiVersion: 1.1, ApiKey: $('PLEASANTER_API_KEY取得').item.json.configValue }) }}", - "options": {} - } - }, - { - "id": "code-jwt-claims-filemiss", - "name": "JWTクレーム組み立て(ファイル未着)", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 5480, - 950 - ], - "parameters": { - "jsCode": "const now = Math.floor(Date.now() / 1000);\nconst jwtClaims = JSON.stringify({\n iss: $('LW_BOT_CLIENT_ID取得').item.json.configValue,\n sub: $('LW_BOT_SERVICE_ACCOUNT取得').item.json.configValue,\n iat: now,\n exp: now + 3600,\n aud: 'https://auth.worksmobile.com/oauth2/v2.0/token',\n});\n\n// --- Bot ID解決 ---\n// TODO(Task 12): 484184(LINEWORKS_BOT_MASTER_SITE_ID)の実データ構造は未調査。Task 9(HC-SUB)と同じ暫定実装を踏襲する。\nconst botMasterSiteSettings = $('Botマスタ取得(ファイル未着)').item.json.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)から解決できませんでした。Task 12でBot選択ロジックを実装してください。');\n}\n\nconst targetEmail = $('署名検証・送信者解決').item.json.targetEmail;\nconst apiUrl = 'https://www.worksapis.com/v1.0/bots/' + botId + '/users/' + targetEmail + '/messages';\n\nreturn [{\n json: {\n jwtClaims,\n apiUrl,\n botId,\n targetEmail,\n },\n}];" - } - }, - { - "id": "jwt-sign-filemiss", - "name": "Sign JWT(ファイル未着)", - "type": "n8n-nodes-base.jwt", - "typeVersion": 1, - "position": [ - 5700, - 950 - ], - "parameters": { - "operation": "sign", - "useJson": true, - "claimsJson": "={{ $json.jwtClaims }}", - "options": { - "algorithm": "RS256" - } - }, - "credentials": { - "jwtAuth": { - "id": "Hw0qlEaGfLPnQWp1", - "name": "LINEWORKS Bot Private Key (v4)" - } - } - }, - { - "id": "http-access-token-filemiss", - "name": "アクセストークン取得(ファイル未着)", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 5920, - 950 - ], - "parameters": { - "method": "POST", - "url": "https://auth.worksmobile.com/oauth2/v2.0/token", - "sendBody": true, - "contentType": "form-urlencoded", - "bodyParameters": { - "parameters": [ - { - "name": "assertion", - "value": "={{ $json.token }}" - }, - { - "name": "grant_type", - "value": "urn:ietf:params:oauth:grant-type:jwt-bearer" - }, - { - "name": "client_id", - "value": "={{ $('LW_BOT_CLIENT_ID取得').item.json.configValue }}" - }, - { - "name": "client_secret", - "value": "={{ $('LW_BOT_CLIENT_SECRET取得').item.json.configValue }}" - }, - { - "name": "scope", - "value": "bot" - } - ] - }, - "options": {} - } - }, - { - "id": "http-bot-master-get-filedl", - "name": "Botマスタ取得(ファイルDL)", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 5040, - 650 - ], - "parameters": { - "method": "POST", - "url": "={{ $('PLEASANTER_BASE_URL取得').item.json.configValue }}api/items/{{ $('LINEWORKS_BOT_MASTER_SITE_ID取得').item.json.configValue }}/getsite", - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ JSON.stringify({ ApiVersion: 1.1, ApiKey: $('PLEASANTER_API_KEY取得').item.json.configValue }) }}", - "options": {} - } - }, - { - "id": "code-jwt-claims-filedl", - "name": "JWTクレーム組み立て(ファイルDL)", - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 5260, - 650 - ], - "parameters": { - "jsCode": "const now = Math.floor(Date.now() / 1000);\nconst jwtClaims = JSON.stringify({\n iss: $('LW_BOT_CLIENT_ID取得').item.json.configValue,\n sub: $('LW_BOT_SERVICE_ACCOUNT取得').item.json.configValue,\n iat: now,\n exp: now + 3600,\n aud: 'https://auth.worksmobile.com/oauth2/v2.0/token',\n});\n\n// --- Bot ID解決 ---\n// TODO(Task 12): 484184(LINEWORKS_BOT_MASTER_SITE_ID)の実データ構造は未調査。Task 9(HC-SUB)と同じ暫定実装を踏襲する。\nconst botMasterSiteSettings = $('Botマスタ取得(ファイルDL)').item.json.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)から解決できませんでした。Task 12でBot選択ロジックを実装してください。');\n}\n\nconst targetEmail = $('署名検証・送信者解決').item.json.targetEmail;\nconst apiUrl = 'https://www.worksapis.com/v1.0/bots/' + botId + '/users/' + targetEmail + '/messages';\n\nreturn [{\n json: {\n jwtClaims,\n apiUrl,\n botId,\n targetEmail,\n },\n}];" - } - }, - { - "id": "jwt-sign-filedl", - "name": "Sign JWT(ファイルDL)", - "type": "n8n-nodes-base.jwt", - "typeVersion": 1, - "position": [ - 5480, - 650 - ], - "parameters": { - "operation": "sign", - "useJson": true, - "claimsJson": "={{ $json.jwtClaims }}", - "options": { - "algorithm": "RS256" - } - }, - "credentials": { - "jwtAuth": { - "id": "Hw0qlEaGfLPnQWp1", - "name": "LINEWORKS Bot Private Key (v4)" - } - } - }, - { - "id": "http-access-token-filedl", - "name": "アクセストークン取得(ファイルDL)", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 5700, - 650 - ], - "parameters": { - "method": "POST", - "url": "https://auth.worksmobile.com/oauth2/v2.0/token", - "sendBody": true, - "contentType": "form-urlencoded", - "bodyParameters": { - "parameters": [ - { - "name": "assertion", - "value": "={{ $json.token }}" - }, - { - "name": "grant_type", - "value": "urn:ietf:params:oauth:grant-type:jwt-bearer" - }, - { - "name": "client_id", - "value": "={{ $('LW_BOT_CLIENT_ID取得').item.json.configValue }}" - }, - { - "name": "client_secret", - "value": "={{ $('LW_BOT_CLIENT_SECRET取得').item.json.configValue }}" - }, - { - "name": "scope", - "value": "bot" - } - ] - }, - "options": {} - } - }, - { - "id": "if-state-exists", - "name": "待機状態存在チェック", - "type": "n8n-nodes-base.if", - "typeVersion": 2, - "position": [ - 2400, + 1960, 680 ], - "parameters": { - "conditions": { - "options": { - "caseSensitive": true, - "leftValue": "", - "typeValidation": "loose", - "version": 2 - }, - "conditions": [ - { - "id": "cond-1", - "leftValue": "={{ !!($('待機状態取得').item.json.awaitInput) }}", - "rightValue": true, - "operator": { - "type": "boolean", - "operation": "true", - "singleValue": true - } - } - ], - "combinator": "and" - }, - "options": {} - } - }, - { - "id": "respond-error-no-state", - "name": "Respond to Webhook(待機状態エラー)", - "type": "n8n-nodes-base.respondToWebhook", - "typeVersion": 1.1, - "position": [ - 2620, - 800 - ], "parameters": { "respondWith": "json", - "responseBody": "={{ JSON.stringify({ result: \"error\", message: \"待機状態が見つかりません\" }) }}", - "options": {} - } - }, - { - "id": "respond-error-switch-fallback", - "name": "Respond to Webhook(分岐エラー)", - "type": "n8n-nodes-base.respondToWebhook", - "typeVersion": 1.1, - "position": [ - 4820, - 1150 - ], - "parameters": { - "respondWith": "json", - "responseBody": "={{ JSON.stringify({ result: \"error\", message: \"未対応のawaitInput値です: \" + ($('待機状態取得').item.json.awaitInput ?? '(undefined)') }) }}", + "responseBody": "={{ JSON.stringify({ result: \"error\", message: $('分岐処理・アクション決定').item.json.messageText || \"unknown error\" }) }}", "options": {} } } @@ -1543,102 +304,25 @@ "main": [ [ { - "node": "LINEWORKS_BOT_SECRET取得", + "node": "設定値一括取得", "type": "main", "index": 0 } ] ] }, - "LINEWORKS_BOT_SECRET取得": { + "設定値一括取得": { "main": [ [ { - "node": "署名検証・送信者解決", + "node": "署名検証・対象レコード特定", "type": "main", "index": 0 } ] ] }, - "署名検証・送信者解決": { - "main": [ - [ - { - "node": "PLEASANTER_BASE_URL取得", - "type": "main", - "index": 0 - } - ] - ] - }, - "PLEASANTER_BASE_URL取得": { - "main": [ - [ - { - "node": "PLEASANTER_API_KEY取得", - "type": "main", - "index": 0 - } - ] - ] - }, - "PLEASANTER_API_KEY取得": { - "main": [ - [ - { - "node": "HEALTHCHECK_SITE_ID取得", - "type": "main", - "index": 0 - } - ] - ] - }, - "HEALTHCHECK_SITE_ID取得": { - "main": [ - [ - { - "node": "対象者UserId解決", - "type": "main", - "index": 0 - } - ] - ] - }, - "対象者UserId解決": { - "main": [ - [ - { - "node": "対象者UserId特定", - "type": "main", - "index": 0 - } - ] - ] - }, - "対象者UserId特定": { - "main": [ - [ - { - "node": "対象レコード検索", - "type": "main", - "index": 0 - } - ] - ] - }, - "対象レコード検索": { - "main": [ - [ - { - "node": "対象レコード選定", - "type": "main", - "index": 0 - } - ] - ] - }, - "対象レコード選定": { + "署名検証・対象レコード特定": { "main": [ [ { @@ -1653,382 +337,65 @@ "main": [ [ { - "node": "待機状態存在チェック", + "node": "分岐処理・アクション決定", "type": "main", "index": 0 } ] ] }, - "サイト設定取得": { + "分岐処理・アクション決定": { "main": [ [ { - "node": "LW_BOT_CLIENT_ID取得", + "node": "状態更新", "type": "main", "index": 0 } ] ] }, - "LW_BOT_CLIENT_ID取得": { + "状態更新": { "main": [ [ { - "node": "LW_BOT_CLIENT_SECRET取得", + "node": "IF: action==execute", "type": "main", "index": 0 } ] ] }, - "LW_BOT_CLIENT_SECRET取得": { + "IF: action==execute": { "main": [ [ { - "node": "LW_BOT_SERVICE_ACCOUNT取得", - "type": "main", - "index": 0 - } - ] - ] - }, - "LW_BOT_SERVICE_ACCOUNT取得": { - "main": [ - [ - { - "node": "LINEWORKS_BOT_MASTER_SITE_ID取得", - "type": "main", - "index": 0 - } - ] - ] - }, - "LINEWORKS_BOT_MASTER_SITE_ID取得": { - "main": [ - [ - { - "node": "awaitInput分岐", - "type": "main", - "index": 0 - } - ] - ] - }, - "awaitInput分岐": { - "main": [ - [ - { - "node": "テキスト照合", + "node": "HC-SUB実行", "type": "main", "index": 0 } ], [ { - "node": "日付パース", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "ファイル判定", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "Respond to Webhook(分岐エラー)", + "node": "IF: action==send", "type": "main", "index": 0 } ] ] }, - "テキスト照合": { + "HC-SUB実行": { "main": [ [ { - "node": "テキスト一致判定", + "node": "Respond to Webhook(成功)", "type": "main", "index": 0 } ] ] }, - "テキスト一致判定": { - "main": [ - [ - { - "node": "入力待ち種別判定", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "Process実行(フォールバック)", - "type": "main", - "index": 0 - } - ] - ] - }, - "入力待ち種別判定": { - "main": [ - [ - { - "node": "即時実行判定", - "type": "main", - "index": 0 - } - ] - ] - }, - "即時実行判定": { - "main": [ - [ - { - "node": "Process実行(即時)", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "待機状態保存(入力待ち)", - "type": "main", - "index": 0 - } - ] - ] - }, - "待機状態保存(入力待ち)": { - "main": [ - [ - { - "node": "追加案内メッセージ組み立て", - "type": "main", - "index": 0 - } - ] - ] - }, - "追加案内メッセージ組み立て": { - "main": [ - [ - { - "node": "Botマスタ取得", - "type": "main", - "index": 0 - } - ] - ] - }, - "Process実行(即時)": { - "main": [ - [ - { - "node": "Respond to Webhook", - "type": "main", - "index": 0 - } - ] - ] - }, - "追加案内送信": { - "main": [ - [ - { - "node": "Respond to Webhook", - "type": "main", - "index": 0 - } - ] - ] - }, - "Process実行(フォールバック)": { - "main": [ - [ - { - "node": "Respond to Webhook", - "type": "main", - "index": 0 - } - ] - ] - }, - "日付パース": { - "main": [ - [ - { - "node": "日付パース成否判定", - "type": "main", - "index": 0 - } - ] - ] - }, - "日付パース成否判定": { - "main": [ - [ - { - "node": "該当列update", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "日付エラーメッセージ組み立て", - "type": "main", - "index": 0 - } - ] - ] - }, - "該当列update": { - "main": [ - [ - { - "node": "待機状態リセット(日付)", - "type": "main", - "index": 0 - } - ] - ] - }, - "待機状態リセット(日付)": { - "main": [ - [ - { - "node": "Process実行(日付完了)", - "type": "main", - "index": 0 - } - ] - ] - }, - "日付エラーメッセージ組み立て": { - "main": [ - [ - { - "node": "Botマスタ取得(日付エラー)", - "type": "main", - "index": 0 - } - ] - ] - }, - "Process実行(日付完了)": { - "main": [ - [ - { - "node": "Respond to Webhook", - "type": "main", - "index": 0 - } - ] - ] - }, - "日付エラー送信": { - "main": [ - [ - { - "node": "Respond to Webhook", - "type": "main", - "index": 0 - } - ] - ] - }, - "ファイル判定": { - "main": [ - [ - { - "node": "Botマスタ取得(ファイルDL)", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "ファイル未着メッセージ組み立て", - "type": "main", - "index": 0 - } - ] - ] - }, - "LINEWORKSファイルダウンロード": { - "main": [ - [ - { - "node": "Pleasanter添付アップロード", - "type": "main", - "index": 0 - } - ] - ] - }, - "Pleasanter添付アップロード": { - "main": [ - [ - { - "node": "待機状態リセット(ファイル)", - "type": "main", - "index": 0 - } - ] - ] - }, - "待機状態リセット(ファイル)": { - "main": [ - [ - { - "node": "Process実行(ファイル完了)", - "type": "main", - "index": 0 - } - ] - ] - }, - "ファイル未着メッセージ組み立て": { - "main": [ - [ - { - "node": "Botマスタ取得(ファイル未着)", - "type": "main", - "index": 0 - } - ] - ] - }, - "Process実行(ファイル完了)": { - "main": [ - [ - { - "node": "Respond to Webhook", - "type": "main", - "index": 0 - } - ] - ] - }, - "ファイル未着送信": { - "main": [ - [ - { - "node": "Respond to Webhook", - "type": "main", - "index": 0 - } - ] - ] - }, - "Botマスタ取得": { + "IF: action==send": { "main": [ [ { @@ -2036,6 +403,13 @@ "type": "main", "index": 0 } + ], + [ + { + "node": "Respond to Webhook(異常系)", + "type": "main", + "index": 0 + } ] ] }, @@ -2054,168 +428,18 @@ "main": [ [ { - "node": "アクセストークン取得", + "node": "アクセストークン取得・LINEWORKS送信", "type": "main", "index": 0 } ] ] }, - "アクセストークン取得": { + "アクセストークン取得・LINEWORKS送信": { "main": [ [ { - "node": "追加案内送信", - "type": "main", - "index": 0 - } - ] - ] - }, - "Botマスタ取得(日付エラー)": { - "main": [ - [ - { - "node": "JWTクレーム組み立て(日付エラー)", - "type": "main", - "index": 0 - } - ] - ] - }, - "JWTクレーム組み立て(日付エラー)": { - "main": [ - [ - { - "node": "Sign JWT(日付エラー)", - "type": "main", - "index": 0 - } - ] - ] - }, - "Sign JWT(日付エラー)": { - "main": [ - [ - { - "node": "アクセストークン取得(日付エラー)", - "type": "main", - "index": 0 - } - ] - ] - }, - "アクセストークン取得(日付エラー)": { - "main": [ - [ - { - "node": "日付エラー送信", - "type": "main", - "index": 0 - } - ] - ] - }, - "Botマスタ取得(ファイル未着)": { - "main": [ - [ - { - "node": "JWTクレーム組み立て(ファイル未着)", - "type": "main", - "index": 0 - } - ] - ] - }, - "JWTクレーム組み立て(ファイル未着)": { - "main": [ - [ - { - "node": "Sign JWT(ファイル未着)", - "type": "main", - "index": 0 - } - ] - ] - }, - "Sign JWT(ファイル未着)": { - "main": [ - [ - { - "node": "アクセストークン取得(ファイル未着)", - "type": "main", - "index": 0 - } - ] - ] - }, - "アクセストークン取得(ファイル未着)": { - "main": [ - [ - { - "node": "ファイル未着送信", - "type": "main", - "index": 0 - } - ] - ] - }, - "Botマスタ取得(ファイルDL)": { - "main": [ - [ - { - "node": "JWTクレーム組み立て(ファイルDL)", - "type": "main", - "index": 0 - } - ] - ] - }, - "JWTクレーム組み立て(ファイルDL)": { - "main": [ - [ - { - "node": "Sign JWT(ファイルDL)", - "type": "main", - "index": 0 - } - ] - ] - }, - "Sign JWT(ファイルDL)": { - "main": [ - [ - { - "node": "アクセストークン取得(ファイルDL)", - "type": "main", - "index": 0 - } - ] - ] - }, - "アクセストークン取得(ファイルDL)": { - "main": [ - [ - { - "node": "LINEWORKSファイルダウンロード", - "type": "main", - "index": 0 - } - ] - ] - }, - "待機状態存在チェック": { - "main": [ - [ - { - "node": "サイト設定取得", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "Respond to Webhook(待機状態エラー)", + "node": "Respond to Webhook(成功)", "type": "main", "index": 0 }