snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

24
apps/meteor/.babelrc Normal file
View File

@@ -0,0 +1,24 @@
{
"presets": [
"@babel/preset-env",
["@babel/preset-react", { "runtime": "automatic" }],
[
"@babel/preset-typescript",
{
"allowDeclareFields": true
}
]
],
"env": {
"coverage": {
"plugins": [
[
"istanbul",
{
"exclude": ["**/*.spec.js", "**/*.test.js"]
}
]
]
}
}
}

View File

@@ -0,0 +1,44 @@
# This is a sample .codeclimate.yml configured for Engine analysis on Code
# Climate Platform. For an overview of the Code Climate Platform, see here:
# http://docs.codeclimate.com/article/300-the-codeclimate-platform
# Under the engines key, you can configure which engines will analyze your repo.
# Each key is an engine name. For each value, you need to specify enabled: true
# to enable the engine as well as any other engines-specific configuration.
# For more details, see here:
# http://docs.codeclimate.com/article/289-configuring-your-repository-via-codeclimate-yml#platform
# For a list of all available engines, see here:
# http://docs.codeclimate.com/article/296-engines-available-engines
engines:
# to turn on an engine, add it here and set enabled to `true`
# to turn off an engine, set enabled to `false` or remove it
coffeelint:
enabled: true
eslint:
enabled: true
csslint:
enabled: true
# Engines can analyze files and report issues on them, but you can separately
# decide which files will receive ratings based on those issues. This is
# specified by path patterns under the ratings key.
# For more details see here:
# http://docs.codeclimate.com/article/289-configuring-your-repository-via-codeclimate-yml#platform
ratings:
paths:
- "**.coffee"
- "**.js"
- "**.css"
# You can globally exclude files from being analyzed by any engine using the
# exclude_paths key.
exclude_paths:
- "private/moment-locales/**/*"
- "packages/rocketchat-favico/favico.js"
#- "vendor/**/*""

View File

@@ -0,0 +1,102 @@
FROM node:24.15.0-alpine3.23 AS builder
ENV LANG=C.UTF-8
RUN apk add --no-cache python3 make g++ py3-setuptools libc6-compat
COPY . /app
ENV NODE_ENV=production
RUN cd /app/bundle/programs/server \
&& npm install --omit=dev \
&& npm cache clean --force \
# Drop sourcemaps from third-party prod deps and the client bundle:
# - server/npm/node_modules: .map files shipped inside packages
# (openpgp, date-fns, etc.). Node does not read them at runtime.
# - web.browser: client-side bundle, maps have no runtime value in the image.
# Maps under /app/bundle/programs/server/{app,packages,node_modules/meteor}
# are loaded by Meteor's source-map-support at boot, so they are kept.
&& find /app/bundle/programs/server/npm/node_modules -type f -name '*.map' -delete \
&& find /app/bundle/programs/web.browser -type f -name '*.map' -delete \
# The bundle carries build/Release output compiled on the glibc CI host.
# node-gyp-build prefers it over prebuilds/, so that binary shadows the musl
# one shipped in the same package and fails to load at runtime.
&& STALE_BUILDS="$(find /app/bundle/programs/server/npm/node_modules -type d -name prebuilds)" \
&& for d in $STALE_BUILDS; do rm -rf "${d%/prebuilds}/build"; done
FROM node:24.15.0-alpine3.23 AS release-standard
LABEL maintainer="buildmaster@rocket.chat"
ENV LANG=C.UTF-8
# `nogroup` group is historically reserved for NFS.
# We don't use any NFS related tools in this image.
# For the same reason of NFS using the gid, we can also use it as long as there are no conflicts in terms of running processes with the same egid (which is 1 in our case).
# While 65533 raw gid could be used, renaming nogroup to rocketchat here for maximum compatibility with older debian image.
# More info on nobody/nogroup - https://wiki.ubuntu.com/nobody
# Debian wiki - https://wiki.debian.org/SystemGroups
# """
# daemon: Some unprivileged daemons that need to write to files on disk run as daemon.daemon (e.g., portmap, atd, probably others).
# Daemons that don't need to own any files can run as nobody.nogroup instead,
# and more complex or security conscious daemons run as dedicated users.
# The daemon user is also handy for locally installed daemons.
# """
RUN apk add --no-cache shadow deno ttf-dejavu \
# Update OpenSSL
# CVE -> https://scout.docker.com/vulnerabilities/id/CVE-2025-9230?s=alpine&n=openssl&ns=alpine&t=apk&osn=alpine&osv=3.22
&& apk upgrade --no-cache openssl \
&& groupmod -n rocketchat nogroup \
&& useradd -u 65533 -r -g rocketchat rocketchat
# needs a mongo instance - defaults to container linking with alias 'mongo'
ENV DEPLOY_METHOD=docker \
NODE_ENV=production \
MONGO_URL=mongodb://mongo:27017/rocketchat \
HOME=/tmp \
PORT=3000 \
ROOT_URL=http://localhost:3000 \
Accounts_AvatarStorePath=/app/uploads
USER rocketchat
COPY --from=builder --chown=rocketchat:rocketchat /app /app
VOLUME /app/uploads
WORKDIR /app/bundle
EXPOSE 3000
CMD ["node", "main.js"]
FROM rocketchat/dhi-node:24.15.0-alpine3.23-fips AS release-fips
LABEL maintainer="buildmaster@rocket.chat"
# needs a mongo instance - defaults to container linking with alias 'mongo'
ENV DEPLOY_METHOD=docker \
NODE_ENV=production \
MONGO_URL=mongodb://mongo:27017/rocketchat \
HOME=/tmp \
PORT=3000 \
ROOT_URL=http://localhost:3000 \
Accounts_AvatarStorePath=/app/uploads \
APPS_ENGINE_RUNTIME_BACKEND=node
# the hardened image has no /etc/alpine-release, so node-gyp-build would detect
# glibc and load the wrong prebuild for native modules
ENV LIBC=musl
USER node
COPY --from=builder --chown=node:node /app /app
VOLUME /app/uploads
WORKDIR /app/bundle
EXPOSE 3000
CMD ["node", "--force-fips", "main.js"]

View File

@@ -0,0 +1,71 @@
ARG DENO_VERSION="1.37.1"
FROM denoland/deno:bin-${DENO_VERSION} as deno
FROM node:24.15.0-bullseye-slim
LABEL maintainer="buildmaster@rocket.chat"
# dependencies
RUN groupadd -g 65533 -r rocketchat \
&& useradd -u 65533 -r -g rocketchat rocketchat \
&& mkdir -p /app/uploads \
&& chown rocketchat:rocketchat /app/uploads \
&& 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
# needs a mongoinstance - defaults to container linking with alias 'mongo'
ENV DEPLOY_METHOD=docker \
NODE_ENV=production \
MONGO_URL=mongodb://mongo:27017/rocketchat \
HOME=/tmp \
PORT=3000 \
ROOT_URL=http://localhost:3000 \
Accounts_AvatarStorePath=/app/uploads
RUN aptMark="$(apt-mark showmanual)" \
&& apt-get install -y --no-install-recommends g++ make python3 ca-certificates \
&& apt-mark auto '.*' > /dev/null \
&& apt-mark manual $aptMark > /dev/null
USER rocketchat
RUN cd /app/bundle/programs/server \
&& npm install --omit=dev \
&& cd npm/node_modules/isolated-vm \
&& npm install --omit=dev \
&& npm cache clear --force \
# Drop sourcemaps from third-party prod deps and the client bundle:
# - server/npm/node_modules: .map files shipped inside packages
# (openpgp, date-fns, etc.). Node does not read them at runtime.
# - web.browser: client-side bundle, maps have no runtime value in the image.
# Maps under /app/bundle/programs/server/{app,packages,node_modules/meteor}
# are loaded by Meteor's source-map-support at boot, so they are kept.
&& find /app/bundle/programs/server/npm/node_modules -type f -name '*.map' -delete \
&& find /app/bundle/programs/web.browser -type f -name '*.map' -delete
USER root
RUN find /usr/local -type f -executable -exec ldd '{}' ';' \
| awk '/=>/ { print $(NF-1) }' \
| sort -u \
| xargs -r dpkg-query --search \
| cut -d: -f1 \
| sort -u \
| xargs -r apt-mark manual \
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false
USER rocketchat
VOLUME /app/uploads
WORKDIR /app/bundle
EXPOSE 3000
CMD ["node", "main.js"]

View File

@@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2015-2022 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.

View File

@@ -0,0 +1,6 @@
.git
.gitignore
LICENSE
README.md
docker-compose.yml
tests/e2e/test-failures/

90
apps/meteor/.gitignore vendored Normal file
View File

@@ -0,0 +1,90 @@
**/bin/**
**/build/*
**/node_modules/*
**/tmp/*
**/.meteor/.id
**/.meteor/dev_bundle
**/.meteor/local*
**/.meteor/meteorite
/private/certs/*
*.bak
*.iml
*.ipr
*.iws
*.launch
*.log
*.pydevproject
*.sublime-project
*.sublime-workspace
*.swp
*.tmp
*.tokens
*.un~
*~
*~.nib
.*.sw[a-z]
.\#*
._*
.buildpath
.classpath
.clover
.cproject
.DS_Store
.elasticbeanstalk
.elc
.emacs.desktop
.emacs.desktop.lock
.env
.externalToolBuilders
.idea
.vscode
.loadpath
.map
.metadata
packages/rocketchat-livechat/assets/rocketchat-livechat.min.js
.mule
.pmd
.project
.sass-cache
.settings
.Spotlight-V100
tatus
.Trashes
.wtpmodules
\#*\#
Desktop.ini
ehthumbs.db
example.css
jrat.output
jrat.xml
local.properties
meteor-vulcanize
nb-configuration.xml
nbactions.xml
nbproject
profiles.xml
Session.vim
smart.lock
temp_*
Thumbs.db
thumbs.db
tramp
ecosystem.json
settings.json
/public/livechat
packages/rocketchat-i18n/i18n/livechat.*
tests/end-to-end/temporary_staged_test
.screenshots
/private/livechat
/storybook-static
/tests/e2e/.playwright
coverage
.nyc_output
/data
tests/e2e/test-failures/
out.txt
dist
*-session.json
matrix-federation-config/*
.eslintcache
tsconfig.typecheck.tsbuildinfo

View File

@@ -0,0 +1,20 @@
# This file contains information which helps Meteor properly upgrade your
# app when you run 'meteor update'. You should check it into version control
# with your project.
notices-for-0.9.0
notices-for-0.9.1
0.9.4-platform-file
notices-for-facebook-graph-api-2
1.2.0-standard-minifiers-package
1.2.0-meteor-platform-split
1.2.0-cordova-changes
1.2.0-breaking-changes
1.3.0-split-minifiers-package
1.3.5-remove-old-dev-bundle-link
1.4.0-remove-old-dev-bundle-link
1.4.1-add-shell-server-package
1.4.3-split-account-service-packages
1.5-add-dynamic-import-package
1.7-split-underscore-from-meteor-base
1.8.3-split-jquery-from-blaze

2
apps/meteor/.meteor/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
dev_bundle
local

View File

@@ -0,0 +1,63 @@
# Meteor packages used by this project, one per line.
#
# 'meteor add' and 'meteor remove' will edit this file for you,
# but you can also edit it by hand.
rocketchat:mongo-config
rocketchat:livechat
rocketchat:version
accounts-base@3.3.1
accounts-facebook@1.3.4
accounts-google@1.4.1
accounts-meteor-developer@1.5.1
accounts-oauth@1.4.7
accounts-password@3.3.1
accounts-twitter@1.5.2
google-oauth@1.4.5
oauth@3.0.4
oauth2@1.3.3
check@1.5.0
ddp-rate-limiter@1.3.0
rate-limit@1.2.0
email@3.2.0
meteor-base@1.5.2
ddp-common@1.4.5
webapp@2.3.0
mongo@2.5.1
reload@1.3.2
service-configuration@1.3.5
session@1.2.3
shell-server@0.7.0
dispatch:run-as-user
ostrio:cookies
meteorhacks:inject-initial
routepolicy@1.1.2
webapp-hashing@1.1.2
facts-base@1.0.2
tracker@1.3.4
reactive-dict@1.3.2
reactive-var@1.0.13
standard-minifier-css@1.10.1
dynamic-import@0.7.4
ecmascript@0.19.1
typescript@5.11.1
autoupdate@2.0.1
# photoswipe
zodern:types
standard-minifier-js@3.2.0

View File

@@ -0,0 +1,3 @@
server
browser
modern

View File

@@ -0,0 +1 @@
METEOR@3.5.2

View File

@@ -0,0 +1,90 @@
accounts-base@3.3.1
accounts-facebook@1.3.4
accounts-google@1.4.1
accounts-meteor-developer@1.5.1
accounts-oauth@1.4.7
accounts-password@3.3.1
accounts-twitter@1.5.2
allow-deny@2.1.0
autoupdate@2.0.1
babel-compiler@7.15.1
babel-runtime@1.5.2
base64@1.0.14
binary-heap@1.0.13
boilerplate-generator@2.1.0
callback-hook@1.8.0
check@1.5.0
core-runtime@1.0.0
ddp@1.4.2
ddp-client@3.4.1
ddp-common@1.4.5
ddp-rate-limiter@1.3.0
ddp-server@3.4.0
diff-sequence@1.1.3
dispatch:run-as-user@1.1.1
dynamic-import@0.7.4
ecmascript@0.19.1
ecmascript-runtime@0.8.3
ecmascript-runtime-client@0.13.0
ecmascript-runtime-server@0.11.1
ejson@1.2.0
email@3.2.0
es5-shim@4.8.1
facebook-oauth@1.11.6
facts-base@1.0.2
fetch@0.2.0
geojson-utils@1.0.12
google-oauth@1.4.5
hot-code-push@1.0.5
http@3.0.0
id-map@1.2.0
inter-process-messaging@0.1.3
localstorage@1.2.1
logging@1.3.6
meteor@2.3.1
meteor-base@1.5.2
meteor-developer-oauth@1.3.3
meteorhacks:inject-initial@1.0.5
minifier-css@2.0.2
minifier-js@3.3.1
minimongo@2.2.0
modern-browsers@0.2.3
modules@0.20.3
modules-runtime@0.13.2
mongo@2.5.1
mongo-decimal@0.2.0
mongo-dev-server@1.1.1
mongo-id@1.0.9
npm-mongo@6.16.3
oauth@3.0.4
oauth1@1.5.3
oauth2@1.3.3
ordered-dict@1.2.0
ostrio:cookies@2.7.2
promise@1.0.0
random@1.2.2
rate-limit@1.2.0
react-fast-refresh@0.3.0
reactive-dict@1.3.2
reactive-var@1.0.13
reload@1.3.2
retry@1.1.1
rocketchat:livechat@0.0.1
rocketchat:mongo-config@0.0.1
rocketchat:version@1.0.0
routepolicy@1.1.2
service-configuration@1.3.5
session@1.2.3
sha@1.0.10
shell-server@0.7.0
socket-stream-client@0.7.1
standard-minifier-css@1.10.1
standard-minifier-js@3.2.0
tracker@1.3.4
twitter-oauth@1.3.4
typescript@5.11.1
underscore@1.6.4
url@1.3.6
webapp@2.3.0
webapp-hashing@1.1.2
zodern:types@1.0.13

View File

@@ -0,0 +1,4 @@
ee/server/services
coverage
data
dist

View File

@@ -0,0 +1,15 @@
'use strict';
/*
* Mocha configuration for REST API integration tests.
*/
module.exports = /** @satisfies {import('mocha').MochaOptions} */ ({
...require('./.mocharc.base.json'), // see https://github.com/mochajs/mocha/issues/3916
timeout: 10000,
bail: false,
retries: 0,
file: 'tests/end-to-end/teardown.ts',
reporter: 'tests/end-to-end/reporter.ts',
spec: ['tests/end-to-end/apps/*'],
});

View File

@@ -0,0 +1,15 @@
'use strict';
/*
* Mocha configuration for REST API integration tests.
*/
module.exports = /** @satisfies {import('mocha').MochaOptions} */ ({
...require('./.mocharc.base.json'), // see https://github.com/mochajs/mocha/issues/3916
timeout: 10000,
bail: false,
retries: 0,
file: 'tests/end-to-end/teardown.ts',
reporter: 'tests/end-to-end/reporter.ts',
spec: ['tests/end-to-end/api/*.ts', 'tests/end-to-end/api/helpers/**/*', 'tests/end-to-end/api/methods/**/*', 'tests/end-to-end/apps/*'],
});

View File

@@ -0,0 +1,15 @@
'use strict';
/*
* Mocha configuration for Livechat REST API integration tests.
*/
module.exports = /** @satisfies {import('mocha').MochaOptions} */ ({
...require('./.mocharc.base.json'), // see https://github.com/mochajs/mocha/issues/3916
timeout: 10000,
bail: true,
retries: 0,
file: 'tests/end-to-end/teardown.ts',
reporter: 'tests/end-to-end/reporter.ts',
spec: ['tests/end-to-end/api/livechat/**/*'],
});

View File

@@ -0,0 +1,14 @@
{
"ui": "bdd",
"reporter": "spec",
"extension": ["js", "ts", "tsx"],
"require": [
"tsx",
"./tests/setup/chaiPlugins.ts"
],
"watch-files": [
"./**/*.js",
"./**/*.ts",
"./**/*.tsx"
]
}

View File

@@ -0,0 +1,15 @@
'use strict';
/**
* Mocha configuration for unit tests for type guards.
*/
const base = require('./.mocharc.base.json');
module.exports = {
...base, // see https://github.com/mochajs/mocha/issues/3916
require: [...base.require],
exit: false,
slow: 200,
spec: ['tests/unit/definition/**/*.spec.ts'],
};

45
apps/meteor/.mocharc.js Normal file
View File

@@ -0,0 +1,45 @@
'use strict';
/**
* Mocha configuration for general unit tests.
*/
const base = require('./.mocharc.base.json');
module.exports = {
...base, // see https://github.com/mochajs/mocha/issues/3916
exit: true,
// getUserInfo.spec.ts lives under server/api/lib but is a Jest test (run via jest.config.ts),
// so it must be excluded from the mocha `server/api/lib/**` glob below. Same for the
// business-hour spec under server/lib/omnichannel.
ignore: ['server/api/lib/getUserInfo.spec.ts', 'server/lib/omnichannel/business-hour/**/*.spec.ts'],
spec: [
'server/lib/callbacks.spec.ts',
'server/lib/cas/*.spec.ts',
'server/lib/messages/**/*.spec.ts',
'server/lib/ldap/*.spec.ts',
'server/lib/ldap/**/*.spec.ts',
'server/lib/dataExport/**/*.spec.ts',
'server/ufs/*.spec.ts',
'ee/server/lib/ldap/*.spec.ts',
'ee/tests/**/*.tests.ts',
'ee/tests/**/*.spec.ts',
'tests/unit/app/**/*.spec.ts',
'tests/unit/app/**/*.tests.js',
'tests/unit/app/**/*.tests.ts',
'tests/unit/lib/**/*.tests.ts',
'server/routes/avatar/**/*.spec.ts',
'tests/unit/lib/**/*.spec.ts',
'tests/unit/server/**/*.tests.js',
'tests/unit/server/**/*.tests.ts',
'tests/unit/server/**/*.spec.ts',
'server/lib/2fa/**/*.spec.ts',
'server/api/lib/**/*.spec.ts',
'server/lib/media/**/*.spec.ts',
'server/lib/statistics/**/*.spec.ts',
'server/meteor-methods/**/*.spec.ts',
'server/lib/omnichannel/**/*.spec.ts',
'server/lib/notifications/push/**/*.spec.ts',
'server/lib/utils/**/*.spec.ts',
],
};

View File

@@ -0,0 +1,443 @@
{
"kind": "Template",
"apiVersion": "v1",
"metadata": {
"name": "rocket-chat-ephemeral",
"annotations": {
"description": "Rocket.Chat with a MongoDB database running with an Ephemeral storage.",
"tags": "quickstart,nodejs,mongodb,instant-app",
"iconClass": "icon-nodejs"
}
},
"objects": [
{
"kind": "DeploymentConfig",
"apiVersion": "v1",
"metadata": {
"name": "mongodb",
"labels": {
"template": "mongodb-ephemeral-template"
}
},
"spec": {
"strategy": {
"type": "Recreate",
"recreateParams": {
"timeoutSeconds": 600
}
},
"triggers": [
{
"type": "ImageChange",
"imageChangeParams": {
"automatic": true,
"containerNames": [
"mongodb"
],
"from": {
"kind": "ImageStreamTag",
"namespace": "openshift",
"name": "mongodb:latest"
}
}
},
{
"type": "ConfigChange"
}
],
"replicas": 1,
"test": false,
"selector": {
"name": "mongodb"
},
"template": {
"metadata": {
"creationTimestamp": null,
"labels": {
"name": "mongodb"
}
},
"spec": {
"volumes": [
{
"name": "mongodb-data",
"emptyDir": {}
}
],
"containers": [
{
"name": "mongodb",
"image": "registry.access.redhat.com/rhscl/mongodb-26-rhel7:latest",
"ports": [
{
"containerPort": 27017,
"protocol": "TCP"
}
],
"env": [
{
"name": "MONGODB_USER",
"value": "${MONGODB_USER}"
},
{
"name": "MONGODB_PASSWORD",
"value": "${MONGODB_PASSWORD}"
},
{
"name": "MONGODB_DATABASE",
"value": "${MONGODB_DATABASE}"
},
{
"name": "MONGODB_ADMIN_PASSWORD",
"value": "${MONGODB_ADMIN_PASSWORD}"
}
],
"resources": {
"limits": {
"memory": "${MEMORY_LIMIT}"
}
},
"volumeMounts": [
{
"name": "mongodb-data",
"mountPath": "/var/lib/mongodb/data"
}
],
"livenessProbe": {
"tcpSocket": {
"port": 27017
},
"initialDelaySeconds": 30,
"timeoutSeconds": 1,
"periodSeconds": 10,
"successThreshold": 1,
"failureThreshold": 3
},
"readinessProbe": {
"exec": {
"command": [
"/bin/sh",
"-i",
"-c",
"mongo 127.0.0.1:27017/$MONGODB_DATABASE -u $MONGODB_USER -p $MONGODB_PASSWORD --eval=\"quit()\""
]
},
"initialDelaySeconds": 3,
"timeoutSeconds": 1,
"periodSeconds": 10,
"successThreshold": 1,
"failureThreshold": 3
},
"terminationMessagePath": "/dev/termination-log",
"imagePullPolicy": "IfNotPresent",
"securityContext": {
"capabilities": {},
"privileged": false
}
}
],
"restartPolicy": "Always",
"terminationGracePeriodSeconds": 30,
"dnsPolicy": "ClusterFirst",
"securityContext": {}
}
}
}
},
{
"kind": "ImageStream",
"apiVersion": "v1",
"metadata": {
"name": "rocketchat"
},
"spec": {
"dockerImageRepository": "${ROCKETCHAT_IMAGE}",
"tags": [
{
"name": "latest",
"annotations": {
"description": "Provides a Rocket.Chat application",
"iconClass": "icon-nodejs",
"tags": "rocketchat"
},
"from": {
"kind": "ImageStreamTag",
"name": "latest"
},
"generation": 1,
"importPolicy": {}
}
]
}
},
{
"kind": "DeploymentConfig",
"apiVersion": "v1",
"metadata": {
"name": "rocketchat",
"creationTimestamp": null,
"labels": {
"app": "rocketchat"
},
"annotations": {
"openshift.io/generated-by": "OpenShiftNewApp"
}
},
"spec": {
"strategy": {
"type": "Rolling",
"rollingParams": {
"updatePeriodSeconds": 1,
"intervalSeconds": 1,
"timeoutSeconds": 600,
"maxUnavailable": "25%",
"maxSurge": "25%"
},
"resources": {}
},
"triggers": [
{
"type": "ConfigChange"
},
{
"type": "ImageChange",
"imageChangeParams": {
"automatic": true,
"containerNames": [
"rocketchat"
],
"from": {
"kind": "ImageStreamTag",
"name": "rocketchat:latest"
}
}
}
],
"replicas": 1,
"test": false,
"selector": {
"app": "rocketchat",
"deploymentconfig": "rocketchat"
},
"template": {
"metadata": {
"creationTimestamp": null,
"labels": {
"app": "rocketchat",
"deploymentconfig": "rocketchat"
},
"annotations": {
"openshift.io/container.rocketchat.image.entrypoint": "[\"node\",\"main.js\"]",
"openshift.io/generated-by": "OpenShiftNewApp"
}
},
"spec": {
"volumes": [
{
"name": "rocketchat-volume-1",
"emptyDir": {}
}
],
"containers": [
{
"name": "rocketchat",
"image": "${ROCKETCHAT_IMAGE}:latest",
"ports": [
{
"containerPort": 3000,
"protocol": "TCP"
}
],
"env": [
{
"name": "MONGO_URL",
"value": "mongodb://${MONGODB_USER}:${MONGODB_PASSWORD}@mongodb:27017/${MONGODB_DATABASE}"
}
],
"resources": {},
"volumeMounts": [
{
"name": "rocketchat-volume-1",
"mountPath": "/app/uploads"
}
],
"readinessProbe": {
"httpGet": {
"path": "/api/v1/info",
"port": 3000,
"scheme": "HTTP"
},
"initialDelaySeconds": 5,
"timeoutSeconds": 1,
"periodSeconds": 10,
"successThreshold": 1,
"failureThreshold": 3
},
"livenessProbe": {
"httpGet": {
"path": "/api/v1/info",
"port": 3000,
"scheme": "HTTP"
},
"initialDelaySeconds": 30,
"timeoutSeconds": 1,
"periodSeconds": 10,
"successThreshold": 1,
"failureThreshold": 3
},
"terminationMessagePath": "/dev/termination-log",
"imagePullPolicy": "Always"
}
],
"restartPolicy": "Always",
"terminationGracePeriodSeconds": 30,
"dnsPolicy": "ClusterFirst",
"securityContext": {}
}
}
},
"status": {}
},
{
"kind": "Route",
"apiVersion": "v1",
"metadata": {
"name": "rocketchat",
"creationTimestamp": null,
"labels": {
"app": "rocketchat"
},
"annotations": {
"openshift.io/host.generated": "true"
}
},
"spec": {
"to": {
"kind": "Service",
"name": "rocketchat"
},
"port": {
"targetPort": "3000-tcp"
}
},
"status": {
"ingress": null
}
},
{
"kind": "Service",
"apiVersion": "v1",
"metadata": {
"name": "mongodb",
"creationTimestamp": null,
"labels": {
"template": "mongodb-ephemeral-template"
}
},
"spec": {
"ports": [
{
"name": "mongo",
"protocol": "TCP",
"port": 27017,
"targetPort": 27017
}
],
"selector": {
"name": "mongodb"
},
"type": "ClusterIP",
"sessionAffinity": "None"
},
"status": {
"loadBalancer": {}
}
},
{
"kind": "Service",
"apiVersion": "v1",
"metadata": {
"name": "rocketchat",
"creationTimestamp": null,
"labels": {
"app": "rocketchat"
},
"annotations": {
"openshift.io/generated-by": "OpenShiftNewApp"
}
},
"spec": {
"ports": [
{
"name": "3000-tcp",
"protocol": "TCP",
"port": 3000,
"targetPort": 3000
}
],
"selector": {
"app": "rocketchat",
"deploymentconfig": "rocketchat"
},
"type": "ClusterIP",
"sessionAffinity": "None"
},
"status": {
"loadBalancer": {}
}
}
],
"parameters": [
{
"name": "MEMORY_LIMIT",
"displayName": "Memory Limit",
"description": "Maximum amount of memory the container can use.",
"value": "512Mi"
},
{
"name": "DATABASE_SERVICE_NAME",
"displayName": "Database Service Name",
"description": "The name of the OpenShift Service exposed for the database.",
"value": "mongodb",
"required": true
},
{
"name": "MONGODB_USER",
"displayName": "MongoDB User",
"description": "Username for MongoDB user that will be used for accessing the database.",
"generate": "expression",
"from": "user[A-Z0-9]{3}",
"required": true
},
{
"name": "MONGODB_PASSWORD",
"displayName": "MongoDB Password",
"description": "Password for the MongoDB user.",
"generate": "expression",
"from": "[a-zA-Z0-9]{16}",
"required": true
},
{
"name": "MONGODB_DATABASE",
"displayName": "MongoDB Database Name",
"description": "Name of the MongoDB database accessed.",
"value": "rocketchatdb",
"required": true
},
{
"name": "ROCKETCHAT_IMAGE",
"displayName": "RocketChat Image",
"description": "The RocketChat image to use for this deployment",
"required": true,
"value": "rocketchat/rocket.chat"
},
{
"name": "MONGODB_ADMIN_PASSWORD",
"displayName": "MongoDB Admin Password",
"description": "Password for the database admin user.",
"generate": "expression",
"from": "[a-zA-Z0-9]{16}",
"required": true
}
]
}

View File

@@ -0,0 +1,470 @@
{
"kind": "Template",
"apiVersion": "v1",
"metadata": {
"name": "rocket-chat",
"annotations": {
"description": "Rocket.Chat with a MongoDB database running with a Persistent storage. Use this template if you want to run Rocket.Chat in production.",
"tags": "quickstart,nodejs,mongodb,instant-app",
"iconClass": "icon-nodejs"
}
},
"objects": [
{
"kind": "PersistentVolumeClaim",
"apiVersion": "v1",
"metadata": {
"name": "${DATABASE_SERVICE_NAME}"
},
"spec": {
"accessModes": [
"ReadWriteOnce"
],
"resources": {
"requests": {
"storage": "${VOLUME_CAPACITY}"
}
}
}
},
{
"kind": "DeploymentConfig",
"apiVersion": "v1",
"metadata": {
"name": "mongodb",
"labels": {
"template": "mongodb-persistent-template"
}
},
"spec": {
"strategy": {
"type": "Recreate",
"recreateParams": {
"timeoutSeconds": 600
}
},
"triggers": [
{
"type": "ImageChange",
"imageChangeParams": {
"automatic": true,
"containerNames": [
"mongodb"
],
"from": {
"kind": "ImageStreamTag",
"namespace": "openshift",
"name": "mongodb:latest"
}
}
},
{
"type": "ConfigChange"
}
],
"replicas": 1,
"test": false,
"selector": {
"name": "mongodb"
},
"template": {
"metadata": {
"creationTimestamp": null,
"labels": {
"name": "mongodb"
}
},
"spec": {
"containers": [
{
"name": "mongodb",
"image": "registry.access.redhat.com/rhscl/mongodb-26-rhel7:latest",
"ports": [
{
"containerPort": 27017,
"protocol": "TCP"
}
],
"env": [
{
"name": "MONGODB_USER",
"value": "${MONGODB_USER}"
},
{
"name": "MONGODB_PASSWORD",
"value": "${MONGODB_PASSWORD}"
},
{
"name": "MONGODB_DATABASE",
"value": "${MONGODB_DATABASE}"
},
{
"name": "MONGODB_ADMIN_PASSWORD",
"value": "${MONGODB_ADMIN_PASSWORD}"
}
],
"resources": {
"limits": {
"memory": "${MEMORY_LIMIT}"
}
},
"volumeMounts": [
{
"name": "mongodb-data",
"mountPath": "/var/lib/mongodb/data"
}
],
"livenessProbe": {
"tcpSocket": {
"port": 27017
},
"initialDelaySeconds": 30,
"timeoutSeconds": 1,
"periodSeconds": 10,
"successThreshold": 1,
"failureThreshold": 3
},
"readinessProbe": {
"exec": {
"command": [
"/bin/sh",
"-i",
"-c",
"mongo 127.0.0.1:27017/$MONGODB_DATABASE -u $MONGODB_USER -p $MONGODB_PASSWORD --eval=\"quit()\""
]
},
"initialDelaySeconds": 3,
"timeoutSeconds": 1,
"periodSeconds": 10,
"successThreshold": 1,
"failureThreshold": 3
},
"terminationMessagePath": "/dev/termination-log",
"imagePullPolicy": "IfNotPresent",
"securityContext": {
"capabilities": {},
"privileged": false
}
}
],
"volumes": [
{
"name": "${DATABASE_SERVICE_NAME}-data",
"persistentVolumeClaim": {
"claimName": "${DATABASE_SERVICE_NAME}"
}
}
],
"restartPolicy": "Always",
"terminationGracePeriodSeconds": 30,
"dnsPolicy": "ClusterFirst",
"securityContext": {}
}
}
},
"status": {}
},
{
"kind": "ImageStream",
"apiVersion": "v1",
"metadata": {
"name": "rocketchat"
},
"spec": {
"dockerImageRepository": "${ROCKETCHAT_IMAGE}",
"tags": [
{
"name": "latest",
"annotations": {
"description": "Provides a Rocket.Chat application",
"iconClass": "icon-nodejs",
"tags": "rocketchat"
},
"from": {
"kind": "ImageStreamTag",
"name": "latest"
},
"generation": 1,
"importPolicy": {}
}
]
}
},
{
"kind": "DeploymentConfig",
"apiVersion": "v1",
"metadata": {
"name": "rocketchat",
"creationTimestamp": null,
"labels": {
"app": "rocketchat"
},
"annotations": {
"openshift.io/generated-by": "OpenShiftNewApp"
}
},
"spec": {
"strategy": {
"type": "Rolling",
"rollingParams": {
"updatePeriodSeconds": 1,
"intervalSeconds": 1,
"timeoutSeconds": 600,
"maxUnavailable": "25%",
"maxSurge": "25%"
},
"resources": {}
},
"triggers": [
{
"type": "ConfigChange"
},
{
"type": "ImageChange",
"imageChangeParams": {
"automatic": true,
"containerNames": [
"rocketchat"
],
"from": {
"kind": "ImageStreamTag",
"name": "rocketchat:latest"
}
}
}
],
"replicas": 1,
"test": false,
"selector": {
"app": "rocketchat",
"deploymentconfig": "rocketchat"
},
"template": {
"metadata": {
"creationTimestamp": null,
"labels": {
"app": "rocketchat",
"deploymentconfig": "rocketchat"
},
"annotations": {
"openshift.io/container.rocketchat.image.entrypoint": "[\"node\",\"main.js\"]",
"openshift.io/generated-by": "OpenShiftNewApp"
}
},
"spec": {
"volumes": [
{
"name": "rocketchat-volume-1",
"emptyDir": {}
}
],
"containers": [
{
"name": "rocketchat",
"image": "${ROCKETCHAT_IMAGE}:latest",
"ports": [
{
"containerPort": 3000,
"protocol": "TCP"
}
],
"env": [
{
"name": "MONGO_URL",
"value": "mongodb://${MONGODB_USER}:${MONGODB_PASSWORD}@mongodb:27017/${MONGODB_DATABASE}"
}
],
"resources": {},
"volumeMounts": [
{
"name": "rocketchat-volume-1",
"mountPath": "/app/uploads"
}
],
"readinessProbe": {
"httpGet": {
"path": "/api/v1/info",
"port": 3000,
"scheme": "HTTP"
},
"initialDelaySeconds": 5,
"timeoutSeconds": 1,
"periodSeconds": 10,
"successThreshold": 1,
"failureThreshold": 3
},
"livenessProbe": {
"httpGet": {
"path": "/api/v1/info",
"port": 3000,
"scheme": "HTTP"
},
"initialDelaySeconds": 30,
"timeoutSeconds": 1,
"periodSeconds": 10,
"successThreshold": 1,
"failureThreshold": 3
},
"terminationMessagePath": "/dev/termination-log",
"imagePullPolicy": "Always"
}
],
"restartPolicy": "Always",
"terminationGracePeriodSeconds": 30,
"dnsPolicy": "ClusterFirst",
"securityContext": {}
}
}
},
"status": {}
},
{
"kind": "Route",
"apiVersion": "v1",
"metadata": {
"name": "rocketchat",
"creationTimestamp": null,
"labels": {
"app": "rocketchat"
},
"annotations": {
"openshift.io/host.generated": "true"
}
},
"spec": {
"to": {
"kind": "Service",
"name": "rocketchat"
},
"port": {
"targetPort": "3000-tcp"
}
},
"status": {
"ingress": null
}
},
{
"kind": "Service",
"apiVersion": "v1",
"metadata": {
"name": "mongodb",
"creationTimestamp": null,
"labels": {
"template": "mongodb-persistent-template"
}
},
"spec": {
"ports": [
{
"name": "mongo",
"protocol": "TCP",
"port": 27017,
"targetPort": 27017
}
],
"selector": {
"name": "mongodb"
},
"type": "ClusterIP",
"sessionAffinity": "None"
},
"status": {
"loadBalancer": {}
}
},
{
"kind": "Service",
"apiVersion": "v1",
"metadata": {
"name": "rocketchat",
"creationTimestamp": null,
"labels": {
"app": "rocketchat"
},
"annotations": {
"openshift.io/generated-by": "OpenShiftNewApp"
}
},
"spec": {
"ports": [
{
"name": "3000-tcp",
"protocol": "TCP",
"port": 3000,
"targetPort": 3000
}
],
"selector": {
"app": "rocketchat",
"deploymentconfig": "rocketchat"
},
"type": "ClusterIP",
"sessionAffinity": "None"
},
"status": {
"loadBalancer": {}
}
}
],
"parameters": [
{
"name": "MEMORY_LIMIT",
"displayName": "Memory Limit",
"description": "Maximum amount of memory the container can use.",
"value": "512Mi"
},
{
"name": "DATABASE_SERVICE_NAME",
"displayName": "Database Service Name",
"description": "The name of the OpenShift Service exposed for the database.",
"value": "mongodb",
"required": true
},
{
"name": "MONGODB_USER",
"displayName": "MongoDB User",
"description": "Username for MongoDB user that will be used for accessing the database.",
"generate": "expression",
"from": "user[A-Z0-9]{3}",
"required": true
},
{
"name": "MONGODB_PASSWORD",
"displayName": "MongoDB Password",
"description": "Password for the MongoDB user.",
"generate": "expression",
"from": "[a-zA-Z0-9]{16}",
"required": true
},
{
"name": "MONGODB_DATABASE",
"displayName": "MongoDB Database Name",
"description": "Name of the MongoDB database accessed.",
"value": "rocketchatdb",
"required": true
},
{
"name": "MONGODB_ADMIN_PASSWORD",
"displayName": "MongoDB Admin Password",
"description": "Password for the database admin user.",
"generate": "expression",
"from": "[a-zA-Z0-9]{16}",
"required": true
},
{
"name": "ROCKETCHAT_IMAGE",
"displayName": "RocketChat Image",
"description": "The RocketChat image to use for this deployment",
"required": true,
"value": "rocketchat/rocket.chat"
},
{
"name": "VOLUME_CAPACITY",
"displayName": "Volume Capacity",
"description": "Volume space available for data, e.g. 512Mi, 2Gi.",
"value": "1Gi",
"required": true
}
]
}

14
apps/meteor/.postcssrc Normal file
View File

@@ -0,0 +1,14 @@
{
"plugins": {
"postcss-easy-import": {},
"postcss-custom-properties": {
"preserve": true
},
"postcss-media-minmax": {},
"postcss-nested": {},
"autoprefixer": {}
},
"excludedPackages": [
"deepwell:bootstrap-datepicker2"
]
}

View File

@@ -0,0 +1,50 @@
import { readdirSync, readFileSync, writeFileSync } from 'fs';
import { renderFile } from 'template-file';
function main(number: string, comment: string): void {
if (!(Number(number) >= 0)) {
console.error(`1st param must be a valid number. ${number} provided`);
return;
}
if (comment.trim()) {
comment = `// ${comment}`;
}
// check if migration will conflict with current on-branch migrations
const migrationName = `v${number}`;
const fileList = readdirSync('./server/startup/migrations');
if (fileList.includes(`${migrationName}.ts`)) {
console.error('Migration with specified number already exists');
return;
}
renderFile('./.scripts/migration.template', { number, comment })
.then((renderedMigration) => {
// generate new migration file
writeFileSync(`./server/startup/migrations/${migrationName}.ts`, renderedMigration);
// get contents of index.ts to append new migration
const indexFile = readFileSync('./server/startup/migrations/index.ts');
const splittedIndexLines = indexFile.toString().split('\n');
// remove end line + xrun import
splittedIndexLines.splice(splittedIndexLines.length - 2, 0, `import './${migrationName}';`);
const data = splittedIndexLines.join('\n');
// append migration import to indexfile
writeFileSync('./server/startup/migrations/index.ts', data);
console.log(`Migration ${migrationName} created`);
})
.catch(console.error);
}
const [, , number, comment = ''] = process.argv;
if (!number || (comment && !comment.trim())) {
console.error('Usage:\n\tmeteor npm run migration:add <migration number> [migration comment: optional]\n');
process.exit(1);
}
main(number, comment);

View File

@@ -0,0 +1,9 @@
import { addMigration } from '../../lib/migrations';
{{ comment }}
addMigration({
version: {{ number }},
up() {
},
});

View File

@@ -0,0 +1,98 @@
import type { SpawnOptions } from 'child_process';
import { spawn } from 'child_process';
import * as path from 'path';
enum ModeParam {
MAIN = 'main',
INSTANCE = 'instance',
}
interface IConfig extends SpawnOptions {
customEnv: typeof process.env;
parentEnv: typeof process.env;
}
function isMode(value: any): value is ModeParam {
return Object.values(ModeParam).includes(value);
}
function buildConfig(cwd: string): IConfig {
const customEnv: IConfig['customEnv'] = {
INSTANCE_IP: '127.0.0.1',
MONGO_URL: 'mongodb://localhost:3001/meteor',
MONGO_OPLOG_URL: 'mongodb://localhost:3001/local',
};
return {
cwd,
stdio: 'inherit',
shell: true,
customEnv,
parentEnv: process.env,
env: {
...customEnv,
...process.env,
},
};
}
async function runMain(config: IConfig): Promise<void> {
const {
customEnv: { INSTANCE_IP },
parentEnv,
...mainConfig
} = config;
const spawnConfig = {
...mainConfig,
env: {
INSTANCE_IP,
...parentEnv,
},
};
spawn('meteor', spawnConfig);
}
async function runInstance(config: IConfig): Promise<void> {
// Desctructuring the unused variables allows us to omit them in the `mainConfig`
const { customEnv, parentEnv, ...mainConfig } = config;
const env = {
PORT: '3030',
ROOT_URL: '',
...mainConfig.env,
};
env.ROOT_URL = `http://localhost:${env.PORT}`;
const spawnConfig = {
...mainConfig,
env,
};
spawn('node', ['.meteor/local/build/main.js'], spawnConfig);
}
async function main(mode: any): Promise<void> {
if (!isMode(mode)) {
mode = 'main';
}
const config = buildConfig(path.resolve(__dirname, '..'));
switch (mode) {
case ModeParam.MAIN:
void runMain(config);
break;
case ModeParam.INSTANCE:
void runInstance(config);
break;
}
}
// First two parameters are the executable and the path to this script
const [, , mode] = process.argv;
void main(mode);

View File

@@ -0,0 +1,12 @@
const path = require('path');
let pkgJson = {};
try {
// eslint-disable-next-line import-x/no-dynamic-require
pkgJson = require(path.resolve(process.cwd(), './package.json'));
} catch (err) {
console.error('no root package.json found');
}
console.log(pkgJson.version);

View File

@@ -0,0 +1,25 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { Decorator } from '@storybook/react';
import ModalContextMock from '../client/stories/contexts/ModalContextMock';
import RouterContextMock from '../client/stories/contexts/RouterContextMock';
import ServerContextMock from '../client/stories/contexts/ServerContextMock';
import TranslationContextMock from '../client/stories/contexts/TranslationContextMock';
const MockedAppRoot = mockAppRoot().build();
export const RocketChatDecorator: Decorator = (Story, { parameters }) => (
<MockedAppRoot>
<ServerContextMock {...parameters.serverContext}>
<TranslationContextMock {...parameters.translationContext}>
<ModalContextMock {...parameters.modalContext}>
<RouterContextMock {...parameters.routerContext}>
<div className='color-primary-font-color rcx-content--main'>
<Story />
</div>
</RouterContextMock>
</ModalContextMock>
</TranslationContextMock>
</ServerContextMock>
</MockedAppRoot>
);

View File

@@ -0,0 +1,50 @@
import { dirname, join, resolve } from 'path';
import baseConfig from '@rocket.chat/storybook-config/main';
import webpack from 'webpack';
export default baseConfig({
stories: ['../client/**/*.stories.{js,tsx}'],
webpackFinal: async (config) => {
// Those aliases are needed because dependencies in the monorepo use another
// dependencies that are not hoisted on this workspace
const swiperRoot = dirname(require.resolve('swiper/package.json'));
config.resolve = {
...config.resolve,
alias: {
...config.resolve?.alias,
'react$': require.resolve('../../../node_modules/react'),
// 'react/jsx-runtime': require.resolve('../../../node_modules/react/jsx-runtime'),
'@tanstack/react-query': require.resolve('../../../node_modules/@tanstack/react-query'),
'@rocket.chat/fuselage$': require.resolve('../../../node_modules/@rocket.chat/fuselage'),
// Meteor's bundler ignores the `exports` field, so source code reaches
// into swiper's internals via deep file paths. Webpack honors `exports`
// and rejects them, so map each subpath to the actual file.
'swiper/swiper-react.mjs$': join(swiperRoot, 'swiper-react.mjs'),
'swiper/swiper-react$': join(swiperRoot, 'swiper-react.d.ts'),
'swiper/modules/index.mjs$': join(swiperRoot, 'modules/index.mjs'),
'swiper/swiper.css$': join(swiperRoot, 'swiper.css'),
'swiper/modules/zoom.css$': join(swiperRoot, 'modules/zoom.css'),
},
// This is only needed because of Rocket.Chat's icon font.
roots: [...(config.resolve?.roots ?? []), resolve(__dirname, '../../../apps/meteor/public')],
};
// Strip the `env` option that addon-webpack5-compiler-swc injects on swc-loader;
// it conflicts with `jsc.target` from `.swcrc` (Meteor's Modern Build Stack).
for (const rule of (config.module?.rules ?? []) as any[]) {
for (const use of Array.isArray(rule?.use) ? rule.use : []) {
if (use?.loader?.includes?.('swc-loader') && use.options) delete use.options.env;
}
}
config.plugins?.push(
new webpack.NormalModuleReplacementPlugin(/^meteor/, require.resolve('./mocks/meteor.ts')),
new webpack.NormalModuleReplacementPlugin(/(app)\/*.*\/(server)\/*/, require.resolve('./mocks/empty.ts')),
new webpack.NormalModuleReplacementPlugin(/rocketchat\.info$/, require.resolve('./mocks/rocketchat.info.ts')),
);
return config;
},
});

View File

@@ -0,0 +1 @@
export default {};

View File

@@ -0,0 +1,98 @@
/* eslint-disable @typescript-eslint/no-empty-function */
export const Meteor = {
Device: {
isDesktop: () => false,
},
isClient: true,
isServer: false,
_localStorage: window.localStorage,
absoluteUrl: Object.assign(() => {}, {
defaultOptions: {},
}),
userId: () => {},
Streamer: () => ({
on: () => {},
removeListener: () => {},
}),
StreamerCentral: {
on: () => {},
removeListener: () => {},
},
startup: () => {},
methods: () => {},
call: () => {},
connection: {
_stream: {
on: () => {},
},
},
users: {},
};
export const Tracker = {
autorun: () => ({
stop: () => {},
}),
nonreactive: (fn: () => any) => fn(),
Dependency: () => {},
};
export const Accounts = {
onLogin: () => {},
onLogout: () => {},
};
export const Mongo = {
Collection: () => ({
find: () => ({
observe: () => {},
fetch: () => [],
}),
}),
};
export const ReactiveVar = (val: unknown) => {
let currentVal = val;
return {
get: () => currentVal,
set: (val: unknown) => {
currentVal = val;
},
};
};
export const ReactiveDict = () => ({
get: () => {},
set: () => {},
all: () => {},
});
export const Template = Object.assign(
() => ({
onCreated: () => {},
onRendered: () => {},
onDestroyed: () => {},
helpers: () => {},
events: () => {},
}),
{
registerHelper: () => {},
__checkName: () => {},
},
);
export const check = () => {};
export const FlowRouter = {
route: () => {},
group: () => ({
route: () => {},
}),
};
export const Session = {
get: () => {},
set: () => {},
};
export const DDPCommon = {};

View File

@@ -0,0 +1,3 @@
export const version = '1.0.0';
export const Info = { version };

View File

@@ -0,0 +1,17 @@
import { parameters, decorators } from '@rocket.chat/storybook-config/preview';
import type { Preview } from '@storybook/react';
import { RocketChatDecorator } from './decorators';
import '../app/theme/client/main.css';
import 'highlight.js/styles/github.css';
const preview: Preview = {
parameters: {
...parameters,
},
decorators: [...decorators, RocketChatDecorator],
tags: ['autodocs'],
};
export default preview;

View File

@@ -0,0 +1,2 @@
app/meteor-autocomplete/client/autocomplete.css
storybook-static

59
apps/meteor/.stylelintrc Normal file
View File

@@ -0,0 +1,59 @@
{
"extends": ["stylelint-config-standard"],
"plugins": ["stylelint-order", "stylelint-selector-bem-pattern"],
"rules": {
"at-rule-empty-line-before": [
"always",
{
"except": ["blockless-after-same-name-blockless", "first-nested"],
"ignore": ["after-comment"]
}
],
"block-no-empty": true,
"color-hex-length": "long",
"color-no-invalid-hex": true,
"comment-empty-line-before": [
"always",
{
"except": ["first-nested"],
"ignore": ["stylelint-commands"]
}
],
"comment-no-empty": true,
"comment-whitespace-inside": "always",
"custom-property-empty-line-before": "never",
"declaration-block-no-duplicate-properties": [
true,
{
"ignore": ["consecutive-duplicates-with-different-values"]
}
],
"declaration-block-no-redundant-longhand-properties": true,
"declaration-block-no-shorthand-property-overrides": true,
"declaration-block-single-line-max-declarations": 1,
"font-family-no-duplicate-names": true,
"function-linear-gradient-no-nonstandard-direction": true,
"function-name-case": "lower",
"keyframe-declaration-no-important": true,
"length-zero-no-unit": true,
"media-feature-name-no-unknown": true,
"no-duplicate-selectors": true,
"no-empty-source": true,
"property-no-unknown": true,
"rule-empty-line-before": [
"always",
{
"except": ["first-nested"],
"ignore": ["after-comment"]
}
],
"selector-pseudo-class-no-unknown": true,
"selector-pseudo-element-colon-notation": "double",
"selector-pseudo-element-no-unknown": true,
"selector-type-case": "lower",
"selector-type-no-unknown": true,
"shorthand-property-no-redundant-values": true,
"string-no-newline": true,
"unit-no-unknown": true
}
}

10
apps/meteor/.swcrc Normal file
View File

@@ -0,0 +1,10 @@
{
"jsc": {
"target": "es2022",
"transform": {
"react": {
"runtime": "automatic"
}
}
}
}

View File

@@ -0,0 +1,12 @@
import type { OauthConfig } from '@rocket.chat/core-typings';
export const config = {
serverURL: 'https://appleid.apple.com',
authorizePath: '/auth/authorize?response_mode=form_post',
responseType: 'code id_token',
tokenPath: '/auth/token',
scope: 'name email',
mergeUsers: true,
accessTokenParam: 'access_token',
loginStyle: 'popup',
} as const satisfies OauthConfig;

1
apps/meteor/app/apps/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.npm

View File

@@ -0,0 +1,11 @@
# Rocket.Chat Apps
Finally! :smile:
## What is an "Orchestrator"?
An orchestrator is the file/class which is responsible for orchestrating (starting up) everything which is required of the system to get up and going. There are two of these. One for the server and one for the client.
## What is a "Bridge"?
A bridge is a file/class which is responsible for bridging the Rocket.Chat system's data and the App system's data. They are implementations of the interfaces inside of the Rocket.Chat Apps-engine project `src/server/bridges`. They allow the two systems to talk to each other (hence the name bridge, as they "bridge the gap").
## What is a "Converter"?
A converter does what the name implies, it handles converting from one system's data type into the other's. **Note**: This causes a schema to be forced on the rooms and messages.

View File

@@ -0,0 +1,42 @@
import type { IAppServerOrchestrator, AppStatus } from '@rocket.chat/apps';
import type { ProxiedApp } from '@rocket.chat/apps/dist/server/ProxiedApp';
import { AppActivationBridge as ActivationBridge } from '@rocket.chat/apps/dist/server/bridges/AppActivationBridge';
import { UserStatus } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
export class AppActivationBridge extends ActivationBridge {
constructor(private readonly orch: IAppServerOrchestrator) {
super();
}
protected async appAdded(_app: ProxiedApp): Promise<void> {
// await this.orch.getNotifier().appAdded(app.getID());
// Calls made via AppActivationBridge should NOT go through
// View https://github.com/RocketChat/Rocket.Chat/pull/29180 for details
return undefined;
}
protected async appUpdated(_app: ProxiedApp): Promise<void> {
// Calls made via AppActivationBridge should NOT go through
// View https://github.com/RocketChat/Rocket.Chat/pull/29180 for details
// await this.orch.getNotifier().appUpdated(app.getID());
return undefined;
}
protected async appRemoved(app: ProxiedApp): Promise<void> {
await this.orch.getNotifier().appRemoved(app.getID());
}
protected async appStatusChanged(app: ProxiedApp, status: AppStatus): Promise<void> {
const userStatus = ['auto_enabled', 'manually_enabled'].includes(status) ? UserStatus.ONLINE : UserStatus.OFFLINE;
await Users.updateStatusByAppId(app.getID(), userStatus);
await this.orch.getNotifier().appStatusUpdated(app.getID(), status);
}
protected async actionsChanged(): Promise<void> {
await this.orch.getNotifier().actionsChanged();
}
}

View File

@@ -0,0 +1,127 @@
import type { IAppServerOrchestrator } from '@rocket.chat/apps';
import { ApiBridge } from '@rocket.chat/apps/dist/server/bridges/ApiBridge';
import type { AppApi } from '@rocket.chat/apps/dist/server/managers/AppApi';
import type { RequestMethod } from '@rocket.chat/apps-engine/definition/accessors';
import type { IApiRequest, IApiEndpoint, IApi } from '@rocket.chat/apps-engine/definition/api';
import type { Response, Request, IRouter, RequestHandler } from 'express';
import express from 'express';
import { Meteor } from 'meteor/meteor';
import { WebApp } from 'meteor/webapp';
import { apiServer } from './router';
import { authenticationMiddleware } from '../../../../server/api/v1/middlewares/authentication';
WebApp.rawConnectHandlers.use(apiServer);
interface IRequestWithPrivateHash extends Request {
_privateHash?: string;
content?: any;
}
export class AppApisBridge extends ApiBridge {
appRouters: Map<string, IRouter>;
constructor(private readonly orch: IAppServerOrchestrator) {
super();
this.appRouters = new Map();
apiServer.use('/api/apps/private/:appId/:hash', (req: IRequestWithPrivateHash, res: Response) => {
const notFound = (): Response => res.sendStatus(404);
const router = this.appRouters.get(req.params.appId);
if (router) {
req._privateHash = req.params.hash;
return router(req, res, notFound);
}
notFound();
});
apiServer.use('/api/apps/public/:appId', (req: Request, res: Response) => {
const notFound = (): Response => res.sendStatus(404);
const router = this.appRouters.get(req.params.appId);
if (router) {
return router(req, res, notFound);
}
notFound();
});
}
public async registerApi({ api, computedPath, endpoint }: AppApi, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is registering the api: "${endpoint.path}" (${computedPath})`);
this._verifyApi(api, endpoint);
let router = this.appRouters.get(appId);
if (!router) {
router = express.Router(); // eslint-disable-line new-cap
this.appRouters.set(appId, router);
}
const method = 'all';
let routePath = endpoint.path.trim();
if (!routePath.startsWith('/')) {
routePath = `/${routePath}`;
}
if (router[method] instanceof Function) {
router[method](
routePath,
authenticationMiddleware({ rejectUnauthorized: !!endpoint.authRequired }),
Meteor.bindEnvironment(this._appApiExecutor(endpoint, appId)),
);
}
}
public async unregisterApis(appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is unregistering all apis`);
if (this.appRouters.get(appId)) {
this.appRouters.delete(appId);
}
}
private _verifyApi(api: IApi, endpoint: IApiEndpoint): void {
if (typeof api !== 'object') {
throw new Error('Invalid Api parameter provided, it must be a valid IApi object.');
}
if (typeof endpoint.path !== 'string') {
throw new Error('Invalid Api parameter provided, it must be a valid IApi object.');
}
}
private _appApiExecutor(endpoint: IApiEndpoint, appId: string): RequestHandler {
return (req: IRequestWithPrivateHash, res: Response): void => {
const request: IApiRequest = {
method: req.method.toLowerCase() as RequestMethod,
headers: req.headers as { [key: string]: string },
query: (req.query as { [key: string]: string }) || {},
params: req.params || {},
content: req.body,
privateHash: req._privateHash,
user: req.user && this.orch.getConverters()?.get('users')?.convertToApp(req.user),
};
this.orch
.getManager()
?.getApiManager()
.executeApi(appId, endpoint.path, request)
.then(({ status, headers = {}, content }) => {
res.set(headers);
res.status(status);
res.send(content);
})
.catch((reason) => {
// Should we handle this as an error?
res.status(500).send(reason.message);
});
};
}
}

View File

@@ -0,0 +1,177 @@
import { AppBridges } from '@rocket.chat/apps/dist/server/bridges/AppBridges';
import { AppActivationBridge } from './activation';
import { AppApisBridge } from './api';
import { AppCloudBridge } from './cloud';
import { AppCommandsBridge } from './commands';
import { AppContactBridge } from './contact';
import { AppDetailChangesBridge } from './details';
import { AppEmailBridge } from './email';
import { AppEnvironmentalVariableBridge } from './environmental';
import { AppExperimentalBridge } from './experimental';
import { AppHttpBridge } from './http';
import { AppInternalBridge } from './internal';
import { AppInternalFederationBridge } from './internalFederation';
import { AppListenerBridge } from './listeners';
import { AppLivechatBridge } from './livechat';
import { AppMessageBridge } from './messages';
import { AppModerationBridge } from './moderation';
import { AppOAuthAppsBridge } from './oauthApps';
import { OutboundCommunicationBridge } from './outboundCommunication';
import { AppPersistenceBridge } from './persistence';
import { AppRoleBridge } from './roles';
import { AppRoomBridge } from './rooms';
import { AppSchedulerBridge } from './scheduler';
import { AppSettingBridge } from './settings';
import { AppThreadBridge } from './thread';
import { UiInteractionBridge } from './uiInteraction';
import { AppUploadBridge } from './uploads';
import { AppUserBridge } from './users';
import { AppVideoConferenceBridge } from './videoConferences';
export class RealAppBridges extends AppBridges {
constructor(orch) {
super();
this._actBridge = new AppActivationBridge(orch);
this._cmdBridge = new AppCommandsBridge(orch);
this._apiBridge = new AppApisBridge(orch);
this._detBridge = new AppDetailChangesBridge(orch);
this._envBridge = new AppEnvironmentalVariableBridge(orch);
this._httpBridge = new AppHttpBridge(orch);
this._lisnBridge = new AppListenerBridge(orch);
this._msgBridge = new AppMessageBridge(orch);
this._persistBridge = new AppPersistenceBridge(orch);
this._roomBridge = new AppRoomBridge(orch);
this._internalBridge = new AppInternalBridge(orch);
this._setsBridge = new AppSettingBridge(orch);
this._userBridge = new AppUserBridge(orch);
this._livechatBridge = new AppLivechatBridge(orch);
this._uploadBridge = new AppUploadBridge(orch);
this._uiInteractionBridge = new UiInteractionBridge(orch);
this._schedulerBridge = new AppSchedulerBridge(orch);
this._cloudWorkspaceBridge = new AppCloudBridge(orch);
this._videoConfBridge = new AppVideoConferenceBridge(orch);
this._oAuthBridge = new AppOAuthAppsBridge(orch);
this._internalFedBridge = new AppInternalFederationBridge();
this._moderationBridge = new AppModerationBridge(orch);
this._threadBridge = new AppThreadBridge(orch);
this._roleBridge = new AppRoleBridge(orch);
this._emailBridge = new AppEmailBridge(orch);
this._contactBridge = new AppContactBridge(orch);
this._outboundMessageBridge = new OutboundCommunicationBridge(orch);
this._experimentalBridge = new AppExperimentalBridge(orch);
}
getCommandBridge() {
return this._cmdBridge;
}
getApiBridge() {
return this._apiBridge;
}
getEnvironmentalVariableBridge() {
return this._envBridge;
}
getHttpBridge() {
return this._httpBridge;
}
getListenerBridge() {
return this._lisnBridge;
}
getMessageBridge() {
return this._msgBridge;
}
getThreadBridge() {
return this._threadBridge;
}
getPersistenceBridge() {
return this._persistBridge;
}
getAppActivationBridge() {
return this._actBridge;
}
getAppDetailChangesBridge() {
return this._detBridge;
}
getRoomBridge() {
return this._roomBridge;
}
getInternalBridge() {
return this._internalBridge;
}
getServerSettingBridge() {
return this._setsBridge;
}
getUserBridge() {
return this._userBridge;
}
getLivechatBridge() {
return this._livechatBridge;
}
getUploadBridge() {
return this._uploadBridge;
}
getUiInteractionBridge() {
return this._uiInteractionBridge;
}
getSchedulerBridge() {
return this._schedulerBridge;
}
getCloudWorkspaceBridge() {
return this._cloudWorkspaceBridge;
}
getVideoConferenceBridge() {
return this._videoConfBridge;
}
getOutboundMessageBridge() {
return this._outboundMessageBridge;
}
getOAuthAppsBridge() {
return this._oAuthBridge;
}
getInternalFederationBridge() {
return this._internalFedBridge;
}
getModerationBridge() {
return this._moderationBridge;
}
getRoleBridge() {
return this._roleBridge;
}
getEmailBridge() {
return this._emailBridge;
}
getContactBridge() {
return this._contactBridge;
}
getExperimentalBridge() {
return this._experimentalBridge;
}
}

View File

@@ -0,0 +1,19 @@
import type { IAppServerOrchestrator } from '@rocket.chat/apps';
import { CloudWorkspaceBridge } from '@rocket.chat/apps/dist/server/bridges/CloudWorkspaceBridge';
import type { IWorkspaceToken } from '@rocket.chat/apps-engine/definition/cloud/IWorkspaceToken';
import { getWorkspaceAccessTokenWithScope } from '../../../../server/lib/cloud';
export class AppCloudBridge extends CloudWorkspaceBridge {
constructor(private readonly orch: IAppServerOrchestrator) {
super();
}
public async getWorkspaceToken(scope: string, appId: string): Promise<IWorkspaceToken> {
this.orch.debugLog(`App ${appId} is getting the workspace's token`);
const token = await getWorkspaceAccessTokenWithScope({ scope });
return token;
}
}

View File

@@ -0,0 +1,221 @@
import type { IAppServerOrchestrator, IAppsRoom, IAppsUser } from '@rocket.chat/apps';
import { CommandBridge } from '@rocket.chat/apps/dist/server/bridges/CommandBridge';
import type { ISlashCommand, ISlashCommandPreview, ISlashCommandPreviewItem } from '@rocket.chat/apps-engine/definition/slashcommands';
import { SlashCommandContext } from '@rocket.chat/apps-engine/definition/slashcommands';
import type { IMessage, RequiredField, SlashCommand, SlashCommandCallbackParams } from '@rocket.chat/core-typings';
import { Utilities } from '../../../../ee/lib/misc/Utilities';
import { parseParameters } from '../../../../lib/utils/parseParameters';
import { slashCommands } from '../../../../server/lib/utils/slashCommand';
export class AppCommandsBridge extends CommandBridge {
disabledCommands: Map<string, (typeof slashCommands.commands)[string]>;
constructor(private readonly orch: IAppServerOrchestrator) {
super();
this.disabledCommands = new Map();
}
protected async doesCommandExist(command: string, appId: string): Promise<boolean> {
this.orch.debugLog(`The App ${appId} is checking if "${command}" command exists.`);
if (typeof command !== 'string' || command.length === 0) {
return false;
}
const cmd = command.toLowerCase();
return typeof slashCommands.commands[cmd] === 'object' || this.disabledCommands.has(cmd);
}
protected async enableCommand(command: string, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is attempting to enable the command: "${command}"`);
if (typeof command !== 'string' || command.trim().length === 0) {
throw new Error('Invalid command parameter provided, must be a string.');
}
const cmd = command.toLowerCase();
if (!this.disabledCommands.has(cmd)) {
throw new Error(`The command is not currently disabled: "${cmd}"`);
}
slashCommands.commands[cmd] = this.disabledCommands.get(cmd) as (typeof slashCommands.commands)[string];
this.disabledCommands.delete(cmd);
void this.orch.getNotifier().commandUpdated(cmd);
}
protected async disableCommand(command: string, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is attempting to disable the command: "${command}"`);
if (typeof command !== 'string' || command.trim().length === 0) {
throw new Error('Invalid command parameter provided, must be a string.');
}
const cmd = command.toLowerCase();
if (this.disabledCommands.has(cmd)) {
// The command is already disabled, no need to disable it yet again
return;
}
const commandObj = slashCommands.commands[cmd];
if (typeof commandObj === 'undefined') {
throw new Error(`Command does not exist in the system currently: "${cmd}"`);
}
this.disabledCommands.set(cmd, commandObj);
delete slashCommands.commands[cmd];
void this.orch.getNotifier().commandDisabled(cmd);
}
// command: { command, paramsExample, i18nDescription, executor: function }
protected async modifyCommand(command: ISlashCommand, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is attempting to modify the command: "${command}"`);
this._verifyCommand(command);
const cmd = command.command.toLowerCase();
if (typeof slashCommands.commands[cmd] === 'undefined') {
throw new Error(`Command does not exist in the system currently (or it is disabled): "${cmd}"`);
}
const item = slashCommands.commands[cmd];
item.params = command.i18nParamsExample ? command.i18nParamsExample : item.params;
item.description = command.i18nDescription ? command.i18nDescription : item.params;
item.callback = this._appCommandExecutor.bind(this);
item.providesPreview = command.providesPreview;
item.previewer = command.previewer ? this._appCommandPreviewer.bind(this) : item.previewer;
item.previewCallback = (
command.executePreviewItem ? this._appCommandPreviewExecutor.bind(this) : item.previewCallback
) as (typeof slashCommands.commands)[string]['previewCallback'];
slashCommands.commands[cmd] = item;
void this.orch.getNotifier().commandUpdated(cmd);
}
protected async registerCommand(command: ISlashCommand, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is registering the command: "${command.command}"`);
this._verifyCommand(command);
const item = {
appId,
command: command.command.toLowerCase(),
params: Utilities.getI18nKeyForApp(command.i18nParamsExample, appId),
description: Utilities.getI18nKeyForApp(command.i18nDescription, appId),
permission: command.permission,
callback: this._appCommandExecutor.bind(this),
providesPreview: command.providesPreview,
previewer: command.providesPreview ? this._appCommandPreviewer.bind(this) : undefined,
previewCallback: (command.providesPreview ? this._appCommandPreviewExecutor.bind(this) : undefined) as
(typeof slashCommands.commands)[string]['previewCallback'] | undefined,
} as SlashCommand;
slashCommands.commands[command.command.toLowerCase()] = item;
void this.orch.getNotifier().commandAdded(command.command.toLowerCase());
}
protected async unregisterCommand(command: string, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is unregistering the command: "${command}"`);
if (typeof command !== 'string' || command.trim().length === 0) {
throw new Error('Invalid command parameter provided, must be a string.');
}
const cmd = command.toLowerCase();
this.disabledCommands.delete(cmd);
delete slashCommands.commands[cmd];
void this.orch.getNotifier().commandRemoved(cmd);
}
private _verifyCommand(command: ISlashCommand): void {
if (typeof command !== 'object') {
throw new Error('Invalid Slash Command parameter provided, it must be a valid ISlashCommand object.');
}
if (typeof command.command !== 'string') {
throw new Error('Invalid Slash Command parameter provided, it must be a valid ISlashCommand object.');
}
if (command.i18nParamsExample && typeof command.i18nParamsExample !== 'string') {
throw new Error('Invalid Slash Command parameter provided, it must be a valid ISlashCommand object.');
}
if (command.i18nDescription && typeof command.i18nDescription !== 'string') {
throw new Error('Invalid Slash Command parameter provided, it must be a valid ISlashCommand object.');
}
if (typeof command.providesPreview !== 'boolean') {
throw new Error('Invalid Slash Command parameter provided, it must be a valid ISlashCommand object.');
}
}
private async _appCommandExecutor({ command, message, params, triggerId, userId }: SlashCommandCallbackParams<string>): Promise<void> {
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
const user: IAppsUser | undefined = await this.orch.getConverters()?.get('users').convertById(userId);
const room: IAppsRoom | undefined = await this.orch.getConverters()?.get('rooms').convertById(message.rid);
const threadId = message.tmid;
const parameters = parseParameters(params);
const context = new SlashCommandContext(
Object.freeze(user as IAppsUser),
Object.freeze(room as IAppsRoom),
Object.freeze(parameters) as string[],
threadId,
triggerId,
);
await this.orch.getManager()?.getCommandManager().executeCommand(command, context);
}
private async _appCommandPreviewer(
command: string,
parameters: any,
message: RequiredField<Partial<IMessage>, 'rid'>,
userId: string,
): Promise<ISlashCommandPreview | undefined> {
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
const user: IAppsUser | undefined = await this.orch.getConverters()?.get('users').convertById(userId);
const room: IAppsRoom | undefined = await this.orch.getConverters()?.get('rooms').convertById(message.rid);
const threadId = message.tmid;
const params = parseParameters(parameters);
const context = new SlashCommandContext(
Object.freeze(user as IAppsUser),
Object.freeze(room as IAppsRoom),
Object.freeze(params) as string[],
threadId,
);
return this.orch.getManager()?.getCommandManager().getPreviews(command, context);
}
private async _appCommandPreviewExecutor(
command: string,
parameters: any,
message: IMessage,
preview: ISlashCommandPreviewItem,
userId: string,
triggerId: string,
): Promise<void> {
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
const user: IAppsUser | undefined = await this.orch.getConverters()?.get('users').convertById(userId);
const room: IAppsRoom | undefined = await this.orch.getConverters()?.get('rooms').convertById(message.rid);
const threadId = message.tmid;
const params = parseParameters(parameters);
const context = new SlashCommandContext(
Object.freeze(user as IAppsUser),
Object.freeze(room as IAppsRoom),
Object.freeze(params) as string[],
threadId,
triggerId,
);
await this.orch.getManager()?.getCommandManager().executePreview(command, preview, context);
}
}

View File

@@ -0,0 +1,39 @@
import type { IAppServerOrchestrator } from '@rocket.chat/apps';
import { ContactBridge } from '@rocket.chat/apps/dist/server/bridges/ContactBridge';
import type { ILivechatContact } from '@rocket.chat/apps-engine/definition/livechat';
import { addContactEmail } from '../../../../server/lib/omnichannel/contacts/addContactEmail';
import { verifyContactChannel } from '../../../../server/lib/omnichannel/contacts/verifyContactChannel';
export class AppContactBridge extends ContactBridge {
constructor(private readonly orch: IAppServerOrchestrator) {
super();
}
async getById(contactId: ILivechatContact['_id'], appId: string): Promise<ILivechatContact | undefined> {
this.orch.debugLog(`The app ${appId} is fetching a contact`);
return this.orch.getConverters().get('contacts').convertById(contactId);
}
async verifyContact(
verifyContactChannelParams: {
contactId: string;
field: string;
value: string;
visitorId: string;
roomId: string;
},
appId: string,
): Promise<void> {
this.orch.debugLog(`The app ${appId} is verifing a contact`);
// Note: If there is more than one app installed, whe should validate the app that called this method to be same one
// selected in the setting.
await verifyContactChannel(verifyContactChannelParams);
}
protected async addContactEmail(contactId: ILivechatContact['_id'], email: string, appId: string): Promise<ILivechatContact> {
this.orch.debugLog(`The app ${appId} is adding a new email to the contact`);
const contact = await addContactEmail(contactId, email);
return this.orch.getConverters().get('contacts').convertContact(contact);
}
}

View File

@@ -0,0 +1,19 @@
import type { IAppServerOrchestrator } from '@rocket.chat/apps';
import { AppDetailChangesBridge as DetailChangesBridge } from '@rocket.chat/apps/dist/server/bridges/AppDetailChangesBridge';
import type { ISetting } from '@rocket.chat/apps-engine/definition/settings';
export class AppDetailChangesBridge extends DetailChangesBridge {
constructor(private readonly orch: IAppServerOrchestrator) {
super();
}
protected onAppSettingsChange(appId: string, setting: ISetting): void {
const logFailure = () => console.warn('failed to notify about the setting change.', appId);
try {
this.orch.getNotifier().appSettingsChange(appId, setting).catch(logFailure);
} catch (e) {
logFailure();
}
}
}

View File

@@ -0,0 +1,23 @@
import type { IAppServerOrchestrator } from '@rocket.chat/apps';
import { EmailBridge } from '@rocket.chat/apps/dist/server/bridges/EmailBridge';
import type { IEmail } from '@rocket.chat/apps-engine/definition/email';
import * as Mailer from '../../../../server/lib/notifications/email/api';
import { settings } from '../../../../server/settings';
export class AppEmailBridge extends EmailBridge {
constructor(private readonly orch: IAppServerOrchestrator) {
super();
}
protected async sendEmail(email: IEmail, appId: string): Promise<void> {
let { from } = email;
if (!from) {
this.orch.debugLog(`The app ${appId} didn't provide a from address, using the default one.`);
from = String(settings.get('From_Email'));
}
this.orch.debugLog(`The app ${appId} is sending an email.`);
await Mailer.send({ ...email, from });
}
}

View File

@@ -0,0 +1,45 @@
import type { IAppServerOrchestrator } from '@rocket.chat/apps';
import { EnvironmentalVariableBridge } from '@rocket.chat/apps/dist/server/bridges/EnvironmentalVariableBridge';
export class AppEnvironmentalVariableBridge extends EnvironmentalVariableBridge {
allowed: Array<string>;
constructor(private readonly orch: IAppServerOrchestrator) {
super();
this.allowed = ['NODE_ENV', 'ROOT_URL', 'INSTANCE_IP'];
}
protected async getValueByName(envVarName: string, appId: string): Promise<string | undefined> {
this.orch.debugLog(`The App ${appId} is getting the environmental variable value ${envVarName}.`);
if (!(await this.isReadable(envVarName, appId))) {
throw new Error(`The environmental variable "${envVarName}" is not readable.`);
}
return process.env[envVarName];
}
protected async isReadable(envVarName: string, appId: string): Promise<boolean> {
this.orch.debugLog(`The App ${appId} is checking if the environmental variable is readable ${envVarName}.`);
return this.allowed.includes(envVarName.toUpperCase()) || this.isAppsOwnVariable(envVarName, appId);
}
protected isAppsOwnVariable(envVarName: string, appId: string): boolean {
/**
* Replace the letter `-` with `_` since environment variable name doesn't support it
*/
const appVariablePrefix = `RC_APPS_${appId.toUpperCase().replace(/-/g, '_')}`;
return envVarName.toUpperCase().startsWith(appVariablePrefix);
}
protected async isSet(envVarName: string, appId: string): Promise<boolean> {
this.orch.debugLog(`The App ${appId} is checking if the environmental variable is set ${envVarName}.`);
if (!(await this.isReadable(envVarName, appId))) {
throw new Error(`The environmental variable "${envVarName}" is not readable.`);
}
return typeof process.env[envVarName] !== 'undefined';
}
}

View File

@@ -0,0 +1,8 @@
import type { IAppServerOrchestrator } from '@rocket.chat/apps';
import { ExperimentalBridge } from '@rocket.chat/apps/dist/server/bridges/ExperimentalBridge';
export class AppExperimentalBridge extends ExperimentalBridge {
constructor(protected readonly orch: IAppServerOrchestrator) {
super();
}
}

View File

@@ -0,0 +1,131 @@
import type { IAppServerOrchestrator } from '@rocket.chat/apps';
import type { IHttpBridgeRequestInfo } from '@rocket.chat/apps/dist/server/bridges/HttpBridge';
import { HttpBridge } from '@rocket.chat/apps/dist/server/bridges/HttpBridge';
import type { IHttpResponse } from '@rocket.chat/apps-engine/definition/accessors';
import { serverFetch as fetch, type ExtendedFetchOptions } from '@rocket.chat/server-fetch';
import { censorUrl } from '@rocket.chat/tools';
import { settings } from '../../../../server/settings';
const isGetOrHead = (method: string): boolean => ['GET', 'HEAD'].includes(method.toUpperCase());
// Previously, there was no timeout for HTTP requests.
// We're setting the default timeout now to 3 minutes as it
// seems to be a good balance
const DEFAULT_TIMEOUT = 3 * 60 * 1000;
export class AppHttpBridge extends HttpBridge {
constructor(private readonly orch: IAppServerOrchestrator) {
super();
}
protected async call(info: IHttpBridgeRequestInfo): Promise<IHttpResponse> {
// begin comptability with old HTTP.call API
const url = new URL(info.url);
const { request, method } = info;
const { headers = {} } = request;
let { content } = request;
if (!content && typeof request.data === 'object') {
content = request.data;
}
if (request.auth) {
if (request.auth.indexOf(':') < 0) {
throw new Error('auth option should be of the form "username:password"');
}
const base64 = Buffer.from(request.auth, 'ascii').toString('base64');
headers.Authorization = `Basic ${base64}`;
}
let paramsForBody;
if (content || isGetOrHead(method)) {
if (request.params) {
Object.keys(request.params).forEach((key) => {
if (request.params?.[key]) {
url.searchParams.append(key, request.params?.[key]);
}
});
}
} else {
paramsForBody = request.params;
}
if (paramsForBody) {
const data = new URLSearchParams();
Object.entries(paramsForBody).forEach(([key, value]) => {
data.append(key, value);
});
content = data.toString();
headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
if (isGetOrHead(method)) {
content = undefined;
}
const timeout = request.timeout || DEFAULT_TIMEOUT;
// end comptability with old HTTP.call API
this.orch.debugLog({ msg: `The App ${info.appId} is requesting from the outter webs:`, info: { ...info, url: censorUrl(info.url) } });
const shouldIgnoreSsrf = request.ssrfValidation !== true;
const fetchOptions: ExtendedFetchOptions = {
method,
body: content,
headers,
timeout,
...(shouldIgnoreSsrf
? { ignoreSsrfValidation: true }
: { ignoreSsrfValidation: false, allowList: settings.get<string>('SSRF_Allowlist') }),
};
const response = await fetch(
url.href,
fetchOptions,
(request.hasOwnProperty('strictSSL') && !request.strictSSL) ||
(request.hasOwnProperty('rejectUnauthorized') && request.rejectUnauthorized),
);
const result: IHttpResponse = {
url: info.url,
method: info.method,
statusCode: response.status,
headers: Object.fromEntries(response.headers as unknown as any),
};
const body = Buffer.from(await response.arrayBuffer());
if (request.encoding === null) {
/**
* The property `content` is not appropriately typed in the
* Apps-engine definition, and we can't simply change it there
* as it would be a breaking change. Thus, we're left with this
* type assertion.
*/
result.content = body as any;
} else {
result.content = body.toString(request.encoding as BufferEncoding);
result.data = ((): any => {
const contentType = (response.headers.get('content-type') || '').split(';')[0];
if (!['application/json', 'text/javascript', 'application/javascript', 'application/x-javascript'].includes(contentType)) {
return null;
}
try {
return JSON.parse(result.content);
} catch {
return null;
}
})();
}
return result;
}
}

View File

@@ -0,0 +1 @@
export * from './bridges';

View File

@@ -0,0 +1,48 @@
import type { IAppServerOrchestrator, IAppsSetting } from '@rocket.chat/apps';
import { InternalBridge } from '@rocket.chat/apps/dist/server/bridges/InternalBridge';
import type { ISetting, ISubscription } from '@rocket.chat/core-typings';
import { Settings, Subscriptions } from '@rocket.chat/models';
import { isTruthy } from '@rocket.chat/tools';
import { deasyncPromise } from '../../../../server/deasync/deasync';
export class AppInternalBridge extends InternalBridge {
constructor(private readonly orch: IAppServerOrchestrator) {
super();
}
protected getUsernamesOfRoomByIdSync(roomId: string): Array<string> {
return deasyncPromise(this.getUsernamesOfRoomById(roomId));
}
protected async getUsernamesOfRoomById(roomId: string): Promise<Array<string>> {
// This function will be converted to sync inside the apps-engine code
// TODO: Track Deprecation
if (!roomId) {
return [];
}
const records = await Subscriptions.findByRoomIdWhenUsernameExists(roomId, {
projection: {
'u.username': 1,
},
}).toArray();
if (!records || records.length === 0) {
return [];
}
return records.map((s: ISubscription) => s.u.username).filter(isTruthy);
}
protected async getWorkspacePublicKey(): Promise<IAppsSetting> {
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
const publicKeySetting: ISetting | null = await Settings.findOneById('Cloud_Workspace_PublicKey');
return this.orch
.getConverters()
?.get('settings')
.convertToApp(publicKeySetting as ISetting);
}
}

View File

@@ -0,0 +1,12 @@
import type { IInternalFederationBridge } from '@rocket.chat/apps/dist/server/bridges/IInternalFederationBridge';
import { FederationKeys } from '@rocket.chat/models';
export class AppInternalFederationBridge implements IInternalFederationBridge {
async getPrivateKey(): Promise<string | null> {
return FederationKeys.getKey('private');
}
async getPublicKey(): Promise<string | null> {
return FederationKeys.getKey('public');
}
}

View File

@@ -0,0 +1,560 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import type { IAppServerOrchestrator, IAppsRoom, IAppsLivechatRoom, IAppsMessage } from '@rocket.chat/apps';
import type { IPreEmailSentContext } from '@rocket.chat/apps-engine/definition/email';
import type { IExternalComponent } from '@rocket.chat/apps-engine/definition/externalComponent';
import { LivechatTransferEventType } from '@rocket.chat/apps-engine/definition/livechat';
import { isLivechatRoom } from '@rocket.chat/apps-engine/definition/livechat/ILivechatRoom';
import { AppInterface } from '@rocket.chat/apps-engine/definition/metadata';
import type { UIKitIncomingInteraction } from '@rocket.chat/apps-engine/definition/uikit';
import type { IUIKitLivechatIncomingInteraction } from '@rocket.chat/apps-engine/definition/uikit/livechat';
import type { IUserContext, IUserUpdateContext } from '@rocket.chat/apps-engine/definition/users';
import type { IMessage, IRoom, IUser, ILivechatDepartment, IUpload } from '@rocket.chat/core-typings';
type LivechatTransferData = {
type: LivechatTransferEventType;
room: string;
from: string;
to: string;
};
type LivechatAgentData = {
room: IRoom;
user: IUser;
};
type UserStatusChangedData = {
user: IUser;
currentStatus: string;
previousStatus: string;
};
type UserCrudData = {
user: IUser;
performedBy?: IUser;
previousUser?: IUser;
};
// IPostMessageSentToBot is an internally triggered event, based on IPostMessageSent
// so we don't add it here
type HandleMessageEvent =
| {
event: AppInterface.IPostMessageDeleted;
payload: [IMessage, IUser];
}
| {
event: AppInterface.IPostMessageReacted;
payload: [IMessage, IUser, string, boolean];
}
| {
event: AppInterface.IPostMessageFollowed;
payload: [IMessage, IUser, boolean];
}
| {
event: AppInterface.IPostMessagePinned;
payload: [IMessage, IUser, boolean];
}
| {
event: AppInterface.IPostMessageStarred;
payload: [IMessage, IUser, boolean];
}
| {
event: AppInterface.IPostMessageReported;
payload: [IMessage, IUser, string];
}
| {
event:
| AppInterface.IPostSystemMessageSent
| AppInterface.IPreMessageSentPrevent
| AppInterface.IPreMessageSentExtend
| AppInterface.IPreMessageSentModify
| AppInterface.IPostMessageSent
| AppInterface.IPreMessageDeletePrevent
| AppInterface.IPreMessageUpdatedPrevent
| AppInterface.IPreMessageUpdatedExtend
| AppInterface.IPreMessageUpdatedModify
| AppInterface.IPostMessageUpdated;
payload: [IMessage];
};
type HandleRoomEvent =
| {
event: AppInterface.IPreRoomUserJoined | AppInterface.IPostRoomUserJoined;
payload: [IRoom, IUser, IUser];
}
| {
event: AppInterface.IPreRoomUserLeave | AppInterface.IPostRoomUserLeave;
payload: [IRoom, IUser, IUser];
}
| {
event:
| AppInterface.IPreRoomCreatePrevent
| AppInterface.IPreRoomCreateExtend
| AppInterface.IPreRoomCreateModify
| AppInterface.IPostRoomCreate
| AppInterface.IPreRoomDeletePrevent
| AppInterface.IPostRoomDeleted
| AppInterface.IPreRoomUserJoined
| AppInterface.IPostRoomUserJoined
| AppInterface.IPreRoomUserLeave
| AppInterface.IPostRoomUserLeave;
payload: [IRoom];
};
type HandleLivechatEvent =
| {
event: AppInterface.IPostLivechatAgentAssigned | AppInterface.IPostLivechatAgentUnassigned;
payload: [LivechatAgentData];
}
| {
event: AppInterface.IPostLivechatRoomTransferred;
payload: [LivechatTransferData];
}
| {
event: AppInterface.IPostLivechatGuestSaved;
payload: [string];
}
| {
event: AppInterface.IPostLivechatRoomSaved;
payload: [string];
}
| {
event: AppInterface.IPostLivechatDepartmentRemoved;
payload: [ILivechatDepartment];
}
| {
event: AppInterface.IPostLivechatDepartmentDisabled;
payload: [ILivechatDepartment];
}
| {
event:
| AppInterface.ILivechatRoomClosedHandler
| AppInterface.IPreLivechatRoomCreatePrevent
| AppInterface.IPostLivechatRoomStarted
| AppInterface.IPostLivechatRoomClosed;
payload: [IRoom];
};
type HandleUserEvent =
| {
event: AppInterface.IPostUserLoggedIn | AppInterface.IPostUserLoggedOut;
payload: [IUser];
}
| {
event: AppInterface.IPostUserStatusChanged;
payload: [UserStatusChangedData];
}
| {
event: AppInterface.IPostUserDeleted | AppInterface.IPostUserCreated | AppInterface.IPostUserUpdated;
payload: [UserCrudData];
};
type HandleDefaultEvent =
| {
event: AppInterface.IPostExternalComponentOpened | AppInterface.IPostExternalComponentClosed;
payload: [IExternalComponent];
}
| {
event: AppInterface.IUIKitInteractionHandler;
payload: [UIKitIncomingInteraction];
}
| {
event: AppInterface.IUIKitLivechatInteractionHandler;
payload: [IUIKitLivechatIncomingInteraction];
}
| {
event: AppInterface.IPreEmailSent;
payload: [IPreEmailSentContext];
};
type HandleFileUploadEvent = {
event: AppInterface.IPreFileUpload;
payload: [{ file: IUpload; content: Buffer | string }];
};
export type HandleEvent =
HandleMessageEvent | HandleRoomEvent | HandleLivechatEvent | HandleUserEvent | HandleFileUploadEvent | HandleDefaultEvent;
export class AppListenerBridge {
constructor(private readonly orch: IAppServerOrchestrator) {}
// eslint-disable-next-line complexity
async handleEvent(args: HandleEvent): Promise<any> {
switch (args.event) {
case AppInterface.IPreFileUpload:
return this.uploadEvent(args);
case AppInterface.IPostMessageDeleted:
case AppInterface.IPostMessageReacted:
case AppInterface.IPostMessageFollowed:
case AppInterface.IPostMessagePinned:
case AppInterface.IPostMessageStarred:
case AppInterface.IPostMessageReported:
case AppInterface.IPostSystemMessageSent:
case AppInterface.IPreMessageSentPrevent:
case AppInterface.IPreMessageSentExtend:
case AppInterface.IPreMessageSentModify:
case AppInterface.IPostMessageSent:
case AppInterface.IPreMessageDeletePrevent:
case AppInterface.IPreMessageUpdatedPrevent:
case AppInterface.IPreMessageUpdatedExtend:
case AppInterface.IPreMessageUpdatedModify:
case AppInterface.IPostMessageUpdated:
return this.messageEvent(args);
case AppInterface.IPreRoomCreatePrevent:
case AppInterface.IPreRoomCreateExtend:
case AppInterface.IPreRoomCreateModify:
case AppInterface.IPostRoomCreate:
case AppInterface.IPreRoomDeletePrevent:
case AppInterface.IPostRoomDeleted:
case AppInterface.IPreRoomUserJoined:
case AppInterface.IPostRoomUserJoined:
case AppInterface.IPreRoomUserLeave:
case AppInterface.IPostRoomUserLeave:
return this.roomEvent(args);
/**
* @deprecated please prefer the AppInterface.IPostLivechatRoomClosed event
*/
case AppInterface.ILivechatRoomClosedHandler:
case AppInterface.IPreLivechatRoomCreatePrevent:
case AppInterface.IPostLivechatRoomStarted:
case AppInterface.IPostLivechatRoomClosed:
case AppInterface.IPostLivechatAgentAssigned:
case AppInterface.IPostLivechatAgentUnassigned:
case AppInterface.IPostLivechatRoomTransferred:
case AppInterface.IPostLivechatGuestSaved:
case AppInterface.IPostLivechatRoomSaved:
case AppInterface.IPostLivechatDepartmentRemoved:
case AppInterface.IPostLivechatDepartmentDisabled:
return this.livechatEvent(args);
case AppInterface.IPostUserCreated:
case AppInterface.IPostUserUpdated:
case AppInterface.IPostUserDeleted:
case AppInterface.IPostUserLoggedIn:
case AppInterface.IPostUserLoggedOut:
case AppInterface.IPostUserStatusChanged:
return this.userEvent(args);
default:
return this.defaultEvent(args);
}
}
async defaultEvent(args: HandleDefaultEvent): Promise<unknown> {
return this.orch.getManager().getListenerManager().executeListener(args.event, args.payload[0]);
}
async uploadEvent(args: HandleFileUploadEvent): Promise<void> {
const [{ file, content }] = args.payload;
const tmpfile = path.join(this.orch.getManager().getTempFilePath(), crypto.randomUUID());
if (typeof content === 'string') {
// If content is a string, we assume it's a path and create a symlink to avoid file duplication
await fs.promises.symlink(content, tmpfile, 'file').catch((err) => {
this.orch.getRocketChatLogger().error({ msg: `AppListenerBridge: Could not create symlink at ${tmpfile}`, err });
throw new Error('Error sending file to apps', { cause: err });
});
} else {
// Otherwise, we write the buffer content to a temporary file
await fs.promises.writeFile(tmpfile, content).catch((err) => {
this.orch.getRocketChatLogger().error({ msg: `AppListenerBridge: Could not write temporary file at ${tmpfile}`, err });
throw new Error('Error sending file to apps', { cause: err });
});
}
try {
const uploadDetails = {
name: file.name || '',
size: file.size || 0,
type: file.type || '',
rid: file.rid || '',
userId: file.userId || '',
visitorToken: file.token,
};
// Execute both events for backward compatibility
await this.orch.getManager().getListenerManager().executeListener(AppInterface.IPreFileUpload, {
file: uploadDetails,
path: tmpfile,
});
} finally {
await fs.promises
.unlink(tmpfile)
.catch((err) =>
this.orch.getRocketChatLogger().warn({ msg: `AppListenerBridge: Could not delete temporary file at ${tmpfile}`, err }),
);
}
}
async messageEvent(args: HandleMessageEvent): Promise<boolean | IMessage | undefined> {
const [message] = args.payload;
const msg = await this.orch.getConverters().get('messages').convertMessage(message);
const result = await (() => {
switch (args.event) {
case AppInterface.IPostMessageDeleted:
const [, userDeleted] = args.payload;
return this.orch
.getManager()
.getListenerManager()
.executeListener(args.event, {
message: msg,
user: this.orch.getConverters().get('users').convertToApp(userDeleted),
});
case AppInterface.IPostMessageReacted:
const [, userReacted, reaction, isReacted] = args.payload;
return this.orch
.getManager()
.getListenerManager()
.executeListener(args.event, {
message: msg,
user: this.orch.getConverters().get('users').convertToApp(userReacted),
reaction,
isReacted,
});
case AppInterface.IPostMessageFollowed:
const [, userFollowed, isFollowed] = args.payload;
return this.orch
.getManager()
.getListenerManager()
.executeListener(args.event, {
message: msg,
user: this.orch.getConverters().get('users').convertToApp(userFollowed),
isFollowed,
});
case AppInterface.IPostMessagePinned:
const [, userPinned, isPinned] = args.payload;
return this.orch
.getManager()
.getListenerManager()
.executeListener(args.event, {
message: msg,
user: this.orch.getConverters().get('users').convertToApp(userPinned),
isPinned,
});
case AppInterface.IPostMessageStarred:
const [, userStarred, isStarred] = args.payload;
return this.orch
.getManager()
.getListenerManager()
.executeListener(args.event, {
message: msg,
user: this.orch.getConverters().get('users').convertToApp(userStarred),
isStarred,
});
case AppInterface.IPostMessageReported:
const [, userReported, reason] = args.payload;
return this.orch
.getManager()
.getListenerManager()
.executeListener(args.event, {
message: msg,
user: this.orch.getConverters().get('users').convertToApp(userReported),
reason,
});
default:
return this.orch.getManager().getListenerManager().executeListener(args.event, msg);
}
})();
// TODO: weird that boolean is not returned by executeListener
if (typeof result === 'boolean' || result === undefined) {
return result ?? undefined;
}
return this.orch
.getConverters()
.get('messages')
.convertAppMessage(result as IAppsMessage);
}
async roomEvent(args: HandleRoomEvent): Promise<boolean | IRoom | IAppsRoom | IAppsLivechatRoom | undefined> {
const [room] = args.payload;
const rm = await this.orch.getConverters().get('rooms').convertRoom(room);
const result = await (() => {
switch (args.event) {
case AppInterface.IPreRoomUserJoined:
case AppInterface.IPostRoomUserJoined:
const [, joiningUser, invitingUser] = args.payload;
return this.orch
.getManager()
.getListenerManager()
.executeListener(args.event, {
room: rm,
joiningUser: this.orch.getConverters().get('users').convertToApp(joiningUser)!,
inviter: this.orch.getConverters().get('users').convertToApp(invitingUser),
});
case AppInterface.IPreRoomUserLeave:
case AppInterface.IPostRoomUserLeave:
const [, leavingUser, removedBy] = args.payload;
return this.orch
.getManager()
.getListenerManager()
.executeListener(args.event, {
room: rm,
leavingUser: this.orch.getConverters().get('users').convertToApp(leavingUser)!,
removedBy: this.orch.getConverters().get('users').convertToApp(removedBy),
});
default:
return this.orch.getManager().getListenerManager().executeListener(args.event, rm);
}
})();
if (typeof result === 'boolean' || result === undefined) {
return result ?? undefined;
}
return this.orch.getConverters().get('rooms').convertAppRoom(result);
}
async livechatEvent(args: HandleLivechatEvent): Promise<unknown> {
switch (args.event) {
case AppInterface.IPostLivechatAgentAssigned:
case AppInterface.IPostLivechatAgentUnassigned:
const [agentData] = args.payload;
return this.orch
.getManager()
.getListenerManager()
.executeListener(args.event, {
room: (await this.orch.getConverters().get('rooms').convertRoom(agentData.room)) as IAppsLivechatRoom,
agent: this.orch.getConverters().get('users').convertToApp(agentData.user),
});
case AppInterface.IPostLivechatRoomTransferred: {
const [transferData] = args.payload;
const converter = transferData.type === LivechatTransferEventType.AGENT ? 'users' : 'departments';
const room = await this.orch.getConverters().get('rooms').convertById(transferData.room);
const from = await this.orch.getConverters().get(converter).convertById(transferData.from);
const to = await this.orch.getConverters().get(converter).convertById(transferData.to);
if (!room) {
throw new Error(`Room with id ${transferData.room} not found`);
}
if (!to) {
throw new Error(`Transfer to entity with id ${transferData.to} not found`);
}
return this.orch
.getManager()
.getListenerManager()
.executeListener(args.event, {
room,
from: from as NonNullable<typeof from>, // type definition in the apps-engine seems to be incorrect
to,
type: transferData.type,
});
}
case AppInterface.IPostLivechatGuestSaved: {
const [visitorId] = args.payload;
const visitor = await this.orch.getConverters().get('visitors').convertById(visitorId);
if (!visitor) {
throw new Error(`Visitor with id ${visitorId} not found`);
}
return this.orch.getManager().getListenerManager().executeListener(args.event, visitor);
}
case AppInterface.IPostLivechatRoomSaved: {
const [roomId] = args.payload;
const room = await this.orch.getConverters().get('rooms').convertById(roomId);
if (!room) {
throw new Error(`Room with id ${roomId} not found`);
}
return this.orch
.getManager()
.getListenerManager()
.executeListener(args.event, room as IAppsLivechatRoom);
}
case AppInterface.IPostLivechatDepartmentDisabled: {
const [departmentData] = args.payload;
const department = await this.orch.getConverters().get('departments').convertDepartment(departmentData);
if (!department) {
throw new Error(`Department ${departmentData._id} not found`);
}
return this.orch.getManager().getListenerManager().executeListener(args.event, { department });
}
case AppInterface.IPostLivechatDepartmentRemoved: {
const [departmentData] = args.payload;
const department = await this.orch.getConverters().get('departments').convertDepartment(departmentData);
if (!department) {
throw new Error(`Department ${departmentData._id} not found`);
}
return this.orch.getManager().getListenerManager().executeListener(args.event, { department });
}
default:
const [roomData] = args.payload;
const room = await this.orch.getConverters().get('rooms').convertRoom(roomData);
if (!room) {
throw new Error(`Room ${roomData._id} not found`);
}
if (!isLivechatRoom(room)) {
throw new Error(`Room ${roomData._id} is not a livechat room`);
}
return this.orch.getManager().getListenerManager().executeListener(args.event, room);
}
}
async userEvent(args: HandleUserEvent): Promise<unknown> {
switch (args.event) {
case AppInterface.IPostUserLoggedIn:
case AppInterface.IPostUserLoggedOut: {
const [loggedInUser] = args.payload;
const context = this.orch.getConverters().get('users').convertToApp(loggedInUser);
return this.orch.getManager().getListenerManager().executeListener(args.event, context);
}
case AppInterface.IPostUserStatusChanged: {
const [statusData] = args.payload;
const { currentStatus, previousStatus } = statusData;
const context = {
user: this.orch.getConverters().get('users').convertToApp(statusData.user),
currentStatus,
previousStatus,
};
return this.orch.getManager().getListenerManager().executeListener(args.event, context);
}
case AppInterface.IPostUserCreated:
case AppInterface.IPostUserDeleted: {
const [crudData] = args.payload;
const context: IUserContext = {
user: this.orch.getConverters().get('users').convertToApp(crudData.user),
performedBy: this.orch.getConverters().get('users').convertToApp(crudData.performedBy),
};
return this.orch.getManager().getListenerManager().executeListener(args.event, context);
}
case AppInterface.IPostUserUpdated: {
const [crudData] = args.payload;
const context: IUserUpdateContext = {
user: this.orch.getConverters().get('users').convertToApp(crudData.user),
performedBy: this.orch.getConverters().get('users').convertToApp(crudData.performedBy),
previousData: this.orch.getConverters().get('users').convertToApp(crudData.previousUser),
};
return this.orch.getManager().getListenerManager().executeListener(args.event, context);
}
}
}
}

View File

@@ -0,0 +1,416 @@
import type { IAppServerOrchestrator, IAppsLivechatMessage } from '@rocket.chat/apps';
import { LivechatBridge } from '@rocket.chat/apps/dist/server/bridges/LivechatBridge';
import type { IExtraRoomParams } from '@rocket.chat/apps-engine/definition/accessors/ILivechatCreator';
import type {
IVisitorExternalIdentifier,
IVisitor,
ILivechatRoom,
ILivechatTransferData,
IDepartment,
ResolveVisitorContactData,
} from '@rocket.chat/apps-engine/definition/livechat';
import type { IMessage as IAppsEngineMessage } from '@rocket.chat/apps-engine/definition/messages';
import type { IUser } from '@rocket.chat/apps-engine/definition/users';
import type { ILivechatDepartment, IOmnichannelRoom, SelectedAgent, ILivechatVisitor } from '@rocket.chat/core-typings';
import { OmnichannelSourceType } from '@rocket.chat/core-typings';
import { LivechatVisitors, LivechatRooms, LivechatDepartment, Users } from '@rocket.chat/models';
import { registerGuest } from '@rocket.chat/omni-core';
import { deasyncPromise } from '../../../../server/deasync/deasync';
import { callbacks } from '../../../../server/lib/callbacks';
import { closeRoom } from '../../../../server/lib/omnichannel/closeRoom';
import { setCustomFields } from '../../../../server/lib/omnichannel/custom-fields';
import { getRoomMessages } from '../../../../server/lib/omnichannel/getRoomMessages';
import type { ILivechatMessage } from '../../../../server/lib/omnichannel/localTypes';
import { updateMessage, sendMessage } from '../../../../server/lib/omnichannel/messages';
import { resolveVisitor } from '../../../../server/lib/omnichannel/resolveVisitor';
import { createRoom } from '../../../../server/lib/omnichannel/rooms';
import { online } from '../../../../server/lib/omnichannel/service-status';
import { transfer } from '../../../../server/lib/omnichannel/transfer';
import { settings } from '../../../../server/settings';
declare module '@rocket.chat/apps-engine/definition/accessors/ILivechatCreator' {
interface IExtraRoomParams {
customFields?: Record<string, unknown>;
}
}
export class AppLivechatBridge extends LivechatBridge {
constructor(private readonly orch: IAppServerOrchestrator) {
super();
}
protected isOnline(departmentId?: string): boolean {
// This function will be converted to sync inside the apps-engine code
// TODO: Track Deprecation
return deasyncPromise(online(departmentId));
}
protected async isOnlineAsync(departmentId?: string): Promise<boolean> {
return online(departmentId);
}
protected async createMessage(message: IAppsLivechatMessage, appId: string): Promise<string> {
this.orch.debugLog(`The App ${appId} is creating a new message.`);
if (!message.token) {
throw new Error('Invalid token for livechat message');
}
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
const guest = this.orch.getConverters().get('visitors').convertAppVisitor(message.visitor);
const appMessage = await this.orch.getConverters().get('messages').convertAppMessage(message);
const livechatMessage = appMessage as ILivechatMessage | undefined;
const msg = await sendMessage({
guest: guest as ILivechatVisitor,
message: livechatMessage as ILivechatMessage,
agent: undefined,
roomInfo: {
source: {
type: OmnichannelSourceType.APP,
id: appId,
alias: this.orch.getManager()?.getOneById(appId)?.getNameSlug(),
},
},
});
return msg._id;
}
protected async getMessageById(messageId: string, appId: string): Promise<IAppsLivechatMessage> {
this.orch.debugLog(`The App ${appId} is getting the message: "${messageId}"`);
const message = await this.orch.getConverters().get('messages').convertById(messageId);
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
return message as IAppsLivechatMessage;
}
protected async updateMessage(message: IAppsLivechatMessage, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is updating a message.`);
const data = {
guest: message.visitor,
message: await this.orch.getConverters()?.get('messages').convertAppMessage(message),
};
// @ts-expect-error IVisitor vs ILivechatVisitor :(
await updateMessage(data);
}
protected async createRoom(
visitor: IVisitor,
agent: IUser,
appId: string,
{ source, customFields }: IExtraRoomParams = {},
): Promise<ILivechatRoom> {
this.orch.debugLog(`The App ${appId} is creating a livechat room.`);
let agentRoom: SelectedAgent | undefined;
if (agent?.id) {
const user = await Users.getAgentInfo(agent.id, settings.get('Livechat_show_agent_email'));
if (!user) {
throw new Error(`The agent with id "${agent.id}" was not found.`);
}
agentRoom = { agentId: user._id, username: user.username };
}
const room = await createRoom({
visitor: this.orch.getConverters()?.get('visitors').convertAppVisitor(visitor),
roomInfo: {
source: {
type: OmnichannelSourceType.APP,
id: appId,
alias: this.orch.getManager()?.getOneById(appId)?.getName(),
...(source?.type === 'app' && {
sidebarIcon: source.sidebarIcon,
defaultIcon: source.defaultIcon,
label: source.label,
destination: source.destination,
}),
},
},
agent: agentRoom,
extraData: customFields && { customFields },
});
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
return this.orch.getConverters()?.get('rooms').convertRoom(room) as Promise<ILivechatRoom>;
}
protected async closeRoom(room: ILivechatRoom, comment: string, closer: IUser | undefined, appId: string): Promise<boolean> {
this.orch.debugLog(`The App ${appId} is closing a livechat room.`);
const user = closer && this.orch.getConverters()?.get('users').convertToRocketChat(closer);
const visitor = this.orch.getConverters()?.get('visitors').convertAppVisitor(room.visitor);
const closeData: any = {
room: await this.orch.getConverters()?.get('rooms').convertAppRoom(room),
comment,
...(user && { user }),
...(visitor && { visitor }),
};
await closeRoom(closeData);
return true;
}
protected async findOpenRoomsByAgentId(agentId: string, appId: string): Promise<ILivechatRoom[]> {
this.orch.debugLog(`The App ${appId} is looking for livechat rooms associated with agent ${agentId}`);
if (!agentId) {
throw new Error('Invalid agentId');
}
const rooms = await LivechatRooms.findOpenByAgent(agentId).toArray();
return Promise.all(rooms.map((room) => this.orch.getConverters()?.get('rooms').convertRoom(room) as Promise<ILivechatRoom>));
}
protected async countOpenRoomsByAgentId(agentId: string, appId: string): Promise<number> {
this.orch.debugLog(`The App ${appId} is counting livechat rooms associated with agent ${agentId}`);
if (!agentId) {
throw new Error('Invalid agentId');
}
return LivechatRooms.countOpenByAgent(agentId);
}
protected async findRooms(visitor: IVisitor, departmentId: string | null, appId: string): Promise<Array<ILivechatRoom>> {
this.orch.debugLog(`The App ${appId} is looking for livechat visitors.`);
if (!visitor) {
return [];
}
let result;
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {});
if (departmentId) {
result = await LivechatRooms.findOpenByVisitorTokenAndDepartmentId(visitor.token, departmentId, {}, extraQuery).toArray();
} else {
result = await LivechatRooms.findOpenByVisitorToken(visitor.token, {}, extraQuery).toArray();
}
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
return Promise.all(result.map((room) => this.orch.getConverters()?.get('rooms').convertRoom(room) as Promise<ILivechatRoom>));
}
/**
* @deprecated Use `createAndReturnVisitor` instead.
* Note: This method does not support `externalIds`.
*/
protected async createVisitor(visitor: IVisitor, appId: string): Promise<string> {
this.orch.debugLog(`The App ${appId} is creating a livechat visitor.`);
const registerData = {
department: visitor.department,
username: visitor.username,
name: visitor.name,
token: visitor.token,
email: '',
id: visitor.id,
...(visitor.phone?.length && { phone: { number: visitor.phone[0].phoneNumber } }),
...(visitor.visitorEmails?.length && { email: visitor.visitorEmails[0].address }),
};
const livechatVisitor = await registerGuest(registerData, {
shouldConsiderIdleAgent: settings.get<boolean>('Livechat_enabled_when_agent_idle'),
shouldConsiderOfflineAgent: settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
});
if (!livechatVisitor) {
throw new Error('Invalid visitor, cannot create');
}
return livechatVisitor._id;
}
protected async createAndReturnVisitor(visitor: IVisitor, appId: string): Promise<IVisitor | undefined> {
this.orch.debugLog(`The App ${appId} is creating a livechat visitor.`);
// Add appId to each externalId entry
const externalIds = visitor.externalIds?.map((entry) => ({ ...entry, appId }));
const registerData = {
department: visitor.department,
username: visitor.username,
name: visitor.name,
token: visitor.token,
email: '',
id: visitor.id,
...(visitor.phone?.length && { phone: { number: visitor.phone[0].phoneNumber } }),
...(visitor.visitorEmails?.length && { email: visitor.visitorEmails[0].address }),
...(externalIds?.length && { externalIds }),
};
const livechatVisitor = await registerGuest(registerData, {
shouldConsiderIdleAgent: settings.get<boolean>('Livechat_enabled_when_agent_idle'),
shouldConsiderOfflineAgent: settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
});
return this.orch.getConverters()?.get('visitors').convertVisitor(livechatVisitor);
}
protected async transferVisitor(visitor: IVisitor, transferData: ILivechatTransferData, appId: string): Promise<boolean> {
this.orch.debugLog(`The App ${appId} is transfering a livechat.`);
if (!visitor) {
throw new Error('Invalid visitor, cannot transfer');
}
const { targetAgent, targetDepartment: departmentId, currentRoom } = transferData;
const appUser = await Users.findOneByAppId(appId, {});
if (!appUser) {
throw new Error('Invalid app user, cannot transfer');
}
const { _id, username, name, type } = appUser;
const transferredBy = {
_id,
username,
name,
type,
userType: 'user',
} as const;
let userId;
let transferredTo;
if (targetAgent?.id) {
transferredTo = await Users.findOneAgentById(targetAgent.id, {
projection: { _id: 1, username: 1, name: 1 },
});
if (!transferredTo) {
throw new Error('Invalid target agent, cannot transfer');
}
userId = transferredTo._id;
}
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
return transfer(
(await this.orch.getConverters()?.get('rooms').convertAppRoom(currentRoom)) as IOmnichannelRoom,
this.orch.getConverters()?.get('visitors').convertAppVisitor(visitor),
{ userId, departmentId, transferredBy, transferredTo },
);
}
protected async findVisitors(query: object, appId: string): Promise<Array<IVisitor>> {
this.orch.debugLog(`The App ${appId} is looking for livechat visitors.`);
if (this.orch.isDebugging()) {
console.warn('The method AppLivechatBridge.findVisitors is deprecated. Please consider using its alternatives');
}
return Promise.all(
(await LivechatVisitors.findEnabled(query).toArray()).map(
async (visitor) => visitor && this.orch.getConverters()?.get('visitors').convertVisitor(visitor),
),
);
}
protected async findVisitorById(id: string, appId: string): Promise<IVisitor | undefined> {
this.orch.debugLog(`The App ${appId} is looking for livechat visitors.`);
return this.orch.getConverters()?.get('visitors').convertById(id);
}
protected async findVisitorByEmail(email: string, appId: string): Promise<IVisitor | undefined> {
this.orch.debugLog(`The App ${appId} is looking for livechat visitors.`);
return this.orch
.getConverters()
?.get('visitors')
.convertVisitor(await LivechatVisitors.findOneGuestByEmailAddress(email));
}
protected async findVisitorByToken(token: string, appId: string): Promise<IVisitor | undefined> {
this.orch.debugLog(`The App ${appId} is looking for livechat visitors.`);
return this.orch
.getConverters()
?.get('visitors')
.convertVisitor(await LivechatVisitors.getVisitorByToken<ILivechatVisitor>(token, {}));
}
protected async findVisitorByPhoneNumber(phoneNumber: string, appId: string): Promise<IVisitor | undefined> {
this.orch.debugLog(`The App ${appId} is looking for livechat visitors.`);
return this.orch
.getConverters()
?.get('visitors')
.convertVisitor(await LivechatVisitors.findOneVisitorByPhone(phoneNumber));
}
protected async resolveVisitor(
externalId: Omit<IVisitorExternalIdentifier, 'appId'>,
contactData: ResolveVisitorContactData | undefined,
appId: string,
): Promise<IVisitor | undefined> {
this.orch.debugLog(`The App ${appId} is resolving a livechat visitor by external ID.`);
const visitor = await resolveVisitor({
appId,
externalId,
contactData,
});
return this.orch.getConverters()?.get('visitors').convertVisitor(visitor);
}
protected async updateVisitorExternalId(
visitorId: string,
externalId: Omit<IVisitorExternalIdentifier, 'appId'>,
appId: string,
): Promise<IVisitor | undefined> {
this.orch.debugLog(`The App ${appId} is updating externalId for visitor ${visitorId}.`);
const visitor = await LivechatVisitors.updateExternalIdById(visitorId, appId, externalId);
return this.orch.getConverters()?.get('visitors').convertVisitor(visitor);
}
protected async findDepartmentByIdOrName(value: string, appId: string): Promise<IDepartment | undefined> {
this.orch.debugLog(`The App ${appId} is looking for livechat departments.`);
return this.orch
.getConverters()
?.get('departments')
.convertDepartment(await LivechatDepartment.findOneByIdOrName<ILivechatDepartment>(value, {}));
}
protected async findDepartmentsEnabledWithAgents(appId: string): Promise<Array<IDepartment>> {
this.orch.debugLog(`The App ${appId} is looking for livechat departments.`);
const converter = this.orch.getConverters()?.get('departments');
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
const boundConverter = converter.convertDepartment.bind(converter) as (_: ILivechatDepartment) => Promise<IDepartment>;
return Promise.all((await LivechatDepartment.findEnabledWithAgents().toArray()).map(boundConverter));
}
protected async _fetchLivechatRoomMessages(appId: string, roomId: string): Promise<Array<IAppsEngineMessage>> {
this.orch.debugLog(`The App ${appId} is getting the transcript for livechat room ${roomId}.`);
const messageConverter = this.orch.getConverters()?.get('messages');
if (!messageConverter) {
throw new Error('Could not get the message converter to process livechat room messages');
}
const livechatMessages = await getRoomMessages({ rid: roomId });
return Promise.all(await livechatMessages.map((message) => messageConverter.convertMessage(message, livechatMessages)).toArray());
}
protected async setCustomFields(
data: { token: IVisitor['token']; key: string; value: string; overwrite: boolean },
appId: string,
): Promise<number> {
this.orch.debugLog(`The App ${appId} is setting livechat visitor's custom fields.`);
return setCustomFields(data);
}
}

View File

@@ -0,0 +1,139 @@
import type { IAppServerOrchestrator, IAppsMessage, IAppsUser } from '@rocket.chat/apps';
import type { ITypingDescriptor } from '@rocket.chat/apps/dist/server/bridges/MessageBridge';
import { MessageBridge } from '@rocket.chat/apps/dist/server/bridges/MessageBridge';
import type { Reaction } from '@rocket.chat/apps-engine/definition/messages';
import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms';
import { api } from '@rocket.chat/core-services';
import type { IMessage } from '@rocket.chat/core-typings';
import { Users, Subscriptions } from '@rocket.chat/models';
import { deleteMessage } from '../../../../server/lib/messages/deleteMessage';
import { updateMessage } from '../../../../server/lib/messages/updateMessage';
import { executeSetReaction } from '../../../../server/lib/messaging/reactions/setReaction';
import notifications from '../../../../server/lib/notifications/core/lib/Notifications';
import { executeSendMessage } from '../../../../server/meteor-methods/messages/sendMessage';
export class AppMessageBridge extends MessageBridge {
constructor(private readonly orch: IAppServerOrchestrator) {
super();
}
protected async create(message: IAppsMessage, appId: string): Promise<string> {
this.orch.debugLog(`The App ${appId} is creating a new message.`);
const convertedMessage: IMessage | undefined = await this.orch.getConverters()?.get('messages').convertAppMessage(message);
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
const definedMessage = convertedMessage as IMessage;
const sentMessage = await executeSendMessage(definedMessage.u._id, definedMessage);
return sentMessage._id;
}
protected async getById(messageId: string, appId: string): Promise<IAppsMessage> {
this.orch.debugLog(`The App ${appId} is getting the message: "${messageId}"`);
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
const message: IAppsMessage | undefined = await this.orch.getConverters()?.get('messages').convertById(messageId);
return message as IAppsMessage;
}
protected async update(message: IAppsMessage, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is updating a message.`);
if (!message.editor) {
throw new Error('Invalid editor assigned to the message for the update.');
}
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
const msg = await this.orch.getConverters()?.get('messages').convertAppMessage(message, true);
const editor = await Users.findOneById(message.editor.id);
if (!editor) {
throw new Error('Invalid editor assigned to the message for the update.');
}
await updateMessage(msg as IMessage, editor);
}
protected async delete(message: IAppsMessage, user: IAppsUser, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is deleting a message.`);
if (!message.id) {
throw new Error('Invalid message id');
}
const convertedMsg = await this.orch.getConverters()?.get('messages').convertAppMessage(message);
const convertedUser = (await Users.findOneById(user.id)) || this.orch.getConverters()?.get('users').convertToRocketChat(user);
await deleteMessage(convertedMsg as IMessage, convertedUser);
}
protected async notifyUser(user: IAppsUser, message: IAppsMessage, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is notifying a user.`);
const msg = await this.orch.getConverters()?.get('messages').convertAppMessage(message);
if (!msg) {
return;
}
void api.broadcast('notify.ephemeralMessage', user.id, msg.rid, {
...msg,
});
}
protected async notifyRoom(room: IRoom, message: IAppsMessage, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is notifying a room's users.`);
if (!room?.id) {
return;
}
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
const msg: IMessage | undefined = await this.orch.getConverters()?.get('messages').convertAppMessage(message);
const convertedMessage = msg as IMessage;
const users = (await Subscriptions.findByRoomIdWhenUserIdExists(room.id, { projection: { 'u._id': 1 } }).toArray()).map((s) => s.u._id);
await Users.findByIds(users, { projection: { _id: 1 } }).forEach(
({ _id }: { _id: string }) =>
void api.broadcast('notify.ephemeralMessage', _id, room.id, {
...convertedMessage,
}),
);
}
protected async typing({ scope, id, username, isTyping }: ITypingDescriptor): Promise<void> {
switch (scope) {
case 'room':
if (!username) {
throw new Error('Invalid username');
}
notifications.notifyRoom(id, 'user-activity', username, isTyping ? ['user-typing'] : []);
return;
default:
throw new Error('Unrecognized typing scope provided');
}
}
private isValidReaction(reaction: Reaction): boolean {
return reaction.startsWith(':') && reaction.endsWith(':');
}
protected async addReaction(messageId: string, userId: string, reaction: Reaction): Promise<void> {
if (!this.isValidReaction(reaction)) {
throw new Error('Invalid reaction');
}
return executeSetReaction(userId, reaction, messageId, true);
}
protected async removeReaction(messageId: string, userId: string, reaction: Reaction): Promise<void> {
if (!this.isValidReaction(reaction)) {
throw new Error('Invalid reaction');
}
return executeSetReaction(userId, reaction, messageId, false);
}
}

View File

@@ -0,0 +1,46 @@
import type { IAppServerOrchestrator } from '@rocket.chat/apps';
import { ModerationBridge } from '@rocket.chat/apps/dist/server/bridges/ModerationBridge';
import type { IMessage } from '@rocket.chat/apps-engine/definition/messages';
import type { IUser } from '@rocket.chat/apps-engine/definition/users';
import { ModerationReports } from '@rocket.chat/models';
import { reportMessage } from '../../../../server/lib/moderation/reportMessage';
export class AppModerationBridge extends ModerationBridge {
constructor(private readonly orch: IAppServerOrchestrator) {
super();
}
protected async report(messageId: IMessage['id'], description: string, userId: string, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is creating a new report.`);
if (!messageId) {
throw new Error('Invalid message id');
}
if (!description) {
throw new Error('Invalid description');
}
await reportMessage(messageId, description, userId || 'rocket.cat');
}
protected async dismissReportsByMessageId(messageId: IMessage['id'], reason: string, action: string, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is dismissing reports by message id.`);
if (!messageId) {
throw new Error('Invalid message id');
}
await ModerationReports.hideMessageReportsByMessageId(messageId, appId, reason, action);
}
protected async dismissReportsByUserId(userId: IUser['id'], reason: string, action: string, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is dismissing reports by user id.`);
if (!userId) {
throw new Error('Invalid user id');
}
await ModerationReports.hideMessageReportsByUserId(userId, appId, reason, action);
}
}

View File

@@ -0,0 +1,81 @@
import { randomUUID } from 'node:crypto';
import type { IAppServerOrchestrator } from '@rocket.chat/apps';
import { OAuthAppsBridge } from '@rocket.chat/apps/dist/server/bridges/OAuthAppsBridge';
import type { IOAuthApp, IOAuthAppParams } from '@rocket.chat/apps-engine/definition/accessors/IOAuthApp';
import type { IOAuthApps } from '@rocket.chat/core-typings';
import { OAuthApps, Users } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
export class AppOAuthAppsBridge extends OAuthAppsBridge {
constructor(private readonly orch: IAppServerOrchestrator) {
super();
}
protected async create(oAuthApp: IOAuthAppParams, appId: string): Promise<string | null> {
this.orch.debugLog(`The App ${appId} is creating a new OAuth app.`);
const { clientId, clientSecret } = oAuthApp;
const botUser = await Users.findOne({ appId });
if (!botUser) {
throw new Error(`The user for app ${appId} is not registered.`);
}
const { _id, username } = botUser;
return (
await OAuthApps.insertOne({
...oAuthApp,
_id: randomUUID(),
appId,
clientId: clientId ?? Random.id(),
clientSecret: clientSecret ?? Random.secret(),
_createdAt: new Date(),
_createdBy: {
_id,
username,
},
} as unknown as IOAuthApps)
).insertedId;
}
protected async getById(id: string, appId: string): Promise<IOAuthApp | null> {
this.orch.debugLog(`The App ${appId} is getting the OAuth app by ID ${id}.`);
const data = await OAuthApps.findOne({ _id: id, appId });
if (data) {
const { _id, _createdAt, _createdBy, _updatedAt, ...rest } = data as any;
return {
...rest,
id: _id,
createdAt: _createdAt.toDateString(),
createdBy: {
id: _createdBy._id,
username: _createdBy.username,
},
updatedAt: _updatedAt,
};
}
return null;
}
protected async getByName(name: string, appId: string): Promise<Array<IOAuthApp>> {
this.orch.debugLog(`The App ${appId} is getting the OAuth apps by name.`);
return OAuthApps.find({ name, appId }).toArray() as unknown as Array<IOAuthApp>;
}
protected async update(oAuthApp: IOAuthAppParams, id: string, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is updating the OAuth app ${id}.`);
await OAuthApps.updateOne({ _id: id, appId }, { $set: oAuthApp }, { upsert: true });
}
protected async delete(id: string, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is deleting the OAuth app ${id}.`);
await OAuthApps.deleteOne({ _id: id, appId });
}
protected async purge(appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is deleting an OAuth app.`);
await OAuthApps.deleteMany({ appId });
}
}

View File

@@ -0,0 +1,45 @@
import type { IAppServerOrchestrator } from '@rocket.chat/apps';
import { OutboundMessageBridge } from '@rocket.chat/apps/dist/server/bridges/OutboundMessagesBridge';
import type {
IOutboundEmailMessageProvider,
IOutboundMessageProviders,
IOutboundPhoneMessageProvider,
} from '@rocket.chat/apps-engine/definition/outboundCommunication';
import { getOutboundService } from '../../../../server/lib/omnichannel/outboundcommunication';
export class OutboundCommunicationBridge extends OutboundMessageBridge {
constructor(private readonly orch: IAppServerOrchestrator) {
super();
}
protected async registerPhoneProvider(provider: IOutboundPhoneMessageProvider, appId: string): Promise<void> {
try {
this.orch.debugLog(`App ${appId} is registering a phone outbound provider.`);
getOutboundService().outboundMessageProvider.registerPhoneProvider(provider);
} catch (err) {
this.orch.getRocketChatLogger().error({ appId, err, msg: 'Failed to register phone provider' });
throw new Error('error-registering-provider');
}
}
protected async registerEmailProvider(provider: IOutboundEmailMessageProvider, appId: string): Promise<void> {
try {
this.orch.debugLog(`App ${appId} is registering an email outbound provider.`);
getOutboundService().outboundMessageProvider.registerEmailProvider(provider);
} catch (err) {
this.orch.getRocketChatLogger().error({ appId, err, msg: 'Failed to register email provider' });
throw new Error('error-registering-provider');
}
}
protected async unRegisterProvider(provider: IOutboundMessageProviders, appId: string): Promise<void> {
try {
this.orch.debugLog(`App ${appId} is unregistering an outbound provider.`);
getOutboundService().outboundMessageProvider.unregisterProvider(appId, provider.type);
} catch (err) {
this.orch.getRocketChatLogger().error({ appId, err, msg: 'Failed to unregister provider' });
throw new Error('error-unregistering-provider');
}
}
}

View File

@@ -0,0 +1,138 @@
import type { IAppServerOrchestrator } from '@rocket.chat/apps';
import { PersistenceBridge } from '@rocket.chat/apps/dist/server/bridges/PersistenceBridge';
import type { RocketChatAssociationRecord } from '@rocket.chat/apps-engine/definition/metadata';
import type { InsertOneResult } from 'mongodb';
export class AppPersistenceBridge extends PersistenceBridge {
constructor(private readonly orch: IAppServerOrchestrator) {
super();
}
protected async purge(appId: string): Promise<void> {
this.orch.debugLog(`The App's persistent storage is being purged: ${appId}`);
await this.orch.getPersistenceModel().remove({ appId });
}
protected async create(data: object, appId: string): Promise<string> {
this.orch.debugLog(`The App ${appId} is storing a new object in their persistence.`);
if (typeof data !== 'object') {
throw new Error('Attempted to store an invalid data type, it must be an object.');
}
return this.orch
.getPersistenceModel()
.insertOne({ appId, data })
.then(({ insertedId }: InsertOneResult) => (insertedId as unknown as string) || '');
}
protected async createWithAssociations(data: object, associations: Array<RocketChatAssociationRecord>, appId: string): Promise<string> {
this.orch.debugLog({
msg: `The App ${appId} is storing a new object in their persistence that is associated with some models.`,
associations,
});
if (typeof data !== 'object') {
throw new Error('Attempted to store an invalid data type, it must be an object.');
}
return this.orch
.getPersistenceModel()
.insertOne({ appId, associations, data })
.then(({ insertedId }: InsertOneResult) => (insertedId as unknown as string) || '');
}
protected async readById(id: string, appId: string): Promise<object> {
this.orch.debugLog(`The App ${appId} is reading their data in their persistence with the id: "${id}"`);
const record = await this.orch.getPersistenceModel().findOneById(id);
return record?.data;
}
protected async readByAssociations(associations: Array<RocketChatAssociationRecord>, appId: string): Promise<Array<object>> {
this.orch.debugLog({ msg: `The App ${appId} is searching for records that are associated with the following:`, associations });
const records = await this.orch
.getPersistenceModel()
.find({
appId,
associations: { $all: associations },
})
.toArray();
return Array.isArray(records) ? records.map((r) => r.data) : [];
}
protected async remove(id: string, appId: string): Promise<object | undefined> {
this.orch.debugLog(`The App ${appId} is removing one of their records by the id: "${id}"`);
const record = await this.orch.getPersistenceModel().findOne({ _id: id, appId });
if (!record) {
return undefined;
}
await this.orch.getPersistenceModel().remove({ _id: id, appId });
return record.data;
}
protected async removeByAssociations(
associations: Array<RocketChatAssociationRecord>,
appId: string,
): Promise<Array<object> | undefined> {
this.orch.debugLog({ msg: `The App ${appId} is removing records with the following associations:`, associations });
const query = {
appId,
associations: {
$all: associations,
},
};
const records = await this.orch.getPersistenceModel().find(query).toArray();
if (!records?.length) {
return undefined;
}
await this.orch.getPersistenceModel().remove(query);
return Array.isArray(records) ? records.map((r) => r.data) : [];
}
protected async update(id: string, data: object, _upsert: boolean, appId: string): Promise<string> {
this.orch.debugLog(`The App ${appId} is updating the record "${id}"`);
if (typeof data !== 'object') {
throw new Error('Attempted to store an invalid data type, it must be an object.');
}
throw new Error('Not implemented.');
}
protected async updateByAssociations(
associations: Array<RocketChatAssociationRecord>,
data: object,
upsert = true,
appId: string,
): Promise<string> {
this.orch.debugLog({ msg: `The App ${appId} is updating the record with association to data as follows:`, associations });
if (typeof data !== 'object') {
throw new Error('Attempted to store an invalid data type, it must be an object.');
}
const query = {
appId,
associations,
};
return this.orch
.getPersistenceModel()
.update(query, { $set: { data } }, { upsert })
.then(({ upsertedId }: any) => upsertedId || '');
}
}

View File

@@ -0,0 +1,36 @@
import type { IAppServerOrchestrator, IAppsRole } from '@rocket.chat/apps';
import { RoleBridge } from '@rocket.chat/apps/dist/server/bridges/RoleBridge';
import type { IRole } from '@rocket.chat/core-typings';
import { Roles } from '@rocket.chat/models';
export class AppRoleBridge extends RoleBridge {
constructor(private readonly orch: IAppServerOrchestrator) {
super();
}
protected async getOneByIdOrName(idOrName: IAppsRole['id'] | IAppsRole['name'], appId: string): Promise<IAppsRole | null> {
this.orch.debugLog(`The App ${appId} is getting the roleByIdOrName: "${idOrName}"`);
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
const role: IRole | null = await Roles.findOneByIdOrName(idOrName);
return this.orch
.getConverters()
?.get('roles')
.convertRole(role as IRole);
}
protected async getCustomRoles(appId: string): Promise<Array<IAppsRole>> {
this.orch.debugLog(`The App ${appId} is getting the custom roles`);
const cursor = Roles.findCustomRoles();
const roles: IAppsRole[] = [];
for await (const role of cursor) {
const convRole = await this.orch.getConverters()?.get('roles').convertRole(role);
roles.push(convRole);
}
return roles;
}
}

View File

@@ -0,0 +1,385 @@
import type { IAppServerOrchestrator } from '@rocket.chat/apps';
import type { GetMessagesOptions, GetRoomsFilters, GetRoomsOptions } from '@rocket.chat/apps/dist/server/bridges/RoomBridge';
import { RoomBridge } from '@rocket.chat/apps/dist/server/bridges/RoomBridge';
import type { IMessage, IMessageRaw } from '@rocket.chat/apps-engine/definition/messages';
import type { IRoom, IRoomRaw } from '@rocket.chat/apps-engine/definition/rooms';
import { RoomType } from '@rocket.chat/apps-engine/definition/rooms';
import type { IUser } from '@rocket.chat/apps-engine/definition/users';
import type { ISubscription, IUser as ICoreUser, IRoom as ICoreRoom, IMessage as ICoreMessage } from '@rocket.chat/core-typings';
import { Subscriptions, Users, Rooms, Messages } from '@rocket.chat/models';
import type { FindOptions, Sort } from 'mongodb';
import { addUserToRoom } from '../../../../server/lib/rooms/addUserToRoom';
import { deleteRoom } from '../../../../server/lib/rooms/deleteRoom';
import { removeUserFromRoom } from '../../../../server/lib/rooms/removeUserFromRoom';
import { createDirectMessage } from '../../../../server/meteor-methods/messages/createDirectMessage';
import { createDiscussion } from '../../../../server/meteor-methods/messages/createDiscussion';
import { createChannelMethod } from '../../../../server/meteor-methods/rooms/createChannel';
import { createPrivateGroupMethod } from '../../../../server/meteor-methods/rooms/createPrivateGroup';
const rawRoomProjection: FindOptions<ICoreRoom>['projection'] = {
_id: 1,
fname: 1,
name: 1,
usernames: 1,
members: 1,
uids: 1,
default: 1,
ro: 1,
sysMes: 1,
msgs: 1,
ts: 1,
_updatedAt: 1,
closedAt: 1,
lm: 1,
description: 1,
customFields: 1,
prid: 1,
teamId: 1,
teamMain: 1,
federated: 1,
federation: 1,
livechatData: 1,
waitingResponse: 1,
open: 1,
source: 1,
closer: 1,
t: 1,
u: 1,
v: 1,
contactId: 1,
departmentId: 1,
closedBy: 1,
servedBy: 1,
responseBy: 1,
};
export class AppRoomBridge extends RoomBridge {
constructor(private readonly orch: IAppServerOrchestrator) {
super();
}
protected async create(room: IRoom, members: Array<string>, appId: string): Promise<string> {
this.orch.debugLog(`The App ${appId} is creating a new room.`, room);
const rcRoom = await this.orch.getConverters()?.get('rooms').convertAppRoom(room);
switch (room.type) {
case RoomType.CHANNEL:
return this.createChannel(room.creator.id, rcRoom, members);
case RoomType.PRIVATE_GROUP:
return this.createPrivateGroup(room.creator.id, rcRoom, members);
case RoomType.DIRECT_MESSAGE:
return this.createDirectMessage(room.creator.id, members);
default:
throw new Error('Only channels, private groups and direct messages can be created.');
}
}
private prepareExtraData(room: Record<string, any>): Record<string, unknown> {
const extraData = Object.assign({}, room);
delete extraData.name;
delete extraData.t;
delete extraData.ro;
delete extraData.customFields;
return extraData;
}
private async createChannel(userId: string, room: ICoreRoom, members: string[]): Promise<string> {
return (await createChannelMethod(userId, room.name || '', members, room.ro, room.customFields, this.prepareExtraData(room))).rid;
}
private async createDirectMessage(userId: string, members: string[]): Promise<string> {
return (await createDirectMessage(members, userId)).rid;
}
private async createPrivateGroup(userId: string, room: ICoreRoom, members: string[]): Promise<string> {
const user = await Users.findOneById(userId);
if (!user) {
throw new Error('Invalid user');
}
return (await createPrivateGroupMethod(user, room.name || '', members, room.ro, room.customFields, this.prepareExtraData(room))).rid;
}
protected async getById(roomId: string, appId: string): Promise<IRoom> {
this.orch.debugLog(`The App ${appId} is getting the roomById: "${roomId}"`);
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
const promise: Promise<IRoom | undefined> = this.orch.getConverters()?.get('rooms').convertById(roomId);
return promise as Promise<IRoom>;
}
protected async getByName(roomName: string, appId: string): Promise<IRoom> {
this.orch.debugLog(`The App ${appId} is getting the roomByName: "${roomName}"`);
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
const promise: Promise<IRoom | undefined> = this.orch.getConverters()?.get('rooms').convertByName(roomName);
return promise as Promise<IRoom>;
}
protected async getCreatorById(roomId: string, appId: string): Promise<IUser | undefined> {
this.orch.debugLog(`The App ${appId} is getting the room's creator by id: "${roomId}"`);
const room = await Rooms.findOneById(roomId);
if (!room?.u?._id) {
return undefined;
}
return this.orch.getConverters()?.get('users').convertById(room.u._id);
}
protected async getCreatorByName(roomName: string, appId: string): Promise<IUser | undefined> {
this.orch.debugLog(`The App ${appId} is getting the room's creator by name: "${roomName}"`);
const room = await Rooms.findOneByName(roomName, {});
if (!room?.u?._id) {
return undefined;
}
return this.orch.getConverters()?.get('users').convertById(room.u._id);
}
protected async getMessages(roomId: string, options: GetMessagesOptions, appId: string): Promise<IMessageRaw[]> {
this.orch.debugLog(`The App ${appId} is getting the messages of the room: "${roomId}" with options:`, options);
const { limit, skip = 0, sort: _sort, showThreadMessages } = options;
const messageConverter = this.orch.getConverters()?.get('messages');
if (!messageConverter) {
throw new Error('Message converter not found');
}
const threadFilterQuery = showThreadMessages ? {} : { tmid: { $exists: false } };
// We support only one field for now
const sort: Sort | undefined = _sort?.createdAt ? { ts: _sort.createdAt } : undefined;
const messageQueryOptions: FindOptions<ICoreMessage> = {
limit,
skip,
sort,
};
const query = {
rid: roomId,
_hidden: { $ne: true },
t: { $exists: false },
...threadFilterQuery,
};
const cursor = Messages.find(query, messageQueryOptions);
const messagePromises: Promise<IMessageRaw>[] = await cursor.map((message) => messageConverter.convertMessageRaw(message)).toArray();
return Promise.all(messagePromises);
}
protected async getMembers(roomId: string, appId: string): Promise<Array<IUser>> {
this.orch.debugLog(`The App ${appId} is getting the room's members by room id: "${roomId}"`);
const subscriptions = await Subscriptions.findByRoomId(roomId, {});
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
const promises: Promise<(IUser | undefined)[]> = Promise.all(
(await subscriptions.toArray()).map((sub: ISubscription) => this.orch.getConverters()?.get('users').convertById(sub.u?._id)),
);
return promises as Promise<IUser[]>;
}
protected async getAllRooms(filters: GetRoomsFilters = {}, options: GetRoomsOptions = {}, appId: string): Promise<Array<IRoomRaw>> {
this.orch.debugLog(`The App ${appId} is getting all rooms with options`, options);
const { limit = 100, skip = 0 } = options;
const findOptions: FindOptions<ICoreRoom> = {
sort: { ts: -1 },
skip,
limit: Math.min(limit, 100),
projection: rawRoomProjection,
};
const { types, discussions, teams } = filters;
const rooms: IRoomRaw[] = [];
const roomConverter = this.orch.getConverters()?.get('rooms');
if (!roomConverter) {
throw new Error('Room converter not found');
}
for await (const room of Rooms.findAllByTypesAndDiscussionAndTeam({ types, discussions, teams }, findOptions)) {
const converted = await roomConverter.convertRoomRaw(room);
if (converted) {
rooms.push(converted);
}
}
return rooms;
}
protected async getDirectByUsernames(usernames: Array<string>, appId: string): Promise<IRoom | undefined> {
this.orch.debugLog(`The App ${appId} is getting direct room by usernames: "${usernames}"`);
const room = await Rooms.findDirectRoomContainingAllUsernames(usernames, {});
if (!room) {
return undefined;
}
return this.orch.getConverters()?.get('rooms').convertRoom(room);
}
protected async update(room: IRoom, members: Array<string> = [], appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is updating a room.`);
const rm = await this.orch.getConverters()?.get('rooms').convertAppRoom(room, true);
const updateResult = await Rooms.updateOne({ _id: room.id }, { $set: rm });
if (!updateResult.matchedCount) {
throw new Error('Room id not found');
}
for (const username of members) {
const member = await Users.findOneByUsername(username, {});
if (!member) {
continue;
}
await addUserToRoom(room.id, member);
}
}
protected async delete(roomId: string, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is deleting a room.`);
await deleteRoom(roomId);
}
protected async createDiscussion(
room: IRoom,
parentMessage: IMessage | undefined = undefined,
reply: string | undefined = '',
members: Array<string> = [],
appId: string,
): Promise<string> {
this.orch.debugLog(`The App ${appId} is creating a new discussion.`, room);
const rcRoom = await this.orch.getConverters()?.get('rooms').convertAppRoom(room);
let rcMessage;
if (parentMessage) {
rcMessage = await this.orch.getConverters()?.get('messages').convertAppMessage(parentMessage);
}
if (!rcRoom.prid || !(await Rooms.findOneById(rcRoom.prid))) {
throw new Error('There must be a parent room to create a discussion.');
}
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
const discussion = {
prid: rcRoom.prid,
t_name: rcRoom.fname as string,
pmid: rcMessage ? rcMessage._id : undefined,
reply: reply && reply.trim() !== '' ? reply : undefined,
users: members.length > 0 ? members : [],
};
const { rid } = await createDiscussion(room.creator.id, discussion);
return rid;
}
protected getModerators(roomId: string, appId: string): Promise<IUser[]> {
this.orch.debugLog(`The App ${appId} is getting room moderators for room id: ${roomId}`);
return this.getUsersByRoomIdAndSubscriptionRole(roomId, 'moderator');
}
protected getOwners(roomId: string, appId: string): Promise<IUser[]> {
this.orch.debugLog(`The App ${appId} is getting room owners for room id: ${roomId}`);
return this.getUsersByRoomIdAndSubscriptionRole(roomId, 'owner');
}
protected getLeaders(roomId: string, appId: string): Promise<IUser[]> {
this.orch.debugLog(`The App ${appId} is getting room leaders for room id: ${roomId}`);
return this.getUsersByRoomIdAndSubscriptionRole(roomId, 'leader');
}
private async getUsersByRoomIdAndSubscriptionRole(roomId: string, role: string): Promise<IUser[]> {
const subs = await Subscriptions.findByRoomIdAndRoles<{ uid: string }>(roomId, [role], {
projection: { uid: '$u._id', _id: 0 },
}).toArray();
// Was this a bug?
const users = await Users.findByIds(subs.map((user: { uid: string }) => user.uid)).toArray();
const userConverter = this.orch.getConverters().get('users');
return users.map((user: ICoreUser) => userConverter.convertToApp(user));
}
protected async getUnreadByUser(roomId: string, uid: string, options: GetMessagesOptions, appId: string): Promise<Array<IMessageRaw>> {
this.orch.debugLog(`The App ${appId} is getting the unread messages for the user: "${uid}" in the room: "${roomId}"`);
const messageConverter = this.orch.getConverters()?.get('messages');
if (!messageConverter) {
throw new Error('Message converter not found');
}
const subscription = await Subscriptions.findOneByRoomIdAndUserId(roomId, uid, { projection: { ls: 1 } });
if (!subscription) {
const errorMessage = `No subscription found for user with ID "${uid}" in room with ID "${roomId}". This means the user is not subscribed to the room.`;
this.orch.debugLog(errorMessage);
throw new Error('User not subscribed to room');
}
const lastSeen = subscription?.ls;
if (!lastSeen) {
return [];
}
const sort: Sort = options.sort?.createdAt ? { ts: options.sort.createdAt } : { ts: 1 };
const cursor = Messages.findVisibleByRoomIdBetweenTimestampsNotContainingTypes(
roomId,
lastSeen,
new Date(),
[],
{
limit: options.limit,
skip: options.skip,
sort,
},
options.showThreadMessages,
);
const messages = await cursor.toArray();
return Promise.all(messages.map((msg) => messageConverter.convertMessageRaw(msg)));
}
protected async getUserUnreadMessageCount(roomId: string, uid: string, appId: string): Promise<number> {
this.orch.debugLog(`The App ${appId} is getting the unread messages count of the room: "${roomId}" for the user: "${uid}"`);
const subscription = await Subscriptions.findOneByRoomIdAndUserId(roomId, uid, { projection: { ls: 1 } });
if (!subscription) {
const errorMessage = `No subscription found for user with ID "${uid}" in room with ID "${roomId}". This means the user is not subscribed to the room.`;
this.orch.debugLog(errorMessage);
throw new Error('User not subscribed to room');
}
const lastSeen = subscription?.ls;
if (!lastSeen) {
return 0;
}
return Messages.countVisibleByRoomIdBetweenTimestampsNotContainingTypes(roomId, lastSeen, new Date(), []);
}
protected async removeUsers(roomId: string, usernames: Array<string>, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is removing users ${usernames} from room id: ${roomId}`);
if (!roomId) {
throw new Error('roomId was not provided.');
}
const members = await Users.findUsersByUsernames(usernames, { limit: 50 }).toArray();
await Promise.all(members.map((user) => removeUserFromRoom(roomId, user)));
}
}

View File

@@ -0,0 +1,95 @@
import request from 'supertest';
import { apiServer } from './router';
apiServer.post('/api/apps/private/:appId/:hash', (req, res) => {
res.json({
body: req.body,
params: req.params,
query: req.query,
});
});
apiServer.post('/api/apps/public/:appId', (req, res) => {
res.json({
body: req.body,
params: req.params,
query: req.query,
});
});
describe('API Server Routes', () => {
it('should handle POST requests to /api/apps/private/:appId/:hash using json encoding', async () => {
const appId = 'testAppId';
const hash = 'testHash';
await request(apiServer)
.post(`/api/apps/private/${appId}/${hash}`)
.set('Content-Type', 'application/json')
.send({
key: 'value',
})
.expect('Content-Type', /json/)
.expect(200)
.then((res) => {
expect(res.body).toEqual({
body: { key: 'value' },
params: { appId, hash },
query: {},
});
});
});
it('should handle POST requests to /api/apps/private/:appId/:hash using x-www-form-urlencoded encoding', async () => {
const appId = 'testAppId';
const hash = 'testHash';
await request(apiServer)
.post(`/api/apps/private/${appId}/${hash}`)
.set('Content-Type', 'application/x-www-form-urlencoded')
.send('key=value')
.expect('Content-Type', /json/)
.expect(200)
.then((res) => {
expect(res.body).toEqual({
body: { key: 'value' },
params: { appId, hash },
query: {},
});
});
});
it('should handle POST requests to /api/apps/public/:appId using json encoding', async () => {
const appId = 'testAppId';
await request(apiServer)
.post(`/api/apps/public/${appId}`)
.set('Content-Type', 'application/json')
.send({
key: 'value',
})
.expect('Content-Type', /json/)
.expect(200)
.then((res) => {
expect(res.body).toEqual({
body: { key: 'value' },
params: { appId },
query: {},
});
});
});
it('should handle POST requests to /api/apps/public/:appId using x-www-form-urlencoded encoding', async () => {
const appId = 'testAppId';
await request(apiServer)
.post(`/api/apps/public/${appId}`)
.set('Content-Type', 'application/x-www-form-urlencoded')
.send('key=value')
.expect('Content-Type', /json/)
.expect(200)
.then((res) => {
expect(res.body).toEqual({
body: { key: 'value' },
params: { appId },
query: {},
});
});
});
});

View File

@@ -0,0 +1,9 @@
import bodyParser from 'body-parser';
import express from 'express';
export const apiServer = express();
apiServer.disable('x-powered-by');
apiServer.use('/api/apps/private/:appId/:hash', bodyParser.urlencoded({ extended: true }), bodyParser.json());
apiServer.use('/api/apps/public/:appId', bodyParser.urlencoded({ extended: true }), bodyParser.json());

View File

@@ -0,0 +1,201 @@
import type { Job } from '@rocket.chat/agenda';
import { Agenda } from '@rocket.chat/agenda';
import type { IAppServerOrchestrator } from '@rocket.chat/apps';
import { SchedulerBridge } from '@rocket.chat/apps/dist/server/bridges/SchedulerBridge';
import type { IProcessor, IOnetimeSchedule, IRecurringSchedule, IJobContext } from '@rocket.chat/apps-engine/definition/scheduler';
import { StartupType } from '@rocket.chat/apps-engine/definition/scheduler';
import { ObjectId } from 'bson';
import { MongoInternals } from 'meteor/mongo';
function _callProcessor(processor: IProcessor['processor']): (job: Job) => Promise<void> {
return (job) => {
const data = job?.attrs?.data || {};
// This field is for internal use, no need to leak to app processor
delete (data as any).appId;
data.jobId = job.attrs._id.toString();
return (processor as (jobContext: IJobContext) => Promise<void>)(data).then(async () => {
// ensure the 'normal' ('onetime' in our vocab) type job is removed after it is run
// as Agenda does not remove it from the DB
if (job.attrs.type === 'normal') {
await job.agenda.cancel({ _id: job.attrs._id });
}
});
};
}
/**
* Provides the Apps Engine with task scheduling capabilities.
* It uses {@link agenda:github.com/agenda/agenda} as backend
*/
export class AppSchedulerBridge extends SchedulerBridge {
private isConnected: boolean;
private scheduler: Agenda;
constructor(private readonly orch: IAppServerOrchestrator) {
super();
this.scheduler = new Agenda({
mongo: (MongoInternals.defaultRemoteCollectionDriver().mongo as any).client.db(),
db: { collection: 'rocketchat_apps_scheduler' },
// this ensures the same job doesn't get executed multiple times in a cluster
defaultConcurrency: 1,
});
this.isConnected = false;
}
/**
* Register processors that can be scheduled to run
*
* @param processors An array of processors
* @param appId
*
* @returns List of task ids run at startup, or void no startup run is set
*/
protected async registerProcessors(processors: Array<IProcessor> = [], appId: string): Promise<void | Array<string>> {
const runAfterRegister: Promise<string>[] = [];
this.orch.debugLog(`The App ${appId} is registering job processors`, processors);
processors.forEach(({ id, processor, startupSetting }: IProcessor) => {
this.scheduler.define(id, _callProcessor(processor));
if (!startupSetting) {
return;
}
switch (startupSetting.type) {
case StartupType.ONETIME:
runAfterRegister.push(
this.scheduleOnceAfterRegister({ id, when: startupSetting.when, data: startupSetting.data }, appId) as Promise<string>,
);
break;
case StartupType.RECURRING:
runAfterRegister.push(
this.scheduleRecurring(
{
id,
interval: startupSetting.interval,
skipImmediate: startupSetting.skipImmediate,
data: startupSetting.data,
},
appId,
) as Promise<string>,
);
break;
default:
this.orch.getRocketChatLogger().error({ msg: 'Unknown startup setting type', type: (startupSetting as any).type });
break;
}
});
if (runAfterRegister.length) {
return Promise.all(runAfterRegister);
}
}
/**
* Schedules a registered processor to run _once_.
*/
protected async scheduleOnce({ id, when, data }: IOnetimeSchedule, appId: string): Promise<void | string> {
this.orch.debugLog(`The App ${appId} is scheduling an onetime job (processor ${id})`);
try {
await this.startScheduler();
const job = await this.scheduler.schedule(when, id, this.decorateJobData(data, appId));
return job.attrs._id.toString();
} catch (err) {
this.orch.getRocketChatLogger().error({ err });
}
}
private async scheduleOnceAfterRegister(job: IOnetimeSchedule, appId: string): Promise<void | string> {
const scheduledJobs = await this.scheduler.jobs({ name: job.id, type: 'normal' }, {}, 1);
if (!scheduledJobs.length) {
return this.scheduleOnce(job, appId);
}
}
/**
* Schedules a registered processor to run recurrently according to a given interval.
*
* @param {Object} job
* @param {string} job.id The processor's id
* @param {string} job.interval When the processor will be re executed
* @param {boolean} job.skipImmediate=false Whether to let the first iteration to execute as soon as the task is registered
* @param {Object} [job.data] An optional object that is passed to the processor
* @param {string} appId
*
* @returns {string} taskid
*/
protected async scheduleRecurring(
{ id, interval, skipImmediate = false, data }: IRecurringSchedule,
appId: string,
): Promise<void | string> {
this.orch.debugLog(`The App ${appId} is scheduling a recurring job (processor ${id})`);
try {
await this.startScheduler();
const job = await this.scheduler.every(interval, id, this.decorateJobData(data, appId), {
skipImmediate,
});
return job.attrs._id.toString();
} catch (err) {
this.orch.getRocketChatLogger().error({ err });
}
}
/**
* Cancels a running job given its jobId
*
* @param {string} jobId
* @param {string} appId
*
* @returns Promise<void>
*/
protected async cancelJob(jobId: string, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is canceling a job`, jobId);
await this.startScheduler();
let cancelQuery;
try {
cancelQuery = { _id: new ObjectId(jobId.split('_')[0]) };
} catch (jobDocIdError) {
// it is not a valid objectid, so it won't try to cancel by document id
cancelQuery = { name: jobId };
}
try {
await this.scheduler.cancel(cancelQuery);
} catch (err) {
this.orch.getRocketChatLogger().error({ err });
}
}
/**
* Cancels all the running jobs from the app
*
* @param {string} appId
*
* @returns Promise<void>
*/
protected async cancelAllJobs(appId: string): Promise<void> {
this.orch.debugLog(`Canceling all jobs of App ${appId}`);
await this.startScheduler();
const matcher = new RegExp(`_${appId}$`);
try {
await this.scheduler.cancel({ name: { $regex: matcher } });
} catch (err) {
this.orch.getRocketChatLogger().error({ err });
}
}
public async startScheduler(): Promise<void> {
if (!this.isConnected) {
await this.scheduler.start();
this.isConnected = true;
}
}
private decorateJobData(jobData: object | undefined, appId: string): object {
return Object.assign({}, jobData, { appId });
}
}

View File

@@ -0,0 +1,127 @@
import { Apps, type IAppServerOrchestrator } from '@rocket.chat/apps';
import { ServerSettingBridge } from '@rocket.chat/apps/dist/server/bridges/ServerSettingBridge';
import type { IReadSettingPermission } from '@rocket.chat/apps-engine/definition/permissions/IPermission';
import type { ISetting } from '@rocket.chat/apps-engine/definition/settings';
import { Settings } from '@rocket.chat/models';
import { notifyOnSettingChanged, notifyOnSettingChangedById } from '../../../../server/lib/notifyListener';
import { updateAuditedByApp } from '../../../../server/settings/lib/auditedSettingUpdates';
export class AppSettingBridge extends ServerSettingBridge {
constructor(private readonly orch: IAppServerOrchestrator) {
super();
}
protected async getAll(appId: string): Promise<Array<ISetting>> {
this.orch.debugLog(`The App ${appId} is getting all the settings.`);
const settings = await Settings.find({ secret: false }).toArray();
return settings.map((s) => this.orch.getConverters()?.get('settings').convertToApp(s));
}
protected async getOneById(id: string, appId: string): Promise<ISetting> {
this.orch.debugLog(`The App ${appId} is getting the setting by id ${id}.`);
const setting = await this.getReadableSettingById(id, appId);
if (!setting) {
throw new Error(`The setting "${id}" is not readable.`);
}
return setting;
}
protected async hideGroup(name: string, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is hidding the group ${name}.`);
throw new Error('Method not implemented.');
}
protected async hideSetting(id: string, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is hidding the setting ${id}.`);
if (!(await this.isReadableById(id, appId))) {
throw new Error(`The setting "${id}" is not readable.`);
}
throw new Error('Method not implemented.');
}
protected async isReadableById(id: string, appId: string): Promise<boolean> {
this.orch.debugLog(`The App ${appId} is checking if they can read the setting ${id}.`);
const setting = await Settings.findOneById(id);
return Boolean(setting && !setting.secret);
}
protected async getReadableSettingById(id: string, appId: string): Promise<ISetting | null> {
this.orch.debugLog(`The app ${appId} is checking if it can read the setting ${id}`);
const app = Apps.self?.getManager().getOneById(appId);
if (!app) {
this.orch.debugLog(`The app ${appId} is not found.`);
return null;
}
const { permissions } = app.getInfo();
// If the app does not have any permissions we must assume it has a set of default permissions
// so, for being cautious, we will not allow it to read all settings.
// If one desires to read a hidden setting it must ask explicitly for it.
if (!permissions) {
const setting = await Settings.findOneNotHiddenById(id);
if (!setting) {
this.orch.debugLog(`The setting ${id} is not found.`);
return null;
}
return this.orch.getConverters()?.get('settings').convertToApp(setting);
}
const readSettingsPermission = permissions.find((perm) => perm.name === 'server-setting.read');
if (!readSettingsPermission) {
this.orch.debugLog(`The app ${appId} has no server-setting.read permission.`);
return null;
}
const readSettings = readSettingsPermission as IReadSettingPermission;
// If the setting is in the hiddenSettings list (defined within the permission), then it can bypass the hidden flag.
// If not, then it must be a non-hidden setting. This is to allow apps to read hidden settings if they have the permission to do so.
const setting = readSettings.hiddenSettings?.includes(id) ? await Settings.findOneById(id) : await Settings.findOneNotHiddenById(id);
if (!setting) {
this.orch.debugLog(`The setting ${id} is not found.`);
return null;
}
return this.orch.getConverters()?.get('settings').convertToApp(setting);
}
protected async updateOne(setting: ISetting & { id: string }, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is updating the setting ${setting.id} .`);
if (!(await this.isReadableById(setting.id, appId))) {
throw new Error(`The setting "${setting.id}" is not readable.`);
}
if (
(
await updateAuditedByApp({
_id: appId,
})(Settings.updateValueById, setting.id, setting.value)
).modifiedCount
) {
void notifyOnSettingChangedById(setting.id);
}
}
protected async incrementValue(id: string, value: number, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is incrementing the value of the setting ${id}.`);
if (!(await this.isReadableById(id, appId))) {
throw new Error(`The setting "${id}" is not readable.`);
}
const setting = await Settings.incrementValueById(id, value, { returnDocument: 'after' });
if (setting) {
void notifyOnSettingChanged(setting);
}
}
}

View File

@@ -0,0 +1,15 @@
import type { IAppServerOrchestrator } from '@rocket.chat/apps';
import { ThreadBridge } from '@rocket.chat/apps/dist/server/bridges/ThreadBridge';
import type { IMessage } from '@rocket.chat/apps-engine/definition/messages';
export class AppThreadBridge extends ThreadBridge {
constructor(private readonly orch: IAppServerOrchestrator) {
super();
}
protected async getById(threadID: string, appId: string): Promise<IMessage[]> {
this.orch.debugLog(`The App ${appId} is getting the thread: "${threadID}"`);
return this.orch.getConverters()?.get('threads').convertById(threadID);
}
}

View File

@@ -0,0 +1,24 @@
import type { IAppServerOrchestrator } from '@rocket.chat/apps';
import { UiInteractionBridge as AppsEngineUiInteractionBridge } from '@rocket.chat/apps/dist/server/bridges/UiInteractionBridge';
import type { IUIKitInteraction } from '@rocket.chat/apps-engine/definition/uikit';
import type { IUser } from '@rocket.chat/apps-engine/definition/users';
import { api } from '@rocket.chat/core-services';
import type * as UiKit from '@rocket.chat/ui-kit';
export class UiInteractionBridge extends AppsEngineUiInteractionBridge {
constructor(private readonly orch: IAppServerOrchestrator) {
super();
}
protected async notifyUser(user: IUser, interaction: IUIKitInteraction, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is sending an interaction to user.`);
const app = this.orch.getManager()?.getOneById(appId);
if (!app) {
throw new Error('Invalid app provided');
}
void api.broadcast('notify.uiInteraction', user.id, interaction as UiKit.ServerInteraction);
}
}

View File

@@ -0,0 +1,65 @@
import type { IAppServerOrchestrator } from '@rocket.chat/apps';
import { UploadBridge } from '@rocket.chat/apps/dist/server/bridges/UploadBridge';
import type { IUpload } from '@rocket.chat/apps-engine/definition/uploads';
import type { IUploadDetails } from '@rocket.chat/apps-engine/definition/uploads/IUploadDetails';
import { determineFileType } from '../../../../ee/lib/misc/determineFileType';
import { FileUpload } from '../../../../server/lib/media/file-upload';
import { sendFileMessage } from '../../../../server/meteor-methods/messages/sendFileMessage';
import { sendFileLivechatMessage } from '../../../../server/meteor-methods/omnichannel/sendFileLivechatMessage';
const getUploadDetails = (details: IUploadDetails): Partial<IUploadDetails> => {
if (details.visitorToken) {
const { userId, ...result } = details;
return result;
}
return details;
};
export class AppUploadBridge extends UploadBridge {
constructor(private readonly orch: IAppServerOrchestrator) {
super();
}
protected async getById(id: string, appId: string): Promise<IUpload> {
this.orch.debugLog(`The App ${appId} is getting the upload: "${id}"`);
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
const promise: Promise<IUpload | undefined> = this.orch.getConverters()?.get('uploads').convertById(id);
return promise as Promise<IUpload>;
}
protected async getBuffer(upload: IUpload, appId: string): Promise<Buffer> {
this.orch.debugLog(`The App ${appId} is getting the upload: "${upload.id}"`);
const rocketChatUpload = this.orch.getConverters()?.get('uploads').convertToRocketChat(upload);
const result = await FileUpload.getBuffer(rocketChatUpload);
if (!(result instanceof Buffer)) {
throw new Error('Unknown error');
}
return result;
}
protected async createUpload(details: IUploadDetails, buffer: Buffer, appId: string): Promise<IUpload> {
this.orch.debugLog(`The App ${appId} is creating an upload "${details.name}"`);
if (!details.userId && !details.visitorToken) {
throw new Error('Missing user to perform the upload operation');
}
const fileStore = FileUpload.getStore('Uploads');
details.type = determineFileType(buffer, details.name);
const uploadedFile = await fileStore.insert(getUploadDetails(details), buffer);
this.orch.debugLog(`The App ${appId} has created an upload`, uploadedFile);
if (details.visitorToken) {
await sendFileLivechatMessage({ roomId: details.rid, visitorToken: details.visitorToken, file: uploadedFile });
} else {
await sendFileMessage(details.userId, { roomId: details.rid, file: uploadedFile });
}
return this.orch.getConverters()?.get('uploads').convertToApp(uploadedFile);
}
}

View File

@@ -0,0 +1,219 @@
import type { IAppServerOrchestrator } from '@rocket.chat/apps';
import { UserBridge } from '@rocket.chat/apps/dist/server/bridges/UserBridge';
import type { IUserCreationOptions, IUser, UserType } from '@rocket.chat/apps-engine/definition/users';
import { Presence } from '@rocket.chat/core-services';
import type { PresenceSource, UserStatus } from '@rocket.chat/core-typings';
import { Subscriptions, Users } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import { notifyOnUserChange, notifyOnUserChangeById } from '../../../../server/lib/notifyListener';
import { checkUsernameAvailability } from '../../../../server/lib/users/checkUsernameAvailability';
import { deleteUser } from '../../../../server/lib/users/deleteUser';
import { getUserCreatedByApp } from '../../../../server/lib/users/getUserCreatedByApp';
import { setStatusText } from '../../../../server/lib/users/setStatusText';
import { setUserActiveStatus } from '../../../../server/lib/users/setUserActiveStatus';
import { setUserAvatar } from '../../../../server/lib/users/setUserAvatar';
export class AppUserBridge extends UserBridge {
constructor(private readonly orch: IAppServerOrchestrator) {
super();
}
protected async getById(userId: string, appId: string): Promise<IUser> {
this.orch.debugLog(`The App ${appId} is getting the userId: "${userId}"`);
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
const promise: Promise<IUser | undefined> = this.orch.getConverters()?.get('users').convertById(userId);
return promise as Promise<IUser>;
}
protected async getByUsername(username: string, appId: string): Promise<IUser> {
this.orch.debugLog(`The App ${appId} is getting the username: "${username}"`);
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
const promise: Promise<IUser | undefined> = this.orch.getConverters()?.get('users').convertByUsername(username);
return promise as Promise<IUser>;
}
protected async getAppUser(appId?: string): Promise<IUser | undefined> {
this.orch.debugLog(`The App ${appId} is getting its assigned user`);
if (!appId) {
return;
}
const user = await Users.findOneByAppId(appId);
return this.orch.getConverters()?.get('users').convertToApp(user);
}
protected async getBySipExtension(extension: string, _appId: string): Promise<IUser | undefined> {
const user = await Users.findOneByFreeSwitchExtension(extension);
return this.orch.getConverters()?.get('users').convertToApp(user);
}
/**
* Deletes all bot or app users created by the App.
* @param appId the App's ID.
* @param type the type of the user to be deleted.
* @returns true if any user was deleted, false otherwise.
*/
protected async deleteUsersCreatedByApp(appId: string, type: UserType.APP | UserType.BOT): Promise<boolean> {
this.orch.debugLog(`The App ${appId} is deleting all bot users`);
const appUsers = await getUserCreatedByApp(appId, type);
if (appUsers.length) {
this.orch.debugLog(`The App ${appId} is deleting ${appUsers.length} users`);
await Promise.all(appUsers.map((appUser) => deleteUser(appUser._id)));
return true;
}
return false;
}
protected async create(userDescriptor: Partial<IUser>, appId: string, options?: IUserCreationOptions): Promise<string> {
this.orch.debugLog(`The App ${appId} is requesting to create a new user.`);
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
const user = this.orch
.getConverters()
?.get('users')
.convertToRocketChat(userDescriptor as IUser);
if (!user._id) {
user._id = Random.id();
}
if (!user.createdAt) {
user.createdAt = new Date();
}
switch (user.type) {
case 'bot':
case 'app':
if (!(await checkUsernameAvailability(user.username as string))) {
throw new Error(`The username "${user.username}" is already being used. Rename or remove the user using it to install this App`);
}
await Users.insertOne(user);
if (options?.avatarUrl) {
await setUserAvatar(user, options.avatarUrl, '', 'local');
}
break;
default:
throw new Error('Creating normal users is currently not supported');
}
void notifyOnUserChangeById({ clientAction: 'inserted', id: user._id });
return user._id;
}
protected async remove(user: IUser & { id: string }, appId: string): Promise<boolean> {
this.orch.debugLog(`The App's user is being removed: ${appId}`);
// It's actually not a problem if there is no App user to delete - just means we don't need to do anything more.
if (!user) {
return true;
}
try {
await deleteUser(user.id);
} catch (err) {
throw new Error(`Errors occurred while deleting an app user: ${err}`);
}
return true;
}
protected async update(user: IUser & { id: string }, fields: Partial<IUser>, appId: string): Promise<boolean> {
this.orch.debugLog(`The App ${appId} is updating a user`);
if (!user) {
throw new Error('User not provided');
}
const { status, statusText, ...updateFields } = fields;
if (status) {
await Presence.setStatus(user.id, status as UserStatus, statusText);
} else if (typeof statusText === 'string') {
await setStatusText(
{
_id: user.id,
username: user.username,
name: user.name,
status: user.status as UserStatus,
roles: user.roles,
statusText: user.statusText,
},
statusText,
);
}
if (!Object.keys(updateFields).length) {
return true;
}
await Users.updateOne({ _id: user.id }, { $set: updateFields as any });
void notifyOnUserChange({ clientAction: 'updated', id: user.id, diff: updateFields });
return true;
}
protected async deactivate(userId: IUser['id'], confirmRelinquish: boolean, appId: string): Promise<boolean> {
this.orch.debugLog(`The App ${appId} is deactivating a user.`);
if (!userId) {
throw new Error('Invalid user id');
}
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
const convertedUser: IUser | undefined = await this.orch.getConverters()?.get('users').convertById(userId);
const { id: uid } = convertedUser as IUser;
await setUserActiveStatus(uid, false, confirmRelinquish);
return true;
}
protected async setActiveState(
userId: IUser['id'],
state: Pick<IUser, 'statusDefault' | 'statusSource' | 'statusText' | 'statusExpiresAt' | 'statusId'>,
appId: string,
): Promise<void> {
this.orch.debugLog(`The App ${appId} is setting active state for user ${userId}`);
await Presence.setActiveState(userId, {
statusDefault: state.statusDefault as UserStatus,
statusText: state.statusText,
statusSource: state.statusSource as PresenceSource,
...(state.statusExpiresAt && { statusExpiresAt: state.statusExpiresAt }),
...(state.statusId && { statusId: state.statusId }),
});
}
protected async endActiveState(userId: IUser['id'], appId: string, statusId?: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is ending active state for user ${userId}`);
await Presence.endActiveState(userId, statusId);
}
protected async getActiveUserCount(): Promise<number> {
return Users.getActiveLocalUserCount();
}
protected async getUserUnreadMessageCount(uid: string): Promise<number> {
return Subscriptions.getBadgeCount(uid);
}
protected async getUserRoomIds(userId: string, appId: string): Promise<string[]> {
this.orch.debugLog(`The App ${appId} is getting the room ids for the user: "${userId}"`);
const subscriptions = await Subscriptions.findByUserId(userId, { projection: { rid: 1 } }).toArray();
return subscriptions.map((subscription) => subscription.rid);
}
}

View File

@@ -0,0 +1,76 @@
import type { IAppServerOrchestrator } from '@rocket.chat/apps';
import { VideoConferenceBridge } from '@rocket.chat/apps/dist/server/bridges/VideoConferenceBridge';
import type { IVideoConfProvider } from '@rocket.chat/apps-engine/definition/videoConfProviders';
import type { AppVideoConference, VideoConference } from '@rocket.chat/apps-engine/definition/videoConferences';
import { VideoConf } from '@rocket.chat/core-services';
import { videoConfProviders } from '../../../../server/lib/videoConfProviders';
import type { AppVideoConferencesConverter } from '../converters/videoConferences';
export class AppVideoConferenceBridge extends VideoConferenceBridge {
constructor(private readonly orch: IAppServerOrchestrator) {
super();
}
protected async getById(callId: string, appId: string): Promise<VideoConference> {
this.orch.debugLog(`The App ${appId} is getting the video conference byId: "${callId}"`);
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
const promise: Promise<VideoConference | undefined> = this.orch.getConverters()?.get('videoConferences').convertById(callId);
return promise as Promise<VideoConference>;
}
protected async create(call: AppVideoConference, appId: string): Promise<string> {
this.orch.debugLog(`The App ${appId} is creating a video conference.`);
return (
await VideoConf.create({
type: 'videoconference',
...call,
})
).callId;
}
protected async update(call: VideoConference, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is updating a video conference.`);
const oldData = call._id && (await VideoConf.getUnfiltered(call._id));
if (!oldData) {
throw new Error('A video conference must exist to update.');
}
const data = (this.orch.getConverters()?.get('videoConferences') as AppVideoConferencesConverter).convertAppVideoConference(call);
await VideoConf.setProviderData(call._id, data.providerData);
for (const { _id, ts } of data.users) {
if (oldData.users.find((user) => user._id === _id)) {
continue;
}
await VideoConf.addUser(call._id, _id, ts);
}
if (data.endedBy && data.endedBy._id !== oldData.endedBy?._id) {
await VideoConf.setEndedBy(call._id, data.endedBy._id);
} else if (data.endedAt) {
await VideoConf.setEndedAt(call._id, data.endedAt);
}
if (data.status > oldData.status) {
await VideoConf.setStatus(call._id, data.status);
}
if (data.discussionRid !== oldData.discussionRid) {
await VideoConf.assignDiscussionToConference(call._id, data.discussionRid);
}
}
protected async registerProvider(info: IVideoConfProvider, appId: string): Promise<void> {
this.orch.debugLog(`The App ${appId} is registering a video conference provider.`);
videoConfProviders.registerProvider(info.name, info.capabilities || {}, appId);
}
protected async unRegisterProvider(info: IVideoConfProvider): Promise<void> {
videoConfProviders.unRegisterProvider(info.name);
}
}

View File

@@ -0,0 +1,17 @@
export const cachedFunction = <F extends (...args: any[]) => any>(fn: F) => {
const cache = new Map<string, unknown>();
return ((...args) => {
const cacheKey = JSON.stringify(args);
if (cache.has(cacheKey)) {
return cache.get(cacheKey) as ReturnType<F>;
}
const result = fn(...args);
cache.set(cacheKey, result);
return result;
}) as F;
};

View File

@@ -0,0 +1,103 @@
import type { IAppsLivechatContact } from '@rocket.chat/apps';
import type { ILivechatContact } from '@rocket.chat/core-typings';
import * as z from 'zod';
import { mappedDecodeAsync } from './mappedData';
/**
* Rocket.Chat `ILivechatContact` <-> Apps-Engine `IAppsLivechatContact`.
*
* `decode` (Rocket.Chat -> Apps-Engine) is a straight clone, mirroring the legacy `convertContact`.
* `encode` (Apps-Engine -> Rocket.Chat) maps every attribute individually via a nested field map so
* no extra data coming from the app leaks through — mirroring the legacy `convertAppContact`.
*/
export const ContactCodec = z.codec(z.custom<ILivechatContact>(), z.custom<IAppsLivechatContact>(), {
decode: (contact): IAppsLivechatContact => structuredClone(contact) as unknown as IAppsLivechatContact,
encode: (contact): Promise<ILivechatContact> => {
const map = {
_id: '_id',
_updatedAt: '_updatedAt',
name: 'name',
phones: {
from: 'phones',
list: true,
map: {
phoneNumber: 'phoneNumber',
},
},
emails: {
from: 'emails',
list: true,
map: {
address: 'address',
},
},
contactManager: 'contactManager',
unknown: 'unknown',
conflictingFields: {
from: 'conflictingFields',
list: true,
map: {
field: 'field',
value: 'value',
},
},
customFields: 'customFields',
channels: {
from: 'channels',
list: true,
map: {
name: 'name',
verified: 'verified',
visitor: {
from: 'visitor',
map: {
visitorId: 'visitorId',
source: {
from: 'source',
map: {
type: 'type',
id: 'id',
},
},
},
},
blocked: 'blocked',
field: 'field',
value: 'value',
verifiedAt: 'verifiedAt',
details: {
from: 'details',
map: {
type: 'type',
id: 'id',
alias: 'alias',
label: 'label',
sidebarIcon: 'sidebarIcon',
defaultIcon: 'defaultIcon',
destination: 'destination',
},
},
lastChat: {
from: 'lastChat',
map: {
_id: '_id',
ts: 'ts',
},
},
},
},
createdAt: 'createdAt',
lastChat: {
from: 'lastChat',
map: {
_id: '_id',
ts: 'ts',
},
},
importIds: 'importIds',
};
return mappedDecodeAsync<ILivechatContact>(contact, map, { dropUnmapped: true });
},
});

View File

@@ -0,0 +1,56 @@
import type { IAppsDepartment } from '@rocket.chat/apps';
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import * as z from 'zod';
import { mappedDecode } from './mappedData';
/**
* Rocket.Chat `ILivechatDepartment` <-> Apps-Engine `IAppsDepartment`.
*
* `decode` is a plain field-rename with an `_unmappedProperties_` bucket (mirrors the legacy
* `convertDepartment`). `encode` mirrors `convertAppDepartment`: the inverse rename written
* unconditionally, then `_unmappedProperties_` merged on top.
*/
export const DepartmentCodec = z.codec(z.custom<ILivechatDepartment>(), z.custom<IAppsDepartment>(), {
decode: mappedDecode<ILivechatDepartment>({
id: '_id',
name: 'name',
email: 'email',
updatedAt: '_updatedAt',
enabled: 'enabled',
numberOfAgents: 'numAgents',
showOnOfflineForm: 'showOnOfflineForm',
description: 'description',
offlineMessageChannelName: 'offlineMessageChannelName',
requestTagBeforeClosingChat: 'requestTagBeforeClosingChat',
chatClosingTags: 'chatClosingTags',
abandonedRoomsCloseCustomMessage: 'abandonedRoomsCloseCustomMessage',
waitingQueueMessage: 'waitingQueueMessage',
departmentsAllowedToForward: 'departmentsAllowedToForward',
showOnRegistration: 'showOnRegistration',
}) as unknown as (department: ILivechatDepartment) => IAppsDepartment,
encode: (department): ILivechatDepartment => {
const newDepartment = {
_id: department.id,
name: department.name,
email: department.email,
_updatedAt: department.updatedAt,
enabled: department.enabled,
numAgents: department.numberOfAgents,
showOnOfflineForm: department.showOnOfflineForm,
showOnRegistration: department.showOnRegistration,
description: department.description,
offlineMessageChannelName: department.offlineMessageChannelName,
requestTagBeforeClosingChat: department.requestTagBeforeClosingChat,
chatClosingTags: department.chatClosingTags,
abandonedRoomsCloseCustomMessage: department.abandonedRoomsCloseCustomMessage,
waitingQueueMessage: department.waitingQueueMessage,
departmentsAllowedToForward: department.departmentsAllowedToForward,
};
return Object.assign(
newDepartment,
(department as { _unmappedProperties_?: Record<string, unknown> })._unmappedProperties_,
) as unknown as ILivechatDepartment;
},
});

View File

@@ -0,0 +1,123 @@
import { RoomType } from '@rocket.chat/apps-engine/definition/rooms';
import { SettingType } from '@rocket.chat/apps-engine/definition/settings';
import { UserStatusConnection, UserType } from '@rocket.chat/apps-engine/definition/users';
import * as z from 'zod';
/**
* Bidirectional enum mappings between Rocket.Chat's stored string values and the
* Apps-Engine enums, expressed as Zod codecs (`decode`: Rocket.Chat -> Apps-Engine,
* `encode`: Apps-Engine -> Rocket.Chat).
*
* These are behaviour-preserving replacements for the private `_convert*ToApp`/`_convert*ToEnum`
* helpers currently living inside the converter classes. The `decode` transforms reproduce the
* exact fallback semantics of those helpers (including passing unknown values through, upper-cased
* where the legacy code did so). The `encode` transforms mirror the reverse converters, which today
* assign the Apps-Engine value straight back onto the Rocket.Chat document without translation.
*
* Note: the contextual `console.warn` calls that depend on data unavailable to a pure enum mapping
* (e.g. the affected user's id/username in the status-connection warning) are intentionally left in
* the converter layer and will be wired back in as each converter is migrated.
*/
/**
* Rocket.Chat `IUser['type']` <-> Apps-Engine `UserType`.
* Mirrors `AppUsersConverter._convertUserTypeToEnum`.
*/
export const UserTypeCodec = z.codec(z.any(), z.any(), {
decode: (type): string => {
switch (type) {
case 'user':
return UserType.USER;
case 'bot':
return UserType.BOT;
case 'app':
return UserType.APP;
case '':
case undefined:
return UserType.UNKNOWN;
default:
console.warn(`A new user type has been added that the Apps don't know about? "${type}"`);
return type.toUpperCase();
}
},
// The reverse converter assigns `user.type` straight back onto the document.
encode: (type): string => type,
});
/**
* Rocket.Chat `IUser['statusConnection']` <-> Apps-Engine `UserStatusConnection`.
* Mirrors the value mapping of `AppUsersConverter._convertStatusConnectionToEnum`. The legacy
* `console.warn` (which includes the affected user's id/username) stays in the converter, since
* that context is not available to a standalone codec.
*/
export const UserStatusConnectionCodec = z.codec(z.any(), z.any(), {
decode: (status): string => {
switch (status) {
case 'offline':
return UserStatusConnection.OFFLINE;
case 'online':
return UserStatusConnection.ONLINE;
case 'away':
return UserStatusConnection.AWAY;
case 'busy':
return UserStatusConnection.BUSY;
case undefined:
// This is needed for Livechat guests and Rocket.Cat user.
return UserStatusConnection.UNDEFINED;
default:
return !status ? UserStatusConnection.OFFLINE : status.toUpperCase();
}
},
encode: (status): string => status,
});
/**
* Rocket.Chat `IRoom['t']` <-> Apps-Engine `RoomType`.
* Mirrors `AppRoomsConverter._convertTypeToApp`. Unknown type characters pass through unchanged.
*/
export const RoomTypeCodec = z.codec(z.any(), z.any(), {
decode: (typeChar): string => {
switch (typeChar) {
case 'c':
return RoomType.CHANNEL;
case 'p':
return RoomType.PRIVATE_GROUP;
case 'd':
return RoomType.DIRECT_MESSAGE;
case 'l':
return RoomType.LIVE_CHAT;
default:
return typeChar;
}
},
// `RoomType` values are identical to the stored `t` characters, so the reverse is an identity.
encode: (type): string => type,
});
/**
* Rocket.Chat `ISetting['type']` <-> Apps-Engine `SettingType`.
* Mirrors `AppSettingsConverter._convertTypeToApp`. Unknown types pass through unchanged.
*/
export const SettingTypeCodec = z.codec(z.any(), z.any(), {
decode: (type): string => {
switch (type) {
case 'boolean':
return SettingType.BOOLEAN;
case 'code':
return SettingType.CODE;
case 'color':
return SettingType.COLOR;
case 'font':
return SettingType.FONT;
case 'int':
return SettingType.NUMBER;
case 'select':
return SettingType.SELECT;
case 'string':
return SettingType.STRING;
default:
return type;
}
},
encode: (type): string => type,
});

View File

@@ -0,0 +1,9 @@
export { RoomTypeCodec, SettingTypeCodec, UserStatusConnectionCodec, UserTypeCodec } from './enums';
export { createMappedCodec, mappedDecode, mappedEncode } from './mappedData';
export type { FieldMap } from './mappedData';
export { SettingCodec } from './settings';
export { RoleCodec } from './roles';
export { VideoConferenceCodec } from './videoConferences';
export { VisitorCodec } from './visitors';
export { DepartmentCodec } from './departments';
export { UserCodec } from './users';

View File

@@ -0,0 +1,181 @@
import * as z from 'zod';
type Loose = Record<string, any>;
/**
* A declarative field map from Apps-Engine (target) property names to Rocket.Chat (source) property
* names. Values are constrained to the keys of `Source`, so mapping a renamed or misspelled source
* field is a compile error.
*/
export type FieldMap<Source> = Record<string, Extract<keyof Source, string>>;
/**
* The Rocket.Chat -> Apps-Engine result of decoding `Source` through `Map`: each target property
* holds its source value (optional, since it is only set when the source value is defined), and
* everything the map did not name is collected under `_unmappedProperties_`.
*/
export type Decoded<Source, Map extends FieldMap<Source>> = {
-readonly [Target in keyof Map]?: Source[Map[Target]];
} & {
_unmappedProperties_: Omit<Source, Map[keyof Map]>;
};
/**
* Rocket.Chat -> Apps-Engine transform for a plain string field map, reproducing the "rename these
* fields, bucket the rest" behaviour of the original mapping helper: the input is deep-cloned (so the
* app can never mutate the stored document), each mapped source field is copied to its target name
* when defined, and every remaining property is collected into `_unmappedProperties_`.
*/
export function mappedDecode<Source extends Loose = Loose, const Map extends FieldMap<Source> = FieldMap<Source>>(
fieldMap: Map,
): (data: Source) => Decoded<Source, Map> {
const entries = Object.entries(fieldMap) as [string, string][];
return (data: Source): Decoded<Source, Map> => {
const clone: Loose = structuredClone(data);
const result: Loose = {};
for (const [target, sourceKey] of entries) {
if (typeof clone[sourceKey] !== 'undefined') {
result[target] = clone[sourceKey];
}
delete clone[sourceKey];
}
result._unmappedProperties_ = clone;
return result as Decoded<Source, Map>;
};
}
/**
* The Apps-Engine -> Rocket.Chat inverse of {@link mappedDecode}: target fields are copied back to
* their source names when defined and `_unmappedProperties_` is merged onto the result. Because both
* the renamed keys and the bucket originate from `Source`, the result is a `Partial<Source>`.
*
* This is the symmetric counterpart to {@link mappedDecode}; converters whose reverse direction has
* defaults, conditional fields or asymmetric mappings provide their own `encode`.
*/
export function mappedEncode<Source extends Loose = Loose, const Map extends FieldMap<Source> = FieldMap<Source>>(
fieldMap: Map,
): (app: Decoded<Source, Map>) => Partial<Source> {
const entries = Object.entries(fieldMap) as [string, string][];
return (app: Decoded<Source, Map>): Partial<Source> => {
const { _unmappedProperties_ = {}, ...rest } = app as Loose;
const result: Loose = {};
for (const [target, sourceKey] of entries) {
if (typeof rest[target] !== 'undefined') {
result[sourceKey] = rest[target];
}
}
return { ...result, ..._unmappedProperties_ } as Partial<Source>;
};
}
/**
* A map whose entries are one of the three forms the original mapping helper supports: a source
* property name (string), a function that derives the target value from the (cloned) source data,
* or a nested `{ from, map, list }` descriptor for sub-objects and arrays.
*
* Unlike {@link FieldMap}, this is intentionally loosely typed: its consumers (rooms, messages,
* uploads) map many optional/livechat-only fields that are not part of the base document types.
*/
export type AsyncFieldMap = Record<
string,
string | ((data: Record<string, any>) => unknown | Promise<unknown>) | { from: string; map?: AsyncFieldMap; list?: boolean }
>;
/**
* Rocket.Chat -> Apps-Engine transform for a map mixing string renames, (possibly async) derived-value
* functions and nested `{ from, map, list }` descriptors, reproducing the original mapping helper
* exactly:
*
* - the input is deep-cloned up front, so functions may freely mutate the clone (e.g. `delete` fields
* they consume) and the app can never mutate the stored document;
* - string entries copy the source field to the target name when defined and always drop the source
* key from the bucket;
* - function entries receive the clone and set the target only when they return a defined value
* (functions are responsible for deleting any source keys they consume);
* - nested entries recurse when they carry a `map` (producing a `_unmappedProperties_` bucket at each
* level) or copy the cloned source value verbatim when they do not, and `list` entries map arrays
* element-by-element (or wrap a lone value into a single-element array);
* - absent (`undefined`/`null`) nested sources are skipped, so the target stays unset;
* - everything left in the clone becomes `_unmappedProperties_`.
*
* The result shape is caller-asserted via the `Result` type parameter, since it depends on the map's
* function/nested entries in ways that are not worth expressing in the type system.
*
* `dropUnmapped` omits the `_unmappedProperties_` bucket at the root and every nested level, for
* callers (e.g. contacts) that map every attribute individually and must not let extra app data leak
* through. It defaults to `false`, preserving the "bucket the rest" behaviour for other callers.
*/
export async function mappedDecodeAsync<Result = Loose>(
data: Loose,
map: AsyncFieldMap,
options: { dropUnmapped?: boolean } = {},
): Promise<Result> {
const clone: Loose = structuredClone(data);
const result: Loose = {};
for (const [to, from] of Object.entries(map)) {
if (typeof from === 'function') {
const value = await from(clone);
if (typeof value !== 'undefined') {
result[to] = value;
}
} else if (typeof from === 'string') {
if (typeof clone[from] !== 'undefined') {
result[to] = clone[from];
}
delete clone[from];
} else {
const { from: fromName } = from;
if (from.list) {
if (Array.isArray(clone[fromName])) {
if ('map' in from && from.map) {
result[to] = await Promise.all(
clone[fromName].map((item: Loose) => mappedDecodeAsync(item, from.map as AsyncFieldMap, options)),
);
} else {
result[to] = [...clone[fromName]];
}
} else if (clone[fromName] !== undefined && clone[fromName] !== null) {
result[to] = [clone[fromName]];
}
} else if (clone[fromName] !== undefined && clone[fromName] !== null) {
result[to] = from.map
? await mappedDecodeAsync(clone[fromName], from.map as AsyncFieldMap, options)
: structuredClone(clone[fromName]);
}
delete clone[fromName];
}
}
if (!options.dropUnmapped) {
result._unmappedProperties_ = clone;
}
return result as Result;
}
/**
* Builds a Zod codec from a plain string field map, using {@link mappedDecode} / {@link mappedEncode}.
* `decode` produces {@link Decoded}; `encode` is its inverse.
*
* The endpoints are typed with `z.custom` so no runtime validation is added yet (behaviour-preserving);
* schemas can be tightened later without changing the transform logic.
*/
export function createMappedCodec<Source extends Loose = Loose, const Map extends FieldMap<Source> = FieldMap<Source>>(fieldMap: Map) {
return z.codec(z.custom<Source>(), z.custom<Decoded<Source, Map>>(), {
decode: mappedDecode<Source, Map>(fieldMap),
encode: (app) => mappedEncode<Source, Map>(fieldMap)(app) as Source,
});
}

View File

@@ -0,0 +1,19 @@
import type { IRole } from '@rocket.chat/core-typings';
import { createMappedCodec } from './mappedData';
/**
* Rocket.Chat `IRole` <-> Apps-Engine role.
*
* A pure field-rename with an `_unmappedProperties_` bucket, so it is expressed directly via
* {@link createMappedCodec}. Keys are the Apps-Engine names, values the Rocket.Chat names (checked
* against `keyof IRole`).
*/
export const RoleCodec = createMappedCodec<IRole>({
id: '_id',
name: 'name',
description: 'description',
mandatory2fa: 'mandatory2fa',
protected: 'protected',
scope: 'scope',
});

View File

@@ -0,0 +1,462 @@
import type { IAppServerOrchestrator, IAppsRoom, IAppsLivechatRoom, IAppsRoomRaw } from '@rocket.chat/apps';
import { secureFieldsMapper } from '@rocket.chat/apps/dist/lib/SecureFields';
import type { IRoom } from '@rocket.chat/core-typings';
import { LivechatVisitors, LivechatDepartment, Users, LivechatContacts } from '@rocket.chat/models';
import * as z from 'zod';
import { RoomTypeCodec } from './enums';
import { mappedDecodeAsync } from './mappedData';
// The stored room documents carry many optional/livechat-only fields that are not part of the base
// `IRoom` union, so the transform inputs are treated as loose records (as the legacy converter did).
type RoomData = Record<string, any>;
const mapUserLookup = (user: any) =>
user && {
_id: user._id ?? user.id,
...(user.username && { username: user.username }),
...(user.name && { name: user.name }),
};
/**
* Rocket.Chat -> Apps-Engine "raw" room transform (no cross-converter resolution). Mirrors the
* legacy `convertRoomRaw`.
*/
export async function decodeRoomRaw(room: IRoom): Promise<IAppsRoomRaw> {
const map = {
id: '_id',
displayName: 'fname',
slugifiedName: 'name',
members: 'members',
userIds: 'uids',
usernames: 'usernames',
messageCount: 'msgs',
createdAt: 'ts',
updatedAt: '_updatedAt',
closedAt: 'closedAt',
lastModifiedAt: 'lm',
customFields: 'customFields',
livechatData: 'livechatData',
isWaitingResponse: 'waitingResponse',
isOpen: 'open',
description: 'description',
source: 'source',
closer: 'closer',
teamId: 'teamId',
isTeamMain: 'teamMain',
isDefault: 'default',
isReadOnly: 'ro',
contactId: 'contactId',
departmentId: 'departmentId',
parentRoomId: 'prid',
isFederated: 'federated',
federation: 'federation',
visitor: (data: RoomData) => {
const { v } = data;
if (!v) {
return undefined;
}
delete data.v;
const { _id: id, ...rest } = v;
return {
id,
...rest,
};
},
displaySystemMessages: (data: RoomData) => {
const { sysMes } = data;
delete data.sysMes;
return typeof sysMes === 'undefined' ? true : sysMes;
},
type: (data: RoomData) => {
const result = z.decode(RoomTypeCodec, data.t);
delete data.t;
return result;
},
creator: (data: RoomData) => {
if (!data.u) {
return undefined;
}
const creator = mapUserLookup(data.u);
delete data.u;
return creator;
},
closedBy: (data: RoomData) => {
if (!data.closedBy) {
return undefined;
}
const { closedBy } = data;
delete data.closedBy;
return mapUserLookup(closedBy);
},
servedBy: (data: RoomData) => {
if (!data.servedBy) {
return undefined;
}
const { servedBy } = data;
delete data.servedBy;
return mapUserLookup(servedBy);
},
responseBy: (data: RoomData) => {
if (!data.responseBy) {
return undefined;
}
const { responseBy } = data;
delete data.responseBy;
return mapUserLookup(responseBy);
},
};
return mappedDecodeAsync<IAppsRoomRaw>(room, map);
}
async function getCreator(user: string | undefined) {
if (!user) {
return;
}
const creator = await Users.findOneById(user, { projection: { _id: 1, username: 1, name: 1 } });
if (!creator) {
return;
}
return {
_id: creator._id,
username: creator.username,
name: creator.name,
};
}
async function getVisitor({ visitor: roomVisitor, visitorChannelInfo }: any) {
if (!roomVisitor) {
return;
}
const visitor = await LivechatVisitors.findOneEnabledById(roomVisitor.id);
if (!visitor) {
return;
}
const { lastMessageTs, phone } = visitorChannelInfo || {};
return {
_id: visitor._id,
username: visitor.username,
token: visitor.token,
status: visitor.status || 'online',
...(visitor.activity?.length && { activity: visitor.activity }),
...(lastMessageTs && { lastMessageTs }),
...(phone && { phone }),
};
}
async function getUserIdAndUsername(userObj: any) {
if (!userObj?.id) {
return;
}
const user = await Users.findOneById(userObj.id, { projection: { _id: 1, username: 1 } });
if (!user) {
return;
}
return {
_id: user._id,
username: user.username,
};
}
async function getRoomCloser(room: any, v: any) {
if (!room.closedBy) {
return;
}
if (room.closer === 'user') {
const user = await Users.findOneById(room.closedBy.id, { projection: { _id: 1, username: 1 } });
if (!user) {
return;
}
return {
_id: user._id,
username: user.username,
};
}
if (room.closer === 'visitor' && v) {
return {
_id: v._id,
username: v.username,
};
}
}
// TODO do we really need this?
async function getContactId({ contact }: any) {
if (!contact?._id) {
return;
}
const contactFromDb = await LivechatContacts.findOneEnabledById(contact._id, { projection: { _id: 1 } });
return contactFromDb?._id;
}
// TODO do we really need this?
async function getDepartment({ department }: any) {
if (!department) {
return;
}
const dept = await LivechatDepartment.findOneById(department.id, { projection: { _id: 1 } });
return dept?._id;
}
/**
* Apps-Engine -> Rocket.Chat room transform. Mirrors the legacy `convertAppRoom`, including the
* `isPartial` behaviour (skip `_unmappedProperties_` merge). Cross-converter independent — it reads
* the models directly.
*/
export async function appRoomToRocketChat(room: any, isPartial = false): Promise<Partial<IRoom>> {
const u = await getCreator(room.creator?.id);
const v = await getVisitor(room);
const departmentId = await getDepartment(room);
const servedBy = await getUserIdAndUsername(room.servedBy);
const closedBy = await getRoomCloser(room, v);
const contactId = await getContactId(room);
const newRoom = {
...(room.id && { _id: room.id }),
...(typeof room.type !== 'undefined' && { t: room.type }),
...(typeof room.createdAt !== 'undefined' && { ts: room.createdAt }),
...(typeof room.messageCount !== 'undefined' && { msgs: room.messageCount || 0 }),
...(typeof room.updatedAt !== 'undefined' && { _updatedAt: room.updatedAt }),
...(room.displayName && { fname: room.displayName }),
...(room.type !== 'd' && room.slugifiedName && { name: room.slugifiedName }),
...(room.members && { members: room.members }),
...(typeof room.isDefault !== 'undefined' && { default: room.isDefault }),
...(typeof room.isReadOnly !== 'undefined' && { ro: room.isReadOnly }),
...(typeof room.displaySystemMessages !== 'undefined' && { sysMes: room.displaySystemMessages }),
...(u && { u }),
...(v && { v }),
...(departmentId && { departmentId }),
...(servedBy && { servedBy }),
...(closedBy && { closedBy }),
...(room.userIds && { uids: room.userIds }),
...(typeof room.isWaitingResponse !== 'undefined' && { waitingResponse: !!room.isWaitingResponse }),
...(typeof room.isOpen !== 'undefined' && { open: !!room.isOpen }),
...(room.closedAt && { closedAt: room.closedAt }),
...(room.lastModifiedAt && { lm: room.lastModifiedAt }),
...(room.customFields && { customFields: room.customFields }),
...(room.livechatData && { livechatData: room.livechatData }),
...(typeof room.isFederated !== 'undefined' && { federated: room.isFederated }),
...(typeof room.federation !== 'undefined' && { federation: room.federation }),
...(typeof room.parentRoom !== 'undefined' && { prid: room.parentRoom.id }),
...(contactId && { contactId }),
...(room._USERNAMES && { _USERNAMES: room._USERNAMES }),
...(room.source && {
source: {
...room.source,
},
}),
};
if (!isPartial) {
Object.assign(newRoom, room._unmappedProperties_);
}
return newRoom as unknown as Partial<IRoom>;
}
/**
* Rocket.Chat `IRoom` <-> Apps-Engine room. Cross-converter dependent, so built via a factory closing
* over the orchestrator. `decode` mirrors `convertRoom` (resolves creator/visitor/contact/department/
* closedBy/servedBy/responseBy/parentRoom through their converters, plus secure fields). `encode`
* mirrors the non-partial `convertAppRoom`; the class handles the `isPartial` variant.
*/
export function createRoomCodec(orch: IAppServerOrchestrator) {
return z.codec(z.custom<IRoom>(), z.custom<IAppsRoom | IAppsLivechatRoom>(), {
decode: async (originalRoom): Promise<IAppsRoom | IAppsLivechatRoom> => {
const map = {
id: '_id',
displayName: 'fname',
slugifiedName: 'name',
members: 'members',
userIds: 'uids',
messageCount: 'msgs',
createdAt: 'ts',
updatedAt: '_updatedAt',
closedAt: 'closedAt',
lastModifiedAt: 'lm',
customFields: 'customFields',
livechatData: 'livechatData',
isWaitingResponse: 'waitingResponse',
isOpen: 'open',
_USERNAMES: '_USERNAMES',
description: 'description',
source: 'source',
closer: 'closer',
teamId: 'teamId',
isTeamMain: 'teamMain',
isFederated: 'federated',
federation: 'federation',
isDefault: (room: RoomData) => {
const result = !!room.default;
delete room.default;
return result;
},
isReadOnly: (room: RoomData) => {
const result = !!room.ro;
delete room.ro;
return result;
},
displaySystemMessages: (room: RoomData) => {
const { sysMes } = room;
if (typeof sysMes === 'undefined') {
return true;
}
delete room.sysMes;
return sysMes;
},
type: (room: RoomData) => {
const result = z.decode(RoomTypeCodec, room.t);
delete room.t;
return result;
},
creator: async (room: RoomData) => {
const { u } = room;
if (!u) {
return undefined;
}
delete room.u;
return orch.getConverters().get('users').convertById(u._id);
},
visitor: (room: RoomData) => {
const { v } = room;
if (!v) {
return undefined;
}
return orch.getConverters().get('visitors').convertById(v._id);
},
contact: (room: RoomData) => {
const { contactId } = room;
if (!contactId) {
return undefined;
}
return orch.getConverters().get('contacts').convertById(contactId);
},
// Note: room.v is not just visitor, it also contains channel related visitor data
// so we need to pass this data to the converter
// So suppose you have a contact whom we're contacting using SMS via 2 phone no's,
// let's call X and Y. Then if the contact sends a message using X phone number,
// then room.v.phoneNo would be X and correspondingly we'll store the timestamp of
// the last message from this visitor from X phone no on room.v.lastMessageTs
visitorChannelInfo: (room: RoomData) => {
const { v } = room;
if (!v) {
return undefined;
}
const { lastMessageTs, phone } = v;
return {
...(phone && { phone }),
...(lastMessageTs && { lastMessageTs }),
};
},
department: async (room: RoomData) => {
const { departmentId } = room;
if (!departmentId) {
return undefined;
}
delete room.departmentId;
return orch.getConverters().get('departments').convertById(departmentId);
},
closedBy: async (room: RoomData) => {
const { closedBy } = room;
if (!closedBy) {
return undefined;
}
delete room.closedBy;
if ((originalRoom as RoomData).closer === 'user') {
return orch.getConverters().get('users').convertById(closedBy._id);
}
return orch.getConverters().get('visitors').convertById(closedBy._id);
},
servedBy: async (room: RoomData) => {
const { servedBy } = room;
if (!servedBy) {
return undefined;
}
delete room.servedBy;
return orch.getConverters().get('users').convertById(servedBy._id);
},
responseBy: async (room: RoomData) => {
const { responseBy } = room;
if (!responseBy) {
return undefined;
}
delete room.responseBy;
return orch.getConverters().get('users').convertById(responseBy._id);
},
parentRoom: async (room: RoomData) => {
const { prid } = room;
if (!prid) {
return undefined;
}
delete room.prid;
return orch.getConverters().get('rooms').convertById(prid);
},
...secureFieldsMapper((room: RoomData) => {
if (!room.abacAttributes) {
return undefined;
}
const value = [
{
permission: 'abac.read',
name: 'abacAttributes',
value: room.abacAttributes,
},
];
delete room.abacAttributes;
return value;
}),
};
return mappedDecodeAsync<IAppsRoom | IAppsLivechatRoom>(originalRoom, map);
},
encode: (room): Promise<IRoom> => appRoomToRocketChat(room, false) as unknown as Promise<IRoom>,
});
}

View File

@@ -0,0 +1,36 @@
import type { IAppsSetting } from '@rocket.chat/apps';
import type { ISetting } from '@rocket.chat/core-typings';
import * as z from 'zod';
import { SettingTypeCodec } from './enums';
/**
* Rocket.Chat `ISetting` -> Apps-Engine `IAppsSetting`.
*
* `decode` reproduces `AppSettingsConverter.convertToApp` (id/date renames + `SettingType` mapping).
* The setting converter is intentionally one-directional, matching the legacy converter which had no
* `convertToRocketChat`: an Apps-Engine setting cannot be turned back into a valid `ISetting` (required
* fields such as `env`, `blocked` and `sorter` are not part of `IAppsSetting`), so `encode` is not
* supported and throws instead of emitting a lossy, invalid document. Endpoints are typed with
* `z.custom` so no runtime validation is added on the decode path.
*/
export const SettingCodec = z.codec(z.custom<ISetting>(), z.custom<IAppsSetting>(), {
decode: (setting): IAppsSetting =>
({
id: setting._id,
type: z.decode(SettingTypeCodec, setting.type),
packageValue: setting.packageValue,
values: setting.values,
value: setting.value,
public: setting.public,
hidden: setting.hidden,
group: setting.group,
i18nLabel: setting.i18nLabel,
i18nDescription: setting.i18nDescription,
createdAt: setting.ts,
updatedAt: setting._updatedAt,
}) as unknown as IAppsSetting,
encode: (): ISetting => {
throw new Error('SettingCodec: converting an Apps-Engine setting back to a Rocket.Chat setting is not supported');
},
});

View File

@@ -0,0 +1,97 @@
import type { IAppServerOrchestrator, IAppsUpload } from '@rocket.chat/apps';
import type { IUpload } from '@rocket.chat/core-typings';
import * as z from 'zod';
import { mappedDecodeAsync } from './mappedData';
/**
* Rocket.Chat `IUpload` <-> Apps-Engine `IAppsUpload`.
*
* Cross-converter dependent, so it is built via a factory closing over the orchestrator. `decode`
* (async) mirrors the legacy `convertToApp`: a field rename plus room/user/visitor resolved through
* their converters, with an `_unmappedProperties_` bucket. `encode` mirrors `convertToRocketChat`.
*/
export function createUploadsCodec(orch: IAppServerOrchestrator) {
return z.codec(z.custom<IUpload>(), z.custom<IAppsUpload>(), {
decode: async (upload): Promise<IAppsUpload> => {
const map = {
id: '_id',
name: 'name',
size: 'size',
type: 'type',
store: 'store',
description: 'description',
complete: 'complete',
uploading: 'uploading',
extension: 'extension',
progress: 'progress',
etag: 'etag',
path: 'path',
token: 'token',
url: 'url',
updatedAt: '_updatedAt',
uploadedAt: 'uploadedAt',
room: async (upload: Record<string, any>) => {
const result = await orch
.getConverters()
.get('rooms')
.convertById(upload.rid as string);
delete upload.rid;
return result;
},
user: async (upload: Record<string, any>) => {
if (!upload.userId) {
return undefined;
}
const result = await orch.getConverters().get('users').convertById(upload.userId);
delete upload.userId;
return result;
},
visitor: async (upload: Record<string, any>) => {
const { visitorToken } = upload;
if (!visitorToken) {
return undefined;
}
const result = await orch.getConverters().get('visitors').convertByToken(visitorToken);
delete upload.visitorToken;
return result;
},
};
return mappedDecodeAsync<IAppsUpload>(upload, map);
},
encode: (upload): IUpload => {
const { id: userId } = upload.user || {};
const { token: visitorToken } = upload.visitor || {};
const { id: rid } = upload.room || {};
const newUpload = {
_id: upload.id,
name: upload.name,
size: upload.size,
type: upload.type,
extension: upload.extension,
description: (upload as { description?: string }).description,
store: upload.store,
etag: upload.etag,
complete: upload.complete,
uploading: upload.uploading,
progress: upload.progress,
token: upload.token,
url: upload.url,
_updatedAt: upload.updatedAt,
uploadedAt: upload.uploadedAt,
rid,
userId,
visitorToken,
};
return Object.assign(
newUpload,
(upload as { _unmappedProperties_?: Record<string, unknown> })._unmappedProperties_,
) as unknown as IUpload;
},
});
}

View File

@@ -0,0 +1,77 @@
import type { IAppsUser } from '@rocket.chat/apps';
import type { IUser } from '@rocket.chat/core-typings';
import { removeEmpty } from '@rocket.chat/tools';
import * as z from 'zod';
import { UserStatusConnectionCodec, UserTypeCodec } from './enums';
const VALID_STATUS_CONNECTIONS = new Set(['offline', 'online', 'away', 'busy']);
/**
* Rocket.Chat `IUser` <-> Apps-Engine `IAppsUser`.
*
* Both directions are bespoke (no `_unmappedProperties_` bucket): `decode` mirrors `convertToApp`
* and `encode` mirrors `convertToRocketChat`. Enum fields go through the shared enum codecs. The
* contextual "invalid status" warning stays here (rather than in `UserStatusConnectionCodec`)
* because it needs the user's id/username. On the `encode` side `utcOffset` falls back to the legacy
* misspelled `utfOffset` property, so app users that only carry the historical typo still convert.
*/
export const UserCodec = z.codec(z.custom<IUser>(), z.custom<IAppsUser>(), {
decode: (user): IAppsUser => {
const { statusConnection } = user;
if (typeof statusConnection !== 'undefined' && !VALID_STATUS_CONNECTIONS.has(statusConnection)) {
console.warn(
`The user ${user.username} (${user._id}) does not have a valid status (offline, online, away, or busy). It is currently: "${statusConnection}"`,
);
}
return {
id: user._id,
username: user.username,
emails: user.emails,
type: z.decode(UserTypeCodec, user.type),
isEnabled: user.active,
name: user.name,
roles: user.roles,
bio: user.bio,
status: user.status,
statusText: user.statusText,
statusConnection: z.decode(UserStatusConnectionCodec, statusConnection),
utcOffset: user.utcOffset,
createdAt: user.createdAt,
updatedAt: user._updatedAt,
lastLoginAt: user.lastLogin,
appId: (user as { appId?: string }).appId,
customFields: user.customFields,
isFederated: user.federated,
federation: user.federation,
sipExtension: user.freeSwitchExtension,
settings: {
preferences: {
...(user?.settings?.preferences?.language && { language: user.settings.preferences.language }),
},
},
} as unknown as IAppsUser;
},
encode: (user): IUser =>
removeEmpty({
_id: user.id,
username: user.username,
emails: user.emails,
type: z.encode(UserTypeCodec, user.type),
active: user.isEnabled,
name: user.name,
roles: user.roles,
bio: user.bio,
status: user.status,
statusConnection: z.encode(UserStatusConnectionCodec, user.statusConnection),
utcOffset: user.utcOffset ?? (user as { utfOffset?: number }).utfOffset,
createdAt: user.createdAt,
_updatedAt: user.updatedAt,
lastLogin: user.lastLoginAt,
appId: user.appId,
federated: user.isFederated,
federation: user.federation,
freeSwitchExtension: user.sipExtension,
}) as unknown as IUser,
});

View File

@@ -0,0 +1,14 @@
import type { AppsVideoConference } from '@rocket.chat/apps';
import type { VideoConference } from '@rocket.chat/core-typings';
import * as z from 'zod';
/**
* Rocket.Chat `VideoConference` <-> Apps-Engine `AppsVideoConference`.
*
* Both directions are a shallow pass-through clone, matching the legacy converter. Endpoints are
* typed with `z.custom` so no runtime validation is added yet.
*/
export const VideoConferenceCodec = z.codec(z.custom<VideoConference>(), z.custom<AppsVideoConference>(), {
decode: (call): AppsVideoConference => ({ ...call }) as unknown as AppsVideoConference,
encode: (call): VideoConference => ({ ...call }) as unknown as VideoConference,
});

View File

@@ -0,0 +1,49 @@
import type { IAppsVisitor } from '@rocket.chat/apps';
import type { ILivechatVisitor } from '@rocket.chat/core-typings';
import * as z from 'zod';
import { mappedDecode } from './mappedData';
/**
* Rocket.Chat `ILivechatVisitor` <-> Apps-Engine `IAppsVisitor`.
*
* `decode` is a plain field-rename with an `_unmappedProperties_` bucket (mirrors the legacy
* `convertVisitor`). `encode` is bespoke: it mirrors `convertAppVisitor`, which defaults `status`,
* includes `visitorEmails`/`department`/`externalIds` only when truthy, deliberately does **not**
* write `_updatedAt`/`activity` back, and merges `_unmappedProperties_`.
*/
export const VisitorCodec = z.codec(z.custom<ILivechatVisitor>(), z.custom<IAppsVisitor>(), {
decode: mappedDecode<ILivechatVisitor>({
id: '_id',
username: 'username',
name: 'name',
department: 'department',
updatedAt: '_updatedAt',
token: 'token',
phone: 'phone',
visitorEmails: 'visitorEmails',
livechatData: 'livechatData',
status: 'status',
activity: 'activity',
externalIds: 'externalIds',
}) as unknown as (visitor: ILivechatVisitor) => IAppsVisitor,
encode: (visitor): ILivechatVisitor => {
const newVisitor = {
_id: visitor.id,
username: visitor.username,
name: visitor.name,
token: visitor.token,
phone: visitor.phone,
livechatData: visitor.livechatData,
status: visitor.status || 'online',
...(visitor.visitorEmails && { visitorEmails: visitor.visitorEmails }),
...(visitor.department && { department: visitor.department }),
...(visitor.externalIds && { externalIds: visitor.externalIds }),
};
return Object.assign(
newVisitor,
(visitor as { _unmappedProperties_?: Record<string, unknown> })._unmappedProperties_,
) as unknown as ILivechatVisitor;
},
});

View File

@@ -0,0 +1,41 @@
import type { IAppContactsConverter, IAppsLivechatContact } from '@rocket.chat/apps';
import type { ILivechatContact } from '@rocket.chat/core-typings';
import { LivechatContacts } from '@rocket.chat/models';
import * as z from 'zod';
import { ContactCodec } from './codecs/contacts';
export class AppContactsConverter implements IAppContactsConverter {
async convertById(contactId: ILivechatContact['_id']): Promise<IAppsLivechatContact | undefined> {
const contact = await LivechatContacts.findOneEnabledById(contactId);
if (!contact) {
return;
}
return this.convertContact(contact);
}
async convertContact(contact: undefined | null): Promise<undefined>;
async convertContact(contact: ILivechatContact): Promise<IAppsLivechatContact>;
async convertContact(contact: ILivechatContact | undefined | null): Promise<IAppsLivechatContact | undefined> {
if (!contact) {
return;
}
return z.decodeAsync(ContactCodec, contact);
}
convertAppContact(contact: undefined | null): Promise<undefined>;
convertAppContact(contact: IAppsLivechatContact): Promise<ILivechatContact>;
async convertAppContact(contact: IAppsLivechatContact | undefined | null): Promise<ILivechatContact | undefined> {
if (!contact) {
return;
}
return z.encodeAsync(ContactCodec, contact);
}
}

View File

@@ -0,0 +1,23 @@
import type { IMessage as AppsEngineMessage } from '@rocket.chat/apps-engine/definition/messages';
import type { IMessage } from '@rocket.chat/core-typings';
export async function convertMessageFiles(
files: IMessage['files'],
attachments: IMessage['attachments'],
): Promise<AppsEngineMessage['files']> {
return files?.map((file) => {
if (!file || file.typeGroup) {
return file;
}
// Thumbnails from older messages did not have any identification but we can extrapolate this information from other data
if (files.length === 2 && attachments?.length === 1 && file === files[1]) {
return {
...file,
typeGroup: 'thumb',
};
}
return file;
});
}

View File

@@ -0,0 +1,46 @@
import type { IAppDepartmentsConverter, IAppServerOrchestrator, IAppsDepartment } from '@rocket.chat/apps';
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import { LivechatDepartment } from '@rocket.chat/models';
import * as z from 'zod';
import { DepartmentCodec } from './codecs';
export class AppDepartmentsConverter implements IAppDepartmentsConverter {
constructor(protected readonly orch: IAppServerOrchestrator) {
this.orch = orch;
}
async convertById(id: ILivechatDepartment['_id']): Promise<IAppsDepartment | undefined> {
const department = await LivechatDepartment.findOneById(id);
return this.convertDepartment(department);
}
async convertDepartment(department: undefined | null): Promise<undefined>;
async convertDepartment(department: ILivechatDepartment): Promise<IAppsDepartment>;
async convertDepartment(department: ILivechatDepartment | undefined | null): Promise<IAppsDepartment | undefined>;
async convertDepartment(department: ILivechatDepartment | undefined | null): Promise<IAppsDepartment | undefined> {
if (!department) {
return undefined;
}
return z.decode(DepartmentCodec, department);
}
convertAppDepartment(department: undefined | null): undefined;
convertAppDepartment(department: IAppsDepartment): ILivechatDepartment;
convertAppDepartment(department: IAppsDepartment | undefined | null): ILivechatDepartment | undefined;
convertAppDepartment(department: IAppsDepartment | undefined | null): ILivechatDepartment | undefined {
if (!department) {
return undefined;
}
return z.encode(DepartmentCodec, department);
}
}

View File

@@ -0,0 +1,23 @@
import { AppContactsConverter } from './contacts';
import { AppDepartmentsConverter } from './departments';
import { AppMessagesConverter } from './messages';
import { AppRolesConverter } from './roles';
import { AppRoomsConverter } from './rooms';
import { AppSettingsConverter } from './settings';
import { AppUploadsConverter } from './uploads';
import { AppUsersConverter } from './users';
import { AppVideoConferencesConverter } from './videoConferences';
import { AppVisitorsConverter } from './visitors';
export {
AppContactsConverter,
AppMessagesConverter,
AppRoomsConverter,
AppSettingsConverter,
AppUsersConverter,
AppVideoConferencesConverter,
AppDepartmentsConverter,
AppUploadsConverter,
AppVisitorsConverter,
AppRolesConverter,
};

View File

@@ -0,0 +1,378 @@
import type { IAppMessagesConverter, IAppServerOrchestrator, IAppsMessage, IAppsMesssageRaw } from '@rocket.chat/apps';
import { isMessageFromVisitor } from '@rocket.chat/core-typings';
import type { IMessage } from '@rocket.chat/core-typings';
import { Messages, Rooms, Users } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import { removeEmpty } from '@rocket.chat/tools';
import { cachedFunction } from './cachedFunction';
import { mappedDecodeAsync } from './codecs/mappedData';
import { convertMessageFiles } from './convertMessageFiles';
// The stored message documents and the app-side payloads carry many optional/dynamic fields that are
// awkward to express against the base `IMessage`/`IAppsMessage` types. The legacy converter accessed
// them dynamically; until this converter is migrated to a codec we treat the transform inputs as
// loosely-typed records to preserve that behaviour verbatim.
type MessageData = Record<string, any>;
export class AppMessagesConverter implements IAppMessagesConverter {
private mem = new WeakMap<object, Map<string, any>>();
constructor(protected readonly orch: IAppServerOrchestrator) {
this.orch = orch;
}
async convertById(msgId: IMessage['_id']): Promise<IAppsMessage | undefined> {
const msg = await Messages.findOneById(msgId);
return this.convertMessage(msg);
}
convertMessageRaw(msgObj: IMessage): Promise<IAppsMesssageRaw>;
convertMessageRaw(msgObj: IMessage | undefined | null): Promise<IAppsMesssageRaw | undefined>;
async convertMessageRaw(msgObj: IMessage | undefined | null): Promise<IAppsMesssageRaw | undefined> {
if (!msgObj) {
return undefined;
}
const { attachments, ...message } = msgObj;
const getAttachments = async () => this._convertAttachmentsToApp(attachments, (msgObj as MessageData).file);
const map = {
id: '_id',
threadId: 'tmid',
reactions: 'reactions',
parseUrls: 'parseUrls',
text: 'msg',
createdAt: 'ts',
updatedAt: '_updatedAt',
editedAt: 'editedAt',
emoji: 'emoji',
avatarUrl: 'avatar',
alias: 'alias',
file: 'file',
files: 'files',
customFields: 'customFields',
groupable: 'groupable',
token: 'token',
blocks: 'blocks',
roomId: 'rid',
editor: 'editedBy',
attachments: getAttachments,
sender: 'u',
threadMsgCount: 'tcount',
type: 't',
} as const;
return mappedDecodeAsync<IAppsMesssageRaw>(message, map);
}
convertMessage(msgObj: undefined | null): Promise<undefined>;
convertMessage(msgObj: IMessage, cacheObj?: object): Promise<IAppsMessage>;
convertMessage(msgObj: IMessage | undefined | null, cacheObj?: object): Promise<IAppsMessage | undefined>;
async convertMessage(
msgObj: IMessage | undefined | null,
cacheObj: object | undefined = msgObj ?? undefined,
): Promise<IAppsMessage | undefined> {
if (!msgObj) {
return undefined;
}
const cache =
this.mem.get(cacheObj as object) ??
new Map<string, any>([
['room', cachedFunction(this.orch.getConverters().get('rooms').convertById.bind(this.orch.getConverters().get('rooms')))],
[
'user.convertById',
cachedFunction(this.orch.getConverters().get('users').convertById.bind(this.orch.getConverters().get('users'))),
],
[
'user.convertToApp',
cachedFunction(this.orch.getConverters().get('users').convertToApp.bind(this.orch.getConverters().get('users'))),
],
]);
this.mem.set(cacheObj as object, cache);
const { attachments, file: mainFile } = msgObj as MessageData;
const map = {
id: '_id',
threadId: 'tmid',
reactions: 'reactions',
parseUrls: 'parseUrls',
text: 'msg',
createdAt: 'ts',
updatedAt: '_updatedAt',
editedAt: 'editedAt',
emoji: 'emoji',
avatarUrl: 'avatar',
alias: 'alias',
file: 'file',
customFields: 'customFields',
groupable: 'groupable',
token: 'token',
blocks: 'blocks',
type: 't',
files: async (message: MessageData) => convertMessageFiles(message.files, attachments),
room: async (message: MessageData) => {
const result = await cache.get('room')(message.rid);
delete message.rid;
return result;
},
editor: async (message: MessageData) => {
const { editedBy } = message;
delete message.editedBy;
if (!editedBy) {
return undefined;
}
return cache.get('user.convertById')(editedBy._id);
},
attachments: async (message: MessageData) => {
const result = await this._convertAttachmentsToApp(message.attachments, mainFile);
delete message.attachments;
return result;
},
sender: async (message: MessageData) => {
if (!message.u?._id) {
return undefined;
}
// Keep a reference to the original sender before it is deleted below, so the fallback still has it.
const sender = message.u;
// When the message contains token, means the message is from the visitor(omnichannel)
const user = await (isMessageFromVisitor(msgObj)
? cache.get('user.convertToApp')(sender)
: cache.get('user.convertById')(sender._id));
delete message.u;
/**
* Old System Messages from visitor doesn't have the `token` field, to not return
* `sender` as undefined, so we need to add this fallback here.
*/
return user || cache.get('user.convertToApp')(sender);
},
} as const;
return mappedDecodeAsync<IAppsMessage>(msgObj, map);
}
convertAppMessage(message: undefined | null): Promise<undefined>;
convertAppMessage(message: IAppsMessage): Promise<IMessage | undefined>;
convertAppMessage(message: IAppsMessage, isPartial: boolean): Promise<Partial<IMessage>>;
async convertAppMessage(message: any, isPartial = false): Promise<Partial<IMessage> | undefined> {
if (!message) {
return undefined;
}
let rid;
if (message.room?.id) {
const room = await Rooms.findOneById(message.room.id, { projection: { _id: 1 } });
rid = room?._id;
}
if (!rid && !isPartial) {
throw new Error('Invalid room provided on the message.');
}
let u;
if (message.sender?.id) {
const user = await Users.findOneById(message.sender.id);
if (user) {
u = {
_id: user._id,
username: user.username,
name: user.name,
};
} else {
u = {
_id: message.sender.id,
username: message.sender.username,
name: message.sender.name,
};
}
}
let editedBy;
if (message.editor) {
const editor = await Users.findOneById(message.editor.id);
// Fall back to the editor data carried on the app payload when the user no longer exists,
// mirroring the sender handling above instead of dereferencing a possibly-null lookup.
editedBy = editor
? {
_id: editor._id,
username: editor.username,
}
: {
_id: message.editor.id,
username: message.editor.username,
};
}
const attachments = this._convertAppAttachments(message.attachments);
let _id = message.id;
let ts = message.createdAt;
if (!isPartial) {
if (!message.id) {
_id = Random.id();
}
if (!message.createdAt) {
ts = new Date();
}
}
const newMessage: MessageData = {
_id,
...('threadId' in message && { tmid: message.threadId }),
rid,
u,
msg: message.text,
ts,
_updatedAt: message.updatedAt,
...(editedBy && { editedBy }),
...('editedAt' in message && { editedAt: message.editedAt }),
...('emoji' in message && { emoji: message.emoji }),
...('avatarUrl' in message && { avatar: message.avatarUrl }),
...('alias' in message && { alias: message.alias }),
...('customFields' in message && { customFields: message.customFields }),
...('groupable' in message && { groupable: message.groupable }),
...(attachments && { attachments }),
...('reactions' in message && { reactions: message.reactions }),
...('parseUrls' in message && { parseUrls: message.parseUrls }),
...('blocks' in message && { blocks: message.blocks }),
...('token' in message && { token: message.token }),
};
if (isPartial) {
Object.entries(newMessage).forEach(([key, value]) => {
if (typeof value === 'undefined') {
delete newMessage[key];
}
});
} else {
Object.assign(newMessage, message._unmappedProperties_);
}
return newMessage as unknown as Partial<IMessage>;
}
_convertAppAttachments(attachments: any) {
if (typeof attachments === 'undefined' || !Array.isArray(attachments)) {
return undefined;
}
return attachments.map((attachment) =>
removeEmpty({
collapsed: attachment.collapsed,
color: attachment.color,
text: attachment.text,
ts: attachment.timestamp ? attachment.timestamp.toJSON() : attachment.timestamp,
message_link: attachment.timestampLink,
thumb_url: attachment.thumbnailUrl,
author_name: attachment.author ? attachment.author.name : undefined,
author_link: attachment.author ? attachment.author.link : undefined,
author_icon: attachment.author ? attachment.author.icon : undefined,
title: attachment.title ? attachment.title.value : undefined,
title_link: attachment.title ? attachment.title.link : undefined,
title_link_download: attachment.title ? attachment.title.displayDownloadLink : undefined,
image_dimensions: attachment.imageDimensions,
image_preview: attachment.imagePreview,
image_url: attachment.imageUrl,
image_type: attachment.imageType,
image_size: attachment.imageSize,
audio_url: attachment.audioUrl,
audio_type: attachment.audioType,
audio_size: attachment.audioSize,
video_url: attachment.videoUrl,
video_type: attachment.videoType,
video_size: attachment.videoSize,
fields: attachment.fields,
button_alignment: attachment.actionButtonsAlignment,
actions: attachment.actions,
type: attachment.type,
description: attachment.description,
...attachment._unmappedProperties_,
}),
);
}
async _convertAttachmentsToApp(attachments: any, mainFile: any) {
if (typeof attachments === 'undefined' || !Array.isArray(attachments)) {
return undefined;
}
const map = {
collapsed: 'collapsed',
color: 'color',
text: 'text',
timestampLink: 'message_link',
thumbnailUrl: 'thumb_url',
imageDimensions: 'image_dimensions',
imagePreview: 'image_preview',
imageUrl: 'image_url',
imageType: 'image_type',
imageSize: 'image_size',
audioUrl: 'audio_url',
audioType: 'audio_type',
audioSize: 'audio_size',
videoUrl: 'video_url',
videoType: 'video_type',
videoSize: 'video_size',
fields: 'fields',
actionButtonsAlignment: 'button_alignment',
actions: 'actions',
type: 'type',
description: 'description',
author: (attachment: MessageData) => {
const { author_name: name, author_link: link, author_icon: icon } = attachment;
delete attachment.author_name;
delete attachment.author_link;
delete attachment.author_icon;
return { name, link, icon };
},
title: (attachment: MessageData) => {
const { title: value, title_link: link, title_link_download: displayDownloadLink } = attachment;
delete attachment.title;
delete attachment.title_link;
delete attachment.title_link_download;
return { value, link, displayDownloadLink };
},
timestamp: (attachment: MessageData) => {
const result = new Date(attachment.ts);
delete attachment.ts;
return result;
},
fileId: (attachment: MessageData) => {
// If the attachment is missing the fileId, but there's only one file in the message, use that file's ID
if (!attachment.fileId && attachment.type === 'file' && mainFile?._id && attachments.length === 1) {
return mainFile._id;
}
return attachment.fileId;
},
} as const;
return Promise.all(attachments.map(async (attachment) => mappedDecodeAsync(attachment, map)));
}
}

View File

@@ -0,0 +1,22 @@
import type { IAppRolesConverter } from '@rocket.chat/apps';
import type { IRole as AppsEngineRole } from '@rocket.chat/apps-engine/definition/roles';
import type { IRole } from '@rocket.chat/core-typings';
import { Roles } from '@rocket.chat/models';
import * as z from 'zod';
import { RoleCodec } from './codecs';
export class AppRolesConverter implements IAppRolesConverter {
async convertById(roleId: string): Promise<AppsEngineRole | undefined> {
const role = await Roles.findOneById(roleId);
if (!role) {
return;
}
return this.convertRole(role);
}
async convertRole(role: IRole): Promise<AppsEngineRole> {
return z.decode(RoleCodec, role) as unknown as AppsEngineRole;
}
}

View File

@@ -0,0 +1,73 @@
import type { IAppRoomsConverter, IAppServerOrchestrator, IAppsRoom, IAppsLivechatRoom, IAppsRoomRaw } from '@rocket.chat/apps';
import type { IRoom } from '@rocket.chat/core-typings';
import { Rooms } from '@rocket.chat/models';
import * as z from 'zod';
import { appRoomToRocketChat, createRoomCodec, decodeRoomRaw } from './codecs/rooms';
export class AppRoomsConverter implements IAppRoomsConverter {
private readonly codec: ReturnType<typeof createRoomCodec>;
constructor(protected readonly orch: IAppServerOrchestrator) {
this.orch = orch;
this.codec = createRoomCodec(orch);
}
async convertById(roomId: IRoom['_id']): Promise<IAppsRoom | undefined> {
const room = await Rooms.findOneById(roomId);
return this.convertRoom(room);
}
async convertByName(roomName: IRoom['name']): Promise<IAppsRoom | undefined> {
const room = await Rooms.findOneByName(roomName as string);
return this.convertRoom(room);
}
convertRoomRaw(room: IRoom): Promise<IAppsRoomRaw>;
convertRoomRaw(room: IRoom | undefined | null): Promise<IAppsRoomRaw | undefined>;
convertRoomRaw(room: IRoom | undefined | null): Promise<IAppsRoomRaw | undefined> {
if (!room) {
return Promise.resolve(undefined);
}
return decodeRoomRaw(room);
}
async convertAppRoom(room: undefined | null): Promise<undefined>;
async convertAppRoom(room: IAppsRoom): Promise<IRoom>;
async convertAppRoom(room: IAppsRoom, isPartial: boolean): Promise<Partial<IRoom>>;
async convertAppRoom(room: IAppsRoom | undefined | null, isPartial?: boolean): Promise<Partial<IRoom> | undefined>;
async convertAppRoom(room: IAppsRoom | undefined | null, isPartial = false): Promise<Partial<IRoom> | undefined> {
if (!room) {
return undefined;
}
if (isPartial) {
return appRoomToRocketChat(room, true);
}
return z.encodeAsync(this.codec, room);
}
convertRoom(originalRoom: undefined | null): Promise<undefined>;
convertRoom(originalRoom: IRoom): Promise<IAppsRoom | IAppsLivechatRoom>;
convertRoom(originalRoom: IRoom | undefined | null): Promise<IAppsRoom | IAppsLivechatRoom | undefined>;
convertRoom(originalRoom: IRoom | undefined | null): Promise<IAppsRoom | IAppsLivechatRoom | undefined> {
if (!originalRoom) {
return Promise.resolve(undefined);
}
return z.decodeAsync(this.codec, originalRoom);
}
}

View File

@@ -0,0 +1,22 @@
import type { IAppServerOrchestrator, IAppSettingsConverter, IAppsSetting } from '@rocket.chat/apps';
import type { ISetting } from '@rocket.chat/core-typings';
import { Settings } from '@rocket.chat/models';
import * as z from 'zod';
import { SettingCodec } from './codecs';
export class AppSettingsConverter implements IAppSettingsConverter {
constructor(protected readonly orch: IAppServerOrchestrator) {
this.orch = orch;
}
async convertById(settingId: ISetting['_id']): Promise<IAppsSetting> {
const setting = await Settings.findOneById(settingId);
return this.convertToApp(setting as ISetting);
}
convertToApp(setting: ISetting): IAppsSetting {
return z.decode(SettingCodec, setting);
}
}

View File

@@ -0,0 +1,209 @@
import type {
IAppRoomsConverter,
IAppServerOrchestrator,
IAppThreadsConverter,
IAppUsersConverter,
IAppsMessage,
IAppsUser,
} from '@rocket.chat/apps';
import type { IMessage as AppsEngineMessage, IMessageAttachment } from '@rocket.chat/apps-engine/definition/messages';
import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms';
import { isEditedMessage, isFileAttachment } from '@rocket.chat/core-typings';
import type { IUser, IMessage } from '@rocket.chat/core-typings';
import { Messages } from '@rocket.chat/models';
import { cachedFunction } from './cachedFunction';
import { mappedDecodeAsync, type AsyncFieldMap } from './codecs/mappedData';
import { convertMessageFiles } from './convertMessageFiles';
// eslint-disable-next-line @typescript-eslint/naming-convention
interface Orchestrator {
rooms: () => {
convertById: IAppRoomsConverter['convertById'];
};
users: () => {
convertById: IAppUsersConverter['convertById'];
convertToApp: IAppUsersConverter['convertToApp'];
};
}
export class AppThreadsConverter implements IAppThreadsConverter {
constructor(private readonly orch: IAppServerOrchestrator) {
this.orch = orch;
}
async convertById(threadId: string) {
const query = {
$or: [
{
_id: threadId,
},
{
tmid: threadId,
},
],
};
const mainMessage = await Messages.findOneById(threadId);
if (!mainMessage) {
return [];
}
const replies = await Messages.find(query).toArray();
const room = await this.orch.getConverters().get('rooms').convertById(mainMessage.rid);
if (!room) {
return [];
}
const convertToApp = cachedFunction(this.orch.getConverters().get('users').convertToApp.bind(this.orch.getConverters().get('users')));
const convertUserById = cachedFunction(this.orch.getConverters().get('users').convertById.bind(this.orch.getConverters().get('users')));
return Promise.all([mainMessage, ...replies].map((msg) => this.convertMessage(msg, room, convertUserById, convertToApp)));
}
async convertMessage(
msgObj: IMessage,
room: IRoom,
convertUserById: ReturnType<Orchestrator['users']>['convertById'],
convertToApp: ReturnType<Orchestrator['users']>['convertToApp'],
): Promise<AppsEngineMessage> {
const { attachments, file: mainFile } = msgObj;
const map = {
id: '_id',
threadId: 'tmid',
reactions: 'reactions',
parseUrls: 'parseUrls',
text: 'msg',
createdAt: 'ts',
updatedAt: '_updatedAt',
editedAt: 'editedAt',
emoji: 'emoji',
avatarUrl: 'avatar',
alias: 'alias',
file: 'file',
customFields: 'customFields',
groupable: 'groupable',
token: 'token',
blocks: 'blocks',
room: () => room,
editor: async (message: IMessage) => {
if (!isEditedMessage(message)) {
return undefined;
}
const { editedBy } = message;
return convertUserById(editedBy._id);
},
attachments: async (message: IMessage): Promise<IAppsMessage['attachments']> => {
if (!message.attachments) {
return undefined;
}
const result = await this._convertAttachmentsToApp(message.attachments, mainFile);
delete message.attachments;
return result;
},
sender: async (message: IMessage): Promise<IAppsUser> => {
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
if (!message.u?._id) {
return undefined as unknown as IAppsUser;
}
let user: IAppsUser | undefined = await convertUserById(message.u._id);
// When the sender of the message is a Guest (livechat) and not a user
if (!user) {
user = await convertToApp(message.u as unknown as IUser);
}
return user;
},
files: async (message: IMessage) => convertMessageFiles(message.files, attachments),
} as const;
// #TODO: #AppsEngineTypes - Remove explicit types and typecasts once the apps-engine definition/implementation mismatch is fixed.
const msgData = {
...msgObj,
reactions: msgObj.reactions as unknown as AppsEngineMessage['reactions'],
} as IMessage & { reactions?: AppsEngineMessage['reactions'] };
return mappedDecodeAsync<AppsEngineMessage>(msgData, map as unknown as AsyncFieldMap);
}
async _convertAttachmentsToApp(
attachments: NonNullable<IMessage['attachments']>,
mainFile: IMessage['file'],
): Promise<NonNullable<IAppsMessage['attachments']>> {
const map = {
collapsed: 'collapsed',
color: 'color',
text: 'text',
timestampLink: 'message_link',
thumbnailUrl: 'thumb_url',
imageDimensions: 'image_dimensions',
imagePreview: 'image_preview',
imageUrl: 'image_url',
imageType: 'image_type',
imageSize: 'image_size',
audioUrl: 'audio_url',
audioType: 'audio_type',
audioSize: 'audio_size',
videoUrl: 'video_url',
videoType: 'video_type',
videoSize: 'video_size',
fields: 'fields',
actionButtonsAlignment: 'button_alignment',
actions: 'actions',
type: 'type',
description: 'description',
author: (attachment: NonNullable<IMessage['attachments']>[number]): IMessageAttachment['author'] => {
if (!('author_name' in attachment)) {
return;
}
const { author_name: name, author_link: link, author_icon: icon } = attachment;
delete attachment.author_name;
delete attachment.author_link;
delete attachment.author_icon;
return { name, link, icon };
},
title: (attachment: NonNullable<IMessage['attachments']>[number]) => {
const { title: value, title_link: link, title_link_download: displayDownloadLink } = attachment;
delete attachment.title;
delete attachment.title_link;
delete attachment.title_link_download;
return { value, link, displayDownloadLink };
},
timestamp: (attachment: NonNullable<IMessage['attachments']>[number]) => {
const result = attachment.ts ? new Date(attachment.ts) : undefined;
delete attachment.ts;
return result;
},
fileId: (attachment: NonNullable<IMessage['attachments']>[number]) => {
if ('fileId' in attachment && attachment.fileId) {
return attachment.fileId;
}
// If the attachment is missing the fileId, but there's only one file in the message, use that file's ID
if (isFileAttachment(attachment) && mainFile?._id && attachments?.length === 1) {
return mainFile._id;
}
return undefined;
},
} as const;
return Promise.all(
attachments.map(async (attachment) => mappedDecodeAsync(attachment, map as unknown as AsyncFieldMap)),
) as unknown as Promise<NonNullable<IAppsMessage['attachments']>>;
}
}

View File

@@ -0,0 +1,49 @@
import type { IAppServerOrchestrator, IAppUploadsConverter, IAppsUpload } from '@rocket.chat/apps';
import type { IUpload } from '@rocket.chat/core-typings';
import { Uploads } from '@rocket.chat/models';
import * as z from 'zod';
import { createUploadsCodec } from './codecs/uploads';
export class AppUploadsConverter implements IAppUploadsConverter {
private readonly codec: ReturnType<typeof createUploadsCodec>;
constructor(protected readonly orch: IAppServerOrchestrator) {
this.orch = orch;
this.codec = createUploadsCodec(orch);
}
async convertById(id: string): Promise<IAppsUpload | undefined> {
const upload = await Uploads.findOneById(id);
return this.convertToApp(upload);
}
async convertToApp(upload: undefined | null): Promise<undefined>;
async convertToApp(upload: IUpload): Promise<IAppsUpload>;
async convertToApp(upload: IUpload | undefined | null): Promise<IAppsUpload | undefined>;
async convertToApp(upload: IUpload | undefined | null): Promise<IAppsUpload | undefined> {
if (!upload) {
return undefined;
}
return z.decodeAsync(this.codec, upload);
}
convertToRocketChat(upload: undefined | null): undefined;
convertToRocketChat(upload: IAppsUpload): IUpload;
convertToRocketChat(upload: IAppsUpload | undefined | null): IUpload | undefined;
convertToRocketChat(upload: IAppsUpload | undefined | null): IUpload | undefined {
if (!upload) {
return undefined;
}
return z.encode(this.codec, upload);
}
}

View File

@@ -0,0 +1,56 @@
import type { IAppServerOrchestrator, IAppUsersConverter, IAppsUser } from '@rocket.chat/apps';
import type { IUser } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import * as z from 'zod';
import { UserCodec } from './codecs';
export class AppUsersConverter implements IAppUsersConverter {
constructor(protected readonly orch: IAppServerOrchestrator) {
this.orch = orch;
}
async convertById(userId: IUser['_id']): Promise<IAppsUser | undefined> {
const user = await Users.findOneById(userId);
return this.convertToApp(user);
}
async convertByUsername(username: IUser['username']): Promise<IAppsUser | undefined> {
if (!username) {
return undefined;
}
const user = await Users.findOneByUsername(username);
return this.convertToApp(user);
}
convertToApp(user: undefined | null): undefined;
convertToApp(user: IUser): IAppsUser;
convertToApp(user: IUser | undefined | null): IAppsUser | undefined;
convertToApp(user: IUser | undefined | null): IAppsUser | undefined {
if (!user) {
return undefined;
}
return z.decode(UserCodec, user);
}
convertToRocketChat(user: undefined | null): undefined;
convertToRocketChat(user: IAppsUser): IUser;
convertToRocketChat(user: IAppsUser | undefined | null): IUser | undefined;
convertToRocketChat(user: IAppsUser | undefined | null): IUser | undefined {
if (!user) {
return undefined;
}
return z.encode(UserCodec, user);
}
}

View File

@@ -0,0 +1,32 @@
import type { IAppVideoConferencesConverter, AppsVideoConference } from '@rocket.chat/apps';
import { VideoConf } from '@rocket.chat/core-services';
import type { VideoConference } from '@rocket.chat/core-typings';
import * as z from 'zod';
import { VideoConferenceCodec } from './codecs';
export class AppVideoConferencesConverter implements IAppVideoConferencesConverter {
async convertById(callId: string): Promise<AppsVideoConference | undefined> {
const call = await VideoConf.getUnfiltered(callId);
return this.convertVideoConference(call);
}
convertVideoConference(call: undefined | null): undefined;
convertVideoConference(call: VideoConference): AppsVideoConference;
convertVideoConference(call: VideoConference | undefined | null): AppsVideoConference | undefined;
convertVideoConference(call: VideoConference | undefined | null): AppsVideoConference | undefined {
if (!call) {
return;
}
return z.decode(VideoConferenceCodec, call);
}
convertAppVideoConference(call: AppsVideoConference): VideoConference {
return z.encode(VideoConferenceCodec, call);
}
}

View File

@@ -0,0 +1,53 @@
import type { IAppServerOrchestrator, IAppVisitorsConverter, IAppsVisitor } from '@rocket.chat/apps';
import type { ILivechatVisitor } from '@rocket.chat/core-typings';
import { LivechatVisitors } from '@rocket.chat/models';
import * as z from 'zod';
import { VisitorCodec } from './codecs';
// TODO: check if functions from this converter can be async
export class AppVisitorsConverter implements IAppVisitorsConverter {
constructor(protected readonly orch: IAppServerOrchestrator) {
this.orch = orch;
}
async convertById(id: ILivechatVisitor['_id']): Promise<IAppsVisitor | undefined> {
const visitor = await LivechatVisitors.findOneEnabledById(id);
return this.convertVisitor(visitor);
}
async convertByToken(token: string): Promise<IAppsVisitor | undefined> {
const visitor = await LivechatVisitors.getVisitorByToken(token);
return this.convertVisitor(visitor);
}
async convertVisitor(visitor: undefined | null): Promise<undefined>;
async convertVisitor(visitor: ILivechatVisitor): Promise<IAppsVisitor>;
async convertVisitor(visitor: ILivechatVisitor | undefined | null): Promise<IAppsVisitor | undefined>;
async convertVisitor(visitor: ILivechatVisitor | undefined | null): Promise<IAppsVisitor | undefined> {
if (!visitor) {
return undefined;
}
return z.decode(VisitorCodec, visitor);
}
convertAppVisitor(visitor: undefined | null): undefined;
convertAppVisitor(visitor: IAppsVisitor): ILivechatVisitor;
convertAppVisitor(visitor: IAppsVisitor | undefined | null): ILivechatVisitor | undefined;
convertAppVisitor(visitor: IAppsVisitor | undefined | null): ILivechatVisitor | undefined {
if (!visitor) {
return undefined;
}
return z.encode(VisitorCodec, visitor);
}
}

View File

@@ -0,0 +1,58 @@
const restrictedRolePermissions = new Map<string, Set<string>>();
export const AuthorizationUtils = class {
static addRolePermissionWhiteList(roleId: string, list: string[]): void {
if (!roleId) {
throw new Error('invalid-param');
}
if (!list) {
throw new Error('invalid-param');
}
if (!restrictedRolePermissions.has(roleId)) {
restrictedRolePermissions.set(roleId, new Set());
}
const rules = restrictedRolePermissions.get(roleId);
for (const permissionId of list) {
rules?.add(permissionId);
}
}
static isPermissionRestrictedForRole(permissionId: string, roleId: string): boolean {
if (!roleId || !permissionId) {
throw new Error('invalid-param');
}
if (!restrictedRolePermissions.has(roleId)) {
return false;
}
const rules = restrictedRolePermissions.get(roleId);
if (!rules?.size) {
return false;
}
return !rules.has(permissionId);
}
static isPermissionRestrictedForRoleList(permissionId: string, roleList: string[]): boolean {
if (!roleList || !permissionId) {
throw new Error('invalid-param');
}
for (const roleId of roleList) {
if (this.isPermissionRestrictedForRole(permissionId, roleId)) {
return true;
}
}
return false;
}
static hasRestrictionsToRole(roleId: string): boolean {
return restrictedRolePermissions.has(roleId);
}
};

View File

@@ -0,0 +1,101 @@
import type { IPermission, IRole, IUser } from '@rocket.chat/core-typings';
import { AuthorizationUtils } from './AuthorizationUtils';
export type AuthorizationDeps = {
/** The currently logged-in user id, or undefined. */
getCurrentUserId: () => IUser['_id'] | undefined;
/** The role ids assigned to a given user (Users scope). */
getUserRoles: (userId: IUser['_id']) => IRole['_id'][] | undefined;
/** Lookup a permission by id. */
getPermission: (permissionId: IPermission['_id']) => IPermission | undefined;
/** The scope of a role; defaults to 'Users' when the role is unknown. */
getRoleScope: (roleId: IRole['_id']) => IRole['scope'] | undefined;
/** Whether a subscription scoped to `rid` grants `roleId`. */
hasSubscriptionRole: (rid: string, roleId: IRole['_id']) => boolean;
/** Whether the permissions cache is hydrated; otherwise checks short-circuit to false. */
isReady: () => boolean;
};
export type AuthorizationFunctions = {
hasRole: (userId: IUser['_id'], roleId: IRole['_id'], scope?: string) => boolean;
hasAllPermission: (permissions: IPermission['_id'] | IPermission['_id'][], scope?: string, scopedRoles?: IRole['_id'][]) => boolean;
hasAtLeastOnePermission: (permissions: IPermission['_id'] | IPermission['_id'][], scope?: string) => boolean;
/** Alias of hasAllPermission, kept for parity with the previous API. */
hasPermission: (permissions: IPermission['_id'] | IPermission['_id'][], scope?: string, scopedRoles?: IRole['_id'][]) => boolean;
userHasAllPermission: (
permissions: IPermission['_id'] | IPermission['_id'][],
scope: string | undefined,
userId: IUser['_id'],
) => boolean;
};
/**
* Pure factory for the client-side authorization helpers. All store access is
* threaded through the {@link AuthorizationDeps} accessors, so the returned
* functions are testable in isolation and reusable across any state backend.
*/
export const createAuthorizationFunctions = (deps: AuthorizationDeps): AuthorizationFunctions => {
const hasRole = (userId: IUser['_id'], roleId: IRole['_id'], scope?: string): boolean => {
const roleScope = deps.getRoleScope(roleId) ?? 'Users';
switch (roleScope) {
case 'Subscriptions':
if (!scope) return false;
return deps.hasSubscriptionRole(scope, roleId);
case 'Users':
return deps.getUserRoles(userId)?.includes(roleId) ?? false;
default:
return false;
}
};
const checkPermissions = (
permissionIds: IPermission['_id'][],
scope: string | undefined,
userId: IUser['_id'],
scopedRoles: IRole['_id'][] | undefined,
quantifier: (this: IPermission['_id'][], predicate: (id: IPermission['_id']) => boolean) => boolean,
): boolean => {
const userRoles = deps.getUserRoles(userId);
return quantifier.call(permissionIds, (permissionId) => {
if (userRoles && AuthorizationUtils.isPermissionRestrictedForRoleList(permissionId, userRoles)) {
return false;
}
const roles = deps.getPermission(permissionId)?.roles ?? [];
return roles.some((roleId) => {
if (scopedRoles?.includes(roleId)) return true;
return hasRole(userId, roleId, scope);
});
});
};
const validatePermissions = (
permissions: IPermission['_id'] | IPermission['_id'][],
scope: string | undefined,
quantifier: (this: IPermission['_id'][], predicate: (id: IPermission['_id']) => boolean) => boolean,
userId: IUser['_id'] | undefined,
scopedRoles?: IRole['_id'][],
): boolean => {
if (!userId) return false;
if (!deps.isReady()) return false;
const ids = ([] as IPermission['_id'][]).concat(permissions);
return checkPermissions(ids, scope, userId, scopedRoles, quantifier);
};
const hasAllPermission: AuthorizationFunctions['hasAllPermission'] = (permissions, scope, scopedRoles) =>
validatePermissions(permissions, scope, Array.prototype.every, deps.getCurrentUserId(), scopedRoles);
const hasAtLeastOnePermission: AuthorizationFunctions['hasAtLeastOnePermission'] = (permissions, scope) =>
validatePermissions(permissions, scope, Array.prototype.some, deps.getCurrentUserId());
const userHasAllPermission: AuthorizationFunctions['userHasAllPermission'] = (permissions, scope, userId) =>
validatePermissions(permissions, scope, Array.prototype.every, userId);
return {
hasRole,
hasAllPermission,
hasAtLeastOnePermission,
hasPermission: hasAllPermission,
userHasAllPermission,
};
};

View File

@@ -0,0 +1,14 @@
import type { ISetting } from '@rocket.chat/core-typings';
export const getSettingPermissionId = function (settingId: ISetting['_id']) {
// setting-based permissions
return `change-setting-${settingId}`;
};
export const CONSTANTS = {
SETTINGS_LEVEL: 'settings',
} as const;
export const confirmationRequiredPermissions = ['access-permissions'];
export { AuthorizationUtils } from './AuthorizationUtils';

Some files were not shown because too many files have changed in this diff Show More