@manonero/chat-client-sdk 0.0.1-beta.1 → 0.0.1-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +391 -137
  2. package/dist/ChatClient.d.ts +36 -4
  3. package/dist/ChatClient.d.ts.map +1 -1
  4. package/dist/ChatClient.js +13 -9
  5. package/dist/ChatClient.js.map +1 -1
  6. package/dist/http/BotApi.d.ts +4 -1
  7. package/dist/http/BotApi.d.ts.map +1 -1
  8. package/dist/http/BotApi.js +4 -1
  9. package/dist/http/BotApi.js.map +1 -1
  10. package/dist/http/ConversationApi.d.ts +8 -5
  11. package/dist/http/ConversationApi.d.ts.map +1 -1
  12. package/dist/http/ConversationApi.js +11 -11
  13. package/dist/http/ConversationApi.js.map +1 -1
  14. package/dist/http/FileApi.d.ts +9 -11
  15. package/dist/http/FileApi.d.ts.map +1 -1
  16. package/dist/http/FileApi.js +8 -12
  17. package/dist/http/FileApi.js.map +1 -1
  18. package/dist/http/SearchApi.d.ts +39 -0
  19. package/dist/http/SearchApi.d.ts.map +1 -0
  20. package/dist/http/SearchApi.js +44 -0
  21. package/dist/http/SearchApi.js.map +1 -0
  22. package/dist/index.d.ts +6 -2
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +2 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/realtime/ChatHubClient.d.ts +29 -2
  27. package/dist/realtime/ChatHubClient.d.ts.map +1 -1
  28. package/dist/realtime/ChatHubClient.js +36 -5
  29. package/dist/realtime/ChatHubClient.js.map +1 -1
  30. package/dist/realtime/NotificationHubClient.d.ts +11 -0
  31. package/dist/realtime/NotificationHubClient.d.ts.map +1 -1
  32. package/dist/realtime/NotificationHubClient.js +7 -3
  33. package/dist/realtime/NotificationHubClient.js.map +1 -1
  34. package/dist/realtime/ReconnectionManager.d.ts +9 -1
  35. package/dist/realtime/ReconnectionManager.d.ts.map +1 -1
  36. package/dist/realtime/ReconnectionManager.js +12 -2
  37. package/dist/realtime/ReconnectionManager.js.map +1 -1
  38. package/dist/realtime/connectionOptions.d.ts +32 -0
  39. package/dist/realtime/connectionOptions.d.ts.map +1 -0
  40. package/dist/realtime/connectionOptions.js +55 -0
  41. package/dist/realtime/connectionOptions.js.map +1 -0
  42. package/dist/types/block.d.ts +1 -0
  43. package/dist/types/block.d.ts.map +1 -1
  44. package/dist/types/block.js.map +1 -1
  45. package/dist/types/bot.d.ts +64 -2
  46. package/dist/types/bot.d.ts.map +1 -1
  47. package/dist/types/broadcast.d.ts +50 -0
  48. package/dist/types/broadcast.d.ts.map +1 -0
  49. package/dist/types/broadcast.js +3 -0
  50. package/dist/types/broadcast.js.map +1 -0
  51. package/dist/types/chat-events.d.ts +21 -18
  52. package/dist/types/chat-events.d.ts.map +1 -1
  53. package/dist/types/common.d.ts +14 -4
  54. package/dist/types/common.d.ts.map +1 -1
  55. package/dist/types/conversation.d.ts +2 -2
  56. package/dist/types/conversation.d.ts.map +1 -1
  57. package/dist/types/participant.d.ts +5 -0
  58. package/dist/types/participant.d.ts.map +1 -1
  59. package/dist/types/search.d.ts +47 -0
  60. package/dist/types/search.d.ts.map +1 -0
  61. package/dist/types/search.js +3 -0
  62. package/dist/types/search.js.map +1 -0
  63. package/package.json +1 -1
package/README.md CHANGED
@@ -13,19 +13,20 @@
13
13
  5. [Participant — ParticipantApi](#5-participant--participantapi)
14
14
  6. [Conversation — ConversationApi](#6-conversation--conversationapi)
15
15
  7. [Message — MessageApi](#7-message--messageapi)
16
- 8. [File — FileApi](#8-file--fileapi)
17
- 9. [Bot — BotApi](#9-bot--botapi)
18
- 10. [Proxy — ProxyApi](#10-proxy--proxyapi)
19
- 11. [Health — HealthApi](#11-health--healthapi)
20
- 12. [Real-time: ChatHubClient](#12-real-time-chathubclient)
21
- 13. [Real-time: NotificationHubClient](#13-real-time-notificationhubclient)
22
- 14. [ReconnectionManager](#14-reconnectionmanager)
23
- 15. [Hệ thống Block (nội dung tin nhắn)](#15-hệ-thống-block-nội-dung-tin-nhắn)
24
- 16. [Toàn bộ kiểu dữ liệu (Types)](#16-toàn-bộ-kiểu-dữ-liệu-types)
25
- 17. [Xử lý lỗi — ChatApiError](#17-xử-lý-lỗi--chatapierror)
26
- 18. [TypedEventEmitter](#18-typedeventemitter)
27
- 19. [Lưu ý quan trọng](#19-lưu-ý-quan-trọng)
28
- 20. [Ví dụ đầy đủ](#20-ví-dụ-đầy-đủ)
16
+ 8. [Tìm kiếm — SearchApi](#8-tìm-kiếm--searchapi)
17
+ 9. [File — FileApi](#9-file--fileapi)
18
+ 10. [Bot — BotApi](#10-bot--botapi)
19
+ 11. [Proxy — ProxyApi](#11-proxy--proxyapi)
20
+ 12. [Health — HealthApi](#12-health--healthapi)
21
+ 13. [Real-time: ChatHubClient](#13-real-time-chathubclient)
22
+ 14. [Real-time: NotificationHubClient](#14-real-time-notificationhubclient)
23
+ 15. [ReconnectionManager](#15-reconnectionmanager)
24
+ 16. [Hệ thống Block (nội dung tin nhắn)](#16-hệ-thống-block-nội-dung-tin-nhắn)
25
+ 17. [Toàn bộ kiểu dữ liệu (Types)](#17-toàn-bộ-kiểu-dữ-liệu-types)
26
+ 18. [Xử lý lỗi — ChatApiError](#18-xử-lý-lỗi--chatapierror)
27
+ 19. [TypedEventEmitter](#19-typedeventemitter)
28
+ 20. [Lưu ý quan trọng](#20-lưu-ý-quan-trọng)
29
+ 21. [Ví dụ đầy đủ](#21-ví-dụ-đầy-đủ)
29
30
 
30
31
  ---
31
32
 
@@ -37,6 +38,7 @@ ChatClient (facade chính)
37
38
  ├── participants → ParticipantApi (quản lý người dùng)
38
39
  ├── conversations → ConversationApi (quản lý cuộc hội thoại)
39
40
  ├── messages → MessageApi (tin nhắn)
41
+ ├── search → SearchApi (tìm kiếm tổng hợp conversations + messages)
40
42
  ├── files → FileApi (upload/download file)
41
43
  ├── bots → BotApi (quản lý bot)
42
44
  ├── proxy → ProxyApi (proxy đến backend khác)
@@ -92,6 +94,9 @@ import type { HttpClientOptions } from '@manonero/chat-client-sdk';
92
94
  // Event map types — dùng khi cần typed wrapper
93
95
  import type { ChatHubEventMap, NotificationHubEventMap } from '@manonero/chat-client-sdk';
94
96
 
97
+ // Ephemeral broadcast (dùng chung cho cả hai hub) — generic theo kiểu của `data`
98
+ import type { EphemeralBroadcastDto, BroadcastSenderType } from '@manonero/chat-client-sdk';
99
+
95
100
  // Hub options (khi dùng hub client độc lập, không qua ChatClient)
96
101
  import type { ChatHubClientOptions, NotificationHubClientOptions } from '@manonero/chat-client-sdk';
97
102
 
@@ -187,6 +192,50 @@ const client = new ChatClient({
187
192
  });
188
193
  ```
189
194
 
195
+ ### Cấu hình transport SignalR (`connectionOptions`)
196
+
197
+ SDK mặc định kết nối thẳng qua WebSockets, bỏ qua bước negotiate:
198
+
199
+ ```ts
200
+ {
201
+ skipNegotiation: true,
202
+ transport: HttpTransportType.WebSockets,
203
+ }
204
+ ```
205
+
206
+ Mọi giá trị truyền vào `signalrOptions.connectionOptions` sẽ **ghi đè** mặc định trên (đây là toàn bộ `IHttpConnectionOptions` của `@microsoft/signalr`, nên có thể override cả `accessTokenFactory`, `headers`, `withCredentials`, `httpClient`, …):
207
+
208
+ ```ts
209
+ import { HttpTransportType } from '@microsoft/signalr';
210
+
211
+ // Quay lại luồng negotiate tiêu chuẩn (ví dụ khi đứng sau proxy không hỗ trợ WS)
212
+ const client = new ChatClient({
213
+ baseUrl: 'https://chat-api.example.com',
214
+ signalrOptions: {
215
+ connectionOptions: {
216
+ skipNegotiation: false,
217
+ transport: HttpTransportType.WebSockets | HttpTransportType.LongPolling,
218
+ },
219
+ },
220
+ });
221
+ ```
222
+
223
+ Có thể cấu hình riêng cho từng hub; giá trị riêng được merge đè lên `connectionOptions` chung:
224
+
225
+ ```ts
226
+ const client = new ChatClient({
227
+ baseUrl: 'https://chat-api.example.com',
228
+ signalrOptions: {
229
+ connectionOptions: { withCredentials: false }, // áp dụng cho cả 2 hub
230
+ notificationConnectionOptions: { skipNegotiation: false }, // chỉ NotificationHub
231
+ },
232
+ });
233
+ ```
234
+
235
+ > **Lưu ý:** `skipNegotiation: true` chỉ hợp lệ khi `transport` đúng bằng `HttpTransportType.WebSockets` — kể cả tổ hợp cờ như `WebSockets | LongPolling` cũng bị SignalR từ chối. Nếu bạn đổi `transport` sang giá trị khác mà không nói gì về `skipNegotiation`, SDK sẽ tự bỏ mặc định `skipNegotiation: true`; nếu không, lỗi sẽ xuất hiện dưới dạng `connect()` bị reject (SignalR kiểm tra trong `HttpConnection.startInternal`, không phải lúc `build()`).
236
+ >
237
+ > Một key mang giá trị `undefined` được coi là "không cấu hình" và vẫn giữ mặc định — nên `connectionOptions: { transport: config.transport }` với `config.transport` optional sẽ không vô tình xoá mất transport mặc định.
238
+
190
239
  ---
191
240
 
192
241
  ## 3. ChatClient — Facade chính
@@ -208,6 +257,9 @@ new ChatClient(options: ChatClientOptions)
208
257
  | `tokenProvider` | `() => string \| null` | ❌ | Hàm lấy token từ bên ngoài |
209
258
  | `requestTimeoutMs` | `number \| null` | ❌ | Timeout HTTP / upload tính bằng ms. Mặc định `30000`. Truyền `null` để tắt (hữu ích khi upload file lớn). |
210
259
  | `signalrOptions.logLevel` | `LogLevel` | ❌ | Mức log cho SignalR (mặc định: `Warning`) |
260
+ | `signalrOptions.connectionOptions` | `IHttpConnectionOptions` | ❌ | Options truyền cho `withUrl()` của cả 2 hub. Ghi đè mặc định `skipNegotiation: true` + `transport: WebSockets` |
261
+ | `signalrOptions.chatConnectionOptions` | `IHttpConnectionOptions` | ❌ | Chỉ áp dụng cho ChatHub, merge đè lên `connectionOptions` |
262
+ | `signalrOptions.notificationConnectionOptions` | `IHttpConnectionOptions` | ❌ | Chỉ áp dụng cho NotificationHub, merge đè lên `connectionOptions` |
211
263
 
212
264
  ### Thuộc tính
213
265
 
@@ -217,6 +269,7 @@ new ChatClient(options: ChatClientOptions)
217
269
  | `participants` | `ParticipantApi` | Quản lý participant |
218
270
  | `conversations` | `ConversationApi` | Quản lý cuộc hội thoại |
219
271
  | `messages` | `MessageApi` | Thao tác với tin nhắn |
272
+ | `search` | `SearchApi` | Tìm kiếm tổng hợp conversations + messages |
220
273
  | `files` | `FileApi` | Upload/download file |
221
274
  | `bots` | `BotApi` | Quản lý bot |
222
275
  | `proxy` | `ProxyApi` | Proxy đến backend khác |
@@ -250,9 +303,9 @@ client.clearToken();
250
303
 
251
304
  #### `refreshConnections(): Promise<void>`
252
305
 
253
- `disconnect()` + `connect()` lại từng hub đang ở trạng thái `Connected`. Hub đang Disconnected/Reconnecting được giữ nguyên. Dùng để áp token mới (sau `setToken()`) cho phiên SignalR đang chạy.
306
+ Reconnect lại từng hub đang ở trạng thái `Connected` với token mới nhất. Hub đang Disconnected/Reconnecting được giữ nguyên. Dùng để áp token mới (sau `setToken()`) cho phiên SignalR đang chạy.
254
307
 
255
- > ⚠️ `disconnect()` xoá local tracking `joinedConversations`. Sau khi gọi `refreshConnections()`, ứng dụng phải tự `joinConversation` lại — hoặc dùng `ReconnectionManager` để khôi phục trong suốt cho mọi loại disconnect.
308
+ > ℹ️ ChatHub **giữ nguyên** các conversation đã join: SDK snapshot danh sách trước khi reconnect rồi tự `joinConversation` lại sau đó (best-effort), nên event theo hội thoại tiếp tục chảy qua lần xoay token. NotificationHub không có khái niệm join nên tự động nhận lại event sau khi reconnect.
256
309
 
257
310
  ```ts
258
311
  client.setToken(newToken);
@@ -435,20 +488,24 @@ const conv = await client.conversations.create({
435
488
  });
436
489
  ```
437
490
 
438
- ### `search(query, limit?): Promise<ConversationListItemDto[]>`
491
+ > ⚠️ **Đã gỡ bỏ `conversations.search()`** — endpoint backend `GET /api/conversations/search` không còn được proxy qua gateway (trả `404`). Dùng endpoint tìm kiếm tổng hợp `client.search.search()` thay thế — xem [mục 8 — SearchApi](#8-tìm-kiếm--searchapi).
439
492
 
440
- Tìm kiếm theo tên cuộc hội thoại hoặc tên participant.
441
-
442
- ```ts
443
- const results = await client.conversations.search('team', 10);
444
- ```
445
-
446
- ### `getById(id: string): Promise<ConversationDto>`
493
+ ### `getById(id, options?): Promise<ConversationDto>`
447
494
 
448
495
  ```ts
496
+ // Mặc định: bao gồm danh sách thành viên
449
497
  const conv = await client.conversations.getById('conv-id-here');
498
+
499
+ // Bỏ danh sách thành viên để giảm payload — khi đó `participants` trả về []
500
+ const light = await client.conversations.getById('conv-id-here', {
501
+ includeParticipants: false,
502
+ });
450
503
  ```
451
504
 
505
+ | Param | Kiểu | Mặc định | Mô tả |
506
+ |-------|------|----------|-------|
507
+ | `options.includeParticipants` | `boolean` | `true` | Đặt `false` để bỏ mảng `participants` khỏi response (spec §6.4). Khi `false`, `participants` là `[]`. |
508
+
452
509
  ### `update(id, request): Promise<ConversationDto>`
453
510
 
454
511
  ```ts
@@ -536,7 +593,7 @@ if (page1.hasMore) {
536
593
  ```ts
537
594
  interface ConversationDto {
538
595
  id: string;
539
- name: string;
596
+ name?: string | null;
540
597
  avatar?: MediaReference | null;
541
598
  ownerId: string;
542
599
  lastMessageAt?: string | null; // ISO 8601, null khi chưa có tin nhắn
@@ -661,12 +718,14 @@ const newer = await client.messages.getHistory('conv-id', {
661
718
 
662
719
  ### `search(conversationId, query, limit?): Promise<MessageDto[]>`
663
720
 
664
- Full-text search trong cuộc hội thoại.
721
+ Full-text search **trong phạm vi một cuộc hội thoại** (`GET /api/conversations/{conversationId}/messages/search`, spec §7.3).
665
722
 
666
723
  ```ts
667
724
  const results = await client.messages.search('conv-id', 'từ khóa tìm kiếm', 20);
668
725
  ```
669
726
 
727
+ > ℹ️ Để tìm kiếm **xuyên suốt tất cả** cuộc hội thoại và tin nhắn, dùng `client.search.search()` — xem [mục 8 — SearchApi](#8-tìm-kiếm--searchapi).
728
+
670
729
  ### `getById(messageId): Promise<MessageDto>`
671
730
 
672
731
  ```ts
@@ -773,7 +832,67 @@ interface EditMessageRequest {
773
832
 
774
833
  ---
775
834
 
776
- ## 8. File — FileApi
835
+ ## 8. Tìm kiếm — SearchApi
836
+
837
+ **Truy cập:** `client.search`
838
+
839
+ Endpoint tìm kiếm tổng hợp do gateway cung cấp (`GET /api/search`, spec §6.3) — tìm **conversations**, **messages**, hoặc **cả hai** trong một request. Yêu cầu JWT.
840
+
841
+ > Endpoint này **thay thế** cho `GET /api/conversations/search` và `GET /api/messages/search` (global) của backend — hai endpoint đó không còn được proxy qua gateway. Việc tìm kiếm tin nhắn trong phạm vi **một** cuộc hội thoại vẫn dùng `client.messages.search()` (spec §7.3).
842
+
843
+ ### `search(params): Promise<SearchResult>`
844
+
845
+ | Param | Kiểu | Mặc định | Mô tả |
846
+ |-------|------|----------|-------|
847
+ | `q` | `string` | — (**bắt buộc**) | Từ khóa tìm kiếm. Thiếu/rỗng → `400 Bad Request`. |
848
+ | `type` | `'MESSAGE' \| 'CONVERSATION' \| 'BOTH'` | `'BOTH'` | Phạm vi tìm kiếm. Giá trị khác → `400 Bad Request`. |
849
+ | `limit` | `number` | `20` | Số kết quả mỗi loại. |
850
+ | `cursor` | `string` | — | Phân trang — **chỉ áp dụng cho messages** (opaque `nextCursor` từ response trước). |
851
+
852
+ **Hành vi theo `type`:**
853
+
854
+ - `CONVERSATION` — tìm conversations; mỗi item kèm `lastMessage` (full `MessageDto`). `messages` trả về `null`.
855
+ - `MESSAGE` — tìm messages, **gom nhóm theo conversation**; mỗi nhóm kèm thông tin `conversation`. Phân trang (`nextCursor`/`hasMore`) phản ánh messages. `conversations` trả về `null`.
856
+ - `BOTH` — thực hiện cả hai (mặc định).
857
+
858
+ > Response **luôn có cả hai key** `conversations` và `messages`; phần không được yêu cầu bởi `type` sẽ là `null`.
859
+
860
+ ```ts
861
+ // Tìm cả hai loại (mặc định)
862
+ const result = await client.search.search({ q: 'hello' });
863
+
864
+ // Conversations: kiểm tra lastMessage.isDeleted trước khi hiển thị
865
+ for (const conv of result.conversations ?? []) {
866
+ const preview = conv.lastMessage?.isDeleted
867
+ ? 'Tin nhắn đã xóa'
868
+ : conv.lastMessage?.plainTextIndex ?? '';
869
+ console.log(conv.name, preview);
870
+ }
871
+
872
+ // Messages: gom nhóm theo conversation
873
+ for (const group of result.messages?.groups ?? []) {
874
+ console.log('Trong:', group.conversation?.name ?? '(không rõ)');
875
+ for (const msg of group.messages) console.log(' -', msg.plainTextIndex);
876
+ }
877
+ ```
878
+
879
+ ```ts
880
+ // Chỉ tìm messages + phân trang
881
+ let page = await client.search.search({ q: 'hello', type: 'MESSAGE', limit: 20 });
882
+ while (page.messages?.hasMore) {
883
+ page = await client.search.search({
884
+ q: 'hello',
885
+ type: 'MESSAGE',
886
+ cursor: page.messages.nextCursor!, // opaque string — KHÔNG cast sang number
887
+ });
888
+ }
889
+ ```
890
+
891
+ > ⚠️ Giống `client.conversations.list()`, backend **không** filter `isDeleted` cho `lastMessage` — client **phải** tự kiểm tra `lastMessage.isDeleted` trước khi hiển thị. Nếu batch fetch lỗi, `lastMessage` (của conversation) hoặc `conversation` (của nhóm message) có thể là `null` (degrade graceful).
892
+
893
+ ---
894
+
895
+ ## 9. File — FileApi
777
896
 
778
897
  **Truy cập:** `client.files`
779
898
 
@@ -820,19 +939,9 @@ const result = await client.files.upload(uploadUrl, file, (loaded, total) => {
820
939
 
821
940
  > Khi có `onProgress`, SDK dùng XHR thay vì fetch (vì fetch không hỗ trợ upload progress events).
822
941
 
823
- ### `getDownloadUrl(storageKey, signed?): string`
824
-
825
- Tạo URL tải file tuyệt đối (dùng để hiển thị ảnh, tải xuống, v.v.).
826
-
827
- ```ts
828
- // URL public (mặc định)
829
- const url = client.files.getDownloadUrl('01HXABCDEF/photo.jpg');
830
- // → "https://chat-api.example.com/api/files/01HXABCDEF/photo.jpg"
831
- ```
832
-
833
- #### Signed URL (kiểm soát truy cập)
942
+ ### `getDownloadUrl(storageKey, signed): string`
834
943
 
835
- Khi server bật signed download (kiểm soát truy cập có thời hạn), truyền `expires` + `sig` ở tham số thứ hai:
944
+ Tạo URL tải file tuyệt đối có chữ ký (dùng để hiển thị ảnh, tải xuống, v.v.). Theo spec §8.3, **cả `expires` lẫn `sig` đều bắt buộc** — không có public unsigned access.
836
945
 
837
946
  ```ts
838
947
  const url = client.files.getDownloadUrl('01HXABCDEF/photo.jpg', {
@@ -842,9 +951,9 @@ const url = client.files.getDownloadUrl('01HXABCDEF/photo.jpg', {
842
951
  // → ".../api/files/01HXABCDEF/photo.jpg?expires=...&sig=abc123..."
843
952
  ```
844
953
 
845
- > Server trả `400 Bad Request` (title `Invalid Signature`) nếu URL bị sửa hoặc đã hết hạn. `expires` và `sig` thường lấy từ nguồn server-issued tương ứng (không tự ghép).
954
+ > Server trả `400 Bad Request` (title `Invalid Signature`) nếu URL bị sửa hoặc đã hết hạn. `expires` và `sig` phải lấy từ nguồn server-issued (không tự ghép).
846
955
 
847
- > **Khi server bật signed download:** Server thường trả sẵn `url` trong `MediaReference` (đã gắn `expires` + `sig`) thay vì yêu cầu client tự gọi `getDownloadUrl(storageKey, { expires, sig })`. Trong trường hợp đó, ưu tiên dùng `MediaReference.url` (sau khi resolve relative — xem [MediaReference](#mediareference)), **không** dùng `getDownloadUrl(storageKey)` vì sẽ tạo ra unsigned URL và bị server từ chối.
956
+ > **Trong thực tế:** Server thường trả sẵn `url` trong `MediaReference` (đã gắn `expires` + `sig`) cho mọi media của tin nhắn / participant. Ưu tiên dùng `MediaReference.url` (xem [MediaReference](#mediareference)) — chỉ gọi `getDownloadUrl()` khi bạn có sẵn cặp `expires`/`sig` từ một response server khác (ví dụ: `downloadUrl` từ `POST /api/files/upload`).
848
957
 
849
958
  > **Encoding:** `storageKey` có thể chứa `/` — SDK giữ `/` làm phân cách path; các ký tự đặc biệt khác (space, `?`, `#`, `%`...) tự động được percent-encode theo từng segment.
850
959
 
@@ -883,7 +992,7 @@ interface UploadResponse {
883
992
 
884
993
  ---
885
994
 
886
- ## 9. Bot — BotApi
995
+ ## 10. Bot — BotApi
887
996
 
888
997
  **Truy cập:** `client.bots`
889
998
 
@@ -916,17 +1025,31 @@ const bot = await client.bots.create({
916
1025
  uniqueName: 'my-bot',
917
1026
  fullName: 'My Assistant Bot',
918
1027
  description: 'Trợ lý AI',
919
- kafkaTopic: 'my-bot-topic', // Không thể thay đổi sau khi tạo
1028
+ kafkaTopic: 'my-bot-topic', // optional — null/omit = không nhận event qua Kafka
1029
+ webhookUrl: 'https://bot.example.com/events', // optional — HTTP/2 NDJSON webhook
1030
+ deliveryPreference: 'Both', // optional — 'Both' | 'WebhookOnly' | 'KafkaOnly' (server default 'Both')
920
1031
  listenAllGroupMessages: false,
921
1032
  });
922
1033
  ```
923
1034
 
1035
+ > Bot có thể nhận outgoing event qua `kafkaTopic`, `webhookUrl`, cả hai, hoặc không cấu hình kênh nào. Cả hai field đều **optional** độc lập với nhau.
1036
+ >
1037
+ > `deliveryPreference` chỉ có hiệu lực khi bot có **cả** `kafkaTopic` lẫn `webhookUrl` được cấu hình. Bot chỉ có một kênh sẽ luôn dùng kênh đó bất kể preference.
1038
+
924
1039
  ### `update(id, request): Promise<BotDto>`
925
1040
 
926
1041
  ```ts
927
1042
  await client.bots.update('bot-id', {
928
1043
  fullName: 'Updated Bot Name',
929
- // kafkaTopic KHÔNG thể cập nhật
1044
+ kafkaTopic: 'new-topic', // set/đổi topic
1045
+ webhookUrl: 'https://bot.example.com/events/v2', // set/đổi webhook
1046
+ deliveryPreference: 'WebhookOnly', // optional — bỏ qua/null = không đổi
1047
+ });
1048
+
1049
+ // Để clear về null, dùng cờ riêng (mutually exclusive với việc set giá trị mới):
1050
+ await client.bots.update('bot-id', {
1051
+ clearKafkaTopic: true,
1052
+ clearWebhookUrl: true,
930
1053
  });
931
1054
  ```
932
1055
 
@@ -948,17 +1071,22 @@ await client.bots.delete('bot-id');
948
1071
  ### Kiểu dữ liệu Bot
949
1072
 
950
1073
  ```ts
1074
+ // Kênh nhận outgoing event. Chỉ có hiệu lực khi bot có cả kafkaTopic lẫn webhookUrl.
1075
+ type BotDeliveryPreference = 'Both' | 'WebhookOnly' | 'KafkaOnly';
1076
+
951
1077
  interface BotDto {
952
1078
  id: string;
953
- participantId: string; // Dùng để lấy name/avatar từ ParticipantApi
1079
+ participantId: string; // Dùng để lấy name/avatar từ ParticipantApi
954
1080
  description?: string | null;
955
1081
  metadata?: Record<string, unknown> | null;
956
- kafkaTopic: string; // KHÔNG thể thay đổi sau khi tạo
1082
+ kafkaTopic?: string | null; // null khi bot không cấu hình Kafka
1083
+ webhookUrl?: string | null; // HTTP/2 NDJSON webhook (chỉ MessageCreatedEvent); null khi không cấu hình
1084
+ deliveryPreference: BotDeliveryPreference; // Chỉ áp dụng khi cả kafkaTopic và webhookUrl đều được cấu hình
957
1085
  isActive: boolean;
958
1086
  listenAllGroupMessages: boolean;
959
- createdBy: string; // ID của người tạo bot
960
- createdAt: string; // ISO 8601
961
- updatedAt: string; // ISO 8601
1087
+ createdBy: string; // ID của người tạo bot
1088
+ createdAt: string; // ISO 8601
1089
+ updatedAt: string; // ISO 8601
962
1090
  }
963
1091
  ```
964
1092
 
@@ -968,29 +1096,35 @@ interface BotDto {
968
1096
 
969
1097
  ```ts
970
1098
  interface CreateBotRequest {
971
- uniqueName: string;
972
- fullName: string;
1099
+ uniqueName: string; // max 50, [a-zA-Z0-9_]
1100
+ fullName: string; // max 100
973
1101
  avatar?: MediaReference; // optional — avatar của bot
974
- description?: string;
1102
+ description?: string; // max 500
975
1103
  metadata?: Record<string, unknown>;
976
- kafkaTopic: string; // KHÔNG thể thay đổi sau khi tạo
1104
+ kafkaTopic?: string | null; // optional — max 200, [a-zA-Z0-9._-]
1105
+ webhookUrl?: string | null; // optional — absolute http/https URI, max 2000
1106
+ deliveryPreference?: BotDeliveryPreference; // optional — server default 'Both'
977
1107
  listenAllGroupMessages?: boolean; // Mặc định false
978
1108
  }
979
1109
 
980
1110
  interface UpdateBotRequest {
981
- uniqueName?: string; // Có thể cập nhật sau khi tạo
1111
+ uniqueName?: string;
982
1112
  fullName?: string;
983
1113
  avatar?: MediaReference;
984
1114
  description?: string;
985
1115
  metadata?: Record<string, unknown>;
986
1116
  listenAllGroupMessages?: boolean;
987
- // kafkaTopic KHÔNG thể cập nhật
1117
+ kafkaTopic?: string | null; // set/đổi topic (mutually exclusive với clearKafkaTopic)
1118
+ clearKafkaTopic?: boolean; // true → clear topic về null
1119
+ webhookUrl?: string | null; // set/đổi webhook (mutually exclusive với clearWebhookUrl)
1120
+ clearWebhookUrl?: boolean; // true → clear webhook về null
1121
+ deliveryPreference?: BotDeliveryPreference | null; // bỏ qua/null = không đổi
988
1122
  }
989
1123
  ```
990
1124
 
991
1125
  ---
992
1126
 
993
- ## 10. Proxy — ProxyApi
1127
+ ## 11. Proxy — ProxyApi
994
1128
 
995
1129
  **Truy cập:** `client.proxy`
996
1130
 
@@ -1047,7 +1181,7 @@ const result = await client.proxy.request<unknown>('myservice', 'api/resource/12
1047
1181
 
1048
1182
  ---
1049
1183
 
1050
- ## 11. Health — HealthApi
1184
+ ## 12. Health — HealthApi
1051
1185
 
1052
1186
  **Truy cập:** `client.health`
1053
1187
 
@@ -1067,7 +1201,7 @@ const ready = await client.health.ready(); // "Healthy"
1067
1201
 
1068
1202
  ---
1069
1203
 
1070
- ## 12. Real-time: ChatHubClient
1204
+ ## 13. Real-time: ChatHubClient
1071
1205
 
1072
1206
  **Truy cập:** `client.realtime.chat`
1073
1207
 
@@ -1205,9 +1339,10 @@ client.realtime.chat.off('messageReceived', handler);
1205
1339
  | `streamStarted` | `StreamStartedDto` | Bot stream bắt đầu |
1206
1340
  | `streamStatusUpdated` | `StreamStatusUpdatedDto` | Trạng thái bot stream thay đổi |
1207
1341
  | `streamChunkReceived` | `StreamChunkReceivedDto` | Nhận thêm chunk text từ bot |
1208
- | `streamChunkBatchReceived` | `StreamChunkBatchReceivedDto` | Phiên bản gộp của `streamChunkReceived` — append theo `parts[i].chunkIndex` tăng dần. Cài cả hai handler vì server có thể gửi bất kỳ event nào |
1342
+ | `streamBlockReceived` | `StreamBlockReceivedDto` | Nhận inline content block (widget) đan xen giữa các chunk text, theo `sequence` |
1209
1343
  | `streamCompleted` | `StreamCompletedDto` | Bot stream hoàn thành (kèm message đầy đủ) |
1210
1344
  | `streamAborted` | `StreamAbortedDto` | Bot stream bị hủy |
1345
+ | `ephemeralBroadcast` | `EphemeralBroadcastDto` | Broadcast tạm thời (không lưu) nhắm `audience="viewers"` của conversation đang mở — `conversationId` luôn có giá trị |
1211
1346
  | `error` | `HubErrorDto` | Lỗi từ server |
1212
1347
  | `reconnecting` | `Error \| undefined` | Đang reconnect |
1213
1348
  | `reconnected` | `string \| undefined` | Đã reconnect (connectionId mới) |
@@ -1222,7 +1357,7 @@ interface MessageUpdatedDto {
1222
1357
  conversationId: string;
1223
1358
  blocks: Block[];
1224
1359
  plainTextContent: string | null;
1225
- mentions?: Mention[];
1360
+ mentions?: Mention[] | null;
1226
1361
  updatedAt: string; // ISO 8601
1227
1362
  replyToMessageId?: string | null; // null nếu reply reference đã bị xóa
1228
1363
  }
@@ -1298,25 +1433,33 @@ client.realtime.chat.on('messageThumbnailsReady', (dto) => {
1298
1433
  Khi bot streaming, **không có** `messageReceived` — thay vào đó nhận chuỗi sự kiện sau:
1299
1434
 
1300
1435
  ```
1301
- streamStarted → streamStatusUpdated* → streamChunkReceived* → streamCompleted
1302
- → streamAborted
1436
+ streamStarted → streamStatusUpdated* → [streamChunkReceived | streamBlockReceived]* → streamCompleted
1437
+ → streamAborted
1303
1438
  ```
1304
1439
 
1440
+ `streamChunkReceived` (text) và `streamBlockReceived` (inline block/widget) dùng **chung** dãy số `sequence` (server gán, tăng dần từ 0). Client sắp xếp theo `sequence` để render đúng vị trí text↔block trong cùng một stream.
1441
+
1305
1442
  ```ts
1306
- const chunks: string[] = [];
1443
+ // Mỗi phần tử là một text chunk hoặc một block, gắn với sequence để sắp xếp.
1444
+ const parts: { sequence: number; text?: string; block?: Block }[] = [];
1307
1445
 
1308
1446
  client.realtime.chat.on('streamStarted', (dto) => {
1309
1447
  console.log('Bot bắt đầu trả lời, streamId:', dto.streamId);
1310
1448
  });
1311
1449
 
1312
1450
  client.realtime.chat.on('streamChunkReceived', (dto) => {
1313
- chunks.push(dto.text);
1314
- // Hiển thị progressive: chunks.join('')
1451
+ parts.push({ sequence: dto.sequence, text: dto.text });
1452
+ // Hiển thị progressive: sắp xếp theo sequence rồi nối lại
1453
+ });
1454
+
1455
+ client.realtime.chat.on('streamBlockReceived', (dto) => {
1456
+ parts.push({ sequence: dto.sequence, block: dto.block });
1457
+ // Render block (card, choice, divider...) đúng vị trí theo sequence
1315
1458
  });
1316
1459
 
1317
1460
  client.realtime.chat.on('streamCompleted', (dto) => {
1318
1461
  console.log('Tin nhắn hoàn chỉnh:', dto.message);
1319
- chunks.length = 0; // reset
1462
+ parts.length = 0; // reset
1320
1463
  });
1321
1464
 
1322
1465
  client.realtime.chat.on('streamAborted', (dto) => {
@@ -1324,9 +1467,44 @@ client.realtime.chat.on('streamAborted', (dto) => {
1324
1467
  });
1325
1468
  ```
1326
1469
 
1470
+ **Giới hạn streaming (server enforce):**
1471
+
1472
+ | Giới hạn | Giá trị |
1473
+ |----------|---------|
1474
+ | Max chunk size | 4096 bytes |
1475
+ | Max chunks / stream | 2000 |
1476
+ | Max blocks / stream | 50 |
1477
+ | Max block size | 65536 bytes (64 KB) |
1478
+ | Max accumulated size | 1 MB |
1479
+ | Stream timeout | 120 giây |
1480
+ | Max stream duration | 300 giây |
1481
+ | Max concurrent streams / bot | 10 |
1482
+
1483
+ ### Ephemeral Broadcast (tin real-time không lưu trữ)
1484
+
1485
+ Bot hoặc SYSTEM user có thể phát **broadcast tạm thời** (không lưu vào lịch sử, không có ack, không edit/delete) qua `POST /api/broadcasts`. Trên ChatHub, client nhận event `ephemeralBroadcast` khi broadcast nhắm `audience="viewers"` của conversation đang mở — khi đó `conversationId` luôn có giá trị.
1486
+
1487
+ `data` là một envelope JSON tự do; client diễn giải dựa trên `type`. Dùng generic `EphemeralBroadcastDto<TData>` để narrow kiểu của `data` khi cần (mặc định `unknown` để vẫn an toàn khi chưa khai báo).
1488
+
1489
+ ```ts
1490
+ import type { EphemeralBroadcastDto } from '@manonero/chat-client-sdk';
1491
+
1492
+ interface PromoBanner { title: string; startsAt: string }
1493
+
1494
+ client.realtime.chat.on('ephemeralBroadcast', (b) => {
1495
+ // b.senderType: 'Bot' | 'System' ; b.conversationId luôn có giá trị trên ChatHub
1496
+ if (b.type === 'promo_banner') {
1497
+ const promo = b.data as PromoBanner; // narrow theo `type`
1498
+ showBanner(promo.title, promo.startsAt);
1499
+ }
1500
+ });
1501
+ ```
1502
+
1503
+ > Cùng event `ephemeralBroadcast` (và cùng kiểu `EphemeralBroadcastDto`) cũng có trên NotificationHub cho các audience cấp user — xem [mục 14](#14-real-time-notificationhubclient). Định nghĩa kiểu đầy đủ ở [mục 17](#17-toàn-bộ-kiểu-dữ-liệu-types).
1504
+
1327
1505
  ---
1328
1506
 
1329
- ## 13. Real-time: NotificationHubClient
1507
+ ## 14. Real-time: NotificationHubClient
1330
1508
 
1331
1509
  **Truy cập:** `client.realtime.notifications`
1332
1510
 
@@ -1370,6 +1548,7 @@ await client.realtime.notifications.disconnect();
1370
1548
  | `participantLeft` | `ParticipantLeftDto` | Thành viên rời/bị xóa — gửi cho tất cả participant còn lại **và** chính người vừa rời (để sync xóa khỏi các tab khác) |
1371
1549
  | `conversationPinned` | `ConversationPinnedDto` | Conversation được pin |
1372
1550
  | `conversationUnpinned` | `ConversationUnpinnedDto` | Conversation được unpin |
1551
+ | `ephemeralBroadcast` | `EphemeralBroadcastDto` | Broadcast tạm thời (không lưu) — audience `members`/`all`/`users`; broadcast toàn cục có `conversationId: null` |
1373
1552
  | `reconnecting` | `Error \| undefined` | Đang reconnect |
1374
1553
  | `reconnected` | `string \| undefined` | Đã reconnect |
1375
1554
  | `disconnected` | `Error \| undefined` | Mất kết nối |
@@ -1440,9 +1619,24 @@ interface ConversationUnpinnedDto {
1440
1619
  }
1441
1620
  ```
1442
1621
 
1622
+ ### Ephemeral Broadcast trên NotificationHub
1623
+
1624
+ Giống ChatHub, NotificationHub cũng phát event `ephemeralBroadcast` (cùng kiểu `EphemeralBroadcastDto`) — nhưng cho các audience **cấp user**: `audience="members"` (mọi thành viên conversation), `audience="all"` (mọi user online), hoặc `audience="users"` (danh sách userId chỉ định). Với broadcast toàn cục, `conversationId` là `null`.
1625
+
1626
+ ```ts
1627
+ client.realtime.notifications.on('ephemeralBroadcast', (b) => {
1628
+ if (b.conversationId === null && b.type === 'system_alert') {
1629
+ // Broadcast toàn cục — vd. cảnh báo bảo trì hệ thống
1630
+ showGlobalAlert(b.data);
1631
+ }
1632
+ });
1633
+ ```
1634
+
1635
+ > Định nghĩa đầy đủ `EphemeralBroadcastDto` ở [mục 17](#17-toàn-bộ-kiểu-dữ-liệu-types).
1636
+
1443
1637
  ---
1444
1638
 
1445
- ## 14. ReconnectionManager
1639
+ ## 15. ReconnectionManager
1446
1640
 
1447
1641
  Class quản lý reconnect thủ công khi cả `withAutomaticReconnect` của SignalR đã thất bại và hub bị `disconnected`. Cung cấp exponential backoff và xử lý token hết hạn.
1448
1642
 
@@ -1470,13 +1664,19 @@ manager.stop();
1470
1664
 
1471
1665
  **Chiến lược backoff:** 3 lần thử với delay 2s → 5s → 10s.
1472
1666
 
1667
+ > **Không có event kết thúc:** nếu cả 3 lần thử đều thất bại, manager **dừng im lặng** — không phát thêm event nào. Một `connect()` thất bại không mở được kết nối nên `onclose`/`disconnected` **không** fire lại; host chỉ nhận đúng 1 lần `disconnected` (lần kích hoạt reconnect). Muốn biết reconnect đã bỏ cuộc, hãy tự giám sát qua event `disconnected` kết hợp getter `state` của hub.
1668
+
1473
1669
  **Phát hiện token hết hạn:** heuristic — match (case-insensitive) các keyword `401`, `unauthorized`, `expired`, `invalid_token`, `invalid token` trong message của error.
1474
1670
 
1671
+ > **⚠️ Giới hạn dưới transport mặc định:** SDK mặc định `skipNegotiation: true` + `transport: WebSockets`. Khi token hết hạn, 401 xảy ra ở bước bắt tay WebSocket — mà trình duyệt **không** expose HTTP status ra cho script, nên `err.message` chỉ là chuỗi chung chung ("WebSocket failed to connect…") không chứa keyword nào ở trên, và `onTokenExpired` **có thể không được gọi**. Không danh sách keyword nào khắc phục được (status đơn giản không nằm trong message). Vì vậy:
1672
+ > - Muốn xử lý hết hạn **tất định**, hãy **refresh chủ động** dựa trên `AuthResponse.expiresAt` (đã có sẵn từ lúc login) — đừng trông chờ vào drop-detection.
1673
+ > - Heuristic chỉ fire đáng tin khi bạn tự override `skipNegotiation: false` (lúc đó bước negotiate trả HTTP 401 đọc được).
1674
+
1475
1675
  **Bỏ qua close chủ động:** Cả `ChatHubClient` và `NotificationHubClient` expose getter `intentionallyClosed`. Trước khi tear down trong `disconnect()` (kể cả khi gọi qua `client.disconnect()` / `client.logout()`), cờ này được set `true`. Manager kiểm tra cờ ngay đầu handler và **bỏ qua** lần `disconnected` đó — không tự reconnect ngược ý người dùng. Cờ được reset về `false` ở đầu lần `connect()` kế tiếp.
1476
1676
 
1477
1677
  ---
1478
1678
 
1479
- ## 15. Hệ thống Block (nội dung tin nhắn)
1679
+ ## 16. Hệ thống Block (nội dung tin nhắn)
1480
1680
 
1481
1681
  `Block` là **discriminated union** — mỗi block phân biệt bằng field `$type`.
1482
1682
 
@@ -1518,6 +1718,7 @@ interface ImageBlock {
1518
1718
  height: number | null;
1519
1719
  caption: string | null;
1520
1720
  fileSizeBytes: number | null;
1721
+ mimeType: string | null; // vd. 'image/jpeg', 'image/png', 'image/svg+xml'
1521
1722
  }
1522
1723
 
1523
1724
  interface VideoBlock {
@@ -1635,6 +1836,32 @@ for (const block of message.blocks) {
1635
1836
 
1636
1837
  Danh sách đầy đủ: `isTextBlock`, `isImageBlock`, `isVideoBlock`, `isAudioBlock`, `isFileBlock`, `isLinkPreviewBlock`, `isEmbedBlock`, `isLocationBlock`, `isContactBlock`, `isChoiceBlock`, `isCardBlock`, `isCarouselBlock`, `isDividerBlock`, `isCustomBlock`.
1637
1838
 
1839
+ ### Serialization helpers — `withTypeFirst` / `normalizeBlocksForSend`
1840
+
1841
+ Server dùng polymorphic deserializer yêu cầu **`$type` phải là property đầu tiên** trong mỗi block JSON (spec §13). SDK đã tự gọi `normalizeBlocksForSend()` nội bộ trong `MessageApi.send/edit` và `ChatHubClient.sendMessage/editMessage`, nên **bạn KHÔNG cần gọi thủ công** trong luồng thông thường.
1842
+
1843
+ Hai helper này được export public chỉ dành cho power users muốn:
1844
+ - Tự xây dựng API module riêng dùng `HttpClient` trực tiếp.
1845
+ - Tạo block từ nguồn dữ liệu mà thứ tự property bị xáo trộn (vd. clone qua `structuredClone` rồi spread).
1846
+
1847
+ ```ts
1848
+ import { withTypeFirst, normalizeBlocksForSend } from '@manonero/chat-client-sdk';
1849
+ import type { Block, TextBlock } from '@manonero/chat-client-sdk';
1850
+
1851
+ // withTypeFirst<T>(block: T): T — chuẩn hoá 1 block đơn lẻ
1852
+ const normalized: TextBlock = withTypeFirst({
1853
+ content: 'Xin chào',
1854
+ format: 'Plain',
1855
+ plainText: 'Xin chào',
1856
+ $type: 'text', // có thể nằm ở cuối khi tạo block — sẽ được đưa lên đầu
1857
+ });
1858
+
1859
+ // normalizeBlocksForSend<T extends Block>(blocks: T[]): T[] — áp dụng cho mảng
1860
+ const blocks: Block[] = normalizeBlocksForSend(rawBlocks);
1861
+ ```
1862
+
1863
+ > `CarouselBlock` là block duy nhất chứa nested blocks (`cards: CardBlock[]`); `withTypeFirst` đệ quy nên rule `$type` đứng đầu giữ vững ở mọi cấp.
1864
+
1638
1865
  ### Ví dụ tạo block
1639
1866
 
1640
1867
  ```ts
@@ -1656,6 +1883,7 @@ const imageBlock: ImageBlock = {
1656
1883
  height: 1080,
1657
1884
  caption: 'Ảnh mô tả',
1658
1885
  fileSizeBytes: 204800,
1886
+ mimeType: 'image/jpeg',
1659
1887
  };
1660
1888
 
1661
1889
  // Gửi tin nhắn kèm file
@@ -1696,7 +1924,7 @@ interface CardField {
1696
1924
  }
1697
1925
  ```
1698
1926
 
1699
- ### ChoiceMode / ChoiceOption / ChoiceBlock
1927
+ ### ChoiceMode / ChoiceOption
1700
1928
 
1701
1929
  ```ts
1702
1930
  type ChoiceMode = 'Single' | 'Multiple';
@@ -1706,16 +1934,10 @@ interface ChoiceOption {
1706
1934
  value: string;
1707
1935
  selected: boolean;
1708
1936
  }
1709
-
1710
- interface ChoiceBlock {
1711
- $type: 'choice';
1712
- prompt: string; // Câu hỏi hiển thị cho user
1713
- options: ChoiceOption[];
1714
- mode: ChoiceMode;
1715
- submitted: boolean; // true sau khi user đã submit lựa chọn
1716
- }
1717
1937
  ```
1718
1938
 
1939
+ > `ChoiceBlock` đã được định nghĩa đầy đủ ở mục [Interfaces tham chiếu đầy đủ](#interfaces-tham-chiếu-đầy-đủ).
1940
+
1719
1941
  ### QuickReply
1720
1942
 
1721
1943
  Nút bấm nhanh hiển thị bên dưới tin nhắn bot:
@@ -1743,7 +1965,7 @@ interface Mention {
1743
1965
 
1744
1966
  ---
1745
1967
 
1746
- ## 16. Toàn bộ kiểu dữ liệu (Types)
1968
+ ## 17. Toàn bộ kiểu dữ liệu (Types)
1747
1969
 
1748
1970
  ### CursorPaginatedResult (cursor-based)
1749
1971
 
@@ -1766,12 +1988,39 @@ interface PagedResult<T> {
1766
1988
  }
1767
1989
  ```
1768
1990
 
1991
+ ### SearchResult (GET /api/search — §8)
1992
+
1993
+ ```ts
1994
+ type SearchType = 'MESSAGE' | 'CONVERSATION' | 'BOTH';
1995
+
1996
+ interface SearchResult {
1997
+ // null khi type=MESSAGE. Mỗi item có `lastMessage` đã hydrate —
1998
+ // kiểm tra lastMessage.isDeleted trước khi hiển thị.
1999
+ conversations: ConversationListItemDto[] | null;
2000
+ // null khi type=CONVERSATION.
2001
+ messages: SearchMessagesResult | null;
2002
+ }
2003
+
2004
+ interface SearchMessagesResult {
2005
+ groups: SearchMessageGroup[];
2006
+ nextCursor: string | null; // opaque — chỉ phân trang cho messages
2007
+ hasMore: boolean;
2008
+ }
2009
+
2010
+ interface SearchMessageGroup {
2011
+ // null nếu batch fetch conversation lỗi (degrade graceful).
2012
+ // `participants` luôn là [] (fetch với includeParticipants=false).
2013
+ conversation: ConversationDto | null;
2014
+ messages: MessageDto[];
2015
+ }
2016
+ ```
2017
+
1769
2018
  ### MediaReference
1770
2019
 
1771
2020
  ```ts
1772
2021
  interface MediaReference {
1773
- storageKey?: string; // Key lưu trữ nội bộ — dùng FileApi.getDownloadUrl(storageKey) để tạo URL tuyệt đối
1774
- url?: string; // URL do server cấp sẵn (xem chi tiết bên dưới)
2022
+ storageKey?: string | null; // Key lưu trữ nội bộ — dùng FileApi.getDownloadUrl(storageKey, { expires, sig }) để tạo URL tuyệt đối có chữ ký. `null` khi reference là external URL.
2023
+ url?: string | null; // URL do server cấp sẵn (xem chi tiết bên dưới). `null` ở request body khi chỉ gửi storageKey.
1775
2024
  }
1776
2025
  ```
1777
2026
 
@@ -1782,22 +2031,20 @@ Trường `url` có hai dạng tuỳ cấu hình server:
1782
2031
  | **Relative signed URL** | `/api/files/abc/photo.jpg?expires=...&sig=...` | Phải prepend `baseUrl` trước khi dùng |
1783
2032
  | **Absolute external URL** | `https://cdn.example.com/photo.jpg` | Dùng trực tiếp |
1784
2033
 
1785
- > **Quy tắc resolve:** Kiểm tra `url` trước, `storageKey` sau. Nếu `url` bắt đầu bằng `/`, đây là relative signed URL — cần ghép `baseUrl`. Tuyệt đối **không** bỏ qua `url` để dùng `storageKey` vì signed URL có thể bắt buộc (server trả `400 Invalid Signature` nếu gọi bằng unsigned URL).
2034
+ > **Quy tắc resolve:** Luôn ưu tiên `url` — nó đã được server gắn signature (`expires` + `sig`). Nếu chỉ có `storageKey` mà không có `url`, bạn cần cặp `expires`/`sig` từ nguồn khác để gọi `getDownloadUrl(storageKey, { expires, sig })` (server bắt buộc signed access — gọi unsigned sẽ bị từ chối với `400 Invalid Signature`).
1786
2035
 
1787
2036
  ```ts
1788
- // Helper chuẩn để resolve MediaReference → URL tuyệt đối
2037
+ // Helper chuẩn để resolve MediaReference → URL tuyệt đối.
2038
+ // Server gần như luôn trả sẵn `url` cho mọi MediaReference của tin nhắn/participant,
2039
+ // nên trường hợp chỉ có storageKey là bất thường và nên log để debug.
1789
2040
  function resolveMediaRef(
1790
2041
  ref: MediaReference | null | undefined,
1791
2042
  baseUrl: string,
1792
- getDownloadUrl: (key: string) => string,
1793
2043
  ): string | null {
1794
- if (!ref) return null;
1795
- if (ref.url) {
1796
- // Relative signed URL → prepend baseUrl
1797
- return ref.url.startsWith('/') ? baseUrl.replace(/\/$/, '') + ref.url : ref.url;
1798
- }
1799
- if (ref.storageKey) return getDownloadUrl(ref.storageKey);
1800
- return null;
2044
+ if (!ref?.url) return null;
2045
+ // url có thể là relative signed URL ("/api/files/...?sig=...")
2046
+ // hoặc absolute external URL ("https://...")
2047
+ return ref.url.startsWith('/') ? baseUrl.replace(/\/$/, '') + ref.url : ref.url;
1801
2048
  }
1802
2049
  ```
1803
2050
 
@@ -1902,23 +2149,15 @@ interface StreamChunkReceivedDto {
1902
2149
  messageId: string;
1903
2150
  conversationId: string;
1904
2151
  text: string; // Chunk text để append vào placeholder
1905
- chunkIndex: number; // Zero-based index
2152
+ sequence: number; // Số thứ tự server gán (zero-based), chung dãy với StreamBlockReceivedDto
1906
2153
  }
1907
2154
 
1908
- // Phiên bản gộp của StreamChunkReceived — server gom các chunk phát ra trong
1909
- // cùng cửa sổ Streaming:ChunkCoalesceWindowMs (mặc định 50ms) thành 1 batch.
1910
- // Client cài cả hai handler vì server có thể gửi bất kỳ event nào.
1911
- // Append theo thứ tự parts[i].chunkIndex tăng dần.
1912
- interface StreamChunkPart {
1913
- text: string;
1914
- chunkIndex: number;
1915
- }
1916
-
1917
- interface StreamChunkBatchReceivedDto {
2155
+ interface StreamBlockReceivedDto {
1918
2156
  streamId: string;
1919
2157
  messageId: string;
1920
2158
  conversationId: string;
1921
- parts: StreamChunkPart[];
2159
+ block: Block; // Inline content block (card, choice, divider...)
2160
+ sequence: number; // Số thứ tự server gán, chung dãy với StreamChunkReceivedDto
1922
2161
  }
1923
2162
 
1924
2163
  interface StreamCompletedDto {
@@ -1939,6 +2178,29 @@ interface StreamAbortedDto {
1939
2178
  }
1940
2179
  ```
1941
2180
 
2181
+ ### EphemeralBroadcastDto (dùng chung — ChatHub & NotificationHub)
2182
+
2183
+ Broadcast tạm thời (**KHÔNG lưu trữ**) do bot hoặc SYSTEM user phát qua `POST /api/broadcasts`. Cùng một payload xuất hiện trên **cả hai hub** với event `ephemeralBroadcast`:
2184
+
2185
+ - **ChatHub** — `audience="viewers"` của conversation đang mở (`conversationId` có giá trị).
2186
+ - **NotificationHub** — `audience="members" | "all" | "users"` (broadcast toàn cục → `conversationId: null`).
2187
+
2188
+ ```ts
2189
+ // Sender của broadcast — luôn là bot hoặc SYSTEM user (không bao giờ là User thường)
2190
+ type BroadcastSenderType = 'Bot' | 'System';
2191
+
2192
+ interface EphemeralBroadcastDto<TData = unknown> {
2193
+ type: string; // Khóa do người gửi đặt — client tự diễn giải `data` theo giá trị này
2194
+ senderId: string; // participantId của bot, hoặc literal "SYSTEM"
2195
+ senderType: BroadcastSenderType; // 'Bot' | 'System'
2196
+ conversationId: string | null; // null khi broadcast toàn cục
2197
+ data: TData | null; // Envelope JSON tự do; null khi người gửi bỏ trống
2198
+ sentAt: string; // UTC ISO 8601
2199
+ }
2200
+ ```
2201
+
2202
+ > `TData` cho phép narrow kiểu của `data` theo `type` (mặc định `unknown` để dùng an toàn khi chưa khai báo). Xem ví dụ ở [mục 13](#13-real-time-chathubclient) (ChatHub) và [mục 14](#14-real-time-notificationhubclient) (NotificationHub).
2203
+
1942
2204
  ### Ack types (return value của Hub methods)
1943
2205
 
1944
2206
  Mỗi Hub method client→server trả về một ack object với `success: boolean` và optional error fields:
@@ -1987,7 +2249,7 @@ interface ReactionAck {
1987
2249
 
1988
2250
  ---
1989
2251
 
1990
- ## 17. Xử lý lỗi — ChatApiError
2252
+ ## 18. Xử lý lỗi — ChatApiError
1991
2253
 
1992
2254
  Tất cả lỗi HTTP đều throw `ChatApiError` (kế thừa `Error`).
1993
2255
 
@@ -2034,7 +2296,7 @@ try {
2034
2296
  |------------|------|-------|
2035
2297
  | `status` | `number` | HTTP status code |
2036
2298
  | `title` | `string` | Tiêu đề lỗi — giá trị `ErrorType` enum: `Validation`, `NotFound`, `Conflict`, `Forbidden`, `Unauthorized`, `Unexpected`. Auth endpoints trả `Bad Request`, v.v. |
2037
- | `detail` | `string \| undefined` | Mô tả của lỗi đầu tiên (từ RFC 7807 `detail`) |
2299
+ | `detail` | `string \| undefined` | Mô tả chi tiết, human-readable của lỗi (RFC 7807 `detail`) |
2038
2300
  | `type` | `string \| undefined` | URI tham chiếu RFC 7807 `type` (nếu server gửi). `undefined` cho proxy errors và lỗi sinh nội bộ (timeout/network/parse) |
2039
2301
  | `errors` | `ProblemError[] \| undefined` | Mảng tất cả lỗi chi tiết — có khi lỗi đi qua handler (ErrorOr path). `undefined` cho lỗi middleware/filter, proxy errors, và lỗi sinh nội bộ |
2040
2302
  | `traceId` | `string \| undefined` | W3C trace ID để correlate log. Tự động thêm bởi framework. `undefined` cho proxy errors và lỗi sinh nội bộ |
@@ -2110,6 +2372,8 @@ const chatHub = new ChatHubClient({
2110
2372
  hubUrl: 'https://chat-api.example.com/hubs/chat',
2111
2373
  tokenProvider,
2112
2374
  logLevel: LogLevel.Warning, // optional
2375
+ // optional — ghi đè mặc định skipNegotiation: true + transport: WebSockets
2376
+ connectionOptions: { skipNegotiation: false },
2113
2377
  });
2114
2378
 
2115
2379
  // Khởi tạo NotificationHub độc lập
@@ -2139,7 +2403,7 @@ manager.start();
2139
2403
 
2140
2404
  ---
2141
2405
 
2142
- ## 18. TypedEventEmitter
2406
+ ## 19. TypedEventEmitter
2143
2407
 
2144
2408
  SDK dùng `TypedEventEmitter<TEventMap>` nội bộ cho cả hai hub. Giao diện public của nó được expose qua `.on()` và `.off()`.
2145
2409
 
@@ -2169,7 +2433,7 @@ unsubs.forEach(fn => fn());
2169
2433
 
2170
2434
  ---
2171
2435
 
2172
- ## 19. Lưu ý quan trọng
2436
+ ## 20. Lưu ý quan trọng
2173
2437
 
2174
2438
  ### Cursor pagination — KHÔNG cast sang number
2175
2439
 
@@ -2212,34 +2476,25 @@ const block: ImageBlock = {
2212
2476
  };
2213
2477
  ```
2214
2478
 
2215
- **Khi hiển thị tin nhắn nhận từ server:** server trả về `MediaReference` có thể chứa cả `storageKey` lẫn `url`. Dùng helper resolve sau:
2479
+ **Khi hiển thị tin nhắn nhận từ server:** server trả về `MediaReference` có sẵn `url` đã được ký (`expires` + `sig`). Dùng helper resolve sau:
2216
2480
 
2217
2481
  ```ts
2218
2482
  // resolve MediaReference → URL tuyệt đối để dùng trong <img src>, <a href>, v.v.
2219
2483
  function resolveMediaRef(
2220
2484
  ref: MediaReference | null | undefined,
2221
2485
  baseUrl: string,
2222
- getDownloadUrl: (key: string) => string,
2223
2486
  ): string | null {
2224
- if (!ref) return null;
2225
- if (ref.url) {
2226
- // url có thể là relative signed URL ("/api/files/...?sig=...")
2227
- // hoặc absolute external URL ("https://...")
2228
- return ref.url.startsWith('/') ? baseUrl.replace(/\/$/, '') + ref.url : ref.url;
2229
- }
2230
- if (ref.storageKey) return getDownloadUrl(ref.storageKey);
2231
- return null;
2487
+ if (!ref?.url) return null;
2488
+ // url có thể là relative signed URL ("/api/files/...?sig=...")
2489
+ // hoặc absolute external URL ("https://...")
2490
+ return ref.url.startsWith('/') ? baseUrl.replace(/\/$/, '') + ref.url : ref.url;
2232
2491
  }
2233
2492
 
2234
2493
  // Ví dụ dùng với ImageBlock:
2235
- const displayUrl = resolveMediaRef(
2236
- block.source,
2237
- 'https://chat-api.example.com',
2238
- client.files.getDownloadUrl.bind(client.files),
2239
- );
2494
+ const displayUrl = resolveMediaRef(block.source, 'https://chat-api.example.com');
2240
2495
  ```
2241
2496
 
2242
- > **Tại sao không chỉ dùng `getDownloadUrl(storageKey)`?** Khi server bật signed download, `getDownloadUrl(storageKey)` tạo unsigned URL → server từ chối (`400 Invalid Signature`). `url` trong `MediaReference` đã được server gắn sẵn signature và phải được ưu tiên dùng.
2497
+ > **Tại sao không dùng `getDownloadUrl(storageKey, ...)`?** Server bắt buộc signed access (spec §8.3), và `getDownloadUrl()` yêu cầu bạn cung cấp cặp `expires`/`sig`. Trong khi đó, `MediaReference.url` đã được server gắn sẵn signature — không cần thao tác thêm.
2243
2498
 
2244
2499
  > **Tại sao không dùng `url` trực tiếp?** Server trả `url` dạng relative (`/api/files/...`), không phải absolute. Dùng trực tiếp trong `<img src>` sẽ resolve về origin của frontend thay vì API server nếu hai service chạy trên host/port khác nhau.
2245
2500
 
@@ -2255,7 +2510,7 @@ console.log(participant.fullName); // Tên hiển thị của bot
2255
2510
 
2256
2511
  ---
2257
2512
 
2258
- ## 20. Ví dụ đầy đủ
2513
+ ## 21. Ví dụ đầy đủ
2259
2514
 
2260
2515
  ### Ví dụ 1: Chat app cơ bản
2261
2516
 
@@ -2367,7 +2622,9 @@ async function loadMoreMessages(conversationId: string) {
2367
2622
  ### Ví dụ 3: Xử lý bot streaming
2368
2623
 
2369
2624
  ```ts
2370
- const streamBuffers = new Map<string, string[]>();
2625
+ // Mỗi part là text hoặc block, gắn sequence (server gán) để sắp xếp đúng vị trí.
2626
+ type StreamPart = { sequence: number; text?: string; block?: Block };
2627
+ const streamBuffers = new Map<string, StreamPart[]>();
2371
2628
 
2372
2629
  client.realtime.chat.on('streamStarted', (dto) => {
2373
2630
  streamBuffers.set(dto.streamId, []);
@@ -2378,24 +2635,21 @@ client.realtime.chat.on('streamStatusUpdated', (dto) => {
2378
2635
  updateStreamStatus(dto.messageId, dto.status); // e.g. "thinking", "searching"
2379
2636
  });
2380
2637
 
2381
- // Append một chunk vào buffer rồi flush ra UI
2382
- function appendChunk(streamId: string, messageId: string, text: string) {
2383
- const chunks = streamBuffers.get(streamId) ?? [];
2384
- chunks.push(text);
2385
- streamBuffers.set(streamId, chunks);
2386
- updateStreamingText(messageId, chunks.join(''));
2638
+ // Append một part (text hoặc block) vào buffer, sắp theo sequence rồi flush ra UI
2639
+ function appendPart(streamId: string, messageId: string, part: StreamPart) {
2640
+ const parts = streamBuffers.get(streamId) ?? [];
2641
+ parts.push(part);
2642
+ parts.sort((a, b) => a.sequence - b.sequence);
2643
+ streamBuffers.set(streamId, parts);
2644
+ renderStreamingParts(messageId, parts); // render text + block đúng thứ tự
2387
2645
  }
2388
2646
 
2389
2647
  client.realtime.chat.on('streamChunkReceived', (dto) => {
2390
- appendChunk(dto.streamId, dto.messageId, dto.text);
2648
+ appendPart(dto.streamId, dto.messageId, { sequence: dto.sequence, text: dto.text });
2391
2649
  });
2392
2650
 
2393
- // Bắt buộc cài cả handler batch — server có thể gửi chunk gộp khi bật coalescing
2394
- // (default 50ms). Append theo thứ tự parts[i].chunkIndex tăng dần.
2395
- client.realtime.chat.on('streamChunkBatchReceived', (dto) => {
2396
- for (const part of dto.parts) {
2397
- appendChunk(dto.streamId, dto.messageId, part.text);
2398
- }
2651
+ client.realtime.chat.on('streamBlockReceived', (dto) => {
2652
+ appendPart(dto.streamId, dto.messageId, { sequence: dto.sequence, block: dto.block });
2399
2653
  });
2400
2654
 
2401
2655
  client.realtime.chat.on('streamCompleted', (dto) => {