/**
* ============================================================================
* LINE 存檔小幫手 (line-drive-keeper) — Google Apps Script 主程式
* ============================================================================
*
* 【這是什麼】
* 把 LINE 對話(群組/私訊/多人聊天室)裡別人傳來的檔案(文件/圖片/影片/語音),
* 自動下載備份到你自己的 Google Drive,並在 LINE 裡回覆一則確認訊息。
* LINE 上的檔案有保存期限,過期就再也下載不到;備份到 Drive 之後就永久保留。
*
* 【怎麼運作】
* LINE 官方帳號 → (webhook) → 這支 GAS Web App → 下載檔案 → 存進指定的 Drive 資料夾
* → 依「來源名稱 / 年月」自動分層 → 回覆一則「✅ 已收到檔案」的確認訊息。
* 另外支援幾個文字指令(/help /id /folder /last /stats /ping),方便查詢與設定白名單。
*
* 【5 步驟快速上手】
* 1. 開一個 Google Drive 資料夾當備份總目錄,網址列 .../folders/【這一段】就是資料夾ID。
* 2. 到 LINE Developers Console 建立 Messaging API 頻道,複製「Channel access token
* (long-lived)」;並把「自動回覆訊息」「加入好友的歡迎訊息」都關閉。
* 3. 把上面兩個值,還有自己隨便打一串英數字當 WEBHOOK_KEY,填進下面的 CONFIG 區塊。
* 4. 部署 → 新增部署作業 → 網頁應用程式:執行身分選「我」、誰可以存取選「所有人,
* 甚至匿名使用者」,部署後複製 /exec 網址。
* 5. 回到 GAS 編輯器,選右上角函式下拉選單,選 setupCheck,按執行(第一次會跳出 Google
* 授權畫面,全部同意)。看執行紀錄(Logger),會列出 ✅/❌ 健檢結果,並印出完整的
* Webhook URL(已自動接好 ?k=)。把這串網址貼到 LINE Developers Console 的
* Webhook URL 欄位,按「驗證」,再把「使用 Webhook」開關打開,就完成了。
*
* 想要更圖文並茂的設定精靈,可以打開同目錄下的 index.html(用瀏覽器直接開啟即可)。
*
* 【重要限制,寫程式前請先知道】
* - GAS 讀不到 HTTP 標頭(Header),所以無法驗證 LINE 官方的簽章(X-Line-Signature)。
* 這不是暫時的技術限制,是 Google 官方在 Issue Tracker #67764685 明確表態「基於安全
* 考量、不會支援」的長期決策。因此本程式改用網址參數 ?k=WEBHOOK_KEY 當簡易防護,
* 這是 GAS 平台下唯一可行的做法,不是偷懶。
* - GAS Web App 沒有任何機制可以回傳非 200 的 HTTP 狀態碼。回 4xx/5xx 會讓 LINE 把
* webhook 標記為異常甚至停用,所以整支程式的鐵則是「無論發生什麼事都一律回 200」。
* - 改完程式碼一定要「部署 → 管理部署作業 → 鉛筆圖示 → 版本選『新版本』」才會生效,
* 直接存檔(Ctrl+S)只會反映在 /dev 網址,不會反映在對外服務的 /exec 網址上。
*
* ============================================================================
*/
// ============================================================================
// 設定區(使用者只需要改這一段,其他地方不要動)
// ============================================================================
const CONFIG = {
// ── 必填 ─────────────────────────────
CHANNEL_ACCESS_TOKEN: '', // LINE Developers → Messaging API → Channel access token (long-lived)
ROOT_FOLDER_ID: '', // Google Drive 資料夾網址 .../folders/【這一段】
WEBHOOK_KEY: '', // 自訂密鑰,webhook URL 要帶 ?k=這串(GAS 無法驗 LINE 簽章,靠這個擋亂打)
// ── 資料夾分層 ───────────────────────
SUBFOLDER_BY_SOURCE: true, // 依「群組名/好友名」分子資料夾
SUBFOLDER_BY_MONTH: true, // 再依 YYYY-MM 分子資料夾
// ── 要不要存 ────────────────────────
SAVE_FILES: true, // 文件檔(pdf/xlsx/docx…)
SAVE_IMAGES: true,
SAVE_VIDEOS: true,
SAVE_AUDIO: false,
// ── 回覆 ────────────────────────────
REPLY_ENABLED: true, // 存好後在 LINE 回覆確認
REPLY_WITH_LINK: true, // 確認訊息附 Drive 連結
QUIET_MODE: false, // true = 只在失敗時才回覆(群組不吵)
NOTIFY_TO: '', // 填你自己的 userId:群組/聊天室的通知改「私訊」給你,群組內完全靜音(含指令回覆)。
// 取得方法:私訊這個官方帳號打 /id。留空 = 維持原本在對話中直接回覆
// ── 安全 ────────────────────────────
ALLOWLIST: [], // 允許的 sourceId 陣列;留空 = 全部允許。用 /id 指令取得
// ── 進階 ────────────────────────────
DUP_STRATEGY: 'rename', // 'rename' 同名加 (2) / 'skip' 跳過 / 'overwrite' 覆蓋
LOG_SHEET_ID: '', // 留空 = 不記錄;填試算表 ID 會逐筆寫入(只記檔名/大小/來源/時間/連結,不記訊息內容)
MAX_FILE_MB: 45, // 超過就不存,回覆提示(GAS Blob/UrlFetchApp 上限約 50MB,留 5MB 緩衝)
TZ: 'Asia/Taipei',
};
// ============================================================================
// 主要進入點:LINE webhook 就是打這兩支
// ============================================================================
// doPost:webhook 主入口。驗密鑰 → 解析事件 → 逐一處理 → 不管發生什麼事都回 200
function doPost(e) {
try {
// 地雷#1:GAS doPost(e) 讀不到 HTTP header,無法驗證 X-Line-Signature,
// 改用網址參數 ?k=WEBHOOK_KEY 當簡易密鑰擋亂打的探測流量。
const key = e && e.parameter && e.parameter.k;
if (!CONFIG.WEBHOOK_KEY || key !== CONFIG.WEBHOOK_KEY) {
// 密鑰不符:靜默回 200、不做任何處理,不要讓外部探測流量得知「這是驗證失敗」
return ContentService.createTextOutput(JSON.stringify({ ok: true }))
.setMimeType(ContentService.MimeType.JSON);
}
if (!e.postData || !e.postData.contents) {
// 沒有 body(例如有人手動開 URL 測試),視為無事發生,一樣回 200
return ContentService.createTextOutput(JSON.stringify({ ok: true }))
.setMimeType(ContentService.MimeType.JSON);
}
const body = JSON.parse(e.postData.contents);
const events = body.events || [];
// ★修正(審查發現#8-中等):LINE 單次 webhook payload 可能包含多個 events(例如群組短時間
// 內密集傳送多個大型影音檔),若毫無時間預算控管地逐一處理,累計耗時可能逼近或超過 GAS
// 單次執行 6 分鐘上限,平台會強制中止執行,佇列後段還沒輪到的事件會「靜默消失」——沒有
// 任何錯誤訊息、不會寫 log,使用者完全不知道漏了哪幾個檔案。這裡改用一般 for 迴圈搭配時間
// 預算檢查,超過預算就停止處理剩餘事件並留下明確 log,好過被平台強制中止時什麼痕跡都沒有。
const doPostStartTs = Date.now();
const TIME_BUDGET_MS = 4.5 * 60 * 1000; // 留 1.5 分鐘餘裕給 GAS 6 分鐘單次執行上限
for (let i = 0; i < events.length; i++) {
if (Date.now() - doPostStartTs > TIME_BUDGET_MS) {
Logger.log(`doPost 執行時間預算超過4.5分鐘,剩餘 ${events.length - i} 筆事件未處理` +
'(為避免撞上 GAS 6 分鐘單次執行上限被強制中止而主動停止,這些事件不會有任何備份或回覆)');
break;
}
try {
handleEvent(events[i]);
} catch (err) {
// handleEvent 內部理論上已經自己 try/catch 過了,這裡是保險,避免任何漏網例外中斷整批處理
Logger.log('doPost 迴圈中 handleEvent 拋出未預期例外: ' + (err && err.stack || err));
}
}
// 地雷#10:一律回 200 OK。回 4xx/5xx 會讓 LINE 把 webhook 標記為錯誤甚至停用。
return ContentService.createTextOutput(JSON.stringify({ ok: true, count: events.length }))
.setMimeType(ContentService.MimeType.JSON);
} catch (err) {
// 任何未預期的錯誤(例如 JSON.parse 失敗)都要吸收掉,還是要回 200
Logger.log('doPost 發生未預期錯誤: ' + (err && err.stack || err));
return ContentService.createTextOutput(JSON.stringify({ ok: false }))
.setMimeType(ContentService.MimeType.JSON);
}
}
// doGet:瀏覽器直接打開 /exec 網址時顯示的狀態頁,用來確認部署成功
function doGet(e) {
const html = '
' +
'' +
'LINE 存檔小幫手' +
'' +
'✅ LINE 存檔小幫手運作中
' +
'這個頁面出現,代表 Web App 已經成功部署。
' +
'接下來請到 GAS 編輯器手動執行一次 setupCheck() 進行完整健檢,' +
'並取得應貼到 LINE Developers Console 的 Webhook URL。
' +
'';
return HtmlService.createHtmlOutput(html);
}
// ============================================================================
// 事件處理
// ============================================================================
// handleEvent:單一事件分派。白名單檢查 → 依訊息類型分派 → 全程 try/catch 保護
function handleEvent(event) {
try {
// 只處理「訊息」事件;加入好友、被邀進群組、退出群組等其他事件類型不處理
if (!event || event.type !== 'message' || !event.message) return;
// 去重:LINE 可能因網路問題、或本服務未在時限內回應而重送(redelivery)同一個事件,
// 官方文件建議用 webhookEventId 偵測重複,避免同一個檔案被重複下載建檔、使用者收到重複確認訊息。
if (event.webhookEventId && isDuplicateEvent(event.webhookEventId)) return;
const source = event.source || {};
const sourceId = source.groupId || source.roomId || source.userId || '';
// 白名單檢查:統一閘門,所有指令與檔案訊息都要先過這關,不設任何例外。
// ★修正(審查發現):先前 /id 指令會例外繞過白名單,導致任何未授權來源都能打 /id
// 探知這支 bot 存在並取得可用於社交工程的 sourceId,不符合規格書資料流程圖「先白名單
// 檢查、再依訊息類型分派」的閘門順序。移除例外後:ALLOWLIST 留空(尚未啟用白名單)時
// 這個 if 本來就不會擋任何東西,/id 自然仍可使用;一旦 ALLOWLIST 非空(白名單已啟用),
// /id 就跟其他指令一樣會被完全擋下、不回應。
if (CONFIG.ALLOWLIST && CONFIG.ALLOWLIST.length > 0 && CONFIG.ALLOWLIST.indexOf(sourceId) === -1) {
return;
}
if (event.message.type === 'text') {
handleCommand(event);
} else {
saveMessageContent(event);
}
} catch (err) {
// 單一事件處理失敗,不能讓整個 doPost 掛掉;記 log 並盡量嘗試回覆使用者
Logger.log('handleEvent 發生未預期錯誤: ' + (err && err.stack || err));
try {
if (event && event.replyToken) {
sendReply(event, '⚠️ 處理時發生未預期錯誤,請稍後再試一次。');
}
} catch (err2) {
Logger.log('handleEvent 錯誤回覆也失敗: ' + (err2 && err2.stack || err2));
}
}
}
// saveMessageContent:下載內容 → 檢查大小 → 決定資料夾 → 命名 → 建檔 → 記 log → 回覆
function saveMessageContent(event) {
const message = event.message;
const type = message.type; // file / image / video / audio
// 依 CONFIG 決定這個類型要不要存;沒開的類型直接略過,不下載也不回覆(避免群組被無關訊息洗版)
const typeEnabled = {
file: CONFIG.SAVE_FILES,
image: CONFIG.SAVE_IMAGES,
video: CONFIG.SAVE_VIDEOS,
audio: CONFIG.SAVE_AUDIO,
};
if (!typeEnabled[type]) return;
const fileName = buildFileName(message, event);
// 下載內容:
// - file 類型沒有 contentProvider 欄位,內容恆由 LINE 伺服器保管,一定能用 fetchLineContent()。
// - image/video/audio 有 contentProvider.type,若為 'external' 代表內容存在第三方(非LINE)伺服器,
// Get content 端點(api-data.line.me/.../content)對 external 內容無效,必須直接向
// contentProvider.originalContentUrl 發請求下載(這是規格書原本沒處理、查證後補上的分支)。
let blob;
try {
const provider = message.contentProvider;
if (provider && provider.type === 'external' && provider.originalContentUrl) {
const res = UrlFetchApp.fetch(provider.originalContentUrl, { muteHttpExceptions: true });
const code = res.getResponseCode();
if (code < 200 || code >= 300) {
throw new Error('外部內容下載失敗,HTTP 狀態碼 ' + code);
}
blob = res.getBlob();
} else {
blob = fetchLineContent(message.id);
}
} catch (err) {
Logger.log('saveMessageContent 下載內容失敗: ' + (err && err.stack || err));
const errText = String((err && err.message) || err);
let reason;
if (errText.indexOf('CONTENT_NOT_READY') !== -1) {
// 大型影音檔在 LINE 伺服器端可能還在準備轉檔中(回傳202)
// 本程式設計為單次同步處理,不做輪詢等待(避免拖長單次執行時間撞到 GAS 6分鐘上限)
reason = '檔案較大,LINE 伺服器仍在準備中,請稍後再試一次';
} else if (/too large|exceed|response.{0,10}size/i.test(errText)) {
// ★修正(審查發現#5):UrlFetchApp 對回應大小約有 50MB 的硬性平台上限,若檔案真的
// 超過這個上限,fetch() 會在下載階段直接拋例外(連不到後面的 MAX_FILE_MB 判斷式),
// 若不特別辨識,使用者只會看到誤導性的「連結過期/網路異常」訊息。這裡用關鍵字比對
// 攔截這類例外(GAS 官方沒有公開固定的錯誤文字,用比對盡量涵蓋,非100%保證命中)。
reason = `檔案過大,超過系統可處理範圍(約 50MB),請壓縮或分割後再傳`;
} else {
reason = '下載內容失敗(連結可能已過期或網路異常)';
}
if (CONFIG.REPLY_ENABLED) sendReply(event, `⚠️ 檔案備份失敗:${fileName}\n原因:${reason}`);
return;
}
// 檔案大小檢查:在寫入 Drive 前做。★Blob 類別沒有 getSize() 方法,只能用 getBytes().length 換算位元組數
const sizeBytes = blob.getBytes().length;
const maxBytes = CONFIG.MAX_FILE_MB * 1024 * 1024;
if (sizeBytes > maxBytes) {
const sizeMB = (sizeBytes / 1024 / 1024).toFixed(1);
if (CONFIG.REPLY_ENABLED) {
sendReply(event, `⚠️ 檔案備份失敗:${fileName}\n原因:檔案 ${sizeMB} MB 超過上限 ${CONFIG.MAX_FILE_MB} MB`);
}
return;
}
// 決定目標資料夾(ROOT / 來源名稱 / YYYY-MM,依 CONFIG 開關決定要不要分層)
const folder = resolveFolder(event);
if (!folder) {
if (CONFIG.REPLY_ENABLED) {
sendReply(event, `⚠️ 檔案備份失敗:${fileName}\n原因:無法建立或找到目標資料夾(可能是 ROOT_FOLDER_ID 設定有誤,或系統目前忙碌中),請確認設定或稍後再試一次`);
}
return;
}
// ★修正(審查發現#3-嚴重):dedupeName(查詢是否同名)與 folder.createFile(實際建檔)
// 這段是典型的 check-then-act,先前完全沒有鎖保護,兩個並行請求可能同時查到「無同名檔案」
// 而各自用同一個候選檔名建檔,Drive 允許同資料夾內同名檔案並存,rename/skip/overwrite
// 三種 DUP_STRATEGY 保證的行為都會失效。這裡把「決定最終檔名 → 建檔」整段納入
// LockService.getScriptLock() 臨界區內,確保對同一資料夾是原子操作。
const lock = LockService.getScriptLock();
let locked = false;
let finalName; // ★注意:初始值刻意留 undefined、不設 null——null 專門代表「dedupeName 判定要
// skip」,若這裡也用 null 當初始值,dedupeName 本身若拋例外,finalName 會維持初始值 null,
// 跟真正的 skip 情況混淆,導致建檔失敗卻被誤判成「安靜略過」而不回覆錯誤訊息給使用者。
let file = null;
let createErr = null;
try {
lock.waitLock(15000);
locked = true;
finalName = dedupeName(folder, fileName);
if (finalName !== null) {
blob.setName(finalName);
file = folder.createFile(blob); // 併發下若 Drive 容量已滿等狀況,例外會在這裡拋出
}
} catch (err) {
createErr = err;
} finally {
if (locked) {
try {
lock.releaseLock();
} catch (err) {
Logger.log('saveMessageContent releaseLock 失敗(不影響結果): ' + err);
}
}
}
if (!locked) {
// ★修正(審查發現#4-嚴重):取不到鎖時「不」降級為無鎖繼續建檔——那樣會重現
// 「兩個並行請求都判斷無同名檔案而各自建檔」的問題。寧可這次備份失敗、提示使用者
// 重傳,也不要在背地裡產生資料不一致。
Logger.log('saveMessageContent 取得鎖逾時,為避免產生重複檔案,中止本次建檔: ' + createErr);
if (CONFIG.REPLY_ENABLED) {
sendReply(event, `⚠️ 檔案備份失敗:${fileName}\n原因:系統忙碌中(同時有多個檔案在處理),請稍後重新傳送此檔案`);
}
return;
}
if (finalName === null) {
// DUP_STRATEGY === 'skip' 且已存在同名檔案:安靜略過,不建檔、不回覆
return;
}
if (createErr || !file) {
// ★修正(審查發現#10-輕微):folder.createFile 失敗時(例如 Drive 容量已滿)不再只靠
// 外層 handleEvent 兜底顯示籠統的「未預期錯誤」,改在這裡辨識容量問題給更精確的提示。
Logger.log('saveMessageContent 建檔失敗: ' + (createErr && createErr.stack || createErr));
const errText = String((createErr && createErr.message) || createErr || '');
const reason = /quota|storage|space|容量|儲存空間/i.test(errText)
? 'Google Drive 容量已滿,請業主清理雲端硬碟空間後再試一次(單純重試無法解決)'
: '建立檔案時發生錯誤,請稍後再試一次';
if (CONFIG.REPLY_ENABLED) sendReply(event, `⚠️ 檔案備份失敗:${fileName}\n原因:${reason}`);
return;
}
const url = `https://drive.google.com/file/d/${file.getId()}/view`;
const sourceName = getSourceName(event);
const sourceId = getSourceKey(event.source || {}); // 見 logRow 內的說明:用來讓 /last /stats 不受改名影響
// 寫 Log(有設定 LOG_SHEET_ID 才會真的寫;隱私鐵則:只記檔名/大小/來源/時間/連結,絕不記錄訊息文字內容)
logRow(finalName, sizeBytes, sourceName, url, sourceId);
// 回覆確認:QUIET_MODE=true 時,成功不回覆、只有失敗才回覆(符合「群組不吵」的設計初衷)
if (CONFIG.REPLY_ENABLED && !CONFIG.QUIET_MODE) {
const pathParts = [];
if (CONFIG.SUBFOLDER_BY_SOURCE) pathParts.push(sourceName);
if (CONFIG.SUBFOLDER_BY_MONTH) pathParts.push(Utilities.formatDate(new Date(), CONFIG.TZ, 'yyyy-MM'));
const folderPath = pathParts.length > 0 ? pathParts.join(' / ') : 'Drive 根資料夾';
let text = `✅ 已收到檔案:${finalName}\n📦 ${formatSize(sizeBytes)} ・ 📁 ${folderPath}`;
if (CONFIG.REPLY_WITH_LINK) text += `\n🔗 ${url}`;
sendReply(event, text);
}
return { file, folder };
}
// fetchLineContent:下載 LINE 伺服器保管的訊息內容,GET .../message/{id}/content,回傳 Blob
function fetchLineContent(messageId) {
const url = `https://api-data.line.me/v2/bot/message/${messageId}/content`;
const res = UrlFetchApp.fetch(url, {
headers: { Authorization: 'Bearer ' + CONFIG.CHANNEL_ACCESS_TOKEN },
muteHttpExceptions: true,
});
const code = res.getResponseCode();
if (code === 202) {
// 查證結果:大型 video/audio 檔在伺服器端準備二進位資料期間會回 202(尚未就緒),
// 正式作法應改呼叫 .../content/transcoding 端點輪詢確認完成後再重試下載,
// 但本程式設計為單次同步處理(GAS 單次執行上限 6 分鐘),不在此做輪詢等待,
// 直接視為「尚未就緒」丟出例外,由呼叫端提示使用者稍後再試一次即可。
throw new Error('CONTENT_NOT_READY');
}
if (code < 200 || code >= 300) {
throw new Error('下載失敗,HTTP 狀態碼 ' + code);
}
return res.getBlob();
}
// ============================================================================
// 資料夾管理
// ============================================================================
// resolveFolder:依設定組出目標資料夾(ROOT / 來源資料夾(綁定) / YYYY-MM),用 Lock+Cache 防併發重複建立
//
// ★修正(本輪重構,取代先前「顯示名稱+ID尾碼」的做法):先前用「來源顯示名稱+ID尾碼」當識別鍵
// 有兩個代價:(1) Drive 裡一堆「XXX_a1b2c3」的醜資料夾名,業主每天要在 Drive 裡翻找檔案,可讀性
// 很重要;(2) 更嚴重的是,識別鍵本身仍含有「會變動」的顯示名稱——群組只要改名(LINE隨時可改,
// 很常見),getSourceName() 回傳的字串就變了,resolveFolder() 找不到舊資料夾 → 建一個全新資料夾,
// 之後的檔案存進新資料夾,舊資料夾裡幾個月的備份就被孤立在旁邊,/folder /last /stats 也全部只看
// 得到新的,業主完全不會發現,直到某天要找舊檔案才發現「怎麼有兩個資料夾」。
// 改法:用不可偽造、永不變動的 sourceKey(groupId/roomId/userId,見 getSourceKey)當唯一識別鍵,
// 把「這個對話對應哪個 Drive 資料夾」的對應關係持久化進 PropertiesService(綁定表,見 getBinding/
// setBinding/clearBinding),資料夾名稱只是顯示用的標籤——改名不會孤立舊檔,撞名不可能發生
// (不同 sourceKey 絕不會綁到同一個 folderId),名稱也能保持乾淨(只有真撞名才加尾碼,見
// createOrClaimSourceFolder)。SUBFOLDER_BY_SOURCE=false 時完全不碰綁定表,行為維持原樣
// (只有 ROOT / YYYY-MM 兩層)。
function resolveFolder(event) {
const source = event.source || {};
const sourceKey = CONFIG.SUBFOLDER_BY_SOURCE ? getSourceKey(source) : '';
const monthName = CONFIG.SUBFOLDER_BY_MONTH ? Utilities.formatDate(new Date(), CONFIG.TZ, 'yyyy-MM') : null;
// 快取 key 刻意用 sourceKey(永不變動的ID)而非顯示名稱:即使群組改名,快取仍命中同一份
// 對應關係;命中時完全不用進鎖,大幅降低「一次傳多檔」時的鎖等待與 Drive 查詢次數。
const cacheKey = 'folder::' + CONFIG.ROOT_FOLDER_ID + '::' + (sourceKey || '-') + '::' + (monthName || '-');
const cache = CacheService.getScriptCache();
const cachedId = cache.get(cacheKey);
if (cachedId) {
try {
const f = DriveApp.getFolderById(cachedId);
if (!f.isTrashed()) return f;
Logger.log('resolveFolder 快取的資料夾已被丟到垃圾桶,重新解析: ' + cachedId);
} catch (err) {
// 快取的資料夾可能已被人手動刪除/移動,忽略錯誤、往下重新解析一次
Logger.log('resolveFolder 快取的 folderId 已失效,重新解析: ' + err);
}
}
// ★修正(審查發現#5-中等,double-checked-locking 不對稱):先前這裡有一段「鎖外預查」,
// 綁定有效就直接沿用、跳過鎖內重新驗證;但下面的 LockService 無論如何都會取鎖(只有快取命中
// 才會在上面提早 return,鎖外預查並不會少取一次鎖,只會讓「鎖內不重查」這個 TOCTOU 缺口出現:
// 鎖外查到的綁定,可能在鎖外檢查之後、鎖內實際使用之前就被別的並行請求改綁/業主整理 Drive
// 弄失效,這個請求卻仍會沿用舊資料夾,檔案就此存到已經不是「目前綁定對象」的地方。既然鎖
// 反正一定會取,就不再做這段沒有實益、卻會製造漏洞的鎖外預查,全部改在鎖內用同一套邏輯
// (revalidateBinding,見下方)驗證,兩個分支(有無預查結果)也就不再不對稱。
//
// 地雷#6:一次傳多檔會觸發多個並行 webhook,若不鎖,可能同時判斷「資料夾不存在」而各自建立一個,
// 造成重複資料夾;月份子資料夾一樣有這個風險。這是「前一輪修好的併發保護」不能退化的部分。
const lock = LockService.getScriptLock();
try {
// 從10秒拉長到15秒(審查發現#4):logRow() 與本函式共用同一把 GAS 全域 script lock,
// 月初一次湧入多檔時容易排隊逼近舊的10秒上限,適度拉長緩解、GAS單次執行仍有6分鐘可用。
lock.waitLock(15000); // ★waitLock逾時是「拋出例外」而非回傳false(跟tryLock不同)
} catch (err) {
// ★修正(審查發現#4-嚴重):取不到鎖時「不」再降級為無鎖繼續執行——那樣兩個並行請求
// 可能同時判斷「資料夾不存在」而各自建立,反而重現地雷#6本來要防止的重複資料夾問題。
// 改為直接回傳 null,讓呼叫端提示使用者稍後重試,寧可這次操作失敗也不要資料不一致。
Logger.log('resolveFolder 取得鎖逾時,為避免建出重複資料夾,中止本次解析: ' + err);
return null;
}
try {
const root = DriveApp.getFolderById(CONFIG.ROOT_FOLDER_ID);
let folder = root;
if (CONFIG.SUBFOLDER_BY_SOURCE) {
if (sourceKey) {
// ★findings#5/#6 修正:鎖內一律用同一套邏輯(revalidateBinding)重新驗證目前的綁定,
// 不信任任何鎖外算出的結果,等鎖期間也可能已經被另一個並行請求建好/改掉綁定,一定要
// 在鎖內重查一次。revalidateBinding 內部偵測到失效時會正確成對清除正反向綁定
// (findings#6:不留殘餘反向索引),也會套用 getBinding 內建的 ROOT 一致性檢查
// (findings#1:ROOT 換過的舊綁定視為失效)。
const boundFolder = revalidateBinding(sourceKey);
if (boundFolder) {
folder = boundFolder;
} else {
const sourceName = getSourceName(event);
folder = createOrClaimSourceFolder(root, sourceKey, sourceName);
}
} else {
// sourceKey 拿不到(理論上不會發生,因為 message 事件的 source.type 只會是
// group/room/user 三者之一):沒有可靠的識別鍵可綁,退化成單純用名稱找/建,不寫綁定。
const sourceName = getSourceName(event);
folder = getOrCreateFolder(root, sourceName);
}
}
if (monthName) folder = getOrCreateFolder(folder, monthName);
cache.put(cacheKey, folder.getId(), 21600); // 6小時=CacheService允許的上限秒數,不可再調高
return folder;
} catch (err) {
Logger.log('resolveFolder 建立/查詢資料夾失敗: ' + (err && err.stack || err));
return null;
} finally {
try {
lock.releaseLock();
} catch (err) {
Logger.log('resolveFolder releaseLock 失敗(不影響結果): ' + err);
}
}
}
// createOrClaimSourceFolder:在 root 底下取得/建立這個來源的資料夾,並寫入綁定(必須在
// resolveFolder 的 LockService 臨界區內呼叫)。只有「真的撞名」(同名資料夾已被別的來源綁定)
// 才會加 ID 尾碼,其餘情況都沿用乾淨名稱——這是本輪重構要求「99%情況資料夾名保持乾淨」的核心。
function createOrClaimSourceFolder(root, sourceKey, sourceName) {
const iter = root.getFoldersByName(sourceName);
let sawAny = false;
while (iter.hasNext()) {
sawAny = true;
const candidate = iter.next();
const owner = getBindingOwner(candidate.getId());
if (!owner || owner === sourceKey) {
// 沒被任何來源綁定(業主可能事先在 Drive 手動建好同名資料夾)、或已綁定給「同一個」
// 來源(正常情況,例如快取過期後重新解析)→ 直接沿用,不建新的。
setBinding(sourceKey, candidate.getId());
return candidate;
}
// 這個同名資料夾已被「別的」sourceId 綁定,繼續看還有沒有下一個同名的可沿用
}
let folder;
if (!sawAny) {
// 完全沒有同名資料夾:正常情況,用乾淨名稱直接建立
folder = root.createFolder(sourceName);
} else {
// 迴圈跑完仍找不到可沿用的資料夾:代表所有同名資料夾都已被「別的」來源占用——這才是
// 真撞名(例如兩個不同的 LINE 群組剛好取了一樣的名字),用「名稱_ID末6碼」建立新的。
const idSuffix = sourceKey.slice(-6);
const newName = `${sourceName}_${idSuffix}`;
Logger.log(`createOrClaimSourceFolder 偵測到真撞名:「${sourceName}」已被其他來源占用,改建立「${newName}」`);
folder = root.createFolder(newName);
}
try {
setBinding(sourceKey, folder.getId());
} catch (err) {
// ★findings#4-輕微:資料夾已經建立成功,但綁定寫入(PropertiesService)失敗,會變成一個
// 沒有任何綁定紀錄的孤兒資料夾(下次同來源再傳檔時,上面的 getFoldersByName 迴圈能自動
// 找到並接管沿用,屬於能自我修復的邊角案例,非必要不做 rollback)。這裡至少打一行明顯的
// log 標註 folderId,方便業主日後對照垃圾桶/資料夾列表人工核對。
Logger.log(`createOrClaimSourceFolder ⚠️資料夾已建立但綁定寫入失敗,folderId=${folder.getId()},sourceKey=${sourceKey},err=${err}`);
throw err; // 讓外層 resolveFolder 的 try/catch 照舊處理(回傳null,這次備份失敗但至少留下明確線索)
}
return folder;
}
// revalidateBinding:在 LockService 臨界區「內」重新驗證 sourceKey 目前的綁定是否仍然有效
// (存在、非垃圾桶、且綁定當下的 ROOT 與目前的 CONFIG.ROOT_FOLDER_ID 一致,見 getBinding),
// 有效就回傳該 Folder,否則回傳 null 並確保正反向綁定成對清除。
// ★findings#5/#6:resolveFolder 內「鎖外已查到綁定」與「鎖外沒查到綁定」原本是兩段不對稱的
// 邏輯,只有後者在鎖內做了 double-check;這裡抽成共用 helper,讓鎖內只有這一套驗證邏輯,
// 兩種情況都必須通過同一次重新驗證才能被信任使用。
function revalidateBinding(sourceKey) {
const recheckId = getBinding(sourceKey);
if (!recheckId) return null;
try {
const f = DriveApp.getFolderById(recheckId);
if (f.isTrashed()) {
Logger.log(`revalidateBinding 綁定的資料夾(${recheckId})已被丟到垃圾桶,清除綁定: sourceKey=${sourceKey}`);
clearBinding(sourceKey, recheckId); // findings#6:偵測到失效要成對清除,不留殘餘反向索引
return null;
}
return f;
} catch (err) {
// 資料夾已失效(例如被永久刪除):一併清除殘留的正反向綁定,避免舊反向索引誤導未來的
// createOrClaimSourceFolder 撞名判斷(findings#6情境)
Logger.log(`revalidateBinding 綁定的資料夾(${recheckId})已失效(${err}),清除綁定: sourceKey=${sourceKey}`);
clearBinding(sourceKey, recheckId);
return null;
}
}
// ── 來源ID → 資料夾ID 綁定表(PropertiesService,永久保存;不能用 CacheService,最多只存6小時)──
// 資料結構:
// bind_src_ → "folderId|rootFolderId"
// 正向:這個對話存到哪個資料夾,並附帶記錄「建立/最後一次確認綁定時的 ROOT_FOLDER_ID」
// (★findings#1-嚴重:只存 folderId 不夠——業主若把 CONFIG.ROOT_FOLDER_ID 換成另一個
// Drive 資料夾,舊的來源資料夾實體仍在「舊 ROOT」底下、沒被刪除,isTrashed() 也判斷不出
// 來,若沒有把 rootFolderId 一起記錄比對,之後的檔案會靜默繼續存進「舊 ROOT」底下業主
// 已經不認得的位置。getBinding() 讀取時會比對這欄,不符就視為失效並清除綁定)
// bind_folder_ → sourceId (反向:這個資料夾目前被哪個對話占用,用來偵測真撞名)
// 正向與反向兩者永遠成對寫入/刪除,維持一致;folderId/rootFolderId 不含個資,sourceId 是
// LINE 內部識別碼也不含個資。
// ★相容性:這是本輪新增的欄位,此次更新前寫入的舊綁定值只有裸 folderId(沒有 "|rootFolderId"
// 這段),getBinding() 解析出的 rootAtBind 會是空字串,必定與目前非空的 CONFIG.ROOT_FOLDER_ID
// 不符,因此舊綁定在這次更新後首次被讀到時會被判定失效並清除——但這不會造成資料夾重複或遺失:
// createOrClaimSourceFolder 會用 getFoldersByName 找到那個(仍然存在、只是暫時失去綁定的)
// 舊資料夾並直接接管沿用,寫回新格式的綁定,屬於一次性、可自我修復的過渡代價。
// getBinding:查詢某個 sourceId 目前綁定的 folderId;若綁定記錄的 ROOT 與目前 CONFIG.ROOT_FOLDER_ID
// 不一致(業主換過 ROOT,findings#1情境A),視為失效、清除綁定並回傳 null,讓呼叫端重新建立/尋找。
// 沒有綁定,或綁定失效,一律回傳 null。
function getBinding(sourceKey) {
if (!sourceKey) return null;
const raw = PropertiesService.getScriptProperties().getProperty('bind_src_' + sourceKey);
if (!raw) return null;
const sep = raw.indexOf('|');
const folderId = sep === -1 ? raw : raw.slice(0, sep);
const rootAtBind = sep === -1 ? '' : raw.slice(sep + 1);
if (rootAtBind !== CONFIG.ROOT_FOLDER_ID) {
Logger.log(`getBinding 偵測到綁定的 ROOT 已變更(綁定時="${rootAtBind}",目前="${CONFIG.ROOT_FOLDER_ID}"),` +
`視為失效並清除綁定: sourceKey=${sourceKey}, folderId=${folderId}`);
clearBinding(sourceKey, folderId);
return null;
}
return folderId;
}
// setBinding:寫入正向綁定(來源→資料夾,附帶目前的 ROOT_FOLDER_ID)與反向索引(資料夾→來源),
// 兩者一定要同時寫
function setBinding(sourceKey, folderId) {
const props = PropertiesService.getScriptProperties();
props.setProperty('bind_src_' + sourceKey, folderId + '|' + CONFIG.ROOT_FOLDER_ID);
props.setProperty('bind_folder_' + folderId, sourceKey);
}
// clearBinding:資料夾已失效(被刪除/丟垃圾桶)時,把正向與反向綁定一起清掉
function clearBinding(sourceKey, folderId) {
const props = PropertiesService.getScriptProperties();
if (sourceKey) props.deleteProperty('bind_src_' + sourceKey);
if (folderId) props.deleteProperty('bind_folder_' + folderId);
}
// getBindingOwner:反查某個資料夾ID目前綁定給哪個 sourceId,用來判斷同名資料夾是否已被別人占用
function getBindingOwner(folderId) {
return PropertiesService.getScriptProperties().getProperty('bind_folder_' + folderId);
}
// resetBindings:清空所有「來源ID → 資料夾ID」綁定(正向+反向索引)。
// ⚠️ 這是獨立函式,不會被 webhook 自動呼叫,需要在 GAS 編輯器手動選取本函式執行。
// 使用時機:業主自己手動搬動/合併/刪除了 Drive 裡的來源資料夾之後,導致綁定表記錄的
// folderId 已經對不上業主現在想要的結構;或想讓所有來源在下一次傳檔時,重新依照
// createOrClaimSourceFolder 的規則(沿用同名未綁定資料夾,或撞名才加尾碼)配對一次。
// 執行後不會刪除任何 Drive 資料夾或檔案,只清空 ScriptProperties 裡的對應關係;下一次
// 每個來源傳檔案時,resolveFolder() 會依規則重新尋找或建立資料夾(並寫回新的綁定)。
function resetBindings() {
const props = PropertiesService.getScriptProperties();
const all = props.getProperties();
let count = 0;
Object.keys(all).forEach((k) => {
if (k.indexOf('bind_src_') === 0 || k.indexOf('bind_folder_') === 0) {
props.deleteProperty(k);
count++;
}
});
Logger.log(`resetBindings 已清空 ${count} 筆綁定資料(來源→資料夾的對應關係)。` +
'下次每個來源傳檔案時,會依規則重新尋找或建立資料夾。');
}
// getOrCreateFolder:在 parent 資料夾內找同名子資料夾,找不到才建立
function getOrCreateFolder(parent, name) {
// ★注意:一定要呼叫 Folder 物件的方法(parent.getFoldersByName / parent.createFolder),
// 範圍才會限定在 parent 這個資料夾內。絕不可寫成 DriveApp.getFoldersByName(name) /
// DriveApp.createFolder(name),那樣搜尋範圍會變成整個雲端硬碟、建立會跑到 Drive 根目錄,
// 完全脫離 ROOT_FOLDER_ID 底下的結構。
const iter = parent.getFoldersByName(name);
if (iter.hasNext()) {
return iter.next(); // FolderIterator 沒有 .length,只能用 hasNext()/next() 走訪
}
return parent.createFolder(name);
}
// ============================================================================
// 命名
// ============================================================================
// buildFileName:依訊息類型組出檔名;file 用原始檔名,其餘用「類型_時間戳_ID尾碼」
function buildFileName(message, event) {
const ts = Utilities.formatDate(new Date(), CONFIG.TZ, 'yyyyMMdd_HHmmss');
// 地雷#14:LINE一次傳多張圖時,每張仍是獨立的 message event,但時間可能落在同一秒,
// 只靠時間戳記會撞名;接上訊息ID末6碼可確保同一秒內連續多檔也不會重複。
const idSuffix = String(message.id).slice(-6);
switch (message.type) {
case 'file':
// ★修正(審查發現#7-中等):message.fileName 是傳送方完全可控的原始檔名,先前直接
// 沿用未經任何清理。若檔名含 Windows 檔案系統不允許的字元(/ \ : * ? " < > |),
// 檔案雖能成功存進 Drive 網頁版,但業主用 Google Drive 桌面版同步到本機時,這類
// 檔名在本機端會同步失敗或被跳過,備份「看起來存在」實際上業主本機拿不到。
return sanitizeFileName(message.fileName);
case 'image':
return `IMG_${ts}_${idSuffix}.jpg`;
case 'video':
return `VID_${ts}_${idSuffix}.mp4`;
case 'audio':
return `AUD_${ts}_${idSuffix}.m4a`;
default:
return `FILE_${ts}_${idSuffix}`;
}
}
// dedupeName:依 DUP_STRATEGY 處理同名檔案,回傳最終應使用的檔名(skip 時回傳 null)
function dedupeName(folder, name) {
// ★folder.getFilesByName 是 Folder 物件的方法,只在該資料夾內查找同名檔案(範圍正確)
const existing = folder.getFilesByName(name);
if (!existing.hasNext()) return name; // 沒有同名檔案,直接用原名
if (CONFIG.DUP_STRATEGY === 'skip') {
return null; // 呼叫端看到 null 要中止建檔流程,不覆蓋也不重複儲存
}
if (CONFIG.DUP_STRATEGY === 'overwrite') {
// 覆蓋策略:把所有同名舊檔丟進垃圾桶,新檔案沿用同一個檔名重新建立
const iter = folder.getFilesByName(name);
while (iter.hasNext()) iter.next().setTrashed(true);
return name;
}
// 預設 'rename':在副檔名前加 (2)(3)…,直到找到沒被佔用的名稱為止
const dotIndex = name.lastIndexOf('.');
const base = dotIndex > 0 ? name.slice(0, dotIndex) : name;
const ext = dotIndex > 0 ? name.slice(dotIndex) : '';
let n = 2;
let candidate;
do {
candidate = `${base}(${n})${ext}`;
n++;
} while (folder.getFilesByName(candidate).hasNext() && n < 1000); // n<1000是防呆上限,避免理論上的無窮迴圈
return candidate;
}
// ============================================================================
// 來源資訊
// ============================================================================
// getSourceKey:依 source.type 判斷取出不可偽造、永不變動的來源識別碼(groupId/roomId/userId)。
// ★只用來判斷型別、不能用猜的(例如單純 source.groupId || source.roomId || source.userId
// 順序湊出來的值理論上跟這裡結果相同,但明確依 type 分派可讀性更好,也是本輪規格要求的寫法)。
// 這個值只拿去當綁定表(bind_src_/bind_folder_)與 Log 比對的 key,不是顯示名稱。
function getSourceKey(source) {
if (!source) return '';
if (source.type === 'group') return source.groupId || '';
if (source.type === 'room') return source.roomId || '';
if (source.type === 'user') return source.userId || '';
return '';
}
// getSourceName:取得對話來源的顯示名稱(群組名/好友暱稱/聊天室代稱),失敗有 fallback,結果快取
//
// ★本輪重構:先前(審查發現#11)為了防止「改名/撞名冒充拿到別人資料夾連結」,一律在顯示名稱
// 後面加 sourceId 末6碼當唯一後綴,這確實擋住了撞名,但代價是 Drive 裡一堆「XXX_a1b2c3」的醜
// 資料夾名,而且識別鍵仍然含有「會變動」的顯示名稱——群組一改名,這裡回傳的字串就變了,
// resolveFolder() 會誤判成新對話而建出孤立的新資料夾(真正的資料遺失風險,比忘記加尾碼更嚴重)。
// 修法:撞名/改名孤立的風險已改由 resolveFolder() 的「來源ID→資料夾ID 綁定表」在資料層徹底解決
// (用 sourceKey 而非顯示名稱當識別鍵,見 getSourceKey/resolveFolder),這裡查詢成功時可以放心
// 直接回傳乾淨的顯示名稱,不用再加尾碼。查詢失敗的 fallback('群組_'+idSuffix 等)維持加尾碼
// 不變——那時候根本沒有名字可用,尾碼是必要的、唯一能區分不同來源的辦法。
function getSourceName(event) {
const source = event.source || {};
const cache = CacheService.getScriptCache();
if (source.type === 'group') {
const idSuffix = source.groupId.slice(-6);
const cacheKey = 'srcname_group_' + source.groupId;
const cached = cache.get(cacheKey);
if (cached) return cached;
try {
// 注意:要讓 bot 能被邀進群組並成功查到摘要,除了 LINE Official Account Manager →
// 設定 → 帳號設定 → 功能切換 → 加入群組或多人聊天室 → 選「接受邀請加入群組或多人聊天室」,
// 還要另外在 LINE Developers Console → Messaging API 分頁打開「Allow bot to join
// group chats」(官方預設是關閉的)。這是兩個獨立介面各自的獨立設定,兩個都要開。
const res = UrlFetchApp.fetch(`https://api.line.me/v2/bot/group/${source.groupId}/summary`, {
headers: { Authorization: 'Bearer ' + CONFIG.CHANNEL_ACCESS_TOKEN },
muteHttpExceptions: true,
});
if (res.getResponseCode() === 200) {
// ★不再附加ID尾碼:撞名/改名孤立風險已由 resolveFolder() 的綁定表在資料層解決,
// 這裡的顯示名稱只是標籤,可以保持乾淨(本輪重構,見上方函式註解)
const name = sanitizeFolderName(JSON.parse(res.getContentText()).groupName);
cache.put(cacheKey, name, 21600);
return name;
}
} catch (err) {
Logger.log('getSourceName 取群組名稱失敗: ' + err);
}
// fallback:bot沒加好友/沒權限/尚未成為群組成員時,summary API會失敗,改用群組ID尾碼,不讓整體流程掛掉
const fallback = '群組_' + idSuffix;
// 短時間快取fallback名稱(審查發現#4):避免同一批次多檔並行處理時,每個並行請求都重打一次
// 會失敗的LINE API,白白拉長處理時間、加大resolveFolder()排隊取鎖逾時的風險;
// 5分鐘後自動失效,之後bot若已成功加入群組會自動改用真正查到的名稱,不影響正確性。
cache.put(cacheKey, fallback, 300);
return fallback;
}
if (source.type === 'user') {
const idSuffix = source.userId.slice(-6);
const cacheKey = 'srcname_user_' + source.userId;
const cached = cache.get(cacheKey);
if (cached) return cached;
try {
const res = UrlFetchApp.fetch(`https://api.line.me/v2/bot/profile/${source.userId}`, {
headers: { Authorization: 'Bearer ' + CONFIG.CHANNEL_ACCESS_TOKEN },
muteHttpExceptions: true,
});
if (res.getResponseCode() === 200) {
// ★不再附加ID尾碼:理由同上方群組區塊註解(本輪重構)
const name = sanitizeFolderName(JSON.parse(res.getContentText()).displayName);
cache.put(cacheKey, name, 21600);
return name;
}
} catch (err) {
Logger.log('getSourceName 取好友名稱失敗: ' + err);
}
// fallback:對方可能還沒加bot為好友(profile API會失敗),改用userId尾碼
const fallback = '好友_' + idSuffix;
cache.put(cacheKey, fallback, 300); // 短時間快取,理由同上(審查發現#4)
return fallback;
}
if (source.type === 'room') {
// 查證結果:LINE平台的 room(多人聊天室)本身沒有名稱概念,也完全沒有對應的查詢API,
// 因此不嘗試呼叫任何API,直接用固定格式命名,這是查證後確認的唯一正確作法。
return '聊天室_' + source.roomId.slice(-6);
}
return '未知來源';
}
// ============================================================================
// 文字指令
// ============================================================================
// handleCommand:文字指令分派(大小寫不拘、支援中英別名);非指令的一般文字完全不回應
function handleCommand(event) {
const cmd = normalizeCommand(event.message.text);
switch (cmd) {
case '/help':
case '/說明':
sendReply(event, buildHelpText());
return;
case '/id':
sendReply(event, buildIdText(event));
return;
case '/folder':
case '/資料夾':
handleFolderCommand(event);
return;
case '/last':
case '/最近':
handleLastCommand(event);
return;
case '/stats':
case '/統計':
handleStatsCommand(event);
return;
case '/ping':
sendReply(event, 'pong');
return;
default:
// 非指令的一般文字訊息 → 完全不回應(群組裡不能吵)
return;
}
}
// buildHelpText:組出 /help 指令的說明文字
function buildHelpText() {
return [
'📖 可用指令:',
'/help 或 /說明 → 顯示這份說明',
'/id → 取得本對話的識別碼(設定白名單用)',
'/folder 或 /資料夾 → 本對話的 Drive 資料夾連結',
'/last 或 /最近 → 最近 5 筆備份紀錄',
'/stats 或 /統計 → 本月備份數量與總大小',
'/ping → 確認機器人是否還活著',
].join('\n');
}
// buildIdText:組出 /id 指令回覆的文字(含 sourceId 與對話類型)
function buildIdText(event) {
const source = event.source || {};
const typeLabel = { group: '群組', room: '聊天室', user: '私訊(好友)' }[source.type] || '未知';
const id = source.groupId || source.roomId || source.userId || '';
return `🆔 本對話識別碼:\n${id}\n類型:${typeLabel}\n\n若要限制此機器人只在特定對話運作,把這串ID加進 CONFIG.ALLOWLIST 陣列。`;
}
// handleFolderCommand:回覆本對話對應的 Drive 資料夾連結;改用綁定直接取得,不再用名稱搜尋。
// 若偵測到目前的群組名稱跟資料夾名稱不一致(多半是群組改名了),回覆會順帶提示,但**不會**
// 自動幫忙改資料夾名稱——業主可能自己整理過資料夾命名,自動改名會覆蓋他的整理(本輪重構,
// 見 resolveFolder() 上方註解對「群組改名」的設計說明)。
function handleFolderCommand(event) {
const folder = resolveFolder(event);
if (!folder) {
sendReply(event, '⚠️ 無法取得本對話的資料夾,請確認 ROOT_FOLDER_ID 設定是否正確。');
return;
}
let text = `📁 本對話的 Drive 資料夾:\nhttps://drive.google.com/drive/folders/${folder.getId()}`;
if (CONFIG.SUBFOLDER_BY_SOURCE) {
try {
// folder 若還有月份子層,要往上取一層才是「來源層」的資料夾,比對名稱才有意義
let sourceFolder = folder;
if (CONFIG.SUBFOLDER_BY_MONTH) {
const parents = folder.getParents();
if (parents.hasNext()) sourceFolder = parents.next();
}
const currentName = getSourceName(event);
const folderName = sourceFolder.getName();
if (folderName !== currentName) {
// ★修正(審查發現#2-中等):造成資料夾名稱與目前顯示名稱不同,有兩種完全不同的原因——
// (1) LINE 群組真的改名了;(2) 業主自己在 Drive 手動把資料夾改了個他喜歡的名字(本函式
// 上方註解已承認這種情況會發生)。程式無法百分之百分辨是哪一種(沒有保留「綁定建立當下
// 所用的顯示名稱」可比對),先前一律顯示「群組名稱已變更為...」,在情境(2)時會把「群組
// 從頭到尾沒改名、是資料夾被業主自己改掉」誤導成「偵測到對方群組改名」。改成中性敘述,
// 不指定成因,兩種可能都列出。
Logger.log(`handleFolderCommand 偵測到資料夾名稱與目前來源顯示名稱不同:資料夾名稱「${folderName}」≠ 目前名稱「${currentName}」(沿用綁定的舊資料夾,不自動改名)`);
text += `\n\nℹ️ 目前備份資料夾名稱為「${folderName}」,與目前 LINE 顯示名稱「${currentName}」不同` +
`(可能是群組已改名,或此資料夾曾被手動重新命名)`;
}
} catch (err) {
// 比對失敗不影響主要回覆(資料夾連結本身已經是對的)
Logger.log('handleFolderCommand 比對來源名稱時發生例外(不影響主要回覆): ' + err);
}
}
sendReply(event, text);
}
// handleLastCommand:回覆最近 5 筆備份檔名(優先讀 Log 試算表,沒設定就改讀資料夾最新 5 檔)
function handleLastCommand(event) {
const sourceName = getSourceName(event);
const sourceKey = getSourceKey(event.source || {});
if (CONFIG.LOG_SHEET_ID) {
try {
const ss = SpreadsheetApp.openById(CONFIG.LOG_SHEET_ID);
const sheet = ss.getSheetByName('Log');
if (sheet) {
// 欄位:時間,檔名,大小(bytes),來源,連結,sourceId(第6欄是本輪新增,用不會變動的ID
// 比對,不受群組改名影響)。★相容性:舊試算表寫入的舊資料列沒有這一欄,r[5]會是
// undefined,此時退回用顯示名稱比對,欄位數不足時仍能正常運作,不會直接爆掉。
const values = sheet.getDataRange().getValues();
const rows = values.slice(1).filter((r) => (r[5] ? r[5] === sourceKey : r[3] === sourceName));
const last5 = rows.slice(-5).reverse();
if (last5.length > 0) {
const lines = last5.map((r) => `・${r[1]}(${formatSize(Number(r[2]) || 0)})`);
sendReply(event, `📋 最近${last5.length}筆備份:\n` + lines.join('\n'));
return;
}
}
} catch (err) {
Logger.log('handleLastCommand 讀 Log 試算表失敗,改用資料夾列表: ' + err);
}
}
// 沒設定 Log 試算表,或讀取失敗:改讀資料夾內最新 5 個檔案
const folder = resolveFolder(event);
if (!folder) {
sendReply(event, '📋 目前還沒有任何備份紀錄。');
return;
}
const files = [];
const iter = folder.getFiles();
while (iter.hasNext()) files.push(iter.next());
files.sort((a, b) => b.getDateCreated() - a.getDateCreated());
const top5 = files.slice(0, 5);
if (top5.length === 0) {
sendReply(event, '📋 這個資料夾還沒有備份紀錄。');
return;
}
const lines = top5.map((f) => `・${f.getName()}(${formatSize(f.getSize())})`);
sendReply(event, `📋 最近${top5.length}筆備份:\n` + lines.join('\n'));
}
// handleStatsCommand:回覆本月備份數量與總大小(優先讀 Log 試算表,沒設定就改統計資料夾內檔案)
function handleStatsCommand(event) {
const sourceName = getSourceName(event);
const sourceKey = getSourceKey(event.source || {});
const monthLabel = Utilities.formatDate(new Date(), CONFIG.TZ, 'yyyy-MM');
if (CONFIG.LOG_SHEET_ID) {
try {
const ss = SpreadsheetApp.openById(CONFIG.LOG_SHEET_ID);
const sheet = ss.getSheetByName('Log');
if (sheet) {
// 相容性同 handleLastCommand:r[5](sourceId)不存在時退回用顯示名稱比對
const values = sheet.getDataRange().getValues();
const rows = values.slice(1).filter((r) =>
(r[5] ? r[5] === sourceKey : r[3] === sourceName) && String(r[0]).indexOf(monthLabel) === 0);
const totalBytes = rows.reduce((sum, r) => sum + (Number(r[2]) || 0), 0);
sendReply(event, `📊 ${monthLabel} 本對話備份統計:\n數量:${rows.length} 筆\n總大小:${formatSize(totalBytes)}`);
return;
}
} catch (err) {
Logger.log('handleStatsCommand 讀 Log 試算表失敗,改用資料夾統計: ' + err);
}
}
// 沒設定 Log 試算表:改直接統計資料夾內的檔案。
// ★修正(審查發現#1-中等):resolveFolder() 回傳的資料夾範圍完全依 CONFIG.SUBFOLDER_BY_SOURCE
// 與 CONFIG.SUBFOLDER_BY_MONTH 這兩個開關而定,並非天生就等於「本對話本月」——只有兩者都是
// true 時,folder.getFiles() 才精確等於「本對話本月」的檔案;只要有一個是 false,folder 內
// 就會混進其他月份甚至其他對話的歷史檔案,若仍不加分辨地全部加總、卻標示成「本月」數字,
// 會嚴重誤導使用者。做法:SUBFOLDER_BY_MONTH 為 false 時,folder 本身沒有依月份分層,
// 改用 getDateCreated() 逐一過濾出真正落在本月的檔案;SUBFOLDER_BY_SOURCE 為 false 時,
// folder 本身就是跨對話共用,統計結果如實標示為「所有對話合計」,不誤稱「本對話」。
const folder = resolveFolder(event);
if (!folder) {
sendReply(event, '📊 目前還沒有任何備份紀錄。');
return;
}
let count = 0;
let totalBytes = 0;
const iter = folder.getFiles();
const needMonthFilter = !CONFIG.SUBFOLDER_BY_MONTH; // 資料夾未依月份分層,要自己依建立日期過濾
while (iter.hasNext()) {
const f = iter.next();
if (needMonthFilter) {
const createdMonth = Utilities.formatDate(f.getDateCreated(), CONFIG.TZ, 'yyyy-MM');
if (createdMonth !== monthLabel) continue;
}
count++;
totalBytes += f.getSize();
}
const scopeLabel = CONFIG.SUBFOLDER_BY_SOURCE
? `${monthLabel} 本對話`
: `${monthLabel}(未依對話分層,此為 ROOT 資料夾內所有對話合計)`;
sendReply(event, `📊 ${scopeLabel}備份統計:\n數量:${count} 筆\n總大小:${formatSize(totalBytes)}`);
}
// ============================================================================
// LINE 訊息傳送
// ============================================================================
// replyMessage:呼叫 LINE reply API 回覆一則文字訊息,成功回傳 true,失敗記 log 並回傳 false
function replyMessage(replyToken, text) {
try {
const res = UrlFetchApp.fetch('https://api.line.me/v2/bot/message/reply', {
method: 'post',
contentType: 'application/json',
headers: { Authorization: 'Bearer ' + CONFIG.CHANNEL_ACCESS_TOKEN },
payload: JSON.stringify({ replyToken: replyToken, messages: [{ type: 'text', text: text }] }),
muteHttpExceptions: true,
});
const code = res.getResponseCode();
if (code >= 200 && code < 300) return true;
// 地雷#5:replyToken 只能用一次、約1分鐘內有效,逾時/用過會在此失敗,由呼叫端 fallback 到 pushMessage
Logger.log(`replyMessage 失敗 HTTP ${code}: ${res.getContentText()}`);
return false;
} catch (err) {
Logger.log('replyMessage 發生例外: ' + err);
return false;
}
}
// pushMessage:呼叫 LINE push API 主動推播一則文字訊息,是 replyToken 失效時的備援
function pushMessage(to, text) {
try {
// ⚠️ 注意:reply 不計入 LINE 免費方案的每月訊息額度,但 push 會計入(每月200則)。
// 若這個 fallback 被頻繁觸發(例如群組回覆常常過期),會悄悄消耗業主的免費額度,值得留意。
const res = UrlFetchApp.fetch('https://api.line.me/v2/bot/message/push', {
method: 'post',
contentType: 'application/json',
headers: { Authorization: 'Bearer ' + CONFIG.CHANNEL_ACCESS_TOKEN },
payload: JSON.stringify({ to: to, messages: [{ type: 'text', text: text }] }),
muteHttpExceptions: true,
});
const code = res.getResponseCode();
if (code >= 200 && code < 300) return true;
Logger.log(`pushMessage 失敗 HTTP ${code}: ${res.getContentText()}`);
return false;
} catch (err) {
Logger.log('pushMessage 發生例外: ' + err);
return false;
}
}
// sendReply:統一的回覆入口,所有指令回覆/備份成功確認/備份失敗訊息/錯誤處理分支都經過這裡,
// 不可讓任何呼叫端繞過(見本函式下方 NOTIFY_TO 分流說明)。
//
// ★本輪新增 NOTIFY_TO 私訊防外洩分流:業主想把 bot 拉進「同事也在的工作群組」,但群組內的
// reply 是全群組可見(含檔名、Drive連結),業主不希望同事看到。設計原則:這是「防外洩」
// 功能,路由判斷只能寫在這一個集中入口,不能散落到各呼叫端各自判斷——否則只要漏改一處
// 呼叫點,就會有訊息(尤其是含Drive連結的確認訊息)直接洩漏到群組裡,而且很難察覺。
// 本函式是 replyMessage/pushMessage 在全專案唯一的呼叫端(其餘一律不得直接呼叫這兩支),
// 因此把分流邏輯集中在這裡,等同保證了「NOTIFY_TO已設定+來源是group/room」時,
// 不存在任何路徑會把訊息送進群組——包含備份成功確認、各種失敗訊息、/help /id /folder
// /last /stats /ping 等所有指令回覆,以及 handleEvent catch 等錯誤處理分支。
function sendReply(event, text) {
const source = event.source || {};
const sourceType = source.type; // 'group' / 'room' / 'user'
const isGroupOrRoom = sourceType === 'group' || sourceType === 'room';
if (CONFIG.NOTIFY_TO && isGroupOrRoom) {
// 防外洩核心分支:群組/聊天室 + 已設定 NOTIFY_TO → 一律改用 push 私訊業主本人,
// 絕對不可以再對群組/聊天室發送任何訊息(不 reply、也不 push 到 source 本身)。
let notifyText = text;
if (/^⚠️/.test(text)) {
// 失敗訊息離開了原本的群組脈絡,業主看到私訊時不知道是哪個群組出的狀況,
// 補上來源名稱方便判斷(例如「⚠️ 檔案備份失敗:xxx.pdf(來自:業務群組A)」)。
try {
notifyText = text + `(來自:${getSourceName(event)})`;
} catch (err) {
// 補來源名稱失敗(例如 API 例外)不影響通知本身送達,忽略即可
Logger.log('sendReply 補上來源名稱時發生例外(不影響通知本身送達): ' + err);
}
}
if (pushMessage(CONFIG.NOTIFY_TO, notifyText)) return;
// ★push失敗(例如 NOTIFY_TO 填錯、或業主還沒把本帳號加為好友):不可以退回去發到群組
// (那就整個防外洩功能破功了)。改為只記 log,檔案本身已經備份成功這件事不受影響。
Logger.log('⚠️ sendReply 對 NOTIFY_TO push失敗,這則通知業主收不到(不影響檔案已備份成功的事實,' +
'也絕不會退回群組發送)。請確認 CONFIG.NOTIFY_TO 是否為正確的 userId,以及該使用者是否已將本帳號加為好友。' +
'NOTIFY_TO=' + CONFIG.NOTIFY_TO + ', text=' + notifyText);
return;
}
// 其他情況(一對一 user,或 NOTIFY_TO 留空)→ 維持原本行為完全不變:
// 先試 replyMessage,失敗(token過期/用過)才 fallback 到 pushMessage
if (event.replyToken && replyMessage(event.replyToken, text)) return;
const to = source.groupId || source.roomId || source.userId || '';
if (to && pushMessage(to, text)) return;
// ★修正(審查發現#6-中等):reply 與 push 都用同一個 CHANNEL_ACCESS_TOKEN,若 token 失效
// (例如業主在 Console 按過「重新簽發」卻忘了更新 CONFIG),兩者會同時失敗,使用者端會是
// 完全「已讀不回」、沒有任何錯誤提示可看,只能靠業主自己翻 GAS 執行紀錄才會發現。這裡在
// 兩者都失敗時記錄一行明顯的錯誤 log,方便業主日後排查(設定定期健檢觸發器不在本規格
// 4.2 函式清單內,此處先以加強 log 可見度作為緩解)。
Logger.log('⚠️ sendReply 完全失敗(reply 與 push 皆失敗),使用者不會收到任何回覆。' +
'請檢查 CONFIG.CHANNEL_ACCESS_TOKEN 是否仍然有效(是否曾在 LINE Developers Console 重新簽發過)。' +
'text=' + text);
}
// ============================================================================
// 紀錄
// ============================================================================
// logRow:寫入一列備份紀錄到 Log 試算表;沒設定 LOG_SHEET_ID 就安靜跳過,不影響備份主流程
//
// ★本輪新增 sourceId 參數:LINE 內部識別碼(groupId/roomId/userId),不含任何個資,寫進試算表
// 可接受。用途:讓 /last /stats 用「不會變動的ID」比對紀錄,而不是用會被群組改名影響的顯示名稱
// (見 getSourceKey/resolveFolder 的說明)。
function logRow(fileName, sizeBytes, sourceName, url, sourceId) {
if (!CONFIG.LOG_SHEET_ID) return; // 沒設定 Log 試算表,安靜跳過(不能讓備份因此失敗)
// 併發寫入同一張表有 race condition 風險(官方文件未保證 appendRow 的並行安全性),
// 用同一套 LockService 保護寫入區段;鎖不到就直接放棄這次記錄,不拖累主流程
const lock = LockService.getScriptLock();
let locked = false;
try {
lock.waitLock(5000);
locked = true;
} catch (err) {
Logger.log('logRow 取得鎖逾時,跳過本次寫入(不影響備份主流程): ' + err);
return;
}
try {
const ss = SpreadsheetApp.openById(CONFIG.LOG_SHEET_ID);
let sheet = ss.getSheetByName('Log');
if (!sheet) {
sheet = ss.insertSheet('Log');
// sourceId:LINE 內部識別碼(groupId/roomId/userId),不含任何個資,只用來讓 /last /stats
// 在群組改名後仍能正確比對出同一個來源(見本函式上方註解)
sheet.appendRow(['時間', '檔名', '大小(bytes)', '來源', '連結', 'sourceId']); // 標題列
} else {
// ★修正(審查發現#3-輕微):sheet 若是「此次更新前」就已經存在的舊試算表,表頭只有五欄
// (時間/檔名/大小/來源/連結),不會走到上面 insertSheet 的分支,本輪新增的第6欄
// sourceId 就不會有欄名——但下面 appendRow 仍然會照樣多寫入第六個值,F欄從此開始有
// 資料、F1卻是空的。這裡在「sheet已存在」的分支也檢查一次表頭,F1若是空的就補上
// 'sourceId',讓既有試算表跟著更新到新結構,不必等業主自己重新建表。
try {
const headerF1 = sheet.getRange(1, 6).getValue();
if (!headerF1) sheet.getRange(1, 6).setValue('sourceId');
} catch (err) {
Logger.log('logRow 檢查/補寫表頭失敗(不影響主要寫入): ' + err);
}
}
const timeStr = Utilities.formatDate(new Date(), CONFIG.TZ, 'yyyy-MM-dd HH:mm:ss');
// ★隱私鐵則:這裡「不得」寫入任何訊息文字內容,只記錄檔名/大小/來源/時間/連結/sourceId
// ★修正(審查發現#12-中等):fileName(file類型時=傳送方可控的原始檔名)與 sourceName
// (群組名/暱稱,同樣是對方可任意設定的字串)若以 = + - @ 開頭,Google Sheets 會把該
// 儲存格當公式解析而非純文字,可能被用來做公式注入(例如誘導點擊的偽裝連結、或觸發對外
// 部網址的資料查詢)。寫入前一律用 sanitizeForSheetCell 檢查並視需要加上前綴單引號。
// sourceId 是本程式產生的 LINE 內部ID(非使用者輸入字串),不需要同樣的公式注入防護。
sheet.appendRow([timeStr, sanitizeForSheetCell(fileName), sizeBytes, sanitizeForSheetCell(sourceName), url, sourceId || '']);
} catch (err) {
// Log試算表ID填錯/沒權限等狀況,安靜記錄到執行紀錄即可,絕不能讓備份主流程失敗
Logger.log('logRow 寫入失敗(不影響備份主流程): ' + err);
} finally {
if (locked) {
try {
lock.releaseLock();
} catch (err) {
Logger.log('logRow releaseLock 失敗(不影響結果): ' + err);
}
}
}
}
// ============================================================================
// 健檢工具
// ============================================================================
// setupCheck:使用者在 GAS 編輯器手動執行的自我健檢,逐項驗證設定並印出應貼到 LINE 的 Webhook URL
function setupCheck() {
Logger.log('========================================');
Logger.log('LINE 存檔小幫手 — 自我健檢開始');
Logger.log('========================================');
let allOk = true;
// 1. CHANNEL_ACCESS_TOKEN 檢查:真的呼叫一次 LINE「取得bot資訊」API,是驗證token是否有效的唯一可靠方式
if (!CONFIG.CHANNEL_ACCESS_TOKEN) {
Logger.log('❌ CHANNEL_ACCESS_TOKEN 尚未填寫。請至 LINE Developers → Messaging API → Channel access token 產生後貼到 CONFIG.CHANNEL_ACCESS_TOKEN。');
allOk = false;
} else {
try {
const res = UrlFetchApp.fetch('https://api.line.me/v2/bot/info', {
headers: { Authorization: 'Bearer ' + CONFIG.CHANNEL_ACCESS_TOKEN },
muteHttpExceptions: true,
});
if (res.getResponseCode() === 200) {
const info = JSON.parse(res.getContentText());
Logger.log(`✅ CHANNEL_ACCESS_TOKEN 有效,Bot 名稱:${info.displayName}(userId: ${info.userId})`);
} else {
Logger.log(`❌ CHANNEL_ACCESS_TOKEN 無效或已失效,LINE API回傳 HTTP ${res.getResponseCode()}:${res.getContentText()}`);
Logger.log(' 修正指引:確認token沒有打錯/漏貼;若曾在Console按過「重新簽發(re-issue)」,舊token會立即失效,需重新複製最新的token。');
allOk = false;
}
} catch (err) {
Logger.log('❌ 呼叫 LINE API 時發生例外:' + err);
allOk = false;
}
}
// 2. ROOT_FOLDER_ID 檢查:實際嘗試建立一個測試檔案再刪除,確認真的可讀寫
if (!CONFIG.ROOT_FOLDER_ID) {
Logger.log('❌ ROOT_FOLDER_ID 尚未填寫。請開啟目標 Google Drive 資料夾,網址 .../folders/ 後面那一段貼到 CONFIG.ROOT_FOLDER_ID。');
allOk = false;
} else {
try {
const folder = DriveApp.getFolderById(CONFIG.ROOT_FOLDER_ID);
const testBlob = Utilities.newBlob('setupCheck 測試檔案,可安全刪除', 'text/plain', '_setupCheck_test.txt');
const testFile = folder.createFile(testBlob);
testFile.setTrashed(true); // 測試完立刻丟進垃圾桶,不留垃圾檔案
Logger.log(`✅ ROOT_FOLDER_ID 可正常讀寫,資料夾名稱:「${folder.getName()}」`);
} catch (err) {
Logger.log('❌ ROOT_FOLDER_ID 無法讀寫:' + err);
Logger.log(' 修正指引:確認ID沒有打錯(只取folders/後面那一段,不含問號後面的參數);且此Google帳號對該資料夾有存取權。');
allOk = false;
}
}
// 3. WEBHOOK_KEY 檢查:只能確認有沒有填寫,無法反向驗證LINE那邊網址是否真的貼對
if (!CONFIG.WEBHOOK_KEY) {
Logger.log('❌ WEBHOOK_KEY 尚未填寫。請自訂一組英數字亂碼(建議20字元以上)貼到 CONFIG.WEBHOOK_KEY,並確保LINE Webhook URL有帶上 ?k= 這串。');
allOk = false;
} else {
Logger.log('✅ WEBHOOK_KEY 已設定。');
}
// 4. Log試算表(選填)檢查
if (CONFIG.LOG_SHEET_ID) {
try {
const ss = SpreadsheetApp.openById(CONFIG.LOG_SHEET_ID);
Logger.log(`✅ LOG_SHEET_ID 可正常開啟,試算表名稱:「${ss.getName()}」`);
// sourceId 欄位用途說明(本輪重構新增,見 logRow 函式上方註解):LINE 內部識別碼,
// 不含個資,讓 /last /stats 在群組改名後仍能正確比對出同一個來源。
Logger.log(' ℹ️ Log 試算表會多寫入一欄「sourceId」(LINE內部識別碼,不含個資),' +
'用於群組改名後 /last /stats 仍能正確比對同一個來源。');
} catch (err) {
Logger.log('❌ LOG_SHEET_ID 已填寫但無法開啟:' + err);
Logger.log(' 修正指引:確認ID正確且此帳號有編輯權;若不需要記錄功能,留空即可(會安靜跳過,不影響備份)。');
allOk = false;
}
} else {
Logger.log('ℹ️ LOG_SHEET_ID 未設定,不會記錄備份日誌(不影響核心備份功能)。');
}
// 5. 印出目前所有已建立的「來源→資料夾」綁定,讓業主一眼看到「哪個群組存到哪裡」
Logger.log('----------------------------------------');
if (!CONFIG.SUBFOLDER_BY_SOURCE) {
Logger.log('ℹ️ SUBFOLDER_BY_SOURCE 目前是 false(不分來源資料夾),不使用綁定表。');
} else {
Logger.log('📂 目前已建立的來源 → 資料夾綁定:');
try {
const props = PropertiesService.getScriptProperties().getProperties();
const srcKeys = Object.keys(props).filter((k) => k.indexOf('bind_src_') === 0);
if (srcKeys.length === 0) {
Logger.log('(尚未有任何綁定,第一次有人在該對話傳檔案時會自動建立)');
} else {
srcKeys.forEach((k) => {
const sourceKey = k.slice('bind_src_'.length);
const raw = String(props[k] || '');
const sep = raw.indexOf('|');
const folderId = sep === -1 ? raw : raw.slice(0, sep);
const rootAtBind = sep === -1 ? '' : raw.slice(sep + 1);
try {
const folder = DriveApp.getFolderById(folderId);
// ★findings#1-嚴重:主動比對兩件事,讓業主能自己發現「檔案其實存到看不到的地方」:
// (a) 這筆綁定記錄的 ROOT 是否跟目前 CONFIG.ROOT_FOLDER_ID 一致(換過 ROOT 的情境);
// (b) 這個資料夾目前的直屬父層是否仍然是目前的 ROOT_FOLDER_ID(業主手動把資料夾搬出
// ROOT 的情境)。這兩個檢查只是「主動列印警示」,不會自動修正,也不影響
// resolveFolder() 實際運作的邏輯(ROOT 換過的部分已由 getBinding() 自動處理)。
let warn = '';
if (rootAtBind && rootAtBind !== CONFIG.ROOT_FOLDER_ID) {
warn = ' ⚠️此綁定記錄的ROOT與目前設定不同(下次該來源傳檔會自動視為失效並重建)';
} else {
try {
const parents = folder.getParents();
let underRoot = false;
while (parents.hasNext()) {
if (parents.next().getId() === CONFIG.ROOT_FOLDER_ID) {
underRoot = true;
break;
}
}
if (!underRoot) warn = ' ⚠️此資料夾目前不在 ROOT_FOLDER_ID 底下(可能被手動搬動過,請人工確認)';
} catch (err) {
// 比對父層失敗不影響主要列印,忽略即可
}
}
Logger.log(`・${sourceKey} → 「${folder.getName()}」 https://drive.google.com/drive/folders/${folderId}${warn}`);
} catch (err) {
Logger.log(`・${sourceKey} → 資料夾ID ${folderId}(⚠️已失效/被刪除,下次傳檔會自動重建並更新綁定)`);
}
});
}
} catch (err) {
Logger.log('⚠️ 讀取綁定表失敗:' + err);
}
Logger.log('(如需清空重建,可在編輯器手動執行 resetBindings() —— 用於業主手動搬動/整理過 Drive 資料夾之後)');
}
// 6. 印出應貼到 LINE Developers Console 的完整 Webhook URL
Logger.log('----------------------------------------');
let deployUrl = null;
try {
deployUrl = ScriptApp.getService().getUrl();
} catch (err) {
Logger.log('⚠️ 無法自動取得部署網址:' + err);
}
// ★尚未部署時,getUrl() 會回傳結尾為 /dev 的「測試網址」——那個網址只有登入本人的 Google
// 帳號才打得開,LINE 打過去只會拿到登入頁,webhook 永遠收不到訊息。必須是結尾 /exec 的
// 正式部署網址才能用。這裡明確辨識並擋下來,避免使用者誤貼 /dev 而卡住找不到原因。
if (deployUrl && /\/dev$/.test(deployUrl)) {
Logger.log('⚠️ 偵測到目前只有「測試部署」網址(結尾是 /dev),這個網址 LINE 不能用!');
Logger.log(' /dev 只有你本人登入 Google 才打得開,LINE 打過去會被導到登入頁。');
Logger.log(' 請先完成正式部署:上方「部署」→「新增部署作業」→ 類型選「網頁應用程式」→');
Logger.log(' 執行身分「我」、誰可以存取「所有人」→ 部署。');
Logger.log(' 部署完成後再執行一次 setupCheck(),就會印出正確的 /exec 網址。');
} else if (deployUrl) {
const webhookUrl = deployUrl + '?k=' + encodeURIComponent(CONFIG.WEBHOOK_KEY || 'WEBHOOK_KEY尚未設定');
Logger.log('📋 請把下面這串完整網址貼到 LINE Developers → Messaging API → Webhook URL:');
Logger.log(webhookUrl);
} else {
Logger.log('⚠️ 尚未偵測到已發布的 Web App 部署,請先「部署 → 新增部署作業 → 網頁應用程式」完成部署,');
Logger.log(' 再從「管理部署作業」複製網址,手動接上 ?k=你的WEBHOOK_KEY 後貼到 LINE Developers。');
}
Logger.log('----------------------------------------');
Logger.log(allOk ? '✅ 健檢結果:全部通過,可以開始使用!' : '❌ 健檢結果:有項目未通過,請依照上方指引修正後重新執行 setupCheck()。');
Logger.log('========================================');
}
// ============================================================================
// 共用小工具
// ============================================================================
// normalizeCommand:指令比對前先去頭尾空白、轉小寫(大小寫不拘;中文指令不受影響)
function normalizeCommand(text) {
return String(text || '').trim().toLowerCase();
}
// sanitizeFolderName:清理從 LINE API 取回的名稱,避免斜線等字元造成資料夾路徑混淆
function sanitizeFolderName(name) {
if (!name) return '未命名';
return String(name).trim().replace(/\//g, '/') || '未命名';
}
// sanitizeFileName:清理 file 類型訊息的原始檔名(審查發現#7),把 Windows 檔案系統不允許的
// 字元(/ \ : * ? " < > |)換成對應的全形符號,避免 Drive 桌面版同步到本機時失敗/被跳過
function sanitizeFileName(name) {
if (!name) return '未命名檔案';
const map = { '\\': '\', '/': '/', ':': ':', '*': '*', '?': '?', '"': '"', '<': '<', '>': '>', '|': '|' };
const cleaned = String(name).trim().replace(/[\\/:*?"<>|]/g, (ch) => map[ch] || '_');
return cleaned || '未命名檔案';
}
// sanitizeForSheetCell:防止 Google Sheets 公式注入(審查發現#12)。若字串開頭是
// = + - @ 這幾個公式觸發字元,補一個單引號強制當純文字處理,不被解析成公式
function sanitizeForSheetCell(value) {
const str = String(value == null ? '' : value);
return /^[=+\-@]/.test(str) ? "'" + str : str;
}
// formatSize:把位元組數轉成人類看得懂的 KB / MB 字串
function formatSize(bytes) {
if (bytes >= 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB';
return Math.round(bytes / 1024) + ' KB';
}
// isDuplicateEvent:用 CacheService 判斷這個 webhookEventId 是否短時間內已處理過(防 LINE 重送造成重複建檔)
//
// 已知殘餘風險(審查發現#9-輕微):這裡的 get 再 put 不是原子操作,理論上若 LINE 在幾乎同一
// 毫秒內對同一個 webhookEventId 重送兩次,兩個並行請求都可能在對方 put 完成前讀到「非重複」。
// 這是 GAS CacheService 平台本身沒有提供原子 compare-and-set 操作的先天限制;若要完全杜絕,
// 需要把這個判斷也併入 saveMessageContent 已在用的 LockService 臨界區,但那會讓原本只需要
// 序列化「同一資料夾內的建檔」的鎖,擴大到序列化「所有事件」,在月初大量並行湧入的情境下
// 反而會加重審查發現#4 提到的鎖排隊逾時風險。兩害相權,這裡選擇維持現狀、僅在此註解說明,
// 而不是為了堵一個機率極低的毫秒級競態窗口,去讓更常見的高併發情境變得更容易逾時降級失敗。
function isDuplicateEvent(webhookEventId) {
const cache = CacheService.getScriptCache();
const key = 'evt_' + webhookEventId;
if (cache.get(key)) return true;
cache.put(key, '1', 600); // 10分鐘內視為重複;LINE redelivery多半在短時間內發生
return false;
}