npm package: @manonero/chat-client-sdk
Description: A TypeScript SDK for building chat clients using Microsoft SignalR.
Author: Manonero
Latest version: 1.0.0-beta.3

# ChatClientSdk — Tài liệu hướng dẫn sử dụng

> 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.

---

## Mục lục

1. [Tổng quan kiến trúc](#1-tổng-quan-kiến-trúc)
2. [Cài đặt và khởi tạo](#2-cài-đặt-và-khởi-tạo)
3. [ChatClient — Facade chính](#3-chatclient--facade-chính)
4. [Xác thực — AuthApi](#4-xác-thực--authapi)
5. [Participant — ParticipantApi](#5-participant--participantapi)
6. [Conversation — ConversationApi](#6-conversation--conversationapi)
7. [Message — MessageApi](#7-message--messageapi)
8. [File — FileApi](#8-file--fileapi)
9. [Bot — BotApi](#9-bot--botapi)
10. [Proxy — ProxyApi](#10-proxy--proxyapi)
11. [Health — HealthApi](#11-health--healthapi)
12. [Real-time: ChatHubClient](#12-real-time-chathubclient)
13. [Real-time: NotificationHubClient](#13-real-time-notificationhubclient)
14. [ReconnectionManager](#14-reconnectionmanager)
15. [Hệ thống Block (nội dung tin nhắn)](#15-hệ-thống-block-nội-dung-tin-nhắn)
16. [Toàn bộ kiểu dữ liệu (Types)](#16-toàn-bộ-kiểu-dữ-liệu-types)
17. [Xử lý lỗi — ChatApiError](#17-xử-lý-lỗi--chatapierror)
18. [TypedEventEmitter](#18-typedeventemitter)
19. [Lưu ý quan trọng](#19-lưu-ý-quan-trọng)
20. [Ví dụ đầy đủ](#20-ví-dụ-đầy-đủ)

---

## 1. Tổng quan kiến trúc

```
ChatClient (facade chính)
├── auth          → AuthApi          (xác thực)
├── participants  → ParticipantApi   (quản lý người dùng)
├── conversations → ConversationApi  (quản lý cuộc hội thoại)
├── messages      → MessageApi       (tin nhắn)
├── files         → FileApi          (upload/download file)
├── bots          → BotApi           (quản lý bot)
├── proxy         → ProxyApi         (proxy đến backend khác)
├── health        → HealthApi        (kiểm tra sức khỏe server)
└── realtime
    ├── chat          → ChatHubClient          (sự kiện tin nhắn real-time)
    └── notifications → NotificationHubClient  (thông báo real-time)
```

**Luồng hoạt động cơ bản:**

1. Khởi tạo `ChatClient` với `baseUrl`.
2. Đăng nhập qua `client.auth.loginWith*()` — token được tự động lưu và truyền vào mọi request sau.
3. Kết nối các hub SignalR qua `client.realtime.notifications.connect()` và `client.realtime.chat.connect()`.
4. Lắng nghe sự kiện real-time bằng `.on(eventName, handler)`.
5. Gọi các phương thức REST hoặc Hub để thực hiện thao tác.

---

## 2. Cài đặt và khởi tạo

### Installation

```bash
npm install @manonero/chat-client-sdk
npm install @microsoft/signalr   # peer dependency
```

### Import

```ts
import { ChatClient } from '@manonero/chat-client-sdk';
```

Tất cả các kiểu dữ liệu đều được export từ package root:

```ts
import type { MessageDto, ConversationDto, Block } from '@manonero/chat-client-sdk';
import { ChatApiError } from '@manonero/chat-client-sdk'; // class — dùng import (không phải import type) để hỗ trợ instanceof
```

**Export nâng cao** — SDK cũng export một số class và type nội bộ cho power users:

```ts
// TypedEventEmitter — dùng độc lập hoặc extend
import { TypedEventEmitter } from '@manonero/chat-client-sdk';
import type { Unsubscribe } from '@manonero/chat-client-sdk';

// HttpClient — tự xây dựng API module bổ sung theo cùng pattern
import { HttpClient } from '@manonero/chat-client-sdk';
import type { HttpClientOptions } from '@manonero/chat-client-sdk';

// Event map types — dùng khi cần typed wrapper
import type { ChatHubEventMap, NotificationHubEventMap } from '@manonero/chat-client-sdk';

// Hub options (khi dùng hub client độc lập, không qua ChatClient)
import type { ChatHubClientOptions, NotificationHubClientOptions } from '@manonero/chat-client-sdk';

// Hub request types (Client → Server) — dùng khi gọi SignalR methods
import type {
  ChatSendMessageRequest,
  ChatEditMessageRequest,
  ChatDeleteMessageRequest,
  ChatRecoverMessageRequest,
  ChatAddReactionRequest,
  ChatRemoveReactionRequest,
} from '@manonero/chat-client-sdk';

// Ack types (Server → Client return) — dùng khi cần type return value của Hub methods
import type {
  SendMessageAck,
  EditMessageAck,
  DeleteMessageAck,
  RecoverMessageAck,
  ReactionAck,
  MarkAsReadAck,
  HubErrorDto,
} from '@manonero/chat-client-sdk';

// ReconnectionManager options
import type { ReconnectionManagerOptions } from '@manonero/chat-client-sdk';

// ChatClient facade types
import type { ChatClientSignalrOptions, ChatClientRealtime } from '@manonero/chat-client-sdk';

// Upload progress callback type — dùng khi cần type hàm onProgress
import type { UploadProgressCallback } from '@manonero/chat-client-sdk';

// RFC 7807 Problem Details — dùng khi cần parse/tạo lỗi thủ công
import type { ProblemDetails } from '@manonero/chat-client-sdk';

// Mark as read request type (REST)
import type { MarkAsReadRequest } from '@manonero/chat-client-sdk';

// Login request types riêng lẻ (khi cần type-check từng provider)
import type {
  GoogleLoginRequest,
  DsAccountLoginRequest,
  DevLoginRequest,
  LoginRequest,
} from '@manonero/chat-client-sdk';

// Standalone hub clients — dùng khi không cần ChatClient facade
import { ChatHubClient, NotificationHubClient, ReconnectionManager } from '@manonero/chat-client-sdk';
```

### Khởi tạo cơ bản

```ts
const client = new ChatClient({
  baseUrl: 'https://chat-api.example.com',
});
```

### Khởi tạo với token có sẵn (đã đăng nhập từ trước)

```ts
const client = new ChatClient({
  baseUrl: 'https://chat-api.example.com',
  token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
});
```

### Khởi tạo với external token provider

Dùng khi token được quản lý bên ngoài SDK (ví dụ: Redux store):

```ts
const client = new ChatClient({
  baseUrl: 'https://chat-api.example.com',
  tokenProvider: () => store.getState().auth.token,
});
```

> **Ưu tiên token:** `token` (hoặc `setToken()`) luôn ưu tiên hơn `tokenProvider`. Khi `_token` khác `null`, `tokenProvider` bị bỏ qua.

### Cấu hình SignalR log level

```ts
import { LogLevel } from '@microsoft/signalr';

const client = new ChatClient({
  baseUrl: 'https://chat-api.example.com',
  signalrOptions: {
    logLevel: LogLevel.Debug, // Mặc định: LogLevel.Warning
  },
});
```

---

## 3. ChatClient — Facade chính

**Class:** `ChatClient`

Đâ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.

### Constructor

```ts
new ChatClient(options: ChatClientOptions)
```

| Option | Kiểu | Bắt buộc | Mô tả |
|--------|------|----------|-------|
| `baseUrl` | `string` | ✅ | URL gốc của server, ví dụ `"https://chat-api.example.com"` |
| `token` | `string` | ❌ | JWT token ban đầu (nếu đã đăng nhập) |
| `tokenProvider` | `() => string \| null` | ❌ | Hàm lấy token từ bên ngoài |
| `signalrOptions.logLevel` | `LogLevel` | ❌ | Mức log cho SignalR (mặc định: `Warning`) |

### Thuộc tính

| Thuộc tính | Kiểu | Mô tả |
|------------|------|-------|
| `auth` | `AuthApi` | Các endpoint xác thực |
| `participants` | `ParticipantApi` | Quản lý participant |
| `conversations` | `ConversationApi` | Quản lý cuộc hội thoại |
| `messages` | `MessageApi` | Thao tác với tin nhắn |
| `files` | `FileApi` | Upload/download file |
| `bots` | `BotApi` | Quản lý bot |
| `proxy` | `ProxyApi` | Proxy đến backend khác |
| `health` | `HealthApi` | Kiểm tra sức khỏe server |
| `realtime.chat` | `ChatHubClient` | SignalR ChatHub |
| `realtime.notifications` | `NotificationHubClient` | SignalR NotificationHub |
| `currentUser` | `AuthUserInfo \| null` | User hiện tại sau khi đăng nhập |

### Phương thức

#### `setToken(token: string): void`

Cập nhật JWT token. Có hiệu lực ngay với mọi HTTP request tiếp theo.

> ⚠️ Các kết nối SignalR đang hoạt động **không** được cập nhật token mid-session. Cần `disconnect()` rồi `connect()` lại để dùng token mới.

```ts
client.setToken('new-jwt-token-here');
```

#### `disconnect(): Promise<void>`

Ngắt kết nối tất cả các SignalR hub (ChatHub + NotificationHub) đồng thời.

```ts
await client.disconnect();
```

---

## 4. Xác thực — AuthApi

**Truy cập:** `client.auth`

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`.

### `loginWithGoogle(idToken: string): Promise<AuthResponse>`

Đăng nhập bằng Google ID Token.

```ts
const { token, user } = await client.auth.loginWithGoogle(googleIdToken);
console.log(client.currentUser); // AuthUserInfo đã được set tự động
```

### `loginWithDsAccount(token: string): Promise<AuthResponse>`

Đăng nhập bằng DS Account token.

```ts
const auth = await client.auth.loginWithDsAccount(dsToken);
```

### `loginWithDev(username: string, password: string): Promise<AuthResponse>`

Đă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.

```ts
const auth = await client.auth.loginWithDev('devuser', 'devpass');
```

### `login(provider: string, body: LoginRequest): Promise<AuthResponse>`

Đăng nhập với provider tùy chỉnh (khi provider được xác định lúc runtime).

```ts
const auth = await client.auth.login('custom-provider', { token: '...' });
```

### `AuthResponse`

```ts
interface AuthResponse {
  token: string;    // JWT token
  expiresAt: string; // ISO 8601 — thời điểm hết hạn
  user: AuthUserInfo;
}

interface AuthUserInfo {
  id: string;
  uniqueName: string;
  fullName: string;
  avatar?: string;        // URL thuần (KHÔNG phải MediaReference)
  role?: string | null;  // 'dev' với dev users; null với Google/DsAccount users
}
```

---

## 5. Participant — ParticipantApi

**Truy cập:** `client.participants`

Tất cả endpoint đều **yêu cầu JWT**. Server cache GET trong 5 phút.

### `createOrUpdate(request): Promise<ParticipantDto>`

Tạo mới hoặc cập nhật participant (upsert theo ID).

```ts
const participant = await client.participants.createOrUpdate({
  id: 'user-123',
  uniqueName: 'john_doe',
  fullName: 'John Doe',
  avatar: { url: 'https://example.com/avatar.jpg' },
  gender: 'Male',
});
```

### `getById(id: string): Promise<ParticipantDto>`

```ts
const participant = await client.participants.getById('user-123');
```

### `getByUniqueName(uniqueName: string): Promise<ParticipantDto>`

```ts
const participant = await client.participants.getByUniqueName('john_doe');
```

### `getBatch(ids: string[]): Promise<ParticipantDto[]>`

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).

```ts
const participants = await client.participants.getBatch(['id-1', 'id-2', 'id-3']);
```

### `ParticipantDto`

```ts
interface ParticipantDto {
  id: string;
  uniqueName: string;
  fullName: string;
  avatar?: MediaReference;
  gender?: string;
  isBot: boolean;
  isOnline: boolean;
  lastSeenAt?: string | null;  // ISO 8601, null khi đang online
  createdAt: string;           // ISO 8601
  updatedAt: string;           // ISO 8601
}
```

---

## 6. Conversation — ConversationApi

**Truy cập:** `client.conversations`

Tất cả endpoint đều **yêu cầu JWT**.

### `list(params?): Promise<CursorPaginatedResult<ConversationListItemDto>>`

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.

```ts
// Trang đầu
const result = await client.conversations.list({ limit: 20 });
console.log(result.items);      // ConversationListItemDto[]
console.log(result.hasMore);    // boolean
console.log(result.nextCursor); // string | null

// Trang tiếp theo
if (result.hasMore) {
  const nextPage = await client.conversations.list({
    limit: 20,
    cursor: result.nextCursor!, // Đây là string — KHÔNG cast sang number
  });
}
```

> ⚠️ `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**.

### `create(request): Promise<ConversationDto>`

```ts
// Tạo cuộc hội thoại nhóm
const conv = await client.conversations.create({
  type: 'Group',
  name: 'Team Chat',
  avatar: { storageKey: '01HXABCDEF/group-avatar.jpg' }, // optional
  participantIds: ['user-1', 'user-2', 'user-3'],
});

// Tạo cuộc hội thoại 1-1
const dm = await client.conversations.create({
  type: 'OneToOne',
  participantIds: ['other-user-id'],
});

// Tạo cuộc hội thoại với chính mình (Saved Messages)
const self = await client.conversations.create({
  type: 'Self',
  participantIds: [],
});
```

### `search(query, limit?): Promise<ConversationListItemDto[]>`

Tìm kiếm theo tên cuộc hội thoại hoặc tên participant.

```ts
const results = await client.conversations.search('team', 10);
```

### `getById(id: string): Promise<ConversationDto>`

```ts
const conv = await client.conversations.getById('conv-id-here');
```

### `update(id, request): Promise<ConversationDto>`

Chỉ áp dụng cho cuộc hội thoại **Group**.

```ts
const updated = await client.conversations.update('conv-id', {
  name: 'New Group Name',
  avatar: { url: 'https://example.com/group-avatar.jpg' },
});
```

### `delete(id: string): Promise<void>`

Hành vi phụ thuộc vào loại cuộc hội thoại:
- **Group** → rời nhóm (tương đương `leaveGroup()`). Các participant khác vẫn còn đó.
- **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.

```ts
await client.conversations.delete('conv-id');
```

### `leaveGroup(id: string): Promise<void>`

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.

> 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.

```ts
await client.conversations.leaveGroup('conv-id');
```

### `findOneToOne(otherParticipantId): Promise<ConversationDto | null>`

Tìm cuộc hội thoại 1-1 với user khác. Trả về `null` (HTTP 204) nếu chưa có.

```ts
const existing = await client.conversations.findOneToOne('other-user-id');
if (!existing) {
  // Tạo mới
}
```

### `addParticipant(conversationId, request): Promise<void>`

```ts
await client.conversations.addParticipant('conv-id', {
  participantId: 'new-user-id',
  canViewHistory: true, // Mặc định true — xem được tin nhắn cũ
});
```

### `removeParticipant(conversationId, participantId): Promise<void>`

```ts
await client.conversations.removeParticipant('conv-id', 'user-to-remove-id');
```

### `getParticipants(conversationId): Promise<ConversationParticipantDto[]>`

Trả về tất cả participant kể cả người đã rời nhóm.

```ts
const members = await client.conversations.getParticipants('conv-id');
const active = members.filter(m => m.isActive);
```

### `pin(id) / unpin(id): Promise<void>`

```ts
await client.conversations.pin('conv-id');
await client.conversations.unpin('conv-id');
```

### `markAsRead(id, messageId) / markAsUnread(id): Promise<void>`

```ts
// Đánh dấu đã đọc đến messageId
await client.conversations.markAsRead('conv-id', 'last-message-id');

// Đánh dấu chưa đọc
await client.conversations.markAsUnread('conv-id');
```

### Kiểu dữ liệu Conversation

```ts
type ConversationType = 'Group' | 'OneToOne' | 'Self';

interface ConversationDto {
  id: string;
  type: ConversationType;
  name?: string;                  // Tên nhóm (chỉ Group)
  avatar?: MediaReference;
  ownerId: string;
  lastMessageAt?: string | null;  // ISO 8601, null khi chưa có tin nhắn
  lastMessageId?: string | null;  // null khi chưa có tin nhắn
  createdAt: string;              // ISO 8601
  updatedAt: string;              // ISO 8601
  participants: ConversationParticipantDto[];
}

interface ConversationParticipantDto {
  participantId: string;
  joinedAt: string;          // ISO 8601
  leftAt?: string | null;    // ISO 8601, null nếu vẫn trong nhóm
  isActive: boolean;
  isPinned: boolean;
  pinnedAt?: string | null;  // ISO 8601
  lastReadMessageId?: string | null;
  lastReadAt?: string | null; // ISO 8601
  unreadCount: number;
}

interface ConversationListItemDto {
  id: string;
  type: ConversationType;
  name?: string;
  avatar?: MediaReference;
  lastMessageAt?: string | null;   // ISO 8601
  lastMessageId?: string | null;
  lastMessage?: MessageDto | null; // Preview tin nhắn cuối, null khi chưa có tin nhắn
  isPinned: boolean;
  pinnedAt?: string | null;        // ISO 8601, null khi chưa pin
  unreadCount: number;
  participantCount: number;
}
```

---

## 7. Message — MessageApi

**Truy cập:** `client.messages`

Tất cả endpoint đều **yêu cầu JWT**.

### `send(conversationId, request): Promise<MessageDto>`

Gửi tin nhắn qua REST API. Trả về 201 Created với `MessageDto` đầy đủ.

```ts
const msg = await client.messages.send('conv-id', {
  blocks: [
    { $type: 'text', format: 'Plain', content: 'Xin chào!', plainText: 'Xin chào!' }
  ],
  replyToMessageId: 'msg-id-to-reply', // optional
  clientMessageId: 'local-uuid-1234',  // optional, dùng cho optimistic rendering
});
```

> 💡 **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.

### `getHistory(conversationId, params?): Promise<CursorPaginatedResult<MessageDto>>`

Lấy lịch sử tin nhắn với cursor-based pagination.

```ts
// Lấy 30 tin nhắn mới nhất
const result = await client.messages.getHistory('conv-id', { limit: 30 });

// Load thêm tin nhắn cũ hơn (scroll lên)
const older = await client.messages.getHistory('conv-id', {
  limit: 30,
  cursor: result.nextCursor!,
  direction: 'older', // mặc định
});

// Load tin nhắn mới hơn cursor (scroll xuống)
const newer = await client.messages.getHistory('conv-id', {
  cursor: someCursor,
  direction: 'newer',
});
```

### `search(conversationId, query, limit?): Promise<MessageDto[]>`

Full-text search trong cuộc hội thoại.

```ts
const results = await client.messages.search('conv-id', 'từ khóa tìm kiếm', 20);
```

### `getById(messageId): Promise<MessageDto>`

```ts
const msg = await client.messages.getById('msg-id');
```

### `edit(messageId, request): Promise<MessageDto>`

Chỉ người gửi mới có thể chỉnh sửa.

```ts
const edited = await client.messages.edit('msg-id', {
  blocks: [{ $type: 'text', format: 'Plain', content: 'Nội dung đã sửa', plainText: 'Nội dung đã sửa' }],
  mentions: [], // optional
});
```

> ⚠️ **Khác biệt REST vs SignalR:** REST dùng `{ blocks, mentions }`, SignalR dùng `{ messageId, newBlocks, newMentions }`.

### `delete(messageId): Promise<{ messageId, deletedAt }>`

Xóa mềm (soft delete). Trả về object (không phải 204).

```ts
const { messageId, deletedAt } = await client.messages.delete('msg-id');
```

### `recover(messageId): Promise<void>`

Khôi phục tin nhắn đã xóa. Chỉ khả dụng trong vòng **10 phút** sau khi xóa.

```ts
await client.messages.recover('msg-id');
```

### `addReaction(messageId, emoji) / removeReaction(messageId, emoji): Promise<void>`

```ts
await client.messages.addReaction('msg-id', '👍');
await client.messages.removeReaction('msg-id', '👍');
```

> Server trả về 409 Conflict nếu đã react với emoji đó. Emoji được tự động URL-encode.

### Kiểu dữ liệu Message

```ts
type SenderType = 'User' | 'Bot' | 'System';

interface MessageDto {
  id: string;               // ULID do server tạo
  conversationId: string;
  senderId: string;
  senderType: SenderType;
  timestamp: string;        // ISO 8601 — thời điểm gửi
  blocks: Block[];          // Nội dung tin nhắn
  plainTextIndex: string | null;
  replyToMessageId: string | null;
  quickReplies: QuickReply[] | null;
  isEdited: boolean;
  editedAt: string | null;  // ISO 8601
  isDeleted: boolean;
  deletedAt: string | null; // ISO 8601
  reactionSummary: ReactionSummary | null;
  mentions: Mention[] | null;
  createdAt: string;        // ISO 8601
  updatedAt: string;        // ISO 8601
  clientMessageId: string | null;
  systemEvent: SystemEventInfo | null;
}
```

### Kiểu dữ liệu bổ sung

```ts
// Reaction summary (REST API)
interface ReactionSummary {
  groups: ReactionGroup[];
  totalCount: number;
}

interface ReactionGroup {
  emoji: string;
  count: number;
  participantIds: string[]; // Danh sách participant đã react
}

// Request để gửi tin nhắn mới
interface SendMessageRequest {
  blocks: Block[];
  replyToMessageId?: string;
  quickReplies?: QuickReply[];  // Nút bấm nhanh đính kèm tin nhắn
  mentions?: Mention[];
  clientMessageId?: string;     // Tối đa 128 ký tự
}

// Request để chỉnh sửa tin nhắn qua REST
interface EditMessageRequest {
  blocks: Block[];
  mentions?: Mention[];
}

// Request đánh dấu đã đọc (REST)
interface MarkAsReadRequest {
  messageId: string;
}
```

---

## 8. File — FileApi

**Truy cập:** `client.files`

Upload yêu cầu JWT. Download là **public** (không cần JWT).

### `uploadFile(file, onProgress?): Promise<UploadResponse>`

Phương thức tiện lợi — gộp `createUploadUrl` + `upload` thành một bước.

```ts
const fileInput = document.getElementById('file') as HTMLInputElement;
const file = fileInput.files![0];

const result = await client.files.uploadFile(file, (loaded, total) => {
  const percent = Math.round((loaded / total) * 100);
  console.log(`Upload: ${percent}%`);
});

console.log(result.storageKey);   // Dùng làm MediaReference.storageKey
console.log(result.downloadUrl);  // Relative URL
```

### `createUploadUrl(request): Promise<CreateUploadUrlResponse>`

Bước 1 của 2 bước upload thủ công — xin URL upload.

```ts
const { uploadUrl, storageKey, expiresAt } = await client.files.createUploadUrl({
  fileName: 'photo.jpg',
  contentType: 'image/jpeg',
  fileSize: file.size, // bytes, tối đa 50MB
});
```

### `upload(uploadUrl, file, onProgress?): Promise<UploadResponse>`

Bước 2 — upload file vào URL đã xin.

```ts
const result = await client.files.upload(uploadUrl, file, (loaded, total) => {
  console.log(`${loaded}/${total}`);
});
```

> Khi có `onProgress`, SDK dùng XHR thay vì fetch (vì fetch không hỗ trợ upload progress events).

### `getDownloadUrl(storageKey: string): string`

Tạo URL tải file tuyệt đối (dùng để hiển thị ảnh, tải xuống, v.v.).

```ts
const url = client.files.getDownloadUrl('01HXABCDEF/photo.jpg');
// → "https://chat-api.example.com/api/files/01HXABCDEF/photo.jpg"
```

### `delete(storageKey: string): Promise<void>`

Xóa file. Chỉ người upload mới có thể xóa. `storageKey` có thể chứa `/`, không cần encode.

```ts
await client.files.delete('01HXABCDEF/photo.jpg');
```

### Kiểu dữ liệu File

```ts
interface CreateUploadUrlRequest {
  fileName: string;
  contentType: string;
  fileSize: number; // bytes
}

interface CreateUploadUrlResponse {
  uploadUrl: string;   // Relative path — HttpClient tự prepend baseUrl
  storageKey: string;
  expiresAt: string;   // ISO 8601
}

interface UploadResponse {
  downloadUrl: string; // Relative path
  storageKey: string;
  fileName: string;
  contentType: string;
  fileSize: number;
  uploadedAt: string;  // ISO 8601
}
```

---

## 9. Bot — BotApi

**Truy cập:** `client.bots`

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`.

### `list(params?): Promise<PagedResult<BotDto>>`

> Lưu ý: BotApi dùng **page-based** pagination (không phải cursor-based). `page` bắt đầu từ 1. Server cache kết quả **5 phút**.

```ts
const result = await client.bots.list({ page: 1, pageSize: 20 });
console.log(result.items);      // BotDto[]
console.log(result.totalCount); // Tổng số bot
```

### `getById(id): Promise<BotDto>`

> Server cache kết quả **5 phút**.

```ts
const bot = await client.bots.getById('bot-id');
// Lấy thêm thông tin display: name, avatar
const participant = await client.participants.getById(bot.participantId);
```

### `create(request): Promise<BotDto & { apiKey: string }>`

Tạo bot và nhận `apiKey` một lần — **lưu ngay**, server không lưu key này.

```ts
const bot = await client.bots.create({
  uniqueName: 'my-bot',
  fullName: 'My Assistant Bot',
  description: 'Trợ lý AI',
  kafkaTopic: 'my-bot-topic',     // Không thể thay đổi sau khi tạo
  rateLimitPerSecond: 5,
  listenAllGroupMessages: false,
});
const apiKey = bot.apiKey; // Lưu ngay!
```

### `update(id, request): Promise<BotDto>`

```ts
await client.bots.update('bot-id', {
  fullName: 'Updated Bot Name',
  rateLimitPerSecond: 10,
  // kafkaTopic KHÔNG thể cập nhật
});
```

### `activate(id) / deactivate(id): Promise<void>`

```ts
await client.bots.activate('bot-id');
await client.bots.deactivate('bot-id');
```

### `regenerateKey(id): Promise<RegenerateKeyResponse>`

Xoay API key — key cũ bị vô hiệu hóa ngay lập tức.

```ts
const { apiKey } = await client.bots.regenerateKey('bot-id');
```

### `delete(id): Promise<void>`

Soft delete.

```ts
await client.bots.delete('bot-id');
```

### Kiểu dữ liệu Bot

```ts
interface BotDto {
  id: string;
  participantId: string;      // Dùng để lấy name/avatar từ ParticipantApi
  description?: string;
  metadata?: Record<string, unknown>;
  kafkaTopic: string;         // KHÔNG thể thay đổi sau khi tạo
  isActive: boolean;
  rateLimitPerSecond: number;
  listenAllGroupMessages: boolean;
  createdBy: string;          // ID của người tạo bot
  createdAt: string;          // ISO 8601
  updatedAt: string;          // ISO 8601
}
```

> ⚠️ `BotDto` **không** chứa `uniqueName` hay `fullName`. Dùng `client.participants.getById(bot.participantId)` để lấy tên và avatar của bot.

### Kiểu dữ liệu Request (Bot)

```ts
interface CreateBotRequest {
  uniqueName: string;
  fullName: string;
  avatar?: MediaReference;           // optional — avatar của bot
  description?: string;
  metadata?: Record<string, unknown>;
  kafkaTopic: string;                // KHÔNG thể thay đổi sau khi tạo
  rateLimitPerSecond?: number;
  listenAllGroupMessages?: boolean;  // Mặc định false
}

interface UpdateBotRequest {
  uniqueName?: string;               // Có thể cập nhật sau khi tạo
  fullName?: string;
  avatar?: MediaReference;
  description?: string;
  metadata?: Record<string, unknown>;
  rateLimitPerSecond?: number;
  listenAllGroupMessages?: boolean;
  // kafkaTopic KHÔNG thể cập nhật
}
```

---

## 10. Proxy — ProxyApi

**Truy cập:** `client.proxy`

Chuyển tiếp request đến các backend service đã đăng ký qua gateway.

```ts
// GET /api/proxy/trading/api/stocks/VN30
const data = await client.proxy.get<StockData>('trading', 'api/stocks/VN30');

// POST /api/proxy/trading/api/orders
const order = await client.proxy.post<Order>('trading', 'api/orders', {
  symbol: 'VNM',
  quantity: 100,
});

// PUT /api/proxy/trading/api/orders/123
const updated = await client.proxy.put<Order>('trading', 'api/orders/123', {
  quantity: 200,
});

// PATCH /api/proxy/trading/api/orders/123
const patched = await client.proxy.patch<Order>('trading', 'api/orders/123', {
  status: 'cancelled',
});

// DELETE /api/proxy/trading/api/orders/123
await client.proxy.delete('trading', 'api/orders/123');

// Request tùy chỉnh method (hỗ trợ GET, POST, PUT, PATCH, DELETE)
const result = await client.proxy.request<unknown>('myservice', 'api/resource/123', {
  method: 'DELETE',
  headers: { 'X-Custom-Header': 'value' },
});
```

---

## 11. Health — HealthApi

**Truy cập:** `client.health`

Các endpoint **public** (không cần JWT). Dùng để kiểm tra trạng thái server.

```ts
// Liveness probe — kiểm tra process có chạy không
const status = await client.health.live(); // "Healthy"

// Readiness probe — kiểm tra tất cả dependencies
const detail = await client.health.ready();
console.log(detail.status);       // "Healthy" | "Degraded" | "Unhealthy"
console.log(detail.totalDuration); // ".NET TimeSpan string" như "00:00:00.0123456" — KHÔNG phải number
console.log(detail.results);      // Record<string, HealthCheckResult>
```

### Kiểu dữ liệu Health

```ts
interface HealthStatus {
  status: string;             // "Healthy" | "Degraded" | "Unhealthy"
  totalDuration: string;      // .NET TimeSpan — "00:00:00.1234567" (KHÔNG phải number)
  results: Record<string, HealthCheckResult>;
}

interface HealthCheckResult {
  status: string;             // "Healthy" | "Degraded" | "Unhealthy"
  description: string | null;
  duration: string;           // .NET TimeSpan — KHÔNG phải number
  error: string | null;
}
```

---

## 12. Real-time: ChatHubClient

**Truy cập:** `client.realtime.chat`

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.

### Kết nối

```ts
await client.realtime.chat.connect();
```

> Nếu đã connected, gọi `connect()` lần nữa sẽ không làm gì. SDK tự động reconnect (dùng SignalR `withAutomaticReconnect`).

### Ngắt kết nối

```ts
await client.realtime.chat.disconnect();
// Xóa toàn bộ joinedConversations
```

> `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`.

### `rejoinConversations(): Promise<void>`

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.

```ts
await client.realtime.chat.rejoinConversations();
```

### Trạng thái kết nối

```ts
import { HubConnectionState } from '@microsoft/signalr';

const state = client.realtime.chat.state;
// HubConnectionState.Connected | Disconnected | Connecting | Reconnecting
```

### Tham gia / Rời cuộc hội thoại

```ts
// Tham gia — bắt đầu nhận sự kiện của cuộc hội thoại
await client.realtime.chat.joinConversation('conv-id');

// Rời
await client.realtime.chat.leaveConversation('conv-id');
```

> SDK theo dõi `joinedConversations` và tự động re-join sau khi reconnect.

### Gửi tin nhắn qua SignalR

```ts
const ack = await client.realtime.chat.sendMessage({
  conversationId: 'conv-id',
  blocks: [{ $type: 'text', format: 'Plain', content: 'Hello!', plainText: 'Hello!' }],
  clientMessageId: 'local-uuid', // optional, optimistic rendering
});

if (ack.success) {
  console.log('Đã gửi, messageId:', ack.messageId);
} else {
  console.error('Lỗi:', ack.errorCode, ack.errorMessage);
}
```

### Chỉnh sửa / Xóa / Khôi phục tin nhắn qua SignalR

```ts
// Chỉnh sửa — dùng newBlocks/newMentions (khác REST!)
const editAck = await client.realtime.chat.editMessage({
  messageId: 'msg-id',
  newBlocks: [{ $type: 'text', format: 'Plain', content: 'Đã sửa', plainText: 'Đã sửa' }],
});

// Xóa — truyền object, KHÔNG phải string
const deleteAck = await client.realtime.chat.deleteMessage({ messageId: 'msg-id' });

// Khôi phục
const recoverAck = await client.realtime.chat.recoverMessage({ messageId: 'msg-id' });
```

### Typing indicator

```ts
await client.realtime.chat.startTyping('conv-id');
await client.realtime.chat.stopTyping('conv-id');
```

### Đánh dấu đã đọc qua SignalR

```ts
const ack = await client.realtime.chat.markAsRead('conv-id', 'last-message-id');
```

### Reaction qua SignalR

```ts
await client.realtime.chat.addReaction({ messageId: 'msg-id', conversationId: 'conv-id', emoji: '❤️' });
await client.realtime.chat.removeReaction({ messageId: 'msg-id', emoji: '❤️' });
```

### Lắng nghe sự kiện

```ts
// Đăng ký — trả về hàm unsubscribe
const unsub = client.realtime.chat.on('messageReceived', (msg) => {
  console.log('Tin nhắn mới:', msg.plainTextContent);
});

// Hủy đăng ký
unsub();

// Hoặc dùng .off()
const handler = (msg: ChatMessageDto) => { /* ... */ };
client.realtime.chat.on('messageReceived', handler);
client.realtime.chat.off('messageReceived', handler);
```

### Toàn bộ sự kiện ChatHub

| Sự kiện | Payload | Mô tả |
|---------|---------|-------|
| `messageReceived` | `ChatMessageDto` | Tin nhắn mới trong cuộc hội thoại |
| `messageUpdated` | `MessageUpdatedDto` | Tin nhắn được chỉnh sửa |
| `messageDeleted` | `MessageDeletedDto` | Tin nhắn bị xóa |
| `messageRecovered` | `ChatMessageDto` | Tin nhắn được khôi phục |
| `reactionAdded` | `ReactionAddedDto` | Thêm reaction |
| `reactionRemoved` | `ReactionRemovedDto` | Xóa reaction |
| `typingStarted` | `TypingDto` | Người dùng đang gõ |
| `typingStopped` | `TypingDto` | Người dùng dừng gõ |
| `readReceiptUpdated` | `ReadReceiptUpdatedDto` | Cập nhật trạng thái đã đọc |
| `streamStarted` | `StreamStartedDto` | Bot stream bắt đầu |
| `streamStatusUpdated` | `StreamStatusUpdatedDto` | Trạng thái bot stream thay đổi |
| `streamChunkReceived` | `StreamChunkReceivedDto` | Nhận thêm chunk text từ bot |
| `streamCompleted` | `StreamCompletedDto` | Bot stream hoàn thành (kèm message đầy đủ) |
| `streamAborted` | `StreamAbortedDto` | Bot stream bị hủy |
| `error` | `HubErrorDto` | Lỗi từ server |
| `reconnecting` | `Error \| undefined` | Đang reconnect |
| `reconnected` | `string \| undefined` | Đã reconnect (connectionId mới) |
| `disconnected` | `Error \| undefined` | Mất kết nối hẳn |

### Kiểu dữ liệu sự kiện (ChatHub)

```ts
// Tin nhắn được chỉnh sửa
interface MessageUpdatedDto {
  messageId: string;
  conversationId: string;
  blocks: Block[];
  plainTextContent: string | null;
  mentions?: Mention[];
  updatedAt: string;          // ISO 8601
}

// Tin nhắn bị xóa
type DeleteType = 'recall' | 'delete'; // 'recall': xóa cho tất cả; 'delete': xóa phía người gửi

interface MessageDeletedDto {
  messageId: string;
  conversationId: string;
  deleteType: DeleteType;
  deletedBy: string;          // participantId
  deletedAt: string;          // ISO 8601
}

// Reaction được thêm
interface ReactionAddedDto {
  messageId: string;
  conversationId: string;
  participantId: string;
  participantName: string;
  emoji: string;
  createdAt: string;          // ISO 8601
}

// Reaction được xóa — chú ý field là removedAt, KHÔNG phải createdAt
interface ReactionRemovedDto {
  messageId: string;
  conversationId: string;
  participantId: string;
  participantName: string;
  emoji: string;
  removedAt: string;          // ISO 8601 — khác với ReactionAddedDto.createdAt
}

// Typing indicator
interface TypingDto {
  conversationId: string;
  participantId: string;
  participantName: string;
}

// Trạng thái đã đọc
interface ReadReceiptUpdatedDto {
  conversationId: string;
  participantId: string;
  lastReadMessageId: string;
  readAt: string;             // ISO 8601
}
```

### Bot streaming

Khi bot streaming, **không có** `messageReceived` — thay vào đó nhận chuỗi sự kiện sau:

```
streamStarted → streamStatusUpdated* → streamChunkReceived* → streamCompleted
                                                             → streamAborted
```

```ts
const chunks: string[] = [];

client.realtime.chat.on('streamStarted', (dto) => {
  console.log('Bot bắt đầu trả lời, streamId:', dto.streamId);
});

client.realtime.chat.on('streamChunkReceived', (dto) => {
  chunks.push(dto.text);
  // Hiển thị progressive: chunks.join('')
});

client.realtime.chat.on('streamCompleted', (dto) => {
  console.log('Tin nhắn hoàn chỉnh:', dto.message);
  chunks.length = 0; // reset
});

client.realtime.chat.on('streamAborted', (dto) => {
  console.error('Stream bị hủy:', dto.reason);
});
```

---

## 13. Real-time: NotificationHubClient

**Truy cập:** `client.realtime.notifications`

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).

### Trạng thái kết nối

```ts
import { HubConnectionState } from '@microsoft/signalr';

const state = client.realtime.notifications.state;
// HubConnectionState.Connected | Disconnected | Connecting | Reconnecting
```

### Kết nối

```ts
await client.realtime.notifications.connect();
// Khi connect, server tự động đánh dấu user là Online
```

### Ngắt kết nối

```ts
await client.realtime.notifications.disconnect();
// Server tự động đánh dấu user là Offline
```

> `subscribedPresenceIds` là `Set<string>` chứa các participantId đang theo dõi presence. SDK tự động re-subscribe sau reconnect. Có thể đọc trực tiếp: `client.realtime.notifications.subscribedPresenceIds`.

### `resubscribePresence(): Promise<void>`

Re-subscribe presence cho tất cả participantId đang track. SDK tự gọi sau reconnect; cũng có thể gọi thủ công nếu cần.

```ts
await client.realtime.notifications.resubscribePresence();
```

### Theo dõi trạng thái online (Presence)

```ts
// Đăng ký theo dõi tối đa 200 participant mỗi lần
await client.realtime.notifications.subscribeToPresence(['user-1', 'user-2', 'user-3']);

// Server gửi PresenceState ngay lập tức sau khi subscribe
client.realtime.notifications.on('presenceState', (states) => {
  states.forEach(s => {
    console.log(s.participantId, s.isOnline, s.lastSeenAt);
  });
});

// Sau đó nhận cập nhật real-time
client.realtime.notifications.on('presenceChanged', (dto) => {
  console.log(dto.participantId, 'is now', dto.isOnline ? 'online' : 'offline');
});

// Hủy theo dõi — cũng giới hạn tối đa 200 participant mỗi lần
await client.realtime.notifications.unsubscribeFromPresence(['user-1']);
```

> SDK tự động re-subscribe sau khi reconnect.

### Toàn bộ sự kiện NotificationHub

| Sự kiện | Payload | Mô tả |
|---------|---------|-------|
| `presenceState` | `PresenceStateItem[]` | Trạng thái online ban đầu sau subscribe |
| `presenceChanged` | `PresenceChangedDto` | Thay đổi trạng thái online |
| `newMessageNotification` | `NewMessageNotificationDto` | Có tin nhắn mới (thông báo cho tất cả participants trừ người gửi) |
| `mentionedNotification` | `MentionedNotificationDto` | Bị mention trong tin nhắn |
| `unreadCountChanged` | `UnreadCountChangedDto` | Số tin chưa đọc thay đổi |
| `conversationCreated` | `ConversationCreatedDto` | Cuộc hội thoại mới được tạo (hoặc bị thêm vào) |
| `conversationUpdated` | `ConversationUpdatedDto` | Metadata cuộc hội thoại thay đổi |
| `participantJoined` | `ParticipantJoinedDto` | Thành viên mới tham gia |
| `participantLeft` | `ParticipantLeftDto` | Thành viên rời nhóm |
| `conversationPinned` | `ConversationPinnedDto` | Cuộc hội thoại được pin |
| `conversationUnpinned` | `ConversationUnpinnedDto` | Cuộc hội thoại được unpin |
| `readStatusChanged` | `ReadStatusChangedDto` | Trạng thái đọc thay đổi |
| `conversationDeleted` | `ConversationDeletedDto` | Conversation bị ẩn/xóa khỏi danh sách (chỉ OneToOne & Self, chỉ user thực hiện) |
| `conversationRestored` | `ConversationRestoredDto` | Conversation bị ẩn được khôi phục tự động do có tin nhắn mới (chỉ OneToOne & Self) |
| `error` | `HubErrorDto` | Lỗi từ server |
| `reconnecting` | `Error \| undefined` | Đang reconnect |
| `reconnected` | `string \| undefined` | Đã reconnect |
| `disconnected` | `Error \| undefined` | Mất kết nối |

### Kiểu dữ liệu sự kiện (NotificationHub)

```ts
// Trạng thái online — trả về ngay sau subscribe
interface PresenceStateItem {
  participantId: string;
  isOnline: boolean;
  lastSeenAt?: string | null; // ISO 8601, null khi đang online
}

// Thay đổi trạng thái online — real-time update
interface PresenceChangedDto {
  participantId: string;
  isOnline: boolean;
  lastSeenAt?: string | null; // ISO 8601, null khi đang online
}

interface NewMessageNotificationDto {
  conversationId: string;
  conversationType: ConversationType;
  conversationName?: string;
  messageId: string;
  senderId: string;
  senderName: string;
  senderAvatar?: MediaReference;
  contentPreview: string;
  sentAt: string;             // ISO 8601
}

// Chú ý: KHÔNG có senderAvatar (khác NewMessageNotificationDto)
interface MentionedNotificationDto {
  conversationId: string;
  messageId: string;
  senderId: string;
  senderName: string;
  contentPreview: string;
  sentAt: string;             // ISO 8601
}

interface UnreadCountChangedDto {
  conversationId: string;
  unreadCount: number;
  lastMessageAt: string;      // ISO 8601
  lastMessageId: string;
}

interface ConversationCreatedDto {
  conversationId: string;
  type: ConversationType;
  name?: string;
  avatar?: MediaReference;
  participantCount: number;
}

interface ConversationUpdatedDto {
  conversationId: string;
  type: ConversationType;
  name?: string;
  avatar?: MediaReference | null; // null = avatar đã bị xóa
  participantCount: number;
}

interface ParticipantJoinedDto {
  conversationId: string;
  participantId: string;
  participantName: string;
  participantAvatar?: MediaReference;
  changedAt: string;          // ISO 8601
}

// Cùng shape với ParticipantJoinedDto
interface ParticipantLeftDto {
  conversationId: string;
  participantId: string;
  participantName: string;
  participantAvatar?: MediaReference;
  changedAt: string;          // ISO 8601
}

interface ConversationPinnedDto {
  conversationId: string;
  pinnedAt: string;           // ISO 8601
}

// Chú ý: KHÔNG có pinnedAt (khác ConversationPinnedDto)
interface ConversationUnpinnedDto {
  conversationId: string;
}

interface ReadStatusChangedDto {
  conversationId: string;
  lastReadMessageId?: string;
  unreadCount: number;
  markedAsUnread: boolean;    // true khi user chủ động đánh dấu chưa đọc
}

// Conversation bị ẩn/xóa khỏi danh sách của user.
// Chỉ áp dụng cho OneToOne và Self — không broadcast tới người còn lại.
// Conversation vẫn tồn tại và sẽ tự khôi phục khi có tin nhắn mới.
interface ConversationDeletedDto {
  conversationId: string;
  deletedAt: string;          // ISO 8601
}

// Conversation đã bị ẩn được khôi phục tự động vì có tin nhắn mới.
// Client nên thêm lại conversation vào sidebar với metadata đầy đủ từ payload.
// 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.
interface ConversationRestoredDto {
  conversationId: string;
  type: ConversationType;
  name?: string | null;
  avatar?: MediaReference | null;
  participantCount: number;
  restoredAt: string;         // ISO 8601
}
```

---

## 14. ReconnectionManager

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.

```ts
import { ReconnectionManager } from '@manonero/chat-client-sdk';

const manager = new ReconnectionManager({
  chatHub: client.realtime.chat,
  notificationHub: client.realtime.notifications,
  onTokenExpired: async () => {
    // Gọi API refresh token của ứng dụng
    const newToken = await refreshToken();
    if (newToken) {
      client.setToken(newToken); // Cập nhật token vào SDK
    }
    return newToken; // Trả về null để hủy reconnect
  },
});

manager.start(); // Bắt đầu quản lý — idempotent, gọi nhiều lần an toàn

// Khi không còn cần
manager.stop();
```

**Chiến lược backoff:** 3 lần thử với delay 2s → 5s → 10s.

**Phát hiện token hết hạn:** Kiểm tra error message có chứa `"401"` hoặc `"unauthorized"`.

---

## 15. Hệ thống Block (nội dung tin nhắn)

`Block` là **discriminated union** — mỗi block phân biệt bằng field `$type`.

### Các loại Block

| `$type` | Interface | Mô tả |
|---------|-----------|-------|
| `"text"` | `TextBlock` | Văn bản (Plain, Markdown, Html, ProseMirrorJson) |
| `"image"` | `ImageBlock` | Hình ảnh |
| `"video"` | `VideoBlock` | Video |
| `"audio"` | `AudioBlock` | Âm thanh |
| `"file"` | `FileBlock` | Tệp tin |
| `"linkPreview"` | `LinkPreviewBlock` | Preview URL |
| `"embed"` | `EmbedBlock` | Nhúng nội dung (iframe) |
| `"location"` | `LocationBlock` | Vị trí địa lý |
| `"contact"` | `ContactBlock` | Thông tin liên hệ |
| `"choice"` | `ChoiceBlock` | Câu hỏi / lựa chọn (bot) |
| `"card"` | `CardBlock` | Thẻ thông tin có button |
| `"carousel"` | `CarouselBlock` | Nhiều card liên tiếp |
| `"divider"` | `DividerBlock` | Đường phân cách |
| `"custom"` | `CustomBlock` | Block tùy chỉnh |

### Interfaces tham chiếu đầy đủ

```ts
interface TextBlock {
  $type: 'text';
  format: TextFormat; // 'Plain' | 'Markdown' | 'Html' | 'ProseMirrorJson'
  content: string;
  plainText: string | null;
}

interface ImageBlock {
  $type: 'image';
  source: MediaReference;
  thumbnail: MediaReference | null;
  altText: string | null;
  width: number | null;
  height: number | null;
  caption: string | null;
  fileSizeBytes: number | null;
}

interface VideoBlock {
  $type: 'video';
  source: MediaReference;
  thumbnail: MediaReference | null;
  caption: string | null;
  durationSeconds: number | null;
  mimeType: string | null;
  fileSizeBytes: number | null;
}

interface AudioBlock {
  $type: 'audio';
  source: MediaReference;
  durationSeconds: number | null;
  mimeType: string | null;
  transcript: string | null;    // Bản ghi âm thanh (nếu có)
  fileSizeBytes: number | null;
}

interface FileBlock {
  $type: 'file';
  source: MediaReference;
  fileName: string;
  mimeType: string | null;
  fileSizeBytes: number | null;
}

interface LinkPreviewBlock {
  $type: 'linkPreview';
  url: string;
  title: string | null;
  description: string | null;
  image: MediaReference | null;
  siteName: string | null;
}

interface EmbedBlock {
  $type: 'embed';
  url: string;
  html: string | null;          // HTML nhúng (iframe snippet)
  width: number | null;
  height: number | null;
}

interface LocationBlock {
  $type: 'location';
  latitude: number;
  longitude: number;
  name: string | null;
  address: string | null;
}

interface ContactBlock {
  $type: 'contact';
  displayName: string;
  phone: string | null;
  email: string | null;
  organization: string | null;
}

interface ChoiceBlock {
  $type: 'choice';
  prompt: string;               // Câu hỏi hiển thị cho user
  options: ChoiceOption[];
  mode: ChoiceMode;             // 'Single' | 'Multiple'
  submitted: boolean;           // true sau khi user đã submit lựa chọn
}

interface CardBlock {
  $type: 'card';
  title: string | null;
  subtitle: string | null;
  image: MediaReference | null;
  fields: CardField[] | null;
  buttons: ActionButton[] | null;
}

interface CarouselBlock {
  $type: 'carousel';
  cards: CardBlock[];           // Danh sách card trình chiếu ngang
}

interface DividerBlock {
  $type: 'divider';             // Đường phân cách — không có field nào khác
}

interface CustomBlock {
  $type: 'custom';
  type: string;                 // Discriminator tùy chỉnh của ứng dụng
  data: Record<string, unknown> | null;
}
```

### Type Guards

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:

```ts
import { isTextBlock, isImageBlock, isFileBlock, isCardBlock } from '@manonero/chat-client-sdk';

for (const block of message.blocks) {
  if (isTextBlock(block)) {
    console.log(block.content); // TypeScript biết đây là TextBlock
  } else if (isImageBlock(block)) {
    console.log(block.source.url); // ImageBlock
  } else if (isFileBlock(block)) {
    console.log(block.fileName, block.fileSizeBytes);
  } else if (isCardBlock(block)) {
    block.buttons?.forEach(btn => console.log(btn.label, btn.action));
  }
}
```

Danh sách đầy đủ: `isTextBlock`, `isImageBlock`, `isVideoBlock`, `isAudioBlock`, `isFileBlock`, `isLinkPreviewBlock`, `isEmbedBlock`, `isLocationBlock`, `isContactBlock`, `isChoiceBlock`, `isCardBlock`, `isCarouselBlock`, `isDividerBlock`, `isCustomBlock`.

### Ví dụ tạo block

```ts
// Văn bản Markdown
const textBlock: TextBlock = {
  $type: 'text',
  format: 'Markdown',
  content: '**Hello** _world_!',
  plainText: 'Hello world!',
};

// Hình ảnh đã upload
const imageBlock: ImageBlock = {
  $type: 'image',
  source: { storageKey: '01HXABCDEF/photo.jpg' },
  thumbnail: null,
  altText: 'Ảnh chụp',
  width: 1920,
  height: 1080,
  caption: 'Ảnh mô tả',
  fileSizeBytes: 204800,
};

// Gửi tin nhắn kèm file
const fileBlock: FileBlock = {
  $type: 'file',
  source: { storageKey: '01HXABCDEF/report.pdf' },
  fileName: 'report.pdf',
  mimeType: 'application/pdf',
  fileSizeBytes: 1048576,
};
```

### TextFormat

| Giá trị | Mô tả |
|---------|-------|
| `'Plain'` | Văn bản thuần |
| `'Markdown'` | Markdown |
| `'Html'` | HTML (render trong WebView) |
| `'ProseMirrorJson'` | Rich text dạng JSON (ProseMirror) |

### ButtonAction / ButtonStyle (dùng trong CardBlock)

```ts
type ButtonAction = 'Postback' | 'Url' | 'Call' | 'Copy';
type ButtonStyle = 'Default' | 'Primary' | 'Danger';

interface ActionButton {
  label: string;
  action: ButtonAction;
  value: string | null;
  style: ButtonStyle;
}

interface CardField {
  label: string;
  value: string;
}
```

### ChoiceMode / ChoiceOption / ChoiceBlock

```ts
type ChoiceMode = 'Single' | 'Multiple';

interface ChoiceOption {
  label: string;
  value: string;
  selected: boolean;
}

interface ChoiceBlock {
  $type: 'choice';
  prompt: string;          // Câu hỏi hiển thị cho user
  options: ChoiceOption[];
  mode: ChoiceMode;
  submitted: boolean;      // true sau khi user đã submit lựa chọn
}
```

### QuickReply

Nút bấm nhanh hiển thị bên dưới tin nhắn bot:

```ts
interface QuickReply {
  label: string;       // Text hiển thị
  action: QuickReplyAction; // 'SendText' | 'Postback' | 'Url'
  value: string | null;
  icon: string | null;
}
```

### Mention

```ts
interface Mention {
  type: MentionType;   // 'User' | 'All' | 'Here' | 'Role'
  targetId: string | null; // User ID (null cho All/Here)
  displayName: string;
  offset: number | null;   // Vị trí trong plainText
  length: number | null;
}
```

---

## 16. Toàn bộ kiểu dữ liệu (Types)

### CursorPaginatedResult (cursor-based)

```ts
interface CursorPaginatedResult<T> {
  items: T[];
  nextCursor: string | null; // PHẢI là string opaque — KHÔNG cast sang number
  hasMore: boolean;
}
```

### PagedResult (page-based)

```ts
interface PagedResult<T> {
  items: T[];
  page: number;
  pageSize: number;
  totalCount: number;
}
```

### MediaReference

```ts
interface MediaReference {
  storageKey?: string; // Key nội bộ — dùng FileApi.getDownloadUrl() để tạo URL
  url?: string;        // URL bên ngoài (không qua storage)
}
```

### ChatMessageDto (SignalR version)

Khác với `MessageDto` (REST):

```ts
interface ChatMessageDto {
  id: string;
  conversationId: string;
  senderId: string;
  senderName: string;        // Có ở SignalR, không có ở REST
  senderAvatar?: MediaReference; // Có ở SignalR, không có ở REST
  senderType: SenderType;    // Có ở cả REST và SignalR
  blocks: Block[];
  plainTextContent: string | null; // Tên khác với REST (plainTextIndex)
  replyToMessageId: string | null;
  quickReplies: QuickReply[] | null;
  isEdited: boolean;
  reactions: ReactionGroupDto[] | null; // Flat array, khác REST
  mentions: Mention[] | null;
  createdAt: string;
  updatedAt: string | null;
  clientMessageId: string | null;
  systemEvent: SystemEventDto | null;
}

// Reaction group (SignalR version — flat array, KHÔNG gói trong ReactionSummary)
// So sánh với REST: ReactionSummary { groups: ReactionGroup[], totalCount }
interface ReactionGroupDto {
  emoji: string;
  count: number;
  participantIds: string[];   // Danh sách participant đã react
}
```

### SystemEvent

```ts
type SystemEventType =
  | 'ConversationRenamed'  // Đổi tên nhóm
  | 'AvatarChanged'        // Đổi avatar
  | 'MemberAdded'          // Thêm thành viên
  | 'MemberRemoved'        // Xóa thành viên
  | 'MemberLeft'           // Thành viên tự rời
  | 'Custom';

interface SystemEventInfo {
  type: SystemEventType;
  actorId: string | null;   // null khi MemberLeft (tự rời)
  targetIds?: string[];
  metadata?: Record<string, unknown>;
}

// SignalR (ChatHub) version — kế thừa SystemEventInfo, thêm display names
interface SystemEventDto extends SystemEventInfo {
  actorName?: string;       // Tên hiển thị của actor
  targetNames?: string[];   // Tên hiển thị của các target participants
}
```

### HubErrorDto

```ts
interface HubErrorDto {
  code: string;
  message: string;
  details?: Record<string, unknown>;
}
```

### Streaming DTOs (ChatHub)

```ts
interface StreamStartedDto {
  streamId: string;
  messageId: string;
  conversationId: string;
  senderId: string;
  senderName: string;
  senderAvatar?: MediaReference;
  replyToMessageId?: string;
  startedAt: string;         // ISO 8601
}

interface StreamStatusUpdatedDto {
  streamId: string;
  messageId: string;
  conversationId: string;
  status: string;            // e.g. "thinking", "searching"
  detail?: string;
  updatedAt: string;         // ISO 8601
}

interface StreamChunkReceivedDto {
  streamId: string;
  messageId: string;
  conversationId: string;
  text: string;              // Chunk text để append vào placeholder
  chunkIndex: number;        // Zero-based index
}

interface StreamCompletedDto {
  streamId: string;
  messageId: string;
  conversationId: string;
  message: ChatMessageDto;   // Tin nhắn hoàn chỉnh (SignalR, không phải REST MessageDto)
  completedAt: string;       // ISO 8601
}

interface StreamAbortedDto {
  streamId: string;
  messageId: string;
  conversationId: string;
  reason: string;
  abortedAt: string;         // ISO 8601
}
```

### Ack types (return value của Hub methods)

Mỗi Hub method client→server trả về một ack object với `success: boolean` và optional error fields:

```ts
interface SendMessageAck {
  success: boolean;
  messageId?: string;
  clientMessageId?: string;
  createdAt?: string;        // ISO 8601 — thời điểm server tạo tin nhắn
  errorCode?: string;
  errorMessage?: string;
}

interface EditMessageAck {
  success: boolean;
  messageId?: string;
  editedAt?: string;         // ISO 8601
  errorCode?: string;
  errorMessage?: string;
}

interface DeleteMessageAck {
  success: boolean;
  messageId?: string;
  deletedAt?: string;        // ISO 8601
  errorCode?: string;
  errorMessage?: string;
}

interface RecoverMessageAck {
  success: boolean;
  messageId?: string;
  errorCode?: string;
  errorMessage?: string;
}

interface ReactionAck {
  success: boolean;
  messageId?: string;
  emoji?: string;
  errorCode?: string;
  errorMessage?: string;
}

interface MarkAsReadAck {
  success: boolean;
  conversationId?: string;
  errorCode?: string;
  errorMessage?: string;
}
```

---

## 17. Xử lý lỗi — ChatApiError

Tất cả lỗi HTTP đều throw `ChatApiError` (kế thừa `Error`).

```ts
import { ChatApiError } from '@manonero/chat-client-sdk';

try {
  await client.messages.send('conv-id', { blocks: [...] });
} catch (err) {
  if (err instanceof ChatApiError) {
    console.error(`HTTP ${err.status}: ${err.title}`);
    console.error('Chi tiết:', err.detail);
    
    switch (err.status) {
      case 401: // Unauthorized — token hết hạn/không hợp lệ
        await refreshAndRetry();
        break;
      case 403: // Forbidden — không có quyền
        break;
      case 404: // Not Found
        break;
      case 409: // Conflict — ví dụ: đã react emoji đó rồi
        break;
      case 429: // Rate Limited
        break;
    }
  }
}
```

### Thuộc tính ChatApiError

| Thuộc tính | Kiểu | Mô tả |
|------------|------|-------|
| `status` | `number` | HTTP status code |
| `title` | `string` | Tiêu đề lỗi (từ RFC 7807 `title`) |
| `detail` | `string \| undefined` | Chi tiết lỗi (từ RFC 7807 `detail`) |
| `message` | `string` | `detail ?? title` (kế thừa từ `Error`) |
| `name` | `string` | Luôn là `"ChatApiError"` |

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.

### Static methods

#### `ChatApiError.fromResponse(response: Response): Promise<ChatApiError>`

Parse lỗi từ HTTP `Response`. SDK gọi nội bộ — bạn không cần gọi trực tiếp.

#### `ChatApiError.fromProblemDetails(pd: ProblemDetails): ChatApiError`

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).

```ts
import { ChatApiError } from '@manonero/chat-client-sdk';
import type { ProblemDetails } from '@manonero/chat-client-sdk';

const pd: ProblemDetails = { status: 404, title: 'Not Found', detail: 'Conversation not found' };
const error = ChatApiError.fromProblemDetails(pd);
console.log(error.status);  // 404
console.log(error.message); // "Conversation not found"
```

### ProblemDetails

Kiểu dữ liệu RFC 7807 mà server trả về khi có lỗi:

```ts
interface ProblemDetails {
  status: number;
  title: string;
  detail?: string;
  type?: string;     // URI reference — thường không dùng trực tiếp
}
```

---

### Sử dụng hub client độc lập (không qua ChatClient)

Power users có thể khởi tạo `ChatHubClient` hoặc `NotificationHubClient` trực tiếp, không qua `ChatClient` facade:

```ts
import { ChatHubClient, NotificationHubClient } from '@manonero/chat-client-sdk';
import { LogLevel } from '@microsoft/signalr';

// Tự quản lý token
const tokenProvider = () => localStorage.getItem('jwt');

// Khởi tạo ChatHub độc lập
const chatHub = new ChatHubClient({
  hubUrl: 'https://chat-api.example.com/hubs/chat',
  tokenProvider,
  logLevel: LogLevel.Warning, // optional
});

// Khởi tạo NotificationHub độc lập
const notificationHub = new NotificationHubClient({
  hubUrl: 'https://chat-api.example.com/hubs/notifications',
  tokenProvider,
});

await chatHub.connect();
await notificationHub.connect();

// Kết hợp với ReconnectionManager
import { ReconnectionManager } from '@manonero/chat-client-sdk';

const manager = new ReconnectionManager({
  chatHub,
  notificationHub,
  onTokenExpired: async () => {
    const newToken = await refreshMyToken();
    return newToken;
  },
});
manager.start();
```

> ⚠️ 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`).

---

## 18. TypedEventEmitter

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()`.

```ts
// Đăng ký handler — trả về hàm unsubscribe
const unsub = hub.on('eventName', (payload) => { /* ... */ });

// Hủy bằng hàm unsubscribe
unsub();

// Hoặc hủy bằng .off()
hub.off('eventName', handler);
```

**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.

> ⚠️ **`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:

```ts
const unsubs: Array<() => void> = [];
unsubs.push(hub.on('messageReceived', handler1));
unsubs.push(hub.on('typingStarted', handler2));

// Khi cleanup:
unsubs.forEach(fn => fn());
```

---

## 19. Lưu ý quan trọng

### Cursor pagination — KHÔNG cast sang number

```ts
// ✅ Đúng
const cursor: string = result.nextCursor!;
await client.conversations.list({ cursor });

// ❌ Sai — mất độ chính xác vì vượt Number.MAX_SAFE_INTEGER
const cursor = Number(result.nextCursor); // BUG!
```

### Token và SignalR

- `setToken()` hoạt động ngay cho **HTTP requests**.
- SignalR đọc token khi **connect()**, không phải mid-session.
- Để cập nhật token cho SignalR: `disconnect()` → `setToken()` → `connect()`.

### REST vs SignalR — field name khác nhau

| Thao tác | REST | SignalR |
|----------|------|---------|
| Edit message | `{ blocks, mentions }` | `{ messageId, newBlocks, newMentions }` |
| Delete message | `messages.delete(messageId)` | `deleteMessage({ messageId })` — object, không phải string |
| Stream completion | Không có event | `streamCompleted` thay `messageReceived` |

### File upload — storageKey vs URL

```ts
// Sau khi upload, dùng storageKey làm MediaReference
const { storageKey } = await client.files.uploadFile(file);

// Trong block:
const block: ImageBlock = {
  $type: 'image',
  source: { storageKey }, // KHÔNG phải url
  // ...
};

// Để hiển thị ảnh:
const displayUrl = client.files.getDownloadUrl(storageKey);
```

### BotDto — không có tên/avatar

`BotDto` **không** chứa `uniqueName` hay `fullName`. Phải gọi thêm:

```ts
const bot = await client.bots.getById('bot-id');
const participant = await client.participants.getById(bot.participantId);
console.log(participant.fullName); // Tên hiển thị của bot
```

### HealthStatus duration — là string, không phải number

```ts
const health = await client.health.ready();
// ✅ Đúng
console.log(health.totalDuration); // "00:00:00.1234567"

// ❌ Sai
const ms = Number(health.totalDuration); // NaN
```

---

## 20. Ví dụ đầy đủ

### Ví dụ 1: Chat app cơ bản

```ts
import { ChatClient, ChatApiError } from '@manonero/chat-client-sdk';
import type { ChatMessageDto } from '@manonero/chat-client-sdk';

// 1. Khởi tạo
const client = new ChatClient({ baseUrl: 'https://chat-api.example.com' });

// 2. Đăng nhập
await client.auth.loginWithGoogle(googleIdToken);
console.log('Xin chào,', client.currentUser?.fullName);

// 3. Kết nối hub (thứ tự: notifications trước, chat sau)
await client.realtime.notifications.connect();
await client.realtime.chat.connect();

// 4. Lắng nghe thông báo
client.realtime.notifications.on('newMessageNotification', (notification) => {
  console.log(`[${notification.conversationId}] ${notification.senderName}: ${notification.contentPreview}`);
});

client.realtime.notifications.on('unreadCountChanged', (dto) => {
  updateBadge(dto.conversationId, dto.unreadCount);
});

// 5. Mở cuộc hội thoại và chat
const convId = 'conv-id-here';
await client.realtime.chat.joinConversation(convId);

// Lắng nghe tin nhắn
const unsubMsg = client.realtime.chat.on('messageReceived', (msg: ChatMessageDto) => {
  appendMessageToUI(msg);
});

// Lắng nghe typing
client.realtime.chat.on('typingStarted', (dto) => {
  showTypingIndicator(dto.participantName);
});

// 6. Gửi tin nhắn
async function sendMessage(text: string) {
  const ack = await client.realtime.chat.sendMessage({
    conversationId: convId,
    blocks: [{ $type: 'text', format: 'Plain', content: text, plainText: text }],
  });
  if (!ack.success) throw new Error(ack.errorMessage);
}

// 7. Upload và gửi file
async function sendFile(file: File) {
  const { storageKey } = await client.files.uploadFile(file, (loaded, total) => {
    setProgress(Math.round((loaded / total) * 100));
  });

  await client.realtime.chat.sendMessage({
    conversationId: convId,
    blocks: [{
      $type: 'file',
      source: { storageKey },
      fileName: file.name,
      mimeType: file.type,
      fileSizeBytes: file.size,
    }],
  });
}

// 8. Đánh máy indicator
let typingTimer: ReturnType<typeof setTimeout>;
function onUserType() {
  client.realtime.chat.startTyping(convId);
  clearTimeout(typingTimer);
  typingTimer = setTimeout(() => client.realtime.chat.stopTyping(convId), 2000);
}

// 9. Dọn dẹp khi thoát
async function cleanup() {
  unsubMsg(); // Hủy event listener
  await client.disconnect();
}
```

### Ví dụ 2: Load lịch sử tin nhắn với infinite scroll

```ts
let cursor: string | null = null;
let loading = false;

async function loadMoreMessages(conversationId: string) {
  if (loading) return;
  loading = true;

  try {
    const result = await client.messages.getHistory(conversationId, {
      limit: 30,
      cursor: cursor ?? undefined,
      direction: 'older',
    });

    result.items.forEach(msg => prependMessageToUI(msg));
    cursor = result.nextCursor;

    if (!result.hasMore) {
      hideLoadMoreButton();
    }
  } finally {
    loading = false;
  }
}
```

### Ví dụ 3: Hiển thị trạng thái online

```ts
await client.realtime.notifications.connect();

// Load danh sách participant trong cuộc hội thoại
const participants = await client.conversations.getParticipants('conv-id');
const participantIds = participants.map(p => p.participantId);

// Subscribe presence
await client.realtime.notifications.subscribeToPresence(participantIds);

// Nhận trạng thái ban đầu
client.realtime.notifications.on('presenceState', (states) => {
  states.forEach(s => {
    updateOnlineIndicator(s.participantId, s.isOnline);
  });
});

// Cập nhật real-time
client.realtime.notifications.on('presenceChanged', (dto) => {
  updateOnlineIndicator(dto.participantId, dto.isOnline);
  if (!dto.isOnline && dto.lastSeenAt) {
    showLastSeen(dto.participantId, new Date(dto.lastSeenAt));
  }
});
```

### Ví dụ 4: Xử lý bot streaming

```ts
const streamBuffers = new Map<string, string[]>();

client.realtime.chat.on('streamStarted', (dto) => {
  streamBuffers.set(dto.streamId, []);
  addStreamingPlaceholder(dto.messageId, dto.senderName);
});

client.realtime.chat.on('streamStatusUpdated', (dto) => {
  updateStreamStatus(dto.messageId, dto.status); // e.g. "thinking", "searching"
});

client.realtime.chat.on('streamChunkReceived', (dto) => {
  const chunks = streamBuffers.get(dto.streamId) ?? [];
  chunks.push(dto.text);
  streamBuffers.set(dto.streamId, chunks);
  updateStreamingText(dto.messageId, chunks.join(''));
});

client.realtime.chat.on('streamCompleted', (dto) => {
  streamBuffers.delete(dto.streamId);
  replaceWithFinalMessage(dto.message); // ChatMessageDto đầy đủ
});

client.realtime.chat.on('streamAborted', (dto) => {
  streamBuffers.delete(dto.streamId);
  showStreamError(dto.messageId, dto.reason);
});
```

### Ví dụ 5: Xử lý lỗi và token refresh

```ts
import { ReconnectionManager } from '@manonero/chat-client-sdk';

let isRefreshing = false;

const manager = new ReconnectionManager({
  chatHub: client.realtime.chat,
  notificationHub: client.realtime.notifications,
  onTokenExpired: async () => {
    if (isRefreshing) return null;
    isRefreshing = true;
    try {
      const newToken = await myAuthService.refreshToken();
      if (newToken) {
        client.setToken(newToken);
        localStorage.setItem('token', newToken);
      }
      return newToken;
    } finally {
      isRefreshing = false;
    }
  },
});

manager.start();

// Lắng nghe lỗi hub
client.realtime.chat.on('error', (err) => {
  console.error(`Hub error [${err.code}]: ${err.message}`);
});

client.realtime.chat.on('disconnected', (err) => {
  if (err) console.warn('ChatHub disconnected:', err.message);