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
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s

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

View File

@@ -0,0 +1,25 @@
export type Maybe<T> = T | null | undefined;
export const AppObjectRegistry = new (class {
registry: Record<string, unknown> = {};
public get<T>(key: string): Maybe<T> {
return this.registry[key] as Maybe<T>;
}
public set(key: string, value: unknown): void {
this.registry[key] = value;
}
public has(key: string): boolean {
return key in this.registry;
}
public delete(key: string): void {
delete this.registry[key];
}
public clear(): void {
this.registry = {};
}
})();

View File

@@ -0,0 +1,51 @@
import type { IApiEndpoint } from '@rocket.chat/apps-engine/definition/api/IApiEndpoint';
import type { Defined } from 'jsonrpc-lite';
import { JsonRpcError } from 'jsonrpc-lite';
import { AppObjectRegistry } from '../AppObjectRegistry';
import { AppAccessorsInstance } from '../lib/accessors/mod';
import type { RequestContext } from '../lib/requestContext';
import { wrapComposedApp } from '../lib/wrapAppForRequest';
export default async function apiHandler(request: RequestContext): Promise<JsonRpcError | Defined> {
const { method: call, params } = request;
const [, /* always "api" */ ...parts] = call.split(':');
const httpMethod = parts.pop();
const path = parts.join(':');
const endpoint = AppObjectRegistry.get<IApiEndpoint>(`api:${path}`);
const { logger } = request.context;
if (!endpoint) {
return new JsonRpcError(`Endpoint ${path} not found`, -32000);
}
const method = endpoint[httpMethod as keyof IApiEndpoint];
if (typeof method !== 'function') {
return new JsonRpcError(`${path}'s ${httpMethod} not exists`, -32000);
}
const [requestData, endpointInfo] = params as Array<unknown>;
logger.debug(`${path}'s ${call} is being executed...`, requestData);
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
const result = await (method as Function).apply(wrapComposedApp(endpoint, request), [
requestData,
endpointInfo,
AppAccessorsInstance.getReader(),
AppAccessorsInstance.getModifier(),
AppAccessorsInstance.getHttp(),
AppAccessorsInstance.getPersistence(),
]);
logger.debug(`${path}'s ${call} was successfully executed.`);
return result;
} catch (e) {
logger.debug(`${path}'s ${call} was unsuccessful.`);
return new JsonRpcError(e.message || 'Internal server error', -32000);
}
}

View File

@@ -0,0 +1,130 @@
import type { IParseAppPackageResult } from '@rocket.chat/apps/dist/server/compiler/IParseAppPackageResult';
import type { App } from '@rocket.chat/apps-engine/definition/App';
import { AppObjectRegistry } from '../../AppObjectRegistry';
import { AppAccessorsInstance } from '../../lib/accessors/mod';
import type { RequestContext } from '../../lib/requestContext';
import { sanitizeDeprecatedUsage } from '../../lib/sanitizeDeprecatedUsage';
/**
* A platform-dependent `require` used to resolve the modules an app is allowed
* to load (native `node:` modules, a small allow-list of npm packages, and
* apps-engine files). Each runtime injects its own via {@link setSandboxRequire}
* — Node hands over its global `require`, Deno hands over its `createRequire`
* shim that knows how to resolve compiled apps-engine paths.
*/
type SandboxRequire = (module: string) => unknown;
function defaultSandboxRequire(): never {
throw new Error('No sandbox require has been injected; the runtime adapter must call setSandboxRequire() during bootstrap');
}
let sandboxRequire: SandboxRequire = defaultSandboxRequire;
export function setSandboxRequire(newRequire: SandboxRequire): void {
sandboxRequire = newRequire;
}
/**
* Extra globals bound into the app's eval shell on top of the common ones
* (`exports`, `module`, `require`, `console`, `globalThis`). Node needs none;
* Deno injects a `Buffer` and shadows `Deno` with `undefined`. Injecting them
* as data keeps the eval-shell skeleton single-source.
*/
type SandboxGlobals = Record<string, unknown>;
let sandboxGlobals: SandboxGlobals = {};
export function setSandboxGlobals(globals: SandboxGlobals): void {
sandboxGlobals = globals;
}
function wrapAppCode(code: string): (require: SandboxRequire) => Promise<Record<string, unknown>> {
const globals = sandboxGlobals;
// The common globals are bound by name; any platform-specific extras are
// spread in by name from the injected `sandboxGlobals`, so the shell
// skeleton stays identical across runtimes.
const extraNames = Object.keys(globals);
const extraParams = extraNames.length ? `,${extraNames.join(',')}` : '';
const extraArgs = extraNames.map((name) => `,__globals[${JSON.stringify(name)}]`).join('');
// eslint-disable-next-line @typescript-eslint/no-implied-eval -- This is the reason we run in a separate process
const fn = new Function(
'require',
'__globals',
`
const exports = {};
const module = { exports };
const _error = console.error.bind(console);
const _console = {
log: _error,
error: _error,
debug: _error,
info: _error,
warn: _error,
};
const result = (async (exports,module,require,console,globalThis${extraParams}) => {
${code};
})(exports,module,require,_console,undefined${extraArgs});
return result.then(() => module.exports);`,
) as (require: SandboxRequire, globals: SandboxGlobals) => Promise<Record<string, unknown>>;
return (require: SandboxRequire) => fn(require, globals);
}
type AppConstructor = new (...args: unknown[]) => App;
export default async function handleConstructApp(request: RequestContext): Promise<boolean> {
const { params } = request;
if (!Array.isArray(params)) {
throw new Error('Invalid params', { cause: 'invalid_param_type' });
}
const [appPackage] = params as [IParseAppPackageResult];
if (!appPackage?.info?.id || !appPackage?.info?.classFile || !appPackage?.files) {
throw new Error('Invalid params', { cause: 'invalid_param_type' });
}
AppObjectRegistry.set('id', appPackage.info.id);
const source = sanitizeDeprecatedUsage(appPackage.files[appPackage.info.classFile]);
const exports = await wrapAppCode(source)(sandboxRequire);
// This is the same naive logic we've been using in the App Compiler
// Applying the correct type here is quite difficult because of the dynamic nature of the code
const appClass = Object.values(exports)[0] as AppConstructor;
const app = new appClass(appPackage.info, request.context.logger, AppAccessorsInstance.getDefaultAppAccessors());
if (typeof app.getName !== 'function') {
throw new Error('App must contain a getName function');
}
if (typeof app.getNameSlug !== 'function') {
throw new Error('App must contain a getNameSlug function');
}
if (typeof app.getVersion !== 'function') {
throw new Error('App must contain a getVersion function');
}
if (typeof app.getID !== 'function') {
throw new Error('App must contain a getID function');
}
if (typeof app.getDescription !== 'function') {
throw new Error('App must contain a getDescription function');
}
if (typeof app.getRequiredApiVersion !== 'function') {
throw new Error('App must contain a getRequiredApiVersion function');
}
AppObjectRegistry.set('app', app);
return true;
}

View File

@@ -0,0 +1,16 @@
import type { App } from '@rocket.chat/apps-engine/definition/App';
import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
import { AppObjectRegistry } from '../../AppObjectRegistry';
export default function handleGetStatus(): Promise<AppStatus> {
const app = AppObjectRegistry.get<App>('app');
if (typeof app?.getStatus !== 'function') {
throw new Error('App must contain a getStatus function', {
cause: 'invalid_app',
});
}
return app.getStatus();
}

View File

@@ -0,0 +1,24 @@
import type { App } from '@rocket.chat/apps-engine/definition/App';
import { AppObjectRegistry } from '../../AppObjectRegistry';
import { AppAccessorsInstance } from '../../lib/accessors/mod';
import type { RequestContext } from '../../lib/requestContext';
import { wrapAppForRequest } from '../../lib/wrapAppForRequest';
export default async function handleInitialize(request: RequestContext): Promise<boolean> {
const app = AppObjectRegistry.get<App>('app');
if (typeof app?.initialize !== 'function') {
throw new Error('App must contain an initialize function', {
cause: 'invalid_app',
});
}
await app.initialize.call(
wrapAppForRequest(app, request),
AppAccessorsInstance.getConfigurationExtend(),
AppAccessorsInstance.getEnvironmentRead(),
);
return true;
}

View File

@@ -0,0 +1,20 @@
import type { App } from '@rocket.chat/apps-engine/definition/App';
import { AppObjectRegistry } from '../../AppObjectRegistry';
import { AppAccessorsInstance } from '../../lib/accessors/mod';
import type { RequestContext } from '../../lib/requestContext';
import { wrapAppForRequest } from '../../lib/wrapAppForRequest';
export default async function handleOnDisable(request: RequestContext): Promise<boolean> {
const app = AppObjectRegistry.get<App>('app');
if (typeof app?.onDisable !== 'function') {
throw new Error('App must contain an onDisable function', {
cause: 'invalid_app',
});
}
await app.onDisable.call(wrapAppForRequest(app, request), AppAccessorsInstance.getConfigurationModify());
return true;
}

View File

@@ -0,0 +1,22 @@
import type { App } from '@rocket.chat/apps-engine/definition/App';
import { AppObjectRegistry } from '../../AppObjectRegistry';
import { AppAccessorsInstance } from '../../lib/accessors/mod';
import type { RequestContext } from '../../lib/requestContext';
import { wrapAppForRequest } from '../../lib/wrapAppForRequest';
export default function handleOnEnable(request: RequestContext): Promise<boolean> {
const app = AppObjectRegistry.get<App>('app');
if (typeof app?.onEnable !== 'function') {
throw new Error('App must contain an onEnable function', {
cause: 'invalid_app',
});
}
return app.onEnable.call(
wrapAppForRequest(app, request),
AppAccessorsInstance.getEnvironmentRead(),
AppAccessorsInstance.getConfigurationModify(),
);
}

View File

@@ -0,0 +1,34 @@
import type { App } from '@rocket.chat/apps-engine/definition/App';
import { AppObjectRegistry } from '../../AppObjectRegistry';
import { AppAccessorsInstance } from '../../lib/accessors/mod';
import type { RequestContext } from '../../lib/requestContext';
import { wrapAppForRequest } from '../../lib/wrapAppForRequest';
export default async function handleOnInstall(request: RequestContext): Promise<boolean> {
const { params } = request;
const app = AppObjectRegistry.get<App>('app');
if (typeof app?.onInstall !== 'function') {
throw new Error('App must contain an onInstall function', {
cause: 'invalid_app',
});
}
if (!Array.isArray(params)) {
throw new Error('Invalid params', { cause: 'invalid_param_type' });
}
const [context] = params as [Record<string, unknown>];
await app.onInstall.call(
wrapAppForRequest(app, request),
context,
AppAccessorsInstance.getReader(),
AppAccessorsInstance.getHttp(),
AppAccessorsInstance.getPersistence(),
AppAccessorsInstance.getModifier(),
);
return true;
}

View File

@@ -0,0 +1,31 @@
import type { App } from '@rocket.chat/apps-engine/definition/App';
import { AppObjectRegistry } from '../../AppObjectRegistry';
import { AppAccessorsInstance } from '../../lib/accessors/mod';
import type { RequestContext } from '../../lib/requestContext';
import { wrapAppForRequest } from '../../lib/wrapAppForRequest';
export default function handleOnPreSettingUpdate(request: RequestContext): Promise<object> {
const { params } = request;
const app = AppObjectRegistry.get<App>('app');
if (typeof app?.onPreSettingUpdate !== 'function') {
throw new Error('App must contain an onPreSettingUpdate function', {
cause: 'invalid_app',
});
}
if (!Array.isArray(params)) {
throw new Error('Invalid params', { cause: 'invalid_param_type' });
}
const [setting] = params as [Record<string, unknown>];
return app.onPreSettingUpdate.call(
wrapAppForRequest(app, request),
setting,
AppAccessorsInstance.getConfigurationModify(),
AppAccessorsInstance.getReader(),
AppAccessorsInstance.getHttp(),
);
}

View File

@@ -0,0 +1,33 @@
import type { App } from '@rocket.chat/apps-engine/definition/App';
import { AppObjectRegistry } from '../../AppObjectRegistry';
import { AppAccessorsInstance } from '../../lib/accessors/mod';
import type { RequestContext } from '../../lib/requestContext';
import { wrapAppForRequest } from '../../lib/wrapAppForRequest';
export default async function handleOnSettingUpdated(request: RequestContext): Promise<boolean> {
const { params } = request;
const app = AppObjectRegistry.get<App>('app');
if (typeof app?.onSettingUpdated !== 'function') {
throw new Error('App must contain an onSettingUpdated function', {
cause: 'invalid_app',
});
}
if (!Array.isArray(params)) {
throw new Error('Invalid params', { cause: 'invalid_param_type' });
}
const [setting] = params as [Record<string, unknown>];
await app.onSettingUpdated.call(
wrapAppForRequest(app, request),
setting,
AppAccessorsInstance.getConfigurationModify(),
AppAccessorsInstance.getReader(),
AppAccessorsInstance.getHttp(),
);
return true;
}

View File

@@ -0,0 +1,34 @@
import type { App } from '@rocket.chat/apps-engine/definition/App';
import { AppObjectRegistry } from '../../AppObjectRegistry';
import { AppAccessorsInstance } from '../../lib/accessors/mod';
import type { RequestContext } from '../../lib/requestContext';
import { wrapAppForRequest } from '../../lib/wrapAppForRequest';
export default async function handleOnUninstall(request: RequestContext): Promise<boolean> {
const { params } = request;
const app = AppObjectRegistry.get<App>('app');
if (typeof app?.onUninstall !== 'function') {
throw new Error('App must contain an onUninstall function', {
cause: 'invalid_app',
});
}
if (!Array.isArray(params)) {
throw new Error('Invalid params', { cause: 'invalid_param_type' });
}
const [context] = params as [Record<string, unknown>];
await app.onUninstall.call(
wrapAppForRequest(app, request),
context,
AppAccessorsInstance.getReader(),
AppAccessorsInstance.getHttp(),
AppAccessorsInstance.getPersistence(),
AppAccessorsInstance.getModifier(),
);
return true;
}

View File

@@ -0,0 +1,34 @@
import type { App } from '@rocket.chat/apps-engine/definition/App';
import { AppObjectRegistry } from '../../AppObjectRegistry';
import { AppAccessorsInstance } from '../../lib/accessors/mod';
import type { RequestContext } from '../../lib/requestContext';
import { wrapAppForRequest } from '../../lib/wrapAppForRequest';
export default async function handleOnUpdate(request: RequestContext): Promise<boolean> {
const { params } = request;
const app = AppObjectRegistry.get<App>('app');
if (typeof app?.onUpdate !== 'function') {
throw new Error('App must contain an onUpdate function', {
cause: 'invalid_app',
});
}
if (!Array.isArray(params)) {
throw new Error('Invalid params', { cause: 'invalid_param_type' });
}
const [context] = params as [Record<string, unknown>];
await app.onUpdate.call(
wrapAppForRequest(app, request),
context,
AppAccessorsInstance.getReader(),
AppAccessorsInstance.getHttp(),
AppAccessorsInstance.getPersistence(),
AppAccessorsInstance.getModifier(),
);
return true;
}

View File

@@ -0,0 +1,28 @@
import type { App } from '@rocket.chat/apps-engine/definition/App';
import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
import { AppObjectRegistry } from '../../AppObjectRegistry';
import type { RequestContext } from '../../lib/requestContext';
import { wrapAppForRequest } from '../../lib/wrapAppForRequest';
export default async function handleSetStatus(request: RequestContext): Promise<null> {
const { params } = request;
if (!Array.isArray(params) || !Object.values(AppStatus).includes(params[0])) {
throw new Error('Invalid params', { cause: 'invalid_param_type' });
}
const [status] = params as [typeof AppStatus];
const app = AppObjectRegistry.get<App>('app');
if (!app || typeof (app as unknown as Record<string, unknown>).setStatus !== 'function') {
throw new Error('App must contain a setStatus function', {
cause: 'invalid_app',
});
}
await (app as unknown as { setStatus(status: typeof AppStatus): Promise<void> }).setStatus.call(wrapAppForRequest(app, request), status);
return null;
}

View File

@@ -0,0 +1,81 @@
import { Buffer } from 'node:buffer';
import { readFile } from 'node:fs/promises';
import type { App } from '@rocket.chat/apps-engine/definition/App';
import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions/AppsEngineException';
import type { IFileUploadContext } from '@rocket.chat/apps-engine/definition/uploads/IFileUploadContext';
import type { IUploadDetails } from '@rocket.chat/apps-engine/definition/uploads/IUploadDetails';
import type { Defined } from 'jsonrpc-lite';
import { JsonRpcError } from 'jsonrpc-lite';
import { AppObjectRegistry } from '../../AppObjectRegistry';
import { AppAccessorsInstance } from '../../lib/accessors/mod';
import type { RequestContext } from '../../lib/requestContext';
import { wrapAppForRequest } from '../../lib/wrapAppForRequest';
import { assertAppAvailable, assertHandlerFunction, isPlainObject } from '../lib/assertions';
export const uploadEvents = ['executePreFileUpload'] as const;
function assertIsUpload(v: unknown): asserts v is IUploadDetails {
if (isPlainObject(v) && !!v.rid && (!!v.userId || !!v.visitorToken)) return;
throw JsonRpcError.invalidParams({ err: `Invalid 'file' parameter. Expected IUploadDetails, got`, value: v });
}
function assertString(v: unknown): asserts v is string {
if (v && typeof v === 'string') return;
throw JsonRpcError.invalidParams({ err: `Invalid 'path' parameter. Expected string, got`, value: v });
}
export default async function handleUploadEvents(request: RequestContext): Promise<Defined | JsonRpcError> {
const { method: rawMethod, params } = request as {
method: `app:${(typeof uploadEvents)[number]}`;
params: [{ file?: IUploadDetails; path?: string }];
};
const [, method] = rawMethod.split(':') as ['app', (typeof uploadEvents)[number]];
try {
const [{ file, path }] = params;
const app = AppObjectRegistry.get<App>('app');
const handlerFunction = app?.[method as keyof App] as unknown;
assertAppAvailable(app);
assertHandlerFunction(handlerFunction);
assertIsUpload(file);
assertString(path);
let context: IFileUploadContext;
switch (method) {
case 'executePreFileUpload': {
const fileContents = await readFile(path);
context = { file, content: Buffer.from(fileContents) };
break;
}
}
return await handlerFunction.call(
wrapAppForRequest(app, request),
context,
AppAccessorsInstance.getReader(),
AppAccessorsInstance.getHttp(),
AppAccessorsInstance.getPersistence(),
AppAccessorsInstance.getModifier(),
);
} catch (e) {
if (e?.name === AppsEngineException.name) {
return new JsonRpcError(e.message, AppsEngineException.JSONRPC_ERROR_CODE, { name: e.name });
}
if (e instanceof JsonRpcError) {
return e;
}
return JsonRpcError.internalError({
err: e.message,
...(e.code && { code: e.code }),
});
}
}

View File

@@ -0,0 +1,116 @@
import type { Defined } from 'jsonrpc-lite';
import { JsonRpcError } from 'jsonrpc-lite';
import handleConstructApp from './construct';
import handleGetStatus from './handleGetStatus';
import handleInitialize from './handleInitialize';
import handleOnDisable from './handleOnDisable';
import handleOnEnable from './handleOnEnable';
import handleOnInstall from './handleOnInstall';
import handleOnPreSettingUpdate from './handleOnPreSettingUpdate';
import handleOnSettingUpdated from './handleOnSettingUpdated';
import handleOnUninstall from './handleOnUninstall';
import handleOnUpdate from './handleOnUpdate';
import handleSetStatus from './handleSetStatus';
import handleUploadEvents, { uploadEvents } from './handleUploadEvents';
import type { RequestContext } from '../../lib/requestContext';
import { isOneOf } from '../lib/assertions';
import handleListener from '../listener/handler';
import handleUIKitInteraction, { uikitInteractions } from '../uikit/handler';
export default async function handleApp(request: RequestContext): Promise<Defined | JsonRpcError> {
const { method } = request;
const { logger } = request.context;
const [, appMethod] = method.split(':');
try {
// We don't want the getStatus method to generate logs, so we handle it separately
if (appMethod === 'getStatus') {
return await handleGetStatus();
}
logger.debug({ msg: `A method is being called...`, appMethod });
const formatResult = (result: Defined | JsonRpcError): Defined | JsonRpcError => {
if (result instanceof JsonRpcError) {
logger.debug({
msg: `'${appMethod}' was unsuccessful.`,
appMethod,
err: result,
errorMessage: result.message,
});
} else {
logger.debug({
msg: `'${appMethod}' was successfully called! The result is:`,
appMethod,
result,
});
}
return result;
};
let result: Promise<Defined | JsonRpcError> | undefined = undefined;
if (isOneOf(appMethod, uploadEvents)) {
result = handleUploadEvents(request);
} else if (isOneOf(appMethod, uikitInteractions)) {
result = handleUIKitInteraction(request);
} else if (appMethod.startsWith('check') || appMethod.startsWith('execute')) {
result = handleListener(request);
}
switch (appMethod) {
case 'construct':
result = handleConstructApp(request);
break;
case 'initialize':
result = handleInitialize(request);
break;
case 'setStatus':
result = handleSetStatus(request);
break;
case 'onEnable':
result = handleOnEnable(request);
break;
case 'onDisable':
result = handleOnDisable(request);
break;
case 'onInstall':
result = handleOnInstall(request);
break;
case 'onUninstall':
result = handleOnUninstall(request);
break;
case 'onPreSettingUpdate':
result = handleOnPreSettingUpdate(request);
break;
case 'onSettingUpdated':
result = handleOnSettingUpdated(request);
break;
case 'onUpdate':
result = handleOnUpdate(request);
break;
}
if (typeof result === 'undefined') {
throw new JsonRpcError(`Unknown method "${appMethod}"`, -32601);
}
return await result.then(formatResult);
} catch (e: unknown) {
if (!(e instanceof Error)) {
return new JsonRpcError('Unknown error', -32000, e);
}
if ((e.cause as string)?.includes('invalid_param_type')) {
return JsonRpcError.invalidParams(null);
}
if ((e.cause as string)?.includes('invalid_app')) {
return JsonRpcError.internalError({ message: 'App unavailable' });
}
return new JsonRpcError(e.message, -32000, e);
}
}

View File

@@ -0,0 +1,51 @@
import type { App } from '@rocket.chat/apps-engine/definition/App';
import { JsonRpcError } from 'jsonrpc-lite';
/**
* Known failures that can happen in the runtime.
*
* DRT = Deno RunTime
*/
export const Errors = {
DRT_APP_NOT_AVAILABLE: 'DRT_APP_NOT_AVAILABLE',
DRT_EVENT_HANDLER_FUNCTION_MISSING: 'DRT_EVENT_HANDLER_FUNCTION_MISSING',
};
export function isRecord(v: unknown): v is Record<string, unknown> {
return !!v && typeof v === 'object' && !Array.isArray(v);
}
export function isPlainObject(v: unknown): v is Record<string, unknown> {
if (!isRecord(v)) {
return false;
}
const prototype = Object.getPrototypeOf(v);
return prototype === null || prototype.constructor === Object;
}
/**
* Type guard function to check if a value is included in a readonly array
* and narrow its type accordingly.
*/
export function isOneOf<T>(value: unknown, array: readonly T[]): value is T {
return array.includes(value as T);
}
export function isApp(v: unknown): v is App {
return !!v && typeof (v as unknown as Record<string, unknown>).extendConfiguration === 'function';
}
export function assertAppAvailable(v: unknown): asserts v is App {
if (isApp(v)) return;
throw JsonRpcError.internalError({ err: 'App object not available', code: Errors.DRT_APP_NOT_AVAILABLE });
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
export function assertHandlerFunction(v: unknown): asserts v is Function {
if (v instanceof Function) return;
throw JsonRpcError.internalError({ err: `Expected handler function, got ${v}`, code: Errors.DRT_EVENT_HANDLER_FUNCTION_MISSING });
}

View File

@@ -0,0 +1,153 @@
import type { App } from '@rocket.chat/apps-engine/definition/App';
import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions/AppsEngineException';
import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage';
import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom';
import type { Defined } from 'jsonrpc-lite';
import { JsonRpcError } from 'jsonrpc-lite';
import { AppObjectRegistry } from '../../AppObjectRegistry';
import { MessageBuilder } from '../../lib/accessors/builders/MessageBuilder';
import { RoomBuilder } from '../../lib/accessors/builders/RoomBuilder';
import { MessageExtender } from '../../lib/accessors/extenders/MessageExtender';
import { RoomExtender } from '../../lib/accessors/extenders/RoomExtender';
import type { AppAccessors } from '../../lib/accessors/mod';
import { AppAccessorsInstance } from '../../lib/accessors/mod';
import type { RequestContext } from '../../lib/requestContext';
import { Room } from '../../lib/room';
import createRoom from '../../lib/roomFactory';
import { wrapAppForRequest } from '../../lib/wrapAppForRequest';
export default async function handleListener(request: RequestContext): Promise<Defined | JsonRpcError> {
const { method, params } = request;
const [, evtInterface] = method.split(':');
const app = AppObjectRegistry.get<App>('app');
const eventExecutor = app?.[evtInterface as keyof App];
if (!app || typeof eventExecutor !== 'function') {
return JsonRpcError.methodNotFound({
message: 'Invalid event interface called on app',
});
}
if (!Array.isArray(params) || params.length < 1 || params.length > 2) {
return JsonRpcError.invalidParams(null);
}
try {
const args = parseArgs({ AppAccessorsInstance }, evtInterface, params);
return await (eventExecutor as (...args: unknown[]) => Promise<Defined>).apply(wrapAppForRequest(app, request), args);
} catch (e) {
if (e instanceof JsonRpcError) {
return e;
}
if (e instanceof AppsEngineException) {
return new JsonRpcError(e.message, AppsEngineException.JSONRPC_ERROR_CODE, { name: e.name });
}
return JsonRpcError.internalError({ message: e.message });
}
}
export function parseArgs(deps: { AppAccessorsInstance: AppAccessors }, evtMethod: string, params: unknown[]): unknown[] {
const { AppAccessorsInstance } = deps;
/**
* param1 is the context for the event handler execution
* param2 is an optional extra content that some hanlers require
*/
const [param1, param2] = params as [unknown, unknown];
if (!param1) {
throw JsonRpcError.invalidParams(null);
}
let context = param1;
if (evtMethod.includes('Message')) {
context = hydrateMessageObjects(context) as Record<string, unknown>;
} else if (evtMethod.endsWith('RoomUserJoined') || evtMethod.endsWith('RoomUserLeave')) {
(context as Record<string, unknown>).room = createRoom(
(context as Record<string, unknown>).room as IRoom,
AppAccessorsInstance.getSenderFn(),
);
} else if (evtMethod.includes('PreRoom')) {
context = createRoom(context as IRoom, AppAccessorsInstance.getSenderFn());
}
const args: unknown[] = [context, AppAccessorsInstance.getReader(), AppAccessorsInstance.getHttp()];
// "check" events will only go this far - (context, reader, http)
if (evtMethod.startsWith('check')) {
// "checkPostMessageDeleted" has an extra param - (context, reader, http, extraContext)
if (param2) {
args.push(hydrateMessageObjects(param2));
}
return args;
}
// From this point on, all events will require (reader, http, persistence) injected
args.push(AppAccessorsInstance.getPersistence());
// "extend" events have an additional "Extender" param - (context, extender, reader, http, persistence)
if (evtMethod.endsWith('Extend')) {
if (evtMethod.includes('Message')) {
args.splice(1, 0, new MessageExtender(param1 as IMessage));
} else if (evtMethod.includes('Room')) {
args.splice(1, 0, new RoomExtender(param1 as IRoom));
}
return args;
}
// "Modify" events have an additional "Builder" param - (context, builder, reader, http, persistence)
if (evtMethod.endsWith('Modify')) {
if (evtMethod.includes('Message')) {
args.splice(1, 0, new MessageBuilder(param1 as IMessage));
} else if (evtMethod.includes('Room')) {
args.splice(1, 0, new RoomBuilder(param1 as IRoom));
}
return args;
}
// From this point on, all events will require (reader, http, persistence, modifier) injected
args.push(AppAccessorsInstance.getModifier());
// This guy gets an extra one
if (evtMethod === 'executePostMessageDeleted') {
if (!param2) {
throw JsonRpcError.invalidParams(null);
}
args.push(hydrateMessageObjects(param2));
}
return args;
}
/**
* Hydrate the context object with the correct IMessage
*
* Some information is lost upon serializing the data from listeners through the pipes,
* so here we hydrate the complete object as necessary
*/
function hydrateMessageObjects(context: unknown): unknown {
if (objectIsRawMessage(context)) {
context.room = createRoom(context.room as IRoom, AppAccessorsInstance.getSenderFn()) as unknown as IRoom;
} else if ((context as Record<string, unknown>)?.message) {
(context as Record<string, unknown>).message = hydrateMessageObjects((context as Record<string, unknown>).message);
}
return context;
}
function objectIsRawMessage(value: unknown): value is IMessage {
if (!value) return false;
const { id, room, sender, createdAt } = value as Record<string, unknown>;
// Check if we have the fields of a message and the room hasn't already been hydrated
return !!(id && room && sender && createdAt) && !(room instanceof Room);
}

View File

@@ -0,0 +1,38 @@
import type { IOutboundMessageProviders } from '@rocket.chat/apps-engine/definition/outboundCommunication/IOutboundCommsProvider';
import type { Defined } from 'jsonrpc-lite';
import { JsonRpcError } from 'jsonrpc-lite';
import { AppObjectRegistry } from '../AppObjectRegistry';
import { AppAccessorsInstance } from '../lib/accessors/mod';
import type { RequestContext } from '../lib/requestContext';
import { wrapComposedApp } from '../lib/wrapAppForRequest';
export default async function outboundMessageHandler(request: RequestContext): Promise<JsonRpcError | Defined> {
const { method: call, params } = request;
const [, providerName, methodName] = call.split(':');
const provider = AppObjectRegistry.get<IOutboundMessageProviders>(`outboundCommunication:${providerName}`);
if (!provider) {
return new JsonRpcError('error-invalid-provider', -32000);
}
const method = provider[methodName as keyof IOutboundMessageProviders];
const { logger } = request.context;
const args = (params as Array<unknown>) ?? [];
try {
logger.debug(`Executing ${methodName} on outbound communication provider...`);
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-function-type
return await (method as Function).apply(wrapComposedApp(provider, request), [
...args,
AppAccessorsInstance.getReader(),
AppAccessorsInstance.getModifier(),
AppAccessorsInstance.getHttp(),
AppAccessorsInstance.getPersistence(),
]);
} catch (e) {
return new JsonRpcError(e.message, -32000);
}
}

View File

@@ -0,0 +1,66 @@
import type { App } from '@rocket.chat/apps-engine/definition/App';
import type { IProcessor } from '@rocket.chat/apps-engine/definition/scheduler/IProcessor';
import type { Defined } from 'jsonrpc-lite';
import { JsonRpcError } from 'jsonrpc-lite';
import { AppObjectRegistry } from '../AppObjectRegistry';
import { AppAccessorsInstance } from '../lib/accessors/mod';
import type { RequestContext } from '../lib/requestContext';
import { wrapAppForRequest } from '../lib/wrapAppForRequest';
import { assertAppAvailable } from './lib/assertions';
export default async function handleScheduler(request: RequestContext): Promise<Defined | JsonRpcError> {
const { method, params } = request;
const { logger } = request.context;
const [, processorId] = method.split(':');
if (!Array.isArray(params)) {
return JsonRpcError.invalidParams({ message: 'Invalid params' });
}
const [context] = params as [Record<string, unknown>];
// AppSchedulerManager will append the appId to the processor name to avoid conflicts
const processor = AppObjectRegistry.get<IProcessor>(`scheduler:${processorId}`);
if (!processor) {
return JsonRpcError.methodNotFound({
message: `Could not find processor for method ${method}`,
});
}
logger.debug({ msg: 'Job processor is being executed...', processorId: processor.id });
const app = AppObjectRegistry.get<App>('app');
try {
assertAppAvailable(app);
await processor.processor.call(
// Processor registration doesn't require the App dev to instantiate a class passing
// a reference to an App object, so we don't have a good way of hijacking the Logger
// we need.
// The only way we have to provide a durable Logger instance for the processor is by
// binding its execution to the proxied App reference itself. Unfortunately, the API
// ends up being opaque, but there isn't much we can do for now.
wrapAppForRequest(app, request),
context,
AppAccessorsInstance.getReader(),
AppAccessorsInstance.getModifier(),
AppAccessorsInstance.getHttp(),
AppAccessorsInstance.getPersistence(),
);
logger.debug({ msg: 'Job processor was successfully executed', processorId: processor.id });
return null;
} catch (err) {
logger.error({ err, msg: 'Job processor was unsuccessful', processorId: processor.id });
if (err instanceof JsonRpcError) {
return err;
}
return JsonRpcError.internalError({ message: err.message });
}
}

View File

@@ -0,0 +1,123 @@
import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom';
import type { ISlashCommand } from '@rocket.chat/apps-engine/definition/slashcommands/ISlashCommand';
import { SlashCommandContext } from '@rocket.chat/apps-engine/definition/slashcommands/SlashCommandContext';
import type { Defined } from 'jsonrpc-lite';
import { JsonRpcError } from 'jsonrpc-lite';
import { AppObjectRegistry } from '../AppObjectRegistry';
import type { AppAccessors } from '../lib/accessors/mod';
import { AppAccessorsInstance } from '../lib/accessors/mod';
import type { RequestContext } from '../lib/requestContext';
import createRoom from '../lib/roomFactory';
import { wrapComposedApp } from '../lib/wrapAppForRequest';
export default async function slashCommandHandler(request: RequestContext): Promise<JsonRpcError | Defined> {
const { method: call, params } = request;
const { logger } = request.context;
const [, commandName, method] = call.split(':');
const command = AppObjectRegistry.get<ISlashCommand>(`slashcommand:${commandName}`);
if (!command) {
return new JsonRpcError(`Slashcommand ${commandName} not found`, -32000);
}
let result: Awaited<ReturnType<typeof handleExecutor>>;
logger.debug({ msg: `Command is being executed...`, commandName, method, params });
try {
if (method === 'executor' || method === 'previewer') {
result = await handleExecutor({ AppAccessorsInstance, request }, command, method, params);
} else if (method === 'executePreviewItem') {
result = await handlePreviewItem({ AppAccessorsInstance, request }, command, params);
} else {
return new JsonRpcError(`Method ${method} not found on slashcommand ${commandName}`, -32000);
}
logger.debug({ msg: `Command was successfully executed.`, commandName, method });
} catch (error) {
logger.debug({ msg: `Command was unsuccessful.`, commandName, method, err: error });
return new JsonRpcError(error.message, -32000);
}
return result;
}
type Deps = {
AppAccessorsInstance: AppAccessors;
request: RequestContext;
};
/**
* @param deps Dependencies that need to be injected into the slashcommand
* @param command The slashcommand that is being executed
* @param method The method that is being executed
* @param params The parameters that are being passed to the method
*/
export function handleExecutor(deps: Deps, command: ISlashCommand, method: 'executor' | 'previewer', params: unknown) {
const executor = command[method];
if (typeof executor !== 'function') {
throw new Error(`Method ${method} not found on slashcommand ${command.command}`);
}
if (!Array.isArray(params) || typeof params[0] !== 'object' || !params[0]) {
throw new Error(`First parameter must be an object`);
}
const { sender, room, params: args, threadId, triggerId } = params[0] as Record<string, unknown>;
const context = new SlashCommandContext(
sender as SlashCommandContext['sender'],
createRoom(room as IRoom, deps.AppAccessorsInstance.getSenderFn()) as unknown as IRoom,
args as SlashCommandContext['params'],
threadId as SlashCommandContext['threadId'],
triggerId as SlashCommandContext['triggerId'],
);
return executor.apply(wrapComposedApp(command, deps.request), [
context,
deps.AppAccessorsInstance.getReader(),
deps.AppAccessorsInstance.getModifier(),
deps.AppAccessorsInstance.getHttp(),
deps.AppAccessorsInstance.getPersistence(),
]);
}
/**
* @param deps Dependencies that need to be injected into the slashcommand
* @param command The slashcommand that is being executed
* @param params The parameters that are being passed to the method
*/
export function handlePreviewItem(deps: Deps, command: ISlashCommand, params: unknown) {
if (typeof command.executePreviewItem !== 'function') {
throw new Error(`Method not found on slashcommand ${command.command}`);
}
if (!Array.isArray(params) || typeof params[0] !== 'object' || !params[0]) {
throw new Error(`First parameter must be an object`);
}
const [previewItem, { sender, room, params: args, threadId, triggerId }] = params as [Record<string, unknown>, Record<string, unknown>];
const context = new SlashCommandContext(
sender as SlashCommandContext['sender'],
createRoom(room as IRoom, deps.AppAccessorsInstance.getSenderFn()) as unknown as IRoom,
args as SlashCommandContext['params'],
threadId as SlashCommandContext['threadId'],
triggerId as SlashCommandContext['triggerId'],
);
return command.executePreviewItem.call(
wrapComposedApp(command, deps.request),
previewItem,
context,
deps.AppAccessorsInstance.getReader(),
deps.AppAccessorsInstance.getModifier(),
deps.AppAccessorsInstance.getHttp(),
deps.AppAccessorsInstance.getPersistence(),
);
}

View File

@@ -0,0 +1,176 @@
import * as assert from 'node:assert';
import { beforeEach, describe, it, mock } from 'node:test';
import type { IRead, IModify, IHttp, IPersistence } from '@rocket.chat/apps-engine/definition/accessors';
import type { IApiRequest, IApiEndpointInfo, IApiResponse } from '@rocket.chat/apps-engine/definition/api';
import type { IApiEndpoint } from '@rocket.chat/apps-engine/definition/api/IApiEndpoint';
import { JsonRpcError } from 'jsonrpc-lite';
import { AppObjectRegistry } from '../../AppObjectRegistry';
import apiHandler from '../api-handler';
import { createMockRequest } from './helpers/mod';
describe('handlers > api', () => {
const mockEndpoint: Required<Omit<IApiEndpoint, 'delete'>> = {
path: '/test',
examples: {},
authRequired: false,
_availableMethods: [],
get(
_request: IApiRequest,
_endpoint: IApiEndpointInfo,
_read: IRead,
_modify: IModify,
_http: IHttp,
_persis: IPersistence,
): Promise<IApiResponse> {
return Promise.resolve({ status: 200 });
},
post(
_request: IApiRequest,
_endpoint: IApiEndpointInfo,
_read: IRead,
_modify: IModify,
_http: IHttp,
_persis: IPersistence,
): Promise<IApiResponse> {
return Promise.resolve({ status: 200 });
},
put(
_request: IApiRequest,
_endpoint: IApiEndpointInfo,
_read: IRead,
_modify: IModify,
_http: IHttp,
_persis: IPersistence,
): Promise<IApiResponse> {
throw new Error('Method execution error example');
},
head(
_request: IApiRequest,
_endpoint: IApiEndpointInfo,
_read: IRead,
_modify: IModify,
_http: IHttp,
_persis: IPersistence,
): Promise<IApiResponse> {
throw new Error('Function not implemented.');
},
options(
_request: IApiRequest,
_endpoint: IApiEndpointInfo,
_read: IRead,
_modify: IModify,
_http: IHttp,
_persis: IPersistence,
): Promise<IApiResponse> {
throw new Error('Function not implemented.');
},
patch(
_request: IApiRequest,
_endpoint: IApiEndpointInfo,
_read: IRead,
_modify: IModify,
_http: IHttp,
_persis: IPersistence,
): Promise<IApiResponse> {
throw new Error('Function not implemented.');
},
};
beforeEach(() => {
AppObjectRegistry.clear();
AppObjectRegistry.set('api:/test', mockEndpoint);
});
it('correctly handles execution of an api endpoint method GET', async () => {
const _spy = mock.method(mockEndpoint, 'get');
const result = await apiHandler(createMockRequest({ method: 'api:/test:get', params: ['request', 'endpointInfo'] }));
assert.deepStrictEqual(result, { status: 200 });
assert.deepStrictEqual(_spy.mock.calls[0].arguments.length, 6);
assert.deepStrictEqual(_spy.mock.calls[0].arguments[0], 'request');
assert.deepStrictEqual(_spy.mock.calls[0].arguments[1], 'endpointInfo');
_spy.mock.restore();
});
it('correctly handles execution of an api endpoint method POST', async () => {
const _spy = mock.method(mockEndpoint, 'post');
const result = await apiHandler(createMockRequest({ method: 'api:/test:post', params: ['request', 'endpointInfo'] }));
assert.deepStrictEqual(result, { status: 200 });
assert.deepStrictEqual(_spy.mock.calls[0].arguments.length, 6);
assert.deepStrictEqual(_spy.mock.calls[0].arguments[0], 'request');
assert.deepStrictEqual(_spy.mock.calls[0].arguments[1], 'endpointInfo');
_spy.mock.restore();
});
it('correctly handles an error if the method not exists for the selected endpoint', async () => {
const result = await apiHandler(createMockRequest({ method: `api:/test:delete`, params: ['request', 'endpointInfo'] }));
assert.ok(result instanceof JsonRpcError, `Expected instance of ${JsonRpcError.name}`);
assert.strictEqual(result.message, `/test's delete not exists`);
assert.strictEqual(result.code, -32000);
});
it('correctly handles an error if endpoint not exists', async () => {
const result = await apiHandler(createMockRequest({ method: `api:/error:get`, params: ['request', 'endpointInfo'] }));
assert.ok(result instanceof JsonRpcError, `Expected instance of ${JsonRpcError.name}`);
assert.strictEqual((result as any).message, `Endpoint /error not found`);
assert.strictEqual((result as any).code, -32000);
});
it('correctly handles an error if the method execution fails', async () => {
const result = await apiHandler(createMockRequest({ method: `api:/test:put`, params: ['request', 'endpointInfo'] }));
assert.ok(result instanceof JsonRpcError, `Expected instance of ${JsonRpcError.name}`);
assert.strictEqual((result as any).message, `Method execution error example`);
assert.strictEqual((result as any).code, -32000);
});
it('correctly handles dynamic paths with parameters (e.g., webhook/:event)', async () => {
const mockDynamicEndpoint = {
...mockEndpoint,
path: 'webhook/:event',
post: (_request: any, _endpoint: any, _read: any, _modify: any, _http: any, _persis: any) =>
Promise.resolve('webhook handled' as any),
};
AppObjectRegistry.set('api:webhook/:event', mockDynamicEndpoint);
const _spy = mock.method(mockDynamicEndpoint, 'post');
const result = await apiHandler(createMockRequest({ method: 'api:webhook/:event:post', params: ['request', 'endpointInfo'] }));
assert.deepStrictEqual(result, 'webhook handled');
assert.deepStrictEqual(_spy.mock.calls[0].arguments.length, 6);
assert.deepStrictEqual(_spy.mock.calls[0].arguments[0], 'request');
assert.deepStrictEqual(_spy.mock.calls[0].arguments[1], 'endpointInfo');
_spy.mock.restore();
});
it('correctly handles paths with multiple segments and colons', async () => {
const mockComplexEndpoint = {
...mockEndpoint,
path: 'api/v1/:resource/:id',
get: (_request: any, _endpoint: any, _read: any, _modify: any, _http: any, _persis: any) => Promise.resolve({ status: 201 }),
};
AppObjectRegistry.set('api:api/v1/:resource/:id', mockComplexEndpoint);
const _spy = mock.method(mockComplexEndpoint, 'get');
const result = await apiHandler(createMockRequest({ method: 'api:api/v1/:resource/:id:get', params: ['request', 'endpointInfo'] }));
assert.deepStrictEqual(result, { status: 201 });
assert.deepStrictEqual(_spy.mock.calls[0].arguments.length, 6);
_spy.mock.restore();
});
});

View File

@@ -0,0 +1,29 @@
import type { App } from '@rocket.chat/apps-engine/definition/App';
import { Logger } from '../../../lib/logger';
import type { RequestDescriptor } from '../../../lib/messenger';
import type { RequestContext } from '../../../lib/requestContext';
export function createMockRequest({ method, params }: RequestDescriptor): RequestContext {
return {
jsonrpc: '2.0',
id: 1,
method,
params,
context: {
logger: new Logger(method),
},
serialize: () => '',
};
}
export function createMockApp(): App {
return {
extendConfiguration: () => {},
getID: () => 'mockApp',
getLogger: () => ({
debug: () => {},
error: () => {},
}),
} as unknown as App;
}

View File

@@ -0,0 +1,225 @@
import * as assert from 'node:assert';
import { describe, it } from 'node:test';
import { MessageBuilder } from '../../lib/accessors/builders/MessageBuilder';
import { RoomBuilder } from '../../lib/accessors/builders/RoomBuilder';
import { MessageExtender } from '../../lib/accessors/extenders/MessageExtender';
import { RoomExtender } from '../../lib/accessors/extenders/RoomExtender';
import type { AppAccessors } from '../../lib/accessors/mod';
import { Room } from '../../lib/room';
import { parseArgs } from '../listener/handler';
describe('handlers > listeners', () => {
const mockAppAccessors = {
getReader: () => ({ __type: 'reader' }),
getHttp: () => ({ __type: 'http' }),
getModifier: () => ({ __type: 'modifier' }),
getPersistence: () => ({ __type: 'persistence' }),
getSenderFn: () => (id: string) => Promise.resolve([{ __type: 'bridgeCall' }, { id }]),
} as unknown as AppAccessors;
it('correctly parses the arguments for a request to trigger the "checkPreMessageSentPrevent" method', () => {
const evtMethod = 'checkPreMessageSentPrevent';
// For the 'checkPreMessageSentPrevent' method, the context will be a message in a real scenario
const evtArgs = [{ __type: 'context' }];
const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs);
assert.deepStrictEqual(params.length, 3);
assert.deepStrictEqual(params[0], { __type: 'context' });
assert.deepStrictEqual(params[1], { __type: 'reader' });
assert.deepStrictEqual(params[2], { __type: 'http' });
});
it('correctly parses the arguments for a request to trigger the "checkPostMessageDeleted" method', () => {
const evtMethod = 'checkPostMessageDeleted';
// For the 'checkPostMessageDeleted' method, the context will be a message in a real scenario,
// and the extraContext will provide further information such the user who deleted the message
const evtArgs = [{ __type: 'context' }, { __type: 'extraContext' }];
const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs);
assert.deepStrictEqual(params.length, 4);
assert.deepStrictEqual(params[0], { __type: 'context' });
assert.deepStrictEqual(params[1], { __type: 'reader' });
assert.deepStrictEqual(params[2], { __type: 'http' });
assert.deepStrictEqual(params[3], { __type: 'extraContext' });
});
it('correctly parses the arguments for a request to trigger the "checkPreRoomCreateExtend" method', () => {
const evtMethod = 'checkPreRoomCreateExtend';
// For the 'checkPreRoomCreateExtend' method, the context will be a room in a real scenario
const evtArgs = [
{
id: 'fake',
type: 'fake',
slugifiedName: 'fake',
creator: 'fake',
createdAt: Date.now(),
},
];
const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs);
assert.deepStrictEqual(params.length, 3);
assert.ok(params[0] instanceof Room, `Expected instance of ${Room.name}`);
assert.deepStrictEqual(params[1], { __type: 'reader' });
assert.deepStrictEqual(params[2], { __type: 'http' });
});
it('correctly parses the arguments for a request to trigger the "executePreMessageSentExtend" method', () => {
const evtMethod = 'executePreMessageSentExtend';
// For the 'executePreMessageSentExtend' method, the context will be a message in a real scenario
const evtArgs = [{ __type: 'context' }];
const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs);
assert.deepStrictEqual(params.length, 5);
// Instantiating the MessageExtender might modify the original object, so we need to assert it matches instead of equals
assert.strictEqual((params[0] as any).__type, 'context');
assert.ok(params[1] instanceof MessageExtender, `Expected instance of ${MessageExtender.name}`);
assert.deepStrictEqual(params[2], { __type: 'reader' });
assert.deepStrictEqual(params[3], { __type: 'http' });
assert.deepStrictEqual(params[4], { __type: 'persistence' });
});
it('correctly parses the arguments for a request to trigger the "executePreRoomCreateExtend" method', () => {
const evtMethod = 'executePreRoomCreateExtend';
// For the 'executePreRoomCreateExtend' method, the context will be a room in a real scenario
const evtArgs = [{ __type: 'context' }];
const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs);
assert.deepStrictEqual(params.length, 5);
// Instantiating the RoomExtender might modify the original object, so we need to assert it matches instead of equals
assert.strictEqual((params[0] as any).__type, 'context');
assert.ok(params[1] instanceof RoomExtender, `Expected instance of ${RoomExtender.name}`);
assert.deepStrictEqual(params[2], { __type: 'reader' });
assert.deepStrictEqual(params[3], { __type: 'http' });
assert.deepStrictEqual(params[4], { __type: 'persistence' });
});
it('correctly parses the arguments for a request to trigger the "executePreMessageSentModify" method', () => {
const evtMethod = 'executePreMessageSentModify';
// For the 'executePreMessageSentModify' method, the context will be a message in a real scenario
const evtArgs = [{ __type: 'context' }];
const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs);
assert.deepStrictEqual(params.length, 5);
// Instantiating the MessageBuilder might modify the original object, so we need to assert it matches instead of equals
assert.strictEqual((params[0] as any).__type, 'context');
assert.ok(params[1] instanceof MessageBuilder, `Expected instance of ${MessageBuilder.name}`);
assert.deepStrictEqual(params[2], { __type: 'reader' });
assert.deepStrictEqual(params[3], { __type: 'http' });
assert.deepStrictEqual(params[4], { __type: 'persistence' });
});
it('correctly parses the arguments for a request to trigger the "executePreRoomCreateModify" method', () => {
const evtMethod = 'executePreRoomCreateModify';
// For the 'executePreRoomCreateModify' method, the context will be a room in a real scenario
const evtArgs = [{ __type: 'context' }];
const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs);
assert.deepStrictEqual(params.length, 5);
// Instantiating the RoomBuilder might modify the original object, so we need to assert it matches instead of equals
assert.strictEqual((params[0] as any).__type, 'context');
assert.ok(params[1] instanceof RoomBuilder, `Expected instance of ${RoomBuilder.name}`);
assert.deepStrictEqual(params[2], { __type: 'reader' });
assert.deepStrictEqual(params[3], { __type: 'http' });
assert.deepStrictEqual(params[4], { __type: 'persistence' });
});
it('correctly parses the arguments for a request to trigger the "executePostRoomUserJoined" method', () => {
const evtMethod = 'executePostRoomUserJoined';
// For the 'executePostRoomUserJoined' method, the context will be a room in a real scenario
const room = {
id: 'fake',
type: 'fake',
slugifiedName: 'fake',
creator: 'fake',
createdAt: Date.now(),
};
const evtArgs = [{ __type: 'context', room }];
const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs);
assert.deepStrictEqual(params.length, 5);
assert.ok((params[0] as any).room instanceof Room, `Expected instance of ${Room.name}`);
assert.deepStrictEqual(params[1], { __type: 'reader' });
assert.deepStrictEqual(params[2], { __type: 'http' });
assert.deepStrictEqual(params[3], { __type: 'persistence' });
assert.deepStrictEqual(params[4], { __type: 'modifier' });
});
it('correctly parses the arguments for a request to trigger the "executePostRoomUserLeave" method', () => {
const evtMethod = 'executePostRoomUserLeave';
// For the 'executePostRoomUserLeave' method, the context will be a room in a real scenario
const room = {
id: 'fake',
type: 'fake',
slugifiedName: 'fake',
creator: 'fake',
createdAt: Date.now(),
};
const evtArgs = [{ __type: 'context', room }];
const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs);
assert.deepStrictEqual(params.length, 5);
assert.ok((params[0] as any).room instanceof Room, `Expected instance of ${Room.name}`);
assert.deepStrictEqual(params[1], { __type: 'reader' });
assert.deepStrictEqual(params[2], { __type: 'http' });
assert.deepStrictEqual(params[3], { __type: 'persistence' });
assert.deepStrictEqual(params[4], { __type: 'modifier' });
});
it('correctly parses the arguments for a request to trigger the "executePostMessageDeleted" method', () => {
const evtMethod = 'executePostMessageDeleted';
// For the 'executePostMessageDeleted' method, the context will be a message in a real scenario
const evtArgs = [{ __type: 'context' }, { __type: 'extraContext' }];
const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs);
assert.deepStrictEqual(params.length, 6);
assert.deepStrictEqual(params[0], { __type: 'context' });
assert.deepStrictEqual(params[1], { __type: 'reader' });
assert.deepStrictEqual(params[2], { __type: 'http' });
assert.deepStrictEqual(params[3], { __type: 'persistence' });
assert.deepStrictEqual(params[4], { __type: 'modifier' });
assert.deepStrictEqual(params[5], { __type: 'extraContext' });
});
it('correctly parses the arguments for a request to trigger the "executePostMessageSent" method', () => {
const evtMethod = 'executePostMessageSent';
// For the 'executePostMessageDeleted' method, the context will be a message in a real scenario
const evtArgs = [
{
id: 'fake',
sender: 'fake',
createdAt: Date.now(),
room: {
id: 'fake-room',
type: 'fake',
slugifiedName: 'fake',
creator: 'fake',
createdAt: Date.now(),
},
},
];
const params = parseArgs({ AppAccessorsInstance: mockAppAccessors }, evtMethod, evtArgs);
assert.deepStrictEqual(params.length, 5);
assert.strictEqual((params[0] as any).id, 'fake');
assert.ok((params[0] as any).room instanceof Room, `Expected instance of ${Room.name}`);
assert.deepStrictEqual(params[1], { __type: 'reader' });
assert.deepStrictEqual(params[2], { __type: 'http' });
assert.deepStrictEqual(params[3], { __type: 'persistence' });
assert.deepStrictEqual(params[4], { __type: 'modifier' });
});
});

View File

@@ -0,0 +1,41 @@
import * as assert from 'node:assert';
import { after, beforeEach, describe, it } from 'node:test';
import { AppObjectRegistry } from '../../AppObjectRegistry';
import { AppAccessors } from '../../lib/accessors/mod';
import handleScheduler from '../scheduler-handler';
import { createMockApp, createMockRequest } from './helpers/mod';
describe('handlers > scheduler', () => {
const mockAppAccessors = new AppAccessors(() =>
Promise.resolve({
id: 'mockId',
result: {},
jsonrpc: '2.0',
serialize: () => '',
}),
);
const mockApp = createMockApp();
beforeEach(() => {
AppObjectRegistry.clear();
AppObjectRegistry.set('app', mockApp);
mockAppAccessors.getConfigurationExtend().scheduler.registerProcessors([
{
id: 'mockId',
processor: () => Promise.resolve(),
},
]);
});
after(() => {
AppObjectRegistry.clear();
});
it('correctly executes a request to a processor', async () => {
const result = await handleScheduler(createMockRequest({ method: 'scheduler:mockId', params: [{}] }));
assert.deepStrictEqual(result, null);
});
});

View File

@@ -0,0 +1,162 @@
import * as assert from 'node:assert';
import { beforeEach, describe, it, mock } from 'node:test';
import type { IRead, IModify, IHttp, IPersistence } from '@rocket.chat/apps-engine/definition/accessors';
import type { ISlashCommand, SlashCommandContext } from '@rocket.chat/apps-engine/definition/slashcommands';
import { AppObjectRegistry } from '../../AppObjectRegistry';
import { createMockRequest } from './helpers/mod';
import type { AppAccessors } from '../../lib/accessors/mod';
import { Room } from '../../lib/room';
import { handleExecutor, handlePreviewItem } from '../slashcommand-handler';
describe('handlers > slashcommand', () => {
const mockAppAccessors = {
getReader: () => ({ __type: 'reader' }),
getHttp: () => ({ __type: 'http' }),
getModifier: () => ({ __type: 'modifier' }),
getPersistence: () => ({ __type: 'persistence' }),
getSenderFn: () => (id: string) => Promise.resolve([{ __type: 'bridgeCall' }, { id }]),
} as unknown as AppAccessors;
const mockCommandExecutorOnly: ISlashCommand = {
command: 'executor-only',
i18nParamsExample: 'test',
i18nDescription: 'test',
providesPreview: false,
executor(_context: SlashCommandContext, _read: IRead, _modify: IModify, _http: IHttp, _persis: IPersistence): Promise<void> {
return Promise.resolve();
},
};
const mockCommandExecutorAndPreview: ISlashCommand = {
command: 'executor-and-preview',
i18nParamsExample: 'test',
i18nDescription: 'test',
providesPreview: true,
executor(_context: SlashCommandContext, _read: IRead, _modify: IModify, _http: IHttp, _persis: IPersistence): Promise<void> {
return Promise.resolve();
},
previewer(_context, _read, _modify, _http, _persis) {
return Promise.resolve({ i18nTitle: 'test', items: [] });
},
executePreviewItem(_item, _context, _read, _modify, _http, _persis) {
return Promise.resolve();
},
};
beforeEach(() => {
AppObjectRegistry.clear();
AppObjectRegistry.set('slashcommand:executor-only', mockCommandExecutorOnly);
AppObjectRegistry.set('slashcommand:executor-and-preview', mockCommandExecutorAndPreview);
});
it('correctly handles execution of a slash command', async () => {
const mockContext = {
sender: { __type: 'sender' },
room: { __type: 'room' },
params: { __type: 'params' },
threadId: 'threadId',
triggerId: 'triggerId',
};
const _spy = mock.method(mockCommandExecutorOnly, 'executor');
const mockRequest = createMockRequest({ method: 'slashcommand:executor-only:executor', params: [mockContext] });
await handleExecutor({ AppAccessorsInstance: mockAppAccessors, request: mockRequest }, mockCommandExecutorOnly, 'executor', [
mockContext,
]);
const context = _spy.mock.calls[0].arguments[0];
assert.ok(context.getRoom() instanceof Room, `Expected instance of ${Room.name}`);
assert.deepStrictEqual(context.getSender(), { __type: 'sender' });
assert.deepStrictEqual(context.getArguments(), { __type: 'params' });
assert.deepStrictEqual(context.getThreadId(), 'threadId');
assert.deepStrictEqual(context.getTriggerId(), 'triggerId');
assert.deepStrictEqual(_spy.mock.calls[0].arguments[1], mockAppAccessors.getReader());
assert.deepStrictEqual(_spy.mock.calls[0].arguments[2], mockAppAccessors.getModifier());
assert.deepStrictEqual(_spy.mock.calls[0].arguments[3], mockAppAccessors.getHttp());
assert.deepStrictEqual(_spy.mock.calls[0].arguments[4], mockAppAccessors.getPersistence());
_spy.mock.restore();
});
it('correctly handles execution of a slash command previewer', async () => {
const mockContext = {
sender: { __type: 'sender' },
room: { __type: 'room' },
params: { __type: 'params' },
threadId: 'threadId',
triggerId: 'triggerId',
};
const _spy = mock.method(mockCommandExecutorAndPreview, 'previewer');
const mockRequest = createMockRequest({ method: 'slashcommand:executor-and-preview:previewer', params: [mockContext] });
await handleExecutor({ AppAccessorsInstance: mockAppAccessors, request: mockRequest }, mockCommandExecutorAndPreview, 'previewer', [
mockContext,
]);
const context = _spy.mock.calls[0].arguments[0];
assert.ok(context.getRoom() instanceof Room, `Expected instance of ${Room.name}`);
assert.deepStrictEqual(context.getSender(), { __type: 'sender' });
assert.deepStrictEqual(context.getArguments(), { __type: 'params' });
assert.deepStrictEqual(context.getThreadId(), 'threadId');
assert.deepStrictEqual(context.getTriggerId(), 'triggerId');
assert.deepStrictEqual(_spy.mock.calls[0].arguments[1], mockAppAccessors.getReader());
assert.deepStrictEqual(_spy.mock.calls[0].arguments[2], mockAppAccessors.getModifier());
assert.deepStrictEqual(_spy.mock.calls[0].arguments[3], mockAppAccessors.getHttp());
assert.deepStrictEqual(_spy.mock.calls[0].arguments[4], mockAppAccessors.getPersistence());
_spy.mock.restore();
});
it('correctly handles execution of a slash command preview item executor', async () => {
const mockContext = {
sender: { __type: 'sender' },
room: { __type: 'room' },
params: { __type: 'params' },
threadId: 'threadId',
triggerId: 'triggerId',
};
const mockPreviewItem = {
id: 'previewItemId',
type: 'image',
value: 'https://example.com/image.png',
};
const _spy = mock.method(mockCommandExecutorAndPreview, 'executePreviewItem');
const mockRequest = createMockRequest({
method: 'slashcommand:executor-and-preview:executePreviewItem',
params: [mockPreviewItem, mockContext],
});
await handlePreviewItem({ AppAccessorsInstance: mockAppAccessors, request: mockRequest }, mockCommandExecutorAndPreview, [
mockPreviewItem,
mockContext,
]);
const context = _spy.mock.calls[0].arguments[1];
assert.ok(context.getRoom() instanceof Room, `Expected instance of ${Room.name}`);
assert.deepStrictEqual(context.getSender(), { __type: 'sender' });
assert.deepStrictEqual(context.getArguments(), { __type: 'params' });
assert.deepStrictEqual(context.getThreadId(), 'threadId');
assert.deepStrictEqual(context.getTriggerId(), 'triggerId');
assert.deepStrictEqual(_spy.mock.calls[0].arguments[2], mockAppAccessors.getReader());
assert.deepStrictEqual(_spy.mock.calls[0].arguments[3], mockAppAccessors.getModifier());
assert.deepStrictEqual(_spy.mock.calls[0].arguments[4], mockAppAccessors.getHttp());
assert.deepStrictEqual(_spy.mock.calls[0].arguments[5], mockAppAccessors.getPersistence());
_spy.mock.restore();
});
});

View File

@@ -0,0 +1,105 @@
import * as assert from 'node:assert';
import { after, beforeEach, describe, it } from 'node:test';
import jsonrpc, { type Defined } from 'jsonrpc-lite';
import { AppObjectRegistry } from '../../AppObjectRegistry';
import { Logger } from '../../lib/logger';
import type { RequestContext } from '../../lib/requestContext';
import handleUIKitInteraction, {
UIKitActionButtonInteractionContext,
UIKitBlockInteractionContext,
UIKitLivechatBlockInteractionContext,
UIKitViewCloseInteractionContext,
UIKitViewSubmitInteractionContext,
} from '../uikit/handler';
function makeMockRequest(method: string, payload: { [k: string]: Defined }): RequestContext {
return Object.assign(jsonrpc.request(1, method, [payload]), {
context: {
logger: new Logger(method),
},
});
}
describe('handlers > uikit', () => {
const mockApp = {
getID: (): string => 'appId',
executeBlockActionHandler: (context: any): Promise<any> => Promise.resolve(context),
executeViewSubmitHandler: (context: any): Promise<any> => Promise.resolve(context),
executeViewClosedHandler: (context: any): Promise<any> => Promise.resolve(context),
executeActionButtonHandler: (context: any): Promise<any> => Promise.resolve(context),
executeLivechatBlockActionHandler: (context: any): Promise<any> => Promise.resolve(context),
};
beforeEach(() => {
AppObjectRegistry.set('app', mockApp);
});
after(() => {
AppObjectRegistry.clear();
});
it('successfully handles a call for "executeBlockActionHandler"', async () => {
const request = makeMockRequest('app:executeBlockActionHandler', {
actionId: 'actionId',
blockId: 'blockId',
value: 'value',
viewId: 'viewId',
});
const result = await handleUIKitInteraction(request);
assert.ok(result instanceof UIKitBlockInteractionContext, `Expected instance of ${UIKitBlockInteractionContext.name}`);
});
it('successfully handles a call for "executeViewSubmitHandler"', async () => {
const request = makeMockRequest('app:executeViewSubmitHandler', {
viewId: 'viewId',
appId: 'appId',
userId: 'userId',
isAppUser: true,
values: {},
});
const result = await handleUIKitInteraction(request);
assert.ok(result instanceof UIKitViewSubmitInteractionContext, `Expected instance of ${UIKitViewSubmitInteractionContext.name}`);
});
it('successfully handles a call for "executeViewClosedHandler"', async () => {
const request = makeMockRequest('app:executeViewClosedHandler', {
viewId: 'viewId',
appId: 'appId',
userId: 'userId',
isAppUser: true,
});
const result = await handleUIKitInteraction(request);
assert.ok(result instanceof UIKitViewCloseInteractionContext, `Expected instance of ${UIKitViewCloseInteractionContext.name}`);
});
it('successfully handles a call for "executeActionButtonHandler"', async () => {
const request = makeMockRequest('app:executeActionButtonHandler', {
actionId: 'actionId',
appId: 'appId',
userId: 'userId',
isAppUser: true,
});
const result = await handleUIKitInteraction(request);
assert.ok(result instanceof UIKitActionButtonInteractionContext, `Expected instance of ${UIKitActionButtonInteractionContext.name}`);
});
it('successfully handles a call for "executeLivechatBlockActionHandler"', async () => {
const request = makeMockRequest('app:executeLivechatBlockActionHandler', {
actionId: 'actionId',
appId: 'appId',
userId: 'userId',
visitor: {},
isAppUser: true,
room: {},
});
const result = await handleUIKitInteraction(request);
assert.ok(result instanceof UIKitLivechatBlockInteractionContext, `Expected instance of ${UIKitLivechatBlockInteractionContext.name}`);
});
});

View File

@@ -0,0 +1,123 @@
import * as assert from 'node:assert';
import { Buffer } from 'node:buffer';
import { randomUUID } from 'node:crypto';
import { writeFile, unlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, it, mock } from 'node:test';
import type { App } from '@rocket.chat/apps-engine/definition/App';
import type { IPreFileUpload } from '@rocket.chat/apps-engine/definition/uploads/IPreFileUpload';
import type { IUploadDetails } from '@rocket.chat/apps-engine/definition/uploads/IUploadDetails';
import { JsonRpcError } from 'jsonrpc-lite';
import { createMockRequest } from './helpers/mod';
import { AppObjectRegistry } from '../../AppObjectRegistry';
import handleUploadEvents from '../app/handleUploadEvents';
import { Errors } from '../lib/assertions';
describe('handlers > upload', () => {
let app: App & IPreFileUpload;
let tmpFilePath: string;
let file: IUploadDetails;
beforeEach(async () => {
AppObjectRegistry.clear();
tmpFilePath = join(tmpdir(), `test-upload-${randomUUID()}.txt`);
app = {
extendConfiguration: () => {},
executePreFileUpload: () => Promise.resolve(),
} as unknown as App & IPreFileUpload;
AppObjectRegistry.set('app', app);
const content = 'Temp file for testing';
await writeFile(tmpFilePath, content, 'utf-8');
file = {
name: 'TempFile.txt',
size: content.length,
type: 'text/plain',
rid: 'RandomRoomId',
userId: 'RandomUserId',
};
});
afterEach(async () => {
await unlink(tmpFilePath).catch((e: any) => e?.code !== 'ENOENT' && console.warn(`Failed to remove temp file at ${tmpFilePath}`, e));
});
it('correctly handles valid parameters', async () => {
const result = await handleUploadEvents(
createMockRequest({ method: 'app:executePreFileUpload', params: [{ file, path: tmpFilePath }] }),
);
assert.ok(!(result instanceof JsonRpcError), 'result is JsonRpcError');
});
it('correctly loads the file contents for IPreFileUpload', async () => {
const _spy = mock.method(app as any, 'executePreFileUpload');
const result = await handleUploadEvents(
createMockRequest({ method: 'app:executePreFileUpload', params: [{ file, path: tmpFilePath }] }),
);
assert.ok(!(result instanceof JsonRpcError), 'result is JsonRpcError');
assert.strictEqual(_spy.mock.calls.length, 1);
assert.ok((_spy.mock.calls[0].arguments[0] as any)?.content instanceof Buffer, 'Expected content to be a Buffer');
_spy.mock.restore();
});
it('fails when app object is not on registry', async () => {
AppObjectRegistry.clear();
const result = await handleUploadEvents(
createMockRequest({ method: 'app:executePreFileUpload', params: [{ file, path: tmpFilePath }] }),
);
assert.ok(result instanceof JsonRpcError, `Expected instance of ${JsonRpcError.name}`);
assert.deepStrictEqual((result as JsonRpcError).data.code, Errors.DRT_APP_NOT_AVAILABLE);
});
it('fails when the app does not implement the IPreFileUpload event handler', async () => {
delete (app as any).executePreFileUpload;
const result = await handleUploadEvents(
createMockRequest({ method: 'app:executePreFileUpload', params: [{ file, path: tmpFilePath }] }),
);
assert.ok(result instanceof JsonRpcError, `Expected instance of ${JsonRpcError.name}`);
assert.deepStrictEqual((result as JsonRpcError).data.code, Errors.DRT_EVENT_HANDLER_FUNCTION_MISSING);
});
it('fails when "file" is not a proper IUploadDetails object', async () => {
const result = await handleUploadEvents(
createMockRequest({ method: 'app:executePreFileUpload', params: [{ file: { nope: 'bad' }, path: tmpFilePath }] }),
);
assert.ok(result instanceof JsonRpcError, `Expected instance of ${JsonRpcError.name}`);
assert.ok((result as JsonRpcError).data.err.includes('Expected IUploadDetails'));
});
it('fails when "path" is not a proper string', async () => {
const result = await handleUploadEvents(createMockRequest({ method: 'app:executePreFileUpload', params: [{ file, path: {} }] }));
assert.ok(result instanceof JsonRpcError, `Expected instance of ${JsonRpcError.name}`);
assert.ok((result as JsonRpcError).data.err.includes('Expected string'));
});
it('fails when "path" is not a readable file path', async () => {
await unlink(tmpFilePath);
const result = await handleUploadEvents(
createMockRequest({ method: 'app:executePreFileUpload', params: [{ file, path: tmpFilePath }] }),
);
assert.ok(result instanceof JsonRpcError, `Expected instance of ${JsonRpcError.name}`);
assert.deepStrictEqual((result as JsonRpcError).data.code, 'ENOENT');
});
});

View File

@@ -0,0 +1,112 @@
import * as assert from 'node:assert';
import { beforeEach, describe, it, mock } from 'node:test';
import { JsonRpcError } from 'jsonrpc-lite';
import videoconfHandler from '../videoconference-handler';
import { createMockRequest } from './helpers/mod';
import { AppObjectRegistry } from '../../AppObjectRegistry';
describe('handlers > videoconference', () => {
const mockMethodWithoutParam = (read: any, modify: any, http: any, persis: any): Promise<string> => Promise.resolve('ok none');
const mockMethodWithOneParam = (call: any, read: any, modify: any, http: any, persis: any): Promise<string> => Promise.resolve('ok one');
const mockMethodWithTwoParam = (call: any, user: any, read: any, modify: any, http: any, persis: any): Promise<string> =>
Promise.resolve('ok two');
const mockMethodWithThreeParam = (call: any, user: any, options: any, read: any, modify: any, http: any, persis: any): Promise<string> =>
Promise.resolve('ok three');
const mockProvider = {
empty: mockMethodWithoutParam,
one: mockMethodWithOneParam,
two: mockMethodWithTwoParam,
three: mockMethodWithThreeParam,
notAFunction: true,
error: () => {
throw new Error('Method execution error example');
},
};
beforeEach(() => {
AppObjectRegistry.clear();
AppObjectRegistry.set('videoConfProvider:test-provider', mockProvider);
});
it('correctly handles execution of a videoconf method without additional params', async () => {
const _spy = mock.method(mockProvider, 'empty');
const result = await videoconfHandler(createMockRequest({ method: 'videoconference:test-provider:empty', params: [] }));
assert.deepStrictEqual(result, 'ok none');
assert.deepStrictEqual(_spy.mock.calls[0].arguments.length, 4);
_spy.mock.restore();
});
it('correctly handles execution of a videoconf method with one param', async () => {
const _spy = mock.method(mockProvider, 'one');
const result = await videoconfHandler(createMockRequest({ method: 'videoconference:test-provider:one', params: ['call'] }));
assert.deepStrictEqual(result, 'ok one');
assert.deepStrictEqual(_spy.mock.calls[0].arguments.length, 5);
assert.deepStrictEqual(_spy.mock.calls[0].arguments[0], 'call');
_spy.mock.restore();
});
it('correctly handles execution of a videoconf method with two params', async () => {
const _spy = mock.method(mockProvider, 'two');
const result = await videoconfHandler(createMockRequest({ method: 'videoconference:test-provider:two', params: ['call', 'user'] }));
assert.deepStrictEqual(result, 'ok two');
assert.deepStrictEqual(_spy.mock.calls[0].arguments.length, 6);
assert.deepStrictEqual(_spy.mock.calls[0].arguments[0], 'call');
assert.deepStrictEqual(_spy.mock.calls[0].arguments[1], 'user');
_spy.mock.restore();
});
it('correctly handles execution of a videoconf method with three params', async () => {
const _spy = mock.method(mockProvider, 'three');
const result = await videoconfHandler(
createMockRequest({ method: 'videoconference:test-provider:three', params: ['call', 'user', 'options'] }),
);
assert.deepStrictEqual(result, 'ok three');
assert.deepStrictEqual(_spy.mock.calls[0].arguments.length, 7);
assert.deepStrictEqual(_spy.mock.calls[0].arguments[0], 'call');
assert.deepStrictEqual(_spy.mock.calls[0].arguments[1], 'user');
assert.deepStrictEqual(_spy.mock.calls[0].arguments[2], 'options');
_spy.mock.restore();
});
it('correctly handles an error on execution of a videoconf method', async () => {
const result = await videoconfHandler(createMockRequest({ method: 'videoconference:test-provider:error', params: [] }));
assert.ok(result instanceof JsonRpcError, `Expected instance of ${JsonRpcError.name}`);
assert.strictEqual((result as any).message, 'Method execution error example');
assert.strictEqual((result as any).code, -32000);
});
it('correctly handles an error when provider is not found', async () => {
const providerName = 'error-provider';
const result = await videoconfHandler(createMockRequest({ method: `videoconference:${providerName}:method`, params: [] }));
assert.ok(result instanceof JsonRpcError, `Expected instance of ${JsonRpcError.name}`);
assert.strictEqual((result as any).message, `Provider ${providerName} not found`);
assert.strictEqual((result as any).code, -32000);
});
it('correctly handles an error if method is not a function of provider', async () => {
const methodName = 'notAFunction';
const providerName = 'test-provider';
const result = await videoconfHandler(createMockRequest({ method: `videoconference:${providerName}:${methodName}`, params: [] }));
assert.ok(result instanceof JsonRpcError, `Expected instance of ${JsonRpcError.name}`);
assert.strictEqual((result as any).message, 'Method not found');
assert.strictEqual((result as any).code, -32601);
assert.strictEqual((result as any).data.message, `Method ${methodName} not found on provider ${providerName}`);
});
});

View File

@@ -0,0 +1,101 @@
import type { App } from '@rocket.chat/apps-engine/definition/App';
import type {
IUIKitBlockIncomingInteraction,
IUIKitViewSubmitIncomingInteraction,
IUIKitViewCloseIncomingInteraction,
IUIKitActionButtonIncomingInteraction,
} from '@rocket.chat/apps-engine/definition/uikit/UIKitIncomingInteractionTypes';
import {
UIKitBlockInteractionContext,
UIKitViewSubmitInteractionContext,
UIKitViewCloseInteractionContext,
UIKitActionButtonInteractionContext,
} from '@rocket.chat/apps-engine/definition/uikit/UIKitInteractionContext';
import type { IUIKitLivechatBlockIncomingInteraction } from '@rocket.chat/apps-engine/definition/uikit/livechat/UIKitLivechatIncomingInteractionType';
import { UIKitLivechatBlockInteractionContext } from '@rocket.chat/apps-engine/definition/uikit/livechat/UIKitLivechatInteractionContext';
import type { Defined } from 'jsonrpc-lite';
import { JsonRpcError } from 'jsonrpc-lite';
import { AppObjectRegistry } from '../../AppObjectRegistry';
import { AppAccessorsInstance } from '../../lib/accessors/mod';
import type { RequestContext } from '../../lib/requestContext';
import { wrapAppForRequest } from '../../lib/wrapAppForRequest';
import { isOneOf } from '../lib/assertions';
export const uikitInteractions = [
'executeBlockActionHandler',
'executeViewSubmitHandler',
'executeViewClosedHandler',
'executeActionButtonHandler',
'executeLivechatBlockActionHandler',
] as const;
export {
UIKitBlockInteractionContext,
UIKitViewSubmitInteractionContext,
UIKitViewCloseInteractionContext,
UIKitActionButtonInteractionContext,
UIKitLivechatBlockInteractionContext,
};
export default async function handleUIKitInteraction(request: RequestContext): Promise<Defined | JsonRpcError> {
const { method: reqMethod, params } = request;
const [, method] = reqMethod.split(':');
if (!isOneOf(method, uikitInteractions)) {
return JsonRpcError.methodNotFound(null);
}
if (!Array.isArray(params)) {
return JsonRpcError.invalidParams(null);
}
const app = AppObjectRegistry.get<App>('app');
const interactionHandler = app?.[method as keyof App] as unknown;
if (!app || typeof interactionHandler !== 'function') {
return JsonRpcError.methodNotFound({
message: `App does not implement method "${method}"`,
});
}
const [payload] = params as [Record<string, unknown>];
if (!payload) {
return JsonRpcError.invalidParams(null);
}
let context;
switch (method) {
case 'executeBlockActionHandler':
context = new UIKitBlockInteractionContext(payload as unknown as IUIKitBlockIncomingInteraction);
break;
case 'executeViewSubmitHandler':
context = new UIKitViewSubmitInteractionContext(payload as unknown as IUIKitViewSubmitIncomingInteraction);
break;
case 'executeViewClosedHandler':
context = new UIKitViewCloseInteractionContext(payload as unknown as IUIKitViewCloseIncomingInteraction);
break;
case 'executeActionButtonHandler':
context = new UIKitActionButtonInteractionContext(payload as unknown as IUIKitActionButtonIncomingInteraction);
break;
case 'executeLivechatBlockActionHandler':
context = new UIKitLivechatBlockInteractionContext(payload as unknown as IUIKitLivechatBlockIncomingInteraction);
break;
}
try {
return await interactionHandler.call(
wrapAppForRequest(app, request),
context,
AppAccessorsInstance.getReader(),
AppAccessorsInstance.getHttp(),
AppAccessorsInstance.getPersistence(),
AppAccessorsInstance.getModifier(),
);
} catch (e) {
return JsonRpcError.internalError({ message: e.message });
}
}

View File

@@ -0,0 +1,53 @@
import type { IVideoConfProvider } from '@rocket.chat/apps-engine/definition/videoConfProviders/IVideoConfProvider';
import type { Defined } from 'jsonrpc-lite';
import { JsonRpcError } from 'jsonrpc-lite';
import { AppObjectRegistry } from '../AppObjectRegistry';
import { AppAccessorsInstance } from '../lib/accessors/mod';
import type { RequestContext } from '../lib/requestContext';
import { wrapComposedApp } from '../lib/wrapAppForRequest';
export default async function videoConferenceHandler(request: RequestContext): Promise<JsonRpcError | Defined> {
const { method: call, params } = request;
const { logger } = request.context;
const [, providerName, methodName] = call.split(':');
const provider = AppObjectRegistry.get<IVideoConfProvider>(`videoConfProvider:${providerName}`);
if (!provider) {
return new JsonRpcError(`Provider ${providerName} not found`, -32000);
}
const method = provider[methodName as keyof IVideoConfProvider];
if (typeof method !== 'function') {
return JsonRpcError.methodNotFound({
message: `Method ${methodName} not found on provider ${providerName}`,
});
}
const [videoconf, user, options] = params as Array<unknown>;
logger.debug(`Executing ${methodName} on video conference provider...`);
const args = [...(videoconf ? [videoconf] : []), ...(user ? [user] : []), ...(options ? [options] : [])];
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
const result = await (method as Function).apply(wrapComposedApp(provider, request), [
...args,
AppAccessorsInstance.getReader(),
AppAccessorsInstance.getModifier(),
AppAccessorsInstance.getHttp(),
AppAccessorsInstance.getPersistence(),
]);
logger.debug(`Video Conference Provider's ${methodName} was successfully executed.`);
return result;
} catch (e) {
logger.debug(`Video Conference Provider's ${methodName} was unsuccessful.`);
return new JsonRpcError(e.message, -32000);
}
}

View File

@@ -0,0 +1,43 @@
import { randomUUID } from 'node:crypto';
import type { IBlock } from '@rocket.chat/apps-engine/definition/uikit';
import type { LayoutBlock } from '@rocket.chat/ui-kit';
/**
* Duplicated from `packages/apps/src/server/misc/UIHelper.ts` so the base-runtime
* does not have to import the host's compiled `apps/dist` output (a CJS `require`
* that bypasses the Deno import map, and would create a host→base-runtime build
* cycle if imported the other way). Both imports here are type-only (erased at
* transpile) except `node:crypto`, which is available in every runtime.
*
* This is the single source of truth once the host accessors that still use the
* `src/server/misc` copy are removed in the teardown phase of the accessor
* consolidation; until then the two copies are kept byte-for-byte identical.
*/
export class UIHelper {
/**
* Assign blockId, appId and actionId to every block/element inside the array
* @param blocks the blocks that will be iterated and assigned the ids
* @param appId the appId that will be assigned to
* @returns the array of block with the ids properties assigned
*/
public static assignIds(blocks: Array<IBlock | LayoutBlock>, appId: string): Array<IBlock | LayoutBlock> {
blocks.forEach((block: (IBlock | LayoutBlock) & { appId?: string; blockId?: string; elements?: Array<any> }) => {
if (!block.appId) {
block.appId = appId;
}
if (!block.blockId) {
block.blockId = randomUUID();
}
if (block.elements) {
block.elements.forEach((element) => {
if (!element.actionId) {
element.actionId = randomUUID();
}
});
}
});
return blocks;
}
}

View File

@@ -0,0 +1,53 @@
import type { IPersistence } from '@rocket.chat/apps-engine/definition/accessors';
import type { RocketChatAssociationRecord } from '@rocket.chat/apps-engine/definition/metadata';
import { bridgeCall } from '../bridges/bridgeCall';
import type * as Messenger from '../messenger';
export class Persistence implements IPersistence {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public create(data: object): Promise<string> {
return bridgeCall<string>(this.senderFn, 'getPersistenceBridge', 'doCreate', data, 'APP_ID');
}
public createWithAssociation(data: object, association: RocketChatAssociationRecord): Promise<string> {
return bridgeCall<string>(this.senderFn, 'getPersistenceBridge', 'doCreateWithAssociations', data, new Array(association), 'APP_ID');
}
public createWithAssociations(data: object, associations: Array<RocketChatAssociationRecord>): Promise<string> {
return bridgeCall<string>(this.senderFn, 'getPersistenceBridge', 'doCreateWithAssociations', data, associations, 'APP_ID');
}
public update(id: string, data: object, upsert = false): Promise<string> {
return bridgeCall<string>(this.senderFn, 'getPersistenceBridge', 'doUpdate', id, data, upsert, 'APP_ID');
}
public updateByAssociation(association: RocketChatAssociationRecord, data: object, upsert = false): Promise<string> {
return bridgeCall<string>(
this.senderFn,
'getPersistenceBridge',
'doUpdateByAssociations',
new Array(association),
data,
upsert,
'APP_ID',
);
}
public updateByAssociations(associations: Array<RocketChatAssociationRecord>, data: object, upsert = false): Promise<string> {
return bridgeCall<string>(this.senderFn, 'getPersistenceBridge', 'doUpdateByAssociations', associations, data, upsert, 'APP_ID');
}
public remove(id: string): Promise<object> {
return bridgeCall<object>(this.senderFn, 'getPersistenceBridge', 'doRemove', id, 'APP_ID');
}
public removeByAssociation(association: RocketChatAssociationRecord): Promise<Array<object>> {
return bridgeCall<Array<object>>(this.senderFn, 'getPersistenceBridge', 'doRemoveByAssociations', new Array(association), 'APP_ID');
}
public removeByAssociations(associations: Array<RocketChatAssociationRecord>): Promise<Array<object>> {
return bridgeCall<Array<object>>(this.senderFn, 'getPersistenceBridge', 'doRemoveByAssociations', associations, 'APP_ID');
}
}

View File

@@ -0,0 +1,16 @@
import { BlockBuilder as AppsEngineBlockBuilder } from '@rocket.chat/apps-engine/definition/uikit/blocks/BlockBuilder';
import { AppObjectRegistry } from '../../../AppObjectRegistry';
/**
* Local BlockBuilder that extends the apps-engine BlockBuilder.
* It overrides the constructor to source the appId from the registry
* instead of requiring it as a constructor argument.
*
* @deprecated please prefer the rocket.chat/ui-kit components
*/
export class BlockBuilder extends AppsEngineBlockBuilder {
constructor() {
super(String(AppObjectRegistry.get('id') ?? ''));
}
}

View File

@@ -0,0 +1,48 @@
import type { IDiscussionBuilder } from '@rocket.chat/apps-engine/definition/accessors/IDiscussionBuilder';
import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage';
import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations';
import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom';
import { RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType';
import { RoomBuilder } from './RoomBuilder';
export class DiscussionBuilder extends RoomBuilder implements IDiscussionBuilder {
declare public kind: RocketChatAssociationModel.DISCUSSION;
private reply?: string;
private parentMessage?: IMessage;
constructor(data?: Partial<IRoom>) {
super(data);
this.kind = RocketChatAssociationModel.DISCUSSION;
this.room.type = RoomType.PRIVATE_GROUP;
}
public setParentRoom(parentRoom: IRoom): IDiscussionBuilder {
this.room.parentRoom = parentRoom;
return this;
}
public getParentRoom(): IRoom {
return this.room.parentRoom!;
}
public setReply(reply: string): IDiscussionBuilder {
this.reply = reply;
return this;
}
public getReply(): string {
return this.reply!;
}
public setParentMessage(parentMessage: IMessage): IDiscussionBuilder {
this.parentMessage = parentMessage;
return this;
}
public getParentMessage(): IMessage {
return this.parentMessage!;
}
}

View File

@@ -0,0 +1,196 @@
import type { ILivechatMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/ILivechatMessageBuilder';
import type { IMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/IMessageBuilder';
import type { ILivechatMessage as EngineLivechatMessage } from '@rocket.chat/apps-engine/definition/livechat/ILivechatMessage';
import type { IVisitor } from '@rocket.chat/apps-engine/definition/livechat/IVisitor';
import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage';
import type { IMessageAttachment } from '@rocket.chat/apps-engine/definition/messages/IMessageAttachment';
import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations';
import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom';
import { RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType';
import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser';
import { MessageBuilder } from './MessageBuilder';
export interface ILivechatMessage extends EngineLivechatMessage, IMessage {}
export class LivechatMessageBuilder implements ILivechatMessageBuilder {
public kind: RocketChatAssociationModel.LIVECHAT_MESSAGE;
private msg: ILivechatMessage;
constructor(message?: ILivechatMessage) {
this.kind = RocketChatAssociationModel.LIVECHAT_MESSAGE;
this.msg = message || ({} as ILivechatMessage);
}
public setData(data: ILivechatMessage): ILivechatMessageBuilder {
delete data.id;
this.msg = data;
return this;
}
public setRoom(room: IRoom): ILivechatMessageBuilder {
this.msg.room = room;
return this;
}
public getRoom(): IRoom {
return this.msg.room;
}
public setSender(sender: IUser): ILivechatMessageBuilder {
this.msg.sender = sender;
delete this.msg.visitor;
return this;
}
public getSender(): IUser {
return this.msg.sender;
}
public setText(text: string): ILivechatMessageBuilder {
this.msg.text = text;
return this;
}
public getText(): string {
return this.msg.text!;
}
public setEmojiAvatar(emoji: string): ILivechatMessageBuilder {
this.msg.emoji = emoji;
return this;
}
public getEmojiAvatar(): string {
return this.msg.emoji!;
}
public setAvatarUrl(avatarUrl: string): ILivechatMessageBuilder {
this.msg.avatarUrl = avatarUrl;
return this;
}
public getAvatarUrl(): string {
return this.msg.avatarUrl!;
}
public setUsernameAlias(alias: string): ILivechatMessageBuilder {
this.msg.alias = alias;
return this;
}
public getUsernameAlias(): string {
return this.msg.alias!;
}
public addAttachment(attachment: IMessageAttachment): ILivechatMessageBuilder {
if (!this.msg.attachments) {
this.msg.attachments = [];
}
this.msg.attachments.push(attachment);
return this;
}
public setAttachments(attachments: Array<IMessageAttachment>): ILivechatMessageBuilder {
this.msg.attachments = attachments;
return this;
}
public getAttachments(): Array<IMessageAttachment> {
return this.msg.attachments!;
}
public replaceAttachment(position: number, attachment: IMessageAttachment): ILivechatMessageBuilder {
if (!this.msg.attachments) {
this.msg.attachments = [];
}
if (!this.msg.attachments[position]) {
throw new Error(`No attachment found at the index of "${position}" to replace.`);
}
this.msg.attachments[position] = attachment;
return this;
}
public removeAttachment(position: number): ILivechatMessageBuilder {
if (!this.msg.attachments) {
this.msg.attachments = [];
}
if (!this.msg.attachments[position]) {
throw new Error(`No attachment found at the index of "${position}" to remove.`);
}
this.msg.attachments.splice(position, 1);
return this;
}
public setEditor(user: IUser): ILivechatMessageBuilder {
this.msg.editor = user;
return this;
}
public getEditor(): IUser {
return this.msg.editor;
}
public setGroupable(groupable: boolean): ILivechatMessageBuilder {
this.msg.groupable = groupable;
return this;
}
public getGroupable(): boolean {
return this.msg.groupable!;
}
public setParseUrls(parseUrls: boolean): ILivechatMessageBuilder {
this.msg.parseUrls = parseUrls;
return this;
}
public getParseUrls(): boolean {
return this.msg.parseUrls!;
}
public setToken(token: string): ILivechatMessageBuilder {
this.msg.token = token;
return this;
}
public getToken(): string {
return this.msg.token!;
}
public setVisitor(visitor: IVisitor): ILivechatMessageBuilder {
this.msg.visitor = visitor;
delete this.msg.sender;
return this;
}
public getVisitor(): IVisitor {
return this.msg.visitor;
}
public getMessage(): ILivechatMessage {
if (!this.msg.room) {
throw new Error('The "room" property is required.');
}
if (this.msg.room.type !== RoomType.LIVE_CHAT) {
throw new Error('The room is not a Livechat room');
}
return this.msg;
}
public getMessageBuilder(): IMessageBuilder {
return new MessageBuilder(this.msg as IMessage);
}
}

View File

@@ -0,0 +1,267 @@
import type { IMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/IMessageBuilder';
import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage';
import type { IMessageAttachment } from '@rocket.chat/apps-engine/definition/messages/IMessageAttachment';
import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations';
import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom';
import type { IBlock } from '@rocket.chat/apps-engine/definition/uikit/blocks/Blocks';
import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser';
import type { LayoutBlock } from '@rocket.chat/ui-kit';
import { BlockBuilder } from './BlockBuilder';
export class MessageBuilder implements IMessageBuilder {
public kind: RocketChatAssociationModel.MESSAGE;
private msg: IMessage;
private changes: Partial<IMessage> = {};
private attachmentsChanged = false;
private customFieldsChanged = false;
constructor(message?: IMessage) {
this.kind = RocketChatAssociationModel.MESSAGE;
this.msg = message || ({} as IMessage);
}
public setData(data: IMessage): IMessageBuilder {
delete data.id;
this.msg = data;
return this as IMessageBuilder;
}
public setUpdateData(data: IMessage, editor: IUser): IMessageBuilder {
this.msg = data;
this.msg.editor = editor;
this.msg.editedAt = new Date();
this.changes = structuredClone(this.msg);
return this as IMessageBuilder;
}
public setThreadId(threadId: string): IMessageBuilder {
this.msg.threadId = threadId;
this.changes.threadId = threadId;
return this as IMessageBuilder;
}
public getThreadId(): string {
return this.msg.threadId!;
}
public setRoom(room: IRoom): IMessageBuilder {
this.msg.room = room;
this.changes.room = room;
return this as IMessageBuilder;
}
public getRoom(): IRoom {
return this.msg.room;
}
public setSender(sender: IUser): IMessageBuilder {
this.msg.sender = sender;
this.changes.sender = sender;
return this as IMessageBuilder;
}
public getSender(): IUser {
return this.msg.sender;
}
public setText(text: string): IMessageBuilder {
this.msg.text = text;
this.changes.text = text;
return this as IMessageBuilder;
}
public getText(): string {
return this.msg.text!;
}
public setEmojiAvatar(emoji: string): IMessageBuilder {
this.msg.emoji = emoji;
this.changes.emoji = emoji;
return this as IMessageBuilder;
}
public getEmojiAvatar(): string {
return this.msg.emoji!;
}
public setAvatarUrl(avatarUrl: string): IMessageBuilder {
this.msg.avatarUrl = avatarUrl;
this.changes.avatarUrl = avatarUrl;
return this as IMessageBuilder;
}
public getAvatarUrl(): string {
return this.msg.avatarUrl!;
}
public setUsernameAlias(alias: string): IMessageBuilder {
this.msg.alias = alias;
this.changes.alias = alias;
return this as IMessageBuilder;
}
public getUsernameAlias(): string {
return this.msg.alias!;
}
public addAttachment(attachment: IMessageAttachment): IMessageBuilder {
if (!this.msg.attachments) {
this.msg.attachments = [];
}
this.msg.attachments.push(attachment);
this.attachmentsChanged = true;
return this as IMessageBuilder;
}
public setAttachments(attachments: Array<IMessageAttachment>): IMessageBuilder {
this.msg.attachments = attachments;
this.attachmentsChanged = true;
return this as IMessageBuilder;
}
public getAttachments(): Array<IMessageAttachment> {
return this.msg.attachments!;
}
public replaceAttachment(position: number, attachment: IMessageAttachment): IMessageBuilder {
if (!this.msg.attachments?.[position]) {
throw new Error(`No attachment found at the index of "${position}" to replace.`);
}
this.msg.attachments[position] = attachment;
this.attachmentsChanged = true;
return this as IMessageBuilder;
}
public removeAttachment(position: number): IMessageBuilder {
if (!this.msg.attachments?.[position]) {
throw new Error(`No attachment found at the index of "${position}" to remove.`);
}
this.msg.attachments.splice(position, 1);
this.attachmentsChanged = true;
return this as IMessageBuilder;
}
public setEditor(user: IUser): IMessageBuilder {
this.msg.editor = user;
this.changes.editor = user;
return this as IMessageBuilder;
}
public getEditor(): IUser {
return this.msg.editor;
}
public setGroupable(groupable: boolean): IMessageBuilder {
this.msg.groupable = groupable;
this.changes.groupable = groupable;
return this as IMessageBuilder;
}
public getGroupable(): boolean {
return this.msg.groupable!;
}
public setParseUrls(parseUrls: boolean): IMessageBuilder {
this.msg.parseUrls = parseUrls;
this.changes.parseUrls = parseUrls;
return this as IMessageBuilder;
}
public getParseUrls(): boolean {
return this.msg.parseUrls!;
}
public getMessage(): IMessage {
if (!this.msg.room) {
throw new Error('The "room" property is required.');
}
return this.msg;
}
public addBlocks(blocks: BlockBuilder | Array<IBlock | LayoutBlock>) {
if (!Array.isArray(this.msg.blocks)) {
this.msg.blocks = [];
}
if (blocks instanceof BlockBuilder) {
this.msg.blocks.push(...blocks.getBlocks());
} else {
this.msg.blocks.push(...blocks);
}
return this as IMessageBuilder;
}
public setBlocks(blocks: BlockBuilder | Array<IBlock | LayoutBlock>) {
const blockArray: Array<IBlock | LayoutBlock> = blocks instanceof BlockBuilder ? blocks.getBlocks() : blocks;
this.msg.blocks = blockArray;
this.changes.blocks = blockArray;
return this as IMessageBuilder;
}
public getBlocks() {
return this.msg.blocks!;
}
public addCustomField(key: string, value: unknown): IMessageBuilder {
if (!this.msg.customFields) {
this.msg.customFields = {};
}
if (this.msg.customFields[key]) {
throw new Error(`The message already contains a custom field by the key: ${key}`);
}
if (key.includes('.')) {
throw new Error(`The given key contains a period, which is not allowed. Key: ${key}`);
}
this.msg.customFields[key] = value;
this.customFieldsChanged = true;
return this as IMessageBuilder;
}
public getChanges(): Partial<IMessage> {
const changes: typeof this.changes = structuredClone(this.changes);
if (this.attachmentsChanged) {
changes.attachments = structuredClone(this.msg.attachments);
}
if (this.customFieldsChanged) {
changes.customFields = structuredClone(this.msg.customFields);
}
return changes;
}
}

View File

@@ -0,0 +1,191 @@
import type { IRoomBuilder } from '@rocket.chat/apps-engine/definition/accessors/IRoomBuilder';
import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations';
import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom';
import type { RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType';
import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser';
export class RoomBuilder implements IRoomBuilder {
public kind: RocketChatAssociationModel.ROOM | RocketChatAssociationModel.DISCUSSION;
protected room: IRoom;
private members: Array<string>;
private changes: Partial<IRoom> = {};
private customFieldsChanged = false;
constructor(data?: Partial<IRoom>) {
this.kind = RocketChatAssociationModel.ROOM;
this.room = (data || { customFields: {} }) as IRoom;
this.members = [];
}
public setData(data: Partial<IRoom>): IRoomBuilder {
delete data.id;
this.room = data as IRoom;
this.changes = structuredClone(this.room);
return this;
}
public setDisplayName(name: string): IRoomBuilder {
this.room.displayName = name;
this.changes.displayName = name;
return this;
}
public getDisplayName(): string {
return this.room.displayName!;
}
public setSlugifiedName(name: string): IRoomBuilder {
this.room.slugifiedName = name;
this.changes.slugifiedName = name;
return this;
}
public getSlugifiedName(): string {
return this.room.slugifiedName;
}
public setType(type: RoomType): IRoomBuilder {
this.room.type = type;
this.changes.type = type;
return this;
}
public getType(): RoomType {
return this.room.type;
}
public setCreator(creator: IUser): IRoomBuilder {
this.room.creator = creator;
this.changes.creator = creator;
return this;
}
public getCreator(): IUser {
return this.room.creator;
}
/**
* @deprecated
*/
public addUsername(username: string): IRoomBuilder {
this.addMemberToBeAddedByUsername(username);
return this;
}
/**
* @deprecated
*/
public setUsernames(usernames: Array<string>): IRoomBuilder {
this.setMembersToBeAddedByUsernames(usernames);
return this;
}
/**
* @deprecated
*/
public getUsernames(): Array<string> {
const usernames = this.getMembersToBeAddedUsernames();
if (usernames && usernames.length > 0) {
return usernames;
}
return this.room.usernames || [];
}
public addMemberToBeAddedByUsername(username: string): IRoomBuilder {
this.members.push(username);
return this;
}
public setMembersToBeAddedByUsernames(usernames: Array<string>): IRoomBuilder {
this.members = usernames;
return this;
}
public getMembersToBeAddedUsernames(): Array<string> {
return this.members;
}
public setDefault(isDefault: boolean): IRoomBuilder {
this.room.isDefault = isDefault;
this.changes.isDefault = isDefault;
return this;
}
public getIsDefault(): boolean {
return this.room.isDefault!;
}
public setReadOnly(isReadOnly: boolean): IRoomBuilder {
this.room.isReadOnly = isReadOnly;
this.changes.isReadOnly = isReadOnly;
return this;
}
public getIsReadOnly(): boolean {
return this.room.isReadOnly!;
}
public setDisplayingOfSystemMessages(displaySystemMessages: boolean): IRoomBuilder {
this.room.displaySystemMessages = displaySystemMessages;
this.changes.displaySystemMessages = displaySystemMessages;
return this;
}
public getDisplayingOfSystemMessages(): boolean {
return this.room.displaySystemMessages!;
}
public addCustomField(key: string, value: object): IRoomBuilder {
if (typeof this.room.customFields !== 'object') {
this.room.customFields = {};
}
this.room.customFields[key] = value;
this.customFieldsChanged = true;
return this;
}
public setCustomFields(fields: { [key: string]: object }): IRoomBuilder {
this.room.customFields = fields;
this.customFieldsChanged = true;
return this;
}
public getCustomFields(): { [key: string]: object } {
return this.room.customFields!;
}
public getUserIds(): Array<string> {
return this.room.userIds!;
}
public getRoom(): IRoom {
return this.room;
}
public getChanges() {
const changes: Partial<IRoom> = structuredClone(this.changes);
if (this.customFieldsChanged) {
changes.customFields = structuredClone(this.room.customFields);
}
return changes;
}
}

View File

@@ -0,0 +1,75 @@
import type { IUserBuilder } from '@rocket.chat/apps-engine/definition/accessors/IUserBuilder';
import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations';
import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser';
import type { IUserEmail } from '@rocket.chat/apps-engine/definition/users/IUserEmail';
import type { IUserSettings } from '@rocket.chat/apps-engine/definition/users/IUserSettings';
export class UserBuilder implements IUserBuilder {
public kind: RocketChatAssociationModel.USER;
private user: Partial<IUser>;
constructor(user?: Partial<IUser>) {
this.kind = RocketChatAssociationModel.USER;
this.user = user || ({} as Partial<IUser>);
}
public setData(data: Partial<IUser>): IUserBuilder {
delete data.id;
this.user = data;
return this;
}
public setEmails(emails: Array<IUserEmail>): IUserBuilder {
this.user.emails = emails;
return this;
}
public getEmails(): Array<IUserEmail> {
return this.user.emails!;
}
public setDisplayName(name: string): IUserBuilder {
this.user.name = name;
return this;
}
public getDisplayName(): string {
return this.user.name!;
}
public setUsername(username: string): IUserBuilder {
this.user.username = username;
return this;
}
public getUsername(): string {
return this.user.username!;
}
public setRoles(roles: Array<string>): IUserBuilder {
this.user.roles = roles;
return this;
}
public getRoles(): Array<string> {
return this.user.roles!;
}
public getSettings(): Partial<IUserSettings> {
return this.user.settings;
}
public getUser(): Partial<IUser> {
if (!this.user.username) {
throw new Error('The "username" property is required.');
}
if (!this.user.name) {
throw new Error('The "name" property is required.');
}
return this.user;
}
}

View File

@@ -0,0 +1,87 @@
import type { IVideoConferenceBuilder } from '@rocket.chat/apps-engine/definition/accessors/IVideoConferenceBuilder';
import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations';
import type { IGroupVideoConference } from '@rocket.chat/apps-engine/definition/videoConferences/IVideoConference';
export type AppVideoConference = Pick<IGroupVideoConference, 'rid' | 'providerName' | 'providerData' | 'title' | 'discussionRid'> & {
createdBy: IGroupVideoConference['createdBy']['_id'];
};
export class VideoConferenceBuilder implements IVideoConferenceBuilder {
public kind: RocketChatAssociationModel.VIDEO_CONFERENCE = RocketChatAssociationModel.VIDEO_CONFERENCE;
protected call: AppVideoConference;
constructor(data?: Partial<AppVideoConference>) {
this.call = (data || {}) as AppVideoConference;
}
public setData(data: Partial<AppVideoConference>): IVideoConferenceBuilder {
this.call = {
rid: data.rid!,
createdBy: data.createdBy,
providerName: data.providerName!,
title: data.title!,
discussionRid: data.discussionRid,
};
return this;
}
public setRoomId(rid: string): IVideoConferenceBuilder {
this.call.rid = rid;
return this;
}
public getRoomId(): string {
return this.call.rid;
}
public setCreatedBy(userId: string): IVideoConferenceBuilder {
this.call.createdBy = userId;
return this;
}
public getCreatedBy(): string {
return this.call.createdBy;
}
public setProviderName(userId: string): IVideoConferenceBuilder {
this.call.providerName = userId;
return this;
}
public getProviderName(): string {
return this.call.providerName;
}
public setProviderData(data: Record<string, unknown> | undefined): IVideoConferenceBuilder {
this.call.providerData = data;
return this;
}
public getProviderData(): Record<string, unknown> {
return this.call.providerData!;
}
public setTitle(userId: string): IVideoConferenceBuilder {
this.call.title = userId;
return this;
}
public getTitle(): string {
return this.call.title;
}
public setDiscussionRid(rid: AppVideoConference['discussionRid']): IVideoConferenceBuilder {
this.call.discussionRid = rid;
return this;
}
public getDiscussionRid(): AppVideoConference['discussionRid'] {
return this.call.discussionRid;
}
public getVideoConference(): AppVideoConference {
return this.call;
}
}

View File

@@ -0,0 +1,26 @@
import type {
IEnvironmentalVariableRead,
IEnvironmentRead,
IServerSettingRead,
ISettingRead,
} from '@rocket.chat/apps-engine/definition/accessors';
export class EnvironmentRead implements IEnvironmentRead {
constructor(
private readonly settings: ISettingRead,
private readonly serverSettings: IServerSettingRead,
private readonly envRead: IEnvironmentalVariableRead,
) {}
public getSettings(): ISettingRead {
return this.settings;
}
public getServerSettings(): IServerSettingRead {
return this.serverSettings;
}
public getEnvironmentVariables(): IEnvironmentalVariableRead {
return this.envRead;
}
}

View File

@@ -0,0 +1,16 @@
import type { IEnvironmentWrite, IServerSettingUpdater, ISettingUpdater } from '@rocket.chat/apps-engine/definition/accessors';
export class EnvironmentWrite implements IEnvironmentWrite {
constructor(
private readonly settings: ISettingUpdater,
private readonly serverSettings: IServerSettingUpdater,
) {}
public getSettings(): ISettingUpdater {
return this.settings;
}
public getServerSettings(): IServerSettingUpdater {
return this.serverSettings;
}
}

View File

@@ -0,0 +1,20 @@
import type { IEnvironmentalVariableRead } from '@rocket.chat/apps-engine/definition/accessors';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
export class EnvironmentalVariableRead implements IEnvironmentalVariableRead {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public getValueByName(envVarName: string): Promise<string> {
return bridgeCall<string>(this.senderFn, 'getEnvironmentalVariableBridge', 'doGetValueByName', envVarName, 'APP_ID');
}
public isReadable(envVarName: string): Promise<boolean> {
return bridgeCall<boolean>(this.senderFn, 'getEnvironmentalVariableBridge', 'doIsReadable', envVarName, 'APP_ID');
}
public isSet(envVarName: string): Promise<boolean> {
return bridgeCall<boolean>(this.senderFn, 'getEnvironmentalVariableBridge', 'doIsSet', envVarName, 'APP_ID');
}
}

View File

@@ -0,0 +1,37 @@
import type { IServerSettingRead } from '@rocket.chat/apps-engine/definition/accessors';
import type { ISetting } from '@rocket.chat/apps-engine/definition/settings';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
export class ServerSettingRead implements IServerSettingRead {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public getOneById(id: string): Promise<ISetting> {
return bridgeCall<ISetting>(this.senderFn, 'getServerSettingBridge', 'doGetOneById', id, 'APP_ID');
}
public async getValueById(id: string): Promise<any> {
const set = (await bridgeCall(this.senderFn, 'getServerSettingBridge', 'doGetOneById', id, 'APP_ID')) as ISetting;
// The host accessor checks `typeof set === 'undefined'`, but across the RPC boundary an
// absent (undefined) host return is serialized as null, so both must be treated as "not found".
if (set === undefined || set === null) {
throw new Error(`No Server Setting found, or it is unaccessible, by the id of "${id}".`);
}
if (set.value === undefined || set.value === null) {
return set.packageValue;
}
return set.value;
}
public getAll(): Promise<IterableIterator<ISetting>> {
throw new Error('Method not implemented.');
}
public isReadableById(id: string): Promise<boolean> {
return bridgeCall<boolean>(this.senderFn, 'getServerSettingBridge', 'doIsReadableById', id, 'APP_ID');
}
}

View File

@@ -0,0 +1,17 @@
import type { IServerSettingUpdater } from '@rocket.chat/apps-engine/definition/accessors';
import type { ISetting } from '@rocket.chat/apps-engine/definition/settings';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
export class ServerSettingUpdater implements IServerSettingUpdater {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public async updateOne(setting: ISetting): Promise<void> {
await bridgeCall(this.senderFn, 'getServerSettingBridge', 'doUpdateOne', setting, 'APP_ID');
}
public async incrementValue(id: ISetting['id'], value = 1): Promise<void> {
await bridgeCall(this.senderFn, 'getServerSettingBridge', 'doIncrementValue', id, value, 'APP_ID');
}
}

View File

@@ -0,0 +1,25 @@
import type { IServerSettingsModify } from '@rocket.chat/apps-engine/definition/accessors';
import type { ISetting } from '@rocket.chat/apps-engine/definition/settings';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
export class ServerSettingsModify implements IServerSettingsModify {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public async hideGroup(name: string): Promise<void> {
await bridgeCall(this.senderFn, 'getServerSettingBridge', 'doHideGroup', name, 'APP_ID');
}
public async hideSetting(id: string): Promise<void> {
await bridgeCall(this.senderFn, 'getServerSettingBridge', 'doHideSetting', id, 'APP_ID');
}
public async modifySetting(setting: ISetting): Promise<void> {
await bridgeCall(this.senderFn, 'getServerSettingBridge', 'doUpdateOne', setting, 'APP_ID');
}
public async incrementValue(id: ISetting['id'], value = 1): Promise<void> {
await bridgeCall(this.senderFn, 'getServerSettingBridge', 'doIncrementValue', id, value, 'APP_ID');
}
}

View File

@@ -0,0 +1,31 @@
import type { ISettingRead } from '@rocket.chat/apps-engine/definition/accessors';
import type { ISetting } from '@rocket.chat/apps-engine/definition/settings';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
// App settings are host-persisted metadata (ProxiedApp storage item), fronted by the internal
// AppResourceBridge. The value fallback that used to run host-side now runs locally.
export class SettingRead implements ISettingRead {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public getById(id: string): Promise<ISetting> {
return bridgeCall<ISetting>(this.senderFn, 'getAppResourceBridge', 'doGetSettingById', id, 'APP_ID');
}
public async getValueById(id: string): Promise<any> {
const set = (await this.getById(id)) as ISetting;
// The host accessor checks `typeof set === 'undefined'`; across the RPC boundary an absent
// host return arrives as null, so both are treated as "does not exist".
if (set === undefined || set === null) {
throw new Error(`Setting "${id}" does not exist.`);
}
if (set.value === undefined || set.value === null) {
return set.packageValue;
}
return set.value;
}
}

View File

@@ -0,0 +1,20 @@
import type { ISettingUpdater } from '@rocket.chat/apps-engine/definition/accessors/ISettingUpdater';
import type { ISetting } from '@rocket.chat/apps-engine/definition/settings';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
// The "not found" guard and the AppSettingsManager persistence run host-side in the
// AppResourceBridge (they depend on the ProxiedApp storage item and the settings manager); the
// runtime accessor is a thin forwarder.
export class SettingUpdater implements ISettingUpdater {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public async updateValue(id: ISetting['id'], value: ISetting['value']): Promise<void> {
await bridgeCall(this.senderFn, 'getAppResourceBridge', 'doUpdateSettingValue', id, value, 'APP_ID');
}
public async updateSelectOptions(id: ISetting['id'], values: ISetting['values']): Promise<void> {
await bridgeCall(this.senderFn, 'getAppResourceBridge', 'doUpdateSettingSelectOptions', id, values, 'APP_ID');
}
}

View File

@@ -0,0 +1,92 @@
import * as assert from 'node:assert';
import { describe, it } from 'node:test';
import { createRecordingSender } from '../../tests/helpers/parityHarness';
import { EnvironmentalVariableRead } from '../EnvironmentalVariableRead';
import { ServerSettingRead } from '../ServerSettingRead';
import { ServerSettingUpdater } from '../ServerSettingUpdater';
import { ServerSettingsModify } from '../ServerSettingsModify';
const setup = (responses = {}) => {
const rec = createRecordingSender(responses);
return { rec, senderFn: rec.sender };
};
describe('Environment accessors (base-runtime)', () => {
describe('ServerSettingRead', () => {
it('getValueById returns the value when set', async () => {
const { senderFn } = setup({ 'bridges:getServerSettingBridge:doGetOneById': { value: 'v', packageValue: 'pv' } });
assert.strictEqual(await new ServerSettingRead(senderFn).getValueById('s1'), 'v');
});
it('getValueById falls back to packageValue when value is null', async () => {
const nullVal = setup({ 'bridges:getServerSettingBridge:doGetOneById': { value: null, packageValue: 'pv' } });
assert.strictEqual(await new ServerSettingRead(nullVal.senderFn).getValueById('s1'), 'pv');
});
it('getValueById falls back to packageValue when value is undefined', async () => {
const undefinedVal = setup({ 'bridges:getServerSettingBridge:doGetOneById': { value: undefined, packageValue: 'pv' } });
assert.strictEqual(await new ServerSettingRead(undefinedVal.senderFn).getValueById('s1'), 'pv');
});
it('getValueById throws when the setting is not found', async () => {
const { senderFn } = setup({ 'bridges:getServerSettingBridge:doGetOneById': undefined });
await assert.rejects(() => new ServerSettingRead(senderFn).getValueById('missing'), /No Server Setting found/);
});
it('getAll throws (not implemented), matching the host accessor', () => {
const { senderFn } = setup();
assert.throws(() => new ServerSettingRead(senderFn).getAll(), /Method not implemented/);
});
it('getOneById and isReadableById forward verbatim', async () => {
const { rec, senderFn } = setup();
const read = new ServerSettingRead(senderFn);
await read.getOneById('s1');
await read.isReadableById('s1');
assert.deepStrictEqual(rec.emitted(), [
{ method: 'bridges:getServerSettingBridge:doGetOneById', params: ['s1', 'APP_ID'] },
{ method: 'bridges:getServerSettingBridge:doIsReadableById', params: ['s1', 'APP_ID'] },
]);
});
});
describe('EnvironmentalVariableRead', () => {
it('forwards each method to its bridge call', async () => {
const { rec, senderFn } = setup();
const env = new EnvironmentalVariableRead(senderFn);
await env.getValueByName('X');
await env.isReadable('X');
await env.isSet('X');
assert.deepStrictEqual(rec.methods(), [
'bridges:getEnvironmentalVariableBridge:doGetValueByName',
'bridges:getEnvironmentalVariableBridge:doIsReadable',
'bridges:getEnvironmentalVariableBridge:doIsSet',
]);
});
});
describe('ServerSettingUpdater', () => {
it('incrementValue defaults the amount to 1', async () => {
const { rec, senderFn } = setup();
await new ServerSettingUpdater(senderFn).incrementValue('s1');
assert.deepStrictEqual(rec.emitted()[0], {
method: 'bridges:getServerSettingBridge:doIncrementValue',
params: ['s1', 1, 'APP_ID'],
});
});
});
describe('ServerSettingsModify', () => {
it('modifySetting maps to doUpdateOne and incrementValue defaults to 1', async () => {
const { rec, senderFn } = setup();
const modify = new ServerSettingsModify(senderFn);
await modify.modifySetting({ id: 's1' } as any);
await modify.incrementValue('s1');
assert.deepStrictEqual(rec.emitted(), [
{ method: 'bridges:getServerSettingBridge:doUpdateOne', params: [{ id: 's1' }, 'APP_ID'] },
{ method: 'bridges:getServerSettingBridge:doIncrementValue', params: ['s1', 1, 'APP_ID'] },
]);
});
});
});

View File

@@ -0,0 +1,58 @@
import type { IHttpExtend, IHttpPreRequestHandler, IHttpPreResponseHandler } from '@rocket.chat/apps-engine/definition/accessors/IHttp';
export class HttpExtend implements IHttpExtend {
private headers: Map<string, string>;
private params: Map<string, string>;
private requests: Array<IHttpPreRequestHandler>;
private responses: Array<IHttpPreResponseHandler>;
constructor() {
this.headers = new Map<string, string>();
this.params = new Map<string, string>();
this.requests = [];
this.responses = [];
}
public provideDefaultHeader(key: string, value: string): void {
this.headers.set(key, value);
}
public provideDefaultHeaders(headers: { [key: string]: string }): void {
Object.keys(headers).forEach((key) => this.headers.set(key, headers[key]));
}
public provideDefaultParam(key: string, value: string): void {
this.params.set(key, value);
}
public provideDefaultParams(params: { [key: string]: string }): void {
Object.keys(params).forEach((key) => this.params.set(key, params[key]));
}
public providePreRequestHandler(handler: IHttpPreRequestHandler): void {
this.requests.push(handler);
}
public providePreResponseHandler(handler: IHttpPreResponseHandler): void {
this.responses.push(handler);
}
public getDefaultHeaders(): Map<string, string> {
return new Map<string, string>(this.headers);
}
public getDefaultParams(): Map<string, string> {
return new Map<string, string>(this.params);
}
public getPreRequestHandlers(): Array<IHttpPreRequestHandler> {
return Array.from(this.requests);
}
public getPreResponseHandlers(): Array<IHttpPreResponseHandler> {
return Array.from(this.responses);
}
}

View File

@@ -0,0 +1,60 @@
import type { IMessageExtender } from '@rocket.chat/apps-engine/definition/accessors/IMessageExtender';
import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage';
import type { IMessageAttachment } from '@rocket.chat/apps-engine/definition/messages/IMessageAttachment';
import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations';
export class MessageExtender implements IMessageExtender {
public readonly kind: RocketChatAssociationModel.MESSAGE;
constructor(private msg: IMessage) {
this.kind = RocketChatAssociationModel.MESSAGE;
if (!Array.isArray(msg.attachments)) {
this.msg.attachments = [];
}
}
public addCustomField(key: string, value: unknown): IMessageExtender {
if (!this.msg.customFields) {
this.msg.customFields = {};
}
if (this.msg.customFields[key]) {
throw new Error(`The message already contains a custom field by the key: ${key}`);
}
if (key.includes('.')) {
throw new Error(`The given key contains a period, which is not allowed. Key: ${key}`);
}
this.msg.customFields[key] = value;
return this;
}
public addAttachment(attachment: IMessageAttachment): IMessageExtender {
this.ensureAttachment();
this.msg.attachments!.push(attachment);
return this;
}
public addAttachments(attachments: Array<IMessageAttachment>): IMessageExtender {
this.ensureAttachment();
this.msg.attachments = this.msg.attachments!.concat(attachments);
return this;
}
public getMessage(): IMessage {
return structuredClone(this.msg);
}
private ensureAttachment(): void {
if (!Array.isArray(this.msg.attachments)) {
this.msg.attachments = [];
}
}
}

View File

@@ -0,0 +1,55 @@
import type { IRoomExtender } from '@rocket.chat/apps-engine/definition/accessors/IRoomExtender';
import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations';
import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom';
import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser';
export class RoomExtender implements IRoomExtender {
public kind: RocketChatAssociationModel.ROOM;
private members: Array<IUser>;
constructor(private room: IRoom) {
this.kind = RocketChatAssociationModel.ROOM;
this.members = [];
}
public addCustomField(key: string, value: unknown): IRoomExtender {
if (!this.room.customFields) {
this.room.customFields = {};
}
if (this.room.customFields[key]) {
throw new Error(`The room already contains a custom field by the key: ${key}`);
}
if (key.includes('.')) {
throw new Error(`The given key contains a period, which is not allowed. Key: ${key}`);
}
this.room.customFields[key] = value;
return this;
}
public addMember(user: IUser): IRoomExtender {
if (this.members.find((u) => u.username === user.username)) {
throw new Error('The user is already in the room.');
}
this.members.push(user);
return this;
}
public getMembersBeingAdded(): Array<IUser> {
return this.members;
}
public getUsernamesOfMembersBeingAdded(): Array<string> {
return this.members.map((u) => u.username);
}
public getRoom(): IRoom {
return structuredClone(this.room);
}
}

View File

@@ -0,0 +1,63 @@
import type { IVideoConferenceExtender } from '@rocket.chat/apps-engine/definition/accessors/IVideoConferenceExtend';
import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations';
import type { VideoConference, VideoConferenceMember } from '@rocket.chat/apps-engine/definition/videoConferences/IVideoConference';
import type { IVideoConferenceUser } from '@rocket.chat/apps-engine/definition/videoConferences/IVideoConferenceUser';
export class VideoConferenceExtender implements IVideoConferenceExtender {
public kind: RocketChatAssociationModel.VIDEO_CONFERENCE;
constructor(private videoConference: VideoConference) {
this.kind = RocketChatAssociationModel.VIDEO_CONFERENCE;
}
public setProviderData(value: Record<string, unknown>): IVideoConferenceExtender {
this.videoConference.providerData = value;
return this;
}
public setStatus(value: VideoConference['status']): IVideoConferenceExtender {
this.videoConference.status = value;
return this;
}
public setEndedBy(value: IVideoConferenceUser['_id']): IVideoConferenceExtender {
this.videoConference.endedBy = {
_id: value,
// Name and username will be loaded automatically by the bridge
username: '',
name: '',
};
return this;
}
public setEndedAt(value: VideoConference['endedAt']): IVideoConferenceExtender {
this.videoConference.endedAt = value;
return this;
}
public addUser(userId: VideoConferenceMember['_id'], ts?: VideoConferenceMember['ts']): IVideoConferenceExtender {
this.videoConference.users.push({
_id: userId,
ts,
// Name and username will be loaded automatically by the bridge
username: '',
name: '',
});
return this;
}
public setDiscussionRid(rid: VideoConference['discussionRid']): IVideoConferenceExtender {
this.videoConference.discussionRid = rid;
return this;
}
public getVideoConference(): VideoConference {
return structuredClone(this.videoConference);
}
}

View File

@@ -0,0 +1,14 @@
import { ErrorObject } from 'jsonrpc-lite';
// deno-lint-ignore no-explicit-any -- that is the type we get from `catch`
export const formatErrorResponse = (error: any): Error => {
if (error instanceof ErrorObject || typeof error?.error?.message === 'string') {
return new Error(error.error.message);
}
if (error instanceof Error) {
return error;
}
return new Error('An unknown error occurred', { cause: error });
};

View File

@@ -0,0 +1,90 @@
import type { IHttp, IHttpExtend, IHttpRequest, IHttpResponse } from '@rocket.chat/apps-engine/definition/accessors/IHttp';
import type { IPersistence } from '@rocket.chat/apps-engine/definition/accessors/IPersistence';
import type { IRead } from '@rocket.chat/apps-engine/definition/accessors/IRead';
import { AppObjectRegistry } from '../../AppObjectRegistry';
import { bridgeCall } from '../bridges/bridgeCall';
import type * as Messenger from '../messenger';
type RequestMethod = 'get' | 'post' | 'put' | 'head' | 'delete' | 'patch';
export class Http implements IHttp {
private httpExtender: IHttpExtend;
private read: IRead;
private persistence: IPersistence;
private senderFn: typeof Messenger.sendRequest;
constructor(read: IRead, persistence: IPersistence, httpExtender: IHttpExtend, senderFn: typeof Messenger.sendRequest) {
this.read = read;
this.persistence = persistence;
this.httpExtender = httpExtender;
this.senderFn = senderFn;
}
public get(url: string, options?: IHttpRequest): Promise<IHttpResponse> {
return this._processHandler(url, 'get', options);
}
public put(url: string, options?: IHttpRequest): Promise<IHttpResponse> {
return this._processHandler(url, 'put', options);
}
public post(url: string, options?: IHttpRequest): Promise<IHttpResponse> {
return this._processHandler(url, 'post', options);
}
public del(url: string, options?: IHttpRequest): Promise<IHttpResponse> {
return this._processHandler(url, 'delete', options);
}
public patch(url: string, options?: IHttpRequest): Promise<IHttpResponse> {
return this._processHandler(url, 'patch', options);
}
private async _processHandler(url: string, method: RequestMethod, options?: IHttpRequest): Promise<IHttpResponse> {
let request = options || {};
if (typeof request.headers === 'undefined') {
request.headers = {};
}
this.httpExtender.getDefaultHeaders().forEach((value: string, key: string) => {
if (typeof request.headers?.[key] !== 'string') {
request.headers![key] = value;
}
});
if (typeof request.params === 'undefined') {
request.params = {};
}
this.httpExtender.getDefaultParams().forEach((value: string, key: string) => {
if (typeof request.params?.[key] !== 'string') {
request.params![key] = value;
}
});
for (const handler of this.httpExtender.getPreRequestHandlers()) {
request = await handler.executePreHttpRequest(url, request, this.read, this.persistence);
}
// `appId` here is a field inside the payload object, not a top-level positional
// param, so the host's `'APP_ID'` sentinel substitution cannot reach it - we send
// the resolved id. See docs/proposals/apps-accessor-consolidation/base-runtime-app-id-exceptions.md (bucket C).
let response = await bridgeCall(this.senderFn, 'getHttpBridge', 'doCall', {
appId: AppObjectRegistry.get<string>('id'),
method,
url,
request,
});
for (const handler of this.httpExtender.getPreResponseHandlers()) {
response = await handler.executePreHttpResponse(response as IHttpResponse, this.read, this.persistence);
}
return response as IHttpResponse;
}
}

View File

@@ -0,0 +1,374 @@
import type { IApiExtend } from '@rocket.chat/apps-engine/definition/accessors/IApiExtend';
import type { IAppAccessors } from '@rocket.chat/apps-engine/definition/accessors/IAppAccessors';
import type { IConfigurationExtend } from '@rocket.chat/apps-engine/definition/accessors/IConfigurationExtend';
import type { IConfigurationModify } from '@rocket.chat/apps-engine/definition/accessors/IConfigurationModify';
import type { IEnvironmentRead } from '@rocket.chat/apps-engine/definition/accessors/IEnvironmentRead';
import type { IEnvironmentWrite } from '@rocket.chat/apps-engine/definition/accessors/IEnvironmentWrite';
import type { IExternalComponentsExtend } from '@rocket.chat/apps-engine/definition/accessors/IExternalComponentsExtend';
import type { IHttp, IHttpExtend } from '@rocket.chat/apps-engine/definition/accessors/IHttp';
import type { IModify } from '@rocket.chat/apps-engine/definition/accessors/IModify';
import type { INotifier } from '@rocket.chat/apps-engine/definition/accessors/INotifier';
import type { IOutboundCommunicationProviderExtend } from '@rocket.chat/apps-engine/definition/accessors/IOutboundCommunicationProviderExtend';
import type { IPersistence } from '@rocket.chat/apps-engine/definition/accessors/IPersistence';
import type { IRead } from '@rocket.chat/apps-engine/definition/accessors/IRead';
import type { ISchedulerExtend } from '@rocket.chat/apps-engine/definition/accessors/ISchedulerExtend';
import type { ISettingsExtend } from '@rocket.chat/apps-engine/definition/accessors/ISettingsExtend';
import type { ISlashCommandsExtend } from '@rocket.chat/apps-engine/definition/accessors/ISlashCommandsExtend';
import type { ISlashCommandsModify } from '@rocket.chat/apps-engine/definition/accessors/ISlashCommandsModify';
import type { IUIExtend } from '@rocket.chat/apps-engine/definition/accessors/IUIExtend';
import type { IVideoConfProvidersExtend } from '@rocket.chat/apps-engine/definition/accessors/IVideoConfProvidersExtend';
import type { IApi } from '@rocket.chat/apps-engine/definition/api/IApi';
import type { IApiEndpointMetadata } from '@rocket.chat/apps-engine/definition/api/IApiEndpointMetadata';
import type {
IOutboundPhoneMessageProvider,
IOutboundEmailMessageProvider,
} from '@rocket.chat/apps-engine/definition/outboundCommunication/IOutboundCommsProvider';
import type { IProcessor } from '@rocket.chat/apps-engine/definition/scheduler/IProcessor';
import type { ISlashCommand } from '@rocket.chat/apps-engine/definition/slashcommands/ISlashCommand';
import type { IVideoConfProvider } from '@rocket.chat/apps-engine/definition/videoConfProviders/IVideoConfProvider';
import { Persistence } from './Persistence';
import { HttpExtend } from './extenders/HttpExtender';
import { Http } from './http';
import { AppObjectRegistry } from '../../AppObjectRegistry';
import { bridgeCall } from '../bridges/bridgeCall';
import * as Messenger from '../messenger';
import { EnvironmentRead } from './environment/EnvironmentRead';
import { EnvironmentWrite } from './environment/EnvironmentWrite';
import { EnvironmentalVariableRead } from './environment/EnvironmentalVariableRead';
import { ServerSettingRead } from './environment/ServerSettingRead';
import { ServerSettingUpdater } from './environment/ServerSettingUpdater';
import { ServerSettingsModify } from './environment/ServerSettingsModify';
import { SettingRead } from './environment/SettingRead';
import { SettingUpdater } from './environment/SettingUpdater';
import { ModerationModify } from './modify/ModerationModify';
import { ModifyCreator } from './modify/ModifyCreator';
import { ModifyDeleter } from './modify/ModifyDeleter';
import { ModifyExtender } from './modify/ModifyExtender';
import { ModifyUpdater } from './modify/ModifyUpdater';
import { OAuthAppsModify } from './modify/OAuthAppsModify';
import { SchedulerModify } from './modify/SchedulerModify';
import { UIController } from './modify/UIController';
import { Notifier } from './notifier';
import { CloudWorkspaceRead } from './read/CloudWorkspaceRead';
import { ContactRead } from './read/ContactRead';
import { ExperimentalRead } from './read/ExperimentalRead';
import { LivechatRead } from './read/LivechatRead';
import { MessageRead } from './read/MessageRead';
import { OAuthAppsReader } from './read/OAuthAppsReader';
import { PersistenceRead } from './read/PersistenceRead';
import { Reader } from './read/Reader';
import { RoleRead } from './read/RoleRead';
import { RoomRead } from './read/RoomRead';
import { ThreadRead } from './read/ThreadRead';
import { UploadRead } from './read/UploadRead';
import { UserRead } from './read/UserRead';
import { VideoConferenceRead } from './read/VideoConferenceRead';
const httpMethods = ['get', 'post', 'put', 'delete', 'head', 'options', 'patch'] as const;
// We need to create this object first thing, as we'll handle references to it later on
if (!AppObjectRegistry.has('apiEndpoints')) {
AppObjectRegistry.set('apiEndpoints', []);
}
export class AppAccessors {
private defaultAppAccessors?: IAppAccessors;
private environmentRead?: IEnvironmentRead;
private environmentWriter?: IEnvironmentWrite;
private configModifier?: IConfigurationModify;
private configExtender?: IConfigurationExtend;
private reader?: IRead;
private modifier?: IModify;
private persistence?: IPersistence;
private creator?: ModifyCreator;
private updater?: ModifyUpdater;
private extender?: ModifyExtender;
private httpExtend: IHttpExtend = new HttpExtend();
private http?: IHttp;
private notifier?: INotifier;
constructor(private readonly senderFn: typeof Messenger.sendRequest) {
// `getReader()` bakes the notifier into the Reader, so the notifier has to be
// resolvable before this line runs -- `getNotifier()` creates it on demand.
this.http = new Http(this.getReader(), this.getPersistence(), this.httpExtend, this.getSenderFn());
}
public getSenderFn() {
return this.senderFn;
}
public getEnvironmentRead(): IEnvironmentRead {
if (!this.environmentRead) {
// App settings, server settings and environment variables all run locally now; app
// settings reach the host ProxiedApp storage item through the internal AppResourceBridge.
this.environmentRead = new EnvironmentRead(
new SettingRead(this.senderFn),
new ServerSettingRead(this.senderFn),
new EnvironmentalVariableRead(this.senderFn),
);
}
return this.environmentRead;
}
public getEnvironmentWrite() {
if (!this.environmentWriter) {
this.environmentWriter = new EnvironmentWrite(new SettingUpdater(this.senderFn), new ServerSettingUpdater(this.senderFn));
}
return this.environmentWriter;
}
public getConfigurationModify() {
if (!this.configModifier) {
const { senderFn } = this;
const slashCommandsModify: ISlashCommandsModify = {
modifySlashCommand(slashcommand: ISlashCommand) {
// Store the slashcommand instance to use when the Apps-Engine calls the slashcommand
AppObjectRegistry.set(`slashcommand:${slashcommand.command}`, slashcommand);
return bridgeCall<void>(senderFn, 'getAppResourceBridge', 'doModifySlashCommand', slashcommand, 'APP_ID');
},
disableSlashCommand(command: string) {
return bridgeCall<void>(senderFn, 'getAppResourceBridge', 'doDisableSlashCommand', command, 'APP_ID');
},
enableSlashCommand(command: string) {
return bridgeCall<void>(senderFn, 'getAppResourceBridge', 'doEnableSlashCommand', command, 'APP_ID');
},
};
this.configModifier = {
scheduler: new SchedulerModify(this.senderFn),
slashCommands: slashCommandsModify,
serverSettings: new ServerSettingsModify(this.senderFn),
};
}
return this.configModifier;
}
public getConfigurationExtend() {
if (!this.configExtender) {
const { senderFn } = this;
const apiExtend: IApiExtend = {
async provideApi(api: IApi) {
const apiEndpoints = AppObjectRegistry.get<IApiEndpointMetadata[]>('apiEndpoints')!;
api.endpoints.forEach((endpoint) => {
endpoint._availableMethods = httpMethods.filter((method) => typeof endpoint[method] === 'function');
// We need to keep a reference to the endpoint around for us to call the executor later
AppObjectRegistry.set(`api:${endpoint.path}`, endpoint);
});
await bridgeCall(senderFn, 'getAppResourceBridge', 'doProvideApi', api, 'APP_ID');
// Let's call the listApis method to cache the info from the endpoints
// Also, since this is a side-effect, we do it async so we can return to the caller
bridgeCall(senderFn, 'getAppResourceBridge', 'doListApis', 'APP_ID')
.then((endpoints) => apiEndpoints.push(...(endpoints as IApiEndpointMetadata[])))
.catch(() => undefined);
},
};
const schedulerExtend: ISchedulerExtend = {
registerProcessors(processors: IProcessor[]) {
// Store the processor instance to use when the Apps-Engine calls the processor
processors.forEach((processor) => {
AppObjectRegistry.set(`scheduler:${processor.id}`, processor);
});
return bridgeCall<void | Array<string>>(senderFn, 'getAppResourceBridge', 'doRegisterProcessors', processors, 'APP_ID');
},
};
const videoConfProviders: IVideoConfProvidersExtend = {
provideVideoConfProvider(provider: IVideoConfProvider) {
// Store the videoConfProvider instance to use when the Apps-Engine calls the videoConfProvider
AppObjectRegistry.set(`videoConfProvider:${provider.name}`, provider);
return bridgeCall<void>(senderFn, 'getAppResourceBridge', 'doProvideVideoConfProvider', provider, 'APP_ID');
},
};
const outboundCommunication: IOutboundCommunicationProviderExtend = {
registerEmailProvider(provider: IOutboundEmailMessageProvider) {
AppObjectRegistry.set(`outboundCommunication:${provider.name}-${provider.type}`, provider);
return bridgeCall<void>(senderFn, 'getAppResourceBridge', 'doRegisterOutboundProvider', provider, 'APP_ID');
},
registerPhoneProvider(provider: IOutboundPhoneMessageProvider) {
AppObjectRegistry.set(`outboundCommunication:${provider.name}-${provider.type}`, provider);
return bridgeCall<void>(senderFn, 'getAppResourceBridge', 'doRegisterOutboundProvider', provider, 'APP_ID');
},
};
const slashCommandsExtend: ISlashCommandsExtend = {
provideSlashCommand(slashcommand: ISlashCommand) {
// Store the slashcommand instance to use when the Apps-Engine calls the slashcommand
AppObjectRegistry.set(`slashcommand:${slashcommand.command}`, slashcommand);
return bridgeCall<void>(senderFn, 'getAppResourceBridge', 'doProvideSlashCommand', slashcommand, 'APP_ID');
},
};
const ui: IUIExtend = {
// `registerButton` is a synchronous `void` in the interface, but the host registration
// is async over the bridge; fire-and-forget matches the contract. The host manager
// logs-and-refuses rather than throwing, so there is no rejection to surface here.
registerButton(button) {
void bridgeCall(senderFn, 'getAppResourceBridge', 'doRegisterActionButton', button, 'APP_ID').catch(() => undefined);
},
};
const settings: ISettingsExtend = {
provideSetting(setting) {
return bridgeCall<void>(senderFn, 'getAppResourceBridge', 'doProvideSetting', setting, 'APP_ID');
},
};
const externalComponents: IExternalComponentsExtend = {
register(externalComponent) {
return bridgeCall<void>(senderFn, 'getAppResourceBridge', 'doRegisterExternalComponent', externalComponent, 'APP_ID');
},
};
this.configExtender = {
ui,
http: this.httpExtend,
settings,
externalComponents,
api: apiExtend,
scheduler: schedulerExtend,
videoConfProviders,
outboundCommunication,
slashCommands: slashCommandsExtend,
};
}
return this.configExtender;
}
public getDefaultAppAccessors() {
if (!this.defaultAppAccessors) {
this.defaultAppAccessors = {
environmentReader: this.getEnvironmentRead(),
environmentWriter: this.getEnvironmentWrite(),
reader: this.getReader(),
http: this.getHttp(),
providedApiEndpoints: AppObjectRegistry.get<IApiEndpointMetadata[]>('apiEndpoints') as IApiEndpointMetadata[],
};
}
return this.defaultAppAccessors;
}
public getReader() {
if (!this.reader) {
const environmentReader = new EnvironmentRead(
new SettingRead(this.senderFn),
new ServerSettingRead(this.senderFn),
new EnvironmentalVariableRead(this.senderFn),
);
this.reader = new Reader(
environmentReader,
new MessageRead(this.senderFn),
new PersistenceRead(this.senderFn),
new RoomRead(this.senderFn),
new UserRead(this.senderFn),
this.getNotifier(),
new LivechatRead(this.senderFn),
new UploadRead(this.senderFn),
new CloudWorkspaceRead(this.senderFn),
new VideoConferenceRead(this.senderFn),
new ContactRead(this.senderFn),
new OAuthAppsReader(this.senderFn),
new ThreadRead(this.senderFn),
new RoleRead(this.senderFn),
new ExperimentalRead(this.senderFn),
);
}
return this.reader;
}
public getModifier() {
if (!this.modifier) {
this.modifier = {
getCreator: this.getCreator.bind(this),
getUpdater: this.getUpdater.bind(this),
getExtender: this.getExtender.bind(this),
getDeleter: () => new ModifyDeleter(this.senderFn),
getNotifier: () => this.getNotifier(),
getUiController: () => new UIController(this.senderFn),
getScheduler: () => new SchedulerModify(this.senderFn),
getOAuthAppsModifier: () => new OAuthAppsModify(this.senderFn),
getModerationModifier: () => new ModerationModify(this.senderFn),
};
}
return this.modifier;
}
public getPersistence() {
if (!this.persistence) {
this.persistence = new Persistence(this.senderFn);
}
return this.persistence;
}
public getHttp() {
return this.http;
}
private getCreator() {
if (!this.creator) {
this.creator = new ModifyCreator(this.senderFn);
}
return this.creator;
}
private getUpdater() {
if (!this.updater) {
this.updater = new ModifyUpdater(this.senderFn);
}
return this.updater;
}
private getExtender() {
if (!this.extender) {
this.extender = new ModifyExtender(this.senderFn);
}
return this.extender;
}
private getNotifier(): INotifier {
if (!this.notifier) {
this.notifier = new Notifier(this.senderFn);
}
return this.notifier;
}
}
export const AppAccessorsInstance = new AppAccessors(Messenger.sendRequest);

View File

@@ -0,0 +1,23 @@
import type { IContactCreator } from '@rocket.chat/apps-engine/definition/accessors/IContactCreator';
import type { ILivechatContact } from '@rocket.chat/apps-engine/definition/livechat';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
export class ContactCreator implements IContactCreator {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public verifyContact(verifyContactChannelParams: {
contactId: string;
field: string;
value: string;
visitorId: string;
roomId: string;
}): Promise<void> {
return bridgeCall<void>(this.senderFn, 'getContactBridge', 'doVerifyContact', verifyContactChannelParams, 'APP_ID');
}
public addContactEmail(contactId: ILivechatContact['_id'], email: string): Promise<ILivechatContact> {
return bridgeCall<ILivechatContact>(this.senderFn, 'getContactBridge', 'doAddContactEmail', contactId, email, 'APP_ID');
}
}

View File

@@ -0,0 +1,13 @@
import type { IEmailCreator } from '@rocket.chat/apps-engine/definition/accessors/IEmailCreator';
import type { IEmail } from '@rocket.chat/apps-engine/definition/email';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
export class EmailCreator implements IEmailCreator {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public async send(email: IEmail): Promise<void> {
await bridgeCall(this.senderFn, 'getEmailBridge', 'doSendEmail', email, 'APP_ID');
}
}

View File

@@ -0,0 +1,41 @@
import { randomBytes } from 'node:crypto';
import type { ILivechatCreator } from '@rocket.chat/apps-engine/definition/accessors';
import type { IExtraRoomParams } from '@rocket.chat/apps-engine/definition/accessors/ILivechatCreator';
import type { ILivechatRoom } from '@rocket.chat/apps-engine/definition/livechat/ILivechatRoom';
import type {
IVisitorExternalIdentifier,
IVisitor,
ResolveVisitorContactData,
} from '@rocket.chat/apps-engine/definition/livechat/IVisitor';
import type { IUser } from '@rocket.chat/apps-engine/definition/users';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
export class LivechatCreator implements ILivechatCreator {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public resolveVisitor(externalId: IVisitorExternalIdentifier, contactData?: ResolveVisitorContactData): Promise<IVisitor | undefined> {
return bridgeCall<IVisitor | undefined>(this.senderFn, 'getLivechatBridge', 'doResolveVisitor', externalId, contactData, 'APP_ID');
}
public createRoom(visitor: IVisitor, agent: IUser, extraParams?: IExtraRoomParams): Promise<ILivechatRoom> {
return bridgeCall<ILivechatRoom>(this.senderFn, 'getLivechatBridge', 'doCreateRoom', visitor, agent, 'APP_ID', extraParams);
}
/**
* @deprecated Use `createAndReturnVisitor` instead.
*/
public createVisitor(visitor: IVisitor): Promise<string> {
return bridgeCall<string>(this.senderFn, 'getLivechatBridge', 'doCreateVisitor', visitor, 'APP_ID');
}
public createAndReturnVisitor(visitor: IVisitor): Promise<IVisitor | undefined> {
return bridgeCall<IVisitor | undefined>(this.senderFn, 'getLivechatBridge', 'doCreateAndReturnVisitor', visitor, 'APP_ID');
}
public createToken(): string {
return randomBytes(16).toString('hex'); // Ensures 128 bits of entropy
}
}

View File

@@ -0,0 +1,40 @@
import type { ILivechatUpdater } from '@rocket.chat/apps-engine/definition/accessors';
import type {
ILivechatRoom,
ILivechatTransferData,
IVisitor,
IVisitorExternalIdentifier,
} from '@rocket.chat/apps-engine/definition/livechat';
import type { IUser } from '@rocket.chat/apps-engine/definition/users';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
export class LivechatUpdater implements ILivechatUpdater {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public transferVisitor(visitor: IVisitor, transferData: ILivechatTransferData): Promise<boolean> {
return bridgeCall<boolean>(this.senderFn, 'getLivechatBridge', 'doTransferVisitor', visitor, transferData, 'APP_ID');
}
public closeRoom(room: ILivechatRoom, comment: string, closer?: IUser): Promise<boolean> {
return bridgeCall<boolean>(this.senderFn, 'getLivechatBridge', 'doCloseRoom', room, comment, closer, 'APP_ID');
}
public setCustomFields(token: IVisitor['token'], key: string, value: string, overwrite: boolean): Promise<boolean> {
return bridgeCall(this.senderFn, 'getLivechatBridge', 'doSetCustomFields', { token, key, value, overwrite }, 'APP_ID').then(
(result) => (result as number) > 0,
);
}
public updateVisitorExternalId(visitorId: string, externalId: Omit<IVisitorExternalIdentifier, 'appId'>): Promise<IVisitor | undefined> {
return bridgeCall<IVisitor | undefined>(
this.senderFn,
'getLivechatBridge',
'doUpdateVisitorExternalId',
visitorId,
externalId,
'APP_ID',
);
}
}

View File

@@ -0,0 +1,17 @@
import type { IMessageUpdater } from '@rocket.chat/apps-engine/definition/accessors/IMessageUpdater';
import type { Reaction } from '@rocket.chat/apps-engine/definition/messages';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
export class MessageUpdater implements IMessageUpdater {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public async addReaction(messageId: string, userId: string, reaction: Reaction): Promise<void> {
await bridgeCall(this.senderFn, 'getMessageBridge', 'doAddReaction', messageId, userId, reaction, 'APP_ID');
}
public async removeReaction(messageId: string, userId: string, reaction: Reaction): Promise<void> {
await bridgeCall(this.senderFn, 'getMessageBridge', 'doRemoveReaction', messageId, userId, reaction, 'APP_ID');
}
}

View File

@@ -0,0 +1,25 @@
import type { IModerationModify } from '@rocket.chat/apps-engine/definition/accessors';
import type { IMessage } from '@rocket.chat/apps-engine/definition/messages';
import type { IUser } from '@rocket.chat/apps-engine/definition/users';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
// Every method here takes `appId` as an app-supplied argument (not caller identity); the host
// accessor likewise ignores its constructor appId and forwards the method-arg one. So the appId is
// forwarded raw (see docs/base-runtime-app-id-exceptions.md, bucket B).
export class ModerationModify implements IModerationModify {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public report(messageId: string, description: string, userId: string, appId: string): Promise<void> {
return bridgeCall<void>(this.senderFn, 'getModerationBridge', 'doReport', messageId, description, userId, appId);
}
public dismissReportsByMessageId(messageId: IMessage['id'], reason: string, action: string, appId: string): Promise<void> {
return bridgeCall<void>(this.senderFn, 'getModerationBridge', 'doDismissReportsByMessageId', messageId, reason, action, appId);
}
public dismissReportsByUserId(userId: IUser['id'], reason: string, action: string, appId: string): Promise<void> {
return bridgeCall<void>(this.senderFn, 'getModerationBridge', 'doDismissReportsByUserId', userId, reason, action, appId);
}
}

View File

@@ -0,0 +1,286 @@
import type { IContactCreator } from '@rocket.chat/apps-engine/definition/accessors/IContactCreator';
import type { IDiscussionBuilder } from '@rocket.chat/apps-engine/definition/accessors/IDiscussionBuilder';
import type { IEmailCreator } from '@rocket.chat/apps-engine/definition/accessors/IEmailCreator';
import type { ILivechatCreator } from '@rocket.chat/apps-engine/definition/accessors/ILivechatCreator';
import type { ILivechatMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/ILivechatMessageBuilder';
import type { IMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/IMessageBuilder';
import type { IModifyCreator } from '@rocket.chat/apps-engine/definition/accessors/IModifyCreator';
import type { IRoomBuilder } from '@rocket.chat/apps-engine/definition/accessors/IRoomBuilder';
import type { IUploadCreator } from '@rocket.chat/apps-engine/definition/accessors/IUploadCreator';
import type { IUserBuilder } from '@rocket.chat/apps-engine/definition/accessors/IUserBuilder';
import type { IVideoConferenceBuilder } from '@rocket.chat/apps-engine/definition/accessors/IVideoConferenceBuilder';
import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage';
import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations';
import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom';
import { RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType';
import type { IBotUser } from '@rocket.chat/apps-engine/definition/users/IBotUser';
import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser';
import { UserType } from '@rocket.chat/apps-engine/definition/users/UserType';
import { ContactCreator } from './ContactCreator';
import { EmailCreator } from './EmailCreator';
import { LivechatCreator } from './LivechatCreator';
import { UploadCreator } from './UploadCreator';
import { AppObjectRegistry } from '../../../AppObjectRegistry';
import { UIHelper } from '../../UIHelper';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
import { BlockBuilder } from '../builders/BlockBuilder';
import { DiscussionBuilder } from '../builders/DiscussionBuilder';
import type { ILivechatMessage } from '../builders/LivechatMessageBuilder';
import { LivechatMessageBuilder } from '../builders/LivechatMessageBuilder';
import { MessageBuilder } from '../builders/MessageBuilder';
import { RoomBuilder } from '../builders/RoomBuilder';
import { UserBuilder } from '../builders/UserBuilder';
import type { AppVideoConference } from '../builders/VideoConferenceBuilder';
import { VideoConferenceBuilder } from '../builders/VideoConferenceBuilder';
export class ModifyCreator implements IModifyCreator {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
getLivechatCreator(): ILivechatCreator {
return new LivechatCreator((request) => this.senderFn(request));
}
getUploadCreator(): IUploadCreator {
return new UploadCreator((request) => this.senderFn(request));
}
getEmailCreator(): IEmailCreator {
return new EmailCreator((request) => this.senderFn(request));
}
getContactCreator(): IContactCreator {
return new ContactCreator((request) => this.senderFn(request));
}
getBlockBuilder() {
return new BlockBuilder();
}
startMessage(data?: IMessage) {
if (data) {
delete data.id;
}
return new MessageBuilder(data);
}
startLivechatMessage(data?: ILivechatMessage) {
if (data) {
delete data.id;
}
return new LivechatMessageBuilder(data);
}
startRoom(data?: IRoom) {
if (data) {
// @ts-ignore - this has been imported from the Apps-Engine
delete data.id;
}
return new RoomBuilder(data);
}
startDiscussion(data?: Partial<IRoom>) {
if (data) {
delete data.id;
}
return new DiscussionBuilder(data);
}
startVideoConference(data?: Partial<AppVideoConference>) {
return new VideoConferenceBuilder(data);
}
startBotUser(data?: Partial<IBotUser>) {
if (data) {
delete data.id;
const { roles } = data;
if (roles?.length) {
const hasRole = roles
.map((role: string) => role.toLocaleLowerCase())
.some((role: string) => role === 'admin' || role === 'owner' || role === 'moderator');
if (hasRole) {
throw new Error('Invalid role assigned to the user. Should not be admin, owner or moderator.');
}
}
if (!data.type) {
data.type = UserType.BOT;
}
}
return new UserBuilder(data);
}
public finish(
builder: IMessageBuilder | ILivechatMessageBuilder | IRoomBuilder | IDiscussionBuilder | IVideoConferenceBuilder | IUserBuilder,
): Promise<string> {
switch (builder.kind) {
case RocketChatAssociationModel.MESSAGE:
return this._finishMessage(builder as IMessageBuilder);
case RocketChatAssociationModel.LIVECHAT_MESSAGE:
return this._finishLivechatMessage(builder as ILivechatMessageBuilder);
case RocketChatAssociationModel.ROOM:
return this._finishRoom(builder as IRoomBuilder);
case RocketChatAssociationModel.DISCUSSION:
return this._finishDiscussion(builder as DiscussionBuilder);
case RocketChatAssociationModel.VIDEO_CONFERENCE:
return this._finishVideoConference(builder as IVideoConferenceBuilder);
case RocketChatAssociationModel.USER:
return this._finishUser(builder as IUserBuilder);
default:
throw new Error('Invalid builder passed to the ModifyCreator.finish function.');
}
}
private async _finishMessage(builder: IMessageBuilder): Promise<string> {
const result = builder.getMessage();
delete result.id;
if (!result.sender?.id) {
const appUser = await bridgeCall(this.senderFn, 'getUserBridge', 'doGetAppUser', 'APP_ID');
if (!appUser) {
throw new Error('Invalid sender assigned to the message.');
}
result.sender = appUser as IUser;
}
if (result.blocks?.length) {
// Can we move this elsewhere? This AppObjectRegistry usage doesn't really belong here, but where?
result.blocks = UIHelper.assignIds(result.blocks, AppObjectRegistry.get('id') || '');
}
const createdMessageId = (await bridgeCall(this.senderFn, 'getMessageBridge', 'doCreate', result, 'APP_ID')) as string;
return String(createdMessageId);
}
private async _finishLivechatMessage(builder: ILivechatMessageBuilder): Promise<string> {
if (builder.getSender() && !builder.getVisitor()) {
return this._finishMessage(builder.getMessageBuilder());
}
const result = builder.getMessage();
delete result.id;
if (!result.token && !result.visitor?.token) {
throw new Error('Invalid visitor sending the message');
}
result.token = result.visitor ? result.visitor.token : result.token;
const createdMessageId = (await bridgeCall(this.senderFn, 'getLivechatBridge', 'doCreateMessage', result, 'APP_ID')) as string;
return String(createdMessageId);
}
private async _finishRoom(builder: IRoomBuilder): Promise<string> {
const result = builder.getRoom();
delete result.id;
if (!result.type) {
throw new Error('Invalid type assigned to the room.');
}
if (result.type !== RoomType.LIVE_CHAT) {
if (!result.creator?.id) {
throw new Error('Invalid creator assigned to the room.');
}
}
if (result.type !== RoomType.DIRECT_MESSAGE) {
if (result.type !== RoomType.LIVE_CHAT) {
if (!result.slugifiedName?.trim()) {
throw new Error('Invalid slugifiedName assigned to the room.');
}
}
if (!result.displayName?.trim()) {
throw new Error('Invalid displayName assigned to the room.');
}
}
const createdRoomId = (await bridgeCall(
this.senderFn,
'getRoomBridge',
'doCreate',
result,
builder.getMembersToBeAddedUsernames(),
'APP_ID',
)) as string;
return String(createdRoomId);
}
private async _finishDiscussion(builder: DiscussionBuilder): Promise<string> {
const room = builder.getRoom();
delete room.id;
if (!room.creator?.id) {
throw new Error('Invalid creator assigned to the discussion.');
}
if (!room.slugifiedName?.trim()) {
throw new Error('Invalid slugifiedName assigned to the discussion.');
}
if (!room.displayName?.trim()) {
throw new Error('Invalid displayName assigned to the discussion.');
}
if (!room.parentRoom?.id) {
throw new Error('Invalid parentRoom assigned to the discussion.');
}
const createdDiscussionId = (await bridgeCall(
this.senderFn,
'getRoomBridge',
'doCreateDiscussion',
room,
builder.getParentMessage(),
builder.getReply(),
builder.getMembersToBeAddedUsernames(),
'APP_ID',
)) as string;
return String(createdDiscussionId);
}
private async _finishVideoConference(builder: IVideoConferenceBuilder): Promise<string> {
const videoConference = builder.getVideoConference();
if (!videoConference.createdBy) {
throw new Error('Invalid creator assigned to the video conference.');
}
if (!videoConference.providerName?.trim()) {
throw new Error('Invalid provider name assigned to the video conference.');
}
if (!videoConference.rid) {
throw new Error('Invalid roomId assigned to the video conference.');
}
const createdCallId = (await bridgeCall(this.senderFn, 'getVideoConferenceBridge', 'doCreate', videoConference, 'APP_ID')) as string;
return String(createdCallId);
}
private async _finishUser(builder: IUserBuilder): Promise<string> {
const user = builder.getUser();
const createdUserId = (await bridgeCall(this.senderFn, 'getUserBridge', 'doCreate', user, 'APP_ID')) as string;
return String(createdUserId);
}
}

View File

@@ -0,0 +1,40 @@
import type { IModifyDeleter } from '@rocket.chat/apps-engine/definition/accessors';
import type { IMessage } from '@rocket.chat/apps-engine/definition/messages';
import type { IUser, UserType } from '@rocket.chat/apps-engine/definition/users';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
export class ModifyDeleter implements IModifyDeleter {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public async deleteRoom(roomId: string): Promise<void> {
await bridgeCall(this.senderFn, 'getRoomBridge', 'doDelete', roomId, 'APP_ID');
}
// `appId` here is an app-supplied argument identifying which app's users to delete, not caller
// identity, so it is forwarded raw (see
// docs/proposals/apps-accessor-consolidation/base-runtime-app-id-exceptions.md, bucket B).
public async deleteUsers(appId: Exclude<IUser['appId'], undefined>, userType: UserType.APP | UserType.BOT): Promise<boolean> {
return bridgeCall<boolean>(this.senderFn, 'getUserBridge', 'doDeleteUsersCreatedByApp', appId, userType);
}
public async deleteMessage(message: IMessage, user: IUser): Promise<void> {
await bridgeCall(this.senderFn, 'getMessageBridge', 'doDelete', message, user, 'APP_ID');
}
/**
* Removes `usernames` from the room's member list
*
* For performance reasons, it is only possible to remove 50 users in one
* call to this method. Removing users is an expensive operation due to the
* amount of entity relationships that need to be modified.
*/
public async removeUsersFromRoom(roomId: string, usernames: Array<string>) {
if (usernames.length > 50) {
throw new Error('A maximum of 50 members can be removed in a single call');
}
return bridgeCall<void>(this.senderFn, 'getRoomBridge', 'doRemoveUsers', roomId, usernames, 'APP_ID');
}
}

View File

@@ -0,0 +1,77 @@
import type { IMessageExtender } from '@rocket.chat/apps-engine/definition/accessors/IMessageExtender';
import type { IModifyExtender } from '@rocket.chat/apps-engine/definition/accessors/IModifyExtender';
import type { IRoomExtender } from '@rocket.chat/apps-engine/definition/accessors/IRoomExtender';
import type { IVideoConferenceExtender } from '@rocket.chat/apps-engine/definition/accessors/IVideoConferenceExtend';
import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage';
import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations';
import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom';
import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser';
import type { VideoConference } from '@rocket.chat/apps-engine/definition/videoConferences/IVideoConference';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
import { MessageExtender } from '../extenders/MessageExtender';
import { RoomExtender } from '../extenders/RoomExtender';
import { VideoConferenceExtender } from '../extenders/VideoConferenceExtend';
export class ModifyExtender implements IModifyExtender {
private readonly senderFn: typeof Messenger.sendRequest;
constructor(senderFn: typeof Messenger.sendRequest) {
this.senderFn = senderFn;
}
public async extendMessage(messageId: string, updater: IUser): Promise<IMessageExtender> {
const msg = (await bridgeCall(this.senderFn, 'getMessageBridge', 'doGetById', messageId, 'APP_ID')) as IMessage;
msg.editor = updater;
msg.editedAt = new Date();
return new MessageExtender(msg);
}
public async extendRoom(roomId: string, _updater: IUser): Promise<IRoomExtender> {
const room = (await bridgeCall(this.senderFn, 'getRoomBridge', 'doGetById', roomId, 'APP_ID')) as IRoom;
room.updatedAt = new Date();
return new RoomExtender(room);
}
public async extendVideoConference(id: string): Promise<IVideoConferenceExtender> {
const call = (await bridgeCall(this.senderFn, 'getVideoConferenceBridge', 'doGetById', id, 'APP_ID')) as VideoConference;
call._updatedAt = new Date();
return new VideoConferenceExtender(call);
}
public async finish(extender: IMessageExtender | IRoomExtender | IVideoConferenceExtender): Promise<void> {
switch (extender.kind) {
case RocketChatAssociationModel.MESSAGE:
await bridgeCall(this.senderFn, 'getMessageBridge', 'doUpdate', (extender as IMessageExtender).getMessage(), 'APP_ID');
break;
case RocketChatAssociationModel.ROOM:
await bridgeCall(
this.senderFn,
'getRoomBridge',
'doUpdate',
(extender as IRoomExtender).getRoom(),
(extender as IRoomExtender).getUsernamesOfMembersBeingAdded(),
'APP_ID',
);
break;
case RocketChatAssociationModel.VIDEO_CONFERENCE:
await bridgeCall(
this.senderFn,
'getVideoConferenceBridge',
'doUpdate',
(extender as IVideoConferenceExtender).getVideoConference(),
'APP_ID',
);
break;
default:
throw new Error('Invalid extender passed to the ModifyExtender.finish function.');
}
}
}

View File

@@ -0,0 +1,126 @@
import type { ILivechatUpdater } from '@rocket.chat/apps-engine/definition/accessors/ILivechatUpdater';
import type { IMessageBuilder } from '@rocket.chat/apps-engine/definition/accessors/IMessageBuilder';
import type { IMessageUpdater } from '@rocket.chat/apps-engine/definition/accessors/IMessageUpdater';
import type { IModifyUpdater } from '@rocket.chat/apps-engine/definition/accessors/IModifyUpdater';
import type { IRoomBuilder } from '@rocket.chat/apps-engine/definition/accessors/IRoomBuilder';
import type { IUserUpdater } from '@rocket.chat/apps-engine/definition/accessors/IUserUpdater';
import type { IMessage } from '@rocket.chat/apps-engine/definition/messages/IMessage';
import { RocketChatAssociationModel } from '@rocket.chat/apps-engine/definition/metadata/RocketChatAssociations';
import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom';
import { RoomType } from '@rocket.chat/apps-engine/definition/rooms/RoomType.js';
import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser';
import { LivechatUpdater } from './LivechatUpdater';
import { MessageUpdater } from './MessageUpdater';
import { UserUpdater } from './UserUpdater';
import { AppObjectRegistry } from '../../../AppObjectRegistry';
import { UIHelper } from '../../UIHelper';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
import { MessageBuilder } from '../builders/MessageBuilder';
import { RoomBuilder } from '../builders/RoomBuilder';
export class ModifyUpdater implements IModifyUpdater {
private readonly livechatUpdater: ILivechatUpdater;
private readonly userUpdater: IUserUpdater;
private readonly messageUpdater: IMessageUpdater;
constructor(private readonly senderFn: typeof Messenger.sendRequest) {
// Thunks, not `this.senderFn` directly: these sub-accessors are built eagerly, so capturing
// the function here would pin them to it before a test could swap it out.
this.livechatUpdater = new LivechatUpdater((request) => this.senderFn(request));
this.userUpdater = new UserUpdater((request) => this.senderFn(request));
this.messageUpdater = new MessageUpdater((request) => this.senderFn(request));
}
public getLivechatUpdater(): ILivechatUpdater {
return this.livechatUpdater;
}
public getUserUpdater(): IUserUpdater {
return this.userUpdater;
}
public getMessageUpdater(): IMessageUpdater {
return this.messageUpdater;
}
public async message(messageId: string, editor: IUser): Promise<IMessageBuilder> {
const message = (await bridgeCall(this.senderFn, 'getMessageBridge', 'doGetById', messageId, 'APP_ID')) as IMessage;
const builder = new MessageBuilder(message);
builder.setEditor(editor);
return builder;
}
public async room(roomId: string, _updater: IUser): Promise<IRoomBuilder> {
const room = (await bridgeCall(this.senderFn, 'getRoomBridge', 'doGetById', roomId, 'APP_ID')) as IRoom;
return new RoomBuilder(room);
}
public finish(builder: IMessageBuilder | IRoomBuilder): Promise<void> {
switch (builder.kind) {
case RocketChatAssociationModel.MESSAGE:
return this._finishMessage(builder as MessageBuilder);
case RocketChatAssociationModel.ROOM:
return this._finishRoom(builder as RoomBuilder);
default:
throw new Error('Invalid builder passed to the ModifyUpdater.finish function.');
}
}
private async _finishMessage(builder: MessageBuilder): Promise<void> {
const result = builder.getMessage();
if (!result.id) {
throw new Error("Invalid message, can't update a message without an id.");
}
if (!result.sender?.id) {
throw new Error('Invalid sender assigned to the message.');
}
if (result.blocks?.length) {
result.blocks = UIHelper.assignIds(result.blocks, AppObjectRegistry.get('id') || '');
}
const changes = { id: result.id, ...builder.getChanges() };
await bridgeCall(this.senderFn, 'getMessageBridge', 'doUpdate', changes, 'APP_ID');
}
private async _finishRoom(builder: RoomBuilder): Promise<void> {
const room = builder.getRoom();
if (!room.id) {
throw new Error("Invalid room, can't update a room without an id.");
}
if (!room.type) {
throw new Error('Invalid type assigned to the room.');
}
if (room.type !== RoomType.LIVE_CHAT) {
if (!room.creator?.id) {
throw new Error('Invalid creator assigned to the room.');
}
if (!room.slugifiedName?.trim()) {
throw new Error('Invalid slugifiedName assigned to the room.');
}
}
if (!room.displayName?.trim()) {
throw new Error('Invalid displayName assigned to the room.');
}
const changes = { id: room.id, ...builder.getChanges() };
await bridgeCall(this.senderFn, 'getRoomBridge', 'doUpdate', changes, builder.getMembersToBeAddedUsernames(), 'APP_ID');
}
}

View File

@@ -0,0 +1,21 @@
import type { IOAuthAppParams } from '@rocket.chat/apps-engine/definition/accessors/IOAuthApp';
import type { IOAuthAppsModify } from '@rocket.chat/apps-engine/definition/accessors/IOAuthAppsModify';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
export class OAuthAppsModify implements IOAuthAppsModify {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public async createOAuthApp(oAuthApp: IOAuthAppParams): Promise<string> {
return bridgeCall<string>(this.senderFn, 'getOAuthAppsBridge', 'doCreate', oAuthApp, 'APP_ID');
}
public async updateOAuthApp(oAuthApp: IOAuthAppParams, id: string): Promise<void> {
await bridgeCall(this.senderFn, 'getOAuthAppsBridge', 'doUpdate', oAuthApp, id, 'APP_ID');
}
public async deleteOAuthApp(id: string): Promise<void> {
await bridgeCall(this.senderFn, 'getOAuthAppsBridge', 'doDelete', id, 'APP_ID');
}
}

View File

@@ -0,0 +1,49 @@
import type { ISchedulerModify } from '@rocket.chat/apps-engine/definition/accessors';
import type { IOnetimeSchedule, IRecurringSchedule } from '@rocket.chat/apps-engine/definition/scheduler';
import { AppObjectRegistry } from '../../../AppObjectRegistry';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
// Namespaces a job id with the app id, matching the host SchedulerModify. The app id here is used to
// build the id string (a non-identity, local use), so it reads the real id from the registry rather
// than the 'APP_ID' sentinel.
function createProcessorId(jobId: string, appId: string): string {
return jobId.includes(`_${appId}`) ? jobId : `${jobId}_${appId}`;
}
export class SchedulerModify implements ISchedulerModify {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
private get appId(): string {
return AppObjectRegistry.get<string>('id') || '';
}
public async scheduleOnce(job: IOnetimeSchedule): Promise<void | string> {
return bridgeCall<void | string>(
this.senderFn,
'getSchedulerBridge',
'doScheduleOnce',
{ ...job, id: createProcessorId(job.id, this.appId) },
'APP_ID',
);
}
public async scheduleRecurring(job: IRecurringSchedule): Promise<void | string> {
return bridgeCall<void | string>(
this.senderFn,
'getSchedulerBridge',
'doScheduleRecurring',
{ ...job, id: createProcessorId(job.id, this.appId) },
'APP_ID',
);
}
public async cancelJob(jobId: string): Promise<void> {
await bridgeCall(this.senderFn, 'getSchedulerBridge', 'doCancelJob', createProcessorId(jobId, this.appId), 'APP_ID');
}
public async cancelAllJobs(): Promise<void> {
await bridgeCall(this.senderFn, 'getSchedulerBridge', 'doCancelAllJobs', 'APP_ID');
}
}

View File

@@ -0,0 +1,149 @@
import type { IUIController } from '@rocket.chat/apps-engine/definition/accessors';
import type {
IUIKitErrorInteractionParam,
IUIKitInteractionParam,
IUIKitSurfaceViewParam,
} from '@rocket.chat/apps-engine/definition/accessors/IUIController';
import { UIKitInteractionType, UIKitSurfaceType } from '@rocket.chat/apps-engine/definition/uikit';
import {
formatContextualBarInteraction,
formatErrorInteraction,
formatModalInteraction,
} from '@rocket.chat/apps-engine/definition/uikit/UIKitInteractionPayloadFormatter';
import type {
IUIKitContextualBarViewParam,
IUIKitModalViewParam,
} from '@rocket.chat/apps-engine/definition/uikit/UIKitInteractionResponder';
import type { IUser } from '@rocket.chat/apps-engine/definition/users';
import { AppObjectRegistry } from '../../../AppObjectRegistry';
import { UIHelper } from '../../UIHelper';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
export class UIController implements IUIController {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
// The real app id, used to stamp block/interaction payloads (a non-identity, local use). The
// trailing appId passed to `doNotifyUser` uses the 'APP_ID' sentinel instead so the host
// substitutes the verified id for its permission check.
private get appId(): string {
return AppObjectRegistry.get<string>('id') || '';
}
/**
* @deprecated please prefer the `openSurfaceView` method
*/
public openModalView(view: IUIKitModalViewParam, context: IUIKitInteractionParam, user: IUser) {
return this.openModal(view, context, user);
}
/**
* @deprecated please prefer the `updateSurfaceView` method
*/
public updateModalView(view: IUIKitModalViewParam, context: IUIKitInteractionParam, user: IUser) {
return this.openModal(view, context, user, true);
}
/**
* @deprecated please prefer the `openSurfaceView` method
*/
public openContextualBarView(view: IUIKitContextualBarViewParam, context: IUIKitInteractionParam, user: IUser) {
return this.openContextualBar(view, context, user);
}
/**
* @deprecated please prefer the `updateSurfaceView` method
*/
public updateContextualBarView(view: IUIKitContextualBarViewParam, context: IUIKitInteractionParam, user: IUser) {
return this.openContextualBar(view, context, user, true);
}
public openSurfaceView(view: IUIKitSurfaceViewParam, context: IUIKitInteractionParam, user: IUser) {
const blocks = UIHelper.assignIds(view.blocks, this.appId);
const viewWithIds = { ...view, blocks };
switch (view.type) {
case UIKitSurfaceType.CONTEXTUAL_BAR:
return this.openContextualBar(viewWithIds, context, user);
case UIKitSurfaceType.MODAL:
return this.openModal(viewWithIds, context, user);
}
}
public updateSurfaceView(view: IUIKitSurfaceViewParam, context: IUIKitInteractionParam, user: IUser) {
const blocks = UIHelper.assignIds(view.blocks, this.appId);
const viewWithIds = { ...view, blocks };
switch (view.type) {
case UIKitSurfaceType.CONTEXTUAL_BAR:
return this.openContextualBar(viewWithIds, context, user, true);
case UIKitSurfaceType.MODAL:
return this.openModal(viewWithIds, context, user, true);
}
}
public setViewError(errorInteraction: IUIKitErrorInteractionParam, context: IUIKitInteractionParam, user: IUser) {
const interactionContext = {
...context,
type: UIKitInteractionType.ERRORS,
appId: this.appId,
};
return bridgeCall<void>(
this.senderFn,
'getUiInteractionBridge',
'doNotifyUser',
user,
formatErrorInteraction(errorInteraction, interactionContext),
'APP_ID',
);
}
private openContextualBar(
view: IUIKitContextualBarViewParam,
context: IUIKitInteractionParam,
user: IUser,
isUpdate = false,
): Promise<void> {
let type = UIKitInteractionType.CONTEXTUAL_BAR_OPEN;
if (isUpdate) {
type = UIKitInteractionType.CONTEXTUAL_BAR_UPDATE;
}
const interactionContext = {
...context,
type,
appId: this.appId,
};
return bridgeCall<void>(
this.senderFn,
'getUiInteractionBridge',
'doNotifyUser',
user,
formatContextualBarInteraction(view, interactionContext),
'APP_ID',
);
}
private openModal(view: IUIKitModalViewParam, context: IUIKitInteractionParam, user: IUser, isUpdate = false): Promise<void> {
let type = UIKitInteractionType.MODAL_OPEN;
if (isUpdate) {
type = UIKitInteractionType.MODAL_UPDATE;
}
const interactionContext = {
...context,
type,
appId: this.appId,
};
return bridgeCall<void>(
this.senderFn,
'getUiInteractionBridge',
'doNotifyUser',
user,
formatModalInteraction(view, interactionContext),
'APP_ID',
);
}
}

View File

@@ -0,0 +1,28 @@
import type { IUploadCreator } from '@rocket.chat/apps-engine/definition/accessors';
import type { IUpload } from '@rocket.chat/apps-engine/definition/uploads';
import type { IUploadDescriptor } from '@rocket.chat/apps-engine/definition/uploads/IUploadDescriptor';
import type { IUploadDetails } from '@rocket.chat/apps-engine/definition/uploads/IUploadDetails';
import type { IUser } from '@rocket.chat/apps-engine/definition/users';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
export class UploadCreator implements IUploadCreator {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public async uploadBuffer(buffer: Buffer, descriptor: IUploadDescriptor): Promise<IUpload> {
if (!Object.hasOwn(descriptor, 'user') && !descriptor.visitorToken) {
descriptor.user = (await bridgeCall(this.senderFn, 'getUserBridge', 'doGetAppUser', 'APP_ID')) as IUser;
}
const details = {
name: descriptor.filename,
size: buffer.length,
rid: descriptor.room.id,
userId: descriptor.user?.id,
visitorToken: descriptor.visitorToken,
} as IUploadDetails;
return bridgeCall<IUpload>(this.senderFn, 'getUploadBridge', 'doCreateUpload', details, buffer, 'APP_ID');
}
}

View File

@@ -0,0 +1,41 @@
import type { IUserUpdater } from '@rocket.chat/apps-engine/definition/accessors/IUserUpdater';
import type { UserStatusConnection } from '@rocket.chat/apps-engine/definition/users';
import type { IUser } from '@rocket.chat/apps-engine/definition/users/IUser';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
export class UserUpdater implements IUserUpdater {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public async updateStatusText(user: IUser, statusText: IUser['statusText']) {
return bridgeCall<boolean>(this.senderFn, 'getUserBridge', 'doUpdate', user, { statusText }, 'APP_ID');
}
public async updateStatus(user: IUser, statusText: IUser['statusText'], status: UserStatusConnection) {
return bridgeCall<boolean>(this.senderFn, 'getUserBridge', 'doUpdate', user, { statusText, status }, 'APP_ID');
}
public async updateBio(user: IUser, bio: IUser['bio']) {
return bridgeCall<boolean>(this.senderFn, 'getUserBridge', 'doUpdate', user, { bio }, 'APP_ID');
}
public async updateCustomFields(user: IUser, customFields: IUser['customFields']) {
return bridgeCall<boolean>(this.senderFn, 'getUserBridge', 'doUpdate', user, { customFields }, 'APP_ID');
}
public async deactivate(userId: IUser['id'], confirmRelinquish: boolean) {
return bridgeCall<boolean>(this.senderFn, 'getUserBridge', 'doDeactivate', userId, confirmRelinquish, 'APP_ID');
}
public async setActiveState(
userId: IUser['id'],
state: Pick<IUser, 'statusDefault' | 'statusSource' | 'statusText' | 'statusExpiresAt' | 'statusId'>,
): Promise<void> {
await bridgeCall(this.senderFn, 'getUserBridge', 'doSetActiveState', userId, state, 'APP_ID');
}
public async endActiveState(userId: IUser['id'], statusId?: string): Promise<void> {
await bridgeCall(this.senderFn, 'getUserBridge', 'doEndActiveState', userId, 'APP_ID', statusId);
}
}

View File

@@ -0,0 +1,170 @@
import * as assert from 'node:assert';
import { after, beforeEach, describe, it } from 'node:test';
import { AppObjectRegistry } from '../../../../AppObjectRegistry';
import { createRecordingSender } from '../../tests/helpers/parityHarness';
import { LivechatUpdater } from '../LivechatUpdater';
import { ModerationModify } from '../ModerationModify';
import { ModifyDeleter } from '../ModifyDeleter';
import { OAuthAppsModify } from '../OAuthAppsModify';
import { SchedulerModify } from '../SchedulerModify';
import { UIController } from '../UIController';
import { UploadCreator } from '../UploadCreator';
import { UserUpdater } from '../UserUpdater';
const setup = (responses = {}) => {
const rec = createRecordingSender(responses);
return { rec, sender: rec.sender };
};
describe('Modify accessors (base-runtime)', () => {
beforeEach(() => {
AppObjectRegistry.clear();
AppObjectRegistry.set('id', 'deno-test');
});
after(() => {
AppObjectRegistry.clear();
});
describe('ModifyDeleter', () => {
it('deleteRoom/deleteMessage/removeUsersFromRoom use the APP_ID sentinel', async () => {
const { rec, sender } = setup();
const deleter = new ModifyDeleter(sender);
const message = { id: 'm1' } as any;
const user = { id: 'u1' } as any;
await deleter.deleteRoom('r1');
await deleter.deleteMessage(message, user);
await deleter.removeUsersFromRoom('r1', ['a', 'b']);
assert.deepStrictEqual(rec.emitted(), [
{ method: 'bridges:getRoomBridge:doDelete', params: ['r1', 'APP_ID'] },
{ method: 'bridges:getMessageBridge:doDelete', params: [message, user, 'APP_ID'] },
{ method: 'bridges:getRoomBridge:doRemoveUsers', params: ['r1', ['a', 'b'], 'APP_ID'] },
]);
});
it('removeUsersFromRoom rejects more than 50 usernames', async () => {
const { sender } = setup();
await assert.rejects(
() =>
new ModifyDeleter(sender).removeUsersFromRoom(
'r1',
Array.from({ length: 51 }, (_v, i) => `u${i}`),
),
/maximum of 50 members/,
);
});
it('deleteUsers forwards the app-supplied appId argument raw (not the sentinel)', async () => {
const { rec, sender } = setup();
await new ModifyDeleter(sender).deleteUsers('target-app', 'bot' as any);
assert.deepStrictEqual(rec.emitted()[0], {
method: 'bridges:getUserBridge:doDeleteUsersCreatedByApp',
params: ['target-app', 'bot'],
});
});
});
describe('SchedulerModify', () => {
it('namespaces the job id with the real app id and sends the APP_ID sentinel as identity', async () => {
const { rec, sender } = setup();
await new SchedulerModify(sender).scheduleOnce({ id: 'job', when: 'x' } as any);
assert.deepStrictEqual(rec.emitted()[0], {
method: 'bridges:getSchedulerBridge:doScheduleOnce',
params: [{ id: 'job_deno-test', when: 'x' }, 'APP_ID'],
});
});
it('does not double-namespace an id that already carries the app id', async () => {
const { rec, sender } = setup();
await new SchedulerModify(sender).cancelJob('job_deno-test');
assert.deepStrictEqual(rec.emitted()[0], {
method: 'bridges:getSchedulerBridge:doCancelJob',
params: ['job_deno-test', 'APP_ID'],
});
});
});
describe('ModerationModify', () => {
it('forwards the app-supplied appId argument raw', async () => {
const { rec, sender } = setup();
await new ModerationModify(sender).report('m1', 'desc', 'u1', 'target-app');
assert.deepStrictEqual(rec.emitted()[0], {
method: 'bridges:getModerationBridge:doReport',
params: ['m1', 'desc', 'u1', 'target-app'],
});
});
});
describe('OAuthAppsModify', () => {
it('createOAuthApp forwards with the APP_ID sentinel', async () => {
const { rec, sender } = setup();
await new OAuthAppsModify(sender).createOAuthApp({ name: 'x' } as any);
assert.deepStrictEqual(rec.emitted()[0], {
method: 'bridges:getOAuthAppsBridge:doCreate',
params: [{ name: 'x' }, 'APP_ID'],
});
});
});
describe('UserUpdater', () => {
it('updateStatus wraps statusText+status into a partial user update', async () => {
const { rec, sender } = setup();
await new UserUpdater(sender).updateStatus({ id: 'u1' } as any, 'brb', 'online' as any);
assert.deepStrictEqual(rec.emitted()[0], {
method: 'bridges:getUserBridge:doUpdate',
params: [{ id: 'u1' }, { statusText: 'brb', status: 'online' }, 'APP_ID'],
});
});
it('endActiveState sends the sentinel in the middle position, statusId last', async () => {
const { rec, sender } = setup();
await new UserUpdater(sender).endActiveState('u1', 'status-1');
assert.deepStrictEqual(rec.emitted()[0], {
method: 'bridges:getUserBridge:doEndActiveState',
params: ['u1', 'APP_ID', 'status-1'],
});
});
});
describe('LivechatUpdater', () => {
it('setCustomFields maps a >0 host result to boolean true', async () => {
const { sender } = setup({ 'bridges:getLivechatBridge:doSetCustomFields': 1 });
assert.strictEqual(await new LivechatUpdater(sender).setCustomFields('tok', 'k', 'v', true), true);
const zero = setup({ 'bridges:getLivechatBridge:doSetCustomFields': 0 });
assert.strictEqual(await new LivechatUpdater(zero.sender).setCustomFields('tok', 'k', 'v', true), false);
});
});
describe('UploadCreator', () => {
it('does not fetch the app user when a visitorToken is provided', async () => {
const { rec, sender } = setup();
await new UploadCreator(sender).uploadBuffer(Buffer.from([1]), {
filename: 'f',
room: { id: 'r1' },
visitorToken: 'vtok',
} as any);
assert.deepStrictEqual(rec.methods(), ['bridges:getUploadBridge:doCreateUpload']);
});
});
describe('UIController', () => {
it('openSurfaceView routes a modal through doNotifyUser with the APP_ID sentinel and stamps the real app id', async () => {
const { rec, sender } = setup();
const user = { id: 'u1' } as any;
await new UIController(sender).openSurfaceView(
{ type: 'modal', title: { type: 'plain_text', text: 't' }, blocks: [{ type: 'section' }] } as any,
{ triggerId: 'tr1' } as any,
user,
);
const call = rec.emitted()[0];
assert.deepEqual(call.params[0], user);
assert.strictEqual(call.method, 'bridges:getUiInteractionBridge:doNotifyUser');
assert.strictEqual(call.params[2], 'APP_ID');
// The interaction payload carries the resolved app id (nested field, not sentinel-substituted).
assert.strictEqual((call.params[1] as any).appId, 'deno-test');
});
});
});

View File

@@ -0,0 +1,61 @@
import type { IMessageBuilder, INotifier } from '@rocket.chat/apps-engine/definition/accessors';
import type { ITypingOptions } from '@rocket.chat/apps-engine/definition/accessors/INotifier';
import { TypingScope } from '@rocket.chat/apps-engine/definition/accessors/INotifier';
import type { IMessage } from '@rocket.chat/apps-engine/definition/messages';
import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms';
import type { IUser } from '@rocket.chat/apps-engine/definition/users';
import { bridgeCall } from '../bridges/bridgeCall';
import type * as Messenger from '../messenger';
import { MessageBuilder } from './builders/MessageBuilder';
export class Notifier implements INotifier {
private readonly senderFn: typeof Messenger.sendRequest;
constructor(senderFn: typeof Messenger.sendRequest) {
this.senderFn = senderFn;
}
public async notifyUser(user: IUser, message: IMessage): Promise<void> {
if (!message.sender?.id) {
const appUser = await this.getAppUser();
message.sender = appUser as IUser;
}
await bridgeCall(this.senderFn, 'getMessageBridge', 'doNotifyUser', user, message, 'APP_ID');
}
public async notifyRoom(room: IRoom, message: IMessage): Promise<void> {
if (!message.sender?.id) {
const appUser = await this.getAppUser();
message.sender = appUser as IUser;
}
await bridgeCall(this.senderFn, 'getMessageBridge', 'doNotifyRoom', room, message, 'APP_ID');
}
public async typing(options: ITypingOptions): Promise<() => Promise<void>> {
options.scope = options.scope || TypingScope.Room;
if (!options.username) {
const appUser = await this.getAppUser();
options.username = appUser?.name || '';
}
await bridgeCall(this.senderFn, 'getMessageBridge', 'doTyping', { ...options, isTyping: true }, 'APP_ID');
return async () => {
await bridgeCall(this.senderFn, 'getMessageBridge', 'doTyping', { ...options, isTyping: false }, 'APP_ID');
};
}
public getMessageBuilder(): IMessageBuilder {
return new MessageBuilder();
}
private async getAppUser(): Promise<IUser | undefined> {
return bridgeCall<IUser | undefined>(this.senderFn, 'getUserBridge', 'doGetAppUser', 'APP_ID');
}
}

View File

@@ -0,0 +1,13 @@
import type { ICloudWorkspaceRead } from '@rocket.chat/apps-engine/definition/accessors/ICloudWorkspaceRead';
import type { IWorkspaceToken } from '@rocket.chat/apps-engine/definition/cloud/IWorkspaceToken';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
export class CloudWorkspaceRead implements ICloudWorkspaceRead {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public async getWorkspaceToken(scope: string): Promise<IWorkspaceToken> {
return bridgeCall<IWorkspaceToken>(this.senderFn, 'getCloudWorkspaceBridge', 'doGetWorkspaceToken', scope, 'APP_ID');
}
}

View File

@@ -0,0 +1,13 @@
import type { IContactRead } from '@rocket.chat/apps-engine/definition/accessors/IContactRead';
import type { ILivechatContact } from '@rocket.chat/apps-engine/definition/livechat';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
export class ContactRead implements IContactRead {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public getById(contactId: ILivechatContact['_id']): Promise<ILivechatContact | null> {
return bridgeCall<ILivechatContact | null>(this.senderFn, 'getContactBridge', 'doGetById', contactId, 'APP_ID');
}
}

View File

@@ -0,0 +1,10 @@
import type { IExperimentalRead } from '@rocket.chat/apps-engine/definition/accessors';
import type * as Messenger from '../../messenger';
// The host ExperimentalRead is an empty placeholder (no public methods); the runtime
// mirror keeps the same shape. The senderFn is retained for parity with the other readers'
// constructors and for the methods this accessor is expected to grow.
export class ExperimentalRead implements IExperimentalRead {
constructor(protected readonly senderFn: typeof Messenger.sendRequest) {}
}

View File

@@ -0,0 +1,84 @@
import type { ILivechatRead } from '@rocket.chat/apps-engine/definition/accessors/ILivechatRead';
import type { IDepartment } from '@rocket.chat/apps-engine/definition/livechat';
import type { ILivechatRoom } from '@rocket.chat/apps-engine/definition/livechat/ILivechatRoom';
import type { IVisitor } from '@rocket.chat/apps-engine/definition/livechat/IVisitor';
import type { IMessage } from '@rocket.chat/apps-engine/definition/messages';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
export class LivechatRead implements ILivechatRead {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
/**
* @deprecated please use the `isOnlineAsync` method instead.
* In the next major, this method will be `async`
*
* NOTE: this really returns a Promise - the declared `boolean` comes from
* `ILivechatRead`, which cannot change before that major. Calls of the form
* `read.getLivechatReader().isOnline(...)` are rewritten to `await` it at load time by
* `fixLivechatIsOnlineCalls` (see `lib/ast/operations.ts`, reached via
* `sanitizeDeprecatedUsage`). A stored reference (`const r = read.getLivechatReader()`)
* is not rewritten and will observe the raw Promise.
*/
public isOnline(departmentId?: string): boolean {
console.warn(
"The `LivechatRead.isOnline` method is deprecated and won't behave as intended. Please use `LivechatRead.isOnlineAsync` instead",
);
return bridgeCall(this.senderFn, 'getLivechatBridge', 'doIsOnline', departmentId, 'APP_ID') as unknown as boolean;
}
public isOnlineAsync(departmentId?: string): Promise<boolean> {
return bridgeCall<boolean>(this.senderFn, 'getLivechatBridge', 'doIsOnlineAsync', departmentId, 'APP_ID');
}
public getDepartmentsEnabledWithAgents(): Promise<Array<IDepartment>> {
return bridgeCall<Array<IDepartment>>(this.senderFn, 'getLivechatBridge', 'doFindDepartmentsEnabledWithAgents', 'APP_ID');
}
public getLivechatRooms(visitor: IVisitor, departmentId?: string): Promise<Array<ILivechatRoom>> {
return bridgeCall<Array<ILivechatRoom>>(this.senderFn, 'getLivechatBridge', 'doFindRooms', visitor, departmentId, 'APP_ID');
}
public getLivechatTotalOpenRoomsByAgentId(agentId: string): Promise<number> {
return bridgeCall<number>(this.senderFn, 'getLivechatBridge', 'doCountOpenRoomsByAgentId', agentId, 'APP_ID');
}
public getLivechatOpenRoomsByAgentId(agentId: string): Promise<Array<ILivechatRoom>> {
return bridgeCall<Array<ILivechatRoom>>(this.senderFn, 'getLivechatBridge', 'doFindOpenRoomsByAgentId', agentId, 'APP_ID');
}
/**
* @deprecated This method does not adhere to the conversion practices applied
* elsewhere in the Apps-Engine and will be removed in the next major version.
* Prefer the alternative methods to fetch visitors.
*/
public getLivechatVisitors(query: object): Promise<Array<IVisitor>> {
return bridgeCall<Array<IVisitor>>(this.senderFn, 'getLivechatBridge', 'doFindVisitors', query, 'APP_ID');
}
public getLivechatVisitorById(id: string): Promise<IVisitor | undefined> {
return bridgeCall<IVisitor | undefined>(this.senderFn, 'getLivechatBridge', 'doFindVisitorById', id, 'APP_ID');
}
public getLivechatVisitorByEmail(email: string): Promise<IVisitor | undefined> {
return bridgeCall<IVisitor | undefined>(this.senderFn, 'getLivechatBridge', 'doFindVisitorByEmail', email, 'APP_ID');
}
public getLivechatVisitorByToken(token: string): Promise<IVisitor | undefined> {
return bridgeCall<IVisitor | undefined>(this.senderFn, 'getLivechatBridge', 'doFindVisitorByToken', token, 'APP_ID');
}
public getLivechatVisitorByPhoneNumber(phoneNumber: string): Promise<IVisitor | undefined> {
return bridgeCall<IVisitor | undefined>(this.senderFn, 'getLivechatBridge', 'doFindVisitorByPhoneNumber', phoneNumber, 'APP_ID');
}
public getLivechatDepartmentByIdOrName(value: string): Promise<IDepartment | undefined> {
return bridgeCall<IDepartment | undefined>(this.senderFn, 'getLivechatBridge', 'doFindDepartmentByIdOrName', value, 'APP_ID');
}
public _fetchLivechatRoomMessages(roomId: string): Promise<Array<IMessage>> {
return bridgeCall<Array<IMessage>>(this.senderFn, 'getLivechatBridge', 'do_fetchLivechatRoomMessages', 'APP_ID', roomId);
}
}

View File

@@ -0,0 +1,35 @@
import type { IMessageRead } from '@rocket.chat/apps-engine/definition/accessors';
import type { IMessage } from '@rocket.chat/apps-engine/definition/messages';
import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms';
import type { IUser } from '@rocket.chat/apps-engine/definition/users';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
export class MessageRead implements IMessageRead {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public getById(id: string): Promise<IMessage> {
return bridgeCall<IMessage>(this.senderFn, 'getMessageBridge', 'doGetById', id, 'APP_ID');
}
public async getSenderUser(messageId: string): Promise<IUser> {
const msg = (await bridgeCall(this.senderFn, 'getMessageBridge', 'doGetById', messageId, 'APP_ID')) as IMessage;
if (!msg) {
return undefined;
}
return msg.sender;
}
public async getRoom(messageId: string): Promise<IRoom> {
const msg = (await bridgeCall(this.senderFn, 'getMessageBridge', 'doGetById', messageId, 'APP_ID')) as IMessage;
if (!msg) {
return undefined;
}
return msg.room;
}
}

View File

@@ -0,0 +1,17 @@
import type { IOAuthApp } from '@rocket.chat/apps-engine/definition/accessors/IOAuthApp';
import type { IOAuthAppsReader } from '@rocket.chat/apps-engine/definition/accessors/IOAuthAppsReader';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
export class OAuthAppsReader implements IOAuthAppsReader {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public async getOAuthAppById(id: string): Promise<IOAuthApp> {
return bridgeCall<IOAuthApp>(this.senderFn, 'getOAuthAppsBridge', 'doGetByid', id, 'APP_ID');
}
public async getOAuthAppByName(name: string): Promise<Array<IOAuthApp>> {
return bridgeCall<Array<IOAuthApp>>(this.senderFn, 'getOAuthAppsBridge', 'doGetByName', name, 'APP_ID');
}
}

View File

@@ -0,0 +1,21 @@
import type { IPersistenceRead } from '@rocket.chat/apps-engine/definition/accessors';
import type { RocketChatAssociationRecord } from '@rocket.chat/apps-engine/definition/metadata';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
export class PersistenceRead implements IPersistenceRead {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public read(id: string): Promise<object> {
return bridgeCall<object>(this.senderFn, 'getPersistenceBridge', 'doReadById', id, 'APP_ID');
}
public readByAssociation(association: RocketChatAssociationRecord): Promise<Array<object>> {
return bridgeCall<Array<object>>(this.senderFn, 'getPersistenceBridge', 'doReadByAssociations', new Array(association), 'APP_ID');
}
public readByAssociations(associations: Array<RocketChatAssociationRecord>): Promise<Array<object>> {
return bridgeCall<Array<object>>(this.senderFn, 'getPersistenceBridge', 'doReadByAssociations', associations, 'APP_ID');
}
}

View File

@@ -0,0 +1,98 @@
import type {
ICloudWorkspaceRead,
IEnvironmentRead,
IExperimentalRead,
ILivechatRead,
IMessageRead,
INotifier,
IPersistenceRead,
IRead,
IRoomRead,
IUploadRead,
IUserRead,
IVideoConferenceRead,
} from '@rocket.chat/apps-engine/definition/accessors';
import type { IContactRead } from '@rocket.chat/apps-engine/definition/accessors/IContactRead';
import type { IOAuthAppsReader } from '@rocket.chat/apps-engine/definition/accessors/IOAuthAppsReader';
import type { IRoleRead } from '@rocket.chat/apps-engine/definition/accessors/IRoleRead';
import type { IThreadRead } from '@rocket.chat/apps-engine/definition/accessors/IThreadRead';
export class Reader implements IRead {
constructor(
private env: IEnvironmentRead,
private message: IMessageRead,
private persist: IPersistenceRead,
private room: IRoomRead,
private user: IUserRead,
private noti: INotifier,
private livechat: ILivechatRead,
private upload: IUploadRead,
private cloud: ICloudWorkspaceRead,
private videoConf: IVideoConferenceRead,
private contactRead: IContactRead,
private oauthApps: IOAuthAppsReader,
private thread: IThreadRead,
private role: IRoleRead,
private experimental: IExperimentalRead,
) {}
public getEnvironmentReader(): IEnvironmentRead {
return this.env;
}
public getThreadReader(): IThreadRead {
return this.thread;
}
public getMessageReader(): IMessageRead {
return this.message;
}
public getPersistenceReader(): IPersistenceRead {
return this.persist;
}
public getRoomReader(): IRoomRead {
return this.room;
}
public getUserReader(): IUserRead {
return this.user;
}
public getNotifier(): INotifier {
return this.noti;
}
public getLivechatReader(): ILivechatRead {
return this.livechat;
}
public getUploadReader(): IUploadRead {
return this.upload;
}
public getCloudWorkspaceReader(): ICloudWorkspaceRead {
return this.cloud;
}
public getVideoConferenceReader(): IVideoConferenceRead {
return this.videoConf;
}
public getOAuthAppsReader(): IOAuthAppsReader {
return this.oauthApps;
}
public getRoleReader(): IRoleRead {
return this.role;
}
public getContactReader(): IContactRead {
return this.contactRead;
}
public getExperimentalReader(): IExperimentalRead {
return this.experimental;
}
}

View File

@@ -0,0 +1,17 @@
import type { IRoleRead } from '@rocket.chat/apps-engine/definition/accessors/IRoleRead';
import type { IRole } from '@rocket.chat/apps-engine/definition/roles';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
export class RoleRead implements IRoleRead {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public getOneByIdOrName(idOrName: string): Promise<IRole | null> {
return bridgeCall<IRole | null>(this.senderFn, 'getRoleBridge', 'doGetOneByIdOrName', idOrName, 'APP_ID');
}
public getCustomRoles(): Promise<Array<IRole>> {
return bridgeCall<Array<IRole>>(this.senderFn, 'getRoleBridge', 'doGetCustomRoles', 'APP_ID');
}
}

View File

@@ -0,0 +1,115 @@
import type { IRoomRead } from '@rocket.chat/apps-engine/definition/accessors';
import type { IMessageRaw } from '@rocket.chat/apps-engine/definition/messages';
import type { IRoom, IRoomRaw } from '@rocket.chat/apps-engine/definition/rooms';
import {
type GetMessagesOptions,
type GetRoomsFilters,
type GetRoomsOptions,
GetMessagesSortableFields,
} from '@rocket.chat/apps-engine/definition/rooms/IGetMessagesOptions';
import type { IUser } from '@rocket.chat/apps-engine/definition/users';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
export class RoomRead implements IRoomRead {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public getById(id: string): Promise<IRoom> {
return bridgeCall<IRoom>(this.senderFn, 'getRoomBridge', 'doGetById', id, 'APP_ID');
}
public getCreatorUserById(id: string): Promise<IUser> {
return bridgeCall<IUser>(this.senderFn, 'getRoomBridge', 'doGetCreatorById', id, 'APP_ID');
}
public getByName(name: string): Promise<IRoom> {
return bridgeCall<IRoom>(this.senderFn, 'getRoomBridge', 'doGetByName', name, 'APP_ID');
}
public getCreatorUserByName(name: string): Promise<IUser> {
return bridgeCall<IUser>(this.senderFn, 'getRoomBridge', 'doGetCreatorByName', name, 'APP_ID');
}
public getMessages(roomId: string, options: Partial<GetMessagesOptions> = {}): Promise<IMessageRaw[]> {
if (typeof options.limit !== 'undefined' && (!Number.isFinite(options.limit) || options.limit > 100)) {
throw new Error(`Invalid limit provided. Expected number <= 100, got ${options.limit}`);
}
options.limit ??= 100;
options.showThreadMessages ??= true;
if (options.sort) {
this.validateSort(options.sort);
}
return bridgeCall<IMessageRaw[]>(this.senderFn, 'getRoomBridge', 'doGetMessages', roomId, options as GetMessagesOptions, 'APP_ID');
}
public getMembers(roomId: string): Promise<Array<IUser>> {
return bridgeCall<Array<IUser>>(this.senderFn, 'getRoomBridge', 'doGetMembers', roomId, 'APP_ID');
}
public getAllRooms(filters: GetRoomsFilters = {}, { limit = 100, skip = 0 }: GetRoomsOptions = {}): Promise<Array<IRoomRaw> | undefined> {
if (!Number.isFinite(limit) || limit <= 0 || limit > 100) {
throw new Error(`Invalid limit provided. Expected number between 1 and 100, got ${limit}`);
}
if (!Number.isFinite(skip) || skip < 0) {
throw new Error(`Invalid skip provided. Expected number >= 0, got ${skip}`);
}
return bridgeCall<Array<IRoomRaw> | undefined>(this.senderFn, 'getRoomBridge', 'doGetAllRooms', filters, { limit, skip }, 'APP_ID');
}
public getDirectByUsernames(usernames: Array<string>): Promise<IRoom> {
return bridgeCall<IRoom>(this.senderFn, 'getRoomBridge', 'doGetDirectByUsernames', usernames, 'APP_ID');
}
public getModerators(roomId: string): Promise<Array<IUser>> {
return bridgeCall<Array<IUser>>(this.senderFn, 'getRoomBridge', 'doGetModerators', roomId, 'APP_ID');
}
public getOwners(roomId: string): Promise<Array<IUser>> {
return bridgeCall<Array<IUser>>(this.senderFn, 'getRoomBridge', 'doGetOwners', roomId, 'APP_ID');
}
public getLeaders(roomId: string): Promise<Array<IUser>> {
return bridgeCall<Array<IUser>>(this.senderFn, 'getRoomBridge', 'doGetLeaders', roomId, 'APP_ID');
}
public async getUnreadByUser(roomId: string, uid: string, options: Partial<GetMessagesOptions> = {}): Promise<IMessageRaw[]> {
const { limit = 100, sort = { createdAt: 'asc' }, skip = 0, showThreadMessages = true } = options;
if (typeof roomId !== 'string' || roomId.trim().length === 0) {
throw new Error('Invalid roomId: must be a non-empty string');
}
if (!Number.isFinite(limit) || limit <= 0 || limit > 100) {
throw new Error(`Invalid limit provided. Expected number between 1 and 100, got ${limit}`);
}
this.validateSort(sort);
const completeOptions: GetMessagesOptions = { limit, sort, skip, showThreadMessages };
return bridgeCall<IMessageRaw[]>(this.senderFn, 'getRoomBridge', 'doGetUnreadByUser', roomId, uid, completeOptions, 'APP_ID');
}
public getUserUnreadMessageCount(roomId: string, uid: string): Promise<number> {
return bridgeCall<number>(this.senderFn, 'getRoomBridge', 'doGetUserUnreadMessageCount', roomId, uid, 'APP_ID');
}
// If there are any invalid fields or values, throw
private validateSort(sort: Record<string, unknown>) {
Object.entries(sort).forEach(([key, value]) => {
if (!GetMessagesSortableFields.includes(key as (typeof GetMessagesSortableFields)[number])) {
throw new Error(`Invalid key "${key}" used in sort. Available keys for sorting are ${GetMessagesSortableFields.join(', ')}`);
}
if (value !== 'asc' && value !== 'desc') {
throw new Error(`Invalid sort direction for field "${key}". Expected "asc" or "desc", got ${value}`);
}
});
}
}

View File

@@ -0,0 +1,15 @@
import type { IThreadRead } from '@rocket.chat/apps-engine/definition/accessors/IThreadRead';
import type { IMessage } from '@rocket.chat/apps-engine/definition/messages';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
export class ThreadRead implements IThreadRead {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public async getThreadById(id: string): Promise<Array<IMessage> | undefined> {
const messages = await bridgeCall<Array<IMessage> | null>(this.senderFn, 'getThreadBridge', 'doGetById', id, 'APP_ID');
return messages ?? undefined;
}
}

View File

@@ -0,0 +1,23 @@
import type { IUploadRead } from '@rocket.chat/apps-engine/definition/accessors';
import type { IUpload } from '@rocket.chat/apps-engine/definition/uploads';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
export class UploadRead implements IUploadRead {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public getById(id: string): Promise<IUpload> {
return bridgeCall<IUpload>(this.senderFn, 'getUploadBridge', 'doGetById', id, 'APP_ID');
}
public getBuffer(upload: IUpload): Promise<Buffer> {
return bridgeCall<Buffer>(this.senderFn, 'getUploadBridge', 'doGetBuffer', upload, 'APP_ID');
}
public async getBufferById(id: string): Promise<Buffer> {
const upload = (await bridgeCall(this.senderFn, 'getUploadBridge', 'doGetById', id, 'APP_ID')) as IUpload;
return bridgeCall<Buffer>(this.senderFn, 'getUploadBridge', 'doGetBuffer', upload, 'APP_ID');
}
}

View File

@@ -0,0 +1,40 @@
import type { IUserRead } from '@rocket.chat/apps-engine/definition/accessors';
import type { IUser } from '@rocket.chat/apps-engine/definition/users';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
export class UserRead implements IUserRead {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public getById(id: string): Promise<IUser> {
return bridgeCall<IUser>(this.senderFn, 'getUserBridge', 'doGetById', id, 'APP_ID');
}
public getByUsername(username: string): Promise<IUser> {
return bridgeCall<IUser>(this.senderFn, 'getUserBridge', 'doGetByUsername', username, 'APP_ID');
}
public getBySipExtension(extension: string): Promise<IUser | undefined> {
if (!extension) {
return Promise.resolve(undefined);
}
return bridgeCall<IUser | undefined>(this.senderFn, 'getUserBridge', 'doGetBySipExtension', extension, 'APP_ID');
}
// `appId` is an app-supplied argument, not caller identity, so it is NOT normalized
// to the 'APP_ID' sentinel - it defaults to the sentinel only when the app omits it.
// See docs/base-runtime-app-id-exceptions.md (bucket B).
public getAppUser(appId: string = 'APP_ID'): Promise<IUser | undefined> {
return bridgeCall<IUser | undefined>(this.senderFn, 'getUserBridge', 'doGetAppUser', appId);
}
public getUserUnreadMessageCount(uid: string): Promise<number> {
return bridgeCall<number>(this.senderFn, 'getUserBridge', 'doGetUserUnreadMessageCount', uid, 'APP_ID');
}
public getUserRoomIds(userId: string): Promise<string[]> {
return bridgeCall<string[]>(this.senderFn, 'getUserBridge', 'doGetUserRoomIds', userId, 'APP_ID');
}
}

View File

@@ -0,0 +1,13 @@
import type { IVideoConferenceRead } from '@rocket.chat/apps-engine/definition/accessors';
import type { VideoConference } from '@rocket.chat/apps-engine/definition/videoConferences';
import { bridgeCall } from '../../bridges/bridgeCall';
import type * as Messenger from '../../messenger';
export class VideoConferenceRead implements IVideoConferenceRead {
constructor(private readonly senderFn: typeof Messenger.sendRequest) {}
public getById(id: string): Promise<VideoConference> {
return bridgeCall<VideoConference>(this.senderFn, 'getVideoConferenceBridge', 'doGetById', id, 'APP_ID');
}
}

View File

@@ -0,0 +1,183 @@
/* eslint-disable testing-library/no-await-sync-queries -- accessor read methods are not testing-library DOM queries */
import * as assert from 'node:assert';
import { describe, it } from 'node:test';
import { createRecordingSender } from '../../tests/helpers/parityHarness';
import { CloudWorkspaceRead } from '../CloudWorkspaceRead';
import { ContactRead } from '../ContactRead';
import { LivechatRead } from '../LivechatRead';
import { MessageRead } from '../MessageRead';
import { OAuthAppsReader } from '../OAuthAppsReader';
import { PersistenceRead } from '../PersistenceRead';
import { RoleRead } from '../RoleRead';
import { RoomRead } from '../RoomRead';
import { ThreadRead } from '../ThreadRead';
import { UploadRead } from '../UploadRead';
import { UserRead } from '../UserRead';
import { VideoConferenceRead } from '../VideoConferenceRead';
const setup = (responses = {}) => {
const rec = createRecordingSender(responses);
return { rec, senderFn: rec.sender };
};
describe('Reader family (base-runtime)', () => {
describe('MessageRead', () => {
it('getById forwards to getMessageBridge:doGetById with the APP_ID sentinel', async () => {
const { rec, senderFn } = setup({ 'bridges:getMessageBridge:doGetById': { id: 'm1' } });
const result = await new MessageRead(senderFn).getById('m1');
assert.deepStrictEqual(rec.emitted(), [{ method: 'bridges:getMessageBridge:doGetById', params: ['m1', 'APP_ID'] }]);
assert.deepStrictEqual(result, { id: 'm1' });
});
it('getSenderUser returns the message sender, or undefined when the message is missing', async () => {
const withMsg = setup({ 'bridges:getMessageBridge:doGetById': { id: 'm1', sender: { id: 'u1' } } });
assert.deepStrictEqual(await new MessageRead(withMsg.senderFn).getSenderUser('m1'), { id: 'u1' });
const noMsg = setup({ 'bridges:getMessageBridge:doGetById': null });
assert.strictEqual(await new MessageRead(noMsg.senderFn).getSenderUser('m1'), undefined);
});
it('getRoom returns the message room, or undefined when the message is missing', async () => {
const withMsg = setup({ 'bridges:getMessageBridge:doGetById': { id: 'm1', room: { id: 'r1' } } });
assert.deepStrictEqual(await new MessageRead(withMsg.senderFn).getRoom('m1'), { id: 'r1' });
const noMsg = setup({ 'bridges:getMessageBridge:doGetById': undefined });
assert.strictEqual(await new MessageRead(noMsg.senderFn).getRoom('m1'), undefined);
});
});
describe('RoomRead', () => {
it('getMessages applies limit/showThreadMessages defaults', async () => {
const { rec, senderFn } = setup({ 'bridges:getRoomBridge:doGetMessages': [] });
await new RoomRead(senderFn).getMessages('r1');
assert.deepStrictEqual(rec.emitted()[0], {
method: 'bridges:getRoomBridge:doGetMessages',
params: ['r1', { limit: 100, showThreadMessages: true }, 'APP_ID'],
});
});
it('getMessages rejects a limit above 100', async () => {
const { senderFn } = setup();
assert.throws(() => new RoomRead(senderFn).getMessages('r1', { limit: 101 }), /Expected number <= 100, got 101/);
});
it('getMessages rejects an invalid sort key and direction', async () => {
const { senderFn } = setup();
assert.throws(() => new RoomRead(senderFn).getMessages('r1', { sort: { bogus: 'asc' } as any }), /Invalid key "bogus"/);
// `createdAt` is a valid sortable field, so this exercises the direction check specifically.
assert.throws(() => new RoomRead(senderFn).getMessages('r1', { sort: { createdAt: 'sideways' } as any }), /Invalid sort direction/);
});
it('getAllRooms validates limit and skip and forwards normalized options', async () => {
const { rec, senderFn } = setup({ 'bridges:getRoomBridge:doGetAllRooms': [] });
await new RoomRead(senderFn).getAllRooms();
assert.deepStrictEqual(rec.emitted()[0], {
method: 'bridges:getRoomBridge:doGetAllRooms',
params: [{}, { limit: 100, skip: 0 }, 'APP_ID'],
});
assert.throws(() => new RoomRead(setup().senderFn).getAllRooms({}, { limit: 0 } as any), /between 1 and 100/);
assert.throws(() => new RoomRead(setup().senderFn).getAllRooms({}, { skip: -1 } as any), /Expected number >= 0/);
});
it('getUnreadByUser validates roomId and fills option defaults', async () => {
const { rec, senderFn } = setup({ 'bridges:getRoomBridge:doGetUnreadByUser': [] });
await new RoomRead(senderFn).getUnreadByUser('r1', 'u1');
assert.deepStrictEqual(rec.emitted()[0], {
method: 'bridges:getRoomBridge:doGetUnreadByUser',
params: ['r1', 'u1', { limit: 100, sort: { createdAt: 'asc' }, skip: 0, showThreadMessages: true }, 'APP_ID'],
});
await assert.rejects(() => new RoomRead(setup().senderFn).getUnreadByUser(' ', 'u1'), /non-empty string/);
});
it('passthrough getters forward verbatim', async () => {
const { rec, senderFn } = setup();
const room = new RoomRead(senderFn);
await room.getById('r1');
await room.getMembers('r1');
await room.getUserUnreadMessageCount('r1', 'u1');
assert.deepStrictEqual(rec.methods(), [
'bridges:getRoomBridge:doGetById',
'bridges:getRoomBridge:doGetMembers',
'bridges:getRoomBridge:doGetUserUnreadMessageCount',
]);
});
});
describe('UserRead', () => {
it('getBySipExtension short-circuits to undefined for an empty extension (no bridge call)', async () => {
const { rec, senderFn } = setup();
assert.strictEqual(await new UserRead(senderFn).getBySipExtension(''), undefined);
assert.strictEqual(rec.emitted().length, 0);
});
it('getAppUser defaults to the APP_ID sentinel but forwards an explicit app id raw', async () => {
const a = setup();
await new UserRead(a.senderFn).getAppUser();
assert.deepStrictEqual(a.rec.emitted()[0], { method: 'bridges:getUserBridge:doGetAppUser', params: ['APP_ID'] });
const b = setup();
await new UserRead(b.senderFn).getAppUser('another-app');
assert.deepStrictEqual(b.rec.emitted()[0], { method: 'bridges:getUserBridge:doGetAppUser', params: ['another-app'] });
});
});
describe('PersistenceRead', () => {
it('readByAssociation wraps the single association into an array', async () => {
const { rec, senderFn } = setup({ 'bridges:getPersistenceBridge:doReadByAssociations': [] });
const assoc = { type: 1, id: 'x' } as any;
await new PersistenceRead(senderFn).readByAssociation(assoc);
assert.deepStrictEqual(rec.emitted()[0], {
method: 'bridges:getPersistenceBridge:doReadByAssociations',
params: [[assoc], 'APP_ID'],
});
});
});
describe('UploadRead', () => {
it('getBufferById fetches the upload then its buffer (two bridge calls)', async () => {
const { rec, senderFn } = setup({ 'bridges:getUploadBridge:doGetById': { _id: 'up1' } });
await new UploadRead(senderFn).getBufferById('up1');
assert.deepStrictEqual(rec.emitted(), [
{ method: 'bridges:getUploadBridge:doGetById', params: ['up1', 'APP_ID'] },
{ method: 'bridges:getUploadBridge:doGetBuffer', params: [{ _id: 'up1' }, 'APP_ID'] },
]);
});
});
describe('LivechatRead', () => {
it('_fetchLivechatRoomMessages sends the app id first, then the room id', async () => {
const { rec, senderFn } = setup({ 'bridges:getLivechatBridge:do_fetchLivechatRoomMessages': [] });
await new LivechatRead(senderFn)._fetchLivechatRoomMessages('r1');
assert.deepStrictEqual(rec.emitted()[0], {
method: 'bridges:getLivechatBridge:do_fetchLivechatRoomMessages',
params: ['APP_ID', 'r1'],
});
});
});
describe('simple passthrough readers', () => {
it('route to their matching bridge method', async () => {
const { rec, senderFn } = setup();
await new CloudWorkspaceRead(senderFn).getWorkspaceToken('scope');
new VideoConferenceRead(senderFn).getById('c1');
await new OAuthAppsReader(senderFn).getOAuthAppById('o1');
new ContactRead(senderFn).getById('ct1');
await new ThreadRead(senderFn).getThreadById('t1');
await new RoleRead(senderFn).getCustomRoles();
assert.deepStrictEqual(rec.emitted(), [
{ method: 'bridges:getCloudWorkspaceBridge:doGetWorkspaceToken', params: ['scope', 'APP_ID'] },
{ method: 'bridges:getVideoConferenceBridge:doGetById', params: ['c1', 'APP_ID'] },
{ method: 'bridges:getOAuthAppsBridge:doGetByid', params: ['o1', 'APP_ID'] },
{ method: 'bridges:getContactBridge:doGetById', params: ['ct1', 'APP_ID'] },
{ method: 'bridges:getThreadBridge:doGetById', params: ['t1', 'APP_ID'] },
{ method: 'bridges:getRoleBridge:doGetCustomRoles', params: ['APP_ID'] },
]);
});
});
});

View File

@@ -0,0 +1,152 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion -- acceptable in this test file */
/* eslint-disable testing-library/no-await-sync-queries */
import * as assert from 'node:assert';
import { after, beforeEach, describe, it, mock } from 'node:test';
import type { IRead, IModify, IHttp, IPersistence } from '@rocket.chat/apps-engine/definition/accessors';
import type { SlashCommandContext } from '@rocket.chat/apps-engine/definition/slashcommands';
import { AppObjectRegistry } from '../../../AppObjectRegistry';
import { AppAccessors } from '../mod';
describe('AppAccessors', () => {
let appAccessors: AppAccessors;
const senderFn = (r: object) =>
Promise.resolve({
id: Math.random().toString(36).substring(2),
jsonrpc: '2.0',
result: r,
serialize() {
return JSON.stringify(this);
},
});
beforeEach(() => {
appAccessors = new AppAccessors(senderFn);
AppObjectRegistry.clear();
});
after(() => {
AppObjectRegistry.clear();
});
// The Reader family, Persistence and server-side Environment accessors now run locally in the
// subprocess (Phase 1): they emit `bridges:*` messages with the 'APP_ID' sentinel instead of the
// old host-resolved `accessor:*` messages. We spy on the sender to assert the emitted bridge call,
// which is robust for the accessors that transform their result or return void.
it('creates the correct format for IRead calls', async () => {
const spy = mock.fn(senderFn);
const roomRead = new AppAccessors(spy).getReader()!.getRoomReader();
await roomRead.getById('123');
assert.deepStrictEqual(spy.mock.calls[0].arguments, [
{
params: ['123', 'APP_ID'],
method: 'bridges:getRoomBridge:doGetById',
},
]);
});
it('creates the correct format for IEnvironmentRead calls from IRead', async () => {
const spy = mock.fn(senderFn);
const reader = new AppAccessors(spy).getReader()!.getEnvironmentReader().getEnvironmentVariables();
await reader.getValueByName('NODE_ENV');
assert.deepStrictEqual(spy.mock.calls[0].arguments, [
{
params: ['NODE_ENV', 'APP_ID'],
method: 'bridges:getEnvironmentalVariableBridge:doGetValueByName',
},
]);
});
it('creates the correct format for IEvironmentRead calls', async () => {
const spy = mock.fn(senderFn);
const envRead = new AppAccessors(spy).getEnvironmentRead();
await envRead.getServerSettings().getOneById('123');
assert.deepStrictEqual(spy.mock.calls[0].arguments, [
{
params: ['123', 'APP_ID'],
method: 'bridges:getServerSettingBridge:doGetOneById',
},
]);
});
it('creates the correct format for IEvironmentWrite calls', async () => {
const spy = mock.fn(senderFn);
const envWrite = new AppAccessors(spy).getEnvironmentWrite();
await envWrite.getServerSettings().incrementValue('123', 6);
assert.deepStrictEqual(spy.mock.calls[0].arguments, [
{
params: ['123', 6, 'APP_ID'],
method: 'bridges:getServerSettingBridge:doIncrementValue',
},
]);
});
it('creates the correct format for IConfigurationModify calls', async () => {
const configModify = appAccessors.getConfigurationModify();
const command = await configModify.slashCommands.modifySlashCommand({
command: 'test',
i18nDescription: 'test',
i18nParamsExample: 'test',
providesPreview: true,
executor(_context: SlashCommandContext, _read: IRead, _modify: IModify, _http: IHttp, _persis: IPersistence): Promise<void> {
throw new Error('Function not implemented.');
},
});
// The function will not be serialized and sent to the main process
delete (command as any).params[0].executor;
assert.deepStrictEqual(command, {
params: [
{
command: 'test',
i18nDescription: 'test',
i18nParamsExample: 'test',
providesPreview: true,
},
'APP_ID',
],
method: 'bridges:getAppResourceBridge:doModifySlashCommand',
});
});
it('correctly stores a reference to a slashcommand object and sends a request via proxy', async () => {
const configExtend = appAccessors.getConfigurationExtend();
const slashcommand = {
command: 'test',
i18nDescription: 'test',
i18nParamsExample: 'test',
providesPreview: true,
executor() {
return Promise.resolve();
},
};
const result = await configExtend.slashCommands.provideSlashCommand(slashcommand);
assert.deepStrictEqual(AppObjectRegistry.get('slashcommand:test'), slashcommand);
// The function will not be serialized and sent to the main process
delete (result as any).params[0].executor;
assert.deepStrictEqual(result, {
method: 'bridges:getAppResourceBridge:doProvideSlashCommand',
params: [
{
command: 'test',
i18nDescription: 'test',
i18nParamsExample: 'test',
providesPreview: true,
},
'APP_ID',
],
});
});
});

View File

@@ -0,0 +1,292 @@
import * as assert from 'node:assert';
import { after, beforeEach, describe, it, mock } from 'node:test';
import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms';
import type { IUploadDescriptor } from '@rocket.chat/apps-engine/definition/uploads/IUploadDescriptor';
import type { IUser } from '@rocket.chat/apps-engine/definition/users';
import { AppObjectRegistry } from '../../../AppObjectRegistry';
import { ModifyCreator } from '../modify/ModifyCreator';
describe('ModifyCreator', () => {
const senderFn = (r: any) =>
Promise.resolve({
id: Math.random().toString(36).substring(2),
jsonrpc: '2.0',
result: r,
serialize() {
return JSON.stringify(this);
},
});
beforeEach(() => {
AppObjectRegistry.clear();
AppObjectRegistry.set('id', 'deno-test');
});
after(() => {
AppObjectRegistry.clear();
});
it('sends the correct payload in the request to create a message', async () => {
const spying = mock.fn(senderFn);
const modifyCreator = new ModifyCreator(spying);
const messageBuilder = modifyCreator.startMessage();
// Importing types from the Apps-Engine is problematic, so we'll go with `any` here
messageBuilder
.setRoom({ id: '123' } as IRoom)
.setSender({ id: '456' } as IUser)
.setText('Hello World')
.setUsernameAlias('alias')
.setAvatarUrl('https://avatars.com/123');
// We can't get a legitimate return value here, so we ignore it
// but we need to know that the request sent was well formed
await modifyCreator.finish(messageBuilder);
assert.deepStrictEqual(spying.mock.calls[0].arguments, [
{
method: 'bridges:getMessageBridge:doCreate',
params: [
{
room: { id: '123' },
sender: { id: '456' },
text: 'Hello World',
alias: 'alias',
avatarUrl: 'https://avatars.com/123',
},
'APP_ID',
],
},
]);
});
it('sends the correct payload in the request to upload a buffer (defaults user to the app user, then creates the upload)', async () => {
// Resolve the app-user lookup to a real user so we can assert it is propagated as the uploader.
const appUserSender = (req: any) => senderFn(req.method === 'bridges:getUserBridge:doGetAppUser' ? { id: 'app-user' } : req);
const spying = mock.fn(appUserSender);
const modifyCreator = new ModifyCreator(spying);
const buffer = Buffer.from([1, 2, 3, 4]);
await modifyCreator.getUploadCreator().uploadBuffer(buffer, { filename: 'testfile', room: { id: 'r1' } } as IUploadDescriptor);
// No `user`/`visitorToken` on the descriptor, so the app user is fetched first...
assert.deepStrictEqual(spying.mock.calls[0].arguments, [{ method: 'bridges:getUserBridge:doGetAppUser', params: ['APP_ID'] }]);
// ...then the upload is created with the derived details, carrying the fetched app user's id.
const [createCall] = spying.mock.calls[1].arguments as any[];
assert.strictEqual(createCall.method, 'bridges:getUploadBridge:doCreateUpload');
assert.deepStrictEqual(createCall.params[0], {
name: 'testfile',
size: 4,
rid: 'r1',
userId: 'app-user',
visitorToken: undefined,
});
assert.deepStrictEqual(createCall.params[1], buffer);
assert.strictEqual(createCall.params[2], 'APP_ID');
});
it('sends the correct payload in the request to create a visitor', async () => {
const spying = mock.fn(senderFn);
const modifyCreator = new ModifyCreator(spying);
await modifyCreator.getLivechatCreator().createVisitor({
token: 'random token',
username: 'random username for visitor',
name: 'Random Visitor',
});
assert.deepStrictEqual(spying.mock.calls[0].arguments, [
{
method: 'bridges:getLivechatBridge:doCreateVisitor',
params: [
{
token: 'random token',
username: 'random username for visitor',
name: 'Random Visitor',
},
'APP_ID',
],
},
]);
});
// This test is important because if we return a promise we break API compatibility
it('does not return a promise for calls of the createToken() method of the LivechatCreator', () => {
const modifyCreator = new ModifyCreator(senderFn);
const result = modifyCreator.getLivechatCreator().createToken();
assert.ok(!((result as any) instanceof Promise));
assert.ok(typeof result === 'string', `Expected "${result}" to be of type "string", but got "${typeof result}"`);
});
it('throws an error when a proxy method of getLivechatCreator fails', async () => {
const failingSenderFn = () => Promise.reject(new Error('Test error'));
const modifyCreator = new ModifyCreator(failingSenderFn);
const livechatCreator = modifyCreator.getLivechatCreator();
await assert.rejects(
() =>
livechatCreator.createAndReturnVisitor({
token: 'visitor-token',
username: 'visitor-username',
name: 'Visitor Name',
}),
{ message: 'Test error' },
);
});
it('throws an instance of Error when getLivechatCreator fails with a specific error object', async () => {
const failingSenderFn = () => Promise.reject({ error: { message: 'Livechat method error' } });
const modifyCreator = new ModifyCreator(failingSenderFn);
const livechatCreator = modifyCreator.getLivechatCreator();
await assert.rejects(
() =>
livechatCreator.createVisitor({
token: 'visitor-token',
username: 'visitor-username',
name: 'Visitor Name',
}),
{ message: 'Livechat method error' },
);
});
it('throws a default Error when getLivechatCreator fails with an unknown error object', async () => {
const failingSenderFn = () => Promise.reject({ error: {} });
const modifyCreator = new ModifyCreator(failingSenderFn);
const livechatCreator = modifyCreator.getLivechatCreator();
await assert.rejects(
() =>
livechatCreator.createVisitor({
token: 'visitor-token',
username: 'visitor-username',
name: 'Visitor Name',
}),
{ message: 'An unknown error occurred' },
);
});
it('throws an error when a proxy method of getUploadCreator fails', async () => {
const failingSenderFn = () => Promise.reject(new Error('Upload error'));
const modifyCreator = new ModifyCreator(failingSenderFn);
const uploadCreator = modifyCreator.getUploadCreator();
await assert.rejects(
() => uploadCreator.uploadBuffer(Buffer.from([1, 2, 3, 4]), { filename: 'testfile-reject' } as IUploadDescriptor),
{
message: 'Upload error',
},
);
});
it('throws an instance of Error when getUploadCreator fails with a specific error object', async () => {
const failingSenderFn = () => Promise.reject({ error: { message: 'Upload method error' } });
const modifyCreator = new ModifyCreator(failingSenderFn);
const uploadCreator = modifyCreator.getUploadCreator();
await assert.rejects(
() => uploadCreator.uploadBuffer(Buffer.from([1, 2, 3, 4]), { filename: 'testfile-reject' } as IUploadDescriptor),
{
message: 'Upload method error',
},
);
});
it('throws a default Error when getUploadCreator fails with an unknown error object', async () => {
const failingSenderFn = () => Promise.reject({ error: {} });
const modifyCreator = new ModifyCreator(failingSenderFn);
const uploadCreator = modifyCreator.getUploadCreator();
await assert.rejects(
() => uploadCreator.uploadBuffer(Buffer.from([1, 2, 3, 4]), { filename: 'testfile-reject' } as IUploadDescriptor),
{
message: 'An unknown error occurred',
},
);
});
it('throws an error when a proxy method of getEmailCreator fails', async () => {
const failingSenderFn = () => Promise.reject(new Error('Email error'));
const modifyCreator = new ModifyCreator(failingSenderFn);
const emailCreator = modifyCreator.getEmailCreator();
await assert.rejects(
() =>
emailCreator.send({
to: 'test@example.com',
from: 'sender@example.com',
subject: 'Test Email',
text: 'This is a test email.',
}),
{ message: 'Email error' },
);
});
it('throws an instance of Error when getEmailCreator fails with a specific error object', async () => {
const failingSenderFn = () => Promise.reject({ error: { message: 'Email method error' } });
const modifyCreator = new ModifyCreator(failingSenderFn);
const emailCreator = modifyCreator.getEmailCreator();
await assert.rejects(
() =>
emailCreator.send({
to: 'test@example.com',
from: 'sender@example.com',
subject: 'Test Email',
text: 'This is a test email.',
}),
{ message: 'Email method error' },
);
});
it('throws a default Error when getEmailCreator fails with an unknown error object', async () => {
const failingSenderFn = () => Promise.reject({ error: {} });
const modifyCreator = new ModifyCreator(failingSenderFn);
const emailCreator = modifyCreator.getEmailCreator();
await assert.rejects(
() =>
emailCreator.send({
to: 'test@example.com',
from: 'sender@example.com',
subject: 'Test Email',
text: 'This is a test email.',
}),
{ message: 'An unknown error occurred' },
);
});
it('throws an error when a proxy method of getContactCreator fails', async () => {
const failingSenderFn = () => Promise.reject(new Error('Contact creation error'));
const modifyCreator = new ModifyCreator(failingSenderFn);
const contactCreator = modifyCreator.getContactCreator();
await assert.rejects(() => contactCreator.addContactEmail('test-contact-id', 'test@example.com'), {
message: 'Contact creation error',
});
});
it('throws an instance of Error when getContactCreator fails with a specific error object', async () => {
const failingSenderFn = () => Promise.reject({ error: { message: 'Contact creation error' } });
const modifyCreator = new ModifyCreator(failingSenderFn);
const contactCreator = modifyCreator.getContactCreator();
await assert.rejects(() => contactCreator.addContactEmail('test-contact-id', 'test@example.com'), {
message: 'Contact creation error',
});
});
it('throws a default Error when getContactCreator fails with an unknown error object', async () => {
const failingSenderFn = () => Promise.reject({ error: {} });
const modifyCreator = new ModifyCreator(failingSenderFn);
const contactCreator = modifyCreator.getContactCreator();
await assert.rejects(() => contactCreator.addContactEmail('test-contact-id', 'test@example.com'), {
message: 'An unknown error occurred',
});
});
});

View File

@@ -0,0 +1,235 @@
import * as assert from 'node:assert';
import { after, beforeEach, describe, it, mock } from 'node:test';
import jsonrpc from 'jsonrpc-lite';
import { AppObjectRegistry } from '../../../AppObjectRegistry';
import { ModifyExtender } from '../modify/ModifyExtender';
describe('ModifyExtender', () => {
let extender: ModifyExtender;
const senderFn = (r: any) =>
Promise.resolve({
id: Math.random().toString(36).substring(2),
jsonrpc: '2.0',
result: structuredClone(r),
serialize() {
return JSON.stringify(this);
},
});
beforeEach(() => {
AppObjectRegistry.clear();
AppObjectRegistry.set('id', 'deno-test');
extender = new ModifyExtender(senderFn);
});
after(() => {
AppObjectRegistry.clear();
});
it('correctly formats requests for the extend message requests', async () => {
const _spy = mock.method(extender, 'senderFn' as any);
const messageExtender = await extender.extendMessage('message-id', { _id: 'user-id' } as any);
assert.deepStrictEqual(_spy.mock.calls[0].arguments, [
{
method: 'bridges:getMessageBridge:doGetById',
params: ['message-id', 'APP_ID'],
},
]);
messageExtender.addCustomField('key', 'value');
await extender.finish(messageExtender);
assert.deepStrictEqual(_spy.mock.calls[1].arguments, [
{
method: 'bridges:getMessageBridge:doUpdate',
params: [messageExtender.getMessage(), 'APP_ID'],
},
]);
_spy.mock.restore();
});
it('correctly formats requests for the extend room requests', async () => {
const _spy = mock.method(extender, 'senderFn' as any);
const roomExtender = await extender.extendRoom('room-id', { _id: 'user-id' } as any);
assert.deepStrictEqual(_spy.mock.calls[0].arguments, [
{
method: 'bridges:getRoomBridge:doGetById',
params: ['room-id', 'APP_ID'],
},
]);
roomExtender.addCustomField('key', 'value');
await extender.finish(roomExtender);
assert.deepStrictEqual(_spy.mock.calls[1].arguments, [
{
method: 'bridges:getRoomBridge:doUpdate',
params: [roomExtender.getRoom(), [], 'APP_ID'],
},
]);
_spy.mock.restore();
});
it('correctly formats requests for the extend video conference requests', async () => {
const _spy = mock.method(extender, 'senderFn' as any);
const videoConferenceExtender = await extender.extendVideoConference('video-conference-id');
assert.deepStrictEqual(_spy.mock.calls[0].arguments, [
{
method: 'bridges:getVideoConferenceBridge:doGetById',
params: ['video-conference-id', 'APP_ID'],
},
]);
videoConferenceExtender.setStatus(4);
await extender.finish(videoConferenceExtender);
assert.deepStrictEqual(_spy.mock.calls[1].arguments, [
{
method: 'bridges:getVideoConferenceBridge:doUpdate',
params: [videoConferenceExtender.getVideoConference(), 'APP_ID'],
},
]);
_spy.mock.restore();
});
describe('Error Handling', () => {
describe('extendMessage', () => {
it('throws an instance of Error when senderFn throws an error', async () => {
const _stub = mock.method(extender, 'senderFn' as any, () => Promise.reject(new Error('unit-test-error')) as any);
await assert.rejects(() => extender.extendMessage('message-id', { _id: 'user-id' } as any), { message: 'unit-test-error' });
_stub.mock.restore();
});
it('throws an instance of Error when senderFn throws a jsonrpc error', async () => {
const _stub = mock.method(
extender,
'senderFn' as any,
() => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any,
);
await assert.rejects(() => extender.extendMessage('message-id', { _id: 'user-id' } as any), { message: 'unit-test-error' });
_stub.mock.restore();
});
it('throws an instance of Error when senderFn throws an unknown value', async () => {
const _stub = mock.method(extender, 'senderFn' as any, () => Promise.reject({}) as any);
await assert.rejects(() => extender.extendMessage('message-id', { _id: 'user-id' } as any), {
message: 'An unknown error occurred',
});
_stub.mock.restore();
});
});
describe('extendRoom', () => {
it('throws an instance of Error when senderFn throws an error', async () => {
const _stub = mock.method(extender, 'senderFn' as any, () => Promise.reject(new Error('unit-test-error')) as any);
await assert.rejects(() => extender.extendRoom('room-id', { _id: 'user-id' } as any), { message: 'unit-test-error' });
_stub.mock.restore();
});
it('throws an instance of Error when senderFn throws a jsonrpc error', async () => {
const _stub = mock.method(
extender,
'senderFn' as any,
() => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any,
);
await assert.rejects(() => extender.extendRoom('room-id', { _id: 'user-id' } as any), { message: 'unit-test-error' });
_stub.mock.restore();
});
it('throws an instance of Error when senderFn throws an unknown value', async () => {
const _stub = mock.method(extender, 'senderFn' as any, () => Promise.reject({}) as any);
await assert.rejects(() => extender.extendRoom('room-id', { _id: 'user-id' } as any), { message: 'An unknown error occurred' });
_stub.mock.restore();
});
});
describe('extendVideoConference', () => {
it('throws an instance of Error when senderFn throws an error', async () => {
const _stub = mock.method(extender, 'senderFn' as any, () => Promise.reject(new Error('unit-test-error')) as any);
await assert.rejects(() => extender.extendVideoConference('video-conference-id'), { message: 'unit-test-error' });
_stub.mock.restore();
});
it('throws an instance of Error when senderFn throws a jsonrpc error', async () => {
const _stub = mock.method(
extender,
'senderFn' as any,
() => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any,
);
await assert.rejects(() => extender.extendVideoConference('video-conference-id'), { message: 'unit-test-error' });
_stub.mock.restore();
});
it('throws an instance of Error when senderFn throws an unknown value', async () => {
const _stub = mock.method(extender, 'senderFn' as any, () => Promise.reject({}) as any);
await assert.rejects(() => extender.extendVideoConference('video-conference-id'), { message: 'An unknown error occurred' });
_stub.mock.restore();
});
});
describe('finish', () => {
it('throws an instance of Error when senderFn throws an error', async () => {
const _stub = mock.method(extender, 'senderFn' as any, () => Promise.reject(new Error('unit-test-error')) as any);
await assert.rejects(() => extender.finish({ kind: 'message', getMessage: () => ({}) } as any), { message: 'unit-test-error' });
_stub.mock.restore();
});
it('throws an instance of Error when senderFn throws a jsonrpc error', async () => {
const _stub = mock.method(
extender,
'senderFn' as any,
() => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any,
);
await assert.rejects(() => extender.finish({ kind: 'message', getMessage: () => ({}) } as any), { message: 'unit-test-error' });
_stub.mock.restore();
});
it('throws an instance of Error when senderFn throws an unknown value', async () => {
const _stub = mock.method(extender, 'senderFn' as any, () => Promise.reject({}) as any);
await assert.rejects(() => extender.finish({ kind: 'message', getMessage: () => ({}) } as any), {
message: 'An unknown error occurred',
});
_stub.mock.restore();
});
});
});
});

View File

@@ -0,0 +1,256 @@
import * as assert from 'node:assert';
import { after, beforeEach, describe, it, mock } from 'node:test';
import type { IMessage } from '@rocket.chat/apps-engine/definition/messages';
import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms';
import type { IUser } from '@rocket.chat/apps-engine/definition/users';
import jsonrpc from 'jsonrpc-lite';
import { AppObjectRegistry } from '../../../AppObjectRegistry';
import type { RoomBuilder } from '../builders/RoomBuilder';
import { ModifyUpdater } from '../modify/ModifyUpdater';
describe('ModifyUpdater', () => {
let modifyUpdater: ModifyUpdater;
const senderFn = (r: any) =>
Promise.resolve({
id: Math.random().toString(36).substring(2),
jsonrpc: '2.0',
result: structuredClone(r),
serialize() {
return JSON.stringify(this);
},
});
beforeEach(() => {
AppObjectRegistry.clear();
AppObjectRegistry.set('id', 'deno-test');
modifyUpdater = new ModifyUpdater(senderFn);
});
after(() => {
AppObjectRegistry.clear();
});
it('correctly formats requests for the update message flow', async () => {
// `as any` because it's hard to align the types for a private prop
const _spy = mock.method(modifyUpdater, 'senderFn' as any);
const messageBuilder = await modifyUpdater.message('123', { id: '456' } as IUser);
assert.deepStrictEqual(_spy.mock.calls[0].arguments, [
{
method: 'bridges:getMessageBridge:doGetById',
params: ['123', 'APP_ID'],
},
]);
messageBuilder.setUpdateData(
{
id: '123',
room: { id: '123' } as IRoom,
sender: { id: '456' } as IUser,
text: 'Hello World',
} as IMessage,
{ id: '456' } as IUser,
);
await modifyUpdater.finish(messageBuilder);
assert.deepStrictEqual(_spy.mock.calls[1].arguments, [
{
method: 'bridges:getMessageBridge:doUpdate',
params: [{ id: '123', ...(messageBuilder as any).getChanges() }, 'APP_ID'],
},
]);
_spy.mock.restore();
});
it('correctly formats requests for the update room flow', async () => {
const _spy = mock.method(modifyUpdater, 'senderFn' as any);
const roomBuilder = (await modifyUpdater.room('123', { id: '456' } as IUser)) as RoomBuilder;
assert.deepStrictEqual(_spy.mock.calls[0].arguments, [
{
method: 'bridges:getRoomBridge:doGetById',
params: ['123', 'APP_ID'],
},
]);
roomBuilder.setData({
id: '123',
type: 'c',
displayName: 'Test Room',
slugifiedName: 'test-room',
creator: { id: '456' },
} as IRoom);
roomBuilder.setMembersToBeAddedByUsernames(['username1', 'username2']);
// We need to sneak in the id as the `modifyUpdater.room` call won't have legitimate data
roomBuilder.getRoom().id = '123';
await modifyUpdater.finish(roomBuilder);
assert.deepStrictEqual(_spy.mock.calls[1].arguments, [
{
method: 'bridges:getRoomBridge:doUpdate',
params: [{ id: '123', ...roomBuilder.getChanges() }, roomBuilder.getMembersToBeAddedUsernames(), 'APP_ID'],
},
]);
});
it('correctly formats requests to UserUpdater methods', async () => {
const _spy = mock.method(modifyUpdater, 'senderFn' as any);
await modifyUpdater.getUserUpdater().updateStatusText({ id: '123' } as IUser, 'Hello World');
assert.deepStrictEqual(_spy.mock.calls[0].arguments, [
{
method: 'bridges:getUserBridge:doUpdate',
params: [{ id: '123' }, { statusText: 'Hello World' }, 'APP_ID'],
},
]);
_spy.mock.restore();
});
it('correctly formats requests to LivechatUpdater methods', async () => {
const _spy = mock.method(modifyUpdater, 'senderFn' as any);
await modifyUpdater.getLivechatUpdater().closeRoom({ id: '123' } as IRoom, 'close it!');
assert.deepStrictEqual(_spy.mock.calls[0].arguments, [
{
method: 'bridges:getLivechatBridge:doCloseRoom',
params: [{ id: '123' }, 'close it!', undefined, 'APP_ID'],
},
]);
_spy.mock.restore();
});
it('correctly formats requests to MessageUpdater methods', async () => {
const _spy = mock.method(modifyUpdater, 'senderFn' as any);
await modifyUpdater.getMessageUpdater().addReaction('message-id', 'user-id', ':smile:' as any);
assert.deepStrictEqual(_spy.mock.calls[0].arguments, [
{
method: 'bridges:getMessageBridge:doAddReaction',
params: ['message-id', 'user-id', ':smile:', 'APP_ID'],
},
]);
_spy.mock.restore();
});
describe('Error Handling', () => {
describe('message', () => {
it('throws an instance of Error when senderFn throws an error', async () => {
const _stub = mock.method(modifyUpdater, 'senderFn' as any, () => Promise.reject(new Error('unit-test-error')) as any);
await assert.rejects(() => modifyUpdater.message('message-id', { id: 'user-id' } as IUser), { message: 'unit-test-error' });
_stub.mock.restore();
});
it('throws an instance of Error when senderFn throws a jsonrpc error', async () => {
const _stub = mock.method(
modifyUpdater,
'senderFn' as any,
() => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any,
);
await assert.rejects(() => modifyUpdater.message('message-id', { id: 'user-id' } as IUser), { message: 'unit-test-error' });
_stub.mock.restore();
});
it('throws an instance of Error when senderFn throws an unknown value', async () => {
const _stub = mock.method(modifyUpdater, 'senderFn' as any, () => Promise.reject({}) as any);
await assert.rejects(() => modifyUpdater.message('message-id', { id: 'user-id' } as IUser), {
message: 'An unknown error occurred',
});
_stub.mock.restore();
});
});
describe('room', () => {
it('throws an instance of Error when senderFn throws an error', async () => {
const _stub = mock.method(modifyUpdater, 'senderFn' as any, () => Promise.reject(new Error('unit-test-error')) as any);
await assert.rejects(() => modifyUpdater.room('room-id', { id: 'user-id' } as IUser), { message: 'unit-test-error' });
_stub.mock.restore();
});
it('throws an instance of Error when senderFn throws a jsonrpc error', async () => {
const _stub = mock.method(
modifyUpdater,
'senderFn' as any,
() => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any,
);
await assert.rejects(() => modifyUpdater.room('room-id', { id: 'user-id' } as IUser), { message: 'unit-test-error' });
_stub.mock.restore();
});
it('throws an instance of Error when senderFn throws an unknown value', async () => {
const _stub = mock.method(modifyUpdater, 'senderFn' as any, () => Promise.reject({}) as any);
await assert.rejects(() => modifyUpdater.room('room-id', { id: 'user-id' } as IUser), { message: 'An unknown error occurred' });
_stub.mock.restore();
});
});
describe('finish', () => {
const messageUpdater = {
kind: 'message',
getMessage: () => ({
id: 'message-id',
sender: { id: 'sender-id' },
}),
getChanges: () => ({
id: 'message-id',
sender: { id: 'sender-id' },
}),
} as any;
it('throws an instance of Error when senderFn throws an error', async () => {
const _stub = mock.method(modifyUpdater, 'senderFn' as any, () => Promise.reject(new Error('unit-test-error')) as any);
await assert.rejects(() => modifyUpdater.finish(messageUpdater), { message: 'unit-test-error' });
_stub.mock.restore();
});
it('throws an instance of Error when senderFn throws a jsonrpc error', async () => {
const _stub = mock.method(
modifyUpdater,
'senderFn' as any,
() => Promise.reject(jsonrpc.error('unit-test-error', new jsonrpc.JsonRpcError('unit-test-error', 1000))) as any,
);
await assert.rejects(() => modifyUpdater.finish(messageUpdater), { message: 'unit-test-error' });
_stub.mock.restore();
});
it('throws an instance of Error when senderFn throws an unknown value', async () => {
const _stub = mock.method(modifyUpdater, 'senderFn' as any, () => Promise.reject({}) as any);
await assert.rejects(() => modifyUpdater.finish(messageUpdater), { message: 'An unknown error occurred' });
_stub.mock.restore();
});
});
});
});

View File

@@ -0,0 +1,93 @@
import * as assert from 'node:assert';
import { describe, it } from 'node:test';
import { Persistence } from '../Persistence';
import { createRecordingSender } from './helpers/parityHarness';
const setup = () => {
const rec = createRecordingSender();
return { rec, senderFn: rec.sender };
};
describe('Persistence (base-runtime)', () => {
it('create forwards data with the APP_ID sentinel', async () => {
const { rec, senderFn } = setup();
await new Persistence(senderFn).create({ a: 1 });
assert.deepStrictEqual(rec.emitted()[0], { method: 'bridges:getPersistenceBridge:doCreate', params: [{ a: 1 }, 'APP_ID'] });
});
it('createWithAssociation wraps the single association into an array (doCreateWithAssociations)', async () => {
const { rec, senderFn } = setup();
const assoc = { type: 1, id: 'x' } as any;
await new Persistence(senderFn).createWithAssociation({ a: 1 }, assoc);
assert.deepStrictEqual(rec.emitted()[0], {
method: 'bridges:getPersistenceBridge:doCreateWithAssociations',
params: [{ a: 1 }, [assoc], 'APP_ID'],
});
});
it('update defaults upsert to false', async () => {
const { rec, senderFn } = setup();
await new Persistence(senderFn).update('id1', { a: 1 });
assert.deepStrictEqual(rec.emitted()[0], {
method: 'bridges:getPersistenceBridge:doUpdate',
params: ['id1', { a: 1 }, false, 'APP_ID'],
});
});
it('updateByAssociation wraps the association and defaults upsert to false', async () => {
const { rec, senderFn } = setup();
const assoc = { type: 1, id: 'x' } as any;
await new Persistence(senderFn).updateByAssociation(assoc, { a: 1 });
assert.deepStrictEqual(rec.emitted()[0], {
method: 'bridges:getPersistenceBridge:doUpdateByAssociations',
params: [[assoc], { a: 1 }, false, 'APP_ID'],
});
});
it('removeByAssociation wraps the single association into an array', async () => {
const { rec, senderFn } = setup();
const assoc = { type: 1, id: 'x' } as any;
await new Persistence(senderFn).removeByAssociation(assoc);
assert.deepStrictEqual(rec.emitted()[0], {
method: 'bridges:getPersistenceBridge:doRemoveByAssociations',
params: [[assoc], 'APP_ID'],
});
});
it('createWithAssociations forwards the associations array as-is', async () => {
const { rec, senderFn } = setup();
const assocs = [{ type: 1, id: 'x' } as any, { type: 2, id: 'y' } as any];
await new Persistence(senderFn).createWithAssociations({ a: 1 }, assocs);
assert.deepStrictEqual(rec.emitted()[0], {
method: 'bridges:getPersistenceBridge:doCreateWithAssociations',
params: [{ a: 1 }, assocs, 'APP_ID'],
});
});
it('updateByAssociations forwards the associations array and defaults upsert to false', async () => {
const { rec, senderFn } = setup();
const assocs = [{ type: 1, id: 'x' } as any, { type: 2, id: 'y' } as any];
await new Persistence(senderFn).updateByAssociations(assocs, { a: 1 });
assert.deepStrictEqual(rec.emitted()[0], {
method: 'bridges:getPersistenceBridge:doUpdateByAssociations',
params: [assocs, { a: 1 }, false, 'APP_ID'],
});
});
it('remove forwards the id with the APP_ID sentinel', async () => {
const { rec, senderFn } = setup();
await new Persistence(senderFn).remove('id1');
assert.deepStrictEqual(rec.emitted()[0], { method: 'bridges:getPersistenceBridge:doRemove', params: ['id1', 'APP_ID'] });
});
it('removeByAssociations forwards the associations array as-is', async () => {
const { rec, senderFn } = setup();
const assocs = [{ type: 1, id: 'x' } as any, { type: 2, id: 'y' } as any];
await new Persistence(senderFn).removeByAssociations(assocs);
assert.deepStrictEqual(rec.emitted()[0], {
method: 'bridges:getPersistenceBridge:doRemoveByAssociations',
params: [assocs, 'APP_ID'],
});
});
});

View File

@@ -0,0 +1,197 @@
import * as assert from 'node:assert';
import { beforeEach, describe, it } from 'node:test';
import type { IMessage } from '@rocket.chat/apps-engine/definition/messages';
import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms';
import type { IUser } from '@rocket.chat/apps-engine/definition/users';
import { createRecordingSender } from './helpers/parityHarness';
import { AppObjectRegistry } from '../../../AppObjectRegistry';
import { AppAccessors } from '../mod';
/**
* Guards the *wiring* of `AppAccessors`, not the behavior of any single accessor.
*
* `AppAccessors` memoizes most sub-accessors in private fields and hands them out
* through `get*()` methods. Some consumers capture a sub-accessor **by value** at
* construction time - most notably `Reader`, which takes all fifteen of its readers
* as constructor arguments and returns them verbatim afterwards. That combination
* makes a construction-order hazard possible: if a memoized field is still
* `undefined` when `getReader()` runs, the `Reader` caches `undefined` forever and
* every app in the subprocess sees a missing accessor.
*
* That is exactly what happened to the notifier: `getReader()` was invoked from the
* `AppAccessors` constructor (to build `Http`) on the line *before*
* `this.notifier` was assigned, so `read.getNotifier()` returned `undefined` and
* apps calling `read.getNotifier().notifyUser(...)` got
* "Cannot read properties of undefined (reading 'notifyUser')". `modify.getNotifier()`
* was unaffected because it resolves lazily through an arrow.
*
* `packages/apps/tsconfig.json` sets `strict: false`, so `strictNullChecks` will not
* catch a `T | undefined` flowing into a parameter typed `T`. These tests stand in
* for that missing compile-time guard.
*
* The sweep below is reflective on purpose: a sub-accessor added to any container in
* the future is covered without touching this file.
*/
/** Zero-argument `get*()` methods reachable on `target`, including inherited ones. */
function zeroArgGetters(target: object): string[] {
const names = new Set<string>();
for (let o: object | null = target; o && o !== Object.prototype; o = Object.getPrototypeOf(o)) {
for (const name of Object.getOwnPropertyNames(o)) {
if (!name.startsWith('get')) {
continue;
}
// Read off the descriptor rather than the instance so a real `get x()`
// accessor property is not invoked just by looking at it.
const descriptor = Object.getOwnPropertyDescriptor(o, name);
if (typeof descriptor?.value !== 'function' || descriptor.value.length !== 0) {
continue;
}
names.add(name);
}
}
return [...names].sort();
}
describe('AppAccessors surface integrity', () => {
beforeEach(() => {
// `mod.ts` seeds this at import time and `getDefaultAppAccessors()` captures the
// array *reference*, so anything that cleared the registry earlier would leave a
// legitimately-undefined `providedApiEndpoints` behind and muddy the sweep below.
if (!AppObjectRegistry.has('apiEndpoints')) {
AppObjectRegistry.set('apiEndpoints', []);
}
});
// A factory rather than a shared instance: every construction-order hazard needs a
// freshly built `AppAccessors` to show up at all.
const makeAccessors = () => new AppAccessors(createRecordingSender().sender);
describe('no accessor resolves to undefined', () => {
// Containers are the objects that *hand out* other accessors, which is where the
// capture-by-value hazard lives. Leaf accessors (ModifyCreator, RoomRead, ...) are
// covered by their own suites.
const containers: Array<[string, (a: AppAccessors) => object]> = [
['reader', (a) => a.getReader()],
['modifier', (a) => a.getModifier()],
['environmentRead', (a) => a.getEnvironmentRead()],
['environmentWrite', (a) => a.getEnvironmentWrite()],
['reader.getEnvironmentReader()', (a) => a.getReader().getEnvironmentReader()],
['defaultAppAccessors', (a) => a.getDefaultAppAccessors()],
];
for (const [label, resolve] of containers) {
it(`${label} exposes only defined accessors`, () => {
const container = resolve(makeAccessors());
assert.notStrictEqual(container, undefined, `${label} itself is undefined`);
const getters = zeroArgGetters(container);
// A container with no getters means the reflection missed its shape (e.g. the
// object was replaced by a plain map), which would silently pass every
// assertion below. `defaultAppAccessors` is a plain data bag by design.
if (label !== 'defaultAppAccessors') {
assert.ok(getters.length > 0, `found no zero-arg get*() methods on ${label}`);
}
for (const name of getters) {
const resolved = (container as Record<string, () => unknown>)[name]();
assert.notStrictEqual(resolved, undefined, `${label}.${name}() returned undefined`);
assert.notStrictEqual(resolved, null, `${label}.${name}() returned null`);
}
// Plain-object containers expose accessors as properties, not methods.
for (const [key, value] of Object.entries(container)) {
assert.notStrictEqual(value, undefined, `${label}.${key} is undefined`);
}
});
}
for (const [label, resolve] of [
['http', (a: AppAccessors) => a.getHttp()],
['persistence', (a: AppAccessors) => a.getPersistence()],
['configurationExtend', (a: AppAccessors) => a.getConfigurationExtend()],
['configurationModify', (a: AppAccessors) => a.getConfigurationModify()],
] as const) {
it(`${label} is defined and fully populated`, () => {
const resolved = resolve(makeAccessors()) as object;
assert.notStrictEqual(resolved, undefined, `${label} is undefined`);
for (const [key, value] of Object.entries(resolved)) {
assert.notStrictEqual(value, undefined, `${label}.${key} is undefined`);
}
});
}
});
describe('accessors reachable by more than one path agree', () => {
// The notifier is reachable through both IRead and IModify. A single shared
// instance is the invariant: two divergent instances would let this bug recur in
// the "defined, but the wrong object" form that a definedness check cannot see.
it('resolves the same notifier through read and modify', () => {
const accessors = makeAccessors();
assert.strictEqual(accessors.getReader().getNotifier(), accessors.getModifier().getNotifier());
});
it('resolves the same notifier regardless of which path is touched first', () => {
const modifyFirst = makeAccessors();
const viaModify = modifyFirst.getModifier().getNotifier();
assert.strictEqual(modifyFirst.getReader().getNotifier(), viaModify);
const readFirst = makeAccessors();
const viaRead = readFirst.getReader().getNotifier();
assert.strictEqual(readFirst.getModifier().getNotifier(), viaRead);
});
it('resolves a settings reader through both IRead and IEnvironmentRead', () => {
const accessors = makeAccessors();
assert.notStrictEqual(accessors.getReader().getEnvironmentReader().getSettings(), undefined);
assert.notStrictEqual(accessors.getEnvironmentRead().getSettings(), undefined);
});
});
// Pins the exact call the app author reported, at the level the app sees it: a
// slashcommand executor handed a fresh `IRead` and calling straight through.
describe('regression: read.getNotifier().notifyUser()', () => {
const user = { id: 'user-id', username: 'user' } as IUser;
const room = { id: 'room-id' } as IRoom;
const message = { room, text: 'hello', sender: user } as IMessage;
it('does not throw when reached through IRead', async () => {
const recording = createRecordingSender();
const read = new AppAccessors(recording.sender).getReader();
await read.getNotifier().notifyUser(user, message);
assert.deepStrictEqual(recording.emitted(), [
{
method: 'bridges:getMessageBridge:doNotifyUser',
params: [user, message, 'APP_ID'],
},
]);
});
it('emits the same traffic through IRead and IModify', async () => {
const viaRead = createRecordingSender();
await new AppAccessors(viaRead.sender).getReader().getNotifier().notifyUser(user, message);
const viaModify = createRecordingSender();
await new AppAccessors(viaModify.sender).getModifier().getNotifier().notifyUser(user, message);
assert.deepStrictEqual(viaRead.emitted(), viaModify.emitted());
});
});
});

View File

@@ -0,0 +1,123 @@
import * as assert from 'node:assert';
import { after, beforeEach, describe, it, mock } from 'node:test';
import type { IApi } from '@rocket.chat/apps-engine/definition/api';
import { AppObjectRegistry } from '../../../AppObjectRegistry';
import { AppAccessors } from '../mod';
// The senderFn echoes the request as the SuccessObject `result`, so a bridge call resolves to the
// `{ method, params }` descriptor and the recorded spy shows exactly what was sent.
const senderFn = (r: any) =>
Promise.resolve({
id: 'test',
jsonrpc: '2.0',
result: r,
serialize() {
return JSON.stringify(this);
},
});
describe('ConfigurationExtend/Modify + app settings (base-runtime, via AppResourceBridge)', () => {
beforeEach(() => {
AppObjectRegistry.clear();
AppObjectRegistry.set('id', 'deno-test');
if (!AppObjectRegistry.has('apiEndpoints')) {
AppObjectRegistry.set('apiEndpoints', []);
}
});
after(() => {
AppObjectRegistry.clear();
});
it('provideSlashCommand stashes the instance and calls doProvideSlashCommand', async () => {
const spy = mock.fn(senderFn);
const configExtend = new AppAccessors(spy).getConfigurationExtend();
const command = { command: 'my-cmd', executor() {} } as any;
await configExtend.slashCommands.provideSlashCommand(command);
assert.strictEqual(AppObjectRegistry.get('slashcommand:my-cmd'), command);
assert.strictEqual((spy.mock.calls[0].arguments[0] as any).method, 'bridges:getAppResourceBridge:doProvideSlashCommand');
assert.strictEqual((spy.mock.calls[0].arguments[0] as any).params[1], 'APP_ID');
});
it('registerProcessors stashes each processor and calls doRegisterProcessors', async () => {
const spy = mock.fn(senderFn);
const configExtend = new AppAccessors(spy).getConfigurationExtend();
await configExtend.scheduler.registerProcessors([{ id: 'p1' } as any, { id: 'p2' } as any]);
assert.ok(AppObjectRegistry.get('scheduler:p1'));
assert.ok(AppObjectRegistry.get('scheduler:p2'));
assert.deepStrictEqual(spy.mock.calls[0].arguments, [
{ method: 'bridges:getAppResourceBridge:doRegisterProcessors', params: [[{ id: 'p1' }, { id: 'p2' }], 'APP_ID'] },
]);
});
it('provideApi stashes endpoints, calls doProvideApi, and then doListApis', async () => {
const spy = mock.fn(senderFn);
const configExtend = new AppAccessors(spy).getConfigurationExtend();
const api = { endpoints: [{ path: 'hello', get() {} }] } as unknown as IApi;
await configExtend.api.provideApi(api);
assert.ok(AppObjectRegistry.get('api:hello'));
assert.strictEqual((spy.mock.calls[0].arguments[0] as any).method, 'bridges:getAppResourceBridge:doProvideApi');
// _availableMethods is computed locally before sending
assert.deepStrictEqual((spy.mock.calls[0].arguments[0] as any).params[0].endpoints[0]._availableMethods, ['get']);
assert.strictEqual((spy.mock.calls[1].arguments[0] as any).method, 'bridges:getAppResourceBridge:doListApis');
});
it('registerButton / provideSetting / externalComponents route to their bridge methods', async () => {
const spy = mock.fn(senderFn);
const configExtend = new AppAccessors(spy).getConfigurationExtend();
await configExtend.ui.registerButton({ actionId: 'b1' } as any);
await configExtend.settings.provideSetting({ id: 's1' } as any);
await configExtend.externalComponents.register({ name: 'c1' } as any);
assert.deepStrictEqual(
spy.mock.calls.map((c) => (c.arguments[0] as any).method),
[
'bridges:getAppResourceBridge:doRegisterActionButton',
'bridges:getAppResourceBridge:doProvideSetting',
'bridges:getAppResourceBridge:doRegisterExternalComponent',
],
);
});
it('ConfigurationModify.slashCommands maps to modify/enable/disable bridge methods', async () => {
const spy = mock.fn(senderFn);
const configModify = new AppAccessors(spy).getConfigurationModify();
await configModify.slashCommands.modifySlashCommand({ command: 'c', executor() {} } as any);
await configModify.slashCommands.enableSlashCommand('c');
await configModify.slashCommands.disableSlashCommand('c');
assert.deepStrictEqual(
spy.mock.calls.map((c) => (c.arguments[0] as any).method),
[
'bridges:getAppResourceBridge:doModifySlashCommand',
'bridges:getAppResourceBridge:doEnableSlashCommand',
'bridges:getAppResourceBridge:doDisableSlashCommand',
],
);
});
it('app-settings read/update go through the AppResourceBridge', async () => {
const spy = mock.fn(senderFn);
const accessors = new AppAccessors(spy);
accessors.getEnvironmentRead().getSettings().getById('s1');
await accessors.getEnvironmentWrite().getSettings().updateValue('s1', 'v');
assert.deepStrictEqual(spy.mock.calls[0].arguments, [
{ method: 'bridges:getAppResourceBridge:doGetSettingById', params: ['s1', 'APP_ID'] },
]);
assert.deepStrictEqual(spy.mock.calls[1].arguments, [
{ method: 'bridges:getAppResourceBridge:doUpdateSettingValue', params: ['s1', 'v', 'APP_ID'] },
]);
});
});

View File

@@ -0,0 +1,211 @@
import * as assert from 'node:assert';
import { describe, it } from 'node:test';
import * as jsonrpc from 'jsonrpc-lite';
import { formatErrorResponse } from '../formatResponseErrorHandler';
describe('formatErrorResponse', () => {
describe('JSON-RPC ErrorObject handling', () => {
it('formats ErrorObject instances correctly', () => {
const errorObject = jsonrpc.error('test-id', new jsonrpc.JsonRpcError('Test error message', 1000));
const result = formatErrorResponse(errorObject);
assert.ok(result instanceof Error, `Expected instance of Error`);
assert.deepStrictEqual(result.message, 'Test error message');
});
it('formats objects with error.message structure', () => {
const errorLikeObject = {
error: {
message: 'Custom error message',
code: 404,
},
};
const result = formatErrorResponse(errorLikeObject);
assert.ok(result instanceof Error, `Expected instance of Error`);
assert.deepStrictEqual(result.message, 'Custom error message');
});
it('handles nested error objects with complex structure', () => {
const complexError = {
error: {
message: 'Database connection failed',
details: {
host: 'localhost',
port: 5432,
},
},
id: 'req-123',
};
const result = formatErrorResponse(complexError);
assert.ok(result instanceof Error, `Expected instance of Error`);
assert.deepStrictEqual(result.message, 'Database connection failed');
});
it('handles error objects with empty message', () => {
const emptyMessageError = {
error: {
message: '',
code: 500,
},
};
const result = formatErrorResponse(emptyMessageError);
assert.ok(result instanceof Error, `Expected instance of Error`);
assert.deepStrictEqual(result.message, '');
});
});
describe('Error instance passthrough', () => {
it('returns existing Error instances unchanged', () => {
const originalError = new Error('Original error message');
const result = formatErrorResponse(originalError);
assert.strictEqual(result, originalError);
assert.deepStrictEqual(result.message, 'Original error message');
});
it('returns custom Error subclasses unchanged', () => {
class CustomError extends Error {
constructor(
message: string,
public code: number,
) {
super(message);
this.name = 'CustomError';
}
}
const customError = new CustomError('Custom error', 404);
const result = formatErrorResponse(customError);
assert.strictEqual(result, customError);
assert.deepStrictEqual(result.message, 'Custom error');
assert.deepStrictEqual((result as CustomError).code, 404);
});
it('handles Error instances with additional properties', () => {
const errorWithProps = new Error('Error with props') as any;
errorWithProps.statusCode = 500;
errorWithProps.details = { reason: 'timeout' };
const result = formatErrorResponse(errorWithProps);
assert.strictEqual(result, errorWithProps);
assert.deepStrictEqual(result.message, 'Error with props');
assert.deepStrictEqual((result as any).statusCode, 500);
});
});
describe('Unknown error handling', () => {
it('wraps string errors with default message and cause', () => {
const stringError = 'Simple string error';
const result = formatErrorResponse(stringError);
assert.ok(result instanceof Error, `Expected instance of Error`);
assert.deepStrictEqual(result.message, 'An unknown error occurred');
assert.deepStrictEqual(result.cause, stringError);
});
it('wraps number errors with default message and cause', () => {
const numberError = 404;
const result = formatErrorResponse(numberError);
assert.ok(result instanceof Error, `Expected instance of Error`);
assert.deepStrictEqual(result.message, 'An unknown error occurred');
assert.deepStrictEqual(result.cause, numberError);
});
it('wraps boolean errors with default message and cause', () => {
const booleanError = false;
const result = formatErrorResponse(booleanError);
assert.ok(result instanceof Error, `Expected instance of Error`);
assert.deepStrictEqual(result.message, 'An unknown error occurred');
assert.deepStrictEqual(result.cause, booleanError);
});
it('wraps null with default message and cause', () => {
const result = formatErrorResponse(null);
assert.ok(result instanceof Error, `Expected instance of Error`);
assert.deepStrictEqual(result.message, 'An unknown error occurred');
assert.deepStrictEqual(result.cause, null);
});
it('wraps undefined with default message and cause', () => {
const result = formatErrorResponse(undefined);
assert.ok(result instanceof Error, `Expected instance of Error`);
assert.deepStrictEqual(result.message, 'An unknown error occurred');
assert.deepStrictEqual(result.cause, undefined);
});
it('wraps arrays with default message and cause', () => {
const arrayError = ['error', 'details'];
const result = formatErrorResponse(arrayError);
assert.ok(result instanceof Error, `Expected instance of Error`);
assert.deepStrictEqual(result.message, 'An unknown error occurred');
assert.deepStrictEqual(result.cause, arrayError);
});
it('wraps functions with default message and cause', () => {
const functionError = () => 'error';
const result = formatErrorResponse(functionError);
assert.ok(result instanceof Error, `Expected instance of Error`);
assert.deepStrictEqual(result.message, 'An unknown error occurred');
assert.deepStrictEqual(result.cause, functionError);
});
it('wraps plain objects without error.message with default message and cause', () => {
const plainObject = {
status: 'failed',
reason: 'timeout',
data: { id: 123 },
};
const result = formatErrorResponse(plainObject);
assert.ok(result instanceof Error, `Expected instance of Error`);
assert.deepStrictEqual(result.message, 'An unknown error occurred');
assert.deepStrictEqual(result.cause, plainObject);
});
it('wraps objects with error property but no message with default message and cause', () => {
const errorObjectNoMessage = {
error: {
code: 500,
details: 'Internal server error',
},
};
const result = formatErrorResponse(errorObjectNoMessage);
assert.ok(result instanceof Error, `Expected instance of Error`);
assert.deepStrictEqual(result.message, 'An unknown error occurred');
assert.deepStrictEqual(result.cause, errorObjectNoMessage);
});
});
it('ensures all returned values are proper Error instances', () => {
const testCases = ['string error', 123, null, undefined, { error: { message: 'test' } }, new Error('test'), { plain: 'object' }];
for (const testCase of testCases) {
const result = formatErrorResponse(testCase);
assert.ok(result instanceof Error, `Failed for input: ${JSON.stringify(testCase)}`);
}
});
it('prevents "[object Object]" error messages for plain objects', () => {
const plainObject = { status: 'error', code: 500 };
const result = formatErrorResponse(plainObject);
assert.ok(result instanceof Error, `Expected instance of Error`);
assert.deepStrictEqual(result.message, 'An unknown error occurred');
// Ensure the message is not "[object Object]"
assert.deepStrictEqual((result.message as string) !== '[object Object]', true);
});
});

View File

@@ -0,0 +1,77 @@
import * as assert from 'node:assert';
import { after, beforeEach, describe, it } from 'node:test';
import { createRecordingSender } from './parityHarness';
import { AppObjectRegistry } from '../../../../AppObjectRegistry';
import { ModifyExtender } from '../../modify/ModifyExtender';
describe('parityHarness', () => {
beforeEach(() => {
AppObjectRegistry.clear();
AppObjectRegistry.set('id', 'deno-test');
});
after(() => {
AppObjectRegistry.clear();
});
it('records every emitted request in order, with method and params', async () => {
const { sender, emitted } = createRecordingSender();
await sender({ method: 'bridges:getMessageBridge:doGetById', params: ['a', 'APP_ID'] });
await sender({ method: 'bridges:getRoomBridge:doUpdate', params: [{ id: 'r' }, [], 'APP_ID'] });
assert.deepStrictEqual(emitted(), [
{ method: 'bridges:getMessageBridge:doGetById', params: ['a', 'APP_ID'] },
{ method: 'bridges:getRoomBridge:doUpdate', params: [{ id: 'r' }, [], 'APP_ID'] },
]);
});
it('resolves canned responses by method (value or function) and defaults to null', async () => {
const { sender } = createRecordingSender({
'bridges:getMessageBridge:doGetById': { id: 'msg-1', text: 'hi' },
'bridges:getMessageBridge:doCreate': (call) => (call.params[0] as { text: string }).text,
});
const got = await sender({ method: 'bridges:getMessageBridge:doGetById', params: ['msg-1', 'APP_ID'] });
assert.deepStrictEqual(got.result, { id: 'msg-1', text: 'hi' });
const created = await sender({ method: 'bridges:getMessageBridge:doCreate', params: [{ text: 'echoed' }, 'APP_ID'] });
assert.strictEqual(created.result, 'echoed');
const uncanned = await sender({ method: 'bridges:getRoomBridge:doGetById', params: ['r', 'APP_ID'] });
assert.strictEqual(uncanned.result, null);
});
it('pins the host-bound traffic a MOVE-style accessor emits (worked example: ModifyExtender.extendMessage + finish)', async () => {
const { sender, emitted } = createRecordingSender({
'bridges:getMessageBridge:doGetById': { id: 'message-id', text: 'original' },
});
const extender = new ModifyExtender(sender);
const messageExtender = await extender.extendMessage('message-id', { id: 'user-id' } as any);
messageExtender.addCustomField('key', 'value');
await extender.finish(messageExtender);
// This is the parity assertion a Phase 1-2 port must keep satisfying: the
// exact ordered bridge traffic *and* its params, normalized to the APP_ID
// sentinel.
const calls = emitted();
assert.deepStrictEqual(
calls.map((c) => c.method),
['bridges:getMessageBridge:doGetById', 'bridges:getMessageBridge:doUpdate'],
);
// extendMessage looks the message up by id; the caller identity is the APP_ID sentinel.
assert.deepStrictEqual(calls[0].params, ['message-id', 'APP_ID']);
// finish writes the mutated message back, again trailed by the APP_ID sentinel.
// (editedAt is a fresh Date, so we assert the fields the accessor is responsible for.)
const [updatedMessage, appId] = calls[1].params as [Record<string, unknown>, string];
assert.strictEqual(appId, 'APP_ID');
assert.strictEqual(updatedMessage.id, 'message-id');
assert.deepStrictEqual(updatedMessage.editor, { id: 'user-id' });
assert.deepStrictEqual(updatedMessage.customFields, { key: 'value' });
});
});

View File

@@ -0,0 +1,85 @@
import type * as Messenger from '../../../messenger';
/**
* Transitional parity harness for the accessor-consolidation migration.
*
* MOVE accessors (see docs/proposals/apps-accessor-consolidation/README.md) are *live in
* production today* via the host proxy path, so a subtly wrong port - a dropped
* default, an off-by-one on a cap, a renamed sort field - is an immediate
* observable regression. Ordinary ported unit tests only prove the new local
* class satisfies carried-over assertions; they do not prove it emits the same
* host-bound traffic the proxy path did.
*
* This harness makes the *emitted wire traffic* of a runtime accessor
* observable and assertable: every `accessor:*` / `bridges:*` request an
* accessor sends is recorded in order, with its params, so a Phase 1-2 port can
* be pinned against the exact message sequence documented for its host
* counterpart.
*
* Introduced as throwaway migration infrastructure, it is retained past the Phase
* 4/5 teardown: the Phase 1-3 accessor suites depend on it, so it is now a
* permanent test utility.
*/
export type RecordedCall = {
method: string;
params: unknown[];
};
/**
* Canned responses keyed by exact request method string. A value may be a
* plain value (wrapped as the SuccessObject `result`) or a function of the
* recorded call (so a response can echo params or vary by input).
*/
export type CannedResponses = Record<string, unknown | ((call: RecordedCall) => unknown)>;
export type RecordingSender = {
/** Drop-in replacement for `Messenger.sendRequest`. */
sender: typeof Messenger.sendRequest;
/** Every request the accessor sent, in order. */
calls: RecordedCall[];
/** Convenience: just the ordered `{ method, params }` pairs. */
emitted(): RecordedCall[];
/** Convenience: just the ordered method strings. */
methods(): string[];
};
const makeSuccessObject = (result: unknown) => ({
id: 'parity-harness',
jsonrpc: '2.0' as const,
result,
serialize() {
return JSON.stringify(this);
},
});
/**
* Build a recording `sendRequest`. Pass `responses` to control what specific
* methods resolve to (e.g. `doGetById` returning a fixture entity); any method
* without a canned response resolves to `null`, mirroring how the host serializes
* a `void`/`undefined` bridge return.
*/
export function createRecordingSender(responses: CannedResponses = {}): RecordingSender {
const calls: RecordedCall[] = [];
const sender = ((requestDescriptor: { method: string; params?: unknown }) => {
// Deep-snapshot the params at emit time (including nested objects/arrays) so a
// later accessor mutation of a shared object cannot rewrite a call we already
// recorded - the recorded sequence must reflect the wire traffic as it was sent.
const params = structuredClone(Array.isArray(requestDescriptor.params) ? requestDescriptor.params : []);
const call: RecordedCall = { method: requestDescriptor.method, params };
calls.push(call);
const canned = responses[requestDescriptor.method];
const result = typeof canned === 'function' ? (canned as (c: RecordedCall) => unknown)(call) : (canned ?? null);
return Promise.resolve(makeSuccessObject(result));
}) as typeof Messenger.sendRequest;
return {
sender,
calls,
emitted: () => calls,
methods: () => calls.map((c) => c.method),
};
}

View File

@@ -0,0 +1,156 @@
import * as assert from 'node:assert';
import { beforeEach, describe, it, after, mock } from 'node:test';
import { AppObjectRegistry } from '../../../AppObjectRegistry';
import { Http } from '../http';
describe('Http accessor error handling integration', () => {
let http: Http;
beforeEach(() => {
AppObjectRegistry.clear();
AppObjectRegistry.set('id', 'test-app-id');
const mockHttpExtend = {
getDefaultHeaders: () => new Map(),
getDefaultParams: () => new Map(),
getPreRequestHandlers: () => [],
getPreResponseHandlers: () => [],
};
const mockRead = {};
const mockPersistence = {};
http = new Http(mockRead as any, mockPersistence as any, mockHttpExtend as any, () => Promise.resolve({}) as any);
});
after(() => {
AppObjectRegistry.clear();
});
describe('HTTP method error handling', () => {
it('formats JSON-RPC errors correctly for GET requests', async () => {
const _stub = mock.method(http, 'senderFn' as any, () =>
Promise.reject({
error: {
message: 'HTTP GET request failed',
code: 404,
},
}),
);
await assert.rejects(() => http.get('https://api.example.com/data'), { message: 'HTTP GET request failed' });
_stub.mock.restore();
});
it('formats JSON-RPC errors correctly for POST requests', async () => {
const _stub = mock.method(http, 'senderFn' as any, () =>
Promise.reject({
error: {
message: 'HTTP POST request validation failed',
code: 400,
},
}),
);
await assert.rejects(() => http.post('https://api.example.com/create', { data: { name: 'test' } }), {
message: 'HTTP POST request validation failed',
});
_stub.mock.restore();
});
it('formats JSON-RPC errors correctly for PUT requests', async () => {
const _stub = mock.method(http, 'senderFn' as any, () =>
Promise.reject({
error: {
message: 'HTTP PUT request unauthorized',
code: 401,
},
}),
);
await assert.rejects(() => http.put('https://api.example.com/update/123', { data: { name: 'updated' } }), {
message: 'HTTP PUT request unauthorized',
});
_stub.mock.restore();
});
it('formats JSON-RPC errors correctly for DELETE requests', async () => {
const _stub = mock.method(http, 'senderFn' as any, () =>
Promise.reject({
error: {
message: 'HTTP DELETE request forbidden',
code: 403,
},
}),
);
await assert.rejects(() => http.del('https://api.example.com/delete/123'), { message: 'HTTP DELETE request forbidden' });
_stub.mock.restore();
});
it('formats JSON-RPC errors correctly for PATCH requests', async () => {
const _stub = mock.method(http, 'senderFn' as any, () =>
Promise.reject({
error: {
message: 'HTTP PATCH request conflict',
code: 409,
},
}),
);
await assert.rejects(() => http.patch('https://api.example.com/patch/123', { data: { status: 'active' } }), {
message: 'HTTP PATCH request conflict',
});
_stub.mock.restore();
});
});
describe('Error instance passthrough', () => {
it('passes through existing Error instances unchanged for HTTP requests', async () => {
const originalError = new Error('Network timeout error');
const _stub = mock.method(http, 'senderFn' as any, () => Promise.reject(originalError));
await assert.rejects(() => http.get('https://api.example.com/data'), { message: 'Network timeout error' });
_stub.mock.restore();
});
});
describe('Unknown error handling', () => {
it('wraps unknown object errors with default message for HTTP requests', async () => {
const unknownError = {
status: 'failed',
details: 'Something went wrong',
timestamp: Date.now(),
};
const _stub = mock.method(http, 'senderFn' as any, () => Promise.reject(unknownError));
await assert.rejects(() => http.get('https://api.example.com/data'), { message: 'An unknown error occurred' });
_stub.mock.restore();
});
it('wraps string errors with default message for HTTP requests', async () => {
const stringError = 'Connection refused';
const _stub = mock.method(http, 'senderFn' as any, () => Promise.reject(stringError));
await assert.rejects(() => http.get('https://api.example.com/data'), { message: 'An unknown error occurred' });
_stub.mock.restore();
});
it('wraps null/undefined errors with default message for HTTP requests', async () => {
const _stub = mock.method(http, 'senderFn' as any, () => Promise.reject(null));
await assert.rejects(() => http.get('https://api.example.com/data'), { message: 'An unknown error occurred' });
_stub.mock.restore();
});
});
});

View File

@@ -0,0 +1,69 @@
import type { Node, AnyNode } from 'acorn';
import { parse } from 'acorn';
import { fullAncestor } from 'acorn-walk';
import { generate } from 'astring';
import * as operations from './operations';
import type { WalkerState } from './operations';
function fixAst(ast: Node): boolean {
const pendingOperations = [
operations.fixLivechatIsOnlineCalls,
operations.checkReassignmentOfModifiedIdentifiers,
operations.fixRoomUsernamesCalls,
];
// Have we touched the tree?
let isModified = false;
while (pendingOperations.length) {
const ops = pendingOperations.splice(0);
const state: WalkerState = {
isModified: false,
functionIdentifiers: new Set<string>(),
};
fullAncestor(
ast,
(node, state, ancestors, type) => {
ops.forEach((operation) => operation(node as AnyNode, state as WalkerState, ancestors as AnyNode[], type));
},
undefined,
state,
);
if (state.isModified) {
isModified = true;
}
if (state.functionIdentifiers.size) {
pendingOperations.push(
operations.buildFixModifiedFunctionsOperation(state.functionIdentifiers),
operations.checkReassignmentOfModifiedIdentifiers,
);
}
}
return isModified;
}
export function fixBrokenSynchronousAPICalls(appSource: string): string {
const astRootNode = parse(appSource, {
// Latest ecma version supported by this version of acorn.
ecmaVersion: 'latest',
// Allow everything, we don't want to complain if code is badly written
// Also, since the code itself has been transpiled, the chance of getting
// shenanigans is lower
allowReserved: true,
allowReturnOutsideFunction: true,
allowImportExportEverywhere: true,
allowAwaitOutsideFunction: true,
allowSuperOutsideMethod: true,
});
if (fixAst(astRootNode)) {
return generate(astRootNode);
}
return appSource;
}

View File

@@ -0,0 +1,237 @@
import type { AnyNode, AssignmentExpression, Function, AwaitExpression, Expression, Identifier, MethodDefinition, Property } from 'acorn';
import type { FullAncestorWalkerCallback } from 'acorn-walk';
export type WalkerState = {
isModified: boolean;
functionIdentifiers: Set<string>;
};
export function getFunctionIdentifier(ancestors: AnyNode[], functionNodeIndex: number) {
const parent = ancestors[functionNodeIndex - 1];
// If there is a parent node and it's not a computed property, we can try to
// extract an identifier for our function from it. This needs to be done first
// because when functions are assigned to named symbols, this will be the only
// way to call it, even if the function itself has an identifier
// Consider the following block:
//
// const foo = function bar() {}
//
// Even though the function itself has a name, the only way to call it in the
// program is wiht `foo()`
if (parent && !(parent as Property | MethodDefinition).computed) {
// Several node types can have an id prop of type Identifier
const { id } = parent as unknown as { id?: Identifier };
if (id?.type === 'Identifier') {
return id.name;
}
// Usually assignments to object properties (MethodDefinition, Property)
const { key } = parent as MethodDefinition | Property;
if (key?.type === 'Identifier') {
return key.name;
}
// Variable assignments have left hand side that can be used as Identifier
const { left } = parent as AssignmentExpression;
// Simple assignment: `const fn = () => {}`
if (left?.type === 'Identifier') {
return left.name;
}
// Object property assignment: `obj.fn = () => {}`
if (left?.type === 'MemberExpression' && !left.computed) {
return (left.property as Identifier).name;
}
}
// nodeIndex needs to be the index of a Function node (either FunctionDeclaration or FunctionExpression)
const currentNode = ancestors[functionNodeIndex] as Function;
// Function declarations or expressions can be directly named
if (currentNode.id?.type === 'Identifier') {
return currentNode.id.name;
}
}
export function wrapWithAwait(node: Expression) {
if (!node.type.endsWith('Expression')) {
throw new Error(`Can't wrap "${node.type}" with await`);
}
const innerNode: Expression = { ...node };
node.type = 'AwaitExpression';
// starting here node has become an AwaitExpression
(node as AwaitExpression).argument = innerNode;
Object.keys(node).forEach((key) => !['type', 'argument'].includes(key) && delete node[key as keyof AnyNode]);
}
export function asyncifyScope(ancestors: AnyNode[], state: WalkerState) {
const functionNodeIndex = ancestors.findLastIndex((n) => 'async' in n);
if (functionNodeIndex === -1) return;
// At this point this is a node with an "async" property, so it has to be
// of type Function - let TS know about that
const functionScopeNode = ancestors[functionNodeIndex] as Function;
if (functionScopeNode.async) {
return;
}
functionScopeNode.async = true;
// If the parent of a function node is a call expression, we're talking about an IIFE
// Should we care about this case as well?
// const parentNode = ancestors[functionScopeIndex-1];
// if (parentNode?.type === 'CallExpression' && ancestors[functionScopeIndex-2] && ancestors[functionScopeIndex-2].type !== 'AwaitExpression') {
// pendingOperations.push(buildFunctionPredicate(getFunctionIdentifier(ancestors, functionScopeIndex-2)));
// }
const identifier = getFunctionIdentifier(ancestors, functionNodeIndex);
// We can't fix calls of functions which name we can't determine at compile time
if (!identifier) return;
state.functionIdentifiers.add(identifier);
}
export function buildFixModifiedFunctionsOperation(functionIdentifiers: Set<string>): FullAncestorWalkerCallback<WalkerState> {
return function _fixModifiedFunctionsOperation(node, state, ancestors) {
if (node.type !== 'CallExpression') return;
// This node is a simple call to a function, like `fn()`
let isWrappable = node.callee.type === 'Identifier' && functionIdentifiers.has(node.callee.name);
// This node is a call to an object property or instance method, like `obj.fn()`, but not computed like `obj[fn]()`
isWrappable ||=
node.callee.type === 'MemberExpression' &&
!node.callee.computed &&
node.callee.property?.type === 'Identifier' &&
functionIdentifiers.has(node.callee.property.name);
// This is a weird dereferencing technique used by bundlers, and since we'll be dealing with bundled sources we have to check for it
// e.g. `r=(0,fn)(e)`
if (!isWrappable && node.callee.type === 'SequenceExpression') {
const [, secondExpression] = node.callee.expressions;
isWrappable = secondExpression?.type === 'Identifier' && functionIdentifiers.has(secondExpression.name);
isWrappable ||=
secondExpression?.type === 'MemberExpression' &&
!secondExpression.computed &&
secondExpression.property.type === 'Identifier' &&
functionIdentifiers.has(secondExpression.property.name);
}
if (!isWrappable) return;
// ancestors[ancestors.length-1] === node, so here we're checking for parent node
const parentNode = ancestors[ancestors.length - 2];
if (!parentNode || parentNode.type === 'AwaitExpression') return;
wrapWithAwait(node);
asyncifyScope(ancestors, state);
state.isModified = true;
};
}
export const checkReassignmentOfModifiedIdentifiers: FullAncestorWalkerCallback<WalkerState> = (
node,
{ functionIdentifiers },
_ancestors,
) => {
if (node.type === 'AssignmentExpression') {
if (node.operator !== '=') return;
let identifier = '';
if (node.left.type === 'Identifier') identifier = node.left.name;
if (node.left.type === 'MemberExpression' && !node.left.computed) {
identifier = (node.left.property as Identifier).name;
}
if (!identifier || node.right.type !== 'Identifier' || !functionIdentifiers.has(node.right.name)) return;
functionIdentifiers.add(identifier);
return;
}
if (node.type === 'VariableDeclarator') {
if (node.id.type !== 'Identifier' || functionIdentifiers.has(node.id.name)) return;
if (node.init?.type !== 'Identifier' || !functionIdentifiers.has(node.init?.name)) return;
functionIdentifiers.add(node.id.name);
return;
}
// "Property" is for plain objects, "PropertyDefinition" is for classes
// but both share the same structure
if (node.type === 'Property' || node.type === 'PropertyDefinition') {
if (node.key.type !== 'Identifier' || functionIdentifiers.has(node.key.name)) return;
if (node.value?.type !== 'Identifier' || !functionIdentifiers.has(node.value.name)) return;
functionIdentifiers.add(node.key.name);
}
};
export const fixLivechatIsOnlineCalls: FullAncestorWalkerCallback<WalkerState> = (node, state, ancestors) => {
if (node.type !== 'MemberExpression' || node.computed) return;
if ((node.property as Identifier).name !== 'isOnline') return;
if (node.object.type !== 'CallExpression') return;
if (node.object.callee.type !== 'MemberExpression') return;
if ((node.object.callee.property as Identifier).name !== 'getLivechatReader') return;
let parentIndex = ancestors.length - 2;
let targetNode = ancestors[parentIndex];
if (targetNode.type !== 'CallExpression') {
targetNode = node;
} else {
parentIndex--;
}
// If we're already wrapped with an await, nothing to do
if (ancestors[parentIndex].type === 'AwaitExpression') return;
// If we're in the middle of a chained member access, we can't wrap with await
if (ancestors[parentIndex].type === 'MemberExpression') return;
wrapWithAwait(targetNode);
asyncifyScope(ancestors, state);
state.isModified = true;
};
export const fixRoomUsernamesCalls: FullAncestorWalkerCallback<WalkerState> = (node, state, ancestors) => {
if (node.type !== 'MemberExpression' || node.computed) return;
if ((node.property as Identifier).name !== 'usernames') return;
let parentIndex = ancestors.length - 2;
let targetNode = ancestors[parentIndex];
if (targetNode.type !== 'CallExpression') {
targetNode = node;
} else {
parentIndex--;
}
// If we're already wrapped with an await, nothing to do
if (ancestors[parentIndex].type === 'AwaitExpression') return;
wrapWithAwait(targetNode);
asyncifyScope(ancestors, state);
state.isModified = true;
};

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