← 返回部落格
·15 min 閱讀·專案紀錄

Google Chat 又難用又醜,所以我做了 Schat:用 Chrome Extension 把它包成 Slack 風格

深度解析 Schat 的設計:為什麼不能走官方 API、為什麼必須在 chat.google.com 分頁內由 Chrome 親自發請求(x-browser-validation 反爬簽章)、五層 RPC 怎麼串、訊息排程/自訂 emoji/桌面通知怎麼疊加在反向工程出來的 Dynamite 私有協定上面。

Chrome ExtensionMV3ReactVite反向工程Google Chat
GP Wang
GP Wang
GWP4 STUDIO 創辦人 · 軟體 / 資料工程師

為什麼要做這個

Google Chat 對於被 Workspace 綁住的人來說,是個讓人天天皺眉的東西。

不是它「沒功能」,而是它每一個有功能的地方都讓人煩躁:

  • 資訊密度低到不像生產力工具:訊息列、頻道列、討論串面板都有一堆留白和巨大頭像,14 吋筆電打開看不到幾條訊息。
  • 頻道與 DM 混在一起的扁平側欄,沒有分組、沒有 section、沒有 pin 與一般項目的視覺差異。
  • 討論串(thread)的呈現邏輯詭異:主串訊息和回覆切成兩個視窗,但右側 panel 又會跳轉,動線一路打結。
  • Markdown 半殘:星號粗體、底線斜體在 composer 看起來是「會生效」的樣子,但實際 render 出來的範圍經常和你輸入的不一致,每次都要重新試。
  • 沒有訊息排程
  • 沒有像樣的桌面通知音——靜音時什麼都不知道,開啟時又用一個聽起來像 2010 年代手機 SDK 的 ping。
  • 介面整體醜。這個沒辦法用功能描述,就是醜。

但工作上躲不掉,於是我做了 Schat:一個跑在 localhost:5173 的 React 前端,搭一個 Chrome extension,把 Google Chat 包成 Slack 風格的介面來操作。沒有後端、沒有 OAuth、沒有 API key——所有讀寫都是用我本人這個 Chrome 分頁去打 Google Chat 的私有介面。

簡單講,它是「Google Chat 官方網頁的遙控器加換皮」,不是獨立 client。

這篇文章主要在記錄:為什麼設計上只能走這條路、五層 RPC 怎麼串、以及在反向工程出來的私有協定上面要怎麼疊加排程訊息、桌面通知、自訂 emoji 這些原版沒有的功能。

第一個分岔:為什麼不走官方 API

最直覺的做法是 Google Chat API,正規 OAuth、scope 申請、發訊息。實際上做不到。

Google Chat 有兩種「身分」:

  • Bot / App:官方 API 的服務對象。可以 OAuth 取得 scope、可以送訊息、可以建立 space,但它是以「bot 帳號」的身分做這些事,不是你。它出現在頻道裡是另一個獨立成員。
  • User:你本人。官方 API 對 user 視角的操作支援很有限——你不能用 user OAuth 直接「以本人身分」在頻道發訊息、reaction、建立排程、列舉所有頻道、抓所有 DM 歷史。

而我要的不是「再多一個 bot」,是換一個我本人在用的 Google Chat 前端。bot 視角完全不適用。

順著這條線想,剩下的選擇就剩下一個:直接打 Google Chat 內部的 web client 用的同一組私有端點

第二個分岔:為什麼不能後端直接打

那寫個 Node.js 後端,登入後拿 cookie 直接打 chat.google.com/u/0/api/... 不就好了?

不行。Google Chat 的所有 mutation 端點都會檢查一個 header:

x-browser-validation: <Base64>

這個值是 Chrome 用 native code 在發每個請求之前現算的反爬簽章——per-request 變動,演算法在 Chrome 本體裡,JavaScript 攔不到、Node.js 算不出來。後端拿著 cookie 直接打會吃 401。

這直接決定了架構的形狀:

所有讀寫都必須在 chat.google.com 的 origin 內、由真的這個 Chrome 程序去發送。

那個分頁不是裝飾品,它是執行器。Schat 的 extension 把所有 RPC 路由到那個分頁裡執行,請求出去之前 Chrome 才會幫你補上 x-browser-validation

副作用:

  • 那個 chat 分頁必須一直開著且已登入。關掉 = Schat 斷線。
  • 權限、身分、rate limit 都跟你本人在原生網頁完全一樣——這是個遙控器,不是繞過去的後門。

第三個分岔:為什麼是五層 RPC

Chrome MV3 的世界切成三個彼此隔離的執行環境:

  • MAIN world(網頁本身的 JS context,看得到網頁的 window
  • Isolated world(content script 的 sandbox,看得到 DOM 但 window 是分開的)
  • Service worker / background(沒有 DOM、長時 idle 後會被殺)

要從跑在 localhost:5173 的 React 前端,呼叫到 chat.google.com 的 MAIN world 裡才能存取的 fetch context,中間每跨一層就要換一種通訊方式。Schat 的訊息會經過五跳:

web/ (localhost:5173, Vite + React)
  └ window.postMessage {__sg}                 ↕
extension/app-bridge.js   (注入 localhost)
  └ chrome.runtime port 'sg-app'              ↕
extension/background.js   (hub:找 chat 分頁、轉送、廣播事件)
  └ chrome.tabs.sendMessage                   ↕
extension/content.js      (chat.google.com, isolated world, 純 relay)
  └ window.postMessage                        ↕
extension/inject-main.js  (chat.google.com, MAIN world)★唯一碰私有協定處
  └ fetch / XHR → https://chat.google.com/u/0/api/...

每一層各司其職:

  • app-bridge.js:注入 localhost:5173,把 React 端的 postMessage 翻譯成 chrome.runtime port。React 端看到的 API 只有 bridge.call(op, args)bridge.on(event, handler),下面這四層它都不用知道。
  • background.js:路由中樞。負責「找到目前哪個分頁是 chat.google.com」、把 RPC 轉送過去、把那邊發回來的 event 廣播給所有正在連線的 app。它也是死掉之後最痛的層——service worker 被殺時所有 port 都會斷,要 app 端會自動重連。
  • content.js:在 isolated world,純 relay。它的存在價值就是「同時看得到 chrome.runtime 又看得到 chat 分頁的 window」這一個 bridging 能力。
  • inject-main.js整個專案的核心。它是被 content script 注入 MAIN world 的腳本,負責所有 Dynamite 私有協定的 request/response wire-format、被動攔截原生網頁發出的 batchexecute 來學 session 參數、實作每一個 op。

RPC 與事件兩條方向:

  • call:app call(op, args) → bridge → background → content → inject-main handleOp(op, args) → 原路回。
  • event:inject-main emitEvent(name, payload)(新訊息、reaction、頻道更新等)→ content → background 廣播 → 所有 app → bridge.on(name, handler)

聽起來很冗,但其實 React 端只需要 bridge.callbridge.on 兩個 API。剩下都是「Chrome 的環境隔離強迫你寫的 boilerplate」。

inject-main.js 在做什麼

把這層拆開講。

1. 偷學 session 參數

Google Chat 用的是 batchexecute 端點——一個由 base URL、f.req payload、at(XSRF token)、bl(client build label)等動態欄位組成的,每隔幾天甚至幾小時就會換的呼叫格式。直接寫死任何一個常數都會壞。

inject-main.js 在注入時會掛上 fetchXHR 的 monkey-patch,被動觀察原生 Google Chat 自己發出去的請求,從中抽出 token、build label、cookies、user GUID。等到 app 第一次發起 RPC,這些參數已經學好了。

這也是 README 為什麼說「載入 extension 之後請先點開任一對話」——那個動作是用來觸發原生網頁送出第一個 batchexecute,讓 inject-main 有東西可以偷。

2. 實作每一個 op

每個 op 是一個非同步函式,吃 args,回 result:

const ops = {
  async listSpaces(args) { /* ... */ },
  async sendMessage({ spaceId, text, threadKey }) { /* ... */ },
  async addReaction({ messageId, emoji }) { /* ... */ },
  async deleteMessage({ messageId }) { /* ... */ },
  async scheduleMessage({ spaceId, text, sendAt }) { /* ... */ },
  async listScheduled() { /* ... */ },
  async createCustomEmoji({ name, blob }) { /* ... */ },
  // ...
}

每一個的內部就是:把 args 包成 Dynamite 的 protobuf-ish JSON(Google 用的是一種他們自己的 nested-array 編碼),打 fetch 過去,把回來的 nested array 解回有意義的形狀。

這部分完全是反向工程——所有端點、所有 payload 形狀都是我用 Chrome DevTools Network panel 看原生網頁的真實流量,逐一抓出來離線驗證的。Google Chat 改私有協定(每隔幾個月會發生)的時候,這層要回去重抓。

3. 被動攔截事件

新訊息、reaction、有人加入頻道,這些事件 Google Chat 用一條長連線推送(streaming POST,內容是一連串的 nested-array 訊息)。inject-main.js 也劫持了這條 stream 的 response reader,每抽到一筆事件就 emitEvent 出去。

於是 React 端可以這樣訂閱:

bridge.on('message:new', ({ spaceId, message }) => {
  // 更新對應頻道的訊息列、未讀數、觸發桌面通知
})

這比輪詢 cheap 很多——而且這就是原生網頁拿到即時推送的同一條 channel。

React 端:原版沒有的功能怎麼疊上去

有了 bridge.callbridge.on 兩個原語,前端剩下的事情就是「把 Slack 體驗實作出來」。其中幾個原版 Google Chat 沒有、我自己很在意的功能:

排程訊息

Google Chat 的網頁版沒有這個功能。Schat 是這樣做的:

  • composer 旁邊的送出鈕加一個下拉箭頭,打開 shadcn 風的日期時間選擇器(自己捲的,因為要嵌進 popover)。
  • 選好時間之後不是真的存在我前端——我把它寫進 inject-main 實作的 scheduleMessage op,這個 op 打的是 Google Chat 內部其實這個能力的 mutation 端點,只是官方 UI 沒暴露出來。
  • 結果:排程的訊息在你完全關掉 Schat 之後,到時間還是會送出來,因為它是登錄在 Google 那邊的,不在我這。
  • 獨立的「排程訊息」分頁可以列出、改時間、取消。

整段功能對使用者是 Slack 的 UX;對 Google 那邊是它本來就支援、只是沒讓 user UI 用的端點。

桌面通知 + 自訂提示音

原版的「噹」聲難聽得很。Schat 用 Web Audio API 合成一個比較溫和的、雙音節提示音,並且只在以下情況才響:

  • 訊息不在當前頻道,或
  • Schat 分頁失焦

不然當你在 Schat 裡面正在打字、每打一條對方回都響一聲是有病。

桌面通知(Notification.requestPermission)走的是相同條件。

完整自訂 emoji 目錄

Google Chat 雖然有自訂 emoji,但選擇器只把最近用過的列出來——對團隊累積了幾百個自訂 emoji 來說等於沒用。

Schat 在 inject-main 多實作了一個 listCustomEmoji op,做分頁列舉,把整個 catalog 拉下來放進 emoji picker。順便也支援直接從前端建立新的自訂 emoji(檔案 → blob → multipart upload)。

Markdown WYSIWYG

不是預覽式,是即所見即所得:你在 composer 裡輸入 *粗* 會看到「」直接以粗體顯示,光標還在原本的位置;按倒退會還原回 marker。清單按 Enter 自動接續下一個 bullet。@ 觸發 mention 自動完成。

這部分跟反向工程無關,純粹是 React 端的功夫——但對日常用感受差很多。

已知限制

老實寫幾個目前還有的坑:

  • 中文化支援還有些地方有 bug:CJK IME composition 在 composer 的 WYSIWYG 模式下、特定狀況會把組字中的 marker(*_)誤判成已 commit,造成標記殘留或樣式錯亂。這是 contentEditable + IME composition 的經典坑,還在改。
  • Google Chat 改私有協定時會壞:mutation endpoint 的 payload 形狀偶爾會變,遇到時就是要回去重新抓封包對照。
  • 必須開著 chat 分頁:見上面 x-browser-validation 的部分。它不是設計缺陷,是硬性限制。
  • 沒有 voice / call:Google Chat 的通話本身是另一套架構,目前沒打算碰。

一些誠實話

Schat 是個搭自己 session 的個人工具,不是繞過 Google 認證的後門——它存取的範圍跟你本人在原生網頁完全一樣,連 rate limit 都共用。它能做的事,你打開官方網頁也都能做;不能做的事,它也別想做。

它的價值純粹是換掉 UX。如果 Google 哪天突然把 web client 改成像 Slack 一樣好用,Schat 就沒存在意義了。

但在那之前——至少我自己不用每天皺著眉用 Google Chat 了。


專案連結github.com/gpwork4u/schat

MIT,歡迎 fork、自己改。前面 disclaimer 也照抄一份:與 Google / Slack 無任何關聯,使用風險自負。

關於作者

GP Wang
GP Wang

GWP4 STUDIO 創辦人,超過 8 年軟體開發經驗,專精資料工程、全端開發與系統架構。 持續在部落格分享專案實作經驗與技術心得。

了解更多 →