Skip to content
This repository was archived by the owner on Nov 5, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions src/definition/accessors/IRoomRead.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { IMessage } from '../messages/index';
import type { GetMessagesOptions } from '../../server/bridges/RoomBridge';
import type { IMessageRaw } from '../messages/index';
import type { IRoom } from '../rooms/index';
import type { IUser } from '../users/index';

Expand Down Expand Up @@ -40,12 +41,16 @@ export interface IRoomRead {
getCreatorUserByName(name: string): Promise<IUser | undefined>;

/**
* Gets an iterator for all of the messages in the provided room.
* Retrieves an array of messages from the specified room.
*
* @param roomId the room's id
* @returns an iterator for messages
* @param roomId The unique identifier of the room from which to retrieve messages.
* @param options Optional parameters for retrieving messages:
* - limit: The maximum number of messages to retrieve. Maximum 100
* - skip: The number of messages to skip (for pagination).
* - sort: An object defining the sorting order of the messages. Each key is a field to sort by, and the value is either "asc" for ascending order or "desc" for descending order.
* @returns A Promise that resolves to an array of IMessage objects representing the messages in the room.
*/
getMessages(roomId: string): Promise<IterableIterator<IMessage>>;
getMessages(roomId: string, options?: Partial<GetMessagesOptions>): Promise<Array<IMessageRaw>>;

/**
* Gets an iterator for all of the users in the provided room.
Expand Down
40 changes: 40 additions & 0 deletions src/definition/messages/IMessageRaw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { IBlock, Block } from '@rocket.chat/ui-kit';

import type { IRoom } from '../rooms';
import type { IUserLookup } from '../users';
import type { IMessageAttachment } from './IMessageAttachment';
import type { IMessageFile } from './IMessageFile';
import type { IMessageReactions } from './IMessageReaction';

/**
* The raw version of a message, without resolved information for relationship fields, i.e.
* `room`, `sender` and `editor` are not the complete entity like they are in `IMessage`
*
* This is used in methods that fetch multiple messages at the same time, as resolving the relationship
* fields require additional queries to the database and would hit the system's performance significantly.
*/
export interface IMessageRaw {
id: string;
roomId: IRoom['id'];
sender: IUserLookup;
createdAt: Date;
threadId?: string;
text?: string;
updatedAt?: Date;
editor?: IUserLookup;
editedAt?: Date;
emoji?: string;
avatarUrl?: string;
alias?: string;
file?: IMessageFile;
attachments?: Array<IMessageAttachment>;
reactions?: IMessageReactions;
groupable?: boolean;
parseUrls?: boolean;
customFields?: { [key: string]: any };
blocks?: Array<IBlock | Block>;
starred?: Array<{ _id: string }>;
pinned?: boolean;
pinnedAt?: Date;
pinnedBy?: IUserLookup;
}
2 changes: 2 additions & 0 deletions src/definition/messages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { IMessageDeleteContext } from './IMessageDeleteContext';
import { IMessageFile } from './IMessageFile';
import { IMessageFollowContext } from './IMessageFollowContext';
import { IMessagePinContext } from './IMessagePinContext';
import { IMessageRaw } from './IMessageRaw';
import { IMessageReaction, IMessageReactions } from './IMessageReaction';
import { IMessageReactionContext } from './IMessageReactionContext';
import { IMessageReportContext } from './IMessageReportContext';
Expand Down Expand Up @@ -39,6 +40,7 @@ export {
IMessageAttachmentField,
IMessageAction,
IMessageFile,
IMessageRaw,
IMessageReactions,
IMessageReaction,
IPostMessageDeleted,
Expand Down
1 change: 1 addition & 0 deletions src/definition/users/IUserLookup.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export interface IUserLookup {
_id: string;
username: string;
name?: string;
}
30 changes: 27 additions & 3 deletions src/server/accessors/RoomRead.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { IRoomRead } from '../../definition/accessors';
import type { IMessage } from '../../definition/messages';
import type { IMessageRaw } from '../../definition/messages';
import type { IRoom } from '../../definition/rooms';
import type { IUser } from '../../definition/users';
import type { RoomBridge } from '../bridges';
import { type GetMessagesOptions, GetMessagesSortableFields } from '../bridges/RoomBridge';

export class RoomRead implements IRoomRead {
constructor(private roomBridge: RoomBridge, private appId: string) {}
Expand All @@ -23,8 +24,18 @@ export class RoomRead implements IRoomRead {
return this.roomBridge.doGetCreatorByName(name, this.appId);
}

public getMessages(roomId: string): Promise<IterableIterator<IMessage>> {
throw new Error('Method not implemented.');
public getMessages(roomId: string, options: Partial<GetMessagesOptions> = {}): Promise<IMessageRaw[]> {
if (typeof options.limit !== 'undefined' && (!Number.isFinite(options.limit) || options.limit > 100)) {
throw new Error(`Invalid limit provided. Expected number <= 100, got ${options.limit}`);
}

options.limit ??= 100;

if (options.sort) {
this.validateSort(options.sort);
}

return this.roomBridge.doGetMessages(roomId, options as GetMessagesOptions, this.appId);
}

public getMembers(roomId: string): Promise<Array<IUser>> {
Expand All @@ -46,4 +57,17 @@ export class RoomRead implements IRoomRead {
public getLeaders(roomId: string): Promise<Array<IUser>> {
return this.roomBridge.doGetLeaders(roomId, this.appId);
}

// If there are any invalid fields or values, throw
private validateSort(sort: Record<string, unknown>) {
Object.entries(sort).forEach(([key, value]) => {
if (!GetMessagesSortableFields.includes(key as typeof GetMessagesSortableFields[number])) {
throw new Error(`Invalid key "${key}" used in sort. Available keys for sorting are ${GetMessagesSortableFields.join(', ')}`);
}

if (value !== 'asc' && value !== 'desc') {
throw new Error(`Invalid sort direction for field "${key}". Expected "asc" or "desc", got ${value}`);
}
});
}
}
18 changes: 17 additions & 1 deletion src/server/bridges/RoomBridge.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import type { IMessage } from '../../definition/messages';
import type { IMessage, IMessageRaw } from '../../definition/messages';
import type { IRoom } from '../../definition/rooms';
import type { IUser } from '../../definition/users';
import { PermissionDeniedError } from '../errors/PermissionDeniedError';
import { AppPermissionManager } from '../managers/AppPermissionManager';
import { AppPermissions } from '../permissions/AppPermissions';
import { BaseBridge } from './BaseBridge';

export const GetMessagesSortableFields = ['createdAt'] as const;

export type GetMessagesOptions = {
limit: number;
skip: number;
sort: Record<typeof GetMessagesSortableFields[number], 'asc' | 'desc'>;
};

export abstract class RoomBridge extends BaseBridge {
public async doCreate(room: IRoom, members: Array<string>, appId: string): Promise<string> {
if (this.hasWritePermission(appId)) {
Expand Down Expand Up @@ -91,6 +99,12 @@ export abstract class RoomBridge extends BaseBridge {
}
}

public async doGetMessages(roomId: string, options: GetMessagesOptions, appId: string): Promise<IMessageRaw[]> {
if (this.hasReadPermission(appId)) {
return this.getMessages(roomId, options, appId);
}
}

public async doRemoveUsers(roomId: string, usernames: Array<string>, appId: string): Promise<void> {
if (this.hasWritePermission(appId)) {
return this.removeUsers(roomId, usernames, appId);
Expand Down Expand Up @@ -129,6 +143,8 @@ export abstract class RoomBridge extends BaseBridge {

protected abstract getLeaders(roomId: string, appId: string): Promise<Array<IUser>>;

protected abstract getMessages(roomId: string, options: GetMessagesOptions, appId: string): Promise<IMessageRaw[]>;

protected abstract removeUsers(roomId: string, usernames: Array<string>, appId: string): Promise<void>;

private hasWritePermission(appId: string): boolean {
Expand Down
11 changes: 10 additions & 1 deletion tests/server/accessors/RoomRead.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,26 @@ import type { IUser } from '../../../src/definition/users';
import { RoomRead } from '../../../src/server/accessors';
import type { RoomBridge } from '../../../src/server/bridges';
import { TestData } from '../../test-data/utilities';
import type { IMessageRaw } from '../../../src/definition/messages';

export class RoomReadAccessorTestFixture {
private room: IRoom;

private user: IUser;

private messages: IMessageRaw[];

private mockRoomBridgeWithRoom: RoomBridge;

@SetupFixture
public setupFixture() {
this.room = TestData.getRoom();
this.user = TestData.getUser();
this.messages = ['507f1f77bcf86cd799439011', '507f191e810c19729de860ea'].map((id) => TestData.getMessageRaw(id));

const theRoom = this.room;
const theUser = this.user;
const theMessages = this.messages;
this.mockRoomBridgeWithRoom = {
doGetById(id, appId): Promise<IRoom> {
return Promise.resolve(theRoom);
Expand All @@ -39,6 +44,9 @@ export class RoomReadAccessorTestFixture {
doGetMembers(name, appId): Promise<Array<IUser>> {
return Promise.resolve([theUser]);
},
doGetMessages(roomId, options, appId): Promise<IMessageRaw[]> {
return Promise.resolve(theMessages);
},
} as RoomBridge;
}

Expand All @@ -58,14 +66,15 @@ export class RoomReadAccessorTestFixture {
Expect(await rr.getCreatorUserByName('testing')).toBe(this.user);
Expect(await rr.getDirectByUsernames([this.user.username])).toBeDefined();
Expect(await rr.getDirectByUsernames([this.user.username])).toBe(this.room);
Expect(await rr.getMessages('testing')).toBeDefined();
Expect(await rr.getMessages('testing')).toBe(this.messages);
}

@AsyncTest()
public async useTheIterators() {
Expect(() => new RoomRead(this.mockRoomBridgeWithRoom, 'testing-app')).not.toThrow();

const rr = new RoomRead(this.mockRoomBridgeWithRoom, 'testing-app');
await Expect(() => rr.getMessages('faker')).toThrowErrorAsync(Error, 'Method not implemented.');

Expect(await rr.getMembers('testing')).toBeDefined();
Expect((await rr.getMembers('testing')) as Array<IUser>).not.toBeEmpty();
Expand Down
7 changes: 6 additions & 1 deletion tests/test-data/bridges/roomBridge.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { IMessage } from '../../../src/definition/messages';
import type { IMessage, IMessageRaw } from '../../../src/definition/messages';
import type { IRoom } from '../../../src/definition/rooms';
import type { IUser } from '../../../src/definition/users';
import { RoomBridge } from '../../../src/server/bridges';
import type { GetMessagesOptions } from '../../../src/server/bridges/RoomBridge';

export class TestsRoomBridge extends RoomBridge {
public create(room: IRoom, members: Array<string>, appId: string): Promise<string> {
Expand Down Expand Up @@ -32,6 +33,10 @@ export class TestsRoomBridge extends RoomBridge {
throw new Error('Method not implemented.');
}

public getMessages(roomId: string, options: GetMessagesOptions, appId: string): Promise<IMessageRaw[]> {
throw new Error('Method not implemented.');
}

public update(room: IRoom, members: Array<string>, appId: string): Promise<void> {
throw new Error('Method not implemented.');
}
Expand Down
106 changes: 70 additions & 36 deletions tests/test-data/utilities.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { IHttp, IModify, IPersistence, IRead } from '../../src/definition/accessors';
import { HttpStatusCode } from '../../src/definition/accessors';
import type { IMessage } from '../../src/definition/messages';
import type { IMessage, IMessageAttachment, IMessageRaw } from '../../src/definition/messages';
import type { IRoom } from '../../src/definition/rooms';
import { RoomType } from '../../src/definition/rooms';
import type { ISetting } from '../../src/definition/settings';
Expand Down Expand Up @@ -128,6 +128,39 @@ export class TestInfastructureSetup {
}

const date = new Date();

const DEFAULT_ATTACHMENT = {
color: '#00b2b2',
collapsed: false,
text: 'Just an attachment that is used for testing',
timestampLink: 'https://google.com/',
thumbnailUrl: 'https://avatars0.githubusercontent.com/u/850391?s=88&v=4',
author: {
name: 'Author Name',
link: 'https://github.com/graywolf336',
icon: 'https://avatars0.githubusercontent.com/u/850391?s=88&v=4',
},
title: {
value: 'Attachment Title',
link: 'https://github.com/RocketChat',
displayDownloadLink: false,
},
imageUrl: 'https://rocket.chat/images/default/logo.svg',
audioUrl: 'http://www.w3schools.com/tags/horse.mp3',
videoUrl: 'http://www.w3schools.com/tags/movie.mp4',
fields: [
{
short: true,
title: 'Test',
value: 'Testing out something or other',
},
{
short: true,
title: 'Another Test',
value: '[Link](https://google.com/) something and this and that.',
},
],
};
export class TestData {
public static getDate(): Date {
return date;
Expand Down Expand Up @@ -193,41 +226,42 @@ export class TestData {
emoji: ':see_no_evil:',
avatarUrl: 'https://avatars0.githubusercontent.com/u/850391?s=88&v=4',
alias: 'Testing Bot',
attachments: [
{
collapsed: false,
color: '#00b2b2',
text: 'Just an attachment that is used for testing',
timestamp: new Date(),
timestampLink: 'https://google.com/',
thumbnailUrl: 'https://avatars0.githubusercontent.com/u/850391?s=88&v=4',
author: {
name: 'Author Name',
link: 'https://github.com/graywolf336',
icon: 'https://avatars0.githubusercontent.com/u/850391?s=88&v=4',
},
title: {
value: 'Attachment Title',
link: 'https://github.com/RocketChat',
displayDownloadLink: false,
},
imageUrl: 'https://rocket.chat/images/default/logo.svg',
audioUrl: 'http://www.w3schools.com/tags/horse.mp3',
videoUrl: 'http://www.w3schools.com/tags/movie.mp4',
fields: [
{
short: true,
title: 'Test',
value: 'Testing out something or other',
},
{
short: true,
title: 'Another Test',
value: '[Link](https://google.com/) something and this and that.',
},
],
},
],
attachments: [this.createAttachment()],
};
}

public static getMessageRaw(id?: string, text?: string): IMessageRaw {
const editorUser = TestData.getUser();
const senderUser = TestData.getUser();

return {
id: id || '4bShvoOXqB',
roomId: TestData.getRoom().id,
sender: {
_id: senderUser.id,
username: senderUser.username,
name: senderUser?.name,
},
text: text || 'This is just a test, do not be alarmed',
createdAt: date,
updatedAt: new Date(),
editor: {
_id: editorUser.id,
username: editorUser.username,
},
editedAt: new Date(),
emoji: ':see_no_evil:',
avatarUrl: 'https://avatars0.githubusercontent.com/u/850391?s=88&v=4',
alias: 'Testing Bot',
attachments: [this.createAttachment()],
};
}

private static createAttachment(attachment?: IMessageAttachment): IMessageAttachment {
attachment = attachment || DEFAULT_ATTACHMENT;
return {
timestamp: new Date(),
...attachment,
};
}

Expand Down