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
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:
5
packages/apps/.gitignore
vendored
Normal file
5
packages/apps/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
.deno-cache/
|
||||
/tests/test-data/dbs
|
||||
|
||||
# File generated at runtime
|
||||
deno.runtime.jsonc
|
||||
2350
packages/apps/CHANGELOG.md
Normal file
2350
packages/apps/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
25
packages/apps/base-runtime/src/AppObjectRegistry.ts
Normal file
25
packages/apps/base-runtime/src/AppObjectRegistry.ts
Normal 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 = {};
|
||||
}
|
||||
})();
|
||||
51
packages/apps/base-runtime/src/handlers/api-handler.ts
Normal file
51
packages/apps/base-runtime/src/handlers/api-handler.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
130
packages/apps/base-runtime/src/handlers/app/construct.ts
Normal file
130
packages/apps/base-runtime/src/handlers/app/construct.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
}
|
||||
116
packages/apps/base-runtime/src/handlers/app/handler.ts
Normal file
116
packages/apps/base-runtime/src/handlers/app/handler.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
51
packages/apps/base-runtime/src/handlers/lib/assertions.ts
Normal file
51
packages/apps/base-runtime/src/handlers/lib/assertions.ts
Normal 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 });
|
||||
}
|
||||
153
packages/apps/base-runtime/src/handlers/listener/handler.ts
Normal file
153
packages/apps/base-runtime/src/handlers/listener/handler.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
66
packages/apps/base-runtime/src/handlers/scheduler-handler.ts
Normal file
66
packages/apps/base-runtime/src/handlers/scheduler-handler.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
123
packages/apps/base-runtime/src/handlers/slashcommand-handler.ts
Normal file
123
packages/apps/base-runtime/src/handlers/slashcommand-handler.ts
Normal 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(),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
29
packages/apps/base-runtime/src/handlers/tests/helpers/mod.ts
Normal file
29
packages/apps/base-runtime/src/handlers/tests/helpers/mod.ts
Normal 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;
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
101
packages/apps/base-runtime/src/handlers/uikit/handler.ts
Normal file
101
packages/apps/base-runtime/src/handlers/uikit/handler.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
43
packages/apps/base-runtime/src/lib/UIHelper.ts
Normal file
43
packages/apps/base-runtime/src/lib/UIHelper.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
53
packages/apps/base-runtime/src/lib/accessors/Persistence.ts
Normal file
53
packages/apps/base-runtime/src/lib/accessors/Persistence.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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') ?? ''));
|
||||
}
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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'] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
};
|
||||
90
packages/apps/base-runtime/src/lib/accessors/http.ts
Normal file
90
packages/apps/base-runtime/src/lib/accessors/http.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
374
packages/apps/base-runtime/src/lib/accessors/mod.ts
Normal file
374
packages/apps/base-runtime/src/lib/accessors/mod.ts
Normal 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);
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
61
packages/apps/base-runtime/src/lib/accessors/notifier.ts
Normal file
61
packages/apps/base-runtime/src/lib/accessors/notifier.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
98
packages/apps/base-runtime/src/lib/accessors/read/Reader.ts
Normal file
98
packages/apps/base-runtime/src/lib/accessors/read/Reader.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
115
packages/apps/base-runtime/src/lib/accessors/read/RoomRead.ts
Normal file
115
packages/apps/base-runtime/src/lib/accessors/read/RoomRead.ts
Normal 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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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'] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
156
packages/apps/base-runtime/src/lib/accessors/tests/http.test.ts
Normal file
156
packages/apps/base-runtime/src/lib/accessors/tests/http.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user