diff --git a/.changeset/dry-taxis-cry.md b/.changeset/dry-taxis-cry.md new file mode 100644 index 000000000000..ae8244087d9e --- /dev/null +++ b/.changeset/dry-taxis-cry.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes a race condition that causes livechat conversations to get stuck in the agent's sidebar panel after being forwarded. diff --git a/.changeset/little-gifts-do.md b/.changeset/little-gifts-do.md new file mode 100644 index 000000000000..3cdc0f2a84ac --- /dev/null +++ b/.changeset/little-gifts-do.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": major +"@rocket.chat/i18n": major +"@rocket.chat/license": major +--- + +Adds restrictions to air-gapped environments without a license diff --git a/.changeset/witty-apples-pretend.md b/.changeset/witty-apples-pretend.md new file mode 100644 index 000000000000..cb4dd696983d --- /dev/null +++ b/.changeset/witty-apples-pretend.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes async E2EE key exchange in development environments where change streams are no longer used diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a834776aeff5..f66c5d29de5b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,4 +1,5 @@ /packages/* @RocketChat/Architecture +/packages/apps-engine/ @RocketChat/apps /packages/core-typings/ @RocketChat/Architecture /packages/rest-typings/ @RocketChat/Architecture @RocketChat/backend /packages/ui-contexts/ @RocketChat/frontend diff --git a/.github/actions/build-docker-image/action.yml b/.github/actions/build-docker-image/action.yml index 378f6bdb01b9..02a05d9605a7 100644 --- a/.github/actions/build-docker-image/action.yml +++ b/.github/actions/build-docker-image/action.yml @@ -12,6 +12,9 @@ inputs: required: false password: required: false + deno-version: + required: true + type: string outputs: image-name: @@ -59,7 +62,7 @@ runs: fi; echo "Build ${{ inputs.release }} Docker image" - docker build -t $IMAGE_NAME . + docker build --build-arg DENO_VERSION=${{ inputs.deno-version }} -t $IMAGE_NAME . echo "image-name-base=${IMAGE_NAME_BASE}" >> $GITHUB_OUTPUT echo "image-name=${IMAGE_NAME}" >> $GITHUB_OUTPUT diff --git a/.github/actions/build-docker/action.yml b/.github/actions/build-docker/action.yml index 5af39b924057..ae84e376a0d9 100644 --- a/.github/actions/build-docker/action.yml +++ b/.github/actions/build-docker/action.yml @@ -9,6 +9,10 @@ inputs: required: true description: 'Node version' type: string + deno-version: + required: true + description: 'Deno version' + type: string platform: required: false description: 'Platform' @@ -66,6 +70,7 @@ runs: if: inputs.setup == 'true' with: node-version: ${{ inputs.node-version }} + deno-version: ${{ inputs.deno-version }} cache-modules: true install: true NPM_TOKEN: ${{ inputs.NPM_TOKEN }} @@ -79,6 +84,8 @@ runs: run: | args=(rocketchat ${{ inputs.build-containers }}) + export DENO_VERSION="${{ inputs.deno-version }}" + docker compose -f docker-compose-ci.yml build "${args[@]}" - name: Publish Docker images to GitHub Container Registry diff --git a/.github/actions/meteor-build/action.yml b/.github/actions/meteor-build/action.yml index 551a57d28a7c..bfd4ae7f5c20 100644 --- a/.github/actions/meteor-build/action.yml +++ b/.github/actions/meteor-build/action.yml @@ -16,6 +16,10 @@ inputs: NPM_TOKEN: required: false description: 'NPM token' + deno-version: + required: true + description: 'Deno version' + type: string runs: using: composite @@ -30,6 +34,7 @@ runs: uses: ./.github/actions/setup-node with: node-version: ${{ inputs.node-version }} + deno-version: ${{ inputs.deno-version }} cache-modules: true install: true NPM_TOKEN: ${{ inputs.NPM_TOKEN }} diff --git a/.github/actions/setup-node/action.yml b/.github/actions/setup-node/action.yml index 1035e2835792..120797d2ba3c 100644 --- a/.github/actions/setup-node/action.yml +++ b/.github/actions/setup-node/action.yml @@ -11,10 +11,11 @@ inputs: install: required: false description: 'Install dependencies' - deno-dir: - required: false - description: 'Deno directory' - default: ~/.deno-cache + type: boolean + deno-version: + required: true + description: 'Deno version' + type: string NPM_TOKEN: required: false description: 'NPM token' @@ -28,21 +29,20 @@ runs: using: composite steps: - - run: echo 'DENO_DIR=${{ inputs.deno-dir }}' >> $GITHUB_ENV - shell: bash - - - name: Cache Node Modules + - name: Cache Node Modules & Deno if: inputs.cache-modules id: cache-node-modules uses: actions/cache@v3 with: + # We need to cache node_modules for all workspaces with "hoistingLimits" defined path: | .turbo/cache node_modules - ${{ env.DENO_DIR }} apps/meteor/node_modules apps/meteor/ee/server/services/node_modules - key: node-modules-${{ hashFiles('yarn.lock') }} + packages/apps-engine/node_modules + packages/apps-engine/.deno-cache + key: node-modules-${{ hashFiles('yarn.lock') }}-deno-${{ hashFiles('packages/apps-engine/deno-runtime/deno.lock') }} # # Could use this command to list all paths to save: # find . -name 'node_modules' -prune | grep -v "/\.meteor/" | grep -v "/meteor/packages/" @@ -54,6 +54,11 @@ runs: node-version: ${{ inputs.node-version }} cache: 'yarn' + - name: Use Deno ${{ inputs.deno-version }} + uses: denoland/setup-deno@v1 + with: + deno-version: ${{ inputs.deno-version }} + - name: yarn login shell: bash if: inputs.NPM_TOKEN diff --git a/.github/workflows/ci-code-check.yml b/.github/workflows/ci-code-check.yml index af50b3230ba7..41facad89a03 100644 --- a/.github/workflows/ci-code-check.yml +++ b/.github/workflows/ci-code-check.yml @@ -6,6 +6,9 @@ on: node-version: required: true type: string + deno-version: + required: true + type: string env: TOOL_NODE_FLAGS: ${{ vars.TOOL_NODE_FLAGS }} @@ -33,6 +36,7 @@ jobs: uses: ./.github/actions/setup-node with: node-version: ${{ inputs.node-version }} + deno-version: ${{ inputs.deno-version }} cache-modules: true install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/ci-deploy-gh-pages-preview.yml b/.github/workflows/ci-deploy-gh-pages-preview.yml index 17f247ddad94..8a0905a174bb 100644 --- a/.github/workflows/ci-deploy-gh-pages-preview.yml +++ b/.github/workflows/ci-deploy-gh-pages-preview.yml @@ -23,6 +23,7 @@ jobs: if: github.event.action != 'closed' with: node-version: 14.21.3 + deno-version: 1.37.1 cache-modules: true install: true diff --git a/.github/workflows/ci-deploy-gh-pages.yml b/.github/workflows/ci-deploy-gh-pages.yml index b381e05ae5d8..0aab8022c7e6 100644 --- a/.github/workflows/ci-deploy-gh-pages.yml +++ b/.github/workflows/ci-deploy-gh-pages.yml @@ -18,6 +18,7 @@ jobs: uses: ./.github/actions/setup-node with: node-version: 14.21.3 + deno-version: 1.37.1 cache-modules: true install: true diff --git a/.github/workflows/ci-test-e2e.yml b/.github/workflows/ci-test-e2e.yml index a80a40419e9f..f219f39c0614 100644 --- a/.github/workflows/ci-test-e2e.yml +++ b/.github/workflows/ci-test-e2e.yml @@ -6,6 +6,9 @@ on: node-version: required: true type: string + deno-version: + required: true + type: string lowercase-repo: required: true type: string @@ -128,6 +131,7 @@ jobs: uses: ./.github/actions/setup-node with: node-version: ${{ inputs.node-version }} + deno-version: ${{ inputs.deno-version }} cache-modules: true install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/ci-test-unit.yml b/.github/workflows/ci-test-unit.yml index 840808ff5e31..883212d0cf3d 100644 --- a/.github/workflows/ci-test-unit.yml +++ b/.github/workflows/ci-test-unit.yml @@ -6,6 +6,9 @@ on: node-version: required: true type: string + deno-version: + required: true + type: string enterprise-license: required: false type: string @@ -37,6 +40,7 @@ jobs: uses: ./.github/actions/setup-node with: node-version: ${{ inputs.node-version }} + deno-version: ${{ inputs.deno-version }} cache-modules: true install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40260f71d21f..6b6fa426ca96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,7 @@ jobs: rc-dockerfile-alpine: '${{ github.workspace }}/apps/meteor/.docker/Dockerfile.alpine' rc-docker-tag-alpine: '${{ steps.docker.outputs.gh-docker-tag }}.alpine' node-version: ${{ steps.var.outputs.node-version }} + deno-version: ${{ steps.var.outputs.deno-version }} # this is 100% intentional, secrets are not available for forks, so ee-tests will always fail # to avoid this, we are using a dummy license, expiring at 2025-06-31 enterprise-license: X/XumwIkgwQuld0alWKt37lVA90XjKOrfiMvMZ0/RtqsMtrdL9GoAk+4jXnaY1b2ePoG7XSzGhuxEDxFKIWJK3hIKGNTvrd980LgH5sM5+1T4P42ivSpd8UZi0bwjJkCFLIu9RozzYwslGG0IehMxe0S6VjcO0UYlUJtbMCBHuR2WmTAmO6YVU3ln+pZCbrPFaTPSS1RovhKaNCNkZwIx/CLWW8UTXUuFV/ML4PbKKVoa5nvvJwPeatgL7UCnlSD90lfCiiuikpzj/Y/JLkIL6velFbwNxsrxg9iRJ2k0sKheMMSmlTiGzSvZUm+na5WQq91aKGncih+DmaEZA7QGrjp4eoA0dqTk6OmItsy0fHmQhvZIOKNMeO7vNQiLbaSV6rqibrzu7WPpeIvsvL57T1h37USoCSB6+jDqkzdfoqIpz8BxTiJDj1d8xGPJFVrgxoqQqkj9qIP/gCaEz5DF39QFv5sovk4yK2O8fEQYod2d14V9yECYl4szZPMk1IBfCAC2w7czWGHHFonhL+CQGT403y5wmDmnsnjlCqMKF72odqfTPTI8XnCvJDriPMWohnQEAGtTTyciAhNokx/mjAVJ4NeZPcsbm4BjhvJvnjxx/BhYhBBTNWPaCSZzocfrGUj9Z+ZA7BEz+xAFQyGDx3xRzqIXfT0G7w8fvgYJMU= @@ -39,6 +40,7 @@ jobs: with: sparse-checkout: | package.json + .tool-versions sparse-checkout-cone-mode: false ref: ${{ github.ref }} @@ -53,6 +55,10 @@ jobs: echo "NODE_VERSION: ${NODE_VERSION}" echo "node-version=${NODE_VERSION}" >> $GITHUB_OUTPUT + DENO_VERSION=$(awk '$1=="deno"{ print $2 }' .tool-versions) + echo "DENO_VERSION: ${DENO_VERSION}" + echo "deno-version=${DENO_VERSION}" >> $GITHUB_OUTPUT + - id: by-tag run: | if echo "$GITHUB_REF_NAME" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' ; then @@ -150,6 +156,7 @@ jobs: uses: ./.github/actions/setup-node with: node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} cache-modules: true install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -194,6 +201,7 @@ jobs: - uses: ./.github/actions/meteor-build with: node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} coverage: ${{ github.event_name != 'release' }} build-prod: @@ -224,6 +232,7 @@ jobs: - uses: ./.github/actions/meteor-build with: node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} coverage: ${{ github.event_name != 'release' }} build-gh-docker-coverage: @@ -252,6 +261,7 @@ jobs: CR_USER: ${{ secrets.CR_USER }} CR_PAT: ${{ secrets.CR_PAT }} node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} platform: ${{ matrix.platform }} build-containers: ${{ matrix.platform == 'alpine' && 'authorization-service account-service ddp-streamer-service presence-service stream-hub-service queue-worker-service omnichannel-transcript-service' || '' }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -280,6 +290,7 @@ jobs: CR_USER: ${{ secrets.CR_USER }} CR_PAT: ${{ secrets.CR_PAT }} node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} platform: ${{ matrix.platform }} build-containers: ${{ matrix.platform == 'alpine' && 'authorization-service account-service ddp-streamer-service presence-service stream-hub-service queue-worker-service omnichannel-transcript-service' || '' }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -300,6 +311,7 @@ jobs: uses: ./.github/workflows/ci-code-check.yml with: node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} test-unit: name: 🔨 Test Unit @@ -308,6 +320,7 @@ jobs: uses: ./.github/workflows/ci-test-unit.yml with: node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} enterprise-license: ${{ needs.release-versions.outputs.enterprise-license }} secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} @@ -321,6 +334,7 @@ jobs: type: api release: ce node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} rc-dockerfile: ${{ needs.release-versions.outputs.rc-dockerfile }} rc-docker-tag: ${{ needs.release-versions.outputs.rc-docker-tag }} @@ -344,6 +358,7 @@ jobs: shard: '[1, 2, 3, 4]' total-shard: 4 node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} rc-dockerfile: ${{ needs.release-versions.outputs.rc-dockerfile }} rc-docker-tag: ${{ needs.release-versions.outputs.rc-docker-tag }} @@ -371,6 +386,7 @@ jobs: enterprise-license: ${{ needs.release-versions.outputs.enterprise-license }} mongodb-version: "['4.4']" node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} rc-dockerfile: ${{ needs.release-versions.outputs.rc-dockerfile }} rc-docker-tag: ${{ needs.release-versions.outputs.rc-docker-tag }} @@ -395,6 +411,7 @@ jobs: total-shard: 5 mongodb-version: "['4.4']" node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} rc-dockerfile: ${{ needs.release-versions.outputs.rc-dockerfile }} rc-docker-tag: ${{ needs.release-versions.outputs.rc-docker-tag }} @@ -425,6 +442,7 @@ jobs: total-shard: 5 mongodb-version: "['6.0']" node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} rc-dockerfile: ${{ needs.release-versions.outputs.rc-dockerfile }} rc-docker-tag: ${{ needs.release-versions.outputs.rc-docker-tag }} @@ -564,6 +582,7 @@ jobs: username: ${{ secrets.CR_USER }} password: ${{ secrets.CR_PAT }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} docker-image-publish: name: 🚀 Publish Docker Image (main) diff --git a/.github/workflows/new-release.yml b/.github/workflows/new-release.yml index b2eae5d90b92..70e9eb354a06 100644 --- a/.github/workflows/new-release.yml +++ b/.github/workflows/new-release.yml @@ -35,6 +35,7 @@ jobs: uses: ./.github/actions/setup-node with: node-version: 14.21.3 + deno-version: 1.37.1 cache-modules: true install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/pr-update-description.yml b/.github/workflows/pr-update-description.yml index 084f2a383480..26ffffc6c86f 100644 --- a/.github/workflows/pr-update-description.yml +++ b/.github/workflows/pr-update-description.yml @@ -22,6 +22,7 @@ jobs: uses: ./.github/actions/setup-node with: node-version: 14.21.3 + deno-version: 1.37.1 cache-modules: true install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 3f2067ac7ec3..fe049f6a8369 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -25,6 +25,7 @@ jobs: uses: ./.github/actions/setup-node with: node-version: 14.21.3 + deno-version: 1.37.1 cache-modules: true install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 4a1e67fca33a..8c9048710dd2 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -16,6 +16,7 @@ jobs: uses: ./.github/actions/setup-node with: node-version: 14.21.3 + deno-version: 1.37.1 cache-modules: true install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/vulnerabilities-jira-integration.yml b/.github/workflows/vulnerabilities-jira-integration.yml deleted file mode 100644 index 2daeb533937d..000000000000 --- a/.github/workflows/vulnerabilities-jira-integration.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Github vulnerabilities and jira board integration - -on: - schedule: - - cron: '0 1 * * *' - -jobs: - IntegrateSecurityVulnerabilities: - runs-on: ubuntu-latest - steps: - - name: "Github vulnerabilities and jira board integration" - uses: RocketChat/github-vulnerabilities-jira-integration@v0.3 - env: - JIRA_URL: https://rocketchat.atlassian.net/ - JIRA_TOKEN: ${{ secrets.JIRA_TOKEN }} - GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }} - JIRA_EMAIL: security-team-accounts@rocket.chat - JIRA_PROJECT_ID: GJIT - UID_CUSTOMFIELD_ID: customfield_10059 - JIRA_COMPLETE_PHASE_ID: 31 - JIRA_START_PHASE_ID: 11 - diff --git a/.gitignore b/.gitignore index dbad2c29a22c..8ca2d018f92e 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ yarn-error.log* .env.test.local .env.production.local +storybook-static # turbo .turbo @@ -55,3 +56,5 @@ yarn-error.log* data/ registration.yaml + +storybook-static diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 000000000000..bc89cc40f83e --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +deno 1.37.1 diff --git a/README.md b/README.md index 6461ad602516..564ca75d2b11 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ You can follow these instructions to setup a dev environment: - Install **Node 14.x (LTS)** either [manually](https://nodejs.org/dist/latest-v14.x/) or using a tool like [nvm](https://github.com/creationix/nvm) or [volta](https://volta.sh/) (recommended) - Install **Meteor** ([version here](apps/meteor/.meteor/release)): https://docs.meteor.com/about/install.html - Install **yarn**: https://yarnpkg.com/getting-started/install +- Install **Deno 1.x**: https://docs.deno.com/runtime/fundamentals/installation/ - Clone this repo: `git clone https://github.com/RocketChat/Rocket.Chat.git` - Run `yarn` to install dependencies diff --git a/apps/meteor/.docker/Dockerfile b/apps/meteor/.docker/Dockerfile index 1e9ed3f5e592..75df2cb90678 100644 --- a/apps/meteor/.docker/Dockerfile +++ b/apps/meteor/.docker/Dockerfile @@ -1,3 +1,7 @@ +ARG DENO_VERSION="1.37.1" + +FROM denoland/deno:bin-${DENO_VERSION} as deno + FROM node:14.21.3-bullseye-slim LABEL maintainer="buildmaster@rocket.chat" @@ -10,6 +14,8 @@ RUN groupadd -g 65533 -r rocketchat \ && apt-get update \ && apt-get install -y --no-install-recommends fontconfig +COPY --from=deno /deno /bin/deno + # --chown requires Docker 17.12 and works only on Linux ADD --chown=rocketchat:rocketchat . /app @@ -20,8 +26,7 @@ ENV DEPLOY_METHOD=docker \ HOME=/tmp \ PORT=3000 \ ROOT_URL=http://localhost:3000 \ - Accounts_AvatarStorePath=/app/uploads \ - DENO_DIR=/usr/share/deno + Accounts_AvatarStorePath=/app/uploads RUN aptMark="$(apt-mark showmanual)" \ && apt-get install -y --no-install-recommends g++ make python3 ca-certificates \ @@ -29,8 +34,6 @@ RUN aptMark="$(apt-mark showmanual)" \ && npm install \ && cd npm/node_modules/isolated-vm \ && npm install \ - && cd /app/bundle/programs/server/npm/node_modules/@rocket.chat/apps-engine/deno-runtime \ - && ../../../deno-bin/bin/deno cache main.ts \ && apt-mark auto '.*' > /dev/null \ && apt-mark manual $aptMark > /dev/null \ && find /usr/local -type f -executable -exec ldd '{}' ';' \ diff --git a/apps/meteor/.docker/Dockerfile.alpine b/apps/meteor/.docker/Dockerfile.alpine index feebf76a03e7..aaa2d2552ab3 100644 --- a/apps/meteor/.docker/Dockerfile.alpine +++ b/apps/meteor/.docker/Dockerfile.alpine @@ -1,7 +1,17 @@ +ARG DENO_VERSION="1.37.1" + +FROM denoland/deno:bin-${DENO_VERSION} as deno + FROM node:14.21.3-alpine3.16 +LABEL maintainer="buildmaster@rocket.chat" + ENV LANG=C.UTF-8 +## Alpine 3.16 does not have a deno package, but newer versions have it +## So as soon as we can update the Alpine version, we can replace the following +## GLIBC installation part by an `apk add deno` + # Installing glibc deps required by Deno # This replaces libc6-compat # Copied from https://github.com/Docker-Hub-frolvlad/docker-alpine-glibc, which denoland/deno:alpine-1.37.1 uses @@ -11,7 +21,7 @@ RUN ALPINE_GLIBC_BASE_URL="https://github.com/sgerrand/alpine-pkg-glibc/releases ALPINE_GLIBC_BASE_PACKAGE_FILENAME="glibc-$ALPINE_GLIBC_PACKAGE_VERSION.apk" && \ ALPINE_GLIBC_BIN_PACKAGE_FILENAME="glibc-bin-$ALPINE_GLIBC_PACKAGE_VERSION.apk" && \ ALPINE_GLIBC_I18N_PACKAGE_FILENAME="glibc-i18n-$ALPINE_GLIBC_PACKAGE_VERSION.apk" && \ - apk add --no-cache --virtual=.build-dependencies wget ca-certificates && \ + apk add --no-cache --virtual=.build-dependencies wget ca-certificates ttf-dejavu && \ echo \ "-----BEGIN PUBLIC KEY-----\ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApZ2u1KJKUu/fW4A25y9m\ @@ -44,12 +54,11 @@ RUN ALPINE_GLIBC_BASE_URL="https://github.com/sgerrand/alpine-pkg-glibc/releases rm \ "$ALPINE_GLIBC_BASE_PACKAGE_FILENAME" \ "$ALPINE_GLIBC_BIN_PACKAGE_FILENAME" \ - "$ALPINE_GLIBC_I18N_PACKAGE_FILENAME" && \ - apk add --no-cache ttf-dejavu + "$ALPINE_GLIBC_I18N_PACKAGE_FILENAME" -ADD . /app +COPY --from=deno /deno /bin/deno -LABEL maintainer="buildmaster@rocket.chat" +ADD . /app # needs a mongo instance - defaults to container linking with alias 'mongo' ENV DEPLOY_METHOD=docker \ @@ -58,13 +67,12 @@ ENV DEPLOY_METHOD=docker \ HOME=/tmp \ PORT=3000 \ ROOT_URL=http://localhost:3000 \ - Accounts_AvatarStorePath=/app/uploads \ - DENO_DIR=/usr/share/deno + Accounts_AvatarStorePath=/app/uploads RUN set -x \ && apk add --no-cache --virtual .fetch-deps python3 make g++ \ && cd /app/bundle/programs/server \ - && npm install --production \ + && npm install --omit=dev --unsafe-perm \ # Start hack for sharp... && rm -rf npm/node_modules/sharp \ && npm install sharp@0.32.6 \ @@ -75,9 +83,6 @@ RUN set -x \ && npm install isolated-vm@4.4.2 \ && mv node_modules/isolated-vm npm/node_modules/isolated-vm \ # End hack for isolated-vm - # Cache Deno dependencies for Apps-Engine - && cd npm/node_modules/@rocket.chat/apps-engine/deno-runtime \ - && /app/bundle/programs/server/npm/node_modules/deno-bin/bin/deno cache main.ts \ && cd /app/bundle/programs/server/npm \ && npm rebuild bcrypt --build-from-source \ && npm cache clear --force \ diff --git a/apps/meteor/app/e2e/server/functions/handleSuggestedGroupKey.ts b/apps/meteor/app/e2e/server/functions/handleSuggestedGroupKey.ts index 22eccf03f407..e498d282d704 100644 --- a/apps/meteor/app/e2e/server/functions/handleSuggestedGroupKey.ts +++ b/apps/meteor/app/e2e/server/functions/handleSuggestedGroupKey.ts @@ -1,7 +1,7 @@ import { Rooms, Subscriptions } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -import { notifyOnSubscriptionChangedById } from '../../../lib/server/lib/notifyListener'; +import { notifyOnSubscriptionChangedById, notifyOnRoomChangedById } from '../../../lib/server/lib/notifyListener'; export async function handleSuggestedGroupKey( handle: 'accept' | 'reject', @@ -25,11 +25,17 @@ export async function handleSuggestedGroupKey( if (handle === 'accept') { await Subscriptions.setGroupE2EKey(sub._id, suggestedKey); - await Rooms.removeUsersFromE2EEQueueByRoomId(sub.rid, [userId]); + const { modifiedCount } = await Rooms.removeUsersFromE2EEQueueByRoomId(sub.rid, [userId]); + if (modifiedCount) { + void notifyOnRoomChangedById(sub.rid); + } } if (handle === 'reject') { - await Rooms.addUserIdToE2EEQueueByRoomIds([sub.rid], userId); + const { modifiedCount } = await Rooms.addUserIdToE2EEQueueByRoomIds([sub.rid], userId); + if (modifiedCount) { + void notifyOnRoomChangedById(sub.rid); + } } const { modifiedCount } = await Subscriptions.unsetGroupE2ESuggestedKey(sub._id); diff --git a/apps/meteor/app/e2e/server/methods/setUserPublicAndPrivateKeys.ts b/apps/meteor/app/e2e/server/methods/setUserPublicAndPrivateKeys.ts index 6ef35a063a28..45a00886af1e 100644 --- a/apps/meteor/app/e2e/server/methods/setUserPublicAndPrivateKeys.ts +++ b/apps/meteor/app/e2e/server/methods/setUserPublicAndPrivateKeys.ts @@ -2,6 +2,8 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Rooms, Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; +import { notifyOnRoomChangedById } from '../../../lib/server/lib/notifyListener'; + declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -36,5 +38,7 @@ Meteor.methods({ const subscribedRoomIds = await Rooms.getSubscribedRoomIdsWithoutE2EKeys(userId); await Rooms.addUserIdToE2EEQueueByRoomIds(subscribedRoomIds, userId); + + void notifyOnRoomChangedById(subscribedRoomIds); }, }); diff --git a/apps/meteor/app/error-handler/server/lib/RocketChat.ErrorHandler.ts b/apps/meteor/app/error-handler/server/lib/RocketChat.ErrorHandler.ts index 264443a1378b..984561fe13cd 100644 --- a/apps/meteor/app/error-handler/server/lib/RocketChat.ErrorHandler.ts +++ b/apps/meteor/app/error-handler/server/lib/RocketChat.ErrorHandler.ts @@ -3,14 +3,13 @@ import { Meteor } from 'meteor/meteor'; import { throttledCounter } from '../../../../lib/utils/throttledCounter'; import { sendMessage } from '../../../lib/server/functions/sendMessage'; -import { notifyOnSettingChanged } from '../../../lib/server/lib/notifyListener'; import { settings } from '../../../settings/server'; const incException = throttledCounter((counter) => { Settings.incrementValueById('Uncaught_Exceptions_Count', counter, { returnDocument: 'after' }) .then(({ value }) => { if (value) { - void notifyOnSettingChanged(value); + settings.set(value); } }) .catch(console.error); @@ -118,5 +117,12 @@ process.on('unhandledRejection', (error) => { process.on('uncaughtException', async (error) => { incException(); + + console.error('=== UnCaughtException ==='); + console.error(error); + console.error('-------------------------'); + console.error('Errors like this can cause oplog processing errors.'); + console.error('==========================='); + void errorHandler.trackError(error.message, error.stack); }); diff --git a/apps/meteor/app/lib/server/functions/addUserToRoom.ts b/apps/meteor/app/lib/server/functions/addUserToRoom.ts index e6ca7b2a8b4d..2e74bf66cf4a 100644 --- a/apps/meteor/app/lib/server/functions/addUserToRoom.ts +++ b/apps/meteor/app/lib/server/functions/addUserToRoom.ts @@ -97,8 +97,6 @@ export const addUserToRoom = async function ( void notifyOnSubscriptionChangedById(insertedId, 'inserted'); } - void notifyOnRoomChangedById(rid); - if (!userToBeAdded.username) { throw new Meteor.Error('error-invalid-user', 'Cannot add an user to a room without a username'); } @@ -147,5 +145,6 @@ export const addUserToRoom = async function ( await Rooms.addUserIdToE2EEQueueByRoomIds([room._id], userToBeAdded._id); } + void notifyOnRoomChangedById(rid); return true; }; diff --git a/apps/meteor/app/lib/server/methods/sendMessage.ts b/apps/meteor/app/lib/server/methods/sendMessage.ts index bce67a7b81d2..7a70ef8c4c62 100644 --- a/apps/meteor/app/lib/server/methods/sendMessage.ts +++ b/apps/meteor/app/lib/server/methods/sendMessage.ts @@ -138,7 +138,7 @@ Meteor.methods({ try { return await applyAirGappedRestrictionsValidation(() => executeSendMessage(uid, message, previewUrls)); } catch (error: any) { - if ((error.error || error.message) === 'error-not-allowed' || (error.error || error.message) === 'restricted-workspace') { + if (['error-not-allowed', 'restricted-workspace'].includes(error.error || error.message)) { throw new Meteor.Error(error.error || error.message, error.reason, { method: 'sendMessage', }); diff --git a/apps/meteor/app/livechat/server/api/v1/contact.ts b/apps/meteor/app/livechat/server/api/v1/contact.ts index f3fec80b23fe..ec0559d5f191 100644 --- a/apps/meteor/app/livechat/server/api/v1/contact.ts +++ b/apps/meteor/app/livechat/server/api/v1/contact.ts @@ -9,7 +9,7 @@ import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { API } from '../../../../api/server'; -import { Contacts, createContact, updateContact } from '../../lib/Contacts'; +import { Contacts, createContact, updateContact, isSingleContactEnabled } from '../../lib/Contacts'; API.v1.addRoute( 'omnichannel/contact', @@ -96,8 +96,8 @@ API.v1.addRoute( { authRequired: true, permissionsRequired: ['create-livechat-contact'], validateParams: isPOSTOmnichannelContactsProps }, { async post() { - if (process.env.TEST_MODE?.toUpperCase() !== 'TRUE') { - throw new Meteor.Error('error-not-allowed', 'This endpoint is only allowed in test mode'); + if (!isSingleContactEnabled()) { + return API.v1.unauthorized(); } const contactId = await createContact({ ...this.bodyParams, unknown: false }); @@ -111,8 +111,8 @@ API.v1.addRoute( { authRequired: true, permissionsRequired: ['update-livechat-contact'], validateParams: isPOSTUpdateOmnichannelContactsProps }, { async post() { - if (process.env.TEST_MODE?.toUpperCase() !== 'TRUE') { - throw new Meteor.Error('error-not-allowed', 'This endpoint is only allowed in test mode'); + if (!isSingleContactEnabled()) { + return API.v1.unauthorized(); } const contact = await updateContact({ ...this.bodyParams }); @@ -127,8 +127,8 @@ API.v1.addRoute( { authRequired: true, permissionsRequired: ['view-livechat-contact'], validateParams: isGETOmnichannelContactsProps }, { async get() { - if (process.env.TEST_MODE?.toUpperCase() !== 'TRUE') { - throw new Meteor.Error('error-not-allowed', 'This endpoint is only allowed in test mode'); + if (!isSingleContactEnabled()) { + return API.v1.unauthorized(); } const contact = await LivechatContacts.findOneById(this.queryParams.contactId); diff --git a/apps/meteor/app/livechat/server/lib/Contacts.ts b/apps/meteor/app/livechat/server/lib/Contacts.ts index f6f812ce8af8..e9be40aa942b 100644 --- a/apps/meteor/app/livechat/server/lib/Contacts.ts +++ b/apps/meteor/app/livechat/server/lib/Contacts.ts @@ -1,4 +1,5 @@ import type { + AtLeast, ILivechatContact, ILivechatContactChannel, ILivechatCustomField, @@ -113,41 +114,8 @@ export const Contacts = { } } - const allowedCF = LivechatCustomField.findByScope>( - 'visitor', - { - projection: { _id: 1, label: 1, regexp: 1, required: 1 }, - }, - false, - ); - - const livechatData: Record = {}; - - for await (const cf of allowedCF) { - if (!customFields.hasOwnProperty(cf._id)) { - if (cf.required) { - throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); - } - continue; - } - const cfValue: string = trim(customFields[cf._id]); - - if (!cfValue || typeof cfValue !== 'string') { - if (cf.required) { - throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); - } - continue; - } - - if (cf.regexp) { - const regex = new RegExp(cf.regexp); - if (!regex.test(cfValue)) { - throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); - } - } - - livechatData[cf._id] = cfValue; - } + const allowedCF = await getAllowedCustomFields(); + const livechatData: Record = validateCustomFields(allowedCF, customFields, { ignoreAdditionalFields: true }); const fieldsToRemove = { // if field is explicitely set to empty string, remove @@ -202,15 +170,20 @@ export const Contacts = { }, }; +export function isSingleContactEnabled(): boolean { + // The Single Contact feature is not yet available in production, but can already be partially used in test environments. + return process.env.TEST_MODE?.toUpperCase() === 'TRUE'; +} + export async function createContact(params: CreateContactParams): Promise { - const { name, emails, phones, customFields = {}, contactManager, channels, unknown } = params; + const { name, emails, phones, customFields: receivedCustomFields = {}, contactManager, channels, unknown } = params; if (contactManager) { await validateContactManager(contactManager); } const allowedCustomFields = await getAllowedCustomFields(); - validateCustomFields(allowedCustomFields, customFields); + const customFields = validateCustomFields(allowedCustomFields, receivedCustomFields); const { insertedId } = await LivechatContacts.insertOne({ name, @@ -226,7 +199,7 @@ export async function createContact(params: CreateContactParams): Promise { - const { contactId, name, emails, phones, customFields, contactManager, channels } = params; + const { contactId, name, emails, phones, customFields: receivedCustomFields, contactManager, channels } = params; const contact = await LivechatContacts.findOneById>(contactId, { projection: { _id: 1 } }); @@ -238,17 +211,21 @@ export async function updateContact(params: UpdateContactParams): Promise { +async function getAllowedCustomFields(): Promise[]> { return LivechatCustomField.findByScope( 'visitor', { @@ -258,7 +235,13 @@ async function getAllowedCustomFields(): Promise { ).toArray(); } -export function validateCustomFields(allowedCustomFields: ILivechatCustomField[], customFields: Record) { +export function validateCustomFields( + allowedCustomFields: AtLeast[], + customFields: Record, + options?: { ignoreAdditionalFields?: boolean }, +): Record { + const validValues: Record = {}; + for (const cf of allowedCustomFields) { if (!customFields.hasOwnProperty(cf._id)) { if (cf.required) { @@ -281,14 +264,20 @@ export function validateCustomFields(allowedCustomFields: ILivechatCustomField[] throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); } } + + validValues[cf._id] = cfValue; } - const allowedCustomFieldIds = new Set(allowedCustomFields.map((cf) => cf._id)); - for (const key in customFields) { - if (!allowedCustomFieldIds.has(key)) { - throw new Error(i18n.t('error-custom-field-not-allowed', { key })); + if (!options?.ignoreAdditionalFields) { + const allowedCustomFieldIds = new Set(allowedCustomFields.map((cf) => cf._id)); + for (const key in customFields) { + if (!allowedCustomFieldIds.has(key)) { + throw new Error(i18n.t('error-custom-field-not-allowed', { key })); + } } } + + return validValues; } export async function validateContactManager(contactManagerUserId: string) { diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index 6c2d655f4c95..44ee46f04418 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -71,7 +71,7 @@ import * as Mailer from '../../../mailer/server/api'; import { metrics } from '../../../metrics/server'; import { settings } from '../../../settings/server'; import { businessHourManager } from '../business-hour'; -import { createContact } from './Contacts'; +import { createContact, isSingleContactEnabled } from './Contacts'; import { parseAgentCustomFields, updateDepartmentAgents, validateEmail, normalizeTransferredByData } from './Helper'; import { QueueManager } from './QueueManager'; import { RoutingManager } from './RoutingManager'; @@ -669,7 +669,7 @@ class LivechatClass { } } - if (process.env.TEST_MODE?.toUpperCase() === 'TRUE') { + if (isSingleContactEnabled()) { const contactId = await createContact({ name: name ?? (visitorDataToUpdate.username as string), emails: email ? [email] : [], diff --git a/apps/meteor/client/views/room/Sidepanel/RoomSidepanel.tsx b/apps/meteor/client/views/room/Sidepanel/RoomSidepanel.tsx index 27c45e2774e8..6ee16a850202 100644 --- a/apps/meteor/client/views/room/Sidepanel/RoomSidepanel.tsx +++ b/apps/meteor/client/views/room/Sidepanel/RoomSidepanel.tsx @@ -51,8 +51,8 @@ const RoomSidepanelWithData = ({ parentRid, openedRoom }: { parentRid: string; o ( diff --git a/apps/meteor/client/views/room/Sidepanel/SidepanelItem/RoomSidepanelItem.tsx b/apps/meteor/client/views/room/Sidepanel/SidepanelItem/RoomSidepanelItem.tsx index dceb69e1aba3..8bb4d84eaebe 100644 --- a/apps/meteor/client/views/room/Sidepanel/SidepanelItem/RoomSidepanelItem.tsx +++ b/apps/meteor/client/views/room/Sidepanel/SidepanelItem/RoomSidepanelItem.tsx @@ -1,4 +1,4 @@ -import type { IRoom, ISubscription, Serialized } from '@rocket.chat/core-typings'; +import type { IRoom, ISubscription } from '@rocket.chat/core-typings'; import { useUserSubscription } from '@rocket.chat/ui-contexts'; import React, { memo } from 'react'; @@ -8,7 +8,7 @@ import { useItemData } from '../hooks/useItemData'; export type RoomSidepanelItemProps = { openedRoom?: string; - room: Serialized; + room: IRoom; parentRid: string; viewMode?: 'extended' | 'medium' | 'condensed'; }; diff --git a/apps/meteor/client/views/room/Sidepanel/hooks/useTeamslistChildren.ts b/apps/meteor/client/views/room/Sidepanel/hooks/useTeamslistChildren.ts index 772c29509608..de7645ae2a30 100644 --- a/apps/meteor/client/views/room/Sidepanel/hooks/useTeamslistChildren.ts +++ b/apps/meteor/client/views/room/Sidepanel/hooks/useTeamslistChildren.ts @@ -1,107 +1,60 @@ import type { IRoom } from '@rocket.chat/core-typings'; -import { useEndpoint } from '@rocket.chat/ui-contexts'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import type { Mongo } from 'meteor/mongo'; import { useEffect, useMemo } from 'react'; import { ChatRoom } from '../../../../../app/models/client'; -const sortRoomByLastMessage = (a: IRoom, b: IRoom) => { - if (!a.lm) { - return 1; - } - if (!b.lm) { - return -1; - } - return b.lm.getTime() - a.lm.getTime(); -}; - export const useTeamsListChildrenUpdate = ( parentRid: string, teamId?: string | null, sidepanelItems?: 'channels' | 'discussions' | null, ) => { - const queryClient = useQueryClient(); - const query = useMemo(() => { const query: Mongo.Selector = { $or: [ { _id: parentRid, }, - { - prid: parentRid, - }, ], }; - if (teamId && query.$or) { + if ((!sidepanelItems || sidepanelItems === 'discussions') && query.$or) { + query.$or.push({ + prid: parentRid, + }); + } + + if ((!sidepanelItems || sidepanelItems === 'channels') && teamId && query.$or) { query.$or.push({ teamId, }); } return query; - }, [parentRid, teamId]); + }, [parentRid, teamId, sidepanelItems]); - const teamList = useEndpoint('GET', '/v1/teams.listChildren'); - - const listRoomsAndDiscussions = useEndpoint('GET', '/v1/teams.listChildren'); const result = useQuery({ queryKey: ['sidepanel', 'list', parentRid, sidepanelItems], queryFn: () => - listRoomsAndDiscussions({ - roomId: parentRid, - sort: JSON.stringify({ lm: -1 }), - type: sidepanelItems || undefined, - }), + ChatRoom.find(query, { + sort: { lm: -1 }, + }).fetch(), enabled: sidepanelItems !== null && teamId !== null, refetchInterval: 5 * 60 * 1000, keepPreviousData: true, }); - const { mutate: update } = useMutation({ - mutationFn: async (params?: { action: 'add' | 'remove' | 'update'; data: IRoom }) => { - queryClient.setQueryData(['sidepanel', 'list', parentRid, sidepanelItems], (data: Awaited> | void) => { - if (!data) { - return; - } - - if (params?.action === 'add') { - data.data = [JSON.parse(JSON.stringify(params.data)), ...data.data].sort(sortRoomByLastMessage); - } - - if (params?.action === 'remove') { - data.data = data.data.filter((item) => item._id !== params.data?._id); - } - - if (params?.action === 'update') { - data.data = data.data - .map((item) => (item._id === params.data?._id ? JSON.parse(JSON.stringify(params.data)) : item)) - .sort(sortRoomByLastMessage); - } - - return { ...data }; - }); - }, - }); - useEffect(() => { const liveQueryHandle = ChatRoom.find(query).observe({ - added: (item) => { - queueMicrotask(() => update({ action: 'add', data: item })); - }, - changed: (item) => { - queueMicrotask(() => update({ action: 'update', data: item })); - }, - removed: (item) => { - queueMicrotask(() => update({ action: 'remove', data: item })); - }, + added: () => queueMicrotask(() => result.refetch({ exact: false })), + changed: () => queueMicrotask(() => result.refetch({ exact: false })), + removed: () => queueMicrotask(() => result.refetch({ exact: false })), }); return () => { liveQueryHandle.stop(); }; - }, [update, query]); + }, [query, result]); return result; }; diff --git a/apps/meteor/ee/app/license/server/airGappedRestrictions.ts b/apps/meteor/ee/app/license/server/airGappedRestrictions.ts index 6e1866691e09..f173037ffd8c 100644 --- a/apps/meteor/ee/app/license/server/airGappedRestrictions.ts +++ b/apps/meteor/ee/app/license/server/airGappedRestrictions.ts @@ -3,11 +3,39 @@ import { AirGappedRestriction } from '@rocket.chat/license'; import { Settings } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; +import { notifyOnSettingChangedById } from '../../../../app/lib/server/lib/notifyListener'; +import { settings } from '../../../../app/settings/server'; +import { i18n } from '../../../../server/lib/i18n'; +import { sendMessagesToAdmins } from '../../../../server/lib/sendMessagesToAdmins'; import { checkAirGappedRestrictions } from './airGappedRestrictionsCheck'; -AirGappedRestriction.on('remainingDays', ({ days }: { days: number }) => - Settings.updateValueById('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', days), -); +const updateRestrictionSetting = async (remainingDays: number) => { + const value = settings.get('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days'); + if (value === remainingDays) { + return; + } + await Settings.updateValueById('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', remainingDays); + void notifyOnSettingChangedById('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days'); +}; + +const sendRocketCatWarningToAdmins = async (remainingDays: number) => { + const lastDayOrNoRestrictionsAtAll = remainingDays <= 0; + if (lastDayOrNoRestrictionsAtAll) { + return; + } + if (AirGappedRestriction.isWarningPeriod(remainingDays)) { + await sendMessagesToAdmins({ + msgs: async ({ adminUser }) => ({ + msg: i18n.t('AirGapped_Restriction_Warning', { lng: adminUser.language || 'en', remainingDays }), + }), + }); + } +}; + +AirGappedRestriction.on('remainingDays', async ({ days }: { days: number }) => { + await updateRestrictionSetting(days); + await sendRocketCatWarningToAdmins(days); +}); Meteor.startup(async () => { await cronJobs.add('AirGapped Restrictions Cron', '0 */12 * * *', async () => { diff --git a/apps/meteor/ee/app/license/server/airGappedRestrictionsCheck.spec.ts b/apps/meteor/ee/app/license/server/airGappedRestrictionsCheck.spec.ts deleted file mode 100644 index e976563a1420..000000000000 --- a/apps/meteor/ee/app/license/server/airGappedRestrictionsCheck.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { License, AirGappedRestriction } from '@rocket.chat/license'; -import { Statistics } from '@rocket.chat/models'; - -import { checkAirGappedRestrictions } from './airGappedRestrictionsCheck'; - -jest.mock('@rocket.chat/models', () => ({ - Statistics: { findLast: jest.fn() }, -})); - -jest.mock('@rocket.chat/license', () => ({ - License: { hasModule: jest.fn() }, - AirGappedRestriction: { removeRestrictions: jest.fn(), applyRestrictions: jest.fn(), checkRemainingDaysSinceLastStatsReport: jest.fn() }, -})); - -describe('#checkAirGappedRestrictions()', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should remove any restriction and not to check the validity of the stats token when the workspace has "unlimited-presence" module enabled', async () => { - (License.hasModule as jest.Mock).mockReturnValueOnce(true); - await checkAirGappedRestrictions(); - expect(AirGappedRestriction.removeRestrictions).toHaveBeenCalledTimes(1); - expect(AirGappedRestriction.applyRestrictions).not.toHaveBeenCalled(); - expect(AirGappedRestriction.checkRemainingDaysSinceLastStatsReport).not.toHaveBeenCalled(); - }); - - it('should apply restrictions right away when the workspace doesnt contain a license with the previous module enabled AND there is no statsToken (no report was made before)', async () => { - (License.hasModule as jest.Mock).mockReturnValueOnce(false); - (Statistics.findLast as jest.Mock).mockReturnValueOnce(undefined); - await checkAirGappedRestrictions(); - expect(AirGappedRestriction.applyRestrictions).toHaveBeenCalledTimes(1); - expect(AirGappedRestriction.removeRestrictions).not.toHaveBeenCalled(); - expect(AirGappedRestriction.checkRemainingDaysSinceLastStatsReport).not.toHaveBeenCalled(); - }); - - it('should check the statsToken validity if there is no valid license and a report to the cloud was made before', async () => { - (License.hasModule as jest.Mock).mockReturnValueOnce(false); - (Statistics.findLast as jest.Mock).mockReturnValueOnce({ statsToken: 'token' }); - await checkAirGappedRestrictions(); - expect(AirGappedRestriction.applyRestrictions).not.toHaveBeenCalled(); - expect(AirGappedRestriction.removeRestrictions).not.toHaveBeenCalled(); - expect(AirGappedRestriction.checkRemainingDaysSinceLastStatsReport).toHaveBeenCalledWith('token'); - }); -}); diff --git a/apps/meteor/ee/app/license/server/airGappedRestrictionsCheck.ts b/apps/meteor/ee/app/license/server/airGappedRestrictionsCheck.ts index aeaee208aa0c..019f9076ca5d 100644 --- a/apps/meteor/ee/app/license/server/airGappedRestrictionsCheck.ts +++ b/apps/meteor/ee/app/license/server/airGappedRestrictionsCheck.ts @@ -1,16 +1,8 @@ -import { License, AirGappedRestriction } from '@rocket.chat/license'; +import { AirGappedRestriction } from '@rocket.chat/license'; import { Statistics } from '@rocket.chat/models'; export async function checkAirGappedRestrictions(): Promise { - if (License.hasModule('unlimited-presence')) { - AirGappedRestriction.removeRestrictions(); - return; - } - const { statsToken: encryptedStatsToken } = (await Statistics.findLast()) || {}; - if (!encryptedStatsToken) { - return AirGappedRestriction.applyRestrictions(); - } - await AirGappedRestriction.checkRemainingDaysSinceLastStatsReport(encryptedStatsToken); + await AirGappedRestriction.computeRestriction(encryptedStatsToken); } diff --git a/apps/meteor/ee/app/license/server/settings.ts b/apps/meteor/ee/app/license/server/settings.ts index d1a966ae831d..045c74e331e4 100644 --- a/apps/meteor/ee/app/license/server/settings.ts +++ b/apps/meteor/ee/app/license/server/settings.ts @@ -22,9 +22,7 @@ await settingsRegistry.addGroup('Enterprise', async function () { }); await this.add('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', -1, { type: 'int', - hidden: true, readonly: true, - secret: true, public: true, }); }); diff --git a/apps/meteor/ee/server/patches/airGappedRestrictionsWrapper.spec.ts b/apps/meteor/ee/server/patches/airGappedRestrictionsWrapper.spec.ts deleted file mode 100644 index f22012b18baa..000000000000 --- a/apps/meteor/ee/server/patches/airGappedRestrictionsWrapper.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { applyAirGappedRestrictionsValidation } from '../../../app/license/server/airGappedRestrictionsWrapper'; -import { settings } from '../../../app/settings/server'; -import './airGappedRestrictionsWrapper'; - -jest.mock('../../../app/settings/server', () => ({ - settings: { - get: jest.fn(), - }, -})); - -describe('#airGappedRestrictionsWrapper()', () => { - it('should throw an error when the remaining days for airgapped restrictions is equal to 0', async () => { - (settings.get as jest.Mock).mockReturnValue(0); - await expect(() => applyAirGappedRestrictionsValidation(jest.fn())).rejects.toThrow(new Error('restricted-workspace')); - }); - it('should NOT throw an error when the remaining days for airgapped restrictions is greater than 0', async () => { - (settings.get as jest.Mock).mockReturnValue(5); - const spy = jest.fn(); - await expect(() => applyAirGappedRestrictionsValidation(spy)).not.toThrow(); - await applyAirGappedRestrictionsValidation(spy); - expect(spy).toHaveBeenCalled(); - }); - it('should NOT throw an error when the remaining days for airgapped restrictions is equal to -1', async () => { - (settings.get as jest.Mock).mockReturnValue(-1); - const spy = jest.fn(); - await expect(() => applyAirGappedRestrictionsValidation(spy)).not.toThrow(); - await applyAirGappedRestrictionsValidation(spy); - expect(spy).toHaveBeenCalled(); - }); -}); diff --git a/apps/meteor/ee/server/patches/airGappedRestrictionsWrapper.ts b/apps/meteor/ee/server/patches/airGappedRestrictionsWrapper.ts index 05427bf11a94..6e2b01a5fa7c 100644 --- a/apps/meteor/ee/server/patches/airGappedRestrictionsWrapper.ts +++ b/apps/meteor/ee/server/patches/airGappedRestrictionsWrapper.ts @@ -1,8 +1,9 @@ +import { AirGappedRestriction } from '@rocket.chat/license'; + import { applyAirGappedRestrictionsValidation } from '../../../app/license/server/airGappedRestrictionsWrapper'; -import { settings } from '../../../app/settings/server'; applyAirGappedRestrictionsValidation.patch(async (_: any, fn: () => Promise): Promise => { - if (settings.get('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days') === 0) { + if (AirGappedRestriction.isRestricted) { throw new Error('restricted-workspace'); } return fn(); diff --git a/apps/meteor/ee/server/services/package.json b/apps/meteor/ee/server/services/package.json index 390b2c646cd1..9eb2e917819d 100644 --- a/apps/meteor/ee/server/services/package.json +++ b/apps/meteor/ee/server/services/package.json @@ -18,7 +18,7 @@ "author": "Rocket.Chat", "license": "MIT", "dependencies": { - "@rocket.chat/apps-engine": "1.45.0-alpha.868", + "@rocket.chat/apps-engine": "workspace:^", "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/emitter": "~0.31.25", diff --git a/apps/meteor/ee/tests/unit/server/airgappedRestrictions/airGappedRestrictionsCheck.spec.ts b/apps/meteor/ee/tests/unit/server/airgappedRestrictions/airGappedRestrictionsCheck.spec.ts new file mode 100644 index 000000000000..b58e4530447e --- /dev/null +++ b/apps/meteor/ee/tests/unit/server/airgappedRestrictions/airGappedRestrictionsCheck.spec.ts @@ -0,0 +1,64 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const AirgappedModule = { + removeRestrictions: sinon.stub(), + applyRestrictions: sinon.stub(), + checkRemainingDaysSinceLastStatsReport: sinon.stub(), +}; + +const LicenseModule = { + hasModule: sinon.stub(), +}; + +const StatisticsModule = { + findLast: sinon.stub(), +}; + +const cleanStubs = () => { + Object.values(AirgappedModule).forEach((stub) => stub.resetHistory()); + LicenseModule.hasModule.resetHistory(); + StatisticsModule.findLast.resetHistory(); +}; + +const { checkAirGappedRestrictions } = proxyquire.noCallThru().load('../../../../app/license/server/airGappedRestrictionsCheck.ts', { + '@rocket.chat/license': { + AirGappedRestriction: AirgappedModule, + License: LicenseModule, + }, + '@rocket.chat/models': { + Statistics: StatisticsModule, + }, +}); + +// TODO: remove this test, checkAirGappedRestrictions just fetches the statsToken and passes it to AirGappedRestrictionModule. +describe.skip('#checkAirGappedRestrictions()', () => { + afterEach(cleanStubs); + + it('should remove any restriction and not to check the validity of the stats token when the workspace has "unlimited-presence" module enabled', async () => { + LicenseModule.hasModule.returns(true); + await checkAirGappedRestrictions(); + expect(AirgappedModule.removeRestrictions.calledOnce).to.be.true; + expect(AirgappedModule.applyRestrictions.notCalled).to.be.true; + expect(AirgappedModule.checkRemainingDaysSinceLastStatsReport.notCalled).to.be.true; + }); + + it('should apply restrictions right away when the workspace doesnt contain a license with the previous module enabled AND there is no statsToken (no report was made before)', async () => { + LicenseModule.hasModule.returns(false); + StatisticsModule.findLast.returns(undefined); + await checkAirGappedRestrictions(); + expect(AirgappedModule.applyRestrictions.calledOnce).to.be.true; + expect(AirgappedModule.removeRestrictions.notCalled).to.be.true; + expect(AirgappedModule.checkRemainingDaysSinceLastStatsReport.notCalled).to.be.true; + }); + + it('should check the statsToken validity if there is no valid license and a report to the cloud was made before', async () => { + LicenseModule.hasModule.returns(false); + StatisticsModule.findLast.returns({ statsToken: 'token' }); + await checkAirGappedRestrictions(); + expect(AirgappedModule.applyRestrictions.notCalled).to.be.true; + expect(AirgappedModule.removeRestrictions.notCalled).to.be.true; + expect(AirgappedModule.checkRemainingDaysSinceLastStatsReport.calledWith('token')).to.be.true; + }); +}); diff --git a/apps/meteor/ee/tests/unit/server/airgappedRestrictions/airGappedRestrictionsWrapper.spec.ts b/apps/meteor/ee/tests/unit/server/airgappedRestrictions/airGappedRestrictionsWrapper.spec.ts new file mode 100644 index 000000000000..4886eba35d6d --- /dev/null +++ b/apps/meteor/ee/tests/unit/server/airgappedRestrictions/airGappedRestrictionsWrapper.spec.ts @@ -0,0 +1,34 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +import { applyAirGappedRestrictionsValidation } from '../../../../../app/license/server/airGappedRestrictionsWrapper'; + +let restrictionFlag = true; + +const airgappedModule = { + get isRestricted() { + return restrictionFlag; + }, +}; + +proxyquire.noCallThru().load('../../../../server/patches/airGappedRestrictionsWrapper.ts', { + '@rocket.chat/license': { + AirGappedRestriction: airgappedModule, + }, + '../../../app/license/server/airGappedRestrictionsWrapper': { + applyAirGappedRestrictionsValidation, + }, +}); + +describe('#airGappedRestrictionsWrapper()', () => { + it('should throw an error when the workspace is restricted', async () => { + await expect(applyAirGappedRestrictionsValidation(sinon.stub())).to.be.rejectedWith('restricted-workspace'); + }); + it('should NOT throw an error when the workspace is not restricted', async () => { + restrictionFlag = false; + const spy = sinon.stub(); + await expect(applyAirGappedRestrictionsValidation(spy)).to.eventually.equal(undefined); + expect(spy.calledOnce).to.be.true; + }); +}); diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 3767939a7e3c..b5c9003b6974 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -230,7 +230,7 @@ "@rocket.chat/agenda": "workspace:^", "@rocket.chat/api-client": "workspace:^", "@rocket.chat/apps": "workspace:^", - "@rocket.chat/apps-engine": "1.45.0-alpha.868", + "@rocket.chat/apps-engine": "workspace:^", "@rocket.chat/base64": "workspace:^", "@rocket.chat/cas-validate": "workspace:^", "@rocket.chat/core-services": "workspace:^", diff --git a/apps/meteor/server/models/raw/BaseRaw.ts b/apps/meteor/server/models/raw/BaseRaw.ts index d822038b177e..3e763017bbd3 100644 --- a/apps/meteor/server/models/raw/BaseRaw.ts +++ b/apps/meteor/server/models/raw/BaseRaw.ts @@ -318,33 +318,33 @@ export abstract class BaseRaw< async findOneAndDelete(filter: Filter, options?: FindOneAndDeleteOptions): Promise> { if (!this.trash) { - if (options) { - return this.col.findOneAndDelete(filter, options); - } - return this.col.findOneAndDelete(filter); + return this.col.findOneAndDelete(filter, options || {}); } - const result = await this.col.findOneAndDelete(filter); - - const { value: doc } = result; + const doc = await this.col.findOne(filter); if (!doc) { - return result; + return { ok: 1, value: null }; } const { _id, ...record } = doc; - const trash: TDeleted = { ...record, _deletedAt: new Date(), __collection__: this.name, } as unknown as TDeleted; - // since the operation is not atomic, we need to make sure that the record is not already deleted/inserted await this.trash?.updateOne({ _id } as Filter, { $set: trash } as UpdateFilter, { upsert: true, }); - return result; + try { + await this.col.deleteOne({ _id } as Filter); + } catch (e) { + await this.trash?.deleteOne({ _id } as Filter); + throw e; + } + + return { ok: 1, value: doc }; } async deleteMany(filter: Filter, options?: DeleteOptions & { onTrash?: (record: ResultFields) => void }): Promise { diff --git a/apps/meteor/server/models/raw/LivechatCustomField.ts b/apps/meteor/server/models/raw/LivechatCustomField.ts index 71228f55069d..38a93f6439b4 100644 --- a/apps/meteor/server/models/raw/LivechatCustomField.ts +++ b/apps/meteor/server/models/raw/LivechatCustomField.ts @@ -13,12 +13,12 @@ export class LivechatCustomFieldRaw extends BaseRaw implem return [{ key: { scope: 1 } }]; } - findByScope( + findByScope( scope: ILivechatCustomField['scope'], options?: FindOptions, includeHidden = true, - ): FindCursor { - return this.find({ scope, ...(includeHidden === true ? {} : { visibility: { $ne: 'hidden' } }) }, options); + ): FindCursor { + return this.find({ scope, ...(includeHidden === true ? {} : { visibility: { $ne: 'hidden' } }) }, options); } findMatchingCustomFields( diff --git a/docker-compose-ci.yml b/docker-compose-ci.yml index 23ee3b125524..0fe101c8fab1 100644 --- a/docker-compose-ci.yml +++ b/docker-compose-ci.yml @@ -8,6 +8,8 @@ services: build: dockerfile: ${RC_DOCKERFILE} context: /tmp/build + args: + DENO_VERSION: ${DENO_VERSION} image: ghcr.io/${LOWERCASE_REPOSITORY}/rocket.chat:${RC_DOCKER_TAG} environment: - TEST_MODE=true @@ -39,6 +41,7 @@ services: context: . args: SERVICE: authorization-service + DENO_VERSION: ${DENO_VERSION} image: ghcr.io/${LOWERCASE_REPOSITORY}/authorization-service:${DOCKER_TAG} environment: - 'MONGO_URL=${MONGO_URL}' @@ -73,6 +76,7 @@ services: context: . args: SERVICE: presence-service + DENO_VERSION: ${DENO_VERSION} image: ghcr.io/${LOWERCASE_REPOSITORY}/presence-service:${DOCKER_TAG} environment: - MONGO_URL=${MONGO_URL} diff --git a/ee/apps/account-service/Dockerfile b/ee/apps/account-service/Dockerfile index c80d4f2eb376..a97290a43394 100644 --- a/ee/apps/account-service/Dockerfile +++ b/ee/apps/account-service/Dockerfile @@ -7,6 +7,10 @@ WORKDIR /app COPY ./packages/core-services/package.json packages/core-services/package.json COPY ./packages/core-services/dist packages/core-services/dist +COPY ./packages/apps-engine/package.json packages/apps-engine/package.json +COPY ./packages/apps-engine/client packages/apps-engine/client +COPY ./packages/apps-engine/definition packages/apps-engine/definition + COPY ./packages/agenda/package.json packages/agenda/package.json COPY ./packages/agenda/dist packages/agenda/dist diff --git a/ee/apps/authorization-service/Dockerfile b/ee/apps/authorization-service/Dockerfile index 9a9e8ded922c..9ddeadd380fe 100644 --- a/ee/apps/authorization-service/Dockerfile +++ b/ee/apps/authorization-service/Dockerfile @@ -7,6 +7,10 @@ WORKDIR /app COPY ./packages/core-services/package.json packages/core-services/package.json COPY ./packages/core-services/dist packages/core-services/dist +COPY ./packages/apps-engine/package.json packages/apps-engine/package.json +COPY ./packages/apps-engine/client packages/apps-engine/client +COPY ./packages/apps-engine/definition packages/apps-engine/definition + COPY ./packages/agenda/package.json packages/agenda/package.json COPY ./packages/agenda/dist packages/agenda/dist diff --git a/ee/apps/ddp-streamer/Dockerfile b/ee/apps/ddp-streamer/Dockerfile index 32103dc3528b..dea2bc3790a1 100644 --- a/ee/apps/ddp-streamer/Dockerfile +++ b/ee/apps/ddp-streamer/Dockerfile @@ -7,6 +7,10 @@ WORKDIR /app COPY ./packages/core-services/package.json packages/core-services/package.json COPY ./packages/core-services/dist packages/core-services/dist +COPY ./packages/apps-engine/package.json packages/apps-engine/package.json +COPY ./packages/apps-engine/client packages/apps-engine/client +COPY ./packages/apps-engine/definition packages/apps-engine/definition + COPY ./packages/agenda/package.json packages/agenda/package.json COPY ./packages/agenda/dist packages/agenda/dist diff --git a/ee/apps/ddp-streamer/package.json b/ee/apps/ddp-streamer/package.json index 36c244f41180..f250b9e33106 100644 --- a/ee/apps/ddp-streamer/package.json +++ b/ee/apps/ddp-streamer/package.json @@ -15,7 +15,6 @@ ], "author": "Rocket.Chat", "dependencies": { - "@rocket.chat/apps-engine": "1.45.0-alpha.868", "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/emitter": "~0.31.25", @@ -45,6 +44,7 @@ "ws": "^8.8.1" }, "devDependencies": { + "@rocket.chat/apps-engine": "workspace:^", "@rocket.chat/ddp-client": "workspace:~", "@rocket.chat/eslint-config": "workspace:^", "@types/ejson": "^2.2.2", diff --git a/ee/apps/omnichannel-transcript/Dockerfile b/ee/apps/omnichannel-transcript/Dockerfile index 9b7e47968e68..0f18534e1453 100644 --- a/ee/apps/omnichannel-transcript/Dockerfile +++ b/ee/apps/omnichannel-transcript/Dockerfile @@ -7,6 +7,10 @@ WORKDIR /app COPY ./packages/core-services/package.json packages/core-services/package.json COPY ./packages/core-services/dist packages/core-services/dist +COPY ./packages/apps-engine/package.json packages/apps-engine/package.json +COPY ./packages/apps-engine/client packages/apps-engine/client +COPY ./packages/apps-engine/definition packages/apps-engine/definition + COPY ./packages/agenda/package.json packages/agenda/package.json COPY ./packages/agenda/dist packages/agenda/dist diff --git a/ee/apps/presence-service/Dockerfile b/ee/apps/presence-service/Dockerfile index 430880d29606..78c6a98f809a 100644 --- a/ee/apps/presence-service/Dockerfile +++ b/ee/apps/presence-service/Dockerfile @@ -13,6 +13,10 @@ COPY ./packages/agenda/dist packages/agenda/dist COPY ./packages/core-services/package.json packages/core-services/package.json COPY ./packages/core-services/dist packages/core-services/dist +COPY ./packages/apps-engine/package.json packages/apps-engine/package.json +COPY ./packages/apps-engine/client packages/apps-engine/client +COPY ./packages/apps-engine/definition packages/apps-engine/definition + COPY ./packages/core-typings/package.json packages/core-typings/package.json COPY ./packages/core-typings/dist packages/core-typings/dist diff --git a/ee/apps/queue-worker/Dockerfile b/ee/apps/queue-worker/Dockerfile index 9b7e47968e68..0f18534e1453 100644 --- a/ee/apps/queue-worker/Dockerfile +++ b/ee/apps/queue-worker/Dockerfile @@ -7,6 +7,10 @@ WORKDIR /app COPY ./packages/core-services/package.json packages/core-services/package.json COPY ./packages/core-services/dist packages/core-services/dist +COPY ./packages/apps-engine/package.json packages/apps-engine/package.json +COPY ./packages/apps-engine/client packages/apps-engine/client +COPY ./packages/apps-engine/definition packages/apps-engine/definition + COPY ./packages/agenda/package.json packages/agenda/package.json COPY ./packages/agenda/dist packages/agenda/dist diff --git a/ee/apps/stream-hub-service/Dockerfile b/ee/apps/stream-hub-service/Dockerfile index 9a9e8ded922c..9ddeadd380fe 100644 --- a/ee/apps/stream-hub-service/Dockerfile +++ b/ee/apps/stream-hub-service/Dockerfile @@ -7,6 +7,10 @@ WORKDIR /app COPY ./packages/core-services/package.json packages/core-services/package.json COPY ./packages/core-services/dist packages/core-services/dist +COPY ./packages/apps-engine/package.json packages/apps-engine/package.json +COPY ./packages/apps-engine/client packages/apps-engine/client +COPY ./packages/apps-engine/definition packages/apps-engine/definition + COPY ./packages/agenda/package.json packages/agenda/package.json COPY ./packages/agenda/dist packages/agenda/dist diff --git a/ee/packages/license/src/AirGappedRestriction.spec.ts b/ee/packages/license/src/AirGappedRestriction.spec.ts index 1f9264415eb7..2b76880f75de 100644 --- a/ee/packages/license/src/AirGappedRestriction.spec.ts +++ b/ee/packages/license/src/AirGappedRestriction.spec.ts @@ -1,18 +1,38 @@ import { AirGappedRestriction } from './AirGappedRestriction'; import { StatsTokenBuilder } from './MockedLicenseBuilder'; +import { License } from './licenseImp'; + +jest.mock('./licenseImp', () => ({ + License: { + hasModule: jest.fn().mockReturnValue(false), + }, +})); describe('AirGappedRestriction', () => { - describe('#checkRemainingDaysSinceLastStatsReport()', () => { + describe('#computeRestriction()', () => { + it('should notify remaining days = 0 (apply restriction) when token is not a string', async () => { + const handler = jest.fn(); + + AirGappedRestriction.on('remainingDays', handler); + await AirGappedRestriction.computeRestriction(); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ + days: 0, + }); + expect(AirGappedRestriction.isRestricted).toBe(true); + }); it('should notify remaining days = 0 (apply restriction) when it was not possible to decrypt the stats token', async () => { const handler = jest.fn(); AirGappedRestriction.on('remainingDays', handler); - await AirGappedRestriction.checkRemainingDaysSinceLastStatsReport('invalid-token'); + await AirGappedRestriction.computeRestriction('invalid-token'); - expect(handler).toBeCalledTimes(1); - expect(handler).toBeCalledWith({ + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ days: 0, }); + expect(AirGappedRestriction.isRestricted).toBe(true); }); it('should notify remaining days (8) within the accepted range (1 - 10) when the last reported stats happened 2 days ago', async () => { const now = new Date(); @@ -21,12 +41,13 @@ describe('AirGappedRestriction', () => { const handler = jest.fn(); AirGappedRestriction.on('remainingDays', handler); - await AirGappedRestriction.checkRemainingDaysSinceLastStatsReport(token); + await AirGappedRestriction.computeRestriction(token); - expect(handler).toBeCalledTimes(1); - expect(handler).toBeCalledWith({ + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ days: 8, }); + expect(AirGappedRestriction.isRestricted).toBe(false); }); it('should notify remaining days = 0 (apply restrictions) when the last reported stats happened more than 10 days ago', async () => { const now = new Date(); @@ -35,12 +56,13 @@ describe('AirGappedRestriction', () => { const handler = jest.fn(); AirGappedRestriction.on('remainingDays', handler); - await AirGappedRestriction.checkRemainingDaysSinceLastStatsReport(token); + await AirGappedRestriction.computeRestriction(token); - expect(handler).toBeCalledTimes(1); - expect(handler).toBeCalledWith({ + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ days: 0, }); + expect(AirGappedRestriction.isRestricted).toBe(true); }); it('should notify remaining days = 0 (apply restrictions) when the last reported stats happened 10 days ago', async () => { const now = new Date(); @@ -49,38 +71,43 @@ describe('AirGappedRestriction', () => { const handler = jest.fn(); AirGappedRestriction.on('remainingDays', handler); - await AirGappedRestriction.checkRemainingDaysSinceLastStatsReport(token); + await AirGappedRestriction.computeRestriction(token); - expect(handler).toBeCalledTimes(1); - expect(handler).toBeCalledWith({ + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ days: 0, }); + expect(AirGappedRestriction.isRestricted).toBe(true); }); - }); - describe('#applyRestrictions()', () => { - it('should notify remaining days = 0 when restrictions should be applied', () => { + it('should notify remaining days = -1 when unlimited-presence module is available', () => { const handler = jest.fn(); + (License.hasModule as jest.Mock).mockReturnValueOnce(true); AirGappedRestriction.on('remainingDays', handler); - AirGappedRestriction.applyRestrictions(); + AirGappedRestriction.computeRestriction(); - expect(handler).toBeCalledTimes(1); - expect(handler).toBeCalledWith({ - days: 0, + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ + days: -1, }); + expect(AirGappedRestriction.isRestricted).toBe(false); }); }); - describe('#removeRestrictions()', () => { - it('should notify remaining days = -1 when restrictions should be reverted', () => { - const handler = jest.fn(); - - AirGappedRestriction.on('remainingDays', handler); - AirGappedRestriction.removeRestrictions(); - - expect(handler).toBeCalledTimes(1); - expect(handler).toBeCalledWith({ - days: -1, - }); + describe('#isWarningPeriod', () => { + it('should return true if value is between or exactly 0 and 7', async () => { + expect(AirGappedRestriction.isWarningPeriod(0)).toBe(true); + expect(AirGappedRestriction.isWarningPeriod(1)).toBe(true); + expect(AirGappedRestriction.isWarningPeriod(2)).toBe(true); + expect(AirGappedRestriction.isWarningPeriod(3)).toBe(true); + expect(AirGappedRestriction.isWarningPeriod(4)).toBe(true); + expect(AirGappedRestriction.isWarningPeriod(5)).toBe(true); + expect(AirGappedRestriction.isWarningPeriod(6)).toBe(true); + expect(AirGappedRestriction.isWarningPeriod(7)).toBe(true); + }); + it('should return false if value is lesser than 0 or bigger than 7', async () => { + expect(AirGappedRestriction.isWarningPeriod(-1)).toBe(false); + expect(AirGappedRestriction.isWarningPeriod(8)).toBe(false); + expect(AirGappedRestriction.isWarningPeriod(10)).toBe(false); }); }); }); diff --git a/ee/packages/license/src/AirGappedRestriction.ts b/ee/packages/license/src/AirGappedRestriction.ts index 126bf3bc0584..85a2c318eece 100644 --- a/ee/packages/license/src/AirGappedRestriction.ts +++ b/ee/packages/license/src/AirGappedRestriction.ts @@ -1,5 +1,6 @@ import EventEmitter from 'events'; +import { License } from '.'; import { decryptStatsToken } from './token'; const DAY_IN_MS = 24 * 60 * 60 * 1000; @@ -7,7 +8,27 @@ const NO_ACTION_PERIOD_IN_DAYS = 3; const WARNING_PERIOD_IN_DAYS = 7; class AirGappedRestrictionClass extends EventEmitter { - public async checkRemainingDaysSinceLastStatsReport(encryptedToken: string): Promise { + private restricted = false; + + public get isRestricted(): boolean { + return this.restricted; + } + + public async computeRestriction(encryptedToken?: string): Promise { + if (License.hasModule('unlimited-presence')) { + this.removeRestrictionsUnderLicense(); + return; + } + + if (typeof encryptedToken !== 'string') { + this.applyRestrictions(); + return; + } + + return this.checkRemainingDaysSinceLastStatsReport(encryptedToken); + } + + private async checkRemainingDaysSinceLastStatsReport(encryptedToken: string): Promise { try { const { timestamp: lastStatsReportTimestamp } = JSON.parse(await decryptStatsToken(encryptedToken)); const now = new Date(); @@ -23,21 +44,26 @@ class AirGappedRestrictionClass extends EventEmitter { } } - public applyRestrictions(): void { - this.emit('remainingDays', { days: 0 }); + private applyRestrictions(): void { + this.notifyRemainingDaysUntilRestriction(NO_ACTION_PERIOD_IN_DAYS + WARNING_PERIOD_IN_DAYS); } - public removeRestrictions(): void { + private removeRestrictionsUnderLicense(): void { + this.restricted = false; this.emit('remainingDays', { days: -1 }); } - private notifyRemainingDaysUntilRestriction(daysSinceLastStatsReport: number): void { - const remainingDaysUntilRestriction = NO_ACTION_PERIOD_IN_DAYS + WARNING_PERIOD_IN_DAYS - daysSinceLastStatsReport; - const olderThanTenDays = remainingDaysUntilRestriction < 0; - if (olderThanTenDays) { - return this.applyRestrictions(); + public isWarningPeriod(days: number) { + if (days < 0) { + return false; } + return days <= WARNING_PERIOD_IN_DAYS; + } + + private notifyRemainingDaysUntilRestriction(daysSinceLastStatsReport: number): void { + const remainingDaysUntilRestriction = Math.max(NO_ACTION_PERIOD_IN_DAYS + WARNING_PERIOD_IN_DAYS - daysSinceLastStatsReport, 0); + this.restricted = remainingDaysUntilRestriction === 0; this.emit('remainingDays', { days: remainingDaysUntilRestriction }); } } diff --git a/ee/packages/presence/package.json b/ee/packages/presence/package.json index 912b4bf453fd..06bbc244fd48 100644 --- a/ee/packages/presence/package.json +++ b/ee/packages/presence/package.json @@ -6,7 +6,7 @@ "@babel/core": "~7.22.20", "@babel/preset-env": "~7.22.20", "@babel/preset-typescript": "~7.22.15", - "@rocket.chat/apps-engine": "1.45.0-alpha.868", + "@rocket.chat/apps-engine": "workspace:^", "@rocket.chat/eslint-config": "workspace:^", "@rocket.chat/rest-typings": "workspace:^", "@types/node": "^14.18.63", diff --git a/packages/apps-engine/.eslintignore b/packages/apps-engine/.eslintignore new file mode 100644 index 000000000000..f7e4e0b38e59 --- /dev/null +++ b/packages/apps-engine/.eslintignore @@ -0,0 +1,8 @@ +!/gulpfile.js +/client +/definition +/docs +/server +/lib +/deno-runtime +/.deno diff --git a/packages/apps-engine/.eslintrc.json b/packages/apps-engine/.eslintrc.json new file mode 100644 index 000000000000..8e42d7cfa9f6 --- /dev/null +++ b/packages/apps-engine/.eslintrc.json @@ -0,0 +1,58 @@ +{ + "extends": "@rocket.chat/eslint-config", + "parser": "@typescript-eslint/parser", + "parserOptions": { + "project": "./tsconfig-lint.json" + }, + "rules": { + "@typescript-eslint/ban-types": [ + "error", + { + "types": { + "{}": false + } + } + ], + "@typescript-eslint/naming-convention": [ + "error", + { + "selector": ["function", "parameter", "variable"], + "modifiers": ["destructured"], + "format": null + }, + { + "selector": ["variable"], + "format": ["camelCase", "UPPER_CASE", "PascalCase"], + "leadingUnderscore": "allowSingleOrDouble" + }, + { + "selector": ["function"], + "format": ["camelCase", "PascalCase"], + "leadingUnderscore": "allowSingleOrDouble" + }, + { + "selector": ["parameter"], + "format": ["camelCase"], + "leadingUnderscore": "allow" + }, + { + "selector": ["parameter"], + "format": ["camelCase"], + "modifiers": ["unused"], + "leadingUnderscore": "allow" + }, + { + "selector": ["interface"], + "format": ["PascalCase"], + "custom": { + "regex": "^I[A-Z]", + "match": true + } + } + ], + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], + "new-cap": "off", + "no-await-in-loop": "off" + } +} diff --git a/packages/apps-engine/.gitignore b/packages/apps-engine/.gitignore new file mode 100644 index 000000000000..c4568f9c7430 --- /dev/null +++ b/packages/apps-engine/.gitignore @@ -0,0 +1,61 @@ +# Created by https://www.gitignore.io/api/node + +### Node ### +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules +jspm_packages + +# Optional npm cache directory +.npm + +.deno +.deno-cache + +# Optional REPL history +.node_repl_history + +### Typings ### +## Ignore downloaded typings +/typings + +## dev environment stuff +/examples/ +/dev-dist/ +/data/ +/tests/test-data/dbs +/client +/definition +/server +/lib + +.DS_Store +.idea/ diff --git a/packages/apps-engine/.prettierrc b/packages/apps-engine/.prettierrc new file mode 100644 index 000000000000..9b77117b35c0 --- /dev/null +++ b/packages/apps-engine/.prettierrc @@ -0,0 +1,7 @@ +{ + "tabWidth": 4, + "useTabs": false, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 160 +} diff --git a/packages/apps-engine/README.md b/packages/apps-engine/README.md new file mode 100644 index 000000000000..d73e09f1aed7 --- /dev/null +++ b/packages/apps-engine/README.md @@ -0,0 +1,125 @@ +## Thoughts While Working (for docs) +- Apps which don't provide a valid uuid4 id will be assigned one, but this is not recommended and your App should provide an id +- The language strings are only done on the clients (`TAPi18next.addResourceBundle(lang, projectName, translations);`) +- The implementer of this should restrict the server setting access and environmental variables. Idea is to allow the implementer to have a default set of restricted ones while letting the admin/owner of the server to restrict it even further or lift the restriction on some more. Simple interface with settings and checkbox to allow/disallow them. :thinking: + +## What does the Apps-Engine enable you to do? +The Apps-Engine is Rocket.Chat's _plugin framework_ - it provides the APIs for Rocket.Chat Apps to interact with the host system. + +Currently, a Rocket.Chat App can: +- Listen to message events + - before/after sent + - before/after updated + - before/after deleted +- Listen to room events + - before/after created + - before/after deleted +- Send messages to users and livechat visitors +- Register new slash commands +- Register new HTTP endpoints + +Some features the Engine allows Apps to use: +- Key-Value Storage system +- App specific settings + +## Development environment with Rocket.Chat +When developing new functionalities, you need to integrate the local version of the Apps-Engine with your local version of Rocket.Chat. + +First of all, make sure you've installed all required packages and compiled the changes you've made to the Apps-Engine, since that is what Rocket.Chat will execute: +```sh +npm install +npm run compile +``` + +Now, you need to setup a local Rocket.Chat server, [so head to the project's README for instructions on getting started](https://github.com/RocketChat/Rocket.Chat#development) (if you haven't already). Make sure to actually clone the repo, since you will probably need to add some code to it in order to make your new functionality work. + +After that, `cd` into Rocket.Chat folder and run: +```sh +meteor npm install PATH_TO_APPS_ENGINE +``` + +Where `PATH_TO_APPS_ENGINE` is the path to the Apps-Engine repo you've cloned. + +That's it! Now when you start Rocket.Chat with the `meteor` command, it will use your local Apps-Engine instead of the one on NPM :) + +Whenever you make changes to the engine, run `npm run compile` again - meteor will take care of restarting the server due to the changes. + +## Troubleshooting +1. Sometimes, when you update the Apps-Engine code and compile it while Rocket.Chat is running, you might run on errors similar to these: + +``` +Unable to resolve some modules: + + "@rocket.chat/apps-engine/definition/AppStatus" in +/Users/dev/rocket.chat/Rocket.Chat/app/apps/client/admin/helpers.js (web.browser) + +If you notice problems related to these missing modules, consider running: + + meteor npm install --save @rocket.chat/apps-engine +``` + +Simply restart the meteor process and it should be fixed. + +2. Sometimes when using `meteor npm install PATH_TO_APPS_ENGINE` will cause the following error :- + +``` +npm ERR! code ENOENT +npm ERR! syscall rename +npm ERR! path PATH_TO_ROCKETCHAT/node_modules/.staging/@rocket.chat/apps-engine-c7135600/node_modules/@babel/code-frame +npm ERR! dest PATH_TO_ROCKETCHAT/node_modules/.staging/@babel/code-frame-f3697825 +npm ERR! errno -2 +npm ERR! enoent ENOENT: no such file or directory, rename 'PATH_TO_ROCKETCHAT/node_modules/.staging/@rocket.chat/apps-engine-c7135600/node_modules/@babel/code-frame' -> 'PATH_TO_ROCKETCHAT/node_modules/.staging/@babel/code-frame-f3697825' +npm ERR! enoent This is related to npm not being able to find a file. +npm ERR! enoent +``` +Here `PATH_TO_ROCKETCHAT` is the path to the main rocketchat server repo in your system +To correct this we reinstall the package once again deleting the previous package +``` +~/Rocket.Chat$ rm -rf node_modules/@rocket.chat/apps-engine +~/Rocket.Chat$ cd PATH_TO_APP_ENGINE +~/Rocket.Chat.Apps-engine$ npm install +~/Rocket.Chat.Apps-engine$ cd PATH_TO_ROCKETCHAT +~/Rocket.Chat$ meteor npm install ../Rocket.Chat.Apps-engine +``` + +## Implementer Needs to Implement: +- `src/server/storage/AppStorage` +- `src/server/storage/AppLogStorage` +- `src/server/bridges/*` + +## Testing Framework: +Makes great usage of TypeScript and decorators: https://github.com/alsatian-test/alsatian/wiki +* To run the tests do: `npm run unit-tests` +* To generate the coverage information: `npm run check-coverage` +* To view the coverage: `npm run view-coverage` + +# Rocket.Chat Apps TypeScript Definitions + +## Handlers +Handlers are essentially "listeners" for different events, except there are various ways to handle an event. +When something happens there is `pre` and `post` handlers. +The set of `pre` handlers happens before the event is finalized. +The set of `post` handlers happens after the event is finalized. +With that said, the rule of thumb is that if you are going to modify, extend, or change the data backing the event then that should be done in the `pre` handlers. If you are simply wanting to listen for when something happens and not modify anything, then the `post` is the way to go. + +The order in which they happen is: +* Pre**Event**Prevent +* Pre**Event**Extend +* Pre**Event**Modify +* Post**Event** + +Here is an explanation of what each of them means: +* **Prevent**: This is ran to determine whether the event should be prevented or not. +* **Extend**: This is ran to allow extending the data without being destructive of the data (adding an attachment to a message for example). +* **Modify**: This is ran and allows for destructive changes to the data (change any and everything). +* Post**Event**: Is mostly for simple listening and no changes can be made to the data. + +## Generating/Updating Documentation +To update or generate the documentation, please commit your changes first and then in a second commit provide the updated documentation. + +# Engage with us +## Share your story +We’d love to hear about [your experience](https://survey.zohopublic.com/zs/e4BUFG) and potentially feature it on our [Blog](https://rocket.chat/case-studies/?utm_source=github&utm_medium=readme&utm_campaign=community). + +## Subscribe for Updates +Once a month our marketing team releases an email update with news about product releases, company related topics, events and use cases. [Sign Up!](https://rocket.chat/newsletter/?utm_source=github&utm_medium=readme&utm_campaign=community) diff --git a/packages/apps-engine/deno-runtime/.gitignore b/packages/apps-engine/deno-runtime/.gitignore new file mode 100644 index 000000000000..5942ea3a153e --- /dev/null +++ b/packages/apps-engine/deno-runtime/.gitignore @@ -0,0 +1 @@ +.deno/ diff --git a/packages/apps-engine/deno-runtime/AppObjectRegistry.ts b/packages/apps-engine/deno-runtime/AppObjectRegistry.ts new file mode 100644 index 000000000000..9069c17eaac5 --- /dev/null +++ b/packages/apps-engine/deno-runtime/AppObjectRegistry.ts @@ -0,0 +1,26 @@ +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-engine/deno-runtime/acorn-walk.d.ts b/packages/apps-engine/deno-runtime/acorn-walk.d.ts new file mode 100644 index 000000000000..25861f3bce0f --- /dev/null +++ b/packages/apps-engine/deno-runtime/acorn-walk.d.ts @@ -0,0 +1,170 @@ +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-engine/deno-runtime/acorn.d.ts b/packages/apps-engine/deno-runtime/acorn.d.ts new file mode 100644 index 000000000000..0b5bc6b407b2 --- /dev/null +++ b/packages/apps-engine/deno-runtime/acorn.d.ts @@ -0,0 +1,857 @@ +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-engine/deno-runtime/deno.jsonc b/packages/apps-engine/deno-runtime/deno.jsonc new file mode 100644 index 000000000000..231d0924237a --- /dev/null +++ b/packages/apps-engine/deno-runtime/deno.jsonc @@ -0,0 +1,16 @@ +{ + "imports": { + "@rocket.chat/apps-engine/": "./../src/", + "@rocket.chat/ui-kit": "npm:@rocket.chat/ui-kit@^0.31.22", + "@msgpack/msgpack": "npm:@msgpack/msgpack@3.0.0-beta2", + "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" + }, + "tasks": { + "test": "deno test --no-check --allow-read=../../../" + } +} diff --git a/packages/apps-engine/deno-runtime/deno.lock b/packages/apps-engine/deno-runtime/deno.lock new file mode 100644 index 000000000000..86cebf98f63a --- /dev/null +++ b/packages/apps-engine/deno-runtime/deno.lock @@ -0,0 +1,107 @@ +{ + "version": "3", + "packages": { + "specifiers": { + "npm:@msgpack/msgpack@3.0.0-beta2": "npm:@msgpack/msgpack@3.0.0-beta2", + "npm:@rocket.chat/ui-kit@^0.31.22": "npm:@rocket.chat/ui-kit@0.31.25_@rocket.chat+icons@0.32.0", + "npm:acorn-walk@8.2.0": "npm:acorn-walk@8.2.0", + "npm:acorn@8.10.0": "npm:acorn@8.10.0", + "npm:astring@1.8.6": "npm:astring@1.8.6", + "npm:jsonrpc-lite@2.2.0": "npm:jsonrpc-lite@2.2.0", + "npm:stack-trace": "npm:stack-trace@0.0.10", + "npm:stack-trace@0.0.10": "npm:stack-trace@0.0.10", + "npm:uuid@8.3.2": "npm:uuid@8.3.2" + }, + "npm": { + "@msgpack/msgpack@3.0.0-beta2": { + "integrity": "sha512-y+l1PNV0XDyY8sM3YtuMLK5vE3/hkfId+Do8pLo/OPxfxuFAUwcGz3oiiUuV46/aBpwTzZ+mRWVMtlSKbradhw==", + "dependencies": {} + }, + "@rocket.chat/icons@0.32.0": { + "integrity": "sha512-7yhhELKNLb9kUtXCvau0V+iMXraV2bOsxcPjc/ZtLR5VeeIDTeaflqRWGtLroX6f3bE+J1n5qB5zi8A4YXuH2g==", + "dependencies": {} + }, + "@rocket.chat/ui-kit@0.31.25_@rocket.chat+icons@0.32.0": { + "integrity": "sha512-yTgTKDw9SMlJ6p8n0PDO6zSvox/nHYUrwCIvILQeAK6PvTrgSe/u9CvU7ATTYjnQiQ603yEGR6dxjF4euCGdNA==", + "dependencies": { + "@rocket.chat/icons": "@rocket.chat/icons@0.32.0" + } + }, + "acorn-walk@8.2.0": { + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dependencies": {} + }, + "acorn@8.10.0": { + "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "dependencies": {} + }, + "astring@1.8.6": { + "integrity": "sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==", + "dependencies": {} + }, + "jsonrpc-lite@2.2.0": { + "integrity": "sha512-/cbbSxtZWs1O7R4tWqabrCM/t3N8qKUZMAg9IUqpPvUs6UyRvm6pCNYkskyKN/XU0UgffW+NY2ZRr8t0AknX7g==", + "dependencies": {} + }, + "stack-trace@0.0.10": { + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "dependencies": {} + }, + "uuid@8.3.2": { + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dependencies": {} + } + } + }, + "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" + }, + "workspace": { + "dependencies": [ + "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-engine/deno-runtime/handlers/api-handler.ts b/packages/apps-engine/deno-runtime/handlers/api-handler.ts new file mode 100644 index 000000000000..32d30e532fd3 --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/api-handler.ts @@ -0,0 +1,46 @@ +import { Defined, JsonRpcError } from 'jsonrpc-lite'; +import type { IApiEndpoint } from '@rocket.chat/apps-engine/definition/api/IApiEndpoint.ts'; + +import { AppObjectRegistry } from '../AppObjectRegistry.ts'; +import { Logger } from '../lib/logger.ts'; +import { AppAccessorsInstance } from '../lib/accessors/mod.ts'; + +export default async function apiHandler(call: string, params: unknown): Promise { + const [, path, httpMethod] = call.split(':'); + + const endpoint = AppObjectRegistry.get(`api:${path}`); + const logger = AppObjectRegistry.get('logger'); + + 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 [request, endpointInfo] = params as Array; + + logger?.debug(`${path}'s ${call} is being executed...`, request); + + try { + // deno-lint-ignore ban-types + const result = await (method as Function).apply(endpoint, [ + request, + 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-engine/deno-runtime/handlers/app/construct.ts b/packages/apps-engine/deno-runtime/handlers/app/construct.ts new file mode 100644 index 000000000000..798a83d0923c --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/app/construct.ts @@ -0,0 +1,126 @@ +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 { Socket } from 'node:net'; + +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; + Socket.prototype._final = function _final(cb) { + // 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 => { + if (ALLOWED_NATIVE_MODULES.includes(module)) { + return require(`node:${module}`); + } + + 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(params: unknown): Promise { + 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 logger = AppObjectRegistry.get('logger'); + + const app = new appClass(appPackage.info, 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-engine/deno-runtime/handlers/app/handleGetStatus.ts b/packages/apps-engine/deno-runtime/handlers/app/handleGetStatus.ts new file mode 100644 index 000000000000..5428d989812e --- /dev/null +++ b/packages/apps-engine/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-engine/deno-runtime/handlers/app/handleInitialize.ts b/packages/apps-engine/deno-runtime/handlers/app/handleInitialize.ts new file mode 100644 index 000000000000..ad90d3b01e25 --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/app/handleInitialize.ts @@ -0,0 +1,19 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; + +export default async function handleInitialize(): 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(AppAccessorsInstance.getConfigurationExtend(), AppAccessorsInstance.getEnvironmentRead()); + + return true; +} + diff --git a/packages/apps-engine/deno-runtime/handlers/app/handleOnDisable.ts b/packages/apps-engine/deno-runtime/handlers/app/handleOnDisable.ts new file mode 100644 index 000000000000..e66c2414fd0a --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/app/handleOnDisable.ts @@ -0,0 +1,19 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; + +export default async function handleOnDisable(): 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(AppAccessorsInstance.getConfigurationModify()); + + return true; +} + diff --git a/packages/apps-engine/deno-runtime/handlers/app/handleOnEnable.ts b/packages/apps-engine/deno-runtime/handlers/app/handleOnEnable.ts new file mode 100644 index 000000000000..1bdf84476422 --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/app/handleOnEnable.ts @@ -0,0 +1,16 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; + +export default function handleOnEnable(): 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(AppAccessorsInstance.getEnvironmentRead(), AppAccessorsInstance.getConfigurationModify()); +} diff --git a/packages/apps-engine/deno-runtime/handlers/app/handleOnInstall.ts b/packages/apps-engine/deno-runtime/handlers/app/handleOnInstall.ts new file mode 100644 index 000000000000..aebf7628a914 --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/app/handleOnInstall.ts @@ -0,0 +1,30 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; + +export default async function handleOnInstall(params: unknown): Promise { + 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( + context, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + AppAccessorsInstance.getModifier(), + ); + + return true; +} diff --git a/packages/apps-engine/deno-runtime/handlers/app/handleOnPreSettingUpdate.ts b/packages/apps-engine/deno-runtime/handlers/app/handleOnPreSettingUpdate.ts new file mode 100644 index 000000000000..19646fa6704f --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/app/handleOnPreSettingUpdate.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'; + +export default function handleOnPreSettingUpdate(params: unknown): Promise { + 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(setting, AppAccessorsInstance.getConfigurationModify(), AppAccessorsInstance.getReader(), AppAccessorsInstance.getHttp()); +} diff --git a/packages/apps-engine/deno-runtime/handlers/app/handleOnSettingUpdated.ts b/packages/apps-engine/deno-runtime/handlers/app/handleOnSettingUpdated.ts new file mode 100644 index 000000000000..07084bc22425 --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/app/handleOnSettingUpdated.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'; + +export default async function handleOnSettingUpdated(params: unknown): Promise { + 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(setting, AppAccessorsInstance.getConfigurationModify(), AppAccessorsInstance.getReader(), AppAccessorsInstance.getHttp()); + + return true; +} diff --git a/packages/apps-engine/deno-runtime/handlers/app/handleOnUninstall.ts b/packages/apps-engine/deno-runtime/handlers/app/handleOnUninstall.ts new file mode 100644 index 000000000000..865819728ca4 --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/app/handleOnUninstall.ts @@ -0,0 +1,30 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; + +export default async function handleOnUninstall(params: unknown): Promise { + 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( + context, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + AppAccessorsInstance.getModifier(), + ); + + return true; +} diff --git a/packages/apps-engine/deno-runtime/handlers/app/handleOnUpdate.ts b/packages/apps-engine/deno-runtime/handlers/app/handleOnUpdate.ts new file mode 100644 index 000000000000..f21e4f947d5d --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/app/handleOnUpdate.ts @@ -0,0 +1,30 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; + +export default async function handleOnUpdate(params: unknown): Promise { + 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( + context, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + AppAccessorsInstance.getModifier(), + ); + + return true; +} diff --git a/packages/apps-engine/deno-runtime/handlers/app/handleSetStatus.ts b/packages/apps-engine/deno-runtime/handlers/app/handleSetStatus.ts new file mode 100644 index 000000000000..c39ab2a16d62 --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/app/handleSetStatus.ts @@ -0,0 +1,29 @@ +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'; + +const { AppStatus } = require('@rocket.chat/apps-engine/definition/AppStatus.js') as { + AppStatus: typeof _AppStatus; +}; + +export default async function handleSetStatus(params: unknown): Promise { + 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'](status); + + return null; +} diff --git a/packages/apps-engine/deno-runtime/handlers/app/handler.ts b/packages/apps-engine/deno-runtime/handlers/app/handler.ts new file mode 100644 index 000000000000..2a44f34cb7fe --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/app/handler.ts @@ -0,0 +1,112 @@ +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; +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 handleListener from '../listener/handler.ts'; +import handleUIKitInteraction, { uikitInteractions } from '../uikit/handler.ts'; +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import handleOnUpdate from './handleOnUpdate.ts'; + +export default async function handleApp(method: string, params: unknown): Promise { + const [, appMethod] = method.split(':'); + + // We don't want the getStatus method to generate logs, so we handle it separately + if (appMethod === 'getStatus') { + return handleGetStatus(); + } + + // `app` will be undefined if the method here is "app:construct" + const app = AppObjectRegistry.get('app'); + + app?.getLogger().debug(`'${appMethod}' is being called...`); + + if (uikitInteractions.includes(appMethod)) { + return handleUIKitInteraction(appMethod, params).then((result) => { + if (result instanceof JsonRpcError) { + app?.getLogger().debug(`'${appMethod}' was unsuccessful.`, result.message); + } else { + app?.getLogger().debug(`'${appMethod}' was successfully called! The result is:`, result); + } + + return result; + }); + } + + if (appMethod.startsWith('check') || appMethod.startsWith('execute')) { + return handleListener(appMethod, params).then((result) => { + if (result instanceof JsonRpcError) { + app?.getLogger().debug(`'${appMethod}' was unsuccessful.`, result.message); + } else { + app?.getLogger().debug(`'${appMethod}' was successfully called! The result is:`, result); + } + + return result; + }); + } + + try { + let result: Defined | JsonRpcError; + + switch (appMethod) { + case 'construct': + result = await handleConstructApp(params); + break; + case 'initialize': + result = await handleInitialize(); + break; + case 'setStatus': + result = await handleSetStatus(params); + break; + case 'onEnable': + result = await handleOnEnable(); + break; + case 'onDisable': + result = await handleOnDisable(); + break; + case 'onInstall': + result = await handleOnInstall(params); + break; + case 'onUninstall': + result = await handleOnUninstall(params); + break; + case 'onPreSettingUpdate': + result = await handleOnPreSettingUpdate(params); + break; + case 'onSettingUpdated': + result = await handleOnSettingUpdated(params); + break; + case 'onUpdate': + result = await handleOnUpdate(params); + break; + default: + throw new JsonRpcError('Method not found', -32601); + } + + app?.getLogger().debug(`'${appMethod}' was successfully called! The result is:`, result); + + return result; + } 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-engine/deno-runtime/handlers/listener/handler.ts b/packages/apps-engine/deno-runtime/handlers/listener/handler.ts new file mode 100644 index 000000000000..1e6de20538fc --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/listener/handler.ts @@ -0,0 +1,150 @@ +import { Defined, JsonRpcError } from 'jsonrpc-lite'; +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 { 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"; + +const { AppsEngineException } = require('@rocket.chat/apps-engine/definition/exceptions/AppsEngineException.js') as { + AppsEngineException: typeof _AppsEngineException; +}; + +export default async function handleListener(evtInterface: string, params: unknown): Promise { + const app = AppObjectRegistry.get('app'); + + const eventExecutor = app?.[evtInterface as keyof App]; + + if (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(app, 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-engine/deno-runtime/handlers/scheduler-handler.ts b/packages/apps-engine/deno-runtime/handlers/scheduler-handler.ts new file mode 100644 index 000000000000..0145034957f2 --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/scheduler-handler.ts @@ -0,0 +1,51 @@ +import { Defined, JsonRpcError } from 'jsonrpc-lite'; +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; +import type { IProcessor } from '@rocket.chat/apps-engine/definition/scheduler/IProcessor.ts'; + +import { AppObjectRegistry } from '../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../lib/accessors/mod.ts'; + +export default async function handleScheduler(method: string, params: unknown): Promise { + const [, processorId] = method.split(':'); + if (!Array.isArray(params)) { + return JsonRpcError.invalidParams({ message: 'Invalid params' }); + } + + const [context] = params as [Record]; + + const app = AppObjectRegistry.get('app'); + + if (!app) { + return JsonRpcError.internalError({ message: 'App not found' }); + } + + // 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}`, + }); + } + + app.getLogger().debug(`Job processor ${processor.id} is being executed...`); + + try { + await processor.processor( + context, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getModifier(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + ); + + app.getLogger().debug(`Job processor ${processor.id} was successfully executed`); + + return null; + } catch (e) { + app.getLogger().error(e); + app.getLogger().error(`Job processor ${processor.id} was unsuccessful`); + + return JsonRpcError.internalError({ message: e.message }); + } +} diff --git a/packages/apps-engine/deno-runtime/handlers/slashcommand-handler.ts b/packages/apps-engine/deno-runtime/handlers/slashcommand-handler.ts new file mode 100644 index 000000000000..cfebf0d1460e --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/slashcommand-handler.ts @@ -0,0 +1,122 @@ +import { Defined, JsonRpcError } from 'jsonrpc-lite'; + +import type { App } from "@rocket.chat/apps-engine/definition/App.ts"; +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 { AppObjectRegistry } from '../AppObjectRegistry.ts'; +import { AppAccessors, AppAccessorsInstance } from '../lib/accessors/mod.ts'; +import { require } from '../lib/require.ts'; +import createRoom from '../lib/roomFactory.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(call: string, params: unknown): Promise { + 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>; + + // If the command is registered, we're pretty safe to assume the app is not undefined + const app = AppObjectRegistry.get('app')!; + + app.getLogger().debug(`${commandName}'s ${method} is being executed...`, params); + + try { + if (method === 'executor' || method === 'previewer') { + result = await handleExecutor({ AppAccessorsInstance }, command, method, params); + } else if (method === 'executePreviewItem') { + result = await handlePreviewItem({ AppAccessorsInstance }, command, params); + } else { + return new JsonRpcError(`Method ${method} not found on slashcommand ${commandName}`, -32000); + } + + app.getLogger().debug(`${commandName}'s ${method} was successfully executed.`); + } catch (error) { + app.getLogger().debug(`${commandName}'s ${method} was unsuccessful.`); + + return new JsonRpcError(error.message, -32000); + } + + return result; +} + +/** + * @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: { AppAccessorsInstance: AppAccessors }, 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(command, [ + 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: { AppAccessorsInstance: AppAccessors }, 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( + previewItem, + context, + deps.AppAccessorsInstance.getReader(), + deps.AppAccessorsInstance.getModifier(), + deps.AppAccessorsInstance.getHttp(), + deps.AppAccessorsInstance.getPersistence(), + ); +} diff --git a/packages/apps-engine/deno-runtime/handlers/tests/api-handler.test.ts b/packages/apps-engine/deno-runtime/handlers/tests/api-handler.test.ts new file mode 100644 index 000000000000..a3789f755542 --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/tests/api-handler.test.ts @@ -0,0 +1,79 @@ +// deno-lint-ignore-file no-explicit-any +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 { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { assertInstanceOf } from "https://deno.land/std@0.203.0/assert/assert_instance_of.ts"; +import { JsonRpcError } from "jsonrpc-lite"; +import type { IApiEndpoint } from "@rocket.chat/apps-engine/definition/api/IApiEndpoint.ts"; +import apiHandler from "../api-handler.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('api:/test:get', ['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('api:/test:post', ['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(`api:/test:delete`, ['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(`api:/error:get`, ['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(`api:/test:put`, ['request', 'endpointInfo']); + + assertInstanceOf(result, JsonRpcError) + assertObjectMatch(result, { + message: `Method execution error example`, + code: -32000 + }) + }); +}); diff --git a/packages/apps-engine/deno-runtime/handlers/tests/listener-handler.test.ts b/packages/apps-engine/deno-runtime/handlers/tests/listener-handler.test.ts new file mode 100644 index 000000000000..3e3663b06d22 --- /dev/null +++ b/packages/apps-engine/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-engine/deno-runtime/handlers/tests/scheduler-handler.test.ts b/packages/apps-engine/deno-runtime/handlers/tests/scheduler-handler.test.ts new file mode 100644 index 000000000000..681f228bdb3e --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/tests/scheduler-handler.test.ts @@ -0,0 +1,46 @@ +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'; + +describe('handlers > scheduler', () => { + const mockAppAccessors = new AppAccessors(() => + Promise.resolve({ + id: 'mockId', + result: {}, + jsonrpc: '2.0', + serialize: () => '', + }), + ); + + const mockApp = { + getID: () => 'mockApp', + getLogger: () => ({ + debug: () => {}, + error: () => {}, + }), + }; + + 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('scheduler:mockId', [{}]); + + assertEquals(result, null); + }); +}); diff --git a/packages/apps-engine/deno-runtime/handlers/tests/slashcommand-handler.test.ts b/packages/apps-engine/deno-runtime/handlers/tests/slashcommand-handler.test.ts new file mode 100644 index 000000000000..d3da4b132d66 --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/tests/slashcommand-handler.test.ts @@ -0,0 +1,152 @@ +// 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"; + +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'); + + await handleExecutor({ AppAccessorsInstance: mockAppAccessors }, 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'); + + await handleExecutor({ AppAccessorsInstance: mockAppAccessors }, 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'); + + await handlePreviewItem({ AppAccessorsInstance: mockAppAccessors }, 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-engine/deno-runtime/handlers/tests/uikit-handler.test.ts b/packages/apps-engine/deno-runtime/handlers/tests/uikit-handler.test.ts new file mode 100644 index 000000000000..f2293d6c98e0 --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/tests/uikit-handler.test.ts @@ -0,0 +1,99 @@ +// 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 { 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 result = await handleUIKitInteraction('executeBlockActionHandler', [ + { + actionId: 'actionId', + blockId: 'blockId', + value: 'value', + viewId: 'viewId', + }, + ]); + + assertInstanceOf(result, UIKitBlockInteractionContext); + }); + + it('successfully handles a call for "executeViewSubmitHandler"', async () => { + const result = await handleUIKitInteraction('executeViewSubmitHandler', [ + { + viewId: 'viewId', + appId: 'appId', + userId: 'userId', + isAppUser: true, + values: {}, + }, + ]); + + assertInstanceOf(result, UIKitViewSubmitInteractionContext); + }); + + it('successfully handles a call for "executeViewClosedHandler"', async () => { + const result = await handleUIKitInteraction('executeViewClosedHandler', [ + { + viewId: 'viewId', + appId: 'appId', + userId: 'userId', + isAppUser: true, + }, + ]); + + assertInstanceOf(result, UIKitViewCloseInteractionContext); + }); + + it('successfully handles a call for "executeActionButtonHandler"', async () => { + const result = await handleUIKitInteraction('executeActionButtonHandler', [ + { + actionId: 'actionId', + appId: 'appId', + userId: 'userId', + isAppUser: true, + }, + ]); + + assertInstanceOf(result, UIKitActionButtonInteractionContext); + }); + + it('successfully handles a call for "executeLivechatBlockActionHandler"', async () => { + const result = await handleUIKitInteraction('executeLivechatBlockActionHandler', [ + { + actionId: 'actionId', + appId: 'appId', + userId: 'userId', + visitor: {}, + isAppUser: true, + room: {}, + }, + ]); + + assertInstanceOf(result, UIKitLivechatBlockInteractionContext); + }); +}); diff --git a/packages/apps-engine/deno-runtime/handlers/tests/videoconference-handler.test.ts b/packages/apps-engine/deno-runtime/handlers/tests/videoconference-handler.test.ts new file mode 100644 index 000000000000..a32d3175e24d --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/tests/videoconference-handler.test.ts @@ -0,0 +1,122 @@ +// deno-lint-ignore-file no-explicit-any +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 { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import videoconfHandler from '../videoconference-handler.ts'; +import { assertInstanceOf } from 'https://deno.land/std@0.203.0/assert/assert_instance_of.ts'; +import { JsonRpcError } from 'jsonrpc-lite'; + +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('videoconference:test-provider:empty', []); + + 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('videoconference:test-provider:one', ['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('videoconference:test-provider:two', ['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('videoconference:test-provider:three', ['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('videoconference:test-provider:error', []); + + 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(`videoconference:${providerName}:method`, []); + + 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(`videoconference:${providerName}:${methodName}`, []); + + 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-engine/deno-runtime/handlers/uikit/handler.ts b/packages/apps-engine/deno-runtime/handlers/uikit/handler.ts new file mode 100644 index 000000000000..5a418f242ad9 --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/uikit/handler.ts @@ -0,0 +1,82 @@ +import { Defined, JsonRpcError } from 'jsonrpc-lite'; +import type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import { require } from '../../lib/require.ts'; +import { AppObjectRegistry } from '../../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../../lib/accessors/mod.ts'; + +export const uikitInteractions = [ + 'executeBlockActionHandler', + 'executeViewSubmitHandler', + 'executeViewClosedHandler', + 'executeActionButtonHandler', + 'executeLivechatBlockActionHandler', +]; + +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(method: string, params: unknown): Promise { + if (!uikitInteractions.includes(method)) { + 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( + app, + context, + AppAccessorsInstance.getReader(), + AppAccessorsInstance.getHttp(), + AppAccessorsInstance.getPersistence(), + AppAccessorsInstance.getModifier(), + ); + } catch (e) { + return JsonRpcError.internalError({ message: e.message }); + } +} diff --git a/packages/apps-engine/deno-runtime/handlers/videoconference-handler.ts b/packages/apps-engine/deno-runtime/handlers/videoconference-handler.ts new file mode 100644 index 000000000000..0347519db2e6 --- /dev/null +++ b/packages/apps-engine/deno-runtime/handlers/videoconference-handler.ts @@ -0,0 +1,49 @@ +import { Defined, JsonRpcError } from 'jsonrpc-lite'; +import type { IVideoConfProvider } from '@rocket.chat/apps-engine/definition/videoConfProviders/IVideoConfProvider.ts'; + +import { AppObjectRegistry } from '../AppObjectRegistry.ts'; +import { AppAccessorsInstance } from '../lib/accessors/mod.ts'; +import { Logger } from '../lib/logger.ts'; + +export default async function videoConferenceHandler(call: string, params: unknown): Promise { + const [, providerName, methodName] = call.split(':'); + + const provider = AppObjectRegistry.get(`videoConfProvider:${providerName}`); + const logger = AppObjectRegistry.get('logger'); + + 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(provider, [ + ...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-engine/deno-runtime/lib/accessors/builders/BlockBuilder.ts b/packages/apps-engine/deno-runtime/lib/accessors/builders/BlockBuilder.ts new file mode 100644 index 000000000000..e1602860fe97 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/builders/BlockBuilder.ts @@ -0,0 +1,216 @@ +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-engine/deno-runtime/lib/accessors/builders/DiscussionBuilder.ts b/packages/apps-engine/deno-runtime/lib/accessors/builders/DiscussionBuilder.ts new file mode 100644 index 000000000000..e2c2dc021438 --- /dev/null +++ b/packages/apps-engine/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-engine/deno-runtime/lib/accessors/builders/LivechatMessageBuilder.ts b/packages/apps-engine/deno-runtime/lib/accessors/builders/LivechatMessageBuilder.ts new file mode 100644 index 000000000000..a12024ab7b5d --- /dev/null +++ b/packages/apps-engine/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-engine/deno-runtime/lib/accessors/builders/MessageBuilder.ts b/packages/apps-engine/deno-runtime/lib/accessors/builders/MessageBuilder.ts new file mode 100644 index 000000000000..98cd919f7b00 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/builders/MessageBuilder.ts @@ -0,0 +1,232 @@ +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; + + 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(); + + return this as IMessageBuilder; + } + + public setThreadId(threadId: string): IMessageBuilder { + this.msg.threadId = threadId; + + return this as IMessageBuilder; + } + + public getThreadId(): string { + return this.msg.threadId!; + } + + public setRoom(room: IRoom): IMessageBuilder { + this.msg.room = room; + return this as IMessageBuilder; + } + + public getRoom(): IRoom { + return this.msg.room; + } + + public setSender(sender: IUser): IMessageBuilder { + this.msg.sender = sender; + return this as IMessageBuilder; + } + + public getSender(): IUser { + return this.msg.sender; + } + + public setText(text: string): IMessageBuilder { + this.msg.text = text; + return this as IMessageBuilder; + } + + public getText(): string { + return this.msg.text!; + } + + public setEmojiAvatar(emoji: string): IMessageBuilder { + this.msg.emoji = emoji; + return this as IMessageBuilder; + } + + public getEmojiAvatar(): string { + return this.msg.emoji!; + } + + public setAvatarUrl(avatarUrl: string): IMessageBuilder { + this.msg.avatarUrl = avatarUrl; + return this as IMessageBuilder; + } + + public getAvatarUrl(): string { + return this.msg.avatarUrl!; + } + + public setUsernameAlias(alias: string): IMessageBuilder { + this.msg.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); + return this as IMessageBuilder; + } + + public setAttachments(attachments: Array): IMessageBuilder { + this.msg.attachments = attachments; + return this as IMessageBuilder; + } + + 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 as IMessageBuilder; + } + + 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 as IMessageBuilder; + } + + public setEditor(user: IUser): IMessageBuilder { + this.msg.editor = user; + return this as IMessageBuilder; + } + + public getEditor(): IUser { + return this.msg.editor; + } + + public setGroupable(groupable: boolean): IMessageBuilder { + this.msg.groupable = groupable; + return this as IMessageBuilder; + } + + public getGroupable(): boolean { + return this.msg.groupable!; + } + + public setParseUrls(parseUrls: boolean): IMessageBuilder { + this.msg.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) { + if (blocks instanceof BlockBuilder) { + this.msg.blocks = blocks.getBlocks(); + } else { + this.msg.blocks = blocks; + } + + 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; + + return this as IMessageBuilder; + } +} diff --git a/packages/apps-engine/deno-runtime/lib/accessors/builders/RoomBuilder.ts b/packages/apps-engine/deno-runtime/lib/accessors/builders/RoomBuilder.ts new file mode 100644 index 000000000000..38983475162d --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/builders/RoomBuilder.ts @@ -0,0 +1,163 @@ +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; + + 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-engine/deno-runtime/lib/accessors/builders/UserBuilder.ts b/packages/apps-engine/deno-runtime/lib/accessors/builders/UserBuilder.ts new file mode 100644 index 000000000000..01c11a13f7a3 --- /dev/null +++ b/packages/apps-engine/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-engine/deno-runtime/lib/accessors/builders/VideoConferenceBuilder.ts b/packages/apps-engine/deno-runtime/lib/accessors/builders/VideoConferenceBuilder.ts new file mode 100644 index 000000000000..e617cdddf154 --- /dev/null +++ b/packages/apps-engine/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-engine/deno-runtime/lib/accessors/extenders/HttpExtender.ts b/packages/apps-engine/deno-runtime/lib/accessors/extenders/HttpExtender.ts new file mode 100644 index 000000000000..c323d385de9d --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/extenders/HttpExtender.ts @@ -0,0 +1,62 @@ +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-engine/deno-runtime/lib/accessors/extenders/MessageExtender.ts b/packages/apps-engine/deno-runtime/lib/accessors/extenders/MessageExtender.ts new file mode 100644 index 000000000000..abf1629c760a --- /dev/null +++ b/packages/apps-engine/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-engine/deno-runtime/lib/accessors/extenders/RoomExtender.ts b/packages/apps-engine/deno-runtime/lib/accessors/extenders/RoomExtender.ts new file mode 100644 index 000000000000..6509d5dae90e --- /dev/null +++ b/packages/apps-engine/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-engine/deno-runtime/lib/accessors/extenders/VideoConferenceExtend.ts b/packages/apps-engine/deno-runtime/lib/accessors/extenders/VideoConferenceExtend.ts new file mode 100644 index 000000000000..9616bf619067 --- /dev/null +++ b/packages/apps-engine/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-engine/deno-runtime/lib/accessors/http.ts b/packages/apps-engine/deno-runtime/lib/accessors/http.ts new file mode 100644 index 000000000000..f55838e60186 --- /dev/null +++ b/packages/apps-engine/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"; + +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, + }], + }) + + 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-engine/deno-runtime/lib/accessors/mod.ts b/packages/apps-engine/deno-runtime/lib/accessors/mod.ts new file mode 100644 index 000000000000..e71f014421ab --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/mod.ts @@ -0,0 +1,302 @@ +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 { 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'; + +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 new Error(err.error) }); + }, + }, + ) 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); + }, + }, + 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'), + }; + } + + 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-engine/deno-runtime/lib/accessors/modify/ModifyCreator.ts b/packages/apps-engine/deno-runtime/lib/accessors/modify/ModifyCreator.ts new file mode 100644 index 000000000000..06797551a621 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/modify/ModifyCreator.ts @@ -0,0 +1,344 @@ +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 { 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 { 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'; + +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 () => Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); + } + + if (prop === 'toJSON') { + return () => ({}); + } + + return (...params: unknown[]) => + this.senderFn({ + method: `accessor:getModifier:getCreator:getLivechatCreator:${prop}`, + params, + }) + .then((response) => response.result) + .catch((err) => { + throw new Error(err.error); + }); + }, + }, + ) 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 new Error(err.error); + }), + }, + ) 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 new Error(err.error); + }), + } + ) + } + + 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'], + }); + + 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')], + }); + + 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')], + }); + + 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')], + }); + + 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')], + }); + + 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')], + }); + + 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')], + }); + + return String(response.result); + } +} diff --git a/packages/apps-engine/deno-runtime/lib/accessors/modify/ModifyExtender.ts b/packages/apps-engine/deno-runtime/lib/accessors/modify/ModifyExtender.ts new file mode 100644 index 000000000000..c0793d015c64 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/modify/ModifyExtender.ts @@ -0,0 +1,93 @@ +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'; + +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')], + }); + + 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')], + }); + + 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')], + }); + + 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')], + }); + break; + case RocketChatAssociationModel.ROOM: + await this.senderFn({ + method: 'bridges:getRoomBridge:doUpdate', + params: [ + (extender as IRoomExtender).getRoom(), + (extender as IRoomExtender).getUsernamesOfMembersBeingAdded(), + AppObjectRegistry.get('id'), + ], + }); + break; + case RocketChatAssociationModel.VIDEO_CONFERENCE: + await this.senderFn({ + method: 'bridges:getVideoConferenceBridge:doUpdate', + params: [(extender as IVideoConferenceExtender).getVideoConference(), AppObjectRegistry.get('id')], + }); + break; + default: + throw new Error('Invalid extender passed to the ModifyExtender.finish function.'); + } + } +} diff --git a/packages/apps-engine/deno-runtime/lib/accessors/modify/ModifyUpdater.ts b/packages/apps-engine/deno-runtime/lib/accessors/modify/ModifyUpdater.ts new file mode 100644 index 000000000000..8befe7bfa983 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/modify/ModifyUpdater.ts @@ -0,0 +1,153 @@ +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 { 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'; + +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 { + constructor(private readonly senderFn: typeof Messenger.sendRequest) { } + + public getLivechatUpdater(): ILivechatUpdater { + return new Proxy( + { __kind: 'getLivechatUpdater' }, + { + get: + (_target: unknown, prop: string) => + (...params: unknown[]) => + prop === 'toJSON' + ? {} + : this.senderFn({ + method: `accessor:getModifier:getUpdater:getLivechatUpdater:${prop}`, + params, + }) + .then((response) => response.result) + .catch((err) => { + throw new Error(err.error); + }), + }, + ) as ILivechatUpdater; + } + + public getUserUpdater(): IUserUpdater { + return new Proxy( + { __kind: 'getUserUpdater' }, + { + get: + (_target: unknown, prop: string) => + (...params: unknown[]) => + prop === 'toJSON' + ? {} + : this.senderFn({ + method: `accessor:getModifier:getUpdater:getUserUpdater:${prop}`, + params, + }) + .then((response) => response.result) + .catch((err) => { + throw new Error(err.error); + }), + }, + ) as IUserUpdater; + } + + public async message(messageId: string, _updater: IUser): Promise { + const response = await this.senderFn({ + method: 'bridges:getMessageBridge:doGetById', + params: [messageId, AppObjectRegistry.get('id')], + }); + + return new MessageBuilder(response.result as IMessage); + } + + public async room(roomId: string, _updater: IUser): Promise { + const response = await this.senderFn({ + method: 'bridges:getRoomBridge:doGetById', + params: [roomId, AppObjectRegistry.get('id')], + }); + + return new RoomBuilder(response.result as IRoom); + } + + public finish(builder: IMessageBuilder | IRoomBuilder): Promise { + switch (builder.kind) { + case RocketChatAssociationModel.MESSAGE: + return this._finishMessage(builder as IMessageBuilder); + case RocketChatAssociationModel.ROOM: + return this._finishRoom(builder as IRoomBuilder); + default: + throw new Error('Invalid builder passed to the ModifyUpdater.finish function.'); + } + } + + private async _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, AppObjectRegistry.get('id') || ''); + } + + await this.senderFn({ + method: 'bridges:getMessageBridge:doUpdate', + params: [result, AppObjectRegistry.get('id')], + }); + } + + private async _finishRoom(builder: IRoomBuilder): Promise { + const result = builder.getRoom(); + + if (!result.id) { + throw new Error("Invalid room, can't 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 || !result.creator.id) { + throw new Error('Invalid creator assigned to the room.'); + } + + 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.'); + } + + await this.senderFn({ + method: 'bridges:getRoomBridge:doUpdate', + params: [result, builder.getMembersToBeAddedUsernames(), AppObjectRegistry.get('id')], + }); + } +} diff --git a/packages/apps-engine/deno-runtime/lib/accessors/notifier.ts b/packages/apps-engine/deno-runtime/lib/accessors/notifier.ts new file mode 100644 index 000000000000..625d68c1039f --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/notifier.ts @@ -0,0 +1,75 @@ +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"; + +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, + }); + } + + private async getAppUser(): Promise { + const response = await this.senderFn({ method: 'bridges:getUserBridge:doGetAppUser', params: [AppObjectRegistry.get('id')] }); + return response.result; + } +} diff --git a/packages/apps-engine/deno-runtime/lib/accessors/tests/AppAccessors.test.ts b/packages/apps-engine/deno-runtime/lib/accessors/tests/AppAccessors.test.ts new file mode 100644 index 000000000000..04592eadd3db --- /dev/null +++ b/packages/apps-engine/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-engine/deno-runtime/lib/accessors/tests/ModifyCreator.test.ts b/packages/apps-engine/deno-runtime/lib/accessors/tests/ModifyCreator.test.ts new file mode 100644 index 000000000000..5927869e6c84 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/tests/ModifyCreator.test.ts @@ -0,0 +1,106 @@ +// 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 } 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}"`); + }); +}); diff --git a/packages/apps-engine/deno-runtime/lib/accessors/tests/ModifyExtender.test.ts b/packages/apps-engine/deno-runtime/lib/accessors/tests/ModifyExtender.test.ts new file mode 100644 index 000000000000..1ec056e02ce3 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/tests/ModifyExtender.test.ts @@ -0,0 +1,120 @@ +// 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 { AppObjectRegistry } from '../../../AppObjectRegistry.ts'; +import { ModifyExtender } from '../modify/ModifyExtender.ts'; + +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(); + }); +}); diff --git a/packages/apps-engine/deno-runtime/lib/accessors/tests/ModifyUpdater.test.ts b/packages/apps-engine/deno-runtime/lib/accessors/tests/ModifyUpdater.test.ts new file mode 100644 index 000000000000..313275c967cf --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/accessors/tests/ModifyUpdater.test.ts @@ -0,0 +1,128 @@ +// 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 { assertEquals } from 'https://deno.land/std@0.203.0/assert/mod.ts'; + +import { AppObjectRegistry } from '../../../AppObjectRegistry.ts'; +import { ModifyUpdater } from '../modify/ModifyUpdater.ts'; + +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: [messageBuilder.getMessage(), '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); + + 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: [roomBuilder.getRoom(), 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!'], + }); + }); +}); diff --git a/packages/apps-engine/deno-runtime/lib/ast/mod.ts b/packages/apps-engine/deno-runtime/lib/ast/mod.ts new file mode 100644 index 000000000000..09f4994f2bad --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/ast/mod.ts @@ -0,0 +1,64 @@ +import { generate } from "astring"; +// @deno-types="../../acorn.d.ts" +import { Program, parse } 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, { + ecmaVersion: 2017, + // 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-engine/deno-runtime/lib/ast/operations.ts b/packages/apps-engine/deno-runtime/lib/ast/operations.ts new file mode 100644 index 000000000000..d3886348041f --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/ast/operations.ts @@ -0,0 +1,239 @@ +// @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-engine/deno-runtime/lib/ast/tests/data/ast_blocks.ts b/packages/apps-engine/deno-runtime/lib/ast/tests/data/ast_blocks.ts new file mode 100644 index 000000000000..330d2bf52620 --- /dev/null +++ b/packages/apps-engine/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-engine/deno-runtime/lib/ast/tests/operations.test.ts b/packages/apps-engine/deno-runtime/lib/ast/tests/operations.test.ts new file mode 100644 index 000000000000..2b00c271f730 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/ast/tests/operations.test.ts @@ -0,0 +1,245 @@ +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 { WalkerState, asyncifyScope, buildFixModifiedFunctionsOperation, checkReassignmentOfModifiedIdentifiers, getFunctionIdentifier, 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-engine/deno-runtime/lib/codec.ts b/packages/apps-engine/deno-runtime/lib/codec.ts new file mode 100644 index 000000000000..288db46169dc --- /dev/null +++ b/packages/apps-engine/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-engine/deno-runtime/lib/logger.ts b/packages/apps-engine/deno-runtime/lib/logger.ts new file mode 100644 index 000000000000..ea2701c70230 --- /dev/null +++ b/packages/apps-engine/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-engine/deno-runtime/lib/messenger.ts b/packages/apps-engine/deno-runtime/lib/messenger.ts new file mode 100644 index 000000000000..1e9ffe05c6c5 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/messenger.ts @@ -0,0 +1,199 @@ +import { writeAll } from "https://deno.land/std@0.216.0/io/write_all.ts"; + +import * as jsonrpc from 'jsonrpc-lite'; + +import { AppObjectRegistry } from '../AppObjectRegistry.ts'; +import type { Logger } from './logger.ts'; +import { encoder } from './codec.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(); + } +}); + +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): Promise { + const logger = AppObjectRegistry.get('logger'); + + 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): Promise { + const payload = { value: result } as Record; + const logger = AppObjectRegistry.get('logger'); + + 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-engine/deno-runtime/lib/require.ts b/packages/apps-engine/deno-runtime/lib/require.ts new file mode 100644 index 000000000000..3288ecf67ffa --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/require.ts @@ -0,0 +1,14 @@ +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')) { + mod = import.meta.resolve(mod).replace('file://', '').replace('src/', ''); + } + + return _require(mod); +} diff --git a/packages/apps-engine/deno-runtime/lib/room.ts b/packages/apps-engine/deno-runtime/lib/room.ts new file mode 100644 index 000000000000..b7423cdd31ff --- /dev/null +++ b/packages/apps-engine/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-engine/deno-runtime/lib/roomFactory.ts b/packages/apps-engine/deno-runtime/lib/roomFactory.ts new file mode 100644 index 000000000000..8c270eeb86b9 --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/roomFactory.ts @@ -0,0 +1,27 @@ +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 { JsonRpcError } from "jsonrpc-lite"; + +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 new JsonRpcError(`Error getting usernames of room: ${err}`, -32000); + }) + }, + }), + }), +}); + +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-engine/deno-runtime/lib/sanitizeDeprecatedUsage.ts b/packages/apps-engine/deno-runtime/lib/sanitizeDeprecatedUsage.ts new file mode 100644 index 000000000000..91cf6587e741 --- /dev/null +++ b/packages/apps-engine/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-engine/deno-runtime/lib/tests/logger.test.ts b/packages/apps-engine/deno-runtime/lib/tests/logger.test.ts new file mode 100644 index 000000000000..f69a728e79af --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/tests/logger.test.ts @@ -0,0 +1,111 @@ +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-engine/deno-runtime/lib/tests/messenger.test.ts b/packages/apps-engine/deno-runtime/lib/tests/messenger.test.ts new file mode 100644 index 000000000000..9b4f128380bc --- /dev/null +++ b/packages/apps-engine/deno-runtime/lib/tests/messenger.test.ts @@ -0,0 +1,96 @@ +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 { Logger } from '../logger.ts'; + +describe('Messenger', () => { + beforeEach(() => { + AppObjectRegistry.clear(); + AppObjectRegistry.set('logger', new Logger('test')); + AppObjectRegistry.set('id', 'test'); + Messenger.Transport.selectTransport('noop'); + }); + + afterAll(() => { + AppObjectRegistry.clear(); + Messenger.Transport.selectTransport('stdout'); + }); + + it('should add logs to success responses', async () => { + const theSpy = spy(Messenger.Queue, 'enqueue'); + + const logger = AppObjectRegistry.get('logger') as Logger; + + logger.info('test'); + + await Messenger.successResponse({ id: 'test', result: 'test' }); + + assertEquals(theSpy.calls.length, 1); + + const [responseArgument] = theSpy.calls[0].args; + + assertObjectMatch(responseArgument, { + 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 = AppObjectRegistry.get('logger') as Logger; + + logger.info('test'); + + await Messenger.errorResponse({ id: 'test', error: { code: -32000, message: 'test' } }); + + assertEquals(theSpy.calls.length, 1); + + const [responseArgument] = theSpy.calls[0].args; + + assertObjectMatch(responseArgument, { + 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-engine/deno-runtime/main.ts b/packages/apps-engine/deno-runtime/main.ts new file mode 100644 index 000000000000..09be5258ecd0 --- /dev/null +++ b/packages/apps-engine/deno-runtime/main.ts @@ -0,0 +1,129 @@ +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 type { App } from '@rocket.chat/apps-engine/definition/App.ts'; + +import * as Messenger from './lib/messenger.ts'; +import { decoder } from './lib/codec.ts'; +import { AppObjectRegistry } from './AppObjectRegistry.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'; + +type Handlers = { + app: typeof handleApp; + api: typeof apiHandler; + slashcommand: typeof slashcommandHandler; + videoconference: typeof videoConferenceHandler; + scheduler: typeof handleScheduler; + ping: (method: string, params: unknown) => 'pong'; +}; + +const COMMAND_PING = '_zPING'; + +async function requestRouter({ type, payload }: Messenger.JsonRpcRequest): Promise { + const methodHandlers: Handlers = { + app: handleApp, + api: apiHandler, + slashcommand: slashcommandHandler, + videoconference: videoConferenceHandler, + scheduler: handleScheduler, + ping: (_method, _params) => 'pong', + }; + + // We're not handling notifications at the moment + if (type === 'notification') { + return Messenger.sendInvalidRequestError(); + } + + const { id, method, params } = payload; + + const logger = new Logger(method); + AppObjectRegistry.set('logger', logger); + + const app = AppObjectRegistry.get('app'); + + if (app) { + // Same logic as applied in the ProxiedApp class previously + (app as unknown as Record).logger = 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, + }); + } + + const result = await handler(method, params); + + if (result instanceof JsonRpcError) { + return Messenger.errorResponse({ id, error: result }); + } + + return Messenger.successResponse({ id, result }); +} + +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) { + Messenger.pongResponse(); + 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(); + } + } + } +} + +main(); diff --git a/packages/apps-engine/package.json b/packages/apps-engine/package.json new file mode 100644 index 000000000000..c229ff447000 --- /dev/null +++ b/packages/apps-engine/package.json @@ -0,0 +1,132 @@ +{ + "name": "@rocket.chat/apps-engine", + "version": "1.47.0-alpha", + "description": "The engine code for the Rocket.Chat Apps which manages, runs, translates, coordinates and all of that.", + "main": "index", + "typings": "index", + "scripts": { + "start": "run-s .:build:clean .:build:watch", + "testunit": "run-p .:test:node .:test:deno", + ".:test:node": "NODE_ENV=test ts-node ./tests/runner.ts", + ".:test:deno": "cd deno-runtime && deno task test", + "lint": "run-p .:lint:eslint .:lint:deno", + ".:lint:eslint": "eslint .", + ".:lint:deno": "deno lint --ignore=deno-runtime/.deno deno-runtime/", + "fix-lint": "eslint . --fix", + "build": "run-s .:build:clean .:build:default .:build:deno-cache", + ".:build:clean": "rimraf client definition server", + ".:build:default": "tsc -p tsconfig.json", + ".:build:deno-cache": "node scripts/deno-cache.js", + ".:build:watch": "yarn .:build:default --watch", + "typecheck": "tsc -p tsconfig.json --noEmit", + "bundle": "node scripts/bundle.js", + "gen-doc": "typedoc", + "prepack": "yarn bundle" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/RocketChat/Rocket.Chat.Apps-engine.git" + }, + "keywords": [ + "rocket.chat", + "team chat", + "apps engine" + ], + "files": [ + "client/**", + "definition/**", + "deno-runtime/**", + "lib/**", + "scripts/**", + "server/**" + ], + "publishConfig": { + "access": "public" + }, + "author": { + "name": "Rocket.Chat", + "url": "https://rocket.chat/" + }, + "contributors": [ + { + "name": "Bradley Hilton", + "email": "bradley.hilton@rocket.chat" + }, + { + "name": "Rodrigo Nascimento", + "email": "rodrigo.nascimento@rocket.chat" + }, + { + "name": "Douglas Gubert", + "email": "douglas.gubert@rocket.chat" + } + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/RocketChat/Rocket.Chat.Apps-engine/issues" + }, + "homepage": "https://github.com/RocketChat/Rocket.Chat.Apps-engine#readme", + "devDependencies": { + "@rocket.chat/eslint-config": "workspace:~", + "@rocket.chat/ui-kit": "workspace:~", + "@types/adm-zip": "^0.5.0", + "@types/debug": "^4.1.12", + "@types/lodash.clonedeep": "^4.5.7", + "@types/nedb": "^1.8.12", + "@types/node": "^18.0.0", + "@types/semver": "^5.5.0", + "@types/stack-trace": "0.0.29", + "@types/uuid": "~8.3.4", + "@typescript-eslint/eslint-plugin": "~5.60.1", + "@typescript-eslint/parser": "~5.60.1", + "alsatian": "^2.4.0", + "browserify": "^16.5.2", + "eslint": "~8.45.0", + "nedb": "^1.8.0", + "npm-run-all": "^4.1.5", + "nyc": "^14.1.1", + "rimraf": "^6.0.1", + "tap-bark": "^1.0.0", + "ts-node": "^6.2.0", + "typedoc": "~0.24.8", + "typescript": "~5.1.6", + "uglify-es": "^3.3.9" + }, + "dependencies": { + "@msgpack/msgpack": "3.0.0-beta2", + "adm-zip": "^0.5.9", + "cryptiles": "^4.1.3", + "debug": "^4.3.4", + "esbuild": "^0.20.2", + "jose": "^4.11.1", + "jsonrpc-lite": "^2.2.0", + "lodash.clonedeep": "^4.5.0", + "semver": "^5.7.1", + "stack-trace": "0.0.10", + "uuid": "~8.3.2" + }, + "peerDependencies": { + "@rocket.chat/ui-kit": "workspace:^" + }, + "nyc": { + "include": [ + "src/*.ts", + "src/server/**/*.ts" + ], + "extension": [ + ".ts" + ], + "reporter": [ + "lcov", + "json", + "html" + ], + "all": true + }, + "volta": { + "extends": "../../package.json" + }, + "installConfig": { + "hoistingLimits": "workspaces" + } +} diff --git a/packages/apps-engine/scripts/bundle.js b/packages/apps-engine/scripts/bundle.js new file mode 100644 index 000000000000..a7f8932bec12 --- /dev/null +++ b/packages/apps-engine/scripts/bundle.js @@ -0,0 +1,35 @@ +const fs = require('fs'); +const path = require('path'); +const { Readable } = require('stream'); + +const browserify = require('browserify'); +const { minify } = require('uglify-es'); + +const targetDir = path.join(__dirname, '..', 'client'); + +// browserify accepts either a stream or a file path +const glue = new Readable({ + read() { + console.log('read'); + this.push("window.AppsEngineUIClient = require('./AppsEngineUIClient').AppsEngineUIClient;"); + this.push(null); + }, +}); + +async function main() { + const bundle = await new Promise((resolve, reject) => + browserify(glue, { + basedir: targetDir, + }).bundle((err, bundle) => { + if (err) return reject(err); + + resolve(bundle.toString()); + }), + ); + + const result = minify(bundle); + + fs.writeFileSync(path.join(targetDir, 'AppsEngineUIClient.min.js'), result.code); +} + +main(); diff --git a/packages/apps-engine/scripts/deno-cache.js b/packages/apps-engine/scripts/deno-cache.js new file mode 100644 index 000000000000..acf2f4b977b4 --- /dev/null +++ b/packages/apps-engine/scripts/deno-cache.js @@ -0,0 +1,25 @@ +const childProcess = require('child_process'); +const path = require('path'); + +try { + childProcess.execSync('deno info'); +} catch (e) { + console.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.\n', + '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', + ); + 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'); + +childProcess.execSync('deno cache main.ts', { + cwd: denoRuntimePath, + env: { + DENO_DIR, + PATH: process.env.PATH, + }, + stdio: 'inherit', +}); diff --git a/packages/apps-engine/src/client/AppClientManager.ts b/packages/apps-engine/src/client/AppClientManager.ts new file mode 100644 index 000000000000..5d409f148f5f --- /dev/null +++ b/packages/apps-engine/src/client/AppClientManager.ts @@ -0,0 +1,28 @@ +import type { IAppInfo } from '../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-engine/src/client/AppServerCommunicator.ts b/packages/apps-engine/src/client/AppServerCommunicator.ts new file mode 100644 index 000000000000..fae400bc7ff7 --- /dev/null +++ b/packages/apps-engine/src/client/AppServerCommunicator.ts @@ -0,0 +1,16 @@ +import type { IAppInfo } from '../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-engine/src/client/AppsEngineUIClient.ts b/packages/apps-engine/src/client/AppsEngineUIClient.ts new file mode 100644 index 000000000000..620be5e21d01 --- /dev/null +++ b/packages/apps-engine/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) => any; + + 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?.hasOwnProperty(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-engine/src/client/AppsEngineUIHost.ts b/packages/apps-engine/src/client/AppsEngineUIHost.ts new file mode 100644 index 000000000000..02f82f236ed2 --- /dev/null +++ b/packages/apps-engine/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', async ({ data, source }) => { + if (!data?.hasOwnProperty(MESSAGE_ID)) { + return; + } + + this.responseDestination = source as Window; + + const { + [MESSAGE_ID]: { action, id }, + } = data; + + switch (action) { + case AppsEngineUIMethods.GET_USER_INFO: + this.handleAction(action, id, await this.getClientUserInfo()); + break; + case AppsEngineUIMethods.GET_ROOM_INFO: + this.handleAction(action, id, await this.getClientRoomInfo()); + 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-engine/src/client/constants/index.ts b/packages/apps-engine/src/client/constants/index.ts new file mode 100644 index 000000000000..bd7f2e779ca1 --- /dev/null +++ b/packages/apps-engine/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-engine/src/client/definition/AppsEngineUIMethods.ts b/packages/apps-engine/src/client/definition/AppsEngineUIMethods.ts new file mode 100644 index 000000000000..df150f9ce62b --- /dev/null +++ b/packages/apps-engine/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-engine/src/client/definition/IAppsEngineUIResponse.ts b/packages/apps-engine/src/client/definition/IAppsEngineUIResponse.ts new file mode 100644 index 000000000000..dff7289226a4 --- /dev/null +++ b/packages/apps-engine/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-engine/src/client/definition/IExternalComponentRoomInfo.ts b/packages/apps-engine/src/client/definition/IExternalComponentRoomInfo.ts new file mode 100644 index 000000000000..32ca449e9650 --- /dev/null +++ b/packages/apps-engine/src/client/definition/IExternalComponentRoomInfo.ts @@ -0,0 +1,16 @@ +import type { IRoom } from '../../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-engine/src/client/definition/IExternalComponentUserInfo.ts b/packages/apps-engine/src/client/definition/IExternalComponentUserInfo.ts new file mode 100644 index 000000000000..9212f5b39876 --- /dev/null +++ b/packages/apps-engine/src/client/definition/IExternalComponentUserInfo.ts @@ -0,0 +1,14 @@ +import type { IUser } from '../../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-engine/src/client/definition/index.ts b/packages/apps-engine/src/client/definition/index.ts new file mode 100644 index 000000000000..70a1fe884a6a --- /dev/null +++ b/packages/apps-engine/src/client/definition/index.ts @@ -0,0 +1,4 @@ +export * from './AppsEngineUIMethods'; +export * from './IExternalComponentUserInfo'; +export * from './IExternalComponentRoomInfo'; +export * from './IAppsEngineUIResponse'; diff --git a/packages/apps-engine/src/client/index.ts b/packages/apps-engine/src/client/index.ts new file mode 100644 index 000000000000..2ebfee0264d2 --- /dev/null +++ b/packages/apps-engine/src/client/index.ts @@ -0,0 +1,4 @@ +import { AppClientManager } from './AppClientManager'; +import { AppServerCommunicator } from './AppServerCommunicator'; + +export { AppClientManager, AppServerCommunicator }; diff --git a/packages/apps-engine/src/client/utils/index.ts b/packages/apps-engine/src/client/utils/index.ts new file mode 100644 index 000000000000..f5e851e7d50f --- /dev/null +++ b/packages/apps-engine/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)); +} diff --git a/packages/apps-engine/src/definition/App.ts b/packages/apps-engine/src/definition/App.ts new file mode 100644 index 000000000000..0044f35134b6 --- /dev/null +++ b/packages/apps-engine/src/definition/App.ts @@ -0,0 +1,236 @@ +import { AppStatus } from './AppStatus'; +import type { IApp } from './IApp'; +import type { + IAppAccessors, + IAppInstallationContext, + IAppUninstallationContext, + IConfigurationExtend, + IConfigurationModify, + IEnvironmentRead, + IHttp, + ILogger, + IModify, + IPersistence, + IRead, + IAppUpdateContext, +} from './accessors'; +import type { IAppAuthorInfo } from './metadata/IAppAuthorInfo'; +import type { IAppInfo } from './metadata/IAppInfo'; +import type { ISetting } from './settings'; +import type { ISettingUpdateContext } from './settings/ISettingUpdateContext'; + +export abstract class App implements IApp { + private status: AppStatus = AppStatus.UNKNOWN; + + /** + * Create a new App, this is called whenever the server starts up and initiates the Apps. + * Note, your implementation of this class should call `super(name, id, version)` so we have it. + * Also, please use the `initialize()` method to do items instead of the constructor as the constructor + * *might* be called more than once but the `initialize()` will only be called once. + */ + public constructor(private readonly info: IAppInfo, private readonly logger: ILogger, private readonly accessors?: IAppAccessors) { + this.logger.debug( + `Constructed the App ${this.info.name} (${this.info.id})`, + `v${this.info.version} which depends on the API v${this.info.requiredApiVersion}!`, + `Created by ${this.info.author.name}`, + ); + + this.setStatus(AppStatus.CONSTRUCTED); + } + + public async getStatus(): Promise { + return this.status; + } + + /** + * Get the name of this App. + * + * @return {string} the name + */ + public getName(): string { + return this.info.name; + } + + /** + * Gets the sluggified name of this App. + * + * @return {string} the name slugged + */ + public getNameSlug(): string { + return this.info.nameSlug; + } + + /** + * Gets the username of this App's app user. + * + * @return {string} the username of the app user + * + * @deprecated This method will be removed in the next major version. + * Please use read.getUserReader().getAppUser() instead. + */ + public getAppUserUsername(): string { + return `${this.info.nameSlug}.bot`; + } + + /** + * Get the ID of this App, please see for how to obtain an ID for your App. + * + * @return {number} the ID + */ + public getID(): string { + return this.info.id; + } + + /** + * Get the version of this App, using http://semver.org/. + * + * @return {string} the version + */ + public getVersion(): string { + return this.info.version; + } + + /** + * Get the description of this App, mostly used to show to the clients/administrators. + * + * @return {string} the description + */ + public getDescription(): string { + return this.info.description; + } + + /** + * Gets the API Version which this App depends on (http://semver.org/). + * This property is used for the dependency injections. + * + * @return {string} the required api version + */ + public getRequiredApiVersion(): string { + return this.info.requiredApiVersion; + } + + /** + * Gets the information regarding the author/maintainer of this App. + * + * @return author information + */ + public getAuthorInfo(): IAppAuthorInfo { + return this.info.author; + } + + /** + * Gets the entirity of the App's information. + * + * @return App information + */ + public getInfo(): IAppInfo { + return this.info; + } + + /** + * Gets the ILogger instance for this App. + * + * @return the logger instance + */ + public getLogger(): ILogger { + return this.logger; + } + + public getAccessors(): IAppAccessors { + return this.accessors; + } + + /** + * Method which will be called when the App is initialized. This is the recommended place + * to add settings and slash commands. If an error is thrown, all commands will be unregistered. + */ + public async initialize(configurationExtend: IConfigurationExtend, environmentRead: IEnvironmentRead): Promise { + await this.extendConfiguration(configurationExtend, environmentRead); + } + + /** + * Method which is called when this App is enabled and can be called several + * times during this instance's life time. Once after the `initialize()` is called, + * pending it doesn't throw an error, and then anytime the App is enabled by the user. + * If this method, `onEnable()`, returns false, then this App will not + * actually be enabled (ex: a setting isn't configured). + * + * @return whether the App should be enabled or not + */ + public async onEnable(environment: IEnvironmentRead, configurationModify: IConfigurationModify): Promise { + return true; + } + + /** + * Method which is called when this App is disabled and it can be called several times. + * If this App was enabled and then the user disabled it, this method will be called. + */ + public async onDisable(configurationModify: IConfigurationModify): Promise {} + + /** + * Method which is called when the App is uninstalled and it is called one single time. + * + * This method will NOT be called when an App is getting disabled manually, ONLY when + * it's being uninstalled from Rocket.Chat. + */ + public async onUninstall(context: IAppUninstallationContext, read: IRead, http: IHttp, persistence: IPersistence, modify: IModify): Promise {} + + /** + * Method which is called when the App is installed and it is called one single time. + * + * This method is NOT called when the App is updated. + */ + public async onInstall(context: IAppInstallationContext, read: IRead, http: IHttp, persistence: IPersistence, modify: IModify): Promise {} + + /** + * Method which is called when the App is updated and it is called one single time. + * + * This method is NOT called when the App is installed. + */ + public async onUpdate(context: IAppUpdateContext, read: IRead, http: IHttp, persistence: IPersistence, modify: IModify): Promise {} + + /** + * Method which is called whenever a setting which belongs to this App has been updated + * by an external system and not this App itself. The setting passed is the newly updated one. + * + * @param setting the setting which was updated + * @param configurationModify the accessor to modifiy the system + * @param reader the reader accessor + * @param http an accessor to the outside world + */ + public async onSettingUpdated(setting: ISetting, configurationModify: IConfigurationModify, read: IRead, http: IHttp): Promise {} + + /** + * Method which is called before a setting which belongs to this App is going to be updated + * by an external system and not this App itself. The setting passed is the newly updated one. + * + * @param setting the setting which is going to be updated + * @param configurationModify the accessor to modifiy the system + * @param reader the reader accessor + * @param http an accessor to the outside world + */ + public async onPreSettingUpdate(context: ISettingUpdateContext, configurationModify: IConfigurationModify, read: IRead, http: IHttp): Promise { + return context.newSetting; + } + + /** + * Method will be called during initialization. It allows for adding custom configuration options and defaults + * @param configuration + */ + protected async extendConfiguration(configuration: IConfigurationExtend, environmentRead: IEnvironmentRead): Promise {} + + /** + * Sets the status this App is now at, use only when 100% true (it's protected for a reason). + * + * @param status the new status of this App + */ + protected async setStatus(status: AppStatus): Promise { + this.logger.debug(`The status is now: ${status}`); + this.status = status; + } + + // Avoid leaking references if object is serialized (e.g. to be sent over IPC) + public toJSON(): Record { + return this.info; + } +} diff --git a/packages/apps-engine/src/definition/AppStatus.ts b/packages/apps-engine/src/definition/AppStatus.ts new file mode 100644 index 000000000000..31638a8d0f1c --- /dev/null +++ b/packages/apps-engine/src/definition/AppStatus.ts @@ -0,0 +1,65 @@ +export enum AppStatus { + /** The status is known, aka not been constructed the proper way. */ + UNKNOWN = 'unknown', + /** The App has been constructed but that's it. */ + CONSTRUCTED = 'constructed', + /** The App's `initialize()` was called and returned true. */ + INITIALIZED = 'initialized', + /** The App's `onEnable()` was called, returned true, and this was done automatically (system start up). */ + AUTO_ENABLED = 'auto_enabled', + /** The App's `onEnable()` was called, returned true, and this was done by the user such as installing a new one. */ + MANUALLY_ENABLED = 'manually_enabled', + /** + * The App was disabled due to an error while attempting to compile it. + * An attempt to enable it again will fail, as it needs to be updated. + */ + COMPILER_ERROR_DISABLED = 'compiler_error_disabled', + /** + * The App was disable due to its license being invalid + */ + INVALID_LICENSE_DISABLED = 'invalid_license_disabled', + /** + * The app was disabled due to an invalid installation or validation in its signature. + */ + INVALID_INSTALLATION_DISABLED = 'invalid_installation_disabled', + /** The App was disabled due to an unrecoverable error being thrown. */ + ERROR_DISABLED = 'error_disabled', + /** The App was manually disabled by a user. */ + MANUALLY_DISABLED = 'manually_disabled', + INVALID_SETTINGS_DISABLED = 'invalid_settings_disabled', + /** The App was disabled due to other circumstances. */ + DISABLED = 'disabled', +} + +export class AppStatusUtilsDef { + public isEnabled(status: AppStatus): boolean { + switch (status) { + case AppStatus.AUTO_ENABLED: + case AppStatus.MANUALLY_ENABLED: + return true; + default: + return false; + } + } + + public isDisabled(status: AppStatus): boolean { + switch (status) { + case AppStatus.COMPILER_ERROR_DISABLED: + case AppStatus.ERROR_DISABLED: + case AppStatus.MANUALLY_DISABLED: + case AppStatus.INVALID_SETTINGS_DISABLED: + case AppStatus.INVALID_LICENSE_DISABLED: + case AppStatus.INVALID_INSTALLATION_DISABLED: + case AppStatus.DISABLED: + return true; + default: + return false; + } + } + + public isError(status: AppStatus): boolean { + return [AppStatus.ERROR_DISABLED, AppStatus.COMPILER_ERROR_DISABLED].includes(status); + } +} + +export const AppStatusUtils = new AppStatusUtilsDef(); diff --git a/packages/apps-engine/src/definition/IApp.ts b/packages/apps-engine/src/definition/IApp.ts new file mode 100644 index 000000000000..53faff9647f4 --- /dev/null +++ b/packages/apps-engine/src/definition/IApp.ts @@ -0,0 +1,90 @@ +import type { AppStatus } from './AppStatus'; +import type { IAppAccessors } from './accessors'; +import type { ILogger } from './accessors/ILogger'; +import type { IAppAuthorInfo } from './metadata/IAppAuthorInfo'; +import type { IAppInfo } from './metadata/IAppInfo'; + +export interface IApp { + /** + * Gets the status of this App. + * + * @return {AppStatus} the status/state of the App + */ + getStatus(): Promise; + + /** + * Get the name of this App. + * + * @return {string} the name + */ + getName(): string; + + /** + * Gets the sluggified name of this App. + * + * @return {string} the name slugged + */ + getNameSlug(): string; + + /** + * Gets the username of this App's app user. + * + * @return {string} the username of the app user + * + * @deprecated This method will be removed in the next major version. + * Please use read.getAppUser instead. + */ + getAppUserUsername(): string; + + /** + * Get the ID of this App, please see for how to obtain an ID for your App. + * + * @return {number} the ID + */ + getID(): string; + + /** + * Get the version of this App, using http://semver.org/. + * + * @return {string} the version + */ + getVersion(): string; + + /** + * Get the description of this App, mostly used to show to the clients/administrators. + * + * @return {string} the description + */ + getDescription(): string; + + /** + * Gets the API Version which this App depends on (http://semver.org/). + * This property is used for the dependency injections. + * + * @return {string} the required api version + */ + getRequiredApiVersion(): string; + + /** + * Gets the information regarding the author/maintainer of this App. + * + * @return author information + */ + getAuthorInfo(): IAppAuthorInfo; + + /** + * Gets the entirity of the App's information. + * + * @return App information + */ + getInfo(): IAppInfo; + + /** + * Gets the ILogger instance for this App. + * + * @return the logger instance + */ + getLogger(): ILogger; + + getAccessors(): IAppAccessors; +} diff --git a/packages/apps-engine/src/definition/LICENSE b/packages/apps-engine/src/definition/LICENSE new file mode 100644 index 000000000000..42ea81dc8cdb --- /dev/null +++ b/packages/apps-engine/src/definition/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2015-2023 Rocket.Chat Technologies Corp. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/apps-engine/src/definition/accessors/IApiExtend.ts b/packages/apps-engine/src/definition/accessors/IApiExtend.ts new file mode 100644 index 000000000000..ab3210105e9c --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IApiExtend.ts @@ -0,0 +1,16 @@ +import type { IApi } from '../api'; + +/** + * This accessor provides methods for adding a custom api. + * It is provided during the initialization of your App + */ + +export interface IApiExtend { + /** + * Adds an api which can be called by external services lateron. + * Should an api already exists an error will be thrown. + * + * @param api the command information + */ + provideApi(api: IApi): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IAppAccessors.ts b/packages/apps-engine/src/definition/accessors/IAppAccessors.ts new file mode 100644 index 000000000000..c2ea3bfcacea --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IAppAccessors.ts @@ -0,0 +1,11 @@ +import type { IEnvironmentRead, IHttp, IRead } from '.'; +import type { IApiEndpointMetadata } from '../api'; +import type { IEnvironmentWrite } from './IEnvironmentWrite'; + +export interface IAppAccessors { + readonly environmentReader: IEnvironmentRead; + readonly environmentWriter: IEnvironmentWrite; + readonly reader: IRead; + readonly http: IHttp; + readonly providedApiEndpoints: Array; +} diff --git a/packages/apps-engine/src/definition/accessors/IAppInstallationContext.ts b/packages/apps-engine/src/definition/accessors/IAppInstallationContext.ts new file mode 100644 index 000000000000..0ca1c08ba0dc --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IAppInstallationContext.ts @@ -0,0 +1,5 @@ +import type { IUser } from '../users'; + +export interface IAppInstallationContext { + user: IUser; +} diff --git a/packages/apps-engine/src/definition/accessors/IAppUninstallationContext.ts b/packages/apps-engine/src/definition/accessors/IAppUninstallationContext.ts new file mode 100644 index 000000000000..96ddbfa03298 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IAppUninstallationContext.ts @@ -0,0 +1,5 @@ +import type { IUser } from '../users'; + +export interface IAppUninstallationContext { + user: IUser; +} diff --git a/packages/apps-engine/src/definition/accessors/IAppUpdateContext.ts b/packages/apps-engine/src/definition/accessors/IAppUpdateContext.ts new file mode 100644 index 000000000000..d0bcf7ea280b --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IAppUpdateContext.ts @@ -0,0 +1,6 @@ +import type { IUser } from '../users'; + +export interface IAppUpdateContext { + user?: IUser; + oldAppVersion: string; +} diff --git a/packages/apps-engine/src/definition/accessors/ICloudWorkspaceRead.ts b/packages/apps-engine/src/definition/accessors/ICloudWorkspaceRead.ts new file mode 100644 index 000000000000..c78749fae59b --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ICloudWorkspaceRead.ts @@ -0,0 +1,24 @@ +import type { IWorkspaceToken } from '../cloud/IWorkspaceToken'; + +/** + * Accessor that enables apps to read information + * related to the Cloud connectivity of the workspace. + * + * Methods in this accessor will usually connect to the + * Rocket.Chat Cloud, which means they won't work properly + * in air-gapped environment. + * + * This accessor available via `IRead` object, which is + * usually received as a parameter wherever it's available. + */ +export interface ICloudWorkspaceRead { + /** + * Returns an access token that can be used to access + * Cloud Services on the workspace's behalf. + * + * @param scope The scope that the token should be authorized with + * + * @RequiresPermission cloud.workspace-token; scopes: Array + */ + getWorkspaceToken(scope: string): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IConfigurationExtend.ts b/packages/apps-engine/src/definition/accessors/IConfigurationExtend.ts new file mode 100644 index 000000000000..a58f127a0421 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IConfigurationExtend.ts @@ -0,0 +1,36 @@ +import type { IApiExtend } from './IApiExtend'; +import type { IExternalComponentsExtend } from './IExternalComponentsExtend'; +import type { IHttpExtend } from './IHttp'; +import type { ISchedulerExtend } from './ISchedulerExtend'; +import type { ISettingsExtend } from './ISettingsExtend'; +import type { ISlashCommandsExtend } from './ISlashCommandsExtend'; +import type { IUIExtend } from './IUIExtend'; +import type { IVideoConfProvidersExtend } from './IVideoConfProvidersExtend'; + +/** + * This accessor provides methods for declaring the configuration + * of your App. It is provided during initialization of your App. + */ +export interface IConfigurationExtend { + /** Accessor for customing the handling of IHttp requests and responses your App causes. */ + readonly http: IHttpExtend; + + /** Accessor for declaring the settings your App provides. */ + readonly settings: ISettingsExtend; + + /** Accessor for declaring the commands which your App provides. */ + readonly slashCommands: ISlashCommandsExtend; + + /** Accessor for declaring api endpoints. */ + readonly api: IApiExtend; + + readonly externalComponents: IExternalComponentsExtend; + + /** Accessor for declaring tasks that can be scheduled (like cron) */ + readonly scheduler: ISchedulerExtend; + /** Accessor for registering different elements in the host UI */ + readonly ui: IUIExtend; + + /** Accessor for declaring the videoconf providers which your App provides. */ + readonly videoConfProviders: IVideoConfProvidersExtend; +} diff --git a/packages/apps-engine/src/definition/accessors/IConfigurationModify.ts b/packages/apps-engine/src/definition/accessors/IConfigurationModify.ts new file mode 100644 index 000000000000..d0f818e2e028 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IConfigurationModify.ts @@ -0,0 +1,18 @@ +import type { ISchedulerModify } from './ISchedulerModify'; +import type { IServerSettingsModify } from './IServerSettingsModify'; +import type { ISlashCommandsModify } from './ISlashCommandsModify'; + +/** + * This accessor provides methods for modifying the configuration + * of Rocket.Chat. It is provided during "onEnable" of your App. + */ +export interface IConfigurationModify { + /** Accessor for modifying the settings inside of Rocket.Chat. */ + readonly serverSettings: IServerSettingsModify; + + /** Accessor for modifying the slash commands inside of Rocket.Chat. */ + readonly slashCommands: ISlashCommandsModify; + + /** Accessor for modifying schedulers */ + readonly scheduler: ISchedulerModify; +} diff --git a/packages/apps-engine/src/definition/accessors/IDiscussionBuilder.ts b/packages/apps-engine/src/definition/accessors/IDiscussionBuilder.ts new file mode 100644 index 000000000000..51dc0c2c4f92 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IDiscussionBuilder.ts @@ -0,0 +1,25 @@ +import type { IRoomBuilder } from '.'; +import type { IMessage } from '../messages'; +import type { RocketChatAssociationModel } from '../metadata'; +import type { IRoom } from '../rooms'; + +/** + * Interface for building out a room. + * Please note, a room creator, name, and type must be set otherwise you will NOT + * be able to successfully save the room object. + */ +export interface IDiscussionBuilder extends IRoomBuilder { + kind: RocketChatAssociationModel.DISCUSSION; + + setParentRoom(parentRoom: IRoom): IDiscussionBuilder; + + getParentRoom(): IRoom; + + setParentMessage(parentMessage: IMessage): IDiscussionBuilder; + + getParentMessage(): IMessage; + + setReply(reply: string): IDiscussionBuilder; + + getReply(): string; +} diff --git a/packages/apps-engine/src/definition/accessors/IEmailCreator.ts b/packages/apps-engine/src/definition/accessors/IEmailCreator.ts new file mode 100644 index 000000000000..d5d051bc2dff --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IEmailCreator.ts @@ -0,0 +1,10 @@ +import type { IEmail } from '../email'; + +export interface IEmailCreator { + /** + * Sends an email through Rocket.Chat + * + * @param email the email data + */ + send(email: IEmail): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IEnvironmentRead.ts b/packages/apps-engine/src/definition/accessors/IEnvironmentRead.ts new file mode 100644 index 000000000000..81b50bee77c4 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IEnvironmentRead.ts @@ -0,0 +1,27 @@ +import type { IEnvironmentalVariableRead } from './IEnvironmentalVariableRead'; +import type { IServerSettingRead } from './IServerSettingRead'; +import type { ISettingRead } from './ISettingRead'; + +/** + * Allows read-access to the App's settings, + * the certain server's settings along with environmental + * variables all of which are not user created. + */ +export interface IEnvironmentRead { + /** Gets an instance of the App's settings reader. */ + getSettings(): ISettingRead; + + /** + * Gets an instance of the Server's Settings reader. + * Please note: Due to security concerns, only a subset of settings + * are accessible. + */ + getServerSettings(): IServerSettingRead; + + /** + * Gets an instance of the Environmental Variables reader. + * Please note: Due to security concerns, only a subset of + * them are readable. + */ + getEnvironmentVariables(): IEnvironmentalVariableRead; +} diff --git a/packages/apps-engine/src/definition/accessors/IEnvironmentWrite.ts b/packages/apps-engine/src/definition/accessors/IEnvironmentWrite.ts new file mode 100644 index 000000000000..ab1869ab67cb --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IEnvironmentWrite.ts @@ -0,0 +1,10 @@ +import type { IServerSettingUpdater } from './IServerSettingUpdater'; +import type { ISettingUpdater } from './ISettingUpdater'; + +/** + * Allows write-access to the App's settings, + */ +export interface IEnvironmentWrite { + getSettings(): ISettingUpdater; + getServerSettings(): IServerSettingUpdater; +} diff --git a/packages/apps-engine/src/definition/accessors/IEnvironmentalVariableRead.ts b/packages/apps-engine/src/definition/accessors/IEnvironmentalVariableRead.ts new file mode 100644 index 000000000000..3bb77b033e83 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IEnvironmentalVariableRead.ts @@ -0,0 +1,11 @@ +/** A reader for reading the Environmental Variables. */ +export interface IEnvironmentalVariableRead { + /** Gets the value for a variable. */ + getValueByName(envVarName: string): Promise; + + /** Checks to see if Apps can access the given variable name. */ + isReadable(envVarName: string): Promise; + + /** Checks to see if any value is set for the given variable name. */ + isSet(envVarName: string): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IExternalComponentsExtend.ts b/packages/apps-engine/src/definition/accessors/IExternalComponentsExtend.ts new file mode 100644 index 000000000000..6a3dc781056f --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IExternalComponentsExtend.ts @@ -0,0 +1,17 @@ +import type { IExternalComponent } from '../externalComponent'; + +/** + * This accessor provides a method for registering external + * components. This is provided during the initialization of your App. + */ +export interface IExternalComponentsExtend { + /** + * Register an external component to the system. + * 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 externalComponent the external component to be registered + */ + register(externalComponent: IExternalComponent): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IHttp.ts b/packages/apps-engine/src/definition/accessors/IHttp.ts new file mode 100644 index 000000000000..8c5eeb9a4d55 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IHttp.ts @@ -0,0 +1,202 @@ +import type { IPersistence } from './IPersistence'; +import type { IRead } from './IRead'; + +/** + * The Http package allows users to call out to an external web service. + * Based off of: https://github.com/meteor-typings/meteor/blob/master/1.4/main.d.ts#L869 + */ +export interface IHttp { + get(url: string, options?: IHttpRequest): Promise; + + post(url: string, options?: IHttpRequest): Promise; + + put(url: string, options?: IHttpRequest): Promise; + + del(url: string, options?: IHttpRequest): Promise; + + patch(url: string, options?: IHttpRequest): Promise; +} + +export enum RequestMethod { + GET = 'get', + POST = 'post', + PUT = 'put', + DELETE = 'delete', + HEAD = 'head', + OPTIONS = 'options', + PATCH = 'patch', +} + +export interface IHttpRequest { + content?: string; + data?: any; + query?: string; + params?: { + [key: string]: string; + }; + auth?: string; + headers?: { + [key: string]: string; + }; + timeout?: number; + /** + * The encoding to be used on response data. + * + * If null, the body is returned as a Buffer. Anything else (including the default value of undefined) + * will be passed as the encoding parameter to toString() (meaning this is effectively 'utf8' by default). + * (Note: if you expect binary data, you should set encoding: null.) + */ + encoding?: string | null; + /** + * if `true`, requires SSL certificates be valid. + * + * Defaul: `true`; + */ + strictSSL?: boolean; + /** + * If `true`, the server certificate is verified against the list of supplied CAs. + * + * Default: `true`. + * + * https://nodejs.org/api/tls.html#tls_tls_connect_options_callback + */ + rejectUnauthorized?: boolean; +} + +export interface IHttpResponse { + url: string; + method: RequestMethod; + statusCode: number; + headers?: { + [key: string]: string; + }; + content?: string; + data?: any; +} + +export interface IHttpExtend { + /** + * A method for providing a single header which is added to every request. + * + * @param key the name of the header + * @param value the header's content + */ + provideDefaultHeader(key: string, value: string): void; + + /** + * A method for providing more than one header which are added to every request. + * + * @param headers an object with strings as the keys (header name) and strings as values (header content) + */ + provideDefaultHeaders(headers: { [key: string]: string }): void; + + /** + * A method for providing a single query parameter which is added to every request. + * + * @param key the name of the query parameter + * @param value the query parameter's content + */ + provideDefaultParam(key: string, value: string): void; + + /** + * A method for providing more than one query parameters which are added to every request. + * + * @param headers an object with strings as the keys (parameter name) and strings as values (parameter content) + */ + provideDefaultParams(params: { [key: string]: string }): void; + + /** + * Method for providing a function which is called before every request is called out to the final destination. + * This can be called more than once which means there can be more than one handler. The order provided is the order called. + * Note: if this handler throws an error when it is executed then the request will be aborted. + * + * @param handler the instance of the IHttpPreRequestHandler + */ + providePreRequestHandler(handler: IHttpPreRequestHandler): void; + + /** + * Method for providing a function which is called after every response is got from the url and before the result is returned. + * This can be called more than once which means there can be more than one handler. The order provided is the order called. + * Note: if this handler throws an error when it is executed then the respone will not be returned + * + * @param handler the instance of the IHttpPreResponseHandler + */ + providePreResponseHandler(handler: IHttpPreResponseHandler): void; + + /** + * A method for getting all of the default headers provided, the value is a readonly and any modifications done will be ignored. + * Please use the provider methods for adding them. + */ + getDefaultHeaders(): Map; + + /** + * A method for getting all of the default parameters provided, the value is a readonly and any modifications done will be ignored. + * Please use the provider methods for adding them. + */ + getDefaultParams(): Map; + + /** + * A method for getting all of the pre-request handlers provided, the value is a readonly and any modifications done will be ignored. + * Please use the provider methods for adding them. + */ + getPreRequestHandlers(): Array; + + /** + * A method for getting all of the pre-response handlers provided, the value is a readonly and any modifications done will be ignored. + * Please use the provider methods for adding them. + */ + getPreResponseHandlers(): Array; +} + +export interface IHttpPreRequestHandler { + executePreHttpRequest(url: string, request: IHttpRequest, read: IRead, persistence: IPersistence): Promise; +} + +export interface IHttpPreResponseHandler { + executePreHttpResponse(response: IHttpResponse, read: IRead, persistence: IPersistence): Promise; +} + +export enum HttpStatusCode { + CONTINUE = 100, + SWITCHING_PROTOCOLS = 101, + OK = 200, + CREATED = 201, + ACCEPTED = 202, + NON_AUTHORITATIVE_INFORMATION = 203, + NO_CONTENT = 204, + RESET_CONTENT = 205, + PARTIAL_CONTENT = 206, + MULTIPLE_CHOICES = 300, + MOVED_PERMANENTLY = 301, + FOUND = 302, + SEE_OTHER = 303, + NOT_MODIFIED = 304, + USE_PROXY = 305, + TEMPORARY_REDIRECT = 307, + BAD_REQUEST = 400, + UNAUTHORIZED = 401, + PAYMENT_REQUIRED = 402, + FORBIDDEN = 403, + NOT_FOUND = 404, + METHOD_NOT_ALLOWED = 405, + NOT_ACCEPTABLE = 406, + PROXY_AUTHENTICATION_REQUIRED = 407, + REQUEST_TIMEOUT = 408, + CONFLICT = 409, + GONE = 410, + LENGTH_REQUIRED = 411, + PRECONDITION_FAILED = 412, + REQUEST_ENTITY_TOO_LARGE = 413, + REQUEST_URI_TOO_LONG = 414, + UNSUPPORTED_MEDIA_TYPE = 415, + REQUESTED_RANGE_NOT_SATISFIABLE = 416, + EXPECTATION_FAILED = 417, + UNPROCESSABLE_ENTITY = 422, + TOO_MANY_REQUESTS = 429, + INTERNAL_SERVER_ERROR = 500, + NOT_IMPLEMENTED = 501, + BAD_GATEWAY = 502, + SERVICE_UNAVAILABLE = 503, + GATEWAY_TIMEOUT = 504, + HTTP_VERSION_NOT_SUPPORTED = 505, +} diff --git a/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts b/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts new file mode 100644 index 000000000000..56a3ec17ec27 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ILivechatCreator.ts @@ -0,0 +1,43 @@ +import type { ILivechatRoom, IVisitor } from '../livechat'; +import type { IUser } from '../users'; + +export interface IExtraRoomParams { + source?: ILivechatRoom['source']; + customFields?: { + [key: string]: unknown; + }; +} + +export interface ILivechatCreator { + /** + * Creates a room to connect the `visitor` to an `agent`. + * + * This method uses the Livechat routing method configured + * in the server + * + * @param visitor The Livechat Visitor that started the conversation + * @param agent The agent responsible for the room + */ + createRoom(visitor: IVisitor, agent: IUser, extraParams?: IExtraRoomParams): Promise; + + /** + * @deprecated Use `createAndReturnVisitor` instead. + * Creates a Livechat visitor + * + * @param visitor Data of the visitor to be created + */ + createVisitor(visitor: IVisitor): Promise; + + /** + * Creates a Livechat visitor + * + * @param visitor Data of the visitor to be created + */ + createAndReturnVisitor(visitor: IVisitor): Promise; + + /** + * Creates a token to be used when + * creating a new livechat visitor + */ + createToken(): string; +} diff --git a/packages/apps-engine/src/definition/accessors/ILivechatMessageBuilder.ts b/packages/apps-engine/src/definition/accessors/ILivechatMessageBuilder.ts new file mode 100644 index 000000000000..c36b078f53a6 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ILivechatMessageBuilder.ts @@ -0,0 +1,219 @@ +import type { ILivechatMessage, IVisitor } from '../livechat'; +import type { IMessageAttachment } from '../messages'; +import type { RocketChatAssociationModel } from '../metadata'; +import type { IRoom } from '../rooms'; +import type { IUser } from '../users'; +import type { IMessageBuilder } from './IMessageBuilder'; + +/** + * Interface for building out a livechat message. + * Please note, that a room and sender must be associated otherwise you will NOT + * be able to successfully save the message object. + */ +export interface ILivechatMessageBuilder { + kind: RocketChatAssociationModel.LIVECHAT_MESSAGE; + + /** + * Provides a convient way to set the data for the message. + * Note: Providing an "id" field here will be ignored. + * + * @param message the message data to set + */ + setData(message: ILivechatMessage): ILivechatMessageBuilder; + + /** + * Sets the room where this message should be sent to. + * + * @param room the room where to send + */ + setRoom(room: IRoom): ILivechatMessageBuilder; + + /** + * Gets the room where this message was sent to. + */ + getRoom(): IRoom; + + /** + * Sets the sender of this message. + * + * @param sender the user sending the message + */ + setSender(sender: IUser): ILivechatMessageBuilder; + + /** + * Gets the User which sent the message. + */ + getSender(): IUser; + + /** + * Sets the text of the message. + * + * @param text the actual text + */ + setText(text: string): ILivechatMessageBuilder; + + /** + * Gets the message text. + */ + getText(): string; + + /** + * Sets the emoji to use for the avatar, this overwrites the current avatar + * whether it be the user's or the avatar url provided. + * + * @param emoji the emoji code + */ + setEmojiAvatar(emoji: string): ILivechatMessageBuilder; + + /** + * Gets the emoji used for the avatar. + */ + getEmojiAvatar(): string; + + /** + * Sets the url which to display for the avatar, this overwrites the current + * avatar whether it be the user's or an emoji one. + * + * @param avatarUrl image url to use as the avatar + */ + setAvatarUrl(avatarUrl: string): ILivechatMessageBuilder; + + /** + * Gets the url used for the avatar. + */ + getAvatarUrl(): string; + + /** + * Sets the display text of the sender's username that is visible. + * + * @param alias the username alias to display + */ + setUsernameAlias(alias: string): ILivechatMessageBuilder; + + /** + * Gets the display text of the sender's username that is visible. + */ + getUsernameAlias(): string; + + /** + * Adds one attachment to the message's list of attachments, this will not + * overwrite any existing ones but just adds. + * + * @param attachment the attachment to add + */ + addAttachment(attachment: IMessageAttachment): ILivechatMessageBuilder; + + /** + * Sets the attachments for the message, replacing and destroying all of the current attachments. + * + * @param attachments array of the attachments + */ + setAttachments(attachments: Array): ILivechatMessageBuilder; + + /** + * Gets the attachments array for the message + */ + getAttachments(): Array; + + /** + * Replaces an attachment at the given position (index). + * If there is no attachment at that position, there will be an error thrown. + * + * @param position the index of the attachment to replace + * @param attachment the attachment to replace with + */ + replaceAttachment(position: number, attachment: IMessageAttachment): ILivechatMessageBuilder; + + /** + * Removes an attachment at the given position (index). + * If there is no attachment at that position, there will be an error thrown. + * + * @param position the index of the attachment to remove + */ + removeAttachment(position: number): ILivechatMessageBuilder; + + /** + * Sets the user who is editing this message. + * This is required if you are modifying an existing message. + * + * @param user the editor + */ + setEditor(user: IUser): ILivechatMessageBuilder; + + /** + * Gets the user who edited the message + */ + getEditor(): IUser; + + /** + * Sets whether this message can group with others. + * This is desirable if you want to avoid confusion with other integrations. + * + * @param groupable whether this message can group with others + */ + setGroupable(groupable: boolean): ILivechatMessageBuilder; + + /** + * Gets whether this message can group with others. + */ + getGroupable(): boolean; + + /** + * Sets whether this message should have any URLs in the text + * parsed by Rocket.Chat and get the details added to the message's + * attachments. + * + * @param parseUrls whether URLs should be parsed in this message + */ + setParseUrls(parseUrls: boolean): ILivechatMessageBuilder; + + /** + * Gets whether this message should have its URLs parsed + */ + getParseUrls(): boolean; + + /** + * Set the token of the livechat visitor that + * sent the message + * + * @param token The Livechat visitor's token + */ + setToken(token: string): ILivechatMessageBuilder; + + /** + * Gets the token of the livechat visitor that + * sent the message + */ + getToken(): string; + + /** + * If the sender of the message is a Livechat Visitor, + * set the visitor who sent the message. + * + * If you set the visitor property of a message, the + * sender will be emptied + * + * @param visitor The visitor who sent the message + */ + setVisitor(visitor: IVisitor): ILivechatMessageBuilder; + + /** + * Get the visitor who sent the message, + * if any + */ + getVisitor(): IVisitor; + + /** + * Gets the resulting message that has been built up to the point of calling it. + * + * *Note:* This will error out if the Room has not been defined OR if the room + * is not of type RoomType.LIVE_CHAT. + */ + getMessage(): ILivechatMessage; + + /** + * Returns a message builder based on the + * livechat message of this builder + */ + getMessageBuilder(): IMessageBuilder; +} diff --git a/packages/apps-engine/src/definition/accessors/ILivechatRead.ts b/packages/apps-engine/src/definition/accessors/ILivechatRead.ts new file mode 100644 index 000000000000..a756d162c337 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ILivechatRead.ts @@ -0,0 +1,38 @@ +import type { IDepartment } from '../livechat'; +import type { ILivechatRoom } from '../livechat/ILivechatRoom'; +import type { IVisitor } from '../livechat/IVisitor'; +import type { IMessage } from '../messages'; + +export interface ILivechatRead { + /** + * Gets online status of the livechat. + * @param departmentId (optional) the id of the livechat department + * @deprecated use `isOnlineAsync` instead + */ + isOnline(departmentId?: string): boolean; + /** + * Gets online status of the livechat. + * @param departmentId (optional) the id of the livechat department + */ + isOnlineAsync(departmentId?: string): Promise; + getDepartmentsEnabledWithAgents(): Promise>; + getLivechatRooms(visitor: IVisitor, departmentId?: string): Promise>; + getLivechatOpenRoomsByAgentId(agentId: string): Promise>; + getLivechatTotalOpenRoomsByAgentId(agentId: 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 the alternative methods to fetch visitors. + */ + getLivechatVisitors(query: object): Promise>; + getLivechatVisitorById(id: string): Promise; + getLivechatVisitorByEmail(email: string): Promise; + getLivechatVisitorByToken(token: string): Promise; + getLivechatVisitorByPhoneNumber(phoneNumber: string): Promise; + getLivechatDepartmentByIdOrName(value: string): Promise; + /** + * @experimental we do not encourage the wider usage of this method, + * as we're evaluating its performance and fit for the API. + */ + _fetchLivechatRoomMessages(roomId: string): Promise>; +} diff --git a/packages/apps-engine/src/definition/accessors/ILivechatUpdater.ts b/packages/apps-engine/src/definition/accessors/ILivechatUpdater.ts new file mode 100644 index 000000000000..fb75cf9ecf3b --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ILivechatUpdater.ts @@ -0,0 +1,33 @@ +import type { ILivechatTransferData, IVisitor } from '../livechat'; +import type { IRoom } from '../rooms'; +import type { IUser } from '../users'; + +export interface ILivechatUpdater { + /** + * Transfer a Livechat visitor to another room + * + * @param visitor Visitor to be transferred + * @param transferData The data to execute the transferring + */ + transferVisitor(visitor: IVisitor, transferData: ILivechatTransferData): Promise; + + /** + * Closes a Livechat room + * + * @param room The room to be closed + * @param comment The comment explaining the reason for closing the room + * @param closer The user that closes the room + */ + closeRoom(room: IRoom, comment: string, closer?: IUser): Promise; + + /** + * Set a livechat visitor's custom fields by its token + * @param token The visitor's token + * @param key The key in the custom fields + * @param value The value to be set + * @param overwrite Whether overwrite or not + * + * @returns Promise to whether success or not + */ + setCustomFields(token: IVisitor['token'], key: string, value: string, overwrite: boolean): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/ILogEntry.ts b/packages/apps-engine/src/definition/accessors/ILogEntry.ts new file mode 100644 index 000000000000..4dc46693b6d9 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ILogEntry.ts @@ -0,0 +1,22 @@ +export enum LogMessageSeverity { + DEBUG = 'debug', + INFORMATION = 'info', + LOG = 'log', + WARNING = 'warning', + ERROR = 'error', + SUCCESS = 'success', +} + +/** + * Message which will be passed to a UI (either in a log or in the application's UI) + */ +export interface ILogEntry { + /** The function name who did this logging, this is automatically added (can be null). */ + caller?: string; + /** The severity rate, this is automatically added. */ + severity: LogMessageSeverity; + /** When this entry was made. */ + timestamp: Date; + /** The items which were logged. */ + args: Array; +} diff --git a/packages/apps-engine/src/definition/accessors/ILogger.ts b/packages/apps-engine/src/definition/accessors/ILogger.ts new file mode 100644 index 000000000000..eac6e531802d --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ILogger.ts @@ -0,0 +1,29 @@ +import type { AppMethod } from '../metadata/AppMethod'; +import type { ILogEntry } from './ILogEntry'; + +/** + * This logger provides a way to log various levels to the entire system. + * When used, the items passed in will be logged to the database. This will + * allow people to easily see what happened (users) or debug what went wrong. + */ +export interface ILogger { + method: `${AppMethod}`; + + debug(...items: Array): void; + info(...items: Array): void; + log(...items: Array): void; + warn(...items: Array): void; + error(...items: Array): void; + success(...items: Array): void; + + /** Gets the entries logged. */ + getEntries(): Array; + /** Gets the method which this logger is for. */ + getMethod(): `${AppMethod}`; + /** Gets when this logger was constructed. */ + getStartTime(): Date; + /** Gets the end time, usually Date.now(). */ + getEndTime(): Date; + /** Gets the amount of time this was a logger, start - Date.now(). */ + getTotalTime(): number; +} diff --git a/packages/apps-engine/src/definition/accessors/IMessageBuilder.ts b/packages/apps-engine/src/definition/accessors/IMessageBuilder.ts new file mode 100644 index 000000000000..024a4b123ff8 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IMessageBuilder.ts @@ -0,0 +1,236 @@ +import type { LayoutBlock } from '@rocket.chat/ui-kit'; + +import type { IMessage, IMessageAttachment } from '../messages'; +import type { RocketChatAssociationModel } from '../metadata'; +import type { IRoom } from '../rooms'; +import type { BlockBuilder, IBlock } from '../uikit'; +import type { IUser } from '../users'; + +/** + * Interface for building out a message. + * Please note, that a room and sender must be associated otherwise you will NOT + * be able to successfully save the message object. + */ +export interface IMessageBuilder { + kind: RocketChatAssociationModel.MESSAGE; + + /** + * Provides a convenient way to set the data for the message. + * Note: Providing an "id" field here will be ignored. + * + * @param message the message data to set + */ + setData(message: IMessage): IMessageBuilder; + + /** + * Provides a convenient way to set the data for the message + * keeping the "id" field so as to update the message later. + * + * @param message the message data to set + * @param editor the user who edited the updated message + */ + setUpdateData(message: IMessage, editor: IUser): IMessageBuilder; + + /** + * Sets the thread to which this message belongs, if any. + * + * @param threadId The id of the thread + */ + setThreadId(threadId: string): IMessageBuilder; + + /** + * Retrieves the threadId to which this message belongs, + * if any. + * + * If you would like to retrieve the actual message that + * the thread originated from, you can use the + * `IMessageRead.getById()` method + */ + getThreadId(): string; + + /** + * Sets the room where this message should be sent to. + * + * @param room the room where to send + */ + setRoom(room: IRoom): IMessageBuilder; + + /** + * Gets the room where this message was sent to. + */ + getRoom(): IRoom; + + /** + * Sets the sender of this message. + * + * @param sender the user sending the message + */ + setSender(sender: IUser): IMessageBuilder; + + /** + * Gets the User which sent the message. + */ + getSender(): IUser; + + /** + * Sets the text of the message. + * + * @param text the actual text + */ + setText(text: string): IMessageBuilder; + + /** + * Gets the message text. + */ + getText(): string; + + /** + * Sets the emoji to use for the avatar, this overwrites the current avatar + * whether it be the user's or the avatar url provided. + * + * @param emoji the emoji code + */ + setEmojiAvatar(emoji: string): IMessageBuilder; + + /** + * Gets the emoji used for the avatar. + */ + getEmojiAvatar(): string; + + /** + * Sets the url which to display for the avatar, this overwrites the current + * avatar whether it be the user's or an emoji one. + * + * @param avatarUrl image url to use as the avatar + */ + setAvatarUrl(avatarUrl: string): IMessageBuilder; + + /** + * Gets the url used for the avatar. + */ + getAvatarUrl(): string; + + /** + * Sets the display text of the sender's username that is visible. + * + * @param alias the username alias to display + */ + setUsernameAlias(alias: string): IMessageBuilder; + + /** + * Gets the display text of the sender's username that is visible. + */ + getUsernameAlias(): string; + + /** + * Adds one attachment to the message's list of attachments, this will not + * overwrite any existing ones but just adds. + * + * @param attachment the attachment to add + */ + addAttachment(attachment: IMessageAttachment): IMessageBuilder; + + /** + * Sets the attachments for the message, replacing and destroying all of the current attachments. + * + * @param attachments array of the attachments + */ + setAttachments(attachments: Array): IMessageBuilder; + + /** + * Gets the attachments array for the message + */ + getAttachments(): Array; + + /** + * Replaces an attachment at the given position (index). + * If there is no attachment at that position, there will be an error thrown. + * + * @param position the index of the attachment to replace + * @param attachment the attachment to replace with + */ + replaceAttachment(position: number, attachment: IMessageAttachment): IMessageBuilder; + + /** + * Removes an attachment at the given position (index). + * If there is no attachment at that position, there will be an error thrown. + * + * @param position the index of the attachment to remove + */ + removeAttachment(position: number): IMessageBuilder; + + /** + * Sets the user who is editing this message. + * This is required if you are modifying an existing message. + * + * @param user the editor + */ + setEditor(user: IUser): IMessageBuilder; + + /** + * Gets the user who edited the message + */ + getEditor(): IUser; + + /** + * Sets whether this message can group with others. + * This is desirable if you want to avoid confusion with other integrations. + * + * @param groupable whether this message can group with others + */ + setGroupable(groupable: boolean): IMessageBuilder; + + /** + * Gets whether this message can group with others. + */ + getGroupable(): boolean; + + /** + * Sets whether this message should have any URLs in the text + * parsed by Rocket.Chat and get the details added to the message's + * attachments. + * + * @param parseUrls whether URLs should be parsed in this message + */ + setParseUrls(parseUrls: boolean): IMessageBuilder; + + /** + * Gets whether this message should have its URLs parsed + */ + getParseUrls(): boolean; + + /** + * Gets the resulting message that has been built up to the point of calling it. + * + * *Note:* This will error out if the Room has not been defined. + */ + getMessage(): IMessage; + + /** + * Adds a block collection to the message's + * own collection + */ + addBlocks(blocks: BlockBuilder | Array): IMessageBuilder; + + /** + * Sets the block collection of the message + * + * @param blocks + */ + setBlocks(blocks: BlockBuilder | Array): IMessageBuilder; + + /** + * Gets the block collection of the message + */ + getBlocks(): Array; + + /** + * Adds a custom field to the message. + * Note: This key can not already exist or it will throw an error. + * Note: The key must not contain a period in it, an error will be thrown. + * + * @param key the name of the custom field + * @param value the value of this custom field + */ + addCustomField(key: string, value: any): IMessageBuilder; +} diff --git a/packages/apps-engine/src/definition/accessors/IMessageExtender.ts b/packages/apps-engine/src/definition/accessors/IMessageExtender.ts new file mode 100644 index 000000000000..7db010bae081 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IMessageExtender.ts @@ -0,0 +1,36 @@ +import type { IMessage, IMessageAttachment } from '../messages'; +import type { RocketChatAssociationModel } from '../metadata'; + +export interface IMessageExtender { + kind: RocketChatAssociationModel.MESSAGE; + + /** + * Adds a custom field to the message. + * Note: This key can not already exist or it will throw an error. + * Note: The key must not contain a period in it, an error will be thrown. + * + * @param key the name of the custom field + * @param value the value of this custom field + */ + addCustomField(key: string, value: any): IMessageExtender; + + /** + * Adds a single attachment to the message. + * + * @param attachment the item to add + */ + addAttachment(attachment: IMessageAttachment): IMessageExtender; + + /** + * Adds all of the provided attachments to the message. + * + * @param attachments an array of attachments + */ + addAttachments(attachments: Array): IMessageExtender; + + /** + * Gets the resulting message that has been extended at the point of calling it. + * Note: modifying the returned value will have no effect. + */ + getMessage(): IMessage; +} diff --git a/packages/apps-engine/src/definition/accessors/IMessageRead.ts b/packages/apps-engine/src/definition/accessors/IMessageRead.ts new file mode 100644 index 000000000000..10c99d26388b --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IMessageRead.ts @@ -0,0 +1,15 @@ +import type { IMessage } from '../messages/index'; +import type { IRoom } from '../rooms/IRoom'; +import type { IUser } from '../users/IUser'; + +/** + * This accessor provides methods for accessing + * messages in a read-only-fashion. + */ +export interface IMessageRead { + getById(id: string): Promise; + + getSenderUser(messageId: string): Promise; + + getRoom(messageId: string): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IMessageUpdater.ts b/packages/apps-engine/src/definition/accessors/IMessageUpdater.ts new file mode 100644 index 000000000000..b21baacae04f --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IMessageUpdater.ts @@ -0,0 +1,21 @@ +import type { Reaction } from '../messages'; + +export interface IMessageUpdater { + /** + * Add a reaction to a message + * + * @param messageId the id of the message + * @param userId the id of the user + * @param reaction the reaction + */ + addReaction(messageId: string, userId: string, reaction: Reaction): Promise; + + /** + * Remove a reaction from a message + * + * @param messageId the id of the message + * @param userId the id of the user + * @param reaction the reaction + */ + removeReaction(messageId: string, userId: string, reaction: Reaction): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IModerationModify.ts b/packages/apps-engine/src/definition/accessors/IModerationModify.ts new file mode 100644 index 000000000000..6b0f54968a04 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IModerationModify.ts @@ -0,0 +1,27 @@ +import type { IMessage } from '../messages'; +import type { IUser } from '../users'; + +export interface IModerationModify { + /** + * Provides a way for Apps to report a message. + * @param messageId the messageId to report + * @param description the description of the report + * @param userId the userId to be reported + * @param appId the app id + */ + report(messageId: string, description: string, userId: string, appId: string): Promise; + + /** + * Provides a way for Apps to dismiss reports by message id. + * @param messageId the messageId to dismiss reports + * @param appId the app id + */ + dismissReportsByMessageId(messageId: IMessage['id'], reason: string, action: string, appId: string): Promise; + + /** + * Provides a way for Apps to dismiss reports by user id. + * @param userId the userId to dismiss reports + * @param appId the app id + */ + dismissReportsByUserId(userId: IUser['id'], reason: string, action: string, appId: string): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IModify.ts b/packages/apps-engine/src/definition/accessors/IModify.ts new file mode 100644 index 000000000000..e76e4cc76824 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IModify.ts @@ -0,0 +1,45 @@ +import type { IModerationModify } from './IModerationModify'; +import type { IModifyCreator } from './IModifyCreator'; +import type { IModifyDeleter } from './IModifyDeleter'; +import type { IModifyExtender } from './IModifyExtender'; +import type { IModifyUpdater } from './IModifyUpdater'; +import type { INotifier } from './INotifier'; +import type { IOAuthAppsModify } from './IOAuthAppsModify'; +import type { ISchedulerModify } from './ISchedulerModify'; +import type { IUIController } from './IUIController'; + +export interface IModify { + getCreator(): IModifyCreator; + + getDeleter(): IModifyDeleter; + + getExtender(): IModifyExtender; + + getUpdater(): IModifyUpdater; + + /** + * Gets the accessor for sending notifications to a user or users in a room. + * + * @returns the notifier accessor + */ + getNotifier(): INotifier; + /** + * Gets the accessor for interacting with the UI + */ + getUiController(): IUIController; + + /** + * Gets the accessor for creating scheduled jobs + */ + getScheduler(): ISchedulerModify; + + /** + * Gets the accessor for creating OAuth apps + */ + getOAuthAppsModifier(): IOAuthAppsModify; + /** + * Gets the accessor for modifying moderation + * @returns the moderation accessor + */ + getModerationModifier(): IModerationModify; +} diff --git a/packages/apps-engine/src/definition/accessors/IModifyCreator.ts b/packages/apps-engine/src/definition/accessors/IModifyCreator.ts new file mode 100644 index 000000000000..6c2acd50493c --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IModifyCreator.ts @@ -0,0 +1,100 @@ +import type { ILivechatMessage } from '../livechat'; +import type { IMessage } from '../messages'; +import type { IRoom } from '../rooms'; +import type { BlockBuilder } from '../uikit'; +import type { IBotUser } from '../users/IBotUser'; +import type { AppVideoConference } from '../videoConferences'; +import type { IDiscussionBuilder } from './IDiscussionBuilder'; +import type { IEmailCreator } from './IEmailCreator'; +import type { ILivechatCreator } from './ILivechatCreator'; +import type { ILivechatMessageBuilder } from './ILivechatMessageBuilder'; +import type { IMessageBuilder } from './IMessageBuilder'; +import type { IRoomBuilder } from './IRoomBuilder'; +import type { IUploadCreator } from './IUploadCreator'; +import type { IUserBuilder } from './IUserBuilder'; +import type { IVideoConferenceBuilder } from './IVideoConferenceBuilder'; + +export interface IModifyCreator { + /** + * Get the creator object responsible for the + * Livechat integrations + */ + getLivechatCreator(): ILivechatCreator; + + /** + * Get the creator object responsible for the upload. + */ + getUploadCreator(): IUploadCreator; + + /** + * Gets the creator object responsible for email sending + */ + getEmailCreator(): IEmailCreator; + + /** + * @deprecated please prefer the rocket.chat/ui-kit components + * + * Gets a new instance of a BlockBuilder + */ + getBlockBuilder(): BlockBuilder; + /** + * Starts the process for building a new message object. + * + * @param data (optional) the initial data to pass into the builder, + * the `id` property will be ignored + * @return an IMessageBuilder instance + */ + startMessage(data?: IMessage): IMessageBuilder; + + /** + * Starts the process for building a new livechat message object. + * + * @param data (optional) the initial data to pass into the builder, + * the `id` property will be ignored + * @return an IMessageBuilder instance + */ + startLivechatMessage(data?: ILivechatMessage): ILivechatMessageBuilder; + + /** + * Starts the process for building a new room. + * + * @param data (optional) the initial data to pass into the builder, + * the `id` property will be ignored + * @return an IRoomBuilder instance + */ + startRoom(data?: IRoom): IRoomBuilder; + + /** + * Starts the process for building a new discussion. + * + * @param data (optional) the initial data to pass into the builder, + * the `id` property will be ignored + * @return an IDiscussionBuilder instance + */ + startDiscussion(data?: Partial): IDiscussionBuilder; + + /** + * Starts the process for building a new video conference. + * + * @param data (optional) the initial data to pass into the builder, + * @return an IVideoConferenceBuilder instance + */ + startVideoConference(data?: Partial): IVideoConferenceBuilder; + + /** + * Starts the process for building a new bot user. + * + * @param data (optional) the initial data to pass into the builder, + * the `id` property will be ignored + * @return an IUserBuilder instance + */ + startBotUser(data?: Partial): IUserBuilder; + + /** + * Finishes the creating process, saving the object to the database. + * + * @param builder the builder instance + * @return the resulting `id` of the resulting object + */ + finish(builder: IMessageBuilder | ILivechatMessageBuilder | IRoomBuilder | IDiscussionBuilder | IVideoConferenceBuilder | IUserBuilder): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IModifyDeleter.ts b/packages/apps-engine/src/definition/accessors/IModifyDeleter.ts new file mode 100644 index 000000000000..7d1103ba13f3 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IModifyDeleter.ts @@ -0,0 +1,12 @@ +import type { IMessage } from '../messages'; +import type { IUser, UserType } from '../users'; + +export interface IModifyDeleter { + deleteRoom(roomId: string): Promise; + + deleteUsers(appId: Exclude, userType: UserType.APP | UserType.BOT): Promise; + + deleteMessage(message: IMessage, user: IUser): Promise; + + removeUsersFromRoom(roomId: string, usernames: Array): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IModifyExtender.ts b/packages/apps-engine/src/definition/accessors/IModifyExtender.ts new file mode 100644 index 000000000000..786b23975407 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IModifyExtender.ts @@ -0,0 +1,40 @@ +import type { IUser } from '../users'; +import type { IMessageExtender } from './IMessageExtender'; +import type { IRoomExtender } from './IRoomExtender'; +import type { IVideoConferenceExtender } from './IVideoConferenceExtend'; + +export interface IModifyExtender { + /** + * Modifies a message in a non-destructive way: Properties can be added to it, + * but existing properties cannot be changed. + * + * @param messageId the id of the message to be extended + * @param updater the user who is updating/extending the message + * @return the extender instance for the message + */ + extendMessage(messageId: string, updater: IUser): Promise; + + /** + * Modifies a room in a non-destructive way: Properties can be added to it, + * but existing properties cannot be changed. + * + * @param roomId the id of the room to be extended + * @param updater the user who is updating/extending the room + * @return the extender instance for the room + */ + extendRoom(roomId: string, updater: IUser): Promise; + + /** + * Modifies a video conference in a non-destructive way: Properties can be added to it, + * but existing properties cannot be changed. + */ + extendVideoConference(id: string): Promise; + + /** + * Finishes the extending process, saving the object to the database. + * Note: If there is an issue or error while updating, this will throw an error. + * + * @param extender the extender instance + */ + finish(extender: IRoomExtender | IMessageExtender | IVideoConferenceExtender): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IModifyUpdater.ts b/packages/apps-engine/src/definition/accessors/IModifyUpdater.ts new file mode 100644 index 000000000000..60dcf90b2df7 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IModifyUpdater.ts @@ -0,0 +1,52 @@ +import type { IUser } from '../users'; +import type { ILivechatUpdater } from './ILivechatUpdater'; +import type { IMessageBuilder } from './IMessageBuilder'; +import type { IMessageUpdater } from './IMessageUpdater'; +import type { IRoomBuilder } from './IRoomBuilder'; +import type { IUserUpdater } from './IUserUpdater'; + +export interface IModifyUpdater { + /** + * Get the updater object responsible for the + * Livechat integrations + */ + getLivechatUpdater(): ILivechatUpdater; + + /** + * Gets the update object responsible for + * methods that update users + */ + getUserUpdater(): IUserUpdater; + + /** + * Get the updater object responsible for + * methods that update messages + */ + getMessageUpdater(): IMessageUpdater; + + /** + * Modifies an existing message. + * Raises an exception if a non-existent messageId is supplied + * + * @param messageId the id of the existing message to modfiy and build + * @param updater the user who is updating the message + */ + message(messageId: string, updater: IUser): Promise; + + /** + * Modifies an existing room. + * Raises an exception if a non-existent roomId is supplied + * + * @param roomId the id of the existing room to modify and build + * @param updater the user who is updating the room + */ + room(roomId: string, updater: IUser): Promise; + + /** + * Finishes the updating process, saving the object to the database. + * Note: If there is an issue or error while updating, this will throw an error. + * + * @param builder the builder instance + */ + finish(builder: IMessageBuilder | IRoomBuilder): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/INotifier.ts b/packages/apps-engine/src/definition/accessors/INotifier.ts new file mode 100644 index 000000000000..a41fb22c4ff6 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/INotifier.ts @@ -0,0 +1,63 @@ +import type { IMessage } from '../messages'; +import type { IRoom } from '../rooms'; +import type { IUser } from '../users'; +import type { IMessageBuilder } from './IMessageBuilder'; + +export enum TypingScope { + Room = 'room', +} + +export interface ITypingOptions { + /** + * The typing scope where the typing message should be presented, + * TypingScope.Room by default. + */ + scope?: TypingScope; + /** + * The id of the typing scope + * + * TypingScope.Room <-> room.id + */ + id: string; + /** + * The name of the user who is typing the message + * + * **Note**: If not provided, it will use app assigned + * user's name by default. + */ + username?: string; +} + +export interface INotifier { + /** + * Notifies the provided user of the provided message. + * + * **Note**: Notifications only are shown to the user if they are + * online and it only stays around for the duration of their session. + * + * @param user The user who should be notified + * @param message The message with the content to notify the user about + */ + notifyUser(user: IUser, message: IMessage): Promise; + + /** + * Notifies all of the users in the provided room. + * + * **Note**: Notifications only are shown to those online + * and it only stays around for the duration of their session. + * + * @param room The room which to notify the users in + * @param message The message content to notify users about + */ + notifyRoom(room: IRoom, message: IMessage): Promise; + + /** + * Notifies all of the users a typing indicator in the provided scope. + * + * @returns a cancellation function to stop typing + */ + typing(options: ITypingOptions): Promise<() => Promise>; + + /** Gets a new message builder for building a notification message. */ + getMessageBuilder(): IMessageBuilder; +} diff --git a/packages/apps-engine/src/definition/accessors/IOAuthApp.ts b/packages/apps-engine/src/definition/accessors/IOAuthApp.ts new file mode 100644 index 000000000000..1c2edbe19c95 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IOAuthApp.ts @@ -0,0 +1,13 @@ +export interface IOAuthApp { + id: string; + name: string; + active: boolean; + clientId?: string; + clientSecret?: string; + redirectUri: string; + createdAt?: string; + updatedAt?: string; + createdBy: { username: string; id: string }; +} + +export type IOAuthAppParams = Omit; diff --git a/packages/apps-engine/src/definition/accessors/IOAuthAppsModify.ts b/packages/apps-engine/src/definition/accessors/IOAuthAppsModify.ts new file mode 100644 index 000000000000..72d0a4ddce5a --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IOAuthAppsModify.ts @@ -0,0 +1,23 @@ +import type { IOAuthAppParams } from './IOAuthApp'; + +export interface IOAuthAppsModify { + /** + * Create an OAuthApp + * @param OAuthApp - the OAuth app to create, in case the clientId and the clientSecret is not sent it will generate automatically + * @param appId - the app id + */ + createOAuthApp(OAuthApp: IOAuthAppParams, appId: string): Promise; + /** + * Update the OAuth app info + * @param OAuthApp - OAuth data that will be updated + * @param id - OAuth app id + * @param appId - the app id + */ + updateOAuthApp(OAuthApp: IOAuthAppParams, id: string, appId: string): Promise; + /** + * Deletes the OAuth app + * @param id - OAuth app id + * @param appId - the app id + */ + deleteOAuthApp(id: string, appId: string): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IOAuthAppsReader.ts b/packages/apps-engine/src/definition/accessors/IOAuthAppsReader.ts new file mode 100644 index 000000000000..97d544cd9366 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IOAuthAppsReader.ts @@ -0,0 +1,16 @@ +import type { IOAuthApp } from './IOAuthApp'; + +export interface IOAuthAppsReader { + /** + * Returns the OAuth app info by its id + * @param id - OAuth app id + * @param appId - the app id + */ + getOAuthAppById(id: string, appId: string): Promise; + /** + * Returns the OAuth app info by its name + * @param name - OAuth app name + * @param appId - the app id + */ + getOAuthAppByName(name: string, appId: string): Promise>; +} diff --git a/packages/apps-engine/src/definition/accessors/IPersistence.ts b/packages/apps-engine/src/definition/accessors/IPersistence.ts new file mode 100644 index 000000000000..30f1d676539e --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IPersistence.ts @@ -0,0 +1,97 @@ +import type { RocketChatAssociationRecord } from '../metadata'; + +/** + * Provides an accessor write data to the App's persistent storage. + * A App only has access to its own persistent storage and does not + * have access to any other App's. + */ +export interface IPersistence { + /** + * Creates a new record in the App's persistent storage, returning the resulting "id". + * + * @param data the actual data to store, must be an object otherwise it will error out. + * @return the resulting record's id + */ + create(data: object): Promise; + + /** + * Creates a new record in the App's persistent storage with the associated information + * being provided. + * + * @param data the actual data to store, must be an object otherwise it will error out + * @param association the association data which includes the model and record id + * @return the resulting record's id + */ + createWithAssociation(data: object, association: RocketChatAssociationRecord): Promise; + + /** + * Creates a new record in the App's persistent storage with the data being + * associated with more than one Rocket.Chat record. + * + * @param data the actual data to store, must be an object otherwise it will error out + * @param associations an array of association data which includes the model and record id + * @return the resulting record's id + */ + createWithAssociations(data: object, associations: Array): Promise; + + /** + * Updates an existing record with the data provided in the App's persistent storage. + * This will throw an error if the record doesn't currently exist or if the data is not an object. + * + * @param id the data record's id + * @param data the actual data to store, must be an object otherwise it will error out + * @param upsert whether a record should be created if the id to be updated does not exist + * @return the id of the updated/upserted record + */ + update(id: string, data: object, upsert?: boolean): Promise; + + /** + * Updates an existing record with the data provided in the App's persistent storage which are + * associated with provided information. + * This will throw an error if the record doesn't currently exist or if the data is not an object. + * + * @param association the association record + * @param data the actual data to store, must be an object otherwise it will error out + * @param upsert whether a record should be created if the id to be updated does not exist + * @return the id of the updated/upserted record + */ + updateByAssociation(association: RocketChatAssociationRecord, data: object, upsert?: boolean): Promise; + + /** + * Updates an existing record with the data provided in the App's persistent storage which are + * associated with more than one Rocket.Chat record. + * This will throw an error if the record doesn't currently exist or if the data is not an object. + * + * @param associations an array of association data which includes the model and record id + * @param data the actual data to store, must be an object otherwise it will error out + * @param upsert whether a record should be created if the id to be updated does not exist + * @return the id of the updated/upserted record + */ + updateByAssociations(associations: Array, data: object, upsert?: boolean): Promise; + + /** + * Removes a record by the provided id and returns the removed record. + * + * @param id of the record to remove + * @return the data record which was removed + */ + remove(id: string): Promise; + + /** + * Removes all of the records in persistent storage which are associated with the provided information. + * + * @param association the information about the association for the records to be removed + * @return the data of the removed records + */ + removeByAssociation(association: RocketChatAssociationRecord): Promise>; + + /** + * Removes all of the records in persistent storage which are associated with the provided information. + * More than one association acts like an AND which means a record in persistent storage must have all + * of the associations to be considered a match. + * + * @param associations the information about the associations for the records to be removed + * @return the data of the removed records + */ + removeByAssociations(associations: Array): Promise>; +} diff --git a/packages/apps-engine/src/definition/accessors/IPersistenceRead.ts b/packages/apps-engine/src/definition/accessors/IPersistenceRead.ts new file mode 100644 index 000000000000..d0d1178a44d0 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IPersistenceRead.ts @@ -0,0 +1,40 @@ +import type { RocketChatAssociationRecord } from '../metadata'; + +/** + * Provides a read-only accessor for the App's persistent storage. + * A App only has access to its own persistent storage and does not + * have access to any other App's. + */ +export interface IPersistenceRead { + /** + * Retrieves a record from the App's persistent storage by the provided id. + * A "falsey" value (undefined or null or false) is returned should nothing exist + * in the storage by the provided id. + * + * @param id the record to get's id + * @return the record if it exists, falsey if not + */ + read(id: string): Promise; + + /** + * Retrieves a record from the App's persistent storage by the provided id. + * An empty array is returned should there be no records associated with the + * data provided. + * + * @param association the association record to query the persistent storage for + * @return array of the records if any exists, empty array if none exist + */ + readByAssociation(association: RocketChatAssociationRecord): Promise>; + + /** + * Retrieves a record from the App's persistent storage by the provided id. + * Providing more than one association record acts like an AND which means a record + * in persistent storage must have all of the associations to be considered a match. + * An empty array is returned should there be no records associated with the + * data provided. + * + * @param associations the association records to query the persistent storage for + * @return array of the records if any exists, empty array if none exist + */ + readByAssociations(associations: Array): Promise>; +} diff --git a/packages/apps-engine/src/definition/accessors/IRead.ts b/packages/apps-engine/src/definition/accessors/IRead.ts new file mode 100644 index 000000000000..17f66d6218dc --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IRead.ts @@ -0,0 +1,52 @@ +import type { ICloudWorkspaceRead } from './ICloudWorkspaceRead'; +import type { IEnvironmentRead } from './IEnvironmentRead'; +import type { ILivechatRead } from './ILivechatRead'; +import type { IMessageRead } from './IMessageRead'; +import type { INotifier } from './INotifier'; +import type { IOAuthAppsReader } from './IOAuthAppsReader'; +import type { IPersistenceRead } from './IPersistenceRead'; +import type { IRoleRead } from './IRoleRead'; +import type { IRoomRead } from './IRoomRead'; +import type { IThreadRead } from './IThreadRead'; +import type { IUploadRead } from './IUploadRead'; +import type { IUserRead } from './IUserRead'; +import type { IVideoConferenceRead } from './IVideoConferenceRead'; + +/** + * The IRead accessor provides methods for accessing the + * Rocket.Chat's environment in a read-only-fashion. + * It is safe to be injected in multiple places, idempotent and extensible + */ +export interface IRead { + /** Gets the IEnvironmentRead instance, contains settings and environmental variables. */ + getEnvironmentReader(): IEnvironmentRead; + + /** Gets the IThreadRead instance */ + + getThreadReader(): IThreadRead; + + /** Gets the IMessageRead instance. */ + getMessageReader(): IMessageRead; + + /** Gets the IPersistenceRead instance. */ + getPersistenceReader(): IPersistenceRead; + + /** Gets the IRoomRead instance. */ + getRoomReader(): IRoomRead; + + /** Gets the IUserRead instance. */ + getUserReader(): IUserRead; + + /** Gets the INotifier for notifying users/rooms. */ + getNotifier(): INotifier; + + getLivechatReader(): ILivechatRead; + getUploadReader(): IUploadRead; + getCloudWorkspaceReader(): ICloudWorkspaceRead; + + getVideoConferenceReader(): IVideoConferenceRead; + + getOAuthAppsReader(): IOAuthAppsReader; + + getRoleReader(): IRoleRead; +} diff --git a/packages/apps-engine/src/definition/accessors/IRoleRead.ts b/packages/apps-engine/src/definition/accessors/IRoleRead.ts new file mode 100644 index 000000000000..fb56ed306a32 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IRoleRead.ts @@ -0,0 +1,27 @@ +import type { IRole } from '../roles'; + +/** + * Interface for reading roles. + */ +export interface IRoleRead { + /** + * Retrieves a role by its id or name. + * @param idOrName The id or name of the role to retrieve. + * @param appId The id of the app. + * @returns The role, if found. + * @returns null if no role is found. + * @throws If there is an error while retrieving the role. + */ + getOneByIdOrName(idOrName: IRole['id'] | IRole['name'], appId: string): Promise; + + /** + * Retrieves all custom roles. + * @param appId The id of the app. + * @returns All custom roles. + * @throws If there is an error while retrieving the roles. + * @throws If the app does not have the necessary permissions. + * @see IRole.protected + * @see AppPermissions.role.read + */ + getCustomRoles(appId: string): Promise>; +} diff --git a/packages/apps-engine/src/definition/accessors/IRoomBuilder.ts b/packages/apps-engine/src/definition/accessors/IRoomBuilder.ts new file mode 100644 index 000000000000..b92955896380 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IRoomBuilder.ts @@ -0,0 +1,186 @@ +import type { RocketChatAssociationModel } from '../metadata'; +import type { IRoom, RoomType } from '../rooms'; +import type { IUser } from '../users'; + +/** + * Interface for building out a room. + * Please note, a room creator, name, and type must be set otherwise you will NOT + * be able to successfully save the room object. + */ +export interface IRoomBuilder { + kind: RocketChatAssociationModel.ROOM | RocketChatAssociationModel.DISCUSSION; + + /** + * Provides a convient way to set the data for the room. + * Note: Providing an "id" field here will be ignored. + * + * @param room the room data to set + */ + setData(room: Partial): IRoomBuilder; + + /** + * Sets the display name of this room. + * + * @param name the display name of the room + */ + setDisplayName(name: string): IRoomBuilder; + + /** + * Gets the display name of this room. + */ + getDisplayName(): string; + + /** + * Sets the slugified name of this room, it must align to the rules of Rocket.Chat room + * names otherwise there will be an error thrown (no spaces, special characters, etc). + * + * @param name the slugified name + */ + setSlugifiedName(name: string): IRoomBuilder; + + /** + * Gets the slugified name of this room. + */ + getSlugifiedName(): string; + + /** + * Sets the room's type. + * + * @param type the room type + */ + setType(type: RoomType): IRoomBuilder; + + /** + * Gets the room's type. + */ + getType(): RoomType; + + /** + * Sets the creator of the room. + * + * @param creator the user who created the room + */ + setCreator(creator: IUser): IRoomBuilder; + + /** + * Gets the room's creator. + */ + getCreator(): IUser; + + /** + * Adds a user to the room, these are by username until further notice. + * + * @param username the user's username to add to the room + * @deprecated in favor of `addMemberToBeAddedByUsername`. This method will be removed on version 2.0.0 + */ + addUsername(username: string): IRoomBuilder; + + /** + * Sets the usernames of who are joined to the room. + * + * @param usernames the list of usernames + * @deprecated in favor of `setMembersByUsernames`. This method will be removed on version 2.0.0 + */ + setUsernames(usernames: Array): IRoomBuilder; + + /** + * Gets the usernames of users in the room. + * @deprecated in favor of `getMembersUsernames`. This method will be removed on version 2.0.0 + */ + getUsernames(): Array; + + /** + * Adds a member to the room by username + * + * @param username the user's username to add to the room + */ + addMemberToBeAddedByUsername(username: string): IRoomBuilder; + + /** + * Sets a list of members to the room by usernames + * + * @param usernames the list of usernames + */ + setMembersToBeAddedByUsernames(usernames: Array): IRoomBuilder; + + /** + * Gets the list of usernames of the members who are been added to the room + */ + getMembersToBeAddedUsernames(): Array; + + /** + * Sets whether this room should be a default room or not. + * This means that new users will automatically join this room + * when they join the server. + * + * @param isDefault room should be default or not + */ + setDefault(isDefault: boolean): IRoomBuilder; + + /** + * Gets whether this room is a default room or not. + */ + getIsDefault(): boolean; + + /** + * Sets whether this room should be in read only state or not. + * This means that users without the required permission to talk when + * a room is muted will not be able to talk but instead will only be + * able to read the contents of the room. + * + * @param isReadOnly whether it should be read only or not + */ + setReadOnly(isReadOnly: boolean): IRoomBuilder; + + /** + * Gets whether this room is on read only state or not. + */ + getIsReadOnly(): boolean; + + /** + * Sets whether this room should display the system messages (like user join, etc) + * or not. This means that whenever a system event, such as joining or leaving, happens + * then Rocket.Chat won't send the message to the channel. + * + * @param displaySystemMessages whether the messages should display or not + */ + setDisplayingOfSystemMessages(displaySystemMessages: boolean): IRoomBuilder; + + /** + * Gets whether this room should display the system messages or not. + */ + getDisplayingOfSystemMessages(): boolean; + + /** + * Adds a custom field to the room. + * Note: This will replace an existing field with the same key should it exist already. + * + * @param key the name of the key + * @param value the value of the custom field + */ + addCustomField(key: string, value: object): IRoomBuilder; + + /** + * Sets the entire custom field property to an object provided. This will overwrite + * every existing key/values which are unrecoverable. + * + * @param fields the data to set + */ + setCustomFields(fields: { [key: string]: object }): IRoomBuilder; + + /** + * Gets the custom field property of the room. + */ + getCustomFields(): { [key: string]: object }; + + /** + * Gets user ids of members from a direct message + */ + getUserIds(): Array; + + /** + * Gets the resulting room that has been built up to the point of calling this method. + * Note: modifying the returned value will have no effect. + */ + getRoom(): IRoom; +} diff --git a/packages/apps-engine/src/definition/accessors/IRoomExtender.ts b/packages/apps-engine/src/definition/accessors/IRoomExtender.ts new file mode 100644 index 000000000000..4135c63edd13 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IRoomExtender.ts @@ -0,0 +1,40 @@ +import type { RocketChatAssociationModel } from '../metadata'; +import type { IRoom } from '../rooms'; +import type { IUser } from '../users'; + +export interface IRoomExtender { + kind: RocketChatAssociationModel.ROOM; + + /** + * Adds a custom field to the room. + * Note: This key can not already exist or it will throw an error. + * Note: The key must not contain a period in it, an error will be thrown. + * + * @param key the name of the custom field + * @param value the value of this custom field + */ + addCustomField(key: string, value: any): IRoomExtender; + + /** + * Adds a user to the room. + * + * @param user the user which is to be added to the room + */ + addMember(user: IUser): IRoomExtender; + + /** + * Get a list of users being added to the room. + */ + getMembersBeingAdded(): Array; + + /** + * Get a list of usernames of users being added to the room. + */ + getUsernamesOfMembersBeingAdded(): Array; + + /** + * Gets the resulting room that has been extended at the point of calling this. + * Note: modifying the returned value will have no effect. + */ + getRoom(): IRoom; +} diff --git a/packages/apps-engine/src/definition/accessors/IRoomRead.ts b/packages/apps-engine/src/definition/accessors/IRoomRead.ts new file mode 100644 index 000000000000..f4e0df33239d --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IRoomRead.ts @@ -0,0 +1,93 @@ +import type { GetMessagesOptions } from '../../server/bridges/RoomBridge'; +import type { IMessageRaw } from '../messages/index'; +import type { IRoom } from '../rooms/index'; +import type { IUser } from '../users/index'; + +/** + * This accessor provides methods for accessing + * rooms in a read-only-fashion. + */ +export interface IRoomRead { + /** + * Gets a room by an id. + * + * @param id the id of the room + * @returns the room + */ + getById(id: string): Promise; + + /** + * Gets just the creator of the room by the room's id. + * + * @param id the id of the room + * @returns the creator of the room + */ + getCreatorUserById(id: string): Promise; + + /** + * Gets a room by its name. + * + * @param name the name of the room + * @returns the room + */ + getByName(name: string): Promise; + + /** + * Gets just the creator of the room by the room's name. + * + * @param name the name of the room + * @returns the creator of the room + */ + getCreatorUserByName(name: string): Promise; + + /** + * Retrieves an array of messages from the specified room. + * + * @param roomId The unique identifier of the room from which to retrieve messages. + * @param options Optional parameters for retrieving messages: + * - limit: The maximum number of messages to retrieve. Maximum 100 + * - skip: The number of messages to skip (for pagination). + * - sort: An object defining the sorting order of the messages. Each key is a field to sort by, and the value is either "asc" for ascending order or "desc" for descending order. + * @returns A Promise that resolves to an array of IMessage objects representing the messages in the room. + */ + getMessages(roomId: string, options?: Partial): Promise>; + + /** + * Gets an iterator for all of the users in the provided room. + * + * @param roomId the room's id + * @returns an iterator for the users in the room + */ + getMembers(roomId: string): Promise>; + + /** + * Gets a direct room with all usernames + * @param usernames all usernames belonging to the direct room + * @returns the room + */ + getDirectByUsernames(usernames: Array): Promise; + + /** + * Get a list of the moderators of a given room + * + * @param roomId the room's id + * @returns a list of the users with the moderator role in the room + */ + getModerators(roomId: string): Promise>; + + /** + * Get a list of the owners of a given room + * + * @param roomId the room's id + * @returns a list of the users with the owner role in the room + */ + getOwners(roomId: string): Promise>; + + /** + * Get a list of the leaders of a given room + * + * @param roomId the room's id + * @returns a list of the users with the leader role in the room + */ + getLeaders(roomId: string): Promise>; +} diff --git a/packages/apps-engine/src/definition/accessors/ISchedulerExtend.ts b/packages/apps-engine/src/definition/accessors/ISchedulerExtend.ts new file mode 100644 index 000000000000..fc003e34b587 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ISchedulerExtend.ts @@ -0,0 +1,11 @@ +import type { IProcessor } from '../scheduler'; + +export interface ISchedulerExtend { + /** + * Register processors that can be scheduled to run + * + * @param {Array} processors An array of processors + * @returns List of task ids run at startup, or void no startup run is set + */ + registerProcessors(processors: Array): Promise>; +} diff --git a/packages/apps-engine/src/definition/accessors/ISchedulerModify.ts b/packages/apps-engine/src/definition/accessors/ISchedulerModify.ts new file mode 100644 index 000000000000..04faa8700799 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ISchedulerModify.ts @@ -0,0 +1,31 @@ +import type { IOnetimeSchedule, IRecurringSchedule } from '../scheduler'; + +/** + * This accessor provides methods to work with the Job Scheduler + */ +export interface ISchedulerModify { + /** + * Schedules a registered processor to run _once_. + * + * @param {IOnetimeSchedule} job + * @returns jobid as string + */ + scheduleOnce(job: IOnetimeSchedule): Promise; + /** + * Schedules a registered processor to run in recurrencly according to a given interval + * + * @param {IRecurringSchedule} job + * @returns jobid as string + */ + scheduleRecurring(job: IRecurringSchedule): Promise; + /** + * Cancels a running job given its jobId + * + * @param {string} jobId + */ + cancelJob(jobId: string): Promise; + /** + * Cancels all the running jobs from the app + */ + cancelAllJobs(): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IServerSettingRead.ts b/packages/apps-engine/src/definition/accessors/IServerSettingRead.ts new file mode 100644 index 000000000000..ecb7af279241 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IServerSettingRead.ts @@ -0,0 +1,43 @@ +import type { ISetting } from '../settings/ISetting'; + +/** + * Reader for the settings inside of the server (Rocket.Chat). + * Only a subset of them are exposed to Apps. + */ +export interface IServerSettingRead { + /** + * Gets a server setting by id. + * Please note: a error will be thrown if not found + * or trying to access one that isn't exposed. + * + * @param id the id of the setting to get + * @return the setting + */ + getOneById(id: string): Promise; + + /** + * Gets a server setting's value by id. + * Please note: a error will be thrown if not found + * or trying to access one that isn't exposed. + * + * @param id the id of the setting to get + * @return the setting's value + */ + getValueById(id: string): Promise; + + /** + * Gets all of the server settings which are exposed + * to the Apps. + * + * @return an iterator of the exposed settings + */ + getAll(): Promise>; + + /** + * Checks if the server setting for the id provided is readable, + * will return true or false and won't throw an error. + * + * @param id the server setting id + */ + isReadableById(id: string): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IServerSettingUpdater.ts b/packages/apps-engine/src/definition/accessors/IServerSettingUpdater.ts new file mode 100644 index 000000000000..766285c25953 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IServerSettingUpdater.ts @@ -0,0 +1,6 @@ +import type { ISetting } from '../settings/ISetting'; + +export interface IServerSettingUpdater { + updateOne(setting: ISetting): Promise; + incrementValue(id: ISetting['id'], value?: number): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IServerSettingsModify.ts b/packages/apps-engine/src/definition/accessors/IServerSettingsModify.ts new file mode 100644 index 000000000000..400bd5ae211f --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IServerSettingsModify.ts @@ -0,0 +1,40 @@ +import type { ISetting } from '../settings'; + +/** + * This accessor provides methods to change default setting options + * of Rocket.Chat in a compatible way. It is provided during + * your App's "onEnable". + */ +export interface IServerSettingsModify { + /** + * Hides an existing settings group. + * + * @param name The technical name of the group + */ + hideGroup(name: string): Promise; + + /** + * Hides a setting. This does not influence the actual functionality (the setting will still + * have its value and can be programatically read), but the administrator will not be able to see it anymore + * + * @param id the id of the setting to hide + */ + hideSetting(id: string): Promise; + + /** + * Modifies the configured value of another setting, please use it with caution as an invalid + * setting configuration could cause a Rocket.Chat instance to become unstable. + * + * @param setting the modified setting (id must be provided) + */ + modifySetting(setting: ISetting): Promise; + + /** + * Increases the setting value by the specified amount. + * To be used only with statistic settings that track the amount of times an action has been performed + * + * @param id the id of the existing Rocket.Chat setting + * @param value how much should the count be increased by. Defaults to 1. + */ + incrementValue(id: ISetting['id'], value?: number): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/ISettingRead.ts b/packages/apps-engine/src/definition/accessors/ISettingRead.ts new file mode 100644 index 000000000000..142ee895b161 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ISettingRead.ts @@ -0,0 +1,23 @@ +import type { ISetting } from '../settings/index'; + +/** + * This accessor provides methods for accessing + * App settings in a read-only-fashion. + */ +export interface ISettingRead { + /** + * Gets the App's setting by the provided id. + * Does not throw an error but instead will return undefined it doesn't exist. + * + * @param id the id of the setting + */ + getById(id: string): Promise; + + /** + * Gets the App's setting value by the provided id. + * Note: this will throw an error if the setting doesn't exist + * + * @param id the id of the setting value to get + */ + getValueById(id: string): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/ISettingUpdater.ts b/packages/apps-engine/src/definition/accessors/ISettingUpdater.ts new file mode 100644 index 000000000000..3826286df6c9 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ISettingUpdater.ts @@ -0,0 +1,5 @@ +import type { ISetting } from '../settings/ISetting'; + +export interface ISettingUpdater { + updateValue(id: ISetting['id'], value: ISetting['value']): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/ISettingsExtend.ts b/packages/apps-engine/src/definition/accessors/ISettingsExtend.ts new file mode 100644 index 000000000000..249776379645 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ISettingsExtend.ts @@ -0,0 +1,17 @@ +import type { ISetting } from '../settings/index'; + +/** + * This accessor provides methods for adding custom settings, + * which are displayed on your App's page. + * This is provided on initialization of your App. + */ +export interface ISettingsExtend { + /** + * Adds a setting which can be configured by an administrator. + * Settings can only be added to groups which have been provided by this App earlier + * and if a group is not provided, the setting will appear outside of a group. + * + * @param setting the setting + */ + provideSetting(setting: ISetting): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/ISlashCommandsExtend.ts b/packages/apps-engine/src/definition/accessors/ISlashCommandsExtend.ts new file mode 100644 index 000000000000..4895e61bf96f --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ISlashCommandsExtend.ts @@ -0,0 +1,16 @@ +import type { ISlashCommand } from '../slashcommands'; + +/** + * This accessor provides methods for adding custom slash commands. + * It is provided during the initialization of your App + */ + +export interface ISlashCommandsExtend { + /** + * Adds a slash command which can be used during conversations lateron. + * Should a command already exists an error will be thrown. + * + * @param slashCommand the command information + */ + provideSlashCommand(slashCommand: ISlashCommand): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/ISlashCommandsModify.ts b/packages/apps-engine/src/definition/accessors/ISlashCommandsModify.ts new file mode 100644 index 000000000000..b9e3d4c3e615 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/ISlashCommandsModify.ts @@ -0,0 +1,30 @@ +import type { ISlashCommand } from '../slashcommands'; + +/** + * This accessor provides methods for modifying existing Rocket.Chat slash commands. + * It is provided during "onEnable" of your App. + */ +export interface ISlashCommandsModify { + /** + * Modifies an existing command. The command must either be your App's + * own command or a system command. One App can not modify another + * App's command. + * + * @param slashCommand the modified slash command + */ + modifySlashCommand(slashCommand: ISlashCommand): Promise; + + /** + * Renders an existing slash command un-usable. + * + * @param command the command's usage without the slash + */ + disableSlashCommand(command: string): Promise; + + /** + * Enables an existing slash command to be usable again. + * + * @param command the command's usage without the slash + */ + enableSlashCommand(command: string): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IThreadRead.ts b/packages/apps-engine/src/definition/accessors/IThreadRead.ts new file mode 100644 index 000000000000..72ceae996eec --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IThreadRead.ts @@ -0,0 +1,9 @@ +import type { IMessage } from '../messages/index'; + +/** + * This accessor provides methods for accessing + * Thread messages in a read-only-fashion. + */ +export interface IThreadRead { + getThreadById(id: string): Promise | undefined>; +} diff --git a/packages/apps-engine/src/definition/accessors/IUIController.ts b/packages/apps-engine/src/definition/accessors/IUIController.ts new file mode 100644 index 000000000000..be7e91f5a05b --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IUIController.ts @@ -0,0 +1,31 @@ +import type { Omit } from '../../lib/utils'; +import type { IUIKitErrorInteraction, IUIKitInteraction, IUIKitSurface } from '../uikit'; +import type { IUIKitContextualBarViewParam, IUIKitModalViewParam } from '../uikit/UIKitInteractionResponder'; +import type { IUser } from '../users'; + +export type IUIKitInteractionParam = Omit; +export type IUIKitErrorInteractionParam = Omit; + +export type IUIKitSurfaceViewParam = Omit & Partial>; + +export interface IUIController { + /** + * @deprecated please prefer the `openSurfaceView` method + */ + openModalView(view: IUIKitModalViewParam, context: IUIKitInteractionParam, user: IUser): Promise; + /** + * @deprecated please prefer the `updateSurfaceView` method + */ + updateModalView(view: IUIKitModalViewParam, context: IUIKitInteractionParam, user: IUser): Promise; + /** + * @deprecated please prefer the `openSurfaceView` method + */ + openContextualBarView(view: IUIKitContextualBarViewParam, context: IUIKitInteractionParam, user: IUser): Promise; + /** + * @deprecated please prefer the `updateSurfaceView` method + */ + updateContextualBarView(view: IUIKitContextualBarViewParam, context: IUIKitInteractionParam, user: IUser): Promise; + setViewError(errorInteraction: IUIKitErrorInteractionParam, context: IUIKitInteractionParam, user: IUser): Promise; + openSurfaceView(view: IUIKitSurfaceViewParam, context: IUIKitInteractionParam, user: IUser): Promise; + updateSurfaceView(view: IUIKitSurfaceViewParam, context: IUIKitInteractionParam, user: IUser): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IUIExtend.ts b/packages/apps-engine/src/definition/accessors/IUIExtend.ts new file mode 100644 index 000000000000..3dca2e32809b --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IUIExtend.ts @@ -0,0 +1,5 @@ +import type { IUIActionButtonDescriptor } from '../ui'; + +export interface IUIExtend { + registerButton(button: IUIActionButtonDescriptor): void; +} diff --git a/packages/apps-engine/src/definition/accessors/IUploadCreator.ts b/packages/apps-engine/src/definition/accessors/IUploadCreator.ts new file mode 100644 index 000000000000..25262b42e7d3 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IUploadCreator.ts @@ -0,0 +1,12 @@ +import type { IUpload } from '../uploads'; +import type { IUploadDescriptor } from '../uploads/IUploadDescriptor'; + +export interface IUploadCreator { + /** + * Create an upload to a room + * + * @param buffer A Buffer with the file's content (See [here](https://nodejs.org/api/buffer.html) + * @param descriptor The metadata about the upload + */ + uploadBuffer(buffer: Buffer, descriptor: IUploadDescriptor): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IUploadRead.ts b/packages/apps-engine/src/definition/accessors/IUploadRead.ts new file mode 100644 index 000000000000..ce4029a8c0c8 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IUploadRead.ts @@ -0,0 +1,7 @@ +import type { IUpload } from '../uploads'; + +export interface IUploadRead { + getById(id: string): Promise; + getBufferById(id: string): Promise; + getBuffer(upload: IUpload): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IUserBuilder.ts b/packages/apps-engine/src/definition/accessors/IUserBuilder.ts new file mode 100644 index 000000000000..4e0c52aa893a --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IUserBuilder.ts @@ -0,0 +1,60 @@ +import type { RocketChatAssociationModel } from '../metadata'; +import type { IUser, IUserEmail } from '../users'; + +/** + * Interface for creating a user. + * Please note, a username and email provided must be unique else you will NOT + * be able to successfully save the user object. + */ +export interface IUserBuilder { + kind: RocketChatAssociationModel.USER; + + /** + * Provides a convient way to set the data for the user. + * Note: Providing an "id" field here will be ignored. + * + * @param user the user data to set + */ + setData(user: Partial): IUserBuilder; + + /** + * Sets emails of the user + * + * @param emails the array of email addresses of the user + */ + setEmails(emails: Array): IUserBuilder; + + /** + * Gets emails of the user + */ + getEmails(): Array; + + /** + * Sets the display name of this user. + * + * @param name the display name of the user + */ + setDisplayName(name: string): IUserBuilder; + + /** + * Gets the display name of this user. + */ + getDisplayName(): string; + + /** + * Sets the username for the user + * + * @param username username of the user + */ + setUsername(username: string): IUserBuilder; + + /** + * Gets the username of this user + */ + getUsername(): string; + + /** + * Gets the user + */ + getUser(): Partial; +} diff --git a/packages/apps-engine/src/definition/accessors/IUserRead.ts b/packages/apps-engine/src/definition/accessors/IUserRead.ts new file mode 100644 index 000000000000..33c4c6e455e4 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IUserRead.ts @@ -0,0 +1,22 @@ +import type { IUser } from '../users/index'; + +/** + * This accessor provides methods for accessing + * users in a read-only-fashion. + */ +export interface IUserRead { + getById(id: string): Promise; + + getByUsername(username: string): Promise; + + /** + * Gets the app user of this app. + */ + getAppUser(appId?: string): Promise; + + /** + * Gets the user's badge count (unread messages count). + * @param uid user's id + */ + getUserUnreadMessageCount(uid: string): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IUserUpdater.ts b/packages/apps-engine/src/definition/accessors/IUserUpdater.ts new file mode 100644 index 000000000000..8c57b4dadfa8 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IUserUpdater.ts @@ -0,0 +1,18 @@ +import type { IUser } from '../users/IUser'; + +/** + * Updating a user is a more granular approach, since + * it is one of the more sensitive aspects of Rocket.Chat - + * or any other system for that matter. + * + * Allowing apps to modify _all_ the aspects of a user + * would open a critical surface for them to abuse such + * power and "take hold" of a server, for instance. + */ +export interface IUserUpdater { + updateStatusText(user: IUser, statusText: IUser['statusText']): Promise; + updateStatus(user: IUser, statusText: IUser['statusText'], status: IUser['status']): Promise; + updateBio(user: IUser, bio: IUser['bio']): Promise; + updateCustomFields(user: IUser, customFields: IUser['customFields']): Promise; + deactivate(userId: IUser['id'], confirmRelinquish: boolean): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IVideoConfProvidersExtend.ts b/packages/apps-engine/src/definition/accessors/IVideoConfProvidersExtend.ts new file mode 100644 index 000000000000..c61224893e11 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IVideoConfProvidersExtend.ts @@ -0,0 +1,15 @@ +import type { IVideoConfProvider } from '../videoConfProviders'; + +/** + * This accessor provides methods for adding videoconf providers. + * It is provided during the initialization of your App + */ + +export interface IVideoConfProvidersExtend { + /** + * Adds a videoconf provider + * + * @param provider the provider information + */ + provideVideoConfProvider(provider: IVideoConfProvider): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/IVideoConferenceBuilder.ts b/packages/apps-engine/src/definition/accessors/IVideoConferenceBuilder.ts new file mode 100644 index 000000000000..11b96da0e4ef --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IVideoConferenceBuilder.ts @@ -0,0 +1,34 @@ +import type { RocketChatAssociationModel } from '../metadata'; +import type { AppVideoConference } from '../videoConferences'; + +export interface IVideoConferenceBuilder { + kind: RocketChatAssociationModel.VIDEO_CONFERENCE; + + setData(call: Partial): IVideoConferenceBuilder; + + setRoomId(rid: string): IVideoConferenceBuilder; + + getRoomId(): string; + + setCreatedBy(userId: string): IVideoConferenceBuilder; + + getCreatedBy(): string; + + setProviderName(name: string): IVideoConferenceBuilder; + + getProviderName(): string; + + setProviderData(data: Record): IVideoConferenceBuilder; + + getProviderData(): Record; + + setTitle(name: string): IVideoConferenceBuilder; + + getTitle(): string; + + setDiscussionRid(rid: string | undefined): IVideoConferenceBuilder; + + getDiscussionRid(): string | undefined; + + getVideoConference(): AppVideoConference; +} diff --git a/packages/apps-engine/src/definition/accessors/IVideoConferenceExtend.ts b/packages/apps-engine/src/definition/accessors/IVideoConferenceExtend.ts new file mode 100644 index 000000000000..d9b7e5838368 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IVideoConferenceExtend.ts @@ -0,0 +1,21 @@ +import type { RocketChatAssociationModel } from '../metadata'; +import type { IVideoConferenceUser, VideoConference } from '../videoConferences'; +import type { VideoConferenceMember } from '../videoConferences/IVideoConference'; + +export interface IVideoConferenceExtender { + kind: RocketChatAssociationModel.VIDEO_CONFERENCE; + + setProviderData(value: Record): IVideoConferenceExtender; + + setStatus(value: VideoConference['status']): IVideoConferenceExtender; + + setEndedBy(value: IVideoConferenceUser['_id']): IVideoConferenceExtender; + + setEndedAt(value: VideoConference['endedAt']): IVideoConferenceExtender; + + addUser(userId: VideoConferenceMember['_id'], ts?: VideoConferenceMember['ts']): IVideoConferenceExtender; + + setDiscussionRid(rid: VideoConference['discussionRid']): IVideoConferenceExtender; + + getVideoConference(): VideoConference; +} diff --git a/packages/apps-engine/src/definition/accessors/IVideoConferenceRead.ts b/packages/apps-engine/src/definition/accessors/IVideoConferenceRead.ts new file mode 100644 index 000000000000..aa2d53d70590 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/IVideoConferenceRead.ts @@ -0,0 +1,15 @@ +import type { VideoConference } from '../videoConferences/IVideoConference'; + +/** + * This accessor provides methods for accessing + * video conferences in a read-only-fashion. + */ +export interface IVideoConferenceRead { + /** + * Gets a video conference by an id. + * + * @param id the id of the video conference + * @returns the video conference + */ + getById(id: string): Promise; +} diff --git a/packages/apps-engine/src/definition/accessors/index.ts b/packages/apps-engine/src/definition/accessors/index.ts new file mode 100644 index 000000000000..e98a4208fe13 --- /dev/null +++ b/packages/apps-engine/src/definition/accessors/index.ts @@ -0,0 +1,58 @@ +export * from './IApiExtend'; +export * from './IAppAccessors'; +export * from './IAppInstallationContext'; +export * from './IAppUpdateContext'; +export * from './IAppUninstallationContext'; +export * from './ICloudWorkspaceRead'; +export * from './IConfigurationExtend'; +export * from './IConfigurationModify'; +export * from './IDiscussionBuilder'; +export * from './IEnvironmentalVariableRead'; +export * from './IEnvironmentRead'; +export * from './IEnvironmentWrite'; +export * from './IExternalComponentsExtend'; +export * from './IHttp'; +export * from './ILivechatCreator'; +export * from './ILivechatMessageBuilder'; +export * from './ILivechatRead'; +export * from './ILivechatUpdater'; +export * from './ILogEntry'; +export * from './ILogger'; +export * from './IMessageBuilder'; +export * from './IMessageExtender'; +export * from './IMessageRead'; +export * from './IMessageUpdater'; +export * from './IModify'; +export * from './IModifyCreator'; +export * from './IModifyDeleter'; +export * from './IModifyExtender'; +export * from './IModifyUpdater'; +export * from './INotifier'; +export * from './IPersistence'; +export * from './IPersistenceRead'; +export * from './IRead'; +export * from './IRoleRead'; +export * from './IRoomBuilder'; +export * from './IRoomExtender'; +export * from './IRoomRead'; +export * from './ISchedulerExtend'; +export * from './ISchedulerModify'; +export * from './IServerSettingRead'; +export * from './IServerSettingsModify'; +export * from './IServerSettingUpdater'; +export * from './ISettingRead'; +export * from './ISettingsExtend'; +export * from './ISettingUpdater'; +export * from './ISlashCommandsExtend'; +export * from './ISlashCommandsModify'; +export * from './IUIController'; +export * from './IUIExtend'; +export * from './IUploadCreator'; +export * from './IUploadRead'; +export * from './IUserBuilder'; +export * from './IUserRead'; +export * from './IVideoConferenceBuilder'; +export * from './IVideoConferenceExtend'; +export * from './IVideoConferenceRead'; +export * from './IVideoConfProvidersExtend'; +export * from './IModerationModify'; diff --git a/packages/apps-engine/src/definition/api/ApiEndpoint.ts b/packages/apps-engine/src/definition/api/ApiEndpoint.ts new file mode 100644 index 000000000000..8ab7610c9b4f --- /dev/null +++ b/packages/apps-engine/src/definition/api/ApiEndpoint.ts @@ -0,0 +1,40 @@ +import type { IApp } from '../IApp'; +import { HttpStatusCode } from '../accessors'; +import type { IApiEndpoint } from './IApiEndpoint'; +import type { IApiResponse, IApiResponseJSON } from './IResponse'; + +/** Represents an api endpoint that is being provided. */ +export abstract class ApiEndpoint implements IApiEndpoint { + /** + * The last part of the api URL. Example: https://{your-server-address}/api/apps/public/{your-app-id}/{path} + * or https://{your-server-address}/api/apps/private/{your-app-id}/{private-hash}/{path} + */ + public path: string; + + constructor(public app: IApp) {} + + /** + * Return response with status 200 (OK) and a optional content + * @param content + */ + protected success(content?: any): IApiResponse { + return { + status: HttpStatusCode.OK, + content, + }; + } + + /** + * Return a json response adding Content Type header as + * application/json if not already provided + * @param reponse + */ + protected json(response: IApiResponseJSON): IApiResponse { + if (!response.headers || !response.headers['content-type']) { + response.headers = response.headers || {}; + response.headers['content-type'] = 'application/json'; + } + + return response; + } +} diff --git a/packages/apps-engine/src/definition/api/IApi.ts b/packages/apps-engine/src/definition/api/IApi.ts new file mode 100644 index 000000000000..9ad2f42ed5a8 --- /dev/null +++ b/packages/apps-engine/src/definition/api/IApi.ts @@ -0,0 +1,58 @@ +import type { IApiEndpoint } from './IApiEndpoint'; + +/** + * Represents an api that is being provided. + */ +export interface IApi { + /** + * Provides the visibility method of the URL, see the ApiVisibility descriptions for more information + */ + visibility: ApiVisibility; + /** + * Provides the visibility method of the URL, see the ApiSecurity descriptions for more information + */ + security: ApiSecurity; + /** + * Provide enpoints for this api registry + */ + endpoints: Array; +} + +export enum ApiVisibility { + /** + * A public Api has a fixed format for a url. Using it enables an + * easy to remember structure, however, it also means the url is + * intelligently guessed. As a result, we recommend having some + * sort of security setup if you must have a public api.Whether + * you use the provided security, ApiSecurity, or implement your own. + * Url format: + * `https://{your-server-address}/api/apps/public/{your-app-id}/{path}` + */ + PUBLIC, + /** + * Private Api's contain a random value in the url format, + * making them harder go guess by default. The random value + * will be generated whenever the App is installed on a server. + * This means that the URL will not be the same on any server, + * but will remain the same throughout the lifecycle of an App + * including updates. As a result, if a user uninstalls the App + * and reinstalls the App, then the random value will change. + * Url format: + * `https://{your-server-address}/api/apps/private/{your-app-id}/{random-hash}/{path}` + */ + PRIVATE, +} + +export enum ApiSecurity { + /** + * No security check will be executed agains the calls made to this URL + */ + UNSECURE, + /** + * Only calls containing a valid token will be able to execute the api + * Mutiple tokens can be generated to access the api, by default one + * will be generated automatically. + * @param `X-Auth-Token` + */ + // CHECKSUM_SECRET, +} diff --git a/packages/apps-engine/src/definition/api/IApiEndpoint.ts b/packages/apps-engine/src/definition/api/IApiEndpoint.ts new file mode 100644 index 000000000000..b369fc175dc2 --- /dev/null +++ b/packages/apps-engine/src/definition/api/IApiEndpoint.ts @@ -0,0 +1,47 @@ +import type { IHttp, IModify, IPersistence, IRead } from '../accessors'; +import type { IApiEndpointInfo } from './IApiEndpointInfo'; +import type { IApiExample } from './IApiExample'; +import type { IApiRequest } from './IRequest'; +import type { IApiResponse } from './IResponse'; + +/** + * Represents an api endpoint that is being provided. + */ +export interface IApiEndpoint { + /** + * The last part of the api URL. Example: https://{your-server-address}/api/apps/public/{your-app-id}/{path} + * or https://{your-server-address}/api/apps/private/{your-app-id}/{private-hash}/{path} + */ + path: string; + examples?: { [key: string]: IApiExample }; + /** + * Whether this endpoint requires an authenticated user to access it. + * + * The authentication will be done by the host server using its own + * authentication system. + * + * If no authentication is provided, the request will be automatically + * rejected with a 401 status code. + */ + authRequired?: boolean; + + /** + * The methods that are available for this endpoint. + * This property is provided by the Runtime and should not be set manually. + * + * Its values are used on the Apps-Engine to validate the request method. + */ + _availableMethods?: string[]; + + /** + * Called whenever the publically accessible url for this App is called, + * if you handle the methods differently then split it out so your code doesn't get too big. + */ + get?(request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify, http: IHttp, persis: IPersistence): Promise; + post?(request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify, http: IHttp, persis: IPersistence): Promise; + put?(request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify, http: IHttp, persis: IPersistence): Promise; + delete?(request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify, http: IHttp, persis: IPersistence): Promise; + head?(request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify, http: IHttp, persis: IPersistence): Promise; + options?(request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify, http: IHttp, persis: IPersistence): Promise; + patch?(request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify, http: IHttp, persis: IPersistence): Promise; +} diff --git a/packages/apps-engine/src/definition/api/IApiEndpointInfo.ts b/packages/apps-engine/src/definition/api/IApiEndpointInfo.ts new file mode 100644 index 000000000000..de9144784b34 --- /dev/null +++ b/packages/apps-engine/src/definition/api/IApiEndpointInfo.ts @@ -0,0 +1,6 @@ +export interface IApiEndpointInfo { + basePath: string; + fullPath: string; + appId: string; + hash?: string; +} diff --git a/packages/apps-engine/src/definition/api/IApiEndpointMetadata.ts b/packages/apps-engine/src/definition/api/IApiEndpointMetadata.ts new file mode 100644 index 000000000000..0ede26045f79 --- /dev/null +++ b/packages/apps-engine/src/definition/api/IApiEndpointMetadata.ts @@ -0,0 +1,10 @@ +import type { IApiExample } from './IApiExample'; + +export interface IApiEndpointMetadata { + path: string; + computedPath: string; + methods: Array; + examples?: { + [key: string]: IApiExample; + }; +} diff --git a/packages/apps-engine/src/definition/api/IApiExample.ts b/packages/apps-engine/src/definition/api/IApiExample.ts new file mode 100644 index 000000000000..f870d59e643d --- /dev/null +++ b/packages/apps-engine/src/definition/api/IApiExample.ts @@ -0,0 +1,19 @@ +/** + * Represents the parameters of an api example. + */ +export interface IApiExample { + params?: { [key: string]: string }; + query?: { [key: string]: string }; + headers?: { [key: string]: string }; + content?: any; +} + +/** + * Decorator to describe api examples + */ +export function example(options: IApiExample) { + return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => { + target.examples = target.examples || {}; + target.examples[propertyKey] = options; + }; +} diff --git a/packages/apps-engine/src/definition/api/IRequest.ts b/packages/apps-engine/src/definition/api/IRequest.ts new file mode 100644 index 000000000000..027de11b2026 --- /dev/null +++ b/packages/apps-engine/src/definition/api/IRequest.ts @@ -0,0 +1,16 @@ +import type { RequestMethod } from '../accessors'; +import type { IUser } from '../users'; + +export interface IApiRequest { + method: RequestMethod; + headers: { [key: string]: string }; + query: { [key: string]: string }; + params: { [key: string]: string }; + content: any; + privateHash?: string; + /** + * The user that is making the request, as + * authenticated by Rocket.Chat's strategy. + */ + user?: IUser; +} diff --git a/packages/apps-engine/src/definition/api/IResponse.ts b/packages/apps-engine/src/definition/api/IResponse.ts new file mode 100644 index 000000000000..8f394b8a93b0 --- /dev/null +++ b/packages/apps-engine/src/definition/api/IResponse.ts @@ -0,0 +1,13 @@ +import type { HttpStatusCode } from '../accessors'; + +export interface IApiResponse { + status: HttpStatusCode; + headers?: { [key: string]: string }; + content?: any; +} + +export interface IApiResponseJSON { + status: HttpStatusCode; + headers?: { [key: string]: string }; + content?: { [key: string]: any }; +} diff --git a/packages/apps-engine/src/definition/api/index.ts b/packages/apps-engine/src/definition/api/index.ts new file mode 100644 index 000000000000..41e4482f2ec6 --- /dev/null +++ b/packages/apps-engine/src/definition/api/index.ts @@ -0,0 +1,8 @@ +export { ApiEndpoint } from './ApiEndpoint'; +export { IApi, ApiVisibility, ApiSecurity } from './IApi'; +export { IApiEndpoint } from './IApiEndpoint'; +export { IApiEndpointInfo } from './IApiEndpointInfo'; +export { IApiExample, example } from './IApiExample'; +export { IApiRequest } from './IRequest'; +export { IApiResponse } from './IResponse'; +export { IApiEndpointMetadata } from './IApiEndpointMetadata'; diff --git a/packages/apps-engine/src/definition/app-schema.json b/packages/apps-engine/src/definition/app-schema.json new file mode 100644 index 000000000000..68c2e0c19edc --- /dev/null +++ b/packages/apps-engine/src/definition/app-schema.json @@ -0,0 +1,75 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Rocket.Chat App", + "description": "A Rocket.Chat App declaration for usage inside of Rocket.Chat.", + "type": "object", + "properties": { + "id": { + "description": "The App's unique identifier in uuid v4 format. This is optional, although recommended, however if you are going to publish on the App store, you will be assigned one.", + "type": "string", + "pattern": "^[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}$", + "minLength": 36, + "maxLength": 36 + }, + "name": { + "description": "The public visible name of this App.", + "type": "string" + }, + "nameSlug": { + "description": "A url friendly slugged version of your App's name.", + "type": "string", + "pattern": "^([a-z]|\\-)+$", + "minLength": 3 + }, + "version": { + "description": "The version of this App which will be used for display publicly and letting users know there is an update. This uses the semver format.", + "type": "string", + "pattern": "^(?:\\d*)\\.(?:\\d*)\\.(?:\\d*)$", + "minLength": 5 + }, + "description": { + "description": "A description of this App, used to explain what this App does and provides for the user.", + "type": "string" + }, + "requiredApiVersion": { + "description": "The required version of the App's API which this App depends on. This uses the semver format.", + "type": "string", + "pattern": "^(?:\\^|~)?(?:\\d*)\\.(?:\\d*)\\.(?:\\d*)$", + "minLength": 5 + }, + "author": { + "type": "object", + "properties": { + "name": { + "description": "The author's name who created this App.", + "type": "string" + }, + "support": { + "description": "The place where people can get support for this App, whether email or website.", + "type": "string" + }, + "homepage": { + "description": "The homepage for this App, it can be a Github or the author's website.", + "type": "string", + "format": "uri" + } + }, + "required": ["name", "support"] + }, + "classFile": { + "type": "string", + "description": "The name of the file which contains your App TypeScript source code.", + "pattern": "^.*\\.(ts)$" + }, + "iconFile": { + "type": "string", + "description": "The name of the file to use as the icon.", + "pattern": "^.*\\.(png|jpg|jpeg|gif)$" + }, + "assetsFolder": { + "type": "string", + "description": "The name of the folder which contains all of your resources, it should not start with a period." + } + }, + "required": ["id", "name", "nameSlug", "version", "description", "requiredApiVersion", "author", "classFile", "iconFile"] +} diff --git a/packages/apps-engine/src/definition/assets/IAsset.ts b/packages/apps-engine/src/definition/assets/IAsset.ts new file mode 100644 index 000000000000..30c54ae6565d --- /dev/null +++ b/packages/apps-engine/src/definition/assets/IAsset.ts @@ -0,0 +1,6 @@ +export interface IAsset { + name: string; + path: string; + type: string; + public: boolean; +} diff --git a/packages/apps-engine/src/definition/assets/IAssetProvider.ts b/packages/apps-engine/src/definition/assets/IAssetProvider.ts new file mode 100644 index 000000000000..4e1da50222fa --- /dev/null +++ b/packages/apps-engine/src/definition/assets/IAssetProvider.ts @@ -0,0 +1,5 @@ +import type { IAsset } from './IAsset'; + +export interface IAssetProvider { + getAssets(): Array; +} diff --git a/packages/apps-engine/src/definition/assets/index.ts b/packages/apps-engine/src/definition/assets/index.ts new file mode 100644 index 000000000000..98abab64ddf5 --- /dev/null +++ b/packages/apps-engine/src/definition/assets/index.ts @@ -0,0 +1,4 @@ +import { IAsset } from './IAsset'; +import { IAssetProvider } from './IAssetProvider'; + +export { IAsset, IAssetProvider }; diff --git a/packages/apps-engine/src/definition/cloud/IWorkspaceToken.ts b/packages/apps-engine/src/definition/cloud/IWorkspaceToken.ts new file mode 100644 index 000000000000..40a46bf7e37f --- /dev/null +++ b/packages/apps-engine/src/definition/cloud/IWorkspaceToken.ts @@ -0,0 +1,4 @@ +export interface IWorkspaceToken { + token: string; + expiresAt: Date; +} diff --git a/packages/apps-engine/src/definition/email/IEmail.ts b/packages/apps-engine/src/definition/email/IEmail.ts new file mode 100644 index 000000000000..ca81b23e5bcc --- /dev/null +++ b/packages/apps-engine/src/definition/email/IEmail.ts @@ -0,0 +1,9 @@ +export interface IEmail { + to: string | string[]; + from: string; + replyTo?: string; + subject: string; + html?: string; + text?: string; + headers?: string; +} diff --git a/packages/apps-engine/src/definition/email/IEmailDescriptor.ts b/packages/apps-engine/src/definition/email/IEmailDescriptor.ts new file mode 100644 index 000000000000..168bae039168 --- /dev/null +++ b/packages/apps-engine/src/definition/email/IEmailDescriptor.ts @@ -0,0 +1,11 @@ +export interface IEmailDescriptor { + from?: string | undefined; + to?: string | Array | undefined; + cc?: string | Array | undefined; + bcc?: string | Array | undefined; + replyTo?: string | Array | undefined; + subject?: string | undefined; + text?: string | undefined; + html?: string | undefined; + headers?: Record | undefined; +} diff --git a/packages/apps-engine/src/definition/email/IPreEmailSent.ts b/packages/apps-engine/src/definition/email/IPreEmailSent.ts new file mode 100644 index 000000000000..2d5e40c92851 --- /dev/null +++ b/packages/apps-engine/src/definition/email/IPreEmailSent.ts @@ -0,0 +1,25 @@ +import type { IEmailDescriptor, IPreEmailSentContext } from '.'; +import type { IHttp, IModify, IPersistence, IRead } from '../accessors'; +import { AppMethod } from '../metadata'; + +/** + * Event interface that allows apps to + * register as a handler of of the `IPreEmailSent` + * event. + * + * This event is trigger before the mailer sends + * an email. + * + * To prevent the email from being sent, you can + * throw an error with a message specifying the + * reason for rejection. + */ +export interface IPreEmailSent { + [AppMethod.EXECUTE_PRE_EMAIL_SENT]( + context: IPreEmailSentContext, + read: IRead, + http: IHttp, + persis: IPersistence, + modify: IModify, + ): Promise; +} diff --git a/packages/apps-engine/src/definition/email/IPreEmailSentContext.ts b/packages/apps-engine/src/definition/email/IPreEmailSentContext.ts new file mode 100644 index 000000000000..7427424f88eb --- /dev/null +++ b/packages/apps-engine/src/definition/email/IPreEmailSentContext.ts @@ -0,0 +1,6 @@ +import type { IEmailDescriptor } from './IEmailDescriptor'; + +export interface IPreEmailSentContext { + context: unknown; + email: IEmailDescriptor; +} diff --git a/packages/apps-engine/src/definition/email/index.ts b/packages/apps-engine/src/definition/email/index.ts new file mode 100644 index 000000000000..6074ebaec4c3 --- /dev/null +++ b/packages/apps-engine/src/definition/email/index.ts @@ -0,0 +1,4 @@ +export * from './IEmailDescriptor'; +export * from './IPreEmailSent'; +export * from './IPreEmailSentContext'; +export * from './IEmail'; diff --git a/packages/apps-engine/src/definition/example-app.json b/packages/apps-engine/src/definition/example-app.json new file mode 100644 index 000000000000..e78048d1d316 --- /dev/null +++ b/packages/apps-engine/src/definition/example-app.json @@ -0,0 +1,13 @@ +{ + //This is an example of how a app.json file will look like + "name": "Testing", + "nameSlug": "testing", + "description": "Testing description", + "version": "1.0.0", + "requiredApiVersion": "0.0.1", + "author": { + "name": "Bradley Hilton", + "support": "https://github.com/RocketChat/Rocket.Chat.Apps-engine" + }, + "classFile": "ExampleApp.ts" +} diff --git a/packages/apps-engine/src/definition/exceptions/AppsEngineException.ts b/packages/apps-engine/src/definition/exceptions/AppsEngineException.ts new file mode 100644 index 000000000000..a3e802aa69d8 --- /dev/null +++ b/packages/apps-engine/src/definition/exceptions/AppsEngineException.ts @@ -0,0 +1,32 @@ +/** + * The internal exception from the framework + * + * It's used to signal to the outside world that + * a _known_ exception has happened during the execution + * of the apps. + * + * It's the base exception for other known classes + * such as UserNotAllowedException, which is used + * to inform the host that an app identified + * that a user cannot perform some action, e.g. + * join a room + */ +export class AppsEngineException extends Error { + public name = 'AppsEngineException'; + + public static JSONRPC_ERROR_CODE = -32070; + + public message: string; + + constructor(message?: string) { + super(); + this.message = message; + } + + public getErrorInfo() { + return { + name: this.name, + message: this.message, + }; + } +} diff --git a/packages/apps-engine/src/definition/exceptions/EssentialAppDisabledException.ts b/packages/apps-engine/src/definition/exceptions/EssentialAppDisabledException.ts new file mode 100644 index 000000000000..e5043d93e336 --- /dev/null +++ b/packages/apps-engine/src/definition/exceptions/EssentialAppDisabledException.ts @@ -0,0 +1,16 @@ +import { AppsEngineException } from '.'; + +/** + * This exception informs the host system that an + * app essential to the execution of a system action + * is disabled, so the action should be halted. + * + * Apps can register to be considered essential to + * the execution of internal events of the framework + * such as `IPreMessageSentPrevent`, `IPreRoomUserJoined`, + * etc. + * + * This is used interally by the framework and is not + * intended to be thrown manually by apps. + */ +export class EssentialAppDisabledException extends AppsEngineException {} diff --git a/packages/apps-engine/src/definition/exceptions/FileUploadNotAllowedException.ts b/packages/apps-engine/src/definition/exceptions/FileUploadNotAllowedException.ts new file mode 100644 index 000000000000..0ae9d98edb3f --- /dev/null +++ b/packages/apps-engine/src/definition/exceptions/FileUploadNotAllowedException.ts @@ -0,0 +1,12 @@ +import { AppsEngineException } from './AppsEngineException'; + +/** + * This exception informs the host system that an + * app has determined that a file upload is not + * allowed to be completed. + * + * Currently it is expected to be thrown by the + * following events: + * - IPreFileUpload + */ +export class FileUploadNotAllowedException extends AppsEngineException {} diff --git a/packages/apps-engine/src/definition/exceptions/InvalidSettingValueException.ts b/packages/apps-engine/src/definition/exceptions/InvalidSettingValueException.ts new file mode 100644 index 000000000000..2b1a193accb2 --- /dev/null +++ b/packages/apps-engine/src/definition/exceptions/InvalidSettingValueException.ts @@ -0,0 +1,8 @@ +import { AppsEngineException } from './AppsEngineException'; + +/** + * This exception informs the host system that an + * app has determined that an invalid setting value + * is passed. + */ +export class InvalidSettingValueException extends AppsEngineException {} diff --git a/packages/apps-engine/src/definition/exceptions/UserNotAllowedException.ts b/packages/apps-engine/src/definition/exceptions/UserNotAllowedException.ts new file mode 100644 index 000000000000..d81969d8f62a --- /dev/null +++ b/packages/apps-engine/src/definition/exceptions/UserNotAllowedException.ts @@ -0,0 +1,14 @@ +import { AppsEngineException } from '.'; + +/** + * This exception informs the host system that an + * app has determined that an user is not allowed + * to perform a specific action. + * + * Currently it is expected to be thrown by the + * following events: + * - IPreRoomCreatePrevent + * - IPreRoomUserJoined + * - IPreRoomUserLeave + */ +export class UserNotAllowedException extends AppsEngineException {} diff --git a/packages/apps-engine/src/definition/exceptions/index.ts b/packages/apps-engine/src/definition/exceptions/index.ts new file mode 100644 index 000000000000..6129978c2f89 --- /dev/null +++ b/packages/apps-engine/src/definition/exceptions/index.ts @@ -0,0 +1,5 @@ +export * from './AppsEngineException'; +export * from './EssentialAppDisabledException'; +export * from './UserNotAllowedException'; +export * from './FileUploadNotAllowedException'; +export * from './InvalidSettingValueException'; diff --git a/packages/apps-engine/src/definition/externalComponent/IExternalComponent.ts b/packages/apps-engine/src/definition/externalComponent/IExternalComponent.ts new file mode 100644 index 000000000000..7c750e1c7e22 --- /dev/null +++ b/packages/apps-engine/src/definition/externalComponent/IExternalComponent.ts @@ -0,0 +1,51 @@ +import type { IExternalComponentOptions } from './IExternalComponentOptions'; +import type { IExternalComponentState } from './IExternalComponentState'; +/** + * Represents an external component that is being provided. + */ +export interface IExternalComponent { + /** + * Provides the appId of the app which the external component belongs to. + */ + appId: string; + /** + * Provides the name of the external component. This key must be unique. + */ + name: string; + /** + * Provides the description of the external component. + */ + description: string; + /** + * Provides the icon's url or base64 string. + */ + icon: string; + /** + * Provides the location which external component needs + * to register, see the ExternalComponentLocation descriptions + * for the more information. + */ + location: ExternalComponentLocation; + /** + * Provides the url that external component will load. + */ + url: string; + /** + * Provides options for the external component. + */ + options?: IExternalComponentOptions; + /** + * Represents the current state of the external component. + * The value is *null* until the ExternalComponentOpened + * event is triggered. It doesn't make sense to get its value in + * PreExternalComponentOpenedPrevent, PreExternalComponentOpenedModify + * and PreExternalComponentOpenedExtend handlers. + */ + state?: IExternalComponentState; +} + +export enum ExternalComponentLocation { + CONTEXTUAL_BAR = 'CONTEXTUAL_BAR', + + MODAL = 'MODAL', +} diff --git a/packages/apps-engine/src/definition/externalComponent/IExternalComponentOptions.ts b/packages/apps-engine/src/definition/externalComponent/IExternalComponentOptions.ts new file mode 100644 index 000000000000..2581c047ab43 --- /dev/null +++ b/packages/apps-engine/src/definition/externalComponent/IExternalComponentOptions.ts @@ -0,0 +1,10 @@ +export interface IExternalComponentOptions { + /** + * The width of the external component + */ + width?: number; + /** + * The height of the external component + */ + height?: number; +} diff --git a/packages/apps-engine/src/definition/externalComponent/IExternalComponentState.ts b/packages/apps-engine/src/definition/externalComponent/IExternalComponentState.ts new file mode 100644 index 000000000000..4c401f3e28d1 --- /dev/null +++ b/packages/apps-engine/src/definition/externalComponent/IExternalComponentState.ts @@ -0,0 +1,16 @@ +import type { IExternalComponentRoomInfo, IExternalComponentUserInfo } from '../../client/definition'; + +/** + * The state of an external component, which contains the + * current user's information and the current room's information. + */ +export interface IExternalComponentState { + /** + * The user who opened this external component + */ + currentUser: IExternalComponentUserInfo; + /** + * The room where the external component belongs to + */ + currentRoom: IExternalComponentRoomInfo; +} diff --git a/packages/apps-engine/src/definition/externalComponent/IPostExternalComponentClosed.ts b/packages/apps-engine/src/definition/externalComponent/IPostExternalComponentClosed.ts new file mode 100644 index 000000000000..24d224ba2913 --- /dev/null +++ b/packages/apps-engine/src/definition/externalComponent/IPostExternalComponentClosed.ts @@ -0,0 +1,16 @@ +import type { IHttp, IPersistence, IRead } from '../accessors'; +import type { IExternalComponent } from './IExternalComponent'; + +/** + * Handler called after an external component is closed. + */ +export interface IPostExternalComponentClosed { + /** + * Method called after an external component is closed. + * + * @param externalComponent The external component which was closed + * @param read An accessor to the environment + * @param http An accessor to the outside world + */ + executePostExternalComponentClosed(externalComponent: IExternalComponent, read: IRead, http: IHttp, persistence: IPersistence): Promise; +} diff --git a/packages/apps-engine/src/definition/externalComponent/IPostExternalComponentOpened.ts b/packages/apps-engine/src/definition/externalComponent/IPostExternalComponentOpened.ts new file mode 100644 index 000000000000..8a09ebe711d2 --- /dev/null +++ b/packages/apps-engine/src/definition/externalComponent/IPostExternalComponentOpened.ts @@ -0,0 +1,16 @@ +import type { IHttp, IPersistence, IRead } from '../accessors'; +import type { IExternalComponent } from './IExternalComponent'; + +/** + * Handler called after an external component is opened. + */ +export interface IPostExternalComponentOpened { + /** + * Method called after an external component is opened. + * + * @param externalComponent The external component which was opened + * @param read An accessor to the environment + * @param http An accessor to the outside world + */ + executePostExternalComponentOpened(externalComponent: IExternalComponent, read: IRead, http: IHttp, persistence: IPersistence): Promise; +} diff --git a/packages/apps-engine/src/definition/externalComponent/index.ts b/packages/apps-engine/src/definition/externalComponent/index.ts new file mode 100644 index 000000000000..acd4bbf44982 --- /dev/null +++ b/packages/apps-engine/src/definition/externalComponent/index.ts @@ -0,0 +1,5 @@ +import { IExternalComponent } from './IExternalComponent'; +import { IPostExternalComponentClosed } from './IPostExternalComponentClosed'; +import { IPostExternalComponentOpened } from './IPostExternalComponentOpened'; + +export { IExternalComponent, IPostExternalComponentClosed, IPostExternalComponentOpened }; diff --git a/packages/apps-engine/src/definition/livechat/IDepartment.ts b/packages/apps-engine/src/definition/livechat/IDepartment.ts new file mode 100644 index 000000000000..1a59c9835612 --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/IDepartment.ts @@ -0,0 +1,17 @@ +export interface IDepartment { + id: string; + name?: string; + email?: string; + description?: string; + offlineMessageChannelName?: string; + requestTagBeforeClosingChat?: false; + chatClosingTags?: Array; + abandonedRoomsCloseCustomMessage?: string; + waitingQueueMessage?: string; + departmentsAllowedToForward?: string; + enabled: boolean; + updatedAt: Date; + numberOfAgents: number; + showOnOfflineForm: boolean; + showOnRegistration: boolean; +} diff --git a/packages/apps-engine/src/definition/livechat/ILivechatEventContext.ts b/packages/apps-engine/src/definition/livechat/ILivechatEventContext.ts new file mode 100644 index 000000000000..b94f07ef0250 --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/ILivechatEventContext.ts @@ -0,0 +1,7 @@ +import type { IUser } from '../users'; +import type { ILivechatRoom } from './ILivechatRoom'; + +export interface ILivechatEventContext { + agent: IUser; + room: ILivechatRoom; +} diff --git a/packages/apps-engine/src/definition/livechat/ILivechatMessage.ts b/packages/apps-engine/src/definition/livechat/ILivechatMessage.ts new file mode 100644 index 000000000000..d7cc5497d70e --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/ILivechatMessage.ts @@ -0,0 +1,7 @@ +import type { IMessage } from '../messages/IMessage'; +import type { IVisitor } from './IVisitor'; + +export interface ILivechatMessage extends IMessage { + visitor?: IVisitor; + token?: string; +} diff --git a/packages/apps-engine/src/definition/livechat/ILivechatRoom.ts b/packages/apps-engine/src/definition/livechat/ILivechatRoom.ts new file mode 100644 index 000000000000..e3f55142331a --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/ILivechatRoom.ts @@ -0,0 +1,55 @@ +import { RoomType } from '../rooms'; +import type { IRoom } from '../rooms/IRoom'; +import type { IUser } from '../users'; +import type { IDepartment } from './IDepartment'; +import type { IVisitor } from './IVisitor'; + +export enum OmnichannelSourceType { + WIDGET = 'widget', + EMAIL = 'email', + SMS = 'sms', + APP = 'app', + OTHER = 'other', +} + +interface IOmnichannelSourceApp { + type: 'app'; + id: string; + // A human readable alias that goes with the ID, for post analytical purposes + alias?: string; + // A label to be shown in the room info + label?: string; + sidebarIcon?: string; + defaultIcon?: string; +} +type OmnichannelSource = + | { + type: Exclude; + } + | IOmnichannelSourceApp; + +export interface IVisitorChannelInfo { + lastMessageTs?: Date; + phone?: string; +} + +export interface ILivechatRoom extends IRoom { + visitor: IVisitor; + visitorChannelInfo?: IVisitorChannelInfo; + department?: IDepartment; + closer: 'user' | 'visitor' | 'bot'; + closedBy?: IUser; + servedBy?: IUser; + responseBy?: IUser; + isWaitingResponse: boolean; + isOpen: boolean; + closedAt?: Date; + source?: OmnichannelSource; +} + +export const isLivechatRoom = (room: IRoom): room is ILivechatRoom => { + return room.type === RoomType.LIVE_CHAT; +}; +export const isLivechatFromApp = (room: ILivechatRoom): room is ILivechatRoom & { source: IOmnichannelSourceApp } => { + return room.source && room.source.type === 'app'; +}; diff --git a/packages/apps-engine/src/definition/livechat/ILivechatRoomClosedHandler.ts b/packages/apps-engine/src/definition/livechat/ILivechatRoomClosedHandler.ts new file mode 100644 index 000000000000..dba672ad391b --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/ILivechatRoomClosedHandler.ts @@ -0,0 +1,19 @@ +import type { IHttp, IPersistence, IRead } from '../accessors'; +import { AppMethod } from '../metadata'; +import type { ILivechatRoom } from './ILivechatRoom'; + +/** + * Handler called after a livechat room is closed. + * @deprecated please prefer the IPostLivechatRoomClosed event + */ +export interface ILivechatRoomClosedHandler { + /** + * Method called *after* a livechat room is closed. + * + * @param livechatRoom The livechat room which is closed. + * @param read An accessor to the environment + * @param http An accessor to the outside world + * @param persistence An accessor to the App's persistence + */ + [AppMethod.EXECUTE_LIVECHAT_ROOM_CLOSED_HANDLER](data: ILivechatRoom, read: IRead, http: IHttp, persistence: IPersistence): Promise; +} diff --git a/packages/apps-engine/src/definition/livechat/ILivechatTransferData.ts b/packages/apps-engine/src/definition/livechat/ILivechatTransferData.ts new file mode 100644 index 000000000000..988d0a2ec5fd --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/ILivechatTransferData.ts @@ -0,0 +1,8 @@ +import type { IUser } from '../users'; +import type { ILivechatRoom } from './ILivechatRoom'; + +export interface ILivechatTransferData { + currentRoom: ILivechatRoom; + targetAgent?: IUser; + targetDepartment?: string; +} diff --git a/packages/apps-engine/src/definition/livechat/ILivechatTransferEventContext.ts b/packages/apps-engine/src/definition/livechat/ILivechatTransferEventContext.ts new file mode 100644 index 000000000000..74db472751d4 --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/ILivechatTransferEventContext.ts @@ -0,0 +1,15 @@ +import type { IRoom } from '../rooms'; +import type { IUser } from '../users'; +import type { IDepartment } from './IDepartment'; + +export enum LivechatTransferEventType { + AGENT = 'agent', + DEPARTMENT = 'department', +} + +export interface ILivechatTransferEventContext { + type: LivechatTransferEventType; + room: IRoom; + from: IUser | IDepartment; + to: IUser | IDepartment; +} diff --git a/packages/apps-engine/src/definition/livechat/IPostLivechatAgentAssigned.ts b/packages/apps-engine/src/definition/livechat/IPostLivechatAgentAssigned.ts new file mode 100644 index 000000000000..5c322534c9ff --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/IPostLivechatAgentAssigned.ts @@ -0,0 +1,25 @@ +import type { IHttp, IModify, IPersistence, IRead } from '../accessors'; +import { AppMethod } from '../metadata'; +import type { ILivechatEventContext } from './ILivechatEventContext'; + +/** + * Handler called after the assignment of a livechat agent. + */ +export interface IPostLivechatAgentAssigned { + /** + * Handler called *after* the assignment of a livechat agent. + * + * @param data the livechat context data which contains agent's info and room's info. + * @param read An accessor to the environment + * @param http An accessor to the outside world + * @param persis An accessor to the App's persistence + * @param modify An accessor to the modifier + */ + [AppMethod.EXECUTE_POST_LIVECHAT_AGENT_ASSIGNED]( + context: ILivechatEventContext, + read: IRead, + http: IHttp, + persis: IPersistence, + modify?: IModify, + ): Promise; +} diff --git a/packages/apps-engine/src/definition/livechat/IPostLivechatAgentUnassigned.ts b/packages/apps-engine/src/definition/livechat/IPostLivechatAgentUnassigned.ts new file mode 100644 index 000000000000..2884cfa94b83 --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/IPostLivechatAgentUnassigned.ts @@ -0,0 +1,25 @@ +import type { IHttp, IModify, IPersistence, IRead } from '../accessors'; +import { AppMethod } from '../metadata'; +import type { ILivechatEventContext } from './ILivechatEventContext'; + +/** + * Handler called after the unassignment of a livechat agent. + */ +export interface IPostLivechatAgentUnassigned { + /** + * Handler called *after* the unassignment of a livechat agent. + * + * @param data the livechat context data which contains agent's info and room's info. + * @param read An accessor to the environment + * @param http An accessor to the outside world + * @param persis An accessor to the App's persistence + * @param modify An accessor to the modifier + */ + [AppMethod.EXECUTE_POST_LIVECHAT_AGENT_UNASSIGNED]( + context: ILivechatEventContext, + read: IRead, + http: IHttp, + persis: IPersistence, + modify?: IModify, + ): Promise; +} diff --git a/packages/apps-engine/src/definition/livechat/IPostLivechatGuestSaved.ts b/packages/apps-engine/src/definition/livechat/IPostLivechatGuestSaved.ts new file mode 100644 index 000000000000..2c5edc0b9692 --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/IPostLivechatGuestSaved.ts @@ -0,0 +1,19 @@ +import type { IHttp, IModify, IPersistence, IRead } from '../accessors'; +import { AppMethod } from '../metadata'; +import type { IVisitor } from './IVisitor'; + +/** + * Handler called after the guest's info get saved. + */ +export interface IPostLivechatGuestSaved { + /** + * Handler called *after* the guest's info get saved. + * + * @param data the livechat context data which contains guest's info and room's info. + * @param read An accessor to the environment + * @param http An accessor to the outside world + * @param persis An accessor to the App's persistence + * @param modify An accessor to the modifier + */ + [AppMethod.EXECUTE_POST_LIVECHAT_GUEST_SAVED](context: IVisitor, read: IRead, http: IHttp, persis: IPersistence, modify: IModify): Promise; +} diff --git a/packages/apps-engine/src/definition/livechat/IPostLivechatRoomClosed.ts b/packages/apps-engine/src/definition/livechat/IPostLivechatRoomClosed.ts new file mode 100644 index 000000000000..072e02e8c721 --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/IPostLivechatRoomClosed.ts @@ -0,0 +1,19 @@ +import type { IHttp, IModify, IPersistence, IRead } from '../accessors'; +import { AppMethod } from '../metadata'; +import type { ILivechatRoom } from './ILivechatRoom'; + +/** + * Handler called after a livechat room is closed. + */ +export interface IPostLivechatRoomClosed { + /** + * Method called *after* a livechat room is closed. + * + * @param livechatRoom The livechat room which is closed. + * @param read An accessor to the environment + * @param http An accessor to the outside world + * @param persis An accessor to the App's persistence + * @param modify An accessor to the modifier + */ + [AppMethod.EXECUTE_POST_LIVECHAT_ROOM_CLOSED](room: ILivechatRoom, read: IRead, http: IHttp, persis: IPersistence, modify?: IModify): Promise; +} diff --git a/packages/apps-engine/src/definition/livechat/IPostLivechatRoomSaved.ts b/packages/apps-engine/src/definition/livechat/IPostLivechatRoomSaved.ts new file mode 100644 index 000000000000..13b6d1cb2e4b --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/IPostLivechatRoomSaved.ts @@ -0,0 +1,19 @@ +import type { IHttp, IModify, IPersistence, IRead } from '../accessors'; +import { AppMethod } from '../metadata'; +import type { ILivechatRoom } from './ILivechatRoom'; + +/** + * Handler called after the room's info get saved. + */ +export interface IPostLivechatRoomSaved { + /** + * Handler called *after* the room's info get saved. + * + * @param data the livechat context data which contains room's info. + * @param read An accessor to the environment + * @param http An accessor to the outside world + * @param persis An accessor to the App's persistence + * @param modify An accessor to the modifier + */ + [AppMethod.EXECUTE_POST_LIVECHAT_ROOM_SAVED](context: ILivechatRoom, read: IRead, http: IHttp, persis: IPersistence, modify: IModify): Promise; +} diff --git a/packages/apps-engine/src/definition/livechat/IPostLivechatRoomStarted.ts b/packages/apps-engine/src/definition/livechat/IPostLivechatRoomStarted.ts new file mode 100644 index 000000000000..237dcd9566e9 --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/IPostLivechatRoomStarted.ts @@ -0,0 +1,19 @@ +import type { IHttp, IModify, IPersistence, IRead } from '../accessors'; +import { AppMethod } from '../metadata'; +import type { ILivechatRoom } from './ILivechatRoom'; + +/** + * Handler called after a livechat room is started. + */ +export interface IPostLivechatRoomStarted { + /** + * Method called *after* a livechat room is started. + * + * @param livechatRoom The livechat room which is started. + * @param read An accessor to the environment + * @param http An accessor to the outside world + * @param persis An accessor to the App's persistence + * @param modify An accessor to the modifier + */ + [AppMethod.EXECUTE_POST_LIVECHAT_ROOM_STARTED](room: ILivechatRoom, read: IRead, http: IHttp, persis: IPersistence, modify?: IModify): Promise; +} diff --git a/packages/apps-engine/src/definition/livechat/IPostLivechatRoomTransferred.ts b/packages/apps-engine/src/definition/livechat/IPostLivechatRoomTransferred.ts new file mode 100644 index 000000000000..aa86f8d358d3 --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/IPostLivechatRoomTransferred.ts @@ -0,0 +1,13 @@ +import type { IHttp, IModify, IPersistence, IRead } from '../accessors'; +import { AppMethod } from '../metadata'; +import type { ILivechatTransferEventContext } from './ILivechatTransferEventContext'; + +export interface IPostLivechatRoomTransferred { + [AppMethod.EXECUTE_POST_LIVECHAT_ROOM_TRANSFERRED]( + context: ILivechatTransferEventContext, + read: IRead, + http: IHttp, + persis: IPersistence, + modify: IModify, + ): Promise; +} diff --git a/packages/apps-engine/src/definition/livechat/IVisitor.ts b/packages/apps-engine/src/definition/livechat/IVisitor.ts new file mode 100644 index 000000000000..db5876dd912d --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/IVisitor.ts @@ -0,0 +1,16 @@ +import type { IVisitorEmail } from './IVisitorEmail'; +import type { IVisitorPhone } from './IVisitorPhone'; + +export interface IVisitor { + id?: string; + token: string; + username: string; + updatedAt?: Date; + name: string; + department?: string; + phone?: Array; + visitorEmails?: Array; + status?: string; + customFields?: { [key: string]: any }; + livechatData?: { [key: string]: any }; +} diff --git a/packages/apps-engine/src/definition/livechat/IVisitorEmail.ts b/packages/apps-engine/src/definition/livechat/IVisitorEmail.ts new file mode 100644 index 000000000000..a1e35380666e --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/IVisitorEmail.ts @@ -0,0 +1,3 @@ +export interface IVisitorEmail { + address: string; +} diff --git a/packages/apps-engine/src/definition/livechat/IVisitorPhone.ts b/packages/apps-engine/src/definition/livechat/IVisitorPhone.ts new file mode 100644 index 000000000000..fe112777e7d7 --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/IVisitorPhone.ts @@ -0,0 +1,3 @@ +export interface IVisitorPhone { + phoneNumber: string; +} diff --git a/packages/apps-engine/src/definition/livechat/index.ts b/packages/apps-engine/src/definition/livechat/index.ts new file mode 100644 index 000000000000..5ea8d9f42885 --- /dev/null +++ b/packages/apps-engine/src/definition/livechat/index.ts @@ -0,0 +1,38 @@ +import { IDepartment } from './IDepartment'; +import { ILivechatEventContext } from './ILivechatEventContext'; +import { ILivechatMessage } from './ILivechatMessage'; +import { ILivechatRoom } from './ILivechatRoom'; +import { ILivechatRoomClosedHandler } from './ILivechatRoomClosedHandler'; +import { ILivechatTransferData } from './ILivechatTransferData'; +import { ILivechatTransferEventContext, LivechatTransferEventType } from './ILivechatTransferEventContext'; +import { IPostLivechatAgentAssigned } from './IPostLivechatAgentAssigned'; +import { IPostLivechatAgentUnassigned } from './IPostLivechatAgentUnassigned'; +import { IPostLivechatGuestSaved } from './IPostLivechatGuestSaved'; +import { IPostLivechatRoomClosed } from './IPostLivechatRoomClosed'; +import { IPostLivechatRoomSaved } from './IPostLivechatRoomSaved'; +import { IPostLivechatRoomStarted } from './IPostLivechatRoomStarted'; +import { IPostLivechatRoomTransferred } from './IPostLivechatRoomTransferred'; +import { IVisitor } from './IVisitor'; +import { IVisitorEmail } from './IVisitorEmail'; +import { IVisitorPhone } from './IVisitorPhone'; + +export { + ILivechatEventContext, + ILivechatMessage, + ILivechatRoom, + IPostLivechatAgentAssigned, + IPostLivechatAgentUnassigned, + IPostLivechatGuestSaved, + IPostLivechatRoomStarted, + IPostLivechatRoomClosed, + IPostLivechatRoomSaved, + IPostLivechatRoomTransferred, + ILivechatRoomClosedHandler, + ILivechatTransferData, + ILivechatTransferEventContext, + IDepartment, + IVisitor, + IVisitorEmail, + IVisitorPhone, + LivechatTransferEventType, +}; diff --git a/packages/apps-engine/src/definition/messages/IMessage.ts b/packages/apps-engine/src/definition/messages/IMessage.ts new file mode 100644 index 000000000000..d7ea6357497a --- /dev/null +++ b/packages/apps-engine/src/definition/messages/IMessage.ts @@ -0,0 +1,34 @@ +import type { LayoutBlock } from '@rocket.chat/ui-kit'; + +import type { IRoom } from '../rooms'; +import type { IBlock } from '../uikit'; +import type { IUser, IUserLookup } from '../users'; +import type { IMessageAttachment } from './IMessageAttachment'; +import type { IMessageFile } from './IMessageFile'; +import type { IMessageReactions } from './IMessageReaction'; + +export interface IMessage { + id?: string; + threadId?: string; + room: IRoom; + sender: IUser; + text?: string; + createdAt?: Date; + updatedAt?: Date; + editor?: IUser; + editedAt?: Date; + emoji?: string; + avatarUrl?: string; + alias?: string; + file?: IMessageFile; + attachments?: Array; + reactions?: IMessageReactions; + groupable?: boolean; + parseUrls?: boolean; + customFields?: { [key: string]: any }; + blocks?: Array; + starred?: Array<{ _id: string }>; + pinned?: boolean; + pinnedAt?: Date; + pinnedBy?: IUserLookup; +} diff --git a/packages/apps-engine/src/definition/messages/IMessageAction.ts b/packages/apps-engine/src/definition/messages/IMessageAction.ts new file mode 100644 index 000000000000..3f32e4aa781d --- /dev/null +++ b/packages/apps-engine/src/definition/messages/IMessageAction.ts @@ -0,0 +1,17 @@ +import type { MessageActionType } from './MessageActionType'; +import type { MessageProcessingType } from './MessageProcessingType'; + +/** + * Interface which represents an action which can be added to a message. + */ +export interface IMessageAction { + type: MessageActionType; + text?: string; + url?: string; + image_url?: string; + is_webview?: boolean; + webview_height_ratio?: string; + msg?: string; + msg_in_chat_window?: boolean; + msg_processing_type?: MessageProcessingType; +} diff --git a/packages/apps-engine/src/definition/messages/IMessageAttachment.ts b/packages/apps-engine/src/definition/messages/IMessageAttachment.ts new file mode 100644 index 000000000000..96dd8aa1fe34 --- /dev/null +++ b/packages/apps-engine/src/definition/messages/IMessageAttachment.ts @@ -0,0 +1,43 @@ +import type { IMessageAction } from './IMessageAction'; +import type { IMessageAttachmentAuthor } from './IMessageAttachmentAuthor'; +import type { IMessageAttachmentField } from './IMessageAttachmentField'; +import type { IMessageAttachmentTitle } from './IMessageAttachmentTitle'; +import type { MessageActionButtonsAlignment } from './MessageActionButtonsAlignment'; + +/** + * Interface which represents an attachment which can be added to a message. + */ +export interface IMessageAttachment { + /** Causes the image, audio, and video sections to be hidding when this is true. */ + collapsed?: boolean; + /** The color you want the order on the left side to be, supports any valid background-css value. */ + color?: string; // TODO: Maybe we change this to a Color class which has helper methods? + /** The text to display for this attachment. */ + text?: string; + /** Displays the time next to the text portion. */ + timestamp?: Date; + /** Only applicable if the timestamp is provided, as it makes the time clickable to this link. */ + timestampLink?: string; + /** An image that displays to the left of the text, looks better when this is relatively small. */ + thumbnailUrl?: string; + /** Author portion of the attachment. */ + author?: IMessageAttachmentAuthor; + /** Title portion of the attachment. */ + title?: IMessageAttachmentTitle; + /** The image to display, will be "big" and easy to see. */ + imageUrl?: string; + /** Audio file to play, only supports what html's