From a7d27ee4828629b006db5bbd24b615abdc906343 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:04:36 +0000 Subject: [PATCH 1/5] Add hasListeners() to AppListenerManager and early-return guard in AppListenerBridge.handleEvent() Co-authored-by: d-gubert <1810309+d-gubert@users.noreply.github.com> --- apps/meteor/.mocharc.js | 1 + .../app/apps/server/bridges/listeners.spec.ts | 67 +++++++++++++++++++ .../app/apps/server/bridges/listeners.ts | 5 ++ .../src/server/managers/AppListenerManager.ts | 7 ++ .../managers/AppListenerManager.spec.ts | 49 ++++++++++++++ 5 files changed, 129 insertions(+) create mode 100644 apps/meteor/app/apps/server/bridges/listeners.spec.ts diff --git a/apps/meteor/.mocharc.js b/apps/meteor/.mocharc.js index 11d408b22a601..ad7024435ceab 100644 --- a/apps/meteor/.mocharc.js +++ b/apps/meteor/.mocharc.js @@ -32,5 +32,6 @@ module.exports = { 'app/livechat/server/lib/**/*.spec.ts', 'app/push/server/**/*.spec.ts', 'app/utils/server/**/*.spec.ts', + 'app/apps/server/bridges/listeners.spec.ts', ], }; diff --git a/apps/meteor/app/apps/server/bridges/listeners.spec.ts b/apps/meteor/app/apps/server/bridges/listeners.spec.ts new file mode 100644 index 0000000000000..2de212c446bcd --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/listeners.spec.ts @@ -0,0 +1,67 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; + +import { AppInterface } from '@rocket.chat/apps-engine/definition/metadata'; + +import { AppListenerBridge } from './listeners'; + +describe('AppListenerBridge', () => { + let bridge: AppListenerBridge; + let listenerManager: { hasListeners: sinon.SinonStub; executeListener: sinon.SinonStub }; + let messageConverter: { convertMessage: sinon.SinonStub; convertToApp: sinon.SinonStub; convertAppMessage: sinon.SinonStub }; + let orch: any; + + beforeEach(() => { + listenerManager = { + hasListeners: sinon.stub(), + executeListener: sinon.stub(), + }; + + messageConverter = { + convertMessage: sinon.stub().resolves({ id: 'converted-msg' }), + convertToApp: sinon.stub().returns({}), + convertAppMessage: sinon.stub().resolves({ _id: 'final-msg' }), + }; + + orch = { + getManager: sinon.stub().returns({ + getListenerManager: sinon.stub().returns(listenerManager), + }), + getConverters: sinon.stub().returns({ + get: sinon.stub().returns(messageConverter), + }), + }; + + bridge = new AppListenerBridge(orch); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('handleEvent', () => { + it('should return undefined and skip conversion when there are no listeners', async () => { + listenerManager.hasListeners.returns(false); + + const result = await bridge.handleEvent({ + event: AppInterface.IPostMessageSent, + payload: [{ _id: 'msg-id', rid: 'room-id', msg: 'hello' } as any], + } as any); + + expect(result).to.be.undefined; + expect(messageConverter.convertMessage.called).to.be.false; + }); + + it('should proceed to conversion and execution when there are listeners', async () => { + listenerManager.hasListeners.returns(true); + listenerManager.executeListener.resolves(undefined); + + await bridge.handleEvent({ + event: AppInterface.IPostMessageSent, + payload: [{ _id: 'msg-id', rid: 'room-id', msg: 'hello' } as any], + } as any); + + expect(messageConverter.convertMessage.calledOnce).to.be.true; + }); + }); +}); diff --git a/apps/meteor/app/apps/server/bridges/listeners.ts b/apps/meteor/app/apps/server/bridges/listeners.ts index 6c3b97e81f0a4..4a253ffb1176d 100644 --- a/apps/meteor/app/apps/server/bridges/listeners.ts +++ b/apps/meteor/app/apps/server/bridges/listeners.ts @@ -187,6 +187,11 @@ export class AppListenerBridge { // eslint-disable-next-line complexity async handleEvent(args: HandleEvent): Promise { + const listenerManager = this.orch.getManager().getListenerManager(); + if (!listenerManager.hasListeners(args.event as AppInterface)) { + return undefined; + } + switch (args.event) { case AppInterface.IPreFileUpload: return this.uploadEvent(args); diff --git a/packages/apps-engine/src/server/managers/AppListenerManager.ts b/packages/apps-engine/src/server/managers/AppListenerManager.ts index 327bc949ba0ac..6b5fea0a843f5 100644 --- a/packages/apps-engine/src/server/managers/AppListenerManager.ts +++ b/packages/apps-engine/src/server/managers/AppListenerManager.ts @@ -339,6 +339,13 @@ export class AppListenerManager { return !!lockedEventList?.size; } + public hasListeners(event: AppInterface): boolean { + if (this.isEventBlocked(event)) { + return true; + } + return (this.listeners.get(event)?.length ?? 0) > 0; + } + /* eslint-disable-next-line complexity */ public async executeListener( int: I, diff --git a/packages/apps-engine/tests/server/managers/AppListenerManager.spec.ts b/packages/apps-engine/tests/server/managers/AppListenerManager.spec.ts index b1fed6035d274..e6fcfd733d2dc 100644 --- a/packages/apps-engine/tests/server/managers/AppListenerManager.spec.ts +++ b/packages/apps-engine/tests/server/managers/AppListenerManager.spec.ts @@ -41,4 +41,53 @@ export class AppListenerManagerTestFixture { Expect(() => alm.registerListeners(this.mockApp)).not.toThrow(); Expect(alm.getListeners(AppInterface.IPostMessageSent).length).toBe(1); } + + @Test() + public hasListenersReturnsFalseWhenNoListeners() { + const alm = new AppListenerManager(this.mockManager); + + Expect(alm.hasListeners(AppInterface.IPostMessageSent)).toBe(false); + } + + @Test() + public hasListenersReturnsTrueAfterRegisterListeners() { + const alm = new AppListenerManager(this.mockManager); + + alm.registerListeners(this.mockApp); + + Expect(alm.hasListeners(AppInterface.IPostMessageSent)).toBe(true); + } + + @Test() + public hasListenersReturnsFalseAfterUnregisterListeners() { + const alm = new AppListenerManager(this.mockManager); + + alm.registerListeners(this.mockApp); + alm.unregisterListeners(this.mockApp); + + Expect(alm.hasListeners(AppInterface.IPostMessageSent)).toBe(false); + } + + @Test() + public hasListenersReturnsTrueForBlockedEvent() { + const alm = new AppListenerManager(this.mockManager); + + const mockEssentialApp = { + getID() { + return 'essential-app'; + }, + getImplementationList() { + return { + [AppInterface.IPostMessageSent]: true, + } as { [inte: string]: boolean }; + }, + getEssentials() { + return [AppInterface.IPostMessageSent]; + }, + } as ProxiedApp; + + alm.lockEssentialEvents(mockEssentialApp); + + Expect(alm.hasListeners(AppInterface.IPostMessageSent)).toBe(true); + } } From d0460b51e0a42e8b5e0f1af916151b290741b9c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:13:49 +0000 Subject: [PATCH 2/5] Use glob pattern for apps spec files in mocharc.js Co-authored-by: d-gubert <1810309+d-gubert@users.noreply.github.com> --- apps/meteor/.mocharc.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/.mocharc.js b/apps/meteor/.mocharc.js index ad7024435ceab..ea04094781313 100644 --- a/apps/meteor/.mocharc.js +++ b/apps/meteor/.mocharc.js @@ -32,6 +32,6 @@ module.exports = { 'app/livechat/server/lib/**/*.spec.ts', 'app/push/server/**/*.spec.ts', 'app/utils/server/**/*.spec.ts', - 'app/apps/server/bridges/listeners.spec.ts', + 'app/apps/**/**.spec.ts', ], }; From 6396bc16bc3faf24657799b60172628599d69009 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:54:10 +0000 Subject: [PATCH 3/5] Fix import order in listeners.spec.ts and convert Jest assertions to Chai in router.spec.ts Co-authored-by: d-gubert <1810309+d-gubert@users.noreply.github.com> --- apps/meteor/app/apps/server/bridges/listeners.spec.ts | 3 +-- apps/meteor/app/apps/server/bridges/router.spec.ts | 9 +++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/meteor/app/apps/server/bridges/listeners.spec.ts b/apps/meteor/app/apps/server/bridges/listeners.spec.ts index 2de212c446bcd..d353d88b9f593 100644 --- a/apps/meteor/app/apps/server/bridges/listeners.spec.ts +++ b/apps/meteor/app/apps/server/bridges/listeners.spec.ts @@ -1,8 +1,7 @@ +import { AppInterface } from '@rocket.chat/apps-engine/definition/metadata'; import { expect } from 'chai'; import sinon from 'sinon'; -import { AppInterface } from '@rocket.chat/apps-engine/definition/metadata'; - import { AppListenerBridge } from './listeners'; describe('AppListenerBridge', () => { diff --git a/apps/meteor/app/apps/server/bridges/router.spec.ts b/apps/meteor/app/apps/server/bridges/router.spec.ts index 61ebe714a2acd..c01ca7ca6d0ac 100644 --- a/apps/meteor/app/apps/server/bridges/router.spec.ts +++ b/apps/meteor/app/apps/server/bridges/router.spec.ts @@ -1,3 +1,4 @@ +import { expect } from 'chai'; import request from 'supertest'; import { apiServer } from './router'; @@ -31,7 +32,7 @@ describe('API Server Routes', () => { .expect('Content-Type', /json/) .expect(200) .then((res) => { - expect(res.body).toEqual({ + expect(res.body).to.deep.equal({ body: { key: 'value' }, params: { appId, hash }, query: {}, @@ -49,7 +50,7 @@ describe('API Server Routes', () => { .expect('Content-Type', /json/) .expect(200) .then((res) => { - expect(res.body).toEqual({ + expect(res.body).to.deep.equal({ body: { key: 'value' }, params: { appId, hash }, query: {}, @@ -68,7 +69,7 @@ describe('API Server Routes', () => { .expect('Content-Type', /json/) .expect(200) .then((res) => { - expect(res.body).toEqual({ + expect(res.body).to.deep.equal({ body: { key: 'value' }, params: { appId }, query: {}, @@ -85,7 +86,7 @@ describe('API Server Routes', () => { .expect('Content-Type', /json/) .expect(200) .then((res) => { - expect(res.body).toEqual({ + expect(res.body).to.deep.equal({ body: { key: 'value' }, params: { appId }, query: {}, From 94271ae2b72df482a30d755fe266683f052e9dfb Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Thu, 12 Mar 2026 17:53:23 -0300 Subject: [PATCH 4/5] test: remove bail to see all possible failing tests --- apps/meteor/.mocharc.api.js | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/meteor/.mocharc.api.js b/apps/meteor/.mocharc.api.js index 6bccb8c9a06df..b73ef82a9dc47 100644 --- a/apps/meteor/.mocharc.api.js +++ b/apps/meteor/.mocharc.api.js @@ -7,7 +7,6 @@ module.exports = /** @satisfies {import('mocha').MochaOptions} */ ({ ...require('./.mocharc.base.json'), // see https://github.com/mochajs/mocha/issues/3916 timeout: 10000, - bail: true, retries: 0, file: 'tests/end-to-end/teardown.ts', spec: ['tests/end-to-end/api/*.ts', 'tests/end-to-end/api/helpers/**/*', 'tests/end-to-end/api/methods/**/*', 'tests/end-to-end/apps/*'], From 0567303626e99e49f6ea4d87cfcd46b7302aeb4d Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Mon, 13 Apr 2026 12:05:13 -0300 Subject: [PATCH 5/5] wip --- .../app/apps/server/bridges/listeners.ts | 124 ++++++++++-------- 1 file changed, 66 insertions(+), 58 deletions(-) diff --git a/apps/meteor/app/apps/server/bridges/listeners.ts b/apps/meteor/app/apps/server/bridges/listeners.ts index 4a253ffb1176d..eaffc4b3bc6f0 100644 --- a/apps/meteor/app/apps/server/bridges/listeners.ts +++ b/apps/meteor/app/apps/server/bridges/listeners.ts @@ -12,6 +12,7 @@ import type { UIKitIncomingInteraction } from '@rocket.chat/apps-engine/definiti import type { IUIKitLivechatIncomingInteraction } from '@rocket.chat/apps-engine/definition/uikit/livechat'; import type { IUserContext, IUserUpdateContext } from '@rocket.chat/apps-engine/definition/users'; import type { IMessage, IRoom, IUser, ILivechatDepartment, IUpload } from '@rocket.chat/core-typings'; +import { inspect } from 'util'; type LivechatTransferData = { type: LivechatTransferEventType; @@ -185,69 +186,76 @@ type HandleEvent = export class AppListenerBridge { constructor(private readonly orch: IAppServerOrchestrator) {} - // eslint-disable-next-line complexity async handleEvent(args: HandleEvent): Promise { const listenerManager = this.orch.getManager().getListenerManager(); - if (!listenerManager.hasListeners(args.event as AppInterface)) { + + const execute = () => { + switch (args.event) { + case AppInterface.IPreFileUpload: + return this.uploadEvent(args); + case AppInterface.IPostMessageDeleted: + case AppInterface.IPostMessageReacted: + case AppInterface.IPostMessageFollowed: + case AppInterface.IPostMessagePinned: + case AppInterface.IPostMessageStarred: + case AppInterface.IPostMessageReported: + case AppInterface.IPostSystemMessageSent: + case AppInterface.IPreMessageSentPrevent: + case AppInterface.IPreMessageSentExtend: + case AppInterface.IPreMessageSentModify: + case AppInterface.IPostMessageSent: + case AppInterface.IPreMessageDeletePrevent: + case AppInterface.IPreMessageUpdatedPrevent: + case AppInterface.IPreMessageUpdatedExtend: + case AppInterface.IPreMessageUpdatedModify: + case AppInterface.IPostMessageUpdated: + return this.messageEvent(args); + case AppInterface.IPreRoomCreatePrevent: + case AppInterface.IPreRoomCreateExtend: + case AppInterface.IPreRoomCreateModify: + case AppInterface.IPostRoomCreate: + case AppInterface.IPreRoomDeletePrevent: + case AppInterface.IPostRoomDeleted: + case AppInterface.IPreRoomUserJoined: + case AppInterface.IPostRoomUserJoined: + case AppInterface.IPreRoomUserLeave: + case AppInterface.IPostRoomUserLeave: + return this.roomEvent(args); + /** + * @deprecated please prefer the AppInterface.IPostLivechatRoomClosed event + */ + case AppInterface.ILivechatRoomClosedHandler: + case AppInterface.IPreLivechatRoomCreatePrevent: + case AppInterface.IPostLivechatRoomStarted: + case AppInterface.IPostLivechatRoomClosed: + case AppInterface.IPostLivechatAgentAssigned: + case AppInterface.IPostLivechatAgentUnassigned: + case AppInterface.IPostLivechatRoomTransferred: + case AppInterface.IPostLivechatGuestSaved: + case AppInterface.IPostLivechatRoomSaved: + case AppInterface.IPostLivechatDepartmentRemoved: + case AppInterface.IPostLivechatDepartmentDisabled: + return this.livechatEvent(args); + case AppInterface.IPostUserCreated: + case AppInterface.IPostUserUpdated: + case AppInterface.IPostUserDeleted: + case AppInterface.IPostUserLoggedIn: + case AppInterface.IPostUserLoggedOut: + case AppInterface.IPostUserStatusChanged: + return this.userEvent(args); + default: + return this.defaultEvent(args); + } + }; + + const result = await execute(); + + if (!listenerManager.hasListeners(args.event)) { + args.event.includes('Message') && console.debug(inspect({ args, result }, false, 10)); return undefined; } - switch (args.event) { - case AppInterface.IPreFileUpload: - return this.uploadEvent(args); - case AppInterface.IPostMessageDeleted: - case AppInterface.IPostMessageReacted: - case AppInterface.IPostMessageFollowed: - case AppInterface.IPostMessagePinned: - case AppInterface.IPostMessageStarred: - case AppInterface.IPostMessageReported: - case AppInterface.IPostSystemMessageSent: - case AppInterface.IPreMessageSentPrevent: - case AppInterface.IPreMessageSentExtend: - case AppInterface.IPreMessageSentModify: - case AppInterface.IPostMessageSent: - case AppInterface.IPreMessageDeletePrevent: - case AppInterface.IPreMessageUpdatedPrevent: - case AppInterface.IPreMessageUpdatedExtend: - case AppInterface.IPreMessageUpdatedModify: - case AppInterface.IPostMessageUpdated: - return this.messageEvent(args); - case AppInterface.IPreRoomCreatePrevent: - case AppInterface.IPreRoomCreateExtend: - case AppInterface.IPreRoomCreateModify: - case AppInterface.IPostRoomCreate: - case AppInterface.IPreRoomDeletePrevent: - case AppInterface.IPostRoomDeleted: - case AppInterface.IPreRoomUserJoined: - case AppInterface.IPostRoomUserJoined: - case AppInterface.IPreRoomUserLeave: - case AppInterface.IPostRoomUserLeave: - return this.roomEvent(args); - /** - * @deprecated please prefer the AppInterface.IPostLivechatRoomClosed event - */ - case AppInterface.ILivechatRoomClosedHandler: - case AppInterface.IPreLivechatRoomCreatePrevent: - case AppInterface.IPostLivechatRoomStarted: - case AppInterface.IPostLivechatRoomClosed: - case AppInterface.IPostLivechatAgentAssigned: - case AppInterface.IPostLivechatAgentUnassigned: - case AppInterface.IPostLivechatRoomTransferred: - case AppInterface.IPostLivechatGuestSaved: - case AppInterface.IPostLivechatRoomSaved: - case AppInterface.IPostLivechatDepartmentRemoved: - case AppInterface.IPostLivechatDepartmentDisabled: - return this.livechatEvent(args); - case AppInterface.IPostUserCreated: - case AppInterface.IPostUserUpdated: - case AppInterface.IPostUserDeleted: - case AppInterface.IPostUserLoggedIn: - case AppInterface.IPostUserLoggedOut: - case AppInterface.IPostUserStatusChanged: - return this.userEvent(args); - default: - return this.defaultEvent(args); - } + return result; } async defaultEvent(args: HandleDefaultEvent): Promise {