@manonero/chat-client-sdk
v1.0.0-beta.1
Published
A TypeScript SDK for building chat clients using Microsoft SignalR.
Maintainers
Readme
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
- Tổng quan kiến trúc
- Cài đặt và khởi tạo
- ChatClient — Facade chính
- Xác thực — AuthApi
- Participant — ParticipantApi
- Conversation — ConversationApi
- Message — MessageApi
- File — FileApi
- Bot — BotApi
- Proxy — ProxyApi
- Health — HealthApi
- Real-time: ChatHubClient
- Real-time: NotificationHubClient
- ReconnectionManager
- Hệ thống Block (nội dung tin nhắn)
- Toàn bộ kiểu dữ liệu (Types)
- Xử lý lỗi — ChatApiError
- TypedEventEmitter
- Lưu ý quan trọng
- 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:
- Khởi tạo
ChatClientvớibaseUrl. - Đăng nhập qua
client.auth.loginWith*()— token được tự động lưu và truyền vào mọi request sau. - Kết nối các hub SignalR qua
client.realtime.notifications.connect()vàclient.realtime.chat.connect(). - Lắng nghe sự kiện real-time bằng
.on(eventName, handler). - 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
npm install @manonero/chat-client-sdk
npm install @microsoft/signalr # peer dependencyImport
import { ChatClient } from '@manonero/chat-client-sdk';Tất cả các kiểu dữ liệu đều được export từ package root:
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ợ instanceofExport nâng cao — SDK cũng export một số class và type nội bộ cho power users:
// 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
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)
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):
const client = new ChatClient({
baseUrl: 'https://chat-api.example.com',
tokenProvider: () => store.getState().auth.token,
});Ưu tiên token:
token(hoặcsetToken()) luôn ưu tiên hơntokenProvider. Khi_tokenkhácnull,tokenProviderbị bỏ qua.
Cấu hình SignalR log level
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
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ồiconnect()lại để dùng token mới.
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.
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.
const { token, user } = await client.auth.loginWithGoogle(googleIdToken);
console.log(client.currentUser); // AuthUserInfo đã được set tự độngloginWithDsAccount(token: string): Promise<AuthResponse>
Đăng nhập bằng DS Account token.
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.
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).
const auth = await client.auth.login('custom-provider', { token: '...' });AuthResponse
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).
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>
const participant = await client.participants.getById('user-123');getByUniqueName(uniqueName: string): Promise<ParticipantDto>
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).
const participants = await client.participants.getBatch(['id-1', 'id-2', 'id-3']);ParticipantDto
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.
// 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
});
}⚠️
cursorlà 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>
// 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.
const results = await client.conversations.search('team', 10);getById(id: string): Promise<ConversationDto>
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.
const updated = await client.conversations.update('conv-id', {
name: 'New Group Name',
avatar: { url: 'https://example.com/group-avatar.jpg' },
});leave(id: string): Promise<void>
Rời khỏi cuộc hội thoại. Các participant khác vẫn còn đó.
await client.conversations.leave('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ó.
const existing = await client.conversations.findOneToOne('other-user-id');
if (!existing) {
// Tạo mới
}addParticipant(conversationId, request): Promise<void>
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>
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.
const members = await client.conversations.getParticipants('conv-id');
const active = members.filter(m => m.isActive);pin(id) / unpin(id): Promise<void>
await client.conversations.pin('conv-id');
await client.conversations.unpin('conv-id');markAsRead(id, messageId) / markAsUnread(id): Promise<void>
// Đá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
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 đủ.
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.
// 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.
const results = await client.messages.search('conv-id', 'từ khóa tìm kiếm', 20);getById(messageId): Promise<MessageDto>
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.
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).
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.
await client.messages.recover('msg-id');addReaction(messageId, emoji) / removeReaction(messageId, emoji): Promise<void>
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
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
// 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.
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 URLcreateUploadUrl(request): Promise<CreateUploadUrlResponse>
Bước 1 của 2 bước upload thủ công — xin URL upload.
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.
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.).
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.
await client.files.delete('01HXABCDEF/photo.jpg');Kiểu dữ liệu File
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).
pagebắt đầu từ 1. Server cache kết quả 5 phút.
const result = await client.bots.list({ page: 1, pageSize: 20 });
console.log(result.items); // BotDto[]
console.log(result.totalCount); // Tổng số botgetById(id): Promise<BotDto>
Server cache kết quả 5 phút.
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.
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>
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>
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.
const { apiKey } = await client.bots.regenerateKey('bot-id');delete(id): Promise<void>
Soft delete.
await client.bots.delete('bot-id');Kiểu dữ liệu Bot
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
}⚠️
BotDtokhông chứauniqueNamehayfullName. Dùngclient.participants.getById(bot.participantId)để lấy tên và avatar của bot.
Kiểu dữ liệu Request (Bot)
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.
// 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,
});
// Request tùy chỉnh method
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.
// 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
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
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 SignalRwithAutomaticReconnect).
Ngắt kết nối
await client.realtime.chat.disconnect();
// Xóa toàn bộ joinedConversations
joinedConversationslà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.
await client.realtime.chat.rejoinConversations();Trạng thái kết nối
import { HubConnectionState } from '@microsoft/signalr';
const state = client.realtime.chat.state;
// HubConnectionState.Connected | Disconnected | Connecting | ReconnectingTham gia / Rời cuộc hội thoại
// 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
joinedConversationsvà tự động re-join sau khi reconnect.
Gửi tin nhắn qua SignalR
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
// 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
await client.realtime.chat.startTyping('conv-id');
await client.realtime.chat.stopTyping('conv-id');Đánh dấu đã đọc qua SignalR
const ack = await client.realtime.chat.markAsRead('conv-id', 'last-message-id');Reaction qua SignalR
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
// Đă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)
// 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
→ streamAbortedconst 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
import { HubConnectionState } from '@microsoft/signalr';
const state = client.realtime.notifications.state;
// HubConnectionState.Connected | Disconnected | Connecting | ReconnectingKết nối
await client.realtime.notifications.connect();
// Khi connect, server tự động đánh dấu user là OnlineNgắt kết nối
await client.realtime.notifications.disconnect();
// Server tự động đánh dấu user là Offline
subscribedPresenceIdslà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.
await client.realtime.notifications.resubscribePresence();Theo dõi trạng thái online (Presence)
// Đă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 |
| 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)
// 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
}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.
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 đủ
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:
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
// 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)
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
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:
interface QuickReply {
label: string; // Text hiển thị
action: QuickReplyAction; // 'SendText' | 'Postback' | 'Url'
value: string | null;
icon: string | null;
}Mention
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)
interface CursorPaginatedResult<T> {
items: T[];
nextCursor: string | null; // PHẢI là string opaque — KHÔNG cast sang number
hasMore: boolean;
}PagedResult (page-based)
interface PagedResult<T> {
items: T[];
page: number;
pageSize: number;
totalCount: number;
}MediaReference
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):
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
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
interface HubErrorDto {
code: string;
message: string;
details?: Record<string, unknown>;
}Streaming DTOs (ChatHub)
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:
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).
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).
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:
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:
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/chathoặ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().
// Đă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ênChatHubClienthayNotificationHubClient. Method này chỉ tồn tại trên classTypedEventEmitterkhi 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:
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
// ✅ Đú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
// 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:
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 botHealthStatus duration — là string, không phải number
const health = await client.health.ready();
// ✅ Đúng
console.log(health.totalDuration); // "00:00:00.1234567"
// ❌ Sai
const ms = Number(health.totalDuration); // NaN20. Ví dụ đầy đủ
Ví dụ 1: Chat app cơ bản
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
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
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
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
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);
showReconnectingUI();
});
client.realtime.chat.on('reconnected', () => {
hideReconnectingUI();
});