聊天橋接
聊天橋接讓外部聊天產品——目前是 Slack——成為進入 Control Center 工作區的一等途徑。提及機械人,代理就會被喚醒;在 Slack 討論串裏回覆,對話就會繼續;輸入一條斜線指令,就會開立一張工單。在底層,用的與桌面應用完全相同:同樣的空間、轉錄、工單和歸屬。聊天是工作區上的一層_介面_,而不是它的副本。
隨產品付運的只有 Slack 這一個提供方。 產品中任何地方都沒有 Discord、Telegram、Teams 或 Matrix 的實作。這個功能刻意寫成不感知特定提供方的形式——增加第二個產品只需要一個枚舉值、一個配接器和一個外掛——但今天「聊天」指的就是 Slack。
它由兩個介面承載,而這兩個介面屬於不同的人。把工作區連接到 Slack、自訂機械人,是管理員在 設定 → 工作區 → 一般 下的操作。把你自己的聊天帳戶連到你的 Control Center 身分,則是成員在 設定 → 工作區 → 個人資料與身分 下的操作。
有三個決定塑造了整個功能:連線朝哪個方向發起、來自外部的訊息憑什麼獲得授權,以及特定於產品的知識可以存放在哪裏。
設計上主動外連
標題為「設計上主動外連」的章節橋接從不監聽提供方。它是透過 Socket Mode 觸達 Slack 的:伺服器先向 Slack 索取一個通訊端 URL 並主動外連,然後在這條連線上接收提及、私訊和斜線指令。
這個後果本身就是重點。位於 NAT 之後的手提電腦、沒有域名的家用伺服器、在不同網絡之間游走的機器——全都能運行橋接,既不需要入站連接埠,也不需要通道或 TLS 憑證,更不需要在公網 URL 改變時輪換它。沒有 webhook 端點需要保護,因為根本沒有 webhook 端點。Control Center 組裝應用定義時會強制指定 Socket Mode 並剝除任何請求 URL,因為同時配置兩者的應用,只會是一個半殘的應用。
代價是要維持一條存活的連線,而這是伺服器的職責:重連採用帶抖動的指數退避,上限 30 秒;由提供方發起的通訊端輪換會直接重連,不會推高該退避;而_憑證被拒_是終局——無止境地重試一個已撤銷的權杖,只會換來速率限制,所以橋接會停下來等待新權杖,同時在設定中逐字報告提供方自己的錯誤。
沒有聊天應用的工作區完全不產生任何開銷:沒有通訊端、沒有計時器、沒有流量。而且整件事是在伺服器打出就緒橫幅_之後_才啟動,因為一個回應緩慢的聊天應用絕不能拖慢開機。
成員身分才是授權依據,而非持有權杖
標題為「成員身分才是授權依據,而非持有權杖」的章節安裝應用的人並不是發訊息的人。因此每個入站事件都會先解析為一個主體,然後才會有任何事情發生,而且它會預設拒絕並附上說明:
- 未連結的聊天帳戶會在它自己發言的那個 Slack 討論串裏,收到如何連結的說明。
- 已連結、但其用戶並非本工作區成員的帳戶,會被告知去請管理員發出邀請。
- 角色為唯讀(
viewer、guest)的成員會被告知,其角色無法展開工作。
以上情況都不會繼續。聊天訊息絕不會被歸屬到並非其發送者的人身上——正是這一點讓稽核紀錄、按主體計算的速率限制和工單報告人保持誠實。這裏所依賴的成員身分模型,見多人協作與線上狀態。
連結是身分解析,而不是存取授權。成員身分會在每個事件上重新檢查,因此屬於某個後來已被移除之人的連結,會解析為一次拒絕,而不是他昔日的權限。建立連結有兩條路徑:成員在聊天中輸入一個短時效的一次性代碼,或按提供方_已驗證_的電郵自動匹配——而電郵這條路徑仍然要求被匹配到的用戶已經是成員,所以一個地址恰好存在於伺服器上的陌生人,什麼也連結不到。
這些代碼刻意只保存在記憶體中,存活時間為 15 分鐘。把其中一個持久化,只會為一件本應蒸發的東西造出一行貌似憑證的持久記錄;而伺服器重啟令所有尚未使用的代碼失效,是正確行為,不是退化——成員再按一次「連結」即可。
拒絕會在訊息所開啟的那個 Slack 討論串裏作為一條真正的回覆給出。討論串回覆會為它所回應的訊息加上標記,只觸達真正需要它的那一個人,同時仍然把「你尚未連結」擋在頻道之外。發訊息也是每個提供方都具備的能力,因此這條說明永遠不會依賴應用可能並不具備的功能。
來源與回聲問題
標題為「來源與回聲問題」的章節雙向鏡像的橋接會陷入迴圈,除非有東西能區分兩個方向。橋接帶進來的每條訊息都會蓋上 metadata['chat'] 標記——提供方、團隊、對話、討論串、訊息和發送者。出站鏡像會跳過任何帶有該標記的訊息。
正是這一條規則讓雙向鏡像變得安全:在 Control Center 裏輸入的人類訊息會被轉發到已連結的討論串(以作者姓名作為前綴),而_來自_聊天的訊息則在它進入的地方停下來。
向外跨越的內容刻意收窄。代理的回答會作為普通聊天文字轉發出去。一句簡短的思考可以出現在卡片的 Thinking… 行上。工具輸出是一整面 diff 牆,留在 Control Center 裏——被截斷的回覆也指向那裏。
卡片報告工作進度,文字依然是訊息
標題為「卡片報告工作進度,文字依然是訊息」的章節在提供方能夠渲染它們的地方,一個回合會帶着一張任務卡片,在該回合運行的整段期間原地編輯,並位於它所解釋的回答之上。
卡片是一份分組呈現的計劃,而不是一段被搗爛的文字。Slack 會把同一個任務上的 details 和 output 串接起來,所以一行的 details 只會發送一次(即 Thinking… 上那句思考),而 output 從不以串流方式發送。初始化只佔一行,其標題會被取代(Working on it…、Cloning …)。推理過程會新增一行 Thinking…。每個工具各佔一行(Read lib/main.dart)。回答是卡片下方的普通文字——Slack 的計劃檢視是從之後追加的一段 markdown 讀到它的,而不是從混入任務中的一塊內容讀到。除此之外沒有別的:沒有工具輸出,工具引數也只來自一份很短的允許清單,因為一個未知工具的引數可能是一整個檔案主體,而一行只有一行。
卡片也覆蓋了回合_開始之前_的等待。第一次提及會建立對話,而建立對話會複製代理將要工作的每一個程式庫——在真實的程式庫上,這意味着好幾分鐘,期間代理還沒有被要求做任何事,討論串看起來就像機械人忽略了這個請求。所以卡片在提及被接受的那一刻就打開(Working on it…),然後那一行初始化資訊會用桌面端所顯示的同一批措辭更新——Cloning acme/widgets…、Setting up Reviewer…、Starting the agent…——接着在代理開始推理時立即出現一行 Thinking…,然後每個工具各一行。一張卡片、一條訊息,從「我聽到了」一路到下方作為普通文字的回答。只有在聊天中確實有人等待時,初始化才會被報告:在應用中正在準備的對話,或在伺服器啟動時恢復的滯留對話,都會完全不顯示卡片、直接佈建。
它承載着聊天唯一無法給讀者的東西:一個 View in Control Center 連結,指向回覆背後的對話。聊天產品在連結中只接受 http(s),其他一律不行,所以這個按鈕指向本伺服器提供的一個小頁面,再由它把瀏覽器彈轉到桌面應用的 control-center:// 連結。那個頁面不讀取任何東西,也不解析任何東西——它 URL 裏的那些 id 只會被回填進一個深層連結,其餘地方一概不用——而一部沒有可達公網 URL 的伺服器,只會產生一張沒有按鈕的卡片,而不是一張壞掉的卡片。
卡片和其他能力一樣,只是一種能力。不具備卡片的提供方行為與以往完全一致,只有文字;而在呼叫時拒絕卡片的提供方則保留自己的串流:回答的優先次序永遠高於描述它的卡片。當串流被完全關閉時,已完成的卡片改為隨那條唯一發出的訊息送出。開立工單也得到同樣的處理——/cc ticket 的確認是一張已完成的卡片,它存在的理由就是指向剛剛建立的那張工單的連結。
能力先協商,再記住
標題為「能力先協商,再記住」的章節聊天產品能渲染的東西各不相同,所以橋接是照着最豐富的介面來寫——在討論串內實時生長的回答、稍縱即逝的「正在思考…」狀態、有標題的討論串、對斜線指令的臨時應答——然後一項一項地降級。什麼能力都沒有的提供方依然可用;回覆只是以單條訊息的形式送達。
宣告某項能力並不等於承諾它。Slack 的串流回覆需要付費方案中的一項功能,而 Slack 只在呼叫時才拒絕。被標記為永久性的拒絕會在整條連線的生命週期內被記住:橋接從此不再請求,改為直接發出完整回覆,設定中亦會直白說明,因為「我的回覆不再串流」這種問題,應該不必讀日誌就能回答。如果 Slack 拒絕任務卡片,卡片也會以同樣方式被放棄。
每項上限都屬於提供方,而不屬於轉發層:訊息長度上限、串流分塊上限,以及一條串流是否需要討論串錨點,全都由配接器宣告;過長的回覆會按段落、行或詞語邊界切分,而不會把一個詞從中間切開。
一個橋接,每個產品一個配接器
標題為「一個橋接,每個產品一個配接器」的章節一切具有產品形態的東西都活在一個埠之後。通用核心只知道 markdown、聊天對話、聊天討論串和成員;它對 Slack 的事件信封、mrkdwn 或 gateway intents 一無所知。讓這套安排成立的有三樣東西:
- 配接器會把一個產品歸一化:它的傳輸方式、它的 API 呼叫、它的文字編解碼,以及它的事件信封怪癖。Slack 的怪癖很能說明問題——一條頻道提及會到達兩次(既作為
app_mention,_也_作為message),而大多數訊息子類型只是裝飾,而不是新請求。兩者都在配接器裏解決,所以核心永遠不知道 Slack 會重複投遞。 - 描述符是提供方的公開形態:它需要哪些憑證、這些憑證的格式是什麼、哪些 URL 能幫用戶找到它們、它能做什麼,以及它的應用能否從這裏建立或編輯。設定卡片、連接對話框和引數驗證全都是從它_生成_的——客戶端回傳的是一個提供方,外加一個以欄位 id 為鍵的映射。
- 外掛是伺服器端的組裝:憑證如何變成一條經驗證的連線、配接器如何建立,以及提供方一側的應用如何管理。
所以增加一個產品,就是一個枚舉值、一個配接器和一個外掛。沒有新的 RPC 操作、沒有 schema 改動、也沒有客戶端改動——而客戶端始終如實呈現,因為一個它從未聽聞過的欄位,依然會帶着伺服器提供的標籤渲染出來。
有兩個細節防止這層抽象向上洩漏。提供方沒有 API 可用的那些應用管理步驟(產生權杖、安裝應用、權限變更後重新安裝)會以各自附有連結的具名步驟回傳,因此完成設定畫面毋須知道自己面對哪個提供方,也能把它們顯示出來。而橋接寫出的每一條指示都會點出提供方,以及應用實際使用的指令,所以即使指令被改過名、或者出現了第二個提供方,讀起來依然正確,毋須新增任何字串。
憑證存放在哪裏
標題為「憑證存放在哪裏」的章節工作區的聊天權杖存放在該工作區自己的目錄裏,就在它的資料庫旁邊——以純 JSON 形式位於 <dataDir>/<workspaceId>/chat_credentials/<provider>.json,不放在資料庫中,並且刻意不放在作業系統鑰匙圈裏。有兩個後果日後很容易被忽略:刪除工作區會解除該目錄的連結,當中的權杖也隨之而去;而匯出或備份工作區只會複製 workspace.db,所以一個交給別人的工作區,裏面不會夾帶仍然有效的機械人權杖。該檔案繼承伺服器主機的檔案系統信任邊界——凡是有權讀取資料目錄的東西都能讀到機械人權杖,這與資料庫所處的邊界完全相同。
每個提供方一個檔案,原子寫入且僅限擁有者存取:撤銷一個應用只需解除一個檔案的連結,而一個損毀的檔案只損失一個提供方,而不是全部。憑證只在連接操作中_進去_,永遠不會再出來——狀態讀取只回傳連線中繼資料。
刻意不提供的東西
標題為「刻意不提供的東西」的章節- 應用的結構性設定。 傳輸方式、權限範圍和事件訂閱都是強制指定的,不對外開放。一個能從設定畫面被配置到徹底沉默的應用,是一張支援工單,而不是一個功能。
- 對話加斜線指令,僅此而已。 Slack 介面就是訊息、討論串回覆和斜線指令。
- 第二個事實來源。 聊天不是儲存工作的地方。Slack 討論串是一個空間,回覆是一份轉錄,工單就是工單——它們全都在工作區裏,從每個客戶端都能存取。
另請參閱
標題為「另請參閱」的章節- 多人協作與線上狀態:橋接所解析的主體與角色
- 工作區與隔離:為何連結和憑證是按工作區劃分
- 代理模型:提及到達時被喚醒的是什麼
- 工單與委派:
/cc ticket開立的是什麼 - 架構:橋接位於伺服器與客戶端之間的什麼位置
- 將 Slack 橋接到工作區
- 設定 Slack 整合
- 連結你的 Slack 帳戶
- 自訂聊天機械人
- PR 對話:為程式碼託管平台做出的同樣三項決定