@manonero/chat-client-sdk 1.0.0-beta.1 → 1.0.0-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 (72) hide show
  1. package/README.md +386 -81
  2. package/dist/ChatClient.d.ts +67 -8
  3. package/dist/ChatClient.d.ts.map +1 -1
  4. package/dist/ChatClient.js +82 -9
  5. package/dist/ChatClient.js.map +1 -1
  6. package/dist/errors/ChatApiError.d.ts +21 -4
  7. package/dist/errors/ChatApiError.d.ts.map +1 -1
  8. package/dist/errors/ChatApiError.js +12 -6
  9. package/dist/errors/ChatApiError.js.map +1 -1
  10. package/dist/http/BotApi.d.ts +6 -13
  11. package/dist/http/BotApi.d.ts.map +1 -1
  12. package/dist/http/BotApi.js +4 -11
  13. package/dist/http/BotApi.js.map +1 -1
  14. package/dist/http/ConversationApi.d.ts +38 -5
  15. package/dist/http/ConversationApi.d.ts.map +1 -1
  16. package/dist/http/ConversationApi.js +55 -7
  17. package/dist/http/ConversationApi.js.map +1 -1
  18. package/dist/http/FileApi.d.ts +16 -4
  19. package/dist/http/FileApi.d.ts.map +1 -1
  20. package/dist/http/FileApi.js +29 -6
  21. package/dist/http/FileApi.js.map +1 -1
  22. package/dist/http/HttpClient.d.ts +20 -2
  23. package/dist/http/HttpClient.d.ts.map +1 -1
  24. package/dist/http/HttpClient.js +76 -14
  25. package/dist/http/HttpClient.js.map +1 -1
  26. package/dist/http/MessageApi.d.ts +2 -1
  27. package/dist/http/MessageApi.d.ts.map +1 -1
  28. package/dist/http/MessageApi.js +15 -3
  29. package/dist/http/MessageApi.js.map +1 -1
  30. package/dist/http/ProxyApi.d.ts +18 -2
  31. package/dist/http/ProxyApi.d.ts.map +1 -1
  32. package/dist/http/ProxyApi.js +33 -6
  33. package/dist/http/ProxyApi.js.map +1 -1
  34. package/dist/index.d.ts +6 -6
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +2 -2
  37. package/dist/index.js.map +1 -1
  38. package/dist/realtime/ChatHubClient.d.ts +58 -1
  39. package/dist/realtime/ChatHubClient.d.ts.map +1 -1
  40. package/dist/realtime/ChatHubClient.js +202 -15
  41. package/dist/realtime/ChatHubClient.js.map +1 -1
  42. package/dist/realtime/NotificationHubClient.d.ts +43 -1
  43. package/dist/realtime/NotificationHubClient.d.ts.map +1 -1
  44. package/dist/realtime/NotificationHubClient.js +134 -29
  45. package/dist/realtime/NotificationHubClient.js.map +1 -1
  46. package/dist/realtime/ReconnectionManager.d.ts +2 -1
  47. package/dist/realtime/ReconnectionManager.d.ts.map +1 -1
  48. package/dist/realtime/ReconnectionManager.js +13 -3
  49. package/dist/realtime/ReconnectionManager.js.map +1 -1
  50. package/dist/types/auth.d.ts +2 -2
  51. package/dist/types/auth.d.ts.map +1 -1
  52. package/dist/types/block.d.ts +16 -1
  53. package/dist/types/block.d.ts.map +1 -1
  54. package/dist/types/block.js +30 -0
  55. package/dist/types/block.js.map +1 -1
  56. package/dist/types/bot.d.ts +2 -8
  57. package/dist/types/bot.d.ts.map +1 -1
  58. package/dist/types/chat-events.d.ts +29 -3
  59. package/dist/types/chat-events.d.ts.map +1 -1
  60. package/dist/types/common.d.ts +19 -0
  61. package/dist/types/common.d.ts.map +1 -1
  62. package/dist/types/conversation.d.ts +58 -8
  63. package/dist/types/conversation.d.ts.map +1 -1
  64. package/dist/types/message.d.ts +9 -5
  65. package/dist/types/message.d.ts.map +1 -1
  66. package/dist/types/notification-events.d.ts +34 -7
  67. package/dist/types/notification-events.d.ts.map +1 -1
  68. package/dist/types/participant.d.ts +2 -2
  69. package/dist/types/participant.d.ts.map +1 -1
  70. package/dist/types/signalr.d.ts +4 -1
  71. package/dist/types/signalr.d.ts.map +1 -1
  72. package/package.json +4 -2
package/README.md CHANGED
@@ -126,7 +126,7 @@ import type { ChatClientSignalrOptions, ChatClientRealtime } from '@manonero/cha
126
126
  import type { UploadProgressCallback } from '@manonero/chat-client-sdk';
127
127
 
128
128
  // RFC 7807 Problem Details — dùng khi cần parse/tạo lỗi thủ công
129
- import type { ProblemDetails } from '@manonero/chat-client-sdk';
129
+ import type { ProblemDetails, ProblemError } from '@manonero/chat-client-sdk';
130
130
 
131
131
  // Mark as read request type (REST)
132
132
  import type { MarkAsReadRequest } from '@manonero/chat-client-sdk';
@@ -171,7 +171,12 @@ const client = new ChatClient({
171
171
  });
172
172
  ```
173
173
 
174
- > **Ưu tiên token:** `token` (hoặc `setToken()`) luôn ưu tiên hơn `tokenProvider`. Khi `_token` khác `null`, `tokenProvider` bị bỏ qua.
174
+ > **Ưu tiên token (resolve mỗi request / connect):**
175
+ > 1. Nếu `tokenProvider` được cấu hình **VÀ** trả về string non-empty → dùng giá trị đó.
176
+ > 2. Ngược lại → dùng token nội bộ (`token` constructor / `setToken()` / auto-set sau login).
177
+ > 3. Ngược lại → `null` (không gửi `Authorization`).
178
+ >
179
+ > Provider luôn được hỏi trước nên các hệ thống refresh-token rotation tiếp tục hoạt động kể cả sau khi `setToken()` đã được gọi. Dùng `clearToken()` nếu muốn xoá token nội bộ và trả quyền hoàn toàn về provider.
175
180
 
176
181
  ### Cấu hình SignalR log level
177
182
 
@@ -205,6 +210,7 @@ new ChatClient(options: ChatClientOptions)
205
210
  | `baseUrl` | `string` | ✅ | URL gốc của server, ví dụ `"https://chat-api.example.com"` |
206
211
  | `token` | `string` | ❌ | JWT token ban đầu (nếu đã đăng nhập) |
207
212
  | `tokenProvider` | `() => string \| null` | ❌ | Hàm lấy token từ bên ngoài |
213
+ | `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). |
208
214
  | `signalrOptions.logLevel` | `LogLevel` | ❌ | Mức log cho SignalR (mặc định: `Warning`) |
209
215
 
210
216
  ### Thuộc tính
@@ -225,20 +231,52 @@ new ChatClient(options: ChatClientOptions)
225
231
 
226
232
  ### Phương thức
227
233
 
228
- #### `setToken(token: string): void`
234
+ #### `setToken(token: string | null): void`
235
+
236
+ Cập nhật JWT token nội bộ. Có hiệu lực **ngay** với mọi HTTP request tiếp theo. Truyền `null` để xoá token nội bộ và `currentUser` (tương đương `clearToken()`).
229
237
 
230
- Cập nhật JWT token. hiệu lực ngay với mọi HTTP request tiếp theo.
238
+ > ⚠️ Các kết nối SignalR **đang hoạt động** vẫn dùng token cũ cho đến lúc reconnect. Gọi `refreshConnections()` để áp token mới ngay cho hub đang Connected.
231
239
 
232
- > ⚠️ Các kết nối SignalR đang hoạt động **không** được cập nhật token mid-session. Cần `disconnect()` rồi `connect()` lại để dùng token mới.
240
+ > ℹ️ Nếu `tokenProvider` đã được cấu hình 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.
233
241
 
234
242
  ```ts
235
243
  client.setToken('new-jwt-token-here');
244
+ await client.refreshConnections(); // tuỳ chọn — chỉ khi muốn áp ngay cho SignalR
245
+ ```
246
+
247
+ #### `clearToken(): void`
248
+
249
+ Xoá token nội bộ và `currentUser`. Không động tới `tokenProvider` (provider tiếp tục hoạt động bình thường) và không ngắt SignalR. Dùng `logout()` nếu muốn ngắt cả hub.
250
+
251
+ ```ts
252
+ client.clearToken();
253
+ ```
254
+
255
+ #### `refreshConnections(): Promise<void>`
256
+
257
+ `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.
258
+
259
+ > ⚠️ `disconnect()` xoá local tracking (`joinedConversations`, `subscribedPresenceIds`). Sau khi gọi `refreshConnections()`, ứng dụng phải tự `joinConversation` / `subscribeToPresence` lại — hoặc dùng `ReconnectionManager` để khôi phục trong suốt cho mọi loại disconnect.
260
+
261
+ ```ts
262
+ client.setToken(newToken);
263
+ await client.refreshConnections();
264
+ ```
265
+
266
+ #### `logout(): Promise<void>`
267
+
268
+ Xoá token + `currentUser` rồi `disconnect()` cả hai hub. Chỉ clear local state — không gọi server (server không có endpoint logout). Nếu `tokenProvider` được cấu hình và vẫn trả token, request kế tiếp vẫn có thể auth lại.
269
+
270
+ ```ts
271
+ await client.logout();
236
272
  ```
237
273
 
238
274
  #### `disconnect(): Promise<void>`
239
275
 
240
276
  Ngắt kết nối tất cả các SignalR hub (ChatHub + NotificationHub) đồng thời.
241
277
 
278
+ > Cả hai hub set cờ `intentionallyClosed` trước khi tear down nên `ReconnectionManager` (nếu được attach) sẽ **bỏ qua** lần `disconnected` này thay vì auto-reconnect ngược ý người dùng.
279
+
242
280
  ```ts
243
281
  await client.disconnect();
244
282
  ```
@@ -297,8 +335,8 @@ interface AuthUserInfo {
297
335
  id: string;
298
336
  uniqueName: string;
299
337
  fullName: string;
300
- avatar?: string; // URL thuần (KHÔNG phải MediaReference)
301
- role?: string | null; // 'dev' với dev users; null với Google/DsAccount users
338
+ avatar?: string | null; // URL thuần (KHÔNG phải MediaReference); server có thể trả null
339
+ role?: string | null; // 'dev' với dev users; null với Google/DsAccount users
302
340
  }
303
341
  ```
304
342
 
@@ -351,8 +389,8 @@ interface ParticipantDto {
351
389
  id: string;
352
390
  uniqueName: string;
353
391
  fullName: string;
354
- avatar?: MediaReference;
355
- gender?: string;
392
+ avatar?: MediaReference | null; // hỗ trợ cả internal storageKey lẫn external URL; server có thể trả null
393
+ gender?: string | null;
356
394
  isBot: boolean;
357
395
  isOnline: boolean;
358
396
  lastSeenAt?: string | null; // ISO 8601, null khi đang online
@@ -440,17 +478,29 @@ const updated = await client.conversations.update('conv-id', {
440
478
  });
441
479
  ```
442
480
 
443
- ### `leave(id: string): Promise<void>`
481
+ ### `delete(id: string): Promise<void>`
482
+
483
+ Hành vi phụ thuộc vào loại cuộc hội thoại:
484
+ - **Group** → rời nhóm (tương đương `leaveGroup()`). Các participant khác vẫn còn đó.
485
+ - **OneToOne / Self** → ẩn cuộc hội thoại khỏi danh sách (soft-delete). Conversation vẫn tồn tại và sẽ tự khôi phục khi có tin nhắn mới.
486
+
487
+ ```ts
488
+ await client.conversations.delete('conv-id');
489
+ ```
490
+
491
+ ### `leaveGroup(id: string): Promise<void>`
444
492
 
445
- Rời khỏi cuộc hội thoại. Các participant khác vẫn còn đó.
493
+ Rời cuộc hội thoại **Group** một cách tường minh. Trả về `403 Forbidden` nếu conversation không phải Group.
494
+
495
+ > Dùng `leaveGroup()` khi cần ngữ nghĩa Group-only với phản hồi lỗi rõ ràng cho non-Group. Dùng `delete()` khi muốn xử lý cả Group lẫn OneToOne/Self trong cùng một flow.
446
496
 
447
497
  ```ts
448
- await client.conversations.leave('conv-id');
498
+ await client.conversations.leaveGroup('conv-id');
449
499
  ```
450
500
 
451
501
  ### `findOneToOne(otherParticipantId): Promise<ConversationDto | null>`
452
502
 
453
- Tìm cuộc hội thoại 1-1 với user khác. Trả về `null` (HTTP 204) nếu chưa có.
503
+ Tìm cuộc hội thoại 1-1 với user khác. Trả về `null` nếu chưa có (server trả 404, SDK chuyển thành `null` để gọi không cần try/catch).
454
504
 
455
505
  ```ts
456
506
  const existing = await client.conversations.findOneToOne('other-user-id');
@@ -490,16 +540,49 @@ await client.conversations.pin('conv-id');
490
540
  await client.conversations.unpin('conv-id');
491
541
  ```
492
542
 
493
- ### `markAsRead(id, messageId) / markAsUnread(id): Promise<void>`
543
+ ### `markAsRead(id, request) / markAsUnread(id): Promise<void>`
494
544
 
495
545
  ```ts
496
546
  // Đánh dấu đã đọc đến messageId
497
- await client.conversations.markAsRead('conv-id', 'last-message-id');
547
+ await client.conversations.markAsRead('conv-id', { messageId: 'last-message-id' });
498
548
 
499
549
  // Đánh dấu chưa đọc
500
550
  await client.conversations.markAsUnread('conv-id');
501
551
  ```
502
552
 
553
+ ### `getMedia(conversationId, params?): Promise<CursorPaginatedResult<ConversationMediaItemDto>>`
554
+
555
+ Liệt kê toàn bộ media (image/video/audio/file) và link (LinkPreview hoặc external URL) đã từng xuất hiện trong cuộc hội thoại — sắp xếp **newest-first**, cursor pagination.
556
+
557
+ ```ts
558
+ // Mặc định: kind = 'all', limit = 50
559
+ const page1 = await client.conversations.getMedia('conv-id');
560
+
561
+ // Chỉ file/ảnh nội bộ
562
+ const attachments = await client.conversations.getMedia('conv-id', {
563
+ kind: 'attachment',
564
+ limit: 100,
565
+ });
566
+
567
+ // Chỉ link / LinkPreview
568
+ const links = await client.conversations.getMedia('conv-id', { kind: 'link' });
569
+
570
+ // Trang tiếp theo
571
+ if (page1.hasMore) {
572
+ const page2 = await client.conversations.getMedia('conv-id', {
573
+ cursor: page1.nextCursor!,
574
+ });
575
+ }
576
+ ```
577
+
578
+ | Param | Type | Default | Mô tả |
579
+ |-------|------|---------|-------|
580
+ | `kind` | `'all' \| 'attachment' \| 'link'` | `'all'` | Lọc theo loại item |
581
+ | `limit` | number | 50 | 1..100 |
582
+ | `cursor` | string | — | `nextCursor` từ trang trước (opaque) |
583
+
584
+ > **Lưu ý:** Người dùng chỉ thấy media của các message từ thời điểm họ join cuộc hội thoại trở đi. Server trả `403` nếu caller không phải participant.
585
+
503
586
  ### Kiểu dữ liệu Conversation
504
587
 
505
588
  ```ts
@@ -508,8 +591,8 @@ type ConversationType = 'Group' | 'OneToOne' | 'Self';
508
591
  interface ConversationDto {
509
592
  id: string;
510
593
  type: ConversationType;
511
- name?: string; // Tên nhóm (chỉ Group)
512
- avatar?: MediaReference;
594
+ name?: string | null; // Tên nhóm (chỉ Group); null cho OneToOne/Self
595
+ avatar?: MediaReference | null;
513
596
  ownerId: string;
514
597
  lastMessageAt?: string | null; // ISO 8601, null khi chưa có tin nhắn
515
598
  lastMessageId?: string | null; // null khi chưa có tin nhắn
@@ -533,8 +616,8 @@ interface ConversationParticipantDto {
533
616
  interface ConversationListItemDto {
534
617
  id: string;
535
618
  type: ConversationType;
536
- name?: string;
537
- avatar?: MediaReference;
619
+ name?: string | null; // null cho OneToOne/Self
620
+ avatar?: MediaReference | null;
538
621
  lastMessageAt?: string | null; // ISO 8601
539
622
  lastMessageId?: string | null;
540
623
  lastMessage?: MessageDto | null; // Preview tin nhắn cuối, null khi chưa có tin nhắn
@@ -543,6 +626,49 @@ interface ConversationListItemDto {
543
626
  unreadCount: number;
544
627
  participantCount: number;
545
628
  }
629
+
630
+ // Media listing (getMedia)
631
+ type ConversationMediaKindFilter = 'all' | 'attachment' | 'link';
632
+ type ConversationMediaKind = 'Attachment' | 'Link';
633
+ type ConversationMediaBlockType = 'Image' | 'Video' | 'Audio' | 'File' | 'LinkPreview';
634
+
635
+ interface ConversationMediaItemDto {
636
+ id: string; // Composite "{messageId}#{blockIndex:D2}"
637
+ conversationId: string;
638
+ messageId: string;
639
+ blockIndex: number; // 0-based vị trí block trong message
640
+ senderId: string;
641
+ senderType: string; // 'User' | 'Bot' | 'System'
642
+ kind: ConversationMediaKind;
643
+ blockType: ConversationMediaBlockType;
644
+ createdAt: string; // ISO 8601 — thời điểm message được tạo
645
+
646
+ storageKey?: string | null; // set khi kind === 'Attachment'
647
+ url?: string | null; // set khi kind === 'Link'
648
+
649
+ // File / audio / video
650
+ fileName?: string | null;
651
+ mimeType?: string | null;
652
+ fileSizeBytes?: number | null;
653
+
654
+ // Image / video / audio
655
+ width?: number | null;
656
+ height?: number | null;
657
+ durationSeconds?: number | null;
658
+ caption?: string | null;
659
+ altText?: string | null;
660
+
661
+ // Image / video thumbnail
662
+ thumbnailStorageKey?: string | null;
663
+ thumbnailUrl?: string | null;
664
+
665
+ // LinkPreview metadata
666
+ linkTitle?: string | null;
667
+ linkDescription?: string | null;
668
+ linkSiteName?: string | null;
669
+ linkImageStorageKey?: string | null;
670
+ linkImageUrl?: string | null;
671
+ }
546
672
  ```
547
673
 
548
674
  ---
@@ -612,11 +738,12 @@ Chỉ người gửi mới có thể chỉnh sửa.
612
738
  ```ts
613
739
  const edited = await client.messages.edit('msg-id', {
614
740
  blocks: [{ $type: 'text', format: 'Plain', content: 'Nội dung đã sửa', plainText: 'Nội dung đã sửa' }],
615
- mentions: [], // optional
741
+ mentions: [], // optional
742
+ replyToMessageId: null, // optional — null để xóa reply reference
616
743
  });
617
744
  ```
618
745
 
619
- > ⚠️ **Khác biệt REST vs SignalR:** REST dùng `{ blocks, mentions }`, SignalR dùng `{ messageId, newBlocks, newMentions }`.
746
+ > ⚠️ **Khác biệt REST vs SignalR:** REST dùng `{ blocks, mentions, replyToMessageId }`, SignalR dùng `{ messageId, newBlocks, newMentions, newReplyToMessageId }`.
620
747
 
621
748
  ### `delete(messageId): Promise<{ messageId, deletedAt }>`
622
749
 
@@ -653,7 +780,6 @@ interface MessageDto {
653
780
  conversationId: string;
654
781
  senderId: string;
655
782
  senderType: SenderType;
656
- timestamp: string; // ISO 8601 — thời điểm gửi
657
783
  blocks: Block[]; // Nội dung tin nhắn
658
784
  plainTextIndex: string | null;
659
785
  replyToMessageId: string | null;
@@ -699,6 +825,7 @@ interface SendMessageRequest {
699
825
  interface EditMessageRequest {
700
826
  blocks: Block[];
701
827
  mentions?: Mention[];
828
+ replyToMessageId?: string | null; // null để xóa reply reference
702
829
  }
703
830
 
704
831
  // Request đánh dấu đã đọc (REST)
@@ -756,18 +883,37 @@ const result = await client.files.upload(uploadUrl, file, (loaded, total) => {
756
883
 
757
884
  > Khi có `onProgress`, SDK dùng XHR thay vì fetch (vì fetch không hỗ trợ upload progress events).
758
885
 
759
- ### `getDownloadUrl(storageKey: string): string`
886
+ ### `getDownloadUrl(storageKey, signed?): string`
760
887
 
761
888
  Tạo URL tải file tuyệt đối (dùng để hiển thị ảnh, tải xuống, v.v.).
762
889
 
763
890
  ```ts
891
+ // URL public (mặc định)
764
892
  const url = client.files.getDownloadUrl('01HXABCDEF/photo.jpg');
765
893
  // → "https://chat-api.example.com/api/files/01HXABCDEF/photo.jpg"
766
894
  ```
767
895
 
896
+ #### Signed URL (kiểm soát truy cập)
897
+
898
+ 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:
899
+
900
+ ```ts
901
+ const url = client.files.getDownloadUrl('01HXABCDEF/photo.jpg', {
902
+ expires: '2026-03-26T13:00:00.0000000Z',
903
+ sig: 'abc123...',
904
+ });
905
+ // → ".../api/files/01HXABCDEF/photo.jpg?expires=...&sig=abc123..."
906
+ ```
907
+
908
+ > 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).
909
+
910
+ > **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.
911
+
912
+ > **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.
913
+
768
914
  ### `delete(storageKey: string): Promise<void>`
769
915
 
770
- Xóa file. Chỉ người upload mới có thể xóa. `storageKey` thể chứa `/`, không cần encode.
916
+ Xóa file. Chỉ người upload mới có thể xóa. Cùng quy tắc encoding như `getDownloadUrl()`: `/` được giữ, tự đặc biệt khác tự động percent-encoded.
771
917
 
772
918
  ```ts
773
919
  await client.files.delete('01HXABCDEF/photo.jpg');
@@ -808,7 +954,7 @@ GET (`list`, `getById`) là **public**. Các thao tác quản lý yêu cầu JWT
808
954
 
809
955
  ### `list(params?): Promise<PagedResult<BotDto>>`
810
956
 
811
- > Lưu ý: BotApi dùng **page-based** pagination (không phải cursor-based). `page` bắt đầu từ 1. Server cache kết quả **5 phút**.
957
+ > Lưu ý: BotApi dùng **page-based** pagination (không phải cursor-based). `page` bắt đầu từ 1.
812
958
 
813
959
  ```ts
814
960
  const result = await client.bots.list({ page: 1, pageSize: 20 });
@@ -818,17 +964,15 @@ console.log(result.totalCount); // Tổng số bot
818
964
 
819
965
  ### `getById(id): Promise<BotDto>`
820
966
 
821
- > Server cache kết quả **5 phút**.
822
-
823
967
  ```ts
824
968
  const bot = await client.bots.getById('bot-id');
825
969
  // Lấy thêm thông tin display: name, avatar
826
970
  const participant = await client.participants.getById(bot.participantId);
827
971
  ```
828
972
 
829
- ### `create(request): Promise<BotDto & { apiKey: string }>`
973
+ ### `create(request): Promise<BotDto>`
830
974
 
831
- Tạo bot và nhận `apiKey` một lần — **lưu ngay**, server không lưu key này.
975
+ Đăng bot mới.
832
976
 
833
977
  ```ts
834
978
  const bot = await client.bots.create({
@@ -836,10 +980,8 @@ const bot = await client.bots.create({
836
980
  fullName: 'My Assistant Bot',
837
981
  description: 'Trợ lý AI',
838
982
  kafkaTopic: 'my-bot-topic', // Không thể thay đổi sau khi tạo
839
- rateLimitPerSecond: 5,
840
983
  listenAllGroupMessages: false,
841
984
  });
842
- const apiKey = bot.apiKey; // Lưu ngay!
843
985
  ```
844
986
 
845
987
  ### `update(id, request): Promise<BotDto>`
@@ -847,7 +989,6 @@ const apiKey = bot.apiKey; // Lưu ngay!
847
989
  ```ts
848
990
  await client.bots.update('bot-id', {
849
991
  fullName: 'Updated Bot Name',
850
- rateLimitPerSecond: 10,
851
992
  // kafkaTopic KHÔNG thể cập nhật
852
993
  });
853
994
  ```
@@ -859,14 +1000,6 @@ await client.bots.activate('bot-id');
859
1000
  await client.bots.deactivate('bot-id');
860
1001
  ```
861
1002
 
862
- ### `regenerateKey(id): Promise<RegenerateKeyResponse>`
863
-
864
- Xoay API key — key cũ bị vô hiệu hóa ngay lập tức.
865
-
866
- ```ts
867
- const { apiKey } = await client.bots.regenerateKey('bot-id');
868
- ```
869
-
870
1003
  ### `delete(id): Promise<void>`
871
1004
 
872
1005
  Soft delete.
@@ -881,11 +1014,10 @@ await client.bots.delete('bot-id');
881
1014
  interface BotDto {
882
1015
  id: string;
883
1016
  participantId: string; // Dùng để lấy name/avatar từ ParticipantApi
884
- description?: string;
885
- metadata?: Record<string, unknown>;
1017
+ description?: string | null;
1018
+ metadata?: Record<string, unknown> | null;
886
1019
  kafkaTopic: string; // KHÔNG thể thay đổi sau khi tạo
887
1020
  isActive: boolean;
888
- rateLimitPerSecond: number;
889
1021
  listenAllGroupMessages: boolean;
890
1022
  createdBy: string; // ID của người tạo bot
891
1023
  createdAt: string; // ISO 8601
@@ -905,7 +1037,6 @@ interface CreateBotRequest {
905
1037
  description?: string;
906
1038
  metadata?: Record<string, unknown>;
907
1039
  kafkaTopic: string; // KHÔNG thể thay đổi sau khi tạo
908
- rateLimitPerSecond?: number;
909
1040
  listenAllGroupMessages?: boolean; // Mặc định false
910
1041
  }
911
1042
 
@@ -915,7 +1046,6 @@ interface UpdateBotRequest {
915
1046
  avatar?: MediaReference;
916
1047
  description?: string;
917
1048
  metadata?: Record<string, unknown>;
918
- rateLimitPerSecond?: number;
919
1049
  listenAllGroupMessages?: boolean;
920
1050
  // kafkaTopic KHÔNG thể cập nhật
921
1051
  }
@@ -939,7 +1069,34 @@ const order = await client.proxy.post<Order>('trading', 'api/orders', {
939
1069
  quantity: 100,
940
1070
  });
941
1071
 
942
- // Request tùy chỉnh method
1072
+ // PUT /api/proxy/trading/api/orders/123
1073
+ const updated = await client.proxy.put<Order>('trading', 'api/orders/123', {
1074
+ quantity: 200,
1075
+ });
1076
+
1077
+ // PATCH /api/proxy/trading/api/orders/123
1078
+ const patched = await client.proxy.patch<Order>('trading', 'api/orders/123', {
1079
+ status: 'cancelled',
1080
+ });
1081
+
1082
+ // DELETE /api/proxy/trading/api/orders/123
1083
+ await client.proxy.delete('trading', 'api/orders/123');
1084
+
1085
+ // Truyền custom headers — mỗi shortcut đều nhận `headers?` ở tham số cuối
1086
+ const dataWithHeader = await client.proxy.get<StockData>(
1087
+ 'trading',
1088
+ 'api/stocks/VN30',
1089
+ { 'X-Trace-Id': 'abc-123' },
1090
+ );
1091
+
1092
+ const orderWithHeader = await client.proxy.post<Order>(
1093
+ 'trading',
1094
+ 'api/orders',
1095
+ { symbol: 'VNM', quantity: 100 },
1096
+ { 'Idempotency-Key': 'order-uuid' },
1097
+ );
1098
+
1099
+ // Request tùy chỉnh method (hỗ trợ GET, POST, PUT, PATCH, DELETE)
943
1100
  const result = await client.proxy.request<unknown>('myservice', 'api/resource/123', {
944
1101
  method: 'DELETE',
945
1102
  headers: { 'X-Custom-Header': 'value' },
@@ -1035,6 +1192,12 @@ await client.realtime.chat.leaveConversation('conv-id');
1035
1192
  ```
1036
1193
 
1037
1194
  > SDK theo dõi `joinedConversations` và tự động re-join sau khi reconnect.
1195
+ >
1196
+ > **Lưu ý về FORBIDDEN/UNAUTHORIZED:** Theo spec, server có thể chấp nhận lời gọi `joinConversation` nhưng từ chối quyền truy cập sau đó qua event `Error` (vd. `FORBIDDEN — You are not a participant`). SDK sẽ tự động loại conversationId đó khỏi `joinedConversations` để tránh re-join sai sau reconnect, dựa trên:
1197
+ > 1. `error.details.conversationId` nếu server cung cấp (chính xác nhất), hoặc
1198
+ > 2. Fallback: message khớp `/not a participant/i` **và** chỉ có đúng 1 lời gọi `joinConversation` đang trong cửa sổ ~2s.
1199
+ >
1200
+ > Vẫn nên đăng ký listener `error` để hiển thị thông báo cho user.
1038
1201
 
1039
1202
  ### Gửi tin nhắn qua SignalR
1040
1203
 
@@ -1055,10 +1218,11 @@ if (ack.success) {
1055
1218
  ### Chỉnh sửa / Xóa / Khôi phục tin nhắn qua SignalR
1056
1219
 
1057
1220
  ```ts
1058
- // Chỉnh sửa — dùng newBlocks/newMentions (khác REST!)
1221
+ // Chỉnh sửa — dùng newBlocks/newMentions/newReplyToMessageId (khác REST!)
1059
1222
  const editAck = await client.realtime.chat.editMessage({
1060
1223
  messageId: 'msg-id',
1061
1224
  newBlocks: [{ $type: 'text', format: 'Plain', content: 'Đã sửa', plainText: 'Đã sửa' }],
1225
+ newReplyToMessageId: null, // optional — null để xóa reply reference
1062
1226
  });
1063
1227
 
1064
1228
  // Xóa — truyền object, KHÔNG phải string
@@ -1113,6 +1277,7 @@ client.realtime.chat.off('messageReceived', handler);
1113
1277
  | `messageUpdated` | `MessageUpdatedDto` | Tin nhắn được chỉnh sửa |
1114
1278
  | `messageDeleted` | `MessageDeletedDto` | Tin nhắn bị xóa |
1115
1279
  | `messageRecovered` | `ChatMessageDto` | Tin nhắn được khôi phục |
1280
+ | `messageThumbnailsReady` | `MessageThumbnailsReadyDto` | Server đã sinh xong thumbnail cho ảnh/video — patch theo `blockIndex` |
1116
1281
  | `reactionAdded` | `ReactionAddedDto` | Thêm reaction |
1117
1282
  | `reactionRemoved` | `ReactionRemovedDto` | Xóa reaction |
1118
1283
  | `typingStarted` | `TypingDto` | Người dùng đang gõ |
@@ -1138,7 +1303,8 @@ interface MessageUpdatedDto {
1138
1303
  blocks: Block[];
1139
1304
  plainTextContent: string | null;
1140
1305
  mentions?: Mention[];
1141
- updatedAt: string; // ISO 8601
1306
+ updatedAt: string; // ISO 8601
1307
+ replyToMessageId?: string | null; // null nếu reply reference đã bị xóa
1142
1308
  }
1143
1309
 
1144
1310
  // Tin nhắn bị xóa
@@ -1186,6 +1352,36 @@ interface ReadReceiptUpdatedDto {
1186
1352
  lastReadMessageId: string;
1187
1353
  readAt: string; // ISO 8601
1188
1354
  }
1355
+
1356
+ // Thumbnail ready cho một block media trong message
1357
+ interface MessageThumbnailDto {
1358
+ blockIndex: number; // Vị trí block trong mảng blocks của message
1359
+ thumbnail: MediaReference;
1360
+ }
1361
+
1362
+ // Server đã sinh xong thumbnail cho ảnh/video (xử lý bất đồng bộ).
1363
+ // Event này gửi SAU messageReceived/streamCompleted.
1364
+ interface MessageThumbnailsReadyDto {
1365
+ messageId: string;
1366
+ conversationId: string;
1367
+ thumbnails: MessageThumbnailDto[];
1368
+ }
1369
+ ```
1370
+
1371
+ ### Cập nhật thumbnail bất đồng bộ
1372
+
1373
+ Khi gửi tin nhắn có ảnh/video, server xử lý thumbnail trong nền và phát event `messageThumbnailsReady` sau khi hoàn tất. Client nên patch block theo `blockIndex` thay vì re-fetch toàn bộ message.
1374
+
1375
+ ```ts
1376
+ client.realtime.chat.on('messageThumbnailsReady', (dto) => {
1377
+ // dto.thumbnails: [{ blockIndex: 0, thumbnail: { storageKey, url } }, ...]
1378
+ for (const item of dto.thumbnails) {
1379
+ const block = messageStore.get(dto.messageId)?.blocks[item.blockIndex];
1380
+ if (block && (block.$type === 'image' || block.$type === 'video')) {
1381
+ block.thumbnail = item.thumbnail;
1382
+ }
1383
+ }
1384
+ });
1189
1385
  ```
1190
1386
 
1191
1387
  ### Bot streaming
@@ -1263,7 +1459,7 @@ await client.realtime.notifications.resubscribePresence();
1263
1459
  ### Theo dõi trạng thái online (Presence)
1264
1460
 
1265
1461
  ```ts
1266
- // Đăng ký theo dõi tối đa 200 participant mỗi lần
1462
+ // Đăng ký theo dõi presence SDK tự chia nhỏ thành các batch 200 nếu danh sách dài hơn
1267
1463
  await client.realtime.notifications.subscribeToPresence(['user-1', 'user-2', 'user-3']);
1268
1464
 
1269
1465
  // Server gửi PresenceState ngay lập tức sau khi subscribe
@@ -1278,7 +1474,7 @@ client.realtime.notifications.on('presenceChanged', (dto) => {
1278
1474
  console.log(dto.participantId, 'is now', dto.isOnline ? 'online' : 'offline');
1279
1475
  });
1280
1476
 
1281
- // Hủy theo dõi — cũng giới hạn tối đa 200 participant mỗi lần
1477
+ // Hủy theo dõi — cũng tự chia nhỏ thành các batch 200
1282
1478
  await client.realtime.notifications.unsubscribeFromPresence(['user-1']);
1283
1479
  ```
1284
1480
 
@@ -1300,6 +1496,8 @@ await client.realtime.notifications.unsubscribeFromPresence(['user-1']);
1300
1496
  | `conversationPinned` | `ConversationPinnedDto` | Cuộc hội thoại được pin |
1301
1497
  | `conversationUnpinned` | `ConversationUnpinnedDto` | Cuộc hội thoại được unpin |
1302
1498
  | `readStatusChanged` | `ReadStatusChangedDto` | Trạng thái đọc thay đổi |
1499
+ | `conversationDeleted` | `ConversationDeletedDto` | Conversation bị ẩn/xóa khỏi danh sách (chỉ OneToOne & Self, chỉ user thực hiện) |
1500
+ | `conversationRestored` | `ConversationRestoredDto` | Conversation bị ẩn được khôi phục tự động do có tin nhắn mới (chỉ OneToOne & Self) |
1303
1501
  | `error` | `HubErrorDto` | Lỗi từ server |
1304
1502
  | `reconnecting` | `Error \| undefined` | Đang reconnect |
1305
1503
  | `reconnected` | `string \| undefined` | Đã reconnect |
@@ -1325,11 +1523,11 @@ interface PresenceChangedDto {
1325
1523
  interface NewMessageNotificationDto {
1326
1524
  conversationId: string;
1327
1525
  conversationType: ConversationType;
1328
- conversationName?: string;
1526
+ conversationName?: string | null;
1329
1527
  messageId: string;
1330
1528
  senderId: string;
1331
1529
  senderName: string;
1332
- senderAvatar?: MediaReference;
1530
+ senderAvatar?: MediaReference | null; // Có thể null khi server chưa sẵn signed URL
1333
1531
  contentPreview: string;
1334
1532
  sentAt: string; // ISO 8601
1335
1533
  }
@@ -1354,15 +1552,15 @@ interface UnreadCountChangedDto {
1354
1552
  interface ConversationCreatedDto {
1355
1553
  conversationId: string;
1356
1554
  type: ConversationType;
1357
- name?: string;
1358
- avatar?: MediaReference;
1555
+ name?: string | null;
1556
+ avatar?: MediaReference | null;
1359
1557
  participantCount: number;
1360
1558
  }
1361
1559
 
1362
1560
  interface ConversationUpdatedDto {
1363
1561
  conversationId: string;
1364
1562
  type: ConversationType;
1365
- name?: string;
1563
+ name?: string | null;
1366
1564
  avatar?: MediaReference | null; // null = avatar đã bị xóa
1367
1565
  participantCount: number;
1368
1566
  }
@@ -1371,7 +1569,7 @@ interface ParticipantJoinedDto {
1371
1569
  conversationId: string;
1372
1570
  participantId: string;
1373
1571
  participantName: string;
1374
- participantAvatar?: MediaReference;
1572
+ participantAvatar?: MediaReference | null; // Có thể null khi server chưa sẵn signed URL
1375
1573
  changedAt: string; // ISO 8601
1376
1574
  }
1377
1575
 
@@ -1380,7 +1578,7 @@ interface ParticipantLeftDto {
1380
1578
  conversationId: string;
1381
1579
  participantId: string;
1382
1580
  participantName: string;
1383
- participantAvatar?: MediaReference;
1581
+ participantAvatar?: MediaReference | null; // Có thể null khi server chưa sẵn signed URL
1384
1582
  changedAt: string; // ISO 8601
1385
1583
  }
1386
1584
 
@@ -1400,6 +1598,26 @@ interface ReadStatusChangedDto {
1400
1598
  unreadCount: number;
1401
1599
  markedAsUnread: boolean; // true khi user chủ động đánh dấu chưa đọc
1402
1600
  }
1601
+
1602
+ // Conversation bị ẩn/xóa khỏi danh sách của user.
1603
+ // Chỉ áp dụng cho OneToOne và Self — không broadcast tới người còn lại.
1604
+ // Conversation vẫn tồn tại và sẽ tự khôi phục khi có tin nhắn mới.
1605
+ interface ConversationDeletedDto {
1606
+ conversationId: string;
1607
+ deletedAt: string; // ISO 8601
1608
+ }
1609
+
1610
+ // Conversation đã bị ẩn được khôi phục tự động vì có tin nhắn mới.
1611
+ // Client nên thêm lại conversation vào sidebar với metadata đầy đủ từ payload.
1612
+ // Chú ý: Ngay sau event này, client cũng sẽ nhận newMessageNotification cho tin nhắn kích hoạt khôi phục.
1613
+ interface ConversationRestoredDto {
1614
+ conversationId: string;
1615
+ type: ConversationType;
1616
+ name?: string | null;
1617
+ avatar?: MediaReference | null;
1618
+ participantCount: number;
1619
+ restoredAt: string; // ISO 8601
1620
+ }
1403
1621
  ```
1404
1622
 
1405
1623
  ---
@@ -1418,7 +1636,7 @@ const manager = new ReconnectionManager({
1418
1636
  // Gọi API refresh token của ứng dụng
1419
1637
  const newToken = await refreshToken();
1420
1638
  if (newToken) {
1421
- client.setToken(newToken); // Cập nhật token vào SDK
1639
+ client.setToken(newToken); // Cập nhật token vào SDK; lần connect() kế tiếp sẽ dùng giá trị mới
1422
1640
  }
1423
1641
  return newToken; // Trả về null để hủy reconnect
1424
1642
  },
@@ -1432,7 +1650,9 @@ manager.stop();
1432
1650
 
1433
1651
  **Chiến lược backoff:** 3 lần thử với delay 2s → 5s → 10s.
1434
1652
 
1435
- **Phát hiện token hết hạn:** Kiểm tra error message chứa `"401"` hoặc `"unauthorized"`.
1653
+ **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.
1654
+
1655
+ **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.
1436
1656
 
1437
1657
  ---
1438
1658
 
@@ -1646,7 +1866,7 @@ type ButtonStyle = 'Default' | 'Primary' | 'Danger';
1646
1866
  interface ActionButton {
1647
1867
  label: string;
1648
1868
  action: ButtonAction;
1649
- value: string | null;
1869
+ value?: string | null;
1650
1870
  style: ButtonStyle;
1651
1871
  }
1652
1872
 
@@ -1730,8 +1950,34 @@ interface PagedResult<T> {
1730
1950
 
1731
1951
  ```ts
1732
1952
  interface MediaReference {
1733
- storageKey?: string; // Key nội bộ — dùng FileApi.getDownloadUrl() để tạo URL
1734
- url?: string; // URL bên ngoài (không qua storage)
1953
+ storageKey?: string; // Key lưu trữ nội bộ — dùng FileApi.getDownloadUrl(storageKey) để tạo URL tuyệt đối
1954
+ url?: string; // URL do server cấp sẵn (xem chi tiết bên dưới)
1955
+ }
1956
+ ```
1957
+
1958
+ Trường `url` có hai dạng tuỳ cấu hình server:
1959
+
1960
+ | Dạng | Ví dụ | Cách dùng |
1961
+ |------|-------|-----------|
1962
+ | **Relative signed URL** | `/api/files/abc/photo.jpg?expires=...&sig=...` | Phải prepend `baseUrl` trước khi dùng |
1963
+ | **Absolute external URL** | `https://cdn.example.com/photo.jpg` | Dùng trực tiếp |
1964
+
1965
+ > **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).
1966
+
1967
+ ```ts
1968
+ // Helper chuẩn để resolve MediaReference → URL tuyệt đối
1969
+ function resolveMediaRef(
1970
+ ref: MediaReference | null | undefined,
1971
+ baseUrl: string,
1972
+ getDownloadUrl: (key: string) => string,
1973
+ ): string | null {
1974
+ if (!ref) return null;
1975
+ if (ref.url) {
1976
+ // Relative signed URL → prepend baseUrl
1977
+ return ref.url.startsWith('/') ? baseUrl.replace(/\/$/, '') + ref.url : ref.url;
1978
+ }
1979
+ if (ref.storageKey) return getDownloadUrl(ref.storageKey);
1980
+ return null;
1735
1981
  }
1736
1982
  ```
1737
1983
 
@@ -1745,7 +1991,7 @@ interface ChatMessageDto {
1745
1991
  conversationId: string;
1746
1992
  senderId: string;
1747
1993
  senderName: string; // Có ở SignalR, không có ở REST
1748
- senderAvatar?: MediaReference; // Có ở SignalR, không có ở REST
1994
+ senderAvatar?: MediaReference | null; // Có ở SignalR, không có ở REST. Có thể null khi server chưa sẵn signed URL
1749
1995
  senderType: SenderType; // Có ở cả REST và SignalR
1750
1996
  blocks: Block[];
1751
1997
  plainTextContent: string | null; // Tên khác với REST (plainTextIndex)
@@ -1778,13 +2024,15 @@ type SystemEventType =
1778
2024
  | 'MemberAdded' // Thêm thành viên
1779
2025
  | 'MemberRemoved' // Xóa thành viên
1780
2026
  | 'MemberLeft' // Thành viên tự rời
1781
- | 'Custom';
2027
+ | 'Custom'; // Sự kiện tùy chỉnh do server định nghĩa
1782
2028
 
1783
2029
  interface SystemEventInfo {
1784
2030
  type: SystemEventType;
1785
- actorId: string | null; // null khi MemberLeft (tự rời)
2031
+ actorId?: string | null; // null/undefined khi không có actor (vd. MemberLeft tự rời)
1786
2032
  targetIds?: string[];
1787
- metadata?: Record<string, unknown>;
2033
+ // Tất cả giá trị đều là string (vd. ConversationRenamed → { newName: "Team Beta" }).
2034
+ // Server có thể trả null cho event không kèm metadata (vd. MemberAdded thuần).
2035
+ metadata?: Record<string, string> | null;
1788
2036
  }
1789
2037
 
1790
2038
  // SignalR (ChatHub) version — kế thừa SystemEventInfo, thêm display names
@@ -1800,7 +2048,8 @@ interface SystemEventDto extends SystemEventInfo {
1800
2048
  interface HubErrorDto {
1801
2049
  code: string;
1802
2050
  message: string;
1803
- details?: Record<string, unknown>;
2051
+ // Server có thể gửi `null` rõ ràng, hoặc bỏ field hoàn toàn
2052
+ details?: Record<string, unknown> | null;
1804
2053
  }
1805
2054
  ```
1806
2055
 
@@ -1813,9 +2062,10 @@ interface StreamStartedDto {
1813
2062
  conversationId: string;
1814
2063
  senderId: string;
1815
2064
  senderName: string;
1816
- senderAvatar?: MediaReference;
1817
- replyToMessageId?: string;
2065
+ senderAvatar?: MediaReference | null; // Có thể null khi server chưa sẵn signed URL
2066
+ replyToMessageId?: string | null;
1818
2067
  startedAt: string; // ISO 8601
2068
+ clientMessageId?: string | null; // ID tạm của client (echo). null nếu bot không truyền
1819
2069
  }
1820
2070
 
1821
2071
  interface StreamStatusUpdatedDto {
@@ -1841,6 +2091,7 @@ interface StreamCompletedDto {
1841
2091
  conversationId: string;
1842
2092
  message: ChatMessageDto; // Tin nhắn hoàn chỉnh (SignalR, không phải REST MessageDto)
1843
2093
  completedAt: string; // ISO 8601
2094
+ finishReason?: string | null; // Lý do kết thúc stream. null nếu bot không truyền
1844
2095
  }
1845
2096
 
1846
2097
  interface StreamAbortedDto {
@@ -1920,7 +2171,17 @@ try {
1920
2171
  if (err instanceof ChatApiError) {
1921
2172
  console.error(`HTTP ${err.status}: ${err.title}`);
1922
2173
  console.error('Chi tiết:', err.detail);
1923
-
2174
+
2175
+ // Xem tất cả lỗi validation chi tiết
2176
+ if (err.errors) {
2177
+ err.errors.forEach(e => console.error(`[${e.code}] ${e.description}`));
2178
+ }
2179
+
2180
+ // Dùng traceId để correlate log
2181
+ if (err.traceId) {
2182
+ console.error('TraceId:', err.traceId);
2183
+ }
2184
+
1924
2185
  switch (err.status) {
1925
2186
  case 401: // Unauthorized — token hết hạn/không hợp lệ
1926
2187
  await refreshAndRetry();
@@ -1943,12 +2204,17 @@ try {
1943
2204
  | Thuộc tính | Kiểu | Mô tả |
1944
2205
  |------------|------|-------|
1945
2206
  | `status` | `number` | HTTP status code |
1946
- | `title` | `string` | Tiêu đề lỗi (từ RFC 7807 `title`) |
1947
- | `detail` | `string \| undefined` | Chi tiết lỗi (từ RFC 7807 `detail`) |
2207
+ | `title` | `string` | Tiêu đề lỗi giá trị `ErrorType` enum: `Validation`, `NotFound`, `Conflict`, `Forbidden`, `Unauthorized`, `Unexpected`. Auth endpoints trả `Bad Request`, v.v. |
2208
+ | `detail` | `string \| undefined` | tả của lỗi đầu tiên (từ RFC 7807 `detail`) |
2209
+ | `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) |
2210
+ | `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ộ |
2211
+ | `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ộ |
1948
2212
  | `message` | `string` | `detail ?? title` (kế thừa từ `Error`) |
1949
2213
  | `name` | `string` | Luôn là `"ChatApiError"` |
1950
2214
 
1951
- Server trả lỗi dạng RFC 7807: `{ status, title, detail }`. Endpoint proxy trả `{ detail }` đơn giản hơn — SDK xử lý cả hai.
2215
+ Server trả lỗi dạng RFC 7807: `{ type?, status, title, detail?, errors?, traceId? }`. Endpoint proxy trả `{ detail }` đơn giản hơn — SDK xử lý cả hai.
2216
+
2217
+ > **Lưu ý:** `errors` chỉ có mặt khi lỗi đi qua handler (ErrorOr path → `ToProblemResult`). Lỗi từ middleware/filter (401 thiếu token, lỗi routing) trả ProblemDetails **không có** trường `errors`.
1952
2218
 
1953
2219
  ### Static methods
1954
2220
 
@@ -1979,7 +2245,16 @@ interface ProblemDetails {
1979
2245
  status: number;
1980
2246
  title: string;
1981
2247
  detail?: string;
1982
- type?: string; // URI referencethường không dùng trực tiếp
2248
+ type?: string; // URI tham chiếu (RFC 7807) `ChatApiError.fromProblemDetails` sẽ forward sang `error.type`
2249
+ errors?: ProblemError[]; // Có khi lỗi đi qua handler; vắng mặt cho lỗi middleware/filter
2250
+ traceId?: string; // W3C trace ID — tự động thêm bởi framework
2251
+ }
2252
+
2253
+ // Một phần tử trong mảng `errors`
2254
+ interface ProblemError {
2255
+ code: string; // Mã định danh lỗi, ví dụ: "Bot.NotFound", "UniqueName"
2256
+ description: string; // Mô tả chi tiết lỗi
2257
+ type: string; // ErrorType: "Validation", "NotFound", "Conflict", "Forbidden", "Unauthorized", "Unexpected"
1983
2258
  }
1984
2259
  ```
1985
2260
 
@@ -2077,33 +2352,63 @@ const cursor = Number(result.nextCursor); // BUG!
2077
2352
 
2078
2353
  - `setToken()` hoạt động ngay cho **HTTP requests**.
2079
2354
  - SignalR đọc token khi **connect()**, không phải mid-session.
2080
- - Để cập nhật token cho SignalR: `disconnect()` `setToken()` → `connect()`.
2355
+ - Để áp token mới ngay cho hub đang Connected: `setToken(newToken)` → `await refreshConnections()`.
2356
+ - Sau `refreshConnections()` cần tự `joinConversation()` / `subscribeToPresence()` lại (tracking bị clear khi disconnect). Hoặc dùng `ReconnectionManager` để tự động khôi phục.
2357
+ - 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.
2081
2358
 
2082
2359
  ### REST vs SignalR — field name khác nhau
2083
2360
 
2084
2361
  | Thao tác | REST | SignalR |
2085
2362
  |----------|------|---------|
2086
- | Edit message | `{ blocks, mentions }` | `{ messageId, newBlocks, newMentions }` |
2363
+ | Edit message | `{ blocks, mentions, replyToMessageId? }` | `{ messageId, newBlocks, newMentions, newReplyToMessageId? }` |
2087
2364
  | Delete message | `messages.delete(messageId)` | `deleteMessage({ messageId })` — object, không phải string |
2088
2365
  | Stream completion | Không có event | `streamCompleted` thay `messageReceived` |
2089
2366
 
2090
2367
  ### File upload — storageKey vs URL
2091
2368
 
2369
+ **Khi gửi tin nhắn (client → server):** dùng `storageKey` trong block, không dùng `url`.
2370
+
2092
2371
  ```ts
2093
- // Sau khi upload, dùng storageKey làm MediaReference
2094
2372
  const { storageKey } = await client.files.uploadFile(file);
2095
2373
 
2096
- // Trong block:
2097
2374
  const block: ImageBlock = {
2098
2375
  $type: 'image',
2099
- source: { storageKey }, // KHÔNG phải url
2376
+ source: { storageKey }, // ĐÚNG — KHÔNG đặt url ở đây
2100
2377
  // ...
2101
2378
  };
2379
+ ```
2380
+
2381
+ **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:
2382
+
2383
+ ```ts
2384
+ // resolve MediaReference → URL tuyệt đối để dùng trong <img src>, <a href>, v.v.
2385
+ function resolveMediaRef(
2386
+ ref: MediaReference | null | undefined,
2387
+ baseUrl: string,
2388
+ getDownloadUrl: (key: string) => string,
2389
+ ): string | null {
2390
+ if (!ref) return null;
2391
+ if (ref.url) {
2392
+ // url có thể là relative signed URL ("/api/files/...?sig=...")
2393
+ // hoặc absolute external URL ("https://...")
2394
+ return ref.url.startsWith('/') ? baseUrl.replace(/\/$/, '') + ref.url : ref.url;
2395
+ }
2396
+ if (ref.storageKey) return getDownloadUrl(ref.storageKey);
2397
+ return null;
2398
+ }
2102
2399
 
2103
- // Để hiển thị ảnh:
2104
- const displayUrl = client.files.getDownloadUrl(storageKey);
2400
+ // dụ dùng với ImageBlock:
2401
+ const displayUrl = resolveMediaRef(
2402
+ block.source,
2403
+ 'https://chat-api.example.com',
2404
+ client.files.getDownloadUrl.bind(client.files),
2405
+ );
2105
2406
  ```
2106
2407
 
2408
+ > **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.
2409
+
2410
+ > **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.
2411
+
2107
2412
  ### BotDto — không có tên/avatar
2108
2413
 
2109
2414
  `BotDto` **không** chứa `uniqueName` hay `fullName`. Phải gọi thêm: