@manonero/chat-client-sdk 1.0.0-beta.0 → 1.0.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +432 -128
  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 +42 -8
  15. package/dist/http/ConversationApi.d.ts.map +1 -1
  16. package/dist/http/ConversationApi.js +57 -8
  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 +6 -4
  27. package/dist/http/MessageApi.d.ts.map +1 -1
  28. package/dist/http/MessageApi.js +17 -4
  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 +4 -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 +25 -3
  59. package/dist/types/chat-events.d.ts.map +1 -1
  60. package/dist/types/common.d.ts +26 -3
  61. package/dist/types/common.d.ts.map +1 -1
  62. package/dist/types/conversation.d.ts +65 -15
  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 +36 -9
  67. package/dist/types/notification-events.d.ts.map +1 -1
  68. package/dist/types/participant.d.ts +4 -4
  69. package/dist/types/participant.d.ts.map +1 -1
  70. package/dist/types/signalr.d.ts +6 -3
  71. package/dist/types/signalr.d.ts.map +1 -1
  72. package/package.json +4 -2
package/README.md CHANGED
@@ -61,39 +61,39 @@ ChatClient (facade chính)
61
61
  ### Installation
62
62
 
63
63
  ```bash
64
- npm install chat-client-sdk
64
+ npm install @manonero/chat-client-sdk
65
65
  npm install @microsoft/signalr # peer dependency
66
66
  ```
67
67
 
68
68
  ### Import
69
69
 
70
70
  ```ts
71
- import { ChatClient } from 'chat-client-sdk';
71
+ import { ChatClient } from '@manonero/chat-client-sdk';
72
72
  ```
73
73
 
74
74
  Tất cả các kiểu dữ liệu đều được export từ package root:
75
75
 
76
76
  ```ts
77
- import type { MessageDto, ConversationDto, Block } from 'chat-client-sdk';
78
- import { ChatApiError } from 'chat-client-sdk'; // class — dùng import (không phải import type) để hỗ trợ instanceof
77
+ import type { MessageDto, ConversationDto, Block } from '@manonero/chat-client-sdk';
78
+ import { ChatApiError } from '@manonero/chat-client-sdk'; // class — dùng import (không phải import type) để hỗ trợ instanceof
79
79
  ```
80
80
 
81
81
  **Export nâng cao** — SDK cũng export một số class và type nội bộ cho power users:
82
82
 
83
83
  ```ts
84
84
  // TypedEventEmitter — dùng độc lập hoặc extend
85
- import { TypedEventEmitter } from 'chat-client-sdk';
86
- import type { Unsubscribe } from 'chat-client-sdk';
85
+ import { TypedEventEmitter } from '@manonero/chat-client-sdk';
86
+ import type { Unsubscribe } from '@manonero/chat-client-sdk';
87
87
 
88
88
  // HttpClient — tự xây dựng API module bổ sung theo cùng pattern
89
- import { HttpClient } from 'chat-client-sdk';
90
- import type { HttpClientOptions } from 'chat-client-sdk';
89
+ import { HttpClient } from '@manonero/chat-client-sdk';
90
+ import type { HttpClientOptions } from '@manonero/chat-client-sdk';
91
91
 
92
92
  // Event map types — dùng khi cần typed wrapper
93
- import type { ChatHubEventMap, NotificationHubEventMap } from 'chat-client-sdk';
93
+ import type { ChatHubEventMap, NotificationHubEventMap } from '@manonero/chat-client-sdk';
94
94
 
95
95
  // Hub options (khi dùng hub client độc lập, không qua ChatClient)
96
- import type { ChatHubClientOptions, NotificationHubClientOptions } from 'chat-client-sdk';
96
+ import type { ChatHubClientOptions, NotificationHubClientOptions } from '@manonero/chat-client-sdk';
97
97
 
98
98
  // Hub request types (Client → Server) — dùng khi gọi SignalR methods
99
99
  import type {
@@ -103,7 +103,7 @@ import type {
103
103
  ChatRecoverMessageRequest,
104
104
  ChatAddReactionRequest,
105
105
  ChatRemoveReactionRequest,
106
- } from 'chat-client-sdk';
106
+ } from '@manonero/chat-client-sdk';
107
107
 
108
108
  // Ack types (Server → Client return) — dùng khi cần type return value của Hub methods
109
109
  import type {
@@ -114,22 +114,22 @@ import type {
114
114
  ReactionAck,
115
115
  MarkAsReadAck,
116
116
  HubErrorDto,
117
- } from 'chat-client-sdk';
117
+ } from '@manonero/chat-client-sdk';
118
118
 
119
119
  // ReconnectionManager options
120
- import type { ReconnectionManagerOptions } from 'chat-client-sdk';
120
+ import type { ReconnectionManagerOptions } from '@manonero/chat-client-sdk';
121
121
 
122
122
  // ChatClient facade types
123
- import type { ChatClientSignalrOptions, ChatClientRealtime } from 'chat-client-sdk';
123
+ import type { ChatClientSignalrOptions, ChatClientRealtime } from '@manonero/chat-client-sdk';
124
124
 
125
125
  // Upload progress callback type — dùng khi cần type hàm onProgress
126
- import type { UploadProgressCallback } from 'chat-client-sdk';
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 'chat-client-sdk';
129
+ import type { ProblemDetails, ProblemError } from '@manonero/chat-client-sdk';
130
130
 
131
131
  // Mark as read request type (REST)
132
- import type { MarkAsReadRequest } from 'chat-client-sdk';
132
+ import type { MarkAsReadRequest } from '@manonero/chat-client-sdk';
133
133
 
134
134
  // Login request types riêng lẻ (khi cần type-check từng provider)
135
135
  import type {
@@ -137,10 +137,10 @@ import type {
137
137
  DsAccountLoginRequest,
138
138
  DevLoginRequest,
139
139
  LoginRequest,
140
- } from 'chat-client-sdk';
140
+ } from '@manonero/chat-client-sdk';
141
141
 
142
142
  // Standalone hub clients — dùng khi không cần ChatClient facade
143
- import { ChatHubClient, NotificationHubClient, ReconnectionManager } from 'chat-client-sdk';
143
+ import { ChatHubClient, NotificationHubClient, ReconnectionManager } from '@manonero/chat-client-sdk';
144
144
  ```
145
145
 
146
146
  ### Khởi tạo cơ bản
@@ -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`
229
235
 
230
- Cập nhật JWT token. Có hiệu lực ngay với mọi HTTP request tiếp theo.
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()`).
231
237
 
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.
238
+ > ⚠️ Các kết nối SignalR **đang hoạt động** vẫn dùng token cho đến lúc reconnect. Gọi `refreshConnections()` để áp token mới ngay cho hub đang Connected.
239
+
240
+ > ℹ️ 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.
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,7 +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)
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
301
340
  }
302
341
  ```
303
342
 
@@ -350,13 +389,13 @@ interface ParticipantDto {
350
389
  id: string;
351
390
  uniqueName: string;
352
391
  fullName: string;
353
- avatar?: MediaReference;
354
- gender?: string;
392
+ avatar?: MediaReference | null; // hỗ trợ cả internal storageKey lẫn external URL; server có thể trả null
393
+ gender?: string | null;
355
394
  isBot: boolean;
356
395
  isOnline: boolean;
357
- lastSeenAt?: string; // ISO 8601
358
- createdAt: string; // ISO 8601
359
- updatedAt: string; // ISO 8601
396
+ lastSeenAt?: string | null; // ISO 8601, null khi đang online
397
+ createdAt: string; // ISO 8601
398
+ updatedAt: string; // ISO 8601
360
399
  }
361
400
  ```
362
401
 
@@ -368,7 +407,7 @@ interface ParticipantDto {
368
407
 
369
408
  Tất cả endpoint đều **yêu cầu JWT**.
370
409
 
371
- ### `list(params?): Promise<PaginatedResult<ConversationListItemDto>>`
410
+ ### `list(params?): Promise<CursorPaginatedResult<ConversationListItemDto>>`
372
411
 
373
412
  Lấy danh sách cuộc hội thoại với cursor-based pagination. Sắp xếp: pinned trước, sau đó theo `lastMessageAt` giảm dần.
374
413
 
@@ -388,7 +427,7 @@ if (result.hasMore) {
388
427
  }
389
428
  ```
390
429
 
391
- > ⚠️ `cursor` là chuỗi .NET DateTime ticks (ví dụ: `"638836680000000000"`, 18 chữ số). Vượt quá `Number.MAX_SAFE_INTEGER` — **KHÔNG được ép kiểu sang number**.
430
+ > ⚠️ `cursor` là chuỗi opaque dạng `"<UnixMs>_<ULID>"` (ví dụ: `"1743004200000_01JRZABC1234567890ABCDEF"`). Luôn truyền nguyên dạng string — **KHÔNG được ép kiểu sang number**.
392
431
 
393
432
  ### `create(request): Promise<ConversationDto>`
394
433
 
@@ -439,17 +478,29 @@ const updated = await client.conversations.update('conv-id', {
439
478
  });
440
479
  ```
441
480
 
442
- ### `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>`
492
+
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.
443
494
 
444
- Rời khỏi cuộc hội thoại. Các participant khác vẫn còn đó.
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.
445
496
 
446
497
  ```ts
447
- await client.conversations.leave('conv-id');
498
+ await client.conversations.leaveGroup('conv-id');
448
499
  ```
449
500
 
450
501
  ### `findOneToOne(otherParticipantId): Promise<ConversationDto | null>`
451
502
 
452
- 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).
453
504
 
454
505
  ```ts
455
506
  const existing = await client.conversations.findOneToOne('other-user-id');
@@ -489,16 +540,49 @@ await client.conversations.pin('conv-id');
489
540
  await client.conversations.unpin('conv-id');
490
541
  ```
491
542
 
492
- ### `markAsRead(id, messageId) / markAsUnread(id): Promise<void>`
543
+ ### `markAsRead(id, request) / markAsUnread(id): Promise<void>`
493
544
 
494
545
  ```ts
495
546
  // Đánh dấu đã đọc đến messageId
496
- await client.conversations.markAsRead('conv-id', 'last-message-id');
547
+ await client.conversations.markAsRead('conv-id', { messageId: 'last-message-id' });
497
548
 
498
549
  // Đánh dấu chưa đọc
499
550
  await client.conversations.markAsUnread('conv-id');
500
551
  ```
501
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
+
502
586
  ### Kiểu dữ liệu Conversation
503
587
 
504
588
  ```ts
@@ -507,13 +591,13 @@ type ConversationType = 'Group' | 'OneToOne' | 'Self';
507
591
  interface ConversationDto {
508
592
  id: string;
509
593
  type: ConversationType;
510
- name?: string; // Tên nhóm (chỉ Group)
511
- avatar?: MediaReference;
594
+ name?: string | null; // Tên nhóm (chỉ Group); null cho OneToOne/Self
595
+ avatar?: MediaReference | null;
512
596
  ownerId: string;
513
- lastMessageAt?: string; // ISO 8601
514
- lastMessageId?: string;
515
- createdAt: string; // ISO 8601
516
- updatedAt: string; // ISO 8601
597
+ lastMessageAt?: string | null; // ISO 8601, null khi chưa có tin nhắn
598
+ lastMessageId?: string | null; // null khi chưa có tin nhắn
599
+ createdAt: string; // ISO 8601
600
+ updatedAt: string; // ISO 8601
517
601
  participants: ConversationParticipantDto[];
518
602
  }
519
603
 
@@ -532,16 +616,59 @@ interface ConversationParticipantDto {
532
616
  interface ConversationListItemDto {
533
617
  id: string;
534
618
  type: ConversationType;
535
- name?: string;
536
- avatar?: MediaReference;
537
- lastMessageAt?: string; // ISO 8601
538
- lastMessageId?: string;
539
- lastMessage?: MessageDto; // Preview tin nhắn cuối
619
+ name?: string | null; // null cho OneToOne/Self
620
+ avatar?: MediaReference | null;
621
+ lastMessageAt?: string | null; // ISO 8601
622
+ lastMessageId?: string | null;
623
+ lastMessage?: MessageDto | null; // Preview tin nhắn cuối, null khi chưa có tin nhắn
540
624
  isPinned: boolean;
541
- pinnedAt?: string; // ISO 8601
625
+ pinnedAt?: string | null; // ISO 8601, null khi chưa pin
542
626
  unreadCount: number;
543
627
  participantCount: number;
544
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
+ }
545
672
  ```
546
673
 
547
674
  ---
@@ -568,7 +695,7 @@ const msg = await client.messages.send('conv-id', {
568
695
 
569
696
  > 💡 **REST vs SignalR:** Có thể gửi tin nhắn qua cả REST (`client.messages.send`) lẫn SignalR (`client.realtime.chat.sendMessage`). SignalR trả về ack ngay lập tức và nhanh hơn trong môi trường real-time.
570
697
 
571
- ### `getHistory(conversationId, params?): Promise<PaginatedResult<MessageDto>>`
698
+ ### `getHistory(conversationId, params?): Promise<CursorPaginatedResult<MessageDto>>`
572
699
 
573
700
  Lấy lịch sử tin nhắn với cursor-based pagination.
574
701
 
@@ -611,11 +738,12 @@ Chỉ người gửi mới có thể chỉnh sửa.
611
738
  ```ts
612
739
  const edited = await client.messages.edit('msg-id', {
613
740
  blocks: [{ $type: 'text', format: 'Plain', content: 'Nội dung đã sửa', plainText: 'Nội dung đã sửa' }],
614
- mentions: [], // optional
741
+ mentions: [], // optional
742
+ replyToMessageId: null, // optional — null để xóa reply reference
615
743
  });
616
744
  ```
617
745
 
618
- > ⚠️ **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 }`.
619
747
 
620
748
  ### `delete(messageId): Promise<{ messageId, deletedAt }>`
621
749
 
@@ -652,7 +780,6 @@ interface MessageDto {
652
780
  conversationId: string;
653
781
  senderId: string;
654
782
  senderType: SenderType;
655
- timestamp: string; // ISO 8601 — thời điểm gửi
656
783
  blocks: Block[]; // Nội dung tin nhắn
657
784
  plainTextIndex: string | null;
658
785
  replyToMessageId: string | null;
@@ -698,6 +825,7 @@ interface SendMessageRequest {
698
825
  interface EditMessageRequest {
699
826
  blocks: Block[];
700
827
  mentions?: Mention[];
828
+ replyToMessageId?: string | null; // null để xóa reply reference
701
829
  }
702
830
 
703
831
  // Request đánh dấu đã đọc (REST)
@@ -755,18 +883,37 @@ const result = await client.files.upload(uploadUrl, file, (loaded, total) => {
755
883
 
756
884
  > Khi có `onProgress`, SDK dùng XHR thay vì fetch (vì fetch không hỗ trợ upload progress events).
757
885
 
758
- ### `getDownloadUrl(storageKey: string): string`
886
+ ### `getDownloadUrl(storageKey, signed?): string`
759
887
 
760
888
  Tạo URL tải file tuyệt đối (dùng để hiển thị ảnh, tải xuống, v.v.).
761
889
 
762
890
  ```ts
891
+ // URL public (mặc định)
763
892
  const url = client.files.getDownloadUrl('01HXABCDEF/photo.jpg');
764
893
  // → "https://chat-api.example.com/api/files/01HXABCDEF/photo.jpg"
765
894
  ```
766
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
+
767
914
  ### `delete(storageKey: string): Promise<void>`
768
915
 
769
- 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.
770
917
 
771
918
  ```ts
772
919
  await client.files.delete('01HXABCDEF/photo.jpg');
@@ -807,7 +954,7 @@ GET (`list`, `getById`) là **public**. Các thao tác quản lý yêu cầu JWT
807
954
 
808
955
  ### `list(params?): Promise<PagedResult<BotDto>>`
809
956
 
810
- > 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.
811
958
 
812
959
  ```ts
813
960
  const result = await client.bots.list({ page: 1, pageSize: 20 });
@@ -817,17 +964,15 @@ console.log(result.totalCount); // Tổng số bot
817
964
 
818
965
  ### `getById(id): Promise<BotDto>`
819
966
 
820
- > Server cache kết quả **5 phút**.
821
-
822
967
  ```ts
823
968
  const bot = await client.bots.getById('bot-id');
824
969
  // Lấy thêm thông tin display: name, avatar
825
970
  const participant = await client.participants.getById(bot.participantId);
826
971
  ```
827
972
 
828
- ### `create(request): Promise<BotDto & { apiKey: string }>`
973
+ ### `create(request): Promise<BotDto>`
829
974
 
830
- 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.
831
976
 
832
977
  ```ts
833
978
  const bot = await client.bots.create({
@@ -835,10 +980,8 @@ const bot = await client.bots.create({
835
980
  fullName: 'My Assistant Bot',
836
981
  description: 'Trợ lý AI',
837
982
  kafkaTopic: 'my-bot-topic', // Không thể thay đổi sau khi tạo
838
- rateLimitPerSecond: 5,
839
983
  listenAllGroupMessages: false,
840
984
  });
841
- const apiKey = bot.apiKey; // Lưu ngay!
842
985
  ```
843
986
 
844
987
  ### `update(id, request): Promise<BotDto>`
@@ -846,7 +989,6 @@ const apiKey = bot.apiKey; // Lưu ngay!
846
989
  ```ts
847
990
  await client.bots.update('bot-id', {
848
991
  fullName: 'Updated Bot Name',
849
- rateLimitPerSecond: 10,
850
992
  // kafkaTopic KHÔNG thể cập nhật
851
993
  });
852
994
  ```
@@ -858,14 +1000,6 @@ await client.bots.activate('bot-id');
858
1000
  await client.bots.deactivate('bot-id');
859
1001
  ```
860
1002
 
861
- ### `regenerateKey(id): Promise<RegenerateKeyResponse>`
862
-
863
- Xoay API key — key cũ bị vô hiệu hóa ngay lập tức.
864
-
865
- ```ts
866
- const { apiKey } = await client.bots.regenerateKey('bot-id');
867
- ```
868
-
869
1003
  ### `delete(id): Promise<void>`
870
1004
 
871
1005
  Soft delete.
@@ -880,11 +1014,10 @@ await client.bots.delete('bot-id');
880
1014
  interface BotDto {
881
1015
  id: string;
882
1016
  participantId: string; // Dùng để lấy name/avatar từ ParticipantApi
883
- description?: string;
884
- metadata?: Record<string, unknown>;
1017
+ description?: string | null;
1018
+ metadata?: Record<string, unknown> | null;
885
1019
  kafkaTopic: string; // KHÔNG thể thay đổi sau khi tạo
886
1020
  isActive: boolean;
887
- rateLimitPerSecond: number;
888
1021
  listenAllGroupMessages: boolean;
889
1022
  createdBy: string; // ID của người tạo bot
890
1023
  createdAt: string; // ISO 8601
@@ -904,7 +1037,6 @@ interface CreateBotRequest {
904
1037
  description?: string;
905
1038
  metadata?: Record<string, unknown>;
906
1039
  kafkaTopic: string; // KHÔNG thể thay đổi sau khi tạo
907
- rateLimitPerSecond?: number;
908
1040
  listenAllGroupMessages?: boolean; // Mặc định false
909
1041
  }
910
1042
 
@@ -914,7 +1046,6 @@ interface UpdateBotRequest {
914
1046
  avatar?: MediaReference;
915
1047
  description?: string;
916
1048
  metadata?: Record<string, unknown>;
917
- rateLimitPerSecond?: number;
918
1049
  listenAllGroupMessages?: boolean;
919
1050
  // kafkaTopic KHÔNG thể cập nhật
920
1051
  }
@@ -938,7 +1069,34 @@ const order = await client.proxy.post<Order>('trading', 'api/orders', {
938
1069
  quantity: 100,
939
1070
  });
940
1071
 
941
- // 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)
942
1100
  const result = await client.proxy.request<unknown>('myservice', 'api/resource/123', {
943
1101
  method: 'DELETE',
944
1102
  headers: { 'X-Custom-Header': 'value' },
@@ -1034,6 +1192,12 @@ await client.realtime.chat.leaveConversation('conv-id');
1034
1192
  ```
1035
1193
 
1036
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.
1037
1201
 
1038
1202
  ### Gửi tin nhắn qua SignalR
1039
1203
 
@@ -1054,10 +1218,11 @@ if (ack.success) {
1054
1218
  ### Chỉnh sửa / Xóa / Khôi phục tin nhắn qua SignalR
1055
1219
 
1056
1220
  ```ts
1057
- // Chỉnh sửa — dùng newBlocks/newMentions (khác REST!)
1221
+ // Chỉnh sửa — dùng newBlocks/newMentions/newReplyToMessageId (khác REST!)
1058
1222
  const editAck = await client.realtime.chat.editMessage({
1059
1223
  messageId: 'msg-id',
1060
1224
  newBlocks: [{ $type: 'text', format: 'Plain', content: 'Đã sửa', plainText: 'Đã sửa' }],
1225
+ newReplyToMessageId: null, // optional — null để xóa reply reference
1061
1226
  });
1062
1227
 
1063
1228
  // Xóa — truyền object, KHÔNG phải string
@@ -1112,6 +1277,7 @@ client.realtime.chat.off('messageReceived', handler);
1112
1277
  | `messageUpdated` | `MessageUpdatedDto` | Tin nhắn được chỉnh sửa |
1113
1278
  | `messageDeleted` | `MessageDeletedDto` | Tin nhắn bị xóa |
1114
1279
  | `messageRecovered` | `ChatMessageDto` | Tin nhắn được khôi phục |
1280
+ | `messageThumbnailsReady` | `MessageThumbnailsReadyDto` | Server đã sinh xong thumbnail cho ảnh/video — patch theo `blockIndex` |
1115
1281
  | `reactionAdded` | `ReactionAddedDto` | Thêm reaction |
1116
1282
  | `reactionRemoved` | `ReactionRemovedDto` | Xóa reaction |
1117
1283
  | `typingStarted` | `TypingDto` | Người dùng đang gõ |
@@ -1137,7 +1303,8 @@ interface MessageUpdatedDto {
1137
1303
  blocks: Block[];
1138
1304
  plainTextContent: string | null;
1139
1305
  mentions?: Mention[];
1140
- updatedAt: string; // ISO 8601
1306
+ updatedAt: string; // ISO 8601
1307
+ replyToMessageId?: string | null; // null nếu reply reference đã bị xóa
1141
1308
  }
1142
1309
 
1143
1310
  // Tin nhắn bị xóa
@@ -1185,6 +1352,36 @@ interface ReadReceiptUpdatedDto {
1185
1352
  lastReadMessageId: string;
1186
1353
  readAt: string; // ISO 8601
1187
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
+ });
1188
1385
  ```
1189
1386
 
1190
1387
  ### Bot streaming
@@ -1262,7 +1459,7 @@ await client.realtime.notifications.resubscribePresence();
1262
1459
  ### Theo dõi trạng thái online (Presence)
1263
1460
 
1264
1461
  ```ts
1265
- // Đă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
1266
1463
  await client.realtime.notifications.subscribeToPresence(['user-1', 'user-2', 'user-3']);
1267
1464
 
1268
1465
  // Server gửi PresenceState ngay lập tức sau khi subscribe
@@ -1277,7 +1474,7 @@ client.realtime.notifications.on('presenceChanged', (dto) => {
1277
1474
  console.log(dto.participantId, 'is now', dto.isOnline ? 'online' : 'offline');
1278
1475
  });
1279
1476
 
1280
- // 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
1281
1478
  await client.realtime.notifications.unsubscribeFromPresence(['user-1']);
1282
1479
  ```
1283
1480
 
@@ -1299,6 +1496,8 @@ await client.realtime.notifications.unsubscribeFromPresence(['user-1']);
1299
1496
  | `conversationPinned` | `ConversationPinnedDto` | Cuộc hội thoại được pin |
1300
1497
  | `conversationUnpinned` | `ConversationUnpinnedDto` | Cuộc hội thoại được unpin |
1301
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) |
1302
1501
  | `error` | `HubErrorDto` | Lỗi từ server |
1303
1502
  | `reconnecting` | `Error \| undefined` | Đang reconnect |
1304
1503
  | `reconnected` | `string \| undefined` | Đã reconnect |
@@ -1311,24 +1510,24 @@ await client.realtime.notifications.unsubscribeFromPresence(['user-1']);
1311
1510
  interface PresenceStateItem {
1312
1511
  participantId: string;
1313
1512
  isOnline: boolean;
1314
- lastSeenAt?: string; // ISO 8601, undefined khi đang online
1513
+ lastSeenAt?: string | null; // ISO 8601, null khi đang online
1315
1514
  }
1316
1515
 
1317
1516
  // Thay đổi trạng thái online — real-time update
1318
1517
  interface PresenceChangedDto {
1319
1518
  participantId: string;
1320
1519
  isOnline: boolean;
1321
- lastSeenAt?: string; // ISO 8601, undefined khi đang online
1520
+ lastSeenAt?: string | null; // ISO 8601, null khi đang online
1322
1521
  }
1323
1522
 
1324
1523
  interface NewMessageNotificationDto {
1325
1524
  conversationId: string;
1326
1525
  conversationType: ConversationType;
1327
- conversationName?: string;
1526
+ conversationName?: string | null;
1328
1527
  messageId: string;
1329
1528
  senderId: string;
1330
1529
  senderName: string;
1331
- senderAvatar?: MediaReference;
1530
+ senderAvatar?: MediaReference | null; // Có thể null khi server chưa sẵn signed URL
1332
1531
  contentPreview: string;
1333
1532
  sentAt: string; // ISO 8601
1334
1533
  }
@@ -1353,15 +1552,15 @@ interface UnreadCountChangedDto {
1353
1552
  interface ConversationCreatedDto {
1354
1553
  conversationId: string;
1355
1554
  type: ConversationType;
1356
- name?: string;
1357
- avatar?: MediaReference;
1555
+ name?: string | null;
1556
+ avatar?: MediaReference | null;
1358
1557
  participantCount: number;
1359
1558
  }
1360
1559
 
1361
1560
  interface ConversationUpdatedDto {
1362
1561
  conversationId: string;
1363
1562
  type: ConversationType;
1364
- name?: string;
1563
+ name?: string | null;
1365
1564
  avatar?: MediaReference | null; // null = avatar đã bị xóa
1366
1565
  participantCount: number;
1367
1566
  }
@@ -1370,7 +1569,7 @@ interface ParticipantJoinedDto {
1370
1569
  conversationId: string;
1371
1570
  participantId: string;
1372
1571
  participantName: string;
1373
- participantAvatar?: MediaReference;
1572
+ participantAvatar?: MediaReference | null; // Có thể null khi server chưa sẵn signed URL
1374
1573
  changedAt: string; // ISO 8601
1375
1574
  }
1376
1575
 
@@ -1379,7 +1578,7 @@ interface ParticipantLeftDto {
1379
1578
  conversationId: string;
1380
1579
  participantId: string;
1381
1580
  participantName: string;
1382
- participantAvatar?: MediaReference;
1581
+ participantAvatar?: MediaReference | null; // Có thể null khi server chưa sẵn signed URL
1383
1582
  changedAt: string; // ISO 8601
1384
1583
  }
1385
1584
 
@@ -1399,6 +1598,26 @@ interface ReadStatusChangedDto {
1399
1598
  unreadCount: number;
1400
1599
  markedAsUnread: boolean; // true khi user chủ động đánh dấu chưa đọc
1401
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
+ }
1402
1621
  ```
1403
1622
 
1404
1623
  ---
@@ -1408,7 +1627,7 @@ interface ReadStatusChangedDto {
1408
1627
  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.
1409
1628
 
1410
1629
  ```ts
1411
- import { ReconnectionManager } from 'chat-client-sdk';
1630
+ import { ReconnectionManager } from '@manonero/chat-client-sdk';
1412
1631
 
1413
1632
  const manager = new ReconnectionManager({
1414
1633
  chatHub: client.realtime.chat,
@@ -1417,7 +1636,7 @@ const manager = new ReconnectionManager({
1417
1636
  // Gọi API refresh token của ứng dụng
1418
1637
  const newToken = await refreshToken();
1419
1638
  if (newToken) {
1420
- 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
1421
1640
  }
1422
1641
  return newToken; // Trả về null để hủy reconnect
1423
1642
  },
@@ -1431,7 +1650,9 @@ manager.stop();
1431
1650
 
1432
1651
  **Chiến lược backoff:** 3 lần thử với delay 2s → 5s → 10s.
1433
1652
 
1434
- **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.
1435
1656
 
1436
1657
  ---
1437
1658
 
@@ -1577,7 +1798,7 @@ interface CustomBlock {
1577
1798
  SDK xuất sẵn các hàm kiểm tra kiểu (type guard), giúp TypeScript thu hẹp kiểu tự động:
1578
1799
 
1579
1800
  ```ts
1580
- import { isTextBlock, isImageBlock, isFileBlock, isCardBlock } from 'chat-client-sdk';
1801
+ import { isTextBlock, isImageBlock, isFileBlock, isCardBlock } from '@manonero/chat-client-sdk';
1581
1802
 
1582
1803
  for (const block of message.blocks) {
1583
1804
  if (isTextBlock(block)) {
@@ -1645,7 +1866,7 @@ type ButtonStyle = 'Default' | 'Primary' | 'Danger';
1645
1866
  interface ActionButton {
1646
1867
  label: string;
1647
1868
  action: ButtonAction;
1648
- value: string | null;
1869
+ value?: string | null;
1649
1870
  style: ButtonStyle;
1650
1871
  }
1651
1872
 
@@ -1704,12 +1925,12 @@ interface Mention {
1704
1925
 
1705
1926
  ## 16. Toàn bộ kiểu dữ liệu (Types)
1706
1927
 
1707
- ### PaginatedResult (cursor-based)
1928
+ ### CursorPaginatedResult (cursor-based)
1708
1929
 
1709
1930
  ```ts
1710
- interface PaginatedResult<T> {
1931
+ interface CursorPaginatedResult<T> {
1711
1932
  items: T[];
1712
- nextCursor: string | null; // PHẢI là string — KHÔNG cast sang number
1933
+ nextCursor: string | null; // PHẢI là string opaque — KHÔNG cast sang number
1713
1934
  hasMore: boolean;
1714
1935
  }
1715
1936
  ```
@@ -1729,8 +1950,34 @@ interface PagedResult<T> {
1729
1950
 
1730
1951
  ```ts
1731
1952
  interface MediaReference {
1732
- storageKey?: string; // Key nội bộ — dùng FileApi.getDownloadUrl() để tạo URL
1733
- 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;
1734
1981
  }
1735
1982
  ```
1736
1983
 
@@ -1744,7 +1991,7 @@ interface ChatMessageDto {
1744
1991
  conversationId: string;
1745
1992
  senderId: string;
1746
1993
  senderName: string; // Có ở SignalR, không có ở REST
1747
- 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
1748
1995
  senderType: SenderType; // Có ở cả REST và SignalR
1749
1996
  blocks: Block[];
1750
1997
  plainTextContent: string | null; // Tên khác với REST (plainTextIndex)
@@ -1777,13 +2024,15 @@ type SystemEventType =
1777
2024
  | 'MemberAdded' // Thêm thành viên
1778
2025
  | 'MemberRemoved' // Xóa thành viên
1779
2026
  | 'MemberLeft' // Thành viên tự rời
1780
- | 'Custom';
2027
+ | 'Custom'; // Sự kiện tùy chỉnh do server định nghĩa
1781
2028
 
1782
2029
  interface SystemEventInfo {
1783
2030
  type: SystemEventType;
1784
- 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)
1785
2032
  targetIds?: string[];
1786
- 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;
1787
2036
  }
1788
2037
 
1789
2038
  // SignalR (ChatHub) version — kế thừa SystemEventInfo, thêm display names
@@ -1799,7 +2048,8 @@ interface SystemEventDto extends SystemEventInfo {
1799
2048
  interface HubErrorDto {
1800
2049
  code: string;
1801
2050
  message: string;
1802
- 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;
1803
2053
  }
1804
2054
  ```
1805
2055
 
@@ -1812,8 +2062,8 @@ interface StreamStartedDto {
1812
2062
  conversationId: string;
1813
2063
  senderId: string;
1814
2064
  senderName: string;
1815
- senderAvatar?: MediaReference;
1816
- replyToMessageId?: string;
2065
+ senderAvatar?: MediaReference | null; // Có thể null khi server chưa sẵn signed URL
2066
+ replyToMessageId?: string | null;
1817
2067
  startedAt: string; // ISO 8601
1818
2068
  }
1819
2069
 
@@ -1860,7 +2110,7 @@ interface SendMessageAck {
1860
2110
  success: boolean;
1861
2111
  messageId?: string;
1862
2112
  clientMessageId?: string;
1863
- timestamp?: string; // ISO 8601
2113
+ createdAt?: string; // ISO 8601 — thời điểm server tạo tin nhắn
1864
2114
  errorCode?: string;
1865
2115
  errorMessage?: string;
1866
2116
  }
@@ -1911,7 +2161,7 @@ interface MarkAsReadAck {
1911
2161
  Tất cả lỗi HTTP đều throw `ChatApiError` (kế thừa `Error`).
1912
2162
 
1913
2163
  ```ts
1914
- import { ChatApiError } from 'chat-client-sdk';
2164
+ import { ChatApiError } from '@manonero/chat-client-sdk';
1915
2165
 
1916
2166
  try {
1917
2167
  await client.messages.send('conv-id', { blocks: [...] });
@@ -1919,7 +2169,17 @@ try {
1919
2169
  if (err instanceof ChatApiError) {
1920
2170
  console.error(`HTTP ${err.status}: ${err.title}`);
1921
2171
  console.error('Chi tiết:', err.detail);
1922
-
2172
+
2173
+ // Xem tất cả lỗi validation chi tiết
2174
+ if (err.errors) {
2175
+ err.errors.forEach(e => console.error(`[${e.code}] ${e.description}`));
2176
+ }
2177
+
2178
+ // Dùng traceId để correlate log
2179
+ if (err.traceId) {
2180
+ console.error('TraceId:', err.traceId);
2181
+ }
2182
+
1923
2183
  switch (err.status) {
1924
2184
  case 401: // Unauthorized — token hết hạn/không hợp lệ
1925
2185
  await refreshAndRetry();
@@ -1942,12 +2202,17 @@ try {
1942
2202
  | Thuộc tính | Kiểu | Mô tả |
1943
2203
  |------------|------|-------|
1944
2204
  | `status` | `number` | HTTP status code |
1945
- | `title` | `string` | Tiêu đề lỗi (từ RFC 7807 `title`) |
1946
- | `detail` | `string \| undefined` | Chi tiết lỗi (từ RFC 7807 `detail`) |
2205
+ | `title` | `string` | Tiêu đề lỗi giá trị `ErrorType` enum: `Validation`, `NotFound`, `Conflict`, `Forbidden`, `Unauthorized`, `Unexpected`. Auth endpoints trả `Bad Request`, v.v. |
2206
+ | `detail` | `string \| undefined` | tả của lỗi đầu tiên (từ RFC 7807 `detail`) |
2207
+ | `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) |
2208
+ | `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ộ |
2209
+ | `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ộ |
1947
2210
  | `message` | `string` | `detail ?? title` (kế thừa từ `Error`) |
1948
2211
  | `name` | `string` | Luôn là `"ChatApiError"` |
1949
2212
 
1950
- 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.
2213
+ 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.
2214
+
2215
+ > **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`.
1951
2216
 
1952
2217
  ### Static methods
1953
2218
 
@@ -1960,8 +2225,8 @@ Parse lỗi từ HTTP `Response`. SDK gọi nội bộ — bạn không cần g
1960
2225
  Tạo `ChatApiError` từ object `ProblemDetails` (RFC 7807). Hữu ích khi bạn nhận `ProblemDetails` từ nguồn khác (ví dụ: WebSocket, tự parse).
1961
2226
 
1962
2227
  ```ts
1963
- import { ChatApiError } from 'chat-client-sdk';
1964
- import type { ProblemDetails } from 'chat-client-sdk';
2228
+ import { ChatApiError } from '@manonero/chat-client-sdk';
2229
+ import type { ProblemDetails } from '@manonero/chat-client-sdk';
1965
2230
 
1966
2231
  const pd: ProblemDetails = { status: 404, title: 'Not Found', detail: 'Conversation not found' };
1967
2232
  const error = ChatApiError.fromProblemDetails(pd);
@@ -1978,7 +2243,16 @@ interface ProblemDetails {
1978
2243
  status: number;
1979
2244
  title: string;
1980
2245
  detail?: string;
1981
- type?: string; // URI referencethường không dùng trực tiếp
2246
+ type?: string; // URI tham chiếu (RFC 7807) `ChatApiError.fromProblemDetails` sẽ forward sang `error.type`
2247
+ errors?: ProblemError[]; // Có khi lỗi đi qua handler; vắng mặt cho lỗi middleware/filter
2248
+ traceId?: string; // W3C trace ID — tự động thêm bởi framework
2249
+ }
2250
+
2251
+ // Một phần tử trong mảng `errors`
2252
+ interface ProblemError {
2253
+ code: string; // Mã định danh lỗi, ví dụ: "Bot.NotFound", "UniqueName"
2254
+ description: string; // Mô tả chi tiết lỗi
2255
+ type: string; // ErrorType: "Validation", "NotFound", "Conflict", "Forbidden", "Unauthorized", "Unexpected"
1982
2256
  }
1983
2257
  ```
1984
2258
 
@@ -1989,7 +2263,7 @@ interface ProblemDetails {
1989
2263
  Power users có thể khởi tạo `ChatHubClient` hoặc `NotificationHubClient` trực tiếp, không qua `ChatClient` facade:
1990
2264
 
1991
2265
  ```ts
1992
- import { ChatHubClient, NotificationHubClient } from 'chat-client-sdk';
2266
+ import { ChatHubClient, NotificationHubClient } from '@manonero/chat-client-sdk';
1993
2267
  import { LogLevel } from '@microsoft/signalr';
1994
2268
 
1995
2269
  // Tự quản lý token
@@ -2012,7 +2286,7 @@ await chatHub.connect();
2012
2286
  await notificationHub.connect();
2013
2287
 
2014
2288
  // Kết hợp với ReconnectionManager
2015
- import { ReconnectionManager } from 'chat-client-sdk';
2289
+ import { ReconnectionManager } from '@manonero/chat-client-sdk';
2016
2290
 
2017
2291
  const manager = new ReconnectionManager({
2018
2292
  chatHub,
@@ -2076,33 +2350,63 @@ const cursor = Number(result.nextCursor); // BUG!
2076
2350
 
2077
2351
  - `setToken()` hoạt động ngay cho **HTTP requests**.
2078
2352
  - SignalR đọc token khi **connect()**, không phải mid-session.
2079
- - Để cập nhật token cho SignalR: `disconnect()` `setToken()` → `connect()`.
2353
+ - Để áp token mới ngay cho hub đang Connected: `setToken(newToken)` → `await refreshConnections()`.
2354
+ - 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.
2355
+ - 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.
2080
2356
 
2081
2357
  ### REST vs SignalR — field name khác nhau
2082
2358
 
2083
2359
  | Thao tác | REST | SignalR |
2084
2360
  |----------|------|---------|
2085
- | Edit message | `{ blocks, mentions }` | `{ messageId, newBlocks, newMentions }` |
2361
+ | Edit message | `{ blocks, mentions, replyToMessageId? }` | `{ messageId, newBlocks, newMentions, newReplyToMessageId? }` |
2086
2362
  | Delete message | `messages.delete(messageId)` | `deleteMessage({ messageId })` — object, không phải string |
2087
2363
  | Stream completion | Không có event | `streamCompleted` thay `messageReceived` |
2088
2364
 
2089
2365
  ### File upload — storageKey vs URL
2090
2366
 
2367
+ **Khi gửi tin nhắn (client → server):** dùng `storageKey` trong block, không dùng `url`.
2368
+
2091
2369
  ```ts
2092
- // Sau khi upload, dùng storageKey làm MediaReference
2093
2370
  const { storageKey } = await client.files.uploadFile(file);
2094
2371
 
2095
- // Trong block:
2096
2372
  const block: ImageBlock = {
2097
2373
  $type: 'image',
2098
- source: { storageKey }, // KHÔNG phải url
2374
+ source: { storageKey }, // ĐÚNG — KHÔNG đặt url ở đây
2099
2375
  // ...
2100
2376
  };
2377
+ ```
2378
+
2379
+ **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:
2380
+
2381
+ ```ts
2382
+ // resolve MediaReference → URL tuyệt đối để dùng trong <img src>, <a href>, v.v.
2383
+ function resolveMediaRef(
2384
+ ref: MediaReference | null | undefined,
2385
+ baseUrl: string,
2386
+ getDownloadUrl: (key: string) => string,
2387
+ ): string | null {
2388
+ if (!ref) return null;
2389
+ if (ref.url) {
2390
+ // url có thể là relative signed URL ("/api/files/...?sig=...")
2391
+ // hoặc absolute external URL ("https://...")
2392
+ return ref.url.startsWith('/') ? baseUrl.replace(/\/$/, '') + ref.url : ref.url;
2393
+ }
2394
+ if (ref.storageKey) return getDownloadUrl(ref.storageKey);
2395
+ return null;
2396
+ }
2101
2397
 
2102
- // Để hiển thị ảnh:
2103
- const displayUrl = client.files.getDownloadUrl(storageKey);
2398
+ // dụ dùng với ImageBlock:
2399
+ const displayUrl = resolveMediaRef(
2400
+ block.source,
2401
+ 'https://chat-api.example.com',
2402
+ client.files.getDownloadUrl.bind(client.files),
2403
+ );
2104
2404
  ```
2105
2405
 
2406
+ > **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.
2407
+
2408
+ > **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.
2409
+
2106
2410
  ### BotDto — không có tên/avatar
2107
2411
 
2108
2412
  `BotDto` **không** chứa `uniqueName` hay `fullName`. Phải gọi thêm:
@@ -2131,8 +2435,8 @@ const ms = Number(health.totalDuration); // NaN
2131
2435
  ### Ví dụ 1: Chat app cơ bản
2132
2436
 
2133
2437
  ```ts
2134
- import { ChatClient, ChatApiError } from 'chat-client-sdk';
2135
- import type { ChatMessageDto } from 'chat-client-sdk';
2438
+ import { ChatClient, ChatApiError } from '@manonero/chat-client-sdk';
2439
+ import type { ChatMessageDto } from '@manonero/chat-client-sdk';
2136
2440
 
2137
2441
  // 1. Khởi tạo
2138
2442
  const client = new ChatClient({ baseUrl: 'https://chat-api.example.com' });
@@ -2302,7 +2606,7 @@ client.realtime.chat.on('streamAborted', (dto) => {
2302
2606
  ### Ví dụ 5: Xử lý lỗi và token refresh
2303
2607
 
2304
2608
  ```ts
2305
- import { ReconnectionManager } from 'chat-client-sdk';
2609
+ import { ReconnectionManager } from '@manonero/chat-client-sdk';
2306
2610
 
2307
2611
  let isRefreshing = false;
2308
2612