From 0b1bea9954f8230a5178005084390429166ccb76 Mon Sep 17 00:00:00 2001 From: yush-1018 Date: Thu, 29 Jan 2026 15:47:48 +0530 Subject: [PATCH 1/3] feat(App Engine): Implement deleteNotifyUser to remove ephemeral messages --- apps/meteor/app/apps/server/bridges/messages.ts | 15 +++++++++++++++ apps/meteor/client/startup/incomingMessages.ts | 6 +++++- .../deno-runtime/lib/accessors/notifier.ts | 10 ++++++++++ .../src/definition/accessors/INotifier.ts | 8 ++++++++ .../apps-engine/src/server/accessors/Notifier.ts | 12 +++++++++++- .../src/server/bridges/MessageBridge.ts | 8 ++++++++ .../tests/test-data/bridges/messageBridge.ts | 4 ++++ 7 files changed, 61 insertions(+), 2 deletions(-) diff --git a/apps/meteor/app/apps/server/bridges/messages.ts b/apps/meteor/app/apps/server/bridges/messages.ts index 16366626c07c8..93a332ed15d95 100644 --- a/apps/meteor/app/apps/server/bridges/messages.ts +++ b/apps/meteor/app/apps/server/bridges/messages.ts @@ -82,6 +82,21 @@ export class AppMessageBridge extends MessageBridge { }); } + protected async deleteNotifyUser(user: IAppsUser, message: IAppsMessage, appId: string): Promise { + 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, + }); + } + protected async notifyRoom(room: IRoom, message: IAppsMessage, appId: string): Promise { this.orch.debugLog(`The App ${appId} is notifying a room's users.`); diff --git a/apps/meteor/client/startup/incomingMessages.ts b/apps/meteor/client/startup/incomingMessages.ts index ac840725e9bf4..19b73b28997f1 100644 --- a/apps/meteor/client/startup/incomingMessages.ts +++ b/apps/meteor/client/startup/incomingMessages.ts @@ -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; diff --git a/packages/apps-engine/deno-runtime/lib/accessors/notifier.ts b/packages/apps-engine/deno-runtime/lib/accessors/notifier.ts index 1a85cc12b579f..5d24a075565d5 100644 --- a/packages/apps-engine/deno-runtime/lib/accessors/notifier.ts +++ b/packages/apps-engine/deno-runtime/lib/accessors/notifier.ts @@ -31,6 +31,16 @@ export class Notifier implements INotifier { await this.callMessageBridge('doNotifyUser', [user, message, AppObjectRegistry.get('id')]); } + public async deleteNotifyUser(user: IUser, message: IMessage): Promise { + if (!message.sender || !message.sender.id) { + const appUser = await this.getAppUser(); + + message.sender = appUser; + } + + await this.callMessageBridge('doDeleteNotifyUser', [user, message, AppObjectRegistry.get('id')]); + } + public async notifyRoom(room: IRoom, message: IMessage): Promise { if (!message.sender || !message.sender.id) { const appUser = await this.getAppUser(); diff --git a/packages/apps-engine/src/definition/accessors/INotifier.ts b/packages/apps-engine/src/definition/accessors/INotifier.ts index cf776fb55cda5..9c2450bf904ec 100644 --- a/packages/apps-engine/src/definition/accessors/INotifier.ts +++ b/packages/apps-engine/src/definition/accessors/INotifier.ts @@ -40,6 +40,14 @@ export interface INotifier { */ notifyUser(user: IUser, message: IMessage): Promise; + /** + * 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; + /** * Notifies all of the users in the provided room. * diff --git a/packages/apps-engine/src/server/accessors/Notifier.ts b/packages/apps-engine/src/server/accessors/Notifier.ts index ff3d97070df06..6de3ee7b7dd69 100644 --- a/packages/apps-engine/src/server/accessors/Notifier.ts +++ b/packages/apps-engine/src/server/accessors/Notifier.ts @@ -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 { if (!message.sender?.id) { @@ -24,6 +24,16 @@ export class Notifier implements INotifier { await this.msgBridge.doNotifyUser(user, message, this.appId); } + public async deleteNotifyUser(user: IUser, message: IMessage): Promise { + 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 notifyRoom(room: IRoom, message: IMessage): Promise { if (!message.sender?.id) { const appUser = await this.userBridge.doGetAppUser(this.appId); diff --git a/packages/apps-engine/src/server/bridges/MessageBridge.ts b/packages/apps-engine/src/server/bridges/MessageBridge.ts index e1d437339cb10..ea0c422effe59 100644 --- a/packages/apps-engine/src/server/bridges/MessageBridge.ts +++ b/packages/apps-engine/src/server/bridges/MessageBridge.ts @@ -66,6 +66,12 @@ export abstract class MessageBridge extends BaseBridge { } } + public async doDeleteNotifyUser(user: IUser, message: IMessage, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.deleteNotifyUser(user, message, appId); + } + } + protected abstract create(message: IMessage, appId: string): Promise; protected abstract update(message: IMessage, appId: string): Promise; @@ -84,6 +90,8 @@ export abstract class MessageBridge extends BaseBridge { protected abstract removeReaction(messageId: string, userId: string, reaction: Reaction): Promise; + protected abstract deleteNotifyUser(user: IUser, message: IMessage, appId: string): Promise; + private hasReadPermission(appId: string): boolean { if (AppPermissionManager.hasPermission(appId, AppPermissions.message.read)) { return true; diff --git a/packages/apps-engine/tests/test-data/bridges/messageBridge.ts b/packages/apps-engine/tests/test-data/bridges/messageBridge.ts index f15b7e0e903f0..6635a55bfd10f 100644 --- a/packages/apps-engine/tests/test-data/bridges/messageBridge.ts +++ b/packages/apps-engine/tests/test-data/bridges/messageBridge.ts @@ -25,6 +25,10 @@ export class TestsMessageBridge extends MessageBridge { throw new Error('Method not implemented.'); } + public deleteNotifyUser(user: IUser, message: IMessage, appId: string): Promise { + throw new Error('Method not implemented.'); + } + public delete(message: IMessage, user: IUser, appId: string): Promise { throw new Error('Method not implemented.'); } From 0e702e5e74672d425122b4e056ff3a1adc4b8d15 Mon Sep 17 00:00:00 2001 From: yush-1018 Date: Fri, 20 Feb 2026 15:03:47 +0530 Subject: [PATCH 2/3] Implement deleteNotifyUser and deleteNotifyRoom APIs for ephemeral messages --- ExampleApp.ts | 72 +++++++++++++++++++ .../app/apps/server/bridges/messages.ts | 21 ++++++ .../src/definition/accessors/INotifier.ts | 8 +++ .../src/server/accessors/Notifier.ts | 10 +++ .../src/server/bridges/MessageBridge.ts | 8 +++ .../tests/server/accessors/Notifier.spec.ts | 8 +++ .../tests/test-data/bridges/messageBridge.ts | 4 ++ 7 files changed, 131 insertions(+) create mode 100644 ExampleApp.ts diff --git a/ExampleApp.ts b/ExampleApp.ts new file mode 100644 index 0000000000000..cf5688d62c73f --- /dev/null +++ b/ExampleApp.ts @@ -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 { + 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 { + 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 = 'test-delete-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); + } +} diff --git a/apps/meteor/app/apps/server/bridges/messages.ts b/apps/meteor/app/apps/server/bridges/messages.ts index 93a332ed15d95..024a00edc6124 100644 --- a/apps/meteor/app/apps/server/bridges/messages.ts +++ b/apps/meteor/app/apps/server/bridges/messages.ts @@ -97,6 +97,27 @@ export class AppMessageBridge extends MessageBridge { }); } + protected async deleteNotifyRoom(room: IRoom, message: IAppsMessage, appId: string): Promise { + 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 { this.orch.debugLog(`The App ${appId} is notifying a room's users.`); diff --git a/packages/apps-engine/src/definition/accessors/INotifier.ts b/packages/apps-engine/src/definition/accessors/INotifier.ts index 9c2450bf904ec..5f08d86be10ea 100644 --- a/packages/apps-engine/src/definition/accessors/INotifier.ts +++ b/packages/apps-engine/src/definition/accessors/INotifier.ts @@ -48,6 +48,14 @@ export interface INotifier { */ deleteNotifyUser(user: IUser, message: IMessage): Promise; + /** + * 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; + /** * Notifies all of the users in the provided room. * diff --git a/packages/apps-engine/src/server/accessors/Notifier.ts b/packages/apps-engine/src/server/accessors/Notifier.ts index 6de3ee7b7dd69..2dcb3aeae3fa1 100644 --- a/packages/apps-engine/src/server/accessors/Notifier.ts +++ b/packages/apps-engine/src/server/accessors/Notifier.ts @@ -34,6 +34,16 @@ export class Notifier implements INotifier { await this.msgBridge.doDeleteNotifyUser(user, message, this.appId); } + public async deleteNotifyRoom(room: IRoom, message: IMessage): Promise { + 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 { if (!message.sender?.id) { const appUser = await this.userBridge.doGetAppUser(this.appId); diff --git a/packages/apps-engine/src/server/bridges/MessageBridge.ts b/packages/apps-engine/src/server/bridges/MessageBridge.ts index ea0c422effe59..0c04ef4367631 100644 --- a/packages/apps-engine/src/server/bridges/MessageBridge.ts +++ b/packages/apps-engine/src/server/bridges/MessageBridge.ts @@ -72,6 +72,12 @@ export abstract class MessageBridge extends BaseBridge { } } + public async doDeleteNotifyRoom(room: IRoom, message: IMessage, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.deleteNotifyRoom(room, message, appId); + } + } + protected abstract create(message: IMessage, appId: string): Promise; protected abstract update(message: IMessage, appId: string): Promise; @@ -92,6 +98,8 @@ export abstract class MessageBridge extends BaseBridge { protected abstract deleteNotifyUser(user: IUser, message: IMessage, appId: string): Promise; + protected abstract deleteNotifyRoom(room: IRoom, message: IMessage, appId: string): Promise; + private hasReadPermission(appId: string): boolean { if (AppPermissionManager.hasPermission(appId, AppPermissions.message.read)) { return true; diff --git a/packages/apps-engine/tests/server/accessors/Notifier.spec.ts b/packages/apps-engine/tests/server/accessors/Notifier.spec.ts index f2f57075e8fb8..c9b0ed32a1683 100644 --- a/packages/apps-engine/tests/server/accessors/Notifier.spec.ts +++ b/packages/apps-engine/tests/server/accessors/Notifier.spec.ts @@ -22,6 +22,12 @@ export class NotifierAccessorTestFixture { doNotifyRoom(room: IRoom, msg: IMessage, appId: string): Promise { return Promise.resolve(); }, + doDeleteNotifyUser(user: IUser, msg: IMessage, appId: string): Promise { + return Promise.resolve(); + }, + doDeleteNotifyRoom(room: IRoom, msg: IMessage, appId: string): Promise { + return Promise.resolve(); + }, } as MessageBridge; } @@ -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); } } diff --git a/packages/apps-engine/tests/test-data/bridges/messageBridge.ts b/packages/apps-engine/tests/test-data/bridges/messageBridge.ts index 6635a55bfd10f..4850cdd48e413 100644 --- a/packages/apps-engine/tests/test-data/bridges/messageBridge.ts +++ b/packages/apps-engine/tests/test-data/bridges/messageBridge.ts @@ -29,6 +29,10 @@ export class TestsMessageBridge extends MessageBridge { throw new Error('Method not implemented.'); } + public deleteNotifyRoom(room: IRoom, message: IMessage, appId: string): Promise { + throw new Error('Method not implemented.'); + } + public delete(message: IMessage, user: IUser, appId: string): Promise { throw new Error('Method not implemented.'); } From b4f75b0b2fbb5070b56cf387ce18041fe3ce3f29 Mon Sep 17 00:00:00 2001 From: yush-1018 Date: Mon, 23 Feb 2026 02:14:03 +0530 Subject: [PATCH 3/3] feat: use robust composite key for ephemeral message IDs in example app --- ExampleApp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ExampleApp.ts b/ExampleApp.ts index cf5688d62c73f..8ba180c1ef220 100644 --- a/ExampleApp.ts +++ b/ExampleApp.ts @@ -58,7 +58,7 @@ class TestDeleteCommand implements ISlashCommand { // 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 = 'test-delete-id-' + Date.now(); + message.id = user.id + '-' + Date.now(); // 2. Notify the user await notifier.notifyUser(user, message);