URLSearchParamsを.toString()した上でjson:trueを併用すると Content-Typeがapplication/jsonに上書きされうる不整合があり、 LINEWORKSアクセストークン取得が失敗する恐れがあったため、 生のURLSearchParamsをbodyに渡しjson:trueと手動Content-Typeを除去。 レスポンスはtypeofで文字列/オブジェクトを判定しJSON.parseする形に変更。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
262 lines
13 KiB
JSON
262 lines
13 KiB
JSON
{
|
||
"name": "HC-SUB: プロセス実行と案内送信",
|
||
"nodes": [
|
||
{
|
||
"id": "trigger-1",
|
||
"name": "Execute Workflow Trigger",
|
||
"type": "n8n-nodes-base.executeWorkflowTrigger",
|
||
"typeVersion": 1.1,
|
||
"position": [
|
||
220,
|
||
300
|
||
],
|
||
"parameters": {
|
||
"workflowInputs": {
|
||
"values": [
|
||
{
|
||
"name": "resultId"
|
||
},
|
||
{
|
||
"name": "processId"
|
||
}
|
||
]
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"id": "dt-get-config",
|
||
"name": "設定値一括取得",
|
||
"type": "n8n-nodes-base.dataTable",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
440,
|
||
300
|
||
],
|
||
"parameters": {
|
||
"operation": "get",
|
||
"dataTableId": {
|
||
"__rl": true,
|
||
"mode": "id",
|
||
"value": "bNkadTyDgDepYx2p"
|
||
},
|
||
"returnAll": true
|
||
}
|
||
},
|
||
{
|
||
"id": "code-pleasanter-query",
|
||
"name": "Pleasanter照会・選択肢組み立て",
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
660,
|
||
300
|
||
],
|
||
"parameters": {
|
||
"jsCode": "function isUnsetSentinel(value) {\n return typeof value === \"string\" && value.startsWith(\"1899\");\n}\nfunction fillTemplate(template, columns, valueHash) {\n const labelToColumnName = new Map();\n for (const column of columns) {\n if (column.LabelText) labelToColumnName.set(column.LabelText, column.ColumnName);\n }\n return template.replace(/\\{([^{}]+)\\}/g, (matched, label) => {\n const columnName = labelToColumnName.get(label);\n if (!columnName) return matched;\n const value = valueHash[columnName];\n if (value === undefined || value === null || value === \"\" || isUnsetSentinel(value)) return \"未設定\";\n return String(value);\n });\n}\nfunction extractProcessesForStatus(processes, status) {\n return processes.filter((p) => p.CurrentStatus === status || p.CurrentStatus === -1);\n}\n\nconst configRows = $('設定値一括取得').all().map((item) => item.json);\nconst config = Object.fromEntries(configRows.map((row) => [row.configKey, row.configValue]));\n\nconst trigger = $('Execute Workflow Trigger').item.json;\nconst baseUrl = config.PLEASANTER_BASE_URL_PROD;\nconst apiKey = config.PLEASANTER_API_KEY_PROD;\n\nasync function pleasanterPost(path, body) {\n const res = await this.helpers.httpRequest({\n method: \"POST\",\n url: `${baseUrl}${path}`,\n body: { ApiVersion: 1.1, ApiKey: apiKey, ...body },\n json: true,\n });\n return res;\n}\n\nif (trigger.processId) {\n await pleasanterPost.call(this, `api/items/${trigger.resultId}/update`, { ProcessId: trigger.processId });\n}\n\nconst recordRes = await pleasanterPost.call(this, `api/items/${trigger.resultId}/get`, {});\nconst record = recordRes.Response.Data;\n\nconst siteRes = await pleasanterPost.call(this, `api/items/${config.HEALTHCHECK_SITE_ID}/getsite`, {});\nconst siteSettings = siteRes.Response.Data.SiteSettings;\nconst columns = siteSettings.Columns || [];\nconst processes = siteSettings.Processes || [];\n\nconst botMasterRes = await pleasanterPost.call(this, `api/items/${config.LINEWORKS_BOT_MASTER_SITE_ID}/getsite`, {});\n// TODO(Task 12): 484184(LINEWORKS_BOT_MASTER_SITE_ID)の実データ構造を見てBotIdの解決方法を確定させる。\n// 暫定: SiteSettings.BotIds配列 または Processes[].BotId のいずれかを想定し、\n// 生データのまま後段(JWTクレーム組み立て)へ渡して解決する。\nconst botMasterSiteSettings = botMasterRes.Response.Data.SiteSettings;\n\nconst valueHash = {\n ...record.ClassHash, ...record.NumHash, ...record.DateHash, ...record.DescriptionHash,\n};\n\nconst candidates = extractProcessesForStatus(processes, record.Status);\nconst options = candidates.map((p) => ({\n processId: p.Id,\n label: p.DisplayName || p.Name,\n tooltip: fillTemplate(p.ToolTip || \"\", columns, valueHash),\n}));\n\nconst userRes = await pleasanterPost.call(this, `api/users/get`, {\n View: { ApiGetMailAddresses: true },\n Where: { UserId: record.ClassHash.ClassC },\n});\n// Pleasanter /api/users/get のレスポンス形状は実機未検証のため、配列/単一\n// オブジェクトどちらでも動くように防御的に処理する。\nconst usersResponseData = userRes.Response.Data;\nconst userRecord = Array.isArray(usersResponseData) ? usersResponseData[0] : usersResponseData;\nconst targetEmail = userRecord && (userRecord.MailAddress\n || (Array.isArray(userRecord.MailAddresses) && userRecord.MailAddresses[0]));\nif (!targetEmail) {\n throw new Error('対象者のメールアドレスを解決できませんでした。');\n}\n\nreturn [{\n json: {\n resultId: record.ResultId,\n currentStatus: record.Status,\n options,\n targetEmail,\n config,\n botMasterSiteSettings,\n },\n}];"
|
||
}
|
||
},
|
||
{
|
||
"id": "code-jwt-claims",
|
||
"name": "JWTクレーム組み立て",
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
880,
|
||
300
|
||
],
|
||
"parameters": {
|
||
"jsCode": "const now = Math.floor(Date.now() / 1000);\nconst { config, botMasterSiteSettings, options, resultId, currentStatus, targetEmail } = $json;\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\n// --- Bot ID解決 ---\n// TODO(Task 12): 484184(LINEWORKS_BOT_MASTER_SITE_ID)の実データ構造は未調査\n// (このワークフローは実データ確認前に構築している)。\n// Task 12で508971のProcess⇔Bot対応表を設計する際、実データに合わせて\n// この解決方法を書き換えるか、Execute Workflow Triggerの入力にbotIdを追加して\n// 呼び出し元(WP/WA)から明示的に渡す方式へ変更すること。\n// 暫定実装: getsiteのSiteSettings直下にBotIds配列がある、または\n// Processes[].BotIdが定義されているケースを想定し、最初の1件を採用する。\n// どちらの構造も無ければ、誤ったBotへの送信を避けるため明示的にエラーとする。\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 apiUrl = `https://www.worksapis.com/v1.0/bots/${botId}/users/${targetEmail}/messages`;\n\n// --- button_template組み立て ---\nconst actions = options.map((opt) => ({\n type: 'message',\n label: opt.label,\n postback: JSON.stringify({ resultId, processId: opt.processId }),\n displayText: opt.label,\n}));\n\nconst messageContent = {\n type: 'button_template',\n contentText: options.map((opt) => `${opt.label}: ${opt.tooltip}`).join('\\n') || '対応可能な操作がありません',\n actions,\n};\n\nreturn [{\n json: {\n jwtClaims,\n apiUrl,\n messageContent,\n targetEmail,\n resultId,\n currentStatus,\n options,\n config,\n },\n}];"
|
||
}
|
||
},
|
||
{
|
||
"id": "jwt-sign",
|
||
"name": "Sign JWT",
|
||
"type": "n8n-nodes-base.jwt",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
1100,
|
||
300
|
||
],
|
||
"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": [
|
||
1320,
|
||
300
|
||
],
|
||
"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()しない、ヘッダーも手動指定しない)。\n// jsonオプションは指定しないため、レスポンスは自前でJSON.parseする。\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 [{\n json: {\n resultId: claims.resultId,\n currentStatus: claims.currentStatus,\n options: claims.options,\n targetEmail: claims.targetEmail,\n lineworksResponse: sendRes,\n },\n}];"
|
||
}
|
||
},
|
||
{
|
||
"id": "dt-state-insert",
|
||
"name": "状態更新",
|
||
"type": "n8n-nodes-base.dataTable",
|
||
"typeVersion": 1,
|
||
"position": [
|
||
1540,
|
||
300
|
||
],
|
||
"parameters": {
|
||
"operation": "insert",
|
||
"dataTableId": {
|
||
"__rl": true,
|
||
"mode": "id",
|
||
"value": "jqMDa2YZTI4f0iQ7"
|
||
},
|
||
"columns": {
|
||
"mappingMode": "defineBelow",
|
||
"value": {
|
||
"resultId": "={{ $json.resultId }}",
|
||
"targetEmail": "={{ $json.targetEmail }}",
|
||
"currentStatus": "={{ String($json.currentStatus) }}",
|
||
"pendingProcesses": "={{ JSON.stringify($json.options) }}",
|
||
"awaitInput": "none",
|
||
"awaitProcessId": "",
|
||
"awaitColumn": ""
|
||
},
|
||
"schema": [
|
||
{
|
||
"id": "resultId",
|
||
"displayName": "resultId",
|
||
"required": false,
|
||
"type": "string",
|
||
"canBeUsedToMatch": true
|
||
},
|
||
{
|
||
"id": "targetEmail",
|
||
"displayName": "targetEmail",
|
||
"required": false,
|
||
"type": "string",
|
||
"canBeUsedToMatch": true
|
||
},
|
||
{
|
||
"id": "currentStatus",
|
||
"displayName": "currentStatus",
|
||
"required": false,
|
||
"type": "string",
|
||
"canBeUsedToMatch": true
|
||
},
|
||
{
|
||
"id": "pendingProcesses",
|
||
"displayName": "pendingProcesses",
|
||
"required": false,
|
||
"type": "string",
|
||
"canBeUsedToMatch": true
|
||
},
|
||
{
|
||
"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
|
||
}
|
||
]
|
||
}
|
||
}
|
||
}
|
||
],
|
||
"connections": {
|
||
"Execute Workflow Trigger": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "設定値一括取得",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"設定値一括取得": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "Pleasanter照会・選択肢組み立て",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"Pleasanter照会・選択肢組み立て": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "JWTクレーム組み立て",
|
||
"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": "状態更新",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
}
|
||
},
|
||
"settings": {
|
||
"executionOrder": "v1"
|
||
}
|
||
}
|