上一篇講了 claude_linebot 的動機和 LINE 的三大平台難題;這篇拆開實作:一則 LINE 訊息的完整旅程、reply/push 的成本決策、以及「圖片沒有 caption」逼出來的兩段式附件設計。
檔案結構
1 | lineBot/ |
一則訊息的十道關卡
1 | webhook POST ─▶ ① 驗 X-Line-Signature ─▶ ② webhookEventId 去重 ─▶ ③ 立刻回 200 |
幾個關卡值得展開:
① 驗簽:對 request body 算 HMAC-SHA256(key 是 channel secret)、base64 後與 X-Line-Signature header 比對,不過直接 400。這是 webhook 模式的必要防線——你的 endpoint 在公網上,任何人都打得到。順帶一提,這也讓離線測試變得容易:自己簽一個假 webhook POST 到 127.0.0.1:8000/callback,不需要真的 LINE 就能走完整條管線。
③ 立刻回 200:LINE 要求 webhook 快速回應,逾時會重送(所以才需要②的去重)。實際處理丟進背景 asyncio task,webhook handler 永遠秒回。
⑥ lock 佔用即拒絕:這是跟 remotetools 的一個刻意分歧。Telegram/Discord 版的 per-對話鎖是排隊——前一個請求跑完,下一個接著跑。LINE 版改成直接拒絕:排隊期間 reply token 早就過期了,等排到的時候只能用付費 push 回覆,等於白花額度。與其默默扣錢,不如立刻告訴你「上一個還在跑」。
三組 key,各管各的
跟 Discord 版同一套拆法:
| 用途 | key | 理由 |
|---|---|---|
| 白名單 | source.userId |
「誰能驅動 bot」跟在哪聊無關 |
| 用量上限 | source.userId |
配額保護是 per-person |
session / lock / /more 暫存 |
session_key |
每段對話獨立脈絡 |
session_key 的規則:1:1 用 userId、群組用 groupId、聊天室用 roomId。所以你私聊問到一半的東西,不會污染群組裡的對話。
群組還有一個順序陷阱:訊息要先判斷「是不是對 bot 說的」(有沒有 claude 前綴),再做白名單檢查。順序反過來的話,群組成員正常聊天會被 bot 回「未授權」洗版——這是真實發生過才寫進文件的教訓。
回覆經濟學:ReplyContext
LINE 的計費模型:reply 免費但 token 一次性、約一分鐘失效;push 扣官方帳號的免費月額度。Claude 跑任務動輒數分鐘,所以「怎麼送回覆」需要一套決策邏輯,集中在 line_client.py 的 ReplyContext:
- 50 秒內(
REPLY_TOKEN_BUDGET_SECONDS)跑完 → 用 reply,免費 - 超過 → 用 push,扣一則額度,log 會誠實印出
used a PUSH message - reply 失敗(token 被判定失效)→ 自動 fallback 到 push,代價跟直接 push 一樣,所以 budget 可以放心用滿
- 執行中的「還在跑」提示用 loading animation——獨立 API,不算訊息、不扣額度(但只有 1:1 有效,群組裡 Claude 跑的時候畫面完全沒動靜)
- 回覆太長只送前 5 則(
MAX_MESSAGE_CHUNKS),其餘存在記憶體等/more——/more是新訊息、帶新 reply token,免費
整套邏輯的優先序就一句話:能免費就免費。專案的 CLAUDE.md 特別警告未來的維護者(包括 AI):不要為了程式簡潔一律改用 push。
兩段式附件:平台限制逼出來的好設計
LINE 的圖片訊息沒有 caption——圖片和說明文字是兩則獨立的 webhook 事件。收到圖就立刻跑 Claude 沒有意義(還不知道你要問什麼),所以:
1 | 你:[一張截圖] ← bot 安靜收下,存進 state/uploads/,排進 pending |
可以連傳五張再一次提問,全部一起送。這裡藏著一個併發細節:每個 webhook 事件各自一個 task,五張圖是併發下載的;如果你手速快、圖剛送出就送問題,文字可能比下載先完成——所以 prompt 在取用附件前會等在途下載歸零(上限 8 秒)。拿掉這個等待,bug 只會在慢網路下出現,最難查的那種。
追問計時器與 reply token 的精算
收到圖時 bot 刻意不回「已收到」。原因還是 token 經濟學:reply token 一次性,拿去 ack 就沒了,之後想追問只能走付費 push。做法是把 token 留在一個計時器裡:
1 | 傳圖 ──30s──▶ 追問「請問你想問什麼?」──30s──▶ 靜默丟棄 pending |
追問本身就兼任 ack,而且用的是留下來的免費 token。追問後仍無下文就靜默丟棄(不發通知——那時 token 已用掉,通知會是一則付費 push)。磁碟上的檔案另由背景任務清理(預設保留 3 天,每 6 小時掃一次)。
一個真實的 bug:64KB 的 readline 上限
claude_runner.py 本來跟 remotetools 逐字元相同,但圖片支援逼出了一個分叉:Claude 讀圖時,stream-json 會把整張圖的 base64 塞進同一行 JSON,遠超 asyncio readline 預設的 64KB 上限——LimitOverrunError 拋出、整個請求靜默崩潰,使用者只看到「沒反應」。修法是 create_subprocess_exec 加上 limit=64MB,並把該例外收成明確的錯誤訊息。remotetools 沒有圖片支援所以永遠碰不到這個 bug——同一份程式碼,新的使用情境挖出了潛伏的地雷。
其他移植差異速覽
| 主題 | Telegram / Discord 版 | LINE 版 |
|---|---|---|
| 傳輸 | long polling | aiohttp webhook server |
| 進度 | 每 2 秒編輯訊息 | loading animation(每 55 秒續一次) |
| 併發 | 排隊等 lock | 佔用即拒絕 |
| 長訊息 | 全部切片送出 | 送 5 則,其餘等 /more |
| 防睡眠 | Win32 API | macOS caffeinate(保留 Windows 分支) |
下一篇實戰:從 LINE Developers Console 到 Cloudflare Tunnel,把整套系統部署起來。
留言