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 {