@ably/chat
Version:
Ably Chat is a set of purpose-built APIs for a host of chat features enabling you to create 1:1, 1:Many, Many:1 and Many:Many chat rooms for any scale. It is designed to meet a wide range of chat use cases, such as livestreams, in-game communication, cust
294 lines (252 loc) • 9.37 kB
text/typescript
import * as Ably from 'ably';
import { ErrorCode } from './errors.js';
import { Logger } from './logger.js';
import { Message, MessageHeaders, MessageMetadata, MessageOperationMetadata } from './message.js';
import { OrderBy } from './messages.js';
import { OccupancyData } from './occupancy-parser.js';
import { PaginatedResult } from './query.js';
import { messageFromRest, RestMessage } from './rest-types.js';
export interface HistoryQueryParams {
start?: number;
end?: number;
orderBy?: OrderBy;
limit?: number;
/**
* Serial indicating the starting point for message retrieval.
* This serial is specific to the region of the channel the client is connected to. Messages published within
* the same region of the channel are guaranteed to be received in increasing serial order.
* @defaultValue undefined (not used if not specified)
*/
fromSerial?: string;
}
/**
* In the REST API, we currently use the `direction` query parameter to specify the order of messages instead
* of orderBy. So define this type for conversion purposes.
*/
type ApiHistoryQueryParams = Omit<HistoryQueryParams, 'orderBy'> & {
direction?: 'forwards' | 'backwards';
};
interface SendMessageParams {
text: string;
metadata?: MessageMetadata;
headers?: MessageHeaders;
}
type UpdateMessageResponse = RestMessage;
type DeleteMessageResponse = RestMessage;
interface UpdateMessageParams {
/**
* Message data to update. All fields are updated and, if omitted, they are
* set to empty.
*/
message: {
text: string;
metadata?: MessageMetadata;
headers?: MessageHeaders;
};
/** Description of the update action */
description?: string;
/** Metadata of the update action */
metadata?: MessageOperationMetadata;
}
interface OperationDetails {
/** Description of the operation */
description?: string;
/** Metadata of the operation */
metadata?: MessageOperationMetadata;
}
/**
* Parameters for sending a message reaction.
*/
export interface SendMessageReactionParams {
/**
* The type of reaction, must be one of {@link MessageReactionType}.
*/
type: string;
/**
* The reaction name to add; ie. the emoji.
*/
name: string;
/**
* The count of the reaction for type {@link MessageReactionType.Multiple}.
* Defaults to 1 if not set. Not supported for other reaction types.
* @defaultValue 1
*/
count?: number;
}
/**
* Parameters for deleting a message reaction.
*/
export interface DeleteMessageReactionParams {
/**
* The type of reaction, must be one of {@link MessageReactionType}.
*/
type: string;
/**
* The reaction name to remove, ie. the emoji. Required for all reaction types
* except {@link MessageReactionType.Unique}.
*/
name?: string;
}
/**
* Bridge for the Chat REST API.
* @internal
*/
export class ChatApi {
private readonly _realtime: Ably.Realtime;
private readonly _logger: Logger;
private readonly _apiProtocolVersion: number = 4;
constructor(realtime: Ably.Realtime, logger: Logger) {
this._realtime = realtime;
this._logger = logger;
}
async history(roomName: string, params: HistoryQueryParams): Promise<PaginatedResult<Message>> {
// convert the params into internal format
const apiParams: ApiHistoryQueryParams = { ...params };
if (params.orderBy) {
switch (params.orderBy) {
case OrderBy.NewestFirst: {
apiParams.direction = 'backwards';
break;
}
case OrderBy.OldestFirst: {
apiParams.direction = 'forwards';
break;
}
default: {
// in vanilla JS use-cases, without types, we need to check non-enum values
throw new Ably.ErrorInfo(
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
`unable to query messages; invalid orderBy value: ${params.orderBy}`,
ErrorCode.InvalidArgument,
400,
);
}
}
}
const data = await this._makeAuthorizedPaginatedRequest<RestMessage>(
this._roomUrl(roomName, '/messages'),
apiParams,
);
return this._recursivePaginateMessages(data);
}
private _recursivePaginateMessages(data: PaginatedResult<RestMessage>): PaginatedResult<Message> {
const result: PaginatedResult<Message> = {} as PaginatedResult<Message>;
result.items = data.items.map((payload) => messageFromRest(payload));
// Recursively map the next paginated data
result.next = async () => {
const nextData = await data.next();
// eslint-disable-next-line unicorn/no-null
return nextData ? this._recursivePaginateMessages(nextData) : null;
};
result.first = async () => {
const firstData = await data.first();
return this._recursivePaginateMessages(firstData);
};
result.current = async () => {
const currentData = await data.current();
return this._recursivePaginateMessages(currentData);
};
result.hasNext = () => data.hasNext();
result.isLast = () => data.isLast();
return { ...data, ...result };
}
async getMessage(roomName: string, serial: string): Promise<Message> {
const restMessage = await this._makeAuthorizedRequest<RestMessage>(this._messageUrl(roomName, serial), 'GET');
return messageFromRest(restMessage);
}
async deleteMessage(roomName: string, serial: string, details?: OperationDetails): Promise<DeleteMessageResponse> {
const body = {
...(details?.description && { description: details.description }),
...(details?.metadata && { metadata: details.metadata }),
};
return this._makeAuthorizedRequest<DeleteMessageResponse>(
this._messageUrl(roomName, serial, '/delete'),
'POST',
body,
{},
);
}
async sendMessage(roomName: string, params: SendMessageParams): Promise<RestMessage> {
const body = {
text: params.text,
...(params.metadata && { metadata: params.metadata }),
...(params.headers && { headers: params.headers }),
};
return this._makeAuthorizedRequest<RestMessage>(this._roomUrl(roomName, '/messages'), 'POST', body);
}
async updateMessage(roomName: string, serial: string, params: UpdateMessageParams): Promise<UpdateMessageResponse> {
return this._makeAuthorizedRequest<UpdateMessageResponse>(this._messageUrl(roomName, serial), 'PUT', params);
}
async sendMessageReaction(roomName: string, serial: string, data: SendMessageReactionParams): Promise<void> {
return this._makeAuthorizedRequest(this._messageUrl(roomName, serial, '/reactions'), 'POST', data);
}
async deleteMessageReaction(roomName: string, serial: string, data: DeleteMessageReactionParams): Promise<void> {
return this._makeAuthorizedRequest(this._messageUrl(roomName, serial, '/reactions'), 'DELETE', undefined, data);
}
async getClientReactions(roomName: string, serial: string, clientId?: string): Promise<Message['reactions']> {
const params = clientId ? { forClientId: clientId } : {};
return this._makeAuthorizedRequest<Message['reactions']>(
this._messageUrl(roomName, serial, '/client-reactions'),
'GET',
undefined,
params,
);
}
async getOccupancy(roomName: string): Promise<OccupancyData> {
return this._makeAuthorizedRequest<OccupancyData>(this._roomUrl(roomName, '/occupancy'), 'GET');
}
private async _makeAuthorizedRequest<RES = undefined>(
url: string,
method: 'POST' | 'GET' | 'PUT' | 'DELETE' | 'PATCH',
body?: unknown,
params?: unknown,
): Promise<RES> {
const response = await this._doRequest(url, method, params, body);
return response.items[0] as RES;
}
private async _makeAuthorizedPaginatedRequest<RES>(
url: string,
params?: unknown,
body?: unknown,
): Promise<PaginatedResult<RES>> {
return this._doRequest(url, 'GET', params, body);
}
private async _doRequest<RES>(
url: string,
method: 'POST' | 'GET' | 'PUT' | 'DELETE' | 'PATCH',
params?: unknown,
body?: unknown,
): Promise<PaginatedResult<RES>> {
const response = await this._realtime.request(method, url, this._apiProtocolVersion, params, body);
if (!response.success) {
this._logger.error('ChatApi._doRequest(); failed to make request', {
url,
method,
statusCode: response.statusCode,
errorCode: response.errorCode,
errorMessage: response.errorMessage,
});
throw new Ably.ErrorInfo(response.errorMessage, response.errorCode, response.statusCode);
}
return response;
}
/**
* Returns a URL for a specific room.
* @param roomName Name of the room
* @param suffix The suffix to add to the room URL, prefixed with /
* @returns string The formatted URL
*/
private _roomUrl(roomName: string, suffix = ''): string {
return `/chat/v4/rooms/${encodeURIComponent(roomName)}${suffix}`;
}
/**
* Returns a URL for a specific message in the room.
* @param roomName string Name of the room
* @param serial string The serial of the message
* @param suffix The suffix to add to the room URL, prefixed with /
* @returns string The formatted URL
*/
private _messageUrl(roomName: string, serial: string, suffix = ''): string {
return `${this._roomUrl(roomName, '/messages')}/${encodeURIComponent(serial)}${suffix}`;
}
}