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:
25
packages/account-utils/CHANGELOG.md
Normal file
25
packages/account-utils/CHANGELOG.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# @rocket.chat/account-utils
|
||||
|
||||
## 0.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration
|
||||
|
||||
## 0.0.3-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration
|
||||
|
||||
## 0.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#31138](https://github.com/RocketChat/Rocket.Chat/pull/31138)) feat(uikit): Move `@rocket.chat/ui-kit` package to the main monorepo
|
||||
|
||||
## 0.0.2-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b223cbde14: feat(uikit): Move `@rocket.chat/ui-kit` package to the main monorepo
|
||||
23
packages/account-utils/package.json
Normal file
23
packages/account-utils/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@rocket.chat/account-utils",
|
||||
"version": "0.0.3",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"typings": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"/dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rm -rf dist && tsc -p tsconfig.json",
|
||||
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "~9.39.5",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"volta": {
|
||||
"extends": "../../package.json"
|
||||
}
|
||||
}
|
||||
7
packages/account-utils/src/index.ts
Normal file
7
packages/account-utils/src/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export function hashLoginToken(loginToken: string): string {
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update(loginToken);
|
||||
return hash.digest('base64');
|
||||
}
|
||||
8
packages/account-utils/tsconfig.json
Normal file
8
packages/account-utils/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@rocket.chat/tsconfig/client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["./src/**/*"]
|
||||
}
|
||||
45
packages/agenda/CHANGELOG.md
Normal file
45
packages/agenda/CHANGELOG.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# @rocket.chat/agenda
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration
|
||||
|
||||
## 0.1.1-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#31427](https://github.com/RocketChat/Rocket.Chat/pull/31427)) Remove `@ts-ignore` directives and unused code from Agenda code
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#31138](https://github.com/RocketChat/Rocket.Chat/pull/31138)) feat(uikit): Move `@rocket.chat/ui-kit` package to the main monorepo
|
||||
|
||||
## 0.1.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- db2551906c: Remove `@ts-ignore` directives and unused code from Agenda code
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b223cbde14: feat(uikit): Move `@rocket.chat/ui-kit` package to the main monorepo
|
||||
|
||||
## 0.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 37c792161f: fix: Saving Business hour throws an alert
|
||||
|
||||
## 0.0.2-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 37c792161f: fix: Saving Business hour throws an alert
|
||||
32
packages/agenda/package.json
Normal file
32
packages/agenda/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@rocket.chat/agenda",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"description": "Fork of https://github.com/agenda/agenda",
|
||||
"main": "./dist/index.js",
|
||||
"typings": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"/dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rm -rf dist && tsc -p tsconfig.json",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix ."
|
||||
},
|
||||
"dependencies": {
|
||||
"cron": "~1.8.2",
|
||||
"date.js": "~0.3.3",
|
||||
"debug": "~4.3.7",
|
||||
"human-interval": "^2.0.1",
|
||||
"moment-timezone": "~0.5.48",
|
||||
"mongodb": "6.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/debug": "^4.1.13",
|
||||
"eslint": "~9.39.5",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"volta": {
|
||||
"extends": "../../package.json"
|
||||
}
|
||||
}
|
||||
984
packages/agenda/src/Agenda.ts
Normal file
984
packages/agenda/src/Agenda.ts
Normal file
@@ -0,0 +1,984 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
import debugInitializer from 'debug';
|
||||
import humanInterval from 'human-interval';
|
||||
import { MongoClient } from 'mongodb';
|
||||
import type { MongoClientOptions, Db, Document, Collection, InsertOneResult } from 'mongodb';
|
||||
|
||||
import { Job } from './Job';
|
||||
import { JobProcessingQueue } from './JobProcessingQueue';
|
||||
import { createJob } from './createJob';
|
||||
import type { IJob } from './definition/IJob';
|
||||
import type { JobDefinition, JobOptions } from './definition/JobDefinition';
|
||||
import { hasMongoProtocol } from './lib/hasMongoProtocol';
|
||||
import { noCallback } from './lib/noCallback';
|
||||
|
||||
const debug = debugInitializer('agenda:agenda');
|
||||
|
||||
const defaultInterval = 5000;
|
||||
|
||||
type JobSort = Partial<Record<keyof IJob, 1 | -1>>;
|
||||
|
||||
type MongoTopology = {
|
||||
autoReconnect?: boolean;
|
||||
connections?(): unknown[];
|
||||
isDestroyed?(): boolean;
|
||||
};
|
||||
|
||||
type MongoDB = Db & {
|
||||
s?: {
|
||||
client?: {
|
||||
topology?: MongoTopology;
|
||||
};
|
||||
};
|
||||
db?: {
|
||||
s?: {
|
||||
client?: {
|
||||
topology?: MongoTopology;
|
||||
};
|
||||
};
|
||||
};
|
||||
topology?: {
|
||||
s?: {
|
||||
options?: {
|
||||
useUnifiedTopology?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AgendaConfig = {
|
||||
name?: string;
|
||||
processEvery?: string;
|
||||
defaultConcurrency?: number;
|
||||
maxConcurrency?: number;
|
||||
defaultLockLimit?: number;
|
||||
lockLimit?: number;
|
||||
defaultLockLifeTime?: number;
|
||||
|
||||
sort?: JobSort;
|
||||
} & (
|
||||
| {
|
||||
mongo: MongoDB;
|
||||
db?: {
|
||||
collection?: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
mongo?: undefined;
|
||||
db?: {
|
||||
address: string;
|
||||
collection?: string;
|
||||
options?: MongoClientOptions;
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export type RepeatOptions = { timezone?: string; skipImmediate?: boolean };
|
||||
|
||||
export class Agenda extends EventEmitter {
|
||||
private _name: string | undefined;
|
||||
|
||||
private _processEvery: number;
|
||||
|
||||
private _defaultConcurrency: number;
|
||||
|
||||
private _maxConcurrency: number;
|
||||
|
||||
private _defaultLockLimit: number;
|
||||
|
||||
private _defaultLockLifetime: number;
|
||||
|
||||
protected _db: MongoClient | undefined;
|
||||
|
||||
private _mdb: MongoDB | undefined;
|
||||
|
||||
private _collection: Collection | undefined;
|
||||
|
||||
private _definitions: Record<string, JobDefinition> = {};
|
||||
|
||||
private _runningJobs: Job[];
|
||||
|
||||
private _lockedJobs: Job[];
|
||||
|
||||
private _jobsToLock: Job[];
|
||||
|
||||
private _jobQueue: JobProcessingQueue;
|
||||
|
||||
private _lockLimit: number;
|
||||
|
||||
private _sort: JobSort;
|
||||
|
||||
private _indexes: JobSort;
|
||||
|
||||
private _isLockingOnTheFly: boolean;
|
||||
|
||||
private _ready: Promise<void>;
|
||||
|
||||
private _processInterval: NodeJS.Timeout | undefined;
|
||||
|
||||
private _nextScanAt?: Date;
|
||||
|
||||
private _mongoUseUnifiedTopology: boolean | undefined;
|
||||
|
||||
constructor(config: AgendaConfig = {}) {
|
||||
super();
|
||||
|
||||
this._name = config.name;
|
||||
this._processEvery = humanInterval(config.processEvery) || defaultInterval;
|
||||
this._defaultConcurrency = config.defaultConcurrency || 5;
|
||||
this._maxConcurrency = config.maxConcurrency || 20;
|
||||
this._defaultLockLimit = config.defaultLockLimit || 0;
|
||||
this._lockLimit = config.lockLimit || 0;
|
||||
|
||||
this._definitions = {};
|
||||
this._runningJobs = [];
|
||||
this._lockedJobs = [];
|
||||
this._jobQueue = new JobProcessingQueue();
|
||||
this._defaultLockLifetime = config.defaultLockLifeTime || 10 * 60 * 1000; // 10 minute default lockLifetime
|
||||
this._sort = config.sort || { nextRunAt: 1, priority: -1 };
|
||||
this._indexes = { name: 1, ...this._sort, priority: -1, lockedAt: 1, nextRunAt: 1, disabled: 1 };
|
||||
|
||||
this._isLockingOnTheFly = false;
|
||||
this._jobsToLock = [];
|
||||
this._ready = new Promise((resolve) => this.once('ready', resolve));
|
||||
|
||||
if (config.mongo) {
|
||||
void this.mongo(config.mongo, config.db ? config.db.collection : undefined);
|
||||
} else if (config.db) {
|
||||
void this.database(config.db.address, config.db.collection, config.db.options);
|
||||
}
|
||||
}
|
||||
|
||||
public mongo(mdb: MongoDB, collection: string | undefined) {
|
||||
this._mdb = mdb;
|
||||
if (mdb.s && mdb.topology && mdb.topology.s) {
|
||||
this._mongoUseUnifiedTopology = Boolean(mdb?.topology?.s?.options?.useUnifiedTopology);
|
||||
}
|
||||
return this.dbInit(collection);
|
||||
}
|
||||
|
||||
/**
|
||||
* * NOTE:
|
||||
* If `url` includes auth details then `options` must specify: { 'uri_decode_auth': true }. This does Auth on
|
||||
* the specified database, not the Admin database. If you are using Auth on the Admin DB and not on the Agenda DB,
|
||||
* then you need to authenticate against the Admin DB and then pass the MongoDB instance into the constructor
|
||||
* or use Agenda.mongo(). If your app already has a MongoDB connection then use that. ie. specify config.mongo in
|
||||
* the constructor or use Agenda.mongo().
|
||||
*/
|
||||
public async database(url: string, collection: string | undefined, options: MongoClientOptions = {}) {
|
||||
if (!hasMongoProtocol(url)) {
|
||||
url = `mongodb://${url}`;
|
||||
}
|
||||
|
||||
collection = collection || 'agendaJobs';
|
||||
|
||||
options = {
|
||||
...options,
|
||||
};
|
||||
|
||||
try {
|
||||
const client = await MongoClient.connect(url, options);
|
||||
debug('successful connection to MongoDB using collection: [%s]', collection);
|
||||
this._db = client;
|
||||
this._mdb = client.db();
|
||||
void this.dbInit(collection);
|
||||
} catch (error) {
|
||||
debug('error connecting to MongoDB using collection: [%s]', collection);
|
||||
this.emit('error:database', error);
|
||||
}
|
||||
}
|
||||
|
||||
public async dbInit(collection: string | undefined) {
|
||||
debug('init database collection using name [%s]', collection);
|
||||
this._collection = this.getMongoDB().collection(collection || 'agendaJobs');
|
||||
debug('attempting index creation');
|
||||
try {
|
||||
await this._collection.createIndex(this._indexes, { name: 'findAndLockNextJobIndex' });
|
||||
debug('index creation success');
|
||||
this.emit('ready');
|
||||
} catch (err) {
|
||||
debug('index creation failed');
|
||||
this.emit('error', err);
|
||||
}
|
||||
}
|
||||
|
||||
public name(name: string): Agenda {
|
||||
debug('Agenda.name(%s)', name);
|
||||
this._name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
public processEvery(time: string): Agenda {
|
||||
debug('Agenda.processEvery(%d)', time);
|
||||
this._processEvery = humanInterval(time) || defaultInterval;
|
||||
return this;
|
||||
}
|
||||
|
||||
public maxConcurrency(num: number): Agenda {
|
||||
debug('Agenda.maxConcurrency(%d)', num);
|
||||
this._maxConcurrency = num;
|
||||
return this;
|
||||
}
|
||||
|
||||
public defaultConcurrency(num: number): Agenda {
|
||||
debug('Agenda.defaultConcurrency(%d)', num);
|
||||
this._defaultConcurrency = num;
|
||||
return this;
|
||||
}
|
||||
|
||||
public lockLimit(num: number): Agenda {
|
||||
debug('Agenda.lockLimit(%d)', num);
|
||||
this._lockLimit = num;
|
||||
return this;
|
||||
}
|
||||
|
||||
public defaultLockLimit(num: number): Agenda {
|
||||
debug('Agenda.defaultLockLimit(%d)', num);
|
||||
this._defaultLockLimit = num;
|
||||
return this;
|
||||
}
|
||||
|
||||
public defaultLockLifetime(ms: number): Agenda {
|
||||
debug('Agenda.defaultLockLifetime(%d)', ms);
|
||||
this._defaultLockLifetime = ms;
|
||||
return this;
|
||||
}
|
||||
|
||||
public sort(query: JobSort): Agenda {
|
||||
debug('Agenda.sort([Object])');
|
||||
this._sort = query;
|
||||
return this;
|
||||
}
|
||||
|
||||
public create(name: string, data: IJob['data'] = {}): Job {
|
||||
debug('Agenda.create(%s, [Object])', name);
|
||||
const priority = this._definitions[name] ? this._definitions[name].priority : 0;
|
||||
const job = new Job({
|
||||
name,
|
||||
data,
|
||||
type: 'normal',
|
||||
priority,
|
||||
agenda: this,
|
||||
});
|
||||
return job;
|
||||
}
|
||||
|
||||
private getCollection(): Collection {
|
||||
if (!this._collection) {
|
||||
throw new Error('Agenda instance is not ready yet');
|
||||
}
|
||||
|
||||
return this._collection;
|
||||
}
|
||||
|
||||
private getMongoDB(): MongoDB {
|
||||
if (!this._mdb) {
|
||||
throw new Error('Agenda instance is not ready yet');
|
||||
}
|
||||
|
||||
return this._mdb;
|
||||
}
|
||||
|
||||
public async jobs(query = {}, sort = {}, limit = 0, skip = 0): Promise<Job[]> {
|
||||
try {
|
||||
const result = await this.getCollection().find<IJob>(query).sort(sort).limit(limit).skip(skip).toArray();
|
||||
|
||||
return result.map((job) => createJob(this, job));
|
||||
} catch (error) {
|
||||
debug('error querying jobs from database');
|
||||
this.emit('error:database', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async purge(): Promise<unknown> {
|
||||
// @NOTE: Only use after defining your jobs
|
||||
const definedNames = Object.keys(this._definitions);
|
||||
debug('Agenda.purge(%o)', definedNames);
|
||||
try {
|
||||
return await this.cancel({ name: { $not: { $in: definedNames } } });
|
||||
} catch (error) {
|
||||
debug('error purging jobs from database');
|
||||
this.emit('error:database', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public define(name: string, processor: JobDefinition['fn']): void;
|
||||
|
||||
public define(name: string, maybeOptions: Partial<JobOptions> | JobDefinition['fn'], maybeProcessor?: JobDefinition['fn']): void {
|
||||
const processor: JobDefinition['fn'] = maybeProcessor || (maybeOptions as JobDefinition['fn']);
|
||||
const options: Partial<JobOptions> = maybeProcessor ? (maybeOptions as Partial<JobOptions>) : {};
|
||||
|
||||
this._definitions[name] = {
|
||||
fn: processor,
|
||||
concurrency: options.concurrency || this._defaultConcurrency,
|
||||
lockLimit: options.lockLimit || this._defaultLockLimit,
|
||||
priority: options.priority || 0,
|
||||
lockLifetime: options.lockLifetime || this._defaultLockLifetime,
|
||||
running: 0,
|
||||
locked: 0,
|
||||
};
|
||||
debug('job [%s] defined with following options: \n%O', name, this._definitions[name]);
|
||||
}
|
||||
|
||||
public async every(interval: string | number, name: string, data: IJob['data'], options: RepeatOptions): Promise<Job>;
|
||||
|
||||
public async every(interval: string | number, names: string[], data: IJob['data'], options: RepeatOptions): Promise<Job[]>;
|
||||
|
||||
public async every(
|
||||
interval: string | number,
|
||||
names: string | string[],
|
||||
data: IJob['data'],
|
||||
options: RepeatOptions,
|
||||
): Promise<Job | Job[] | undefined> {
|
||||
if (typeof names === 'string') {
|
||||
debug('Agenda.every(%s, %O, %O)', interval, names, options);
|
||||
return this._createIntervalJob(interval, names, data, options);
|
||||
}
|
||||
|
||||
if (Array.isArray(names)) {
|
||||
debug('Agenda.every(%s, %s, %O)', interval, names, options);
|
||||
return this._createIntervalJobs(interval, names, data, options);
|
||||
}
|
||||
|
||||
throw new Error('Unexpected error: Invalid job name(s)');
|
||||
}
|
||||
|
||||
public async _createIntervalJob(interval: string | number, name: string, data: IJob['data'], options: RepeatOptions): Promise<Job> {
|
||||
const job = this.create(name, data);
|
||||
job.attrs.type = 'single';
|
||||
job.repeatEvery(interval, options);
|
||||
await job.save();
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
private _createIntervalJobs(
|
||||
interval: string | number,
|
||||
names: string[],
|
||||
data: IJob['data'],
|
||||
options: RepeatOptions,
|
||||
): Promise<Job[]> | undefined {
|
||||
try {
|
||||
const jobs = names.map((name) => this._createIntervalJob(interval, name, data, options));
|
||||
debug('every() -> all jobs created successfully');
|
||||
|
||||
return Promise.all(jobs);
|
||||
} catch (error) {
|
||||
debug('every() -> error creating one or more of the jobs', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async _createScheduledJob(when: string | Date, name: string, data: IJob['data']): Promise<Job> {
|
||||
const job = this.create(name, data);
|
||||
await job.schedule(when).save();
|
||||
return job;
|
||||
}
|
||||
|
||||
private async _createScheduledJobs(when: string | Date, names: string[], data: IJob['data']): Promise<Job[]> {
|
||||
try {
|
||||
const jobs = await Promise.all(names.map((name) => this._createScheduledJob(when, name, data)));
|
||||
debug('Agenda.schedule()::createJobs() -> all jobs created successfully');
|
||||
return jobs;
|
||||
} catch (error) {
|
||||
debug('Agenda.schedule()::createJobs() -> error creating one or more of the jobs');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public schedule(when: string | Date, name: string, data: IJob['data']): Promise<Job>;
|
||||
|
||||
public schedule(when: string | Date, names: string[], data: IJob['data']): Promise<Job[]>;
|
||||
|
||||
public schedule(when: string | Date, names: string | string[], data: IJob['data']): Promise<Job | Job[]> {
|
||||
if (typeof names === 'string') {
|
||||
debug('Agenda.schedule(%s, %O, [%O], cb)', when, names);
|
||||
return this._createScheduledJob(when, names, data);
|
||||
}
|
||||
|
||||
if (Array.isArray(names)) {
|
||||
debug('Agenda.schedule(%s, %O, [%O])', when, names);
|
||||
return this._createScheduledJobs(when, names, data);
|
||||
}
|
||||
|
||||
throw new Error('Unexpected error: invalid job name(s)');
|
||||
}
|
||||
|
||||
public async now(name: string, data: IJob['data'], ...args: Array<any>): Promise<Job> {
|
||||
debug('Agenda.now(%s, [Object])', name);
|
||||
try {
|
||||
noCallback([name, data, ...args], 2);
|
||||
const job = this.create(name, data);
|
||||
|
||||
job.schedule(new Date());
|
||||
await job.save();
|
||||
|
||||
return job;
|
||||
} catch (error) {
|
||||
debug('error trying to create a job for this exact moment');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async cancel(query: Record<string, any>): Promise<number> {
|
||||
debug('attempting to cancel all Agenda jobs', query);
|
||||
try {
|
||||
const { deletedCount = 0 } = await this.getCollection().deleteMany(query);
|
||||
debug('%s jobs cancelled', deletedCount);
|
||||
return deletedCount;
|
||||
} catch (error) {
|
||||
debug('error trying to delete jobs from MongoDB');
|
||||
this.emit('error:database', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async has(query: Record<string, any>): Promise<boolean> {
|
||||
debug('checking whether Agenda has any jobs matching query', query);
|
||||
const record = await this.getCollection().findOne(query, { projection: { _id: 1 } });
|
||||
return record !== null;
|
||||
}
|
||||
|
||||
private async _processDbResult(job: Job, result: Document | InsertOneResult | null): Promise<void> {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
debug('processDbResult() called with success, checking whether to process job immediately or not');
|
||||
|
||||
// We have a result from the above calls
|
||||
// findOneAndUpdate() returns different results than insertOne() so check for that
|
||||
const res = await (async (): Promise<Document | null> => {
|
||||
if ('insertedId' in result) {
|
||||
return this.getCollection().findOne({ _id: result.insertedId });
|
||||
}
|
||||
|
||||
return result;
|
||||
})();
|
||||
|
||||
if (!res) {
|
||||
debug('job not found');
|
||||
return;
|
||||
}
|
||||
|
||||
job.attrs._id = res._id;
|
||||
job.attrs.nextRunAt = res.nextRunAt;
|
||||
|
||||
// If the current job would have been processed in an older scan, process the job immediately
|
||||
if (job.attrs.nextRunAt && job.attrs.nextRunAt < (this._nextScanAt as Date)) {
|
||||
debug('[%s:%s] job would have ran by nextScanAt, processing the job immediately', job.attrs.name, res._id);
|
||||
this.processJobs(job);
|
||||
}
|
||||
}
|
||||
|
||||
private async _updateJob(job: Job, props: Record<string, any>): Promise<void> {
|
||||
const id = job.attrs._id;
|
||||
const update = {
|
||||
$set: props,
|
||||
};
|
||||
|
||||
// Update the job and process the resulting data'
|
||||
debug('job already has _id, calling findOneAndUpdate() using _id as query');
|
||||
try {
|
||||
const result = await this.getCollection().findOneAndUpdate({ _id: id } as any, update, { returnDocument: 'after' });
|
||||
result && (await this._processDbResult(job, result));
|
||||
} catch (error) {
|
||||
this.emit('error:database', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async _saveSingleJob(job: Job, props: Record<string, any>, now: Date): Promise<void> {
|
||||
// Job type set to 'single' so...
|
||||
debug('job with type of "single" found');
|
||||
|
||||
const { nextRunAt, ...$set } = props;
|
||||
const $setOnInsert: Record<string, any> = {};
|
||||
|
||||
if (nextRunAt && nextRunAt <= now) {
|
||||
debug('job has a scheduled nextRunAt time, protecting that field from upsert');
|
||||
$setOnInsert.nextRunAt = nextRunAt;
|
||||
} else {
|
||||
$set.nextRunAt = nextRunAt;
|
||||
}
|
||||
|
||||
const update = {
|
||||
$set,
|
||||
...(Object.keys($setOnInsert).length && { $setOnInsert }),
|
||||
};
|
||||
|
||||
// Try an upsert
|
||||
debug('calling findOneAndUpdate() with job name and type of "single" as query');
|
||||
try {
|
||||
const result = await this.getCollection().findOneAndUpdate(
|
||||
{
|
||||
name: props.name,
|
||||
type: 'single',
|
||||
},
|
||||
update,
|
||||
{
|
||||
upsert: true,
|
||||
returnDocument: 'after',
|
||||
},
|
||||
);
|
||||
|
||||
return await this._processDbResult(job, result);
|
||||
} catch (error) {
|
||||
this.emit('error:database', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async _saveUniqueJob(job: Job, props: Record<string, any>): Promise<void> {
|
||||
// If we want the job to be unique, then we can upsert based on the 'unique' query object that was passed in
|
||||
const { unique: query, uniqueOpts } = job.attrs;
|
||||
if (!query) {
|
||||
throw new Error('Unexpected Error: No unique data to store on the job');
|
||||
}
|
||||
|
||||
query.name = props.name;
|
||||
const update = uniqueOpts?.insertOnly ? { $setOnInsert: props } : { $set: props };
|
||||
|
||||
// Use the 'unique' query object to find an existing job or create a new one
|
||||
debug('calling findOneAndUpdate() with unique object as query: \n%O', query);
|
||||
try {
|
||||
const result = await this.getCollection().findOneAndUpdate(query, update, { upsert: true, returnDocument: 'after' });
|
||||
return await this._processDbResult(job, result);
|
||||
} catch (error) {
|
||||
debug('error saving unique job to database');
|
||||
this.emit('error:database', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async _saveNewJob(job: Job, props: Record<string, any>): Promise<void> {
|
||||
// If all else fails, the job does not exist yet so we just insert it into MongoDB
|
||||
debug('using default behavior, inserting new job via insertOne() with props that were set: \n%O', props);
|
||||
try {
|
||||
const result = await this.getCollection().insertOne(props);
|
||||
return await this._processDbResult(job, result);
|
||||
} catch (error) {
|
||||
debug('error inserting new job to database');
|
||||
this.emit('error:database', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async saveJob(job: Job): Promise<void> {
|
||||
try {
|
||||
debug('attempting to save a job into Agenda instance');
|
||||
|
||||
// Grab information needed to save job but that we don't want to persist in MongoDB
|
||||
const id = job.attrs._id;
|
||||
|
||||
const props = job.toJSON();
|
||||
// delete props._id;
|
||||
// delete props.unique;
|
||||
// delete props.uniqueOpts;
|
||||
|
||||
// Store name of agenda queue as last modifier in job data
|
||||
props.lastModifiedBy = this._name;
|
||||
debug('[job %s] set job props: \n%O', id, props);
|
||||
|
||||
// Grab current time and set default query options for MongoDB
|
||||
const now = new Date();
|
||||
debug('current time stored as %s', now.toISOString());
|
||||
|
||||
// If the job already had an ID, then update the properties of the job
|
||||
// i.e, who last modified it, etc
|
||||
if (id) {
|
||||
return this._updateJob(job, props);
|
||||
}
|
||||
|
||||
if (props.type === 'single') {
|
||||
return this._saveSingleJob(job, props, now);
|
||||
}
|
||||
|
||||
if (job.attrs.unique) {
|
||||
return this._saveUniqueJob(job, props);
|
||||
}
|
||||
|
||||
return this._saveNewJob(job, props);
|
||||
} catch (error) {
|
||||
debug('processDbResult() received an error, job was not updated/created');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
if (this._processInterval) {
|
||||
debug('Agenda.start was already called, ignoring');
|
||||
return this._ready;
|
||||
}
|
||||
|
||||
await this._ready;
|
||||
debug('Agenda.start called, creating interval to call processJobs every [%dms]', this._processEvery);
|
||||
this._processInterval = setInterval(() => this.processJobs(), this._processEvery || defaultInterval);
|
||||
process.nextTick(() => this.processJobs());
|
||||
}
|
||||
|
||||
private async _unlockJobs(): Promise<void> {
|
||||
debug('Agenda._unlockJobs()');
|
||||
const jobIds = this._lockedJobs.map((job) => job.attrs._id);
|
||||
|
||||
if (jobIds.length === 0) {
|
||||
debug('no jobs to unlock');
|
||||
return;
|
||||
}
|
||||
|
||||
debug('about to unlock jobs with ids: %O', jobIds);
|
||||
try {
|
||||
await this.getCollection().updateMany({ _id: { $in: jobIds } } as any, { $set: { lockedAt: null } });
|
||||
this._lockedJobs = [];
|
||||
} catch (error) {
|
||||
debug('error unlocking jobs in database');
|
||||
this.emit('error:database', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public stop(): Promise<void> {
|
||||
debug('Agenda.stop called, clearing interval for processJobs()');
|
||||
clearInterval(this._processInterval as NodeJS.Timeout);
|
||||
this._processInterval = undefined;
|
||||
|
||||
return this._unlockJobs();
|
||||
}
|
||||
|
||||
public getDefinition(jobName: string): JobDefinition {
|
||||
return this._definitions[jobName];
|
||||
}
|
||||
|
||||
private async _findAndLockNextJob(jobName: string, definition: JobDefinition): Promise<Job | undefined> {
|
||||
const now = new Date();
|
||||
const lockDeadline = new Date(Date.now().valueOf() - definition.lockLifetime);
|
||||
debug('_findAndLockNextJob(%s, [Function])', jobName);
|
||||
|
||||
// Don't try and access MongoDB if we've lost connection to it.
|
||||
const client = (this.getMongoDB() || this.getMongoDB().db)?.s?.client;
|
||||
if (client?.topology?.connections?.().length === 0 && !this._mongoUseUnifiedTopology) {
|
||||
if (client.topology.autoReconnect && !client.topology.isDestroyed?.()) {
|
||||
// Continue processing but notify that Agenda has lost the connection
|
||||
debug('Missing MongoDB connection, not attempting to find and lock a job');
|
||||
this.emit('error', new Error('Lost MongoDB connection'));
|
||||
return undefined;
|
||||
}
|
||||
// No longer recoverable
|
||||
debug('topology.autoReconnect: %s, topology.isDestroyed(): %s', client.topology.autoReconnect, client.topology.isDestroyed?.());
|
||||
throw new Error('MongoDB connection is not recoverable, application restart required');
|
||||
} else {
|
||||
// /**
|
||||
// * Query used to find job to run
|
||||
// * @type {{$and: [*]}}
|
||||
// */
|
||||
const JOB_PROCESS_WHERE_QUERY = {
|
||||
$and: [
|
||||
{
|
||||
name: jobName,
|
||||
disabled: { $ne: true },
|
||||
},
|
||||
{
|
||||
$or: [
|
||||
{
|
||||
lockedAt: { $eq: null },
|
||||
nextRunAt: { $lte: this._nextScanAt },
|
||||
},
|
||||
{
|
||||
lockedAt: { $lte: lockDeadline },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const JOB_PROCESS_SET_QUERY = { $set: { lockedAt: now } };
|
||||
|
||||
// Find ONE and ONLY ONE job and set the 'lockedAt' time so that job begins to be processed
|
||||
try {
|
||||
const result = await this.getCollection().findOneAndUpdate(JOB_PROCESS_WHERE_QUERY, JOB_PROCESS_SET_QUERY, {
|
||||
returnDocument: 'after',
|
||||
sort: this._sort,
|
||||
});
|
||||
|
||||
let job;
|
||||
if (result) {
|
||||
debug('found a job available to lock, creating a new job on Agenda with id [%s]', result._id);
|
||||
job = createJob(this, result as unknown as IJob);
|
||||
}
|
||||
|
||||
return job;
|
||||
} catch (error) {
|
||||
debug('error finding and locking next job in database');
|
||||
this.emit('error:database', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a job of the specified name can be locked.
|
||||
* Considers maximum locked jobs at any time if self._lockLimit is > 0
|
||||
* Considers maximum locked jobs of the specified name at any time if jobDefinition.lockLimit is > 0
|
||||
*/
|
||||
private _shouldLock(name: string): boolean {
|
||||
const jobDefinition = this.getDefinition(name);
|
||||
let shouldLock = true;
|
||||
if (this._lockLimit && this._lockLimit <= this._lockedJobs.length) {
|
||||
shouldLock = false;
|
||||
}
|
||||
|
||||
if (jobDefinition.lockLimit && jobDefinition.lockLimit <= jobDefinition.locked) {
|
||||
shouldLock = false;
|
||||
}
|
||||
|
||||
debug('job [%s] lock status: shouldLock = %s', name, shouldLock);
|
||||
return shouldLock;
|
||||
}
|
||||
|
||||
private _enqueueJobs(job: Job | Job[]): void {
|
||||
const jobs = Array.isArray(job) ? job : [job];
|
||||
|
||||
jobs.forEach((job) => this._jobQueue.insert(job));
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method that will lock a job and store it on MongoDB
|
||||
* This method is called when we immediately start to process a job without using the process interval
|
||||
* We do this because sometimes jobs are scheduled but will be run before the next process time
|
||||
*/
|
||||
private async _lockOnTheFly(): Promise<void> {
|
||||
// Already running this? Return
|
||||
if (this._isLockingOnTheFly) {
|
||||
debug('lockOnTheFly() already running, returning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't have any jobs to run? Return
|
||||
if (this._jobsToLock.length === 0) {
|
||||
debug('no jobs to current lock on the fly, returning');
|
||||
this._isLockingOnTheFly = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Set that we are running this
|
||||
this._isLockingOnTheFly = true;
|
||||
|
||||
// Grab a job that needs to be locked
|
||||
const now = new Date();
|
||||
const job = this._jobsToLock.pop();
|
||||
if (!job) {
|
||||
throw new Error('Unexpected Error: Job not found [lockOnTheFly]');
|
||||
}
|
||||
|
||||
// If locking limits have been hit, stop locking on the fly.
|
||||
// Jobs that were waiting to be locked will be picked up during a
|
||||
// future locking interval.
|
||||
if (!this._shouldLock(job.attrs.name)) {
|
||||
debug('lock limit hit for: [%s]', job.attrs.name);
|
||||
this._jobsToLock = [];
|
||||
this._isLockingOnTheFly = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Query to run against collection to see if we need to lock it
|
||||
const criteria = {
|
||||
_id: job.attrs._id,
|
||||
lockedAt: null,
|
||||
nextRunAt: job.attrs.nextRunAt,
|
||||
disabled: { $ne: true },
|
||||
};
|
||||
|
||||
// Update / options for the MongoDB query
|
||||
const update = { $set: { lockedAt: now } };
|
||||
|
||||
// Lock the job in MongoDB!
|
||||
try {
|
||||
const resp = await this.getCollection().findOneAndUpdate(criteria as any, update, { returnDocument: 'after' });
|
||||
|
||||
if (resp) {
|
||||
const job = createJob(this, resp as unknown as IJob);
|
||||
debug('found job [%s] that can be locked on the fly', job.attrs.name);
|
||||
this._lockedJobs.push(job);
|
||||
this._definitions[job.attrs.name].locked++;
|
||||
this._enqueueJobs(job);
|
||||
this._jobProcessing();
|
||||
}
|
||||
} catch (error) {
|
||||
debug('error locking job on the fly in database');
|
||||
this.emit('error:database', error);
|
||||
}
|
||||
|
||||
// Mark lock on fly is done for now
|
||||
this._isLockingOnTheFly = false;
|
||||
|
||||
// Re-run in case anything is in the queue
|
||||
await this._lockOnTheFly();
|
||||
}
|
||||
|
||||
private async _jobQueueFilling(name: string): Promise<void> {
|
||||
// Don't lock because of a limit we have set (lockLimit, etc)
|
||||
if (!this._shouldLock(name)) {
|
||||
debug('lock limit reached in queue filling for [%s]', name);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the date of the next time we are going to run _processEvery function
|
||||
this._nextScanAt = new Date(Date.now() + this._processEvery || defaultInterval);
|
||||
|
||||
// For this job name, find the next job to run and lock it!
|
||||
try {
|
||||
const job = await this._findAndLockNextJob(name, this._definitions[name]);
|
||||
// Still have the job?
|
||||
// 1. Add it to lock list
|
||||
// 2. Add count of locked jobs
|
||||
// 3. Queue the job to actually be run now that it is locked
|
||||
// 4. Recursively run this same method we are in to check for more available jobs of same type!
|
||||
if (job) {
|
||||
debug('[%s:%s] job locked while filling queue', name, job.attrs._id);
|
||||
this._lockedJobs.push(job);
|
||||
this._definitions[job.attrs.name].locked++;
|
||||
this._enqueueJobs(job);
|
||||
await this._jobQueueFilling(name);
|
||||
this._jobProcessing();
|
||||
}
|
||||
} catch (error) {
|
||||
debug('[%s] job lock failed while filling queue', name, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async _runOrRetry(): Promise<void> {
|
||||
if (!this._processInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
const job = this._jobQueue.pop();
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
|
||||
const jobDefinition = this._definitions[job.attrs.name];
|
||||
if (jobDefinition.concurrency > jobDefinition.running && this._runningJobs.length < this._maxConcurrency) {
|
||||
// Get the deadline of when the job is not supposed to go past for locking
|
||||
const lockDeadline = new Date(Date.now() - jobDefinition.lockLifetime);
|
||||
|
||||
// This means a job has "expired", as in it has not been "touched" within the lockoutTime
|
||||
// Remove from local lock
|
||||
// NOTE: Shouldn't we update the 'lockedAt' value in MongoDB so it can be picked up on restart?
|
||||
if (job.attrs.lockedAt && job.attrs.lockedAt < lockDeadline) {
|
||||
debug('[%s:%s] job lock has expired, freeing it up', job.attrs.name, job.attrs._id);
|
||||
this._lockedJobs.splice(this._lockedJobs.indexOf(job), 1);
|
||||
jobDefinition.locked--;
|
||||
this._jobProcessing();
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to local "running" queue
|
||||
this._runningJobs.push(job);
|
||||
jobDefinition.running++;
|
||||
|
||||
// CALL THE ACTUAL METHOD TO PROCESS THE JOB!!!
|
||||
debug('[%s:%s] processing job', job.attrs.name, job.attrs._id);
|
||||
|
||||
void job
|
||||
.run()
|
||||
.then((jobRan) => [null, jobRan])
|
||||
.catch((error) => [error, job])
|
||||
.then(([error, jobRan]) => this._processJobResult(error, jobRan));
|
||||
} else {
|
||||
// Run the job immediately by putting it on the top of the queue
|
||||
debug('[%s:%s] concurrency preventing immediate run, pushing job to top of queue', job.attrs.name, job.attrs._id);
|
||||
this._enqueueJobs(job);
|
||||
}
|
||||
}
|
||||
|
||||
private _jobProcessing(): void {
|
||||
// Ensure we have jobs
|
||||
if (this._jobQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store for all sorts of things
|
||||
const now = new Date();
|
||||
|
||||
// Get the next job that is not blocked by concurrency
|
||||
const job = this._jobQueue.returnNextConcurrencyFreeJob(this._definitions);
|
||||
|
||||
if (!job.attrs.nextRunAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
debug('[%s:%s] about to process job', job.attrs.name, job.attrs._id);
|
||||
|
||||
// If the 'nextRunAt' time is older than the current time, run the job
|
||||
// Otherwise, setTimeout that gets called at the time of 'nextRunAt'
|
||||
if (job.attrs.nextRunAt <= now) {
|
||||
debug('[%s:%s] nextRunAt is in the past, run the job immediately', job.attrs.name, job.attrs._id);
|
||||
void this._runOrRetry();
|
||||
} else {
|
||||
const runIn = job.attrs.nextRunAt.valueOf() - now.valueOf();
|
||||
debug('[%s:%s] nextRunAt is in the future, calling setTimeout(%d)', job.attrs.name, job.attrs._id, runIn);
|
||||
setTimeout(() => this._jobProcessing(), runIn);
|
||||
}
|
||||
}
|
||||
|
||||
private _processJobResult(err: Error | null, job: Job): void {
|
||||
if (err) {
|
||||
job.agenda.emit('error', err);
|
||||
return;
|
||||
}
|
||||
|
||||
const { name } = job.attrs;
|
||||
|
||||
// Job isn't in running jobs so throw an error
|
||||
if (!this._runningJobs.includes(job)) {
|
||||
debug('[%s] callback was called, job must have been marked as complete already', job.attrs._id);
|
||||
throw new Error(`callback already called - job ${name} already marked complete`);
|
||||
}
|
||||
|
||||
// Remove the job from the running queue
|
||||
this._runningJobs.splice(this._runningJobs.indexOf(job), 1);
|
||||
if (this._definitions[name].running > 0) {
|
||||
this._definitions[name].running--;
|
||||
}
|
||||
|
||||
// Remove the job from the locked queue
|
||||
this._lockedJobs.splice(this._lockedJobs.indexOf(job), 1);
|
||||
if (this._definitions[name].locked > 0) {
|
||||
this._definitions[name].locked--;
|
||||
}
|
||||
|
||||
// Re-process jobs now that one has finished
|
||||
this._jobProcessing();
|
||||
}
|
||||
|
||||
public processJobs(extraJob?: Job): void {
|
||||
debug('starting to process jobs');
|
||||
// Make sure an interval has actually been set
|
||||
// Prevents race condition with 'Agenda.stop' and already scheduled run
|
||||
if (!this._processInterval) {
|
||||
debug('no _processInterval set when calling processJobs, returning');
|
||||
return;
|
||||
}
|
||||
|
||||
let jobName;
|
||||
|
||||
// Determine whether or not we have a direct process call!
|
||||
if (!extraJob) {
|
||||
// Go through each jobName set in 'Agenda.process' and fill the queue with the next jobs
|
||||
for (jobName in this._definitions) {
|
||||
if (Object.hasOwn(this._definitions, jobName)) {
|
||||
debug('queuing up job to process: [%s]', jobName);
|
||||
void this._jobQueueFilling(jobName);
|
||||
}
|
||||
}
|
||||
} else if (this._definitions[extraJob.attrs.name]) {
|
||||
// Add the job to list of jobs to lock and then lock it immediately!
|
||||
debug('job [%s] was passed directly to processJobs(), locking and running immediately', extraJob.attrs.name);
|
||||
this._jobsToLock.push(extraJob);
|
||||
void this._lockOnTheFly();
|
||||
}
|
||||
}
|
||||
}
|
||||
293
packages/agenda/src/Job.ts
Normal file
293
packages/agenda/src/Job.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { CronTime } from 'cron';
|
||||
import date from 'date.js';
|
||||
import debugInitializer from 'debug';
|
||||
import humanInterval from 'human-interval';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
import type { Agenda, RepeatOptions } from './Agenda';
|
||||
import type { IJob, IJobAttributes } from './definition/IJob';
|
||||
import { noCallback } from './lib/noCallback';
|
||||
import { parsePriority } from './lib/parsePriority';
|
||||
|
||||
const debug = debugInitializer('agenda:job');
|
||||
|
||||
export type JobArgs = {
|
||||
agenda: Agenda;
|
||||
} & IJob;
|
||||
|
||||
export class Job {
|
||||
public agenda: Agenda;
|
||||
|
||||
public attrs: IJobAttributes;
|
||||
|
||||
constructor({ agenda, priority, ...args }: JobArgs) {
|
||||
args = args || {};
|
||||
|
||||
this.agenda = agenda;
|
||||
|
||||
this.attrs = {
|
||||
_id: '',
|
||||
...args,
|
||||
priority: parsePriority(priority),
|
||||
nextRunAt: args.nextRunAt || new Date(),
|
||||
type: args.type || 'once',
|
||||
};
|
||||
}
|
||||
|
||||
public toJSON(): Partial<IJob> {
|
||||
const { _id, unique, uniqueOpts, ...props } = this.attrs || {};
|
||||
|
||||
return props;
|
||||
}
|
||||
|
||||
public computeNextRunAt(): Job {
|
||||
const { repeatInterval: interval, repeatAt } = this.attrs;
|
||||
|
||||
const previousNextRunAt = this.attrs.nextRunAt || new Date();
|
||||
this.attrs.nextRunAt = null;
|
||||
|
||||
if (interval) {
|
||||
this._computeFromInterval(interval, previousNextRunAt);
|
||||
} else if (repeatAt) {
|
||||
this._computeFromRepeatAt(repeatAt);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public dateForTimezone(date: Date, timezone?: string | null): moment.Moment {
|
||||
const newDate = moment(date);
|
||||
if (timezone) {
|
||||
newDate.tz(timezone);
|
||||
}
|
||||
|
||||
return newDate;
|
||||
}
|
||||
|
||||
private _computeFromInterval(interval: string | number, previousNextRunAt: Date): void {
|
||||
const { repeatTimezone: timezone, name, _id } = this.attrs;
|
||||
|
||||
debug('[%s:%s] computing next run via interval [%s]', name, _id, interval);
|
||||
const lastRun = this.dateForTimezone(this.attrs.lastRunAt || new Date(), timezone);
|
||||
try {
|
||||
const cronTime = new CronTime(interval);
|
||||
let nextDate = cronTime._getNextDateFrom(lastRun);
|
||||
if (nextDate.valueOf() === lastRun.valueOf() || nextDate.valueOf() <= previousNextRunAt.valueOf()) {
|
||||
// Handle cronTime giving back the same date for the next run time
|
||||
nextDate = cronTime._getNextDateFrom(this.dateForTimezone(new Date(lastRun.valueOf() + 1000), timezone));
|
||||
}
|
||||
|
||||
this.attrs.nextRunAt = new Date(nextDate.valueOf());
|
||||
debug('[%s:%s] nextRunAt set to [%s]', this.attrs.name, this.attrs._id, this.attrs.nextRunAt.toISOString());
|
||||
} catch (error) {
|
||||
// Nope, humanInterval then!
|
||||
try {
|
||||
const numberInterval = (typeof interval === 'number' ? interval : humanInterval(interval)) || 0;
|
||||
|
||||
if (!this.attrs.lastRunAt && numberInterval) {
|
||||
this.attrs.nextRunAt = new Date(lastRun.valueOf());
|
||||
debug('[%s:%s] nextRunAt set to [%s]', this.attrs.name, this.attrs._id, this.attrs.nextRunAt.toISOString());
|
||||
} else {
|
||||
this.attrs.nextRunAt = new Date(lastRun.valueOf() + numberInterval);
|
||||
debug('[%s:%s] nextRunAt set to [%s]', this.attrs.name, this.attrs._id, this.attrs.nextRunAt.toISOString());
|
||||
}
|
||||
// Either `xo` linter or Node.js 8 stumble on this line if it isn't just ignored
|
||||
} catch (error) {
|
||||
//
|
||||
}
|
||||
} finally {
|
||||
if (!this.attrs.nextRunAt || isNaN(this.attrs.nextRunAt.valueOf())) {
|
||||
this.attrs.nextRunAt = null;
|
||||
debug('[%s:%s] failed to calculate nextRunAt due to invalid repeat interval', this.attrs.name, this.attrs._id);
|
||||
this.fail('failed to calculate nextRunAt due to invalid repeat interval');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _computeFromRepeatAt(repeatAt: string): void {
|
||||
const lastRun = this.attrs.lastRunAt || new Date();
|
||||
const nextDate = date(repeatAt).valueOf();
|
||||
|
||||
// If you do not specify offset date for below test it will fail for ms
|
||||
const offset = Date.now();
|
||||
if (offset === date(repeatAt, offset).valueOf()) {
|
||||
this.attrs.nextRunAt = null;
|
||||
debug('[%s:%s] failed to calculate repeatAt due to invalid format', this.attrs.name, this.attrs._id);
|
||||
this.fail('failed to calculate repeatAt time due to invalid format');
|
||||
} else if (nextDate.valueOf() === lastRun.valueOf()) {
|
||||
this.attrs.nextRunAt = date('tomorrow at ', repeatAt);
|
||||
debug('[%s:%s] nextRunAt set to [%s]', this.attrs.name, this.attrs._id, this.attrs.nextRunAt.toISOString());
|
||||
} else {
|
||||
this.attrs.nextRunAt = date(repeatAt);
|
||||
debug('[%s:%s] nextRunAt set to [%s]', this.attrs.name, this.attrs._id, this.attrs.nextRunAt.toISOString());
|
||||
}
|
||||
}
|
||||
|
||||
public repeatEvery(interval: string | number, options: RepeatOptions = {}): Job {
|
||||
this.attrs.repeatInterval = interval;
|
||||
this.attrs.repeatTimezone = options.timezone ? options.timezone : null;
|
||||
|
||||
if (options.skipImmediate) {
|
||||
// Set the lastRunAt time to the nextRunAt so that the new nextRunAt will be computed in reference to the current value.
|
||||
this.attrs.lastRunAt = this.attrs.nextRunAt || new Date();
|
||||
this.computeNextRunAt();
|
||||
this.attrs.lastRunAt = undefined;
|
||||
} else {
|
||||
this.computeNextRunAt();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public repeatAt(time: string): Job {
|
||||
this.attrs.repeatAt = time;
|
||||
return this;
|
||||
}
|
||||
|
||||
public disable(): Job {
|
||||
this.attrs.disabled = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
public enable(): Job {
|
||||
this.attrs.disabled = false;
|
||||
return this;
|
||||
}
|
||||
|
||||
public unique(unique: IJobAttributes['unique'], opts?: IJobAttributes['uniqueOpts']): Job {
|
||||
this.attrs.unique = unique;
|
||||
this.attrs.uniqueOpts = opts;
|
||||
return this;
|
||||
}
|
||||
|
||||
public schedule(time: string | Date): Job {
|
||||
const d = new Date(time);
|
||||
this.attrs.nextRunAt = Number.isNaN(d.getTime()) ? date(time as string) : d;
|
||||
return this;
|
||||
}
|
||||
|
||||
public priority(priority: string): Job {
|
||||
this.attrs.priority = parsePriority(priority);
|
||||
return this;
|
||||
}
|
||||
|
||||
public fail(reason: Error | string): Job {
|
||||
if (reason instanceof Error) {
|
||||
reason = reason.message;
|
||||
}
|
||||
|
||||
this.attrs.failReason = reason;
|
||||
this.attrs.failCount = (this.attrs.failCount || 0) + 1;
|
||||
|
||||
const now = new Date();
|
||||
this.attrs.failedAt = now;
|
||||
this.attrs.lastFinishedAt = now;
|
||||
debug('[%s:%s] fail() called [%d] times so far', this.attrs.name, this.attrs._id, this.attrs.failCount);
|
||||
return this;
|
||||
}
|
||||
|
||||
public run(): Promise<Job> {
|
||||
const definition = this.agenda.getDefinition(this.attrs.name);
|
||||
|
||||
return new Promise(async (resolve, reject) => {
|
||||
this.attrs.lastRunAt = new Date();
|
||||
debug('[%s:%s] setting lastRunAt to: %s', this.attrs.name, this.attrs._id, this.attrs.lastRunAt.toISOString());
|
||||
this.computeNextRunAt();
|
||||
await this.save();
|
||||
|
||||
let finished = false;
|
||||
const jobCallback = async (err?: Error): Promise<void> => {
|
||||
// We don't want to complete the job multiple times
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
finished = true;
|
||||
|
||||
if (err) {
|
||||
this.fail(err);
|
||||
} else {
|
||||
this.attrs.lastFinishedAt = new Date();
|
||||
}
|
||||
|
||||
this.attrs.lockedAt = null;
|
||||
|
||||
await this.save().catch((error) => {
|
||||
debug('[%s:%s] failed to be saved to MongoDB', this.attrs.name, this.attrs._id);
|
||||
reject(error);
|
||||
});
|
||||
debug('[%s:%s] was saved successfully to MongoDB', this.attrs.name, this.attrs._id);
|
||||
|
||||
if (err) {
|
||||
this.agenda.emit('fail', err, this);
|
||||
this.agenda.emit(`fail:${this.attrs.name}`, err, this);
|
||||
debug('[%s:%s] has failed [%s]', this.attrs.name, this.attrs._id, err.message);
|
||||
} else {
|
||||
this.agenda.emit('success', this);
|
||||
this.agenda.emit(`success:${this.attrs.name}`, this);
|
||||
debug('[%s:%s] has succeeded', this.attrs.name, this.attrs._id);
|
||||
}
|
||||
|
||||
this.agenda.emit('complete', this);
|
||||
this.agenda.emit(`complete:${this.attrs.name}`, this);
|
||||
debug('[%s:%s] job finished at [%s] and was unlocked', this.attrs.name, this.attrs._id, this.attrs.lastFinishedAt);
|
||||
// Curiously, we still resolve successfully if the job processor failed.
|
||||
// Agenda is not equipped to handle errors originating in user code, so, we leave them to inspect the side-effects of job.fail()
|
||||
resolve(this);
|
||||
};
|
||||
|
||||
try {
|
||||
this.agenda.emit('start', this);
|
||||
this.agenda.emit(`start:${this.attrs.name}`, this);
|
||||
debug('[%s:%s] starting job', this.attrs.name, this.attrs._id);
|
||||
if (!definition) {
|
||||
debug('[%s:%s] has no definition, can not run', this.attrs.name, this.attrs._id);
|
||||
throw new Error('Undefined job');
|
||||
}
|
||||
|
||||
if (definition.fn.length === 2) {
|
||||
debug('[%s:%s] process function being called', this.attrs.name, this.attrs._id);
|
||||
await definition.fn(this, jobCallback);
|
||||
} else {
|
||||
debug('[%s:%s] process function being called', this.attrs.name, this.attrs._id);
|
||||
await definition.fn(this);
|
||||
await jobCallback();
|
||||
}
|
||||
} catch (error: any) {
|
||||
debug('[%s:%s] unknown error occurred', this.attrs.name, this.attrs._id);
|
||||
await jobCallback(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public isRunning(): boolean {
|
||||
if (!this.attrs.lastRunAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.attrs.lastFinishedAt) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.attrs.lockedAt && this.attrs.lastRunAt.getTime() > this.attrs.lastFinishedAt.getTime()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public save(...args: Array<any>): Promise<void> {
|
||||
noCallback(args);
|
||||
|
||||
return this.agenda.saveJob(this);
|
||||
}
|
||||
|
||||
public remove(): Promise<number> {
|
||||
return this.agenda.cancel({ _id: this.attrs._id });
|
||||
}
|
||||
|
||||
public async touch(...args: Array<any>): Promise<void> {
|
||||
noCallback(args);
|
||||
this.attrs.lockedAt = new Date();
|
||||
return this.save();
|
||||
}
|
||||
}
|
||||
72
packages/agenda/src/JobProcessingQueue.ts
Normal file
72
packages/agenda/src/JobProcessingQueue.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { Job } from './Job';
|
||||
import type { JobDefinition } from './definition/JobDefinition';
|
||||
|
||||
export class JobProcessingQueue {
|
||||
private _queue: Job[] = [];
|
||||
|
||||
get length(): number {
|
||||
return this._queue.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pops and returns last queue element (next job to be processed) without checking concurrency.
|
||||
* @returns {Job} Next Job to be processed
|
||||
*/
|
||||
pop(): Job | undefined {
|
||||
return this._queue.pop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts job in first queue position
|
||||
*/
|
||||
push(job: Job): void {
|
||||
this._queue.push(job);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts job in queue where it will be order from left to right in decreasing
|
||||
* order of nextRunAt and priority (in case of same nextRunAt), if all values
|
||||
* are even the first jobs to be introduced will have priority
|
||||
*/
|
||||
insert(job: Job): void {
|
||||
const matchIndex = this._queue.findIndex((element) => {
|
||||
if (!element.attrs.nextRunAt || !job.attrs.nextRunAt) {
|
||||
return element.attrs.nextRunAt === job.attrs.nextRunAt;
|
||||
}
|
||||
|
||||
if (element.attrs.nextRunAt.getTime() <= job.attrs.nextRunAt.getTime()) {
|
||||
if (element.attrs.nextRunAt.getTime() === job.attrs.nextRunAt.getTime()) {
|
||||
if ((element.attrs.priority || 0) >= (job.attrs.priority || 0)) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (matchIndex === -1) {
|
||||
this._queue.push(job);
|
||||
} else {
|
||||
this._queue.splice(matchIndex, 0, job);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns (does not pop, element remains in queue) first element (always from the right)
|
||||
* that can be processed (not blocked by concurrency execution)
|
||||
*/
|
||||
returnNextConcurrencyFreeJob(agendaDefinitions: Record<string, JobDefinition>): Job {
|
||||
let next;
|
||||
for (next = this._queue.length - 1; next > 0; next -= 1) {
|
||||
const def = agendaDefinitions[this._queue[next].attrs.name];
|
||||
if (def.concurrency > def.running) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return this._queue[next];
|
||||
}
|
||||
}
|
||||
7
packages/agenda/src/createJob.ts
Normal file
7
packages/agenda/src/createJob.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { Agenda } from './Agenda';
|
||||
import { Job } from './Job';
|
||||
import type { IJob } from './definition/IJob';
|
||||
|
||||
export const createJob = (agenda: Agenda, jobData: IJob): Job => {
|
||||
return new Job({ agenda, ...jobData });
|
||||
};
|
||||
38
packages/agenda/src/definition/IJob.ts
Normal file
38
packages/agenda/src/definition/IJob.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export interface IJob {
|
||||
name: string;
|
||||
|
||||
nextRunAt?: Date | null;
|
||||
type?: 'once' | 'single' | 'normal';
|
||||
|
||||
repeatInterval?: string | number;
|
||||
repeatTimezone?: string | null;
|
||||
repeatAt?: string;
|
||||
|
||||
lastRunAt?: Date;
|
||||
lastFinishedAt?: Date;
|
||||
|
||||
failedAt?: Date;
|
||||
lockedAt?: Date | null;
|
||||
|
||||
disabled?: boolean;
|
||||
|
||||
priority?: number;
|
||||
|
||||
failReason?: string | Error;
|
||||
failCount?: number;
|
||||
|
||||
lastModifiedBy?: string;
|
||||
|
||||
data?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface IJobAttributes extends Omit<IJob, 'lastModifiedBy'> {
|
||||
_id: string;
|
||||
|
||||
unique?: Record<string, any>;
|
||||
uniqueOpts?: { insertOnly?: boolean };
|
||||
}
|
||||
|
||||
export interface IJobRecord extends IJob {
|
||||
_id: string;
|
||||
}
|
||||
14
packages/agenda/src/definition/JobDefinition.ts
Normal file
14
packages/agenda/src/definition/JobDefinition.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Job } from '../Job';
|
||||
|
||||
export type JobOptions = {
|
||||
concurrency: number;
|
||||
lockLimit: number;
|
||||
priority: number;
|
||||
lockLifetime: number;
|
||||
};
|
||||
|
||||
export type JobDefinition = JobOptions & {
|
||||
fn: (job: Job, jobCallback?: (err?: Error) => Promise<void>) => Promise<void> | void;
|
||||
running: number;
|
||||
locked: number;
|
||||
};
|
||||
7
packages/agenda/src/definition/cron.d.ts
vendored
Normal file
7
packages/agenda/src/definition/cron.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
declare module 'cron' {
|
||||
export declare class CronTime {
|
||||
constructor(source: string | Date | Moment, zone?: string, utcOffset?: string | number);
|
||||
|
||||
_getNextDateFrom<T>(date: T, zone?: string): T;
|
||||
}
|
||||
}
|
||||
4
packages/agenda/src/definition/date.js.d.ts
vendored
Normal file
4
packages/agenda/src/definition/date.js.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module 'date.js' {
|
||||
function data(str: string, offset?: string | Date | number): Date;
|
||||
export = data;
|
||||
}
|
||||
4
packages/agenda/src/index.ts
Normal file
4
packages/agenda/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { Agenda } from './Agenda';
|
||||
import { Job } from './Job';
|
||||
|
||||
export { Agenda, Job };
|
||||
3
packages/agenda/src/lib/hasMongoProtocol.ts
Normal file
3
packages/agenda/src/lib/hasMongoProtocol.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const hasMongoProtocol = function (url: string): boolean {
|
||||
return url.match(/mongodb(?:\+srv)?:\/\/.*/) !== null;
|
||||
};
|
||||
5
packages/agenda/src/lib/noCallback.ts
Normal file
5
packages/agenda/src/lib/noCallback.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const noCallback = (args: Array<any>, length = 0): void => {
|
||||
if (args.length > length) {
|
||||
throw new Error(`This function does not accept a callback function. ${args.length}/${length}`);
|
||||
}
|
||||
};
|
||||
19
packages/agenda/src/lib/parsePriority.ts
Normal file
19
packages/agenda/src/lib/parsePriority.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export const parsePriority = (priority: string | number | undefined): number => {
|
||||
if (!priority) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const priorityMap: Record<string, number> = {
|
||||
lowest: -20,
|
||||
low: -10,
|
||||
normal: 0,
|
||||
high: 10,
|
||||
highest: 20,
|
||||
};
|
||||
|
||||
if (typeof priority === 'number') {
|
||||
return priority;
|
||||
}
|
||||
|
||||
return priorityMap[priority] || 0;
|
||||
};
|
||||
9
packages/agenda/tsconfig.json
Normal file
9
packages/agenda/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@rocket.chat/tsconfig/server.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["./src/**/*"]
|
||||
}
|
||||
13
packages/ai-search/CHANGELOG.md
Normal file
13
packages/ai-search/CHANGELOG.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# @rocket.chat/ai-search
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#40890](https://github.com/RocketChat/Rocket.Chat/pull/40890)) Adds AI Search with semantic message results, optional OpenAI-compatible answers, and AI Center configuration.
|
||||
|
||||
## 0.2.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#40890](https://github.com/RocketChat/Rocket.Chat/pull/40890)) Adds AI Search with semantic message results, optional OpenAI-compatible answers, and AI Center configuration.
|
||||
17
packages/ai-search/README.md
Normal file
17
packages/ai-search/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# @rocket.chat/ai-search
|
||||
|
||||
Shared AI Search primitives.
|
||||
|
||||
This package keeps AI Search provider calls and normalization logic outside the
|
||||
Meteor REST handlers. It is intentionally framework-light: callers inject
|
||||
configuration, logger, and `fetch`, which makes the code usable from the current
|
||||
monolith or from a future standalone service process.
|
||||
|
||||
Current responsibilities:
|
||||
|
||||
- Client-side filter parsing and search-query serialization.
|
||||
- OpenAI-compatible model listing.
|
||||
- OpenAI-compatible answer generation for search results.
|
||||
- Intelligent Search pipeline request construction.
|
||||
- Pipeline filter construction.
|
||||
- Pipeline response normalization and similarity score handling.
|
||||
6
packages/ai-search/jest.config.ts
Normal file
6
packages/ai-search/jest.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import server from '@rocket.chat/jest-presets/server';
|
||||
import type { Config } from 'jest';
|
||||
|
||||
export default {
|
||||
preset: server.preset,
|
||||
} satisfies Config;
|
||||
31
packages/ai-search/package.json
Normal file
31
packages/ai-search/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@rocket.chat/ai-search",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"typings": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"/dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rm -rf dist && tsc -p tsconfig.json",
|
||||
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"test": "jest",
|
||||
"testunit": "jest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rocket.chat/jest-presets": "workspace:~",
|
||||
"@rocket.chat/tsconfig": "workspace:*",
|
||||
"@types/jest": "~30.0.0",
|
||||
"eslint": "~9.39.5",
|
||||
"jest": "~30.2.0",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"volta": {
|
||||
"extends": "../../package.json"
|
||||
}
|
||||
}
|
||||
264
packages/ai-search/src/clientSearch.spec.ts
Normal file
264
packages/ai-search/src/clientSearch.spec.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
buildAppliedFilterChips,
|
||||
buildFilterSuggestions,
|
||||
buildRoomSearchQuery,
|
||||
buildUserFilterSuggestions,
|
||||
emptySearchFilters,
|
||||
extractCompletedSearchFilters,
|
||||
getAISearchButtonTooltip,
|
||||
getFilterSearchState,
|
||||
mergeSearchFilters,
|
||||
parseSearchFilterText,
|
||||
serializeSearchQuery,
|
||||
} from './clientSearch';
|
||||
|
||||
describe('AI Search client helpers', () => {
|
||||
describe('parseSearchFilterText', () => {
|
||||
it('extracts room, user, and date filters while preserving the free-text query', () => {
|
||||
expect(parseSearchFilterText('in:general,dev from:@alice after:2026-01-01 before:2026-01-31 fruit colors')).toEqual({
|
||||
searchText: 'fruit colors',
|
||||
filters: {
|
||||
roomNames: ['general', 'dev'],
|
||||
rids: [],
|
||||
fromUsernames: ['alice'],
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-01-31',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('supports quoted filter values with spaces', () => {
|
||||
expect(parseSearchFilterText('mongo in:"team room" from:"rocket user"')).toEqual({
|
||||
searchText: 'mongo',
|
||||
filters: {
|
||||
roomNames: ['team room'],
|
||||
rids: [],
|
||||
fromUsernames: ['rocket user'],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractCompletedSearchFilters', () => {
|
||||
it('keeps a single active trailing filter token editable', () => {
|
||||
expect(extractCompletedSearchFilters('mongo from:ren')).toEqual({
|
||||
searchText: 'mongo from:ren',
|
||||
filters: emptySearchFilters(),
|
||||
hasCompletedFilters: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts completed filters when the token is followed by whitespace', () => {
|
||||
expect(extractCompletedSearchFilters('mongo from:ren ')).toEqual({
|
||||
searchText: 'mongo ',
|
||||
filters: {
|
||||
roomNames: [],
|
||||
rids: [],
|
||||
fromUsernames: ['ren'],
|
||||
},
|
||||
hasCompletedFilters: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts multiple completed filters in the same input', () => {
|
||||
expect(extractCompletedSearchFilters('in:general from:ren ')).toEqual({
|
||||
searchText: '',
|
||||
filters: {
|
||||
roomNames: ['general'],
|
||||
rids: [],
|
||||
fromUsernames: ['ren'],
|
||||
},
|
||||
hasCompletedFilters: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the trailing token editable while completing the preceding filters', () => {
|
||||
expect(extractCompletedSearchFilters('in:general from:ren')).toEqual({
|
||||
searchText: 'from:ren',
|
||||
filters: {
|
||||
roomNames: ['general'],
|
||||
rids: [],
|
||||
fromUsernames: [],
|
||||
},
|
||||
hasCompletedFilters: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeSearchFilters', () => {
|
||||
it('deduplicates repeated rooms and users while preserving latest date filters', () => {
|
||||
expect(
|
||||
mergeSearchFilters(
|
||||
{ roomNames: ['general'], rids: ['r1'], fromUsernames: ['alice'], startDate: '2026-01-01' },
|
||||
{ roomNames: ['general', 'dev'], rids: ['r1', 'r2'], fromUsernames: ['alice', 'bob'], endDate: '2026-01-31' },
|
||||
),
|
||||
).toEqual({
|
||||
roomNames: ['general', 'dev'],
|
||||
rids: ['r1', 'r2'],
|
||||
fromUsernames: ['alice', 'bob'],
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-01-31',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAppliedFilterChips', () => {
|
||||
it('groups common filter types into a single readable chip', () => {
|
||||
const t = (key: string, options?: Record<string, string>): string =>
|
||||
({
|
||||
Search_filter_in_rooms: `Messages in ${options?.rooms}`,
|
||||
Search_filter_from_users: `Messages from ${options?.users}`,
|
||||
Search_filter_after_date: `Messages after ${options?.date}`,
|
||||
Search_filter_before_date: `Messages before ${options?.date}`,
|
||||
})[key] || key;
|
||||
|
||||
expect(
|
||||
buildAppliedFilterChips(
|
||||
{
|
||||
roomNames: ['general', 'dev'],
|
||||
rids: [],
|
||||
fromUsernames: ['alice', 'bob'],
|
||||
startDate: '2026-01-01',
|
||||
},
|
||||
t,
|
||||
),
|
||||
).toEqual([
|
||||
{ key: 'in', values: ['general', 'dev'], label: '#general, #dev', title: 'Messages in #general, #dev' },
|
||||
{ key: 'from', values: ['alice', 'bob'], label: '@alice, @bob', title: 'Messages from @alice, @bob' },
|
||||
{ key: 'after', values: ['2026-01-01'], label: 'after:2026-01-01', title: 'Messages after 2026-01-01' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeSearchQuery', () => {
|
||||
it('quotes filter values with spaces and appends the free-text query', () => {
|
||||
expect(
|
||||
serializeSearchQuery('fruit colors', {
|
||||
roomNames: ['team room'],
|
||||
rids: [],
|
||||
fromUsernames: ['alice'],
|
||||
startDate: '2026-01-01',
|
||||
}),
|
||||
).toBe('in:"team room" from:alice after:2026-01-01 fruit colors');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRoomSearchQuery', () => {
|
||||
it('builds indexed room-name and fname regex predicates and excludes direct rooms for channel mentions', () => {
|
||||
expect(buildRoomSearchQuery('gen', '#')).toEqual({
|
||||
$or: [{ name: /gen/i }, { fname: /gen/i }],
|
||||
t: { $ne: 'd' },
|
||||
});
|
||||
});
|
||||
|
||||
it('bounds the escaped regex pattern used for room lookup', () => {
|
||||
const query = buildRoomSearchQuery('/'.repeat(128));
|
||||
const firstPredicate = query.$or[0];
|
||||
|
||||
if (!firstPredicate || !('name' in firstPredicate)) {
|
||||
throw new Error('Expected the first room lookup predicate to match room names');
|
||||
}
|
||||
|
||||
const nameRegex = firstPredicate.name;
|
||||
if (!(nameRegex instanceof RegExp)) {
|
||||
throw new Error('Expected the room name lookup predicate to use a regex');
|
||||
}
|
||||
|
||||
expect(nameRegex.source).toBe('\\/'.repeat(64));
|
||||
});
|
||||
});
|
||||
|
||||
describe('filter suggestions', () => {
|
||||
const t = (key: string): string => key;
|
||||
|
||||
it('builds room suggestions from an active in filter', () => {
|
||||
expect(
|
||||
buildFilterSuggestions(
|
||||
'deployment in:gen',
|
||||
{ key: 'in', value: 'gen', start: 11, end: 17 },
|
||||
[{ _id: 'room-id', name: 'general', fname: 'General' }],
|
||||
t,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
key: 'in-room-id',
|
||||
group: 'rooms',
|
||||
title: '#General',
|
||||
description: 'Search_in_this_room',
|
||||
value: 'deployment in:general ',
|
||||
icon: 'hash',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds user suggestions from an active from filter', () => {
|
||||
expect(
|
||||
buildUserFilterSuggestions(
|
||||
'deployment from:ali',
|
||||
{ key: 'from', value: 'ali', start: 11, end: 19 },
|
||||
[{ _id: 'user-id', name: 'Example User', username: 'alice' }],
|
||||
t,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
key: 'from-user-id',
|
||||
group: 'users',
|
||||
title: '@alice',
|
||||
description: 'Example User',
|
||||
value: 'deployment from:alice ',
|
||||
icon: 'user',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not parse filters while AI Search is inactive', () => {
|
||||
expect(getFilterSearchState('deployment in:general', emptySearchFilters(), false)).toEqual({
|
||||
searchText: 'deployment in:general',
|
||||
filters: emptySearchFilters(),
|
||||
activeFilter: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses and merges filters while AI Search is active', () => {
|
||||
expect(
|
||||
getFilterSearchState('deployment in:general from:bob', { roomNames: [], rids: ['room-id'], fromUsernames: ['alice'] }, true),
|
||||
).toEqual({
|
||||
searchText: 'deployment',
|
||||
filters: {
|
||||
roomNames: ['general'],
|
||||
rids: ['room-id'],
|
||||
fromUsernames: ['alice', 'bob'],
|
||||
},
|
||||
activeFilter: { key: 'from', value: 'bob', start: 22, end: 30 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAISearchButtonTooltip', () => {
|
||||
const t = (key: string): string => key;
|
||||
|
||||
it('explains unavailable AI Search when the add-on is missing', () => {
|
||||
expect(
|
||||
getAISearchButtonTooltip({ hasIntelligentSearchLicense: false, intelligentSearchEnabled: true, aiSearchActive: false, t }),
|
||||
).toBe('AI_Search_license_required_tooltip');
|
||||
});
|
||||
|
||||
it('explains disabled AI Search when the add-on is available', () => {
|
||||
expect(
|
||||
getAISearchButtonTooltip({ hasIntelligentSearchLicense: true, intelligentSearchEnabled: false, aiSearchActive: false, t }),
|
||||
).toBe('AI_Search_disabled_tooltip');
|
||||
});
|
||||
|
||||
it('offers to enable AI Search when it is inactive', () => {
|
||||
expect(
|
||||
getAISearchButtonTooltip({ hasIntelligentSearchLicense: true, intelligentSearchEnabled: true, aiSearchActive: false, t }),
|
||||
).toBe('Enable_AI_Search');
|
||||
});
|
||||
|
||||
it('offers to disable AI Search when it is active', () => {
|
||||
expect(getAISearchButtonTooltip({ hasIntelligentSearchLicense: true, intelligentSearchEnabled: true, aiSearchActive: true, t })).toBe(
|
||||
'Disable_AI_Search',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
445
packages/ai-search/src/clientSearch.ts
Normal file
445
packages/ai-search/src/clientSearch.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
import { AI_SEARCH_FILTER_SUGGESTION_LIMIT, MAX_ROOM_SEARCH_PATTERN_LENGTH } from './constants';
|
||||
|
||||
export type SearchFilters = {
|
||||
roomNames: string[];
|
||||
rids: string[];
|
||||
fromUsernames: string[];
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
rid?: string;
|
||||
fromUsername?: string;
|
||||
};
|
||||
|
||||
export type NavBarSearchFormValues = {
|
||||
filterText: string;
|
||||
appliedFilters: SearchFilters;
|
||||
};
|
||||
|
||||
export type SearchFilterSuggestion = {
|
||||
key: string;
|
||||
group: 'rooms' | 'users' | 'dates';
|
||||
title: string;
|
||||
description: string;
|
||||
value: string;
|
||||
icon: 'hash' | 'user' | 'calendar';
|
||||
};
|
||||
|
||||
export type SearchRoomSuggestionSource = {
|
||||
_id: string;
|
||||
rid?: string;
|
||||
name?: string;
|
||||
fname?: string;
|
||||
};
|
||||
|
||||
export type SearchUserSuggestionSource = {
|
||||
_id: string;
|
||||
name?: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
export type SearchFilterChip = {
|
||||
key: string;
|
||||
label: string;
|
||||
title: string;
|
||||
values: string[];
|
||||
};
|
||||
|
||||
export type ActiveSearchFilter = {
|
||||
key: 'in' | 'from' | 'after' | 'before';
|
||||
value: string;
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
const FILTER_PATTERN = /(?:^|\s)(in|from|after|before):(?:"([^"]*)"|(\S+))/gi;
|
||||
|
||||
const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
const normalizeFilterText = (value: string): string => value.replace(/\s+/g, ' ').trimStart();
|
||||
|
||||
const splitFilterValues = (value: string): string[] =>
|
||||
value
|
||||
.split(',')
|
||||
.map((item) => item.replace(/^[@#]/, '').trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const isSearchFilterKey = (value: string): value is ActiveSearchFilter['key'] =>
|
||||
value === 'in' || value === 'from' || value === 'after' || value === 'before';
|
||||
|
||||
export const emptySearchFilters = (): SearchFilters => ({ roomNames: [], rids: [], fromUsernames: [] });
|
||||
|
||||
export const mergeSearchFilters = (...filtersList: SearchFilters[]): SearchFilters => {
|
||||
const roomNames = new Set<string>();
|
||||
const rids = new Set<string>();
|
||||
const fromUsernames = new Set<string>();
|
||||
let startDate: string | undefined;
|
||||
let endDate: string | undefined;
|
||||
let rid: string | undefined;
|
||||
let fromUsername: string | undefined;
|
||||
|
||||
for (const filters of filtersList) {
|
||||
filters.roomNames.forEach((roomName) => roomNames.add(roomName));
|
||||
filters.rids.forEach((roomId) => rids.add(roomId));
|
||||
filters.fromUsernames.forEach((username) => fromUsernames.add(username));
|
||||
startDate = filters.startDate || startDate;
|
||||
endDate = filters.endDate || endDate;
|
||||
rid = filters.rid || rid;
|
||||
fromUsername = filters.fromUsername || fromUsername;
|
||||
}
|
||||
|
||||
return {
|
||||
roomNames: [...roomNames],
|
||||
rids: [...rids],
|
||||
fromUsernames: [...fromUsernames],
|
||||
...(startDate && { startDate }),
|
||||
...(endDate && { endDate }),
|
||||
...(rid && { rid }),
|
||||
...(fromUsername && { fromUsername }),
|
||||
};
|
||||
};
|
||||
|
||||
export const parseSearchFilterText = (filterText: string): { searchText: string; filters: SearchFilters } => {
|
||||
const filters: SearchFilters = emptySearchFilters();
|
||||
const searchText = filterText
|
||||
.replace(FILTER_PATTERN, (_match, key: string, quotedValue?: string, bareValue?: string) => {
|
||||
const value = String(quotedValue || bareValue || '').trim();
|
||||
const values = splitFilterValues(value);
|
||||
if (!values.length) {
|
||||
return ' ';
|
||||
}
|
||||
|
||||
switch (key.toLowerCase()) {
|
||||
case 'in':
|
||||
filters.roomNames.push(...values);
|
||||
break;
|
||||
case 'from':
|
||||
filters.fromUsernames.push(...values);
|
||||
break;
|
||||
case 'after':
|
||||
filters.startDate = values[0];
|
||||
break;
|
||||
case 'before':
|
||||
filters.endDate = values[0];
|
||||
break;
|
||||
}
|
||||
|
||||
return ' ';
|
||||
})
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
return { searchText, filters };
|
||||
};
|
||||
|
||||
export const extractCompletedSearchFilters = (
|
||||
filterText: string,
|
||||
): { searchText: string; filters: SearchFilters; hasCompletedFilters: boolean } => {
|
||||
const filters: SearchFilters = emptySearchFilters();
|
||||
let hasCompletedFilters = false;
|
||||
const trimmedLength = filterText.trimEnd().length;
|
||||
const searchText = filterText
|
||||
.replace(FILTER_PATTERN, (match, key: string, quotedValue?: string, bareValue?: string, offset?: number) => {
|
||||
const start = typeof offset === 'number' ? offset : 0;
|
||||
const end = start + match.length;
|
||||
// the trailing token stays editable until followed by whitespace, even after completed tokens
|
||||
const isActiveToken = end >= trimmedLength && !/\s$/.test(filterText);
|
||||
if (isActiveToken) {
|
||||
return match;
|
||||
}
|
||||
|
||||
const values = splitFilterValues(String(quotedValue || bareValue || ''));
|
||||
if (!values.length) {
|
||||
return ' ';
|
||||
}
|
||||
|
||||
hasCompletedFilters = true;
|
||||
switch (key.toLowerCase()) {
|
||||
case 'in':
|
||||
filters.roomNames.push(...values);
|
||||
break;
|
||||
case 'from':
|
||||
filters.fromUsernames.push(...values);
|
||||
break;
|
||||
case 'after':
|
||||
filters.startDate = values[0];
|
||||
break;
|
||||
case 'before':
|
||||
filters.endDate = values[0];
|
||||
break;
|
||||
}
|
||||
|
||||
return ' ';
|
||||
})
|
||||
.replace(/\s+/g, ' ')
|
||||
.trimStart();
|
||||
|
||||
return { searchText, filters, hasCompletedFilters };
|
||||
};
|
||||
|
||||
export const getActiveSearchFilter = (filterText: string): ActiveSearchFilter | undefined => {
|
||||
const match = /(?:^|\s)(in|from|after|before):([^\s]*)$/i.exec(filterText);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const key = match[1].toLowerCase();
|
||||
if (!isSearchFilterKey(key)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const tokenStart = filterText.lastIndexOf(match[1], filterText.length - match[2].length - 1);
|
||||
return {
|
||||
key,
|
||||
value: match[2],
|
||||
start: tokenStart,
|
||||
end: filterText.length,
|
||||
};
|
||||
};
|
||||
|
||||
export const formatSearchFilterValue = (key: ActiveSearchFilter['key'], value: string): string =>
|
||||
`${key}:${/\s/.test(value) ? `"${value}"` : value}`;
|
||||
|
||||
export const applySearchFilterToken = (
|
||||
filterText: string,
|
||||
activeFilter: ActiveSearchFilter | undefined,
|
||||
key: ActiveSearchFilter['key'],
|
||||
value: string,
|
||||
): string => {
|
||||
const token = formatSearchFilterValue(key, value);
|
||||
if (activeFilter) {
|
||||
return normalizeFilterText(`${filterText.slice(0, activeFilter.start)}${token} `);
|
||||
}
|
||||
|
||||
return normalizeFilterText(`${filterText.trim()} ${token} `);
|
||||
};
|
||||
|
||||
const formatDate = (date: Date): string => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const getDateFilterSuggestions = (
|
||||
filterText: string,
|
||||
activeFilter: ActiveSearchFilter,
|
||||
key: 'after' | 'before',
|
||||
t: (key: string) => string,
|
||||
): SearchFilterSuggestion[] => {
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
const lastWeek = new Date(today);
|
||||
lastWeek.setDate(today.getDate() - 7);
|
||||
|
||||
return [
|
||||
{ label: t('Today'), value: formatDate(today) },
|
||||
{ label: t('Yesterday'), value: formatDate(yesterday) },
|
||||
{ label: t('Last_7_days'), value: formatDate(lastWeek) },
|
||||
].map(({ label, value }) => ({
|
||||
key: `${key}-${value}`,
|
||||
group: 'dates',
|
||||
title: `${key}:${value}`,
|
||||
description: label,
|
||||
value: applySearchFilterToken(filterText, activeFilter, key, value),
|
||||
icon: 'calendar',
|
||||
}));
|
||||
};
|
||||
|
||||
export const buildFilterSuggestions = (
|
||||
filterText: string,
|
||||
activeFilter: ActiveSearchFilter | undefined,
|
||||
rooms: SearchRoomSuggestionSource[],
|
||||
t: (key: string) => string,
|
||||
): SearchFilterSuggestion[] => {
|
||||
if (!activeFilter) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (activeFilter.key === 'in') {
|
||||
return rooms.slice(0, AI_SEARCH_FILTER_SUGGESTION_LIMIT).map((room) => ({
|
||||
key: `in-${room.rid || room._id}`,
|
||||
group: 'rooms',
|
||||
title: `#${room.fname || room.name}`,
|
||||
description: t('Search_in_this_room'),
|
||||
value: applySearchFilterToken(filterText, activeFilter, 'in', room.name || room.fname || ''),
|
||||
icon: 'hash',
|
||||
}));
|
||||
}
|
||||
|
||||
if (activeFilter.key === 'from') {
|
||||
return [
|
||||
{
|
||||
key: 'from-current',
|
||||
group: 'users',
|
||||
title: activeFilter.value ? `from:${activeFilter.value.replace(/^@/, '')}` : 'from:username',
|
||||
description: t('Search_messages_from_this_username'),
|
||||
value: applySearchFilterToken(filterText, activeFilter, 'from', activeFilter.value.replace(/^@/, '')),
|
||||
icon: 'user',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return getDateFilterSuggestions(filterText, activeFilter, activeFilter.key, t);
|
||||
};
|
||||
|
||||
export const buildUserFilterSuggestions = (
|
||||
filterText: string,
|
||||
activeFilter: ActiveSearchFilter | undefined,
|
||||
users: SearchUserSuggestionSource[],
|
||||
t: (key: string) => string,
|
||||
): SearchFilterSuggestion[] => {
|
||||
if (activeFilter?.key !== 'from') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return users.slice(0, AI_SEARCH_FILTER_SUGGESTION_LIMIT).map((user) => ({
|
||||
key: `from-${user._id}`,
|
||||
group: 'users',
|
||||
title: `@${user.username}`,
|
||||
description: user.name || t('Search_messages_from_this_user'),
|
||||
value: applySearchFilterToken(filterText, activeFilter, 'from', user.username),
|
||||
icon: 'user',
|
||||
}));
|
||||
};
|
||||
|
||||
export const mergeFilterSuggestions = (primary: SearchFilterSuggestion[], fallback: SearchFilterSuggestion[]): SearchFilterSuggestion[] => {
|
||||
const existingValues = new Set(primary.map(({ value }) => value));
|
||||
return [...primary, ...fallback.filter(({ value }) => !existingValues.has(value))];
|
||||
};
|
||||
|
||||
export const getFilterSearchState = (
|
||||
filterText: string,
|
||||
appliedSearchFilters: SearchFilters,
|
||||
canUseInlineFilters: boolean,
|
||||
): {
|
||||
searchText: string;
|
||||
filters: SearchFilters;
|
||||
activeFilter: ActiveSearchFilter | undefined;
|
||||
} => {
|
||||
if (!canUseInlineFilters) {
|
||||
return { searchText: filterText, filters: emptySearchFilters(), activeFilter: undefined };
|
||||
}
|
||||
|
||||
const parsed = parseSearchFilterText(filterText);
|
||||
return {
|
||||
searchText: parsed.searchText,
|
||||
filters: mergeSearchFilters(appliedSearchFilters, parsed.filters),
|
||||
activeFilter: getActiveSearchFilter(filterText),
|
||||
};
|
||||
};
|
||||
|
||||
export const getRoomLookupText = (activeFilter: ActiveSearchFilter | undefined, canUseInlineFilters: boolean): string => {
|
||||
if (!canUseInlineFilters || activeFilter?.key !== 'in') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return activeFilter.value.replace(/^#/, '');
|
||||
};
|
||||
|
||||
const getFilterChipLabel = (key: ActiveSearchFilter['key'], value: string): string => {
|
||||
switch (key) {
|
||||
case 'in':
|
||||
return `#${value}`;
|
||||
case 'from':
|
||||
return `@${value}`;
|
||||
default:
|
||||
return `${key}:${value}`;
|
||||
}
|
||||
};
|
||||
|
||||
export const buildAppliedFilterChips = (
|
||||
filters: SearchFilters,
|
||||
t: (key: string, options?: Record<string, string>) => string,
|
||||
): SearchFilterChip[] => {
|
||||
const chips: SearchFilterChip[] = [];
|
||||
|
||||
if (filters.roomNames.length) {
|
||||
const label = filters.roomNames.map((roomName) => getFilterChipLabel('in', roomName)).join(', ');
|
||||
chips.push({
|
||||
key: 'in',
|
||||
values: filters.roomNames,
|
||||
label,
|
||||
title: t('Search_filter_in_rooms', { rooms: label }),
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.fromUsernames.length) {
|
||||
const label = filters.fromUsernames.map((username) => getFilterChipLabel('from', username)).join(', ');
|
||||
chips.push({
|
||||
key: 'from',
|
||||
values: filters.fromUsernames,
|
||||
label,
|
||||
title: t('Search_filter_from_users', { users: label }),
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.startDate) {
|
||||
const label = getFilterChipLabel('after', filters.startDate);
|
||||
chips.push({
|
||||
key: 'after',
|
||||
values: [filters.startDate],
|
||||
label,
|
||||
title: t('Search_filter_after_date', { date: filters.startDate }),
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.endDate) {
|
||||
const label = getFilterChipLabel('before', filters.endDate);
|
||||
chips.push({
|
||||
key: 'before',
|
||||
values: [filters.endDate],
|
||||
label,
|
||||
title: t('Search_filter_before_date', { date: filters.endDate }),
|
||||
});
|
||||
}
|
||||
|
||||
return chips;
|
||||
};
|
||||
|
||||
export const serializeSearchQuery = (searchText: string, filters: SearchFilters): string =>
|
||||
normalizeFilterText(
|
||||
[
|
||||
...filters.roomNames.map((roomName) => formatSearchFilterValue('in', roomName)),
|
||||
...filters.fromUsernames.map((username) => formatSearchFilterValue('from', username)),
|
||||
filters.startDate && formatSearchFilterValue('after', filters.startDate),
|
||||
filters.endDate && formatSearchFilterValue('before', filters.endDate),
|
||||
searchText,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
);
|
||||
|
||||
export const buildRoomSearchQuery = (value: string, mention?: string) => {
|
||||
const filterRegex = new RegExp(escapeRegExp(value.slice(0, MAX_ROOM_SEARCH_PATTERN_LENGTH)), 'i');
|
||||
|
||||
return {
|
||||
$or: [{ name: filterRegex }, { fname: filterRegex }],
|
||||
...(mention && {
|
||||
t: mention === '@' ? 'd' : { $ne: 'd' },
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const getAISearchButtonTooltip = ({
|
||||
hasIntelligentSearchLicense,
|
||||
intelligentSearchEnabled,
|
||||
aiSearchActive,
|
||||
t,
|
||||
}: {
|
||||
hasIntelligentSearchLicense: boolean;
|
||||
intelligentSearchEnabled: boolean;
|
||||
aiSearchActive: boolean;
|
||||
t: (key: string) => string;
|
||||
}): string => {
|
||||
if (!hasIntelligentSearchLicense) {
|
||||
return t('AI_Search_license_required_tooltip');
|
||||
}
|
||||
|
||||
if (!intelligentSearchEnabled) {
|
||||
return t('AI_Search_disabled_tooltip');
|
||||
}
|
||||
|
||||
return t(aiSearchActive ? 'Disable_AI_Search' : 'Enable_AI_Search');
|
||||
};
|
||||
13
packages/ai-search/src/constants.ts
Normal file
13
packages/ai-search/src/constants.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const AI_LICENSE_MODULE = 'chat.rocket.rc-ai';
|
||||
|
||||
export const AI_SEARCH_PAGE_SIZE = 5;
|
||||
export const AI_SEARCH_RESULTS_PAGE_SIZE = 8;
|
||||
export const AI_SEARCH_FILTER_SUGGESTION_LIMIT = 5;
|
||||
export const AI_SEARCH_ROOM_LOOKUP_LIMIT = 20;
|
||||
export const MAX_INTELLIGENT_SEARCH_RESULTS = 50;
|
||||
export const MAX_SEARCH_FILTER_VALUES = 25;
|
||||
export const MAX_ROOM_SEARCH_PATTERN_LENGTH = 64;
|
||||
export const MAX_AI_SERVICE_RESPONSE_SIZE = 5 * 1024 * 1024;
|
||||
export const MAX_SOURCE_MESSAGE_LENGTH = 700;
|
||||
export const MAX_SEARCH_ANSWER_MESSAGES = 20;
|
||||
export const MAX_SEARCH_ANSWER_TEXT_LENGTH = 1600;
|
||||
5
packages/ai-search/src/index.ts
Normal file
5
packages/ai-search/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './clientSearch';
|
||||
export * from './constants';
|
||||
export * from './intelligentSearch';
|
||||
export * from './llm';
|
||||
export type * from './types';
|
||||
195
packages/ai-search/src/intelligentSearch.spec.ts
Normal file
195
packages/ai-search/src/intelligentSearch.spec.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
buildIntelligentSearchPipelineFilters,
|
||||
getSemanticDistanceThreshold,
|
||||
normalizeIntelligentSearchCandidates,
|
||||
normalizeSimilarityPercent,
|
||||
searchIntelligentPipeline,
|
||||
} from './intelligentSearch';
|
||||
import type { AIServiceFetch } from './types';
|
||||
|
||||
describe('AI Search intelligent search helpers', () => {
|
||||
describe('normalizeSimilarityPercent', () => {
|
||||
it('normalizes invalid and out-of-range values', () => {
|
||||
expect(normalizeSimilarityPercent(undefined)).toBe(0);
|
||||
expect(normalizeSimilarityPercent('abc')).toBe(0);
|
||||
expect(normalizeSimilarityPercent(-10)).toBe(0);
|
||||
expect(normalizeSimilarityPercent(101)).toBe(100);
|
||||
expect(normalizeSimilarityPercent(72.9)).toBe(72);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSemanticDistanceThreshold', () => {
|
||||
it('converts minimum similarity to pipeline distance threshold', () => {
|
||||
expect(getSemanticDistanceThreshold(89)).toBe(0.11);
|
||||
expect(getSemanticDistanceThreshold(0)).toBe(1);
|
||||
expect(getSemanticDistanceThreshold(100)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeIntelligentSearchCandidates', () => {
|
||||
it('normalizes supported pipeline response shapes and score formats', () => {
|
||||
const results = normalizeIntelligentSearchCandidates(
|
||||
{
|
||||
results: [
|
||||
{ metadata: { room_id: 'r1', msg_id: 'm1', text: 'metadata text', score: 0.11 } },
|
||||
{ external_identifier: 'r2:m2', content: 'content text', similarity: 0.49 },
|
||||
{ id: 'm3', rid: 'r3', document: 'document text', distance: 12 },
|
||||
{ metadata: { room_id: 'r4', msg_id: 'm4', score: null, similarity: '' }, text: 'no numeric score' },
|
||||
{ text: 'missing ids' },
|
||||
],
|
||||
},
|
||||
[],
|
||||
10,
|
||||
);
|
||||
|
||||
expect(results).toEqual([
|
||||
{ _id: 'm1', rid: 'r1', msgId: 'm1', pipelineText: 'metadata text', score: 0.89 },
|
||||
{ _id: 'm2', rid: 'r2', msgId: 'm2', pipelineText: 'content text', score: 0.49 },
|
||||
{ _id: 'm3', rid: 'r3', msgId: 'm3', pipelineText: 'document text', score: 0.88 },
|
||||
{ _id: 'm4', rid: 'r4', msgId: 'm4', pipelineText: 'no numeric score' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters by subscribed room ids only when a prefilter is provided', () => {
|
||||
const rawResults = [
|
||||
{ metadata: { room_id: 'allowed', msg_id: 'm1' }, text: 'allowed' },
|
||||
{ metadata: { room_id: 'blocked', msg_id: 'm2' }, text: 'blocked' },
|
||||
];
|
||||
|
||||
expect(normalizeIntelligentSearchCandidates(rawResults, [], 10)).toHaveLength(2);
|
||||
expect(normalizeIntelligentSearchCandidates(rawResults, ['allowed'], 10)).toEqual([
|
||||
{ _id: 'm1', rid: 'allowed', msgId: 'm1', pipelineText: 'allowed' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('honors the requested candidate limit after filtering invalid results', () => {
|
||||
const results = normalizeIntelligentSearchCandidates(
|
||||
[{ text: 'invalid' }, { metadata: { room_id: 'r1', msg_id: 'm1' } }, { metadata: { room_id: 'r2', msg_id: 'm2' } }],
|
||||
[],
|
||||
1,
|
||||
);
|
||||
|
||||
expect(results).toEqual([{ _id: 'm1', rid: 'r1', msgId: 'm1', pipelineText: '' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildIntelligentSearchPipelineFilters', () => {
|
||||
it('returns undefined when no subscribed room ids are available', () => {
|
||||
expect(buildIntelligentSearchPipelineFilters([], {})).toBe(undefined);
|
||||
});
|
||||
|
||||
it('serializes room, user, and date filters for the pipeline', () => {
|
||||
const startDate = new Date('2026-01-01T00:00:00.000Z');
|
||||
const endDate = new Date('2026-01-31T23:59:59.000Z');
|
||||
|
||||
expect(
|
||||
buildIntelligentSearchPipelineFilters(['r1', 'r2', 'r3'], {
|
||||
rids: ['r1', 'r2'],
|
||||
fromUsername: '@alice',
|
||||
fromUsernames: ['bob', 'alice'],
|
||||
startDate,
|
||||
endDate,
|
||||
}),
|
||||
).toEqual({
|
||||
room_id: { $in: ['r1', 'r2'] },
|
||||
username: { $in: ['bob', 'alice'] },
|
||||
timestamp: { $ge: startDate.toISOString(), $le: endDate.toISOString() },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects explicit room filters that are not subscribed', () => {
|
||||
expect(buildIntelligentSearchPipelineFilters(['r1'], { rid: 'r2' })).toBe(undefined);
|
||||
});
|
||||
|
||||
it('passes broad subscribed room filters to the pipeline', () => {
|
||||
const roomIds = Array.from({ length: 1001 }, (_, index) => `r${index}`);
|
||||
|
||||
expect(buildIntelligentSearchPipelineFilters(roomIds, { fromUsername: 'alice' })).toEqual({
|
||||
room_id: { $in: roomIds },
|
||||
username: { $eq: 'alice' },
|
||||
});
|
||||
});
|
||||
|
||||
it('intersects explicit room filters while preserving their order', () => {
|
||||
const roomIds = Array.from({ length: 10_000 }, (_, index) => `r${index}`);
|
||||
|
||||
expect(buildIntelligentSearchPipelineFilters(roomIds, { rids: ['r9999', 'missing', 'r1'] })).toEqual({
|
||||
room_id: { $in: ['r9999', 'r1'] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchIntelligentPipeline', () => {
|
||||
it('sends the expected bounded request to the pipeline', async () => {
|
||||
let requestUrl = '';
|
||||
let requestBody = '';
|
||||
const fetch: AIServiceFetch = async (url, options) => {
|
||||
requestUrl = url;
|
||||
requestBody = String(options.body);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ results: [] }),
|
||||
text: async () => '',
|
||||
};
|
||||
};
|
||||
|
||||
const result = await searchIntelligentPipeline({
|
||||
query: 'fruit colors',
|
||||
config: {
|
||||
baseUrl: 'https://pipeline.example.com/',
|
||||
pipelineId: 'workspace pipeline',
|
||||
apiKey: 'key',
|
||||
apiKeySecret: 'secret',
|
||||
queryTemplate: 'semantic: {query}',
|
||||
minimumSimilarityPercent: 61,
|
||||
},
|
||||
classifications: ['user', 'admin'],
|
||||
pipelineFilters: { room_id: { $eq: 'r1' } },
|
||||
limit: 8,
|
||||
fetch,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ results: [] });
|
||||
expect(requestUrl).toBe('https://pipeline.example.com/pipelines/workspace%20pipeline/search');
|
||||
expect(JSON.parse(requestBody)).toEqual({
|
||||
query: 'semantic: fruit colors',
|
||||
type: 'similarity',
|
||||
classification: {
|
||||
classifications: ['user', 'admin'],
|
||||
search_type: 2,
|
||||
},
|
||||
filters: { room_id: { $eq: 'r1' } },
|
||||
params: {
|
||||
k: 8,
|
||||
threshold: 0.39,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty result set for non-2xx pipeline responses', async () => {
|
||||
const fetch: AIServiceFetch = async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({}),
|
||||
text: async () => 'failed',
|
||||
});
|
||||
|
||||
const result = await searchIntelligentPipeline({
|
||||
query: 'fruit colors',
|
||||
config: {
|
||||
baseUrl: 'https://pipeline.example.com',
|
||||
pipelineId: 'workspace',
|
||||
apiKey: 'key',
|
||||
apiKeySecret: 'secret',
|
||||
},
|
||||
classifications: ['user'],
|
||||
pipelineFilters: {},
|
||||
limit: 5,
|
||||
fetch,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
286
packages/ai-search/src/intelligentSearch.ts
Normal file
286
packages/ai-search/src/intelligentSearch.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { MAX_AI_SERVICE_RESPONSE_SIZE } from './constants';
|
||||
import type {
|
||||
AIServiceFetch,
|
||||
AIServiceLogger,
|
||||
IntelligentSearchCandidate,
|
||||
IntelligentSearchFilters,
|
||||
IntelligentSearchPipelineFilters,
|
||||
IntelligentSearchPipelineRequest,
|
||||
} from './types';
|
||||
import { getErrorType } from './utils';
|
||||
|
||||
const buildEndpointUrl = (baseUrl: string, path: string): string =>
|
||||
new URL(path, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString();
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});
|
||||
|
||||
const firstString = (...values: unknown[]): string | undefined => {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string' && value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const firstNumber = (...values: unknown[]): number | undefined => {
|
||||
for (const value of values) {
|
||||
if (typeof value !== 'number' && typeof value !== 'string') {
|
||||
continue;
|
||||
}
|
||||
if (typeof value === 'string' && !value.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const numberValue = Number(value);
|
||||
if (Number.isFinite(numberValue)) {
|
||||
return numberValue;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const normalizeSimilarityPercent = (value: unknown): number => {
|
||||
const numeric = Number(value);
|
||||
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(100, Math.max(0, Math.floor(numeric)));
|
||||
};
|
||||
|
||||
export const getSemanticDistanceThreshold = (minimumSimilarityPercent: number): number =>
|
||||
Number((1 - minimumSimilarityPercent / 100).toFixed(4));
|
||||
|
||||
// pipeline contract: `score`/`distance` are cosine distances (lower is better, similarity = 1 - distance)
|
||||
const normalizePipelineSimilarityScore = (value: number, type: 'distance' | 'similarity'): number => {
|
||||
const normalizedValue = Math.abs(value) > 1 ? value / 100 : value;
|
||||
const similarity = type === 'distance' ? 1 - normalizedValue : normalizedValue;
|
||||
|
||||
return Math.min(1, Math.max(0, similarity));
|
||||
};
|
||||
|
||||
const extractPipelineSimilarityScore = (result: Record<string, unknown>, metadata: Record<string, unknown>): number | undefined => {
|
||||
const similarity = firstNumber(result.similarity, metadata.similarity);
|
||||
if (typeof similarity === 'number') {
|
||||
return normalizePipelineSimilarityScore(similarity, 'similarity');
|
||||
}
|
||||
|
||||
const distance = firstNumber(result.score, result.distance, metadata.score, metadata.distance);
|
||||
if (typeof distance === 'number') {
|
||||
return normalizePipelineSimilarityScore(distance, 'distance');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const extractIntelligentResultIds = (result: Record<string, unknown>): { rid?: string; msgId?: string } => {
|
||||
const metadata = asRecord(result.metadata);
|
||||
let rid = firstString(metadata.room_id, metadata.rid, result.room_id, result.rid);
|
||||
let msgId = firstString(metadata.msg_id, metadata.message_id, result.msg_id, result.message_id, result.id);
|
||||
const externalIdentifier = firstString(result.external_identifier);
|
||||
|
||||
if ((!rid || !msgId) && externalIdentifier) {
|
||||
const separator = externalIdentifier.indexOf(':');
|
||||
if (separator > 0 && separator < externalIdentifier.length - 1) {
|
||||
rid = rid || externalIdentifier.slice(0, separator);
|
||||
msgId = msgId || externalIdentifier.slice(separator + 1);
|
||||
} else {
|
||||
msgId = msgId || externalIdentifier;
|
||||
}
|
||||
}
|
||||
|
||||
return { rid, msgId };
|
||||
};
|
||||
|
||||
export const normalizeIntelligentSearchCandidates = (
|
||||
rawSearchResults: unknown,
|
||||
userRoomIds: string[] = [],
|
||||
limit: number,
|
||||
logger?: AIServiceLogger,
|
||||
): IntelligentSearchCandidate[] => {
|
||||
let rawResults: unknown[] = [];
|
||||
const rawSearchResultsRecord = asRecord(rawSearchResults);
|
||||
|
||||
if (Array.isArray(rawSearchResults)) {
|
||||
rawResults = rawSearchResults;
|
||||
} else if (Array.isArray(rawSearchResultsRecord.results)) {
|
||||
rawResults = rawSearchResultsRecord.results;
|
||||
} else if (Array.isArray(rawSearchResultsRecord.context)) {
|
||||
rawResults = rawSearchResultsRecord.context;
|
||||
} else if (Array.isArray(rawSearchResultsRecord.documents)) {
|
||||
rawResults = rawSearchResultsRecord.documents;
|
||||
} else if (Array.isArray(rawSearchResultsRecord.hits)) {
|
||||
rawResults = rawSearchResultsRecord.hits;
|
||||
} else if (Array.isArray(rawSearchResultsRecord.data)) {
|
||||
rawResults = rawSearchResultsRecord.data;
|
||||
}
|
||||
|
||||
logger?.debug?.({
|
||||
msg: 'Intelligent search normalizing results',
|
||||
rawCount: rawResults.length,
|
||||
rawKeys: Object.keys(rawSearchResultsRecord),
|
||||
});
|
||||
|
||||
const userRoomIdSet = new Set(userRoomIds);
|
||||
const shouldFilterByRoomIds = userRoomIdSet.size > 0;
|
||||
|
||||
const candidates: IntelligentSearchCandidate[] = [];
|
||||
for (let index = 0; index < rawResults.length && candidates.length < limit; index++) {
|
||||
const result = asRecord(rawResults[index]);
|
||||
const metadata = asRecord(result.metadata);
|
||||
const { rid, msgId } = extractIntelligentResultIds(result);
|
||||
if (!msgId && !rid) {
|
||||
continue;
|
||||
}
|
||||
if (shouldFilterByRoomIds && rid && !userRoomIdSet.has(rid)) {
|
||||
logger?.debug?.({ msg: 'Intelligent search result filtered: room not in user subscriptions', rid });
|
||||
continue;
|
||||
}
|
||||
|
||||
const score = extractPipelineSimilarityScore(result, metadata);
|
||||
candidates.push({
|
||||
_id: msgId || `intelligent-${index}`,
|
||||
rid,
|
||||
msgId,
|
||||
pipelineText: firstString(result.text, result.content, result.document, result.page_content, metadata.text) || '',
|
||||
...(typeof score === 'number' && { score }),
|
||||
});
|
||||
}
|
||||
|
||||
logger?.debug?.({ msg: 'Intelligent search after filter', candidateCount: candidates.length });
|
||||
|
||||
return candidates;
|
||||
};
|
||||
|
||||
export const buildIntelligentSearchPipelineFilters = (
|
||||
userRoomIds: string[],
|
||||
{ rid, rids, fromUsername, fromUsernames, startDate, endDate }: Omit<IntelligentSearchFilters, 'roomNames'>,
|
||||
): IntelligentSearchPipelineFilters | undefined => {
|
||||
if (!userRoomIds.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const requestedRoomIdSet = new Set(rids);
|
||||
if (rid) {
|
||||
requestedRoomIdSet.add(rid);
|
||||
}
|
||||
const requestedRoomIds = [...requestedRoomIdSet];
|
||||
let subscribedRoomIds = userRoomIds;
|
||||
if (requestedRoomIds.length) {
|
||||
const matchedRoomIds = new Set<string>();
|
||||
for (const roomId of userRoomIds) {
|
||||
if (requestedRoomIdSet.has(roomId)) {
|
||||
matchedRoomIds.add(roomId);
|
||||
if (matchedRoomIds.size === requestedRoomIdSet.size) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
subscribedRoomIds = requestedRoomIds.filter((roomId) => matchedRoomIds.has(roomId));
|
||||
}
|
||||
const filters: IntelligentSearchPipelineFilters = {};
|
||||
|
||||
if (requestedRoomIds.length && !subscribedRoomIds.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
filters.room_id = subscribedRoomIds.length === 1 ? { $eq: subscribedRoomIds[0] } : { $in: subscribedRoomIds };
|
||||
|
||||
const usernameSet = new Set<string>();
|
||||
for (const username of fromUsernames || []) {
|
||||
const normalizedUsername = username.replace(/^@/, '');
|
||||
if (normalizedUsername) {
|
||||
usernameSet.add(normalizedUsername);
|
||||
}
|
||||
}
|
||||
if (fromUsername) {
|
||||
const normalizedUsername = fromUsername.replace(/^@/, '');
|
||||
if (normalizedUsername) {
|
||||
usernameSet.add(normalizedUsername);
|
||||
}
|
||||
}
|
||||
const usernames = [...usernameSet];
|
||||
if (usernames.length === 1) {
|
||||
filters.username = { $eq: usernames[0] };
|
||||
} else if (usernames.length > 1) {
|
||||
filters.username = { $in: usernames };
|
||||
}
|
||||
|
||||
if (startDate || endDate) {
|
||||
filters.timestamp = {
|
||||
...(startDate && { $ge: startDate.toISOString() }),
|
||||
...(endDate && { $le: endDate.toISOString() }),
|
||||
};
|
||||
}
|
||||
|
||||
return filters;
|
||||
};
|
||||
|
||||
export const searchIntelligentPipeline = async ({
|
||||
query,
|
||||
config,
|
||||
classifications,
|
||||
pipelineFilters,
|
||||
limit,
|
||||
fetch,
|
||||
logger,
|
||||
}: IntelligentSearchPipelineRequest): Promise<unknown> => {
|
||||
const minimumSimilarity = normalizeSimilarityPercent(config.minimumSimilarityPercent);
|
||||
const formattedQuery = config.queryTemplate ? config.queryTemplate.replace('{query}', query) : query;
|
||||
const url = buildEndpointUrl(config.baseUrl, `pipelines/${encodeURIComponent(config.pipelineId)}/search`);
|
||||
|
||||
logger?.debug?.({
|
||||
msg: 'Intelligent search request',
|
||||
url,
|
||||
queryLength: formattedQuery.length,
|
||||
hasQueryTemplate: Boolean(config.queryTemplate),
|
||||
filterKeys: Object.keys(pipelineFilters),
|
||||
classificationCount: classifications.length,
|
||||
threshold: getSemanticDistanceThreshold(minimumSimilarity),
|
||||
});
|
||||
|
||||
let response: Awaited<ReturnType<AIServiceFetch>>;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: 'POST',
|
||||
timeout: 10000,
|
||||
size: MAX_AI_SERVICE_RESPONSE_SIZE,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-API-KEY': config.apiKey,
|
||||
'X-API-KEY-SECRET': config.apiKeySecret,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: formattedQuery,
|
||||
type: 'similarity',
|
||||
classification: {
|
||||
classifications,
|
||||
search_type: 2,
|
||||
},
|
||||
filters: pipelineFilters,
|
||||
params: {
|
||||
k: limit,
|
||||
threshold: getSemanticDistanceThreshold(minimumSimilarity),
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch (fetchError: unknown) {
|
||||
logger?.warn?.({ msg: 'Intelligent search fetch failed', url, errorType: getErrorType(fetchError) });
|
||||
throw fetchError;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
logger?.warn?.({ msg: 'Intelligent search pipeline returned error', url, status: response.status, bodyLength: body.length });
|
||||
return [];
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
logger?.debug?.({ msg: 'Intelligent search raw response received', resultKeys: Object.keys(asRecord(json)) });
|
||||
return json;
|
||||
};
|
||||
232
packages/ai-search/src/llm.spec.ts
Normal file
232
packages/ai-search/src/llm.spec.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { buildSearchAnswerPrompt, generateOpenAICompatibleSearchAnswer, listOpenAICompatibleModels } from './llm';
|
||||
import type { AIServiceFetch } from './types';
|
||||
|
||||
describe('AI Search LLM helpers', () => {
|
||||
describe('buildSearchAnswerPrompt', () => {
|
||||
it('limits messages and truncates source text deterministically', () => {
|
||||
const prompt = buildSearchAnswerPrompt(
|
||||
'What did we decide?',
|
||||
[
|
||||
{
|
||||
text: 'A'.repeat(12),
|
||||
username: 'alice',
|
||||
roomName: 'general',
|
||||
ts: '2026-01-01T00:00:00.000Z',
|
||||
score: 0.615,
|
||||
},
|
||||
{ text: 'second result', username: 'bob' },
|
||||
{ text: 'third result' },
|
||||
],
|
||||
{ maxMessages: 2, maxTextLength: 5 },
|
||||
);
|
||||
|
||||
expect(prompt).toBe(
|
||||
[
|
||||
'User question (untrusted):\nWhat did we decide?',
|
||||
'Source messages (untrusted):',
|
||||
'[1] from @alice, in #general, at 2026-01-01T00:00:00.000Z, score 62%\nAAAAA',
|
||||
'[2] from @bob\nsecon',
|
||||
'Answer the question using only the source messages above and cite supporting sources as [N].',
|
||||
].join('\n\n'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateOpenAICompatibleSearchAnswer', () => {
|
||||
it('calls chat completions and returns the selected answer with provider metadata', async () => {
|
||||
let requestUrl = '';
|
||||
let requestBody = '';
|
||||
const fetch: AIServiceFetch = async (url, options) => {
|
||||
requestUrl = url;
|
||||
requestBody = String(options.body);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ choices: [{ message: { content: ' Answer from sources. ' } }] }),
|
||||
text: async () => '',
|
||||
};
|
||||
};
|
||||
|
||||
const result = await generateOpenAICompatibleSearchAnswer({
|
||||
query: 'fruit colors',
|
||||
messages: [{ text: 'oranges are green', username: 'alice' }],
|
||||
provider: {
|
||||
name: 'OpenAI compatible',
|
||||
baseUrl: 'https://llm.example.com/',
|
||||
apiKey: 'secret',
|
||||
model: 'gpt-test',
|
||||
},
|
||||
systemPrompt: 'Use sources only.',
|
||||
fetch,
|
||||
maxMessages: 4,
|
||||
maxTextLength: 200,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ answer: 'Answer from sources.', provider: { name: 'OpenAI compatible', model: 'gpt-test' } });
|
||||
expect(requestUrl).toBe('https://llm.example.com/chat/completions');
|
||||
expect(JSON.parse(requestBody)).toMatchObject({
|
||||
model: 'gpt-test',
|
||||
temperature: 0.2,
|
||||
});
|
||||
expect(JSON.parse(requestBody).messages[0]).toEqual({ role: 'system', content: 'Use sources only.' });
|
||||
});
|
||||
|
||||
it('normalizes provider-specific source markers to numbered citations', async () => {
|
||||
const fetch: AIServiceFetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ choices: [{ message: { content: 'Supported by both sources.【1†L1-L2】【2†L4-L5】' } }] }),
|
||||
text: async () => '',
|
||||
});
|
||||
|
||||
await expect(
|
||||
generateOpenAICompatibleSearchAnswer({
|
||||
query: 'What happened?',
|
||||
messages: [{ text: 'First source' }, { text: 'Second source' }],
|
||||
provider: { name: 'OpenAI compatible', baseUrl: 'https://llm.example.com', apiKey: 'secret', model: 'gpt-test' },
|
||||
systemPrompt: 'Use numbered citations.',
|
||||
fetch,
|
||||
maxMessages: 4,
|
||||
maxTextLength: 200,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
answer: 'Supported by both sources. [1] [2]',
|
||||
provider: { name: 'OpenAI compatible', model: 'gpt-test' },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on provider failures and empty responses', async () => {
|
||||
const failedFetch: AIServiceFetch = async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({}),
|
||||
text: async () => 'failed',
|
||||
});
|
||||
|
||||
await expect(
|
||||
generateOpenAICompatibleSearchAnswer({
|
||||
query: 'fruit colors',
|
||||
messages: [{ text: 'oranges are green' }],
|
||||
provider: { name: 'OpenAI compatible', baseUrl: 'https://llm.example.com', apiKey: 'secret', model: 'gpt-test' },
|
||||
systemPrompt: 'Use sources only.',
|
||||
fetch: failedFetch,
|
||||
maxMessages: 4,
|
||||
maxTextLength: 200,
|
||||
}),
|
||||
).rejects.toThrow('error-ai-provider-request-failed');
|
||||
|
||||
const emptyFetch: AIServiceFetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ choices: [{ message: { content: ' ' } }] }),
|
||||
text: async () => '',
|
||||
});
|
||||
|
||||
await expect(
|
||||
generateOpenAICompatibleSearchAnswer({
|
||||
query: 'fruit colors',
|
||||
messages: [{ text: 'oranges are green' }],
|
||||
provider: { name: 'OpenAI compatible', baseUrl: 'https://llm.example.com', apiKey: 'secret', model: 'gpt-test' },
|
||||
systemPrompt: 'Use sources only.',
|
||||
fetch: emptyFetch,
|
||||
maxMessages: 4,
|
||||
maxTextLength: 200,
|
||||
}),
|
||||
).rejects.toThrow('error-ai-provider-empty-response');
|
||||
});
|
||||
|
||||
it('normalizes malformed provider JSON responses as request failures', async () => {
|
||||
const malformedFetch: AIServiceFetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new SyntaxError('Unexpected token');
|
||||
},
|
||||
text: async () => '',
|
||||
});
|
||||
|
||||
await expect(
|
||||
generateOpenAICompatibleSearchAnswer({
|
||||
query: 'fruit colors',
|
||||
messages: [{ text: 'oranges are green' }],
|
||||
provider: { name: 'OpenAI compatible', baseUrl: 'https://llm.example.com', apiKey: 'secret', model: 'gpt-test' },
|
||||
systemPrompt: 'Use sources only.',
|
||||
fetch: malformedFetch,
|
||||
maxMessages: 4,
|
||||
maxTextLength: 200,
|
||||
}),
|
||||
).rejects.toThrow('error-ai-provider-request-failed');
|
||||
});
|
||||
|
||||
it('rejects valid JSON with an invalid chat completion shape', async () => {
|
||||
const invalidResponseFetch: AIServiceFetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ choices: [{ message: { content: 42 } }] }),
|
||||
text: async () => '',
|
||||
});
|
||||
|
||||
await expect(
|
||||
generateOpenAICompatibleSearchAnswer({
|
||||
query: 'fruit colors',
|
||||
messages: [{ text: 'oranges are green' }],
|
||||
provider: { name: 'OpenAI compatible', baseUrl: 'https://llm.example.com', apiKey: 'secret', model: 'gpt-test' },
|
||||
systemPrompt: 'Use sources only.',
|
||||
fetch: invalidResponseFetch,
|
||||
maxMessages: 4,
|
||||
maxTextLength: 200,
|
||||
}),
|
||||
).rejects.toThrow('error-ai-provider-empty-response');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listOpenAICompatibleModels', () => {
|
||||
it('returns the configured model when provider lookup is not configured', async () => {
|
||||
const fetch: AIServiceFetch = async () => {
|
||||
throw new Error('must not fetch');
|
||||
};
|
||||
|
||||
expect(await listOpenAICompatibleModels({ selectedModel: 'existing-model', fetch })).toEqual([
|
||||
{ key: 'existing-model', label: 'existing-model' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts provider models and preserves the selected custom model', async () => {
|
||||
const fetch: AIServiceFetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: [{ id: 'z-model' }, { id: 'a-model' }, { id: 'a-model' }, { id: 42 }, null] }),
|
||||
text: async () => '',
|
||||
});
|
||||
|
||||
expect(
|
||||
await listOpenAICompatibleModels({
|
||||
provider: { baseUrl: 'https://llm.example.com', apiKey: 'secret' },
|
||||
selectedModel: 'custom-model',
|
||||
fetch,
|
||||
}),
|
||||
).toEqual([
|
||||
{ key: 'custom-model', label: 'custom-model' },
|
||||
{ key: 'a-model', label: 'a-model' },
|
||||
{ key: 'z-model', label: 'z-model' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the selected model when provider lookup fails', async () => {
|
||||
const fetch: AIServiceFetch = async () => ({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({}),
|
||||
text: async () => 'unauthorized',
|
||||
});
|
||||
|
||||
expect(
|
||||
await listOpenAICompatibleModels({
|
||||
provider: { baseUrl: 'https://llm.example.com', apiKey: 'secret' },
|
||||
selectedModel: 'configured-model',
|
||||
fetch,
|
||||
}),
|
||||
).toEqual([{ key: 'configured-model', label: 'configured-model' }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
176
packages/ai-search/src/llm.ts
Normal file
176
packages/ai-search/src/llm.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { MAX_AI_SERVICE_RESPONSE_SIZE } from './constants';
|
||||
import type { AIServiceFetch, AIServiceLogger, OpenAICompatibleProviderConfig, SearchAnswerMessage, SearchAnswerResult } from './types';
|
||||
import { getErrorType } from './utils';
|
||||
|
||||
const buildEndpointUrl = (baseUrl: string, path: string): string =>
|
||||
new URL(path, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString();
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const getChatCompletionContent = (value: unknown): string | undefined => {
|
||||
if (!isRecord(value) || !Array.isArray(value.choices)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const firstChoice = value.choices[0];
|
||||
if (!isRecord(firstChoice) || !isRecord(firstChoice.message) || typeof firstChoice.message.content !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return firstChoice.message.content.trim() || undefined;
|
||||
};
|
||||
|
||||
const getModelIds = (value: unknown): string[] => {
|
||||
if (!isRecord(value) || !Array.isArray(value.data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const modelIds = new Set<string>();
|
||||
for (const model of value.data) {
|
||||
if (isRecord(model) && typeof model.id === 'string' && model.id) {
|
||||
modelIds.add(model.id);
|
||||
}
|
||||
}
|
||||
|
||||
return [...modelIds].sort((a, b) => a.localeCompare(b));
|
||||
};
|
||||
|
||||
const normalizeSearchAnswerCitations = (answer: string): string =>
|
||||
answer.replace(/【\s*(\d+)(?:†[^】\r\n]*)?\s*】/g, (_marker, sourceNumber: string, offset: number) => {
|
||||
const leadingSpace = offset > 0 && !/\s/.test(answer[offset - 1]) ? ' ' : '';
|
||||
return `${leadingSpace}[${sourceNumber}]`;
|
||||
});
|
||||
|
||||
export const buildSearchAnswerPrompt = (
|
||||
query: string,
|
||||
messages: SearchAnswerMessage[],
|
||||
options: {
|
||||
maxMessages: number;
|
||||
maxTextLength: number;
|
||||
},
|
||||
): string =>
|
||||
[
|
||||
`User question (untrusted):\n${query}`,
|
||||
'Source messages (untrusted):',
|
||||
...messages.slice(0, options.maxMessages).map((message, index) => {
|
||||
const metadata = [
|
||||
message.username && `from @${message.username}`,
|
||||
message.roomName && `in #${message.roomName}`,
|
||||
message.ts && `at ${message.ts}`,
|
||||
typeof message.score === 'number' && `score ${Math.round(message.score * 100)}%`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
return `[${index + 1}]${metadata ? ` ${metadata}` : ''}\n${message.text.slice(0, options.maxTextLength)}`;
|
||||
}),
|
||||
'Answer the question using only the source messages above and cite supporting sources as [N].',
|
||||
].join('\n\n');
|
||||
|
||||
export const generateOpenAICompatibleSearchAnswer = async ({
|
||||
query,
|
||||
messages,
|
||||
provider,
|
||||
systemPrompt,
|
||||
fetch,
|
||||
logger,
|
||||
maxMessages,
|
||||
maxTextLength,
|
||||
}: {
|
||||
query: string;
|
||||
messages: SearchAnswerMessage[];
|
||||
provider: OpenAICompatibleProviderConfig;
|
||||
systemPrompt: string;
|
||||
fetch: AIServiceFetch;
|
||||
logger?: AIServiceLogger;
|
||||
maxMessages: number;
|
||||
maxTextLength: number;
|
||||
}): Promise<SearchAnswerResult> => {
|
||||
try {
|
||||
const response = await fetch(buildEndpointUrl(provider.baseUrl, 'chat/completions'), {
|
||||
method: 'POST',
|
||||
timeout: 20000,
|
||||
size: MAX_AI_SERVICE_RESPONSE_SIZE,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${provider.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: provider.model,
|
||||
temperature: 0.2,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: buildSearchAnswerPrompt(query, messages, { maxMessages, maxTextLength }) },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
logger?.warn?.({ msg: 'Search answer LLM provider returned error', status: response.status, bodyLength: body.length });
|
||||
throw new Error('error-ai-provider-request-failed');
|
||||
}
|
||||
|
||||
const answer = getChatCompletionContent(await response.json());
|
||||
if (!answer) {
|
||||
throw new Error('error-ai-provider-empty-response');
|
||||
}
|
||||
|
||||
return { answer: normalizeSearchAnswerCitations(answer), provider: { name: provider.name, model: provider.model } };
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.message === 'error-ai-provider-empty-response' || error.message === 'error-ai-provider-request-failed')
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger?.warn?.({ msg: 'Search answer LLM provider request failed', errorType: getErrorType(error) });
|
||||
throw new Error('error-ai-provider-request-failed', { cause: error });
|
||||
}
|
||||
};
|
||||
|
||||
export const listOpenAICompatibleModels = async ({
|
||||
provider,
|
||||
selectedModel,
|
||||
fetch,
|
||||
logger,
|
||||
}: {
|
||||
provider?: Pick<OpenAICompatibleProviderConfig, 'baseUrl' | 'apiKey'>;
|
||||
selectedModel?: string;
|
||||
fetch: AIServiceFetch;
|
||||
logger?: AIServiceLogger;
|
||||
}): Promise<{ key: string; label: string }[]> => {
|
||||
const fallback = selectedModel ? [{ key: selectedModel, label: selectedModel }] : [];
|
||||
if (!provider?.baseUrl || !provider.apiKey) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(buildEndpointUrl(provider.baseUrl, 'models'), {
|
||||
method: 'GET',
|
||||
timeout: 10000,
|
||||
size: MAX_AI_SERVICE_RESPONSE_SIZE,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${provider.apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger?.warn?.({ msg: 'AI LLM model lookup failed', status: response.status });
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const modelIds = getModelIds(await response.json());
|
||||
|
||||
if (selectedModel && !modelIds.includes(selectedModel)) {
|
||||
modelIds.unshift(selectedModel);
|
||||
}
|
||||
|
||||
return modelIds.map((id) => ({ key: id, label: id }));
|
||||
} catch (error) {
|
||||
logger?.warn?.({ msg: 'AI LLM model lookup request failed', errorType: getErrorType(error) });
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
81
packages/ai-search/src/types.ts
Normal file
81
packages/ai-search/src/types.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
export type AIServiceFetchResponse = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json(): Promise<unknown>;
|
||||
text(): Promise<string>;
|
||||
};
|
||||
|
||||
export type AIServiceFetch = (
|
||||
url: string,
|
||||
options: {
|
||||
method: string;
|
||||
timeout?: number;
|
||||
size?: number;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
},
|
||||
) => Promise<AIServiceFetchResponse>;
|
||||
|
||||
export type AIServiceLogger = {
|
||||
debug?(payload: Record<string, unknown>): void;
|
||||
warn?(payload: Record<string, unknown>): void;
|
||||
};
|
||||
|
||||
export type OpenAICompatibleProviderConfig = {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
export type SearchAnswerMessage = {
|
||||
text: string;
|
||||
username?: string;
|
||||
roomName?: string;
|
||||
ts?: string;
|
||||
score?: number;
|
||||
};
|
||||
|
||||
export type SearchAnswerResult = {
|
||||
answer: string;
|
||||
provider: Pick<OpenAICompatibleProviderConfig, 'name' | 'model'>;
|
||||
};
|
||||
|
||||
export type IntelligentSearchPipelineConfig = {
|
||||
baseUrl: string;
|
||||
pipelineId: string;
|
||||
apiKey: string;
|
||||
apiKeySecret: string;
|
||||
queryTemplate?: string;
|
||||
minimumSimilarityPercent?: number;
|
||||
};
|
||||
|
||||
export type IntelligentSearchFilters = {
|
||||
rid?: string;
|
||||
rids?: string[];
|
||||
roomNames?: string[];
|
||||
fromUsername?: string;
|
||||
fromUsernames?: string[];
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
};
|
||||
|
||||
export type IntelligentSearchPipelineFilters = Record<string, unknown>;
|
||||
|
||||
export type IntelligentSearchCandidate = {
|
||||
_id: string;
|
||||
rid?: string;
|
||||
msgId?: string;
|
||||
pipelineText: string;
|
||||
score?: number;
|
||||
};
|
||||
|
||||
export type IntelligentSearchPipelineRequest = {
|
||||
query: string;
|
||||
config: IntelligentSearchPipelineConfig;
|
||||
classifications: string[];
|
||||
pipelineFilters: IntelligentSearchPipelineFilters;
|
||||
limit: number;
|
||||
fetch: AIServiceFetch;
|
||||
logger?: AIServiceLogger;
|
||||
};
|
||||
1
packages/ai-search/src/utils.ts
Normal file
1
packages/ai-search/src/utils.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const getErrorType = (error: unknown): string => (error instanceof Error ? error.name : typeof error);
|
||||
9
packages/ai-search/tsconfig.json
Normal file
9
packages/ai-search/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@rocket.chat/tsconfig/server.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["./src/**/*"]
|
||||
}
|
||||
2732
packages/api-client/CHANGELOG.md
Normal file
2732
packages/api-client/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
43
packages/api-client/LICENSE
Normal file
43
packages/api-client/LICENSE
Normal file
@@ -0,0 +1,43 @@
|
||||
The Rocket.Chat Enterprise Edition (EE) license (the "EE License")
|
||||
Copyright (c) 2015-2023 Rocket.Chat Technologies Corp.
|
||||
|
||||
With regard to the Rocket.Chat Software:
|
||||
|
||||
This software and associated documentation files (the "Software") may only be
|
||||
used in production, if you (and any entity that you represent) have agreed to,
|
||||
and are in compliance with, the Rocket.Chat Subscription Terms of Service,
|
||||
available at https://rocket.chat/terms (the "EE Terms"), or other agreement
|
||||
governing the use of the Software, as agreed by you and Rocket.Chat, and otherwise
|
||||
have a valid Rocket.Chat Enterprise Edition subscription for the correct number
|
||||
of user seats. Subject to the foregoing sentence, you are free to modify this
|
||||
Software and publish patches to the Software. You agree that Rocket.Chat and/or
|
||||
its licensors (as applicable) retain all right, title and interest in and to all
|
||||
such modifications and/or patches, and all such modifications and/or patches may
|
||||
only be used, copied, modified, displayed, distributed, or otherwise exploited
|
||||
with a valid Rocket.Chat Enterprise Edition subscription for the correct number
|
||||
of user seats. Notwithstanding the foregoing, you may copy and modify the Software
|
||||
for development and testing purposes, without requiring a Subscription. You agree
|
||||
that Rocket.Chat and/or its licensors (as applicable) retain all right, title and
|
||||
interest in and to all such modifications. You are not granted any other rights
|
||||
beyond what is expressly stated herein. Subject to the foregoing, it is forbidden
|
||||
to copy, merge, publish, distribute, sublicense, and/or sell the Software.
|
||||
|
||||
This EE License applies only to the part of this Software that is not distributed
|
||||
as part of Rocket.Chat Community Edition (CE). Any part of this Software distributed
|
||||
as part of Rocket.Chat CE or is served client-side as an image, font, cascading
|
||||
stylesheet (CSS), file which produces or is compiled, arranged, augmented, or combined
|
||||
into client-side JavaScript, in whole or in part, is copyrighted under the MIT Expat
|
||||
license. The full text of this EE License shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
For all third-party components incorporated into the Rocket.Chat Software,
|
||||
those components are licensed under the original license provided by the owner
|
||||
of the applicable component.
|
||||
163
packages/api-client/__tests__/2fahandling.spec.ts
Normal file
163
packages/api-client/__tests__/2fahandling.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import fetchMock from 'jest-fetch-mock';
|
||||
|
||||
import { RestClient } from '../src/index';
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock.enableMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fetchMock.disableMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockIf(/^https?:\/\/example.com.*$/, async (req) => {
|
||||
if (req.headers.get('x-2fa-code') === '2FA_CODE') {
|
||||
return {
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
status: 'success',
|
||||
data: {
|
||||
userId: 'foo',
|
||||
email: 'foo',
|
||||
username: 'foo',
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (req.headers.get('x-2fa-code') === 'WRONG_2FA_CODE') {
|
||||
return {
|
||||
status: 400,
|
||||
body: JSON.stringify({
|
||||
errorType: 'totp-invalid',
|
||||
message: 'Invalid TOTP provided',
|
||||
details: {
|
||||
method: 'totp',
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 400,
|
||||
body: JSON.stringify({
|
||||
errorType: 'totp-required',
|
||||
details: {
|
||||
method: 'totp',
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
fetchMock.doMock();
|
||||
});
|
||||
|
||||
const isResponse = (e: any): e is Response => {
|
||||
expect(e).toBeInstanceOf(Response);
|
||||
return true;
|
||||
};
|
||||
|
||||
test('if the 2fa handler is not provided, it should throw an error', async () => {
|
||||
const client = new RestClient({
|
||||
baseUrl: 'https://example.com',
|
||||
});
|
||||
|
||||
try {
|
||||
await client.post('/v1/login', { user: 'foo', username: 'foo', email: 'foo', password: 'foo', code: 'foo' });
|
||||
} catch (error) {
|
||||
if (!isResponse(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
expect(error.status).toBe(400);
|
||||
|
||||
const body = await error.json();
|
||||
|
||||
expect(body).toMatchObject({
|
||||
errorType: 'totp-required',
|
||||
details: {
|
||||
method: 'totp',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('if the 2fa handler is provided, and fails if should throw the error thrown by the handler', async () => {
|
||||
const fn = jest.fn();
|
||||
|
||||
const client = new RestClient({
|
||||
baseUrl: 'https://example.com',
|
||||
});
|
||||
|
||||
client.handleTwoFactorChallenge((e) => {
|
||||
fn(e);
|
||||
|
||||
throw new Error('foo');
|
||||
});
|
||||
|
||||
await expect(client.post('/v1/login', { user: 'foo', username: 'foo', email: 'foo', password: 'foo', code: 'foo' })).rejects.toThrow(
|
||||
new Error('foo'),
|
||||
);
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('if the 2fa handler is provided it should resolves', async () => {
|
||||
const fn = jest.fn();
|
||||
|
||||
const client = new RestClient({
|
||||
baseUrl: 'https://example.com',
|
||||
});
|
||||
|
||||
client.handleTwoFactorChallenge(() => {
|
||||
fn();
|
||||
return Promise.resolve('2FA_CODE');
|
||||
});
|
||||
|
||||
const result = await client.post('/v1/login', { user: 'foo', username: 'foo', email: 'foo', password: 'foo', code: 'foo' });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'success',
|
||||
data: {
|
||||
userId: 'foo',
|
||||
email: 'foo',
|
||||
username: 'foo',
|
||||
},
|
||||
});
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should be ask for 2fa code again if the code is wrong', async () => {
|
||||
const fn = jest.fn();
|
||||
|
||||
const client = new RestClient({
|
||||
baseUrl: 'https://example.com',
|
||||
});
|
||||
|
||||
let retries = 0;
|
||||
|
||||
client.handleTwoFactorChallenge(() => {
|
||||
fn();
|
||||
|
||||
if (!retries) {
|
||||
retries++;
|
||||
return Promise.resolve('WRONG_2FA_CODE');
|
||||
}
|
||||
|
||||
return Promise.resolve('2FA_CODE');
|
||||
});
|
||||
|
||||
const result = await client.post('/v1/login', { user: 'foo', username: 'foo', email: 'foo', password: 'foo', code: 'foo' });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'success',
|
||||
data: {
|
||||
userId: 'foo',
|
||||
email: 'foo',
|
||||
username: 'foo',
|
||||
},
|
||||
});
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
7
packages/api-client/jest.config.ts
Normal file
7
packages/api-client/jest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import client from '@rocket.chat/jest-presets/client';
|
||||
import type { Config } from 'jest';
|
||||
|
||||
export default {
|
||||
preset: client.preset,
|
||||
setupFilesAfterEnv: [...client.setupFilesAfterEnv],
|
||||
} satisfies Config;
|
||||
43
packages/api-client/package.json
Normal file
43
packages/api-client/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@rocket.chat/api-client",
|
||||
"version": "0.2.56",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/RocketChat/Rocket.Chat.git",
|
||||
"directory": "packages/api-client"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"typings": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"/dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch --preserveWatchOutput",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"test": "jest",
|
||||
"testunit": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rocket.chat/core-typings": "workspace:^",
|
||||
"@rocket.chat/rest-typings": "workspace:^",
|
||||
"filter-obj": "^3.0.0",
|
||||
"query-string": "^7.1.3",
|
||||
"split-on-first": "^3.0.0",
|
||||
"strict-uri-encode": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rocket.chat/jest-presets": "workspace:~",
|
||||
"@rocket.chat/tsconfig": "workspace:*",
|
||||
"@types/jest": "~30.0.0",
|
||||
"@types/strict-uri-encode": "^2.0.2",
|
||||
"eslint": "~9.39.5",
|
||||
"jest": "~30.2.0",
|
||||
"jest-fetch-mock": "~3.0.3",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"volta": {
|
||||
"extends": "../../package.json"
|
||||
}
|
||||
}
|
||||
4
packages/api-client/src/Credentials.ts
Normal file
4
packages/api-client/src/Credentials.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export type Credentials = {
|
||||
'X-User-Id': string;
|
||||
'X-Auth-Token': string;
|
||||
};
|
||||
92
packages/api-client/src/RestClientInterface.ts
Normal file
92
packages/api-client/src/RestClientInterface.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { Serialized } from '@rocket.chat/core-typings';
|
||||
import type {
|
||||
MatchPathPattern,
|
||||
OperationParams,
|
||||
OperationResult,
|
||||
PathFor,
|
||||
PathWithParamsFor,
|
||||
PathWithoutParamsFor,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
type Next<T extends (...args: any[]) => any> = (...args: Parameters<T>) => ReturnType<T>;
|
||||
|
||||
export type Middleware<T extends (...args: any[]) => any> = (context: Parameters<T>, next: Next<T>) => ReturnType<T>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export interface RestClientInterface {
|
||||
get<TPathPattern extends MatchPathPattern<TPath>, TPath extends PathWithParamsFor<'GET'> = PathWithParamsFor<'GET'>>(
|
||||
endpoint: TPath,
|
||||
params: OperationParams<'GET', TPathPattern>,
|
||||
options?: Omit<RequestInit, 'method'>,
|
||||
): Promise<Serialized<OperationResult<'GET', TPathPattern>>>;
|
||||
|
||||
get<TPathPattern extends MatchPathPattern<TPath>, TPath extends PathWithoutParamsFor<'GET'> = PathWithoutParamsFor<'GET'>>(
|
||||
endpoint: TPath,
|
||||
params?: undefined,
|
||||
options?: Omit<RequestInit, 'method'>,
|
||||
): Promise<Serialized<OperationResult<'GET', TPathPattern>>>;
|
||||
|
||||
post<TPathPattern extends MatchPathPattern<TPath>, TPath extends PathWithParamsFor<'POST'> = PathWithParamsFor<'POST'>>(
|
||||
endpoint: TPath,
|
||||
params: OperationParams<'POST', TPathPattern>,
|
||||
options?: Omit<RequestInit, 'method'>,
|
||||
): Promise<Serialized<OperationResult<'POST', TPathPattern>>>;
|
||||
|
||||
post<TPathPattern extends MatchPathPattern<TPath>, TPath extends PathWithoutParamsFor<'POST'> = PathWithoutParamsFor<'POST'>>(
|
||||
endpoint: TPath,
|
||||
params?: undefined,
|
||||
options?: Omit<RequestInit, 'method'>,
|
||||
): Promise<Serialized<OperationResult<'POST', TPathPattern>>>;
|
||||
|
||||
put<TPathPattern extends MatchPathPattern<TPath>, TPath extends PathWithParamsFor<'PUT'> = PathWithParamsFor<'PUT'>>(
|
||||
endpoint: TPath,
|
||||
params: OperationParams<'PUT', TPathPattern>,
|
||||
options?: Omit<RequestInit, 'method'>,
|
||||
): Promise<Serialized<OperationResult<'PUT', TPathPattern>>>;
|
||||
|
||||
put<TPathPattern extends MatchPathPattern<TPath>, TPath extends PathWithoutParamsFor<'PUT'> = PathWithoutParamsFor<'PUT'>>(
|
||||
endpoint: TPath,
|
||||
params?: undefined,
|
||||
options?: Omit<RequestInit, 'method'>,
|
||||
): Promise<Serialized<OperationResult<'PUT', TPathPattern>>>;
|
||||
|
||||
delete<TPathPattern extends MatchPathPattern<TPath>, TPath extends PathWithParamsFor<'DELETE'> = PathWithParamsFor<'DELETE'>>(
|
||||
endpoint: TPath,
|
||||
params: OperationParams<'DELETE', TPathPattern>,
|
||||
options?: Omit<RequestInit, 'method'>,
|
||||
): Promise<Serialized<OperationResult<'DELETE', TPathPattern>>>;
|
||||
|
||||
delete<TPathPattern extends MatchPathPattern<TPath>, TPath extends PathWithoutParamsFor<'DELETE'> = PathWithoutParamsFor<'DELETE'>>(
|
||||
endpoint: TPath,
|
||||
params?: undefined,
|
||||
options?: Omit<RequestInit, 'method'>,
|
||||
): Promise<Serialized<OperationResult<'DELETE', TPathPattern>>>;
|
||||
|
||||
upload<TPath extends PathFor<'POST'>>(
|
||||
endpoint: TPath,
|
||||
params: void extends OperationParams<'POST', MatchPathPattern<TPath>> ? void : OperationParams<'POST', MatchPathPattern<TPath>>,
|
||||
events?: {
|
||||
load?: (event: ProgressEvent<XMLHttpRequestEventTarget>) => void;
|
||||
progress?: (event: ProgressEvent<XMLHttpRequestEventTarget>) => void;
|
||||
abort?: (event: ProgressEvent<XMLHttpRequestEventTarget>) => void;
|
||||
error?: (event: ProgressEvent<XMLHttpRequestEventTarget>) => void;
|
||||
},
|
||||
options?: Omit<RequestInit, 'method'>,
|
||||
): XMLHttpRequest;
|
||||
|
||||
getCredentials():
|
||||
| {
|
||||
'X-User-Id': string;
|
||||
'X-Auth-Token': string;
|
||||
}
|
||||
| undefined;
|
||||
setCredentials(credentials: undefined | { 'X-User-Id': string; 'X-Auth-Token': string }): void;
|
||||
|
||||
use(middleware: Middleware<RestClientInterface['send']>): void;
|
||||
|
||||
send(endpoint: string, method: string, options?: Omit<RequestInit, 'method'>): Promise<Response>;
|
||||
|
||||
handleTwoFactorChallenge(
|
||||
cb: (args: { method: 'totp' | 'email' | 'password'; emailOrUsername?: string; invalidAttempt?: boolean }) => Promise<string>,
|
||||
): void;
|
||||
}
|
||||
24
packages/api-client/src/errors.ts
Normal file
24
packages/api-client/src/errors.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
const twoFactorMethods = ['totp', 'email', 'password'] as const;
|
||||
|
||||
type TwoFactorMethod = (typeof twoFactorMethods)[number];
|
||||
|
||||
export const isTotpRequiredError = (error: unknown): error is { error: 'totp-required' } | { errorType: 'totp-required' } =>
|
||||
typeof error === 'object' &&
|
||||
((error as { error?: unknown } | undefined)?.error === 'totp-required' ||
|
||||
(error as { errorType?: unknown } | undefined)?.errorType === 'totp-required');
|
||||
|
||||
export const isTotpInvalidError = (error: unknown): error is { error: 'totp-invalid' } | { errorType: 'totp-invalid' } =>
|
||||
(error as { error?: unknown } | undefined)?.error === 'totp-invalid' ||
|
||||
(error as { errorType?: unknown } | undefined)?.errorType === 'totp-invalid';
|
||||
|
||||
export const isTwoFactorMethod = (method: string): method is TwoFactorMethod => twoFactorMethods.includes(method as TwoFactorMethod);
|
||||
|
||||
export const hasRequiredTwoFactorMethod = (error: unknown): error is { details: { method: TwoFactorMethod; emailOrUsername?: string } } => {
|
||||
const details = error && typeof error === 'object' && 'details' in error && (error.details as unknown);
|
||||
return (
|
||||
typeof details === 'object' &&
|
||||
details !== null &&
|
||||
typeof (details as { method: unknown }).method === 'string' &&
|
||||
isTwoFactorMethod((details as { method: string }).method)
|
||||
);
|
||||
};
|
||||
305
packages/api-client/src/index.ts
Normal file
305
packages/api-client/src/index.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import type { Serialized } from '@rocket.chat/core-typings';
|
||||
import type {
|
||||
MatchPathPattern,
|
||||
ParamsFor,
|
||||
OperationResult,
|
||||
PathFor,
|
||||
PathWithoutParamsFor,
|
||||
PathWithParamsFor,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import { stringify } from 'query-string';
|
||||
|
||||
import type { Credentials } from './Credentials';
|
||||
import type { Middleware, RestClientInterface } from './RestClientInterface';
|
||||
import { hasRequiredTwoFactorMethod, isTotpInvalidError, isTotpRequiredError } from './errors';
|
||||
|
||||
export type { RestClientInterface, Credentials };
|
||||
|
||||
const pipe =
|
||||
<T extends (...args: any[]) => any>(fn: T) =>
|
||||
(...args: Parameters<T>): ReturnType<T> =>
|
||||
fn(...args);
|
||||
|
||||
function buildFormData(data?: Record<string, any> | void, formData = new FormData(), parentKey?: string): FormData {
|
||||
if (data instanceof FormData) {
|
||||
return data;
|
||||
}
|
||||
if (!data) {
|
||||
return formData;
|
||||
}
|
||||
|
||||
if (typeof data === 'object' && !(data instanceof File)) {
|
||||
Object.keys(data).forEach((key) => {
|
||||
buildFormData(formData, data[key], parentKey ? `${parentKey}[${key}]` : key);
|
||||
});
|
||||
} else {
|
||||
data && parentKey && formData.append(parentKey, data);
|
||||
}
|
||||
return formData;
|
||||
}
|
||||
|
||||
const checkIfIsFormData = (data: any = {}): boolean => {
|
||||
if (data instanceof FormData) {
|
||||
return true;
|
||||
}
|
||||
return Object.values(data).some((value) => {
|
||||
if (value && typeof value === 'object' && !(value instanceof File)) {
|
||||
return checkIfIsFormData(value);
|
||||
}
|
||||
return value instanceof File;
|
||||
});
|
||||
};
|
||||
|
||||
export class RestClient implements RestClientInterface {
|
||||
private twoFactorHandler?: (args: {
|
||||
method: 'totp' | 'email' | 'password';
|
||||
emailOrUsername?: string;
|
||||
invalidAttempt?: boolean;
|
||||
}) => Promise<string>;
|
||||
|
||||
private readonly baseUrl: string;
|
||||
|
||||
private headers: Record<string, string> = {};
|
||||
|
||||
private credentials: Credentials | undefined;
|
||||
|
||||
constructor({ baseUrl, credentials, headers = {} }: { baseUrl: string; credentials?: Credentials; headers?: Record<string, string> }) {
|
||||
this.baseUrl = `${baseUrl}/api`;
|
||||
this.setCredentials(credentials);
|
||||
this.headers = headers;
|
||||
}
|
||||
|
||||
getCredentials(): ReturnType<RestClientInterface['getCredentials']> {
|
||||
return this.credentials;
|
||||
}
|
||||
|
||||
setCredentials: RestClientInterface['setCredentials'] = (credentials) => {
|
||||
this.credentials = credentials;
|
||||
};
|
||||
|
||||
get<TPathPattern extends MatchPathPattern<TPath>, TPath extends PathWithParamsFor<'GET'> = PathWithParamsFor<'GET'>>(
|
||||
endpoint: TPath,
|
||||
params: ParamsFor<'GET', TPathPattern>,
|
||||
options?: Omit<RequestInit, 'method'>,
|
||||
): Promise<Serialized<OperationResult<'GET', TPathPattern>>>;
|
||||
|
||||
get<TPathPattern extends MatchPathPattern<TPath>, TPath extends PathWithoutParamsFor<'GET'> = PathWithoutParamsFor<'GET'>>(
|
||||
endpoint: TPath,
|
||||
params?: undefined,
|
||||
options?: Omit<RequestInit, 'method'>,
|
||||
): Promise<Serialized<OperationResult<'GET', TPathPattern>>>;
|
||||
|
||||
async get<TPathPattern extends MatchPathPattern<TPath>, TPath extends PathFor<'GET'>>(
|
||||
endpoint: TPath,
|
||||
params?: ParamsFor<'GET', TPathPattern>,
|
||||
options?: Omit<RequestInit, 'method'>,
|
||||
): Promise<Serialized<OperationResult<'GET', TPathPattern>>> {
|
||||
if (/\?/.test(endpoint)) {
|
||||
// throw new Error('Endpoint cannot contain query string');
|
||||
console.warn('Endpoint cannot contain query string', endpoint);
|
||||
}
|
||||
const queryParams = this.getParams(params);
|
||||
const response = await this.send(`${endpoint}${queryParams ? `?${queryParams}` : ''}`, 'GET', options ?? {});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
post<TPathPattern extends MatchPathPattern<TPath>, TPath extends PathWithParamsFor<'POST'> = PathWithParamsFor<'POST'>>(
|
||||
endpoint: TPath,
|
||||
params: ParamsFor<'POST', TPathPattern>,
|
||||
options?: Omit<RequestInit, 'method'>,
|
||||
): Promise<Serialized<OperationResult<'POST', TPathPattern>>>;
|
||||
|
||||
post<TPathPattern extends MatchPathPattern<TPath>, TPath extends PathWithoutParamsFor<'POST'> = PathWithoutParamsFor<'POST'>>(
|
||||
endpoint: TPath,
|
||||
params?: undefined,
|
||||
options?: Omit<RequestInit, 'method'>,
|
||||
): Promise<Serialized<OperationResult<'POST', TPathPattern>>>;
|
||||
|
||||
async post<TPathPattern extends MatchPathPattern<TPath>, TPath extends PathFor<'POST'>>(
|
||||
endpoint: TPath,
|
||||
params?: ParamsFor<'POST', TPathPattern>,
|
||||
{ headers, ...options }: Omit<RequestInit, 'method'> = {},
|
||||
): Promise<Serialized<OperationResult<'POST', TPathPattern>>> {
|
||||
const isFormData = checkIfIsFormData(params);
|
||||
|
||||
const response = await this.send(endpoint, 'POST', {
|
||||
body: isFormData ? buildFormData(params) : JSON.stringify(params),
|
||||
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(!isFormData && { 'Content-Type': 'application/json' }),
|
||||
...headers,
|
||||
},
|
||||
|
||||
...options,
|
||||
});
|
||||
|
||||
// If the server sent no data, return an empty record as we're only expecting objects.
|
||||
if (response.status === 204) {
|
||||
return {} as any;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
put<TPathPattern extends MatchPathPattern<TPath>, TPath extends PathWithParamsFor<'PUT'> = PathWithParamsFor<'PUT'>>(
|
||||
endpoint: TPath,
|
||||
params: ParamsFor<'PUT', TPathPattern>,
|
||||
options?: Omit<RequestInit, 'method'>,
|
||||
): Promise<Serialized<OperationResult<'PUT', TPathPattern>>>;
|
||||
|
||||
put<TPathPattern extends MatchPathPattern<TPath>, TPath extends PathWithoutParamsFor<'PUT'> = PathWithoutParamsFor<'PUT'>>(
|
||||
endpoint: TPath,
|
||||
params?: undefined,
|
||||
options?: Omit<RequestInit, 'method'>,
|
||||
): Promise<Serialized<OperationResult<'PUT', TPathPattern>>>;
|
||||
|
||||
async put<TPathPattern extends MatchPathPattern<TPath>, TPath extends PathFor<'PUT'>>(
|
||||
endpoint: TPath,
|
||||
params?: ParamsFor<'PUT', TPathPattern>,
|
||||
{ headers, ...options }: Omit<RequestInit, 'method'> = {},
|
||||
): Promise<Serialized<OperationResult<'PUT', TPathPattern>>> {
|
||||
const isFormData = checkIfIsFormData(params);
|
||||
const response = await this.send(endpoint, 'PUT', {
|
||||
body: isFormData ? buildFormData(params) : JSON.stringify(params),
|
||||
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(!isFormData && { 'Content-Type': 'application/json' }),
|
||||
...headers,
|
||||
},
|
||||
|
||||
...options,
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
delete<TPathPattern extends MatchPathPattern<TPath>, TPath extends PathWithParamsFor<'DELETE'> = PathWithParamsFor<'DELETE'>>(
|
||||
endpoint: TPath,
|
||||
params: ParamsFor<'DELETE', TPathPattern>,
|
||||
options?: Omit<RequestInit, 'method'>,
|
||||
): Promise<Serialized<OperationResult<'DELETE', TPathPattern>>>;
|
||||
|
||||
delete<TPathPattern extends MatchPathPattern<TPath>, TPath extends PathWithoutParamsFor<'DELETE'> = PathWithoutParamsFor<'DELETE'>>(
|
||||
endpoint: TPath,
|
||||
params?: undefined,
|
||||
options?: Omit<RequestInit, 'method'>,
|
||||
): Promise<Serialized<OperationResult<'DELETE', TPathPattern>>>;
|
||||
|
||||
async delete<TPathPattern extends MatchPathPattern<TPath>, TPath extends PathFor<'DELETE'>>(
|
||||
endpoint: TPath,
|
||||
_params?: ParamsFor<'DELETE', TPathPattern>,
|
||||
options: Omit<RequestInit, 'method'> = {},
|
||||
): Promise<Serialized<OperationResult<'DELETE', TPathPattern>>> {
|
||||
const response = await this.send(endpoint, 'DELETE', options ?? {});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
protected getCredentialsAsHeaders(): Record<string, string> {
|
||||
const credentials = this.getCredentials();
|
||||
return credentials
|
||||
? {
|
||||
'X-User-Id': credentials['X-User-Id'],
|
||||
'X-Auth-Token': credentials['X-Auth-Token'],
|
||||
}
|
||||
: {};
|
||||
}
|
||||
|
||||
send(endpoint: string, method: string, { headers, ...options }: Omit<RequestInit, 'method'> = {}): Promise<Response> {
|
||||
return fetch(`${this.baseUrl}${`/${endpoint}`.replace(/\/+/, '/')}`, {
|
||||
...options,
|
||||
headers: { ...this.getCredentialsAsHeaders(), ...this.headers, ...headers },
|
||||
method,
|
||||
}).then(async (response) => {
|
||||
if (response.ok) {
|
||||
return response;
|
||||
}
|
||||
|
||||
if (response.status !== 400) {
|
||||
return Promise.reject(response);
|
||||
}
|
||||
|
||||
const clone = response.clone();
|
||||
|
||||
const error = await clone.json();
|
||||
|
||||
if ((isTotpRequiredError(error) || isTotpInvalidError(error)) && hasRequiredTwoFactorMethod(error) && this.twoFactorHandler) {
|
||||
const method2fa = 'details' in error ? error.details.method : 'password';
|
||||
|
||||
const code = await this.twoFactorHandler({
|
||||
method: method2fa,
|
||||
emailOrUsername: error.details.emailOrUsername,
|
||||
invalidAttempt: isTotpInvalidError(error),
|
||||
});
|
||||
|
||||
return this.send(endpoint, method, {
|
||||
...options,
|
||||
headers: {
|
||||
...this.getCredentialsAsHeaders(),
|
||||
...this.headers,
|
||||
...headers,
|
||||
'x-2fa-code': code,
|
||||
'x-2fa-method': method2fa,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(response);
|
||||
});
|
||||
}
|
||||
|
||||
protected getParams(data: Record<string, object | number | string | boolean> | void): string {
|
||||
return data ? stringify(data, { arrayFormat: 'bracket' }) : '';
|
||||
}
|
||||
|
||||
upload: RestClientInterface['upload'] = (endpoint, params, events, options = {}) => {
|
||||
if (!params) {
|
||||
throw new Error('Missing params');
|
||||
}
|
||||
const xhr = new XMLHttpRequest();
|
||||
const data = new FormData();
|
||||
|
||||
Object.entries(params as any).forEach(([key, value]) => {
|
||||
if (value instanceof File) {
|
||||
data.append(key, value, value.name);
|
||||
return;
|
||||
}
|
||||
value && data.append(key, value as any);
|
||||
});
|
||||
|
||||
xhr.open('POST', `${this.baseUrl}${`/${endpoint}`.replace(/\/+/, '/')}`, true);
|
||||
Object.entries({ ...this.getCredentialsAsHeaders(), ...options.headers }).forEach(([key, value]) => {
|
||||
xhr.setRequestHeader(key, value);
|
||||
});
|
||||
|
||||
if (events?.load) {
|
||||
xhr.upload.addEventListener('load', events.load);
|
||||
}
|
||||
if (events?.progress) {
|
||||
xhr.upload.addEventListener('progress', events.progress);
|
||||
}
|
||||
if (events?.error) {
|
||||
xhr.addEventListener('error', events.error);
|
||||
}
|
||||
if (events?.abort) {
|
||||
xhr.addEventListener('abort', events.abort);
|
||||
}
|
||||
|
||||
xhr.send(data);
|
||||
|
||||
return xhr;
|
||||
};
|
||||
|
||||
use(middleware: Middleware<RestClientInterface['send']>): void {
|
||||
const fn = this.send.bind(this);
|
||||
this.send = function (this: RestClient, ...context: Parameters<RestClientInterface['send']>): ReturnType<RestClientInterface['send']> {
|
||||
return middleware(context, pipe(fn));
|
||||
} as RestClientInterface['send'];
|
||||
}
|
||||
|
||||
handleTwoFactorChallenge(
|
||||
cb: (args: { method: 'totp' | 'email' | 'password'; emailOrUsername?: string; invalidAttempt?: boolean }) => Promise<string>,
|
||||
): void {
|
||||
this.twoFactorHandler = cb;
|
||||
}
|
||||
}
|
||||
9
packages/api-client/tsconfig.json
Normal file
9
packages/api-client/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@rocket.chat/tsconfig/client.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["./src/**/*"]
|
||||
}
|
||||
61
packages/apps-engine/.gitignore
vendored
Normal file
61
packages/apps-engine/.gitignore
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
# Created by https://www.gitignore.io/api/node
|
||||
|
||||
### Node ###
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules
|
||||
jspm_packages
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
.deno
|
||||
.deno-cache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
### Typings ###
|
||||
## Ignore downloaded typings
|
||||
/typings
|
||||
|
||||
## dev environment stuff
|
||||
/examples/
|
||||
/dev-dist/
|
||||
/data/
|
||||
/tests/test-data/dbs
|
||||
/client
|
||||
/definition
|
||||
/server
|
||||
/lib
|
||||
|
||||
.DS_Store
|
||||
.idea/
|
||||
635
packages/apps-engine/CHANGELOG.md
Normal file
635
packages/apps-engine/CHANGELOG.md
Normal file
@@ -0,0 +1,635 @@
|
||||
# @rocket.chat/apps-engine
|
||||
|
||||
## 1.66.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#41765](https://github.com/RocketChat/Rocket.Chat/pull/41765)) Accepts a role name in the `when.hasOneRole` and `when.hasAllRoles` filters of an app action button
|
||||
|
||||
## 1.66.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#41765](https://github.com/RocketChat/Rocket.Chat/pull/41765)) Accepts a role name in the `when.hasOneRole` and `when.hasAllRoles` filters of an app action button
|
||||
|
||||
## 1.65.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#41304](https://github.com/RocketChat/Rocket.Chat/pull/41304)) Fixes wrong FederationLookup type assigned to IUser in apps. The correct data is there, but the type does not represent it.
|
||||
|
||||
## 1.64.1-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#41304](https://github.com/RocketChat/Rocket.Chat/pull/41304)) Fixes wrong FederationLookup type assigned to IUser in apps. The correct data is there, but the type does not represent it.
|
||||
|
||||
## 1.64.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#41308](https://github.com/RocketChat/Rocket.Chat/pull/41308) by [@dionisio-bot](https://github.com/dionisio-bot)) Fixes wrong FederationLookup type assigned to IUser in apps. The correct data is there, but the type does not represent it.
|
||||
|
||||
## 1.64.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#40791](https://github.com/RocketChat/Rocket.Chat/pull/40791)) Exposes the `isFederated` and `federation` fields for room and user objects in apps
|
||||
|
||||
## 1.64.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#40791](https://github.com/RocketChat/Rocket.Chat/pull/40791)) Exposes the `isFederated` and `federation` fields for room and user objects in apps
|
||||
|
||||
## 1.63.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#40343](https://github.com/RocketChat/Rocket.Chat/pull/40343)) Remove files that weren't accessible to apps at development time
|
||||
|
||||
- ([#40463](https://github.com/RocketChat/Rocket.Chat/pull/40463)) Allows apps with the right permission to read room's ABAC attributes.
|
||||
|
||||
- ([#40604](https://github.com/RocketChat/Rocket.Chat/pull/40604)) Adds the capability for fetching a user by their sip extension to the apps
|
||||
|
||||
## 1.63.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#40343](https://github.com/RocketChat/Rocket.Chat/pull/40343)) Remove files that weren't accessible to apps at development time
|
||||
|
||||
- ([#40463](https://github.com/RocketChat/Rocket.Chat/pull/40463)) Allows apps with the right permission to read room's ABAC attributes.
|
||||
|
||||
- ([#40604](https://github.com/RocketChat/Rocket.Chat/pull/40604)) Adds the capability for fetching a user by their sip extension to the apps
|
||||
|
||||
## 1.62.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#39535](https://github.com/RocketChat/Rocket.Chat/pull/39535)) Adds externalIds field to livechat visitors for external platform identification.
|
||||
|
||||
- ([#40093](https://github.com/RocketChat/Rocket.Chat/pull/40093)) Adds new API endpoints to load the user's current voice call state from the server
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#40125](https://github.com/RocketChat/Rocket.Chat/pull/40125)) Security Hotfix (https://docs.rocket.chat/docs/security-fixes-and-updates)
|
||||
|
||||
- ([#39883](https://github.com/RocketChat/Rocket.Chat/pull/39883)) Fixes the issue of the lacking MessageUpdater not being available to apps during runtime
|
||||
|
||||
## 1.61.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#40130](https://github.com/RocketChat/Rocket.Chat/pull/40130) by [@dionisio-bot](https://github.com/dionisio-bot)) Security Hotfix (https://docs.rocket.chat/docs/security-fixes-and-updates)
|
||||
|
||||
## 1.62.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#39535](https://github.com/RocketChat/Rocket.Chat/pull/39535)) Adds externalIds field to livechat visitors for external platform identification.
|
||||
|
||||
- ([#40093](https://github.com/RocketChat/Rocket.Chat/pull/40093)) Adds new API endpoints to load the user's current voice call state from the server
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#40125](https://github.com/RocketChat/Rocket.Chat/pull/40125)) Security Hotfix (https://docs.rocket.chat/docs/security-fixes-and-updates)
|
||||
|
||||
- ([#39883](https://github.com/RocketChat/Rocket.Chat/pull/39883)) Fixes the issue of the lacking MessageUpdater not being available to apps during runtime
|
||||
|
||||
## 1.61.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#39268](https://github.com/RocketChat/Rocket.Chat/pull/39268)) refactor(ui-kit): Remove UiKit deprecations
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration
|
||||
|
||||
## 1.61.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#39268](https://github.com/RocketChat/Rocket.Chat/pull/39268)) refactor(ui-kit): Remove UiKit deprecations
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration
|
||||
|
||||
## 1.60.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#38099](https://github.com/RocketChat/Rocket.Chat/pull/38099)) Adds file metadata to the Apps.Engine for messages with multiple files
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#38374](https://github.com/RocketChat/Rocket.Chat/pull/38374)) Fixes an issue where apps logs were being lost in nested requests
|
||||
|
||||
- ([#38513](https://github.com/RocketChat/Rocket.Chat/pull/38513)) Fixes an issue where app-defined API endpoints with dynamic paths could fail to receive requests when using path parameters like `:param`.
|
||||
|
||||
## 1.60.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#38099](https://github.com/RocketChat/Rocket.Chat/pull/38099)) Adds file metadata to the Apps.Engine for messages with multiple files
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#38374](https://github.com/RocketChat/Rocket.Chat/pull/38374)) Fixes an issue where apps logs were being lost in nested requests
|
||||
|
||||
- ([#38513](https://github.com/RocketChat/Rocket.Chat/pull/38513)) Fixes an issue where app-defined API endpoints with dynamic paths could fail to receive requests when using path parameters like `:param`.
|
||||
|
||||
## 1.59.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#38068](https://github.com/RocketChat/Rocket.Chat/pull/38068)) Fixes an issue that caused a spike in memory usage when apps handled the IPreFileUpload event
|
||||
|
||||
## 1.59.1-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#38068](https://github.com/RocketChat/Rocket.Chat/pull/38068)) Fixes an issue that caused a spike in memory usage when apps handled the IPreFileUpload event
|
||||
|
||||
## 1.59.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#37719](https://github.com/RocketChat/Rocket.Chat/pull/37719)) Adds a new method to the Apps-Engine that allows apps to retrieve multiple rooms from database
|
||||
|
||||
- ([#37672](https://github.com/RocketChat/Rocket.Chat/pull/37672)) Removes deprecated VoIP from Omnichannel
|
||||
|
||||
- ([#37091](https://github.com/RocketChat/Rocket.Chat/pull/37091)) Adds Attribute Based Access Control (ABAC) for private channels & private teams.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#37730](https://github.com/RocketChat/Rocket.Chat/pull/37730)) Adds an execution flag to apps-engine runtime that helps prevent the publishing of faulty builds
|
||||
|
||||
## 1.59.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#37719](https://github.com/RocketChat/Rocket.Chat/pull/37719)) Adds a new method to the Apps-Engine that allows apps to retrieve multiple rooms from database
|
||||
|
||||
- ([#37672](https://github.com/RocketChat/Rocket.Chat/pull/37672)) Removes deprecated VoIP from Omnichannel
|
||||
|
||||
- ([#37091](https://github.com/RocketChat/Rocket.Chat/pull/37091)) Adds Attribute Based Access Control (ABAC) for private channels & private teams.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#37730](https://github.com/RocketChat/Rocket.Chat/pull/37730)) Adds an execution flag to apps-engine runtime that helps prevent the publishing of faulty builds
|
||||
|
||||
## 1.58.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#37547](https://github.com/RocketChat/Rocket.Chat/pull/37547)) Adds the `getUserRoomIds` method to the `UserRead` accessor in the Apps-Engine, graduating it from the experimental bridge to the stable user bridge.
|
||||
|
||||
- ([#37167](https://github.com/RocketChat/Rocket.Chat/pull/37167)) Changes a behavior that would store the result of every status transition that happened to apps
|
||||
|
||||
This caused intermediate status to be saved to the database, which could prevent apps from being restored to the desired status when restarted or during server startup.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#37496](https://github.com/RocketChat/Rocket.Chat/pull/37496)) Fixes the ping behavior so it only triggers when the app becomes idle, preventing unnecessary restarts
|
||||
|
||||
- ([#37380](https://github.com/RocketChat/Rocket.Chat/pull/37380)) Fixes an issue where apps would never get the bio of a user even though the type has the field defined
|
||||
|
||||
- ([#37384](https://github.com/RocketChat/Rocket.Chat/pull/37384)) Fixes a problem in apps-engine debug logs where only 2 depth levels were displayed for objects, which is often not enough for debugging purposes
|
||||
|
||||
- ([#37152](https://github.com/RocketChat/Rocket.Chat/pull/37152)) Fixes a bug that would cause apps to go into `invalid_installation_disabled` in some cases
|
||||
|
||||
## 1.58.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#37547](https://github.com/RocketChat/Rocket.Chat/pull/37547)) Adds the `getUserRoomIds` method to the `UserRead` accessor in the Apps-Engine, graduating it from the experimental bridge to the stable user bridge.
|
||||
|
||||
- ([#37167](https://github.com/RocketChat/Rocket.Chat/pull/37167)) Changes a behavior that would store the result of every status transition that happened to apps
|
||||
|
||||
This caused intermediate status to be saved to the database, which could prevent apps from being restored to the desired status when restarted or during server startup.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#37496](https://github.com/RocketChat/Rocket.Chat/pull/37496)) Fixes the ping behavior so it only triggers when the app becomes idle, preventing unnecessary restarts
|
||||
|
||||
- ([#37380](https://github.com/RocketChat/Rocket.Chat/pull/37380)) Fixes an issue where apps would never get the bio of a user even though the type has the field defined
|
||||
|
||||
- ([#37384](https://github.com/RocketChat/Rocket.Chat/pull/37384)) Fixes a problem in apps-engine debug logs where only 2 depth levels were displayed for objects, which is often not enough for debugging purposes
|
||||
|
||||
- ([#37152](https://github.com/RocketChat/Rocket.Chat/pull/37152)) Fixes a bug that would cause apps to go into `invalid_installation_disabled` in some cases
|
||||
|
||||
## 1.57.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#37440](https://github.com/RocketChat/Rocket.Chat/pull/37440) by [@dionisio-bot](https://github.com/dionisio-bot)) Fixes a problem in apps-engine debug logs where only 2 depth levels were displayed for objects, which is often not enough for debugging purposes
|
||||
|
||||
## 1.57.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#37057](https://github.com/RocketChat/Rocket.Chat/pull/37057)) Adds an experimental API to the apps-engine that retrieves the ids of rooms the user is a member of
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#36990](https://github.com/RocketChat/Rocket.Chat/pull/36990)) Change app update strategies to prevent unwanted data changes in database
|
||||
|
||||
## 1.57.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#37057](https://github.com/RocketChat/Rocket.Chat/pull/37057)) Adds an experimental API to the apps-engine that retrieves the ids of rooms the user is a member of
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#36990](https://github.com/RocketChat/Rocket.Chat/pull/36990)) Change app update strategies to prevent unwanted data changes in database
|
||||
|
||||
## 1.56.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#36207](https://github.com/RocketChat/Rocket.Chat/pull/36207)) Introduces the Outbound Message feature to Omnichannel, allowing organizations to initiate proactive communication with contacts through their preferred messaging channel directly from Rocket.Chat
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#36967](https://github.com/RocketChat/Rocket.Chat/pull/36967)) Fixes an issue where apps that import node native modules with the optional `node:` specifier would fail to construct
|
||||
|
||||
- ([#36855](https://github.com/RocketChat/Rocket.Chat/pull/36855)) Changes a strict behavior on reporting slash commands provided by apps
|
||||
|
||||
- ([#36943](https://github.com/RocketChat/Rocket.Chat/pull/36943)) Fixes an issue where an object reference leak would cause invalid data to be stored in the database during app installation
|
||||
|
||||
## 1.56.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#36207](https://github.com/RocketChat/Rocket.Chat/pull/36207)) Introduces the Outbound Message feature to Omnichannel, allowing organizations to initiate proactive communication with contacts through their preferred messaging channel directly from Rocket.Chat
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#36967](https://github.com/RocketChat/Rocket.Chat/pull/36967)) Fixes an issue where apps that import node native modules with the optional `node:` specifier would fail to construct
|
||||
|
||||
- ([#36855](https://github.com/RocketChat/Rocket.Chat/pull/36855)) Changes a strict behavior on reporting slash commands provided by apps
|
||||
|
||||
- ([#36943](https://github.com/RocketChat/Rocket.Chat/pull/36943)) Fixes an issue where an object reference leak would cause invalid data to be stored in the database during app installation
|
||||
|
||||
## 1.55.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#37028](https://github.com/RocketChat/Rocket.Chat/pull/37028) by [@dionisio-bot](https://github.com/dionisio-bot)) Changes a strict behavior on reporting slash commands provided by apps
|
||||
|
||||
## 1.55.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#36553](https://github.com/RocketChat/Rocket.Chat/pull/36553)) Creates a new endpoint that allows agents to send an outbound message from a registered app provider
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#36625](https://github.com/RocketChat/Rocket.Chat/pull/36625)) Fixes an issue where app installation would fail if the app package contained JS syntax newer than 2017
|
||||
|
||||
- ([#36802](https://github.com/RocketChat/Rocket.Chat/pull/36802)) Fixes an error on apps loading that would cause an unhandled promise rejection crash during startup in some cases
|
||||
|
||||
- ([#36670](https://github.com/RocketChat/Rocket.Chat/pull/36670)) Fixes an issue that would cause the chat server to crash with an unhandled rejection in some cases
|
||||
|
||||
## 1.55.0-rc.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#36802](https://github.com/RocketChat/Rocket.Chat/pull/36802)) Fixes an error on apps loading that would cause an unhandled promise rejection crash during startup in some cases
|
||||
|
||||
## 1.55.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#36553](https://github.com/RocketChat/Rocket.Chat/pull/36553)) Creates a new endpoint that allows agents to send an outbound message from a registered app provider
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#36625](https://github.com/RocketChat/Rocket.Chat/pull/36625)) Fixes an issue where app installation would fail if the app package contained JS syntax newer than 2017
|
||||
|
||||
- ([#36670](https://github.com/RocketChat/Rocket.Chat/pull/36670)) Fixes an issue that would cause the chat server to crash with an unhandled rejection in some cases
|
||||
|
||||
## 1.54.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#36377](https://github.com/RocketChat/Rocket.Chat/pull/36377)) Adds new endpoints for outbound communications
|
||||
|
||||
- ([#36403](https://github.com/RocketChat/Rocket.Chat/pull/36403)) Adds a button to the apps logs UI that exports logs as a downloadable file
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#36504](https://github.com/RocketChat/Rocket.Chat/pull/36504)) Fixes an issue that would leave an app in an unrecoverable state if the installation failed during the construction of the runtime
|
||||
|
||||
- ([#36346](https://github.com/RocketChat/Rocket.Chat/pull/36346)) Fixes an issue where some error objects sent to apps' method calls would only contain the message '[object Object]'
|
||||
|
||||
## 1.54.0-rc.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#36504](https://github.com/RocketChat/Rocket.Chat/pull/36504)) Fixes an issue that would leave an app in an unrecoverable state if the installation failed during the construction of the runtime
|
||||
|
||||
## 1.54.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#36377](https://github.com/RocketChat/Rocket.Chat/pull/36377)) Adds new endpoints for outbound communications
|
||||
|
||||
- ([#36403](https://github.com/RocketChat/Rocket.Chat/pull/36403)) Adds a button to the apps logs UI that exports logs as a downloadable file
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#36346](https://github.com/RocketChat/Rocket.Chat/pull/36346)) Fixes an issue where some error objects sent to apps' method calls would only contain the message '[object Object]'
|
||||
|
||||
## 1.53.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#36543](https://github.com/RocketChat/Rocket.Chat/pull/36543)) Fixes an issue that would leave an app in an unrecoverable state if the installation failed during the construction of the runtime
|
||||
|
||||
## 1.53.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#36169](https://github.com/RocketChat/Rocket.Chat/pull/36169)) Fix an issue where action buttons registered by apps would be displayed even if their apps were disabled
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#36346](https://github.com/RocketChat/Rocket.Chat/pull/36346)) Fixes an issue where some error objects sent to apps' method calls would only contain the message '[object Object]'
|
||||
|
||||
## 1.53.0-rc.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#36346](https://github.com/RocketChat/Rocket.Chat/pull/36346)) Fixes an issue where some error objects sent to apps' method calls would only contain the message '[object Object]'
|
||||
|
||||
## 1.53.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#36169](https://github.com/RocketChat/Rocket.Chat/pull/36169)) Fix an issue where action buttons registered by apps would be displayed even if their apps were disabled
|
||||
|
||||
## 1.52.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#36360](https://github.com/RocketChat/Rocket.Chat/pull/36360) by [@dionisio-bot](https://github.com/dionisio-bot)) Fixes an issue where some error objects sent to apps' method calls would only contain the message '[object Object]'
|
||||
|
||||
## 1.52.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#35416](https://github.com/RocketChat/Rocket.Chat/pull/35416)) Improve the `/api/apps/:id/logs` endpoint to accept filters
|
||||
|
||||
- ([#35416](https://github.com/RocketChat/Rocket.Chat/pull/35416)) Add a new endpoint `/api/apps/logs` that allows for fetching logs without a filter for app id
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#36007](https://github.com/RocketChat/Rocket.Chat/pull/36007)) Fixes the Apps-Engine package installation, removing the dependency on an internal package
|
||||
|
||||
## 1.52.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#35416](https://github.com/RocketChat/Rocket.Chat/pull/35416)) Improve the `/api/apps/:id/logs` endpoint to accept filters
|
||||
|
||||
- ([#35416](https://github.com/RocketChat/Rocket.Chat/pull/35416)) Add a new endpoint `/api/apps/logs` that allows for fetching logs without a filter for app id
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#36007](https://github.com/RocketChat/Rocket.Chat/pull/36007)) Fixes the Apps-Engine package installation, removing the dependency on an internal package
|
||||
|
||||
## 1.51.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#36033](https://github.com/RocketChat/Rocket.Chat/pull/36033) by [@dionisio-bot](https://github.com/dionisio-bot)) Fixes the Apps-Engine package installation, removing the dependency on an internal package
|
||||
|
||||
## 1.51.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#35280](https://github.com/RocketChat/Rocket.Chat/pull/35280)) Allows apps to react to department status changes.
|
||||
|
||||
- ([#35644](https://github.com/RocketChat/Rocket.Chat/pull/35644)) Adds the ability to dynamically add and remove options from select/multi-select settings in the Apps Engine to support more flexible configuration scenarios by exposing two new methods on the settings API.
|
||||
|
||||
## 1.51.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#35280](https://github.com/RocketChat/Rocket.Chat/pull/35280)) Allows apps to react to department status changes.
|
||||
|
||||
- ([#35644](https://github.com/RocketChat/Rocket.Chat/pull/35644)) Adds the ability to dynamically add and remove options from select/multi-select settings in the Apps Engine to support more flexible configuration scenarios by exposing two new methods on the settings API.
|
||||
|
||||
## 1.50.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#35486](https://github.com/RocketChat/Rocket.Chat/pull/35486)) Adds the executeLivechatRoomCreatePrevent hook to the Rocket.Chat Apps-Engine to prevent the creation of live chat rooms.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#35479](https://github.com/RocketChat/Rocket.Chat/pull/35479)) Fixes deno dependency caching for the docker image builds
|
||||
|
||||
- ([#35426](https://github.com/RocketChat/Rocket.Chat/pull/35426)) Improves overall performance on dealing with apps, by reducing the number of getStatus calls through the pipe.
|
||||
|
||||
- ([#35667](https://github.com/RocketChat/Rocket.Chat/pull/35667)) Fixes an issue with error handling where errors were not properly propagated to integrated apps.
|
||||
|
||||
- ([#35342](https://github.com/RocketChat/Rocket.Chat/pull/35342)) Fixes an issue that prevents modifications from being persisted in the IPreRoomCreateModify
|
||||
|
||||
- ([#35603](https://github.com/RocketChat/Rocket.Chat/pull/35603)) Fixes an issue where apps where not able to update messages using the BlockBuilder.
|
||||
|
||||
## 1.50.0-rc.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#35667](https://github.com/RocketChat/Rocket.Chat/pull/35667)) Fixes an issue with error handling where errors were not properly propagated to integrated apps.
|
||||
|
||||
- ([#35603](https://github.com/RocketChat/Rocket.Chat/pull/35603)) Fixes an issue where apps where not able to update messages using the BlockBuilder.
|
||||
|
||||
## 1.50.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#35486](https://github.com/RocketChat/Rocket.Chat/pull/35486)) Adds the executeLivechatRoomCreatePrevent hook to the Rocket.Chat Apps-Engine to prevent the creation of live chat rooms.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#35479](https://github.com/RocketChat/Rocket.Chat/pull/35479)) Fixes deno dependency caching for the docker image builds
|
||||
|
||||
- ([#35426](https://github.com/RocketChat/Rocket.Chat/pull/35426)) Improves overall performance on dealing with apps, by reducing the number of getStatus calls through the pipe.
|
||||
|
||||
- ([#35342](https://github.com/RocketChat/Rocket.Chat/pull/35342)) Fixes an issue that prevents modifications from being persisted in the IPreRoomCreateModify
|
||||
|
||||
## 1.49.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#35177](https://github.com/RocketChat/Rocket.Chat/pull/35177)) Adds a new IPostSystemMessageSent event, that is triggered whenever a new System Message is sent
|
||||
|
||||
- ([#35013](https://github.com/RocketChat/Rocket.Chat/pull/35013)) Adds a filter option to include or exclude threads in the Apps Engine room read/unread messages bridge, enhancing flexibility in message retrieval.
|
||||
|
||||
- ([#35199](https://github.com/RocketChat/Rocket.Chat/pull/35199)) Enables specifying hidden settings that are enabled to be accessed through the apps-engine in the permission list.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#35120](https://github.com/RocketChat/Rocket.Chat/pull/35120)) Fixes behavior of app updates that would save undesired field changes to documents
|
||||
|
||||
- ([#35170](https://github.com/RocketChat/Rocket.Chat/pull/35170)) Fixes an issue that would cause marketplace apps to become invalid installations after an update
|
||||
|
||||
## 1.49.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#35177](https://github.com/RocketChat/Rocket.Chat/pull/35177)) Adds a new IPostSystemMessageSent event, that is triggered whenever a new System Message is sent
|
||||
|
||||
- ([#35013](https://github.com/RocketChat/Rocket.Chat/pull/35013)) Adds a filter option to include or exclude threads in the Apps Engine room read/unread messages bridge, enhancing flexibility in message retrieval.
|
||||
|
||||
- ([#35199](https://github.com/RocketChat/Rocket.Chat/pull/35199)) Enables specifying hidden settings that are enabled to be accessed through the apps-engine in the permission list.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#35120](https://github.com/RocketChat/Rocket.Chat/pull/35120)) Fixes behavior of app updates that would save undesired field changes to documents
|
||||
|
||||
- ([#35170](https://github.com/RocketChat/Rocket.Chat/pull/35170)) Fixes an issue that would cause marketplace apps to become invalid installations after an update
|
||||
|
||||
## 1.48.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#34858](https://github.com/RocketChat/Rocket.Chat/pull/34858)) Fixes an issue that prevented the apps-engine from reestablishing communications with subprocesses in some cases
|
||||
|
||||
- ([#35009](https://github.com/RocketChat/Rocket.Chat/pull/35009)) Fix an issue with apps installations via Marketplace
|
||||
|
||||
## 1.48.2-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#34858](https://github.com/RocketChat/Rocket.Chat/pull/34858)) Fixes an issue that prevented the apps-engine from reestablishing communications with subprocesses in some cases
|
||||
|
||||
## 1.48.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#34205](https://github.com/RocketChat/Rocket.Chat/pull/34205)) Fixes an error where the engine would not retry a subprocess restart if the last attempt failed
|
||||
|
||||
- ([#34172](https://github.com/RocketChat/Rocket.Chat/pull/34172)) Fixes the subprocess restarting routine failing to correctly restart apps in some cases
|
||||
|
||||
- ([#34858](https://github.com/RocketChat/Rocket.Chat/pull/34858)) Fixes an issue that prevented the apps-engine from reestablishing communications with subprocesses in some cases
|
||||
|
||||
- ([#34106](https://github.com/RocketChat/Rocket.Chat/pull/34106)) Adds simple app subprocess metrics report
|
||||
|
||||
- ([#34205](https://github.com/RocketChat/Rocket.Chat/pull/34205)) Fixes error propagation when trying to get the status of apps in some cases
|
||||
|
||||
- ([#34106](https://github.com/RocketChat/Rocket.Chat/pull/34106)) Attempts to restart an app subprocess if the spawn command fails
|
||||
|
||||
- ([#34205](https://github.com/RocketChat/Rocket.Chat/pull/34205)) Fixes wrong data being reported to total failed apps metrics and statistics
|
||||
|
||||
- ([#34106](https://github.com/RocketChat/Rocket.Chat/pull/34106)) Fixes an issue while collecting the error message from a failed restart attempt of an app subprocess
|
||||
|
||||
- ([#34106](https://github.com/RocketChat/Rocket.Chat/pull/34106)) Prevents app:getStatus requests from timing out in some cases
|
||||
|
||||
## 1.48.1-rc.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#34858](https://github.com/RocketChat/Rocket.Chat/pull/34858)) Fixes an issue that prevented the apps-engine from reestablishing communications with subprocesses in some cases
|
||||
|
||||
## 1.48.1-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#34205](https://github.com/RocketChat/Rocket.Chat/pull/34205)) Fixes an error where the engine would not retry a subprocess restart if the last attempt failed
|
||||
|
||||
- ([#34172](https://github.com/RocketChat/Rocket.Chat/pull/34172)) Fixes the subprocess restarting routine failing to correctly restart apps in some cases
|
||||
|
||||
- ([#34106](https://github.com/RocketChat/Rocket.Chat/pull/34106)) Adds simple app subprocess metrics report
|
||||
|
||||
- ([#34205](https://github.com/RocketChat/Rocket.Chat/pull/34205)) Fixes error propagation when trying to get the status of apps in some cases
|
||||
|
||||
- ([#34106](https://github.com/RocketChat/Rocket.Chat/pull/34106)) Attempts to restart an app subprocess if the spawn command fails
|
||||
|
||||
- ([#34205](https://github.com/RocketChat/Rocket.Chat/pull/34205)) Fixes wrong data being reported to total failed apps metrics and statistics
|
||||
|
||||
- ([#34106](https://github.com/RocketChat/Rocket.Chat/pull/34106)) Fixes an issue while collecting the error message from a failed restart attempt of an app subprocess
|
||||
|
||||
- ([#34106](https://github.com/RocketChat/Rocket.Chat/pull/34106)) Prevents app:getStatus requests from timing out in some cases
|
||||
|
||||
## 1.48.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#32727](https://github.com/RocketChat/Rocket.Chat/pull/32727)) These changes aims to add:
|
||||
- A brand-new omnichannel contact profile
|
||||
- The ability to communicate with known contacts only
|
||||
- Communicate with verified contacts only
|
||||
- Merge verified contacts across different channels
|
||||
- Block contact channels
|
||||
- Resolve conflicting contact information when registered via different channels
|
||||
- An advanced contact center filters
|
||||
- ([#33997](https://github.com/RocketChat/Rocket.Chat/pull/33997)) Prevent apps' subprocesses from crashing on unhandled rejections or uncaught exceptions
|
||||
|
||||
- ([#33690](https://github.com/RocketChat/Rocket.Chat/pull/33690)) Add support to configure apps runtime timeout via the APPS_ENGINE_RUNTIME_TIMEOUT environment variable
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#33713](https://github.com/RocketChat/Rocket.Chat/pull/33713)) Deprecated the `from` field in the apps email bridge and made it optional, using the server's settings when the field is omitted
|
||||
|
||||
- ([#33786](https://github.com/RocketChat/Rocket.Chat/pull/33786)) Fixed an issue that would grant network permission to app's processes in wrong cases
|
||||
|
||||
- ([#33865](https://github.com/RocketChat/Rocket.Chat/pull/33865)) Fixes an issue that would cause apps to appear disabled after a subprocess restart
|
||||
|
||||
- ([#33690](https://github.com/RocketChat/Rocket.Chat/pull/33690)) Removed the 1 second timeout of `Pre` app events. Now they will follow the "global" configuration
|
||||
|
||||
## 1.48.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#32727](https://github.com/RocketChat/Rocket.Chat/pull/32727)) These changes aims to add:
|
||||
- A brand-new omnichannel contact profile
|
||||
- The ability to communicate with known contacts only
|
||||
- Communicate with verified contacts only
|
||||
- Merge verified contacts across different channels
|
||||
- Block contact channels
|
||||
- Resolve conflicting contact information when registered via different channels
|
||||
- An advanced contact center filters
|
||||
- ([#33997](https://github.com/RocketChat/Rocket.Chat/pull/33997)) Prevent apps' subprocesses from crashing on unhandled rejections or uncaught exceptions
|
||||
|
||||
- ([#33690](https://github.com/RocketChat/Rocket.Chat/pull/33690)) Add support to configure apps runtime timeout via the APPS_ENGINE_RUNTIME_TIMEOUT environment variable
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#33713](https://github.com/RocketChat/Rocket.Chat/pull/33713)) Deprecated the `from` field in the apps email bridge and made it optional, using the server's settings when the field is omitted
|
||||
|
||||
- ([#33786](https://github.com/RocketChat/Rocket.Chat/pull/33786)) Fixed an issue that would grant network permission to app's processes in wrong cases
|
||||
|
||||
- ([#33865](https://github.com/RocketChat/Rocket.Chat/pull/33865)) Fixes an issue that would cause apps to appear disabled after a subprocess restart
|
||||
|
||||
- ([#33690](https://github.com/RocketChat/Rocket.Chat/pull/33690)) Removed the 1 second timeout of `Pre` app events. Now they will follow the "global" configuration
|
||||
|
||||
## 1.47.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#33433](https://github.com/RocketChat/Rocket.Chat/pull/33433)) Added support for interacting with add-ons issued in the license
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#33494](https://github.com/RocketChat/Rocket.Chat/pull/33494)) Fixed a problem in the deno runtime controller where it would not handle undefined child process references correctly
|
||||
|
||||
- ([#33417](https://github.com/RocketChat/Rocket.Chat/pull/33417)) Fixes issue with previously disabled private apps being auto enabled on update
|
||||
|
||||
## 1.47.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#33433](https://github.com/RocketChat/Rocket.Chat/pull/33433)) Added support for interacting with add-ons issued in the license
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#33494](https://github.com/RocketChat/Rocket.Chat/pull/33494)) Fixed a problem in the deno runtime controller where it would not handle undefined child process references correctly
|
||||
|
||||
- ([#33417](https://github.com/RocketChat/Rocket.Chat/pull/33417)) Fixes issue with previously disabled private apps being auto enabled on update
|
||||
125
packages/apps-engine/README.md
Normal file
125
packages/apps-engine/README.md
Normal file
@@ -0,0 +1,125 @@
|
||||
## Thoughts While Working (for docs)
|
||||
- Apps which don't provide a valid uuid4 id will be assigned one, but this is not recommended and your App should provide an id
|
||||
- The language strings are only done on the clients (`TAPi18next.addResourceBundle(lang, projectName, translations);`)
|
||||
- The implementer of this should restrict the server setting access and environmental variables. Idea is to allow the implementer to have a default set of restricted ones while letting the admin/owner of the server to restrict it even further or lift the restriction on some more. Simple interface with settings and checkbox to allow/disallow them. :thinking:
|
||||
|
||||
## What does the Apps-Engine enable you to do?
|
||||
The Apps-Engine is Rocket.Chat's _plugin framework_ - it provides the APIs for Rocket.Chat Apps to interact with the host system.
|
||||
|
||||
Currently, a Rocket.Chat App can:
|
||||
- Listen to message events
|
||||
- before/after sent
|
||||
- before/after updated
|
||||
- before/after deleted
|
||||
- Listen to room events
|
||||
- before/after created
|
||||
- before/after deleted
|
||||
- Send messages to users and livechat visitors
|
||||
- Register new slash commands
|
||||
- Register new HTTP endpoints
|
||||
|
||||
Some features the Engine allows Apps to use:
|
||||
- Key-Value Storage system
|
||||
- App specific settings
|
||||
|
||||
## Development environment with Rocket.Chat
|
||||
When developing new functionalities, you need to integrate the local version of the Apps-Engine with your local version of Rocket.Chat.
|
||||
|
||||
First of all, make sure you've installed all required packages and compiled the changes you've made to the Apps-Engine, since that is what Rocket.Chat will execute:
|
||||
```sh
|
||||
npm install
|
||||
npm run compile
|
||||
```
|
||||
|
||||
Now, you need to setup a local Rocket.Chat server, [so head to the project's README for instructions on getting started](https://github.com/RocketChat/Rocket.Chat#development) (if you haven't already). Make sure to actually clone the repo, since you will probably need to add some code to it in order to make your new functionality work.
|
||||
|
||||
After that, `cd` into Rocket.Chat folder and run:
|
||||
```sh
|
||||
meteor npm install PATH_TO_APPS_ENGINE
|
||||
```
|
||||
|
||||
Where `PATH_TO_APPS_ENGINE` is the path to the Apps-Engine repo you've cloned.
|
||||
|
||||
That's it! Now when you start Rocket.Chat with the `meteor` command, it will use your local Apps-Engine instead of the one on NPM :)
|
||||
|
||||
Whenever you make changes to the engine, run `npm run compile` again - meteor will take care of restarting the server due to the changes.
|
||||
|
||||
## Troubleshooting
|
||||
1. Sometimes, when you update the Apps-Engine code and compile it while Rocket.Chat is running, you might run on errors similar to these:
|
||||
|
||||
```
|
||||
Unable to resolve some modules:
|
||||
|
||||
"@rocket.chat/apps-engine/definition/AppStatus" in
|
||||
/Users/dev/rocket.chat/Rocket.Chat/app/apps/client/admin/helpers.js (web.browser)
|
||||
|
||||
If you notice problems related to these missing modules, consider running:
|
||||
|
||||
meteor npm install --save @rocket.chat/apps-engine
|
||||
```
|
||||
|
||||
Simply restart the meteor process and it should be fixed.
|
||||
|
||||
2. Sometimes when using `meteor npm install PATH_TO_APPS_ENGINE` will cause the following error :-
|
||||
|
||||
```
|
||||
npm ERR! code ENOENT
|
||||
npm ERR! syscall rename
|
||||
npm ERR! path PATH_TO_ROCKETCHAT/node_modules/.staging/@rocket.chat/apps-engine-c7135600/node_modules/@babel/code-frame
|
||||
npm ERR! dest PATH_TO_ROCKETCHAT/node_modules/.staging/@babel/code-frame-f3697825
|
||||
npm ERR! errno -2
|
||||
npm ERR! enoent ENOENT: no such file or directory, rename 'PATH_TO_ROCKETCHAT/node_modules/.staging/@rocket.chat/apps-engine-c7135600/node_modules/@babel/code-frame' -> 'PATH_TO_ROCKETCHAT/node_modules/.staging/@babel/code-frame-f3697825'
|
||||
npm ERR! enoent This is related to npm not being able to find a file.
|
||||
npm ERR! enoent
|
||||
```
|
||||
Here `PATH_TO_ROCKETCHAT` is the path to the main rocketchat server repo in your system
|
||||
To correct this we reinstall the package once again deleting the previous package
|
||||
```
|
||||
~/Rocket.Chat$ rm -rf node_modules/@rocket.chat/apps-engine
|
||||
~/Rocket.Chat$ cd PATH_TO_APP_ENGINE
|
||||
~/Rocket.Chat.Apps-engine$ npm install
|
||||
~/Rocket.Chat.Apps-engine$ cd PATH_TO_ROCKETCHAT
|
||||
~/Rocket.Chat$ meteor npm install ../Rocket.Chat.Apps-engine
|
||||
```
|
||||
|
||||
## Implementer Needs to Implement:
|
||||
- `src/server/storage/AppStorage`
|
||||
- `src/server/storage/AppLogStorage`
|
||||
- `src/server/bridges/*`
|
||||
|
||||
## Testing Framework:
|
||||
Makes great usage of TypeScript and decorators: https://github.com/alsatian-test/alsatian/wiki
|
||||
* To run the tests do: `npm run unit-tests`
|
||||
* To generate the coverage information: `npm run check-coverage`
|
||||
* To view the coverage: `npm run view-coverage`
|
||||
|
||||
# Rocket.Chat Apps TypeScript Definitions
|
||||
|
||||
## Handlers
|
||||
Handlers are essentially "listeners" for different events, except there are various ways to handle an event.
|
||||
When something happens there is `pre` and `post` handlers.
|
||||
The set of `pre` handlers happens before the event is finalized.
|
||||
The set of `post` handlers happens after the event is finalized.
|
||||
With that said, the rule of thumb is that if you are going to modify, extend, or change the data backing the event then that should be done in the `pre` handlers. If you are simply wanting to listen for when something happens and not modify anything, then the `post` is the way to go.
|
||||
|
||||
The order in which they happen is:
|
||||
* Pre**Event**Prevent
|
||||
* Pre**Event**Extend
|
||||
* Pre**Event**Modify
|
||||
* Post**Event**
|
||||
|
||||
Here is an explanation of what each of them means:
|
||||
* **Prevent**: This is ran to determine whether the event should be prevented or not.
|
||||
* **Extend**: This is ran to allow extending the data without being destructive of the data (adding an attachment to a message for example).
|
||||
* **Modify**: This is ran and allows for destructive changes to the data (change any and everything).
|
||||
* Post**Event**: Is mostly for simple listening and no changes can be made to the data.
|
||||
|
||||
## Generating/Updating Documentation
|
||||
To update or generate the documentation, please commit your changes first and then in a second commit provide the updated documentation.
|
||||
|
||||
# Engage with us
|
||||
## Share your story
|
||||
We’d love to hear about [your experience](https://survey.zohopublic.com/zs/e4BUFG) and potentially feature it on our [Blog](https://rocket.chat/case-studies/?utm_source=github&utm_medium=readme&utm_campaign=community).
|
||||
|
||||
## Subscribe for Updates
|
||||
Once a month our marketing team releases an email update with news about product releases, company related topics, events and use cases. [Sign Up!](https://rocket.chat/newsletter/?utm_source=github&utm_medium=readme&utm_campaign=community)
|
||||
83
packages/apps-engine/package.json
Normal file
83
packages/apps-engine/package.json
Normal file
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"name": "@rocket.chat/apps-engine",
|
||||
"version": "1.66.0",
|
||||
"description": "The public API and type definitions for Rocket.Chat App development.",
|
||||
"keywords": [
|
||||
"rocket.chat",
|
||||
"team chat",
|
||||
"apps engine"
|
||||
],
|
||||
"homepage": "https://github.com/RocketChat/Rocket.Chat.Apps-engine#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/RocketChat/Rocket.Chat.Apps-engine/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/RocketChat/Rocket.Chat.git",
|
||||
"directory": "packages/apps-engine"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Rocket.Chat",
|
||||
"url": "https://rocket.chat/"
|
||||
},
|
||||
"contributors": [
|
||||
{
|
||||
"name": "Bradley Hilton",
|
||||
"email": "bradley.hilton@rocket.chat"
|
||||
},
|
||||
{
|
||||
"name": "Rodrigo Nascimento",
|
||||
"email": "rodrigo.nascimento@rocket.chat"
|
||||
},
|
||||
{
|
||||
"name": "Douglas Gubert",
|
||||
"email": "douglas.gubert@rocket.chat"
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
"definition/**"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "run-s build:clean build:default",
|
||||
"build:clean": "rimraf definition",
|
||||
"build:default": "tsc -p tsconfig.json",
|
||||
"dev": "yarn build:default --watch",
|
||||
"gen-doc": "typedoc",
|
||||
"lint": "yarn eslint .",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"nyc": {
|
||||
"all": true,
|
||||
"extension": [
|
||||
".ts"
|
||||
],
|
||||
"include": [
|
||||
"src/*.ts",
|
||||
"src/server/**/*.ts"
|
||||
],
|
||||
"reporter": [
|
||||
"lcov",
|
||||
"json",
|
||||
"html"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"uuid": "~11.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rocket.chat/ui-kit": "workspace:~",
|
||||
"@types/node": "~24.13.2",
|
||||
"eslint": "~9.39.5",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"rimraf": "^6.0.1",
|
||||
"typedoc": "~0.28.20",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"volta": {
|
||||
"extends": "../../package.json"
|
||||
},
|
||||
"installConfig": {
|
||||
"hoistingLimits": "workspaces"
|
||||
}
|
||||
}
|
||||
261
packages/apps-engine/src/definition/App.ts
Normal file
261
packages/apps-engine/src/definition/App.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { AppStatus } from './AppStatus';
|
||||
import type { IApp } from './IApp';
|
||||
import type {
|
||||
IAppAccessors,
|
||||
IAppInstallationContext,
|
||||
IAppUninstallationContext,
|
||||
IConfigurationExtend,
|
||||
IConfigurationModify,
|
||||
IEnvironmentRead,
|
||||
IHttp,
|
||||
ILogger,
|
||||
IModify,
|
||||
IPersistence,
|
||||
IRead,
|
||||
IAppUpdateContext,
|
||||
} from './accessors';
|
||||
import type { IAppAuthorInfo } from './metadata/IAppAuthorInfo';
|
||||
import type { IAppInfo } from './metadata/IAppInfo';
|
||||
import type { ISetting } from './settings';
|
||||
import type { ISettingUpdateContext } from './settings/ISettingUpdateContext';
|
||||
|
||||
export abstract class App implements IApp {
|
||||
private status: AppStatus = AppStatus.UNKNOWN;
|
||||
|
||||
/**
|
||||
* Create a new App, this is called whenever the server starts up and initiates the Apps.
|
||||
* Note, your implementation of this class should call `super(name, id, version)` so we have it.
|
||||
* Also, please use the `initialize()` method to do items instead of the constructor as the constructor
|
||||
* *might* be called more than once but the `initialize()` will only be called once.
|
||||
*/
|
||||
public constructor(
|
||||
private readonly info: IAppInfo,
|
||||
private readonly logger: ILogger,
|
||||
private readonly accessors?: IAppAccessors,
|
||||
) {
|
||||
this.logger.debug(
|
||||
`Constructed the App ${this.info.name} (${this.info.id})`,
|
||||
`v${this.info.version} which depends on the API v${this.info.requiredApiVersion}!`,
|
||||
`Created by ${this.info.author.name}`,
|
||||
);
|
||||
|
||||
void this.setStatus(AppStatus.CONSTRUCTED);
|
||||
}
|
||||
|
||||
public async getStatus(): Promise<AppStatus> {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of this App.
|
||||
*
|
||||
* @return {string} the name
|
||||
*/
|
||||
public getName(): string {
|
||||
return this.info.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the sluggified name of this App.
|
||||
*
|
||||
* @return {string} the name slugged
|
||||
*/
|
||||
public getNameSlug(): string {
|
||||
return this.info.nameSlug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the username of this App's app user.
|
||||
*
|
||||
* @return {string} the username of the app user
|
||||
*
|
||||
* @deprecated This method will be removed in the next major version.
|
||||
* Please use read.getUserReader().getAppUser() instead.
|
||||
*/
|
||||
public getAppUserUsername(): string {
|
||||
return `${this.info.nameSlug}.bot`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID of this App, please see <link> for how to obtain an ID for your App.
|
||||
*
|
||||
* @return {number} the ID
|
||||
*/
|
||||
public getID(): string {
|
||||
return this.info.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the version of this App, using http://semver.org/.
|
||||
*
|
||||
* @return {string} the version
|
||||
*/
|
||||
public getVersion(): string {
|
||||
return this.info.version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the description of this App, mostly used to show to the clients/administrators.
|
||||
*
|
||||
* @return {string} the description
|
||||
*/
|
||||
public getDescription(): string {
|
||||
return this.info.description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the API Version which this App depends on (http://semver.org/).
|
||||
* This property is used for the dependency injections.
|
||||
*
|
||||
* @return {string} the required api version
|
||||
*/
|
||||
public getRequiredApiVersion(): string {
|
||||
return this.info.requiredApiVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the information regarding the author/maintainer of this App.
|
||||
*
|
||||
* @return author information
|
||||
*/
|
||||
public getAuthorInfo(): IAppAuthorInfo {
|
||||
return this.info.author;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the entirity of the App's information.
|
||||
*
|
||||
* @return App information
|
||||
*/
|
||||
public getInfo(): IAppInfo {
|
||||
return this.info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the ILogger instance for this App.
|
||||
*
|
||||
* @return the logger instance
|
||||
*/
|
||||
public getLogger(): ILogger {
|
||||
return this.logger;
|
||||
}
|
||||
|
||||
public getAccessors(): IAppAccessors {
|
||||
if (!this.accessors) {
|
||||
throw new Error('Accessors not initialized');
|
||||
}
|
||||
|
||||
return this.accessors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method which will be called when the App is initialized. This is the recommended place
|
||||
* to add settings and slash commands. If an error is thrown, all commands will be unregistered.
|
||||
*/
|
||||
public async initialize(configurationExtend: IConfigurationExtend, environmentRead: IEnvironmentRead): Promise<void> {
|
||||
await this.extendConfiguration(configurationExtend, environmentRead);
|
||||
}
|
||||
|
||||
/**
|
||||
* Method which is called when this App is enabled and can be called several
|
||||
* times during this instance's life time. Once after the `initialize()` is called,
|
||||
* pending it doesn't throw an error, and then anytime the App is enabled by the user.
|
||||
* If this method, `onEnable()`, returns false, then this App will not
|
||||
* actually be enabled (ex: a setting isn't configured).
|
||||
*
|
||||
* @return whether the App should be enabled or not
|
||||
*/
|
||||
public async onEnable(environment: IEnvironmentRead, configurationModify: IConfigurationModify): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method which is called when this App is disabled and it can be called several times.
|
||||
* If this App was enabled and then the user disabled it, this method will be called.
|
||||
*/
|
||||
public async onDisable(configurationModify: IConfigurationModify): Promise<void> {}
|
||||
|
||||
/**
|
||||
* Method which is called when the App is uninstalled and it is called one single time.
|
||||
*
|
||||
* This method will NOT be called when an App is getting disabled manually, ONLY when
|
||||
* it's being uninstalled from Rocket.Chat.
|
||||
*/
|
||||
public async onUninstall(
|
||||
context: IAppUninstallationContext,
|
||||
read: IRead,
|
||||
http: IHttp,
|
||||
persistence: IPersistence,
|
||||
modify: IModify,
|
||||
): Promise<void> {}
|
||||
|
||||
/**
|
||||
* Method which is called when the App is installed and it is called one single time.
|
||||
*
|
||||
* This method is NOT called when the App is updated.
|
||||
*/
|
||||
public async onInstall(
|
||||
context: IAppInstallationContext,
|
||||
read: IRead,
|
||||
http: IHttp,
|
||||
persistence: IPersistence,
|
||||
modify: IModify,
|
||||
): Promise<void> {}
|
||||
|
||||
/**
|
||||
* Method which is called when the App is updated and it is called one single time.
|
||||
*
|
||||
* This method is NOT called when the App is installed.
|
||||
*/
|
||||
public async onUpdate(context: IAppUpdateContext, read: IRead, http: IHttp, persistence: IPersistence, modify: IModify): Promise<void> {}
|
||||
|
||||
/**
|
||||
* Method which is called whenever a setting which belongs to this App has been updated
|
||||
* by an external system and not this App itself. The setting passed is the newly updated one.
|
||||
*
|
||||
* @param setting the setting which was updated
|
||||
* @param configurationModify the accessor to modifiy the system
|
||||
* @param reader the reader accessor
|
||||
* @param http an accessor to the outside world
|
||||
*/
|
||||
public async onSettingUpdated(setting: ISetting, configurationModify: IConfigurationModify, read: IRead, http: IHttp): Promise<void> {}
|
||||
|
||||
/**
|
||||
* Method which is called before a setting which belongs to this App is going to be updated
|
||||
* by an external system and not this App itself. The setting passed is the newly updated one.
|
||||
*
|
||||
* @param setting the setting which is going to be updated
|
||||
* @param configurationModify the accessor to modifiy the system
|
||||
* @param reader the reader accessor
|
||||
* @param http an accessor to the outside world
|
||||
*/
|
||||
public async onPreSettingUpdate(
|
||||
context: ISettingUpdateContext,
|
||||
configurationModify: IConfigurationModify,
|
||||
read: IRead,
|
||||
http: IHttp,
|
||||
): Promise<ISetting> {
|
||||
return context.newSetting;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method will be called during initialization. It allows for adding custom configuration options and defaults
|
||||
* @param configuration
|
||||
*/
|
||||
protected async extendConfiguration(configuration: IConfigurationExtend, environmentRead: IEnvironmentRead): Promise<void> {}
|
||||
|
||||
/**
|
||||
* Sets the status this App is now at, use only when 100% true (it's protected for a reason).
|
||||
*
|
||||
* @param status the new status of this App
|
||||
*/
|
||||
protected async setStatus(status: AppStatus): Promise<void> {
|
||||
this.logger.debug({ msg: 'The status is now', status });
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
// Avoid leaking references if object is serialized (e.g. to be sent over IPC)
|
||||
public toJSON(): Record<string, any> {
|
||||
return this.info;
|
||||
}
|
||||
}
|
||||
65
packages/apps-engine/src/definition/AppStatus.ts
Normal file
65
packages/apps-engine/src/definition/AppStatus.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export enum AppStatus {
|
||||
/** The status is known, aka not been constructed the proper way. */
|
||||
UNKNOWN = 'unknown',
|
||||
/** The App has been constructed but that's it. */
|
||||
CONSTRUCTED = 'constructed',
|
||||
/** The App's `initialize()` was called and returned true. */
|
||||
INITIALIZED = 'initialized',
|
||||
/** The App's `onEnable()` was called, returned true, and this was done automatically (system start up). */
|
||||
AUTO_ENABLED = 'auto_enabled',
|
||||
/** The App's `onEnable()` was called, returned true, and this was done by the user such as installing a new one. */
|
||||
MANUALLY_ENABLED = 'manually_enabled',
|
||||
/**
|
||||
* The App was disabled due to an error while attempting to compile it.
|
||||
* An attempt to enable it again will fail, as it needs to be updated.
|
||||
*/
|
||||
COMPILER_ERROR_DISABLED = 'compiler_error_disabled',
|
||||
/**
|
||||
* The App was disable due to its license being invalid
|
||||
*/
|
||||
INVALID_LICENSE_DISABLED = 'invalid_license_disabled',
|
||||
/**
|
||||
* The app was disabled due to an invalid installation or validation in its signature.
|
||||
*/
|
||||
INVALID_INSTALLATION_DISABLED = 'invalid_installation_disabled',
|
||||
/** The App was disabled due to an unrecoverable error being thrown. */
|
||||
ERROR_DISABLED = 'error_disabled',
|
||||
/** The App was manually disabled by a user. */
|
||||
MANUALLY_DISABLED = 'manually_disabled',
|
||||
INVALID_SETTINGS_DISABLED = 'invalid_settings_disabled',
|
||||
/** The App was disabled due to other circumstances. */
|
||||
DISABLED = 'disabled',
|
||||
}
|
||||
|
||||
export class AppStatusUtilsDef {
|
||||
public isEnabled(status: AppStatus): boolean {
|
||||
switch (status) {
|
||||
case AppStatus.AUTO_ENABLED:
|
||||
case AppStatus.MANUALLY_ENABLED:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public isDisabled(status: AppStatus): boolean {
|
||||
switch (status) {
|
||||
case AppStatus.COMPILER_ERROR_DISABLED:
|
||||
case AppStatus.ERROR_DISABLED:
|
||||
case AppStatus.MANUALLY_DISABLED:
|
||||
case AppStatus.INVALID_SETTINGS_DISABLED:
|
||||
case AppStatus.INVALID_LICENSE_DISABLED:
|
||||
case AppStatus.INVALID_INSTALLATION_DISABLED:
|
||||
case AppStatus.DISABLED:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public isError(status: AppStatus): boolean {
|
||||
return [AppStatus.ERROR_DISABLED, AppStatus.COMPILER_ERROR_DISABLED].includes(status);
|
||||
}
|
||||
}
|
||||
|
||||
export const AppStatusUtils = new AppStatusUtilsDef();
|
||||
90
packages/apps-engine/src/definition/IApp.ts
Normal file
90
packages/apps-engine/src/definition/IApp.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { AppStatus } from './AppStatus';
|
||||
import type { IAppAccessors } from './accessors';
|
||||
import type { ILogger } from './accessors/ILogger';
|
||||
import type { IAppAuthorInfo } from './metadata/IAppAuthorInfo';
|
||||
import type { IAppInfo } from './metadata/IAppInfo';
|
||||
|
||||
export interface IApp {
|
||||
/**
|
||||
* Gets the status of this App.
|
||||
*
|
||||
* @return {AppStatus} the status/state of the App
|
||||
*/
|
||||
getStatus(): Promise<AppStatus>;
|
||||
|
||||
/**
|
||||
* Get the name of this App.
|
||||
*
|
||||
* @return {string} the name
|
||||
*/
|
||||
getName(): string;
|
||||
|
||||
/**
|
||||
* Gets the sluggified name of this App.
|
||||
*
|
||||
* @return {string} the name slugged
|
||||
*/
|
||||
getNameSlug(): string;
|
||||
|
||||
/**
|
||||
* Gets the username of this App's app user.
|
||||
*
|
||||
* @return {string} the username of the app user
|
||||
*
|
||||
* @deprecated This method will be removed in the next major version.
|
||||
* Please use read.getAppUser instead.
|
||||
*/
|
||||
getAppUserUsername(): string;
|
||||
|
||||
/**
|
||||
* Get the ID of this App, please see <link> for how to obtain an ID for your App.
|
||||
*
|
||||
* @return {number} the ID
|
||||
*/
|
||||
getID(): string;
|
||||
|
||||
/**
|
||||
* Get the version of this App, using http://semver.org/.
|
||||
*
|
||||
* @return {string} the version
|
||||
*/
|
||||
getVersion(): string;
|
||||
|
||||
/**
|
||||
* Get the description of this App, mostly used to show to the clients/administrators.
|
||||
*
|
||||
* @return {string} the description
|
||||
*/
|
||||
getDescription(): string;
|
||||
|
||||
/**
|
||||
* Gets the API Version which this App depends on (http://semver.org/).
|
||||
* This property is used for the dependency injections.
|
||||
*
|
||||
* @return {string} the required api version
|
||||
*/
|
||||
getRequiredApiVersion(): string;
|
||||
|
||||
/**
|
||||
* Gets the information regarding the author/maintainer of this App.
|
||||
*
|
||||
* @return author information
|
||||
*/
|
||||
getAuthorInfo(): IAppAuthorInfo;
|
||||
|
||||
/**
|
||||
* Gets the entirity of the App's information.
|
||||
*
|
||||
* @return App information
|
||||
*/
|
||||
getInfo(): IAppInfo;
|
||||
|
||||
/**
|
||||
* Gets the ILogger instance for this App.
|
||||
*
|
||||
* @return the logger instance
|
||||
*/
|
||||
getLogger(): ILogger;
|
||||
|
||||
getAccessors(): IAppAccessors;
|
||||
}
|
||||
21
packages/apps-engine/src/definition/LICENSE
Normal file
21
packages/apps-engine/src/definition/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2015-2023 Rocket.Chat Technologies Corp.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
12
packages/apps-engine/src/definition/abac/AbacAttributes.ts
Normal file
12
packages/apps-engine/src/definition/abac/AbacAttributes.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export interface IAbacAttributeDefinition {
|
||||
/**
|
||||
* Validation expectation (NOT enforced here, must be enforced by caller):
|
||||
* /^[A-Za-z0-9_-]+$/
|
||||
*/
|
||||
key: string;
|
||||
|
||||
/**
|
||||
* List of string values for this attribute key.
|
||||
*/
|
||||
values: string[];
|
||||
}
|
||||
16
packages/apps-engine/src/definition/accessors/IApiExtend.ts
Normal file
16
packages/apps-engine/src/definition/accessors/IApiExtend.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { IApi } from '../api';
|
||||
|
||||
/**
|
||||
* This accessor provides methods for adding a custom api.
|
||||
* It is provided during the initialization of your App
|
||||
*/
|
||||
|
||||
export interface IApiExtend {
|
||||
/**
|
||||
* Adds an api which can be called by external services lateron.
|
||||
* Should an api already exists an error will be thrown.
|
||||
*
|
||||
* @param api the command information
|
||||
*/
|
||||
provideApi(api: IApi): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { IEnvironmentRead, IHttp, IRead } from '.';
|
||||
import type { IApiEndpointMetadata } from '../api';
|
||||
import type { IEnvironmentWrite } from './IEnvironmentWrite';
|
||||
|
||||
export interface IAppAccessors {
|
||||
readonly environmentReader: IEnvironmentRead;
|
||||
readonly environmentWriter: IEnvironmentWrite;
|
||||
readonly reader: IRead;
|
||||
readonly http: IHttp;
|
||||
readonly providedApiEndpoints: Array<IApiEndpointMetadata>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { IUser } from '../users';
|
||||
|
||||
export interface IAppInstallationContext {
|
||||
user: IUser;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { IUser } from '../users';
|
||||
|
||||
export interface IAppUninstallationContext {
|
||||
user: IUser;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { IUser } from '../users';
|
||||
|
||||
export interface IAppUpdateContext {
|
||||
user?: IUser;
|
||||
oldAppVersion: string;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { IWorkspaceToken } from '../cloud/IWorkspaceToken';
|
||||
|
||||
/**
|
||||
* Accessor that enables apps to read information
|
||||
* related to the Cloud connectivity of the workspace.
|
||||
*
|
||||
* Methods in this accessor will usually connect to the
|
||||
* Rocket.Chat Cloud, which means they won't work properly
|
||||
* in air-gapped environment.
|
||||
*
|
||||
* This accessor available via `IRead` object, which is
|
||||
* usually received as a parameter wherever it's available.
|
||||
*/
|
||||
export interface ICloudWorkspaceRead {
|
||||
/**
|
||||
* Returns an access token that can be used to access
|
||||
* Cloud Services on the workspace's behalf.
|
||||
*
|
||||
* @param scope The scope that the token should be authorized with
|
||||
*
|
||||
* @RequiresPermission cloud.workspace-token; scopes: Array<string>
|
||||
*/
|
||||
getWorkspaceToken(scope: string): Promise<IWorkspaceToken>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { IApiExtend } from './IApiExtend';
|
||||
import type { IExternalComponentsExtend } from './IExternalComponentsExtend';
|
||||
import type { IHttpExtend } from './IHttp';
|
||||
import type { IOutboundCommunicationProviderExtend } from './IOutboundCommunicationProviderExtend';
|
||||
import type { ISchedulerExtend } from './ISchedulerExtend';
|
||||
import type { ISettingsExtend } from './ISettingsExtend';
|
||||
import type { ISlashCommandsExtend } from './ISlashCommandsExtend';
|
||||
import type { IUIExtend } from './IUIExtend';
|
||||
import type { IVideoConfProvidersExtend } from './IVideoConfProvidersExtend';
|
||||
|
||||
/**
|
||||
* This accessor provides methods for declaring the configuration
|
||||
* of your App. It is provided during initialization of your App.
|
||||
*/
|
||||
export interface IConfigurationExtend {
|
||||
/** Accessor for customing the handling of IHttp requests and responses your App causes. */
|
||||
readonly http: IHttpExtend;
|
||||
|
||||
/** Accessor for declaring the settings your App provides. */
|
||||
readonly settings: ISettingsExtend;
|
||||
|
||||
/** Accessor for declaring the commands which your App provides. */
|
||||
readonly slashCommands: ISlashCommandsExtend;
|
||||
|
||||
/** Accessor for declaring api endpoints. */
|
||||
readonly api: IApiExtend;
|
||||
|
||||
readonly externalComponents: IExternalComponentsExtend;
|
||||
|
||||
/** Accessor for declaring tasks that can be scheduled (like cron) */
|
||||
readonly scheduler: ISchedulerExtend;
|
||||
/** Accessor for registering different elements in the host UI */
|
||||
readonly ui: IUIExtend;
|
||||
|
||||
/** Accessor for declaring the videoconf providers which your App provides. */
|
||||
readonly videoConfProviders: IVideoConfProvidersExtend;
|
||||
|
||||
/** Accessor for declaring outbound communication providers */
|
||||
readonly outboundCommunication: IOutboundCommunicationProviderExtend;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ISchedulerModify } from './ISchedulerModify';
|
||||
import type { IServerSettingsModify } from './IServerSettingsModify';
|
||||
import type { ISlashCommandsModify } from './ISlashCommandsModify';
|
||||
|
||||
/**
|
||||
* This accessor provides methods for modifying the configuration
|
||||
* of Rocket.Chat. It is provided during "onEnable" of your App.
|
||||
*/
|
||||
export interface IConfigurationModify {
|
||||
/** Accessor for modifying the settings inside of Rocket.Chat. */
|
||||
readonly serverSettings: IServerSettingsModify;
|
||||
|
||||
/** Accessor for modifying the slash commands inside of Rocket.Chat. */
|
||||
readonly slashCommands: ISlashCommandsModify;
|
||||
|
||||
/** Accessor for modifying schedulers */
|
||||
readonly scheduler: ISchedulerModify;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ILivechatContact } from '../livechat';
|
||||
|
||||
export interface IContactCreator {
|
||||
verifyContact(verifyContactChannelParams: {
|
||||
contactId: string;
|
||||
field: string;
|
||||
value: string;
|
||||
visitorId: string;
|
||||
roomId: string;
|
||||
}): Promise<void>;
|
||||
|
||||
addContactEmail(contactId: ILivechatContact['_id'], email: string): Promise<ILivechatContact>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ILivechatContact } from '../livechat';
|
||||
|
||||
export interface IContactRead {
|
||||
getById(contactId: ILivechatContact['_id']): Promise<ILivechatContact | null>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { IRoomBuilder } from '.';
|
||||
import type { IMessage } from '../messages';
|
||||
import type { RocketChatAssociationModel } from '../metadata';
|
||||
import type { IRoom } from '../rooms';
|
||||
|
||||
/**
|
||||
* Interface for building out a room.
|
||||
* Please note, a room creator, name, and type must be set otherwise you will NOT
|
||||
* be able to successfully save the room object.
|
||||
*/
|
||||
export interface IDiscussionBuilder extends IRoomBuilder {
|
||||
kind: RocketChatAssociationModel.DISCUSSION;
|
||||
|
||||
setParentRoom(parentRoom: IRoom): IDiscussionBuilder;
|
||||
|
||||
getParentRoom(): IRoom;
|
||||
|
||||
setParentMessage(parentMessage: IMessage): IDiscussionBuilder;
|
||||
|
||||
getParentMessage(): IMessage;
|
||||
|
||||
setReply(reply: string): IDiscussionBuilder;
|
||||
|
||||
getReply(): string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { IEmail } from '../email';
|
||||
|
||||
export interface IEmailCreator {
|
||||
/**
|
||||
* Sends an email through Rocket.Chat
|
||||
*
|
||||
* @param email the email data
|
||||
*/
|
||||
send(email: IEmail): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { IEnvironmentalVariableRead } from './IEnvironmentalVariableRead';
|
||||
import type { IServerSettingRead } from './IServerSettingRead';
|
||||
import type { ISettingRead } from './ISettingRead';
|
||||
|
||||
/**
|
||||
* Allows read-access to the App's settings,
|
||||
* the certain server's settings along with environmental
|
||||
* variables all of which are not user created.
|
||||
*/
|
||||
export interface IEnvironmentRead {
|
||||
/** Gets an instance of the App's settings reader. */
|
||||
getSettings(): ISettingRead;
|
||||
|
||||
/**
|
||||
* Gets an instance of the Server's Settings reader.
|
||||
* Please note: Due to security concerns, only a subset of settings
|
||||
* are accessible.
|
||||
*/
|
||||
getServerSettings(): IServerSettingRead;
|
||||
|
||||
/**
|
||||
* Gets an instance of the Environmental Variables reader.
|
||||
* Please note: Due to security concerns, only a subset of
|
||||
* them are readable.
|
||||
*/
|
||||
getEnvironmentVariables(): IEnvironmentalVariableRead;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { IServerSettingUpdater } from './IServerSettingUpdater';
|
||||
import type { ISettingUpdater } from './ISettingUpdater';
|
||||
|
||||
/**
|
||||
* Allows write-access to the App's settings,
|
||||
*/
|
||||
export interface IEnvironmentWrite {
|
||||
getSettings(): ISettingUpdater;
|
||||
getServerSettings(): IServerSettingUpdater;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** A reader for reading the Environmental Variables. */
|
||||
export interface IEnvironmentalVariableRead {
|
||||
/** Gets the value for a variable. */
|
||||
getValueByName(envVarName: string): Promise<string>;
|
||||
|
||||
/** Checks to see if Apps can access the given variable name. */
|
||||
isReadable(envVarName: string): Promise<boolean>;
|
||||
|
||||
/** Checks to see if any value is set for the given variable name. */
|
||||
isSet(envVarName: string): Promise<boolean>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @description
|
||||
* Experimental bridge for experimental features.
|
||||
* Methods in this class are not guaranteed to be stable between updates as the
|
||||
* team evaluates the proper signature, underlying implementation and performance
|
||||
* impact of candidates for future APIs
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface IExperimentalRead {}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { IExternalComponent } from '../externalComponent';
|
||||
|
||||
/**
|
||||
* This accessor provides a method for registering external
|
||||
* components. This is provided during the initialization of your App.
|
||||
*/
|
||||
export interface IExternalComponentsExtend {
|
||||
/**
|
||||
* Register an external component to the system.
|
||||
* If you call this method twice and the component
|
||||
* has the same name as before, the first one will be
|
||||
* overwritten as the names provided **must** be unique.
|
||||
*
|
||||
* @param externalComponent the external component to be registered
|
||||
*/
|
||||
register(externalComponent: IExternalComponent): Promise<void>;
|
||||
}
|
||||
211
packages/apps-engine/src/definition/accessors/IHttp.ts
Normal file
211
packages/apps-engine/src/definition/accessors/IHttp.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import type { IPersistence } from './IPersistence';
|
||||
import type { IRead } from './IRead';
|
||||
|
||||
/**
|
||||
* The Http package allows users to call out to an external web service.
|
||||
* Based off of: https://github.com/meteor-typings/meteor/blob/master/1.4/main.d.ts#L869
|
||||
*/
|
||||
export interface IHttp {
|
||||
get(url: string, options?: IHttpRequest): Promise<IHttpResponse>;
|
||||
|
||||
post(url: string, options?: IHttpRequest): Promise<IHttpResponse>;
|
||||
|
||||
put(url: string, options?: IHttpRequest): Promise<IHttpResponse>;
|
||||
|
||||
del(url: string, options?: IHttpRequest): Promise<IHttpResponse>;
|
||||
|
||||
patch(url: string, options?: IHttpRequest): Promise<IHttpResponse>;
|
||||
}
|
||||
|
||||
export enum RequestMethod {
|
||||
GET = 'get',
|
||||
POST = 'post',
|
||||
PUT = 'put',
|
||||
DELETE = 'delete',
|
||||
HEAD = 'head',
|
||||
OPTIONS = 'options',
|
||||
PATCH = 'patch',
|
||||
}
|
||||
|
||||
export interface IHttpRequest {
|
||||
content?: string;
|
||||
data?: any;
|
||||
query?: string;
|
||||
params?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
auth?: string;
|
||||
headers?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
timeout?: number;
|
||||
/**
|
||||
* The encoding to be used on response data.
|
||||
*
|
||||
* If null, the body is returned as a Buffer. Anything else (including the default value of undefined)
|
||||
* will be passed as the encoding parameter to toString() (meaning this is effectively 'utf8' by default).
|
||||
* (Note: if you expect binary data, you should set encoding: null.)
|
||||
*/
|
||||
encoding?: string | null;
|
||||
/**
|
||||
* if `true`, requires SSL certificates be valid.
|
||||
*
|
||||
* Defaul: `true`;
|
||||
*/
|
||||
strictSSL?: boolean;
|
||||
/**
|
||||
* If `true`, the server certificate is verified against the list of supplied CAs.
|
||||
*
|
||||
* Default: `true`.
|
||||
*
|
||||
* https://nodejs.org/api/tls.html#tls_tls_connect_options_callback
|
||||
*/
|
||||
rejectUnauthorized?: boolean;
|
||||
/**
|
||||
* If `true`, enables SSRF (Server-Side Request Forgery) validation for the HTTP request.
|
||||
* The request URL will be checked against internal IP addresses and private networks.
|
||||
*
|
||||
* Default: `false`
|
||||
*
|
||||
* As SSRF validation can potentially break legitimate requests to internal resources, it is not enabled by default.
|
||||
*/
|
||||
ssrfValidation?: boolean;
|
||||
}
|
||||
|
||||
export interface IHttpResponse {
|
||||
url: string;
|
||||
method: RequestMethod;
|
||||
statusCode: number;
|
||||
headers?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
content?: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export interface IHttpExtend {
|
||||
/**
|
||||
* A method for providing a single header which is added to every request.
|
||||
*
|
||||
* @param key the name of the header
|
||||
* @param value the header's content
|
||||
*/
|
||||
provideDefaultHeader(key: string, value: string): void;
|
||||
|
||||
/**
|
||||
* A method for providing more than one header which are added to every request.
|
||||
*
|
||||
* @param headers an object with strings as the keys (header name) and strings as values (header content)
|
||||
*/
|
||||
provideDefaultHeaders(headers: { [key: string]: string }): void;
|
||||
|
||||
/**
|
||||
* A method for providing a single query parameter which is added to every request.
|
||||
*
|
||||
* @param key the name of the query parameter
|
||||
* @param value the query parameter's content
|
||||
*/
|
||||
provideDefaultParam(key: string, value: string): void;
|
||||
|
||||
/**
|
||||
* A method for providing more than one query parameters which are added to every request.
|
||||
*
|
||||
* @param headers an object with strings as the keys (parameter name) and strings as values (parameter content)
|
||||
*/
|
||||
provideDefaultParams(params: { [key: string]: string }): void;
|
||||
|
||||
/**
|
||||
* Method for providing a function which is called before every request is called out to the final destination.
|
||||
* This can be called more than once which means there can be more than one handler. The order provided is the order called.
|
||||
* Note: if this handler throws an error when it is executed then the request will be aborted.
|
||||
*
|
||||
* @param handler the instance of the IHttpPreRequestHandler
|
||||
*/
|
||||
providePreRequestHandler(handler: IHttpPreRequestHandler): void;
|
||||
|
||||
/**
|
||||
* Method for providing a function which is called after every response is got from the url and before the result is returned.
|
||||
* This can be called more than once which means there can be more than one handler. The order provided is the order called.
|
||||
* Note: if this handler throws an error when it is executed then the respone will not be returned
|
||||
*
|
||||
* @param handler the instance of the IHttpPreResponseHandler
|
||||
*/
|
||||
providePreResponseHandler(handler: IHttpPreResponseHandler): void;
|
||||
|
||||
/**
|
||||
* A method for getting all of the default headers provided, the value is a readonly and any modifications done will be ignored.
|
||||
* Please use the provider methods for adding them.
|
||||
*/
|
||||
getDefaultHeaders(): Map<string, string>;
|
||||
|
||||
/**
|
||||
* A method for getting all of the default parameters provided, the value is a readonly and any modifications done will be ignored.
|
||||
* Please use the provider methods for adding them.
|
||||
*/
|
||||
getDefaultParams(): Map<string, string>;
|
||||
|
||||
/**
|
||||
* A method for getting all of the pre-request handlers provided, the value is a readonly and any modifications done will be ignored.
|
||||
* Please use the provider methods for adding them.
|
||||
*/
|
||||
getPreRequestHandlers(): Array<IHttpPreRequestHandler>;
|
||||
|
||||
/**
|
||||
* A method for getting all of the pre-response handlers provided, the value is a readonly and any modifications done will be ignored.
|
||||
* Please use the provider methods for adding them.
|
||||
*/
|
||||
getPreResponseHandlers(): Array<IHttpPreResponseHandler>;
|
||||
}
|
||||
|
||||
export interface IHttpPreRequestHandler {
|
||||
executePreHttpRequest(url: string, request: IHttpRequest, read: IRead, persistence: IPersistence): Promise<IHttpRequest>;
|
||||
}
|
||||
|
||||
export interface IHttpPreResponseHandler {
|
||||
executePreHttpResponse(response: IHttpResponse, read: IRead, persistence: IPersistence): Promise<IHttpResponse>;
|
||||
}
|
||||
|
||||
export enum HttpStatusCode {
|
||||
CONTINUE = 100,
|
||||
SWITCHING_PROTOCOLS = 101,
|
||||
OK = 200,
|
||||
CREATED = 201,
|
||||
ACCEPTED = 202,
|
||||
NON_AUTHORITATIVE_INFORMATION = 203,
|
||||
NO_CONTENT = 204,
|
||||
RESET_CONTENT = 205,
|
||||
PARTIAL_CONTENT = 206,
|
||||
MULTIPLE_CHOICES = 300,
|
||||
MOVED_PERMANENTLY = 301,
|
||||
FOUND = 302,
|
||||
SEE_OTHER = 303,
|
||||
NOT_MODIFIED = 304,
|
||||
USE_PROXY = 305,
|
||||
TEMPORARY_REDIRECT = 307,
|
||||
BAD_REQUEST = 400,
|
||||
UNAUTHORIZED = 401,
|
||||
PAYMENT_REQUIRED = 402,
|
||||
FORBIDDEN = 403,
|
||||
NOT_FOUND = 404,
|
||||
METHOD_NOT_ALLOWED = 405,
|
||||
NOT_ACCEPTABLE = 406,
|
||||
PROXY_AUTHENTICATION_REQUIRED = 407,
|
||||
REQUEST_TIMEOUT = 408,
|
||||
CONFLICT = 409,
|
||||
GONE = 410,
|
||||
LENGTH_REQUIRED = 411,
|
||||
PRECONDITION_FAILED = 412,
|
||||
REQUEST_ENTITY_TOO_LARGE = 413,
|
||||
REQUEST_URI_TOO_LONG = 414,
|
||||
UNSUPPORTED_MEDIA_TYPE = 415,
|
||||
REQUESTED_RANGE_NOT_SATISFIABLE = 416,
|
||||
EXPECTATION_FAILED = 417,
|
||||
UNPROCESSABLE_ENTITY = 422,
|
||||
TOO_MANY_REQUESTS = 429,
|
||||
INTERNAL_SERVER_ERROR = 500,
|
||||
NOT_IMPLEMENTED = 501,
|
||||
BAD_GATEWAY = 502,
|
||||
SERVICE_UNAVAILABLE = 503,
|
||||
GATEWAY_TIMEOUT = 504,
|
||||
HTTP_VERSION_NOT_SUPPORTED = 505,
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ILivechatRoom, IVisitor, IVisitorExternalIdentifier, ResolveVisitorContactData } from '../livechat';
|
||||
import type { IUser } from '../users';
|
||||
|
||||
export interface IExtraRoomParams {
|
||||
source?: ILivechatRoom['source'];
|
||||
customFields?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ILivechatCreator {
|
||||
/**
|
||||
* Resolves a visitor by external identifier (e.g., WhatsApp BSUID) with contact data fallback.
|
||||
* If found by contact data (phone or email) but not by externalId, enriches the visitor record with the externalId.
|
||||
* @param externalId The external identifier containing entityId and optional metadata (e.g., `{ entityId: 'bsuid-123', metadata: { username: '@user' } }`)
|
||||
* @param contactData Optional contact data for fallback lookup. Use `{ phone: '+1234567890' }` or `{ email: 'user@example.com' }`
|
||||
* @returns The visitor if found, undefined otherwise
|
||||
*/
|
||||
resolveVisitor(
|
||||
externalId: Omit<IVisitorExternalIdentifier, 'appId'>,
|
||||
contactData?: ResolveVisitorContactData,
|
||||
): Promise<IVisitor | undefined>;
|
||||
/**
|
||||
* Creates a room to connect the `visitor` to an `agent`.
|
||||
*
|
||||
* This method uses the Livechat routing method configured
|
||||
* in the server
|
||||
*
|
||||
* @param visitor The Livechat Visitor that started the conversation
|
||||
* @param agent The agent responsible for the room
|
||||
*/
|
||||
createRoom(visitor: IVisitor, agent: IUser, extraParams?: IExtraRoomParams): Promise<ILivechatRoom>;
|
||||
|
||||
/**
|
||||
* @deprecated Use `createAndReturnVisitor` instead.
|
||||
* Creates a Livechat visitor
|
||||
*
|
||||
* @param visitor Data of the visitor to be created
|
||||
*/
|
||||
createVisitor(visitor: IVisitor): Promise<string>;
|
||||
|
||||
/**
|
||||
* Creates a Livechat visitor
|
||||
*
|
||||
* @param visitor Data of the visitor to be created
|
||||
*/
|
||||
createAndReturnVisitor(visitor: IVisitor): Promise<IVisitor | undefined>;
|
||||
|
||||
/**
|
||||
* Creates a token to be used when
|
||||
* creating a new livechat visitor
|
||||
*/
|
||||
createToken(): string;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { ILivechatMessage, IVisitor } from '../livechat';
|
||||
import type { IMessageAttachment } from '../messages';
|
||||
import type { RocketChatAssociationModel } from '../metadata';
|
||||
import type { IRoom } from '../rooms';
|
||||
import type { IUser } from '../users';
|
||||
import type { IMessageBuilder } from './IMessageBuilder';
|
||||
|
||||
/**
|
||||
* Interface for building out a livechat message.
|
||||
* Please note, that a room and sender must be associated otherwise you will NOT
|
||||
* be able to successfully save the message object.
|
||||
*/
|
||||
export interface ILivechatMessageBuilder {
|
||||
kind: RocketChatAssociationModel.LIVECHAT_MESSAGE;
|
||||
|
||||
/**
|
||||
* Provides a convient way to set the data for the message.
|
||||
* Note: Providing an "id" field here will be ignored.
|
||||
*
|
||||
* @param message the message data to set
|
||||
*/
|
||||
setData(message: ILivechatMessage): ILivechatMessageBuilder;
|
||||
|
||||
/**
|
||||
* Sets the room where this message should be sent to.
|
||||
*
|
||||
* @param room the room where to send
|
||||
*/
|
||||
setRoom(room: IRoom): ILivechatMessageBuilder;
|
||||
|
||||
/**
|
||||
* Gets the room where this message was sent to.
|
||||
*/
|
||||
getRoom(): IRoom;
|
||||
|
||||
/**
|
||||
* Sets the sender of this message.
|
||||
*
|
||||
* @param sender the user sending the message
|
||||
*/
|
||||
setSender(sender: IUser): ILivechatMessageBuilder;
|
||||
|
||||
/**
|
||||
* Gets the User which sent the message.
|
||||
*/
|
||||
getSender(): IUser;
|
||||
|
||||
/**
|
||||
* Sets the text of the message.
|
||||
*
|
||||
* @param text the actual text
|
||||
*/
|
||||
setText(text: string): ILivechatMessageBuilder;
|
||||
|
||||
/**
|
||||
* Gets the message text.
|
||||
*/
|
||||
getText(): string;
|
||||
|
||||
/**
|
||||
* Sets the emoji to use for the avatar, this overwrites the current avatar
|
||||
* whether it be the user's or the avatar url provided.
|
||||
*
|
||||
* @param emoji the emoji code
|
||||
*/
|
||||
setEmojiAvatar(emoji: string): ILivechatMessageBuilder;
|
||||
|
||||
/**
|
||||
* Gets the emoji used for the avatar.
|
||||
*/
|
||||
getEmojiAvatar(): string;
|
||||
|
||||
/**
|
||||
* Sets the url which to display for the avatar, this overwrites the current
|
||||
* avatar whether it be the user's or an emoji one.
|
||||
*
|
||||
* @param avatarUrl image url to use as the avatar
|
||||
*/
|
||||
setAvatarUrl(avatarUrl: string): ILivechatMessageBuilder;
|
||||
|
||||
/**
|
||||
* Gets the url used for the avatar.
|
||||
*/
|
||||
getAvatarUrl(): string;
|
||||
|
||||
/**
|
||||
* Sets the display text of the sender's username that is visible.
|
||||
*
|
||||
* @param alias the username alias to display
|
||||
*/
|
||||
setUsernameAlias(alias: string): ILivechatMessageBuilder;
|
||||
|
||||
/**
|
||||
* Gets the display text of the sender's username that is visible.
|
||||
*/
|
||||
getUsernameAlias(): string;
|
||||
|
||||
/**
|
||||
* Adds one attachment to the message's list of attachments, this will not
|
||||
* overwrite any existing ones but just adds.
|
||||
*
|
||||
* @param attachment the attachment to add
|
||||
*/
|
||||
addAttachment(attachment: IMessageAttachment): ILivechatMessageBuilder;
|
||||
|
||||
/**
|
||||
* Sets the attachments for the message, replacing and destroying all of the current attachments.
|
||||
*
|
||||
* @param attachments array of the attachments
|
||||
*/
|
||||
setAttachments(attachments: Array<IMessageAttachment>): ILivechatMessageBuilder;
|
||||
|
||||
/**
|
||||
* Gets the attachments array for the message
|
||||
*/
|
||||
getAttachments(): Array<IMessageAttachment>;
|
||||
|
||||
/**
|
||||
* Replaces an attachment at the given position (index).
|
||||
* If there is no attachment at that position, there will be an error thrown.
|
||||
*
|
||||
* @param position the index of the attachment to replace
|
||||
* @param attachment the attachment to replace with
|
||||
*/
|
||||
replaceAttachment(position: number, attachment: IMessageAttachment): ILivechatMessageBuilder;
|
||||
|
||||
/**
|
||||
* Removes an attachment at the given position (index).
|
||||
* If there is no attachment at that position, there will be an error thrown.
|
||||
*
|
||||
* @param position the index of the attachment to remove
|
||||
*/
|
||||
removeAttachment(position: number): ILivechatMessageBuilder;
|
||||
|
||||
/**
|
||||
* Sets the user who is editing this message.
|
||||
* This is required if you are modifying an existing message.
|
||||
*
|
||||
* @param user the editor
|
||||
*/
|
||||
setEditor(user: IUser): ILivechatMessageBuilder;
|
||||
|
||||
/**
|
||||
* Gets the user who edited the message
|
||||
*/
|
||||
getEditor(): IUser;
|
||||
|
||||
/**
|
||||
* Sets whether this message can group with others.
|
||||
* This is desirable if you want to avoid confusion with other integrations.
|
||||
*
|
||||
* @param groupable whether this message can group with others
|
||||
*/
|
||||
setGroupable(groupable: boolean): ILivechatMessageBuilder;
|
||||
|
||||
/**
|
||||
* Gets whether this message can group with others.
|
||||
*/
|
||||
getGroupable(): boolean;
|
||||
|
||||
/**
|
||||
* Sets whether this message should have any URLs in the text
|
||||
* parsed by Rocket.Chat and get the details added to the message's
|
||||
* attachments.
|
||||
*
|
||||
* @param parseUrls whether URLs should be parsed in this message
|
||||
*/
|
||||
setParseUrls(parseUrls: boolean): ILivechatMessageBuilder;
|
||||
|
||||
/**
|
||||
* Gets whether this message should have its URLs parsed
|
||||
*/
|
||||
getParseUrls(): boolean;
|
||||
|
||||
/**
|
||||
* Set the token of the livechat visitor that
|
||||
* sent the message
|
||||
*
|
||||
* @param token The Livechat visitor's token
|
||||
*/
|
||||
setToken(token: string): ILivechatMessageBuilder;
|
||||
|
||||
/**
|
||||
* Gets the token of the livechat visitor that
|
||||
* sent the message
|
||||
*/
|
||||
getToken(): string;
|
||||
|
||||
/**
|
||||
* If the sender of the message is a Livechat Visitor,
|
||||
* set the visitor who sent the message.
|
||||
*
|
||||
* If you set the visitor property of a message, the
|
||||
* sender will be emptied
|
||||
*
|
||||
* @param visitor The visitor who sent the message
|
||||
*/
|
||||
setVisitor(visitor: IVisitor): ILivechatMessageBuilder;
|
||||
|
||||
/**
|
||||
* Get the visitor who sent the message,
|
||||
* if any
|
||||
*/
|
||||
getVisitor(): IVisitor;
|
||||
|
||||
/**
|
||||
* Gets the resulting message that has been built up to the point of calling it.
|
||||
*
|
||||
* *Note:* This will error out if the Room has not been defined OR if the room
|
||||
* is not of type RoomType.LIVE_CHAT.
|
||||
*/
|
||||
getMessage(): ILivechatMessage;
|
||||
|
||||
/**
|
||||
* Returns a message builder based on the
|
||||
* livechat message of this builder
|
||||
*/
|
||||
getMessageBuilder(): IMessageBuilder;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { IDepartment } from '../livechat';
|
||||
import type { ILivechatRoom } from '../livechat/ILivechatRoom';
|
||||
import type { IVisitor } from '../livechat/IVisitor';
|
||||
import type { IMessage } from '../messages';
|
||||
|
||||
export interface ILivechatRead {
|
||||
/**
|
||||
* Gets online status of the livechat.
|
||||
* @param departmentId (optional) the id of the livechat department
|
||||
* @deprecated use `isOnlineAsync` instead
|
||||
*/
|
||||
isOnline(departmentId?: string): boolean;
|
||||
/**
|
||||
* Gets online status of the livechat.
|
||||
* @param departmentId (optional) the id of the livechat department
|
||||
*/
|
||||
isOnlineAsync(departmentId?: string): Promise<boolean>;
|
||||
getDepartmentsEnabledWithAgents(): Promise<Array<IDepartment>>;
|
||||
getLivechatRooms(visitor: IVisitor, departmentId?: string): Promise<Array<ILivechatRoom>>;
|
||||
getLivechatOpenRoomsByAgentId(agentId: string): Promise<Array<ILivechatRoom>>;
|
||||
getLivechatTotalOpenRoomsByAgentId(agentId: string): Promise<number>;
|
||||
/**
|
||||
* @deprecated This method does not adhere to the conversion practices applied
|
||||
* elsewhere in the Apps-Engine and will be removed in the next major version.
|
||||
* Prefer the alternative methods to fetch visitors.
|
||||
*/
|
||||
getLivechatVisitors(query: object): Promise<Array<IVisitor>>;
|
||||
getLivechatVisitorById(id: string): Promise<IVisitor | undefined>;
|
||||
getLivechatVisitorByEmail(email: string): Promise<IVisitor | undefined>;
|
||||
getLivechatVisitorByToken(token: string): Promise<IVisitor | undefined>;
|
||||
getLivechatVisitorByPhoneNumber(phoneNumber: string): Promise<IVisitor | undefined>;
|
||||
getLivechatDepartmentByIdOrName(value: string): Promise<IDepartment | undefined>;
|
||||
/**
|
||||
* @experimental we do not encourage the wider usage of this method,
|
||||
* as we're evaluating its performance and fit for the API.
|
||||
*/
|
||||
_fetchLivechatRoomMessages(roomId: string): Promise<Array<IMessage>>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ILivechatTransferData, IVisitor, IVisitorExternalIdentifier } from '../livechat';
|
||||
import type { IRoom } from '../rooms';
|
||||
import type { IUser } from '../users';
|
||||
|
||||
export interface ILivechatUpdater {
|
||||
/**
|
||||
* Transfer a Livechat visitor to another room
|
||||
*
|
||||
* @param visitor Visitor to be transferred
|
||||
* @param transferData The data to execute the transferring
|
||||
*/
|
||||
transferVisitor(visitor: IVisitor, transferData: ILivechatTransferData): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Closes a Livechat room
|
||||
*
|
||||
* @param room The room to be closed
|
||||
* @param comment The comment explaining the reason for closing the room
|
||||
* @param closer The user that closes the room
|
||||
*/
|
||||
closeRoom(room: IRoom, comment: string, closer?: IUser): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Set a livechat visitor's custom fields by its token
|
||||
* @param token The visitor's token
|
||||
* @param key The key in the custom fields
|
||||
* @param value The value to be set
|
||||
* @param overwrite Whether overwrite or not
|
||||
*
|
||||
* @returns Promise to whether success or not
|
||||
*/
|
||||
setCustomFields(token: IVisitor['token'], key: string, value: string, overwrite: boolean): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Updates or adds an external identifier for a visitor.
|
||||
* The appId is automatically set to the calling app's ID.
|
||||
* If an externalId with the same appId already exists, it will be replaced.
|
||||
*
|
||||
* @param visitorId The visitor's ID
|
||||
* @param externalId The external identifier containing entityId and optional metadata
|
||||
*
|
||||
* @returns Promise resolving to the updated visitor, or undefined if not found
|
||||
*/
|
||||
updateVisitorExternalId(visitorId: string, externalId: Omit<IVisitorExternalIdentifier, 'appId'>): Promise<IVisitor | undefined>;
|
||||
}
|
||||
24
packages/apps-engine/src/definition/accessors/ILogEntry.ts
Normal file
24
packages/apps-engine/src/definition/accessors/ILogEntry.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export enum LogMessageSeverity {
|
||||
DEBUG = 'debug',
|
||||
INFORMATION = 'info',
|
||||
LOG = 'log',
|
||||
WARNING = 'warning',
|
||||
ERROR = 'error',
|
||||
SUCCESS = 'success',
|
||||
}
|
||||
|
||||
/**
|
||||
* Message which will be passed to a UI (either in a log or in the application's UI)
|
||||
*/
|
||||
export interface ILogEntry {
|
||||
/** The function name who did this logging, this is automatically added (can be null). */
|
||||
caller?: string;
|
||||
/** The severity rate, this is automatically added. */
|
||||
severity: LogMessageSeverity;
|
||||
/** When this entry was made. */
|
||||
timestamp: Date;
|
||||
/** The items which were logged. */
|
||||
args: Array<any>;
|
||||
/** The method which was logged. */
|
||||
method?: string;
|
||||
}
|
||||
29
packages/apps-engine/src/definition/accessors/ILogger.ts
Normal file
29
packages/apps-engine/src/definition/accessors/ILogger.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { ILogEntry } from './ILogEntry';
|
||||
import type { AppMethod } from '../metadata/AppMethod';
|
||||
|
||||
/**
|
||||
* This logger provides a way to log various levels to the entire system.
|
||||
* When used, the items passed in will be logged to the database. This will
|
||||
* allow people to easily see what happened (users) or debug what went wrong.
|
||||
*/
|
||||
export interface ILogger {
|
||||
method: `${AppMethod}`;
|
||||
|
||||
debug(...items: Array<any>): void;
|
||||
info(...items: Array<any>): void;
|
||||
log(...items: Array<any>): void;
|
||||
warn(...items: Array<any>): void;
|
||||
error(...items: Array<any>): void;
|
||||
success(...items: Array<any>): void;
|
||||
|
||||
/** Gets the entries logged. */
|
||||
getEntries(): Array<ILogEntry>;
|
||||
/** Gets the method which this logger is for. */
|
||||
getMethod(): `${AppMethod}`;
|
||||
/** Gets when this logger was constructed. */
|
||||
getStartTime(): Date;
|
||||
/** Gets the end time, usually Date.now(). */
|
||||
getEndTime(): Date;
|
||||
/** Gets the amount of time this was a logger, start - Date.now(). */
|
||||
getTotalTime(): number;
|
||||
}
|
||||
236
packages/apps-engine/src/definition/accessors/IMessageBuilder.ts
Normal file
236
packages/apps-engine/src/definition/accessors/IMessageBuilder.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import type { LayoutBlock } from '@rocket.chat/ui-kit';
|
||||
|
||||
import type { IMessage, IMessageAttachment } from '../messages';
|
||||
import type { RocketChatAssociationModel } from '../metadata';
|
||||
import type { IRoom } from '../rooms';
|
||||
import type { BlockBuilder, IBlock } from '../uikit';
|
||||
import type { IUser } from '../users';
|
||||
|
||||
/**
|
||||
* Interface for building out a message.
|
||||
* Please note, that a room and sender must be associated otherwise you will NOT
|
||||
* be able to successfully save the message object.
|
||||
*/
|
||||
export interface IMessageBuilder {
|
||||
kind: RocketChatAssociationModel.MESSAGE;
|
||||
|
||||
/**
|
||||
* Provides a convenient way to set the data for the message.
|
||||
* Note: Providing an "id" field here will be ignored.
|
||||
*
|
||||
* @param message the message data to set
|
||||
*/
|
||||
setData(message: IMessage): IMessageBuilder;
|
||||
|
||||
/**
|
||||
* Provides a convenient way to set the data for the message
|
||||
* keeping the "id" field so as to update the message later.
|
||||
*
|
||||
* @param message the message data to set
|
||||
* @param editor the user who edited the updated message
|
||||
*/
|
||||
setUpdateData(message: IMessage, editor: IUser): IMessageBuilder;
|
||||
|
||||
/**
|
||||
* Sets the thread to which this message belongs, if any.
|
||||
*
|
||||
* @param threadId The id of the thread
|
||||
*/
|
||||
setThreadId(threadId: string): IMessageBuilder;
|
||||
|
||||
/**
|
||||
* Retrieves the threadId to which this message belongs,
|
||||
* if any.
|
||||
*
|
||||
* If you would like to retrieve the actual message that
|
||||
* the thread originated from, you can use the
|
||||
* `IMessageRead.getById()` method
|
||||
*/
|
||||
getThreadId(): string;
|
||||
|
||||
/**
|
||||
* Sets the room where this message should be sent to.
|
||||
*
|
||||
* @param room the room where to send
|
||||
*/
|
||||
setRoom(room: IRoom): IMessageBuilder;
|
||||
|
||||
/**
|
||||
* Gets the room where this message was sent to.
|
||||
*/
|
||||
getRoom(): IRoom;
|
||||
|
||||
/**
|
||||
* Sets the sender of this message.
|
||||
*
|
||||
* @param sender the user sending the message
|
||||
*/
|
||||
setSender(sender: IUser): IMessageBuilder;
|
||||
|
||||
/**
|
||||
* Gets the User which sent the message.
|
||||
*/
|
||||
getSender(): IUser;
|
||||
|
||||
/**
|
||||
* Sets the text of the message.
|
||||
*
|
||||
* @param text the actual text
|
||||
*/
|
||||
setText(text: string): IMessageBuilder;
|
||||
|
||||
/**
|
||||
* Gets the message text.
|
||||
*/
|
||||
getText(): string;
|
||||
|
||||
/**
|
||||
* Sets the emoji to use for the avatar, this overwrites the current avatar
|
||||
* whether it be the user's or the avatar url provided.
|
||||
*
|
||||
* @param emoji the emoji code
|
||||
*/
|
||||
setEmojiAvatar(emoji: string): IMessageBuilder;
|
||||
|
||||
/**
|
||||
* Gets the emoji used for the avatar.
|
||||
*/
|
||||
getEmojiAvatar(): string;
|
||||
|
||||
/**
|
||||
* Sets the url which to display for the avatar, this overwrites the current
|
||||
* avatar whether it be the user's or an emoji one.
|
||||
*
|
||||
* @param avatarUrl image url to use as the avatar
|
||||
*/
|
||||
setAvatarUrl(avatarUrl: string): IMessageBuilder;
|
||||
|
||||
/**
|
||||
* Gets the url used for the avatar.
|
||||
*/
|
||||
getAvatarUrl(): string;
|
||||
|
||||
/**
|
||||
* Sets the display text of the sender's username that is visible.
|
||||
*
|
||||
* @param alias the username alias to display
|
||||
*/
|
||||
setUsernameAlias(alias: string): IMessageBuilder;
|
||||
|
||||
/**
|
||||
* Gets the display text of the sender's username that is visible.
|
||||
*/
|
||||
getUsernameAlias(): string;
|
||||
|
||||
/**
|
||||
* Adds one attachment to the message's list of attachments, this will not
|
||||
* overwrite any existing ones but just adds.
|
||||
*
|
||||
* @param attachment the attachment to add
|
||||
*/
|
||||
addAttachment(attachment: IMessageAttachment): IMessageBuilder;
|
||||
|
||||
/**
|
||||
* Sets the attachments for the message, replacing and destroying all of the current attachments.
|
||||
*
|
||||
* @param attachments array of the attachments
|
||||
*/
|
||||
setAttachments(attachments: Array<IMessageAttachment>): IMessageBuilder;
|
||||
|
||||
/**
|
||||
* Gets the attachments array for the message
|
||||
*/
|
||||
getAttachments(): Array<IMessageAttachment>;
|
||||
|
||||
/**
|
||||
* Replaces an attachment at the given position (index).
|
||||
* If there is no attachment at that position, there will be an error thrown.
|
||||
*
|
||||
* @param position the index of the attachment to replace
|
||||
* @param attachment the attachment to replace with
|
||||
*/
|
||||
replaceAttachment(position: number, attachment: IMessageAttachment): IMessageBuilder;
|
||||
|
||||
/**
|
||||
* Removes an attachment at the given position (index).
|
||||
* If there is no attachment at that position, there will be an error thrown.
|
||||
*
|
||||
* @param position the index of the attachment to remove
|
||||
*/
|
||||
removeAttachment(position: number): IMessageBuilder;
|
||||
|
||||
/**
|
||||
* Sets the user who is editing this message.
|
||||
* This is required if you are modifying an existing message.
|
||||
*
|
||||
* @param user the editor
|
||||
*/
|
||||
setEditor(user: IUser): IMessageBuilder;
|
||||
|
||||
/**
|
||||
* Gets the user who edited the message
|
||||
*/
|
||||
getEditor(): IUser;
|
||||
|
||||
/**
|
||||
* Sets whether this message can group with others.
|
||||
* This is desirable if you want to avoid confusion with other integrations.
|
||||
*
|
||||
* @param groupable whether this message can group with others
|
||||
*/
|
||||
setGroupable(groupable: boolean): IMessageBuilder;
|
||||
|
||||
/**
|
||||
* Gets whether this message can group with others.
|
||||
*/
|
||||
getGroupable(): boolean;
|
||||
|
||||
/**
|
||||
* Sets whether this message should have any URLs in the text
|
||||
* parsed by Rocket.Chat and get the details added to the message's
|
||||
* attachments.
|
||||
*
|
||||
* @param parseUrls whether URLs should be parsed in this message
|
||||
*/
|
||||
setParseUrls(parseUrls: boolean): IMessageBuilder;
|
||||
|
||||
/**
|
||||
* Gets whether this message should have its URLs parsed
|
||||
*/
|
||||
getParseUrls(): boolean;
|
||||
|
||||
/**
|
||||
* Gets the resulting message that has been built up to the point of calling it.
|
||||
*
|
||||
* *Note:* This will error out if the Room has not been defined.
|
||||
*/
|
||||
getMessage(): IMessage;
|
||||
|
||||
/**
|
||||
* Adds a block collection to the message's
|
||||
* own collection
|
||||
*/
|
||||
addBlocks(blocks: BlockBuilder | Array<IBlock | LayoutBlock>): IMessageBuilder;
|
||||
|
||||
/**
|
||||
* Sets the block collection of the message
|
||||
*
|
||||
* @param blocks
|
||||
*/
|
||||
setBlocks(blocks: BlockBuilder | Array<IBlock | LayoutBlock>): IMessageBuilder;
|
||||
|
||||
/**
|
||||
* Gets the block collection of the message
|
||||
*/
|
||||
getBlocks(): Array<IBlock | LayoutBlock>;
|
||||
|
||||
/**
|
||||
* Adds a custom field to the message.
|
||||
* Note: This key can not already exist or it will throw an error.
|
||||
* Note: The key must not contain a period in it, an error will be thrown.
|
||||
*
|
||||
* @param key the name of the custom field
|
||||
* @param value the value of this custom field
|
||||
*/
|
||||
addCustomField(key: string, value: any): IMessageBuilder;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { IMessage, IMessageAttachment } from '../messages';
|
||||
import type { RocketChatAssociationModel } from '../metadata';
|
||||
|
||||
export interface IMessageExtender {
|
||||
kind: RocketChatAssociationModel.MESSAGE;
|
||||
|
||||
/**
|
||||
* Adds a custom field to the message.
|
||||
* Note: This key can not already exist or it will throw an error.
|
||||
* Note: The key must not contain a period in it, an error will be thrown.
|
||||
*
|
||||
* @param key the name of the custom field
|
||||
* @param value the value of this custom field
|
||||
*/
|
||||
addCustomField(key: string, value: any): IMessageExtender;
|
||||
|
||||
/**
|
||||
* Adds a single attachment to the message.
|
||||
*
|
||||
* @param attachment the item to add
|
||||
*/
|
||||
addAttachment(attachment: IMessageAttachment): IMessageExtender;
|
||||
|
||||
/**
|
||||
* Adds all of the provided attachments to the message.
|
||||
*
|
||||
* @param attachments an array of attachments
|
||||
*/
|
||||
addAttachments(attachments: Array<IMessageAttachment>): IMessageExtender;
|
||||
|
||||
/**
|
||||
* Gets the resulting message that has been extended at the point of calling it.
|
||||
* Note: modifying the returned value will have no effect.
|
||||
*/
|
||||
getMessage(): IMessage;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { IMessage } from '../messages/index';
|
||||
import type { IRoom } from '../rooms/IRoom';
|
||||
import type { IUser } from '../users/IUser';
|
||||
|
||||
/**
|
||||
* This accessor provides methods for accessing
|
||||
* messages in a read-only-fashion.
|
||||
*/
|
||||
export interface IMessageRead {
|
||||
getById(id: string): Promise<IMessage | undefined>;
|
||||
|
||||
getSenderUser(messageId: string): Promise<IUser | undefined>;
|
||||
|
||||
getRoom(messageId: string): Promise<IRoom | undefined>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Reaction } from '../messages';
|
||||
|
||||
export interface IMessageUpdater {
|
||||
/**
|
||||
* Add a reaction to a message
|
||||
*
|
||||
* @param messageId the id of the message
|
||||
* @param userId the id of the user
|
||||
* @param reaction the reaction
|
||||
*/
|
||||
addReaction(messageId: string, userId: string, reaction: Reaction): Promise<void>;
|
||||
|
||||
/**
|
||||
* Remove a reaction from a message
|
||||
*
|
||||
* @param messageId the id of the message
|
||||
* @param userId the id of the user
|
||||
* @param reaction the reaction
|
||||
*/
|
||||
removeReaction(messageId: string, userId: string, reaction: Reaction): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { IMessage } from '../messages';
|
||||
import type { IUser } from '../users';
|
||||
|
||||
export interface IModerationModify {
|
||||
/**
|
||||
* Provides a way for Apps to report a message.
|
||||
* @param messageId the messageId to report
|
||||
* @param description the description of the report
|
||||
* @param userId the userId to be reported
|
||||
* @param appId the app id
|
||||
*/
|
||||
report(messageId: string, description: string, userId: string, appId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Provides a way for Apps to dismiss reports by message id.
|
||||
* @param messageId the messageId to dismiss reports
|
||||
* @param appId the app id
|
||||
*/
|
||||
dismissReportsByMessageId(messageId: IMessage['id'], reason: string, action: string, appId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Provides a way for Apps to dismiss reports by user id.
|
||||
* @param userId the userId to dismiss reports
|
||||
* @param appId the app id
|
||||
*/
|
||||
dismissReportsByUserId(userId: IUser['id'], reason: string, action: string, appId: string): Promise<void>;
|
||||
}
|
||||
45
packages/apps-engine/src/definition/accessors/IModify.ts
Normal file
45
packages/apps-engine/src/definition/accessors/IModify.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { IModerationModify } from './IModerationModify';
|
||||
import type { IModifyCreator } from './IModifyCreator';
|
||||
import type { IModifyDeleter } from './IModifyDeleter';
|
||||
import type { IModifyExtender } from './IModifyExtender';
|
||||
import type { IModifyUpdater } from './IModifyUpdater';
|
||||
import type { INotifier } from './INotifier';
|
||||
import type { IOAuthAppsModify } from './IOAuthAppsModify';
|
||||
import type { ISchedulerModify } from './ISchedulerModify';
|
||||
import type { IUIController } from './IUIController';
|
||||
|
||||
export interface IModify {
|
||||
getCreator(): IModifyCreator;
|
||||
|
||||
getDeleter(): IModifyDeleter;
|
||||
|
||||
getExtender(): IModifyExtender;
|
||||
|
||||
getUpdater(): IModifyUpdater;
|
||||
|
||||
/**
|
||||
* Gets the accessor for sending notifications to a user or users in a room.
|
||||
*
|
||||
* @returns the notifier accessor
|
||||
*/
|
||||
getNotifier(): INotifier;
|
||||
/**
|
||||
* Gets the accessor for interacting with the UI
|
||||
*/
|
||||
getUiController(): IUIController;
|
||||
|
||||
/**
|
||||
* Gets the accessor for creating scheduled jobs
|
||||
*/
|
||||
getScheduler(): ISchedulerModify;
|
||||
|
||||
/**
|
||||
* Gets the accessor for creating OAuth apps
|
||||
*/
|
||||
getOAuthAppsModifier(): IOAuthAppsModify;
|
||||
/**
|
||||
* Gets the accessor for modifying moderation
|
||||
* @returns the moderation accessor
|
||||
*/
|
||||
getModerationModifier(): IModerationModify;
|
||||
}
|
||||
108
packages/apps-engine/src/definition/accessors/IModifyCreator.ts
Normal file
108
packages/apps-engine/src/definition/accessors/IModifyCreator.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { ILivechatMessage } from '../livechat';
|
||||
import type { IMessage } from '../messages';
|
||||
import type { IRoom } from '../rooms';
|
||||
import type { BlockBuilder } from '../uikit';
|
||||
import type { IBotUser } from '../users/IBotUser';
|
||||
import type { AppVideoConference } from '../videoConferences';
|
||||
import type { IContactCreator } from './IContactCreator';
|
||||
import type { IDiscussionBuilder } from './IDiscussionBuilder';
|
||||
import type { IEmailCreator } from './IEmailCreator';
|
||||
import type { ILivechatCreator } from './ILivechatCreator';
|
||||
import type { ILivechatMessageBuilder } from './ILivechatMessageBuilder';
|
||||
import type { IMessageBuilder } from './IMessageBuilder';
|
||||
import type { IRoomBuilder } from './IRoomBuilder';
|
||||
import type { IUploadCreator } from './IUploadCreator';
|
||||
import type { IUserBuilder } from './IUserBuilder';
|
||||
import type { IVideoConferenceBuilder } from './IVideoConferenceBuilder';
|
||||
|
||||
export interface IModifyCreator {
|
||||
/**
|
||||
* Get the creator object responsible for the
|
||||
* Livechat integrations
|
||||
*/
|
||||
getLivechatCreator(): ILivechatCreator;
|
||||
|
||||
/**
|
||||
* Get the creator object responsible for the upload.
|
||||
*/
|
||||
getUploadCreator(): IUploadCreator;
|
||||
|
||||
/**
|
||||
* Gets the creator object responsible for email sending
|
||||
*/
|
||||
getEmailCreator(): IEmailCreator;
|
||||
|
||||
/**
|
||||
* Gets the creator object responsible for contact related operations.
|
||||
*/
|
||||
getContactCreator(): IContactCreator;
|
||||
|
||||
/**
|
||||
* @deprecated please prefer the rocket.chat/ui-kit components
|
||||
*
|
||||
* Gets a new instance of a BlockBuilder
|
||||
*/
|
||||
getBlockBuilder(): BlockBuilder;
|
||||
/**
|
||||
* Starts the process for building a new message object.
|
||||
*
|
||||
* @param data (optional) the initial data to pass into the builder,
|
||||
* the `id` property will be ignored
|
||||
* @return an IMessageBuilder instance
|
||||
*/
|
||||
startMessage(data?: IMessage): IMessageBuilder;
|
||||
|
||||
/**
|
||||
* Starts the process for building a new livechat message object.
|
||||
*
|
||||
* @param data (optional) the initial data to pass into the builder,
|
||||
* the `id` property will be ignored
|
||||
* @return an IMessageBuilder instance
|
||||
*/
|
||||
startLivechatMessage(data?: ILivechatMessage): ILivechatMessageBuilder;
|
||||
|
||||
/**
|
||||
* Starts the process for building a new room.
|
||||
*
|
||||
* @param data (optional) the initial data to pass into the builder,
|
||||
* the `id` property will be ignored
|
||||
* @return an IRoomBuilder instance
|
||||
*/
|
||||
startRoom(data?: IRoom): IRoomBuilder;
|
||||
|
||||
/**
|
||||
* Starts the process for building a new discussion.
|
||||
*
|
||||
* @param data (optional) the initial data to pass into the builder,
|
||||
* the `id` property will be ignored
|
||||
* @return an IDiscussionBuilder instance
|
||||
*/
|
||||
startDiscussion(data?: Partial<IRoom>): IDiscussionBuilder;
|
||||
|
||||
/**
|
||||
* Starts the process for building a new video conference.
|
||||
*
|
||||
* @param data (optional) the initial data to pass into the builder,
|
||||
* @return an IVideoConferenceBuilder instance
|
||||
*/
|
||||
startVideoConference(data?: Partial<AppVideoConference>): IVideoConferenceBuilder;
|
||||
|
||||
/**
|
||||
* Starts the process for building a new bot user.
|
||||
*
|
||||
* @param data (optional) the initial data to pass into the builder,
|
||||
* the `id` property will be ignored
|
||||
* @return an IUserBuilder instance
|
||||
*/
|
||||
startBotUser(data?: Partial<IBotUser>): IUserBuilder;
|
||||
|
||||
/**
|
||||
* Finishes the creating process, saving the object to the database.
|
||||
*
|
||||
* @param builder the builder instance
|
||||
* @return the resulting `id` of the resulting object
|
||||
*/
|
||||
finish(
|
||||
builder: IMessageBuilder | ILivechatMessageBuilder | IRoomBuilder | IDiscussionBuilder | IVideoConferenceBuilder | IUserBuilder,
|
||||
): Promise<string>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { IMessage } from '../messages';
|
||||
import type { IUser, UserType } from '../users';
|
||||
|
||||
export interface IModifyDeleter {
|
||||
deleteRoom(roomId: string): Promise<void>;
|
||||
|
||||
deleteUsers(appId: Exclude<IUser['appId'], undefined>, userType: UserType.APP | UserType.BOT): Promise<boolean>;
|
||||
|
||||
deleteMessage(message: IMessage, user: IUser): Promise<void>;
|
||||
|
||||
removeUsersFromRoom(roomId: string, usernames: Array<string>): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { IUser } from '../users';
|
||||
import type { IMessageExtender } from './IMessageExtender';
|
||||
import type { IRoomExtender } from './IRoomExtender';
|
||||
import type { IVideoConferenceExtender } from './IVideoConferenceExtend';
|
||||
|
||||
export interface IModifyExtender {
|
||||
/**
|
||||
* Modifies a message in a non-destructive way: Properties can be added to it,
|
||||
* but existing properties cannot be changed.
|
||||
*
|
||||
* @param messageId the id of the message to be extended
|
||||
* @param updater the user who is updating/extending the message
|
||||
* @return the extender instance for the message
|
||||
*/
|
||||
extendMessage(messageId: string, updater: IUser): Promise<IMessageExtender>;
|
||||
|
||||
/**
|
||||
* Modifies a room in a non-destructive way: Properties can be added to it,
|
||||
* but existing properties cannot be changed.
|
||||
*
|
||||
* @param roomId the id of the room to be extended
|
||||
* @param updater the user who is updating/extending the room
|
||||
* @return the extender instance for the room
|
||||
*/
|
||||
extendRoom(roomId: string, updater: IUser): Promise<IRoomExtender>;
|
||||
|
||||
/**
|
||||
* Modifies a video conference in a non-destructive way: Properties can be added to it,
|
||||
* but existing properties cannot be changed.
|
||||
*/
|
||||
extendVideoConference(id: string): Promise<IVideoConferenceExtender>;
|
||||
|
||||
/**
|
||||
* Finishes the extending process, saving the object to the database.
|
||||
* Note: If there is an issue or error while updating, this will throw an error.
|
||||
*
|
||||
* @param extender the extender instance
|
||||
*/
|
||||
finish(extender: IRoomExtender | IMessageExtender | IVideoConferenceExtender): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { IUser } from '../users';
|
||||
import type { ILivechatUpdater } from './ILivechatUpdater';
|
||||
import type { IMessageBuilder } from './IMessageBuilder';
|
||||
import type { IMessageUpdater } from './IMessageUpdater';
|
||||
import type { IRoomBuilder } from './IRoomBuilder';
|
||||
import type { IUserUpdater } from './IUserUpdater';
|
||||
|
||||
export interface IModifyUpdater {
|
||||
/**
|
||||
* Get the updater object responsible for the
|
||||
* Livechat integrations
|
||||
*/
|
||||
getLivechatUpdater(): ILivechatUpdater;
|
||||
|
||||
/**
|
||||
* Gets the update object responsible for
|
||||
* methods that update users
|
||||
*/
|
||||
getUserUpdater(): IUserUpdater;
|
||||
|
||||
/**
|
||||
* Get the updater object responsible for
|
||||
* methods that update messages
|
||||
*/
|
||||
getMessageUpdater(): IMessageUpdater;
|
||||
|
||||
/**
|
||||
* Modifies an existing message.
|
||||
* Raises an exception if a non-existent messageId is supplied
|
||||
*
|
||||
* @param messageId the id of the existing message to modfiy and build
|
||||
* @param updater the user who is updating the message
|
||||
*/
|
||||
message(messageId: string, updater: IUser): Promise<IMessageBuilder>;
|
||||
|
||||
/**
|
||||
* Modifies an existing room.
|
||||
* Raises an exception if a non-existent roomId is supplied
|
||||
*
|
||||
* @param roomId the id of the existing room to modify and build
|
||||
* @param updater the user who is updating the room
|
||||
*/
|
||||
room(roomId: string, updater: IUser): Promise<IRoomBuilder>;
|
||||
|
||||
/**
|
||||
* Finishes the updating process, saving the object to the database.
|
||||
* Note: If there is an issue or error while updating, this will throw an error.
|
||||
*
|
||||
* @param builder the builder instance
|
||||
*/
|
||||
finish(builder: IMessageBuilder | IRoomBuilder): Promise<void>;
|
||||
}
|
||||
63
packages/apps-engine/src/definition/accessors/INotifier.ts
Normal file
63
packages/apps-engine/src/definition/accessors/INotifier.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { IMessage } from '../messages';
|
||||
import type { IRoom } from '../rooms';
|
||||
import type { IUser } from '../users';
|
||||
import type { IMessageBuilder } from './IMessageBuilder';
|
||||
|
||||
export enum TypingScope {
|
||||
Room = 'room',
|
||||
}
|
||||
|
||||
export interface ITypingOptions {
|
||||
/**
|
||||
* The typing scope where the typing message should be presented,
|
||||
* TypingScope.Room by default.
|
||||
*/
|
||||
scope?: TypingScope;
|
||||
/**
|
||||
* The id of the typing scope
|
||||
*
|
||||
* TypingScope.Room <-> room.id
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* The name of the user who is typing the message
|
||||
*
|
||||
* **Note**: If not provided, it will use app assigned
|
||||
* user's name by default.
|
||||
*/
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface INotifier {
|
||||
/**
|
||||
* Notifies the provided user of the provided message.
|
||||
*
|
||||
* **Note**: Notifications only are shown to the user if they are
|
||||
* online and it only stays around for the duration of their session.
|
||||
*
|
||||
* @param user The user who should be notified
|
||||
* @param message The message with the content to notify the user about
|
||||
*/
|
||||
notifyUser(user: IUser, message: IMessage): Promise<void>;
|
||||
|
||||
/**
|
||||
* Notifies all of the users in the provided room.
|
||||
*
|
||||
* **Note**: Notifications only are shown to those online
|
||||
* and it only stays around for the duration of their session.
|
||||
*
|
||||
* @param room The room which to notify the users in
|
||||
* @param message The message content to notify users about
|
||||
*/
|
||||
notifyRoom(room: IRoom, message: IMessage): Promise<void>;
|
||||
|
||||
/**
|
||||
* Notifies all of the users a typing indicator in the provided scope.
|
||||
*
|
||||
* @returns a cancellation function to stop typing
|
||||
*/
|
||||
typing(options: ITypingOptions): Promise<() => Promise<void>>;
|
||||
|
||||
/** Gets a new message builder for building a notification message. */
|
||||
getMessageBuilder(): IMessageBuilder;
|
||||
}
|
||||
13
packages/apps-engine/src/definition/accessors/IOAuthApp.ts
Normal file
13
packages/apps-engine/src/definition/accessors/IOAuthApp.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface IOAuthApp {
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
redirectUri: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
createdBy: { username: string; id: string };
|
||||
}
|
||||
|
||||
export type IOAuthAppParams = Omit<IOAuthApp, 'id' | 'createdAt' | 'updatedAt' | 'createdBy' | 'appId'>;
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { IOAuthAppParams } from './IOAuthApp';
|
||||
|
||||
export interface IOAuthAppsModify {
|
||||
/**
|
||||
* Create an OAuthApp
|
||||
* @param OAuthApp - the OAuth app to create, in case the clientId and the clientSecret is not sent it will generate automatically
|
||||
* @param appId - the app id
|
||||
*/
|
||||
createOAuthApp(OAuthApp: IOAuthAppParams, appId: string): Promise<string>;
|
||||
/**
|
||||
* Update the OAuth app info
|
||||
* @param OAuthApp - OAuth data that will be updated
|
||||
* @param id - OAuth app id
|
||||
* @param appId - the app id
|
||||
*/
|
||||
updateOAuthApp(OAuthApp: IOAuthAppParams, id: string, appId: string): Promise<void>;
|
||||
/**
|
||||
* Deletes the OAuth app
|
||||
* @param id - OAuth app id
|
||||
* @param appId - the app id
|
||||
*/
|
||||
deleteOAuthApp(id: string, appId: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { IOAuthApp } from './IOAuthApp';
|
||||
|
||||
export interface IOAuthAppsReader {
|
||||
/**
|
||||
* Returns the OAuth app info by its id
|
||||
* @param id - OAuth app id
|
||||
* @param appId - the app id
|
||||
*/
|
||||
getOAuthAppById(id: string, appId: string): Promise<IOAuthApp>;
|
||||
/**
|
||||
* Returns the OAuth app info by its name
|
||||
* @param name - OAuth app name
|
||||
* @param appId - the app id
|
||||
*/
|
||||
getOAuthAppByName(name: string, appId: string): Promise<Array<IOAuthApp>>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { IOutboundEmailMessageProvider, IOutboundPhoneMessageProvider } from '../outboundCommunication';
|
||||
|
||||
export interface IOutboundCommunicationProviderExtend {
|
||||
registerPhoneProvider(provider: IOutboundPhoneMessageProvider): Promise<void>;
|
||||
registerEmailProvider(provider: IOutboundEmailMessageProvider): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { RocketChatAssociationRecord } from '../metadata';
|
||||
|
||||
/**
|
||||
* Provides an accessor write data to the App's persistent storage.
|
||||
* A App only has access to its own persistent storage and does not
|
||||
* have access to any other App's.
|
||||
*/
|
||||
export interface IPersistence {
|
||||
/**
|
||||
* Creates a new record in the App's persistent storage, returning the resulting "id".
|
||||
*
|
||||
* @param data the actual data to store, must be an object otherwise it will error out.
|
||||
* @return the resulting record's id
|
||||
*/
|
||||
create(data: object): Promise<string>;
|
||||
|
||||
/**
|
||||
* Creates a new record in the App's persistent storage with the associated information
|
||||
* being provided.
|
||||
*
|
||||
* @param data the actual data to store, must be an object otherwise it will error out
|
||||
* @param association the association data which includes the model and record id
|
||||
* @return the resulting record's id
|
||||
*/
|
||||
createWithAssociation(data: object, association: RocketChatAssociationRecord): Promise<string>;
|
||||
|
||||
/**
|
||||
* Creates a new record in the App's persistent storage with the data being
|
||||
* associated with more than one Rocket.Chat record.
|
||||
*
|
||||
* @param data the actual data to store, must be an object otherwise it will error out
|
||||
* @param associations an array of association data which includes the model and record id
|
||||
* @return the resulting record's id
|
||||
*/
|
||||
createWithAssociations(data: object, associations: Array<RocketChatAssociationRecord>): Promise<string>;
|
||||
|
||||
/**
|
||||
* Updates an existing record with the data provided in the App's persistent storage.
|
||||
* This will throw an error if the record doesn't currently exist or if the data is not an object.
|
||||
*
|
||||
* @param id the data record's id
|
||||
* @param data the actual data to store, must be an object otherwise it will error out
|
||||
* @param upsert whether a record should be created if the id to be updated does not exist
|
||||
* @return the id of the updated/upserted record
|
||||
*/
|
||||
update(id: string, data: object, upsert?: boolean): Promise<string>;
|
||||
|
||||
/**
|
||||
* Updates an existing record with the data provided in the App's persistent storage which are
|
||||
* associated with provided information.
|
||||
* This will throw an error if the record doesn't currently exist or if the data is not an object.
|
||||
*
|
||||
* @param association the association record
|
||||
* @param data the actual data to store, must be an object otherwise it will error out
|
||||
* @param upsert whether a record should be created if the id to be updated does not exist
|
||||
* @return the id of the updated/upserted record
|
||||
*/
|
||||
updateByAssociation(association: RocketChatAssociationRecord, data: object, upsert?: boolean): Promise<string>;
|
||||
|
||||
/**
|
||||
* Updates an existing record with the data provided in the App's persistent storage which are
|
||||
* associated with more than one Rocket.Chat record.
|
||||
* This will throw an error if the record doesn't currently exist or if the data is not an object.
|
||||
*
|
||||
* @param associations an array of association data which includes the model and record id
|
||||
* @param data the actual data to store, must be an object otherwise it will error out
|
||||
* @param upsert whether a record should be created if the id to be updated does not exist
|
||||
* @return the id of the updated/upserted record
|
||||
*/
|
||||
updateByAssociations(associations: Array<RocketChatAssociationRecord>, data: object, upsert?: boolean): Promise<string>;
|
||||
|
||||
/**
|
||||
* Removes a record by the provided id and returns the removed record.
|
||||
*
|
||||
* @param id of the record to remove
|
||||
* @return the data record which was removed
|
||||
*/
|
||||
remove(id: string): Promise<object>;
|
||||
|
||||
/**
|
||||
* Removes all of the records in persistent storage which are associated with the provided information.
|
||||
*
|
||||
* @param association the information about the association for the records to be removed
|
||||
* @return the data of the removed records
|
||||
*/
|
||||
removeByAssociation(association: RocketChatAssociationRecord): Promise<Array<object>>;
|
||||
|
||||
/**
|
||||
* Removes all of the records in persistent storage which are associated with the provided information.
|
||||
* More than one association acts like an AND which means a record in persistent storage must have all
|
||||
* of the associations to be considered a match.
|
||||
*
|
||||
* @param associations the information about the associations for the records to be removed
|
||||
* @return the data of the removed records
|
||||
*/
|
||||
removeByAssociations(associations: Array<RocketChatAssociationRecord>): Promise<Array<object>>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { RocketChatAssociationRecord } from '../metadata';
|
||||
|
||||
/**
|
||||
* Provides a read-only accessor for the App's persistent storage.
|
||||
* A App only has access to its own persistent storage and does not
|
||||
* have access to any other App's.
|
||||
*/
|
||||
export interface IPersistenceRead {
|
||||
/**
|
||||
* Retrieves a record from the App's persistent storage by the provided id.
|
||||
* A "falsey" value (undefined or null or false) is returned should nothing exist
|
||||
* in the storage by the provided id.
|
||||
*
|
||||
* @param id the record to get's id
|
||||
* @return the record if it exists, falsey if not
|
||||
*/
|
||||
read(id: string): Promise<object>;
|
||||
|
||||
/**
|
||||
* Retrieves a record from the App's persistent storage by the provided id.
|
||||
* An empty array is returned should there be no records associated with the
|
||||
* data provided.
|
||||
*
|
||||
* @param association the association record to query the persistent storage for
|
||||
* @return array of the records if any exists, empty array if none exist
|
||||
*/
|
||||
readByAssociation(association: RocketChatAssociationRecord): Promise<Array<object>>;
|
||||
|
||||
/**
|
||||
* Retrieves a record from the App's persistent storage by the provided id.
|
||||
* Providing more than one association record acts like an AND which means a record
|
||||
* in persistent storage must have all of the associations to be considered a match.
|
||||
* An empty array is returned should there be no records associated with the
|
||||
* data provided.
|
||||
*
|
||||
* @param associations the association records to query the persistent storage for
|
||||
* @return array of the records if any exists, empty array if none exist
|
||||
*/
|
||||
readByAssociations(associations: Array<RocketChatAssociationRecord>): Promise<Array<object>>;
|
||||
}
|
||||
57
packages/apps-engine/src/definition/accessors/IRead.ts
Normal file
57
packages/apps-engine/src/definition/accessors/IRead.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { ICloudWorkspaceRead } from './ICloudWorkspaceRead';
|
||||
import type { IContactRead } from './IContactRead';
|
||||
import type { IEnvironmentRead } from './IEnvironmentRead';
|
||||
import type { IExperimentalRead } from './IExperimentalRead';
|
||||
import type { ILivechatRead } from './ILivechatRead';
|
||||
import type { IMessageRead } from './IMessageRead';
|
||||
import type { INotifier } from './INotifier';
|
||||
import type { IOAuthAppsReader } from './IOAuthAppsReader';
|
||||
import type { IPersistenceRead } from './IPersistenceRead';
|
||||
import type { IRoleRead } from './IRoleRead';
|
||||
import type { IRoomRead } from './IRoomRead';
|
||||
import type { IThreadRead } from './IThreadRead';
|
||||
import type { IUploadRead } from './IUploadRead';
|
||||
import type { IUserRead } from './IUserRead';
|
||||
import type { IVideoConferenceRead } from './IVideoConferenceRead';
|
||||
|
||||
/**
|
||||
* The IRead accessor provides methods for accessing the
|
||||
* Rocket.Chat's environment in a read-only-fashion.
|
||||
* It is safe to be injected in multiple places, idempotent and extensible
|
||||
*/
|
||||
export interface IRead {
|
||||
/** Gets the IEnvironmentRead instance, contains settings and environmental variables. */
|
||||
getEnvironmentReader(): IEnvironmentRead;
|
||||
|
||||
/** Gets the IThreadRead instance */
|
||||
|
||||
getThreadReader(): IThreadRead;
|
||||
|
||||
/** Gets the IMessageRead instance. */
|
||||
getMessageReader(): IMessageRead;
|
||||
|
||||
/** Gets the IPersistenceRead instance. */
|
||||
getPersistenceReader(): IPersistenceRead;
|
||||
|
||||
/** Gets the IRoomRead instance. */
|
||||
getRoomReader(): IRoomRead;
|
||||
|
||||
/** Gets the IUserRead instance. */
|
||||
getUserReader(): IUserRead;
|
||||
|
||||
/** Gets the INotifier for notifying users/rooms. */
|
||||
getNotifier(): INotifier;
|
||||
|
||||
getLivechatReader(): ILivechatRead;
|
||||
getUploadReader(): IUploadRead;
|
||||
getCloudWorkspaceReader(): ICloudWorkspaceRead;
|
||||
|
||||
getVideoConferenceReader(): IVideoConferenceRead;
|
||||
|
||||
getOAuthAppsReader(): IOAuthAppsReader;
|
||||
|
||||
getRoleReader(): IRoleRead;
|
||||
getContactReader(): IContactRead;
|
||||
|
||||
getExperimentalReader(): IExperimentalRead;
|
||||
}
|
||||
27
packages/apps-engine/src/definition/accessors/IRoleRead.ts
Normal file
27
packages/apps-engine/src/definition/accessors/IRoleRead.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { IRole } from '../roles';
|
||||
|
||||
/**
|
||||
* Interface for reading roles.
|
||||
*/
|
||||
export interface IRoleRead {
|
||||
/**
|
||||
* Retrieves a role by its id or name.
|
||||
* @param idOrName The id or name of the role to retrieve.
|
||||
* @param appId The id of the app.
|
||||
* @returns The role, if found.
|
||||
* @returns null if no role is found.
|
||||
* @throws If there is an error while retrieving the role.
|
||||
*/
|
||||
getOneByIdOrName(idOrName: string, appId: string): Promise<IRole | null>;
|
||||
|
||||
/**
|
||||
* Retrieves all custom roles.
|
||||
* @param appId The id of the app.
|
||||
* @returns All custom roles.
|
||||
* @throws If there is an error while retrieving the roles.
|
||||
* @throws If the app does not have the necessary permissions.
|
||||
* @see IRole.protected
|
||||
* @see AppPermissions.role.read
|
||||
*/
|
||||
getCustomRoles(appId: string): Promise<Array<IRole>>;
|
||||
}
|
||||
186
packages/apps-engine/src/definition/accessors/IRoomBuilder.ts
Normal file
186
packages/apps-engine/src/definition/accessors/IRoomBuilder.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import type { RocketChatAssociationModel } from '../metadata';
|
||||
import type { IRoom, RoomType } from '../rooms';
|
||||
import type { IUser } from '../users';
|
||||
|
||||
/**
|
||||
* Interface for building out a room.
|
||||
* Please note, a room creator, name, and type must be set otherwise you will NOT
|
||||
* be able to successfully save the room object.
|
||||
*/
|
||||
export interface IRoomBuilder {
|
||||
kind: RocketChatAssociationModel.ROOM | RocketChatAssociationModel.DISCUSSION;
|
||||
|
||||
/**
|
||||
* Provides a convient way to set the data for the room.
|
||||
* Note: Providing an "id" field here will be ignored.
|
||||
*
|
||||
* @param room the room data to set
|
||||
*/
|
||||
setData(room: Partial<IRoom>): IRoomBuilder;
|
||||
|
||||
/**
|
||||
* Sets the display name of this room.
|
||||
*
|
||||
* @param name the display name of the room
|
||||
*/
|
||||
setDisplayName(name: string): IRoomBuilder;
|
||||
|
||||
/**
|
||||
* Gets the display name of this room.
|
||||
*/
|
||||
getDisplayName(): string;
|
||||
|
||||
/**
|
||||
* Sets the slugified name of this room, it must align to the rules of Rocket.Chat room
|
||||
* names otherwise there will be an error thrown (no spaces, special characters, etc).
|
||||
*
|
||||
* @param name the slugified name
|
||||
*/
|
||||
setSlugifiedName(name: string): IRoomBuilder;
|
||||
|
||||
/**
|
||||
* Gets the slugified name of this room.
|
||||
*/
|
||||
getSlugifiedName(): string;
|
||||
|
||||
/**
|
||||
* Sets the room's type.
|
||||
*
|
||||
* @param type the room type
|
||||
*/
|
||||
setType(type: RoomType): IRoomBuilder;
|
||||
|
||||
/**
|
||||
* Gets the room's type.
|
||||
*/
|
||||
getType(): RoomType;
|
||||
|
||||
/**
|
||||
* Sets the creator of the room.
|
||||
*
|
||||
* @param creator the user who created the room
|
||||
*/
|
||||
setCreator(creator: IUser): IRoomBuilder;
|
||||
|
||||
/**
|
||||
* Gets the room's creator.
|
||||
*/
|
||||
getCreator(): IUser;
|
||||
|
||||
/**
|
||||
* Adds a user to the room, these are by username until further notice.
|
||||
*
|
||||
* @param username the user's username to add to the room
|
||||
* @deprecated in favor of `addMemberToBeAddedByUsername`. This method will be removed on version 2.0.0
|
||||
*/
|
||||
addUsername(username: string): IRoomBuilder;
|
||||
|
||||
/**
|
||||
* Sets the usernames of who are joined to the room.
|
||||
*
|
||||
* @param usernames the list of usernames
|
||||
* @deprecated in favor of `setMembersByUsernames`. This method will be removed on version 2.0.0
|
||||
*/
|
||||
setUsernames(usernames: Array<string>): IRoomBuilder;
|
||||
|
||||
/**
|
||||
* Gets the usernames of users in the room.
|
||||
* @deprecated in favor of `getMembersUsernames`. This method will be removed on version 2.0.0
|
||||
*/
|
||||
getUsernames(): Array<string>;
|
||||
|
||||
/**
|
||||
* Adds a member to the room by username
|
||||
*
|
||||
* @param username the user's username to add to the room
|
||||
*/
|
||||
addMemberToBeAddedByUsername(username: string): IRoomBuilder;
|
||||
|
||||
/**
|
||||
* Sets a list of members to the room by usernames
|
||||
*
|
||||
* @param usernames the list of usernames
|
||||
*/
|
||||
setMembersToBeAddedByUsernames(usernames: Array<string>): IRoomBuilder;
|
||||
|
||||
/**
|
||||
* Gets the list of usernames of the members who are been added to the room
|
||||
*/
|
||||
getMembersToBeAddedUsernames(): Array<string>;
|
||||
|
||||
/**
|
||||
* Sets whether this room should be a default room or not.
|
||||
* This means that new users will automatically join this room
|
||||
* when they join the server.
|
||||
*
|
||||
* @param isDefault room should be default or not
|
||||
*/
|
||||
setDefault(isDefault: boolean): IRoomBuilder;
|
||||
|
||||
/**
|
||||
* Gets whether this room is a default room or not.
|
||||
*/
|
||||
getIsDefault(): boolean;
|
||||
|
||||
/**
|
||||
* Sets whether this room should be in read only state or not.
|
||||
* This means that users without the required permission to talk when
|
||||
* a room is muted will not be able to talk but instead will only be
|
||||
* able to read the contents of the room.
|
||||
*
|
||||
* @param isReadOnly whether it should be read only or not
|
||||
*/
|
||||
setReadOnly(isReadOnly: boolean): IRoomBuilder;
|
||||
|
||||
/**
|
||||
* Gets whether this room is on read only state or not.
|
||||
*/
|
||||
getIsReadOnly(): boolean;
|
||||
|
||||
/**
|
||||
* Sets whether this room should display the system messages (like user join, etc)
|
||||
* or not. This means that whenever a system event, such as joining or leaving, happens
|
||||
* then Rocket.Chat won't send the message to the channel.
|
||||
*
|
||||
* @param displaySystemMessages whether the messages should display or not
|
||||
*/
|
||||
setDisplayingOfSystemMessages(displaySystemMessages: boolean): IRoomBuilder;
|
||||
|
||||
/**
|
||||
* Gets whether this room should display the system messages or not.
|
||||
*/
|
||||
getDisplayingOfSystemMessages(): boolean;
|
||||
|
||||
/**
|
||||
* Adds a custom field to the room.
|
||||
* Note: This will replace an existing field with the same key should it exist already.
|
||||
*
|
||||
* @param key the name of the key
|
||||
* @param value the value of the custom field
|
||||
*/
|
||||
addCustomField(key: string, value: object): IRoomBuilder;
|
||||
|
||||
/**
|
||||
* Sets the entire custom field property to an object provided. This will overwrite
|
||||
* every existing key/values which are unrecoverable.
|
||||
*
|
||||
* @param fields the data to set
|
||||
*/
|
||||
setCustomFields(fields: { [key: string]: object }): IRoomBuilder;
|
||||
|
||||
/**
|
||||
* Gets the custom field property of the room.
|
||||
*/
|
||||
getCustomFields(): { [key: string]: object };
|
||||
|
||||
/**
|
||||
* Gets user ids of members from a direct message
|
||||
*/
|
||||
getUserIds(): Array<string>;
|
||||
|
||||
/**
|
||||
* Gets the resulting room that has been built up to the point of calling this method.
|
||||
* Note: modifying the returned value will have no effect.
|
||||
*/
|
||||
getRoom(): IRoom;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { RocketChatAssociationModel } from '../metadata';
|
||||
import type { IRoom } from '../rooms';
|
||||
import type { IUser } from '../users';
|
||||
|
||||
export interface IRoomExtender {
|
||||
kind: RocketChatAssociationModel.ROOM;
|
||||
|
||||
/**
|
||||
* Adds a custom field to the room.
|
||||
* Note: This key can not already exist or it will throw an error.
|
||||
* Note: The key must not contain a period in it, an error will be thrown.
|
||||
*
|
||||
* @param key the name of the custom field
|
||||
* @param value the value of this custom field
|
||||
*/
|
||||
addCustomField(key: string, value: any): IRoomExtender;
|
||||
|
||||
/**
|
||||
* Adds a user to the room.
|
||||
*
|
||||
* @param user the user which is to be added to the room
|
||||
*/
|
||||
addMember(user: IUser): IRoomExtender;
|
||||
|
||||
/**
|
||||
* Get a list of users being added to the room.
|
||||
*/
|
||||
getMembersBeingAdded(): Array<IUser>;
|
||||
|
||||
/**
|
||||
* Get a list of usernames of users being added to the room.
|
||||
*/
|
||||
getUsernamesOfMembersBeingAdded(): Array<string>;
|
||||
|
||||
/**
|
||||
* Gets the resulting room that has been extended at the point of calling this.
|
||||
* Note: modifying the returned value will have no effect.
|
||||
*/
|
||||
getRoom(): IRoom;
|
||||
}
|
||||
122
packages/apps-engine/src/definition/accessors/IRoomRead.ts
Normal file
122
packages/apps-engine/src/definition/accessors/IRoomRead.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { IMessageRaw } from '../messages/index';
|
||||
import type { GetMessagesOptions, GetRoomsFilters, GetRoomsOptions } from '../rooms/IGetMessagesOptions';
|
||||
import type { IRoom, IRoomRaw } from '../rooms/index';
|
||||
import type { IUser } from '../users/index';
|
||||
|
||||
/**
|
||||
* This accessor provides methods for accessing
|
||||
* rooms in a read-only-fashion.
|
||||
*/
|
||||
export interface IRoomRead {
|
||||
/**
|
||||
* Gets a room by an id.
|
||||
*
|
||||
* @param id the id of the room
|
||||
* @returns the room
|
||||
*/
|
||||
getById(id: string): Promise<IRoom | undefined>;
|
||||
|
||||
/**
|
||||
* Gets just the creator of the room by the room's id.
|
||||
*
|
||||
* @param id the id of the room
|
||||
* @returns the creator of the room
|
||||
*/
|
||||
getCreatorUserById(id: string): Promise<IUser | undefined>;
|
||||
|
||||
/**
|
||||
* Gets a room by its name.
|
||||
*
|
||||
* @param name the name of the room
|
||||
* @returns the room
|
||||
*/
|
||||
getByName(name: string): Promise<IRoom | undefined>;
|
||||
|
||||
/**
|
||||
* Gets just the creator of the room by the room's name.
|
||||
*
|
||||
* @param name the name of the room
|
||||
* @returns the creator of the room
|
||||
*/
|
||||
getCreatorUserByName(name: string): Promise<IUser | undefined>;
|
||||
|
||||
/**
|
||||
* Retrieves an array of messages from the specified room.
|
||||
*
|
||||
* @param roomId The unique identifier of the room from which to retrieve messages.
|
||||
* @param options Optional parameters for retrieving messages:
|
||||
* - limit: The maximum number of messages to retrieve. Maximum 100
|
||||
* - skip: The number of messages to skip (for pagination).
|
||||
* - sort: An object defining the sorting order of the messages. Each key is a field to sort by, and the value is either "asc" for ascending order or "desc" for descending order.
|
||||
* - showThreadMessages: Whether to include thread messages in the results. Defaults to true.
|
||||
* @returns A Promise that resolves to an array of IMessage objects representing the messages in the room.
|
||||
*/
|
||||
getMessages(roomId: string, options?: Partial<GetMessagesOptions>): Promise<Array<IMessageRaw>>;
|
||||
|
||||
/**
|
||||
* Gets an iterator for all of the users in the provided room.
|
||||
*
|
||||
* @param roomId the room's id
|
||||
* @returns an iterator for the users in the room
|
||||
*/
|
||||
getMembers(roomId: string): Promise<Array<IUser>>;
|
||||
|
||||
/**
|
||||
* Retrieves rooms in the workspace, optionally filtered by type and whether they are discussions or part of teams.
|
||||
*
|
||||
* @returns a list of raw rooms, or undefined if the app does not have the permission to view all rooms
|
||||
*/
|
||||
getAllRooms(filters?: GetRoomsFilters, options?: GetRoomsOptions): Promise<Array<IRoomRaw> | undefined>;
|
||||
|
||||
/**
|
||||
* Gets a direct room with all usernames
|
||||
* @param usernames all usernames belonging to the direct room
|
||||
* @returns the room
|
||||
*/
|
||||
getDirectByUsernames(usernames: Array<string>): Promise<IRoom>;
|
||||
|
||||
/**
|
||||
* Get a list of the moderators of a given room
|
||||
*
|
||||
* @param roomId the room's id
|
||||
* @returns a list of the users with the moderator role in the room
|
||||
*/
|
||||
getModerators(roomId: string): Promise<Array<IUser>>;
|
||||
|
||||
/**
|
||||
* Get a list of the owners of a given room
|
||||
*
|
||||
* @param roomId the room's id
|
||||
* @returns a list of the users with the owner role in the room
|
||||
*/
|
||||
getOwners(roomId: string): Promise<Array<IUser>>;
|
||||
|
||||
/**
|
||||
* Get a list of the leaders of a given room
|
||||
*
|
||||
* @param roomId the room's id
|
||||
* @returns a list of the users with the leader role in the room
|
||||
*/
|
||||
getLeaders(roomId: string): Promise<Array<IUser>>;
|
||||
|
||||
/**
|
||||
* Retrieves an array of unread messages for a specific user in a specific room.
|
||||
*
|
||||
* @param roomId The unique identifier of the room from which to retrieve unread messages.
|
||||
* @param uid The unique identifier of the user for whom to retrieve unread messages.
|
||||
* @param options Optional parameters for retrieving messages:
|
||||
* - limit: The maximum number of messages to retrieve. If more than 100 is passed, it defaults to 100.
|
||||
* - skip: The number of messages to skip (for pagination).
|
||||
* - sort: An object defining the sorting order of the messages. Each key is a field to sort by, and the value is either 'asc' for ascending order or 'desc' for descending order.
|
||||
* - showThreadMessages: Whether to include thread messages in the results. Defaults to true.
|
||||
* @returns A Promise that resolves to an array of IMessage objects representing the unread messages for the specified user in the specified room.
|
||||
*/
|
||||
getUnreadByUser(roomId: string, uid: string, options?: Partial<GetMessagesOptions>): Promise<IMessageRaw[]>;
|
||||
|
||||
/**
|
||||
* Gets the user's unread messages count in a room.
|
||||
* @param roomId room's id
|
||||
* @param uid user's id
|
||||
*/
|
||||
getUserUnreadMessageCount(roomId: string, uid: string): Promise<number>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { IProcessor } from '../scheduler';
|
||||
|
||||
export interface ISchedulerExtend {
|
||||
/**
|
||||
* Register processors that can be scheduled to run
|
||||
*
|
||||
* @param {Array<IProcessor>} processors An array of processors
|
||||
* @returns List of task ids run at startup, or void no startup run is set
|
||||
*/
|
||||
registerProcessors(processors: Array<IProcessor>): Promise<void | Array<string>>;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user