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 # 指定ユーザーのカレンダー一覧'); console.log(' node getCalendarList..js --all-users [--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); });