469 lines
15 KiB
JavaScript
469 lines
15 KiB
JavaScript
const fs = require('fs');
|
||
const path = require('path');
|
||
const crypto = require('crypto');
|
||
|
||
// --- LINE WORKS 設定 ---
|
||
const LW_TOKEN_URL = 'https://auth.worksmobile.com/oauth2/v2.0/token';
|
||
const LW_API_BASE_URL = 'https://www.worksapis.com/v1.0';
|
||
|
||
// 既存スクリプト(importBoard.js)と同様の認証設定を流用
|
||
const PRIVATE_KEY_FILE = process.env.LW_PRIVATE_KEY_FILE
|
||
? path.resolve(process.env.LW_PRIVATE_KEY_FILE)
|
||
: path.join(__dirname, 'private_20260307184804.key');
|
||
|
||
const LW_CLIENT_ID = process.env.LW_CLIENT_ID || 'tre8J_Tk8RblfsMSyZRh';
|
||
const LW_CLIENT_SECRET = process.env.LW_CLIENT_SECRET || 'O5_R5gcHxg';
|
||
const LW_SERVICE_ACCOUNT = process.env.LW_SERVICE_ACCOUNT || 'wh4k8.serviceaccount@next-hd.co.jp';
|
||
|
||
const LW_PRIVATE_KEY = (process.env.LW_PRIVATE_KEY && process.env.LW_PRIVATE_KEY.trim())
|
||
? process.env.LW_PRIVATE_KEY
|
||
: (fs.existsSync(PRIVATE_KEY_FILE) ? fs.readFileSync(PRIVATE_KEY_FILE, 'utf8') : '');
|
||
|
||
// scope
|
||
// - カレンダー一覧取得: 通常は calendar または calendar.read が必要
|
||
// - ユーザー一覧から userId を解決する場合: user.read が必要
|
||
const LW_CALENDAR_SCOPE = process.env.LW_CALENDAR_SCOPE || process.env.LW_SCOPE || 'calendar';
|
||
const LW_USER_LOOKUP_SCOPE = process.env.LW_USER_LOOKUP_SCOPE || 'user.read';
|
||
|
||
// 取得エンドポイント(仕様差分に備えて env で上書き可能)
|
||
// 例: /users/{userId}/calendar/calendars
|
||
const LW_CALENDAR_LIST_ENDPOINT_TEMPLATE = process.env.LW_CALENDAR_LIST_ENDPOINT_TEMPLATE
|
||
|| '/users/{userId}/calendar/calendars';
|
||
|
||
// 実行制御
|
||
const LW_API_WAIT_MS = Number(process.env.LW_API_WAIT_MS || '300');
|
||
|
||
let lastApiRequestAt = 0;
|
||
|
||
function ensureFetchAvailable() {
|
||
if (typeof fetch !== 'function') {
|
||
throw new Error('このスクリプトは Node.js 18+ (fetch対応) を想定しています');
|
||
}
|
||
}
|
||
|
||
function ensureRequiredEnv() {
|
||
const missing = [];
|
||
if (!LW_CLIENT_ID) missing.push('LW_CLIENT_ID');
|
||
if (!LW_CLIENT_SECRET) missing.push('LW_CLIENT_SECRET');
|
||
if (!LW_SERVICE_ACCOUNT) missing.push('LW_SERVICE_ACCOUNT');
|
||
if (!LW_PRIVATE_KEY) missing.push('LW_PRIVATE_KEY');
|
||
|
||
if (missing.length > 0) {
|
||
if (missing.includes('LW_PRIVATE_KEY') && !fs.existsSync(PRIVATE_KEY_FILE)) {
|
||
throw new Error(`秘密鍵が見つかりません: ${PRIVATE_KEY_FILE}`);
|
||
}
|
||
throw new Error(`環境変数が不足しています: ${missing.join(', ')}`);
|
||
}
|
||
}
|
||
|
||
function sleep(ms) {
|
||
return new Promise(resolve => setTimeout(resolve, ms));
|
||
}
|
||
|
||
async function waitApiInterval() {
|
||
const now = Date.now();
|
||
const elapsed = now - lastApiRequestAt;
|
||
const waitMs = Math.max(0, LW_API_WAIT_MS - elapsed);
|
||
if (waitMs > 0) {
|
||
await sleep(waitMs);
|
||
}
|
||
lastApiRequestAt = Date.now();
|
||
}
|
||
|
||
async function lwFetch(url, options) {
|
||
await waitApiInterval();
|
||
return fetch(url, options);
|
||
}
|
||
|
||
function base64UrlEncode(value) {
|
||
return Buffer.from(value)
|
||
.toString('base64')
|
||
.replace(/=/g, '')
|
||
.replace(/\+/g, '-')
|
||
.replace(/\//g, '_');
|
||
}
|
||
|
||
function createJwtAssertion() {
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const header = { alg: 'RS256', typ: 'JWT' };
|
||
const payload = {
|
||
iss: LW_CLIENT_ID,
|
||
sub: LW_SERVICE_ACCOUNT,
|
||
aud: LW_TOKEN_URL,
|
||
iat: now,
|
||
exp: now + 300
|
||
};
|
||
|
||
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
||
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||
|
||
const signer = crypto.createSign('RSA-SHA256');
|
||
signer.update(signingInput);
|
||
signer.end();
|
||
|
||
const signature = signer
|
||
.sign(LW_PRIVATE_KEY)
|
||
.toString('base64')
|
||
.replace(/=/g, '')
|
||
.replace(/\+/g, '-')
|
||
.replace(/\//g, '_');
|
||
|
||
return `${signingInput}.${signature}`;
|
||
}
|
||
|
||
async function getAccessToken(scope) {
|
||
const assertion = createJwtAssertion();
|
||
const form = new URLSearchParams({
|
||
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||
assertion,
|
||
client_id: LW_CLIENT_ID,
|
||
client_secret: LW_CLIENT_SECRET,
|
||
scope
|
||
});
|
||
|
||
const response = await lwFetch(LW_TOKEN_URL, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: form
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const detail = await response.text();
|
||
throw new Error(`アクセストークン取得失敗: ${response.status} ${detail}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
if (!data.access_token) {
|
||
throw new Error('アクセストークン取得失敗: access_token が返却されませんでした');
|
||
}
|
||
|
||
return data.access_token;
|
||
}
|
||
|
||
function parseJsonWithQuotedInt64(text, keys) {
|
||
// JSON.parseの数値(IEEE754)でint64が壊れるのを避けるため、指定キーの整数を文字列にしてからparseする
|
||
let patched = String(text || '');
|
||
for (const key of keys) {
|
||
const re = new RegExp(`"${key}"\\s*:\\s*(\\d+)`, 'g');
|
||
patched = patched.replace(re, `"${key}":"$1"`);
|
||
}
|
||
return JSON.parse(patched);
|
||
}
|
||
|
||
function normalizeLoginIdForCompare(value) {
|
||
return String(value || '').trim().toLowerCase();
|
||
}
|
||
|
||
function localPartIfEmail(value) {
|
||
const v = String(value || '').trim();
|
||
const at = v.indexOf('@');
|
||
if (at <= 0) return '';
|
||
return v.slice(0, at);
|
||
}
|
||
|
||
function loginIdCandidates(profile) {
|
||
const ids = new Set();
|
||
const add = (v) => {
|
||
if (v) ids.add(String(v));
|
||
};
|
||
|
||
add(profile.email);
|
||
add(profile.loginId);
|
||
add(profile.userId);
|
||
if (typeof profile.userName === 'string') {
|
||
add(profile.userName);
|
||
}
|
||
if (profile.account && typeof profile.account === 'object') {
|
||
add(profile.account.loginId);
|
||
add(profile.account.email);
|
||
}
|
||
|
||
return Array.from(ids);
|
||
}
|
||
|
||
function isSameLoginId(candidate, target) {
|
||
const c = normalizeLoginIdForCompare(candidate);
|
||
const t = normalizeLoginIdForCompare(target);
|
||
if (!c || !t) return false;
|
||
if (c === t) return true;
|
||
|
||
// メールアドレスの local-part(@より前)でも照合できるようにする
|
||
const cLocal = normalizeLoginIdForCompare(localPartIfEmail(c));
|
||
const tLocal = normalizeLoginIdForCompare(localPartIfEmail(t));
|
||
if (cLocal && tLocal && cLocal === tLocal) return true;
|
||
if (cLocal && !tLocal && cLocal === t) return true;
|
||
if (!cLocal && tLocal && c === tLocal) return true;
|
||
|
||
return false;
|
||
}
|
||
|
||
async function fetchAllLineworksUsers(accessToken, domainId) {
|
||
const users = [];
|
||
let cursor = '';
|
||
|
||
while (true) {
|
||
const params = new URLSearchParams();
|
||
params.set('count', '100');
|
||
if (domainId) {
|
||
params.set('domainId', domainId);
|
||
}
|
||
if (cursor) {
|
||
params.set('cursor', cursor);
|
||
}
|
||
|
||
const apiUrl = `${LW_API_BASE_URL}/users?${params.toString()}`;
|
||
const response = await lwFetch(apiUrl, {
|
||
method: 'GET',
|
||
headers: {
|
||
Authorization: `Bearer ${accessToken}`
|
||
}
|
||
});
|
||
|
||
const text = await response.text();
|
||
let data;
|
||
try {
|
||
data = JSON.parse(text);
|
||
} catch (error) {
|
||
throw new Error('LINE WORKSユーザー一覧の解析に失敗しました');
|
||
}
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`LINE WORKSユーザー一覧取得に失敗しました: ${response.status} ${JSON.stringify(data)}`);
|
||
}
|
||
|
||
const pageUsers = Array.isArray(data.users) ? data.users : [];
|
||
users.push(...pageUsers);
|
||
|
||
const nextCursor = data?.responseMetaData?.nextCursor || '';
|
||
if (!nextCursor || nextCursor === cursor) {
|
||
break;
|
||
}
|
||
cursor = nextCursor;
|
||
}
|
||
|
||
return users;
|
||
}
|
||
|
||
function findUserIdByLoginId(users, loginId) {
|
||
for (const user of users) {
|
||
const candidates = loginIdCandidates(user);
|
||
const matched = candidates.some(id => isSameLoginId(id, loginId));
|
||
if (matched) {
|
||
return String(user.userId || user.id || '');
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
async function fetchMyProfile(accessToken) {
|
||
// LINE WORKS API の実装差分に備えて複数候補を試す
|
||
const candidates = [
|
||
`${LW_API_BASE_URL}/users/me`,
|
||
`${LW_API_BASE_URL}/users/@me`
|
||
];
|
||
|
||
for (const url of candidates) {
|
||
const response = await lwFetch(url, {
|
||
method: 'GET',
|
||
headers: {
|
||
Authorization: `Bearer ${accessToken}`
|
||
}
|
||
});
|
||
|
||
if (!response.ok) {
|
||
continue;
|
||
}
|
||
|
||
const text = await response.text();
|
||
try {
|
||
return JSON.parse(text);
|
||
} catch (error) {
|
||
throw new Error(`自分自身のユーザー情報の解析に失敗しました: ${text.slice(0, 200)}`);
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
async function fetchAllCalendarsOfUser(accessToken, userId) {
|
||
const calendars = [];
|
||
let cursor = '';
|
||
|
||
while (true) {
|
||
const endpoint = LW_CALENDAR_LIST_ENDPOINT_TEMPLATE
|
||
.replace('{userId}', encodeURIComponent(userId));
|
||
|
||
const params = new URLSearchParams();
|
||
params.set('count', '200');
|
||
if (cursor) params.set('cursor', cursor);
|
||
|
||
const url = `${LW_API_BASE_URL}${endpoint}?${params.toString()}`;
|
||
const response = await lwFetch(url, {
|
||
method: 'GET',
|
||
headers: {
|
||
Authorization: `Bearer ${accessToken}`
|
||
}
|
||
});
|
||
|
||
const text = await response.text();
|
||
let data;
|
||
try {
|
||
data = parseJsonWithQuotedInt64(text, ['calendarId']);
|
||
} catch (error) {
|
||
throw new Error(`カレンダー一覧の解析に失敗しました: ${text.slice(0, 200)}`);
|
||
}
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`カレンダー一覧取得に失敗しました: ${response.status} ${JSON.stringify(data)}`);
|
||
}
|
||
|
||
const pageCalendars = Array.isArray(data.calendars)
|
||
? data.calendars
|
||
: (Array.isArray(data.calendarList) ? data.calendarList : []);
|
||
|
||
calendars.push(...pageCalendars);
|
||
|
||
const nextCursor = data?.responseMetaData?.nextCursor || '';
|
||
if (!nextCursor || nextCursor === cursor) {
|
||
break;
|
||
}
|
||
cursor = nextCursor;
|
||
}
|
||
|
||
return calendars;
|
||
}
|
||
|
||
function pickCalendarId(calendar) {
|
||
if (!calendar || typeof calendar !== 'object') return '';
|
||
if (calendar.calendarId != null) return String(calendar.calendarId);
|
||
if (calendar.id != null) return String(calendar.id);
|
||
if (calendar.calendarID != null) return String(calendar.calendarID);
|
||
return '';
|
||
}
|
||
|
||
function pickCalendarName(calendar) {
|
||
if (!calendar || typeof calendar !== 'object') return '';
|
||
if (calendar.calendarName != null) return String(calendar.calendarName);
|
||
if (calendar.name != null) return String(calendar.name);
|
||
if (calendar.displayName != null) return String(calendar.displayName);
|
||
return '';
|
||
}
|
||
|
||
function parseArgs(argv) {
|
||
const args = {
|
||
allUsers: false,
|
||
userId: '',
|
||
domainId: ''
|
||
};
|
||
|
||
for (let i = 0; i < argv.length; i += 1) {
|
||
const a = argv[i];
|
||
if (a === '--all-users' || a === '--allUsers') {
|
||
args.allUsers = true;
|
||
continue;
|
||
}
|
||
if (a === '--userId' && argv[i + 1]) {
|
||
args.userId = String(argv[i + 1]);
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (a === '--domainId' && argv[i + 1]) {
|
||
args.domainId = String(argv[i + 1]);
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (a === '--help' || a === '-h') {
|
||
args.help = true;
|
||
}
|
||
}
|
||
|
||
return args;
|
||
}
|
||
|
||
function printHelp() {
|
||
console.log('LINE WORKS: カレンダー一覧(名前 + ID)取得');
|
||
console.log('');
|
||
console.log('使い方:');
|
||
console.log(' node getCalendarList..js # サービスアカウント本人のカレンダー一覧');
|
||
console.log(' node getCalendarList..js --userId <id> # 指定ユーザーのカレンダー一覧');
|
||
console.log(' node getCalendarList..js --all-users [--domainId <domainId>] # 全ユーザーのカレンダー一覧');
|
||
console.log('');
|
||
console.log('主な環境変数:');
|
||
console.log(' LW_PRIVATE_KEY_FILE, LW_CLIENT_ID, LW_CLIENT_SECRET, LW_SERVICE_ACCOUNT');
|
||
console.log(' LW_CALENDAR_SCOPE (default: calendar)');
|
||
console.log(' LW_USER_LOOKUP_SCOPE (default: user.read)');
|
||
console.log(' LW_CALENDAR_LIST_ENDPOINT_TEMPLATE (default: /users/{userId}/calendar/calendars)');
|
||
}
|
||
|
||
async function main() {
|
||
ensureFetchAvailable();
|
||
ensureRequiredEnv();
|
||
|
||
const args = parseArgs(process.argv.slice(2));
|
||
if (args.help) {
|
||
printHelp();
|
||
return;
|
||
}
|
||
|
||
const calendarToken = await getAccessToken(LW_CALENDAR_SCOPE);
|
||
|
||
if (args.allUsers) {
|
||
const userLookupToken = await getAccessToken(LW_USER_LOOKUP_SCOPE);
|
||
const users = await fetchAllLineworksUsers(userLookupToken, args.domainId || process.env.LW_DOMAIN_ID || '');
|
||
if (users.length === 0) {
|
||
throw new Error('LINE WORKSユーザー一覧が0件です');
|
||
}
|
||
|
||
console.log('userId\tloginId\tcalendarId\tcalendarName');
|
||
let total = 0;
|
||
|
||
for (const user of users) {
|
||
const userId = String(user.userId || user.id || '');
|
||
if (!userId) {
|
||
continue;
|
||
}
|
||
const loginId = String(user.loginId || user.email || user.account?.loginId || '');
|
||
const list = await fetchAllCalendarsOfUser(calendarToken, userId);
|
||
for (const cal of list) {
|
||
const calendarId = pickCalendarId(cal);
|
||
const calendarName = pickCalendarName(cal);
|
||
console.log(`${userId}\t${loginId}\t${calendarId}\t${calendarName}`);
|
||
total += 1;
|
||
}
|
||
}
|
||
|
||
console.log(`TOTAL\t\t\t${total}`);
|
||
return;
|
||
}
|
||
|
||
let targetUserId = (args.userId || '').trim();
|
||
if (!targetUserId) {
|
||
// サービスアカウントの userId を users API から解決する
|
||
const userLookupToken = await getAccessToken(LW_USER_LOOKUP_SCOPE);
|
||
const domainId = process.env.LW_DOMAIN_ID || '';
|
||
const users = await fetchAllLineworksUsers(userLookupToken, domainId);
|
||
targetUserId = findUserIdByLoginId(users, LW_SERVICE_ACCOUNT);
|
||
if (!targetUserId) {
|
||
const me = await fetchMyProfile(userLookupToken);
|
||
targetUserId = String(me?.userId || me?.id || '');
|
||
}
|
||
if (!targetUserId) {
|
||
throw new Error('サービスアカウントの userId を解決できませんでした。--userId 指定を試してください。');
|
||
}
|
||
}
|
||
|
||
const calendars = await fetchAllCalendarsOfUser(calendarToken, targetUserId);
|
||
console.log('calendarId\tcalendarName');
|
||
for (const cal of calendars) {
|
||
console.log(`${pickCalendarId(cal)}\t${pickCalendarName(cal)}`);
|
||
}
|
||
console.log(`TOTAL\t${calendars.length}`);
|
||
}
|
||
|
||
main().catch(error => {
|
||
console.error(error.message || error);
|
||
process.exit(1);
|
||
});
|
||
|