@manonero/chat-client-sdk 1.0.0-beta.1 → 1.0.0-beta.11
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 +386 -81
- package/dist/ChatClient.d.ts +67 -8
- package/dist/ChatClient.d.ts.map +1 -1
- package/dist/ChatClient.js +82 -9
- package/dist/ChatClient.js.map +1 -1
- package/dist/errors/ChatApiError.d.ts +21 -4
- package/dist/errors/ChatApiError.d.ts.map +1 -1
- package/dist/errors/ChatApiError.js +12 -6
- package/dist/errors/ChatApiError.js.map +1 -1
- package/dist/http/BotApi.d.ts +6 -13
- package/dist/http/BotApi.d.ts.map +1 -1
- package/dist/http/BotApi.js +4 -11
- package/dist/http/BotApi.js.map +1 -1
- package/dist/http/ConversationApi.d.ts +38 -5
- package/dist/http/ConversationApi.d.ts.map +1 -1
- package/dist/http/ConversationApi.js +55 -7
- package/dist/http/ConversationApi.js.map +1 -1
- package/dist/http/FileApi.d.ts +16 -4
- package/dist/http/FileApi.d.ts.map +1 -1
- package/dist/http/FileApi.js +29 -6
- package/dist/http/FileApi.js.map +1 -1
- package/dist/http/HttpClient.d.ts +20 -2
- package/dist/http/HttpClient.d.ts.map +1 -1
- package/dist/http/HttpClient.js +76 -14
- package/dist/http/HttpClient.js.map +1 -1
- package/dist/http/MessageApi.d.ts +2 -1
- package/dist/http/MessageApi.d.ts.map +1 -1
- package/dist/http/MessageApi.js +15 -3
- package/dist/http/MessageApi.js.map +1 -1
- package/dist/http/ProxyApi.d.ts +18 -2
- package/dist/http/ProxyApi.d.ts.map +1 -1
- package/dist/http/ProxyApi.js +33 -6
- package/dist/http/ProxyApi.js.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/realtime/ChatHubClient.d.ts +58 -1
- package/dist/realtime/ChatHubClient.d.ts.map +1 -1
- package/dist/realtime/ChatHubClient.js +202 -15
- package/dist/realtime/ChatHubClient.js.map +1 -1
- package/dist/realtime/NotificationHubClient.d.ts +43 -1
- package/dist/realtime/NotificationHubClient.d.ts.map +1 -1
- package/dist/realtime/NotificationHubClient.js +134 -29
- package/dist/realtime/NotificationHubClient.js.map +1 -1
- package/dist/realtime/ReconnectionManager.d.ts +2 -1
- package/dist/realtime/ReconnectionManager.d.ts.map +1 -1
- package/dist/realtime/ReconnectionManager.js +13 -3
- package/dist/realtime/ReconnectionManager.js.map +1 -1
- package/dist/types/auth.d.ts +2 -2
- package/dist/types/auth.d.ts.map +1 -1
- package/dist/types/block.d.ts +16 -1
- package/dist/types/block.d.ts.map +1 -1
- package/dist/types/block.js +30 -0
- package/dist/types/block.js.map +1 -1
- package/dist/types/bot.d.ts +2 -8
- package/dist/types/bot.d.ts.map +1 -1
- package/dist/types/chat-events.d.ts +29 -3
- package/dist/types/chat-events.d.ts.map +1 -1
- package/dist/types/common.d.ts +19 -0
- package/dist/types/common.d.ts.map +1 -1
- package/dist/types/conversation.d.ts +58 -8
- package/dist/types/conversation.d.ts.map +1 -1
- package/dist/types/message.d.ts +9 -5
- package/dist/types/message.d.ts.map +1 -1
- package/dist/types/notification-events.d.ts +34 -7
- package/dist/types/notification-events.d.ts.map +1 -1
- package/dist/types/participant.d.ts +2 -2
- package/dist/types/participant.d.ts.map +1 -1
- package/dist/types/signalr.d.ts +4 -1
- package/dist/types/signalr.d.ts.map +1 -1
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -126,7 +126,7 @@ import type { ChatClientSignalrOptions, ChatClientRealtime } from '@manonero/cha
|
|
|
126
126
|
import type { UploadProgressCallback } from '@manonero/chat-client-sdk';
|
|
127
127
|
|
|
128
128
|
// RFC 7807 Problem Details — dùng khi cần parse/tạo lỗi thủ công
|
|
129
|
-
import type { ProblemDetails } from '@manonero/chat-client-sdk';
|
|
129
|
+
import type { ProblemDetails, ProblemError } from '@manonero/chat-client-sdk';
|
|
130
130
|
|
|
131
131
|
// Mark as read request type (REST)
|
|
132
132
|
import type { MarkAsReadRequest } from '@manonero/chat-client-sdk';
|
|
@@ -171,7 +171,12 @@ const client = new ChatClient({
|
|
|
171
171
|
});
|
|
172
172
|
```
|
|
173
173
|
|
|
174
|
-
> **Ưu tiên token
|
|
174
|
+
> **Ưu tiên token (resolve mỗi request / connect):**
|
|
175
|
+
> 1. Nếu `tokenProvider` được cấu hình **VÀ** trả về string non-empty → dùng giá trị đó.
|
|
176
|
+
> 2. Ngược lại → dùng token nội bộ (`token` constructor / `setToken()` / auto-set sau login).
|
|
177
|
+
> 3. Ngược lại → `null` (không gửi `Authorization`).
|
|
178
|
+
>
|
|
179
|
+
> Provider luôn được hỏi trước nên các hệ thống refresh-token rotation tiếp tục hoạt động kể cả sau khi `setToken()` đã được gọi. Dùng `clearToken()` nếu muốn xoá token nội bộ và trả quyền hoàn toàn về provider.
|
|
175
180
|
|
|
176
181
|
### Cấu hình SignalR log level
|
|
177
182
|
|
|
@@ -205,6 +210,7 @@ new ChatClient(options: ChatClientOptions)
|
|
|
205
210
|
| `baseUrl` | `string` | ✅ | URL gốc của server, ví dụ `"https://chat-api.example.com"` |
|
|
206
211
|
| `token` | `string` | ❌ | JWT token ban đầu (nếu đã đăng nhập) |
|
|
207
212
|
| `tokenProvider` | `() => string \| null` | ❌ | Hàm lấy token từ bên ngoài |
|
|
213
|
+
| `requestTimeoutMs` | `number \| null` | ❌ | Timeout HTTP / upload tính bằng ms. Mặc định `30000`. Truyền `null` để tắt (hữu ích khi upload file lớn). |
|
|
208
214
|
| `signalrOptions.logLevel` | `LogLevel` | ❌ | Mức log cho SignalR (mặc định: `Warning`) |
|
|
209
215
|
|
|
210
216
|
### Thuộc tính
|
|
@@ -225,20 +231,52 @@ new ChatClient(options: ChatClientOptions)
|
|
|
225
231
|
|
|
226
232
|
### Phương thức
|
|
227
233
|
|
|
228
|
-
#### `setToken(token: string): void`
|
|
234
|
+
#### `setToken(token: string | null): void`
|
|
235
|
+
|
|
236
|
+
Cập nhật JWT token nội bộ. Có hiệu lực **ngay** với mọi HTTP request tiếp theo. Truyền `null` để xoá token nội bộ và `currentUser` (tương đương `clearToken()`).
|
|
229
237
|
|
|
230
|
-
|
|
238
|
+
> ⚠️ 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.
|
|
231
239
|
|
|
232
|
-
>
|
|
240
|
+
> ℹ️ Nếu `tokenProvider` đã được cấu hình và vẫn trả về giá trị, provider tiếp tục được ưu tiên — `setToken()` chỉ ảnh hưởng khi provider trả `null`/empty.
|
|
233
241
|
|
|
234
242
|
```ts
|
|
235
243
|
client.setToken('new-jwt-token-here');
|
|
244
|
+
await client.refreshConnections(); // tuỳ chọn — chỉ khi muốn áp ngay cho SignalR
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
#### `clearToken(): void`
|
|
248
|
+
|
|
249
|
+
Xoá token nội bộ và `currentUser`. Không động tới `tokenProvider` (provider tiếp tục hoạt động bình thường) và không ngắt SignalR. Dùng `logout()` nếu muốn ngắt cả hub.
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
client.clearToken();
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
#### `refreshConnections(): Promise<void>`
|
|
256
|
+
|
|
257
|
+
`disconnect()` + `connect()` lại từng hub đang ở trạng thái `Connected`. Hub đang Disconnected/Reconnecting được giữ nguyên. Dùng để áp token mới (sau `setToken()`) cho phiên SignalR đang chạy.
|
|
258
|
+
|
|
259
|
+
> ⚠️ `disconnect()` xoá local tracking (`joinedConversations`, `subscribedPresenceIds`). Sau khi gọi `refreshConnections()`, ứng dụng phải tự `joinConversation` / `subscribeToPresence` lại — hoặc dùng `ReconnectionManager` để khôi phục trong suốt cho mọi loại disconnect.
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
client.setToken(newToken);
|
|
263
|
+
await client.refreshConnections();
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
#### `logout(): Promise<void>`
|
|
267
|
+
|
|
268
|
+
Xoá token + `currentUser` rồi `disconnect()` cả hai hub. Chỉ clear local state — không gọi server (server không có endpoint logout). Nếu `tokenProvider` được cấu hình và vẫn trả token, request kế tiếp vẫn có thể auth lại.
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
await client.logout();
|
|
236
272
|
```
|
|
237
273
|
|
|
238
274
|
#### `disconnect(): Promise<void>`
|
|
239
275
|
|
|
240
276
|
Ngắt kết nối tất cả các SignalR hub (ChatHub + NotificationHub) đồng thời.
|
|
241
277
|
|
|
278
|
+
> Cả hai hub set cờ `intentionallyClosed` trước khi tear down nên `ReconnectionManager` (nếu được attach) sẽ **bỏ qua** lần `disconnected` này thay vì auto-reconnect ngược ý người dùng.
|
|
279
|
+
|
|
242
280
|
```ts
|
|
243
281
|
await client.disconnect();
|
|
244
282
|
```
|
|
@@ -297,8 +335,8 @@ interface AuthUserInfo {
|
|
|
297
335
|
id: string;
|
|
298
336
|
uniqueName: string;
|
|
299
337
|
fullName: string;
|
|
300
|
-
avatar?: string;
|
|
301
|
-
role?: string | null;
|
|
338
|
+
avatar?: string | null; // URL thuần (KHÔNG phải MediaReference); server có thể trả null
|
|
339
|
+
role?: string | null; // 'dev' với dev users; null với Google/DsAccount users
|
|
302
340
|
}
|
|
303
341
|
```
|
|
304
342
|
|
|
@@ -351,8 +389,8 @@ interface ParticipantDto {
|
|
|
351
389
|
id: string;
|
|
352
390
|
uniqueName: string;
|
|
353
391
|
fullName: string;
|
|
354
|
-
avatar?: MediaReference;
|
|
355
|
-
gender?: string;
|
|
392
|
+
avatar?: MediaReference | null; // hỗ trợ cả internal storageKey lẫn external URL; server có thể trả null
|
|
393
|
+
gender?: string | null;
|
|
356
394
|
isBot: boolean;
|
|
357
395
|
isOnline: boolean;
|
|
358
396
|
lastSeenAt?: string | null; // ISO 8601, null khi đang online
|
|
@@ -440,17 +478,29 @@ const updated = await client.conversations.update('conv-id', {
|
|
|
440
478
|
});
|
|
441
479
|
```
|
|
442
480
|
|
|
443
|
-
### `
|
|
481
|
+
### `delete(id: string): Promise<void>`
|
|
482
|
+
|
|
483
|
+
Hành vi phụ thuộc vào loại cuộc hội thoại:
|
|
484
|
+
- **Group** → rời nhóm (tương đương `leaveGroup()`). Các participant khác vẫn còn đó.
|
|
485
|
+
- **OneToOne / Self** → ẩn cuộc hội thoại khỏi danh sách (soft-delete). Conversation vẫn tồn tại và sẽ tự khôi phục khi có tin nhắn mới.
|
|
486
|
+
|
|
487
|
+
```ts
|
|
488
|
+
await client.conversations.delete('conv-id');
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
### `leaveGroup(id: string): Promise<void>`
|
|
444
492
|
|
|
445
|
-
Rời
|
|
493
|
+
Rời cuộc hội thoại **Group** một cách tường minh. Trả về `403 Forbidden` nếu conversation không phải Group.
|
|
494
|
+
|
|
495
|
+
> Dùng `leaveGroup()` khi cần ngữ nghĩa Group-only với phản hồi lỗi rõ ràng cho non-Group. Dùng `delete()` khi muốn xử lý cả Group lẫn OneToOne/Self trong cùng một flow.
|
|
446
496
|
|
|
447
497
|
```ts
|
|
448
|
-
await client.conversations.
|
|
498
|
+
await client.conversations.leaveGroup('conv-id');
|
|
449
499
|
```
|
|
450
500
|
|
|
451
501
|
### `findOneToOne(otherParticipantId): Promise<ConversationDto | null>`
|
|
452
502
|
|
|
453
|
-
Tìm cuộc hội thoại 1-1 với user khác. Trả về `null` (
|
|
503
|
+
Tìm cuộc hội thoại 1-1 với user khác. Trả về `null` nếu chưa có (server trả 404, SDK chuyển thành `null` để gọi không cần try/catch).
|
|
454
504
|
|
|
455
505
|
```ts
|
|
456
506
|
const existing = await client.conversations.findOneToOne('other-user-id');
|
|
@@ -490,16 +540,49 @@ await client.conversations.pin('conv-id');
|
|
|
490
540
|
await client.conversations.unpin('conv-id');
|
|
491
541
|
```
|
|
492
542
|
|
|
493
|
-
### `markAsRead(id,
|
|
543
|
+
### `markAsRead(id, request) / markAsUnread(id): Promise<void>`
|
|
494
544
|
|
|
495
545
|
```ts
|
|
496
546
|
// Đánh dấu đã đọc đến messageId
|
|
497
|
-
await client.conversations.markAsRead('conv-id', 'last-message-id');
|
|
547
|
+
await client.conversations.markAsRead('conv-id', { messageId: 'last-message-id' });
|
|
498
548
|
|
|
499
549
|
// Đánh dấu chưa đọc
|
|
500
550
|
await client.conversations.markAsUnread('conv-id');
|
|
501
551
|
```
|
|
502
552
|
|
|
553
|
+
### `getMedia(conversationId, params?): Promise<CursorPaginatedResult<ConversationMediaItemDto>>`
|
|
554
|
+
|
|
555
|
+
Liệt kê toàn bộ media (image/video/audio/file) và link (LinkPreview hoặc external URL) đã từng xuất hiện trong cuộc hội thoại — sắp xếp **newest-first**, cursor pagination.
|
|
556
|
+
|
|
557
|
+
```ts
|
|
558
|
+
// Mặc định: kind = 'all', limit = 50
|
|
559
|
+
const page1 = await client.conversations.getMedia('conv-id');
|
|
560
|
+
|
|
561
|
+
// Chỉ file/ảnh nội bộ
|
|
562
|
+
const attachments = await client.conversations.getMedia('conv-id', {
|
|
563
|
+
kind: 'attachment',
|
|
564
|
+
limit: 100,
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// Chỉ link / LinkPreview
|
|
568
|
+
const links = await client.conversations.getMedia('conv-id', { kind: 'link' });
|
|
569
|
+
|
|
570
|
+
// Trang tiếp theo
|
|
571
|
+
if (page1.hasMore) {
|
|
572
|
+
const page2 = await client.conversations.getMedia('conv-id', {
|
|
573
|
+
cursor: page1.nextCursor!,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
| Param | Type | Default | Mô tả |
|
|
579
|
+
|-------|------|---------|-------|
|
|
580
|
+
| `kind` | `'all' \| 'attachment' \| 'link'` | `'all'` | Lọc theo loại item |
|
|
581
|
+
| `limit` | number | 50 | 1..100 |
|
|
582
|
+
| `cursor` | string | — | `nextCursor` từ trang trước (opaque) |
|
|
583
|
+
|
|
584
|
+
> **Lưu ý:** Người dùng chỉ thấy media của các message từ thời điểm họ join cuộc hội thoại trở đi. Server trả `403` nếu caller không phải participant.
|
|
585
|
+
|
|
503
586
|
### Kiểu dữ liệu Conversation
|
|
504
587
|
|
|
505
588
|
```ts
|
|
@@ -508,8 +591,8 @@ type ConversationType = 'Group' | 'OneToOne' | 'Self';
|
|
|
508
591
|
interface ConversationDto {
|
|
509
592
|
id: string;
|
|
510
593
|
type: ConversationType;
|
|
511
|
-
name?: string;
|
|
512
|
-
avatar?: MediaReference;
|
|
594
|
+
name?: string | null; // Tên nhóm (chỉ Group); null cho OneToOne/Self
|
|
595
|
+
avatar?: MediaReference | null;
|
|
513
596
|
ownerId: string;
|
|
514
597
|
lastMessageAt?: string | null; // ISO 8601, null khi chưa có tin nhắn
|
|
515
598
|
lastMessageId?: string | null; // null khi chưa có tin nhắn
|
|
@@ -533,8 +616,8 @@ interface ConversationParticipantDto {
|
|
|
533
616
|
interface ConversationListItemDto {
|
|
534
617
|
id: string;
|
|
535
618
|
type: ConversationType;
|
|
536
|
-
name?: string;
|
|
537
|
-
avatar?: MediaReference;
|
|
619
|
+
name?: string | null; // null cho OneToOne/Self
|
|
620
|
+
avatar?: MediaReference | null;
|
|
538
621
|
lastMessageAt?: string | null; // ISO 8601
|
|
539
622
|
lastMessageId?: string | null;
|
|
540
623
|
lastMessage?: MessageDto | null; // Preview tin nhắn cuối, null khi chưa có tin nhắn
|
|
@@ -543,6 +626,49 @@ interface ConversationListItemDto {
|
|
|
543
626
|
unreadCount: number;
|
|
544
627
|
participantCount: number;
|
|
545
628
|
}
|
|
629
|
+
|
|
630
|
+
// Media listing (getMedia)
|
|
631
|
+
type ConversationMediaKindFilter = 'all' | 'attachment' | 'link';
|
|
632
|
+
type ConversationMediaKind = 'Attachment' | 'Link';
|
|
633
|
+
type ConversationMediaBlockType = 'Image' | 'Video' | 'Audio' | 'File' | 'LinkPreview';
|
|
634
|
+
|
|
635
|
+
interface ConversationMediaItemDto {
|
|
636
|
+
id: string; // Composite "{messageId}#{blockIndex:D2}"
|
|
637
|
+
conversationId: string;
|
|
638
|
+
messageId: string;
|
|
639
|
+
blockIndex: number; // 0-based vị trí block trong message
|
|
640
|
+
senderId: string;
|
|
641
|
+
senderType: string; // 'User' | 'Bot' | 'System'
|
|
642
|
+
kind: ConversationMediaKind;
|
|
643
|
+
blockType: ConversationMediaBlockType;
|
|
644
|
+
createdAt: string; // ISO 8601 — thời điểm message được tạo
|
|
645
|
+
|
|
646
|
+
storageKey?: string | null; // set khi kind === 'Attachment'
|
|
647
|
+
url?: string | null; // set khi kind === 'Link'
|
|
648
|
+
|
|
649
|
+
// File / audio / video
|
|
650
|
+
fileName?: string | null;
|
|
651
|
+
mimeType?: string | null;
|
|
652
|
+
fileSizeBytes?: number | null;
|
|
653
|
+
|
|
654
|
+
// Image / video / audio
|
|
655
|
+
width?: number | null;
|
|
656
|
+
height?: number | null;
|
|
657
|
+
durationSeconds?: number | null;
|
|
658
|
+
caption?: string | null;
|
|
659
|
+
altText?: string | null;
|
|
660
|
+
|
|
661
|
+
// Image / video thumbnail
|
|
662
|
+
thumbnailStorageKey?: string | null;
|
|
663
|
+
thumbnailUrl?: string | null;
|
|
664
|
+
|
|
665
|
+
// LinkPreview metadata
|
|
666
|
+
linkTitle?: string | null;
|
|
667
|
+
linkDescription?: string | null;
|
|
668
|
+
linkSiteName?: string | null;
|
|
669
|
+
linkImageStorageKey?: string | null;
|
|
670
|
+
linkImageUrl?: string | null;
|
|
671
|
+
}
|
|
546
672
|
```
|
|
547
673
|
|
|
548
674
|
---
|
|
@@ -612,11 +738,12 @@ Chỉ người gửi mới có thể chỉnh sửa.
|
|
|
612
738
|
```ts
|
|
613
739
|
const edited = await client.messages.edit('msg-id', {
|
|
614
740
|
blocks: [{ $type: 'text', format: 'Plain', content: 'Nội dung đã sửa', plainText: 'Nội dung đã sửa' }],
|
|
615
|
-
mentions: [],
|
|
741
|
+
mentions: [], // optional
|
|
742
|
+
replyToMessageId: null, // optional — null để xóa reply reference
|
|
616
743
|
});
|
|
617
744
|
```
|
|
618
745
|
|
|
619
|
-
> ⚠️ **Khác biệt REST vs SignalR:** REST dùng `{ blocks, mentions }`, SignalR dùng `{ messageId, newBlocks, newMentions }`.
|
|
746
|
+
> ⚠️ **Khác biệt REST vs SignalR:** REST dùng `{ blocks, mentions, replyToMessageId }`, SignalR dùng `{ messageId, newBlocks, newMentions, newReplyToMessageId }`.
|
|
620
747
|
|
|
621
748
|
### `delete(messageId): Promise<{ messageId, deletedAt }>`
|
|
622
749
|
|
|
@@ -653,7 +780,6 @@ interface MessageDto {
|
|
|
653
780
|
conversationId: string;
|
|
654
781
|
senderId: string;
|
|
655
782
|
senderType: SenderType;
|
|
656
|
-
timestamp: string; // ISO 8601 — thời điểm gửi
|
|
657
783
|
blocks: Block[]; // Nội dung tin nhắn
|
|
658
784
|
plainTextIndex: string | null;
|
|
659
785
|
replyToMessageId: string | null;
|
|
@@ -699,6 +825,7 @@ interface SendMessageRequest {
|
|
|
699
825
|
interface EditMessageRequest {
|
|
700
826
|
blocks: Block[];
|
|
701
827
|
mentions?: Mention[];
|
|
828
|
+
replyToMessageId?: string | null; // null để xóa reply reference
|
|
702
829
|
}
|
|
703
830
|
|
|
704
831
|
// Request đánh dấu đã đọc (REST)
|
|
@@ -756,18 +883,37 @@ const result = await client.files.upload(uploadUrl, file, (loaded, total) => {
|
|
|
756
883
|
|
|
757
884
|
> Khi có `onProgress`, SDK dùng XHR thay vì fetch (vì fetch không hỗ trợ upload progress events).
|
|
758
885
|
|
|
759
|
-
### `getDownloadUrl(storageKey
|
|
886
|
+
### `getDownloadUrl(storageKey, signed?): string`
|
|
760
887
|
|
|
761
888
|
Tạo URL tải file tuyệt đối (dùng để hiển thị ảnh, tải xuống, v.v.).
|
|
762
889
|
|
|
763
890
|
```ts
|
|
891
|
+
// URL public (mặc định)
|
|
764
892
|
const url = client.files.getDownloadUrl('01HXABCDEF/photo.jpg');
|
|
765
893
|
// → "https://chat-api.example.com/api/files/01HXABCDEF/photo.jpg"
|
|
766
894
|
```
|
|
767
895
|
|
|
896
|
+
#### Signed URL (kiểm soát truy cập)
|
|
897
|
+
|
|
898
|
+
Khi server bật signed download (kiểm soát truy cập có thời hạn), truyền `expires` + `sig` ở tham số thứ hai:
|
|
899
|
+
|
|
900
|
+
```ts
|
|
901
|
+
const url = client.files.getDownloadUrl('01HXABCDEF/photo.jpg', {
|
|
902
|
+
expires: '2026-03-26T13:00:00.0000000Z',
|
|
903
|
+
sig: 'abc123...',
|
|
904
|
+
});
|
|
905
|
+
// → ".../api/files/01HXABCDEF/photo.jpg?expires=...&sig=abc123..."
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
> Server trả `400 Bad Request` (title `Invalid Signature`) nếu URL bị sửa hoặc đã hết hạn. `expires` và `sig` thường lấy từ nguồn server-issued tương ứng (không tự ghép).
|
|
909
|
+
|
|
910
|
+
> **Khi server bật signed download:** Server thường trả sẵn `url` trong `MediaReference` (đã gắn `expires` + `sig`) thay vì yêu cầu client tự gọi `getDownloadUrl(storageKey, { expires, sig })`. Trong trường hợp đó, ưu tiên dùng `MediaReference.url` (sau khi resolve relative — xem [MediaReference](#mediareference)), **không** dùng `getDownloadUrl(storageKey)` vì sẽ tạo ra unsigned URL và bị server từ chối.
|
|
911
|
+
|
|
912
|
+
> **Encoding:** `storageKey` có thể chứa `/` — SDK giữ `/` làm phân cách path; các ký tự đặc biệt khác (space, `?`, `#`, `%`...) tự động được percent-encode theo từng segment.
|
|
913
|
+
|
|
768
914
|
### `delete(storageKey: string): Promise<void>`
|
|
769
915
|
|
|
770
|
-
Xóa file. Chỉ người upload mới có thể xóa. `
|
|
916
|
+
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.
|
|
771
917
|
|
|
772
918
|
```ts
|
|
773
919
|
await client.files.delete('01HXABCDEF/photo.jpg');
|
|
@@ -808,7 +954,7 @@ GET (`list`, `getById`) là **public**. Các thao tác quản lý yêu cầu JWT
|
|
|
808
954
|
|
|
809
955
|
### `list(params?): Promise<PagedResult<BotDto>>`
|
|
810
956
|
|
|
811
|
-
> Lưu ý: BotApi dùng **page-based** pagination (không phải cursor-based). `page` bắt đầu từ 1.
|
|
957
|
+
> Lưu ý: BotApi dùng **page-based** pagination (không phải cursor-based). `page` bắt đầu từ 1.
|
|
812
958
|
|
|
813
959
|
```ts
|
|
814
960
|
const result = await client.bots.list({ page: 1, pageSize: 20 });
|
|
@@ -818,17 +964,15 @@ console.log(result.totalCount); // Tổng số bot
|
|
|
818
964
|
|
|
819
965
|
### `getById(id): Promise<BotDto>`
|
|
820
966
|
|
|
821
|
-
> Server cache kết quả **5 phút**.
|
|
822
|
-
|
|
823
967
|
```ts
|
|
824
968
|
const bot = await client.bots.getById('bot-id');
|
|
825
969
|
// Lấy thêm thông tin display: name, avatar
|
|
826
970
|
const participant = await client.participants.getById(bot.participantId);
|
|
827
971
|
```
|
|
828
972
|
|
|
829
|
-
### `create(request): Promise<BotDto
|
|
973
|
+
### `create(request): Promise<BotDto>`
|
|
830
974
|
|
|
831
|
-
|
|
975
|
+
Đăng ký bot mới.
|
|
832
976
|
|
|
833
977
|
```ts
|
|
834
978
|
const bot = await client.bots.create({
|
|
@@ -836,10 +980,8 @@ const bot = await client.bots.create({
|
|
|
836
980
|
fullName: 'My Assistant Bot',
|
|
837
981
|
description: 'Trợ lý AI',
|
|
838
982
|
kafkaTopic: 'my-bot-topic', // Không thể thay đổi sau khi tạo
|
|
839
|
-
rateLimitPerSecond: 5,
|
|
840
983
|
listenAllGroupMessages: false,
|
|
841
984
|
});
|
|
842
|
-
const apiKey = bot.apiKey; // Lưu ngay!
|
|
843
985
|
```
|
|
844
986
|
|
|
845
987
|
### `update(id, request): Promise<BotDto>`
|
|
@@ -847,7 +989,6 @@ const apiKey = bot.apiKey; // Lưu ngay!
|
|
|
847
989
|
```ts
|
|
848
990
|
await client.bots.update('bot-id', {
|
|
849
991
|
fullName: 'Updated Bot Name',
|
|
850
|
-
rateLimitPerSecond: 10,
|
|
851
992
|
// kafkaTopic KHÔNG thể cập nhật
|
|
852
993
|
});
|
|
853
994
|
```
|
|
@@ -859,14 +1000,6 @@ await client.bots.activate('bot-id');
|
|
|
859
1000
|
await client.bots.deactivate('bot-id');
|
|
860
1001
|
```
|
|
861
1002
|
|
|
862
|
-
### `regenerateKey(id): Promise<RegenerateKeyResponse>`
|
|
863
|
-
|
|
864
|
-
Xoay API key — key cũ bị vô hiệu hóa ngay lập tức.
|
|
865
|
-
|
|
866
|
-
```ts
|
|
867
|
-
const { apiKey } = await client.bots.regenerateKey('bot-id');
|
|
868
|
-
```
|
|
869
|
-
|
|
870
1003
|
### `delete(id): Promise<void>`
|
|
871
1004
|
|
|
872
1005
|
Soft delete.
|
|
@@ -881,11 +1014,10 @@ await client.bots.delete('bot-id');
|
|
|
881
1014
|
interface BotDto {
|
|
882
1015
|
id: string;
|
|
883
1016
|
participantId: string; // Dùng để lấy name/avatar từ ParticipantApi
|
|
884
|
-
description?: string;
|
|
885
|
-
metadata?: Record<string, unknown
|
|
1017
|
+
description?: string | null;
|
|
1018
|
+
metadata?: Record<string, unknown> | null;
|
|
886
1019
|
kafkaTopic: string; // KHÔNG thể thay đổi sau khi tạo
|
|
887
1020
|
isActive: boolean;
|
|
888
|
-
rateLimitPerSecond: number;
|
|
889
1021
|
listenAllGroupMessages: boolean;
|
|
890
1022
|
createdBy: string; // ID của người tạo bot
|
|
891
1023
|
createdAt: string; // ISO 8601
|
|
@@ -905,7 +1037,6 @@ interface CreateBotRequest {
|
|
|
905
1037
|
description?: string;
|
|
906
1038
|
metadata?: Record<string, unknown>;
|
|
907
1039
|
kafkaTopic: string; // KHÔNG thể thay đổi sau khi tạo
|
|
908
|
-
rateLimitPerSecond?: number;
|
|
909
1040
|
listenAllGroupMessages?: boolean; // Mặc định false
|
|
910
1041
|
}
|
|
911
1042
|
|
|
@@ -915,7 +1046,6 @@ interface UpdateBotRequest {
|
|
|
915
1046
|
avatar?: MediaReference;
|
|
916
1047
|
description?: string;
|
|
917
1048
|
metadata?: Record<string, unknown>;
|
|
918
|
-
rateLimitPerSecond?: number;
|
|
919
1049
|
listenAllGroupMessages?: boolean;
|
|
920
1050
|
// kafkaTopic KHÔNG thể cập nhật
|
|
921
1051
|
}
|
|
@@ -939,7 +1069,34 @@ const order = await client.proxy.post<Order>('trading', 'api/orders', {
|
|
|
939
1069
|
quantity: 100,
|
|
940
1070
|
});
|
|
941
1071
|
|
|
942
|
-
//
|
|
1072
|
+
// PUT /api/proxy/trading/api/orders/123
|
|
1073
|
+
const updated = await client.proxy.put<Order>('trading', 'api/orders/123', {
|
|
1074
|
+
quantity: 200,
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
// PATCH /api/proxy/trading/api/orders/123
|
|
1078
|
+
const patched = await client.proxy.patch<Order>('trading', 'api/orders/123', {
|
|
1079
|
+
status: 'cancelled',
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
// DELETE /api/proxy/trading/api/orders/123
|
|
1083
|
+
await client.proxy.delete('trading', 'api/orders/123');
|
|
1084
|
+
|
|
1085
|
+
// Truyền custom headers — mỗi shortcut đều nhận `headers?` ở tham số cuối
|
|
1086
|
+
const dataWithHeader = await client.proxy.get<StockData>(
|
|
1087
|
+
'trading',
|
|
1088
|
+
'api/stocks/VN30',
|
|
1089
|
+
{ 'X-Trace-Id': 'abc-123' },
|
|
1090
|
+
);
|
|
1091
|
+
|
|
1092
|
+
const orderWithHeader = await client.proxy.post<Order>(
|
|
1093
|
+
'trading',
|
|
1094
|
+
'api/orders',
|
|
1095
|
+
{ symbol: 'VNM', quantity: 100 },
|
|
1096
|
+
{ 'Idempotency-Key': 'order-uuid' },
|
|
1097
|
+
);
|
|
1098
|
+
|
|
1099
|
+
// Request tùy chỉnh method (hỗ trợ GET, POST, PUT, PATCH, DELETE)
|
|
943
1100
|
const result = await client.proxy.request<unknown>('myservice', 'api/resource/123', {
|
|
944
1101
|
method: 'DELETE',
|
|
945
1102
|
headers: { 'X-Custom-Header': 'value' },
|
|
@@ -1035,6 +1192,12 @@ await client.realtime.chat.leaveConversation('conv-id');
|
|
|
1035
1192
|
```
|
|
1036
1193
|
|
|
1037
1194
|
> SDK theo dõi `joinedConversations` và tự động re-join sau khi reconnect.
|
|
1195
|
+
>
|
|
1196
|
+
> **Lưu ý về FORBIDDEN/UNAUTHORIZED:** Theo spec, server có thể chấp nhận lời gọi `joinConversation` nhưng từ chối quyền truy cập sau đó qua event `Error` (vd. `FORBIDDEN — You are not a participant`). SDK sẽ tự động loại conversationId đó khỏi `joinedConversations` để tránh re-join sai sau reconnect, dựa trên:
|
|
1197
|
+
> 1. `error.details.conversationId` nếu server cung cấp (chính xác nhất), hoặc
|
|
1198
|
+
> 2. Fallback: message khớp `/not a participant/i` **và** chỉ có đúng 1 lời gọi `joinConversation` đang trong cửa sổ ~2s.
|
|
1199
|
+
>
|
|
1200
|
+
> Vẫn nên đăng ký listener `error` để hiển thị thông báo cho user.
|
|
1038
1201
|
|
|
1039
1202
|
### Gửi tin nhắn qua SignalR
|
|
1040
1203
|
|
|
@@ -1055,10 +1218,11 @@ if (ack.success) {
|
|
|
1055
1218
|
### Chỉnh sửa / Xóa / Khôi phục tin nhắn qua SignalR
|
|
1056
1219
|
|
|
1057
1220
|
```ts
|
|
1058
|
-
// Chỉnh sửa — dùng newBlocks/newMentions (khác REST!)
|
|
1221
|
+
// Chỉnh sửa — dùng newBlocks/newMentions/newReplyToMessageId (khác REST!)
|
|
1059
1222
|
const editAck = await client.realtime.chat.editMessage({
|
|
1060
1223
|
messageId: 'msg-id',
|
|
1061
1224
|
newBlocks: [{ $type: 'text', format: 'Plain', content: 'Đã sửa', plainText: 'Đã sửa' }],
|
|
1225
|
+
newReplyToMessageId: null, // optional — null để xóa reply reference
|
|
1062
1226
|
});
|
|
1063
1227
|
|
|
1064
1228
|
// Xóa — truyền object, KHÔNG phải string
|
|
@@ -1113,6 +1277,7 @@ client.realtime.chat.off('messageReceived', handler);
|
|
|
1113
1277
|
| `messageUpdated` | `MessageUpdatedDto` | Tin nhắn được chỉnh sửa |
|
|
1114
1278
|
| `messageDeleted` | `MessageDeletedDto` | Tin nhắn bị xóa |
|
|
1115
1279
|
| `messageRecovered` | `ChatMessageDto` | Tin nhắn được khôi phục |
|
|
1280
|
+
| `messageThumbnailsReady` | `MessageThumbnailsReadyDto` | Server đã sinh xong thumbnail cho ảnh/video — patch theo `blockIndex` |
|
|
1116
1281
|
| `reactionAdded` | `ReactionAddedDto` | Thêm reaction |
|
|
1117
1282
|
| `reactionRemoved` | `ReactionRemovedDto` | Xóa reaction |
|
|
1118
1283
|
| `typingStarted` | `TypingDto` | Người dùng đang gõ |
|
|
@@ -1138,7 +1303,8 @@ interface MessageUpdatedDto {
|
|
|
1138
1303
|
blocks: Block[];
|
|
1139
1304
|
plainTextContent: string | null;
|
|
1140
1305
|
mentions?: Mention[];
|
|
1141
|
-
updatedAt: string;
|
|
1306
|
+
updatedAt: string; // ISO 8601
|
|
1307
|
+
replyToMessageId?: string | null; // null nếu reply reference đã bị xóa
|
|
1142
1308
|
}
|
|
1143
1309
|
|
|
1144
1310
|
// Tin nhắn bị xóa
|
|
@@ -1186,6 +1352,36 @@ interface ReadReceiptUpdatedDto {
|
|
|
1186
1352
|
lastReadMessageId: string;
|
|
1187
1353
|
readAt: string; // ISO 8601
|
|
1188
1354
|
}
|
|
1355
|
+
|
|
1356
|
+
// Thumbnail ready cho một block media trong message
|
|
1357
|
+
interface MessageThumbnailDto {
|
|
1358
|
+
blockIndex: number; // Vị trí block trong mảng blocks của message
|
|
1359
|
+
thumbnail: MediaReference;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// Server đã sinh xong thumbnail cho ảnh/video (xử lý bất đồng bộ).
|
|
1363
|
+
// Event này gửi SAU messageReceived/streamCompleted.
|
|
1364
|
+
interface MessageThumbnailsReadyDto {
|
|
1365
|
+
messageId: string;
|
|
1366
|
+
conversationId: string;
|
|
1367
|
+
thumbnails: MessageThumbnailDto[];
|
|
1368
|
+
}
|
|
1369
|
+
```
|
|
1370
|
+
|
|
1371
|
+
### Cập nhật thumbnail bất đồng bộ
|
|
1372
|
+
|
|
1373
|
+
Khi gửi tin nhắn có ảnh/video, server xử lý thumbnail trong nền và phát event `messageThumbnailsReady` sau khi hoàn tất. Client nên patch block theo `blockIndex` thay vì re-fetch toàn bộ message.
|
|
1374
|
+
|
|
1375
|
+
```ts
|
|
1376
|
+
client.realtime.chat.on('messageThumbnailsReady', (dto) => {
|
|
1377
|
+
// dto.thumbnails: [{ blockIndex: 0, thumbnail: { storageKey, url } }, ...]
|
|
1378
|
+
for (const item of dto.thumbnails) {
|
|
1379
|
+
const block = messageStore.get(dto.messageId)?.blocks[item.blockIndex];
|
|
1380
|
+
if (block && (block.$type === 'image' || block.$type === 'video')) {
|
|
1381
|
+
block.thumbnail = item.thumbnail;
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
});
|
|
1189
1385
|
```
|
|
1190
1386
|
|
|
1191
1387
|
### Bot streaming
|
|
@@ -1263,7 +1459,7 @@ await client.realtime.notifications.resubscribePresence();
|
|
|
1263
1459
|
### Theo dõi trạng thái online (Presence)
|
|
1264
1460
|
|
|
1265
1461
|
```ts
|
|
1266
|
-
// Đăng ký theo dõi
|
|
1462
|
+
// Đăng ký theo dõi presence — SDK tự chia nhỏ thành các batch 200 nếu danh sách dài hơn
|
|
1267
1463
|
await client.realtime.notifications.subscribeToPresence(['user-1', 'user-2', 'user-3']);
|
|
1268
1464
|
|
|
1269
1465
|
// Server gửi PresenceState ngay lập tức sau khi subscribe
|
|
@@ -1278,7 +1474,7 @@ client.realtime.notifications.on('presenceChanged', (dto) => {
|
|
|
1278
1474
|
console.log(dto.participantId, 'is now', dto.isOnline ? 'online' : 'offline');
|
|
1279
1475
|
});
|
|
1280
1476
|
|
|
1281
|
-
// Hủy theo dõi — cũng
|
|
1477
|
+
// Hủy theo dõi — cũng tự chia nhỏ thành các batch 200
|
|
1282
1478
|
await client.realtime.notifications.unsubscribeFromPresence(['user-1']);
|
|
1283
1479
|
```
|
|
1284
1480
|
|
|
@@ -1300,6 +1496,8 @@ await client.realtime.notifications.unsubscribeFromPresence(['user-1']);
|
|
|
1300
1496
|
| `conversationPinned` | `ConversationPinnedDto` | Cuộc hội thoại được pin |
|
|
1301
1497
|
| `conversationUnpinned` | `ConversationUnpinnedDto` | Cuộc hội thoại được unpin |
|
|
1302
1498
|
| `readStatusChanged` | `ReadStatusChangedDto` | Trạng thái đọc thay đổi |
|
|
1499
|
+
| `conversationDeleted` | `ConversationDeletedDto` | Conversation bị ẩn/xóa khỏi danh sách (chỉ OneToOne & Self, chỉ user thực hiện) |
|
|
1500
|
+
| `conversationRestored` | `ConversationRestoredDto` | Conversation bị ẩn được khôi phục tự động do có tin nhắn mới (chỉ OneToOne & Self) |
|
|
1303
1501
|
| `error` | `HubErrorDto` | Lỗi từ server |
|
|
1304
1502
|
| `reconnecting` | `Error \| undefined` | Đang reconnect |
|
|
1305
1503
|
| `reconnected` | `string \| undefined` | Đã reconnect |
|
|
@@ -1325,11 +1523,11 @@ interface PresenceChangedDto {
|
|
|
1325
1523
|
interface NewMessageNotificationDto {
|
|
1326
1524
|
conversationId: string;
|
|
1327
1525
|
conversationType: ConversationType;
|
|
1328
|
-
conversationName?: string;
|
|
1526
|
+
conversationName?: string | null;
|
|
1329
1527
|
messageId: string;
|
|
1330
1528
|
senderId: string;
|
|
1331
1529
|
senderName: string;
|
|
1332
|
-
senderAvatar?: MediaReference;
|
|
1530
|
+
senderAvatar?: MediaReference | null; // Có thể null khi server chưa sẵn signed URL
|
|
1333
1531
|
contentPreview: string;
|
|
1334
1532
|
sentAt: string; // ISO 8601
|
|
1335
1533
|
}
|
|
@@ -1354,15 +1552,15 @@ interface UnreadCountChangedDto {
|
|
|
1354
1552
|
interface ConversationCreatedDto {
|
|
1355
1553
|
conversationId: string;
|
|
1356
1554
|
type: ConversationType;
|
|
1357
|
-
name?: string;
|
|
1358
|
-
avatar?: MediaReference;
|
|
1555
|
+
name?: string | null;
|
|
1556
|
+
avatar?: MediaReference | null;
|
|
1359
1557
|
participantCount: number;
|
|
1360
1558
|
}
|
|
1361
1559
|
|
|
1362
1560
|
interface ConversationUpdatedDto {
|
|
1363
1561
|
conversationId: string;
|
|
1364
1562
|
type: ConversationType;
|
|
1365
|
-
name?: string;
|
|
1563
|
+
name?: string | null;
|
|
1366
1564
|
avatar?: MediaReference | null; // null = avatar đã bị xóa
|
|
1367
1565
|
participantCount: number;
|
|
1368
1566
|
}
|
|
@@ -1371,7 +1569,7 @@ interface ParticipantJoinedDto {
|
|
|
1371
1569
|
conversationId: string;
|
|
1372
1570
|
participantId: string;
|
|
1373
1571
|
participantName: string;
|
|
1374
|
-
participantAvatar?: MediaReference;
|
|
1572
|
+
participantAvatar?: MediaReference | null; // Có thể null khi server chưa sẵn signed URL
|
|
1375
1573
|
changedAt: string; // ISO 8601
|
|
1376
1574
|
}
|
|
1377
1575
|
|
|
@@ -1380,7 +1578,7 @@ interface ParticipantLeftDto {
|
|
|
1380
1578
|
conversationId: string;
|
|
1381
1579
|
participantId: string;
|
|
1382
1580
|
participantName: string;
|
|
1383
|
-
participantAvatar?: MediaReference;
|
|
1581
|
+
participantAvatar?: MediaReference | null; // Có thể null khi server chưa sẵn signed URL
|
|
1384
1582
|
changedAt: string; // ISO 8601
|
|
1385
1583
|
}
|
|
1386
1584
|
|
|
@@ -1400,6 +1598,26 @@ interface ReadStatusChangedDto {
|
|
|
1400
1598
|
unreadCount: number;
|
|
1401
1599
|
markedAsUnread: boolean; // true khi user chủ động đánh dấu chưa đọc
|
|
1402
1600
|
}
|
|
1601
|
+
|
|
1602
|
+
// Conversation bị ẩn/xóa khỏi danh sách của user.
|
|
1603
|
+
// Chỉ áp dụng cho OneToOne và Self — không broadcast tới người còn lại.
|
|
1604
|
+
// Conversation vẫn tồn tại và sẽ tự khôi phục khi có tin nhắn mới.
|
|
1605
|
+
interface ConversationDeletedDto {
|
|
1606
|
+
conversationId: string;
|
|
1607
|
+
deletedAt: string; // ISO 8601
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// Conversation đã bị ẩn được khôi phục tự động vì có tin nhắn mới.
|
|
1611
|
+
// Client nên thêm lại conversation vào sidebar với metadata đầy đủ từ payload.
|
|
1612
|
+
// Chú ý: Ngay sau event này, client cũng sẽ nhận newMessageNotification cho tin nhắn kích hoạt khôi phục.
|
|
1613
|
+
interface ConversationRestoredDto {
|
|
1614
|
+
conversationId: string;
|
|
1615
|
+
type: ConversationType;
|
|
1616
|
+
name?: string | null;
|
|
1617
|
+
avatar?: MediaReference | null;
|
|
1618
|
+
participantCount: number;
|
|
1619
|
+
restoredAt: string; // ISO 8601
|
|
1620
|
+
}
|
|
1403
1621
|
```
|
|
1404
1622
|
|
|
1405
1623
|
---
|
|
@@ -1418,7 +1636,7 @@ const manager = new ReconnectionManager({
|
|
|
1418
1636
|
// Gọi API refresh token của ứng dụng
|
|
1419
1637
|
const newToken = await refreshToken();
|
|
1420
1638
|
if (newToken) {
|
|
1421
|
-
client.setToken(newToken); // Cập nhật token vào SDK
|
|
1639
|
+
client.setToken(newToken); // Cập nhật token vào SDK; lần connect() kế tiếp sẽ dùng giá trị mới
|
|
1422
1640
|
}
|
|
1423
1641
|
return newToken; // Trả về null để hủy reconnect
|
|
1424
1642
|
},
|
|
@@ -1432,7 +1650,9 @@ manager.stop();
|
|
|
1432
1650
|
|
|
1433
1651
|
**Chiến lược backoff:** 3 lần thử với delay 2s → 5s → 10s.
|
|
1434
1652
|
|
|
1435
|
-
**Phát hiện token hết hạn:**
|
|
1653
|
+
**Phát hiện token hết hạn:** heuristic — match (case-insensitive) các keyword `401`, `unauthorized`, `expired`, `invalid_token`, `invalid token` trong message của error.
|
|
1654
|
+
|
|
1655
|
+
**Bỏ qua close chủ động:** Cả `ChatHubClient` và `NotificationHubClient` expose getter `intentionallyClosed`. Trước khi tear down trong `disconnect()` (kể cả khi gọi qua `client.disconnect()` / `client.logout()`), cờ này được set `true`. Manager kiểm tra cờ ngay đầu handler và **bỏ qua** lần `disconnected` đó — không tự reconnect ngược ý người dùng. Cờ được reset về `false` ở đầu lần `connect()` kế tiếp.
|
|
1436
1656
|
|
|
1437
1657
|
---
|
|
1438
1658
|
|
|
@@ -1646,7 +1866,7 @@ type ButtonStyle = 'Default' | 'Primary' | 'Danger';
|
|
|
1646
1866
|
interface ActionButton {
|
|
1647
1867
|
label: string;
|
|
1648
1868
|
action: ButtonAction;
|
|
1649
|
-
value
|
|
1869
|
+
value?: string | null;
|
|
1650
1870
|
style: ButtonStyle;
|
|
1651
1871
|
}
|
|
1652
1872
|
|
|
@@ -1730,8 +1950,34 @@ interface PagedResult<T> {
|
|
|
1730
1950
|
|
|
1731
1951
|
```ts
|
|
1732
1952
|
interface MediaReference {
|
|
1733
|
-
storageKey?: string; // Key nội bộ — dùng FileApi.getDownloadUrl() để tạo URL
|
|
1734
|
-
url?: string; // URL
|
|
1953
|
+
storageKey?: string; // Key lưu trữ nội bộ — dùng FileApi.getDownloadUrl(storageKey) để tạo URL tuyệt đối
|
|
1954
|
+
url?: string; // URL do server cấp sẵn (xem chi tiết bên dưới)
|
|
1955
|
+
}
|
|
1956
|
+
```
|
|
1957
|
+
|
|
1958
|
+
Trường `url` có hai dạng tuỳ cấu hình server:
|
|
1959
|
+
|
|
1960
|
+
| Dạng | Ví dụ | Cách dùng |
|
|
1961
|
+
|------|-------|-----------|
|
|
1962
|
+
| **Relative signed URL** | `/api/files/abc/photo.jpg?expires=...&sig=...` | Phải prepend `baseUrl` trước khi dùng |
|
|
1963
|
+
| **Absolute external URL** | `https://cdn.example.com/photo.jpg` | Dùng trực tiếp |
|
|
1964
|
+
|
|
1965
|
+
> **Quy tắc resolve:** Kiểm tra `url` trước, `storageKey` sau. Nếu `url` bắt đầu bằng `/`, đây là relative signed URL — cần ghép `baseUrl`. Tuyệt đối **không** bỏ qua `url` để dùng `storageKey` vì signed URL có thể bắt buộc (server trả `400 Invalid Signature` nếu gọi bằng unsigned URL).
|
|
1966
|
+
|
|
1967
|
+
```ts
|
|
1968
|
+
// Helper chuẩn để resolve MediaReference → URL tuyệt đối
|
|
1969
|
+
function resolveMediaRef(
|
|
1970
|
+
ref: MediaReference | null | undefined,
|
|
1971
|
+
baseUrl: string,
|
|
1972
|
+
getDownloadUrl: (key: string) => string,
|
|
1973
|
+
): string | null {
|
|
1974
|
+
if (!ref) return null;
|
|
1975
|
+
if (ref.url) {
|
|
1976
|
+
// Relative signed URL → prepend baseUrl
|
|
1977
|
+
return ref.url.startsWith('/') ? baseUrl.replace(/\/$/, '') + ref.url : ref.url;
|
|
1978
|
+
}
|
|
1979
|
+
if (ref.storageKey) return getDownloadUrl(ref.storageKey);
|
|
1980
|
+
return null;
|
|
1735
1981
|
}
|
|
1736
1982
|
```
|
|
1737
1983
|
|
|
@@ -1745,7 +1991,7 @@ interface ChatMessageDto {
|
|
|
1745
1991
|
conversationId: string;
|
|
1746
1992
|
senderId: string;
|
|
1747
1993
|
senderName: string; // Có ở SignalR, không có ở REST
|
|
1748
|
-
senderAvatar?: MediaReference; // Có ở SignalR, không có ở REST
|
|
1994
|
+
senderAvatar?: MediaReference | null; // Có ở SignalR, không có ở REST. Có thể null khi server chưa sẵn signed URL
|
|
1749
1995
|
senderType: SenderType; // Có ở cả REST và SignalR
|
|
1750
1996
|
blocks: Block[];
|
|
1751
1997
|
plainTextContent: string | null; // Tên khác với REST (plainTextIndex)
|
|
@@ -1778,13 +2024,15 @@ type SystemEventType =
|
|
|
1778
2024
|
| 'MemberAdded' // Thêm thành viên
|
|
1779
2025
|
| 'MemberRemoved' // Xóa thành viên
|
|
1780
2026
|
| 'MemberLeft' // Thành viên tự rời
|
|
1781
|
-
| 'Custom';
|
|
2027
|
+
| 'Custom'; // Sự kiện tùy chỉnh do server định nghĩa
|
|
1782
2028
|
|
|
1783
2029
|
interface SystemEventInfo {
|
|
1784
2030
|
type: SystemEventType;
|
|
1785
|
-
actorId
|
|
2031
|
+
actorId?: string | null; // null/undefined khi không có actor (vd. MemberLeft tự rời)
|
|
1786
2032
|
targetIds?: string[];
|
|
1787
|
-
|
|
2033
|
+
// Tất cả giá trị đều là string (vd. ConversationRenamed → { newName: "Team Beta" }).
|
|
2034
|
+
// Server có thể trả null cho event không kèm metadata (vd. MemberAdded thuần).
|
|
2035
|
+
metadata?: Record<string, string> | null;
|
|
1788
2036
|
}
|
|
1789
2037
|
|
|
1790
2038
|
// SignalR (ChatHub) version — kế thừa SystemEventInfo, thêm display names
|
|
@@ -1800,7 +2048,8 @@ interface SystemEventDto extends SystemEventInfo {
|
|
|
1800
2048
|
interface HubErrorDto {
|
|
1801
2049
|
code: string;
|
|
1802
2050
|
message: string;
|
|
1803
|
-
|
|
2051
|
+
// Server có thể gửi `null` rõ ràng, hoặc bỏ field hoàn toàn
|
|
2052
|
+
details?: Record<string, unknown> | null;
|
|
1804
2053
|
}
|
|
1805
2054
|
```
|
|
1806
2055
|
|
|
@@ -1813,9 +2062,10 @@ interface StreamStartedDto {
|
|
|
1813
2062
|
conversationId: string;
|
|
1814
2063
|
senderId: string;
|
|
1815
2064
|
senderName: string;
|
|
1816
|
-
senderAvatar?: MediaReference;
|
|
1817
|
-
replyToMessageId?: string;
|
|
2065
|
+
senderAvatar?: MediaReference | null; // Có thể null khi server chưa sẵn signed URL
|
|
2066
|
+
replyToMessageId?: string | null;
|
|
1818
2067
|
startedAt: string; // ISO 8601
|
|
2068
|
+
clientMessageId?: string | null; // ID tạm của client (echo). null nếu bot không truyền
|
|
1819
2069
|
}
|
|
1820
2070
|
|
|
1821
2071
|
interface StreamStatusUpdatedDto {
|
|
@@ -1841,6 +2091,7 @@ interface StreamCompletedDto {
|
|
|
1841
2091
|
conversationId: string;
|
|
1842
2092
|
message: ChatMessageDto; // Tin nhắn hoàn chỉnh (SignalR, không phải REST MessageDto)
|
|
1843
2093
|
completedAt: string; // ISO 8601
|
|
2094
|
+
finishReason?: string | null; // Lý do kết thúc stream. null nếu bot không truyền
|
|
1844
2095
|
}
|
|
1845
2096
|
|
|
1846
2097
|
interface StreamAbortedDto {
|
|
@@ -1920,7 +2171,17 @@ try {
|
|
|
1920
2171
|
if (err instanceof ChatApiError) {
|
|
1921
2172
|
console.error(`HTTP ${err.status}: ${err.title}`);
|
|
1922
2173
|
console.error('Chi tiết:', err.detail);
|
|
1923
|
-
|
|
2174
|
+
|
|
2175
|
+
// Xem tất cả lỗi validation chi tiết
|
|
2176
|
+
if (err.errors) {
|
|
2177
|
+
err.errors.forEach(e => console.error(`[${e.code}] ${e.description}`));
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
// Dùng traceId để correlate log
|
|
2181
|
+
if (err.traceId) {
|
|
2182
|
+
console.error('TraceId:', err.traceId);
|
|
2183
|
+
}
|
|
2184
|
+
|
|
1924
2185
|
switch (err.status) {
|
|
1925
2186
|
case 401: // Unauthorized — token hết hạn/không hợp lệ
|
|
1926
2187
|
await refreshAndRetry();
|
|
@@ -1943,12 +2204,17 @@ try {
|
|
|
1943
2204
|
| Thuộc tính | Kiểu | Mô tả |
|
|
1944
2205
|
|------------|------|-------|
|
|
1945
2206
|
| `status` | `number` | HTTP status code |
|
|
1946
|
-
| `title` | `string` | Tiêu đề lỗi
|
|
1947
|
-
| `detail` | `string \| undefined` |
|
|
2207
|
+
| `title` | `string` | Tiêu đề lỗi — giá trị `ErrorType` enum: `Validation`, `NotFound`, `Conflict`, `Forbidden`, `Unauthorized`, `Unexpected`. Auth endpoints trả `Bad Request`, v.v. |
|
|
2208
|
+
| `detail` | `string \| undefined` | Mô tả của lỗi đầu tiên (từ RFC 7807 `detail`) |
|
|
2209
|
+
| `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) |
|
|
2210
|
+
| `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ộ |
|
|
2211
|
+
| `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ộ |
|
|
1948
2212
|
| `message` | `string` | `detail ?? title` (kế thừa từ `Error`) |
|
|
1949
2213
|
| `name` | `string` | Luôn là `"ChatApiError"` |
|
|
1950
2214
|
|
|
1951
|
-
Server trả lỗi dạng RFC 7807: `{ status, title, detail }`. Endpoint proxy trả `{ detail }` đơn giản hơn — SDK xử lý cả hai.
|
|
2215
|
+
Server trả lỗi dạng RFC 7807: `{ type?, status, title, detail?, errors?, traceId? }`. Endpoint proxy trả `{ detail }` đơn giản hơn — SDK xử lý cả hai.
|
|
2216
|
+
|
|
2217
|
+
> **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`.
|
|
1952
2218
|
|
|
1953
2219
|
### Static methods
|
|
1954
2220
|
|
|
@@ -1979,7 +2245,16 @@ interface ProblemDetails {
|
|
|
1979
2245
|
status: number;
|
|
1980
2246
|
title: string;
|
|
1981
2247
|
detail?: string;
|
|
1982
|
-
type?: string;
|
|
2248
|
+
type?: string; // URI tham chiếu (RFC 7807) — `ChatApiError.fromProblemDetails` sẽ forward sang `error.type`
|
|
2249
|
+
errors?: ProblemError[]; // Có khi lỗi đi qua handler; vắng mặt cho lỗi middleware/filter
|
|
2250
|
+
traceId?: string; // W3C trace ID — tự động thêm bởi framework
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
// Một phần tử trong mảng `errors`
|
|
2254
|
+
interface ProblemError {
|
|
2255
|
+
code: string; // Mã định danh lỗi, ví dụ: "Bot.NotFound", "UniqueName"
|
|
2256
|
+
description: string; // Mô tả chi tiết lỗi
|
|
2257
|
+
type: string; // ErrorType: "Validation", "NotFound", "Conflict", "Forbidden", "Unauthorized", "Unexpected"
|
|
1983
2258
|
}
|
|
1984
2259
|
```
|
|
1985
2260
|
|
|
@@ -2077,33 +2352,63 @@ const cursor = Number(result.nextCursor); // BUG!
|
|
|
2077
2352
|
|
|
2078
2353
|
- `setToken()` hoạt động ngay cho **HTTP requests**.
|
|
2079
2354
|
- SignalR đọc token khi **connect()**, không phải mid-session.
|
|
2080
|
-
- Để
|
|
2355
|
+
- Để áp token mới ngay cho hub đang Connected: `setToken(newToken)` → `await refreshConnections()`.
|
|
2356
|
+
- Sau `refreshConnections()` cần tự `joinConversation()` / `subscribeToPresence()` lại (tracking bị clear khi disconnect). Hoặc dùng `ReconnectionManager` để tự động khôi phục.
|
|
2357
|
+
- 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.
|
|
2081
2358
|
|
|
2082
2359
|
### REST vs SignalR — field name khác nhau
|
|
2083
2360
|
|
|
2084
2361
|
| Thao tác | REST | SignalR |
|
|
2085
2362
|
|----------|------|---------|
|
|
2086
|
-
| Edit message | `{ blocks, mentions }` | `{ messageId, newBlocks, newMentions }` |
|
|
2363
|
+
| Edit message | `{ blocks, mentions, replyToMessageId? }` | `{ messageId, newBlocks, newMentions, newReplyToMessageId? }` |
|
|
2087
2364
|
| Delete message | `messages.delete(messageId)` | `deleteMessage({ messageId })` — object, không phải string |
|
|
2088
2365
|
| Stream completion | Không có event | `streamCompleted` thay `messageReceived` |
|
|
2089
2366
|
|
|
2090
2367
|
### File upload — storageKey vs URL
|
|
2091
2368
|
|
|
2369
|
+
**Khi gửi tin nhắn (client → server):** dùng `storageKey` trong block, không dùng `url`.
|
|
2370
|
+
|
|
2092
2371
|
```ts
|
|
2093
|
-
// Sau khi upload, dùng storageKey làm MediaReference
|
|
2094
2372
|
const { storageKey } = await client.files.uploadFile(file);
|
|
2095
2373
|
|
|
2096
|
-
// Trong block:
|
|
2097
2374
|
const block: ImageBlock = {
|
|
2098
2375
|
$type: 'image',
|
|
2099
|
-
source: { storageKey }, // KHÔNG
|
|
2376
|
+
source: { storageKey }, // ĐÚNG — KHÔNG đặt url ở đây
|
|
2100
2377
|
// ...
|
|
2101
2378
|
};
|
|
2379
|
+
```
|
|
2380
|
+
|
|
2381
|
+
**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:
|
|
2382
|
+
|
|
2383
|
+
```ts
|
|
2384
|
+
// resolve MediaReference → URL tuyệt đối để dùng trong <img src>, <a href>, v.v.
|
|
2385
|
+
function resolveMediaRef(
|
|
2386
|
+
ref: MediaReference | null | undefined,
|
|
2387
|
+
baseUrl: string,
|
|
2388
|
+
getDownloadUrl: (key: string) => string,
|
|
2389
|
+
): string | null {
|
|
2390
|
+
if (!ref) return null;
|
|
2391
|
+
if (ref.url) {
|
|
2392
|
+
// url có thể là relative signed URL ("/api/files/...?sig=...")
|
|
2393
|
+
// hoặc absolute external URL ("https://...")
|
|
2394
|
+
return ref.url.startsWith('/') ? baseUrl.replace(/\/$/, '') + ref.url : ref.url;
|
|
2395
|
+
}
|
|
2396
|
+
if (ref.storageKey) return getDownloadUrl(ref.storageKey);
|
|
2397
|
+
return null;
|
|
2398
|
+
}
|
|
2102
2399
|
|
|
2103
|
-
//
|
|
2104
|
-
const displayUrl =
|
|
2400
|
+
// Ví dụ dùng với ImageBlock:
|
|
2401
|
+
const displayUrl = resolveMediaRef(
|
|
2402
|
+
block.source,
|
|
2403
|
+
'https://chat-api.example.com',
|
|
2404
|
+
client.files.getDownloadUrl.bind(client.files),
|
|
2405
|
+
);
|
|
2105
2406
|
```
|
|
2106
2407
|
|
|
2408
|
+
> **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.
|
|
2409
|
+
|
|
2410
|
+
> **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.
|
|
2411
|
+
|
|
2107
2412
|
### BotDto — không có tên/avatar
|
|
2108
2413
|
|
|
2109
2414
|
`BotDto` **không** chứa `uniqueName` hay `fullName`. Phải gọi thêm:
|