專案筆記

remotetools 底層邏輯:一則手機訊息如何變成 Claude Code 的一次執行

2026-08-08 #Claude Code#Python#asyncio#架構設計

上一篇介紹了 remotetools 是什麼;這篇拆開來看它的內部:一則訊息從手機到 Claude 再回到手機,中間經過哪些關卡,以及幾個關鍵設計決策背後的理由。

訊息的生命週期

每則訊息進來都走同一條管線:

1
2
3
訊息 ─▶ ① 白名單檢查 ─▶ ② 用量原子預留 ─▶ ③ per-對話 asyncio.Lock
─▶ ④ spawn claude subprocess(stream-json)─▶ ⑤ 節流推送進度
─▶ ⑥ 持久化 session_id ─▶ ⑦ 切片回傳

逐關來看。

① 白名單: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.pysession_store.pyusage_tracker.pykeep_awake.py 這四個模組在 telegram/discord/ 是完全相同的複製,卻刻意不抽成 core/ package。README 給的理由:

  • 抽出來會讓部署從「進資料夾跑 start.ps1」變成「裝兩層東西」
  • 第三個平台出現之前,你只能共用介面該長怎樣——過早抽象猜錯的成本,比「修 bug 兩邊各改一次」高
  • 等真的有第三個平台,才知道介面的正確形狀

這違反教科書的 DRY 直覺,但對一個兩平台、千行等級的專案是務實的選擇。專案的 CLAUDE.md 甚至明文警告未來的 AI agent:「修共用模組時兩邊都要改,否則會 drift」。

一些小而美的細節

  • keep_awake.py:呼叫 Win32 SetThreadExecutionState,bot 在跑時 Windows 不會睡著(螢幕還是會關)。效果是 per-process 的——bot 死了就自動退回正常睡眠行為,不會留下副作用。
  • /cancel 的語意:直接 kill() 正在跑的 subprocess,並把該對話標進 cancelled 集合,結果送回來也直接丟棄。
  • /reset 不清模型偏好:reset 只把 session_id 清成 None,last_promptmodel 保留——「重置對話」不等於「忘記我喜歡用哪個模型」。
  • 切模型不重置 sessionclaude --resume 跨模型沿用對話脈絡,所以 /model sonnet 之後對話還是接得上。
  • state 的向前相容sessions.json 載入時用欄位白名單過濾,舊資料缺欄位用 dataclass 預設值補,未知欄位直接丟掉——升級不會被舊 state 檔絆倒。

下一篇是實戰:從零把這套系統部署起來,包含把 bot 包成 Windows 服務開機自動跑。

Creative Commons 姓名標示 非商業性

本文採用 CC BY-NC 4.0 授權

歡迎轉載與引用,請標明出處(remotetools 底層邏輯:一則手機訊息如何變成 Claude Code 的一次執行 — EmptyWu);禁止用於商業用途。

商業使用或合作提案,歡迎來信洽談:[email protected]

留言
分享

留言