我們是如何將 iMessage 整合進 Lindy 的
我們是如何將 iMessage 整合進 Lindy 的
在拉斯維加斯某個資料中心的機架上,曾有一台 Mac Mini 承載了 Lindy.ai 整個 iMessage 的基礎設施。它配對了一支 iPhone,擁有一個 Apple 帳號,並執行著一個 Swift 常駐程式(daemon),該程式會監控 SQLite 資料庫,並以 Apple 絕對不樂見的方式透過 Private Frameworks 注入訊息。我們開發了它,重寫了它,最後刪除了所有東西,並用一個 API 呼叫取而代之。一個月內經歷了三次徹底的重寫。這就是這三次重寫皆為正確決策的故事。
為什麼 iMessage 與其他管道截然不同
若要以程式方式發送簡訊,你會呼叫 Twilio 的 REST API。若要發送 WhatsApp 訊息,你會呼叫 Meta 的 Business API。Telegram 也是一樣。這些平台都希望開發者在它們之上進行開發。它們會發布文件、提供 API 金鑰,並按訊息量向你收費。
Apple 則完全不做這些事。沒有 iMessage Business API,也沒有針對 Messages 的開發者計畫。如果你想以程式方式發送 iMessage,你需要:
一台實體 Mac(或資料中心裡的 Mac)
一支與該 Mac 配對的實體 iPhone
一個登入 Mac 上 Messages.app 的 Apple ID
一個監控 Messages 資料庫以接收訊息,並透過未公開的框架注入發送訊息的常駐程式
這就是起跑線。每一家開發 iMessage 整合的公司都從同樣荒謬的處境開始。

第一座橋樑:Swift 常駐程式
2025 年 12 月下旬,團隊為 Lindy 的 iMessage 整合編寫了一份 RFC。架構如下:一個在資料中心 Mac Mini 上執行的 Swift 常駐程式,監控 Messages.app 的 SQLite 預寫式記錄檔(chat.db-wal)以獲取傳入訊息。至於發送,它使用 Apple 的 Private Frameworks 直接將訊息注入 Messages.app 的內部,確保藍色氣泡的顯示。AppleScript 則是備用方案。
這座橋樑成功運作了。它能發送和接收 iMessage、處理附件,並透過 webhooks 將所有內容轉發回 Lindy 的 API。程式碼很乾淨,文件也很詳盡。
但團隊中只有一位工程師懂 Swift。iMessage 橋樑運行在資料中心的實體硬體上。當它在凌晨兩點崩潰時,必須有人 SSH 進 Mac Mini 並除錯一個監控 SQLite WAL 檔案的 Swift 程序。如果只有一個人能做到這一點,這不僅是基礎設施的問題,更是團隊擴展性的問題。
健康檢查端點說明了一切:
const iMessageBridgeHealthSchema = z.object({
bridgeId: z.string(),
status: z.enum(['healthy', 'unhealthy']),
messagesAppRunning: z.boolean(),
lastMessageSentAt: z.number().optional(),
pendingCommands: z.number(),
uptime: z.number(), // seconds
})
messagesAppRunning: z.boolean()。我們實際上是在檢查資料中心裡的 Mac 上,Messages.app 是否仍在執行。
團隊開始研究競爭對手在做什麼。OpenClaw 和其他人正在使用一個名為 BlueBubbles 的開源函式庫,這是一個用於控制 iMessage 的 JavaScript 引擎。整個團隊都能閱讀並除錯 JavaScript。
押注 BlueBubbles
遷移到 BlueBubbles 後,我們用執行在相同硬體上的 JavaScript 引擎取代了自訂的 Swift 常駐程式。同樣的 Mac Mini,同樣的 iPhone,同樣的 Apple ID。但現在,橋樑程式碼使用的是全團隊都能維護的語言。
這次遷移快速解鎖了多項功能。幾週內,團隊就實現了表情符號反應(reactions)和 tapbacks,並透過自訂的 BlueBubbles 建置支援了 Markdown 格式(粗體、斜體、刪除線)。編輯和取消發送功能也隨後跟進。
但真正的產品工作是讓 iMessage 感覺像是一個完整的訊息平台,而不僅僅是一個純文字管道。我們將 iMessage 視為一等公民管道,這意味著要支援使用者對原生對話所期望的一切:
語音備忘錄可以雙向運作。如果你發送語音備忘錄給你的 Lindy Agent,它會被轉錄並以文字形式傳送給 Agent。如果你要求 Agent 發送語音備忘錄給你(例如,在你開車時總結你的早晨行程),它會透過 ElevenLabs 生成音訊,並以 iMessage 語音備忘錄的形式發送回來。
反應功能也是雙向的。你可以對 Agent 的訊息做出反應(讚、愛心等),這會觸發 Agent 的動作,對於快速確認非常有用。Agent 也可以對你的訊息做出反應,提供輕量級的回饋,而無需發送完整的回覆。
圖片處理則更有趣。將照片發送給 Agent,多模態 LLM 會直接處理它。Agent 也可以回傳圖片:如果它從電子郵件附件中抓取了圖表,或者生成了你要求的內容,它會以原生 iMessage 圖片的形式傳送過來。每張圖片都會儲存在我們的基礎設施中,以便 Agent 日後參考。你可以在週六發送週末計畫的照片,並在週二詢問相關內容。Agent 依然可以存取它。

然而,硬體限制依然存在。每個 iMessage 號碼都需要自己的 Mac Mini、自己的 iPhone 和自己的 Apple 帳號。團隊考慮了競爭對手的做法:透過輪詢(round-robin)分配多個號碼,並共享聯絡人卡片,這樣使用者就不會注意到訊息來自不同的號碼。但計算結果行不通。一個號碼代表一台 Mac Mini、一支 iPhone、一個 Apple 帳號。擴展到十個號碼意味著所有東西都要乘以十。我們最終僅以單一號碼上線。
RFC 中的容量估算為每個 Apple ID 每小時 100 到 500 則訊息。團隊預計每天會有幾百則訊息。
被封鎖
上線當天,iMessage 的流量遠超我們所有的估計。訊息量遠比我們計畫的多,速度也比我們預期的快。
該功能運作得完全符合預期。使用者發現了 iMessage,嘗試了它,並持續使用。一個可以像同事一樣傳簡訊的 AI 助理,正是人們會頻繁使用的工具。
Apple 的垃圾訊息偵測是一個黑盒子。iMessage 沒有公開的速率限制,沒有關於觸發封鎖原因的文件,也沒有申訴流程。新帳號、高流量、接收者多樣性低以及發送與接收比例失衡,都會導致被封鎖。在 Apple 看來,一個成功的 AI 助理上線所表現出的行為模式,與垃圾訊息操作完全相同。
該帳號被永久封鎖。半天的 iMessage 功能,然後就沒了。
橋樑程式碼很穩固。BlueBubbles 處理了負載。這項功能顯然是使用者想要的。Apple 未公開的垃圾訊息閾值抓住了我們,再多的橋樑工程也無法改變這一點。
顯而易見的下一步(購買新 iPhone、設定新 Apple ID、配置新 Mac Mini)需要數天時間,花費更多金錢,卻無法解決任何問題。下一個帳號也會被封鎖,只是時間早晚而已。

重建:一個 API 呼叫
當團隊在思考下一步時,我們的一位工程師聯繫了一家名為 Linq 的公司。Linq 是一項託管式 iMessage 橋樑服務。他們處理困難的部分(Mac Minis、iPhones、Apple IDs、號碼輪替、反垃圾訊息策略),並公開一個 REST API。你呼叫他們的 API,獲取電話號碼,發送訊息,接收 webhooks。除了 iMessage 之外,開發體驗與 Twilio 或 WhatsApp 相同。
重建過程花了四個小時,並使用 Claude Code 加速重寫。團隊已經兩次開發過 iMessage 整合。他們了解訊息路由、webhook 處理和附件處理。改變的是,他們不再需要擁有硬體層。
新的訊息發送程式碼:
try {
return await trySendViaLinq(actor, identity, phoneNumber, message, attachments)
} catch (error) {
logger.info('Linq send failed, falling back to SMS', { error })
}
return await sendViaSMS(actor, identity, phoneNumber, message)
嘗試 Linq,失敗則退回到 SMS。就這樣。
幾天內,團隊完成了完整的遷移:已讀回條、附件、語音備忘錄、回覆串、聯絡人卡片生成,以及一種新的引導流程,讓使用者先傳簡訊給系統(這樣 Apple 看到的是雙向對話,而不是單向的轟炸)。
舊的橋樑程式碼在一次清理 PR 中被刪除:76 個檔案變更,數千行程式碼被移除。自動化 PR 審查員給了它 9/10 的評分,並補充道:「這主要是刪除,這是最好的程式碼類型。」
第二次清理完全移除了 apps/imessage-bridge 目錄。審查員評論:「這是一個刪除操作。你很難搞砸它,但也沒什麼特別的技巧可言。非常完美。」

讓它感覺像是在與人傳簡訊
隨著基礎設施在 Linq 上穩定下來,問題轉移了。iMessage 管道運作正常。訊息可靠地進出。但「運作正常」和「感覺對勁」是兩回事。一些粗糙的邊緣讓體驗感覺像是與系統對話,而不是與同事傳簡訊。
輸入中的氣泡(Typing bubble)
你傳簡訊給你的 Lindy Agent,要求它檢查你本週的行事曆。Agent 需要抓取你所有的行程、篩選它們,或許還要交叉比對一些事情。這需要一兩分鐘。從你的角度來看,你只看到已讀回條,然後是沉默。三十秒後,你會懷疑它是否壞了。一分鐘後,你會傳簡訊問「哈囉?」和「你還在嗎?」
Agent 運作正常。它正處於執行中,正在抓取行程並篩選結果。
解決方法是輸入指示器,也就是當有人正在撰寫訊息時你看到的跳動的三個點。當 Agent 開始處理時,我們顯示輸入氣泡。它會保持啟用狀態,直到發送第一則回覆訊息為止。如果輸入指示器消失但沒有訊息到達,那才是真的出了問題。如果它還在跳動,請稍候。
顯而易見的替代方案是發送狀態訊息:「處理中!」或「正在檢查你的行事曆...」。我們沒有這麼做,原因與封鎖有關。Apple 的垃圾訊息偵測會查看訊息比例。粗略的規則大約是每收到一則訊息,發送十則出站訊息,之後你就會看起來像個垃圾訊息發送者。每一則「處理中...」的訊息都會消耗掉這個額度。如果 Agent 在實際答案之前發送了三則狀態更新,那對使用者的一次查詢來說就是四則訊息。輸入指示器不消耗任何訊息額度。這是一個免費的訊號。使用者獲得了回饋,而我們的發送/接收比例保持乾淨。
防抖動(Debounce)問題
另一個粗糙的邊緣是:連珠炮式的訊息。人們傳簡訊的方式就像他們說話的方式。你發送「嘿」並按下 Enter。然後「我今天行程如何?」並按下 Enter。然後「舊金山天氣如何?」並按下 Enter。
如果沒有防抖動,Agent 會收到三則獨立的訊息,並啟動三次獨立的處理程序。你會收到三則獨立的回覆,一則回答「嘿」,一則關於你的行事曆,一則關於天氣。Agent 也會感到困惑,因為當它在回答「嘿」時,又有兩則它還不知道的訊息進來了。
解決方法是三秒的防抖動視窗。當訊息到達時,我們等待三秒。如果在這段視窗內有另一則訊息進來,我們重置計時器。一旦三秒過去且沒有新訊息,我們將所有內容打包,作為一個請求發送給 Agent。「嘿,我今天行程如何,還有舊金山天氣如何?」一則訊息,一個回應。
為什麼是三秒?時間太長,單一訊息的使用者會坐在那裡看著什麼都沒發生,以為它壞了。時間太短,你會錯過兩則訊息連發中的第二則。三秒是折衷方案。
我們在 Temporal 上建置了這個功能。每一則傳入的訊息都會發送一個訊號給 Temporal 工作流,該工作流會保持狀態並等待沉默,然後再進行分派。選擇 Temporal 而不是 Redis 之類的東西,是因為我們可以重試失敗的分派,在除錯時檢查工作流狀態,而且整個系統預設是持久化的。
這實際上在最初的橋樑上就運作過。我們在 Linq 遷移期間丟失了它,現在正在重建。這就是那種當它存在時你不會注意到,但當它消失時你絕對會注意到的功能之一。
尚未解決的問題
三種架構,每一種在當時都是正確的。當沒有 iMessage API 時,Swift 常駐程式是正確的第一步。當團隊需要每個人都能在凌晨兩點進行除錯時,BlueBubbles 是正確的第二步。在封鎖事件證明我們不應該經營自己的電話基礎設施後,Linq 是正確的第三步。
Linq 現在運行著八個電話號碼,在使用者之間進行負載平衡。反垃圾訊息策略包括每個號碼的每日訊息上限、使用者先傳簡訊的引導流程,以及動態聯絡人卡片生成,以便使用者可以儲存該號碼。當初導致帳號被封鎖的發送與接收比例,現在成了我們主動監控的指標。
但我們是在一個可以隨時、以任何理由、無需文件且無法申訴的情況下撤銷存取權的平台上進行開發。Linq 處理了操作複雜性,但根本風險沒有改變。Apple 明天可能會更改其服務條款,每一家開發 iMessage 整合的公司,包括 Linq 本身,都必須適應或關閉。
拉斯維加斯的 Mac Mini 已退役。訂閱已取消。我們在這個版本中使用了八個電話號碼、一個 API 和零台 Mac Mini,而 Apple 至今仍未發布任何一頁關於 iMessage 整合的文件。
