318 lines
12 KiB
JavaScript
318 lines
12 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 { pipeline } = require("stream/promises");
|
|
const { Readable } = require("stream");
|
|
|
|
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;
|
|
}
|
|
|
|
// ====================================================================
|
|
// ダウンロード (段階1: /download で302リダイレクト先URLを取得 → 段階2: そのURLから実バイト取得)
|
|
// 仕様書: https://developers.worksmobile.com/jp/docs/sharedrive-file-download
|
|
// ====================================================================
|
|
|
|
async function downloadFile(token, sharedriveId, fileId, destPath, logger = defaultLogger) {
|
|
const fileName = path.basename(destPath);
|
|
logger.info(`ダウンロード開始: ファイル名=${fileName} fileId=${fileId}`);
|
|
const startedAt = Date.now();
|
|
|
|
const stage1Res = await lwFetch(
|
|
`${LW_API_BASE_URL}/sharedrives/${encodeURIComponent(sharedriveId)}/files/${encodeURIComponent(fileId)}/download`,
|
|
{ headers: { Authorization: `Bearer ${token}` }, redirect: "manual" },
|
|
logger
|
|
);
|
|
if (stage1Res.status !== 302 && stage1Res.status !== 303) {
|
|
const raw = await stage1Res.text().catch(() => "");
|
|
throw new Error(`ダウンロードURL取得失敗 (${fileName}): ${stage1Res.status} ${raw}`);
|
|
}
|
|
const downloadUrl = stage1Res.headers.get("location");
|
|
if (!downloadUrl) {
|
|
throw new Error(`ダウンロードURL取得失敗 (${fileName}): Locationヘッダーがありません`);
|
|
}
|
|
|
|
const stage2Res = await lwFetch(downloadUrl, { headers: { Authorization: `Bearer ${token}` } }, logger);
|
|
if (!stage2Res.ok || !stage2Res.body) {
|
|
throw new Error(`ファイルダウンロード失敗 (${fileName}): ${stage2Res.status}`);
|
|
}
|
|
|
|
await pipeline(Readable.fromWeb(stage2Res.body), fs.createWriteStream(destPath));
|
|
|
|
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
|
|
const size = fs.statSync(destPath).size;
|
|
logger.info(`ダウンロード成功: ファイル名=${fileName} サイズ=${size}bytes 所要時間=${elapsedSec}秒`);
|
|
|
|
return destPath;
|
|
}
|
|
|
|
// ====================================================================
|
|
// 削除
|
|
// ====================================================================
|
|
|
|
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,
|
|
downloadFile,
|
|
deleteFile,
|
|
};
|