建一張試算表 → 貼程式 → 放好兩把金鑰 → 部署 → 用手機傳訊息給它
另外請先在手機 LINE 設好「電子郵件帳號」和「密碼」(LINE → 設定 → 帳號):第 3 步要用它在電腦登入 LINE 官方帳號的後台,沒設會登不進去。
有事就來敲門:LINE 收到訊息時,會主動去敲你留給它的網址,這種安排叫 Webhook。
欄位固定的資料:Gemini 回給程式的不是一段話,而是「事項、分類、日期、時間」這種欄位固定的資料(JSON),程式才分得出哪個是日期。
金鑰是一串「證明是你在用」的密碼(API key),不要截圖,也不要貼到群組。開頭字母不一定是 AIza,新建的多半是 AQ. 開頭,別靠開頭判斷對不對。
LINE 機器人需要三樣東西:LINE 官方帳號(機器人的身分)、打開它的 Messaging API(讓程式收發訊息的通道),以及一組長效通行碼(channel access token)。
2024 年 9 月起的順序:先建官方帳號 → 在官方帳號管理後台啟用 Messaging API → 到 LINE Developers 後台發長效通行碼。後台畫面常改版,按鈕字樣以當天畫面為準;看不懂下一步,就把畫面上的字貼給 AI 問。官方說明:Getting started。
3-1 建官方帳號
3-2 啟用 Messaging API
3-3 關掉罐頭回覆
3-4 發長效通行碼
GEMINI_API_KEY、LINE_CHANNEL_ACCESS_TOKEN。金鑰放這裡、不寫進程式碼,分享程式或截圖時才不會一起外流。testSetup,按「執行」。/exec 的網址。目標:傳一張活動海報或便條紙的照片,bot 也能記下來。這次在自己的電腦上請 AI agent 改程式,再用 clasp(Google 做的指令工具)一次推上雲端。
npm install -g @google/clasp。也可以直接請 AI agent 幫你裝。clasp login,用同一個個人帳號登入。在 Apps Script「專案設定」複製「指令碼 ID」,開一個新資料夾,執行 clasp clone <指令碼 ID>。
這是一支 LINE 待辦機器人的 Apps Script(Code.gs)。請幫我加上:使用者傳照片時,把照片下載下來交給 Gemini 判讀,整理成跟文字訊息一樣的欄位,再記進試算表。也請加上「對方正在輸入…」的動畫,並且確定試算表真的寫進去了,才回覆「記好了」。金鑰繼續從指令碼屬性讀,不要寫進程式。
它改了哪些地方要自己看過,看不懂的請它解釋。對照答案在「程式碼」分頁的 Level 2。
clasp push:把本機的程式整份覆蓋到雲端。/exec 跑的是「部署那一刻」的版本,要再更新部署:clasp list-deployments 找到部署 ID,執行 clasp update-deployment <部署 ID>;或在網頁上「部署 → 管理部署作業 → 鉛筆 → 版本選『新版本』」。傳一張活動海報或便條紙的照片,看回覆,再看試算表「來源」欄是不是「照片」。
/exec 網址不要公開:Apps Script 看不到 LINE 附在訊息上的簽章,知道網址的人都能假冒 LINE 送訊息進來。兩支程式都從「指令碼屬性」讀金鑰,程式裡找不到任何金鑰,也不該出現金鑰。
| Level 1 基礎版 | Level 2 文字+照片 | |
|---|---|---|
| 做事順序 | 問 AI → 寫表 → 排日曆 → 全部做完才回覆 | 問 AI → 驗算 → 寫表 → 先回覆 → 再排日曆,結果寫回表 |
| 看得懂 | 文字 | 文字、照片 |
| 打招呼 | 也拿去問 AI | 程式直接回,不花 AI 額度 |
| AI 給錯格式 | 直接出錯,回報錯誤訊息 | 先驗算:日期不存在、時間格式不對,就改成「未指定」 |
| 手機要等 | 比較久(每一步都要排隊) | 比較短,而且有「對方正在輸入…」動畫 |
levels/Level1-Basic.gs:貼到試算表的「擴充功能 → Apps Script」
/**
* LINE AI 待辦小秘書(Level 1:基礎版)
*
* 一步做完再做下一步:收到訊息 → 問 Gemini → 寫試算表 → 排日曆 → 回覆 LINE。
* 最好讀懂,但手機要等上好幾秒。Level 2 會把它變快,還會多一項本事:看得懂照片。
*
* 用法:在試算表按「擴充功能 → Apps Script」,把這整份貼進去。
* 金鑰放「專案設定 → 指令碼屬性」:GEMINI_API_KEY、LINE_CHANNEL_ACCESS_TOKEN
*/
const GEMINI_API_KEY = PropertiesService.getScriptProperties().getProperty("GEMINI_API_KEY") || "";
const LINE_ACCESS_TOKEN = PropertiesService.getScriptProperties().getProperty("LINE_CHANNEL_ACCESS_TOKEN") || "";
// 模型名稱會隨 Google 改版而停用:上課前到 https://ai.google.dev/gemini-api/docs/models 確認還在
const GEMINI_MODEL = "gemini-3.5-flash-lite";
// LINE 每收到一則訊息,就會來敲這個門
function doPost(e) {
const events = JSON.parse(e.postData.contents).events; // 在 LINE 後台按 Verify 時,這裡是空的
for (const event of events) {
if (event.type !== "message" || !event.replyToken) continue; // 沒有回覆碼就回不了話,直接跳過
if (event.message.type !== "text") {
replyLine(event.replyToken, "Level 1 只看得懂文字;照片要等 Level 2 教它。");
continue;
}
try {
const todo = askGemini(event.message.text); // ① 問 Gemini
saveToSheet(todo); // ② 寫試算表
const hasTime = todo.date !== "未指定" && todo.time !== "未指定";
if (hasTime) createCalendarEvent(todo); // ③ 有日期和時間才排日曆
replyLine(event.replyToken, // ④ 全部做完才回覆
`✅ 記好了\n📌 ${todo.summary}\n🏷️ ${todo.category}\n📅 ${todo.date} ⏰ ${todo.time}` +
(hasTime ? "\n🗓️ 已排進 Google 日曆" : ""));
} catch (err) {
// 出錯就把原因回給手機:整段貼給 AI,問它怎麼修
replyLine(event.replyToken, "⚠️ 出錯了:" + err.message);
}
}
return ContentService.createTextOutput("OK");
}
// 用瀏覽器打開 /exec 網址會看到這行字,可以確認部署成功
function doGet() {
return ContentService.createTextOutput("LINE AI 待辦小秘書運作中(Level 1)");
}
// ① 請 Gemini 把一句口語整理成固定欄位
function askGemini(text) {
const now = Utilities.formatDate(new Date(), "Asia/Taipei", "yyyy-MM-dd EEEE HH:mm");
const prompt = `你是待辦事項助理。現在是台北時間 ${now}。
從使用者的話整理出待辦事項,把「明天」「下週五」這類說法換算成實際日期。
只輸出一個 JSON 物件:
{"summary": "事項", "category": "繳費、會議、課業、生活、其他 五選一",
"date": "YYYY-MM-DD,沒提到就填 未指定", "time": "HH:mm,沒提到就填 未指定"}
使用者說:${text}`;
const res = UrlFetchApp.fetch(`https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`, {
method: "post",
contentType: "application/json",
headers: { "x-goog-api-key": GEMINI_API_KEY },
payload: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { responseMimeType: "application/json" }
}),
muteHttpExceptions: true
});
if (res.getResponseCode() !== 200) {
throw new Error(`Gemini 回應 ${res.getResponseCode()}:${res.getContentText().slice(0, 150)}`);
}
const data = JSON.parse(res.getContentText());
return JSON.parse(data.candidates[0].content.parts[0].text);
}
// ② 寫進這份試算表的第一個工作表
function saveToSheet(todo) {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0];
if (sheet.getLastRow() === 0) sheet.appendRow(["建立時間", "事項", "分類", "日期", "時間"]);
const now = Utilities.formatDate(new Date(), "Asia/Taipei", "yyyy-MM-dd HH:mm:ss");
sheet.appendRow([now, todo.summary, todo.category, todo.date, todo.time]);
}
// ③ 在自己的 Google 日曆排 30 分鐘的行程,提前 30 分鐘提醒
function createCalendarEvent(todo) {
const start = new Date(`${todo.date}T${todo.time}:00+08:00`);
// AI 偶爾會給不存在的日子(例如 2 月 30 日),JavaScript 會默默換算成 3 月 2 日。
// 轉回文字對不上就停下來,別排出一個錯的行程(Level 2 會在更前面就把關)
if (isNaN(start.getTime()) || Utilities.formatDate(start, "Asia/Taipei", "yyyy-MM-dd HH:mm") !== `${todo.date} ${todo.time}`) {
throw new Error(`AI 給的日期或時間不存在:${todo.date} ${todo.time}`);
}
const end = new Date(start.getTime() + 30 * 60 * 1000);
CalendarApp.getDefaultCalendar()
.createEvent(`【${todo.category}】${todo.summary}`, start, end)
.addPopupReminder(30);
}
// ④ 用回覆碼回訊息給傳訊息的人(回覆不算進每月的免費則數)
function replyLine(replyToken, text) {
const res = UrlFetchApp.fetch("https://api.line.me/v2/bot/message/reply", {
method: "post",
contentType: "application/json",
headers: { Authorization: "Bearer " + LINE_ACCESS_TOKEN },
payload: JSON.stringify({ replyToken: replyToken, messages: [{ type: "text", text: text }] }),
muteHttpExceptions: true
});
// 回覆失敗(token 貼錯、回覆碼過期)時手機上什麼都看不到,只能到 Apps Script 的執行紀錄裡找
if (res.getResponseCode() !== 200) console.error("LINE 回覆失敗:", res.getResponseCode(), res.getContentText());
}
// 自我檢查:在編輯器上方選 testSetup,按「執行」
function testSetup() {
const todo = askGemini("明天早上十點要交房租,記得轉帳給房東");
console.log("Gemini 整理出來的欄位:", JSON.stringify(todo));
saveToSheet(todo);
console.log("👉 打開試算表,親眼看到多了一列才算成功。出現權限錯誤,或這裡沒報錯、表裡卻沒有,多半是授權畫面沒有全部勾選。");
}
apps-script/Code.gs:Level 2 對照答案,也是老師示範 bot 的正本
/**
* LINE AI 待辦小秘書(Level 2:文字+照片)
*
* 這份是正本:老師的示範 bot、學生 Level 2 的對照答案,都以這份為準。
* 一則訊息的旅程:
* LINE 送來訊息 → 請 Gemini 把口語整理成固定欄位 → 程式驗算欄位
* → 寫進試算表 → 回覆 LINE(只講已經做完的事)→ 排 Google 日曆,結果寫回試算表
*
* 金鑰不寫在程式裡,一律放「專案設定 → 指令碼屬性」:
* GEMINI_API_KEY 必填,到 Google AI Studio 申請
* LINE_CHANNEL_ACCESS_TOKEN 必填,LINE Developers 後台發的長效 token
* SHEET_ID 選填,程式不是從試算表「擴充功能 → Apps Script」開的才要填
* GEMINI_MODEL 選填,模型停用或想換模型時改這裡,不用動程式
*/
// ===== 1. 設定 =====
const PROPS = PropertiesService.getScriptProperties();
const GEMINI_API_KEY = PROPS.getProperty("GEMINI_API_KEY") || "";
const LINE_ACCESS_TOKEN = PROPS.getProperty("LINE_CHANNEL_ACCESS_TOKEN") || "";
const SHEET_ID = PROPS.getProperty("SHEET_ID") || "";
// 模型名稱會隨 Google 改版而停用:上課前到 https://ai.google.dev/gemini-api/docs/models 確認還在
const GEMINI_MODEL = PROPS.getProperty("GEMINI_MODEL") || "gemini-3.5-flash-lite";
const TZ = "Asia/Taipei";
const CATEGORIES = ["繳費", "會議", "課業", "生活", "其他"];
const HEADERS = ["建立時間", "事項", "分類", "日期", "時間", "來源", "日曆"];
const GREETING = /^(hi|hello|hey|你好|嗨|哈囉|安安|早安|午安|晚安|謝謝|感謝|ok|好的|掰|bye|test|測試)$/i;
const HELP_TEXT = "你好!我是你的 AI 待辦小秘書 🤖\n\n直接傳給我:\n•「明天下午三點要開會」\n•「週五前記得繳房租」\n• 或拍一張活動海報、便條紙\n\n我會記進試算表;有日期和時間的,再幫你排進 Google 日曆。";
// ===== 2. LINE 送訊息進來的入口 =====
function doPost(e) {
try {
const body = JSON.parse((e && e.postData && e.postData.contents) || "{}");
// 在 LINE 後台按 Verify 時,送來的 events 是空的,這裡就什麼都不做
(body.events || []).forEach(handleEvent);
} catch (err) {
console.error("讀不懂 LINE 送來的內容:", err);
}
return ContentService.createTextOutput("OK");
}
// 用瀏覽器打開 /exec 網址會看到這行字,可以確認部署成功
function doGet() {
return ContentService.createTextOutput("LINE AI 待辦小秘書運作中(Level 2)");
}
// 一次處理一則訊息;其中一則出錯,不影響同一批的其他訊息
function handleEvent(event) {
if (event.type !== "message" || !event.replyToken) return;
try {
const msg = event.message;
if (msg.type === "text") {
const text = msg.text.trim();
if (GREETING.test(text)) return replyLine(event.replyToken, HELP_TEXT); // 打招呼不必問 AI,直接回
showLoading(event.source);
return saveAndReply(event.replyToken, askGeminiAboutText(text), "文字");
}
if (msg.type === "image") {
showLoading(event.source);
const image = downloadLineImage(msg.id);
return saveAndReply(event.replyToken, askGeminiAboutImage(image), "照片");
}
replyLine(event.replyToken, "目前我看得懂文字和照片,其他類型還不行喔。");
} catch (err) {
console.error("處理訊息失敗:", err);
// 課堂版刻意把錯誤原因回給手機:學生可以整段貼給 AI,問它怎麼修
try {
replyLine(event.replyToken, "⚠️ 出錯了:" + err.message);
} catch (err2) {
console.error("連錯誤訊息都回不出去(多半是 LINE token 沒設定或貼錯):", err2);
}
}
}
// ===== 3. 記錄、回覆、排日曆 =====
function saveAndReply(replyToken, todo, source) {
if (!todo.isTodo) return replyLine(replyToken, todo.chatReply || HELP_TEXT);
// ① 先寫進試算表:確定真的寫進去了,才能跟使用者說「記好了」
const row = appendTodoRow(todo, source);
const wantsCalendar = todo.date !== "未指定" && todo.time !== "未指定";
// ② 回覆手機:只講已經做完的事(回覆碼要在收到訊息後 1 分鐘內用掉)
let reply = `✅ 記好了(試算表第 ${row} 列)\n\n📌 ${todo.summary}\n🏷️ ${todo.category}\n📅 ${todo.date} ⏰ ${todo.time}`;
reply += wantsCalendar
? "\n🗓️ 日曆提醒建立中,結果會寫在試算表的「日曆」欄"
: "\nℹ️ 沒有明確的日期和時間,這筆先不排日曆";
replyLine(replyToken, reply);
// ③ 最後才排日曆;成功或失敗都寫回試算表,不讓人誤以為做好了
if (wantsCalendar) getSheet().getRange(row, 7).setValue(createCalendarEvent(todo));
}
function getSheet() {
const ss = SHEET_ID ? SpreadsheetApp.openById(SHEET_ID) : SpreadsheetApp.getActiveSpreadsheet();
if (!ss) throw new Error("找不到試算表:請從試算表的「擴充功能 → Apps Script」開程式,或在指令碼屬性填 SHEET_ID");
const sheet = ss.getSheets()[0];
if (sheet.getLastRow() === 0) {
sheet.appendRow(HEADERS);
sheet.getRange(1, 1, 1, HEADERS.length).setFontWeight("bold").setBackground("#0E7C7B").setFontColor("#FFFFFF");
}
return sheet;
}
function appendTodoRow(todo, source) {
const sheet = getSheet();
const now = Utilities.formatDate(new Date(), TZ, "yyyy-MM-dd HH:mm:ss");
// 全班同時傳訊息時,先排隊再寫,避免兩筆搶到同一列
const lock = LockService.getScriptLock();
lock.waitLock(10000);
try {
sheet.appendRow([now, todo.summary, todo.category, todo.date, todo.time, source, ""]);
return sheet.getLastRow();
} finally {
lock.releaseLock();
}
}
function createCalendarEvent(todo) {
try {
const start = new Date(`${todo.date}T${todo.time}:00+08:00`);
const end = new Date(start.getTime() + 30 * 60 * 1000);
const event = CalendarApp.getDefaultCalendar().createEvent(`【${todo.category}】${todo.summary}`, start, end, {
description: "由 LINE AI 待辦小秘書建立"
});
event.addPopupReminder(30);
return "已建立";
} catch (err) {
console.error("建立日曆失敗:", err);
return "失敗:" + err.message;
}
}
// ===== 4. 請 Gemini 把口語整理成固定欄位 =====
const JSON_SHAPE = `只輸出一個 JSON 物件,欄位固定如下:
{"isTodo": true 或 false,
"summary": "事項,簡短一句",
"category": "繳費、會議、課業、生活、其他 五選一",
"date": "YYYY-MM-DD,沒提到就填 未指定",
"time": "HH:mm(24 小時制),沒提到就填 未指定",
"chatReply": "不是待辦時,給使用者的一句簡短回覆;是待辦就填空字串"}`;
// 附上星期幾,AI 才算得準「下週五」是哪一天
function nowText() {
return Utilities.formatDate(new Date(), TZ, "yyyy-MM-dd EEEE HH:mm");
}
function askGeminiAboutText(text) {
const prompt = `你是待辦事項助理。現在是台北時間 ${nowText()}。
判斷使用者這句話是不是待辦、提醒或行程。
是的話,把「明天」「下週五」這類說法換算成實際日期;不是的話,isTodo 填 false。
${JSON_SHAPE}
使用者說:${text}`;
return checkTodo(callGemini([{ text: prompt }]));
}
function askGeminiAboutImage(blob) {
const prompt = `你是待辦事項助理。現在是台北時間 ${nowText()}。
這是一張照片,可能是活動海報、便條紙或繳費單。找出上面最重要的一件待辦或活動。
照片上沒寫的欄位就填 未指定,不要自己猜。
${JSON_SHAPE}`;
return checkTodo(callGemini([
{ text: prompt },
{ inlineData: { mimeType: blob.getContentType() || "image/jpeg", data: Utilities.base64Encode(blob.getBytes()) } }
]));
}
function callGemini(parts) {
if (!GEMINI_API_KEY) throw new Error("還沒設定 GEMINI_API_KEY(專案設定 → 指令碼屬性)");
const res = UrlFetchApp.fetch(`https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`, {
method: "post",
contentType: "application/json",
headers: { "x-goog-api-key": GEMINI_API_KEY }, // 金鑰放標頭、不放網址,才不會留在各種紀錄裡
payload: JSON.stringify({
contents: [{ role: "user", parts: parts }],
generationConfig: { responseMimeType: "application/json" }
}),
muteHttpExceptions: true
});
const code = res.getResponseCode();
if (code !== 200) throw new Error(`Gemini 回應 ${code}:${res.getContentText().slice(0, 150)}`);
const data = JSON.parse(res.getContentText());
const cand = (data.candidates || [])[0];
const text = cand && cand.content && (cand.content.parts || []).map(p => p.text || "").join("");
if (!text) throw new Error(`Gemini 沒有給內容(原因:${cand ? cand.finishReason : "沒有任何答案"})`);
return JSON.parse(text.replace(/^```(json)?\s*|\s*```$/g, ""));
}
// ===== 5. 驗算:AI 給的欄位不直接照單全收 =====
function checkTodo(t) {
return {
isTodo: t.isTodo === true,
summary: String(t.summary || "").trim() || "(沒抓到事項)",
category: CATEGORIES.includes(t.category) ? t.category : "其他",
date: isRealDate(t.date) ? t.date : "未指定",
time: /^([01]\d|2[0-3]):[0-5]\d$/.test(t.time) ? t.time : "未指定",
chatReply: String(t.chatReply || "")
};
}
// 「2026-02-30」格式對、日子卻不存在:轉成日期再轉回來,對得上才算數
function isRealDate(s) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false;
const d = new Date(`${s}T12:00:00+08:00`);
return !isNaN(d.getTime()) && Utilities.formatDate(d, TZ, "yyyy-MM-dd") === s;
}
// ===== 6. LINE 相關 =====
function lineApi(path, payload, host) {
if (!LINE_ACCESS_TOKEN) throw new Error("還沒設定 LINE_CHANNEL_ACCESS_TOKEN(專案設定 → 指令碼屬性)");
const options = {
method: payload ? "post" : "get",
headers: { Authorization: "Bearer " + LINE_ACCESS_TOKEN },
muteHttpExceptions: true
};
if (payload) {
options.contentType = "application/json";
options.payload = JSON.stringify(payload);
}
return UrlFetchApp.fetch((host || "https://api.line.me") + path, options);
}
function replyLine(replyToken, text) {
const res = lineApi("/v2/bot/message/reply", {
replyToken: replyToken,
messages: [{ type: "text", text: text.slice(0, 5000) }]
});
if (res.getResponseCode() !== 200) console.error("LINE 回覆失敗:", res.getResponseCode(), res.getContentText());
}
// 「對方正在輸入…」動畫:只有一對一聊天看得到,沒開成也不影響功能
function showLoading(source) {
if (!source || source.type !== "user") return;
try {
lineApi("/v2/bot/chat/loading/start", { chatId: source.userId, loadingSeconds: 20 });
} catch (err) {
console.warn("輸入中動畫沒開成:", err);
}
}
// 照片要到另一個網域(api-data)下載,而且 LINE 不會一直保存,收到就要抓
function downloadLineImage(messageId) {
const res = lineApi(`/v2/bot/message/${messageId}/content`, null, "https://api-data.line.me");
if (res.getResponseCode() !== 200) throw new Error(`照片下載失敗(LINE 回應 ${res.getResponseCode()})`);
const blob = res.getBlob();
// 送給 Gemini 的請求有大小上限,照片轉成文字(Base64)還會再大三分之一,太大的先請使用者縮小
if (blob.getBytes().length > 10 * 1024 * 1024) throw new Error("照片太大(超過 10 MB),請截圖或壓縮後再傳");
return blob;
}
// ===== 7. 自我檢查:在編輯器上方選 testSetup,按「執行」 =====
function testSetup() {
const results = [];
const check = (name, fn) => {
try {
results.push(`✅ ${name}:${fn()}`);
} catch (err) {
results.push(`❌ ${name}:${err.message}`);
}
};
check("LINE token", () => {
const res = lineApi("/v2/bot/info");
if (res.getResponseCode() !== 200) throw new Error(`LINE 回應 ${res.getResponseCode()},token 可能貼錯或已經重發`);
return "bot 名稱「" + JSON.parse(res.getContentText()).displayName + "」";
});
check("Google 日曆", () => "可以使用「" + CalendarApp.getDefaultCalendar().getName() + "」");
let todo = null;
check("Gemini", () => {
todo = askGeminiAboutText("明天早上十點要交房租,記得轉帳給房東");
return JSON.stringify(todo);
});
check("試算表", () => {
if (!todo) throw new Error("Gemini 那關沒過,先不寫");
return `寫進第 ${appendTodoRow(todo, "testSetup")} 列`;
});
console.log(results.join("\n"));
console.log("👉 打開試算表,親眼看到多了一列才算成功。出現權限錯誤,或這裡說成功、表裡卻沒有,多半是授權畫面沒有全部勾選。");
}
用 clasp 時會一起推上去:設定台北時區、網頁應用程式的存取方式,以及要跟你要的三種權限。
{
"timeZone": "Asia/Taipei",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"webapp": {
"executeAs": "USER_DEPLOYING",
"access": "ANYONE_ANONYMOUS"
},
"oauthScopes": [
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/script.external_request"
]
}
點一個例句開始。
—
勾選會存在這台電腦的瀏覽器裡;換電腦或無痕視窗不會保留。
把錯誤訊息整段貼給 AI,照這個格式問:
我在做 LINE 待辦機器人(Apps Script+Gemini+LINE Messaging API)。我在做〔哪一步〕,出現這段錯誤:〔整段貼上〕。請告訴我可能的原因,以及我要去哪裡確認。
依序確認:部署時「誰可以存取」是不是「所有人」(不是「任何擁有 Google 帳戶的使用者」)?Webhook URL 是不是結尾 /exec 的那個網址?LINE Developers 的「Use webhook」、官方帳號回應設定裡的「Webhook」都打開了嗎?改過程式卻沒更新部署,也會這樣。
用 Apps Script 當 Webhook 常會這樣:回應被轉到另一個網址,LINE 看到轉址就判定失敗;存取權設錯時也會看到 302,所以這個訊息判斷不了成敗。先記下錯誤,再換一條路確認:用手機傳一則訊息,看試算表和 Apps Script 的執行紀錄。
這是程式刻意把錯誤原因回給你。把那段文字整段貼給 AI。常見的有:金鑰沒設定、Gemini 回應 404(模型名稱停用了)、429(今天的免費額度用完)。
多半是授權沒有全部勾選。到 Google 帳戶的第三方連結移除這個專案,回編輯器重跑 testSetup,這次全部勾選。
模型會改版停用。到 Gemini 模型頁找現在能用的名字:Level 1 改程式裡的 GEMINI_MODEL,Level 2 在指令碼屬性加一筆 GEMINI_MODEL。
到官方帳號的「回應設定」,把「自動回應訊息」關掉。
LINE Developers 的「Webhook 重新傳送(redelivery)」被打開了。Apps Script 的回應在 LINE 看來都算失敗,一打開就會重送,請關掉。
到 Apps Script 使用者設定打開開關,等幾分鐘再試。
還要更新部署:clasp update-deployment <部署 ID>,或網頁上「管理部署作業 → 新版本」。