@manonero/chat-client-sdk 0.0.1-beta.1 → 0.0.1-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +391 -137
- package/dist/ChatClient.d.ts +36 -4
- package/dist/ChatClient.d.ts.map +1 -1
- package/dist/ChatClient.js +13 -9
- package/dist/ChatClient.js.map +1 -1
- package/dist/http/BotApi.d.ts +4 -1
- package/dist/http/BotApi.d.ts.map +1 -1
- package/dist/http/BotApi.js +4 -1
- package/dist/http/BotApi.js.map +1 -1
- package/dist/http/ConversationApi.d.ts +8 -5
- package/dist/http/ConversationApi.d.ts.map +1 -1
- package/dist/http/ConversationApi.js +11 -11
- package/dist/http/ConversationApi.js.map +1 -1
- package/dist/http/FileApi.d.ts +9 -11
- package/dist/http/FileApi.d.ts.map +1 -1
- package/dist/http/FileApi.js +8 -12
- package/dist/http/FileApi.js.map +1 -1
- package/dist/http/SearchApi.d.ts +39 -0
- package/dist/http/SearchApi.d.ts.map +1 -0
- package/dist/http/SearchApi.js +44 -0
- package/dist/http/SearchApi.js.map +1 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/realtime/ChatHubClient.d.ts +29 -2
- package/dist/realtime/ChatHubClient.d.ts.map +1 -1
- package/dist/realtime/ChatHubClient.js +36 -5
- package/dist/realtime/ChatHubClient.js.map +1 -1
- package/dist/realtime/NotificationHubClient.d.ts +11 -0
- package/dist/realtime/NotificationHubClient.d.ts.map +1 -1
- package/dist/realtime/NotificationHubClient.js +7 -3
- package/dist/realtime/NotificationHubClient.js.map +1 -1
- package/dist/realtime/ReconnectionManager.d.ts +9 -1
- package/dist/realtime/ReconnectionManager.d.ts.map +1 -1
- package/dist/realtime/ReconnectionManager.js +12 -2
- package/dist/realtime/ReconnectionManager.js.map +1 -1
- package/dist/realtime/connectionOptions.d.ts +32 -0
- package/dist/realtime/connectionOptions.d.ts.map +1 -0
- package/dist/realtime/connectionOptions.js +55 -0
- package/dist/realtime/connectionOptions.js.map +1 -0
- package/dist/types/block.d.ts +1 -0
- package/dist/types/block.d.ts.map +1 -1
- package/dist/types/block.js.map +1 -1
- package/dist/types/bot.d.ts +64 -2
- package/dist/types/bot.d.ts.map +1 -1
- package/dist/types/broadcast.d.ts +50 -0
- package/dist/types/broadcast.d.ts.map +1 -0
- package/dist/types/broadcast.js +3 -0
- package/dist/types/broadcast.js.map +1 -0
- package/dist/types/chat-events.d.ts +21 -18
- package/dist/types/chat-events.d.ts.map +1 -1
- package/dist/types/common.d.ts +14 -4
- package/dist/types/common.d.ts.map +1 -1
- package/dist/types/conversation.d.ts +2 -2
- package/dist/types/conversation.d.ts.map +1 -1
- package/dist/types/participant.d.ts +5 -0
- package/dist/types/participant.d.ts.map +1 -1
- package/dist/types/search.d.ts +47 -0
- package/dist/types/search.d.ts.map +1 -0
- package/dist/types/search.js +3 -0
- package/dist/types/search.js.map +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,19 +13,20 @@
|
|
|
13
13
|
5. [Participant — ParticipantApi](#5-participant--participantapi)
|
|
14
14
|
6. [Conversation — ConversationApi](#6-conversation--conversationapi)
|
|
15
15
|
7. [Message — MessageApi](#7-message--messageapi)
|
|
16
|
-
8. [
|
|
17
|
-
9. [
|
|
18
|
-
10. [
|
|
19
|
-
11. [
|
|
20
|
-
12. [
|
|
21
|
-
13. [Real-time:
|
|
22
|
-
14. [
|
|
23
|
-
15. [
|
|
24
|
-
16. [
|
|
25
|
-
17. [
|
|
26
|
-
18. [
|
|
27
|
-
19. [
|
|
28
|
-
20. [
|
|
16
|
+
8. [Tìm kiếm — SearchApi](#8-tìm-kiếm--searchapi)
|
|
17
|
+
9. [File — FileApi](#9-file--fileapi)
|
|
18
|
+
10. [Bot — BotApi](#10-bot--botapi)
|
|
19
|
+
11. [Proxy — ProxyApi](#11-proxy--proxyapi)
|
|
20
|
+
12. [Health — HealthApi](#12-health--healthapi)
|
|
21
|
+
13. [Real-time: ChatHubClient](#13-real-time-chathubclient)
|
|
22
|
+
14. [Real-time: NotificationHubClient](#14-real-time-notificationhubclient)
|
|
23
|
+
15. [ReconnectionManager](#15-reconnectionmanager)
|
|
24
|
+
16. [Hệ thống Block (nội dung tin nhắn)](#16-hệ-thống-block-nội-dung-tin-nhắn)
|
|
25
|
+
17. [Toàn bộ kiểu dữ liệu (Types)](#17-toàn-bộ-kiểu-dữ-liệu-types)
|
|
26
|
+
18. [Xử lý lỗi — ChatApiError](#18-xử-lý-lỗi--chatapierror)
|
|
27
|
+
19. [TypedEventEmitter](#19-typedeventemitter)
|
|
28
|
+
20. [Lưu ý quan trọng](#20-lưu-ý-quan-trọng)
|
|
29
|
+
21. [Ví dụ đầy đủ](#21-ví-dụ-đầy-đủ)
|
|
29
30
|
|
|
30
31
|
---
|
|
31
32
|
|
|
@@ -37,6 +38,7 @@ ChatClient (facade chính)
|
|
|
37
38
|
├── participants → ParticipantApi (quản lý người dùng)
|
|
38
39
|
├── conversations → ConversationApi (quản lý cuộc hội thoại)
|
|
39
40
|
├── messages → MessageApi (tin nhắn)
|
|
41
|
+
├── search → SearchApi (tìm kiếm tổng hợp conversations + messages)
|
|
40
42
|
├── files → FileApi (upload/download file)
|
|
41
43
|
├── bots → BotApi (quản lý bot)
|
|
42
44
|
├── proxy → ProxyApi (proxy đến backend khác)
|
|
@@ -92,6 +94,9 @@ import type { HttpClientOptions } from '@manonero/chat-client-sdk';
|
|
|
92
94
|
// Event map types — dùng khi cần typed wrapper
|
|
93
95
|
import type { ChatHubEventMap, NotificationHubEventMap } from '@manonero/chat-client-sdk';
|
|
94
96
|
|
|
97
|
+
// Ephemeral broadcast (dùng chung cho cả hai hub) — generic theo kiểu của `data`
|
|
98
|
+
import type { EphemeralBroadcastDto, BroadcastSenderType } from '@manonero/chat-client-sdk';
|
|
99
|
+
|
|
95
100
|
// Hub options (khi dùng hub client độc lập, không qua ChatClient)
|
|
96
101
|
import type { ChatHubClientOptions, NotificationHubClientOptions } from '@manonero/chat-client-sdk';
|
|
97
102
|
|
|
@@ -187,6 +192,50 @@ const client = new ChatClient({
|
|
|
187
192
|
});
|
|
188
193
|
```
|
|
189
194
|
|
|
195
|
+
### Cấu hình transport SignalR (`connectionOptions`)
|
|
196
|
+
|
|
197
|
+
SDK mặc định kết nối thẳng qua WebSockets, bỏ qua bước negotiate:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
{
|
|
201
|
+
skipNegotiation: true,
|
|
202
|
+
transport: HttpTransportType.WebSockets,
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Mọi giá trị truyền vào `signalrOptions.connectionOptions` sẽ **ghi đè** mặc định trên (đây là toàn bộ `IHttpConnectionOptions` của `@microsoft/signalr`, nên có thể override cả `accessTokenFactory`, `headers`, `withCredentials`, `httpClient`, …):
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
import { HttpTransportType } from '@microsoft/signalr';
|
|
210
|
+
|
|
211
|
+
// Quay lại luồng negotiate tiêu chuẩn (ví dụ khi đứng sau proxy không hỗ trợ WS)
|
|
212
|
+
const client = new ChatClient({
|
|
213
|
+
baseUrl: 'https://chat-api.example.com',
|
|
214
|
+
signalrOptions: {
|
|
215
|
+
connectionOptions: {
|
|
216
|
+
skipNegotiation: false,
|
|
217
|
+
transport: HttpTransportType.WebSockets | HttpTransportType.LongPolling,
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Có thể cấu hình riêng cho từng hub; giá trị riêng được merge đè lên `connectionOptions` chung:
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
const client = new ChatClient({
|
|
227
|
+
baseUrl: 'https://chat-api.example.com',
|
|
228
|
+
signalrOptions: {
|
|
229
|
+
connectionOptions: { withCredentials: false }, // áp dụng cho cả 2 hub
|
|
230
|
+
notificationConnectionOptions: { skipNegotiation: false }, // chỉ NotificationHub
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
> **Lưu ý:** `skipNegotiation: true` chỉ hợp lệ khi `transport` đúng bằng `HttpTransportType.WebSockets` — kể cả tổ hợp cờ như `WebSockets | LongPolling` cũng bị SignalR từ chối. Nếu bạn đổi `transport` sang giá trị khác mà không nói gì về `skipNegotiation`, SDK sẽ tự bỏ mặc định `skipNegotiation: true`; nếu không, lỗi sẽ xuất hiện dưới dạng `connect()` bị reject (SignalR kiểm tra trong `HttpConnection.startInternal`, không phải lúc `build()`).
|
|
236
|
+
>
|
|
237
|
+
> Một key mang giá trị `undefined` được coi là "không cấu hình" và vẫn giữ mặc định — nên `connectionOptions: { transport: config.transport }` với `config.transport` optional sẽ không vô tình xoá mất transport mặc định.
|
|
238
|
+
|
|
190
239
|
---
|
|
191
240
|
|
|
192
241
|
## 3. ChatClient — Facade chính
|
|
@@ -208,6 +257,9 @@ new ChatClient(options: ChatClientOptions)
|
|
|
208
257
|
| `tokenProvider` | `() => string \| null` | ❌ | Hàm lấy token từ bên ngoài |
|
|
209
258
|
| `requestTimeoutMs` | `number \| null` | ❌ | Timeout HTTP / upload tính bằng ms. Mặc định `30000`. Truyền `null` để tắt (hữu ích khi upload file lớn). |
|
|
210
259
|
| `signalrOptions.logLevel` | `LogLevel` | ❌ | Mức log cho SignalR (mặc định: `Warning`) |
|
|
260
|
+
| `signalrOptions.connectionOptions` | `IHttpConnectionOptions` | ❌ | Options truyền cho `withUrl()` của cả 2 hub. Ghi đè mặc định `skipNegotiation: true` + `transport: WebSockets` |
|
|
261
|
+
| `signalrOptions.chatConnectionOptions` | `IHttpConnectionOptions` | ❌ | Chỉ áp dụng cho ChatHub, merge đè lên `connectionOptions` |
|
|
262
|
+
| `signalrOptions.notificationConnectionOptions` | `IHttpConnectionOptions` | ❌ | Chỉ áp dụng cho NotificationHub, merge đè lên `connectionOptions` |
|
|
211
263
|
|
|
212
264
|
### Thuộc tính
|
|
213
265
|
|
|
@@ -217,6 +269,7 @@ new ChatClient(options: ChatClientOptions)
|
|
|
217
269
|
| `participants` | `ParticipantApi` | Quản lý participant |
|
|
218
270
|
| `conversations` | `ConversationApi` | Quản lý cuộc hội thoại |
|
|
219
271
|
| `messages` | `MessageApi` | Thao tác với tin nhắn |
|
|
272
|
+
| `search` | `SearchApi` | Tìm kiếm tổng hợp conversations + messages |
|
|
220
273
|
| `files` | `FileApi` | Upload/download file |
|
|
221
274
|
| `bots` | `BotApi` | Quản lý bot |
|
|
222
275
|
| `proxy` | `ProxyApi` | Proxy đến backend khác |
|
|
@@ -250,9 +303,9 @@ client.clearToken();
|
|
|
250
303
|
|
|
251
304
|
#### `refreshConnections(): Promise<void>`
|
|
252
305
|
|
|
253
|
-
|
|
306
|
+
Reconnect lại từng hub đang ở trạng thái `Connected` với token mới nhất. Hub đang Disconnected/Reconnecting được giữ nguyên. Dùng để áp token mới (sau `setToken()`) cho phiên SignalR đang chạy.
|
|
254
307
|
|
|
255
|
-
>
|
|
308
|
+
> ℹ️ ChatHub **giữ nguyên** các conversation đã join: SDK snapshot danh sách trước khi reconnect rồi tự `joinConversation` lại sau đó (best-effort), nên event theo hội thoại tiếp tục chảy qua lần xoay token. NotificationHub không có khái niệm join nên tự động nhận lại event sau khi reconnect.
|
|
256
309
|
|
|
257
310
|
```ts
|
|
258
311
|
client.setToken(newToken);
|
|
@@ -435,20 +488,24 @@ const conv = await client.conversations.create({
|
|
|
435
488
|
});
|
|
436
489
|
```
|
|
437
490
|
|
|
438
|
-
|
|
491
|
+
> ⚠️ **Đã gỡ bỏ `conversations.search()`** — endpoint backend `GET /api/conversations/search` không còn được proxy qua gateway (trả `404`). Dùng endpoint tìm kiếm tổng hợp `client.search.search()` thay thế — xem [mục 8 — SearchApi](#8-tìm-kiếm--searchapi).
|
|
439
492
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
```ts
|
|
443
|
-
const results = await client.conversations.search('team', 10);
|
|
444
|
-
```
|
|
445
|
-
|
|
446
|
-
### `getById(id: string): Promise<ConversationDto>`
|
|
493
|
+
### `getById(id, options?): Promise<ConversationDto>`
|
|
447
494
|
|
|
448
495
|
```ts
|
|
496
|
+
// Mặc định: bao gồm danh sách thành viên
|
|
449
497
|
const conv = await client.conversations.getById('conv-id-here');
|
|
498
|
+
|
|
499
|
+
// Bỏ danh sách thành viên để giảm payload — khi đó `participants` trả về []
|
|
500
|
+
const light = await client.conversations.getById('conv-id-here', {
|
|
501
|
+
includeParticipants: false,
|
|
502
|
+
});
|
|
450
503
|
```
|
|
451
504
|
|
|
505
|
+
| Param | Kiểu | Mặc định | Mô tả |
|
|
506
|
+
|-------|------|----------|-------|
|
|
507
|
+
| `options.includeParticipants` | `boolean` | `true` | Đặt `false` để bỏ mảng `participants` khỏi response (spec §6.4). Khi `false`, `participants` là `[]`. |
|
|
508
|
+
|
|
452
509
|
### `update(id, request): Promise<ConversationDto>`
|
|
453
510
|
|
|
454
511
|
```ts
|
|
@@ -536,7 +593,7 @@ if (page1.hasMore) {
|
|
|
536
593
|
```ts
|
|
537
594
|
interface ConversationDto {
|
|
538
595
|
id: string;
|
|
539
|
-
name
|
|
596
|
+
name?: string | null;
|
|
540
597
|
avatar?: MediaReference | null;
|
|
541
598
|
ownerId: string;
|
|
542
599
|
lastMessageAt?: string | null; // ISO 8601, null khi chưa có tin nhắn
|
|
@@ -661,12 +718,14 @@ const newer = await client.messages.getHistory('conv-id', {
|
|
|
661
718
|
|
|
662
719
|
### `search(conversationId, query, limit?): Promise<MessageDto[]>`
|
|
663
720
|
|
|
664
|
-
Full-text search trong cuộc hội thoại.
|
|
721
|
+
Full-text search **trong phạm vi một cuộc hội thoại** (`GET /api/conversations/{conversationId}/messages/search`, spec §7.3).
|
|
665
722
|
|
|
666
723
|
```ts
|
|
667
724
|
const results = await client.messages.search('conv-id', 'từ khóa tìm kiếm', 20);
|
|
668
725
|
```
|
|
669
726
|
|
|
727
|
+
> ℹ️ Để tìm kiếm **xuyên suốt tất cả** cuộc hội thoại và tin nhắn, dùng `client.search.search()` — xem [mục 8 — SearchApi](#8-tìm-kiếm--searchapi).
|
|
728
|
+
|
|
670
729
|
### `getById(messageId): Promise<MessageDto>`
|
|
671
730
|
|
|
672
731
|
```ts
|
|
@@ -773,7 +832,67 @@ interface EditMessageRequest {
|
|
|
773
832
|
|
|
774
833
|
---
|
|
775
834
|
|
|
776
|
-
## 8.
|
|
835
|
+
## 8. Tìm kiếm — SearchApi
|
|
836
|
+
|
|
837
|
+
**Truy cập:** `client.search`
|
|
838
|
+
|
|
839
|
+
Endpoint tìm kiếm tổng hợp do gateway cung cấp (`GET /api/search`, spec §6.3) — tìm **conversations**, **messages**, hoặc **cả hai** trong một request. Yêu cầu JWT.
|
|
840
|
+
|
|
841
|
+
> Endpoint này **thay thế** cho `GET /api/conversations/search` và `GET /api/messages/search` (global) của backend — hai endpoint đó không còn được proxy qua gateway. Việc tìm kiếm tin nhắn trong phạm vi **một** cuộc hội thoại vẫn dùng `client.messages.search()` (spec §7.3).
|
|
842
|
+
|
|
843
|
+
### `search(params): Promise<SearchResult>`
|
|
844
|
+
|
|
845
|
+
| Param | Kiểu | Mặc định | Mô tả |
|
|
846
|
+
|-------|------|----------|-------|
|
|
847
|
+
| `q` | `string` | — (**bắt buộc**) | Từ khóa tìm kiếm. Thiếu/rỗng → `400 Bad Request`. |
|
|
848
|
+
| `type` | `'MESSAGE' \| 'CONVERSATION' \| 'BOTH'` | `'BOTH'` | Phạm vi tìm kiếm. Giá trị khác → `400 Bad Request`. |
|
|
849
|
+
| `limit` | `number` | `20` | Số kết quả mỗi loại. |
|
|
850
|
+
| `cursor` | `string` | — | Phân trang — **chỉ áp dụng cho messages** (opaque `nextCursor` từ response trước). |
|
|
851
|
+
|
|
852
|
+
**Hành vi theo `type`:**
|
|
853
|
+
|
|
854
|
+
- `CONVERSATION` — tìm conversations; mỗi item kèm `lastMessage` (full `MessageDto`). `messages` trả về `null`.
|
|
855
|
+
- `MESSAGE` — tìm messages, **gom nhóm theo conversation**; mỗi nhóm kèm thông tin `conversation`. Phân trang (`nextCursor`/`hasMore`) phản ánh messages. `conversations` trả về `null`.
|
|
856
|
+
- `BOTH` — thực hiện cả hai (mặc định).
|
|
857
|
+
|
|
858
|
+
> Response **luôn có cả hai key** `conversations` và `messages`; phần không được yêu cầu bởi `type` sẽ là `null`.
|
|
859
|
+
|
|
860
|
+
```ts
|
|
861
|
+
// Tìm cả hai loại (mặc định)
|
|
862
|
+
const result = await client.search.search({ q: 'hello' });
|
|
863
|
+
|
|
864
|
+
// Conversations: kiểm tra lastMessage.isDeleted trước khi hiển thị
|
|
865
|
+
for (const conv of result.conversations ?? []) {
|
|
866
|
+
const preview = conv.lastMessage?.isDeleted
|
|
867
|
+
? 'Tin nhắn đã xóa'
|
|
868
|
+
: conv.lastMessage?.plainTextIndex ?? '';
|
|
869
|
+
console.log(conv.name, preview);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Messages: gom nhóm theo conversation
|
|
873
|
+
for (const group of result.messages?.groups ?? []) {
|
|
874
|
+
console.log('Trong:', group.conversation?.name ?? '(không rõ)');
|
|
875
|
+
for (const msg of group.messages) console.log(' -', msg.plainTextIndex);
|
|
876
|
+
}
|
|
877
|
+
```
|
|
878
|
+
|
|
879
|
+
```ts
|
|
880
|
+
// Chỉ tìm messages + phân trang
|
|
881
|
+
let page = await client.search.search({ q: 'hello', type: 'MESSAGE', limit: 20 });
|
|
882
|
+
while (page.messages?.hasMore) {
|
|
883
|
+
page = await client.search.search({
|
|
884
|
+
q: 'hello',
|
|
885
|
+
type: 'MESSAGE',
|
|
886
|
+
cursor: page.messages.nextCursor!, // opaque string — KHÔNG cast sang number
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
> ⚠️ Giống `client.conversations.list()`, backend **không** filter `isDeleted` cho `lastMessage` — client **phải** tự kiểm tra `lastMessage.isDeleted` trước khi hiển thị. Nếu batch fetch lỗi, `lastMessage` (của conversation) hoặc `conversation` (của nhóm message) có thể là `null` (degrade graceful).
|
|
892
|
+
|
|
893
|
+
---
|
|
894
|
+
|
|
895
|
+
## 9. File — FileApi
|
|
777
896
|
|
|
778
897
|
**Truy cập:** `client.files`
|
|
779
898
|
|
|
@@ -820,19 +939,9 @@ const result = await client.files.upload(uploadUrl, file, (loaded, total) => {
|
|
|
820
939
|
|
|
821
940
|
> Khi có `onProgress`, SDK dùng XHR thay vì fetch (vì fetch không hỗ trợ upload progress events).
|
|
822
941
|
|
|
823
|
-
### `getDownloadUrl(storageKey, signed
|
|
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)
|
|
942
|
+
### `getDownloadUrl(storageKey, signed): string`
|
|
834
943
|
|
|
835
|
-
|
|
944
|
+
Tạo URL tải file tuyệt đối có chữ ký (dùng để hiển thị ảnh, tải xuống, v.v.). Theo spec §8.3, **cả `expires` lẫn `sig` đều bắt buộc** — không có public unsigned access.
|
|
836
945
|
|
|
837
946
|
```ts
|
|
838
947
|
const url = client.files.getDownloadUrl('01HXABCDEF/photo.jpg', {
|
|
@@ -842,9 +951,9 @@ const url = client.files.getDownloadUrl('01HXABCDEF/photo.jpg', {
|
|
|
842
951
|
// → ".../api/files/01HXABCDEF/photo.jpg?expires=...&sig=abc123..."
|
|
843
952
|
```
|
|
844
953
|
|
|
845
|
-
> Server trả `400 Bad Request` (title `Invalid Signature`) nếu URL bị sửa hoặc đã hết hạn. `expires` và `sig`
|
|
954
|
+
> Server trả `400 Bad Request` (title `Invalid Signature`) nếu URL bị sửa hoặc đã hết hạn. `expires` và `sig` phải lấy từ nguồn server-issued (không tự ghép).
|
|
846
955
|
|
|
847
|
-
> **
|
|
956
|
+
> **Trong thực tế:** Server thường trả sẵn `url` trong `MediaReference` (đã gắn `expires` + `sig`) cho mọi media của tin nhắn / participant. Ưu tiên dùng `MediaReference.url` (xem [MediaReference](#mediareference)) — chỉ gọi `getDownloadUrl()` khi bạn có sẵn cặp `expires`/`sig` từ một response server khác (ví dụ: `downloadUrl` từ `POST /api/files/upload`).
|
|
848
957
|
|
|
849
958
|
> **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
959
|
|
|
@@ -883,7 +992,7 @@ interface UploadResponse {
|
|
|
883
992
|
|
|
884
993
|
---
|
|
885
994
|
|
|
886
|
-
##
|
|
995
|
+
## 10. Bot — BotApi
|
|
887
996
|
|
|
888
997
|
**Truy cập:** `client.bots`
|
|
889
998
|
|
|
@@ -916,17 +1025,31 @@ const bot = await client.bots.create({
|
|
|
916
1025
|
uniqueName: 'my-bot',
|
|
917
1026
|
fullName: 'My Assistant Bot',
|
|
918
1027
|
description: 'Trợ lý AI',
|
|
919
|
-
kafkaTopic: 'my-bot-topic',
|
|
1028
|
+
kafkaTopic: 'my-bot-topic', // optional — null/omit = không nhận event qua Kafka
|
|
1029
|
+
webhookUrl: 'https://bot.example.com/events', // optional — HTTP/2 NDJSON webhook
|
|
1030
|
+
deliveryPreference: 'Both', // optional — 'Both' | 'WebhookOnly' | 'KafkaOnly' (server default 'Both')
|
|
920
1031
|
listenAllGroupMessages: false,
|
|
921
1032
|
});
|
|
922
1033
|
```
|
|
923
1034
|
|
|
1035
|
+
> Bot có thể nhận outgoing event qua `kafkaTopic`, `webhookUrl`, cả hai, hoặc không cấu hình kênh nào. Cả hai field đều **optional** độc lập với nhau.
|
|
1036
|
+
>
|
|
1037
|
+
> `deliveryPreference` chỉ có hiệu lực khi bot có **cả** `kafkaTopic` lẫn `webhookUrl` được cấu hình. Bot chỉ có một kênh sẽ luôn dùng kênh đó bất kể preference.
|
|
1038
|
+
|
|
924
1039
|
### `update(id, request): Promise<BotDto>`
|
|
925
1040
|
|
|
926
1041
|
```ts
|
|
927
1042
|
await client.bots.update('bot-id', {
|
|
928
1043
|
fullName: 'Updated Bot Name',
|
|
929
|
-
|
|
1044
|
+
kafkaTopic: 'new-topic', // set/đổi topic
|
|
1045
|
+
webhookUrl: 'https://bot.example.com/events/v2', // set/đổi webhook
|
|
1046
|
+
deliveryPreference: 'WebhookOnly', // optional — bỏ qua/null = không đổi
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
// Để clear về null, dùng cờ riêng (mutually exclusive với việc set giá trị mới):
|
|
1050
|
+
await client.bots.update('bot-id', {
|
|
1051
|
+
clearKafkaTopic: true,
|
|
1052
|
+
clearWebhookUrl: true,
|
|
930
1053
|
});
|
|
931
1054
|
```
|
|
932
1055
|
|
|
@@ -948,17 +1071,22 @@ await client.bots.delete('bot-id');
|
|
|
948
1071
|
### Kiểu dữ liệu Bot
|
|
949
1072
|
|
|
950
1073
|
```ts
|
|
1074
|
+
// Kênh nhận outgoing event. Chỉ có hiệu lực khi bot có cả kafkaTopic lẫn webhookUrl.
|
|
1075
|
+
type BotDeliveryPreference = 'Both' | 'WebhookOnly' | 'KafkaOnly';
|
|
1076
|
+
|
|
951
1077
|
interface BotDto {
|
|
952
1078
|
id: string;
|
|
953
|
-
participantId: string;
|
|
1079
|
+
participantId: string; // Dùng để lấy name/avatar từ ParticipantApi
|
|
954
1080
|
description?: string | null;
|
|
955
1081
|
metadata?: Record<string, unknown> | null;
|
|
956
|
-
kafkaTopic
|
|
1082
|
+
kafkaTopic?: string | null; // null khi bot không cấu hình Kafka
|
|
1083
|
+
webhookUrl?: string | null; // HTTP/2 NDJSON webhook (chỉ MessageCreatedEvent); null khi không cấu hình
|
|
1084
|
+
deliveryPreference: BotDeliveryPreference; // Chỉ áp dụng khi cả kafkaTopic và webhookUrl đều được cấu hình
|
|
957
1085
|
isActive: boolean;
|
|
958
1086
|
listenAllGroupMessages: boolean;
|
|
959
|
-
createdBy: string;
|
|
960
|
-
createdAt: string;
|
|
961
|
-
updatedAt: string;
|
|
1087
|
+
createdBy: string; // ID của người tạo bot
|
|
1088
|
+
createdAt: string; // ISO 8601
|
|
1089
|
+
updatedAt: string; // ISO 8601
|
|
962
1090
|
}
|
|
963
1091
|
```
|
|
964
1092
|
|
|
@@ -968,29 +1096,35 @@ interface BotDto {
|
|
|
968
1096
|
|
|
969
1097
|
```ts
|
|
970
1098
|
interface CreateBotRequest {
|
|
971
|
-
uniqueName: string;
|
|
972
|
-
fullName: string;
|
|
1099
|
+
uniqueName: string; // max 50, [a-zA-Z0-9_]
|
|
1100
|
+
fullName: string; // max 100
|
|
973
1101
|
avatar?: MediaReference; // optional — avatar của bot
|
|
974
|
-
description?: string;
|
|
1102
|
+
description?: string; // max 500
|
|
975
1103
|
metadata?: Record<string, unknown>;
|
|
976
|
-
kafkaTopic
|
|
1104
|
+
kafkaTopic?: string | null; // optional — max 200, [a-zA-Z0-9._-]
|
|
1105
|
+
webhookUrl?: string | null; // optional — absolute http/https URI, max 2000
|
|
1106
|
+
deliveryPreference?: BotDeliveryPreference; // optional — server default 'Both'
|
|
977
1107
|
listenAllGroupMessages?: boolean; // Mặc định false
|
|
978
1108
|
}
|
|
979
1109
|
|
|
980
1110
|
interface UpdateBotRequest {
|
|
981
|
-
uniqueName?: string;
|
|
1111
|
+
uniqueName?: string;
|
|
982
1112
|
fullName?: string;
|
|
983
1113
|
avatar?: MediaReference;
|
|
984
1114
|
description?: string;
|
|
985
1115
|
metadata?: Record<string, unknown>;
|
|
986
1116
|
listenAllGroupMessages?: boolean;
|
|
987
|
-
//
|
|
1117
|
+
kafkaTopic?: string | null; // set/đổi topic (mutually exclusive với clearKafkaTopic)
|
|
1118
|
+
clearKafkaTopic?: boolean; // true → clear topic về null
|
|
1119
|
+
webhookUrl?: string | null; // set/đổi webhook (mutually exclusive với clearWebhookUrl)
|
|
1120
|
+
clearWebhookUrl?: boolean; // true → clear webhook về null
|
|
1121
|
+
deliveryPreference?: BotDeliveryPreference | null; // bỏ qua/null = không đổi
|
|
988
1122
|
}
|
|
989
1123
|
```
|
|
990
1124
|
|
|
991
1125
|
---
|
|
992
1126
|
|
|
993
|
-
##
|
|
1127
|
+
## 11. Proxy — ProxyApi
|
|
994
1128
|
|
|
995
1129
|
**Truy cập:** `client.proxy`
|
|
996
1130
|
|
|
@@ -1047,7 +1181,7 @@ const result = await client.proxy.request<unknown>('myservice', 'api/resource/12
|
|
|
1047
1181
|
|
|
1048
1182
|
---
|
|
1049
1183
|
|
|
1050
|
-
##
|
|
1184
|
+
## 12. Health — HealthApi
|
|
1051
1185
|
|
|
1052
1186
|
**Truy cập:** `client.health`
|
|
1053
1187
|
|
|
@@ -1067,7 +1201,7 @@ const ready = await client.health.ready(); // "Healthy"
|
|
|
1067
1201
|
|
|
1068
1202
|
---
|
|
1069
1203
|
|
|
1070
|
-
##
|
|
1204
|
+
## 13. Real-time: ChatHubClient
|
|
1071
1205
|
|
|
1072
1206
|
**Truy cập:** `client.realtime.chat`
|
|
1073
1207
|
|
|
@@ -1205,9 +1339,10 @@ client.realtime.chat.off('messageReceived', handler);
|
|
|
1205
1339
|
| `streamStarted` | `StreamStartedDto` | Bot stream bắt đầu |
|
|
1206
1340
|
| `streamStatusUpdated` | `StreamStatusUpdatedDto` | Trạng thái bot stream thay đổi |
|
|
1207
1341
|
| `streamChunkReceived` | `StreamChunkReceivedDto` | Nhận thêm chunk text từ bot |
|
|
1208
|
-
| `
|
|
1342
|
+
| `streamBlockReceived` | `StreamBlockReceivedDto` | Nhận inline content block (widget) đan xen giữa các chunk text, theo `sequence` |
|
|
1209
1343
|
| `streamCompleted` | `StreamCompletedDto` | Bot stream hoàn thành (kèm message đầy đủ) |
|
|
1210
1344
|
| `streamAborted` | `StreamAbortedDto` | Bot stream bị hủy |
|
|
1345
|
+
| `ephemeralBroadcast` | `EphemeralBroadcastDto` | Broadcast tạm thời (không lưu) nhắm `audience="viewers"` của conversation đang mở — `conversationId` luôn có giá trị |
|
|
1211
1346
|
| `error` | `HubErrorDto` | Lỗi từ server |
|
|
1212
1347
|
| `reconnecting` | `Error \| undefined` | Đang reconnect |
|
|
1213
1348
|
| `reconnected` | `string \| undefined` | Đã reconnect (connectionId mới) |
|
|
@@ -1222,7 +1357,7 @@ interface MessageUpdatedDto {
|
|
|
1222
1357
|
conversationId: string;
|
|
1223
1358
|
blocks: Block[];
|
|
1224
1359
|
plainTextContent: string | null;
|
|
1225
|
-
mentions?: Mention[];
|
|
1360
|
+
mentions?: Mention[] | null;
|
|
1226
1361
|
updatedAt: string; // ISO 8601
|
|
1227
1362
|
replyToMessageId?: string | null; // null nếu reply reference đã bị xóa
|
|
1228
1363
|
}
|
|
@@ -1298,25 +1433,33 @@ client.realtime.chat.on('messageThumbnailsReady', (dto) => {
|
|
|
1298
1433
|
Khi bot streaming, **không có** `messageReceived` — thay vào đó nhận chuỗi sự kiện sau:
|
|
1299
1434
|
|
|
1300
1435
|
```
|
|
1301
|
-
streamStarted → streamStatusUpdated* → streamChunkReceived* → streamCompleted
|
|
1302
|
-
|
|
1436
|
+
streamStarted → streamStatusUpdated* → [streamChunkReceived | streamBlockReceived]* → streamCompleted
|
|
1437
|
+
→ streamAborted
|
|
1303
1438
|
```
|
|
1304
1439
|
|
|
1440
|
+
`streamChunkReceived` (text) và `streamBlockReceived` (inline block/widget) dùng **chung** dãy số `sequence` (server gán, tăng dần từ 0). Client sắp xếp theo `sequence` để render đúng vị trí text↔block trong cùng một stream.
|
|
1441
|
+
|
|
1305
1442
|
```ts
|
|
1306
|
-
|
|
1443
|
+
// Mỗi phần tử là một text chunk hoặc một block, gắn với sequence để sắp xếp.
|
|
1444
|
+
const parts: { sequence: number; text?: string; block?: Block }[] = [];
|
|
1307
1445
|
|
|
1308
1446
|
client.realtime.chat.on('streamStarted', (dto) => {
|
|
1309
1447
|
console.log('Bot bắt đầu trả lời, streamId:', dto.streamId);
|
|
1310
1448
|
});
|
|
1311
1449
|
|
|
1312
1450
|
client.realtime.chat.on('streamChunkReceived', (dto) => {
|
|
1313
|
-
|
|
1314
|
-
// Hiển thị progressive:
|
|
1451
|
+
parts.push({ sequence: dto.sequence, text: dto.text });
|
|
1452
|
+
// Hiển thị progressive: sắp xếp theo sequence rồi nối lại
|
|
1453
|
+
});
|
|
1454
|
+
|
|
1455
|
+
client.realtime.chat.on('streamBlockReceived', (dto) => {
|
|
1456
|
+
parts.push({ sequence: dto.sequence, block: dto.block });
|
|
1457
|
+
// Render block (card, choice, divider...) đúng vị trí theo sequence
|
|
1315
1458
|
});
|
|
1316
1459
|
|
|
1317
1460
|
client.realtime.chat.on('streamCompleted', (dto) => {
|
|
1318
1461
|
console.log('Tin nhắn hoàn chỉnh:', dto.message);
|
|
1319
|
-
|
|
1462
|
+
parts.length = 0; // reset
|
|
1320
1463
|
});
|
|
1321
1464
|
|
|
1322
1465
|
client.realtime.chat.on('streamAborted', (dto) => {
|
|
@@ -1324,9 +1467,44 @@ client.realtime.chat.on('streamAborted', (dto) => {
|
|
|
1324
1467
|
});
|
|
1325
1468
|
```
|
|
1326
1469
|
|
|
1470
|
+
**Giới hạn streaming (server enforce):**
|
|
1471
|
+
|
|
1472
|
+
| Giới hạn | Giá trị |
|
|
1473
|
+
|----------|---------|
|
|
1474
|
+
| Max chunk size | 4096 bytes |
|
|
1475
|
+
| Max chunks / stream | 2000 |
|
|
1476
|
+
| Max blocks / stream | 50 |
|
|
1477
|
+
| Max block size | 65536 bytes (64 KB) |
|
|
1478
|
+
| Max accumulated size | 1 MB |
|
|
1479
|
+
| Stream timeout | 120 giây |
|
|
1480
|
+
| Max stream duration | 300 giây |
|
|
1481
|
+
| Max concurrent streams / bot | 10 |
|
|
1482
|
+
|
|
1483
|
+
### Ephemeral Broadcast (tin real-time không lưu trữ)
|
|
1484
|
+
|
|
1485
|
+
Bot hoặc SYSTEM user có thể phát **broadcast tạm thời** (không lưu vào lịch sử, không có ack, không edit/delete) qua `POST /api/broadcasts`. Trên ChatHub, client nhận event `ephemeralBroadcast` khi broadcast nhắm `audience="viewers"` của conversation đang mở — khi đó `conversationId` luôn có giá trị.
|
|
1486
|
+
|
|
1487
|
+
`data` là một envelope JSON tự do; client diễn giải dựa trên `type`. Dùng generic `EphemeralBroadcastDto<TData>` để narrow kiểu của `data` khi cần (mặc định `unknown` để vẫn an toàn khi chưa khai báo).
|
|
1488
|
+
|
|
1489
|
+
```ts
|
|
1490
|
+
import type { EphemeralBroadcastDto } from '@manonero/chat-client-sdk';
|
|
1491
|
+
|
|
1492
|
+
interface PromoBanner { title: string; startsAt: string }
|
|
1493
|
+
|
|
1494
|
+
client.realtime.chat.on('ephemeralBroadcast', (b) => {
|
|
1495
|
+
// b.senderType: 'Bot' | 'System' ; b.conversationId luôn có giá trị trên ChatHub
|
|
1496
|
+
if (b.type === 'promo_banner') {
|
|
1497
|
+
const promo = b.data as PromoBanner; // narrow theo `type`
|
|
1498
|
+
showBanner(promo.title, promo.startsAt);
|
|
1499
|
+
}
|
|
1500
|
+
});
|
|
1501
|
+
```
|
|
1502
|
+
|
|
1503
|
+
> Cùng event `ephemeralBroadcast` (và cùng kiểu `EphemeralBroadcastDto`) cũng có trên NotificationHub cho các audience cấp user — xem [mục 14](#14-real-time-notificationhubclient). Định nghĩa kiểu đầy đủ ở [mục 17](#17-toàn-bộ-kiểu-dữ-liệu-types).
|
|
1504
|
+
|
|
1327
1505
|
---
|
|
1328
1506
|
|
|
1329
|
-
##
|
|
1507
|
+
## 14. Real-time: NotificationHubClient
|
|
1330
1508
|
|
|
1331
1509
|
**Truy cập:** `client.realtime.notifications`
|
|
1332
1510
|
|
|
@@ -1370,6 +1548,7 @@ await client.realtime.notifications.disconnect();
|
|
|
1370
1548
|
| `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
1549
|
| `conversationPinned` | `ConversationPinnedDto` | Conversation được pin |
|
|
1372
1550
|
| `conversationUnpinned` | `ConversationUnpinnedDto` | Conversation được unpin |
|
|
1551
|
+
| `ephemeralBroadcast` | `EphemeralBroadcastDto` | Broadcast tạm thời (không lưu) — audience `members`/`all`/`users`; broadcast toàn cục có `conversationId: null` |
|
|
1373
1552
|
| `reconnecting` | `Error \| undefined` | Đang reconnect |
|
|
1374
1553
|
| `reconnected` | `string \| undefined` | Đã reconnect |
|
|
1375
1554
|
| `disconnected` | `Error \| undefined` | Mất kết nối |
|
|
@@ -1440,9 +1619,24 @@ interface ConversationUnpinnedDto {
|
|
|
1440
1619
|
}
|
|
1441
1620
|
```
|
|
1442
1621
|
|
|
1622
|
+
### Ephemeral Broadcast trên NotificationHub
|
|
1623
|
+
|
|
1624
|
+
Giống ChatHub, NotificationHub cũng phát event `ephemeralBroadcast` (cùng kiểu `EphemeralBroadcastDto`) — nhưng cho các audience **cấp user**: `audience="members"` (mọi thành viên conversation), `audience="all"` (mọi user online), hoặc `audience="users"` (danh sách userId chỉ định). Với broadcast toàn cục, `conversationId` là `null`.
|
|
1625
|
+
|
|
1626
|
+
```ts
|
|
1627
|
+
client.realtime.notifications.on('ephemeralBroadcast', (b) => {
|
|
1628
|
+
if (b.conversationId === null && b.type === 'system_alert') {
|
|
1629
|
+
// Broadcast toàn cục — vd. cảnh báo bảo trì hệ thống
|
|
1630
|
+
showGlobalAlert(b.data);
|
|
1631
|
+
}
|
|
1632
|
+
});
|
|
1633
|
+
```
|
|
1634
|
+
|
|
1635
|
+
> Định nghĩa đầy đủ `EphemeralBroadcastDto` ở [mục 17](#17-toàn-bộ-kiểu-dữ-liệu-types).
|
|
1636
|
+
|
|
1443
1637
|
---
|
|
1444
1638
|
|
|
1445
|
-
##
|
|
1639
|
+
## 15. ReconnectionManager
|
|
1446
1640
|
|
|
1447
1641
|
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
1642
|
|
|
@@ -1470,13 +1664,19 @@ manager.stop();
|
|
|
1470
1664
|
|
|
1471
1665
|
**Chiến lược backoff:** 3 lần thử với delay 2s → 5s → 10s.
|
|
1472
1666
|
|
|
1667
|
+
> **Không có event kết thúc:** nếu cả 3 lần thử đều thất bại, manager **dừng im lặng** — không phát thêm event nào. Một `connect()` thất bại không mở được kết nối nên `onclose`/`disconnected` **không** fire lại; host chỉ nhận đúng 1 lần `disconnected` (lần kích hoạt reconnect). Muốn biết reconnect đã bỏ cuộc, hãy tự giám sát qua event `disconnected` kết hợp getter `state` của hub.
|
|
1668
|
+
|
|
1473
1669
|
**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
1670
|
|
|
1671
|
+
> **⚠️ Giới hạn dưới transport mặc định:** SDK mặc định `skipNegotiation: true` + `transport: WebSockets`. Khi token hết hạn, 401 xảy ra ở bước bắt tay WebSocket — mà trình duyệt **không** expose HTTP status ra cho script, nên `err.message` chỉ là chuỗi chung chung ("WebSocket failed to connect…") không chứa keyword nào ở trên, và `onTokenExpired` **có thể không được gọi**. Không danh sách keyword nào khắc phục được (status đơn giản không nằm trong message). Vì vậy:
|
|
1672
|
+
> - Muốn xử lý hết hạn **tất định**, hãy **refresh chủ động** dựa trên `AuthResponse.expiresAt` (đã có sẵn từ lúc login) — đừng trông chờ vào drop-detection.
|
|
1673
|
+
> - Heuristic chỉ fire đáng tin khi bạn tự override `skipNegotiation: false` (lúc đó bước negotiate trả HTTP 401 đọc được).
|
|
1674
|
+
|
|
1475
1675
|
**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
1676
|
|
|
1477
1677
|
---
|
|
1478
1678
|
|
|
1479
|
-
##
|
|
1679
|
+
## 16. Hệ thống Block (nội dung tin nhắn)
|
|
1480
1680
|
|
|
1481
1681
|
`Block` là **discriminated union** — mỗi block phân biệt bằng field `$type`.
|
|
1482
1682
|
|
|
@@ -1518,6 +1718,7 @@ interface ImageBlock {
|
|
|
1518
1718
|
height: number | null;
|
|
1519
1719
|
caption: string | null;
|
|
1520
1720
|
fileSizeBytes: number | null;
|
|
1721
|
+
mimeType: string | null; // vd. 'image/jpeg', 'image/png', 'image/svg+xml'
|
|
1521
1722
|
}
|
|
1522
1723
|
|
|
1523
1724
|
interface VideoBlock {
|
|
@@ -1635,6 +1836,32 @@ for (const block of message.blocks) {
|
|
|
1635
1836
|
|
|
1636
1837
|
Danh sách đầy đủ: `isTextBlock`, `isImageBlock`, `isVideoBlock`, `isAudioBlock`, `isFileBlock`, `isLinkPreviewBlock`, `isEmbedBlock`, `isLocationBlock`, `isContactBlock`, `isChoiceBlock`, `isCardBlock`, `isCarouselBlock`, `isDividerBlock`, `isCustomBlock`.
|
|
1637
1838
|
|
|
1839
|
+
### Serialization helpers — `withTypeFirst` / `normalizeBlocksForSend`
|
|
1840
|
+
|
|
1841
|
+
Server dùng polymorphic deserializer yêu cầu **`$type` phải là property đầu tiên** trong mỗi block JSON (spec §13). SDK đã tự gọi `normalizeBlocksForSend()` nội bộ trong `MessageApi.send/edit` và `ChatHubClient.sendMessage/editMessage`, nên **bạn KHÔNG cần gọi thủ công** trong luồng thông thường.
|
|
1842
|
+
|
|
1843
|
+
Hai helper này được export public chỉ dành cho power users muốn:
|
|
1844
|
+
- Tự xây dựng API module riêng dùng `HttpClient` trực tiếp.
|
|
1845
|
+
- Tạo block từ nguồn dữ liệu mà thứ tự property bị xáo trộn (vd. clone qua `structuredClone` rồi spread).
|
|
1846
|
+
|
|
1847
|
+
```ts
|
|
1848
|
+
import { withTypeFirst, normalizeBlocksForSend } from '@manonero/chat-client-sdk';
|
|
1849
|
+
import type { Block, TextBlock } from '@manonero/chat-client-sdk';
|
|
1850
|
+
|
|
1851
|
+
// withTypeFirst<T>(block: T): T — chuẩn hoá 1 block đơn lẻ
|
|
1852
|
+
const normalized: TextBlock = withTypeFirst({
|
|
1853
|
+
content: 'Xin chào',
|
|
1854
|
+
format: 'Plain',
|
|
1855
|
+
plainText: 'Xin chào',
|
|
1856
|
+
$type: 'text', // có thể nằm ở cuối khi tạo block — sẽ được đưa lên đầu
|
|
1857
|
+
});
|
|
1858
|
+
|
|
1859
|
+
// normalizeBlocksForSend<T extends Block>(blocks: T[]): T[] — áp dụng cho mảng
|
|
1860
|
+
const blocks: Block[] = normalizeBlocksForSend(rawBlocks);
|
|
1861
|
+
```
|
|
1862
|
+
|
|
1863
|
+
> `CarouselBlock` là block duy nhất chứa nested blocks (`cards: CardBlock[]`); `withTypeFirst` đệ quy nên rule `$type` đứng đầu giữ vững ở mọi cấp.
|
|
1864
|
+
|
|
1638
1865
|
### Ví dụ tạo block
|
|
1639
1866
|
|
|
1640
1867
|
```ts
|
|
@@ -1656,6 +1883,7 @@ const imageBlock: ImageBlock = {
|
|
|
1656
1883
|
height: 1080,
|
|
1657
1884
|
caption: 'Ảnh mô tả',
|
|
1658
1885
|
fileSizeBytes: 204800,
|
|
1886
|
+
mimeType: 'image/jpeg',
|
|
1659
1887
|
};
|
|
1660
1888
|
|
|
1661
1889
|
// Gửi tin nhắn kèm file
|
|
@@ -1696,7 +1924,7 @@ interface CardField {
|
|
|
1696
1924
|
}
|
|
1697
1925
|
```
|
|
1698
1926
|
|
|
1699
|
-
### ChoiceMode / ChoiceOption
|
|
1927
|
+
### ChoiceMode / ChoiceOption
|
|
1700
1928
|
|
|
1701
1929
|
```ts
|
|
1702
1930
|
type ChoiceMode = 'Single' | 'Multiple';
|
|
@@ -1706,16 +1934,10 @@ interface ChoiceOption {
|
|
|
1706
1934
|
value: string;
|
|
1707
1935
|
selected: boolean;
|
|
1708
1936
|
}
|
|
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
1937
|
```
|
|
1718
1938
|
|
|
1939
|
+
> `ChoiceBlock` đã được định nghĩa đầy đủ ở mục [Interfaces tham chiếu đầy đủ](#interfaces-tham-chiếu-đầy-đủ).
|
|
1940
|
+
|
|
1719
1941
|
### QuickReply
|
|
1720
1942
|
|
|
1721
1943
|
Nút bấm nhanh hiển thị bên dưới tin nhắn bot:
|
|
@@ -1743,7 +1965,7 @@ interface Mention {
|
|
|
1743
1965
|
|
|
1744
1966
|
---
|
|
1745
1967
|
|
|
1746
|
-
##
|
|
1968
|
+
## 17. Toàn bộ kiểu dữ liệu (Types)
|
|
1747
1969
|
|
|
1748
1970
|
### CursorPaginatedResult (cursor-based)
|
|
1749
1971
|
|
|
@@ -1766,12 +1988,39 @@ interface PagedResult<T> {
|
|
|
1766
1988
|
}
|
|
1767
1989
|
```
|
|
1768
1990
|
|
|
1991
|
+
### SearchResult (GET /api/search — §8)
|
|
1992
|
+
|
|
1993
|
+
```ts
|
|
1994
|
+
type SearchType = 'MESSAGE' | 'CONVERSATION' | 'BOTH';
|
|
1995
|
+
|
|
1996
|
+
interface SearchResult {
|
|
1997
|
+
// null khi type=MESSAGE. Mỗi item có `lastMessage` đã hydrate —
|
|
1998
|
+
// kiểm tra lastMessage.isDeleted trước khi hiển thị.
|
|
1999
|
+
conversations: ConversationListItemDto[] | null;
|
|
2000
|
+
// null khi type=CONVERSATION.
|
|
2001
|
+
messages: SearchMessagesResult | null;
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
interface SearchMessagesResult {
|
|
2005
|
+
groups: SearchMessageGroup[];
|
|
2006
|
+
nextCursor: string | null; // opaque — chỉ phân trang cho messages
|
|
2007
|
+
hasMore: boolean;
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
interface SearchMessageGroup {
|
|
2011
|
+
// null nếu batch fetch conversation lỗi (degrade graceful).
|
|
2012
|
+
// `participants` luôn là [] (fetch với includeParticipants=false).
|
|
2013
|
+
conversation: ConversationDto | null;
|
|
2014
|
+
messages: MessageDto[];
|
|
2015
|
+
}
|
|
2016
|
+
```
|
|
2017
|
+
|
|
1769
2018
|
### MediaReference
|
|
1770
2019
|
|
|
1771
2020
|
```ts
|
|
1772
2021
|
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)
|
|
2022
|
+
storageKey?: string | null; // Key lưu trữ nội bộ — dùng FileApi.getDownloadUrl(storageKey, { expires, sig }) để tạo URL tuyệt đối có chữ ký. `null` khi reference là external URL.
|
|
2023
|
+
url?: string | null; // URL do server cấp sẵn (xem chi tiết bên dưới). `null` ở request body khi chỉ gửi storageKey.
|
|
1775
2024
|
}
|
|
1776
2025
|
```
|
|
1777
2026
|
|
|
@@ -1782,22 +2031,20 @@ Trường `url` có hai dạng tuỳ cấu hình server:
|
|
|
1782
2031
|
| **Relative signed URL** | `/api/files/abc/photo.jpg?expires=...&sig=...` | Phải prepend `baseUrl` trước khi dùng |
|
|
1783
2032
|
| **Absolute external URL** | `https://cdn.example.com/photo.jpg` | Dùng trực tiếp |
|
|
1784
2033
|
|
|
1785
|
-
> **Quy tắc resolve:**
|
|
2034
|
+
> **Quy tắc resolve:** Luôn ưu tiên `url` — nó đã được server gắn signature (`expires` + `sig`). Nếu chỉ có `storageKey` mà không có `url`, bạn cần cặp `expires`/`sig` từ nguồn khác để gọi `getDownloadUrl(storageKey, { expires, sig })` (server bắt buộc signed access — gọi unsigned sẽ bị từ chối với `400 Invalid Signature`).
|
|
1786
2035
|
|
|
1787
2036
|
```ts
|
|
1788
|
-
// Helper chuẩn để resolve MediaReference → URL tuyệt đối
|
|
2037
|
+
// Helper chuẩn để resolve MediaReference → URL tuyệt đối.
|
|
2038
|
+
// Server gần như luôn trả sẵn `url` cho mọi MediaReference của tin nhắn/participant,
|
|
2039
|
+
// nên trường hợp chỉ có storageKey là bất thường và nên log để debug.
|
|
1789
2040
|
function resolveMediaRef(
|
|
1790
2041
|
ref: MediaReference | null | undefined,
|
|
1791
2042
|
baseUrl: string,
|
|
1792
|
-
getDownloadUrl: (key: string) => string,
|
|
1793
2043
|
): string | null {
|
|
1794
|
-
if (!ref) return null;
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
}
|
|
1799
|
-
if (ref.storageKey) return getDownloadUrl(ref.storageKey);
|
|
1800
|
-
return null;
|
|
2044
|
+
if (!ref?.url) return null;
|
|
2045
|
+
// url có thể là relative signed URL ("/api/files/...?sig=...")
|
|
2046
|
+
// hoặc absolute external URL ("https://...")
|
|
2047
|
+
return ref.url.startsWith('/') ? baseUrl.replace(/\/$/, '') + ref.url : ref.url;
|
|
1801
2048
|
}
|
|
1802
2049
|
```
|
|
1803
2050
|
|
|
@@ -1902,23 +2149,15 @@ interface StreamChunkReceivedDto {
|
|
|
1902
2149
|
messageId: string;
|
|
1903
2150
|
conversationId: string;
|
|
1904
2151
|
text: string; // Chunk text để append vào placeholder
|
|
1905
|
-
|
|
2152
|
+
sequence: number; // Số thứ tự server gán (zero-based), chung dãy với StreamBlockReceivedDto
|
|
1906
2153
|
}
|
|
1907
2154
|
|
|
1908
|
-
|
|
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 {
|
|
2155
|
+
interface StreamBlockReceivedDto {
|
|
1918
2156
|
streamId: string;
|
|
1919
2157
|
messageId: string;
|
|
1920
2158
|
conversationId: string;
|
|
1921
|
-
|
|
2159
|
+
block: Block; // Inline content block (card, choice, divider...)
|
|
2160
|
+
sequence: number; // Số thứ tự server gán, chung dãy với StreamChunkReceivedDto
|
|
1922
2161
|
}
|
|
1923
2162
|
|
|
1924
2163
|
interface StreamCompletedDto {
|
|
@@ -1939,6 +2178,29 @@ interface StreamAbortedDto {
|
|
|
1939
2178
|
}
|
|
1940
2179
|
```
|
|
1941
2180
|
|
|
2181
|
+
### EphemeralBroadcastDto (dùng chung — ChatHub & NotificationHub)
|
|
2182
|
+
|
|
2183
|
+
Broadcast tạm thời (**KHÔNG lưu trữ**) do bot hoặc SYSTEM user phát qua `POST /api/broadcasts`. Cùng một payload xuất hiện trên **cả hai hub** với event `ephemeralBroadcast`:
|
|
2184
|
+
|
|
2185
|
+
- **ChatHub** — `audience="viewers"` của conversation đang mở (`conversationId` có giá trị).
|
|
2186
|
+
- **NotificationHub** — `audience="members" | "all" | "users"` (broadcast toàn cục → `conversationId: null`).
|
|
2187
|
+
|
|
2188
|
+
```ts
|
|
2189
|
+
// Sender của broadcast — luôn là bot hoặc SYSTEM user (không bao giờ là User thường)
|
|
2190
|
+
type BroadcastSenderType = 'Bot' | 'System';
|
|
2191
|
+
|
|
2192
|
+
interface EphemeralBroadcastDto<TData = unknown> {
|
|
2193
|
+
type: string; // Khóa do người gửi đặt — client tự diễn giải `data` theo giá trị này
|
|
2194
|
+
senderId: string; // participantId của bot, hoặc literal "SYSTEM"
|
|
2195
|
+
senderType: BroadcastSenderType; // 'Bot' | 'System'
|
|
2196
|
+
conversationId: string | null; // null khi broadcast toàn cục
|
|
2197
|
+
data: TData | null; // Envelope JSON tự do; null khi người gửi bỏ trống
|
|
2198
|
+
sentAt: string; // UTC ISO 8601
|
|
2199
|
+
}
|
|
2200
|
+
```
|
|
2201
|
+
|
|
2202
|
+
> `TData` cho phép narrow kiểu của `data` theo `type` (mặc định `unknown` để dùng an toàn khi chưa khai báo). Xem ví dụ ở [mục 13](#13-real-time-chathubclient) (ChatHub) và [mục 14](#14-real-time-notificationhubclient) (NotificationHub).
|
|
2203
|
+
|
|
1942
2204
|
### Ack types (return value của Hub methods)
|
|
1943
2205
|
|
|
1944
2206
|
Mỗi Hub method client→server trả về một ack object với `success: boolean` và optional error fields:
|
|
@@ -1987,7 +2249,7 @@ interface ReactionAck {
|
|
|
1987
2249
|
|
|
1988
2250
|
---
|
|
1989
2251
|
|
|
1990
|
-
##
|
|
2252
|
+
## 18. Xử lý lỗi — ChatApiError
|
|
1991
2253
|
|
|
1992
2254
|
Tất cả lỗi HTTP đều throw `ChatApiError` (kế thừa `Error`).
|
|
1993
2255
|
|
|
@@ -2034,7 +2296,7 @@ try {
|
|
|
2034
2296
|
|------------|------|-------|
|
|
2035
2297
|
| `status` | `number` | HTTP status code |
|
|
2036
2298
|
| `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
|
|
2299
|
+
| `detail` | `string \| undefined` | Mô tả chi tiết, human-readable của lỗi (RFC 7807 `detail`) |
|
|
2038
2300
|
| `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
2301
|
| `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
2302
|
| `traceId` | `string \| undefined` | W3C trace ID để correlate log. Tự động thêm bởi framework. `undefined` cho proxy errors và lỗi sinh nội bộ |
|
|
@@ -2110,6 +2372,8 @@ const chatHub = new ChatHubClient({
|
|
|
2110
2372
|
hubUrl: 'https://chat-api.example.com/hubs/chat',
|
|
2111
2373
|
tokenProvider,
|
|
2112
2374
|
logLevel: LogLevel.Warning, // optional
|
|
2375
|
+
// optional — ghi đè mặc định skipNegotiation: true + transport: WebSockets
|
|
2376
|
+
connectionOptions: { skipNegotiation: false },
|
|
2113
2377
|
});
|
|
2114
2378
|
|
|
2115
2379
|
// Khởi tạo NotificationHub độc lập
|
|
@@ -2139,7 +2403,7 @@ manager.start();
|
|
|
2139
2403
|
|
|
2140
2404
|
---
|
|
2141
2405
|
|
|
2142
|
-
##
|
|
2406
|
+
## 19. TypedEventEmitter
|
|
2143
2407
|
|
|
2144
2408
|
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
2409
|
|
|
@@ -2169,7 +2433,7 @@ unsubs.forEach(fn => fn());
|
|
|
2169
2433
|
|
|
2170
2434
|
---
|
|
2171
2435
|
|
|
2172
|
-
##
|
|
2436
|
+
## 20. Lưu ý quan trọng
|
|
2173
2437
|
|
|
2174
2438
|
### Cursor pagination — KHÔNG cast sang number
|
|
2175
2439
|
|
|
@@ -2212,34 +2476,25 @@ const block: ImageBlock = {
|
|
|
2212
2476
|
};
|
|
2213
2477
|
```
|
|
2214
2478
|
|
|
2215
|
-
**Khi hiển thị tin nhắn nhận từ server:** server trả về `MediaReference` có
|
|
2479
|
+
**Khi hiển thị tin nhắn nhận từ server:** server trả về `MediaReference` có sẵn `url` đã được ký (`expires` + `sig`). Dùng helper resolve sau:
|
|
2216
2480
|
|
|
2217
2481
|
```ts
|
|
2218
2482
|
// resolve MediaReference → URL tuyệt đối để dùng trong <img src>, <a href>, v.v.
|
|
2219
2483
|
function resolveMediaRef(
|
|
2220
2484
|
ref: MediaReference | null | undefined,
|
|
2221
2485
|
baseUrl: string,
|
|
2222
|
-
getDownloadUrl: (key: string) => string,
|
|
2223
2486
|
): string | null {
|
|
2224
|
-
if (!ref) return null;
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
return ref.url.startsWith('/') ? baseUrl.replace(/\/$/, '') + ref.url : ref.url;
|
|
2229
|
-
}
|
|
2230
|
-
if (ref.storageKey) return getDownloadUrl(ref.storageKey);
|
|
2231
|
-
return null;
|
|
2487
|
+
if (!ref?.url) return null;
|
|
2488
|
+
// url có thể là relative signed URL ("/api/files/...?sig=...")
|
|
2489
|
+
// hoặc absolute external URL ("https://...")
|
|
2490
|
+
return ref.url.startsWith('/') ? baseUrl.replace(/\/$/, '') + ref.url : ref.url;
|
|
2232
2491
|
}
|
|
2233
2492
|
|
|
2234
2493
|
// 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
|
-
);
|
|
2494
|
+
const displayUrl = resolveMediaRef(block.source, 'https://chat-api.example.com');
|
|
2240
2495
|
```
|
|
2241
2496
|
|
|
2242
|
-
> **Tại sao không
|
|
2497
|
+
> **Tại sao không dùng `getDownloadUrl(storageKey, ...)`?** Server bắt buộc signed access (spec §8.3), và `getDownloadUrl()` yêu cầu bạn cung cấp cặp `expires`/`sig`. Trong khi đó, `MediaReference.url` đã được server gắn sẵn signature — không cần thao tác thêm.
|
|
2243
2498
|
|
|
2244
2499
|
> **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
2500
|
|
|
@@ -2255,7 +2510,7 @@ console.log(participant.fullName); // Tên hiển thị của bot
|
|
|
2255
2510
|
|
|
2256
2511
|
---
|
|
2257
2512
|
|
|
2258
|
-
##
|
|
2513
|
+
## 21. Ví dụ đầy đủ
|
|
2259
2514
|
|
|
2260
2515
|
### Ví dụ 1: Chat app cơ bản
|
|
2261
2516
|
|
|
@@ -2367,7 +2622,9 @@ async function loadMoreMessages(conversationId: string) {
|
|
|
2367
2622
|
### Ví dụ 3: Xử lý bot streaming
|
|
2368
2623
|
|
|
2369
2624
|
```ts
|
|
2370
|
-
|
|
2625
|
+
// Mỗi part là text hoặc block, gắn sequence (server gán) để sắp xếp đúng vị trí.
|
|
2626
|
+
type StreamPart = { sequence: number; text?: string; block?: Block };
|
|
2627
|
+
const streamBuffers = new Map<string, StreamPart[]>();
|
|
2371
2628
|
|
|
2372
2629
|
client.realtime.chat.on('streamStarted', (dto) => {
|
|
2373
2630
|
streamBuffers.set(dto.streamId, []);
|
|
@@ -2378,24 +2635,21 @@ client.realtime.chat.on('streamStatusUpdated', (dto) => {
|
|
|
2378
2635
|
updateStreamStatus(dto.messageId, dto.status); // e.g. "thinking", "searching"
|
|
2379
2636
|
});
|
|
2380
2637
|
|
|
2381
|
-
// Append một
|
|
2382
|
-
function
|
|
2383
|
-
const
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2638
|
+
// Append một part (text hoặc block) vào buffer, sắp theo sequence rồi flush ra UI
|
|
2639
|
+
function appendPart(streamId: string, messageId: string, part: StreamPart) {
|
|
2640
|
+
const parts = streamBuffers.get(streamId) ?? [];
|
|
2641
|
+
parts.push(part);
|
|
2642
|
+
parts.sort((a, b) => a.sequence - b.sequence);
|
|
2643
|
+
streamBuffers.set(streamId, parts);
|
|
2644
|
+
renderStreamingParts(messageId, parts); // render text + block đúng thứ tự
|
|
2387
2645
|
}
|
|
2388
2646
|
|
|
2389
2647
|
client.realtime.chat.on('streamChunkReceived', (dto) => {
|
|
2390
|
-
|
|
2648
|
+
appendPart(dto.streamId, dto.messageId, { sequence: dto.sequence, text: dto.text });
|
|
2391
2649
|
});
|
|
2392
2650
|
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
client.realtime.chat.on('streamChunkBatchReceived', (dto) => {
|
|
2396
|
-
for (const part of dto.parts) {
|
|
2397
|
-
appendChunk(dto.streamId, dto.messageId, part.text);
|
|
2398
|
-
}
|
|
2651
|
+
client.realtime.chat.on('streamBlockReceived', (dto) => {
|
|
2652
|
+
appendPart(dto.streamId, dto.messageId, { sequence: dto.sequence, block: dto.block });
|
|
2399
2653
|
});
|
|
2400
2654
|
|
|
2401
2655
|
client.realtime.chat.on('streamCompleted', (dto) => {
|