把 LINE 待辦小幫手,升級成我的專屬 AI 秘書【麥麥特助】
因為看了數位時代分享的這篇【把 LINE 變成待辦小幫手!4 步驟教你用 Google Apps Script 串接 Gemini,一句話自動排入日曆】
也讓我興起了自己做一個專屬 LINE 秘書的念頭。我把我的機器人設定成【麥麥特助】,主力請 Gemini 寫程式碼,寫完再請 ChatGPT 幫忙複查,因為 GPT 很熟悉我的生活型態,會精準抓出我在日常使用中容易漏掉的盲點。
經過兩個 AI 的反覆除錯與調校後,【麥麥特助】終於正式上線了!
要完成這個 LINE 機器人,需要準備 5 樣東西:
Google Apps Script
1~4 都是 Google 家的服務,在個人正常用量下目前都是免費提供;
5 的 LINE 官方帳號,目前官方方案提供「每月 200 則免費主動推播」,只要沒超過額度就一毛錢都不用花。
這篇落落長……最後我會把改過、可以直接上線的完整程式碼留給有緣人參考;至於最初步的串接設定,請大家直接照著數位時代那篇教學一步步點選即可。
(如果中途卡住,直接截圖丟給你的 AI,請他帶你找設定的位置最快🤣)
目前【麥麥特助】可以幫我做到:
隨拍隨記,不用自己打字
拿到看診單、繳費單直接拍照截圖丟過去,或是隨口打一句話,AI 就自動幫我記進 Google 日曆跟試算表,連備註(如「順便抽血」、「提早30分鐘到」)都會自己抓。
在 LINE 隨時查行程
出門在外不用切換日曆 App,在 LINE 傳「查今天」、「查這週」或「查本月」,它立刻用中文條列全部行程回報給我。
早上自動叫我、不用額外花錢
每天早上約 06:30 自動傳 LINE 跟我說今天、明天要做什麼;每週一早上約 06:00 還會推播這週總覽;每月 1 號自動彙整當月重點日程。每個月推播額度用不到兩成,目前完全 0 元、不用多花一毛錢!
直接用 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 左側工具列點選「觸發條件」(鬧鐘圖示 ⏰),在右下角點「+ 新增觸發條件」,依序新增以下三個定時任務:
- 每天早安推播(今明日程提醒):
- 選擇要執行的功能:
sendDailyReminder - 活動來源:
時間驅動 - 時間型觸發條件類型:
日計時器 - 當日時段:
早上 6 點到 7 點
- 選擇要執行的功能:
- 每週一早安推播(本週總覽):
- 選擇要執行的功能:
sendWeeklyReminder - 活動來源:
時間驅動 - 時間型觸發條件類型:
週計時器 - 星期幾:
每星期一 - 當日時段:
早上 6 點到 7 點
- 選擇要執行的功能:
- 每月 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);
}

留言