ken_nogi/PleasanterSystem/dbbackup/lib/lineworksDrive.js
Kenichiro NOGI 67cc3da6c8 up
2026-07-13 19:02:04 +09:00

277 lines
10 KiB
JavaScript

"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,
};