From 3b36fe4b3231e093c0a3d2253db989c4d58998f1 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Thu, 16 Apr 2026 13:15:54 -0300 Subject: [PATCH 01/12] feat(apps): copy apps-engine server code into @rocket.chat/apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copies src/server/ from @rocket.chat/apps-engine verbatim, then rewrites all relative definition/ imports to package imports (`@rocket.chat/apps-engine/definition/...`). apps-engine still contains its server code at this point — this is an additive copy only. The deletion happens in a later PR once @packages/apps is confirmed working independently. Co-Authored-By: Claude Sonnet 4.6 --- packages/apps/src/server/AppManager.ts | 1241 ++++++++++++++++ packages/apps/src/server/IGetAppsFilter.ts | 9 + packages/apps/src/server/ProxiedApp.ts | 162 +++ .../apps/src/server/accessors/ApiExtend.ts | 15 + .../apps/src/server/accessors/AppAccessors.ts | 40 + .../server/accessors/CloudWorkspaceRead.ts | 15 + .../server/accessors/ConfigurationExtend.ts | 26 + .../server/accessors/ConfigurationModify.ts | 14 + .../src/server/accessors/ContactCreator.ts | 25 + .../apps/src/server/accessors/ContactRead.ts | 15 + .../src/server/accessors/DiscussionBuilder.ts | 48 + .../apps/src/server/accessors/EmailCreator.ts | 15 + .../src/server/accessors/EnvironmentRead.ts | 26 + .../src/server/accessors/EnvironmentWrite.ts | 16 + .../accessors/EnvironmentalVariableRead.ts | 22 + .../src/server/accessors/ExperimentalRead.ts | 10 + .../accessors/ExternalComponentsExtend.ts | 16 + packages/apps/src/server/accessors/Http.ts | 78 + .../apps/src/server/accessors/HttpExtend.ts | 58 + .../src/server/accessors/LivechatCreator.ts | 43 + .../accessors/LivechatMessageBuilder.ts | 192 +++ .../apps/src/server/accessors/LivechatRead.ts | 79 + .../src/server/accessors/LivechatUpdater.ts | 36 + .../src/server/accessors/MessageBuilder.ts | 225 +++ .../src/server/accessors/MessageExtender.ts | 51 + .../apps/src/server/accessors/MessageRead.ts | 37 + .../src/server/accessors/MessageUpdater.ts | 19 + .../src/server/accessors/ModerationModify.ts | 24 + packages/apps/src/server/accessors/Modify.ts | 93 ++ .../src/server/accessors/ModifyCreator.ts | 275 ++++ .../src/server/accessors/ModifyDeleter.ts | 39 + .../src/server/accessors/ModifyExtender.ts | 55 + .../src/server/accessors/ModifyUpdater.ts | 118 ++ .../apps/src/server/accessors/Notifier.ts | 54 + .../src/server/accessors/OAuthAppsModify.ts | 23 + .../src/server/accessors/OAuthAppsReader.ts | 19 + .../OutboundCommunicationProviderExtend.ts | 22 + .../apps/src/server/accessors/Persistence.ts | 46 + .../src/server/accessors/PersistenceRead.ts | 23 + packages/apps/src/server/accessors/Reader.ts | 98 ++ .../apps/src/server/accessors/RoleRead.ts | 19 + .../apps/src/server/accessors/RoomBuilder.ts | 155 ++ .../apps/src/server/accessors/RoomExtender.ts | 57 + .../apps/src/server/accessors/RoomRead.ts | 112 ++ .../src/server/accessors/SchedulerExtend.ts | 15 + .../src/server/accessors/SchedulerModify.ts | 31 + .../src/server/accessors/ServerSettingRead.ts | 38 + .../server/accessors/ServerSettingUpdater.ts | 19 + .../server/accessors/ServerSettingsModify.ts | 27 + .../apps/src/server/accessors/SettingRead.ts | 26 + .../src/server/accessors/SettingUpdater.ts | 67 + .../src/server/accessors/SettingsExtend.ts | 27 + .../server/accessors/SlashCommandsExtend.ts | 15 + .../server/accessors/SlashCommandsModify.ts | 23 + .../apps/src/server/accessors/ThreadRead.ts | 15 + .../apps/src/server/accessors/UIController.ts | 126 ++ .../apps/src/server/accessors/UIExtend.ts | 15 + .../src/server/accessors/UploadCreator.ts | 29 + .../apps/src/server/accessors/UploadRead.ts | 25 + .../apps/src/server/accessors/UserBuilder.ts | 74 + .../apps/src/server/accessors/UserRead.ts | 31 + .../apps/src/server/accessors/UserUpdater.ts | 32 + .../accessors/VideoConfProviderExtend.ts | 15 + .../accessors/VideoConferenceBuilder.ts | 83 ++ .../server/accessors/VideoConferenceExtend.ts | 65 + .../server/accessors/VideoConferenceRead.ts | 15 + packages/apps/src/server/accessors/index.ts | 97 ++ packages/apps/src/server/bridges/ApiBridge.ts | 49 + .../src/server/bridges/AppActivationBridge.ts | 36 + .../apps/src/server/bridges/AppBridges.ts | 113 ++ .../server/bridges/AppDetailChangesBridge.ts | 17 + .../apps/src/server/bridges/BaseBridge.ts | 6 + .../server/bridges/CloudWorkspaceBridge.ts | 31 + .../apps/src/server/bridges/CommandBridge.ts | 118 ++ .../apps/src/server/bridges/ContactBridge.ts | 70 + .../apps/src/server/bridges/EmailBridge.ts | 31 + .../bridges/EnvironmentalVariableBridge.ts | 45 + .../src/server/bridges/ExperimentalBridge.ts | 10 + .../apps/src/server/bridges/HttpBridge.ts | 38 + .../src/server/bridges/IInternalBridge.ts | 7 + .../bridges/IInternalFederationBridge.ts | 15 + .../bridges/IInternalPersistenceBridge.ts | 9 + .../bridges/IInternalSchedulerBridge.ts | 8 + .../src/server/bridges/IInternalUserBridge.ts | 8 + .../src/server/bridges/IListenerBridge.ts | 13 + .../apps/src/server/bridges/InternalBridge.ts | 23 + .../apps/src/server/bridges/ListenerBridge.ts | 19 + .../apps/src/server/bridges/LivechatBridge.ts | 306 ++++ .../apps/src/server/bridges/MessageBridge.ts | 117 ++ .../src/server/bridges/ModerationBridge.ts | 48 + .../src/server/bridges/OAuthAppsBridge.ts | 86 ++ .../server/bridges/OutboundMessagesBridge.ts | 51 + .../src/server/bridges/PersistenceBridge.ts | 175 +++ .../apps/src/server/bridges/RoleBridge.ts | 39 + .../apps/src/server/bridges/RoomBridge.ts | 257 ++++ .../src/server/bridges/SchedulerBridge.ts | 63 + .../src/server/bridges/ServerSettingBridge.ts | 94 ++ .../apps/src/server/bridges/ThreadBridge.ts | 36 + .../src/server/bridges/UiInteractionBridge.ts | 32 + .../apps/src/server/bridges/UploadBridge.ts | 63 + .../apps/src/server/bridges/UserBridge.ts | 158 ++ .../server/bridges/VideoConferenceBridge.ts | 95 ++ packages/apps/src/server/bridges/index.ts | 57 + .../apps/src/server/compiler/AppCompiler.ts | 30 + .../compiler/AppFabricationFulfillment.ts | 76 + .../apps/src/server/compiler/AppImplements.ts | 33 + .../src/server/compiler/AppPackageParser.ts | 142 ++ .../server/compiler/IParseAppPackageResult.ts | 10 + packages/apps/src/server/compiler/index.ts | 9 + .../apps/src/server/compiler/modules/index.ts | 55 + .../src/server/compiler/modules/networking.ts | 36 + .../server/errors/AppOutboundProcessError.ts | 12 + .../errors/CommandAlreadyExistsError.ts | 9 + .../CommandHasAlreadyBeenTouchedError.ts | 9 + .../apps/src/server/errors/CompilerError.ts | 9 + .../server/errors/InvalidInstallationError.ts | 5 + .../src/server/errors/InvalidLicenseError.ts | 7 + .../server/errors/MustContainFunctionError.ts | 9 + .../src/server/errors/MustExtendAppError.ts | 5 + .../errors/NotEnoughMethodArgumentsError.ts | 9 + .../server/errors/PathAlreadyExistsError.ts | 9 + .../server/errors/PermissionDeniedError.ts | 25 + .../server/errors/RequiredApiVersionError.ts | 20 + .../VideoConfProviderAlreadyExistsError.ts | 9 + .../VideoConfProviderNotRegisteredError.ts | 9 + packages/apps/src/server/errors/index.ts | 25 + .../apps/src/server/logging/AppConsole.ts | 121 ++ .../src/server/logging/ILoggerStorageEntry.ts | 14 + packages/apps/src/server/logging/index.ts | 6 + .../src/server/managers/AppAccessorManager.ts | 252 ++++ packages/apps/src/server/managers/AppApi.ts | 100 ++ .../apps/src/server/managers/AppApiManager.ts | 166 +++ .../managers/AppExternalComponentManager.ts | 142 ++ .../src/server/managers/AppLicenseManager.ts | 99 ++ .../src/server/managers/AppListenerManager.ts | 1288 +++++++++++++++++ .../AppOutboundCommunicationProvider.ts | 57 + ...AppOutboundCommunicationProviderManager.ts | 139 ++ .../server/managers/AppPermissionManager.ts | 41 + .../src/server/managers/AppRuntimeManager.ts | 76 + .../server/managers/AppSchedulerManager.ts | 99 ++ .../src/server/managers/AppSettingsManager.ts | 57 + .../server/managers/AppSignatureManager.ts | 85 ++ .../src/server/managers/AppSlashCommand.ts | 86 ++ .../server/managers/AppSlashCommandManager.ts | 478 ++++++ .../server/managers/AppVideoConfProvider.ts | 114 ++ .../managers/AppVideoConfProviderManager.ts | 217 +++ .../server/managers/UIActionButtonManager.ts | 96 ++ packages/apps/src/server/managers/index.ts | 23 + .../server/marketplace/IAppLicenseMetadata.ts | 5 + .../server/marketplace/IMarketplaceInfo.ts | 25 + .../marketplace/IMarketplacePricingPlan.ts | 11 + .../marketplace/IMarketplacePricingTier.ts | 6 + .../IMarketplaceSimpleBundleInfo.ts | 4 + .../IMarketplaceSubscriptionInfo.ts | 15 + .../marketplace/MarketplacePricingStrategy.ts | 5 + .../marketplace/MarketplacePurchaseType.ts | 4 + .../MarketplaceSubscriptionStatus.ts | 10 + .../MarketplaceSubscriptionType.ts | 4 + packages/apps/src/server/marketplace/index.ts | 15 + .../license/AppLicenseValidationResult.ts | 56 + .../src/server/marketplace/license/Crypto.ts | 26 + .../src/server/marketplace/license/index.ts | 4 + packages/apps/src/server/messages/Message.ts | 109 ++ packages/apps/src/server/misc/UIHelper.ts | 31 + packages/apps/src/server/misc/Utilities.ts | 36 + .../apps/src/server/oauth2/OAuth2Client.ts | 337 +++++ .../src/server/permissions/AppPermissions.ts | 168 +++ packages/apps/src/server/rooms/Room.ts | 105 ++ .../server/runtime/AppsEngineEmptyRuntime.ts | 22 + .../server/runtime/AppsEngineNodeRuntime.ts | 75 + .../src/server/runtime/AppsEngineRuntime.ts | 29 + .../apps/src/server/runtime/EmptyRuntime.ts | 51 + .../src/server/runtime/IRuntimeController.ts | 34 + .../runtime/deno/AppsEngineDenoRuntime.ts | 735 ++++++++++ .../server/runtime/deno/LivenessManager.ts | 254 ++++ .../server/runtime/deno/ProcessMessenger.ts | 57 + .../apps/src/server/runtime/deno/bundler.ts | 90 ++ .../apps/src/server/runtime/deno/codec.ts | 45 + .../apps/src/server/storage/AppLogStorage.ts | 27 + .../src/server/storage/AppMetadataStorage.ts | 37 + .../src/server/storage/AppSourceStorage.ts | 40 + .../src/server/storage/IAppStorageItem.ts | 32 + packages/apps/src/server/storage/index.ts | 6 + 183 files changed, 13846 insertions(+) create mode 100644 packages/apps/src/server/AppManager.ts create mode 100644 packages/apps/src/server/IGetAppsFilter.ts create mode 100644 packages/apps/src/server/ProxiedApp.ts create mode 100644 packages/apps/src/server/accessors/ApiExtend.ts create mode 100644 packages/apps/src/server/accessors/AppAccessors.ts create mode 100644 packages/apps/src/server/accessors/CloudWorkspaceRead.ts create mode 100644 packages/apps/src/server/accessors/ConfigurationExtend.ts create mode 100644 packages/apps/src/server/accessors/ConfigurationModify.ts create mode 100644 packages/apps/src/server/accessors/ContactCreator.ts create mode 100644 packages/apps/src/server/accessors/ContactRead.ts create mode 100644 packages/apps/src/server/accessors/DiscussionBuilder.ts create mode 100644 packages/apps/src/server/accessors/EmailCreator.ts create mode 100644 packages/apps/src/server/accessors/EnvironmentRead.ts create mode 100644 packages/apps/src/server/accessors/EnvironmentWrite.ts create mode 100644 packages/apps/src/server/accessors/EnvironmentalVariableRead.ts create mode 100644 packages/apps/src/server/accessors/ExperimentalRead.ts create mode 100644 packages/apps/src/server/accessors/ExternalComponentsExtend.ts create mode 100644 packages/apps/src/server/accessors/Http.ts create mode 100644 packages/apps/src/server/accessors/HttpExtend.ts create mode 100644 packages/apps/src/server/accessors/LivechatCreator.ts create mode 100644 packages/apps/src/server/accessors/LivechatMessageBuilder.ts create mode 100644 packages/apps/src/server/accessors/LivechatRead.ts create mode 100644 packages/apps/src/server/accessors/LivechatUpdater.ts create mode 100644 packages/apps/src/server/accessors/MessageBuilder.ts create mode 100644 packages/apps/src/server/accessors/MessageExtender.ts create mode 100644 packages/apps/src/server/accessors/MessageRead.ts create mode 100644 packages/apps/src/server/accessors/MessageUpdater.ts create mode 100644 packages/apps/src/server/accessors/ModerationModify.ts create mode 100644 packages/apps/src/server/accessors/Modify.ts create mode 100644 packages/apps/src/server/accessors/ModifyCreator.ts create mode 100644 packages/apps/src/server/accessors/ModifyDeleter.ts create mode 100644 packages/apps/src/server/accessors/ModifyExtender.ts create mode 100644 packages/apps/src/server/accessors/ModifyUpdater.ts create mode 100644 packages/apps/src/server/accessors/Notifier.ts create mode 100644 packages/apps/src/server/accessors/OAuthAppsModify.ts create mode 100644 packages/apps/src/server/accessors/OAuthAppsReader.ts create mode 100644 packages/apps/src/server/accessors/OutboundCommunicationProviderExtend.ts create mode 100644 packages/apps/src/server/accessors/Persistence.ts create mode 100644 packages/apps/src/server/accessors/PersistenceRead.ts create mode 100644 packages/apps/src/server/accessors/Reader.ts create mode 100644 packages/apps/src/server/accessors/RoleRead.ts create mode 100644 packages/apps/src/server/accessors/RoomBuilder.ts create mode 100644 packages/apps/src/server/accessors/RoomExtender.ts create mode 100644 packages/apps/src/server/accessors/RoomRead.ts create mode 100644 packages/apps/src/server/accessors/SchedulerExtend.ts create mode 100644 packages/apps/src/server/accessors/SchedulerModify.ts create mode 100644 packages/apps/src/server/accessors/ServerSettingRead.ts create mode 100644 packages/apps/src/server/accessors/ServerSettingUpdater.ts create mode 100644 packages/apps/src/server/accessors/ServerSettingsModify.ts create mode 100644 packages/apps/src/server/accessors/SettingRead.ts create mode 100644 packages/apps/src/server/accessors/SettingUpdater.ts create mode 100644 packages/apps/src/server/accessors/SettingsExtend.ts create mode 100644 packages/apps/src/server/accessors/SlashCommandsExtend.ts create mode 100644 packages/apps/src/server/accessors/SlashCommandsModify.ts create mode 100644 packages/apps/src/server/accessors/ThreadRead.ts create mode 100644 packages/apps/src/server/accessors/UIController.ts create mode 100644 packages/apps/src/server/accessors/UIExtend.ts create mode 100644 packages/apps/src/server/accessors/UploadCreator.ts create mode 100644 packages/apps/src/server/accessors/UploadRead.ts create mode 100644 packages/apps/src/server/accessors/UserBuilder.ts create mode 100644 packages/apps/src/server/accessors/UserRead.ts create mode 100644 packages/apps/src/server/accessors/UserUpdater.ts create mode 100644 packages/apps/src/server/accessors/VideoConfProviderExtend.ts create mode 100644 packages/apps/src/server/accessors/VideoConferenceBuilder.ts create mode 100644 packages/apps/src/server/accessors/VideoConferenceExtend.ts create mode 100644 packages/apps/src/server/accessors/VideoConferenceRead.ts create mode 100644 packages/apps/src/server/accessors/index.ts create mode 100644 packages/apps/src/server/bridges/ApiBridge.ts create mode 100644 packages/apps/src/server/bridges/AppActivationBridge.ts create mode 100644 packages/apps/src/server/bridges/AppBridges.ts create mode 100644 packages/apps/src/server/bridges/AppDetailChangesBridge.ts create mode 100644 packages/apps/src/server/bridges/BaseBridge.ts create mode 100644 packages/apps/src/server/bridges/CloudWorkspaceBridge.ts create mode 100644 packages/apps/src/server/bridges/CommandBridge.ts create mode 100644 packages/apps/src/server/bridges/ContactBridge.ts create mode 100644 packages/apps/src/server/bridges/EmailBridge.ts create mode 100644 packages/apps/src/server/bridges/EnvironmentalVariableBridge.ts create mode 100644 packages/apps/src/server/bridges/ExperimentalBridge.ts create mode 100644 packages/apps/src/server/bridges/HttpBridge.ts create mode 100644 packages/apps/src/server/bridges/IInternalBridge.ts create mode 100644 packages/apps/src/server/bridges/IInternalFederationBridge.ts create mode 100644 packages/apps/src/server/bridges/IInternalPersistenceBridge.ts create mode 100644 packages/apps/src/server/bridges/IInternalSchedulerBridge.ts create mode 100644 packages/apps/src/server/bridges/IInternalUserBridge.ts create mode 100644 packages/apps/src/server/bridges/IListenerBridge.ts create mode 100644 packages/apps/src/server/bridges/InternalBridge.ts create mode 100644 packages/apps/src/server/bridges/ListenerBridge.ts create mode 100644 packages/apps/src/server/bridges/LivechatBridge.ts create mode 100644 packages/apps/src/server/bridges/MessageBridge.ts create mode 100644 packages/apps/src/server/bridges/ModerationBridge.ts create mode 100644 packages/apps/src/server/bridges/OAuthAppsBridge.ts create mode 100644 packages/apps/src/server/bridges/OutboundMessagesBridge.ts create mode 100644 packages/apps/src/server/bridges/PersistenceBridge.ts create mode 100644 packages/apps/src/server/bridges/RoleBridge.ts create mode 100644 packages/apps/src/server/bridges/RoomBridge.ts create mode 100644 packages/apps/src/server/bridges/SchedulerBridge.ts create mode 100644 packages/apps/src/server/bridges/ServerSettingBridge.ts create mode 100644 packages/apps/src/server/bridges/ThreadBridge.ts create mode 100644 packages/apps/src/server/bridges/UiInteractionBridge.ts create mode 100644 packages/apps/src/server/bridges/UploadBridge.ts create mode 100644 packages/apps/src/server/bridges/UserBridge.ts create mode 100644 packages/apps/src/server/bridges/VideoConferenceBridge.ts create mode 100644 packages/apps/src/server/bridges/index.ts create mode 100644 packages/apps/src/server/compiler/AppCompiler.ts create mode 100644 packages/apps/src/server/compiler/AppFabricationFulfillment.ts create mode 100644 packages/apps/src/server/compiler/AppImplements.ts create mode 100644 packages/apps/src/server/compiler/AppPackageParser.ts create mode 100644 packages/apps/src/server/compiler/IParseAppPackageResult.ts create mode 100644 packages/apps/src/server/compiler/index.ts create mode 100644 packages/apps/src/server/compiler/modules/index.ts create mode 100644 packages/apps/src/server/compiler/modules/networking.ts create mode 100644 packages/apps/src/server/errors/AppOutboundProcessError.ts create mode 100644 packages/apps/src/server/errors/CommandAlreadyExistsError.ts create mode 100644 packages/apps/src/server/errors/CommandHasAlreadyBeenTouchedError.ts create mode 100644 packages/apps/src/server/errors/CompilerError.ts create mode 100644 packages/apps/src/server/errors/InvalidInstallationError.ts create mode 100644 packages/apps/src/server/errors/InvalidLicenseError.ts create mode 100644 packages/apps/src/server/errors/MustContainFunctionError.ts create mode 100644 packages/apps/src/server/errors/MustExtendAppError.ts create mode 100644 packages/apps/src/server/errors/NotEnoughMethodArgumentsError.ts create mode 100644 packages/apps/src/server/errors/PathAlreadyExistsError.ts create mode 100644 packages/apps/src/server/errors/PermissionDeniedError.ts create mode 100644 packages/apps/src/server/errors/RequiredApiVersionError.ts create mode 100644 packages/apps/src/server/errors/VideoConfProviderAlreadyExistsError.ts create mode 100644 packages/apps/src/server/errors/VideoConfProviderNotRegisteredError.ts create mode 100644 packages/apps/src/server/errors/index.ts create mode 100644 packages/apps/src/server/logging/AppConsole.ts create mode 100644 packages/apps/src/server/logging/ILoggerStorageEntry.ts create mode 100644 packages/apps/src/server/logging/index.ts create mode 100644 packages/apps/src/server/managers/AppAccessorManager.ts create mode 100644 packages/apps/src/server/managers/AppApi.ts create mode 100644 packages/apps/src/server/managers/AppApiManager.ts create mode 100644 packages/apps/src/server/managers/AppExternalComponentManager.ts create mode 100644 packages/apps/src/server/managers/AppLicenseManager.ts create mode 100644 packages/apps/src/server/managers/AppListenerManager.ts create mode 100644 packages/apps/src/server/managers/AppOutboundCommunicationProvider.ts create mode 100644 packages/apps/src/server/managers/AppOutboundCommunicationProviderManager.ts create mode 100644 packages/apps/src/server/managers/AppPermissionManager.ts create mode 100644 packages/apps/src/server/managers/AppRuntimeManager.ts create mode 100644 packages/apps/src/server/managers/AppSchedulerManager.ts create mode 100644 packages/apps/src/server/managers/AppSettingsManager.ts create mode 100644 packages/apps/src/server/managers/AppSignatureManager.ts create mode 100644 packages/apps/src/server/managers/AppSlashCommand.ts create mode 100644 packages/apps/src/server/managers/AppSlashCommandManager.ts create mode 100644 packages/apps/src/server/managers/AppVideoConfProvider.ts create mode 100644 packages/apps/src/server/managers/AppVideoConfProviderManager.ts create mode 100644 packages/apps/src/server/managers/UIActionButtonManager.ts create mode 100644 packages/apps/src/server/managers/index.ts create mode 100644 packages/apps/src/server/marketplace/IAppLicenseMetadata.ts create mode 100644 packages/apps/src/server/marketplace/IMarketplaceInfo.ts create mode 100644 packages/apps/src/server/marketplace/IMarketplacePricingPlan.ts create mode 100644 packages/apps/src/server/marketplace/IMarketplacePricingTier.ts create mode 100644 packages/apps/src/server/marketplace/IMarketplaceSimpleBundleInfo.ts create mode 100644 packages/apps/src/server/marketplace/IMarketplaceSubscriptionInfo.ts create mode 100644 packages/apps/src/server/marketplace/MarketplacePricingStrategy.ts create mode 100644 packages/apps/src/server/marketplace/MarketplacePurchaseType.ts create mode 100644 packages/apps/src/server/marketplace/MarketplaceSubscriptionStatus.ts create mode 100644 packages/apps/src/server/marketplace/MarketplaceSubscriptionType.ts create mode 100644 packages/apps/src/server/marketplace/index.ts create mode 100644 packages/apps/src/server/marketplace/license/AppLicenseValidationResult.ts create mode 100644 packages/apps/src/server/marketplace/license/Crypto.ts create mode 100644 packages/apps/src/server/marketplace/license/index.ts create mode 100644 packages/apps/src/server/messages/Message.ts create mode 100644 packages/apps/src/server/misc/UIHelper.ts create mode 100644 packages/apps/src/server/misc/Utilities.ts create mode 100644 packages/apps/src/server/oauth2/OAuth2Client.ts create mode 100644 packages/apps/src/server/permissions/AppPermissions.ts create mode 100644 packages/apps/src/server/rooms/Room.ts create mode 100644 packages/apps/src/server/runtime/AppsEngineEmptyRuntime.ts create mode 100644 packages/apps/src/server/runtime/AppsEngineNodeRuntime.ts create mode 100644 packages/apps/src/server/runtime/AppsEngineRuntime.ts create mode 100644 packages/apps/src/server/runtime/EmptyRuntime.ts create mode 100644 packages/apps/src/server/runtime/IRuntimeController.ts create mode 100644 packages/apps/src/server/runtime/deno/AppsEngineDenoRuntime.ts create mode 100644 packages/apps/src/server/runtime/deno/LivenessManager.ts create mode 100644 packages/apps/src/server/runtime/deno/ProcessMessenger.ts create mode 100644 packages/apps/src/server/runtime/deno/bundler.ts create mode 100644 packages/apps/src/server/runtime/deno/codec.ts create mode 100644 packages/apps/src/server/storage/AppLogStorage.ts create mode 100644 packages/apps/src/server/storage/AppMetadataStorage.ts create mode 100644 packages/apps/src/server/storage/AppSourceStorage.ts create mode 100644 packages/apps/src/server/storage/IAppStorageItem.ts create mode 100644 packages/apps/src/server/storage/index.ts diff --git a/packages/apps/src/server/AppManager.ts b/packages/apps/src/server/AppManager.ts new file mode 100644 index 0000000000000..d24931c7be431 --- /dev/null +++ b/packages/apps/src/server/AppManager.ts @@ -0,0 +1,1241 @@ +import { Buffer } from 'buffer'; + +import { AppStatus, AppStatusUtils } from '@rocket.chat/apps-engine/definition/AppStatus'; +import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; +import { AppMethod } from '@rocket.chat/apps-engine/definition/metadata'; +import type { IPermission } from '@rocket.chat/apps-engine/definition/permissions/IPermission'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; +import { UserType } from '@rocket.chat/apps-engine/definition/users'; + +import type { IGetAppsFilter } from './IGetAppsFilter'; +import { ProxiedApp } from './ProxiedApp'; +import { AppBridges } from './bridges'; +import type { PersistenceBridge, UserBridge } from './bridges'; +import type { IInternalPersistenceBridge } from './bridges/IInternalPersistenceBridge'; +import type { IInternalUserBridge } from './bridges/IInternalUserBridge'; +import { AppCompiler, AppFabricationFulfillment, AppPackageParser } from './compiler'; +import { InvalidLicenseError } from './errors'; +import { InvalidInstallationError } from './errors/InvalidInstallationError'; +import { + AppAccessorManager, + AppApiManager, + AppExternalComponentManager, + AppLicenseManager, + AppListenerManager, + AppSchedulerManager, + AppSettingsManager, + AppSlashCommandManager, + AppVideoConfProviderManager, +} from './managers'; +import { AppOutboundCommunicationProviderManager } from './managers/AppOutboundCommunicationProviderManager'; +import { AppRuntimeManager } from './managers/AppRuntimeManager'; +import { AppSignatureManager } from './managers/AppSignatureManager'; +import { UIActionButtonManager } from './managers/UIActionButtonManager'; +import type { IMarketplaceInfo } from './marketplace'; +import { defaultPermissions } from './permissions/AppPermissions'; +import { EmptyRuntime } from './runtime/EmptyRuntime'; +import type { IAppStorageItem } from './storage'; +import { AppLogStorage, AppMetadataStorage } from './storage'; +import { AppSourceStorage } from './storage/AppSourceStorage'; +import { AppInstallationSource } from './storage/IAppStorageItem'; + +export interface IAppInstallParameters { + enable: boolean; + marketplaceInfo?: IMarketplaceInfo[]; + permissionsGranted?: Array; + user: IUser; +} + +export interface IAppUninstallParameters { + user: IUser; +} + +export interface IAppManagerDeps { + metadataStorage: AppMetadataStorage; + logStorage: AppLogStorage; + bridges: AppBridges; + sourceStorage: AppSourceStorage; + /** + * Path to temporary file storage. + * + * Needs to be accessible for reading and writing. + */ + tempFilePath: string; +} + +interface IPurgeAppConfigOpts { + keepScheduledJobs?: boolean; + keepSlashcommands?: boolean; + keepOutboundCommunicationProviders?: boolean; +} + +export class AppManager { + public static Instance: AppManager; + + // apps contains all of the Apps + private readonly apps: Map; + + private readonly appMetadataStorage: AppMetadataStorage; + + private appSourceStorage: AppSourceStorage; + + private readonly logStorage: AppLogStorage; + + private readonly bridges: AppBridges; + + private readonly parser: AppPackageParser; + + private readonly compiler: AppCompiler; + + private readonly accessorManager: AppAccessorManager; + + private readonly listenerManager: AppListenerManager; + + private readonly commandManager: AppSlashCommandManager; + + private readonly apiManager: AppApiManager; + + private readonly externalComponentManager: AppExternalComponentManager; + + private readonly settingsManager: AppSettingsManager; + + private readonly licenseManager: AppLicenseManager; + + private readonly schedulerManager: AppSchedulerManager; + + private readonly uiActionButtonManager: UIActionButtonManager; + + private readonly videoConfProviderManager: AppVideoConfProviderManager; + + private readonly outboundCommunicationProviderManager: AppOutboundCommunicationProviderManager; + + private readonly signatureManager: AppSignatureManager; + + private readonly runtime: AppRuntimeManager; + + private readonly tempFilePath: string; + + private isLoaded: boolean; + + constructor({ metadataStorage, logStorage, bridges, sourceStorage, tempFilePath }: IAppManagerDeps) { + // Singleton style. There can only ever be one AppManager instance + if (typeof AppManager.Instance !== 'undefined') { + throw new Error('There is already a valid AppManager instance'); + } + + if (metadataStorage instanceof AppMetadataStorage) { + this.appMetadataStorage = metadataStorage; + } else { + throw new Error('Invalid instance of the AppMetadataStorage'); + } + + if (logStorage instanceof AppLogStorage) { + this.logStorage = logStorage; + } else { + throw new Error('Invalid instance of the AppLogStorage'); + } + + if (bridges instanceof AppBridges) { + this.bridges = bridges; + } else { + throw new Error('Invalid instance of the AppBridges'); + } + + if (sourceStorage instanceof AppSourceStorage) { + this.appSourceStorage = sourceStorage; + } else { + throw new Error('Invalid instance of the AppSourceStorage'); + } + + this.tempFilePath = tempFilePath; + + this.apps = new Map(); + + this.parser = new AppPackageParser(); + this.compiler = new AppCompiler(); + this.accessorManager = new AppAccessorManager(this); + this.listenerManager = new AppListenerManager(this); + this.commandManager = new AppSlashCommandManager(this); + this.apiManager = new AppApiManager(this); + this.externalComponentManager = new AppExternalComponentManager(); + this.settingsManager = new AppSettingsManager(this); + this.licenseManager = new AppLicenseManager(this); + this.schedulerManager = new AppSchedulerManager(this); + this.uiActionButtonManager = new UIActionButtonManager(this); + this.videoConfProviderManager = new AppVideoConfProviderManager(this); + this.outboundCommunicationProviderManager = new AppOutboundCommunicationProviderManager(this); + this.signatureManager = new AppSignatureManager(this); + this.runtime = new AppRuntimeManager(this); + + this.isLoaded = false; + AppManager.Instance = this; + } + + /** + * Gets the path to the temporary file storage. + * + * Mainly used for upload events + */ + public getTempFilePath(): string { + return this.tempFilePath; + } + + /** Gets the instance of the storage connector. */ + public getStorage(): AppMetadataStorage { + return this.appMetadataStorage; + } + + /** Gets the instance of the log storage connector. */ + public getLogStorage(): AppLogStorage { + return this.logStorage; + } + + /** Gets the instance of the App package parser. */ + public getParser(): AppPackageParser { + return this.parser; + } + + /** Gets the compiler instance. */ + public getCompiler(): AppCompiler { + return this.compiler; + } + + /** Gets the accessor manager instance. */ + public getAccessorManager(): AppAccessorManager { + return this.accessorManager; + } + + /** Gets the instance of the Bridge manager. */ + public getBridges(): AppBridges { + return this.bridges; + } + + /** Gets the instance of the listener manager. */ + public getListenerManager(): AppListenerManager { + return this.listenerManager; + } + + /** Gets the command manager's instance. */ + public getCommandManager(): AppSlashCommandManager { + return this.commandManager; + } + + public getVideoConfProviderManager(): AppVideoConfProviderManager { + return this.videoConfProviderManager; + } + + public getOutboundCommunicationProviderManager(): AppOutboundCommunicationProviderManager { + return this.outboundCommunicationProviderManager; + } + + public getLicenseManager(): AppLicenseManager { + return this.licenseManager; + } + + /** Gets the api manager's instance. */ + public getApiManager(): AppApiManager { + return this.apiManager; + } + + /** Gets the external component manager's instance. */ + public getExternalComponentManager(): AppExternalComponentManager { + return this.externalComponentManager; + } + + /** Gets the manager of the settings, updates and getting. */ + public getSettingsManager(): AppSettingsManager { + return this.settingsManager; + } + + public getSchedulerManager(): AppSchedulerManager { + return this.schedulerManager; + } + + public getUIActionButtonManager(): UIActionButtonManager { + return this.uiActionButtonManager; + } + + public getSignatureManager(): AppSignatureManager { + return this.signatureManager; + } + + public getRuntime(): AppRuntimeManager { + return this.runtime; + } + + /** Gets whether the Apps have been loaded or not. */ + public areAppsLoaded(): boolean { + return this.isLoaded; + } + + public setSourceStorage(storage: AppSourceStorage): void { + this.appSourceStorage = storage; + } + + /** + * Goes through the entire loading up process. + * Expect this to take some time, as it goes through a very + * long process of loading all the Apps up. + */ + public async load(): Promise { + // You can not load the AppManager system again + // if it has already been loaded. + if (this.isLoaded) { + return true; + } + + const items: Map = await this.appMetadataStorage.retrieveAll(); + + for (const item of items.values()) { + try { + const appPackage = await this.appSourceStorage.fetch(item); + const unpackageResult = await this.getParser().unpackageApp(appPackage); + + const app = await this.getCompiler().toSandBox(this, item, unpackageResult); + + this.apps.set(item.id, app); + } catch (e) { + console.warn(`Error while compiling the App "${item.info.name} (${item.id})":`); + console.error(e); + + const prl = new ProxiedApp(this, item, new EmptyRuntime(item.id)); + + this.apps.set(item.id, prl); + } + } + + this.isLoaded = true; + return true; + } + + public async enableAll(): Promise> { + const affs: Array = []; + + // Let's initialize them + for (const rl of this.apps.values()) { + const aff = new AppFabricationFulfillment(); + + aff.setAppInfo(rl.getInfo()); + aff.setImplementedInterfaces(rl.getImplementationList()); + aff.setApp(rl); + affs.push(aff); + + if (AppStatusUtils.isDisabled(await rl.getStatus())) { + // Usually if an App is disabled before it's initialized, + // then something (such as an error) occured while + // it was compiled or something similar. + // We still have to validate its license, though + await rl.validateLicense(); + + continue; + } + + await this.initializeApp(rl, true).catch(console.error); + } + + // Let's ensure the required settings are all set + for (const rl of this.apps.values()) { + if (AppStatusUtils.isDisabled(await rl.getStatus())) { + continue; + } + + if (!this.areRequiredSettingsSet(rl.getStorageItem())) { + await rl.setStatus(AppStatus.INVALID_SETTINGS_DISABLED).catch(console.error); + } + } + + // Now let's enable the apps which were once enabled + // but are not currently disabled. + for (const app of this.apps.values()) { + const status = await app.getStatus(); + if (!AppStatusUtils.isDisabled(status) && AppStatusUtils.isEnabled(app.getPreviousStatus())) { + await this.enableApp(app).catch(console.error); + } else if (!AppStatusUtils.isError(status)) { + this.listenerManager.lockEssentialEvents(app); + this.uiActionButtonManager.clearAppActionButtons(app.getID()); + } + } + + return affs; + } + + public async unload(isManual: boolean): Promise { + // If the AppManager hasn't been loaded yet, then + // there is nothing to unload + if (!this.isLoaded) { + return; + } + + for (const app of this.apps.values()) { + const status = await app.getStatus(); + if (status === AppStatus.INITIALIZED) { + await this.purgeAppConfig(app); + } else if (!AppStatusUtils.isDisabled(status)) { + await this.disable(app.getID(), isManual ? AppStatus.MANUALLY_DISABLED : AppStatus.DISABLED); + } + + this.listenerManager.releaseEssentialEvents(app); + + void app.getRuntimeController().stopApp(); + } + + // Remove all the apps from the system now that we have unloaded everything + this.apps.clear(); + + this.isLoaded = false; + } + + /** Gets the Apps which match the filter passed in. */ + public async get(filter?: IGetAppsFilter): Promise { + let rls: Array = []; + + if (typeof filter === 'undefined') { + this.apps.forEach((rl) => rls.push(rl)); + + return rls; + } + + let nothing = true; + + if (typeof filter.enabled === 'boolean' && filter.enabled) { + for (const rl of this.apps.values()) { + if (AppStatusUtils.isEnabled(await rl.getStatus())) { + rls.push(rl); + } + } + + nothing = false; + } + + if (typeof filter.disabled === 'boolean' && filter.disabled) { + for (const rl of this.apps.values()) { + if (AppStatusUtils.isDisabled(await rl.getStatus())) { + rls.push(rl); + } + } + + nothing = false; + } + + if (nothing) { + this.apps.forEach((rl) => rls.push(rl)); + } + + if (typeof filter.ids !== 'undefined') { + rls = rls.filter((rl) => filter.ids.includes(rl.getID())); + } + + if (typeof filter.installationSource !== 'undefined') { + rls = rls.filter((rl) => rl.getInstallationSource() === filter.installationSource); + } + + if (typeof filter.name === 'string') { + rls = rls.filter((rl) => rl.getName() === filter.name); + } else if (filter.name instanceof RegExp) { + rls = rls.filter((rl) => (filter.name as RegExp).test(rl.getName())); + } + + return rls; + } + + /** Gets a single App by the id passed in. */ + public getOneById(appId: string): ProxiedApp { + return this.apps.get(appId); + } + + public getPermissionsById(appId: string): Array { + const app = this.apps.get(appId); + + if (!app) { + return []; + } + const { permissionsGranted } = app.getStorageItem(); + + return permissionsGranted || defaultPermissions; + } + + public async enable(id: string): Promise { + const rl = this.apps.get(id); + + if (!rl) { + throw new Error(`No App by the id "${id}" exists.`); + } + + const status = await rl.getStatus(); + + if (AppStatusUtils.isEnabled(status)) { + return true; + } + + if (status === AppStatus.COMPILER_ERROR_DISABLED) { + throw new Error('The App had compiler errors, can not enable it.'); + } + + const storageItem = await this.appMetadataStorage.retrieveOne(id); + + if (!storageItem) { + throw new Error(`Could not enable an App with the id of "${id}" as it doesn't exist.`); + } + + const isSetup = await this.runStartUpProcess(storageItem, rl, false); + + return isSetup; + } + + public async disable(id: string, status: AppStatus = AppStatus.DISABLED, silent?: boolean): Promise { + if (!AppStatusUtils.isDisabled(status)) { + throw new Error('Invalid disabled status'); + } + + const app = this.apps.get(id); + + if (!app) { + throw new Error(`No App by the id "${id}" exists.`); + } + + if (AppStatusUtils.isEnabled(await app.getStatus())) { + await app.call(AppMethod.ONDISABLE).catch((e) => console.warn('Error while disabling:', e)); + } + + await this.purgeAppConfig(app, { + keepScheduledJobs: true, + keepSlashcommands: true, + keepOutboundCommunicationProviders: true, + }); + + await app.setStatus(status, silent); + + const storageItem = await this.appMetadataStorage.retrieveOne(id); + + app.getStorageItem().marketplaceInfo = storageItem.marketplaceInfo; + await app.validateLicense().catch(() => {}); + + return true; + } + + public async migrate(id: string): Promise { + const app = this.apps.get(id); + + if (!app) { + throw new Error(`No App by the id "${id}" exists.`); + } + + await app.call(AppMethod.ONUPDATE).catch((e) => console.warn('Error while migrating:', e)); + + await this.purgeAppConfig(app, { keepScheduledJobs: true }); + + const storageItem = await this.appMetadataStorage.retrieveOne(id); + + app.getStorageItem().marketplaceInfo = storageItem.marketplaceInfo; + await app.validateLicense().catch(() => {}); + + storageItem.migrated = true; + storageItem.signature = await this.getSignatureManager().signApp(storageItem); + + const { marketplaceInfo, signature, migrated, _id } = storageItem; + const stored = await this.appMetadataStorage.updatePartialAndReturnDocument({ marketplaceInfo, signature, migrated, _id }); + + await this.updateLocal(stored, app); + await this.bridges + .getAppActivationBridge() + .doAppUpdated(app) + .catch(() => {}); + + return true; + } + + public async addLocal(appId: string): Promise { + const storageItem = await this.appMetadataStorage.retrieveOne(appId); + + if (!storageItem) { + throw new Error(`App with id ${appId} couldn't be found`); + } + + const appPackage = await this.appSourceStorage.fetch(storageItem); + + if (!appPackage) { + throw new Error(`Package file for app "${storageItem.info.name}" (${appId}) couldn't be found`); + } + + const parsedPackage = await this.getParser().unpackageApp(appPackage); + const app = await this.getCompiler().toSandBox(this, storageItem, parsedPackage); + + this.apps.set(app.getID(), app); + + await this.loadOne(appId); + } + + public async add(appPackage: Buffer, installationParameters: IAppInstallParameters): Promise { + const { enable = true, marketplaceInfo, permissionsGranted, user } = installationParameters; + + const aff = new AppFabricationFulfillment(); + const result = await this.getParser().unpackageApp(appPackage); + const undoSteps: Array<() => Promise> = []; + + aff.setAppInfo(result.info); + aff.setImplementedInterfaces(result.implemented.getValues()); + + const descriptor: IAppStorageItem = { + id: result.info.id, + info: result.info, + status: enable ? AppStatus.MANUALLY_ENABLED : AppStatus.MANUALLY_DISABLED, + settings: {}, + implemented: result.implemented.getValues(), + installationSource: marketplaceInfo ? AppInstallationSource.MARKETPLACE : AppInstallationSource.PRIVATE, + marketplaceInfo, + permissionsGranted, + languageContent: result.languageContent, + }; + + try { + descriptor.sourcePath = await this.appSourceStorage.store(descriptor, appPackage); + + undoSteps.push(() => this.appSourceStorage.remove(descriptor)); + } catch { + aff.setStorageError('Failed to store app package'); + + return aff; + } + + let app: ProxiedApp; + + try { + app = await this.getCompiler().toSandBox(this, descriptor, result); + } catch (error) { + await Promise.all(undoSteps.map((undoer) => undoer())); + + throw error; + } + + undoSteps.push(() => + this.getRuntime() + .stopRuntime(app.getRuntimeController()) + .catch(() => {}), + ); + + // Create a user for the app + try { + await this.createAppUser(result.info); + + undoSteps.push(async () => void this.removeAppUser(app)); + } catch { + aff.setAppUserError({ + username: `${result.info.nameSlug}.bot`, + message: 'Failed to create an app user for this app.', + }); + + await Promise.all(undoSteps.map((undoer) => undoer())); + + return aff; + } + + descriptor.signature = await this.getSignatureManager().signApp(descriptor); + const created = await this.appMetadataStorage.create(descriptor); + + if (!created) { + aff.setStorageError('Failed to create the App, the storage did not return it.'); + + await Promise.all(undoSteps.map((undoer) => undoer())); + + return aff; + } + + app.getStorageItem()._id = created._id; + + this.apps.set(app.getID(), app); + aff.setApp(app); + + // Let everyone know that the App has been added + await this.bridges + .getAppActivationBridge() + .doAppAdded(app) + .catch(() => { + // If an error occurs during this, oh well. + }); + + await this.installApp(app, user); + + // Should enable === true, then we go through the entire start up process + // Otherwise, we only initialize it. + if (enable) { + // Start up the app + await this.runStartUpProcess(created, app, false); + } else { + await this.initializeApp(app); + } + + return aff; + } + + /** + * Uninstalls specified app from the server and remove + * all database records regarding it + * + * @returns the instance of the removed ProxiedApp + */ + public async remove(id: string, uninstallationParameters: IAppUninstallParameters): Promise { + const app = this.apps.get(id); + const { user } = uninstallationParameters; + + // First remove the app + await this.uninstallApp(app, user); + await this.removeLocal(id); + + // Then let everyone know that the App has been removed + await this.bridges.getAppActivationBridge().doAppRemoved(app).catch(); + + return app; + } + + /** + * Removes the app instance from the local Apps container + * and every type of data associated with it + */ + public async removeLocal(id: string): Promise { + const app = this.apps.get(id); + + if (AppStatusUtils.isEnabled(await app.getStatus())) { + await this.disable(id); + } + + await this.purgeAppConfig(app); + this.listenerManager.releaseEssentialEvents(app); + await this.removeAppUser(app); + await (this.bridges.getPersistenceBridge() as IInternalPersistenceBridge & PersistenceBridge).purge(app.getID()); + await this.appMetadataStorage.remove(app.getID()); + await this.appSourceStorage.remove(app.getStorageItem()).catch(() => {}); + + // Errors here don't really prevent the process from dying, so we don't really need to do anything on the catch + await this.getRuntime() + .stopRuntime(app.getRuntimeController()) + .catch(() => {}); + + this.apps.delete(app.getID()); + } + + public async update( + appPackage: Buffer, + permissionsGranted: Array, + updateOptions: { loadApp?: boolean; user?: IUser } = { loadApp: true }, + ): Promise { + const aff = new AppFabricationFulfillment(); + const result = await this.getParser().unpackageApp(appPackage); + + aff.setAppInfo(result.info); + aff.setImplementedInterfaces(result.implemented.getValues()); + + const old = await this.appMetadataStorage.retrieveOne(result.info.id); + + if (!old) { + throw new Error('Can not update an App that does not currently exist.'); + } + + // If there is any error during disabling, it doesn't really matter + await this.disable(old.id).catch(() => {}); + + const descriptor: IAppStorageItem = { + ...old, + id: result.info.id, + info: result.info, + languageContent: result.languageContent, + implemented: result.implemented.getValues(), + }; + + if (!permissionsGranted) { + delete descriptor.permissionsGranted; + } else { + descriptor.permissionsGranted = permissionsGranted; + } + + try { + descriptor.sourcePath = await this.appSourceStorage.update(descriptor, appPackage); + } catch { + aff.setStorageError('Failed to storage app package'); + + return aff; + } + + descriptor.signature = await this.signatureManager.signApp(descriptor); + const stored = await this.appMetadataStorage.updatePartialAndReturnDocument(descriptor, { + unsetPermissionsGranted: typeof permissionsGranted === 'undefined', + }); + + // Errors here don't really prevent the process from dying, so we don't really need to do anything on the catch + await this.getRuntime() + .stopRuntime(this.apps.get(old.id).getRuntimeController()) + .catch(() => {}); + + const app = await this.getCompiler().toSandBox(this, descriptor, result); + + // Ensure there is an user for the app + try { + await this.createAppUser(result.info); + } catch { + aff.setAppUserError({ + username: `${result.info.nameSlug}.bot`, + message: 'Failed to create an app user for this app.', + }); + + return aff; + } + + aff.setApp(app); + + if (updateOptions.loadApp) { + const shouldEnableApp = AppStatusUtils.isEnabled(old.status); + if (shouldEnableApp) { + await this.updateAndStartupLocal(stored, app); + } else { + await this.updateAndInitializeLocal(stored, app); + } + + await this.bridges + .getAppActivationBridge() + .doAppUpdated(app) + .catch(() => {}); + } + + await this.updateApp(app, updateOptions.user, old.info.version); + + return aff; + } + + /** + * Updates the local instance of an app. + * + * If the second parameter is a Buffer of an app package, + * unpackage and instantiate the app's main class + * + * With an instance of a ProxiedApp, start it up and replace + * the reference in the local app collection + */ + async updateLocal(stored: IAppStorageItem, appPackageOrInstance: ProxiedApp | Buffer): Promise { + const app = await (async () => { + if (appPackageOrInstance instanceof Buffer) { + const parseResult = await this.getParser().unpackageApp(appPackageOrInstance); + + // Errors here don't really prevent the process from dying, so we don't really need to do anything on the catch + await this.getRuntime() + .stopRuntime(this.apps.get(stored.id).getRuntimeController()) + .catch(() => {}); + + return this.getCompiler().toSandBox(this, stored, parseResult); + } + + if (appPackageOrInstance instanceof ProxiedApp) { + return appPackageOrInstance; + } + })(); + + // We don't keep slashcommands here as the update could potentially not provide the same list + await this.purgeAppConfig(app, { keepScheduledJobs: true }); + + this.apps.set(app.getID(), app); + return app; + } + + public async updateAndStartupLocal(stored: IAppStorageItem, appPackageOrInstance: ProxiedApp | Buffer) { + const app = await this.updateLocal(stored, appPackageOrInstance); + await this.runStartUpProcess(stored, app, true); + } + + public async updateAndInitializeLocal(stored: IAppStorageItem, appPackageOrInstance: ProxiedApp | Buffer) { + const app = await this.updateLocal(stored, appPackageOrInstance); + await this.initializeApp(app, true); + } + + public getLanguageContent(): { [key: string]: object } { + const langs: { [key: string]: object } = {}; + + this.apps.forEach((rl) => { + const content = rl.getStorageItem().languageContent; + + Object.keys(content).forEach((key) => { + langs[key] = Object.assign(langs[key] || {}, content[key]); + }); + }); + + return langs; + } + + public async changeStatus(appId: string, status: AppStatus): Promise { + switch (status) { + case AppStatus.MANUALLY_DISABLED: + case AppStatus.MANUALLY_ENABLED: + break; + default: + throw new Error('Invalid status to change an App to, must be manually disabled or enabled.'); + } + + const rl = this.apps.get(appId); + + if (!rl) { + throw new Error('Can not change the status of an App which does not currently exist.'); + } + + const storageItem = rl.getStorageItem(); + + if (AppStatusUtils.isEnabled(status)) { + // Then enable it + if (AppStatusUtils.isEnabled(await rl.getStatus())) { + throw new Error('Can not enable an App which is already enabled.'); + } + + await this.enable(rl.getID()); + + storageItem.status = AppStatus.MANUALLY_ENABLED; + await this.appMetadataStorage.updateStatus(storageItem._id, AppStatus.MANUALLY_ENABLED); + } else { + if (!AppStatusUtils.isEnabled(await rl.getStatus())) { + throw new Error('Can not disable an App which is not enabled.'); + } + + await this.disable(rl.getID(), AppStatus.MANUALLY_DISABLED); + + storageItem.status = AppStatus.MANUALLY_DISABLED; + await this.appMetadataStorage.updateStatus(storageItem._id, AppStatus.MANUALLY_DISABLED); + } + + return rl; + } + + public async updateAppsMarketplaceInfo(appsOverview: Array<{ latest: IMarketplaceInfo }>): Promise { + await Promise.all( + appsOverview.map(async ({ latest: appInfo }) => { + if (!appInfo.subscriptionInfo) { + return; + } + + const app = this.apps.get(appInfo.id); + + if (!app) { + return; + } + + const appStorageItem = app.getStorageItem(); + const { subscriptionInfo } = appStorageItem.marketplaceInfo?.[0] || {}; + + if (subscriptionInfo?.license.license === appInfo.subscriptionInfo.license.license) { + return; + } + + appStorageItem.marketplaceInfo[0].subscriptionInfo = appInfo.subscriptionInfo; + appStorageItem.signature = await this.getSignatureManager().signApp(appStorageItem); + + return this.appMetadataStorage.updatePartialAndReturnDocument({ + _id: appStorageItem._id, + marketplaceInfo: appStorageItem.marketplaceInfo, + signature: appStorageItem.signature, + }); + }), + ).catch(() => {}); + + const queue = [] as Array>; + + this.apps.forEach((app) => + queue.push( + app + .validateLicense() + .then(async () => { + if ((await app.getStatus()) !== AppStatus.INVALID_LICENSE_DISABLED) { + return; + } + + return app.setStatus(AppStatus.DISABLED); + }) + .catch(async (error) => { + if (!(error instanceof InvalidLicenseError)) { + console.error(error); + return; + } + + await this.purgeAppConfig(app, { keepScheduledJobs: true }); + + return app.setStatus(AppStatus.INVALID_LICENSE_DISABLED); + }) + .then(async () => { + const status = await app.getStatus(); + if (status === app.getPreviousStatus()) { + return; + } + + const storageItem = app.getStorageItem(); + storageItem.status = status; + + return this.appMetadataStorage.updateStatus(storageItem._id, storageItem.status).catch(console.error) as Promise; + }), + ), + ); + + await Promise.all(queue); + } + + /** + * Goes through the entire loading up process. + * + * @param appId the id of the application to load + */ + public async loadOne(appId: string, silenceStatus = false): Promise { + const rl = this.apps.get(appId); + + if (!rl) { + throw new Error(`No App found by the id of: "${appId}"`); + } + + const item = rl.getStorageItem(); + + await this.initializeApp(rl, silenceStatus); + + if (!this.areRequiredSettingsSet(item)) { + await rl.setStatus(AppStatus.INVALID_SETTINGS_DISABLED); + } + + if (!AppStatusUtils.isDisabled(await rl.getStatus()) && AppStatusUtils.isEnabled(rl.getPreviousStatus())) { + await this.enableApp(rl, silenceStatus); + } + + return this.apps.get(item.id); + } + + private async runStartUpProcess(storageItem: IAppStorageItem, app: ProxiedApp, silenceStatus: boolean): Promise { + if ((await app.getStatus()) !== AppStatus.INITIALIZED) { + const isInitialized = await this.initializeApp(app, silenceStatus); + if (!isInitialized) { + return false; + } + } + + if (!this.areRequiredSettingsSet(storageItem)) { + await app.setStatus(AppStatus.INVALID_SETTINGS_DISABLED, silenceStatus); + return false; + } + + return this.enableApp(app, silenceStatus); + } + + private async installApp(app: ProxiedApp, user: IUser): Promise { + let result: boolean; + const context = { user }; + + try { + await app.call(AppMethod.ONINSTALL, context); + + result = true; + } catch { + const status = AppStatus.ERROR_DISABLED; + + result = false; + + await app.setStatus(status); + } + + return result; + } + + private async updateApp(app: ProxiedApp, user: IUser | null, oldAppVersion: string): Promise { + let result: boolean; + + try { + await app.call(AppMethod.ONUPDATE, { oldAppVersion, user }); + + result = true; + } catch { + const status = AppStatus.ERROR_DISABLED; + + result = false; + + await app.setStatus(status); + } + + return result; + } + + private async initializeApp(app: ProxiedApp, silenceStatus = false): Promise { + let result: boolean; + + try { + await app.validateLicense(); + await app.validateInstallation(); + + await app.call(AppMethod.INITIALIZE); + await app.setStatus(AppStatus.INITIALIZED, silenceStatus); + + await this.commandManager.registerCommands(app.getID()); + + result = true; + } catch (e) { + let status = AppStatus.ERROR_DISABLED; + + if (e instanceof InvalidLicenseError) { + status = AppStatus.INVALID_LICENSE_DISABLED; + } + + if (e instanceof InvalidInstallationError) { + status = AppStatus.INVALID_INSTALLATION_DISABLED; + } + + await this.purgeAppConfig(app); + result = false; + + await app.setStatus(status, silenceStatus); + } + + return result; + } + + private async purgeAppConfig(app: ProxiedApp, opts: IPurgeAppConfigOpts = {}) { + if (!opts.keepScheduledJobs) { + await this.schedulerManager.cleanUp(app.getID()); + } + + if (!opts.keepSlashcommands) { + await this.commandManager.unregisterCommands(app.getID()); + } + + this.listenerManager.unregisterListeners(app); + this.listenerManager.lockEssentialEvents(app); + this.externalComponentManager.unregisterExternalComponents(app.getID()); + await this.apiManager.unregisterApis(app.getID()); + this.accessorManager.purifyApp(app.getID()); + this.uiActionButtonManager.clearAppActionButtons(app.getID()); + await this.videoConfProviderManager.unregisterProviders(app.getID()); + await this.outboundCommunicationProviderManager.unregisterProviders(app.getID(), { + keepReferences: opts.keepOutboundCommunicationProviders, + }); + } + + /** + * Determines if the App's required settings are set or not. + * Should a packageValue be provided and not empty, then it's considered set. + */ + private areRequiredSettingsSet(storageItem: IAppStorageItem): boolean { + let result = true; + + for (const setk of Object.keys(storageItem.settings)) { + const sett = storageItem.settings[setk]; + // If it's not required, ignore + if (!sett.required) { + continue; + } + + if (sett.value !== 'undefined' || sett.packageValue !== 'undefined') { + continue; + } + + result = false; + } + + return result; + } + + private async enableApp(app: ProxiedApp, silenceStatus = false): Promise { + let enable: boolean; + let status = AppStatus.ERROR_DISABLED; + + try { + await app.validateLicense(); + await app.validateInstallation(); + + enable = (await app.call(AppMethod.ONENABLE)) as boolean; + + if (enable) { + status = AppStatus.MANUALLY_ENABLED; + } else { + status = AppStatus.DISABLED; + console.warn(`The App (${app.getID()}) disabled itself when being enabled. \nCheck the "onEnable" implementation for details.`); + } + } catch (e) { + enable = false; + + if (e instanceof InvalidLicenseError) { + status = AppStatus.INVALID_LICENSE_DISABLED; + } + + if (e instanceof InvalidInstallationError) { + status = AppStatus.INVALID_INSTALLATION_DISABLED; + } + + console.error(e); + } + + if (enable) { + this.externalComponentManager.registerExternalComponents(app.getID()); + await this.apiManager.registerApis(app.getID()); + this.listenerManager.registerListeners(app); + this.listenerManager.releaseEssentialEvents(app); + await this.videoConfProviderManager.registerProviders(app.getID()); + await this.outboundCommunicationProviderManager.registerProviders(app.getID()); + } else { + await this.purgeAppConfig(app, { + keepScheduledJobs: true, + keepSlashcommands: true, + keepOutboundCommunicationProviders: true, + }); + } + + await app.setStatus(status, silenceStatus); + + return enable; + } + + private async createAppUser(appInfo: IAppInfo): Promise { + const appUser = await (this.bridges.getUserBridge() as IInternalUserBridge & UserBridge).getAppUser(appInfo.id); + + if (appUser) { + return appUser.id; + } + + const userData: Partial = { + username: `${appInfo.nameSlug}.bot`, + name: appInfo.name, + roles: ['app'], + appId: appInfo.id, + type: UserType.APP, + status: 'online', + isEnabled: true, + }; + + return (this.bridges.getUserBridge() as IInternalUserBridge & UserBridge).create(userData, appInfo.id, { + avatarUrl: appInfo.iconFileContent || appInfo.iconFile, + joinDefaultChannels: true, + sendWelcomeEmail: false, + }); + } + + private async removeAppUser(app: ProxiedApp): Promise { + const appUser = await (this.bridges.getUserBridge() as IInternalUserBridge & UserBridge).getAppUser(app.getID()); + + if (!appUser) { + return true; + } + + return (this.bridges.getUserBridge() as IInternalUserBridge & UserBridge).remove(appUser, app.getID()); + } + + private async uninstallApp(app: ProxiedApp, user: IUser): Promise { + let result: boolean; + const context = { user }; + + try { + await app.call(AppMethod.ONUNINSTALL, context); + + result = true; + } catch { + const status = AppStatus.ERROR_DISABLED; + + result = false; + + await app.setStatus(status); + } + + return result; + } +} + +export const getPermissionsByAppId = (appId: string) => { + if (!AppManager.Instance) { + console.error('AppManager should be instantiated first'); + return []; + } + return AppManager.Instance.getPermissionsById(appId); +}; diff --git a/packages/apps/src/server/IGetAppsFilter.ts b/packages/apps/src/server/IGetAppsFilter.ts new file mode 100644 index 0000000000000..7829725634445 --- /dev/null +++ b/packages/apps/src/server/IGetAppsFilter.ts @@ -0,0 +1,9 @@ +import type { AppInstallationSource } from './storage'; + +export interface IGetAppsFilter { + ids?: Array; + name?: string | RegExp; + enabled?: boolean; + disabled?: boolean; + installationSource?: AppInstallationSource; +} diff --git a/packages/apps/src/server/ProxiedApp.ts b/packages/apps/src/server/ProxiedApp.ts new file mode 100644 index 0000000000000..273b774721ffd --- /dev/null +++ b/packages/apps/src/server/ProxiedApp.ts @@ -0,0 +1,162 @@ +import { inspect } from 'util'; + +import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; +import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; +import type { IAppAuthorInfo, IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; +import { AppMethod } from '@rocket.chat/apps-engine/definition/metadata'; +import mem from 'mem'; + +import type { AppManager } from './AppManager'; +import { InvalidInstallationError } from './errors/InvalidInstallationError'; +import { AppConsole } from './logging'; +import { AppLicenseValidationResult } from './marketplace/license'; +import type { AppsEngineRuntime } from './runtime/AppsEngineRuntime'; +import type { IRuntimeController } from './runtime/IRuntimeController'; +import { JSONRPC_METHOD_NOT_FOUND } from './runtime/deno/AppsEngineDenoRuntime'; +import type { AppInstallationSource, IAppStorageItem } from './storage'; + +export class ProxiedApp { + private previousStatus: AppStatus; + + private latestLicenseValidationResult: AppLicenseValidationResult; + + constructor( + private readonly manager: AppManager, + private storageItem: IAppStorageItem, + private readonly appRuntime: IRuntimeController, + ) { + this.previousStatus = storageItem.status; + + this.appRuntime.on('processExit', () => mem.clear(this.getStatus)); + } + + public getRuntime(): AppsEngineRuntime { + return this.manager.getRuntime(); + } + + public getRuntimeController(): IRuntimeController { + return this.appRuntime; + } + + public getStorageItem(): IAppStorageItem { + return this.storageItem; + } + + public setStorageItem(item: IAppStorageItem): void { + this.storageItem = item; + } + + public getPreviousStatus(): AppStatus { + return this.previousStatus; + } + + public getImplementationList(): { [inter: string]: boolean } { + return this.storageItem.implemented; + } + + public setupLogger(method: `${AppMethod}`): AppConsole { + const logger = new AppConsole(method); + + return logger; + } + + // We'll need to refactor this method to remove the rest parameters so we can pass an options parameter + public async call(method: `${AppMethod}`, ...args: Array): Promise { + let options; + + try { + return await this.appRuntime.sendRequest({ method: `app:${method}`, params: args }, options); + } catch (e) { + if (e.code === AppsEngineException.JSONRPC_ERROR_CODE) { + throw new AppsEngineException(e.message); + } + + if (e.code === JSONRPC_METHOD_NOT_FOUND) { + throw e; + } + + // We cannot throw this error as the previous implementation swallowed those + // and since the server is not prepared to handle those we might crash it if we throw + // Range of JSON-RPC error codes: https://www.jsonrpc.org/specification#error_object + if (e.code >= -32999 || e.code <= -32000) { + // we really need to receive a logger from rocket.chat + console.error('JSON-RPC error received: ', inspect(e, { depth: 10 })); + } + } + } + + public getStatus = mem(() => this.appRuntime.getStatus().catch(() => AppStatus.UNKNOWN), { maxAge: 1000 * 60 * 5 }); + + public async setStatus(status: AppStatus, silent?: boolean): Promise { + await this.call(AppMethod.SETSTATUS, status); + mem.clear(this.getStatus); + if (!silent) { + await this.manager.getBridges().getAppActivationBridge().doAppStatusChanged(this, status); + } + } + + public getName(): string { + return this.storageItem.info.name; + } + + public getNameSlug(): string { + return this.storageItem.info.nameSlug; + } + + // @deprecated This method will be removed in the next major version + public getAppUserUsername(): string { + return `${this.storageItem.info.nameSlug}.bot`; + } + + public getID(): string { + return this.storageItem.id; + } + + public getInstallationSource(): AppInstallationSource { + return this.storageItem.installationSource; + } + + public getVersion(): string { + return this.storageItem.info.version; + } + + public getDescription(): string { + return this.storageItem.info.description; + } + + public getRequiredApiVersion(): string { + return this.storageItem.info.requiredApiVersion; + } + + public getAuthorInfo(): IAppAuthorInfo { + return this.storageItem.info.author; + } + + public getInfo(): IAppInfo { + return this.storageItem.info; + } + + public getEssentials(): IAppInfo['essentials'] { + return this.getInfo().essentials; + } + + public getLatestLicenseValidationResult(): AppLicenseValidationResult { + return this.latestLicenseValidationResult; + } + + public async validateInstallation(): Promise { + try { + await this.manager.getSignatureManager().verifySignedApp(this.getStorageItem()); + } catch (e) { + throw new InvalidInstallationError(e.message); + } + } + + public validateLicense(): Promise { + const { marketplaceInfo } = this.getStorageItem(); + + this.latestLicenseValidationResult = new AppLicenseValidationResult(); + + return this.manager.getLicenseManager().validate(this.latestLicenseValidationResult, marketplaceInfo); + } +} diff --git a/packages/apps/src/server/accessors/ApiExtend.ts b/packages/apps/src/server/accessors/ApiExtend.ts new file mode 100644 index 0000000000000..46e9eeab782bc --- /dev/null +++ b/packages/apps/src/server/accessors/ApiExtend.ts @@ -0,0 +1,15 @@ +import type { IApiExtend } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IApi } from '@rocket.chat/apps-engine/definition/api'; + +import type { AppApiManager } from '../managers/AppApiManager'; + +export class ApiExtend implements IApiExtend { + constructor( + private readonly manager: AppApiManager, + private readonly appId: string, + ) {} + + public provideApi(api: IApi): Promise { + return Promise.resolve(this.manager.addApi(this.appId, api)); + } +} diff --git a/packages/apps/src/server/accessors/AppAccessors.ts b/packages/apps/src/server/accessors/AppAccessors.ts new file mode 100644 index 0000000000000..0959de4db4fea --- /dev/null +++ b/packages/apps/src/server/accessors/AppAccessors.ts @@ -0,0 +1,40 @@ +import type { IAppAccessors, IEnvironmentRead, IEnvironmentWrite, IHttp, IRead } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IApiEndpointMetadata } from '@rocket.chat/apps-engine/definition/api'; + +import type { AppManager } from '../AppManager'; +import type { AppAccessorManager } from '../managers/AppAccessorManager'; +import type { AppApiManager } from '../managers/AppApiManager'; + +export class AppAccessors implements IAppAccessors { + private accessorManager: AppAccessorManager; + + private apiManager: AppApiManager; + + constructor( + manager: AppManager, + private readonly appId: string, + ) { + this.accessorManager = manager.getAccessorManager(); + this.apiManager = manager.getApiManager(); + } + + public get environmentReader(): IEnvironmentRead { + return this.accessorManager.getEnvironmentRead(this.appId); + } + + public get environmentWriter(): IEnvironmentWrite { + return this.accessorManager.getEnvironmentWrite(this.appId); + } + + public get reader(): IRead { + return this.accessorManager.getReader(this.appId); + } + + public get http(): IHttp { + return this.accessorManager.getHttp(this.appId); + } + + public get providedApiEndpoints(): Array { + return this.apiManager.listApis(this.appId); + } +} diff --git a/packages/apps/src/server/accessors/CloudWorkspaceRead.ts b/packages/apps/src/server/accessors/CloudWorkspaceRead.ts new file mode 100644 index 0000000000000..999ec62a9110a --- /dev/null +++ b/packages/apps/src/server/accessors/CloudWorkspaceRead.ts @@ -0,0 +1,15 @@ +import type { ICloudWorkspaceRead } from '@rocket.chat/apps-engine/definition/accessors/ICloudWorkspaceRead'; +import type { IWorkspaceToken } from '@rocket.chat/apps-engine/definition/cloud/IWorkspaceToken'; + +import type { CloudWorkspaceBridge } from '../bridges/CloudWorkspaceBridge'; + +export class CloudWorkspaceRead implements ICloudWorkspaceRead { + constructor( + private readonly cloudBridge: CloudWorkspaceBridge, + private readonly appId: string, + ) {} + + public async getWorkspaceToken(scope: string): Promise { + return this.cloudBridge.doGetWorkspaceToken(scope, this.appId); + } +} diff --git a/packages/apps/src/server/accessors/ConfigurationExtend.ts b/packages/apps/src/server/accessors/ConfigurationExtend.ts new file mode 100644 index 0000000000000..a1629114efd20 --- /dev/null +++ b/packages/apps/src/server/accessors/ConfigurationExtend.ts @@ -0,0 +1,26 @@ +import type { + IApiExtend, + IConfigurationExtend, + IExternalComponentsExtend, + IHttpExtend, + ISchedulerExtend, + ISettingsExtend, + ISlashCommandsExtend, + IUIExtend, + IVideoConfProvidersExtend, + IOutboundCommunicationProviderExtend, +} from '@rocket.chat/apps-engine/definition/accessors'; + +export class ConfigurationExtend implements IConfigurationExtend { + constructor( + public readonly http: IHttpExtend, + public readonly settings: ISettingsExtend, + public readonly slashCommands: ISlashCommandsExtend, + public readonly api: IApiExtend, + public readonly externalComponents: IExternalComponentsExtend, + public readonly scheduler: ISchedulerExtend, + public readonly ui: IUIExtend, + public readonly videoConfProviders: IVideoConfProvidersExtend, + public readonly outboundCommunication: IOutboundCommunicationProviderExtend, + ) {} +} diff --git a/packages/apps/src/server/accessors/ConfigurationModify.ts b/packages/apps/src/server/accessors/ConfigurationModify.ts new file mode 100644 index 0000000000000..66de60bb0d93a --- /dev/null +++ b/packages/apps/src/server/accessors/ConfigurationModify.ts @@ -0,0 +1,14 @@ +import type { + IConfigurationModify, + ISchedulerModify, + IServerSettingsModify, + ISlashCommandsModify, +} from '@rocket.chat/apps-engine/definition/accessors'; + +export class ConfigurationModify implements IConfigurationModify { + constructor( + public readonly serverSettings: IServerSettingsModify, + public readonly slashCommands: ISlashCommandsModify, + public readonly scheduler: ISchedulerModify, + ) {} +} diff --git a/packages/apps/src/server/accessors/ContactCreator.ts b/packages/apps/src/server/accessors/ContactCreator.ts new file mode 100644 index 0000000000000..26d66414052d9 --- /dev/null +++ b/packages/apps/src/server/accessors/ContactCreator.ts @@ -0,0 +1,25 @@ +import type { IContactCreator } from '@rocket.chat/apps-engine/definition/accessors/IContactCreator'; +import type { ILivechatContact } from '@rocket.chat/apps-engine/definition/livechat'; + +import type { AppBridges } from '../bridges'; + +export class ContactCreator implements IContactCreator { + constructor( + private readonly bridges: AppBridges, + private readonly appId: string, + ) {} + + verifyContact(verifyContactChannelParams: { + contactId: string; + field: string; + value: string; + visitorId: string; + roomId: string; + }): Promise { + return this.bridges.getContactBridge().doVerifyContact(verifyContactChannelParams, this.appId); + } + + addContactEmail(contactId: ILivechatContact['_id'], email: string): Promise { + return this.bridges.getContactBridge().doAddContactEmail(contactId, email, this.appId); + } +} diff --git a/packages/apps/src/server/accessors/ContactRead.ts b/packages/apps/src/server/accessors/ContactRead.ts new file mode 100644 index 0000000000000..c950bca5d1327 --- /dev/null +++ b/packages/apps/src/server/accessors/ContactRead.ts @@ -0,0 +1,15 @@ +import type { IContactRead } from '@rocket.chat/apps-engine/definition/accessors/IContactRead'; +import type { ILivechatContact } from '@rocket.chat/apps-engine/definition/livechat'; + +import type { AppBridges } from '../bridges'; + +export class ContactRead implements IContactRead { + constructor( + private readonly bridges: AppBridges, + private readonly appId: string, + ) {} + + public getById(contactId: ILivechatContact['_id']): Promise { + return this.bridges.getContactBridge().doGetById(contactId, this.appId); + } +} diff --git a/packages/apps/src/server/accessors/DiscussionBuilder.ts b/packages/apps/src/server/accessors/DiscussionBuilder.ts new file mode 100644 index 0000000000000..4c65baa6c3f04 --- /dev/null +++ b/packages/apps/src/server/accessors/DiscussionBuilder.ts @@ -0,0 +1,48 @@ +import type { IDiscussionBuilder } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata'; +import { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; + +import { RoomBuilder } from './RoomBuilder'; + +export class DiscussionBuilder extends RoomBuilder implements IDiscussionBuilder { + public kind: RocketChatAssociationModel.DISCUSSION; + + private reply: string; + + private parentMessage: IMessage; + + constructor(data?: Partial) { + super(data); + this.kind = RocketChatAssociationModel.DISCUSSION; + this.room.type = RoomType.PRIVATE_GROUP; + } + + public setParentRoom(parentRoom: IRoom): IDiscussionBuilder { + this.room.parentRoom = parentRoom; + return this; + } + + public getParentRoom(): IRoom { + return this.room.parentRoom; + } + + public setReply(reply: string): IDiscussionBuilder { + this.reply = reply; + return this; + } + + public getReply(): string { + return this.reply; + } + + public setParentMessage(parentMessage: IMessage): IDiscussionBuilder { + this.parentMessage = parentMessage; + return this; + } + + public getParentMessage(): IMessage { + return this.parentMessage; + } +} diff --git a/packages/apps/src/server/accessors/EmailCreator.ts b/packages/apps/src/server/accessors/EmailCreator.ts new file mode 100644 index 0000000000000..921378ed8504d --- /dev/null +++ b/packages/apps/src/server/accessors/EmailCreator.ts @@ -0,0 +1,15 @@ +import type { IEmailCreator } from '@rocket.chat/apps-engine/definition/accessors/IEmailCreator'; +import type { IEmail } from '@rocket.chat/apps-engine/definition/email'; + +import type { AppBridges } from '../bridges'; + +export class EmailCreator implements IEmailCreator { + constructor( + private readonly bridges: AppBridges, + private readonly appId: string, + ) {} + + public async send(email: IEmail): Promise { + return this.bridges.getEmailBridge().doSendEmail(email, this.appId); + } +} diff --git a/packages/apps/src/server/accessors/EnvironmentRead.ts b/packages/apps/src/server/accessors/EnvironmentRead.ts new file mode 100644 index 0000000000000..8400a2c79a4de --- /dev/null +++ b/packages/apps/src/server/accessors/EnvironmentRead.ts @@ -0,0 +1,26 @@ +import type { + IEnvironmentalVariableRead, + IEnvironmentRead, + IServerSettingRead, + ISettingRead, +} from '@rocket.chat/apps-engine/definition/accessors'; + +export class EnvironmentRead implements IEnvironmentRead { + constructor( + private readonly settings: ISettingRead, + private readonly serverSettings: IServerSettingRead, + private readonly envRead: IEnvironmentalVariableRead, + ) {} + + public getSettings(): ISettingRead { + return this.settings; + } + + public getServerSettings(): IServerSettingRead { + return this.serverSettings; + } + + public getEnvironmentVariables(): IEnvironmentalVariableRead { + return this.envRead; + } +} diff --git a/packages/apps/src/server/accessors/EnvironmentWrite.ts b/packages/apps/src/server/accessors/EnvironmentWrite.ts new file mode 100644 index 0000000000000..d13bc5b834f64 --- /dev/null +++ b/packages/apps/src/server/accessors/EnvironmentWrite.ts @@ -0,0 +1,16 @@ +import type { IEnvironmentWrite, IServerSettingUpdater, ISettingUpdater } from '@rocket.chat/apps-engine/definition/accessors'; + +export class EnvironmentWrite implements IEnvironmentWrite { + constructor( + private readonly settings: ISettingUpdater, + private readonly serverSettings: IServerSettingUpdater, + ) {} + + public getSettings(): ISettingUpdater { + return this.settings; + } + + public getServerSettings(): IServerSettingUpdater { + return this.serverSettings; + } +} diff --git a/packages/apps/src/server/accessors/EnvironmentalVariableRead.ts b/packages/apps/src/server/accessors/EnvironmentalVariableRead.ts new file mode 100644 index 0000000000000..c8e45f936b6f6 --- /dev/null +++ b/packages/apps/src/server/accessors/EnvironmentalVariableRead.ts @@ -0,0 +1,22 @@ +import type { IEnvironmentalVariableRead } from '@rocket.chat/apps-engine/definition/accessors'; + +import type { EnvironmentalVariableBridge } from '../bridges'; + +export class EnvironmentalVariableRead implements IEnvironmentalVariableRead { + constructor( + private readonly bridge: EnvironmentalVariableBridge, + private readonly appId: string, + ) {} + + public getValueByName(envVarName: string): Promise { + return this.bridge.doGetValueByName(envVarName, this.appId); + } + + public isReadable(envVarName: string): Promise { + return this.bridge.doIsReadable(envVarName, this.appId); + } + + public isSet(envVarName: string): Promise { + return this.bridge.doIsSet(envVarName, this.appId); + } +} diff --git a/packages/apps/src/server/accessors/ExperimentalRead.ts b/packages/apps/src/server/accessors/ExperimentalRead.ts new file mode 100644 index 0000000000000..0b76281d9e405 --- /dev/null +++ b/packages/apps/src/server/accessors/ExperimentalRead.ts @@ -0,0 +1,10 @@ +import type { IExperimentalRead } from '@rocket.chat/apps-engine/definition/accessors'; + +import type { ExperimentalBridge } from '../bridges'; + +export class ExperimentalRead implements IExperimentalRead { + constructor( + protected readonly experimentalBridge: ExperimentalBridge, + protected readonly appId: string, + ) {} +} diff --git a/packages/apps/src/server/accessors/ExternalComponentsExtend.ts b/packages/apps/src/server/accessors/ExternalComponentsExtend.ts new file mode 100644 index 0000000000000..41397cb817379 --- /dev/null +++ b/packages/apps/src/server/accessors/ExternalComponentsExtend.ts @@ -0,0 +1,16 @@ +import type { IExternalComponentsExtend } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IExternalComponent } from '@rocket.chat/apps-engine/definition/externalComponent/IExternalComponent'; + +import type { AppExternalComponentManager } from '../managers/AppExternalComponentManager'; + +export class ExternalComponentsExtend implements IExternalComponentsExtend { + constructor( + private readonly manager: AppExternalComponentManager, + private readonly appId: string, + ) {} + + // eslint-disable-next-line @typescript-eslint/naming-convention + public async register(externalComponent: IExternalComponent): Promise { + return Promise.resolve(this.manager.addExternalComponent(this.appId, externalComponent)); + } +} diff --git a/packages/apps/src/server/accessors/Http.ts b/packages/apps/src/server/accessors/Http.ts new file mode 100644 index 0000000000000..aa9bcf403cd9b --- /dev/null +++ b/packages/apps/src/server/accessors/Http.ts @@ -0,0 +1,78 @@ +import type { IHttp, IHttpExtend, IHttpRequest, IHttpResponse } from '@rocket.chat/apps-engine/definition/accessors'; +import { RequestMethod } from '@rocket.chat/apps-engine/definition/accessors'; + +import type { AppBridges } from '../bridges/AppBridges'; +import type { AppAccessorManager } from '../managers/AppAccessorManager'; + +export class Http implements IHttp { + constructor( + private readonly accessManager: AppAccessorManager, + private readonly bridges: AppBridges, + private readonly httpExtender: IHttpExtend, + private readonly appId: string, + ) {} + + public get(url: string, options?: IHttpRequest): Promise { + return this._processHandler(url, RequestMethod.GET, options); + } + + public put(url: string, options?: IHttpRequest): Promise { + return this._processHandler(url, RequestMethod.PUT, options); + } + + public post(url: string, options?: IHttpRequest): Promise { + return this._processHandler(url, RequestMethod.POST, options); + } + + public del(url: string, options?: IHttpRequest): Promise { + return this._processHandler(url, RequestMethod.DELETE, options); + } + + public patch(url: string, options?: IHttpRequest): Promise { + return this._processHandler(url, RequestMethod.PATCH, options); + } + + private async _processHandler(url: string, method: RequestMethod, options?: IHttpRequest): Promise { + let request = options || {}; + + if (typeof request.headers === 'undefined') { + request.headers = {}; + } + + this.httpExtender.getDefaultHeaders().forEach((value: string, key: string) => { + if (typeof request.headers[key] !== 'string') { + request.headers[key] = value; + } + }); + + if (typeof request.params === 'undefined') { + request.params = {}; + } + + this.httpExtender.getDefaultParams().forEach((value: string, key: string) => { + if (typeof request.params[key] !== 'string') { + request.params[key] = value; + } + }); + + const reader = this.accessManager.getReader(this.appId); + const persis = this.accessManager.getPersistence(this.appId); + + for (const handler of this.httpExtender.getPreRequestHandlers()) { + request = await handler.executePreHttpRequest(url, request, reader, persis); + } + + let response = await this.bridges.getHttpBridge().doCall({ + appId: this.appId, + method, + url, + request, + }); + + for (const handler of this.httpExtender.getPreResponseHandlers()) { + response = await handler.executePreHttpResponse(response, reader, persis); + } + + return response; + } +} diff --git a/packages/apps/src/server/accessors/HttpExtend.ts b/packages/apps/src/server/accessors/HttpExtend.ts new file mode 100644 index 0000000000000..98311a633577b --- /dev/null +++ b/packages/apps/src/server/accessors/HttpExtend.ts @@ -0,0 +1,58 @@ +import type { IHttpExtend, IHttpPreRequestHandler, IHttpPreResponseHandler } from '@rocket.chat/apps-engine/definition/accessors'; + +export class HttpExtend implements IHttpExtend { + private headers: Map; + + private params: Map; + + private requests: Array; + + private responses: Array; + + constructor() { + this.headers = new Map(); + this.params = new Map(); + this.requests = []; + this.responses = []; + } + + public provideDefaultHeader(key: string, value: string): void { + this.headers.set(key, value); + } + + public provideDefaultHeaders(headers: { [key: string]: string }): void { + Object.keys(headers).forEach((key) => this.headers.set(key, headers[key])); + } + + public provideDefaultParam(key: string, value: string): void { + this.params.set(key, value); + } + + public provideDefaultParams(params: { [key: string]: string }): void { + Object.keys(params).forEach((key) => this.params.set(key, params[key])); + } + + public providePreRequestHandler(handler: IHttpPreRequestHandler): void { + this.requests.push(handler); + } + + public providePreResponseHandler(handler: IHttpPreResponseHandler): void { + this.responses.push(handler); + } + + public getDefaultHeaders(): Map { + return new Map(this.headers); + } + + public getDefaultParams(): Map { + return new Map(this.params); + } + + public getPreRequestHandlers(): Array { + return Array.from(this.requests); + } + + public getPreResponseHandlers(): Array { + return Array.from(this.responses); + } +} diff --git a/packages/apps/src/server/accessors/LivechatCreator.ts b/packages/apps/src/server/accessors/LivechatCreator.ts new file mode 100644 index 0000000000000..a40b2e416affe --- /dev/null +++ b/packages/apps/src/server/accessors/LivechatCreator.ts @@ -0,0 +1,43 @@ +import { randomBytes } from 'crypto'; + +import type { ILivechatCreator } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IExtraRoomParams } from '@rocket.chat/apps-engine/definition/accessors/ILivechatCreator'; +import type { ILivechatRoom } from '@rocket.chat/apps-engine/definition/livechat/ILivechatRoom'; +import type { + IVisitorExternalIdentifier, + IVisitor, + ResolveVisitorContactData, +} from '@rocket.chat/apps-engine/definition/livechat/IVisitor'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import type { AppBridges } from '../bridges'; + +export class LivechatCreator implements ILivechatCreator { + constructor( + private readonly bridges: AppBridges, + private readonly appId: string, + ) {} + + public resolveVisitor(externalId: IVisitorExternalIdentifier, contactData?: ResolveVisitorContactData): Promise { + return this.bridges.getLivechatBridge().doResolveVisitor(externalId, contactData, this.appId); + } + + public createRoom(visitor: IVisitor, agent: IUser, extraParams?: IExtraRoomParams): Promise { + return this.bridges.getLivechatBridge().doCreateRoom(visitor, agent, this.appId, extraParams); + } + + /** + * @deprecated Use `createAndReturnVisitor` instead. + */ + public createVisitor(visitor: IVisitor): Promise { + return this.bridges.getLivechatBridge().doCreateVisitor(visitor, this.appId); + } + + public createAndReturnVisitor(visitor: IVisitor): Promise { + return this.bridges.getLivechatBridge().doCreateAndReturnVisitor(visitor, this.appId); + } + + public createToken(): string { + return randomBytes(16).toString('hex'); // Ensures 128 bits of entropy + } +} diff --git a/packages/apps/src/server/accessors/LivechatMessageBuilder.ts b/packages/apps/src/server/accessors/LivechatMessageBuilder.ts new file mode 100644 index 0000000000000..ffe02e3ed0997 --- /dev/null +++ b/packages/apps/src/server/accessors/LivechatMessageBuilder.ts @@ -0,0 +1,192 @@ +import type { ILivechatMessageBuilder, IMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors'; +import type { ILivechatMessage } from '@rocket.chat/apps-engine/definition/livechat/ILivechatMessage'; +import type { IVisitor } from '@rocket.chat/apps-engine/definition/livechat/IVisitor'; +import type { IMessage, IMessageAttachment } from '@rocket.chat/apps-engine/definition/messages'; +import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import { MessageBuilder } from './MessageBuilder'; + +export class LivechatMessageBuilder implements ILivechatMessageBuilder { + public kind: RocketChatAssociationModel.LIVECHAT_MESSAGE; + + private msg: ILivechatMessage; + + constructor(message?: ILivechatMessage) { + this.kind = RocketChatAssociationModel.LIVECHAT_MESSAGE; + this.msg = message || ({} as ILivechatMessage); + } + + public setData(data: ILivechatMessage): ILivechatMessageBuilder { + delete data.id; + this.msg = data; + + return this; + } + + public setRoom(room: IRoom): ILivechatMessageBuilder { + this.msg.room = room; + return this; + } + + public getRoom(): IRoom { + return this.msg.room; + } + + public setSender(sender: IUser): ILivechatMessageBuilder { + this.msg.sender = sender; + delete this.msg.visitor; + + return this; + } + + public getSender(): IUser { + return this.msg.sender; + } + + public setText(text: string): ILivechatMessageBuilder { + this.msg.text = text; + return this; + } + + public getText(): string { + return this.msg.text; + } + + public setEmojiAvatar(emoji: string): ILivechatMessageBuilder { + this.msg.emoji = emoji; + return this; + } + + public getEmojiAvatar(): string { + return this.msg.emoji; + } + + public setAvatarUrl(avatarUrl: string): ILivechatMessageBuilder { + this.msg.avatarUrl = avatarUrl; + return this; + } + + public getAvatarUrl(): string { + return this.msg.avatarUrl; + } + + public setUsernameAlias(alias: string): ILivechatMessageBuilder { + this.msg.alias = alias; + return this; + } + + public getUsernameAlias(): string { + return this.msg.alias; + } + + public addAttachment(attachment: IMessageAttachment): ILivechatMessageBuilder { + if (!this.msg.attachments) { + this.msg.attachments = []; + } + + this.msg.attachments.push(attachment); + return this; + } + + public setAttachments(attachments: Array): ILivechatMessageBuilder { + this.msg.attachments = attachments; + return this; + } + + public getAttachments(): Array { + return this.msg.attachments; + } + + public replaceAttachment(position: number, attachment: IMessageAttachment): ILivechatMessageBuilder { + if (!this.msg.attachments) { + this.msg.attachments = []; + } + + if (!this.msg.attachments[position]) { + throw new Error(`No attachment found at the index of "${position}" to replace.`); + } + + this.msg.attachments[position] = attachment; + return this; + } + + public removeAttachment(position: number): ILivechatMessageBuilder { + if (!this.msg.attachments) { + this.msg.attachments = []; + } + + if (!this.msg.attachments[position]) { + throw new Error(`No attachment found at the index of "${position}" to remove.`); + } + + this.msg.attachments.splice(position, 1); + + return this; + } + + public setEditor(user: IUser): ILivechatMessageBuilder { + this.msg.editor = user; + return this; + } + + public getEditor(): IUser { + return this.msg.editor; + } + + public setGroupable(groupable: boolean): ILivechatMessageBuilder { + this.msg.groupable = groupable; + return this; + } + + public getGroupable(): boolean { + return this.msg.groupable; + } + + public setParseUrls(parseUrls: boolean): ILivechatMessageBuilder { + this.msg.parseUrls = parseUrls; + return this; + } + + public getParseUrls(): boolean { + return this.msg.parseUrls; + } + + public setToken(token: string): ILivechatMessageBuilder { + this.msg.token = token; + return this; + } + + public getToken(): string { + return this.msg.token; + } + + public setVisitor(visitor: IVisitor): ILivechatMessageBuilder { + this.msg.visitor = visitor; + delete this.msg.sender; + + return this; + } + + public getVisitor(): IVisitor { + return this.msg.visitor; + } + + public getMessage(): ILivechatMessage { + if (!this.msg.room) { + throw new Error('The "room" property is required.'); + } + + if (this.msg.room.type !== RoomType.LIVE_CHAT) { + throw new Error('The room is not a Livechat room'); + } + + return this.msg; + } + + public getMessageBuilder(): IMessageBuilder { + return new MessageBuilder(this.msg as IMessage); + } +} diff --git a/packages/apps/src/server/accessors/LivechatRead.ts b/packages/apps/src/server/accessors/LivechatRead.ts new file mode 100644 index 0000000000000..6e5d5cd443163 --- /dev/null +++ b/packages/apps/src/server/accessors/LivechatRead.ts @@ -0,0 +1,79 @@ +import type { ILivechatRead } from '@rocket.chat/apps-engine/definition/accessors/ILivechatRead'; +import type { IDepartment } from '@rocket.chat/apps-engine/definition/livechat'; +import type { ILivechatRoom } from '@rocket.chat/apps-engine/definition/livechat/ILivechatRoom'; +import type { IVisitor } from '@rocket.chat/apps-engine/definition/livechat/IVisitor'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; + +import type { LivechatBridge } from '../bridges/LivechatBridge'; + +export class LivechatRead implements ILivechatRead { + constructor( + private readonly livechatBridge: LivechatBridge, + private readonly appId: string, + ) {} + + /** + * @deprecated please use the `isOnlineAsync` method instead. + * In the next major, this method will be `async` + */ + public isOnline(departmentId?: string): boolean { + console.warn( + "The `LivechatRead.isOnline` method is deprecated and won't behave as intended. Please use `LivechatRead.isOnlineAsync` instead", + ); + + return this.livechatBridge.doIsOnline(departmentId, this.appId); + } + + public isOnlineAsync(departmentId?: string): Promise { + return this.livechatBridge.doIsOnlineAsync(departmentId, this.appId); + } + + public getDepartmentsEnabledWithAgents(): Promise> { + return this.livechatBridge.doFindDepartmentsEnabledWithAgents(this.appId); + } + + public getLivechatRooms(visitor: IVisitor, departmentId?: string): Promise> { + return this.livechatBridge.doFindRooms(visitor, departmentId, this.appId); + } + + public getLivechatTotalOpenRoomsByAgentId(agentId: string): Promise { + return this.livechatBridge.doCountOpenRoomsByAgentId(agentId, this.appId); + } + + public getLivechatOpenRoomsByAgentId(agentId: string): Promise> { + return this.livechatBridge.doFindOpenRoomsByAgentId(agentId, this.appId); + } + + /** + * @deprecated This method does not adhere to the conversion practices applied + * elsewhere in the Apps-Engine and will be removed in the next major version. + * Prefer the alternative methods to fetch visitors. + */ + public getLivechatVisitors(query: object): Promise> { + return this.livechatBridge.doFindVisitors(query, this.appId); + } + + public getLivechatVisitorById(id: string): Promise { + return this.livechatBridge.doFindVisitorById(id, this.appId); + } + + public getLivechatVisitorByEmail(email: string): Promise { + return this.livechatBridge.doFindVisitorByEmail(email, this.appId); + } + + public getLivechatVisitorByToken(token: string): Promise { + return this.livechatBridge.doFindVisitorByToken(token, this.appId); + } + + public getLivechatVisitorByPhoneNumber(phoneNumber: string): Promise { + return this.livechatBridge.doFindVisitorByPhoneNumber(phoneNumber, this.appId); + } + + public getLivechatDepartmentByIdOrName(value: string): Promise { + return this.livechatBridge.doFindDepartmentByIdOrName(value, this.appId); + } + + public _fetchLivechatRoomMessages(roomId: string): Promise> { + return this.livechatBridge.do_fetchLivechatRoomMessages(this.appId, roomId); + } +} diff --git a/packages/apps/src/server/accessors/LivechatUpdater.ts b/packages/apps/src/server/accessors/LivechatUpdater.ts new file mode 100644 index 0000000000000..cc222bdc5a10b --- /dev/null +++ b/packages/apps/src/server/accessors/LivechatUpdater.ts @@ -0,0 +1,36 @@ +import type { ILivechatUpdater } from '@rocket.chat/apps-engine/definition/accessors'; +import type { + ILivechatRoom, + ILivechatTransferData, + IVisitor, + IVisitorExternalIdentifier, +} from '@rocket.chat/apps-engine/definition/livechat'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import type { AppBridges } from '../bridges'; + +export class LivechatUpdater implements ILivechatUpdater { + constructor( + private readonly bridges: AppBridges, + private readonly appId: string, + ) {} + + public transferVisitor(visitor: IVisitor, transferData: ILivechatTransferData): Promise { + return this.bridges.getLivechatBridge().doTransferVisitor(visitor, transferData, this.appId); + } + + public closeRoom(room: ILivechatRoom, comment: string, closer?: IUser): Promise { + return this.bridges.getLivechatBridge().doCloseRoom(room, comment, closer, this.appId); + } + + public setCustomFields(token: IVisitor['token'], key: string, value: string, overwrite: boolean): Promise { + return this.bridges + .getLivechatBridge() + .doSetCustomFields({ token, key, value, overwrite }, this.appId) + .then((result) => result > 0); + } + + public updateVisitorExternalId(visitorId: string, externalId: Omit): Promise { + return this.bridges.getLivechatBridge().doUpdateVisitorExternalId(visitorId, externalId, this.appId); + } +} diff --git a/packages/apps/src/server/accessors/MessageBuilder.ts b/packages/apps/src/server/accessors/MessageBuilder.ts new file mode 100644 index 0000000000000..9b6aaf3c1e0c6 --- /dev/null +++ b/packages/apps/src/server/accessors/MessageBuilder.ts @@ -0,0 +1,225 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion -- the builder works under the assumption that "gets" would only happen after the corresponding "sets" */ + +import type { IMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IMessage, IMessageAttachment } from '@rocket.chat/apps-engine/definition/messages'; +import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import type { IBlock } from '@rocket.chat/apps-engine/definition/uikit'; +import { BlockBuilder } from '@rocket.chat/apps-engine/definition/uikit'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; +import type { LayoutBlock } from '@rocket.chat/ui-kit'; + +export class MessageBuilder implements IMessageBuilder { + public kind: RocketChatAssociationModel.MESSAGE; + + private msg: IMessage; + + constructor(message?: IMessage) { + this.kind = RocketChatAssociationModel.MESSAGE; + this.msg = message || ({} as IMessage); + } + + public setData(data: IMessage): IMessageBuilder { + delete data.id; + this.msg = data; + + return this; + } + + public setUpdateData(data: IMessage, editor: IUser): IMessageBuilder { + this.msg = data; + this.msg.editor = editor; + this.msg.editedAt = new Date(); + + return this; + } + + public setThreadId(threadId: string): IMessageBuilder { + this.msg.threadId = threadId; + + return this; + } + + public getThreadId(): string { + return this.msg.threadId!; + } + + public setRoom(room: IRoom): IMessageBuilder { + this.msg.room = room; + return this; + } + + public getRoom(): IRoom { + return this.msg.room!; + } + + public setSender(sender: IUser): IMessageBuilder { + this.msg.sender = sender; + return this; + } + + public getSender(): IUser { + return this.msg.sender!; + } + + public setText(text: string): IMessageBuilder { + this.msg.text = text; + return this; + } + + public getText(): string { + return this.msg.text!; + } + + public setEmojiAvatar(emoji: string): IMessageBuilder { + this.msg.emoji = emoji; + return this; + } + + public getEmojiAvatar(): string { + return this.msg.emoji!; + } + + public setAvatarUrl(avatarUrl: string): IMessageBuilder { + this.msg.avatarUrl = avatarUrl; + return this; + } + + public getAvatarUrl(): string { + return this.msg.avatarUrl!; + } + + public setUsernameAlias(alias: string): IMessageBuilder { + this.msg.alias = alias; + return this; + } + + public getUsernameAlias(): string { + return this.msg.alias!; + } + + public addAttachment(attachment: IMessageAttachment): IMessageBuilder { + if (!this.msg.attachments) { + this.msg.attachments = []; + } + + this.msg.attachments.push(attachment); + return this; + } + + public setAttachments(attachments: Array): IMessageBuilder { + this.msg.attachments = attachments; + return this; + } + + public getAttachments(): Array { + return this.msg.attachments!; + } + + public replaceAttachment(position: number, attachment: IMessageAttachment): IMessageBuilder { + if (!this.msg.attachments) { + this.msg.attachments = []; + } + + if (!this.msg.attachments[position]) { + throw new Error(`No attachment found at the index of "${position}" to replace.`); + } + + this.msg.attachments[position] = attachment; + return this; + } + + public removeAttachment(position: number): IMessageBuilder { + if (!this.msg.attachments) { + this.msg.attachments = []; + } + + if (!this.msg.attachments[position]) { + throw new Error(`No attachment found at the index of "${position}" to remove.`); + } + + this.msg.attachments.splice(position, 1); + + return this; + } + + public setEditor(user: IUser): IMessageBuilder { + this.msg.editor = user; + return this; + } + + public getEditor(): IUser { + return this.msg.editor!; + } + + public setGroupable(groupable: boolean): IMessageBuilder { + this.msg.groupable = groupable; + return this; + } + + public getGroupable(): boolean { + return this.msg.groupable!; + } + + public setParseUrls(parseUrls: boolean): IMessageBuilder { + this.msg.parseUrls = parseUrls; + return this; + } + + public getParseUrls() { + return this.msg.parseUrls!; + } + + public getMessage(): IMessage { + if (!this.msg.room) { + throw new Error('The "room" property is required.'); + } + + return this.msg; + } + + public addBlocks(blocks: BlockBuilder | Array) { + if (!Array.isArray(this.msg.blocks)) { + this.msg.blocks = []; + } + + if (blocks instanceof BlockBuilder) { + this.msg.blocks.push(...blocks.getBlocks()); + } else { + this.msg.blocks.push(...blocks); + } + + return this; + } + + public setBlocks(blocks: BlockBuilder | Array) { + if (blocks instanceof BlockBuilder) { + this.msg.blocks = blocks.getBlocks(); + } else { + this.msg.blocks = blocks; + } + + return this; + } + + public getBlocks(): Array { + return this.msg.blocks!; + } + + public addCustomField(key: string, value: any): IMessageBuilder { + if (!this.msg.customFields) { + this.msg.customFields = {}; + } + + if (this.msg.customFields[key]) { + throw new Error(`The message already contains a custom field by the key: ${key}`); + } + + if (key.includes('.')) { + throw new Error(`The given key contains a period, which is not allowed. Key: ${key}`); + } + + this.msg.customFields[key] = value; + return this; + } +} diff --git a/packages/apps/src/server/accessors/MessageExtender.ts b/packages/apps/src/server/accessors/MessageExtender.ts new file mode 100644 index 0000000000000..fee05802d0856 --- /dev/null +++ b/packages/apps/src/server/accessors/MessageExtender.ts @@ -0,0 +1,51 @@ +import type { IMessageExtender } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IMessage, IMessageAttachment } from '@rocket.chat/apps-engine/definition/messages'; +import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata'; + +import { Utilities } from '../misc/Utilities'; + +export class MessageExtender implements IMessageExtender { + public readonly kind: RocketChatAssociationModel.MESSAGE; + + constructor(private msg: IMessage) { + this.kind = RocketChatAssociationModel.MESSAGE; + + if (!Array.isArray(msg.attachments)) { + this.msg.attachments = []; + } + } + + public addCustomField(key: string, value: any): IMessageExtender { + if (!this.msg.customFields) { + this.msg.customFields = {}; + } + + if (this.msg.customFields[key]) { + throw new Error(`The message already contains a custom field by the key: ${key}`); + } + + if (key.includes('.')) { + throw new Error(`The given key contains a period, which is not allowed. Key: ${key}`); + } + + this.msg.customFields[key] = value; + + return this; + } + + public addAttachment(attachment: IMessageAttachment): IMessageExtender { + this.msg.attachments.push(attachment); + + return this; + } + + public addAttachments(attachments: Array): IMessageExtender { + this.msg.attachments = this.msg.attachments.concat(attachments); + + return this; + } + + public getMessage(): IMessage { + return Utilities.deepClone(this.msg); + } +} diff --git a/packages/apps/src/server/accessors/MessageRead.ts b/packages/apps/src/server/accessors/MessageRead.ts new file mode 100644 index 0000000000000..914529d07f000 --- /dev/null +++ b/packages/apps/src/server/accessors/MessageRead.ts @@ -0,0 +1,37 @@ +import type { IMessageRead } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import type { MessageBridge } from '../bridges/MessageBridge'; + +export class MessageRead implements IMessageRead { + constructor( + private messageBridge: MessageBridge, + private appId: string, + ) {} + + public getById(id: string): Promise { + return this.messageBridge.doGetById(id, this.appId); + } + + public async getSenderUser(messageId: string): Promise { + const msg = await this.messageBridge.doGetById(messageId, this.appId); + + if (!msg) { + return undefined; + } + + return msg.sender; + } + + public async getRoom(messageId: string): Promise { + const msg = await this.messageBridge.doGetById(messageId, this.appId); + + if (!msg) { + return undefined; + } + + return msg.room; + } +} diff --git a/packages/apps/src/server/accessors/MessageUpdater.ts b/packages/apps/src/server/accessors/MessageUpdater.ts new file mode 100644 index 0000000000000..33728a8b833e6 --- /dev/null +++ b/packages/apps/src/server/accessors/MessageUpdater.ts @@ -0,0 +1,19 @@ +import type { IMessageUpdater } from '@rocket.chat/apps-engine/definition/accessors/IMessageUpdater'; +import type { Reaction } from '@rocket.chat/apps-engine/definition/messages'; + +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/src/server/accessors/ModerationModify.ts b/packages/apps/src/server/accessors/ModerationModify.ts new file mode 100644 index 0000000000000..e74772efbac22 --- /dev/null +++ b/packages/apps/src/server/accessors/ModerationModify.ts @@ -0,0 +1,24 @@ +import type { IModerationModify } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import type { ModerationBridge } from '../bridges'; + +export class ModerationModify implements IModerationModify { + constructor( + private moderationBridge: ModerationBridge, + _appId: string, + ) {} + + public report(messageId: string, description: string, userId: string, appId: string): Promise { + return this.moderationBridge.doReport(messageId, description, userId, appId); + } + + public dismissReportsByMessageId(messageId: IMessage['id'], reason: string, action: string, appId: string): Promise { + return this.moderationBridge.doDismissReportsByMessageId(messageId, reason, action, appId); + } + + public dismissReportsByUserId(userId: IUser['id'], reason: string, action: string, appId: string): Promise { + return this.moderationBridge.doDismissReportsByUserId(userId, reason, action, appId); + } +} diff --git a/packages/apps/src/server/accessors/Modify.ts b/packages/apps/src/server/accessors/Modify.ts new file mode 100644 index 0000000000000..e982403beed47 --- /dev/null +++ b/packages/apps/src/server/accessors/Modify.ts @@ -0,0 +1,93 @@ +import type { + IModify, + IModifyCreator, + IModifyDeleter, + IModifyExtender, + IModifyUpdater, + INotifier, + ISchedulerModify, + IUIController, +} from '@rocket.chat/apps-engine/definition/accessors'; +import type { IOAuthAppsModify } from '@rocket.chat/apps-engine/definition/accessors/IOAuthAppsModify'; + +import type { AppBridges } from '../bridges'; +import { ModerationModify } from './ModerationModify'; +import { ModifyCreator } from './ModifyCreator'; +import { ModifyDeleter } from './ModifyDeleter'; +import { ModifyExtender } from './ModifyExtender'; +import { ModifyUpdater } from './ModifyUpdater'; +import { Notifier } from './Notifier'; +import { OAuthAppsModify } from './OAuthAppsModify'; +import { SchedulerModify } from './SchedulerModify'; +import { UIController } from './UIController'; + +export class Modify implements IModify { + private creator: IModifyCreator; + + private deleter: IModifyDeleter; + + private updater: IModifyUpdater; + + private extender: IModifyExtender; + + private notifier: INotifier; + + private uiController: IUIController; + + private scheduler: ISchedulerModify; + + private oauthApps: IOAuthAppsModify; + + private moderation: ModerationModify; + + constructor( + private readonly bridges: AppBridges, + private readonly appId: string, + ) { + this.creator = new ModifyCreator(this.bridges, this.appId); + this.deleter = new ModifyDeleter(this.bridges, this.appId); + this.updater = new ModifyUpdater(this.bridges, this.appId); + this.extender = new ModifyExtender(this.bridges, this.appId); + this.notifier = new Notifier(this.bridges.getUserBridge(), this.bridges.getMessageBridge(), this.appId); + this.uiController = new UIController(this.appId, this.bridges); + this.scheduler = new SchedulerModify(this.bridges.getSchedulerBridge(), this.appId); + this.oauthApps = new OAuthAppsModify(this.bridges.getOAuthAppsBridge(), this.appId); + this.moderation = new ModerationModify(this.bridges.getModerationBridge(), this.appId); + } + + public getCreator(): IModifyCreator { + return this.creator; + } + + public getDeleter(): IModifyDeleter { + return this.deleter; + } + + public getUpdater(): IModifyUpdater { + return this.updater; + } + + public getExtender(): IModifyExtender { + return this.extender; + } + + public getNotifier(): INotifier { + return this.notifier; + } + + public getUiController(): IUIController { + return this.uiController; + } + + public getScheduler(): ISchedulerModify { + return this.scheduler; + } + + public getOAuthAppsModifier() { + return this.oauthApps; + } + + public getModerationModifier() { + return this.moderation; + } +} diff --git a/packages/apps/src/server/accessors/ModifyCreator.ts b/packages/apps/src/server/accessors/ModifyCreator.ts new file mode 100644 index 0000000000000..6971680ed4198 --- /dev/null +++ b/packages/apps/src/server/accessors/ModifyCreator.ts @@ -0,0 +1,275 @@ +import type { + IDiscussionBuilder, + ILivechatCreator, + ILivechatMessageBuilder, + IMessageBuilder, + IModifyCreator, + IRoomBuilder, + IUploadCreator, + IUserBuilder, + IVideoConferenceBuilder, +} from '@rocket.chat/apps-engine/definition/accessors'; +import type { IContactCreator } from '@rocket.chat/apps-engine/definition/accessors/IContactCreator'; +import type { IEmailCreator } from '@rocket.chat/apps-engine/definition/accessors/IEmailCreator'; +import type { ILivechatMessage } from '@rocket.chat/apps-engine/definition/livechat/ILivechatMessage'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; +import { BlockBuilder } from '@rocket.chat/apps-engine/definition/uikit'; +import type { IBotUser } from '@rocket.chat/apps-engine/definition/users/IBotUser'; +import { UserType } from '@rocket.chat/apps-engine/definition/users/UserType'; +import type { AppVideoConference } from '@rocket.chat/apps-engine/definition/videoConferences'; + +import { ContactCreator } from './ContactCreator'; +import { DiscussionBuilder } from './DiscussionBuilder'; +import { EmailCreator } from './EmailCreator'; +import { LivechatCreator } from './LivechatCreator'; +import { LivechatMessageBuilder } from './LivechatMessageBuilder'; +import { MessageBuilder } from './MessageBuilder'; +import { RoomBuilder } from './RoomBuilder'; +import { UploadCreator } from './UploadCreator'; +import { UserBuilder } from './UserBuilder'; +import { VideoConferenceBuilder } from './VideoConferenceBuilder'; +import type { AppBridges } from '../bridges'; +import { UIHelper } from '../misc/UIHelper'; + +export class ModifyCreator implements IModifyCreator { + private livechatCreator: LivechatCreator; + + private uploadCreator: UploadCreator; + + private emailCreator: EmailCreator; + + private contactCreator: ContactCreator; + + constructor( + private readonly bridges: AppBridges, + private readonly appId: string, + ) { + this.livechatCreator = new LivechatCreator(bridges, appId); + this.uploadCreator = new UploadCreator(bridges, appId); + this.emailCreator = new EmailCreator(bridges, appId); + this.contactCreator = new ContactCreator(bridges, appId); + } + + public getLivechatCreator(): ILivechatCreator { + return this.livechatCreator; + } + + public getUploadCreator(): IUploadCreator { + return this.uploadCreator; + } + + public getEmailCreator(): IEmailCreator { + return this.emailCreator; + } + + public getContactCreator(): IContactCreator { + return this.contactCreator; + } + + /** + * @deprecated please prefer the rocket.chat/ui-kit components + */ + public getBlockBuilder(): BlockBuilder { + return new BlockBuilder(this.appId); + } + + public startMessage(data?: IMessage): IMessageBuilder { + if (data) { + delete data.id; + } + + return new MessageBuilder(data); + } + + public startLivechatMessage(data?: ILivechatMessage): ILivechatMessageBuilder { + if (data) { + delete data.id; + } + + return new LivechatMessageBuilder(data); + } + + public startRoom(data?: IRoom): IRoomBuilder { + if (data) { + delete data.id; + } + + return new RoomBuilder(data); + } + + public startDiscussion(data?: Partial): IDiscussionBuilder { + if (data) { + delete data.id; + } + + return new DiscussionBuilder(data); + } + + public startVideoConference(data?: Partial): IVideoConferenceBuilder { + return new VideoConferenceBuilder(data); + } + + public startBotUser(data?: Partial): IUserBuilder { + if (data) { + delete data.id; + + const { roles } = data; + + if (roles?.length) { + const hasRole = roles + .map((role) => role.toLocaleLowerCase()) + .some((role) => role === 'admin' || role === 'owner' || role === 'moderator'); + + if (hasRole) { + throw new Error('Invalid role assigned to the user. Should not be admin, owner or moderator.'); + } + } + + if (!data.type) { + data.type = UserType.BOT; + } + } + + return new UserBuilder(data); + } + + public finish( + builder: IMessageBuilder | ILivechatMessageBuilder | IRoomBuilder | IDiscussionBuilder | IVideoConferenceBuilder | IUserBuilder, + ): Promise { + switch (builder.kind) { + case RocketChatAssociationModel.MESSAGE: + return this._finishMessage(builder); + case RocketChatAssociationModel.LIVECHAT_MESSAGE: + return this._finishLivechatMessage(builder); + case RocketChatAssociationModel.ROOM: + return this._finishRoom(builder); + case RocketChatAssociationModel.DISCUSSION: + return this._finishDiscussion(builder as IDiscussionBuilder); + case RocketChatAssociationModel.VIDEO_CONFERENCE: + return this._finishVideoConference(builder); + case RocketChatAssociationModel.USER: + return this._finishUser(builder); + default: + throw new Error('Invalid builder passed to the ModifyCreator.finish function.'); + } + } + + private async _finishMessage(builder: IMessageBuilder): Promise { + const result = builder.getMessage(); + delete result.id; + + if (!result.sender?.id) { + const appUser = await this.bridges.getUserBridge().doGetAppUser(this.appId); + + if (!appUser) { + throw new Error('Invalid sender assigned to the message.'); + } + + result.sender = appUser; + } + + if (result.blocks?.length) { + result.blocks = UIHelper.assignIds(result.blocks, this.appId); + } + + return this.bridges.getMessageBridge().doCreate(result, this.appId); + } + + private _finishLivechatMessage(builder: ILivechatMessageBuilder): Promise { + if (builder.getSender() && !builder.getVisitor()) { + return this._finishMessage(builder.getMessageBuilder()); + } + + const result = builder.getMessage(); + delete result.id; + + if (!result.token && !result.visitor?.token) { + throw new Error('Invalid visitor sending the message'); + } + + result.token = result.visitor ? result.visitor.token : result.token; + + return this.bridges.getLivechatBridge().doCreateMessage(result, this.appId); + } + + private _finishRoom(builder: IRoomBuilder): Promise { + const result = builder.getRoom(); + delete result.id; + + if (!result.type) { + throw new Error('Invalid type assigned to the room.'); + } + + if (result.type !== RoomType.LIVE_CHAT) { + if (!result.creator?.id) { + throw new Error('Invalid creator assigned to the room.'); + } + } + + if (result.type !== RoomType.DIRECT_MESSAGE) { + if (result.type !== RoomType.LIVE_CHAT) { + if (!result.slugifiedName?.trim()) { + throw new Error('Invalid slugifiedName assigned to the room.'); + } + } + + if (!result.displayName?.trim()) { + throw new Error('Invalid displayName assigned to the room.'); + } + } + + return this.bridges.getRoomBridge().doCreate(result, builder.getMembersToBeAddedUsernames(), this.appId); + } + + private _finishDiscussion(builder: IDiscussionBuilder): Promise { + const room = builder.getRoom(); + delete room.id; + + if (!room.creator?.id) { + throw new Error('Invalid creator assigned to the discussion.'); + } + + if (!room.slugifiedName?.trim()) { + throw new Error('Invalid slugifiedName assigned to the discussion.'); + } + + if (!room.displayName?.trim()) { + throw new Error('Invalid displayName assigned to the discussion.'); + } + + if (!room.parentRoom?.id) { + throw new Error('Invalid parentRoom assigned to the discussion.'); + } + + return this.bridges + .getRoomBridge() + .doCreateDiscussion(room, builder.getParentMessage(), builder.getReply(), builder.getMembersToBeAddedUsernames(), this.appId); + } + + private _finishVideoConference(builder: IVideoConferenceBuilder): Promise { + const videoConference = builder.getVideoConference(); + + if (!videoConference.createdBy) { + throw new Error('Invalid creator assigned to the video conference.'); + } + + if (!videoConference.providerName?.trim()) { + throw new Error('Invalid provider name assigned to the video conference.'); + } + + if (!videoConference.rid) { + throw new Error('Invalid roomId assigned to the video conference.'); + } + + return this.bridges.getVideoConferenceBridge().doCreate(videoConference, this.appId); + } + + private _finishUser(builder: IUserBuilder): Promise { + const user = builder.getUser(); + + return this.bridges.getUserBridge().doCreate(user, this.appId); + } +} diff --git a/packages/apps/src/server/accessors/ModifyDeleter.ts b/packages/apps/src/server/accessors/ModifyDeleter.ts new file mode 100644 index 0000000000000..50c390f2d555d --- /dev/null +++ b/packages/apps/src/server/accessors/ModifyDeleter.ts @@ -0,0 +1,39 @@ +import type { IModifyDeleter } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IUser, UserType } from '@rocket.chat/apps-engine/definition/users'; + +import type { AppBridges } from '../bridges'; + +export class ModifyDeleter implements IModifyDeleter { + constructor( + private readonly bridges: AppBridges, + private readonly appId: string, + ) {} + + public async deleteRoom(roomId: string): Promise { + return this.bridges.getRoomBridge().doDelete(roomId, this.appId); + } + + public async deleteUsers(appId: Exclude, userType: UserType.APP | UserType.BOT): Promise { + return this.bridges.getUserBridge().doDeleteUsersCreatedByApp(appId, userType); + } + + public async deleteMessage(message: IMessage, user: IUser): Promise { + return this.bridges.getMessageBridge().doDelete(message, user, this.appId); + } + + /** + * Removes `usernames` from the room's member list + * + * For performance reasons, it is only possible to remove 50 users in one + * call to this method. Removing users is an expensive operation due to the + * amount of entity relationships that need to be modified. + */ + public async removeUsersFromRoom(roomId: string, usernames: Array) { + if (usernames.length > 50) { + throw new Error('A maximum of 50 members can be removed in a single call'); + } + + return this.bridges.getRoomBridge().doRemoveUsers(roomId, usernames, this.appId); + } +} diff --git a/packages/apps/src/server/accessors/ModifyExtender.ts b/packages/apps/src/server/accessors/ModifyExtender.ts new file mode 100644 index 0000000000000..98f8dec4d0361 --- /dev/null +++ b/packages/apps/src/server/accessors/ModifyExtender.ts @@ -0,0 +1,55 @@ +import type { + IMessageExtender, + IModifyExtender, + IRoomExtender, + IVideoConferenceExtender, +} from '@rocket.chat/apps-engine/definition/accessors'; +import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import { MessageExtender } from './MessageExtender'; +import { RoomExtender } from './RoomExtender'; +import { VideoConferenceExtender } from './VideoConferenceExtend'; +import type { AppBridges } from '../bridges/AppBridges'; + +export class ModifyExtender implements IModifyExtender { + constructor( + private readonly bridges: AppBridges, + private readonly appId: string, + ) {} + + public async extendMessage(messageId: string, updater: IUser): Promise { + const msg = await this.bridges.getMessageBridge().doGetById(messageId, this.appId); + msg.editor = updater; + msg.editedAt = new Date(); + + return new MessageExtender(msg); + } + + public async extendRoom(roomId: string, _updater: IUser): Promise { + const room = await this.bridges.getRoomBridge().doGetById(roomId, this.appId); + room.updatedAt = new Date(); + + return new RoomExtender(room); + } + + public async extendVideoConference(id: string): Promise { + const call = await this.bridges.getVideoConferenceBridge().doGetById(id, this.appId); + call._updatedAt = new Date(); + + return new VideoConferenceExtender(call); + } + + public finish(extender: IMessageExtender | IRoomExtender | IVideoConferenceExtender): Promise { + switch (extender.kind) { + case RocketChatAssociationModel.MESSAGE: + return this.bridges.getMessageBridge().doUpdate(extender.getMessage(), this.appId); + case RocketChatAssociationModel.ROOM: + return this.bridges.getRoomBridge().doUpdate(extender.getRoom(), extender.getUsernamesOfMembersBeingAdded(), this.appId); + case RocketChatAssociationModel.VIDEO_CONFERENCE: + return this.bridges.getVideoConferenceBridge().doUpdate(extender.getVideoConference(), this.appId); + default: + throw new Error('Invalid extender passed to the ModifyExtender.finish function.'); + } + } +} diff --git a/packages/apps/src/server/accessors/ModifyUpdater.ts b/packages/apps/src/server/accessors/ModifyUpdater.ts new file mode 100644 index 0000000000000..1da37395ef696 --- /dev/null +++ b/packages/apps/src/server/accessors/ModifyUpdater.ts @@ -0,0 +1,118 @@ +import type { + ILivechatUpdater, + IMessageBuilder, + IMessageUpdater, + IModifyUpdater, + IRoomBuilder, +} from '@rocket.chat/apps-engine/definition/accessors'; +import type { IUserUpdater } from '@rocket.chat/apps-engine/definition/accessors/IUserUpdater'; +import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata'; +import { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import { LivechatUpdater } from './LivechatUpdater'; +import { MessageBuilder } from './MessageBuilder'; +import { MessageUpdater } from './MessageUpdater'; +import { RoomBuilder } from './RoomBuilder'; +import { UserUpdater } from './UserUpdater'; +import type { AppBridges } from '../bridges'; +import { UIHelper } from '../misc/UIHelper'; + +export class ModifyUpdater implements IModifyUpdater { + private livechatUpdater: ILivechatUpdater; + + private userUpdater: IUserUpdater; + + private messageUpdater: IMessageUpdater; + + constructor( + private readonly bridges: AppBridges, + private readonly appId: string, + ) { + 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 { + return this.livechatUpdater; + } + + public getUserUpdater(): IUserUpdater { + return this.userUpdater; + } + + public getMessageUpdater(): IMessageUpdater { + return this.messageUpdater; + } + + public async message(messageId: string, _updater: IUser): Promise { + const msg = await this.bridges.getMessageBridge().doGetById(messageId, this.appId); + + return new MessageBuilder(msg); + } + + public async room(roomId: string, _updater: IUser): Promise { + const room = await this.bridges.getRoomBridge().doGetById(roomId, this.appId); + + return new RoomBuilder(room); + } + + public finish(builder: IMessageBuilder | IRoomBuilder): Promise { + switch (builder.kind) { + case RocketChatAssociationModel.MESSAGE: + return this._finishMessage(builder); + case RocketChatAssociationModel.ROOM: + return this._finishRoom(builder); + default: + throw new Error('Invalid builder passed to the ModifyUpdater.finish function.'); + } + } + + private _finishMessage(builder: IMessageBuilder): Promise { + const result = builder.getMessage(); + + if (!result.id) { + throw new Error("Invalid message, can't update a message without an id."); + } + + if (!result.sender?.id) { + throw new Error('Invalid sender assigned to the message.'); + } + + if (result.blocks?.length) { + result.blocks = UIHelper.assignIds(result.blocks, this.appId); + // result.blocks = this._assignIds(result.blocks); + } + + return this.bridges.getMessageBridge().doUpdate(result, this.appId); + } + + private _finishRoom(builder: IRoomBuilder): Promise { + const result = builder.getRoom(); + + if (!result.id) { + throw new Error('Invalid room, can not update a room without an id.'); + } + + if (!result.type) { + throw new Error('Invalid type assigned to the room.'); + } + + if (result.type !== RoomType.LIVE_CHAT) { + if (!result.creator?.id) { + throw new Error('Invalid creator assigned to the room.'); + } + + if (!result.slugifiedName?.trim()) { + throw new Error('Invalid slugifiedName assigned to the room.'); + } + } + + if (!result.displayName?.trim()) { + throw new Error('Invalid displayName assigned to the room.'); + } + + return this.bridges.getRoomBridge().doUpdate(result, builder.getMembersToBeAddedUsernames(), this.appId); + } +} diff --git a/packages/apps/src/server/accessors/Notifier.ts b/packages/apps/src/server/accessors/Notifier.ts new file mode 100644 index 0000000000000..ba5b46b841717 --- /dev/null +++ b/packages/apps/src/server/accessors/Notifier.ts @@ -0,0 +1,54 @@ +import type { IMessageBuilder, INotifier } from '@rocket.chat/apps-engine/definition/accessors'; +import type { ITypingOptions } from '@rocket.chat/apps-engine/definition/accessors/INotifier'; +import { TypingScope } from '@rocket.chat/apps-engine/definition/accessors/INotifier'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import type { MessageBridge, UserBridge } from '../bridges'; +import { MessageBuilder } from './MessageBuilder'; + +export class Notifier implements INotifier { + constructor( + private readonly userBridge: UserBridge, + private readonly msgBridge: MessageBridge, + private readonly appId: string, + ) {} + + public async notifyUser(user: IUser, message: IMessage): Promise { + if (!message.sender?.id) { + const appUser = (await this.userBridge.doGetAppUser(this.appId)) as IUser; + + message.sender = appUser; + } + + await this.msgBridge.doNotifyUser(user, message, this.appId); + } + + public async notifyRoom(room: IRoom, message: IMessage): Promise { + if (!message.sender?.id) { + const appUser = (await this.userBridge.doGetAppUser(this.appId)) as IUser; + + message.sender = appUser; + } + + await this.msgBridge.doNotifyRoom(room, message, this.appId); + } + + public async typing(options: ITypingOptions): Promise<() => Promise> { + options.scope = options.scope || TypingScope.Room; + + if (!options.username) { + const appUser = await this.userBridge.doGetAppUser(this.appId); + options.username = appUser?.name || ''; + } + + void this.msgBridge.doTyping({ ...options, isTyping: true }, this.appId); + + return () => this.msgBridge.doTyping({ ...options, isTyping: false }, this.appId); + } + + public getMessageBuilder(): IMessageBuilder { + return new MessageBuilder(); + } +} diff --git a/packages/apps/src/server/accessors/OAuthAppsModify.ts b/packages/apps/src/server/accessors/OAuthAppsModify.ts new file mode 100644 index 0000000000000..2946504f5b08e --- /dev/null +++ b/packages/apps/src/server/accessors/OAuthAppsModify.ts @@ -0,0 +1,23 @@ +import type { IOAuthAppParams } from '@rocket.chat/apps-engine/definition/accessors/IOAuthApp'; +import type { IOAuthAppsModify } from '@rocket.chat/apps-engine/definition/accessors/IOAuthAppsModify'; + +import type { OAuthAppsBridge } from '../bridges/OAuthAppsBridge'; + +export class OAuthAppsModify implements IOAuthAppsModify { + constructor( + private readonly oauthAppsBridge: OAuthAppsBridge, + private readonly appId: string, + ) {} + + public async createOAuthApp(oAuthApp: IOAuthAppParams): Promise { + return this.oauthAppsBridge.doCreate(oAuthApp, this.appId); + } + + public async updateOAuthApp(oAuthApp: IOAuthAppParams, id: string): Promise { + return this.oauthAppsBridge.doUpdate(oAuthApp, id, this.appId); + } + + public async deleteOAuthApp(id: string): Promise { + return this.oauthAppsBridge.doDelete(id, this.appId); + } +} diff --git a/packages/apps/src/server/accessors/OAuthAppsReader.ts b/packages/apps/src/server/accessors/OAuthAppsReader.ts new file mode 100644 index 0000000000000..6de492e00a64e --- /dev/null +++ b/packages/apps/src/server/accessors/OAuthAppsReader.ts @@ -0,0 +1,19 @@ +import type { IOAuthApp } from '@rocket.chat/apps-engine/definition/accessors/IOAuthApp'; +import type { IOAuthAppsReader } from '@rocket.chat/apps-engine/definition/accessors/IOAuthAppsReader'; + +import type { OAuthAppsBridge } from '../bridges/OAuthAppsBridge'; + +export class OAuthAppsReader implements IOAuthAppsReader { + constructor( + private readonly oauthAppsBridge: OAuthAppsBridge, + private readonly appId: string, + ) {} + + public async getOAuthAppById(id: string): Promise { + return this.oauthAppsBridge.doGetByid(id, this.appId); + } + + public async getOAuthAppByName(name: string): Promise> { + return this.oauthAppsBridge.doGetByName(name, this.appId); + } +} diff --git a/packages/apps/src/server/accessors/OutboundCommunicationProviderExtend.ts b/packages/apps/src/server/accessors/OutboundCommunicationProviderExtend.ts new file mode 100644 index 0000000000000..d1ba5b7fdc514 --- /dev/null +++ b/packages/apps/src/server/accessors/OutboundCommunicationProviderExtend.ts @@ -0,0 +1,22 @@ +import type { IOutboundCommunicationProviderExtend } from '@rocket.chat/apps-engine/definition/accessors/IOutboundCommunicationProviderExtend'; +import type { + IOutboundPhoneMessageProvider, + IOutboundEmailMessageProvider, +} from '@rocket.chat/apps-engine/definition/outboundCommunication'; + +import type { AppOutboundCommunicationProviderManager } from '../managers/AppOutboundCommunicationProviderManager'; + +export class OutboundMessageProviderExtend implements IOutboundCommunicationProviderExtend { + constructor( + private readonly manager: AppOutboundCommunicationProviderManager, + private readonly appId: string, + ) {} + + public registerPhoneProvider(provider: IOutboundPhoneMessageProvider): Promise { + return Promise.resolve(this.manager.addProvider(this.appId, provider)); + } + + public registerEmailProvider(provider: IOutboundEmailMessageProvider): Promise { + return Promise.resolve(this.manager.addProvider(this.appId, provider)); + } +} diff --git a/packages/apps/src/server/accessors/Persistence.ts b/packages/apps/src/server/accessors/Persistence.ts new file mode 100644 index 0000000000000..cd783e891c34b --- /dev/null +++ b/packages/apps/src/server/accessors/Persistence.ts @@ -0,0 +1,46 @@ +import type { IPersistence } from '../../definition/accessors'; +import type { RocketChatAssociationRecord } from '../../definition/metadata'; +import type { PersistenceBridge } from '../bridges/PersistenceBridge'; + +export class Persistence implements IPersistence { + constructor( + private persistBridge: PersistenceBridge, + private appId: string, + ) {} + + public create(data: object): Promise { + return this.persistBridge.doCreate(data, this.appId); + } + + public createWithAssociation(data: object, association: RocketChatAssociationRecord): Promise { + return this.persistBridge.doCreateWithAssociations(data, new Array(association), this.appId); + } + + public createWithAssociations(data: object, associations: Array): Promise { + return this.persistBridge.doCreateWithAssociations(data, associations, this.appId); + } + + public update(id: string, data: object, upsert = false): Promise { + return this.persistBridge.doUpdate(id, data, upsert, this.appId); + } + + public updateByAssociation(association: RocketChatAssociationRecord, data: object, upsert = false): Promise { + return this.persistBridge.doUpdateByAssociations(new Array(association), data, upsert, this.appId); + } + + public updateByAssociations(associations: Array, data: object, upsert = false): Promise { + return this.persistBridge.doUpdateByAssociations(associations, data, upsert, this.appId); + } + + public remove(id: string): Promise { + return this.persistBridge.doRemove(id, this.appId); + } + + public removeByAssociation(association: RocketChatAssociationRecord): Promise> { + return this.persistBridge.doRemoveByAssociations(new Array(association), this.appId); + } + + public removeByAssociations(associations: Array): Promise> { + return this.persistBridge.doRemoveByAssociations(associations, this.appId); + } +} diff --git a/packages/apps/src/server/accessors/PersistenceRead.ts b/packages/apps/src/server/accessors/PersistenceRead.ts new file mode 100644 index 0000000000000..dc8fcddc935c4 --- /dev/null +++ b/packages/apps/src/server/accessors/PersistenceRead.ts @@ -0,0 +1,23 @@ +import type { IPersistenceRead } from '@rocket.chat/apps-engine/definition/accessors'; +import type { RocketChatAssociationRecord } from '@rocket.chat/apps-engine/definition/metadata'; + +import type { PersistenceBridge } from '../bridges'; + +export class PersistenceRead implements IPersistenceRead { + constructor( + private persistBridge: PersistenceBridge, + private appId: string, + ) {} + + public read(id: string): Promise { + return this.persistBridge.doReadById(id, this.appId); + } + + public readByAssociation(association: RocketChatAssociationRecord): Promise> { + return this.persistBridge.doReadByAssociations(new Array(association), this.appId); + } + + public readByAssociations(associations: Array): Promise> { + return this.persistBridge.doReadByAssociations(associations, this.appId); + } +} diff --git a/packages/apps/src/server/accessors/Reader.ts b/packages/apps/src/server/accessors/Reader.ts new file mode 100644 index 0000000000000..2f73452b1af85 --- /dev/null +++ b/packages/apps/src/server/accessors/Reader.ts @@ -0,0 +1,98 @@ +import type { + ICloudWorkspaceRead, + IEnvironmentRead, + IExperimentalRead, + ILivechatRead, + IMessageRead, + INotifier, + IPersistenceRead, + IRead, + IRoomRead, + IUploadRead, + IUserRead, + IVideoConferenceRead, +} from '@rocket.chat/apps-engine/definition/accessors'; +import type { IContactRead } from '@rocket.chat/apps-engine/definition/accessors/IContactRead'; +import type { IOAuthAppsReader } from '@rocket.chat/apps-engine/definition/accessors/IOAuthAppsReader'; +import type { IRoleRead } from '@rocket.chat/apps-engine/definition/accessors/IRoleRead'; +import type { IThreadRead } from '@rocket.chat/apps-engine/definition/accessors/IThreadRead'; + +export class Reader implements IRead { + constructor( + private env: IEnvironmentRead, + private message: IMessageRead, + private persist: IPersistenceRead, + private room: IRoomRead, + private user: IUserRead, + private noti: INotifier, + private livechat: ILivechatRead, + private upload: IUploadRead, + private cloud: ICloudWorkspaceRead, + private videoConf: IVideoConferenceRead, + private contactRead: IContactRead, + private oauthApps: IOAuthAppsReader, + private thread: IThreadRead, + private role: IRoleRead, + private experimental: IExperimentalRead, + ) {} + + public getEnvironmentReader(): IEnvironmentRead { + return this.env; + } + + public getThreadReader(): IThreadRead { + return this.thread; + } + + public getMessageReader(): IMessageRead { + return this.message; + } + + public getPersistenceReader(): IPersistenceRead { + return this.persist; + } + + public getRoomReader(): IRoomRead { + return this.room; + } + + public getUserReader(): IUserRead { + return this.user; + } + + public getNotifier(): INotifier { + return this.noti; + } + + public getLivechatReader(): ILivechatRead { + return this.livechat; + } + + public getUploadReader(): IUploadRead { + return this.upload; + } + + public getCloudWorkspaceReader(): ICloudWorkspaceRead { + return this.cloud; + } + + public getVideoConferenceReader(): IVideoConferenceRead { + return this.videoConf; + } + + public getOAuthAppsReader(): IOAuthAppsReader { + return this.oauthApps; + } + + public getRoleReader(): IRoleRead { + return this.role; + } + + public getContactReader(): IContactRead { + return this.contactRead; + } + + public getExperimentalReader(): IExperimentalRead { + return this.experimental; + } +} diff --git a/packages/apps/src/server/accessors/RoleRead.ts b/packages/apps/src/server/accessors/RoleRead.ts new file mode 100644 index 0000000000000..a6ad18742aea5 --- /dev/null +++ b/packages/apps/src/server/accessors/RoleRead.ts @@ -0,0 +1,19 @@ +import type { IRoleRead } from '@rocket.chat/apps-engine/definition/accessors/IRoleRead'; +import type { IRole } from '@rocket.chat/apps-engine/definition/roles'; + +import type { RoleBridge } from '../bridges'; + +export class RoleRead implements IRoleRead { + constructor( + private roleBridge: RoleBridge, + private appId: string, + ) {} + + public getOneByIdOrName(idOrName: string): Promise { + return this.roleBridge.doGetOneByIdOrName(idOrName, this.appId); + } + + public getCustomRoles(): Promise> { + return this.roleBridge.doGetCustomRoles(this.appId); + } +} diff --git a/packages/apps/src/server/accessors/RoomBuilder.ts b/packages/apps/src/server/accessors/RoomBuilder.ts new file mode 100644 index 0000000000000..aa1e4c31d43b7 --- /dev/null +++ b/packages/apps/src/server/accessors/RoomBuilder.ts @@ -0,0 +1,155 @@ +import type { IRoomBuilder } from '@rocket.chat/apps-engine/definition/accessors'; +import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata'; +import type { IRoom, RoomType } from '@rocket.chat/apps-engine/definition/rooms'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +export class RoomBuilder implements IRoomBuilder { + public kind: RocketChatAssociationModel.ROOM | RocketChatAssociationModel.DISCUSSION; + + protected room: IRoom; + + private members: Array; + + constructor(data?: Partial) { + this.kind = RocketChatAssociationModel.ROOM; + this.room = (data || { customFields: {} }) as IRoom; + this.members = []; + } + + public setData(data: Partial): IRoomBuilder { + delete data.id; + this.room = data as IRoom; + + return this; + } + + public setDisplayName(name: string): IRoomBuilder { + this.room.displayName = name; + return this; + } + + public getDisplayName(): string { + return this.room.displayName; + } + + public setSlugifiedName(name: string): IRoomBuilder { + this.room.slugifiedName = name; + return this; + } + + public getSlugifiedName(): string { + return this.room.slugifiedName; + } + + public setType(type: RoomType): IRoomBuilder { + this.room.type = type; + return this; + } + + public getType(): RoomType { + return this.room.type; + } + + public setCreator(creator: IUser): IRoomBuilder { + this.room.creator = creator; + return this; + } + + public getCreator(): IUser { + return this.room.creator; + } + + /** + * @deprecated + */ + public addUsername(username: string): IRoomBuilder { + this.addMemberToBeAddedByUsername(username); + return this; + } + + /** + * @deprecated + */ + public setUsernames(usernames: Array): IRoomBuilder { + this.setMembersToBeAddedByUsernames(usernames); + return this; + } + + /** + * @deprecated + */ + public getUsernames(): Array { + const usernames = this.getMembersToBeAddedUsernames(); + if (usernames && usernames.length > 0) { + return usernames; + } + return this.room.usernames || []; + } + + public addMemberToBeAddedByUsername(username: string): IRoomBuilder { + this.members.push(username); + return this; + } + + public setMembersToBeAddedByUsernames(usernames: Array): IRoomBuilder { + this.members = usernames; + return this; + } + + public getMembersToBeAddedUsernames(): Array { + return this.members; + } + + public setDefault(isDefault: boolean): IRoomBuilder { + this.room.isDefault = isDefault; + return this; + } + + public getIsDefault(): boolean { + return this.room.isDefault; + } + + public setReadOnly(isReadOnly: boolean): IRoomBuilder { + this.room.isReadOnly = isReadOnly; + return this; + } + + public getIsReadOnly(): boolean { + return this.room.isReadOnly; + } + + public setDisplayingOfSystemMessages(displaySystemMessages: boolean): IRoomBuilder { + this.room.displaySystemMessages = displaySystemMessages; + return this; + } + + public getDisplayingOfSystemMessages(): boolean { + return this.room.displaySystemMessages; + } + + public addCustomField(key: string, value: object): IRoomBuilder { + if (typeof this.room.customFields !== 'object') { + this.room.customFields = {}; + } + + this.room.customFields[key] = value; + return this; + } + + public setCustomFields(fields: { [key: string]: object }): IRoomBuilder { + this.room.customFields = fields; + return this; + } + + public getCustomFields(): { [key: string]: object } { + return this.room.customFields; + } + + public getUserIds(): Array { + return this.room.userIds; + } + + public getRoom(): IRoom { + return this.room; + } +} diff --git a/packages/apps/src/server/accessors/RoomExtender.ts b/packages/apps/src/server/accessors/RoomExtender.ts new file mode 100644 index 0000000000000..05722126d6791 --- /dev/null +++ b/packages/apps/src/server/accessors/RoomExtender.ts @@ -0,0 +1,57 @@ +import type { IRoomExtender } from '@rocket.chat/apps-engine/definition/accessors'; +import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import { Utilities } from '../misc/Utilities'; + +export class RoomExtender implements IRoomExtender { + public kind: RocketChatAssociationModel.ROOM; + + private members: Array; + + constructor(private room: IRoom) { + this.kind = RocketChatAssociationModel.ROOM; + this.members = []; + } + + public addCustomField(key: string, value: any): IRoomExtender { + if (!this.room.customFields) { + this.room.customFields = {}; + } + + if (this.room.customFields[key]) { + throw new Error(`The room already contains a custom field by the key: ${key}`); + } + + if (key.includes('.')) { + throw new Error(`The given key contains a period, which is not allowed. Key: ${key}`); + } + + this.room.customFields[key] = value; + + return this; + } + + public addMember(user: IUser): IRoomExtender { + if (this.members.find((u) => u.username === user.username)) { + throw new Error('The user is already in the room.'); + } + + this.members.push(user); + + return this; + } + + public getMembersBeingAdded(): Array { + return this.members; + } + + public getUsernamesOfMembersBeingAdded(): Array { + return this.members.map((u) => u.username); + } + + public getRoom(): IRoom { + return Utilities.deepClone(this.room); + } +} diff --git a/packages/apps/src/server/accessors/RoomRead.ts b/packages/apps/src/server/accessors/RoomRead.ts new file mode 100644 index 0000000000000..99bf71e51e913 --- /dev/null +++ b/packages/apps/src/server/accessors/RoomRead.ts @@ -0,0 +1,112 @@ +import type { IRoomRead } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IMessageRaw } from '@rocket.chat/apps-engine/definition/messages'; +import type { IRoom, IRoomRaw } from '@rocket.chat/apps-engine/definition/rooms'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import type { RoomBridge } from '../bridges'; +import { type GetMessagesOptions, type GetRoomsFilters, type GetRoomsOptions, GetMessagesSortableFields } from '../bridges/RoomBridge'; + +export class RoomRead implements IRoomRead { + constructor( + private roomBridge: RoomBridge, + private appId: string, + ) {} + + public getById(id: string): Promise { + return this.roomBridge.doGetById(id, this.appId); + } + + public getCreatorUserById(id: string): Promise { + return this.roomBridge.doGetCreatorById(id, this.appId); + } + + public getByName(name: string): Promise { + return this.roomBridge.doGetByName(name, this.appId); + } + + public getCreatorUserByName(name: string): Promise { + return this.roomBridge.doGetCreatorByName(name, this.appId); + } + + public getMessages(roomId: string, options: Partial = {}): Promise { + if (typeof options.limit !== 'undefined' && (!Number.isFinite(options.limit) || options.limit > 100)) { + throw new Error(`Invalid limit provided. Expected number <= 100, got ${options.limit}`); + } + + options.limit ??= 100; + options.showThreadMessages ??= true; + + if (options.sort) { + this.validateSort(options.sort); + } + + return this.roomBridge.doGetMessages(roomId, options as GetMessagesOptions, this.appId); + } + + public getMembers(roomId: string): Promise> { + return this.roomBridge.doGetMembers(roomId, this.appId); + } + + public getAllRooms(filters: GetRoomsFilters = {}, { limit = 100, skip = 0 }: GetRoomsOptions = {}): Promise | undefined> { + if (!Number.isFinite(limit) || limit <= 0 || limit > 100) { + throw new Error(`Invalid limit provided. Expected number between 1 and 100, got ${limit}`); + } + + if (!Number.isFinite(skip) || skip < 0) { + throw new Error(`Invalid skip provided. Expected number >= 0, got ${skip}`); + } + + return this.roomBridge.doGetAllRooms(filters, { limit, skip }, this.appId); + } + + public getDirectByUsernames(usernames: Array): Promise { + return this.roomBridge.doGetDirectByUsernames(usernames, this.appId); + } + + public getModerators(roomId: string): Promise> { + return this.roomBridge.doGetModerators(roomId, this.appId); + } + + public getOwners(roomId: string): Promise> { + return this.roomBridge.doGetOwners(roomId, this.appId); + } + + public getLeaders(roomId: string): Promise> { + return this.roomBridge.doGetLeaders(roomId, this.appId); + } + + public async getUnreadByUser(roomId: string, uid: string, options: Partial = {}): Promise { + const { limit = 100, sort = { createdAt: 'asc' }, skip = 0, showThreadMessages = true } = options; + + if (typeof roomId !== 'string' || roomId.trim().length === 0) { + throw new Error('Invalid roomId: must be a non-empty string'); + } + + if (!Number.isFinite(limit) || limit <= 0 || limit > 100) { + throw new Error(`Invalid limit provided. Expected number between 1 and 100, got ${limit}`); + } + + this.validateSort(sort); + + const completeOptions: GetMessagesOptions = { limit, sort, skip, showThreadMessages }; + + return this.roomBridge.doGetUnreadByUser(roomId, uid, completeOptions, this.appId); + } + + public getUserUnreadMessageCount(roomId: string, uid: string): Promise { + return this.roomBridge.doGetUserUnreadMessageCount(roomId, uid, this.appId); + } + + // If there are any invalid fields or values, throw + private validateSort(sort: Record) { + Object.entries(sort).forEach(([key, value]) => { + if (!GetMessagesSortableFields.includes(key as (typeof GetMessagesSortableFields)[number])) { + throw new Error(`Invalid key "${key}" used in sort. Available keys for sorting are ${GetMessagesSortableFields.join(', ')}`); + } + + if (value !== 'asc' && value !== 'desc') { + throw new Error(`Invalid sort direction for field "${key}". Expected "asc" or "desc", got ${value}`); + } + }); + } +} diff --git a/packages/apps/src/server/accessors/SchedulerExtend.ts b/packages/apps/src/server/accessors/SchedulerExtend.ts new file mode 100644 index 0000000000000..753bd4aa14a1a --- /dev/null +++ b/packages/apps/src/server/accessors/SchedulerExtend.ts @@ -0,0 +1,15 @@ +import type { ISchedulerExtend } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IProcessor } from '@rocket.chat/apps-engine/definition/scheduler'; + +import type { AppSchedulerManager } from '../managers/AppSchedulerManager'; + +export class SchedulerExtend implements ISchedulerExtend { + constructor( + private readonly manager: AppSchedulerManager, + private readonly appId: string, + ) {} + + public async registerProcessors(processors: Array = []): Promise> { + return this.manager.registerProcessors(processors, this.appId); + } +} diff --git a/packages/apps/src/server/accessors/SchedulerModify.ts b/packages/apps/src/server/accessors/SchedulerModify.ts new file mode 100644 index 0000000000000..20e82909ac3eb --- /dev/null +++ b/packages/apps/src/server/accessors/SchedulerModify.ts @@ -0,0 +1,31 @@ +import type { ISchedulerModify } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IOnetimeSchedule, IRecurringSchedule } from '@rocket.chat/apps-engine/definition/scheduler'; + +import type { SchedulerBridge } from '../bridges'; + +function createProcessorId(jobId: string, appId: string): string { + return jobId.includes(`_${appId}`) ? jobId : `${jobId}_${appId}`; +} + +export class SchedulerModify implements ISchedulerModify { + constructor( + private readonly bridge: SchedulerBridge, + private readonly appId: string, + ) {} + + public async scheduleOnce(job: IOnetimeSchedule): Promise { + return this.bridge.doScheduleOnce({ ...job, id: createProcessorId(job.id, this.appId) }, this.appId); + } + + public async scheduleRecurring(job: IRecurringSchedule): Promise { + return this.bridge.doScheduleRecurring({ ...job, id: createProcessorId(job.id, this.appId) }, this.appId); + } + + public async cancelJob(jobId: string): Promise { + return this.bridge.doCancelJob(createProcessorId(jobId, this.appId), this.appId); + } + + public async cancelAllJobs(): Promise { + return this.bridge.doCancelAllJobs(this.appId); + } +} diff --git a/packages/apps/src/server/accessors/ServerSettingRead.ts b/packages/apps/src/server/accessors/ServerSettingRead.ts new file mode 100644 index 0000000000000..5bfab63c4054f --- /dev/null +++ b/packages/apps/src/server/accessors/ServerSettingRead.ts @@ -0,0 +1,38 @@ +import type { IServerSettingRead } from '@rocket.chat/apps-engine/definition/accessors'; +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; + +import type { ServerSettingBridge } from '../bridges/ServerSettingBridge'; + +export class ServerSettingRead implements IServerSettingRead { + constructor( + private readonly settingBridge: ServerSettingBridge, + private readonly appId: string, + ) {} + + public getOneById(id: string): Promise { + return this.settingBridge.doGetOneById(id, this.appId); + } + + public async getValueById(id: string): Promise { + const set = await this.settingBridge.doGetOneById(id, this.appId); + + if (typeof set === 'undefined') { + throw new Error(`No Server Setting found, or it is unaccessible, by the id of "${id}".`); + } + + if (set.value === undefined || set.value === null) { + return set.packageValue; + } + + return set.value; + } + + public getAll(): Promise> { + throw new Error('Method not implemented.'); + // return this.settingBridge.getAll(this.appId); + } + + public isReadableById(id: string): Promise { + return this.settingBridge.doIsReadableById(id, this.appId); + } +} diff --git a/packages/apps/src/server/accessors/ServerSettingUpdater.ts b/packages/apps/src/server/accessors/ServerSettingUpdater.ts new file mode 100644 index 0000000000000..280aae967a819 --- /dev/null +++ b/packages/apps/src/server/accessors/ServerSettingUpdater.ts @@ -0,0 +1,19 @@ +import type { IServerSettingUpdater } from '@rocket.chat/apps-engine/definition/accessors'; +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; + +import type { AppBridges } from '../bridges'; + +export class ServerSettingUpdater implements IServerSettingUpdater { + constructor( + private readonly bridges: AppBridges, + private readonly appId: string, + ) {} + + public async updateOne(setting: ISetting): Promise { + await this.bridges.getServerSettingBridge().doUpdateOne(setting, this.appId); + } + + public async incrementValue(id: ISetting['id'], value = 1): Promise { + await this.bridges.getServerSettingBridge().doIncrementValue(id, value, this.appId); + } +} diff --git a/packages/apps/src/server/accessors/ServerSettingsModify.ts b/packages/apps/src/server/accessors/ServerSettingsModify.ts new file mode 100644 index 0000000000000..c742ffbe5bf70 --- /dev/null +++ b/packages/apps/src/server/accessors/ServerSettingsModify.ts @@ -0,0 +1,27 @@ +import type { IServerSettingsModify } from '@rocket.chat/apps-engine/definition/accessors'; +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; + +import type { ServerSettingBridge } from '../bridges/ServerSettingBridge'; + +export class ServerSettingsModify implements IServerSettingsModify { + constructor( + private readonly bridge: ServerSettingBridge, + private readonly appId: string, + ) {} + + public async hideGroup(name: string): Promise { + await this.bridge.doHideGroup(name, this.appId); + } + + public async hideSetting(id: string): Promise { + await this.bridge.doHideSetting(id, this.appId); + } + + public async modifySetting(setting: ISetting): Promise { + await this.bridge.doUpdateOne(setting, this.appId); + } + + public async incrementValue(id: ISetting['id'], value = 1): Promise { + await this.bridge.doIncrementValue(id, value, this.appId); + } +} diff --git a/packages/apps/src/server/accessors/SettingRead.ts b/packages/apps/src/server/accessors/SettingRead.ts new file mode 100644 index 0000000000000..04d076fa7d323 --- /dev/null +++ b/packages/apps/src/server/accessors/SettingRead.ts @@ -0,0 +1,26 @@ +import type { ISettingRead } from '@rocket.chat/apps-engine/definition/accessors'; +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; + +import type { ProxiedApp } from '../ProxiedApp'; + +export class SettingRead implements ISettingRead { + constructor(private readonly app: ProxiedApp) {} + + public getById(id: string): Promise { + return Promise.resolve(this.app.getStorageItem().settings[id]); + } + + public async getValueById(id: string): Promise { + const set = await this.getById(id); + + if (typeof set === 'undefined') { + throw new Error(`Setting "${id}" does not exist.`); + } + + if (set.value === undefined || set.value === null) { + return set.packageValue; + } + + return set.value; + } +} diff --git a/packages/apps/src/server/accessors/SettingUpdater.ts b/packages/apps/src/server/accessors/SettingUpdater.ts new file mode 100644 index 0000000000000..45bc4b5ecabbe --- /dev/null +++ b/packages/apps/src/server/accessors/SettingUpdater.ts @@ -0,0 +1,67 @@ +import type { ISettingUpdater } from '@rocket.chat/apps-engine/definition/accessors/ISettingUpdater'; +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; + +import type { ProxiedApp } from '../ProxiedApp'; +import type { AppSettingsManager } from '../managers'; + +/** + * Implementation of ISettingUpdater that provides methods to update app settings. + */ +export class SettingUpdater implements ISettingUpdater { + constructor( + private readonly app: ProxiedApp, + private readonly manager: AppSettingsManager, + ) {} + + /** + * Updates a single setting value + * @param id The setting ID to update + * @param value The new value to set + * @returns Promise that resolves when the update is complete + * @throws Error if the setting doesn't exist + */ + public async updateValue(id: ISetting['id'], value: ISetting['value']): Promise { + const appId = this.app.getID(); + const storageItem = this.app.getStorageItem(); + + if (!storageItem.settings?.[id]) { + throw new Error(`Setting "${id}" not found for app ${appId}`); + } + + const setting = this.manager.getAppSetting(appId, id); + + await this.manager.updateAppSetting(appId, { + ...setting, + updatedAt: new Date(), + value, + }); + } + + /** + * Updates the values for a multi-value setting by overwriting them + * @param id The setting ID to update + * @param values The new values to set + * @returns Promise that resolves when the update is complete + * @throws Error if the setting doesn't exist + */ + public async updateSelectOptions(id: ISetting['id'], values: ISetting['values']): Promise { + const appId = this.app.getID(); + const storageItem = this.app.getStorageItem(); + + if (!storageItem.settings?.[id]) { + throw new Error(`Setting "${id}" not found for app ${appId}`); + } + + const setting = this.manager.getAppSetting(appId, id); + + // TODO: This operation completely overwrites existing values + // which could lead to loss of selected values. Consider: + // Adding warning logs when selected value will be removed + + await this.manager.updateAppSetting(appId, { + ...setting, + updatedAt: new Date(), + values, // Overwrite the values instead of merging + }); + } +} diff --git a/packages/apps/src/server/accessors/SettingsExtend.ts b/packages/apps/src/server/accessors/SettingsExtend.ts new file mode 100644 index 0000000000000..951c3f4610a83 --- /dev/null +++ b/packages/apps/src/server/accessors/SettingsExtend.ts @@ -0,0 +1,27 @@ +import type { ISettingsExtend } from '@rocket.chat/apps-engine/definition/accessors'; +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; + +import type { ProxiedApp } from '../ProxiedApp'; + +export class SettingsExtend implements ISettingsExtend { + constructor(private readonly app: ProxiedApp) {} + + public async provideSetting(setting: ISetting): Promise { + if (this.app.getStorageItem().settings[setting.id]) { + // :see_no_evil: + const old = await Promise.resolve(this.app.getStorageItem().settings[setting.id]); + + setting.createdAt = old.createdAt; + setting.updatedAt = new Date(); + setting.value = old.value; + + this.app.getStorageItem().settings[setting.id] = setting; + + return; + } + + setting.createdAt = new Date(); + setting.updatedAt = new Date(); + this.app.getStorageItem().settings[setting.id] = setting; + } +} diff --git a/packages/apps/src/server/accessors/SlashCommandsExtend.ts b/packages/apps/src/server/accessors/SlashCommandsExtend.ts new file mode 100644 index 0000000000000..981dc8d01ffe7 --- /dev/null +++ b/packages/apps/src/server/accessors/SlashCommandsExtend.ts @@ -0,0 +1,15 @@ +import type { ISlashCommandsExtend } from '@rocket.chat/apps-engine/definition/accessors'; +import type { ISlashCommand } from '@rocket.chat/apps-engine/definition/slashcommands'; + +import type { AppSlashCommandManager } from '../managers/AppSlashCommandManager'; + +export class SlashCommandsExtend implements ISlashCommandsExtend { + constructor( + private readonly manager: AppSlashCommandManager, + private readonly appId: string, + ) {} + + public async provideSlashCommand(slashCommand: ISlashCommand): Promise { + await this.manager.addCommand(this.appId, slashCommand); + } +} diff --git a/packages/apps/src/server/accessors/SlashCommandsModify.ts b/packages/apps/src/server/accessors/SlashCommandsModify.ts new file mode 100644 index 0000000000000..469f9cda3c37d --- /dev/null +++ b/packages/apps/src/server/accessors/SlashCommandsModify.ts @@ -0,0 +1,23 @@ +import type { ISlashCommandsModify } from '@rocket.chat/apps-engine/definition/accessors'; +import type { ISlashCommand } from '@rocket.chat/apps-engine/definition/slashcommands'; + +import type { AppSlashCommandManager } from '../managers'; + +export class SlashCommandsModify implements ISlashCommandsModify { + constructor( + private readonly manager: AppSlashCommandManager, + private readonly appId: string, + ) {} + + public modifySlashCommand(slashCommand: ISlashCommand): Promise { + return Promise.resolve(this.manager.modifyCommand(this.appId, slashCommand)); + } + + public disableSlashCommand(command: string): Promise { + return Promise.resolve(this.manager.disableCommand(this.appId, command)); + } + + public enableSlashCommand(command: string): Promise { + return Promise.resolve(this.manager.enableCommand(this.appId, command)); + } +} diff --git a/packages/apps/src/server/accessors/ThreadRead.ts b/packages/apps/src/server/accessors/ThreadRead.ts new file mode 100644 index 0000000000000..1cb6a6a66a6a9 --- /dev/null +++ b/packages/apps/src/server/accessors/ThreadRead.ts @@ -0,0 +1,15 @@ +import type { IThreadRead } from '@rocket.chat/apps-engine/definition/accessors/IThreadRead'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; + +import type { ThreadBridge } from '../bridges/ThreadBridge'; + +export class ThreadRead implements IThreadRead { + constructor( + private threadBridge: ThreadBridge, + private appId: string, + ) {} + + public getThreadById(id: string): Promise> { + return this.threadBridge.doGetById(id, this.appId); + } +} diff --git a/packages/apps/src/server/accessors/UIController.ts b/packages/apps/src/server/accessors/UIController.ts new file mode 100644 index 0000000000000..e21cf80b845f1 --- /dev/null +++ b/packages/apps/src/server/accessors/UIController.ts @@ -0,0 +1,126 @@ +import type { IUIController } from '@rocket.chat/apps-engine/definition/accessors'; +import type { + IUIKitErrorInteractionParam, + IUIKitInteractionParam, + IUIKitSurfaceViewParam, +} from '@rocket.chat/apps-engine/definition/accessors/IUIController'; +import { UIKitInteractionType, UIKitSurfaceType } from '@rocket.chat/apps-engine/definition/uikit'; +import { + formatContextualBarInteraction, + formatErrorInteraction, + formatModalInteraction, +} from '@rocket.chat/apps-engine/definition/uikit/UIKitInteractionPayloadFormatter'; +import type { + IUIKitContextualBarViewParam, + IUIKitModalViewParam, +} from '@rocket.chat/apps-engine/definition/uikit/UIKitInteractionResponder'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import type { AppBridges, UiInteractionBridge } from '../bridges'; +import { UIHelper } from '../misc/UIHelper'; + +export class UIController implements IUIController { + private readonly uiInteractionBridge: UiInteractionBridge; + + constructor( + private readonly appId: string, + bridges: AppBridges, + ) { + this.uiInteractionBridge = bridges.getUiInteractionBridge(); + } + + /** + * @deprecated please prefer the `openSurfaceView` method + */ + public openModalView(view: IUIKitModalViewParam, context: IUIKitInteractionParam, user: IUser) { + return this.openModal(view, context, user); + } + + /** + * @deprecated please prefer the `updateSurfaceView` method + */ + public updateModalView(view: IUIKitModalViewParam, context: IUIKitInteractionParam, user: IUser) { + return this.openModal(view, context, user, true); + } + + /** + * @deprecated please prefer the `openSurfaceView` method + */ + public openContextualBarView(view: IUIKitContextualBarViewParam, context: IUIKitInteractionParam, user: IUser) { + return this.openContextualBar(view, context, user); + } + + /** + * @deprecated please prefer the `updateSurfaceView` method + */ + public updateContextualBarView(view: IUIKitContextualBarViewParam, context: IUIKitInteractionParam, user: IUser) { + return this.openContextualBar(view, context, user, true); + } + + public openSurfaceView(view: IUIKitSurfaceViewParam, context: IUIKitInteractionParam, user: IUser) { + const blocks = UIHelper.assignIds(view.blocks, this.appId); + const viewWithIds = { ...view, blocks }; + + switch (view.type) { + case UIKitSurfaceType.CONTEXTUAL_BAR: + return this.openContextualBar(viewWithIds, context, user); + case UIKitSurfaceType.MODAL: + return this.openModal(viewWithIds, context, user); + } + } + + public updateSurfaceView(view: IUIKitSurfaceViewParam, context: IUIKitInteractionParam, user: IUser) { + const blocks = UIHelper.assignIds(view.blocks, this.appId); + const viewWithIds = { ...view, blocks }; + + switch (view.type) { + case UIKitSurfaceType.CONTEXTUAL_BAR: + return this.openContextualBar(viewWithIds, context, user, true); + case UIKitSurfaceType.MODAL: + return this.openModal(viewWithIds, context, user, true); + } + } + + public setViewError(errorInteraction: IUIKitErrorInteractionParam, context: IUIKitInteractionParam, user: IUser) { + const interactionContext = { + ...context, + type: UIKitInteractionType.ERRORS, + appId: this.appId, + }; + + return this.uiInteractionBridge.doNotifyUser(user, formatErrorInteraction(errorInteraction, interactionContext), this.appId); + } + + private openContextualBar( + view: IUIKitContextualBarViewParam, + context: IUIKitInteractionParam, + user: IUser, + isUpdate = false, + ): Promise { + let type = UIKitInteractionType.CONTEXTUAL_BAR_OPEN; + if (isUpdate) { + type = UIKitInteractionType.CONTEXTUAL_BAR_UPDATE; + } + const interactionContext = { + ...context, + type, + appId: this.appId, + }; + + return this.uiInteractionBridge.doNotifyUser(user, formatContextualBarInteraction(view, interactionContext), this.appId); + } + + private openModal(view: IUIKitModalViewParam, context: IUIKitInteractionParam, user: IUser, isUpdate = false): Promise { + let type = UIKitInteractionType.MODAL_OPEN; + if (isUpdate) { + type = UIKitInteractionType.MODAL_UPDATE; + } + const interactionContext = { + ...context, + type, + appId: this.appId, + }; + + return this.uiInteractionBridge.doNotifyUser(user, formatModalInteraction(view, interactionContext), this.appId); + } +} diff --git a/packages/apps/src/server/accessors/UIExtend.ts b/packages/apps/src/server/accessors/UIExtend.ts new file mode 100644 index 0000000000000..7369461dae569 --- /dev/null +++ b/packages/apps/src/server/accessors/UIExtend.ts @@ -0,0 +1,15 @@ +import type { IUIExtend } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IUIActionButtonDescriptor } from '@rocket.chat/apps-engine/definition/ui'; + +import type { UIActionButtonManager } from '../managers/UIActionButtonManager'; + +export class UIExtend implements IUIExtend { + constructor( + private readonly manager: UIActionButtonManager, + private readonly appId: string, + ) {} + + public registerButton(button: IUIActionButtonDescriptor): void { + this.manager.registerActionButton(this.appId, button); + } +} diff --git a/packages/apps/src/server/accessors/UploadCreator.ts b/packages/apps/src/server/accessors/UploadCreator.ts new file mode 100644 index 0000000000000..b21c333dc5076 --- /dev/null +++ b/packages/apps/src/server/accessors/UploadCreator.ts @@ -0,0 +1,29 @@ +import type { IUploadCreator } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IUpload } from '@rocket.chat/apps-engine/definition/uploads'; +import type { IUploadDescriptor } from '@rocket.chat/apps-engine/definition/uploads/IUploadDescriptor'; +import type { IUploadDetails } from '@rocket.chat/apps-engine/definition/uploads/IUploadDetails'; + +import type { AppBridges } from '../bridges'; + +export class UploadCreator implements IUploadCreator { + constructor( + private readonly bridges: AppBridges, + private readonly appId: string, + ) {} + + public async uploadBuffer(buffer: Buffer, descriptor: IUploadDescriptor): Promise { + if (!descriptor.hasOwnProperty('user') && !descriptor.visitorToken) { + descriptor.user = await this.bridges.getUserBridge().doGetAppUser(this.appId); + } + + const details = { + name: descriptor.filename, + size: buffer.length, + rid: descriptor.room.id, + userId: descriptor.user?.id, + visitorToken: descriptor.visitorToken, + } as IUploadDetails; + + return this.bridges.getUploadBridge().doCreateUpload(details, buffer, this.appId); + } +} diff --git a/packages/apps/src/server/accessors/UploadRead.ts b/packages/apps/src/server/accessors/UploadRead.ts new file mode 100644 index 0000000000000..5df4ed5b2755c --- /dev/null +++ b/packages/apps/src/server/accessors/UploadRead.ts @@ -0,0 +1,25 @@ +import type { IUploadRead } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IUpload } from '@rocket.chat/apps-engine/definition/uploads'; + +import type { UploadBridge } from '../bridges/UploadBridge'; + +export class UploadRead implements IUploadRead { + constructor( + private readonly uploadBridge: UploadBridge, + private readonly appId: string, + ) {} + + public getById(id: string): Promise { + return this.uploadBridge.doGetById(id, this.appId); + } + + public getBuffer(upload: IUpload): Promise { + return this.uploadBridge.doGetBuffer(upload, this.appId); + } + + public async getBufferById(id: string): Promise { + const upload = await this.uploadBridge.doGetById(id, this.appId); + + return this.uploadBridge.doGetBuffer(upload, this.appId); + } +} diff --git a/packages/apps/src/server/accessors/UserBuilder.ts b/packages/apps/src/server/accessors/UserBuilder.ts new file mode 100644 index 0000000000000..f1c891ec73bd6 --- /dev/null +++ b/packages/apps/src/server/accessors/UserBuilder.ts @@ -0,0 +1,74 @@ +import type { IUserBuilder } from '@rocket.chat/apps-engine/definition/accessors'; +import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata'; +import type { IUser, IUserEmail } from '@rocket.chat/apps-engine/definition/users'; +import type { IUserSettings } from '@rocket.chat/apps-engine/definition/users/IUserSettings'; + +export class UserBuilder implements IUserBuilder { + public kind: RocketChatAssociationModel.USER; + + private user: Partial; + + constructor(user?: Partial) { + this.kind = RocketChatAssociationModel.USER; + this.user = user || ({} as Partial); + } + + public setData(data: Partial): IUserBuilder { + delete data.id; + this.user = data; + + return this; + } + + public setEmails(emails: Array): IUserBuilder { + this.user.emails = emails; + return this; + } + + public getEmails(): Array { + return this.user.emails; + } + + public setDisplayName(name: string): IUserBuilder { + this.user.name = name; + return this; + } + + public getDisplayName(): string { + return this.user.name; + } + + public setUsername(username: string): IUserBuilder { + this.user.username = username; + return this; + } + + public getUsername(): string { + return this.user.username; + } + + public setRoles(roles: Array): IUserBuilder { + this.user.roles = roles; + return this; + } + + public getRoles(): Array { + return this.user.roles; + } + + public getSettings(): Partial { + return this.user.settings; + } + + public getUser(): Partial { + if (!this.user.username) { + throw new Error('The "username" property is required.'); + } + + if (!this.user.name) { + throw new Error('The "name" property is required.'); + } + + return this.user; + } +} diff --git a/packages/apps/src/server/accessors/UserRead.ts b/packages/apps/src/server/accessors/UserRead.ts new file mode 100644 index 0000000000000..c7dbd1d9e5c3d --- /dev/null +++ b/packages/apps/src/server/accessors/UserRead.ts @@ -0,0 +1,31 @@ +import type { IUserRead } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import type { UserBridge } from '../bridges/UserBridge'; + +export class UserRead implements IUserRead { + constructor( + private userBridge: UserBridge, + private appId: string, + ) {} + + public getById(id: string): Promise { + return this.userBridge.doGetById(id, this.appId); + } + + public getByUsername(username: string): Promise { + return this.userBridge.doGetByUsername(username, this.appId); + } + + public getAppUser(appId: string = this.appId): Promise { + return this.userBridge.doGetAppUser(appId); + } + + public getUserUnreadMessageCount(uid: string): Promise { + return this.userBridge.doGetUserUnreadMessageCount(uid, this.appId); + } + + public getUserRoomIds(userId: string): Promise { + return this.userBridge.doGetUserRoomIds(userId, this.appId); + } +} diff --git a/packages/apps/src/server/accessors/UserUpdater.ts b/packages/apps/src/server/accessors/UserUpdater.ts new file mode 100644 index 0000000000000..bbb0252f33f8e --- /dev/null +++ b/packages/apps/src/server/accessors/UserUpdater.ts @@ -0,0 +1,32 @@ +import type { IUserUpdater } from '@rocket.chat/apps-engine/definition/accessors/IUserUpdater'; +import type { UserStatusConnection } from '@rocket.chat/apps-engine/definition/users'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser'; + +import type { AppBridges } from '../bridges'; + +export class UserUpdater implements IUserUpdater { + constructor( + private readonly bridges: AppBridges, + private readonly appId: string, + ) {} + + public async updateStatusText(user: IUser, statusText: IUser['statusText']) { + return this.bridges.getUserBridge().doUpdate(user, { statusText }, this.appId); + } + + public async updateStatus(user: IUser, statusText: IUser['statusText'], status: UserStatusConnection) { + return this.bridges.getUserBridge().doUpdate(user, { statusText, status }, this.appId); + } + + public async updateBio(user: IUser, bio: IUser['bio']) { + return this.bridges.getUserBridge().doUpdate(user, { bio }, this.appId); + } + + public async updateCustomFields(user: IUser, customFields: IUser['customFields']) { + return this.bridges.getUserBridge().doUpdate(user, { customFields }, this.appId); + } + + public async deactivate(userId: IUser['id'], confirmRelinquish: boolean) { + return this.bridges.getUserBridge().doDeactivate(userId, confirmRelinquish, this.appId); + } +} diff --git a/packages/apps/src/server/accessors/VideoConfProviderExtend.ts b/packages/apps/src/server/accessors/VideoConfProviderExtend.ts new file mode 100644 index 0000000000000..45bb2b174e381 --- /dev/null +++ b/packages/apps/src/server/accessors/VideoConfProviderExtend.ts @@ -0,0 +1,15 @@ +import type { IVideoConfProvidersExtend } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IVideoConfProvider } from '@rocket.chat/apps-engine/definition/videoConfProviders'; + +import type { AppVideoConfProviderManager } from '../managers/AppVideoConfProviderManager'; + +export class VideoConfProviderExtend implements IVideoConfProvidersExtend { + constructor( + private readonly manager: AppVideoConfProviderManager, + private readonly appId: string, + ) {} + + public provideVideoConfProvider(provider: IVideoConfProvider): Promise { + return Promise.resolve(this.manager.addProvider(this.appId, provider)); + } +} diff --git a/packages/apps/src/server/accessors/VideoConferenceBuilder.ts b/packages/apps/src/server/accessors/VideoConferenceBuilder.ts new file mode 100644 index 0000000000000..a7c101b9851e7 --- /dev/null +++ b/packages/apps/src/server/accessors/VideoConferenceBuilder.ts @@ -0,0 +1,83 @@ +import type { IVideoConferenceBuilder } from '@rocket.chat/apps-engine/definition/accessors'; +import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata'; +import type { AppVideoConference } from '@rocket.chat/apps-engine/definition/videoConferences'; + +export class VideoConferenceBuilder implements IVideoConferenceBuilder { + public kind: RocketChatAssociationModel.VIDEO_CONFERENCE = RocketChatAssociationModel.VIDEO_CONFERENCE; + + protected call: AppVideoConference; + + constructor(data?: Partial) { + this.call = (data || {}) as AppVideoConference; + } + + public setData(data: Partial): IVideoConferenceBuilder { + this.call = { + rid: data.rid, + createdBy: data.createdBy, + providerName: data.providerName, + title: data.title, + discussionRid: data.discussionRid, + }; + + return this; + } + + public setRoomId(rid: string): IVideoConferenceBuilder { + this.call.rid = rid; + return this; + } + + public getRoomId(): string { + return this.call.rid; + } + + public setCreatedBy(userId: string): IVideoConferenceBuilder { + this.call.createdBy = userId; + return this; + } + + public getCreatedBy(): string { + return this.call.createdBy; + } + + public setProviderName(userId: string): IVideoConferenceBuilder { + this.call.providerName = userId; + return this; + } + + public getProviderName(): string { + return this.call.providerName; + } + + public setProviderData(data: Record | undefined): IVideoConferenceBuilder { + this.call.providerData = data; + return this; + } + + public getProviderData(): Record | undefined { + return this.call.providerData; + } + + public setTitle(userId: string): IVideoConferenceBuilder { + this.call.title = userId; + return this; + } + + public getTitle(): string { + return this.call.title; + } + + public setDiscussionRid(rid: AppVideoConference['discussionRid']): IVideoConferenceBuilder { + this.call.discussionRid = rid; + return this; + } + + public getDiscussionRid(): AppVideoConference['discussionRid'] { + return this.call.discussionRid; + } + + public getVideoConference(): AppVideoConference { + return this.call; + } +} diff --git a/packages/apps/src/server/accessors/VideoConferenceExtend.ts b/packages/apps/src/server/accessors/VideoConferenceExtend.ts new file mode 100644 index 0000000000000..01c816bf952ad --- /dev/null +++ b/packages/apps/src/server/accessors/VideoConferenceExtend.ts @@ -0,0 +1,65 @@ +import type { IVideoConferenceExtender } from '@rocket.chat/apps-engine/definition/accessors'; +import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata'; +import type { IVideoConferenceUser, VideoConference } from '@rocket.chat/apps-engine/definition/videoConferences'; +import type { VideoConferenceMember } from '@rocket.chat/apps-engine/definition/videoConferences/IVideoConference'; + +import { Utilities } from '../misc/Utilities'; + +export class VideoConferenceExtender implements IVideoConferenceExtender { + public kind: RocketChatAssociationModel.VIDEO_CONFERENCE; + + constructor(private videoConference: VideoConference) { + this.kind = RocketChatAssociationModel.VIDEO_CONFERENCE; + } + + public setProviderData(value: Record): IVideoConferenceExtender { + this.videoConference.providerData = value; + + return this; + } + + public setStatus(value: VideoConference['status']): IVideoConferenceExtender { + this.videoConference.status = value; + + return this; + } + + public setEndedBy(value: IVideoConferenceUser['_id']): IVideoConferenceExtender { + this.videoConference.endedBy = { + _id: value, + // Name and username will be loaded automatically by the bridge + username: '', + name: '', + }; + + return this; + } + + public setEndedAt(value: VideoConference['endedAt']): IVideoConferenceExtender { + this.videoConference.endedAt = value; + + return this; + } + + public addUser(userId: VideoConferenceMember['_id'], ts?: VideoConferenceMember['ts']): IVideoConferenceExtender { + this.videoConference.users.push({ + _id: userId, + ts, + // Name and username will be loaded automatically by the bridge + username: '', + name: '', + }); + + return this; + } + + public setDiscussionRid(rid: VideoConference['discussionRid']): IVideoConferenceExtender { + this.videoConference.discussionRid = rid; + + return this; + } + + public getVideoConference(): VideoConference { + return Utilities.deepClone(this.videoConference); + } +} diff --git a/packages/apps/src/server/accessors/VideoConferenceRead.ts b/packages/apps/src/server/accessors/VideoConferenceRead.ts new file mode 100644 index 0000000000000..02dc89e356dd6 --- /dev/null +++ b/packages/apps/src/server/accessors/VideoConferenceRead.ts @@ -0,0 +1,15 @@ +import type { IVideoConferenceRead } from '@rocket.chat/apps-engine/definition/accessors'; +import type { VideoConference } from '@rocket.chat/apps-engine/definition/videoConferences'; + +import type { VideoConferenceBridge } from '../bridges'; + +export class VideoConferenceRead implements IVideoConferenceRead { + constructor( + private videoConfBridge: VideoConferenceBridge, + private appId: string, + ) {} + + public getById(id: string): Promise { + return this.videoConfBridge.doGetById(id, this.appId); + } +} diff --git a/packages/apps/src/server/accessors/index.ts b/packages/apps/src/server/accessors/index.ts new file mode 100644 index 0000000000000..eb5cbdc0218c7 --- /dev/null +++ b/packages/apps/src/server/accessors/index.ts @@ -0,0 +1,97 @@ +import { ApiExtend } from './ApiExtend'; +import { AppAccessors } from './AppAccessors'; +import { ConfigurationExtend } from './ConfigurationExtend'; +import { ConfigurationModify } from './ConfigurationModify'; +import { EnvironmentRead } from './EnvironmentRead'; +import { EnvironmentWrite } from './EnvironmentWrite'; +import { EnvironmentalVariableRead } from './EnvironmentalVariableRead'; +import { ExternalComponentsExtend } from './ExternalComponentsExtend'; +import { Http } from './Http'; +import { HttpExtend } from './HttpExtend'; +import { LivechatRead } from './LivechatRead'; +import { MessageBuilder } from './MessageBuilder'; +import { MessageExtender } from './MessageExtender'; +import { MessageRead } from './MessageRead'; +import { ModerationModify } from './ModerationModify'; +import { Modify } from './Modify'; +import { ModifyCreator } from './ModifyCreator'; +import { ModifyExtender } from './ModifyExtender'; +import { ModifyUpdater } from './ModifyUpdater'; +import { Notifier } from './Notifier'; +import { OAuthAppsModify } from './OAuthAppsModify'; +import { OAuthAppsReader } from './OAuthAppsReader'; +import { OutboundMessageProviderExtend } from './OutboundCommunicationProviderExtend'; +import { Persistence } from './Persistence'; +import { PersistenceRead } from './PersistenceRead'; +import { Reader } from './Reader'; +import { RoleRead } from './RoleRead'; +import { RoomBuilder } from './RoomBuilder'; +import { RoomExtender } from './RoomExtender'; +import { RoomRead } from './RoomRead'; +import { SchedulerExtend } from './SchedulerExtend'; +import { SchedulerModify } from './SchedulerModify'; +import { ServerSettingRead } from './ServerSettingRead'; +import { ServerSettingUpdater } from './ServerSettingUpdater'; +import { ServerSettingsModify } from './ServerSettingsModify'; +import { SettingRead } from './SettingRead'; +import { SettingUpdater } from './SettingUpdater'; +import { SettingsExtend } from './SettingsExtend'; +import { SlashCommandsExtend } from './SlashCommandsExtend'; +import { SlashCommandsModify } from './SlashCommandsModify'; +import { UploadRead } from './UploadRead'; +import { UserBuilder } from './UserBuilder'; +import { UserRead } from './UserRead'; +import { VideoConfProviderExtend } from './VideoConfProviderExtend'; +import { VideoConferenceBuilder } from './VideoConferenceBuilder'; +import { VideoConferenceExtender } from './VideoConferenceExtend'; +import { VideoConferenceRead } from './VideoConferenceRead'; + +export { + ApiExtend, + AppAccessors, + ConfigurationExtend, + ConfigurationModify, + EnvironmentalVariableRead, + EnvironmentRead, + EnvironmentWrite, + ExternalComponentsExtend, + Http, + HttpExtend, + LivechatRead, + MessageBuilder, + MessageExtender, + MessageRead, + ModerationModify, + Modify, + ModifyCreator, + ModifyExtender, + ModifyUpdater, + Notifier, + Persistence, + PersistenceRead, + Reader, + RoleRead, + RoomBuilder, + RoomExtender, + RoomRead, + ServerSettingRead, + ServerSettingsModify, + ServerSettingUpdater, + SettingRead, + SettingsExtend, + SettingUpdater, + SlashCommandsExtend, + SlashCommandsModify, + UploadRead, + UserBuilder, + UserRead, + SchedulerExtend, + SchedulerModify, + VideoConferenceBuilder, + VideoConferenceExtender, + VideoConferenceRead, + VideoConfProviderExtend, + OAuthAppsModify, + OAuthAppsReader, + OutboundMessageProviderExtend, +}; diff --git a/packages/apps/src/server/bridges/ApiBridge.ts b/packages/apps/src/server/bridges/ApiBridge.ts new file mode 100644 index 0000000000000..47be5b27e92e0 --- /dev/null +++ b/packages/apps/src/server/bridges/ApiBridge.ts @@ -0,0 +1,49 @@ +import { BaseBridge } from './BaseBridge'; +import { PermissionDeniedError } from '../errors/PermissionDeniedError'; +import type { AppApi } from '../managers/AppApi'; +import { AppPermissionManager } from '../managers/AppPermissionManager'; +import { AppPermissions } from '../permissions/AppPermissions'; + +export abstract class ApiBridge extends BaseBridge { + public async doRegisterApi(api: AppApi, appId: string): Promise { + if (this.hasDefaultPermission(appId)) { + return this.registerApi(api, appId); + } + } + + public async doUnregisterApis(appId: string): Promise { + if (this.hasDefaultPermission(appId)) { + return this.unregisterApis(appId); + } + } + + /** + * Registers an api with the system which is being bridged. + * + * @param api the api to register + * @param appId the id of the app calling this + */ + protected abstract registerApi(api: AppApi, appId: string): Promise; + + /** + * Unregisters all provided api's of an app from the bridged system. + * + * @param appId the id of the app calling this + */ + protected abstract unregisterApis(appId: string): Promise; + + private hasDefaultPermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.apis.default)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.apis.default], + }), + ); + + return false; + } +} diff --git a/packages/apps/src/server/bridges/AppActivationBridge.ts b/packages/apps/src/server/bridges/AppActivationBridge.ts new file mode 100644 index 0000000000000..667444ca099e0 --- /dev/null +++ b/packages/apps/src/server/bridges/AppActivationBridge.ts @@ -0,0 +1,36 @@ +import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; + +import type { ProxiedApp } from '../ProxiedApp'; +import { BaseBridge } from './BaseBridge'; + +export abstract class AppActivationBridge extends BaseBridge { + public async doAppAdded(app: ProxiedApp): Promise { + return this.appAdded(app); + } + + public async doAppUpdated(app: ProxiedApp): Promise { + return this.appUpdated(app); + } + + public async doAppRemoved(app: ProxiedApp): Promise { + return this.appRemoved(app); + } + + public async doAppStatusChanged(app: ProxiedApp, status: AppStatus): Promise { + return this.appStatusChanged(app, status); + } + + public async doActionsChanged(): Promise { + return this.actionsChanged(); + } + + protected abstract appAdded(app: ProxiedApp): Promise; + + protected abstract appUpdated(app: ProxiedApp): Promise; + + protected abstract appRemoved(app: ProxiedApp): Promise; + + protected abstract appStatusChanged(app: ProxiedApp, status: AppStatus): Promise; + + protected abstract actionsChanged(): Promise; +} diff --git a/packages/apps/src/server/bridges/AppBridges.ts b/packages/apps/src/server/bridges/AppBridges.ts new file mode 100644 index 0000000000000..5e5ef7ca12106 --- /dev/null +++ b/packages/apps/src/server/bridges/AppBridges.ts @@ -0,0 +1,113 @@ +import type { ApiBridge } from './ApiBridge'; +import type { AppActivationBridge } from './AppActivationBridge'; +import type { AppDetailChangesBridge } from './AppDetailChangesBridge'; +import type { CloudWorkspaceBridge } from './CloudWorkspaceBridge'; +import type { CommandBridge } from './CommandBridge'; +import type { ContactBridge } from './ContactBridge'; +import type { EmailBridge } from './EmailBridge'; +import type { EnvironmentalVariableBridge } from './EnvironmentalVariableBridge'; +import type { ExperimentalBridge } from './ExperimentalBridge'; +import type { HttpBridge } from './HttpBridge'; +import type { IInternalBridge } from './IInternalBridge'; +import type { IInternalFederationBridge } from './IInternalFederationBridge'; +import type { IListenerBridge } from './IListenerBridge'; +import type { LivechatBridge } from './LivechatBridge'; +import type { MessageBridge } from './MessageBridge'; +import type { ModerationBridge } from './ModerationBridge'; +import type { OAuthAppsBridge } from './OAuthAppsBridge'; +import type { OutboundMessageBridge } from './OutboundMessagesBridge'; +import type { PersistenceBridge } from './PersistenceBridge'; +import type { RoleBridge } from './RoleBridge'; +import type { RoomBridge } from './RoomBridge'; +import type { SchedulerBridge } from './SchedulerBridge'; +import type { ServerSettingBridge } from './ServerSettingBridge'; +import type { ThreadBridge } from './ThreadBridge'; +import type { UiInteractionBridge } from './UiInteractionBridge'; +import type { UploadBridge } from './UploadBridge'; +import type { UserBridge } from './UserBridge'; +import type { VideoConferenceBridge } from './VideoConferenceBridge'; + +export type Bridge = + | CommandBridge + | ContactBridge + | ApiBridge + | AppDetailChangesBridge + | EnvironmentalVariableBridge + | HttpBridge + | IListenerBridge + | LivechatBridge + | MessageBridge + | PersistenceBridge + | AppActivationBridge + | RoomBridge + | IInternalBridge + | ServerSettingBridge + | EmailBridge + | ExperimentalBridge + | UploadBridge + | UserBridge + | UiInteractionBridge + | SchedulerBridge + | VideoConferenceBridge + | OAuthAppsBridge + | ModerationBridge + | RoleBridge + | OutboundMessageBridge; + +export abstract class AppBridges { + public abstract getCommandBridge(): CommandBridge; + + public abstract getContactBridge(): ContactBridge; + + public abstract getApiBridge(): ApiBridge; + + public abstract getAppDetailChangesBridge(): AppDetailChangesBridge; + + public abstract getEnvironmentalVariableBridge(): EnvironmentalVariableBridge; + + public abstract getHttpBridge(): HttpBridge; + + public abstract getListenerBridge(): IListenerBridge; + + public abstract getLivechatBridge(): LivechatBridge; + + public abstract getMessageBridge(): MessageBridge; + + public abstract getPersistenceBridge(): PersistenceBridge; + + public abstract getAppActivationBridge(): AppActivationBridge; + + public abstract getRoomBridge(): RoomBridge; + + public abstract getInternalBridge(): IInternalBridge; + + public abstract getInternalFederationBridge(): IInternalFederationBridge; + + public abstract getServerSettingBridge(): ServerSettingBridge; + + public abstract getUploadBridge(): UploadBridge; + + public abstract getEmailBridge(): EmailBridge; + + public abstract getUserBridge(): UserBridge; + + public abstract getUiInteractionBridge(): UiInteractionBridge; + + public abstract getSchedulerBridge(): SchedulerBridge; + + public abstract getCloudWorkspaceBridge(): CloudWorkspaceBridge; + + public abstract getVideoConferenceBridge(): VideoConferenceBridge; + + public abstract getOAuthAppsBridge(): OAuthAppsBridge; + + public abstract getModerationBridge(): ModerationBridge; + + public abstract getThreadBridge(): ThreadBridge; + + public abstract getRoleBridge(): RoleBridge; + + public abstract getOutboundMessageBridge(): OutboundMessageBridge; + + public abstract getExperimentalBridge(): ExperimentalBridge; +} diff --git a/packages/apps/src/server/bridges/AppDetailChangesBridge.ts b/packages/apps/src/server/bridges/AppDetailChangesBridge.ts new file mode 100644 index 0000000000000..2082b3c586d0c --- /dev/null +++ b/packages/apps/src/server/bridges/AppDetailChangesBridge.ts @@ -0,0 +1,17 @@ +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; + +import { BaseBridge } from './BaseBridge'; + +/** + * An abstract class which will contain various methods related to Apps + * which are called for various inner detail working changes. This + * allows for us to notify various external components of internal + * changes. + */ +export abstract class AppDetailChangesBridge extends BaseBridge { + public doOnAppSettingsChange(appId: string, setting: ISetting): void { + return this.onAppSettingsChange(appId, setting); + } + + protected abstract onAppSettingsChange(appId: string, setting: ISetting): void; +} diff --git a/packages/apps/src/server/bridges/BaseBridge.ts b/packages/apps/src/server/bridges/BaseBridge.ts new file mode 100644 index 0000000000000..7085c3d54db78 --- /dev/null +++ b/packages/apps/src/server/bridges/BaseBridge.ts @@ -0,0 +1,6 @@ +/** + * This class will be used for identification + * of the instances the host sends over to + * the Apps-Engine + */ +export abstract class BaseBridge {} diff --git a/packages/apps/src/server/bridges/CloudWorkspaceBridge.ts b/packages/apps/src/server/bridges/CloudWorkspaceBridge.ts new file mode 100644 index 0000000000000..99b0480924f70 --- /dev/null +++ b/packages/apps/src/server/bridges/CloudWorkspaceBridge.ts @@ -0,0 +1,31 @@ +import type { IWorkspaceToken } from '@rocket.chat/apps-engine/definition/cloud/IWorkspaceToken'; + +import { BaseBridge } from './BaseBridge'; +import { PermissionDeniedError } from '../errors/PermissionDeniedError'; +import { AppPermissionManager } from '../managers/AppPermissionManager'; +import { AppPermissions } from '../permissions/AppPermissions'; + +export abstract class CloudWorkspaceBridge extends BaseBridge { + public doGetWorkspaceToken(scope: string, appId: string): Promise { + if (this.hasCloudTokenPermission(appId)) { + return this.getWorkspaceToken(scope, appId); + } + } + + protected abstract getWorkspaceToken(scope: string, appId: string): Promise; + + private hasCloudTokenPermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.cloud['workspace-token'])) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.cloud['workspace-token']], + }), + ); + + return false; + } +} diff --git a/packages/apps/src/server/bridges/CommandBridge.ts b/packages/apps/src/server/bridges/CommandBridge.ts new file mode 100644 index 0000000000000..df1e11a9e705a --- /dev/null +++ b/packages/apps/src/server/bridges/CommandBridge.ts @@ -0,0 +1,118 @@ +import type { ISlashCommand } from '@rocket.chat/apps-engine/definition/slashcommands'; + +import { BaseBridge } from './BaseBridge'; +import { PermissionDeniedError } from '../errors/PermissionDeniedError'; +import { AppPermissionManager } from '../managers/AppPermissionManager'; +import { AppPermissions } from '../permissions/AppPermissions'; + +export abstract class CommandBridge extends BaseBridge { + public async doDoesCommandExist(command: string, appId: string): Promise { + if (this.hasDefaultPermission(appId)) { + return this.doesCommandExist(command, appId); + } + } + + public async doEnableCommand(command: string, appId: string): Promise { + if (this.hasDefaultPermission(appId)) { + return this.enableCommand(command, appId); + } + } + + public async doDisableCommand(command: string, appId: string): Promise { + if (this.hasDefaultPermission(appId)) { + return this.disableCommand(command, appId); + } + } + + public async doModifyCommand(command: ISlashCommand, appId: string): Promise { + if (this.hasDefaultPermission(appId)) { + return this.modifyCommand(command, appId); + } + } + + public async doRegisterCommand(command: ISlashCommand, appId: string): Promise { + if (this.hasDefaultPermission(appId)) { + return this.registerCommand(command, appId); + } + } + + public async doUnregisterCommand(command: string, appId: string): Promise { + if (this.hasDefaultPermission(appId)) { + return this.unregisterCommand(command, appId); + } + } + + /** + * Checks if the provided command already exists inside of the + * system which is being bridged. This does not check if the app + * registered it but it will return whether the supplied command is + * already defined by something else or not. + * + * @param command the command to check if it exists + * @param appId the id of the app calling this + * @returns whether the command is already in the system + */ + protected abstract doesCommandExist(command: string, appId: string): Promise; + + /** + * Enables an existing command from the bridged system. The callee + * must ensure that the command that's being enabled is defined by + * the bridged system and not another App since the bridged system + * will not check that. + * + * @param command the command to enable + * @param appId the id of the app calling this + */ + protected abstract enableCommand(command: string, appId: string): Promise; + + /** + * Disables an existing command from the bridged system, the callee must + * ensure the command disabling is defined by the system and not another + * App since the bridged system won't check that. + * + * @param command the command which to disable + * @param appId the id of the app calling this + */ + protected abstract disableCommand(command: string, appId: string): Promise; + + /** + * Changes how a system slash command behaves, allows Apps to provide + * different executors per system commands. + * + * @param command the modified slash command + * @param appId the id of the app calling this + */ + protected abstract modifyCommand(command: ISlashCommand, appId: string): Promise; + + /** + * Registers a command with the system which is being bridged. + * + * @param command the command to register + * @param appId the id of the app calling this + * @param toRun the executor which is called when the command is ran + */ + protected abstract registerCommand(command: ISlashCommand, appId: string): Promise; + + /** + * Unregisters the provided command from the bridged system. + * + * @param command the command to unregister + * @param appId the id of the app calling this + */ + protected abstract unregisterCommand(command: string, appId: string): Promise; + + private hasDefaultPermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.command.default)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.command.default], + }), + ); + + return false; + } +} diff --git a/packages/apps/src/server/bridges/ContactBridge.ts b/packages/apps/src/server/bridges/ContactBridge.ts new file mode 100644 index 0000000000000..c2a001b10a299 --- /dev/null +++ b/packages/apps/src/server/bridges/ContactBridge.ts @@ -0,0 +1,70 @@ +import type { ILivechatContact } from '@rocket.chat/apps-engine/definition/livechat/ILivechatContact'; + +import { BaseBridge } from './BaseBridge'; +import { PermissionDeniedError } from '../errors/PermissionDeniedError'; +import { AppPermissionManager } from '../managers/AppPermissionManager'; +import { AppPermissions } from '../permissions/AppPermissions'; + +export type VerifyContactChannelParams = { + contactId: string; + field: string; + value: string; + visitorId: string; + roomId: string; +}; + +export abstract class ContactBridge extends BaseBridge { + public async doGetById(contactId: ILivechatContact['_id'], appId: string): Promise { + if (this.hasReadPermission(appId)) { + return this.getById(contactId, appId); + } + } + + public async doVerifyContact(verifyContactChannelParams: VerifyContactChannelParams, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.verifyContact(verifyContactChannelParams, appId); + } + } + + public async doAddContactEmail(contactId: ILivechatContact['_id'], email: string, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.addContactEmail(contactId, email, appId); + } + } + + protected abstract getById(contactId: ILivechatContact['_id'], appId: string): Promise; + + protected abstract verifyContact(verifyContactChannelParams: VerifyContactChannelParams, appId: string): Promise; + + protected abstract addContactEmail(contactId: ILivechatContact['_id'], email: string, appId: string): Promise; + + private hasReadPermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.contact.read)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.contact.read], + }), + ); + + return false; + } + + private hasWritePermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.contact.write)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.contact.write], + }), + ); + + return false; + } +} diff --git a/packages/apps/src/server/bridges/EmailBridge.ts b/packages/apps/src/server/bridges/EmailBridge.ts new file mode 100644 index 0000000000000..35daec8506854 --- /dev/null +++ b/packages/apps/src/server/bridges/EmailBridge.ts @@ -0,0 +1,31 @@ +import type { IEmail } from '@rocket.chat/apps-engine/definition/email'; + +import { BaseBridge } from './BaseBridge'; +import { PermissionDeniedError } from '../errors/PermissionDeniedError'; +import { AppPermissionManager } from '../managers/AppPermissionManager'; +import { AppPermissions } from '../permissions/AppPermissions'; + +export abstract class EmailBridge extends BaseBridge { + public async doSendEmail(email: IEmail, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.sendEmail(email, appId); + } + } + + protected abstract sendEmail(email: IEmail, appId: string): Promise; + + private hasWritePermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.email.send)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.email.send], + }), + ); + + return false; + } +} diff --git a/packages/apps/src/server/bridges/EnvironmentalVariableBridge.ts b/packages/apps/src/server/bridges/EnvironmentalVariableBridge.ts new file mode 100644 index 0000000000000..79b29bf316c41 --- /dev/null +++ b/packages/apps/src/server/bridges/EnvironmentalVariableBridge.ts @@ -0,0 +1,45 @@ +import { BaseBridge } from './BaseBridge'; +import { PermissionDeniedError } from '../errors/PermissionDeniedError'; +import { AppPermissionManager } from '../managers/AppPermissionManager'; +import { AppPermissions } from '../permissions/AppPermissions'; + +export abstract class EnvironmentalVariableBridge extends BaseBridge { + public async doGetValueByName(envVarName: string, appId: string): Promise { + if (this.hasReadPermission(appId)) { + return this.getValueByName(envVarName, appId); + } + } + + public async doIsReadable(envVarName: string, appId: string): Promise { + if (this.hasReadPermission(appId)) { + return this.isReadable(envVarName, appId); + } + } + + public async doIsSet(envVarName: string, appId: string): Promise { + if (this.hasReadPermission(appId)) { + return this.isSet(envVarName, appId); + } + } + + protected abstract getValueByName(envVarName: string, appId: string): Promise; + + protected abstract isReadable(envVarName: string, appId: string): Promise; + + protected abstract isSet(envVarName: string, appId: string): Promise; + + private hasReadPermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.env.read)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.env.read], + }), + ); + + return false; + } +} diff --git a/packages/apps/src/server/bridges/ExperimentalBridge.ts b/packages/apps/src/server/bridges/ExperimentalBridge.ts new file mode 100644 index 0000000000000..806b0d66fef37 --- /dev/null +++ b/packages/apps/src/server/bridges/ExperimentalBridge.ts @@ -0,0 +1,10 @@ +import { BaseBridge } from './BaseBridge'; + +/** + * @description + * Experimental bridge for experimental features. + * Methods in this class are not guaranteed to be stable between updates as the + * team evaluates the proper signature, underlying implementation and performance + * impact of candidates for future APIs + */ +export abstract class ExperimentalBridge extends BaseBridge {} diff --git a/packages/apps/src/server/bridges/HttpBridge.ts b/packages/apps/src/server/bridges/HttpBridge.ts new file mode 100644 index 0000000000000..86499ee89f801 --- /dev/null +++ b/packages/apps/src/server/bridges/HttpBridge.ts @@ -0,0 +1,38 @@ +import type { IHttpRequest, IHttpResponse, RequestMethod } from '@rocket.chat/apps-engine/definition/accessors'; + +import { BaseBridge } from './BaseBridge'; +import { PermissionDeniedError } from '../errors/PermissionDeniedError'; +import { AppPermissionManager } from '../managers/AppPermissionManager'; +import { AppPermissions } from '../permissions/AppPermissions'; + +export interface IHttpBridgeRequestInfo { + appId: string; + method: RequestMethod; + url: string; + request: IHttpRequest; +} + +export abstract class HttpBridge extends BaseBridge { + public async doCall(info: IHttpBridgeRequestInfo): Promise { + if (this.hasDefaultPermission(info.appId)) { + return this.call(info); + } + } + + protected abstract call(info: IHttpBridgeRequestInfo): Promise; + + private hasDefaultPermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.networking.default)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.networking.default], + }), + ); + + return false; + } +} diff --git a/packages/apps/src/server/bridges/IInternalBridge.ts b/packages/apps/src/server/bridges/IInternalBridge.ts new file mode 100644 index 0000000000000..f4b94b3111e4e --- /dev/null +++ b/packages/apps/src/server/bridges/IInternalBridge.ts @@ -0,0 +1,7 @@ +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; + +export interface IInternalBridge { + doGetUsernamesOfRoomById(roomId: string): Promise>; + doGetUsernamesOfRoomByIdSync(roomId: string): Array; + doGetWorkspacePublicKey(): Promise; +} diff --git a/packages/apps/src/server/bridges/IInternalFederationBridge.ts b/packages/apps/src/server/bridges/IInternalFederationBridge.ts new file mode 100644 index 0000000000000..382f69679b842 --- /dev/null +++ b/packages/apps/src/server/bridges/IInternalFederationBridge.ts @@ -0,0 +1,15 @@ +export interface IInternalFederationBridge { + /** + * Get Federation's private key. + * For apps engine's internal use + * + */ + getPrivateKey(): Promise; + + /** + * Get Federation's public key. + * For apps engine's internal use + * + */ + getPublicKey(): Promise; +} diff --git a/packages/apps/src/server/bridges/IInternalPersistenceBridge.ts b/packages/apps/src/server/bridges/IInternalPersistenceBridge.ts new file mode 100644 index 0000000000000..e1dcd9b247697 --- /dev/null +++ b/packages/apps/src/server/bridges/IInternalPersistenceBridge.ts @@ -0,0 +1,9 @@ +export interface IInternalPersistenceBridge { + /** + * Purges the App's persistant storage data from the persistent storage. + * For apps engine's internal use + * + * @argument appId the id of the app's data to remove + */ + purge(appId: string): Promise; +} diff --git a/packages/apps/src/server/bridges/IInternalSchedulerBridge.ts b/packages/apps/src/server/bridges/IInternalSchedulerBridge.ts new file mode 100644 index 0000000000000..2064043128a39 --- /dev/null +++ b/packages/apps/src/server/bridges/IInternalSchedulerBridge.ts @@ -0,0 +1,8 @@ +export interface IInternalSchedulerBridge { + /** + * Cancels all the running jobs from the app + * For apps-engine's internal use + * @param appId the id of the app calling this + */ + cancelAllJobs(appId: string): Promise; +} diff --git a/packages/apps/src/server/bridges/IInternalUserBridge.ts b/packages/apps/src/server/bridges/IInternalUserBridge.ts new file mode 100644 index 0000000000000..a539f8b7d23f8 --- /dev/null +++ b/packages/apps/src/server/bridges/IInternalUserBridge.ts @@ -0,0 +1,8 @@ +import type { IUser, IUserCreationOptions } from '@rocket.chat/apps-engine/definition/users'; + +export interface IInternalUserBridge { + create(data: Partial, appId: string, options?: IUserCreationOptions): Promise; + getAppUser(appId?: string): Promise; + remove(user: IUser, appId: string): Promise; + getActiveUserCount(): Promise; +} diff --git a/packages/apps/src/server/bridges/IListenerBridge.ts b/packages/apps/src/server/bridges/IListenerBridge.ts new file mode 100644 index 0000000000000..73523eb73c02e --- /dev/null +++ b/packages/apps/src/server/bridges/IListenerBridge.ts @@ -0,0 +1,13 @@ +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { AppInterface } from '@rocket.chat/apps-engine/definition/metadata'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import type { UIKitIncomingInteraction } from '@rocket.chat/apps-engine/definition/uikit'; +import type { ILivechatDepartment, ILivechatVisitor, IMessage, IOmnichannelRoom, IRoom, IUser } from '@rocket.chat/core-typings'; + +import type { AppEvents } from '../../AppsEngine'; + +export interface IListenerBridge { + messageEvent(int: AppInterface, message: IMessage): Promise; + roomEvent(int: AppInterface, room: IRoom): Promise; + uiKitInteractionEvent(int: AppInterface, action: UIKitIncomingInteraction): Promise; +} diff --git a/packages/apps/src/server/bridges/InternalBridge.ts b/packages/apps/src/server/bridges/InternalBridge.ts new file mode 100644 index 0000000000000..86637bb596ff9 --- /dev/null +++ b/packages/apps/src/server/bridges/InternalBridge.ts @@ -0,0 +1,23 @@ +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; + +import { BaseBridge } from './BaseBridge'; + +export abstract class InternalBridge extends BaseBridge { + public doGetUsernamesOfRoomById(roomId: string): Promise> { + return this.getUsernamesOfRoomById(roomId); + } + + public doGetUsernamesOfRoomByIdSync(roomId: string): Array { + return this.getUsernamesOfRoomByIdSync(roomId); + } + + public async doGetWorkspacePublicKey(): Promise { + return this.getWorkspacePublicKey(); + } + + protected abstract getUsernamesOfRoomById(roomId: string): Promise>; + + protected abstract getUsernamesOfRoomByIdSync(roomId: string): Array; + + protected abstract getWorkspacePublicKey(): Promise; +} diff --git a/packages/apps/src/server/bridges/ListenerBridge.ts b/packages/apps/src/server/bridges/ListenerBridge.ts new file mode 100644 index 0000000000000..8a32512e3d471 --- /dev/null +++ b/packages/apps/src/server/bridges/ListenerBridge.ts @@ -0,0 +1,19 @@ +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { AppInterface } from '@rocket.chat/apps-engine/definition/metadata'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; + +import { BaseBridge } from './BaseBridge'; + +export abstract class ListenerBridge extends BaseBridge { + public async doMessageEvent(int: AppInterface, message: IMessage): Promise { + return this.messageEvent(int, message); + } + + public async doRoomEvent(int: AppInterface, room: IRoom): Promise { + return this.roomEvent(int, room); + } + + protected abstract messageEvent(int: AppInterface, message: IMessage): Promise; + + protected abstract roomEvent(int: AppInterface, room: IRoom): Promise; +} diff --git a/packages/apps/src/server/bridges/LivechatBridge.ts b/packages/apps/src/server/bridges/LivechatBridge.ts new file mode 100644 index 0000000000000..91b6647c9dac9 --- /dev/null +++ b/packages/apps/src/server/bridges/LivechatBridge.ts @@ -0,0 +1,306 @@ +import type { IExtraRoomParams } from '@rocket.chat/apps-engine/definition/accessors/ILivechatCreator'; +import type { + IDepartment, + IVisitorExternalIdentifier, + ILivechatMessage, + ILivechatRoom, + ILivechatTransferData, + IVisitor, + ResolveVisitorContactData, +} from '@rocket.chat/apps-engine/definition/livechat'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import { BaseBridge } from './BaseBridge'; +import { PermissionDeniedError } from '../errors/PermissionDeniedError'; +import { AppPermissionManager } from '../managers/AppPermissionManager'; +import { AppPermissions } from '../permissions/AppPermissions'; + +type LivechatReadPermissions = keyof Pick< + typeof AppPermissions, + 'livechat-department' | 'livechat-message' | 'livechat-room' | 'livechat-status' | 'livechat-visitor' +>; + +type LivechatWritePermissions = keyof Pick< + typeof AppPermissions, + 'livechat-custom-fields' | 'livechat-department' | 'livechat-message' | 'livechat-room' | 'livechat-visitor' +>; + +type LivechatMultiplePermissions = keyof Pick; + +export abstract class LivechatBridge extends BaseBridge { + public doIsOnline(departmentId?: string, appId?: string): boolean { + if (this.hasReadPermission(appId, 'livechat-status')) { + return this.isOnline(departmentId, appId); + } + } + + public async doIsOnlineAsync(departmentId?: string, appId?: string): Promise { + if (this.hasReadPermission(appId, 'livechat-status')) { + return this.isOnlineAsync(departmentId, appId); + } + } + + public async doCreateMessage(message: ILivechatMessage, appId: string): Promise { + if (this.hasWritePermission(appId, 'livechat-message')) { + return this.createMessage(message, appId); + } + } + + public async doGetMessageById(messageId: string, appId: string): Promise { + if (this.hasReadPermission(appId, 'livechat-message')) { + return this.getMessageById(messageId, appId); + } + } + + public async doUpdateMessage(message: ILivechatMessage, appId: string): Promise { + if (this.hasWritePermission(appId, 'livechat-message')) { + return this.updateMessage(message, appId); + } + } + + /** + * @deprecated please use the `doCreateAndReturnVisitor` method instead. + */ + public async doCreateVisitor(visitor: IVisitor, appId: string): Promise { + if (this.hasWritePermission(appId, 'livechat-visitor')) { + return this.createVisitor(visitor, appId); + } + } + + public async doCreateAndReturnVisitor(visitor: IVisitor, appId: string): Promise { + if (this.hasWritePermission(appId, 'livechat-visitor')) { + return this.createAndReturnVisitor(visitor, appId); + } + } + + public async doFindVisitors(query: object, appId: string): Promise> { + if (this.hasReadPermission(appId, 'livechat-visitor')) { + return this.findVisitors(query, appId); + } + } + + public async doFindVisitorById(id: string, appId: string): Promise { + if (this.hasReadPermission(appId, 'livechat-visitor')) { + return this.findVisitorById(id, appId); + } + } + + public async doFindVisitorByEmail(email: string, appId: string): Promise { + if (this.hasReadPermission(appId, 'livechat-visitor')) { + return this.findVisitorByEmail(email, appId); + } + } + + public async doFindVisitorByToken(token: string, appId: string): Promise { + if (this.hasReadPermission(appId, 'livechat-visitor')) { + return this.findVisitorByToken(token, appId); + } + } + + public async doFindVisitorByPhoneNumber(phoneNumber: string, appId: string): Promise { + if (this.hasReadPermission(appId, 'livechat-visitor')) { + return this.findVisitorByPhoneNumber(phoneNumber, appId); + } + } + + public async doResolveVisitor( + externalId: Omit, + contactData: ResolveVisitorContactData | undefined, + appId: string, + ): Promise { + if (this.hasWritePermission(appId, 'livechat-visitor')) { + return this.resolveVisitor(externalId, contactData, appId); + } + } + + public async doTransferVisitor(visitor: IVisitor, transferData: ILivechatTransferData, appId: string): Promise { + if (this.hasWritePermission(appId, 'livechat-visitor')) { + return this.transferVisitor(visitor, transferData, appId); + } + } + + public async doUpdateVisitorExternalId( + visitorId: string, + externalId: Omit, + appId: string, + ): Promise { + if (this.hasWritePermission(appId, 'livechat-visitor')) { + return this.updateVisitorExternalId(visitorId, externalId, appId); + } + } + + public async doCreateRoom(visitor: IVisitor, agent: IUser, appId: string, extraParams?: IExtraRoomParams): Promise { + if (this.hasWritePermission(appId, 'livechat-room')) { + return this.createRoom(visitor, agent, appId, extraParams); + } + } + + public async doCloseRoom(room: ILivechatRoom, comment: string, closer: IUser | undefined, appId: string): Promise { + if (this.hasWritePermission(appId, 'livechat-room')) { + return this.closeRoom(room, comment, closer, appId); + } + } + + public async doCountOpenRoomsByAgentId(agentId: string, appId: string): Promise { + if (this.hasReadPermission(appId, 'livechat-room')) { + return this.countOpenRoomsByAgentId(agentId, appId); + } + } + + public async doFindOpenRoomsByAgentId(agentId: string, appId: string): Promise> { + if (this.hasReadPermission(appId, 'livechat-room')) { + return this.findOpenRoomsByAgentId(agentId, appId); + } + } + + public async doFindRooms(visitor: IVisitor, departmentId: string | null, appId: string): Promise> { + if (this.hasReadPermission(appId, 'livechat-room')) { + return this.findRooms(visitor, departmentId, appId); + } + } + + public async doFindDepartmentByIdOrName(value: string, appId: string): Promise { + if (this.hasReadPermission(appId, 'livechat-department') || this.hasMultiplePermission(appId, 'livechat-department')) { + return this.findDepartmentByIdOrName(value, appId); + } + } + + public async doFindDepartmentsEnabledWithAgents(appId: string): Promise> { + if (this.hasMultiplePermission(appId, 'livechat-department')) { + return this.findDepartmentsEnabledWithAgents(appId); + } + } + + public async do_fetchLivechatRoomMessages(appId: string, roomId: string): Promise> { + if (this.hasMultiplePermission(appId, 'livechat-message')) { + return this._fetchLivechatRoomMessages(appId, roomId); + } + } + + public async doSetCustomFields( + data: { token: IVisitor['token']; key: string; value: string; overwrite: boolean }, + appId: string, + ): Promise { + if (this.hasWritePermission(appId, 'livechat-custom-fields')) { + return this.setCustomFields(data, appId); + } + } + + /** + * @deprecated please use the `isOnlineAsync` method instead. + * In the next major, this method will be `async` + */ + protected abstract isOnline(departmentId?: string, appId?: string): boolean; + + protected abstract isOnlineAsync(departmentId?: string, appId?: string): Promise; + + protected abstract createMessage(message: ILivechatMessage, appId: string): Promise; + + protected abstract getMessageById(messageId: string, appId: string): Promise; + + protected abstract updateMessage(message: ILivechatMessage, appId: string): Promise; + + /** + * @deprecated please use `createAndReturnVisitor` instead. + * It returns the created record rather than the ID. + */ + protected abstract createVisitor(visitor: IVisitor, appId: string): Promise; + + protected abstract createAndReturnVisitor(visitor: IVisitor, appId: string): Promise; + + /** + * @deprecated This method does not adhere to the conversion practices applied + * elsewhere in the Apps-Engine and will be removed in the next major version. + * Prefer other methods that fetch visitors. + */ + protected abstract findVisitors(query: object, appId: string): Promise>; + + protected abstract findVisitorById(id: string, appId: string): Promise; + + protected abstract findVisitorByEmail(email: string, appId: string): Promise; + + protected abstract findVisitorByToken(token: string, appId: string): Promise; + + protected abstract findVisitorByPhoneNumber(phoneNumber: string, appId: string): Promise; + + protected abstract resolveVisitor( + externalId: Omit, + contactData: ResolveVisitorContactData | undefined, + appId: string, + ): Promise; + + protected abstract transferVisitor(visitor: IVisitor, transferData: ILivechatTransferData, appId: string): Promise; + + protected abstract updateVisitorExternalId( + visitorId: string, + externalId: Omit, + appId: string, + ): Promise; + + protected abstract createRoom(visitor: IVisitor, agent: IUser, appId: string, extraParams?: IExtraRoomParams): Promise; + + protected abstract closeRoom(room: ILivechatRoom, comment: string, closer: IUser | undefined, appId: string): Promise; + + protected abstract countOpenRoomsByAgentId(agentId: string, appId: string): Promise; + + protected abstract findOpenRoomsByAgentId(agentId: string, appId: string): Promise>; + + protected abstract findRooms(visitor: IVisitor, departmentId: string | null, appId: string): Promise>; + + protected abstract findDepartmentByIdOrName(value: string, appId: string): Promise; + + protected abstract findDepartmentsEnabledWithAgents(appId: string): Promise>; + + protected abstract _fetchLivechatRoomMessages(appId: string, roomId: string): Promise>; + + protected abstract setCustomFields( + data: { token: IVisitor['token']; key: string; value: string; overwrite: boolean }, + appId: string, + ): Promise; + + private hasReadPermission(appId: string, scope: LivechatReadPermissions): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions[scope].read)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions[scope].read], + }), + ); + + return false; + } + + private hasWritePermission(appId: string, scope: LivechatWritePermissions): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions[scope].write)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions[scope].write], + }), + ); + + return false; + } + + private hasMultiplePermission(appId: string, scope: LivechatMultiplePermissions): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions[scope].multiple)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions[scope].multiple], + }), + ); + + return false; + } +} diff --git a/packages/apps/src/server/bridges/MessageBridge.ts b/packages/apps/src/server/bridges/MessageBridge.ts new file mode 100644 index 0000000000000..e809e85a6288e --- /dev/null +++ b/packages/apps/src/server/bridges/MessageBridge.ts @@ -0,0 +1,117 @@ +import type { ITypingOptions } from '@rocket.chat/apps-engine/definition/accessors/INotifier'; +import type { IMessage, Reaction } from '@rocket.chat/apps-engine/definition/messages'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import { BaseBridge } from './BaseBridge'; +import { PermissionDeniedError } from '../errors/PermissionDeniedError'; +import { AppPermissionManager } from '../managers/AppPermissionManager'; +import { AppPermissions } from '../permissions/AppPermissions'; + +export interface ITypingDescriptor extends ITypingOptions { + isTyping: boolean; +} + +export abstract class MessageBridge extends BaseBridge { + public async doCreate(message: IMessage, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.create(message, appId); + } + } + + public async doUpdate(message: IMessage, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.update(message, appId); + } + } + + public async doNotifyUser(user: IUser, message: IMessage, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.notifyUser(user, message, appId); + } + } + + public async doNotifyRoom(room: IRoom, message: IMessage, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.notifyRoom(room, message, appId); + } + } + + public async doTyping(options: ITypingDescriptor, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.typing(options, appId); + } + } + + public async doGetById(messageId: string, appId: string): Promise { + if (this.hasReadPermission(appId)) { + return this.getById(messageId, appId); + } + } + + public async doDelete(message: IMessage, user: IUser, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.delete(message, user, appId); + } + } + + public async doAddReaction(messageId: string, userId: string, reaction: Reaction, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.addReaction(messageId, userId, reaction); + } + } + + public async doRemoveReaction(messageId: string, userId: string, reaction: Reaction, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.removeReaction(messageId, userId, reaction); + } + } + + protected abstract create(message: IMessage, appId: string): Promise; + + protected abstract update(message: IMessage, appId: string): Promise; + + protected abstract notifyUser(user: IUser, message: IMessage, appId: string): Promise; + + protected abstract notifyRoom(room: IRoom, message: IMessage, appId: string): Promise; + + protected abstract typing(options: ITypingDescriptor, appId: string): Promise; + + protected abstract getById(messageId: string, appId: string): Promise; + + protected abstract delete(message: IMessage, user: IUser, appId: string): Promise; + + protected abstract addReaction(messageId: string, userId: string, reaction: Reaction): Promise; + + protected abstract removeReaction(messageId: string, userId: string, reaction: Reaction): Promise; + + private hasReadPermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.message.read)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.message.read], + }), + ); + + return false; + } + + private hasWritePermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.message.write)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.message.write], + }), + ); + + return false; + } +} diff --git a/packages/apps/src/server/bridges/ModerationBridge.ts b/packages/apps/src/server/bridges/ModerationBridge.ts new file mode 100644 index 0000000000000..6c928fbd78fbb --- /dev/null +++ b/packages/apps/src/server/bridges/ModerationBridge.ts @@ -0,0 +1,48 @@ +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import { BaseBridge } from './BaseBridge'; +import { PermissionDeniedError } from '../errors/PermissionDeniedError'; +import { AppPermissionManager } from '../managers/AppPermissionManager'; +import { AppPermissions } from '../permissions/AppPermissions'; + +export abstract class ModerationBridge extends BaseBridge { + public async doReport(messageId: IMessage['id'], description: string, userId: string, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.report(messageId, description, userId, appId); + } + } + + public async doDismissReportsByMessageId(messageId: IMessage['id'], reason: string, action: string, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.dismissReportsByMessageId(messageId, reason, action, appId); + } + } + + public async doDismissReportsByUserId(userId: IUser['id'], reason: string, action: string, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.dismissReportsByUserId(userId, reason, action, appId); + } + } + + protected abstract report(messageId: string, description: string, userId: string, appId: string): Promise; + + protected abstract dismissReportsByMessageId(messageId: string, reason: string, action: string, appId: string): Promise; + + protected abstract dismissReportsByUserId(userId: string, reason: string, action: string, appId: string): Promise; + + private hasWritePermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.moderation.write)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.moderation.write], + }), + ); + + return false; + } +} diff --git a/packages/apps/src/server/bridges/OAuthAppsBridge.ts b/packages/apps/src/server/bridges/OAuthAppsBridge.ts new file mode 100644 index 0000000000000..14c2e8ac2acaa --- /dev/null +++ b/packages/apps/src/server/bridges/OAuthAppsBridge.ts @@ -0,0 +1,86 @@ +import type { IOAuthApp, IOAuthAppParams } from '@rocket.chat/apps-engine/definition/accessors/IOAuthApp'; + +import { BaseBridge } from './BaseBridge'; +import { PermissionDeniedError } from '../errors/PermissionDeniedError'; +import { AppPermissionManager } from '../managers/AppPermissionManager'; +import { AppPermissions } from '../permissions/AppPermissions'; + +export abstract class OAuthAppsBridge extends BaseBridge { + public async doCreate(oAuthApp: IOAuthAppParams, appId: string) { + if (this.hasWritePermission(appId)) { + return this.create(oAuthApp, appId); + } + } + + public async doGetByid(id: string, appId: string) { + if (this.hasReadPermission(appId)) { + return this.getById(id, appId); + } + } + + public async doGetByName(name: string, appId: string) { + if (this.hasReadPermission(appId)) { + return this.getByName(name, appId); + } + } + + public async doUpdate(oAuthApp: IOAuthAppParams, id: string, appId: string) { + if (this.hasWritePermission(appId)) { + return this.update(oAuthApp, id, appId); + } + } + + public async doDelete(id: string, appId: string) { + if (this.hasWritePermission(appId)) { + return this.delete(id, appId); + } + } + + public async doPurge(appId: string) { + if (this.hasWritePermission(appId)) { + return this.purge(appId); + } + } + + protected abstract create(oAuthApp: IOAuthAppParams, appId: string): Promise; + + protected abstract getById(id: string, appId: string): Promise; + + protected abstract getByName(name: string, appId: string): Promise>; + + protected abstract update(oAuthApp: IOAuthAppParams, id: string, appId: string): Promise; + + protected abstract delete(id: string, appId: string): Promise; + + protected abstract purge(appId: string): Promise; + + private hasWritePermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions['oauth-app'].write)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions['oauth-app'].write], + }), + ); + + return false; + } + + private hasReadPermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions['oauth-app'].read)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions['oauth-app'].read], + }), + ); + + return false; + } +} diff --git a/packages/apps/src/server/bridges/OutboundMessagesBridge.ts b/packages/apps/src/server/bridges/OutboundMessagesBridge.ts new file mode 100644 index 0000000000000..b6e1e10d51a51 --- /dev/null +++ b/packages/apps/src/server/bridges/OutboundMessagesBridge.ts @@ -0,0 +1,51 @@ +import type { + IOutboundEmailMessageProvider, + IOutboundMessageProviders, + IOutboundPhoneMessageProvider, +} from '@rocket.chat/apps-engine/definition/outboundCommunication'; + +import { BaseBridge } from './BaseBridge'; +import { PermissionDeniedError } from '../errors/PermissionDeniedError'; +import { AppPermissionManager } from '../managers/AppPermissionManager'; +import { AppPermissions } from '../permissions/AppPermissions'; + +export abstract class OutboundMessageBridge extends BaseBridge { + public async doRegisterPhoneProvider(info: IOutboundPhoneMessageProvider, appId: string): Promise { + if (this.hasProviderPermission(appId)) { + return this.registerPhoneProvider(info, appId); + } + } + + public async doRegisterEmailProvider(info: IOutboundEmailMessageProvider, appId: string): Promise { + if (this.hasProviderPermission(appId)) { + return this.registerEmailProvider(info, appId); + } + } + + public async doUnRegisterProvider(info: IOutboundMessageProviders, appId: string): Promise { + if (this.hasProviderPermission(appId)) { + return this.unRegisterProvider(info, appId); + } + } + + private hasProviderPermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.outboundComms.provide)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.outboundComms.provide], + }), + ); + + return false; + } + + protected abstract registerPhoneProvider(info: IOutboundPhoneMessageProvider, appId: string): Promise; + + protected abstract registerEmailProvider(info: IOutboundEmailMessageProvider, appId: string): Promise; + + protected abstract unRegisterProvider(info: IOutboundMessageProviders, appId: string): Promise; +} diff --git a/packages/apps/src/server/bridges/PersistenceBridge.ts b/packages/apps/src/server/bridges/PersistenceBridge.ts new file mode 100644 index 0000000000000..c77d53277521b --- /dev/null +++ b/packages/apps/src/server/bridges/PersistenceBridge.ts @@ -0,0 +1,175 @@ +import type { RocketChatAssociationRecord } from '@rocket.chat/apps-engine/definition/metadata'; + +import { BaseBridge } from './BaseBridge'; +import { PermissionDeniedError } from '../errors/PermissionDeniedError'; +import { AppPermissionManager } from '../managers/AppPermissionManager'; +import { AppPermissions } from '../permissions/AppPermissions'; + +export abstract class PersistenceBridge extends BaseBridge { + public async doPurge(appId: string): Promise { + if (this.hasDefaultPermission(appId)) { + return this.purge(appId); + } + } + + public async doCreate(data: object, appId: string): Promise { + if (this.hasDefaultPermission(appId)) { + return this.create(data, appId); + } + } + + public async doCreateWithAssociations(data: object, associations: Array, appId: string): Promise { + if (this.hasDefaultPermission(appId)) { + return this.createWithAssociations(data, associations, appId); + } + } + + public async doReadById(id: string, appId: string): Promise { + if (this.hasDefaultPermission(appId)) { + return this.readById(id, appId); + } + } + + public async doReadByAssociations(associations: Array, appId: string): Promise> { + if (this.hasDefaultPermission(appId)) { + return this.readByAssociations(associations, appId); + } + } + + public async doRemove(id: string, appId: string): Promise { + if (this.hasDefaultPermission(appId)) { + return this.remove(id, appId); + } + } + + public async doRemoveByAssociations(associations: Array, appId: string): Promise | undefined> { + if (this.hasDefaultPermission(appId)) { + return this.removeByAssociations(associations, appId); + } + } + + public async doUpdate(id: string, data: object, upsert: boolean, appId: string): Promise { + if (this.hasDefaultPermission(appId)) { + return this.update(id, data, upsert, appId); + } + } + + public async doUpdateByAssociations( + associations: Array, + data: object, + upsert: boolean, + appId: string, + ): Promise { + if (this.hasDefaultPermission(appId)) { + return this.updateByAssociations(associations, data, upsert, appId); + } + } + + /** + * Purges the App's persistant storage data from the persistent storage. + * + * @argument appId the id of the app's data to remove + */ + protected abstract purge(appId: string): Promise; + + /** + * Creates a new persistant record with the provided data attached. + * + * @argument data the data to store in persistent storage + * @argument appId the id of the app which is storing the data + * @returns the id of the stored record + */ + protected abstract create(data: object, appId: string): Promise; + + /** + * Creates a new record in the App's persistent storage with the data being + * associated with at least one Rocket.Chat record. + * + * @argument data the data to store in the persistent storage + * @argument associations the associations records this data is associated with + * @argument appId the id of the app which is storing the data + * @returns the id of the stored record + */ + protected abstract createWithAssociations(data: object, associations: Array, appId: string): Promise; + + /** + * Retrieves from the persistent storage the record by the id provided. + * + * @argument id the record id to read + * @argument appId the id of the app calling this + * @returns the data stored in the persistent storage, or undefined + */ + protected abstract readById(id: string, appId: string): Promise; + + /** + * Retrieves the data which is associated with the provided records. + * + * @argument associations the association records to query about + * @argument appId the id of the app calling this + * @returns an array of records if they exist, an empty array otherwise + */ + protected abstract readByAssociations(associations: Array, appId: string): Promise>; + + /** + * Removes the record which matches the provided id. + * + * @argument id the id of the record + * @argument appId the id of the app calling this + * @returns the data being removed + */ + protected abstract remove(id: string, appId: string): Promise; + + /** + * Removes any data which has been associated with the provided records. + * + * @argument associations the associations which to remove records + * @argument appId the id of the app calling this + * @returns the data of the removed records + */ + protected abstract removeByAssociations( + associations: Array, + appId: string, + ): Promise | undefined>; + + /** + * Updates the record in the database, with the option of creating a new one if it doesn't exist. + * + * @argument id the id of the record to update + * @argument data the updated data to set in the record + * @argument upsert whether to create if the id doesn't exist + * @argument appId the id of the app calling this + * @returns the id, whether the new one or the existing one + */ + protected abstract update(id: string, data: object, upsert: boolean, appId: string): Promise; + + /** + * Updates the record in the database, with the option of creating a new one if it doesn't exist. + * + * @argument associations the association records to update + * @argument data the updated data to set in the record + * @argument upsert whether to create if the id doesn't exist + * @argument appId the id of the app calling this + * @returns the id, whether the new one or the existing one + */ + protected abstract updateByAssociations( + associations: Array, + data: object, + upsert: boolean, + appId: string, + ): Promise; + + private hasDefaultPermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.persistence.default)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.persistence.default], + }), + ); + + return false; + } +} diff --git a/packages/apps/src/server/bridges/RoleBridge.ts b/packages/apps/src/server/bridges/RoleBridge.ts new file mode 100644 index 0000000000000..0ccbbfd1f3db1 --- /dev/null +++ b/packages/apps/src/server/bridges/RoleBridge.ts @@ -0,0 +1,39 @@ +import type { IRole } from '@rocket.chat/apps-engine/definition/roles'; + +import { BaseBridge } from './BaseBridge'; +import { PermissionDeniedError } from '../errors/PermissionDeniedError'; +import { AppPermissionManager } from '../managers/AppPermissionManager'; +import { AppPermissions } from '../permissions/AppPermissions'; + +export abstract class RoleBridge extends BaseBridge { + public async doGetOneByIdOrName(idOrName: string, appId: string): Promise { + if (this.hasReadPermission(appId)) { + return this.getOneByIdOrName(idOrName, appId); + } + } + + public async doGetCustomRoles(appId: string): Promise> { + if (this.hasReadPermission(appId)) { + return this.getCustomRoles(appId); + } + } + + protected abstract getOneByIdOrName(idOrName: string, appId: string): Promise; + + protected abstract getCustomRoles(appId: string): Promise>; + + private hasReadPermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.role.read)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.role.read], + }), + ); + + return false; + } +} diff --git a/packages/apps/src/server/bridges/RoomBridge.ts b/packages/apps/src/server/bridges/RoomBridge.ts new file mode 100644 index 0000000000000..5f1d7b2ae7ff6 --- /dev/null +++ b/packages/apps/src/server/bridges/RoomBridge.ts @@ -0,0 +1,257 @@ +import type { IMessage, IMessageRaw } from '@rocket.chat/apps-engine/definition/messages'; +import type { IRoom, IRoomRaw, RoomType } from '@rocket.chat/apps-engine/definition/rooms'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import { BaseBridge } from './BaseBridge'; +import { PermissionDeniedError } from '../errors/PermissionDeniedError'; +import { AppPermissionManager } from '../managers/AppPermissionManager'; +import { AppPermissions } from '../permissions/AppPermissions'; + +export const GetMessagesSortableFields = ['createdAt'] as const; + +export type GetMessagesOptions = { + limit: number; + skip: number; + sort: Record<(typeof GetMessagesSortableFields)[number], 'asc' | 'desc'>; + showThreadMessages: boolean; +}; + +/** + * Filters for querying rooms in the system. + */ +export type GetRoomsFilters = { + /** + * When specified, only rooms matching the provided types will be returned. + */ + types?: Array; + /** + * Filter to include or exclude discussion rooms. + * + * When undefined (default), discussions are included in the result set. + * + * When true, ONLY discussions are included in the result set (remove non-discussions). + * When false, discussion rooms are excluded from the result set. + */ + discussions?: boolean; + /** + * Filter to include or exclude team main rooms. + * + * When undefined (default), team main rooms are included in the result set. + * + * When true, ONLY team main rooms are included in the result set (remove non-teams). + * When false, team main rooms are excluded from the result set. + */ + teams?: boolean; +}; + +export type GetRoomsOptions = { + limit?: number; + skip?: number; +}; + +export abstract class RoomBridge extends BaseBridge { + public async doCreate(room: IRoom, members: Array, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.create(room, members, appId); + } + } + + public async doGetById(roomId: string, appId: string): Promise { + if (this.hasReadPermission(appId)) { + return this.getById(roomId, appId); + } + } + + public async doGetByName(roomName: string, appId: string): Promise { + if (this.hasReadPermission(appId)) { + return this.getByName(roomName, appId); + } + } + + public async doGetCreatorById(roomId: string, appId: string): Promise { + if (this.hasReadPermission(appId)) { + return this.getCreatorById(roomId, appId); + } + } + + public async doGetCreatorByName(roomName: string, appId: string): Promise { + if (this.hasReadPermission(appId)) { + return this.getCreatorByName(roomName, appId); + } + } + + public async doGetDirectByUsernames(usernames: Array, appId: string): Promise { + if (this.hasReadPermission(appId)) { + return this.getDirectByUsernames(usernames, appId); + } + } + + public async doGetMembers(roomId: string, appId: string): Promise> { + if (this.hasReadPermission(appId)) { + return this.getMembers(roomId, appId); + } + } + + public async doGetAllRooms( + filters: GetRoomsFilters = {}, + options: GetRoomsOptions = {}, + appId: string, + ): Promise | undefined> { + if (this.hasViewAllRoomsPermission(appId)) { + return this.getAllRooms(filters, options, appId); + } + } + + public async doUpdate(room: IRoom, members: Array, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.update(room, members, appId); + } + } + + public async doCreateDiscussion( + room: IRoom, + parentMessage: IMessage | undefined, + reply: string | undefined, + members: Array, + appId: string, + ): Promise { + if (this.hasWritePermission(appId)) { + return this.createDiscussion(room, parentMessage, reply, members, appId); + } + } + + public async doDelete(room: string, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.delete(room, appId); + } + } + + public async doGetModerators(roomId: string, appId: string): Promise> { + if (this.hasReadPermission(appId)) { + return this.getModerators(roomId, appId); + } + } + + public async doGetOwners(roomId: string, appId: string): Promise> { + if (this.hasReadPermission(appId)) { + return this.getOwners(roomId, appId); + } + } + + public async doGetLeaders(roomId: string, appId: string): Promise> { + if (this.hasReadPermission(appId)) { + return this.getLeaders(roomId, appId); + } + } + + public async doGetMessages(roomId: string, options: GetMessagesOptions, appId: string): Promise { + if (this.hasReadPermission(appId)) { + return this.getMessages(roomId, options, appId); + } + } + + public async doRemoveUsers(roomId: string, usernames: Array, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.removeUsers(roomId, usernames, appId); + } + } + + public async doGetUnreadByUser(roomId: string, uid: string, options: GetMessagesOptions, appId: string): Promise { + if (this.hasReadPermission(appId)) { + return this.getUnreadByUser(roomId, uid, options, appId); + } + } + + public async doGetUserUnreadMessageCount(roomId: string, uid: string, appId: string): Promise { + if (this.hasReadPermission(appId)) { + return this.getUserUnreadMessageCount(roomId, uid, appId); + } + } + + protected abstract create(room: IRoom, members: Array, appId: string): Promise; + + protected abstract getById(roomId: string, appId: string): Promise; + + protected abstract getByName(roomName: string, appId: string): Promise; + + protected abstract getCreatorById(roomId: string, appId: string): Promise; + + protected abstract getCreatorByName(roomName: string, appId: string): Promise; + + protected abstract getDirectByUsernames(usernames: Array, appId: string): Promise; + + protected abstract getMembers(roomId: string, appId: string): Promise>; + + protected abstract getAllRooms(filters: GetRoomsFilters, options: GetRoomsOptions, appId: string): Promise>; + + protected abstract update(room: IRoom, members: Array, appId: string): Promise; + + protected abstract createDiscussion( + room: IRoom, + parentMessage: IMessage | undefined, + reply: string | undefined, + members: Array, + appId: string, + ): Promise; + + protected abstract delete(room: string, appId: string): Promise; + + protected abstract getModerators(roomId: string, appId: string): Promise>; + + protected abstract getOwners(roomId: string, appId: string): Promise>; + + protected abstract getLeaders(roomId: string, appId: string): Promise>; + + protected abstract getMessages(roomId: string, options: GetMessagesOptions, appId: string): Promise; + + protected abstract removeUsers(roomId: string, usernames: Array, appId: string): Promise; + + protected abstract getUnreadByUser(roomId: string, uid: string, options: GetMessagesOptions, appId: string): Promise; + + protected abstract getUserUnreadMessageCount(roomId: string, uid: string, appId: string): Promise; + + private hasWritePermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.room.write)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.room.write], + }), + ); + + return false; + } + + private hasReadPermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.room.read)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.room.read], + }), + ); + + return false; + } + + private hasViewAllRoomsPermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.room['system-view-all'])) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.room['system-view-all']], + }), + ); + + return false; + } +} diff --git a/packages/apps/src/server/bridges/SchedulerBridge.ts b/packages/apps/src/server/bridges/SchedulerBridge.ts new file mode 100644 index 0000000000000..53d05c5a870e7 --- /dev/null +++ b/packages/apps/src/server/bridges/SchedulerBridge.ts @@ -0,0 +1,63 @@ +import type { IOnetimeSchedule, IProcessor, IRecurringSchedule } from '@rocket.chat/apps-engine/definition/scheduler'; + +import { BaseBridge } from './BaseBridge'; +import { PermissionDeniedError } from '../errors/PermissionDeniedError'; +import { AppPermissionManager } from '../managers/AppPermissionManager'; +import { AppPermissions } from '../permissions/AppPermissions'; + +export abstract class SchedulerBridge extends BaseBridge { + public async doRegisterProcessors(processors: Array = [], appId: string): Promise> { + if (this.hasDefaultPermission(appId)) { + return this.registerProcessors(processors, appId); + } + } + + public async doScheduleOnce(job: IOnetimeSchedule, appId: string): Promise { + if (this.hasDefaultPermission(appId)) { + return this.scheduleOnce(job, appId); + } + } + + public async doScheduleRecurring(job: IRecurringSchedule, appId: string): Promise { + if (this.hasDefaultPermission(appId)) { + return this.scheduleRecurring(job, appId); + } + } + + public async doCancelJob(jobId: string, appId: string): Promise { + if (this.hasDefaultPermission(appId)) { + return this.cancelJob(jobId, appId); + } + } + + public async doCancelAllJobs(appId: string): Promise { + if (this.hasDefaultPermission(appId)) { + return this.cancelAllJobs(appId); + } + } + + protected abstract registerProcessors(processors: Array, appId: string): Promise>; + + protected abstract scheduleOnce(job: IOnetimeSchedule, appId: string): Promise; + + protected abstract scheduleRecurring(job: IRecurringSchedule, appId: string): Promise; + + protected abstract cancelJob(jobId: string, appId: string): Promise; + + protected abstract cancelAllJobs(appId: string): Promise; + + private hasDefaultPermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.scheduler.default)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.scheduler.default], + }), + ); + + return false; + } +} diff --git a/packages/apps/src/server/bridges/ServerSettingBridge.ts b/packages/apps/src/server/bridges/ServerSettingBridge.ts new file mode 100644 index 0000000000000..2114a98b8c3f9 --- /dev/null +++ b/packages/apps/src/server/bridges/ServerSettingBridge.ts @@ -0,0 +1,94 @@ +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; + +import { BaseBridge } from './BaseBridge'; +import { PermissionDeniedError } from '../errors/PermissionDeniedError'; +import { AppPermissionManager } from '../managers/AppPermissionManager'; +import { AppPermissions } from '../permissions/AppPermissions'; + +export abstract class ServerSettingBridge extends BaseBridge { + public async doGetAll(appId: string): Promise> { + if (this.hasReadPermission(appId)) { + return this.getAll(appId); + } + } + + public async doGetOneById(id: string, appId: string): Promise { + if (this.hasReadPermission(appId)) { + return this.getOneById(id, appId); + } + } + + public async doHideGroup(name: string, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.hideGroup(name, appId); + } + } + + public async doHideSetting(id: string, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.hideSetting(id, appId); + } + } + + public async doIsReadableById(id: string, appId: string): Promise { + if (this.hasReadPermission(appId)) { + return this.isReadableById(id, appId); + } + } + + public async doUpdateOne(setting: ISetting, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.updateOne(setting, appId); + } + } + + public async doIncrementValue(id: ISetting['id'], value: number, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.incrementValue(id, value, appId); + } + } + + protected abstract getAll(appId: string): Promise>; + + protected abstract getOneById(id: string, appId: string): Promise; + + protected abstract hideGroup(name: string, appId: string): Promise; + + protected abstract hideSetting(id: string, appId: string): Promise; + + protected abstract isReadableById(id: string, appId: string): Promise; + + protected abstract updateOne(setting: ISetting, appId: string): Promise; + + protected abstract incrementValue(id: ISetting['id'], value: number, appId: string): Promise; + + private hasWritePermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.setting.write)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.setting.write], + }), + ); + + return false; + } + + private hasReadPermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.setting.read)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.setting.read], + }), + ); + + return false; + } +} diff --git a/packages/apps/src/server/bridges/ThreadBridge.ts b/packages/apps/src/server/bridges/ThreadBridge.ts new file mode 100644 index 0000000000000..c11d332db7e40 --- /dev/null +++ b/packages/apps/src/server/bridges/ThreadBridge.ts @@ -0,0 +1,36 @@ +import type { ITypingOptions } from '@rocket.chat/apps-engine/definition/accessors/INotifier'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; + +import { BaseBridge } from './BaseBridge'; +import { PermissionDeniedError } from '../errors/PermissionDeniedError'; +import { AppPermissionManager } from '../managers/AppPermissionManager'; +import { AppPermissions } from '../permissions/AppPermissions'; + +export interface ITypingDescriptor extends ITypingOptions { + isTyping: boolean; +} + +export abstract class ThreadBridge extends BaseBridge { + public async doGetById(messageId: string, appId: string): Promise> { + if (this.hasReadPermission(appId)) { + return this.getById(messageId, appId); + } + } + + protected abstract getById(messageId: string, appId: string): Promise>; + + private hasReadPermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.threads.read)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.threads.read], + }), + ); + + return false; + } +} diff --git a/packages/apps/src/server/bridges/UiInteractionBridge.ts b/packages/apps/src/server/bridges/UiInteractionBridge.ts new file mode 100644 index 0000000000000..c503a0d034e4a --- /dev/null +++ b/packages/apps/src/server/bridges/UiInteractionBridge.ts @@ -0,0 +1,32 @@ +import type { IUIKitInteraction } from '@rocket.chat/apps-engine/definition/uikit'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import { BaseBridge } from './BaseBridge'; +import { PermissionDeniedError } from '../errors/PermissionDeniedError'; +import { AppPermissionManager } from '../managers/AppPermissionManager'; +import { AppPermissions } from '../permissions/AppPermissions'; + +export abstract class UiInteractionBridge extends BaseBridge { + public async doNotifyUser(user: IUser, interaction: IUIKitInteraction, appId: string): Promise { + if (this.hasInteractionPermission(appId)) { + return this.notifyUser(user, interaction, appId); + } + } + + protected abstract notifyUser(user: IUser, interaction: IUIKitInteraction, appId: string): Promise; + + private hasInteractionPermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.ui.interaction)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.ui.interaction], + }), + ); + + return false; + } +} diff --git a/packages/apps/src/server/bridges/UploadBridge.ts b/packages/apps/src/server/bridges/UploadBridge.ts new file mode 100644 index 0000000000000..41a1c520b6e42 --- /dev/null +++ b/packages/apps/src/server/bridges/UploadBridge.ts @@ -0,0 +1,63 @@ +import type { IUpload } from '@rocket.chat/apps-engine/definition/uploads'; +import type { IUploadDetails } from '@rocket.chat/apps-engine/definition/uploads/IUploadDetails'; + +import { BaseBridge } from './BaseBridge'; +import { PermissionDeniedError } from '../errors/PermissionDeniedError'; +import { AppPermissionManager } from '../managers/AppPermissionManager'; +import { AppPermissions } from '../permissions/AppPermissions'; + +export abstract class UploadBridge extends BaseBridge { + public async doGetById(id: string, appId: string): Promise { + if (this.hasReadPermission(appId)) { + return this.getById(id, appId); + } + } + + public async doGetBuffer(upload: IUpload, appId: string): Promise { + if (this.hasReadPermission(appId)) { + return this.getBuffer(upload, appId); + } + } + + public async doCreateUpload(details: IUploadDetails, buffer: Buffer, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.createUpload(details, buffer, appId); + } + } + + protected abstract getById(id: string, appId: string): Promise; + + protected abstract getBuffer(upload: IUpload, appId: string): Promise; + + protected abstract createUpload(details: IUploadDetails, buffer: Buffer, appId: string): Promise; + + private hasReadPermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.upload.read)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.upload.read], + }), + ); + + return false; + } + + private hasWritePermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.upload.write)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.upload.write], + }), + ); + + return false; + } +} diff --git a/packages/apps/src/server/bridges/UserBridge.ts b/packages/apps/src/server/bridges/UserBridge.ts new file mode 100644 index 0000000000000..bdeb239bb05d7 --- /dev/null +++ b/packages/apps/src/server/bridges/UserBridge.ts @@ -0,0 +1,158 @@ +import type { IUser, IUserCreationOptions, UserType } from '@rocket.chat/apps-engine/definition/users'; + +import { BaseBridge } from './BaseBridge'; +import { PermissionDeniedError } from '../errors/PermissionDeniedError'; +import { AppPermissionManager } from '../managers/AppPermissionManager'; +import { AppPermissions } from '../permissions/AppPermissions'; + +export abstract class UserBridge extends BaseBridge { + public async doGetById(id: string, appId: string): Promise { + if (this.hasReadPermission(appId)) { + return this.getById(id, appId); + } + } + + public async doGetByUsername(username: string, appId: string): Promise { + if (this.hasReadPermission(appId)) { + return this.getByUsername(username, appId); + } + } + + public async doGetAppUser(appId?: string): Promise { + return this.getAppUser(appId); + } + + public async doCreate(data: Partial, appId: string, options?: IUserCreationOptions): Promise { + if (this.hasWritePermission(appId)) { + return this.create(data, appId, options || {}); + } + } + + public async doRemove(user: IUser, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.remove(user, appId); + } + } + + public async doUpdate(user: IUser, updates: Partial, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.update(user, updates, appId); + } + } + + public async doGetUserUnreadMessageCount(uid: string, appId: string): Promise { + if (this.hasReadPermission(appId)) { + return this.getUserUnreadMessageCount(uid, appId); + } + } + + public async doGetUserRoomIds(userId: string, appId: string): Promise { + if (this.hasReadPermission(appId)) { + return this.getUserRoomIds(userId, appId); + } + } + + public async doDeleteUsersCreatedByApp(appId: string, type: UserType.BOT | UserType.APP): Promise { + if (this.hasWritePermission(appId)) { + return this.deleteUsersCreatedByApp(appId, type); + } + } + + public async doDeactivate(userId: IUser['id'], confirmRelinquish: boolean, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.deactivate(userId, confirmRelinquish, appId); + } + } + + protected abstract getById(id: string, appId: string): Promise; + + protected abstract getByUsername(username: string, appId: string): Promise; + + protected abstract getAppUser(appId?: string): Promise; + + protected abstract getActiveUserCount(): Promise; + + protected abstract getUserUnreadMessageCount(uid: string, appId: string): Promise; + + protected abstract getUserRoomIds(userId: string, appId: string): Promise; + + /** + * Creates a user. + * @param data the essential data for creating a user + * @param appId the id of the app calling this + * @param options options for passing extra data + */ + protected abstract create(data: Partial, appId: string, options?: IUserCreationOptions): Promise; + + /** + * Remove a user. + * + * @param user the user object to be removed + * @param appId the id of the app executing the call + */ + protected abstract remove(user: IUser, appId: string): Promise; + + /** + * Updates a user. + * + * Note: the actual methods used by apps to update + * user properties are much more granular, but at a + * bridge level we can adopt a more practical approach + * since it is only accessible internally by the framework + * + * @param user the user to be updated + * @param updates a map of properties to be updated + * @param appId the id of the app executing the call + */ + protected abstract update(user: IUser, updates: Partial, appId: string): Promise; + + /** + * Deletes all bot or app users created by the App. + * @param appId the App's ID. + * @param type the type of the user to be deleted. + * @returns true if any user was deleted, false otherwise. + */ + protected abstract deleteUsersCreatedByApp(appId: string, type: UserType.APP | UserType.BOT): Promise; + + /** + * Deactivates a user. + * @param userId the user's ID. + * @param confirmRelinquish whether the user confirmed the relinquish of the account. + * @param appId the App's ID. + * @returns true if the user was deactivated, false otherwise. + * @throws {Error} if the user is not found. + * @throws {Error} if the user is the last admin. + * @throws {Error} if the user is the last owner, if confirmRelinquish is false. + */ + protected abstract deactivate(userId: IUser['id'], confirmRelinquish: boolean, appId: string): Promise; + + private hasReadPermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.user.read)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.user.read], + }), + ); + + return false; + } + + private hasWritePermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.user.write)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.user.write], + }), + ); + + return false; + } +} diff --git a/packages/apps/src/server/bridges/VideoConferenceBridge.ts b/packages/apps/src/server/bridges/VideoConferenceBridge.ts new file mode 100644 index 0000000000000..e5fadbd12bcd7 --- /dev/null +++ b/packages/apps/src/server/bridges/VideoConferenceBridge.ts @@ -0,0 +1,95 @@ +import type { IVideoConfProvider } from '@rocket.chat/apps-engine/definition/videoConfProviders'; +import type { AppVideoConference } from '@rocket.chat/apps-engine/definition/videoConferences/AppVideoConference'; +import type { VideoConference } from '@rocket.chat/apps-engine/definition/videoConferences/IVideoConference'; + +import { BaseBridge } from './BaseBridge'; +import { PermissionDeniedError } from '../errors/PermissionDeniedError'; +import { AppPermissionManager } from '../managers/AppPermissionManager'; +import { AppPermissions } from '../permissions/AppPermissions'; + +export abstract class VideoConferenceBridge extends BaseBridge { + public async doGetById(callId: string, appId: string): Promise { + if (this.hasReadPermission(appId)) { + return this.getById(callId, appId); + } + } + + public async doCreate(call: AppVideoConference, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.create(call, appId); + } + } + + public async doUpdate(call: VideoConference, appId: string): Promise { + if (this.hasWritePermission(appId)) { + return this.update(call, appId); + } + } + + public async doRegisterProvider(info: IVideoConfProvider, appId: string): Promise { + if (this.hasProviderPermission(appId)) { + return this.registerProvider(info, appId); + } + } + + public async doUnRegisterProvider(info: IVideoConfProvider, appId: string): Promise { + if (this.hasProviderPermission(appId)) { + return this.unRegisterProvider(info, appId); + } + } + + protected abstract create(call: AppVideoConference, appId: string): Promise; + + protected abstract getById(callId: string, appId: string): Promise; + + protected abstract update(call: VideoConference, appId: string): Promise; + + protected abstract registerProvider(info: IVideoConfProvider, appId: string): Promise; + + protected abstract unRegisterProvider(info: IVideoConfProvider, appId: string): Promise; + + private hasWritePermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.videoConference.write)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.videoConference.write], + }), + ); + + return false; + } + + private hasReadPermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.videoConference.read)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.videoConference.read], + }), + ); + + return false; + } + + private hasProviderPermission(appId: string): boolean { + if (AppPermissionManager.hasPermission(appId, AppPermissions.videoConference.provider)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.videoConference.provider], + }), + ); + + return false; + } +} diff --git a/packages/apps/src/server/bridges/index.ts b/packages/apps/src/server/bridges/index.ts new file mode 100644 index 0000000000000..6381da83a562c --- /dev/null +++ b/packages/apps/src/server/bridges/index.ts @@ -0,0 +1,57 @@ +import { ApiBridge } from './ApiBridge'; +import { AppActivationBridge } from './AppActivationBridge'; +import { AppBridges } from './AppBridges'; +import { AppDetailChangesBridge } from './AppDetailChangesBridge'; +import { CloudWorkspaceBridge } from './CloudWorkspaceBridge'; +import { CommandBridge } from './CommandBridge'; +import { ContactBridge } from './ContactBridge'; +import { EmailBridge } from './EmailBridge'; +import { EnvironmentalVariableBridge } from './EnvironmentalVariableBridge'; +import { ExperimentalBridge } from './ExperimentalBridge'; +import type { IHttpBridgeRequestInfo } from './HttpBridge'; +import { HttpBridge } from './HttpBridge'; +import type { IInternalBridge } from './IInternalBridge'; +import type { IInternalFederationBridge } from './IInternalFederationBridge'; +import type { IListenerBridge } from './IListenerBridge'; +import { LivechatBridge } from './LivechatBridge'; +import { MessageBridge } from './MessageBridge'; +import { ModerationBridge } from './ModerationBridge'; +import { OutboundMessageBridge } from './OutboundMessagesBridge'; +import { PersistenceBridge } from './PersistenceBridge'; +import { RoleBridge } from './RoleBridge'; +import { RoomBridge } from './RoomBridge'; +import { SchedulerBridge } from './SchedulerBridge'; +import { ServerSettingBridge } from './ServerSettingBridge'; +import { UiInteractionBridge } from './UiInteractionBridge'; +import { UploadBridge } from './UploadBridge'; +import { UserBridge } from './UserBridge'; +import { VideoConferenceBridge } from './VideoConferenceBridge'; + +export type { IHttpBridgeRequestInfo, IListenerBridge, IInternalBridge, IInternalFederationBridge }; + +export { + CloudWorkspaceBridge, + ContactBridge, + EnvironmentalVariableBridge, + HttpBridge, + LivechatBridge, + MessageBridge, + PersistenceBridge, + AppActivationBridge, + AppDetailChangesBridge, + CommandBridge, + ApiBridge, + RoomBridge, + ServerSettingBridge, + UserBridge, + UploadBridge, + EmailBridge, + ExperimentalBridge, + UiInteractionBridge, + SchedulerBridge, + AppBridges, + VideoConferenceBridge, + ModerationBridge, + RoleBridge, + OutboundMessageBridge, +}; diff --git a/packages/apps/src/server/compiler/AppCompiler.ts b/packages/apps/src/server/compiler/AppCompiler.ts new file mode 100644 index 0000000000000..9ae3b12ba67a0 --- /dev/null +++ b/packages/apps/src/server/compiler/AppCompiler.ts @@ -0,0 +1,30 @@ +import * as path from 'path'; + +import type { AppManager } from '../AppManager'; +import { ProxiedApp } from '../ProxiedApp'; +import type { IAppStorageItem } from '../storage'; +import type { IParseAppPackageResult } from './IParseAppPackageResult'; + +export class AppCompiler { + public normalizeStorageFiles(files: { [key: string]: string }): { [key: string]: string } { + const result: { [key: string]: string } = {}; + + Object.entries(files).forEach(([name, content]) => { + result[name.replace(/\$/g, '.')] = content; + }); + + return result; + } + + public async toSandBox(manager: AppManager, storage: IAppStorageItem, packageResult: IParseAppPackageResult): Promise { + if (typeof packageResult.files[path.normalize(storage.info.classFile)] === 'undefined') { + throw new Error(`Invalid App package for "${storage.info.name}". Could not find the classFile (${storage.info.classFile}) file.`); + } + + const runtime = await manager.getRuntime().startRuntimeForApp(packageResult, storage); + + const app = new ProxiedApp(manager, storage, runtime); + + return app; + } +} diff --git a/packages/apps/src/server/compiler/AppFabricationFulfillment.ts b/packages/apps/src/server/compiler/AppFabricationFulfillment.ts new file mode 100644 index 0000000000000..97e030dd514a8 --- /dev/null +++ b/packages/apps/src/server/compiler/AppFabricationFulfillment.ts @@ -0,0 +1,76 @@ +import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; + +import type { ProxiedApp } from '../ProxiedApp'; +import { AppLicenseValidationResult } from '../marketplace/license'; + +export class AppFabricationFulfillment { + public info: IAppInfo; + + public app: ProxiedApp; + + public implemented: { [int: string]: boolean }; + + public licenseValidationResult: AppLicenseValidationResult; + + public storageError: string; + + public appUserError: object; + + constructor() { + this.licenseValidationResult = new AppLicenseValidationResult(); + } + + public setAppInfo(information: IAppInfo): void { + this.info = structuredClone(information); + + this.licenseValidationResult.setAppId(information.id); + } + + public getAppInfo(): IAppInfo { + return this.info; + } + + public setApp(application: ProxiedApp): void { + this.app = application; + } + + public getApp(): ProxiedApp { + return this.app; + } + + public setImplementedInterfaces(interfaces: { [int: string]: boolean }): void { + this.implemented = structuredClone(interfaces); + } + + public getImplementedInferfaces(): { [int: string]: boolean } { + return this.implemented; + } + + public setStorageError(errorMessage: string): void { + this.storageError = errorMessage; + } + + public setAppUserError(error: object): void { + this.appUserError = error; + } + + public getStorageError(): string { + return this.storageError; + } + + public getAppUserError(): object { + return this.appUserError; + } + + public hasStorageError(): boolean { + return !!this.storageError; + } + + public hasAppUserError(): boolean { + return !!this.appUserError; + } + + public getLicenseValidationResult(): AppLicenseValidationResult { + return this.licenseValidationResult; + } +} diff --git a/packages/apps/src/server/compiler/AppImplements.ts b/packages/apps/src/server/compiler/AppImplements.ts new file mode 100644 index 0000000000000..1d618f8a989a0 --- /dev/null +++ b/packages/apps/src/server/compiler/AppImplements.ts @@ -0,0 +1,33 @@ +import { AppInterface } from '@rocket.chat/apps-engine/definition/metadata/AppInterface'; + +import { Utilities } from '../misc/Utilities'; + +export class AppImplements { + private implemented: Record; + + constructor() { + this.implemented = {} as Record; + + Object.keys(AppInterface).forEach((int: AppInterface) => { + this.implemented[int] = false; + }); + } + + public setImplements(int: AppInterface): void { + if (int in AppInterface) { + this.implemented[int] = true; + } + } + + public doesImplement(int: AppInterface): boolean { + return this.implemented[int]; + } + + public getValues(): Record { + return Utilities.deepCloneAndFreeze(this.implemented); + } + + public toJSON(): Record { + return this.getValues(); + } +} diff --git a/packages/apps/src/server/compiler/AppPackageParser.ts b/packages/apps/src/server/compiler/AppPackageParser.ts new file mode 100644 index 0000000000000..97018af67bcc9 --- /dev/null +++ b/packages/apps/src/server/compiler/AppPackageParser.ts @@ -0,0 +1,142 @@ +import * as path from 'path'; + +import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata/IAppInfo'; +import { ENGINE_VERSION } from '@rocket.chat/apps-engine/definition/version'; +import AdmZip from 'adm-zip'; +import * as semver from 'semver'; +import { v4 as uuidv4 } from 'uuid'; + +import { AppImplements } from '.'; +import type { IParseAppPackageResult } from './IParseAppPackageResult'; +import { RequiredApiVersionError } from '../errors'; + +export class AppPackageParser { + public static uuid4Regex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/; + + private allowedIconExts: Array = ['.png', '.jpg', '.jpeg', '.gif']; + + private appsEngineVersion: string = ENGINE_VERSION; + + public async unpackageApp(appPackage: Buffer): Promise { + const zip = new AdmZip(appPackage); + const infoZip = zip.getEntry('app.json'); + let info: IAppInfo; + + if (infoZip && !infoZip.isDirectory) { + try { + info = JSON.parse(infoZip.getData().toString()) as IAppInfo; + + if (!AppPackageParser.uuid4Regex.test(info.id)) { + info.id = uuidv4(); + console.warn( + 'WARNING: We automatically generated a uuid v4 id for', + info.name, + 'since it did not provide us an id. This is NOT', + 'recommended as the same App can be installed several times.', + ); + } + } catch { + throw new Error('Invalid App package. The "app.json" file is not valid json.'); + } + } else { + throw new Error('Invalid App package. No "app.json" file.'); + } + + info.classFile = info.classFile.replace('.ts', '.js'); + + if (!semver.satisfies(this.appsEngineVersion, info.requiredApiVersion)) { + throw new RequiredApiVersionError(info, this.appsEngineVersion); + } + + // Load all of the TypeScript only files + const files: { [s: string]: string } = {}; + + zip + .getEntries() + .filter((entry) => !entry.isDirectory && entry.entryName.endsWith('.js')) + .forEach((entry) => { + const norm = path.normalize(entry.entryName); + + // Files which start with `.` are supposed to be hidden + if (norm.startsWith('.')) { + return; + } + + files[norm] = entry.getData().toString(); + }); + + // Ensure that the main class file exists + if (!files[path.normalize(info.classFile)]) { + throw new Error(`Invalid App package. Could not find the classFile (${info.classFile}) file.`); + } + + const languageContent = this.getLanguageContent(zip); + + // Get the icon's content + const iconFile = this.getIconFile(zip, info.iconFile); + if (iconFile) { + info.iconFileContent = iconFile; + } + + const implemented = new AppImplements(); + + if (Array.isArray(info.implements)) { + info.implements.forEach((interfaceName) => implemented.setImplements(interfaceName)); + } + + return { + info, + files, + languageContent, + implemented, + }; + } + + private getLanguageContent(zip: AdmZip): { [key: string]: object } { + const languageContent: { [key: string]: object } = {}; + + zip + .getEntries() + .filter((entry) => !entry.isDirectory && entry.entryName.startsWith('i18n/') && entry.entryName.endsWith('.json')) + .forEach((entry) => { + const entrySplit = entry.entryName.split('/'); + const lang = entrySplit[entrySplit.length - 1].split('.')[0].toLowerCase(); + + let content; + try { + content = JSON.parse(entry.getData().toString()); + } catch { + // Failed to parse it, maybe warn them? idk yet + } + + languageContent[lang] = Object.assign(languageContent[lang] || {}, content); + }); + + return languageContent; + } + + private getIconFile(zip: AdmZip, filePath: string): string { + if (!filePath) { + return undefined; + } + + const ext = path.extname(filePath); + if (!this.allowedIconExts.includes(ext)) { + return undefined; + } + + const entry = zip.getEntry(filePath); + + if (!entry) { + return undefined; + } + + if (entry.isDirectory) { + return undefined; + } + + const base64 = entry.getData().toString('base64'); + + return `data:image/${ext.replace('.', '')};base64,${base64}`; + } +} diff --git a/packages/apps/src/server/compiler/IParseAppPackageResult.ts b/packages/apps/src/server/compiler/IParseAppPackageResult.ts new file mode 100644 index 0000000000000..73f475e8c41f7 --- /dev/null +++ b/packages/apps/src/server/compiler/IParseAppPackageResult.ts @@ -0,0 +1,10 @@ +import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; + +import type { AppImplements } from './AppImplements'; + +export interface IParseAppPackageResult { + info: IAppInfo; + files: { [key: string]: string }; + languageContent: { [key: string]: object }; + implemented: AppImplements; +} diff --git a/packages/apps/src/server/compiler/index.ts b/packages/apps/src/server/compiler/index.ts new file mode 100644 index 0000000000000..dddff056423b0 --- /dev/null +++ b/packages/apps/src/server/compiler/index.ts @@ -0,0 +1,9 @@ +import { AppCompiler } from './AppCompiler'; +import { AppFabricationFulfillment } from './AppFabricationFulfillment'; +import { AppImplements } from './AppImplements'; +import { AppPackageParser } from './AppPackageParser'; +import type { IParseAppPackageResult } from './IParseAppPackageResult'; + +export type { IParseAppPackageResult }; + +export { AppCompiler, AppFabricationFulfillment, AppImplements, AppPackageParser }; diff --git a/packages/apps/src/server/compiler/modules/index.ts b/packages/apps/src/server/compiler/modules/index.ts new file mode 100644 index 0000000000000..31cbec5af30c9 --- /dev/null +++ b/packages/apps/src/server/compiler/modules/index.ts @@ -0,0 +1,55 @@ +import { moduleHandlerFactory } from './networking'; + +export enum AllowedInternalModules { + path = 'path', + url = 'url', + crypto = 'crypto', + buffer = 'buffer', + stream = 'stream', + net = 'net', + http = 'http', + https = 'https', + zlib = 'zlib', + util = 'util', + punycode = 'punycode', + os = 'os', + querystring = 'querystring', +} + +export class ForbiddenNativeModuleAccess extends Error { + constructor(module: string, prop: string) { + super(`Access to property ${prop} in module ${module} is forbidden`); + } +} + +const defaultHandler = () => ({}); + +const noopHandler = () => ({ + get: (): undefined => undefined, +}); + +const proxyHandlers = { + path: defaultHandler, + url: defaultHandler, + crypto: defaultHandler, + buffer: defaultHandler, + stream: defaultHandler, + net: moduleHandlerFactory('net'), + http: moduleHandlerFactory('http'), + https: moduleHandlerFactory('https'), + zlib: defaultHandler, + util: defaultHandler, + punycode: defaultHandler, + os: noopHandler, + querystring: defaultHandler, +}; + +export function requireNativeModule(module: AllowedInternalModules, appId: string, requirer: any) { + const requiredModule = requirer(module); + + return new Proxy( + requiredModule, + // Creates a proxy handler that is aware of the appId requiring the module + Reflect.apply(proxyHandlers[module], undefined, [appId]), + ); +} diff --git a/packages/apps/src/server/compiler/modules/networking.ts b/packages/apps/src/server/compiler/modules/networking.ts new file mode 100644 index 0000000000000..d8ed3c8e744b7 --- /dev/null +++ b/packages/apps/src/server/compiler/modules/networking.ts @@ -0,0 +1,36 @@ +import type * as http from 'http'; +import type * as https from 'https'; +import type * as net from 'net'; + +import { ForbiddenNativeModuleAccess } from '.'; +import { PermissionDeniedError } from '../../errors/PermissionDeniedError'; +import { AppPermissionManager } from '../../managers/AppPermissionManager'; +import { AppPermissions } from '../../permissions/AppPermissions'; + +type IHttp = typeof http; +type IHttps = typeof https; +type INet = typeof net; + +type NetworkingLibs = IHttp | IHttps | INet; + +const networkingModuleBlockList = ['createServer', 'Server']; + +export const moduleHandlerFactory = (module: string) => { + return (appId: string): ProxyHandler => ({ + get(target, prop: string, receiver) { + if (networkingModuleBlockList.includes(prop)) { + throw new ForbiddenNativeModuleAccess(module, prop); + } + + if (!AppPermissionManager.hasPermission(appId, AppPermissions.networking.default)) { + throw new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.networking.default], + methodName: `${module}.${prop}`, + }); + } + + return Reflect.get(target, prop, receiver); + }, + }); +}; diff --git a/packages/apps/src/server/errors/AppOutboundProcessError.ts b/packages/apps/src/server/errors/AppOutboundProcessError.ts new file mode 100644 index 0000000000000..a7e905ae90b90 --- /dev/null +++ b/packages/apps/src/server/errors/AppOutboundProcessError.ts @@ -0,0 +1,12 @@ +export class AppOutboundProcessError implements Error { + public name = 'OutboundProviderError'; + + public message: string; + + constructor(message: string, where?: string) { + this.message = message; + if (where) { + this.message += ` (${where})`; + } + } +} diff --git a/packages/apps/src/server/errors/CommandAlreadyExistsError.ts b/packages/apps/src/server/errors/CommandAlreadyExistsError.ts new file mode 100644 index 0000000000000..8c49b4315e083 --- /dev/null +++ b/packages/apps/src/server/errors/CommandAlreadyExistsError.ts @@ -0,0 +1,9 @@ +export class CommandAlreadyExistsError implements Error { + public name = 'CommandAlreadyExists'; + + public message: string; + + constructor(command: string) { + this.message = `The command "${command}" already exists in the system.`; + } +} diff --git a/packages/apps/src/server/errors/CommandHasAlreadyBeenTouchedError.ts b/packages/apps/src/server/errors/CommandHasAlreadyBeenTouchedError.ts new file mode 100644 index 0000000000000..bbc0e39c02cea --- /dev/null +++ b/packages/apps/src/server/errors/CommandHasAlreadyBeenTouchedError.ts @@ -0,0 +1,9 @@ +export class CommandHasAlreadyBeenTouchedError implements Error { + public name = 'CommandHasAlreadyBeenTouched'; + + public message: string; + + constructor(command: string) { + this.message = `The command "${command}" has already been touched by another App.`; + } +} diff --git a/packages/apps/src/server/errors/CompilerError.ts b/packages/apps/src/server/errors/CompilerError.ts new file mode 100644 index 0000000000000..3d71ad50d443a --- /dev/null +++ b/packages/apps/src/server/errors/CompilerError.ts @@ -0,0 +1,9 @@ +export class CompilerError implements Error { + public name = 'CompilerError'; + + public message: string; + + constructor(detail: string) { + this.message = `An error occured while compiling an App: ${detail}`; + } +} diff --git a/packages/apps/src/server/errors/InvalidInstallationError.ts b/packages/apps/src/server/errors/InvalidInstallationError.ts new file mode 100644 index 0000000000000..4388261c17c91 --- /dev/null +++ b/packages/apps/src/server/errors/InvalidInstallationError.ts @@ -0,0 +1,5 @@ +export class InvalidInstallationError extends Error { + public constructor(message: string) { + super(`Invalid app installation: ${message}`); + } +} diff --git a/packages/apps/src/server/errors/InvalidLicenseError.ts b/packages/apps/src/server/errors/InvalidLicenseError.ts new file mode 100644 index 0000000000000..4ab70cdfc4dcf --- /dev/null +++ b/packages/apps/src/server/errors/InvalidLicenseError.ts @@ -0,0 +1,7 @@ +import type { AppLicenseValidationResult } from '../marketplace/license/AppLicenseValidationResult'; + +export class InvalidLicenseError extends Error { + public constructor(public readonly validationResult: AppLicenseValidationResult) { + super('Invalid app license'); + } +} diff --git a/packages/apps/src/server/errors/MustContainFunctionError.ts b/packages/apps/src/server/errors/MustContainFunctionError.ts new file mode 100644 index 0000000000000..bbe2d56a3d1cf --- /dev/null +++ b/packages/apps/src/server/errors/MustContainFunctionError.ts @@ -0,0 +1,9 @@ +export class MustContainFunctionError implements Error { + public name = 'MustContainFunction'; + + public message: string; + + constructor(fileName: string, funcName: string) { + this.message = `The App (${fileName}) doesn't have a "${funcName}" function which is required.`; + } +} diff --git a/packages/apps/src/server/errors/MustExtendAppError.ts b/packages/apps/src/server/errors/MustExtendAppError.ts new file mode 100644 index 0000000000000..bdb2fb3f21a56 --- /dev/null +++ b/packages/apps/src/server/errors/MustExtendAppError.ts @@ -0,0 +1,5 @@ +export class MustExtendAppError implements Error { + public name = 'MustExtendApp'; + + public message = 'App must extend the "App" abstract class.'; +} diff --git a/packages/apps/src/server/errors/NotEnoughMethodArgumentsError.ts b/packages/apps/src/server/errors/NotEnoughMethodArgumentsError.ts new file mode 100644 index 0000000000000..9fc6a01bfca1f --- /dev/null +++ b/packages/apps/src/server/errors/NotEnoughMethodArgumentsError.ts @@ -0,0 +1,9 @@ +export class NotEnoughMethodArgumentsError implements Error { + public readonly name: string = 'NotEnoughMethodArgumentsError'; + + public readonly message: string; + + constructor(method: string, requiredCount: number, providedCount: number) { + this.message = `The method "${method}" requires ${requiredCount} parameters but was only passed ${providedCount}.`; + } +} diff --git a/packages/apps/src/server/errors/PathAlreadyExistsError.ts b/packages/apps/src/server/errors/PathAlreadyExistsError.ts new file mode 100644 index 0000000000000..9f2a7a671b3c5 --- /dev/null +++ b/packages/apps/src/server/errors/PathAlreadyExistsError.ts @@ -0,0 +1,9 @@ +export class PathAlreadyExistsError implements Error { + public name = 'PathAlreadyExists'; + + public message: string; + + constructor(path: string) { + this.message = `The api path "${path}" already exists in the system.`; + } +} diff --git a/packages/apps/src/server/errors/PermissionDeniedError.ts b/packages/apps/src/server/errors/PermissionDeniedError.ts new file mode 100644 index 0000000000000..c850bc7f6276f --- /dev/null +++ b/packages/apps/src/server/errors/PermissionDeniedError.ts @@ -0,0 +1,25 @@ +import type { IPermission } from '@rocket.chat/apps-engine/definition/permissions/IPermission'; + +interface IPermissionDeniedErrorParams { + appId: string; + missingPermissions: Array; + methodName?: string; + reason?: string; + message?: string; +} + +export class PermissionDeniedError extends Error { + constructor({ appId, missingPermissions, methodName, reason, message }: IPermissionDeniedErrorParams) { + if (message) { + super(message); + } else { + const permissions = missingPermissions.map((permission) => `"${permission.name}"`).join(', '); + + super( + `Failed to call the method ${methodName ? `"${methodName}"` : ''} as the app (${appId}) lacks the following permissions:\n` + + `[${permissions}]. Declare them in your app.json to fix the issue.\n` + + `reason: ${reason}`, + ); + } + } +} diff --git a/packages/apps/src/server/errors/RequiredApiVersionError.ts b/packages/apps/src/server/errors/RequiredApiVersionError.ts new file mode 100644 index 0000000000000..c12bf7ae4c33c --- /dev/null +++ b/packages/apps/src/server/errors/RequiredApiVersionError.ts @@ -0,0 +1,20 @@ +import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; +import * as semver from 'semver'; + +export class RequiredApiVersionError implements Error { + public name = 'RequiredApiVersion'; + + public message: string; + + constructor(info: IAppInfo, versionInstalled: string) { + let moreInfo = ''; + if (semver.gt(versionInstalled, info.requiredApiVersion)) { + moreInfo = ' Please tell the author to update their App as it is out of date.'; + } + + this.message = + `Failed to load the App "${info.name}" (${info.id}) as it requires ` + + `v${info.requiredApiVersion} of the App API however your server comes with ` + + `v${versionInstalled}.${moreInfo}`; + } +} diff --git a/packages/apps/src/server/errors/VideoConfProviderAlreadyExistsError.ts b/packages/apps/src/server/errors/VideoConfProviderAlreadyExistsError.ts new file mode 100644 index 0000000000000..934d2a35dd3ca --- /dev/null +++ b/packages/apps/src/server/errors/VideoConfProviderAlreadyExistsError.ts @@ -0,0 +1,9 @@ +export class VideoConfProviderAlreadyExistsError implements Error { + public name = 'VideoConfProviderAlreadyExists'; + + public message: string; + + constructor(name: string) { + this.message = `The video conference provider "${name}" was already registered by another App.`; + } +} diff --git a/packages/apps/src/server/errors/VideoConfProviderNotRegisteredError.ts b/packages/apps/src/server/errors/VideoConfProviderNotRegisteredError.ts new file mode 100644 index 0000000000000..9ba854f9ea74e --- /dev/null +++ b/packages/apps/src/server/errors/VideoConfProviderNotRegisteredError.ts @@ -0,0 +1,9 @@ +export class VideoConfProviderNotRegisteredError implements Error { + public name = 'VideoConfProviderNotRegistered'; + + public message: string; + + constructor(providerName: string) { + this.message = `The video conference provider "${providerName}" is not registered in the system.`; + } +} diff --git a/packages/apps/src/server/errors/index.ts b/packages/apps/src/server/errors/index.ts new file mode 100644 index 0000000000000..f93385e5abb9a --- /dev/null +++ b/packages/apps/src/server/errors/index.ts @@ -0,0 +1,25 @@ +import { CommandAlreadyExistsError } from './CommandAlreadyExistsError'; +import { CommandHasAlreadyBeenTouchedError } from './CommandHasAlreadyBeenTouchedError'; +import { CompilerError } from './CompilerError'; +import { InvalidLicenseError } from './InvalidLicenseError'; +import { MustContainFunctionError } from './MustContainFunctionError'; +import { MustExtendAppError } from './MustExtendAppError'; +import { NotEnoughMethodArgumentsError } from './NotEnoughMethodArgumentsError'; +import { PathAlreadyExistsError } from './PathAlreadyExistsError'; +import { RequiredApiVersionError } from './RequiredApiVersionError'; +import { VideoConfProviderAlreadyExistsError } from './VideoConfProviderAlreadyExistsError'; +import { VideoConfProviderNotRegisteredError } from './VideoConfProviderNotRegisteredError'; + +export { + CommandAlreadyExistsError, + CommandHasAlreadyBeenTouchedError, + PathAlreadyExistsError, + CompilerError, + MustContainFunctionError, + MustExtendAppError, + NotEnoughMethodArgumentsError, + RequiredApiVersionError, + InvalidLicenseError, + VideoConfProviderAlreadyExistsError, + VideoConfProviderNotRegisteredError, +}; diff --git a/packages/apps/src/server/logging/AppConsole.ts b/packages/apps/src/server/logging/AppConsole.ts new file mode 100644 index 0000000000000..3d4aabb6d31b0 --- /dev/null +++ b/packages/apps/src/server/logging/AppConsole.ts @@ -0,0 +1,121 @@ +import type { ILogEntry, ILogger } from '@rocket.chat/apps-engine/definition/accessors'; +import { LogMessageSeverity } from '@rocket.chat/apps-engine/definition/accessors'; +import type { AppMethod } from '@rocket.chat/apps-engine/definition/metadata'; +import * as stackTrace from 'stack-trace'; + +import type { ILoggerStorageEntry } from './ILoggerStorageEntry'; + +export class AppConsole implements ILogger { + public static toStorageEntry(appId: string, logger: AppConsole): ILoggerStorageEntry { + return { + appId, + method: logger.getMethod(), + entries: logger.getEntries(), + startTime: logger.getStartTime(), + endTime: logger.getEndTime(), + totalTime: logger.getTotalTime(), + _createdAt: new Date(), + }; + } + + public method: `${AppMethod}`; + + private entries: Array; + + private start: Date; + + constructor(method: `${AppMethod}`) { + this.method = method; + this.entries = []; + this.start = new Date(); + } + + public debug(...items: Array): void { + this.addEntry(LogMessageSeverity.DEBUG, this.getFunc(stackTrace.get()), ...items); + } + + public info(...items: Array): void { + this.addEntry(LogMessageSeverity.INFORMATION, this.getFunc(stackTrace.get()), ...items); + } + + public log(...items: Array): void { + this.addEntry(LogMessageSeverity.LOG, this.getFunc(stackTrace.get()), ...items); + } + + public warn(...items: Array): void { + this.addEntry(LogMessageSeverity.WARNING, this.getFunc(stackTrace.get()), ...items); + } + + public error(...items: Array): void { + this.addEntry(LogMessageSeverity.ERROR, this.getFunc(stackTrace.get()), ...items); + } + + public success(...items: Array): void { + this.addEntry(LogMessageSeverity.SUCCESS, this.getFunc(stackTrace.get()), ...items); + } + + public getEntries(): Array { + return Array.from(this.entries); + } + + public getMethod(): `${AppMethod}` { + return this.method; + } + + public getStartTime(): Date { + return this.start; + } + + public getEndTime(): Date { + return new Date(); + } + + public getTotalTime(): number { + return this.getEndTime().getTime() - this.getStartTime().getTime(); + } + + private addEntry(severity: LogMessageSeverity, caller: string, ...items: Array): void { + const i = items.map((v) => { + if (v instanceof Error) { + return JSON.stringify(v, Object.getOwnPropertyNames(v)); + } + if (typeof v === 'object' && typeof v.stack === 'string' && typeof v.message === 'string') { + return JSON.stringify(v, Object.getOwnPropertyNames(v)); + } + const str = JSON.stringify(v, null, 2); + return str ? JSON.parse(str) : str; // force call toJSON to prevent circular references + }); + + this.entries.push({ + caller, + severity, + timestamp: new Date(), + args: i, + }); + + // This should be a setting? :thinking: + // console.log(`${ severity.toUpperCase() }:`, i); + } + + private getFunc(stack: Array): string { + let func = 'anonymous'; + + if (stack.length === 1) { + return func; + } + + const frame: stackTrace.StackFrame = stack[1]; + + if (frame.getMethodName() === null) { + func = 'anonymous OR constructor'; + } else { + func = frame.getMethodName(); + } + + if (frame.getFunctionName() !== null) { + func = `${func} -> ${frame.getFunctionName()}`; + } + + return func; + } +} diff --git a/packages/apps/src/server/logging/ILoggerStorageEntry.ts b/packages/apps/src/server/logging/ILoggerStorageEntry.ts new file mode 100644 index 0000000000000..12c6f47dfb01d --- /dev/null +++ b/packages/apps/src/server/logging/ILoggerStorageEntry.ts @@ -0,0 +1,14 @@ +import type { ILogEntry } from '@rocket.chat/apps-engine/definition/accessors'; +import type { AppMethod } from '@rocket.chat/apps-engine/definition/metadata'; + +export interface ILoggerStorageEntry { + appId: string; + method: `${AppMethod}`; + entries: Array; + startTime: Date; + endTime: Date; + totalTime: number; + instanceId?: string; + // Internal value to be used for sorting + _createdAt: Date; +} diff --git a/packages/apps/src/server/logging/index.ts b/packages/apps/src/server/logging/index.ts new file mode 100644 index 0000000000000..f9c8290ba48cc --- /dev/null +++ b/packages/apps/src/server/logging/index.ts @@ -0,0 +1,6 @@ +import { AppConsole } from './AppConsole'; +import type { ILoggerStorageEntry } from './ILoggerStorageEntry'; + +export type { ILoggerStorageEntry }; + +export { AppConsole }; diff --git a/packages/apps/src/server/managers/AppAccessorManager.ts b/packages/apps/src/server/managers/AppAccessorManager.ts new file mode 100644 index 0000000000000..57681c85856d5 --- /dev/null +++ b/packages/apps/src/server/managers/AppAccessorManager.ts @@ -0,0 +1,252 @@ +import type { + IConfigurationExtend, + IConfigurationModify, + IEnvironmentRead, + IEnvironmentWrite, + IHttp, + IHttpExtend, + IModify, + IPersistence, + IRead, +} from '@rocket.chat/apps-engine/definition/accessors'; + +import type { AppManager } from '../AppManager'; +import { + ApiExtend, + ConfigurationExtend, + ConfigurationModify, + EnvironmentalVariableRead, + EnvironmentRead, + EnvironmentWrite, + ExternalComponentsExtend, + Http, + HttpExtend, + LivechatRead, + MessageRead, + Modify, + Notifier, + OAuthAppsReader, + OutboundMessageProviderExtend, + Persistence, + PersistenceRead, + Reader, + RoleRead, + RoomRead, + SchedulerExtend, + SchedulerModify, + ServerSettingRead, + ServerSettingsModify, + ServerSettingUpdater, + SettingRead, + SettingsExtend, + SettingUpdater, + SlashCommandsExtend, + SlashCommandsModify, + UploadRead, + UserRead, + VideoConferenceRead, + VideoConfProviderExtend, +} from '../accessors'; +import { CloudWorkspaceRead } from '../accessors/CloudWorkspaceRead'; +import { ContactRead } from '../accessors/ContactRead'; +import { ExperimentalRead } from '../accessors/ExperimentalRead'; +import { ThreadRead } from '../accessors/ThreadRead'; +import { UIExtend } from '../accessors/UIExtend'; +import type { AppBridges } from '../bridges/AppBridges'; + +export class AppAccessorManager { + private readonly bridges: AppBridges; + + private readonly configExtenders: Map; + + private readonly envReaders: Map; + + private readonly envWriters: Map; + + private readonly configModifiers: Map; + + private readonly readers: Map; + + private readonly modifiers: Map; + + private readonly persists: Map; + + private readonly https: Map; + + constructor(private readonly manager: AppManager) { + this.bridges = this.manager.getBridges(); + this.configExtenders = new Map(); + this.envReaders = new Map(); + this.envWriters = new Map(); + this.configModifiers = new Map(); + this.readers = new Map(); + this.modifiers = new Map(); + this.persists = new Map(); + this.https = new Map(); + } + + /** + * Purifies the accessors for the provided App. + * + * @param appId The id of the App to purge the accessors for. + */ + public purifyApp(appId: string): void { + this.configExtenders.delete(appId); + this.envReaders.delete(appId); + this.envWriters.delete(appId); + this.configModifiers.delete(appId); + this.readers.delete(appId); + this.modifiers.delete(appId); + this.persists.delete(appId); + this.https.delete(appId); + } + + public getConfigurationExtend(appId: string): IConfigurationExtend { + if (!this.configExtenders.has(appId)) { + const rl = this.manager.getOneById(appId); + + if (!rl) { + throw new Error(`No App found by the provided id: ${appId}`); + } + + const htt = new HttpExtend(); + const cmds = new SlashCommandsExtend(this.manager.getCommandManager(), appId); + const videoConf = new VideoConfProviderExtend(this.manager.getVideoConfProviderManager(), appId); + const apis = new ApiExtend(this.manager.getApiManager(), appId); + const sets = new SettingsExtend(rl); + const excs = new ExternalComponentsExtend(this.manager.getExternalComponentManager(), appId); + const scheduler = new SchedulerExtend(this.manager.getSchedulerManager(), appId); + const ui = new UIExtend(this.manager.getUIActionButtonManager(), appId); + const outboundComms = new OutboundMessageProviderExtend(this.manager.getOutboundCommunicationProviderManager(), appId); + + this.configExtenders.set(appId, new ConfigurationExtend(htt, sets, cmds, apis, excs, scheduler, ui, videoConf, outboundComms)); + } + + return this.configExtenders.get(appId); + } + + public getEnvironmentRead(appId: string): IEnvironmentRead { + if (!this.envReaders.has(appId)) { + const rl = this.manager.getOneById(appId); + + if (!rl) { + throw new Error(`No App found by the provided id: ${appId}`); + } + + const sets = new SettingRead(rl); + const servsets = new ServerSettingRead(this.bridges.getServerSettingBridge(), appId); + const env = new EnvironmentalVariableRead(this.bridges.getEnvironmentalVariableBridge(), appId); + + this.envReaders.set(appId, new EnvironmentRead(sets, servsets, env)); + } + + return this.envReaders.get(appId); + } + + public getEnvironmentWrite(appId: string): IEnvironmentWrite { + if (!this.envWriters.has(appId)) { + const rl = this.manager.getOneById(appId); + + if (!rl) { + throw new Error(`No App found by the provided id: ${appId}`); + } + + const sets = new SettingUpdater(rl, this.manager.getSettingsManager()); + const serverSetting = new ServerSettingUpdater(this.bridges, appId); + + this.envWriters.set(appId, new EnvironmentWrite(sets, serverSetting)); + } + + return this.envWriters.get(appId); + } + + public getConfigurationModify(appId: string): IConfigurationModify { + if (!this.configModifiers.has(appId)) { + this.configModifiers.set( + appId, + new ConfigurationModify( + new ServerSettingsModify(this.bridges.getServerSettingBridge(), appId), + new SlashCommandsModify(this.manager.getCommandManager(), appId), + new SchedulerModify(this.bridges.getSchedulerBridge(), appId), + ), + ); + } + + return this.configModifiers.get(appId); + } + + public getReader(appId: string): IRead { + if (!this.readers.has(appId)) { + const env = this.getEnvironmentRead(appId); + const msg = new MessageRead(this.bridges.getMessageBridge(), appId); + const persist = new PersistenceRead(this.bridges.getPersistenceBridge(), appId); + const room = new RoomRead(this.bridges.getRoomBridge(), appId); + const user = new UserRead(this.bridges.getUserBridge(), appId); + const noti = new Notifier(this.bridges.getUserBridge(), this.bridges.getMessageBridge(), appId); + const livechat = new LivechatRead(this.bridges.getLivechatBridge(), appId); + const upload = new UploadRead(this.bridges.getUploadBridge(), appId); + const cloud = new CloudWorkspaceRead(this.bridges.getCloudWorkspaceBridge(), appId); + const videoConf = new VideoConferenceRead(this.bridges.getVideoConferenceBridge(), appId); + const oauthApps = new OAuthAppsReader(this.bridges.getOAuthAppsBridge(), appId); + const contactReader = new ContactRead(this.bridges, appId); + const thread = new ThreadRead(this.bridges.getThreadBridge(), appId); + const role = new RoleRead(this.bridges.getRoleBridge(), appId); + const experimental = new ExperimentalRead(this.bridges.getExperimentalBridge(), appId); + + this.readers.set( + appId, + new Reader( + env, + msg, + persist, + room, + user, + noti, + livechat, + upload, + cloud, + videoConf, + contactReader, + oauthApps, + thread, + role, + experimental, + ), + ); + } + + return this.readers.get(appId); + } + + public getModifier(appId: string): IModify { + if (!this.modifiers.has(appId)) { + this.modifiers.set(appId, new Modify(this.bridges, appId)); + } + + return this.modifiers.get(appId); + } + + public getPersistence(appId: string): IPersistence { + if (!this.persists.has(appId)) { + this.persists.set(appId, new Persistence(this.bridges.getPersistenceBridge(), appId)); + } + + return this.persists.get(appId); + } + + public getHttp(appId: string): IHttp { + if (!this.https.has(appId)) { + let ext: IHttpExtend; + if (this.configExtenders.has(appId)) { + ext = this.configExtenders.get(appId).http; + } else { + const cf = this.getConfigurationExtend(appId); + ext = cf.http; + } + + this.https.set(appId, new Http(this, this.bridges, ext, appId)); + } + + return this.https.get(appId); + } +} diff --git a/packages/apps/src/server/managers/AppApi.ts b/packages/apps/src/server/managers/AppApi.ts new file mode 100644 index 0000000000000..88205731dd0da --- /dev/null +++ b/packages/apps/src/server/managers/AppApi.ts @@ -0,0 +1,100 @@ +import type { IApi, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api'; +import { ApiSecurity, ApiVisibility } from '@rocket.chat/apps-engine/definition/api'; +import type { IApiEndpoint } from '@rocket.chat/apps-engine/definition/api/IApiEndpoint'; +import type { IApiEndpointInfo } from '@rocket.chat/apps-engine/definition/api/IApiEndpointInfo'; + +import type { ProxiedApp } from '../ProxiedApp'; +import type { AppLogStorage } from '../storage'; +import type { AppAccessorManager } from './AppAccessorManager'; + +export class AppApi { + public readonly computedPath: string; + + public readonly basePath: string; + + public readonly appId: string; + + public readonly hash?: string; + + public readonly implementedMethods: Array; + + constructor( + public app: ProxiedApp, + public api: IApi, + public endpoint: IApiEndpoint, + ) { + this.appId = app.getID(); + + switch (this.api.visibility) { + case ApiVisibility.PUBLIC: + this.basePath = `/api/apps/public/${app.getID()}`; + break; + + case ApiVisibility.PRIVATE: + this.basePath = `/api/apps/private/${app.getID()}/${app.getStorageItem()._id}`; + this.hash = app.getStorageItem()._id; + break; + } + + this.computedPath = `${this.basePath}/${endpoint.path}`; + + this.implementedMethods = endpoint._availableMethods; + } + + public async runExecutor(request: IApiRequest, _logStorage: AppLogStorage, _accessors: AppAccessorManager): Promise { + const { path } = this.endpoint; + + const { method } = request; + + if (!this.validateVisibility(request)) { + return { + status: 404, + }; + } + + if (!this.validateSecurity(request)) { + return { + status: 401, + }; + } + + const endpoint: IApiEndpointInfo = { + basePath: this.basePath, + fullPath: this.computedPath, + appId: this.appId, + hash: this.hash, + }; + + try { + const result = await this.app.getRuntimeController().sendRequest({ + method: `api:${path}:${method}`, + params: [request, endpoint], + }); + + return result as IApiResponse; + } catch (e) { + console.error(e); + throw e; + } + } + + private validateVisibility(request: IApiRequest): boolean { + if (this.api.visibility === ApiVisibility.PUBLIC) { + return true; + } + + if (this.api.visibility === ApiVisibility.PRIVATE) { + return this.app.getStorageItem()._id === request.privateHash; + } + + return false; + } + + private validateSecurity(_request: IApiRequest): boolean { + if (this.api.security === ApiSecurity.UNSECURE) { + return true; + } + + return false; + } +} diff --git a/packages/apps/src/server/managers/AppApiManager.ts b/packages/apps/src/server/managers/AppApiManager.ts new file mode 100644 index 0000000000000..e553eef9743ef --- /dev/null +++ b/packages/apps/src/server/managers/AppApiManager.ts @@ -0,0 +1,166 @@ +import { AppStatusUtils } from '@rocket.chat/apps-engine/definition/AppStatus'; +import { HttpStatusCode } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IApi, IApiEndpointMetadata, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api'; + +import type { AppManager } from '../AppManager'; +import type { ApiBridge } from '../bridges'; +import { PathAlreadyExistsError } from '../errors'; +import type { AppAccessorManager } from './AppAccessorManager'; +import { AppApi } from './AppApi'; + +/** + * The api manager for the Apps. + * + * An App will add api's during their `initialize` method. + * Then once an App's `onEnable` is called and it returns true, + * only then will that App's api's be enabled. + */ +export class AppApiManager { + private readonly bridge: ApiBridge; + + private readonly accessors: AppAccessorManager; + + // Variable that contains the api's which have been provided by apps. + // The key of the top map is app id and the key of the inner map is the path + private providedApis: Map>; + + constructor(private readonly manager: AppManager) { + this.bridge = this.manager.getBridges().getApiBridge(); + this.accessors = this.manager.getAccessorManager(); + this.providedApis = new Map>(); + } + + /** + * Adds an to *be* registered. This will *not register* it with the + * bridged system yet as this is only called on an App's + * `initialize` method and an App might not get enabled. + * When adding an api, it can *not* already exist in the system. + * + * @param appId the app's id which the api belongs to + * @param api the api to add to the system + */ + public addApi(appId: string, api: IApi): void { + if (api.endpoints.length === 0) { + throw new Error('Invalid Api parameter provided, endpoints must contain, at least, one IApiEndpoint.'); + } + + const app = this.manager.getOneById(appId); + if (!app) { + throw new Error('App must exist in order for an api to be added.'); + } + + // Verify the api's path doesn't exist already + if (this.providedApis.get(appId)) { + api.endpoints.forEach((endpoint) => { + if (this.providedApis.get(appId).has(endpoint.path)) { + throw new PathAlreadyExistsError(endpoint.path); + } + }); + } + + if (!this.providedApis.has(appId)) { + this.providedApis.set(appId, new Map()); + } + + api.endpoints.forEach((endpoint) => { + this.providedApis.get(appId).set(endpoint.path, new AppApi(app, api, endpoint)); + }); + } + + /** + * Registers all of the api's for the provided app inside + * of the bridged system which then enables them. + * + * @param appId The app's id of which to register it's api's with the bridged system + */ + public async registerApis(appId: string): Promise { + if (!this.providedApis.has(appId)) { + return; + } + + await this.bridge.doUnregisterApis(appId); + for await (const [, apiApp] of this.providedApis.get(appId).entries()) { + await this.registerApi(appId, apiApp); + } + } + + /** + * Unregisters the api's from the system. + * + * @param appId the appId for the api's to purge + */ + public async unregisterApis(appId: string): Promise { + if (this.providedApis.has(appId)) { + await this.bridge.doUnregisterApis(appId); + + this.providedApis.delete(appId); + } + } + + /** + * Executes an App's api. + * + * @param appId the app which is providing the api + * @param path the path to be executed in app's api's + * @param request the request data to be evaluated byt the app + */ + public async executeApi(appId: string, path: string, request: IApiRequest): Promise { + const api = this.providedApis.get(appId).get(path); + + if (!api) { + return { + status: HttpStatusCode.NOT_FOUND, + }; + } + + const app = this.manager.getOneById(appId); + + if (!app || AppStatusUtils.isDisabled(await app.getStatus())) { + // Just in case someone decides to do something they shouldn't + // let's ensure the app actually exists + return { + status: HttpStatusCode.NOT_FOUND, + }; + } + + return api.runExecutor(request, this.manager.getLogStorage(), this.accessors); + } + + /** + * Return a list of api's for a certain app + * + * @param appId the app which is providing the api + */ + public listApis(appId: string): Array { + const apis = this.providedApis.get(appId); + + if (!apis) { + return []; + } + + const result = []; + + for (const api of apis.values()) { + const metadata: IApiEndpointMetadata = { + path: api.endpoint.path, + computedPath: api.computedPath, + methods: api.implementedMethods, + examples: api.endpoint.examples || {}, + }; + + result.push(metadata); + } + + return result; + } + + /** + * Actually goes and provide's the bridged system with the api information. + * + * @param appId the app which is providing the api + * @param info the api's registration information + */ + private async registerApi(appId: string, api: AppApi): Promise { + await this.bridge.doRegisterApi(api, appId); + } +} diff --git a/packages/apps/src/server/managers/AppExternalComponentManager.ts b/packages/apps/src/server/managers/AppExternalComponentManager.ts new file mode 100644 index 0000000000000..a03c984173863 --- /dev/null +++ b/packages/apps/src/server/managers/AppExternalComponentManager.ts @@ -0,0 +1,142 @@ +import type { IExternalComponent } from '@rocket.chat/apps-engine/definition/externalComponent'; + +/** + * The external component manager for the apps. + * + * An app will register external components during its `initialize` method. + * Then once an app's `onEnable` method is called and it returns true, + * only then will that app's external components be enabled. + */ +export class AppExternalComponentManager { + /** + * The map that maintains all registered components. + * The key of the top map is app id and the key of inner map is the + * external component name. + */ + private registeredExternalComponents: Map>; + + /** + * Contains the apps and the external components they have touhed. + * The key of the top map is app id and the key of inner map is the + * external component name. + * Doesn't matter whether the app provided, modified, disabled, + * or enabled. As long as an app touched external components, then + * they are listed here. + */ + private appTouchedExternalComponents: Map>; + + constructor() { + this.registeredExternalComponents = new Map>(); + this.appTouchedExternalComponents = new Map>(); + } + + /** + * Get all registered components. + */ + public getRegisteredExternalComponents(): Map> { + return this.registeredExternalComponents; + } + + /** + * Get all external components that apps have registered + * before, including disabled apps' external components. + */ + public getAppTouchedExternalComponents(): Map> { + return this.appTouchedExternalComponents; + } + + /** + * Get all external components of an app by specifying the appId. + * + * @param appId the id of the app + */ + public getExternalComponents(appId: string): Map { + if (this.appTouchedExternalComponents.has(appId)) { + return this.appTouchedExternalComponents.get(appId); + } + + return null; + } + + /** + * Get an array of external components which are enabled and ready for usage. + */ + public getProvidedComponents(): Array { + const registeredExternalComponents = this.getRegisteredExternalComponents(); + const providedComponents: Array = []; + + registeredExternalComponents.forEach((appExternalComponents) => { + Array.from(appExternalComponents.values()).forEach((externalComponent) => { + providedComponents.push(externalComponent); + }); + }); + + return providedComponents; + } + + /** + * Add an external component to the appTouchedExternalComponents. + * If you call this method twice and the component + * has the same name as before, the first one will be + * overwritten as the names provided **must** be unique. + * + * @param appId the id of the app + * @param externalComponent the external component about to be added + */ + public addExternalComponent(appId: string, externalComponent: IExternalComponent): void { + externalComponent.appId = appId; + + if (!this.appTouchedExternalComponents.get(appId)) { + this.appTouchedExternalComponents.set(appId, new Map(Object.entries({ [externalComponent.name]: externalComponent }))); + } else { + const appExternalComponents = this.appTouchedExternalComponents.get(appId); + + appExternalComponents.set(externalComponent.name, externalComponent); + } + } + + /** + * Add enabled apps' external components from the appTouchedExternalComponents + * to the registeredExternalComponents. + * + * @param appId the id of the app + */ + public registerExternalComponents(appId: string): void { + if (!this.appTouchedExternalComponents.has(appId)) { + return; + } + const externalComponents = this.appTouchedExternalComponents.get(appId); + + if (externalComponents.size > 0) { + this.registeredExternalComponents.set(appId, externalComponents); + } + } + + /** + * Remove all external components of an app from the + * registeredExternalComponents by specifying the appId. + * + * @param appId the id of the app + */ + public unregisterExternalComponents(appId: string): void { + if (this.registeredExternalComponents.has(appId)) { + this.registeredExternalComponents.delete(appId); + } + } + + /** + * Remove all external components of an app from both the + * registeredExternalComponents and the appTouchedComponents + * by specifying the appId. + * + * @param appId the id of the app + */ + public purgeExternalComponents(appId: string): void { + if (this.appTouchedExternalComponents.has(appId)) { + this.appTouchedExternalComponents.delete(appId); + } + if (this.registeredExternalComponents.has(appId)) { + this.registeredExternalComponents.delete(appId); + } + } +} diff --git a/packages/apps/src/server/managers/AppLicenseManager.ts b/packages/apps/src/server/managers/AppLicenseManager.ts new file mode 100644 index 0000000000000..fb323eb113ad8 --- /dev/null +++ b/packages/apps/src/server/managers/AppLicenseManager.ts @@ -0,0 +1,99 @@ +import type { AppManager } from '../AppManager'; +import type { UserBridge } from '../bridges'; +import type { IInternalUserBridge } from '../bridges/IInternalUserBridge'; +import { InvalidLicenseError } from '../errors'; +import type { IMarketplaceInfo } from '../marketplace'; +import { MarketplacePurchaseType } from '../marketplace/MarketplacePurchaseType'; +import { Crypto } from '../marketplace/license'; +import type { AppLicenseValidationResult } from '../marketplace/license'; + +enum LicenseVersion { + v1 = 1, +} + +export class AppLicenseManager { + private readonly crypto: Crypto; + + private readonly userBridge: UserBridge; + + constructor(private readonly manager: AppManager) { + this.crypto = new Crypto(this.manager.getBridges().getInternalBridge()); + this.userBridge = this.manager.getBridges().getUserBridge(); + } + + public async validate(validationResult: AppLicenseValidationResult, appMarketplaceInfo?: IMarketplaceInfo[]): Promise { + const marketplaceInfo = appMarketplaceInfo?.[0]; + if (marketplaceInfo?.purchaseType !== MarketplacePurchaseType.PurchaseTypeSubscription) { + return; + } + + validationResult.setValidated(true); + + const encryptedLicense = marketplaceInfo.subscriptionInfo.license.license; + + if (!encryptedLicense) { + validationResult.addError('license', 'License for app is invalid'); + + throw new InvalidLicenseError(validationResult); + } + + let license; + try { + license = (await this.crypto.decryptLicense(encryptedLicense)) as any; + } catch (err) { + validationResult.addError('publicKey', err.message); + + throw new InvalidLicenseError(validationResult); + } + + switch (license.version) { + case LicenseVersion.v1: + await this.validateV1(marketplaceInfo, license, validationResult); + break; + } + } + + private async validateV1( + appMarketplaceInfo: IMarketplaceInfo, + license: any, + validationResult: AppLicenseValidationResult, + ): Promise { + if (license.isBundle && !appMarketplaceInfo.bundledIn?.find((value) => value.bundleId === license.appId)) { + validationResult.addError('bundle', 'License issued for a bundle that does not contain the app'); + } else if (!license.isBundle && license.appId !== appMarketplaceInfo.id) { + validationResult.addError('appId', `License hasn't been issued for this app`); + } + + const renewal = new Date(license.renewalDate); + const expire = new Date(license.expireDate); + const now = new Date(); + + if (expire < now) { + validationResult.addError('expire', 'License is no longer valid and needs to be renewed'); + } + + const currentActiveUsers = await (this.userBridge as UserBridge & IInternalUserBridge).getActiveUserCount(); + + if (license.maxSeats < currentActiveUsers) { + validationResult.addError( + 'maxSeats', + 'License does not accomodate the current amount of active users. Please increase the number of seats', + ); + } + + if (validationResult.hasErrors) { + throw new InvalidLicenseError(validationResult); + } + + if (renewal < now) { + validationResult.addWarning('renewal', 'License has expired and needs to be renewed'); + } + + if (license.seats < currentActiveUsers) { + validationResult.addWarning( + 'seats', + 'License does not have enough seats to accommodate the current amount of active users. Please increase the number of seats', + ); + } + } +} diff --git a/packages/apps/src/server/managers/AppListenerManager.ts b/packages/apps/src/server/managers/AppListenerManager.ts new file mode 100644 index 0000000000000..38061236a360f --- /dev/null +++ b/packages/apps/src/server/managers/AppListenerManager.ts @@ -0,0 +1,1288 @@ +import type { IEmailDescriptor, IPreEmailSentContext } from '@rocket.chat/apps-engine/definition/email'; +import { EssentialAppDisabledException } from '@rocket.chat/apps-engine/definition/exceptions'; +import type { IExternalComponent } from '@rocket.chat/apps-engine/definition/externalComponent'; +import type { + ILivechatEventContext, + ILivechatRoom, + ILivechatTransferEventContext, + IVisitor, +} from '@rocket.chat/apps-engine/definition/livechat'; +import type { ILivechatDepartmentEventContext } from '@rocket.chat/apps-engine/definition/livechat/ILivechatEventContext'; +import type { + IMessage, + IMessageDeleteContext, + IMessageFollowContext, + IMessagePinContext, + IMessageReactionContext, + IMessageReportContext, + IMessageStarContext, +} from '@rocket.chat/apps-engine/definition/messages'; +import { AppInterface, AppMethod } from '@rocket.chat/apps-engine/definition/metadata'; +import type { IRoom, IRoomUserJoinedContext, IRoomUserLeaveContext } from '@rocket.chat/apps-engine/definition/rooms'; +import { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; +import { UIActionButtonContext } from '@rocket.chat/apps-engine/definition/ui'; +import type { IUIKitResponse, IUIKitSurface, UIKitIncomingInteraction } from '@rocket.chat/apps-engine/definition/uikit'; +import { UIKitIncomingInteractionType } from '@rocket.chat/apps-engine/definition/uikit'; +import { isUIKitIncomingInteractionActionButtonMessageBox } from '@rocket.chat/apps-engine/definition/uikit/IUIKitIncomingInteractionActionButton'; +import type { + IUIKitLivechatBlockIncomingInteraction, + IUIKitLivechatIncomingInteraction, +} from '@rocket.chat/apps-engine/definition/uikit/livechat'; +import type { IFileUploadInternalContext } from '@rocket.chat/apps-engine/definition/uploads/IFileUploadContext'; +import type { IUser, IUserContext, IUserStatusContext, IUserUpdateContext } from '@rocket.chat/apps-engine/definition/users'; + +import type { AppAccessorManager } from './AppAccessorManager'; +import type { AppManager } from '../AppManager'; +import type { ProxiedApp } from '../ProxiedApp'; +import { Utilities } from '../misc/Utilities'; +import { JSONRPC_METHOD_NOT_FOUND } from '../runtime/deno/AppsEngineDenoRuntime'; + +export interface IListenerExecutor { + [AppInterface.IPreMessageSentPrevent]: { + args: [IMessage]; + result: boolean; + }; + [AppInterface.IPreMessageSentExtend]: { + args: [IMessage]; + result: IMessage; + }; + [AppInterface.IPreMessageSentModify]: { + args: [IMessage]; + result: IMessage; + }; + [AppInterface.IPostSystemMessageSent]: { + args: [IMessage]; + result: void; + }; + [AppInterface.IPostMessageSent]: { + args: [IMessage]; + result: void; + }; + [AppInterface.IPreMessageDeletePrevent]: { + args: [IMessage]; + result: boolean; + }; + [AppInterface.IPostMessageDeleted]: { + args: [IMessageDeleteContext]; + result: void; + }; + [AppInterface.IPreMessageUpdatedPrevent]: { + args: [IMessage]; + result: unknown; + }; + [AppInterface.IPreMessageUpdatedExtend]: { + args: [IMessage]; + result: boolean; + }; + [AppInterface.IPreMessageUpdatedModify]: { + args: [IMessage]; + result: IMessage; + }; + [AppInterface.IPostMessageUpdated]: { + args: [IMessage]; + result: IMessage; + }; + [AppInterface.IPostMessageReacted]: { + args: [IMessageReactionContext]; + result: void; + }; + [AppInterface.IPostMessageFollowed]: { + args: [IMessageFollowContext]; + result: void; + }; + [AppInterface.IPostMessagePinned]: { + args: [IMessagePinContext]; + result: void; + }; + [AppInterface.IPostMessageStarred]: { + args: [IMessageStarContext]; + result: void; + }; + [AppInterface.IPostMessageReported]: { + args: [IMessageReportContext]; + result: void; + }; + // Rooms + [AppInterface.IPreRoomCreatePrevent]: { + args: [IRoom]; + result: boolean; + }; + [AppInterface.IPreRoomCreateExtend]: { + args: [IRoom]; + result: IRoom; + }; + [AppInterface.IPreRoomCreateModify]: { + args: [IRoom]; + result: IRoom; + }; + [AppInterface.IPostRoomCreate]: { + args: [IRoom]; + result: void; + }; + [AppInterface.IPreRoomDeletePrevent]: { + args: [IRoom]; + result: boolean; + }; + [AppInterface.IPostRoomDeleted]: { + args: [IRoom]; + result: void; + }; + [AppInterface.IPreRoomUserJoined]: { + args: [IRoomUserJoinedContext]; + result: void; + }; + [AppInterface.IPostRoomUserJoined]: { + args: [IRoomUserJoinedContext]; + result: void; + }; + [AppInterface.IPreRoomUserLeave]: { + args: [IRoomUserLeaveContext]; + result: void; + }; + [AppInterface.IPostRoomUserLeave]: { + args: [IRoomUserLeaveContext]; + result: void; + }; + // External Components + [AppInterface.IPostExternalComponentOpened]: { + args: [IExternalComponent]; + result: void; + }; + [AppInterface.IPostExternalComponentClosed]: { + args: [IExternalComponent]; + result: void; + }; + [AppInterface.IUIKitInteractionHandler]: { + args: [UIKitIncomingInteraction]; + result: IUIKitResponse; + }; + [AppInterface.IUIKitLivechatInteractionHandler]: { + args: [IUIKitLivechatIncomingInteraction]; + result: IUIKitResponse; + }; + // Livechat + [AppInterface.IPostLivechatRoomStarted]: { + args: [ILivechatRoom]; + result: void; + }; + /** + * @deprecated please prefer the AppInterface.IPostLivechatRoomClosed event + */ + [AppInterface.ILivechatRoomClosedHandler]: { + args: [ILivechatRoom]; + result: void; + }; + [AppInterface.IPreLivechatRoomCreatePrevent]: { + args: [ILivechatRoom]; + result: void; + }; + [AppInterface.IPostLivechatRoomClosed]: { + args: [ILivechatRoom]; + result: void; + }; + [AppInterface.IPostLivechatRoomSaved]: { + args: [ILivechatRoom]; + result: void; + }; + [AppInterface.IPostLivechatAgentAssigned]: { + args: [ILivechatEventContext]; + result: void; + }; + [AppInterface.IPostLivechatAgentUnassigned]: { + args: [ILivechatEventContext]; + result: void; + }; + [AppInterface.IPostLivechatRoomTransferred]: { + args: [ILivechatTransferEventContext]; + result: void; + }; + [AppInterface.IPostLivechatGuestSaved]: { + args: [IVisitor]; + result: void; + }; + [AppInterface.IPostLivechatDepartmentRemoved]: { + args: [ILivechatDepartmentEventContext]; + result: void; + }; + [AppInterface.IPostLivechatDepartmentDisabled]: { + args: [ILivechatDepartmentEventContext]; + result: void; + }; + // FileUpload + [AppInterface.IPreFileUpload]: { + args: [IFileUploadInternalContext]; + result: void; + }; + // Email + [AppInterface.IPreEmailSent]: { + args: [IPreEmailSentContext]; + result: IUIKitResponse; + }; + // User + [AppInterface.IPostUserCreated]: { + args: [IUserContext]; + result: void; + }; + [AppInterface.IPostUserUpdated]: { + args: [IUserContext]; + result: void; + }; + [AppInterface.IPostUserDeleted]: { + args: [IUserContext]; + result: void; + }; + [AppInterface.IPostUserLoggedIn]: { + args: [IUser]; + result: void; + }; + [AppInterface.IPostUserLoggedOut]: { + args: [IUser]; + result: void; + }; + [AppInterface.IPostUserStatusChanged]: { + args: [IUserStatusContext]; + result: void; + }; +} + +// type EventReturn = void | boolean | IMessage | IRoom | IUser | IUIKitResponse | ILivechatRoom | IEmailDescriptor; + +export class AppListenerManager { + private am: AppAccessorManager; + + private listeners: Map>; + + private defaultHandlers = new Map(); + + /** + * Locked events are those who are listed in an app's + * "essentials" list but the app is disabled. + * + * They will throw a EssentialAppDisabledException upon call + */ + private lockedEvents: Map>; + + constructor(private readonly manager: AppManager) { + this.am = manager.getAccessorManager(); + this.listeners = new Map>(); + this.lockedEvents = new Map>(); + + Object.keys(AppInterface).forEach((intt) => { + this.listeners.set(intt, []); + this.lockedEvents.set(intt, new Set()); + }); + + this.defaultHandlers.set('executeViewClosedHandler', { success: true }); + } + + public registerListeners(app: ProxiedApp): void { + this.unregisterListeners(app); + + Object.entries(app.getImplementationList()).forEach(([event, isImplemented]) => { + if (!isImplemented) { + return; + } + + this.listeners.get(event).push(app.getID()); + }); + } + + public unregisterListeners(app: ProxiedApp): void { + this.listeners.forEach((apps, int) => { + if (apps.includes(app.getID())) { + const where = apps.indexOf(app.getID()); + this.listeners.get(int).splice(where, 1); + } + }); + } + + public releaseEssentialEvents(app: ProxiedApp): void { + if (!app.getEssentials()) { + return; + } + + app.getEssentials().forEach((event) => { + const lockedEvent = this.lockedEvents.get(event); + + if (!lockedEvent) { + return; + } + + lockedEvent.delete(app.getID()); + }); + } + + public lockEssentialEvents(app: ProxiedApp): void { + if (!app.getEssentials()) { + return; + } + + app.getEssentials().forEach((event) => { + const lockedEvent = this.lockedEvents.get(event); + + if (!lockedEvent) { + return; + } + + lockedEvent.add(app.getID()); + }); + } + + public getListeners(int: AppInterface): Array { + const results: Array = []; + + for (const appId of this.listeners.get(int)) { + results.push(this.manager.getOneById(appId)); + } + + return results; + } + + public isEventBlocked(event: AppInterface): boolean { + const lockedEventList = this.lockedEvents.get(event); + + return !!lockedEventList?.size; + } + + /* eslint-disable-next-line complexity */ + public async executeListener( + int: I, + data: IListenerExecutor[I]['args'][0], + ): Promise { + if (this.isEventBlocked(int)) { + throw new EssentialAppDisabledException('There is one or more apps that are essential to this event but are disabled'); + } + + switch (int) { + // Messages + case AppInterface.IPreMessageSentPrevent: + return this.executePreMessageSentPrevent(data as IMessage); + case AppInterface.IPreMessageSentExtend: + return this.executePreMessageSentExtend(data as IMessage); + case AppInterface.IPreMessageSentModify: + return this.executePreMessageSentModify(data as IMessage); + case AppInterface.IPostMessageSent: + void this.executePostMessageSent(data as IMessage); + return; + case AppInterface.IPostSystemMessageSent: + void this.executePostSystemMessageSent(data as IMessage); + return; + case AppInterface.IPreMessageDeletePrevent: + return this.executePreMessageDeletePrevent(data as IMessage); + case AppInterface.IPostMessageDeleted: + void this.executePostMessageDelete(data as IMessageDeleteContext); + return; + case AppInterface.IPreMessageUpdatedPrevent: + return this.executePreMessageUpdatedPrevent(data as IMessage); + case AppInterface.IPreMessageUpdatedExtend: + return this.executePreMessageUpdatedExtend(data as IMessage); + case AppInterface.IPreMessageUpdatedModify: + return this.executePreMessageUpdatedModify(data as IMessage); + case AppInterface.IPostMessageUpdated: + void this.executePostMessageUpdated(data as IMessage); + return; + case AppInterface.IPostMessageReacted: + return this.executePostMessageReacted(data as IMessageReactionContext); + case AppInterface.IPostMessageFollowed: + return this.executePostMessageFollowed(data as IMessageFollowContext); + case AppInterface.IPostMessagePinned: + return this.executePostMessagePinned(data as IMessagePinContext); + case AppInterface.IPostMessageStarred: + return this.executePostMessageStarred(data as IMessageStarContext); + case AppInterface.IPostMessageReported: + return this.executePostMessageReported(data as IMessageReportContext); + // Rooms + case AppInterface.IPreRoomCreatePrevent: + return this.executePreRoomCreatePrevent(data as IRoom); + case AppInterface.IPreRoomCreateExtend: + return this.executePreRoomCreateExtend(data as IRoom); + case AppInterface.IPreRoomCreateModify: + return this.executePreRoomCreateModify(data as IRoom); + case AppInterface.IPostRoomCreate: + void this.executePostRoomCreate(data as IRoom); + return; + case AppInterface.IPreRoomDeletePrevent: + return this.executePreRoomDeletePrevent(data as IRoom); + case AppInterface.IPostRoomDeleted: + void this.executePostRoomDeleted(data as IRoom); + return; + case AppInterface.IPreRoomUserJoined: + return this.executePreRoomUserJoined(data as IRoomUserJoinedContext); + case AppInterface.IPostRoomUserJoined: + return this.executePostRoomUserJoined(data as IRoomUserJoinedContext); + case AppInterface.IPreRoomUserLeave: + return this.executePreRoomUserLeave(data as IRoomUserLeaveContext); + case AppInterface.IPostRoomUserLeave: + return this.executePostRoomUserLeave(data as IRoomUserLeaveContext); + // External Components + case AppInterface.IPostExternalComponentOpened: + void this.executePostExternalComponentOpened(data as IExternalComponent); + return; + case AppInterface.IPostExternalComponentClosed: + void this.executePostExternalComponentClosed(data as IExternalComponent); + return; + case AppInterface.IUIKitInteractionHandler: + return this.executeUIKitInteraction(data as UIKitIncomingInteraction); + case AppInterface.IUIKitLivechatInteractionHandler: + return this.executeUIKitLivechatInteraction(data as IUIKitLivechatIncomingInteraction); + // Livechat + case AppInterface.IPostLivechatRoomStarted: + return this.executePostLivechatRoomStarted(data as ILivechatRoom); + /** + * @deprecated please prefer the AppInterface.IPostLivechatRoomClosed event + */ + case AppInterface.ILivechatRoomClosedHandler: + return this.executeLivechatRoomClosedHandler(data as ILivechatRoom); + case AppInterface.IPreLivechatRoomCreatePrevent: + return this.executePreLivechatRoomCreatePrevent(data as ILivechatRoom); + case AppInterface.IPostLivechatRoomClosed: + return this.executePostLivechatRoomClosed(data as ILivechatRoom); + case AppInterface.IPostLivechatRoomSaved: + return this.executePostLivechatRoomSaved(data as ILivechatRoom); + case AppInterface.IPostLivechatAgentAssigned: + return this.executePostLivechatAgentAssigned(data as ILivechatEventContext); + case AppInterface.IPostLivechatAgentUnassigned: + return this.executePostLivechatAgentUnassigned(data as ILivechatEventContext); + case AppInterface.IPostLivechatRoomTransferred: + return this.executePostLivechatRoomTransferred(data as ILivechatTransferEventContext); + case AppInterface.IPostLivechatDepartmentRemoved: + return this.executePostLivechatDepartmentRemoved(data as ILivechatDepartmentEventContext); + case AppInterface.IPostLivechatDepartmentDisabled: + return this.executePostLivechatDepartmentDisabled(data as ILivechatDepartmentEventContext); + case AppInterface.IPostLivechatGuestSaved: + return this.executePostLivechatGuestSaved(data as IVisitor); + // FileUpload + case AppInterface.IPreFileUpload: + return this.executePreFileUpload(data as IFileUploadInternalContext); + // Email + case AppInterface.IPreEmailSent: + return this.executePreEmailSent(data as IPreEmailSentContext); + // User + case AppInterface.IPostUserCreated: + return this.executePostUserCreated(data as IUserContext); + case AppInterface.IPostUserUpdated: + return this.executePostUserUpdated(data as IUserContext); + case AppInterface.IPostUserDeleted: + return this.executePostUserDeleted(data as IUserContext); + case AppInterface.IPostUserLoggedIn: + return this.executePostUserLoggedIn(data as IUser); + case AppInterface.IPostUserLoggedOut: + return this.executePostUserLoggedOut(data as IUser); + case AppInterface.IPostUserStatusChanged: + return this.executePostUserStatusChanged(data as IUserStatusContext); + default: + console.warn('An invalid listener was called'); + } + } + + // Messages + private async executePreMessageSentPrevent(data: IMessage): Promise { + let prevented = false; + + for (const appId of this.listeners.get(AppInterface.IPreMessageSentPrevent)) { + const app = this.manager.getOneById(appId); + + const continueOn = (await app.call(AppMethod.CHECKPREMESSAGESENTPREVENT, data).catch((error) => { + // This method is optional, so if it doesn't exist, we should continue + if (error?.code === JSONRPC_METHOD_NOT_FOUND) { + return true; + } + + throw error; + })) as boolean; + + if (!continueOn) { + continue; + } + + prevented = (await app.call(AppMethod.EXECUTEPREMESSAGESENTPREVENT, data)) as boolean; + + if (prevented) { + return prevented; + } + } + + return prevented; + } + + private async executePreMessageSentExtend(data: IMessage): Promise { + let msg = data; + + for (const appId of this.listeners.get(AppInterface.IPreMessageSentExtend)) { + const app = this.manager.getOneById(appId); + + const continueOn = (await app.call(AppMethod.CHECKPREMESSAGESENTEXTEND, data).catch((error) => { + // This method is optional, so if it doesn't exist, we should continue + if (error?.code === JSONRPC_METHOD_NOT_FOUND) { + return true; + } + + throw error; + })) as boolean; + + if (continueOn) { + msg = await app.call(AppMethod.EXECUTEPREMESSAGESENTEXTEND, msg); + } + } + + return msg; + } + + private async executePreMessageSentModify(data: IMessage): Promise { + let msg = data; + + for (const appId of this.listeners.get(AppInterface.IPreMessageSentModify)) { + const app = this.manager.getOneById(appId); + + const continueOn = (await app.call(AppMethod.CHECKPREMESSAGESENTMODIFY, msg).catch((error) => { + // This method is optional, so if it doesn't exist, we should continue + if (error?.code === JSONRPC_METHOD_NOT_FOUND) { + return true; + } + + throw error; + })) as boolean; + + if (continueOn) { + msg = (await app.call(AppMethod.EXECUTEPREMESSAGESENTMODIFY, msg)) as IMessage; + } + } + + return msg; + } + + private async executePostMessageSent(data: IMessage): Promise { + // First check if the app implements Bot DM handlers and check if the dm contains more than one user + if (data.room.type === RoomType.DIRECT_MESSAGE && data.room.userIds.length > 1) { + for (const appId of this.listeners.get(AppInterface.IPostMessageSentToBot)) { + const app = this.manager.getOneById(appId); + + const reader = this.am.getReader(appId); + const bot = await reader.getUserReader().getAppUser(); + if (!bot) { + continue; + } + + // if the sender is the bot just ignore it + + if (bot.id === data.sender.id) { + continue; + } + // if the user doesnt belong to the room ignore it + if (!data.room.userIds.includes(bot.id)) { + continue; + } + + await app.call(AppMethod.EXECUTEPOSTMESSAGESENTTOBOT, data); + } + } + + for (const appId of this.listeners.get(AppInterface.IPostMessageSent)) { + const app = this.manager.getOneById(appId); + + const continueOn = (await app.call(AppMethod.CHECKPOSTMESSAGESENT, data).catch((error) => { + // This method is optional, so if it doesn't exist, we should continue + if (error?.code === JSONRPC_METHOD_NOT_FOUND) { + return true; + } + + throw error; + })) as boolean; + + if (continueOn) { + await app.call(AppMethod.EXECUTEPOSTMESSAGESENT, data); + } + } + } + + private async executePostSystemMessageSent(data: IMessage): Promise { + for (const appId of this.listeners.get(AppInterface.IPostSystemMessageSent)) { + const app = this.manager.getOneById(appId); + await app.call(AppMethod.EXECUTEPOSTSYSTEMMESSAGESENT, data); + } + } + + private async executePreMessageDeletePrevent(data: IMessage): Promise { + let prevented = false; + + for (const appId of this.listeners.get(AppInterface.IPreMessageDeletePrevent)) { + const app = this.manager.getOneById(appId); + + const continueOn = (await app.call(AppMethod.CHECKPREMESSAGEDELETEPREVENT, data).catch((error) => { + // This method is optional, so if it doesn't exist, we should continue + if (error?.code === JSONRPC_METHOD_NOT_FOUND) { + return true; + } + + throw error; + })) as boolean; + + if (continueOn) { + prevented = (await app.call(AppMethod.EXECUTEPREMESSAGEDELETEPREVENT, data)) as boolean; + + if (prevented) { + return prevented; + } + } + } + + return prevented; + } + + private async executePostMessageDelete(data: IMessageDeleteContext): Promise { + const context = Utilities.deepCloneAndFreeze(data); + const { message } = context; + + for (const appId of this.listeners.get(AppInterface.IPostMessageDeleted)) { + const app = this.manager.getOneById(appId); + + const continueOn = (await app + .call( + AppMethod.CHECKPOSTMESSAGEDELETED, + // `context` has more information about the event, but + // we had to keep this `message` here for compatibility + message, + context, + ) + .catch((error) => { + // This method is optional, so if it doesn't exist, we should continue + if (error?.code === JSONRPC_METHOD_NOT_FOUND) { + return true; + } + + throw error; + })) as boolean; + + if (continueOn) { + await app.call(AppMethod.EXECUTEPOSTMESSAGEDELETED, message, context); + } + } + } + + private async executePreMessageUpdatedPrevent(data: IMessage): Promise { + let prevented = false; + + for (const appId of this.listeners.get(AppInterface.IPreMessageUpdatedPrevent)) { + const app = this.manager.getOneById(appId); + + const continueOn = (await app.call(AppMethod.CHECKPREMESSAGEUPDATEDPREVENT, data).catch((error) => { + // This method is optional, so if it doesn't exist, we should continue + if (error?.code === JSONRPC_METHOD_NOT_FOUND) { + return true; + } + + throw error; + })) as boolean; + + if (continueOn) { + prevented = (await app.call(AppMethod.EXECUTEPREMESSAGEUPDATEDPREVENT, data)) as boolean; + + if (prevented) { + return prevented; + } + } + } + + return prevented; + } + + private async executePreMessageUpdatedExtend(data: IMessage): Promise { + let msg = data; + + for (const appId of this.listeners.get(AppInterface.IPreMessageUpdatedExtend)) { + const app = this.manager.getOneById(appId); + + const continueOn = (await app.call(AppMethod.CHECKPREMESSAGEUPDATEDEXTEND, msg).catch((error) => { + // This method is optional, so if it doesn't exist, we should continue + if (error?.code === JSONRPC_METHOD_NOT_FOUND) { + return true; + } + + throw error; + })) as boolean; + + if (continueOn) { + msg = await app.call(AppMethod.EXECUTEPREMESSAGEUPDATEDEXTEND, msg); + } + } + + return msg; + } + + private async executePreMessageUpdatedModify(data: IMessage): Promise { + let msg = data; + + for (const appId of this.listeners.get(AppInterface.IPreMessageUpdatedModify)) { + const app = this.manager.getOneById(appId); + + const continueOn = (await app.call(AppMethod.CHECKPREMESSAGEUPDATEDMODIFY, msg).catch((error) => { + // This method is optional, so if it doesn't exist, we should continue + if (error?.code === JSONRPC_METHOD_NOT_FOUND) { + return true; + } + + throw error; + })) as boolean; + + if (continueOn) { + msg = (await app.call(AppMethod.EXECUTEPREMESSAGEUPDATEDMODIFY, msg)) as IMessage; + } + } + + return msg; + } + + private async executePostMessageUpdated(data: IMessage): Promise { + for (const appId of this.listeners.get(AppInterface.IPostMessageUpdated)) { + const app = this.manager.getOneById(appId); + + const continueOn = (await app.call(AppMethod.CHECKPOSTMESSAGEUPDATED, data).catch((error) => { + // This method is optional, so if it doesn't exist, we should continue + if (error?.code === JSONRPC_METHOD_NOT_FOUND) { + return true; + } + + throw error; + })) as boolean; + + if (continueOn) { + await app.call(AppMethod.EXECUTEPOSTMESSAGEUPDATED, data); + } + } + } + + // Rooms + private async executePreRoomCreatePrevent(data: IRoom): Promise { + let prevented = false; + + for (const appId of this.listeners.get(AppInterface.IPreRoomCreatePrevent)) { + const app = this.manager.getOneById(appId); + + const continueOn = (await app.call(AppMethod.CHECKPREROOMCREATEPREVENT, data).catch((error) => { + // This method is optional, so if it doesn't exist, we should continue + if (error?.code === JSONRPC_METHOD_NOT_FOUND) { + return true; + } + + throw error; + })) as boolean; + + if (continueOn) { + prevented = (await app.call(AppMethod.EXECUTEPREROOMCREATEPREVENT, data)) as boolean; + + if (prevented) { + return prevented; + } + } + } + + return prevented; + } + + private async executePreRoomCreateExtend(data: IRoom): Promise { + let room = data; + + for (const appId of this.listeners.get(AppInterface.IPreRoomCreateExtend)) { + const app = this.manager.getOneById(appId); + + const continueOn = (await app.call(AppMethod.CHECKPREROOMCREATEEXTEND, room).catch((error) => { + // This method is optional, so if it doesn't exist, we should continue + if (error?.code === JSONRPC_METHOD_NOT_FOUND) { + return true; + } + + throw error; + })) as boolean; + + if (continueOn) { + room = await app.call(AppMethod.EXECUTEPREROOMCREATEEXTEND, room); + } + } + + return room; + } + + private async executePreRoomCreateModify(data: IRoom): Promise { + let room = data; + + for (const appId of this.listeners.get(AppInterface.IPreRoomCreateModify)) { + const app = this.manager.getOneById(appId); + + const continueOn = (await app.call(AppMethod.CHECKPREROOMCREATEMODIFY, room).catch((error) => { + // This method is optional, so if it doesn't exist, we should continue + if (error?.code === JSONRPC_METHOD_NOT_FOUND) { + return true; + } + + throw error; + })) as boolean; + + if (continueOn) { + room = (await app.call(AppMethod.EXECUTEPREROOMCREATEMODIFY, room)) as IRoom; + } + } + + return room; + } + + private async executePostRoomCreate(data: IRoom): Promise { + for (const appId of this.listeners.get(AppInterface.IPostRoomCreate)) { + const app = this.manager.getOneById(appId); + + const continueOn = (await app.call(AppMethod.CHECKPOSTROOMCREATE, data).catch((error) => { + // This method is optional, so if it doesn't exist, we should continue + if (error?.code === JSONRPC_METHOD_NOT_FOUND) { + return true; + } + + throw error; + })) as boolean; + + if (continueOn) { + await app.call(AppMethod.EXECUTEPOSTROOMCREATE, data); + } + } + } + + private async executePreRoomDeletePrevent(data: IRoom): Promise { + let prevented = false; + + for (const appId of this.listeners.get(AppInterface.IPreRoomDeletePrevent)) { + const app = this.manager.getOneById(appId); + + const continueOn = (await app.call(AppMethod.CHECKPREROOMDELETEPREVENT, data).catch((error) => { + // This method is optional, so if it doesn't exist, we should continue + if (error?.code === JSONRPC_METHOD_NOT_FOUND) { + return true; + } + + throw error; + })) as boolean; + + if (continueOn) { + prevented = (await app.call(AppMethod.EXECUTEPREROOMDELETEPREVENT, data)) as boolean; + + if (prevented) { + return prevented; + } + } + } + + return prevented; + } + + private async executePostRoomDeleted(data: IRoom): Promise { + for (const appId of this.listeners.get(AppInterface.IPostRoomDeleted)) { + const app = this.manager.getOneById(appId); + + const continueOn = (await app.call(AppMethod.CHECKPOSTROOMDELETED, data).catch((error) => { + // This method is optional, so if it doesn't exist, we should continue + if (error?.code === JSONRPC_METHOD_NOT_FOUND) { + return true; + } + + throw error; + })) as boolean; + + if (continueOn) { + await app.call(AppMethod.EXECUTEPOSTROOMDELETED, data); + } + } + } + + private async executePreRoomUserJoined(externalData: IRoomUserJoinedContext): Promise { + for (const appId of this.listeners.get(AppInterface.IPreRoomUserJoined)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTE_PRE_ROOM_USER_JOINED, externalData); + } + } + + private async executePostRoomUserJoined(externalData: IRoomUserJoinedContext): Promise { + for (const appId of this.listeners.get(AppInterface.IPostRoomUserJoined)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTE_POST_ROOM_USER_JOINED, externalData); + } + } + + private async executePreRoomUserLeave(externalData: IRoomUserLeaveContext): Promise { + for (const appId of this.listeners.get(AppInterface.IPreRoomUserLeave)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTE_PRE_ROOM_USER_LEAVE, externalData); + } + } + + private async executePostRoomUserLeave(externalData: IRoomUserLeaveContext): Promise { + for (const appId of this.listeners.get(AppInterface.IPostRoomUserLeave)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTE_POST_ROOM_USER_LEAVE, externalData); + } + } + + // External Components + private async executePostExternalComponentOpened(data: IExternalComponent): Promise { + for (const appId of this.listeners.get(AppInterface.IPostExternalComponentOpened)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTEPOSTEXTERNALCOMPONENTOPENED, data); + } + } + + private async executePostExternalComponentClosed(data: IExternalComponent): Promise { + for (const appId of this.listeners.get(AppInterface.IPostExternalComponentClosed)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTEPOSTEXTERNALCOMPONENTCLOSED, data); + } + } + + private async executeUIKitInteraction(data: UIKitIncomingInteraction): Promise { + const { appId } = data; + + const app = this.manager.getOneById(appId); + const handleError = (method: string) => (error: unknown) => { + if ((error as Record)?.code === JSONRPC_METHOD_NOT_FOUND) { + if (this.defaultHandlers.has(method)) { + console.warn( + `App ${appId} triggered an interaction but it doesn't exist or doesn't have method ${method}. Falling back to default handler.`, + ); + return this.defaultHandlers.get(method); + } + + console.warn( + `App ${appId} triggered an interaction but it doesn't exist or doesn't have method ${method} and there is no default handler for it.`, + ); + return; + } + + throw error; + }; + + const { actionId, user, triggerId } = data; + + switch (data.type) { + case UIKitIncomingInteractionType.BLOCK: { + const method = 'executeBlockActionHandler'; + const { value, blockId } = data.payload as { value: string; blockId: string }; + + return app + .call(method, { + appId, + actionId, + blockId, + user, + room: data.room, + triggerId, + value, + message: data.message, + container: data.container, + }) + .catch(handleError(method)); + } + case UIKitIncomingInteractionType.VIEW_SUBMIT: { + const method = 'executeViewSubmitHandler'; + const { view } = data.payload as { view: IUIKitSurface }; + + return app + .call(method, { + appId, + actionId, + view, + room: data.room, + triggerId, + user, + }) + .catch(handleError(method)); + } + case UIKitIncomingInteractionType.VIEW_CLOSED: { + const method = 'executeViewClosedHandler'; + const { view, isCleared } = data.payload as { view: IUIKitSurface; isCleared: boolean }; + + return app + .call(method, { + appId, + actionId, + view, + room: data.room, + isCleared, + user, + }) + .catch(handleError(method)); + } + case 'actionButton': { + const method = 'executeActionButtonHandler'; + + if (isUIKitIncomingInteractionActionButtonMessageBox(data)) { + return app + .call(method, { + appId, + actionId, + buttonContext: UIActionButtonContext.MESSAGE_BOX_ACTION, + room: data.room, + triggerId, + user, + threadId: data.tmid, + ...('message' in data.payload && { text: data.payload.message }), + }) + .catch(handleError(method)); + } + + return app + .call(method, { + appId, + actionId, + triggerId, + buttonContext: data.payload.context as UIActionButtonContext, + room: ('room' in data && data.room) || undefined, + user, + ...('message' in data && { message: data.message }), + }) + .catch(handleError(method)); + } + } + } + + private async executeUIKitLivechatInteraction(data: IUIKitLivechatIncomingInteraction): Promise { + const { appId, type } = data; + + const method = ((interactionType: string) => { + switch (interactionType) { + case UIKitIncomingInteractionType.BLOCK: + return AppMethod.UIKIT_LIVECHAT_BLOCK_ACTION; + } + })(type); + + const app = this.manager.getOneById(appId); + + const interactionData = (( + interactionType: UIKitIncomingInteractionType, + interaction: IUIKitLivechatIncomingInteraction, + ): IUIKitLivechatBlockIncomingInteraction => { + const { actionId, message, visitor, room, triggerId, container } = interaction; + + switch (interactionType) { + case UIKitIncomingInteractionType.BLOCK: { + const { value, blockId } = interaction.payload as { value: string; blockId: string }; + + return { + appId, + actionId, + blockId, + visitor, + room, + triggerId, + value, + message, + container, + }; + } + } + })(type, data); + + return app.call(method, interactionData); + } + + // Livechat + private async executePreLivechatRoomCreatePrevent(data: ILivechatRoom): Promise { + for (const appId of this.listeners.get(AppInterface.IPreLivechatRoomCreatePrevent)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTE_PRE_LIVECHAT_ROOM_CREATE_PREVENT, data); + } + } + + private async executePostLivechatRoomStarted(data: ILivechatRoom): Promise { + for (const appId of this.listeners.get(AppInterface.IPostLivechatRoomStarted)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTE_POST_LIVECHAT_ROOM_STARTED, data); + } + } + + private async executeLivechatRoomClosedHandler(data: ILivechatRoom): Promise { + for (const appId of this.listeners.get(AppInterface.ILivechatRoomClosedHandler)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTE_LIVECHAT_ROOM_CLOSED_HANDLER, data); + } + } + + private async executePostLivechatRoomClosed(data: ILivechatRoom): Promise { + for (const appId of this.listeners.get(AppInterface.IPostLivechatRoomClosed)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTE_POST_LIVECHAT_ROOM_CLOSED, data); + } + } + + private async executePostLivechatAgentAssigned(data: ILivechatEventContext): Promise { + for (const appId of this.listeners.get(AppInterface.IPostLivechatAgentAssigned)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTE_POST_LIVECHAT_AGENT_ASSIGNED, data); + } + } + + private async executePostLivechatAgentUnassigned(data: ILivechatEventContext): Promise { + for (const appId of this.listeners.get(AppInterface.IPostLivechatAgentUnassigned)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTE_POST_LIVECHAT_AGENT_UNASSIGNED, data); + } + } + + private async executePostLivechatRoomTransferred(data: ILivechatTransferEventContext): Promise { + for (const appId of this.listeners.get(AppInterface.IPostLivechatRoomTransferred)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTE_POST_LIVECHAT_ROOM_TRANSFERRED, data); + } + } + + private async executePostLivechatGuestSaved(data: IVisitor): Promise { + for (const appId of this.listeners.get(AppInterface.IPostLivechatGuestSaved)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTE_POST_LIVECHAT_GUEST_SAVED, data); + } + } + + private async executePostLivechatRoomSaved(data: ILivechatRoom): Promise { + for (const appId of this.listeners.get(AppInterface.IPostLivechatRoomSaved)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTE_POST_LIVECHAT_ROOM_SAVED, data); + } + } + + private async executePostLivechatDepartmentRemoved(data: ILivechatDepartmentEventContext): Promise { + for (const appId of this.listeners.get(AppInterface.IPostLivechatDepartmentRemoved)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTE_POST_LIVECHAT_DEPARTMENT_REMOVED, data); + } + } + + private async executePostLivechatDepartmentDisabled(data: ILivechatDepartmentEventContext): Promise { + for (const appId of this.listeners.get(AppInterface.IPostLivechatDepartmentDisabled)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTE_POST_LIVECHAT_DEPARTMENT_DISABLED, data); + } + } + + // FileUpload + private async executePreFileUpload(data: IFileUploadInternalContext): Promise { + for (const appId of this.listeners.get(AppInterface.IPreFileUpload)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTE_PRE_FILE_UPLOAD, data); + } + } + + private async executePreEmailSent(data: IPreEmailSentContext): Promise { + let descriptor = data.email; + + for (const appId of this.listeners.get(AppInterface.IPreEmailSent)) { + const app = this.manager.getOneById(appId); + + descriptor = await app.call(AppMethod.EXECUTE_PRE_EMAIL_SENT, { + context: data.context, + email: descriptor, + }); + } + + return descriptor; + } + + private async executePostMessageReacted(data: IMessageReactionContext): Promise { + for (const appId of this.listeners.get(AppInterface.IPostMessageReacted)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTE_POST_MESSAGE_REACTED, data); + } + } + + private async executePostMessageFollowed(data: IMessageFollowContext): Promise { + for (const appId of this.listeners.get(AppInterface.IPostMessageFollowed)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTE_POST_MESSAGE_FOLLOWED, data); + } + } + + private async executePostMessagePinned(data: IMessagePinContext): Promise { + for (const appId of this.listeners.get(AppInterface.IPostMessagePinned)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTE_POST_MESSAGE_PINNED, data); + } + } + + private async executePostMessageStarred(data: IMessageStarContext): Promise { + for (const appId of this.listeners.get(AppInterface.IPostMessageStarred)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTE_POST_MESSAGE_STARRED, data); + } + } + + private async executePostMessageReported(data: IMessageReportContext): Promise { + for (const appId of this.listeners.get(AppInterface.IPostMessageReported)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTE_POST_MESSAGE_REPORTED, data); + } + } + + private async executePostUserCreated(data: IUserContext): Promise { + for (const appId of this.listeners.get(AppInterface.IPostUserCreated)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTE_POST_USER_CREATED, data); + } + } + + private async executePostUserUpdated(data: IUserUpdateContext): Promise { + for (const appId of this.listeners.get(AppInterface.IPostUserUpdated)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTE_POST_USER_UPDATED, data); + } + } + + private async executePostUserDeleted(data: IUserContext): Promise { + for (const appId of this.listeners.get(AppInterface.IPostUserDeleted)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTE_POST_USER_DELETED, data); + } + } + + private async executePostUserLoggedIn(data: IUser): Promise { + for (const appId of this.listeners.get(AppInterface.IPostUserLoggedIn)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTE_POST_USER_LOGGED_IN, data); + } + } + + private async executePostUserLoggedOut(data: IUser): Promise { + for (const appId of this.listeners.get(AppInterface.IPostUserLoggedOut)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTE_POST_USER_LOGGED_OUT, data); + } + } + + private async executePostUserStatusChanged(data: IUserStatusContext): Promise { + for (const appId of this.listeners.get(AppInterface.IPostUserStatusChanged)) { + const app = this.manager.getOneById(appId); + + await app.call(AppMethod.EXECUTE_POST_USER_STATUS_CHANGED, data); + } + } +} diff --git a/packages/apps/src/server/managers/AppOutboundCommunicationProvider.ts b/packages/apps/src/server/managers/AppOutboundCommunicationProvider.ts new file mode 100644 index 0000000000000..ad93ea1e4136c --- /dev/null +++ b/packages/apps/src/server/managers/AppOutboundCommunicationProvider.ts @@ -0,0 +1,57 @@ +import { AppMethod } from '@rocket.chat/apps-engine/definition/metadata'; +import type { + IOutboundMessage, + IOutboundMessageProviders, + ProviderMetadata, +} from '@rocket.chat/apps-engine/definition/outboundCommunication'; + +import type { AppAccessorManager } from '.'; +import type { ProxiedApp } from '../ProxiedApp'; +import { AppOutboundProcessError } from '../errors/AppOutboundProcessError'; +import type { AppLogStorage } from '../storage'; + +export class OutboundMessageProvider { + public isRegistered: boolean; + + constructor( + public app: ProxiedApp, + public provider: IOutboundMessageProviders, + ) { + this.isRegistered = false; + } + + public async runGetProviderMetadata(logStorage: AppLogStorage, accessors: AppAccessorManager): Promise { + return this.runTheCode(AppMethod._OUTBOUND_GET_PROVIDER_METADATA, logStorage, accessors, []); + } + + public async runSendOutboundMessage(logStorage: AppLogStorage, accessors: AppAccessorManager, body: IOutboundMessage): Promise { + await this.runTheCode(AppMethod._OUTBOUND_SEND_MESSAGE, logStorage, accessors, [body]); + } + + private async runTheCode( + method: AppMethod._OUTBOUND_GET_PROVIDER_METADATA | AppMethod._OUTBOUND_SEND_MESSAGE, + _logStorage: AppLogStorage, + _accessors: AppAccessorManager, + runContextArgs: Array, + ): Promise { + const provider = `${this.provider.name}-${this.provider.type}`; + + try { + const result = await this.app.getRuntimeController().sendRequest({ + method: `outboundCommunication:${provider}:${method}`, + params: runContextArgs, + }); + + return result as T; + } catch (e) { + if (e?.message === 'error-invalid-provider') { + throw new Error('error-provider-not-registered'); + } + throw new AppOutboundProcessError(e.message, method); + } + } + + public setRegistered(registered: boolean): void { + this.isRegistered = registered; + } +} diff --git a/packages/apps/src/server/managers/AppOutboundCommunicationProviderManager.ts b/packages/apps/src/server/managers/AppOutboundCommunicationProviderManager.ts new file mode 100644 index 0000000000000..a4767c2ab665b --- /dev/null +++ b/packages/apps/src/server/managers/AppOutboundCommunicationProviderManager.ts @@ -0,0 +1,139 @@ +import type { + IOutboundMessageProviders, + IOutboundEmailMessageProvider, + IOutboundPhoneMessageProvider, + ValidOutboundProvider, + IOutboundMessage, +} from '@rocket.chat/apps-engine/definition/outboundCommunication'; + +import type { AppAccessorManager } from '.'; +import type { AppManager } from '../AppManager'; +import type { OutboundMessageBridge } from '../bridges'; +import { OutboundMessageProvider } from './AppOutboundCommunicationProvider'; +import { AppPermissionManager } from './AppPermissionManager'; +import { PermissionDeniedError } from '../errors/PermissionDeniedError'; +import { AppPermissions } from '../permissions/AppPermissions'; + +export class AppOutboundCommunicationProviderManager { + private readonly accessors: AppAccessorManager; + + private readonly bridge: OutboundMessageBridge; + + private outboundMessageProviders: Map>; + + constructor(private readonly manager: AppManager) { + this.bridge = this.manager.getBridges().getOutboundMessageBridge(); + this.accessors = this.manager.getAccessorManager(); + + this.outboundMessageProviders = new Map>(); + } + + public isAlreadyDefined(providerId: string, providerType: ValidOutboundProvider): boolean { + const providersByApp = this.outboundMessageProviders.get(providerId); + if (!providersByApp) { + return false; + } + if (!providersByApp.get(providerType)) { + return false; + } + return true; + } + + public addProvider(appId: string, provider: IOutboundMessageProviders): void { + const app = this.manager.getOneById(appId); + if (!app) { + throw new Error('App must exist in order for an outbound provider to be added.'); + } + + if (!AppPermissionManager.hasPermission(appId, AppPermissions.outboundComms.provide)) { + throw new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.outboundComms.provide], + }); + } + + if (!this.outboundMessageProviders.has(appId)) { + this.outboundMessageProviders.set(appId, new Map()); + } + + this.outboundMessageProviders.get(appId).set(provider.type, new OutboundMessageProvider(app, provider)); + } + + public async registerProviders(appId: string): Promise { + if (!this.outboundMessageProviders.has(appId)) { + return; + } + + const appProviders = this.outboundMessageProviders.get(appId); + if (!appProviders) { + return; + } + + for await (const [, providerInfo] of appProviders) { + if (providerInfo.isRegistered) { + continue; + } + + if (providerInfo.provider.type === 'phone') { + await this.registerPhoneProvider(appId, providerInfo.provider); + providerInfo.setRegistered(true); + } else if (providerInfo.provider.type === 'email') { + await this.registerEmailProvider(appId, providerInfo.provider); + providerInfo.setRegistered(true); + } + } + } + + public async unregisterProviders(appId: string, opts?: { keepReferences: boolean }): Promise { + if (!this.outboundMessageProviders.has(appId)) { + return; + } + + const appProviders = this.outboundMessageProviders.get(appId); + for await (const [, providerInfo] of appProviders) { + await this.unregisterProvider(appId, providerInfo, opts); + } + + if (!opts?.keepReferences) { + this.outboundMessageProviders.delete(appId); + } + } + + private async registerPhoneProvider(appId: string, provider: IOutboundPhoneMessageProvider): Promise { + await this.bridge.doRegisterPhoneProvider(provider, appId); + } + + private async registerEmailProvider(appId: string, provider: IOutboundEmailMessageProvider): Promise { + await this.bridge.doRegisterEmailProvider(provider, appId); + } + + private async unregisterProvider(appId: string, info: OutboundMessageProvider, opts?: { keepReferences: boolean }): Promise { + const key = info.provider.type; + + await this.bridge.doUnRegisterProvider(info.provider, appId); + + info.setRegistered(false); + + if (!opts?.keepReferences) { + this.outboundMessageProviders.get(appId)?.delete(key); + } + } + + public getProviderMetadata(appId: string, providerType: ValidOutboundProvider) { + const providerInfo = this.outboundMessageProviders.get(appId)?.get(providerType); + if (!providerInfo) { + throw new Error('provider-not-registered'); + } + + return providerInfo.runGetProviderMetadata(this.manager.getLogStorage(), this.accessors); + } + + public sendOutboundMessage(appId: string, providerType: ValidOutboundProvider, body: IOutboundMessage) { + const providerInfo = this.outboundMessageProviders.get(appId)?.get(providerType); + if (!providerInfo) { + throw new Error('provider-not-registered'); + } + + return providerInfo.runSendOutboundMessage(this.manager.getLogStorage(), this.accessors, body); + } +} diff --git a/packages/apps/src/server/managers/AppPermissionManager.ts b/packages/apps/src/server/managers/AppPermissionManager.ts new file mode 100644 index 0000000000000..9d7adbe138749 --- /dev/null +++ b/packages/apps/src/server/managers/AppPermissionManager.ts @@ -0,0 +1,41 @@ +import type { IPermission } from '@rocket.chat/apps-engine/definition/permissions/IPermission'; + +import { getPermissionsByAppId } from '../AppManager'; +import { PermissionDeniedError } from '../errors/PermissionDeniedError'; +import { APPS_ENGINE_RUNTIME_FILE_PREFIX } from '../runtime/AppsEngineRuntime'; + +export class AppPermissionManager { + /** + * It returns the declaration of the permission if the app declared, or it returns `undefined`. + */ + public static hasPermission

(appId: string, permission: P): P | undefined { + if (process.env.NODE_ENV === 'test') { + return permission; + } + + const grantedPermission = getPermissionsByAppId(appId).find(({ name }) => name === permission.name) as unknown; + + if (!grantedPermission) { + return undefined; + } + + return grantedPermission as P; + } + + public static notifyAboutError(err: Error): void { + if (err instanceof PermissionDeniedError) { + const { name, message } = err; + + console.error(`${name}: ${message}\n${this.getCallStack()}`); + } else { + console.error(err); + } + } + + private static getCallStack(): string { + const stack = new Error().stack.toString().split('\n'); + const appStackIndex = stack.findIndex((position) => position.includes(APPS_ENGINE_RUNTIME_FILE_PREFIX)); + + return stack.slice(4, appStackIndex).join('\n'); + } +} diff --git a/packages/apps/src/server/managers/AppRuntimeManager.ts b/packages/apps/src/server/managers/AppRuntimeManager.ts new file mode 100644 index 0000000000000..dd631bc8eebd0 --- /dev/null +++ b/packages/apps/src/server/managers/AppRuntimeManager.ts @@ -0,0 +1,76 @@ +import type { AppManager } from '../AppManager'; +import type { IParseAppPackageResult } from '../compiler'; +import type { IRuntimeController } from '../runtime/IRuntimeController'; +import { DenoRuntimeSubprocessController } from '../runtime/deno/AppsEngineDenoRuntime'; +import type { IAppStorageItem } from '../storage'; + +export type AppRuntimeParams = { + appId: string; + appSource: string; +}; + +export type ExecRequestContext = { + method: string; + params: unknown[]; +}; + +export type ExecRequestOptions = { + timeout?: number; +}; + +const defaultRuntimeFactory = (manager: AppManager, appPackage: IParseAppPackageResult, storageItem: IAppStorageItem) => + new DenoRuntimeSubprocessController(manager, appPackage, storageItem); + +export class AppRuntimeManager { + private readonly subprocesses: Record = {}; + + constructor( + private readonly manager: AppManager, + private readonly runtimeFactory = defaultRuntimeFactory, + ) {} + + public async startRuntimeForApp( + appPackage: IParseAppPackageResult, + storageItem: IAppStorageItem, + options = { force: false }, + ): Promise { + const { id: appId } = appPackage.info; + + if (appId in this.subprocesses && !options.force) { + throw new Error('App already has an associated runtime'); + } + + this.subprocesses[appId] = this.runtimeFactory(this.manager, appPackage, storageItem); + + try { + await this.subprocesses[appId].setupApp(); + } catch (error) { + const subprocess = this.subprocesses[appId]; + delete this.subprocesses[appId]; + await subprocess.stopApp(); + throw error; + } + + return this.subprocesses[appId]; + } + + public async runInSandbox(appId: string, execRequest: ExecRequestContext, options?: ExecRequestOptions): Promise { + const subprocess = this.subprocesses[appId]; + + if (!subprocess) { + throw new Error('App does not have an associated runtime'); + } + + return subprocess.sendRequest(execRequest); + } + + public async stopRuntime(controller: IRuntimeController): Promise { + await controller.stopApp(); + + const appId = controller.getAppId(); + + if (appId in this.subprocesses) { + delete this.subprocesses[appId]; + } + } +} diff --git a/packages/apps/src/server/managers/AppSchedulerManager.ts b/packages/apps/src/server/managers/AppSchedulerManager.ts new file mode 100644 index 0000000000000..2af591964d6b4 --- /dev/null +++ b/packages/apps/src/server/managers/AppSchedulerManager.ts @@ -0,0 +1,99 @@ +import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; +import type { IJobContext, IOnetimeSchedule, IProcessor, IRecurringSchedule } from '@rocket.chat/apps-engine/definition/scheduler'; + +import type { AppManager } from '../AppManager'; +import type { IInternalSchedulerBridge } from '../bridges/IInternalSchedulerBridge'; +import type { SchedulerBridge } from '../bridges/SchedulerBridge'; + +function createProcessorId(jobId: string, appId: string): string { + return jobId.includes(`_${appId}`) ? jobId : `${jobId}_${appId}`; +} + +export class AppSchedulerManager { + private readonly bridge: SchedulerBridge; + + private registeredProcessors: Map; + + constructor(private readonly manager: AppManager) { + this.bridge = this.manager.getBridges().getSchedulerBridge(); + this.registeredProcessors = new Map(); + } + + public async registerProcessors(processors: Array = [], appId: string): Promise> { + if (!this.registeredProcessors.get(appId)) { + this.registeredProcessors.set(appId, {}); + } + + return this.bridge.doRegisterProcessors( + processors.map((processor) => { + const processorId = createProcessorId(processor.id, appId); + + this.registeredProcessors.get(appId)[processorId] = processor; + + return { + id: processorId, + processor: this.wrapProcessor(appId, processorId).bind(this), + startupSetting: processor.startupSetting, + }; + }), + appId, + ); + } + + public wrapProcessor(appId: string, processorId: string): IProcessor['processor'] { + return async (jobContext: IJobContext) => { + const processor = this.registeredProcessors.get(appId)[processorId]; + + if (!processor) { + throw new Error(`Processor ${processorId} not available`); + } + + const app = this.manager.getOneById(appId); + const status = await app.getStatus(); + const previousStatus = app.getPreviousStatus(); + + const isNotToRunJob = this.isNotToRunJob(status, previousStatus); + + if (isNotToRunJob) { + return; + } + + try { + await app.getRuntimeController().sendRequest({ + method: `scheduler:${processor.id}`, + params: [jobContext], + }); + } catch (e) { + console.error(e); + throw e; + } + }; + } + + public async scheduleOnce(job: IOnetimeSchedule, appId: string): Promise { + return this.bridge.doScheduleOnce({ ...job, id: createProcessorId(job.id, appId) }, appId); + } + + public async scheduleRecurring(job: IRecurringSchedule, appId: string): Promise { + return this.bridge.doScheduleRecurring({ ...job, id: createProcessorId(job.id, appId) }, appId); + } + + public async cancelJob(jobId: string, appId: string): Promise { + return this.bridge.doCancelJob(createProcessorId(jobId, appId), appId); + } + + public async cancelAllJobs(appId: string): Promise { + return this.bridge.doCancelAllJobs(appId); + } + + public async cleanUp(appId: string): Promise { + await (this.bridge as IInternalSchedulerBridge & SchedulerBridge).cancelAllJobs(appId); + } + + private isNotToRunJob(status: AppStatus, previousStatus: AppStatus): boolean { + const isAppCurrentDisabled = status === AppStatus.DISABLED || status === AppStatus.MANUALLY_DISABLED; + const wasAppDisabled = previousStatus === AppStatus.DISABLED || previousStatus === AppStatus.MANUALLY_DISABLED; + + return (status === AppStatus.INITIALIZED && wasAppDisabled) || isAppCurrentDisabled; + } +} diff --git a/packages/apps/src/server/managers/AppSettingsManager.ts b/packages/apps/src/server/managers/AppSettingsManager.ts new file mode 100644 index 0000000000000..4247672f8338b --- /dev/null +++ b/packages/apps/src/server/managers/AppSettingsManager.ts @@ -0,0 +1,57 @@ +import { AppMethod } from '@rocket.chat/apps-engine/definition/metadata'; +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; +import type { ISettingUpdateContext } from '@rocket.chat/apps-engine/definition/settings/ISettingUpdateContext'; + +import type { AppManager } from '../AppManager'; +import { Utilities } from '../misc/Utilities'; + +export class AppSettingsManager { + constructor(private manager: AppManager) {} + + public getAppSettings(appId: string): { [key: string]: ISetting } { + const rl = this.manager.getOneById(appId); + + if (!rl) { + throw new Error('No App found by the provided id.'); + } + + return Utilities.deepCloneAndFreeze(rl.getStorageItem().settings); + } + + public getAppSetting(appId: string, settingId: string): ISetting { + const settings = this.getAppSettings(appId); + + if (!settings[settingId]) { + throw new Error('No setting found for the App by the provided id.'); + } + + return Utilities.deepCloneAndFreeze(settings[settingId]); + } + + public async updateAppSetting(appId: string, setting: ISetting): Promise { + const rl = this.manager.getOneById(appId); + + if (!rl) { + throw new Error('No App found by the provided id.'); + } + + const storageItem = rl.getStorageItem(); + + const oldSetting = storageItem.settings[setting.id]; + if (!oldSetting) { + throw new Error('No setting found for the App by the provided id.'); + } + + const decoratedSetting = + (await rl.call(AppMethod.ON_PRE_SETTING_UPDATE, { oldSetting, newSetting: setting } as ISettingUpdateContext)) || setting; + + decoratedSetting.updatedAt = new Date(); + storageItem.settings[decoratedSetting.id] = decoratedSetting; + + await this.manager.getStorage().updateSetting(storageItem._id, decoratedSetting); + + this.manager.getBridges().getAppDetailChangesBridge().doOnAppSettingsChange(appId, decoratedSetting); + + await rl.call(AppMethod.ONSETTINGUPDATED, decoratedSetting); + } +} diff --git a/packages/apps/src/server/managers/AppSignatureManager.ts b/packages/apps/src/server/managers/AppSignatureManager.ts new file mode 100644 index 0000000000000..1b24cd9d7d4da --- /dev/null +++ b/packages/apps/src/server/managers/AppSignatureManager.ts @@ -0,0 +1,85 @@ +import { createHash } from 'crypto'; + +import * as jose from 'jose'; + +import type { AppManager } from '../AppManager'; +import type { IInternalFederationBridge } from '../bridges'; +import type { IAppStorageItem } from '../storage'; + +export class AppSignatureManager { + private readonly federationBridge: IInternalFederationBridge; + + private readonly checksumAlgorithm = 'SHA256'; + + private readonly signingAlgorithm = 'RS512'; + + private privateKey: string; + + private publicKey: string; + + constructor(private readonly manager: AppManager) { + this.federationBridge = this.manager.getBridges().getInternalFederationBridge(); + } + + public async verifySignedApp(app: IAppStorageItem): Promise { + const publicKey = await jose.importSPKI(await this.getPublicKey(), 'pem'); + const { payload } = await jose.jwtVerify(app.signature, publicKey); + + const checksum = this.calculateChecksumForApp(app); + + if (payload.checksum !== checksum) { + throw new Error('Invalid checksum'); + } + } + + public async signApp(app: IAppStorageItem): Promise { + const checksum = this.calculateChecksumForApp(app); + const privateKey = await jose.importPKCS8(await this.getPrivateKey(), this.signingAlgorithm); + const signature = await new jose.SignJWT({ checksum, calg: this.checksumAlgorithm }) + .setProtectedHeader({ alg: this.signingAlgorithm }) + .setIssuedAt() + .sign(privateKey); + + return signature; + } + + private async getPrivateKey(): Promise { + if (!this.privateKey) { + this.privateKey = await this.federationBridge.getPrivateKey(); + } + return this.privateKey; + } + + private async getPublicKey(): Promise { + if (!this.publicKey) { + this.publicKey = await this.federationBridge.getPublicKey(); + } + return this.publicKey; + } + + private calculateChecksumForApp(app: IAppStorageItem, alg = this.checksumAlgorithm): string { + return createHash(alg).update(this.getFieldsForChecksum(app)).digest('hex'); + } + + private getFieldsForChecksum(obj: IAppStorageItem): string { + // These fields don't hold valuable information and should NOT invalidate + // the checksum + const fieldsToIgnore = ['_id', 'status', 'signature', 'updatedAt', 'createdAt', '_updatedAt', '_createdAt', 'settings']; + + // TODO revisit algorithm + const allKeys: Array = []; + const seen: Record = {}; + + JSON.stringify(obj, (key, value) => { + if (!(key in seen)) { + allKeys.push(key); + seen[key] = null; + } + return value; + }); + + const filteredKeys = allKeys.sort().filter((key) => !fieldsToIgnore.includes(key)); + + return JSON.stringify(obj, filteredKeys); + } +} diff --git a/packages/apps/src/server/managers/AppSlashCommand.ts b/packages/apps/src/server/managers/AppSlashCommand.ts new file mode 100644 index 0000000000000..a69f81112a970 --- /dev/null +++ b/packages/apps/src/server/managers/AppSlashCommand.ts @@ -0,0 +1,86 @@ +import { AppMethod } from '@rocket.chat/apps-engine/definition/metadata'; +import type { + ISlashCommand, + ISlashCommandPreview, + ISlashCommandPreviewItem, + SlashCommandContext, +} from '@rocket.chat/apps-engine/definition/slashcommands'; + +import type { ProxiedApp } from '../ProxiedApp'; +import type { AppLogStorage } from '../storage'; +import type { AppAccessorManager } from './AppAccessorManager'; + +export class AppSlashCommand { + /** + * States whether this command has been registered into the Rocket.Chat system or not. + */ + public isRegistered: boolean; + + /** + * Declares whether this command has been enabled or not, + * does not have to be inside of the Rocket.Chat system if `isRegistered` is false. + */ + public isEnabled: boolean; + + /** + * Proclaims whether this command has been disabled or not, + * does not have to be inside the Rocket.Chat system if `isRegistered` is false. + */ + public isDisabled: boolean; + + constructor( + public app: ProxiedApp, + public slashCommand: ISlashCommand, + ) { + this.isRegistered = false; + this.isEnabled = false; + this.isDisabled = false; + } + + public hasBeenRegistered(): void { + this.isDisabled = false; + this.isEnabled = true; + this.isRegistered = true; + } + + public async runExecutorOrPreviewer( + method: AppMethod._COMMAND_EXECUTOR | AppMethod._COMMAND_PREVIEWER, + context: SlashCommandContext, + logStorage: AppLogStorage, + accessors: AppAccessorManager, + ): Promise { + return this.runTheCode(method, logStorage, accessors, context, []); + } + + public async runPreviewExecutor( + previewItem: ISlashCommandPreviewItem, + context: SlashCommandContext, + logStorage: AppLogStorage, + accessors: AppAccessorManager, + ): Promise { + await this.runTheCode(AppMethod._COMMAND_PREVIEW_EXECUTOR, logStorage, accessors, context, [previewItem]); + } + + private async runTheCode( + method: AppMethod._COMMAND_EXECUTOR | AppMethod._COMMAND_PREVIEWER | AppMethod._COMMAND_PREVIEW_EXECUTOR, + _logStorage: AppLogStorage, + _accessors: AppAccessorManager, + context: SlashCommandContext, + runContextArgs: Array, + ): Promise { + const { command } = this.slashCommand; + + try { + const result = await this.app.getRuntimeController().sendRequest({ + method: `slashcommand:${command}:${method}`, + params: [...runContextArgs, context], + }); + + return result as void | ISlashCommandPreview; + } catch (e) { + // @TODO this needs to be revisited + console.error(e); + throw e; + } + } +} diff --git a/packages/apps/src/server/managers/AppSlashCommandManager.ts b/packages/apps/src/server/managers/AppSlashCommandManager.ts new file mode 100644 index 0000000000000..41cac1ecdd966 --- /dev/null +++ b/packages/apps/src/server/managers/AppSlashCommandManager.ts @@ -0,0 +1,478 @@ +import { AppStatusUtils } from '@rocket.chat/apps-engine/definition/AppStatus'; +import { AppMethod } from '@rocket.chat/apps-engine/definition/metadata'; +import type { ISlashCommand, ISlashCommandPreview, ISlashCommandPreviewItem } from '@rocket.chat/apps-engine/definition/slashcommands'; +import { SlashCommandContext } from '@rocket.chat/apps-engine/definition/slashcommands'; + +import type { AppManager } from '../AppManager'; +import type { CommandBridge } from '../bridges'; +import { CommandAlreadyExistsError, CommandHasAlreadyBeenTouchedError } from '../errors'; +import type { AppAccessorManager } from './AppAccessorManager'; +import { AppSlashCommand } from './AppSlashCommand'; +import { Room } from '../rooms/Room'; + +/** + * The command manager for the Apps. + * + * An App will add commands during their `initialize` method. + * Then once an App's `onEnable` is called and it returns true, + * only then will that App's commands be enabled. + * + * Registered means the command has been provided to the bridged system. + */ +export class AppSlashCommandManager { + private readonly bridge: CommandBridge; + + private readonly accessors: AppAccessorManager; + + /** + * Variable that contains the commands which have been provided by apps. + * The key of the top map is app id and the key of the inner map is the command + */ + private providedCommands: Map>; + + /** + * Contains the commands which have modified the system commands + */ + private modifiedCommands: Map; + + /** + * Contains the commands as keys and appId that touched it. + * Doesn't matter whether the app provided, modified, disabled, or enabled. + * As long as an app touched the command (besides to see if it exists), then it is listed here. + */ + private touchedCommandsToApps: Map; + + /** + * Contains the apps and the commands they have touched. The key is the appId and value is the commands. + * Doesn't matter whether the app provided, modified, disabled, or enabled. + * As long as an app touched the command (besides to see if it exists), then it is listed here. + */ + private appsTouchedCommands: Map>; + + constructor(private readonly manager: AppManager) { + this.bridge = this.manager.getBridges().getCommandBridge(); + this.accessors = this.manager.getAccessorManager(); + this.touchedCommandsToApps = new Map(); + this.appsTouchedCommands = new Map>(); + this.providedCommands = new Map>(); + this.modifiedCommands = new Map(); + } + + /** + * Checks whether an App can touch a command or not. There are only two ways an App can touch + * a command: + * 1. The command has yet to be touched + * 2. The app has already touched the command + * + * When do we consider an App touching a command? Whenever it adds, modifies, + * or removes one that it didn't provide. + * + * @param appId the app's id which to check for + * @param command the command to check about + * @returns whether or not the app can touch the command + */ + public canCommandBeTouchedBy(appId: string, command: string): boolean { + const cmd = command.toLowerCase().trim(); + return cmd && (!this.touchedCommandsToApps.has(cmd) || this.touchedCommandsToApps.get(cmd) === appId); + } + + /** + * Determines whether the command is already provided by an App or not. + * It is case insensitive. + * + * @param command the command to check if it exists or not + * @returns whether or not it is already provided + */ + public isAlreadyDefined(command: string): boolean { + const search = command.toLowerCase().trim(); + let exists = false; + + this.providedCommands.forEach((cmds) => { + if (cmds.has(search)) { + exists = true; + } + }); + + return exists; + } + + /** + * Adds a command to *be* registered. This will *not register* it with the + * bridged system yet as this is only called on an App's + * `initialize` method and an App might not get enabled. + * When adding a command, it can *not* already exist in the system + * (to overwrite) and another App can *not* have already touched or provided it. + * Apps are on a first come first serve basis for providing and modifying commands. + * + * @param appId the app's id which the command belongs to + * @param command the command to add to the system + */ + public async addCommand(appId: string, command: ISlashCommand): Promise { + command.command = command.command.toLowerCase().trim(); + + // Ensure the app can touch this command + if (!this.canCommandBeTouchedBy(appId, command.command)) { + throw new CommandHasAlreadyBeenTouchedError(command.command); + } + + // Verify the command doesn't exist already + if ((await this.bridge.doDoesCommandExist(command.command, appId)) || this.isAlreadyDefined(command.command)) { + throw new CommandAlreadyExistsError(command.command); + } + + const app = this.manager.getOneById(appId); + if (!app) { + throw new Error('App must exist in order for a command to be added.'); + } + + if (!this.providedCommands.has(appId)) { + this.providedCommands.set(appId, new Map()); + } + + this.providedCommands.get(appId).set(command.command, new AppSlashCommand(app, command)); + + // The app has now touched the command, so let's set it + this.setAsTouched(appId, command.command); + } + + /** + * Modifies an existing command. The command must either be the App's + * own command or a system command. One App can not modify another + * App's command. Apps are on a first come first serve basis as to whether + * or not they can touch or provide a command. If App "A" first provides, + * or overwrites, a command then App "B" can not touch that command. + * + * @param appId the app's id of the command to modify + * @param command the modified command to replace the current one with + */ + public async modifyCommand(appId: string, command: ISlashCommand): Promise { + command.command = command.command.toLowerCase().trim(); + + // Ensure the app can touch this command + if (!this.canCommandBeTouchedBy(appId, command.command)) { + throw new CommandHasAlreadyBeenTouchedError(command.command); + } + + const app = this.manager.getOneById(appId); + if (!app) { + throw new Error('App must exist in order to modify a command.'); + } + + const hasNotProvidedIt = !this.providedCommands.has(appId) || !this.providedCommands.get(appId).has(command.command); + + // They haven't provided (added) it and the bridged system doesn't have it, error out + if (hasNotProvidedIt && !(await this.bridge.doDoesCommandExist(command.command, appId))) { + throw new Error('You must first register a command before you can modify it.'); + } + + if (hasNotProvidedIt) { + await this.bridge.doModifyCommand(command, appId); + const regInfo = new AppSlashCommand(app, command); + regInfo.isDisabled = false; + regInfo.isEnabled = true; + regInfo.isRegistered = true; + this.modifiedCommands.set(command.command, regInfo); + } else { + this.providedCommands.get(appId).get(command.command).slashCommand = command; + } + + this.setAsTouched(appId, command.command); + } + + /** + * Goes and enables a command in the bridged system. The command + * which is being enabled must either be the App's or a system + * command which has yet to be touched by an App. + * + * @param appId the id of the app enabling the command + * @param command the command which is being enabled + */ + public async enableCommand(appId: string, command: string): Promise { + const cmd = command.toLowerCase().trim(); + + // Ensure the app can touch this command + if (!this.canCommandBeTouchedBy(appId, cmd)) { + throw new CommandHasAlreadyBeenTouchedError(cmd); + } + + // Handle if the App provided the command fist + if (this.providedCommands.has(appId) && this.providedCommands.get(appId).has(cmd)) { + const cmdInfo = this.providedCommands.get(appId).get(cmd); + + // A command marked as disabled can then be "enabled" but not be registered. + // This happens when an App is not enabled and they change the status of + // command based upon a setting they provide which a User can change. + if (!cmdInfo.isRegistered) { + cmdInfo.isDisabled = false; + cmdInfo.isEnabled = true; + } + + return; + } + + if (!(await this.bridge.doDoesCommandExist(cmd, appId))) { + throw new Error(`The command "${cmd}" does not exist to enable.`); + } + + await this.bridge.doEnableCommand(cmd, appId); + this.setAsTouched(appId, cmd); + } + + /** + * Renders an existing slash command un-usable. Whether that command is provided + * by the App calling this or a command provided by the bridged system, we don't care. + * However, an App can not disable a command which has already been touched + * by another App in some way. + * + * @param appId the app's id which is disabling the command + * @param command the command to disable in the bridged system + */ + public async disableCommand(appId: string, command: string): Promise { + const cmd = command.toLowerCase().trim(); + + // Ensure the app can touch this command + if (!this.canCommandBeTouchedBy(appId, cmd)) { + throw new CommandHasAlreadyBeenTouchedError(cmd); + } + + // Handle if the App provided the command fist + if (this.providedCommands.has(appId) && this.providedCommands.get(appId).has(cmd)) { + const cmdInfo = this.providedCommands.get(appId).get(cmd); + + // A command marked as enabled can then be "disabled" but not yet be registered. + // This happens when an App is not enabled and they change the status of + // command based upon a setting they provide which a User can change. + if (!cmdInfo.isRegistered) { + cmdInfo.isDisabled = true; + cmdInfo.isEnabled = false; + } + + return; + } + + if (!(await this.bridge.doDoesCommandExist(cmd, appId))) { + throw new Error(`The command "${cmd}" does not exist to disable.`); + } + + await this.bridge.doDisableCommand(cmd, appId); + this.setAsTouched(appId, cmd); + } + + /** + * Registers all of the commands for the provided app inside + * of the bridged system which then enables them. + * + * @param appId The app's id of which to register it's commands with the bridged system + */ + public async registerCommands(appId: string): Promise { + if (!this.providedCommands.has(appId)) { + return; + } + + const commands = this.providedCommands.get(appId); + for await (const [, appSlashCommand] of commands) { + if (appSlashCommand.isDisabled) { + continue; + } + await this.registerCommand(appId, appSlashCommand); + } + } + + /** + * Unregisters the commands from the system and restores the commands + * which the app modified in the system. + * + * @param appId the appId for the commands to purge + */ + public async unregisterCommands(appId: string): Promise { + if (this.providedCommands.has(appId)) { + const commands = this.providedCommands.get(appId); + for await (const [, appSlashCommand] of commands) { + const cmd = appSlashCommand.slashCommand.command; + await this.bridge.doUnregisterCommand(cmd, appId); + this.touchedCommandsToApps.delete(cmd); + if (!this.appsTouchedCommands.has(appId)) { + continue; + } + const ind = this.appsTouchedCommands.get(appId).indexOf(cmd); + this.appsTouchedCommands.get(appId).splice(ind, 1); + appSlashCommand.isRegistered = true; + } + + this.providedCommands.delete(appId); + } + + if (this.appsTouchedCommands.has(appId)) { + // The commands inside the appsTouchedCommands should now + // only be the ones which the App has enabled, disabled, or modified. + // We call restore to enable the commands provided by the bridged system + // or unmodify the commands modified by the App + this.appsTouchedCommands.get(appId).forEach((cmd) => { + // @NOTE this "restore" method isn't present in the bridge + // this.bridge.doRestoreCommand(cmd, appId); + this.modifiedCommands.get(cmd).isRegistered = false; + this.modifiedCommands.delete(cmd); + this.touchedCommandsToApps.delete(cmd); + }); + + this.appsTouchedCommands.delete(appId); + } + } + + /** + * Executes an App's command. + * + * @param command the command to execute + * @param context the context in which the command was entered + */ + public async executeCommand(command: string, context: SlashCommandContext): Promise { + const cmd = command.toLowerCase().trim(); + + if (!this.shouldCommandFunctionsRun(cmd)) { + return; + } + + const app = this.manager.getOneById(this.touchedCommandsToApps.get(cmd)); + + if (!app) { + throw new Error('App not found'); + } + + if (!AppStatusUtils.isEnabled(await app.getStatus())) { + throw new Error('App not enabled'); + } + + const appCmd = this.retrieveCommandInfo(cmd, app.getID()); + await appCmd.runExecutorOrPreviewer( + AppMethod._COMMAND_EXECUTOR, + this.ensureContext(context), + this.manager.getLogStorage(), + this.accessors, + ); + } + + public async getPreviews(command: string, context: SlashCommandContext): Promise { + const cmd = command.toLowerCase().trim(); + + if (!this.shouldCommandFunctionsRun(cmd)) { + return; + } + + const app = this.manager.getOneById(this.touchedCommandsToApps.get(cmd)); + + if (!app || AppStatusUtils.isDisabled(await app.getStatus())) { + // Just in case someone decides to do something they shouldn't + // let's ensure the app actually exists + return; + } + + const appCmd = this.retrieveCommandInfo(cmd, app.getID()); + + const result = await appCmd.runExecutorOrPreviewer( + AppMethod._COMMAND_PREVIEWER, + this.ensureContext(context), + this.manager.getLogStorage(), + this.accessors, + ); + + if (!result) { + // Failed to get the preview, thus returning is fine + return; + } + + return result; + } + + public async executePreview(command: string, previewItem: ISlashCommandPreviewItem, context: SlashCommandContext): Promise { + const cmd = command.toLowerCase().trim(); + + if (!this.shouldCommandFunctionsRun(cmd)) { + return; + } + + const app = this.manager.getOneById(this.touchedCommandsToApps.get(cmd)); + + if (!app || AppStatusUtils.isDisabled(await app.getStatus())) { + // Just in case someone decides to do something they shouldn't + // let's ensure the app actually exists + return; + } + + const appCmd = this.retrieveCommandInfo(cmd, app.getID()); + await appCmd.runPreviewExecutor(previewItem, this.ensureContext(context), this.manager.getLogStorage(), this.accessors); + } + + private ensureContext(context: SlashCommandContext): SlashCommandContext { + // Due to the internal changes for the usernames property, we need to ensure the room + // is a class and not just an interface + let room: Room; + if (context.getRoom() instanceof Room) { + room = context.getRoom() as Room; + } else { + room = new Room(context.getRoom(), this.manager); + } + + return new SlashCommandContext(context.getSender(), room, context.getArguments(), context.getThreadId(), context.getTriggerId()); + } + + /** + * Determines if the command's functions should run, + * this way the code isn't duplicated three times. + * + * @param command the lowercase and trimmed command + * @returns whether or not to continue + */ + private shouldCommandFunctionsRun(command: string): boolean { + // None of the Apps have touched the command to execute, + // thus we don't care so exit out + if (!this.touchedCommandsToApps.has(command)) { + return false; + } + + const appId = this.touchedCommandsToApps.get(command); + const cmdInfo = this.retrieveCommandInfo(command, appId); + + // Should the command information really not exist + // Or if the command hasn't been registered + // Or the command is disabled on our side + // then let's not execute it, as the App probably doesn't want it yet + if (!cmdInfo?.isRegistered || cmdInfo?.isDisabled) { + return false; + } + + return true; + } + + private retrieveCommandInfo(command: string, appId: string): AppSlashCommand { + return this.modifiedCommands.get(command) || this.providedCommands.get(appId).get(command); + } + + /** + * Sets that an App has been touched. + * + * @param appId the app's id which has touched the command + * @param command the command, lowercase and trimmed, which has been touched + */ + private setAsTouched(appId: string, command: string): void { + if (!this.appsTouchedCommands.has(appId)) { + this.appsTouchedCommands.set(appId, []); + } + + if (!this.appsTouchedCommands.get(appId).includes(command)) { + this.appsTouchedCommands.get(appId).push(command); + } + + this.touchedCommandsToApps.set(command, appId); + } + + /** + * Actually goes and provide's the bridged system with the command information. + * + * @param appId the app which is providing the command + * @param info the command's registration information + */ + private async registerCommand(appId: string, info: AppSlashCommand): Promise { + await this.bridge.doRegisterCommand(info.slashCommand, appId); + info.hasBeenRegistered(); + } +} diff --git a/packages/apps/src/server/managers/AppVideoConfProvider.ts b/packages/apps/src/server/managers/AppVideoConfProvider.ts new file mode 100644 index 0000000000000..13e263e796b24 --- /dev/null +++ b/packages/apps/src/server/managers/AppVideoConfProvider.ts @@ -0,0 +1,114 @@ +import { AppMethod } from '@rocket.chat/apps-engine/definition/metadata'; +import type { IBlock } from '@rocket.chat/apps-engine/definition/uikit'; +import type { + IVideoConferenceOptions, + IVideoConfProvider, + VideoConfData, + VideoConfDataExtended, +} from '@rocket.chat/apps-engine/definition/videoConfProviders'; +import type { VideoConference } from '@rocket.chat/apps-engine/definition/videoConferences'; +import type { IVideoConferenceUser } from '@rocket.chat/apps-engine/definition/videoConferences/IVideoConferenceUser'; + +import type { ProxiedApp } from '../ProxiedApp'; +import type { AppAccessorManager } from './AppAccessorManager'; +import { JSONRPC_METHOD_NOT_FOUND } from '../runtime/deno/AppsEngineDenoRuntime'; +import type { AppLogStorage } from '../storage'; + +export class AppVideoConfProvider { + /** + * States whether this provider has been registered into the Rocket.Chat system or not. + */ + public isRegistered: boolean; + + constructor( + public app: ProxiedApp, + public provider: IVideoConfProvider, + ) { + this.isRegistered = false; + } + + public hasBeenRegistered(): void { + this.isRegistered = true; + } + + public async runIsFullyConfigured(logStorage: AppLogStorage, accessors: AppAccessorManager): Promise { + return !!(await this.runTheCode(AppMethod._VIDEOCONF_IS_CONFIGURED, logStorage, accessors, [])); + } + + public async runGenerateUrl(call: VideoConfData, logStorage: AppLogStorage, accessors: AppAccessorManager): Promise { + return (await this.runTheCode(AppMethod._VIDEOCONF_GENERATE_URL, logStorage, accessors, [call])) as string; + } + + public async runCustomizeUrl( + call: VideoConfDataExtended, + user: IVideoConferenceUser | undefined, + options: IVideoConferenceOptions = {}, + logStorage: AppLogStorage, + accessors: AppAccessorManager, + ): Promise { + return (await this.runTheCode(AppMethod._VIDEOCONF_CUSTOMIZE_URL, logStorage, accessors, [call, user, options])) as string; + } + + public async runOnNewVideoConference(call: VideoConference, logStorage: AppLogStorage, accessors: AppAccessorManager): Promise { + await this.runTheCode(AppMethod._VIDEOCONF_NEW, logStorage, accessors, [call]); + } + + public async runOnVideoConferenceChanged(call: VideoConference, logStorage: AppLogStorage, accessors: AppAccessorManager): Promise { + await this.runTheCode(AppMethod._VIDEOCONF_CHANGED, logStorage, accessors, [call]); + } + + public async runOnUserJoin( + call: VideoConference, + user: IVideoConferenceUser | undefined, + logStorage: AppLogStorage, + accessors: AppAccessorManager, + ): Promise { + await this.runTheCode(AppMethod._VIDEOCONF_USER_JOINED, logStorage, accessors, [call, user]); + } + + public async runGetVideoConferenceInfo( + call: VideoConference, + user: IVideoConferenceUser | undefined, + logStorage: AppLogStorage, + accessors: AppAccessorManager, + ): Promise | undefined> { + return (await this.runTheCode(AppMethod._VIDEOCONF_GET_INFO, logStorage, accessors, [call, user])) as Array | undefined; + } + + private async runTheCode( + method: + | AppMethod._VIDEOCONF_GENERATE_URL + | AppMethod._VIDEOCONF_CUSTOMIZE_URL + | AppMethod._VIDEOCONF_IS_CONFIGURED + | AppMethod._VIDEOCONF_NEW + | AppMethod._VIDEOCONF_CHANGED + | AppMethod._VIDEOCONF_GET_INFO + | AppMethod._VIDEOCONF_USER_JOINED, + _logStorage: AppLogStorage, + _accessors: AppAccessorManager, + runContextArgs: Array, + ): Promise | undefined> { + const provider = this.provider.name; + + try { + const result = await this.app.getRuntimeController().sendRequest({ + method: `videoconference:${provider}:${method}`, + params: runContextArgs, + }); + + return result as string | boolean | Array | undefined; + } catch (e) { + if (e?.code === JSONRPC_METHOD_NOT_FOUND) { + if (method === AppMethod._VIDEOCONF_IS_CONFIGURED) { + return true; + } + if (![AppMethod._VIDEOCONF_GENERATE_URL, AppMethod._VIDEOCONF_CUSTOMIZE_URL].includes(method)) { + return undefined; + } + } + + // @TODO add error handling + console.log(e); + } + } +} diff --git a/packages/apps/src/server/managers/AppVideoConfProviderManager.ts b/packages/apps/src/server/managers/AppVideoConfProviderManager.ts new file mode 100644 index 0000000000000..2a3ec752dd10c --- /dev/null +++ b/packages/apps/src/server/managers/AppVideoConfProviderManager.ts @@ -0,0 +1,217 @@ +import type { IBlock } from '@rocket.chat/apps-engine/definition/uikit'; +import type { + IVideoConferenceOptions, + IVideoConfProvider, + VideoConfData, + VideoConfDataExtended, +} from '@rocket.chat/apps-engine/definition/videoConfProviders'; +import type { VideoConference } from '@rocket.chat/apps-engine/definition/videoConferences'; +import type { IVideoConferenceUser } from '@rocket.chat/apps-engine/definition/videoConferences/IVideoConferenceUser'; + +import type { AppManager } from '../AppManager'; +import type { VideoConferenceBridge } from '../bridges'; +import { VideoConfProviderAlreadyExistsError, VideoConfProviderNotRegisteredError } from '../errors'; +import type { AppAccessorManager } from './AppAccessorManager'; +import { AppPermissionManager } from './AppPermissionManager'; +import { AppVideoConfProvider } from './AppVideoConfProvider'; +import { PermissionDeniedError } from '../errors/PermissionDeniedError'; +import { AppPermissions } from '../permissions/AppPermissions'; + +export class AppVideoConfProviderManager { + private readonly accessors: AppAccessorManager; + + private readonly bridge: VideoConferenceBridge; + + private videoConfProviders: Map>; + + private providerApps: Map; + + constructor(private readonly manager: AppManager) { + this.bridge = this.manager.getBridges().getVideoConferenceBridge(); + this.accessors = this.manager.getAccessorManager(); + + this.videoConfProviders = new Map>(); + this.providerApps = new Map(); + } + + public canProviderBeTouchedBy(appId: string, providerName: string): boolean { + const key = providerName.toLowerCase().trim(); + return (key && (!this.providerApps.has(key) || this.providerApps.get(key) === appId)) || false; + } + + public isAlreadyDefined(providerName: string): boolean { + const search = providerName.toLowerCase().trim(); + + for (const [, providers] of this.videoConfProviders) { + if (providers.has(search)) { + return true; + } + } + + return false; + } + + public addProvider(appId: string, provider: IVideoConfProvider): void { + const app = this.manager.getOneById(appId); + if (!app) { + throw new Error('App must exist in order for a video conference provider to be added.'); + } + + if (!AppPermissionManager.hasPermission(appId, AppPermissions.videoConference.provider)) { + throw new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.videoConference.provider], + }); + } + + const providerName = provider.name.toLowerCase().trim(); + if (!this.canProviderBeTouchedBy(appId, providerName)) { + throw new VideoConfProviderAlreadyExistsError(provider.name); + } + + if (!this.videoConfProviders.has(appId)) { + this.videoConfProviders.set(appId, new Map()); + } + + this.videoConfProviders.get(appId).set(providerName, new AppVideoConfProvider(app, provider)); + this.linkAppProvider(appId, providerName); + } + + public async registerProviders(appId: string): Promise { + if (!this.videoConfProviders.has(appId)) { + return; + } + + const appProviders = this.videoConfProviders.get(appId); + if (!appProviders) { + return; + } + + for (const [, providerInfo] of appProviders) { + await this.registerProvider(appId, providerInfo); + } + } + + public async unregisterProviders(appId: string): Promise { + if (!this.videoConfProviders.has(appId)) { + return; + } + + const appProviders = this.videoConfProviders.get(appId); + for (const [, providerInfo] of appProviders) { + await this.unregisterProvider(appId, providerInfo); + } + + this.videoConfProviders.delete(appId); + } + + public async isFullyConfigured(providerName: string): Promise { + const providerInfo = this.retrieveProviderInfo(providerName); + if (!providerInfo) { + throw new VideoConfProviderNotRegisteredError(providerName); + } + + return providerInfo.runIsFullyConfigured(this.manager.getLogStorage(), this.accessors); + } + + public async onNewVideoConference(providerName: string, call: VideoConference): Promise { + const providerInfo = this.retrieveProviderInfo(providerName); + if (!providerInfo) { + throw new VideoConfProviderNotRegisteredError(providerName); + } + + return providerInfo.runOnNewVideoConference(call, this.manager.getLogStorage(), this.accessors); + } + + public async onVideoConferenceChanged(providerName: string, call: VideoConference): Promise { + const providerInfo = this.retrieveProviderInfo(providerName); + if (!providerInfo) { + throw new VideoConfProviderNotRegisteredError(providerName); + } + + return providerInfo.runOnVideoConferenceChanged(call, this.manager.getLogStorage(), this.accessors); + } + + public async onUserJoin(providerName: string, call: VideoConference, user?: IVideoConferenceUser): Promise { + const providerInfo = this.retrieveProviderInfo(providerName); + if (!providerInfo) { + throw new VideoConfProviderNotRegisteredError(providerName); + } + + return providerInfo.runOnUserJoin(call, user, this.manager.getLogStorage(), this.accessors); + } + + public async getVideoConferenceInfo( + providerName: string, + call: VideoConference, + user?: IVideoConferenceUser, + ): Promise | undefined> { + const providerInfo = this.retrieveProviderInfo(providerName); + if (!providerInfo) { + throw new VideoConfProviderNotRegisteredError(providerName); + } + + return providerInfo.runGetVideoConferenceInfo(call, user, this.manager.getLogStorage(), this.accessors); + } + + public async generateUrl(providerName: string, call: VideoConfData): Promise { + const providerInfo = this.retrieveProviderInfo(providerName); + if (!providerInfo) { + throw new VideoConfProviderNotRegisteredError(providerName); + } + + return providerInfo.runGenerateUrl(call, this.manager.getLogStorage(), this.accessors); + } + + public async customizeUrl( + providerName: string, + call: VideoConfDataExtended, + user?: IVideoConferenceUser, + options?: IVideoConferenceOptions, + ): Promise { + const providerInfo = this.retrieveProviderInfo(providerName); + if (!providerInfo) { + throw new VideoConfProviderNotRegisteredError(providerName); + } + + return providerInfo.runCustomizeUrl(call, user, options, this.manager.getLogStorage(), this.accessors); + } + + private retrieveProviderInfo(providerName: string): AppVideoConfProvider | undefined { + const key = providerName.toLowerCase().trim(); + + for (const [, providers] of this.videoConfProviders) { + if (!providers.has(key)) { + continue; + } + + const provider = providers.get(key); + if (provider?.isRegistered) { + return provider; + } + } + } + + private linkAppProvider(appId: string, providerName: string): void { + this.providerApps.set(providerName, appId); + } + + private async registerProvider(appId: string, info: AppVideoConfProvider): Promise { + await this.bridge.doRegisterProvider(info.provider, appId); + info.hasBeenRegistered(); + } + + private async unregisterProvider(appId: string, info: AppVideoConfProvider): Promise { + const key = info.provider.name.toLowerCase().trim(); + + await this.bridge.doUnRegisterProvider(info.provider, appId); + this.providerApps.delete(key); + + info.isRegistered = false; + + const map = this.videoConfProviders.get(appId); + if (map) { + map.delete(key); + } + } +} diff --git a/packages/apps/src/server/managers/UIActionButtonManager.ts b/packages/apps/src/server/managers/UIActionButtonManager.ts new file mode 100644 index 0000000000000..2c18e912de98f --- /dev/null +++ b/packages/apps/src/server/managers/UIActionButtonManager.ts @@ -0,0 +1,96 @@ +import { AppStatusUtils } from '@rocket.chat/apps-engine/definition/AppStatus'; +import type { IUIActionButton, IUIActionButtonDescriptor } from '@rocket.chat/apps-engine/definition/ui'; + +import type { AppManager } from '../AppManager'; +import type { AppActivationBridge } from '../bridges'; +import { AppPermissionManager } from './AppPermissionManager'; +import { PermissionDeniedError } from '../errors/PermissionDeniedError'; +import { AppPermissions } from '../permissions/AppPermissions'; + +export class UIActionButtonManager { + private readonly activationBridge: AppActivationBridge; + + private readonly manager: AppManager; + + private registeredActionButtons = new Map>(); + + constructor(manager: AppManager) { + this.manager = manager; + this.activationBridge = manager.getBridges().getAppActivationBridge(); + } + + public registerActionButton(appId: string, button: IUIActionButtonDescriptor) { + if (!this.hasPermission(appId)) { + return false; + } + + if (!this.registeredActionButtons.has(appId)) { + this.registeredActionButtons.set(appId, new Map()); + } + + this.registeredActionButtons.get(appId).set(button.actionId, button); + + void this.activationBridge.doActionsChanged(); + + return true; + } + + public clearAppActionButtons(appId: string) { + this.registeredActionButtons.set(appId, new Map()); + void this.activationBridge.doActionsChanged(); + } + + public getAppActionButtons(appId: string) { + return this.registeredActionButtons.get(appId); + } + + public async getAllActionButtons(): Promise> { + const buttonList: Array = []; + + // Flatten map to a simple list of buttons from enabled apps only + for (const [appId, appButtons] of this.registeredActionButtons) { + const app = this.manager.getOneById(appId); + + // Skip if app doesn't exist + if (!app) { + continue; + } + + // or if it is not enabled + try { + const appStatus = await app.getStatus(); + if (!AppStatusUtils.isEnabled(appStatus)) { + continue; + } + } catch { + // If we can't get the app status, skip this app's buttons + continue; + } + + // Add buttons from this enabled app + appButtons.forEach((button) => + buttonList.push({ + ...button, + appId, + }), + ); + } + + return buttonList; + } + + private hasPermission(appId: string) { + if (AppPermissionManager.hasPermission(appId, AppPermissions.ui.registerButtons)) { + return true; + } + + AppPermissionManager.notifyAboutError( + new PermissionDeniedError({ + appId, + missingPermissions: [AppPermissions.ui.registerButtons], + }), + ); + + return false; + } +} diff --git a/packages/apps/src/server/managers/index.ts b/packages/apps/src/server/managers/index.ts new file mode 100644 index 0000000000000..9d7b22c79bc53 --- /dev/null +++ b/packages/apps/src/server/managers/index.ts @@ -0,0 +1,23 @@ +import { AppAccessorManager } from './AppAccessorManager'; +import { AppApiManager } from './AppApiManager'; +import { AppExternalComponentManager } from './AppExternalComponentManager'; +import { AppLicenseManager } from './AppLicenseManager'; +import { AppListenerManager } from './AppListenerManager'; +import { AppOutboundCommunicationProviderManager } from './AppOutboundCommunicationProviderManager'; +import { AppSchedulerManager } from './AppSchedulerManager'; +import { AppSettingsManager } from './AppSettingsManager'; +import { AppSlashCommandManager } from './AppSlashCommandManager'; +import { AppVideoConfProviderManager } from './AppVideoConfProviderManager'; + +export { + AppAccessorManager, + AppLicenseManager, + AppListenerManager, + AppExternalComponentManager, + AppSettingsManager, + AppSlashCommandManager, + AppApiManager, + AppSchedulerManager, + AppVideoConfProviderManager, + AppOutboundCommunicationProviderManager, +}; diff --git a/packages/apps/src/server/marketplace/IAppLicenseMetadata.ts b/packages/apps/src/server/marketplace/IAppLicenseMetadata.ts new file mode 100644 index 0000000000000..0c8ceb9411979 --- /dev/null +++ b/packages/apps/src/server/marketplace/IAppLicenseMetadata.ts @@ -0,0 +1,5 @@ +export interface IAppLicenseMetadata { + license: string; + version: number; + expireDate: Date; +} diff --git a/packages/apps/src/server/marketplace/IMarketplaceInfo.ts b/packages/apps/src/server/marketplace/IMarketplaceInfo.ts new file mode 100644 index 0000000000000..aa4e84ab45a83 --- /dev/null +++ b/packages/apps/src/server/marketplace/IMarketplaceInfo.ts @@ -0,0 +1,25 @@ +import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; + +import type { IMarketplacePricingPlan } from './IMarketplacePricingPlan'; +import type { IMarketplaceSimpleBundleInfo } from './IMarketplaceSimpleBundleInfo'; +import type { IMarketplaceSubscriptionInfo } from './IMarketplaceSubscriptionInfo'; +import type { MarketplacePurchaseType } from './MarketplacePurchaseType'; + +export interface IMarketplaceInfo extends IAppInfo { + categories: Array; + status: string; + reviewedNote?: string; + rejectionNote?: string; + isVisible: boolean; + isPurchased: boolean; + isSubscribed: boolean; + isBundled: boolean; + createdDate: string; + modifiedDate: string; + price: number; + subscriptionInfo?: IMarketplaceSubscriptionInfo; + purchaseType: MarketplacePurchaseType; + pricingPlans?: Array; + bundledIn?: Array; + isEnterpriseOnly?: boolean; +} diff --git a/packages/apps/src/server/marketplace/IMarketplacePricingPlan.ts b/packages/apps/src/server/marketplace/IMarketplacePricingPlan.ts new file mode 100644 index 0000000000000..7d3a7f5958c11 --- /dev/null +++ b/packages/apps/src/server/marketplace/IMarketplacePricingPlan.ts @@ -0,0 +1,11 @@ +import type { IMarketplacePricingTier } from './IMarketplacePricingTier'; +import type { MarketplacePricingStrategy } from './MarketplacePricingStrategy'; + +export interface IMarketplacePricingPlan { + id: string; + enabled: boolean; + price: number; + isPerSeat: boolean; + strategy: MarketplacePricingStrategy; + tiers?: Array; +} diff --git a/packages/apps/src/server/marketplace/IMarketplacePricingTier.ts b/packages/apps/src/server/marketplace/IMarketplacePricingTier.ts new file mode 100644 index 0000000000000..65d3f593f7bff --- /dev/null +++ b/packages/apps/src/server/marketplace/IMarketplacePricingTier.ts @@ -0,0 +1,6 @@ +export interface IMarketplacePricingTier { + perUnit: boolean; + minimum: number; + maximum: number; + price: number; +} diff --git a/packages/apps/src/server/marketplace/IMarketplaceSimpleBundleInfo.ts b/packages/apps/src/server/marketplace/IMarketplaceSimpleBundleInfo.ts new file mode 100644 index 0000000000000..4fc68e39afc9b --- /dev/null +++ b/packages/apps/src/server/marketplace/IMarketplaceSimpleBundleInfo.ts @@ -0,0 +1,4 @@ +export interface IMarketplaceSimpleBundleInfo { + bundleId: string; + bundleName: string; +} diff --git a/packages/apps/src/server/marketplace/IMarketplaceSubscriptionInfo.ts b/packages/apps/src/server/marketplace/IMarketplaceSubscriptionInfo.ts new file mode 100644 index 0000000000000..4be076b629ad9 --- /dev/null +++ b/packages/apps/src/server/marketplace/IMarketplaceSubscriptionInfo.ts @@ -0,0 +1,15 @@ +import type { IAppLicenseMetadata } from './IAppLicenseMetadata'; +import type { MarketplaceSubscriptionStatus } from './MarketplaceSubscriptionStatus'; +import type { MarketplaceSubscriptionType } from './MarketplaceSubscriptionType'; + +export interface IMarketplaceSubscriptionInfo { + seats: number; + maxSeats: number; + startDate: string; + periodEnd: string; + isSubscripbedViaBundle: boolean; + endDate?: string; + typeOf: MarketplaceSubscriptionType; + status: MarketplaceSubscriptionStatus; + license: IAppLicenseMetadata; +} diff --git a/packages/apps/src/server/marketplace/MarketplacePricingStrategy.ts b/packages/apps/src/server/marketplace/MarketplacePricingStrategy.ts new file mode 100644 index 0000000000000..473f4428430c0 --- /dev/null +++ b/packages/apps/src/server/marketplace/MarketplacePricingStrategy.ts @@ -0,0 +1,5 @@ +export enum MarketplacePricingStrategy { + PricingStrategyOnce = 'once', + PricingStrategyMonthly = 'monthly', + PricingStrategyYearly = 'yearly', +} diff --git a/packages/apps/src/server/marketplace/MarketplacePurchaseType.ts b/packages/apps/src/server/marketplace/MarketplacePurchaseType.ts new file mode 100644 index 0000000000000..e4deef5b7f9c3 --- /dev/null +++ b/packages/apps/src/server/marketplace/MarketplacePurchaseType.ts @@ -0,0 +1,4 @@ +export enum MarketplacePurchaseType { + PurchaseTypeBuy = 'buy', + PurchaseTypeSubscription = 'subscription', +} diff --git a/packages/apps/src/server/marketplace/MarketplaceSubscriptionStatus.ts b/packages/apps/src/server/marketplace/MarketplaceSubscriptionStatus.ts new file mode 100644 index 0000000000000..c57bc59440cee --- /dev/null +++ b/packages/apps/src/server/marketplace/MarketplaceSubscriptionStatus.ts @@ -0,0 +1,10 @@ +export enum MarketplaceSubscriptionStatus { + // PurchaseSubscriptionStatusTrialing is when the subscription is in the trial phase + PurchaseSubscriptionStatusTrialing = 'trialing', + // PurchaseSubscriptionStatusActive is when the subscription is active and being billed for + PurchaseSubscriptionStatusActive = 'active', + // PurchaseSubscriptionStatusCanceled is when the subscription is inactive due to being canceled + PurchaseSubscriptionStatusCanceled = 'canceled', + // PurchaseSubscriptionStatusPastDue is when the subscription was active but is now past due as a result of incorrect billing information + PurchaseSubscriptionStatusPastDue = 'pastDue', +} diff --git a/packages/apps/src/server/marketplace/MarketplaceSubscriptionType.ts b/packages/apps/src/server/marketplace/MarketplaceSubscriptionType.ts new file mode 100644 index 0000000000000..30256fc48cf1e --- /dev/null +++ b/packages/apps/src/server/marketplace/MarketplaceSubscriptionType.ts @@ -0,0 +1,4 @@ +export enum MarketplaceSubscriptionType { + SubscriptionTypeApp = 'app', + SubscriptionTypeService = 'service', +} diff --git a/packages/apps/src/server/marketplace/index.ts b/packages/apps/src/server/marketplace/index.ts new file mode 100644 index 0000000000000..d19ee746064dc --- /dev/null +++ b/packages/apps/src/server/marketplace/index.ts @@ -0,0 +1,15 @@ +import type { IAppLicenseMetadata } from './IAppLicenseMetadata'; +import type { IMarketplaceInfo } from './IMarketplaceInfo'; +import type { IMarketplacePricingPlan } from './IMarketplacePricingPlan'; +import type { IMarketplacePricingTier } from './IMarketplacePricingTier'; +import type { IMarketplaceSimpleBundleInfo } from './IMarketplaceSimpleBundleInfo'; +import type { IMarketplaceSubscriptionInfo } from './IMarketplaceSubscriptionInfo'; + +export type { + IAppLicenseMetadata, + IMarketplaceInfo, + IMarketplacePricingPlan, + IMarketplacePricingTier, + IMarketplaceSimpleBundleInfo, + IMarketplaceSubscriptionInfo, +}; diff --git a/packages/apps/src/server/marketplace/license/AppLicenseValidationResult.ts b/packages/apps/src/server/marketplace/license/AppLicenseValidationResult.ts new file mode 100644 index 0000000000000..2946d40111f54 --- /dev/null +++ b/packages/apps/src/server/marketplace/license/AppLicenseValidationResult.ts @@ -0,0 +1,56 @@ +export class AppLicenseValidationResult { + private errors: { [key: string]: string } = {}; + + private warnings: { [key: string]: string } = {}; + + private validated = false; + + private appId: string; + + public addError(field: string, message: string): void { + this.errors[field] = message; + } + + public addWarning(field: string, message: string): void { + this.warnings[field] = message; + } + + public get hasErrors(): boolean { + return !!Object.keys(this.errors).length; + } + + public get hasWarnings(): boolean { + return !!Object.keys(this.warnings).length; + } + + public get hasBeenValidated(): boolean { + return this.validated; + } + + public setValidated(validated: boolean): void { + this.validated = validated; + } + + public setAppId(appId: string): void { + this.appId = appId; + } + + public getAppId(): string { + return this.appId; + } + + public getErrors(): object { + return this.errors; + } + + public getWarnings(): object { + return this.warnings; + } + + public toJSON(): object { + return { + errors: this.errors, + warnings: this.warnings, + }; + } +} diff --git a/packages/apps/src/server/marketplace/license/Crypto.ts b/packages/apps/src/server/marketplace/license/Crypto.ts new file mode 100644 index 0000000000000..a4f92799f3d3e --- /dev/null +++ b/packages/apps/src/server/marketplace/license/Crypto.ts @@ -0,0 +1,26 @@ +import { publicDecrypt } from 'crypto'; + +import type { IInternalBridge } from '../../bridges'; + +export class Crypto { + constructor(private readonly internalBridge: IInternalBridge) {} + + public async decryptLicense(content: string): Promise { + const publicKeySetting = await this.internalBridge.doGetWorkspacePublicKey(); + + if (!publicKeySetting?.value) { + throw new Error('Public key not available, cannot decrypt'); // TODO: add custom error? + } + + const decoded = publicDecrypt(publicKeySetting.value, Buffer.from(content, 'base64')); + + let license; + try { + license = JSON.parse(decoded.toString()); + } catch { + throw new Error('Invalid license provided'); + } + + return license; + } +} diff --git a/packages/apps/src/server/marketplace/license/index.ts b/packages/apps/src/server/marketplace/license/index.ts new file mode 100644 index 0000000000000..573b1e7fbae52 --- /dev/null +++ b/packages/apps/src/server/marketplace/license/index.ts @@ -0,0 +1,4 @@ +import { AppLicenseValidationResult } from './AppLicenseValidationResult'; +import { Crypto } from './Crypto'; + +export { AppLicenseValidationResult, Crypto }; diff --git a/packages/apps/src/server/messages/Message.ts b/packages/apps/src/server/messages/Message.ts new file mode 100644 index 0000000000000..b6c8fa1de07ef --- /dev/null +++ b/packages/apps/src/server/messages/Message.ts @@ -0,0 +1,109 @@ +import type { IMessage, IMessageAttachment, IMessageFile, IMessageReactions } from '@rocket.chat/apps-engine/definition/messages'; +import type { IUser, IUserLookup } from '@rocket.chat/apps-engine/definition/users'; +import type { LayoutBlock } from '@rocket.chat/ui-kit'; + +import type { AppManager } from '../AppManager'; +import { Room } from '../rooms/Room'; + +export class Message implements IMessage { + public id?: string; + + public sender: IUser; + + public text?: string; + + public createdAt?: Date; + + public updatedAt?: Date; + + public editor?: IUser; + + public editedAt?: Date; + + public emoji?: string; + + public avatarUrl?: string; + + public alias?: string; + + public attachments?: Array; + + public reactions?: IMessageReactions; + + public groupable?: boolean; + + public parseUrls?: boolean; + + public customFields?: { [key: string]: any }; + + public threadId?: string; + + public file?: IMessageFile; + + public blocks?: Array; + + public starred?: Array<{ _id: string }>; + + public pinned?: boolean; + + public pinnedAt?: Date; + + public pinnedBy?: IUserLookup; + + private _ROOM: Room; + + public get room(): Room { + return this._ROOM; + } + + public set room(room) { + this._ROOM = new Room(room, this.manager); + } + + public constructor( + message: IMessage, + private manager: AppManager, + ) { + Object.assign(this, message); + } + + get value(): object { + return { + id: this.id, + sender: this.sender, + text: this.text, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + editor: this.editor, + editedAt: this.editedAt, + emoji: this.emoji, + avatarUrl: this.avatarUrl, + alias: this.alias, + attachments: this.attachments, + reactions: this.reactions, + groupable: this.groupable, + parseUrls: this.parseUrls, + customFields: this.customFields, + threadId: this.threadId, + room: this.room, + file: this.file, + blocks: this.blocks, + starred: this.starred, + pinned: this.pinned, + pinnedAt: this.pinnedAt, + pinnedBy: this.pinnedBy, + }; + } + + public toJSON() { + return this.value; + } + + public toString() { + return this.value; + } + + public valueOf() { + return this.value; + } +} diff --git a/packages/apps/src/server/misc/UIHelper.ts b/packages/apps/src/server/misc/UIHelper.ts new file mode 100644 index 0000000000000..398a86124ef56 --- /dev/null +++ b/packages/apps/src/server/misc/UIHelper.ts @@ -0,0 +1,31 @@ +import type { IBlock } from '@rocket.chat/apps-engine/definition/uikit'; +import type { LayoutBlock } from '@rocket.chat/ui-kit'; +import { v4 as uuid } from 'uuid'; + +export class UIHelper { + /** + * Assign blockId, appId and actionId to every block/element inside the array + * @param blocks the blocks that will be iterated and assigned the ids + * @param appId the appId that will be assigned to + * @returns the array of block with the ids properties assigned + */ + public static assignIds(blocks: Array, appId: string): Array { + blocks.forEach((block: (IBlock | LayoutBlock) & { appId?: string; blockId?: string; elements?: Array }) => { + if (!block.appId) { + block.appId = appId; + } + if (!block.blockId) { + block.blockId = uuid(); + } + if (block.elements) { + block.elements.forEach((element) => { + if (!element.actionId) { + element.actionId = uuid(); + } + }); + } + }); + + return blocks; + } +} diff --git a/packages/apps/src/server/misc/Utilities.ts b/packages/apps/src/server/misc/Utilities.ts new file mode 100644 index 0000000000000..f126d379752e8 --- /dev/null +++ b/packages/apps/src/server/misc/Utilities.ts @@ -0,0 +1,36 @@ +import cloneDeep = require('lodash.clonedeep'); + +export class Utilities { + public static deepClone(item: T): T { + return cloneDeep(item); + } + + public static deepFreeze(item: any): T { + Object.freeze(item); + + Object.getOwnPropertyNames(item).forEach((prop: string) => { + if ( + item.hasOwnProperty(prop) && + item[prop] !== null && + (typeof item[prop] === 'object' || typeof item[prop] === 'function') && + !Object.isFrozen(item[prop]) + ) { + Utilities.deepFreeze(item[prop]); + } + }); + + return item; + } + + public static deepCloneAndFreeze(item: T): T { + return Utilities.deepFreeze(Utilities.deepClone(item)); + } + + public static omit(object: { [key: string]: any }, keys: Array) { + const cloned = this.deepClone(object); + for (const key of keys) { + delete cloned[key]; + } + return cloned; + } +} diff --git a/packages/apps/src/server/oauth2/OAuth2Client.ts b/packages/apps/src/server/oauth2/OAuth2Client.ts new file mode 100644 index 0000000000000..08cbd0314aec9 --- /dev/null +++ b/packages/apps/src/server/oauth2/OAuth2Client.ts @@ -0,0 +1,337 @@ +import { URL } from 'url'; + +import type { App } from '@rocket.chat/apps-engine/definition/App'; +import type { IConfigurationExtend, IHttp, IModify, IPersistence, IRead } from '@rocket.chat/apps-engine/definition/accessors'; +import { HttpStatusCode } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IApiEndpointInfo, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api'; +import { ApiSecurity, ApiVisibility } from '@rocket.chat/apps-engine/definition/api'; +import { RocketChatAssociationModel, RocketChatAssociationRecord } from '@rocket.chat/apps-engine/definition/metadata'; +import type { IAuthData, IOAuth2Client, IOAuth2ClientOptions } from '@rocket.chat/apps-engine/definition/oauth2/IOAuth2'; +import { SettingType } from '@rocket.chat/apps-engine/definition/settings'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +export enum GrantType { + RefreshToken = 'refresh_token', + AuthorizationCode = 'authorization_code', +} + +export class OAuth2Client implements IOAuth2Client { + private defaultContents = { + success: `
\ +

\ + Authorization went successfully
\ + You can close this tab now
\ +

\ +
`, + failed: `
\ +

\ + Oops, something went wrong, please try again or in case it still does not work, contact the administrator.\ +

\ +
`, + }; + + constructor( + private readonly app: App, + private readonly config: IOAuth2ClientOptions, + ) {} + + public async setup(configuration: IConfigurationExtend): Promise { + await configuration.api.provideApi({ + security: ApiSecurity.UNSECURE, + visibility: ApiVisibility.PUBLIC, + endpoints: [ + { + path: `${this.config.alias}-callback`, + get: this.handleOAuthCallback.bind(this), + }, + ], + }); + + await Promise.all([ + configuration.settings.provideSetting({ + id: `${this.config.alias}-oauth-client-id`, + type: SettingType.STRING, + public: true, + required: true, + packageValue: '', + i18nLabel: `${this.config.alias}-oauth-client-id`, + }), + + configuration.settings.provideSetting({ + id: `${this.config.alias}-oauth-clientsecret`, + type: SettingType.STRING, + public: true, + required: true, + packageValue: '', + i18nLabel: `${this.config.alias}-oauth-client-secret`, + }), + ]); + } + + public async getUserAuthorizationUrl(user: IUser, scopes?: Array): Promise { + const redirectUri = this.app.getAccessors().providedApiEndpoints[0].computedPath.substring(1); + + const siteUrl = await this.getBaseURLWithoutTrailingSlash(); + + const finalScopes = ([] as Array).concat(this.config.defaultScopes || [], scopes || []); + + const { authUri } = this.config; + + const clientId = await this.app + .getAccessors() + .reader.getEnvironmentReader() + .getSettings() + .getValueById(`${this.config.alias}-oauth-client-id`); + + const url = new URL(authUri, siteUrl); + + url.searchParams.set('response_type', 'code'); + url.searchParams.set('redirect_uri', `${siteUrl}/${redirectUri}`); + url.searchParams.set('state', user.id); + url.searchParams.set('client_id', clientId); + url.searchParams.set('access_type', 'offline'); + + if (finalScopes.length > 0) { + url.searchParams.set('scope', finalScopes.join(' ')); + } + + return url; + } + + public async getAccessTokenForUser(user: IUser): Promise { + const associations = [ + new RocketChatAssociationRecord(RocketChatAssociationModel.USER, user.id), + new RocketChatAssociationRecord(RocketChatAssociationModel.MISC, `${this.config.alias}-oauth-connection`), + ]; + + const [result] = (await this.app.getAccessors().reader.getPersistenceReader().readByAssociations(associations)) as unknown as Array< + IAuthData | undefined + >; + + return result; + } + + public async refreshUserAccessToken(user: IUser, persis: IPersistence): Promise { + try { + const tokenInfo = await this.getAccessTokenForUser(user); + + if (!tokenInfo) { + throw new Error('User has no access token information'); + } + + if (!tokenInfo.refreshToken) { + throw new Error('User token information has no refresh token available'); + } + + const { + config: { refreshTokenUri }, + } = this; + + const clientId = await this.app + .getAccessors() + .reader.getEnvironmentReader() + .getSettings() + .getValueById(`${this.config.alias}-oauth-client-id`); + + const clientSecret = await this.app + .getAccessors() + .reader.getEnvironmentReader() + .getSettings() + .getValueById(`${this.config.alias}-oauth-clientsecret`); + + const siteUrl = await this.getBaseURLWithoutTrailingSlash(); + + const redirectUri = this.app.getAccessors().providedApiEndpoints[0].computedPath.substring(1); + + const url = new URL(refreshTokenUri); + + url.searchParams.set('client_id', clientId); + url.searchParams.set('client_secret', clientSecret); + url.searchParams.set('redirect_uri', `${siteUrl}/${redirectUri}`); + url.searchParams.set('refresh_token', tokenInfo.refreshToken); + url.searchParams.set('grant_type', GrantType.RefreshToken); + + const { content, statusCode } = await this.app.getAccessors().http.post(url.href); + + if (statusCode !== 200) { + throw new Error('Request to provider was unsuccessful. Check logs for more information'); + } + + const { access_token, expires_in, refresh_token, scope } = JSON.parse(content as string); + + if (!access_token) { + throw new Error('No access token returned by the provider'); + } + + const authData: IAuthData = { + scope, + token: access_token, + expiresAt: expires_in, + refreshToken: refresh_token || tokenInfo.refreshToken, + }; + + await this.saveToken(authData, user.id, persis); + + return authData; + } catch (error) { + this.app.getLogger().error(error); + throw error; + } + } + + public async revokeUserAccessToken(user: IUser, persis: IPersistence): Promise { + try { + const tokenInfo = await this.getAccessTokenForUser(user); + + if (!tokenInfo?.token) { + throw new Error('No access token available for this user.'); + } + + const url = new URL(this.config.revokeTokenUri); + + url.searchParams.set('token', tokenInfo?.token); + + const result = await this.app.getAccessors().http.post(url.href); + + if (result.statusCode !== 200) { + throw new Error('Provider did not allow token to be revoked'); + } + + await this.removeToken({ userId: user.id, persis }); + + return true; + } catch (error) { + this.app.getLogger().error(error); + return false; + } + } + + private async getBaseURLWithoutTrailingSlash(): Promise { + const SITE_URL = 'Site_Url'; + const url = await this.app.getAccessors().environmentReader.getServerSettings().getValueById(SITE_URL); + + if (url.endsWith('/')) { + return url.substr(0, url.length - 1); + } + return url; + } + + private async handleOAuthCallback( + request: IApiRequest, + endpoint: IApiEndpointInfo, + read: IRead, + modify: IModify, + http: IHttp, + persis: IPersistence, + ): Promise { + try { + const { + query: { code, state }, + } = request; + + const user = await this.app.getAccessors().reader.getUserReader().getById(state); + + if (!user) { + throw new Error('User could not be determined.'); + } + + // User chose not to authorize the access + if (!code) { + const failedResult = await this.config.authorizationCallback?.(undefined, user, read, modify, http, persis); + + return { + status: HttpStatusCode.UNAUTHORIZED, + content: failedResult?.responseContent || this.defaultContents.failed, + }; + } + + const siteUrl = await this.getBaseURLWithoutTrailingSlash(); + + const accessTokenUrl = this.config.accessTokenUri; + + const redirectUri = this.app.getAccessors().providedApiEndpoints[0].computedPath.substring(1); + + const clientId = await this.app + .getAccessors() + .reader.getEnvironmentReader() + .getSettings() + .getValueById(`${this.config.alias}-oauth-client-id`); + + const clientSecret = await this.app + .getAccessors() + .reader.getEnvironmentReader() + .getSettings() + .getValueById(`${this.config.alias}-oauth-clientsecret`); + + const url = new URL(accessTokenUrl, siteUrl); + + url.searchParams.set('client_id', clientId); + url.searchParams.set('redirect_uri', `${siteUrl}/${redirectUri}`); + url.searchParams.set('code', code); + url.searchParams.set('client_secret', clientSecret); + url.searchParams.set('access_type', 'offline'); + url.searchParams.set('grant_type', GrantType.AuthorizationCode); + + const { content, statusCode } = await http.post(url.href, { + headers: { Accept: 'application/json' }, + }); + + // If provider had a server error, nothing we can do + if (statusCode >= 500) { + throw new Error('Request for access token failed. Check logs for more information'); + } + + const response = JSON.parse(content as string); + const { access_token, expires_in, refresh_token, scope } = response; + + const authData: IAuthData = { + scope, + token: access_token, + expiresAt: expires_in, + refreshToken: refresh_token, + }; + + const result = await this.config.authorizationCallback?.(authData, user, read, modify, http, persis); + + await this.saveToken(authData, user.id, persis); + + return { + status: statusCode, + content: result?.responseContent || this.defaultContents.success, + }; + } catch (error) { + this.app.getLogger().error(error); + return { + status: HttpStatusCode.INTERNAL_SERVER_ERROR, + content: this.defaultContents.failed, + }; + } + } + + private async saveToken(authData: IAuthData, userId: string, persis: IPersistence): Promise { + const { scope, token, expiresAt, refreshToken } = authData; + + return persis.updateByAssociations( + [ + new RocketChatAssociationRecord(RocketChatAssociationModel.USER, userId), + new RocketChatAssociationRecord(RocketChatAssociationModel.MISC, `${this.config.alias}-oauth-connection`), + ], + { + scope, + token, + expiresAt: expiresAt || '', + refreshToken: refreshToken || '', + }, + true, // we want to create the record if it doesn't exist + ); + } + + private async removeToken({ userId, persis }: { userId: string; persis: IPersistence }): Promise { + const [result] = (await persis.removeByAssociations([ + new RocketChatAssociationRecord(RocketChatAssociationModel.USER, userId), + new RocketChatAssociationRecord(RocketChatAssociationModel.MISC, `${this.config.alias}-oauth-connection`), + ])) as unknown as Array; + + return result; + } +} diff --git a/packages/apps/src/server/permissions/AppPermissions.ts b/packages/apps/src/server/permissions/AppPermissions.ts new file mode 100644 index 0000000000000..4e3b55e31fd86 --- /dev/null +++ b/packages/apps/src/server/permissions/AppPermissions.ts @@ -0,0 +1,168 @@ +import type { + INetworkingPermission, + IPermission, + IReadSettingPermission, + IWorkspaceTokenPermission, +} from '@rocket.chat/apps-engine/definition/permissions/IPermission'; + +/** + * @description + * + * App Permission naming rules: + * + * 'scope-name': { + * 'permission-name': { name: 'scope-name.permission-name' } + * } + * + * You can retrive this permission by using: + * AppPermissions['scope-name']['permission-name'] -> { name: 'scope-name.permission-name' } + * + * @example + * + * AppPermissions.upload.read // { name: 'upload.read', domains: [] } + */ +export const AppPermissions = { + 'user': { + read: { name: 'user.read' }, + write: { name: 'user.write' }, + }, + 'upload': { + read: { name: 'upload.read' }, + write: { name: 'upload.write' }, + }, + 'email': { + send: { name: 'email.send' }, + }, + 'ui': { + interaction: { name: 'ui.interact' }, + registerButtons: { name: 'ui.registerButtons' }, + }, + 'setting': { + read: { name: 'server-setting.read', hiddenSettings: [] } as IReadSettingPermission, + write: { name: 'server-setting.write' }, + }, + 'room': { + 'read': { name: 'room.read' }, + 'write': { name: 'room.write' }, + 'system-view-all': { name: 'room.system.view-all' }, + }, + 'role': { + read: { name: 'role.read' }, + write: { name: 'role.write' }, + }, + 'message': { + read: { name: 'message.read' }, + write: { name: 'message.write' }, + }, + 'moderation': { + read: { name: 'moderation.read' }, + write: { name: 'moderation.write' }, + }, + 'contact': { + read: { name: 'contact.read' }, + write: { name: 'contact.write' }, + }, + 'threads': { + read: { name: 'threads.read' }, + }, + 'livechat-status': { + read: { name: 'livechat-status.read' }, + }, + 'livechat-custom-fields': { + write: { name: 'livechat-custom-fields.write' }, + }, + 'livechat-visitor': { + read: { name: 'livechat-visitor.read' }, + write: { name: 'livechat-visitor.write' }, + }, + 'livechat-message': { + read: { name: 'livechat-message.read' }, + write: { name: 'livechat-message.write' }, + multiple: { name: 'livechat-message.multiple' }, + }, + 'livechat-room': { + read: { name: 'livechat-room.read' }, + write: { name: 'livechat-room.write' }, + }, + 'livechat-department': { + read: { name: 'livechat-department.read' }, + write: { name: 'livechat-department.write' }, + multiple: { name: 'livechat-department.multiple' }, + }, + 'env': { + read: { name: 'env.read' }, + }, + 'cloud': { + 'workspace-token': { name: 'cloud.workspace-token', scopes: [] } as IWorkspaceTokenPermission, + }, + // Internal permissions + 'scheduler': { + default: { name: 'scheduler' }, + }, + 'networking': { + default: { name: 'networking', domains: [] } as INetworkingPermission, + }, + 'persistence': { + default: { name: 'persistence' }, + }, + 'command': { + default: { name: 'slashcommand' }, + }, + 'videoConference': { + read: { name: 'video-conference.read' }, + write: { name: 'video-conference.write' }, + provider: { name: 'video-conference-provider' }, + }, + 'apis': { + default: { name: 'api' }, + }, + 'oauth-app': { + read: { name: 'oauth-app.read' }, + write: { name: 'oauth-app.write' }, + }, + 'outboundComms': { + provide: { name: 'outbound-communication.provide' }, + }, + 'experimental': { + default: { name: 'experimental.default' }, + }, +}; + +/** + * @description + * Default permissions for apps + * Used to ensure backward compatibility with apps + * that were developed before the permission system was introduced. + */ +export const defaultPermissions: Array = [ + AppPermissions.user.read, + AppPermissions.user.write, + AppPermissions.upload.read, + AppPermissions.upload.write, + AppPermissions.ui.interaction, + AppPermissions.setting.read, + AppPermissions.setting.write, + AppPermissions.room.read, + AppPermissions.room.write, + AppPermissions.message.read, + AppPermissions.message.write, + AppPermissions['livechat-department'].read, + AppPermissions['livechat-department'].write, + AppPermissions['livechat-room'].read, + AppPermissions['livechat-room'].write, + AppPermissions['livechat-message'].read, + AppPermissions['livechat-message'].write, + AppPermissions['livechat-visitor'].read, + AppPermissions['livechat-visitor'].write, + AppPermissions['livechat-status'].read, + AppPermissions['livechat-custom-fields'].write, + AppPermissions.scheduler.default, + AppPermissions.networking.default, + AppPermissions.persistence.default, + AppPermissions.env.read, + AppPermissions.command.default, + AppPermissions.videoConference.provider, + AppPermissions.videoConference.read, + AppPermissions.videoConference.write, + AppPermissions.apis.default, +]; diff --git a/packages/apps/src/server/rooms/Room.ts b/packages/apps/src/server/rooms/Room.ts new file mode 100644 index 0000000000000..70d81d645826c --- /dev/null +++ b/packages/apps/src/server/rooms/Room.ts @@ -0,0 +1,105 @@ +import type { IRoom, RoomType } from '@rocket.chat/apps-engine/definition/rooms'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import type { AppManager } from '../AppManager'; + +const PrivateManager = Symbol('RoomPrivateManager'); + +export class Room implements IRoom { + public id: string; + + public displayName?: string; + + public slugifiedName: string; + + public type: RoomType; + + public creator: IUser; + + public isDefault?: boolean; + + public isReadOnly?: boolean; + + public displaySystemMessages?: boolean; + + public messageCount?: number; + + public createdAt?: Date; + + public updatedAt?: Date; + + public lastModifiedAt?: Date; + + public customFields?: { [key: string]: any }; + + public userIds?: Array; + + private _USERNAMES: Array; + + private [PrivateManager]: AppManager; + + /** + * @deprecated + */ + public get usernames(): Array { + // Get usernames + if (!this._USERNAMES) { + this._USERNAMES = this[PrivateManager].getBridges().getInternalBridge().doGetUsernamesOfRoomByIdSync(this.id); + } + + return this._USERNAMES; + } + + public set usernames(usernames) {} + + public constructor(room: IRoom, manager: AppManager) { + Object.assign(this, room); + + Object.defineProperty(this, PrivateManager, { + configurable: false, + enumerable: false, + writable: false, + value: manager, + }); + } + + get value(): object { + return { + id: this.id, + displayName: this.displayName, + slugifiedName: this.slugifiedName, + type: this.type, + creator: this.creator, + isDefault: this.isDefault, + isReadOnly: this.isReadOnly, + displaySystemMessages: this.displaySystemMessages, + messageCount: this.messageCount, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + lastModifiedAt: this.lastModifiedAt, + customFields: this.customFields, + userIds: this.userIds, + }; + } + + public async getUsernames(): Promise> { + // Get usernames + if (!this._USERNAMES) { + this._USERNAMES = await this[PrivateManager].getBridges().getInternalBridge().doGetUsernamesOfRoomById(this.id); + } + + return this._USERNAMES; + } + + public toJSON() { + return this.value; + } + + public toString() { + return this.value; + } + + public valueOf() { + return this.value; + } +} diff --git a/packages/apps/src/server/runtime/AppsEngineEmptyRuntime.ts b/packages/apps/src/server/runtime/AppsEngineEmptyRuntime.ts new file mode 100644 index 0000000000000..14040905b40c8 --- /dev/null +++ b/packages/apps/src/server/runtime/AppsEngineEmptyRuntime.ts @@ -0,0 +1,22 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App'; + +import type { IAppsEngineRuntimeOptions } from './AppsEngineRuntime'; +import { AppsEngineRuntime } from './AppsEngineRuntime'; + +export class AppsEngineEmptyRuntime extends AppsEngineRuntime { + public static async runCode(_code: string, _sandbox?: Record, _options?: IAppsEngineRuntimeOptions): Promise { + throw new Error('Empty runtime does not support code execution'); + } + + public static runCodeSync(_code: string, _sandbox?: Record, _options?: IAppsEngineRuntimeOptions): any { + throw new Error('Empty runtime does not support code execution'); + } + + constructor(readonly app: App) { + super(app, () => {}); + } + + public async runInSandbox(_code: string, _sandbox?: Record, _options?: IAppsEngineRuntimeOptions): Promise { + return Promise.reject(new Error('Empty runtime does not support execution')); + } +} diff --git a/packages/apps/src/server/runtime/AppsEngineNodeRuntime.ts b/packages/apps/src/server/runtime/AppsEngineNodeRuntime.ts new file mode 100644 index 0000000000000..b0cba60c912c1 --- /dev/null +++ b/packages/apps/src/server/runtime/AppsEngineNodeRuntime.ts @@ -0,0 +1,75 @@ +import * as timers from 'timers'; +import * as vm from 'vm'; + +import type { App } from '@rocket.chat/apps-engine/definition/App'; + +import type { IAppsEngineRuntimeOptions } from './AppsEngineRuntime'; +import { APPS_ENGINE_RUNTIME_DEFAULT_TIMEOUT, AppsEngineRuntime, getFilenameForApp } from './AppsEngineRuntime'; + +export class AppsEngineNodeRuntime extends AppsEngineRuntime { + public static defaultRuntimeOptions = { + timeout: APPS_ENGINE_RUNTIME_DEFAULT_TIMEOUT, + }; + + public static defaultContext = { + ...timers, + Buffer, + console, + process: {}, + exports: {}, + }; + + public static async runCode(code: string, sandbox?: Record, options?: IAppsEngineRuntimeOptions): Promise { + return new Promise((resolve, reject) => { + process.nextTick(() => { + try { + resolve(this.runCodeSync(code, sandbox, options)); + } catch (e) { + reject(e); + } + }); + }); + } + + public static runCodeSync(code: string, sandbox?: Record, options?: IAppsEngineRuntimeOptions): any { + return vm.runInNewContext( + code, + { ...AppsEngineNodeRuntime.defaultContext, ...sandbox }, + { ...AppsEngineNodeRuntime.defaultRuntimeOptions, ...(options || {}) }, + ); + } + + constructor( + private readonly app: App, + private readonly customRequire: (mod: string) => any, + ) { + super(app, customRequire); + } + + public async runInSandbox(code: string, sandbox?: Record, options?: IAppsEngineRuntimeOptions): Promise { + return new Promise((resolve, reject) => { + process.nextTick(async () => { + try { + sandbox ??= {}; + + const result = await vm.runInNewContext( + code, + { + ...AppsEngineNodeRuntime.defaultContext, + ...sandbox, + require: this.customRequire, + }, + { + ...AppsEngineNodeRuntime.defaultRuntimeOptions, + filename: getFilenameForApp(options?.filename || this.app.getName()), + }, + ); + + resolve(result); + } catch (e) { + reject(e); + } + }); + }); + } +} diff --git a/packages/apps/src/server/runtime/AppsEngineRuntime.ts b/packages/apps/src/server/runtime/AppsEngineRuntime.ts new file mode 100644 index 0000000000000..d8e419b0ff3c8 --- /dev/null +++ b/packages/apps/src/server/runtime/AppsEngineRuntime.ts @@ -0,0 +1,29 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App'; + +export const APPS_ENGINE_RUNTIME_DEFAULT_TIMEOUT = 1000; + +export const APPS_ENGINE_RUNTIME_FILE_PREFIX = '$RocketChat_App$'; + +export function getFilenameForApp(filename: string): string { + return `${APPS_ENGINE_RUNTIME_FILE_PREFIX}_${filename}`; +} + +export abstract class AppsEngineRuntime { + public static async runCode(_code: string, _sandbox?: Record, _options?: IAppsEngineRuntimeOptions): Promise { + throw new Error(`Can't call this method on abstract class. Override it in a proper runtime class.`); + } + + public static runCodeSync(_code: string, _sandbox?: Record, _options?: IAppsEngineRuntimeOptions): any { + throw new Error(`Can't call this method on abstract class. Override it in a proper runtime class.`); + } + + constructor(_app: App, _customRequire: (module: string) => any) {} + + public abstract runInSandbox(code: string, sandbox?: Record, options?: IAppsEngineRuntimeOptions): Promise; +} + +export interface IAppsEngineRuntimeOptions { + timeout?: number; + filename?: string; + returnAllExports?: boolean; +} diff --git a/packages/apps/src/server/runtime/EmptyRuntime.ts b/packages/apps/src/server/runtime/EmptyRuntime.ts new file mode 100644 index 0000000000000..6c7b11a778c28 --- /dev/null +++ b/packages/apps/src/server/runtime/EmptyRuntime.ts @@ -0,0 +1,51 @@ +import { EventEmitter } from 'events'; + +import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; + +import type { IRuntimeController, RuntimeRequestOptions } from './IRuntimeController'; + +export class EmptyRuntime extends EventEmitter implements IRuntimeController { + private readonly appId: string; + + constructor(appId: string) { + super(); + this.appId = appId; + } + + /** + * Returns a disabled status since this is an empty runtime + */ + public async getStatus(): Promise { + return Promise.resolve(AppStatus.COMPILER_ERROR_DISABLED); + } + + /** + * Stub implementation that throws an error since this runtime cannot handle requests + */ + public async sendRequest(message: { method: string; params: any[] }, _options?: RuntimeRequestOptions): Promise { + throw new Error(`EmptyRuntime cannot handle requests. Method: ${message.method}`); + } + + /** + * Stub implementation for setting up the runtime + */ + public async setupApp(): Promise { + // Nothing to setup in an empty runtime + return Promise.resolve(); + } + + /** + * Stub implementation for stopping the runtime + */ + public async stopApp(): Promise { + // Nothing to stop in an empty runtime + return Promise.resolve(); + } + + /** + * Get the app ID associated with this runtime + */ + public getAppId(): string { + return this.appId; + } +} diff --git a/packages/apps/src/server/runtime/IRuntimeController.ts b/packages/apps/src/server/runtime/IRuntimeController.ts new file mode 100644 index 0000000000000..69791e25e0ab8 --- /dev/null +++ b/packages/apps/src/server/runtime/IRuntimeController.ts @@ -0,0 +1,34 @@ +import type { EventEmitter } from 'events'; + +import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; + +export type RuntimeRequestOptions = { + timeout: number; +}; + +export interface IRuntimeController extends EventEmitter { + /** + * Get the current status of the app runtime + */ + getStatus(): Promise; + + /** + * Send a request to the app runtime + */ + sendRequest(message: { method: string; params: any[] }, options?: RuntimeRequestOptions): Promise; + + /** + * Setup the app runtime + */ + setupApp(): Promise; + + /** + * Stop the app runtime + */ + stopApp(): Promise; + + /** + * Get the app ID associated with this runtime + */ + getAppId(): string; +} diff --git a/packages/apps/src/server/runtime/deno/AppsEngineDenoRuntime.ts b/packages/apps/src/server/runtime/deno/AppsEngineDenoRuntime.ts new file mode 100644 index 0000000000000..28cb60027da15 --- /dev/null +++ b/packages/apps/src/server/runtime/deno/AppsEngineDenoRuntime.ts @@ -0,0 +1,735 @@ +import * as child_process from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { type Readable, EventEmitter } from 'stream'; +import { inspect as utilInspect } from 'util'; + +import { AppStatus, AppStatusUtils } from '@rocket.chat/apps-engine/definition/AppStatus'; +import type { AppMethod } from '@rocket.chat/apps-engine/definition/metadata'; +import debugFactory from 'debug'; +import * as jsonrpc from 'jsonrpc-lite'; + +import { LivenessManager } from './LivenessManager'; +import { ProcessMessenger } from './ProcessMessenger'; +import { bundleLegacyApp } from './bundler'; +import { newDecoder } from './codec'; +import type { AppManager } from '../../AppManager'; +import type { AppBridges } from '../../bridges'; +import type { IParseAppPackageResult } from '../../compiler'; +import { AppConsole, type ILoggerStorageEntry } from '../../logging'; +import type { AppAccessorManager, AppApiManager } from '../../managers'; +import type { AppLogStorage, IAppStorageItem } from '../../storage'; +import type { IRuntimeController } from '../IRuntimeController'; + +const baseDebug = debugFactory('appsEngine:runtime:deno'); + +const inspect = (value: unknown) => utilInspect(value, { depth: 10, compact: true, breakLength: Infinity }); + +export const ALLOWED_ACCESSOR_METHODS = [ + 'getConfigurationExtend', + 'getEnvironmentRead', + 'getEnvironmentWrite', + 'getConfigurationModify', + 'getReader', + 'getPersistence', + 'getHttp', + 'getModifier', +] as Array< + keyof Pick< + AppAccessorManager, + | 'getConfigurationExtend' + | 'getEnvironmentRead' + | 'getEnvironmentWrite' + | 'getConfigurationModify' + | 'getReader' + | 'getPersistence' + | 'getHttp' + | 'getModifier' + > +>; + +// Trying to access environment variables in Deno throws an error where in vm2 it simply returned `undefined` +// So here we define the allowed envvars to prevent the process (and the compatibility) from breaking +export const ALLOWED_ENVIRONMENT_VARIABLES = [ + 'NODE_EXTRA_CA_CERTS', // Accessed by the `https` node module +]; + +const COMMAND_PONG = '_zPONG'; + +export const JSONRPC_METHOD_NOT_FOUND = -32601; + +export function getRuntimeTimeout() { + const defaultTimeout = 30000; + const envValue = isFinite(process.env.APPS_ENGINE_RUNTIME_TIMEOUT as any) + ? Number(process.env.APPS_ENGINE_RUNTIME_TIMEOUT) + : defaultTimeout; + + if (envValue < 0) { + console.log('Environment variable APPS_ENGINE_RUNTIME_TIMEOUT has a negative value, ignoring...'); + return defaultTimeout; + } + + return envValue; +} + +export function isValidOrigin(accessor: string): accessor is (typeof ALLOWED_ACCESSOR_METHODS)[number] { + return ALLOWED_ACCESSOR_METHODS.includes(accessor as any); +} + +export function getDenoConfigPath(): string { + try { + // This path is relative to the compiled version of the Apps-Engine source + return require.resolve('../../../deno-runtime/deno.jsonc'); + } catch { + // This path is relative to the original Apps-Engine files - used during tests + return require.resolve('../../../../deno-runtime/deno.jsonc'); + } +} + +type AbortFunction = (reason?: any) => void; + +export class DenoRuntimeSubprocessController extends EventEmitter implements IRuntimeController { + private deno: child_process.ChildProcess | undefined; + + private state: 'uninitialized' | 'ready' | 'invalid' | 'restarting' | 'unknown' | 'stopped'; + + /** + * Incremental id that keeps track of how many times we've spawned a process for this app + */ + private spawnId = 0; + + private readonly debug: debug.Debugger; + + private readonly options = { + timeout: getRuntimeTimeout(), + }; + + private readonly accessors: AppAccessorManager; + + private readonly api: AppApiManager; + + private readonly logStorage: AppLogStorage; + + private readonly bridges: AppBridges; + + private readonly messenger: ProcessMessenger; + + private readonly livenessManager: LivenessManager; + + private readonly tempFilePath: string; + + private readonly denoRuntimePath: string; + + private readonly denoConfigPath: string; + + constructor( + manager: AppManager, + // We need to keep the appSource around in case the Deno process needs to be restarted + private readonly appPackage: IParseAppPackageResult, + private readonly storageItem: IAppStorageItem, + ) { + super(); + + this.tempFilePath = manager.getTempFilePath(); + this.denoRuntimePath = path.join(this.tempFilePath, 'deno-runtime', 'main.ts'); + this.denoConfigPath = getDenoConfigPath(); + + /** + * Deno 2.x refuses to run scripts inside the node_modules, so we create a symlink to the deno runtime files in the temp directory + * The temp directory is the same we are given by the host to store temporary upload files + */ + try { + fs.symlinkSync(path.dirname(this.denoConfigPath), path.dirname(this.denoRuntimePath), 'dir'); + } catch (reason: unknown) { + if ((reason as NodeJS.ErrnoException).code !== 'EEXIST') { + throw reason; + } + } + + this.debug = baseDebug.extend(appPackage.info.id); + this.messenger = new ProcessMessenger(); + this.livenessManager = new LivenessManager({ + controller: this, + messenger: this.messenger, + debug: this.debug, + }); + + this.state = 'uninitialized'; + + this.accessors = manager.getAccessorManager(); + this.api = manager.getApiManager(); + this.logStorage = manager.getLogStorage(); + this.bridges = manager.getBridges(); + } + + public spawnProcess(): void { + try { + const denoExePath = 'deno'; + + const denoWrapperPath = this.denoRuntimePath; + // During development, the appsEngineDir is enough to run the deno process + const appsEngineDir = path.dirname(path.join(this.denoConfigPath, '..')); + const DENO_DIR = process.env.DENO_DIR ?? path.join(appsEngineDir, '.deno-cache'); + // When running in production, we're likely inside a node_modules which the Deno + // process must be able to read in order to include files that use NPM packages + const parentNodeModulesDir = path.dirname(path.join(appsEngineDir, '..')); + + const allowedDirs = [appsEngineDir, parentNodeModulesDir, this.tempFilePath]; + + const options = [ + 'run', + '--cached-only', + `--config=${this.denoConfigPath}`, + `--allow-read=${allowedDirs.join(',')}`, + `--allow-env=${ALLOWED_ENVIRONMENT_VARIABLES.join(',')}`, + denoWrapperPath, + '--subprocess', + this.appPackage.info.id, + '--spawnId', + String(this.spawnId++), + ]; + + // If the app doesn't request any permissions, it gets the default set of permissions, which includes "networking" + // If the app requests specific permissions, we need to check whether it requests "networking" or not + if (this.appPackage.info.permissions?.findIndex((p) => p.name === 'networking') !== -1) { + options.splice(1, 0, '--allow-net'); + } + + const environment = { + env: { + // We need to pass the PATH, otherwise the shell won't find the deno executable + // But the runtime itself won't have access to the env var because of the parameters + PATH: process.env.PATH, + DENO_DIR, + }, + }; + + // SECURITY: We control the command, the arguments and the script that will be executed. + this.deno = child_process.spawn(denoExePath, options, environment); + this.messenger.setReceiver(this.deno); + this.livenessManager.attach(this.deno); + + this.debug('Started subprocess %d with options %s and env %s', this.deno.pid, inspect(options), inspect(environment)); + + this.setupListeners(); + } catch (e) { + this.state = 'invalid'; + console.error(`Failed to start Deno subprocess for app ${this.getAppId()}`, e); + } + } + + /** + * Attempts to kill the process currently controlled by this.deno + * + * @returns boolean - if a process has been killed or not + */ + public async killProcess(): Promise { + if (!this.deno) { + this.debug('No child process reference'); + return false; + } + + let { killed } = this.deno; + + // This field is not populated if the process is killed by the OS + if (killed) { + this.debug('App process was already killed'); + return killed; + } + + // What else should we do? + if (this.deno.kill('SIGKILL')) { + // Let's wait until we get confirmation the process exited + await new Promise((r) => this.deno.on('exit', r)); + killed = true; + } else { + this.debug('Tried killing the process but failed. Was it already dead?'); + killed = false; + } + + delete this.deno; + this.messenger.clearReceiver(); + return killed; + } + + // Debug purposes, could be deleted later + emit(eventName: string | symbol, ...args: any[]): boolean { + const hadListeners = super.emit(eventName, ...args); + + if (!hadListeners) { + this.debug('Emitted but no one listened: ', eventName, args); + } + + return hadListeners; + } + + public getProcessState() { + return this.state; + } + + public async getStatus(): Promise { + // If the process has been terminated, we can't get the status + if (this.deno?.exitCode !== null) { + return AppStatus.UNKNOWN; + } + + return this.sendRequest({ method: 'app:getStatus', params: [] }) as Promise; + } + + public async setupApp() { + this.debug('Setting up app subprocess'); + this.spawnProcess(); + + // If there is more than one file in the package, then it is a legacy app that has not been bundled + if (Object.keys(this.appPackage.files).length > 1) { + await bundleLegacyApp(this.appPackage); + } + + await this.waitUntilReady(); + + await this.sendRequest({ method: 'app:construct', params: [this.appPackage] }); + + this.emit('constructed'); + } + + public async stopApp() { + this.debug('Stopping app subprocess'); + + this.state = 'stopped'; + + await this.killProcess(); + } + + public async restartApp() { + this.debug('Restarting app subprocess'); + const logger = new AppConsole('runtime:restart'); + + logger.info({ msg: 'Starting restart procedure for app subprocess...', runtimeData: this.livenessManager.getRuntimeData() }); + + this.state = 'restarting'; + + try { + const pid = this.deno?.pid; + + const hasKilled = await this.killProcess(); + + if (hasKilled) { + logger.debug({ msg: 'Process successfully terminated', pid }); + } else { + logger.warn({ msg: 'Could not terminate process. Maybe it was already dead?', pid }); + } + + await this.setupApp(); + logger.info({ msg: 'New subprocess successfully spawned', pid: this.deno.pid }); + + // setupApp() changes the state to 'ready' - we'll need to workaround that for now + this.state = 'restarting'; + + await this.sendRequest({ method: 'app:initialize' }); + await this.sendRequest({ method: 'app:setStatus', params: [this.storageItem.status] }); + + if (AppStatusUtils.isEnabled(this.storageItem.status)) { + await this.sendRequest({ method: 'app:onEnable' }); + } + + this.state = 'ready'; + + logger.info('Successfully restarted app subprocess'); + } catch (e) { + logger.error({ msg: "Failed to restart app's subprocess", err: e }); + throw e; + } finally { + await this.logStorage.storeEntries(AppConsole.toStorageEntry(this.getAppId(), logger)); + } + } + + public getAppId(): string { + return this.appPackage.info.id; + } + + public async sendRequest(message: Pick, options = this.options): Promise { + const id = String(Math.random().toString(36)).substring(2); + + const start = Date.now(); + + const request = jsonrpc.request(id, message.method, message.params); + + const { promise, abort } = this.waitForResponse(request, options); + + try { + this.debug('Sending message to subprocess %s', inspect(message)); + this.messenger.send(request); + } catch (e) { + abort(e); + } + + return promise.finally(() => { + this.debug('Request %s for method %s took %dms', id, message.method, Date.now() - start); + }); + } + + private waitUntilReady(): Promise { + if (this.state === 'ready') { + return; + } + + return new Promise((resolve, reject) => { + let timeoutId: NodeJS.Timeout; + + const handler = () => { + clearTimeout(timeoutId); + resolve(); + }; + + timeoutId = setTimeout(() => { + this.off('ready', handler); + reject(new Error(`[${this.getAppId()}] Timeout: app process not ready`)); + }, this.options.timeout); + + this.once('ready', handler); + }); + } + + private waitForResponse(req: jsonrpc.RequestObject, options = this.options): { abort: AbortFunction; promise: Promise } { + const controller = new AbortController(); + const { abort, signal } = controller; + + return { + abort: abort.bind(controller), + promise: new Promise((resolve, reject) => { + const eventName = `result:${req.id}`; + + const responseCallback = (result: unknown, error: jsonrpc.IParsedObjectError['payload']['error'] | Error) => { + this.off(eventName, responseCallback); + clearTimeout(timeoutId); + + if (error) { + reject(error); + } + + resolve(result); + }; + + const timeoutId = setTimeout( + () => + responseCallback( + undefined, + new Error(`[${this.getAppId()}] Request "${req.id}" for method "${req.method}" timed out after ${options.timeout}ms`), + ), + options.timeout, + ); + + signal.onabort = () => + responseCallback(undefined, signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason))); + + this.once(eventName, responseCallback); + }), + }; + } + + private onReady(): void { + this.state = 'ready'; + } + + /** + * Listeners need to be setup every time the reference + * in `this.deno` changes, i.e. every time the subprocess + * is restarted + */ + private setupListeners(): void { + if (!this.deno) { + return; + } + + this.deno.stderr.on('data', this.parseError.bind(this)); + this.deno.on('error', (err) => { + this.state = 'invalid'; + console.error(`Failed to startup Deno subprocess for app ${this.getAppId()}`, err); + }); + + this.deno.once('exit', (code) => this.emit('processExit', code)); + + this.once('ready', this.onReady.bind(this)); + + void this.parseStdout(this.deno.stdout); + } + + // Probable should extract this to a separate file + private async handleAccessorMessage({ payload: { method, id, params } }: jsonrpc.IParsedObjectRequest): Promise { + const accessorMethods = method.substring(9).split(':'); // First 9 characters are always 'accessor:' + + this.debug('Handling accessor message %s with params %s', inspect(accessorMethods), inspect(params)); + + const managerOrigin = accessorMethods.shift(); + const tailMethodName = accessorMethods.pop(); + + // If we're restarting the app, we can't register resources again, so we + // hijack requests for the `ConfigurationExtend` accessor and don't let them through + // This needs to be refactored ASAP + if (this.state === 'restarting' && managerOrigin === 'getConfigurationExtend') { + return jsonrpc.success(id, null); + } + + if (managerOrigin === 'api' && tailMethodName === 'listApis') { + const result = this.api.listApis(this.appPackage.info.id); + + return jsonrpc.success(id, result); + } + + /** + * At this point, the accessorMethods array will contain the path to the accessor from the origin (AppAccessorManager) + * The accessor is the one that contains the actual method the app wants to call + * + * Most of the times, it will take one step from origin to accessor + * For example, for the call AppAccessorManager.getEnvironmentRead().getServerSettings().getValueById() we'll have + * the following: + * + * ``` + * const managerOrigin = 'getEnvironmentRead' + * const tailMethod = 'getValueById' + * const accessorMethods = ['getServerSettings'] + * ``` + * + * But sometimes there can be more steps, like in the following example: + * AppAccessorManager.getReader().getEnvironmentReader().getEnvironmentVariables().getValueByName() + * In this case, we'll have: + * + * ``` + * const managerOrigin = 'getReader' + * const tailMethod = 'getValueByName' + * const accessorMethods = ['getEnvironmentReader', 'getEnvironmentVariables'] + * ``` + **/ + // Prevent app from trying to get properties from the manager that + // are not intended for public access + if (!isValidOrigin(managerOrigin)) { + throw new Error(`Invalid accessor namespace "${managerOrigin}"`); + } + + // Need to fix typing of return value + const getAccessorForOrigin = ( + accessorMethods: string[], + managerOrigin: (typeof ALLOWED_ACCESSOR_METHODS)[number], + accessorManager: AppAccessorManager, + ) => { + const origin = accessorManager[managerOrigin](this.appPackage.info.id); + + if (managerOrigin === 'getHttp' || managerOrigin === 'getPersistence') { + return origin; + } + + if (managerOrigin === 'getConfigurationExtend' || managerOrigin === 'getConfigurationModify') { + return origin[accessorMethods[0] as keyof typeof origin]; + } + + let accessor = origin; + + // Call all intermediary objects to "resolve" the accessor + accessorMethods.forEach((methodName) => { + const method = accessor[methodName as keyof typeof accessor] as unknown; + + if (typeof method !== 'function') { + throw new Error(`Invalid accessor method "${methodName}"`); + } + + accessor = method.apply(accessor); + }); + + return accessor; + }; + + const accessor = getAccessorForOrigin(accessorMethods, managerOrigin, this.accessors); + + const tailMethod = accessor[tailMethodName as keyof typeof accessor] as unknown; + + if (typeof tailMethod !== 'function') { + throw new Error(`Invalid accessor method "${tailMethodName}"`); + } + + const result = await tailMethod.apply(accessor, params); + + return jsonrpc.success(id, typeof result === 'undefined' ? null : result); + } + + private async handleBridgeMessage({ + payload: { method, id, params }, + }: jsonrpc.IParsedObjectRequest): Promise { + const [bridgeName, bridgeMethod] = method.substring(8).split(':'); + + this.debug('Handling bridge message %s().%s() with params %s', bridgeName, bridgeMethod, inspect(params)); + + const bridge = this.bridges[bridgeName as keyof typeof this.bridges]; + + if (!bridgeMethod.startsWith('do') || typeof bridge !== 'function' || !Array.isArray(params)) { + throw new Error('Invalid bridge request'); + } + + const bridgeInstance = bridge.call(this.bridges); + + const methodRef = bridgeInstance[bridgeMethod as keyof typeof bridge] as unknown; + + if (typeof methodRef !== 'function') { + throw new Error('Invalid bridge request'); + } + + let result; + try { + result = await methodRef.apply( + bridgeInstance, + // Should the protocol expect the placeholder APP_ID value or should the Deno process send the actual appId? + // If we do not expect the APP_ID, the Deno process will be able to impersonate other apps, potentially + params.map((value: unknown) => (value === 'APP_ID' ? this.appPackage.info.id : value)), + ); + } catch (error) { + this.debug('Error executing bridge method %s().%s() %s', bridgeName, bridgeMethod, inspect(error.message)); + const jsonRpcError = new jsonrpc.JsonRpcError(error.message, -32000, error); + return jsonrpc.error(id, jsonRpcError); + } + + return jsonrpc.success(id, typeof result === 'undefined' ? null : result); + } + + private async handleIncomingMessage(message: jsonrpc.IParsedObjectNotification | jsonrpc.IParsedObjectRequest): Promise { + const { method } = message.payload; + + if (method.startsWith('accessor:')) { + let result: jsonrpc.SuccessObject | jsonrpc.ErrorObject; + + try { + result = await this.handleAccessorMessage(message as jsonrpc.IParsedObjectRequest); + } catch (e) { + result = jsonrpc.error((message.payload as jsonrpc.RequestObject).id, new jsonrpc.JsonRpcError(e.message, 1000)); + } + + this.messenger.send(result); + + return; + } + + if (method.startsWith('bridges:')) { + let result: jsonrpc.SuccessObject | jsonrpc.ErrorObject; + + try { + result = await this.handleBridgeMessage(message as jsonrpc.IParsedObjectRequest); + } catch (e) { + result = jsonrpc.error((message.payload as jsonrpc.RequestObject).id, new jsonrpc.JsonRpcError(e.message, 1000)); + } + + this.messenger.send(result); + + return; + } + + switch (method) { + case 'ready': + this.emit('ready'); + break; + case 'log': + console.log('SUBPROCESS LOG', message); + break; + case 'unhandledRejection': + case 'uncaughtException': + await this.logUnhandledError(`runtime:${method}`, message); + break; + default: + console.warn('Unrecognized method from sub process'); + break; + } + } + + private async logUnhandledError( + method: `${AppMethod.RUNTIME_UNCAUGHT_EXCEPTION | AppMethod.RUNTIME_UNHANDLED_REJECTION}`, + message: jsonrpc.IParsedObjectRequest | jsonrpc.IParsedObjectNotification, + ) { + this.debug('Unhandled error of type "%s" caught in subprocess', method); + + const logger = new AppConsole(method); + logger.error(message.payload); + + await this.logStorage.storeEntries(AppConsole.toStorageEntry(this.getAppId(), logger)); + } + + private async handleResultMessage(message: jsonrpc.IParsedObjectError | jsonrpc.IParsedObjectSuccess): Promise { + const { id } = message.payload; + + let result: unknown; + let error: jsonrpc.IParsedObjectError['payload']['error'] | undefined; + let logs: ILoggerStorageEntry; + + if (message.type === 'success') { + const params = message.payload.result as { value: unknown; logs?: ILoggerStorageEntry }; + result = params.value; + logs = params.logs; + } else { + error = message.payload.error; + logs = message.payload.error.data?.logs as ILoggerStorageEntry; + } + + // Should we try to make sure all result messages have logs? + if (logs) { + await this.logStorage.storeEntries(logs); + } + + this.emit(`result:${id}`, result, error); + } + + private async parseStdout(stream: Readable): Promise { + try { + for await (const message of newDecoder().decodeStream(stream)) { + this.debug('Received message from subprocess %s', inspect(message)); + try { + // Process PONG resonse first as it is not JSON RPC + if (message === COMMAND_PONG) { + this.emit('pong'); + continue; + } + + const JSONRPCMessage = jsonrpc.parseObject(message); + + if (Array.isArray(JSONRPCMessage)) { + throw new Error('Invalid message format'); + } + + this.emit('heartbeat'); + + if (JSONRPCMessage.type === 'request' || JSONRPCMessage.type === 'notification') { + this.handleIncomingMessage(JSONRPCMessage).catch((reason) => + console.error(`[${this.getAppId()}] Error executing handler`, reason, message), + ); + continue; + } + + if (JSONRPCMessage.type === 'success' || JSONRPCMessage.type === 'error') { + this.handleResultMessage(JSONRPCMessage).catch((reason) => + console.error(`[${this.getAppId()}] Error executing handler`, reason, message), + ); + continue; + } + + console.error('Unrecognized message type', JSONRPCMessage); + } catch (e) { + // SyntaxError is thrown when the message is not a valid JSON + if (e instanceof SyntaxError) { + console.error(`[${this.getAppId()}] Failed to parse message`); + continue; + } + + console.error(`[${this.getAppId()}] Error executing handler`, e, message); + } + } + } catch (e) { + console.error(`[${this.getAppId()}]`, e); + this.emit('error', new Error('DECODE_ERROR')); + } + } + + private async parseError(chunk: Buffer): Promise { + try { + const data = JSON.parse(chunk.toString()); + + this.debug('Metrics received from subprocess (via stderr): %s', inspect(data)); + } catch { + console.error('Subprocess stderr', chunk.toString()); + } + } +} diff --git a/packages/apps/src/server/runtime/deno/LivenessManager.ts b/packages/apps/src/server/runtime/deno/LivenessManager.ts new file mode 100644 index 0000000000000..ae9e843add7ca --- /dev/null +++ b/packages/apps/src/server/runtime/deno/LivenessManager.ts @@ -0,0 +1,254 @@ +import type { ChildProcess } from 'child_process'; +import { EventEmitter } from 'stream'; + +import type { DenoRuntimeSubprocessController } from './AppsEngineDenoRuntime'; +import type { ProcessMessenger } from './ProcessMessenger'; + +export const COMMAND_PING = '_zPING'; + +const defaultOptions: LivenessManager['options'] = { + pingTimeoutInMS: 1000, + pingIntervalInMS: 10000, + consecutiveTimeoutLimit: 4, + maxRestarts: Infinity, + restartAttemptDelayInMS: 1000, +}; + +/** + * Responsible for pinging the Deno subprocess and for restarting it + * if something doesn't look right + */ +export class LivenessManager { + private readonly controller: DenoRuntimeSubprocessController; + + private readonly messenger: ProcessMessenger; + + private readonly debug: debug.Debugger; + + private readonly options: { + // How long should we wait for a response to the ping request + pingTimeoutInMS: number; + + // How long is the delay between ping messages + pingIntervalInMS: number; + + // Limit of times the process can timeout the ping response before we consider it as unresponsive + consecutiveTimeoutLimit: number; + + // Limit of times we can try to restart a process + maxRestarts: number; + + // Time to delay the next restart attempt after a failed one + restartAttemptDelayInMS: number; + }; + + private subprocess: ChildProcess; + + private watchdogTimeout: NodeJS.Timeout | null = null; + + private lastHeartbeatTimestamp = NaN; + + // A promise tracking the current ping process - used mostly for testing + private pendingPing: Promise | null; + + // This is the perfect use-case for an AbortController, but it's experimental in Node 14.x + private pingAbortController: EventEmitter; + + private pingTimeoutConsecutiveCount = 0; + + private restartCount = 0; + + private restartLog: Record[] = []; + + constructor( + deps: { + controller: DenoRuntimeSubprocessController; + messenger: ProcessMessenger; + debug: debug.Debugger; + }, + options: Partial = {}, + ) { + this.controller = deps.controller; + this.messenger = deps.messenger; + this.debug = deps.debug; + this.pingAbortController = new EventEmitter(); + + this.options = Object.assign({}, defaultOptions, options); + + this.controller.on('heartbeat', () => { + this.lastHeartbeatTimestamp = Date.now(); + this.pingTimeoutConsecutiveCount = 0; + }); + + this.controller.on('error', async (reason) => { + if (reason instanceof Error && reason.message.startsWith('DECODE_ERROR')) { + await this.restartProcess('Decode error', 'controller'); + } + }); + } + + public getRuntimeData() { + const { lastHeartbeatTimestamp, restartCount, pingTimeoutConsecutiveCount, restartLog } = this; + + return { + lastHeartbeatTimestamp, + restartCount, + pingTimeoutConsecutiveCount, + restartLog, + }; + } + + public attach(deno: ChildProcess) { + this.subprocess = deno; + + this.pingTimeoutConsecutiveCount = 0; + + this.subprocess.once('exit', this.handleExit.bind(this)); + this.subprocess.once('error', this.handleError.bind(this)); + + this.controller.once('constructed', this.start.bind(this)); + } + + public start() { + this.lastHeartbeatTimestamp = Date.now(); + + this.watchdogTimeout = setInterval(() => { + if (Date.now() - this.lastHeartbeatTimestamp < this.options.pingIntervalInMS) { + return; + } + + try { + this.ping(); + } catch { + // If the ping call fails synchronously, it's because we couldn't send the ping message + // then likely the process isn't running, so we stop everything + this.debug('[LivenessManager] Failed to send ping to subprocess, stopping watchdog...'); + this.stop(); + } + }, this.options.pingIntervalInMS); + + this.watchdogTimeout.unref(); + } + + public stop() { + this.pingAbortController.emit('abort'); + clearInterval(this.watchdogTimeout); + this.watchdogTimeout = null; + this.pendingPing = null; + } + + public getPendingPing() { + return this.pendingPing; + } + + /** + * Start up the process of ping/pong for liveness check + * + * The message exchange does not use JSON RPC as it adds a lot of overhead + * with the creation and encoding of a full object for transfer. By using a + * string the process is less intensive. + */ + private ping() { + const start = Date.now(); + + this.pendingPing = new Promise((resolve, reject) => { + const onceCallback = () => { + const now = Date.now(); + this.debug('Ping successful in %d ms', now - start); + clearTimeout(timeoutId); + this.pingTimeoutConsecutiveCount = 0; + this.lastHeartbeatTimestamp = now; + resolve(true); + }; + + const timeoutCallback = () => { + this.debug('Ping failed in %d ms (consecutive failure #%d)', Date.now() - start, this.pingTimeoutConsecutiveCount); + this.controller.off('pong', onceCallback); + this.pingTimeoutConsecutiveCount++; + reject('timeout'); + }; + + this.pingAbortController.once('abort', () => { + this.debug('Ping aborted'); + reject('abort'); + }); + + const timeoutId = setTimeout(timeoutCallback, this.options.pingTimeoutInMS); + + this.controller.once('pong', onceCallback); + }) + .catch((reason) => { + if (reason === 'abort') { + return false; + } + + if (reason === 'timeout' && this.pingTimeoutConsecutiveCount >= this.options.consecutiveTimeoutLimit) { + this.debug( + 'Subprocess failed to respond to pings %d consecutive times. Attempting restart...', + this.options.consecutiveTimeoutLimit, + ); + void this.restartProcess('Too many pings timed out'); + return false; + } + + return true; + }) + .finally(() => { + this.pingAbortController.removeAllListeners('abort'); + }); + + this.messenger.send(COMMAND_PING); + } + + private handleError(err: Error) { + this.debug('App has failed to start.`', err); + void this.restartProcess(err.message); + } + + private handleExit(exitCode: number, signal: string) { + const processState = this.controller.getProcessState(); + // If the we're restarting the process, or want to stop the process, or it exited cleanly, nothing else for us to do + if (processState === 'restarting' || processState === 'stopped' || (exitCode === 0 && !signal)) { + return; + } + + let reason: string; + + // Otherwise we attempt to restart the process + if (signal) { + this.debug('App has been killed (%s). Attempting restart #%d...', signal, this.restartCount + 1); + reason = `App has been killed with signal ${signal}`; + } else { + this.debug('App has exited with code %d. Attempting restart #%d...', exitCode, this.restartCount + 1); + reason = `App has exited with code ${exitCode}`; + } + + void this.restartProcess(reason); + } + + private async restartProcess(reason: string, source = 'liveness-manager') { + this.stop(); + + if (this.restartCount >= this.options.maxRestarts) { + this.debug('Limit of restarts reached (%d). Aborting restart...', this.options.maxRestarts); + void this.controller.stopApp(); + return; + } + + this.restartLog.push({ + reason, + source, + restartedAt: new Date(), + pid: this.subprocess.pid, + }); + + try { + await this.controller.restartApp(); + } catch { + this.debug('Restart attempt failed. Retrying in %dms', this.options.restartAttemptDelayInMS); + setTimeout(() => void this.restartProcess('Failed restart attempt'), this.options.restartAttemptDelayInMS); + } + + this.restartCount++; + } +} diff --git a/packages/apps/src/server/runtime/deno/ProcessMessenger.ts b/packages/apps/src/server/runtime/deno/ProcessMessenger.ts new file mode 100644 index 0000000000000..c5c2394e56dfa --- /dev/null +++ b/packages/apps/src/server/runtime/deno/ProcessMessenger.ts @@ -0,0 +1,57 @@ +import type { ChildProcess } from 'child_process'; + +import type { JsonRpc } from 'jsonrpc-lite'; + +import type { COMMAND_PING } from './LivenessManager'; +import type { Encoder } from './codec'; +import { newEncoder } from './codec'; + +type Message = JsonRpc | typeof COMMAND_PING; + +export class ProcessMessenger { + private deno: ChildProcess | undefined; + + private encoder: Encoder | undefined; + + private _sendStrategy: (message: Message) => void; + + constructor() { + this._sendStrategy = this.strategyError; + } + + public send(message: Message) { + this._sendStrategy(message); + } + + public setReceiver(deno: ChildProcess) { + this.deno = deno; + + this.switchStrategy(); + } + + public clearReceiver() { + delete this.deno; + delete this.encoder; + + this.switchStrategy(); + } + + private switchStrategy() { + if (this.deno?.stdin?.writable) { + this._sendStrategy = this.strategySend.bind(this); + + // Get a clean encoder + this.encoder = newEncoder(); + } else { + this._sendStrategy = this.strategyError.bind(this); + } + } + + private strategyError(_message: Message) { + throw new Error('No process configured to receive a message'); + } + + private strategySend(message: Message) { + this.deno.stdin.write(this.encoder.encode(message)); + } +} diff --git a/packages/apps/src/server/runtime/deno/bundler.ts b/packages/apps/src/server/runtime/deno/bundler.ts new file mode 100644 index 0000000000000..e6442a336b83c --- /dev/null +++ b/packages/apps/src/server/runtime/deno/bundler.ts @@ -0,0 +1,90 @@ +import * as path from 'path'; + +import { build, type PluginBuild, type OnLoadArgs, type OnResolveArgs } from 'esbuild'; + +import type { IParseAppPackageResult } from '../../compiler'; + +/** + * Some legacy apps that might be installed in workspaces have not been bundled after compilation, + * leading to multiple files being sent to the subprocess and requiring further logic to require one another. + * This makes running the app in the Deno Runtime much more difficult, so instead we bundle the files at runtime. + */ +export async function bundleLegacyApp(appPackage: IParseAppPackageResult) { + const buildResult = await build({ + write: false, + bundle: true, + minify: true, + platform: 'node', + target: ['node10'], + define: { + 'global.Promise': 'Promise', + }, + external: ['@rocket.chat/apps-engine/*'], + stdin: { + contents: appPackage.files[appPackage.info.classFile], + sourcefile: appPackage.info.classFile, + loader: 'js', + }, + plugins: [ + { + name: 'legacy-app', + setup(build: PluginBuild) { + build.onResolve({ filter: /.*/ }, (args: OnResolveArgs) => { + if (args.namespace === 'file') { + return; + } + + const modulePath = path.join(path.dirname(args.importer), args.path).concat('.js'); + + const hasFile = !!appPackage.files[modulePath]; + + if (hasFile) { + return { + namespace: 'app-source', + path: modulePath, + }; + } + + // require('../') or require('./') are both valid, but aren't included in the files record in the same way + // we need to treat those differently + if (/\.\.?\//.test(args.path)) { + const indexModulePath = modulePath.replace(/\.js$/, `${path.sep}index.js`); + + if (appPackage.files[indexModulePath]) { + return { + namespace: 'app-source', + path: indexModulePath, + }; + } + } + + return { + path: args.path, + external: true, + }; + }); + + build.onLoad({ filter: /.*/, namespace: 'app-source' }, (args: OnLoadArgs) => { + if (!appPackage.files[args.path]) { + return { + errors: [ + { + text: `File ${args.path} could not be found`, + }, + ], + }; + } + + return { + contents: appPackage.files[args.path], + }; + }); + }, + }, + ], + }); + + const [{ text: bundle }] = buildResult.outputFiles; + + appPackage.files = { [appPackage.info.classFile]: bundle }; +} diff --git a/packages/apps/src/server/runtime/deno/codec.ts b/packages/apps/src/server/runtime/deno/codec.ts new file mode 100644 index 0000000000000..53b05846565ee --- /dev/null +++ b/packages/apps/src/server/runtime/deno/codec.ts @@ -0,0 +1,45 @@ +import { Decoder as _Decoder, Encoder as _Encoder, ExtensionCodec } from '@msgpack/msgpack'; + +const extensionCodec = new ExtensionCodec(); + +extensionCodec.register({ + type: 0, + encode: (object: unknown) => { + // We don't care about functions, but also don't want to throw an error + if (typeof object === 'function') { + return new Uint8Array([0]); + } + }, + + decode: (_data: Uint8Array) => undefined, +}); + +// We need to handle Buffers because Deno needs its own decoding +extensionCodec.register({ + type: 1, + encode: (object: unknown) => { + if (object instanceof Buffer) { + return new Uint8Array(object.buffer, object.byteOffset, object.byteLength); + } + }, + + // msgpack will reuse the Uint8Array instance, so WE NEED to copy it instead of simply creating a view + decode: (data: Uint8Array) => Buffer.from(data), +}); + +/** + * The Encoder and Decoder classes perform "stateful" operations, i.e. they read from a + * stream, store the data locally and decode it from its buffer. + * + * In practice, this affects the decoder when there is decode error. After an error, the decoder + * keeps the malformed data in its buffer, and even if we try to decode from another source (e.g. different stream) + * it will fail again as there's still data in the buffer. + * + * For that reason, we can't have a singleton instance of Encoder and Decoder, but rather one + * instance for each time we create a new subprocess + */ +export const newEncoder = () => new _Encoder({ extensionCodec }); +export const newDecoder = () => new _Decoder({ extensionCodec }); + +export type Encoder = _Encoder; +export type Decoder = _Decoder; diff --git a/packages/apps/src/server/storage/AppLogStorage.ts b/packages/apps/src/server/storage/AppLogStorage.ts new file mode 100644 index 0000000000000..8a3487a5587be --- /dev/null +++ b/packages/apps/src/server/storage/AppLogStorage.ts @@ -0,0 +1,27 @@ +import type { ILoggerStorageEntry } from '../logging'; + +export interface IAppLogStorageFindOptions { + sort?: Record; + skip?: number; + limit?: number; + projection?: Record; +} + +export abstract class AppLogStorage { + constructor(private readonly engine: string) {} + + public getEngine() { + return this.engine; + } + + public abstract findPaginated( + query: { [field: string]: any }, + options?: IAppLogStorageFindOptions, + ): Promise<{ logs: ILoggerStorageEntry[]; total: number }>; + + public abstract storeEntries(logEntry: ILoggerStorageEntry): Promise; + + public abstract getEntriesFor(appId: string): Promise>; + + public abstract removeEntriesFor(appId: string): Promise; +} diff --git a/packages/apps/src/server/storage/AppMetadataStorage.ts b/packages/apps/src/server/storage/AppMetadataStorage.ts new file mode 100644 index 0000000000000..39767839389e5 --- /dev/null +++ b/packages/apps/src/server/storage/AppMetadataStorage.ts @@ -0,0 +1,37 @@ +import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; +import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata/IAppInfo'; +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; + +import type { IAppStorageItem } from './IAppStorageItem'; +import type { IMarketplaceInfo } from '../marketplace'; + +export abstract class AppMetadataStorage { + constructor(private readonly engine: string) {} + + public getEngine() { + return this.engine; + } + + public abstract create(item: IAppStorageItem): Promise; + + public abstract retrieveOne(id: string): Promise; + + public abstract retrieveAll(): Promise>; + + public abstract retrieveAllPrivate(): Promise>; + + public abstract remove(id: string): Promise<{ success: boolean }>; + + public abstract updatePartialAndReturnDocument( + item: Partial, + options?: { unsetPermissionsGranted?: boolean }, + ): Promise; + + public abstract updateStatus(_id: string, status: AppStatus): Promise; + + public abstract updateSetting(_id: string, setting: ISetting): Promise; + + public abstract updateAppInfo(_id: string, info: IAppInfo): Promise; + + public abstract updateMarketplaceInfo(_id: string, marketplaceInfo: IMarketplaceInfo[]): Promise; +} diff --git a/packages/apps/src/server/storage/AppSourceStorage.ts b/packages/apps/src/server/storage/AppSourceStorage.ts new file mode 100644 index 0000000000000..c0c0f8ea61971 --- /dev/null +++ b/packages/apps/src/server/storage/AppSourceStorage.ts @@ -0,0 +1,40 @@ +import type { IAppStorageItem } from './IAppStorageItem'; + +export abstract class AppSourceStorage { + /** + * Stores an app package (zip file) in the underlying + * storage provided by the host + * + * @param item descriptor of the App + * @param zip the app package file contents + * + * @returns the path in which the pacakge has been stored + */ + public abstract store(item: IAppStorageItem, zip: Buffer): Promise; + + /** + * Fetches an app's package file contents + * + * @param item descriptor of the App + * + * @returns buffer containing the file contents of the app's package + */ + public abstract fetch(item: IAppStorageItem): Promise; + + /** + * Updates an app package (zip file) in the underlying + * storage provided by the host + * + * @param item descriptor of the App + * @param zip the app package file contents + * + * @returns the path in which the pacakge has been stored + */ + public abstract update(item: IAppStorageItem, zip: Buffer): Promise; + + /** + * + * @param item descriptor of the App + */ + public abstract remove(item: IAppStorageItem): Promise; +} diff --git a/packages/apps/src/server/storage/IAppStorageItem.ts b/packages/apps/src/server/storage/IAppStorageItem.ts new file mode 100644 index 0000000000000..db44ae5e21a66 --- /dev/null +++ b/packages/apps/src/server/storage/IAppStorageItem.ts @@ -0,0 +1,32 @@ +import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; +import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; +import type { IPermission } from '@rocket.chat/apps-engine/definition/permissions/IPermission'; +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; + +import type { IMarketplaceInfo } from '../marketplace'; + +export interface IAppStorageItem { + _id?: string; + id: string; + createdAt?: Date; + updatedAt?: Date; + status: AppStatus; + info: IAppInfo; + installationSource: AppInstallationSource; + /** + * The path that represents where the source of the app storaged. + */ + sourcePath?: string; + languageContent: { [key: string]: object }; + settings: { [id: string]: ISetting }; + implemented: { [int: string]: boolean }; + marketplaceInfo?: IMarketplaceInfo[]; + permissionsGranted?: Array; + signature?: string; + migrated?: boolean; +} + +export enum AppInstallationSource { + MARKETPLACE = 'marketplace', + PRIVATE = 'private', +} diff --git a/packages/apps/src/server/storage/index.ts b/packages/apps/src/server/storage/index.ts new file mode 100644 index 0000000000000..0c066ecdcf559 --- /dev/null +++ b/packages/apps/src/server/storage/index.ts @@ -0,0 +1,6 @@ +export type { IAppLogStorageFindOptions } from './AppLogStorage'; +export { AppLogStorage } from './AppLogStorage'; +export { AppMetadataStorage } from './AppMetadataStorage'; +export type { IAppStorageItem } from './IAppStorageItem'; +export { AppInstallationSource } from './IAppStorageItem'; +export { AppSourceStorage } from './AppSourceStorage'; From 02c6cc63eef5b6308e73c47ed15c5f923021d5da Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Thu, 16 Apr 2026 13:16:28 -0300 Subject: [PATCH 02/12] feat(apps): copy apps-engine client UI host code into @rocket.chat/apps Copies src/client/ (AppClientManager, AppsEngineUIHost, AppsEngineUIClient) from @rocket.chat/apps-engine, rewriting relative definition/ imports to package imports. This code is a known rough edge: browser-side UI host logic does not semantically belong in a server orchestration package. It is consolidated here for pragmatic simplicity during the apps-engine split. A future @rocket.chat/apps-client package is tracked in the TODO comment added to src/client/index.ts. Co-Authored-By: Claude Sonnet 4.6 --- packages/apps/src/client/AppClientManager.ts | 32 ++++++++ .../apps/src/client/AppServerCommunicator.ts | 16 ++++ .../apps/src/client/AppsEngineUIClient.ts | 70 +++++++++++++++++ packages/apps/src/client/AppsEngineUIHost.ts | 78 +++++++++++++++++++ packages/apps/src/client/constants/index.ts | 6 ++ .../client/definition/AppsEngineUIMethods.ts | 7 ++ .../definition/IAppsEngineUIResponse.ts | 19 +++++ .../definition/IExternalComponentRoomInfo.ts | 17 ++++ .../definition/IExternalComponentUserInfo.ts | 14 ++++ packages/apps/src/client/definition/index.ts | 4 + packages/apps/src/client/index.ts | 4 + packages/apps/src/client/utils/index.ts | 18 +++++ 12 files changed, 285 insertions(+) create mode 100644 packages/apps/src/client/AppClientManager.ts create mode 100644 packages/apps/src/client/AppServerCommunicator.ts create mode 100644 packages/apps/src/client/AppsEngineUIClient.ts create mode 100644 packages/apps/src/client/AppsEngineUIHost.ts create mode 100644 packages/apps/src/client/constants/index.ts create mode 100644 packages/apps/src/client/definition/AppsEngineUIMethods.ts create mode 100644 packages/apps/src/client/definition/IAppsEngineUIResponse.ts create mode 100644 packages/apps/src/client/definition/IExternalComponentRoomInfo.ts create mode 100644 packages/apps/src/client/definition/IExternalComponentUserInfo.ts create mode 100644 packages/apps/src/client/definition/index.ts create mode 100644 packages/apps/src/client/index.ts create mode 100644 packages/apps/src/client/utils/index.ts diff --git a/packages/apps/src/client/AppClientManager.ts b/packages/apps/src/client/AppClientManager.ts new file mode 100644 index 0000000000000..6f3c2f4978dba --- /dev/null +++ b/packages/apps/src/client/AppClientManager.ts @@ -0,0 +1,32 @@ +import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; + +import { AppServerCommunicator } from './AppServerCommunicator'; +import { AppsEngineUIHost } from './AppsEngineUIHost'; + +export class AppClientManager { + private apps: Array; + + constructor( + private readonly appsEngineUIHost: AppsEngineUIHost, + private readonly communicator?: AppServerCommunicator, + ) { + if (!(appsEngineUIHost instanceof AppsEngineUIHost)) { + throw new Error('The appClientUIHost must extend appClientUIHost'); + } + + if (communicator && !(communicator instanceof AppServerCommunicator)) { + throw new Error('The communicator must extend AppServerCommunicator'); + } + + this.apps = []; + } + + public async load(): Promise { + this.apps = await this.communicator.getEnabledApps(); + console.log('Enabled apps:', this.apps); + } + + public async initialize(): Promise { + this.appsEngineUIHost.initialize(); + } +} diff --git a/packages/apps/src/client/AppServerCommunicator.ts b/packages/apps/src/client/AppServerCommunicator.ts new file mode 100644 index 0000000000000..c40e139fc4f0f --- /dev/null +++ b/packages/apps/src/client/AppServerCommunicator.ts @@ -0,0 +1,16 @@ +import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; + +export abstract class AppServerCommunicator { + public abstract getEnabledApps(): Promise>; + + public abstract getDisabledApps(): Promise>; + + // Map> + public abstract getLanguageAdditions(): Promise>>; + + // Map> + public abstract getSlashCommands(): Promise>>; + + // Map> + public abstract getContextualBarButtons(): Promise>>; +} diff --git a/packages/apps/src/client/AppsEngineUIClient.ts b/packages/apps/src/client/AppsEngineUIClient.ts new file mode 100644 index 0000000000000..8d3577e32dec7 --- /dev/null +++ b/packages/apps/src/client/AppsEngineUIClient.ts @@ -0,0 +1,70 @@ +import { ACTION_ID_LENGTH, MESSAGE_ID } from './constants'; +import type { IExternalComponentRoomInfo, IExternalComponentUserInfo } from './definition'; +import { AppsEngineUIMethods } from './definition/AppsEngineUIMethods'; +import { randomString } from './utils'; + +/** + * Represents the SDK provided to the external component. + */ +export class AppsEngineUIClient { + private listener: (this: Window, ev: MessageEvent<{ [MESSAGE_ID]?: { id: string; payload: any } }>) => void; + + private callbacks: Map any>; + + constructor() { + this.listener = () => console.log('init'); + this.callbacks = new Map(); + } + + /** + * Get the current user's information. + * + * @return the information of the current user. + */ + public getUserInfo(): Promise { + return this.call(AppsEngineUIMethods.GET_USER_INFO); + } + + /** + * Get the current room's information. + * + * @return the information of the current room. + */ + public getRoomInfo(): Promise { + return this.call(AppsEngineUIMethods.GET_ROOM_INFO); + } + + /** + * Initialize the app SDK for communicating with Rocket.Chat + */ + public init(): void { + this.listener = ({ data }) => { + if (!data?.[MESSAGE_ID]) { + return; + } + + const { + [MESSAGE_ID]: { id, payload }, + } = data; + + if (this.callbacks.has(id)) { + const resolve = this.callbacks.get(id); + + if (typeof resolve === 'function') { + resolve(payload); + } + this.callbacks.delete(id); + } + }; + window.addEventListener('message', this.listener); + } + + private call(action: string, payload?: any): Promise { + return new Promise((resolve) => { + const id = randomString(ACTION_ID_LENGTH); + + window.parent.postMessage({ [MESSAGE_ID]: { action, payload, id } }, '*'); + this.callbacks.set(id, resolve); + }); + } +} diff --git a/packages/apps/src/client/AppsEngineUIHost.ts b/packages/apps/src/client/AppsEngineUIHost.ts new file mode 100644 index 0000000000000..3517cbf348563 --- /dev/null +++ b/packages/apps/src/client/AppsEngineUIHost.ts @@ -0,0 +1,78 @@ +import { MESSAGE_ID } from './constants'; +import type { IAppsEngineUIResponse, IExternalComponentRoomInfo, IExternalComponentUserInfo } from './definition'; +import { AppsEngineUIMethods } from './definition'; + +type HandleActionData = IExternalComponentUserInfo | IExternalComponentRoomInfo; + +/** + * Represents the host which handles API calls from external components. + */ +export abstract class AppsEngineUIHost { + /** + * The message emitter who calling the API. + */ + private responseDestination!: Window; + + constructor() { + this.initialize(); + } + + /** + * initialize the AppClientUIHost by registering window `message` listener + */ + public initialize() { + window.addEventListener('message', ({ data, source }) => { + if (!data?.[MESSAGE_ID]) { + return; + } + + this.responseDestination = source as Window; + + const { + [MESSAGE_ID]: { action, id }, + } = data; + + switch (action) { + case AppsEngineUIMethods.GET_USER_INFO: + void this.getClientUserInfo().then((userInfo) => this.handleAction(action, id, userInfo)); + break; + case AppsEngineUIMethods.GET_ROOM_INFO: + void this.getClientRoomInfo().then((roomInfo) => this.handleAction(action, id, roomInfo)); + break; + } + }); + } + + /** + * Get the current user's information. + */ + public abstract getClientUserInfo(): Promise; + + /** + * Get the opened room's information. + */ + public abstract getClientRoomInfo(): Promise; + + /** + * Handle the action sent from the external component. + * @param action the name of the action + * @param id the unique id of the API call + * @param data The data that will return to the caller + */ + private async handleAction(action: AppsEngineUIMethods, id: string, data: HandleActionData): Promise { + if (this.responseDestination instanceof MessagePort || this.responseDestination instanceof ServiceWorker) { + return; + } + + this.responseDestination.postMessage( + { + [MESSAGE_ID]: { + id, + action, + payload: data, + } as IAppsEngineUIResponse, + }, + '*', + ); + } +} diff --git a/packages/apps/src/client/constants/index.ts b/packages/apps/src/client/constants/index.ts new file mode 100644 index 0000000000000..bd7f2e779ca1b --- /dev/null +++ b/packages/apps/src/client/constants/index.ts @@ -0,0 +1,6 @@ +/** + * The id length of each action. + */ +export const ACTION_ID_LENGTH = 80; + +export const MESSAGE_ID = 'rc-apps-engine-ui'; diff --git a/packages/apps/src/client/definition/AppsEngineUIMethods.ts b/packages/apps/src/client/definition/AppsEngineUIMethods.ts new file mode 100644 index 0000000000000..6eb3fb908e5a1 --- /dev/null +++ b/packages/apps/src/client/definition/AppsEngineUIMethods.ts @@ -0,0 +1,7 @@ +/** + * The actions provided by the AppClientSDK. + */ +export enum AppsEngineUIMethods { + GET_USER_INFO = 'getUserInfo', + GET_ROOM_INFO = 'getRoomInfo', +} diff --git a/packages/apps/src/client/definition/IAppsEngineUIResponse.ts b/packages/apps/src/client/definition/IAppsEngineUIResponse.ts new file mode 100644 index 0000000000000..d8690b9b31781 --- /dev/null +++ b/packages/apps/src/client/definition/IAppsEngineUIResponse.ts @@ -0,0 +1,19 @@ +import type { IExternalComponentRoomInfo, IExternalComponentUserInfo } from './index'; + +/** + * The response to the AppClientSDK's API call. + */ +export interface IAppsEngineUIResponse { + /** + * The name of the action + */ + action: string; + /** + * The unique id of the API call + */ + id: string; + /** + * The data that will return to the caller + */ + payload: IExternalComponentUserInfo | IExternalComponentRoomInfo; +} diff --git a/packages/apps/src/client/definition/IExternalComponentRoomInfo.ts b/packages/apps/src/client/definition/IExternalComponentRoomInfo.ts new file mode 100644 index 0000000000000..334ebb12de5fd --- /dev/null +++ b/packages/apps/src/client/definition/IExternalComponentRoomInfo.ts @@ -0,0 +1,17 @@ +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; + +import type { IExternalComponentUserInfo } from './IExternalComponentUserInfo'; + +type ClientRoomInfo = Pick; + +/** + * Represents the room's information returned to the + * external component. + */ +export interface IExternalComponentRoomInfo extends ClientRoomInfo { + /** + * the list that contains all the users belonging + * to this room. + */ + members: Array; +} diff --git a/packages/apps/src/client/definition/IExternalComponentUserInfo.ts b/packages/apps/src/client/definition/IExternalComponentUserInfo.ts new file mode 100644 index 0000000000000..aca1be1405aa3 --- /dev/null +++ b/packages/apps/src/client/definition/IExternalComponentUserInfo.ts @@ -0,0 +1,14 @@ +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +type ClientUserInfo = Pick; + +/** + * Represents the user's information returned to + * the external component. + */ +export interface IExternalComponentUserInfo extends ClientUserInfo { + /** + * the avatar URL of the Rocket.Chat user + */ + avatarUrl: string; +} diff --git a/packages/apps/src/client/definition/index.ts b/packages/apps/src/client/definition/index.ts new file mode 100644 index 0000000000000..977946f342f25 --- /dev/null +++ b/packages/apps/src/client/definition/index.ts @@ -0,0 +1,4 @@ +export * from './AppsEngineUIMethods'; +export type * from './IExternalComponentUserInfo'; +export type * from './IExternalComponentRoomInfo'; +export type * from './IAppsEngineUIResponse'; diff --git a/packages/apps/src/client/index.ts b/packages/apps/src/client/index.ts new file mode 100644 index 0000000000000..2ebfee0264d2d --- /dev/null +++ b/packages/apps/src/client/index.ts @@ -0,0 +1,4 @@ +import { AppClientManager } from './AppClientManager'; +import { AppServerCommunicator } from './AppServerCommunicator'; + +export { AppClientManager, AppServerCommunicator }; diff --git a/packages/apps/src/client/utils/index.ts b/packages/apps/src/client/utils/index.ts new file mode 100644 index 0000000000000..ff726ee5934f4 --- /dev/null +++ b/packages/apps/src/client/utils/index.ts @@ -0,0 +1,18 @@ +/** + * Generate a random string with the specified length. + * @param length the length for the generated random string. + */ +export function randomString(length: number): string { + const buffer: Array = []; + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + + for (let i = 0; i < length; i++) { + buffer.push(chars[getRandomInt(chars.length)]); + } + + return buffer.join(''); +} + +function getRandomInt(max: number): number { + return Math.floor(Math.random() * Math.floor(max)); +} From af06a1c3fa8db17a3b9b16b4925a171c13b4b519 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Thu, 16 Apr 2026 13:16:54 -0300 Subject: [PATCH 03/12] feat(apps): copy deno-runtime into @rocket.chat/apps Copies deno-runtime/ verbatim from @rocket.chat/apps-engine. The import map in deno.jsonc still points to ./../src/ which is only valid in the current location (apps-engine). Making the import map location-independent (using a runtime-generated map) is handled in a dedicated follow-up PR to keep the diff focused. Co-Authored-By: Claude Sonnet 4.6 --- packages/apps/deno-runtime/.gitignore | 1 + .../apps/deno-runtime/AppObjectRegistry.ts | 25 + packages/apps/deno-runtime/acorn-walk.d.ts | 175 ++++ packages/apps/deno-runtime/acorn.d.ts | 915 ++++++++++++++++++ packages/apps/deno-runtime/deno.jsonc | 26 + packages/apps/deno-runtime/deno.lock | 128 +++ packages/apps/deno-runtime/error-handlers.ts | 33 + .../apps/deno-runtime/handlers/api-handler.ts | 50 + .../deno-runtime/handlers/app/construct.ts | 132 +++ .../handlers/app/handleGetStatus.ts | 15 + .../handlers/app/handleInitialize.ts | 24 + .../handlers/app/handleOnDisable.ts | 20 + .../handlers/app/handleOnEnable.ts | 22 + .../handlers/app/handleOnInstall.ts | 34 + .../handlers/app/handleOnPreSettingUpdate.ts | 31 + .../handlers/app/handleOnSettingUpdated.ts | 33 + .../handlers/app/handleOnUninstall.ts | 34 + .../handlers/app/handleOnUpdate.ts | 34 + .../handlers/app/handleSetStatus.ts | 33 + .../handlers/app/handleUploadEvents.ts | 78 ++ .../apps/deno-runtime/handlers/app/handler.ts | 115 +++ .../deno-runtime/handlers/lib/assertions.ts | 51 + .../deno-runtime/handlers/listener/handler.ts | 153 +++ .../handlers/outboundcomms-handler.ts | 37 + .../handlers/scheduler-handler.ts | 65 ++ .../handlers/slashcommand-handler.ts | 128 +++ .../handlers/tests/api-handler.test.ts | 118 +++ .../handlers/tests/helpers/mod.ts | 29 + .../handlers/tests/listener-handler.test.ts | 234 +++++ .../handlers/tests/scheduler-handler.test.ts | 41 + .../tests/slashcommand-handler.test.ts | 159 +++ .../handlers/tests/uikit-handler.test.ts | 105 ++ .../tests/upload-event-handler.test.ts | 107 ++ .../tests/videoconference-handler.test.ts | 122 +++ .../deno-runtime/handlers/uikit/handler.ts | 88 ++ .../handlers/videoconference-handler.ts | 52 + .../lib/accessors/builders/BlockBuilder.ts | 215 ++++ .../accessors/builders/DiscussionBuilder.ts | 59 ++ .../builders/LivechatMessageBuilder.ts | 204 ++++ .../lib/accessors/builders/MessageBuilder.ts | 271 ++++++ .../lib/accessors/builders/RoomBuilder.ts | 197 ++++ .../lib/accessors/builders/UserBuilder.ts | 81 ++ .../builders/VideoConferenceBuilder.ts | 94 ++ .../lib/accessors/extenders/HttpExtender.ts | 58 ++ .../accessors/extenders/MessageExtender.ts | 66 ++ .../lib/accessors/extenders/RoomExtender.ts | 61 ++ .../extenders/VideoConferenceExtend.ts | 69 ++ .../accessors/formatResponseErrorHandler.ts | 14 + .../apps/deno-runtime/lib/accessors/http.ts | 92 ++ .../apps/deno-runtime/lib/accessors/mod.ts | 322 ++++++ .../lib/accessors/modify/ModifyCreator.ts | 383 ++++++++ .../lib/accessors/modify/ModifyExtender.ts | 106 ++ .../lib/accessors/modify/ModifyUpdater.ts | 170 ++++ .../deno-runtime/lib/accessors/notifier.ts | 84 ++ .../lib/accessors/tests/AppAccessors.test.ts | 122 +++ .../lib/accessors/tests/ModifyCreator.test.ts | 259 +++++ .../accessors/tests/ModifyExtender.test.ts | 244 +++++ .../lib/accessors/tests/ModifyUpdater.test.ts | 243 +++++ .../tests/formatResponseErrorHandler.test.ts | 211 ++++ .../lib/accessors/tests/http.test.ts | 164 ++++ packages/apps/deno-runtime/lib/ast/mod.ts | 70 ++ .../apps/deno-runtime/lib/ast/operations.ts | 237 +++++ .../lib/ast/tests/data/ast_blocks.ts | 436 +++++++++ .../lib/ast/tests/operations.test.ts | 261 +++++ packages/apps/deno-runtime/lib/codec.ts | 43 + packages/apps/deno-runtime/lib/logger.ts | 142 +++ packages/apps/deno-runtime/lib/messenger.ts | 202 ++++ .../apps/deno-runtime/lib/metricsCollector.ts | 20 + packages/apps/deno-runtime/lib/parseArgs.ts | 11 + .../apps/deno-runtime/lib/requestContext.ts | 10 + packages/apps/deno-runtime/lib/require.ts | 15 + packages/apps/deno-runtime/lib/room.ts | 104 ++ packages/apps/deno-runtime/lib/roomFactory.ts | 29 + .../lib/sanitizeDeprecatedUsage.ts | 20 + .../deno-runtime/lib/tests/logger.test.ts | 110 +++ .../deno-runtime/lib/tests/messenger.test.ts | 99 ++ .../deno-runtime/lib/wrapAppForRequest.ts | 60 ++ packages/apps/deno-runtime/main.ts | 132 +++ 78 files changed, 9237 insertions(+) create mode 100644 packages/apps/deno-runtime/.gitignore create mode 100644 packages/apps/deno-runtime/AppObjectRegistry.ts create mode 100644 packages/apps/deno-runtime/acorn-walk.d.ts create mode 100644 packages/apps/deno-runtime/acorn.d.ts create mode 100644 packages/apps/deno-runtime/deno.jsonc create mode 100644 packages/apps/deno-runtime/deno.lock create mode 100644 packages/apps/deno-runtime/error-handlers.ts create mode 100644 packages/apps/deno-runtime/handlers/api-handler.ts create mode 100644 packages/apps/deno-runtime/handlers/app/construct.ts create mode 100644 packages/apps/deno-runtime/handlers/app/handleGetStatus.ts create mode 100644 packages/apps/deno-runtime/handlers/app/handleInitialize.ts create mode 100644 packages/apps/deno-runtime/handlers/app/handleOnDisable.ts create mode 100644 packages/apps/deno-runtime/handlers/app/handleOnEnable.ts create mode 100644 packages/apps/deno-runtime/handlers/app/handleOnInstall.ts create mode 100644 packages/apps/deno-runtime/handlers/app/handleOnPreSettingUpdate.ts create mode 100644 packages/apps/deno-runtime/handlers/app/handleOnSettingUpdated.ts create mode 100644 packages/apps/deno-runtime/handlers/app/handleOnUninstall.ts create mode 100644 packages/apps/deno-runtime/handlers/app/handleOnUpdate.ts create mode 100644 packages/apps/deno-runtime/handlers/app/handleSetStatus.ts create mode 100644 packages/apps/deno-runtime/handlers/app/handleUploadEvents.ts create mode 100644 packages/apps/deno-runtime/handlers/app/handler.ts create mode 100644 packages/apps/deno-runtime/handlers/lib/assertions.ts create mode 100644 packages/apps/deno-runtime/handlers/listener/handler.ts create mode 100644 packages/apps/deno-runtime/handlers/outboundcomms-handler.ts create mode 100644 packages/apps/deno-runtime/handlers/scheduler-handler.ts create mode 100644 packages/apps/deno-runtime/handlers/slashcommand-handler.ts create mode 100644 packages/apps/deno-runtime/handlers/tests/api-handler.test.ts create mode 100644 packages/apps/deno-runtime/handlers/tests/helpers/mod.ts create mode 100644 packages/apps/deno-runtime/handlers/tests/listener-handler.test.ts create mode 100644 packages/apps/deno-runtime/handlers/tests/scheduler-handler.test.ts create mode 100644 packages/apps/deno-runtime/handlers/tests/slashcommand-handler.test.ts create mode 100644 packages/apps/deno-runtime/handlers/tests/uikit-handler.test.ts create mode 100644 packages/apps/deno-runtime/handlers/tests/upload-event-handler.test.ts create mode 100644 packages/apps/deno-runtime/handlers/tests/videoconference-handler.test.ts create mode 100644 packages/apps/deno-runtime/handlers/uikit/handler.ts create mode 100644 packages/apps/deno-runtime/handlers/videoconference-handler.ts create mode 100644 packages/apps/deno-runtime/lib/accessors/builders/BlockBuilder.ts create mode 100644 packages/apps/deno-runtime/lib/accessors/builders/DiscussionBuilder.ts create mode 100644 packages/apps/deno-runtime/lib/accessors/builders/LivechatMessageBuilder.ts create mode 100644 packages/apps/deno-runtime/lib/accessors/builders/MessageBuilder.ts create mode 100644 packages/apps/deno-runtime/lib/accessors/builders/RoomBuilder.ts create mode 100644 packages/apps/deno-runtime/lib/accessors/builders/UserBuilder.ts create mode 100644 packages/apps/deno-runtime/lib/accessors/builders/VideoConferenceBuilder.ts create mode 100644 packages/apps/deno-runtime/lib/accessors/extenders/HttpExtender.ts create mode 100644 packages/apps/deno-runtime/lib/accessors/extenders/MessageExtender.ts create mode 100644 packages/apps/deno-runtime/lib/accessors/extenders/RoomExtender.ts create mode 100644 packages/apps/deno-runtime/lib/accessors/extenders/VideoConferenceExtend.ts create mode 100644 packages/apps/deno-runtime/lib/accessors/formatResponseErrorHandler.ts create mode 100644 packages/apps/deno-runtime/lib/accessors/http.ts create mode 100644 packages/apps/deno-runtime/lib/accessors/mod.ts create mode 100644 packages/apps/deno-runtime/lib/accessors/modify/ModifyCreator.ts create mode 100644 packages/apps/deno-runtime/lib/accessors/modify/ModifyExtender.ts create mode 100644 packages/apps/deno-runtime/lib/accessors/modify/ModifyUpdater.ts create mode 100644 packages/apps/deno-runtime/lib/accessors/notifier.ts create mode 100644 packages/apps/deno-runtime/lib/accessors/tests/AppAccessors.test.ts create mode 100644 packages/apps/deno-runtime/lib/accessors/tests/ModifyCreator.test.ts create mode 100644 packages/apps/deno-runtime/lib/accessors/tests/ModifyExtender.test.ts create mode 100644 packages/apps/deno-runtime/lib/accessors/tests/ModifyUpdater.test.ts create mode 100644 packages/apps/deno-runtime/lib/accessors/tests/formatResponseErrorHandler.test.ts create mode 100644 packages/apps/deno-runtime/lib/accessors/tests/http.test.ts create mode 100644 packages/apps/deno-runtime/lib/ast/mod.ts create mode 100644 packages/apps/deno-runtime/lib/ast/operations.ts create mode 100644 packages/apps/deno-runtime/lib/ast/tests/data/ast_blocks.ts create mode 100644 packages/apps/deno-runtime/lib/ast/tests/operations.test.ts create mode 100644 packages/apps/deno-runtime/lib/codec.ts create mode 100644 packages/apps/deno-runtime/lib/logger.ts create mode 100644 packages/apps/deno-runtime/lib/messenger.ts create mode 100644 packages/apps/deno-runtime/lib/metricsCollector.ts create mode 100644 packages/apps/deno-runtime/lib/parseArgs.ts create mode 100644 packages/apps/deno-runtime/lib/requestContext.ts create mode 100644 packages/apps/deno-runtime/lib/require.ts create mode 100644 packages/apps/deno-runtime/lib/room.ts create mode 100644 packages/apps/deno-runtime/lib/roomFactory.ts create mode 100644 packages/apps/deno-runtime/lib/sanitizeDeprecatedUsage.ts create mode 100644 packages/apps/deno-runtime/lib/tests/logger.test.ts create mode 100644 packages/apps/deno-runtime/lib/tests/messenger.test.ts create mode 100644 packages/apps/deno-runtime/lib/wrapAppForRequest.ts create mode 100644 packages/apps/deno-runtime/main.ts diff --git a/packages/apps/deno-runtime/.gitignore b/packages/apps/deno-runtime/.gitignore new file mode 100644 index 0000000000000..5942ea3a153e7 --- /dev/null +++ b/packages/apps/deno-runtime/.gitignore @@ -0,0 +1 @@ +.deno/ diff --git a/packages/apps/deno-runtime/AppObjectRegistry.ts b/packages/apps/deno-runtime/AppObjectRegistry.ts new file mode 100644 index 0000000000000..c9c05137a4a3d --- /dev/null +++ b/packages/apps/deno-runtime/AppObjectRegistry.ts @@ -0,0 +1,25 @@ +export type Maybe = T | null | undefined; + +export const AppObjectRegistry = new class { + registry: Record = {}; + + public get(key: string): Maybe { + return this.registry[key] as Maybe; + } + + public set(key: string, value: unknown): void { + this.registry[key] = value; + } + + public has(key: string): boolean { + return key in this.registry; + } + + public delete(key: string): void { + delete this.registry[key]; + } + + public clear(): void { + this.registry = {}; + } +}(); diff --git a/packages/apps/deno-runtime/acorn-walk.d.ts b/packages/apps/deno-runtime/acorn-walk.d.ts new file mode 100644 index 0000000000000..56db3bc38e9d2 --- /dev/null +++ b/packages/apps/deno-runtime/acorn-walk.d.ts @@ -0,0 +1,175 @@ +import type acorn from './acorn.d.ts'; + +export type FullWalkerCallback = ( + node: acorn.AnyNode, + state: TState, + type: string, +) => void; + +export type FullAncestorWalkerCallback = ( + node: acorn.AnyNode, + state: TState, + ancestors: acorn.AnyNode[], + type: string, +) => void; + +type AggregateType = { + Expression: acorn.Expression; + Statement: acorn.Statement; + Pattern: acorn.Pattern; + ForInit: acorn.VariableDeclaration | acorn.Expression; +}; + +export type SimpleVisitors = + & { + [type in acorn.AnyNode['type']]?: (node: Extract, state: TState) => void; + } + & { + [type in keyof AggregateType]?: (node: AggregateType[type], state: TState) => void; + }; + +export type AncestorVisitors = + & { + [type in acorn.AnyNode['type']]?: (node: Extract, state: TState, ancestors: acorn.Node[]) => void; + } + & { + [type in keyof AggregateType]?: (node: AggregateType[type], state: TState, ancestors: acorn.Node[]) => void; + }; + +export type WalkerCallback = (node: acorn.Node, state: TState) => void; + +export type RecursiveVisitors = + & { + [type in acorn.AnyNode['type']]?: (node: Extract, state: TState, callback: WalkerCallback) => void; + } + & { + [type in keyof AggregateType]?: (node: AggregateType[type], state: TState, callback: WalkerCallback) => void; + }; + +export type FindPredicate = (type: string, node: acorn.Node) => boolean; + +export interface Found { + node: acorn.Node; + state: TState; +} + +/** + * does a 'simple' walk over a tree + * @param node the AST node to walk + * @param visitors an object with properties whose names correspond to node types in the {@link https://github.com/estree/estree | ESTree spec}. The properties should contain functions that will be called with the node object and, if applicable the state at that point. + * @param base a walker algorithm + * @param state a start state. The default walker will simply visit all statements and expressions and not produce a meaningful state. (An example of a use of state is to track scope at each point in the tree.) + */ +export function simple( + node: acorn.Node, + visitors: SimpleVisitors, + base?: RecursiveVisitors, + state?: TState, +): void; + +/** + * does a 'simple' walk over a tree, building up an array of ancestor nodes (including the current node) and passing the array to the callbacks as a third parameter. + * @param node + * @param visitors + * @param base + * @param state + */ +export function ancestor( + node: acorn.Node, + visitors: AncestorVisitors, + base?: RecursiveVisitors, + state?: TState, +): void; + +/** + * does a 'recursive' walk, where the walker functions are responsible for continuing the walk on the child nodes of their target node. + * @param node + * @param state the start state + * @param functions contain an object that maps node types to walker functions + * @param base provides the fallback walker functions for node types that aren't handled in the {@link functions} object. If not given, the default walkers will be used. + */ +export function recursive( + node: acorn.Node, + state: TState, + functions: RecursiveVisitors, + base?: RecursiveVisitors, +): void; + +/** + * does a 'full' walk over a tree, calling the {@link callback} with the arguments (node, state, type) for each node + * @param node + * @param callback + * @param base + * @param state + */ +export function full( + node: acorn.Node, + callback: FullWalkerCallback, + base?: RecursiveVisitors, + state?: TState, +): void; + +/** + * does a 'full' walk over a tree, building up an array of ancestor nodes (including the current node) and passing the array to the callbacks as a third parameter. + * @param node + * @param callback + * @param base + * @param state + */ +export function fullAncestor( + node: acorn.AnyNode, + callback: FullAncestorWalkerCallback, + base?: RecursiveVisitors, + state?: TState, +): void; + +/** + * builds a new walker object by using the walker functions in {@link functions} and filling in the missing ones by taking defaults from {@link base}. + * @param functions + * @param base + */ +export function make( + functions: RecursiveVisitors, + base?: RecursiveVisitors, +): RecursiveVisitors; + +/** + * tries to locate a node in a tree at the given start and/or end offsets, which satisfies the predicate test. {@link start} and {@link end} can be either `null` (as wildcard) or a `number`. {@link test} may be a string (indicating a node type) or a function that takes (nodeType, node) arguments and returns a boolean indicating whether this node is interesting. {@link base} and {@link state} are optional, and can be used to specify a custom walker. Nodes are tested from inner to outer, so if two nodes match the boundaries, the inner one will be preferred. + * @param node + * @param start + * @param end + * @param type + * @param base + * @param state + */ +export function findNodeAt( + node: acorn.AnyNode, + start: number | undefined, + end?: number | undefined, + type?: FindPredicate | string, + base?: RecursiveVisitors, + state?: TState, +): Found | undefined; + +/** + * like {@link findNodeAt}, but will match any node that exists 'around' (spanning) the given position. + * @param node + * @param start + * @param type + * @param base + * @param state + */ +export function findNodeAround( + node: acorn.AnyNode, + start: number | undefined, + type?: FindPredicate | string, + base?: RecursiveVisitors, + state?: TState, +): Found | undefined; + +/** + * similar to {@link findNodeAround}, but will match all nodes after the given position (testing outer nodes before inner nodes). + */ +export const findNodeAfter: typeof findNodeAround; + +export const base: RecursiveVisitors; diff --git a/packages/apps/deno-runtime/acorn.d.ts b/packages/apps/deno-runtime/acorn.d.ts new file mode 100644 index 0000000000000..7ba007d999b11 --- /dev/null +++ b/packages/apps/deno-runtime/acorn.d.ts @@ -0,0 +1,915 @@ +export interface Node { + start?: number; + end?: number; + type: string; + range?: [number, number]; + loc?: SourceLocation | null; +} + +export interface SourceLocation { + source?: string | null; + start: Position; + end: Position; +} + +export interface Position { + /** 1-based */ + line: number; + /** 0-based */ + column: number; +} + +export interface Identifier extends Node { + type: 'Identifier'; + name: string; +} + +export interface Literal extends Node { + type: 'Literal'; + value?: string | boolean | null | number | RegExp | bigint; + raw?: string; + regex?: { + pattern: string; + flags: string; + }; + bigint?: string; +} + +export interface Program extends Node { + type: 'Program'; + body: Array; + sourceType: 'script' | 'module'; +} + +export interface Function extends Node { + id?: Identifier | null; + params: Array; + body: BlockStatement | Expression; + generator: boolean; + expression: boolean; + async: boolean; +} + +export interface ExpressionStatement extends Node { + type: 'ExpressionStatement'; + expression: Expression | Literal; + directive?: string; +} + +export interface BlockStatement extends Node { + type: 'BlockStatement'; + body: Array; +} + +export interface EmptyStatement extends Node { + type: 'EmptyStatement'; +} + +export interface DebuggerStatement extends Node { + type: 'DebuggerStatement'; +} + +export interface WithStatement extends Node { + type: 'WithStatement'; + object: Expression; + body: Statement; +} + +export interface ReturnStatement extends Node { + type: 'ReturnStatement'; + argument?: Expression | null; +} + +export interface LabeledStatement extends Node { + type: 'LabeledStatement'; + label: Identifier; + body: Statement; +} + +export interface BreakStatement extends Node { + type: 'BreakStatement'; + label?: Identifier | null; +} + +export interface ContinueStatement extends Node { + type: 'ContinueStatement'; + label?: Identifier | null; +} + +export interface IfStatement extends Node { + type: 'IfStatement'; + test: Expression; + consequent: Statement; + alternate?: Statement | null; +} + +export interface SwitchStatement extends Node { + type: 'SwitchStatement'; + discriminant: Expression; + cases: Array; +} + +export interface SwitchCase extends Node { + type: 'SwitchCase'; + test?: Expression | null; + consequent: Array; +} + +export interface ThrowStatement extends Node { + type: 'ThrowStatement'; + argument: Expression; +} + +export interface TryStatement extends Node { + type: 'TryStatement'; + block: BlockStatement; + handler?: CatchClause | null; + finalizer?: BlockStatement | null; +} + +export interface CatchClause extends Node { + type: 'CatchClause'; + param?: Pattern | null; + body: BlockStatement; +} + +export interface WhileStatement extends Node { + type: 'WhileStatement'; + test: Expression; + body: Statement; +} + +export interface DoWhileStatement extends Node { + type: 'DoWhileStatement'; + body: Statement; + test: Expression; +} + +export interface ForStatement extends Node { + type: 'ForStatement'; + init?: VariableDeclaration | Expression | null; + test?: Expression | null; + update?: Expression | null; + body: Statement; +} + +export interface ForInStatement extends Node { + type: 'ForInStatement'; + left: VariableDeclaration | Pattern; + right: Expression; + body: Statement; +} + +export interface FunctionDeclaration extends Function { + type: 'FunctionDeclaration'; + id: Identifier; + body: BlockStatement; +} + +export interface VariableDeclaration extends Node { + type: 'VariableDeclaration'; + declarations: Array; + kind: 'var' | 'let' | 'const'; +} + +export interface VariableDeclarator extends Node { + type: 'VariableDeclarator'; + id: Pattern; + init?: Expression | null; +} + +export interface ThisExpression extends Node { + type: 'ThisExpression'; +} + +export interface ArrayExpression extends Node { + type: 'ArrayExpression'; + elements: Array; +} + +export interface ObjectExpression extends Node { + type: 'ObjectExpression'; + properties: Array; +} + +export interface Property extends Node { + type: 'Property'; + key: Expression; + value: Expression; + kind: 'init' | 'get' | 'set'; + method: boolean; + shorthand: boolean; + computed: boolean; +} + +export interface FunctionExpression extends Function { + type: 'FunctionExpression'; + body: BlockStatement; +} + +export interface UnaryExpression extends Node { + type: 'UnaryExpression'; + operator: UnaryOperator; + prefix: boolean; + argument: Expression; +} + +export type UnaryOperator = '-' | '+' | '!' | '~' | 'typeof' | 'void' | 'delete'; + +export interface UpdateExpression extends Node { + type: 'UpdateExpression'; + operator: UpdateOperator; + argument: Expression; + prefix: boolean; +} + +export type UpdateOperator = '++' | '--'; + +export interface BinaryExpression extends Node { + type: 'BinaryExpression'; + operator: BinaryOperator; + left: Expression | PrivateIdentifier; + right: Expression; +} + +export type BinaryOperator = + | '==' + | '!=' + | '===' + | '!==' + | '<' + | '<=' + | '>' + | '>=' + | '<<' + | '>>' + | '>>>' + | '+' + | '-' + | '*' + | '/' + | '%' + | '|' + | '^' + | '&' + | 'in' + | 'instanceof' + | '**'; + +export interface AssignmentExpression extends Node { + type: 'AssignmentExpression'; + operator: AssignmentOperator; + left: Pattern; + right: Expression; +} + +export type AssignmentOperator = '=' | '+=' | '-=' | '*=' | '/=' | '%=' | '<<=' | '>>=' | '>>>=' | '|=' | '^=' | '&=' | '**=' | '||=' | '&&=' | '??='; + +export interface LogicalExpression extends Node { + type: 'LogicalExpression'; + operator: LogicalOperator; + left: Expression; + right: Expression; +} + +export type LogicalOperator = '||' | '&&' | '??'; + +export interface MemberExpression extends Node { + type: 'MemberExpression'; + object: Expression | Super; + property: Expression | PrivateIdentifier; + computed: boolean; + optional: boolean; +} + +export interface ConditionalExpression extends Node { + type: 'ConditionalExpression'; + test: Expression; + alternate: Expression; + consequent: Expression; +} + +export interface CallExpression extends Node { + type: 'CallExpression'; + callee: Expression | Super; + arguments: Array; + optional: boolean; +} + +export interface NewExpression extends Node { + type: 'NewExpression'; + callee: Expression; + arguments: Array; +} + +export interface SequenceExpression extends Node { + type: 'SequenceExpression'; + expressions: Array; +} + +export interface ForOfStatement extends Node { + type: 'ForOfStatement'; + left: VariableDeclaration | Pattern; + right: Expression; + body: Statement; + await: boolean; +} + +export interface Super extends Node { + type: 'Super'; +} + +export interface SpreadElement extends Node { + type: 'SpreadElement'; + argument: Expression; +} + +export interface ArrowFunctionExpression extends Function { + type: 'ArrowFunctionExpression'; +} + +export interface YieldExpression extends Node { + type: 'YieldExpression'; + argument?: Expression | null; + delegate: boolean; +} + +export interface TemplateLiteral extends Node { + type: 'TemplateLiteral'; + quasis: Array; + expressions: Array; +} + +export interface TaggedTemplateExpression extends Node { + type: 'TaggedTemplateExpression'; + tag: Expression; + quasi: TemplateLiteral; +} + +export interface TemplateElement extends Node { + type: 'TemplateElement'; + tail: boolean; + value: { + cooked?: string | null; + raw: string; + }; +} + +export interface AssignmentProperty extends Node { + type: 'Property'; + key: Expression; + value: Pattern; + kind: 'init'; + method: false; + shorthand: boolean; + computed: boolean; +} + +export interface ObjectPattern extends Node { + type: 'ObjectPattern'; + properties: Array; +} + +export interface ArrayPattern extends Node { + type: 'ArrayPattern'; + elements: Array; +} + +export interface RestElement extends Node { + type: 'RestElement'; + argument: Pattern; +} + +export interface AssignmentPattern extends Node { + type: 'AssignmentPattern'; + left: Pattern; + right: Expression; +} + +export interface Class extends Node { + id?: Identifier | null; + superClass?: Expression | null; + body: ClassBody; +} + +export interface ClassBody extends Node { + type: 'ClassBody'; + body: Array; +} + +export interface MethodDefinition extends Node { + type: 'MethodDefinition'; + key: Expression | PrivateIdentifier; + value: FunctionExpression; + kind: 'constructor' | 'method' | 'get' | 'set'; + computed: boolean; + static: boolean; +} + +export interface ClassDeclaration extends Class { + type: 'ClassDeclaration'; + id: Identifier; +} + +export interface ClassExpression extends Class { + type: 'ClassExpression'; +} + +export interface MetaProperty extends Node { + type: 'MetaProperty'; + meta: Identifier; + property: Identifier; +} + +export interface ImportDeclaration extends Node { + type: 'ImportDeclaration'; + specifiers: Array; + source: Literal; +} + +export interface ImportSpecifier extends Node { + type: 'ImportSpecifier'; + imported: Identifier | Literal; + local: Identifier; +} + +export interface ImportDefaultSpecifier extends Node { + type: 'ImportDefaultSpecifier'; + local: Identifier; +} + +export interface ImportNamespaceSpecifier extends Node { + type: 'ImportNamespaceSpecifier'; + local: Identifier; +} + +export interface ExportNamedDeclaration extends Node { + type: 'ExportNamedDeclaration'; + declaration?: Declaration | null; + specifiers: Array; + source?: Literal | null; +} + +export interface ExportSpecifier extends Node { + type: 'ExportSpecifier'; + exported: Identifier | Literal; + local: Identifier | Literal; +} + +export interface AnonymousFunctionDeclaration extends Function { + type: 'FunctionDeclaration'; + id: null; + body: BlockStatement; +} + +export interface AnonymousClassDeclaration extends Class { + type: 'ClassDeclaration'; + id: null; +} + +export interface ExportDefaultDeclaration extends Node { + type: 'ExportDefaultDeclaration'; + declaration: AnonymousFunctionDeclaration | FunctionDeclaration | AnonymousClassDeclaration | ClassDeclaration | Expression; +} + +export interface ExportAllDeclaration extends Node { + type: 'ExportAllDeclaration'; + source: Literal; + exported?: Identifier | Literal | null; +} + +export interface AwaitExpression extends Node { + type: 'AwaitExpression'; + argument: Expression; +} + +export interface ChainExpression extends Node { + type: 'ChainExpression'; + expression: MemberExpression | CallExpression; +} + +export interface ImportExpression extends Node { + type: 'ImportExpression'; + source: Expression; +} + +export interface ParenthesizedExpression extends Node { + type: 'ParenthesizedExpression'; + expression: Expression; +} + +export interface PropertyDefinition extends Node { + type: 'PropertyDefinition'; + key: Expression | PrivateIdentifier; + value?: Expression | null; + computed: boolean; + static: boolean; +} + +export interface PrivateIdentifier extends Node { + type: 'PrivateIdentifier'; + name: string; +} + +export interface StaticBlock extends Node { + type: 'StaticBlock'; + body: Array; +} + +export type Statement = + | ExpressionStatement + | BlockStatement + | EmptyStatement + | DebuggerStatement + | WithStatement + | ReturnStatement + | LabeledStatement + | BreakStatement + | ContinueStatement + | IfStatement + | SwitchStatement + | ThrowStatement + | TryStatement + | WhileStatement + | DoWhileStatement + | ForStatement + | ForInStatement + | ForOfStatement + | Declaration; + +export type Declaration = + | FunctionDeclaration + | VariableDeclaration + | ClassDeclaration; + +export type Expression = + | Identifier + | Literal + | ThisExpression + | ArrayExpression + | ObjectExpression + | FunctionExpression + | UnaryExpression + | UpdateExpression + | BinaryExpression + | AssignmentExpression + | LogicalExpression + | MemberExpression + | ConditionalExpression + | CallExpression + | NewExpression + | SequenceExpression + | ArrowFunctionExpression + | YieldExpression + | TemplateLiteral + | TaggedTemplateExpression + | ClassExpression + | MetaProperty + | AwaitExpression + | ChainExpression + | ImportExpression + | ParenthesizedExpression; + +export type Pattern = + | Identifier + | MemberExpression + | ObjectPattern + | ArrayPattern + | RestElement + | AssignmentPattern; + +export type ModuleDeclaration = + | ImportDeclaration + | ExportNamedDeclaration + | ExportDefaultDeclaration + | ExportAllDeclaration; + +export type AnyNode = + | Statement + | Expression + | Declaration + | ModuleDeclaration + | Literal + | Program + | SwitchCase + | CatchClause + | Property + | Super + | SpreadElement + | TemplateElement + | AssignmentProperty + | ObjectPattern + | ArrayPattern + | RestElement + | AssignmentPattern + | ClassBody + | MethodDefinition + | MetaProperty + | ImportSpecifier + | ImportDefaultSpecifier + | ImportNamespaceSpecifier + | ExportSpecifier + | AnonymousFunctionDeclaration + | AnonymousClassDeclaration + | PropertyDefinition + | PrivateIdentifier + | StaticBlock + | VariableDeclaration + | VariableDeclarator; + +export function parse(input: string, options: Options): Program; + +export function parseExpressionAt(input: string, pos: number, options: Options): Expression; + +export function tokenizer(input: string, options: Options): { + getToken(): Token; + [Symbol.iterator](): Iterator; +}; + +export type ecmaVersion = 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 2015 | 2016 | 2017 | 2018 | 2019 | 2020 | 2021 | 2022 | 2023 | 2024 | 'latest'; + +export interface Options { + /** + * `ecmaVersion` indicates the ECMAScript version to parse. Must be + * either 3, 5, 6 (or 2015), 7 (2016), 8 (2017), 9 (2018), 10 + * (2019), 11 (2020), 12 (2021), 13 (2022), 14 (2023), or `"latest"` + * (the latest version the library supports). This influences + * support for strict mode, the set of reserved words, and support + * for new syntax features. + */ + ecmaVersion: ecmaVersion; + + /** + * `sourceType` indicates the mode the code should be parsed in. + * Can be either `"script"` or `"module"`. This influences global + * strict mode and parsing of `import` and `export` declarations. + */ + sourceType?: 'script' | 'module'; + + /** + * a callback that will be called when a semicolon is automatically inserted. + * @param lastTokEnd the position of the comma as an offset + * @param lastTokEndLoc location if {@link locations} is enabled + */ + onInsertedSemicolon?: (lastTokEnd: number, lastTokEndLoc?: Position) => void; + + /** + * similar to `onInsertedSemicolon`, but for trailing commas + * @param lastTokEnd the position of the comma as an offset + * @param lastTokEndLoc location if `locations` is enabled + */ + onTrailingComma?: (lastTokEnd: number, lastTokEndLoc?: Position) => void; + + /** + * By default, reserved words are only enforced if ecmaVersion >= 5. + * Set `allowReserved` to a boolean value to explicitly turn this on + * an off. When this option has the value "never", reserved words + * and keywords can also not be used as property names. + */ + allowReserved?: boolean | 'never'; + + /** + * When enabled, a return at the top level is not considered an error. + */ + allowReturnOutsideFunction?: boolean; + + /** + * When enabled, import/export statements are not constrained to + * appearing at the top of the program, and an import.meta expression + * in a script isn't considered an error. + */ + allowImportExportEverywhere?: boolean; + + /** + * By default, `await` identifiers are allowed to appear at the top-level scope only if {@link ecmaVersion} >= 2022. + * When enabled, await identifiers are allowed to appear at the top-level scope, + * but they are still not allowed in non-async functions. + */ + allowAwaitOutsideFunction?: boolean; + + /** + * When enabled, super identifiers are not constrained to + * appearing in methods and do not raise an error when they appear elsewhere. + */ + allowSuperOutsideMethod?: boolean; + + /** + * When enabled, hashbang directive in the beginning of file is + * allowed and treated as a line comment. Enabled by default when + * {@link ecmaVersion} >= 2023. + */ + allowHashBang?: boolean; + + /** + * By default, the parser will verify that private properties are + * only used in places where they are valid and have been declared. + * Set this to false to turn such checks off. + */ + checkPrivateFields?: boolean; + + /** + * When `locations` is on, `loc` properties holding objects with + * `start` and `end` properties as {@link Position} objects will be attached to the + * nodes. + */ + locations?: boolean; + + /** + * a callback that will cause Acorn to call that export function with object in the same + * format as tokens returned from `tokenizer().getToken()`. Note + * that you are not allowed to call the parser from the + * callback—that will corrupt its internal state. + */ + onToken?: ((token: Token) => void) | Token[]; + + /** + * This takes a export function or an array. + * + * When a export function is passed, Acorn will call that export function with `(block, text, start, + * end)` parameters whenever a comment is skipped. `block` is a + * boolean indicating whether this is a block (`/* *\/`) comment, + * `text` is the content of the comment, and `start` and `end` are + * character offsets that denote the start and end of the comment. + * When the {@link locations} option is on, two more parameters are + * passed, the full locations of {@link Position} export type of the start and + * end of the comments. + * + * When a array is passed, each found comment of {@link Comment} export type is pushed to the array. + * + * Note that you are not allowed to call the + * parser from the callback—that will corrupt its internal state. + */ + onComment?: + | (( + isBlock: boolean, + text: string, + start: number, + end: number, + startLoc?: Position, + endLoc?: Position, + ) => void) + | Comment[]; + + /** + * Nodes have their start and end characters offsets recorded in + * `start` and `end` properties (directly on the node, rather than + * the `loc` object, which holds line/column data. To also add a + * [semi-standardized][range] `range` property holding a `[start, + * end]` array with the same numbers, set the `ranges` option to + * `true`. + */ + ranges?: boolean; + + /** + * It is possible to parse multiple files into a single AST by + * passing the tree produced by parsing the first file as + * `program` option in subsequent parses. This will add the + * toplevel forms of the parsed file to the `Program` (top) node + * of an existing parse tree. + */ + program?: Node; + + /** + * When {@link locations} is on, you can pass this to record the source + * file in every node's `loc` object. + */ + sourceFile?: string; + + /** + * This value, if given, is stored in every node, whether {@link locations} is on or off. + */ + directSourceFile?: string; + + /** + * When enabled, parenthesized expressions are represented by + * (non-standard) ParenthesizedExpression nodes + */ + preserveParens?: boolean; +} + +export class Parser { + options: Options; + input: string; + + constructor(options: Options, input: string, startPos?: number); + parse(): Program; + + static parse(input: string, options: Options): Program; + static parseExpressionAt(input: string, pos: number, options: Options): Expression; + static tokenizer(input: string, options: Options): { + getToken(): Token; + [Symbol.iterator](): Iterator; + }; + static extend(...plugins: ((BaseParser: typeof Parser) => typeof Parser)[]): typeof Parser; +} + +export const defaultOptions: Options; + +export function getLineInfo(input: string, offset: number): Position; + +export class TokenType { + label: string; + keyword: string | undefined; +} + +export const tokTypes: { + num: TokenType; + regexp: TokenType; + string: TokenType; + name: TokenType; + privateId: TokenType; + eof: TokenType; + + bracketL: TokenType; + bracketR: TokenType; + braceL: TokenType; + braceR: TokenType; + parenL: TokenType; + parenR: TokenType; + comma: TokenType; + semi: TokenType; + colon: TokenType; + dot: TokenType; + question: TokenType; + questionDot: TokenType; + arrow: TokenType; + template: TokenType; + invalidTemplate: TokenType; + ellipsis: TokenType; + backQuote: TokenType; + dollarBraceL: TokenType; + + eq: TokenType; + assign: TokenType; + incDec: TokenType; + prefix: TokenType; + logicalOR: TokenType; + logicalAND: TokenType; + bitwiseOR: TokenType; + bitwiseXOR: TokenType; + bitwiseAND: TokenType; + equality: TokenType; + relational: TokenType; + bitShift: TokenType; + plusMin: TokenType; + modulo: TokenType; + star: TokenType; + slash: TokenType; + starstar: TokenType; + coalesce: TokenType; + + _break: TokenType; + _case: TokenType; + _catch: TokenType; + _continue: TokenType; + _debugger: TokenType; + _default: TokenType; + _do: TokenType; + _else: TokenType; + _finally: TokenType; + _for: TokenType; + _function: TokenType; + _if: TokenType; + _return: TokenType; + _switch: TokenType; + _throw: TokenType; + _try: TokenType; + _var: TokenType; + _const: TokenType; + _while: TokenType; + _with: TokenType; + _new: TokenType; + _this: TokenType; + _super: TokenType; + _class: TokenType; + _extends: TokenType; + _export: TokenType; + _import: TokenType; + _null: TokenType; + _true: TokenType; + _false: TokenType; + _in: TokenType; + _instanceof: TokenType; + _typeof: TokenType; + _void: TokenType; + _delete: TokenType; +}; + +export interface Comment { + type: 'Line' | 'Block'; + value: string; + start: number; + end: number; + loc?: SourceLocation; + range?: [number, number]; +} + +export class Token { + type: TokenType; + start: number; + end: number; + loc?: SourceLocation; + range?: [number, number]; +} + +export const version: string; diff --git a/packages/apps/deno-runtime/deno.jsonc b/packages/apps/deno-runtime/deno.jsonc new file mode 100644 index 0000000000000..4fa3142b99261 --- /dev/null +++ b/packages/apps/deno-runtime/deno.jsonc @@ -0,0 +1,26 @@ +{ + "imports": { + "@msgpack/msgpack": "npm:@msgpack/msgpack@3.0.0-beta2", + "@rocket.chat/apps-engine/": "./../src/", + "@rocket.chat/ui-kit": "npm:@rocket.chat/ui-kit@^0.31.22", + "@std/cli": "jsr:@std/cli@^1.0.9", + "@std/io": "jsr:@std/io@^0.225.3", + "@std/streams": "jsr:@std/streams@^1.0.16", + "acorn": "npm:acorn@8.10.0", + "acorn-walk": "npm:acorn-walk@8.2.0", + "astring": "npm:astring@1.8.6", + "jsonrpc-lite": "npm:jsonrpc-lite@2.2.0", + "stack-trace": "npm:stack-trace@0.0.10", + "uuid": "npm:uuid@8.3.2" + }, + "unstable": ["detect-cjs"], + "tasks": { + "test": "deno test --no-check --allow-read=../../../,/tmp --allow-write=/tmp" + }, + "fmt": { + "lineWidth": 160, + "useTabs": true, + "indentWidth": 4, + "singleQuote": true + } +} diff --git a/packages/apps/deno-runtime/deno.lock b/packages/apps/deno-runtime/deno.lock new file mode 100644 index 0000000000000..bc87cb5e87d84 --- /dev/null +++ b/packages/apps/deno-runtime/deno.lock @@ -0,0 +1,128 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/bytes@^1.0.6": "1.0.6", + "jsr:@std/cli@^1.0.9": "1.0.13", + "jsr:@std/io@~0.225.3": "0.225.3", + "jsr:@std/streams@^1.0.16": "1.0.16", + "npm:@msgpack/msgpack@3.0.0-beta2": "3.0.0-beta2", + "npm:@rocket.chat/ui-kit@~0.31.22": "0.31.25_@rocket.chat+icons@0.32.0", + "npm:acorn-walk@8.2.0": "8.2.0", + "npm:acorn@8.10.0": "8.10.0", + "npm:astring@1.8.6": "1.8.6", + "npm:jsonrpc-lite@2.2.0": "2.2.0", + "npm:stack-trace@*": "0.0.10", + "npm:stack-trace@0.0.10": "0.0.10", + "npm:uuid@8.3.2": "8.3.2" + }, + "jsr": { + "@std/bytes@1.0.6": { + "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" + }, + "@std/cli@1.0.13": { + "integrity": "5db2d95ab2dca3bca9fb6ad3c19908c314e93d6391c8b026725e4892d4615a69" + }, + "@std/io@0.225.3": { + "integrity": "27b07b591384d12d7b568f39e61dff966b8230559122df1e9fd11cc068f7ddd1", + "dependencies": [ + "jsr:@std/bytes" + ] + }, + "@std/streams@1.0.16": { + "integrity": "85030627befb1767c60d4f65cb30fa2f94af1d6ee6e5b2515b76157a542e89c4", + "dependencies": [ + "jsr:@std/bytes" + ] + } + }, + "npm": { + "@msgpack/msgpack@3.0.0-beta2": { + "integrity": "sha512-y+l1PNV0XDyY8sM3YtuMLK5vE3/hkfId+Do8pLo/OPxfxuFAUwcGz3oiiUuV46/aBpwTzZ+mRWVMtlSKbradhw==" + }, + "@rocket.chat/icons@0.32.0": { + "integrity": "sha512-7yhhELKNLb9kUtXCvau0V+iMXraV2bOsxcPjc/ZtLR5VeeIDTeaflqRWGtLroX6f3bE+J1n5qB5zi8A4YXuH2g==" + }, + "@rocket.chat/ui-kit@0.31.25_@rocket.chat+icons@0.32.0": { + "integrity": "sha512-yTgTKDw9SMlJ6p8n0PDO6zSvox/nHYUrwCIvILQeAK6PvTrgSe/u9CvU7ATTYjnQiQ603yEGR6dxjF4euCGdNA==", + "dependencies": [ + "@rocket.chat/icons" + ] + }, + "acorn-walk@8.2.0": { + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==" + }, + "acorn@8.10.0": { + "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "bin": true + }, + "astring@1.8.6": { + "integrity": "sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==", + "bin": true + }, + "jsonrpc-lite@2.2.0": { + "integrity": "sha512-/cbbSxtZWs1O7R4tWqabrCM/t3N8qKUZMAg9IUqpPvUs6UyRvm6pCNYkskyKN/XU0UgffW+NY2ZRr8t0AknX7g==" + }, + "stack-trace@0.0.10": { + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==" + }, + "uuid@8.3.2": { + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": true + } + }, + "remote": { + "https://deno.land/std@0.203.0/assert/_constants.ts": "8a9da298c26750b28b326b297316cdde860bc237533b07e1337c021379e6b2a9", + "https://deno.land/std@0.203.0/assert/_diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", + "https://deno.land/std@0.203.0/assert/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", + "https://deno.land/std@0.203.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee", + "https://deno.land/std@0.203.0/assert/assert_almost_equals.ts": "e15ca1f34d0d5e0afae63b3f5d975cbd18335a132e42b0c747d282f62ad2cd6c", + "https://deno.land/std@0.203.0/assert/assert_array_includes.ts": "6856d7f2c3544bc6e62fb4646dfefa3d1df5ff14744d1bca19f0cbaf3b0d66c9", + "https://deno.land/std@0.203.0/assert/assert_equals.ts": "d8ec8a22447fbaf2fc9d7c3ed2e66790fdb74beae3e482855d75782218d68227", + "https://deno.land/std@0.203.0/assert/assert_exists.ts": "407cb6b9fb23a835cd8d5ad804e2e2edbbbf3870e322d53f79e1c7a512e2efd7", + "https://deno.land/std@0.203.0/assert/assert_false.ts": "0ccbcaae910f52c857192ff16ea08bda40fdc79de80846c206bfc061e8c851c6", + "https://deno.land/std@0.203.0/assert/assert_greater.ts": "ae2158a2d19313bf675bf7251d31c6dc52973edb12ac64ac8fc7064152af3e63", + "https://deno.land/std@0.203.0/assert/assert_greater_or_equal.ts": "1439da5ebbe20855446cac50097ac78b9742abe8e9a43e7de1ce1426d556e89c", + "https://deno.land/std@0.203.0/assert/assert_instance_of.ts": "3aedb3d8186e120812d2b3a5dea66a6e42bf8c57a8bd927645770bd21eea554c", + "https://deno.land/std@0.203.0/assert/assert_is_error.ts": "c21113094a51a296ffaf036767d616a78a2ae5f9f7bbd464cd0197476498b94b", + "https://deno.land/std@0.203.0/assert/assert_less.ts": "aec695db57db42ec3e2b62e97e1e93db0063f5a6ec133326cc290ff4b71b47e4", + "https://deno.land/std@0.203.0/assert/assert_less_or_equal.ts": "5fa8b6a3ffa20fd0a05032fe7257bf985d207b85685fdbcd23651b70f928c848", + "https://deno.land/std@0.203.0/assert/assert_match.ts": "c4083f80600bc190309903c95e397a7c9257ff8b5ae5c7ef91e834704e672e9b", + "https://deno.land/std@0.203.0/assert/assert_not_equals.ts": "9f1acab95bd1f5fc9a1b17b8027d894509a745d91bac1718fdab51dc76831754", + "https://deno.land/std@0.203.0/assert/assert_not_instance_of.ts": "0c14d3dfd9ab7a5276ed8ed0b18c703d79a3d106102077ec437bfe7ed912bd22", + "https://deno.land/std@0.203.0/assert/assert_not_match.ts": "3796a5b0c57a1ce6c1c57883dd4286be13a26f715ea662318ab43a8491a13ab0", + "https://deno.land/std@0.203.0/assert/assert_not_strict_equals.ts": "ca6c6d645e95fbc873d25320efeb8c4c6089a9a5e09f92d7c1c4b6e935c2a6ad", + "https://deno.land/std@0.203.0/assert/assert_object_match.ts": "d8fc2867cfd92eeacf9cea621e10336b666de1874a6767b5ec48988838370b54", + "https://deno.land/std@0.203.0/assert/assert_rejects.ts": "45c59724de2701e3b1f67c391d6c71c392363635aad3f68a1b3408f9efca0057", + "https://deno.land/std@0.203.0/assert/assert_strict_equals.ts": "b1f538a7ea5f8348aeca261d4f9ca603127c665e0f2bbfeb91fa272787c87265", + "https://deno.land/std@0.203.0/assert/assert_string_includes.ts": "b821d39ebf5cb0200a348863c86d8c4c4b398e02012ce74ad15666fc4b631b0c", + "https://deno.land/std@0.203.0/assert/assert_throws.ts": "63784e951475cb7bdfd59878cd25a0931e18f6dc32a6077c454b2cd94f4f4bcd", + "https://deno.land/std@0.203.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56", + "https://deno.land/std@0.203.0/assert/equal.ts": "9f1a46d5993966d2596c44e5858eec821859b45f783a5ee2f7a695dfc12d8ece", + "https://deno.land/std@0.203.0/assert/fail.ts": "c36353d7ae6e1f7933d45f8ea51e358c8c4b67d7e7502028598fe1fea062e278", + "https://deno.land/std@0.203.0/assert/mod.ts": "37c49a26aae2b254bbe25723434dc28cd7532e444cf0b481a97c045d110ec085", + "https://deno.land/std@0.203.0/assert/unimplemented.ts": "d56fbeecb1f108331a380f72e3e010a1f161baa6956fd0f7cf3e095ae1a4c75a", + "https://deno.land/std@0.203.0/assert/unreachable.ts": "4600dc0baf7d9c15a7f7d234f00c23bca8f3eba8b140286aaca7aa998cf9a536", + "https://deno.land/std@0.203.0/fmt/colors.ts": "c51c4642678eb690dcf5ffee5918b675bf01a33fba82acf303701ae1a4f8c8d9", + "https://deno.land/std@0.203.0/testing/_test_suite.ts": "30f018feeb3835f12ab198d8a518f9089b1bcb2e8c838a8b615ab10d5005465c", + "https://deno.land/std@0.203.0/testing/bdd.ts": "3f446df5ef8e856a869e8eec54c8482590415741ff0b6358a00c43486cc15769", + "https://deno.land/std@0.203.0/testing/mock.ts": "6576b4aa55ee20b1990d656a78fff83599e190948c00e9f25a7f3ac5e9d6492d", + "https://deno.land/std@0.216.0/io/types.ts": "748bbb3ac96abda03594ef5a0db15ce5450dcc6c0d841c8906f8b10ac8d32c96", + "https://deno.land/std@0.216.0/io/write_all.ts": "24aac2312bb21096ae3ae0b102b22c26164d3249dff96dbac130958aa736f038", + "https://jsr.io/@std/cli/1.0.9/parse_args.ts": "29ac18602d8836d2723cab1d90111ff954acc369f184626a3f9f677e3185caef" + }, + "workspace": { + "dependencies": [ + "jsr:@std/cli@^1.0.9", + "jsr:@std/io@~0.225.3", + "jsr:@std/streams@^1.0.16", + "npm:@msgpack/msgpack@3.0.0-beta2", + "npm:@rocket.chat/ui-kit@~0.31.22", + "npm:acorn-walk@8.2.0", + "npm:acorn@8.10.0", + "npm:astring@1.8.6", + "npm:jsonrpc-lite@2.2.0", + "npm:stack-trace@0.0.10", + "npm:uuid@8.3.2" + ] + } +} diff --git a/packages/apps/deno-runtime/error-handlers.ts b/packages/apps/deno-runtime/error-handlers.ts new file mode 100644 index 0000000000000..e26a5ad6b2d86 --- /dev/null +++ b/packages/apps/deno-runtime/error-handlers.ts @@ -0,0 +1,33 @@ +import * as Messenger from './lib/messenger.ts'; + +export function unhandledRejectionListener(event: PromiseRejectionEvent) { + event.preventDefault(); + + const { type, reason } = event; + + Messenger.sendNotification({ + method: 'unhandledRejection', + params: [ + { + type, + reason: reason instanceof Error ? reason.message : reason, + timestamp: new Date(), + }, + ], + }); +} + +export function unhandledExceptionListener(event: ErrorEvent) { + event.preventDefault(); + + const { type, message, filename, lineno, colno } = event; + Messenger.sendNotification({ + method: 'uncaughtException', + params: [{ type, message, filename, lineno, colno }], + }); +} + +export default function registerErrorListeners() { + addEventListener('unhandledrejection', unhandledRejectionListener); + addEventListener('error', unhandledExceptionListener); +} diff --git a/packages/apps/deno-runtime/handlers/api-handler.ts b/packages/apps/deno-runtime/handlers/api-handler.ts new file mode 100644 index 0000000000000..1b88a92551ea7 --- /dev/null +++ b/packages/apps/deno-runtime/handlers/api-handler.ts @@ -0,0 +1,50 @@ +import type { IApiEndpoint } from '@rocket.chat/apps-engine/definition/api/IApiEndpoint.ts'; +import { Defined, JsonRpcError } from 'jsonrpc-lite'; + +import { AppObjectRegistry } from '../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../lib/accessors/mod.ts'; +import { RequestContext } from '../lib/requestContext.ts'; +import { wrapComposedApp } from '../lib/wrapAppForRequest.ts'; + +export default async function apiHandler(request: RequestContext): Promise { + const { method: call, params } = request; + const [/* always "api" */, ...parts] = call.split(':'); + const httpMethod = parts.pop(); + const path = parts.join(':'); + + const endpoint = AppObjectRegistry.get(`api:${path}`); + const { logger } = request.context; + + if (!endpoint) { + return new JsonRpcError(`Endpoint ${path} not found`, -32000); + } + + const method = endpoint[httpMethod as keyof IApiEndpoint]; + + if (typeof method !== 'function') { + return new JsonRpcError(`${path}'s ${httpMethod} not exists`, -32000); + } + + const [requestData, endpointInfo] = params as Array; + + logger.debug(`${path}'s ${call} is being executed...`, requestData); + + try { + // deno-lint-ignore ban-types + const result = await (method as Function).apply(wrapComposedApp(endpoint, request), [ + requestData, + endpointInfo, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getModifier(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + ]); + + logger.debug(`${path}'s ${call} was successfully executed.`); + + return result; + } catch (e) { + logger.debug(`${path}'s ${call} was unsuccessful.`); + return new JsonRpcError(e.message || 'Internal server error', -32000); + } +} diff --git a/packages/apps/deno-runtime/handlers/app/construct.ts b/packages/apps/deno-runtime/handlers/app/construct.ts new file mode 100644 index 0000000000000..b391088fee217 --- /dev/null +++ b/packages/apps/deno-runtime/handlers/app/construct.ts @@ -0,0 +1,132 @@ +import { Socket } from 'node:net'; + +import type { IParseAppPackageResult } from '@rocket.chat/apps-engine/server/compiler/IParseAppPackageResult.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { require } from '../../lib/require.ts'; +import { sanitizeDeprecatedUsage } from '../../lib/sanitizeDeprecatedUsage.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; +import { RequestContext } from '../../lib/requestContext.ts'; + +const ALLOWED_NATIVE_MODULES = ['path', 'url', 'crypto', 'buffer', 'stream', 'net', 'http', 'https', 'zlib', 'util', 'punycode', 'os', 'querystring', 'fs']; +const ALLOWED_EXTERNAL_MODULES = ['uuid']; + +function prepareEnvironment() { + // Deno does not behave equally to Node when it comes to piping content to a socket + // So we intervene here + const originalFinal = Socket.prototype._final; + // deno-lint-ignore no-explicit-any + Socket.prototype._final = function _final(cb: any) { + // Deno closes the readable stream in the Socket earlier than Node + // The exact reason for that is yet unknown, so we'll need to simply delay the execution + // which allows data to be read in a response + setTimeout(() => originalFinal.call(this, cb), 1); + }; +} + +// As the apps are bundled, the only times they will call require are +// 1. To require native modules +// 2. To require external npm packages we may provide +// 3. To require apps-engine files +function buildRequire(): (module: string) => unknown { + return (module: string): unknown => { + // Normalize Node built-in specifiers: accept both 'crypto' and 'node:crypto' + const normalized = module.replace('node:', ''); + + if (ALLOWED_NATIVE_MODULES.includes(normalized)) { + return require(`node:${normalized}`); + } + + if (ALLOWED_EXTERNAL_MODULES.includes(module)) { + return require(`npm:${module}`); + } + + if (module.startsWith('@rocket.chat/apps-engine')) { + // Our `require` function knows how to handle these + return require(module); + } + + throw new Error(`Module ${module} is not allowed`); + }; +} + +function wrapAppCode(code: string): (require: (module: string) => unknown) => Promise> { + return new Function( + 'require', + ` + const { Buffer } = require('buffer'); + const exports = {}; + const module = { exports }; + const _error = console.error.bind(console); + const _console = { + log: _error, + error: _error, + debug: _error, + info: _error, + warn: _error, + }; + + const result = (async (exports,module,require,Buffer,console,globalThis,Deno) => { + ${code}; + })(exports,module,require,Buffer,_console,undefined,undefined); + + return result.then(() => module.exports);`, + ) as (require: (module: string) => unknown) => Promise>; +} + +export default async function handleConstructApp(request: RequestContext): Promise { + const { params } = request; + + if (!Array.isArray(params)) { + throw new Error('Invalid params', { cause: 'invalid_param_type' }); + } + + const [appPackage] = params as [IParseAppPackageResult]; + + if (!appPackage?.info?.id || !appPackage?.info?.classFile || !appPackage?.files) { + throw new Error('Invalid params', { cause: 'invalid_param_type' }); + } + + prepareEnvironment(); + + AppObjectRegistry.set('id', appPackage.info.id); + const source = sanitizeDeprecatedUsage(appPackage.files[appPackage.info.classFile]); + + const require = buildRequire(); + const exports = await wrapAppCode(source)(require); + + // This is the same naive logic we've been using in the App Compiler + // Applying the correct type here is quite difficult because of the dynamic nature of the code + // deno-lint-ignore no-explicit-any + const appClass = Object.values(exports)[0] as any; + + const app = new appClass(appPackage.info, request.context.logger, AppAccessorsInstance.getDefaultAppAccessors()); + + if (typeof app.getName !== 'function') { + throw new Error('App must contain a getName function'); + } + + if (typeof app.getNameSlug !== 'function') { + throw new Error('App must contain a getNameSlug function'); + } + + if (typeof app.getVersion !== 'function') { + throw new Error('App must contain a getVersion function'); + } + + if (typeof app.getID !== 'function') { + throw new Error('App must contain a getID function'); + } + + if (typeof app.getDescription !== 'function') { + throw new Error('App must contain a getDescription function'); + } + + if (typeof app.getRequiredApiVersion !== 'function') { + throw new Error('App must contain a getRequiredApiVersion function'); + } + + AppObjectRegistry.set('app', app); + + return true; +} diff --git a/packages/apps/deno-runtime/handlers/app/handleGetStatus.ts b/packages/apps/deno-runtime/handlers/app/handleGetStatus.ts new file mode 100644 index 0000000000000..8bd454b98ca7f --- /dev/null +++ b/packages/apps/deno-runtime/handlers/app/handleGetStatus.ts @@ -0,0 +1,15 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; + +export default function handleGetStatus(): Promise { + const app = AppObjectRegistry.get('app'); + + if (typeof app?.getStatus !== 'function') { + throw new Error('App must contain a getStatus function', { + cause: 'invalid_app', + }); + } + + return app.getStatus(); +} diff --git a/packages/apps/deno-runtime/handlers/app/handleInitialize.ts b/packages/apps/deno-runtime/handlers/app/handleInitialize.ts new file mode 100644 index 0000000000000..e8ee4ed1de136 --- /dev/null +++ b/packages/apps/deno-runtime/handlers/app/handleInitialize.ts @@ -0,0 +1,24 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; +import { RequestContext } from '../../lib/requestContext.ts'; +import { wrapAppForRequest } from '../../lib/wrapAppForRequest.ts'; + +export default async function handleInitialize(request: RequestContext): Promise { + const app = AppObjectRegistry.get('app'); + + if (typeof app?.initialize !== 'function') { + throw new Error('App must contain an initialize function', { + cause: 'invalid_app', + }); + } + + await app.initialize.call( + wrapAppForRequest(app, request), + AppAccessorsInstance.getConfigurationExtend(), + AppAccessorsInstance.getEnvironmentRead() + ); + + return true; +} diff --git a/packages/apps/deno-runtime/handlers/app/handleOnDisable.ts b/packages/apps/deno-runtime/handlers/app/handleOnDisable.ts new file mode 100644 index 0000000000000..ffac456cd9bc9 --- /dev/null +++ b/packages/apps/deno-runtime/handlers/app/handleOnDisable.ts @@ -0,0 +1,20 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; +import { RequestContext } from '../../lib/requestContext.ts'; +import { wrapAppForRequest } from '../../lib/wrapAppForRequest.ts'; + +export default async function handleOnDisable(request: RequestContext): Promise { + const app = AppObjectRegistry.get('app'); + + if (typeof app?.onDisable !== 'function') { + throw new Error('App must contain an onDisable function', { + cause: 'invalid_app', + }); + } + + await app.onDisable.call(wrapAppForRequest(app, request), AppAccessorsInstance.getConfigurationModify()); + + return true; +} diff --git a/packages/apps/deno-runtime/handlers/app/handleOnEnable.ts b/packages/apps/deno-runtime/handlers/app/handleOnEnable.ts new file mode 100644 index 0000000000000..34c1d49b0f367 --- /dev/null +++ b/packages/apps/deno-runtime/handlers/app/handleOnEnable.ts @@ -0,0 +1,22 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; +import { RequestContext } from '../../lib/requestContext.ts'; +import { wrapAppForRequest } from '../../lib/wrapAppForRequest.ts'; + +export default function handleOnEnable(request: RequestContext): Promise { + const app = AppObjectRegistry.get('app'); + + if (typeof app?.onEnable !== 'function') { + throw new Error('App must contain an onEnable function', { + cause: 'invalid_app', + }); + } + + return app.onEnable.call( + wrapAppForRequest(app, request), + AppAccessorsInstance.getEnvironmentRead(), + AppAccessorsInstance.getConfigurationModify() + ); +} diff --git a/packages/apps/deno-runtime/handlers/app/handleOnInstall.ts b/packages/apps/deno-runtime/handlers/app/handleOnInstall.ts new file mode 100644 index 0000000000000..d6e4ada5cf6f0 --- /dev/null +++ b/packages/apps/deno-runtime/handlers/app/handleOnInstall.ts @@ -0,0 +1,34 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; +import { RequestContext } from '../../lib/requestContext.ts'; +import { wrapAppForRequest } from '../../lib/wrapAppForRequest.ts'; + +export default async function handleOnInstall(request: RequestContext): Promise { + const { params } = request; + const app = AppObjectRegistry.get('app'); + + if (typeof app?.onInstall !== 'function') { + throw new Error('App must contain an onInstall function', { + cause: 'invalid_app', + }); + } + + if (!Array.isArray(params)) { + throw new Error('Invalid params', { cause: 'invalid_param_type' }); + } + + const [context] = params as [Record]; + + await app.onInstall.call( + wrapAppForRequest(app, request), + context, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + AppAccessorsInstance.getModifier(), + ); + + return true; +} diff --git a/packages/apps/deno-runtime/handlers/app/handleOnPreSettingUpdate.ts b/packages/apps/deno-runtime/handlers/app/handleOnPreSettingUpdate.ts new file mode 100644 index 0000000000000..601e1429be025 --- /dev/null +++ b/packages/apps/deno-runtime/handlers/app/handleOnPreSettingUpdate.ts @@ -0,0 +1,31 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; +import { RequestContext } from '../../lib/requestContext.ts'; +import { wrapAppForRequest } from '../../lib/wrapAppForRequest.ts'; + +export default function handleOnPreSettingUpdate(request: RequestContext): Promise { + const { params } = request; + const app = AppObjectRegistry.get('app'); + + if (typeof app?.onPreSettingUpdate !== 'function') { + throw new Error('App must contain an onPreSettingUpdate function', { + cause: 'invalid_app', + }); + } + + if (!Array.isArray(params)) { + throw new Error('Invalid params', { cause: 'invalid_param_type' }); + } + + const [setting] = params as [Record]; + + return app.onPreSettingUpdate.call( + wrapAppForRequest(app, request), + setting, + AppAccessorsInstance.getConfigurationModify(), + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getHttp(), + ); +} diff --git a/packages/apps/deno-runtime/handlers/app/handleOnSettingUpdated.ts b/packages/apps/deno-runtime/handlers/app/handleOnSettingUpdated.ts new file mode 100644 index 0000000000000..e78ece63dda92 --- /dev/null +++ b/packages/apps/deno-runtime/handlers/app/handleOnSettingUpdated.ts @@ -0,0 +1,33 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; +import { RequestContext } from '../../lib/requestContext.ts'; +import { wrapAppForRequest } from '../../lib/wrapAppForRequest.ts'; + +export default async function handleOnSettingUpdated(request: RequestContext): Promise { + const { params } = request; + const app = AppObjectRegistry.get('app'); + + if (typeof app?.onSettingUpdated !== 'function') { + throw new Error('App must contain an onSettingUpdated function', { + cause: 'invalid_app', + }); + } + + if (!Array.isArray(params)) { + throw new Error('Invalid params', { cause: 'invalid_param_type' }); + } + + const [setting] = params as [Record]; + + await app.onSettingUpdated.call( + wrapAppForRequest(app, request), + setting, + AppAccessorsInstance.getConfigurationModify(), + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getHttp(), + ); + + return true; +} diff --git a/packages/apps/deno-runtime/handlers/app/handleOnUninstall.ts b/packages/apps/deno-runtime/handlers/app/handleOnUninstall.ts new file mode 100644 index 0000000000000..34b02c2b45f1f --- /dev/null +++ b/packages/apps/deno-runtime/handlers/app/handleOnUninstall.ts @@ -0,0 +1,34 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; +import { RequestContext } from '../../lib/requestContext.ts'; +import { wrapAppForRequest } from '../../lib/wrapAppForRequest.ts'; + +export default async function handleOnUninstall(request: RequestContext): Promise { + const { params } = request; + const app = AppObjectRegistry.get('app'); + + if (typeof app?.onUninstall !== 'function') { + throw new Error('App must contain an onUninstall function', { + cause: 'invalid_app', + }); + } + + if (!Array.isArray(params)) { + throw new Error('Invalid params', { cause: 'invalid_param_type' }); + } + + const [context] = params as [Record]; + + await app.onUninstall.call( + wrapAppForRequest(app, request), + context, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + AppAccessorsInstance.getModifier(), + ); + + return true; +} diff --git a/packages/apps/deno-runtime/handlers/app/handleOnUpdate.ts b/packages/apps/deno-runtime/handlers/app/handleOnUpdate.ts new file mode 100644 index 0000000000000..0eb8643c928f4 --- /dev/null +++ b/packages/apps/deno-runtime/handlers/app/handleOnUpdate.ts @@ -0,0 +1,34 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; +import { RequestContext } from '../../lib/requestContext.ts'; +import { wrapAppForRequest } from '../../lib/wrapAppForRequest.ts'; + +export default async function handleOnUpdate(request: RequestContext): Promise { + const { params } = request; + const app = AppObjectRegistry.get('app'); + + if (typeof app?.onUpdate !== 'function') { + throw new Error('App must contain an onUpdate function', { + cause: 'invalid_app', + }); + } + + if (!Array.isArray(params)) { + throw new Error('Invalid params', { cause: 'invalid_param_type' }); + } + + const [context] = params as [Record]; + + await app.onUpdate.call( + wrapAppForRequest(app, request), + context, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + AppAccessorsInstance.getModifier(), + ); + + return true; +} diff --git a/packages/apps/deno-runtime/handlers/app/handleSetStatus.ts b/packages/apps/deno-runtime/handlers/app/handleSetStatus.ts new file mode 100644 index 0000000000000..163fa3684ae67 --- /dev/null +++ b/packages/apps/deno-runtime/handlers/app/handleSetStatus.ts @@ -0,0 +1,33 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; +import type { AppStatus as _AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { require } from '../../lib/require.ts'; +import { RequestContext } from '../../lib/requestContext.ts'; +import { wrapAppForRequest } from '../../lib/wrapAppForRequest.ts'; + +const { AppStatus } = require('@rocket.chat/apps-engine/definition/AppStatus.js') as { + AppStatus: typeof _AppStatus; +}; + +export default async function handleSetStatus(request: RequestContext): Promise { + const { params } = request; + + if (!Array.isArray(params) || !Object.values(AppStatus).includes(params[0])) { + throw new Error('Invalid params', { cause: 'invalid_param_type' }); + } + + const [status] = params as [typeof AppStatus]; + + const app = AppObjectRegistry.get('app'); + + if (!app || typeof app['setStatus'] !== 'function') { + throw new Error('App must contain a setStatus function', { + cause: 'invalid_app', + }); + } + + await app['setStatus'].call(wrapAppForRequest(app, request), status); + + return null; +} diff --git a/packages/apps/deno-runtime/handlers/app/handleUploadEvents.ts b/packages/apps/deno-runtime/handlers/app/handleUploadEvents.ts new file mode 100644 index 0000000000000..72d58801d537c --- /dev/null +++ b/packages/apps/deno-runtime/handlers/app/handleUploadEvents.ts @@ -0,0 +1,78 @@ +import { Buffer } from 'node:buffer'; + +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; +import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions/AppsEngineException.ts'; +import type { IFileUploadContext } from '@rocket.chat/apps-engine/definition/uploads/IFileUploadContext.ts' +import type { IUploadDetails } from '@rocket.chat/apps-engine/definition/uploads/IUploadDetails.ts' +import { toArrayBuffer } from '@std/streams'; +import { Defined, JsonRpcError } from 'jsonrpc-lite'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { assertAppAvailable, assertHandlerFunction, isPlainObject } from '../lib/assertions.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; +import { RequestContext } from '../../lib/requestContext.ts'; +import { wrapAppForRequest } from '../../lib/wrapAppForRequest.ts'; + +export const uploadEvents = ['executePreFileUpload'] as const; + +function assertIsUpload(v: unknown): asserts v is IUploadDetails { + if (isPlainObject(v) && !!v.rid && (!!v.userId || !!v.visitorToken)) return; + + throw JsonRpcError.invalidParams({ err: `Invalid 'file' parameter. Expected IUploadDetails, got`, value: v }); +} + +function assertString(v: unknown): asserts v is string { + if (v && typeof v === 'string') return; + + throw JsonRpcError.invalidParams({ err: `Invalid 'path' parameter. Expected string, got`, value: v }); +} + +export default async function handleUploadEvents(request: RequestContext): Promise { + const { method: rawMethod, params } = request as { method: `app:${typeof uploadEvents[number]}`; params: [{ file?: IUploadDetails, path?: string }]}; + const [, method] = rawMethod.split(':') as ['app', typeof uploadEvents[number]]; + + try { + const [{ file, path }] = params; + + const app = AppObjectRegistry.get('app'); + const handlerFunction = app?.[method as keyof App] as unknown; + + assertAppAvailable(app); + assertHandlerFunction(handlerFunction); + assertIsUpload(file); + assertString(path); + + using tempFile = await Deno.open(path, { read: true, create: false }); + let context: IFileUploadContext; + + switch (method) { + case 'executePreFileUpload': { + const fileContents = await toArrayBuffer(tempFile.readable); + context = { file, content: Buffer.from(fileContents) }; + break; + } + } + + return await handlerFunction.call( + wrapAppForRequest(app, request), + context, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + AppAccessorsInstance.getModifier(), + ); + } catch(e) { + if (e?.name === AppsEngineException.name) { + return new JsonRpcError(e.message, AppsEngineException.JSONRPC_ERROR_CODE, { name: e.name }); + } + + if (e instanceof JsonRpcError) { + return e; + } + + return JsonRpcError.internalError({ + err: e.message, + ...(e.code && { code: e.code }), + }); + } +} diff --git a/packages/apps/deno-runtime/handlers/app/handler.ts b/packages/apps/deno-runtime/handlers/app/handler.ts new file mode 100644 index 0000000000000..e0e8085813347 --- /dev/null +++ b/packages/apps/deno-runtime/handlers/app/handler.ts @@ -0,0 +1,115 @@ +import { Defined, JsonRpcError } from 'jsonrpc-lite'; + +import handleConstructApp from './construct.ts'; +import handleInitialize from './handleInitialize.ts'; +import handleGetStatus from './handleGetStatus.ts'; +import handleSetStatus from './handleSetStatus.ts'; +import handleOnEnable from './handleOnEnable.ts'; +import handleOnInstall from './handleOnInstall.ts'; +import handleOnDisable from './handleOnDisable.ts'; +import handleOnUninstall from './handleOnUninstall.ts'; +import handleOnPreSettingUpdate from './handleOnPreSettingUpdate.ts'; +import handleOnSettingUpdated from './handleOnSettingUpdated.ts'; +import handleOnUpdate from './handleOnUpdate.ts'; +import handleUploadEvents, { uploadEvents } from './handleUploadEvents.ts'; +import { isOneOf } from '../lib/assertions.ts'; +import handleListener from '../listener/handler.ts'; +import handleUIKitInteraction, { uikitInteractions } from '../uikit/handler.ts'; +import { RequestContext } from '../../lib/requestContext.ts'; + +export default async function handleApp(request: RequestContext): Promise { + const { method } = request; + const { logger } = request.context; + const [, appMethod] = method.split(':'); + + try { + // We don't want the getStatus method to generate logs, so we handle it separately + if (appMethod === 'getStatus') { + return await handleGetStatus(); + } + + logger.debug({ msg: `A method is being called...`, appMethod }); + + const formatResult = (result: Defined | JsonRpcError): Defined | JsonRpcError => { + if (result instanceof JsonRpcError) { + logger.debug({ + msg: `'${appMethod}' was unsuccessful.`, + appMethod, + err: result, + errorMessage: result.message, + }); + } else { + logger.debug({ + msg: `'${appMethod}' was successfully called! The result is:`, + appMethod, + result, + }); + } + + return result; + }; + + let result: Promise | undefined = undefined; + + if (isOneOf(appMethod, uploadEvents)) { + result = handleUploadEvents(request); + } else if (isOneOf(appMethod, uikitInteractions)) { + result = handleUIKitInteraction(request); + } else if (appMethod.startsWith('check') || appMethod.startsWith('execute')) { + result = handleListener(request); + } + + switch (appMethod) { + case 'construct': + result = handleConstructApp(request); + break; + case 'initialize': + result = handleInitialize(request); + break; + case 'setStatus': + result = handleSetStatus(request); + break; + case 'onEnable': + result = handleOnEnable(request); + break; + case 'onDisable': + result = handleOnDisable(request); + break; + case 'onInstall': + result = handleOnInstall(request); + break; + case 'onUninstall': + result = handleOnUninstall(request); + break; + case 'onPreSettingUpdate': + result = handleOnPreSettingUpdate(request); + break; + case 'onSettingUpdated': + result = handleOnSettingUpdated(request); + break; + case 'onUpdate': + result = handleOnUpdate(request); + break; + } + + if (typeof result === 'undefined') { + throw new JsonRpcError(`Unknown method "${appMethod}"`, -32601); + } + + return await result.then(formatResult); + } catch (e: unknown) { + if (!(e instanceof Error)) { + return new JsonRpcError('Unknown error', -32000, e); + } + + if ((e.cause as string)?.includes('invalid_param_type')) { + return JsonRpcError.invalidParams(null); + } + + if ((e.cause as string)?.includes('invalid_app')) { + return JsonRpcError.internalError({ message: 'App unavailable' }); + } + + return new JsonRpcError(e.message, -32000, e); + } +} diff --git a/packages/apps/deno-runtime/handlers/lib/assertions.ts b/packages/apps/deno-runtime/handlers/lib/assertions.ts new file mode 100644 index 0000000000000..d6dcd0a4c9651 --- /dev/null +++ b/packages/apps/deno-runtime/handlers/lib/assertions.ts @@ -0,0 +1,51 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; +import { JsonRpcError } from 'jsonrpc-lite'; + +/** + * Known failures that can happen in the runtime. + * + * DRT = Deno RunTime + */ +export const Errors = { + DRT_APP_NOT_AVAILABLE: 'DRT_APP_NOT_AVAILABLE', + DRT_EVENT_HANDLER_FUNCTION_MISSING: 'DRT_EVENT_HANDLER_FUNCTION_MISSING', +} + +export function isRecord(v: unknown): v is Record { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +export function isPlainObject(v: unknown): v is Record { + if (!isRecord(v)) { + return false; + } + + const prototype = Object.getPrototypeOf(v); + + return prototype === null || prototype.constructor === Object; +} + +/** + * Type guard function to check if a value is included in a readonly array + * and narrow its type accordingly. + */ +export function isOneOf(value: unknown, array: readonly T[]): value is T { + return array.includes(value as T); +} + +export function isApp(v: unknown): v is App { + return !!v && typeof (v as App)['extendConfiguration'] === 'function'; +} + +export function assertAppAvailable(v: unknown): asserts v is App { + if (isApp(v)) return; + + throw JsonRpcError.internalError({ err: 'App object not available', code: Errors.DRT_APP_NOT_AVAILABLE }); +} + +// deno-lint-ignore ban-types -- Function is the best we can do at this time +export function assertHandlerFunction(v: unknown): asserts v is Function { + if (v instanceof Function) return; + + throw JsonRpcError.internalError({ err: `Expected handler function, got ${v}`, code: Errors.DRT_EVENT_HANDLER_FUNCTION_MISSING }); +} diff --git a/packages/apps/deno-runtime/handlers/listener/handler.ts b/packages/apps/deno-runtime/handlers/listener/handler.ts new file mode 100644 index 0000000000000..88bc09b81403d --- /dev/null +++ b/packages/apps/deno-runtime/handlers/listener/handler.ts @@ -0,0 +1,153 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage.ts'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom.ts'; +import type { AppsEngineException as _AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions/AppsEngineException.ts'; +import { Defined, JsonRpcError } from 'jsonrpc-lite'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { MessageExtender } from '../../lib/accessors/extenders/MessageExtender.ts'; +import { RoomExtender } from '../../lib/accessors/extenders/RoomExtender.ts'; +import { MessageBuilder } from '../../lib/accessors/builders/MessageBuilder.ts'; +import { RoomBuilder } from '../../lib/accessors/builders/RoomBuilder.ts'; +import { AppAccessors, AppAccessorsInstance } from '../../lib/accessors/mod.ts'; +import { require } from '../../lib/require.ts'; +import createRoom from '../../lib/roomFactory.ts'; +import { Room } from '../../lib/room.ts'; +import { RequestContext } from '../../lib/requestContext.ts'; +import { wrapAppForRequest } from '../../lib/wrapAppForRequest.ts'; + +const { AppsEngineException } = require('@rocket.chat/apps-engine/definition/exceptions/AppsEngineException.js') as { + AppsEngineException: typeof _AppsEngineException; +}; + +export default async function handleListener(request: RequestContext): Promise { + const { method, params } = request; + const [, evtInterface] = method.split(':'); + const app = AppObjectRegistry.get('app'); + + const eventExecutor = app?.[evtInterface as keyof App]; + + if (!app || typeof eventExecutor !== 'function') { + return JsonRpcError.methodNotFound({ + message: 'Invalid event interface called on app', + }); + } + + if (!Array.isArray(params) || params.length < 1 || params.length > 2) { + return JsonRpcError.invalidParams(null); + } + + try { + const args = parseArgs({ AppAccessorsInstance }, evtInterface, params); + return await (eventExecutor as (...args: unknown[]) => Promise).apply(wrapAppForRequest(app, request), args); + } catch (e) { + if (e instanceof JsonRpcError) { + return e; + } + + if (e instanceof AppsEngineException) { + return new JsonRpcError(e.message, AppsEngineException.JSONRPC_ERROR_CODE, { name: e.name }); + } + + return JsonRpcError.internalError({ message: e.message }); + } +} + +export function parseArgs(deps: { AppAccessorsInstance: AppAccessors }, evtMethod: string, params: unknown[]): unknown[] { + const { AppAccessorsInstance } = deps; + /** + * param1 is the context for the event handler execution + * param2 is an optional extra content that some hanlers require + */ + const [param1, param2] = params as [unknown, unknown]; + + if (!param1) { + throw JsonRpcError.invalidParams(null); + } + + let context = param1; + + if (evtMethod.includes('Message')) { + context = hydrateMessageObjects(context) as Record; + } else if (evtMethod.endsWith('RoomUserJoined') || evtMethod.endsWith('RoomUserLeave')) { + (context as Record).room = createRoom((context as Record).room as IRoom, AppAccessorsInstance.getSenderFn()); + } else if (evtMethod.includes('PreRoom')) { + context = createRoom(context as IRoom, AppAccessorsInstance.getSenderFn()); + } + + const args: unknown[] = [context, AppAccessorsInstance.getReader(), AppAccessorsInstance.getHttp()]; + + // "check" events will only go this far - (context, reader, http) + if (evtMethod.startsWith('check')) { + // "checkPostMessageDeleted" has an extra param - (context, reader, http, extraContext) + if (param2) { + args.push(hydrateMessageObjects(param2)); + } + + return args; + } + + // From this point on, all events will require (reader, http, persistence) injected + args.push(AppAccessorsInstance.getPersistence()); + + // "extend" events have an additional "Extender" param - (context, extender, reader, http, persistence) + if (evtMethod.endsWith('Extend')) { + if (evtMethod.includes('Message')) { + args.splice(1, 0, new MessageExtender(param1 as IMessage)); + } else if (evtMethod.includes('Room')) { + args.splice(1, 0, new RoomExtender(param1 as IRoom)); + } + + return args; + } + + // "Modify" events have an additional "Builder" param - (context, builder, reader, http, persistence) + if (evtMethod.endsWith('Modify')) { + if (evtMethod.includes('Message')) { + args.splice(1, 0, new MessageBuilder(param1 as IMessage)); + } else if (evtMethod.includes('Room')) { + args.splice(1, 0, new RoomBuilder(param1 as IRoom)); + } + + return args; + } + + // From this point on, all events will require (reader, http, persistence, modifier) injected + args.push(AppAccessorsInstance.getModifier()); + + // This guy gets an extra one + if (evtMethod === 'executePostMessageDeleted') { + if (!param2) { + throw JsonRpcError.invalidParams(null); + } + + args.push(hydrateMessageObjects(param2)); + } + + return args; +} + +/** + * Hydrate the context object with the correct IMessage + * + * Some information is lost upon serializing the data from listeners through the pipes, + * so here we hydrate the complete object as necessary + */ +function hydrateMessageObjects(context: unknown): unknown { + if (objectIsRawMessage(context)) { + context.room = createRoom(context.room as IRoom, AppAccessorsInstance.getSenderFn()); + } else if ((context as Record)?.message) { + (context as Record).message = hydrateMessageObjects((context as Record).message); + } + + return context; +} + +function objectIsRawMessage(value: unknown): value is IMessage { + if (!value) return false; + + const { id, room, sender, createdAt } = value as Record; + + // Check if we have the fields of a message and the room hasn't already been hydrated + return !!(id && room && sender && createdAt) && !(room instanceof Room); +} diff --git a/packages/apps/deno-runtime/handlers/outboundcomms-handler.ts b/packages/apps/deno-runtime/handlers/outboundcomms-handler.ts new file mode 100644 index 0000000000000..cb425e61684f5 --- /dev/null +++ b/packages/apps/deno-runtime/handlers/outboundcomms-handler.ts @@ -0,0 +1,37 @@ +import type { IOutboundMessageProviders } from '@rocket.chat/apps-engine/definition/outboundCommunication/IOutboundCommsProvider.ts'; +import { JsonRpcError, Defined } from 'jsonrpc-lite'; + +import { AppObjectRegistry } from '../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../lib/accessors/mod.ts'; +import { RequestContext } from '../lib/requestContext.ts'; +import { wrapComposedApp } from '../lib/wrapAppForRequest.ts'; + +export default async function outboundMessageHandler(request: RequestContext): Promise { + const { method: call, params } = request; + const [, providerName, methodName] = call.split(':'); + + const provider = AppObjectRegistry.get(`outboundCommunication:${providerName}`); + + if (!provider) { + return new JsonRpcError('error-invalid-provider', -32000); + } + + const method = provider[methodName as keyof IOutboundMessageProviders]; + const { logger } = request.context; + const args = (params as Array) ?? []; + + try { + logger.debug(`Executing ${methodName} on outbound communication provider...`); + + // deno-lint-ignore ban-types + return await (method as Function).apply(wrapComposedApp(provider, request), [ + ...args, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getModifier(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + ]); + } catch (e) { + return new JsonRpcError(e.message, -32000); + } +} diff --git a/packages/apps/deno-runtime/handlers/scheduler-handler.ts b/packages/apps/deno-runtime/handlers/scheduler-handler.ts new file mode 100644 index 0000000000000..23c969ed46131 --- /dev/null +++ b/packages/apps/deno-runtime/handlers/scheduler-handler.ts @@ -0,0 +1,65 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; +import type { IProcessor } from '@rocket.chat/apps-engine/definition/scheduler/IProcessor.ts'; +import { Defined, JsonRpcError } from 'jsonrpc-lite'; + +import { AppObjectRegistry } from '../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../lib/accessors/mod.ts'; +import { RequestContext } from '../lib/requestContext.ts'; +import { wrapAppForRequest } from '../lib/wrapAppForRequest.ts'; +import { assertAppAvailable } from './lib/assertions.ts'; + +export default async function handleScheduler(request: RequestContext): Promise { + const { method, params } = request; + const { logger } = request.context; + + const [, processorId] = method.split(':'); + if (!Array.isArray(params)) { + return JsonRpcError.invalidParams({ message: 'Invalid params' }); + } + + const [context] = params as [Record]; + + // AppSchedulerManager will append the appId to the processor name to avoid conflicts + const processor = AppObjectRegistry.get(`scheduler:${processorId}`); + + if (!processor) { + return JsonRpcError.methodNotFound({ + message: `Could not find processor for method ${method}`, + }); + } + + logger.debug({ msg: 'Job processor is being executed...', processorId: processor.id }); + + const app = AppObjectRegistry.get('app'); + + try { + assertAppAvailable(app); + + await processor.processor.call( + // Processor registration doesn't require the App dev to instantiate a class passing + // a reference to an App object, so we don't have a good way of hijacking the Logger + // we need. + // The only way we have to provide a durable Logger instance for the processor is by + // binding its execution to the proxied App reference itself. Unfortunately, the API + // ends up being opaque, but there isn't much we can do for now. + wrapAppForRequest(app, request), + context, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getModifier(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + ); + + logger.debug({ msg: 'Job processor was successfully executed', processorId: processor.id }); + + return null; + } catch (err) { + logger.error({ err, msg: 'Job processor was unsuccessful', processorId: processor.id }); + + if (err instanceof JsonRpcError) { + return err; + } + + return JsonRpcError.internalError({ message: err.message }); + } +} diff --git a/packages/apps/deno-runtime/handlers/slashcommand-handler.ts b/packages/apps/deno-runtime/handlers/slashcommand-handler.ts new file mode 100644 index 0000000000000..de1a9ecd1efe1 --- /dev/null +++ b/packages/apps/deno-runtime/handlers/slashcommand-handler.ts @@ -0,0 +1,128 @@ +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom.ts'; +import type { ISlashCommand } from '@rocket.chat/apps-engine/definition/slashcommands/ISlashCommand.ts'; +import type { SlashCommandContext as _SlashCommandContext } from '@rocket.chat/apps-engine/definition/slashcommands/SlashCommandContext.ts'; +import type { Room as _Room } from '@rocket.chat/apps-engine/server/rooms/Room.ts'; +import { Defined, JsonRpcError } from 'jsonrpc-lite'; + +import { AppObjectRegistry } from '../AppObjectRegistry.ts'; +import { AppAccessors, AppAccessorsInstance } from '../lib/accessors/mod.ts'; +import { require } from '../lib/require.ts'; +import createRoom from '../lib/roomFactory.ts'; +import { RequestContext } from '../lib/requestContext.ts'; +import { wrapComposedApp } from '../lib/wrapAppForRequest.ts'; + +// For some reason Deno couldn't understand the typecast to the original interfaces and said it wasn't a constructor type +const { SlashCommandContext } = require('@rocket.chat/apps-engine/definition/slashcommands/SlashCommandContext.js') as { + SlashCommandContext: typeof _SlashCommandContext; +}; + +export default async function slashCommandHandler(request: RequestContext): Promise { + const { method: call, params } = request; + const { logger } = request.context; + + const [, commandName, method] = call.split(':'); + + const command = AppObjectRegistry.get(`slashcommand:${commandName}`); + + if (!command) { + return new JsonRpcError(`Slashcommand ${commandName} not found`, -32000); + } + + let result: Awaited> | Awaited>; + + logger.debug({ msg: `Command is being executed...`, commandName, method, params }); + + try { + if (method === 'executor' || method === 'previewer') { + result = await handleExecutor({ AppAccessorsInstance, request }, command, method, params); + } else if (method === 'executePreviewItem') { + result = await handlePreviewItem({ AppAccessorsInstance, request }, command, params); + } else { + return new JsonRpcError(`Method ${method} not found on slashcommand ${commandName}`, -32000); + } + + logger.debug({ msg: `Command was successfully executed.`, commandName, method }); + } catch (error) { + logger.debug({ msg: `Command was unsuccessful.`, commandName, method, err: error }); + + return new JsonRpcError(error.message, -32000); + } + + return result; +} + +type Deps = { + AppAccessorsInstance: AppAccessors, + request: RequestContext; +} + +/** + * @param deps Dependencies that need to be injected into the slashcommand + * @param command The slashcommand that is being executed + * @param method The method that is being executed + * @param params The parameters that are being passed to the method + */ +export function handleExecutor(deps: Deps, command: ISlashCommand, method: 'executor' | 'previewer', params: unknown) { + const executor = command[method]; + + if (typeof executor !== 'function') { + throw new Error(`Method ${method} not found on slashcommand ${command.command}`); + } + + if (!Array.isArray(params) || typeof params[0] !== 'object' || !params[0]) { + throw new Error(`First parameter must be an object`); + } + + const { sender, room, params: args, threadId, triggerId } = params[0] as Record; + + const context = new SlashCommandContext( + sender as _SlashCommandContext['sender'], + createRoom(room as IRoom, deps.AppAccessorsInstance.getSenderFn()), + args as _SlashCommandContext['params'], + threadId as _SlashCommandContext['threadId'], + triggerId as _SlashCommandContext['triggerId'], + ); + + return executor.apply(wrapComposedApp(command, deps.request), [ + context, + deps.AppAccessorsInstance.getReader(), + deps.AppAccessorsInstance.getModifier(), + deps.AppAccessorsInstance.getHttp(), + deps.AppAccessorsInstance.getPersistence(), + ]); +} + +/** + * @param deps Dependencies that need to be injected into the slashcommand + * @param command The slashcommand that is being executed + * @param params The parameters that are being passed to the method + */ +export function handlePreviewItem(deps: Deps, command: ISlashCommand, params: unknown) { + if (typeof command.executePreviewItem !== 'function') { + throw new Error(`Method not found on slashcommand ${command.command}`); + } + + if (!Array.isArray(params) || typeof params[0] !== 'object' || !params[0]) { + throw new Error(`First parameter must be an object`); + } + + const [previewItem, { sender, room, params: args, threadId, triggerId }] = params as [Record, Record]; + + const context = new SlashCommandContext( + sender as _SlashCommandContext['sender'], + createRoom(room as IRoom, deps.AppAccessorsInstance.getSenderFn()), + args as _SlashCommandContext['params'], + threadId as _SlashCommandContext['threadId'], + triggerId as _SlashCommandContext['triggerId'], + ); + + return command.executePreviewItem.call( + wrapComposedApp(command, deps.request), + previewItem, + context, + deps.AppAccessorsInstance.getReader(), + deps.AppAccessorsInstance.getModifier(), + deps.AppAccessorsInstance.getHttp(), + deps.AppAccessorsInstance.getPersistence(), + ); +} diff --git a/packages/apps/deno-runtime/handlers/tests/api-handler.test.ts b/packages/apps/deno-runtime/handlers/tests/api-handler.test.ts new file mode 100644 index 0000000000000..26ccbd43fb80f --- /dev/null +++ b/packages/apps/deno-runtime/handlers/tests/api-handler.test.ts @@ -0,0 +1,118 @@ +// deno-lint-ignore-file no-explicit-any +import type { IApiEndpoint } from '@rocket.chat/apps-engine/definition/api/IApiEndpoint.ts'; +import { assertEquals, assertObjectMatch } from 'https://deno.land/std@0.203.0/assert/mod.ts'; +import { beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; +import { spy } from 'https://deno.land/std@0.203.0/testing/mock.ts'; +import { assertInstanceOf } from 'https://deno.land/std@0.203.0/assert/assert_instance_of.ts'; +import { JsonRpcError } from 'jsonrpc-lite'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import apiHandler from '../api-handler.ts'; +import { createMockRequest } from './helpers/mod.ts'; + +describe('handlers > api', () => { + const mockEndpoint: IApiEndpoint = { + path: '/test', + // deno-lint-ignore no-unused-vars + get: (request: any, endpoint: any, read: any, modify: any, http: any, persis: any) => Promise.resolve('ok'), + // deno-lint-ignore no-unused-vars + post: (request: any, endpoint: any, read: any, modify: any, http: any, persis: any) => Promise.resolve('ok'), + // deno-lint-ignore no-unused-vars + put: (request: any, endpoint: any, read: any, modify: any, http: any, persis: any) => { + throw new Error('Method execution error example'); + }, + }; + + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('api:/test', mockEndpoint); + }); + + it('correctly handles execution of an api endpoint method GET', async () => { + const _spy = spy(mockEndpoint, 'get'); + + const result = await apiHandler(createMockRequest({ method: 'api:/test:get', params: ['request', 'endpointInfo'] })); + + assertEquals(result, 'ok'); + assertEquals(_spy.calls[0].args.length, 6); + assertEquals(_spy.calls[0].args[0], 'request'); + assertEquals(_spy.calls[0].args[1], 'endpointInfo'); + }); + + it('correctly handles execution of an api endpoint method POST', async () => { + const _spy = spy(mockEndpoint, 'post'); + + const result = await apiHandler(createMockRequest({ method: 'api:/test:post', params: ['request', 'endpointInfo'] })); + + assertEquals(result, 'ok'); + assertEquals(_spy.calls[0].args.length, 6); + assertEquals(_spy.calls[0].args[0], 'request'); + assertEquals(_spy.calls[0].args[1], 'endpointInfo'); + }); + + it('correctly handles an error if the method not exists for the selected endpoint', async () => { + const result = await apiHandler(createMockRequest({ method: `api:/test:delete`, params: ['request', 'endpointInfo'] })); + + assertInstanceOf(result, JsonRpcError); + assertObjectMatch(result, { + message: `/test's delete not exists`, + code: -32000, + }); + }); + + it('correctly handles an error if endpoint not exists', async () => { + const result = await apiHandler(createMockRequest({ method: `api:/error:get`, params: ['request', 'endpointInfo'] })); + + assertInstanceOf(result, JsonRpcError); + assertObjectMatch(result, { + message: `Endpoint /error not found`, + code: -32000, + }); + }); + + it('correctly handles an error if the method execution fails', async () => { + const result = await apiHandler(createMockRequest({ method: `api:/test:put`, params: ['request', 'endpointInfo'] })); + + assertInstanceOf(result, JsonRpcError); + assertObjectMatch(result, { + message: `Method execution error example`, + code: -32000, + }); + }); + + it('correctly handles dynamic paths with parameters (e.g., webhook/:event)', async () => { + const mockDynamicEndpoint: IApiEndpoint = { + path: 'webhook/:event', + // deno-lint-ignore no-unused-vars + post: (request: any, endpoint: any, read: any, modify: any, http: any, persis: any) => Promise.resolve('webhook handled'), + }; + + AppObjectRegistry.set('api:webhook/:event', mockDynamicEndpoint); + + const _spy = spy(mockDynamicEndpoint, 'post'); + + const result = await apiHandler(createMockRequest({ method: 'api:webhook/:event:post', params: ['request', 'endpointInfo'] })); + + assertEquals(result, 'webhook handled'); + assertEquals(_spy.calls[0].args.length, 6); + assertEquals(_spy.calls[0].args[0], 'request'); + assertEquals(_spy.calls[0].args[1], 'endpointInfo'); + }); + + it('correctly handles paths with multiple segments and colons', async () => { + const mockComplexEndpoint: IApiEndpoint = { + path: 'api/v1/:resource/:id', + // deno-lint-ignore no-unused-vars + get: (request: any, endpoint: any, read: any, modify: any, http: any, persis: any) => Promise.resolve('complex path'), + }; + + AppObjectRegistry.set('api:api/v1/:resource/:id', mockComplexEndpoint); + + const _spy = spy(mockComplexEndpoint, 'get'); + + const result = await apiHandler(createMockRequest({ method: 'api:api/v1/:resource/:id:get', params: ['request', 'endpointInfo'] })); + + assertEquals(result, 'complex path'); + assertEquals(_spy.calls[0].args.length, 6); + }); +}); diff --git a/packages/apps/deno-runtime/handlers/tests/helpers/mod.ts b/packages/apps/deno-runtime/handlers/tests/helpers/mod.ts new file mode 100644 index 0000000000000..581d95e4c3484 --- /dev/null +++ b/packages/apps/deno-runtime/handlers/tests/helpers/mod.ts @@ -0,0 +1,29 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { Logger } from '../../../lib/logger.ts'; +import { RequestDescriptor } from '../../../lib/messenger.ts'; +import { RequestContext } from '../../../lib/requestContext.ts'; + +export function createMockRequest({ method, params }: RequestDescriptor): RequestContext { + return { + jsonrpc: '2.0', + id: 1, + method, + params, + context: { + logger: new Logger(method), + }, + serialize: () => '', + } +} + +export function createMockApp(): App { + return { + extendConfiguration: () => {}, + getID: () => 'mockApp', + getLogger: () => ({ + debug: () => {}, + error: () => {}, + }), + } as unknown as App; +} diff --git a/packages/apps/deno-runtime/handlers/tests/listener-handler.test.ts b/packages/apps/deno-runtime/handlers/tests/listener-handler.test.ts new file mode 100644 index 0000000000000..8e355f6ac4d3d --- /dev/null +++ b/packages/apps/deno-runtime/handlers/tests/listener-handler.test.ts @@ -0,0 +1,234 @@ +// deno-lint-ignore-file no-explicit-any +import { assertEquals, assertInstanceOf, assertObjectMatch } from 'https://deno.land/std@0.203.0/assert/mod.ts'; +import { describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; + +import { parseArgs } from '../listener/handler.ts'; +import { AppAccessors } from '../../lib/accessors/mod.ts'; +import { Room } from '../../lib/room.ts'; +import { MessageExtender } from '../../lib/accessors/extenders/MessageExtender.ts'; +import { RoomExtender } from '../../lib/accessors/extenders/RoomExtender.ts'; +import { MessageBuilder } from '../../lib/accessors/builders/MessageBuilder.ts'; +import { RoomBuilder } from '../../lib/accessors/builders/RoomBuilder.ts'; + +describe('handlers > listeners', () => { + const mockAppAccessors = { + getReader: () => ({ __type: 'reader' }), + getHttp: () => ({ __type: 'http' }), + getModifier: () => ({ __type: 'modifier' }), + getPersistence: () => ({ __type: 'persistence' }), + getSenderFn: () => (id: string) => Promise.resolve([{ __type: 'bridgeCall' }, { id }]), + } as unknown as AppAccessors; + + it('correctly parses the arguments for a request to trigger the "checkPreMessageSentPrevent" method', () => { + const evtMethod = 'checkPreMessageSentPrevent'; + // For the 'checkPreMessageSentPrevent' method, the context will be a message in a real scenario + const evtArgs = [{ __type: 'context' }]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assertEquals(params.length, 3); + assertEquals(params[0], { __type: 'context' }); + assertEquals(params[1], { __type: 'reader' }); + assertEquals(params[2], { __type: 'http' }); + }); + + it('correctly parses the arguments for a request to trigger the "checkPostMessageDeleted" method', () => { + const evtMethod = 'checkPostMessageDeleted'; + // For the 'checkPostMessageDeleted' method, the context will be a message in a real scenario, + // and the extraContext will provide further information such the user who deleted the message + const evtArgs = [{ __type: 'context' }, { __type: 'extraContext' }]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assertEquals(params.length, 4); + assertEquals(params[0], { __type: 'context' }); + assertEquals(params[1], { __type: 'reader' }); + assertEquals(params[2], { __type: 'http' }); + assertEquals(params[3], { __type: 'extraContext' }); + }); + + it('correctly parses the arguments for a request to trigger the "checkPreRoomCreateExtend" method', () => { + const evtMethod = 'checkPreRoomCreateExtend'; + // For the 'checkPreRoomCreateExtend' method, the context will be a room in a real scenario + const evtArgs = [ + { + id: 'fake', + type: 'fake', + slugifiedName: 'fake', + creator: 'fake', + createdAt: Date.now(), + }, + ]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assertEquals(params.length, 3); + + assertInstanceOf(params[0], Room); + assertEquals(params[1], { __type: 'reader' }); + assertEquals(params[2], { __type: 'http' }); + }); + + it('correctly parses the arguments for a request to trigger the "executePreMessageSentExtend" method', () => { + const evtMethod = 'executePreMessageSentExtend'; + // For the 'executePreMessageSentExtend' method, the context will be a message in a real scenario + const evtArgs = [{ __type: 'context' }]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assertEquals(params.length, 5); + // Instantiating the MessageExtender might modify the original object, so we need to assert it matches instead of equals + assertObjectMatch(params[0] as Record, { + __type: 'context', + }); + assertInstanceOf(params[1], MessageExtender); + assertEquals(params[2], { __type: 'reader' }); + assertEquals(params[3], { __type: 'http' }); + assertEquals(params[4], { __type: 'persistence' }); + }); + + it('correctly parses the arguments for a request to trigger the "executePreRoomCreateExtend" method', () => { + const evtMethod = 'executePreRoomCreateExtend'; + // For the 'executePreRoomCreateExtend' method, the context will be a room in a real scenario + const evtArgs = [{ __type: 'context' }]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assertEquals(params.length, 5); + // Instantiating the RoomExtender might modify the original object, so we need to assert it matches instead of equals + assertObjectMatch(params[0] as Record, { + __type: 'context', + }); + assertInstanceOf(params[1], RoomExtender); + assertEquals(params[2], { __type: 'reader' }); + assertEquals(params[3], { __type: 'http' }); + assertEquals(params[4], { __type: 'persistence' }); + }); + + it('correctly parses the arguments for a request to trigger the "executePreMessageSentModify" method', () => { + const evtMethod = 'executePreMessageSentModify'; + // For the 'executePreMessageSentModify' method, the context will be a message in a real scenario + const evtArgs = [{ __type: 'context' }]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assertEquals(params.length, 5); + // Instantiating the MessageBuilder might modify the original object, so we need to assert it matches instead of equals + assertObjectMatch(params[0] as Record, { + __type: 'context', + }); + assertInstanceOf(params[1], MessageBuilder); + assertEquals(params[2], { __type: 'reader' }); + assertEquals(params[3], { __type: 'http' }); + assertEquals(params[4], { __type: 'persistence' }); + }); + + it('correctly parses the arguments for a request to trigger the "executePreRoomCreateModify" method', () => { + const evtMethod = 'executePreRoomCreateModify'; + // For the 'executePreRoomCreateModify' method, the context will be a room in a real scenario + const evtArgs = [{ __type: 'context' }]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assertEquals(params.length, 5); + // Instantiating the RoomBuilder might modify the original object, so we need to assert it matches instead of equals + assertObjectMatch(params[0] as Record, { + __type: 'context', + }); + assertInstanceOf(params[1], RoomBuilder); + assertEquals(params[2], { __type: 'reader' }); + assertEquals(params[3], { __type: 'http' }); + assertEquals(params[4], { __type: 'persistence' }); + }); + + it('correctly parses the arguments for a request to trigger the "executePostRoomUserJoined" method', () => { + const evtMethod = 'executePostRoomUserJoined'; + // For the 'executePostRoomUserJoined' method, the context will be a room in a real scenario + const room = { + id: 'fake', + type: 'fake', + slugifiedName: 'fake', + creator: 'fake', + createdAt: Date.now(), + }; + + const evtArgs = [{ __type: 'context', room }]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assertEquals(params.length, 5); + assertInstanceOf((params[0] as any).room, Room); + assertEquals(params[1], { __type: 'reader' }); + assertEquals(params[2], { __type: 'http' }); + assertEquals(params[3], { __type: 'persistence' }); + assertEquals(params[4], { __type: 'modifier' }); + }); + + it('correctly parses the arguments for a request to trigger the "executePostRoomUserLeave" method', () => { + const evtMethod = 'executePostRoomUserLeave'; + // For the 'executePostRoomUserLeave' method, the context will be a room in a real scenario + const room = { + id: 'fake', + type: 'fake', + slugifiedName: 'fake', + creator: 'fake', + createdAt: Date.now(), + }; + + const evtArgs = [{ __type: 'context', room }]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assertEquals(params.length, 5); + assertInstanceOf((params[0] as any).room, Room); + assertEquals(params[1], { __type: 'reader' }); + assertEquals(params[2], { __type: 'http' }); + assertEquals(params[3], { __type: 'persistence' }); + assertEquals(params[4], { __type: 'modifier' }); + }); + + it('correctly parses the arguments for a request to trigger the "executePostMessageDeleted" method', () => { + const evtMethod = 'executePostMessageDeleted'; + // For the 'executePostMessageDeleted' method, the context will be a message in a real scenario + const evtArgs = [{ __type: 'context' }, { __type: 'extraContext' }]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assertEquals(params.length, 6); + assertEquals(params[0], { __type: 'context' }); + assertEquals(params[1], { __type: 'reader' }); + assertEquals(params[2], { __type: 'http' }); + assertEquals(params[3], { __type: 'persistence' }); + assertEquals(params[4], { __type: 'modifier' }); + assertEquals(params[5], { __type: 'extraContext' }); + }); + + it('correctly parses the arguments for a request to trigger the "executePostMessageSent" method', () => { + const evtMethod = 'executePostMessageSent'; + // For the 'executePostMessageDeleted' method, the context will be a message in a real scenario + const evtArgs = [ + { + id: 'fake', + sender: 'fake', + createdAt: Date.now(), + room: { + id: 'fake-room', + type: 'fake', + slugifiedName: 'fake', + creator: 'fake', + createdAt: Date.now(), + }, + }, + ]; + + const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs); + + assertEquals(params.length, 5); + assertObjectMatch(params[0] as Record, { id: 'fake' }); + assertInstanceOf((params[0] as any).room, Room); + assertEquals(params[1], { __type: 'reader' }); + assertEquals(params[2], { __type: 'http' }); + assertEquals(params[3], { __type: 'persistence' }); + assertEquals(params[4], { __type: 'modifier' }); + }); +}); diff --git a/packages/apps/deno-runtime/handlers/tests/scheduler-handler.test.ts b/packages/apps/deno-runtime/handlers/tests/scheduler-handler.test.ts new file mode 100644 index 0000000000000..7f5c6eccaf569 --- /dev/null +++ b/packages/apps/deno-runtime/handlers/tests/scheduler-handler.test.ts @@ -0,0 +1,41 @@ +import { assertEquals } from 'https://deno.land/std@0.203.0/assert/mod.ts'; +import { afterAll, beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessors } from '../../lib/accessors/mod.ts'; +import handleScheduler from '../scheduler-handler.ts'; +import { createMockApp, createMockRequest } from './helpers/mod.ts'; + +describe('handlers > scheduler', () => { + const mockAppAccessors = new AppAccessors(() => + Promise.resolve({ + id: 'mockId', + result: {}, + jsonrpc: '2.0', + serialize: () => '', + }) + ); + + const mockApp = createMockApp(); + + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('app', mockApp); + mockAppAccessors.getConfigurationExtend().scheduler.registerProcessors([ + { + id: 'mockId', + processor: () => Promise.resolve('it works!'), + }, + ]); + }); + + afterAll(() => { + AppObjectRegistry.clear(); + }); + + it('correctly executes a request to a processor', async () => { + const result = await handleScheduler(createMockRequest({ method: 'scheduler:mockId', params: [{}] })); + + assertEquals(result, null); + }); +}); diff --git a/packages/apps/deno-runtime/handlers/tests/slashcommand-handler.test.ts b/packages/apps/deno-runtime/handlers/tests/slashcommand-handler.test.ts new file mode 100644 index 0000000000000..7114aa1f85bea --- /dev/null +++ b/packages/apps/deno-runtime/handlers/tests/slashcommand-handler.test.ts @@ -0,0 +1,159 @@ +// deno-lint-ignore-file no-explicit-any +import { assertEquals, assertInstanceOf } from 'https://deno.land/std@0.203.0/assert/mod.ts'; +import { beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; +import { spy } from 'https://deno.land/std@0.203.0/testing/mock.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessors } from '../../lib/accessors/mod.ts'; +import { handleExecutor, handlePreviewItem } from '../slashcommand-handler.ts'; +import { Room } from '../../lib/room.ts'; +import { createMockRequest } from './helpers/mod.ts'; + +describe('handlers > slashcommand', () => { + const mockAppAccessors = { + getReader: () => ({ __type: 'reader' }), + getHttp: () => ({ __type: 'http' }), + getModifier: () => ({ __type: 'modifier' }), + getPersistence: () => ({ __type: 'persistence' }), + getSenderFn: () => (id: string) => Promise.resolve([{ __type: 'bridgeCall' }, { id }]), + } as unknown as AppAccessors; + + const mockCommandExecutorOnly = { + command: 'executor-only', + i18nParamsExample: 'test', + i18nDescription: 'test', + providesPreview: false, + // deno-lint-ignore no-unused-vars + async executor(context: any, read: any, modify: any, http: any, persis: any): Promise {}, + }; + + const mockCommandExecutorAndPreview = { + command: 'executor-and-preview', + i18nParamsExample: 'test', + i18nDescription: 'test', + providesPreview: true, + // deno-lint-ignore no-unused-vars + async executor(context: any, read: any, modify: any, http: any, persis: any): Promise {}, + // deno-lint-ignore no-unused-vars + async previewer(context: any, read: any, modify: any, http: any, persis: any): Promise {}, + // deno-lint-ignore no-unused-vars + async executePreviewItem(previewItem: any, context: any, read: any, modify: any, http: any, persis: any): Promise {}, + }; + + const mockCommandPreviewWithNoExecutor = { + command: 'preview-with-no-executor', + i18nParamsExample: 'test', + i18nDescription: 'test', + providesPreview: true, + // deno-lint-ignore no-unused-vars + async previewer(context: any, read: any, modify: any, http: any, persis: any): Promise {}, + // deno-lint-ignore no-unused-vars + async executePreviewItem(previewItem: any, context: any, read: any, modify: any, http: any, persis: any): Promise {}, + }; + + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('slashcommand:executor-only', mockCommandExecutorOnly); + AppObjectRegistry.set('slashcommand:executor-and-preview', mockCommandExecutorAndPreview); + AppObjectRegistry.set('slashcommand:preview-with-no-executor', mockCommandPreviewWithNoExecutor); + }); + + it('correctly handles execution of a slash command', async () => { + const mockContext = { + sender: { __type: 'sender' }, + room: { __type: 'room' }, + params: { __type: 'params' }, + threadId: 'threadId', + triggerId: 'triggerId', + }; + + const _spy = spy(mockCommandExecutorOnly, 'executor'); + + const mockRequest = createMockRequest({ method: 'slashcommand:executor-only:executor', params: [mockContext] }); + + await handleExecutor({ AppAccessorsInstance: mockAppAccessors, request: mockRequest }, mockCommandExecutorOnly, 'executor', [mockContext]); + + const context = _spy.calls[0].args[0]; + + assertInstanceOf(context.getRoom(), Room); + assertEquals(context.getSender(), { __type: 'sender' }); + assertEquals(context.getArguments(), { __type: 'params' }); + assertEquals(context.getThreadId(), 'threadId'); + assertEquals(context.getTriggerId(), 'triggerId'); + + assertEquals(_spy.calls[0].args[1], mockAppAccessors.getReader()); + assertEquals(_spy.calls[0].args[2], mockAppAccessors.getModifier()); + assertEquals(_spy.calls[0].args[3], mockAppAccessors.getHttp()); + assertEquals(_spy.calls[0].args[4], mockAppAccessors.getPersistence()); + + _spy.restore(); + }); + + it('correctly handles execution of a slash command previewer', async () => { + const mockContext = { + sender: { __type: 'sender' }, + room: { __type: 'room' }, + params: { __type: 'params' }, + threadId: 'threadId', + triggerId: 'triggerId', + }; + + const _spy = spy(mockCommandExecutorAndPreview, 'previewer'); + + const mockRequest = createMockRequest({ method: 'slashcommand:executor-and-preview:previewer', params: [mockContext] }); + + await handleExecutor({ AppAccessorsInstance: mockAppAccessors, request: mockRequest }, mockCommandExecutorAndPreview, 'previewer', [mockContext]); + + const context = _spy.calls[0].args[0]; + + assertInstanceOf(context.getRoom(), Room); + assertEquals(context.getSender(), { __type: 'sender' }); + assertEquals(context.getArguments(), { __type: 'params' }); + assertEquals(context.getThreadId(), 'threadId'); + assertEquals(context.getTriggerId(), 'triggerId'); + + assertEquals(_spy.calls[0].args[1], mockAppAccessors.getReader()); + assertEquals(_spy.calls[0].args[2], mockAppAccessors.getModifier()); + assertEquals(_spy.calls[0].args[3], mockAppAccessors.getHttp()); + assertEquals(_spy.calls[0].args[4], mockAppAccessors.getPersistence()); + + _spy.restore(); + }); + + it('correctly handles execution of a slash command preview item executor', async () => { + const mockContext = { + sender: { __type: 'sender' }, + room: { __type: 'room' }, + params: { __type: 'params' }, + threadId: 'threadId', + triggerId: 'triggerId', + }; + + const mockPreviewItem = { + id: 'previewItemId', + type: 'image', + value: 'https://example.com/image.png', + }; + + const _spy = spy(mockCommandExecutorAndPreview, 'executePreviewItem'); + + const mockRequest = createMockRequest({ method: 'slashcommand:executor-and-preview:executePreviewItem', params: [mockPreviewItem, mockContext] }); + + await handlePreviewItem({ AppAccessorsInstance: mockAppAccessors, request: mockRequest }, mockCommandExecutorAndPreview, [mockPreviewItem, mockContext]); + + const context = _spy.calls[0].args[1]; + + assertInstanceOf(context.getRoom(), Room); + assertEquals(context.getSender(), { __type: 'sender' }); + assertEquals(context.getArguments(), { __type: 'params' }); + assertEquals(context.getThreadId(), 'threadId'); + assertEquals(context.getTriggerId(), 'triggerId'); + + assertEquals(_spy.calls[0].args[2], mockAppAccessors.getReader()); + assertEquals(_spy.calls[0].args[3], mockAppAccessors.getModifier()); + assertEquals(_spy.calls[0].args[4], mockAppAccessors.getHttp()); + assertEquals(_spy.calls[0].args[5], mockAppAccessors.getPersistence()); + + _spy.restore(); + }); +}); diff --git a/packages/apps/deno-runtime/handlers/tests/uikit-handler.test.ts b/packages/apps/deno-runtime/handlers/tests/uikit-handler.test.ts new file mode 100644 index 0000000000000..b663bd2ae6833 --- /dev/null +++ b/packages/apps/deno-runtime/handlers/tests/uikit-handler.test.ts @@ -0,0 +1,105 @@ +// deno-lint-ignore-file no-explicit-any +import { assertInstanceOf } from 'https://deno.land/std@0.203.0/assert/mod.ts'; +import { afterAll, beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; +import jsonrpc from 'jsonrpc-lite'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import handleUIKitInteraction, { + UIKitActionButtonInteractionContext, + UIKitBlockInteractionContext, + UIKitLivechatBlockInteractionContext, + UIKitViewCloseInteractionContext, + UIKitViewSubmitInteractionContext, +} from '../uikit/handler.ts'; + +describe('handlers > uikit', () => { + const mockApp = { + getID: (): string => 'appId', + executeBlockActionHandler: (context: any): Promise => Promise.resolve(context), + executeViewSubmitHandler: (context: any): Promise => Promise.resolve(context), + executeViewClosedHandler: (context: any): Promise => Promise.resolve(context), + executeActionButtonHandler: (context: any): Promise => Promise.resolve(context), + executeLivechatBlockActionHandler: (context: any): Promise => Promise.resolve(context), + }; + + beforeEach(() => { + AppObjectRegistry.set('app', mockApp); + }); + + afterAll(() => { + AppObjectRegistry.clear(); + }); + + it('successfully handles a call for "executeBlockActionHandler"', async () => { + const request = jsonrpc.request(1, 'app:executeBlockActionHandler', [ + { + actionId: 'actionId', + blockId: 'blockId', + value: 'value', + viewId: 'viewId', + }, + ]); + + const result = await handleUIKitInteraction(request); + assertInstanceOf(result, UIKitBlockInteractionContext); + }); + + it('successfully handles a call for "executeViewSubmitHandler"', async () => { + const request = jsonrpc.request(1, 'app:executeViewSubmitHandler', [ + { + viewId: 'viewId', + appId: 'appId', + userId: 'userId', + isAppUser: true, + values: {}, + }, + ]); + + const result = await handleUIKitInteraction(request); + assertInstanceOf(result, UIKitViewSubmitInteractionContext); + }); + + it('successfully handles a call for "executeViewClosedHandler"', async () => { + const request = jsonrpc.request(1, 'app:executeViewClosedHandler', [ + { + viewId: 'viewId', + appId: 'appId', + userId: 'userId', + isAppUser: true, + }, + ]); + + const result = await handleUIKitInteraction(request); + assertInstanceOf(result, UIKitViewCloseInteractionContext); + }); + + it('successfully handles a call for "executeActionButtonHandler"', async () => { + const request = jsonrpc.request(1, 'app:executeActionButtonHandler', [ + { + actionId: 'actionId', + appId: 'appId', + userId: 'userId', + isAppUser: true, + }, + ]); + + const result = await handleUIKitInteraction(request); + assertInstanceOf(result, UIKitActionButtonInteractionContext); + }); + + it('successfully handles a call for "executeLivechatBlockActionHandler"', async () => { + const request = jsonrpc.request(1, 'app:executeLivechatBlockActionHandler', [ + { + actionId: 'actionId', + appId: 'appId', + userId: 'userId', + visitor: {}, + isAppUser: true, + room: {}, + }, + ]); + + const result = await handleUIKitInteraction(request); + assertInstanceOf(result, UIKitLivechatBlockInteractionContext); + }); +}); diff --git a/packages/apps/deno-runtime/handlers/tests/upload-event-handler.test.ts b/packages/apps/deno-runtime/handlers/tests/upload-event-handler.test.ts new file mode 100644 index 0000000000000..182d32eda3353 --- /dev/null +++ b/packages/apps/deno-runtime/handlers/tests/upload-event-handler.test.ts @@ -0,0 +1,107 @@ +// deno-lint-ignore-file no-explicit-any +import { Buffer } from 'node:buffer'; + +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; +import type { IPreFileUpload } from '@rocket.chat/apps-engine/definition/uploads/IPreFileUpload.ts'; +import type { IUploadDetails } from '@rocket.chat/apps-engine/definition/uploads/IUploadDetails.ts'; +import { assertInstanceOf, assertNotInstanceOf, assertEquals, assertStringIncludes } from 'https://deno.land/std@0.203.0/assert/mod.ts'; +import { afterEach, beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; +import { assertSpyCalls, spy } from 'https://deno.land/std@0.203.0/testing/mock.ts'; +import { JsonRpcError } from 'jsonrpc-lite'; + +import { createMockRequest } from './helpers/mod.ts'; +import handleUploadEvents from '../app/handleUploadEvents.ts'; +import { Errors } from '../lib/assertions.ts'; +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; + +describe('handlers > upload', () => { + let app: App & IPreFileUpload; + let path: string; + let file: IUploadDetails; + + beforeEach(async () => { + AppObjectRegistry.clear(); + + path = await Deno.makeTempFile(); + + app = { + extendConfiguration: () => {}, + executePreFileUpload: () => Promise.resolve(), + } as unknown as App; + + AppObjectRegistry.set('app', app); + + const content = 'Temp file for testing'; + + await Deno.writeTextFile(path, content); + + file = { + name: 'TempFile.txt', + size: content.length, + type: 'text/plain', + rid: 'RandomRoomId', + userId: 'RandomUserId', + }; + }); + + afterEach(async () => { + await Deno.remove(path).catch((e) => e?.code !== 'ENOENT' && console.warn(`Failed to remove temp file at ${path}`, e)); + }); + + it('correctly handles valid parameters', async () => { + const result = await handleUploadEvents(createMockRequest({ method: 'app:executePreFileUpload', params: [{ file, path }] })); + + assertNotInstanceOf(result, JsonRpcError, 'result is JsonRpcError'); + }); + + it('correctly loads the file contents for IPreFileUpload', async () => { + const _spy = spy(app as any, 'executePreFileUpload'); + + const result = await handleUploadEvents(createMockRequest({ method: 'app:executePreFileUpload', params: [{ file, path }] })); + + assertNotInstanceOf(result, JsonRpcError, 'result is JsonRpcError'); + assertSpyCalls(_spy, 1); + assertInstanceOf((_spy.calls[0].args[0] as any)?.content, Buffer); + }); + + it('fails when app object is not on registry', async () => { + AppObjectRegistry.clear(); + + const result = await handleUploadEvents(createMockRequest({ method: 'app:executePreFileUpload', params: [{ file, path }] })); + + assertInstanceOf(result, JsonRpcError); + assertEquals(result.data.code, Errors.DRT_APP_NOT_AVAILABLE); + }); + + it('fails when the app does not implement the IPreFileUpload event handler', async () => { + delete (app as any)['executePreFileUpload']; + + const result = await handleUploadEvents(createMockRequest({ method: 'app:executePreFileUpload', params: [{ file, path }] })); + + assertInstanceOf(result, JsonRpcError); + assertEquals(result.data.code, Errors.DRT_EVENT_HANDLER_FUNCTION_MISSING); + }); + + it('fails when "file" is not a proper IUploadDetails object', async () => { + const result = await handleUploadEvents(createMockRequest({ method: 'app:executePreFileUpload', params: [{ file: { nope: "bad" }, path }] })); + + assertInstanceOf(result, JsonRpcError); + assertStringIncludes(result.data.err, 'Expected IUploadDetails'); + }); + + it('fails when "path" is not a proper string', async () => { + const result = await handleUploadEvents(createMockRequest({ method: 'app:executePreFileUpload', params: [{ file, path: {} }] })); + + assertInstanceOf(result, JsonRpcError); + assertStringIncludes(result.data.err, 'Expected string'); + }); + + it('fails when "path" is not a readable file path', async () => { + await Deno.remove(path); + + const result = await handleUploadEvents(createMockRequest({ method: 'app:executePreFileUpload', params: [{ file, path }] })); + + assertInstanceOf(result, JsonRpcError); + assertEquals(result.data.code, "ENOENT"); + }); +}); diff --git a/packages/apps/deno-runtime/handlers/tests/videoconference-handler.test.ts b/packages/apps/deno-runtime/handlers/tests/videoconference-handler.test.ts new file mode 100644 index 0000000000000..7632b08c39258 --- /dev/null +++ b/packages/apps/deno-runtime/handlers/tests/videoconference-handler.test.ts @@ -0,0 +1,122 @@ +// deno-lint-ignore-file no-explicit-any +import { assertEquals, assertObjectMatch, assertInstanceOf } from 'https://deno.land/std@0.203.0/assert/mod.ts'; +import { beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; +import { spy } from 'https://deno.land/std@0.203.0/testing/mock.ts'; +import { JsonRpcError } from 'jsonrpc-lite'; + +import { createMockRequest } from './helpers/mod.ts'; +import videoconfHandler from '../videoconference-handler.ts'; +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; + +describe('handlers > videoconference', () => { + // deno-lint-ignore no-unused-vars + const mockMethodWithoutParam = (read: any, modify: any, http: any, persis: any): Promise => Promise.resolve('ok none'); + // deno-lint-ignore no-unused-vars + const mockMethodWithOneParam = (call: any, read: any, modify: any, http: any, persis: any): Promise => Promise.resolve('ok one'); + // deno-lint-ignore no-unused-vars + const mockMethodWithTwoParam = (call: any, user: any, read: any, modify: any, http: any, persis: any): Promise => Promise.resolve('ok two'); + // deno-lint-ignore no-unused-vars + const mockMethodWithThreeParam = (call: any, user: any, options: any, read: any, modify: any, http: any, persis: any): Promise => + Promise.resolve('ok three'); + const mockProvider = { + empty: mockMethodWithoutParam, + one: mockMethodWithOneParam, + two: mockMethodWithTwoParam, + three: mockMethodWithThreeParam, + notAFunction: true, + error: () => { + throw new Error('Method execution error example'); + }, + }; + + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('videoConfProvider:test-provider', mockProvider); + }); + + it('correctly handles execution of a videoconf method without additional params', async () => { + const _spy = spy(mockProvider, 'empty'); + + const result = await videoconfHandler(createMockRequest({ method: 'videoconference:test-provider:empty', params: [] })); + + assertEquals(result, 'ok none'); + assertEquals(_spy.calls[0].args.length, 4); + + _spy.restore(); + }); + + it('correctly handles execution of a videoconf method with one param', async () => { + const _spy = spy(mockProvider, 'one'); + + const result = await videoconfHandler(createMockRequest({ method: 'videoconference:test-provider:one', params: ['call'] })); + + assertEquals(result, 'ok one'); + assertEquals(_spy.calls[0].args.length, 5); + assertEquals(_spy.calls[0].args[0], 'call'); + + _spy.restore(); + }); + + it('correctly handles execution of a videoconf method with two params', async () => { + const _spy = spy(mockProvider, 'two'); + + const result = await videoconfHandler(createMockRequest({ method: 'videoconference:test-provider:two', params: ['call', 'user'] })); + + assertEquals(result, 'ok two'); + assertEquals(_spy.calls[0].args.length, 6); + assertEquals(_spy.calls[0].args[0], 'call'); + assertEquals(_spy.calls[0].args[1], 'user'); + + _spy.restore(); + }); + + it('correctly handles execution of a videoconf method with three params', async () => { + const _spy = spy(mockProvider, 'three'); + + const result = await videoconfHandler(createMockRequest({ method: 'videoconference:test-provider:three', params: ['call', 'user', 'options'] })); + + assertEquals(result, 'ok three'); + assertEquals(_spy.calls[0].args.length, 7); + assertEquals(_spy.calls[0].args[0], 'call'); + assertEquals(_spy.calls[0].args[1], 'user'); + assertEquals(_spy.calls[0].args[2], 'options'); + + _spy.restore(); + }); + + it('correctly handles an error on execution of a videoconf method', async () => { + const result = await videoconfHandler(createMockRequest({ method: 'videoconference:test-provider:error', params: [] })); + + assertInstanceOf(result, JsonRpcError); + assertObjectMatch(result, { + message: 'Method execution error example', + code: -32000, + }); + }); + + it('correctly handles an error when provider is not found', async () => { + const providerName = 'error-provider'; + const result = await videoconfHandler(createMockRequest({ method: `videoconference:${providerName}:method`, params: [] })); + + assertInstanceOf(result, JsonRpcError); + assertObjectMatch(result, { + message: `Provider ${providerName} not found`, + code: -32000, + }); + }); + + it('correctly handles an error if method is not a function of provider', async () => { + const methodName = 'notAFunction'; + const providerName = 'test-provider'; + const result = await videoconfHandler(createMockRequest({ method: `videoconference:${providerName}:${methodName}`, params: [] })); + + assertInstanceOf(result, JsonRpcError); + assertObjectMatch(result, { + message: 'Method not found', + code: -32601, + data: { + message: `Method ${methodName} not found on provider ${providerName}`, + }, + }); + }); +}); diff --git a/packages/apps/deno-runtime/handlers/uikit/handler.ts b/packages/apps/deno-runtime/handlers/uikit/handler.ts new file mode 100644 index 0000000000000..8d352d21927e7 --- /dev/null +++ b/packages/apps/deno-runtime/handlers/uikit/handler.ts @@ -0,0 +1,88 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; +import { Defined, JsonRpcError } from 'jsonrpc-lite'; + +import { require } from '../../lib/require.ts'; +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; +import { RequestContext } from '../../lib/requestContext.ts'; +import { isOneOf } from '../lib/assertions.ts'; +import { wrapAppForRequest } from '../../lib/wrapAppForRequest.ts'; + +export const uikitInteractions = [ + 'executeBlockActionHandler', + 'executeViewSubmitHandler', + 'executeViewClosedHandler', + 'executeActionButtonHandler', + 'executeLivechatBlockActionHandler', +] as const; + +export const { + UIKitBlockInteractionContext, + UIKitViewSubmitInteractionContext, + UIKitViewCloseInteractionContext, + UIKitActionButtonInteractionContext, +} = require('@rocket.chat/apps-engine/definition/uikit/UIKitInteractionContext.js'); + +export const { UIKitLivechatBlockInteractionContext } = require('@rocket.chat/apps-engine/definition/uikit/livechat/UIKitLivechatInteractionContext.js'); + +export default async function handleUIKitInteraction(request: RequestContext): Promise { + const { method: reqMethod, params } = request; + const [, method] = reqMethod.split(':'); + + if (!isOneOf(method, uikitInteractions)) { + return JsonRpcError.methodNotFound(null); + } + + if (!Array.isArray(params)) { + return JsonRpcError.invalidParams(null); + } + + const app = AppObjectRegistry.get('app'); + + const interactionHandler = app?.[method as keyof App] as unknown; + + if (!app || typeof interactionHandler !== 'function') { + return JsonRpcError.methodNotFound({ + message: `App does not implement method "${method}"`, + }); + } + + const [payload] = params as [Record]; + + if (!payload) { + return JsonRpcError.invalidParams(null); + } + + let context; + + switch (method) { + case 'executeBlockActionHandler': + context = new UIKitBlockInteractionContext(payload); + break; + case 'executeViewSubmitHandler': + context = new UIKitViewSubmitInteractionContext(payload); + break; + case 'executeViewClosedHandler': + context = new UIKitViewCloseInteractionContext(payload); + break; + case 'executeActionButtonHandler': + context = new UIKitActionButtonInteractionContext(payload); + break; + case 'executeLivechatBlockActionHandler': + context = new UIKitLivechatBlockInteractionContext(payload); + break; + } + + try { + return await interactionHandler.call( + wrapAppForRequest(app, request), + context, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + AppAccessorsInstance.getModifier(), + ); + } catch (e) { + return JsonRpcError.internalError({ message: e.message }); + } +} diff --git a/packages/apps/deno-runtime/handlers/videoconference-handler.ts b/packages/apps/deno-runtime/handlers/videoconference-handler.ts new file mode 100644 index 0000000000000..5c82aedd3a63c --- /dev/null +++ b/packages/apps/deno-runtime/handlers/videoconference-handler.ts @@ -0,0 +1,52 @@ +import type { IVideoConfProvider } from '@rocket.chat/apps-engine/definition/videoConfProviders/IVideoConfProvider.ts'; +import { Defined, JsonRpcError } from 'jsonrpc-lite'; + +import { AppObjectRegistry } from '../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../lib/accessors/mod.ts'; +import { RequestContext } from '../lib/requestContext.ts'; +import { wrapComposedApp } from '../lib/wrapAppForRequest.ts'; + +export default async function videoConferenceHandler(request: RequestContext): Promise { + const { method: call, params } = request; + const { logger } = request.context; + + const [, providerName, methodName] = call.split(':'); + + const provider = AppObjectRegistry.get(`videoConfProvider:${providerName}`); + + if (!provider) { + return new JsonRpcError(`Provider ${providerName} not found`, -32000); + } + + const method = provider[methodName as keyof IVideoConfProvider]; + + if (typeof method !== 'function') { + return JsonRpcError.methodNotFound({ + message: `Method ${methodName} not found on provider ${providerName}`, + }); + } + + const [videoconf, user, options] = params as Array; + + logger.debug(`Executing ${methodName} on video conference provider...`); + + const args = [...(videoconf ? [videoconf] : []), ...(user ? [user] : []), ...(options ? [options] : [])]; + + try { + // deno-lint-ignore ban-types + const result = await (method as Function).apply(wrapComposedApp(provider, request), [ + ...args, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getModifier(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + ]); + + logger.debug(`Video Conference Provider's ${methodName} was successfully executed.`); + + return result; + } catch (e) { + logger.debug(`Video Conference Provider's ${methodName} was unsuccessful.`); + return new JsonRpcError(e.message, -32000); + } +} diff --git a/packages/apps/deno-runtime/lib/accessors/builders/BlockBuilder.ts b/packages/apps/deno-runtime/lib/accessors/builders/BlockBuilder.ts new file mode 100644 index 0000000000000..de103fe50be02 --- /dev/null +++ b/packages/apps/deno-runtime/lib/accessors/builders/BlockBuilder.ts @@ -0,0 +1,215 @@ +import { v1 as uuid } from 'uuid'; + +import type { + BlockType as _BlockType, + IActionsBlock, + IBlock, + IConditionalBlock, + IConditionalBlockFilters, + IContextBlock, + IImageBlock, + IInputBlock, + ISectionBlock, +} from '@rocket.chat/apps-engine/definition/uikit/blocks/Blocks.ts'; +import type { + BlockElementType as _BlockElementType, + IBlockElement, + IButtonElement, + IImageElement, + IInputElement, + IInteractiveElement, + IMultiStaticSelectElement, + IOverflowMenuElement, + IPlainTextInputElement, + ISelectElement, + IStaticSelectElement, +} from '@rocket.chat/apps-engine/definition/uikit/blocks/Elements.ts'; +import type { ITextObject, TextObjectType as _TextObjectType } from '@rocket.chat/apps-engine/definition/uikit/blocks/Objects.ts'; + +import { AppObjectRegistry } from '../../../AppObjectRegistry.ts'; +import { require } from '../../../lib/require.ts'; + +const { BlockType } = require('@rocket.chat/apps-engine/definition/uikit/blocks/Blocks.js') as { BlockType: typeof _BlockType }; +const { BlockElementType } = require('@rocket.chat/apps-engine/definition/uikit/blocks/Elements.js') as { BlockElementType: typeof _BlockElementType }; +const { TextObjectType } = require('@rocket.chat/apps-engine/definition/uikit/blocks/Objects.js') as { TextObjectType: typeof _TextObjectType }; + +type BlockFunctionParameter = Omit; +type ElementFunctionParameter = T extends IInteractiveElement ? Omit | Partial> + : Omit; + +type SectionBlockParam = BlockFunctionParameter; +type ImageBlockParam = BlockFunctionParameter; +type ActionsBlockParam = BlockFunctionParameter; +type ContextBlockParam = BlockFunctionParameter; +type InputBlockParam = BlockFunctionParameter; + +type ButtonElementParam = ElementFunctionParameter; +type ImageElementParam = ElementFunctionParameter; +type OverflowMenuElementParam = ElementFunctionParameter; +type PlainTextInputElementParam = ElementFunctionParameter; +type StaticSelectElementParam = ElementFunctionParameter; +type MultiStaticSelectElementParam = ElementFunctionParameter; + +/** + * @deprecated please prefer the rocket.chat/ui-kit components + */ +export class BlockBuilder { + private readonly blocks: Array; + private readonly appId: string; + + constructor() { + this.blocks = []; + this.appId = String(AppObjectRegistry.get('id')); + } + + public addSectionBlock(block: SectionBlockParam): BlockBuilder { + this.addBlock({ type: BlockType.SECTION, ...block } as ISectionBlock); + + return this; + } + + public addImageBlock(block: ImageBlockParam): BlockBuilder { + this.addBlock({ type: BlockType.IMAGE, ...block } as IImageBlock); + + return this; + } + + public addDividerBlock(): BlockBuilder { + this.addBlock({ type: BlockType.DIVIDER }); + + return this; + } + + public addActionsBlock(block: ActionsBlockParam): BlockBuilder { + this.addBlock({ type: BlockType.ACTIONS, ...block } as IActionsBlock); + + return this; + } + + public addContextBlock(block: ContextBlockParam): BlockBuilder { + this.addBlock({ type: BlockType.CONTEXT, ...block } as IContextBlock); + + return this; + } + + public addInputBlock(block: InputBlockParam): BlockBuilder { + this.addBlock({ type: BlockType.INPUT, ...block } as IInputBlock); + + return this; + } + + public addConditionalBlock(innerBlocks: BlockBuilder | Array, condition?: IConditionalBlockFilters): BlockBuilder { + const render = innerBlocks instanceof BlockBuilder ? innerBlocks.getBlocks() : innerBlocks; + + this.addBlock({ + type: BlockType.CONDITIONAL, + render, + when: condition, + } as IConditionalBlock); + + return this; + } + + public getBlocks() { + return this.blocks; + } + + public newPlainTextObject(text: string, emoji = false): ITextObject { + return { + type: TextObjectType.PLAINTEXT, + text, + emoji, + }; + } + + public newMarkdownTextObject(text: string): ITextObject { + return { + type: TextObjectType.MARKDOWN, + text, + }; + } + + public newButtonElement(info: ButtonElementParam): IButtonElement { + return this.newInteractiveElement({ + type: BlockElementType.BUTTON, + ...info, + } as IButtonElement); + } + + public newImageElement(info: ImageElementParam): IImageElement { + return { + type: BlockElementType.IMAGE, + ...info, + }; + } + + public newOverflowMenuElement(info: OverflowMenuElementParam): IOverflowMenuElement { + return this.newInteractiveElement({ + type: BlockElementType.OVERFLOW_MENU, + ...info, + } as IOverflowMenuElement); + } + + public newPlainTextInputElement(info: PlainTextInputElementParam): IPlainTextInputElement { + return this.newInputElement({ + type: BlockElementType.PLAIN_TEXT_INPUT, + ...info, + } as IPlainTextInputElement); + } + + public newStaticSelectElement(info: StaticSelectElementParam): IStaticSelectElement { + return this.newSelectElement({ + type: BlockElementType.STATIC_SELECT, + ...info, + } as IStaticSelectElement); + } + + public newMultiStaticElement(info: MultiStaticSelectElementParam): IMultiStaticSelectElement { + return this.newSelectElement({ + type: BlockElementType.MULTI_STATIC_SELECT, + ...info, + } as IMultiStaticSelectElement); + } + + private newInteractiveElement(element: T): T { + if (!element.actionId) { + element.actionId = this.generateActionId(); + } + + return element; + } + + private newInputElement(element: T): T { + if (!element.actionId) { + element.actionId = this.generateActionId(); + } + + return element; + } + + private newSelectElement(element: T): T { + if (!element.actionId) { + element.actionId = this.generateActionId(); + } + + return element; + } + + private addBlock(block: IBlock): void { + if (!block.blockId) { + block.blockId = this.generateBlockId(); + } + + block.appId = this.appId; + + this.blocks.push(block); + } + + private generateBlockId(): string { + return uuid(); + } + + private generateActionId(): string { + return uuid(); + } +} diff --git a/packages/apps/deno-runtime/lib/accessors/builders/DiscussionBuilder.ts b/packages/apps/deno-runtime/lib/accessors/builders/DiscussionBuilder.ts new file mode 100644 index 0000000000000..adbf060182e1d --- /dev/null +++ b/packages/apps/deno-runtime/lib/accessors/builders/DiscussionBuilder.ts @@ -0,0 +1,59 @@ +import type { IDiscussionBuilder as _IDiscussionBuilder } from '@rocket.chat/apps-engine/definition/accessors/IDiscussionBuilder.ts'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage.ts'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom.ts'; +import type { IRoomBuilder } from '@rocket.chat/apps-engine/definition/accessors/IRoomBuilder.ts'; + +import type { RocketChatAssociationModel as _RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts'; +import type { RoomType as _RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType.ts'; + +import { RoomBuilder } from './RoomBuilder.ts'; +import { require } from '../../../lib/require.ts'; + +const { RocketChatAssociationModel } = require('@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.js') as { + RocketChatAssociationModel: typeof _RocketChatAssociationModel; +}; + +const { RoomType } = require('@rocket.chat/apps-engine/definition/rooms/RoomType.js') as { RoomType: typeof _RoomType }; + +export interface IDiscussionBuilder extends _IDiscussionBuilder, IRoomBuilder {} + +export class DiscussionBuilder extends RoomBuilder implements IDiscussionBuilder { + public kind: _RocketChatAssociationModel.DISCUSSION; + + private reply?: string; + + private parentMessage?: IMessage; + + constructor(data?: Partial) { + super(data); + this.kind = RocketChatAssociationModel.DISCUSSION; + this.room.type = RoomType.PRIVATE_GROUP; + } + + public setParentRoom(parentRoom: IRoom): IDiscussionBuilder { + this.room.parentRoom = parentRoom; + return this; + } + + public getParentRoom(): IRoom { + return this.room.parentRoom!; + } + + public setReply(reply: string): IDiscussionBuilder { + this.reply = reply; + return this; + } + + public getReply(): string { + return this.reply!; + } + + public setParentMessage(parentMessage: IMessage): IDiscussionBuilder { + this.parentMessage = parentMessage; + return this; + } + + public getParentMessage(): IMessage { + return this.parentMessage!; + } +} diff --git a/packages/apps/deno-runtime/lib/accessors/builders/LivechatMessageBuilder.ts b/packages/apps/deno-runtime/lib/accessors/builders/LivechatMessageBuilder.ts new file mode 100644 index 0000000000000..b39a418c5aec0 --- /dev/null +++ b/packages/apps/deno-runtime/lib/accessors/builders/LivechatMessageBuilder.ts @@ -0,0 +1,204 @@ +import type { RocketChatAssociationModel as _RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts'; +import type { RoomType as _RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType.ts'; + +import type { ILivechatMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/ILivechatMessageBuilder.ts'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage.ts'; +import type { IMessageAttachment } from '@rocket.chat/apps-engine/definition/messages/IMessageAttachment.ts'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom.ts'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser.ts'; +import type { ILivechatMessage as EngineLivechatMessage } from '@rocket.chat/apps-engine/definition/livechat/ILivechatMessage.ts'; +import type { IVisitor } from '@rocket.chat/apps-engine/definition/livechat/IVisitor.ts'; +import type { IMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/IMessageBuilder.ts'; + +import { MessageBuilder } from './MessageBuilder.ts'; +import { require } from '../../../lib/require.ts'; + +const { RocketChatAssociationModel } = require('@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.js') as { + RocketChatAssociationModel: typeof _RocketChatAssociationModel; +}; + +const { RoomType } = require('@rocket.chat/apps-engine/definition/rooms/RoomType.js') as { RoomType: typeof _RoomType }; + +export interface ILivechatMessage extends EngineLivechatMessage, IMessage {} + +export class LivechatMessageBuilder implements ILivechatMessageBuilder { + public kind: _RocketChatAssociationModel.LIVECHAT_MESSAGE; + + private msg: ILivechatMessage; + + constructor(message?: ILivechatMessage) { + this.kind = RocketChatAssociationModel.LIVECHAT_MESSAGE; + this.msg = message || ({} as ILivechatMessage); + } + + public setData(data: ILivechatMessage): ILivechatMessageBuilder { + delete data.id; + this.msg = data; + + return this; + } + + public setRoom(room: IRoom): ILivechatMessageBuilder { + this.msg.room = room; + return this; + } + + public getRoom(): IRoom { + return this.msg.room; + } + + public setSender(sender: IUser): ILivechatMessageBuilder { + this.msg.sender = sender; + delete this.msg.visitor; + + return this; + } + + public getSender(): IUser { + return this.msg.sender; + } + + public setText(text: string): ILivechatMessageBuilder { + this.msg.text = text; + return this; + } + + public getText(): string { + return this.msg.text!; + } + + public setEmojiAvatar(emoji: string): ILivechatMessageBuilder { + this.msg.emoji = emoji; + return this; + } + + public getEmojiAvatar(): string { + return this.msg.emoji!; + } + + public setAvatarUrl(avatarUrl: string): ILivechatMessageBuilder { + this.msg.avatarUrl = avatarUrl; + return this; + } + + public getAvatarUrl(): string { + return this.msg.avatarUrl!; + } + + public setUsernameAlias(alias: string): ILivechatMessageBuilder { + this.msg.alias = alias; + return this; + } + + public getUsernameAlias(): string { + return this.msg.alias!; + } + + public addAttachment(attachment: IMessageAttachment): ILivechatMessageBuilder { + if (!this.msg.attachments) { + this.msg.attachments = []; + } + + this.msg.attachments.push(attachment); + return this; + } + + public setAttachments(attachments: Array): ILivechatMessageBuilder { + this.msg.attachments = attachments; + return this; + } + + public getAttachments(): Array { + return this.msg.attachments!; + } + + public replaceAttachment(position: number, attachment: IMessageAttachment): ILivechatMessageBuilder { + if (!this.msg.attachments) { + this.msg.attachments = []; + } + + if (!this.msg.attachments[position]) { + throw new Error(`No attachment found at the index of "${position}" to replace.`); + } + + this.msg.attachments[position] = attachment; + return this; + } + + public removeAttachment(position: number): ILivechatMessageBuilder { + if (!this.msg.attachments) { + this.msg.attachments = []; + } + + if (!this.msg.attachments[position]) { + throw new Error(`No attachment found at the index of "${position}" to remove.`); + } + + this.msg.attachments.splice(position, 1); + + return this; + } + + public setEditor(user: IUser): ILivechatMessageBuilder { + this.msg.editor = user; + return this; + } + + public getEditor(): IUser { + return this.msg.editor; + } + + public setGroupable(groupable: boolean): ILivechatMessageBuilder { + this.msg.groupable = groupable; + return this; + } + + public getGroupable(): boolean { + return this.msg.groupable!; + } + + public setParseUrls(parseUrls: boolean): ILivechatMessageBuilder { + this.msg.parseUrls = parseUrls; + return this; + } + + public getParseUrls(): boolean { + return this.msg.parseUrls!; + } + + public setToken(token: string): ILivechatMessageBuilder { + this.msg.token = token; + return this; + } + + public getToken(): string { + return this.msg.token!; + } + + public setVisitor(visitor: IVisitor): ILivechatMessageBuilder { + this.msg.visitor = visitor; + delete this.msg.sender; + + return this; + } + + public getVisitor(): IVisitor { + return this.msg.visitor; + } + + public getMessage(): ILivechatMessage { + if (!this.msg.room) { + throw new Error('The "room" property is required.'); + } + + if (this.msg.room.type !== RoomType.LIVE_CHAT) { + throw new Error('The room is not a Livechat room'); + } + + return this.msg; + } + + public getMessageBuilder(): IMessageBuilder { + return new MessageBuilder(this.msg as IMessage); + } +} diff --git a/packages/apps/deno-runtime/lib/accessors/builders/MessageBuilder.ts b/packages/apps/deno-runtime/lib/accessors/builders/MessageBuilder.ts new file mode 100644 index 0000000000000..032b4ba2552e9 --- /dev/null +++ b/packages/apps/deno-runtime/lib/accessors/builders/MessageBuilder.ts @@ -0,0 +1,271 @@ +import { LayoutBlock } from '@rocket.chat/ui-kit'; + +import type { IMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/IMessageBuilder.ts'; +import type { RocketChatAssociationModel as _RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage.ts'; +import type { IMessageAttachment } from '@rocket.chat/apps-engine/definition/messages/IMessageAttachment.ts'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser.ts'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom.ts'; +import type { IBlock } from '@rocket.chat/apps-engine/definition/uikit/blocks/Blocks.ts'; + +import { BlockBuilder } from './BlockBuilder.ts'; +import { require } from '../../../lib/require.ts'; + +const { RocketChatAssociationModel } = require('@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.js') as { + RocketChatAssociationModel: typeof _RocketChatAssociationModel; +}; + +export class MessageBuilder implements IMessageBuilder { + public kind: _RocketChatAssociationModel.MESSAGE; + + private msg: IMessage; + + private changes: Partial = {}; + private attachmentsChanged = false; + private customFieldsChanged = false; + + constructor(message?: IMessage) { + this.kind = RocketChatAssociationModel.MESSAGE; + this.msg = message || ({} as IMessage); + } + + public setData(data: IMessage): IMessageBuilder { + delete data.id; + this.msg = data; + + return this as IMessageBuilder; + } + + public setUpdateData(data: IMessage, editor: IUser): IMessageBuilder { + this.msg = data; + this.msg.editor = editor; + this.msg.editedAt = new Date(); + + this.changes = structuredClone(this.msg); + + return this as IMessageBuilder; + } + + public setThreadId(threadId: string): IMessageBuilder { + this.msg.threadId = threadId; + this.changes.threadId = threadId; + + return this as IMessageBuilder; + } + + public getThreadId(): string { + return this.msg.threadId!; + } + + public setRoom(room: IRoom): IMessageBuilder { + this.msg.room = room; + this.changes.room = room; + + return this as IMessageBuilder; + } + + public getRoom(): IRoom { + return this.msg.room; + } + + public setSender(sender: IUser): IMessageBuilder { + this.msg.sender = sender; + this.changes.sender = sender; + + return this as IMessageBuilder; + } + + public getSender(): IUser { + return this.msg.sender; + } + + public setText(text: string): IMessageBuilder { + this.msg.text = text; + this.changes.text = text; + + return this as IMessageBuilder; + } + + public getText(): string { + return this.msg.text!; + } + + public setEmojiAvatar(emoji: string): IMessageBuilder { + this.msg.emoji = emoji; + this.changes.emoji = emoji; + + return this as IMessageBuilder; + } + + public getEmojiAvatar(): string { + return this.msg.emoji!; + } + + public setAvatarUrl(avatarUrl: string): IMessageBuilder { + this.msg.avatarUrl = avatarUrl; + this.changes.avatarUrl = avatarUrl; + + return this as IMessageBuilder; + } + + public getAvatarUrl(): string { + return this.msg.avatarUrl!; + } + + public setUsernameAlias(alias: string): IMessageBuilder { + this.msg.alias = alias; + this.changes.alias = alias; + + return this as IMessageBuilder; + } + + public getUsernameAlias(): string { + return this.msg.alias!; + } + + public addAttachment(attachment: IMessageAttachment): IMessageBuilder { + if (!this.msg.attachments) { + this.msg.attachments = []; + } + + this.msg.attachments.push(attachment); + this.attachmentsChanged = true; + + return this as IMessageBuilder; + } + + public setAttachments(attachments: Array): IMessageBuilder { + this.msg.attachments = attachments; + this.attachmentsChanged = true; + + return this as IMessageBuilder; + } + + public getAttachments(): Array { + return this.msg.attachments!; + } + + public replaceAttachment(position: number, attachment: IMessageAttachment): IMessageBuilder { + if (!this.msg.attachments?.[position]) { + throw new Error(`No attachment found at the index of "${position}" to replace.`); + } + + this.msg.attachments[position] = attachment; + this.attachmentsChanged = true; + + return this as IMessageBuilder; + } + + public removeAttachment(position: number): IMessageBuilder { + if (!this.msg.attachments?.[position]) { + throw new Error(`No attachment found at the index of "${position}" to remove.`); + } + + this.msg.attachments.splice(position, 1); + this.attachmentsChanged = true; + + return this as IMessageBuilder; + } + + public setEditor(user: IUser): IMessageBuilder { + this.msg.editor = user; + this.changes.editor = user; + + return this as IMessageBuilder; + } + + public getEditor(): IUser { + return this.msg.editor; + } + + public setGroupable(groupable: boolean): IMessageBuilder { + this.msg.groupable = groupable; + this.changes.groupable = groupable; + + return this as IMessageBuilder; + } + + public getGroupable(): boolean { + return this.msg.groupable!; + } + + public setParseUrls(parseUrls: boolean): IMessageBuilder { + this.msg.parseUrls = parseUrls; + this.changes.parseUrls = parseUrls; + + return this as IMessageBuilder; + } + + public getParseUrls(): boolean { + return this.msg.parseUrls!; + } + + public getMessage(): IMessage { + if (!this.msg.room) { + throw new Error('The "room" property is required.'); + } + + return this.msg; + } + + public addBlocks(blocks: BlockBuilder | Array) { + if (!Array.isArray(this.msg.blocks)) { + this.msg.blocks = []; + } + + if (blocks instanceof BlockBuilder) { + this.msg.blocks.push(...blocks.getBlocks()); + } else { + this.msg.blocks.push(...blocks); + } + + return this as IMessageBuilder; + } + + public setBlocks(blocks: BlockBuilder | Array) { + const blockArray: Array = blocks instanceof BlockBuilder ? blocks.getBlocks() : blocks; + + this.msg.blocks = blockArray; + this.changes.blocks = blockArray; + + return this as IMessageBuilder; + } + + public getBlocks() { + return this.msg.blocks!; + } + + public addCustomField(key: string, value: unknown): IMessageBuilder { + if (!this.msg.customFields) { + this.msg.customFields = {}; + } + + if (this.msg.customFields[key]) { + throw new Error(`The message already contains a custom field by the key: ${key}`); + } + + if (key.includes('.')) { + throw new Error(`The given key contains a period, which is not allowed. Key: ${key}`); + } + + this.msg.customFields[key] = value; + + this.customFieldsChanged = true; + + return this as IMessageBuilder; + } + + public getChanges(): Partial { + const changes: typeof this.changes = structuredClone(this.changes); + + if (this.attachmentsChanged) { + changes.attachments = structuredClone(this.msg.attachments); + } + + if (this.customFieldsChanged) { + changes.customFields = structuredClone(this.msg.customFields); + } + + return changes; + } +} diff --git a/packages/apps/deno-runtime/lib/accessors/builders/RoomBuilder.ts b/packages/apps/deno-runtime/lib/accessors/builders/RoomBuilder.ts new file mode 100644 index 0000000000000..208d476d32162 --- /dev/null +++ b/packages/apps/deno-runtime/lib/accessors/builders/RoomBuilder.ts @@ -0,0 +1,197 @@ +import type { IRoomBuilder } from '@rocket.chat/apps-engine/definition/accessors/IRoomBuilder.ts'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom.ts'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser.ts'; + +import type { RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType.ts'; +import type { RocketChatAssociationModel as _RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts'; + +import { require } from '../../../lib/require.ts'; + +const { RocketChatAssociationModel } = require('@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.js') as { + RocketChatAssociationModel: typeof _RocketChatAssociationModel; +}; + +export class RoomBuilder implements IRoomBuilder { + public kind: _RocketChatAssociationModel.ROOM | _RocketChatAssociationModel.DISCUSSION; + + protected room: IRoom; + + private members: Array; + + private changes: Partial = {}; + private customFieldsChanged = false; + + constructor(data?: Partial) { + this.kind = RocketChatAssociationModel.ROOM; + this.room = (data || { customFields: {} }) as IRoom; + this.members = []; + } + + public setData(data: Partial): IRoomBuilder { + delete data.id; + this.room = data as IRoom; + + this.changes = structuredClone(this.room); + + return this; + } + + public setDisplayName(name: string): IRoomBuilder { + this.room.displayName = name; + this.changes.displayName = name; + + return this; + } + + public getDisplayName(): string { + return this.room.displayName!; + } + + public setSlugifiedName(name: string): IRoomBuilder { + this.room.slugifiedName = name; + this.changes.slugifiedName = name; + + return this; + } + + public getSlugifiedName(): string { + return this.room.slugifiedName; + } + + public setType(type: RoomType): IRoomBuilder { + this.room.type = type; + this.changes.type = type; + + return this; + } + + public getType(): RoomType { + return this.room.type; + } + + public setCreator(creator: IUser): IRoomBuilder { + this.room.creator = creator; + this.changes.creator = creator; + + return this; + } + + public getCreator(): IUser { + return this.room.creator; + } + + /** + * @deprecated + */ + public addUsername(username: string): IRoomBuilder { + this.addMemberToBeAddedByUsername(username); + return this; + } + + /** + * @deprecated + */ + public setUsernames(usernames: Array): IRoomBuilder { + this.setMembersToBeAddedByUsernames(usernames); + return this; + } + + /** + * @deprecated + */ + public getUsernames(): Array { + const usernames = this.getMembersToBeAddedUsernames(); + if (usernames && usernames.length > 0) { + return usernames; + } + return this.room.usernames || []; + } + + public addMemberToBeAddedByUsername(username: string): IRoomBuilder { + this.members.push(username); + return this; + } + + public setMembersToBeAddedByUsernames(usernames: Array): IRoomBuilder { + this.members = usernames; + return this; + } + + public getMembersToBeAddedUsernames(): Array { + return this.members; + } + + public setDefault(isDefault: boolean): IRoomBuilder { + this.room.isDefault = isDefault; + this.changes.isDefault = isDefault; + + return this; + } + + public getIsDefault(): boolean { + return this.room.isDefault!; + } + + public setReadOnly(isReadOnly: boolean): IRoomBuilder { + this.room.isReadOnly = isReadOnly; + this.changes.isReadOnly = isReadOnly; + + return this; + } + + public getIsReadOnly(): boolean { + return this.room.isReadOnly!; + } + + public setDisplayingOfSystemMessages(displaySystemMessages: boolean): IRoomBuilder { + this.room.displaySystemMessages = displaySystemMessages; + this.changes.displaySystemMessages = displaySystemMessages; + + return this; + } + + public getDisplayingOfSystemMessages(): boolean { + return this.room.displaySystemMessages!; + } + + public addCustomField(key: string, value: object): IRoomBuilder { + if (typeof this.room.customFields !== 'object') { + this.room.customFields = {}; + } + + this.room.customFields[key] = value; + + this.customFieldsChanged = true; + + return this; + } + + public setCustomFields(fields: { [key: string]: object }): IRoomBuilder { + this.room.customFields = fields; + this.customFieldsChanged = true; + + return this; + } + + public getCustomFields(): { [key: string]: object } { + return this.room.customFields!; + } + + public getUserIds(): Array { + return this.room.userIds!; + } + + public getRoom(): IRoom { + return this.room; + } + + public getChanges() { + const changes: Partial = structuredClone(this.changes); + + if (this.customFieldsChanged) { + changes.customFields = structuredClone(this.room.customFields); + } + + return changes; + } +} diff --git a/packages/apps/deno-runtime/lib/accessors/builders/UserBuilder.ts b/packages/apps/deno-runtime/lib/accessors/builders/UserBuilder.ts new file mode 100644 index 0000000000000..caaf9a69d5941 --- /dev/null +++ b/packages/apps/deno-runtime/lib/accessors/builders/UserBuilder.ts @@ -0,0 +1,81 @@ +import type { IUserBuilder } from '@rocket.chat/apps-engine/definition/accessors/IUserBuilder.ts'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser.ts'; +import type { IUserSettings } from '@rocket.chat/apps-engine/definition/users/IUserSettings.ts'; +import type { IUserEmail } from '@rocket.chat/apps-engine/definition/users/IUserEmail.ts'; +import type { RocketChatAssociationModel as _RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts'; + +import { require } from '../../../lib/require.ts'; + +const { RocketChatAssociationModel } = require('@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.js') as { + RocketChatAssociationModel: typeof _RocketChatAssociationModel; +}; + +export class UserBuilder implements IUserBuilder { + public kind: _RocketChatAssociationModel.USER; + + private user: Partial; + + constructor(user?: Partial) { + this.kind = RocketChatAssociationModel.USER; + this.user = user || ({} as Partial); + } + + public setData(data: Partial): IUserBuilder { + delete data.id; + this.user = data; + + return this; + } + + public setEmails(emails: Array): IUserBuilder { + this.user.emails = emails; + return this; + } + + public getEmails(): Array { + return this.user.emails!; + } + + public setDisplayName(name: string): IUserBuilder { + this.user.name = name; + return this; + } + + public getDisplayName(): string { + return this.user.name!; + } + + public setUsername(username: string): IUserBuilder { + this.user.username = username; + return this; + } + + public getUsername(): string { + return this.user.username!; + } + + public setRoles(roles: Array): IUserBuilder { + this.user.roles = roles; + return this; + } + + public getRoles(): Array { + return this.user.roles!; + } + + public getSettings(): Partial { + return this.user.settings; + } + + public getUser(): Partial { + if (!this.user.username) { + throw new Error('The "username" property is required.'); + } + + if (!this.user.name) { + throw new Error('The "name" property is required.'); + } + + return this.user; + } +} diff --git a/packages/apps/deno-runtime/lib/accessors/builders/VideoConferenceBuilder.ts b/packages/apps/deno-runtime/lib/accessors/builders/VideoConferenceBuilder.ts new file mode 100644 index 0000000000000..e1bc3f3cf5b24 --- /dev/null +++ b/packages/apps/deno-runtime/lib/accessors/builders/VideoConferenceBuilder.ts @@ -0,0 +1,94 @@ +import type { IVideoConferenceBuilder } from '@rocket.chat/apps-engine/definition/accessors/IVideoConferenceBuilder.ts'; +import type { IGroupVideoConference } from '@rocket.chat/apps-engine/definition/videoConferences/IVideoConference.ts'; + +import type { RocketChatAssociationModel as _RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts'; + +import { require } from '../../../lib/require.ts'; + +const { RocketChatAssociationModel } = require('@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.js') as { + RocketChatAssociationModel: typeof _RocketChatAssociationModel; +}; + +export type AppVideoConference = Pick & { + createdBy: IGroupVideoConference['createdBy']['_id']; +}; + +export class VideoConferenceBuilder implements IVideoConferenceBuilder { + public kind: _RocketChatAssociationModel.VIDEO_CONFERENCE = RocketChatAssociationModel.VIDEO_CONFERENCE; + + protected call: AppVideoConference; + + constructor(data?: Partial) { + this.call = (data || {}) as AppVideoConference; + } + + public setData(data: Partial): IVideoConferenceBuilder { + this.call = { + rid: data.rid!, + createdBy: data.createdBy, + providerName: data.providerName!, + title: data.title!, + discussionRid: data.discussionRid, + }; + + return this; + } + + public setRoomId(rid: string): IVideoConferenceBuilder { + this.call.rid = rid; + return this; + } + + public getRoomId(): string { + return this.call.rid; + } + + public setCreatedBy(userId: string): IVideoConferenceBuilder { + this.call.createdBy = userId; + return this; + } + + public getCreatedBy(): string { + return this.call.createdBy; + } + + public setProviderName(userId: string): IVideoConferenceBuilder { + this.call.providerName = userId; + return this; + } + + public getProviderName(): string { + return this.call.providerName; + } + + public setProviderData(data: Record | undefined): IVideoConferenceBuilder { + this.call.providerData = data; + return this; + } + + public getProviderData(): Record { + return this.call.providerData!; + } + + public setTitle(userId: string): IVideoConferenceBuilder { + this.call.title = userId; + return this; + } + + public getTitle(): string { + return this.call.title; + } + + public setDiscussionRid(rid: AppVideoConference['discussionRid']): IVideoConferenceBuilder { + this.call.discussionRid = rid; + return this; + } + + public getDiscussionRid(): AppVideoConference['discussionRid'] { + return this.call.discussionRid; + } + + public getVideoConference(): AppVideoConference { + return this.call; + } +} diff --git a/packages/apps/deno-runtime/lib/accessors/extenders/HttpExtender.ts b/packages/apps/deno-runtime/lib/accessors/extenders/HttpExtender.ts new file mode 100644 index 0000000000000..8342850975017 --- /dev/null +++ b/packages/apps/deno-runtime/lib/accessors/extenders/HttpExtender.ts @@ -0,0 +1,58 @@ +import type { IHttpExtend, IHttpPreRequestHandler, IHttpPreResponseHandler } from '@rocket.chat/apps-engine/definition/accessors/IHttp.ts'; + +export class HttpExtend implements IHttpExtend { + private headers: Map; + + private params: Map; + + private requests: Array; + + private responses: Array; + + constructor() { + this.headers = new Map(); + this.params = new Map(); + this.requests = []; + this.responses = []; + } + + public provideDefaultHeader(key: string, value: string): void { + this.headers.set(key, value); + } + + public provideDefaultHeaders(headers: { [key: string]: string }): void { + Object.keys(headers).forEach((key) => this.headers.set(key, headers[key])); + } + + public provideDefaultParam(key: string, value: string): void { + this.params.set(key, value); + } + + public provideDefaultParams(params: { [key: string]: string }): void { + Object.keys(params).forEach((key) => this.params.set(key, params[key])); + } + + public providePreRequestHandler(handler: IHttpPreRequestHandler): void { + this.requests.push(handler); + } + + public providePreResponseHandler(handler: IHttpPreResponseHandler): void { + this.responses.push(handler); + } + + public getDefaultHeaders(): Map { + return new Map(this.headers); + } + + public getDefaultParams(): Map { + return new Map(this.params); + } + + public getPreRequestHandlers(): Array { + return Array.from(this.requests); + } + + public getPreResponseHandlers(): Array { + return Array.from(this.responses); + } +} diff --git a/packages/apps/deno-runtime/lib/accessors/extenders/MessageExtender.ts b/packages/apps/deno-runtime/lib/accessors/extenders/MessageExtender.ts new file mode 100644 index 0000000000000..1f45137e15d4b --- /dev/null +++ b/packages/apps/deno-runtime/lib/accessors/extenders/MessageExtender.ts @@ -0,0 +1,66 @@ +import type { IMessageExtender } from '@rocket.chat/apps-engine/definition/accessors/IMessageExtender.ts'; +import type { RocketChatAssociationModel as _RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage.ts'; +import type { IMessageAttachment } from '@rocket.chat/apps-engine/definition/messages/IMessageAttachment.ts'; + +import { require } from '../../../lib/require.ts'; + +const { RocketChatAssociationModel } = require('@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.js') as { + RocketChatAssociationModel: typeof _RocketChatAssociationModel; +}; + +export class MessageExtender implements IMessageExtender { + public readonly kind: _RocketChatAssociationModel.MESSAGE; + + constructor(private msg: IMessage) { + this.kind = RocketChatAssociationModel.MESSAGE; + + if (!Array.isArray(msg.attachments)) { + this.msg.attachments = []; + } + } + + public addCustomField(key: string, value: unknown): IMessageExtender { + if (!this.msg.customFields) { + this.msg.customFields = {}; + } + + if (this.msg.customFields[key]) { + throw new Error(`The message already contains a custom field by the key: ${key}`); + } + + if (key.includes('.')) { + throw new Error(`The given key contains a period, which is not allowed. Key: ${key}`); + } + + this.msg.customFields[key] = value; + + return this; + } + + public addAttachment(attachment: IMessageAttachment): IMessageExtender { + this.ensureAttachment(); + + this.msg.attachments!.push(attachment); + + return this; + } + + public addAttachments(attachments: Array): IMessageExtender { + this.ensureAttachment(); + + this.msg.attachments = this.msg.attachments!.concat(attachments); + + return this; + } + + public getMessage(): IMessage { + return structuredClone(this.msg); + } + + private ensureAttachment(): void { + if (!Array.isArray(this.msg.attachments)) { + this.msg.attachments = []; + } + } +} diff --git a/packages/apps/deno-runtime/lib/accessors/extenders/RoomExtender.ts b/packages/apps/deno-runtime/lib/accessors/extenders/RoomExtender.ts new file mode 100644 index 0000000000000..a138e08d2d28e --- /dev/null +++ b/packages/apps/deno-runtime/lib/accessors/extenders/RoomExtender.ts @@ -0,0 +1,61 @@ +import type { IRoomExtender } from '@rocket.chat/apps-engine/definition/accessors/IRoomExtender.ts'; +import type { RocketChatAssociationModel as _RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom.ts'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser.ts'; + +import { require } from '../../../lib/require.ts'; + +const { RocketChatAssociationModel } = require('@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.js') as { + RocketChatAssociationModel: typeof _RocketChatAssociationModel; +}; + +export class RoomExtender implements IRoomExtender { + public kind: _RocketChatAssociationModel.ROOM; + + private members: Array; + + constructor(private room: IRoom) { + this.kind = RocketChatAssociationModel.ROOM; + this.members = []; + } + + public addCustomField(key: string, value: unknown): IRoomExtender { + if (!this.room.customFields) { + this.room.customFields = {}; + } + + if (this.room.customFields[key]) { + throw new Error(`The room already contains a custom field by the key: ${key}`); + } + + if (key.includes('.')) { + throw new Error(`The given key contains a period, which is not allowed. Key: ${key}`); + } + + this.room.customFields[key] = value; + + return this; + } + + public addMember(user: IUser): IRoomExtender { + if (this.members.find((u) => u.username === user.username)) { + throw new Error('The user is already in the room.'); + } + + this.members.push(user); + + return this; + } + + public getMembersBeingAdded(): Array { + return this.members; + } + + public getUsernamesOfMembersBeingAdded(): Array { + return this.members.map((u) => u.username); + } + + public getRoom(): IRoom { + return structuredClone(this.room); + } +} diff --git a/packages/apps/deno-runtime/lib/accessors/extenders/VideoConferenceExtend.ts b/packages/apps/deno-runtime/lib/accessors/extenders/VideoConferenceExtend.ts new file mode 100644 index 0000000000000..c4b154f46cb32 --- /dev/null +++ b/packages/apps/deno-runtime/lib/accessors/extenders/VideoConferenceExtend.ts @@ -0,0 +1,69 @@ +import type { IVideoConferenceExtender } from '@rocket.chat/apps-engine/definition/accessors/IVideoConferenceExtend.ts'; +import type { VideoConference, VideoConferenceMember } from '@rocket.chat/apps-engine/definition/videoConferences/IVideoConference.ts'; +import type { IVideoConferenceUser } from '@rocket.chat/apps-engine/definition/videoConferences/IVideoConferenceUser.ts'; +import type { RocketChatAssociationModel as _RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts'; + +import { require } from '../../../lib/require.ts'; + +const { RocketChatAssociationModel } = require('@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.js') as { + RocketChatAssociationModel: typeof _RocketChatAssociationModel; +}; + +export class VideoConferenceExtender implements IVideoConferenceExtender { + public kind: _RocketChatAssociationModel.VIDEO_CONFERENCE; + + constructor(private videoConference: VideoConference) { + this.kind = RocketChatAssociationModel.VIDEO_CONFERENCE; + } + + public setProviderData(value: Record): IVideoConferenceExtender { + this.videoConference.providerData = value; + + return this; + } + + public setStatus(value: VideoConference['status']): IVideoConferenceExtender { + this.videoConference.status = value; + + return this; + } + + public setEndedBy(value: IVideoConferenceUser['_id']): IVideoConferenceExtender { + this.videoConference.endedBy = { + _id: value, + // Name and username will be loaded automatically by the bridge + username: '', + name: '', + }; + + return this; + } + + public setEndedAt(value: VideoConference['endedAt']): IVideoConferenceExtender { + this.videoConference.endedAt = value; + + return this; + } + + public addUser(userId: VideoConferenceMember['_id'], ts?: VideoConferenceMember['ts']): IVideoConferenceExtender { + this.videoConference.users.push({ + _id: userId, + ts, + // Name and username will be loaded automatically by the bridge + username: '', + name: '', + }); + + return this; + } + + public setDiscussionRid(rid: VideoConference['discussionRid']): IVideoConferenceExtender { + this.videoConference.discussionRid = rid; + + return this; + } + + public getVideoConference(): VideoConference { + return structuredClone(this.videoConference); + } +} diff --git a/packages/apps/deno-runtime/lib/accessors/formatResponseErrorHandler.ts b/packages/apps/deno-runtime/lib/accessors/formatResponseErrorHandler.ts new file mode 100644 index 0000000000000..6840c3ab5baa3 --- /dev/null +++ b/packages/apps/deno-runtime/lib/accessors/formatResponseErrorHandler.ts @@ -0,0 +1,14 @@ +import { ErrorObject } from 'jsonrpc-lite'; + +// deno-lint-ignore no-explicit-any -- that is the type we get from `catch` +export const formatErrorResponse = (error: any): Error => { + if (error instanceof ErrorObject || typeof error?.error?.message === 'string') { + return new Error(error.error.message); + } + + if (error instanceof Error) { + return error; + } + + return new Error('An unknown error occurred', { cause: error }); +}; diff --git a/packages/apps/deno-runtime/lib/accessors/http.ts b/packages/apps/deno-runtime/lib/accessors/http.ts new file mode 100644 index 0000000000000..41f1025150fdc --- /dev/null +++ b/packages/apps/deno-runtime/lib/accessors/http.ts @@ -0,0 +1,92 @@ +import type { IHttp, IHttpExtend, IHttpRequest, IHttpResponse } from '@rocket.chat/apps-engine/definition/accessors/IHttp.ts'; +import type { IPersistence } from '@rocket.chat/apps-engine/definition/accessors/IPersistence.ts'; +import type { IRead } from '@rocket.chat/apps-engine/definition/accessors/IRead.ts'; + +import * as Messenger from '../messenger.ts'; +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { formatErrorResponse } from './formatResponseErrorHandler.ts'; + +type RequestMethod = 'get' | 'post' | 'put' | 'head' | 'delete' | 'patch'; + +export class Http implements IHttp { + private httpExtender: IHttpExtend; + private read: IRead; + private persistence: IPersistence; + private senderFn: typeof Messenger.sendRequest; + + constructor(read: IRead, persistence: IPersistence, httpExtender: IHttpExtend, senderFn: typeof Messenger.sendRequest) { + this.read = read; + this.persistence = persistence; + this.httpExtender = httpExtender; + this.senderFn = senderFn; + // this.httpExtender = new HttpExtend(); + } + + public get(url: string, options?: IHttpRequest): Promise { + return this._processHandler(url, 'get', options); + } + + public put(url: string, options?: IHttpRequest): Promise { + return this._processHandler(url, 'put', options); + } + + public post(url: string, options?: IHttpRequest): Promise { + return this._processHandler(url, 'post', options); + } + + public del(url: string, options?: IHttpRequest): Promise { + return this._processHandler(url, 'delete', options); + } + + public patch(url: string, options?: IHttpRequest): Promise { + return this._processHandler(url, 'patch', options); + } + + private async _processHandler(url: string, method: RequestMethod, options?: IHttpRequest): Promise { + let request = options || {}; + + if (typeof request.headers === 'undefined') { + request.headers = {}; + } + + this.httpExtender.getDefaultHeaders().forEach((value: string, key: string) => { + if (typeof request.headers?.[key] !== 'string') { + request.headers![key] = value; + } + }); + + if (typeof request.params === 'undefined') { + request.params = {}; + } + + this.httpExtender.getDefaultParams().forEach((value: string, key: string) => { + if (typeof request.params?.[key] !== 'string') { + request.params![key] = value; + } + }); + + for (const handler of this.httpExtender.getPreRequestHandlers()) { + request = await handler.executePreHttpRequest(url, request, this.read, this.persistence); + } + + let { result: response } = await this.senderFn({ + method: `bridges:getHttpBridge:doCall`, + params: [ + { + appId: AppObjectRegistry.get('id'), + method, + url, + request, + }, + ], + }).catch((error) => { + throw formatErrorResponse(error); + }); + + for (const handler of this.httpExtender.getPreResponseHandlers()) { + response = await handler.executePreHttpResponse(response as IHttpResponse, this.read, this.persistence); + } + + return response as IHttpResponse; + } +} diff --git a/packages/apps/deno-runtime/lib/accessors/mod.ts b/packages/apps/deno-runtime/lib/accessors/mod.ts new file mode 100644 index 0000000000000..fc2fb6a3f6669 --- /dev/null +++ b/packages/apps/deno-runtime/lib/accessors/mod.ts @@ -0,0 +1,322 @@ +import type { IAppAccessors } from '@rocket.chat/apps-engine/definition/accessors/IAppAccessors.ts'; +import type { IApiEndpointMetadata } from '@rocket.chat/apps-engine/definition/api/IApiEndpointMetadata.ts'; +import type { IEnvironmentWrite } from '@rocket.chat/apps-engine/definition/accessors/IEnvironmentWrite.ts'; +import type { IEnvironmentRead } from '@rocket.chat/apps-engine/definition/accessors/IEnvironmentRead.ts'; +import type { IConfigurationModify } from '@rocket.chat/apps-engine/definition/accessors/IConfigurationModify.ts'; +import type { IRead } from '@rocket.chat/apps-engine/definition/accessors/IRead.ts'; +import type { IModify } from '@rocket.chat/apps-engine/definition/accessors/IModify.ts'; +import type { INotifier } from '@rocket.chat/apps-engine/definition/accessors/INotifier.ts'; +import type { IPersistence } from '@rocket.chat/apps-engine/definition/accessors/IPersistence.ts'; +import type { IHttp, IHttpExtend } from '@rocket.chat/apps-engine/definition/accessors/IHttp.ts'; +import type { IConfigurationExtend } from '@rocket.chat/apps-engine/definition/accessors/IConfigurationExtend.ts'; +import type { ISlashCommand } from '@rocket.chat/apps-engine/definition/slashcommands/ISlashCommand.ts'; +import type { IProcessor } from '@rocket.chat/apps-engine/definition/scheduler/IProcessor.ts'; +import type { IApi } from '@rocket.chat/apps-engine/definition/api/IApi.ts'; +import type { IVideoConfProvider } from '@rocket.chat/apps-engine/definition/videoConfProviders/IVideoConfProvider.ts'; +import type { + IOutboundPhoneMessageProvider, + IOutboundEmailMessageProvider, +} from '@rocket.chat/apps-engine/definition/outboundCommunication/IOutboundCommsProvider.ts'; + +import { Http } from './http.ts'; +import { HttpExtend } from './extenders/HttpExtender.ts'; +import * as Messenger from '../messenger.ts'; +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { ModifyCreator } from './modify/ModifyCreator.ts'; +import { ModifyUpdater } from './modify/ModifyUpdater.ts'; +import { ModifyExtender } from './modify/ModifyExtender.ts'; +import { Notifier } from './notifier.ts'; +import { formatErrorResponse } from './formatResponseErrorHandler.ts'; + +const httpMethods = ['get', 'post', 'put', 'delete', 'head', 'options', 'patch'] as const; + +// We need to create this object first thing, as we'll handle references to it later on +if (!AppObjectRegistry.has('apiEndpoints')) { + AppObjectRegistry.set('apiEndpoints', []); +} + +export class AppAccessors { + private defaultAppAccessors?: IAppAccessors; + private environmentRead?: IEnvironmentRead; + private environmentWriter?: IEnvironmentWrite; + private configModifier?: IConfigurationModify; + private configExtender?: IConfigurationExtend; + private reader?: IRead; + private modifier?: IModify; + private persistence?: IPersistence; + private creator?: ModifyCreator; + private updater?: ModifyUpdater; + private extender?: ModifyExtender; + private httpExtend: IHttpExtend = new HttpExtend(); + private http?: IHttp; + private notifier?: INotifier; + + private proxify: (namespace: string, overrides?: Record unknown>) => T; + + constructor(private readonly senderFn: typeof Messenger.sendRequest) { + this.proxify = (namespace: string, overrides: Record unknown> = {}): T => + new Proxy( + { __kind: `accessor:${namespace}` }, + { + get: + (_target: unknown, prop: string) => + (...params: unknown[]) => { + // We don't want to send a request for this prop + if (prop === 'toJSON') { + return {}; + } + + // If the prop is inteded to be overriden by the caller + if (prop in overrides) { + return overrides[prop].apply(undefined, params); + } + + return senderFn({ + method: `accessor:${namespace}:${prop}`, + params, + }) + .then((response) => response.result) + .catch((err) => { + throw formatErrorResponse(err); + }); + }, + }, + ) as T; + + this.http = new Http(this.getReader(), this.getPersistence(), this.httpExtend, this.getSenderFn()); + this.notifier = new Notifier(this.getSenderFn()); + } + + public getSenderFn() { + return this.senderFn; + } + + public getEnvironmentRead(): IEnvironmentRead { + if (!this.environmentRead) { + this.environmentRead = { + getSettings: () => this.proxify('getEnvironmentRead:getSettings'), + getServerSettings: () => this.proxify('getEnvironmentRead:getServerSettings'), + getEnvironmentVariables: () => this.proxify('getEnvironmentRead:getEnvironmentVariables'), + }; + } + + return this.environmentRead; + } + + public getEnvironmentWrite() { + if (!this.environmentWriter) { + this.environmentWriter = { + getSettings: () => this.proxify('getEnvironmentWrite:getSettings'), + getServerSettings: () => this.proxify('getEnvironmentWrite:getServerSettings'), + }; + } + + return this.environmentWriter; + } + + public getConfigurationModify() { + if (!this.configModifier) { + this.configModifier = { + scheduler: this.proxify('getConfigurationModify:scheduler'), + slashCommands: { + _proxy: this.proxify('getConfigurationModify:slashCommands'), + modifySlashCommand(slashcommand: ISlashCommand) { + // Store the slashcommand instance to use when the Apps-Engine calls the slashcommand + AppObjectRegistry.set(`slashcommand:${slashcommand.command}`, slashcommand); + + return this._proxy.modifySlashCommand(slashcommand); + }, + disableSlashCommand(command: string) { + return this._proxy.disableSlashCommand(command); + }, + enableSlashCommand(command: string) { + return this._proxy.enableSlashCommand(command); + }, + }, + serverSettings: this.proxify('getConfigurationModify:serverSettings'), + }; + } + + return this.configModifier; + } + + public getConfigurationExtend() { + if (!this.configExtender) { + const senderFn = this.senderFn; + + this.configExtender = { + ui: this.proxify('getConfigurationExtend:ui'), + http: this.httpExtend, + settings: this.proxify('getConfigurationExtend:settings'), + externalComponents: this.proxify('getConfigurationExtend:externalComponents'), + api: { + _proxy: this.proxify('getConfigurationExtend:api'), + async provideApi(api: IApi) { + const apiEndpoints = AppObjectRegistry.get('apiEndpoints')!; + + api.endpoints.forEach((endpoint) => { + endpoint._availableMethods = httpMethods.filter((method) => typeof endpoint[method] === 'function'); + + // We need to keep a reference to the endpoint around for us to call the executor later + AppObjectRegistry.set(`api:${endpoint.path}`, endpoint); + }); + + const result = await this._proxy.provideApi(api); + + // Let's call the listApis method to cache the info from the endpoints + // Also, since this is a side-effect, we do it async so we can return to the caller + senderFn({ method: 'accessor:api:listApis' }) + .then((response) => apiEndpoints.push(...(response.result as IApiEndpointMetadata[]))) + .catch((err) => err.error); + + return result; + }, + }, + scheduler: { + _proxy: this.proxify('getConfigurationExtend:scheduler'), + registerProcessors(processors: IProcessor[]) { + // Store the processor instance to use when the Apps-Engine calls the processor + processors.forEach((processor) => { + AppObjectRegistry.set(`scheduler:${processor.id}`, processor); + }); + + return this._proxy.registerProcessors(processors); + }, + }, + videoConfProviders: { + _proxy: this.proxify('getConfigurationExtend:videoConfProviders'), + provideVideoConfProvider(provider: IVideoConfProvider) { + // Store the videoConfProvider instance to use when the Apps-Engine calls the videoConfProvider + AppObjectRegistry.set(`videoConfProvider:${provider.name}`, provider); + + return this._proxy.provideVideoConfProvider(provider); + }, + }, + outboundCommunication: { + _proxy: this.proxify('getConfigurationExtend:outboundCommunication'), + registerEmailProvider(provider: IOutboundEmailMessageProvider) { + AppObjectRegistry.set(`outboundCommunication:${provider.name}-${provider.type}`, provider); + return this._proxy.registerEmailProvider(provider); + }, + registerPhoneProvider(provider: IOutboundPhoneMessageProvider) { + AppObjectRegistry.set(`outboundCommunication:${provider.name}-${provider.type}`, provider); + return this._proxy.registerPhoneProvider(provider); + }, + }, + slashCommands: { + _proxy: this.proxify('getConfigurationExtend:slashCommands'), + provideSlashCommand(slashcommand: ISlashCommand) { + // Store the slashcommand instance to use when the Apps-Engine calls the slashcommand + AppObjectRegistry.set(`slashcommand:${slashcommand.command}`, slashcommand); + + return this._proxy.provideSlashCommand(slashcommand); + }, + }, + }; + } + + return this.configExtender; + } + + public getDefaultAppAccessors() { + if (!this.defaultAppAccessors) { + this.defaultAppAccessors = { + environmentReader: this.getEnvironmentRead(), + environmentWriter: this.getEnvironmentWrite(), + reader: this.getReader(), + http: this.getHttp(), + providedApiEndpoints: AppObjectRegistry.get('apiEndpoints') as IApiEndpointMetadata[], + }; + } + + return this.defaultAppAccessors; + } + + public getReader() { + if (!this.reader) { + this.reader = { + getEnvironmentReader: () => ({ + getSettings: () => this.proxify('getReader:getEnvironmentReader:getSettings'), + getServerSettings: () => this.proxify('getReader:getEnvironmentReader:getServerSettings'), + getEnvironmentVariables: () => this.proxify('getReader:getEnvironmentReader:getEnvironmentVariables'), + }), + getMessageReader: () => this.proxify('getReader:getMessageReader'), + getPersistenceReader: () => this.proxify('getReader:getPersistenceReader'), + getRoomReader: () => this.proxify('getReader:getRoomReader'), + getUserReader: () => this.proxify('getReader:getUserReader'), + getNotifier: () => this.getNotifier(), + getLivechatReader: () => this.proxify('getReader:getLivechatReader'), + getUploadReader: () => this.proxify('getReader:getUploadReader'), + getCloudWorkspaceReader: () => this.proxify('getReader:getCloudWorkspaceReader'), + getVideoConferenceReader: () => this.proxify('getReader:getVideoConferenceReader'), + getOAuthAppsReader: () => this.proxify('getReader:getOAuthAppsReader'), + getThreadReader: () => this.proxify('getReader:getThreadReader'), + getRoleReader: () => this.proxify('getReader:getRoleReader'), + getContactReader: () => this.proxify('getReader:getContactReader'), + getExperimentalReader: () => this.proxify('getReader:getExperimentalReader'), + }; + } + + return this.reader; + } + + public getModifier() { + if (!this.modifier) { + this.modifier = { + getCreator: this.getCreator.bind(this), + getUpdater: this.getUpdater.bind(this), + getExtender: this.getExtender.bind(this), + getDeleter: () => this.proxify('getModifier:getDeleter'), + getNotifier: () => this.getNotifier(), + getUiController: () => this.proxify('getModifier:getUiController'), + getScheduler: () => this.proxify('getModifier:getScheduler'), + getOAuthAppsModifier: () => this.proxify('getModifier:getOAuthAppsModifier'), + getModerationModifier: () => this.proxify('getModifier:getModerationModifier'), + }; + } + + return this.modifier; + } + + public getPersistence() { + if (!this.persistence) { + this.persistence = this.proxify('getPersistence'); + } + + return this.persistence; + } + + public getHttp() { + return this.http; + } + + private getCreator() { + if (!this.creator) { + this.creator = new ModifyCreator(this.senderFn); + } + + return this.creator; + } + + private getUpdater() { + if (!this.updater) { + this.updater = new ModifyUpdater(this.senderFn); + } + + return this.updater; + } + + private getExtender() { + if (!this.extender) { + this.extender = new ModifyExtender(this.senderFn); + } + + return this.extender; + } + + private getNotifier() { + return this.notifier; + } +} + +export const AppAccessorsInstance = new AppAccessors(Messenger.sendRequest); diff --git a/packages/apps/deno-runtime/lib/accessors/modify/ModifyCreator.ts b/packages/apps/deno-runtime/lib/accessors/modify/ModifyCreator.ts new file mode 100644 index 0000000000000..d30e22c1be182 --- /dev/null +++ b/packages/apps/deno-runtime/lib/accessors/modify/ModifyCreator.ts @@ -0,0 +1,383 @@ +import type { IModifyCreator } from '@rocket.chat/apps-engine/definition/accessors/IModifyCreator.ts'; +import type { IUploadCreator } from '@rocket.chat/apps-engine/definition/accessors/IUploadCreator.ts'; +import type { IEmailCreator } from '@rocket.chat/apps-engine/definition/accessors/IEmailCreator.ts'; +import type { IContactCreator } from '@rocket.chat/apps-engine/definition/accessors/IContactCreator.ts'; +import type { ILivechatCreator } from '@rocket.chat/apps-engine/definition/accessors/ILivechatCreator.ts'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage.ts'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom.ts'; +import type { IBotUser } from '@rocket.chat/apps-engine/definition/users/IBotUser.ts'; +import type { UserType as _UserType } from '@rocket.chat/apps-engine/definition/users/UserType.ts'; +import type { RocketChatAssociationModel as _RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.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 { IUserBuilder } from '@rocket.chat/apps-engine/definition/accessors/IUserBuilder.ts'; +import type { IVideoConferenceBuilder } from '@rocket.chat/apps-engine/definition/accessors/IVideoConferenceBuilder.ts'; +import type { RoomType as _RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType.ts'; +import type { ILivechatMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/ILivechatMessageBuilder.ts'; +import type { UIHelper as _UIHelper } from '@rocket.chat/apps-engine/server/misc/UIHelper.ts'; + +import * as Messenger from '../../messenger.ts'; +import { randomBytes } from 'node:crypto'; + +import { BlockBuilder } from '../builders/BlockBuilder.ts'; +import { MessageBuilder } from '../builders/MessageBuilder.ts'; +import { DiscussionBuilder, IDiscussionBuilder } from '../builders/DiscussionBuilder.ts'; +import { ILivechatMessage, LivechatMessageBuilder } from '../builders/LivechatMessageBuilder.ts'; +import { RoomBuilder } from '../builders/RoomBuilder.ts'; +import { UserBuilder } from '../builders/UserBuilder.ts'; +import { AppVideoConference, VideoConferenceBuilder } from '../builders/VideoConferenceBuilder.ts'; +import { AppObjectRegistry } from '../../../AppObjectRegistry.ts'; +import { require } from '../../../lib/require.ts'; +import { formatErrorResponse } from '../formatResponseErrorHandler.ts'; + +const { UIHelper } = require('@rocket.chat/apps-engine/server/misc/UIHelper.js') as { UIHelper: typeof _UIHelper }; +const { RoomType } = require('@rocket.chat/apps-engine/definition/rooms/RoomType.js') as { RoomType: typeof _RoomType }; +const { UserType } = require('@rocket.chat/apps-engine/definition/users/UserType.js') as { UserType: typeof _UserType }; +const { RocketChatAssociationModel } = require('@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.js') as { + RocketChatAssociationModel: typeof _RocketChatAssociationModel; +}; + +export class ModifyCreator implements IModifyCreator { + constructor(private readonly senderFn: typeof Messenger.sendRequest) {} + + getLivechatCreator(): ILivechatCreator { + return new Proxy( + { __kind: 'getLivechatCreator' }, + { + get: (_target: unknown, prop: string) => { + // It's not worthwhile to make an asynchronous request for such a simple method + if (prop === 'createToken') { + return () => randomBytes(16).toString('hex'); + } + + if (prop === 'toJSON') { + return () => ({}); + } + + return (...params: unknown[]) => + this.senderFn({ + method: `accessor:getModifier:getCreator:getLivechatCreator:${prop}`, + params, + }) + .then((response) => response.result) + .catch((err) => { + throw formatErrorResponse(err); + }); + }, + }, + ) as ILivechatCreator; + } + + getUploadCreator(): IUploadCreator { + return new Proxy( + { __kind: 'getUploadCreator' }, + { + get: + (_target: unknown, prop: string) => + (...params: unknown[]) => + prop === 'toJSON' + ? {} + : this.senderFn({ + method: `accessor:getModifier:getCreator:getUploadCreator:${prop}`, + params, + }) + .then((response) => response.result) + .catch((err) => { + throw formatErrorResponse(err); + }), + }, + ) as IUploadCreator; + } + + getEmailCreator(): IEmailCreator { + return new Proxy( + { __kind: 'getEmailCreator' }, + { + get: + (_target: unknown, prop: string) => + (...params: unknown[]) => + prop === 'toJSON' + ? {} + : this.senderFn({ + method: `accessor:getModifier:getCreator:getEmailCreator:${prop}`, + params, + }) + .then((response) => response.result) + .catch((err) => { + throw formatErrorResponse(err); + }), + }, + ); + } + + getContactCreator(): IContactCreator { + return new Proxy( + { __kind: 'getContactCreator' }, + { + get: + (_target: unknown, prop: string) => + (...params: unknown[]) => + prop === 'toJSON' + ? {} + : this.senderFn({ + method: `accessor:getModifier:getCreator:getContactCreator:${prop}`, + params, + }) + .then((response) => response.result) + .catch((err) => { + throw formatErrorResponse(err); + }), + }, + ); + } + + getBlockBuilder() { + return new BlockBuilder(); + } + + startMessage(data?: IMessage) { + if (data) { + delete data.id; + } + + return new MessageBuilder(data); + } + + startLivechatMessage(data?: ILivechatMessage) { + if (data) { + delete data.id; + } + + return new LivechatMessageBuilder(data); + } + + startRoom(data?: IRoom) { + if (data) { + // @ts-ignore - this has been imported from the Apps-Engine + delete data.id; + } + + return new RoomBuilder(data); + } + + startDiscussion(data?: Partial) { + if (data) { + delete data.id; + } + + return new DiscussionBuilder(data); + } + + startVideoConference(data?: Partial) { + return new VideoConferenceBuilder(data); + } + + startBotUser(data?: Partial) { + if (data) { + delete data.id; + + const { roles } = data; + + if (roles?.length) { + const hasRole = roles + .map((role: string) => role.toLocaleLowerCase()) + .some((role: string) => role === 'admin' || role === 'owner' || role === 'moderator'); + + if (hasRole) { + throw new Error('Invalid role assigned to the user. Should not be admin, owner or moderator.'); + } + } + + if (!data.type) { + data.type = UserType.BOT; + } + } + + return new UserBuilder(data); + } + + public finish( + builder: IMessageBuilder | ILivechatMessageBuilder | IRoomBuilder | IDiscussionBuilder | IVideoConferenceBuilder | IUserBuilder, + ): Promise { + switch (builder.kind) { + case RocketChatAssociationModel.MESSAGE: + return this._finishMessage(builder as IMessageBuilder); + case RocketChatAssociationModel.LIVECHAT_MESSAGE: + return this._finishLivechatMessage(builder as ILivechatMessageBuilder); + case RocketChatAssociationModel.ROOM: + return this._finishRoom(builder as IRoomBuilder); + case RocketChatAssociationModel.DISCUSSION: + return this._finishDiscussion(builder as IDiscussionBuilder); + case RocketChatAssociationModel.VIDEO_CONFERENCE: + return this._finishVideoConference(builder as IVideoConferenceBuilder); + case RocketChatAssociationModel.USER: + return this._finishUser(builder as IUserBuilder); + default: + throw new Error('Invalid builder passed to the ModifyCreator.finish function.'); + } + } + + private async _finishMessage(builder: IMessageBuilder): Promise { + const result = builder.getMessage(); + delete result.id; + + if (!result.sender || !result.sender.id) { + const response = await this.senderFn({ + method: 'bridges:getUserBridge:doGetAppUser', + params: ['APP_ID'], + }).catch((err) => { + throw formatErrorResponse(err); + }); + + const appUser = response.result; + + if (!appUser) { + throw new Error('Invalid sender assigned to the message.'); + } + + result.sender = appUser; + } + + if (result.blocks?.length) { + // Can we move this elsewhere? This AppObjectRegistry usage doesn't really belong here, but where? + result.blocks = UIHelper.assignIds(result.blocks, AppObjectRegistry.get('id') || ''); + } + + const response = await this.senderFn({ + method: 'bridges:getMessageBridge:doCreate', + params: [result, AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + + return String(response.result); + } + + private async _finishLivechatMessage(builder: ILivechatMessageBuilder): Promise { + if (builder.getSender() && !builder.getVisitor()) { + return this._finishMessage(builder.getMessageBuilder()); + } + + const result = builder.getMessage(); + delete result.id; + + if (!result.token && (!result.visitor || !result.visitor.token)) { + throw new Error('Invalid visitor sending the message'); + } + + result.token = result.visitor ? result.visitor.token : result.token; + + const response = await this.senderFn({ + method: 'bridges:getLivechatBridge:doCreateMessage', + params: [result, AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + + return String(response.result); + } + + private async _finishRoom(builder: IRoomBuilder): Promise { + const result = builder.getRoom(); + delete result.id; + + if (!result.type) { + throw new Error('Invalid type assigned to the room.'); + } + + if (result.type !== RoomType.LIVE_CHAT) { + if (!result.creator || !result.creator.id) { + throw new Error('Invalid creator assigned to the room.'); + } + } + + if (result.type !== RoomType.DIRECT_MESSAGE) { + if (result.type !== RoomType.LIVE_CHAT) { + if (!result.slugifiedName || !result.slugifiedName.trim()) { + throw new Error('Invalid slugifiedName assigned to the room.'); + } + } + + if (!result.displayName || !result.displayName.trim()) { + throw new Error('Invalid displayName assigned to the room.'); + } + } + + const response = await this.senderFn({ + method: 'bridges:getRoomBridge:doCreate', + params: [result, builder.getMembersToBeAddedUsernames(), AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + + return String(response.result); + } + + private async _finishDiscussion(builder: IDiscussionBuilder): Promise { + const room = builder.getRoom(); + delete room.id; + + if (!room.creator || !room.creator.id) { + throw new Error('Invalid creator assigned to the discussion.'); + } + + if (!room.slugifiedName || !room.slugifiedName.trim()) { + throw new Error('Invalid slugifiedName assigned to the discussion.'); + } + + if (!room.displayName || !room.displayName.trim()) { + throw new Error('Invalid displayName assigned to the discussion.'); + } + + if (!room.parentRoom || !room.parentRoom.id) { + throw new Error('Invalid parentRoom assigned to the discussion.'); + } + + const response = await this.senderFn({ + method: 'bridges:getRoomBridge:doCreateDiscussion', + params: [room, builder.getParentMessage(), builder.getReply(), builder.getMembersToBeAddedUsernames(), AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + + return String(response.result); + } + + private async _finishVideoConference(builder: IVideoConferenceBuilder): Promise { + const videoConference = builder.getVideoConference(); + + if (!videoConference.createdBy) { + throw new Error('Invalid creator assigned to the video conference.'); + } + + if (!videoConference.providerName?.trim()) { + throw new Error('Invalid provider name assigned to the video conference.'); + } + + if (!videoConference.rid) { + throw new Error('Invalid roomId assigned to the video conference.'); + } + + const response = await this.senderFn({ + method: 'bridges:getVideoConferenceBridge:doCreate', + params: [videoConference, AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + + return String(response.result); + } + + private async _finishUser(builder: IUserBuilder): Promise { + const user = builder.getUser(); + + const response = await this.senderFn({ + method: 'bridges:getUserBridge:doCreate', + params: [user, AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + + return String(response.result); + } +} diff --git a/packages/apps/deno-runtime/lib/accessors/modify/ModifyExtender.ts b/packages/apps/deno-runtime/lib/accessors/modify/ModifyExtender.ts new file mode 100644 index 0000000000000..5f8e0c53ec04a --- /dev/null +++ b/packages/apps/deno-runtime/lib/accessors/modify/ModifyExtender.ts @@ -0,0 +1,106 @@ +import type { IModifyExtender } from '@rocket.chat/apps-engine/definition/accessors/IModifyExtender.ts'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage.ts'; +import type { IMessageExtender } from '@rocket.chat/apps-engine/definition/accessors/IMessageExtender.ts'; +import type { IRoomExtender } from '@rocket.chat/apps-engine/definition/accessors/IRoomExtender.ts'; +import type { IVideoConferenceExtender } from '@rocket.chat/apps-engine/definition/accessors/IVideoConferenceExtend.ts'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser.ts'; +import type { VideoConference } from '@rocket.chat/apps-engine/definition/videoConferences/IVideoConference.ts'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom.ts'; +import type { RocketChatAssociationModel as _RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts'; + +import * as Messenger from '../../messenger.ts'; +import { AppObjectRegistry } from '../../../AppObjectRegistry.ts'; +import { MessageExtender } from '../extenders/MessageExtender.ts'; +import { RoomExtender } from '../extenders/RoomExtender.ts'; +import { VideoConferenceExtender } from '../extenders/VideoConferenceExtend.ts'; +import { require } from '../../../lib/require.ts'; +import { formatErrorResponse } from '../formatResponseErrorHandler.ts'; + +const { RocketChatAssociationModel } = require('@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.js') as { + RocketChatAssociationModel: typeof _RocketChatAssociationModel; +}; + +export class ModifyExtender implements IModifyExtender { + constructor(private readonly senderFn: typeof Messenger.sendRequest) {} + + public async extendMessage(messageId: string, updater: IUser): Promise { + const result = await this.senderFn({ + method: 'bridges:getMessageBridge:doGetById', + params: [messageId, AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + + const msg = result.result as IMessage; + + msg.editor = updater; + msg.editedAt = new Date(); + + return new MessageExtender(msg); + } + + public async extendRoom(roomId: string, _updater: IUser): Promise { + const result = await this.senderFn({ + method: 'bridges:getRoomBridge:doGetById', + params: [roomId, AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + + const room = result.result as IRoom; + + room.updatedAt = new Date(); + + return new RoomExtender(room); + } + + public async extendVideoConference(id: string): Promise { + const result = await this.senderFn({ + method: 'bridges:getVideoConferenceBridge:doGetById', + params: [id, AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + + const call = result.result as VideoConference; + + call._updatedAt = new Date(); + + return new VideoConferenceExtender(call); + } + + public async finish(extender: IMessageExtender | IRoomExtender | IVideoConferenceExtender): Promise { + switch (extender.kind) { + case RocketChatAssociationModel.MESSAGE: + await this.senderFn({ + method: 'bridges:getMessageBridge:doUpdate', + params: [(extender as IMessageExtender).getMessage(), AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + break; + case RocketChatAssociationModel.ROOM: + await this.senderFn({ + method: 'bridges:getRoomBridge:doUpdate', + params: [ + (extender as IRoomExtender).getRoom(), + (extender as IRoomExtender).getUsernamesOfMembersBeingAdded(), + AppObjectRegistry.get('id'), + ], + }).catch((err) => { + throw formatErrorResponse(err); + }); + break; + case RocketChatAssociationModel.VIDEO_CONFERENCE: + await this.senderFn({ + method: 'bridges:getVideoConferenceBridge:doUpdate', + params: [(extender as IVideoConferenceExtender).getVideoConference(), AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + break; + default: + throw new Error('Invalid extender passed to the ModifyExtender.finish function.'); + } + } +} diff --git a/packages/apps/deno-runtime/lib/accessors/modify/ModifyUpdater.ts b/packages/apps/deno-runtime/lib/accessors/modify/ModifyUpdater.ts new file mode 100644 index 0000000000000..301437627de18 --- /dev/null +++ b/packages/apps/deno-runtime/lib/accessors/modify/ModifyUpdater.ts @@ -0,0 +1,170 @@ +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'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage.ts'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom.ts'; + +import type { UIHelper as _UIHelper } from '@rocket.chat/apps-engine/server/misc/UIHelper.ts'; +import type { RoomType as _RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType.ts'; +import type { RocketChatAssociationModel as _RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.ts'; + +import * as Messenger from '../../messenger.ts'; + +import { MessageBuilder } from '../builders/MessageBuilder.ts'; +import { RoomBuilder } from '../builders/RoomBuilder.ts'; +import { AppObjectRegistry } from '../../../AppObjectRegistry.ts'; + +import { require } from '../../../lib/require.ts'; +import { formatErrorResponse } from '../formatResponseErrorHandler.ts'; + +const { UIHelper } = require('@rocket.chat/apps-engine/server/misc/UIHelper.js') as { UIHelper: typeof _UIHelper }; +const { RoomType } = require('@rocket.chat/apps-engine/definition/rooms/RoomType.js') as { RoomType: typeof _RoomType }; +const { RocketChatAssociationModel } = require('@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations.js') as { + RocketChatAssociationModel: typeof _RocketChatAssociationModel; +}; + +export class ModifyUpdater implements IModifyUpdater { + 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'); + } + + private proxify(target: 'getLivechatUpdater' | 'getUserUpdater' | 'getMessageUpdater'): T { + return new Proxy( + { __kind: target }, + { + get: + (_target: unknown, prop: string) => + (...params: unknown[]) => + prop === 'toJSON' + ? {} + : this.senderFn({ + method: `accessor:getModifier:getUpdater:${target}:${prop}`, + params, + }) + .then((response) => response.result) + .catch((err) => { + throw formatErrorResponse(err); + }), + }, + ) as T; + } + + public getLivechatUpdater(): ILivechatUpdater { + return this.livechatUpdater; + } + + public getUserUpdater(): IUserUpdater { + return this.userUpdater; + } + + public getMessageUpdater(): IMessageUpdater { + return this.messageUpdater; + } + + public async message(messageId: string, editor: IUser): Promise { + const response = await this.senderFn({ + method: 'bridges:getMessageBridge:doGetById', + params: [messageId, AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + + const builder = new MessageBuilder(response.result as IMessage); + + builder.setEditor(editor); + + return builder; + } + + public async room(roomId: string, _updater: IUser): Promise { + const response = await this.senderFn({ + method: 'bridges:getRoomBridge:doGetById', + params: [roomId, AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + + return new RoomBuilder(response.result as IRoom); + } + + public finish(builder: IMessageBuilder | IRoomBuilder): Promise { + switch (builder.kind) { + case RocketChatAssociationModel.MESSAGE: + return this._finishMessage(builder as MessageBuilder); + case RocketChatAssociationModel.ROOM: + return this._finishRoom(builder as RoomBuilder); + default: + throw new Error('Invalid builder passed to the ModifyUpdater.finish function.'); + } + } + + private async _finishMessage(builder: MessageBuilder): Promise { + const result = builder.getMessage(); + + if (!result.id) { + throw new Error("Invalid message, can't update a message without an id."); + } + + if (!result.sender?.id) { + throw new Error('Invalid sender assigned to the message.'); + } + + if (result.blocks?.length) { + result.blocks = UIHelper.assignIds(result.blocks, AppObjectRegistry.get('id') || ''); + } + + const changes = { id: result.id, ...builder.getChanges() }; + + await this.senderFn({ + method: 'bridges:getMessageBridge:doUpdate', + params: [changes, AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + } + + private async _finishRoom(builder: RoomBuilder): Promise { + const room = builder.getRoom(); + + if (!room.id) { + throw new Error("Invalid room, can't update a room without an id."); + } + + if (!room.type) { + throw new Error('Invalid type assigned to the room.'); + } + + if (room.type !== RoomType.LIVE_CHAT) { + if (!room.creator || !room.creator.id) { + throw new Error('Invalid creator assigned to the room.'); + } + + if (!room.slugifiedName || !room.slugifiedName.trim()) { + throw new Error('Invalid slugifiedName assigned to the room.'); + } + } + + if (!room.displayName || !room.displayName.trim()) { + throw new Error('Invalid displayName assigned to the room.'); + } + + const changes = { id: room.id, ...builder.getChanges() }; + + await this.senderFn({ + method: 'bridges:getRoomBridge:doUpdate', + params: [changes, builder.getMembersToBeAddedUsernames(), AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + } +} diff --git a/packages/apps/deno-runtime/lib/accessors/notifier.ts b/packages/apps/deno-runtime/lib/accessors/notifier.ts new file mode 100644 index 0000000000000..1a85cc12b579f --- /dev/null +++ b/packages/apps/deno-runtime/lib/accessors/notifier.ts @@ -0,0 +1,84 @@ +import type { IMessageBuilder, INotifier } from '@rocket.chat/apps-engine/definition/accessors'; +import type { ITypingOptions } from '@rocket.chat/apps-engine/definition/accessors/INotifier.ts'; +import type { _TypingScope } from '@rocket.chat/apps-engine/definition/accessors/INotifier.ts'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; +import { MessageBuilder } from './builders/MessageBuilder.ts'; +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import * as Messenger from '../messenger.ts'; +import { require } from '../require.ts'; +import { formatErrorResponse } from './formatResponseErrorHandler.ts'; + +const { TypingScope } = require('@rocket.chat/apps-engine/definition/accessors/INotifier.js') as { + TypingScope: typeof _TypingScope; +}; + +export class Notifier implements INotifier { + private senderFn: typeof Messenger.sendRequest; + + constructor(senderFn: typeof Messenger.sendRequest) { + this.senderFn = senderFn; + } + + public async notifyUser(user: IUser, message: IMessage): Promise { + if (!message.sender || !message.sender.id) { + const appUser = await this.getAppUser(); + + message.sender = appUser; + } + + await this.callMessageBridge('doNotifyUser', [user, message, AppObjectRegistry.get('id')]); + } + + public async notifyRoom(room: IRoom, message: IMessage): Promise { + if (!message.sender || !message.sender.id) { + const appUser = await this.getAppUser(); + + message.sender = appUser; + } + + await this.callMessageBridge('doNotifyRoom', [room, message, AppObjectRegistry.get('id')]); + } + + public async typing(options: ITypingOptions): Promise<() => Promise> { + options.scope = options.scope || TypingScope.Room; + + if (!options.username) { + const appUser = await this.getAppUser(); + options.username = (appUser && appUser.name) || ''; + } + + const appId = AppObjectRegistry.get('id'); + + await this.callMessageBridge('doTyping', [{ ...options, isTyping: true }, appId]); + + return async () => { + await this.callMessageBridge('doTyping', [{ ...options, isTyping: false }, appId]); + }; + } + + public getMessageBuilder(): IMessageBuilder { + return new MessageBuilder(); + } + + private async callMessageBridge(method: string, params: Array): Promise { + await this.senderFn({ + method: `bridges:getMessageBridge:${method}`, + params, + }).catch((err) => { + throw formatErrorResponse(err); + }); + } + + private async getAppUser(): Promise { + const response = await this.senderFn({ + method: 'bridges:getUserBridge:doGetAppUser', + params: [AppObjectRegistry.get('id')], + }).catch((err) => { + throw formatErrorResponse(err); + }); + + return response.result; + } +} diff --git a/packages/apps/deno-runtime/lib/accessors/tests/AppAccessors.test.ts b/packages/apps/deno-runtime/lib/accessors/tests/AppAccessors.test.ts new file mode 100644 index 0000000000000..ffc77b6904bb7 --- /dev/null +++ b/packages/apps/deno-runtime/lib/accessors/tests/AppAccessors.test.ts @@ -0,0 +1,122 @@ +import { afterAll, beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; +import { assertEquals } from 'https://deno.land/std@0.203.0/assert/assert_equals.ts'; + +import { AppAccessors } from '../mod.ts'; +import { AppObjectRegistry } from '../../../AppObjectRegistry.ts'; + +describe('AppAccessors', () => { + let appAccessors: AppAccessors; + const senderFn = (r: object) => + Promise.resolve({ + id: Math.random().toString(36).substring(2), + jsonrpc: '2.0', + result: r, + serialize() { + return JSON.stringify(this); + }, + }); + + beforeEach(() => { + appAccessors = new AppAccessors(senderFn); + AppObjectRegistry.clear(); + }); + + afterAll(() => { + AppObjectRegistry.clear(); + }); + + it('creates the correct format for IRead calls', async () => { + const roomRead = appAccessors.getReader().getRoomReader(); + const room = await roomRead.getById('123'); + + assertEquals(room, { + params: ['123'], + method: 'accessor:getReader:getRoomReader:getById', + }); + }); + + it('creates the correct format for IEnvironmentRead calls from IRead', async () => { + const reader = appAccessors.getReader().getEnvironmentReader().getEnvironmentVariables(); + const room = await reader.getValueByName('NODE_ENV'); + + assertEquals(room, { + params: ['NODE_ENV'], + method: 'accessor:getReader:getEnvironmentReader:getEnvironmentVariables:getValueByName', + }); + }); + + it('creates the correct format for IEvironmentRead calls', async () => { + const envRead = appAccessors.getEnvironmentRead(); + const env = await envRead.getServerSettings().getValueById('123'); + + assertEquals(env, { + params: ['123'], + method: 'accessor:getEnvironmentRead:getServerSettings:getValueById', + }); + }); + + it('creates the correct format for IEvironmentWrite calls', async () => { + const envRead = appAccessors.getEnvironmentWrite(); + const env = await envRead.getServerSettings().incrementValue('123', 6); + + assertEquals(env, { + params: ['123', 6], + method: 'accessor:getEnvironmentWrite:getServerSettings:incrementValue', + }); + }); + + it('creates the correct format for IConfigurationModify calls', async () => { + const configModify = appAccessors.getConfigurationModify(); + const command = await configModify.slashCommands.modifySlashCommand({ + command: 'test', + i18nDescription: 'test', + i18nParamsExample: 'test', + providesPreview: true, + }); + + assertEquals(command, { + params: [ + { + command: 'test', + i18nDescription: 'test', + i18nParamsExample: 'test', + providesPreview: true, + }, + ], + method: 'accessor:getConfigurationModify:slashCommands:modifySlashCommand', + }); + }); + + it('correctly stores a reference to a slashcommand object and sends a request via proxy', async () => { + const configExtend = appAccessors.getConfigurationExtend(); + + const slashcommand = { + command: 'test', + i18nDescription: 'test', + i18nParamsExample: 'test', + providesPreview: true, + executor() { + return Promise.resolve(); + }, + }; + + const result = await configExtend.slashCommands.provideSlashCommand(slashcommand); + + assertEquals(AppObjectRegistry.get('slashcommand:test'), slashcommand); + + // The function will not be serialized and sent to the main process + delete result.params[0].executor; + + assertEquals(result, { + method: 'accessor:getConfigurationExtend:slashCommands:provideSlashCommand', + params: [ + { + command: 'test', + i18nDescription: 'test', + i18nParamsExample: 'test', + providesPreview: true, + }, + ], + }); + }); +}); diff --git a/packages/apps/deno-runtime/lib/accessors/tests/ModifyCreator.test.ts b/packages/apps/deno-runtime/lib/accessors/tests/ModifyCreator.test.ts new file mode 100644 index 0000000000000..d88690a77dbfa --- /dev/null +++ b/packages/apps/deno-runtime/lib/accessors/tests/ModifyCreator.test.ts @@ -0,0 +1,259 @@ +// deno-lint-ignore-file no-explicit-any +import { afterAll, beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; +import { assertSpyCall, spy } from 'https://deno.land/std@0.203.0/testing/mock.ts'; +import { assert, assertEquals, assertNotInstanceOf, assertRejects } from 'https://deno.land/std@0.203.0/assert/mod.ts'; + +import { AppObjectRegistry } from '../../../AppObjectRegistry.ts'; +import { ModifyCreator } from '../modify/ModifyCreator.ts'; + +describe('ModifyCreator', () => { + const senderFn = (r: any) => + Promise.resolve({ + id: Math.random().toString(36).substring(2), + jsonrpc: '2.0', + result: r, + serialize() { + return JSON.stringify(this); + }, + }); + + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('id', 'deno-test'); + }); + + afterAll(() => { + AppObjectRegistry.clear(); + }); + + it('sends the correct payload in the request to create a message', async () => { + const spying = spy(senderFn); + const modifyCreator = new ModifyCreator(spying); + const messageBuilder = modifyCreator.startMessage(); + + // Importing types from the Apps-Engine is problematic, so we'll go with `any` here + messageBuilder + .setRoom({ id: '123' } as any) + .setSender({ id: '456' } as any) + .setText('Hello World') + .setUsernameAlias('alias') + .setAvatarUrl('https://avatars.com/123'); + + // We can't get a legitimate return value here, so we ignore it + // but we need to know that the request sent was well formed + await modifyCreator.finish(messageBuilder); + + assertSpyCall(spying, 0, { + args: [ + { + method: 'bridges:getMessageBridge:doCreate', + params: [ + { + room: { id: '123' }, + sender: { id: '456' }, + text: 'Hello World', + alias: 'alias', + avatarUrl: 'https://avatars.com/123', + }, + 'deno-test', + ], + }, + ], + }); + }); + + it('sends the correct payload in the request to upload a buffer', async () => { + const modifyCreator = new ModifyCreator(senderFn); + + const result = await modifyCreator.getUploadCreator().uploadBuffer(new Uint8Array([1, 2, 3, 4]), 'text/plain'); + + assertEquals(result, { + method: 'accessor:getModifier:getCreator:getUploadCreator:uploadBuffer', + params: [new Uint8Array([1, 2, 3, 4]), 'text/plain'], + }); + }); + + it('sends the correct payload in the request to create a visitor', async () => { + const modifyCreator = new ModifyCreator(senderFn); + + const result = (await modifyCreator.getLivechatCreator().createVisitor({ + token: 'random token', + username: 'random username for visitor', + name: 'Random Visitor', + })) as any; // We modified the send function so it changed the original return type of the function + + assertEquals(result, { + method: 'accessor:getModifier:getCreator:getLivechatCreator:createVisitor', + params: [ + { + token: 'random token', + username: 'random username for visitor', + name: 'Random Visitor', + }, + ], + }); + }); + + // This test is important because if we return a promise we break API compatibility + it('does not return a promise for calls of the createToken() method of the LivechatCreator', () => { + const modifyCreator = new ModifyCreator(senderFn); + + const result = modifyCreator.getLivechatCreator().createToken(); + + assertNotInstanceOf(result, Promise); + assert(typeof result === 'string', `Expected "${result}" to be of type "string", but got "${typeof result}"`); + }); + + it('throws an error when a proxy method of getLivechatCreator fails', async () => { + const failingSenderFn = () => Promise.reject(new Error('Test error')); + const modifyCreator = new ModifyCreator(failingSenderFn); + const livechatCreator = modifyCreator.getLivechatCreator(); + + await assertRejects( + () => + livechatCreator.createAndReturnVisitor({ + token: 'visitor-token', + username: 'visitor-username', + name: 'Visitor Name', + }), + Error, + 'Test error', + ); + }); + + it('throws an instance of Error when getLivechatCreator fails with a specific error object', async () => { + const failingSenderFn = () => Promise.reject({ error: { message: 'Livechat method error' } }); + const modifyCreator = new ModifyCreator(failingSenderFn); + const livechatCreator = modifyCreator.getLivechatCreator(); + + await assertRejects( + () => + livechatCreator.createVisitor({ + token: 'visitor-token', + username: 'visitor-username', + name: 'Visitor Name', + }), + Error, + 'Livechat method error', + ); + }); + + it('throws a default Error when getLivechatCreator fails with an unknown error object', async () => { + const failingSenderFn = () => Promise.reject({ error: {} }); + const modifyCreator = new ModifyCreator(failingSenderFn); + const livechatCreator = modifyCreator.getLivechatCreator(); + + await assertRejects( + () => + livechatCreator.createVisitor({ + token: 'visitor-token', + username: 'visitor-username', + name: 'Visitor Name', + }), + Error, + 'An unknown error occurred', + ); + }); + + it('throws an error when a proxy method of getUploadCreator fails', async () => { + const failingSenderFn = () => Promise.reject(new Error('Upload error')); + const modifyCreator = new ModifyCreator(failingSenderFn); + const uploadCreator = modifyCreator.getUploadCreator(); + + await assertRejects(() => uploadCreator.uploadBuffer(new Uint8Array([9, 10, 11, 12]), 'image/png'), Error, 'Upload error'); + }); + + it('throws an instance of Error when getUploadCreator fails with a specific error object', async () => { + const failingSenderFn = () => Promise.reject({ error: { message: 'Upload method error' } }); + const modifyCreator = new ModifyCreator(failingSenderFn); + const uploadCreator = modifyCreator.getUploadCreator(); + + await assertRejects(() => uploadCreator.uploadBuffer(new Uint8Array([1, 2, 3]), 'image/png'), Error, 'Upload method error'); + }); + + it('throws a default Error when getUploadCreator fails with an unknown error object', async () => { + const failingSenderFn = () => Promise.reject({ error: {} }); + const modifyCreator = new ModifyCreator(failingSenderFn); + const uploadCreator = modifyCreator.getUploadCreator(); + + await assertRejects(() => uploadCreator.uploadBuffer(new Uint8Array([1, 2, 3]), 'image/png'), Error, 'An unknown error occurred'); + }); + + it('throws an error when a proxy method of getEmailCreator fails', async () => { + const failingSenderFn = () => Promise.reject(new Error('Email error')); + const modifyCreator = new ModifyCreator(failingSenderFn); + const emailCreator = modifyCreator.getEmailCreator(); + + await assertRejects( + () => + emailCreator.send({ + to: 'test@example.com', + from: 'sender@example.com', + subject: 'Test Email', + text: 'This is a test email.', + }), + Error, + 'Email error', + ); + }); + + it('throws an instance of Error when getEmailCreator fails with a specific error object', async () => { + const failingSenderFn = () => Promise.reject({ error: { message: 'Email method error' } }); + const modifyCreator = new ModifyCreator(failingSenderFn); + const emailCreator = modifyCreator.getEmailCreator(); + + await assertRejects( + () => + emailCreator.send({ + to: 'test@example.com', + from: 'sender@example.com', + subject: 'Test Email', + text: 'This is a test email.', + }), + Error, + 'Email method error', + ); + }); + + it('throws a default Error when getEmailCreator fails with an unknown error object', async () => { + const failingSenderFn = () => Promise.reject({ error: {} }); + const modifyCreator = new ModifyCreator(failingSenderFn); + const emailCreator = modifyCreator.getEmailCreator(); + + await assertRejects( + () => + emailCreator.send({ + to: 'test@example.com', + from: 'sender@example.com', + subject: 'Test Email', + text: 'This is a test email.', + }), + Error, + 'An unknown error occurred', + ); + }); + + it('throws an error when a proxy method of getContactCreator fails', async () => { + const failingSenderFn = () => Promise.reject(new Error('Contact creation error')); + const modifyCreator = new ModifyCreator(failingSenderFn); + const contactCreator = modifyCreator.getContactCreator(); + + await assertRejects(() => contactCreator.addContactEmail('test-contact-id', 'test@example.com'), Error, 'Contact creation error'); + }); + + it('throws an instance of Error when getContactCreator fails with a specific error object', async () => { + const failingSenderFn = () => Promise.reject({ error: { message: 'Contact creation error' } }); + const modifyCreator = new ModifyCreator(failingSenderFn); + const contactCreator = modifyCreator.getContactCreator(); + + await assertRejects(() => contactCreator.addContactEmail('test-contact-id', 'test@example.com'), Error, 'Contact creation error'); + }); + + it('throws a default Error when getContactCreator fails with an unknown error object', async () => { + const failingSenderFn = () => Promise.reject({ error: {} }); + const modifyCreator = new ModifyCreator(failingSenderFn); + const contactCreator = modifyCreator.getContactCreator(); + + await assertRejects(() => contactCreator.addContactEmail('test-contact-id', 'test@example.com'), Error, 'An unknown error occurred'); + }); +}); diff --git a/packages/apps/deno-runtime/lib/accessors/tests/ModifyExtender.test.ts b/packages/apps/deno-runtime/lib/accessors/tests/ModifyExtender.test.ts new file mode 100644 index 0000000000000..de6fd4a7053b3 --- /dev/null +++ b/packages/apps/deno-runtime/lib/accessors/tests/ModifyExtender.test.ts @@ -0,0 +1,244 @@ +// deno-lint-ignore-file no-explicit-any +import { afterAll, beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; +import { assertSpyCall, spy, stub } from 'https://deno.land/std@0.203.0/testing/mock.ts'; +import { assertRejects } from 'https://deno.land/std@0.203.0/assert/mod.ts'; + +import { AppObjectRegistry } from '../../../AppObjectRegistry.ts'; +import { ModifyExtender } from '../modify/ModifyExtender.ts'; +import jsonrpc from 'jsonrpc-lite'; + +describe('ModifyExtender', () => { + let extender: ModifyExtender; + + const senderFn = (r: any) => + Promise.resolve({ + id: Math.random().toString(36).substring(2), + jsonrpc: '2.0', + result: structuredClone(r), + serialize() { + return JSON.stringify(this); + }, + }); + + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('id', 'deno-test'); + extender = new ModifyExtender(senderFn); + }); + + afterAll(() => { + AppObjectRegistry.clear(); + }); + + it('correctly formats requests for the extend message requests', async () => { + const _spy = spy(extender, 'senderFn' as keyof ModifyExtender); + + const messageExtender = await extender.extendMessage('message-id', { _id: 'user-id' } as any); + + assertSpyCall(_spy, 0, { + args: [ + { + method: 'bridges:getMessageBridge:doGetById', + params: ['message-id', 'deno-test'], + }, + ], + }); + + messageExtender.addCustomField('key', 'value'); + + await extender.finish(messageExtender); + + assertSpyCall(_spy, 1, { + args: [ + { + method: 'bridges:getMessageBridge:doUpdate', + params: [messageExtender.getMessage(), 'deno-test'], + }, + ], + }); + + _spy.restore(); + }); + + it('correctly formats requests for the extend room requests', async () => { + const _spy = spy(extender, 'senderFn' as keyof ModifyExtender); + + const roomExtender = await extender.extendRoom('room-id', { _id: 'user-id' } as any); + + assertSpyCall(_spy, 0, { + args: [ + { + method: 'bridges:getRoomBridge:doGetById', + params: ['room-id', 'deno-test'], + }, + ], + }); + + roomExtender.addCustomField('key', 'value'); + + await extender.finish(roomExtender); + + assertSpyCall(_spy, 1, { + args: [ + { + method: 'bridges:getRoomBridge:doUpdate', + params: [roomExtender.getRoom(), [], 'deno-test'], + }, + ], + }); + + _spy.restore(); + }); + + it('correctly formats requests for the extend video conference requests', async () => { + const _spy = spy(extender, 'senderFn' as keyof ModifyExtender); + + const videoConferenceExtender = await extender.extendVideoConference('video-conference-id'); + + assertSpyCall(_spy, 0, { + args: [ + { + method: 'bridges:getVideoConferenceBridge:doGetById', + params: ['video-conference-id', 'deno-test'], + }, + ], + }); + + videoConferenceExtender.setStatus(4); + + await extender.finish(videoConferenceExtender); + + assertSpyCall(_spy, 1, { + args: [ + { + method: 'bridges:getVideoConferenceBridge:doUpdate', + params: [videoConferenceExtender.getVideoConference(), 'deno-test'], + }, + ], + }); + + _spy.restore(); + }); + + describe('Error Handling', () => { + describe('extendMessage', () => { + it('throws an instance of Error when senderFn throws an error', async () => { + const _stub = stub(extender, 'senderFn' as keyof ModifyExtender, () => Promise.reject(new Error('unit-test-error')) as any); + + await assertRejects(() => extender.extendMessage('message-id', { _id: 'user-id' } as any), Error, 'unit-test-error'); + + _stub.restore(); + }); + + it('throws an instance of Error when senderFn throws a jsonrpc error', async () => { + const _stub = stub( + extender, + 'senderFn' as keyof ModifyExtender, + () => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any, + ); + + await assertRejects(() => extender.extendMessage('message-id', { _id: 'user-id' } as any), Error, 'unit-test-error'); + + _stub.restore(); + }); + + it('throws an instance of Error when senderFn throws an unknown value', async () => { + const _stub = stub(extender, 'senderFn' as keyof ModifyExtender, () => Promise.reject({}) as any); + + await assertRejects(() => extender.extendMessage('message-id', { _id: 'user-id' } as any), Error, 'An unknown error occurred'); + + _stub.restore(); + }); + }); + + describe('extendRoom', () => { + it('throws an instance of Error when senderFn throws an error', async () => { + const _stub = stub(extender, 'senderFn' as keyof ModifyExtender, () => Promise.reject(new Error('unit-test-error')) as any); + + await assertRejects(() => extender.extendRoom('room-id', { _id: 'user-id' } as any), Error, 'unit-test-error'); + + _stub.restore(); + }); + + it('throws an instance of Error when senderFn throws a jsonrpc error', async () => { + const _stub = stub( + extender, + 'senderFn' as keyof ModifyExtender, + () => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any, + ); + + await assertRejects(() => extender.extendRoom('room-id', { _id: 'user-id' } as any), Error, 'unit-test-error'); + + _stub.restore(); + }); + + it('throws an instance of Error when senderFn throws an unknown value', async () => { + const _stub = stub(extender, 'senderFn' as keyof ModifyExtender, () => Promise.reject({}) as any); + + await assertRejects(() => extender.extendRoom('room-id', { _id: 'user-id' } as any), Error, 'An unknown error occurred'); + + _stub.restore(); + }); + }); + + describe('extendVideoConference', () => { + it('throws an instance of Error when senderFn throws an error', async () => { + const _stub = stub(extender, 'senderFn' as keyof ModifyExtender, () => Promise.reject(new Error('unit-test-error')) as any); + + await assertRejects(() => extender.extendVideoConference('video-conference-id'), Error, 'unit-test-error'); + + _stub.restore(); + }); + + it('throws an instance of Error when senderFn throws a jsonrpc error', async () => { + const _stub = stub( + extender, + 'senderFn' as keyof ModifyExtender, + () => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any, + ); + + await assertRejects(() => extender.extendVideoConference('video-conference-id'), Error, 'unit-test-error'); + + _stub.restore(); + }); + + it('throws an instance of Error when senderFn throws an unknown value', async () => { + const _stub = stub(extender, 'senderFn' as keyof ModifyExtender, () => Promise.reject({}) as any); + + await assertRejects(() => extender.extendVideoConference('video-conference-id'), Error, 'An unknown error occurred'); + + _stub.restore(); + }); + }); + + describe('finish', () => { + it('throws an instance of Error when senderFn throws an error', async () => { + const _stub = stub(extender, 'senderFn' as keyof ModifyExtender, () => Promise.reject(new Error('unit-test-error')) as any); + + await assertRejects(() => extender.finish({ kind: 'message', getMessage: () => ({}) } as any), Error, 'unit-test-error'); + + _stub.restore(); + }); + + it('throws an instance of Error when senderFn throws a jsonrpc error', async () => { + const _stub = stub( + extender, + 'senderFn' as keyof ModifyExtender, + () => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any, + ); + + await assertRejects(() => extender.finish({ kind: 'message', getMessage: () => ({}) } as any), Error, 'unit-test-error'); + + _stub.restore(); + }); + + it('throws an instance of Error when senderFn throws an unknown value', async () => { + const _stub = stub(extender, 'senderFn' as keyof ModifyExtender, () => Promise.reject({}) as any); + + await assertRejects(() => extender.finish({ kind: 'message', getMessage: () => ({}) } as any), Error, 'An unknown error occurred'); + + _stub.restore(); + }); + }); + }); +}); diff --git a/packages/apps/deno-runtime/lib/accessors/tests/ModifyUpdater.test.ts b/packages/apps/deno-runtime/lib/accessors/tests/ModifyUpdater.test.ts new file mode 100644 index 0000000000000..d351d6ebba721 --- /dev/null +++ b/packages/apps/deno-runtime/lib/accessors/tests/ModifyUpdater.test.ts @@ -0,0 +1,243 @@ +// deno-lint-ignore-file no-explicit-any +import { afterAll, beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; +import { assertSpyCall, spy, stub } from 'https://deno.land/std@0.203.0/testing/mock.ts'; +import { assertEquals, assertRejects } from 'https://deno.land/std@0.203.0/assert/mod.ts'; + +import { AppObjectRegistry } from '../../../AppObjectRegistry.ts'; +import { ModifyUpdater } from '../modify/ModifyUpdater.ts'; +import { RoomBuilder } from '../builders/RoomBuilder.ts'; +import jsonrpc from 'jsonrpc-lite'; + +describe('ModifyUpdater', () => { + let modifyUpdater: ModifyUpdater; + + const senderFn = (r: any) => + Promise.resolve({ + id: Math.random().toString(36).substring(2), + jsonrpc: '2.0', + result: structuredClone(r), + serialize() { + return JSON.stringify(this); + }, + }); + + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('id', 'deno-test'); + modifyUpdater = new ModifyUpdater(senderFn); + }); + + afterAll(() => { + AppObjectRegistry.clear(); + }); + + it('correctly formats requests for the update message flow', async () => { + const _spy = spy(modifyUpdater, 'senderFn' as keyof ModifyUpdater); + + const messageBuilder = await modifyUpdater.message('123', { id: '456' } as any); + + assertSpyCall(_spy, 0, { + args: [ + { + method: 'bridges:getMessageBridge:doGetById', + params: ['123', 'deno-test'], + }, + ], + }); + + messageBuilder.setUpdateData( + { + id: '123', + room: { id: '123' }, + sender: { id: '456' }, + text: 'Hello World', + }, + { + id: '456', + }, + ); + + await modifyUpdater.finish(messageBuilder); + + assertSpyCall(_spy, 1, { + args: [ + { + method: 'bridges:getMessageBridge:doUpdate', + params: [{ id: '123', ...messageBuilder.getChanges() }, 'deno-test'], + }, + ], + }); + + _spy.restore(); + }); + + it('correctly formats requests for the update room flow', async () => { + const _spy = spy(modifyUpdater, 'senderFn' as keyof ModifyUpdater); + + const roomBuilder = (await modifyUpdater.room('123', { id: '456' } as any)) as RoomBuilder; + + assertSpyCall(_spy, 0, { + args: [ + { + method: 'bridges:getRoomBridge:doGetById', + params: ['123', 'deno-test'], + }, + ], + }); + + roomBuilder.setData({ + id: '123', + type: 'c', + displayName: 'Test Room', + slugifiedName: 'test-room', + creator: { id: '456' }, + }); + + roomBuilder.setMembersToBeAddedByUsernames(['username1', 'username2']); + + // We need to sneak in the id as the `modifyUpdater.room` call won't have legitimate data + roomBuilder.getRoom().id = '123'; + + await modifyUpdater.finish(roomBuilder); + + assertSpyCall(_spy, 1, { + args: [ + { + method: 'bridges:getRoomBridge:doUpdate', + params: [{ id: '123', ...roomBuilder.getChanges() }, roomBuilder.getMembersToBeAddedUsernames(), 'deno-test'], + }, + ], + }); + }); + + it('correctly formats requests to UserUpdater methods', async () => { + const result = (await modifyUpdater.getUserUpdater().updateStatusText({ id: '123' } as any, 'Hello World')) as any; + + assertEquals(result, { + method: 'accessor:getModifier:getUpdater:getUserUpdater:updateStatusText', + params: [{ id: '123' }, 'Hello World'], + }); + }); + + it('correctly formats requests to LivechatUpdater methods', async () => { + const result = (await modifyUpdater.getLivechatUpdater().closeRoom({ id: '123' } as any, 'close it!')) as any; + + assertEquals(result, { + method: 'accessor:getModifier:getUpdater:getLivechatUpdater:closeRoom', + params: [{ id: '123' }, 'close it!'], + }); + }); + + 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 () => { + const _stub = stub(modifyUpdater, 'senderFn' as keyof ModifyUpdater, () => Promise.reject(new Error('unit-test-error')) as any); + + await assertRejects(() => modifyUpdater.message('message-id', { _id: 'user-id' } as any), Error, 'unit-test-error'); + + _stub.restore(); + }); + + it('throws an instance of Error when senderFn throws a jsonrpc error', async () => { + const _stub = stub( + modifyUpdater, + 'senderFn' as keyof ModifyUpdater, + () => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any, + ); + + await assertRejects(() => modifyUpdater.message('message-id', { _id: 'user-id' } as any), Error, 'unit-test-error'); + + _stub.restore(); + }); + + it('throws an instance of Error when senderFn throws an unknown value', async () => { + const _stub = stub(modifyUpdater, 'senderFn' as keyof ModifyUpdater, () => Promise.reject({}) as any); + + await assertRejects(() => modifyUpdater.message('message-id', { _id: 'user-id' } as any), Error, 'An unknown error occurred'); + + _stub.restore(); + }); + }); + + describe('room', () => { + it('throws an instance of Error when senderFn throws an error', async () => { + const _stub = stub(modifyUpdater, 'senderFn' as keyof ModifyUpdater, () => Promise.reject(new Error('unit-test-error')) as any); + + await assertRejects(() => modifyUpdater.room('room-id', { _id: 'user-id' } as any), Error, 'unit-test-error'); + + _stub.restore(); + }); + + it('throws an instance of Error when senderFn throws a jsonrpc error', async () => { + const _stub = stub( + modifyUpdater, + 'senderFn' as keyof ModifyUpdater, + () => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any, + ); + + await assertRejects(() => modifyUpdater.room('room-id', { _id: 'user-id' } as any), Error, 'unit-test-error'); + + _stub.restore(); + }); + + it('throws an instance of Error when senderFn throws an unknown value', async () => { + const _stub = stub(modifyUpdater, 'senderFn' as keyof ModifyUpdater, () => Promise.reject({}) as any); + + await assertRejects(() => modifyUpdater.room('room-id', { _id: 'user-id' } as any), Error, 'An unknown error occurred'); + + _stub.restore(); + }); + }); + + describe('finish', () => { + const messageUpdater = { + kind: 'message', + getMessage: () => ({ + id: 'message-id', + sender: { id: 'sender-id' }, + }), + getChanges: () => ({ + id: 'message-id', + sender: { id: 'sender-id' }, + }), + } as any; + + it('throws an instance of Error when senderFn throws an error', async () => { + const _stub = stub(modifyUpdater, 'senderFn' as keyof ModifyUpdater, () => Promise.reject(new Error('unit-test-error')) as any); + + await assertRejects(() => modifyUpdater.finish(messageUpdater), Error, 'unit-test-error'); + + _stub.restore(); + }); + + it('throws an instance of Error when senderFn throws a jsonrpc error', async () => { + const _stub = stub( + modifyUpdater, + 'senderFn' as keyof ModifyUpdater, + () => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any, + ); + + await assertRejects(() => modifyUpdater.finish(messageUpdater), Error, 'unit-test-error'); + + _stub.restore(); + }); + + it('throws an instance of Error when senderFn throws an unknown value', async () => { + const _stub = stub(modifyUpdater, 'senderFn' as keyof ModifyUpdater, () => Promise.reject({}) as any); + + await assertRejects(() => modifyUpdater.finish(messageUpdater), Error, 'An unknown error occurred'); + + _stub.restore(); + }); + }); + }); +}); diff --git a/packages/apps/deno-runtime/lib/accessors/tests/formatResponseErrorHandler.test.ts b/packages/apps/deno-runtime/lib/accessors/tests/formatResponseErrorHandler.test.ts new file mode 100644 index 0000000000000..c909fecdd04a1 --- /dev/null +++ b/packages/apps/deno-runtime/lib/accessors/tests/formatResponseErrorHandler.test.ts @@ -0,0 +1,211 @@ +// deno-lint-ignore-file no-explicit-any +import { assertEquals, assertInstanceOf, assertStrictEquals } from 'https://deno.land/std@0.203.0/assert/mod.ts'; +import { describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; +import * as jsonrpc from 'jsonrpc-lite'; + +import { formatErrorResponse } from '../formatResponseErrorHandler.ts'; + +describe('formatErrorResponse', () => { + describe('JSON-RPC ErrorObject handling', () => { + it('formats ErrorObject instances correctly', () => { + const errorObject = jsonrpc.error('test-id', new jsonrpc.JsonRpcError('Test error message', 1000)); + const result = formatErrorResponse(errorObject); + + assertInstanceOf(result, Error); + assertEquals(result.message, 'Test error message'); + }); + + it('formats objects with error.message structure', () => { + const errorLikeObject = { + error: { + message: 'Custom error message', + code: 404, + }, + }; + const result = formatErrorResponse(errorLikeObject); + + assertInstanceOf(result, Error); + assertEquals(result.message, 'Custom error message'); + }); + + it('handles nested error objects with complex structure', () => { + const complexError = { + error: { + message: 'Database connection failed', + details: { + host: 'localhost', + port: 5432, + }, + }, + id: 'req-123', + }; + const result = formatErrorResponse(complexError); + + assertInstanceOf(result, Error); + assertEquals(result.message, 'Database connection failed'); + }); + + it('handles error objects with empty message', () => { + const emptyMessageError = { + error: { + message: '', + code: 500, + }, + }; + const result = formatErrorResponse(emptyMessageError); + + assertInstanceOf(result, Error); + assertEquals(result.message, ''); + }); + }); + + describe('Error instance passthrough', () => { + it('returns existing Error instances unchanged', () => { + const originalError = new Error('Original error message'); + const result = formatErrorResponse(originalError); + + assertStrictEquals(result, originalError); + assertEquals(result.message, 'Original error message'); + }); + + it('returns custom Error subclasses unchanged', () => { + class CustomError extends Error { + constructor( + message: string, + public code: number, + ) { + super(message); + this.name = 'CustomError'; + } + } + + const customError = new CustomError('Custom error', 404); + const result = formatErrorResponse(customError); + + assertStrictEquals(result, customError); + assertEquals(result.message, 'Custom error'); + assertEquals((result as CustomError).code, 404); + }); + + it('handles Error instances with additional properties', () => { + const errorWithProps = new Error('Error with props') as any; + errorWithProps.statusCode = 500; + errorWithProps.details = { reason: 'timeout' }; + + const result = formatErrorResponse(errorWithProps); + + assertStrictEquals(result, errorWithProps); + assertEquals(result.message, 'Error with props'); + assertEquals((result as any).statusCode, 500); + }); + }); + + describe('Unknown error handling', () => { + it('wraps string errors with default message and cause', () => { + const stringError = 'Simple string error'; + const result = formatErrorResponse(stringError); + + assertInstanceOf(result, Error); + assertEquals(result.message, 'An unknown error occurred'); + assertEquals(result.cause, stringError); + }); + + it('wraps number errors with default message and cause', () => { + const numberError = 404; + const result = formatErrorResponse(numberError); + + assertInstanceOf(result, Error); + assertEquals(result.message, 'An unknown error occurred'); + assertEquals(result.cause, numberError); + }); + + it('wraps boolean errors with default message and cause', () => { + const booleanError = false; + const result = formatErrorResponse(booleanError); + + assertInstanceOf(result, Error); + assertEquals(result.message, 'An unknown error occurred'); + assertEquals(result.cause, booleanError); + }); + + it('wraps null with default message and cause', () => { + const result = formatErrorResponse(null); + + assertInstanceOf(result, Error); + assertEquals(result.message, 'An unknown error occurred'); + assertEquals(result.cause, null); + }); + + it('wraps undefined with default message and cause', () => { + const result = formatErrorResponse(undefined); + + assertInstanceOf(result, Error); + assertEquals(result.message, 'An unknown error occurred'); + assertEquals(result.cause, undefined); + }); + + it('wraps arrays with default message and cause', () => { + const arrayError = ['error', 'details']; + const result = formatErrorResponse(arrayError); + + assertInstanceOf(result, Error); + assertEquals(result.message, 'An unknown error occurred'); + assertEquals(result.cause, arrayError); + }); + + it('wraps functions with default message and cause', () => { + const functionError = () => 'error'; + const result = formatErrorResponse(functionError); + + assertInstanceOf(result, Error); + assertEquals(result.message, 'An unknown error occurred'); + assertEquals(result.cause, functionError); + }); + + it('wraps plain objects without error.message with default message and cause', () => { + const plainObject = { + status: 'failed', + reason: 'timeout', + data: { id: 123 }, + }; + const result = formatErrorResponse(plainObject); + + assertInstanceOf(result, Error); + assertEquals(result.message, 'An unknown error occurred'); + assertEquals(result.cause, plainObject); + }); + + it('wraps objects with error property but no message with default message and cause', () => { + const errorObjectNoMessage = { + error: { + code: 500, + details: 'Internal server error', + }, + }; + const result = formatErrorResponse(errorObjectNoMessage); + + assertInstanceOf(result, Error); + assertEquals(result.message, 'An unknown error occurred'); + assertEquals(result.cause, errorObjectNoMessage); + }); + }); + + it('ensures all returned values are proper Error instances', () => { + const testCases = ['string error', 123, null, undefined, { error: { message: 'test' } }, new Error('test'), { plain: 'object' }]; + + for (const testCase of testCases) { + const result = formatErrorResponse(testCase); + assertInstanceOf(result, Error, `Failed for input: ${JSON.stringify(testCase)}`); + } + }); + + it('prevents "[object Object]" error messages for plain objects', () => { + const plainObject = { status: 'error', code: 500 }; + const result = formatErrorResponse(plainObject); + + assertInstanceOf(result, Error); + assertEquals(result.message, 'An unknown error occurred'); + // Ensure the message is not "[object Object]" + assertEquals(result.message !== '[object Object]', true); + }); +}); diff --git a/packages/apps/deno-runtime/lib/accessors/tests/http.test.ts b/packages/apps/deno-runtime/lib/accessors/tests/http.test.ts new file mode 100644 index 0000000000000..88392dec774cc --- /dev/null +++ b/packages/apps/deno-runtime/lib/accessors/tests/http.test.ts @@ -0,0 +1,164 @@ +// deno-lint-ignore-file no-explicit-any +import { assertRejects } from 'https://deno.land/std@0.203.0/assert/mod.ts'; +import { beforeEach, describe, it, afterAll } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; + +import { Http } from '../http.ts'; +import { AppObjectRegistry } from '../../../AppObjectRegistry.ts'; +import { stub } from 'https://deno.land/std@0.203.0/testing/mock.ts'; + +describe('Http accessor error handling integration', () => { + let http: Http; + + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('id', 'test-app-id'); + + const mockHttpExtend = { + getDefaultHeaders: () => new Map(), + getDefaultParams: () => new Map(), + getPreRequestHandlers: () => [], + getPreResponseHandlers: () => [], + }; + + const mockRead = {}; + const mockPersistence = {}; + + http = new Http(mockRead as any, mockPersistence as any, mockHttpExtend as any, () => Promise.resolve({}) as any); + }); + + afterAll(() => { + AppObjectRegistry.clear(); + }); + + describe('HTTP method error handling', () => { + it('formats JSON-RPC errors correctly for GET requests', async () => { + const _stub = stub(http, 'senderFn' as keyof Http, () => + Promise.reject({ + error: { + message: 'HTTP GET request failed', + code: 404, + }, + }), + ); + + await assertRejects(() => http.get('https://api.example.com/data'), Error, 'HTTP GET request failed'); + + _stub.restore(); + }); + + it('formats JSON-RPC errors correctly for POST requests', async () => { + const _stub = stub(http, 'senderFn' as keyof Http, () => + Promise.reject({ + error: { + message: 'HTTP POST request validation failed', + code: 400, + }, + }), + ); + + await assertRejects( + () => http.post('https://api.example.com/create', { data: { name: 'test' } }), + Error, + 'HTTP POST request validation failed', + ); + + _stub.restore(); + }); + + it('formats JSON-RPC errors correctly for PUT requests', async () => { + const _stub = stub(http, 'senderFn' as keyof Http, () => + Promise.reject({ + error: { + message: 'HTTP PUT request unauthorized', + code: 401, + }, + }), + ); + + await assertRejects( + () => http.put('https://api.example.com/update/123', { data: { name: 'updated' } }), + Error, + 'HTTP PUT request unauthorized', + ); + + _stub.restore(); + }); + + it('formats JSON-RPC errors correctly for DELETE requests', async () => { + const _stub = stub(http, 'senderFn' as keyof Http, () => + Promise.reject({ + error: { + message: 'HTTP DELETE request forbidden', + code: 403, + }, + }), + ); + + await assertRejects(() => http.del('https://api.example.com/delete/123'), Error, 'HTTP DELETE request forbidden'); + + _stub.restore(); + }); + + it('formats JSON-RPC errors correctly for PATCH requests', async () => { + const _stub = stub(http, 'senderFn' as keyof Http, () => + Promise.reject({ + error: { + message: 'HTTP PATCH request conflict', + code: 409, + }, + }), + ); + + await assertRejects( + () => http.patch('https://api.example.com/patch/123', { data: { status: 'active' } }), + Error, + 'HTTP PATCH request conflict', + ); + + _stub.restore(); + }); + }); + + describe('Error instance passthrough', () => { + it('passes through existing Error instances unchanged for HTTP requests', async () => { + const originalError = new Error('Network timeout error'); + const _stub = stub(http, 'senderFn' as keyof Http, () => Promise.reject(originalError)); + + await assertRejects(() => http.get('https://api.example.com/data'), Error, 'Network timeout error'); + + _stub.restore(); + }); + }); + + describe('Unknown error handling', () => { + it('wraps unknown object errors with default message for HTTP requests', async () => { + const unknownError = { + status: 'failed', + details: 'Something went wrong', + timestamp: Date.now(), + }; + const _stub = stub(http, 'senderFn' as keyof Http, () => Promise.reject(unknownError)); + + await assertRejects(() => http.get('https://api.example.com/data'), Error, 'An unknown error occurred'); + + _stub.restore(); + }); + + it('wraps string errors with default message for HTTP requests', async () => { + const stringError = 'Connection refused'; + const _stub = stub(http, 'senderFn' as keyof Http, () => Promise.reject(stringError)); + + await assertRejects(() => http.get('https://api.example.com/data'), Error, 'An unknown error occurred'); + + _stub.restore(); + }); + + it('wraps null/undefined errors with default message for HTTP requests', async () => { + const _stub = stub(http, 'senderFn' as keyof Http, () => Promise.reject(null)); + + await assertRejects(() => http.get('https://api.example.com/data'), Error, 'An unknown error occurred'); + + _stub.restore(); + }); + }); +}); diff --git a/packages/apps/deno-runtime/lib/ast/mod.ts b/packages/apps/deno-runtime/lib/ast/mod.ts new file mode 100644 index 0000000000000..555b4defc36a0 --- /dev/null +++ b/packages/apps/deno-runtime/lib/ast/mod.ts @@ -0,0 +1,70 @@ +import { generate } from 'astring'; +// @deno-types="../../acorn.d.ts" +import { parse, Program } from 'acorn'; +// @deno-types="../../acorn-walk.d.ts" +import { fullAncestor } from 'acorn-walk'; + +import * as operations from './operations.ts'; +import type { WalkerState } from './operations.ts'; + +function fixAst(ast: Program): boolean { + const pendingOperations = [ + operations.fixLivechatIsOnlineCalls, + operations.checkReassignmentOfModifiedIdentifiers, + operations.fixRoomUsernamesCalls, + ]; + + // Have we touched the tree? + let isModified = false; + + while (pendingOperations.length) { + const ops = pendingOperations.splice(0); + const state: WalkerState = { + isModified: false, + functionIdentifiers: new Set(), + }; + + fullAncestor( + ast, + (node, state, ancestors, type) => { + ops.forEach((operation) => operation(node, state, ancestors, type)); + }, + undefined, + state, + ); + + if (state.isModified) { + isModified = true; + } + + if (state.functionIdentifiers.size) { + pendingOperations.push( + operations.buildFixModifiedFunctionsOperation(state.functionIdentifiers), + operations.checkReassignmentOfModifiedIdentifiers, + ); + } + } + + return isModified; +} + +export function fixBrokenSynchronousAPICalls(appSource: string): string { + const astRootNode = parse(appSource, { + // Latest ecma version supported by this version of acorn. + ecmaVersion: "latest", + // Allow everything, we don't want to complain if code is badly written + // Also, since the code itself has been transpiled, the chance of getting + // shenanigans is lower + allowReserved: true, + allowReturnOutsideFunction: true, + allowImportExportEverywhere: true, + allowAwaitOutsideFunction: true, + allowSuperOutsideMethod: true, + }); + + if (fixAst(astRootNode)) { + return generate(astRootNode); + } + + return appSource; +} diff --git a/packages/apps/deno-runtime/lib/ast/operations.ts b/packages/apps/deno-runtime/lib/ast/operations.ts new file mode 100644 index 0000000000000..7a5a4993ad297 --- /dev/null +++ b/packages/apps/deno-runtime/lib/ast/operations.ts @@ -0,0 +1,237 @@ +// @deno-types="../../acorn.d.ts" +import { AnyNode, AssignmentExpression, AwaitExpression, Expression, Function, Identifier, MethodDefinition, Property } from 'acorn'; +// @deno-types="../../acorn-walk.d.ts" +import { FullAncestorWalkerCallback } from 'acorn-walk'; + +export type WalkerState = { + isModified: boolean; + functionIdentifiers: Set; +}; + +export function getFunctionIdentifier(ancestors: AnyNode[], functionNodeIndex: number) { + const parent = ancestors[functionNodeIndex - 1]; + + // If there is a parent node and it's not a computed property, we can try to + // extract an identifier for our function from it. This needs to be done first + // because when functions are assigned to named symbols, this will be the only + // way to call it, even if the function itself has an identifier + // Consider the following block: + // + // const foo = function bar() {} + // + // Even though the function itself has a name, the only way to call it in the + // program is wiht `foo()` + if (parent && !(parent as Property | MethodDefinition).computed) { + // Several node types can have an id prop of type Identifier + const { id } = parent as unknown as { id?: Identifier }; + if (id?.type === 'Identifier') { + return id.name; + } + + // Usually assignments to object properties (MethodDefinition, Property) + const { key } = parent as MethodDefinition | Property; + if (key?.type === 'Identifier') { + return key.name; + } + + // Variable assignments have left hand side that can be used as Identifier + const { left } = parent as AssignmentExpression; + + // Simple assignment: `const fn = () => {}` + if (left?.type === 'Identifier') { + return left.name; + } + + // Object property assignment: `obj.fn = () => {}` + if (left?.type === 'MemberExpression' && !left.computed) { + return (left.property as Identifier).name; + } + } + + // nodeIndex needs to be the index of a Function node (either FunctionDeclaration or FunctionExpression) + const currentNode = ancestors[functionNodeIndex] as Function; + + // Function declarations or expressions can be directly named + if (currentNode.id?.type === 'Identifier') { + return currentNode.id.name; + } +} + +export function wrapWithAwait(node: Expression) { + if (!node.type.endsWith('Expression')) { + throw new Error(`Can't wrap "${node.type}" with await`); + } + + const innerNode: Expression = { ...node }; + + node.type = 'AwaitExpression'; + // starting here node has become an AwaitExpression + (node as AwaitExpression).argument = innerNode; + + Object.keys(node).forEach((key) => !['type', 'argument'].includes(key) && delete node[key as keyof AnyNode]); +} + +export function asyncifyScope(ancestors: AnyNode[], state: WalkerState) { + const functionNodeIndex = ancestors.findLastIndex((n) => 'async' in n); + if (functionNodeIndex === -1) return; + + // At this point this is a node with an "async" property, so it has to be + // of type Function - let TS know about that + const functionScopeNode = ancestors[functionNodeIndex] as Function; + + if (functionScopeNode.async) { + return; + } + + functionScopeNode.async = true; + + // If the parent of a function node is a call expression, we're talking about an IIFE + // Should we care about this case as well? + // const parentNode = ancestors[functionScopeIndex-1]; + // if (parentNode?.type === 'CallExpression' && ancestors[functionScopeIndex-2] && ancestors[functionScopeIndex-2].type !== 'AwaitExpression') { + // pendingOperations.push(buildFunctionPredicate(getFunctionIdentifier(ancestors, functionScopeIndex-2))); + // } + + const identifier = getFunctionIdentifier(ancestors, functionNodeIndex); + + // We can't fix calls of functions which name we can't determine at compile time + if (!identifier) return; + + state.functionIdentifiers.add(identifier); +} + +export function buildFixModifiedFunctionsOperation(functionIdentifiers: Set): FullAncestorWalkerCallback { + return function _fixModifiedFunctionsOperation(node, state, ancestors) { + if (node.type !== 'CallExpression') return; + + let isWrappable = false; + + // This node is a simple call to a function, like `fn()` + isWrappable = node.callee.type === 'Identifier' && functionIdentifiers.has(node.callee.name); + + // This node is a call to an object property or instance method, like `obj.fn()`, but not computed like `obj[fn]()` + isWrappable ||= node.callee.type === 'MemberExpression' && + !node.callee.computed && + node.callee.property?.type === 'Identifier' && + functionIdentifiers.has(node.callee.property.name); + + // This is a weird dereferencing technique used by bundlers, and since we'll be dealing with bundled sources we have to check for it + // e.g. `r=(0,fn)(e)` + if (!isWrappable && node.callee.type === 'SequenceExpression') { + const [, secondExpression] = node.callee.expressions; + isWrappable = secondExpression?.type === 'Identifier' && functionIdentifiers.has(secondExpression.name); + isWrappable ||= secondExpression?.type === 'MemberExpression' && + !secondExpression.computed && + secondExpression.property.type === 'Identifier' && + functionIdentifiers.has(secondExpression.property.name); + } + + if (!isWrappable) return; + + // ancestors[ancestors.length-1] === node, so here we're checking for parent node + const parentNode = ancestors[ancestors.length - 2]; + if (!parentNode || parentNode.type === 'AwaitExpression') return; + + wrapWithAwait(node); + asyncifyScope(ancestors, state); + + state.isModified = true; + }; +} + +export const checkReassignmentOfModifiedIdentifiers: FullAncestorWalkerCallback = (node, { functionIdentifiers }, _ancestors) => { + if (node.type === 'AssignmentExpression') { + if (node.operator !== '=') return; + + let identifier = ''; + + if (node.left.type === 'Identifier') identifier = node.left.name; + + if (node.left.type === 'MemberExpression' && !node.left.computed) { + identifier = (node.left.property as Identifier).name; + } + + if (!identifier || node.right.type !== 'Identifier' || !functionIdentifiers.has(node.right.name)) return; + + functionIdentifiers.add(identifier); + + return; + } + + if (node.type === 'VariableDeclarator') { + if (node.id.type !== 'Identifier' || functionIdentifiers.has(node.id.name)) return; + + if (node.init?.type !== 'Identifier' || !functionIdentifiers.has(node.init?.name)) return; + + functionIdentifiers.add(node.id.name); + + return; + } + + // "Property" is for plain objects, "PropertyDefinition" is for classes + // but both share the same structure + if (node.type === 'Property' || node.type === 'PropertyDefinition') { + if (node.key.type !== 'Identifier' || functionIdentifiers.has(node.key.name)) return; + + if (node.value?.type !== 'Identifier' || !functionIdentifiers.has(node.value.name)) return; + + functionIdentifiers.add(node.key.name); + + return; + } +}; + +export const fixLivechatIsOnlineCalls: FullAncestorWalkerCallback = (node, state, ancestors) => { + if (node.type !== 'MemberExpression' || node.computed) return; + + if ((node.property as Identifier).name !== 'isOnline') return; + + if (node.object.type !== 'CallExpression') return; + + if (node.object.callee.type !== 'MemberExpression') return; + + if ((node.object.callee.property as Identifier).name !== 'getLivechatReader') return; + + let parentIndex = ancestors.length - 2; + let targetNode = ancestors[parentIndex]; + + if (targetNode.type !== 'CallExpression') { + targetNode = node; + } else { + parentIndex--; + } + + // If we're already wrapped with an await, nothing to do + if (ancestors[parentIndex].type === 'AwaitExpression') return; + + // If we're in the middle of a chained member access, we can't wrap with await + if (ancestors[parentIndex].type === 'MemberExpression') return; + + wrapWithAwait(targetNode); + asyncifyScope(ancestors, state); + + state.isModified = true; +}; + +export const fixRoomUsernamesCalls: FullAncestorWalkerCallback = (node, state, ancestors) => { + if (node.type !== 'MemberExpression' || node.computed) return; + + if ((node.property as Identifier).name !== 'usernames') return; + + let parentIndex = ancestors.length - 2; + let targetNode = ancestors[parentIndex]; + + if (targetNode.type !== 'CallExpression') { + targetNode = node; + } else { + parentIndex--; + } + + // If we're already wrapped with an await, nothing to do + if (ancestors[parentIndex].type === 'AwaitExpression') return; + + wrapWithAwait(targetNode); + asyncifyScope(ancestors, state); + + state.isModified = true; +}; diff --git a/packages/apps/deno-runtime/lib/ast/tests/data/ast_blocks.ts b/packages/apps/deno-runtime/lib/ast/tests/data/ast_blocks.ts new file mode 100644 index 0000000000000..8e750e6eaf587 --- /dev/null +++ b/packages/apps/deno-runtime/lib/ast/tests/data/ast_blocks.ts @@ -0,0 +1,436 @@ +// @deno-types="../../../../acorn.d.ts" +import { AnyNode, ClassDeclaration, ExpressionStatement, FunctionDeclaration, VariableDeclaration } from 'acorn'; + +/** + * Partial AST blocks to support testing. + * `start` and `end` properties are omitted for brevity. + */ + +type TestNodeExcerpt = { + code: string; + node: N; +}; + +export const FunctionDeclarationFoo: TestNodeExcerpt = { + code: 'function foo() {}', + node: { + type: 'FunctionDeclaration', + id: { + type: 'Identifier', + name: 'foo', + }, + expression: false, + generator: false, + async: false, + params: [], + body: { + type: 'BlockStatement', + body: [], + }, + }, +}; + +export const ConstFooAssignedFunctionExpression: TestNodeExcerpt = { + code: 'const foo = function() {}', + node: { + type: 'VariableDeclaration', + kind: 'const', + declarations: [ + { + type: 'VariableDeclarator', + id: { + type: 'Identifier', + name: 'foo', + }, + init: { + type: 'FunctionExpression', + id: null, + expression: false, + generator: false, + async: false, + params: [], + body: { + type: 'BlockStatement', + body: [], + }, + }, + }, + ], + }, +}; + +export const AssignmentExpressionOfArrowFunctionToFooIdentifier: TestNodeExcerpt = { + code: 'foo = () => {}', + node: { + type: 'ExpressionStatement', + expression: { + type: 'AssignmentExpression', + operator: '=', + left: { + type: 'Identifier', + name: 'foo', + }, + right: { + type: 'ArrowFunctionExpression', + id: null, + expression: false, + generator: false, + async: false, + params: [], + body: { + type: 'BlockStatement', + body: [], + }, + }, + }, + }, +}; + +export const AssignmentExpressionOfNamedFunctionToFooMemberExpression: TestNodeExcerpt = { + code: 'obj.foo = function bar() {}', + node: { + type: 'ExpressionStatement', + expression: { + type: 'AssignmentExpression', + operator: '=', + left: { + type: 'MemberExpression', + object: { + type: 'Identifier', + name: 'a', + }, + property: { + type: 'Identifier', + name: 'foo', + }, + computed: false, + optional: false, + }, + right: { + type: 'FunctionExpression', + id: null, + expression: false, + generator: false, + async: false, + params: [], + body: { + type: 'BlockStatement', + body: [], + }, + }, + }, + }, +}; + +export const MethodDefinitionOfFooInClassBar: TestNodeExcerpt = { + code: 'class Bar { foo() {} }', + node: { + type: 'ClassDeclaration', + id: { + type: 'Identifier', + name: 'Bar', + }, + superClass: null, + body: { + type: 'ClassBody', + body: [ + { + type: 'MethodDefinition', + key: { + type: 'Identifier', + name: 'foo', + }, + value: { + type: 'FunctionExpression', + id: null, + expression: false, + generator: false, + async: false, + params: [], + body: { + type: 'BlockStatement', + body: [], + }, + }, + kind: 'method', + computed: false, + static: false, + }, + ], + }, + }, +}; + +export const SimpleCallExpressionOfFoo: TestNodeExcerpt = { + code: 'foo()', + node: { + type: 'ExpressionStatement', + expression: { + type: 'CallExpression', + callee: { + type: 'Identifier', + name: 'foo', + }, + arguments: [], + optional: false, + }, + }, +}; + +export const SyncFunctionDeclarationWithAsyncCallExpression: TestNodeExcerpt = { + // NOTE: this is invalid syntax, it won't be parsed by acorn + // but it can be an intermediary state of the AST after we run + // `wrapWithAwait` on "bar" call expressions, for instance + code: 'function foo() { return () => await bar() }', + node: { + type: 'FunctionDeclaration', + id: { + type: 'Identifier', + name: 'foo', + }, + expression: false, + generator: false, + async: false, + params: [], + body: { + type: 'BlockStatement', + body: [ + { + type: 'ReturnStatement', + argument: { + type: 'ArrowFunctionExpression', + id: null, + expression: true, + generator: false, + async: false, + params: [], + body: { + type: 'AwaitExpression', + argument: { + type: 'CallExpression', + callee: { + type: 'Identifier', + name: 'bar', + }, + arguments: [], + optional: false, + }, + }, + }, + }, + ], + }, + }, +}; + +export const AssignmentOfFooToBar: TestNodeExcerpt = { + code: 'bar = foo', + node: { + type: 'ExpressionStatement', + expression: { + type: 'AssignmentExpression', + operator: '=', + left: { + type: 'Identifier', + name: 'bar', + }, + right: { + type: 'Identifier', + name: 'foo', + }, + }, + }, +}; + +export const AssignmentOfFooToBarMemberExpression: TestNodeExcerpt = { + code: 'obj.bar = foo', + node: { + type: 'ExpressionStatement', + expression: { + type: 'AssignmentExpression', + operator: '=', + left: { + type: 'MemberExpression', + computed: false, + optional: false, + object: { + type: 'Identifier', + name: 'obj', + }, + property: { + type: 'Identifier', + name: 'bar', + }, + }, + right: { + type: 'Identifier', + name: 'foo', + }, + }, + }, +}; + +export const AssignmentOfFooToBarVariableDeclarator: TestNodeExcerpt = { + code: 'const bar = foo', + node: { + type: 'VariableDeclaration', + kind: 'const', + declarations: [ + { + type: 'VariableDeclarator', + id: { + type: 'Identifier', + name: 'bar', + }, + init: { + type: 'Identifier', + name: 'foo', + }, + }, + ], + }, +}; + +export const AssignmentOfFooToBarPropertyDefinition: TestNodeExcerpt = { + code: 'class baz { bar = foo }', + node: { + type: 'ClassDeclaration', + id: { + type: 'Identifier', + name: 'baz', + }, + superClass: null, + body: { + type: 'ClassBody', + body: [ + { + type: 'PropertyDefinition', + static: false, + computed: false, + key: { + type: 'Identifier', + name: 'bar', + }, + value: { + type: 'Identifier', + name: 'foo', + }, + }, + ], + }, + }, +}; + +const fixSimpleCallExpressionCode = ` +function bar() { + const a = foo(); + + return a; +}`; + +export const FixSimpleCallExpression: TestNodeExcerpt = { + code: fixSimpleCallExpressionCode, + node: { + type: 'FunctionDeclaration', + id: { + type: 'Identifier', + name: 'bar', + }, + expression: false, + generator: false, + async: false, + params: [], + body: { + type: 'BlockStatement', + body: [ + { + type: 'VariableDeclaration', + kind: 'const', + declarations: [ + { + type: 'VariableDeclarator', + id: { + type: 'Identifier', + name: 'a', + }, + init: { + type: 'CallExpression', + callee: { + type: 'Identifier', + name: 'foo', + }, + arguments: [], + optional: false, + }, + }, + ], + }, + { + type: 'ReturnStatement', + argument: { + type: 'Identifier', + name: 'a', + }, + }, + ], + }, + }, +}; + +export const ArrowFunctionDerefCallExpression: TestNodeExcerpt = { + // NOTE: this call strategy is widely used by bundlers; it's used to sever the `this` + // reference in the method from the object that contains it. This is mostly because + // the bundler wants to ensure that it does not messes up the bindings in the code it + // generates. + // + // This would be similar to doing `foo.call(undefined)` + code: 'const bar = () => (0, e.foo)();', + node: { + type: 'VariableDeclaration', + kind: 'const', + declarations: [ + { + type: 'VariableDeclarator', + id: { + type: 'Identifier', + name: 'bar', + }, + init: { + type: 'ArrowFunctionExpression', + id: null, + expression: true, + generator: false, + async: false, + params: [], + body: { + type: 'CallExpression', + optional: false, + arguments: [], + callee: { + type: 'SequenceExpression', + expressions: [ + { + type: 'Literal', + value: 0, + }, + { + type: 'MemberExpression', + object: { + type: 'Identifier', + name: 'e', + }, + property: { + type: 'Identifier', + name: 'foo', + }, + computed: false, + optional: false, + }, + ], + }, + }, + }, + }, + ], + }, +}; diff --git a/packages/apps/deno-runtime/lib/ast/tests/operations.test.ts b/packages/apps/deno-runtime/lib/ast/tests/operations.test.ts new file mode 100644 index 0000000000000..809de475013c9 --- /dev/null +++ b/packages/apps/deno-runtime/lib/ast/tests/operations.test.ts @@ -0,0 +1,261 @@ +import { assertEquals, assertThrows } from 'https://deno.land/std@0.203.0/assert/mod.ts'; +import { beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; + +import { + asyncifyScope, + buildFixModifiedFunctionsOperation, + checkReassignmentOfModifiedIdentifiers, + getFunctionIdentifier, + WalkerState, + wrapWithAwait, +} from '../operations.ts'; +import { + ArrowFunctionDerefCallExpression, + AssignmentExpressionOfArrowFunctionToFooIdentifier, + AssignmentExpressionOfNamedFunctionToFooMemberExpression, + AssignmentOfFooToBar, + AssignmentOfFooToBarMemberExpression, + AssignmentOfFooToBarPropertyDefinition, + AssignmentOfFooToBarVariableDeclarator, + ConstFooAssignedFunctionExpression, + FixSimpleCallExpression, + FunctionDeclarationFoo, + MethodDefinitionOfFooInClassBar, + SimpleCallExpressionOfFoo, + SyncFunctionDeclarationWithAsyncCallExpression, +} from './data/ast_blocks.ts'; +import { + AnyNode, + ArrowFunctionExpression, + AssignmentExpression, + AwaitExpression, + Expression, + MethodDefinition, + ReturnStatement, + VariableDeclaration, +} from '../../../acorn.d.ts'; +import { assertNotEquals } from 'https://deno.land/std@0.203.0/assert/assert_not_equals.ts'; + +describe('getFunctionIdentifier', () => { + it(`identifies the name "foo" for the code \`${FunctionDeclarationFoo.code}\``, () => { + // ancestors array is built by the walking lib + const nodeAncestors = [FunctionDeclarationFoo.node]; + const functionNodeIndex = 0; + assertEquals('foo', getFunctionIdentifier(nodeAncestors, functionNodeIndex)); + }); + + it(`identifies the name "foo" for the code \`${ConstFooAssignedFunctionExpression.code}\``, () => { + // ancestors array is built by the walking lib + const nodeAncestors = [ + ConstFooAssignedFunctionExpression.node, // VariableDeclaration + ConstFooAssignedFunctionExpression.node.declarations[0], // VariableDeclarator + ConstFooAssignedFunctionExpression.node.declarations[0].init!, // FunctionExpression + ]; + const functionNodeIndex = 2; + assertEquals('foo', getFunctionIdentifier(nodeAncestors, functionNodeIndex)); + }); + + it(`identifies the name "foo" for the code \`${AssignmentExpressionOfArrowFunctionToFooIdentifier.code}\``, () => { + // ancestors array is built by the walking lib + const nodeAncestors = [ + AssignmentExpressionOfArrowFunctionToFooIdentifier.node, // ExpressionStatement + AssignmentExpressionOfArrowFunctionToFooIdentifier.node.expression, // AssignmentExpression + (AssignmentExpressionOfArrowFunctionToFooIdentifier.node.expression as AssignmentExpression).right, // ArrowFunctionExpression + ]; + const functionNodeIndex = 2; + assertEquals('foo', getFunctionIdentifier(nodeAncestors, functionNodeIndex)); + }); + + it(`identifies the name "foo" for the code \`${AssignmentExpressionOfNamedFunctionToFooMemberExpression.code}\``, () => { + // ancestors array is built by the walking lib + const nodeAncestors = [ + AssignmentExpressionOfNamedFunctionToFooMemberExpression.node, // ExpressionStatement + AssignmentExpressionOfNamedFunctionToFooMemberExpression.node.expression, // AssignmentExpression + (AssignmentExpressionOfNamedFunctionToFooMemberExpression.node.expression as AssignmentExpression).right, // FunctionExpression + ]; + const functionNodeIndex = 2; + assertEquals('foo', getFunctionIdentifier(nodeAncestors, functionNodeIndex)); + }); + + it(`identifies the name "foo" for the code \`${MethodDefinitionOfFooInClassBar.code}\``, () => { + // ancestors array is built by the walking lib + const nodeAncestors = [ + MethodDefinitionOfFooInClassBar.node, // ClassDeclaration + MethodDefinitionOfFooInClassBar.node.body, // ClassBody + MethodDefinitionOfFooInClassBar.node.body!.body[0], // MethodDefinition + (MethodDefinitionOfFooInClassBar.node.body!.body[0] as MethodDefinition).value, // FunctionExpression + ]; + const functionNodeIndex = 3; + assertEquals('foo', getFunctionIdentifier(nodeAncestors, functionNodeIndex)); + }); +}); + +describe('wrapWithAwait', () => { + it('wraps a call expression with await', () => { + const node = structuredClone(SimpleCallExpressionOfFoo.node.expression); + wrapWithAwait(node); + + assertEquals('AwaitExpression', node.type); + assertNotEquals(SimpleCallExpressionOfFoo.node.expression.type, node.type); + assertEquals(SimpleCallExpressionOfFoo.node.expression, (node as AwaitExpression).argument); + }); + + it('throws if node is not an expression', () => { + const node = structuredClone(SimpleCallExpressionOfFoo.node); + assertThrows(() => wrapWithAwait(node as unknown as Expression)); + }); +}); + +describe('asyncifyScope', () => { + it('makes only the first function scope async', () => { + const node = structuredClone(SyncFunctionDeclarationWithAsyncCallExpression.node); + const ancestors: AnyNode[] = [ + node, // FunctionDeclaration + node.body, // BlockStatement + node.body!.body[0], // ReturnStatement + (node.body!.body[0] as ReturnStatement).argument!, // ArrowFunctionExpression + ((node.body!.body[0] as ReturnStatement).argument! as ArrowFunctionExpression).body, // AwaitExpression + (((node.body!.body[0] as ReturnStatement).argument! as ArrowFunctionExpression).body as AwaitExpression).argument, // CallExpression + ]; + const state: WalkerState = { + isModified: false, + functionIdentifiers: new Set(), + }; + + asyncifyScope(ancestors, state); + + // Assert the function did indeed change the expression to async + assertEquals(((node.body.body[0] as ReturnStatement).argument as ArrowFunctionExpression).async, true); + + // Assert the function did NOT change all ancestors in the chain + assertEquals(node.async, false); + + // Assert it couldn't find a function identifier + assertEquals(state.functionIdentifiers.size, 0); + }); +}); + +describe('checkReassignmentofModifiedIdentifiers', () => { + it(`identifies the reassignment of "foo" in the code "${AssignmentOfFooToBar.code}"`, () => { + const node = structuredClone(AssignmentOfFooToBar.node); + const ancestors: AnyNode[] = [ + node, // ExpressionStatement + node.expression, // AssignmentExpression + (node.expression as AssignmentExpression).right, // Identifier + ]; + const state: WalkerState = { + isModified: true, + functionIdentifiers: new Set(['foo']), + }; + + checkReassignmentOfModifiedIdentifiers(node.expression, state, ancestors, ''); + + assertEquals(state.functionIdentifiers.has('bar'), true); + }); + + it(`identifies the reassignment of "foo" in the code "${AssignmentOfFooToBarMemberExpression.code}"`, () => { + const node = structuredClone(AssignmentOfFooToBarMemberExpression.node); + const ancestors: AnyNode[] = [ + node, // ExpressionStatement + node.expression, // AssignmentExpression + (node.expression as AssignmentExpression).right, // Identifier + ]; + const state: WalkerState = { + isModified: true, + functionIdentifiers: new Set(['foo']), + }; + + checkReassignmentOfModifiedIdentifiers(node.expression, state, ancestors, ''); + + assertEquals(state.functionIdentifiers.has('bar'), true); + }); + + it(`identifies the reassignment of "foo" in the code "${AssignmentOfFooToBarVariableDeclarator.code}"`, () => { + const node = structuredClone(AssignmentOfFooToBarVariableDeclarator.node); + const ancestors: AnyNode[] = [ + node, // VariableDeclaration + node.declarations[0], // VariableDeclarator + ]; + const state: WalkerState = { + isModified: true, + functionIdentifiers: new Set(['foo']), + }; + + checkReassignmentOfModifiedIdentifiers(node.declarations[0], state, ancestors, ''); + + assertEquals(state.functionIdentifiers.has('bar'), true); + }); + + it(`identifies the reassignment of "foo" in the code "${AssignmentOfFooToBarPropertyDefinition.code}"`, () => { + const node = structuredClone(AssignmentOfFooToBarPropertyDefinition.node); + const ancestors: AnyNode[] = [ + node, // ClassDeclaration + node.body, // ClassBody + node.body.body[0], // PropertyDefinition + ]; + const state: WalkerState = { + isModified: true, + functionIdentifiers: new Set(['foo']), + }; + + checkReassignmentOfModifiedIdentifiers(node.body.body[0], state, ancestors, ''); + + assertEquals(state.functionIdentifiers.has('bar'), true); + }); +}); + +describe('buildFixModifiedFunctionsOperation', function () { + const state: WalkerState = { + isModified: false, + functionIdentifiers: new Set(['foo']), + }; + + const fixFunction = buildFixModifiedFunctionsOperation(state.functionIdentifiers); + + beforeEach(() => { + state.isModified = false; + state.functionIdentifiers = new Set(['foo']); + }); + + it(`fixes calls of "foo" in the code "${FixSimpleCallExpression.code}"`, () => { + const node = structuredClone(FixSimpleCallExpression.node); + const ancestors: AnyNode[] = [ + node, // FunctionDeclaration + node.body, // BlockStatement + node.body.body[0], // VariableDeclaration + (node.body.body[0] as VariableDeclaration).declarations[0], // VariableDeclarator + (node.body.body[0] as VariableDeclaration).declarations[0].init!, // CallExpression + ]; + + fixFunction(ancestors[4], state, ancestors, ''); + + assertEquals(state.isModified, true); + assertEquals(state.functionIdentifiers.has('bar'), true); + assertNotEquals(FixSimpleCallExpression.node, node); + assertEquals(node.async, true); + assertEquals(ancestors[4].type, 'AwaitExpression'); + }); + + it(`fixes calls of "foo" in the code "${ArrowFunctionDerefCallExpression.code}"`, () => { + const node = structuredClone(ArrowFunctionDerefCallExpression.node); + const ancestors: AnyNode[] = [ + node, // VariableDeclaration + node.declarations[0], // VariableDeclarator + node.declarations[0].init!, // ArrowFunctionExpression + (node.declarations[0].init as ArrowFunctionExpression).body, // CallExpression + ]; + + fixFunction(ancestors[3], state, ancestors, ''); + + // Recorded that a modification has been made + assertEquals(state.isModified, true); + // Recorded that the enclosing scope of the call also requires fixing + assertEquals(state.functionIdentifiers.has('bar'), true); + // Original node and fixed node are different + assertNotEquals(ArrowFunctionDerefCallExpression.node, node); + // The function call is now await'ed + assertEquals(ancestors[3].type, 'AwaitExpression'); + // The parent function of the call is now marked as async + assertEquals((ancestors[2] as ArrowFunctionExpression).async, true); + }); +}); diff --git a/packages/apps/deno-runtime/lib/codec.ts b/packages/apps/deno-runtime/lib/codec.ts new file mode 100644 index 0000000000000..95bbfa1a2aa26 --- /dev/null +++ b/packages/apps/deno-runtime/lib/codec.ts @@ -0,0 +1,43 @@ +import { Buffer } from 'node:buffer'; +import { Decoder, Encoder, ExtensionCodec } from '@msgpack/msgpack'; + +import type { App as _App } from '@rocket.chat/apps-engine/definition/App.ts'; +import { require } from './require.ts'; + +const { App } = require('@rocket.chat/apps-engine/definition/App.js') as { + App: typeof _App; +}; + +const extensionCodec = new ExtensionCodec(); + +extensionCodec.register({ + type: 0, + encode: (object: unknown) => { + // We don't care about functions, but also don't want to throw an error + if (typeof object === 'function' || object instanceof App) { + return new Uint8Array(0); + } + + return null; + }, + decode: (_data: Uint8Array) => undefined, +}); + +// Since Deno doesn't have Buffer by default, we need to use Uint8Array +extensionCodec.register({ + type: 1, + encode: (object: unknown) => { + if (object instanceof Buffer) { + return new Uint8Array(object.buffer, object.byteOffset, object.byteLength); + } + + return null; + }, + // msgpack will reuse the Uint8Array instance, so WE NEED to copy it instead of simply creating a view + decode: (data: Uint8Array) => { + return Buffer.from(data); + }, +}); + +export const encoder = new Encoder({ extensionCodec }); +export const decoder = new Decoder({ extensionCodec }); diff --git a/packages/apps/deno-runtime/lib/logger.ts b/packages/apps/deno-runtime/lib/logger.ts new file mode 100644 index 0000000000000..336c420080d37 --- /dev/null +++ b/packages/apps/deno-runtime/lib/logger.ts @@ -0,0 +1,142 @@ +import stackTrace from 'stack-trace'; +import { AppObjectRegistry } from '../AppObjectRegistry.ts'; + +export interface StackFrame { + getTypeName(): string; + getFunctionName(): string; + getMethodName(): string; + getFileName(): string; + getLineNumber(): number; + getColumnNumber(): number; + isNative(): boolean; + isConstructor(): boolean; +} + +enum LogMessageSeverity { + DEBUG = 'debug', + INFORMATION = 'info', + LOG = 'log', + WARNING = 'warning', + ERROR = 'error', + SUCCESS = 'success', +} + +type Entry = { + caller: string; + severity: LogMessageSeverity; + method: string; + timestamp: Date; + args: Array; +}; + +interface ILoggerStorageEntry { + appId: string; + method: string; + entries: Array; + startTime: Date; + endTime: Date; + totalTime: number; + _createdAt: Date; +} + +export class Logger { + private entries: Array; + private start: Date; + private method: string; + + constructor(method: string) { + this.method = method; + this.entries = []; + this.start = new Date(); + } + + public debug(...args: Array): void { + this.addEntry(LogMessageSeverity.DEBUG, this.getStack(stackTrace.get()), ...args); + } + + public info(...args: Array): void { + this.addEntry(LogMessageSeverity.INFORMATION, this.getStack(stackTrace.get()), ...args); + } + + public log(...args: Array): void { + this.addEntry(LogMessageSeverity.LOG, this.getStack(stackTrace.get()), ...args); + } + + public warn(...args: Array): void { + this.addEntry(LogMessageSeverity.WARNING, this.getStack(stackTrace.get()), ...args); + } + + public error(...args: Array): void { + this.addEntry(LogMessageSeverity.ERROR, this.getStack(stackTrace.get()), ...args); + } + + public success(...args: Array): void { + this.addEntry(LogMessageSeverity.SUCCESS, this.getStack(stackTrace.get()), ...args); + } + + private addEntry(severity: LogMessageSeverity, caller: string, ...items: Array): void { + const i = items.map((args) => { + if (args instanceof Error) { + return JSON.stringify(args, Object.getOwnPropertyNames(args)); + } + if (typeof args === 'object' && args !== null && 'stack' in args) { + return JSON.stringify(args, Object.getOwnPropertyNames(args)); + } + if (typeof args === 'object' && args !== null && 'message' in args) { + return JSON.stringify(args, Object.getOwnPropertyNames(args)); + } + const str = JSON.stringify(args, null, 2); + return str ? JSON.parse(str) : str; // force call toJSON to prevent circular references + }); + + this.entries.push({ + caller, + severity, + method: this.method, + timestamp: new Date(), + args: i, + }); + } + + private getStack(stack: Array): string { + let func = 'anonymous'; + + if (stack.length === 1) { + return func; + } + + const frame = stack[1]; + + if (frame.getMethodName() === null) { + func = 'anonymous OR constructor'; + } else { + func = frame.getMethodName(); + } + + if (frame.getFunctionName() !== null) { + func = `${func} -> ${frame.getFunctionName()}`; + } + + return func; + } + + private getTotalTime(): number { + return new Date().getTime() - this.start.getTime(); + } + + public hasEntries(): boolean { + return this.entries.length > 0; + } + + public getLogs(): ILoggerStorageEntry { + return { + appId: AppObjectRegistry.get('id')!, + method: this.method, + entries: this.entries, + startTime: this.start, + endTime: new Date(), + totalTime: this.getTotalTime(), + _createdAt: new Date(), + }; + } +} diff --git a/packages/apps/deno-runtime/lib/messenger.ts b/packages/apps/deno-runtime/lib/messenger.ts new file mode 100644 index 0000000000000..3a55aba594f7c --- /dev/null +++ b/packages/apps/deno-runtime/lib/messenger.ts @@ -0,0 +1,202 @@ +import { writeAll } from '@std/io'; + +import * as jsonrpc from 'jsonrpc-lite'; + +import { encoder } from './codec.ts'; +import { RequestContext } from './requestContext.ts'; + +export type RequestDescriptor = Pick; + +export type NotificationDescriptor = Pick; + +export type SuccessResponseDescriptor = Pick; + +export type ErrorResponseDescriptor = Pick; + +export type JsonRpcRequest = jsonrpc.IParsedObjectRequest | jsonrpc.IParsedObjectNotification; +export type JsonRpcResponse = jsonrpc.IParsedObjectSuccess | jsonrpc.IParsedObjectError; + +export function isRequest(message: jsonrpc.IParsedObject): message is JsonRpcRequest { + return message.type === 'request' || message.type === 'notification'; +} + +export function isResponse(message: jsonrpc.IParsedObject): message is JsonRpcResponse { + return message.type === 'success' || message.type === 'error'; +} + +export function isErrorResponse(message: jsonrpc.JsonRpc): message is jsonrpc.ErrorObject { + return message instanceof jsonrpc.ErrorObject; +} + +const COMMAND_PONG = '_zPONG'; + +export const RPCResponseObserver = new EventTarget(); + +export const Queue = new (class Queue { + private queue: Uint8Array[] = []; + private isProcessing = false; + + private async processQueue() { + if (this.isProcessing) { + return; + } + + this.isProcessing = true; + + while (this.queue.length) { + const message = this.queue.shift(); + + if (message) { + await Transport.send(message); + } + } + + this.isProcessing = false; + } + + public enqueue(message: jsonrpc.JsonRpc | typeof COMMAND_PONG) { + this.queue.push(encoder.encode(message)); + this.processQueue(); + } + + public getCurrentSize() { + return this.queue.length; + } +})(); + +export const Transport = new (class Transporter { + private selectedTransport: Transporter['stdoutTransport'] | Transporter['noopTransport']; + + constructor() { + this.selectedTransport = this.stdoutTransport.bind(this); + } + + private async stdoutTransport(message: Uint8Array): Promise { + await writeAll(Deno.stdout, message); + } + + private async noopTransport(_message: Uint8Array): Promise {} + + public selectTransport(transport: 'stdout' | 'noop'): void { + switch (transport) { + case 'stdout': + this.selectedTransport = this.stdoutTransport.bind(this); + break; + case 'noop': + this.selectedTransport = this.noopTransport.bind(this); + break; + } + } + + public send(message: Uint8Array): Promise { + return this.selectedTransport(message); + } +})(); + +export function parseMessage(message: string | Record) { + let parsed: jsonrpc.IParsedObject | jsonrpc.IParsedObject[]; + + if (typeof message === 'string') { + parsed = jsonrpc.parse(message); + } else { + parsed = jsonrpc.parseObject(message); + } + + if (Array.isArray(parsed)) { + throw jsonrpc.error(null, jsonrpc.JsonRpcError.invalidRequest(null)); + } + + if (parsed.type === 'invalid') { + throw jsonrpc.error(null, parsed.payload); + } + + return parsed; +} + +export async function sendInvalidRequestError(): Promise { + const rpc = jsonrpc.error(null, jsonrpc.JsonRpcError.invalidRequest(null)); + + await Queue.enqueue(rpc); +} + +export async function sendInvalidParamsError(id: jsonrpc.ID): Promise { + const rpc = jsonrpc.error(id, jsonrpc.JsonRpcError.invalidParams(null)); + + await Queue.enqueue(rpc); +} + +export async function sendParseError(): Promise { + const rpc = jsonrpc.error(null, jsonrpc.JsonRpcError.parseError(null)); + + await Queue.enqueue(rpc); +} + +export async function sendMethodNotFound(id: jsonrpc.ID): Promise { + const rpc = jsonrpc.error(id, jsonrpc.JsonRpcError.methodNotFound(null)); + + await Queue.enqueue(rpc); +} + +export async function errorResponse({ error: { message, code = -32000, data = {} }, id }: ErrorResponseDescriptor, req?: RequestContext): Promise { + const { logger } = req?.context || {}; + + if (logger?.hasEntries()) { + data.logs = logger.getLogs(); + } + + const rpc = jsonrpc.error(id, new jsonrpc.JsonRpcError(message, code, data)); + + await Queue.enqueue(rpc); +} + +export async function successResponse({ id, result }: SuccessResponseDescriptor, req: RequestContext): Promise { + const payload = { value: result } as Record; + const { logger } = req.context; + + if (logger.hasEntries()) { + payload.logs = logger.getLogs(); + } + + const rpc = jsonrpc.success(id, payload); + + await Queue.enqueue(rpc); +} + +export function pongResponse(): Promise { + return Promise.resolve(Queue.enqueue(COMMAND_PONG)); +} + +export async function sendRequest(requestDescriptor: RequestDescriptor): Promise { + const request = jsonrpc.request(Math.random().toString(36).slice(2), requestDescriptor.method, requestDescriptor.params); + + // TODO: add timeout to this + const responsePromise = new Promise((resolve, reject) => { + const handler = (event: Event) => { + if (event instanceof ErrorEvent) { + reject(event.error); + } + + if (event instanceof CustomEvent) { + resolve(event.detail); + } + + RPCResponseObserver.removeEventListener(`response:${request.id}`, handler); + }; + + RPCResponseObserver.addEventListener(`response:${request.id}`, handler); + }); + + await Queue.enqueue(request); + + return responsePromise as Promise; +} + +export function sendNotification({ method, params }: NotificationDescriptor) { + const request = jsonrpc.notification(method, params); + + Queue.enqueue(request); +} + +export function log(params: jsonrpc.RpcParams) { + sendNotification({ method: 'log', params }); +} diff --git a/packages/apps/deno-runtime/lib/metricsCollector.ts b/packages/apps/deno-runtime/lib/metricsCollector.ts new file mode 100644 index 0000000000000..8484aef826f9b --- /dev/null +++ b/packages/apps/deno-runtime/lib/metricsCollector.ts @@ -0,0 +1,20 @@ +import { writeAll } from '@std/io'; +import { Queue } from './messenger.ts'; + +export function collectMetrics() { + return { + pid: Deno.pid, + queueSize: Queue.getCurrentSize(), + }; +} + +const encoder = new TextEncoder(); + +/** + * Sends metrics collected from the system via stderr + */ +export async function sendMetrics() { + const metrics = collectMetrics(); + + await writeAll(Deno.stderr, encoder.encode(JSON.stringify(metrics))); +} diff --git a/packages/apps/deno-runtime/lib/parseArgs.ts b/packages/apps/deno-runtime/lib/parseArgs.ts new file mode 100644 index 0000000000000..a9c4844154990 --- /dev/null +++ b/packages/apps/deno-runtime/lib/parseArgs.ts @@ -0,0 +1,11 @@ +import { parseArgs as $parseArgs } from '@std/cli/parse-args'; + +export type ParsedArgs = { + subprocess: string; + spawnId: number; + metricsReportFrequencyInMs?: number; +}; + +export function parseArgs(args: string[]): ParsedArgs { + return $parseArgs(args); +} diff --git a/packages/apps/deno-runtime/lib/requestContext.ts b/packages/apps/deno-runtime/lib/requestContext.ts new file mode 100644 index 0000000000000..91e9346f34bd4 --- /dev/null +++ b/packages/apps/deno-runtime/lib/requestContext.ts @@ -0,0 +1,10 @@ +import { RequestObject } from 'jsonrpc-lite'; + +import { Logger } from './logger.ts'; + +export type RequestContext = RequestObject & { + context: { + logger: Logger; + [key: string]: unknown; + } +}; diff --git a/packages/apps/deno-runtime/lib/require.ts b/packages/apps/deno-runtime/lib/require.ts new file mode 100644 index 0000000000000..7d842d829e598 --- /dev/null +++ b/packages/apps/deno-runtime/lib/require.ts @@ -0,0 +1,15 @@ +import { createRequire } from 'node:module'; + +const _require = createRequire(import.meta.url); + +export const require = (mod: string) => { + // When we try to import something from the apps-engine, we resolve the path using import maps from Deno + // However, the import maps are configured to look at the source folder for typescript files, but during + // runtime those files are not available + if (mod.startsWith('@rocket.chat/apps-engine')) { + // Only remove "src/" substring when it comes after "apps-engine/" + mod = import.meta.resolve(mod).replace('file://', '').replace('apps-engine/src/', 'apps-engine/'); + } + + return _require(mod); +}; diff --git a/packages/apps/deno-runtime/lib/room.ts b/packages/apps/deno-runtime/lib/room.ts new file mode 100644 index 0000000000000..282ded4a90457 --- /dev/null +++ b/packages/apps/deno-runtime/lib/room.ts @@ -0,0 +1,104 @@ +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom.ts'; +import type { RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType.ts'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser.ts'; +import type { AppManager } from '@rocket.chat/apps-engine/server/AppManager.ts'; + +const PrivateManager = Symbol('RoomPrivateManager'); + +export class Room { + public id: string | undefined; + + public displayName?: string; + + public slugifiedName: string | undefined; + + public type: RoomType | undefined; + + public creator: IUser | undefined; + + public isDefault?: boolean; + + public isReadOnly?: boolean; + + public displaySystemMessages?: boolean; + + public messageCount?: number; + + public createdAt?: Date; + + public updatedAt?: Date; + + public lastModifiedAt?: Date; + + public customFields?: { [key: string]: unknown }; + + public userIds?: Array; + + private _USERNAMES: Promise> | undefined; + + private [PrivateManager]: AppManager | undefined; + + /** + * @deprecated + */ + public get usernames(): Promise> { + if (!this._USERNAMES) { + this._USERNAMES = this[PrivateManager]?.getBridges().getInternalBridge().doGetUsernamesOfRoomById(this.id); + } + + return this._USERNAMES || Promise.resolve([]); + } + + public set usernames(usernames) {} + + public constructor(room: IRoom, manager: AppManager) { + Object.assign(this, room); + + Object.defineProperty(this, PrivateManager, { + configurable: false, + enumerable: false, + writable: false, + value: manager, + }); + } + + get value(): object { + return { + id: this.id, + displayName: this.displayName, + slugifiedName: this.slugifiedName, + type: this.type, + creator: this.creator, + isDefault: this.isDefault, + isReadOnly: this.isReadOnly, + displaySystemMessages: this.displaySystemMessages, + messageCount: this.messageCount, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + lastModifiedAt: this.lastModifiedAt, + customFields: this.customFields, + userIds: this.userIds, + }; + } + + public async getUsernames(): Promise> { + // Get usernames + if (!this._USERNAMES) { + this._USERNAMES = await this[PrivateManager]?.getBridges().getInternalBridge().doGetUsernamesOfRoomById(this.id); + } + + return this._USERNAMES || []; + } + + public toJSON() { + return this.value; + } + + public toString() { + return this.value; + } + + public valueOf() { + return this.value; + } +} diff --git a/packages/apps/deno-runtime/lib/roomFactory.ts b/packages/apps/deno-runtime/lib/roomFactory.ts new file mode 100644 index 0000000000000..e0c2b9f1c4c80 --- /dev/null +++ b/packages/apps/deno-runtime/lib/roomFactory.ts @@ -0,0 +1,29 @@ +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom.ts'; +import type { AppManager } from '@rocket.chat/apps-engine/server/AppManager.ts'; + +import { AppAccessors } from './accessors/mod.ts'; +import { Room } from './room.ts'; +import { formatErrorResponse } from './accessors/formatResponseErrorHandler.ts'; + +const getMockAppManager = (senderFn: AppAccessors['senderFn']) => ({ + getBridges: () => ({ + getInternalBridge: () => ({ + doGetUsernamesOfRoomById: (roomId: string) => { + return senderFn({ + method: 'bridges:getInternalBridge:doGetUsernamesOfRoomById', + params: [roomId], + }) + .then((result) => result.result) + .catch((err) => { + throw formatErrorResponse(err); + }); + }, + }), + }), +}); + +export default function createRoom(room: IRoom, senderFn: AppAccessors['senderFn']) { + const mockAppManager = getMockAppManager(senderFn); + + return new Room(room, mockAppManager as unknown as AppManager); +} diff --git a/packages/apps/deno-runtime/lib/sanitizeDeprecatedUsage.ts b/packages/apps/deno-runtime/lib/sanitizeDeprecatedUsage.ts new file mode 100644 index 0000000000000..4b5838bce12d1 --- /dev/null +++ b/packages/apps/deno-runtime/lib/sanitizeDeprecatedUsage.ts @@ -0,0 +1,20 @@ +import { fixBrokenSynchronousAPICalls } from './ast/mod.ts'; + +function hasPotentialDeprecatedUsage(source: string) { + return ( + // potential usage of Room.usernames getter + source.includes('.usernames') || + // potential usage of LivechatRead.isOnline method + source.includes('.isOnline(') || + // potential usage of LivechatCreator.createToken method + source.includes('.createToken(') + ); +} + +export function sanitizeDeprecatedUsage(source: string) { + if (!hasPotentialDeprecatedUsage(source)) { + return source; + } + + return fixBrokenSynchronousAPICalls(source); +} diff --git a/packages/apps/deno-runtime/lib/tests/logger.test.ts b/packages/apps/deno-runtime/lib/tests/logger.test.ts new file mode 100644 index 0000000000000..7ccc49b3b9ca4 --- /dev/null +++ b/packages/apps/deno-runtime/lib/tests/logger.test.ts @@ -0,0 +1,110 @@ +import { assertEquals } from 'https://deno.land/std@0.203.0/assert/mod.ts'; +import { describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; +import { Logger } from '../logger.ts'; + +describe('Logger', () => { + it('getLogs should return an array of entries', () => { + const logger = new Logger('test'); + logger.info('test'); + const logs = logger.getLogs(); + assertEquals(logs.entries.length, 1); + assertEquals(logs.method, 'test'); + }); + + it('should be able to add entries of different severity', () => { + const logger = new Logger('test'); + logger.info('test'); + logger.debug('test'); + logger.error('test'); + const logs = logger.getLogs(); + assertEquals(logs.entries.length, 3); + assertEquals(logs.entries[0].severity, 'info'); + assertEquals(logs.entries[1].severity, 'debug'); + assertEquals(logs.entries[2].severity, 'error'); + }); + + it('should be able to add an info entry', () => { + const logger = new Logger('test'); + logger.info('test'); + const logs = logger.getLogs(); + assertEquals(logs.entries.length, 1); + assertEquals(logs.entries[0].args[0], 'test'); + assertEquals(logs.entries[0].method, 'test'); + assertEquals(logs.entries[0].severity, 'info'); + }); + + it('should be able to add an debug entry', () => { + const logger = new Logger('test'); + logger.debug('test'); + const logs = logger.getLogs(); + assertEquals(logs.entries.length, 1); + assertEquals(logs.entries[0].args[0], 'test'); + assertEquals(logs.entries[0].method, 'test'); + assertEquals(logs.entries[0].severity, 'debug'); + }); + + it('should be able to add an error entry', () => { + const logger = new Logger('test'); + logger.error('test'); + const logs = logger.getLogs(); + assertEquals(logs.entries.length, 1); + assertEquals(logs.entries[0].args[0], 'test'); + assertEquals(logs.entries[0].method, 'test'); + assertEquals(logs.entries[0].severity, 'error'); + }); + + it('should be able to add an success entry', () => { + const logger = new Logger('test'); + logger.success('test'); + const logs = logger.getLogs(); + assertEquals(logs.entries.length, 1); + assertEquals(logs.entries[0].args[0], 'test'); + assertEquals(logs.entries[0].method, 'test'); + assertEquals(logs.entries[0].severity, 'success'); + }); + + it('should be able to add an warning entry', () => { + const logger = new Logger('test'); + logger.warn('test'); + const logs = logger.getLogs(); + assertEquals(logs.entries.length, 1); + assertEquals(logs.entries[0].args[0], 'test'); + assertEquals(logs.entries[0].method, 'test'); + assertEquals(logs.entries[0].severity, 'warning'); + }); + + it('should be able to add an log entry', () => { + const logger = new Logger('test'); + logger.log('test'); + const logs = logger.getLogs(); + assertEquals(logs.entries.length, 1); + assertEquals(logs.entries[0].args[0], 'test'); + assertEquals(logs.entries[0].method, 'test'); + assertEquals(logs.entries[0].severity, 'log'); + }); + + it('should be able to add an entry with multiple arguments', () => { + const logger = new Logger('test'); + logger.log('test', 'test', 'test'); + const logs = logger.getLogs(); + assertEquals(logs.entries.length, 1); + assertEquals(logs.entries[0].args[0], 'test'); + assertEquals(logs.entries[0].args[1], 'test'); + assertEquals(logs.entries[0].args[2], 'test'); + assertEquals(logs.entries[0].method, 'test'); + assertEquals(logs.entries[0].severity, 'log'); + }); + + it('should be able to add an entry with multiple arguments of different types', () => { + const logger = new Logger('test'); + logger.log('test', 1, true, { foo: 'bar' }); + const logs = logger.getLogs(); + assertEquals(logs.entries.length, 1); + assertEquals(logs.entries[0].args[0], 'test'); + assertEquals(logs.entries[0].args[1], 1); + assertEquals(logs.entries[0].args[2], true); + assertEquals(logs.entries[0].args[3], { foo: 'bar' }); + assertEquals(logs.entries[0].method, 'test'); + assertEquals(logs.entries[0].severity, 'log'); + }); +}); diff --git a/packages/apps/deno-runtime/lib/tests/messenger.test.ts b/packages/apps/deno-runtime/lib/tests/messenger.test.ts new file mode 100644 index 0000000000000..47b46f0db6e33 --- /dev/null +++ b/packages/apps/deno-runtime/lib/tests/messenger.test.ts @@ -0,0 +1,99 @@ +import { assertEquals, assertObjectMatch } from 'https://deno.land/std@0.203.0/assert/mod.ts'; +import { afterAll, beforeEach, describe, it } from 'https://deno.land/std@0.203.0/testing/bdd.ts'; +import { spy } from 'https://deno.land/std@0.203.0/testing/mock.ts'; + +import * as Messenger from '../messenger.ts'; +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { createMockRequest } from '../../handlers/tests/helpers/mod.ts'; +import { RequestContext } from '../requestContext.ts'; +import { JsonRpc } from 'jsonrpc-lite'; + +describe('Messenger', () => { + let context: RequestContext; + + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('id', 'test'); + Messenger.Transport.selectTransport('noop'); + + context = createMockRequest({ method: 'test', params: [] }); + }); + + afterAll(() => { + AppObjectRegistry.clear(); + Messenger.Transport.selectTransport('stdout'); + }); + + it('should add logs to success responses', async () => { + const theSpy = spy(Messenger.Queue, 'enqueue'); + const { logger } = context.context; + + logger.info('test'); + + await Messenger.successResponse({ id: 'test', result: 'test' }, context); + + assertEquals(theSpy.calls.length, 1); + + const [responseArgument] = theSpy.calls[0].args; + + assertObjectMatch(responseArgument as JsonRpc, { + jsonrpc: '2.0', + id: 'test', + result: { + value: 'test', + logs: { + appId: 'test', + method: 'test', + entries: [ + { + severity: 'info', + method: 'test', + args: ['test'], + caller: 'anonymous OR constructor', + }, + ], + }, + }, + }); + + theSpy.restore(); + }); + + it('should add logs to error responses', async () => { + const theSpy = spy(Messenger.Queue, 'enqueue'); + const { logger } = context.context; + + logger.info('test'); + + await Messenger.errorResponse({ id: 'test', error: { code: -32000, message: 'test' } }, context); + + assertEquals(theSpy.calls.length, 1); + + const [responseArgument] = theSpy.calls[0].args; + + assertObjectMatch(responseArgument as JsonRpc, { + jsonrpc: '2.0', + id: 'test', + error: { + code: -32000, + message: 'test', + data: { + logs: { + appId: 'test', + method: 'test', + entries: [ + { + severity: 'info', + method: 'test', + args: ['test'], + caller: 'anonymous OR constructor', + }, + ], + }, + }, + }, + }); + + theSpy.restore(); + }); +}); diff --git a/packages/apps/deno-runtime/lib/wrapAppForRequest.ts b/packages/apps/deno-runtime/lib/wrapAppForRequest.ts new file mode 100644 index 0000000000000..e9643c2c0274a --- /dev/null +++ b/packages/apps/deno-runtime/lib/wrapAppForRequest.ts @@ -0,0 +1,60 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { RequestContext } from './requestContext.ts'; +import { isApp, isRecord } from '../handlers/lib/assertions.ts'; + +export function wrapAppForRequest(app: App, req: RequestContext): App { + return new Proxy(app, { + get(target, property, receiver) { + if (property === 'logger') { + return req.context.logger; + } + + return Reflect.get(target, property, receiver); + }, + }); +} + +// Instances of objects that have a reference to an App instance won't change throughout the +// lifetime of the runtime, so we can cache the results to avoid iterating the same object multiple times +const composedCache = new WeakMap, ReturnType>(); + +function findAppProperty(v: NonNullable): [string, App] | undefined { + const cachedEntry = composedCache.get(v); + + if (cachedEntry) { + return cachedEntry; + } + + if (!isRecord(v)) { + // Enables us to avoid having to determine whether the value is a record again + composedCache.set(v, undefined); + return undefined; + } + + const entry = Object.entries(v).find(([_,v]) => isApp(v)) as [string, App] | undefined; + + composedCache.set(v, entry); + + return entry; +} + +export function wrapComposedApp>(composed: T, req: RequestContext): T { + const prop = findAppProperty(composed); + + if (!prop) { + return composed; + } + + const proxy = wrapAppForRequest(prop[1], req); + + return new Proxy(composed, { + get(target, property, receiver) { + if (property === prop[0]) { + return proxy; + } + + return Reflect.get(target, property, receiver); + }, + }) +} diff --git a/packages/apps/deno-runtime/main.ts b/packages/apps/deno-runtime/main.ts new file mode 100644 index 0000000000000..dc7dacbcea7d0 --- /dev/null +++ b/packages/apps/deno-runtime/main.ts @@ -0,0 +1,132 @@ +if (!Deno.args.includes('--subprocess')) { + Deno.stderr.writeSync( + new TextEncoder().encode(` + This is a Deno wrapper for Rocket.Chat Apps. It is not meant to be executed stand-alone; + It is instead meant to be executed as a subprocess by the Apps-Engine framework. + `), + ); + Deno.exit(1001); +} + +import { JsonRpcError } from 'jsonrpc-lite'; + +import * as Messenger from './lib/messenger.ts'; +import { decoder } from './lib/codec.ts'; +import { Logger } from './lib/logger.ts'; + +import slashcommandHandler from './handlers/slashcommand-handler.ts'; +import videoConferenceHandler from './handlers/videoconference-handler.ts'; +import apiHandler from './handlers/api-handler.ts'; +import handleApp from './handlers/app/handler.ts'; +import handleScheduler from './handlers/scheduler-handler.ts'; +import registerErrorListeners from './error-handlers.ts'; +import { sendMetrics } from './lib/metricsCollector.ts'; +import outboundMessageHandler from './handlers/outboundcomms-handler.ts'; +import { RequestContext } from './lib/requestContext.ts'; + +type Handlers = { + app: typeof handleApp; + api: typeof apiHandler; + slashcommand: typeof slashcommandHandler; + videoconference: typeof videoConferenceHandler; + outboundCommunication: typeof outboundMessageHandler; + scheduler: typeof handleScheduler; + ping: (request: RequestContext) => 'pong'; +}; + +const COMMAND_PING = '_zPING'; + +async function requestRouter({ type, payload }: Messenger.JsonRpcRequest): Promise { + const methodHandlers: Handlers = { + app: handleApp, + api: apiHandler, + slashcommand: slashcommandHandler, + videoconference: videoConferenceHandler, + outboundCommunication: outboundMessageHandler, + scheduler: handleScheduler, + ping: (_request) => 'pong', + }; + + // We're not handling notifications at the moment + if (type === 'notification') { + return Messenger.sendInvalidRequestError(); + } + + const { id, method } = payload; + + const logger = new Logger(method); + + const context: RequestContext = Object.assign(payload, { + context: { logger } + }) + + const [methodPrefix] = method.split(':') as [keyof Handlers]; + const handler = methodHandlers[methodPrefix]; + + if (!handler) { + return Messenger.errorResponse({ + error: { message: 'Method not found', code: -32601 }, + id, + }, context); + } + + const result = await handler(context); + + if (result instanceof JsonRpcError) { + return Messenger.errorResponse({ id, error: result }, context); + } + + return Messenger.successResponse({ id, result }, context); +} + +function handleResponse(response: Messenger.JsonRpcResponse): void { + let event: Event; + + if (response.type === 'error') { + event = new ErrorEvent(`response:${response.payload.id}`, { + error: response.payload, + }); + } else { + event = new CustomEvent(`response:${response.payload.id}`, { + detail: response.payload, + }); + } + + Messenger.RPCResponseObserver.dispatchEvent(event); +} + +async function main() { + Messenger.sendNotification({ method: 'ready' }); + + for await (const message of decoder.decodeStream(Deno.stdin.readable)) { + try { + // Process PING command first as it is not JSON RPC + if (message === COMMAND_PING) { + void Messenger.pongResponse(); + void sendMetrics(); + continue; + } + + const JSONRPCMessage = Messenger.parseMessage(message as Record); + + if (Messenger.isRequest(JSONRPCMessage)) { + void requestRouter(JSONRPCMessage); + continue; + } + + if (Messenger.isResponse(JSONRPCMessage)) { + handleResponse(JSONRPCMessage); + } + } catch (error) { + if (Messenger.isErrorResponse(error)) { + await Messenger.errorResponse(error); + } else { + await Messenger.sendParseError(); + } + } + } +} + +registerErrorListeners(); + +main(); From eb081ee5914c1c8f20f14dd2d844222e04a399e7 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Thu, 16 Apr 2026 13:18:07 -0300 Subject: [PATCH 04/12] refactor(apps): merge IListenerBridge augmentation into the interface directly The old src/bridges/IListenerBridge.ts used module augmentation (`declare module '@rocket.chat/apps-engine/server/bridges'`) to extend IListenerBridge with core-typings-specific overloads. Now that IListenerBridge lives in this package, the augmentation workaround is no longer needed. The extra overload signatures are merged directly into src/server/bridges/IListenerBridge.ts and the augmentation file is deleted. Co-Authored-By: Claude Sonnet 4.6 --- packages/apps/src/bridges/IListenerBridge.ts | 55 ------------------- packages/apps/src/index.ts | 2 - .../src/server/bridges/IListenerBridge.ts | 54 ++++++++++++++++-- 3 files changed, 50 insertions(+), 61 deletions(-) delete mode 100644 packages/apps/src/bridges/IListenerBridge.ts diff --git a/packages/apps/src/bridges/IListenerBridge.ts b/packages/apps/src/bridges/IListenerBridge.ts deleted file mode 100644 index 83c3910d152cc..0000000000000 --- a/packages/apps/src/bridges/IListenerBridge.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { IMessage, IRoom, IUser, ILivechatDepartment, ILivechatVisitor, IOmnichannelRoom } from '@rocket.chat/core-typings'; - -import type { AppEvents } from '../AppsEngine'; - -declare module '@rocket.chat/apps-engine/server/bridges' { - interface IListenerBridge { - messageEvent(int: 'IPostMessageDeleted', message: IMessage, userDeleted: IUser): Promise; - messageEvent(int: 'IPostMessageReacted', message: IMessage, userReacted: IUser, reaction: string, isReacted: boolean): Promise; - messageEvent(int: 'IPostMessageFollowed', message: IMessage, userFollowed: IUser, isFollowed: boolean): Promise; - messageEvent(int: 'IPostMessagePinned', message: IMessage, userPinned: IUser, isPinned: boolean): Promise; - messageEvent(int: 'IPostMessageStarred', message: IMessage, userStarred: IUser, isStarred: boolean): Promise; - messageEvent(int: 'IPostMessageReported', message: IMessage, userReported: IUser, reason: boolean): Promise; - - messageEvent( - int: 'IPreMessageSentPrevent' | 'IPreMessageDeletePrevent' | 'IPreMessageUpdatedPrevent', - message: IMessage, - ): Promise; - messageEvent( - int: 'IPreMessageSentExtend' | 'IPreMessageSentModify' | 'IPreMessageUpdatedExtend' | 'IPreMessageUpdatedModify', - message: IMessage, - ): Promise; - messageEvent(int: 'IPostMessageSent' | 'IPostMessageUpdated' | 'IPostSystemMessageSent', message: IMessage): Promise; - - roomEvent(int: 'IPreRoomUserJoined' | 'IPostRoomUserJoined', room: IRoom, joiningUser: IUser, invitingUser?: IUser): Promise; - roomEvent(int: 'IPreRoomUserLeave' | 'IPostRoomUserLeave', room: IRoom, leavingUser: IUser): Promise; - - roomEvent(int: 'IPreRoomCreatePrevent' | 'IPreRoomDeletePrevent', room: IRoom): Promise; - roomEvent(int: 'IPreRoomCreateExtend' | 'IPreRoomCreateModify', room: IRoom): Promise; - roomEvent(int: 'IPostRoomCreate' | 'IPostRoomDeleted', room: IRoom): Promise; - - livechatEvent( - int: - | 'IPostLivechatAgentAssigned' - | 'IPostLivechatAgentUnassigned' - | 'IPostLivechatDepartmentRemoved' - | 'IPostLivechatDepartmentDisabled', - data: { user: IUser; room: IOmnichannelRoom }, - ): Promise; - livechatEvent( - int: 'IPostLivechatRoomTransferred', - data: { type: 'agent'; room: IRoom['_id']; from: IUser['_id']; to: IUser['_id'] }, - ): Promise; - livechatEvent( - int: 'IPostLivechatRoomTransferred', - data: { type: 'department'; room: IRoom['_id']; from: ILivechatDepartment['_id']; to: ILivechatDepartment['_id'] }, - ): Promise; - livechatEvent(int: 'IPostLivechatGuestSaved', data: ILivechatVisitor['_id']): Promise; - livechatEvent(int: 'IPostLivechatRoomSaved', data: IRoom['_id']): Promise; - livechatEvent( - int: 'ILivechatRoomClosedHandler' | 'IPostLivechatRoomStarted' | 'IPostLivechatRoomClosed' | 'IPreLivechatRoomCreatePrevent', - data: IRoom, - ): Promise; - livechatEvent(int: AppEvents | AppEvents[keyof AppEvents], data: any): Promise; - } -} diff --git a/packages/apps/src/index.ts b/packages/apps/src/index.ts index 31a44b6ce0578..89f846217284a 100644 --- a/packages/apps/src/index.ts +++ b/packages/apps/src/index.ts @@ -1,5 +1,3 @@ -import './bridges/IListenerBridge'; - export type * from './converters'; export * from './AppsEngine'; export type * from './IAppServerNotifier'; diff --git a/packages/apps/src/server/bridges/IListenerBridge.ts b/packages/apps/src/server/bridges/IListenerBridge.ts index 73523eb73c02e..7e30e5aa59b99 100644 --- a/packages/apps/src/server/bridges/IListenerBridge.ts +++ b/packages/apps/src/server/bridges/IListenerBridge.ts @@ -1,13 +1,59 @@ -import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IMessage as IAppsMessage } from '@rocket.chat/apps-engine/definition/messages'; import type { AppInterface } from '@rocket.chat/apps-engine/definition/metadata'; -import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import type { IRoom as IAppsRoom } from '@rocket.chat/apps-engine/definition/rooms'; import type { UIKitIncomingInteraction } from '@rocket.chat/apps-engine/definition/uikit'; import type { ILivechatDepartment, ILivechatVisitor, IMessage, IOmnichannelRoom, IRoom, IUser } from '@rocket.chat/core-typings'; import type { AppEvents } from '../../AppsEngine'; export interface IListenerBridge { - messageEvent(int: AppInterface, message: IMessage): Promise; - roomEvent(int: AppInterface, room: IRoom): Promise; + messageEvent(int: AppInterface, message: IAppsMessage): Promise; + roomEvent(int: AppInterface, room: IAppsRoom): Promise; uiKitInteractionEvent(int: AppInterface, action: UIKitIncomingInteraction): Promise; + + messageEvent(int: 'IPostMessageDeleted', message: IMessage, userDeleted: IUser): Promise; + messageEvent(int: 'IPostMessageReacted', message: IMessage, userReacted: IUser, reaction: string, isReacted: boolean): Promise; + messageEvent(int: 'IPostMessageFollowed', message: IMessage, userFollowed: IUser, isFollowed: boolean): Promise; + messageEvent(int: 'IPostMessagePinned', message: IMessage, userPinned: IUser, isPinned: boolean): Promise; + messageEvent(int: 'IPostMessageStarred', message: IMessage, userStarred: IUser, isStarred: boolean): Promise; + messageEvent(int: 'IPostMessageReported', message: IMessage, userReported: IUser, reason: boolean): Promise; + messageEvent( + int: 'IPreMessageSentPrevent' | 'IPreMessageDeletePrevent' | 'IPreMessageUpdatedPrevent', + message: IMessage, + ): Promise; + messageEvent( + int: 'IPreMessageSentExtend' | 'IPreMessageSentModify' | 'IPreMessageUpdatedExtend' | 'IPreMessageUpdatedModify', + message: IMessage, + ): Promise; + messageEvent(int: 'IPostMessageSent' | 'IPostMessageUpdated' | 'IPostSystemMessageSent', message: IMessage): Promise; + + roomEvent(int: 'IPreRoomUserJoined' | 'IPostRoomUserJoined', room: IRoom, joiningUser: IUser, invitingUser?: IUser): Promise; + roomEvent(int: 'IPreRoomUserLeave' | 'IPostRoomUserLeave', room: IRoom, leavingUser: IUser): Promise; + roomEvent(int: 'IPreRoomCreatePrevent' | 'IPreRoomDeletePrevent', room: IRoom): Promise; + roomEvent(int: 'IPreRoomCreateExtend' | 'IPreRoomCreateModify', room: IRoom): Promise; + roomEvent(int: 'IPostRoomCreate' | 'IPostRoomDeleted', room: IRoom): Promise; + + livechatEvent( + int: + | 'IPostLivechatAgentAssigned' + | 'IPostLivechatAgentUnassigned' + | 'IPostLivechatDepartmentRemoved' + | 'IPostLivechatDepartmentDisabled', + data: { user: IUser; room: IOmnichannelRoom }, + ): Promise; + livechatEvent( + int: 'IPostLivechatRoomTransferred', + data: { type: 'agent'; room: IRoom['_id']; from: IUser['_id']; to: IUser['_id'] }, + ): Promise; + livechatEvent( + int: 'IPostLivechatRoomTransferred', + data: { type: 'department'; room: IRoom['_id']; from: ILivechatDepartment['_id']; to: ILivechatDepartment['_id'] }, + ): Promise; + livechatEvent(int: 'IPostLivechatGuestSaved', data: ILivechatVisitor['_id']): Promise; + livechatEvent(int: 'IPostLivechatRoomSaved', data: IRoom['_id']): Promise; + livechatEvent( + int: 'ILivechatRoomClosedHandler' | 'IPostLivechatRoomStarted' | 'IPostLivechatRoomClosed' | 'IPreLivechatRoomCreatePrevent', + data: IRoom, + ): Promise; + livechatEvent(int: AppEvents | AppEvents[keyof AppEvents], data: any): Promise; } From 67aa33eed98d22c8d365ef505e0120c7dbbbb3f1 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Thu, 16 Apr 2026 13:18:54 -0300 Subject: [PATCH 05/12] chore(apps): add build config for server code and deno-runtime - package.json: add all runtime deps from apps-engine (msgpack, adm-zip, esbuild, jose, semver, etc.), deno-related devDeps (npm-run-all, rimraf, ts-node), build/test scripts, and include deno-runtime/ and scripts/ in published files - tsconfig.json: enable experimentalDecorators and emitDecoratorMetadata required by the incoming server code - turbo.json: declare build outputs (dist/, deno-runtime/, .deno-cache/) - scripts/deno-cache.js: copied from apps-engine; validates Deno version and pre-caches deno-runtime dependencies Co-Authored-By: Claude Sonnet 4.6 --- packages/apps/package.json | 36 ++++++++++-- packages/apps/scripts/deno-cache.js | 89 +++++++++++++++++++++++++++++ packages/apps/tsconfig.json | 4 +- packages/apps/turbo.json | 10 ++++ 4 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 packages/apps/scripts/deno-cache.js create mode 100644 packages/apps/turbo.json diff --git a/packages/apps/package.json b/packages/apps/package.json index 2ac4c89d94f7a..37323c9182726 100644 --- a/packages/apps/package.json +++ b/packages/apps/package.json @@ -5,22 +5,50 @@ "main": "./dist/index.js", "typings": "./dist/index.d.ts", "files": [ - "/dist" + "/dist", + "/deno-runtime", + "/scripts" ], "scripts": { - "build": "rm -rf dist && tsc -p tsconfig.json", + "build": "run-s build:clean build:default build:deno-cache", + "build:clean": "rimraf dist", + "build:default": "tsc -p tsconfig.json", + "build:deno-cache": "node scripts/deno-cache.js", "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", "lint": "eslint .", - "lint:fix": "eslint --fix ." + "lint:fix": "eslint --fix .", + "test:deno": "cd deno-runtime && deno task test", + "test:node": "NODE_ENV=test node --require ts-node/register/transpile-only --test-reporter spec --test \"tests/**/*.test.ts\"" }, "dependencies": { "@rocket.chat/apps-engine": "workspace:^", "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/model-typings": "workspace:^" + "@rocket.chat/model-typings": "workspace:^", + "@msgpack/msgpack": "3.0.0-beta2", + "adm-zip": "^0.5.16", + "debug": "^4.3.7", + "esbuild": "~0.27.3", + "jose": "^4.15.9", + "jsonrpc-lite": "^2.2.0", + "lodash.clonedeep": "^4.5.0", + "semver": "^7.6.3", + "stack-trace": "0.0.10", + "uuid": "~11.0.5" }, "devDependencies": { "@rocket.chat/tsconfig": "workspace:*", + "@seald-io/nedb": "^4.1.2", + "@types/adm-zip": "^0.5.7", + "@types/debug": "^4.1.12", + "@types/lodash.clonedeep": "^4.5.9", + "@types/node": "~22.16.5", + "@types/semver": "^7.5.8", + "@types/stack-trace": "0.0.33", + "@types/uuid": "~10.0.0", "eslint": "~9.39.4", + "npm-run-all": "^4.1.5", + "rimraf": "^6.0.1", + "ts-node": "^6.2.0", "typescript": "~5.9.3" }, "volta": { diff --git a/packages/apps/scripts/deno-cache.js b/packages/apps/scripts/deno-cache.js new file mode 100644 index 0000000000000..c1626e32ff0d2 --- /dev/null +++ b/packages/apps/scripts/deno-cache.js @@ -0,0 +1,89 @@ +const childProcess = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const SHELL_ERR_CMD_NOT_FOUND = 127; +const { CI } = process.env; + +/** + * Matches 'deno 2.3.1' or 'Deno 2.7.11-alpha3.24' or even 'some deno and-anything in between 1.43.5' (as long as everything is in the same line) + * and extracts the correct version string from those ('2.3.1', '2.7.11' and '1.43.5' respectively). + * + * Doesn't match 'denoing 2.3.1' or 'deno2.3.1' or 'mydeno 2.7.11alpha3.24' or 'deno\n1.43.5' + * + * The expression gets a bit complicated because the word boundary assertion (\b) identifies the dash (-) as a valid word boundary, + * but that is not the case for use, as we don't want to match "make-deno" for instance. So, for correctness, we use a negative lookbehind + * assertion ("(? /(?\d+\.\d+\.\d+)\b/.exec(input)?.groups?.version; + +try { + const toolVersionsPath = path.resolve(__dirname, '..', '..', '..', '.tool-versions'); + const denoToolVersion = extractDenoVersion(fs.readFileSync(toolVersionsPath).toString()); + + if (!denoToolVersion) { + throw new Error(`Invalid Deno version in ${toolVersionsPath}, aborting...`); + } + + const installedVersion = extractDenoVersion(childProcess.execSync('deno --version').toString()); + + if (!installedVersion) { + throw new Error( + `Couldn't determine version of installed Deno. Try validating the version with 'deno --version' and make sure it is a valid Deno installation`, + ); + } + + if (installedVersion !== denoToolVersion) { + const message = `Incorrect Deno version. Required '${denoToolVersion}', found '${installedVersion}'.${CI ? '' : " The server will likely work, but it may cause your deno.lock to change - do not commit it. Make sure your Deno version matches the required one so you don't see this message again."}`; + + if (CI) { + throw new Error(message); + } + + // We don't need to fail if a dev environment doesn't have a matching Deno version, just the warning is enough + console.warn(message); + } +} catch (e) { + if (e.status === SHELL_ERR_CMD_NOT_FOUND) { + console.error( + new Error( + [ + 'Could not execute "deno" in the system. It is now a requirement for the Apps-Engine framework, and Rocket.Chat apps will not work without it.', + 'Make sure to install Deno and run the installation process for the Apps-Engine again. More info on https://docs.deno.com/runtime/manual/getting_started/installation', + ].join('\n'), + { cause: e }, + ), + ); + } else { + console.error(e); + } + + process.exit(1); +} + +const rootPath = path.join(__dirname, '..'); +const denoRuntimePath = path.join(rootPath, 'deno-runtime'); +const DENO_DIR = process.env.DENO_DIR ?? path.join(rootPath, '.deno-cache'); + +// In CI envs, break if lockfile changes; in dev envs, it's alright +const commandLine = CI ? 'deno install --frozen --entrypoint main.ts' : 'deno install --entrypoint main.ts'; + +childProcess.execSync(commandLine, { + cwd: denoRuntimePath, + env: { + ...process.env, + DENO_DIR, + }, + stdio: 'inherit', +}); diff --git a/packages/apps/tsconfig.json b/packages/apps/tsconfig.json index e00a45b253fa4..b96364dddaa17 100644 --- a/packages/apps/tsconfig.json +++ b/packages/apps/tsconfig.json @@ -3,7 +3,9 @@ "compilerOptions": { "declaration": true, "rootDir": "./src", - "outDir": "./dist" + "outDir": "./dist", + "experimentalDecorators": true, + "emitDecoratorMetadata": true }, "include": ["./src/**/*"] } diff --git a/packages/apps/turbo.json b/packages/apps/turbo.json new file mode 100644 index 0000000000000..966596e743534 --- /dev/null +++ b/packages/apps/turbo.json @@ -0,0 +1,10 @@ +{ + "extends": ["//"], + "tasks": { + "build": { + "dependsOn": ["^build"], + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/.tool-versions"], + "outputs": ["client/**", "definition/**", "deno-runtime/**", "lib/**", "scripts/**", "server/**", ".deno-cache/**"] + } + } +} From eda1db06a6bfc5197c995dfd1cb83509f2fc6b43 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Thu, 16 Apr 2026 13:22:01 -0300 Subject: [PATCH 06/12] chore(deps): install new dependencies for @rocket.chat/apps Installs runtime and dev dependencies added to @rocket.chat/apps in the previous commit (adm-zip, debug, esbuild, jose, jsonrpc-lite, lodash.clonedeep, msgpack, semver, stack-trace, uuid, npm-run-all, rimraf, ts-node). Co-Authored-By: Claude Sonnet 4.6 --- yarn.lock | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/yarn.lock b/yarn.lock index 0f6b1db648dce..ef377ba13758d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8954,12 +8954,33 @@ __metadata: version: 0.0.0-use.local resolution: "@rocket.chat/apps@workspace:packages/apps" dependencies: + "@msgpack/msgpack": "npm:3.0.0-beta2" "@rocket.chat/apps-engine": "workspace:^" "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/model-typings": "workspace:^" "@rocket.chat/tsconfig": "workspace:*" + "@seald-io/nedb": "npm:^4.1.2" + "@types/adm-zip": "npm:^0.5.7" + "@types/debug": "npm:^4.1.12" + "@types/lodash.clonedeep": "npm:^4.5.9" + "@types/node": "npm:~22.16.5" + "@types/semver": "npm:^7.5.8" + "@types/stack-trace": "npm:0.0.33" + "@types/uuid": "npm:~10.0.0" + adm-zip: "npm:^0.5.16" + debug: "npm:^4.3.7" + esbuild: "npm:~0.27.3" eslint: "npm:~9.39.4" + jose: "npm:^4.15.9" + jsonrpc-lite: "npm:^2.2.0" + lodash.clonedeep: "npm:^4.5.0" + npm-run-all: "npm:^4.1.5" + rimraf: "npm:^6.0.1" + semver: "npm:^7.6.3" + stack-trace: "npm:0.0.10" + ts-node: "npm:^6.2.0" typescript: "npm:~5.9.3" + uuid: "npm:~11.0.5" languageName: unknown linkType: soft From dbb609e447e6746a679cb0577035f68f1bcb501f Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Mon, 27 Apr 2026 19:07:42 -0300 Subject: [PATCH 07/12] apply apps-engine lint rules to apps --- eslint.config.mjs | 4 ++-- packages/apps/package.json | 2 +- yarn.lock | 13 ++++++++++--- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index b23c2b1a8bc1c..88356fd9694d5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -289,7 +289,7 @@ export default [ }, }, { - files: ['packages/apps-engine/**/*'], + files: ['packages/apps-engine/**/*', 'packages/apps/**/*'], rules: { '@typescript-eslint/no-empty-object-type': 'off', '@typescript-eslint/no-unnecessary-type-assertion': 'off', // this rule does not deal well with assertions that remove `undefined` from the type @@ -345,7 +345,7 @@ export default [ }, }, { - ignores: ['packages/apps-engine/@(client|definition|docs|server|lib|deno-runtime|.deno|.deno-cache)'], + ignores: ['packages/@(apps|apps-engine)/@(client|definition|docs|server|lib|deno-runtime|.deno|.deno-cache)'], }, { files: ['packages/core-typings/**/*'], diff --git a/packages/apps/package.json b/packages/apps/package.json index 37323c9182726..f5f11360d9e1e 100644 --- a/packages/apps/package.json +++ b/packages/apps/package.json @@ -21,10 +21,10 @@ "test:node": "NODE_ENV=test node --require ts-node/register/transpile-only --test-reporter spec --test \"tests/**/*.test.ts\"" }, "dependencies": { + "@msgpack/msgpack": "3.0.0-beta2", "@rocket.chat/apps-engine": "workspace:^", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/model-typings": "workspace:^", - "@msgpack/msgpack": "3.0.0-beta2", "adm-zip": "^0.5.16", "debug": "^4.3.7", "esbuild": "~0.27.3", diff --git a/yarn.lock b/yarn.lock index ef377ba13758d..b8074571378cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5307,6 +5307,13 @@ __metadata: languageName: node linkType: hard +"@msgpack/msgpack@npm:3.0.0-beta2": + version: 3.0.0-beta2 + resolution: "@msgpack/msgpack@npm:3.0.0-beta2" + checksum: 10/d02f9221aa152cbd2977d1f56dc591baa2a37420a694cbc7e54ff0724f56ac0523e94de010e56bb845d67a1f2226c1761064b5777e63e9fc26884f4144d391a7 + languageName: node + linkType: hard + "@msgpack/msgpack@npm:3.0.1": version: 3.0.1 resolution: "@msgpack/msgpack@npm:3.0.1" @@ -13670,7 +13677,7 @@ __metadata: languageName: node linkType: hard -"@types/adm-zip@npm:^0.5.8": +"@types/adm-zip@npm:^0.5.7, @types/adm-zip@npm:^0.5.8": version: 0.5.8 resolution: "@types/adm-zip@npm:0.5.8" dependencies: @@ -14229,7 +14236,7 @@ __metadata: languageName: node linkType: hard -"@types/debug@npm:^4.1.13": +"@types/debug@npm:^4.1.12, @types/debug@npm:^4.1.13": version: 4.1.13 resolution: "@types/debug@npm:4.1.13" dependencies: @@ -21522,7 +21529,7 @@ __metadata: languageName: node linkType: hard -"esbuild@npm:~0.27.0, esbuild@npm:~0.27.7": +"esbuild@npm:~0.27.0, esbuild@npm:~0.27.3, esbuild@npm:~0.27.7": version: 0.27.7 resolution: "esbuild@npm:0.27.7" dependencies: From ca2d56f7c91dbf110be2d62341ba77793499095e Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Mon, 27 Apr 2026 19:15:38 -0300 Subject: [PATCH 08/12] fix lint import --- packages/apps/src/server/accessors/Persistence.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/apps/src/server/accessors/Persistence.ts b/packages/apps/src/server/accessors/Persistence.ts index cd783e891c34b..6cff70718069e 100644 --- a/packages/apps/src/server/accessors/Persistence.ts +++ b/packages/apps/src/server/accessors/Persistence.ts @@ -1,5 +1,6 @@ -import type { IPersistence } from '../../definition/accessors'; -import type { RocketChatAssociationRecord } from '../../definition/metadata'; +import type { IPersistence } from '@rocket.chat/apps-engine/definition/accessors'; +import type { RocketChatAssociationRecord } from '@rocket.chat/apps-engine/definition/metadata'; + import type { PersistenceBridge } from '../bridges/PersistenceBridge'; export class Persistence implements IPersistence { From 39df9d2dee45cd139152d96084481a3996f1816d Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Mon, 27 Apr 2026 19:19:34 -0300 Subject: [PATCH 09/12] import compiler options and .gitignore from apps-engine --- packages/apps/.gitignore | 1 + packages/apps/tsconfig.json | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 packages/apps/.gitignore diff --git a/packages/apps/.gitignore b/packages/apps/.gitignore new file mode 100644 index 0000000000000..a6016eb2c1312 --- /dev/null +++ b/packages/apps/.gitignore @@ -0,0 +1 @@ +.deno-cache/ diff --git a/packages/apps/tsconfig.json b/packages/apps/tsconfig.json index b96364dddaa17..3e395a0d35b89 100644 --- a/packages/apps/tsconfig.json +++ b/packages/apps/tsconfig.json @@ -4,8 +4,10 @@ "declaration": true, "rootDir": "./src", "outDir": "./dist", - "experimentalDecorators": true, - "emitDecoratorMetadata": true + "strict": false, + "noUnusedParameters": false, + "noImplicitOverride": false, + "noImplicitReturns": false }, "include": ["./src/**/*"] } From 016e6efeb178a9dfb45476d2cb4f49dd7616dcae Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Wed, 29 Apr 2026 20:02:59 -0300 Subject: [PATCH 10/12] copy apps-engine tests --- eslint.config.mjs | 2 +- packages/apps/.gitignore | 1 + packages/apps/tests/server/AppManager.test.ts | 279 +++++++ .../server/accessors/AppAccessors.test.ts | 121 +++ .../accessors/ConfigurationExtend.test.ts | 56 ++ .../accessors/ConfigurationModify.test.ts | 27 + .../server/accessors/EnvironmentRead.test.ts | 21 + .../server/accessors/EnvironmentWrite.test.ts | 19 + .../EnvironmentalVariableRead.test.ts | 28 + .../apps/tests/server/accessors/Http.test.ts | 155 ++++ .../tests/server/accessors/HttpExtend.test.ts | 80 ++ .../server/accessors/MessageBuilder.test.ts | 134 ++++ .../server/accessors/MessageExtender.test.ts | 41 + .../server/accessors/MessageRead.test.ts | 48 ++ .../tests/server/accessors/Modify.test.ts | 46 ++ .../server/accessors/ModifyCreator.test.ts | 130 ++++ .../server/accessors/ModifyExtender.test.ts | 77 ++ .../server/accessors/ModifyUpdater.test.ts | 140 ++++ .../tests/server/accessors/Notifier.test.ts | 50 ++ .../server/accessors/Persistence.test.ts | 83 ++ .../server/accessors/PersistenceRead.test.ts | 27 + .../tests/server/accessors/Reader.test.ts | 59 ++ .../server/accessors/RoomBuilder.test.ts | 92 +++ .../server/accessors/RoomExtender.test.ts | 47 ++ .../tests/server/accessors/RoomRead.test.ts | 150 ++++ .../accessors/ServerSettingRead.test.ts | 41 + .../accessors/ServerSettingsModify.test.ts | 54 ++ .../server/accessors/SettingRead.test.ts | 36 + .../server/accessors/SettingUpdater.test.ts | 104 +++ .../server/accessors/SettingsExtend.test.ts | 54 ++ .../accessors/SlashCommandsExtend.test.ts | 47 ++ .../accessors/SlashCommandsModify.test.ts | 40 + .../server/accessors/UserBuilder.test.ts | 59 ++ .../tests/server/accessors/UserRead.test.ts | 46 ++ .../accessors/VideoConfProviderExtend.test.ts | 38 + .../accessors/VideoConferenceBuilder.test.ts | 101 +++ .../accessors/VideoConferenceExtend.test.ts | 76 ++ .../accessors/VideoConferenceRead.test.ts | 28 + .../tests/server/compiler/AppCompiler.test.ts | 24 + .../AppFabricationFulfillment.test.ts | 129 ++++ .../server/compiler/AppImplements.test.ts | 21 + .../errors/CommandAlreadyExistsError.test.ts | 13 + .../CommandHasAlreadyBeenTouchedError.test.ts | 13 + .../tests/server/errors/CompilerError.test.ts | 13 + .../errors/MustContainFunctionError.test.ts | 13 + .../server/errors/MustExtendAppError.test.ts | 13 + .../NotEnoughMethodArgumentsError.test.ts | 13 + .../errors/RequiredApiVersionError.test.ts | 31 + .../tests/server/logging/AppConsole.test.ts | 92 +++ .../managers/AppAccessorManager.test.ts | 174 +++++ .../apps/tests/server/managers/AppApi.test.ts | 24 + .../server/managers/AppApiManager.test.ts | 227 ++++++ .../AppExternalComponentManager.test.ts | 137 ++++ .../managers/AppListenerManager.test.ts | 38 + .../AppOutboundCommunicationProvider.test.ts | 23 + ...tboundCommunicationProviderManager.test.ts | 284 +++++++ .../server/managers/AppRuntimeManager.test.ts | 140 ++++ .../managers/AppSettingsManager.test.ts | 159 ++++ .../server/managers/AppSlashCommand.test.ts | 27 + .../managers/AppSlashCommandManager.test.ts | 462 +++++++++++ .../managers/AppVideoConfProvider.test.ts | 21 + .../AppVideoConfProviderManager.test.ts | 359 +++++++++ .../managers/UIActionButtonManager.test.ts | 311 ++++++++ .../apps/tests/server/misc/Utilities.test.ts | 99 +++ .../DenoRuntimeSubprocessController.test.ts | 228 ++++++ .../runtime/deno/LivenessManager.test.ts | 271 +++++++ .../runtime/deno/bundleLegacyApp.test.ts | 107 +++ packages/apps/tests/test-data/README.md | 2 + .../test-data/apps/hello-world-test_0.0.1.zip | Bin 0 -> 10309 bytes .../test-data/apps/testing-app_0.0.8.zip | Bin 0 -> 37318 bytes .../test-data/bridges/OAuthAppsBridge.ts | 29 + .../test-data/bridges/activationBridge.ts | 26 + .../apps/tests/test-data/bridges/apiBridge.ts | 38 + .../tests/test-data/bridges/appBridges.ts | 255 ++++++ .../test-data/bridges/appDetailChanges.ts | 7 + .../tests/test-data/bridges/cloudBridge.ts | 16 + .../tests/test-data/bridges/commandBridge.ts | 42 + .../tests/test-data/bridges/contactBridge.ts | 23 + .../tests/test-data/bridges/emailBridge.ts | 9 + .../bridges/environmentalVariableBridge.ts | 15 + .../test-data/bridges/experimentalBridge.ts | 3 + .../tests/test-data/bridges/httpBridge.ts | 16 + .../tests/test-data/bridges/internalBridge.ts | 17 + .../bridges/internalFederationBridge.ts | 11 + .../tests/test-data/bridges/livechatBridge.ts | 124 +++ .../tests/test-data/bridges/messageBridge.ts | 44 ++ .../test-data/bridges/moderationBridge.ts | 18 + .../tests/test-data/bridges/outboundComms.ts | 21 + .../tests/test-data/bridges/persisBridge.ts | 46 ++ .../tests/test-data/bridges/roleBridge.ts | 13 + .../tests/test-data/bridges/roomBridge.ts | 81 ++ .../test-data/bridges/schedulerBridge.ts | 25 + .../test-data/bridges/serverSettingBridge.ts | 42 + .../tests/test-data/bridges/threadBridge.ts | 9 + .../test-data/bridges/uiIntegrationBridge.ts | 10 + .../tests/test-data/bridges/uploadBridge.ts | 18 + .../tests/test-data/bridges/userBridge.ts | 49 ++ .../bridges/videoConferenceBridge.ts | 26 + .../test-data/misc/fake-library-file.d.ts | 5 + .../test-data/storage/TestSourceStorage.ts | 20 + .../tests/test-data/storage/logStorage.ts | 28 + .../apps/tests/test-data/storage/storage.ts | 133 ++++ packages/apps/tests/test-data/utilities.ts | 725 ++++++++++++++++++ packages/apps/tests/tsconfig.json | 8 + 104 files changed, 8056 insertions(+), 1 deletion(-) create mode 100644 packages/apps/tests/server/AppManager.test.ts create mode 100644 packages/apps/tests/server/accessors/AppAccessors.test.ts create mode 100644 packages/apps/tests/server/accessors/ConfigurationExtend.test.ts create mode 100644 packages/apps/tests/server/accessors/ConfigurationModify.test.ts create mode 100644 packages/apps/tests/server/accessors/EnvironmentRead.test.ts create mode 100644 packages/apps/tests/server/accessors/EnvironmentWrite.test.ts create mode 100644 packages/apps/tests/server/accessors/EnvironmentalVariableRead.test.ts create mode 100644 packages/apps/tests/server/accessors/Http.test.ts create mode 100644 packages/apps/tests/server/accessors/HttpExtend.test.ts create mode 100644 packages/apps/tests/server/accessors/MessageBuilder.test.ts create mode 100644 packages/apps/tests/server/accessors/MessageExtender.test.ts create mode 100644 packages/apps/tests/server/accessors/MessageRead.test.ts create mode 100644 packages/apps/tests/server/accessors/Modify.test.ts create mode 100644 packages/apps/tests/server/accessors/ModifyCreator.test.ts create mode 100644 packages/apps/tests/server/accessors/ModifyExtender.test.ts create mode 100644 packages/apps/tests/server/accessors/ModifyUpdater.test.ts create mode 100644 packages/apps/tests/server/accessors/Notifier.test.ts create mode 100644 packages/apps/tests/server/accessors/Persistence.test.ts create mode 100644 packages/apps/tests/server/accessors/PersistenceRead.test.ts create mode 100644 packages/apps/tests/server/accessors/Reader.test.ts create mode 100644 packages/apps/tests/server/accessors/RoomBuilder.test.ts create mode 100644 packages/apps/tests/server/accessors/RoomExtender.test.ts create mode 100644 packages/apps/tests/server/accessors/RoomRead.test.ts create mode 100644 packages/apps/tests/server/accessors/ServerSettingRead.test.ts create mode 100644 packages/apps/tests/server/accessors/ServerSettingsModify.test.ts create mode 100644 packages/apps/tests/server/accessors/SettingRead.test.ts create mode 100644 packages/apps/tests/server/accessors/SettingUpdater.test.ts create mode 100644 packages/apps/tests/server/accessors/SettingsExtend.test.ts create mode 100644 packages/apps/tests/server/accessors/SlashCommandsExtend.test.ts create mode 100644 packages/apps/tests/server/accessors/SlashCommandsModify.test.ts create mode 100644 packages/apps/tests/server/accessors/UserBuilder.test.ts create mode 100644 packages/apps/tests/server/accessors/UserRead.test.ts create mode 100644 packages/apps/tests/server/accessors/VideoConfProviderExtend.test.ts create mode 100644 packages/apps/tests/server/accessors/VideoConferenceBuilder.test.ts create mode 100644 packages/apps/tests/server/accessors/VideoConferenceExtend.test.ts create mode 100644 packages/apps/tests/server/accessors/VideoConferenceRead.test.ts create mode 100644 packages/apps/tests/server/compiler/AppCompiler.test.ts create mode 100644 packages/apps/tests/server/compiler/AppFabricationFulfillment.test.ts create mode 100644 packages/apps/tests/server/compiler/AppImplements.test.ts create mode 100644 packages/apps/tests/server/errors/CommandAlreadyExistsError.test.ts create mode 100644 packages/apps/tests/server/errors/CommandHasAlreadyBeenTouchedError.test.ts create mode 100644 packages/apps/tests/server/errors/CompilerError.test.ts create mode 100644 packages/apps/tests/server/errors/MustContainFunctionError.test.ts create mode 100644 packages/apps/tests/server/errors/MustExtendAppError.test.ts create mode 100644 packages/apps/tests/server/errors/NotEnoughMethodArgumentsError.test.ts create mode 100644 packages/apps/tests/server/errors/RequiredApiVersionError.test.ts create mode 100644 packages/apps/tests/server/logging/AppConsole.test.ts create mode 100644 packages/apps/tests/server/managers/AppAccessorManager.test.ts create mode 100644 packages/apps/tests/server/managers/AppApi.test.ts create mode 100644 packages/apps/tests/server/managers/AppApiManager.test.ts create mode 100644 packages/apps/tests/server/managers/AppExternalComponentManager.test.ts create mode 100644 packages/apps/tests/server/managers/AppListenerManager.test.ts create mode 100644 packages/apps/tests/server/managers/AppOutboundCommunicationProvider.test.ts create mode 100644 packages/apps/tests/server/managers/AppOutboundCommunicationProviderManager.test.ts create mode 100644 packages/apps/tests/server/managers/AppRuntimeManager.test.ts create mode 100644 packages/apps/tests/server/managers/AppSettingsManager.test.ts create mode 100644 packages/apps/tests/server/managers/AppSlashCommand.test.ts create mode 100644 packages/apps/tests/server/managers/AppSlashCommandManager.test.ts create mode 100644 packages/apps/tests/server/managers/AppVideoConfProvider.test.ts create mode 100644 packages/apps/tests/server/managers/AppVideoConfProviderManager.test.ts create mode 100644 packages/apps/tests/server/managers/UIActionButtonManager.test.ts create mode 100644 packages/apps/tests/server/misc/Utilities.test.ts create mode 100644 packages/apps/tests/server/runtime/DenoRuntimeSubprocessController.test.ts create mode 100644 packages/apps/tests/server/runtime/deno/LivenessManager.test.ts create mode 100644 packages/apps/tests/server/runtime/deno/bundleLegacyApp.test.ts create mode 100644 packages/apps/tests/test-data/README.md create mode 100644 packages/apps/tests/test-data/apps/hello-world-test_0.0.1.zip create mode 100644 packages/apps/tests/test-data/apps/testing-app_0.0.8.zip create mode 100644 packages/apps/tests/test-data/bridges/OAuthAppsBridge.ts create mode 100644 packages/apps/tests/test-data/bridges/activationBridge.ts create mode 100644 packages/apps/tests/test-data/bridges/apiBridge.ts create mode 100644 packages/apps/tests/test-data/bridges/appBridges.ts create mode 100644 packages/apps/tests/test-data/bridges/appDetailChanges.ts create mode 100644 packages/apps/tests/test-data/bridges/cloudBridge.ts create mode 100644 packages/apps/tests/test-data/bridges/commandBridge.ts create mode 100644 packages/apps/tests/test-data/bridges/contactBridge.ts create mode 100644 packages/apps/tests/test-data/bridges/emailBridge.ts create mode 100644 packages/apps/tests/test-data/bridges/environmentalVariableBridge.ts create mode 100644 packages/apps/tests/test-data/bridges/experimentalBridge.ts create mode 100644 packages/apps/tests/test-data/bridges/httpBridge.ts create mode 100644 packages/apps/tests/test-data/bridges/internalBridge.ts create mode 100644 packages/apps/tests/test-data/bridges/internalFederationBridge.ts create mode 100644 packages/apps/tests/test-data/bridges/livechatBridge.ts create mode 100644 packages/apps/tests/test-data/bridges/messageBridge.ts create mode 100644 packages/apps/tests/test-data/bridges/moderationBridge.ts create mode 100644 packages/apps/tests/test-data/bridges/outboundComms.ts create mode 100644 packages/apps/tests/test-data/bridges/persisBridge.ts create mode 100644 packages/apps/tests/test-data/bridges/roleBridge.ts create mode 100644 packages/apps/tests/test-data/bridges/roomBridge.ts create mode 100644 packages/apps/tests/test-data/bridges/schedulerBridge.ts create mode 100644 packages/apps/tests/test-data/bridges/serverSettingBridge.ts create mode 100644 packages/apps/tests/test-data/bridges/threadBridge.ts create mode 100644 packages/apps/tests/test-data/bridges/uiIntegrationBridge.ts create mode 100644 packages/apps/tests/test-data/bridges/uploadBridge.ts create mode 100644 packages/apps/tests/test-data/bridges/userBridge.ts create mode 100644 packages/apps/tests/test-data/bridges/videoConferenceBridge.ts create mode 100644 packages/apps/tests/test-data/misc/fake-library-file.d.ts create mode 100644 packages/apps/tests/test-data/storage/TestSourceStorage.ts create mode 100644 packages/apps/tests/test-data/storage/logStorage.ts create mode 100644 packages/apps/tests/test-data/storage/storage.ts create mode 100644 packages/apps/tests/test-data/utilities.ts create mode 100644 packages/apps/tests/tsconfig.json diff --git a/eslint.config.mjs b/eslint.config.mjs index 88356fd9694d5..e0716a0d4898d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -338,7 +338,7 @@ export default [ }, }, { - files: ['packages/apps-engine/tests/**/*'], + files: ['packages/apps-engine/tests/**/*', 'packages/apps/tests/**/*'], rules: { '@typescript-eslint/no-non-null-assertion': 'off', 'testing-library/no-await-sync-queries': 'off', diff --git a/packages/apps/.gitignore b/packages/apps/.gitignore index a6016eb2c1312..6275ba88f1b7e 100644 --- a/packages/apps/.gitignore +++ b/packages/apps/.gitignore @@ -1 +1,2 @@ .deno-cache/ +/tests/test-data/dbs diff --git a/packages/apps/tests/server/AppManager.test.ts b/packages/apps/tests/server/AppManager.test.ts new file mode 100644 index 0000000000000..f4b7db974e9c3 --- /dev/null +++ b/packages/apps/tests/server/AppManager.test.ts @@ -0,0 +1,279 @@ +import * as assert from 'node:assert'; +import { describe, it, afterEach, mock } from 'node:test'; + +import { AppManager } from '../../src/server/AppManager'; +import { AppBridges } from '../../src/server/bridges'; +import { AppCompiler, AppPackageParser } from '../../src/server/compiler'; +import { + AppAccessorManager, + AppApiManager, + AppExternalComponentManager, + AppListenerManager, + AppSettingsManager, + AppSlashCommandManager, + AppVideoConfProviderManager, + AppOutboundCommunicationProviderManager, +} from '../../src/server/managers'; +import type { AppLogStorage, AppMetadataStorage, AppSourceStorage } from '../../src/server/storage'; +import { SimpleClass, TestData, TestInfastructureSetup } from '../test-data/utilities'; + +describe('AppManager', () => { + const testingInfastructure = new TestInfastructureSetup(); + + afterEach(() => { + AppManager.Instance = undefined; + mock.restoreAll(); + }); + + it('Setup of the AppManager', () => { + const manager = new AppManager({ + metadataStorage: testingInfastructure.getAppStorage(), + logStorage: testingInfastructure.getLogStorage(), + bridges: testingInfastructure.getAppBridges(), + sourceStorage: testingInfastructure.getSourceStorage(), + tempFilePath: testingInfastructure.getTempFilePath(), + }); + + assert.strictEqual(manager.getStorage(), testingInfastructure.getAppStorage()); + assert.strictEqual(manager.getLogStorage(), testingInfastructure.getLogStorage()); + // NOTE: manager.getBridges() returns a proxy, so they are vlaue equality instead of reference equality + assert.deepStrictEqual(manager.getBridges(), testingInfastructure.getAppBridges()); + assert.strictEqual(manager.areAppsLoaded(), false); + + assert.throws( + () => + new AppManager({ + metadataStorage: {} as AppMetadataStorage, + logStorage: {} as AppLogStorage, + bridges: {} as AppBridges, + sourceStorage: {} as AppSourceStorage, + tempFilePath: 'temp-file-path', + }), + { + name: 'Error', + message: 'There is already a valid AppManager instance', + }, + ); + }); + + it('Invalid Storage and Bridge', () => { + const invalid = new SimpleClass(); + + assert.throws( + () => + new AppManager({ + metadataStorage: invalid as any, + logStorage: invalid as any, + bridges: invalid as any, + sourceStorage: invalid as any, + tempFilePath: 'temp-file-path', + }), + { + name: 'Error', + message: 'Invalid instance of the AppMetadataStorage', + }, + ); + + assert.throws( + () => + new AppManager({ + metadataStorage: testingInfastructure.getAppStorage(), + logStorage: invalid as any, + bridges: invalid as any, + sourceStorage: invalid as any, + tempFilePath: 'temp-file-path', + }), + { + name: 'Error', + message: 'Invalid instance of the AppLogStorage', + }, + ); + + assert.throws( + () => + new AppManager({ + metadataStorage: testingInfastructure.getAppStorage(), + logStorage: testingInfastructure.getLogStorage(), + bridges: invalid as any, + sourceStorage: invalid as any, + tempFilePath: 'temp-file-path', + }), + { + name: 'Error', + message: 'Invalid instance of the AppBridges', + }, + ); + + assert.throws( + () => + new AppManager({ + metadataStorage: testingInfastructure.getAppStorage(), + logStorage: testingInfastructure.getLogStorage(), + bridges: testingInfastructure.getAppBridges(), + sourceStorage: invalid as any, + tempFilePath: testingInfastructure.getTempFilePath(), + }), + { + name: 'Error', + message: 'Invalid instance of the AppSourceStorage', + }, + ); + }); + + it('Ensure Managers are Valid Types', () => { + const manager = new AppManager({ + metadataStorage: testingInfastructure.getAppStorage(), + logStorage: testingInfastructure.getLogStorage(), + bridges: testingInfastructure.getAppBridges(), + sourceStorage: testingInfastructure.getSourceStorage(), + tempFilePath: testingInfastructure.getTempFilePath(), + }); + + assert.ok(manager.getParser() instanceof AppPackageParser); + assert.ok(manager.getCompiler() instanceof AppCompiler); + assert.ok(manager.getAccessorManager() instanceof AppAccessorManager); + assert.ok(manager.getBridges() instanceof AppBridges); + assert.ok(manager.getListenerManager() instanceof AppListenerManager); + assert.ok(manager.getCommandManager() instanceof AppSlashCommandManager); + assert.ok(manager.getExternalComponentManager() instanceof AppExternalComponentManager); + assert.ok(manager.getApiManager() instanceof AppApiManager); + assert.ok(manager.getSettingsManager() instanceof AppSettingsManager); + assert.ok(manager.getVideoConfProviderManager() instanceof AppVideoConfProviderManager); + assert.ok(manager.getOutboundCommunicationProviderManager() instanceof AppOutboundCommunicationProviderManager); + }); + + it('Update Apps Marketplace Info - Apps without subscription info are skipped', async () => { + const manager = new AppManager({ + metadataStorage: testingInfastructure.getAppStorage(), + logStorage: testingInfastructure.getLogStorage(), + bridges: testingInfastructure.getAppBridges(), + sourceStorage: testingInfastructure.getSourceStorage(), + tempFilePath: testingInfastructure.getTempFilePath(), + }); + + const appsOverview = TestData.getAppsOverview(); + appsOverview[0].latest.subscriptionInfo = undefined; // No subscription info + + // Mock the apps Map to return our mock app + (manager as any).apps = new Map([['test-app', TestData.getMockApp(TestData.getAppStorageItem(), manager)]]); + + const updatePartialAndReturnDocumentSpy = mock.method(manager.getStorage(), 'updatePartialAndReturnDocument', () => Promise.resolve()); + + // Should not throw and complete successfully + await manager.updateAppsMarketplaceInfo(appsOverview); + + assert.strictEqual(updatePartialAndReturnDocumentSpy.mock.calls.length, 0); + }); + + it('Update Apps Marketplace Info - Apps not found in manager are skipped', async () => { + const manager = new AppManager({ + metadataStorage: testingInfastructure.getAppStorage(), + logStorage: testingInfastructure.getLogStorage(), + bridges: testingInfastructure.getAppBridges(), + sourceStorage: testingInfastructure.getSourceStorage(), + tempFilePath: testingInfastructure.getTempFilePath(), + }); + + const appsOverview = TestData.getAppsOverview(); + appsOverview[0].latest.id = 'nonexistent-app'; // App not in manager + + // Mock the apps Map to return our mock app + (manager as any).apps = new Map([['test-app', TestData.getMockApp(TestData.getAppStorageItem(), manager)]]); + + const updatePartialAndReturnDocumentSpy = mock.method(manager.getStorage(), 'updatePartialAndReturnDocument', () => Promise.resolve()); + + // Should not throw and complete successfully + await manager.updateAppsMarketplaceInfo(appsOverview); + + assert.strictEqual(updatePartialAndReturnDocumentSpy.mock.calls.length, 0); + }); + + it('Update Apps Marketplace Info - Apps with same license are skipped', async () => { + const manager = new AppManager({ + metadataStorage: testingInfastructure.getAppStorage(), + logStorage: testingInfastructure.getLogStorage(), + bridges: testingInfastructure.getAppBridges(), + sourceStorage: testingInfastructure.getSourceStorage(), + tempFilePath: testingInfastructure.getTempFilePath(), + }); + + const sameLicenseData = 'same-license-data'; + const existingSubscriptionInfo = TestData.getMarketplaceSubscriptionInfo({ + license: { license: sameLicenseData, version: 1, expireDate: new Date('2023-01-01') }, + }); + + const mockStorageItem = TestData.getAppStorageItem({ + marketplaceInfo: [TestData.getMarketplaceInfo({ subscriptionInfo: existingSubscriptionInfo })], + }); + + const mockApp = TestData.getMockApp(mockStorageItem, manager); + + // Mock the apps Map to return our mock app + (manager as any).apps = new Map([['test-app', mockApp]]); + + const appsOverview = TestData.getAppsOverview( + TestData.getMarketplaceSubscriptionInfo({ + license: { license: sameLicenseData, version: 1, expireDate: new Date('2023-01-01') }, + }), + ); + + const updatePartialAndReturnDocumentSpy = mock.method(manager.getStorage(), 'updatePartialAndReturnDocument', () => Promise.resolve()); + + // Should not throw and complete successfully + await manager.updateAppsMarketplaceInfo(appsOverview); + + // Verify the subscription info was not updated (should remain the same) + assert.strictEqual(mockStorageItem.marketplaceInfo[0].subscriptionInfo.seats, 10); // Original value + assert.strictEqual(updatePartialAndReturnDocumentSpy.mock.calls.length, 0); + }); + + it('Update Apps Marketplace Info - Subscription info is updated and app is signed', async () => { + const manager = new AppManager({ + metadataStorage: testingInfastructure.getAppStorage(), + logStorage: testingInfastructure.getLogStorage(), + bridges: testingInfastructure.getAppBridges(), + sourceStorage: testingInfastructure.getSourceStorage(), + tempFilePath: testingInfastructure.getTempFilePath(), + }); + + const existingSubscriptionInfo = TestData.getMarketplaceSubscriptionInfo({ + license: { license: 'old-license-data', version: 1, expireDate: new Date('2023-01-01') }, + }); + + const newSubscriptionInfo = TestData.getMarketplaceSubscriptionInfo({ + seats: 20, + maxSeats: 200, + startDate: '2023-02-01', + periodEnd: '2024-01-31', + license: { license: 'new-license-data', version: 1, expireDate: new Date('2026-01-01') }, + }); + + const mockStorageItem = TestData.getAppStorageItem({ + marketplaceInfo: [TestData.getMarketplaceInfo({ subscriptionInfo: existingSubscriptionInfo })], + }); + + const mockApp = TestData.getMockApp(mockStorageItem, manager); + + mock.method(manager.getSignatureManager(), 'signApp', () => Promise.resolve('signed-app-data')); + mock.method(mockApp, 'validateLicense', () => Promise.resolve()); + + const updatePartialAndReturnDocumentSpy = mock.method(manager.getStorage(), 'updatePartialAndReturnDocument', () => + Promise.resolve(mockStorageItem), + ); + + // Mock the apps Map and dependencies + (manager as any).apps = new Map([['test-app', mockApp]]); + + const appsOverview = TestData.getAppsOverview(newSubscriptionInfo); + + await manager.updateAppsMarketplaceInfo(appsOverview); + + const expectedStorageItem = mockApp.getStorageItem(); + + // Verify the subscription info was updated + assert.strictEqual(expectedStorageItem.marketplaceInfo[0].subscriptionInfo.seats, 20); + assert.strictEqual(expectedStorageItem.marketplaceInfo[0].subscriptionInfo.license.license, 'new-license-data'); + assert.strictEqual(expectedStorageItem.signature, 'signed-app-data'); + assert.strictEqual(updatePartialAndReturnDocumentSpy.mock.calls.length, 1); + }); +}); diff --git a/packages/apps/tests/server/accessors/AppAccessors.test.ts b/packages/apps/tests/server/accessors/AppAccessors.test.ts new file mode 100644 index 0000000000000..9cd414de009fa --- /dev/null +++ b/packages/apps/tests/server/accessors/AppAccessors.test.ts @@ -0,0 +1,121 @@ +import * as assert from 'node:assert'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; + +import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; +import type { AppMethod } from '@rocket.chat/apps-engine/definition/metadata'; + +import type { AppManager } from '../../../src/server/AppManager'; +import type { ProxiedApp } from '../../../src/server/ProxiedApp'; +import { AppAccessors } from '../../../src/server/accessors'; +import type { AppBridges } from '../../../src/server/bridges'; +import { AppConsole } from '../../../src/server/logging'; +import type { + AppExternalComponentManager, + AppSchedulerManager, + AppSettingsManager, + AppSlashCommandManager, + AppVideoConfProviderManager, +} from '../../../src/server/managers'; +import { AppAccessorManager, AppApiManager } from '../../../src/server/managers'; +import type { AppOutboundCommunicationProviderManager } from '../../../src/server/managers/AppOutboundCommunicationProviderManager'; +import type { UIActionButtonManager } from '../../../src/server/managers/UIActionButtonManager'; +import type { AppsEngineRuntime } from '../../../src/server/runtime/AppsEngineRuntime'; +import type { AppLogStorage } from '../../../src/server/storage'; +import { TestsAppBridges } from '../../test-data/bridges/appBridges'; +import { TestsAppLogStorage } from '../../test-data/storage/logStorage'; +import { TestData } from '../../test-data/utilities'; + +describe('AppAccessors', () => { + let mockBridges: TestsAppBridges; + let mockApp: ProxiedApp; + let mockAccessors: AppAccessorManager; + let mockManager: AppManager; + let mockApiManager: AppApiManager; + + beforeEach(() => { + mockBridges = new TestsAppBridges(); + + mockApp = { + getRuntime() { + return {} as AppsEngineRuntime; + }, + getID() { + return 'testing'; + }, + getStatus() { + return Promise.resolve(AppStatus.AUTO_ENABLED); + }, + setupLogger(method: AppMethod): AppConsole { + return new AppConsole(method); + }, + } as ProxiedApp; + + const bri = mockBridges; + const app = mockApp; + mockManager = { + getBridges(): AppBridges { + return bri; + }, + getCommandManager() { + return {} as AppSlashCommandManager; + }, + getExternalComponentManager() { + return {} as AppExternalComponentManager; + }, + getOneById(appId: string): ProxiedApp { + return appId === 'failMePlease' ? undefined : app; + }, + getLogStorage(): AppLogStorage { + return new TestsAppLogStorage(); + }, + getSchedulerManager() { + return {} as AppSchedulerManager; + }, + getUIActionButtonManager() { + return {} as UIActionButtonManager; + }, + getVideoConfProviderManager() { + return {} as AppVideoConfProviderManager; + }, + getSettingsManager() { + return {} as AppSettingsManager; + }, + getOutboundCommunicationProviderManager() { + return {} as AppOutboundCommunicationProviderManager; + }, + } as unknown as AppManager; + + mockAccessors = new AppAccessorManager(mockManager); + const ac = mockAccessors; + mockManager.getAccessorManager = function _getAccessorManager(): AppAccessorManager { + return ac; + }; + + mockApiManager = new AppApiManager(mockManager); + const apiManager = mockApiManager; + mockManager.getApiManager = function _getApiManager(): AppApiManager { + return apiManager; + }; + }); + + afterEach(() => { + mock.restoreAll(); + }); + + it('testAppAccessor', () => { + assert.throws(() => new AppAccessors({} as AppManager, '')); + assert.doesNotThrow(() => new AppAccessors(mockManager, 'testing')); + + const appAccessors = new AppAccessors(mockManager, 'testing'); + + assert.deepStrictEqual(appAccessors.environmentReader, mockAccessors.getEnvironmentRead('testing')); + assert.deepStrictEqual(appAccessors.environmentWriter, mockAccessors.getEnvironmentWrite('testing')); + assert.deepStrictEqual(appAccessors.reader, mockAccessors.getReader('testing')); + assert.deepStrictEqual(appAccessors.http, mockAccessors.getHttp('testing')); + assert.deepStrictEqual(appAccessors.providedApiEndpoints, mockApiManager.listApis('testing')); + + mockApiManager.addApi('testing', TestData.getApi('app-accessor-api')); + + assert.deepStrictEqual(appAccessors.providedApiEndpoints, mockApiManager.listApis('testing')); + }); +}); diff --git a/packages/apps/tests/server/accessors/ConfigurationExtend.test.ts b/packages/apps/tests/server/accessors/ConfigurationExtend.test.ts new file mode 100644 index 0000000000000..fda3204f9d0c4 --- /dev/null +++ b/packages/apps/tests/server/accessors/ConfigurationExtend.test.ts @@ -0,0 +1,56 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { + IApiExtend, + IExternalComponentsExtend, + IHttpExtend, + IOutboundCommunicationProviderExtend, + ISchedulerExtend, + ISettingsExtend, + ISlashCommandsExtend, + IUIExtend, + IVideoConfProvidersExtend, +} from '@rocket.chat/apps-engine/definition/accessors'; + +import { ConfigurationExtend } from '../../../src/server/accessors'; + +describe('ConfigurationExtend', () => { + it('useConfigurationExtend', () => { + const he = {} as IHttpExtend; + const se = {} as ISettingsExtend; + const sce = {} as ISlashCommandsExtend; + const api = {} as IApiExtend; + const externalComponent = {} as IExternalComponentsExtend; + const schedulerExtend = {} as ISchedulerExtend; + const uiExtend = {} as IUIExtend; + const vcProvidersExtend = {} as IVideoConfProvidersExtend; + const outboundCommunication = {} as IOutboundCommunicationProviderExtend; + + assert.doesNotThrow( + () => + new ConfigurationExtend(he, se, sce, api, externalComponent, schedulerExtend, uiExtend, vcProvidersExtend, outboundCommunication), + ); + + const ce = new ConfigurationExtend( + he, + se, + sce, + api, + externalComponent, + schedulerExtend, + uiExtend, + vcProvidersExtend, + outboundCommunication, + ); + assert.ok(ce.http !== undefined); + assert.ok(ce.settings !== undefined); + assert.ok(ce.slashCommands !== undefined); + assert.ok(ce.api !== undefined); + assert.ok(ce.externalComponents !== undefined); + assert.ok(ce.scheduler !== undefined); + assert.ok(ce.ui !== undefined); + assert.ok(ce.videoConfProviders !== undefined); + assert.ok(ce.outboundCommunication !== undefined); + }); +}); diff --git a/packages/apps/tests/server/accessors/ConfigurationModify.test.ts b/packages/apps/tests/server/accessors/ConfigurationModify.test.ts new file mode 100644 index 0000000000000..96d8439638d7f --- /dev/null +++ b/packages/apps/tests/server/accessors/ConfigurationModify.test.ts @@ -0,0 +1,27 @@ +import * as assert from 'node:assert'; +import { describe, it, beforeEach } from 'node:test'; + +import type { ISchedulerModify, IServerSettingsModify, ISlashCommandsModify } from '@rocket.chat/apps-engine/definition/accessors'; + +import { ConfigurationModify } from '../../../src/server/accessors'; + +describe('ConfigurationModify', () => { + let ssm: IServerSettingsModify; + let scm: ISlashCommandsModify; + let scheduler: ISchedulerModify; + + beforeEach(() => { + ssm = {} as IServerSettingsModify; + scm = {} as ISlashCommandsModify; + scheduler = {} as ISchedulerModify; + }); + + it('useConfigurationModify', () => { + assert.doesNotThrow(() => new ConfigurationModify(ssm, scm, scheduler)); + + const sm = new ConfigurationModify(ssm, scm, scheduler); + assert.strictEqual(sm.serverSettings, ssm); + assert.strictEqual(sm.slashCommands, scm); + assert.strictEqual(sm.scheduler, scheduler); + }); +}); diff --git a/packages/apps/tests/server/accessors/EnvironmentRead.test.ts b/packages/apps/tests/server/accessors/EnvironmentRead.test.ts new file mode 100644 index 0000000000000..df41ac7a72989 --- /dev/null +++ b/packages/apps/tests/server/accessors/EnvironmentRead.test.ts @@ -0,0 +1,21 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { IEnvironmentalVariableRead, IServerSettingRead, ISettingRead } from '@rocket.chat/apps-engine/definition/accessors'; + +import { EnvironmentRead } from '../../../src/server/accessors'; + +describe('EnvironmentRead', () => { + it('useEnvironmentRead', () => { + const evr = {} as IEnvironmentalVariableRead; + const ssr = {} as IServerSettingRead; + const sr = {} as ISettingRead; + + assert.doesNotThrow(() => new EnvironmentRead(sr, ssr, evr)); + + const er = new EnvironmentRead(sr, ssr, evr); + assert.ok(er.getSettings() !== undefined); + assert.ok(er.getServerSettings() !== undefined); + assert.ok(er.getEnvironmentVariables() !== undefined); + }); +}); diff --git a/packages/apps/tests/server/accessors/EnvironmentWrite.test.ts b/packages/apps/tests/server/accessors/EnvironmentWrite.test.ts new file mode 100644 index 0000000000000..b7f2ce4ae8d78 --- /dev/null +++ b/packages/apps/tests/server/accessors/EnvironmentWrite.test.ts @@ -0,0 +1,19 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { IServerSettingUpdater, ISettingUpdater } from '@rocket.chat/apps-engine/definition/accessors'; + +import { EnvironmentWrite } from '../../../src/server/accessors'; + +describe('EnvironmentWrite', () => { + it('useEnvironmentWrite', () => { + const sr = {} as ISettingUpdater; + const serverSettings = {} as IServerSettingUpdater; + + assert.doesNotThrow(() => new EnvironmentWrite(sr, serverSettings)); + + const er = new EnvironmentWrite(sr, serverSettings); + assert.ok(er.getSettings() !== undefined); + assert.ok(er.getServerSettings() !== undefined); + }); +}); diff --git a/packages/apps/tests/server/accessors/EnvironmentalVariableRead.test.ts b/packages/apps/tests/server/accessors/EnvironmentalVariableRead.test.ts new file mode 100644 index 0000000000000..423a6ff9036f4 --- /dev/null +++ b/packages/apps/tests/server/accessors/EnvironmentalVariableRead.test.ts @@ -0,0 +1,28 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import { EnvironmentalVariableRead } from '../../../src/server/accessors'; +import type { EnvironmentalVariableBridge } from '../../../src/server/bridges'; + +describe('EnvironmentalVariableRead', () => { + it('useEnvironmentalVariableRead', async () => { + const mockEnvVarBridge = { + doGetValueByName(name: string, appId: string): Promise { + return Promise.resolve('value'); + }, + doIsReadable(name: string, appId: string): Promise { + return Promise.resolve(true); + }, + doIsSet(name: string, appId: string): Promise { + return Promise.resolve(false); + }, + } as EnvironmentalVariableBridge; + + assert.doesNotThrow(() => new EnvironmentalVariableRead(mockEnvVarBridge, 'testing')); + + const evr = new EnvironmentalVariableRead(mockEnvVarBridge, 'testing'); + assert.strictEqual(await evr.getValueByName('testing'), 'value'); + assert.ok(await evr.isReadable('testing')); + assert.ok(!(await evr.isSet('testing2'))); + }); +}); diff --git a/packages/apps/tests/server/accessors/Http.test.ts b/packages/apps/tests/server/accessors/Http.test.ts new file mode 100644 index 0000000000000..69d5ffd94afdb --- /dev/null +++ b/packages/apps/tests/server/accessors/Http.test.ts @@ -0,0 +1,155 @@ +import * as assert from 'node:assert'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; + +import type { + IHttpExtend, + IHttpPreRequestHandler, + IHttpPreResponseHandler, + IHttpRequest, + IHttpResponse, + IPersistence, + IRead, +} from '@rocket.chat/apps-engine/definition/accessors'; + +import { Http, HttpExtend } from '../../../src/server/accessors'; +import type { AppBridges, HttpBridge, IHttpBridgeRequestInfo } from '../../../src/server/bridges'; +import type { AppAccessorManager } from '../../../src/server/managers'; + +describe('Http', () => { + let mockAppId: string; + let mockHttpBridge: HttpBridge; + let mockAppBridge: AppBridges; + let mockHttpExtender: IHttpExtend; + let mockReader: IRead; + let mockPersis: IPersistence; + let mockAccessorManager: AppAccessorManager; + let mockPreRequestHandler: IHttpPreRequestHandler; + let mockPreResponseHandler: IHttpPreResponseHandler; + let mockResponse: IHttpResponse; + + beforeEach(() => { + mockAppId = 'testing-app'; + + mockResponse = { statusCode: 200 } as IHttpResponse; + const res = mockResponse; + mockHttpBridge = { + doCall(info: IHttpBridgeRequestInfo): Promise { + return Promise.resolve(res); + }, + } as HttpBridge; + + const httpBridge = mockHttpBridge; + mockAppBridge = { + getHttpBridge(): HttpBridge { + return httpBridge; + }, + } as AppBridges; + + mockHttpExtender = new HttpExtend(); + + mockReader = {} as IRead; + mockPersis = {} as IPersistence; + const reader = mockReader; + const persis = mockPersis; + mockAccessorManager = { + getReader(appId: string): IRead { + return reader; + }, + getPersistence(appId: string): IPersistence { + return persis; + }, + } as AppAccessorManager; + + mockPreRequestHandler = { + executePreHttpRequest(url: string, request: IHttpRequest, read: IRead, persistence: IPersistence): Promise { + return Promise.resolve(request); + }, + } as IHttpPreRequestHandler; + + mockPreResponseHandler = { + executePreHttpResponse(response: IHttpResponse, read: IRead, persistence: IPersistence): Promise { + return Promise.resolve(response); + }, + } as IHttpPreResponseHandler; + }); + + afterEach(() => { + mock.restoreAll(); + }); + + it('useHttp', async () => { + assert.doesNotThrow(() => new Http(mockAccessorManager, mockAppBridge, mockHttpExtender, mockAppId)); + + const http = new Http(mockAccessorManager, mockAppBridge, mockHttpExtender, mockAppId); + + const doCallSpy = mock.method(mockHttpBridge, 'doCall'); + const preRequestSpy = mock.method(mockPreRequestHandler, 'executePreHttpRequest'); + const preResponseSpy = mock.method(mockPreResponseHandler, 'executePreHttpResponse'); + + assert.ok((await http.get('url-here')) !== undefined); + assert.ok((await http.post('url-here')) !== undefined); + assert.ok((await http.put('url-here')) !== undefined); + assert.ok((await http.del('url-here')) !== undefined); + assert.ok((await http.get('url-here', { headers: {}, params: {} })) !== undefined); + + const request1 = {} as IHttpRequest; + mockHttpExtender.provideDefaultHeader('Auth-Token', 'Bearer asdfasdf'); + assert.ok((await http.post('url-here', request1)) !== undefined); + assert.strictEqual(request1.headers['Auth-Token'], 'Bearer asdfasdf'); + request1.headers['Auth-Token'] = 'mine'; + assert.ok((await http.put('url-here', request1)) !== undefined); // Check that the default doesn't override provided + assert.strictEqual(request1.headers['Auth-Token'], 'mine'); + + const request2 = {} as IHttpRequest; + mockHttpExtender.provideDefaultParam('count', '20'); + assert.ok((await http.del('url-here', request2)) !== undefined); + assert.strictEqual(request2.params.count, '20'); + request2.params.count = '50'; + assert.ok((await http.get('url-here', request2)) !== undefined); // Check that the default doesn't override provided + assert.strictEqual(request2.params.count, '50'); + + mockHttpExtender.providePreRequestHandler(mockPreRequestHandler); + const request3 = {} as IHttpRequest; + assert.ok((await http.post('url-here', request3)) !== undefined); + assert.strictEqual(preRequestSpy.mock.calls.length, 1); + assert.deepStrictEqual(preRequestSpy.mock.calls[0].arguments, ['url-here', request3, mockReader, mockPersis]); + (mockHttpExtender as any).requests = []; + + mockHttpExtender.providePreResponseHandler(mockPreResponseHandler); + assert.ok((await http.post('url-here')) !== undefined); + assert.strictEqual(preResponseSpy.mock.calls.length, 1); + assert.deepStrictEqual(preResponseSpy.mock.calls[0].arguments, [mockResponse, mockReader, mockPersis]); + + assert.strictEqual(doCallSpy.mock.calls.length, 11); + }); + + it('ssrfValidationOption', async () => { + const http = new Http(mockAccessorManager, mockAppBridge, mockHttpExtender, mockAppId); + + let capturedInfo: IHttpBridgeRequestInfo | undefined; + + // Override doCall to capture the info parameter + const originalDoCall = mockHttpBridge.doCall.bind(mockHttpBridge); + mockHttpBridge.doCall = async (info: IHttpBridgeRequestInfo) => { + capturedInfo = info; + return originalDoCall(info); + }; + + // Test with ssrfValidation enabled + await http.get('url-here', { ssrfValidation: true }); + assert.ok(capturedInfo !== undefined); + assert.strictEqual(capturedInfo!.request.ssrfValidation, true); + + // Test with ssrfValidation disabled + await http.post('url-here', { ssrfValidation: false }); + assert.strictEqual(capturedInfo!.request.ssrfValidation, false); + + // Test with ssrfValidation undefined (default) + await http.put('url-here', {}); + assert.strictEqual(capturedInfo!.request.ssrfValidation, undefined); + + // Test with no options + await http.del('url-here'); + assert.strictEqual(capturedInfo!.request.ssrfValidation, undefined); + }); +}); diff --git a/packages/apps/tests/server/accessors/HttpExtend.test.ts b/packages/apps/tests/server/accessors/HttpExtend.test.ts new file mode 100644 index 0000000000000..4f80e69e13924 --- /dev/null +++ b/packages/apps/tests/server/accessors/HttpExtend.test.ts @@ -0,0 +1,80 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { IHttpPreRequestHandler, IHttpPreResponseHandler } from '@rocket.chat/apps-engine/definition/accessors'; + +import { HttpExtend } from '../../../src/server/accessors'; + +describe('HttpExtend', () => { + it('basicHttpExtend', () => { + assert.doesNotThrow(() => new HttpExtend()); + + const he = new HttpExtend(); + assert.deepStrictEqual(he.getDefaultHeaders(), new Map()); + assert.deepStrictEqual(he.getDefaultParams(), new Map()); + assert.strictEqual(he.getPreRequestHandlers().length, 0); + assert.strictEqual(he.getPreResponseHandlers().length, 0); + }); + + it('defaultHeadersInHttpExtend', () => { + const he = new HttpExtend(); + + assert.doesNotThrow(() => he.provideDefaultHeader('Auth', 'token')); + assert.strictEqual(he.getDefaultHeaders().size, 1); + assert.strictEqual(he.getDefaultHeaders().get('Auth'), 'token'); + + assert.doesNotThrow(() => + he.provideDefaultHeaders({ + Auth: 'token2', + Another: 'thing', + }), + ); + assert.strictEqual(he.getDefaultHeaders().size, 2); + assert.strictEqual(he.getDefaultHeaders().get('Auth'), 'token2'); + assert.strictEqual(he.getDefaultHeaders().get('Another'), 'thing'); + }); + + it('defaultParamsInHttpExtend', () => { + const he = new HttpExtend(); + + assert.doesNotThrow(() => he.provideDefaultParam('id', 'abcdefg')); + assert.strictEqual(he.getDefaultParams().size, 1); + assert.strictEqual(he.getDefaultParams().get('id'), 'abcdefg'); + + assert.doesNotThrow(() => + he.provideDefaultParams({ + id: 'zyxwvu', + count: '4', + }), + ); + assert.strictEqual(he.getDefaultParams().size, 2); + assert.strictEqual(he.getDefaultParams().get('id'), 'zyxwvu'); + assert.strictEqual(he.getDefaultParams().get('count'), '4'); + }); + + it('preRequestHandlersInHttpExtend', () => { + const he = new HttpExtend(); + + const preRequestHandler: IHttpPreRequestHandler = { + executePreHttpRequest: function _thing(url, req) { + return new Promise((resolve) => resolve(req)); + }, + }; + + assert.doesNotThrow(() => he.providePreRequestHandler(preRequestHandler)); + assert.ok(he.getPreRequestHandlers().length > 0); + }); + + it('preResponseHandlersInHttpExtend', () => { + const he = new HttpExtend(); + + const preResponseHandler: IHttpPreResponseHandler = { + executePreHttpResponse: function _thing(res) { + return new Promise((resolve) => resolve(res)); + }, + }; + + assert.doesNotThrow(() => he.providePreResponseHandler(preResponseHandler)); + assert.ok(he.getPreResponseHandlers().length > 0); + }); +}); diff --git a/packages/apps/tests/server/accessors/MessageBuilder.test.ts b/packages/apps/tests/server/accessors/MessageBuilder.test.ts new file mode 100644 index 0000000000000..e73d04b856874 --- /dev/null +++ b/packages/apps/tests/server/accessors/MessageBuilder.test.ts @@ -0,0 +1,134 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import { MessageBuilder, UserBuilder } from '../../../src/server/accessors'; +import { TestData } from '../../test-data/utilities'; + +describe('MessageBuilder', () => { + it('basicMessageBuilder', () => { + assert.doesNotThrow(() => new MessageBuilder()); + assert.doesNotThrow(() => new MessageBuilder(TestData.getMessage())); + }); + + it('settingOnMessageBuilder', () => { + const mbOnce = new MessageBuilder(); + + // setData just replaces the passed in object, so let's treat it differently + assert.strictEqual(mbOnce.setData({ text: 'hello' } as IMessage), mbOnce); + assert.strictEqual((mbOnce as any).msg.text, 'hello'); + + const mbUpdate = new MessageBuilder(); + const editor = new UserBuilder(); + editor.setUsername('username'); + editor.setDisplayName('name'); + + // setUpdateData keeps the ID passed in the message object, so let's treat it differently + assert.strictEqual(mbUpdate.setUpdateData({ text: 'hello', id: 'messageID' } as IMessage, editor.getUser() as IUser), mbUpdate); + assert.strictEqual((mbUpdate as any).msg.text, 'hello'); + assert.strictEqual((mbUpdate as any).msg.id, 'messageID'); + + const msg: IMessage = {} as IMessage; + const mb = new MessageBuilder(msg); + + assert.strictEqual(mb.setThreadId('a random thread id'), mb); + assert.strictEqual(msg.threadId, 'a random thread id'); + assert.strictEqual(mb.getThreadId(), 'a random thread id'); + + const room = TestData.getRoom(); + assert.strictEqual(mb.setRoom(room), mb); + assert.deepStrictEqual(msg.room, room); + assert.deepStrictEqual(mb.getRoom(), room); + + const sender = TestData.getUser(); + assert.strictEqual(mb.setSender(sender), mb); + assert.deepStrictEqual(msg.sender, sender); + assert.deepStrictEqual(mb.getSender(), sender); + + assert.strictEqual(mb.setText('testing, yo!'), mb); + assert.deepStrictEqual(msg.text, 'testing, yo!'); + assert.deepStrictEqual(mb.getText(), 'testing, yo!'); + + assert.strictEqual(mb.setEmojiAvatar(':ghost:'), mb); + assert.deepStrictEqual(msg.emoji, ':ghost:'); + assert.deepStrictEqual(mb.getEmojiAvatar(), ':ghost:'); + + assert.strictEqual(mb.setAvatarUrl('https://rocket.chat/'), mb); + assert.deepStrictEqual(msg.avatarUrl, 'https://rocket.chat/'); + assert.deepStrictEqual(mb.getAvatarUrl(), 'https://rocket.chat/'); + + assert.strictEqual(mb.setUsernameAlias('Some Bot'), mb); + assert.deepStrictEqual(msg.alias, 'Some Bot'); + assert.deepStrictEqual(mb.getUsernameAlias(), 'Some Bot'); + + assert.strictEqual(msg.attachments, undefined); + assert.strictEqual(mb.getAttachments(), undefined); + assert.strictEqual(mb.addAttachment({ color: '#0ff' }), mb); + assert.ok(msg.attachments !== undefined); + assert.ok(mb.getAttachments() !== undefined); + assert.ok(msg.attachments.length > 0); + assert.ok(mb.getAttachments().length > 0); + + assert.deepStrictEqual(msg.attachments[0].color, '#0ff'); + assert.deepStrictEqual(mb.getAttachments()[0].color, '#0ff'); + + assert.strictEqual(mb.setAttachments([]), mb); + assert.strictEqual(msg.attachments.length, 0); + assert.strictEqual(mb.getAttachments().length, 0); + + delete msg.attachments; + assert.throws(() => mb.replaceAttachment(1, {}), { name: 'Error', message: 'No attachment found at the index of "1" to replace.' }); + assert.strictEqual(mb.addAttachment({}), mb); + assert.strictEqual(mb.replaceAttachment(0, { color: '#f0f' }), mb); + assert.deepStrictEqual(msg.attachments[0].color, '#f0f'); + assert.deepStrictEqual(mb.getAttachments()[0].color, '#f0f'); + + assert.strictEqual(mb.removeAttachment(0), mb); + assert.strictEqual(msg.attachments.length, 0); + assert.strictEqual(mb.getAttachments().length, 0); + + delete msg.attachments; + assert.throws(() => mb.removeAttachment(4), { name: 'Error', message: 'No attachment found at the index of "4" to remove.' }); + + const msgEditor = TestData.getUser('msg-editor-id'); + assert.strictEqual(mb.setEditor(msgEditor), mb); + assert.ok(msg.editor !== undefined); + assert.ok(mb.getEditor() !== undefined); + assert.deepStrictEqual(msg.editor.id, 'msg-editor-id'); + assert.deepStrictEqual(mb.getEditor().id, 'msg-editor-id'); + + assert.strictEqual(mb.getMessage(), msg); + delete msg.room; + assert.throws(() => mb.getMessage(), { name: 'Error', message: 'The "room" property is required.' }); + + assert.strictEqual(mb.setGroupable(true), mb); + assert.deepStrictEqual(msg.groupable, true); + assert.deepStrictEqual(mb.getGroupable(), true); + + assert.strictEqual(mb.setGroupable(false), mb); + assert.deepStrictEqual(msg.groupable, false); + assert.deepStrictEqual(mb.getGroupable(), false); + + assert.strictEqual(mb.setParseUrls(true), mb); + assert.deepStrictEqual(msg.parseUrls, true); + assert.deepStrictEqual(mb.getParseUrls(), true); + + assert.strictEqual(mb.setParseUrls(false), mb); + assert.deepStrictEqual(msg.parseUrls, false); + assert.deepStrictEqual(mb.getParseUrls(), false); + + assert.strictEqual(mb.addCustomField('thing', 'value'), mb); + assert.ok(msg.customFields !== undefined); + assert.strictEqual(msg.customFields.thing, 'value'); + assert.throws(() => mb.addCustomField('thing', 'second'), { + name: 'Error', + message: 'The message already contains a custom field by the key: thing', + }); + assert.throws(() => mb.addCustomField('thing.', 'second'), { + name: 'Error', + message: 'The given key contains a period, which is not allowed. Key: thing.', + }); + }); +}); diff --git a/packages/apps/tests/server/accessors/MessageExtender.test.ts b/packages/apps/tests/server/accessors/MessageExtender.test.ts new file mode 100644 index 0000000000000..2031e41598b1d --- /dev/null +++ b/packages/apps/tests/server/accessors/MessageExtender.test.ts @@ -0,0 +1,41 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; + +import { MessageExtender } from '../../../src/server/accessors'; +import { TestData } from '../../test-data/utilities'; + +describe('MessageExtender', () => { + it('basicMessageExtender', () => { + assert.doesNotThrow(() => new MessageExtender({} as IMessage)); + assert.doesNotThrow(() => new MessageExtender(TestData.getMessage())); + }); + + it('usingMessageExtender', () => { + const msg: IMessage = {} as IMessage; + const me = new MessageExtender(msg); + + assert.ok(msg.attachments !== undefined); + assert.strictEqual(msg.attachments.length, 0); + assert.strictEqual(me.addCustomField('thing', 'value'), me); + assert.ok(msg.customFields !== undefined); + assert.strictEqual(msg.customFields.thing, 'value'); + assert.throws(() => me.addCustomField('thing', 'second'), { + name: 'Error', + message: 'The message already contains a custom field by the key: thing', + }); + assert.throws(() => me.addCustomField('thing.', 'second'), { + name: 'Error', + message: 'The given key contains a period, which is not allowed. Key: thing.', + }); + + assert.strictEqual(me.addAttachment({}), me); + assert.strictEqual(msg.attachments.length, 1); + assert.strictEqual(me.addAttachments([{ collapsed: true }, { color: '#f00' }]), me); + assert.strictEqual(msg.attachments.length, 3); + + assert.notStrictEqual(me.getMessage(), msg); + assert.deepStrictEqual(me.getMessage(), msg); + }); +}); diff --git a/packages/apps/tests/server/accessors/MessageRead.test.ts b/packages/apps/tests/server/accessors/MessageRead.test.ts new file mode 100644 index 0000000000000..30ab6affa4df6 --- /dev/null +++ b/packages/apps/tests/server/accessors/MessageRead.test.ts @@ -0,0 +1,48 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; + +import { MessageRead } from '../../../src/server/accessors'; +import type { MessageBridge } from '../../../src/server/bridges'; +import { TestData } from '../../test-data/utilities'; + +describe('MessageRead', () => { + const msg = TestData.getMessage(); + + const mockMsgBridgeWithMsg = { + doGetById(id: string, appId: string): Promise { + return Promise.resolve(msg); + }, + } as MessageBridge; + + const mockMsgBridgeNoMsg = { + doGetById(id: string, appId: string) { + return Promise.resolve(undefined); + }, + } as MessageBridge; + + it('expectDataFromMessageRead', async () => { + assert.doesNotThrow(() => new MessageRead(mockMsgBridgeWithMsg, 'testing-app')); + + const mr = new MessageRead(mockMsgBridgeWithMsg, 'testing-app'); + + assert.ok((await mr.getById('fake')) !== undefined); + assert.deepStrictEqual(await mr.getById('fake'), msg); + + assert.ok((await mr.getSenderUser('fake')) !== undefined); + assert.deepStrictEqual(await mr.getSenderUser('fake'), msg.sender); + + assert.ok((await mr.getRoom('fake')) !== undefined); + assert.deepStrictEqual(await mr.getRoom('fake'), msg.room); + }); + + it('doNotExpectDataFromMessageRead', async () => { + assert.doesNotThrow(() => new MessageRead(mockMsgBridgeNoMsg, 'testing')); + + const nomr = new MessageRead(mockMsgBridgeNoMsg, 'testing'); + assert.strictEqual(await nomr.getById('fake'), undefined); + assert.strictEqual(await nomr.getSenderUser('fake'), undefined); + assert.strictEqual(await nomr.getRoom('fake'), undefined); + }); +}); diff --git a/packages/apps/tests/server/accessors/Modify.test.ts b/packages/apps/tests/server/accessors/Modify.test.ts new file mode 100644 index 0000000000000..982c48419d39f --- /dev/null +++ b/packages/apps/tests/server/accessors/Modify.test.ts @@ -0,0 +1,46 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import { Modify } from '../../../src/server/accessors'; +import type { + AppBridges, + MessageBridge, + ModerationBridge, + SchedulerBridge, + UiInteractionBridge, + UserBridge, +} from '../../../src/server/bridges'; +import type { OAuthAppsBridge } from '../../../src/server/bridges/OAuthAppsBridge'; + +describe('Modify', () => { + it('useModify', () => { + const mockAppBridges = { + getUserBridge(): UserBridge { + return {} as UserBridge; + }, + getMessageBridge(): MessageBridge { + return {} as MessageBridge; + }, + getUiInteractionBridge(): UiInteractionBridge { + return {} as UiInteractionBridge; + }, + getSchedulerBridge() { + return {} as SchedulerBridge; + }, + getOAuthAppsBridge() { + return {} as OAuthAppsBridge; + }, + getModerationBridge() { + return {} as ModerationBridge; + }, + } as AppBridges; + + assert.doesNotThrow(() => new Modify(mockAppBridges, 'testing')); + + const md = new Modify(mockAppBridges, 'testing'); + assert.ok(md.getCreator() !== undefined); + assert.ok(md.getExtender() !== undefined); + assert.ok(md.getNotifier() !== undefined); + assert.ok(md.getUpdater() !== undefined); + }); +}); diff --git a/packages/apps/tests/server/accessors/ModifyCreator.test.ts b/packages/apps/tests/server/accessors/ModifyCreator.test.ts new file mode 100644 index 0000000000000..ea3d49c15894b --- /dev/null +++ b/packages/apps/tests/server/accessors/ModifyCreator.test.ts @@ -0,0 +1,130 @@ +import * as assert from 'node:assert'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; + +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; +import { UserStatusConnection, UserType } from '@rocket.chat/apps-engine/definition/users'; + +import { ModifyCreator } from '../../../src/server/accessors'; +import type { AppBridges, MessageBridge, RoomBridge, UserBridge } from '../../../src/server/bridges'; +import { TestData } from '../../test-data/utilities'; + +describe('ModifyCreator', () => { + let mockAppId: string; + let mockRoomBridge: RoomBridge; + let mockMessageBridge: MessageBridge; + let mockAppBridge: AppBridges; + let mockAppUser: IUser; + let mockUserBridge: UserBridge; + + beforeEach(() => { + mockAppId = 'testing-app'; + + mockAppUser = { + id: 'mockAppUser', + isEnabled: true, + name: 'mockAppUser', + roles: ['app'], + status: 'online', + statusConnection: UserStatusConnection.UNDEFINED, + type: UserType.APP, + username: 'mockAppUser', + emails: [], + utcOffset: -5, + createdAt: new Date(), + updatedAt: new Date(), + lastLoginAt: new Date(), + }; + + mockRoomBridge = { + doCreate(room: IRoom, members: Array, appId: string): Promise { + return Promise.resolve('roomId'); + }, + } as RoomBridge; + + mockMessageBridge = { + doCreate(msg: IMessage, appId: string): Promise { + return Promise.resolve('msgId'); + }, + } as MessageBridge; + + const appUser = mockAppUser; + mockUserBridge = { + doGetAppUser: (appId: string) => { + return Promise.resolve(appUser); + }, + } as UserBridge; + + const msgBridge = mockMessageBridge; + const rmBridge = mockRoomBridge; + const userBridge = mockUserBridge; + mockAppBridge = { + getMessageBridge: () => msgBridge, + getRoomBridge: () => rmBridge, + getUserBridge: () => userBridge, + } as AppBridges; + }); + + afterEach(() => { + mock.restoreAll(); + }); + + it('basicModifyCreator', async () => { + assert.doesNotThrow(() => new ModifyCreator(mockAppBridge, mockAppId)); + + const mc = new ModifyCreator(mockAppBridge, mockAppId); + assert.ok(mc.startMessage() !== undefined); + assert.ok(mc.startMessage({ id: 'value' } as IMessage) !== undefined); + assert.ok(mc.startRoom() !== undefined); + assert.ok(mc.startRoom({ id: 'value' } as IRoom) !== undefined); + + assert.throws(() => mc.finish({} as any), { name: 'Error', message: 'Invalid builder passed to the ModifyCreator.finish function.' }); + }); + + it('msgModifyCreator', async () => { + const mc = new ModifyCreator(mockAppBridge, mockAppId); + + const msg = {} as IMessage; + const msgBd = mc.startMessage(msg); + await assert.rejects(() => mc.finish(msgBd), { name: 'Error', message: 'The "room" property is required.' }); + msgBd.setRoom(TestData.getRoom()); + assert.ok(msg.room !== undefined); + await assert.doesNotReject(() => mc.finish(msgBd)); + msgBd.setSender(TestData.getUser()); + assert.ok(msg.sender !== undefined); + + const msgBriSpy = mock.method(mockMessageBridge, 'doCreate'); + assert.strictEqual(await mc.finish(msgBd), 'msgId'); + assert.strictEqual(msgBriSpy.mock.calls.length, 1); + assert.deepStrictEqual(msgBriSpy.mock.calls[0].arguments, [msg, mockAppId]); + }); + + it('roomModifyCreator', async () => { + const mc = new ModifyCreator(mockAppBridge, mockAppId); + + const room = {} as IRoom; + const roomBd = mc.startRoom(room); + assert.throws(() => mc.finish(roomBd), { name: 'Error', message: 'Invalid type assigned to the room.' }); + roomBd.setType(RoomType.CHANNEL); + assert.strictEqual(room.type, RoomType.CHANNEL); + + assert.throws(() => mc.finish(roomBd), { name: 'Error', message: 'Invalid creator assigned to the room.' }); + roomBd.setCreator(TestData.getUser()); + assert.ok(room.creator !== undefined); + + assert.throws(() => mc.finish(roomBd), { name: 'Error', message: 'Invalid slugifiedName assigned to the room.' }); + roomBd.setSlugifiedName('testing-room'); + assert.strictEqual(room.slugifiedName, 'testing-room'); + + assert.throws(() => mc.finish(roomBd), { name: 'Error', message: 'Invalid displayName assigned to the room.' }); + roomBd.setDisplayName('Display Name'); + assert.strictEqual(room.displayName, 'Display Name'); + + const roomBriSpy = mock.method(mockRoomBridge, 'doCreate'); + assert.strictEqual(await mc.finish(roomBd), 'roomId'); + assert.strictEqual(roomBriSpy.mock.calls.length, 1); + assert.deepStrictEqual(roomBriSpy.mock.calls[0].arguments, [room, roomBd.getMembersToBeAddedUsernames(), mockAppId]); + }); +}); diff --git a/packages/apps/tests/server/accessors/ModifyExtender.test.ts b/packages/apps/tests/server/accessors/ModifyExtender.test.ts new file mode 100644 index 0000000000000..f355661c42cd6 --- /dev/null +++ b/packages/apps/tests/server/accessors/ModifyExtender.test.ts @@ -0,0 +1,77 @@ +import * as assert from 'node:assert'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; + +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; + +import { ModifyExtender } from '../../../src/server/accessors'; +import type { AppBridges, MessageBridge, RoomBridge } from '../../../src/server/bridges'; +import { TestData } from '../../test-data/utilities'; + +describe('ModifyExtender', () => { + let mockAppId: string; + let mockRoomBridge: RoomBridge; + let mockMessageBridge: MessageBridge; + let mockAppBridge: AppBridges; + + beforeEach(() => { + mockAppId = 'testing-app'; + + mockRoomBridge = { + doGetById(roomId: string, appId: string): Promise { + return Promise.resolve(TestData.getRoom()); + }, + doUpdate(room: IRoom, members: Array, appId: string): Promise { + return Promise.resolve(); + }, + } as RoomBridge; + + mockMessageBridge = { + doGetById(msgId: string, appId: string): Promise { + return Promise.resolve(TestData.getMessage()); + }, + doUpdate(msg: IMessage, appId: string): Promise { + return Promise.resolve(); + }, + } as MessageBridge; + + const rmBridge = mockRoomBridge; + const msgBridge = mockMessageBridge; + mockAppBridge = { + getMessageBridge() { + return msgBridge; + }, + getRoomBridge() { + return rmBridge; + }, + } as AppBridges; + }); + + afterEach(() => { + mock.restoreAll(); + }); + + it('useModifyExtender', async () => { + assert.doesNotThrow(() => new ModifyExtender(mockAppBridge, mockAppId)); + + const me = new ModifyExtender(mockAppBridge, mockAppId); + + const doGetByIdRoomSpy = mock.method(mockRoomBridge, 'doGetById'); + const doUpdateRoomSpy = mock.method(mockRoomBridge, 'doUpdate'); + const doGetByIdMsgSpy = mock.method(mockMessageBridge, 'doGetById'); + const doUpdateMsgSpy = mock.method(mockMessageBridge, 'doUpdate'); + + assert.ok((await me.extendRoom('roomId', TestData.getUser())) !== undefined); + assert.strictEqual(doGetByIdRoomSpy.mock.calls.length, 1); + assert.deepStrictEqual(doGetByIdRoomSpy.mock.calls[0].arguments, ['roomId', mockAppId]); + assert.ok((await me.extendMessage('msgId', TestData.getUser())) !== undefined); + assert.strictEqual(doGetByIdMsgSpy.mock.calls.length, 1); + assert.deepStrictEqual(doGetByIdMsgSpy.mock.calls[0].arguments, ['msgId', mockAppId]); + + assert.throws(() => me.finish({} as any), { name: 'Error', message: 'Invalid extender passed to the ModifyExtender.finish function.' }); + assert.strictEqual(await me.finish(await me.extendRoom('roomId', TestData.getUser())), undefined); + assert.ok(doUpdateRoomSpy.mock.calls.length > 0); + assert.strictEqual(await me.finish(await me.extendMessage('msgId', TestData.getUser())), undefined); + assert.ok(doUpdateMsgSpy.mock.calls.length > 0); + }); +}); diff --git a/packages/apps/tests/server/accessors/ModifyUpdater.test.ts b/packages/apps/tests/server/accessors/ModifyUpdater.test.ts new file mode 100644 index 0000000000000..e3c31bb450ddf --- /dev/null +++ b/packages/apps/tests/server/accessors/ModifyUpdater.test.ts @@ -0,0 +1,140 @@ +import * as assert from 'node:assert'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; + +import type { ILivechatRoom } from '@rocket.chat/apps-engine/definition/livechat/ILivechatRoom'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; + +import { MessageBuilder, ModifyUpdater, RoomBuilder } from '../../../src/server/accessors'; +import type { AppBridges, MessageBridge, RoomBridge } from '../../../src/server/bridges'; +import { TestData } from '../../test-data/utilities'; + +describe('ModifyUpdater', () => { + let mockAppId: string; + let mockRoomBridge: RoomBridge; + let mockMessageBridge: MessageBridge; + let mockAppBridge: AppBridges; + + beforeEach(() => { + mockAppId = 'testing-app'; + + mockRoomBridge = { + doGetById(roomId: string, appId: string): Promise { + return Promise.resolve(TestData.getRoom()); + }, + doUpdate(room: IRoom, members: Array, appId: string): Promise { + return Promise.resolve(); + }, + } as RoomBridge; + + mockMessageBridge = { + doGetById(msgId: string, appId: string): Promise { + return Promise.resolve(TestData.getMessage()); + }, + doUpdate(msg: IMessage, appId: string): Promise { + return Promise.resolve(); + }, + } as MessageBridge; + + const rmBridge = mockRoomBridge; + const msgBridge = mockMessageBridge; + mockAppBridge = { + getMessageBridge() { + return msgBridge; + }, + getRoomBridge() { + return rmBridge; + }, + } as AppBridges; + }); + + afterEach(() => { + mock.restoreAll(); + }); + + it('basicModifyUpdater', async () => { + assert.doesNotThrow(() => new ModifyUpdater(mockAppBridge, mockAppId)); + + const mc = new ModifyUpdater(mockAppBridge, mockAppId); + assert.ok((await mc.message('msgId', TestData.getUser())) !== undefined); + assert.ok((await mc.room('roomId', TestData.getUser())) !== undefined); + + assert.throws(() => mc.finish({} as any), { + name: 'Error', + message: 'Invalid builder passed to the ModifyUpdater.finish function.', + }); + }); + + it('msgModifyUpdater', async () => { + const mc = new ModifyUpdater(mockAppBridge, mockAppId); + + const msg = {} as IMessage; + const msgBd = new MessageBuilder(msg); + assert.throws(() => mc.finish(msgBd), { name: 'Error', message: 'The "room" property is required.' }); + msgBd.setRoom(TestData.getRoom()); + assert.ok(msg.room !== undefined); + assert.throws(() => mc.finish(msgBd), { name: 'Error', message: "Invalid message, can't update a message without an id." }); + msg.id = 'testing-msg'; + assert.throws(() => mc.finish(msgBd), { name: 'Error', message: 'Invalid sender assigned to the message.' }); + msgBd.setSender(TestData.getUser()); + assert.ok(msg.sender !== undefined); + + const msgBriSpy = mock.method(mockMessageBridge, 'doUpdate'); + assert.strictEqual(await mc.finish(msgBd), undefined); + assert.strictEqual(msgBriSpy.mock.calls.length, 1); + assert.deepStrictEqual(msgBriSpy.mock.calls[0].arguments, [msg, mockAppId]); + }); + + it('roomModifyUpdater', async () => { + const mc = new ModifyUpdater(mockAppBridge, mockAppId); + + const room = {} as IRoom; + const roomBd = new RoomBuilder(room); + assert.throws(() => mc.finish(roomBd), { name: 'Error', message: 'Invalid room, can not update a room without an id.' }); + room.id = 'testing-room'; + + assert.throws(() => mc.finish(roomBd), { name: 'Error', message: 'Invalid type assigned to the room.' }); + roomBd.setType(RoomType.CHANNEL); + assert.strictEqual(room.type, RoomType.CHANNEL); + + assert.throws(() => mc.finish(roomBd), { name: 'Error', message: 'Invalid creator assigned to the room.' }); + roomBd.setCreator(TestData.getUser()); + assert.ok(room.creator !== undefined); + + assert.throws(() => mc.finish(roomBd), { name: 'Error', message: 'Invalid slugifiedName assigned to the room.' }); + roomBd.setSlugifiedName('testing-room'); + assert.strictEqual(room.slugifiedName, 'testing-room'); + + assert.throws(() => mc.finish(roomBd), { name: 'Error', message: 'Invalid displayName assigned to the room.' }); + roomBd.setDisplayName('Display Name'); + assert.strictEqual(room.displayName, 'Display Name'); + + const roomBriSpy = mock.method(mockRoomBridge, 'doUpdate'); + assert.strictEqual(await mc.finish(roomBd), undefined); + assert.strictEqual(roomBriSpy.mock.calls.length, 1); + assert.deepStrictEqual(roomBriSpy.mock.calls[0].arguments, [room, roomBd.getMembersToBeAddedUsernames(), mockAppId]); + }); + + it('livechatRoomModifyUpdater', async () => { + const mc = new ModifyUpdater(mockAppBridge, mockAppId); + + const room = {} as ILivechatRoom; + const roomBd = new RoomBuilder(room); + assert.throws(() => mc.finish(roomBd), { name: 'Error', message: 'Invalid room, can not update a room without an id.' }); + room.id = 'testing-room'; + + assert.throws(() => mc.finish(roomBd), { name: 'Error', message: 'Invalid type assigned to the room.' }); + roomBd.setType(RoomType.LIVE_CHAT); + assert.strictEqual(room.type, RoomType.LIVE_CHAT); + + assert.throws(() => mc.finish(roomBd), { name: 'Error', message: 'Invalid displayName assigned to the room.' }); + roomBd.setDisplayName('Display Name'); + assert.strictEqual(room.displayName, 'Display Name'); + + const roomBriSpy = mock.method(mockRoomBridge, 'doUpdate'); + assert.strictEqual(await mc.finish(roomBd), undefined); + assert.strictEqual(roomBriSpy.mock.calls.length, 1); + assert.deepStrictEqual(roomBriSpy.mock.calls[0].arguments, [room, roomBd.getMembersToBeAddedUsernames(), mockAppId]); + }); +}); diff --git a/packages/apps/tests/server/accessors/Notifier.test.ts b/packages/apps/tests/server/accessors/Notifier.test.ts new file mode 100644 index 0000000000000..fd22099a3fdb4 --- /dev/null +++ b/packages/apps/tests/server/accessors/Notifier.test.ts @@ -0,0 +1,50 @@ +import * as assert from 'node:assert'; +import { describe, it, mock } from 'node:test'; + +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import { MessageBuilder, Notifier } from '../../../src/server/accessors'; +import type { MessageBridge, UserBridge } from '../../../src/server/bridges'; +import { TestData } from '../../test-data/utilities'; + +describe('Notifier', () => { + it('useNotifier', async () => { + const mockUserBridge = {} as UserBridge; + const mockMsgBridge = { + doNotifyUser(user: IUser, msg: IMessage, appId: string): Promise { + return Promise.resolve(); + }, + doNotifyRoom(room: IRoom, msg: IMessage, appId: string): Promise { + return Promise.resolve(); + }, + } as MessageBridge; + + assert.doesNotThrow(() => new Notifier(mockUserBridge, mockMsgBridge, 'testing')); + + const noti = new Notifier(mockUserBridge, mockMsgBridge, 'testing'); + + const doNotifyRoomSpy = mock.method(mockMsgBridge, 'doNotifyRoom'); + const doNotifyUserSpy = mock.method(mockMsgBridge, 'doNotifyUser'); + + const room = TestData.getRoom(); + const user = TestData.getUser(); + const roomMsg = TestData.getMessage(); + const userMsg = TestData.getMessage(); + + await assert.doesNotReject(() => noti.notifyRoom(room, roomMsg)); + assert.strictEqual(doNotifyRoomSpy.mock.calls.length, 1); + assert.strictEqual(doNotifyRoomSpy.mock.calls[0].arguments[0], room); + assert.strictEqual(doNotifyRoomSpy.mock.calls[0].arguments[1], roomMsg); + assert.strictEqual(doNotifyRoomSpy.mock.calls[0].arguments[2], 'testing'); + + await assert.doesNotReject(() => noti.notifyUser(user, userMsg)); + assert.strictEqual(doNotifyUserSpy.mock.calls.length, 1); + assert.strictEqual(doNotifyUserSpy.mock.calls[0].arguments[0], user); + assert.strictEqual(doNotifyUserSpy.mock.calls[0].arguments[1], userMsg); + assert.strictEqual(doNotifyUserSpy.mock.calls[0].arguments[2], 'testing'); + + assert.ok(noti.getMessageBuilder() instanceof MessageBuilder); + }); +}); diff --git a/packages/apps/tests/server/accessors/Persistence.test.ts b/packages/apps/tests/server/accessors/Persistence.test.ts new file mode 100644 index 0000000000000..f101139363b2c --- /dev/null +++ b/packages/apps/tests/server/accessors/Persistence.test.ts @@ -0,0 +1,83 @@ +import * as assert from 'node:assert'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; + +import { RocketChatAssociationModel, RocketChatAssociationRecord } from '@rocket.chat/apps-engine/definition/metadata'; + +import { Persistence } from '../../../src/server/accessors'; +import type { PersistenceBridge } from '../../../src/server/bridges'; + +describe('Persistence', () => { + let mockAppId: string; + let mockPersisBridge: PersistenceBridge; + let mockAssoc: RocketChatAssociationRecord; + let data: object; + + beforeEach(() => { + mockAppId = 'testing-app'; + data = { hello: 'world' }; + + const theData = data; + mockPersisBridge = { + doCreate(d: any, appId: string): Promise { + return Promise.resolve('id'); + }, + doCreateWithAssociations(d: any, assocs: Array, appId: string): Promise { + return Promise.resolve('id2'); + }, + doUpdate(id: string, d: object, upsert: boolean, appId: string): Promise { + return Promise.resolve('id3'); + }, + doRemove(id: string, appId: string): Promise { + return Promise.resolve(theData); + }, + doRemoveByAssociations(assocs: Array, appId: string): Promise> { + return Promise.resolve([theData]); + }, + doUpdateByAssociations(associations: Array, d: object, upsert: boolean, appId: string): Promise { + return Promise.resolve('id4'); + }, + } as PersistenceBridge; + mockAssoc = new RocketChatAssociationRecord(RocketChatAssociationModel.USER, 'fake-id'); + }); + + afterEach(() => { + mock.restoreAll(); + }); + + it('usePersistenceAccessor', async () => { + assert.doesNotThrow(() => new Persistence(mockPersisBridge, mockAppId)); + + const sp1 = mock.method(mockPersisBridge, 'doCreate'); + const sp2 = mock.method(mockPersisBridge, 'doCreateWithAssociations'); + const sp3 = mock.method(mockPersisBridge, 'doUpdate'); + const sp4 = mock.method(mockPersisBridge, 'doRemove'); + const sp5 = mock.method(mockPersisBridge, 'doRemoveByAssociations'); + const sp6 = mock.method(mockPersisBridge, 'doUpdateByAssociations'); + + const ps = new Persistence(mockPersisBridge, mockAppId); + + assert.strictEqual(await ps.create(data), 'id'); + assert.strictEqual(sp1.mock.calls.length, 1); + assert.deepStrictEqual(sp1.mock.calls[0].arguments, [data, mockAppId]); + + assert.strictEqual(await ps.createWithAssociation(data, mockAssoc), 'id2'); + assert.strictEqual(await ps.createWithAssociations(data, [mockAssoc]), 'id2'); + assert.strictEqual(sp2.mock.calls.length, 2); + + assert.strictEqual(await ps.update('id', data), 'id3'); + assert.strictEqual(sp3.mock.calls.length, 1); + assert.deepStrictEqual(sp3.mock.calls[0].arguments, ['id', data, false, mockAppId]); + + assert.deepStrictEqual(await ps.remove('id'), data); + assert.strictEqual(sp4.mock.calls.length, 1); + assert.deepStrictEqual(sp4.mock.calls[0].arguments, ['id', mockAppId]); + + assert.ok((await ps.removeByAssociation(mockAssoc)) !== undefined); + assert.ok((await ps.removeByAssociations([mockAssoc])) !== undefined); + assert.strictEqual(sp5.mock.calls.length, 2); + + assert.ok((await ps.updateByAssociation(mockAssoc, data)) !== undefined); + assert.ok((await ps.updateByAssociations([mockAssoc], data)) !== undefined); + assert.strictEqual(sp6.mock.calls.length, 2); + }); +}); diff --git a/packages/apps/tests/server/accessors/PersistenceRead.test.ts b/packages/apps/tests/server/accessors/PersistenceRead.test.ts new file mode 100644 index 0000000000000..865b19abdfa17 --- /dev/null +++ b/packages/apps/tests/server/accessors/PersistenceRead.test.ts @@ -0,0 +1,27 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { RocketChatAssociationRecord } from '@rocket.chat/apps-engine/definition/metadata'; + +import { PersistenceRead } from '../../../src/server/accessors'; +import type { PersistenceBridge } from '../../../src/server/bridges'; + +describe('PersistenceRead', () => { + it('usePersistenceRead', async () => { + const mockPersisBridge = { + doReadById(id: string, appId: string): Promise { + return Promise.resolve({ id, appId }); + }, + doReadByAssociations(assocs: Array, appId: string): Promise> { + return Promise.resolve([{ appId }]); + }, + } as PersistenceBridge; + + assert.doesNotThrow(() => new PersistenceRead(mockPersisBridge, 'testing')); + + const pr = new PersistenceRead(mockPersisBridge, 'testing'); + assert.deepStrictEqual(await pr.read('thing'), { id: 'thing', appId: 'testing' }); + assert.deepStrictEqual(await pr.readByAssociation({} as RocketChatAssociationRecord), [{ appId: 'testing' }]); + assert.deepStrictEqual(await pr.readByAssociations([{} as RocketChatAssociationRecord]), [{ appId: 'testing' }]); + }); +}); diff --git a/packages/apps/tests/server/accessors/Reader.test.ts b/packages/apps/tests/server/accessors/Reader.test.ts new file mode 100644 index 0000000000000..cbfdf00256fab --- /dev/null +++ b/packages/apps/tests/server/accessors/Reader.test.ts @@ -0,0 +1,59 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { + ICloudWorkspaceRead, + IEnvironmentRead, + IExperimentalRead, + ILivechatRead, + IMessageRead, + INotifier, + IPersistenceRead, + IRoleRead, + IRoomRead, + IUploadRead, + IUserRead, + IVideoConferenceRead, +} from '@rocket.chat/apps-engine/definition/accessors'; +import type { IContactRead } from '@rocket.chat/apps-engine/definition/accessors/IContactRead'; +import type { IOAuthAppsReader } from '@rocket.chat/apps-engine/definition/accessors/IOAuthAppsReader'; +import type { IThreadRead } from '@rocket.chat/apps-engine/definition/accessors/IThreadRead'; + +import { Reader } from '../../../src/server/accessors'; + +describe('Reader', () => { + const env = {} as IEnvironmentRead; + const msg = {} as IMessageRead; + const pr = {} as IPersistenceRead; + const rm = {} as IRoomRead; + const ur = {} as IUserRead; + const ni = {} as INotifier; + const livechat = {} as ILivechatRead; + const upload = {} as IUploadRead; + const cloud = {} as ICloudWorkspaceRead; + const videoConf = {} as IVideoConferenceRead; + const oauthApps = {} as IOAuthAppsReader; + const thread = {} as IThreadRead; + const role = {} as IRoleRead; + const contact = {} as IContactRead; + const experimental = {} as IExperimentalRead; + + it('useReader', () => { + assert.doesNotThrow( + () => new Reader(env, msg, pr, rm, ur, ni, livechat, upload, cloud, videoConf, contact, oauthApps, thread, role, experimental), + ); + + const rd = new Reader(env, msg, pr, rm, ur, ni, livechat, upload, cloud, videoConf, contact, oauthApps, thread, role, experimental); + + assert.ok(rd.getEnvironmentReader() !== undefined); + assert.ok(rd.getMessageReader() !== undefined); + assert.ok(rd.getNotifier() !== undefined); + assert.ok(rd.getPersistenceReader() !== undefined); + assert.ok(rd.getRoomReader() !== undefined); + assert.ok(rd.getUserReader() !== undefined); + assert.ok(rd.getLivechatReader() !== undefined); + assert.ok(rd.getUploadReader() !== undefined); + assert.ok(rd.getVideoConferenceReader() !== undefined); + assert.ok(rd.getRoleReader() !== undefined); + }); +}); diff --git a/packages/apps/tests/server/accessors/RoomBuilder.test.ts b/packages/apps/tests/server/accessors/RoomBuilder.test.ts new file mode 100644 index 0000000000000..403dd97a86e85 --- /dev/null +++ b/packages/apps/tests/server/accessors/RoomBuilder.test.ts @@ -0,0 +1,92 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; + +import { RoomBuilder } from '../../../src/server/accessors'; +import { TestData } from '../../test-data/utilities'; + +describe('RoomBuilder', () => { + it('basicRoomBuilder', () => { + assert.doesNotThrow(() => new RoomBuilder()); + assert.doesNotThrow(() => new RoomBuilder(TestData.getRoom())); + }); + + it('settingOnRoomBuilder', () => { + const rbOnce = new RoomBuilder(); + + // setData just replaces the passed in object, so let's treat it differently + assert.strictEqual(rbOnce.setData({ displayName: 'Testing Channel' } as IRoom), rbOnce); + assert.strictEqual((rbOnce as any).room.displayName, 'Testing Channel'); + + const room: IRoom = {} as IRoom; + const rb = new RoomBuilder(room); + assert.strictEqual(rb.setDisplayName('Just a Test'), rb); + assert.deepStrictEqual(room.displayName, 'Just a Test'); + assert.deepStrictEqual(rb.getDisplayName(), 'Just a Test'); + + assert.strictEqual(rb.setSlugifiedName('just_a_test'), rb); + assert.deepStrictEqual(room.slugifiedName, 'just_a_test'); + assert.deepStrictEqual(rb.getSlugifiedName(), 'just_a_test'); + + assert.strictEqual(rb.setType(RoomType.CHANNEL), rb); + assert.deepStrictEqual(room.type, RoomType.CHANNEL); + assert.deepStrictEqual(rb.getType(), RoomType.CHANNEL); + + const creator = TestData.getUser(); + + assert.strictEqual(rb.setCreator(creator), rb); + assert.deepStrictEqual(room.creator, creator); + assert.deepStrictEqual(rb.getCreator(), creator); + + assert.strictEqual(rb.addUsername('testing.username'), rb); + assert.strictEqual(room.usernames, undefined); + assert.ok(rb.getUsernames().length > 0); + assert.strictEqual(room.usernames, undefined); + assert.deepStrictEqual(rb.getUsernames()[0], 'testing.username'); + assert.strictEqual(rb.addUsername('another.username'), rb); + assert.strictEqual(room.usernames, undefined); + assert.strictEqual(rb.getUsernames().length, 2); + + assert.strictEqual(rb.setUsernames([]), rb); + assert.strictEqual(room.usernames, undefined); + assert.strictEqual(rb.getUsernames().length, 0); + + assert.strictEqual(rb.addMemberToBeAddedByUsername('testing.username'), rb); + assert.ok(rb.getMembersToBeAddedUsernames().length > 0); + assert.deepStrictEqual(rb.getMembersToBeAddedUsernames()[0], 'testing.username'); + assert.strictEqual(rb.addMemberToBeAddedByUsername('another.username'), rb); + assert.strictEqual(rb.getMembersToBeAddedUsernames().length, 2); + + assert.strictEqual(rb.setMembersToBeAddedByUsernames([]), rb); + assert.strictEqual(rb.getMembersToBeAddedUsernames().length, 0); + + assert.strictEqual(rb.setDefault(true), rb); + assert.ok(room.isDefault); + assert.ok(rb.getIsDefault()); + + assert.strictEqual(rb.setReadOnly(false), rb); + assert.ok(!room.isReadOnly); + assert.ok(!rb.getIsReadOnly()); + + assert.strictEqual(rb.setDisplayingOfSystemMessages(true), rb); + assert.ok(room.displaySystemMessages); + assert.ok(rb.getDisplayingOfSystemMessages()); + + assert.strictEqual(rb.addCustomField('thing', {}), rb); + assert.ok(Object.keys(room.customFields).length > 0); + assert.ok(Object.keys(rb.getCustomFields()).length > 0); + assert.ok(room.customFields.thing !== undefined); + assert.ok(rb.getCustomFields().thing !== undefined); + assert.strictEqual(rb.addCustomField('another', { thingy: 'two' }), rb); + assert.deepStrictEqual(room.customFields.another, { thingy: 'two' }); + assert.deepStrictEqual(rb.getCustomFields().another, { thingy: 'two' }); + + assert.strictEqual(rb.setCustomFields({}), rb); + assert.strictEqual(Object.keys(room.customFields).length, 0); + assert.strictEqual(Object.keys(rb.getCustomFields()).length, 0); + + assert.strictEqual(rb.getRoom(), room); + }); +}); diff --git a/packages/apps/tests/server/accessors/RoomExtender.test.ts b/packages/apps/tests/server/accessors/RoomExtender.test.ts new file mode 100644 index 0000000000000..099f7598a1b2f --- /dev/null +++ b/packages/apps/tests/server/accessors/RoomExtender.test.ts @@ -0,0 +1,47 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; + +import { RoomExtender } from '../../../src/server/accessors'; +import { TestData } from '../../test-data/utilities'; + +describe('RoomExtender', () => { + it('basicRoomExtender', () => { + assert.doesNotThrow(() => new RoomExtender({} as IRoom)); + assert.doesNotThrow(() => new RoomExtender(TestData.getRoom())); + }); + + it('usingRoomExtender', () => { + const room: IRoom = {} as IRoom; + const re = new RoomExtender(room); + + assert.ok(room.customFields === undefined); + assert.strictEqual(re.addCustomField('thing', 'value'), re); + assert.ok(room.customFields !== undefined); + assert.strictEqual(room.customFields.thing as any, 'value'); + assert.throws(() => re.addCustomField('thing', 'second'), { + name: 'Error', + message: 'The room already contains a custom field by the key: thing', + }); + assert.throws(() => re.addCustomField('thing.', 'second'), { + name: 'Error', + message: 'The given key contains a period, which is not allowed. Key: thing.', + }); + + assert.ok(room.usernames === undefined); + assert.strictEqual(re.addMember(TestData.getUser('theId', 'bradley')), re); + assert.ok(room.usernames === undefined); + assert.ok(re.getMembersBeingAdded() !== undefined); + assert.ok(re.getMembersBeingAdded().length > 0); + assert.ok(re.getMembersBeingAdded()[0] !== undefined); + assert.strictEqual(re.getMembersBeingAdded()[0].username, 'bradley'); + assert.throws(() => re.addMember(TestData.getUser('theSameUsername', 'bradley')), { + name: 'Error', + message: 'The user is already in the room.', + }); + + assert.notStrictEqual(re.getRoom(), room); + assert.deepStrictEqual(re.getRoom(), room); + }); +}); diff --git a/packages/apps/tests/server/accessors/RoomRead.test.ts b/packages/apps/tests/server/accessors/RoomRead.test.ts new file mode 100644 index 0000000000000..aa4b9b7b2b3f9 --- /dev/null +++ b/packages/apps/tests/server/accessors/RoomRead.test.ts @@ -0,0 +1,150 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { IMessageRaw } from '@rocket.chat/apps-engine/definition/messages'; +import type { IRoom, IRoomRaw } from '@rocket.chat/apps-engine/definition/rooms'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import { RoomRead } from '../../../src/server/accessors'; +import type { RoomBridge } from '../../../src/server/bridges'; +import { TestData } from '../../test-data/utilities'; + +describe('RoomRead', () => { + const room = TestData.getRoom(); + room.id = room.id || 'room-id'; + const user = TestData.getUser(); + const messages: IMessageRaw[] = ['507f1f77bcf86cd799439011', '507f191e810c19729de860ea'].map((id) => TestData.getMessageRaw(id)); + const unreadRoomId = messages[0].roomId; + const unreadUserId = messages[0].sender._id; + + const theRooms: IRoomRaw[] = [ + { + id: room.id, + slugifiedName: room.slugifiedName, + displayName: room.displayName, + type: room.type, + creator: { + _id: room.creator.id, + username: room.creator.username, + name: room.creator.name, + }, + }, + ]; + + const mockRoomBridgeWithRoom = { + doGetById(id: string, appId: string): Promise { + return Promise.resolve(room); + }, + doGetByName(name: string, appId: string): Promise { + return Promise.resolve(room); + }, + doGetCreatorById(id: string, appId: string): Promise { + return Promise.resolve(user); + }, + doGetCreatorByName(name: string, appId: string): Promise { + return Promise.resolve(user); + }, + doGetDirectByUsernames(usernames: Array, appId: string): Promise { + return Promise.resolve(room); + }, + doGetMembers(name: string, appId: string): Promise> { + return Promise.resolve([user]); + }, + doGetAllRooms(filter: any, appId: string): Promise> { + return Promise.resolve(theRooms); + }, + doGetMessages(roomId: string, options: any, appId: string): Promise { + return Promise.resolve(messages); + }, + doGetUnreadByUser(roomId: string, uid: string, options: any, appId: string): Promise { + if (roomId === unreadRoomId && uid === unreadUserId) { + return Promise.resolve(messages); + } + return Promise.resolve([]); + }, + } as unknown as RoomBridge; + + it('expectDataFromRoomRead', async () => { + assert.doesNotThrow(() => new RoomRead(mockRoomBridgeWithRoom, 'testing-app')); + + const rr = new RoomRead(mockRoomBridgeWithRoom, 'testing-app'); + + assert.ok((await rr.getById('fake')) !== undefined); + assert.strictEqual(await rr.getById('fake'), room); + assert.ok((await rr.getByName('testing-room')) !== undefined); + assert.strictEqual(await rr.getByName('testing-room'), room); + assert.ok((await rr.getCreatorUserById('testing')) !== undefined); + assert.strictEqual(await rr.getCreatorUserById('testing'), user); + assert.ok((await rr.getCreatorUserByName('testing')) !== undefined); + assert.strictEqual(await rr.getCreatorUserByName('testing'), user); + assert.ok((await rr.getDirectByUsernames([user.username])) !== undefined); + assert.strictEqual(await rr.getDirectByUsernames([user.username]), room); + assert.ok((await rr.getMessages('testing')) !== undefined); + assert.strictEqual(await rr.getMessages('testing'), messages); + assert.ok((await rr.getAllRooms()) !== undefined); + assert.deepStrictEqual(await rr.getAllRooms(), [ + { + id: room.id, + slugifiedName: room.slugifiedName, + displayName: room.displayName, + type: room.type, + creator: { + _id: room.creator.id, + username: room.creator.username, + name: room.creator.name, + }, + }, + ]); + assert.ok((await rr.getUnreadByUser(unreadRoomId, unreadUserId)) !== undefined); + assert.deepStrictEqual(await rr.getUnreadByUser(unreadRoomId, unreadUserId), messages); + + assert.ok((await rr.getUnreadByUser('fake', 'fake')) !== undefined); + assert.deepStrictEqual(await rr.getUnreadByUser('fake', 'fake'), []); + }); + + it('useTheIterators', async () => { + assert.doesNotThrow(() => new RoomRead(mockRoomBridgeWithRoom, 'testing-app')); + + const rr = new RoomRead(mockRoomBridgeWithRoom, 'testing-app'); + + assert.ok((await rr.getMembers('testing')) !== undefined); + assert.ok(((await rr.getMembers('testing')) as Array).length > 0); + assert.strictEqual((await rr.getMembers('testing'))[0], user); + }); + + it('validateGetAllRoomsEdgeCases', async () => { + const rr = new RoomRead(mockRoomBridgeWithRoom, 'testing-app'); + + // Test negative limit + await assert.rejects(async () => rr.getAllRooms({}, { limit: -1 })); + await assert.rejects(async () => rr.getAllRooms({}, { limit: -100 })); + + // Test zero limit + await assert.rejects(async () => rr.getAllRooms({}, { limit: 0 })); + // Test non-finite limit values + await assert.rejects(async () => rr.getAllRooms({}, { limit: NaN })); + await assert.rejects(async () => rr.getAllRooms({}, { limit: Infinity })); + await assert.rejects(async () => rr.getAllRooms({}, { limit: -Infinity })); + + // Test limit > 100 (existing test case) + await assert.rejects(async () => rr.getAllRooms({}, { limit: 101 })); + await assert.rejects(async () => rr.getAllRooms({}, { limit: 200 })); + + // Test negative skip values + await assert.rejects(async () => rr.getAllRooms({}, { skip: -1 })); + await assert.rejects(async () => rr.getAllRooms({}, { skip: -100 })); + + // Test non-finite skip values + await assert.rejects(async () => rr.getAllRooms({}, { skip: NaN })); + await assert.rejects(async () => rr.getAllRooms({}, { skip: Infinity })); + await assert.rejects(async () => rr.getAllRooms({}, { skip: -Infinity })); + + // Test valid calls to ensure validation doesn't break normal behavior + await assert.doesNotReject(async () => rr.getAllRooms({}, { limit: 1 })); + await assert.doesNotReject(async () => rr.getAllRooms({}, { limit: 50 })); + await assert.doesNotReject(async () => rr.getAllRooms({}, { limit: 100 })); + await assert.doesNotReject(async () => rr.getAllRooms({}, { skip: 0 })); + await assert.doesNotReject(async () => rr.getAllRooms({}, { skip: 10 })); + await assert.doesNotReject(async () => rr.getAllRooms({}, { limit: 50, skip: 10 })); + }); +}); diff --git a/packages/apps/tests/server/accessors/ServerSettingRead.test.ts b/packages/apps/tests/server/accessors/ServerSettingRead.test.ts new file mode 100644 index 0000000000000..e35e089803943 --- /dev/null +++ b/packages/apps/tests/server/accessors/ServerSettingRead.test.ts @@ -0,0 +1,41 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import { ServerSettingRead } from '../../../src/server/accessors'; +import type { ServerSettingBridge } from '../../../src/server/bridges'; +import { TestData } from '../../test-data/utilities'; + +describe('ServerSettingRead', () => { + it('expectDataFromServerSettingRead', async () => { + const setting = TestData.getSetting('testing'); + + const theSetting = setting; + const mockServerSettingBridge = { + doGetOneById(id: string, appId: string) { + return Promise.resolve(id === 'testing' ? theSetting : undefined); + }, + doIsReadableById(id: string, appId: string): Promise { + return Promise.resolve(true); + }, + } as ServerSettingBridge; + + assert.doesNotThrow(() => new ServerSettingRead(mockServerSettingBridge, 'testing-app')); + + const ssr = new ServerSettingRead(mockServerSettingBridge, 'testing-app'); + + assert.ok((await ssr.getOneById('testing')) !== undefined); + assert.deepStrictEqual(await ssr.getOneById('testing'), setting); + assert.deepStrictEqual(await ssr.getValueById('testing'), setting.packageValue); + setting.value = 'theValue'; + assert.strictEqual(await ssr.getValueById('testing'), 'theValue'); + await assert.rejects(async () => ssr.getValueById('fake'), { + name: 'Error', + message: 'No Server Setting found, or it is unaccessible, by the id of "fake".', + }); + assert.throws(() => ssr.getAll(), { + name: 'Error', + message: 'Method not implemented.', + }); + assert.strictEqual(await ssr.isReadableById('testing'), true); + }); +}); diff --git a/packages/apps/tests/server/accessors/ServerSettingsModify.test.ts b/packages/apps/tests/server/accessors/ServerSettingsModify.test.ts new file mode 100644 index 0000000000000..736751265acfc --- /dev/null +++ b/packages/apps/tests/server/accessors/ServerSettingsModify.test.ts @@ -0,0 +1,54 @@ +import * as assert from 'node:assert'; +import { describe, it, mock } from 'node:test'; + +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; + +import { ServerSettingsModify } from '../../../src/server/accessors'; +import type { ServerSettingBridge } from '../../../src/server/bridges'; +import { TestData } from '../../test-data/utilities'; + +describe('ServerSettingsModify', () => { + it('useServerSettingsModify', async () => { + const setting = TestData.getSetting(); + const mockAppId = 'testing-app'; + const mockServerSettingBridge = { + doHideGroup(name: string, appId: string): Promise { + return Promise.resolve(); + }, + doHideSetting(id: string, appId: string): Promise { + return Promise.resolve(); + }, + doUpdateOne(setting: ISetting, appId: string): Promise { + return Promise.resolve(); + }, + doIncrementValue(id: ISetting['id'], value: number, appId: string): Promise { + return Promise.resolve(); + }, + } as ServerSettingBridge; + + assert.doesNotThrow(() => new ServerSettingsModify(mockServerSettingBridge, mockAppId)); + + const hideGroupSpy = mock.method(mockServerSettingBridge, 'doHideGroup'); + const hideSettingSpy = mock.method(mockServerSettingBridge, 'doHideSetting'); + const updateOneSpy = mock.method(mockServerSettingBridge, 'doUpdateOne'); + const incrementValueSpy = mock.method(mockServerSettingBridge, 'doIncrementValue'); + + const ssm = new ServerSettingsModify(mockServerSettingBridge, mockAppId); + + assert.ok((await ssm.hideGroup('api')) === undefined); + assert.strictEqual(hideGroupSpy.mock.calls.length, 1); + assert.deepStrictEqual(hideGroupSpy.mock.calls[0].arguments, ['api', mockAppId]); + + assert.ok((await ssm.hideSetting('api')) === undefined); + assert.strictEqual(hideSettingSpy.mock.calls.length, 1); + assert.deepStrictEqual(hideSettingSpy.mock.calls[0].arguments, ['api', mockAppId]); + + assert.ok((await ssm.modifySetting(setting)) === undefined); + assert.strictEqual(updateOneSpy.mock.calls.length, 1); + assert.deepStrictEqual(updateOneSpy.mock.calls[0].arguments, [setting, mockAppId]); + + assert.ok((await ssm.incrementValue(setting.id, 5)) === undefined); + assert.strictEqual(incrementValueSpy.mock.calls.length, 1); + assert.deepStrictEqual(incrementValueSpy.mock.calls[0].arguments, [setting.id, 5, mockAppId]); + }); +}); diff --git a/packages/apps/tests/server/accessors/SettingRead.test.ts b/packages/apps/tests/server/accessors/SettingRead.test.ts new file mode 100644 index 0000000000000..4d222cd8a3533 --- /dev/null +++ b/packages/apps/tests/server/accessors/SettingRead.test.ts @@ -0,0 +1,36 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { ProxiedApp } from '../../../src/server/ProxiedApp'; +import { SettingRead } from '../../../src/server/accessors'; +import type { IAppStorageItem } from '../../../src/server/storage'; +import { TestData } from '../../test-data/utilities'; + +describe('SettingRead', () => { + it('appSettingRead', async () => { + const mockStorageItem = { + settings: {}, + } as IAppStorageItem; + mockStorageItem.settings.testing = TestData.getSetting('testing'); + + const si = mockStorageItem; + const mockProxiedApp = { + getStorageItem(): IAppStorageItem { + return si; + }, + } as ProxiedApp; + + assert.doesNotThrow(() => new SettingRead({} as ProxiedApp)); + + const sr = new SettingRead(mockProxiedApp); + assert.ok((await sr.getById('testing')) !== undefined); + assert.deepStrictEqual(await sr.getById('testing'), TestData.getSetting('testing')); + assert.strictEqual(await sr.getValueById('testing'), 'The packageValue'); + mockStorageItem.settings.testing.value = 'my value'; + assert.strictEqual(await sr.getValueById('testing'), 'my value'); + await assert.rejects(() => sr.getValueById('superfake'), { + name: 'Error', + message: 'Setting "superfake" does not exist.', + }); + }); +}); diff --git a/packages/apps/tests/server/accessors/SettingUpdater.test.ts b/packages/apps/tests/server/accessors/SettingUpdater.test.ts new file mode 100644 index 0000000000000..136e8b1c15c0d --- /dev/null +++ b/packages/apps/tests/server/accessors/SettingUpdater.test.ts @@ -0,0 +1,104 @@ +import * as assert from 'node:assert'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; + +import type { ProxiedApp } from '../../../src/server/ProxiedApp'; +import { SettingUpdater } from '../../../src/server/accessors'; +import type { AppSettingsManager } from '../../../src/server/managers'; +import type { IAppStorageItem } from '../../../src/server/storage'; +import { TestData } from '../../test-data/utilities'; + +describe('SettingUpdater', () => { + let mockStorageItem: IAppStorageItem; + let mockProxiedApp: ProxiedApp; + let mockSettingsManager: AppSettingsManager; + + beforeEach(() => { + // Set up mock storage with test settings + mockStorageItem = { + settings: {}, + } as IAppStorageItem; + + mockStorageItem.settings.singleValue = TestData.getSetting('singleValue'); + mockStorageItem.settings.multiValue = { + ...TestData.getSetting('multiValue'), + values: [ + { key: 'key1', i18nLabel: 'value1' }, + { key: 'key2', i18nLabel: 'value2' }, + ], + }; + + // Mock ProxiedApp + const si = mockStorageItem; + mockProxiedApp = { + getStorageItem(): IAppStorageItem { + return si; + }, + getID(): string { + return 'test-app-id'; + }, + } as ProxiedApp; + + // Mock AppSettingsManager + mockSettingsManager = {} as AppSettingsManager; + mockSettingsManager.getAppSetting = (appId: string, settingId: string) => { + return mockStorageItem.settings[settingId]; + }; + mockSettingsManager.updateAppSetting = (appId: string, setting: any) => { + mockStorageItem.settings[setting.id] = setting; + return Promise.resolve(); + }; + }); + + afterEach(() => { + mock.restoreAll(); + }); + + it('updateValueSuccessfully', async () => { + const updateAppSettingSpy = mock.method(mockSettingsManager, 'updateAppSetting'); + const settingUpdater = new SettingUpdater(mockProxiedApp, mockSettingsManager); + + await settingUpdater.updateValue('singleValue', 'updated value'); + + assert.ok(updateAppSettingSpy.mock.calls.length > 0); + assert.strictEqual(mockStorageItem.settings.singleValue.value, 'updated value'); + // Verify updatedAt was set + assert.ok(mockStorageItem.settings.singleValue.updatedAt !== undefined); + }); + + it('updateValueThrowsErrorForNonExistentSetting', async () => { + const settingUpdater = new SettingUpdater(mockProxiedApp, mockSettingsManager); + + await assert.rejects(() => settingUpdater.updateValue('nonExistent', 'value'), { + name: 'Error', + message: 'Setting "nonExistent" not found for app test-app-id', + }); + }); + + it('updateSelectOptionsSuccessfully', async () => { + const updateAppSettingSpy = mock.method(mockSettingsManager, 'updateAppSetting'); + const settingUpdater = new SettingUpdater(mockProxiedApp, mockSettingsManager); + const newValues = [ + { key: 'key3', i18nLabel: 'value3' }, + { key: 'key4', i18nLabel: 'value4' }, + ]; + + await settingUpdater.updateSelectOptions('multiValue', newValues); + + assert.ok(updateAppSettingSpy.mock.calls.length > 0); + const updatedValues = mockStorageItem.settings.multiValue.values; + // Should completely replace old values + assert.strictEqual((updatedValues ?? []).length, 2); + assert.deepStrictEqual(updatedValues, newValues); + // Verify updatedAt was set + assert.ok(mockStorageItem.settings.multiValue.updatedAt !== undefined); + }); + + it('updateSelectOptionsThrowsErrorForNonExistentSetting', async () => { + const settingUpdater = new SettingUpdater(mockProxiedApp, mockSettingsManager); + + await assert.rejects(() => settingUpdater.updateSelectOptions('nonExistent', [{ key: 'test', i18nLabel: 'value' }]), { + name: 'Error', + message: 'Setting "nonExistent" not found for app test-app-id', + }); + }); +}); diff --git a/packages/apps/tests/server/accessors/SettingsExtend.test.ts b/packages/apps/tests/server/accessors/SettingsExtend.test.ts new file mode 100644 index 0000000000000..944ad5ed8cfc0 --- /dev/null +++ b/packages/apps/tests/server/accessors/SettingsExtend.test.ts @@ -0,0 +1,54 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; +import { SettingType } from '@rocket.chat/apps-engine/definition/settings'; + +import type { ProxiedApp } from '../../../src/server/ProxiedApp'; +import { SettingsExtend } from '../../../src/server/accessors'; +import type { IAppStorageItem } from '../../../src/server/storage'; + +describe('SettingsExtend', () => { + it('basicSettingsExtend', () => { + assert.doesNotThrow(() => new SettingsExtend({} as ProxiedApp)); + }); + + it('provideSettingToSettingsExtend', async () => { + const mockedStorageItem: IAppStorageItem = { + settings: {}, + } as IAppStorageItem; + + const mockedApp: ProxiedApp = { + getStorageItem: function _getStorageItem() { + return mockedStorageItem; + }, + } as ProxiedApp; + const se = new SettingsExtend(mockedApp); + + const setting: ISetting = { + id: 'testing', + type: SettingType.STRING, + packageValue: 'thing', + required: false, + public: false, + i18nLabel: 'Testing_Settings', + }; + + await assert.doesNotReject(() => se.provideSetting(setting)); + assert.ok(Object.keys(mockedStorageItem.settings).length > 0); + + const settingModified: ISetting = { + id: 'testing', + type: SettingType.STRING, + packageValue: 'thing', + required: false, + public: false, + i18nLabel: 'Testing_Thing', + value: 'dont-use-me', + }; + await assert.doesNotReject(() => se.provideSetting(settingModified)); + assert.ok(mockedStorageItem.settings.testing !== undefined); + assert.ok(mockedStorageItem.settings.testing.value === undefined); + assert.strictEqual(mockedStorageItem.settings.testing.i18nLabel, 'Testing_Thing'); + }); +}); diff --git a/packages/apps/tests/server/accessors/SlashCommandsExtend.test.ts b/packages/apps/tests/server/accessors/SlashCommandsExtend.test.ts new file mode 100644 index 0000000000000..fec22df9093c9 --- /dev/null +++ b/packages/apps/tests/server/accessors/SlashCommandsExtend.test.ts @@ -0,0 +1,47 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { ISlashCommand } from '@rocket.chat/apps-engine/definition/slashcommands'; + +import { SlashCommandsExtend } from '../../../src/server/accessors'; +import { CommandAlreadyExistsError } from '../../../src/server/errors'; +import type { AppSlashCommandManager } from '../../../src/server/managers'; + +describe('SlashCommandsExtend', () => { + it('basicSlashCommandsExtend', () => { + assert.doesNotThrow(() => new SlashCommandsExtend({} as AppSlashCommandManager, 'testing')); + }); + + it('provideCommandToCommandsExtend', async () => { + const commands = new Map>(); + const mockManager: AppSlashCommandManager = { + addCommand(appId: string, command: ISlashCommand) { + if (commands.has(appId)) { + const cmds = commands.get(appId); + if (cmds.find((v) => v.command === command.command)) { + throw new CommandAlreadyExistsError(command.command); + } + + cmds.push(command); + return; + } + + commands.set(appId, Array.from([command])); + }, + } as AppSlashCommandManager; + + const se = new SlashCommandsExtend(mockManager, 'testing'); + + const mockCommand: ISlashCommand = { + command: 'mock', + i18nDescription: 'Thing', + } as ISlashCommand; + + await assert.doesNotReject(() => se.provideSlashCommand(mockCommand)); + assert.strictEqual(commands.size, 1); + await assert.rejects(() => se.provideSlashCommand(mockCommand), { + name: 'CommandAlreadyExists', + message: 'The command "mock" already exists in the system.', + }); + }); +}); diff --git a/packages/apps/tests/server/accessors/SlashCommandsModify.test.ts b/packages/apps/tests/server/accessors/SlashCommandsModify.test.ts new file mode 100644 index 0000000000000..7202f688a3cdf --- /dev/null +++ b/packages/apps/tests/server/accessors/SlashCommandsModify.test.ts @@ -0,0 +1,40 @@ +import * as assert from 'node:assert'; +import { describe, it, mock } from 'node:test'; + +import type { ISlashCommand } from '@rocket.chat/apps-engine/definition/slashcommands'; + +import { SlashCommandsModify } from '../../../src/server/accessors'; +import type { AppSlashCommandManager } from '../../../src/server/managers'; +import { TestData } from '../../test-data/utilities'; + +describe('SlashCommandsModify', () => { + it('useSlashCommandsModify', async () => { + const cmd = TestData.getSlashCommand(); + const mockAppId = 'testing-app'; + const mockCmdManager = { + modifyCommand(appId: string, command: ISlashCommand): void {}, + disableCommand(appId: string, command: string): void {}, + enableCommand(appId: string, command: string): void {}, + } as AppSlashCommandManager; + + assert.doesNotThrow(() => new SlashCommandsModify(mockCmdManager, mockAppId)); + + const modifySpy = mock.method(mockCmdManager, 'modifyCommand'); + const disableSpy = mock.method(mockCmdManager, 'disableCommand'); + const enableSpy = mock.method(mockCmdManager, 'enableCommand'); + + const scm = new SlashCommandsModify(mockCmdManager, mockAppId); + + assert.ok((await scm.modifySlashCommand(cmd)) === undefined); + assert.strictEqual(modifySpy.mock.calls.length, 1); + assert.deepStrictEqual(modifySpy.mock.calls[0].arguments, [mockAppId, cmd]); + + assert.ok((await scm.disableSlashCommand('testing-cmd')) === undefined); + assert.strictEqual(disableSpy.mock.calls.length, 1); + assert.deepStrictEqual(disableSpy.mock.calls[0].arguments, [mockAppId, 'testing-cmd']); + + assert.ok((await scm.enableSlashCommand('testing-cmd')) === undefined); + assert.strictEqual(enableSpy.mock.calls.length, 1); + assert.deepStrictEqual(enableSpy.mock.calls[0].arguments, [mockAppId, 'testing-cmd']); + }); +}); diff --git a/packages/apps/tests/server/accessors/UserBuilder.test.ts b/packages/apps/tests/server/accessors/UserBuilder.test.ts new file mode 100644 index 0000000000000..028fff0d7f802 --- /dev/null +++ b/packages/apps/tests/server/accessors/UserBuilder.test.ts @@ -0,0 +1,59 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { IUser, IUserEmail } from '@rocket.chat/apps-engine/definition/users'; + +import { UserBuilder } from '../../../src/server/accessors'; + +describe('UserBuilder', () => { + it('basicUserBuilder', () => { + assert.doesNotThrow(() => new UserBuilder()); + }); + + it('settingOnUserBuilder', () => { + const ubOnce = new UserBuilder(); + assert.strictEqual(ubOnce.setData({ name: 'Test User', email: 'testuser@gmail.com', username: 'testuser' } as Partial), ubOnce); + assert.strictEqual((ubOnce as any).user.name, 'Test User'); + assert.strictEqual((ubOnce as any).user.username, 'testuser'); + assert.strictEqual((ubOnce as any).user.email, 'testuser@gmail.com'); + + const user: Partial = {} as Partial; + const ub = new UserBuilder(user); + + assert.strictEqual( + ub.setEmails([ + { + address: 'testuser@gmail.com', + verified: false, + } as IUserEmail, + ]), + ub, + ); + assert.deepStrictEqual(user.emails, [ + { + address: 'testuser@gmail.com', + verified: false, + } as IUserEmail, + ]); + assert.deepStrictEqual(ub.getEmails(), [ + { + address: 'testuser@gmail.com', + verified: false, + } as IUserEmail, + ]); + + assert.strictEqual(ub.setDisplayName('Test User'), ub); + assert.deepStrictEqual(user.name, 'Test User'); + assert.deepStrictEqual(ub.getDisplayName(), 'Test User'); + + assert.strictEqual(ub.setUsername('testuser'), ub); + assert.deepStrictEqual(user.username, 'testuser'); + assert.deepStrictEqual(ub.getUsername(), 'testuser'); + + assert.strictEqual(ub.setRoles(['bot']), ub); + assert.deepStrictEqual(user.roles, ['bot']); + assert.deepStrictEqual(ub.getRoles(), ['bot']); + + assert.strictEqual(ub.getUser(), user); + }); +}); diff --git a/packages/apps/tests/server/accessors/UserRead.test.ts b/packages/apps/tests/server/accessors/UserRead.test.ts new file mode 100644 index 0000000000000..276e293fa5179 --- /dev/null +++ b/packages/apps/tests/server/accessors/UserRead.test.ts @@ -0,0 +1,46 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import { UserRead } from '../../../src/server/accessors'; +import type { UserBridge } from '../../../src/server/bridges'; +import { TestData } from '../../test-data/utilities'; + +describe('UserRead', () => { + const user = TestData.getUser(); + const roomIds = ['room-1', 'room-2']; + const mockAppId = 'test-appId'; + + const mockUserBridge = { + doGetById(id: string, appId: string): Promise { + return Promise.resolve(user); + }, + doGetByUsername(id: string, appId: string): Promise { + return Promise.resolve(user); + }, + doGetAppUser(appId?: string): Promise { + return Promise.resolve(user); + }, + doGetUserRoomIds(userId: string): Promise> { + return Promise.resolve(roomIds); + }, + } as unknown as UserBridge; + + it('expectDataFromUserRead', async () => { + assert.doesNotThrow(() => new UserRead(mockUserBridge, 'testing-app')); + + const ur = new UserRead(mockUserBridge, 'testing-app'); + + assert.ok((await ur.getById('fake')) !== undefined); + assert.deepStrictEqual(await ur.getById('fake'), user); + + assert.ok((await ur.getByUsername('username')) !== undefined); + assert.deepStrictEqual(await ur.getByUsername('username'), user); + + assert.ok((await ur.getAppUser(mockAppId)) !== undefined); + assert.deepStrictEqual(await ur.getAppUser(mockAppId), user); + assert.deepStrictEqual(await ur.getAppUser(), user); + assert.deepStrictEqual(await ur.getUserRoomIds(user.id), roomIds); + }); +}); diff --git a/packages/apps/tests/server/accessors/VideoConfProviderExtend.test.ts b/packages/apps/tests/server/accessors/VideoConfProviderExtend.test.ts new file mode 100644 index 0000000000000..4a4077b7a8fb9 --- /dev/null +++ b/packages/apps/tests/server/accessors/VideoConfProviderExtend.test.ts @@ -0,0 +1,38 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { IVideoConfProvider } from '@rocket.chat/apps-engine/definition/videoConfProviders'; + +import { VideoConfProviderExtend } from '../../../src/server/accessors'; +import type { AppVideoConfProviderManager } from '../../../src/server/managers'; + +describe('VideoConfProviderExtend', () => { + it('basicVideoConfProviderExtend', () => { + assert.doesNotThrow(() => new VideoConfProviderExtend({} as AppVideoConfProviderManager, 'testing')); + }); + + it('provideProviderToVideoConfProviderExtend', async () => { + let providerAdded: IVideoConfProvider | undefined; + const mockManager: AppVideoConfProviderManager = { + addProvider(appId: string, provider: IVideoConfProvider) { + providerAdded = provider; + }, + } as AppVideoConfProviderManager; + + const se = new VideoConfProviderExtend(mockManager, 'testing'); + + const mockProvider: IVideoConfProvider = { + name: 'test', + + async generateUrl(): Promise { + return ''; + }, + async customizeUrl(): Promise { + return ''; + }, + } as IVideoConfProvider; + + await assert.doesNotReject(() => se.provideVideoConfProvider(mockProvider)); + assert.strictEqual(providerAdded, mockProvider); + }); +}); diff --git a/packages/apps/tests/server/accessors/VideoConferenceBuilder.test.ts b/packages/apps/tests/server/accessors/VideoConferenceBuilder.test.ts new file mode 100644 index 0000000000000..1d8bb9f8323ac --- /dev/null +++ b/packages/apps/tests/server/accessors/VideoConferenceBuilder.test.ts @@ -0,0 +1,101 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { AppVideoConference } from '@rocket.chat/apps-engine/definition/videoConferences'; + +import { VideoConferenceBuilder } from '../../../src/server/accessors'; +import { TestData } from '../../test-data/utilities'; + +describe('VideoConferenceBuilder', () => { + it('basicVideoConferenceBuilderBuilder', () => { + assert.doesNotThrow(() => new VideoConferenceBuilder()); + assert.doesNotThrow(() => new VideoConferenceBuilder(TestData.getAppVideoConference())); + }); + + it('setData', () => { + const builder = new VideoConferenceBuilder(); + + assert.strictEqual(builder.setData({ providerName: 'test-provider' } as AppVideoConference), builder); + assert.strictEqual(((builder as any).call as AppVideoConference).providerName, 'test-provider'); + }); + + it('setRoomId', () => { + const call = {} as AppVideoConference; + const builder = new VideoConferenceBuilder(call); + + assert.strictEqual(builder.setRoomId('roomId'), builder); + assert.strictEqual(call.rid, 'roomId'); + assert.strictEqual(builder.getRoomId(), 'roomId'); + + assert.strictEqual(builder.getVideoConference(), call); + }); + + it('setCreatedBy', () => { + const call = {} as AppVideoConference; + const builder = new VideoConferenceBuilder(call); + + assert.strictEqual(builder.setCreatedBy('userId'), builder); + assert.strictEqual(call.createdBy, 'userId'); + assert.strictEqual(builder.getCreatedBy(), 'userId'); + + assert.strictEqual(builder.getVideoConference(), call); + }); + + it('setProviderName', () => { + const call = {} as AppVideoConference; + const builder = new VideoConferenceBuilder(call); + + assert.strictEqual(builder.setProviderName('test'), builder); + assert.strictEqual(call.providerName, 'test'); + assert.strictEqual(builder.getProviderName(), 'test'); + + assert.strictEqual(builder.getVideoConference(), call); + }); + + it('setProviderData', () => { + const call = {} as AppVideoConference; + const builder = new VideoConferenceBuilder(call); + + assert.strictEqual(builder.setProviderData({ custom: true }), builder); + assert.deepStrictEqual(call.providerData, { custom: true }); + assert.deepStrictEqual(builder.getProviderData(), { custom: true }); + + assert.strictEqual(builder.getVideoConference(), call); + }); + + it('setTitle', () => { + const call = {} as AppVideoConference; + const builder = new VideoConferenceBuilder(call); + + assert.strictEqual(builder.setTitle('Video Conference'), builder); + assert.strictEqual(call.title, 'Video Conference'); + assert.strictEqual(builder.getTitle(), 'Video Conference'); + + assert.strictEqual(builder.getVideoConference(), call); + }); + + it('setDiscussionRid', () => { + const call = {} as AppVideoConference; + const builder = new VideoConferenceBuilder(call); + + assert.strictEqual(builder.setDiscussionRid('testId'), builder); + assert.strictEqual(call.discussionRid, 'testId'); + assert.strictEqual(builder.getDiscussionRid(), 'testId'); + + assert.strictEqual(builder.getVideoConference(), call); + }); + + it('initialData', () => { + const call = { providerName: 'test' } as AppVideoConference; + const builder = new VideoConferenceBuilder(call); + + assert.strictEqual(call.providerName, 'test'); + assert.strictEqual(builder.getProviderName(), 'test'); + + assert.strictEqual(builder.setProviderName('test2'), builder); + assert.strictEqual(call.providerName, 'test2'); + assert.strictEqual(builder.getProviderName(), 'test2'); + + assert.strictEqual(builder.getVideoConference(), call); + }); +}); diff --git a/packages/apps/tests/server/accessors/VideoConferenceExtend.test.ts b/packages/apps/tests/server/accessors/VideoConferenceExtend.test.ts new file mode 100644 index 0000000000000..20e317c2ae6d5 --- /dev/null +++ b/packages/apps/tests/server/accessors/VideoConferenceExtend.test.ts @@ -0,0 +1,76 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { VideoConference } from '@rocket.chat/apps-engine/definition/videoConferences'; + +import { VideoConferenceExtender } from '../../../src/server/accessors'; +import { TestData } from '../../test-data/utilities'; + +describe('VideoConferenceExtend', () => { + it('basicVideoConferenceExtend', () => { + assert.doesNotThrow(() => new VideoConferenceExtender({} as VideoConference)); + assert.doesNotThrow(() => new VideoConferenceExtender(TestData.getVideoConference())); + }); + + it('setProviderData', () => { + const call = {} as VideoConference; + const extend = new VideoConferenceExtender(call); + + assert.strictEqual(call.providerData, undefined); + assert.strictEqual(extend.setProviderData({ key: 'test' }), extend); + assert.ok(call.providerData !== undefined); + assert.strictEqual(call.providerData.key, 'test'); + + assert.notStrictEqual(extend.getVideoConference(), call); + assert.deepStrictEqual(extend.getVideoConference(), call); + }); + + it('setStatus', () => { + const call = { status: 0 } as VideoConference; + const extend = new VideoConferenceExtender(call); + + assert.strictEqual(call.status, 0); + assert.strictEqual(extend.setStatus(1), extend); + assert.strictEqual(call.status, 1); + }); + + it('setEndedBy', () => { + const call = {} as VideoConference; + const extend = new VideoConferenceExtender(call); + + assert.strictEqual(call.endedBy, undefined); + assert.strictEqual(extend.setEndedBy('userId'), extend); + assert.ok(call.endedBy !== undefined); + assert.strictEqual(call.endedBy._id, 'userId'); + }); + + it('setEndedAt', () => { + const call = {} as VideoConference; + const extend = new VideoConferenceExtender(call); + + const date = new Date(); + + assert.strictEqual(call.endedAt, undefined); + assert.strictEqual(extend.setEndedAt(date), extend); + assert.strictEqual(call.endedAt, date); + }); + + it('setDiscussionRid', () => { + const call = {} as VideoConference; + const extend = new VideoConferenceExtender(call); + + assert.strictEqual(call.discussionRid, undefined); + assert.strictEqual(extend.setDiscussionRid('testId'), extend); + assert.strictEqual(call.discussionRid, 'testId'); + }); + + it('addUser', () => { + const call = { users: [] } as VideoConference; + const extend = new VideoConferenceExtender(call); + + assert.strictEqual(call.users.length, 0); + assert.strictEqual(extend.addUser('userId'), extend); + assert.ok(call.users.length > 0); + assert.strictEqual(call.users[0]._id, 'userId'); + }); +}); diff --git a/packages/apps/tests/server/accessors/VideoConferenceRead.test.ts b/packages/apps/tests/server/accessors/VideoConferenceRead.test.ts new file mode 100644 index 0000000000000..478e92a1df91b --- /dev/null +++ b/packages/apps/tests/server/accessors/VideoConferenceRead.test.ts @@ -0,0 +1,28 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { VideoConference } from '@rocket.chat/apps-engine/definition/videoConferences'; + +import { VideoConferenceRead } from '../../../src/server/accessors'; +import type { VideoConferenceBridge } from '../../../src/server/bridges'; +import { TestData } from '../../test-data/utilities'; + +describe('VideoConferenceRead', () => { + it('expectDataFromVideoConferenceRead', async () => { + const videoConference = TestData.getVideoConference(); + + const call = videoConference; + const mockVideoConfBridge = { + doGetById(id, appId): Promise { + return Promise.resolve(call); + }, + } as VideoConferenceBridge; + + assert.doesNotThrow(() => new VideoConferenceRead(mockVideoConfBridge, 'testing-app')); + + const read = new VideoConferenceRead(mockVideoConfBridge, 'testing-app'); + + assert.ok((await read.getById('fake')) !== undefined); + assert.strictEqual(await read.getById('fake'), videoConference); + }); +}); diff --git a/packages/apps/tests/server/compiler/AppCompiler.test.ts b/packages/apps/tests/server/compiler/AppCompiler.test.ts new file mode 100644 index 0000000000000..1ec89bc618c97 --- /dev/null +++ b/packages/apps/tests/server/compiler/AppCompiler.test.ts @@ -0,0 +1,24 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import { AppCompiler } from '../../../src/server/compiler'; + +describe('AppCompiler', () => { + it('verifyStorageFileToCompiler', () => { + const compiler = new AppCompiler(); + + assert.doesNotThrow(() => compiler.normalizeStorageFiles({})); + + const files: { [key: string]: string } = { + TestingApp$ts: 'act-like-this-is-real', + TestingAppCommand$ts: 'something-here-as well, yay', + }; + + const expected: { [key: string]: string } = { + 'TestingApp.ts': files.TestingApp$ts, + 'TestingAppCommand.ts': files.TestingAppCommand$ts, + }; + + assert.deepStrictEqual(compiler.normalizeStorageFiles(files), expected); + }); +}); diff --git a/packages/apps/tests/server/compiler/AppFabricationFulfillment.test.ts b/packages/apps/tests/server/compiler/AppFabricationFulfillment.test.ts new file mode 100644 index 0000000000000..38a61df542e91 --- /dev/null +++ b/packages/apps/tests/server/compiler/AppFabricationFulfillment.test.ts @@ -0,0 +1,129 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; +import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; +import { AppInterface } from '@rocket.chat/apps-engine/definition/metadata'; + +import type { AppManager } from '../../../src/server/AppManager'; +import { ProxiedApp } from '../../../src/server/ProxiedApp'; +import { AppFabricationFulfillment } from '../../../src/server/compiler'; +import { AppPermissions } from '../../../src/server/permissions/AppPermissions'; +import type { IAppStorageItem } from '../../../src/server/storage'; +import { TestData } from '../../test-data/utilities'; + +describe('AppFabricationFulfillment', () => { + it('appFabricationDefinement', () => { + const expctedInfo: IAppInfo = { + id: '614055e2-3dba-41fb-be48-c1ff146f5932', + name: 'Testing App', + nameSlug: 'testing-app', + description: 'A Rocket.Chat Application used to test out the various features.', + version: '0.0.8', + requiredApiVersion: '>=0.9.6', + author: { + name: 'Bradley Hilton', + homepage: 'https://github.com/RocketChat/Rocket.Chat.Apps-ts-definitions', + support: 'https://github.com/RocketChat/Rocket.Chat.Apps-ts-definitions/issues', + }, + classFile: 'TestingApp.ts', + iconFile: 'testing.jpg', + implements: [], + permissions: [], + }; + + assert.doesNotThrow(() => new AppFabricationFulfillment()); + + const aff = new AppFabricationFulfillment(); + assert.doesNotThrow(() => aff.setAppInfo(expctedInfo)); + assert.deepStrictEqual(aff.getAppInfo(), expctedInfo); + + const expectedInter: { [key: string]: boolean } = {}; + expectedInter[AppInterface.IPreMessageSentPrevent] = true; + assert.doesNotThrow(() => aff.setImplementedInterfaces(expectedInter)); + assert.deepStrictEqual(aff.getImplementedInferfaces(), expectedInter); + + const fakeApp = new ProxiedApp( + {} as AppManager, + { status: AppStatus.UNKNOWN } as IAppStorageItem, + TestData.getMockRuntimeController('unknown'), + ); + + assert.doesNotThrow(() => aff.setApp(fakeApp)); + assert.deepStrictEqual(aff.getApp(), fakeApp); + }); + + it('setAppInfoCreatesDeepClone', () => { + const originalInfo: IAppInfo = { + id: 'test-app-id', + name: 'Test App', + nameSlug: 'test-app', + description: 'A test application', + version: '1.0.0', + requiredApiVersion: '>=1.0.0', + author: { + name: 'Test Author', + homepage: 'https://example.com', + support: 'https://example.com/support', + }, + classFile: 'TestApp.ts', + iconFile: 'test.jpg', + implements: [AppInterface.IPreMessageSentPrevent], + permissions: [AppPermissions.user.read, AppPermissions.user.write], + }; + + const aff = new AppFabricationFulfillment(); + aff.setAppInfo(originalInfo); + + assert.deepStrictEqual(aff.getAppInfo(), originalInfo); + + originalInfo.name = 'Modified Name'; + originalInfo.author.name = 'Modified Author'; + originalInfo.implements.push(AppInterface.IPostMessageSent); + originalInfo.permissions.push(AppPermissions.message.write); + + assert.notStrictEqual(aff.getAppInfo().name, 'Modified Name'); + assert.notStrictEqual(aff.getAppInfo().author.name, 'Modified Author'); + assert.ok(!aff.getAppInfo().implements.includes(AppInterface.IPostMessageSent)); + assert.ok(!aff.getAppInfo().permissions.includes(AppPermissions.message.write)); + + assert.strictEqual(aff.getAppInfo().name, 'Test App'); + assert.strictEqual(aff.getAppInfo().author.name, 'Test Author'); + assert.deepStrictEqual(aff.getAppInfo().implements, [AppInterface.IPreMessageSentPrevent]); + assert.deepStrictEqual(aff.getAppInfo().permissions, [AppPermissions.user.read, AppPermissions.user.write]); + }); + + it('setImplementedInterfacesCreatesDeepClone', () => { + const originalInterfaces: { [int: string]: boolean } = { + [AppInterface.IPreMessageSentPrevent]: true, + [AppInterface.IPostMessageSent]: false, + [AppInterface.IPreMessageSentExtend]: true, + }; + + const aff = new AppFabricationFulfillment(); + aff.setImplementedInterfaces(originalInterfaces); + + assert.deepStrictEqual(aff.getImplementedInferfaces(), originalInterfaces); + + originalInterfaces[AppInterface.IPreMessageSentPrevent] = false; + originalInterfaces[AppInterface.IPostMessageSent] = true; + originalInterfaces[AppInterface.IPreMessageSentModify] = true; + + assert.notStrictEqual(aff.getImplementedInferfaces()[AppInterface.IPreMessageSentPrevent], false); + assert.notStrictEqual(aff.getImplementedInferfaces()[AppInterface.IPostMessageSent], true); + assert.ok(aff.getImplementedInferfaces()[AppInterface.IPreMessageSentModify] === undefined); + + assert.strictEqual(aff.getImplementedInferfaces()[AppInterface.IPreMessageSentPrevent], true); + assert.strictEqual(aff.getImplementedInferfaces()[AppInterface.IPostMessageSent], false); + assert.strictEqual(aff.getImplementedInferfaces()[AppInterface.IPreMessageSentExtend], true); + }); + + it('setImplementedInterfacesHandlesEmptyObject', () => { + const emptyInterfaces: { [int: string]: boolean } = {}; + + const aff = new AppFabricationFulfillment(); + assert.doesNotThrow(() => aff.setImplementedInterfaces(emptyInterfaces)); + assert.strictEqual(Object.keys(aff.getImplementedInferfaces()).length, 0); + assert.notStrictEqual(aff.getImplementedInferfaces(), emptyInterfaces); + }); +}); diff --git a/packages/apps/tests/server/compiler/AppImplements.test.ts b/packages/apps/tests/server/compiler/AppImplements.test.ts new file mode 100644 index 0000000000000..ef96db589fe3c --- /dev/null +++ b/packages/apps/tests/server/compiler/AppImplements.test.ts @@ -0,0 +1,21 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import { AppInterface } from '@rocket.chat/apps-engine/definition/metadata'; + +import { AppImplements } from '../../../src/server/compiler'; + +describe('AppImplements', () => { + it('appImplements', () => { + assert.doesNotThrow(() => new AppImplements()); + + const impls = new AppImplements(); + + assert.ok(impls.getValues() !== undefined); + assert.doesNotThrow(() => impls.setImplements(AppInterface.IPreMessageSentPrevent)); + assert.strictEqual(impls.doesImplement(AppInterface.IPreMessageSentPrevent), true); + assert.strictEqual(impls.doesImplement(AppInterface.IPostMessageDeleted), false); + assert.strictEqual(impls.getValues()[AppInterface.IPreMessageSentPrevent], true); + assert.strictEqual(impls.getValues()[AppInterface.IPostMessageDeleted], false); + }); +}); diff --git a/packages/apps/tests/server/errors/CommandAlreadyExistsError.test.ts b/packages/apps/tests/server/errors/CommandAlreadyExistsError.test.ts new file mode 100644 index 0000000000000..22916885fe79e --- /dev/null +++ b/packages/apps/tests/server/errors/CommandAlreadyExistsError.test.ts @@ -0,0 +1,13 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import { CommandAlreadyExistsError } from '../../../src/server/errors'; + +describe('CommandAlreadyExistsError', () => { + it('verifyCommandAlreadyExistsError', () => { + const er = new CommandAlreadyExistsError('testing'); + + assert.strictEqual(er.name, 'CommandAlreadyExists'); + assert.strictEqual(er.message, 'The command "testing" already exists in the system.'); + }); +}); diff --git a/packages/apps/tests/server/errors/CommandHasAlreadyBeenTouchedError.test.ts b/packages/apps/tests/server/errors/CommandHasAlreadyBeenTouchedError.test.ts new file mode 100644 index 0000000000000..a7c9cb7929bd2 --- /dev/null +++ b/packages/apps/tests/server/errors/CommandHasAlreadyBeenTouchedError.test.ts @@ -0,0 +1,13 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import { CommandHasAlreadyBeenTouchedError } from '../../../src/server/errors'; + +describe('CommandHasAlreadyBeenTouchedError', () => { + it('verifyCommandHasAlreadyBeenTouched', () => { + const er = new CommandHasAlreadyBeenTouchedError('testing'); + + assert.strictEqual(er.name, 'CommandHasAlreadyBeenTouched'); + assert.strictEqual(er.message, 'The command "testing" has already been touched by another App.'); + }); +}); diff --git a/packages/apps/tests/server/errors/CompilerError.test.ts b/packages/apps/tests/server/errors/CompilerError.test.ts new file mode 100644 index 0000000000000..f6f7453c4d2d2 --- /dev/null +++ b/packages/apps/tests/server/errors/CompilerError.test.ts @@ -0,0 +1,13 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import { CompilerError } from '../../../src/server/errors'; + +describe('CompilerError', () => { + it('verifyCompilerError', () => { + const er = new CompilerError('syntax'); + + assert.strictEqual(er.name, 'CompilerError'); + assert.strictEqual(er.message, 'An error occured while compiling an App: syntax'); + }); +}); diff --git a/packages/apps/tests/server/errors/MustContainFunctionError.test.ts b/packages/apps/tests/server/errors/MustContainFunctionError.test.ts new file mode 100644 index 0000000000000..aae9d336e4a02 --- /dev/null +++ b/packages/apps/tests/server/errors/MustContainFunctionError.test.ts @@ -0,0 +1,13 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import { MustContainFunctionError } from '../../../src/server/errors'; + +describe('MustContainFunctionError', () => { + it('verifyCompilerError', () => { + const er = new MustContainFunctionError('App.ts', 'getVersion'); + + assert.strictEqual(er.name, 'MustContainFunction'); + assert.strictEqual(er.message, 'The App (App.ts) doesn\'t have a "getVersion" function which is required.'); + }); +}); diff --git a/packages/apps/tests/server/errors/MustExtendAppError.test.ts b/packages/apps/tests/server/errors/MustExtendAppError.test.ts new file mode 100644 index 0000000000000..515552ea34b99 --- /dev/null +++ b/packages/apps/tests/server/errors/MustExtendAppError.test.ts @@ -0,0 +1,13 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import { MustExtendAppError } from '../../../src/server/errors'; + +describe('MustExtendAppError', () => { + it('verifyCompilerError', () => { + const er = new MustExtendAppError(); + + assert.strictEqual(er.name, 'MustExtendApp'); + assert.strictEqual(er.message, 'App must extend the "App" abstract class.'); + }); +}); diff --git a/packages/apps/tests/server/errors/NotEnoughMethodArgumentsError.test.ts b/packages/apps/tests/server/errors/NotEnoughMethodArgumentsError.test.ts new file mode 100644 index 0000000000000..ea2f985fa7470 --- /dev/null +++ b/packages/apps/tests/server/errors/NotEnoughMethodArgumentsError.test.ts @@ -0,0 +1,13 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import { NotEnoughMethodArgumentsError } from '../../../src/server/errors'; + +describe('NotEnoughMethodArgumentsError', () => { + it('verifyCompilerError', () => { + const er = new NotEnoughMethodArgumentsError('enable', 3, 1); + + assert.strictEqual(er.name, 'NotEnoughMethodArgumentsError'); + assert.strictEqual(er.message, 'The method "enable" requires 3 parameters but was only passed 1.'); + }); +}); diff --git a/packages/apps/tests/server/errors/RequiredApiVersionError.test.ts b/packages/apps/tests/server/errors/RequiredApiVersionError.test.ts new file mode 100644 index 0000000000000..d4b1fc5e9354e --- /dev/null +++ b/packages/apps/tests/server/errors/RequiredApiVersionError.test.ts @@ -0,0 +1,31 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; + +import { RequiredApiVersionError } from '../../../src/server/errors'; + +describe('RequiredApiVersionError', () => { + it('verifyCompilerError', () => { + const info = { + requiredApiVersion: '1.0.1', + name: 'Testing', + id: 'fake-id', + } as IAppInfo; + const er = new RequiredApiVersionError(info, '1.0.0'); + + assert.strictEqual(er.name, 'RequiredApiVersion'); + assert.strictEqual( + er.message, + 'Failed to load the App "Testing" (fake-id) as it requires v1.0.1 of the App API however your server comes with v1.0.0.', + ); + + const er2 = new RequiredApiVersionError(info, '2.0.0'); + + assert.strictEqual(er2.name, 'RequiredApiVersion'); + assert.strictEqual( + er2.message, + 'Failed to load the App "Testing" (fake-id) as it requires v1.0.1 of the App API however your server comes with v2.0.0. Please tell the author to update their App as it is out of date.', + ); + }); +}); diff --git a/packages/apps/tests/server/logging/AppConsole.test.ts b/packages/apps/tests/server/logging/AppConsole.test.ts new file mode 100644 index 0000000000000..549c5b4930d50 --- /dev/null +++ b/packages/apps/tests/server/logging/AppConsole.test.ts @@ -0,0 +1,92 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; +import type { TestContext } from 'node:test'; + +import { LogMessageSeverity } from '@rocket.chat/apps-engine/definition/accessors'; +import { AppMethod } from '@rocket.chat/apps-engine/definition/metadata'; +import type * as stackTrace from 'stack-trace'; + +import { AppConsole } from '../../../src/server/logging'; + +describe('AppConsole', () => { + it('basicConsoleMethods', (t: TestContext) => { + t.mock.timers.enable({ apis: ['Date'] }); + + assert.doesNotThrow(() => new AppConsole(AppMethod._CONSTRUCTOR)); + + const logger = new AppConsole(AppMethod._CONSTRUCTOR); + const { entries } = logger as any; + + assert.doesNotThrow(() => logger.debug('this is a debug')); + assert.strictEqual(entries.length, 1); + assert.strictEqual(entries[0].severity, LogMessageSeverity.DEBUG); + assert.strictEqual(entries[0].args[0], 'this is a debug'); + + assert.doesNotThrow(() => logger.info('this is an info log')); + assert.strictEqual(entries.length, 2); + assert.strictEqual(entries[1].severity, LogMessageSeverity.INFORMATION); + assert.strictEqual(entries[1].args[0], 'this is an info log'); + + assert.doesNotThrow(() => logger.log('this is a log')); + assert.strictEqual(entries.length, 3); + assert.strictEqual(entries[2].severity, LogMessageSeverity.LOG); + assert.strictEqual(entries[2].args[0], 'this is a log'); + + assert.doesNotThrow(() => logger.warn('this is a warn')); + assert.strictEqual(entries.length, 4); + assert.strictEqual(entries[3].severity, LogMessageSeverity.WARNING); + assert.strictEqual(entries[3].args[0], 'this is a warn'); + + const e = new Error('just a test'); + assert.doesNotThrow(() => logger.error(e)); + assert.strictEqual(entries.length, 5); + assert.strictEqual(entries[4].severity, LogMessageSeverity.ERROR); + assert.strictEqual(entries[4].args[0], JSON.stringify(e, Object.getOwnPropertyNames(e))); + + assert.doesNotThrow(() => logger.success('this is a success')); + assert.strictEqual(entries.length, 6); + assert.strictEqual(entries[5].severity, LogMessageSeverity.SUCCESS); + assert.strictEqual(entries[5].args[0], 'this is a success'); + + assert.doesNotThrow(() => { + class Item { + constructor() { + logger.debug('inside'); + } + } + + return new Item(); + }); + + assert.deepStrictEqual(logger.getEntries(), entries); + assert.strictEqual(logger.getMethod(), AppMethod._CONSTRUCTOR); + assert.ok(logger.getStartTime() !== undefined); + assert.ok(logger.getEndTime() !== undefined); + t.mock.timers.tick(1000); + assert.strictEqual(logger.getTotalTime(), 1000); + + const getFunc = (logger as any).getFunc.bind(logger); + assert.strictEqual(getFunc([{} as stackTrace.StackFrame]), 'anonymous'); + + const mockFrames = []; + mockFrames.push({} as stackTrace.StackFrame); + mockFrames.push({ + getMethodName() { + return 'testing'; + }, + getFunctionName() { + return null; + }, + } as stackTrace.StackFrame); + assert.strictEqual(getFunc(mockFrames), 'testing'); + + const entry = AppConsole.toStorageEntry('testing-app', logger); + assert.strictEqual(entry.appId, 'testing-app'); + assert.strictEqual(entry.method, AppMethod._CONSTRUCTOR); + assert.deepStrictEqual(entry.entries, logger.getEntries()); + assert.ok(entry.startTime instanceof Date); + assert.ok(entry.endTime instanceof Date); + assert.strictEqual(entry.totalTime, 1000); + assert.ok(entry._createdAt instanceof Date); + }); +}); diff --git a/packages/apps/tests/server/managers/AppAccessorManager.test.ts b/packages/apps/tests/server/managers/AppAccessorManager.test.ts new file mode 100644 index 0000000000000..ba98152dd2f8a --- /dev/null +++ b/packages/apps/tests/server/managers/AppAccessorManager.test.ts @@ -0,0 +1,174 @@ +import * as assert from 'node:assert'; +import { describe, it, beforeEach, afterEach, mock } from 'node:test'; + +import type { AppManager } from '../../../src/server/AppManager'; +import type { ProxiedApp } from '../../../src/server/ProxiedApp'; +import type { AppBridges } from '../../../src/server/bridges'; +import type { + AppApiManager, + AppExternalComponentManager, + AppSchedulerManager, + AppSlashCommandManager, + AppVideoConfProviderManager, +} from '../../../src/server/managers'; +import { AppAccessorManager } from '../../../src/server/managers'; +import type { AppOutboundCommunicationProviderManager } from '../../../src/server/managers/AppOutboundCommunicationProviderManager'; +import type { UIActionButtonManager } from '../../../src/server/managers/UIActionButtonManager'; +import { TestsAppBridges } from '../../test-data/bridges/appBridges'; + +describe('AppAccessorManager', () => { + let bridges: AppBridges; + let manager: AppManager; + let spies: { + getServerSettingBridge: any; + getEnvironmentalVariableBridge: any; + getMessageBridge: any; + getPersistenceBridge: any; + getRoomBridge: any; + getUserBridge: any; + getBridges: any; + getCommandManager: any; + getExternalComponentManager: any; + getApiManager: any; + }; + + beforeEach(() => { + bridges = new TestsAppBridges(); + + const brds = bridges; + manager = { + getBridges() { + return brds; + }, + getCommandManager() { + return {} as AppSlashCommandManager; + }, + getExternalComponentManager() { + return {} as AppExternalComponentManager; + }, + getApiManager() { + return {} as AppApiManager; + }, + getOneById(appId: string): ProxiedApp { + return appId === 'testing' ? ({} as ProxiedApp) : undefined; + }, + getSchedulerManager() { + return {} as AppSchedulerManager; + }, + getUIActionButtonManager() { + return {} as UIActionButtonManager; + }, + getVideoConfProviderManager() { + return {} as AppVideoConfProviderManager; + }, + getOutboundCommunicationProviderManager() { + return {} as AppOutboundCommunicationProviderManager; + }, + } as unknown as AppManager; + + // Set up spies before each test + spies = { + getServerSettingBridge: mock.method(bridges, 'getServerSettingBridge'), + getEnvironmentalVariableBridge: mock.method(bridges, 'getEnvironmentalVariableBridge'), + getMessageBridge: mock.method(bridges, 'getMessageBridge'), + getPersistenceBridge: mock.method(bridges, 'getPersistenceBridge'), + getRoomBridge: mock.method(bridges, 'getRoomBridge'), + getUserBridge: mock.method(bridges, 'getUserBridge'), + getBridges: mock.method(manager, 'getBridges'), + getCommandManager: mock.method(manager, 'getCommandManager'), + getExternalComponentManager: mock.method(manager, 'getExternalComponentManager'), + getApiManager: mock.method(manager, 'getApiManager'), + }; + }); + + afterEach(() => { + mock.restoreAll(); + }); + + it('basicAppAccessorManager', () => { + assert.doesNotThrow(() => new AppAccessorManager(manager)); + assert.doesNotThrow(() => new AppAccessorManager(manager).purifyApp('testing')); + }); + + it('configurationExtend', () => { + const acm = new AppAccessorManager(manager); + + assert.ok(acm.getConfigurationExtend('testing')); + assert.throws(() => acm.getConfigurationExtend('fake'), { + name: 'Error', + message: 'No App found by the provided id: fake', + }); + assert.ok(acm.getConfigurationExtend('testing')); + + assert.strictEqual(spies.getExternalComponentManager.mock.calls.length, 1); + assert.strictEqual(spies.getCommandManager.mock.calls.length, 1); + assert.strictEqual(spies.getApiManager.mock.calls.length, 1); + }); + + it('environmentRead', () => { + const acm = new AppAccessorManager(manager); + + assert.ok(acm.getEnvironmentRead('testing')); + assert.throws(() => acm.getEnvironmentRead('fake'), { + name: 'Error', + message: 'No App found by the provided id: fake', + }); + assert.ok(acm.getEnvironmentRead('testing')); + + assert.strictEqual(spies.getServerSettingBridge.mock.calls.length, 1); + assert.strictEqual(spies.getEnvironmentalVariableBridge.mock.calls.length, 1); + }); + + it('configurationModify', () => { + const acm = new AppAccessorManager(manager); + + assert.ok(acm.getConfigurationModify('testing')); + assert.ok(acm.getConfigurationModify('testing')); + + assert.strictEqual(spies.getServerSettingBridge.mock.calls.length, 1); + assert.strictEqual(spies.getCommandManager.mock.calls.length, 1); + }); + + it('reader', () => { + const acm = new AppAccessorManager(manager); + + assert.ok(acm.getReader('testing')); + assert.ok(acm.getReader('testing')); + + assert.strictEqual(spies.getServerSettingBridge.mock.calls.length, 1); + assert.strictEqual(spies.getEnvironmentalVariableBridge.mock.calls.length, 1); + assert.strictEqual(spies.getPersistenceBridge.mock.calls.length, 1); + assert.strictEqual(spies.getRoomBridge.mock.calls.length, 1); + assert.strictEqual(spies.getUserBridge.mock.calls.length, 2); + assert.strictEqual(spies.getMessageBridge.mock.calls.length, 2); + }); + + it('modifier', () => { + const acm = new AppAccessorManager(manager); + + assert.ok(acm.getModifier('testing')); + assert.ok(acm.getModifier('testing')); + + assert.strictEqual(spies.getBridges.mock.calls.length, 1); + assert.strictEqual(spies.getMessageBridge.mock.calls.length, 1); + }); + + it('persistence', () => { + const acm = new AppAccessorManager(manager); + + assert.ok(acm.getPersistence('testing')); + assert.ok(acm.getPersistence('testing')); + + assert.strictEqual(spies.getPersistenceBridge.mock.calls.length, 1); + }); + + it('http', () => { + const acm = new AppAccessorManager(manager); + + assert.ok(acm.getHttp('testing')); + assert.ok(acm.getHttp('testing')); + + (acm as any).https.delete('testing'); + assert.ok(acm.getHttp('testing')); + }); +}); diff --git a/packages/apps/tests/server/managers/AppApi.test.ts b/packages/apps/tests/server/managers/AppApi.test.ts new file mode 100644 index 0000000000000..634d638d556de --- /dev/null +++ b/packages/apps/tests/server/managers/AppApi.test.ts @@ -0,0 +1,24 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { IApi } from '@rocket.chat/apps-engine/definition/api'; +import type { IApiEndpoint } from '@rocket.chat/apps-engine/definition/api/IApiEndpoint'; + +import type { ProxiedApp } from '../../../src/server/ProxiedApp'; +import { AppApi } from '../../../src/server/managers/AppApi'; + +describe('AppApi', () => { + it('ensureAppApi', () => { + const mockApp = { + getID() { + return 'id'; + }, + } as ProxiedApp; + + assert.doesNotThrow(() => new AppApi(mockApp, {} as IApi, {} as IApiEndpoint)); + + const ascr = new AppApi(mockApp, {} as IApi, {} as IApiEndpoint); + assert.ok(ascr.app !== undefined); + assert.ok(ascr.api !== undefined); + }); +}); diff --git a/packages/apps/tests/server/managers/AppApiManager.test.ts b/packages/apps/tests/server/managers/AppApiManager.test.ts new file mode 100644 index 0000000000000..9437a6ffea674 --- /dev/null +++ b/packages/apps/tests/server/managers/AppApiManager.test.ts @@ -0,0 +1,227 @@ +import * as assert from 'node:assert'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; + +import { RequestMethod } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IApi, IApiRequest } from '@rocket.chat/apps-engine/definition/api'; + +import type { AppManager } from '../../../src/server/AppManager'; +import type { ProxiedApp } from '../../../src/server/ProxiedApp'; +import type { AppBridges } from '../../../src/server/bridges'; +import type { + AppExternalComponentManager, + AppSchedulerManager, + AppSlashCommandManager, + AppVideoConfProviderManager, +} from '../../../src/server/managers'; +import { AppAccessorManager, AppApiManager } from '../../../src/server/managers'; +import { AppApi } from '../../../src/server/managers/AppApi'; +import type { UIActionButtonManager } from '../../../src/server/managers/UIActionButtonManager'; +import type { AppLogStorage, IAppStorageItem } from '../../../src/server/storage'; +import { TestsAppBridges } from '../../test-data/bridges/appBridges'; +import { TestsAppLogStorage } from '../../test-data/storage/logStorage'; +import { TestData } from '../../test-data/utilities'; + +describe('AppApiManager', () => { + let mockBridges: TestsAppBridges; + let mockApp: ProxiedApp; + let mockAccessors: AppAccessorManager; + let mockManager: AppManager; + + beforeEach(() => { + mockBridges = new TestsAppBridges(); + + mockApp = TestData.getMockApp({ info: { id: 'testing', name: 'TestApp' } } as IAppStorageItem, {} as AppManager); + + const bri = mockBridges; + const app = mockApp; + mockManager = { + getBridges(): AppBridges { + return bri; + }, + getCommandManager() { + return {} as AppSlashCommandManager; + }, + getExternalComponentManager() { + return {} as AppExternalComponentManager; + }, + getApiManager() { + return {} as AppApiManager; + }, + getOneById(appId: string): ProxiedApp { + return appId === 'failMePlease' ? undefined : app; + }, + getLogStorage(): AppLogStorage { + return new TestsAppLogStorage(); + }, + getSchedulerManager() { + return {} as AppSchedulerManager; + }, + getUIActionButtonManager() { + return {} as UIActionButtonManager; + }, + getVideoConfProviderManager() { + return {} as AppVideoConfProviderManager; + }, + } as AppManager; + + mockAccessors = new AppAccessorManager(mockManager); + const ac = mockAccessors; + mockManager.getAccessorManager = function _getAccessorManager(): AppAccessorManager { + return ac; + }; + }); + + afterEach(() => { + mock.restoreAll(); + }); + + it('basicAppApiManager', () => { + assert.throws(() => new AppApiManager({} as AppManager)); + assert.doesNotThrow(() => new AppApiManager(mockManager)); + + const ascm = new AppApiManager(mockManager); + assert.strictEqual((ascm as any).manager, mockManager); + assert.strictEqual((ascm as any).bridge, mockBridges.getApiBridge()); + assert.strictEqual((ascm as any).accessors, mockManager.getAccessorManager()); + assert.ok((ascm as any).providedApis !== undefined); + assert.strictEqual((ascm as any).providedApis.size, 0); + }); + + it('registerApi', async () => { + const doRegisterApiSpy = mock.method(mockBridges.getApiBridge(), 'doRegisterApi'); + const ascm = new AppApiManager(mockManager); + + const api: IApi = TestData.getApi('path'); + const regInfo = new AppApi(mockApp, api, api.endpoints[0]); + + await assert.doesNotReject(() => (ascm as any).registerApi('testing', regInfo)); + assert.strictEqual(doRegisterApiSpy.mock.calls.length, 1); + assert.deepStrictEqual(doRegisterApiSpy.mock.calls[0].arguments, [regInfo, 'testing']); + }); + + it('addApi', () => { + mockBridges = new TestsAppBridges(); + const bri = mockBridges; + mockManager.getBridges = function _refreshedGetBridges(): AppBridges { + return bri; + }; + + const doRegisterApiSpy = mock.method(mockBridges.getApiBridge(), 'doRegisterApi'); + void doRegisterApiSpy; + + const api = TestData.getApi('apipath'); + const ascm = new AppApiManager(mockManager); + assert.doesNotThrow(() => ascm.addApi('testing', api)); + assert.strictEqual(mockBridges.getApiBridge().apis.size, 1); + assert.strictEqual((ascm as any).providedApis.size, 1); + assert.strictEqual((ascm as any).providedApis.get('testing').get('apipath').api, api); + + assert.throws(() => ascm.addApi('testing', api), { + name: 'PathAlreadyExists', + message: 'The api path "apipath" already exists in the system.', + }); + + assert.throws(() => ascm.addApi('failMePlease', TestData.getApi('yet-another')), { + name: 'Error', + message: 'App must exist in order for an api to be added.', + }); + assert.doesNotThrow(() => ascm.addApi('testing', TestData.getApi('another-api'))); + assert.strictEqual((ascm as any).providedApis.size, 1); + assert.strictEqual((ascm as any).providedApis.get('testing').size, 2); + }); + + it('registerApis', async () => { + mockBridges = new TestsAppBridges(); + const bri = mockBridges; + mockManager.getBridges = function _refreshedGetBridges(): AppBridges { + return bri; + }; + const doRegisterApiSpy = mock.method(mockBridges.getApiBridge(), 'doRegisterApi'); + + const ascm = new AppApiManager(mockManager); + + const registerApiSpy = mock.method(ascm as any, 'registerApi'); + + ascm.addApi('testing', TestData.getApi('apipath')); + const regInfo = (ascm as any).providedApis.get('testing').get('apipath') as AppApi; + + await assert.doesNotReject(() => ascm.registerApis('non-existant')); + await assert.doesNotReject(() => ascm.registerApis('testing')); + assert.strictEqual(registerApiSpy.mock.calls.filter((c: any) => c.arguments[0] === 'testing' && c.arguments[1] === regInfo).length, 1); + assert.strictEqual( + doRegisterApiSpy.mock.calls.filter((c: any) => c.arguments[0] === regInfo && c.arguments[1] === 'testing').length, + 1, + ); + }); + + it('unregisterApis', async () => { + mockBridges = new TestsAppBridges(); + const bri = mockBridges; + mockManager.getBridges = function _refreshedGetBridges(): AppBridges { + return bri; + }; + const doUnregisterApisSpy = mock.method(mockBridges.getApiBridge(), 'doUnregisterApis'); + + const ascm = new AppApiManager(mockManager); + + ascm.addApi('testing', TestData.getApi('apipath')); + + await assert.doesNotReject(() => ascm.unregisterApis('non-existant')); + await assert.doesNotReject(() => ascm.unregisterApis('testing')); + assert.strictEqual(doUnregisterApisSpy.mock.calls.length, 1); + }); + + it('executeApis', async () => { + mockBridges = new TestsAppBridges(); + const bri = mockBridges; + mockManager.getBridges = function _refreshedGetBridges(): AppBridges { + return bri; + }; + + const ascm = new AppApiManager(mockManager); + ascm.addApi('testing', TestData.getApi('api1')); + ascm.addApi('testing', TestData.getApi('api2')); + ascm.addApi('testing', TestData.getApi('api3')); + await ascm.registerApis('testing'); + + const request: IApiRequest = { + method: RequestMethod.GET, + headers: {}, + query: {}, + params: {}, + content: '', + }; + + await assert.doesNotReject(() => ascm.executeApi('testing', 'nope', request)); + await assert.doesNotReject(() => ascm.executeApi('testing', 'not-exists', request)); + await assert.doesNotReject(() => ascm.executeApi('testing', 'api1', request)); + await assert.doesNotReject(() => ascm.executeApi('testing', 'api2', request)); + await assert.doesNotReject(() => ascm.executeApi('testing', 'api3', request)); + }); + + it('listApis', async () => { + mockBridges = new TestsAppBridges(); + const bri = mockBridges; + mockManager.getBridges = function _refreshedGetBridges(): AppBridges { + return bri; + }; + + const ascm = new AppApiManager(mockManager); + + assert.deepStrictEqual(ascm.listApis('testing'), []); + + ascm.addApi('testing', TestData.getApi('api1')); + await ascm.registerApis('testing'); + + assert.doesNotThrow(() => ascm.listApis('testing')); + assert.notDeepStrictEqual(ascm.listApis('testing'), []); + assert.deepStrictEqual(ascm.listApis('testing'), [ + { + path: 'api1', + computedPath: '/api/apps/public/testing/api1', + methods: ['get'], + examples: {}, + }, + ]); + }); +}); diff --git a/packages/apps/tests/server/managers/AppExternalComponentManager.test.ts b/packages/apps/tests/server/managers/AppExternalComponentManager.test.ts new file mode 100644 index 0000000000000..d0f1dbc069b04 --- /dev/null +++ b/packages/apps/tests/server/managers/AppExternalComponentManager.test.ts @@ -0,0 +1,137 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { IExternalComponent } from '@rocket.chat/apps-engine/definition/externalComponent/IExternalComponent'; +import { ExternalComponentLocation } from '@rocket.chat/apps-engine/definition/externalComponent/IExternalComponent'; + +import { AppExternalComponentManager } from '../../../src/server/managers'; + +describe('AppExternalComponentManager', () => { + const mockExternalComponent1: IExternalComponent = { + appId: '1eb382c0-3679-44a6-8af0-18802e342fb1', + name: 'TestExternalComponent1', + description: 'TestExternalComponent1', + url: '', + icon: '', + location: ExternalComponentLocation.CONTEXTUAL_BAR, + }; + + const mockExternalComponent2: IExternalComponent = { + appId: '125a944b-9747-4e6e-b029-6e9b26bb3481', + name: 'TestExternalComponent2', + description: 'TestExternalComponent2', + url: '', + icon: '', + location: ExternalComponentLocation.CONTEXTUAL_BAR, + }; + + const mockExternalComponent3: IExternalComponent = { + ...mockExternalComponent2, + appId: mockExternalComponent1.appId, + name: mockExternalComponent2.name, + description: 'TestExternalComponent3', + }; + + function register(aecm: AppExternalComponentManager, externalComponent: IExternalComponent): void { + const { appId } = externalComponent; + aecm.addExternalComponent(appId, externalComponent); + aecm.registerExternalComponents(appId); + } + + it('basicAppExternalComponentManager', () => { + const aecm = new AppExternalComponentManager(); + + assert.strictEqual((aecm as any).registeredExternalComponents.size, 0); + assert.strictEqual((aecm as any).appTouchedExternalComponents.size, 0); + }); + + it('verifyGetRegisteredExternalComponents', () => { + const aecm = new AppExternalComponentManager(); + const component = mockExternalComponent1; + + assert.strictEqual(aecm.getRegisteredExternalComponents().size, 0); + + register(aecm, component); + assert.strictEqual(aecm.getRegisteredExternalComponents().size, 1); + }); + + it('verifyGetAppTouchedExternalComponents', () => { + const aecm = new AppExternalComponentManager(); + const component = mockExternalComponent1; + + assert.strictEqual(aecm.getAppTouchedExternalComponents().size, 0); + + aecm.addExternalComponent(component.appId, component); + assert.strictEqual(aecm.getAppTouchedExternalComponents().size, 1); + }); + + it('verifyGetExternalComponents', () => { + const aecm = new AppExternalComponentManager(); + const component = mockExternalComponent1; + + assert.strictEqual(aecm.getExternalComponents(component.appId), null); + aecm.addExternalComponent(component.appId, component); + const components = aecm.getExternalComponents(component.appId); + assert.notStrictEqual(components, null); + assert.strictEqual(components!.size, 1); + }); + + it('verifyGetProvidedComponents', () => { + const aecm = new AppExternalComponentManager(); + const component1 = mockExternalComponent1; + const component2 = mockExternalComponent2; + + assert.strictEqual(Array.isArray(aecm.getProvidedComponents()), true); + register(aecm, component1); + register(aecm, component2); + assert.strictEqual(aecm.getProvidedComponents().length, 2); + }); + + it('verifyAddExternalComponent', () => { + const aecm1 = new AppExternalComponentManager(); + const component1 = mockExternalComponent1; + const component3 = mockExternalComponent3; + + aecm1.addExternalComponent(component1.appId, component1); + assert.strictEqual(aecm1.getAppTouchedExternalComponents().size, 1); + assert.strictEqual(aecm1.getExternalComponents(component1.appId).size, 1); + + aecm1.addExternalComponent(component1.appId, component3); + assert.strictEqual(aecm1.getExternalComponents(component1.appId).size, 2); + }); + + it('verifyRegisterExternalComponents', () => { + const aecm = new AppExternalComponentManager(); + const component = mockExternalComponent1; + + assert.strictEqual(aecm.getRegisteredExternalComponents().size, 0); + register(aecm, component); + assert.strictEqual(aecm.getRegisteredExternalComponents().size, 1); + }); + + it('verifyUnregisterExternalComponents', () => { + const aecm = new AppExternalComponentManager(); + const component = mockExternalComponent1; + + register(aecm, component); + assert.strictEqual(aecm.getAppTouchedExternalComponents().size, 1); + assert.strictEqual(aecm.getRegisteredExternalComponents().size, 1); + + aecm.unregisterExternalComponents(component.appId); + assert.strictEqual(aecm.getAppTouchedExternalComponents().size, 1); + assert.strictEqual(aecm.getRegisteredExternalComponents().size, 0); + }); + + it('verifyPurgeExternalComponents', () => { + const aecm = new AppExternalComponentManager(); + const component = mockExternalComponent1; + + register(aecm, component); + assert.strictEqual(aecm.getAppTouchedExternalComponents().size, 1); + assert.strictEqual(aecm.getRegisteredExternalComponents().size, 1); + + aecm.purgeExternalComponents(component.appId); + assert.strictEqual(aecm.getAppTouchedExternalComponents().size, 0); + assert.strictEqual(aecm.getRegisteredExternalComponents().size, 0); + }); +}); diff --git a/packages/apps/tests/server/managers/AppListenerManager.test.ts b/packages/apps/tests/server/managers/AppListenerManager.test.ts new file mode 100644 index 0000000000000..6bb43f31d4262 --- /dev/null +++ b/packages/apps/tests/server/managers/AppListenerManager.test.ts @@ -0,0 +1,38 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import { AppInterface } from '@rocket.chat/apps-engine/definition/metadata'; + +import type { AppManager } from '../../../src/server/AppManager'; +import type { ProxiedApp } from '../../../src/server/ProxiedApp'; +import { AppListenerManager } from '../../../src/server/managers'; + +describe('AppListenerManager', () => { + const mockApp = { + getID() { + return 'testing'; + }, + getImplementationList() { + return { + [AppInterface.IPostMessageSent]: true, + } as { [inte: string]: boolean }; + }, + } as ProxiedApp; + + const mockManager = { + getAccessorManager() {}, + getOneById(appId: string) { + return mockApp; + }, + } as AppManager; + + it('basicAppListenerManager', () => { + assert.doesNotThrow(() => new AppListenerManager(mockManager)); + + const alm = new AppListenerManager(mockManager); + + assert.strictEqual(alm.getListeners(AppInterface.IPostMessageSent).length, 0); + assert.doesNotThrow(() => alm.registerListeners(mockApp)); + assert.strictEqual(alm.getListeners(AppInterface.IPostMessageSent).length, 1); + }); +}); diff --git a/packages/apps/tests/server/managers/AppOutboundCommunicationProvider.test.ts b/packages/apps/tests/server/managers/AppOutboundCommunicationProvider.test.ts new file mode 100644 index 0000000000000..c1862d9fa86b6 --- /dev/null +++ b/packages/apps/tests/server/managers/AppOutboundCommunicationProvider.test.ts @@ -0,0 +1,23 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { IOutboundMessageProviders } from '@rocket.chat/apps-engine/definition/outboundCommunication'; + +import type { ProxiedApp } from '../../../src/server/ProxiedApp'; +import { OutboundMessageProvider } from '../../../src/server/managers/AppOutboundCommunicationProvider'; + +describe('OutboundMessageProvider', () => { + it('ensureAppOutboundCommunicationProviderManager', () => { + const mockApp = {} as ProxiedApp; + + assert.doesNotThrow(() => new OutboundMessageProvider(mockApp, {} as IOutboundMessageProviders)); + + const aocp = new OutboundMessageProvider(mockApp, {} as IOutboundMessageProviders); + + assert.strictEqual(aocp.isRegistered, false); + + aocp.setRegistered(true); + + assert.strictEqual(aocp.isRegistered, true); + }); +}); diff --git a/packages/apps/tests/server/managers/AppOutboundCommunicationProviderManager.test.ts b/packages/apps/tests/server/managers/AppOutboundCommunicationProviderManager.test.ts new file mode 100644 index 0000000000000..a025ab297c9b7 --- /dev/null +++ b/packages/apps/tests/server/managers/AppOutboundCommunicationProviderManager.test.ts @@ -0,0 +1,284 @@ +import * as assert from 'node:assert'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; + +import type { AppManager } from '../../../src/server/AppManager'; +import type { ProxiedApp } from '../../../src/server/ProxiedApp'; +import type { AppBridges } from '../../../src/server/bridges'; +import type { AppApiManager, AppExternalComponentManager, AppSchedulerManager, AppSlashCommandManager } from '../../../src/server/managers'; +import { AppAccessorManager, AppOutboundCommunicationProviderManager } from '../../../src/server/managers'; +import { OutboundMessageProvider } from '../../../src/server/managers/AppOutboundCommunicationProvider'; +import { AppPermissionManager } from '../../../src/server/managers/AppPermissionManager'; +import type { UIActionButtonManager } from '../../../src/server/managers/UIActionButtonManager'; +import type { AppLogStorage, IAppStorageItem } from '../../../src/server/storage'; +import { TestsAppBridges } from '../../test-data/bridges/appBridges'; +import { TestsAppLogStorage } from '../../test-data/storage/logStorage'; +import { TestData } from '../../test-data/utilities'; + +describe('AppOutboundCommunicationProviderManager', () => { + let mockBridges: TestsAppBridges; + let mockApp: ProxiedApp; + let mockAccessors: AppAccessorManager; + let mockManager: AppManager; + let hasPermissionSpy: ReturnType; + + beforeEach(() => { + mockBridges = new TestsAppBridges(); + + mockApp = TestData.getMockApp({ info: { id: 'testing', name: 'testing' } } as IAppStorageItem, {} as AppManager); + + const bri = mockBridges; + const app = mockApp; + + mockManager = { + getBridges(): AppBridges { + return bri; + }, + getCommandManager() { + return {} as AppSlashCommandManager; + }, + getExternalComponentManager(): AppExternalComponentManager { + return {} as AppExternalComponentManager; + }, + getApiManager() { + return {} as AppApiManager; + }, + getOneById(appId: string): ProxiedApp { + return appId === 'failMePlease' ? undefined : app; + }, + getLogStorage(): AppLogStorage { + return new TestsAppLogStorage(); + }, + getSchedulerManager() { + return {} as AppSchedulerManager; + }, + getUIActionButtonManager() { + return {} as UIActionButtonManager; + }, + getOutboundCommunicationProviderManager() { + return {} as AppOutboundCommunicationProviderManager; + }, + } as AppManager; + + mockAccessors = new AppAccessorManager(mockManager); + const ac = mockAccessors; + mockManager.getAccessorManager = function _getAccessorManager(): AppAccessorManager { + return ac; + }; + + hasPermissionSpy = mock.method(AppPermissionManager, 'hasPermission', () => true); + }); + + afterEach(() => { + mock.restoreAll(); + }); + + it('basicAppOutboundCommunicationProviderManager', () => { + assert.throws(() => new AppOutboundCommunicationProviderManager({} as AppManager)); + assert.doesNotThrow(() => new AppOutboundCommunicationProviderManager(mockManager)); + + const manager = new AppOutboundCommunicationProviderManager(mockManager); + assert.strictEqual((manager as any).manager, mockManager); + assert.strictEqual((manager as any).accessors, mockManager.getAccessorManager()); + assert.ok((manager as any).outboundMessageProviders !== undefined); + assert.strictEqual((manager as any).outboundMessageProviders.size, 0); + }); + + it('addProvider', () => { + const provider = TestData.getOutboundPhoneMessageProvider(); + const manager = new AppOutboundCommunicationProviderManager(mockManager); + + assert.doesNotThrow(() => manager.addProvider('testing', provider)); + assert.strictEqual((manager as any).outboundMessageProviders.size, 1); + assert.throws(() => manager.addProvider('failMePlease', provider), { + name: 'Error', + message: 'App must exist in order for an outbound provider to be added.', + }); + assert.strictEqual((manager as any).outboundMessageProviders.size, 1); + }); + + it('isAlreadyDefined', () => { + const provider = TestData.getOutboundPhoneMessageProvider(); + const manager = new AppOutboundCommunicationProviderManager(mockManager); + + assert.strictEqual(manager.isAlreadyDefined('testing', 'phone'), false); + + manager.addProvider('testing', provider); + + assert.strictEqual(manager.isAlreadyDefined('testing', 'phone'), true); + assert.strictEqual(manager.isAlreadyDefined('testing', 'email'), false); + assert.strictEqual(manager.isAlreadyDefined('another-app', 'phone'), false); + }); + + it('addProviderTwiceShouldOverwrite', () => { + const provider1 = TestData.getOutboundPhoneMessageProvider('provider1'); + const provider2 = TestData.getOutboundPhoneMessageProvider('provider2'); + const manager = new AppOutboundCommunicationProviderManager(mockManager); + + manager.addProvider('testing', provider1); + const firstProviderInfo = (manager as any).outboundMessageProviders.get('testing').get('phone'); + assert.strictEqual(firstProviderInfo.provider.name, 'provider1'); + + // Adding a provider of the same type should overwrite the previous one + manager.addProvider('testing', provider2); + const secondProviderInfo = (manager as any).outboundMessageProviders.get('testing').get('phone'); + assert.strictEqual(secondProviderInfo.provider.name, 'provider2'); + assert.strictEqual((manager as any).outboundMessageProviders.get('testing').size, 1); + }); + + it('addProviderWithoutPermission', () => { + const provider = TestData.getOutboundPhoneMessageProvider(); + const manager = new AppOutboundCommunicationProviderManager(mockManager); + + hasPermissionSpy.mock.mockImplementation(() => false); + + assert.throws(() => manager.addProvider('testing', provider)); + }); + + it('ignoreAppsWithoutProviders', async () => { + const manager = new AppOutboundCommunicationProviderManager(mockManager); + + await assert.doesNotReject(() => manager.registerProviders('non-existant')); + }); + + it('registerProviders', async () => { + const manager = new AppOutboundCommunicationProviderManager(mockManager); + + manager.addProvider('firstApp', TestData.getOutboundPhoneMessageProvider()); + const appInfo = (manager as any).outboundMessageProviders.get('firstApp'); + assert.ok(appInfo !== undefined); + const regInfo = appInfo.get('phone'); + assert.ok(regInfo !== undefined); + + assert.strictEqual(regInfo.isRegistered, false); + await assert.doesNotReject(async () => manager.registerProviders('firstApp')); + assert.strictEqual(regInfo.isRegistered, true); + }); + + it('registerTwoProviders', async () => { + const manager = new AppOutboundCommunicationProviderManager(mockManager); + + manager.addProvider('firstApp', TestData.getOutboundPhoneMessageProvider()); + manager.addProvider('firstApp', TestData.getOutboundEmailMessageProvider()); + const firstApp = (manager as any).outboundMessageProviders.get('firstApp'); + assert.ok(firstApp !== undefined); + const firstRegInfo = firstApp.get('phone'); + assert.ok(firstRegInfo !== undefined); + const secondRegInfo = firstApp.get('email'); + assert.ok(secondRegInfo !== undefined); + + assert.strictEqual(firstRegInfo.isRegistered, false); + assert.strictEqual(secondRegInfo.isRegistered, false); + await assert.doesNotReject(async () => manager.registerProviders('firstApp')); + assert.strictEqual(firstRegInfo.isRegistered, true); + assert.strictEqual(secondRegInfo.isRegistered, true); + }); + + it('registerProvidersFromMultipleApps', async () => { + const manager = new AppOutboundCommunicationProviderManager(mockManager); + + manager.addProvider('firstApp', TestData.getOutboundPhoneMessageProvider()); + manager.addProvider('firstApp', TestData.getOutboundEmailMessageProvider()); + manager.addProvider('secondApp', TestData.getOutboundPhoneMessageProvider('another-phone-provider')); + + const firstApp = (manager as any).outboundMessageProviders.get('firstApp'); + assert.ok(firstApp !== undefined); + const firstRegInfo = firstApp.get('phone'); + const secondRegInfo = firstApp.get('email'); + assert.ok(firstRegInfo !== undefined); + assert.ok(secondRegInfo !== undefined); + const secondApp = (manager as any).outboundMessageProviders.get('secondApp'); + assert.ok(secondApp !== undefined); + const thirdRegInfo = secondApp.get('phone'); + assert.ok(thirdRegInfo !== undefined); + + assert.strictEqual(firstRegInfo.isRegistered, false); + assert.strictEqual(secondRegInfo.isRegistered, false); + await assert.doesNotReject(async () => manager.registerProviders('firstApp')); + assert.strictEqual(firstRegInfo.isRegistered, true); + assert.strictEqual(secondRegInfo.isRegistered, true); + assert.strictEqual(thirdRegInfo.isRegistered, false); + await assert.doesNotReject(async () => manager.registerProviders('secondApp')); + assert.strictEqual(thirdRegInfo.isRegistered, true); + }); + + it('unregisterProviders', async () => { + const manager = new AppOutboundCommunicationProviderManager(mockManager); + + manager.addProvider('testing', TestData.getOutboundPhoneMessageProvider()); + const regInfo = (manager as any).outboundMessageProviders.get('testing').get('phone'); + await assert.doesNotReject(async () => manager.registerProviders('testing')); + + await assert.doesNotReject(async () => manager.unregisterProviders('non-existant')); + assert.strictEqual(regInfo.isRegistered, true); + await assert.doesNotReject(async () => manager.unregisterProviders('testing')); + assert.strictEqual(regInfo.isRegistered, false); + // It should be removed from the map + assert.strictEqual((manager as any).outboundMessageProviders.has('testing'), false); + }); + + it('unregisterProvidersWithKeepReferences', async () => { + const manager = new AppOutboundCommunicationProviderManager(mockManager); + + manager.addProvider('testing', TestData.getOutboundPhoneMessageProvider()); + const appInfo = (manager as any).outboundMessageProviders.get('testing'); + const regInfo = appInfo.get('phone'); + + await assert.doesNotReject(async () => manager.registerProviders('testing')); + assert.strictEqual(regInfo.isRegistered, true); + await assert.doesNotReject(async () => manager.unregisterProviders('testing', { keepReferences: true })); + assert.strictEqual(regInfo.isRegistered, false); + // It should not be removed from the map + assert.strictEqual((manager as any).outboundMessageProviders.has('testing'), true); + }); + + it('failToGetMetadataWithoutProvider', () => { + const manager = new AppOutboundCommunicationProviderManager(mockManager); + + assert.throws(() => manager.getProviderMetadata('testing', 'phone'), { name: 'Error', message: 'provider-not-registered' }); + + manager.addProvider('testing', TestData.getOutboundPhoneMessageProvider()); + + assert.throws(() => manager.getProviderMetadata('testing', 'email'), { name: 'Error', message: 'provider-not-registered' }); + }); + + it('getProviderMetadata', async () => { + const manager = new AppOutboundCommunicationProviderManager(mockManager); + manager.addProvider('testing', TestData.getOutboundPhoneMessageProvider()); + + mock.method(OutboundMessageProvider.prototype, 'runGetProviderMetadata', () => + Promise.resolve({ + name: 'test-provider', + capabilities: ['sms'], + }), + ); + + const metadata = await manager.getProviderMetadata('testing', 'phone'); + assert.deepStrictEqual(metadata, { + name: 'test-provider', + capabilities: ['sms'], + }); + }); + + it('failToSendOutboundMessageWithoutProvider', () => { + const manager = new AppOutboundCommunicationProviderManager(mockManager); + const message = TestData.getOutboundMessage(); + + assert.throws(() => manager.sendOutboundMessage('testing', 'phone', message), { name: 'Error', message: 'provider-not-registered' }); + + manager.addProvider('testing', TestData.getOutboundPhoneMessageProvider()); + + assert.throws(() => manager.sendOutboundMessage('testing', 'email', message), { name: 'Error', message: 'provider-not-registered' }); + }); + + it('sendOutboundMessage', async () => { + const manager = new AppOutboundCommunicationProviderManager(mockManager); + manager.addProvider('testing', TestData.getOutboundPhoneMessageProvider()); + + const message = TestData.getOutboundMessage(); + + mock.method(OutboundMessageProvider.prototype, 'runSendOutboundMessage', () => Promise.resolve('message-id')); + + const result = await manager.sendOutboundMessage('testing', 'phone', message); + assert.strictEqual(result, 'message-id'); + }); +}); diff --git a/packages/apps/tests/server/managers/AppRuntimeManager.test.ts b/packages/apps/tests/server/managers/AppRuntimeManager.test.ts new file mode 100644 index 0000000000000..1bb44ed3f699f --- /dev/null +++ b/packages/apps/tests/server/managers/AppRuntimeManager.test.ts @@ -0,0 +1,140 @@ +import * as assert from 'node:assert'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; + +import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; + +import type { AppManager } from '../../../src/server/AppManager'; +import type { IParseAppPackageResult } from '../../../src/server/compiler'; +import { AppRuntimeManager } from '../../../src/server/managers/AppRuntimeManager'; +import type { IRuntimeController } from '../../../src/server/runtime/IRuntimeController'; +import type { DenoRuntimeSubprocessController } from '../../../src/server/runtime/deno/AppsEngineDenoRuntime'; +import type { IAppStorageItem } from '../../../src/server/storage'; +import { TestInfastructureSetup } from '../../test-data/utilities'; + +describe('AppRuntimeManager', () => { + let mockManager: AppManager; + let runtimeManager: AppRuntimeManager; + let mockAppPackage: IParseAppPackageResult; + let mockStorageItem: IAppStorageItem; + let mockSubprocessController: IRuntimeController; + + beforeEach(() => { + const testInfrastructure = new TestInfastructureSetup(); + + mockManager = testInfrastructure.getMockManager(); + + mockAppPackage = { + info: { + id: 'test-app', + name: 'Test App', + nameSlug: 'test-app', + version: '1.0.0', + description: 'Test app for unit testing', + author: { + name: 'Test Author', + homepage: 'https://test.com', + support: 'https://test.com/support', + }, + permissions: [], + requiredApiVersion: '1.0.0', + classFile: 'main.js', + iconFile: 'icon.png', + implements: [], + }, + files: { + 'main.js': 'console.log("Hello World");', + }, + languageContent: {} as unknown as IParseAppPackageResult['languageContent'], + implemented: {} as unknown as IParseAppPackageResult['implemented'], + } as IParseAppPackageResult; + + mockStorageItem = { + id: 'test-app', + status: AppStatus.MANUALLY_ENABLED, + info: mockAppPackage.info, + createdAt: new Date(), + updatedAt: new Date(), + } as IAppStorageItem; + + mockSubprocessController = { + async setupApp() { + return Promise.resolve(); + }, + + async stopApp() { + return Promise.resolve(); + }, + + getAppId() { + return 'test-app'; + }, + + async getStatus() { + return Promise.resolve(AppStatus.MANUALLY_ENABLED); + }, + + async sendRequest() { + return Promise.resolve(true); + }, + + on: () => mockSubprocessController, + once: () => mockSubprocessController, + off: () => mockSubprocessController, + emit: () => true, + addListener: () => mockSubprocessController, + removeListener: () => mockSubprocessController, + removeAllListeners: () => mockSubprocessController, + setMaxListeners: () => mockSubprocessController, + getMaxListeners: () => 10, + listeners: () => [], + rawListeners: () => [], + listenerCount: () => 0, + prependListener: () => mockSubprocessController, + prependOnceListener: () => mockSubprocessController, + eventNames: () => [], + } as IRuntimeController; + + runtimeManager = new AppRuntimeManager(mockManager, () => mockSubprocessController as unknown as DenoRuntimeSubprocessController); + }); + + afterEach(() => { + mock.restoreAll(); + }); + + it('Starts runtime for app successfully', async () => { + await assert.doesNotReject(() => runtimeManager.startRuntimeForApp(mockAppPackage, mockStorageItem)); + + /* eslint-disable-next-line dot-notation -- We need to access the property like this for the compile not to complain */ + assert.strictEqual(runtimeManager['subprocesses'][mockAppPackage.info.id], mockSubprocessController); + }); + + it('Fails to start runtime for app that already has a runtime', async () => { + await assert.doesNotReject(() => runtimeManager.startRuntimeForApp(mockAppPackage, mockStorageItem)); + + await assert.rejects(() => runtimeManager.startRuntimeForApp(mockAppPackage, mockStorageItem), { + name: 'Error', + message: 'App already has an associated runtime', + }); + }); + + it('Starts multiple runtimes for app successfully with force option', async () => { + await assert.doesNotReject(() => runtimeManager.startRuntimeForApp(mockAppPackage, mockStorageItem)); + + await assert.doesNotReject(() => runtimeManager.startRuntimeForApp(mockAppPackage, mockStorageItem, { force: true })); + + /* eslint-disable-next-line dot-notation -- We need to access the property like this for the compile not to complain */ + assert.strictEqual(runtimeManager['subprocesses'][mockAppPackage.info.id], mockSubprocessController); + }); + + it('startRuntimeThatFailsToSetup', async () => { + mock.method(mockSubprocessController, 'setupApp', () => Promise.reject(new Error('Nope'))); + + await assert.rejects(() => runtimeManager.startRuntimeForApp(mockAppPackage, mockStorageItem), { + name: 'Error', + message: 'Nope', + }); + + /* eslint-disable-next-line dot-notation -- We need to access the property like this for the compile not to complain */ + assert.strictEqual(runtimeManager['subprocesses'][mockAppPackage.info.id], undefined); + }); +}); diff --git a/packages/apps/tests/server/managers/AppSettingsManager.test.ts b/packages/apps/tests/server/managers/AppSettingsManager.test.ts new file mode 100644 index 0000000000000..83f2fd28b53dc --- /dev/null +++ b/packages/apps/tests/server/managers/AppSettingsManager.test.ts @@ -0,0 +1,159 @@ +import * as assert from 'node:assert'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; + +import { AppMethod } from '@rocket.chat/apps-engine/definition/metadata'; +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; + +import type { AppManager } from '../../../src/server/AppManager'; +import type { ProxiedApp } from '../../../src/server/ProxiedApp'; +import type { AppBridges } from '../../../src/server/bridges'; +import type { + AppApiManager, + AppExternalComponentManager, + AppSchedulerManager, + AppSlashCommandManager, + AppVideoConfProviderManager, +} from '../../../src/server/managers'; +import { AppAccessorManager, AppSettingsManager } from '../../../src/server/managers'; +import type { UIActionButtonManager } from '../../../src/server/managers/UIActionButtonManager'; +import type { AppMetadataStorage, IAppStorageItem } from '../../../src/server/storage'; +import { TestsAppBridges } from '../../test-data/bridges/appBridges'; +import { TestData } from '../../test-data/utilities'; + +describe('AppSettingsManager', () => { + let mockStorageItem: IAppStorageItem; + let mockApp: ProxiedApp; + let mockBridges: AppBridges; + let mockAccessors: AppAccessorManager; + let mockStorage: AppMetadataStorage; + let mockManager: AppManager; + + beforeEach(() => { + mockStorageItem = { + _id: 'test_underscore_id', + settings: {}, + } as IAppStorageItem; + + mockStorageItem.settings.testing = TestData.getSetting('testing'); + + const si = mockStorageItem; + mockApp = { + getID() { + return 'testing'; + }, + getStorageItem() { + return si; + }, + setStorageItem(item: IAppStorageItem) {}, + call(method: AppMethod, ...args: Array): Promise { + return Promise.resolve(); + }, + } as ProxiedApp; + + mockBridges = new TestsAppBridges(); + + mockStorage = { + updateSetting(appId: string, setting: ISetting): Promise { + return Promise.resolve(true); + }, + } as AppMetadataStorage; + + const st = mockStorage; + const bri = mockBridges; + const app = mockApp; + mockManager = { + getOneById(appId: string) { + return appId === 'testing' ? app : undefined; + }, + getBridges(): AppBridges { + return bri; + }, + getStorage(): AppMetadataStorage { + return st; + }, + getCommandManager(): AppSlashCommandManager { + return {} as AppSlashCommandManager; + }, + getExternalComponentManager(): AppExternalComponentManager { + return {} as AppExternalComponentManager; + }, + getApiManager(): AppApiManager { + return {} as AppApiManager; + }, + getSchedulerManager() { + return {} as AppSchedulerManager; + }, + getUIActionButtonManager() { + return {} as UIActionButtonManager; + }, + getVideoConfProviderManager() { + return {} as AppVideoConfProviderManager; + }, + } as AppManager; + + mockAccessors = new AppAccessorManager(mockManager); + const ac = mockAccessors; + mockManager.getAccessorManager = function _getAccessorManager(): AppAccessorManager { + return ac; + }; + }); + + afterEach(() => { + mock.restoreAll(); + }); + + it('basicAppSettingsManager', () => { + assert.doesNotThrow(() => new AppSettingsManager(mockManager)); + + const asm = new AppSettingsManager(mockManager); + assert.notStrictEqual(asm.getAppSettings('testing'), mockStorageItem.settings); + assert.deepStrictEqual(asm.getAppSettings('testing'), mockStorageItem.settings); + assert.throws(() => asm.getAppSettings('fake'), { name: 'Error', message: 'No App found by the provided id.' }); + assert.throws(() => { + asm.getAppSettings('testing').testing.value = 'testing'; + }); + + assert.notStrictEqual(asm.getAppSetting('testing', 'testing'), mockStorageItem.settings.testing); + assert.deepStrictEqual(asm.getAppSetting('testing', 'testing'), mockStorageItem.settings.testing); + assert.throws(() => asm.getAppSetting('fake', 'testing'), { name: 'Error', message: 'No App found by the provided id.' }); + assert.throws(() => asm.getAppSetting('testing', 'fake'), { + name: 'Error', + message: 'No setting found for the App by the provided id.', + }); + assert.throws(() => { + asm.getAppSetting('testing', 'testing').value = 'testing'; + }); + }); + + it('updatingSettingViaAppSettingsManager', async () => { + const asm = new AppSettingsManager(mockManager); + + const updateSettingSpy = mock.method(mockStorage, 'updateSetting'); + const callSpy = mock.method(mockApp, 'call'); + const doOnAppSettingsChangeSpy = mock.method((mockBridges as TestsAppBridges).getAppDetailChangesBridge(), 'doOnAppSettingsChange'); + + await assert.rejects(() => asm.updateAppSetting('fake', TestData.getSetting()), { + name: 'Error', + message: 'No App found by the provided id.', + }); + await assert.rejects(() => asm.updateAppSetting('testing', TestData.getSetting('fake')), { + name: 'Error', + message: 'No setting found for the App by the provided id.', + }); + + const set = TestData.getSetting('testing'); + await assert.doesNotReject(() => asm.updateAppSetting('testing', set)); + + assert.strictEqual(updateSettingSpy.mock.calls.length, 1); + assert.strictEqual(updateSettingSpy.mock.calls[0].arguments[0], 'test_underscore_id'); + const settingArg = updateSettingSpy.mock.calls[0].arguments[1] as any; + assert.ok(Object.keys(set).every((k) => (settingArg as any)[k] === (set as any)[k])); + + assert.strictEqual(doOnAppSettingsChangeSpy.mock.calls.length, 1); + assert.deepStrictEqual(doOnAppSettingsChangeSpy.mock.calls[0].arguments, ['testing', set]); + + const onsettingupdatedCalls = callSpy.mock.calls.filter((c: any) => c.arguments[0] === AppMethod.ONSETTINGUPDATED); + assert.strictEqual(onsettingupdatedCalls.length, 1); + assert.deepStrictEqual(onsettingupdatedCalls[0].arguments, [AppMethod.ONSETTINGUPDATED, set]); + }); +}); diff --git a/packages/apps/tests/server/managers/AppSlashCommand.test.ts b/packages/apps/tests/server/managers/AppSlashCommand.test.ts new file mode 100644 index 0000000000000..ffd2f32486fd4 --- /dev/null +++ b/packages/apps/tests/server/managers/AppSlashCommand.test.ts @@ -0,0 +1,27 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { ISlashCommand } from '@rocket.chat/apps-engine/definition/slashcommands'; + +import type { AppManager } from '../../../src/server/AppManager'; +import { AppSlashCommand } from '../../../src/server/managers/AppSlashCommand'; +import type { IAppStorageItem } from '../../../src/server/storage'; +import { TestData } from '../../test-data/utilities'; + +describe('AppSlashCommand', () => { + it('ensureAppSlashCommand', () => { + const mockApp = TestData.getMockApp({ info: { id: 'test', name: 'TestApp' } } as IAppStorageItem, {} as AppManager); + + assert.doesNotThrow(() => new AppSlashCommand(mockApp, {} as ISlashCommand)); + + const ascr = new AppSlashCommand(mockApp, {} as ISlashCommand); + assert.strictEqual(ascr.isRegistered, false); + assert.strictEqual(ascr.isEnabled, false); + assert.strictEqual(ascr.isDisabled, false); + + ascr.hasBeenRegistered(); + assert.strictEqual(ascr.isDisabled, false); + assert.strictEqual(ascr.isEnabled, true); + assert.strictEqual(ascr.isRegistered, true); + }); +}); diff --git a/packages/apps/tests/server/managers/AppSlashCommandManager.test.ts b/packages/apps/tests/server/managers/AppSlashCommandManager.test.ts new file mode 100644 index 0000000000000..53f3086db4f5b --- /dev/null +++ b/packages/apps/tests/server/managers/AppSlashCommandManager.test.ts @@ -0,0 +1,462 @@ +import * as assert from 'node:assert'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; + +import type { ISlashCommandPreviewItem } from '@rocket.chat/apps-engine/definition/slashcommands'; +import { SlashCommandContext } from '@rocket.chat/apps-engine/definition/slashcommands'; + +import type { AppManager } from '../../../src/server/AppManager'; +import type { ProxiedApp } from '../../../src/server/ProxiedApp'; +import type { AppBridges } from '../../../src/server/bridges'; +import type { + AppApiManager, + AppExternalComponentManager, + AppSchedulerManager, + AppVideoConfProviderManager, +} from '../../../src/server/managers'; +import { AppAccessorManager, AppSlashCommandManager } from '../../../src/server/managers'; +import { AppSlashCommand } from '../../../src/server/managers/AppSlashCommand'; +import type { UIActionButtonManager } from '../../../src/server/managers/UIActionButtonManager'; +import { Room } from '../../../src/server/rooms/Room'; +import type { AppLogStorage, IAppStorageItem } from '../../../src/server/storage'; +import { TestsAppBridges } from '../../test-data/bridges/appBridges'; +import { TestsAppLogStorage } from '../../test-data/storage/logStorage'; +import { TestData } from '../../test-data/utilities'; + +describe('AppSlashCommandManager', () => { + let mockBridges: TestsAppBridges; + let mockApp: ProxiedApp; + let mockAccessors: AppAccessorManager; + let mockManager: AppManager; + + function setupMocks() { + mockBridges = new TestsAppBridges(); + const bri = mockBridges; + mockManager.getBridges = function _refreshedGetBridges(): AppBridges { + return bri; + }; + + mock.method(mockBridges.getCommandBridge(), 'doDoesCommandExist'); + mock.method(mockBridges.getCommandBridge(), 'doRegisterCommand'); + mock.method(mockBridges.getCommandBridge(), 'doUnregisterCommand'); + mock.method(mockBridges.getCommandBridge(), 'doEnableCommand'); + mock.method(mockBridges.getCommandBridge(), 'doDisableCommand'); + } + + beforeEach(() => { + mockBridges = new TestsAppBridges(); + + mockApp = TestData.getMockApp({ info: { id: 'testing', name: 'TestApp' } } as IAppStorageItem, {} as AppManager); + + const bri = mockBridges; + const app = mockApp; + mockManager = { + getBridges(): AppBridges { + return bri; + }, + getCommandManager() { + return {} as AppSlashCommandManager; + }, + getExternalComponentManager(): AppExternalComponentManager { + return {} as AppExternalComponentManager; + }, + getApiManager() { + return {} as AppApiManager; + }, + getOneById(appId: string): ProxiedApp { + return appId === 'failMePlease' ? undefined : app; + }, + getLogStorage(): AppLogStorage { + return new TestsAppLogStorage(); + }, + getSchedulerManager() { + return {} as AppSchedulerManager; + }, + getUIActionButtonManager() { + return {} as UIActionButtonManager; + }, + getVideoConfProviderManager() { + return {} as AppVideoConfProviderManager; + }, + } as AppManager; + + mockAccessors = new AppAccessorManager(mockManager); + const ac = mockAccessors; + mockManager.getAccessorManager = function _getAccessorManager(): AppAccessorManager { + return ac; + }; + }); + + afterEach(() => { + mock.restoreAll(); + }); + + it('basicAppSlashCommandManager', () => { + setupMocks(); + assert.throws(() => new AppSlashCommandManager({} as AppManager)); + assert.doesNotThrow(() => new AppSlashCommandManager(mockManager)); + + const ascm = new AppSlashCommandManager(mockManager); + assert.strictEqual((ascm as any).manager, mockManager); + assert.strictEqual((ascm as any).bridge, mockBridges.getCommandBridge()); + assert.strictEqual((ascm as any).accessors, mockManager.getAccessorManager()); + assert.ok((ascm as any).providedCommands !== undefined); + assert.strictEqual((ascm as any).providedCommands.size, 0); + assert.ok((ascm as any).modifiedCommands !== undefined); + assert.strictEqual((ascm as any).modifiedCommands.size, 0); + assert.ok((ascm as any).touchedCommandsToApps !== undefined); + assert.strictEqual((ascm as any).touchedCommandsToApps.size, 0); + assert.ok((ascm as any).appsTouchedCommands !== undefined); + assert.strictEqual((ascm as any).appsTouchedCommands.size, 0); + }); + + it('canCommandBeTouchedBy', () => { + setupMocks(); + const ascm = new AppSlashCommandManager(mockManager); + + assert.strictEqual(ascm.canCommandBeTouchedBy('testing', 'command'), true); + (ascm as any).touchedCommandsToApps.set('just-a-test', 'anotherAppId'); + assert.strictEqual(ascm.canCommandBeTouchedBy('testing', 'just-a-test'), false); + }); + + it('isAlreadyDefined', () => { + setupMocks(); + const ascm = new AppSlashCommandManager(mockManager); + + const reg = new Map(); + reg.set('command', new AppSlashCommand(mockApp, TestData.getSlashCommand('command'))); + + assert.strictEqual(ascm.isAlreadyDefined('command'), false); + (ascm as any).providedCommands.set('testing', reg); + assert.strictEqual(ascm.isAlreadyDefined('command'), true); + assert.strictEqual(ascm.isAlreadyDefined('cOMMand'), true); + assert.strictEqual(ascm.isAlreadyDefined(' command '), true); + assert.strictEqual(ascm.isAlreadyDefined('c0mmand'), false); + }); + + it('setAsTouched', () => { + setupMocks(); + const ascm = new AppSlashCommandManager(mockManager); + + assert.doesNotThrow(() => (ascm as any).setAsTouched('testing', 'command')); + assert.strictEqual((ascm as any).appsTouchedCommands.has('testing'), true); + assert.ok((ascm as any).appsTouchedCommands.get('testing').length > 0); + assert.strictEqual((ascm as any).appsTouchedCommands.get('testing').length, 1); + assert.strictEqual((ascm as any).touchedCommandsToApps.has('command'), true); + assert.strictEqual((ascm as any).touchedCommandsToApps.get('command'), 'testing'); + assert.doesNotThrow(() => (ascm as any).setAsTouched('testing', 'command')); + assert.strictEqual((ascm as any).appsTouchedCommands.get('testing').length, 1); + }); + + it('registerCommand', async () => { + setupMocks(); + const ascm = new AppSlashCommandManager(mockManager); + const doRegisterCommandSpy = (mockBridges.getCommandBridge() as any).doRegisterCommand; + + const regInfo = new AppSlashCommand(mockApp, TestData.getSlashCommand('command')); + + await assert.doesNotReject(() => (ascm as any).registerCommand('testing', regInfo)); + assert.strictEqual(doRegisterCommandSpy.mock.calls.length, 1); + assert.deepStrictEqual(doRegisterCommandSpy.mock.calls[0].arguments, [regInfo.slashCommand, 'testing']); + assert.strictEqual(regInfo.isRegistered, true); + assert.strictEqual(regInfo.isDisabled, false); + assert.strictEqual(regInfo.isEnabled, true); + }); + + it('addCommand', async () => { + setupMocks(); + const cmd = TestData.getSlashCommand('my-cmd'); + const ascm = new AppSlashCommandManager(mockManager); + + await assert.doesNotReject(async () => ascm.addCommand('testing', cmd)); + assert.strictEqual(mockBridges.getCommandBridge().commands.size, 1); + assert.strictEqual((ascm as any).providedCommands.size, 1); + assert.strictEqual((ascm as any).touchedCommandsToApps.get('my-cmd'), 'testing'); + assert.strictEqual((ascm as any).appsTouchedCommands.get('testing').length, 1); + await assert.rejects(() => ascm.addCommand('another-app', cmd), { + name: 'CommandHasAlreadyBeenTouched', + message: 'The command "my-cmd" has already been touched by another App.', + }); + await assert.rejects(() => ascm.addCommand('testing', cmd), { + name: 'CommandAlreadyExists', + message: 'The command "my-cmd" already exists in the system.', + }); + await assert.rejects(() => ascm.addCommand('failMePlease', TestData.getSlashCommand('yet-another')), { + name: 'Error', + message: 'App must exist in order for a command to be added.', + }); + await assert.doesNotReject(async () => ascm.addCommand('testing', TestData.getSlashCommand('another-command'))); + assert.strictEqual((ascm as any).providedCommands.size, 1); + assert.strictEqual((ascm as any).providedCommands.get('testing').size, 2); + await assert.rejects(() => ascm.addCommand('even-another-app', TestData.getSlashCommand('it-exists')), { + name: 'CommandAlreadyExists', + message: 'The command "it-exists" already exists in the system.', + }); + }); + + it('failToModifyAnotherAppsCommand', async () => { + setupMocks(); + const ascm = new AppSlashCommandManager(mockManager); + await ascm.addCommand('other-app', TestData.getSlashCommand('my-cmd')); + + await assert.rejects(() => ascm.modifyCommand('testing', TestData.getSlashCommand('my-cmd')), { + name: 'CommandHasAlreadyBeenTouched', + message: 'The command "my-cmd" has already been touched by another App.', + }); + }); + + it('failToModifyNonExistantAppCommand', async () => { + setupMocks(); + const ascm = new AppSlashCommandManager(mockManager); + + await assert.rejects(() => ascm.modifyCommand('failMePlease', TestData.getSlashCommand('yet-another')), { + name: 'Error', + message: 'App must exist in order to modify a command.', + }); + }); + + it('modifyMyCommand', async () => { + setupMocks(); + const ascm = new AppSlashCommandManager(mockManager); + + await assert.rejects(() => ascm.modifyCommand('testing', TestData.getSlashCommand()), { + name: 'Error', + message: 'You must first register a command before you can modify it.', + }); + await ascm.addCommand('testing', TestData.getSlashCommand('the-cmd')); + await assert.doesNotReject(() => ascm.modifyCommand('testing', TestData.getSlashCommand('the-cmd'))); + }); + + it('modifySystemCommand', async () => { + setupMocks(); + const ascm = new AppSlashCommandManager(mockManager); + + await assert.doesNotReject(() => ascm.modifyCommand('brand-new-id', TestData.getSlashCommand('it-exists'))); + assert.strictEqual((ascm as any).modifiedCommands.size, 1); + assert.ok((ascm as any).modifiedCommands.get('it-exists') !== undefined); + assert.strictEqual((ascm as any).touchedCommandsToApps.get('it-exists'), 'brand-new-id'); + }); + + it('enableMyCommand', async () => { + setupMocks(); + const ascm = new AppSlashCommandManager(mockManager); + const doDoesCommandExistSpy = (mockBridges.getCommandBridge() as any).doDoesCommandExist; + + await assert.rejects(() => ascm.enableCommand('testing', 'doesnt-exist'), { + name: 'Error', + message: 'The command "doesnt-exist" does not exist to enable.', + }); + await ascm.addCommand('testing', TestData.getSlashCommand('command')); + await assert.doesNotReject(() => ascm.enableCommand('testing', 'command')); + assert.strictEqual((ascm as any).providedCommands.get('testing').get('command').isDisabled, false); + assert.strictEqual((ascm as any).providedCommands.get('testing').get('command').isEnabled, true); + await ascm.addCommand('testing', TestData.getSlashCommand('another-command')); + (ascm as any).providedCommands.get('testing').get('another-command').isRegistered = true; + await assert.doesNotReject(() => ascm.enableCommand('testing', 'another-command')); + assert.strictEqual(doDoesCommandExistSpy.mock.calls.length, 3); + }); + + it('enableSystemCommand', async () => { + setupMocks(); + const ascm = new AppSlashCommandManager(mockManager); + const doEnableCommandSpy = (mockBridges.getCommandBridge() as any).doEnableCommand; + const doDoesCommandExistSpy = (mockBridges.getCommandBridge() as any).doDoesCommandExist; + + await assert.doesNotReject(() => ascm.enableCommand('testing', 'it-exists')); + assert.strictEqual(doEnableCommandSpy.mock.calls.length, 1); + assert.deepStrictEqual(doEnableCommandSpy.mock.calls[0].arguments, ['it-exists', 'testing']); + assert.strictEqual(doDoesCommandExistSpy.mock.calls.length, 1); + }); + + it('failToEnableAnotherAppsCommand', async () => { + setupMocks(); + const ascm = new AppSlashCommandManager(mockManager); + await ascm.addCommand('another-app', TestData.getSlashCommand('command')); + + await assert.rejects(() => ascm.enableCommand('my-app', 'command'), { + name: 'CommandHasAlreadyBeenTouched', + message: 'The command "command" has already been touched by another App.', + }); + }); + + it('disableMyCommand', async () => { + setupMocks(); + const ascm = new AppSlashCommandManager(mockManager); + const doDoesCommandExistSpy = (mockBridges.getCommandBridge() as any).doDoesCommandExist; + + await assert.rejects(() => ascm.disableCommand('testing', 'doesnt-exist'), { + name: 'Error', + message: 'The command "doesnt-exist" does not exist to disable.', + }); + await ascm.addCommand('testing', TestData.getSlashCommand('command')); + await assert.doesNotReject(() => ascm.disableCommand('testing', 'command')); + assert.strictEqual((ascm as any).providedCommands.get('testing').get('command').isDisabled, true); + assert.strictEqual((ascm as any).providedCommands.get('testing').get('command').isEnabled, false); + await ascm.addCommand('testing', TestData.getSlashCommand('another-command')); + (ascm as any).providedCommands.get('testing').get('another-command').isRegistered = true; + await assert.doesNotReject(() => ascm.disableCommand('testing', 'another-command')); + assert.strictEqual(doDoesCommandExistSpy.mock.calls.length, 3); + }); + + it('disableSystemCommand', async () => { + setupMocks(); + const ascm = new AppSlashCommandManager(mockManager); + const doDisableCommandSpy = (mockBridges.getCommandBridge() as any).doDisableCommand; + const doDoesCommandExistSpy = (mockBridges.getCommandBridge() as any).doDoesCommandExist; + + await assert.doesNotReject(() => ascm.disableCommand('testing', 'it-exists')); + assert.strictEqual(doDisableCommandSpy.mock.calls.length, 1); + assert.deepStrictEqual(doDisableCommandSpy.mock.calls[0].arguments, ['it-exists', 'testing']); + assert.strictEqual(doDoesCommandExistSpy.mock.calls.length, 1); + }); + + it('failToDisableAnotherAppsCommand', async () => { + setupMocks(); + const ascm = new AppSlashCommandManager(mockManager); + await ascm.addCommand('another-app', TestData.getSlashCommand('command')); + + await assert.rejects(() => ascm.disableCommand('my-app', 'command'), { + name: 'CommandHasAlreadyBeenTouched', + message: 'The command "command" has already been touched by another App.', + }); + }); + + it('registerCommands', async () => { + setupMocks(); + const ascm = new AppSlashCommandManager(mockManager); + const doRegisterCommandSpy = (mockBridges.getCommandBridge() as any).doRegisterCommand; + + const registerCommandSpy = mock.method(ascm as any, 'registerCommand'); + + await ascm.addCommand('testing', TestData.getSlashCommand('enabled-command')); + const enabledRegInfo = (ascm as any).providedCommands.get('testing').get('enabled-command') as AppSlashCommand; + await ascm.addCommand('testing', TestData.getSlashCommand('disabled-command')); + await ascm.disableCommand('testing', 'disabled-command'); + const disabledRegInfo = (ascm as any).providedCommands.get('testing').get('disabled-command') as AppSlashCommand; + + await assert.doesNotReject(() => ascm.registerCommands('non-existant')); + await assert.doesNotReject(() => ascm.registerCommands('testing')); + assert.strictEqual(enabledRegInfo.isRegistered, true); + assert.strictEqual(disabledRegInfo.isRegistered, false); + assert.strictEqual( + registerCommandSpy.mock.calls.filter((c: any) => c.arguments[0] === 'testing' && c.arguments[1] === enabledRegInfo).length, + 1, + ); + assert.strictEqual( + doRegisterCommandSpy.mock.calls.filter((c: any) => c.arguments[0] === enabledRegInfo.slashCommand && c.arguments[1] === 'testing') + .length, + 1, + ); + }); + + it('unregisterCommands', async () => { + setupMocks(); + const ascm = new AppSlashCommandManager(mockManager); + const doUnregisterCommandSpy = (mockBridges.getCommandBridge() as any).doUnregisterCommand; + + await ascm.addCommand('testing', TestData.getSlashCommand('command')); + await ascm.modifyCommand('testing', TestData.getSlashCommand('it-exists')); + + await assert.doesNotReject(() => ascm.unregisterCommands('non-existant')); + await assert.doesNotReject(() => ascm.unregisterCommands('testing')); + assert.strictEqual(doUnregisterCommandSpy.mock.calls.length, 1); + }); + + it('executeCommands', async () => { + setupMocks(); + const ascm = new AppSlashCommandManager(mockManager); + await ascm.addCommand('testing', TestData.getSlashCommand('command')); + await ascm.addCommand('testing', TestData.getSlashCommand('not-registered')); + await ascm.addCommand('testing', TestData.getSlashCommand('disabled-command')); + await ascm.disableCommand('testing', 'not-registered'); + await ascm.registerCommands('testing'); + (ascm as any).providedCommands.get('testing').get('disabled-command').isDisabled = true; + await ascm.modifyCommand('testing', TestData.getSlashCommand('it-exists')); + + const context = new SlashCommandContext(TestData.getUser(), TestData.getRoom(), []); + + await assert.doesNotReject(() => ascm.executeCommand('nope', context)); + await assert.doesNotReject(() => ascm.executeCommand('it-exists', context)); + await assert.doesNotReject(() => ascm.executeCommand('command', context)); + await assert.doesNotReject(() => ascm.executeCommand('not-registered', context)); + await assert.doesNotReject(() => ascm.executeCommand('disabled-command', context)); + + const classContext = new SlashCommandContext(TestData.getUser(), new Room(TestData.getRoom(), mockManager), []); + await assert.doesNotReject(() => ascm.executeCommand('it-exists', classContext)); + + // set it up for no "no app failure" + const failedItems = new Map(); + const asm = new AppSlashCommand(mockApp, TestData.getSlashCommand('failure')); + asm.hasBeenRegistered(); + failedItems.set('failure', asm); + (ascm as any).providedCommands.set('failMePlease', failedItems); + (ascm as any).touchedCommandsToApps.set('failure', 'failMePlease'); + await assert.rejects(() => ascm.executeCommand('failure', context)); + }); + + it('getPreviews', async () => { + setupMocks(); + const ascm = new AppSlashCommandManager(mockManager); + await ascm.addCommand('testing', TestData.getSlashCommand('command')); + await ascm.addCommand('testing', TestData.getSlashCommand('not-registered')); + await ascm.addCommand('testing', TestData.getSlashCommand('disabled-command')); + await ascm.disableCommand('testing', 'not-registered'); + await ascm.registerCommands('testing'); + (ascm as any).providedCommands.get('testing').get('disabled-command').isDisabled = true; + await ascm.modifyCommand('testing', TestData.getSlashCommand('it-exists')); + + const context = new SlashCommandContext(TestData.getUser(), TestData.getRoom(), ['testing']); + + await assert.doesNotReject(() => ascm.getPreviews('nope', context)); + await assert.doesNotReject(() => ascm.getPreviews('it-exists', context)); + await assert.doesNotReject(() => ascm.getPreviews('command', context)); + await assert.doesNotReject(() => ascm.getPreviews('not-registered', context)); + await assert.doesNotReject(() => ascm.getPreviews('disabled-command', context)); + + const classContext = new SlashCommandContext(TestData.getUser(), new Room(TestData.getRoom(), mockManager), []); + await assert.doesNotReject(() => ascm.getPreviews('it-exists', classContext)); + + // set it up for no "no app failure" + const failedItems = new Map(); + const asm = new AppSlashCommand(mockApp, TestData.getSlashCommand('failure')); + asm.hasBeenRegistered(); + failedItems.set('failure', asm); + (ascm as any).providedCommands.set('failMePlease', failedItems); + (ascm as any).touchedCommandsToApps.set('failure', 'failMePlease'); + await assert.doesNotReject(() => ascm.getPreviews('failure', context)); + }); + + it('executePreview', async () => { + setupMocks(); + const previewItem = {} as ISlashCommandPreviewItem; + const ascm = new AppSlashCommandManager(mockManager); + await ascm.addCommand('testing', TestData.getSlashCommand('command')); + await ascm.addCommand('testing', TestData.getSlashCommand('not-registered')); + await ascm.addCommand('testing', TestData.getSlashCommand('disabled-command')); + await ascm.disableCommand('testing', 'not-registered'); + await ascm.registerCommands('testing'); + (ascm as any).providedCommands.get('testing').get('disabled-command').isDisabled = true; + await ascm.modifyCommand('testing', TestData.getSlashCommand('it-exists')); + + const context = new SlashCommandContext(TestData.getUser(), TestData.getRoom(), ['testing']); + + await assert.doesNotReject(() => ascm.executePreview('nope', previewItem, context)); + await assert.doesNotReject(() => ascm.executePreview('it-exists', previewItem, context)); + await assert.doesNotReject(() => ascm.executePreview('command', previewItem, context)); + await assert.doesNotReject(() => ascm.executePreview('not-registered', previewItem, context)); + await assert.doesNotReject(() => ascm.executePreview('disabled-command', previewItem, context)); + + const classContext = new SlashCommandContext(TestData.getUser(), new Room(TestData.getRoom(), mockManager), []); + await assert.doesNotReject(() => ascm.executePreview('it-exists', previewItem, classContext)); + + // set it up for no "no app failure" + const failedItems = new Map(); + const asm = new AppSlashCommand(mockApp, TestData.getSlashCommand('failure')); + asm.hasBeenRegistered(); + failedItems.set('failure', asm); + (ascm as any).providedCommands.set('failMePlease', failedItems); + (ascm as any).touchedCommandsToApps.set('failure', 'failMePlease'); + await ascm.executePreview('nope', previewItem, context).catch(() => {}); + + await assert.doesNotReject(() => ascm.executePreview('failure', previewItem, context)); + }); +}); diff --git a/packages/apps/tests/server/managers/AppVideoConfProvider.test.ts b/packages/apps/tests/server/managers/AppVideoConfProvider.test.ts new file mode 100644 index 0000000000000..d85bdda196c01 --- /dev/null +++ b/packages/apps/tests/server/managers/AppVideoConfProvider.test.ts @@ -0,0 +1,21 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { IVideoConfProvider } from '@rocket.chat/apps-engine/definition/videoConfProviders'; + +import type { ProxiedApp } from '../../../src/server/ProxiedApp'; +import { AppVideoConfProvider } from '../../../src/server/managers/AppVideoConfProvider'; + +describe('AppVideoConfProvider', () => { + it('ensureAppVideoConfManager', () => { + const mockApp = {} as ProxiedApp; + + assert.doesNotThrow(() => new AppVideoConfProvider(mockApp, {} as IVideoConfProvider)); + + const ascr = new AppVideoConfProvider(mockApp, {} as IVideoConfProvider); + assert.strictEqual(ascr.isRegistered, false); + + ascr.hasBeenRegistered(); + assert.strictEqual(ascr.isRegistered, true); + }); +}); diff --git a/packages/apps/tests/server/managers/AppVideoConfProviderManager.test.ts b/packages/apps/tests/server/managers/AppVideoConfProviderManager.test.ts new file mode 100644 index 0000000000000..16265aaa07ffd --- /dev/null +++ b/packages/apps/tests/server/managers/AppVideoConfProviderManager.test.ts @@ -0,0 +1,359 @@ +import * as assert from 'node:assert'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; + +import type { AppManager } from '../../../src/server/AppManager'; +import type { ProxiedApp } from '../../../src/server/ProxiedApp'; +import type { AppBridges } from '../../../src/server/bridges'; +import type { AppApiManager, AppExternalComponentManager, AppSchedulerManager, AppSlashCommandManager } from '../../../src/server/managers'; +import { AppAccessorManager, AppVideoConfProviderManager } from '../../../src/server/managers'; +import { AppVideoConfProvider } from '../../../src/server/managers/AppVideoConfProvider'; +import type { UIActionButtonManager } from '../../../src/server/managers/UIActionButtonManager'; +import type { AppLogStorage, IAppStorageItem } from '../../../src/server/storage'; +import { TestsAppBridges } from '../../test-data/bridges/appBridges'; +import { TestsAppLogStorage } from '../../test-data/storage/logStorage'; +import { TestData } from '../../test-data/utilities'; + +describe('AppVideoConfProviderManager', () => { + let mockBridges: TestsAppBridges; + let mockApp: ProxiedApp; + let mockAccessors: AppAccessorManager; + let mockManager: AppManager; + + beforeEach(() => { + mockBridges = new TestsAppBridges(); + + mockApp = TestData.getMockApp({ info: { id: 'testing', name: 'testing' } } as IAppStorageItem, {} as AppManager); + + const bri = mockBridges; + const app = mockApp; + mockManager = { + getBridges(): AppBridges { + return bri; + }, + getCommandManager() { + return {} as AppSlashCommandManager; + }, + getExternalComponentManager(): AppExternalComponentManager { + return {} as AppExternalComponentManager; + }, + getApiManager() { + return {} as AppApiManager; + }, + getOneById(appId: string): ProxiedApp { + return appId === 'failMePlease' ? undefined : app; + }, + getLogStorage(): AppLogStorage { + return new TestsAppLogStorage(); + }, + getSchedulerManager() { + return {} as AppSchedulerManager; + }, + getUIActionButtonManager() { + return {} as UIActionButtonManager; + }, + getVideoConfProviderManager() { + return {} as AppVideoConfProviderManager; + }, + } as AppManager; + + mockAccessors = new AppAccessorManager(mockManager); + const ac = mockAccessors; + mockManager.getAccessorManager = function _getAccessorManager(): AppAccessorManager { + return ac; + }; + }); + + afterEach(() => { + mock.restoreAll(); + }); + + it('basicAppVideoConfProviderManager', () => { + assert.throws(() => new AppVideoConfProviderManager({} as AppManager)); + assert.doesNotThrow(() => new AppVideoConfProviderManager(mockManager)); + + const manager = new AppVideoConfProviderManager(mockManager); + assert.strictEqual((manager as any).manager, mockManager); + assert.strictEqual((manager as any).accessors, mockManager.getAccessorManager()); + assert.ok((manager as any).videoConfProviders !== undefined); + assert.strictEqual((manager as any).videoConfProviders.size, 0); + }); + + it('addProvider', () => { + const provider = TestData.getVideoConfProvider(); + const manager = new AppVideoConfProviderManager(mockManager); + + assert.doesNotThrow(() => manager.addProvider('testing', provider)); + assert.strictEqual((manager as any).videoConfProviders.size, 1); + assert.throws(() => manager.addProvider('failMePlease', provider), { + name: 'Error', + message: 'App must exist in order for a video conference provider to be added.', + }); + assert.strictEqual((manager as any).videoConfProviders.size, 1); + }); + + it('ignoreAppsWithoutProviders', async () => { + const manager = new AppVideoConfProviderManager(mockManager); + + await assert.doesNotReject(() => manager.registerProviders('non-existant')); + }); + + it('registerProviders', async () => { + const manager = new AppVideoConfProviderManager(mockManager); + + manager.addProvider('firstApp', TestData.getVideoConfProvider()); + const appInfo = (manager as any).videoConfProviders.get('firstApp') as Map; + assert.ok(appInfo !== undefined); + const regInfo = appInfo.get('test'); + assert.ok(regInfo !== undefined); + + assert.strictEqual(regInfo.isRegistered, false); + await assert.doesNotReject(() => manager.registerProviders('firstApp')); + assert.strictEqual(regInfo.isRegistered, true); + }); + + it('registerTwoProviders', async () => { + const manager = new AppVideoConfProviderManager(mockManager); + + manager.addProvider('firstApp', TestData.getVideoConfProvider()); + manager.addProvider('firstApp', TestData.getVideoConfProvider('another-test')); + const firstApp = (manager as any).videoConfProviders.get('firstApp') as Map; + assert.ok(firstApp !== undefined); + const firstRegInfo = firstApp.get('test'); + assert.ok(firstRegInfo !== undefined); + const secondRegInfo = firstApp.get('another-test'); + assert.ok(secondRegInfo !== undefined); + + assert.strictEqual(firstRegInfo.isRegistered, false); + assert.strictEqual(secondRegInfo.isRegistered, false); + await assert.doesNotReject(() => manager.registerProviders('firstApp')); + assert.strictEqual(firstRegInfo.isRegistered, true); + assert.strictEqual(secondRegInfo.isRegistered, true); + }); + + it('registerProvidersFromMultipleApps', async () => { + const manager = new AppVideoConfProviderManager(mockManager); + + manager.addProvider('firstApp', TestData.getVideoConfProvider()); + manager.addProvider('firstApp', TestData.getVideoConfProvider('another-test')); + manager.addProvider('secondApp', TestData.getVideoConfProvider('test3')); + + const firstApp = (manager as any).videoConfProviders.get('firstApp') as Map; + assert.ok(firstApp !== undefined); + const firstRegInfo = firstApp.get('test'); + const secondRegInfo = firstApp.get('another-test'); + assert.ok(firstRegInfo !== undefined); + assert.ok(secondRegInfo !== undefined); + const secondApp = (manager as any).videoConfProviders.get('secondApp') as Map; + assert.ok(secondApp !== undefined); + const thirdRegInfo = secondApp.get('test3'); + assert.ok(thirdRegInfo !== undefined); + + assert.strictEqual(firstRegInfo.isRegistered, false); + assert.strictEqual(secondRegInfo.isRegistered, false); + await assert.doesNotReject(() => manager.registerProviders('firstApp')); + assert.strictEqual(firstRegInfo.isRegistered, true); + assert.strictEqual(secondRegInfo.isRegistered, true); + assert.strictEqual(thirdRegInfo.isRegistered, false); + await assert.doesNotReject(() => manager.registerProviders('secondApp')); + assert.strictEqual(thirdRegInfo.isRegistered, true); + }); + + it('failToRegisterSameProvider', () => { + const manager = new AppVideoConfProviderManager(mockManager); + + manager.addProvider('firstApp', TestData.getVideoConfProvider()); + + assert.throws(() => manager.addProvider('secondApp', TestData.getVideoConfProvider('test')), { + name: 'VideoConfProviderAlreadyExists', + message: `The video conference provider "test" was already registered by another App.`, + }); + }); + + it('unregisterProviders', async () => { + const manager = new AppVideoConfProviderManager(mockManager); + + manager.addProvider('testing', TestData.getVideoConfProvider()); + const regInfo = (manager as any).videoConfProviders.get('testing').get('test') as AppVideoConfProvider; + await assert.doesNotReject(() => manager.registerProviders('testing')); + + await assert.doesNotReject(() => manager.unregisterProviders('non-existant')); + assert.strictEqual(regInfo.isRegistered, true); + await assert.doesNotReject(() => manager.unregisterProviders('testing')); + assert.strictEqual(regInfo.isRegistered, false); + }); + + it('failToGenerateUrlWithoutProvider', async () => { + const manager = new AppVideoConfProviderManager(mockManager); + + const call = TestData.getVideoConfData(); + + await assert.rejects(() => manager.generateUrl('test', call), { + name: 'VideoConfProviderNotRegistered', + message: `The video conference provider "test" is not registered in the system.`, + }); + + manager.addProvider('testing', TestData.getVideoConfProvider()); + + await assert.rejects(() => manager.generateUrl('test', call), { + name: 'VideoConfProviderNotRegistered', + message: `The video conference provider "test" is not registered in the system.`, + }); + }); + + it('generateUrl', async () => { + const manager = new AppVideoConfProviderManager(mockManager); + manager.addProvider('testing', TestData.getVideoConfProvider()); + await manager.registerProviders('testing'); + + const call = TestData.getVideoConfData(); + + mock.method(AppVideoConfProvider.prototype, 'runGenerateUrl', () => 'test/first-call'); + const url = await manager.generateUrl('test', call); + assert.strictEqual(url, 'test/first-call'); + }); + + it('generateUrlWithMultipleProvidersAvailable', async () => { + const manager = new AppVideoConfProviderManager(mockManager); + manager.addProvider('testing', TestData.getVideoConfProvider()); + manager.addProvider('testing', TestData.getVideoConfProvider('test2')); + await manager.registerProviders('testing'); + manager.addProvider('secondApp', TestData.getVideoConfProvider('differentProvider')); + await manager.registerProviders('secondApp'); + + const call = TestData.getVideoConfData(); + + const testProvider = (manager as any).videoConfProviders.get('testing').get('test') as AppVideoConfProvider; + const test2Provider = (manager as any).videoConfProviders.get('testing').get('test2') as AppVideoConfProvider; + const differentProvider = (manager as any).videoConfProviders.get('secondApp').get('differentprovider') as AppVideoConfProvider; + + mock.method(testProvider, 'runGenerateUrl', () => 'test/first-call'); + mock.method(test2Provider, 'runGenerateUrl', () => 'test2/first-call'); + mock.method(differentProvider, 'runGenerateUrl', () => 'differentProvider/first-call'); + + assert.strictEqual(await manager.generateUrl('test', call), 'test/first-call'); + assert.strictEqual(await manager.generateUrl('test2', call), 'test2/first-call'); + assert.strictEqual(await manager.generateUrl('differentProvider', call), 'differentProvider/first-call'); + }); + + it('failToGenerateUrlWithUnknownProvider', async () => { + const call = TestData.getVideoConfData(); + const manager = new AppVideoConfProviderManager(mockManager); + await assert.rejects(() => manager.generateUrl('unknownProvider', call), { + name: 'VideoConfProviderNotRegistered', + message: `The video conference provider "unknownProvider" is not registered in the system.`, + }); + }); + + it('failToGenerateUrlWithUnregisteredProvider', async () => { + const call = TestData.getVideoConfData(); + const manager = new AppVideoConfProviderManager(mockManager); + manager.addProvider('unregisteredApp', TestData.getVideoConfProvider('unregisteredProvider')); + await assert.rejects(() => manager.generateUrl('unregisteredProvider', call), { + name: 'VideoConfProviderNotRegistered', + message: `The video conference provider "unregisteredProvider" is not registered in the system.`, + }); + }); + + it('failToCustomizeUrlWithoutProvider', async () => { + const manager = new AppVideoConfProviderManager(mockManager); + const call = TestData.getVideoConfDataExtended(); + const user = TestData.getVideoConferenceUser(); + + await assert.rejects(() => manager.customizeUrl('test', call, user, {}), { + name: 'VideoConfProviderNotRegistered', + message: `The video conference provider "test" is not registered in the system.`, + }); + + manager.addProvider('testing', TestData.getVideoConfProvider()); + + await assert.rejects(() => manager.customizeUrl('test', call, user, {}), { + name: 'VideoConfProviderNotRegistered', + message: `The video conference provider "test" is not registered in the system.`, + }); + }); + + it('customizeUrl', async () => { + const manager = new AppVideoConfProviderManager(mockManager); + manager.addProvider('testing', TestData.getVideoConfProvider()); + await manager.registerProviders('testing'); + + const call = TestData.getVideoConfDataExtended(); + const user = TestData.getVideoConferenceUser(); + + const cases: any = [ + { + name: 'test', + call, + user, + options: {}, + runCustomizeUrl: 'test/first-call#caller', + result: 'test/first-call#caller', + }, + { + name: 'test', + call, + user: undefined, + options: {}, + runCustomizeUrl: 'test/first-call#', + result: 'test/first-call#', + }, + ]; + + for (const c of cases) { + mock.method(AppVideoConfProvider.prototype, 'runCustomizeUrl', () => c.runCustomizeUrl); + assert.strictEqual(await manager.customizeUrl(c.name, c.call, c.user, c.options), c.result); + } + }); + + it('customizeUrlWithMultipleProvidersAvailable', async () => { + const manager = new AppVideoConfProviderManager(mockManager); + manager.addProvider('testing', TestData.getVideoConfProvider()); + manager.addProvider('testing', TestData.getVideoConfProvider('test2')); + await manager.registerProviders('testing'); + manager.addProvider('secondApp', TestData.getVideoConfProvider('differentProvider')); + await manager.registerProviders('secondApp'); + + const call = TestData.getVideoConfDataExtended(); + const user = TestData.getVideoConferenceUser(); + + const testProvider = (manager as any).videoConfProviders.get('testing').get('test') as AppVideoConfProvider; + const test2Provider = (manager as any).videoConfProviders.get('testing').get('test2') as AppVideoConfProvider; + const differentProvider = (manager as any).videoConfProviders.get('secondApp').get('differentprovider') as AppVideoConfProvider; + + mock.method(testProvider, 'runCustomizeUrl', (_call: any, user: any) => (user ? 'test/first-call#caller' : 'test/first-call#')); + mock.method(test2Provider, 'runCustomizeUrl', (_call: any, user: any) => (user ? 'test2/first-call#caller' : 'test2/first-call#')); + mock.method(differentProvider, 'runCustomizeUrl', (_call: any, user: any) => + user ? 'differentProvider/first-call#caller' : 'differentProvider/first-call#', + ); + + assert.strictEqual(await manager.customizeUrl('test', call, user, {}), 'test/first-call#caller'); + assert.strictEqual(await manager.customizeUrl('test', call, undefined, {}), 'test/first-call#'); + assert.strictEqual(await manager.customizeUrl('test2', call, user, {}), 'test2/first-call#caller'); + assert.strictEqual(await manager.customizeUrl('test2', call, undefined, {}), 'test2/first-call#'); + assert.strictEqual(await manager.customizeUrl('differentProvider', call, user, {}), 'differentProvider/first-call#caller'); + assert.strictEqual(await manager.customizeUrl('differentProvider', call, undefined, {}), 'differentProvider/first-call#'); + }); + + it('failToCustomizeUrlWithUnknownProvider', async () => { + const call = TestData.getVideoConfDataExtended(); + const user = TestData.getVideoConferenceUser(); + const manager = new AppVideoConfProviderManager(mockManager); + + await assert.rejects(() => manager.customizeUrl('unknownProvider', call, user, {}), { + name: 'VideoConfProviderNotRegistered', + message: `The video conference provider "unknownProvider" is not registered in the system.`, + }); + }); + + it('failToCustomizeUrlWithUnregisteredProvider', async () => { + const call = TestData.getVideoConfDataExtended(); + const user = TestData.getVideoConferenceUser(); + const manager = new AppVideoConfProviderManager(mockManager); + + manager.addProvider('unregisteredApp', TestData.getVideoConfProvider('unregisteredProvider')); + await assert.rejects(() => manager.customizeUrl('unregisteredProvider', call, user, {}), { + name: 'VideoConfProviderNotRegistered', + message: `The video conference provider "unregisteredProvider" is not registered in the system.`, + }); + }); +}); diff --git a/packages/apps/tests/server/managers/UIActionButtonManager.test.ts b/packages/apps/tests/server/managers/UIActionButtonManager.test.ts new file mode 100644 index 0000000000000..f17fa2143badb --- /dev/null +++ b/packages/apps/tests/server/managers/UIActionButtonManager.test.ts @@ -0,0 +1,311 @@ +import * as assert from 'node:assert'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; + +import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; +import type { IUIActionButtonDescriptor } from '@rocket.chat/apps-engine/definition/ui'; +import { UIActionButtonContext } from '@rocket.chat/apps-engine/definition/ui'; + +import type { AppManager } from '../../../src/server/AppManager'; +import type { ProxiedApp } from '../../../src/server/ProxiedApp'; +import type { AppActivationBridge, AppBridges } from '../../../src/server/bridges'; +import { AppPermissionManager } from '../../../src/server/managers/AppPermissionManager'; +import { UIActionButtonManager } from '../../../src/server/managers/UIActionButtonManager'; +import { AppPermissions } from '../../../src/server/permissions/AppPermissions'; +import { TestsAppBridges } from '../../test-data/bridges/appBridges'; + +describe('UIActionButtonManager', () => { + let mockBridges: TestsAppBridges; + let mockApp: ProxiedApp; + let mockApp2: ProxiedApp; + let mockManager: AppManager; + let mockActivationBridge: AppActivationBridge; + let hasPermissionSpy: ReturnType; + let notifyAboutErrorSpy: ReturnType; + let doActionsChangedSpy: ReturnType; + + beforeEach(() => { + mockBridges = new TestsAppBridges(); + mockActivationBridge = mockBridges.getAppActivationBridge(); + + mockApp = { + getID() { + return 'testing-app'; + }, + getName() { + return 'Test App'; + }, + getStatus() { + return Promise.resolve(AppStatus.AUTO_ENABLED); + }, + } as ProxiedApp; + + mockApp2 = { + getID() { + return 'testing-app-2'; + }, + getName() { + return 'Test App 2'; + }, + getStatus() { + return Promise.resolve(AppStatus.AUTO_ENABLED); + }, + } as ProxiedApp; + + const bri = mockBridges; + const app = mockApp; + const app2 = mockApp2; + mockManager = { + getBridges(): AppBridges { + return bri; + }, + getOneById: (appId: string): ProxiedApp | undefined => { + if (appId === 'testing-app') { + return app; + } + if (appId === 'testing-app-2') { + return app2; + } + return undefined; + }, + } as AppManager; + + notifyAboutErrorSpy = mock.method(AppPermissionManager, 'notifyAboutError'); + hasPermissionSpy = mock.method(AppPermissionManager, 'hasPermission'); + doActionsChangedSpy = mock.method(mockActivationBridge, 'doActionsChanged'); + }); + + afterEach(() => { + mock.restoreAll(); + }); + + it('basicUIActionButtonManager', () => { + assert.doesNotThrow(() => new UIActionButtonManager(mockManager)); + + const manager = new UIActionButtonManager(mockManager); + assert.strictEqual((manager as any).manager, mockManager); + assert.strictEqual((manager as any).activationBridge, mockActivationBridge); + assert.ok((manager as any).registeredActionButtons !== undefined); + assert.strictEqual((manager as any).registeredActionButtons.size, 0); + }); + + it('registerActionButtonWithPermission', () => { + hasPermissionSpy.mock.mockImplementation(() => true); + + const manager = new UIActionButtonManager(mockManager); + const button: IUIActionButtonDescriptor = { + actionId: 'test-action', + context: UIActionButtonContext.MESSAGE_ACTION, + labelI18n: 'test.label', + }; + + const result = manager.registerActionButton('testing-app', button); + + assert.strictEqual(result, true); + assert.strictEqual(hasPermissionSpy.mock.calls.length, 1); + assert.deepStrictEqual(hasPermissionSpy.mock.calls[0].arguments, ['testing-app', AppPermissions.ui.registerButtons]); + assert.ok(doActionsChangedSpy.mock.calls.length > 0); + assert.strictEqual((manager as any).registeredActionButtons.size, 1); + assert.strictEqual((manager as any).registeredActionButtons.get('testing-app').size, 1); + assert.strictEqual((manager as any).registeredActionButtons.get('testing-app').get('test-action'), button); + }); + + it('registerActionButtonWithoutPermission', () => { + hasPermissionSpy.mock.mockImplementation(() => false); + notifyAboutErrorSpy.mock.mockImplementation(() => {}); + + const manager = new UIActionButtonManager(mockManager); + const button: IUIActionButtonDescriptor = { + actionId: 'test-action', + context: UIActionButtonContext.MESSAGE_ACTION, + labelI18n: 'test.label', + }; + + const result = manager.registerActionButton('testing-app', button); + + assert.strictEqual(result, false); + assert.strictEqual(hasPermissionSpy.mock.calls.length, 1); + assert.deepStrictEqual(hasPermissionSpy.mock.calls[0].arguments, ['testing-app', AppPermissions.ui.registerButtons]); + assert.ok(notifyAboutErrorSpy.mock.calls.length > 0); + assert.strictEqual(doActionsChangedSpy.mock.calls.length, 0); + assert.strictEqual((manager as any).registeredActionButtons.size, 0); + }); + + it('registerMultipleButtonsForSameApp', () => { + hasPermissionSpy.mock.mockImplementation(() => true); + + const manager = new UIActionButtonManager(mockManager); + const button1: IUIActionButtonDescriptor = { + actionId: 'action-1', + context: UIActionButtonContext.MESSAGE_ACTION, + labelI18n: 'test.label1', + }; + const button2: IUIActionButtonDescriptor = { + actionId: 'action-2', + context: UIActionButtonContext.ROOM_ACTION, + labelI18n: 'test.label2', + }; + + manager.registerActionButton('testing-app', button1); + manager.registerActionButton('testing-app', button2); + + assert.strictEqual((manager as any).registeredActionButtons.size, 1); + assert.strictEqual((manager as any).registeredActionButtons.get('testing-app').size, 2); + assert.strictEqual((manager as any).registeredActionButtons.get('testing-app').get('action-1'), button1); + assert.strictEqual((manager as any).registeredActionButtons.get('testing-app').get('action-2'), button2); + }); + + it('clearAppActionButtons', () => { + hasPermissionSpy.mock.mockImplementation(() => true); + + const manager = new UIActionButtonManager(mockManager); + const button: IUIActionButtonDescriptor = { + actionId: 'test-action', + context: UIActionButtonContext.MESSAGE_ACTION, + labelI18n: 'test.label', + }; + + manager.registerActionButton('testing-app', button); + assert.strictEqual((manager as any).registeredActionButtons.get('testing-app').size, 1); + + manager.clearAppActionButtons('testing-app'); + + assert.strictEqual((manager as any).registeredActionButtons.get('testing-app').size, 0); + assert.strictEqual(doActionsChangedSpy.mock.calls.length, 2); + }); + + it('getAppActionButtons', () => { + hasPermissionSpy.mock.mockImplementation(() => true); + + const manager = new UIActionButtonManager(mockManager); + const button: IUIActionButtonDescriptor = { + actionId: 'test-action', + context: UIActionButtonContext.MESSAGE_ACTION, + labelI18n: 'test.label', + }; + + manager.registerActionButton('testing-app', button); + + const buttons = manager.getAppActionButtons('testing-app'); + assert.ok(buttons !== undefined); + assert.strictEqual(buttons?.size, 1); + assert.strictEqual(buttons?.get('test-action'), button); + + const nonExistentButtons = manager.getAppActionButtons('non-existent'); + assert.strictEqual(nonExistentButtons, undefined); + }); + + it('getAllActionButtonsFromEnabledApp', async () => { + hasPermissionSpy.mock.mockImplementation(() => true); + + mock.method(mockApp, 'getStatus', () => Promise.resolve(AppStatus.AUTO_ENABLED)); + + const manager = new UIActionButtonManager(mockManager); + const button: IUIActionButtonDescriptor = { + actionId: 'test-action', + context: UIActionButtonContext.MESSAGE_ACTION, + labelI18n: 'test.label', + }; + + manager.registerActionButton('testing-app', button); + + const allButtons = await manager.getAllActionButtons(); + + assert.ok(allButtons !== undefined); + assert.strictEqual(allButtons.length, 1); + assert.strictEqual(allButtons[0].actionId, 'test-action'); + assert.strictEqual(allButtons[0].appId, 'testing-app'); + assert.strictEqual(allButtons[0].context, UIActionButtonContext.MESSAGE_ACTION); + assert.strictEqual(allButtons[0].labelI18n, 'test.label'); + }); + + it('getAllActionButtonsFromDisabledApp', async () => { + hasPermissionSpy.mock.mockImplementation(() => true); + + mock.method(mockApp, 'getStatus', () => Promise.resolve(AppStatus.DISABLED)); + + const manager = new UIActionButtonManager(mockManager); + const button: IUIActionButtonDescriptor = { + actionId: 'test-action', + context: UIActionButtonContext.MESSAGE_ACTION, + labelI18n: 'test.label', + }; + + manager.registerActionButton('testing-app', button); + + const allButtons = await manager.getAllActionButtons(); + + assert.ok(allButtons !== undefined); + assert.strictEqual(allButtons.length, 0); + }); + + it('getAllActionButtonsFromNonExistentApp', async () => { + hasPermissionSpy.mock.mockImplementation(() => true); + + const manager = new UIActionButtonManager(mockManager); + const button: IUIActionButtonDescriptor = { + actionId: 'test-action', + context: UIActionButtonContext.MESSAGE_ACTION, + labelI18n: 'test.label', + }; + + manager.registerActionButton('non-existent-app', button); + + const allButtons = await manager.getAllActionButtons(); + + assert.ok(allButtons !== undefined); + assert.strictEqual(allButtons.length, 0); + }); + + it('getAllActionButtonsWithStatusError', async () => { + hasPermissionSpy.mock.mockImplementation(() => true); + + mock.method(mockApp, 'getStatus', () => Promise.reject(new Error('Status error'))); + + const manager = new UIActionButtonManager(mockManager); + const button: IUIActionButtonDescriptor = { + actionId: 'test-action', + context: UIActionButtonContext.MESSAGE_ACTION, + labelI18n: 'test.label', + }; + + manager.registerActionButton('testing-app', button); + + const allButtons = await manager.getAllActionButtons(); + + assert.ok(allButtons !== undefined); + assert.strictEqual(allButtons.length, 0); + }); + + it('getAllActionButtonsFromMultipleApps', async () => { + hasPermissionSpy.mock.mockImplementation(() => true); + + const button1: IUIActionButtonDescriptor = { + actionId: 'action-1', + context: UIActionButtonContext.MESSAGE_ACTION, + labelI18n: 'test.label1', + }; + const button2: IUIActionButtonDescriptor = { + actionId: 'action-2', + context: UIActionButtonContext.ROOM_ACTION, + labelI18n: 'test.label2', + }; + + const manager = new UIActionButtonManager(mockManager); + + manager.registerActionButton('testing-app', button1); + manager.registerActionButton('testing-app-2', button2); + + const allButtons = await manager.getAllActionButtons(); + + assert.ok(allButtons !== undefined); + assert.strictEqual(allButtons.length, 2); + + const app1Button = allButtons.find((b) => b.appId === 'testing-app'); + const app2Button = allButtons.find((b) => b.appId === 'testing-app-2'); + + assert.ok(app1Button !== undefined); + assert.strictEqual(app1Button!.actionId, 'action-1'); + assert.ok(app2Button !== undefined); + assert.strictEqual(app2Button!.actionId, 'action-2'); + }); +}); diff --git a/packages/apps/tests/server/misc/Utilities.test.ts b/packages/apps/tests/server/misc/Utilities.test.ts new file mode 100644 index 0000000000000..b0a47b0b2646d --- /dev/null +++ b/packages/apps/tests/server/misc/Utilities.test.ts @@ -0,0 +1,99 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import { Utilities } from '../../../src/server/misc/Utilities'; + +describe('Utilities', () => { + const expectedInfo = { + id: '614055e2-3dba-41fb-be48-c1ff146f5932', + name: 'Testing App', + nameSlug: 'testing-app', + description: 'A Rocket.Chat Application used to test out the various features.', + version: '0.0.8', + requiredApiVersion: '>=0.9.6', + author: { + name: 'Bradley Hilton', + homepage: 'https://github.com/RocketChat/Rocket.Chat.Apps-ts-definitions', + support: 'https://github.com/RocketChat/Rocket.Chat.Apps-ts-definitions/issues', + }, + classFile: 'TestingApp.ts', + iconFile: 'testing.jpg', + }; + + it('testDeepClone', () => { + assert.doesNotThrow(() => Utilities.deepClone(expectedInfo)); + const info = Utilities.deepClone(expectedInfo); + + assert.deepStrictEqual(info, expectedInfo); + info.name = 'New Testing App'; + assert.strictEqual(info.name, 'New Testing App'); + assert.strictEqual(info.author.name, expectedInfo.author.name); + }); + + it('testDeepFreeze', () => { + const testInfo = { + id: '614055e2-3dba-41fb-be48-c1ff146f5932', + name: 'Testing App', + nameSlug: 'testing-app', + description: 'A Rocket.Chat Application used to test out the various features.', + version: '0.0.8', + requiredApiVersion: '>=0.9.6', + author: { + name: 'Bradley Hilton', + homepage: 'https://github.com/RocketChat/Rocket.Chat.Apps-ts-definitions', + support: 'https://github.com/RocketChat/Rocket.Chat.Apps-ts-definitions/issues', + }, + classFile: 'TestingApp.ts', + iconFile: 'testing.jpg', + }; + + assert.doesNotThrow(() => { + testInfo.name = 'New Testing App'; + }); + assert.doesNotThrow(() => { + testInfo.author.name = 'Bradley H'; + }); + assert.strictEqual(testInfo.name, 'New Testing App'); + assert.strictEqual(testInfo.author.name, 'Bradley H'); + + assert.doesNotThrow(() => Utilities.deepFreeze(testInfo)); + + assert.throws(() => { + testInfo.name = 'Old Testing App'; + }); + assert.throws(() => { + testInfo.author.name = 'Bradley'; + }); + assert.strictEqual(testInfo.name, 'New Testing App'); + assert.strictEqual(testInfo.author.name, 'Bradley H'); + }); + + it('testDeepCloneAndFreeze', () => { + const testInfo = { + id: '614055e2-3dba-41fb-be48-c1ff146f5932', + name: 'Testing App', + nameSlug: 'testing-app', + description: 'A Rocket.Chat Application used to test out the various features.', + version: '0.0.8', + requiredApiVersion: '>=0.9.6', + author: { + name: 'Bradley H', + homepage: 'https://github.com/RocketChat/Rocket.Chat.Apps-ts-definitions', + support: 'https://github.com/RocketChat/Rocket.Chat.Apps-ts-definitions/issues', + }, + classFile: 'TestingApp.ts', + iconFile: 'testing.jpg', + }; + + assert.doesNotThrow(() => Utilities.deepCloneAndFreeze({})); + + const info = Utilities.deepCloneAndFreeze(testInfo); + assert.deepStrictEqual(info, testInfo); + assert.notStrictEqual(info, testInfo); + assert.strictEqual(info.author.name, testInfo.author.name); + assert.strictEqual(info.author.name, 'Bradley H'); + assert.throws(() => { + info.author.name = 'Bradley Hilton'; + }); + }); +}); diff --git a/packages/apps/tests/server/runtime/DenoRuntimeSubprocessController.test.ts b/packages/apps/tests/server/runtime/DenoRuntimeSubprocessController.test.ts new file mode 100644 index 0000000000000..c9aafe4e4d8e2 --- /dev/null +++ b/packages/apps/tests/server/runtime/DenoRuntimeSubprocessController.test.ts @@ -0,0 +1,228 @@ +/* eslint-disable dot-notation -- we avoid the dot notation here when testing private methods */ + +import * as fs from 'fs/promises'; +import * as assert from 'node:assert'; +import { describe, it, beforeEach, afterEach, mock, before, after } from 'node:test'; +import * as os from 'os'; +import * as path from 'path'; + +import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; +import { UserStatusConnection, UserType } from '@rocket.chat/apps-engine/definition/users'; +import { type RpcStatusType, SuccessObject } from 'jsonrpc-lite'; + +import type { AppManager } from '../../../src/server/AppManager'; +import type { IParseAppPackageResult } from '../../../src/server/compiler'; +import { AppAccessorManager, AppApiManager } from '../../../src/server/managers'; +import { DenoRuntimeSubprocessController } from '../../../src/server/runtime/deno/AppsEngineDenoRuntime'; +import type { IAppStorageItem } from '../../../src/server/storage'; +import { TestInfastructureSetup } from '../../test-data/utilities'; + +describe('DenoRuntimeSubprocessController', () => { + const rpcTypeRequest = 'request' as RpcStatusType.request; + + let manager: AppManager; + let controller: DenoRuntimeSubprocessController; + let appPackage: IParseAppPackageResult; + let appStorageItem: IAppStorageItem; + + before(async () => { + const infrastructure = new TestInfastructureSetup(); + manager = infrastructure.getMockManager(); + + const accessors = new AppAccessorManager(manager); + manager.getAccessorManager = () => accessors; + + const api = new AppApiManager(manager); + manager.getApiManager = () => api; + + const appPackageBuffer = await fs.readFile(path.join(__dirname, '../../test-data/apps/hello-world-test_0.0.1.zip')); + appPackage = await manager.getParser().unpackageApp(appPackageBuffer); + + appStorageItem = { + id: 'hello-world-test', + status: AppStatus.MANUALLY_ENABLED, + } as IAppStorageItem; + }); + + beforeEach(async () => { + controller = new DenoRuntimeSubprocessController(manager, appPackage, appStorageItem); + await controller.setupApp(); + }); + + afterEach(async () => { + await controller?.stopApp(); + mock.restoreAll(); + }); + + after(async () => { + await fs.unlink(path.join(os.tmpdir(), 'deno-runtime')).catch((reason) => { + console.warn('Failed to delete temporary Deno runtime symlink', reason); + }); + }); + + it('correctly identifies a call to the HTTP accessor', async () => { + const httpBridge = manager.getBridges().getHttpBridge(); + const doCallSpy = mock.method(httpBridge, 'doCall'); + + const r = await controller['handleAccessorMessage']({ + type: rpcTypeRequest, + payload: { + jsonrpc: '2.0', + id: 'test', + method: 'accessor:getHttp:get', + params: ['https://google.com', { content: "{ test: 'test' }" }], + serialize: () => '', + }, + }); + + assert.strictEqual(doCallSpy.mock.calls.length, 1, 'doCallSpy.mock.calls.length'); + const callArgs = doCallSpy.mock.calls[0].arguments; + assert.partialDeepStrictEqual( + callArgs[0], + { + appId: '9c1d62ca-e40f-456f-8601-17c823a16c68', + method: 'get', + url: 'https://google.com', + }, + 'callArgs[0]', + ); + + assert.deepStrictEqual( + r.result, + { + method: 'get', + url: 'https://google.com', + content: "{ test: 'test' }", + statusCode: 200, + headers: {}, + }, + 'r.result', + ); + }); + + it('correctly identifies a call to the IRead accessor', async () => { + const userBridge = manager.getBridges().getUserBridge(); + const doGetByUsernameSpy = mock.method(userBridge, 'doGetByUsername', () => + Promise.resolve({ + id: 'id', + username: 'rocket.cat', + isEnabled: true, + emails: [], + name: 'name', + roles: [], + type: UserType.USER, + active: true, + utcOffset: 0, + status: 'offline', + statusConnection: UserStatusConnection.OFFLINE, + lastLoginAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }), + ); + + const { id, result } = await controller['handleAccessorMessage']({ + type: rpcTypeRequest, + payload: { + jsonrpc: '2.0', + id: 'test', + method: 'accessor:getReader:getUserReader:getByUsername', + params: ['rocket.cat'], + serialize: () => '', + }, + }); + + assert.strictEqual(doGetByUsernameSpy.mock.calls.length, 1); + assert.deepStrictEqual(doGetByUsernameSpy.mock.calls[0].arguments, ['rocket.cat', '9c1d62ca-e40f-456f-8601-17c823a16c68']); + + assert.strictEqual(id, 'test'); + assert.partialDeepStrictEqual(result, { username: 'rocket.cat' }); + }); + + it('correctly identifies a call to the IEnvironmentReader accessor via IRead', async () => { + const { id, result } = await controller['handleAccessorMessage']({ + type: rpcTypeRequest, + payload: { + jsonrpc: '2.0', + id: 'requestId', + method: 'accessor:getReader:getEnvironmentReader:getServerSettings:getOneById', + params: ['setting test id'], + serialize: () => '', + }, + }); + + assert.strictEqual(id, 'requestId'); + assert.partialDeepStrictEqual(result, { id: 'setting test id' }); + }); + + it('correctly identifies a call to create a visitor via the LivechatCreator', async () => { + const livechatBridge = manager.getBridges().getLivechatBridge(); + const doCreateVisitorSpy = mock.method(livechatBridge, 'doCreateVisitor', () => Promise.resolve('random id')); + + const { id, result } = await controller['handleAccessorMessage']({ + type: rpcTypeRequest, + payload: { + jsonrpc: '2.0', + id: 'requestId', + method: 'accessor:getModifier:getCreator:getLivechatCreator:createVisitor', + params: [ + { + id: 'random id', + token: 'random token', + username: 'random username for visitor', + name: 'Random Visitor', + }, + ], + serialize: () => '', + }, + }); + + assert.strictEqual(doCreateVisitorSpy.mock.calls.length, 1); + assert.deepStrictEqual(doCreateVisitorSpy.mock.calls[0].arguments, [ + { + id: 'random id', + token: 'random token', + username: 'random username for visitor', + name: 'Random Visitor', + }, + '9c1d62ca-e40f-456f-8601-17c823a16c68', + ]); + + assert.strictEqual(id, 'requestId'); + assert.strictEqual(result, 'random id'); + }); + + it('correctly identifies a call to the message bridge', async () => { + const messageBridge = manager.getBridges().getMessageBridge(); + const doCreateSpy = mock.method(messageBridge, 'doCreate', () => Promise.resolve('random-message-id')); + + const messageParam = { + room: { id: '123' }, + sender: { id: '456' }, + text: 'Hello World', + alias: 'alias', + avatarUrl: 'https://avatars.com/123', + }; + + const response = await controller['handleBridgeMessage']({ + type: rpcTypeRequest, + payload: { + jsonrpc: '2.0', + id: 'requestId', + method: 'bridges:getMessageBridge:doCreate', + params: [messageParam, 'APP_ID'], + serialize: () => '', + }, + }); + + assert.ok(response instanceof SuccessObject); + + const { id, result } = response; + + assert.strictEqual(doCreateSpy.mock.calls.length, 1); + assert.deepStrictEqual(doCreateSpy.mock.calls[0].arguments, [messageParam, '9c1d62ca-e40f-456f-8601-17c823a16c68']); + + assert.strictEqual(id, 'requestId'); + assert.strictEqual(result, 'random-message-id'); + }); +}); diff --git a/packages/apps/tests/server/runtime/deno/LivenessManager.test.ts b/packages/apps/tests/server/runtime/deno/LivenessManager.test.ts new file mode 100644 index 0000000000000..9a157d8a123d4 --- /dev/null +++ b/packages/apps/tests/server/runtime/deno/LivenessManager.test.ts @@ -0,0 +1,271 @@ +import type { ChildProcess } from 'child_process'; +import * as assert from 'node:assert'; +import { describe, it, beforeEach, afterEach, mock, type Mock } from 'node:test'; +import { EventEmitter } from 'stream'; + +import debugFactory from 'debug'; + +import type { DenoRuntimeSubprocessController } from '../../../../src/server/runtime/deno/AppsEngineDenoRuntime'; +import { COMMAND_PING, LivenessManager } from '../../../../src/server/runtime/deno/LivenessManager'; +import { ProcessMessenger } from '../../../../src/server/runtime/deno/ProcessMessenger'; + +describe('LivenessManager ping mechanism', () => { + const PING_INTERVAL_MS = 100; + const PING_TIMEOUT_MS = 50; + const CONSECUTIVE_TIMEOUT_LIMIT = 3; + const MAX_RESTARTS = Infinity; + const RESTART_ATTEMPT_DELAY_MS = 10; + + let mockController: DenoRuntimeSubprocessController; + let mockMessenger: ProcessMessenger; + let mockSubprocess: ChildProcess; + let debug: debug.Debugger; + let livenessManager: LivenessManager; + let controllerEventEmitter: EventEmitter; + let subprocessEventEmitter: EventEmitter; + let sendMock: Mock; + + beforeEach(() => { + debug = debugFactory('test:liveness-manager'); + mock.timers.enable({ apis: ['setTimeout', 'setInterval', 'Date'] }); + mock.timers.setTime(0); + + // Create event emitters for controller and subprocess + controllerEventEmitter = new EventEmitter(); + subprocessEventEmitter = new EventEmitter(); + + // Mock controller + mockController = { + getProcessState: () => 'ready', + restartApp: async () => Promise.resolve(), + stopApp: async () => Promise.resolve(), + on: (event: string, listener: (...args: any[]) => void) => { + controllerEventEmitter.on(event, listener); + return mockController; + }, + once: (event: string, listener: (...args: any[]) => void) => { + controllerEventEmitter.once(event, listener); + return mockController; + }, + off: (event: string, listener: (...args: any[]) => void) => { + controllerEventEmitter.off(event, listener); + return mockController; + }, + emit: (event: string, ...args: any[]) => { + return controllerEventEmitter.emit(event, ...args); + }, + addListener: () => mockController, + removeListener: () => mockController, + removeAllListeners: () => mockController, + setMaxListeners: () => mockController, + getMaxListeners: () => 10, + listeners: (): any[] => [], + rawListeners: (): any[] => [], + listenerCount: (): number => 0, + prependListener: () => mockController, + prependOnceListener: () => mockController, + eventNames: (): string[] => [], + } as unknown as DenoRuntimeSubprocessController; + + // Mock subprocess + mockSubprocess = { + pid: 12345, + once: (event: string, listener: (...args: any[]) => void) => { + subprocessEventEmitter.once(event, listener); + return mockSubprocess; + }, + on: () => mockSubprocess, + off: () => mockSubprocess, + emit: (event: string, ...args: any[]) => { + return subprocessEventEmitter.emit(event, ...args); + }, + } as unknown as ChildProcess; + + // Mock messenger + mockMessenger = new ProcessMessenger(); + mockMessenger.setReceiver(mockSubprocess); + + sendMock = mock.method(mockMessenger, 'send', () => Promise.resolve()); + + // Create LivenessManager with fast ping options for testing + livenessManager = new LivenessManager( + { + controller: mockController, + messenger: mockMessenger, + debug, + }, + { + pingTimeoutInMS: PING_TIMEOUT_MS, + pingIntervalInMS: PING_INTERVAL_MS, + consecutiveTimeoutLimit: CONSECUTIVE_TIMEOUT_LIMIT, + maxRestarts: MAX_RESTARTS, + restartAttemptDelayInMS: RESTART_ATTEMPT_DELAY_MS, + }, + ); + + livenessManager.attach(mockSubprocess); + }); + + afterEach(() => { + // Stop the liveness manager to clean up intervals + livenessManager.stop(); + + // Clear all event listeners + controllerEventEmitter.removeAllListeners(); + subprocessEventEmitter.removeAllListeners(); + + mock.timers.reset(); + sendMock.mock.restore(); + }); + + it('should update lastHeartbeatTimestamp when heartbeat event is emitted', () => { + controllerEventEmitter.emit('constructed'); + + const initialTimestamp = livenessManager.getRuntimeData().lastHeartbeatTimestamp; + assert.strictEqual(initialTimestamp, 0); + + mock.timers.tick(50); + + controllerEventEmitter.emit('heartbeat'); + + const newTimestamp = livenessManager.getRuntimeData().lastHeartbeatTimestamp; + assert.strictEqual(newTimestamp, 50); + }); + + it('should ping when heartbeat is stale', () => { + controllerEventEmitter.emit('constructed'); + + mock.timers.tick(PING_INTERVAL_MS); + + assert.strictEqual(sendMock.mock.calls.length, 1); + assert.deepStrictEqual(sendMock.mock.calls[0].arguments, [COMMAND_PING]); + }); + + it('should not ping when heartbeat is recent', () => { + controllerEventEmitter.emit('constructed'); + + // Wait half the interval to update the heartbeat + mock.timers.tick(PING_INTERVAL_MS / 2); + + controllerEventEmitter.emit('heartbeat'); + + // Wait for the rest of the interval that would trigger the ping + mock.timers.tick(PING_INTERVAL_MS / 2); + + // Ping should not have been sent + assert.strictEqual(sendMock.mock.calls.length, 0); + }); + + it('should handle successful ping/pong', () => { + controllerEventEmitter.emit('constructed'); + + // Wait for the full interval + mock.timers.tick(PING_INTERVAL_MS); + + // Verify ping was sent + assert.strictEqual(sendMock.mock.calls.length, 1); + assert.deepStrictEqual(sendMock.mock.calls[0].arguments, [COMMAND_PING]); + + // Advance time to simulate latency + mock.timers.tick(20); + + // Emit pong response + controllerEventEmitter.emit('pong'); + + // Verify consecutive timeout count was reset + const runtimeData = livenessManager.getRuntimeData(); + assert.strictEqual(runtimeData.pingTimeoutConsecutiveCount, 0); + + // Verify heartbeat timestamp was updated + const newTimestamp = runtimeData.lastHeartbeatTimestamp; + assert.strictEqual(newTimestamp, PING_INTERVAL_MS + 20); + }); + + it('should keep track of consecutive timeouts, and clear the count on a heartbeat', () => { + controllerEventEmitter.emit('constructed'); + + // Wait for the full interval + mock.timers.tick(PING_INTERVAL_MS); + + // Verify ping was sent + assert.strictEqual(sendMock.mock.calls.length, 1); + assert.deepStrictEqual(sendMock.mock.calls[0].arguments, [COMMAND_PING]); + + // Timeout the ping + mock.timers.tick(PING_TIMEOUT_MS); + + // Verify consecutive timeout count incremented + let runtimeData = livenessManager.getRuntimeData(); + assert.strictEqual(runtimeData.pingTimeoutConsecutiveCount, 1); + + // Tick the rest of the interval to next ping + mock.timers.tick(PING_INTERVAL_MS - PING_TIMEOUT_MS); + + // Verify ping was sent + assert.strictEqual(sendMock.mock.calls.length, 2); + + // Timeout the ping + mock.timers.tick(PING_TIMEOUT_MS); + + // Verify consecutive timeout count incremented + runtimeData = livenessManager.getRuntimeData(); + assert.strictEqual(runtimeData.pingTimeoutConsecutiveCount, 2); + + controllerEventEmitter.emit('heartbeat'); + + mock.timers.tick(PING_INTERVAL_MS - PING_TIMEOUT_MS); + + // Shouldn't have called ping due to recent heartbeat + assert.strictEqual(sendMock.mock.calls.length, 2); + + // Verify consecutive timeout count was reset + runtimeData = livenessManager.getRuntimeData(); + assert.strictEqual(runtimeData.pingTimeoutConsecutiveCount, 0); + }); + + it('should call restart when consecutive timeout count reaches limit from options', async () => { + (livenessManager as any).options.consecutiveTimeoutLimit = 2; + const restartSpy = mock.method(livenessManager as any, 'restartProcess', () => Promise.resolve()); + + controllerEventEmitter.emit('constructed'); + + // Wait for the full interval + mock.timers.tick(PING_INTERVAL_MS); + + // Verify ping was sent + assert.strictEqual(sendMock.mock.calls.length, 1); + assert.deepStrictEqual(sendMock.mock.calls[0].arguments, [COMMAND_PING]); + + // Timeout the ping + mock.timers.tick(PING_TIMEOUT_MS); + + // Verify consecutive timeout count incremented + let runtimeData = livenessManager.getRuntimeData(); + assert.strictEqual(runtimeData.pingTimeoutConsecutiveCount, 1); + + // Wait for ping handler to finish + await livenessManager.getPendingPing(); + + // Tick the rest of the interval to next ping + mock.timers.tick(PING_INTERVAL_MS - PING_TIMEOUT_MS); + + // Verify ping was sent + assert.strictEqual(sendMock.mock.calls.length, 2); + + // Timeout the ping + mock.timers.tick(PING_TIMEOUT_MS); + + // Wait for ping handler to finish + await livenessManager.getPendingPing(); + + // Verify consecutive timeout count incremented + runtimeData = livenessManager.getRuntimeData(); + assert.strictEqual(runtimeData.pingTimeoutConsecutiveCount, 2); + + // Verify that restart has been called due to reaching consecutive timeout limit + assert.strictEqual(restartSpy.mock.calls.length, 1); + assert.deepStrictEqual(restartSpy.mock.calls[0].arguments, ['Too many pings timed out']); + + restartSpy.mock.restore(); + }); +}); diff --git a/packages/apps/tests/server/runtime/deno/bundleLegacyApp.test.ts b/packages/apps/tests/server/runtime/deno/bundleLegacyApp.test.ts new file mode 100644 index 0000000000000..cca6f7d80bcf7 --- /dev/null +++ b/packages/apps/tests/server/runtime/deno/bundleLegacyApp.test.ts @@ -0,0 +1,107 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import type { AppImplements } from '../../../../src/server/compiler/AppImplements'; +import type { IParseAppPackageResult } from '../../../../src/server/compiler/IParseAppPackageResult'; +import { bundleLegacyApp } from '../../../../src/server/runtime/deno/bundler'; + +function makeAppPackage(classFile: string, files: { [key: string]: string }): IParseAppPackageResult { + return { + info: { + id: 'test-app', + name: 'Test App', + nameSlug: 'test-app', + version: '0.0.1', + description: 'Test App', + requiredApiVersion: '*', + classFile, + iconFile: 'icon.png', + implements: [], + author: { name: 'Test', homepage: 'https://test.com', support: 'https://test.com' }, + }, + files, + languageContent: {}, + implemented: {} as AppImplements, + }; +} + +describe('bundleLegacyApp', () => { + it('bundles a single-file app into one output file', async () => { + const appPackage = makeAppPackage('app.js', { + 'app.js': 'module.exports = { hello: "world" };', + }); + + await bundleLegacyApp(appPackage); + + // After bundling, files should contain only the single bundled entry + assert.strictEqual(Object.keys(appPackage.files).length, 1); + assert.ok(appPackage.files['app.js'] !== undefined); + assert.strictEqual(typeof appPackage.files['app.js'], 'string'); + assert.ok(appPackage.files['app.js'].length > 0); + }); + + it('includes code from relative imports', async () => { + const appPackage = makeAppPackage('app.js', { + 'app.js': 'var utils = require("./utils"); module.exports = utils;', + 'utils.js': 'module.exports = { value: 42 };', + }); + + await bundleLegacyApp(appPackage); + + assert.strictEqual(Object.keys(appPackage.files).length, 1); + // The bundled output should contain the inlined value from utils.js + assert.ok(appPackage.files['app.js'].includes('42')); + }); + + it('resolves directory index imports (require("./dir") → dir/index.js)', async () => { + const appPackage = makeAppPackage('app.js', { + 'app.js': 'var lib = require("./lib"); module.exports = lib;', + 'lib/index.js': 'module.exports = { fromIndex: true };', + }); + + await bundleLegacyApp(appPackage); + + assert.strictEqual(Object.keys(appPackage.files).length, 1); + assert.ok(appPackage.files['app.js'].includes('fromIndex')); + }); + + it('marks @rocket.chat/apps-engine/* imports as external', async () => { + const appPackage = makeAppPackage('app.js', { + 'app.js': ['var AppInterface = require("@rocket.chat/apps-engine/definition/AppInterface");', 'module.exports = AppInterface;'].join( + '\n', + ), + }); + + await bundleLegacyApp(appPackage); + + assert.strictEqual(Object.keys(appPackage.files).length, 1); + // The apps-engine import should remain as an external require, not be inlined + assert.ok(appPackage.files['app.js'].includes('@rocket.chat/apps-engine')); + }); + + it('handles deeply nested relative imports', async () => { + const appPackage = makeAppPackage('app.js', { + 'app.js': 'var a = require("./commands/run"); module.exports = a;', + 'commands/run.js': 'var helper = require("../helpers/format"); module.exports = helper;', + 'helpers/format.js': 'module.exports = { formatted: true };', + }); + + await bundleLegacyApp(appPackage); + + assert.strictEqual(Object.keys(appPackage.files).length, 1); + assert.ok(appPackage.files['app.js'].includes('formatted')); + }); + + it('replaces the files record with only the bundled classFile entry', async () => { + const appPackage = makeAppPackage('main.js', { + 'main.js': 'var dep = require("./dep"); module.exports = dep;', + 'dep.js': 'module.exports = "dep-value";', + 'unused.js': 'module.exports = "unused";', + }); + + await bundleLegacyApp(appPackage); + + // Only the classFile key should remain after bundling + assert.deepStrictEqual(Object.keys(appPackage.files), ['main.js']); + }); +}); diff --git a/packages/apps/tests/test-data/README.md b/packages/apps/tests/test-data/README.md new file mode 100644 index 0000000000000..d02811b01243d --- /dev/null +++ b/packages/apps/tests/test-data/README.md @@ -0,0 +1,2 @@ +# What is this folder? +This contains data, classes, and implementations of pieces that the user of this package would have to implement. diff --git a/packages/apps/tests/test-data/apps/hello-world-test_0.0.1.zip b/packages/apps/tests/test-data/apps/hello-world-test_0.0.1.zip new file mode 100644 index 0000000000000000000000000000000000000000..df9e608838972f812d5e9f400a6bfa3e8174d222 GIT binary patch literal 10309 zcmaKS1#lh9lBJlgB#W6XiOZ4hHty9nj5D8MI}z4s{&Js2h&i!8P0$m!hs-8#`x;Q>KuZny&L-sa{bgN zjcAykG5qy@@j{^$BTn8(YCN_8KeilH#oXonNR}n-wN+wlUzv3&0a#_7D3 zwN@c(nPNnY(PNu6_6x@k8k*MzouEl@5U-=xOufC*Zhc7d01Y&(Yf4O}9F!juL z@k?O(z;o;V*Or&dymbpVWa0-ZNHDPN#`35vupS03Fp~QNxpw0b(ap_8mV9?eM8`Pw zQl&5z#Zr{QNEQ3Qm9ME zq)`lhe%w$0G=@+CnQJv!WxQ^YyH^EU5r9BeP|#o#%2vYe>0Tc z%f9s$QsT|czDq_6(;uV6GmC?}s1-D87<3)HAkH_y!ShUo&BOYz&YXLL&7;ar3j3qL zP#FpLbZ7d(TnLxCS#PK6D03eKwv}Z=Jx*ulszBWP_3^bwR&ev&^GgbJ)U-h;M*5Ln zNY<)Lu#DnXGAfVfDPk*!j5_CPIowam3|xWFQ@5@}n{69daMqFPQwAw3an=g_U7dI* zgLF|QbQb+ZNd0VF03wlruaz!Fh~U#_mzwVOSreZ2L?j&xI;6xtryN_MgaxDV5q=$% zKS{w~pJZiG&xg=4H*RhnVG40eg_h|`ud;aDrpQ-f!bQ^Vt*#vg3#xZRq{?|~^OUxC zS!0rk^91vOU#T7C@N7Hx$}utZkBpDkRTQsa4_9`*4xL=bV<9ZnB(zsL(j6ZS-_$Pp zB&+h%lM>jtsk(Qu+)g<2{LlA2(vO+%?Z-W_Lc_=Hz(gh^mhgwzyA z=;bScmW>Qms!M|{hcSg{P}DCPp2klS2@S(nn+uQ^4agwSxpck{3;vl*wz*zVMG^*0 z>p@Fao6_k!e(h6E(V|CKqfX+e+g(NU>{hzJW?cCa5q#Idr6f8W%%b0&=#Mm6pMyc@ zY9-!HqHe0%9?q=_16eoS!Trj_K)2AD$<8JeNbRed=!_n&Z0$DV7#(8gZ`$-yxJbfC zwiDm+jCqsh?5=O;G1AVtc}2C=%Pl4;QVr&*T7BQhmBK$Iv$7nQ8MA-b*A0bWYsA%y z*Cd)e`|$&cB;9g|Rk}9s@oR6a1HyM3%DgU#N#XacqYpqEB)@I+sP3zFmvP(H=A|)m z7cdq5bo|v7CWbN=)zTEEI+Fa-^(7O~^&XQqO(^;__L>c+5_UBQSh2;InG&r?uUoNF=H>98aS6pp3Q-L|sF7!+*8J`%HSv{F zlM%@IVB_1~%CNE1`1bOeSkqHDTAsJmzg9S)+f8XpZi#7rq)_E2aovjiHZdhj(IziN zH7s)$G=-zMYT`DNRHtFVi*@39u>`895Sf-y}snhVdu0VWnq2=N&_~~)YH)$)`nPv2p#;8 zvCh=gByJ;iP-^Am311T;Nlwm+Utr|A$cmMlx9NVK3{4H)ce$Rjkiz30OdwnS6mhmU zg7i6D*$1Sx%Au}(Gc-ex#M?J9rTz#R*S|ZAQNwB1GubhdN={3pe$wcT3U9_$8TN*# z^(9tp3WZ7zEjT)U^R8A3^JkOcv~pt5W*27C^{1;}Ep(MWQJgu0~aLFCkAiY+f=F zU-@`%@Z84(J_c0TD0hEBJpHH};$5$$KXsaAQ&I{m{v?wAq`C(eG_gfO2)$z6S?#b z6~L)N>L5VdlxP4XM!b;=StPxAKfv?oSpc`F0r_)Rdf@{u&xe`r9^q?=9sS}z3c7cV zd2!C0bYgdc4N0;#rwu6}Nxz|;@LRcD@MyF+T_5lwfPWLsLNEr8y)-pX z`e46*wuf@b*yA{zdY6#5RR%`p*^gFSeATc_HG&og8+oGy$u7%lmBBHH+39WItuu*! zwSRKETOhQtH|Q`C*-OTuq)>k&^^RXH=(Ao`9f8p0qUC|UQJB|?b~LN)ot1&imuma= zV!w`g)$A7(Q(iYOPZXCA zrBy~PG*#%OFN1Z0mUeP&KZZ>3sHE~%N5Un3mS1D|TKT>fiqD+WDGk7Asf5w2qyI9r zeveeyhpprV4a87JAWMbzZ1grXZ|+miKSy2?HM-Mf4Rdksjiw*8lsJ<3+Jn9EU7dM9 z+}mmOF|zF@?W7Dk5Kf=O7&Fn27$)9jFWw5pf^lXJexILq^r|s2kVU6gzoACPQOr_8 zzD!`QwC?e^IW*)pS-Q6!NW`8n#>gd4#+W;3e|l3+r+K>4exlRSLUP+=04R_Q;EO&k zIS5obo#l_$AfoSF48ysdC>2v4Q8bGoMNZxBpZyvkUthDYuh~$v>WrN#f{nV`w0->I z;6&m2ye&$CCm=7{Xh9G%;EYFaZ*tmCOp^1F*G9(V@l9-- z%GS|jGDkvGyO%Sy_4&P3svn;tBQCko?R8cy0cJwmd@WU^{ZU!##q;QLLAaP^?tsqD z|N8-^`a#6bM+%uP8Q;~y=Cy1;j@ls6iG?xLtRGr2V?QIDDpHZCoKO*z2jdP_a(BR)Oe9ud?m_QsSYn?g5JFN(RXc-#mR9IlKr%(^W}!d{atb z;W0YKIR}Ja^Y~?_6@YXihKBVUUIbl{5G~h3==~6hR>?X+*?eM4FRA$zAxUz1X9gQ_ zfD+fwzOPT-4+~GMlioY&PnsIb4A`97O;=<@F;^dKCPyc0@2zlDwY z&VTa()nnj!ZO^B6t!psOl zkesTZ@VSK?hhShsCk&*M_p6vnB}DV^ln`p@eavh@6N3Uep~x^da4oIXXv zEm9`q)_m7`qmNM#j!=C+c^%1`GskyRmr7kydFyi;@5u}GO^iCIRdB&P-lbbhhx<1k z#dKj2x@EKEf-=H@sZby-#QqxWL+Cb(sQpfH$MNJWQFskAjfjnvUwi8uXkNYB$;FgQ z#9Y6jxs0#vx={{JT5*28x`G+F5A@xOlpE>A7?-Z2Rbf}0x)cwRg17cbVFf2ZyC88H z2i+IhKqONT<_}C<7g=KMtcM6n4AOjEgg^F7hf#YrU3feq!7I@PKa>m1Xf|7|wz5VG z(4CQk&crxhhkJm2v;3TLTDkkM8}Q}yT$|IAFzTJ6Uf*Fy;Cl5PgRVYn!OiO?=SFfVsxbcjAt!DmWLXaw7S; zQAk2g6CYZ`Kz7PxMWL0SXzYoB5g>GH$G=R-%YW)*sxCDAr8fRssSwv@gRahJ*}}$} z#L|sT7&BX;$;43p(3z6fD*k3Y5$}4u54mhL58#W1tazqdfaIM@rz~}ocD8BJImG*R zGlN;y;e!;XX0QN7vG#S)#I3Zr7H+ZL-q8=_jlyCjmW0KQQDbdeY1Leow~$FdF1N)U zy3K3Nb+aYU5?hYIzRa1}mgPE;xNF`#cVFGAS3 z&~0o@+}S2a9v(%5&~!ocs#8lZ4n>ekT0yBUci6}=d#m@ZNwR2nUIp<3{N~GGi07({ z?F53f_X@O(U3NDD>IWBCy|lrAx9Z8+#O=;Q$_FyDP&*p^ad@gJh^SPw@be#y?6r@bR`c=)W=ISOA!>S_8&d^Y)L(Hrd+PtrO(c5sqiI zc76@ZRAiFRVv|xoIT+cIdijP$6DG97i+5cLj0;C9t=t=uhyaJ=P`&===Y#Y2C>%!fg zaiK61o-JrMeKZK7X!a}+fI8AnH$i5V7C~v3tf$OeXwTuO+z_(W6hZTNao!ASl6s;#^o(CMYo}N{7xD2 zo#Jg!M>Jc~xD)7HQAeEm2G??qroA6rNF1GKr0dz(g}fp_+Zye`5cllzlcv=jdO4$c zLjV!~sDuQ3SXe^~KhDh5v11F@@n@siHa-G-^X1$TO17Gb_)U7s&=LmM zI8r`_zV)(h?f{)-s;9!f%3%mT@Ouax(=C^)D<5BeJA{W5glAykd0kuR zwBcjq2cpeSP4Aob_%LmOhJ_%(23_t>U^Gy0-h0AdxBk4JF^ongzJpG}zxr2Nq0q%I zTQA3GkPP%wv*q}vf#$od7L#ZKDe1ph3}2Xc z>0I;n)Xl?q%t&jbJGqJ-IOF+5Ad>4U(jtU`Ig7qu2c;$oQmU+uo(Zx};s=-M6M8*z z?!!sn%nzX7$oFnj5O`j@qMY=WY}XKGBYQoHq4L^60{^#%{hW;fVt zzVYbB(>!e$+Nes@m=)8GlUkPo##^SYy55O0=4wB++&~z(FT2azJa7pbm8v{yNyKld zu{Ad=ZRbWMw%@A~{!DDKaU^%~+P?$n$o}w)VZgnFF&Opk^XFhieFq{&+=$Gkc;pyk ztHnT8(pwS@El7~eDI0dB^84Z0bmjpYuX!e1AD;omhHM@s&`FI_Q3k)*xHM%*gN$a2 zocO!#i@%f&FNK$r{~PN!ewXY_qTRiA&Jzpw*dKHZRW~w(8cel$Sb9Iz^X4=@FVPZb z%Y6i?rtWQQF{-ACQC&vG-vqkDVVxMV12D`8+sVuGxw`qtZB)urA0BR;7`a17o)lKI z*aNF?U`K+Ma_pS(g;mSMYDra(wjlWEpdDJHIA`+Mo3`!F%IKP6FM{3H`8cQ^SV}{9MM!@UKdpE{rak#fRw~WHV37|rK#aWeU-sH zOwM?ZGMThH8L_2c*fQYbU%C zQ`FOYCS86uo9e9%i}-4f=~EqjbP-M= zC_9bMjV37YLdV}0@ziiV5*-ct(1Vid)%KC_vG$$yqrX=iCT`GeRQB`O(LKQ)lR?Us z7jxaIj+gRLSNk(>M?k)E1aj`aXD#J*#}_w4o6-hXtEd^t2q3e{2d3>7o^(Sg`jXp} z{bCQ-o(QWpfvQjC4zwi&`eVqz&k(uTh9r?_3K#ykzdxkBZ+B_{1P#?luhGqH;EY=QZL~S!H5t^5or5%;}k>kIqkA_0&didK$4_&|AZaG2b42#O9og=2^m%O z1uJ>@ZC0E*%$eY!L{@b+ao@C}64BtTBc6KR`D7LKM;DBzDYQ)|%B;#Z7sDC0sZgT|5sj21|0bU99D+Rj%q-n-1Vdf(Qqdp0Sx~PPI857NUgy3_sS+CX4R|hQPh65P8+0#LLutjrL&n0 z(ipRgb7v2h`tI|a)NVgHpv7_7BE*@Pi8a0tXp_25~m(0F*fR)eG2 zX^TENdWKkD?>(yrynjH(&e&QL^>M$y?p`S`7d?b4sXrd-N;=Z%RYoD5qIGLjUBESJ zFX9&*Q=}uQSWx`Ek0aMIn`yGoDx}aX;zP5+t8L)@uvbyG;`8fDQLRx0!)xb@&e&a6 z@NwTZ!a^Z?TU>j4-NzXtyQa;uf>gO9bsxWRDJ*MzQgP&Bp0*W;BiFtj2_xQ0-tyv?kT@-U!>>z zZgn1hv>K-?i9jkUQ76=13{yP{>Q^nX+WXSmJeg34Mc9q}7?gZApuey-S%ESSyt~HLXnFdB#gB#VHp0l2Sl* z=_xOWqv{#A_mt;#^QFVvc){@fq|}1@sMAhw$;rP;X`t{dqUX43d0_%Gxi-UqtnJks^Y~UPd*uh4^x|ZrL%n!e88cMSXwM$x`jfOZ$0BL!?aPrMqF85VoOy2@o<`3;x zqLN8sk1(pJo`OnW@YHm_xbO4boisoAa&R+dHx+{060yth55JY^twx4J(7hg=!i6gjg2!^<79?Ds^eY>qtF_a$8V6Fjrn5*)f zir-_lne(4`?xDVQFR5>(MNUf=60d6cJlhxq2%^Ov7vGkyS|_6UQ^{PVMywl*nrpex zijClu(#E*riKP^#-%&$2^-0)ptXz}GMiP`a95K|9G!G)X9Lik#1Xj=&Sd2DPfP1Hj z-hk{M3awt>sns6*F5H2h0=u<+K?MOES59AuJ3P0kqj;S|1No4)kwn3P?9Ff{u6Edm zX?P!vB9SUQ$k#jnDu+l+X=5kYX;oW;{d9ykmrcje@_rZrlXg$`A$;8j3xylv22>nR zn8RJhC|7d?gN2$qPve$0E3n3#lZ}=d0w4;(((#=ua8|0eAu7Iebsl#em^e(q@+RTwXYRH7esQQlRXg(2TWX1W5n@{ z#{)k*#w!;9DO+BnSQ z(ffnj#%-6M0E|0O~79JS5rGDl#4*VznHo4{cN-O;tTO%#ze^M;s%IOhQrH z6lt*szZDnHhQ=I(7ULppna>c(2BcgzN}Gx_y^REHtxK}MJrS2h*9$fl5_i$q;s$S+ z$as^}ynE{;cg0;8+gb75xjBT;E5_(zfa2yS15l3HA#f6j&<}M0KZ~`Af%EG zk?Y+ryx%IqJl6JpMS!n<yi0zCT=5twwI+k1k_!!)iPe$tz1ExFQ=WZB!yu=&#zKJI9BBQ= z2_^nrsPNw=-v1u|JD&yV_gx43--MQLW+s1mp&ipHE6R)*c+V%EcvZGr3_!!D0YH*C zR`Q6Af{SE6jM<_nFFI4^(w8i!zY-D?yINNquu1rJMB#B`y9=(TtJ9XtZsk@sQ-!Cm z^YRK#p$C6G{MD4k<((u|C!$}rSz*Bfo8jt1NqHWyMSz*{`DZbk!VQr48q^0B+FfJW z9MgE9SZbo8l((Gxwc*9*yX;~234+EaIVOb#Nf%J4T2?Ltmm~V@R8X{d4*U zM@?Spe=7bbsqDYhz`z=O-F{F1Ulg;y-|#m@>z_=cKYXIUrNI~U+t9xWTmRJh!$SIJ zujzkk^+Noclk`umKYgfwLitZ0>ThZAz5R{f|EnwYpX&bv(I343Ee*aCLoV*k?%%9t^zmN3a5lEo;bM=1!zI|*n literal 0 HcmV?d00001 diff --git a/packages/apps/tests/test-data/apps/testing-app_0.0.8.zip b/packages/apps/tests/test-data/apps/testing-app_0.0.8.zip new file mode 100644 index 0000000000000000000000000000000000000000..51d82f85853db5ef960e1db8492f8a501e4133d9 GIT binary patch literal 37318 zcmYhgV{j%+w1penwryi#8xz~Mt%+?+Y)ow1dSlzRlQ+2EsayA)+f|LKUVrwg_2b#y z%5vZk7$6`pFdz-;dGemstT!28ARx%lARzD{ARtUm#-_H$mgZ(A-lghO4x20}-KX>r zuWQkbpOu6d5(_VLL_V}G@OVL3t7xWC6w;GX_(!{46we+G9ol?NO3UMr?pI`@}A=A|a6* zzDbET8tg)XR2ANTRwQ!4q7ofS$H4>a-W;TOYKUOWZ)afXHkV%B`gF4c1x4#gCv)M& zFCBl7RkUd2*7q~3F_=lY3g#8<+QZt(jc2OrYD8p0O9Wq!ueHP2wpuS*n6k&m26fD9 zQy>DdpYx0|DclA)NpU$sw|eId55&7JqcRzi6M|&j*auLXP@TQ zu%#P?KI=(Y9w{Mj>(a>Ht08vU&qpW3-f+@XesJUN+ugn!Q9HxakbfKkUim+v_^Ki2 zy2|5lYV518GJ$i#+K|_^c>zISTkJ{BIPLAui=41*hk<$>mB_A*WK+%84q|I^9`TsV z?Sgr!$!=mA=xF@(h@)PEaLDEznJ5tT{wckbQ?xXhZC~}|84FbH$Big;}WJ+`Qo&+t}GVvbKrT7r_O@mP^&ll0GI#{m0>FSf}*(n3E(j1SvRHdOnIR8@OH0f^iFLHD?9#zD220*cs6DR&`M70%Az zxc}zR6t!poO=q*Q-VKRoGZ5gOnh9ZSu6{JRhg#?|KtAPfXzAB3{d#U{b9_3Wha@{K z%3aMW`D!sxt^u0zI1OyxVp#0`dC$_U)BFKd{@?r(hK0)a%dr)0{FmDhP!JH<|8^A# z5pe|xCVR8E!~vTjVFdA8Af~Dx{Rr`#9E?44n6NZ>kL22hR>=-0{krpaZ=+mc&vDTZ z%cEDtVA3*0ZUbRBiXb0bxHqylhrAj^LbOP`u|)ii1%ZpJag`xf zP_dtW#i=b5w!COTbBQ~QdKh8VLMA7_iJ{kl*=&1*pa|LUTvF|Dp;PL52BTu%i29Wp z@WGx-@w%@N4^vfdq*EveZ?kn-B51ne+-Hm-NZ)~S=LW1hekg2grUd~({LcV2b5}QO2TKtrCnh)7 zWgUCRLoT%M-oD@nA|qV)RJ%okwhQ~)hl5rq0_w<~YtW1kF}Y@dB$;}W3p&L6EjPJr zy43`;_c}una;pRY!7>jQ$Og~WZSuoDDrF_|-zf8|b%_aXsaGDZ*`Qx6Dd73Z!Fc%WUKCMN$ zhvPun_F{J4heM1mHv#!EDO4RQU3p_jj^Wz4UAKRArc*L}xv>Qr>S-i>e6+lL5BvZzze1`DeqCq^dGS=sWlFUD3sE5K5-Qzmk5Tf~R%gh8-9e~C z788wSNBe|4ZA5Syg)LUAh3bHaxJ|)%_vlG#Bo5Q<=$r5lz2Y0#Z8Iw(Px(>N`ky~#afesdE2y7Q)x#`10hQ!ltBaMtZ~F^betg5tHbQZgr{f)6ySOH zHhb{u55^{`VE4{)K#HlyLo=y=RlI?vRdN1m=7x4w%c40KW?f-{uR$}$LnV*pdh%TL z#sgj@^SIb+`U^}`n_!mgjP0h4VK^EWOv=(6(zGG{JBQqblDv!5Vant8yZL6Gx~o%o z87c_nn&A<~3(x5nGI8%^VxZeNH}S8dFK)sIMn7UAxABNux-H~q3hU2<1rnMXHw3rL zB3>^=-_<=f-66%Q@x8070b_;kD_r)11|t}z^-DixDPgAmUqz^)^^O)j-yoZ&0EIQqZ0Ps zY^;3PIYJx)6o(0OD!vCx>og)h;LDFvZTl@QY{|ebbyH8Qrq#NOhnJatr#7Jt`*(Ki zbt>fV=lY9vZ-TJplsEAU^IP?57rmwB4+?rooe^W0=44p2pu9M;2TUD?p( zTK^1^2~R*9KEm!m}UmI6KaTtqVW6Xm5uS zq1rSH5#LwlX*8#AqJx_T1u2$_DDP&p417zLCE*r&T@CMA}SI0&{cfjUitiE%=+-ksx$4mptgkU-2)zMHo=rpx(c*I7IT|F%=`H+OR2T`}Y`hqKO(9TJIm z&2o|Rgt=vt4xn+VGi0SMVFFU2T|UkK%p8sITjgD*XlZ)Y>nObo^|8R6>_iJpV|FU~ zP|o5t{CTkrP|E-bwxI@4dH^@U#?ZS9LZlBtdI?8_o>S~{QF}+od@**Ta?f4sZqb+J zc#Yo*235N4>!21qgs% z#@6^1BZrg4Z%-DqDx=Uow#BRkwyw-Bok3*z>#ktRxFz*^Z6vB(sh z$BD4X>AOf~&O)f04T$0(i;~*8;Bz0moq3(F(EDFXNmG@rq#zQ6H8bUT>RmA>f+{ek z6F_rt+W$n+&dpHj1FXXcQAyxyY01GJXvtOipHGaX`7(2-yFWqMB{6jBReUpYC_v=+ z%jZ0(F?NH6XM-M8FT^k^&{PwLsa;W6&{JuM#if4gVMaZ>Aq|+Y|)lIbuwOg?J$BJ74q$8C!3zBxK`}_DP?2>CDdW zP{LK*GYL{M9(MWma^xO?(v!^A}hAhvZ!0AwD|;EYFTt3o0cz+~Z)$UFHG^7`*}p-VQp10{dQ*INhtd#{j$Dq}U_!_+i^1+-pX4 zxl-5W)VX?An%m-2xEfQx4|^p}S0CCGII-G&Whq6wSNwGuGU2HDr7VB9iak+1b=nE& z=3dhkaC@lh2mJqt=YNn-xk?Sb^gq&3{15rC{*QQ6&E5V7d|V|Q-0lBEAN!dGxDXZ; zsJ-p-@e>u|!jKJHR1*~r(KhAl78o0fD3a7(Uvho2bH=eRKWL%ariJvq=2-E9hOei} zd55X~^d9m9Nnu7fkEB(&nUOdG8M{EPOin={r{I+q9s3Mz$#h1I3iwWW&`Hk2WJE4r zDiV(EB!b7xvMp;Ur}B6hG9uF2I!HPqW>^U%xOU8>`S)x)LaS2j^39v-I|OhP+3gJ7 z|NqCb|ATTh7+$7i{}sjXUqALA(SaEM$8a{Tjt-?sB8o#SC=#y%VQKEy6!tpvvLu)k z!+0pZW0@4-)^_}dvK1HGQJa#8w%tuX`P4!HXR7cNqcl5-#+ewqmf$cwjnZ@pgN(+| z1I4WF+D8-pI<6@Rwm$hMA7tkf!pH{4;h7HWrD{4* zcl+#;WX)JI(w2@8#Mt9k!hZ3pI<(LMBp*@uX`7_&>zt^ zk}P$!jWWdq1${-sn3b$54diL{X&}2Q1)T6ireCK|mLj@3%2w z^Ldt;N7fhE{}&JtkjVdh{Li}o9sko4m^#|q8#|b}GXJ0Ke{%G{n*R=v|4nOF9*6&x zz<~UZB#dHH6V@99L@e_E?T+34U$|znak3ol_EghdPiA5|1>qVMN8c1rQ9${-#l@RV zB0fq*jw(MhcP-2@cf^oI0xAlI4iRaUZH5AdPVq;Y%2Ir0|FqXLpy_71i`{W0`*p=n zM=CJK-$VU+W4Bhd#XX)$ia8L3$g0ZrA9$@^2iV-_JE&efxEjc6oqm_c*T!>x`}?Xh z@Wjy2(_y>Q|8j+}k}nW^nUIdohOUzD^>%S?t54|%8|Mcz5VphRa5Rpu?RHM(_xyrT z`)g2M<~jA>Ox&NT_D_U@9{n)E%cl+b}$N-EIn; z13L?wjBFLorZ7Y%=RU>!{>En-_pX47sL159Tj27uxs(Efy|SW;A!iH@#h`F%(;BA*~&=2lhDi&3?kclh% zsmufWrKoTspqYb!km&MQ;&`MqAexGU(%TzdQtxk`U-6mz+L$ckQaG3&&D-OTIs464 zUjWak+XETHiaP}j@8xVt$|7sC)wo8#(LQBRtDb3+C11nCQvx?A;X{fZTJ9+l?71@@d8aYro zdWorB0Ug+U!efsd=$+vGj_e<-FaE*`^hDsONc5vBK%K?T8&!L>x+#FXxaOTkGvhAy zp-NWqq`=>r+6@qD?{Mm6@O2GZU6cH6*qaaHI-87#ZbUg1T}Ax)lF^IBHnB71 zz6&6_B*wA6N~N1?h#-<^#b}!)g?BEg=~%^&?-E>?H~L*%439L|))R8cp}plie!i={ zo(DV&V{L|u@l!WQ>@Ij!?qouYB*A$b9cMtLPEhsJZk$s{7GwTW)MFtv5Qkdt7wu0w zYlXEX+%93N&Qpm{IBNaZeMa`3%3)kyHA~dkHH%<%8Qz}jQ^Uq z$%4g8Lc2Sa46**bn(&6MGU;G`lsVU(_F+cFD@V)xS#+TsF{bx2Amm9y*y1-oaW2)y zrgryQ$VHQTCtB}_y0%$2-I^*s&3Mag4tpAUvm)fY_EKKcu+|B|gY(ZotGpuR2|4T` z@zS#1^MiJ8j=@@sA9t2XkdIPRc6t0d>BMIS`zA$z-aymKxt+lymyF#M743I(3T9e5 z&S1J#etN-b9d=c;;&7xI(@7YWH9^|Ftl;rvM|4g8$*woSFS z$8_V2pb2}{3yVrGn=iCn|6@hnL+6>aru5#-{=-=in{WjJhk7NsO3+gX81n=Uax)QH#3Rd5UHOqU&P6ufNr$ECmGo~(QO*=AhKHiKOBp8 zBzPOihX$*T>x!=S%xaSMH;V)R+{J>Gnz}yEi*q0KL4y1#Fo!^Vcewi8xe5z8e7G-XHtQA%2(KD5R@JC*JaO*Xw zP^s`m@5zSRlbtNjNVmoGp~S3kx8O?ttEEs(M07iQ`gD&1Zr_0XK#(WqXe6tbGmN{w zX?uPPf@={9ftxNRdk6dWkX}$`L%aGVWpZP68O$}5>|h^VdYS^Zeko+{D?SnvG#Fvj zK-Mu%S;&5}KN=)28ei{UYWwB?hX^}W>xnt3elsw@O5TU{V6PY9IdDsgDBaE>meNH= zswWZPhLy7et?H|1l4FK$Nxn(J%M~xJecxbU|`0CFA}E0 z`B6uFD3O3O1O&*<_>Q_ zDi@Q!Q%kIX%8{GzEK>vxha-66eDlYQpgnqTp+jiDQP9Yj>Ev zO&20y$g{Ugy7)T)*`*}A(uN%kt@#KC2^oQQn?GjEz}z{)7jI{ZMul1C3~d~hPy702 zFQ7{DP0S2>eb8ePF+et!KPR~UC}y4>2sp7gvTbmuKlQC+eetlNUqgG)Nvuu#UjyU>y^E2Wq6kL(JmSq8ykM{ zi^!flSN9ovTYVts9ydgaqiSNujJ_V< zYD;Of7P2uN4<7vCr~Rqmn6-H5s)OQjuUEx5Wr58$mCcMZ^XEpJB`N;t>Gsbf9EL&( zBT3^JlWN2R$yBOM&MC3=2>T!1R-DJ~B=&p{36|{)ZCI0JK;)nCeK5)?()fIMry4vB z90CsA4-LkZ5js|lTrj`@-rWR`&IK=YEf-vhO9`gU5ljFIW*>6gF4k&IxTS$1*x8`v z<;#VZ!20_8n&hkPsy#GQd|$fz-}l*d&gM}Qot?8&_kvazB)QsfdJ0Iz`Ke=2p}18q z1GRhi?sxpsx+wpOM6$ep9mP|^Szs}E8>QOPxIZN6glMCm{@oQ+gTVyVI)tA^<~f5o z&9^}1c8{=)DjqJ6;2?cjIlBw*}l8sEenf}R> zTD}H~Uv-rzqMI+nl>?7N?9DC!FxQ;@z1krxR-~T#_xbLBJpBtqBljiZ z-cnn&8bP!;Srx-ZA_ys)`s_rpROg4qFMZ3il3VxhTXA?O%-r%W0-@44%lJ7P5(=|@ zeksU+RK09=1?*rcOu|*=-P7Nq}47mz_kA;0GA0)lT?O$%Mo{)k9C%VjP!gQ>&XYhU@Y=1Kz5v4~l~JJu+Dr#vIJ zi=7?Hn>o8-%*4u{w=n=!T=GhyvtoJ43F1VnH-V|p=I*Hx_-_q3<~YXCKk69bQ4M96 zR8(-8Db_dr-MmL8fK;a;kz2gTpnxuC05i}Le&gmec!h`<&)xCm8S$xsdZuQx43MkH zdM4yhXK8-B1ztF+dyBwLF>p5c8i3gI*eQJdS0LQYNHA6V_q}^I~;09av zQuk?fP1{i;g;9)5ApfnW-M%ryiA3Wsc0=jTWm}JSO*D!L?&lo`q~I34tO&Rx!`V$Y zK)RuLC$2Y*QI-I}@M$>ysPI;8>)m9@sulMRVd$D6Bd0m|{Kr|#MgNBj=OSa*0VPJ2 zewq)FhtWx1e=4#BVoA3hSt)Yier~n)NZvDeSNlkZQ)rEJ)Z@AY1DQ8=I*=n9o|R`S zm`M<~cXqWR&!iMNFH8Zo`9d~OLUA*BStVQNUV}69gy#(#p?My7!>V|Y-X6C%?KFv zfm_Wx=Z3iYosL`&RW6@CP86Q5OuxLnF=P}7XZJ_HosDp-D`6M4X5x%3$1xAjLm$1$ z;GPgSuLk915T@QnoKgWm#w2l0BoiKD?o=xw4&aPKy>(mbIcgMjV+sjCiqIxt_Dqg=1b6H6LcNG9Tf<+ z)(*#QIQh-&9Q!YZv1(MFLVMI#i_jukIImmpskmklHdAdD>8$nHB4}!2QuK?&&j6)E zH;s-{9DIG%khTZq$?gK}(0U_Q7uQJXxTCaa(5dVDzRHuI#a%E%CSfaqY=977_B`Nh z^agBW(t~t5PjH@O^arg!txM~@>rdKsuAH#ER{t$3`TAEeFyxuqb|@ogd1bhac-Nq= z^ZTJZj??PNG`l)l}VT)uC^JOnIWBmdbunO8j}*I(rufzm?v zJ*u8Q`Ud(4Bf~K+KjVEWl!s*t&0~VvWhw|i;!FjXkGs^dJ?S9HgL7F3w#O zqob0g^%_6wBOls?p>7sVvD#wzfAXt;o+_{&u-&&mo))d0UNO1PX?c0|*DE>ez#Det z`k&ZQTruL(bU_vitObSz7K~HI-qR5zem=BORy`ikN#w>aeaZ!gD*JAp34#?EBZp0u z{}v@Ny)LPiolWEznqftVbaA1$5EsMIpy=!H4M`GF=#9IBKfP1N;5|l_X~3KOB|Ij&!;R^aV5rP32(aTfZYk7Qi72r>jTnOpnwh zjIpX+qH7>i@A~nOSjYX@QTq~AW1ii5T;0K;r?IR)nj@Q9=0)OlYeK4Er)aTR5+K(M zN73qhdl5muHGN37Ft1LqyKUoJmV==d@mBvLpb}Q~{3=Nga$)6BIt?b(+`V2~u_m;3 zzveD=K4)@1zl5&0xa}9fOLyj(w6=E%3=J5^jsDGHxv#swDkuLBLqEtDBTD4+Rc(do z#?G6nZ$nXO>jlYjZIS2Fem@=f#C@Un#a2NE$xzBu*3EtLda&Vndp@q( zc)qER%K5z^OUX@2glC+orBY540PCe=*fNpR?L;^|*dF3dH?hmP9-BWD9!YN{;LC`@(s@0`)`s0)gN$|)kS+vp3}N z6*pazrfa|uQSp($#wdp||CjTpH8gJUWqJz~q2tY+@6gr5yN%cEu^3z4a#jt4lG1gG z!QtAr+uCvkKq8}3w(F7)>(ub_Uk##mG-)Sz#tqd%pXBJh&jV>y#GU)O#GU0|YtTv= z>rjZOPwIh|(8A+Ga)XmV>=y?!Ozj!pbP8lR_43E8E?E^e-Egr(8`LI?Mrc=zs+y=K zs!lOMv?}7;RV{dXh-Upce~Ig{ZooG3J{+x%s!N3pkVhqsEMzwbO4Tv(xk6$?L)h+W z3}e1C8Z;`_QtJNm3qH6Lz{W2EV5gWBIXGOPcps|>ZeJ-;K=l4C-g6{g3zvO+z;yaW zlyLHdTra}83MS>jG*9-E-XbHRC)Xz&XX@~Q!E=WgC2(}^SWA#}y)hNr7N@mnQ0snRzymD-80jE+$Avpjp!Ihw+L+dGdQ&SPj`?fgrdW!x_DJW7 zsy1&Ku9k*7l^o~polj>^PA?sTS#h7#*d?yI^U7P#;Zi#pNwzZEdn$o2l28Od(FjOG zAv+!*m+@s#J~GVXe7Q(c1_gi9m_ zae8C)S|=B~TiGCiDHjM-V;;|^B2PSl)gzi|Cq-@kVzI1loxJswdnx3fF3@a6lVLd! zTYEm)YY>0GA~>8qIAmFW{=+1BiLucj>wM;!asaU$a1BB%HT%|T)L9An6lE6^P;{1y zUN@8?HJV7>_UWbYvS5G~usx@fDXSqMl+HMNGWTqj7W9LuL+(yA9qvYiQKEHk<7Hdg zTMf41YlGdHqWku@ZTZNIM3}`OfI&73Sf@~~dn25fr-rO-vsZI&p+ewNJR*ZT>{-RmPW*h#uEc)Z9LC}&x&0Ck0Cu4 zYj+&DA}OF$2n0jALOiV#kLW|*tbSMVMyF|O6iKZWr>B!L3bggGH-)fPVL07EbAXgm z($?Zs%o!pu8zZ%n&@P=UVho6bIF2WX_^6Xb_82XNI?#M8kS-sGEQ7fiAcHz=yjG`x z#=$sabs7G!L@xhjILV>E89UdtNuT^`alS3C5?S7mD)fke+fU7wjcT^-ptWrb9x;w= z618ElJ-QVzANIA!YUuIqsg%D%S%Z!#-a=V))8){Jm>hdSJKI)=m){xbl0zdCH=E&{ ztXNU;lN@MU+-j1>!}30=CY-o^58`RXBA{a`mbINE2qGydbWteMmL0+V}Lq_FE7oePPqK1(WJU zRKFm%IZX^TRw6N4Wp;`&~2FYm=-6sVAz(ej94z4JT9;lRDncZ!TgYd{8P3C+2P^~bp=$ZL`&N8>nx0nbS^Q_S!hPxwQlLMIk_1NmW8HH) zEY{JS`ZeCKy(aiKWi)l*KokFW0_(ywVMv37pigTVs=gf?T@Vl8m*zRYG&7bd4WT>4>T}-=~f?R*mOo0;R^St2+Cc&#oJJ z&s)k&gVatHzW?MKxQNJWUBeR5)N2LVtgnOA^@#66WUyTvXNV3+PHvR!PcsBxMOxuG zyYX5#f~dk~rB;Y0_)Wl4DxRHheWo9o$8MvR?6HujszmtgI~>0lzS=<1p{_pqHafb; zco?r#=PCE#@2?VdtG42cW9cWtbuh%qPyJ=-DZGp9>pHx%*`F3l%^==;aWK7jHte?7 z?yipe9BQ@LF<3;BUfOQ8T%BEw{jy^1Bkwo4T|1w_5z3&~Re=lP#l?ZaGy?~k1MujVT8keK!o{@^ z^R(IMZE?|*d)cfVgbDr3(78nHc@M5F zSoh|gYixGUGzUNIfh-{;iOzlaZdM#4NbliROBU7TT+s6&M4CkBm+T`*;h8)yOOEb{ zF5xP8kO;+e3uitEh=9@bh0ncp8^I2EdXnN;$416Pc-GJ_$^r&WTE=uYL-b?(o}D!` zOCKzo6t=sV!+`hSfcw|mjG*S&$7H&3VUw-I!CTzn$@Mw&j7Wp6kvp1=CSCimL(MHb zFWm`ES?_^+G!RV78quD6O_iRZjukOK+YLOS-oLO0 zccW-l4PQr;^EPIs5*Bletj+f2IS)2oB@f!}5M^C(Y+rSF$Qf#CO!D@~M!CnbvL;!4 z-1dwcCLZu`OrWAB!WM|(aW#QnckJr~m zaY7V|FqTl>uU7E@@^QK1zkvtY!+})IN5s${b+upA+xkD}i2>Hokgba}S@GUuKl2AE zdDU>=o%N#iqp0M*yIR+E!iiG2N$6YYIj(h1_JucYn>HP3T{V^%A$aW7Q zZ+X1y7igWD;ETmfpxgT8R^d(B_%^xqbtSRjh`w`)v>9TE;T>)U3EjzoLhC}OWohdE z)6ny1l=~+k(`uZzpj{(+K3d1wdx3`Jbz@KfBDgiSXYbXzz{}tT)D`zGLoGAQFw_t) zO))AO9Cg*3qJ|C7dhba?A*b+3fj`E&rMkg2MX0gc=>hZqh#9k_PD!{z#Upy1z_qXs zXg;zuPpb~zm962NgO_3x1V)!Gq?Hsm zhgB-j@^Mil0{Cy?QSE@DhBT)ki_T=1D-p1*PZUf|^0 zJ3#mJ!vkiL!~xdSJMmZq%%ZmTzxuJ_I zRbc(Mn~Q6j~j98_cwtUZ|L*C1A*$(hks=SnsVrVoe#QW@-v$>&p9`B zPF9|PeoI9H5ic*AQtYU|ibefvK=no0{}l=Ks^#(|IJbpARt>KVAnyt}oFm2_hP6vA z`D?y!17@At>8?q++Pru@VWUCc*X}-4%TI}hiQ$3Mg?g(;{!71^mod1h|IL?Z#YkJb z&qHnxMRXmaY&$r{Z!YrxhaHH}u;;kQ&p0LGJ9O4lUR~!WX5>`lk2j3*D?@tPic8(x zYmGMzaa}MX{CWeOJ^x_2H9I0OS*!)AY=xOy-BLfCdY&+RMg8NcFK6JB{j&(Zs9A_5 zv;17W={jUd*cGG(*LlR|sd1cP3tC!bg;prTF6%cmU)JJ)@`5zYZ7oaJC$7j6BC3Tfokn~^rvUf6hbJ9s1@HXK2E z+T^Va+Gj%C%9~D6%U=KsT()Oi+IAZAJ!B!kOJ52454&qd5f@h3f}FR73WkBsg< zteJl?Hfm^%=2!jMZfVJl?FiHYZtxB!!dzpGMFIts)uTUa*SWHZqmS6~7QVBm;7X#$ z=8i7cax9!dc8Q2$SUgk0Rf2NcF|yla8~|haNcdOep1a}?n9gHZm7ebM;?(k1ty9SN z2~-wx+@28qxM2i!+*`ZQkFpP`BgoUtnG6#+3$T`-ilrsR23pIb>;wN*ULd;T|?c@lOdmL_)g_#=M-fvD9=9kiAU7$Dvk7@*rn({oV8=oqk3@ z>tC;2DAXW|Tmlq;RhKKeJv1;d9iERq2}X3FaW+-2?OMP;_&CT`DOr*v+;91sZM;gN zsh1{gdX2ICnW8!p3LE?mxQX+;KFu;as`xwzn zC(Q?wl@?w)N?3P2IE`X}I!5Gt1LT6Rx`Q|5fM`xe*N*?r1Ubi?5;m#Nmn;Got4%NF zZtshNl~CSMhmqeT0vmsC>{U)-P^s6oZ@B0X`Bh^ln~Xar6`tAO-8IkyrS7v5c-m6# zLpq0ou#a@G<$QI2pyriV(^BI~tR1mfpH6`8kiS&Eu+M!mhBlc`t42E(HCk1Wr1!SsvEy%mZA5n18&%M2;k!{=&#>Js62YhDPA0vo*0QaW|S|U zt$JpqGcNQpCKWEKA5_VsxZMg1$00d#>QMI+5ICB=z)?t?7lQV+CCVCFYB9-^m9n>0wkATC+juPWb zuNN`si*xK~w4mmiwNEnfXz?TSD5(TzHP zjWn5F?6VA9j1F+vqp!!P|zX=H4E@N^W)Fl?jTlm`c2Gd(lSOuxFclbsPRx z@pdtW?Gd+_4zABlvGZL~@lftvO3dj9S@uI?qGt38s)}yaKn-z1g2Sgmi>p$|$A5sy z@G0QBIzi_*>}N>E7_ur5fu#+-RWvN~-X>Imj%zU>LJFL4=+l86zWG|3!mQWKU0mhF zEFE$#(6Y|gux^t+xKvxbjN>p;0uK%vjT_MRh6B&O+Z%y|Y}Qw=afDH;qp*60h?gAj zr+PNXgRoMxVFml|3!NE?EbEyoZ>~Vr|I2gX^>v;G{+SeYndjQU%Dp!^*AMpf3u%_i zt2g4utNq(I5zjzpZb<9nTgKFpoC`*U%bun(Qxmw zURo-^yl|{hlaRi0(WN-nS)|YtZiAVgI!@0H3^KKp1u`4WyJLz7sz2<3PD3ogl-pHk zad4otcC2da_ZyLjkdJ+hWs9B#bLbzxp!ss2Jo8V@`;szI?m>$0jR%W z%Wq0RL9e?B>e8<1`kP1}JqSfj)T-=PhUG_9uSEa_fyAVH`SLtOfp~e=P!+&1!S42v zOY18lq|UF~@d&)s2xPlwH-2wf$TBuH9Otx}kU*(8-$;h6PmeZ>UF7zpcP{-;QOr z&Hu3#0XH*V(Lf5^6bn<(QGTj#w9%)qdqm{aE+f#!2MOOgUl@X71`YPw2tcop2vfpi zltqrRD=JL`x86~*F*g35$qRH`a!-3W>34?4&(|Mw%c9dtf5cn;#g?ITdK1^?$r>ho zeKjXB^gEmT%|5?{H;REbqdm5aA%R!0@w?T_#+iX7PUx33&fKSpE^50eS>+O$3;mz{SWHvB+U&;g9hC{L!)LAfc)wL*61pCHpXvItmAR9Rm` z<5#1iMmY1jDjMfTeWTx+m>w0ZX@gd3PZG$wGElu@wp;mZbcx&qt&?Q4Z;Msd%iLf{ zRg8^IK6Ev&;8Onw0m9qj8pFino>aGyC&ATaDwR3c$hS==ovDXNh_(h6j?^2q+LSYE zc@|H?c1%2YC@-;m!O;V15b6sB(Wa*DvkMH7zLT|(i4^wKnJ97N+OM^c<(R;CwRC)N zbA0R@kgU$nnnJdtNPO~4%aV=vD8E+jF46Ua7x#_6b%C=|klydgSPu)Nig^~!kySaO zoC_34Pmgb?^x`3%1gWgO6Q@FqGWUWOpc&@Cxy3AFhz@@@%M~v0#-^6#c-rnX4WM|*J)XGqTpP2JP9LW>(njlbm7Z(^QlkSlo zs6{iWB*8Mb&0|z7>Q5&<1Bl(8z>0@p=JO`cdh}Fgb{vjH+R-ay-y==?WAq$S5ea=Sj^o5xPRHd_{6yzhc{z5Vs+R#?m0pfJaLwp4}fA!-6< z8S=4*@QiJr1z}x}?wAr_cQ@|Jz1^PbzPlbNY-gP#-(jo^{|aYH-I3IDV@H@Y4;f5A zv$V)Dj9}xlo*So)_LHEC9u`Q$!?j9`0^r^o`lW+?1MEzQk=}+?KDfDOCnlKv4mDpO z4apfw4VpY(p0Na21~M7E0odymk2!(${zdVsBobnv;>~Z>qr$LxNjGQWKMZfmRZEfrsUX?I`n(Q&70$X$^SPA){EXN5Gh zRWt6vETmN*9(>2byXvzJ1fYrbXLydT>%Q{uJ%LJMn&+2hmv45az3~iD|bTZ+A z0p&XbmWBFmKU=c9yeA4|gucZ}$DcC&e3TZ?owNazU=F;7H40ODaV_)vi|=$`*lMAK zwQlr^Cc69!EsD*2y3?qh)s=hWAnnVt~P)Vv~4Fu=q9cWcrN#2trOoFX>6Sm7}H3pA1m=G5$o~vdM2t1?z zj$|$BhQXu0Y(*-Sy6H*Yzm`^s4RqI7I}cj_n^+t3$;Rr17ctKb2tV{Zs(x_sdr6#) zT;E}*hKu2}uR>G;#e04-DVT;!WFMu=MG)ux!P@i^m&Qq-!o9= zypX(IYzUEOM*CeoTejD`EU?8%n!?D(bMuQt$Fb#Hq?bahNFNOQpI~!V_50Tz@;?*_ z4QC1Y>w>hS&Mj`DHpY=&Ky~y)!1BG>U)BWkB&U$BfPa;!@ttr=5~+-L8fXRZ|2@-V zNng*oYnpNW3c^Oi8U|u=vs~l0t~e~%!k)NbdQU{lN>V)*;{b6%zo zq)ST!@~>YaJtct(t+Lys5bD)zb?4!aP6uSKWfh^n4K@!R+?~W|{1Nfpq{W*Z2uF_- zX>@o$Wgd@2#*OzR*R>#TQduLgNvDm!20p5cP7p8{>n?-w#|B`!}_pQa`#>pen$1v*ag zF=cZhV);JX1X>_iq1S3wd-Q8nw^?Jz^7LRe&IT&M`0WN4mU`ZKkJ7ylh|(eQDP!|5 zt+Kjvpf(i*Ke{X4)p7RsCqYQ_@(dd?7-S)K^7eX{au}(vge8+&DUSU>Qb1_TNDTU= z9mr+bujgWnSl^-JEj5upjsTs(W8SAYHvXAqlG_#|>%_V(=EG*=24;;fvV&gRlV6T zq98MpcYde1T$1A43$smm)|}paXhtufJ7)FCnpT{oHS1Kb=_U#w5r1BdY;??~%D@J8 zLG}ppxk9KT{GQ49ca4A84vD~P|dg?ez*XPY}GpR)s7JIMA+*+mVPh}iMKN>IEi=25=SU-pZ zn@heb`5>9B5rJRAp!zm$%SxCgk?Ws8^`BtJE5N5<@?p^L z$Wm`IO46zZJlde%iuDEcxQ@G!!#B0LAFha2*A)>_;&cnippkWAy)65=PGIPQu+*87 z5Id@khS0aIe20=f#jimpsoc4ezi{qI*TKTy@rfMfPh9U^0EPKcNrpyG-(jC_`ua0> z=-l>iw%SQl#ll2>_*XQ98!p)%m9s5PZ@~7UoyM`AFOI+dK5P5FHXyv?Dc+dc^#+d~ zDAu(G>J%LC@pQ5QP4ZF*R7$iA*UxwxC|*YDnBsQxTFIYxvY!tbq(r4Oxka zNj7?Ep8BC-clh5=MLm9A_PtAUV>ZA_=&NuxG&J9wj@5g5nB(H;0G%Dt8$^%j^(!@n zE0xG{RE|zmPs1F=oC3y&C<=}tis*}kamu;}2TN_;@T+>uluN+Fo9g>3;Z_SfO_J#L z#4627pV{nlgI>vLezfwLqh9H6ocXXap(5yBcNk&Q#oRz^A%O=RZ1vDv)D=ts~?3i5xj+J zqxk}pq9R)}u^O0mD6noo;TSx4d#r`}zehj-;JPQrcM%V|hOQC(7KT5u`{B|73lKP! zTs31JYJ;em#?Ahz?Ho8cU}XBly;~z?40g8q^xSgvg2yb%Rh-}PgY?V3_wQpVK6O_Rhfyn02K5#>wT%7k?R5w&mpb1|@px-@ z)!=!I@pg|izhOE5;EAQ@(+tFhLuR|o`q1!U$U<^^p#I?Nl98fBivT`jqVlBe5x)@| zu?Q4eGw|btME&DP3PeuYYmwf=$*Eg#AAY3yAUY&xv1VnYySeJ`%oaE2BQemc`=vv2 zcEM7=*FE4JnceE3dM>6uLhFn}@ZO_W!LmkX8^UQDR^%G&P+^2)Z`rc+{46=AYqBzm z$6DsCl?*k7xzKsM`64vA3xkFLOXH!&d>Cmiej|om(O51n>4cB>pL~^WIv0iO1j(iI z&+nr)#|S$)BbvhybYzh4CdkW%CdyI|-8(omm3f||OB*9D9aE);sJjHXq_0GA3K3I+ zRrmGcM1&I-*ScX)mP-IOETLi7J;TE@XVnL?M;pdAzC5t7zBxTi^PTNc{HK1gsxLw9 z`|A~jIpLO^tVi8KOi+$oL#L6+??l7wp*;YxM`p}?`e^L0sHMI+P zpGu4e3|CtTGL*u2Xtg+|t65|{U)LClVuW;G#dNGjuR}9R+VdBaz<^xLLK)J=}y%YZe4iwwXaXW001%R>?Dkx6&0; z5|o}fJIU@U|7%11QOPY3&im_-tURw(>_KtRX?&>O6pR`_N$4it%Nmr1ZP_SQHwLag zf;1U+SNheADAI24^{{5}B#TDzXK;_kkCF$?rh;D7?W?;Y)82F(dGzF@@8*JlIQ&^& z`M9jqbjoQiDbi^oGlX^kRlB@zn9p;dKt2g;?JCGNEpJmd{ZP>EUYdl}^NtX0QONc& zUnX0+3SbmBh2mEgoWhFWL17oEeW@k?T`~dyH_Q>Nj>1okY|_;=_`(vUAf*XOGF28i zB(Qhd2|0oe{(}Ww->oS3H9ffyiCJUQDre%n1i*zRuan&eeU`PeyH$i%%TkOV4#``m zC6E_8hpz@;JpvwvC)Z{kU@w4k+kLtQ-wy&#Ee)f5yNw@8uudvF0HysMSUJxT9=4Cu z^_Y-}h&f}nYW0gz^MgEme-Y)>XtS!FqC7KTOQGgFE55}YR79l zf(IGr&ku`mEpNDEBxg}TZui`{M(UXS57mdM<#C}thiGbxDHMMen1)7RGRE*<#WSY zEYFy8CW8(ir?Z7Mxj`|8@93w8mk;aMQTEJ4@zO4FT9loKI-=vn%Id)keXsn= zp*!f>tbkoQpAOxSW-&xgmKDQBa#vGZHtw!r_0Aakuk3$bzs9;^H27*k;K{y+jLB#f z*Fk2+37BvH_=y@G8S9U(82ez z9Hs0a6%Ni8eKBD-y4bb@4;nlkw^I=HVf@7xtt0UjJ{*bmX^EcR2Nn7T4-2qY`08A# zn7(sK%g2?(%V@le5rYR!rRdd;VwzUWp4TA=;yw(+u8m;LjSx)@o=?W56 zONpLtUgYEh0UavRpYEKj91iD7Xt1G*|7Mxh7$Y+HjO1X=xr5 z%+F#sR>mh{Kp+AE*Gn+bG8T&xN?Ti*@Qm&71(SDOr#*@7|5* z=nzTTew(56mL}kmMA3GgB<$4ak1x5>y#dbDI#mteXB$}iHyEB9h-sp;dNZNTWj}a8KA6?m*PCoop z!Y~VV8j$6$(NHV;PP!55;n9|{*&tji@p25kXC%wc%P#d&5x)ttw*!LY>KN(c3m>aw zpEu!~7B(dy|6Ag7Ku%6r?rI4MK_2Po+;!&J5!Ey34F2fu5xr^=vWE=*PtI{H_7P z)*#7YjO~Sj6agrOp&c>SUgnSSNiY@FR}+sJFY;Tl>~Yt(`B=Mq@S&R($ns zeNy1aU*l}A?=H7sp01TQdpAdfp3eB0dEG!9d%FA874UuV*PI-PpQ4Wc0sq@u;yz`$-k9 z1%)2`!k|pc+SChJ{&UL=sRbB5SWb~IA=~0;qBUUQ6xB@k`$mJ^@AgpwYbC1dF|6OQpt@=j?rjo7S9qJ$6o50LH##l}TE@1z zu{>EPDp;_$M)@yQP0=yE&EBz(>l#Wzj7;BXGJ!$t>0b%8WZ6o&TZ~8CMexg( zopb+x&AoS6Q`y!(o*73SMP(ELrH)kTB25UzLXj>tbOw-?5PA)5!~vvBmlmW2Qs@K- zEg}K}0#XtnH0hntAqnN@%$>P&@7#Oe_qosW{XNgS{y673=h=Ioeb(A*eb(OVtbGjp z5Bc>k@7L5C#sV6~c}Cu1l2ZM#F}>ETegZjSQp*Fm+6lr? z|0t~zNo-Kmbs>SDFgjvdGob~Ndp97ZbwzsVt;Yt7pGLMFW(H5N26bkuxA1q?Jz+lD z17-2^LjJ%|m6_2EG1IEWaa3xMNAXnP&g!9f!o?~o;n;-Kd{3tYsSdW2W0#C3KheWG zm-$S7PEKeYJrz2>!`Ntt(e(sZnGwR6-NJHY`6!;G7Eh^t=Bgpx_j^aUcE4OVN%z5u zK&Wq4>L5~}L|{@+EI8eBc*U!Z>$ZNfGl@lFRUo1n-S@eED*}*TEd*Xo#iolM=)R1h z)>tXst!YI>d%?@pUU@(qsZvzQW5;PKpK{TIi)nMvxAg;>D%LvPDAOTXc9aX{JKJp0&TosAhTt=2*9A7-^JISG^U0$25y}bb{C}>5u zW6EQCx~HdAQp3DoI(_&ImJl@0V$D7{6a_bnMg<;FpQT6YF&`rLGwHs-sfVXXk2~M} zCqs{lj)uVkz=FIkKRhG^4Jmj+%(4iEJB|)Um`@w*h7LUK%%6h&TshkYaA}1W3$R^% zRctWPhis>_-xOr;s#V+jTgGFsl%)6X8IQ)b^sTi`60YlD!v(N?7X*@>O|i(eGYDhw zu!(-04no%kx+y69kjR?&zbV7HMRo3YnctKYQIn+LV>YZKh_2sWS6z){TJeQNCz!d% zx*?IN0h0CDhCa_dgX)BS{R@^>$0f~!TP3eH78ls3%a=D>8-7&PNY~Qe{8XA4plePb zZK!$oot{jZC%CUD$k?H0r&}Xa{7z%+uU{`A=1Y}oWe$(Nifa7PNvYv70FTxX=x(THPn~T||HR|UW zt1Q=KT{F!>AVF24)Aw&|FE`us6~Dg(Hz{y)IgPm%HvQz0R?4v9I^sq19D|q=<4!Wf zr2OtP``)0m4J*I&!mbhk;Np1|QcR_upP(2(P#BsYkRdl!0rw`nZcO!Qks7vqK3=NR z#5_-Vb-M=NuCkdB?YZu-#C`@qkGznqyy_i?wPn;0$^s?Fm#%bm3Jp16UbQUo=V?`d z>lR!wQWIj;d)vF^euqS+L@AAbEA4LvOwH9*)TJz+_acglU7LoQJt2DZe73SZg8ERP znrtd_5Z-z(2Bs;MDPu5}=i5l2v7&OX>ZPw&lZC_r9e%aT77xUik>hya86c=&-~0%4 z*=%c}^6s*ER!%Ysm9v?wR5WRM>0NHjO?~O`YiJ{vgAMaDKjVL0^YnBn149L^PWW?aKl&IsgAK|(49X0PXw2!u2wzjM=PsQjr^VO86> zma>$qGoLaM@ds~y?x4B1`vwMnMOB6GspaU@+>11$kTXD+4n|z`Kq@F4*x)g`6=WXt z&e0tvRi8PQ#N|7U({j0z4_FS!Vr9K} zd%PT!Pd1iS`w=m_dzcz|)lpepxe%*)Zn~8VlyITupW0t{*9>^sNDQ_BN>jq>lrvH6uv)t&Jpbq-C7hV~r`ulCYIH0T1^ zRqTpHeYg%wg`g0}+JS*VyUv6s30POZT(Z-FsEbU*hJVOtH(gMGxO6YD zc@T8uW=%=7Nnq2Lj#MfcHkWENJ_F2&x;XuBqQBh9y5bozu{4e`t7A=r#Upist^}Wc?f_Y_c8|MeecPfj_`J#~nMeW>7!Ao@HeuS%j4UXe{b!dpr_y|T2FvI%cT(!wJsf6a+;TP* z4BhpttRy9tY1i zua*Se1x@g4z)TFX_ovXUr8=Tk-`fPX+XX<|qsK(X%4PL-b)zJ+Lg#H%-d5@XrTQUU zZoiPPIr7ba0cnjZG{?ttGri>4?b|&%?Wty~js4POwP6fnCQhKtHFc&alSApPc78b6>-UGz9XsQZS9iv% zO{NU7)3W01ntzSmtF2Mx(!F0#4RGGfWB!Y3(HrB^>FdhSc>$k19R&^Neqnuo8k;&z?=D(ynt5S@0_KOquTFU46Yx%^F=T0U< z7dQpGrUE!MS5NN;+S+mUDNS~s>g`~1mj0V!q947JW4U3nklXk9j^?8>#0bg;qlD*n zu5^gpnQ^k|DDd{+^p`-!oH+Lv67Vc}Qt8%cGpCKsOZn%AxH9fGC<0E^Ek5PF%Oms~ zi!Vsif@@7hswPAwJfEpmGT;O2#iQE0e;fX;tzl!(v2r8cD-Y7uM^26HeXd4Fjk%CM znt!Ql%jjzMXB=SaceBgSXn)&TXmSj4*aoNNmb9f5f-e)ChC1^63 zXhLFJw5aWJCE$7{9ESfFtNFdC^k8uAHSFGJ@Pz1ufLU`H?@C5j<04{E6slz~E+oQy zwZ+w|8$YdlttZUGN~R3Dbc3L-?Pgg~Cjx4QU%9>+mn-16pNNd0IAUF6Koulx{Wqb8e#F461dg47G51s3z;rQaSVtWFfNQLVM*$|Wrf-15?M0?z=TTg;kl z7^LHeGe8okqbbFQtgY;o>`=Ht<{YX2i2xHDCT=yiz(svFPncR|RlmYJVQ0X`JbPOd z)0E5C>FpUE+dt?$Y!224z0!VLiZN;hKmXQQ04#`dUJ#_> zgj!rml}e_lG%jsvzA)CF0^&MPxTV&}!h~=Gsb?VbN9S$SWqHn5xiQI?iug}_Ai#^2 zjJ`Y{aiXqUda`4C(5sD9(1KsdER8X7XwcRKTzblt<73f>wKW8@RpJ)bY4_A}=2wtlLF zl>$ZCLaG7YRp1ODA+g_c#Gq)ou6G6)Ph2~_SaTjyr=aLdk^Sg>`iL~P8KJE``=<}8PhnJ*GvhIYptOy;6N1XxgD|96Zu=W_| z>belu30mVD^$F^J3+45)!@hEAU_`ll$FZU8p}ttqYzy|+Sr7XKtv7oQXi`f7r-!+= zyS3v^(3*RnKffb^&AbWGd?)BZbi1Y?QHb?PR#(zU9e85Vs(|k=dKDtmSz%)eZBA5P ztrsF12ELhxjCFxsRYq}ZiMx*5f&9xg5+&2lB%AT)gNoN0(Wd9yYjzubq(IIg!QZ2M zt-0VsdYUkk#TiQFe%#zxuh2~H`3c0{kN*s+D)#og?82!=UR9ZyH;HKh%tj?}iLN*C zm|w%rYzx}9mi$xXeR$PRM|{dznb zE7vX!u(u2^f~K&dAjOr6!)MAHl#N3*pRzVrb{k zZ@*<9Ffi=de5Nk;3Ea5;>Nw0n8+b* zykki4x=dLr2c5RS(yyn7B7J!VI435DbDhkPA`+{m@-u0;oFlmTRmtgaRQioh?GkKp z)q;QBmChKpg}B3vW1_l3-=7`-_IA9t7x=?xOjj!VSX7G9$1Khr2OWysVIHeAT#Rhu zR3E;jGYOfb3QYjx#hK{pd0*aq^cDZcuH=l*I$l&J|B`@?d>fM>RjKPR$lmeHzsOmhY`1k|-MgLHyAtVlZyQ z&%U6l-yl)h$Y`e8(L{CIb66u-P>mxixPmEaGMe@h5zN%5Ct%g5)?Svlu^n;RFOubB zxf(sdD-}2Lbo)Ca2(py1fya}8qxi8U*hY841-6<3b{I42PW$eUOug>vl6>62k)oDn#GQ0BDW-$@in_X0xr8G zcy#vI_A+v`_}3Zb(w>#Kp<#;Q>p}g;BpBFtwT(`i`&ktR%K9X;d2y&|Hv@L%&Cma= zt^w#$B6ZpLGU+Mgj`hooS9?2-jUVm6ANC!!(hA9@W7bo90^veFxeqDO`{l6q1siI@ z_@chZo)0`b?Tvq*8D&~?S0y-N#hR`Ro7PO&!yMh^EX(j2>N#ali|R?})uuQk>n(K* zZJgMw*E!cNsL9yQ0tKs7*MlnZ+xXK2qytjA8@dwYSa#gOB$;+M%CYhJz(jv03~Of5tfT$HE53qGrJ+(v?warP|(B8|;;1CmsDbu}vY@);nd z@NVgDzhX?|^VYn$f}Aq|Cm!c9(Bq} zj2}3}m3;YoHv&a!8^b#aUgsD4C};cl@tdc6256glf!N%f#~LrdDbTS(H=)Y|`DxF` zM6qD>F)7zJz^XCmqk-d`Vqx&k^4`=ns%QTsC(7|r_`QwG{C4AcYW(jNnhKgWKcTxY zJt4KJL=M)MoEmNShE);T%3yE4_xD{M7wv1HX!;hX$!f>mJkCLfX-?Y?9{_+$h!}-K ziRGc-R~iG9Rab=rw-#m(m)SqL1~_;9RRsZl25>V2rpOn_v`P4CGBoi4BS#@!@nWo+ zA-M)Y(i=uvifncTz|1Zd>YQy!C@_?pzfU(Ty*$Cx8oQ-i`I#JPZMD|fc^ICyig&pp zV4xX36a0Qb8sS@LTR+7QRwb9Qjv)IGj4!pB0Ins*8SdaH_sx;?$w^DgQ z`*~-8XBeN!dWCm4_&2TcV*FhY4^0R{-J_pG3MdF_^0Q(JhA4Y z&vs-kKY-Y6IoJZlZjg2F#G#p1#}Y)4K%y@wz4?2a%x1@N$IKC~&4kIk{>8>@xC)oG zis?$1b7Y)-dCngGl4|mm6~D_HSfiFX)&8*XiN^bDVLG0n&xdAr?N)jv7UUGva#?lT zwj&$Hif?~O6{x(L^SOI6AuU%L*mEVKuT0Wq%nEcDm zZ>yx}rSF|0)cNMMmGRniEwSybzF8HN-Cv4W95E#K2E_zhIcFlw>l^Lu=X;ThmXuF(3AYd)1>!1bz` z2{}@hpRru`YOPs`NKIT(e)x}3jXP5dE1ug1={=ca{DC?Ce6hcTa=_77$?b(>O+cUNUKG) zUCotBb#1xn{KeTZ!HkZ@ytN!sNi9LX_)BU@JqR{|%+ETs96I2~$uYf!69dY|4ol$e z%DtsaRzS6q-Imy6B_@rbV(BWKPQ015u8^;ySUoF1*-q42@ZfD8>tnxie!);1ihyVa+BFH)ThJ5Jhn6cr;%%_EFAg%jUfJpe3apHh z&{$2Mycd%nTqe8K)AJrAVp-xlo9*9z)(MyjFur+pnsHXMW;3m2Nxr+WKn=~UgQ&!D zG5U5veA0DeG!xjMW^IVcS7Nm*NnqAgZOOQ;=bpu>C{%QSIY~}a_>F)+Y2ZeEMQvsY zPi^0ua9I|Tu_N1S8A;@->S6gF!#0H5t%&dl`Gpu08a^0GKgN!3n1k{dH^hgDzN=E+ zBxRr;PvHuW)Pho^w9DbX@lihQFQ2fE|B5?}IOi|x=j__~0`To%>hLxsKHkmKO?kW(AmWQo=nI&^Ur53twtx4xT@x3CE zw*7QXj4+I87%p0 zO;*?5#-yfQK%haM4kejA!x@X}gEp}lT-Afmjw15iXR=1J+sq|9WCK0{Z3m0&X!(q{>F>|a-kA;e5O6`&zkmZH;dzQ{sru5>0O{u2X;6@3 zX8sWm)VTP=NN}C`RtSUKdh*jcN2lrLZ_fZb7h-m=OqaV+o^pT^j0Qr-WX=H6ikuB& z)e|@7!1?7*i9Dq*l6f?6=m6EP-YEM12s_OvitN#V;oujk++Y9H*?O}xQ|;wtL%k=w z&HK%noy}}x*E1WUquQAZV^Bo@jCk|S`Y#g~eD-VFX$rXxCwkJ()VO{~ux(a$34C)( zX^e|vreO5xh|xcxG$C@czQ^xcS!G(9bH(H~v5qv4_OgARU2P9x$Zg{V?=e#0cW#xA zjr-}oZ(^yfrjLth57-#OB~-4|Cana#t*z}97A?!DC`3ybv6gm9Z@>f zt*k$_xxnx9SF`S&5G$SL2u3t*?=&>Q2TOEz^tp+?@hMrVWW9a?WwbEsp{eb=ytZd` z$y||TVLOq%WV&Gd-f44jlYs7jG_)EFs-Xzg^dWPRE@etdtry0(|ipf$^B zOUJay+&SLd4-?kjlh>esnC!mYd92Li98$$N0b1WZp_=Vj{OdlEe~?`nk&c(7pqbio z`NLKr7*ZV7)wlP?oE=JNWD1 z34UtiLXP1|`;AJXlB94;l_kGuPC_A#mOkxe zzv3HxGq2cQgf;K^YlDsqsgi?&mXn1I>G9-WdZn}aA=#UUx3T~5TIXNn$H3IRFr}W3 zqm?`!<=v^F7g8o1`G%tp8^kWUlaod^6MCU+Yvo_@B2uahjQto7UN^KXDY+Ck+7K%h zDadVt61nNs(P%7t$1bZH`D|R4teD|cmc{Pq|0*g#A?&Bn0krig#(F_~JB~~_}HQ1m8dNk)b zq7eV$svALOi}P;Rn);5sA|{C51e2SE#2Ze(3o76*<^Q-vt=v+utqO5PoEU_D^K0E) z?zYv=cl*&@l!mN*?c#83>0B#xzZ)Gr9jcqHB%q3*|+-=W~acZ zucj{Yet?sw0@18;0}95yN~+Mel%C%Q+Bc#D z33!NKwDnAV0*Z<=amUwy$j4bX4I0nu72^Y?$;JFzQ$`+c=!C$^EwK%mQ(a0#OWADr zUfR@`X_&FnTZ1#e)oXRy`#y(HW}8480ip2e%)1<%jV z488?lrA|sU9D3ws+)v8PR}~Fr8Ox^_I6r}HNL(Xdc-qW0uoCg$s~T+HeV%Fjgu!S> zYd&6@Z{I7OM^`Q~8R+H8*pgle-Sb9coqvjFw5lJCv#x0QIxBqu9LGAtDKM=!e>^a- z)E83J=c*Brx_>!mDA|s6H@oqNp>zfDj)R=6E$1qS93(3uMUKC|xn#9_8A-K^Lvv9m zB}Vgek_pl>R<>JyJd}f1$EN;!+Rn;LBf|=?#5h_?g1p1Y=M0W(c6+4Ii;h^8WXb|) zJv61WM;4fueu9f3*3D3P9=OpBjOLfbXPWYAL~_Hc>~p2=k0srqH$vA2#GTNw)6&R zU(N{VjeGc8mG2r=6;Dd11VSTb7165;Ht$adtnoW8dOGWjs_>Kb@s{k|wg98{mNJFS z&Ug5lX^PS!3$-ztdC)xYW$=p0=?mRsNx4ZG!pJ0j^tLs2;WRJ=%u09^?JPXvBHi^R z$sP{RGpk&vHu^N3S**@%UQp{CSDe4rIwh5$V&0zbh!r1D8Cnq}V(;yBAGsJGXM-R2_;VwA>qR{xR?nA1rA{i3k|KAR?9uq zo{(8t8g*WsRTNsb?mfoap95(cr~%)i!}qEy zJ-zQ^P3Ct5OvUR!y5=ec{!8?)-cSl%o>dNMoW#bxD)T%_lrZS52KrcLiVehBvKxYW z<}FgTy-JCPe%hYL!a0|7Rs%GzN@uwhIRZZ%rI^WEDZ(?(0O9XU?mqmzd%XT)7u5rE z97~At;yx}20(yfB zii_y1F=e@IBp6j;Bg89vNtSFeu@91kjev7vmXd+>3jTjZ7DNm z6Wd_?>TUD2-`s39bS6*rHm-CX%cNSEug*=a2hq{A&j87%Hs$F0ykOqCdYP%zgyoxO zfVL;4L&288pO4BJX;#)tb&G^)W4DvT?@yS}hjTLAHV|$8orcggQ`$#XYZP?z*&Qre z+v;?ID$E(s=b=k%hifJz$?F2!UKLa$l&fqN%H7`S}r85DgD~Re_`gI^vx3bZ63z8)KXYVdpR{Ol+5*}*A`Y?x?z=E zJsUsbl7FhEqN_P_6KYGn{+%&*UQk+{X$I`m-|ZI+x@TZKVldFQiM`L`va`M8CN=-- z@2`Td)_dq^wsT`@ImX=(b)Q?+bS6!Z+}q30sCmM)D)KJ%xu~|ZMRN|Ib`)Pt5njEiHK7X1D{-4?{7Dl*zS1n7Lyr` zGRcQCc8w`db7B4Sx8&F?l(b<7=%J~3uWhIUP+LTtf#ZWt%YwDFl9>53YDN%$+5EEd z1pIL)hv1+z26{PmtUS%Hi7X~i`TJkY{s)v~UM<9TIkeVBjh%Beb%n;6Ef9`wGb<{5 z2Q|)=P#s^#eQDD7Hs#x9FP(Ehi6*0Vtf?-c80&X0=cB28>8IOWrxrn>4ku1KNN@$m zZKUz4h)P+SiY>IqjHyfoA?FLZJ~8VP(Bx`vT?}>z5pmdeI0H<(Ck%k`Ardlg%!bsD z%W|p2kev%7t6705-$R{;%tzns7=pOOY+I|9Zufb7g+1oYV9i#bvQq)2i2a?eW3`J? zpYFnd$C{+G-LV;s800XpBaiPA!O0bOWTqF=ha8d-N}IBl*2M8Cn%H+VCb0!aNip(4 z*7EM@N@K){-{f5mrr?OsOxAne2`T@*C;QK&BWeCHc^ZN8bRgXgz$@b7 zB3~YD^7`@j@;5ea(GKeTi%wZz{rZ(oBpprV*Twfr6<`G<7tK1wo^S@JbKBy*THjF9 z>UZ~PNV-$w86d9W>up_?;|t#beIR~lnN!*?Au&d@?TB>|=fYEv?g>OG;2dOMySe*2 z=~pmRYH;1E5_Q$OvX!4%S?tMb3#nel?F>*`eDbCHgtPF)btLTJAvuI5bp8Ac=6@JZ(QLxz)k zlXG_({5gh9om=08jF`niEgPn_we^Xj)%s8KA(qLt4jFIN;3!7|XTC5bUi3{!0ezdc zudhw(KxF}c1x*%fdu1^*O<%3VGQWlF>t0*u9AhI6_~yUw zH63aRtBH)+3zu5$I?t9uUl_*J0FGCuZZDPg60BZNYJPYuRnTt`k%FlwU$b0WDPdKC zmPh0xKcPlos_ng=uK5mm8&#^kpRQiM$h4e$+oDp6*pen_k3g4rzeXbnzNkG!+51Sn zw>>V&;=RjO3Gue{OKj+vWR5zp^e_OLZ<_O2wkbX{!_ zVbTf!8ZSq6)}N*u6=nM`2AX%~`7n!K{VzQS_XPqNr{}5M??4#){Ig1h)oWoBl;%q( zR!L3QL{=WJe5p;lx~FkkJ6Zz>Mkh@RUIlVFof1tzgi z;qh8k+PIEWg^P*udZye&RX)fon|>~*upD_heSVrIP-5$^T7E%Pn-OEaWl zE3+_Z*$^gS_7om4Woq-WO*9%XZR)M>js^MJN8mGS)IyYn`7T*jQxWhyIur)li!LJ4y^J~ zPOReFE32&={UwJdSjv&PM)lGG_NdPDA>iK-eMW|K0f{hg?*%lNnAidFOOcukqUHz^ zFSZx&RLxyp7m8gqR$t{O=GCiLBDrZPDx=vc%}bBh#iKNPqD8C-w>~{?A2>~0+1$?W ze2!nlYHRnZg9OG;A{V$gywkkT01O;cZ(!mD<2FO_oQ(xl#f`NbdMmh($1Bt6iF(TW z0SPO%Z#JQQF)SstDsNBI(=qdQrAV^+jCK2=ux+0EH#gfy<>+d3kVRk>Pg@tuEY7&`S#7&4(#vKjq`Q+ z0TNZj>UYSK#k@X`H)ntw3Vm&@U1xyH0~@;CevkjkoQr95Pp5mYs)fVrw${)giI5-kypwetI|)GAoF?HfnO$G_KgO>(8XG?~GJbRJshn zf-Z)J-@dO|3H?Fepq8bcs~*g!ozaa+4Dm!bwb-)%785-3?=$NdVn?WrSK)V+txK)n zwatr9O)m7nitFq{7hmDc@IOU1H^b!5*&Vc_T1fwTIQjvn1mxZa|?SeIDrq+y}|2`+!CYc?Plq8>TJCsZ$_nTS$UEv9LN+=brcT;ZEJ$ z#(4pq;&P=jru>57P5(}+|AFefy<9Z{FFr^4c2gKv@prO`Ckyn~yIQmx(~p6ijvOZ| z0pI*o=AB9Pq8+oLgi zW_-wd-YJ<=#DaO-slExUsVD(m#lFnxzP{%LPz1rl+v)c zpdHJ*rj}4t0n8!wIu5zpNZ8F1pRRPO^RhDYU6Ue1Xuks2Mm%_ae(gz#XBoZKAKY@N zNF5XL4~s|kq{vO=sO@{Y811-mt+^jf7+7ogT^sh#-mfbtq!m?MWTXD&1Ne*aGav)) zZ+osXtoish+|3>iO=M4Vm@tgd83IZ=_JCEltwwNkA!#B|qOLg#J6>tBp*oLj^<5X? zerZh%=Q&b~SBm(k%HIJJnk{rwMikht-A|2yLk9O}ixA0AwAiTQ%_`-y0aEhCseyPy z_w3UDVibI1Px5|gn~xnB6lRng{)~+`#^NAc(4L$@gN4t}v4{XKi`m&*D^JUYy4DZ+ zD$W2hC~D~$K)k#Q2b`(y_NNsA+L_ZV%O#9e<6Q)aRZL1SDdfVenRCTagRhYpX6pV) z`4g*PJ!aA?B$VlCc4>6ESy?GV%hM5K>2l)$WL0m2+f%Jmxxl4VWuPf z{55yN0%NQf)`Tb_NemqEg`~CXix$Y+#Su+bNGWFk9S(%bJAbG+FML~sH1=I(yJrMK zT1{RawOXZ$2^x_w^)&2}?2jte#ccEq_VaOkaxL?g2!W5qFaM;rpzUY%0hJK8292AN zuFo}O*Y+#MVM`OY6{@5>gLKreFcL0{PsZ4-+U84oT5*J1TwFP|E2Envos(nl=s!7X z+x5iuu0HEYpQKhI_WpCYjN@UBBHXW&V{Kh$SX0Xu4NW>o2Wd)`B3(p*AVm~H@BIn^ zX%adZq=U48bdZipkD-PlMS7P20-+;PLIzBPGsw59my=75ws;@EY($jz229 zzN}^okN-yBv9J>5&m&GRsCx^oNrdFaMdxPBeeZXpesr2edlQC}gJP*1d5d?!cb2}0 zJZNazAY2}^1rRK$v^CT~ng=5M=ia8+K2O(3SB8lI%>riNRLhE$q8baZH1{#>TIUku zn&j0|SG>FI;-T=d=U_hAJn^#tU#|6!=(G&LC)qGVcSM9|!Vva@4wQ(@yV6GbaFe(0 zb80Fb6;Tey2KyaBEG10xHP&=Or*_q2DxJGp_LZnS0{WH#3?zbptUYzmg zV~U#if(}4hV5U-tbuyAAVyL1KLy(0e;@lh@U!c!WF2>#|uz&w5^iXGM_Z9Izt>2Hi+EZJ!C}taH;sS@7Zt z&2sr8Ldo{__UGPMrgX3?>%M5@G1B~@@&}*gR812 zm`f{o!8B$oqL$xNQ7k(}w-eQO$gNDw@C=k*#;kQXopZ^Yn_M!U;f>vXqMk}ChoigT zw-mzq^@Di~F$MLI?!8RC!P=;1mXVT`jGR&&Ht%e!w@ND6FlvD5FoeQrF3V0R(dMPB zAa{4Rz+G;raMcSU>v}Yu?gm$v%Frze%IbP0{%czf(K3U>ZUBarJf>WdPM~>!wht%( zSR(hag^I;amE)s2 zU_7!caa-f(-`j&fFUf69tj8ay*D4y6$p3gW+4TeM=8~!?%oH%d-K1(}K(hnsT~*o{ zCGRVTZD7izH!ymXYkMxX1LUU zfe+i*-rG0f1~_Z@JX`&dojZDeGd`@;YnYB${wp6my4R7oV2?S+BGha!DTKs0^lZLS zug0$^P%vo)2*ev&*APzHzYnS3RfFEo|e^7jKV!kqmx&!VEZPk-)RtAH{nU!$0 zj*jgzQ3}OgWf{QJY5|=sgC_djP9Gso=Ec5#i&cGS@ui=veQ&A(D$*y1N18+Vv zmfhX*=}Ft2T|hez8G&$K>*&d2ptB&+wfkSxie)i##VOOz25M4%Tq8DN=0TAb>w46t z1phYK-)ORRJ%O>2iZS7)|J?Vmg1BPUO2be9k^gFGa`ZH`Y0qSK_Ns1om_(uhGBciFk4l{tcb(dnAEyJ2ZRqMC46Oc=m4Y?$g3}o>Yu? zkKk*wX<7N$2CMZqYa5<5ZI2h7;ccasNtM&;0|^@Cs?`Z$7`Bd^BTOZ8zll zDfGePm<%gi#FAbfEexe^#?&X1lrIeC_ZyF1@E^C>xKSjm{diL5bE`Fqq=1P7R z7qyXzm?$bM$f*-R>Q&jS`3BXD8{p+|3ZcW4Bh5}kSu9f>>kLp;96BE+g_r3~ z^vzu8qJ%m@@dj;pM=M+DBNAo`eMpJ{H0#dbLxp#PCkvOzx19m%SW1c{1! z;bI%tYDqoSH`C@mM0``Sfrt|k?Y1{AkMPVX9{^zgReM?QztYJX&RRBZPWJBZ_RbKjN_UKID`Z-P ztZ7Byg*({D%6y>|9hkIkvhlJ1q{$BSAhG@Ldm7Vt`__hmmy+_ZfGwUV8gQ8I6I#vBcZ;Jp3~>GMp7P%T1&t z(L5wXM1l?yc4;VR%Laa%{gfwyj*Oe&`l*(ssJ~-gY8b>z%%DE~t(-6NYkbnUy7h6& z2sfXt;tao+e>#QJ;y2Zi=XPW{x4vcjLCohefAYfi&&0*3?%G*M4(#MMylm<)8)|rn zP#dOv9?0s0-oGz0HdT3BqSrI#BRQ%-pYj`njdtAUaybv=6xI z$l^kLKt2XO<#aan7!CH+v}+Kxc6C@ACJFDA`zK{KRLbgpU@2pZrOd7WLm5MR54(So zMn~QGchVTHVi?P5HYIOQmkSD%vzg#KgbdcHacsQ!X2Tk3MXdS9BBAIMJ}qsmS1n zPVF&$B)LIGO%P2a;~?@hO(QJ=d&j8IxOudw^HULR2XOQaxqHlB-_U?Tr>bGVe0c1U zbllN|Qua8UbxlZ7<%LiMwi?zL=v8M9mJM~xk}@h-f=0purp;A(5HC@D*3*VBs6 z8JHhUrZto9pP4SjN84!6)r8YoS6D=5PY7pDly|betm%o2*%{M6S%oM9hB1FFpON4Vj(t*xj&oqq5d_T5vL9o$+#M+>@K`XhpIY;6r_}K>`kf9llCoD8OK2H%RV8@rFMe7aT z&z)}`R%&&$W~Vi;+>-`hvf-R}IeJ*E&LZtC=%4ip>Oy=e7heVOKmv{NVRELH@eGS9 zns;@5IenXy;K44>lC07bJD>vmu`GR@B6dB(nKa6jEDDoVoT1WL3SyBc^8~N{{7!@v zKGdWYZA1k02JP=flvchYRtffh)jT9d42<}yN=etTO3X+eWi&xCiywLoeBb1I*oBjR zNSQWWd2!kf?YzT?ss@Ev@=uY^`;&~3f(jm>RhfHtp;W(_u0&40rIhFM=8yO}U#(U$ z?%q<-ii^2G1&N{hIU}+pN8MT!U&Rp4O);ic(tlrD6dC#a1gy(zGyV#Gx1+o%VQ|$|lA+R{3Vockf68Yn zp-{WyXkf%N;I3Us0Y_Jt%W6wdIA$BO3r&<-tMoe~4y=~jmk8Bo+NyHBc)*+O0llZ~ zim~s4?{6X^*i&=Pw{g&SA4F?9uRnhAng1nOP}p{m#GRZ#S~(GRk(BDh%&6N_${d}J zc@s|Kvpvb)KNPjinkHCAnlLZkA6-vikZso03VWO%$So@;B;RZfe%+)k9sM{!IRHls zgo7)GM+*QD5dkXnv(>ymh;FB1sYOW$0FYv-^=Cl_UDbqKEWwVJwl>yQ&|iU9oNNJ! zy_>>z3Bm4W#DAi&Ph?OF)|}2Gc_sBnLQdAd!eG9sYGx4(^W8)Mzy%)wK>iO{D;@yw zN8*%salz)xU!hkXwcvWB`B;bs)+7BtpjFsK@ds4b#>2xNV(b10;_37+8&~cTv@gIM zSiB%MMr;&+6=YB}HX2K;->WtCw`aibiTp#Xql#J{J5jS>Lx`0u$w$ic<-Z(slaEb^y2 zWRMot)L$X~|C7nTjr}`I{cS8H>VJ*>PtN+)+|{7|y?y;{?ql>H@8OTlObbMS-Quo> OP727zP7!tNRqH?O{<*~f literal 0 HcmV?d00001 diff --git a/packages/apps/tests/test-data/bridges/OAuthAppsBridge.ts b/packages/apps/tests/test-data/bridges/OAuthAppsBridge.ts new file mode 100644 index 0000000000000..0a514f2752d71 --- /dev/null +++ b/packages/apps/tests/test-data/bridges/OAuthAppsBridge.ts @@ -0,0 +1,29 @@ +import type { IOAuthApp, IOAuthAppParams } from '@rocket.chat/apps-engine/definition/accessors/IOAuthApp'; + +import { OAuthAppsBridge } from '../../../src/server/bridges/OAuthAppsBridge'; + +export class TestOAuthAppsBridge extends OAuthAppsBridge { + protected create(oAuthApp: IOAuthAppParams, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + protected getById(id: string, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + protected getByName(name: string, appId: string): Promise> { + throw new Error('Method not implemented.'); + } + + protected update(oAuthApp: IOAuthAppParams, id: string, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + protected delete(id: string, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + protected purge(appId: string): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/packages/apps/tests/test-data/bridges/activationBridge.ts b/packages/apps/tests/test-data/bridges/activationBridge.ts new file mode 100644 index 0000000000000..c8f61e145dd3e --- /dev/null +++ b/packages/apps/tests/test-data/bridges/activationBridge.ts @@ -0,0 +1,26 @@ +import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; + +import type { ProxiedApp } from '../../../src/server/ProxiedApp'; +import { AppActivationBridge } from '../../../src/server/bridges'; + +export class TestsActivationBridge extends AppActivationBridge { + public async appAdded(app: ProxiedApp): Promise { + console.log(`The App ${app.getName()} (${app.getID()}) has been added.`); + } + + public async appUpdated(app: ProxiedApp): Promise { + console.log(`The App ${app.getName()} (${app.getID()}) has been updated.`); + } + + public async appRemoved(app: ProxiedApp): Promise { + console.log(`The App ${app.getName()} (${app.getID()}) has been removed.`); + } + + public async appStatusChanged(app: ProxiedApp, status: AppStatus): Promise { + console.log(`The App ${app.getName()} (${app.getID()}) status has changed to: ${status}`); + } + + protected async actionsChanged(): Promise { + console.log('The actions have changed.'); + } +} diff --git a/packages/apps/tests/test-data/bridges/apiBridge.ts b/packages/apps/tests/test-data/bridges/apiBridge.ts new file mode 100644 index 0000000000000..74504625b710c --- /dev/null +++ b/packages/apps/tests/test-data/bridges/apiBridge.ts @@ -0,0 +1,38 @@ +import type { IApi } from '@rocket.chat/apps-engine/definition/api'; + +import { ApiBridge } from '../../../src/server/bridges'; +import type { AppApi } from '../../../src/server/managers/AppApi'; +import { TestData } from '../utilities'; + +export class TestsApiBridge extends ApiBridge { + public apis: Map>; + + constructor() { + super(); + this.apis = new Map>(); + this.apis.set('appId', new Map()); + this.apis.get('appId').set('it-exists', TestData.getApi('it-exists')); + } + + public async registerApi(api: AppApi, appId: string): Promise { + if (!this.apis.has(appId)) { + this.apis.set(appId, new Map()); + } + + if (this.apis.get(appId)) { + api.api.endpoints.forEach((endpoint) => { + if (this.apis.get(appId).has(endpoint.path)) { + throw new Error(`Api "${api.endpoint.path}" has already been registered for app ${appId}.`); + } + }); + + api.api.endpoints.forEach((endpoint) => { + this.apis.get(appId).set(api.endpoint.path, api.api); + }); + } + } + + public async unregisterApis(appId: string): Promise { + this.apis.delete(appId); + } +} diff --git a/packages/apps/tests/test-data/bridges/appBridges.ts b/packages/apps/tests/test-data/bridges/appBridges.ts new file mode 100644 index 0000000000000..b2a35e6aac804 --- /dev/null +++ b/packages/apps/tests/test-data/bridges/appBridges.ts @@ -0,0 +1,255 @@ +import { TestOAuthAppsBridge } from './OAuthAppsBridge'; +import { TestsActivationBridge } from './activationBridge'; +import { TestsApiBridge } from './apiBridge'; +import { TestsAppDetailChangesBridge } from './appDetailChanges'; +import { TestAppCloudWorkspaceBridge } from './cloudBridge'; +import { TestsCommandBridge } from './commandBridge'; +import { TestContactBridge } from './contactBridge'; +import { TestsEmailBridge } from './emailBridge'; +import { TestsEnvironmentalVariableBridge } from './environmentalVariableBridge'; +import { TestExperimentalBridge } from './experimentalBridge'; +import { TestsHttpBridge } from './httpBridge'; +import { TestsInternalBridge } from './internalBridge'; +import { TestsInternalFederationBridge } from './internalFederationBridge'; +import { TestLivechatBridge } from './livechatBridge'; +import { TestsMessageBridge } from './messageBridge'; +import { TestsModerationBridge } from './moderationBridge'; +import { TestOutboundCommunicationBridge } from './outboundComms'; +import { TestsPersisBridge } from './persisBridge'; +import { TestsRoleBridge } from './roleBridge'; +import { TestsRoomBridge } from './roomBridge'; +import { TestSchedulerBridge } from './schedulerBridge'; +import { TestsServerSettingBridge } from './serverSettingBridge'; +import { TestsThreadBridge } from './threadBridge'; +import { TestsUiIntegrationBridge } from './uiIntegrationBridge'; +import { TestUploadBridge } from './uploadBridge'; +import { TestsUserBridge } from './userBridge'; +import { TestsVideoConferenceBridge } from './videoConferenceBridge'; +import { AppBridges } from '../../../src/server/bridges'; +import type { + AppActivationBridge, + AppDetailChangesBridge, + ContactBridge, + EnvironmentalVariableBridge, + ExperimentalBridge, + HttpBridge, + IInternalBridge, + IListenerBridge, + LivechatBridge, + MessageBridge, + ModerationBridge, + OutboundMessageBridge, + PersistenceBridge, + RoleBridge, + RoomBridge, + SchedulerBridge, + ServerSettingBridge, + UiInteractionBridge, + UploadBridge, + UserBridge, + VideoConferenceBridge, +} from '../../../src/server/bridges'; +import type { CloudWorkspaceBridge } from '../../../src/server/bridges/CloudWorkspaceBridge'; +import type { EmailBridge } from '../../../src/server/bridges/EmailBridge'; +import type { IInternalFederationBridge } from '../../../src/server/bridges/IInternalFederationBridge'; +import type { OAuthAppsBridge } from '../../../src/server/bridges/OAuthAppsBridge'; +import type { ThreadBridge } from '../../../src/server/bridges/ThreadBridge'; + +export class TestsAppBridges extends AppBridges { + private readonly appDetails: TestsAppDetailChangesBridge; + + private readonly cmdBridge: TestsCommandBridge; + + private readonly apiBridge: TestsApiBridge; + + private readonly setsBridge: TestsServerSettingBridge; + + private readonly envBridge: TestsEnvironmentalVariableBridge; + + private readonly rlActBridge: TestsActivationBridge; + + private readonly msgBridge: TestsMessageBridge; + + private readonly moderationBridge: TestsModerationBridge; + + private readonly persisBridge: TestsPersisBridge; + + private readonly roleBridge: TestsRoleBridge; + + private readonly roomBridge: TestsRoomBridge; + + private readonly internalBridge: TestsInternalBridge; + + private readonly userBridge: TestsUserBridge; + + private readonly httpBridge: TestsHttpBridge; + + private readonly livechatBridge: TestLivechatBridge; + + private readonly uploadBridge: TestUploadBridge; + + private readonly emailBridge: EmailBridge; + + private readonly contactBridge: ContactBridge; + + private readonly uiIntegrationBridge: TestsUiIntegrationBridge; + + private readonly schedulerBridge: TestSchedulerBridge; + + private readonly cloudWorkspaceBridge: TestAppCloudWorkspaceBridge; + + private readonly videoConfBridge: TestsVideoConferenceBridge; + + private readonly oauthBridge: OAuthAppsBridge; + + private readonly internalFederationBridge: IInternalFederationBridge; + + private readonly threadBridge: ThreadBridge; + + private readonly outboundCommsBridge: TestOutboundCommunicationBridge; + + private readonly experimentalBridge: TestExperimentalBridge; + + constructor() { + super(); + this.appDetails = new TestsAppDetailChangesBridge(); + this.cmdBridge = new TestsCommandBridge(); + this.apiBridge = new TestsApiBridge(); + this.setsBridge = new TestsServerSettingBridge(); + this.envBridge = new TestsEnvironmentalVariableBridge(); + this.rlActBridge = new TestsActivationBridge(); + this.msgBridge = new TestsMessageBridge(); + this.moderationBridge = new TestsModerationBridge(); + this.persisBridge = new TestsPersisBridge(); + this.roleBridge = new TestsRoleBridge(); + this.roomBridge = new TestsRoomBridge(); + this.internalBridge = new TestsInternalBridge(); + this.userBridge = new TestsUserBridge(); + this.httpBridge = new TestsHttpBridge(); + this.livechatBridge = new TestLivechatBridge(); + this.uploadBridge = new TestUploadBridge(); + this.uiIntegrationBridge = new TestsUiIntegrationBridge(); + this.schedulerBridge = new TestSchedulerBridge(); + this.cloudWorkspaceBridge = new TestAppCloudWorkspaceBridge(); + this.videoConfBridge = new TestsVideoConferenceBridge(); + this.oauthBridge = new TestOAuthAppsBridge(); + this.internalFederationBridge = new TestsInternalFederationBridge(); + this.threadBridge = new TestsThreadBridge(); + this.emailBridge = new TestsEmailBridge(); + this.contactBridge = new TestContactBridge(); + this.outboundCommsBridge = new TestOutboundCommunicationBridge(); + this.experimentalBridge = new TestExperimentalBridge(); + } + + public getCommandBridge(): TestsCommandBridge { + return this.cmdBridge; + } + + public getApiBridge(): TestsApiBridge { + return this.apiBridge; + } + + public getServerSettingBridge(): ServerSettingBridge { + return this.setsBridge; + } + + public getEnvironmentalVariableBridge(): EnvironmentalVariableBridge { + return this.envBridge; + } + + public getAppDetailChangesBridge(): AppDetailChangesBridge { + return this.appDetails; + } + + public getHttpBridge(): HttpBridge { + return this.httpBridge; + } + + public getListenerBridge(): IListenerBridge { + throw new Error('Method not implemented.'); + } + + public getMessageBridge(): MessageBridge { + return this.msgBridge; + } + + public getModerationBridge(): ModerationBridge { + return this.moderationBridge; + } + + public getPersistenceBridge(): PersistenceBridge { + return this.persisBridge; + } + + public getAppActivationBridge(): AppActivationBridge { + return this.rlActBridge; + } + + public getThreadBridge(): ThreadBridge { + return this.threadBridge; + } + + public getRoleBridge(): RoleBridge { + return this.roleBridge; + } + + public getRoomBridge(): RoomBridge { + return this.roomBridge; + } + + public getInternalBridge(): IInternalBridge { + return this.internalBridge; + } + + public getUserBridge(): UserBridge { + return this.userBridge; + } + + public getLivechatBridge(): LivechatBridge { + return this.livechatBridge; + } + + public getEmailBridge(): EmailBridge { + return this.emailBridge; + } + + public getUploadBridge(): UploadBridge { + return this.uploadBridge; + } + + public getUiInteractionBridge(): UiInteractionBridge { + return this.uiIntegrationBridge; + } + + public getSchedulerBridge(): SchedulerBridge { + return this.schedulerBridge; + } + + public getCloudWorkspaceBridge(): CloudWorkspaceBridge { + return this.cloudWorkspaceBridge; + } + + public getVideoConferenceBridge(): VideoConferenceBridge { + return this.videoConfBridge; + } + + public getOAuthAppsBridge(): OAuthAppsBridge { + return this.oauthBridge; + } + + public getInternalFederationBridge(): IInternalFederationBridge { + return this.internalFederationBridge; + } + + public getContactBridge(): ContactBridge { + return this.contactBridge; + } + + public getOutboundMessageBridge(): OutboundMessageBridge { + return this.outboundCommsBridge; + } + + public getExperimentalBridge(): ExperimentalBridge { + return this.experimentalBridge; + } +} diff --git a/packages/apps/tests/test-data/bridges/appDetailChanges.ts b/packages/apps/tests/test-data/bridges/appDetailChanges.ts new file mode 100644 index 0000000000000..b091306c83f50 --- /dev/null +++ b/packages/apps/tests/test-data/bridges/appDetailChanges.ts @@ -0,0 +1,7 @@ +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; + +import { AppDetailChangesBridge } from '../../../src/server/bridges'; + +export class TestsAppDetailChangesBridge extends AppDetailChangesBridge { + public onAppSettingsChange(appId: string, setting: ISetting): void {} +} diff --git a/packages/apps/tests/test-data/bridges/cloudBridge.ts b/packages/apps/tests/test-data/bridges/cloudBridge.ts new file mode 100644 index 0000000000000..43882f3550064 --- /dev/null +++ b/packages/apps/tests/test-data/bridges/cloudBridge.ts @@ -0,0 +1,16 @@ +import type { IWorkspaceToken } from '@rocket.chat/apps-engine/definition/cloud/IWorkspaceToken'; + +import { CloudWorkspaceBridge } from '../../../src/server/bridges/CloudWorkspaceBridge'; + +export class TestAppCloudWorkspaceBridge extends CloudWorkspaceBridge { + constructor() { + super(); + } + + public async getWorkspaceToken(scope: string, appId: string): Promise { + return { + token: 'mock-workspace-token', + expiresAt: new Date(Date.now() + 10000), + }; + } +} diff --git a/packages/apps/tests/test-data/bridges/commandBridge.ts b/packages/apps/tests/test-data/bridges/commandBridge.ts new file mode 100644 index 0000000000000..c2169c734d60c --- /dev/null +++ b/packages/apps/tests/test-data/bridges/commandBridge.ts @@ -0,0 +1,42 @@ +import type { IHttp, IModify, IPersistence, IRead } from '@rocket.chat/apps-engine/definition/accessors'; +import type { ISlashCommand, SlashCommandContext } from '@rocket.chat/apps-engine/definition/slashcommands'; + +import { CommandBridge } from '../../../src/server/bridges'; +import { TestData } from '../utilities'; + +export class TestsCommandBridge extends CommandBridge { + public commands: Map void>; + + constructor() { + super(); + this.commands = new Map< + string, + (context: SlashCommandContext, read: IRead, modify: IModify, http: IHttp, persis: IPersistence) => void + >(); + this.commands.set('it-exists', TestData.getSlashCommand('it-exists').executor); + } + + public async doesCommandExist(command: string, appId: string): Promise { + return this.commands.has(command); + } + + public async enableCommand(command: string, appId: string): Promise {} + + public async disableCommand(command: string, appId: string): Promise {} + + public async modifyCommand(command: ISlashCommand, appId: string): Promise {} + + public restoreCommand(comand: string, appId: string): void {} + + public async registerCommand(command: ISlashCommand, appId: string): Promise { + if (this.commands.has(command.command)) { + throw new Error(`Command "${command.command}" has already been registered.`); + } + + this.commands.set(command.command, command.executor); + } + + public async unregisterCommand(command: string, appId: string): Promise { + this.commands.delete(command); + } +} diff --git a/packages/apps/tests/test-data/bridges/contactBridge.ts b/packages/apps/tests/test-data/bridges/contactBridge.ts new file mode 100644 index 0000000000000..b21a539aea9e9 --- /dev/null +++ b/packages/apps/tests/test-data/bridges/contactBridge.ts @@ -0,0 +1,23 @@ +import type { ILivechatContact } from '@rocket.chat/apps-engine/definition/livechat'; + +import { ContactBridge } from '../../../src/server/bridges'; + +export class TestContactBridge extends ContactBridge { + protected addContactEmail(contactId: ILivechatContact['_id'], email: string, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + protected getById(id: ILivechatContact['_id']): Promise { + throw new Error('Method not implemented.'); + } + + protected verifyContact(verifyContactChannelParams: { + contactId: string; + field: string; + value: string; + visitorId: string; + roomId: string; + }): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/packages/apps/tests/test-data/bridges/emailBridge.ts b/packages/apps/tests/test-data/bridges/emailBridge.ts new file mode 100644 index 0000000000000..31245d5d2d335 --- /dev/null +++ b/packages/apps/tests/test-data/bridges/emailBridge.ts @@ -0,0 +1,9 @@ +import type { IEmail } from '@rocket.chat/apps-engine/definition/email'; + +import { EmailBridge } from '../../../src/server/bridges/EmailBridge'; + +export class TestsEmailBridge extends EmailBridge { + protected sendEmail(email: IEmail, appId: string): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/packages/apps/tests/test-data/bridges/environmentalVariableBridge.ts b/packages/apps/tests/test-data/bridges/environmentalVariableBridge.ts new file mode 100644 index 0000000000000..9bf9cdc174d8b --- /dev/null +++ b/packages/apps/tests/test-data/bridges/environmentalVariableBridge.ts @@ -0,0 +1,15 @@ +import { EnvironmentalVariableBridge } from '../../../src/server/bridges/EnvironmentalVariableBridge'; + +export class TestsEnvironmentalVariableBridge extends EnvironmentalVariableBridge { + public getValueByName(envVarName: string, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public isReadable(envVarName: string, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public isSet(envVarName: string, appId: string): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/packages/apps/tests/test-data/bridges/experimentalBridge.ts b/packages/apps/tests/test-data/bridges/experimentalBridge.ts new file mode 100644 index 0000000000000..c30da179c5c98 --- /dev/null +++ b/packages/apps/tests/test-data/bridges/experimentalBridge.ts @@ -0,0 +1,3 @@ +import { ExperimentalBridge } from '../../../src/server/bridges'; + +export class TestExperimentalBridge extends ExperimentalBridge {} diff --git a/packages/apps/tests/test-data/bridges/httpBridge.ts b/packages/apps/tests/test-data/bridges/httpBridge.ts new file mode 100644 index 0000000000000..cdc5aee6d9e4e --- /dev/null +++ b/packages/apps/tests/test-data/bridges/httpBridge.ts @@ -0,0 +1,16 @@ +import type { IHttpResponse } from '@rocket.chat/apps-engine/definition/accessors'; + +import type { IHttpBridgeRequestInfo } from '../../../src/server/bridges'; +import { HttpBridge } from '../../../src/server/bridges'; + +export class TestsHttpBridge extends HttpBridge { + public call(info: IHttpBridgeRequestInfo): Promise { + return Promise.resolve({ + url: info.url, + method: info.method, + statusCode: 200, + headers: info.request.headers, + content: info.request.content, + }); + } +} diff --git a/packages/apps/tests/test-data/bridges/internalBridge.ts b/packages/apps/tests/test-data/bridges/internalBridge.ts new file mode 100644 index 0000000000000..ac8dba6df92fa --- /dev/null +++ b/packages/apps/tests/test-data/bridges/internalBridge.ts @@ -0,0 +1,17 @@ +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; + +import type { IInternalBridge } from '../../../src/server/bridges'; + +export class TestsInternalBridge implements IInternalBridge { + public doGetUsernamesOfRoomByIdSync(roomId: string): Array { + throw new Error('Method not implemented.'); + } + + public doGetUsernamesOfRoomById(roomId: string): Promise> { + throw new Error('Method not implemented.'); + } + + public doGetWorkspacePublicKey(): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/packages/apps/tests/test-data/bridges/internalFederationBridge.ts b/packages/apps/tests/test-data/bridges/internalFederationBridge.ts new file mode 100644 index 0000000000000..85bddaa2a8061 --- /dev/null +++ b/packages/apps/tests/test-data/bridges/internalFederationBridge.ts @@ -0,0 +1,11 @@ +import type { IInternalFederationBridge } from '../../../src/server/bridges'; + +export class TestsInternalFederationBridge implements IInternalFederationBridge { + public async getPrivateKey(): Promise { + return 'fake_private-key'; + } + + public async getPublicKey(): Promise { + return 'fake_public-key'; + } +} diff --git a/packages/apps/tests/test-data/bridges/livechatBridge.ts b/packages/apps/tests/test-data/bridges/livechatBridge.ts new file mode 100644 index 0000000000000..7f3b7879295e0 --- /dev/null +++ b/packages/apps/tests/test-data/bridges/livechatBridge.ts @@ -0,0 +1,124 @@ +import type { IExtraRoomParams } from '@rocket.chat/apps-engine/definition/accessors/ILivechatCreator'; +import type { + IDepartment, + IVisitorExternalIdentifier, + ILivechatMessage, + ILivechatRoom, + ILivechatTransferData, + IVisitor, + ResolveVisitorContactData, +} from '@rocket.chat/apps-engine/definition/livechat'; +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import { LivechatBridge } from '../../../src/server/bridges/LivechatBridge'; + +export class TestLivechatBridge extends LivechatBridge { + public findDepartmentsEnabledWithAgents(appId: string): Promise> { + throw new Error('Method not implemented.'); + } + + public isOnline(departmentId?: string): boolean { + throw new Error('Method not implemented'); + } + + public isOnlineAsync(departmentId?: string): Promise { + throw new Error('Method not implemented'); + } + + public createMessage(message: ILivechatMessage, appId: string): Promise { + throw new Error('Method not implemented'); + } + + public getMessageById(messageId: string, appId: string): Promise { + throw new Error('Method not implemented'); + } + + public updateMessage(message: ILivechatMessage, appId: string): Promise { + throw new Error('Method not implemented'); + } + + public createVisitor(visitor: IVisitor, appId: string): Promise { + throw new Error('Method not implemented'); + } + + public createAndReturnVisitor(visitor: IVisitor, appId: string): Promise { + throw new Error('Method not implemented'); + } + + public transferVisitor(visitor: IVisitor, transferData: ILivechatTransferData, appId: string): Promise { + throw new Error('Method not implemented'); + } + + public findVisitors(query: object, appId: string): Promise> { + console.warn('The method AppLivechatBridge.findVisitors is deprecated. Please consider using its alternatives'); + throw new Error('Method not implemented'); + } + + public findVisitorById(id: string, appId: string): Promise { + throw new Error('Method not implemented'); + } + + public findVisitorByEmail(email: string, appId: string): Promise { + throw new Error('Method not implemented'); + } + + public findVisitorByToken(token: string, appId: string): Promise { + throw new Error('Method not implemented'); + } + + public findVisitorByPhoneNumber(phoneNumber: string, appId: string): Promise { + throw new Error('Method not implemented'); + } + + public resolveVisitor( + externalId: IVisitorExternalIdentifier, + contactData: ResolveVisitorContactData | undefined, + appId: string, + ): Promise { + throw new Error('Method not implemented'); + } + + public updateVisitorExternalId( + visitorId: string, + externalId: Omit, + appId: string, + ): Promise { + throw new Error('Method not implemented'); + } + + public createRoom(visitor: IVisitor, agent: IUser, appId: string, extraParams?: IExtraRoomParams): Promise { + throw new Error('Method not implemented'); + } + + public closeRoom(room: ILivechatRoom, comment: string, closer: IUser | undefined, appId: string): Promise { + throw new Error('Method not implemented'); + } + + public findRooms(visitor: IVisitor, departmentId: string | null, appId: string): Promise> { + throw new Error('Method not implemented'); + } + + public findOpenRoomsByAgentId(agentId: string, appId: string): Promise { + throw new Error('Method not implemented'); + } + + public countOpenRoomsByAgentId(agentId: string, appId: string): Promise { + throw new Error('Method not implemented'); + } + + public findDepartmentByIdOrName(value: string, appId: string): Promise { + throw new Error('Method not implemented'); + } + + public _fetchLivechatRoomMessages(appId: string, roomId: string): Promise> { + throw new Error('Method not implemented'); + } + + public setCustomFields( + data: { token: IVisitor['token']; key: string; value: string; overwrite: boolean }, + appId: string, + ): Promise { + throw new Error('Method not implemented'); + } +} diff --git a/packages/apps/tests/test-data/bridges/messageBridge.ts b/packages/apps/tests/test-data/bridges/messageBridge.ts new file mode 100644 index 0000000000000..e3aa26a98a98c --- /dev/null +++ b/packages/apps/tests/test-data/bridges/messageBridge.ts @@ -0,0 +1,44 @@ +import type { IMessage, Reaction } from '@rocket.chat/apps-engine/definition/messages'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import { MessageBridge } from '../../../src/server/bridges'; +import type { ITypingDescriptor } from '../../../src/server/bridges/MessageBridge'; + +export class TestsMessageBridge extends MessageBridge { + public create(message: IMessage, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public getById(messageId: string, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public update(message: IMessage, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public notifyUser(user: IUser, message: IMessage, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public notifyRoom(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.'); + } + + public typing(options: ITypingDescriptor): Promise { + throw new Error('Method not implemented.'); + } + + public addReaction(_messageId: string, _userId: string, _reaction: Reaction): Promise { + throw new Error('Method not implemented.'); + } + + public removeReaction(_messageId: string, _userId: string, _reaction: Reaction): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/packages/apps/tests/test-data/bridges/moderationBridge.ts b/packages/apps/tests/test-data/bridges/moderationBridge.ts new file mode 100644 index 0000000000000..b03398f008293 --- /dev/null +++ b/packages/apps/tests/test-data/bridges/moderationBridge.ts @@ -0,0 +1,18 @@ +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import { ModerationBridge } from '../../../src/server/bridges'; + +export class TestsModerationBridge extends ModerationBridge { + public report(messageId: string, description: string, userId: string, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public dismissReportsByMessageId(messageId: IMessage['id'], reason: string, action: string, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public dismissReportsByUserId(userId: IUser['id'], reason: string, action: string, appId: string): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/packages/apps/tests/test-data/bridges/outboundComms.ts b/packages/apps/tests/test-data/bridges/outboundComms.ts new file mode 100644 index 0000000000000..902bc9fa56bf9 --- /dev/null +++ b/packages/apps/tests/test-data/bridges/outboundComms.ts @@ -0,0 +1,21 @@ +import type { + IOutboundEmailMessageProvider, + IOutboundMessageProviders, + IOutboundPhoneMessageProvider, +} from '@rocket.chat/apps-engine/definition/outboundCommunication'; + +import { OutboundMessageBridge } from '../../../src/server/bridges'; + +export class TestOutboundCommunicationBridge extends OutboundMessageBridge { + protected async registerPhoneProvider(provider: IOutboundPhoneMessageProvider, appId: string): Promise { + return Promise.resolve(); + } + + protected async registerEmailProvider(provider: IOutboundEmailMessageProvider, appId: string): Promise { + return Promise.resolve(); + } + + protected async unRegisterProvider(provider: IOutboundMessageProviders, appId: string): Promise { + return Promise.resolve(); + } +} diff --git a/packages/apps/tests/test-data/bridges/persisBridge.ts b/packages/apps/tests/test-data/bridges/persisBridge.ts new file mode 100644 index 0000000000000..94fc77bf02628 --- /dev/null +++ b/packages/apps/tests/test-data/bridges/persisBridge.ts @@ -0,0 +1,46 @@ +import type { RocketChatAssociationRecord } from '@rocket.chat/apps-engine/definition/metadata'; + +import { PersistenceBridge } from '../../../src/server/bridges'; + +export class TestsPersisBridge extends PersistenceBridge { + public purge(appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public create(data: any, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public createWithAssociations(data: object, associations: Array, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public readById(id: string, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public readByAssociations(associations: Array, appId: string): Promise> { + throw new Error('Method not implemented.'); + } + + public remove(id: string, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public removeByAssociations(associations: Array, appId: string): Promise> { + throw new Error('Method not implemented.'); + } + + public update(id: string, data: object, upsert: boolean, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public updateByAssociations( + associations: Array, + data: object, + upsert: boolean, + appId: string, + ): Promise { + throw new Error('Method not implemented'); + } +} diff --git a/packages/apps/tests/test-data/bridges/roleBridge.ts b/packages/apps/tests/test-data/bridges/roleBridge.ts new file mode 100644 index 0000000000000..bd5f16dce413a --- /dev/null +++ b/packages/apps/tests/test-data/bridges/roleBridge.ts @@ -0,0 +1,13 @@ +import type { IRole } from '@rocket.chat/apps-engine/definition/roles'; + +import { RoleBridge } from '../../../src/server/bridges'; + +export class TestsRoleBridge extends RoleBridge { + public getOneByIdOrName(idOrName: IRole['id'] | IRole['name'], appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public getCustomRoles(appId: string): Promise> { + throw new Error('Method not implemented.'); + } +} diff --git a/packages/apps/tests/test-data/bridges/roomBridge.ts b/packages/apps/tests/test-data/bridges/roomBridge.ts new file mode 100644 index 0000000000000..cc54efef30a60 --- /dev/null +++ b/packages/apps/tests/test-data/bridges/roomBridge.ts @@ -0,0 +1,81 @@ +import type { IMessage, IMessageRaw } from '@rocket.chat/apps-engine/definition/messages'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import type { IRoomRaw } from '@rocket.chat/apps-engine/definition/rooms/IRoomRaw'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import { RoomBridge } from '../../../src/server/bridges'; +import type { GetMessagesOptions, GetRoomsOptions, GetRoomsFilters } from '../../../src/server/bridges/RoomBridge'; + +export class TestsRoomBridge extends RoomBridge { + public create(room: IRoom, members: Array, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public getById(roomId: string, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public getCreatorById(roomId: string, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public getByName(roomName: string, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public getCreatorByName(roomName: string, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public getDirectByUsernames(username: Array, appId: string): Promise { + throw new Error('Method not implemented'); + } + + public getMembers(roomName: string, appId: string): Promise> { + throw new Error('Method not implemented.'); + } + + public getAllRooms(filter: GetRoomsFilters, options: GetRoomsOptions, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public getMessages(roomId: string, options: GetMessagesOptions, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public update(room: IRoom, members: Array, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public createDiscussion(room: IRoom, parentMessage: IMessage, reply: string, members: Array, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public delete(roomId: string, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public getLeaders(roomId: string, appId: string): Promise> { + throw new Error('Method not implemented.'); + } + + public getModerators(roomId: string, appId: string): Promise> { + throw new Error('Method not implemented.'); + } + + public getOwners(roomId: string, appId: string): Promise> { + throw new Error('Method not implemented.'); + } + + public removeUsers(roomId: string, usernames: string[], appId: string): Promise { + throw new Error('Method not implemented'); + } + + public getUnreadByUser(roomId: string, uid: string, options: GetMessagesOptions, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + protected getUserUnreadMessageCount(roomId: string, uid: string, appId: string): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/packages/apps/tests/test-data/bridges/schedulerBridge.ts b/packages/apps/tests/test-data/bridges/schedulerBridge.ts new file mode 100644 index 0000000000000..e891e367c31ce --- /dev/null +++ b/packages/apps/tests/test-data/bridges/schedulerBridge.ts @@ -0,0 +1,25 @@ +import type { IOnetimeSchedule, IProcessor, IRecurringSchedule } from '@rocket.chat/apps-engine/definition/scheduler'; + +import { SchedulerBridge } from '../../../src/server/bridges'; + +export class TestSchedulerBridge extends SchedulerBridge { + public async registerProcessors(processors: Array, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public async scheduleOnce(job: IOnetimeSchedule, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public async scheduleRecurring(job: IRecurringSchedule, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public async cancelJob(jobId: string, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public async cancelAllJobs(appId: string): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/packages/apps/tests/test-data/bridges/serverSettingBridge.ts b/packages/apps/tests/test-data/bridges/serverSettingBridge.ts new file mode 100644 index 0000000000000..ed071a25bbbea --- /dev/null +++ b/packages/apps/tests/test-data/bridges/serverSettingBridge.ts @@ -0,0 +1,42 @@ +import { SettingType, type ISetting } from '@rocket.chat/apps-engine/definition/settings'; + +import { ServerSettingBridge } from '../../../src/server/bridges'; + +export class TestsServerSettingBridge extends ServerSettingBridge { + public getAll(appId: string): Promise> { + throw new Error('Method not implemented.'); + } + + public getOneById(id: string, appId: string): Promise { + return Promise.resolve({ + id, + packageValue: 'packageValue', + value: 'value', + i18nLabel: 'i18nLabel', + i18nDescription: 'i18nDescription', + required: true, + public: true, + type: SettingType.STRING, + }); + } + + public hideGroup(name: string): Promise { + throw new Error('Method not implemented.'); + } + + public hideSetting(id: string): Promise { + throw new Error('Method not implemented.'); + } + + public isReadableById(id: string, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public updateOne(setting: ISetting, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public incrementValue(id: ISetting['id'], value: number, appId: string): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/packages/apps/tests/test-data/bridges/threadBridge.ts b/packages/apps/tests/test-data/bridges/threadBridge.ts new file mode 100644 index 0000000000000..b629c305a6f6a --- /dev/null +++ b/packages/apps/tests/test-data/bridges/threadBridge.ts @@ -0,0 +1,9 @@ +import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; + +import { ThreadBridge } from '../../../src/server/bridges/ThreadBridge'; + +export class TestsThreadBridge extends ThreadBridge { + public getById(messageId: string, appId: string): Promise> { + throw new Error('Method not implemented.'); + } +} diff --git a/packages/apps/tests/test-data/bridges/uiIntegrationBridge.ts b/packages/apps/tests/test-data/bridges/uiIntegrationBridge.ts new file mode 100644 index 0000000000000..40b522bc65290 --- /dev/null +++ b/packages/apps/tests/test-data/bridges/uiIntegrationBridge.ts @@ -0,0 +1,10 @@ +import type { IUIKitInteractionParam } from '@rocket.chat/apps-engine/definition/accessors/IUIController'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; + +import { UiInteractionBridge } from '../../../src/server/bridges'; + +export class TestsUiIntegrationBridge extends UiInteractionBridge { + public async notifyUser(user: IUser, interaction: IUIKitInteractionParam, appId: string) { + throw new Error('Method not implemented.'); + } +} diff --git a/packages/apps/tests/test-data/bridges/uploadBridge.ts b/packages/apps/tests/test-data/bridges/uploadBridge.ts new file mode 100644 index 0000000000000..d15221897d940 --- /dev/null +++ b/packages/apps/tests/test-data/bridges/uploadBridge.ts @@ -0,0 +1,18 @@ +import type { IUpload } from '@rocket.chat/apps-engine/definition/uploads'; +import type { IUploadDetails } from '@rocket.chat/apps-engine/definition/uploads/IUploadDetails'; + +import { UploadBridge } from '../../../src/server/bridges/UploadBridge'; + +export class TestUploadBridge extends UploadBridge { + public getById(id: string, appId: string): Promise { + throw new Error('Method not implemented'); + } + + public getBuffer(upload: IUpload, appId: string): Promise { + throw new Error('Method not implemented'); + } + + public createUpload(details: IUploadDetails, buffer: Buffer, appId: string): Promise { + throw new Error('Method not implemented'); + } +} diff --git a/packages/apps/tests/test-data/bridges/userBridge.ts b/packages/apps/tests/test-data/bridges/userBridge.ts new file mode 100644 index 0000000000000..071b80f488e1f --- /dev/null +++ b/packages/apps/tests/test-data/bridges/userBridge.ts @@ -0,0 +1,49 @@ +import type { IUser, UserType } from '@rocket.chat/apps-engine/definition/users'; + +import { UserBridge } from '../../../src/server/bridges'; + +export class TestsUserBridge extends UserBridge { + public getById(id: string, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public getByUsername(username: string, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public create(user: Partial): Promise { + throw new Error('Method not implemented'); + } + + public getActiveUserCount(): Promise { + throw new Error('Method not implemented.'); + } + + public remove(user: IUser, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public getAppUser(appId?: string): Promise { + throw new Error('Method not implemented.'); + } + + public async update(user: IUser, updates: Partial, appId: string): Promise { + throw new Error('Method not implemented'); + } + + public async deleteUsersCreatedByApp(appId: string, type: UserType.BOT): Promise { + throw new Error('Method not implemented'); + } + + protected getUserUnreadMessageCount(uid: string, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + protected getUserRoomIds(userId: string, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + protected deactivate(userId: IUser['id'], confirmRelinquish: boolean, appId: string): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/packages/apps/tests/test-data/bridges/videoConferenceBridge.ts b/packages/apps/tests/test-data/bridges/videoConferenceBridge.ts new file mode 100644 index 0000000000000..b8b34c70a9b8b --- /dev/null +++ b/packages/apps/tests/test-data/bridges/videoConferenceBridge.ts @@ -0,0 +1,26 @@ +import type { IVideoConfProvider } from '@rocket.chat/apps-engine/definition/videoConfProviders'; +import type { VideoConference, AppVideoConference } from '@rocket.chat/apps-engine/definition/videoConferences'; + +import { VideoConferenceBridge } from '../../../src/server/bridges'; + +export class TestsVideoConferenceBridge extends VideoConferenceBridge { + public getById(callId: string, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + public create(call: AppVideoConference, appId: string): Promise { + throw new Error('Method not implemented'); + } + + public update(call: VideoConference, appId: string): Promise { + throw new Error('Method not implemented.'); + } + + protected registerProvider(info: IVideoConfProvider, appId: string): Promise { + return Promise.resolve(); + } + + protected unRegisterProvider(info: IVideoConfProvider, appId: string): Promise { + return Promise.resolve(); + } +} diff --git a/packages/apps/tests/test-data/misc/fake-library-file.d.ts b/packages/apps/tests/test-data/misc/fake-library-file.d.ts new file mode 100644 index 0000000000000..9dd1bbc2a503f --- /dev/null +++ b/packages/apps/tests/test-data/misc/fake-library-file.d.ts @@ -0,0 +1,5 @@ +export declare class FakeLibraryClass { + startWork(): boolean; + + stopWorking(): boolean; +} diff --git a/packages/apps/tests/test-data/storage/TestSourceStorage.ts b/packages/apps/tests/test-data/storage/TestSourceStorage.ts new file mode 100644 index 0000000000000..f2b3e9768b269 --- /dev/null +++ b/packages/apps/tests/test-data/storage/TestSourceStorage.ts @@ -0,0 +1,20 @@ +import type { IAppStorageItem } from '../../../src/server/storage'; +import { AppSourceStorage } from '../../../src/server/storage'; + +export class TestSourceStorage extends AppSourceStorage { + public async store(item: IAppStorageItem, zip: Buffer): Promise { + return 'app_package_path'; + } + + public async fetch(item: IAppStorageItem): Promise { + return Buffer.from('buffer'); + } + + public async update(item: IAppStorageItem, zip: Buffer): Promise { + return 'updated_path'; + } + + public async remove(item: IAppStorageItem): Promise { + // yup + } +} diff --git a/packages/apps/tests/test-data/storage/logStorage.ts b/packages/apps/tests/test-data/storage/logStorage.ts new file mode 100644 index 0000000000000..5841d02385c44 --- /dev/null +++ b/packages/apps/tests/test-data/storage/logStorage.ts @@ -0,0 +1,28 @@ +import type { ILoggerStorageEntry } from '../../../src/server/logging'; +import type { IAppLogStorageFindOptions } from '../../../src/server/storage'; +import { AppLogStorage } from '../../../src/server/storage'; + +export class TestsAppLogStorage extends AppLogStorage { + constructor() { + super('nothing'); + } + + public findPaginated( + query: { [field: string]: any }, + options?: IAppLogStorageFindOptions, + ): Promise<{ logs: ILoggerStorageEntry[]; total: number }> { + return Promise.resolve({ logs: [], total: 0 }); + } + + public storeEntries(logEntry: ILoggerStorageEntry): Promise { + return Promise.resolve({} as ILoggerStorageEntry); + } + + public getEntriesFor(appId: string): Promise> { + return Promise.resolve([]); + } + + public removeEntriesFor(appId: string): Promise { + return Promise.resolve(); + } +} diff --git a/packages/apps/tests/test-data/storage/storage.ts b/packages/apps/tests/test-data/storage/storage.ts new file mode 100644 index 0000000000000..1e95f5372e41d --- /dev/null +++ b/packages/apps/tests/test-data/storage/storage.ts @@ -0,0 +1,133 @@ +import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; +import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; + +import type { IMarketplaceInfo } from '../../../src/server/marketplace'; +import type { IAppStorageItem } from '../../../src/server/storage'; +import { AppMetadataStorage } from '../../../src/server/storage'; + +const Datastore = require('@seald-io/nedb') as typeof import('@seald-io/nedb').default; + +export class TestsAppStorage extends AppMetadataStorage { + private db: InstanceType; + + private static instance: TestsAppStorage; + + public static getInstance(): TestsAppStorage { + if (!TestsAppStorage.instance) { + TestsAppStorage.instance = new TestsAppStorage(); + } + + return TestsAppStorage.instance; + } + + private constructor() { + super('nedb'); + this.db = new Datastore({ filename: 'tests/test-data/dbs/apps.nedb', autoload: true }); + this.db.ensureIndex({ fieldName: 'id', unique: true }); + } + + public create(item: IAppStorageItem): Promise { + return new Promise((resolve, reject) => { + item.createdAt = new Date(); + item.updatedAt = new Date(); + + this.db.findOne({ $or: [{ id: item.id }, { 'info.nameSlug': item.info.nameSlug }] }, (err, doc: IAppStorageItem) => { + if (err) { + reject(err); + } else if (doc) { + reject(new Error('App already exists.')); + } else { + this.db.insert(item, (err2, doc2: IAppStorageItem) => { + if (err2) { + reject(err2); + } else { + resolve(doc2); + } + }); + } + }); + }); + } + + public retrieveOne(id: string): Promise { + return new Promise((resolve, reject) => { + this.db.findOne({ id }, (err, doc: IAppStorageItem) => { + if (err) { + reject(err); + } else if (doc) { + resolve(doc); + } else { + reject(new Error(`No App found by the id: ${id}`)); + } + }); + }); + } + + public retrieveAll(): Promise> { + return new Promise((resolve, reject) => { + this.db.find({}, (err: Error, docs: Array) => { + if (err) { + reject(err); + } else { + const items = new Map(); + + docs.forEach((i) => items.set(i.id, i)); + + resolve(items); + } + }); + }); + } + + public retrieveAllPrivate(): Promise> { + return new Promise((resolve, reject) => { + this.db.find({ installationSource: 'private' }, (err: Error, docs: Array) => { + if (err) { + reject(err); + } else { + const items = new Map(); + + docs.forEach((i) => items.set(i.id, i)); + + resolve(items); + } + }); + }); + } + + public remove(id: string): Promise<{ success: boolean }> { + return new Promise((resolve, reject) => { + this.db.remove({ id }, (err) => { + if (err) { + reject(err); + } else { + resolve({ success: true }); + } + }); + }); + } + + public updatePartialAndReturnDocument( + item: Partial, + options?: { unsetPermissionsGranted?: boolean }, + ): Promise { + throw new Error('Method not implemented.'); + } + + public updateStatus(_id: string, status: AppStatus): Promise { + throw new Error('Method not implemented.'); + } + + public updateSetting(_id: string, setting: ISetting): Promise { + throw new Error('Method not implemented.'); + } + + public updateAppInfo(_id: string, info: IAppInfo): Promise { + throw new Error('Method not implemented.'); + } + + public updateMarketplaceInfo(_id: string, marketplaceInfo: IMarketplaceInfo[]): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/packages/apps/tests/test-data/utilities.ts b/packages/apps/tests/test-data/utilities.ts new file mode 100644 index 0000000000000..b9e464d8fa682 --- /dev/null +++ b/packages/apps/tests/test-data/utilities.ts @@ -0,0 +1,725 @@ +import * as os from 'os'; + +import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; +import type { IHttp, IModify, IPersistence, IRead } from '@rocket.chat/apps-engine/definition/accessors'; +import { HttpStatusCode } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IApi, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api'; +import { ApiSecurity, ApiVisibility } from '@rocket.chat/apps-engine/definition/api'; +import type { IApiEndpointInfo } from '@rocket.chat/apps-engine/definition/api/IApiEndpointInfo'; +import type { IMessage, IMessageAttachment, IMessageRaw } from '@rocket.chat/apps-engine/definition/messages'; +import type { + IOutboundEmailMessageProvider, + IOutboundMessage, + IOutboundPhoneMessageProvider, + ProviderMetadata, +} from '@rocket.chat/apps-engine/definition/outboundCommunication'; +import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; +import { SettingType } from '@rocket.chat/apps-engine/definition/settings'; +import type { + ISlashCommand, + ISlashCommandPreview, + ISlashCommandPreviewItem, + SlashCommandContext, +} from '@rocket.chat/apps-engine/definition/slashcommands'; +import type { IUser } from '@rocket.chat/apps-engine/definition/users'; +import { UserStatusConnection, UserType } from '@rocket.chat/apps-engine/definition/users'; +import type { + IVideoConferenceOptions, + IVideoConfProvider, + VideoConfData, + VideoConfDataExtended, +} from '@rocket.chat/apps-engine/definition/videoConfProviders'; +import type { AppVideoConference } from '@rocket.chat/apps-engine/definition/videoConferences/AppVideoConference'; +import type { VideoConference } from '@rocket.chat/apps-engine/definition/videoConferences/IVideoConference'; +import { VideoConferenceStatus } from '@rocket.chat/apps-engine/definition/videoConferences/IVideoConference'; +import type { IVideoConferenceUser } from '@rocket.chat/apps-engine/definition/videoConferences/IVideoConferenceUser'; + +import { TestsAppBridges } from './bridges/appBridges'; +import { TestSourceStorage } from './storage/TestSourceStorage'; +import { TestsAppLogStorage } from './storage/logStorage'; +import { TestsAppStorage } from './storage/storage'; +import type { AppOutboundCommunicationProviderManager } from '../../server/managers/AppOutboundCommunicationProviderManager'; +import type { AppManager } from '../../src/server/AppManager'; +import { ProxiedApp } from '../../src/server/ProxiedApp'; +import type { AppBridges } from '../../src/server/bridges'; +import { AppPackageParser } from '../../src/server/compiler'; +import type { + AppExternalComponentManager, + AppSchedulerManager, + AppSettingsManager, + AppSlashCommandManager, + AppVideoConfProviderManager, +} from '../../src/server/managers'; +import type { AppRuntimeManager } from '../../src/server/managers/AppRuntimeManager'; +import type { UIActionButtonManager } from '../../src/server/managers/UIActionButtonManager'; +import type { IMarketplaceInfo, IMarketplaceSubscriptionInfo } from '../../src/server/marketplace'; +import { MarketplacePurchaseType } from '../../src/server/marketplace/MarketplacePurchaseType'; +import { MarketplaceSubscriptionStatus } from '../../src/server/marketplace/MarketplaceSubscriptionStatus'; +import { MarketplaceSubscriptionType } from '../../src/server/marketplace/MarketplaceSubscriptionType'; +import type { IRuntimeController } from '../../src/server/runtime/IRuntimeController'; +import type { AppLogStorage, AppMetadataStorage, AppSourceStorage, IAppStorageItem } from '../../src/server/storage'; +import { AppInstallationSource } from '../../src/server/storage/IAppStorageItem'; + +export class TestInfastructureSetup { + private appStorage: TestsAppStorage; + + private logStorage: TestsAppLogStorage; + + private bridges: TestsAppBridges; + + private sourceStorage: TestSourceStorage; + + private appManager: AppManager; + + private runtimeManager: AppRuntimeManager; + + constructor() { + this.appStorage = TestsAppStorage.getInstance(); + this.logStorage = new TestsAppLogStorage(); + this.bridges = new TestsAppBridges(); + this.sourceStorage = new TestSourceStorage(); + this.runtimeManager = { + startRuntimeForApp: async () => { + return TestData.getMockRuntimeController('test'); + }, + runInSandbox: async () => { + return {} as unknown as Promise; + }, + stopRuntime: () => {}, + } as unknown as AppRuntimeManager; + + this.appManager = { + getParser() { + if (!this.parser) { + this.parser = new AppPackageParser(); + } + + return this.parser; + }, + getBridges: () => { + return this.bridges as AppBridges; + }, + getCommandManager() { + return {} as AppSlashCommandManager; + }, + getExternalComponentManager() { + return {} as AppExternalComponentManager; + }, + getOneById(appId: string): ProxiedApp { + return appId === 'failMePlease' + ? undefined + : TestData.getMockApp({ info: { id: appId, name: 'testing' } } as IAppStorageItem, this); + }, + getLogStorage(): AppLogStorage { + return new TestsAppLogStorage(); + }, + getSchedulerManager() { + return {} as AppSchedulerManager; + }, + getUIActionButtonManager() { + return {} as UIActionButtonManager; + }, + getVideoConfProviderManager() { + return {} as AppVideoConfProviderManager; + }, + getOutboundCommunicationProviderManager() { + return {} as AppOutboundCommunicationProviderManager; + }, + getSettingsManager() { + return {} as AppSettingsManager; + }, + getRuntime: () => { + return this.runtimeManager; + }, + getTempFilePath: this.getTempFilePath, + } as unknown as AppManager; + } + + public getTempFilePath(): string { + return os.tmpdir(); + } + + public getAppStorage(): AppMetadataStorage { + return this.appStorage; + } + + public getLogStorage(): AppLogStorage { + return this.logStorage; + } + + public getAppBridges(): AppBridges { + return this.bridges; + } + + public getSourceStorage(): AppSourceStorage { + return this.sourceStorage; + } + + public getMockManager(): AppManager { + return this.appManager; + } +} + +const date = new Date(); + +const DEFAULT_ATTACHMENT = { + color: '#00b2b2', + collapsed: false, + text: 'Just an attachment that is used for testing', + timestampLink: 'https://google.com/', + thumbnailUrl: 'https://avatars0.githubusercontent.com/u/850391?s=88&v=4', + author: { + name: 'Author Name', + link: 'https://github.com/graywolf336', + icon: 'https://avatars0.githubusercontent.com/u/850391?s=88&v=4', + }, + title: { + value: 'Attachment Title', + link: 'https://github.com/RocketChat', + displayDownloadLink: false, + }, + imageUrl: 'https://rocket.chat/images/default/logo.svg', + audioUrl: 'http://www.w3schools.com/tags/horse.mp3', + videoUrl: 'http://www.w3schools.com/tags/movie.mp4', + fields: [ + { + short: true, + title: 'Test', + value: 'Testing out something or other', + }, + { + short: true, + title: 'Another Test', + value: '[Link](https://google.com/) something and this and that.', + }, + ], +}; +export class TestData { + public static getDate(): Date { + return date; + } + + public static getSetting(id?: string): ISetting { + return { + id: id || 'testing', + type: SettingType.STRING, + packageValue: 'The packageValue', + required: false, + public: false, + i18nLabel: 'Testing', + }; + } + + public static getUser(id?: string, username?: string): IUser { + return { + id: id || 'BBxwgCBzLeMC6esTb', + username: username || 'testing-user', + name: 'Testing User', + emails: [], + type: UserType.USER, + isEnabled: true, + roles: ['admin'], + status: 'online', + statusConnection: UserStatusConnection.ONLINE, + utcOffset: -5, + createdAt: date, + updatedAt: new Date(), + lastLoginAt: new Date(), + }; + } + + public static getRoom(id?: string, slugifiedName?: string): IRoom { + return { + id: id || 'bTse6CMeLzBCgwxBB', + slugifiedName: slugifiedName || 'testing-room', + displayName: 'Testing Room', + type: RoomType.CHANNEL, + creator: TestData.getUser(), + usernames: [TestData.getUser().username], + isDefault: true, + isReadOnly: false, + displaySystemMessages: true, + messageCount: 145, + createdAt: date, + updatedAt: new Date(), + lastModifiedAt: new Date(), + }; + } + + public static getMessage(id?: string, text?: string): IMessage { + return { + id: id || '4bShvoOXqB', + room: TestData.getRoom(), + sender: TestData.getUser(), + text: 'This is just a test, do not be alarmed', + createdAt: date, + updatedAt: new Date(), + editor: TestData.getUser(), + editedAt: new Date(), + emoji: ':see_no_evil:', + avatarUrl: 'https://avatars0.githubusercontent.com/u/850391?s=88&v=4', + alias: 'Testing Bot', + attachments: [this.createAttachment()], + }; + } + + public static getMessageRaw(id?: string, text?: string): IMessageRaw { + const editorUser = TestData.getUser(); + const senderUser = TestData.getUser(); + + return { + id: id || '4bShvoOXqB', + roomId: TestData.getRoom().id, + sender: { + _id: senderUser.id, + username: senderUser.username, + name: senderUser?.name, + }, + text: text || 'This is just a test, do not be alarmed', + createdAt: date, + updatedAt: new Date(), + editor: { + _id: editorUser.id, + username: editorUser.username, + }, + editedAt: new Date(), + emoji: ':see_no_evil:', + avatarUrl: 'https://avatars0.githubusercontent.com/u/850391?s=88&v=4', + alias: 'Testing Bot', + attachments: [this.createAttachment()], + }; + } + + private static createAttachment(attachment?: IMessageAttachment): IMessageAttachment { + attachment = attachment || DEFAULT_ATTACHMENT; + return { + timestamp: new Date(), + ...attachment, + }; + } + + public static getSlashCommand(command?: string): ISlashCommand { + return { + command: command || 'testing-cmd', + i18nParamsExample: 'justATest', + i18nDescription: 'justATest_Description', + permission: 'create-c', + providesPreview: true, + executor: (context: SlashCommandContext, read: IRead, modify: IModify, http: IHttp, persis: IPersistence): Promise => { + return Promise.resolve(); + }, + previewer: ( + context: SlashCommandContext, + read: IRead, + modify: IModify, + http: IHttp, + persis: IPersistence, + ): Promise => { + return Promise.resolve({ + i18nTitle: 'my i18nTitle', + items: [], + } as ISlashCommandPreview); + }, + executePreviewItem: ( + item: ISlashCommandPreviewItem, + context: SlashCommandContext, + read: IRead, + modify: IModify, + http: IHttp, + persis: IPersistence, + ): Promise => { + return Promise.resolve(); + }, + }; + } + + public static getApi( + path = 'testing-path', + visibility: ApiVisibility = ApiVisibility.PUBLIC, + security: ApiSecurity = ApiSecurity.UNSECURE, + ): IApi { + return { + visibility, + security, + endpoints: [ + { + path, + // The move to the Deno runtime now requires us to manually set what methods are available + _availableMethods: ['get'], + get( + request: IApiRequest, + endpoint: IApiEndpointInfo, + read: IRead, + modify: IModify, + http: IHttp, + persis: IPersistence, + ): Promise { + return Promise.resolve({ + status: HttpStatusCode.OK, + }); + }, + }, + ], + }; + } + + public static getVideoConfProvider(name = 'test'): IVideoConfProvider { + return { + name, + + async generateUrl(call: VideoConfData): Promise { + return `${name}/${call._id}`; + }, + + async customizeUrl( + call: VideoConfDataExtended, + user: IVideoConferenceUser | undefined, + options: IVideoConferenceOptions, + ): Promise { + return `${name}/${call._id}#${user ? user.username : ''}`; + }, + }; + } + + public static getInvalidConfProvider(name = 'invalid'): IVideoConfProvider { + return { + name, + + async isFullyConfigured(): Promise { + return false; + }, + + async generateUrl(call: VideoConfData): Promise { + return ``; + }, + + async customizeUrl( + call: VideoConfDataExtended, + user: IVideoConferenceUser | undefined, + options: IVideoConferenceOptions, + ): Promise { + return ``; + }, + }; + } + + public static getFullVideoConfProvider(name = 'test'): IVideoConfProvider { + return { + name, + + capabilities: { + mic: true, + cam: true, + title: true, + }, + + async isFullyConfigured(): Promise { + return true; + }, + + async generateUrl(call: VideoConfData): Promise { + return `${name}/${call._id}`; + }, + + async customizeUrl( + call: VideoConfDataExtended, + user: IVideoConferenceUser | undefined, + options: IVideoConferenceOptions, + ): Promise { + return `${name}/${call._id}#${user ? user.username : ''}`; + }, + }; + } + + public static getVideoConferenceUser(): IVideoConferenceUser { + return { + _id: 'callerId', + username: 'caller', + name: 'John Caller', + }; + } + + public static getVideoConfData(): VideoConfData { + return { + _id: 'first-call', + type: 'videoconference', + rid: 'roomId', + createdBy: this.getVideoConferenceUser(), + title: 'Test Call', + }; + } + + public static getVideoConfDataExtended(providerName = 'test'): VideoConfDataExtended { + return { + ...this.getVideoConfData(), + url: '${providerName}/first-call', + }; + } + + public static getAppVideoConference(): AppVideoConference { + return { + rid: 'roomId', + createdBy: 'userId', + title: 'Video Conference', + providerName: 'test', + }; + } + + public static getVideoConference(): VideoConference { + return { + _id: 'first-call', + _updatedAt: new Date(), + type: 'videoconference', + rid: 'roomId', + users: [ + { + _id: 'johnId', + name: 'John Doe', + username: 'mrdoe', + ts: new Date(), + }, + { + _id: 'janeId', + name: 'Jane Doe', + username: 'msdoe', + ts: new Date(), + }, + ], + status: VideoConferenceStatus.STARTED, + messages: { + started: 'messageId', + }, + url: 'video-conf/first-call', + createdBy: { + _id: 'johnId', + name: 'John Doe', + username: 'mrdoe', + }, + createdAt: new Date(), + title: 'Video Conference', + anonymousUsers: 0, + providerName: 'test', + }; + } + + public static getOutboundPhoneMessageProvider(name = 'Test Phone Provider'): IOutboundPhoneMessageProvider { + return { + type: 'phone', + appId: `${name}-app-id`, + name, + supportsTemplates: true, + documentationUrl: 'https://rocket.chat', + sendOutboundMessage: async (message): Promise => { + console.log('Sending message', message); + }, + getProviderMetadata: async (): Promise => { + return {} as ProviderMetadata; + }, + }; + } + + public static getOutboundEmailMessageProvider(name = 'Test Email Provider'): IOutboundEmailMessageProvider { + return { + type: 'email', + appId: `${name}-app-id`, + name, + supportsTemplates: true, + documentationUrl: 'https://rocket.chat', + sendOutboundMessage: async (message): Promise => { + console.log('Sending message', message); + }, + }; + } + + public static getOutboundMessage(): IOutboundMessage { + return { + to: '+123456789', + type: 'template', + templateProviderPhoneNumber: '+123456789', + agentId: 'agent-id', + departmentId: 'department-id', + template: { + name: 'template-name', + language: { + code: 'en', + policy: 'deterministic', + }, + components: [ + { + type: 'body', + parameters: [ + { + type: 'text', + text: 'Sample text', + }, + ], + }, + ], + namespace: 'template-namespace', + }, + }; + } + + public static getOAuthApp(isToCreate: boolean) { + const OAuthApp = { + _id: '4526fcab-b068-4dcc-b208-4fff599165b0', + name: 'name-test', + active: true, + clientId: 'clientId-test', + clientSecret: 'clientSecret-test', + redirectUri: 'redirectUri-test', + appId: 'app-123', + _createdAt: '2022-07-11T14:30:48.937Z', + _createdBy: { + _id: 'Em5TQwMD4P7AmTs73', + username: 'testa.bot', + }, + _updatedAt: '2022-07-11T14:30:48.937Z', + }; + + if (isToCreate) { + delete OAuthApp._id; + delete OAuthApp._createdAt; + delete OAuthApp._createdBy; + delete OAuthApp._updatedAt; + delete OAuthApp.appId; + } + return OAuthApp; + } + + public static getMockRuntimeController(id: string): IRuntimeController { + const mock = { + getStatus: () => Promise.resolve(AppStatus.AUTO_ENABLED), + sendRequest: () => Promise.resolve(undefined), + setupApp: () => Promise.resolve(), + stopApp: () => Promise.resolve(), + getAppId: () => id, + on: () => mock, + once: () => mock, + off: () => mock, + emit: () => true, + addListener: () => mock, + removeListener: () => mock, + removeAllListeners: () => mock, + setMaxListeners: () => mock, + getMaxListeners: () => 10, + listeners: () => [], + rawListeners: () => [], + listenerCount: () => 0, + prependListener: () => mock, + prependOnceListener: () => mock, + eventNames: () => [], + } as IRuntimeController; + + return mock; + } + + public static getMockApp(storageItem: Partial, manager: AppManager): ProxiedApp { + const { id, name } = storageItem.info || { id: 'test-app', name: 'Test App' }; + + return new ProxiedApp( + manager, + { id, status: AppStatus.AUTO_ENABLED, info: { id, name }, ...storageItem } as IAppStorageItem, + TestData.getMockRuntimeController(id), + ); + } + + public static getMarketplaceSubscriptionInfo(overrides: Partial = {}): IMarketplaceSubscriptionInfo { + return { + seats: 10, + maxSeats: 100, + startDate: '2023-01-01', + periodEnd: '2023-12-31', + isSubscripbedViaBundle: false, + typeOf: MarketplaceSubscriptionType.SubscriptionTypeApp, + status: MarketplaceSubscriptionStatus.PurchaseSubscriptionStatusActive, + license: { + license: 'encrypted-license-data', + version: 1, + expireDate: new Date('2023-01-01'), + }, + ...overrides, + }; + } + + public static getMarketplaceInfo(overrides: Partial = {}): IMarketplaceInfo { + return { + id: 'test-app', + name: 'Test App', + nameSlug: 'test-app', + version: '1.0.0', + description: 'Test app', + author: { name: 'Test Author', support: 'https://test.com', homepage: 'https://test.com' }, + permissions: [], + requiredApiVersion: '1.0.0', + classFile: 'main.js', + iconFile: 'icon.png', + implements: [], + categories: [], + status: 'active', + isVisible: true, + isPurchased: false, + isSubscribed: false, + isBundled: false, + createdDate: '2023-01-01', + modifiedDate: '2023-01-01', + price: 0, + purchaseType: MarketplacePurchaseType.PurchaseTypeSubscription, + subscriptionInfo: TestData.getMarketplaceSubscriptionInfo(), + ...overrides, + }; + } + + public static getAppStorageItem(overrides: Partial = {}): IAppStorageItem { + return { + id: 'test-app', + status: AppStatus.AUTO_ENABLED, + info: { + id: 'test-app', + name: 'Test App', + nameSlug: 'test-app', + version: '1.0.0', + description: 'Test app', + author: { name: 'Test Author', support: 'https://test.com', homepage: 'https://test.com' }, + permissions: [], + requiredApiVersion: '1.0.0', + classFile: 'main.js', + iconFile: 'icon.png', + implements: [], + }, + marketplaceInfo: [TestData.getMarketplaceInfo()], + createdAt: new Date(), + updatedAt: new Date(), + installationSource: AppInstallationSource.MARKETPLACE, + languageContent: {}, + settings: {}, + implemented: {}, + signature: 'default-signature', + ...overrides, + }; + } + + public static getAppsOverview(subscriptionInfo?: IMarketplaceSubscriptionInfo): Array<{ latest: IMarketplaceInfo }> { + return [ + { + latest: TestData.getMarketplaceInfo({ + subscriptionInfo: subscriptionInfo || TestData.getMarketplaceSubscriptionInfo(), + }), + }, + ]; + } +} + +export class SimpleClass { + private readonly world: string; + + constructor(world = 'Earith') { + this.world = world; + } + + public getWorld(): string { + return this.world; + } +} diff --git a/packages/apps/tests/tsconfig.json b/packages/apps/tests/tsconfig.json new file mode 100644 index 0000000000000..b1bdb388b72a9 --- /dev/null +++ b/packages/apps/tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "useDefineForClassFields": false, + "rootDir": "../" + }, + "include": ["./**/*"] +} From 292fae1374732a2e663cbf7308ce74f015a081ea Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Wed, 29 Apr 2026 13:02:41 -0300 Subject: [PATCH 11/12] fix: turbo config for packages/apps --- packages/apps/turbo.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/apps/turbo.json b/packages/apps/turbo.json index 966596e743534..0c2eb270418c2 100644 --- a/packages/apps/turbo.json +++ b/packages/apps/turbo.json @@ -4,7 +4,7 @@ "build": { "dependsOn": ["^build"], "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/.tool-versions"], - "outputs": ["client/**", "definition/**", "deno-runtime/**", "lib/**", "scripts/**", "server/**", ".deno-cache/**"] + "outputs": ["deno-runtime/**", "scripts/**", ".deno-cache/**", "dist/**"] } } } From d88c192d196cf077c8515d590c55b1cdcb5e2e63 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Mon, 4 May 2026 16:58:56 -0300 Subject: [PATCH 12/12] docs: apps-engine-migration.md --- docs/apps-engine-migration.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/apps-engine-migration.md b/docs/apps-engine-migration.md index be700f394833d..048690599b0d9 100644 --- a/docs/apps-engine-migration.md +++ b/docs/apps-engine-migration.md @@ -24,3 +24,4 @@ To make this migration easier to understand and review, we're using a stacked PR - [40395](https://github.com/RocketChat/Rocket.Chat/pull/40395) The feature branch itself. It will accumulate the changes of the whole stack. - [40183](https://github.com/RocketChat/Rocket.Chat/pull/40183) Replaces `AppPackageParser.getEngineVersion()` - which resolved the version by traversing the filesystem relative to `__dirname` - with a direct import of `ENGINE_VERSION`. This will support the migration of the `AppPackageParser` class itself. +- [40184](https://github.com/RocketChat/Rocket.Chat/pull/40184) Copies all relevant source files from `packages/apps-engine/src/server`, `packages/apps-engine/src/client`, `packages/apps-engine/deno-runtime`, `packages/apps-engine/tests` and `packages/apps-engine/scripts` into their corresponding path at `packages/apps`.