昨天講完重複網址怎麼查重,最後提到 LINE 上那些搜尋、刪除、註記、標待辦的指令,今天就來看這幾個指令是怎麼分工的。先講結論:這一整塊,我一行 AI 都沒用。
既然存筆記都靠 AI 分類,搜尋和下指令也可以丟給模型,讓我直接用講的,像「幫我把那篇 Cloudflare 的刪掉」。我沒有這樣做,理由很實際:這些操作的意圖都很單純,而且做錯的代價比分類錯高。分類下歪了我可以事後改,但誤刪或誤標,我不想承擔「模型這次可能理解錯」的風險。
所以入口 handleTextMessage 的判斷順序是純規則:
const command = parseCommand(text);
if (command) {
await handleCommand(env, replyToken, command);
return;
}
const match = text.match(/https?:\/\/[^\s]+/);
// 沒有網址 → 當作搜尋關鍵字
if (!match) {
await handleSearch(env, replyToken, text);
return;
}
// 有網址(且不是指令)→ 才走抓內文、AI 分類、寫進 Notion
一則訊息進來,先看是不是「指令關鍵字 + 網址」,不是的話看有沒有網址,沒有網址就當搜尋,都不是才走存筆記。AI 只出現在最後那條路,前面兩條完全沒碰。
指令的比對靠一張對照表:
const COMMANDS = [
{ keys: ['取消待辦', '取消待办', 'untodo'], action: 'untodo' },
{ keys: ['待辦', '待办', 'todo'], action: 'todo' },
{ keys: ['刪除', '删除', 'delete'], action: 'delete' },
{ keys: ['註記', '注记', '備註', '备注', 'note'], action: 'note' },
{ keys: ['分類', '分类', 'category'], action: 'category' },
];
parseCommand 用 startsWith 一個一個比,所以「取消待辦」一定要排在「待辦」前面,不然「取消待辦」會先被當成「待辦」吃掉,意思整個反過來。每組關鍵字除了繁體中文,還多放了簡體和英文的寫法。
比到關鍵字之後,還要在後面抓得到網址才算指令,沒有網址就 continue,讓訊息繼續往下掉到搜尋。這是刻意的:「待辦」單獨傳,會列出待辦清單;「待辦 https://...」才是把那篇標成待辦。關鍵字一樣,有沒有網址決定它是哪一種意思。網址後面剩下的文字則當作 extra,註記的內容和新的分類名稱都從這裡來。
有一個小細節要留意:parseCommand 的比對沒有轉小寫,所以「todo <網址>」可以,「Todo <網址>」不行。手機輸入法如果自動把句首字母大寫,這則訊息會因為帶網址、又沒被認成指令,直接被當成新網址存進去。搜尋那邊的關鍵字有做 toLowerCase(),兩邊處理不一致,這是目前留著的小缺口。
指令要對哪一篇筆記動手,靠 resolvePageId 找出 page ID,這個函式昨天講過了,可以貼 Notion 頁面連結,也可以貼原始文章網址。找到之後,四個動作各自很短:
case 'delete':
await archiveNotionPage(env, pageId); // PATCH { archived: true }
case 'note':
await appendNote(env, pageId, command.extra);
case 'todo':
await setTodo(env, pageId, true); // 勾選「待辦」checkbox
case 'category':
await setCategory(env, pageId, command.extra);
刪除不是真的刪。archiveNotionPage 只是對頁面送 { archived: true },等於丟進 Notion 垃圾桶,30 天內還撈得回來,回覆訊息裡我也有寫明這件事。刪除是這四個動作裡最危險的一個,用封存而不是真的移除,操作失誤還有救。
註記則是疊加,跟 Day 14 講的邏輯一樣,appendNote 在頁面內容底下補一段,前面帶著台北時間的時間戳,可以一直加,不會蓋掉舊的。
分類就不設限。setCategory 直接把我打的字寫進去,不用管是不是在 CATEGORIES 那七個裡面,Notion 遇到沒看過的名稱會自動建立新選項。AI 分類時我把它限制在固定清單裡,是為了讓資料整齊;但我自己手動改的時候,有想法就是有想法,不需要再被清單管。
沒有網址的訊息進到 handleSearch,裡面也是一串 if:
searchNotion
最後那個才是一般搜尋。搜尋照理說可以用 Notion 的 API filter 去篩,但這裡有個雷:分類是 select、標籤是 multi_select,篩選時給的值如果不是資料庫裡已經存在的選項,API 直接回 400,包在 or 裡也一樣。搜尋的關鍵字很常不是既有選項,這條路走不通。
所以 searchNotion 改成先抓最近 100 筆回來,自己在 JS 裡把標題、分類、標籤串成一個字串,用 includes 比對關鍵字,最後只回前 5 筆。
const haystack = [item.title, item.category, ...item.tags].join(' ').toLowerCase();
return haystack.includes(needle);
這種做法很土,而且有明顯的限制:只搜得到最近 100 筆,也只能比對標題、分類、標籤,不會搜摘要或內文。但對我的用法夠了,搜尋的詞多半是分類或標籤裡看得到的字,部分符合就找得到。這也是為什麼標籤一致這麼重要,Day 11 花力氣讓 AI 重複用同一個詞,在這裡派上用場:標籤越統一,單純的字串比對就越準。
回頭看這一整塊,我覺得可以用一個標準來切:使用者要的東西是不是有明確答案。
存筆記要判斷「這篇屬於哪個分類、值得記哪幾個重點」,沒有標準答案,才需要 AI。刪除、標待辦、改分類、加註記這些,我要的就是照字面執行,一個字都不想被詮釋。搜尋也是,我要的是「標題有這個詞的都給我」,不是「模型覺得相關的給我」。
用規則還有兩個附帶好處。一個是不吃模型額度,也不怕免費模型掛掉,Day 12 講的 fallback 在這條路上根本用不到,指令永遠反應得出來。另一個是行為可預測,出問題我看程式碼就知道為什麼,不用猜模型當下在想什麼。
代價就是我得記住指令格式,打錯字就不會觸發。這也是明天要講的事:用圖文選單、Quick Reply 這些介面,把「要記住格式」這個負擔再降低一點。
明天來看 LINE 上的體驗細節:輸入中動畫、Quick Reply、圖文選單,以及為什麼這個 Bot 只服務我一個人。