# 聊天訊息系統：規格架構與部署文件

> 涵蓋範圍：站內即時聊天（私訊／群組）、端對端加密、貼圖、通話、跨組織聯邦聊天、推播通知，以及對應的部署方式。
> 不含：Google Chat／LINE Notify 的 CVE 告警橋接（`GoogleChatService`、`ChatNotificationChannel` 等）——那是漏洞通知系統，跟本文件的聊天功能是兩套完全不相關的東西，避免混淆。
> 程式碼位置：開發／測試站 `/var/www/html/vpn`，正式站 `/var/www/html/portal`。兩邊程式碼目前完全同步（僅一處註解文字差異）。

---

## 1. 系統總覽

```
┌─────────────┐        HTTPS (session/Sanctum)        ┌──────────────────────┐
│  瀏覽器 / PWA │ ─────────────────────────────────────▶ │   Laravel 應用程式    │
│  Flutter App │ ◀───────────────────────────────────── │ (Conversation/Message │
└──────┬──────┘        WSS /app/  (Reverb 協定)          │  Controller 等)       │
       │                                                 └──────────┬───────────┘
       │  wss://<host>/app/...                                       │
       ▼                                                             ▼
┌─────────────┐   Apache ProxyPass /app/ → ws://127.0.0.1:PORT  ┌─────────┐
│   Apache     │ ───────────────────────────────────────────▶ │ Reverb  │
│ (vhost per站) │                                                │ (WS Server, per站一個process) │
└─────────────┘                                                └─────────┘
                                                                     ▲
                                                             broadcast(...)（同步，ShouldBroadcastNow）
                                                                     │
                                                              MySQL（messages / conversations / ...）
                                                                     │
                                              WebPushService ──▶ 瀏覽器 Web Push (VAPID) + FCM（原生 App）
                                                                     │
                                              FederationClientService ──▶ 跨組織 broker (message.steps.tw)
```

核心子系統：

| 子系統 | 主要用途 | 是否已完整支援 |
|---|---|---|
| 私訊 / 群組聊天 | 文字、圖片、語音、檔案、位置、貼圖、回覆、編輯、收回、表情回應 | ✅ |
| 端對端加密（E2EE） | 文字／位置訊息內容伺服器端不可讀 | ✅（文字/位置），⚠️（圖片/語音/檔案/貼圖恆為明文，屬設計內範圍） |
| 即時推播（Reverb） | 訊息／已讀／表情回應即時同步、全站在線狀態、通話訊令 | ✅ |
| 背景推播通知 | App 背景/鎖屏、瀏覽器分頁未開啟時的到達通知 | ✅（Web Push），⚠️（FCM 需設定 `FIREBASE_CREDENTIALS` 才會實際送達） |
| 貼圖商店 | 貼圖包瀏覽、下載、聊天室內使用 | ✅ |
| 通話（語音/視訊/畫面分享/白板） | `CallController` + `call.{id}` presence channel | ✅（本文件僅概略帶過，非本次重點） |
| 跨組織聯邦聊天 | 透過外部 broker 與其他組織用戶聊天 | ✅ 文字/圖片/位置/貼圖 + 語音/視訊通話 + E2EE（見 8.6）+ 已讀狀態（見 8.7）+ 回覆/編輯/收回（見 8.8）+ 跨組織群組（見 8.9），需手動設定 `.env`；通話固定只支援兩人（不支援群組通話）、畫面分享、白板 |
| 行動 App API（Sanctum） | Flutter App 走 `/api/messaging/*` | ⚠️ 缺表情回應路由（見第 9 節） |

---

## 2. 資料模型

### 2.1 本地聊天核心

**`conversations`**
| 欄位 | 說明 |
|---|---|
| `type` | `direct` \| `group` |
| `title` | 群組名稱（私訊為 null，前端顯示對方名稱） |
| `avatar_path` | 群組大頭照（私訊沒有） |
| `direct_key` | `min(userA,userB)-max(userA,userB)`，唯一索引，確保同兩人只會有一個私訊對話 |
| `last_message_at` | 排序用 |
| `created_by`, `deleted_at`（soft delete） | |

**`conversation_participants`**（`conversation_id`+`user_id` 唯一）
| 欄位 | 說明 |
|---|---|
| `role` | `member` \| `owner`（僅群組有意義） |
| `left_at` | 退出群組時間；私訊用不到 |
| `hidden_at` | 「先不看」但未退出，收到新訊息會自動復原（見 `MessageController::store`） |
| `last_read_message_id` / `last_read_at` | 未讀數用「訊息 id 比對」而非逐則寫已讀記錄，避免資料量爆炸 |
| `muted` | 靜音，靜音者不收到 Web Push/FCM（但仍會收到 Reverb 即時廣播） |

**`messages`**（`(conversation_id, id)` 建索引，供游標分頁）
| 欄位 | 說明 |
|---|---|
| `type` | `text` \| `image` \| `voice` \| `file` \| `sticker` \| `location` |
| `body` | 明文或（`encrypted=true` 時）base64 密文 |
| `meta` (json) | 依 type 存放路徑/URL/檔名/MIME/貼圖 id 等 |
| `encrypted`, `key_version`, `iv` | 見第 4 節 |
| `reply_to_message_id` | 只能指向同一對話內的訊息（伺服器驗證） |
| `edited_by` / `edited_at` | 僅文字訊息可編輯 |
| `recalled_at` | 對所有人收回（區別於「個人刪除」） |
| `deleted_at`（soft delete） | Eloquent SoftDeletes，非使用者操作觸發 |

**`message_deletions`**：`(message_id, user_id)`，代表「我」對這則訊息按了刪除，只影響自己看到的列表，不影響其他參與者。

**`message_reactions`**：`(message_id, user_id, emoji)` 唯一索引。同一人可疊加多種表情，但同一表情再按一次是取消（toggle），不是累加次數——比照 Google Chat 的行為。

### 2.2 端對端加密

**`user_devices`**：`user_id, device_label, public_key_jwk, status(pending/active/revoked), approved_at, last_seen_at`
（沿用自舊版單金鑰表 `user_encryption_keys`，遷移後每人至少一台「裝置」）

**`conversation_keys`**：`conversation_id, user_id, device_id, wrapped_by_user_id, wrapped_by_device_id, key_version, wrapped_key`，唯一鍵 `(conversation_id, device_id, key_version)`。**以「裝置」為單位包裝，不是以「人」為單位**——同一使用者的每台已核准裝置都各自有一份用該裝置公鑰包裝出來的金鑰密文。

### 2.3 貼圖

`sticker_packs`(`name, category, cover_path, is_active, downloads_count, created_by`) 1─N `stickers`(`sticker_pack_id, image_path, sort_order`)；`sticker_pack_user` 樞紐表（`downloaded_at`）記錄使用者下載了哪些包。檔案存 `storage/app/public/stickers/`。

### 2.4 跨組織聯邦聊天

`federated_conversations` / `federated_messages` / `federated_conversation_keys` / `federated_contacts` / `federated_calls`，結構分別對應本地的 `conversations` / `messages` / `conversation_keys` / — / `calls`，但額外帶：
- `direction`(`inbound`/`outbound`)
- `client_message_id`（冪等去重）
- `from_federated_id` / 對方組織的裝置 id（字串，非本地 FK，因為對方組織的 id 空間跟本地不共用）
- `delivered_at`（只代表 broker 已 ack，不代表對方真的已讀）

---

## 3. 核心訊息流程

### 3.1 傳送訊息（`MessageController::store`）

1. 驗證輸入：`body`(≤8000字，預留 AES-GCM+base64 膨脹空間)、`image`(≤5MB)/`voice`(≤10MB)/`file`(≤20MB)、`lat`/`lng`、`sticker_id`、`reply_to_message_id`、加密欄位 `encrypted`/`key_version`/`iv`/`type`。
2. 依附件類型決定 `type`/`meta`，優先序：圖片 → 語音（MIME 判斷：伺服器 finfo 或前端回報只要有一個是 `audio/*` 就放行，因為 libmagic 常把 webm/ogg 誤判成 `video/*`）→ 一般檔案 → 貼圖（**必須已下載該貼圖包**才能送，防止繞過商店直接帶 id）→ 加密文字/位置（`body` 是密文，伺服器完全不解讀）→ 明文位置（舊版相容路徑）。
   - **圖片／語音／檔案／貼圖恆為明文**，不在本次 E2EE 範圍內（貼圖是伺服器靜態素材，本來就不是隱私內容；附件加密留待未來擴充）。
3. `DB::transaction`：寫入 `Message` → 更新對話 `last_message_at` → 更新寄件者自己的 `last_read_message_id` → 若對話先前被任何人「隱藏」過，自動復原（新訊息到來時跟 LINE 一樣要浮現）。
4. `broadcast(new MessageSent($message))->toOthers()` — **同步廣播**（`ShouldBroadcastNow`，不進 queue）。
5. `notifyNewMessage()`：找出未離開群組、未靜音的其他參與者，組一句不含真實內容的預覽文字（因為 E2EE 下伺服器看不到明文，通知只能說「傳送了一則訊息」），呼叫 `WebPushService::notify()`（見第 6 節）。

### 3.2 讀取訊息（`index`）

游標分頁（`before_id`，非 offset），排除自己已個人刪除的訊息，一次 50 筆，依 id 升冪回傳。

### 3.3 已讀（`markRead`）

把自己的 `last_read_message_id` 設成該對話最新訊息 id，廣播 `ConversationRead`，讓對方畫面即時把「已讀」狀態點亮，不用整串重拉。

### 3.4 編輯 / 收回 / 個人刪除 —— 三者差異

| 操作 | 誰能做 | 影響範圍 | 限制 |
|---|---|---|---|
| 編輯 `update()` | 僅本人 | 所有人看到新內容 | 僅 `type=text`、未收回；沿用原 `key_version`（改內容者本來就要解得開才能編輯，不接受前端指定新版本，避免版本被亂改） |
| 收回 `recall()` | 僅本人 | 所有人都看不到原內容（伺服器端把 `body/meta/iv/reactions` 清空，非僅前端隱藏） | 已收回不能再收回 |
| 個人刪除 `destroy()` | 任何看得到訊息的參與者 | 只有操作者自己的列表看不到，其他人不受影響 | 無限制（連別人送的訊息也能對自己刪除） |

### 3.5 表情回應（`toggleReaction`）

任一參與者對任一（未收回）訊息可疊加多種表情；同一人對同一則訊息按同一個表情第二次＝取消。回傳格式刻意不含「是不是我按的」（`mine`）欄位——因為同一份 broadcast payload 會送給所有訂閱者，若在伺服器端算好會變成「觸發這次廣播的人」的視角，其他人看到的會是錯的；改成回傳 `user_ids`，由各自前端比對自己的 `userId`。

---

## 4. 端對端加密（E2EE）架構

- **對稱金鑰以「裝置」為單位包裝**，不是以「人」為單位：同一使用者的每台已核准裝置（`user_devices.status=active`）都各自有一份用該裝置公鑰（ECDH，`public_key_jwk`）包裝出的金鑰密文，存在各自的 `conversation_keys` 列。
- **建立流程**：前端在建立對話（`POST /messaging`）前，先呼叫 `GET /messaging/keys`（`batchPublicKeys`）取得所有初始成員「目前有效」裝置的公鑰，自行產生一把對話對稱金鑰，對每個收件裝置各包一份，整批連同 `wraps[]:{user_id,device_id,wrapped_key}` 送給伺服器。**伺服器只負責存放這些自己看不懂的密文材料，不做任何加解密運算**。
- **金鑰版號（`key_version`）**：建立對話時是 v1；每次新增成員（`addParticipant`）都要 +1——新成員不該讀到加入前的歷史，因此前端要重新產生一把新金鑰，包裝給「全部現有成員＋新成員」（不是只包給新成員），整批送出成新版號。
- **既有（加密上線前）對話補發金鑰**：`distributeKeys()`，任何現有成員都可補發 v1。
- **讀取自己的金鑰**：`GET /messaging/{conversation}/keys/mine?device_id=&version=`，回傳指定裝置在指定版本（預設最新）的 wrap，以及包裝者裝置當時的公鑰（`wrapper_public_key_jwk`，直接查 `wrapped_by_device` 而非走 `batchPublicKeys`，因為包裝者裝置屆時可能已被撤銷，但歷史包裝金鑰仍要能解出）。
- **新裝置補齊歷史**：`GET /messaging/{conversation}/keys/versions` 列出某裝置手上有哪些版號，用於核准新連結的裝置時回填完整歷史。
- **訊息密文欄位**：`messages.encrypted`(bool) / `key_version`(uint) / `iv`(varchar 64) / `body`(base64 密文)。回覆預覽（`reply_to`）與收回後的清空邏輯都會一併考慮加密欄位，確保收回的訊息即使加密狀態下也不外洩內容。

---

## 5. 即時通訊架構（Laravel Reverb）

### 5.1 廣播頻道一覽（`routes/channels.php`）

| Channel | 類型 | 授權條件 | 用途 |
|---|---|---|---|
| `App.Models.User.{id}` | Private | 僅本人 | 全站導覽列徽章、跨對話的新訊息/來電通知 |
| `online-users` | Presence | 任何登入者 | 全站在線狀態；member payload 帶 `dnd`（勿擾），上線/離線本身由前端用 here/joining/leaving 即時算，不落資料庫 |
| `conversation.{id}` | Presence | 該對話「未離開」的參與者 | 訊息/編輯/收回/表情回應/已讀 即時同步；presence 名單預留給未來通話訊令用 |
| `call.{callId}` | Presence | 該通話所屬對話的參與者 | 與 `conversation.{id}` **刻意分開**——只在真的進入通話室才訂閱，讓訂閱名單準確等於「誰真的在通話中」，供 `CallController::reconcileParticipants()` 核對卡死的通話 |

### 5.2 廣播事件

| Event | 廣播頻道 | 是否額外發私人頻道 | 說明 |
|---|---|---|---|
| `MessageSent` | `conversation.{id}` | ✅ 對每個收件人各發一份到 `App.Models.User.{id}` | 讓沒開著該對話的人也能更新徽章/浮動視窗 |
| `MessageUpdated`（`action`=`edited`\|`recalled`） | `conversation.{id}` | ❌ | 只影響正開著該串的人 |
| `MessageReacted` | `conversation.{id}` | ❌ | |
| `ConversationRead` | `conversation.{id}` | ❌ | |
| `FederatedCallSignal` | 對方使用者的 `App.Models.User.{id}` | — | 跨組織通話訊令，無共享 presence channel |

**全部事件皆實作 `ShouldBroadcastNow`，是同步廣播、不進 queue** —— 這點與一般 queue job（`QUEUE_CONNECTION=database`，`vpn-queue`/`portal-queue` supervisor worker）無關，訊息即時性不受 queue worker 存活與否影響。

### 5.3 前端連線

`resources/js/echo.js`（Vite build）與獨立的 `public/js/messaging.js` 皆用 `laravel-echo` + `pusher-js`，`broadcaster:'reverb'`。連線參數不寫死在前端，而是先打 `GET /api/broadcast-config`（未認證、30次/分鐘節流，只回傳公開的 Reverb key/host/port/scheme）取得設定後才建立 Echo 連線；私人/presence 頻道走標準 Laravel broadcasting auth（CSRF header + `routes/channels.php` 授權邏輯）。

---

## 6. 背景推播通知（`WebPushService`）

伺服器端看不到明文（E2EE），所以通知內容一律是型別預覽句（「傳送了一則訊息」/「傳送了一張圖片」…），不含真實內容。同時做三件事：

1. 寫一筆 `site_notifications`（站內通知中心）。
2. **瀏覽器 Web Push**（VAPID，`minishlink/web-push`）：對 `push_subscriptions` 裡的訂閱送出，遇到 404/410（訂閱已失效）自動刪除該筆訂閱。
3. **原生 App FCM**（`kreait/laravel-firebase`）：對 `push_tokens` 裡的裝置 token 送出；**若 `FIREBASE_CREDENTIALS` 未設定，`pushFcm()` 會捕捉例外只留警告 log**，不會讓整個通知流程掛掉——代表沒設定這個環境變數時，App 端背景推播實際上是靜默失效的，需部署時特別注意確認。

### 6.1 PushKit VoIP 推播（`ApnsVoipService`，來電專用）

上面的 `WebPushService` 是一般訊息通知；通話另外走一條獨立的路，因為 CallKit 接聽畫面需要的是「立即喚醒」而不是一般通知：

- `voip_push_tokens` 表存 PushKit token（`user_id` + `token` + `device_name`），跟 `push_tokens`（一般 FCM/APNs 通知）、`user_devices`（E2EE 對話金鑰裝置）都是分開的三張表，語意不同、生命週期也不同。App 端 API：`POST/DELETE /api/voip-push-tokens`（`VoipPushTokenController`）。建議 App 每次啟動都重新註冊一次（PushKit token 本身可能變動），不需要另外處理過期通知。
- `CallController::store()` 發起通話、`broadcast(new CallRinging($call))` 的同一時間點，額外呼叫 `ApnsVoipService::notifyCallRinging()`，對這通電話的其他參與者名下所有 `voip_push_tokens` 發送 APNs HTTP/2 VoIP 推播（`apns-push-type: voip`），payload 帶 `call_id`/`conversation_id`/`initiator_id`/`initiator_name`/`type`，跟 `call.ringing` 廣播事件給的欄位一致，App 端不用再額外打 API 查一次來電資訊。
- 認證用 APNs Auth Key（`.p8` + Key ID + Team ID，`APNS_KEY_ID`/`APNS_TEAM_ID`/`APNS_AUTH_KEY_PATH`/`APNS_BUNDLE_ID`/`APNS_PRODUCTION`），簽 ES256 JWT 當 provider token（快取到剩 5 分鐘內才重簽）。**若 `APNS_KEY_ID`/`APNS_AUTH_KEY_PATH` 未設定，`notifyCallRinging()` 會捕捉、只留警告 log**，不會讓 `call.ringing` 廣播（App 開著/前景時接聽畫面靠的是這個）跟著失敗——代表沒設定時，只有「App 開著」才收得到來電，背景/被系統砍掉時是靜默失效的，需部署時特別注意確認（跟 FCM 那條同一個模式）。
- 只有 APNs 回應 400/410（token 本身失效，例如裝置換了/App 移除了）才會刪掉那筆 `voip_push_tokens`；401/403 是憑證本身有問題，留著等憑證修好，不刪 token。

---

## 7. 貼圖系統

- 商店瀏覽（`index`/`apiIndex`，網頁版與 App 版分開回傳格式）支援分類篩選、依熱門(`downloads_count`)/最新排序。
- 下載（`download`）是 idempotent 的 attach + 計數，貼圖包需 `is_active=true` 才能瀏覽/下載。
- 聊天室貼圖面板（`mine`）只回傳「我已下載」的包。
- 送出貼圖訊息時，伺服器會二次驗證寄件者確實已下載該貼圖包所屬的包，避免繞過商店直接帶 `sticker_id` 送圖。
- 上架/新增貼圖為 admin-only（`Auth::user()->isAdmin()`）。

---

## 8. 跨組織聯邦聊天（Federation）

架構是「組織對組織」透過外部 broker 中繼，**不是點對點**。Broker 為 `message.steps.tw`（`services.federation.broker_url`）。

### 8.1 組織註冊

`federation:register`（console 指令）：本地產生 RSA-2048 金鑰對存於 `storage/app/federation/{private,public}.pem`（權限 0600/0700），帶 `X-Bootstrap-Key` 呼叫 `POST {broker}/api/v1/orgs/register`，換回一次性的 `client_id`/`client_secret`/`webhook_secret`，需手動寫入本地 `.env`（`FEDERATION_ORG_ID/CLIENT_ID/CLIENT_SECRET/WEBHOOK_SECRET` 等，`config/services.php` 對應 `services.federation.*`）。

### 8.2 認證

- 組織層級：`client_credentials` 換 access token，快取 55 分鐘。
- 使用者層級：本地私鑰簽發 60 秒短效 RS256 JWT（`iss=org_id, sub=user_id, name, avatar_url`），向 broker 換 `federation_token`，快取 4 分鐘／人。
- 對外請求同時帶 `Authorization: Bearer <access token>` 與 `X-Federation-Token`。

### 8.3 Inbound Webhook

`POST /api/federation/inbound`（無 Sanctum，120 次/分鐘節流），用 `X-Broker-Signature: sha256=<hmac>`（`hash_hmac('sha256', 原始 body, webhook_secret)` + `hash_equals`）驗證來源。依 `event` 分派 `message.received` / `conversation.key` / `call.signal`；若本地尚未見過該跨組織對話，會依 payload 的 `to`/`from` 動態建立（`createConversationFromInbound`）。**驗證失敗或無法重建對話時回傳非 2xx，讓 broker 重試**，避免訊息遺失。

### 8.4 目錄同步

排程 `federation:sync-directory`（每 15 分鐘，`routes/console.php`，未設定 `federation.org_id` 時自動跳過）：
- **推送**：把本組織所有 `is_active` 使用者＋其裝置公鑰整批覆蓋式同步給 broker（目前無個人選擇加入/退出機制）。
- **拉取**：依 `SystemSetting::get('federation_peer_org_ids')` 設定的對端組織清單，整批覆蓋式更新本地 `federated_contacts` 快取，並清掉不在最新同步結果內的舊資料。

### 8.6 端對端加密（已接上）

跨組織訊息現在跟本地聊天一樣走 E2EE，共用前端 `public/js/messaging-crypto.js`（ECDH P-256 +
AES-256-GCM），只是金鑰交換的對象是「另一個組織的裝置」而不是本地使用者：

- **公鑰來源**：對方裝置的公鑰不是即時查詢，而是透過 8.4 節的通訊錄同步快取在
  `federated_contacts.devices`（json）裡；建立金鑰前直接讀這個欄位，不用另外打 API 問對方。
- **建立與分發**（`FederatedChatController::storeConversationKeys()`）：發起對話的一方產生一把新的
  AES-256-GCM 對話金鑰，分別包給「自己所有 active 裝置」與「對方所有裝置」。自己那份純本地寫入
  `federated_conversation_keys`（`direction=inbound`），不經過中間層；對方那份透過
  `FederationClientService::sendConversationKey()` 推給 broker 轉發，同時本地也記一筆
  `direction=outbound` 留存查核。跨組織對話只會有兩個人，沒有「新增成員」的概念，`key_version`
  固定是 1，不會像本地群組那樣因為換人而換版。
- **讀取自己那份**：`GET /messaging/federated/{id}/keys/mine?device_id=`
  （`FederatedChatController::conversationKeyMine()`），對稱本地的 `myConversationKey()`。包裝者
  公鑰的查詢比本地多一層判斷——`wrapped_by_device` 這個字串 id 可能是「自己的另一台裝置」也可能是
  「對方的裝置」，兩邊的裝置 id 是各自獨立的字串空間（同一個數字字串在兩邊都可能存在但代表不同
  裝置），用「這個 id 在本地 `user_devices` 裡查不查得到、且屬於自己」判斷，查得到就是自己人，
  查不到就去 `federated_contacts.devices` 找對方的（見 `resolveDevicePublicKeyJwk()`）。
- **收對方送來的金鑰**：`FederationInboundController::handleConversationKey()` 收到 broker 轉發的
  `conversation.key` 事件後直接落地成 `direction=inbound`，不需要額外處理。
- **明文降級**：跟本地聊天室的策略一致，屬於「best effort」——建立金鑰失敗（例如對方通訊錄還沒
  同步出任何裝置公鑰）就讓這則訊息維持明文送出，不會整個卡住聊天功能；訊息本身依 `encrypted`
  欄位各自獨立判斷是否加密，不要求整個對話從頭到尾都一致。
- **已知限制**：如果雙方剛好同時第一次開啟同一段對話並同時嘗試建立金鑰，各自會各產生一把不同的
  v1 金鑰，導致互相解不開對方之後送來的訊息（沒有版本協商/衝突合併機制）。實務上機率極低——這個
  對話必須先透過雙方互加通訊錄好友、其中一方呼叫 `start()` 建立，而且一定要有人先送出第一則訊息
  才會觸發建立金鑰，兩邊真正「同時」搶著建立的窗口非常小，暫不特別處理。

### 8.7 已讀狀態（read receipt）

跟本地聊天室一樣，只在「自己送出的訊息」下面顯示已讀/未讀，但實作方式完全不同——本地靠
`last_read_message_id`（整數 id，雙方共用同一張 `messages` 表）+ Reverb 即時廣播；跨組織
訊息 id 兩邊各自獨立（見 8.5），沒有 Reverb，所以改用「時間戳比對」+「輪詢」：

- `federated_conversations.peer_read_at`：對方回報「已經讀到這個時間點」，跟自己每則訊息的
  `created_at` 比較，`created_at <= peer_read_at` 就顯示已讀。
- **傳送已讀回條**：不新增 broker 事件類型（避免要改中間層，牽連到共用它的 aihub）——直接
  複用既有的訊息通道，`FederationClientService::sendReadReceipt()` 送一則
  `type=read_receipt`、`body=<ISO 時間戳>` 的「訊息」過去，中間層原樣轉發（`type` 對它來說
  本來就是不透明字串）。觸發時機：`FederatedChatController::show()`（打開頁面）與
  `markRead`（`POST /messaging/federated/{id}/read`，前端在輪詢收到對方新訊息時呼叫）。
- **接收已讀回條**：`FederationInboundController::handleMessage()` 特判
  `type === 'read_receipt'`，更新 `peer_read_at` 後直接回應，**不落地成一則 `FederatedMessage`**
  （不然聊天室會憑空多出一則看不懂的訊息）。
- **前端同步**：`GET .../messages`（既有的 4 秒輪詢端點）回應多帶 `peer_read_at`，前端每次
  輪詢都拿最新值重算所有「自己」訊息列的已讀/未讀文字，頂替本地聊天室的 `conversation.read`
  廣播。
- **已知限制（第三方組織相容性）**：`read_receipt` 是雙方都要認得的「約定」，不是 wire
  protocol 正式定義的事件類型。跟 vpnadmin/portal 互聊沒問題（兩邊程式碼同步）；但跨組織對象
  若是 aihub 這類外部組織，若對方的程式碼沒有相同的 `read_receipt` 特判，會把這則回條當成
  一般訊息落地／顯示，可能在對方畫面上出現一則內容是 ISO 時間戳的怪訊息。目前沒有能力驗證
  aihub 那邊的實作，屬於已知風險，非阻擋性問題。

### 8.8 回覆／編輯／收回

跟本地聊天室一樣支援右鍵選單的回覆／編輯／收回，但因為跨組織訊息雙方各自存一份獨立的
`FederatedMessage`（見 8.5），實作方式分成兩類：

- **回覆**：本地整數 id 兩邊各自獨立、不能共用，改用 `client_message_id`（雙方都存了同一個
  UUID 字串）當作跨組織的訊息識別碼。這是唯一一個需要中間層配合的部分——`message.steps.tw`
  的 `POST /v1/conversations/{fed_id}/messages` 白名單新增了一個**選填**欄位
  `reply_to_client_message_id`（純新增、向下相容，沒帶這個欄位的舊呼叫端不受影響），原樣轉發
  給對方。雙方收到後都用這個欄位在自己的 `federated_messages` 表裡查出「被回覆的是哪一則」，
  組出跟本地聊天室 `renderReplyPreview()` 一樣的回覆預覽（`FederatedChatController::
  attachReplyPreviews()`）。
- **編輯／收回**：不新增 broker 事件類型，複用一般訊息通道（跟 8.7 的已讀回條同一手法）——
  `type=message_edited`／`message_recalled`，`body` 包一層 JSON `{ref: 原訊息的
  client_message_id, body?: 新內容}`。收到的一端在 `FederationInboundController::
  handleMessage()` 特判這兩個 type，直接更新本地那則訊息的 `body`/`edited_at` 或
  `recalled_at`，**不落地成新訊息**。
- **前端即時性**：編輯/收回是原地 UPDATE 既有的 `FederatedMessage` 列，id 不會變，單靠
  `after_id` 輪詢撈不到——`GET .../messages` 多帶一個 `since`（上次輪詢的時間點，用伺服器的
  `server_time` 校準，不用瀏覽器時鐘）參數，額外撈出「`edited_at`/`recalled_at` 晚於 since」
  的舊訊息，前端收到後整列重畫（`upsertMessage()`）取代掉舊的。
- **已知限制（第三方組織相容性）**：`message_edited`/`message_recalled` 的 type 不透明字串
  跟已讀回條一樣，若對方是不認得這個約定的外部組織（例如 aihub），會把它當成一般訊息顯示，
  可能出現一則內容是 JSON 字串的怪訊息；`reply_to_client_message_id` 則是額外欄位、對方
  不認得就單純忽略不會出錯，相容性風險比前兩者低很多。
- 跟本地聊天室的差異：編輯限定文字訊息、限本人送出；收回不限型別、限本人送出——這兩點跟本地
  `MessageController::update()`/`recall()` 的規則一致。「刪除」則維持既有的單向設計不變（見
  8.5），不會通知對方。

### 8.5 與本地聊天的差異（重要限制）

- `federated_conversation_keys` 用字串型的 `for_device`/`wrapped_by_device`（對端組織的裝置 id 是不透明字串，非本地外鍵），跟本地 `conversation_keys` 用整數外鍵不同，多了一層「這是自己人還是對方」的判斷（見 8.6）。
- 跨組織通話（`federated_calls`）狀態機純粹由 inbound broker 訊令驅動，沒有本地 Reverb presence 可以核對「真的還在通話中」。
- **訊息型別**（`FederatedChatController::sendMessage`）：`text`/`image`/`location`/`sticker` 四種，跟本地一樣不支援語音/一般檔案附件（跨組織沒有共用檔案儲存）。**貼圖不能像本地那樣只傳 `sticker_id`**——對方是完全不同伺服器/資料庫，查不到我方的 `stickers` 表，所以伺服器端把 `sticker_id` 換成 `Sticker::imageUrl()` 回傳的**絕對網址**（含網域，`Storage::disk('public')->url()` 因為 `filesystems.php` 的 `public` 磁碟 `url` 設成 `env('APP_URL').'/storage'`）當作 `body` 送出，讓對方瀏覽器能直接 `<img>` 顯示；貼圖一樣不支援加密（跟本地相同理由：伺服器靜態素材，非隱私內容），也一樣要求寄件者已下載該貼圖包（`downloadedStickerPacks()` 二次驗證）。
- **通話型別**：`audio`/`video` 皆支援（`getLocalStream(type)` 沿用跟本地共用的 `public/js/messaging-call.js`），但只做本地 Meet 版面裡最陽春的「兩人各一格 video」，**不支援本地那套群組通話／畫面分享／白板**（`federated_calls` 永遠只有兩個參與者，沒有群組概念）。呼叫類型透過 `sendCallSignal` 的 `payload.call_type`（`'audio'`|`'video'`）在雙方之間傳遞——這個欄位是 `payload`（自由格式 JSON）裡的一個 key，不是頂層 `type`（頂層 `type` 固定是 `offer`/`answer`/`ice-candidate`/`hangup` 這組信令動作），broker 原樣轉發不需要改協定；`federated_calls.type` 欄位（`enum('audio','video')`）從這裡取值決定要不要記成視訊通話。
- **中間層本身的 bug（已修復）**：`message.steps.tw` 的 `conversation.key` webhook 原本沒有帶
  `to` 欄位，導致接收方在「從沒見過這個 fed_id」時無法動態補建對話，中間層又把這種情況的 HTTP 200
  誤判成「已送達」直接把金鑰永久刪除——已在 `/var/www/html/message` 修好（`ConversationKeyController`
  補上 `to` 欄位，`MessageController` 也補上 `encrypted`/`key_version`/`iv` 讓加密訊息的中繼資料
  不會在中間層被丟掉），這是中間層自己的正式環境程式碼，非 vpn/portal 這兩份專案的一部分。
- **來電通知（已修復）**：本地通話的「不管在哪一頁都會跳出來電畫面」是靠 `public/js/messaging.js`
  全站共用的 Echo 連線監聽 `.call.ringing`；跨組織通話的 signaling 邏輯（含 E2EE 金鑰、federated
  routes）只寫在 `federated-show.blade.php` 自己的 script 裡，一開始沒有接到那個全站監聽器上——
  代表接聽方只有「剛好開著那一個特定跨組織對話的頁面」才會看到接聽列，其他情況（在別頁、在對話
  列表）來電廣播就直接錯過，永遠等不到人接聽（回頭查 `federated_calls`，從功能剛做出來的 9/10
  到這次修復前，`answered_at` 沒有一筆成功過）。修法分兩層，都不需要把完整 WebRTC 協商邏輯搬進
  `messaging.js`：
  1. **全站提示**：`messaging.js` 的全站私人頻道監聽器多掛一個 `.federated.call.signal`，收到
     `type=offer` 時（且目前所在頁面不是那個對話本身，避免跟頁面自己的接聽列重複）跳出一個右上角
     小提示（`fed-call-toast`），點「接聽」導去 `/messaging/federated/{conversation_id}`，收到
     `answer`/`hangup` 自動收掉提示。`FederatedCallSignal` event 因此多帶一個 `conversation_id`
     欄位供前端組網址。
  2. **補撈 offer**：即時廣播是一次性的，光靠提示把人導過去還是接不了電話（真正的 SDP offer
     早就已經廣播過、沒人聽到就沒了）——`federated_calls` 新增 `signal_payload`（json）欄位，
     `FederationInboundController::handleCallSignal` 收到 `offer` 時把 signaling payload
     （sdp/`call_type`）存進去，`answer`/`hangup` 時清空。`FederatedChatController::show()`
     載入頁面時額外查一次「這個對話還有沒有『對方發起、還在響鈴中、60 秒內』的通話」，有的話直接
     把存起來的 offer 灌回前端的 `pendingOffer`/`callType`，跳出接聽列——不管使用者是自己剛好在
     這頁看到即時廣播，還是從全站提示點進來，都走同一套「接手繼續協商」的路徑。

### 8.9 跨組織群組（已接上）

跨組織群組成員可以橫跨任意數量的組織（不限 2 間），也可以同一組織有多個本地成員一起在同一個
群組裡；語音/視訊通話仍然固定只支援兩人，群組聊天不含通話。

- **broker（`/var/www/html/message`）改動**：`federation_orgs` 新增 `supports_group_chat`
  旗標（預設 `false`，每個組織要自己選擇開通，對既有 1:1 流量零影響）；`federation_conversations`
  新增 `type`/`title`；新表 `federation_conversation_participants` 記完整成員名單（`federated_id`/
  `org_id`/`role`）。`FederationConversation::recipientOrgs()` 取代 `MessageController`/
  `ConversationKeyController` 原本寫死的 `otherParticipant()` 呼叫，送達邊界仍然是「組織」而不是
  「使用者」——同一個組織裡不管有幾個收件人，一次發送只產生一筆 `federation_messages`（`payload.to`
  是該組織成員的 federated_id 陣列），該組織自己的 `FederationInboundController` 再往下發給本地每個
  相關使用者。新增 `ParticipantController`（`POST`/`DELETE .../participants`）處理成員異動，異動後
  沿用既有「共用訊息通道、靠 `type` 特判」慣例塞 `participant_added`/`participant_removed` 事件。
  群組建立時發 `group_created` 事件（含完整成員名單），讓從沒呼叫過 `/v1/conversations` 的其他組織
  能一次建出本地群組紀錄，跟 1:1 的 `createConversationFromInbound()` 是同一個「補建」的道理，只是
  需要整份名單而非單一 `to`。新增 payload 欄位 `is_group`：收件方不能只靠 `to` 是不是陣列判斷是不是
  群組（金鑰交換通道的 `to` 即使群組也永遠是單一字串，一次只包給一個人的一台裝置），得靠這個明確
  欄位。
- **vpn/portal 資料模型**：群組的 `FederatedConversation` 是「同組織共用一筆」（`type=group` 時
  `user_id`/`peer_federated_id` 皆為 null），不是像 1:1 那樣「每個本地使用者各一筆」——跟本地
  `Conversation`/`ConversationParticipant` 的設計一致，新表 `federated_conversation_participants`
  記本地+遠端所有成員（`local_user_id` 有值代表是本組織的人），每人各自的已讀時間/隱藏聊天狀態也搬
  到這張表的 `read_at`/`hidden_at`（1:1 沒受影響，繼續用 `FederatedConversation` 上原本的欄位）。
- **同組織多本地成員的關鍵細節**：`FederatedMessage.direction`（inbound/outbound）原本是存死值，
  對 1:1（永遠只有一個本地視角）沒問題，但群組訊息是共用列，A 傳的訊息如果直接看 `direction`，
  B 眼裡也會誤判成「B 自己發的」（能誤編輯/收回、泡泡對齊錯邊）。改用
  `FederatedMessage::isMine(int $viewerId)`（比對 `from_user_id`）動態算：`FederatedChatController`
  取訊息時一律呼叫 `remapDirectionForViewer()` 把 `direction` 依目前登入者重寫成正確視角再往下送，
  編輯/收回的權限判斷也直接比 `from_user_id` 而不是 `direction`。
- **E2EE**：沿用本地群組「client 端算好每個收件人每台裝置的 wrap，server 只負責存/轉發」的模式，
  不是新的加密方案。`FederatedChatController::groupMemberDevicesFor()` 幫前端把「除了自己以外每個
  活躍成員的裝置公鑰」統一成同一種格式（本地成員查 `user_devices`，遠端成員查
  `federated_contacts.devices`，兩邊原始格式不同，這裡統一 decode 成陣列）；
  `federated_conversation_keys` 新增 `for_federated_id` 分辨每筆 wrap 是包給/來自哪個成員（1:1 只有
  一個對方不需要這欄，維持 null）。前端 `createConversationKey()` 對群組會產生 `member_wraps`
  （取代 1:1 的 `peer_wraps`）送給新端點參數 `to_federated_id`（金鑰交換端點原本靠反查對話唯一的
  對方，群組沒有唯一對方，得明講）。**成員異動後的金鑰輪替是手動觸發**：群組資訊面板的「更新金鑰」
  按鈕（僅 owner 看得到）對「目前所有活躍成員」重新包一輪新版金鑰，新成員從那個版本開始才看得懂，
  舊訊息解不開——不是自動偵測成員異動就觸發，避免背景靜默做加密操作。**移除/退出群組不會觸發金鑰
  輪替**（跟本地群組現況一致，是已知、故意維持一致的限制，不在這次一併補）。
- **權限**：只有 `role=owner`（群組建立者）能邀請/移除成員，比照本地 `ConversationPolicy::manage()`；
  自己退出不需要這個權限。邀請對象目前限制只能是已加入通訊錄的跨組織聯絡人（跟 1:1 `start()` 同一個
  既定慣例），本地同事則直接用同一支「新增成員」API（不受這個限制）加入。
- **已讀狀態**：群組沒有單一「對方」，`peer_read_at` 換成每個成員各自的 `read_at`；UI 顯示
  「N/總人數 已讀」而不是 1:1 的已讀/未讀二元狀態。
- **滾動方式**：`supports_group_chat` 旗標讓沒有主動開通的組織（例如第三方 org）完全不受影響，
  wire format 對既有 1:1 流量零改變；要讓其他組織用上群組功能，得先手動幫對方的 `FederationOrg`
  開這個旗標。

---

## 9. 行動 App（Flutter）API 差異

`routes/api.php` 的 `auth:sanctum` + `prefix('messaging')` 路由群組，端點與 `web.php` 的 `messaging.` 群組大致一致（對話/訊息/通話/貼圖），但：

> ⚠️ **已知缺口**：`web.php` 有 `POST /{conversation}/messages/{message}/reactions`（`toggleReaction`），但 `api.php` 的 Sanctum 群組**完全沒有這條路由**——目前 App（Sanctum 客戶端）無法對訊息按表情回應，只有網頁版能用。若要讓 App 支援表情回應，需在 `routes/api.php` 補上對應路由。

另外 `POST /api/federation/inbound`（無 Sanctum，HMAC 驗證）與 `GET /api/broadcast-config`（公開）掛在 `messaging` 群組之外的頂層。

---

## 10. 部署架構

### 10.1 站台配置

| | 開發／測試站 | 正式站 |
|---|---|---|
| 路徑 | `/var/www/html/vpn` | `/var/www/html/portal` |
| 網域 | `vpnadmin.steps.tw` | `portal.steps.tw` |
| Reverb 監聽埠 | `127.0.0.1:8080` | `127.0.0.1:8081` |
| Supervisor Reverb 設定 | `/etc/supervisor/conf.d/vpn-reverb.conf` | `/etc/supervisor/conf.d/portal-reverb.conf` |
| Supervisor Queue 設定 | `/etc/supervisor/conf.d/vpn-queue.conf` | 對應的 `portal-queue.conf` |

> **部署方式：`cp` 覆蓋，不是 git 同步**——先在 vpnadmin 開發測試，經使用者確認後才手動 `cp` 到 portal 並跑 `migrate`（見專案既有工作流程，非本文件新增規則）。目前兩站程式碼完全一致（僅一處註解文字差異），代表訊息系統已同步到正式機。

### 10.2 Reverb（WebSocket 伺服器）

- **以獨立長駐行程執行，由 Supervisor 管理（不是 systemd，也不是跑在 Apache 裡）**：`php artisan reverb:start --host=127.0.0.1 --port=<8080|8081>`，`autostart=true autorestart=true`，以 `www-data` 執行，log 落在各自 `storage/logs/reverb.log`。
- 兩站各自獨立行程＋獨立埠號，互不干擾，可分別重啟（`supervisorctl restart vpn-reverb` / `portal-reverb`）。
- **對外靠 Apache 反向代理**（vhost 設定）：
  ```
  ProxyPass        "/app/"  "ws://127.0.0.1:8080/app/"   # vpn.conf
  ProxyPassReverse "/app/"  "ws://127.0.0.1:8080/app/"
  ```
  （portal.conf 相同，只是埠號換成 8081）——前端一律連 `wss://<domain>/app/...`（見 `.env` 的 `REVERB_HOST/PORT/SCHEME`），實際流量在伺服器內部才轉成明文 ws 給本機的 Reverb process。需要 Apache 啟用 `mod_proxy_wstunnel`。
- 後端（Laravel 本身觸發 `broadcast()`）走 `REVERB_SERVER_HOST=127.0.0.1` / `REVERB_SERVER_PORT`，跟前端連線用的 `REVERB_HOST/PORT/SCHEME` 是兩組不同設定，不要混淆。

### 10.3 Queue Worker

`queue:work --queue=maintenance-reports,default --tries=3 --sleep=5 --timeout=300`，由 Supervisor 管理、`www-data` 執行。**注意：聊天訊息廣播（`MessageSent` 等）走 `ShouldBroadcastNow`，不經過這個 queue**，此 worker 影響的是其他非即時性的排程任務（如維護報告），與聊天即時性無關；但聯邦同步（`federation:sync-directory`）等指令仍依賴系統 crontab 觸發的 `schedule:run`。

### 10.4 關鍵環境變數

| 變數 | 用途 | 備註 |
|---|---|---|
| `BROADCAST_CONNECTION` | 需為 `reverb` | ⚠️ `.env.sample` 範例預設是 `log`，**新環境部署時務必手動改成 `reverb`**，否則聊天即時廣播不會發送 |
| `QUEUE_CONNECTION` | `database`（依 `.env.sample`） | 與聊天即時性無關（見上） |
| `REVERB_APP_ID/KEY/SECRET` | Reverb 應用程式憑證 | 兩站需各自獨立一組，不可共用 |
| `REVERB_HOST/PORT/SCHEME` | 前端連線用（走 Apache wss 代理） | vpnadmin 用 443/https，內部代理到 8080 |
| `REVERB_SERVER_HOST/PORT` | Reverb process 實際監聽位址 | `127.0.0.1:8080`（vpn）／`127.0.0.1:8081`（portal） |
| `VAPID_PUBLIC_KEY/PRIVATE_KEY` | 瀏覽器 Web Push | 缺 private key 時 Web Push 會失敗 |
| `FIREBASE_CREDENTIALS` | 原生 App FCM 推播（Service Account JSON 路徑） | 留空時 App 背景推播靜默失效（不影響其他功能），部署時務必確認有填 |
| `APNS_KEY_ID/TEAM_ID/AUTH_KEY_PATH/BUNDLE_ID/PRODUCTION` | PushKit VoIP 推播（來電喚醒，見 6.1） | 需在 Apple Developer 後台建立 VoIP Services 憑證或 APNs Auth Key（`.p8`）取得；留空時只有 App 開著才收得到來電，背景/被砍掉時靜默失效 |
| `FEDERATION_ORG_ID/CLIENT_ID/CLIENT_SECRET/WEBHOOK_SECRET/BOOTSTRAP_KEY/BROKER_URL` | 跨組織聯邦聊天 | ⚠️ **完全不在 `.env.sample` 裡**，需參照 `config/services.php` 的 `services.federation.*` 手動加入；未設定時聯邦相關排程會自動跳過，不影響本地聊天 |

### 10.5 排程任務

`routes/console.php`：`federation:sync-directory` 每 15 分鐘執行一次（`withoutOverlapping`，未設定 `federation.org_id` 時自動略過，不會噴錯）。需系統 crontab 有標準的 `php artisan schedule:run`（Laravel 慣例，每分鐘一次）。

### 10.6 新環境部署檢查清單

1. `.env`：確認 `BROADCAST_CONNECTION=reverb`（**不是 sample 預設的 log**）、各站獨立的 `REVERB_APP_ID/KEY/SECRET`、正確的 `REVERB_SERVER_PORT`（避免跟其他站衝突）。
2. Apache vhost：`ProxyPass /app/` 指到本站對應的 Reverb 埠，且已啟用 `mod_proxy_wstunnel`。
3. Supervisor：新增 `<站名>-reverb.conf` 與（如尚未有）`<站名>-queue.conf`，`supervisorctl reread && supervisorctl update`。
4. `VAPID_PUBLIC_KEY/PRIVATE_KEY` 已設定；如需原生 App 推播，`FIREBASE_CREDENTIALS` 指向有效的 Service Account JSON；如需來電 VoIP 喚醒，`APNS_*` 系列變數指向有效的 Auth Key（`.p8`）。
5. 如需啟用跨組織聯邦：另外設定 `FEDERATION_*` 系列變數並執行 `php artisan federation:register`。
6. 執行 `php artisan migrate`（訊息相關 migration 眾多，務必確認版本一致）。
7. 前端資產：`resources/js/echo.js` 走 Vite 建置，需 `VITE_REVERB_*` 對應環境變數並重新 build（`public/js/messaging.js` 為獨立不經 Vite 的版本，改動後不需要 build，但要留意兩份是否邏輯一致）。

---

## 11. 已知缺口 / 待辦事項彙整

- 圖片／語音／檔案／貼圖附件目前恆為明文儲存，非 E2EE 範圍（設計內決定，非疏漏，但部署/教育使用者時應明確告知這個邊界）。
- 跨組織對話若雙方剛好同時第一次建立金鑰會各自產生不同版本，見 8.6 節「已知限制」，機率極低、暫不處理。

以下項目已修復，記錄於此供追蹤：
- ~~App（Sanctum）無法對訊息按表情回應~~ —— `routes/api.php` 已補上 `messages.reactions.toggle` 路由。
- ~~跨組織聯邦訊息目前明文傳輸~~ —— 已接上 E2EE，見 8.6 節；同時修復了中間層 `conversation.key` webhook 遺漏 `to` 欄位、`MessageController` 未轉發加密欄位這兩個 `/var/www/html/message` 上的協定 bug。
- ~~`.env.sample` 的 `BROADCAST_CONNECTION` 範例值是 `log`，`FEDERATION_*` 無範例可抄~~ —— 已補上正確預設值與範例欄位（見 10.4）。
- ~~`FIREBASE_CREDENTIALS` 未設定時原生 App 推播靜默失效，不易察覺~~ —— 系統設定頁（`/settings/system` 的「Web Push 推播」分頁）現在會顯示 FCM 是否已正確設定的狀態徽章。
- ~~跨組織來電只有剛好開著那個對話頁面才會跳出接聽列，其他情況永遠接不到（`federated_calls.answered_at` 從功能上線後沒有一筆成功過）~~ —— 見 8.5「來電通知」：`messaging.js` 全站監聽加上跨組織來電提示 + `federated_calls.signal_payload` 補撈機制。
- ~~跨組織通話只做得出語音，`federated_calls.type` 的 `video` 列舉值一直沒被用到~~ —— 見 8.5「通話型別」，`FederatedChatController::sendCallSignal`/`FederationInboundController::handleCallSignal` 已接上視訊通話。
