把 LINE 待辦小幫手,升級成我的專屬 AI 秘書【麥麥特助】


因為看了數位時代分享的這篇【把 LINE 變成待辦小幫手!4 步驟教你用 Google Apps Script 串接 Gemini,一句話自動排入日曆

也讓我興起了自己做一個專屬 LINE 秘書的念頭。我把我的機器人設定成【麥麥特助】,主力請 Gemini 寫程式碼,寫完再請 ChatGPT 幫忙複查,因為 GPT 很熟悉我的生活型態,會精準抓出我在日常使用中容易漏掉的盲點。

經過兩個 AI 的反覆除錯與調校後,【麥麥特助】終於正式上線了!

要完成這個 LINE 機器人,需要準備 5 樣東西:

  1. Google 試算表

  2. Gemini API

  3. Google Apps Script

  4. Google 日曆

  5. LINE Messaging API

1~4 都是 Google 家的服務,在個人正常用量下目前都是免費提供;

5 的 LINE 官方帳號,目前官方方案提供「每月 200 則免費主動推播」,只要沒超過額度就一毛錢都不用花。

這篇落落長……最後我會把改過、可以直接上線的完整程式碼留給有緣人參考;至於最初步的串接設定,請大家直接照著數位時代那篇教學一步步點選即可。

(如果中途卡住,直接截圖丟給你的 AI,請他帶你找設定的位置最快🤣)

目前【麥麥特助】可以幫我做到:

  1. 隨拍隨記,不用自己打字

    拿到看診單、繳費單直接拍照截圖丟過去,或是隨口打一句話,AI 就自動幫我記進 Google 日曆跟試算表,連備註(如「順便抽血」、「提早30分鐘到」)都會自己抓。

  2. 在 LINE 隨時查行程

    出門在外不用切換日曆 App,在 LINE 傳「查今天」、「查這週」或「查本月」,它立刻用中文條列全部行程回報給我。

  3. 早上自動叫我、不用額外花錢

    每天早上約 06:30 自動傳 LINE 跟我說今天、明天要做什麼;每週一早上約 06:00 還會推播這週總覽;每月 1 號自動彙整當月重點日程。每個月推播額度用不到兩成,目前完全 0 元、不用多花一毛錢!

  4. 直接用 LINE 修改或刪除行程

    行程有變也不用自己打開 Google 日曆慢慢找。直接跟麥麥特助說「取消明天的會議」或「把明天看診改到後天下午兩點」,它會去找出原本的行程,同步修改/刪除 Google 日曆與試算表。

與數位時代原版的「9 大關鍵進化與升級」

數位時代那篇提供了一個很棒的基礎架構,但要真正落入日常生活長期使用,我一路加著加著,現在已經變成 9 個重大升級:

1. 從「單向輸入」變成「雙向互動(隨時查日曆)」

  • 數位時代原版:只能「單向寫入」,傳訊息記下就結束了,要看行程還是得自己開日曆找。

  • 我的版本:加上反向查詢大腦。在 LINE 傳「查」、「查今天」、「查明天」、「查這週」、「查本月」「查幾月」等,支援以「查」開頭的指令(如:查10月、查工作、查醫療、查萬芳),機器人直接去翻 Google 日曆並整理回報。

2. 從「只能新增」進化到「可以直接修改、刪除」

  • 數位時代原版:行程建立之後,如果時間改了或取消,還是得自己進 Google 日曆處理。

  • 我的版本:現在直接在 LINE 講人話就可以處理,例如「取消明天的會議」、「把明天看診改到後天下午兩點」。AI 會先判斷這次是新增、修改還是刪除,再去找到原本的行程,同步處理 Google 日曆與試算表。

這個功能也是我今天早上手癢加上去之後,害麥麥特助一度暈車的元凶🤣 不過最後還是被 Gemini+ChatGPT 兩邊交叉抓 bug 救回來了。

3. 加入「主動推播(晨間秘書)」且嚴格控管 0 元額度

  • 數位時代原版:完全沒有主動推播功能。

  • 我的版本:
    自動捕捉我的 LINE User ID(鎖定防覆蓋,不怕別人傳話搶走推播);
    設定每週一約 06:00 推播一週總覽、每天約 06:30 推播今明行程、每月 1 號推播本月行程(Apps Script 的定時觸發器會有約 ±15 分鐘的排程浮動)。
    精算過後每個月只用約 36 則,控制在免費 200 則額度內,達成 0 元自動提醒。

4. 日曆與試算表的「雙重防重複機制」

  • 數位時代原版:傳幾次就記幾列,重複傳就會產生一堆髒資料。

  • 我的版本:比對「日期+開始時間+事項」,重複的一律跳過不新增;更解決了日曆自動預設 30 分鐘(10:54-11:24)與試算表(10:54)導致比對破功的難題,雙邊資料庫乾乾淨淨。

5. 試算表自動依日期由近到遠排序

  • 數位時代原版:新資料永遠塞在最底層,隨著時間越積越多,近期的事項極難尋找。

  • 我的版本:內建自動排序防呆。不管是手動同步日曆還是 LINE 隨時傳訊寫入,只要寫入成功就會自動按日期、時間升冪重排,近期的待辦永遠整齊排列在最上方。

6. 增加「日曆現有行程一鍵同步回填 (syncCalendarToSheet)」

  • 數位時代原版:只能記「從今以後在 LINE 傳的事情」,原本日曆上已經排好的未來行程,試算表完全看不到。

  • 我的版本:內建手動同步工具,隨時點一下就能把 Google 日曆未來半年(180 天)內既有的行程抓回試算表補齊;而且只補缺少的、不覆蓋、不刪除既有資料。

7. 訊息解析能力大幅擴充(貼合真實生活)

  • 數位時代原版:只有 3 種分類,僅支援單一時間點。

  • 我的版本:擴充為 6 大分類(醫療、繳費、會議、工作、生活、其他);支援「起訖時間區間(如 13:00-21:00)」、「全天與跨日連續行程(如 2/16-2/18 日本行)」以及「獨立備註欄位」。
    (這 6 大分類只是我自己用得順手的版本,如果你平常想分成「工作、家庭、小孩、投資、繳費、醫療」,也可以自己改,叫 AI 幫你調就好。)

8. 日期與語意呈現的「繁體在地化」

  • 數位時代原版:系統原始英文格式(如 (Tue)、(Thu))。

  • 我的版本:全面轉換為台灣人習慣的繁體中文(如 週二、週四),不管新增確認還是查詢行程,回覆都更有溫度。

9. 系統穩定度與格式防呆優化

  • 數位時代原版:單次呼叫,遇到伺服器塞車、AI 回傳格式偏差或網址被格式轉換時,容易造成錯誤。

  • 我的版本:目前使用 gemini-flash-latest,加入 JSON 解析容錯、API 自動重試、判讀失敗保護,以及 LINE API 回傳狀態紀錄,方便真的出錯時直接從執行紀錄抓問題。

📌 2026/09/09 更新

昨天文章貼出去之後,我又手癢加了「修改/刪除行程」功能🤣

現在除了新增、查詢之外,也可以直接跟麥麥特助說「取消明天的會議」或「把明天的看診改到後天下午兩點」。

但也因為這次擴充,程式中途出現 LINE 收得到訊息、卻完全不回話的狀況。最後用 Gemini 負責修改、ChatGPT 負責 Code Review 的方式交叉檢查,才抓到真正的問題。

所以下方程式碼已更新成 9/9 最新版,請以這一版為準。

⚠️ 最容易被忘記的關鍵一步:設定定時推播鬧鐘!

把程式碼貼上、部署好之後,千萬不要以為它隔天早上就會自動傳訊息給你

因為 Google Apps Script 是被動的,你必須在系統裡加上定時開關,告訴它「每天早上幾點要執行哪一個功能」。請到 Apps Script 左側工具列點選「觸發條件」(鬧鐘圖示 ⏰),在右下角點「+ 新增觸發條件」,依序新增以下三個定時任務:

  1. 每天早安推播(今明日程提醒)
    • 選擇要執行的功能:sendDailyReminder
    • 活動來源:時間驅動
    • 時間型觸發條件類型:日計時器
    • 當日時段:早上 6 點到 7 點
  2. 每週一早安推播(本週總覽)
    • 選擇要執行的功能:sendWeeklyReminder
    • 活動來源:時間驅動
    • 時間型觸發條件類型:週計時器
    • 星期幾:每星期一
    • 當日時段:早上 6 點到 7 點
  3. 每月 1 號推播(整月行程總覽)
    • 選擇要執行的功能:sendMonthlyReminder
    • 活動來源:時間驅動
    • 時間型觸發條件類型:月計時器
    • 月份中的哪一天:1
    • 當日時段:早上 6 點到 7 點

💡 小提醒:設定好之後,請先拿你的個人 LINE 帳號向特助隨便傳一句「早安」或「查今天」,讓特助自動鎖定並記錄你的 User ID。接著回到 Apps Script 編輯器,上方選取 sendDailyReminder 點一次「執行」,手機 LINE 就會立刻收到早安推播,驗收成功!

好,我的變動大概是這些,下方附上 2026/09/09 更新後的 Apps Script 完整程式碼給需要的朋友直接取用!


/**
 * LINE AI 待辦助理(終極精準防重複 + 全繁體中文星期 + 試算表自動排序穩定版)
 * 支援功能:新增, 修改, 刪除, 查詢日曆
 * + 採用 Google 官方最新指定模型 (gemini-flash-latest)
 * + 徹底杜絕 Markdown 超連結格式(採用拼裝字串,杜絕 Invalid argument)
 * + 強化 JSON 解析容錯(自動剝離 Markdown 代碼塊標記)
 * + 試算表寫入後自動按日期由近到遠排序(近期的在最上方)
 * + 判讀失敗防護機制(失敗時不寫入試算表,避免誤鎖重複)
 * + 支援 LINE 傳訊查行程:今天、明天、這週、本月、指定月份(如「查10月」)、查分類(如「查工作」)
 * + 支援修改與刪除指令(如「取消明天會議」、「把明天看診改到後天下午兩點」)
 * + 週一約 06:00 本週行程彙整推播、每天約 06:30 今明兩日行程提醒推播、每月 1 號本月行程推播
 * + 支援試算表自動附加 (週X) 格式,且完美相容跨日顯示與底層防重複機制
 * + 智慧防呆:自動搬移 AI 填錯的修改/刪除目標欄位,提升指令成功率
 * + 保留 doGet 作為瀏覽器健康檢查;LINE Webhook 實際由 doPost 處理
 */

const CONFIG = {
  MODEL: 'gemini-flash-latest',
  TIMEZONE: 'Asia/Taipei',
};

function getSecrets_() {
  const p = PropertiesService.getScriptProperties();
  return {
    lineToken: p.getProperty('LINE_CHANNEL_ACCESS_TOKEN'),
    geminiKey: p.getProperty('GEMINI_API_KEY'),
    sheetId: p.getProperty('SHEET_ID'),
    targetUserId: p.getProperty('TARGET_LINE_USER_ID'),
  };
}

// ---------- 星期中文轉換輔助函式 ----------
function formatChineseDate_(dateObj, includeTime, isAllDay) {
  const weekdays = ['週日', '週一', '週二', '週三', '週四', '週五', '週六'];
  const monthDay = Utilities.formatDate(dateObj, CONFIG.TIMEZONE, 'MM/dd');
  const weekdayStr = weekdays[dateObj.getDay()];

  if (isAllDay) {
    return monthDay + ' (' + weekdayStr + ') 全天';
  }
  if (includeTime) {
    const timeStr = Utilities.formatDate(dateObj, CONFIG.TIMEZONE, 'HH:mm');
    return monthDay + ' (' + weekdayStr + ') ' + timeStr;
  }
  return monthDay + ' (' + weekdayStr + ')';
}

function formatFullDateWithWeekday_(dateStr) {
  if (!dateStr) return '—';
  try {
    const dObj = new Date(dateStr + 'T00:00:00+08:00');
    if (isNaN(dObj.getTime())) return dateStr;
    const weekdays = ['週日', '週一', '週二', '週三', '週四', '週五', '週六'];
    return dateStr + ' (' + weekdays[dObj.getDay()] + ')';
  } catch (e) {
    return dateStr;
  }
}

// ---------- 核心:統一抽取「開始時間」進行比對 ----------
function extractStartTime_(timeStr) {
  if (!timeStr) return '';
  const str = String(timeStr).trim();
  const match = str.match(/^(\d{1,2}:\d{2})/);
  return match ? match[1] : str;
}

// ---------- 核心:生成標準唯一的比對 Key ----------
function buildEventKey_(dateStr, timeStr, summaryStr) {
  let cleanDate = (dateStr || '').toString().trim();
  cleanDate = cleanDate.replace(/\s*\([^\)]+\)/g, '').trim();
  
  const startTime = extractStartTime_(timeStr);
  const cleanSummary = (summaryStr || '').toString().trim();
  return cleanDate + '_' + startTime + '_' + cleanSummary;
}

// ---------- 核心:試算表按日期、時間排序(加防呆) ----------
function sortSheetByDate_(sheet) {
  try {
    const numRows = sheet.getLastRow() - 1; 
    if (numRows > 1) {
      sheet.getRange(2, 1, numRows, sheet.getLastColumn())
           .sort([
             { column: 1, ascending: true }, 
             { column: 2, ascending: true }  
           ]);
    }
  } catch (err) {
    console.warn('試算表排序略過:' + err);
  }
}

// ---------- 瀏覽器 GET 健康檢查(LINE Webhook Verify 本身使用 POST) ----------
function doGet(e) {
  return ContentService.createTextOutput(JSON.stringify({ status: 'ok', message: '麥麥特助運作中' }))
    .setMimeType(ContentService.MimeType.JSON);
}

// ---------- LINE 進來的每一則訊息,都會先經過這裡 ----------
function doPost(e) {
  try {
    const contents = JSON.parse(e.postData.contents);
    const events = contents.events || [];
    
    events.forEach(function(event) {
      if (event.source && event.source.userId) {
        saveUserIdIfNeeded_(event.source.userId);
      }
      routeMessage_(event);
    });
  } catch (err) {
    console.error('doPost 例外:' + err);
  }
  return ContentService.createTextOutput(JSON.stringify({ status: 'ok' }))
    .setMimeType(ContentService.MimeType.JSON);
}

function saveUserIdIfNeeded_(userId) {
  const p = PropertiesService.getScriptProperties();
  const currentId = p.getProperty('TARGET_LINE_USER_ID');
  if (!currentId) {
    p.setProperty('TARGET_LINE_USER_ID', userId);
    console.log('已鎖定目標推播 User ID:' + userId);
  }
}

function routeMessage_(event) {
  if (event.type !== 'message') return;

  const token = event.replyToken;
  const msg = event.message;

  if (msg.type === 'text') {
    const trimmed = msg.text.trim();
    if (isQueryCommand_(trimmed)) {
      const queryResult = handleCalendarQuery_(trimmed);
      sendReply_(token, queryResult);
      return;
    }
  }

  let record;
  if (msg.type === 'text') {
    record = interpretText_(msg.text);
  } else if (msg.type === 'image') {
    record = interpretImage_(fetchLineImage_(msg.id));
  } else {
    sendReply_(token, '目前只支援文字訊息和截圖喔!');
    return;
  }

  if (!record || record.isError) {
    let warnMsg = '⚠️ AI 判讀失敗或伺服器短暫忙碌中,請稍後再試一次,或換個方式重傳。';
    if (record && record.debugMsg) {
      warnMsg += '\n\n🛠️【工程師除錯報告】\n' + record.debugMsg;
    }
    sendReply_(token, warnMsg);
    return;
  }

  const action = record.action || 'create';

  if (action === 'delete' || action === 'update') {
    if (!record.targetDate && record.date) {
      record.targetDate = record.date;
    }
    if (!record.targetSummary && record.summary) {
      record.targetSummary = record.summary;
    }
  }

  if (!record.date && !record.targetDate) {
    sendReply_(token, '⚠️ 未偵測到有效日期,請說明確切的日期(例如:明天、9/27)。');
    return;
  }

  if (action === 'delete') {
    if (!record.targetDate || !record.targetSummary) {
      sendReply_(token, '⚠️ 無法確認要刪除的日期或名稱,請說明確切日期與行程(例:幫我取消明天的會議)。');
      return;
    }
    const deleted = deleteTargetEvent_(record.targetDate, record.targetSummary);
    if (deleted) {
      sendReply_(token, '🗑️ 已為您刪除行程:' + record.targetDate + ' 的「' + record.targetSummary + '」。');
    } else {
      sendReply_(token, '⚠️ 找不到符合「' + record.targetDate + '」且包含「' + record.targetSummary + '」的舊行程。');
    }
    return;
  }

  if (action === 'update') {
    if (!record.targetDate || !record.targetSummary) {
      sendReply_(token, '⚠️ 無法確認要修改的原行程,請說明清楚(例:把明天的會議改到後天)。');
      return;
    }
    const deleted = deleteTargetEvent_(record.targetDate, record.targetSummary);
    if (!deleted) {
      sendReply_(token, '⚠️ 找不到要修改的原行程:「' + record.targetSummary + '」,請確認該行程是否存在。');
      return;
    }
  }

  const sheetStatus = writeRecord_(record);
  const calendarStatus = maybeCreateEvent_(record);

  let calendarNote = '';
  if (calendarStatus === 'created') {
    if (record.allDay || (!record.time && record.date)) {
      calendarNote = '\n📅 已加入日曆';
    } else {
      calendarNote = '\n📅 已加入日曆並設定提醒';
    }
  } else if (calendarStatus === 'duplicate') {
    calendarNote = '\n⚠️ 日曆已有相同行程,未重複新增';
  }

  let sheetNote = '';
  if (sheetStatus === 'duplicate') {
    sheetNote = '(試算表已有紀錄)';
  }

  let headerText = action === 'update' ? '✏️ 已修改' : '⭕ 已記錄';
  if (sheetStatus === 'duplicate' && calendarStatus === 'duplicate') {
    headerText = '⚠️ 此行程已在日曆與試算表中,未重複新增';
  }

  const formattedDate = formatFullDateWithWeekday_(record.date);

  let replyText = headerText + '\n' +
    '事項:' + (record.summary || '—') + sheetNote + '\n' +
    '分類:' + (record.category || '—') + '\n' +
    '日期:' + formattedDate;

  if (record.endDate && record.endDate !== record.date) {
    replyText += '\n結束日期:' + formatFullDateWithWeekday_(record.endDate);
  }

  if (record.allDay || (!record.time && record.date)) {
    replyText += '\n型態:全天事件';
  } else if (record.time) {
    replyText += '\n開始時間:' + record.time;
    if (record.endTime) {
      replyText += '\n結束時間:' + record.endTime;
    }
  }

  if (record.note) {
    replyText += '\n備註:' + record.note;
  }

  replyText += calendarNote;
  sendReply_(token, replyText);
}

// ---------- 判斷與處理日曆查詢 ----------
function isQueryCommand_(text) {
  const queryWords = [
    '查', '查今天', '查明天', '查這週', '查本週', '查本月', '查這個月', '查下個月',
    '查行程', '今天行程', '明天行程', '本週行程', '這週行程', '本月行程', '這個月行程'
  ];
  if (queryWords.indexOf(text) !== -1 || text.indexOf('查日曆') !== -1) {
    return true;
  }
  if (text.startsWith('查') && text.length <= 10) {
    return true;
  }
  return false;
}

function handleCalendarQuery_(text) {
  const cal = CalendarApp.getDefaultCalendar();
  const now = new Date();

  if (text.indexOf('月') !== -1) {
    let targetYear = now.getFullYear();
    let targetMonth = now.getMonth(); 

    const yearMatch = text.match(/(\d{2,4})\s*年/);
    if (yearMatch) {
      let y = parseInt(yearMatch[1], 10);
      if (y < 100) y += 2000;
      targetYear = y;
    }

    const monthMap = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9, '十': 10, '十一': 11, '十二': 12 };
    const monthNumMatch = text.match(/(\d{1,2})\s*月/);
    const monthChineseMatch = text.match(/([一二三四五六七八九十]+)\s*月/);

    if (monthNumMatch) {
      targetMonth = parseInt(monthNumMatch[1], 10) - 1;
    } else if (monthChineseMatch && monthMap[monthChineseMatch[1]]) {
      targetMonth = monthMap[monthChineseMatch[1]] - 1;
    } else if (text.indexOf('下個') !== -1 || text.indexOf('下月') !== -1) {
      targetMonth = now.getMonth() + 1;
      if (targetMonth > 11) {
        targetMonth = 0;
        targetYear += 1;
      }
    } else {
      targetMonth = now.getMonth();
    }

    const startMonth = new Date(targetYear, targetMonth, 1, 0, 0, 0);
    const endMonth = new Date(targetYear, targetMonth + 1, 0, 23, 59, 59);

    const events = cal.getEvents(startMonth, endMonth);
    const monthTitle = Utilities.formatDate(startMonth, CONFIG.TIMEZONE, 'yyyy年MM月');
    let reply = '🗓️【' + monthTitle + ' Google 日曆行程】\n';

    if (events.length === 0) return reply + '\n該月份目前沒有排定任何事項。';

    events.forEach(function(ev) {
      const isAllDay = ev.isAllDayEvent();
      const timeStr = formatChineseDate_(ev.getStartTime(), true, isAllDay);
      reply += '\n📌 ' + timeStr + ' ' + ev.getTitle();
      if (ev.getDescription()) reply += '(' + ev.getDescription() + ')';
    });
    return reply;
  }

  if (text.indexOf('週') !== -1 || text.indexOf('星期') !== -1) {
    const dayOfWeek = now.getDay();
    const distanceToMonday = (dayOfWeek + 6) % 7;
    const monday = new Date(now);
    monday.setDate(now.getDate() - distanceToMonday);
    monday.setHours(0, 0, 0, 0);

    const sunday = new Date(monday);
    sunday.setDate(monday.getDate() + 6);
    sunday.setHours(23, 59, 59, 999);

    const events = cal.getEvents(monday, sunday);
    let reply = '🗓️【本週 Google 日曆行程(' +
      Utilities.formatDate(monday, CONFIG.TIMEZONE, 'MM/dd') + ' ~ ' +
      Utilities.formatDate(sunday, CONFIG.TIMEZONE, 'MM/dd') + ')】\n';

    if (events.length === 0) return reply + '\n本週日曆上目前沒有任何排定事項。';

    events.forEach(function(ev) {
      const isAllDay = ev.isAllDayEvent();
      const timeStr = formatChineseDate_(ev.getStartTime(), true, isAllDay);
      reply += '\n📌 ' + timeStr + ' ' + ev.getTitle();
      if (ev.getDescription()) reply += '(' + ev.getDescription() + ')';
    });
    return reply;
  }

  if (text.indexOf('明天') !== -1) {
    const startTomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 0);
    const endTomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 23, 59, 59);
    const events = cal.getEvents(startTomorrow, endTomorrow);

    let reply = '📆【明天 Google 日曆行程 ' + formatChineseDate_(startTomorrow, false, false) + '】\n';
    if (events.length === 0) return reply + '\n明天沒有排定行程。';

    events.forEach(function(ev) {
      const timeStr = ev.isAllDayEvent() ? '全天' : Utilities.formatDate(ev.getStartTime(), CONFIG.TIMEZONE, 'HH:mm');
      reply += '\n• ' + timeStr + ' ' + ev.getTitle();
      if (ev.getDescription()) reply += '(' + ev.getDescription() + ')';
    });
    return reply;
  }

  if (text === '查' || text.indexOf('今天') !== -1) {
    const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
    const endToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59);
    const events = cal.getEvents(startToday, endToday);

    let reply = '📅【今天 Google 日曆行程 ' + formatChineseDate_(startToday, false, false) + '】\n';
    if (events.length === 0) return reply + '\n今天沒有排定行程。';

    events.forEach(function(ev) {
      const timeStr = ev.isAllDayEvent() ? '全天' : Utilities.formatDate(ev.getStartTime(), CONFIG.TIMEZONE, 'HH:mm');
      reply += '\n• ' + timeStr + ' ' + ev.getTitle();
      if (ev.getDescription()) reply += '(' + ev.getDescription() + ')';
    });
    return reply;
  }

  const queryTerm = text.replace(/^查\s*/, '').trim();
  const categories = ['醫療', '繳費', '會議', '工作', '生活', '其他'];
  const isCategory = categories.indexOf(queryTerm) !== -1;

  const searchStart = new Date();
  const searchEnd = new Date();
  searchEnd.setDate(searchStart.getDate() + 180);

  const searchTerm = isCategory ? ('[' + queryTerm + ']') : queryTerm;
  const events = cal.getEvents(searchStart, searchEnd, { search: searchTerm });

  const label = isCategory ? ('分類【' + queryTerm + '】') : ('關鍵字「' + queryTerm + '」');
  let reply = '🔍【' + label + '未來半年排程】\n';

  if (events.length === 0) return reply + '\n未來 180 天內沒有符合的事項。';

  events.forEach(function(ev) {
    const isAllDay = ev.isAllDayEvent();
    const timeStr = formatChineseDate_(ev.getStartTime(), true, isAllDay);
    reply += '\n📌 ' + timeStr + ' ' + ev.getTitle();
    if (ev.getDescription()) reply += '(' + ev.getDescription() + ')';
  });

  return reply;
}

// ---------- 呼叫 Gemini 做判讀 ----------
function buildPrompt_() {
  const today = Utilities.formatDate(new Date(), CONFIG.TIMEZONE, 'yyyy-MM-dd (EEE)');
  return [
    '你是專業的行政秘書,負責將使用者的對話轉為標準 JSON 格式。今天是 ' + today + '。',
    '【動作判定】',
    '- action 欄位:新增填 "create",修改填 "update",刪除填 "delete"。若未指明預設 "create"。',
    '【目標定位 (修改/刪除必填)】',
    '- targetDate 欄位:要異動的「原行程」日期 YYYY-MM-DD,無則留空 ""。',
    '- targetSummary 欄位:要異動的「原行程」名稱關鍵字,無則留空 ""。',
    '【新行程內容 (新增/修改必填)】',
    '- date 欄位:YYYY-MM-DD 格式日期。未指明年份依今年推算。',
    '- endDate 欄位:跨日結束日期 YYYY-MM-DD,無則留空 ""。',
    '- time 欄位:開始時間 HH:mm,無則留空 ""。',
    '- endTime 欄位:結束時間 HH:mm,無則留空 ""。',
    '- allDay 欄位:全天為 true,有具體時間為 false。',
    '- category 欄位:「醫療」「繳費」「會議」「工作」「生活」「其他」六選一。',
    '- summary 欄位:精簡的事項標題。',
    '- note 欄位:備註細節,無則留空 ""。',
    '務必只回傳標準 JSON 物件,嚴禁包含任何 markdown 標記(如 ```json)或額外文字。',
    '回傳格式參考:',
    '{"action":"create","targetDate":"","targetSummary":"","summary":"","category":"","date":"","time":"","endDate":"","endTime":"","allDay":false,"note":""}'
  ].join('\n');
}

function interpretText_(text) {
  return callGemini_({
    systemInstruction: { parts: [{ text: buildPrompt_() }] },
    contents: [{ parts: [{ text: text }] }],
    generationConfig: { responseMimeType: 'application/json' },
  });
}

function interpretImage_(base64Image) {
  return callGemini_({
    systemInstruction: { parts: [{ text: buildPrompt_() }] },
    contents: [{
      parts: [
        { text: '請讀出這張截圖的文字內容,並依規則抽取待辦重點。' },
        { inline_data: { mime_type: 'image/jpeg', data: base64Image } },
      ],
    }],
    generationConfig: { responseMimeType: 'application/json' },
  });
}

function callGemini_(payload) {
  const secrets = getSecrets_();
  const host = 'https://' + 'generativelanguage.googleapis.com';
  const endpoint = '/v1beta/models/' + CONFIG.MODEL + ':generateContent';
  const url = host + endpoint;

  const maxRetries = 2;
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const res = UrlFetchApp.fetch(url, {
        method: 'post',
        contentType: 'application/json',
        headers: { 'x-goog-api-key': secrets.geminiKey },
        payload: JSON.stringify(payload),
        muteHttpExceptions: true,
      });

      const statusCode = res.getResponseCode();
      const contentText = res.getContentText();

      if (statusCode === 503 && attempt < maxRetries) {
        Utilities.sleep(2000);
        continue;
      }

      if (statusCode !== 200) {
        return { isError: true, debugMsg: `API 狀態碼 ${statusCode}\n錯誤詳情:\n${contentText.substring(0, 150)}` };
      }

      const data = JSON.parse(contentText);
      if (!data.candidates || !data.candidates[0]) {
        return { isError: true, debugMsg: 'API 呼叫成功,但未回傳有效的 candidates。' };
      }

      let rawText = data.candidates[0].content.parts[0].text;
      
      try {
        let cleanText = rawText.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim();
        return JSON.parse(cleanText);
      } catch (parseErr) {
        return { isError: true, debugMsg: `JSON 解析失敗!\nAI 實際回傳內容:\n${rawText.substring(0, 150)}` };
      }

    } catch (err) {
      if (attempt === maxRetries) {
        return { isError: true, debugMsg: `連線發生例外狀況:${err.message}` };
      }
      Utilities.sleep(2000);
    }
  }
}

// ---------- LINE 訊息接收、下載與推播 ----------
function fetchLineImage_(messageId) {
  const secrets = getSecrets_();
  const res = UrlFetchApp.fetch(
    'https://api-data.line.me/v2/bot/message/' + messageId + '/content',
    {
      headers: { Authorization: 'Bearer ' + secrets.lineToken },
      muteHttpExceptions: true
    }
  );

  console.log('LINE 圖片下載 API:' + res.getResponseCode());
  return Utilities.base64Encode(res.getContent());
}

function sendReply_(replyToken, text) {
  const secrets = getSecrets_();

  const res = UrlFetchApp.fetch(
    'https://api.line.me/v2/bot/message/reply',
    {
      method: 'post',
      contentType: 'application/json',
      headers: { Authorization: 'Bearer ' + secrets.lineToken },
      payload: JSON.stringify({
        replyToken: replyToken,
        messages: [{ type: 'text', text: text }]
      }),
      muteHttpExceptions: true
    }
  );

  console.log(
    'LINE Reply API:' +
    res.getResponseCode() +
    ' / ' +
    res.getContentText()
  );
}

function sendPushMessage_(text) {
  const secrets = getSecrets_();

  if (!secrets.targetUserId) {
    console.error('尚未取得 TARGET_LINE_USER_ID,無法主動推播。');
    return;
  }

  const res = UrlFetchApp.fetch(
    'https://api.line.me/v2/bot/message/push',
    {
      method: 'post',
      contentType: 'application/json',
      headers: { Authorization: 'Bearer ' + secrets.lineToken },
      payload: JSON.stringify({
        to: secrets.targetUserId,
        messages: [{ type: 'text', text: text }]
      }),
      muteHttpExceptions: true
    }
  );

  console.log(
    'LINE Push API:' +
    res.getResponseCode() +
    ' / ' +
    res.getContentText()
  );
}

// ---------- 寫入試算表(防重複 + 自動由近到遠排序) ----------
function writeRecord_(record) {
  const secrets = getSecrets_();
  const sheet = SpreadsheetApp.openById(secrets.sheetId).getSheets()[0];

  let dateDisplay = formatFullDateWithWeekday_(record.date) || '';
  if (record.endDate && record.endDate !== record.date) {
    dateDisplay = formatFullDateWithWeekday_(record.date) + ' - ' + formatFullDateWithWeekday_(record.endDate);
  }

  let timeDisplay = '';
  if (!record.allDay && record.time) {
    timeDisplay = record.time;
    if (record.endTime) timeDisplay += '-' + record.endTime;
  }

  const summary = record.summary || '';
  const currentKey = buildEventKey_(dateDisplay, timeDisplay, summary);

  const lastRow = sheet.getLastRow();
  if (lastRow > 1) {
    const existingValues = sheet.getRange(2, 1, lastRow - 1, 3).getValues();
    for (let i = 0; i < existingValues.length; i++) {
      let rDate = existingValues[i][0];
      let rTime = existingValues[i][1];
      let rSummary = existingValues[i][2];

      if (rDate instanceof Date) rDate = Utilities.formatDate(rDate, CONFIG.TIMEZONE, 'yyyy-MM-dd');
      if (rTime instanceof Date) rTime = Utilities.formatDate(rTime, CONFIG.TIMEZONE, 'HH:mm');

      if (buildEventKey_(rDate, rTime, rSummary) === currentKey) return 'duplicate';
    }
  }

  sheet.appendRow([
    dateDisplay, timeDisplay, summary, record.category || '', record.note || '', '未完成', new Date()
  ]);

  sortSheetByDate_(sheet);
  return 'written';
}

// ---------- 建立日曆事件(防重複檢查) ----------
function maybeCreateEvent_(record) {
  if (!record.date) return false;

  const cal = CalendarApp.getDefaultCalendar();
  const title = '[' + (record.category || '待辦') + '] ' + (record.summary || '');
  const description = record.note || '';
  const isAllDay = record.allDay || !record.time;

  if (isAllDay) {
    const startDate = new Date(record.date + 'T00:00:00+08:00');
    let endDateForQuery;
    let eventEndDate;

    if (record.endDate && record.endDate !== record.date) {
      endDateForQuery = new Date(record.endDate + 'T23:59:59+08:00');
      const nextDay = new Date(record.endDate + 'T00:00:00+08:00');
      nextDay.setDate(nextDay.getDate() + 1);
      eventEndDate = nextDay;
    } else {
      endDateForQuery = new Date(record.date + 'T23:59:59+08:00');
    }

    const existingEvents = cal.getEvents(startDate, endDateForQuery);
    const isDuplicate = existingEvents.some(function(ev) {
      return ev.getTitle().indexOf(record.summary) !== -1 || record.summary.indexOf(ev.getTitle().replace(/\[.*?\]\s*/, '')) !== -1;
    });

    if (isDuplicate) return 'duplicate';

    if (eventEndDate) {
      cal.createAllDayEvent(title, startDate, eventEndDate, { description: description });
    } else {
      cal.createAllDayEvent(title, startDate, { description: description });
    }
    return 'created';

  } else {
    const start = new Date(record.date + 'T' + record.time + ':00+08:00');
    let end;

    if (record.endTime) {
      const targetEndDate = (record.endDate && record.endDate !== '') ? record.endDate : record.date;
      end = new Date(targetEndDate + 'T' + record.endTime + ':00+08:00');
    } else {
      end = new Date(start.getTime() + 30 * 60 * 1000);
    }

    const searchStart = new Date(start.getTime() - 10 * 60 * 1000);
    const searchEnd = new Date(end.getTime() + 10 * 60 * 1000);
    const existingEvents = cal.getEvents(searchStart, searchEnd);

    const isDuplicate = existingEvents.some(function(ev) {
      return ev.getTitle().indexOf(record.summary) !== -1 || record.summary.indexOf(ev.getTitle().replace(/\[.*?\]\s*/, '')) !== -1;
    });

    if (isDuplicate) return 'duplicate';

    const event = cal.createEvent(title, start, end, { description: description });
    event.addPopupReminder(30);
    return 'created';
  }
}

// ---------- 定時主動推播:每週一約 06:00 本週行程 ----------
function sendWeeklyReminder() {
  const cal = CalendarApp.getDefaultCalendar();
  const now = new Date();
  
  const distanceToMonday = (now.getDay() + 6) % 7;
  const monday = new Date(now);
  monday.setDate(now.getDate() - distanceToMonday);
  monday.setHours(0, 0, 0, 0);

  const sunday = new Date(monday);
  sunday.setDate(monday.getDate() + 6);
  sunday.setHours(23, 59, 59, 999);

  const events = cal.getEvents(monday, sunday);
  let msg = '☀️ 早安!本週行程提醒(' + Utilities.formatDate(monday, CONFIG.TIMEZONE, 'MM/dd') + ' ~ ' + Utilities.formatDate(sunday, CONFIG.TIMEZONE, 'MM/dd') + '):\n';

  if (events.length === 0) {
    msg += '\n本週目前沒有排定行程,祝你這週順心!';
  } else {
    events.forEach(function(ev) {
      const timeStr = formatChineseDate_(ev.getStartTime(), true, ev.isAllDayEvent());
      msg += '\n📌 ' + timeStr + ' ' + ev.getTitle();
      if (ev.getDescription()) msg += '(' + ev.getDescription() + ')';
    });
  }
  sendPushMessage_(msg);
}

// ---------- 定時主動推播:每天約 06:30 今明兩日行程 ----------
function sendDailyReminder() {
  const cal = CalendarApp.getDefaultCalendar();
  const now = new Date();

  const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
  const endToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59);
  const startTomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 0);
  const endTomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 23, 59, 59);

  const todayEvents = cal.getEvents(startToday, endToday);
  const tomorrowEvents = cal.getEvents(startTomorrow, endTomorrow);

  let msg = '☀️ 早安!待辦助理為您彙整今明日程:\n';

  msg += '\n📅【今日行程 ' + formatChineseDate_(startToday, false, false) + '】';
  if (todayEvents.length === 0) msg += '\n  無排定行程';
  else {
    todayEvents.forEach(function(ev) {
      const timeStr = ev.isAllDayEvent() ? '全天' : Utilities.formatDate(ev.getStartTime(), CONFIG.TIMEZONE, 'HH:mm');
      msg += '\n  • ' + timeStr + ' ' + ev.getTitle();
      if (ev.getDescription()) msg += '(' + ev.getDescription() + ')';
    });
  }

  msg += '\n\n📆【明日預告 ' + formatChineseDate_(startTomorrow, false, false) + '】';
  if (tomorrowEvents.length === 0) msg += '\n  無排定行程';
  else {
    tomorrowEvents.forEach(function(ev) {
      const timeStr = ev.isAllDayEvent() ? '全天' : Utilities.formatDate(ev.getStartTime(), CONFIG.TIMEZONE, 'HH:mm');
      msg += '\n  • ' + timeStr + ' ' + ev.getTitle();
      if (ev.getDescription()) msg += '(' + ev.getDescription() + ')';
    });
  }
  sendPushMessage_(msg);
}

// ---------- t定時主動推播:每月 1 號約 06:00 本月行程 ----------
function sendMonthlyReminder() {
  const result = handleCalendarQuery_('查本月');
  sendPushMessage_('🗓️ 新月份開始囉!以下為您整理本月行程:\n\n' + result);
}

// ---------- 手動測試與初始化函式 ----------
function initSheet() {
  const secrets = getSecrets_();
  const sheet = SpreadsheetApp.openById(secrets.sheetId).getSheets()[0];
  sheet.getRange(1, 1, 1, 7).setValues([['日期', '時間', '事項', '分類', '備註', '狀態', '建立時間']]).setFontWeight('bold');
}

function testParseSample() {
  const result = interpretText_('9/27 14:00 行銷會議,提早10分鐘到');
  console.log(JSON.stringify(result, null, 2));
  writeRecord_(result);
  maybeCreateEvent_(result);
}

// ---------- 一鍵將 Google 日曆現有行程匯入試算表(只補缺少項目 + 自動排序) ----------
function syncCalendarToSheet() {
  const secrets = getSecrets_();
  const sheet = SpreadsheetApp.openById(secrets.sheetId).getSheets()[0];
  const cal = CalendarApp.getDefaultCalendar();

  if (sheet.getLastRow() === 0) {
    sheet.getRange(1, 1, 1, 7).setValues([['日期', '時間', '事項', '分類', '備註', '狀態', '建立時間']]).setFontWeight('bold');
  }

  const startDate = new Date();
  startDate.setHours(0, 0, 0, 0);
  const endDate = new Date();
  endDate.setDate(startDate.getDate() + 180);
  endDate.setHours(23, 59, 59, 999);

  const events = cal.getEvents(startDate, endDate);
  if (events.length === 0) return console.log('指定區間內沒有日曆行程。');

  const existingKeys = new Set();
  if (sheet.getLastRow() > 1) {
    const existingData = sheet.getRange(2, 1, sheet.getLastRow() - 1, 3).getValues();
    for (let i = 0; i < existingData.length; i++) {
      let rDate = existingData[i][0] instanceof Date ? Utilities.formatDate(existingData[i][0], CONFIG.TIMEZONE, 'yyyy-MM-dd') : existingData[i][0];
      let rTime = existingData[i][1] instanceof Date ? Utilities.formatDate(existingData[i][1], CONFIG.TIMEZONE, 'HH:mm') : existingData[i][1];
      existingKeys.add(buildEventKey_(rDate, rTime, existingData[i][2]));
    }
  }

  const rowsToAdd = [];
  events.forEach(function(ev) {
    const rawTitle = ev.getTitle();
    let category = '其他', summary = rawTitle;
    const match = rawTitle.match(/^\[(.*?)\]\s*(.*)$/);
    if (match) { category = match[1]; summary = match[2]; }

    let dateDisplay = '', timeDisplay = '';
    if (ev.isAllDayEvent()) {
      const sStr = Utilities.formatDate(ev.getAllDayStartDate(), CONFIG.TIMEZONE, 'yyyy-MM-dd');
      const eStr = Utilities.formatDate(new Date(ev.getAllDayEndDate().getTime() - 86400000), CONFIG.TIMEZONE, 'yyyy-MM-dd');
      
      const sDisplay = formatFullDateWithWeekday_(sStr);
      const eDisplay = formatFullDateWithWeekday_(eStr);
      dateDisplay = (sStr === eStr) ? sDisplay : (sDisplay + ' - ' + eDisplay);
    } else {
      const sStr = Utilities.formatDate(ev.getStartTime(), CONFIG.TIMEZONE, 'yyyy-MM-dd');
      dateDisplay = formatFullDateWithWeekday_(sStr);
      timeDisplay = Utilities.formatDate(ev.getStartTime(), CONFIG.TIMEZONE, 'HH:mm') + '-' + Utilities.formatDate(ev.getEndTime(), CONFIG.TIMEZONE, 'HH:mm');
    }

    const key = buildEventKey_(dateDisplay, timeDisplay, summary);
    if (!existingKeys.has(key)) {
      rowsToAdd.push([dateDisplay, timeDisplay, summary, category, ev.getDescription() || '', '未完成', ev.getDateCreated() || new Date()]);
      existingKeys.add(key);
    }
  });

  if (rowsToAdd.length > 0) {
    sheet.getRange(sheet.getLastRow() + 1, 1, rowsToAdd.length, 7).setValues(rowsToAdd);
    console.log('同步完成!共補寫入 ' + rowsToAdd.length + ' 筆缺少行程至試算表。');
  }
  sortSheetByDate_(sheet);
}

// ---------- 處理刪除與修改邏輯 ----------
function deleteTargetEvent_(tDate, tSummary) {
  let foundCal = false;
  let foundSheet = false;

  const cal = CalendarApp.getDefaultCalendar();
  const start = new Date(tDate + 'T00:00:00+08:00');
  const end = new Date(tDate + 'T23:59:59+08:00');
  const events = cal.getEvents(start, end); 
  
  for (let ev of events) {
    if (ev.getTitle().indexOf(tSummary) !== -1) {
      ev.deleteEvent();
      foundCal = true;
      break; 
    }
  }

  const secrets = getSecrets_();
  const sheet = SpreadsheetApp.openById(secrets.sheetId).getSheets()[0];
  const lastRow = sheet.getLastRow();
  
  if (lastRow > 1) {
    const data = sheet.getRange(2, 1, lastRow - 1, 3).getValues();
    for (let i = data.length - 1; i >= 0; i--) { 
      let rDate = data[i][0];
      let rSummary = data[i][2];
      
      if (rDate instanceof Date) rDate = Utilities.formatDate(rDate, CONFIG.TIMEZONE, 'yyyy-MM-dd');
      
      if (String(rDate).indexOf(tDate) !== -1 && String(rSummary).indexOf(tSummary) !== -1) {
        sheet.deleteRow(i + 2); 
        foundSheet = true;
        break; 
      }
    }
  }
  return (foundCal || foundSheet);
}

留言

這個網誌中的熱門文章

名字,不單只是名字有時認真,有時很鬧。講的就是我🤣

居然把自己做的 LINE 貼圖送上架了