Skip to content
Open
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
72 changes: 72 additions & 0 deletions ExampleApp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@

import {
IAppAccessors,
IConfigurationExtend,
IHttp,
ILogger,
IModify,
IPersistence,
IRead,
} from '@rocket.chat/apps-engine/definition/accessors';
import { App } from '@rocket.chat/apps-engine/definition/App';
import { IMessage } from '@rocket.chat/apps-engine/definition/messages';
import { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
import {
ISlashCommand,
SlashCommandContext,
} from '@rocket.chat/apps-engine/definition/slashcommands';

export class DeleteNotifyUserApp extends App {
constructor(info: IAppInfo, logger: ILogger, accessors: IAppAccessors) {
super(info, logger, accessors);
}

public async extendConfiguration(
configuration: IConfigurationExtend
): Promise<void> {
await configuration.slashCommands.provideSlashCommand(
new TestDeleteCommand()
);
}
}

class TestDeleteCommand implements ISlashCommand {
public command = 'test-delete-notify';
public i18nParamsExample = '';
public i18nDescription = 'Tests the deleteNotifyUser API';
public providesPreview = false;

public async executor(
context: SlashCommandContext,
read: IRead,
modify: IModify,
http: IHttp,
persis: IPersistence
): Promise<void> {
const user = context.getSender();
const room = context.getRoom();
const notifier = modify.getNotifier();

// 1. Construct the message
const message: IMessage = notifier
.getMessageBuilder()
.setRoom(room)
.setText('This message will be deleted in 3 seconds.')
.getMessage();

// VERY IMPORTANT:
// Because ephemeral messages are not stored in the database, the bridge does not
// return an ID for them. We must assign an ID manually to ensure we can
// reference the SAME message in the delete call.
message.id = user.id + '-' + Date.now();

// 2. Notify the user
await notifier.notifyUser(user, message);

// 3. Wait for 3 seconds
await new Promise((resolve) => setTimeout(resolve, 3000));

// 4. Delete the notification
await notifier.deleteNotifyUser(user, message);
}
}
36 changes: 36 additions & 0 deletions apps/meteor/app/apps/server/bridges/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,42 @@ export class AppMessageBridge extends MessageBridge {
});
}

protected async deleteNotifyUser(user: IAppsUser, message: IAppsMessage, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is deleting a notification message.`);

const msg = await this.orch.getConverters()?.get('messages').convertAppMessage(message);

if (!msg) {
return;
}

void api.broadcast('notify.ephemeralMessage', user.id, msg.rid, {
...msg,
_deleted: true,
});
}
Comment on lines +85 to +98
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Validate a message id before broadcasting deletion.
If Line 88’s conversion yields a message without _id, the client can’t match and remove the notification, turning the delete into a silent no-op. Consider guarding for a missing id.

🛠️ Proposed guard
 	const msg = await this.orch.getConverters()?.get('messages').convertAppMessage(message);

 	if (!msg) {
 		return;
 	}
+
+	if (!msg._id) {
+		this.orch.debugLog(`The App ${appId} attempted to delete a notification without a message id.`);
+		return;
+	}

 	void api.broadcast('notify.ephemeralMessage', user.id, msg.rid, {
 		...msg,
 		_deleted: true,
 	});
🤖 Prompt for AI Agents
In `@apps/meteor/app/apps/server/bridges/messages.ts` around lines 85 - 98, In
deleteNotifyUser, ensure the converted app message has an identifier before
broadcasting deletion: after calling
this.orch.getConverters()?.get('messages').convertAppMessage(message) check that
msg and msg._id are present (and msg.rid if needed) and bail out (or log) if
missing so you don't send a notify.ephemeralMessage delete that clients cannot
match; update the guard around convertAppMessage to verify msg._id and avoid
broadcasting when it's absent.


protected async deleteNotifyRoom(room: IRoom, message: IAppsMessage, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is deleting a notification message from the room.`);

if (!room?.id) {
return;
}

const msg: IMessage | undefined = await this.orch.getConverters()?.get('messages').convertAppMessage(message);
const convertedMessage = msg as IMessage;

const users = (await Subscriptions.findByRoomIdWhenUserIdExists(room.id, { projection: { 'u._id': 1 } }).toArray()).map((s) => s.u._id);

await Users.findByIds(users, { projection: { _id: 1 } }).forEach(
({ _id }: { _id: string }) =>
void api.broadcast('notify.ephemeralMessage', _id, room.id, {
...convertedMessage,
_deleted: true,
}),
);
}

protected async notifyRoom(room: IRoom, message: IAppsMessage, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is notifying a room's users.`);

Expand Down
6 changes: 5 additions & 1 deletion apps/meteor/client/startup/incomingMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ Meteor.startup(() => {
onLoggedIn(() => {
// Only event I found triggers this is from ephemeral messages
// Other types of messages come from another stream
return sdk.stream('notify-user', [`${getUserId()}/message`], (msg: IMessage) => {
return sdk.stream('notify-user', [`${getUserId()}/message`], (msg: IMessage & { _deleted?: boolean }) => {
if (msg._deleted) {
return Messages.state.delete(msg._id);
}

msg.u = msg.u || { username: 'rocket.cat' };
msg.private = true;

Expand Down
10 changes: 10 additions & 0 deletions packages/apps-engine/deno-runtime/lib/accessors/notifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ export class Notifier implements INotifier {
await this.callMessageBridge('doNotifyUser', [user, message, AppObjectRegistry.get<string>('id')]);
}

public async deleteNotifyUser(user: IUser, message: IMessage): Promise<void> {
if (!message.sender || !message.sender.id) {
const appUser = await this.getAppUser();

message.sender = appUser;
}

await this.callMessageBridge('doDeleteNotifyUser', [user, message, AppObjectRegistry.get<string>('id')]);
}

public async notifyRoom(room: IRoom, message: IMessage): Promise<void> {
if (!message.sender || !message.sender.id) {
const appUser = await this.getAppUser();
Expand Down
16 changes: 16 additions & 0 deletions packages/apps-engine/src/definition/accessors/INotifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,22 @@ export interface INotifier {
*/
notifyUser(user: IUser, message: IMessage): Promise<void>;

/**
* Deletes the provided notification message.
*
* @param user The user who received the notification
* @param message The message to delete
*/
deleteNotifyUser(user: IUser, message: IMessage): Promise<void>;

/**
* Deletes the provided notification message from the room.
*
* @param room The room which to delete the notification in
* @param message The message to delete
*/
deleteNotifyRoom(room: IRoom, message: IMessage): Promise<void>;

/**
* Notifies all of the users in the provided room.
*
Expand Down
22 changes: 21 additions & 1 deletion packages/apps-engine/src/server/accessors/Notifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export class Notifier implements INotifier {
private readonly userBridge: UserBridge,
private readonly msgBridge: MessageBridge,
private readonly appId: string,
) {}
) { }

public async notifyUser(user: IUser, message: IMessage): Promise<void> {
if (!message.sender?.id) {
Expand All @@ -24,6 +24,26 @@ export class Notifier implements INotifier {
await this.msgBridge.doNotifyUser(user, message, this.appId);
}

public async deleteNotifyUser(user: IUser, message: IMessage): Promise<void> {
if (!message.sender?.id) {
const appUser = await this.userBridge.doGetAppUser(this.appId);

message.sender = appUser;
}

await this.msgBridge.doDeleteNotifyUser(user, message, this.appId);
}

public async deleteNotifyRoom(room: IRoom, message: IMessage): Promise<void> {
if (!message.sender?.id) {
const appUser = await this.userBridge.doGetAppUser(this.appId);

message.sender = appUser;
}

await this.msgBridge.doDeleteNotifyRoom(room, message, this.appId);
}

public async notifyRoom(room: IRoom, message: IMessage): Promise<void> {
if (!message.sender?.id) {
const appUser = await this.userBridge.doGetAppUser(this.appId);
Expand Down
16 changes: 16 additions & 0 deletions packages/apps-engine/src/server/bridges/MessageBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,18 @@ export abstract class MessageBridge extends BaseBridge {
}
}

public async doDeleteNotifyUser(user: IUser, message: IMessage, appId: string): Promise<void> {
if (this.hasWritePermission(appId)) {
return this.deleteNotifyUser(user, message, appId);
}
}

public async doDeleteNotifyRoom(room: IRoom, message: IMessage, appId: string): Promise<void> {
if (this.hasWritePermission(appId)) {
return this.deleteNotifyRoom(room, message, appId);
}
}

protected abstract create(message: IMessage, appId: string): Promise<string>;

protected abstract update(message: IMessage, appId: string): Promise<void>;
Expand All @@ -84,6 +96,10 @@ export abstract class MessageBridge extends BaseBridge {

protected abstract removeReaction(messageId: string, userId: string, reaction: Reaction): Promise<void>;

protected abstract deleteNotifyUser(user: IUser, message: IMessage, appId: string): Promise<void>;

protected abstract deleteNotifyRoom(room: IRoom, message: IMessage, appId: string): Promise<void>;

private hasReadPermission(appId: string): boolean {
if (AppPermissionManager.hasPermission(appId, AppPermissions.message.read)) {
return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ export class NotifierAccessorTestFixture {
doNotifyRoom(room: IRoom, msg: IMessage, appId: string): Promise<void> {
return Promise.resolve();
},
doDeleteNotifyUser(user: IUser, msg: IMessage, appId: string): Promise<void> {
return Promise.resolve();
},
doDeleteNotifyRoom(room: IRoom, msg: IMessage, appId: string): Promise<void> {
return Promise.resolve();
},
} as MessageBridge;
}

Expand All @@ -32,6 +38,8 @@ export class NotifierAccessorTestFixture {
const noti = new Notifier(this.mockUserBridge, this.mockMsgBridge, 'testing');
await Expect(() => noti.notifyRoom(TestData.getRoom(), TestData.getMessage())).not.toThrowAsync();
await Expect(() => noti.notifyUser(TestData.getUser(), TestData.getMessage())).not.toThrowAsync();
await Expect(() => noti.deleteNotifyUser(TestData.getUser(), TestData.getMessage())).not.toThrowAsync();
await Expect(() => noti.deleteNotifyRoom(TestData.getRoom(), TestData.getMessage())).not.toThrowAsync();
Expect(noti.getMessageBuilder() instanceof MessageBuilder).toBe(true);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ export class TestsMessageBridge extends MessageBridge {
throw new Error('Method not implemented.');
}

public deleteNotifyUser(user: IUser, message: IMessage, appId: string): Promise<void> {
throw new Error('Method not implemented.');
}

public deleteNotifyRoom(room: IRoom, message: IMessage, appId: string): Promise<void> {
throw new Error('Method not implemented.');
}

public delete(message: IMessage, user: IUser, appId: string): Promise<void> {
throw new Error('Method not implemented.');
}
Expand Down