@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.
- package/README.md +2453 -0
- package/dist/ChatClient.d.ts +181 -0
- package/dist/ChatClient.d.ts.map +1 -0
- package/dist/ChatClient.js +246 -0
- package/dist/ChatClient.js.map +1 -0
- package/dist/errors/ChatApiError.d.ts +45 -0
- package/dist/errors/ChatApiError.d.ts.map +1 -0
- package/dist/errors/ChatApiError.js +69 -0
- package/dist/errors/ChatApiError.js.map +1 -0
- package/dist/http/AuthApi.d.ts +26 -0
- package/dist/http/AuthApi.d.ts.map +1 -0
- package/dist/http/AuthApi.js +35 -0
- package/dist/http/AuthApi.js.map +1 -0
- package/dist/http/BotApi.d.ts +43 -0
- package/dist/http/BotApi.d.ts.map +1 -0
- package/dist/http/BotApi.js +60 -0
- package/dist/http/BotApi.js.map +1 -0
- package/dist/http/ConversationApi.d.ts +92 -0
- package/dist/http/ConversationApi.d.ts.map +1 -0
- package/dist/http/ConversationApi.js +128 -0
- package/dist/http/ConversationApi.js.map +1 -0
- package/dist/http/FileApi.d.ts +60 -0
- package/dist/http/FileApi.d.ts.map +1 -0
- package/dist/http/FileApi.js +91 -0
- package/dist/http/FileApi.js.map +1 -0
- package/dist/http/HealthApi.d.ts +28 -0
- package/dist/http/HealthApi.d.ts.map +1 -0
- package/dist/http/HealthApi.js +34 -0
- package/dist/http/HealthApi.js.map +1 -0
- package/dist/http/HttpClient.d.ts +69 -0
- package/dist/http/HttpClient.d.ts.map +1 -0
- package/dist/http/HttpClient.js +244 -0
- package/dist/http/HttpClient.js.map +1 -0
- package/dist/http/MessageApi.d.ts +69 -0
- package/dist/http/MessageApi.d.ts.map +1 -0
- package/dist/http/MessageApi.js +104 -0
- package/dist/http/MessageApi.js.map +1 -0
- package/dist/http/ParticipantApi.d.ts +28 -0
- package/dist/http/ParticipantApi.d.ts.map +1 -0
- package/dist/http/ParticipantApi.js +37 -0
- package/dist/http/ParticipantApi.js.map +1 -0
- package/dist/http/ProxyApi.d.ts +59 -0
- package/dist/http/ProxyApi.d.ts.map +1 -0
- package/dist/http/ProxyApi.js +86 -0
- package/dist/http/ProxyApi.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/realtime/ChatHubClient.d.ts +143 -0
- package/dist/realtime/ChatHubClient.d.ts.map +1 -0
- package/dist/realtime/ChatHubClient.js +365 -0
- package/dist/realtime/ChatHubClient.js.map +1 -0
- package/dist/realtime/NotificationHubClient.d.ts +89 -0
- package/dist/realtime/NotificationHubClient.d.ts.map +1 -0
- package/dist/realtime/NotificationHubClient.js +191 -0
- package/dist/realtime/NotificationHubClient.js.map +1 -0
- package/dist/realtime/ReconnectionManager.d.ts +65 -0
- package/dist/realtime/ReconnectionManager.d.ts.map +1 -0
- package/dist/realtime/ReconnectionManager.js +129 -0
- package/dist/realtime/ReconnectionManager.js.map +1 -0
- package/dist/types/auth.d.ts +30 -0
- package/dist/types/auth.d.ts.map +1 -0
- package/dist/types/auth.js +3 -0
- package/dist/types/auth.js.map +1 -0
- package/dist/types/block.d.ts +163 -0
- package/dist/types/block.d.ts.map +1 -0
- package/dist/types/block.js +77 -0
- package/dist/types/block.js.map +1 -0
- package/dist/types/bot.d.ts +42 -0
- package/dist/types/bot.d.ts.map +1 -0
- package/dist/types/bot.js +3 -0
- package/dist/types/bot.js.map +1 -0
- package/dist/types/chat-events.d.ts +191 -0
- package/dist/types/chat-events.d.ts.map +1 -0
- package/dist/types/chat-events.js +3 -0
- package/dist/types/chat-events.js.map +1 -0
- package/dist/types/common.d.ts +64 -0
- package/dist/types/common.d.ts.map +1 -0
- package/dist/types/common.js +3 -0
- package/dist/types/common.js.map +1 -0
- package/dist/types/conversation.d.ts +106 -0
- package/dist/types/conversation.d.ts.map +1 -0
- package/dist/types/conversation.js +3 -0
- package/dist/types/conversation.js.map +1 -0
- package/dist/types/file.d.ts +31 -0
- package/dist/types/file.d.ts.map +1 -0
- package/dist/types/file.js +3 -0
- package/dist/types/file.js.map +1 -0
- package/dist/types/message.d.ts +84 -0
- package/dist/types/message.d.ts.map +1 -0
- package/dist/types/message.js +3 -0
- package/dist/types/message.js.map +1 -0
- package/dist/types/notification-events.d.ts +89 -0
- package/dist/types/notification-events.d.ts.map +1 -0
- package/dist/types/notification-events.js +3 -0
- package/dist/types/notification-events.js.map +1 -0
- package/dist/types/participant.d.ts +22 -0
- package/dist/types/participant.d.ts.map +1 -0
- package/dist/types/participant.js +3 -0
- package/dist/types/participant.js.map +1 -0
- package/dist/types/signalr.d.ts +84 -0
- package/dist/types/signalr.d.ts.map +1 -0
- package/dist/types/signalr.js +3 -0
- package/dist/types/signalr.js.map +1 -0
- package/dist/utils/TypedEventEmitter.d.ts +32 -0
- package/dist/utils/TypedEventEmitter.d.ts.map +1 -0
- package/dist/utils/TypedEventEmitter.js +60 -0
- package/dist/utils/TypedEventEmitter.js.map +1 -0
- 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
|
+
```
|