npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@manonero/chat-client-sdk

v1.0.0-beta.1

Published

A TypeScript SDK for building chat clients using Microsoft SignalR.

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

  1. Tổng quan kiến trúc
  2. Cài đặt và khởi tạo
  3. ChatClient — Facade chính
  4. Xác thực — AuthApi
  5. Participant — ParticipantApi
  6. Conversation — ConversationApi
  7. Message — MessageApi
  8. File — FileApi
  9. Bot — BotApi
  10. Proxy — ProxyApi
  11. Health — HealthApi
  12. Real-time: ChatHubClient
  13. Real-time: NotificationHubClient
  14. ReconnectionManager
  15. Hệ thống Block (nội dung tin nhắn)
  16. Toàn bộ kiểu dữ liệu (Types)
  17. Xử lý lỗi — ChatApiError
  18. TypedEventEmitter
  19. Lưu ý quan trọng
  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()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

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

Import

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ợ instanceof

Export 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ặ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

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ồi connect() 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ự động

loginWithDsAccount(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
  });
}

⚠️ 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>

// 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 URL

createUploadUrl(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). page bắ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ố bot

getById(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
}

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

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

Ngắt kết nối

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

joinedConversationsSet<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 | Reconnecting

Tham 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 joinedConversations và 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
                                                             → streamAborted
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

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

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

Kết nối

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

Ngắt kết nối

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

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

Blockdiscriminated 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/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().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ê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().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 bot

HealthStatus 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); // NaN

20. 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();
});