ken_nogi/NodeJS/アルファオフィススケジュール/getCalendarList..js
Kenichiro NOGI 88a402ce0f up
2026-07-10 18:13:30 +09:00

469 lines
15 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');
// --- 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);
});