上一篇介紹了 remotetools 是什麼;這篇拆開來看它的內部:一則訊息從手機到 Claude 再回到手機,中間經過哪些關卡,以及幾個關鍵設計決策背後的理由。
訊息的生命週期
每則訊息進來都走同一條管線:
1 | 訊息 ─▶ ① 白名單檢查 ─▶ ② 用量原子預留 ─▶ ③ per-對話 asyncio.Lock |
逐關來看。
① 白名單:fail-closed
Telegram 用 chat_id、Discord 用 author.id 比對白名單。名單留空時拒絕所有人——這是刻意的 fail-closed 設計:新手第一次跑起來,bot 會把陌生 id 印在 console log(rejected message from chat_id=XXXXX),你把自己的 id 填進 .env 重啟才開始服務。永遠不會有「忘了設白名單所以全網開放」的窗口。
② 用量檢查:check-and-reserve
usage_tracker.check_and_reserve 原子地檢查並預留額度(RPM + 每日上限),而不是「先檢查、後扣款」兩步走——兩個請求同時通過檢查再各自扣款會超賣。這層存在的理由不是防垃圾訊息,是防自己:自動化呼叫 claude 比人手快得多,一個失控迴圈能在 30 分鐘燒光 Claude Max 的五小時配額。
③ per-對話鎖:防 –resume race
這是整個系統最關鍵的一道防線。每段對話持有一把獨立的 asyncio.Lock,同一對話的請求強制序列化。原因:session 的延續靠 claude --resume <session_id>,如果同一對話兩個請求並發執行,兩個 subprocess 會拿同一個舊 session_id 去 resume,跑完各自產生新的 session_id,後寫的把先寫的蓋掉——對話脈絡從此分岔錯亂。鎖是 per-對話而非全域的,所以不同對話仍然可以並行。
④ subprocess:包在 asyncio 裡的 claude CLI
claude_runner.py 用 project 的 working directory spawn:
1 | claude --print --verbose --output-format stream-json [--resume <sid>] [--model <name>] <prompt> |
--output-format stream-json 讓 claude 逐行輸出 JSONL 事件流。runner 逐行 parse,累積兩樣東西:assistant 事件裡的文字,和 tool call 事件(渲染成 🔧 Bash \git status`這樣的單行摘要)。最後的result` 事件帶出 session_id、成本、耗時、turn 數。
⑤ streaming UI:節流的 edit
跑任務可能耗時數分鐘,bot 不能讓用戶盯著已讀不回。做法是先送一則 placeholder 訊息,然後 runner 透過 on_update callback、以 2 秒節流推送進度快照,bot 把 callback 實作成「edit 那則 placeholder」。快照內容是最近 12 筆 tool 呼叫加上 Claude 當前累積文字的末尾 1200 字。跑完後 placeholder 被最終答覆覆蓋。
節流是必要的:Telegram 和 Discord 對訊息編輯都有 rate limit,每個事件都 edit 一次會被平台掐死。
⑥⑦ 持久化與切片
result 事件的新 session_id 寫進 state/sessions.json(用 tmp file + rename 確保原子性),原始 prompt 也同步存下來給 /retry 用。最後把輸出按平台上限切片(Telegram 4000 字、Discord 1900 字)送出。
Discord 的三組 key
Telegram 一個 chat_id 打天下;Discord 的世界複雜得多(DM、guild channel、thread),所以拆成三組 key,各管各的:
| 用途 | key | 理由 |
|---|---|---|
| 白名單 | author.id |
「誰能驅動 bot」跟在哪個頻道無關 |
| 用量上限 | author.id |
配額保護是 per-person |
| session / lock / cancel | session_key |
每個 thread 是獨立對話 |
session_key 的決定規則:DM 用 author.id,thread 用 channel.id,一般 guild channel 則沒有 session——bot 會先用 message.create_thread() 開一條 thread,讓每個話題天然隔離。觸發規則也不同:DM 永遠回應,bot 自己開的 thread 永遠回應,其他地方要 @mention 它才理你。
權限模式:為什麼只有兩種能用
Claude Code 平常遇到敏感操作會停下來問「可以執行嗎?」——但 bot 沒有把 stdin 接給 claude,手機端也沒人能按 Always Allow。所以:
| 模式 | 結果 |
|---|---|
default / acceptEdits |
卡死等一個永遠不會來的回答 |
auto |
依 ~/.claude/settings.json 的 allow/deny 表決定,沒授權的操作直接失敗。適合 allow 表已備齊的情境 |
bypassPermissions |
全放行,連 deny 規則都跳過。手機遠端的推薦模式 |
選 bypassPermissions 的代價很明確:失去 settings.json 這層 safety net,信任邊界完全推到白名單上。這是「個人裝置 + 白名單鎖死」場景下的合理取捨,但必須是知情的取捨。
兩份 byte-identical 的程式碼,為什麼不抽共用?
claude_runner.py、session_store.py、usage_tracker.py、keep_awake.py 這四個模組在 telegram/ 和 discord/ 是完全相同的複製,卻刻意不抽成 core/ package。README 給的理由:
- 抽出來會讓部署從「進資料夾跑
start.ps1」變成「裝兩層東西」 - 第三個平台出現之前,你只能猜共用介面該長怎樣——過早抽象猜錯的成本,比「修 bug 兩邊各改一次」高
- 等真的有第三個平台,才知道介面的正確形狀
這違反教科書的 DRY 直覺,但對一個兩平台、千行等級的專案是務實的選擇。專案的 CLAUDE.md 甚至明文警告未來的 AI agent:「修共用模組時兩邊都要改,否則會 drift」。
一些小而美的細節
keep_awake.py:呼叫 Win32SetThreadExecutionState,bot 在跑時 Windows 不會睡著(螢幕還是會關)。效果是 per-process 的——bot 死了就自動退回正常睡眠行為,不會留下副作用。/cancel的語意:直接kill()正在跑的 subprocess,並把該對話標進 cancelled 集合,結果送回來也直接丟棄。/reset不清模型偏好:reset 只把 session_id 清成 None,last_prompt和model保留——「重置對話」不等於「忘記我喜歡用哪個模型」。- 切模型不重置 session:
claude --resume跨模型沿用對話脈絡,所以/model sonnet之後對話還是接得上。 - state 的向前相容:
sessions.json載入時用欄位白名單過濾,舊資料缺欄位用 dataclass 預設值補,未知欄位直接丟掉——升級不會被舊 state 檔絆倒。
下一篇是實戰:從零把這套系統部署起來,包含把 bot 包成 Windows 服務開機自動跑。
留言