ken_nogi/NodeJS/express/modules/lineworksSurvey.js
Kenichiro NOGI 88a402ce0f up
2026-07-10 18:13:30 +09:00

1515 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { v4: uuidv4 } = require('uuid');
const { API_KEY, PLEASANTER, LINEWORKS_BOT_SECRET } = require('../config.js');
const { getAccessToken } = require('./lineworksAuth');
const USER_LOOKUP_SCOPES = ['user.read', 'directory.read'];
let SURVEY_RESULT_TABLE_ID = null;
const SESSION_TIMEOUT_MS = 5 * 60 * 1000;
const SESSION_SWEEP_INTERVAL_MS = 60 * 1000;
const SESSION_TIMEOUT_MESSAGE = '一定時間回答がなかったため、アンケートを終了します';
const SKIP_KEYWORD = 'スキップ';
const CANCEL_KEYWORDS = ['取消', '取消し', 'やめる', 'キャンセル', '終了', 'おわり', '終わり', 'ストップ', '停止'];
const CANCEL_HINT_TEXT = '※回答をやめる場合は「取消」「キャンセル」「終了」など中止キーワードを送信してください。';
const FIRST_RESPONSE_TIMEOUT_MS = 72 * 60 * 60 * 1000;
const FIRST_RESPONSE_TIMEOUT_MESSAGE = '72時間以内に回答がなかったため、アンケートを終了します。';
const SURVEY_NOT_STARTED_MESSAGE = '※アンケートが開始されていません';
const surveySessions = new Map();
const sessionIdByTarget = new Map();
const LOG_DIR = path.resolve(process.cwd(), 'logs');
const LOG_FILE_PATH = path.join(LOG_DIR, 'lineworksSurvey.log');
if (!fs.existsSync(LOG_DIR)) {
fs.mkdirSync(LOG_DIR, { recursive: true });
}
function toLogText(payload) {
if (typeof payload === 'undefined') {
return '';
}
try {
return ` ${JSON.stringify(payload)}`;
} catch {
return ' [payload stringify failed]';
}
}
function appendLog(level, message, payload = undefined) {
const line = `[lineworksSurvey] ${new Date().toISOString()} [${level}] ${message}${toLogText(payload)}\n`;
try {
fs.appendFileSync(LOG_FILE_PATH, line, 'utf8');
} catch (error) {
console.error('[lineworksSurvey] log file write failed', { message: error.message });
}
}
function logInfo(message, payload = undefined) {
appendLog('INFO', message, payload);
if (typeof payload === 'undefined') {
console.log(`[lineworksSurvey] ${new Date().toISOString()} ${message}`);
return;
}
console.log(`[lineworksSurvey] ${new Date().toISOString()} ${message}`, payload);
}
function logWarn(message, payload = undefined) {
appendLog('WARN', message, payload);
if (typeof payload === 'undefined') {
console.warn(`[lineworksSurvey] ${new Date().toISOString()} ${message}`);
return;
}
console.warn(`[lineworksSurvey] ${new Date().toISOString()} ${message}`, payload);
}
function logError(message, payload = undefined) {
appendLog('ERROR', message, payload);
if (typeof payload === 'undefined') {
console.error(`[lineworksSurvey] ${new Date().toISOString()} ${message}`);
return;
}
console.error(`[lineworksSurvey] ${new Date().toISOString()} ${message}`, payload);
}
setInterval(() => {
void sweepExpiredSessions();
}, SESSION_SWEEP_INTERVAL_MS).unref();
logInfo('session sweeper initialized', { intervalMs: SESSION_SWEEP_INTERVAL_MS, timeoutMs: SESSION_TIMEOUT_MS });
// アンケート開始・回答受付のAPIエンドポイントをExpressへ登録する。
module.exports = (app) => {
app.use(require('express').json({
verify: (req, _res, buf) => {
req.rawBody = Buffer.from(buf);
}
}));
logInfo('module initialized and json middleware attached');
app.post('/lineworksSurvey/webhook', async (req, res) => {
const body = req.body || {};
const apiKey = req.headers['x-api-key'];
logInfo('webhook request received', {
headers: req.headers,
body,
rawBody: req.rawBody ? req.rawBody.toString('utf8') : undefined
});
// 内部API(x-api-keyあり)以外は LINE WORKS署名検証
if (!apiKey) {
const ok = verifyWebhookSignature(req);
if (!ok) {
logWarn('webhook rejected: signature verification failed', {
botId: req.headers['x-works-botid'] || req.headers['x-works-botno'] || null,
sourceIp: req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || null,
hasRawBody: Boolean(req.rawBody),
contentLength: req.headers['content-length'] || null
});
return res.status(401).json({ error: '署名検証に失敗しました' });
}
}
try {
// 1) 内部API互換x-api-keyあり: start / answer を同一入口で処理
if (apiKey) {
if (apiKey !== API_KEY) {
logWarn('webhook rejected: invalid api key');
return res.status(401).json({ error: 'APIキーが不正です' });
}
// start判定
if (body.surveyId && body.targetId && body.botId && typeof body.answer === 'undefined') {
const surveyId = Number(body.surveyId);
if (!Number.isFinite(surveyId)) {
logWarn('webhook(start) rejected: invalid surveyId', { surveyId: body.surveyId });
return res.status(400).json({ error: 'surveyIdが不正です' });
}
let resultId = null;
if (typeof body.resultId !== 'undefined') {
resultId = Number(body.resultId);
if (!Number.isFinite(resultId)) {
logWarn('webhook(start) rejected: invalid resultId', { resultId: body.resultId });
return res.status(400).json({ error: 'resultIdが不正です' });
}
}
logInfo('webhook(start) received', {
targetId: body.targetId,
surveyId,
botId: body.botId
});
const survey = await getSurvey(surveyId);
const questions = normalizeQuestions(survey);
if (questions.length === 0) {
logWarn('webhook(start) rejected: no survey questions', { surveyId: body.surveyId });
return res.status(400).json({ error: 'アンケート項目が存在しません' });
}
logInfo('resolving lineworks user identity', {
identifier: body.targetId,
domainId: body.domainId
});
const userProfile = await fetchLineworksUserProfile(body.targetId, body.domainId);
if (!userProfile?.userId) {
logWarn('webhook(start) rejected: userId not found', { targetId: body.targetId, domainId: body.domainId });
return res.status(404).json({ error: 'LINE WORKSユーザーが見つかりません' });
}
logInfo('lineworks user resolved', {
identifier: body.targetId,
resolvedUserId: userProfile.userId,
domainId: body.domainId
});
if (!SURVEY_RESULT_TABLE_ID) {
SURVEY_RESULT_TABLE_ID = surveyId;
logInfo('survey result table id initialized', { surveyResultTableId: SURVEY_RESULT_TABLE_ID });
}
const sessionId = uuidv4();
const session = {
sessionId,
surveyId,
botId: body.botId,
targetId: userProfile.userId,
deliveryTargetId: body.targetId,
deliveryTargetType: body.targetType || 'user',
targetDisplayName: buildUserDisplayName(userProfile),
userName: userProfile.userName || null,
title: survey.title || `Survey-${body.surveyId}`,
questions,
currentIndex: 0,
answers: [],
expiresAt: Date.now() + SESSION_TIMEOUT_MS,
targetType: 'user',
cancelHintSent: false,
startedAt: Date.now(),
firstAnswerReceived: false,
resultId
};
surveySessions.set(sessionId, session);
registerSessionIndex(session);
await sendSurvey(sessionId);
return res.json({
result: 'ok',
sessionId,
message: 'アンケートを開始しました'
});
}
// answer判定
if (typeof body.answer !== 'undefined') {
logInfo('webhook(answer) received', {
sessionId: body.sessionId,
targetId: body.targetId,
botId: body.botId
});
const resolvedSessionId = resolveSessionId({
sessionId: body.sessionId,
targetId: body.targetId
});
if (!resolvedSessionId) {
return res.status(404).json({ error: '有効なアンケートセッションが見つかりません' });
}
const result = await getAnswer(resolvedSessionId, body.answer);
return res.json(result);
}
return res.status(400).json({ error: 'startまたはanswerに必要なパラメータが不足しています' });
}
// 2) LINE WORKS webhookx-api-keyなし
const { targetId, botId, answer } = parseLineworksWebhook(body, req.headers);
logInfo('webhook(lineworks) received', {
targetId,
botId,
hasAnswer: typeof answer !== 'undefined'
});
if (!targetId || typeof answer === 'undefined') {
logWarn('webhook rejected: targetId or answer missing');
return res.status(400).json({ error: 'targetIdとanswerの解決に失敗しました' });
}
const resolvedSessionId = resolveSessionId({ targetId });
if (!resolvedSessionId) {
logWarn('webhook: active session not found', { targetId, botId });
await safeSendLineworksMessage(
botId,
targetId,
SURVEY_NOT_STARTED_MESSAGE,
'user'
);
return res.status(200).json({
result: 'ok',
status: 'no_active_session',
message: SURVEY_NOT_STARTED_MESSAGE
});
}
const result = await getAnswer(resolvedSessionId, answer);
logInfo('webhook answer processed', {
resolvedSessionId,
status: result?.status,
nextQuestion: result?.nextQuestion
});
return res.status(200).json(result);
} catch (error) {
const status = error.statusCode || 500;
logError('webhook processing failed', { status, message: error.message });
return res.status(status).json({ error: error.message });
}
});
};
// セッション検索を高速化するため、targetId単位のインデックスを作成する。
function registerSessionIndex(session) {
if (!session?.targetId) {
return;
}
sessionIdByTarget.set(session.targetId, session.sessionId);
}
// セッション削除時にインデックスを掃除する。
function unregisterSessionIndex(sessionId, session) {
if (!session?.targetId) {
return;
}
if (sessionIdByTarget.get(session.targetId) === sessionId) {
sessionIdByTarget.delete(session.targetId);
}
}
// セッションIDの直接指定、またはtargetIdから有効なセッションを解決する。
function resolveSessionId({ sessionId, targetId }) {
if (sessionId) {
if (surveySessions.has(sessionId)) {
return sessionId;
}
logWarn('resolveSessionId: sessionId not found', { sessionId });
return null;
}
if (!targetId) {
return null;
}
const indexedSessionId = sessionIdByTarget.get(targetId);
if (!indexedSessionId) {
return null;
}
if (surveySessions.has(indexedSessionId)) {
return indexedSessionId;
}
sessionIdByTarget.delete(targetId);
return null;
}
// Pleasanterからアンケート定義データを取得し、タイトルと設問情報を返す。
async function getSurvey(surveyId) {
logInfo('fetching survey definition', { surveyId });
const endpoint = `${PLEASANTER.HOST}/api/items/${Number(surveyId)}/getsite`;
const json = {
'ApiVersion': 1.1,
'ApiKey': PLEASANTER.API_KEY,
};
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(json)
});
const resData = await res.json();
if (String(resData?.StatusCode) !== '200') {
logError('failed to fetch survey definition', { surveyId, statusCode: resData?.StatusCode, response: resData });
throw new Error(`Pleasanterサイト取得失敗: ${JSON.stringify(resData)}`);
}
const responseBody = resData?.Response || resData?.Repponse;
const data = responseBody?.Data || {};
const siteSettings = data?.SiteSettings || {};
const columns = siteSettings?.Columns || siteSettings?.Column || [];
return {
title: data?.Title || '',
questions: columns
};
}
async function fetchLineworksUserProfile(identifier, domainId) {
if (!identifier) {
throw new Error('LINE WORKSユーザー識別子が指定されていません');
}
const accessToken = await getAccessToken(USER_LOOKUP_SCOPES);
let apiUrl = `https://www.worksapis.com/v1.0/users/${encodeURIComponent(identifier)}`;
if (domainId) {
apiUrl += `?domainId=${encodeURIComponent(domainId)}`;
}
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`
}
});
const text = await response.text();
let data;
try {
data = JSON.parse(text);
} catch (error) {
logError('failed to parse lineworks user response', { apiUrl, text });
throw new Error('LINE WORKSユーザー情報の解析に失敗しました');
}
if (!response.ok) {
logError('lineworks user lookup failed', { apiUrl, response: data });
throw new Error('LINE WORKSユーザー照会に失敗しました');
}
return data;
}
function formatUserName(userName = {}) {
if (!userName) {
return '';
}
const lastName = userName?.lastName || '';
const firstName = userName?.firstName || '';
return `${lastName} ${firstName}`.trim();
}
function buildUserDisplayName(userProfile = {}) {
const fullName = formatUserName(userProfile?.userName);
if (fullName) {
return fullName;
}
if (userProfile.email) {
return userProfile.email;
}
return userProfile.userId || '';
}
function parseLineworksWebhook(body, headers = {}) {
const source = body?.source || {};
const content = body?.content || {};
const botId = headers['x-works-botid']
|| headers['x-works-botno']
|| headers['x-works-bot-id']
|| headers['x-works-bot-no']
|| body?.botId
|| body?.botNo;
return {
targetId: source.userId,
botId,
answer: content.text
};
}
const ERA_INFO = {
'令和': 2018,
'平成': 1988,
'昭和': 1925,
'大正': 1911
};
const ERA_ALIASES = {
R: '令和',
H: '平成',
S: '昭和',
T: '大正'
};
function normalizeQuestionTypeValue(type) {
if (!type) {
return '';
}
const raw = String(type).trim();
switch (raw) {
case '選択式':
return 'select';
case '短文テキスト':
return 'class_text';
case '長文テキスト':
return 'description';
case '数値':
return 'number';
case '日付':
return 'date';
default:
break;
}
const lower = raw.toLowerCase();
switch (lower) {
case 'select':
case 'button':
return 'select';
case 'class_text':
case 'text':
return 'class_text';
case 'description':
case 'long_text':
return 'description';
case 'number':
return 'number';
case 'date':
return 'date';
default:
return lower;
}
}
function isSelectQuestion(question) {
return normalizeQuestionTypeValue(question?.type) === 'select';
}
function isNumberQuestion(question) {
return normalizeQuestionTypeValue(question?.type) === 'number';
}
function isDateQuestion(question) {
return normalizeQuestionTypeValue(question?.type) === 'date';
}
// 現在の設問をLINE WORKSに送信する。
async function sendSurvey(sessionId) {
logInfo('sending survey question', { sessionId });
const session = surveySessions.get(sessionId);
if (!session) {
const error = new Error('セッションが見つかりません');
error.statusCode = 404;
throw error;
}
if (isSessionExpired(session)) {
await expireSessionWithNotice(sessionId, session);
const error = new Error('セッションの有効期限が切れました');
error.statusCode = 410;
throw error;
}
const question = session.questions[session.currentIndex];
if (!question) {
logWarn('sendSurvey skipped: question not found', { sessionId, currentIndex: session.currentIndex });
return;
}
if (!session.cancelHintSent) {
await sendLineworksMessage(
session.botId,
session.deliveryTargetId,
CANCEL_HINT_TEXT,
session.deliveryTargetType || 'user'
);
session.cancelHintSent = true;
logInfo('cancel hint sent', { sessionId });
}
const prefix = session.currentIndex === 0 ? `アンケート開始: ${session.title}\n\n` : '';
const baseQuestionText = `${prefix}Q${session.currentIndex + 1}. ${question.text}`;
const questionText = question.required ? `${baseQuestionText}\n※必須` : baseQuestionText;
if (isSelectQuestion(question) && question.options.length > 0) {
const maxOptions = question.required ? 10 : 9;
const actions = question.options.slice(0, maxOptions).map((option) => ({
type: 'message',
label: String(option),
text: String(option)
}));
if (!question.required) {
actions.push({
type: 'message',
label: SKIP_KEYWORD,
text: SKIP_KEYWORD
});
}
const content = {
type: 'button_template',
contentText: questionText,
actions
};
const sendResult = await sendLineworksMessage(
session.botId,
session.deliveryTargetId,
content,
session.deliveryTargetType || 'user'
);
if (session.currentIndex === 0) {
logInfo('initial survey question response logged', {
sessionId,
questionIndex: 1,
response: sendResult
});
}
logInfo('select question sent', { sessionId, questionIndex: session.currentIndex + 1, optionCount: actions.length });
return;
}
const messageText = `${questionText}\n回答形式: ${question.type}`;
const sendResult = await sendLineworksMessage(
session.botId,
session.deliveryTargetId,
messageText,
session.deliveryTargetType || 'user'
);
if (session.currentIndex === 0) {
logInfo('initial survey question response logged', {
sessionId,
questionIndex: 1,
response: sendResult
});
}
logInfo('text question sent', { sessionId, questionIndex: session.currentIndex + 1, type: question.type });
if (isDateQuestion(question)) {
await sendDateShortcutButtons(session, questionText, !question.required);
} else if (!question.required) {
await sendSkipButton(session);
}
}
async function sendDateShortcutButtons(session, questionText, allowSkip = false) {
const today = new Date();
const formatDate = (offset) => {
const date = new Date(today);
date.setDate(date.getDate() + offset);
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
};
const actions = [
{ label: `本日 (${formatDate(0)})`, text: formatDate(0) },
{ label: `昨日 (${formatDate(-1)})`, text: formatDate(-1) },
{ label: `明日 (${formatDate(1)})`, text: formatDate(1) }
];
if (allowSkip) {
actions.push({ label: SKIP_KEYWORD, text: SKIP_KEYWORD });
}
const content = {
type: 'button_template',
contentText: `${questionText}\n入力しづらい場合は以下のボタンを利用できます。${allowSkip ? '\n※スキップ可能' : ''}`,
actions: actions.map((action) => ({
type: 'message',
label: action.label,
text: action.text
}))
};
await sendLineworksMessage(
session.botId,
session.deliveryTargetId,
content,
session.deliveryTargetType || 'user'
);
logInfo('date shortcut buttons sent', {
sessionId: session.sessionId,
questionIndex: session.currentIndex + 1,
actions: actions.map((action) => action.label)
});
}
async function sendSkipButton(session) {
const content = {
type: 'button_template',
contentText: '※スキップ可能',
actions: [
{
type: 'message',
label: SKIP_KEYWORD,
text: SKIP_KEYWORD
}
]
};
await sendLineworksMessage(
session.botId,
session.deliveryTargetId,
content,
session.deliveryTargetType || 'user'
);
logInfo('optional question skip button sent', {
sessionId: session.sessionId,
questionIndex: session.currentIndex + 1
});
}
// 受信した回答を検証して保持し、次設問送信または最終保存処理を実行する。
async function getAnswer(sessionId, answerText) {
logInfo('processing answer', { sessionId });
const session = surveySessions.get(sessionId);
if (!session) {
const error = new Error('セッションが見つかりません');
error.statusCode = 404;
throw error;
}
if (isSessionExpired(session)) {
await expireSessionWithNotice(sessionId, session);
const error = new Error('セッションの有効期限が切れました。再度アンケートを開始してください');
error.statusCode = 410;
throw error;
}
const question = session.questions[session.currentIndex];
if (!question) {
const error = new Error('回答対象の設問が見つかりません');
error.statusCode = 400;
throw error;
}
const rawInput = typeof answerText === 'string' ? answerText : String(answerText ?? '');
const trimmedInput = rawInput.trim();
const skipRequested = trimmedInput === SKIP_KEYWORD;
const cancelRequested = CANCEL_KEYWORDS.includes(trimmedInput);
if (cancelRequested) {
await cancelSurveySession(sessionId, session, trimmedInput);
return {
result: 'cancelled',
status: 'cancelled',
message: 'アンケートを取り消しました'
};
}
if (skipRequested && question.required) {
const message = 'この設問は必須です。回答を入力してください。';
logWarn('skip requested on required question', { sessionId, questionIndex: session.currentIndex + 1 });
await handleInvalidAnswer(sessionId, session, message);
return {
result: 'retry',
status: 'invalid_answer',
message
};
}
const shouldSkip = !question.required && (skipRequested || trimmedInput.length === 0);
if (!shouldSkip) {
let normalizedAnswer;
try {
normalizedAnswer = validateAnswer(question, rawInput);
} catch (error) {
logWarn('answer validation failed', { sessionId, questionIndex: session.currentIndex + 1, message: error.message });
await handleInvalidAnswer(sessionId, session, error.message);
return {
result: 'retry',
status: 'invalid_answer',
message: error.message
};
}
logInfo('answer validated', { sessionId, questionIndex: session.currentIndex + 1, type: question.type });
session.answers.push({
index: session.currentIndex + 1,
question: question.text,
type: question.type,
columnName: question.columnName,
answer: normalizedAnswer
});
session.firstAnswerReceived = session.firstAnswerReceived || session.answers.length > 0;
} else {
logInfo('optional question skipped by user', { sessionId, questionIndex: session.currentIndex + 1 });
}
session.currentIndex += 1;
if (session.currentIndex < session.questions.length) {
session.expiresAt = Date.now() + SESSION_TIMEOUT_MS;
logInfo('moving to next question', { sessionId, nextQuestion: session.currentIndex + 1 });
await sendSurvey(sessionId);
return {
result: 'ok',
status: 'in_progress',
nextQuestion: session.currentIndex + 1
};
}
let saveError = null;
let statusUpdateError = null;
try {
await sendAnswer(session);
} catch (error) {
saveError = error;
}
if (!saveError && session.resultId) {
try {
await updatePleasanterStatus(session.resultId, 900);
} catch (error) {
statusUpdateError = error;
}
}
const finalError = saveError || statusUpdateError;
await notifySurveyCompletion(session, finalError);
surveySessions.delete(sessionId);
unregisterSessionIndex(sessionId, session);
logInfo('survey completed and session removed', { sessionId, answerCount: session.answers.length });
if (finalError) {
finalError.statusCode = finalError.statusCode || 500;
throw finalError;
}
return {
result: 'ok',
status: 'completed',
message: '回答を保存しました'
};
}
// 回答完了後の全回答をPleasanterへ1レコードとして登録する。
async function sendAnswer(session) {
logInfo('sending answers to pleasanter', { sessionId: session.sessionId, answerCount: session.answers.length });
const targetSurveyId = Number(session?.surveyId ?? SURVEY_RESULT_TABLE_ID);
if (!Number.isFinite(targetSurveyId)) {
logError('sendAnswer aborted: surveyId unavailable', { sessionId: session.sessionId });
throw new Error('アンケートIDが未設定のため回答登録に失敗しました');
}
const endpoint = `${PLEASANTER.HOST}/api/items/${targetSurveyId}/create`;
const hashData = buildAnswerHashData(session.answers);
const ownerValue = formatUserName(session.userName) || session.targetDisplayName || session.targetId || '';
const bodyMessage = [];
bodyMessage.push(`回答者: ${ownerValue}`);
bodyMessage.push(`LINEWORKS ID: ${session.deliveryTargetId} (${session.targetId})`);
bodyMessage.push(`回答日時: ${new Date().toLocaleString()}`);
const json = {
ApiVersion: 1.1,
ApiKey: PLEASANTER.API_KEY,
Body: bodyMessage.join('\n'),
...hashData
};
//if (ownerValue) {
// json.Owner = ownerValue;
//}
const requestPayload = JSON.stringify(json);
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: requestPayload
});
const responseText = await response.text();
let responseJson;
try {
responseJson = JSON.parse(responseText);
} catch (error) {
logError('failed to parse pleasanter response', {
sessionId: session.sessionId,
responseText,
requestPayload
});
throw new Error('Pleasanterのレスポンス解析に失敗しました');
}
const statusCode = Number(responseJson?.StatusCode);
if (statusCode !== 200) {
logError('failed to send answers to pleasanter', {
sessionId: session.sessionId,
statusCode,
message: responseJson?.Message,
response: responseJson,
requestPayload
});
throw new Error(`Pleasanter回答登録失敗: ${responseJson?.Message || responseText}`);
}
logInfo('answers saved to pleasanter', {
sessionId: session.sessionId,
statusCode,
message: responseJson?.Message
});
}
// PleasanterレコードのStatus項目を更新する。
async function updatePleasanterStatus(resultId, status) {
const numericResultId = Number(resultId);
if (!Number.isFinite(numericResultId)) {
const error = new Error('resultIdが不正です');
error.statusCode = 400;
throw error;
}
const endpoint = `${PLEASANTER.HOST}/api/items/${numericResultId}/update`;
const payload = JSON.stringify({
ApiVersion: 1.1,
ApiKey: PLEASANTER.API_KEY,
Status: status
});
logInfo('updating pleasanter status', { resultId: numericResultId, status });
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload
});
const responseText = await response.text();
const responseJson = parseJsonIfPossible(responseText);
const statusCode = Number(responseJson?.StatusCode);
if (!response.ok || statusCode !== 200) {
logError('pleasanter status update failed', {
resultId: numericResultId,
status,
httpStatus: response.status,
statusCode,
response: responseJson
});
throw new Error('Pleasanterステータス更新に失敗しました');
}
logInfo('pleasanter status updated', {
resultId: numericResultId,
status,
statusCode
});
}
// 列名プレフィックスに応じて回答をClass/Num/Date/Descriptionの各Hashへ振り分ける。
function buildAnswerHashData(answers) {
const classHash = {};
const numHash = {};
const dateHash = {};
const descriptionHash = {};
for (const item of answers) {
const columnName = String(item?.columnName || '');
if (!columnName) {
continue;
}
if (columnName.startsWith('Class')) {
classHash[columnName] = item.answer;
continue;
}
if (columnName.startsWith('Num')) {
numHash[columnName] = item.answer;
continue;
}
if (columnName.startsWith('Date')) {
dateHash[columnName] = item.answer;
continue;
}
if (columnName.startsWith('Description')) {
descriptionHash[columnName] = item.answer;
}
}
return {
ClassHash: classHash,
NumHash: numHash,
DateHash: dateHash,
DescriptionHash: descriptionHash
};
}
function parseExtendedControlCssTokens(value) {
if (typeof value === 'undefined' || value === null) {
return [];
}
if (Array.isArray(value)) {
return value
.flatMap((item) => parseExtendedControlCssTokens(item))
.filter((item) => item);
}
return String(value)
.split(/[\s\u3000,]+/u)
.map((item) => item.trim())
.filter((item) => item);
}
function isSurveyColumn(item) {
if (!item || typeof item !== 'object') {
return false;
}
const extendedControlCss = item.ExtendedControlCss ?? item.extendedControlCss;
if (!extendedControlCss) {
return false;
}
const tokens = parseExtendedControlCssTokens(extendedControlCss).map((token) => token.toLowerCase());
return tokens.includes('survey');
}
// 取得したアンケート定義を内部で扱う設問配列形式へ正規化する。
function normalizeQuestions(survey) {
const source = Array.isArray(survey.questions) ? survey.questions : parseJsonIfNeeded(survey.questions);
if (!Array.isArray(source)) {
return [];
}
const filteredColumns = source.filter(isSurveyColumn);
if (filteredColumns.length !== source.length) {
logInfo('survey columns filtered by ExtendedControlCss', {
original: source.length,
filtered: filteredColumns.length
});
}
return filteredColumns
.map((item, idx) => {
const columnName = String(item.ColumnName || item.columnName || '');
const label = item.LabelText || item.label || item.text || item.question || `質問${idx + 1}`;
const choicesText = item.ChoicesText || item.choicesText || '';
const hasChoices = String(choicesText).trim().length > 0;
const required = toBooleanFlag(item?.ValidateRequired ?? item?.validateRequired);
if (columnName.startsWith('Class')) {
if (hasChoices) {
return {
id: idx + 1,
columnName,
text: label,
type: '選択式',
options: splitChoices(choicesText),
required
};
}
return {
id: idx + 1,
columnName,
text: label,
type: '短文テキスト',
options: [],
required
};
}
if (columnName.startsWith('Num')) {
return {
id: idx + 1,
columnName,
text: label,
type: '数値',
options: [],
required
};
}
if (columnName.startsWith('Date')) {
return {
id: idx + 1,
columnName,
text: label,
type: '日付',
options: [],
required
};
}
if (columnName.startsWith('Description')) {
return {
id: idx + 1,
columnName,
text: label,
type: '長文テキスト',
options: [],
required
};
}
return null;
})
.filter((question) => !!question);
}
// 設問タイプに応じて回答値を検証し、保存可能な形式へ変換する。
function validateAnswer(question, answerText) {
const rawValue = String(answerText ?? '');
const value = rawValue.trim();
if (!value) {
const error = new Error('回答が空です');
error.statusCode = 400;
throw error;
}
const normalizedType = normalizeQuestionTypeValue(question?.type);
if (normalizedType === 'number') {
const normalized = toHalfWidthNumber(value);
if (!/^[-+]?\d+(\.\d+)?$/.test(normalized)) {
const error = new Error('数値入力の設問です。数値で回答してください(例: 123 または -45.6');
error.statusCode = 400;
throw error;
}
return Number(normalized);
}
if (normalizedType === 'date') {
try {
return parseDateInput(value);
} catch (error) {
const err = new Error(error.message || '日付入力の設問です。正しい形式で回答してください');
err.statusCode = 400;
throw err;
}
}
if (normalizedType === 'select') {
if (!Array.isArray(question.options) || question.options.length === 0) {
return value;
}
const exists = question.options.some((option) => String(option) === value);
if (!exists) {
const error = new Error(`選択肢から選んでください: ${question.options.join(', ')}`);
error.statusCode = 400;
throw error;
}
return value;
}
if (normalizedType === 'class_text') {
return value.replace(/[\r\n]+/g, ' ');
}
return rawValue;
}
// JSON文字列または配列を安全に配列へ変換する。
function parseJsonIfNeeded(value) {
if (Array.isArray(value)) {
return value;
}
if (typeof value !== 'string') {
return [];
}
try {
return JSON.parse(value);
} catch {
return [];
}
}
function parseJsonIfPossible(text) {
if (typeof text !== 'string' || text.length === 0) {
return null;
}
try {
return JSON.parse(text);
} catch {
return text;
}
}
// 改行区切りの選択肢文字列を配列に変換する。
function splitChoices(choicesText) {
return String(choicesText)
.split(/\r?\n/)
.map((option) => option.trim())
.filter((option) => option.length > 0)
.slice(0, 10);
}
function toBooleanFlag(value) {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value === 1;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
return normalized === 'true' || normalized === '1' || normalized === 'yes';
}
return false;
}
// 全角数字を半角数字へ変換し、数値判定しやすい形式に整える。
function toHalfWidthNumber(value) {
return String(value)
.replace(/[-]/g, (s) => String.fromCharCode(s.charCodeAt(0) - 0xFEE0))
.replace(//g, ',')
.replace(//g, '.')
.replace(//g, '-')
.replace(//g, '+')
.replace(/,/g, '');
}
function parseDateInput(value) {
const trimmed = value.trim();
if (!trimmed) {
throw new Error('日付が空です');
}
const compact = trimmed.replace(/\s+/g, '');
try {
const eraResult = tryParseEraDate(compact);
if (eraResult) {
return eraResult;
}
} catch (error) {
throw error;
}
const monthDayResult = tryParseMonthDay(compact);
if (monthDayResult) {
return monthDayResult;
}
const normalized = compact
.replace(/年/g, '-')
.replace(/月/g, '-')
.replace(/日/g, '')
.replace(/[\.\/]/g, '-');
const isoParts = normalized.split('-').filter((part) => part.length > 0);
if (isoParts.length === 3 && isoParts[0].length >= 4) {
return finalizeDateParts(Number(isoParts[0]), Number(isoParts[1]), Number(isoParts[2]));
}
const parsed = new Date(trimmed);
if (Number.isNaN(parsed.getTime())) {
throw new Error('日付形式で回答してください(例: 2026-03-01 や 令和6年3月1日');
}
return finalizeDateParts(parsed.getFullYear(), parsed.getMonth() + 1, parsed.getDate());
}
function tryParseEraDate(compact) {
const match = compact.match(/^(令和|平成|昭和|大正|R|H|S|T)(.+)$/i);
if (!match) {
return null;
}
let era = match[1];
if (/^[RHST]$/i.test(era)) {
era = ERA_ALIASES[era.toUpperCase()] || era;
}
if (!ERA_INFO[era]) {
return null;
}
const rest = match[2];
const normalized = rest
.replace(/年/g, '-')
.replace(/月/g, '-')
.replace(/日/g, '')
.replace(/[\.\/]/g, '-');
const parts = normalized.split('-').filter((part) => part.length > 0);
if (parts.length < 3) {
throw new Error('月と日まで入力してください(例: 令和6年3月1日');
}
const eraYear = Number(parts[0]);
const month = Number(parts[1]);
const day = Number(parts[2]);
if (!Number.isFinite(eraYear) || !Number.isFinite(month) || !Number.isFinite(day)) {
throw new Error('日付形式で回答してください(例: 令和6年3月1日');
}
const year = ERA_INFO[era] + eraYear - 1;
return finalizeDateParts(year, month, day);
}
function tryParseMonthDay(compact) {
const match = compact.match(/^(\d{1,2})(?:月|\/|\-|\.)(\d{1,2})(?:日)?$/);
if (!match) {
return null;
}
const currentYear = new Date().getFullYear();
const month = Number(match[1]);
const day = Number(match[2]);
return finalizeDateParts(currentYear, month, day);
}
function finalizeDateParts(year, month, day) {
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
throw new Error('日付形式で回答してください(例: 2026-03-01');
}
const date = new Date(year, month - 1, day);
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
throw new Error('存在しない日付です');
}
return `${year}-${pad2(month)}-${pad2(day)}`;
}
function pad2(value) {
return String(value).padStart(2, '0');
}
// LINE WORKS Bot APIを使ってユーザーまたはトークルームへメッセージを送信する。
async function sendLineworksMessage(botId, targetId, messageText, targetType = 'user') {
logInfo('sending message to lineworks', {
botId,
targetId,
targetType,
contentType: typeof messageText === 'string' ? 'text' : messageText?.type
});
const accessToken = await getAccessToken();
if (!accessToken) {
logError('sendLineworksMessage failed: access token unavailable', { botId, targetId });
throw new Error('アクセストークンの取得に失敗しました');
}
const encodedTarget = encodeURIComponent(targetId);
let apiUrl;
switch (targetType) {
case 'channel':
apiUrl = `https://www.worksapis.com/v1.0/bots/${botId}/channels/${encodedTarget}/messages`;
break;
case 'room':
apiUrl = `https://www.worksapis.com/v1.0/bots/${botId}/rooms/${encodedTarget}/messages`;
break;
default:
apiUrl = `https://www.worksapis.com/v1.0/bots/${botId}/users/${encodedTarget}/messages`;
break;
}
const content = typeof messageText === 'string'
? {
type: 'text',
text: messageText
}
: messageText;
const body = { content };
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json;charset=UTF-8',
Authorization: `Bearer ${accessToken}`
},
body: JSON.stringify(body)
});
const responseText = await response.text();
const responsePayload = parseJsonIfPossible(responseText);
if (!response.ok) {
logError('lineworks send failed', {
botId,
targetId,
targetType,
apiUrl,
status: response.status,
response: responsePayload
});
const message = typeof responsePayload === 'string'
? responsePayload
: responsePayload === null
? `status=${response.status}`
: JSON.stringify(responsePayload);
throw new Error(`LINE WORKS送信失敗: ${message}`);
}
const logPayload = {
botId,
targetId,
targetType,
apiUrl,
status: response.status,
response: responsePayload
};
logInfo('lineworks message sent', logPayload);
return logPayload;
}
// セッションが期限切れかどうかを判定する。
function isSessionExpired(session, now = Date.now()) {
return !session?.expiresAt || now > session.expiresAt;
}
// 期限切れセッションを走査して終了通知後に削除する。
async function sweepExpiredSessions() {
const now = Date.now();
logInfo('sweeping expired sessions', { activeSessions: surveySessions.size });
for (const [sessionId, session] of surveySessions.entries()) {
const startedAt = Number(session?.startedAt) || null;
const waitingTooLong = !session?.firstAnswerReceived
&& Number.isFinite(startedAt)
&& now - startedAt >= FIRST_RESPONSE_TIMEOUT_MS;
if (waitingTooLong) {
logWarn('session expired due to no response within 72h', { sessionId });
await expireSessionWithNotice(sessionId, session, FIRST_RESPONSE_TIMEOUT_MESSAGE);
continue;
}
if (!isSessionExpired(session, now)) {
continue;
}
logWarn('expired session detected', { sessionId });
await expireSessionWithNotice(sessionId, session);
}
}
// 期限切れセッションに終了通知を送信してからセッションを削除する。
async function expireSessionWithNotice(sessionId, session, message = SESSION_TIMEOUT_MESSAGE) {
const currentSession = session || surveySessions.get(sessionId);
if (!currentSession) {
return;
}
try {
logInfo('sending session timeout notice', { sessionId, targetId: currentSession.targetId });
await sendLineworksMessage(
currentSession.botId,
currentSession.deliveryTargetId,
message,
currentSession.deliveryTargetType || 'user'
);
} catch (error) {
logError(`期限切れ通知送信失敗: ${sessionId}`, { message: error.message });
} finally {
surveySessions.delete(sessionId);
unregisterSessionIndex(sessionId, currentSession);
logInfo('expired session removed', { sessionId });
}
}
function normalizeSignature(value) {
const raw = String(value || '').trim();
return raw.replace(/^sha256=/i, '');
}
function safeEqual(a, b) {
const ab = Buffer.from(String(a), 'utf8');
const bb = Buffer.from(String(b), 'utf8');
if (ab.length !== bb.length) return false;
return crypto.timingSafeEqual(ab, bb);
}
function verifyWebhookSignature(req) {
if (!LINEWORKS_BOT_SECRET) {
logWarn('LINEWORKS_BOT_SECRET is empty');
return false;
}
const headerSig = normalizeSignature(req.headers['x-works-signature']);
if (!headerSig) {
logWarn('webhook rejected: x-works-signature missing');
return false;
}
// 生ボディ優先(なければフォールバック)
const payload = req.rawBody
? req.rawBody
: Buffer.from(JSON.stringify(req.body || {}), 'utf8');
const expected = crypto
.createHmac('sha256', LINEWORKS_BOT_SECRET)
.update(payload)
.digest('base64');
const matched = safeEqual(headerSig, expected);
if (!matched) {
logWarn('webhook signature mismatch', {
headerSignaturePreview: `${headerSig.slice(0, 8)}...(${headerSig.length})`,
expectedSignaturePreview: `${expected.slice(0, 8)}...(${expected.length})`,
payloadByteLength: payload.length,
hasRawBody: Boolean(req.rawBody),
botId: req.headers['x-works-botid'] || req.headers['x-works-botno'] || null
});
}
return matched;
}
async function safeSendLineworksMessage(botId, targetId, messageText, targetType = 'user') {
if (!botId || !targetId) {
return;
}
try {
await sendLineworksMessage(botId, targetId, messageText, targetType);
} catch (error) {
logError('lineworks notification failed', { botId, targetId, message: error.message });
}
}
async function handleInvalidAnswer(sessionId, session, errorMessage) {
const notice = `入力内容に不備があります。\n理由: ${errorMessage}\n同じ設問にもう一度お答えください。`;
await safeSendLineworksMessage(
session.botId,
session.deliveryTargetId,
notice,
session.deliveryTargetType || 'user'
);
session.expiresAt = Date.now() + SESSION_TIMEOUT_MS;
await sendSurvey(sessionId);
}
async function notifySurveyCompletion(session, saveError) {
const message = saveError
? 'アンケートは完了しましたが、内部処理でエラーが発生しました。担当者により確認を進めます。'
: 'アンケート回答を受け付けました。ありがとうございました。';
await safeSendLineworksMessage(
session.botId,
session.deliveryTargetId,
message,
session.deliveryTargetType || 'user'
);
}
async function cancelSurveySession(sessionId, session, cancelInput = '') {
const currentSession = session || surveySessions.get(sessionId);
if (!currentSession) {
return;
}
const trimmed = String(cancelInput || '').trim();
const notice = trimmed
? `${trimmed}」を受け取り、アンケート回答を中止しました。`
: 'アンケート回答を中止しました。';
if (currentSession.resultId) {
try {
await updatePleasanterStatus(currentSession.resultId, 910);
} catch (error) {
logError('failed to update pleasanter status on cancel', {
sessionId: currentSession.sessionId,
resultId: currentSession.resultId,
message: error.message
});
}
}
await safeSendLineworksMessage(
currentSession.botId,
currentSession.deliveryTargetId,
notice,
currentSession.deliveryTargetType || 'user'
);
surveySessions.delete(currentSession.sessionId);
unregisterSessionIndex(currentSession.sessionId, currentSession);
logInfo('survey cancelled by user', {
sessionId: currentSession.sessionId,
cancelInput: trimmed || undefined
});
}