"use strict"; /* * LINE WORKS 共有ドライブAPIのラッパー。 * 仕様書: * https://developers.worksmobile.com/jp/docs/sharedrive-list * https://developers.worksmobile.com/jp/docs/sharedrive-file-folder-create * https://developers.worksmobile.com/jp/docs/sharedrive-file-create * https://developers.worksmobile.com/en/docs/file-upload (アップロード段階2の詳細) * * 必要スコープ: file (読み書き全般) */ const fs = require("fs"); const path = require("path"); const { updateEnvValue } = require("./env"); const defaultLogger = require("./logger"); const LW_API_BASE_URL = "https://www.worksapis.com/v1.0"; // 各API呼び出しの間隔(ミリ秒)とレート制限時の最大リトライ回数。 // lineworks-anythingllm.js の lwFetch と同じスロットリング方式。 const LW_REQUEST_DELAY_MS = Number(process.env.LW_REQUEST_DELAY_MS || 250); const LW_MAX_RETRIES = Number(process.env.LW_MAX_RETRIES || 5); function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function lwFetch(url, options = {}, logger = defaultLogger) { for (let attempt = 0; attempt <= LW_MAX_RETRIES; attempt++) { await sleep(LW_REQUEST_DELAY_MS); const response = await fetch(url, options); if (response.status !== 429) { return response; } if (attempt === LW_MAX_RETRIES) { return response; } const retryAfterHeader = response.headers.get("retry-after"); const retryAfterMs = retryAfterHeader ? Number(retryAfterHeader) * 1000 : LW_REQUEST_DELAY_MS * Math.pow(2, attempt + 1); logger.warn( `[Rate Limit] 429を検知。${Math.round(retryAfterMs / 1000)}秒待機してリトライします... ` + `(${attempt + 1}/${LW_MAX_RETRIES}) url=${url}` ); await sleep(retryAfterMs); } } async function parseJsonResponse(response, errorLabel) { const raw = await response.text(); let data; try { data = raw ? JSON.parse(raw) : {}; } catch (_err) { throw new Error(`${errorLabel}のレスポンスがJSONではありません: ${raw}`); } if (!response.ok) { throw new Error(`${errorLabel}失敗: ${response.status} ${JSON.stringify(data)}`); } return data; } // ==================================================================== // 共有ドライブID解決 // ==================================================================== // GET /sharedrives にはページング用パラメータ(count/cursor)が存在しない // (/files 系のようなresponseMetaData.nextCursorは返らない)ため、1回のみ呼び出す。 async function listSharedDrives(token, logger = defaultLogger) { const res = await lwFetch( `${LW_API_BASE_URL}/sharedrives`, { headers: { Authorization: `Bearer ${token}` } }, logger ); const data = await parseJsonResponse(res, "共有ドライブ一覧取得"); return Array.isArray(data.sharedrives) ? data.sharedrives : []; } // .env の LW_SHAREDRIVE_ID があればそれを使用(APIを呼ばない)。無ければ // LW_SHAREDRIVE_NAME と一致する共有ドライブを検索し、.env に書き戻して次回以降キャッシュする。 async function resolveSharedDriveId(token, logger = defaultLogger) { const cached = process.env.LW_SHAREDRIVE_ID; if (cached) { logger.info(`共有ドライブID: キャッシュ値を使用 (${cached})`); return cached; } const targetName = process.env.LW_SHAREDRIVE_NAME || "システムバックアップ"; logger.info(`共有ドライブID未キャッシュ。名前で検索します: ${targetName}`); const drives = await listSharedDrives(token, logger); const found = drives.find((d) => d.name === targetName); if (!found) { throw new Error(`共有ドライブが見つかりません: ${targetName}`); } updateEnvValue("LW_SHAREDRIVE_ID", found.sharedriveId); logger.info(`共有ドライブID解決: name=${targetName} id=${found.sharedriveId} (.envへ保存)`); return found.sharedriveId; } // ==================================================================== // フォルダ一覧・作成 // ==================================================================== // parentFileId が falsy の場合はルート直下を対象にする。 async function listChildren(token, sharedriveId, parentFileId, logger = defaultLogger) { const files = []; let cursor = ""; const basePath = parentFileId ? `${LW_API_BASE_URL}/sharedrives/${encodeURIComponent(sharedriveId)}/files/${encodeURIComponent(parentFileId)}/children` : `${LW_API_BASE_URL}/sharedrives/${encodeURIComponent(sharedriveId)}/files`; while (true) { const params = new URLSearchParams(); params.set("count", "200"); if (cursor) params.set("cursor", cursor); const res = await lwFetch( `${basePath}?${params.toString()}`, { headers: { Authorization: `Bearer ${token}` } }, logger ); const data = await parseJsonResponse(res, "ファイル一覧取得"); const page = Array.isArray(data.files) ? data.files : []; files.push(...page); const nextCursor = data?.responseMetaData?.nextCursor || ""; if (!nextCursor || nextCursor === cursor) break; cursor = nextCursor; } return files; } async function createFolder(token, sharedriveId, parentFileId, folderName, logger = defaultLogger) { const url = parentFileId ? `${LW_API_BASE_URL}/sharedrives/${encodeURIComponent(sharedriveId)}/files/${encodeURIComponent(parentFileId)}/createfolder` : `${LW_API_BASE_URL}/sharedrives/${encodeURIComponent(sharedriveId)}/files/createfolder`; const res = await lwFetch( url, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ fileName: folderName }), }, logger ); return parseJsonResponse(res, `フォルダ作成 (${folderName})`); } // 同名フォルダが既にあれば再利用し、無ければ作成する。 async function ensureFolder(token, sharedriveId, parentFileId, folderName, logger = defaultLogger) { const children = await listChildren(token, sharedriveId, parentFileId, logger); const existing = children.find((f) => f.fileType === "FOLDER" && f.fileName === folderName); if (existing) { logger.info(`フォルダ確認: 既存を使用 name=${folderName} id=${existing.fileId}`); return existing; } const created = await createFolder(token, sharedriveId, parentFileId, folderName, logger); logger.info(`フォルダ確認: 新規作成 name=${folderName} id=${created.fileId}`); return created; } // ==================================================================== // アップロード (段階1: uploadUrl発行 → 段階2: multipart/form-dataで実バイト送信) // ==================================================================== async function fileToBlob(filePath, mimeType) { if (typeof fs.openAsBlob === "function") { // Node 18.13+: ファイル全体をメモリに載せずBlobとして扱える return fs.openAsBlob(filePath, { type: mimeType }); } const buffer = fs.readFileSync(filePath); return new Blob([buffer], { type: mimeType }); } async function uploadFile(token, sharedriveId, parentFileId, filePath, options = {}, logger = defaultLogger) { const overwrite = options.overwrite === true; const stat = fs.statSync(filePath); const fileName = path.basename(filePath); logger.info(`アップロード開始: ファイル名=${fileName} サイズ=${stat.size}bytes`); const startedAt = Date.now(); const stage1Res = await lwFetch( `${LW_API_BASE_URL}/sharedrives/${encodeURIComponent(sharedriveId)}/files/${encodeURIComponent(parentFileId)}`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ fileName, modifiedTime: new Date(stat.mtimeMs).toISOString(), fileSize: stat.size, overwrite, resume: false, suffixOnDuplicate: false, }), }, logger ); const stage1Data = await parseJsonResponse(stage1Res, `アップロードURL発行 (${fileName})`); if (!stage1Data.uploadUrl) { throw new Error(`アップロードURL発行失敗 (${fileName}): uploadUrlが返却されませんでした`); } const blob = await fileToBlob(filePath, "application/octet-stream"); const form = new FormData(); form.append("Filedata", blob, fileName); const stage2Res = await lwFetch( stage1Data.uploadUrl, { method: "POST", headers: { Authorization: `Bearer ${token}` }, body: form, }, logger ); const stage2Data = await parseJsonResponse(stage2Res, `アップロード (${fileName})`); const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1); logger.info(`アップロード成功: ファイル名=${fileName} fileId=${stage2Data.fileId} 所要時間=${elapsedSec}秒`); return stage2Data; } // ==================================================================== // 削除 // ==================================================================== async function deleteFile(token, sharedriveId, fileId, logger = defaultLogger) { const res = await lwFetch( `${LW_API_BASE_URL}/sharedrives/${encodeURIComponent(sharedriveId)}/files/${encodeURIComponent(fileId)}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` }, }, logger ); if (res.status !== 204 && !res.ok) { const raw = await res.text(); throw new Error(`削除失敗 (${fileId}): ${res.status} ${raw}`); } } module.exports = { LW_API_BASE_URL, listSharedDrives, resolveSharedDriveId, listChildren, ensureFolder, uploadFile, deleteFile, };