diff --git a/ExampleApp.ts b/ExampleApp.ts new file mode 100644 index 0000000000000..8ba180c1ef220 --- /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 = 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); + } +} diff --git a/apps/meteor/app/apps/server/bridges/messages.ts b/apps/meteor/app/apps/server/bridges/messages.ts index 16366626c07c8..024a00edc6124 100644 --- a/apps/meteor/app/apps/server/bridges/messages.ts +++ b/apps/meteor/app/apps/server/bridges/messages.ts @@ -82,6 +82,42 @@ 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 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/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..5f08d86be10ea 100644 --- a/packages/apps-engine/src/definition/accessors/INotifier.ts +++ b/packages/apps-engine/src/definition/accessors/INotifier.ts @@ -40,6 +40,22 @@ 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; + + /** + * 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 ff3d97070df06..2dcb3aeae3fa1 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,26 @@ 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 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 e1d437339cb10..0c04ef4367631 100644 --- a/packages/apps-engine/src/server/bridges/MessageBridge.ts +++ b/packages/apps-engine/src/server/bridges/MessageBridge.ts @@ -66,6 +66,18 @@ 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); + } + } + + 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; @@ -84,6 +96,10 @@ 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; + + 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 f15b7e0e903f0..4850cdd48e413 100644 --- a/packages/apps-engine/tests/test-data/bridges/messageBridge.ts +++ b/packages/apps-engine/tests/test-data/bridges/messageBridge.ts @@ -25,6 +25,14 @@ 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 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.'); }