diff --git a/.changeset/quick-impalas-pump.md b/.changeset/quick-impalas-pump.md new file mode 100644 index 0000000000000..71a0718491dca --- /dev/null +++ b/.changeset/quick-impalas-pump.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/apps-engine': patch +'@rocket.chat/meteor': patch +--- + +Fixes the issue of the lacking MessageUpdater not being available to apps during runtime diff --git a/apps/meteor/app/reactions/server/setReaction.ts b/apps/meteor/app/reactions/server/setReaction.ts index 483b1fff8ed7a..1a6d8dfa537f6 100644 --- a/apps/meteor/app/reactions/server/setReaction.ts +++ b/apps/meteor/app/reactions/server/setReaction.ts @@ -119,8 +119,7 @@ export async function executeSetReaction( throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setReaction' }); } - const userAlreadyReacted = - message.reactions && Boolean(message.reactions[reaction]) && message.reactions[reaction].usernames.includes(user.username as string); + const userAlreadyReacted = Boolean(message.reactions?.[reaction]?.usernames?.includes(user.username as string)); // When shouldReact was not informed, toggle the reaction. if (shouldReact === undefined) { diff --git a/apps/meteor/tests/data/apps/app-packages/README.md b/apps/meteor/tests/data/apps/app-packages/README.md index 5af84e4ab31c8..5fa01c3d040f1 100644 --- a/apps/meteor/tests/data/apps/app-packages/README.md +++ b/apps/meteor/tests/data/apps/app-packages/README.md @@ -440,3 +440,47 @@ export class UpdateStatusTextEndpoint extends ApiEndpoint { ``` + +#### Message Reaction Test + +File name: `message-updater-test_0.0.1.zip` + +An app used to test the message reaction updates. Provides a `/msg-update` slashcommand that takes an action of `'add' | 'remove'` and a message id, then adds or removes a reaction in the target message accordingly using the App's bot user. + +
+App source code + +```typescript +export class MessageUpdaterTestApp extends App { + protected async extendConfiguration(configuration: IConfigurationExtend, _environmentRead: IEnvironmentRead): Promise { + await configuration.slashCommands.provideSlashCommand(new class UpdateCommand implements ISlashCommand { + command = 'msg-update'; + i18nDescription = 'msg-update'; + i18nParamsExample = 'msg-update'; + providesPreview = false; + + constructor(private readonly app: App) { } + + public async executor(context: SlashCommandContext, read: IRead, modify: IModify, _http: IHttp, _persis: IPersistence) { + const [action, msgId] = context.getArguments() as ['add' | 'remove', string]; + + const user = await read.getUserReader().getAppUser(); + + if (!user) { + this.app.getLogger().error(`Couldn't find app user`); + return; + } + + if (action === 'add') { + await modify.getUpdater().getMessageUpdater().addReaction(msgId, user.id, ':+1:'); + this.app.getLogger().debug(`Added reaction 👍 to message ${msgId}`); + } else { + await modify.getUpdater().getMessageUpdater().removeReaction(msgId, user.id, ':+1:'); + this.app.getLogger().debug(`Removed reaction 👍 from message ${msgId}`); + } + } + }(this)); + } +} +``` +
diff --git a/apps/meteor/tests/data/apps/app-packages/index.ts b/apps/meteor/tests/data/apps/app-packages/index.ts index 580e7700ffeac..00e941a06fc34 100644 --- a/apps/meteor/tests/data/apps/app-packages/index.ts +++ b/apps/meteor/tests/data/apps/app-packages/index.ts @@ -9,3 +9,5 @@ export const appCausingNestedRequests = path.resolve(__dirname, './nested-reques export const appUpdateStatusTest = path.resolve(__dirname, './update-status-test_0.0.1.zip'); export const appExternalIdTest = path.resolve(__dirname, './external-id-test_0.0.1.zip'); + +export const messageReactionTest = path.resolve(__dirname, './message-updater-test_0.0.1.zip'); diff --git a/apps/meteor/tests/data/apps/app-packages/message-updater-test_0.0.1.zip b/apps/meteor/tests/data/apps/app-packages/message-updater-test_0.0.1.zip new file mode 100644 index 0000000000000..cf9bd96cb65c2 Binary files /dev/null and b/apps/meteor/tests/data/apps/app-packages/message-updater-test_0.0.1.zip differ diff --git a/apps/meteor/tests/end-to-end/api/rooms.ts b/apps/meteor/tests/end-to-end/api/rooms.ts index 96d9385569030..efc96c1ef552a 100644 --- a/apps/meteor/tests/end-to-end/api/rooms.ts +++ b/apps/meteor/tests/end-to-end/api/rooms.ts @@ -22,7 +22,13 @@ import { sleep } from '../../../lib/utils/sleep'; import { getCredentials, api, request, credentials } from '../../data/api-data'; import { sendSimpleMessage, deleteMessage } from '../../data/chat.helper'; import { imgURL } from '../../data/interactions'; -import { getSettingValueById, updateEEPermission, updatePermission, updateSetting } from '../../data/permissions.helper'; +import { + getSettingValueById, + restorePermissionToRoles, + updateEEPermission, + updatePermission, + updateSetting, +} from '../../data/permissions.helper'; import { assignRoleToUser, createCustomRole, deleteCustomRole } from '../../data/roles.helper'; import { createRoom, deleteRoom } from '../../data/rooms.helper'; import { createTeam, deleteTeam } from '../../data/teams.helper'; @@ -3939,7 +3945,11 @@ describe('[Rooms]', () => { deleteRoom({ type: 'c', roomId: publicChannelInPrivateTeam._id }), ]); - await Promise.all([deleteTeam(credentials, publicTeam.name), deleteTeam(credentials, privateTeam.name)]); + await Promise.all([ + deleteTeam(credentials, publicTeam.name), + deleteTeam(credentials, privateTeam.name), + restorePermissionToRoles('view-c-room'), + ]); await Promise.all([deleteUser(outsiderUser), deleteUser(insideUser), deleteUser(nonTeamUser)]); }); diff --git a/apps/meteor/tests/end-to-end/apps/app-message-reactions.ts b/apps/meteor/tests/end-to-end/apps/app-message-reactions.ts new file mode 100644 index 0000000000000..68c44c2b067e5 --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/app-message-reactions.ts @@ -0,0 +1,49 @@ +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials } from '../../data/api-data'; +import { messageReactionTest } from '../../data/apps/app-packages'; +import { cleanupApps, installLocalTestPackage } from '../../data/apps/helper'; +import { sendSimpleMessage, getMessageById } from '../../data/chat.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; +import { executeAppSlashCommand } from '../../data/slashcommands.helpers'; +import { IS_EE } from '../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('Apps - Message Reactions', () => { + let roomId: string; + + before((done) => getCredentials(done)); + + before(async () => { + await cleanupApps(); + await installLocalTestPackage(messageReactionTest); + + const res = await createRoom({ type: 'c', name: `apps-reactions-test-${Date.now()}` }); + roomId = res.body.channel._id; + }); + + after(async () => { + await deleteRoom({ type: 'c', roomId }); + await cleanupApps(); + }); + + it('should add and remove a thumbs-up reaction to a message', async () => { + const sendRes = await sendSimpleMessage({ roomId, text: 'reaction test message' }); + const messageId = sendRes.body.message._id; + + const slashRes = await executeAppSlashCommand('msg-update', roomId, `add ${messageId}`); + expect(slashRes.status, 'Slash command to add reaction failed').to.equal(200); + + let message = await getMessageById({ msgId: messageId }); + expect(message.reactions, 'Message reactions should exist after adding').to.exist; + expect(message.reactions, 'Message should have a thumbs-up reaction').to.have.property(':+1:'); + + // Now remove the reaction + const removeRes = await executeAppSlashCommand('msg-update', roomId, `remove ${messageId}`); + expect(removeRes.status, 'Slash command to remove reaction failed').to.equal(200); + + message = await getMessageById({ msgId: messageId }); + const hasThumbsUp = Boolean(message.reactions && ':+1:' in message.reactions); + expect(hasThumbsUp, 'Thumbs-up reaction should have been removed').to.be.false; + }); +}); diff --git a/packages/apps-engine/deno-runtime/lib/accessors/modify/ModifyUpdater.ts b/packages/apps-engine/deno-runtime/lib/accessors/modify/ModifyUpdater.ts index dc9178be1f52d..301437627de18 100644 --- a/packages/apps-engine/deno-runtime/lib/accessors/modify/ModifyUpdater.ts +++ b/packages/apps-engine/deno-runtime/lib/accessors/modify/ModifyUpdater.ts @@ -1,6 +1,7 @@ import type { IModifyUpdater } from '@rocket.chat/apps-engine/definition/accessors/IModifyUpdater.ts'; import type { ILivechatUpdater } from '@rocket.chat/apps-engine/definition/accessors/ILivechatUpdater.ts'; import type { IUserUpdater } from '@rocket.chat/apps-engine/definition/accessors/IUserUpdater.ts'; +import type { IMessageUpdater } from '@rocket.chat/apps-engine/definition/accessors/IMessageUpdater.ts'; import type { IMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/IMessageBuilder.ts'; import type { IRoomBuilder } from '@rocket.chat/apps-engine/definition/accessors/IRoomBuilder.ts'; import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser.ts'; @@ -27,11 +28,19 @@ const { RocketChatAssociationModel } = require('@rocket.chat/apps-engine/definit }; export class ModifyUpdater implements IModifyUpdater { - constructor(private readonly senderFn: typeof Messenger.sendRequest) {} + private readonly livechatUpdater: ILivechatUpdater; + private readonly userUpdater: IUserUpdater; + private readonly messageUpdater: IMessageUpdater; + + constructor(private readonly senderFn: typeof Messenger.sendRequest) { + this.livechatUpdater = this.proxify('getLivechatUpdater'); + this.userUpdater = this.proxify('getUserUpdater'); + this.messageUpdater = this.proxify('getMessageUpdater'); + } - public getLivechatUpdater(): ILivechatUpdater { + private proxify(target: 'getLivechatUpdater' | 'getUserUpdater' | 'getMessageUpdater'): T { return new Proxy( - { __kind: 'getLivechatUpdater' }, + { __kind: target }, { get: (_target: unknown, prop: string) => @@ -39,7 +48,7 @@ export class ModifyUpdater implements IModifyUpdater { prop === 'toJSON' ? {} : this.senderFn({ - method: `accessor:getModifier:getUpdater:getLivechatUpdater:${prop}`, + method: `accessor:getModifier:getUpdater:${target}:${prop}`, params, }) .then((response) => response.result) @@ -47,28 +56,19 @@ export class ModifyUpdater implements IModifyUpdater { throw formatErrorResponse(err); }), }, - ) as ILivechatUpdater; + ) as T; + } + + public getLivechatUpdater(): ILivechatUpdater { + return this.livechatUpdater; } public getUserUpdater(): IUserUpdater { - return new Proxy( - { __kind: 'getUserUpdater' }, - { - get: - (_target: unknown, prop: string) => - (...params: unknown[]) => - prop === 'toJSON' - ? {} - : this.senderFn({ - method: `accessor:getModifier:getUpdater:getUserUpdater:${prop}`, - params, - }) - .then((response) => response.result) - .catch((err) => { - throw formatErrorResponse(err); - }), - }, - ) as IUserUpdater; + return this.userUpdater; + } + + public getMessageUpdater(): IMessageUpdater { + return this.messageUpdater; } public async message(messageId: string, editor: IUser): Promise { diff --git a/packages/apps-engine/deno-runtime/lib/accessors/tests/ModifyUpdater.test.ts b/packages/apps-engine/deno-runtime/lib/accessors/tests/ModifyUpdater.test.ts index 487c10725028a..d351d6ebba721 100644 --- a/packages/apps-engine/deno-runtime/lib/accessors/tests/ModifyUpdater.test.ts +++ b/packages/apps-engine/deno-runtime/lib/accessors/tests/ModifyUpdater.test.ts @@ -128,6 +128,15 @@ describe('ModifyUpdater', () => { }); }); + it('correctly formats requests to MessageUpdater methods', async () => { + const result = (await modifyUpdater.getMessageUpdater().addReaction('message-id', 'user-id', ':smile:')) as any; + + assertEquals(result, { + method: 'accessor:getModifier:getUpdater:getMessageUpdater:addReaction', + params: ['message-id', 'user-id', ':smile:'], + }); + }); + describe('Error Handling', () => { describe('message', () => { it('throws an instance of Error when senderFn throws an error', async () => { diff --git a/packages/apps-engine/src/server/accessors/MessageUpdater.ts b/packages/apps-engine/src/server/accessors/MessageUpdater.ts new file mode 100644 index 0000000000000..218ebdf9b5840 --- /dev/null +++ b/packages/apps-engine/src/server/accessors/MessageUpdater.ts @@ -0,0 +1,18 @@ +import type { Reaction } from '../../definition/messages'; +import type { IMessageUpdater } from '../../definition/accessors/IMessageUpdater'; +import type { AppBridges } from '../bridges'; + +export class MessageUpdater implements IMessageUpdater { + constructor( + private readonly bridges: AppBridges, + private readonly appId: string, + ) {} + + public async addReaction(messageId: string, userId: string, reaction: Reaction): Promise { + return this.bridges.getMessageBridge().doAddReaction(messageId, userId, reaction, this.appId); + } + + public async removeReaction(messageId: string, userId: string, reaction: Reaction): Promise { + return this.bridges.getMessageBridge().doRemoveReaction(messageId, userId, reaction, this.appId); + } +} diff --git a/packages/apps-engine/src/server/accessors/ModifyUpdater.ts b/packages/apps-engine/src/server/accessors/ModifyUpdater.ts index 496e497860690..e06aad123d659 100644 --- a/packages/apps-engine/src/server/accessors/ModifyUpdater.ts +++ b/packages/apps-engine/src/server/accessors/ModifyUpdater.ts @@ -1,5 +1,6 @@ import { LivechatUpdater } from './LivechatUpdater'; import { MessageBuilder } from './MessageBuilder'; +import { MessageUpdater } from './MessageUpdater'; import { RoomBuilder } from './RoomBuilder'; import { UserUpdater } from './UserUpdater'; import type { ILivechatUpdater, IMessageBuilder, IMessageUpdater, IModifyUpdater, IRoomBuilder } from '../../definition/accessors'; @@ -23,6 +24,7 @@ export class ModifyUpdater implements IModifyUpdater { ) { this.livechatUpdater = new LivechatUpdater(this.bridges, this.appId); this.userUpdater = new UserUpdater(this.bridges, this.appId); + this.messageUpdater = new MessageUpdater(this.bridges, this.appId); } public getLivechatUpdater(): ILivechatUpdater {