聊天橋接
聊天橋接讓外部聊天產品——目前是 Slack——成為進入 Control Center 工作區的一等途徑。提及機器人,代理就會被喚醒;在 Slack 討論串中回覆,對話就會繼續;輸入斜線指令,工單就會開立。底層用的是與桌面應用程式相同的空間、對話紀錄、工單與歸屬。聊天是工作區上的一層_介面_,而不是它的副本。
實際隨產品提供的供應商只有 Slack。 產品中任何地方都沒有 Discord、Telegram、Teams 或 Matrix 的實作。這項功能刻意寫成不感知特定供應商的形式——新增第二個產品只需一個列舉值、一個配接器與一個外掛——但今天「聊天」指的就是 Slack。
它由兩個介面呈現,而這兩個介面屬於不同的人。將工作區連線到 Slack、自訂機器人,是管理員在 設定 → 工作區 → 一般 之下的操作。將你自己的聊天帳號連結到你的 Control Center 身分,則是成員在 設定 → 工作區 → 個人資料與身分 之下的操作。
有三個決策塑造了整項功能:連線往哪個方向發起、來自外部的訊息憑什麼獲得授權,以及產品專屬的知識允許存放在哪裡。
設計上主動外連
標題為「設計上主動外連」的章節橋接從不監聽供應商。Slack 是透過 Socket Mode 觸達的:伺服器向 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 對話:為程式碼代管平台所做的同樣三項決策