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

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 +398 -138
  2. package/dist/ChatClient.d.ts +53 -4
  3. package/dist/ChatClient.d.ts.map +1 -1
  4. package/dist/ChatClient.js +37 -13
  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 |
@@ -235,6 +288,8 @@ Cập nhật JWT token nội bộ. Có hiệu lực **ngay** với mọi HTTP re
235
288
 
236
289
  > ℹ️ Nếu `tokenProvider` đã được cấu hình và vẫn trả về giá trị, provider tiếp tục được ưu tiên — `setToken()` chỉ ảnh hưởng khi provider trả `null`/empty.
237
290
 
291
+ > ⚠️ Truyền token **khác `null`** không đụng tới `currentUser` — SDK giả định đây là rotate token của **cùng một user** (đúng use case của `onTokenExpired`, xem mục 15). Nếu bạn dùng `setToken()` để **đổi sang user khác** mà không gọi `login*()` lại, `currentUser` sẽ vẫn trỏ về user cũ (sai). Muốn đổi user, hãy gọi lại `auth.login*()` (tự cập nhật cả token lẫn `currentUser`), hoặc `clearToken()`/`setToken(null)` trước.
292
+
238
293
  ```ts
239
294
  client.setToken('new-jwt-token-here');
240
295
  await client.refreshConnections(); // tuỳ chọn — chỉ khi muốn áp ngay cho SignalR
@@ -250,9 +305,11 @@ client.clearToken();
250
305
 
251
306
  #### `refreshConnections(): Promise<void>`
252
307
 
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.
308
+ 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.
309
+
310
+ > ℹ️ 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.
254
311
 
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.
312
+ > ℹ️ Nếu tại thời điểm gọi **không còn token nào khả dụng** (ví dụ vừa `setToken(null)`/`clearToken()` và không có `tokenProvider` nào trả về giá trị thay thế), hub đang `Connected` sẽ được **disconnect** thay vì reconnect — vì không có token hợp lệ để reconnect. Nhờ vậy gọi `refreshConnections()` ngay sau khi xoá token là an toàn, không throw.
256
313
 
257
314
  ```ts
258
315
  client.setToken(newToken);
@@ -435,20 +492,24 @@ const conv = await client.conversations.create({
435
492
  });
436
493
  ```
437
494
 
438
- ### `search(query, limit?): Promise<ConversationListItemDto[]>`
495
+ > ⚠️ **Đã 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
496
 
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>`
497
+ ### `getById(id, options?): Promise<ConversationDto>`
447
498
 
448
499
  ```ts
500
+ // Mặc định: bao gồm danh sách thành viên
449
501
  const conv = await client.conversations.getById('conv-id-here');
502
+
503
+ // Bỏ danh sách thành viên để giảm payload — khi đó `participants` trả về []
504
+ const light = await client.conversations.getById('conv-id-here', {
505
+ includeParticipants: false,
506
+ });
450
507
  ```
451
508
 
509
+ | Param | Kiểu | Mặc định | Mô tả |
510
+ |-------|------|----------|-------|
511
+ | `options.includeParticipants` | `boolean` | `true` | Đặt `false` để bỏ mảng `participants` khỏi response (spec §6.4). Khi `false`, `participants` là `[]`. |
512
+
452
513
  ### `update(id, request): Promise<ConversationDto>`
453
514
 
454
515
  ```ts
@@ -536,7 +597,7 @@ if (page1.hasMore) {
536
597
  ```ts
537
598
  interface ConversationDto {
538
599
  id: string;
539
- name: string;
600
+ name?: string | null;
540
601
  avatar?: MediaReference | null;
541
602
  ownerId: string;
542
603
  lastMessageAt?: string | null; // ISO 8601, null khi chưa có tin nhắn
@@ -661,12 +722,14 @@ const newer = await client.messages.getHistory('conv-id', {
661
722
 
662
723
  ### `search(conversationId, query, limit?): Promise<MessageDto[]>`
663
724
 
664
- Full-text search trong cuộc hội thoại.
725
+ 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
726
 
666
727
  ```ts
667
728
  const results = await client.messages.search('conv-id', 'từ khóa tìm kiếm', 20);
668
729
  ```
669
730
 
731
+ > ℹ️ Để 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).
732
+
670
733
  ### `getById(messageId): Promise<MessageDto>`
671
734
 
672
735
  ```ts
@@ -773,7 +836,67 @@ interface EditMessageRequest {
773
836
 
774
837
  ---
775
838
 
776
- ## 8. File — FileApi
839
+ ## 8. Tìm kiếm — SearchApi
840
+
841
+ **Truy cập:** `client.search`
842
+
843
+ 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.
844
+
845
+ > 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).
846
+
847
+ ### `search(params): Promise<SearchResult>`
848
+
849
+ | Param | Kiểu | Mặc định | Mô tả |
850
+ |-------|------|----------|-------|
851
+ | `q` | `string` | — (**bắt buộc**) | Từ khóa tìm kiếm. Thiếu/rỗng → `400 Bad Request`. |
852
+ | `type` | `'MESSAGE' \| 'CONVERSATION' \| 'BOTH'` | `'BOTH'` | Phạm vi tìm kiếm. Giá trị khác → `400 Bad Request`. |
853
+ | `limit` | `number` | `20` | Số kết quả mỗi loại. |
854
+ | `cursor` | `string` | — | Phân trang — **chỉ áp dụng cho messages** (opaque `nextCursor` từ response trước). |
855
+
856
+ **Hành vi theo `type`:**
857
+
858
+ - `CONVERSATION` — tìm conversations; mỗi item kèm `lastMessage` (full `MessageDto`). `messages` trả về `null`.
859
+ - `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`.
860
+ - `BOTH` — thực hiện cả hai (mặc định).
861
+
862
+ > 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`.
863
+
864
+ ```ts
865
+ // Tìm cả hai loại (mặc định)
866
+ const result = await client.search.search({ q: 'hello' });
867
+
868
+ // Conversations: kiểm tra lastMessage.isDeleted trước khi hiển thị
869
+ for (const conv of result.conversations ?? []) {
870
+ const preview = conv.lastMessage?.isDeleted
871
+ ? 'Tin nhắn đã xóa'
872
+ : conv.lastMessage?.plainTextIndex ?? '';
873
+ console.log(conv.name, preview);
874
+ }
875
+
876
+ // Messages: gom nhóm theo conversation
877
+ for (const group of result.messages?.groups ?? []) {
878
+ console.log('Trong:', group.conversation?.name ?? '(không rõ)');
879
+ for (const msg of group.messages) console.log(' -', msg.plainTextIndex);
880
+ }
881
+ ```
882
+
883
+ ```ts
884
+ // Chỉ tìm messages + phân trang
885
+ let page = await client.search.search({ q: 'hello', type: 'MESSAGE', limit: 20 });
886
+ while (page.messages?.hasMore) {
887
+ page = await client.search.search({
888
+ q: 'hello',
889
+ type: 'MESSAGE',
890
+ cursor: page.messages.nextCursor!, // opaque string — KHÔNG cast sang number
891
+ });
892
+ }
893
+ ```
894
+
895
+ > ⚠️ 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).
896
+
897
+ ---
898
+
899
+ ## 9. File — FileApi
777
900
 
778
901
  **Truy cập:** `client.files`
779
902
 
@@ -820,19 +943,9 @@ const result = await client.files.upload(uploadUrl, file, (loaded, total) => {
820
943
 
821
944
  > Khi có `onProgress`, SDK dùng XHR thay vì fetch (vì fetch không hỗ trợ upload progress events).
822
945
 
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)
946
+ ### `getDownloadUrl(storageKey, signed): string`
834
947
 
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:
948
+ 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
949
 
837
950
  ```ts
838
951
  const url = client.files.getDownloadUrl('01HXABCDEF/photo.jpg', {
@@ -842,9 +955,9 @@ const url = client.files.getDownloadUrl('01HXABCDEF/photo.jpg', {
842
955
  // → ".../api/files/01HXABCDEF/photo.jpg?expires=...&sig=abc123..."
843
956
  ```
844
957
 
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).
958
+ > 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
959
 
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.
960
+ > **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
961
 
849
962
  > **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
963
 
@@ -883,7 +996,7 @@ interface UploadResponse {
883
996
 
884
997
  ---
885
998
 
886
- ## 9. Bot — BotApi
999
+ ## 10. Bot — BotApi
887
1000
 
888
1001
  **Truy cập:** `client.bots`
889
1002
 
@@ -916,17 +1029,31 @@ const bot = await client.bots.create({
916
1029
  uniqueName: 'my-bot',
917
1030
  fullName: 'My Assistant Bot',
918
1031
  description: 'Trợ lý AI',
919
- kafkaTopic: 'my-bot-topic', // Không thể thay đổi sau khi tạo
1032
+ kafkaTopic: 'my-bot-topic', // optional — null/omit = không nhận event qua Kafka
1033
+ webhookUrl: 'https://bot.example.com/events', // optional — HTTP/2 NDJSON webhook
1034
+ deliveryPreference: 'Both', // optional — 'Both' | 'WebhookOnly' | 'KafkaOnly' (server default 'Both')
920
1035
  listenAllGroupMessages: false,
921
1036
  });
922
1037
  ```
923
1038
 
1039
+ > 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.
1040
+ >
1041
+ > `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.
1042
+
924
1043
  ### `update(id, request): Promise<BotDto>`
925
1044
 
926
1045
  ```ts
927
1046
  await client.bots.update('bot-id', {
928
1047
  fullName: 'Updated Bot Name',
929
- // kafkaTopic KHÔNG thể cập nhật
1048
+ kafkaTopic: 'new-topic', // set/đổi topic
1049
+ webhookUrl: 'https://bot.example.com/events/v2', // set/đổi webhook
1050
+ deliveryPreference: 'WebhookOnly', // optional — bỏ qua/null = không đổi
1051
+ });
1052
+
1053
+ // Để clear về null, dùng cờ riêng (mutually exclusive với việc set giá trị mới):
1054
+ await client.bots.update('bot-id', {
1055
+ clearKafkaTopic: true,
1056
+ clearWebhookUrl: true,
930
1057
  });
931
1058
  ```
932
1059
 
@@ -948,17 +1075,22 @@ await client.bots.delete('bot-id');
948
1075
  ### Kiểu dữ liệu Bot
949
1076
 
950
1077
  ```ts
1078
+ // Kênh nhận outgoing event. Chỉ có hiệu lực khi bot có cả kafkaTopic lẫn webhookUrl.
1079
+ type BotDeliveryPreference = 'Both' | 'WebhookOnly' | 'KafkaOnly';
1080
+
951
1081
  interface BotDto {
952
1082
  id: string;
953
- participantId: string; // Dùng để lấy name/avatar từ ParticipantApi
1083
+ participantId: string; // Dùng để lấy name/avatar từ ParticipantApi
954
1084
  description?: string | null;
955
1085
  metadata?: Record<string, unknown> | null;
956
- kafkaTopic: string; // KHÔNG thể thay đổi sau khi tạo
1086
+ kafkaTopic?: string | null; // null khi bot không cấu hình Kafka
1087
+ webhookUrl?: string | null; // HTTP/2 NDJSON webhook (chỉ MessageCreatedEvent); null khi không cấu hình
1088
+ deliveryPreference: BotDeliveryPreference; // Chỉ áp dụng khi cả kafkaTopic và webhookUrl đều được cấu hình
957
1089
  isActive: boolean;
958
1090
  listenAllGroupMessages: boolean;
959
- createdBy: string; // ID của người tạo bot
960
- createdAt: string; // ISO 8601
961
- updatedAt: string; // ISO 8601
1091
+ createdBy: string; // ID của người tạo bot
1092
+ createdAt: string; // ISO 8601
1093
+ updatedAt: string; // ISO 8601
962
1094
  }
963
1095
  ```
964
1096
 
@@ -968,29 +1100,35 @@ interface BotDto {
968
1100
 
969
1101
  ```ts
970
1102
  interface CreateBotRequest {
971
- uniqueName: string;
972
- fullName: string;
1103
+ uniqueName: string; // max 50, [a-zA-Z0-9_]
1104
+ fullName: string; // max 100
973
1105
  avatar?: MediaReference; // optional — avatar của bot
974
- description?: string;
1106
+ description?: string; // max 500
975
1107
  metadata?: Record<string, unknown>;
976
- kafkaTopic: string; // KHÔNG thể thay đổi sau khi tạo
1108
+ kafkaTopic?: string | null; // optional — max 200, [a-zA-Z0-9._-]
1109
+ webhookUrl?: string | null; // optional — absolute http/https URI, max 2000
1110
+ deliveryPreference?: BotDeliveryPreference; // optional — server default 'Both'
977
1111
  listenAllGroupMessages?: boolean; // Mặc định false
978
1112
  }
979
1113
 
980
1114
  interface UpdateBotRequest {
981
- uniqueName?: string; // Có thể cập nhật sau khi tạo
1115
+ uniqueName?: string;
982
1116
  fullName?: string;
983
1117
  avatar?: MediaReference;
984
1118
  description?: string;
985
1119
  metadata?: Record<string, unknown>;
986
1120
  listenAllGroupMessages?: boolean;
987
- // kafkaTopic KHÔNG thể cập nhật
1121
+ kafkaTopic?: string | null; // set/đổi topic (mutually exclusive với clearKafkaTopic)
1122
+ clearKafkaTopic?: boolean; // true → clear topic về null
1123
+ webhookUrl?: string | null; // set/đổi webhook (mutually exclusive với clearWebhookUrl)
1124
+ clearWebhookUrl?: boolean; // true → clear webhook về null
1125
+ deliveryPreference?: BotDeliveryPreference | null; // bỏ qua/null = không đổi
988
1126
  }
989
1127
  ```
990
1128
 
991
1129
  ---
992
1130
 
993
- ## 10. Proxy — ProxyApi
1131
+ ## 11. Proxy — ProxyApi
994
1132
 
995
1133
  **Truy cập:** `client.proxy`
996
1134
 
@@ -1047,7 +1185,7 @@ const result = await client.proxy.request<unknown>('myservice', 'api/resource/12
1047
1185
 
1048
1186
  ---
1049
1187
 
1050
- ## 11. Health — HealthApi
1188
+ ## 12. Health — HealthApi
1051
1189
 
1052
1190
  **Truy cập:** `client.health`
1053
1191
 
@@ -1067,7 +1205,7 @@ const ready = await client.health.ready(); // "Healthy"
1067
1205
 
1068
1206
  ---
1069
1207
 
1070
- ## 12. Real-time: ChatHubClient
1208
+ ## 13. Real-time: ChatHubClient
1071
1209
 
1072
1210
  **Truy cập:** `client.realtime.chat`
1073
1211
 
@@ -1205,9 +1343,10 @@ client.realtime.chat.off('messageReceived', handler);
1205
1343
  | `streamStarted` | `StreamStartedDto` | Bot stream bắt đầu |
1206
1344
  | `streamStatusUpdated` | `StreamStatusUpdatedDto` | Trạng thái bot stream thay đổi |
1207
1345
  | `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 |
1346
+ | `streamBlockReceived` | `StreamBlockReceivedDto` | Nhận inline content block (widget) đan xen giữa các chunk text, theo `sequence` |
1209
1347
  | `streamCompleted` | `StreamCompletedDto` | Bot stream hoàn thành (kèm message đầy đủ) |
1210
1348
  | `streamAborted` | `StreamAbortedDto` | Bot stream bị hủy |
1349
+ | `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
1350
  | `error` | `HubErrorDto` | Lỗi từ server |
1212
1351
  | `reconnecting` | `Error \| undefined` | Đang reconnect |
1213
1352
  | `reconnected` | `string \| undefined` | Đã reconnect (connectionId mới) |
@@ -1222,7 +1361,7 @@ interface MessageUpdatedDto {
1222
1361
  conversationId: string;
1223
1362
  blocks: Block[];
1224
1363
  plainTextContent: string | null;
1225
- mentions?: Mention[];
1364
+ mentions?: Mention[] | null;
1226
1365
  updatedAt: string; // ISO 8601
1227
1366
  replyToMessageId?: string | null; // null nếu reply reference đã bị xóa
1228
1367
  }
@@ -1298,25 +1437,33 @@ client.realtime.chat.on('messageThumbnailsReady', (dto) => {
1298
1437
  Khi bot streaming, **không có** `messageReceived` — thay vào đó nhận chuỗi sự kiện sau:
1299
1438
 
1300
1439
  ```
1301
- streamStarted → streamStatusUpdated* → streamChunkReceived* → streamCompleted
1302
- → streamAborted
1440
+ streamStarted → streamStatusUpdated* → [streamChunkReceived | streamBlockReceived]* → streamCompleted
1441
+ → streamAborted
1303
1442
  ```
1304
1443
 
1444
+ `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.
1445
+
1305
1446
  ```ts
1306
- const chunks: string[] = [];
1447
+ // Mỗi phần tử là một text chunk hoặc một block, gắn với sequence để sắp xếp.
1448
+ const parts: { sequence: number; text?: string; block?: Block }[] = [];
1307
1449
 
1308
1450
  client.realtime.chat.on('streamStarted', (dto) => {
1309
1451
  console.log('Bot bắt đầu trả lời, streamId:', dto.streamId);
1310
1452
  });
1311
1453
 
1312
1454
  client.realtime.chat.on('streamChunkReceived', (dto) => {
1313
- chunks.push(dto.text);
1314
- // Hiển thị progressive: chunks.join('')
1455
+ parts.push({ sequence: dto.sequence, text: dto.text });
1456
+ // Hiển thị progressive: sắp xếp theo sequence rồi nối lại
1457
+ });
1458
+
1459
+ client.realtime.chat.on('streamBlockReceived', (dto) => {
1460
+ parts.push({ sequence: dto.sequence, block: dto.block });
1461
+ // Render block (card, choice, divider...) đúng vị trí theo sequence
1315
1462
  });
1316
1463
 
1317
1464
  client.realtime.chat.on('streamCompleted', (dto) => {
1318
1465
  console.log('Tin nhắn hoàn chỉnh:', dto.message);
1319
- chunks.length = 0; // reset
1466
+ parts.length = 0; // reset
1320
1467
  });
1321
1468
 
1322
1469
  client.realtime.chat.on('streamAborted', (dto) => {
@@ -1324,9 +1471,44 @@ client.realtime.chat.on('streamAborted', (dto) => {
1324
1471
  });
1325
1472
  ```
1326
1473
 
1474
+ **Giới hạn streaming (server enforce):**
1475
+
1476
+ | Giới hạn | Giá trị |
1477
+ |----------|---------|
1478
+ | Max chunk size | 4096 bytes |
1479
+ | Max chunks / stream | 2000 |
1480
+ | Max blocks / stream | 50 |
1481
+ | Max block size | 65536 bytes (64 KB) |
1482
+ | Max accumulated size | 1 MB |
1483
+ | Stream timeout | 120 giây |
1484
+ | Max stream duration | 300 giây |
1485
+ | Max concurrent streams / bot | 10 |
1486
+
1487
+ ### Ephemeral Broadcast (tin real-time không lưu trữ)
1488
+
1489
+ 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ị.
1490
+
1491
+ `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).
1492
+
1493
+ ```ts
1494
+ import type { EphemeralBroadcastDto } from '@manonero/chat-client-sdk';
1495
+
1496
+ interface PromoBanner { title: string; startsAt: string }
1497
+
1498
+ client.realtime.chat.on('ephemeralBroadcast', (b) => {
1499
+ // b.senderType: 'Bot' | 'System' ; b.conversationId luôn có giá trị trên ChatHub
1500
+ if (b.type === 'promo_banner') {
1501
+ const promo = b.data as PromoBanner; // narrow theo `type`
1502
+ showBanner(promo.title, promo.startsAt);
1503
+ }
1504
+ });
1505
+ ```
1506
+
1507
+ > 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).
1508
+
1327
1509
  ---
1328
1510
 
1329
- ## 13. Real-time: NotificationHubClient
1511
+ ## 14. Real-time: NotificationHubClient
1330
1512
 
1331
1513
  **Truy cập:** `client.realtime.notifications`
1332
1514
 
@@ -1370,6 +1552,7 @@ await client.realtime.notifications.disconnect();
1370
1552
  | `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
1553
  | `conversationPinned` | `ConversationPinnedDto` | Conversation được pin |
1372
1554
  | `conversationUnpinned` | `ConversationUnpinnedDto` | Conversation được unpin |
1555
+ | `ephemeralBroadcast` | `EphemeralBroadcastDto` | Broadcast tạm thời (không lưu) — audience `members`/`all`/`users`; broadcast toàn cục có `conversationId: null` |
1373
1556
  | `reconnecting` | `Error \| undefined` | Đang reconnect |
1374
1557
  | `reconnected` | `string \| undefined` | Đã reconnect |
1375
1558
  | `disconnected` | `Error \| undefined` | Mất kết nối |
@@ -1440,9 +1623,24 @@ interface ConversationUnpinnedDto {
1440
1623
  }
1441
1624
  ```
1442
1625
 
1626
+ ### Ephemeral Broadcast trên NotificationHub
1627
+
1628
+ 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`.
1629
+
1630
+ ```ts
1631
+ client.realtime.notifications.on('ephemeralBroadcast', (b) => {
1632
+ if (b.conversationId === null && b.type === 'system_alert') {
1633
+ // Broadcast toàn cục — vd. cảnh báo bảo trì hệ thống
1634
+ showGlobalAlert(b.data);
1635
+ }
1636
+ });
1637
+ ```
1638
+
1639
+ > Định nghĩa đầy đủ `EphemeralBroadcastDto` ở [mục 17](#17-toàn-bộ-kiểu-dữ-liệu-types).
1640
+
1443
1641
  ---
1444
1642
 
1445
- ## 14. ReconnectionManager
1643
+ ## 15. ReconnectionManager
1446
1644
 
1447
1645
  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
1646
 
@@ -1470,13 +1668,19 @@ manager.stop();
1470
1668
 
1471
1669
  **Chiến lược backoff:** 3 lần thử với delay 2s → 5s → 10s.
1472
1670
 
1671
+ > **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.
1672
+
1473
1673
  **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
1674
 
1675
+ > **⚠️ 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:
1676
+ > - 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.
1677
+ > - Heuristic chỉ fire đáng tin khi bạn tự override `skipNegotiation: false` (lúc đó bước negotiate trả HTTP 401 đọc được).
1678
+
1475
1679
  **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
1680
 
1477
1681
  ---
1478
1682
 
1479
- ## 15. Hệ thống Block (nội dung tin nhắn)
1683
+ ## 16. Hệ thống Block (nội dung tin nhắn)
1480
1684
 
1481
1685
  `Block` là **discriminated union** — mỗi block phân biệt bằng field `$type`.
1482
1686
 
@@ -1518,6 +1722,7 @@ interface ImageBlock {
1518
1722
  height: number | null;
1519
1723
  caption: string | null;
1520
1724
  fileSizeBytes: number | null;
1725
+ mimeType: string | null; // vd. 'image/jpeg', 'image/png', 'image/svg+xml'
1521
1726
  }
1522
1727
 
1523
1728
  interface VideoBlock {
@@ -1635,6 +1840,32 @@ for (const block of message.blocks) {
1635
1840
 
1636
1841
  Danh sách đầy đủ: `isTextBlock`, `isImageBlock`, `isVideoBlock`, `isAudioBlock`, `isFileBlock`, `isLinkPreviewBlock`, `isEmbedBlock`, `isLocationBlock`, `isContactBlock`, `isChoiceBlock`, `isCardBlock`, `isCarouselBlock`, `isDividerBlock`, `isCustomBlock`.
1637
1842
 
1843
+ ### Serialization helpers — `withTypeFirst` / `normalizeBlocksForSend`
1844
+
1845
+ 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.
1846
+
1847
+ Hai helper này được export public chỉ dành cho power users muốn:
1848
+ - Tự xây dựng API module riêng dùng `HttpClient` trực tiếp.
1849
+ - 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).
1850
+
1851
+ ```ts
1852
+ import { withTypeFirst, normalizeBlocksForSend } from '@manonero/chat-client-sdk';
1853
+ import type { Block, TextBlock } from '@manonero/chat-client-sdk';
1854
+
1855
+ // withTypeFirst<T>(block: T): T — chuẩn hoá 1 block đơn lẻ
1856
+ const normalized: TextBlock = withTypeFirst({
1857
+ content: 'Xin chào',
1858
+ format: 'Plain',
1859
+ plainText: 'Xin chào',
1860
+ $type: 'text', // có thể nằm ở cuối khi tạo block — sẽ được đưa lên đầu
1861
+ });
1862
+
1863
+ // normalizeBlocksForSend<T extends Block>(blocks: T[]): T[] — áp dụng cho mảng
1864
+ const blocks: Block[] = normalizeBlocksForSend(rawBlocks);
1865
+ ```
1866
+
1867
+ > `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.
1868
+
1638
1869
  ### Ví dụ tạo block
1639
1870
 
1640
1871
  ```ts
@@ -1656,6 +1887,7 @@ const imageBlock: ImageBlock = {
1656
1887
  height: 1080,
1657
1888
  caption: 'Ảnh mô tả',
1658
1889
  fileSizeBytes: 204800,
1890
+ mimeType: 'image/jpeg',
1659
1891
  };
1660
1892
 
1661
1893
  // Gửi tin nhắn kèm file
@@ -1696,7 +1928,7 @@ interface CardField {
1696
1928
  }
1697
1929
  ```
1698
1930
 
1699
- ### ChoiceMode / ChoiceOption / ChoiceBlock
1931
+ ### ChoiceMode / ChoiceOption
1700
1932
 
1701
1933
  ```ts
1702
1934
  type ChoiceMode = 'Single' | 'Multiple';
@@ -1706,16 +1938,10 @@ interface ChoiceOption {
1706
1938
  value: string;
1707
1939
  selected: boolean;
1708
1940
  }
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
1941
  ```
1718
1942
 
1943
+ > `ChoiceBlock` đã được định nghĩa đầy đủ ở mục [Interfaces tham chiếu đầy đủ](#interfaces-tham-chiếu-đầy-đủ).
1944
+
1719
1945
  ### QuickReply
1720
1946
 
1721
1947
  Nút bấm nhanh hiển thị bên dưới tin nhắn bot:
@@ -1743,7 +1969,7 @@ interface Mention {
1743
1969
 
1744
1970
  ---
1745
1971
 
1746
- ## 16. Toàn bộ kiểu dữ liệu (Types)
1972
+ ## 17. Toàn bộ kiểu dữ liệu (Types)
1747
1973
 
1748
1974
  ### CursorPaginatedResult (cursor-based)
1749
1975
 
@@ -1766,12 +1992,39 @@ interface PagedResult<T> {
1766
1992
  }
1767
1993
  ```
1768
1994
 
1995
+ ### SearchResult (GET /api/search — §8)
1996
+
1997
+ ```ts
1998
+ type SearchType = 'MESSAGE' | 'CONVERSATION' | 'BOTH';
1999
+
2000
+ interface SearchResult {
2001
+ // null khi type=MESSAGE. Mỗi item có `lastMessage` đã hydrate —
2002
+ // kiểm tra lastMessage.isDeleted trước khi hiển thị.
2003
+ conversations: ConversationListItemDto[] | null;
2004
+ // null khi type=CONVERSATION.
2005
+ messages: SearchMessagesResult | null;
2006
+ }
2007
+
2008
+ interface SearchMessagesResult {
2009
+ groups: SearchMessageGroup[];
2010
+ nextCursor: string | null; // opaque — chỉ phân trang cho messages
2011
+ hasMore: boolean;
2012
+ }
2013
+
2014
+ interface SearchMessageGroup {
2015
+ // null nếu batch fetch conversation lỗi (degrade graceful).
2016
+ // `participants` luôn là [] (fetch với includeParticipants=false).
2017
+ conversation: ConversationDto | null;
2018
+ messages: MessageDto[];
2019
+ }
2020
+ ```
2021
+
1769
2022
  ### MediaReference
1770
2023
 
1771
2024
  ```ts
1772
2025
  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)
2026
+ 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.
2027
+ 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
2028
  }
1776
2029
  ```
1777
2030
 
@@ -1782,22 +2035,20 @@ Trường `url` có hai dạng tuỳ cấu hình server:
1782
2035
  | **Relative signed URL** | `/api/files/abc/photo.jpg?expires=...&sig=...` | Phải prepend `baseUrl` trước khi dùng |
1783
2036
  | **Absolute external URL** | `https://cdn.example.com/photo.jpg` | Dùng trực tiếp |
1784
2037
 
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).
2038
+ > **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
2039
 
1787
2040
  ```ts
1788
- // Helper chuẩn để resolve MediaReference → URL tuyệt đối
2041
+ // Helper chuẩn để resolve MediaReference → URL tuyệt đối.
2042
+ // Server gần như luôn trả sẵn `url` cho mọi MediaReference của tin nhắn/participant,
2043
+ // nên trường hợp chỉ có storageKey là bất thường và nên log để debug.
1789
2044
  function resolveMediaRef(
1790
2045
  ref: MediaReference | null | undefined,
1791
2046
  baseUrl: string,
1792
- getDownloadUrl: (key: string) => string,
1793
2047
  ): 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;
2048
+ if (!ref?.url) return null;
2049
+ // url có thể là relative signed URL ("/api/files/...?sig=...")
2050
+ // hoặc absolute external URL ("https://...")
2051
+ return ref.url.startsWith('/') ? baseUrl.replace(/\/$/, '') + ref.url : ref.url;
1801
2052
  }
1802
2053
  ```
1803
2054
 
@@ -1902,23 +2153,15 @@ interface StreamChunkReceivedDto {
1902
2153
  messageId: string;
1903
2154
  conversationId: string;
1904
2155
  text: string; // Chunk text để append vào placeholder
1905
- chunkIndex: number; // Zero-based index
2156
+ sequence: number; // Số thứ tự server gán (zero-based), chung dãy với StreamBlockReceivedDto
1906
2157
  }
1907
2158
 
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 {
2159
+ interface StreamBlockReceivedDto {
1918
2160
  streamId: string;
1919
2161
  messageId: string;
1920
2162
  conversationId: string;
1921
- parts: StreamChunkPart[];
2163
+ block: Block; // Inline content block (card, choice, divider...)
2164
+ sequence: number; // Số thứ tự server gán, chung dãy với StreamChunkReceivedDto
1922
2165
  }
1923
2166
 
1924
2167
  interface StreamCompletedDto {
@@ -1939,6 +2182,29 @@ interface StreamAbortedDto {
1939
2182
  }
1940
2183
  ```
1941
2184
 
2185
+ ### EphemeralBroadcastDto (dùng chung — ChatHub & NotificationHub)
2186
+
2187
+ 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`:
2188
+
2189
+ - **ChatHub** — `audience="viewers"` của conversation đang mở (`conversationId` có giá trị).
2190
+ - **NotificationHub** — `audience="members" | "all" | "users"` (broadcast toàn cục → `conversationId: null`).
2191
+
2192
+ ```ts
2193
+ // Sender của broadcast — luôn là bot hoặc SYSTEM user (không bao giờ là User thường)
2194
+ type BroadcastSenderType = 'Bot' | 'System';
2195
+
2196
+ interface EphemeralBroadcastDto<TData = unknown> {
2197
+ type: string; // Khóa do người gửi đặt — client tự diễn giải `data` theo giá trị này
2198
+ senderId: string; // participantId của bot, hoặc literal "SYSTEM"
2199
+ senderType: BroadcastSenderType; // 'Bot' | 'System'
2200
+ conversationId: string | null; // null khi broadcast toàn cục
2201
+ data: TData | null; // Envelope JSON tự do; null khi người gửi bỏ trống
2202
+ sentAt: string; // UTC ISO 8601
2203
+ }
2204
+ ```
2205
+
2206
+ > `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).
2207
+
1942
2208
  ### Ack types (return value của Hub methods)
1943
2209
 
1944
2210
  Mỗi Hub method client→server trả về một ack object với `success: boolean` và optional error fields:
@@ -1987,7 +2253,7 @@ interface ReactionAck {
1987
2253
 
1988
2254
  ---
1989
2255
 
1990
- ## 17. Xử lý lỗi — ChatApiError
2256
+ ## 18. Xử lý lỗi — ChatApiError
1991
2257
 
1992
2258
  Tất cả lỗi HTTP đều throw `ChatApiError` (kế thừa `Error`).
1993
2259
 
@@ -2034,7 +2300,7 @@ try {
2034
2300
  |------------|------|-------|
2035
2301
  | `status` | `number` | HTTP status code |
2036
2302
  | `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`) |
2303
+ | `detail` | `string \| undefined` | Mô tả chi tiết, human-readable của lỗi (RFC 7807 `detail`) |
2038
2304
  | `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
2305
  | `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
2306
  | `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 +2376,8 @@ const chatHub = new ChatHubClient({
2110
2376
  hubUrl: 'https://chat-api.example.com/hubs/chat',
2111
2377
  tokenProvider,
2112
2378
  logLevel: LogLevel.Warning, // optional
2379
+ // optional — ghi đè mặc định skipNegotiation: true + transport: WebSockets
2380
+ connectionOptions: { skipNegotiation: false },
2113
2381
  });
2114
2382
 
2115
2383
  // Khởi tạo NotificationHub độc lập
@@ -2139,7 +2407,7 @@ manager.start();
2139
2407
 
2140
2408
  ---
2141
2409
 
2142
- ## 18. TypedEventEmitter
2410
+ ## 19. TypedEventEmitter
2143
2411
 
2144
2412
  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
2413
 
@@ -2169,7 +2437,7 @@ unsubs.forEach(fn => fn());
2169
2437
 
2170
2438
  ---
2171
2439
 
2172
- ## 19. Lưu ý quan trọng
2440
+ ## 20. Lưu ý quan trọng
2173
2441
 
2174
2442
  ### Cursor pagination — KHÔNG cast sang number
2175
2443
 
@@ -2187,8 +2455,10 @@ const cursor = Number(result.nextCursor); // BUG!
2187
2455
  - `setToken()` hoạt động ngay cho **HTTP requests**.
2188
2456
  - SignalR đọc token khi **connect()**, không phải mid-session.
2189
2457
  - Để áp token mới ngay cho hub đang Connected: `setToken(newToken)` → `await refreshConnections()`.
2190
- - Sau `refreshConnections()` cần tự `joinConversation()` lại (tracking bị clear khi disconnect). Hoặc dùng `ReconnectionManager` để tự động khôi phục.
2458
+ - `refreshConnections()` tự `joinConversation()` lại cho ChatHub sau reconnect (best-effort) — không cần tự làm lại thủ công. Hoặc dùng `ReconnectionManager` để tự động khôi phục toàn bộ vòng đời kết nối.
2191
2459
  - Khi `tokenProvider` được cấu hình, provider luôn được hỏi trước. `setToken()` chỉ là fallback cho trường hợp provider trả `null`/empty.
2460
+ - `setToken(token)` với `token` khác `null` **không** cập nhật `currentUser` — chỉ dùng cho rotate token của cùng user. Đổi user: gọi lại `auth.login*()` hoặc `clearToken()`/`setToken(null)` trước.
2461
+ - `refreshConnections()` không throw nếu không còn token khả dụng — hub `Connected` sẽ bị disconnect thay vì reconnect.
2192
2462
 
2193
2463
  ### REST vs SignalR — field name khác nhau
2194
2464
 
@@ -2212,34 +2482,25 @@ const block: ImageBlock = {
2212
2482
  };
2213
2483
  ```
2214
2484
 
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:
2485
+ **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
2486
 
2217
2487
  ```ts
2218
2488
  // resolve MediaReference → URL tuyệt đối để dùng trong <img src>, <a href>, v.v.
2219
2489
  function resolveMediaRef(
2220
2490
  ref: MediaReference | null | undefined,
2221
2491
  baseUrl: string,
2222
- getDownloadUrl: (key: string) => string,
2223
2492
  ): 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;
2493
+ if (!ref?.url) return null;
2494
+ // url có thể là relative signed URL ("/api/files/...?sig=...")
2495
+ // hoặc absolute external URL ("https://...")
2496
+ return ref.url.startsWith('/') ? baseUrl.replace(/\/$/, '') + ref.url : ref.url;
2232
2497
  }
2233
2498
 
2234
2499
  // 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
- );
2500
+ const displayUrl = resolveMediaRef(block.source, 'https://chat-api.example.com');
2240
2501
  ```
2241
2502
 
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.
2503
+ > **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
2504
 
2244
2505
  > **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
2506
 
@@ -2255,7 +2516,7 @@ console.log(participant.fullName); // Tên hiển thị của bot
2255
2516
 
2256
2517
  ---
2257
2518
 
2258
- ## 20. Ví dụ đầy đủ
2519
+ ## 21. Ví dụ đầy đủ
2259
2520
 
2260
2521
  ### Ví dụ 1: Chat app cơ bản
2261
2522
 
@@ -2367,7 +2628,9 @@ async function loadMoreMessages(conversationId: string) {
2367
2628
  ### Ví dụ 3: Xử lý bot streaming
2368
2629
 
2369
2630
  ```ts
2370
- const streamBuffers = new Map<string, string[]>();
2631
+ // Mỗi part là text hoặc block, gắn sequence (server gán) để sắp xếp đúng vị trí.
2632
+ type StreamPart = { sequence: number; text?: string; block?: Block };
2633
+ const streamBuffers = new Map<string, StreamPart[]>();
2371
2634
 
2372
2635
  client.realtime.chat.on('streamStarted', (dto) => {
2373
2636
  streamBuffers.set(dto.streamId, []);
@@ -2378,24 +2641,21 @@ client.realtime.chat.on('streamStatusUpdated', (dto) => {
2378
2641
  updateStreamStatus(dto.messageId, dto.status); // e.g. "thinking", "searching"
2379
2642
  });
2380
2643
 
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(''));
2644
+ // Append một part (text hoặc block) vào buffer, sắp theo sequence rồi flush ra UI
2645
+ function appendPart(streamId: string, messageId: string, part: StreamPart) {
2646
+ const parts = streamBuffers.get(streamId) ?? [];
2647
+ parts.push(part);
2648
+ parts.sort((a, b) => a.sequence - b.sequence);
2649
+ streamBuffers.set(streamId, parts);
2650
+ renderStreamingParts(messageId, parts); // render text + block đúng thứ tự
2387
2651
  }
2388
2652
 
2389
2653
  client.realtime.chat.on('streamChunkReceived', (dto) => {
2390
- appendChunk(dto.streamId, dto.messageId, dto.text);
2654
+ appendPart(dto.streamId, dto.messageId, { sequence: dto.sequence, text: dto.text });
2391
2655
  });
2392
2656
 
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
- }
2657
+ client.realtime.chat.on('streamBlockReceived', (dto) => {
2658
+ appendPart(dto.streamId, dto.messageId, { sequence: dto.sequence, block: dto.block });
2399
2659
  });
2400
2660
 
2401
2661
  client.realtime.chat.on('streamCompleted', (dto) => {