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

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