跳转至

打造提醒插件

這篇文章說明我如何為 OpenClaw 做出一個自然語言提醒插件,讓家庭聊天機器人能理解中文時間描述、可靠地持久化提醒,並在正確時間把通知或 AI 任務送回 Telegram、LINE 等頻道。

這篇文章的重點

如果你只想先抓住核心設計,重點有三個:

  1. OpenClaw 原生已經能做 cron 排程,這個插件補的是自然語言入口、一次性提醒,以及一次性 AI 任務。
  2. 解析層採用 LLM 做意圖整理,再交給正則表達式、中文數字 tokenizer 與 SQLite 做可預期的執行。
  3. 最難的部分不是排程本身,而是跨頻道投遞路由、非同步任務派遣,以及避免 timeout 導致的連鎖重試。

適合誰閱讀

這篇特別適合以下幾種情境:

  • 你正在為 OpenClaw 或類似 agent gateway 設計提醒、排程或 background task 功能。
  • 你想讓聊天機器人理解中文時間表達,而不想直接要求使用者寫 cron expression。
  • 你在意提醒資料重啟後不能遺失,並且需要把結果送回 Telegram、LINE 或其他聊天通道。

這個插件解決什麼問題

我用 OpenClaw 架了一個家庭聊天機器人,接上 Telegram 和 LINE,後端是 Azure AI Foundry(GPT)。平常拿來聊天、問問題都很好用,但用久了之後,我真正想要的其實不是「它會回答」,而是「它會自己在對的時間幫我做事」。

「明天早上九點提醒我繳電費。」 「每天早上8:50,幫我整理今天的台積電新聞。」 「倫敦時間每天早上七點,準備一份 AI 新聞摘要。」

這些需求其實跨得很大。有的是單純提醒,有的是要 AI 去查資料、做摘要,有的則是每天固定執行。更麻煩的是,全家平常講中文,不會有人真的去講「請幫我建立 cron expression」。大家只會很自然地說:「明天下午三點半提醒我」或「每週一早上九點幫我整理一下」。

OpenClaw 本身其實已經有很不錯的 cron 排程系統,可以透過 CLI 手動設定定期任務。但我想補上的,是它和日常聊天之間最後那一哩路:讓人直接在聊天室裡用中文開口,系統自己理解時間、建立提醒、到點再把結果送回來。

這就是這個插件在做的事。

系統架構

系統提供三種提醒類型,對應不同使用場景:

類型 功能 範例
remind 時間到了發一則通知 「5分鐘後提醒我倒垃圾」
task 時間到了讓 AI 去執行任務,把結果送回來 「明天早上8:50幫我整理台積電新聞」
cron 按排程定期自動執行 AI 任務 「每天早上8:50幫我整理台積電新聞」

這裡有一個很重要的前提:OpenClaw 原生 cron 本身已經夠用,支援 cron 表達式、時區和 agent turn 投遞。我這個插件在 cron 類型上,其實不是重造輪子,而是直接調用 openclaw cron add,站在現有能力上往前走。

真正新增的是:

  1. 自然語言時間解析——不用自己寫 cron 表達式
  2. 一次性提醒(remind)——原生 cron 只支援週期性任務
  3. 一次性任務(task)——時間到了讓 AI 去做事,只做一次
  4. 中文時間表達——「明天下午三點半」、「每週一早上九點」
  5. 自動投遞路由——從聊天上下文自動判斷回覆對象

換句話說,原生 OpenClaw 已經會排程;我做的是讓人不用先學排程工具,也能直接用聊天的方式把這件事講清楚。

如果你只想快速判斷三種類型怎麼選,可以這樣理解:remind 適合單純通知,task 適合一次性的 AI 任務,cron 適合重複執行的工作流程。

架構如下:

使用者(Telegram / Line)
  │
  ▼
OpenClaw Gateway
  │
  ├─ Reminder Tool(LLM tool-call 介面)
  │    ├─ 自然語言解析器(中文 + 英文)
  │    ├─ 時區轉換(IANA 時區)
  │    └─ SQLite 持久化儲存
  │
  ├─ Poller(每 15 秒輪詢)
  │    ├─ 到期的 remind → openclaw message send
  │    └─ 到期的 task → openclaw agent(執行 + 投遞)
  │
  └─ Cron 整合
       └─ openclaw cron add(Gateway 原生排程器)

關鍵設計決策

1. 讓 LLM 當路由器,插件當執行者

我沒有去建一套很重的 NLU 管線,而是讓 LLM 去做它擅長的事:理解人話、判斷意圖、把模糊的描述整理成可執行的格式。插件則負責真正的解析、儲存和派送。

系統提示詞裡我明確告訴模型:

  • 「remind」→ 使用者只要一則通知(提醒我...)
  • 「task」→ 使用者要 AI 去做某件事(幫我整理/查/分析...)
  • 「cron」→ 使用者說了週期性的關鍵字(每天/每週/每月)

模型的工作是把雜亂的自然語言整理成結構化的 tool call。舉例來說,「你以後每天早上在倫敦時間早上七點準備一份關於AI的新聞給我」這種講法,會先被去掉前綴,再轉成:type=cron, input="倫敦時間 每天 07:00 準備一份關於AI的新聞"

2. 中文時間表達解析器

一開始我以為時間解析只是幾條 regex,結果很快就發現自己太天真。中文時間表達其實非常多變:

  • 「明天下午三點半」→ 明天 15:30
  • 「倫敦時間今天下午6:42」→ 今天 18:42 Europe/London
  • 「每週一、三早上九點」→ cron: 0 9 * * 1,3

解析器支援: - 相對時間N分鐘/小時/天後 - 絕對時間YYYY-MM-DD HH:MMM/D HH:MMM月D日 HH:MM - 中文日期+時段今天/明天 + 凌晨/早上/上午/中午/下午/晚上/傍晚 + N點(半/M分) - 中文數字:一二三...十,包含複合形式(二十三 = 23) - 時區前綴:台北時間、倫敦時間(對應到 IANA 時區) - 週期性排程:每天/每週X/每月N號 + 時間

這部分最後全部用正則表達式和中文數字 tokenizer 實作,沒有引入任何 NLP 函式庫。對這類範圍夠明確的問題來說,這樣反而比較穩。

3. SQLite 持久化

提醒這種東西,最怕的不是功能做不出來,而是你以為它記住了,結果一重啟就沒了。所以我一開始就把它當成要持久化的資料,而不是 process memory 裡的一個暫存陣列。

我用的是 Node.js 內建的 node:sqliteDatabaseSync API),零依賴:

const { DatabaseSync } = require("node:sqlite");

資料表裡記錄了可靠投遞需要的資訊:發送者、頻道、session 路由、時區、提醒類型、任務 prompt、cron 表達式、狀態追蹤等,共 23 個欄位。欄位看起來很多,但少一個,日後都可能變成不好查的 bug。

4. 投遞路由還原

這是整個系統裡我覺得最麻煩、也最容易低估的部分。使用者從 Telegram 建立提醒時,插件會捕捉完整的 session 上下文:頻道、發送者 ID、群組或私訊資訊、thread ID。等到幾個小時後提醒真的觸發時,原本那個聊天當下的上下文早就不在了,所以系統得自己把「這則訊息到底要送回哪裡」重新拼回來。

parseReminderDeliveryRoute() 解析 OpenClaw 的 session key 格式(agent:v1:telegram:direct:12345678),提取出頻道、目標、thread ID 和帳號 ID。

5. 任務派遣:非同步的挑戰

「task」類型的提醒會產生一個 openclaw agent 子程序: 1. 開一個新的 agent session 2. 把任務 prompt 送給 LLM 3. 等 LLM 完成(包含任何 tool call——網頁搜尋等) 4. 把結果投遞到使用者的頻道

複雜一點的研究任務,跑超過 5 分鐘是很正常的。這時候 timeout 就不能亂設,因為它不是只有一層;外層太短,會把其實還在正常工作的內層任務提早砍掉。當時我就是在這裡踩坑,後來會用 Azure AI Foundry 追蹤 agent task 派發,把每一層 timeout 看清楚。

大概的結構是這樣:

Gateway fallback timeout:     60 秒
Agent 處理 timeout:          300 秒
緩衝:                         60 秒
總派遣 timeout:              420 秒(7 分鐘)

我後來還踩到一個很微妙的 bug:spawnSync 會阻塞 Node 的事件迴圈。當 createCronRecordspawnSync 去呼叫 openclaw cron add 時,Gateway 反而沒辦法接受自己子程序打回來的 WebSocket 連線,最後直接卡死。

解法是把它改成非同步的 spawn 包裝:

function runOpenClawAsync(args, timeoutMs) {
  return new Promise((resolve, reject) => {
    const child = spawn("openclaw", args);
    // ... 收集 stdout/stderr, 處理 timeout
  });
}

6. 防止連鎖崩潰

測試時我遇到一個最嚇人的失敗模式:如果任務派遣時間超過「過期」門檻,輪詢器就會以為這個任務卡住了,於是再派一次。結果就是多生一個 agent 程序;如果第二個也還沒跑完,又會再派第三個。很快就會從一個 bug 變成整台 VM 被打爆。

修復分兩步: - 過期門檻 = 派遣 timeout + 30 秒緩衝 - 失敗的派遣不重設為 "pending"——留在 "dispatching" 狀態,讓過期門檻來控制重試

exec-sleep 防護

LLM 有時候真的太會「自己想辦法」。當你要它設提醒,它不一定會乖乖用 reminder tool;它可能會想說:「那我跑個 sleep 300 && echo "提醒" 不就好了嗎?」

這種作法表面上看起來能動,實際上問題很多:它會佔用一個 agent 程序整段 sleep 時間、重啟後就消失,還可能連鎖成資源耗盡。

所以我加了一層 before_tool_call hook,專門攔截這種純 sleep 的 exec 呼叫,把模型導回正確的 reminder tool。這個防護看起來很小,但對穩定性幫助很大。

模組化重構

插件一開始其實就是一個 1,645 行的單一檔案。原型階段這很正常,因為重點是先把東西做出來、先驗證它真的有用。但當功能慢慢變多、bug 修了好幾輪之後,這種結構就開始拖累維護速度了。

所以穩定後,我把它拆成 11 個專注的模組:

extensions/reminder/
├── index.js          (124 行 — 薄 orchestrator)
└── lib/
    ├── constants.js   — 設定值
    ├── utils.js       — 核心工具函式
    ├── timezone.js    — 時區/日期處理
    ├── parsing.js     — 自然語言解析
    ├── context.js     — Session/頻道提取
    ├── validation.js  — Tool call 防護
    ├── openclaw.js    — CLI 包裝與派遣
    ├── database.js    — SQLite 操作
    ├── dispatch.js    — 輪詢迴圈
    ├── formatting.js  — 使用者端訊息格式
    └── tool.js        — Tool 定義與指令處理

拆完之後,每個模組的職責就清楚很多。33 個測試還是透過主入口的 _internals export 去跑,所以重構時我可以很放心,知道自己不是在「邊整理邊冒險」。我在另一個 OpenClaw plugin:語音轉錄上也用了同樣的模組化做法。

學到的事

  1. 讓 LLM 處理模糊性,讓程式碼處理精確性。模型很適合聽懂「你以後每天早上在倫敦時間早上七點準備一份新聞給我」這種人話,再把它整理成比較乾淨的輸入。後面的解析、儲存、排程,還是交給可預期的程式碼比較穩。

  2. 非同步比你想的更重要。一個放錯位置的 spawnSync,就讓我多花了好幾個小時在追死鎖問題。在處理 WebSocket 的 Gateway 裡,每一個同步阻塞呼叫,真的都可能是地雷。

  3. 儘早設計連鎖失敗的防護。最危險的 bug 不一定是報錯,而是它會自己擴大。這次的派遣連鎖問題就是這樣:一開始只是一個 timeout 設定不對,最後卻能把整台 VM 打掛。

  4. 中文 NLP 不一定需要機器學習。在像「時間表達」這種邊界相對清楚的領域裡,regex 加上中文數字 tokenizer,其實就已經能處理大部分情況。剩下比較模糊的部分,再交給 LLM 先做正規化就好。

  5. SQLite 被低估了。對這種插件來說,它真的非常剛好:零依賴、Node.js 22+ 內建、重啟不丟資料、讀取也夠快。不是每個問題都需要一個外部資料庫。

下一步

  • 英文時間表達支援 — 目前解析器以中文為主
  • 延後 / 重新排程 — 「延後30分鐘」
  • 多使用者定期任務 — 不同家人收到不同的每日摘要
  • 投遞確認 — 追蹤使用者是否真的看到提醒

相關閱讀

這個插件跑在一台 Azure VM(Standard B2s,約每月 15 美元)上,服務我們家的 Telegram 和 LINE 群組。完整程式碼在專案的 extensions/reminder/ 目錄。