/** * ============================================================================ * 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; }