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

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 (110) hide show
  1. package/README.md +2343 -0
  2. package/dist/ChatClient.d.ts +122 -0
  3. package/dist/ChatClient.d.ts.map +1 -0
  4. package/dist/ChatClient.js +173 -0
  5. package/dist/ChatClient.js.map +1 -0
  6. package/dist/errors/ChatApiError.d.ts +22 -0
  7. package/dist/errors/ChatApiError.d.ts.map +1 -0
  8. package/dist/errors/ChatApiError.js +50 -0
  9. package/dist/errors/ChatApiError.js.map +1 -0
  10. package/dist/http/AuthApi.d.ts +26 -0
  11. package/dist/http/AuthApi.d.ts.map +1 -0
  12. package/dist/http/AuthApi.js +35 -0
  13. package/dist/http/AuthApi.js.map +1 -0
  14. package/dist/http/BotApi.d.ts +50 -0
  15. package/dist/http/BotApi.d.ts.map +1 -0
  16. package/dist/http/BotApi.js +67 -0
  17. package/dist/http/BotApi.js.map +1 -0
  18. package/dist/http/ConversationApi.d.ts +81 -0
  19. package/dist/http/ConversationApi.d.ts.map +1 -0
  20. package/dist/http/ConversationApi.js +115 -0
  21. package/dist/http/ConversationApi.js.map +1 -0
  22. package/dist/http/FileApi.d.ts +48 -0
  23. package/dist/http/FileApi.d.ts.map +1 -0
  24. package/dist/http/FileApi.js +68 -0
  25. package/dist/http/FileApi.js.map +1 -0
  26. package/dist/http/HealthApi.d.ts +26 -0
  27. package/dist/http/HealthApi.d.ts.map +1 -0
  28. package/dist/http/HealthApi.js +31 -0
  29. package/dist/http/HealthApi.js.map +1 -0
  30. package/dist/http/HttpClient.d.ts +51 -0
  31. package/dist/http/HttpClient.d.ts.map +1 -0
  32. package/dist/http/HttpClient.js +182 -0
  33. package/dist/http/HttpClient.js.map +1 -0
  34. package/dist/http/MessageApi.d.ts +67 -0
  35. package/dist/http/MessageApi.d.ts.map +1 -0
  36. package/dist/http/MessageApi.js +91 -0
  37. package/dist/http/MessageApi.js.map +1 -0
  38. package/dist/http/ParticipantApi.d.ts +28 -0
  39. package/dist/http/ParticipantApi.d.ts.map +1 -0
  40. package/dist/http/ParticipantApi.js +37 -0
  41. package/dist/http/ParticipantApi.js.map +1 -0
  42. package/dist/http/ProxyApi.d.ts +41 -0
  43. package/dist/http/ProxyApi.d.ts.map +1 -0
  44. package/dist/http/ProxyApi.js +57 -0
  45. package/dist/http/ProxyApi.js.map +1 -0
  46. package/dist/index.d.ts +26 -0
  47. package/dist/index.d.ts.map +1 -0
  48. package/dist/index.js +18 -0
  49. package/dist/index.js.map +1 -0
  50. package/dist/realtime/ChatHubClient.d.ts +87 -0
  51. package/dist/realtime/ChatHubClient.d.ts.map +1 -0
  52. package/dist/realtime/ChatHubClient.js +182 -0
  53. package/dist/realtime/ChatHubClient.js.map +1 -0
  54. package/dist/realtime/NotificationHubClient.d.ts +73 -0
  55. package/dist/realtime/NotificationHubClient.d.ts.map +1 -0
  56. package/dist/realtime/NotificationHubClient.js +162 -0
  57. package/dist/realtime/NotificationHubClient.js.map +1 -0
  58. package/dist/realtime/ReconnectionManager.d.ts +65 -0
  59. package/dist/realtime/ReconnectionManager.d.ts.map +1 -0
  60. package/dist/realtime/ReconnectionManager.js +118 -0
  61. package/dist/realtime/ReconnectionManager.js.map +1 -0
  62. package/dist/types/auth.d.ts +28 -0
  63. package/dist/types/auth.d.ts.map +1 -0
  64. package/dist/types/auth.js +3 -0
  65. package/dist/types/auth.js.map +1 -0
  66. package/dist/types/block.d.ts +148 -0
  67. package/dist/types/block.d.ts.map +1 -0
  68. package/dist/types/block.js +47 -0
  69. package/dist/types/block.js.map +1 -0
  70. package/dist/types/bot.d.ts +48 -0
  71. package/dist/types/bot.d.ts.map +1 -0
  72. package/dist/types/bot.js +3 -0
  73. package/dist/types/bot.js.map +1 -0
  74. package/dist/types/chat-events.d.ts +153 -0
  75. package/dist/types/chat-events.d.ts.map +1 -0
  76. package/dist/types/chat-events.js +3 -0
  77. package/dist/types/chat-events.js.map +1 -0
  78. package/dist/types/common.d.ts +57 -0
  79. package/dist/types/common.d.ts.map +1 -0
  80. package/dist/types/common.js +3 -0
  81. package/dist/types/common.js.map +1 -0
  82. package/dist/types/conversation.d.ts +70 -0
  83. package/dist/types/conversation.d.ts.map +1 -0
  84. package/dist/types/conversation.js +3 -0
  85. package/dist/types/conversation.js.map +1 -0
  86. package/dist/types/file.d.ts +31 -0
  87. package/dist/types/file.d.ts.map +1 -0
  88. package/dist/types/file.js +3 -0
  89. package/dist/types/file.js.map +1 -0
  90. package/dist/types/message.d.ts +83 -0
  91. package/dist/types/message.d.ts.map +1 -0
  92. package/dist/types/message.js +3 -0
  93. package/dist/types/message.js.map +1 -0
  94. package/dist/types/notification-events.d.ts +97 -0
  95. package/dist/types/notification-events.d.ts.map +1 -0
  96. package/dist/types/notification-events.js +3 -0
  97. package/dist/types/notification-events.js.map +1 -0
  98. package/dist/types/participant.d.ts +25 -0
  99. package/dist/types/participant.d.ts.map +1 -0
  100. package/dist/types/participant.js +3 -0
  101. package/dist/types/participant.js.map +1 -0
  102. package/dist/types/signalr.d.ts +87 -0
  103. package/dist/types/signalr.d.ts.map +1 -0
  104. package/dist/types/signalr.js +3 -0
  105. package/dist/types/signalr.js.map +1 -0
  106. package/dist/utils/TypedEventEmitter.d.ts +32 -0
  107. package/dist/utils/TypedEventEmitter.d.ts.map +1 -0
  108. package/dist/utils/TypedEventEmitter.js +60 -0
  109. package/dist/utils/TypedEventEmitter.js.map +1 -0
  110. package/package.json +40 -0
package/README.md ADDED
@@ -0,0 +1,2343 @@
1
+ # ChatClientSdk — Tài liệu hướng dẫn sử dụng
2
+
3
+ > SDK TypeScript để tích hợp với Chat Gateway API. Bao gồm toàn bộ HTTP REST API và kết nối real-time qua SignalR.
4
+
5
+ ---
6
+
7
+ ## Mục lục
8
+
9
+ 1. [Tổng quan kiến trúc](#1-tổng-quan-kiến-trúc)
10
+ 2. [Cài đặt và khởi tạo](#2-cài-đặt-và-khởi-tạo)
11
+ 3. [ChatClient — Facade chính](#3-chatclient--facade-chính)
12
+ 4. [Xác thực — AuthApi](#4-xác-thực--authapi)
13
+ 5. [Participant — ParticipantApi](#5-participant--participantapi)
14
+ 6. [Conversation — ConversationApi](#6-conversation--conversationapi)
15
+ 7. [Message — MessageApi](#7-message--messageapi)
16
+ 8. [File — FileApi](#8-file--fileapi)
17
+ 9. [Bot — BotApi](#9-bot--botapi)
18
+ 10. [Proxy — ProxyApi](#10-proxy--proxyapi)
19
+ 11. [Health — HealthApi](#11-health--healthapi)
20
+ 12. [Real-time: ChatHubClient](#12-real-time-chathubclient)
21
+ 13. [Real-time: NotificationHubClient](#13-real-time-notificationhubclient)
22
+ 14. [ReconnectionManager](#14-reconnectionmanager)
23
+ 15. [Hệ thống Block (nội dung tin nhắn)](#15-hệ-thống-block-nội-dung-tin-nhắn)
24
+ 16. [Toàn bộ kiểu dữ liệu (Types)](#16-toàn-bộ-kiểu-dữ-liệu-types)
25
+ 17. [Xử lý lỗi — ChatApiError](#17-xử-lý-lỗi--chatapierror)
26
+ 18. [TypedEventEmitter](#18-typedeventemitter)
27
+ 19. [Lưu ý quan trọng](#19-lưu-ý-quan-trọng)
28
+ 20. [Ví dụ đầy đủ](#20-ví-dụ-đầy-đủ)
29
+
30
+ ---
31
+
32
+ ## 1. Tổng quan kiến trúc
33
+
34
+ ```
35
+ ChatClient (facade chính)
36
+ ├── auth → AuthApi (xác thực)
37
+ ├── participants → ParticipantApi (quản lý người dùng)
38
+ ├── conversations → ConversationApi (quản lý cuộc hội thoại)
39
+ ├── messages → MessageApi (tin nhắn)
40
+ ├── files → FileApi (upload/download file)
41
+ ├── bots → BotApi (quản lý bot)
42
+ ├── proxy → ProxyApi (proxy đến backend khác)
43
+ ├── health → HealthApi (kiểm tra sức khỏe server)
44
+ └── realtime
45
+ ├── chat → ChatHubClient (sự kiện tin nhắn real-time)
46
+ └── notifications → NotificationHubClient (thông báo real-time)
47
+ ```
48
+
49
+ **Luồng hoạt động cơ bản:**
50
+
51
+ 1. Khởi tạo `ChatClient` với `baseUrl`.
52
+ 2. Đăng nhập qua `client.auth.loginWith*()` — token được tự động lưu và truyền vào mọi request sau.
53
+ 3. Kết nối các hub SignalR qua `client.realtime.notifications.connect()` và `client.realtime.chat.connect()`.
54
+ 4. Lắng nghe sự kiện real-time bằng `.on(eventName, handler)`.
55
+ 5. Gọi các phương thức REST hoặc Hub để thực hiện thao tác.
56
+
57
+ ---
58
+
59
+ ## 2. Cài đặt và khởi tạo
60
+
61
+ ### Installation
62
+
63
+ ```bash
64
+ npm install chat-client-sdk
65
+ npm install @microsoft/signalr # peer dependency
66
+ ```
67
+
68
+ ### Import
69
+
70
+ ```ts
71
+ import { ChatClient } from 'chat-client-sdk';
72
+ ```
73
+
74
+ Tất cả các kiểu dữ liệu đều được export từ package root:
75
+
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
79
+ ```
80
+
81
+ **Export nâng cao** — SDK cũng export một số class và type nội bộ cho power users:
82
+
83
+ ```ts
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';
87
+
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';
91
+
92
+ // Event map types — dùng khi cần typed wrapper
93
+ import type { ChatHubEventMap, NotificationHubEventMap } from 'chat-client-sdk';
94
+
95
+ // Hub options (khi dùng hub client độc lập, không qua ChatClient)
96
+ import type { ChatHubClientOptions, NotificationHubClientOptions } from 'chat-client-sdk';
97
+
98
+ // Hub request types (Client → Server) — dùng khi gọi SignalR methods
99
+ import type {
100
+ ChatSendMessageRequest,
101
+ ChatEditMessageRequest,
102
+ ChatDeleteMessageRequest,
103
+ ChatRecoverMessageRequest,
104
+ ChatAddReactionRequest,
105
+ ChatRemoveReactionRequest,
106
+ } from 'chat-client-sdk';
107
+
108
+ // Ack types (Server → Client return) — dùng khi cần type return value của Hub methods
109
+ import type {
110
+ SendMessageAck,
111
+ EditMessageAck,
112
+ DeleteMessageAck,
113
+ RecoverMessageAck,
114
+ ReactionAck,
115
+ MarkAsReadAck,
116
+ HubErrorDto,
117
+ } from 'chat-client-sdk';
118
+
119
+ // ReconnectionManager options
120
+ import type { ReconnectionManagerOptions } from 'chat-client-sdk';
121
+
122
+ // ChatClient facade types
123
+ import type { ChatClientSignalrOptions, ChatClientRealtime } from 'chat-client-sdk';
124
+
125
+ // Upload progress callback type — dùng khi cần type hàm onProgress
126
+ import type { UploadProgressCallback } from 'chat-client-sdk';
127
+
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';
130
+
131
+ // Mark as read request type (REST)
132
+ import type { MarkAsReadRequest } from 'chat-client-sdk';
133
+
134
+ // Login request types riêng lẻ (khi cần type-check từng provider)
135
+ import type {
136
+ GoogleLoginRequest,
137
+ DsAccountLoginRequest,
138
+ DevLoginRequest,
139
+ LoginRequest,
140
+ } from 'chat-client-sdk';
141
+
142
+ // Standalone hub clients — dùng khi không cần ChatClient facade
143
+ import { ChatHubClient, NotificationHubClient, ReconnectionManager } from 'chat-client-sdk';
144
+ ```
145
+
146
+ ### Khởi tạo cơ bản
147
+
148
+ ```ts
149
+ const client = new ChatClient({
150
+ baseUrl: 'https://chat-api.example.com',
151
+ });
152
+ ```
153
+
154
+ ### Khởi tạo với token có sẵn (đã đăng nhập từ trước)
155
+
156
+ ```ts
157
+ const client = new ChatClient({
158
+ baseUrl: 'https://chat-api.example.com',
159
+ token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
160
+ });
161
+ ```
162
+
163
+ ### Khởi tạo với external token provider
164
+
165
+ Dùng khi token được quản lý bên ngoài SDK (ví dụ: Redux store):
166
+
167
+ ```ts
168
+ const client = new ChatClient({
169
+ baseUrl: 'https://chat-api.example.com',
170
+ tokenProvider: () => store.getState().auth.token,
171
+ });
172
+ ```
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.
175
+
176
+ ### Cấu hình SignalR log level
177
+
178
+ ```ts
179
+ import { LogLevel } from '@microsoft/signalr';
180
+
181
+ const client = new ChatClient({
182
+ baseUrl: 'https://chat-api.example.com',
183
+ signalrOptions: {
184
+ logLevel: LogLevel.Debug, // Mặc định: LogLevel.Warning
185
+ },
186
+ });
187
+ ```
188
+
189
+ ---
190
+
191
+ ## 3. ChatClient — Facade chính
192
+
193
+ **Class:** `ChatClient`
194
+
195
+ Đây là điểm vào duy nhất của SDK. Quản lý token JWT dùng chung cho tất cả HTTP request và kết nối SignalR.
196
+
197
+ ### Constructor
198
+
199
+ ```ts
200
+ new ChatClient(options: ChatClientOptions)
201
+ ```
202
+
203
+ | Option | Kiểu | Bắt buộc | Mô tả |
204
+ |--------|------|----------|-------|
205
+ | `baseUrl` | `string` | ✅ | URL gốc của server, ví dụ `"https://chat-api.example.com"` |
206
+ | `token` | `string` | ❌ | JWT token ban đầu (nếu đã đăng nhập) |
207
+ | `tokenProvider` | `() => string \| null` | ❌ | Hàm lấy token từ bên ngoài |
208
+ | `signalrOptions.logLevel` | `LogLevel` | ❌ | Mức log cho SignalR (mặc định: `Warning`) |
209
+
210
+ ### Thuộc tính
211
+
212
+ | Thuộc tính | Kiểu | Mô tả |
213
+ |------------|------|-------|
214
+ | `auth` | `AuthApi` | Các endpoint xác thực |
215
+ | `participants` | `ParticipantApi` | Quản lý participant |
216
+ | `conversations` | `ConversationApi` | Quản lý cuộc hội thoại |
217
+ | `messages` | `MessageApi` | Thao tác với tin nhắn |
218
+ | `files` | `FileApi` | Upload/download file |
219
+ | `bots` | `BotApi` | Quản lý bot |
220
+ | `proxy` | `ProxyApi` | Proxy đến backend khác |
221
+ | `health` | `HealthApi` | Kiểm tra sức khỏe server |
222
+ | `realtime.chat` | `ChatHubClient` | SignalR ChatHub |
223
+ | `realtime.notifications` | `NotificationHubClient` | SignalR NotificationHub |
224
+ | `currentUser` | `AuthUserInfo \| null` | User hiện tại sau khi đăng nhập |
225
+
226
+ ### Phương thức
227
+
228
+ #### `setToken(token: string): void`
229
+
230
+ Cập nhật JWT token. Có hiệu lực ngay với mọi HTTP request tiếp theo.
231
+
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.
233
+
234
+ ```ts
235
+ client.setToken('new-jwt-token-here');
236
+ ```
237
+
238
+ #### `disconnect(): Promise<void>`
239
+
240
+ Ngắt kết nối tất cả các SignalR hub (ChatHub + NotificationHub) đồng thời.
241
+
242
+ ```ts
243
+ await client.disconnect();
244
+ ```
245
+
246
+ ---
247
+
248
+ ## 4. Xác thực — AuthApi
249
+
250
+ **Truy cập:** `client.auth`
251
+
252
+ Các endpoint xác thực **không yêu cầu JWT**. Sau khi đăng nhập thành công, SDK tự động lưu token và cập nhật `client.currentUser`.
253
+
254
+ ### `loginWithGoogle(idToken: string): Promise<AuthResponse>`
255
+
256
+ Đăng nhập bằng Google ID Token.
257
+
258
+ ```ts
259
+ const { token, user } = await client.auth.loginWithGoogle(googleIdToken);
260
+ console.log(client.currentUser); // AuthUserInfo đã được set tự động
261
+ ```
262
+
263
+ ### `loginWithDsAccount(token: string): Promise<AuthResponse>`
264
+
265
+ Đăng nhập bằng DS Account token.
266
+
267
+ ```ts
268
+ const auth = await client.auth.loginWithDsAccount(dsToken);
269
+ ```
270
+
271
+ ### `loginWithDev(username: string, password: string): Promise<AuthResponse>`
272
+
273
+ Đăng nhập tài khoản dev. Chỉ dùng trong môi trường phát triển. JWT được cấp có claim `role=dev`, cho phép truy cập các endpoint quản lý bot.
274
+
275
+ ```ts
276
+ const auth = await client.auth.loginWithDev('devuser', 'devpass');
277
+ ```
278
+
279
+ ### `login(provider: string, body: LoginRequest): Promise<AuthResponse>`
280
+
281
+ Đăng nhập với provider tùy chỉnh (khi provider được xác định lúc runtime).
282
+
283
+ ```ts
284
+ const auth = await client.auth.login('custom-provider', { token: '...' });
285
+ ```
286
+
287
+ ### `AuthResponse`
288
+
289
+ ```ts
290
+ interface AuthResponse {
291
+ token: string; // JWT token
292
+ expiresAt: string; // ISO 8601 — thời điểm hết hạn
293
+ user: AuthUserInfo;
294
+ }
295
+
296
+ interface AuthUserInfo {
297
+ id: string;
298
+ uniqueName: string;
299
+ fullName: string;
300
+ avatar?: string; // URL thuần (KHÔNG phải MediaReference)
301
+ }
302
+ ```
303
+
304
+ ---
305
+
306
+ ## 5. Participant — ParticipantApi
307
+
308
+ **Truy cập:** `client.participants`
309
+
310
+ Tất cả endpoint đều **yêu cầu JWT**. Server cache GET trong 5 phút.
311
+
312
+ ### `createOrUpdate(request): Promise<ParticipantDto>`
313
+
314
+ Tạo mới hoặc cập nhật participant (upsert theo ID).
315
+
316
+ ```ts
317
+ const participant = await client.participants.createOrUpdate({
318
+ id: 'user-123',
319
+ uniqueName: 'john_doe',
320
+ fullName: 'John Doe',
321
+ avatar: { url: 'https://example.com/avatar.jpg' },
322
+ gender: 'Male',
323
+ });
324
+ ```
325
+
326
+ ### `getById(id: string): Promise<ParticipantDto>`
327
+
328
+ ```ts
329
+ const participant = await client.participants.getById('user-123');
330
+ ```
331
+
332
+ ### `getByUniqueName(uniqueName: string): Promise<ParticipantDto>`
333
+
334
+ ```ts
335
+ const participant = await client.participants.getByUniqueName('john_doe');
336
+ ```
337
+
338
+ ### `getBatch(ids: string[]): Promise<ParticipantDto[]>`
339
+
340
+ Lấy nhiều participant trong một request. ID không tìm thấy sẽ bị bỏ qua (không báo lỗi).
341
+
342
+ ```ts
343
+ const participants = await client.participants.getBatch(['id-1', 'id-2', 'id-3']);
344
+ ```
345
+
346
+ ### `ParticipantDto`
347
+
348
+ ```ts
349
+ interface ParticipantDto {
350
+ id: string;
351
+ uniqueName: string;
352
+ fullName: string;
353
+ avatar?: MediaReference;
354
+ gender?: string;
355
+ isBot: boolean;
356
+ isOnline: boolean;
357
+ lastSeenAt?: string; // ISO 8601
358
+ createdAt: string; // ISO 8601
359
+ updatedAt: string; // ISO 8601
360
+ }
361
+ ```
362
+
363
+ ---
364
+
365
+ ## 6. Conversation — ConversationApi
366
+
367
+ **Truy cập:** `client.conversations`
368
+
369
+ Tất cả endpoint đều **yêu cầu JWT**.
370
+
371
+ ### `list(params?): Promise<PaginatedResult<ConversationListItemDto>>`
372
+
373
+ 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
+
375
+ ```ts
376
+ // Trang đầu
377
+ const result = await client.conversations.list({ limit: 20 });
378
+ console.log(result.items); // ConversationListItemDto[]
379
+ console.log(result.hasMore); // boolean
380
+ console.log(result.nextCursor); // string | null
381
+
382
+ // Trang tiếp theo
383
+ if (result.hasMore) {
384
+ const nextPage = await client.conversations.list({
385
+ limit: 20,
386
+ cursor: result.nextCursor!, // Đây là string — KHÔNG cast sang number
387
+ });
388
+ }
389
+ ```
390
+
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**.
392
+
393
+ ### `create(request): Promise<ConversationDto>`
394
+
395
+ ```ts
396
+ // Tạo cuộc hội thoại nhóm
397
+ const conv = await client.conversations.create({
398
+ type: 'Group',
399
+ name: 'Team Chat',
400
+ avatar: { storageKey: '01HXABCDEF/group-avatar.jpg' }, // optional
401
+ participantIds: ['user-1', 'user-2', 'user-3'],
402
+ });
403
+
404
+ // Tạo cuộc hội thoại 1-1
405
+ const dm = await client.conversations.create({
406
+ type: 'OneToOne',
407
+ participantIds: ['other-user-id'],
408
+ });
409
+
410
+ // Tạo cuộc hội thoại với chính mình (Saved Messages)
411
+ const self = await client.conversations.create({
412
+ type: 'Self',
413
+ participantIds: [],
414
+ });
415
+ ```
416
+
417
+ ### `search(query, limit?): Promise<ConversationListItemDto[]>`
418
+
419
+ Tìm kiếm theo tên cuộc hội thoại hoặc tên participant.
420
+
421
+ ```ts
422
+ const results = await client.conversations.search('team', 10);
423
+ ```
424
+
425
+ ### `getById(id: string): Promise<ConversationDto>`
426
+
427
+ ```ts
428
+ const conv = await client.conversations.getById('conv-id-here');
429
+ ```
430
+
431
+ ### `update(id, request): Promise<ConversationDto>`
432
+
433
+ Chỉ áp dụng cho cuộc hội thoại **Group**.
434
+
435
+ ```ts
436
+ const updated = await client.conversations.update('conv-id', {
437
+ name: 'New Group Name',
438
+ avatar: { url: 'https://example.com/group-avatar.jpg' },
439
+ });
440
+ ```
441
+
442
+ ### `leave(id: string): Promise<void>`
443
+
444
+ Rời khỏi cuộc hội thoại. Các participant khác vẫn còn đó.
445
+
446
+ ```ts
447
+ await client.conversations.leave('conv-id');
448
+ ```
449
+
450
+ ### `findOneToOne(otherParticipantId): Promise<ConversationDto | null>`
451
+
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ó.
453
+
454
+ ```ts
455
+ const existing = await client.conversations.findOneToOne('other-user-id');
456
+ if (!existing) {
457
+ // Tạo mới
458
+ }
459
+ ```
460
+
461
+ ### `addParticipant(conversationId, request): Promise<void>`
462
+
463
+ ```ts
464
+ await client.conversations.addParticipant('conv-id', {
465
+ participantId: 'new-user-id',
466
+ canViewHistory: true, // Mặc định true — xem được tin nhắn cũ
467
+ });
468
+ ```
469
+
470
+ ### `removeParticipant(conversationId, participantId): Promise<void>`
471
+
472
+ ```ts
473
+ await client.conversations.removeParticipant('conv-id', 'user-to-remove-id');
474
+ ```
475
+
476
+ ### `getParticipants(conversationId): Promise<ConversationParticipantDto[]>`
477
+
478
+ Trả về tất cả participant kể cả người đã rời nhóm.
479
+
480
+ ```ts
481
+ const members = await client.conversations.getParticipants('conv-id');
482
+ const active = members.filter(m => m.isActive);
483
+ ```
484
+
485
+ ### `pin(id) / unpin(id): Promise<void>`
486
+
487
+ ```ts
488
+ await client.conversations.pin('conv-id');
489
+ await client.conversations.unpin('conv-id');
490
+ ```
491
+
492
+ ### `markAsRead(id, messageId) / markAsUnread(id): Promise<void>`
493
+
494
+ ```ts
495
+ // Đánh dấu đã đọc đến messageId
496
+ await client.conversations.markAsRead('conv-id', 'last-message-id');
497
+
498
+ // Đánh dấu chưa đọc
499
+ await client.conversations.markAsUnread('conv-id');
500
+ ```
501
+
502
+ ### Kiểu dữ liệu Conversation
503
+
504
+ ```ts
505
+ type ConversationType = 'Group' | 'OneToOne' | 'Self';
506
+
507
+ interface ConversationDto {
508
+ id: string;
509
+ type: ConversationType;
510
+ name?: string; // Tên nhóm (chỉ Group)
511
+ avatar?: MediaReference;
512
+ ownerId: string;
513
+ lastMessageAt?: string; // ISO 8601
514
+ lastMessageId?: string;
515
+ createdAt: string; // ISO 8601
516
+ updatedAt: string; // ISO 8601
517
+ participants: ConversationParticipantDto[];
518
+ }
519
+
520
+ interface ConversationParticipantDto {
521
+ participantId: string;
522
+ joinedAt: string; // ISO 8601
523
+ leftAt?: string | null; // ISO 8601, null nếu vẫn trong nhóm
524
+ isActive: boolean;
525
+ isPinned: boolean;
526
+ pinnedAt?: string | null; // ISO 8601
527
+ lastReadMessageId?: string | null;
528
+ lastReadAt?: string | null; // ISO 8601
529
+ unreadCount: number;
530
+ }
531
+
532
+ interface ConversationListItemDto {
533
+ id: string;
534
+ 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
540
+ isPinned: boolean;
541
+ pinnedAt?: string; // ISO 8601
542
+ unreadCount: number;
543
+ participantCount: number;
544
+ }
545
+ ```
546
+
547
+ ---
548
+
549
+ ## 7. Message — MessageApi
550
+
551
+ **Truy cập:** `client.messages`
552
+
553
+ Tất cả endpoint đều **yêu cầu JWT**.
554
+
555
+ ### `send(conversationId, request): Promise<MessageDto>`
556
+
557
+ Gửi tin nhắn qua REST API. Trả về 201 Created với `MessageDto` đầy đủ.
558
+
559
+ ```ts
560
+ const msg = await client.messages.send('conv-id', {
561
+ blocks: [
562
+ { $type: 'text', format: 'Plain', content: 'Xin chào!', plainText: 'Xin chào!' }
563
+ ],
564
+ replyToMessageId: 'msg-id-to-reply', // optional
565
+ clientMessageId: 'local-uuid-1234', // optional, dùng cho optimistic rendering
566
+ });
567
+ ```
568
+
569
+ > 💡 **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
+
571
+ ### `getHistory(conversationId, params?): Promise<PaginatedResult<MessageDto>>`
572
+
573
+ Lấy lịch sử tin nhắn với cursor-based pagination.
574
+
575
+ ```ts
576
+ // Lấy 30 tin nhắn mới nhất
577
+ const result = await client.messages.getHistory('conv-id', { limit: 30 });
578
+
579
+ // Load thêm tin nhắn cũ hơn (scroll lên)
580
+ const older = await client.messages.getHistory('conv-id', {
581
+ limit: 30,
582
+ cursor: result.nextCursor!,
583
+ direction: 'older', // mặc định
584
+ });
585
+
586
+ // Load tin nhắn mới hơn cursor (scroll xuống)
587
+ const newer = await client.messages.getHistory('conv-id', {
588
+ cursor: someCursor,
589
+ direction: 'newer',
590
+ });
591
+ ```
592
+
593
+ ### `search(conversationId, query, limit?): Promise<MessageDto[]>`
594
+
595
+ Full-text search trong cuộc hội thoại.
596
+
597
+ ```ts
598
+ const results = await client.messages.search('conv-id', 'từ khóa tìm kiếm', 20);
599
+ ```
600
+
601
+ ### `getById(messageId): Promise<MessageDto>`
602
+
603
+ ```ts
604
+ const msg = await client.messages.getById('msg-id');
605
+ ```
606
+
607
+ ### `edit(messageId, request): Promise<MessageDto>`
608
+
609
+ Chỉ người gửi mới có thể chỉnh sửa.
610
+
611
+ ```ts
612
+ const edited = await client.messages.edit('msg-id', {
613
+ blocks: [{ $type: 'text', format: 'Plain', content: 'Nội dung đã sửa', plainText: 'Nội dung đã sửa' }],
614
+ mentions: [], // optional
615
+ });
616
+ ```
617
+
618
+ > ⚠️ **Khác biệt REST vs SignalR:** REST dùng `{ blocks, mentions }`, SignalR dùng `{ messageId, newBlocks, newMentions }`.
619
+
620
+ ### `delete(messageId): Promise<{ messageId, deletedAt }>`
621
+
622
+ Xóa mềm (soft delete). Trả về object (không phải 204).
623
+
624
+ ```ts
625
+ const { messageId, deletedAt } = await client.messages.delete('msg-id');
626
+ ```
627
+
628
+ ### `recover(messageId): Promise<void>`
629
+
630
+ Khôi phục tin nhắn đã xóa. Chỉ khả dụng trong vòng **10 phút** sau khi xóa.
631
+
632
+ ```ts
633
+ await client.messages.recover('msg-id');
634
+ ```
635
+
636
+ ### `addReaction(messageId, emoji) / removeReaction(messageId, emoji): Promise<void>`
637
+
638
+ ```ts
639
+ await client.messages.addReaction('msg-id', '👍');
640
+ await client.messages.removeReaction('msg-id', '👍');
641
+ ```
642
+
643
+ > Server trả về 409 Conflict nếu đã react với emoji đó. Emoji được tự động URL-encode.
644
+
645
+ ### Kiểu dữ liệu Message
646
+
647
+ ```ts
648
+ type SenderType = 'User' | 'Bot' | 'System';
649
+
650
+ interface MessageDto {
651
+ id: string; // ULID do server tạo
652
+ conversationId: string;
653
+ senderId: string;
654
+ senderType: SenderType;
655
+ timestamp: string; // ISO 8601 — thời điểm gửi
656
+ blocks: Block[]; // Nội dung tin nhắn
657
+ plainTextIndex: string | null;
658
+ replyToMessageId: string | null;
659
+ quickReplies: QuickReply[] | null;
660
+ isEdited: boolean;
661
+ editedAt: string | null; // ISO 8601
662
+ isDeleted: boolean;
663
+ deletedAt: string | null; // ISO 8601
664
+ reactionSummary: ReactionSummary | null;
665
+ mentions: Mention[] | null;
666
+ createdAt: string; // ISO 8601
667
+ updatedAt: string; // ISO 8601
668
+ clientMessageId: string | null;
669
+ systemEvent: SystemEventInfo | null;
670
+ }
671
+ ```
672
+
673
+ ### Kiểu dữ liệu bổ sung
674
+
675
+ ```ts
676
+ // Reaction summary (REST API)
677
+ interface ReactionSummary {
678
+ groups: ReactionGroup[];
679
+ totalCount: number;
680
+ }
681
+
682
+ interface ReactionGroup {
683
+ emoji: string;
684
+ count: number;
685
+ participantIds: string[]; // Danh sách participant đã react
686
+ }
687
+
688
+ // Request để gửi tin nhắn mới
689
+ interface SendMessageRequest {
690
+ blocks: Block[];
691
+ replyToMessageId?: string;
692
+ quickReplies?: QuickReply[]; // Nút bấm nhanh đính kèm tin nhắn
693
+ mentions?: Mention[];
694
+ clientMessageId?: string; // Tối đa 128 ký tự
695
+ }
696
+
697
+ // Request để chỉnh sửa tin nhắn qua REST
698
+ interface EditMessageRequest {
699
+ blocks: Block[];
700
+ mentions?: Mention[];
701
+ }
702
+
703
+ // Request đánh dấu đã đọc (REST)
704
+ interface MarkAsReadRequest {
705
+ messageId: string;
706
+ }
707
+ ```
708
+
709
+ ---
710
+
711
+ ## 8. File — FileApi
712
+
713
+ **Truy cập:** `client.files`
714
+
715
+ Upload yêu cầu JWT. Download là **public** (không cần JWT).
716
+
717
+ ### `uploadFile(file, onProgress?): Promise<UploadResponse>`
718
+
719
+ Phương thức tiện lợi — gộp `createUploadUrl` + `upload` thành một bước.
720
+
721
+ ```ts
722
+ const fileInput = document.getElementById('file') as HTMLInputElement;
723
+ const file = fileInput.files![0];
724
+
725
+ const result = await client.files.uploadFile(file, (loaded, total) => {
726
+ const percent = Math.round((loaded / total) * 100);
727
+ console.log(`Upload: ${percent}%`);
728
+ });
729
+
730
+ console.log(result.storageKey); // Dùng làm MediaReference.storageKey
731
+ console.log(result.downloadUrl); // Relative URL
732
+ ```
733
+
734
+ ### `createUploadUrl(request): Promise<CreateUploadUrlResponse>`
735
+
736
+ Bước 1 của 2 bước upload thủ công — xin URL upload.
737
+
738
+ ```ts
739
+ const { uploadUrl, storageKey, expiresAt } = await client.files.createUploadUrl({
740
+ fileName: 'photo.jpg',
741
+ contentType: 'image/jpeg',
742
+ fileSize: file.size, // bytes, tối đa 50MB
743
+ });
744
+ ```
745
+
746
+ ### `upload(uploadUrl, file, onProgress?): Promise<UploadResponse>`
747
+
748
+ Bước 2 — upload file vào URL đã xin.
749
+
750
+ ```ts
751
+ const result = await client.files.upload(uploadUrl, file, (loaded, total) => {
752
+ console.log(`${loaded}/${total}`);
753
+ });
754
+ ```
755
+
756
+ > Khi có `onProgress`, SDK dùng XHR thay vì fetch (vì fetch không hỗ trợ upload progress events).
757
+
758
+ ### `getDownloadUrl(storageKey: string): string`
759
+
760
+ Tạo URL tải file tuyệt đối (dùng để hiển thị ảnh, tải xuống, v.v.).
761
+
762
+ ```ts
763
+ const url = client.files.getDownloadUrl('01HXABCDEF/photo.jpg');
764
+ // → "https://chat-api.example.com/api/files/01HXABCDEF/photo.jpg"
765
+ ```
766
+
767
+ ### `delete(storageKey: string): Promise<void>`
768
+
769
+ Xóa file. Chỉ người upload mới có thể xóa. `storageKey` có thể chứa `/`, không cần encode.
770
+
771
+ ```ts
772
+ await client.files.delete('01HXABCDEF/photo.jpg');
773
+ ```
774
+
775
+ ### Kiểu dữ liệu File
776
+
777
+ ```ts
778
+ interface CreateUploadUrlRequest {
779
+ fileName: string;
780
+ contentType: string;
781
+ fileSize: number; // bytes
782
+ }
783
+
784
+ interface CreateUploadUrlResponse {
785
+ uploadUrl: string; // Relative path — HttpClient tự prepend baseUrl
786
+ storageKey: string;
787
+ expiresAt: string; // ISO 8601
788
+ }
789
+
790
+ interface UploadResponse {
791
+ downloadUrl: string; // Relative path
792
+ storageKey: string;
793
+ fileName: string;
794
+ contentType: string;
795
+ fileSize: number;
796
+ uploadedAt: string; // ISO 8601
797
+ }
798
+ ```
799
+
800
+ ---
801
+
802
+ ## 9. Bot — BotApi
803
+
804
+ **Truy cập:** `client.bots`
805
+
806
+ GET (`list`, `getById`) là **public**. Các thao tác quản lý yêu cầu JWT với `role=dev` (đăng nhập qua `loginWithDev`). User không có quyền nhận `404 Not Found`.
807
+
808
+ ### `list(params?): Promise<PagedResult<BotDto>>`
809
+
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**.
811
+
812
+ ```ts
813
+ const result = await client.bots.list({ page: 1, pageSize: 20 });
814
+ console.log(result.items); // BotDto[]
815
+ console.log(result.totalCount); // Tổng số bot
816
+ ```
817
+
818
+ ### `getById(id): Promise<BotDto>`
819
+
820
+ > Server cache kết quả **5 phút**.
821
+
822
+ ```ts
823
+ const bot = await client.bots.getById('bot-id');
824
+ // Lấy thêm thông tin display: name, avatar
825
+ const participant = await client.participants.getById(bot.participantId);
826
+ ```
827
+
828
+ ### `create(request): Promise<BotDto & { apiKey: string }>`
829
+
830
+ Tạo bot và nhận `apiKey` một lần — **lưu ngay**, server không lưu key này.
831
+
832
+ ```ts
833
+ const bot = await client.bots.create({
834
+ uniqueName: 'my-bot',
835
+ fullName: 'My Assistant Bot',
836
+ description: 'Trợ lý AI',
837
+ kafkaTopic: 'my-bot-topic', // Không thể thay đổi sau khi tạo
838
+ rateLimitPerSecond: 5,
839
+ listenAllGroupMessages: false,
840
+ });
841
+ const apiKey = bot.apiKey; // Lưu ngay!
842
+ ```
843
+
844
+ ### `update(id, request): Promise<BotDto>`
845
+
846
+ ```ts
847
+ await client.bots.update('bot-id', {
848
+ fullName: 'Updated Bot Name',
849
+ rateLimitPerSecond: 10,
850
+ // kafkaTopic KHÔNG thể cập nhật
851
+ });
852
+ ```
853
+
854
+ ### `activate(id) / deactivate(id): Promise<void>`
855
+
856
+ ```ts
857
+ await client.bots.activate('bot-id');
858
+ await client.bots.deactivate('bot-id');
859
+ ```
860
+
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
+ ### `delete(id): Promise<void>`
870
+
871
+ Soft delete.
872
+
873
+ ```ts
874
+ await client.bots.delete('bot-id');
875
+ ```
876
+
877
+ ### Kiểu dữ liệu Bot
878
+
879
+ ```ts
880
+ interface BotDto {
881
+ id: string;
882
+ participantId: string; // Dùng để lấy name/avatar từ ParticipantApi
883
+ description?: string;
884
+ metadata?: Record<string, unknown>;
885
+ kafkaTopic: string; // KHÔNG thể thay đổi sau khi tạo
886
+ isActive: boolean;
887
+ rateLimitPerSecond: number;
888
+ listenAllGroupMessages: boolean;
889
+ createdBy: string; // ID của người tạo bot
890
+ createdAt: string; // ISO 8601
891
+ updatedAt: string; // ISO 8601
892
+ }
893
+ ```
894
+
895
+ > ⚠️ `BotDto` **không** chứa `uniqueName` hay `fullName`. Dùng `client.participants.getById(bot.participantId)` để lấy tên và avatar của bot.
896
+
897
+ ### Kiểu dữ liệu Request (Bot)
898
+
899
+ ```ts
900
+ interface CreateBotRequest {
901
+ uniqueName: string;
902
+ fullName: string;
903
+ avatar?: MediaReference; // optional — avatar của bot
904
+ description?: string;
905
+ metadata?: Record<string, unknown>;
906
+ kafkaTopic: string; // KHÔNG thể thay đổi sau khi tạo
907
+ rateLimitPerSecond?: number;
908
+ listenAllGroupMessages?: boolean; // Mặc định false
909
+ }
910
+
911
+ interface UpdateBotRequest {
912
+ uniqueName?: string; // Có thể cập nhật sau khi tạo
913
+ fullName?: string;
914
+ avatar?: MediaReference;
915
+ description?: string;
916
+ metadata?: Record<string, unknown>;
917
+ rateLimitPerSecond?: number;
918
+ listenAllGroupMessages?: boolean;
919
+ // kafkaTopic KHÔNG thể cập nhật
920
+ }
921
+ ```
922
+
923
+ ---
924
+
925
+ ## 10. Proxy — ProxyApi
926
+
927
+ **Truy cập:** `client.proxy`
928
+
929
+ Chuyển tiếp request đến các backend service đã đăng ký qua gateway.
930
+
931
+ ```ts
932
+ // GET /api/proxy/trading/api/stocks/VN30
933
+ const data = await client.proxy.get<StockData>('trading', 'api/stocks/VN30');
934
+
935
+ // POST /api/proxy/trading/api/orders
936
+ const order = await client.proxy.post<Order>('trading', 'api/orders', {
937
+ symbol: 'VNM',
938
+ quantity: 100,
939
+ });
940
+
941
+ // Request tùy chỉnh method
942
+ const result = await client.proxy.request<unknown>('myservice', 'api/resource/123', {
943
+ method: 'DELETE',
944
+ headers: { 'X-Custom-Header': 'value' },
945
+ });
946
+ ```
947
+
948
+ ---
949
+
950
+ ## 11. Health — HealthApi
951
+
952
+ **Truy cập:** `client.health`
953
+
954
+ Các endpoint **public** (không cần JWT). Dùng để kiểm tra trạng thái server.
955
+
956
+ ```ts
957
+ // Liveness probe — kiểm tra process có chạy không
958
+ const status = await client.health.live(); // "Healthy"
959
+
960
+ // Readiness probe — kiểm tra tất cả dependencies
961
+ const detail = await client.health.ready();
962
+ console.log(detail.status); // "Healthy" | "Degraded" | "Unhealthy"
963
+ console.log(detail.totalDuration); // ".NET TimeSpan string" như "00:00:00.0123456" — KHÔNG phải number
964
+ console.log(detail.results); // Record<string, HealthCheckResult>
965
+ ```
966
+
967
+ ### Kiểu dữ liệu Health
968
+
969
+ ```ts
970
+ interface HealthStatus {
971
+ status: string; // "Healthy" | "Degraded" | "Unhealthy"
972
+ totalDuration: string; // .NET TimeSpan — "00:00:00.1234567" (KHÔNG phải number)
973
+ results: Record<string, HealthCheckResult>;
974
+ }
975
+
976
+ interface HealthCheckResult {
977
+ status: string; // "Healthy" | "Degraded" | "Unhealthy"
978
+ description: string | null;
979
+ duration: string; // .NET TimeSpan — KHÔNG phải number
980
+ error: string | null;
981
+ }
982
+ ```
983
+
984
+ ---
985
+
986
+ ## 12. Real-time: ChatHubClient
987
+
988
+ **Truy cập:** `client.realtime.chat`
989
+
990
+ Kết nối đến SignalR ChatHub (`/hubs/chat`). Xử lý tất cả sự kiện liên quan đến tin nhắn trong cuộc hội thoại.
991
+
992
+ ### Kết nối
993
+
994
+ ```ts
995
+ await client.realtime.chat.connect();
996
+ ```
997
+
998
+ > Nếu đã connected, gọi `connect()` lần nữa sẽ không làm gì. SDK tự động reconnect (dùng SignalR `withAutomaticReconnect`).
999
+
1000
+ ### Ngắt kết nối
1001
+
1002
+ ```ts
1003
+ await client.realtime.chat.disconnect();
1004
+ // Xóa toàn bộ joinedConversations
1005
+ ```
1006
+
1007
+ > `joinedConversations` là `Set<string>` chứa các conversationId đã join. SDK dùng set này để tự động re-join sau reconnect. Có thể đọc trực tiếp: `client.realtime.chat.joinedConversations`.
1008
+
1009
+ ### `rejoinConversations(): Promise<void>`
1010
+
1011
+ Re-join tất cả conversation đang track. SDK tự gọi sau reconnect; cũng có thể gọi thủ công nếu cần.
1012
+
1013
+ ```ts
1014
+ await client.realtime.chat.rejoinConversations();
1015
+ ```
1016
+
1017
+ ### Trạng thái kết nối
1018
+
1019
+ ```ts
1020
+ import { HubConnectionState } from '@microsoft/signalr';
1021
+
1022
+ const state = client.realtime.chat.state;
1023
+ // HubConnectionState.Connected | Disconnected | Connecting | Reconnecting
1024
+ ```
1025
+
1026
+ ### Tham gia / Rời cuộc hội thoại
1027
+
1028
+ ```ts
1029
+ // Tham gia — bắt đầu nhận sự kiện của cuộc hội thoại
1030
+ await client.realtime.chat.joinConversation('conv-id');
1031
+
1032
+ // Rời
1033
+ await client.realtime.chat.leaveConversation('conv-id');
1034
+ ```
1035
+
1036
+ > SDK theo dõi `joinedConversations` và tự động re-join sau khi reconnect.
1037
+
1038
+ ### Gửi tin nhắn qua SignalR
1039
+
1040
+ ```ts
1041
+ const ack = await client.realtime.chat.sendMessage({
1042
+ conversationId: 'conv-id',
1043
+ blocks: [{ $type: 'text', format: 'Plain', content: 'Hello!', plainText: 'Hello!' }],
1044
+ clientMessageId: 'local-uuid', // optional, optimistic rendering
1045
+ });
1046
+
1047
+ if (ack.success) {
1048
+ console.log('Đã gửi, messageId:', ack.messageId);
1049
+ } else {
1050
+ console.error('Lỗi:', ack.errorCode, ack.errorMessage);
1051
+ }
1052
+ ```
1053
+
1054
+ ### Chỉnh sửa / Xóa / Khôi phục tin nhắn qua SignalR
1055
+
1056
+ ```ts
1057
+ // Chỉnh sửa — dùng newBlocks/newMentions (khác REST!)
1058
+ const editAck = await client.realtime.chat.editMessage({
1059
+ messageId: 'msg-id',
1060
+ newBlocks: [{ $type: 'text', format: 'Plain', content: 'Đã sửa', plainText: 'Đã sửa' }],
1061
+ });
1062
+
1063
+ // Xóa — truyền object, KHÔNG phải string
1064
+ const deleteAck = await client.realtime.chat.deleteMessage({ messageId: 'msg-id' });
1065
+
1066
+ // Khôi phục
1067
+ const recoverAck = await client.realtime.chat.recoverMessage({ messageId: 'msg-id' });
1068
+ ```
1069
+
1070
+ ### Typing indicator
1071
+
1072
+ ```ts
1073
+ await client.realtime.chat.startTyping('conv-id');
1074
+ await client.realtime.chat.stopTyping('conv-id');
1075
+ ```
1076
+
1077
+ ### Đánh dấu đã đọc qua SignalR
1078
+
1079
+ ```ts
1080
+ const ack = await client.realtime.chat.markAsRead('conv-id', 'last-message-id');
1081
+ ```
1082
+
1083
+ ### Reaction qua SignalR
1084
+
1085
+ ```ts
1086
+ await client.realtime.chat.addReaction({ messageId: 'msg-id', conversationId: 'conv-id', emoji: '❤️' });
1087
+ await client.realtime.chat.removeReaction({ messageId: 'msg-id', emoji: '❤️' });
1088
+ ```
1089
+
1090
+ ### Lắng nghe sự kiện
1091
+
1092
+ ```ts
1093
+ // Đăng ký — trả về hàm unsubscribe
1094
+ const unsub = client.realtime.chat.on('messageReceived', (msg) => {
1095
+ console.log('Tin nhắn mới:', msg.plainTextContent);
1096
+ });
1097
+
1098
+ // Hủy đăng ký
1099
+ unsub();
1100
+
1101
+ // Hoặc dùng .off()
1102
+ const handler = (msg: ChatMessageDto) => { /* ... */ };
1103
+ client.realtime.chat.on('messageReceived', handler);
1104
+ client.realtime.chat.off('messageReceived', handler);
1105
+ ```
1106
+
1107
+ ### Toàn bộ sự kiện ChatHub
1108
+
1109
+ | Sự kiện | Payload | Mô tả |
1110
+ |---------|---------|-------|
1111
+ | `messageReceived` | `ChatMessageDto` | Tin nhắn mới trong cuộc hội thoại |
1112
+ | `messageUpdated` | `MessageUpdatedDto` | Tin nhắn được chỉnh sửa |
1113
+ | `messageDeleted` | `MessageDeletedDto` | Tin nhắn bị xóa |
1114
+ | `messageRecovered` | `ChatMessageDto` | Tin nhắn được khôi phục |
1115
+ | `reactionAdded` | `ReactionAddedDto` | Thêm reaction |
1116
+ | `reactionRemoved` | `ReactionRemovedDto` | Xóa reaction |
1117
+ | `typingStarted` | `TypingDto` | Người dùng đang gõ |
1118
+ | `typingStopped` | `TypingDto` | Người dùng dừng gõ |
1119
+ | `readReceiptUpdated` | `ReadReceiptUpdatedDto` | Cập nhật trạng thái đã đọc |
1120
+ | `streamStarted` | `StreamStartedDto` | Bot stream bắt đầu |
1121
+ | `streamStatusUpdated` | `StreamStatusUpdatedDto` | Trạng thái bot stream thay đổi |
1122
+ | `streamChunkReceived` | `StreamChunkReceivedDto` | Nhận thêm chunk text từ bot |
1123
+ | `streamCompleted` | `StreamCompletedDto` | Bot stream hoàn thành (kèm message đầy đủ) |
1124
+ | `streamAborted` | `StreamAbortedDto` | Bot stream bị hủy |
1125
+ | `error` | `HubErrorDto` | Lỗi từ server |
1126
+ | `reconnecting` | `Error \| undefined` | Đang reconnect |
1127
+ | `reconnected` | `string \| undefined` | Đã reconnect (connectionId mới) |
1128
+ | `disconnected` | `Error \| undefined` | Mất kết nối hẳn |
1129
+
1130
+ ### Kiểu dữ liệu sự kiện (ChatHub)
1131
+
1132
+ ```ts
1133
+ // Tin nhắn được chỉnh sửa
1134
+ interface MessageUpdatedDto {
1135
+ messageId: string;
1136
+ conversationId: string;
1137
+ blocks: Block[];
1138
+ plainTextContent: string | null;
1139
+ mentions?: Mention[];
1140
+ updatedAt: string; // ISO 8601
1141
+ }
1142
+
1143
+ // Tin nhắn bị xóa
1144
+ type DeleteType = 'recall' | 'delete'; // 'recall': xóa cho tất cả; 'delete': xóa phía người gửi
1145
+
1146
+ interface MessageDeletedDto {
1147
+ messageId: string;
1148
+ conversationId: string;
1149
+ deleteType: DeleteType;
1150
+ deletedBy: string; // participantId
1151
+ deletedAt: string; // ISO 8601
1152
+ }
1153
+
1154
+ // Reaction được thêm
1155
+ interface ReactionAddedDto {
1156
+ messageId: string;
1157
+ conversationId: string;
1158
+ participantId: string;
1159
+ participantName: string;
1160
+ emoji: string;
1161
+ createdAt: string; // ISO 8601
1162
+ }
1163
+
1164
+ // Reaction được xóa — chú ý field là removedAt, KHÔNG phải createdAt
1165
+ interface ReactionRemovedDto {
1166
+ messageId: string;
1167
+ conversationId: string;
1168
+ participantId: string;
1169
+ participantName: string;
1170
+ emoji: string;
1171
+ removedAt: string; // ISO 8601 — khác với ReactionAddedDto.createdAt
1172
+ }
1173
+
1174
+ // Typing indicator
1175
+ interface TypingDto {
1176
+ conversationId: string;
1177
+ participantId: string;
1178
+ participantName: string;
1179
+ }
1180
+
1181
+ // Trạng thái đã đọc
1182
+ interface ReadReceiptUpdatedDto {
1183
+ conversationId: string;
1184
+ participantId: string;
1185
+ lastReadMessageId: string;
1186
+ readAt: string; // ISO 8601
1187
+ }
1188
+ ```
1189
+
1190
+ ### Bot streaming
1191
+
1192
+ Khi bot streaming, **không có** `messageReceived` — thay vào đó nhận chuỗi sự kiện sau:
1193
+
1194
+ ```
1195
+ streamStarted → streamStatusUpdated* → streamChunkReceived* → streamCompleted
1196
+ → streamAborted
1197
+ ```
1198
+
1199
+ ```ts
1200
+ const chunks: string[] = [];
1201
+
1202
+ client.realtime.chat.on('streamStarted', (dto) => {
1203
+ console.log('Bot bắt đầu trả lời, streamId:', dto.streamId);
1204
+ });
1205
+
1206
+ client.realtime.chat.on('streamChunkReceived', (dto) => {
1207
+ chunks.push(dto.text);
1208
+ // Hiển thị progressive: chunks.join('')
1209
+ });
1210
+
1211
+ client.realtime.chat.on('streamCompleted', (dto) => {
1212
+ console.log('Tin nhắn hoàn chỉnh:', dto.message);
1213
+ chunks.length = 0; // reset
1214
+ });
1215
+
1216
+ client.realtime.chat.on('streamAborted', (dto) => {
1217
+ console.error('Stream bị hủy:', dto.reason);
1218
+ });
1219
+ ```
1220
+
1221
+ ---
1222
+
1223
+ ## 13. Real-time: NotificationHubClient
1224
+
1225
+ **Truy cập:** `client.realtime.notifications`
1226
+
1227
+ Kết nối đến SignalR NotificationHub (`/hubs/notifications`). Nhận thông báo cấp user (không cần tham gia conversation).
1228
+
1229
+ ### Trạng thái kết nối
1230
+
1231
+ ```ts
1232
+ import { HubConnectionState } from '@microsoft/signalr';
1233
+
1234
+ const state = client.realtime.notifications.state;
1235
+ // HubConnectionState.Connected | Disconnected | Connecting | Reconnecting
1236
+ ```
1237
+
1238
+ ### Kết nối
1239
+
1240
+ ```ts
1241
+ await client.realtime.notifications.connect();
1242
+ // Khi connect, server tự động đánh dấu user là Online
1243
+ ```
1244
+
1245
+ ### Ngắt kết nối
1246
+
1247
+ ```ts
1248
+ await client.realtime.notifications.disconnect();
1249
+ // Server tự động đánh dấu user là Offline
1250
+ ```
1251
+
1252
+ > `subscribedPresenceIds` là `Set<string>` chứa các participantId đang theo dõi presence. SDK tự động re-subscribe sau reconnect. Có thể đọc trực tiếp: `client.realtime.notifications.subscribedPresenceIds`.
1253
+
1254
+ ### `resubscribePresence(): Promise<void>`
1255
+
1256
+ Re-subscribe presence cho tất cả participantId đang track. SDK tự gọi sau reconnect; cũng có thể gọi thủ công nếu cần.
1257
+
1258
+ ```ts
1259
+ await client.realtime.notifications.resubscribePresence();
1260
+ ```
1261
+
1262
+ ### Theo dõi trạng thái online (Presence)
1263
+
1264
+ ```ts
1265
+ // Đăng ký theo dõi tối đa 200 participant mỗi lần
1266
+ await client.realtime.notifications.subscribeToPresence(['user-1', 'user-2', 'user-3']);
1267
+
1268
+ // Server gửi PresenceState ngay lập tức sau khi subscribe
1269
+ client.realtime.notifications.on('presenceState', (states) => {
1270
+ states.forEach(s => {
1271
+ console.log(s.participantId, s.isOnline, s.lastSeenAt);
1272
+ });
1273
+ });
1274
+
1275
+ // Sau đó nhận cập nhật real-time
1276
+ client.realtime.notifications.on('presenceChanged', (dto) => {
1277
+ console.log(dto.participantId, 'is now', dto.isOnline ? 'online' : 'offline');
1278
+ });
1279
+
1280
+ // Hủy theo dõi — cũng giới hạn tối đa 200 participant mỗi lần
1281
+ await client.realtime.notifications.unsubscribeFromPresence(['user-1']);
1282
+ ```
1283
+
1284
+ > SDK tự động re-subscribe sau khi reconnect.
1285
+
1286
+ ### Toàn bộ sự kiện NotificationHub
1287
+
1288
+ | Sự kiện | Payload | Mô tả |
1289
+ |---------|---------|-------|
1290
+ | `presenceState` | `PresenceStateItem[]` | Trạng thái online ban đầu sau subscribe |
1291
+ | `presenceChanged` | `PresenceChangedDto` | Thay đổi trạng thái online |
1292
+ | `newMessageNotification` | `NewMessageNotificationDto` | Có tin nhắn mới (thông báo cho tất cả participants trừ người gửi) |
1293
+ | `mentionedNotification` | `MentionedNotificationDto` | Bị mention trong tin nhắn |
1294
+ | `unreadCountChanged` | `UnreadCountChangedDto` | Số tin chưa đọc thay đổi |
1295
+ | `conversationCreated` | `ConversationCreatedDto` | Cuộc hội thoại mới được tạo (hoặc bị thêm vào) |
1296
+ | `conversationUpdated` | `ConversationUpdatedDto` | Metadata cuộc hội thoại thay đổi |
1297
+ | `participantJoined` | `ParticipantJoinedDto` | Thành viên mới tham gia |
1298
+ | `participantLeft` | `ParticipantLeftDto` | Thành viên rời nhóm |
1299
+ | `conversationPinned` | `ConversationPinnedDto` | Cuộc hội thoại được pin |
1300
+ | `conversationUnpinned` | `ConversationUnpinnedDto` | Cuộc hội thoại được unpin |
1301
+ | `readStatusChanged` | `ReadStatusChangedDto` | Trạng thái đọc thay đổi |
1302
+ | `error` | `HubErrorDto` | Lỗi từ server |
1303
+ | `reconnecting` | `Error \| undefined` | Đang reconnect |
1304
+ | `reconnected` | `string \| undefined` | Đã reconnect |
1305
+ | `disconnected` | `Error \| undefined` | Mất kết nối |
1306
+
1307
+ ### Kiểu dữ liệu sự kiện (NotificationHub)
1308
+
1309
+ ```ts
1310
+ // Trạng thái online — trả về ngay sau subscribe
1311
+ interface PresenceStateItem {
1312
+ participantId: string;
1313
+ isOnline: boolean;
1314
+ lastSeenAt?: string; // ISO 8601, undefined khi đang online
1315
+ }
1316
+
1317
+ // Thay đổi trạng thái online — real-time update
1318
+ interface PresenceChangedDto {
1319
+ participantId: string;
1320
+ isOnline: boolean;
1321
+ lastSeenAt?: string; // ISO 8601, undefined khi đang online
1322
+ }
1323
+
1324
+ interface NewMessageNotificationDto {
1325
+ conversationId: string;
1326
+ conversationType: ConversationType;
1327
+ conversationName?: string;
1328
+ messageId: string;
1329
+ senderId: string;
1330
+ senderName: string;
1331
+ senderAvatar?: MediaReference;
1332
+ contentPreview: string;
1333
+ sentAt: string; // ISO 8601
1334
+ }
1335
+
1336
+ // Chú ý: KHÔNG có senderAvatar (khác NewMessageNotificationDto)
1337
+ interface MentionedNotificationDto {
1338
+ conversationId: string;
1339
+ messageId: string;
1340
+ senderId: string;
1341
+ senderName: string;
1342
+ contentPreview: string;
1343
+ sentAt: string; // ISO 8601
1344
+ }
1345
+
1346
+ interface UnreadCountChangedDto {
1347
+ conversationId: string;
1348
+ unreadCount: number;
1349
+ lastMessageAt: string; // ISO 8601
1350
+ lastMessageId: string;
1351
+ }
1352
+
1353
+ interface ConversationCreatedDto {
1354
+ conversationId: string;
1355
+ type: ConversationType;
1356
+ name?: string;
1357
+ avatar?: MediaReference;
1358
+ participantCount: number;
1359
+ }
1360
+
1361
+ interface ConversationUpdatedDto {
1362
+ conversationId: string;
1363
+ type: ConversationType;
1364
+ name?: string;
1365
+ avatar?: MediaReference | null; // null = avatar đã bị xóa
1366
+ participantCount: number;
1367
+ }
1368
+
1369
+ interface ParticipantJoinedDto {
1370
+ conversationId: string;
1371
+ participantId: string;
1372
+ participantName: string;
1373
+ participantAvatar?: MediaReference;
1374
+ changedAt: string; // ISO 8601
1375
+ }
1376
+
1377
+ // Cùng shape với ParticipantJoinedDto
1378
+ interface ParticipantLeftDto {
1379
+ conversationId: string;
1380
+ participantId: string;
1381
+ participantName: string;
1382
+ participantAvatar?: MediaReference;
1383
+ changedAt: string; // ISO 8601
1384
+ }
1385
+
1386
+ interface ConversationPinnedDto {
1387
+ conversationId: string;
1388
+ pinnedAt: string; // ISO 8601
1389
+ }
1390
+
1391
+ // Chú ý: KHÔNG có pinnedAt (khác ConversationPinnedDto)
1392
+ interface ConversationUnpinnedDto {
1393
+ conversationId: string;
1394
+ }
1395
+
1396
+ interface ReadStatusChangedDto {
1397
+ conversationId: string;
1398
+ lastReadMessageId?: string;
1399
+ unreadCount: number;
1400
+ markedAsUnread: boolean; // true khi user chủ động đánh dấu chưa đọc
1401
+ }
1402
+ ```
1403
+
1404
+ ---
1405
+
1406
+ ## 14. ReconnectionManager
1407
+
1408
+ 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
+
1410
+ ```ts
1411
+ import { ReconnectionManager } from 'chat-client-sdk';
1412
+
1413
+ const manager = new ReconnectionManager({
1414
+ chatHub: client.realtime.chat,
1415
+ notificationHub: client.realtime.notifications,
1416
+ onTokenExpired: async () => {
1417
+ // Gọi API refresh token của ứng dụng
1418
+ const newToken = await refreshToken();
1419
+ if (newToken) {
1420
+ client.setToken(newToken); // Cập nhật token vào SDK
1421
+ }
1422
+ return newToken; // Trả về null để hủy reconnect
1423
+ },
1424
+ });
1425
+
1426
+ manager.start(); // Bắt đầu quản lý — idempotent, gọi nhiều lần an toàn
1427
+
1428
+ // Khi không còn cần
1429
+ manager.stop();
1430
+ ```
1431
+
1432
+ **Chiến lược backoff:** 3 lần thử với delay 2s → 5s → 10s.
1433
+
1434
+ **Phát hiện token hết hạn:** Kiểm tra error message có chứa `"401"` hoặc `"unauthorized"`.
1435
+
1436
+ ---
1437
+
1438
+ ## 15. Hệ thống Block (nội dung tin nhắn)
1439
+
1440
+ `Block` là **discriminated union** — mỗi block phân biệt bằng field `$type`.
1441
+
1442
+ ### Các loại Block
1443
+
1444
+ | `$type` | Interface | Mô tả |
1445
+ |---------|-----------|-------|
1446
+ | `"text"` | `TextBlock` | Văn bản (Plain, Markdown, Html, ProseMirrorJson) |
1447
+ | `"image"` | `ImageBlock` | Hình ảnh |
1448
+ | `"video"` | `VideoBlock` | Video |
1449
+ | `"audio"` | `AudioBlock` | Âm thanh |
1450
+ | `"file"` | `FileBlock` | Tệp tin |
1451
+ | `"linkPreview"` | `LinkPreviewBlock` | Preview URL |
1452
+ | `"embed"` | `EmbedBlock` | Nhúng nội dung (iframe) |
1453
+ | `"location"` | `LocationBlock` | Vị trí địa lý |
1454
+ | `"contact"` | `ContactBlock` | Thông tin liên hệ |
1455
+ | `"choice"` | `ChoiceBlock` | Câu hỏi / lựa chọn (bot) |
1456
+ | `"card"` | `CardBlock` | Thẻ thông tin có button |
1457
+ | `"carousel"` | `CarouselBlock` | Nhiều card liên tiếp |
1458
+ | `"divider"` | `DividerBlock` | Đường phân cách |
1459
+ | `"custom"` | `CustomBlock` | Block tùy chỉnh |
1460
+
1461
+ ### Interfaces tham chiếu đầy đủ
1462
+
1463
+ ```ts
1464
+ interface TextBlock {
1465
+ $type: 'text';
1466
+ format: TextFormat; // 'Plain' | 'Markdown' | 'Html' | 'ProseMirrorJson'
1467
+ content: string;
1468
+ plainText: string | null;
1469
+ }
1470
+
1471
+ interface ImageBlock {
1472
+ $type: 'image';
1473
+ source: MediaReference;
1474
+ thumbnail: MediaReference | null;
1475
+ altText: string | null;
1476
+ width: number | null;
1477
+ height: number | null;
1478
+ caption: string | null;
1479
+ fileSizeBytes: number | null;
1480
+ }
1481
+
1482
+ interface VideoBlock {
1483
+ $type: 'video';
1484
+ source: MediaReference;
1485
+ thumbnail: MediaReference | null;
1486
+ caption: string | null;
1487
+ durationSeconds: number | null;
1488
+ mimeType: string | null;
1489
+ fileSizeBytes: number | null;
1490
+ }
1491
+
1492
+ interface AudioBlock {
1493
+ $type: 'audio';
1494
+ source: MediaReference;
1495
+ durationSeconds: number | null;
1496
+ mimeType: string | null;
1497
+ transcript: string | null; // Bản ghi âm thanh (nếu có)
1498
+ fileSizeBytes: number | null;
1499
+ }
1500
+
1501
+ interface FileBlock {
1502
+ $type: 'file';
1503
+ source: MediaReference;
1504
+ fileName: string;
1505
+ mimeType: string | null;
1506
+ fileSizeBytes: number | null;
1507
+ }
1508
+
1509
+ interface LinkPreviewBlock {
1510
+ $type: 'linkPreview';
1511
+ url: string;
1512
+ title: string | null;
1513
+ description: string | null;
1514
+ image: MediaReference | null;
1515
+ siteName: string | null;
1516
+ }
1517
+
1518
+ interface EmbedBlock {
1519
+ $type: 'embed';
1520
+ url: string;
1521
+ html: string | null; // HTML nhúng (iframe snippet)
1522
+ width: number | null;
1523
+ height: number | null;
1524
+ }
1525
+
1526
+ interface LocationBlock {
1527
+ $type: 'location';
1528
+ latitude: number;
1529
+ longitude: number;
1530
+ name: string | null;
1531
+ address: string | null;
1532
+ }
1533
+
1534
+ interface ContactBlock {
1535
+ $type: 'contact';
1536
+ displayName: string;
1537
+ phone: string | null;
1538
+ email: string | null;
1539
+ organization: string | null;
1540
+ }
1541
+
1542
+ interface ChoiceBlock {
1543
+ $type: 'choice';
1544
+ prompt: string; // Câu hỏi hiển thị cho user
1545
+ options: ChoiceOption[];
1546
+ mode: ChoiceMode; // 'Single' | 'Multiple'
1547
+ submitted: boolean; // true sau khi user đã submit lựa chọn
1548
+ }
1549
+
1550
+ interface CardBlock {
1551
+ $type: 'card';
1552
+ title: string | null;
1553
+ subtitle: string | null;
1554
+ image: MediaReference | null;
1555
+ fields: CardField[] | null;
1556
+ buttons: ActionButton[] | null;
1557
+ }
1558
+
1559
+ interface CarouselBlock {
1560
+ $type: 'carousel';
1561
+ cards: CardBlock[]; // Danh sách card trình chiếu ngang
1562
+ }
1563
+
1564
+ interface DividerBlock {
1565
+ $type: 'divider'; // Đường phân cách — không có field nào khác
1566
+ }
1567
+
1568
+ interface CustomBlock {
1569
+ $type: 'custom';
1570
+ type: string; // Discriminator tùy chỉnh của ứng dụng
1571
+ data: Record<string, unknown> | null;
1572
+ }
1573
+ ```
1574
+
1575
+ ### Type Guards
1576
+
1577
+ 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
+
1579
+ ```ts
1580
+ import { isTextBlock, isImageBlock, isFileBlock, isCardBlock } from 'chat-client-sdk';
1581
+
1582
+ for (const block of message.blocks) {
1583
+ if (isTextBlock(block)) {
1584
+ console.log(block.content); // TypeScript biết đây là TextBlock
1585
+ } else if (isImageBlock(block)) {
1586
+ console.log(block.source.url); // ImageBlock
1587
+ } else if (isFileBlock(block)) {
1588
+ console.log(block.fileName, block.fileSizeBytes);
1589
+ } else if (isCardBlock(block)) {
1590
+ block.buttons?.forEach(btn => console.log(btn.label, btn.action));
1591
+ }
1592
+ }
1593
+ ```
1594
+
1595
+ Danh sách đầy đủ: `isTextBlock`, `isImageBlock`, `isVideoBlock`, `isAudioBlock`, `isFileBlock`, `isLinkPreviewBlock`, `isEmbedBlock`, `isLocationBlock`, `isContactBlock`, `isChoiceBlock`, `isCardBlock`, `isCarouselBlock`, `isDividerBlock`, `isCustomBlock`.
1596
+
1597
+ ### Ví dụ tạo block
1598
+
1599
+ ```ts
1600
+ // Văn bản Markdown
1601
+ const textBlock: TextBlock = {
1602
+ $type: 'text',
1603
+ format: 'Markdown',
1604
+ content: '**Hello** _world_!',
1605
+ plainText: 'Hello world!',
1606
+ };
1607
+
1608
+ // Hình ảnh đã upload
1609
+ const imageBlock: ImageBlock = {
1610
+ $type: 'image',
1611
+ source: { storageKey: '01HXABCDEF/photo.jpg' },
1612
+ thumbnail: null,
1613
+ altText: 'Ảnh chụp',
1614
+ width: 1920,
1615
+ height: 1080,
1616
+ caption: 'Ảnh mô tả',
1617
+ fileSizeBytes: 204800,
1618
+ };
1619
+
1620
+ // Gửi tin nhắn kèm file
1621
+ const fileBlock: FileBlock = {
1622
+ $type: 'file',
1623
+ source: { storageKey: '01HXABCDEF/report.pdf' },
1624
+ fileName: 'report.pdf',
1625
+ mimeType: 'application/pdf',
1626
+ fileSizeBytes: 1048576,
1627
+ };
1628
+ ```
1629
+
1630
+ ### TextFormat
1631
+
1632
+ | Giá trị | Mô tả |
1633
+ |---------|-------|
1634
+ | `'Plain'` | Văn bản thuần |
1635
+ | `'Markdown'` | Markdown |
1636
+ | `'Html'` | HTML (render trong WebView) |
1637
+ | `'ProseMirrorJson'` | Rich text dạng JSON (ProseMirror) |
1638
+
1639
+ ### ButtonAction / ButtonStyle (dùng trong CardBlock)
1640
+
1641
+ ```ts
1642
+ type ButtonAction = 'Postback' | 'Url' | 'Call' | 'Copy';
1643
+ type ButtonStyle = 'Default' | 'Primary' | 'Danger';
1644
+
1645
+ interface ActionButton {
1646
+ label: string;
1647
+ action: ButtonAction;
1648
+ value: string | null;
1649
+ style: ButtonStyle;
1650
+ }
1651
+
1652
+ interface CardField {
1653
+ label: string;
1654
+ value: string;
1655
+ }
1656
+ ```
1657
+
1658
+ ### ChoiceMode / ChoiceOption / ChoiceBlock
1659
+
1660
+ ```ts
1661
+ type ChoiceMode = 'Single' | 'Multiple';
1662
+
1663
+ interface ChoiceOption {
1664
+ label: string;
1665
+ value: string;
1666
+ selected: boolean;
1667
+ }
1668
+
1669
+ interface ChoiceBlock {
1670
+ $type: 'choice';
1671
+ prompt: string; // Câu hỏi hiển thị cho user
1672
+ options: ChoiceOption[];
1673
+ mode: ChoiceMode;
1674
+ submitted: boolean; // true sau khi user đã submit lựa chọn
1675
+ }
1676
+ ```
1677
+
1678
+ ### QuickReply
1679
+
1680
+ Nút bấm nhanh hiển thị bên dưới tin nhắn bot:
1681
+
1682
+ ```ts
1683
+ interface QuickReply {
1684
+ label: string; // Text hiển thị
1685
+ action: QuickReplyAction; // 'SendText' | 'Postback' | 'Url'
1686
+ value: string | null;
1687
+ icon: string | null;
1688
+ }
1689
+ ```
1690
+
1691
+ ### Mention
1692
+
1693
+ ```ts
1694
+ interface Mention {
1695
+ type: MentionType; // 'User' | 'All' | 'Here' | 'Role'
1696
+ targetId: string | null; // User ID (null cho All/Here)
1697
+ displayName: string;
1698
+ offset: number | null; // Vị trí trong plainText
1699
+ length: number | null;
1700
+ }
1701
+ ```
1702
+
1703
+ ---
1704
+
1705
+ ## 16. Toàn bộ kiểu dữ liệu (Types)
1706
+
1707
+ ### PaginatedResult (cursor-based)
1708
+
1709
+ ```ts
1710
+ interface PaginatedResult<T> {
1711
+ items: T[];
1712
+ nextCursor: string | null; // PHẢI là string — KHÔNG cast sang number
1713
+ hasMore: boolean;
1714
+ }
1715
+ ```
1716
+
1717
+ ### PagedResult (page-based)
1718
+
1719
+ ```ts
1720
+ interface PagedResult<T> {
1721
+ items: T[];
1722
+ page: number;
1723
+ pageSize: number;
1724
+ totalCount: number;
1725
+ }
1726
+ ```
1727
+
1728
+ ### MediaReference
1729
+
1730
+ ```ts
1731
+ 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)
1734
+ }
1735
+ ```
1736
+
1737
+ ### ChatMessageDto (SignalR version)
1738
+
1739
+ Khác với `MessageDto` (REST):
1740
+
1741
+ ```ts
1742
+ interface ChatMessageDto {
1743
+ id: string;
1744
+ conversationId: string;
1745
+ senderId: string;
1746
+ senderName: string; // Có ở SignalR, không có ở REST
1747
+ senderAvatar?: MediaReference; // Có ở SignalR, không có ở REST
1748
+ senderType: SenderType; // Có ở cả REST và SignalR
1749
+ blocks: Block[];
1750
+ plainTextContent: string | null; // Tên khác với REST (plainTextIndex)
1751
+ replyToMessageId: string | null;
1752
+ quickReplies: QuickReply[] | null;
1753
+ isEdited: boolean;
1754
+ reactions: ReactionGroupDto[] | null; // Flat array, khác REST
1755
+ mentions: Mention[] | null;
1756
+ createdAt: string;
1757
+ updatedAt: string | null;
1758
+ clientMessageId: string | null;
1759
+ systemEvent: SystemEventDto | null;
1760
+ }
1761
+
1762
+ // Reaction group (SignalR version — flat array, KHÔNG gói trong ReactionSummary)
1763
+ // So sánh với REST: ReactionSummary { groups: ReactionGroup[], totalCount }
1764
+ interface ReactionGroupDto {
1765
+ emoji: string;
1766
+ count: number;
1767
+ participantIds: string[]; // Danh sách participant đã react
1768
+ }
1769
+ ```
1770
+
1771
+ ### SystemEvent
1772
+
1773
+ ```ts
1774
+ type SystemEventType =
1775
+ | 'ConversationRenamed' // Đổi tên nhóm
1776
+ | 'AvatarChanged' // Đổi avatar
1777
+ | 'MemberAdded' // Thêm thành viên
1778
+ | 'MemberRemoved' // Xóa thành viên
1779
+ | 'MemberLeft' // Thành viên tự rời
1780
+ | 'Custom';
1781
+
1782
+ interface SystemEventInfo {
1783
+ type: SystemEventType;
1784
+ actorId: string | null; // null khi MemberLeft (tự rời)
1785
+ targetIds?: string[];
1786
+ metadata?: Record<string, unknown>;
1787
+ }
1788
+
1789
+ // SignalR (ChatHub) version — kế thừa SystemEventInfo, thêm display names
1790
+ interface SystemEventDto extends SystemEventInfo {
1791
+ actorName?: string; // Tên hiển thị của actor
1792
+ targetNames?: string[]; // Tên hiển thị của các target participants
1793
+ }
1794
+ ```
1795
+
1796
+ ### HubErrorDto
1797
+
1798
+ ```ts
1799
+ interface HubErrorDto {
1800
+ code: string;
1801
+ message: string;
1802
+ details?: Record<string, unknown>;
1803
+ }
1804
+ ```
1805
+
1806
+ ### Streaming DTOs (ChatHub)
1807
+
1808
+ ```ts
1809
+ interface StreamStartedDto {
1810
+ streamId: string;
1811
+ messageId: string;
1812
+ conversationId: string;
1813
+ senderId: string;
1814
+ senderName: string;
1815
+ senderAvatar?: MediaReference;
1816
+ replyToMessageId?: string;
1817
+ startedAt: string; // ISO 8601
1818
+ }
1819
+
1820
+ interface StreamStatusUpdatedDto {
1821
+ streamId: string;
1822
+ messageId: string;
1823
+ conversationId: string;
1824
+ status: string; // e.g. "thinking", "searching"
1825
+ detail?: string;
1826
+ updatedAt: string; // ISO 8601
1827
+ }
1828
+
1829
+ interface StreamChunkReceivedDto {
1830
+ streamId: string;
1831
+ messageId: string;
1832
+ conversationId: string;
1833
+ text: string; // Chunk text để append vào placeholder
1834
+ chunkIndex: number; // Zero-based index
1835
+ }
1836
+
1837
+ interface StreamCompletedDto {
1838
+ streamId: string;
1839
+ messageId: string;
1840
+ conversationId: string;
1841
+ message: ChatMessageDto; // Tin nhắn hoàn chỉnh (SignalR, không phải REST MessageDto)
1842
+ completedAt: string; // ISO 8601
1843
+ }
1844
+
1845
+ interface StreamAbortedDto {
1846
+ streamId: string;
1847
+ messageId: string;
1848
+ conversationId: string;
1849
+ reason: string;
1850
+ abortedAt: string; // ISO 8601
1851
+ }
1852
+ ```
1853
+
1854
+ ### Ack types (return value của Hub methods)
1855
+
1856
+ Mỗi Hub method client→server trả về một ack object với `success: boolean` và optional error fields:
1857
+
1858
+ ```ts
1859
+ interface SendMessageAck {
1860
+ success: boolean;
1861
+ messageId?: string;
1862
+ clientMessageId?: string;
1863
+ timestamp?: string; // ISO 8601
1864
+ errorCode?: string;
1865
+ errorMessage?: string;
1866
+ }
1867
+
1868
+ interface EditMessageAck {
1869
+ success: boolean;
1870
+ messageId?: string;
1871
+ editedAt?: string; // ISO 8601
1872
+ errorCode?: string;
1873
+ errorMessage?: string;
1874
+ }
1875
+
1876
+ interface DeleteMessageAck {
1877
+ success: boolean;
1878
+ messageId?: string;
1879
+ deletedAt?: string; // ISO 8601
1880
+ errorCode?: string;
1881
+ errorMessage?: string;
1882
+ }
1883
+
1884
+ interface RecoverMessageAck {
1885
+ success: boolean;
1886
+ messageId?: string;
1887
+ errorCode?: string;
1888
+ errorMessage?: string;
1889
+ }
1890
+
1891
+ interface ReactionAck {
1892
+ success: boolean;
1893
+ messageId?: string;
1894
+ emoji?: string;
1895
+ errorCode?: string;
1896
+ errorMessage?: string;
1897
+ }
1898
+
1899
+ interface MarkAsReadAck {
1900
+ success: boolean;
1901
+ conversationId?: string;
1902
+ errorCode?: string;
1903
+ errorMessage?: string;
1904
+ }
1905
+ ```
1906
+
1907
+ ---
1908
+
1909
+ ## 17. Xử lý lỗi — ChatApiError
1910
+
1911
+ Tất cả lỗi HTTP đều throw `ChatApiError` (kế thừa `Error`).
1912
+
1913
+ ```ts
1914
+ import { ChatApiError } from 'chat-client-sdk';
1915
+
1916
+ try {
1917
+ await client.messages.send('conv-id', { blocks: [...] });
1918
+ } catch (err) {
1919
+ if (err instanceof ChatApiError) {
1920
+ console.error(`HTTP ${err.status}: ${err.title}`);
1921
+ console.error('Chi tiết:', err.detail);
1922
+
1923
+ switch (err.status) {
1924
+ case 401: // Unauthorized — token hết hạn/không hợp lệ
1925
+ await refreshAndRetry();
1926
+ break;
1927
+ case 403: // Forbidden — không có quyền
1928
+ break;
1929
+ case 404: // Not Found
1930
+ break;
1931
+ case 409: // Conflict — ví dụ: đã react emoji đó rồi
1932
+ break;
1933
+ case 429: // Rate Limited
1934
+ break;
1935
+ }
1936
+ }
1937
+ }
1938
+ ```
1939
+
1940
+ ### Thuộc tính ChatApiError
1941
+
1942
+ | Thuộc tính | Kiểu | Mô tả |
1943
+ |------------|------|-------|
1944
+ | `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`) |
1947
+ | `message` | `string` | `detail ?? title` (kế thừa từ `Error`) |
1948
+ | `name` | `string` | Luôn là `"ChatApiError"` |
1949
+
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.
1951
+
1952
+ ### Static methods
1953
+
1954
+ #### `ChatApiError.fromResponse(response: Response): Promise<ChatApiError>`
1955
+
1956
+ Parse lỗi từ HTTP `Response`. SDK gọi nội bộ — bạn không cần gọi trực tiếp.
1957
+
1958
+ #### `ChatApiError.fromProblemDetails(pd: ProblemDetails): ChatApiError`
1959
+
1960
+ 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
+
1962
+ ```ts
1963
+ import { ChatApiError } from 'chat-client-sdk';
1964
+ import type { ProblemDetails } from 'chat-client-sdk';
1965
+
1966
+ const pd: ProblemDetails = { status: 404, title: 'Not Found', detail: 'Conversation not found' };
1967
+ const error = ChatApiError.fromProblemDetails(pd);
1968
+ console.log(error.status); // 404
1969
+ console.log(error.message); // "Conversation not found"
1970
+ ```
1971
+
1972
+ ### ProblemDetails
1973
+
1974
+ Kiểu dữ liệu RFC 7807 mà server trả về khi có lỗi:
1975
+
1976
+ ```ts
1977
+ interface ProblemDetails {
1978
+ status: number;
1979
+ title: string;
1980
+ detail?: string;
1981
+ type?: string; // URI reference — thường không dùng trực tiếp
1982
+ }
1983
+ ```
1984
+
1985
+ ---
1986
+
1987
+ ### Sử dụng hub client độc lập (không qua ChatClient)
1988
+
1989
+ Power users có thể khởi tạo `ChatHubClient` hoặc `NotificationHubClient` trực tiếp, không qua `ChatClient` facade:
1990
+
1991
+ ```ts
1992
+ import { ChatHubClient, NotificationHubClient } from 'chat-client-sdk';
1993
+ import { LogLevel } from '@microsoft/signalr';
1994
+
1995
+ // Tự quản lý token
1996
+ const tokenProvider = () => localStorage.getItem('jwt');
1997
+
1998
+ // Khởi tạo ChatHub độc lập
1999
+ const chatHub = new ChatHubClient({
2000
+ hubUrl: 'https://chat-api.example.com/hubs/chat',
2001
+ tokenProvider,
2002
+ logLevel: LogLevel.Warning, // optional
2003
+ });
2004
+
2005
+ // Khởi tạo NotificationHub độc lập
2006
+ const notificationHub = new NotificationHubClient({
2007
+ hubUrl: 'https://chat-api.example.com/hubs/notifications',
2008
+ tokenProvider,
2009
+ });
2010
+
2011
+ await chatHub.connect();
2012
+ await notificationHub.connect();
2013
+
2014
+ // Kết hợp với ReconnectionManager
2015
+ import { ReconnectionManager } from 'chat-client-sdk';
2016
+
2017
+ const manager = new ReconnectionManager({
2018
+ chatHub,
2019
+ notificationHub,
2020
+ onTokenExpired: async () => {
2021
+ const newToken = await refreshMyToken();
2022
+ return newToken;
2023
+ },
2024
+ });
2025
+ manager.start();
2026
+ ```
2027
+
2028
+ > ⚠️ Khi dùng standalone, bạn phải tự quản lý token và truyền đúng `hubUrl` (bao gồm full URL kèm `/hubs/chat` hoặc `/hubs/notifications`).
2029
+
2030
+ ---
2031
+
2032
+ ## 18. TypedEventEmitter
2033
+
2034
+ SDK dùng `TypedEventEmitter<TEventMap>` nội bộ cho cả hai hub. Giao diện public của nó được expose qua `.on()` và `.off()`.
2035
+
2036
+ ```ts
2037
+ // Đăng ký handler — trả về hàm unsubscribe
2038
+ const unsub = hub.on('eventName', (payload) => { /* ... */ });
2039
+
2040
+ // Hủy bằng hàm unsubscribe
2041
+ unsub();
2042
+
2043
+ // Hoặc hủy bằng .off()
2044
+ hub.off('eventName', handler);
2045
+ ```
2046
+
2047
+ **Lưu ý:** Khi dùng `.off()`, phải truyền chính xác **cùng một reference hàm** đã đăng ký. Dùng cách unsubscribe function từ `.on()` để tránh lỗi này.
2048
+
2049
+ > ⚠️ **`removeAllListeners()` không được expose** trên `ChatHubClient` hay `NotificationHubClient`. Method này chỉ tồn tại trên class `TypedEventEmitter` khi dùng trực tiếp — không qua hub. Hai hub client chỉ expose `.on()` và `.off()`. Để dọn dẹp nhiều listener khi unmount component, lưu lại từng unsubscribe function và gọi từng cái:
2050
+
2051
+ ```ts
2052
+ const unsubs: Array<() => void> = [];
2053
+ unsubs.push(hub.on('messageReceived', handler1));
2054
+ unsubs.push(hub.on('typingStarted', handler2));
2055
+
2056
+ // Khi cleanup:
2057
+ unsubs.forEach(fn => fn());
2058
+ ```
2059
+
2060
+ ---
2061
+
2062
+ ## 19. Lưu ý quan trọng
2063
+
2064
+ ### Cursor pagination — KHÔNG cast sang number
2065
+
2066
+ ```ts
2067
+ // ✅ Đúng
2068
+ const cursor: string = result.nextCursor!;
2069
+ await client.conversations.list({ cursor });
2070
+
2071
+ // ❌ Sai — mất độ chính xác vì vượt Number.MAX_SAFE_INTEGER
2072
+ const cursor = Number(result.nextCursor); // BUG!
2073
+ ```
2074
+
2075
+ ### Token và SignalR
2076
+
2077
+ - `setToken()` hoạt động ngay cho **HTTP requests**.
2078
+ - SignalR đọc token khi **connect()**, không phải mid-session.
2079
+ - Để cập nhật token cho SignalR: `disconnect()` → `setToken()` → `connect()`.
2080
+
2081
+ ### REST vs SignalR — field name khác nhau
2082
+
2083
+ | Thao tác | REST | SignalR |
2084
+ |----------|------|---------|
2085
+ | Edit message | `{ blocks, mentions }` | `{ messageId, newBlocks, newMentions }` |
2086
+ | Delete message | `messages.delete(messageId)` | `deleteMessage({ messageId })` — object, không phải string |
2087
+ | Stream completion | Không có event | `streamCompleted` thay `messageReceived` |
2088
+
2089
+ ### File upload — storageKey vs URL
2090
+
2091
+ ```ts
2092
+ // Sau khi upload, dùng storageKey làm MediaReference
2093
+ const { storageKey } = await client.files.uploadFile(file);
2094
+
2095
+ // Trong block:
2096
+ const block: ImageBlock = {
2097
+ $type: 'image',
2098
+ source: { storageKey }, // KHÔNG phải url
2099
+ // ...
2100
+ };
2101
+
2102
+ // Để hiển thị ảnh:
2103
+ const displayUrl = client.files.getDownloadUrl(storageKey);
2104
+ ```
2105
+
2106
+ ### BotDto — không có tên/avatar
2107
+
2108
+ `BotDto` **không** chứa `uniqueName` hay `fullName`. Phải gọi thêm:
2109
+
2110
+ ```ts
2111
+ const bot = await client.bots.getById('bot-id');
2112
+ const participant = await client.participants.getById(bot.participantId);
2113
+ console.log(participant.fullName); // Tên hiển thị của bot
2114
+ ```
2115
+
2116
+ ### HealthStatus duration — là string, không phải number
2117
+
2118
+ ```ts
2119
+ const health = await client.health.ready();
2120
+ // ✅ Đúng
2121
+ console.log(health.totalDuration); // "00:00:00.1234567"
2122
+
2123
+ // ❌ Sai
2124
+ const ms = Number(health.totalDuration); // NaN
2125
+ ```
2126
+
2127
+ ---
2128
+
2129
+ ## 20. Ví dụ đầy đủ
2130
+
2131
+ ### Ví dụ 1: Chat app cơ bản
2132
+
2133
+ ```ts
2134
+ import { ChatClient, ChatApiError } from 'chat-client-sdk';
2135
+ import type { ChatMessageDto } from 'chat-client-sdk';
2136
+
2137
+ // 1. Khởi tạo
2138
+ const client = new ChatClient({ baseUrl: 'https://chat-api.example.com' });
2139
+
2140
+ // 2. Đăng nhập
2141
+ await client.auth.loginWithGoogle(googleIdToken);
2142
+ console.log('Xin chào,', client.currentUser?.fullName);
2143
+
2144
+ // 3. Kết nối hub (thứ tự: notifications trước, chat sau)
2145
+ await client.realtime.notifications.connect();
2146
+ await client.realtime.chat.connect();
2147
+
2148
+ // 4. Lắng nghe thông báo
2149
+ client.realtime.notifications.on('newMessageNotification', (notification) => {
2150
+ console.log(`[${notification.conversationId}] ${notification.senderName}: ${notification.contentPreview}`);
2151
+ });
2152
+
2153
+ client.realtime.notifications.on('unreadCountChanged', (dto) => {
2154
+ updateBadge(dto.conversationId, dto.unreadCount);
2155
+ });
2156
+
2157
+ // 5. Mở cuộc hội thoại và chat
2158
+ const convId = 'conv-id-here';
2159
+ await client.realtime.chat.joinConversation(convId);
2160
+
2161
+ // Lắng nghe tin nhắn
2162
+ const unsubMsg = client.realtime.chat.on('messageReceived', (msg: ChatMessageDto) => {
2163
+ appendMessageToUI(msg);
2164
+ });
2165
+
2166
+ // Lắng nghe typing
2167
+ client.realtime.chat.on('typingStarted', (dto) => {
2168
+ showTypingIndicator(dto.participantName);
2169
+ });
2170
+
2171
+ // 6. Gửi tin nhắn
2172
+ async function sendMessage(text: string) {
2173
+ const ack = await client.realtime.chat.sendMessage({
2174
+ conversationId: convId,
2175
+ blocks: [{ $type: 'text', format: 'Plain', content: text, plainText: text }],
2176
+ });
2177
+ if (!ack.success) throw new Error(ack.errorMessage);
2178
+ }
2179
+
2180
+ // 7. Upload và gửi file
2181
+ async function sendFile(file: File) {
2182
+ const { storageKey } = await client.files.uploadFile(file, (loaded, total) => {
2183
+ setProgress(Math.round((loaded / total) * 100));
2184
+ });
2185
+
2186
+ await client.realtime.chat.sendMessage({
2187
+ conversationId: convId,
2188
+ blocks: [{
2189
+ $type: 'file',
2190
+ source: { storageKey },
2191
+ fileName: file.name,
2192
+ mimeType: file.type,
2193
+ fileSizeBytes: file.size,
2194
+ }],
2195
+ });
2196
+ }
2197
+
2198
+ // 8. Đánh máy indicator
2199
+ let typingTimer: ReturnType<typeof setTimeout>;
2200
+ function onUserType() {
2201
+ client.realtime.chat.startTyping(convId);
2202
+ clearTimeout(typingTimer);
2203
+ typingTimer = setTimeout(() => client.realtime.chat.stopTyping(convId), 2000);
2204
+ }
2205
+
2206
+ // 9. Dọn dẹp khi thoát
2207
+ async function cleanup() {
2208
+ unsubMsg(); // Hủy event listener
2209
+ await client.disconnect();
2210
+ }
2211
+ ```
2212
+
2213
+ ### Ví dụ 2: Load lịch sử tin nhắn với infinite scroll
2214
+
2215
+ ```ts
2216
+ let cursor: string | null = null;
2217
+ let loading = false;
2218
+
2219
+ async function loadMoreMessages(conversationId: string) {
2220
+ if (loading) return;
2221
+ loading = true;
2222
+
2223
+ try {
2224
+ const result = await client.messages.getHistory(conversationId, {
2225
+ limit: 30,
2226
+ cursor: cursor ?? undefined,
2227
+ direction: 'older',
2228
+ });
2229
+
2230
+ result.items.forEach(msg => prependMessageToUI(msg));
2231
+ cursor = result.nextCursor;
2232
+
2233
+ if (!result.hasMore) {
2234
+ hideLoadMoreButton();
2235
+ }
2236
+ } finally {
2237
+ loading = false;
2238
+ }
2239
+ }
2240
+ ```
2241
+
2242
+ ### Ví dụ 3: Hiển thị trạng thái online
2243
+
2244
+ ```ts
2245
+ await client.realtime.notifications.connect();
2246
+
2247
+ // Load danh sách participant trong cuộc hội thoại
2248
+ const participants = await client.conversations.getParticipants('conv-id');
2249
+ const participantIds = participants.map(p => p.participantId);
2250
+
2251
+ // Subscribe presence
2252
+ await client.realtime.notifications.subscribeToPresence(participantIds);
2253
+
2254
+ // Nhận trạng thái ban đầu
2255
+ client.realtime.notifications.on('presenceState', (states) => {
2256
+ states.forEach(s => {
2257
+ updateOnlineIndicator(s.participantId, s.isOnline);
2258
+ });
2259
+ });
2260
+
2261
+ // Cập nhật real-time
2262
+ client.realtime.notifications.on('presenceChanged', (dto) => {
2263
+ updateOnlineIndicator(dto.participantId, dto.isOnline);
2264
+ if (!dto.isOnline && dto.lastSeenAt) {
2265
+ showLastSeen(dto.participantId, new Date(dto.lastSeenAt));
2266
+ }
2267
+ });
2268
+ ```
2269
+
2270
+ ### Ví dụ 4: Xử lý bot streaming
2271
+
2272
+ ```ts
2273
+ const streamBuffers = new Map<string, string[]>();
2274
+
2275
+ client.realtime.chat.on('streamStarted', (dto) => {
2276
+ streamBuffers.set(dto.streamId, []);
2277
+ addStreamingPlaceholder(dto.messageId, dto.senderName);
2278
+ });
2279
+
2280
+ client.realtime.chat.on('streamStatusUpdated', (dto) => {
2281
+ updateStreamStatus(dto.messageId, dto.status); // e.g. "thinking", "searching"
2282
+ });
2283
+
2284
+ client.realtime.chat.on('streamChunkReceived', (dto) => {
2285
+ const chunks = streamBuffers.get(dto.streamId) ?? [];
2286
+ chunks.push(dto.text);
2287
+ streamBuffers.set(dto.streamId, chunks);
2288
+ updateStreamingText(dto.messageId, chunks.join(''));
2289
+ });
2290
+
2291
+ client.realtime.chat.on('streamCompleted', (dto) => {
2292
+ streamBuffers.delete(dto.streamId);
2293
+ replaceWithFinalMessage(dto.message); // ChatMessageDto đầy đủ
2294
+ });
2295
+
2296
+ client.realtime.chat.on('streamAborted', (dto) => {
2297
+ streamBuffers.delete(dto.streamId);
2298
+ showStreamError(dto.messageId, dto.reason);
2299
+ });
2300
+ ```
2301
+
2302
+ ### Ví dụ 5: Xử lý lỗi và token refresh
2303
+
2304
+ ```ts
2305
+ import { ReconnectionManager } from 'chat-client-sdk';
2306
+
2307
+ let isRefreshing = false;
2308
+
2309
+ const manager = new ReconnectionManager({
2310
+ chatHub: client.realtime.chat,
2311
+ notificationHub: client.realtime.notifications,
2312
+ onTokenExpired: async () => {
2313
+ if (isRefreshing) return null;
2314
+ isRefreshing = true;
2315
+ try {
2316
+ const newToken = await myAuthService.refreshToken();
2317
+ if (newToken) {
2318
+ client.setToken(newToken);
2319
+ localStorage.setItem('token', newToken);
2320
+ }
2321
+ return newToken;
2322
+ } finally {
2323
+ isRefreshing = false;
2324
+ }
2325
+ },
2326
+ });
2327
+
2328
+ manager.start();
2329
+
2330
+ // Lắng nghe lỗi hub
2331
+ client.realtime.chat.on('error', (err) => {
2332
+ console.error(`Hub error [${err.code}]: ${err.message}`);
2333
+ });
2334
+
2335
+ client.realtime.chat.on('disconnected', (err) => {
2336
+ if (err) console.warn('ChatHub disconnected:', err.message);
2337
+ showReconnectingUI();
2338
+ });
2339
+
2340
+ client.realtime.chat.on('reconnected', () => {
2341
+ hideReconnectingUI();
2342
+ });
2343
+ ```