427 lines
15 KiB
JavaScript
427 lines
15 KiB
JavaScript
// LINEWORKS と Pleasanter をつなぐ中継サーバー
|
||
const express = require('express');
|
||
const cors = require('cors');
|
||
const axios = require('axios');
|
||
|
||
// Express アプリケーション本体
|
||
const app = express();
|
||
|
||
// CORS 許可(フロントエンドやLINEWORKSからのアクセスを想定)
|
||
app.use(cors());
|
||
// JSON ボディの自動パース
|
||
app.use(express.json());
|
||
|
||
// サーバーの待受ポート
|
||
const PORT = process.env.PORT || 3000;
|
||
// Pleasanter のベースURLとAPIトークン(環境変数から取得)
|
||
const PLEASANTER_BASE_URL = process.env.PLEASANTER_BASE_URL || 'https://your-pleasanter.example.com';
|
||
const PLEASANTER_API_TOKEN = process.env.PLEASANTER_API_TOKEN || '';
|
||
|
||
// LINE WORKS ボット連携用設定(環境変数から取得)
|
||
const LINEWORKS_ACCESS_TOKEN = process.env.LINEWORKS_ACCESS_TOKEN || '';
|
||
const LINEWORKS_BOT_ID = process.env.LINEWORKS_BOT_ID || '';
|
||
const LINEWORKS_API_BASE = 'https://www.worksapis.com/v1.0';
|
||
|
||
// このボットで紐付けるアンケート(Pleasanter側のサイトID)
|
||
// 例: LINEWORKS_SURVEY_ID=123
|
||
const LINEWORKS_SURVEY_ID = process.env.LINEWORKS_SURVEY_ID || null;
|
||
// LINE WORKS からの回答保存時の重複キー処理
|
||
// reject: 二重回答を拒否 / overwrite: 上書き
|
||
const LINEWORKS_RESPONSE_MODE = process.env.LINEWORKS_RESPONSE_MODE || 'reject';
|
||
|
||
// ユーザーごとの対話状態を保持(サンプルなのでメモリ管理)
|
||
// 本番では Redis やDBなどの永続ストアを推奨
|
||
const userStates = new Map();
|
||
|
||
// Pleasanter にアクセスするためのAxiosクライアント生成関数
|
||
function pleasanterClient() {
|
||
if (!PLEASANTER_API_TOKEN) {
|
||
// APIトークンが設定されていない場合は起動時に気づけるよう例外を投げる
|
||
throw new Error('PLEASANTER_API_TOKEN が設定されていません。');
|
||
}
|
||
|
||
const client = axios.create({
|
||
baseURL: PLEASANTER_BASE_URL,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-Authorization': PLEASANTER_API_TOKEN,
|
||
},
|
||
});
|
||
|
||
return client;
|
||
}
|
||
|
||
// アンケート定義(質問内容・順番など)をPleasanterから取得する
|
||
async function fetchSurveyDefinition(surveyId) {
|
||
const client = pleasanterClient();
|
||
|
||
const response = await client.post('/api/items/' + surveyId + '/search', {
|
||
// Pleasanter上でアンケート定義を保持しているサイト/ビューに合わせて調整してください
|
||
// ここでは surveyId をサイトIDとみなし、そのサイトに1件だけ存在する定義レコードを取得するイメージです
|
||
View: {
|
||
ViewId: 0,
|
||
UsePaging: false,
|
||
},
|
||
});
|
||
|
||
// Rows 配列から定義レコードを取得
|
||
const items = response.data && response.data.Rows ? response.data.Rows : [];
|
||
if (!items.length) {
|
||
// 該当する定義がない場合は null を返す
|
||
return null;
|
||
}
|
||
|
||
const definitionRow = items[0];
|
||
|
||
// フロントエンドやLINEWORKS側で扱いやすい形に整形して返却
|
||
|
||
return {
|
||
surveyId,
|
||
title: definitionRow.Title || 'アンケート',
|
||
description: definitionRow.Note || '',
|
||
// 質問内容は Pleasanter 側のカラム設計に合わせてマッピングする想定
|
||
questions: definitionRow.Questions || [],
|
||
};
|
||
}
|
||
|
||
// key(回答者や対象レコードを一意に識別するキー)で既存レコードを検索する
|
||
async function findExistingRecordByKey(surveyId, key) {
|
||
const client = pleasanterClient();
|
||
|
||
const response = await client.post('/api/items/' + surveyId + '/search', {
|
||
View: {
|
||
ViewId: 0,
|
||
UsePaging: false,
|
||
Conditions: [
|
||
{
|
||
// Pleasanter 側の「キー」用カラム名に合わせること
|
||
ColumnName: 'Key',
|
||
// Operator 0: 完全一致など、実際の定義に合わせて変更可
|
||
Operator: 0,
|
||
Value: key,
|
||
},
|
||
],
|
||
},
|
||
});
|
||
|
||
const items = response.data && response.data.Rows ? response.data.Rows : [];
|
||
if (!items.length) {
|
||
// 該当レコードがなければ null
|
||
return null;
|
||
}
|
||
|
||
// キーに一致した最初のレコードを返却
|
||
return items[0];
|
||
}
|
||
|
||
// Pleasanter に新規レコード(アンケート回答)を登録
|
||
async function createRecord(surveyId, payload) {
|
||
const client = pleasanterClient();
|
||
|
||
const response = await client.post('/api/items/' + surveyId + '/add', {
|
||
Row: payload,
|
||
});
|
||
|
||
return response.data;
|
||
}
|
||
|
||
// Pleasanter の既存レコード(アンケート回答)を更新(上書き)
|
||
async function updateRecord(surveyId, recordId, payload) {
|
||
const client = pleasanterClient();
|
||
|
||
const response = await client.post('/api/items/' + surveyId + '/edit', {
|
||
Row: {
|
||
Id: recordId,
|
||
...payload,
|
||
},
|
||
});
|
||
|
||
return response.data;
|
||
}
|
||
|
||
// ===== LINE WORKS アンケート用ボット連携(sample.js ベース) =====
|
||
|
||
// LINE WORKS へテキストメッセージを送信
|
||
async function sendLineworksMessage(userId, text) {
|
||
if (!LINEWORKS_ACCESS_TOKEN || !LINEWORKS_BOT_ID) {
|
||
console.warn('LINEWORKS_ACCESS_TOKEN または LINEWORKS_BOT_ID が未設定のため、メッセージ送信をスキップしました。');
|
||
return;
|
||
}
|
||
|
||
const url = `${LINEWORKS_API_BASE}/bots/${LINEWORKS_BOT_ID}/users/${userId}/messages`;
|
||
|
||
await axios.post(
|
||
url,
|
||
{
|
||
content: {
|
||
type: 'text',
|
||
text,
|
||
},
|
||
},
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${LINEWORKS_ACCESS_TOKEN}`,
|
||
},
|
||
},
|
||
);
|
||
}
|
||
|
||
// LINE WORKS へ Quick Reply 付きメッセージを送信
|
||
async function sendLineworksQuickReply(userId, text, options) {
|
||
if (!LINEWORKS_ACCESS_TOKEN || !LINEWORKS_BOT_ID) {
|
||
console.warn('LINEWORKS_ACCESS_TOKEN または LINEWORKS_BOT_ID が未設定のため、Quick Reply送信をスキップしました。');
|
||
return;
|
||
}
|
||
|
||
const url = `${LINEWORKS_API_BASE}/bots/${LINEWORKS_BOT_ID}/users/${userId}/messages`;
|
||
|
||
const items = options.map((opt) => ({
|
||
action: {
|
||
type: 'message',
|
||
label: opt,
|
||
text: opt,
|
||
},
|
||
}));
|
||
|
||
await axios.post(
|
||
url,
|
||
{
|
||
content: {
|
||
type: 'text',
|
||
text,
|
||
quickReply: {
|
||
items,
|
||
},
|
||
},
|
||
},
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${LINEWORKS_ACCESS_TOKEN}`,
|
||
},
|
||
},
|
||
);
|
||
}
|
||
|
||
// ユーザー状態のクリア
|
||
function clearUserState(userId) {
|
||
userStates.delete(userId);
|
||
}
|
||
|
||
// アンケート定義取得用のエンドポイント
|
||
// 例: GET /api/surveys/123
|
||
app.get('/api/surveys/:surveyId', async (req, res) => {
|
||
const { surveyId } = req.params;
|
||
|
||
try {
|
||
const definition = await fetchSurveyDefinition(surveyId);
|
||
|
||
if (!definition) {
|
||
// 指定されたIDのアンケート定義が存在しない場合
|
||
return res.status(404).json({ message: 'アンケート定義が見つかりません。' });
|
||
}
|
||
|
||
// アンケート定義をJSONとして返却
|
||
return res.json(definition);
|
||
} catch (error) {
|
||
console.error(error);
|
||
return res.status(500).json({ message: 'アンケート定義取得中にエラーが発生しました。' });
|
||
}
|
||
});
|
||
|
||
// アンケート回答登録用のエンドポイント
|
||
// 例: POST /api/surveys/123/responses?mode=reject|overwrite
|
||
// body: { key: '一意キー', answers: {...} }
|
||
app.post('/api/surveys/:surveyId/responses', async (req, res) => {
|
||
const { surveyId } = req.params;
|
||
const { key, answers } = req.body;
|
||
// mode=reject : 二重回答を拒否(デフォルト)
|
||
// mode=overwrite : 既存回答を上書き
|
||
const mode = (req.query.mode || 'reject').toString();
|
||
|
||
// 必須項目チェック
|
||
if (!key || !answers) {
|
||
return res.status(400).json({ message: 'key と answers は必須です。' });
|
||
}
|
||
|
||
try {
|
||
// 同じキーのレコードが既に存在するか確認
|
||
const existing = await findExistingRecordByKey(surveyId, key);
|
||
|
||
if (existing && mode === 'reject') {
|
||
// 二重回答禁止モードの場合は409で返して終了
|
||
return res.status(409).json({
|
||
message: 'このキーでは既に回答済みです。',
|
||
status: 'duplicate',
|
||
});
|
||
}
|
||
|
||
// Pleasanter に保存するレコード用のペイロード
|
||
const payload = {
|
||
// 一意キー(対象者や対象データを紐付ける)
|
||
Key: key,
|
||
// 回答内容はJSON文字列として1カラムにまとめて保存する例
|
||
Answers: JSON.stringify(answers),
|
||
};
|
||
|
||
if (existing && mode === 'overwrite') {
|
||
// 既存レコードがあり、上書きモードの場合は edit API で更新
|
||
const updated = await updateRecord(surveyId, existing.Id, payload);
|
||
return res.json({
|
||
message: '回答を上書きしました。',
|
||
status: 'updated',
|
||
result: updated,
|
||
});
|
||
}
|
||
|
||
// 新規回答として登録
|
||
const created = await createRecord(surveyId, payload);
|
||
return res.status(201).json({
|
||
message: '回答を登録しました。',
|
||
status: 'created',
|
||
result: created,
|
||
});
|
||
} catch (error) {
|
||
console.error(error);
|
||
return res.status(500).json({ message: '回答登録中にエラーが発生しました。' });
|
||
}
|
||
});
|
||
|
||
// LINE WORKS ボット用 Webhook エンドポイント
|
||
// sample.js の doPost(e) 相当
|
||
// 例: LINE WORKS 管理画面でこのURLをWebhookとして設定
|
||
app.post('/lineworks/webhook', async (req, res) => {
|
||
const data = req.body;
|
||
const event = data && data.events && data.events[0];
|
||
|
||
if (!event || !event.source || !event.source.userId) {
|
||
// 想定外のペイロードは黙って200を返す
|
||
return res.status(200).end();
|
||
}
|
||
|
||
const userId = event.source.userId;
|
||
const userText = event.content && event.content.text ? event.content.text : '';
|
||
|
||
// ユーザーごとの状態を取得(なければ step "0" から開始)
|
||
const state = userStates.get(userId) || { step: '0' };
|
||
|
||
// 「キャンセル」でアンケートを中断
|
||
if (userText === 'キャンセル') {
|
||
clearUserState(userId);
|
||
await sendLineworksMessage(userId, 'アンケートを中断しました。');
|
||
return res.status(200).end();
|
||
}
|
||
|
||
try {
|
||
switch (state.step) {
|
||
case '0':
|
||
// アンケート開始メッセージ
|
||
await sendLineworksMessage(
|
||
userId,
|
||
'アンケートを開始します。\nまずは【お名前】を入力してください。',
|
||
);
|
||
userStates.set(userId, { step: '1' });
|
||
break;
|
||
|
||
case '1': {
|
||
// 名前を受け取り、次の質問へ
|
||
const newState = {
|
||
...state,
|
||
step: '2',
|
||
name: userText,
|
||
};
|
||
userStates.set(userId, newState);
|
||
await sendLineworksMessage(
|
||
userId,
|
||
'次に【希望日】を 2026/03/01 の形式で入力してください。',
|
||
);
|
||
break;
|
||
}
|
||
|
||
case '2': {
|
||
// 希望日を受け取り、満足度のQuick Replyを送信
|
||
const newState = {
|
||
...state,
|
||
step: '3',
|
||
date: userText,
|
||
};
|
||
userStates.set(userId, newState);
|
||
await sendLineworksQuickReply(userId, '今回の満足度を教えてください。', [
|
||
'満足',
|
||
'普通',
|
||
'不満',
|
||
]);
|
||
break;
|
||
}
|
||
|
||
case '3': {
|
||
// 最終ステップ:満足度を受け取り、Pleasanter に送信
|
||
const finalData = {
|
||
name: state.name,
|
||
date: state.date,
|
||
rating: userText,
|
||
userId,
|
||
};
|
||
|
||
if (LINEWORKS_SURVEY_ID) {
|
||
const key = userId; // ユーザーIDをキーとして二重回答制御
|
||
|
||
try {
|
||
const existing = await findExistingRecordByKey(LINEWORKS_SURVEY_ID, key);
|
||
|
||
const payload = {
|
||
Key: key,
|
||
Answers: JSON.stringify(finalData),
|
||
};
|
||
|
||
if (existing && LINEWORKS_RESPONSE_MODE === 'overwrite') {
|
||
await updateRecord(LINEWORKS_SURVEY_ID, existing.Id, payload);
|
||
} else if (!existing) {
|
||
await createRecord(LINEWORKS_SURVEY_ID, payload);
|
||
} else if (existing && LINEWORKS_RESPONSE_MODE === 'reject') {
|
||
// 既に回答がある場合でrejectモードなら、その旨を通知
|
||
await sendLineworksMessage(
|
||
userId,
|
||
'このアンケートは既に回答済みのため、新しい回答は保存されませんでした。',
|
||
);
|
||
}
|
||
} catch (err) {
|
||
console.error('Pleasanter 送信中にエラーが発生しました:', err);
|
||
await sendLineworksMessage(userId, '回答の保存中にエラーが発生しました。時間をおいて再度お試しください。');
|
||
clearUserState(userId);
|
||
return res.status(200).end();
|
||
}
|
||
}
|
||
|
||
// ユーザーへ完了メッセージ
|
||
await sendLineworksMessage(userId, 'ご回答ありがとうございました!データを送信しました。');
|
||
|
||
// 状態をリセット
|
||
clearUserState(userId);
|
||
break;
|
||
}
|
||
|
||
default:
|
||
// 想定外のステップは最初からやり直し
|
||
userStates.set(userId, { step: '0' });
|
||
await sendLineworksMessage(userId, 'アンケートを最初からやり直します。');
|
||
break;
|
||
}
|
||
} catch (error) {
|
||
console.error('LINE WORKS Webhook 処理中にエラーが発生しました:', error);
|
||
// ユーザー側には一般的なエラーメッセージのみ返す
|
||
try {
|
||
await sendLineworksMessage(userId, '処理中にエラーが発生しました。時間をおいて再度お試しください。');
|
||
} catch (e) {
|
||
console.error('エラーメッセージ送信にも失敗しました:', e);
|
||
}
|
||
}
|
||
|
||
// LINE WORKS 側には常に200を返却
|
||
return res.status(200).end();
|
||
});
|
||
|
||
// サーバー起動
|
||
app.listen(PORT, () => {
|
||
console.log(`LINEWORKS to Pleasanter relay server listening on port ${PORT}`);
|
||
});
|