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

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

View File

@@ -0,0 +1,191 @@
import { Emitter } from '@rocket.chat/emitter';
import { DDPDispatcher } from './DDPDispatcher';
import type { DDPDispatchOptions } from './MinimalDDPClient';
import type { ClientStream } from './types/ClientStream';
import type { DDPClient } from './types/DDPClient';
import type { Subscription } from './types/Subscription';
import type { MethodPayload } from './types/methodsPayloads';
import type { PublicationPayloads } from './types/publicationPayloads';
export class ClientStreamImpl extends Emitter implements ClientStream {
subscriptions = new Map<string, Subscription>();
constructor(
private ddp: DDPClient,
readonly dispatcher: DDPDispatcher = new DDPDispatcher(),
) {
ddp.onConnection(({ msg }) => {
if (msg === 'connected') {
this.emit('connected');
}
});
super();
}
private apply({
payload: ddpCallPayload,
options,
callback,
}: {
payload: MethodPayload;
callback?: (...args: any[]) => void;
options?: DDPDispatchOptions;
}): string {
this.dispatcher.dispatch(ddpCallPayload, options);
this.ddp.onResult(ddpCallPayload.id, (payload) => {
this.dispatcher.removeItem(ddpCallPayload);
if (typeof callback === 'function') {
if ('error' in payload) {
callback(payload.error);
} else {
callback(null, payload.result);
}
}
});
return ddpCallPayload.id;
}
call(method: string, ...params: any[]): string {
// get the last argument
return this.callWithOptions(method, {}, ...params);
}
callWithOptions(method: string, options: DDPDispatchOptions, ...params: any[]): string {
// get the last argument
const callback = params.pop();
// if it's not a function, then push it back
if (typeof callback !== 'function') {
params.push(callback);
}
const payload = this.ddp.call(method, params);
this.apply({ payload, callback, options });
return payload.id;
}
callAsync(method: string, ...params: any[]) {
return this.callAsyncWithOptions(method, {}, ...params);
}
callAsyncWithOptions(
method: string,
options: DDPDispatchOptions,
...params: any[]
): Promise<any> & {
id: string;
} {
const payload = this.ddp.call(method, params);
return Object.assign(
new Promise((resolve, reject) => {
this.apply({
payload,
options,
callback: (error, result) => {
if (error) {
reject(error);
} else {
resolve(result);
}
},
});
}),
{
id: payload.id,
},
);
}
subscribe(name: string, ...params: any[]) {
const id = this.ddp.subscribe(name, params);
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
const s = self.ddp.onPublish(id, (payload) => {
if ('error' in payload) {
result.error = payload.error;
this.subscriptions.delete(id);
return;
}
result.isReady = true;
this.subscriptions.set(id, {
...result,
isReady: true,
});
});
const stop = () => {
s();
void self.unsubscribe(id);
};
const result: Subscription = {
id,
name,
params,
async ready() {
const subscription = self.subscriptions.get(id);
if (!subscription) {
return Promise.reject(result.error);
}
if (subscription.isReady) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
this.onChange((payload) => {
if ('error' in payload) {
reject(payload.error);
return;
}
resolve();
});
});
},
isReady: false,
onChange: (cb) => {
self.ddp.onPublish(id, cb);
},
stop,
};
this.subscriptions.set(id, result);
return result;
}
unsubscribe(id: string): Promise<any> {
return new Promise((resolve, reject) => {
this.subscriptions.delete(id);
this.ddp.unsubscribe(id);
this.ddp.onNoSub(id, (payload) => {
if ('error' in payload) {
reject(payload.error);
} else {
resolve(payload);
}
});
});
}
connect(): Promise<any> {
this.ddp.connect();
return new Promise((resolve, reject) => {
this.ddp.onConnection((data) => {
if (data.msg === 'failed') reject(data);
else resolve(data);
});
});
}
onCollection(id: string, callback: (data: PublicationPayloads) => void) {
return this.ddp.onCollection(id, callback);
}
}

View File

@@ -0,0 +1,284 @@
import { Emitter } from '@rocket.chat/emitter';
import type { DDPClient } from './types/DDPClient';
// type Subscription = {
// name: string;
// params: unknown[];
// id: string;
// status: 'queued' | 'subscribing' | 'ready' | 'error';
// };
// type Method = {
// method: string;
// params: unknown[];
// id: string;
// status: 'queued' | 'calling' | 'ready' | 'error';
// };
type RetryOptions = {
retryCount: number;
retryTimer?: NodeJS.Timeout;
retryTime: number;
};
export type ConnectionStatus = 'idle' | 'connecting' | 'connected' | 'failed' | 'closed' | 'disconnected' | 'reconnecting';
export interface Connection extends Emitter<{
connection: ConnectionStatus;
connecting: void;
connected: string;
disconnected: void;
reconnecting: void;
close: void;
}> {
url: string;
ssl: boolean;
session?: string;
status: ConnectionStatus;
connect(): Promise<boolean>;
reconnect(): Promise<boolean>;
close(): void;
}
interface WebSocketConstructor {
new (url: string | URL, protocols?: string | string[]): WebSocket;
}
export class ConnectionImpl
extends Emitter<{
connection: ConnectionStatus;
connecting: void;
connected: string;
disconnected: void;
reconnecting: void;
close: void;
}>
implements Connection
{
ssl: boolean;
url: string;
session?: string;
status: ConnectionStatus = 'idle';
ws: WebSocket | undefined;
retryCount = 0;
private connectPromise?: Promise<boolean>;
public queue = new Set<string>();
constructor(
url: string,
private WS: WebSocketConstructor,
private client: DDPClient,
readonly retryOptions: RetryOptions = { retryCount: 0, retryTime: 1000 },
) {
super();
this.ssl = url.startsWith('https') || url.startsWith('wss');
this.url = url.replace(/^https?:\/\//, '').replace(/^wss?:\/\//, '');
this.client.onDispatchMessage((message: string) => {
if (this.ws && this.ws.readyState === this.ws.OPEN) {
this.ws.send(message);
return;
}
this.queue.add(message);
});
this.on('connected', () => {
this.queue.forEach((message) => {
this.ws?.send(message);
});
this.queue.clear();
});
}
private emitStatus() {
this.emit('connection', this.status);
}
reconnect(): Promise<boolean> {
// Idempotent — if another caller already started (or finished) a connection
// since this reconnect was scheduled, we don't need to do anything. The
// retry timer enqueued by `ws.onclose` runs with no awareness of any
// concurrent `connect()` (e.g. the consumer's own bootstrap or
// resume-on-userId-change path), so without this guard a late timer
// rejected with "Connection in progress" — and because the timer fires
// from `void this.reconnect()` the rejection became an unhandled
// rejection at the page level.
if (this.status === 'connected') {
clearTimeout(this.retryOptions.retryTimer);
return Promise.resolve(true);
}
if (this.status === 'connecting') {
// Share the in-flight handshake promise so a `failed` payload
// later in the same attempt isn't masked by a synthesized success.
clearTimeout(this.retryOptions.retryTimer);
return this.connectPromise as Promise<boolean>;
}
clearTimeout(this.retryOptions.retryTimer);
this.emit('reconnecting');
this.emit('connection', 'reconnecting');
return this.connect();
}
connect() {
// Same idempotency guard as `reconnect()` — multiple call sites
// (`reconnect()`, ws.onclose retry timer, external `startConnect`) can
// race; rejecting forced every caller to wrap in `.catch(() => {})`
// just to silence noise, and the internal timer's `void this.reconnect()`
// path didn't have a catch at all.
if (this.status === 'connected') {
clearTimeout(this.retryOptions.retryTimer);
return Promise.resolve(true);
}
if (this.status === 'connecting') {
clearTimeout(this.retryOptions.retryTimer);
return this.connectPromise as Promise<boolean>;
}
this.status = 'connecting';
const ws = new this.WS(`${this.ssl ? 'wss://' : 'ws://'}${this.url}/websocket`);
this.ws = ws;
// Build the in-flight promise and publish it on `this.connectPromise`
// before emitting any status change, so a synchronous re-entrant
// caller (an event listener that calls `connect()`/`reconnect()`)
// hits the `'connecting'` guard and gets this same promise.
const connectPromise = new Promise<boolean>((resolve, reject) => {
ws.onopen = () => {
ws.onmessage = (event) => {
this.client.handleMessage(String(event.data));
};
// The server may send an initial message which is a JSON object lacking a msg key. If so, the client should ignore it. The client does not have to wait for this message.
// (The message was once used to help implement Meteor's hot code reload feature; it is now only included to force old clients to update).
// this.client.onceMessage((data) => {
// if (data.msg === undefined) {
// return;
// }
// if (data.msg === 'failed') {
// return;
// }
// if (data.msg === 'connected') {
// return;
// }
// this.close();
// });
// The client sends a connect message.
this.client.connect();
// If the server is willing to speak the version of the protocol specified in the connect message, it sends back a connected message.
// Otherwise the server sends back a failed message with a version of DDP it would rather speak, informed by the connect message's support field, and closes the underlying transport.
this.client.onConnection((payload) => {
if (payload.msg === 'connected') {
this.status = 'connected';
// Reset the retry budget on successful connection so a future
// disconnect can schedule reconnects again. Without this,
// long-lived connections that recover once would burn through
// `retryCount` permanently and stop reconnecting on subsequent
// drops — observed when a server-side ws.close (logout, force-
// logout) chained with a reconnect cycle saturated the
// budget; the next disconnect left frames stuck in the
// dispatcher queue forever because the socket never came back.
this.retryCount = 0;
this.emitStatus();
this.emit('connected', payload.session);
this.session = payload.session;
return resolve(true);
}
if (payload.msg === 'failed') {
this.status = 'failed';
this.emitStatus();
this.emit('disconnected');
return reject(payload.version);
}
/* istanbul ignore next */
reject(new Error('Unknown message type'));
});
};
ws.onclose = () => {
// If a newer ws has already taken over (this socket was closed
// after `connect()` opened a replacement), ignore the late
// onclose. Otherwise its handler would clobber `this.status` and
// `retryCount`, and could even schedule a redundant retry timer
// that fires while the new socket is healthy — observed as the
// "Connection in progress" pageError racing on every reconnect.
if (this.ws !== ws) {
return;
}
clearTimeout(this.retryOptions.retryTimer);
if (this.status === 'closed') {
return;
}
this.status = 'disconnected';
this.emitStatus();
this.emit('disconnected');
if (this.retryCount >= this.retryOptions.retryCount) {
return;
}
this.retryCount += 1;
this.retryOptions.retryTimer = setTimeout(() => {
// Re-check the status when the timer actually fires. If the
// consumer bootstrapped a fresh `connect()` in the meantime
// (status flipped from 'disconnected' to 'connecting' or
// 'connected'), there's nothing for us to do. Without this
// the timer would call `this.reconnect()`, which (pre-this
// patch) rejected with "Connection in progress" and surfaced
// as an unhandled rejection.
if (this.status === 'connecting' || this.status === 'connected' || this.status === 'closed') {
return;
}
void this.reconnect();
}, this.retryOptions.retryTime * this.retryCount);
};
});
this.connectPromise = connectPromise;
this.emit('connecting');
this.emitStatus();
return connectPromise;
}
close() {
this.status = 'closed';
this.ws?.close();
this.emitStatus();
}
static create(
url: string,
webSocketImpl: WebSocketConstructor,
client: DDPClient,
retryOptions: RetryOptions = { retryCount: 0, retryTime: 1000 },
): ConnectionImpl {
return new ConnectionImpl(url, webSocketImpl, client, retryOptions);
}
}

View File

@@ -0,0 +1,116 @@
/**
* A queue of ddp blocking methods that are waiting to be sent to the server.
*/
import { MinimalDDPClient } from './MinimalDDPClient';
import type { OutgoingPayload } from './types/OutgoingPayload';
import type { MethodPayload } from './types/methodsPayloads';
type Blocks = {
wait: boolean;
items: MethodPayload[];
};
type Queue = Blocks[];
export class DDPDispatcher extends MinimalDDPClient {
queue: Queue = [];
override dispatch(payload: OutgoingPayload, options?: { wait?: boolean }) {
// Only method payloads participate in the wait/queue serialization that
// implements Meteor's wait-method semantics. Protocol-level frames
// (connect, sub, unsub, ping, pong) must be delivered immediately:
// queueing a `connect` frame behind a wait `login` block, for example,
// wedges the DDP handshake — the socket opens but `connect` never
// reaches the server, so the server never replies `connected` and the
// session never establishes.
if (payload.msg !== 'method') {
this.emit('send', payload);
return;
}
if (options?.wait) {
this.wait(payload);
return;
}
this.pushItem(payload);
}
wait(block: MethodPayload) {
this.queue.push({
wait: true,
items: [block],
});
if (this.queue.length === 1) {
this.sendOutstandingBlocks();
}
}
private pushItem(item: MethodPayload) {
const block = this.tail();
if (!block || block.wait) {
this.queue.push({
wait: false,
items: [item],
});
}
if (!block) {
this.sendOutstandingBlocks();
return;
}
if (block.wait) {
return;
}
block.items.push(item);
}
private tail() {
return this.queue[this.queue.length - 1];
}
private sendOutstandingBlocks() {
const block = this.queue[0];
if (!block) {
return;
}
block.items.forEach((payload) => {
this.emit('send', payload);
});
if (block.wait) {
return;
}
this.queue.shift();
this.sendOutstandingBlocks();
}
removeItem(item: MethodPayload) {
const block = this.queue[0];
if (!block) {
return;
// throw new Error('No block to remove item from');
}
const index = block.items.indexOf(item);
if (index === -1) {
return;
// throw new Error('Item not found in block');
}
block.items.splice(index, 1);
if (block.items.length === 0) {
this.queue.shift();
this.sendOutstandingBlocks();
}
}
}

View File

@@ -0,0 +1,147 @@
import { RestClient } from '@rocket.chat/api-client';
import { ClientStreamImpl } from './ClientStream';
import type { Connection } from './Connection';
import { ConnectionImpl } from './Connection';
import { DDPDispatcher } from './DDPDispatcher';
import { TimeoutControl } from './TimeoutControl';
import type { Account } from './types/Account';
import { AccountImpl } from './types/Account';
import type { ClientStream } from './types/ClientStream';
import type { SDK } from './types/SDK';
interface PublicationPayloads {
collection: string;
id: string;
msg: 'added' | 'changed' | 'removed';
fields: {
eventName: string;
args: [unknown];
};
}
const isValidPayload = (data: unknown): data is PublicationPayloads => {
if (typeof data !== 'object' && (data !== null || data !== undefined)) {
return false;
}
return true;
};
export class DDPSDK implements SDK {
constructor(
readonly connection: Connection,
readonly client: ClientStream,
readonly account: Account,
readonly timeoutControl: TimeoutControl,
readonly rest: RestClient,
) {}
call(method: string, ...params: unknown[]) {
return this.client.callAsync(method, ...params);
}
stream(name: string, data: unknown, cb: (...data: PublicationPayloads['fields']['args']) => void) {
const [key, args] = Array.isArray(data) ? data : [data];
const subscription = this.client.subscribe(`stream-${name}`, key, { useCollection: false, args: [args] });
const stop = subscription.stop.bind(subscription);
const cancel = [
() => stop(),
this.client.onCollection(`stream-${name}`, (data) => {
if (!isValidPayload(data)) {
return;
}
if (data.collection !== `stream-${name}`) {
return;
}
if (data.msg !== 'changed') {
return;
}
if (data.fields.eventName === key) {
cb(...data.fields.args);
}
}),
];
return Object.assign(subscription, {
stop: () => {
cancel.forEach((fn) => fn());
},
});
}
/**
* Compounds the Objects responsible for the SDK and returns it through
* SDK interface
*
* @param url - The URL of the server to connect to
* @param retryOptions - The options for the retry strategy of the connection
* @param retryOptions.retryCount - The number of times to retry the connection
* @param retryOptions.retryTime - The time to wait between retries
* @returns The SDK interface
*
* @example
* ```ts
* const sdk = DDPSDK.create('wss://open.rocket.chat/websocket');
* sdk.connection.connect();
* ```
*/
static create(url: string, retryOptions = { retryCount: 1, retryTime: 100 }): DDPSDK {
const ddp = new DDPDispatcher();
const connection = ConnectionImpl.create(url, WebSocket, ddp, retryOptions);
const stream = new ClientStreamImpl(ddp, ddp);
const account = new AccountImpl(stream);
const timeoutControl = TimeoutControl.create(ddp, connection);
const rest = new (class RestApiClient extends RestClient {
override getCredentials() {
if (!account.uid || !account.user?.token) {
return;
}
return {
'X-User-Id': account.uid,
'X-Auth-Token': account.user.token,
};
}
})({ baseUrl: url });
const sdk = new DDPSDK(connection, stream, account, timeoutControl, rest);
connection.on('connected', () => {
if (account.user?.token) {
void account.loginWithToken(account.user.token);
}
[...stream.subscriptions.entries()].forEach(([, sub]) => {
ddp.subscribeWithId(sub.id, sub.name, sub.params);
});
});
return sdk;
}
/**
* Same as `DDPSDK.create`, but also connects to the server and waits for the connection to be established
* @param url - The URL of the server to connect to
* @param retryOptions - The options for the retry strategy of the connection
* @param retryOptions.retryCount - The number of times to retry the connection
* @param retryOptions.retryTime - The time to wait between retries
* @returns A promise that resolves to the SDK interface
* @example
* ```ts
* const sdk = await DDPSDK.createAndConnect('wss://open.rocket.chat/websocket');
* ```
*/
static async createAndConnect(url: string, retryOptions = { retryCount: 1, retryTime: 100 }): Promise<DDPSDK> {
const sdk = DDPSDK.create(url, retryOptions);
await sdk.connection.connect();
return sdk;
}
}

View File

@@ -0,0 +1,241 @@
import { Emitter } from '@rocket.chat/emitter';
import type { DDPClient } from './types/DDPClient';
import type { IncomingPayload } from './types/IncomingPayload';
import type { OutgoingPayload } from './types/OutgoingPayload';
import type { RemoveListener } from './types/RemoveListener';
import type { ConnectedPayload, ConnectPayload, FailedPayload } from './types/connectionPayloads';
import type { PongPayload } from './types/heartbeatsPayloads';
import type { MethodPayload, ResultPayload, UpdatedPayload } from './types/methodsPayloads';
import type {
AddedPayload,
ChangedPayload,
NosubPayload,
PublicationPayloads,
ReadyPayload,
RemovedPayload,
ServerPublicationPayloads,
SubscribePayload,
UnsubscribePayload,
} from './types/publicationPayloads';
/**
* Creates a unique id for a DDP client.
* @returns {string} - A unique id for a DDP client.
*/
const getUniqueId = (() => {
let id = 0;
return () => `rc-ddp-client-${++id}`;
})();
const SUPPORTED_DDP_VERSIONS = ['1', 'pre2', 'pre1'];
export interface DDPDispatchOptions {
wait?: boolean;
}
interface MinimalDDPClientEvents {
pong: PongPayload;
connection: ConnectedPayload | FailedPayload;
message: IncomingPayload;
send: OutgoingPayload;
[x: `publication/${string}`]: NosubPayload | ReadyPayload;
[x: `nosub/${string}`]: NosubPayload;
[x: `collection/${string}`]: AddedPayload | ChangedPayload | RemovedPayload;
[x: `result/${string}`]: ResultPayload;
[x: `updated/${string}`]: UpdatedPayload;
}
/**
* This class was created to be used together with the `WebSocket` class.
* It is responsible for low-level communication with the server.
* It is responsible for sending and receiving messages.
* It does not provide any procedures for connecting or reconnecting.
* It does not provide any procedure to handle collections.
* @example
* ```ts
* const socket = new WebSocket('ws://localhost:3000/websocket');
* const ddp = new MinimalDDPClient(socket.send.bind(socket));
* ddp.on('message', (data) => {
* console.log('Received message', data);
* });
* socket.onmessage = ({ data }) => {
* ddp.handleMessage(data);
* };
* ```
*/
export class MinimalDDPClient extends Emitter<MinimalDDPClientEvents> implements DDPClient {
constructor(
send?: (params: string) => void,
readonly encode = JSON.stringify,
readonly decode = JSON.parse,
) {
super();
if (send) {
this.onDispatchMessage(send);
}
}
/**
* @remarks
* if the received message is a valid DDP message, it will be emitted as an event.
*
* @param payload - The incoming message as a string.
* @throws {Error} - If the message is not a string.
* @throws {Error} - If the message is not a valid JSON.
*/
handleMessage(payload: string): void {
const data = this.decode(payload) as IncomingPayload;
Object.freeze(data);
switch (data.msg) {
case 'ping':
this.pong(data.id);
break;
case 'pong':
this.emit('pong', data);
break;
// Streamer related messages
case 'nosub':
this.emit(`publication/${data.id}`, data);
this.emit(`nosub/${data.id}`, data);
break;
case 'ready':
data.subs.forEach((id) => {
// this.emit(`publication/${id}`, { msg: 'ready', id });
this.emit(`publication/${id}`, { msg: 'ready', subs: [id] });
});
break;
case 'added':
case 'changed':
case 'removed':
this.emit(`collection/${data.collection}`, data);
break;
// DDP/RCP related messages
case 'result':
this.emit(`result/${data.id}`, data);
break;
case 'updated':
data.methods.forEach((id) => {
this.emit(`updated/${id}`, data);
});
break;
case 'connected':
case 'failed':
this.emit('connection', data);
break;
}
this.emit('message', data);
}
onDispatchMessage(callback: (msg: string) => void): RemoveListener {
return this.on('send', (payload) => {
callback(this.encode(payload));
});
}
protected dispatch(payload: OutgoingPayload): void {
this.emit('send', payload);
}
call(method: string, params: any[] = []) {
const id = getUniqueId();
const payload: MethodPayload = {
msg: 'method',
method,
params,
id,
};
return payload;
}
subscribe(name: string, params?: any[]): string {
const id = getUniqueId();
return this.subscribeWithId(id, name, params);
}
subscribeWithId(id: string, name: string, params?: any[]): string {
const payload: SubscribePayload = {
msg: 'sub',
id,
name,
params,
};
this.dispatch(payload);
return id;
}
unsubscribe(id: string): void {
const payload: UnsubscribePayload = {
msg: 'unsub',
id,
};
this.dispatch(payload);
}
connect(): void {
const payload: ConnectPayload = {
msg: 'connect',
version: '1',
support: SUPPORTED_DDP_VERSIONS,
};
this.dispatch(payload);
}
onPublish(name: string, callback: (payload: ServerPublicationPayloads) => void): RemoveListener {
return this.once(`publication/${name}`, callback);
}
onResult(id: string, callback: (payload: ResultPayload) => void): RemoveListener {
return this.once(`result/${id}`, callback);
}
onUpdate(id: string, callback: (payload: UpdatedPayload) => void): RemoveListener {
return this.on(`updated/${id}`, callback);
}
onNoSub(id: string, callback: (payload: NosubPayload) => void): RemoveListener {
return this.once(`nosub/${id}`, callback);
}
onCollection(name: string, callback: (payload: PublicationPayloads) => void): RemoveListener {
return this.on(`collection/${name}`, callback);
}
onConnection(callback: (payload: ConnectedPayload | FailedPayload) => void): RemoveListener {
return this.once('connection', callback);
}
onceMessage(callback: (payload: IncomingPayload) => void): RemoveListener {
return this.once('message', callback);
}
onMessage(callback: (payload: IncomingPayload) => void): RemoveListener {
return this.on('message', callback);
}
ping(id?: string): void {
this.dispatch({
msg: 'ping',
...(id && { id }),
});
}
private pong(id?: string): void {
this.dispatch({
msg: 'pong',
...(id && { id }),
});
}
}

View File

@@ -0,0 +1,85 @@
import { Emitter } from '@rocket.chat/emitter';
import type { Connection } from './Connection';
import type { DDPClient } from './types/DDPClient';
export interface TimeoutControlEvents extends Emitter<{
timeout: void;
heartbeat: void;
}> {
reset(): void;
stop(): void;
readonly timeout: number;
readonly heartbeat: number;
}
export class TimeoutControl
extends Emitter<{
timeout: void;
heartbeat: void;
}>
implements TimeoutControlEvents
{
private timeoutId: ReturnType<typeof setTimeout> | undefined;
private heartbeatId: ReturnType<typeof setTimeout> | undefined;
constructor(
readonly timeout: number = 60_000,
readonly heartbeat: number = timeout / 2,
) {
super();
/* istanbul ignore next */
if (this.heartbeat >= this.timeout) {
throw new Error('Heartbeat must be less than timeout');
}
}
reset() {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
if (this.heartbeatId) {
clearTimeout(this.heartbeatId);
}
this.timeoutId = setTimeout(() => this.emit('timeout'), this.timeout);
this.heartbeatId = setTimeout(() => this.emit('heartbeat'), this.heartbeat);
}
stop() {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
if (this.heartbeatId) {
clearTimeout(this.heartbeatId);
}
}
static create(ddp: DDPClient, connection: Connection, timeout?: number, heartbeat?: number): TimeoutControl {
const timeoutControl = new TimeoutControl(timeout, heartbeat);
timeoutControl.on('heartbeat', () => {
ddp.ping();
});
timeoutControl.on('timeout', () => {
connection.close();
});
ddp.onMessage(() => timeoutControl.reset());
connection.on('close', () => {
timeoutControl.stop();
});
connection.on('disconnected', () => {
timeoutControl.stop();
});
connection.on('connected', () => {
timeoutControl.reset();
});
return timeoutControl;
}
}

View File

@@ -0,0 +1,9 @@
export * from './DDPSDK';
export * from './legacy/RocketchatSDKLegacy';
export * from './livechat/LivechatClientImpl';
export type * from './livechat/types/LivechatSDK';
export type * from './types/ClientStream';
export type * from './types/methods';
export type * from './types/streams';
export type { SDK } from './types/SDK';
export type { ConnectionStatus } from './Connection';

View File

@@ -0,0 +1,287 @@
/* eslint-disable @typescript-eslint/no-this-alias */
import { RestClient } from '@rocket.chat/api-client';
import type { IMessage, Serialized } from '@rocket.chat/core-typings';
import { Emitter } from '@rocket.chat/emitter';
import type { OperationParams, OperationResult } from '@rocket.chat/rest-typings';
import { ClientStreamImpl } from '../ClientStream';
import { ConnectionImpl } from '../Connection';
import { DDPDispatcher } from '../DDPDispatcher';
import { DDPSDK } from '../DDPSDK';
import { TimeoutControl } from '../TimeoutControl';
import { AccountImpl } from '../types/Account';
import type { ClientStream } from '../types/ClientStream';
import type { DDPDispatchOptions } from '../types/DDPClient';
import type { ServerMethodReturn, ServerMethods } from '../types/methods';
import type { StreamNames, StreamKeys, StreamerCallbackArgs } from '../types/streams';
import type {
APILegacy,
DPPLegacy,
RocketchatSdkLegacyEvents,
RocketchatSdkLegacyEventsKeys,
RocketchatSdkLegacyEventsValues,
} from './types/SDKLegacy';
declare module '../ClientStream' {
interface ClientStream {
callAsync<MethodName extends keyof ServerMethods>(
methodName: MethodName,
...params: Parameters<ServerMethods[MethodName]>
): Promise<ServerMethodReturn<MethodName>>;
callAsyncWithOptions<MethodName extends keyof ServerMethods>(
methodName: MethodName,
options: DDPDispatchOptions,
...params: Parameters<ServerMethods[MethodName]>
): Promise<ServerMethodReturn<MethodName>>;
}
}
declare module '../types/SDK' {
interface SDK {
stream<N extends StreamNames, K extends StreamKeys<N>>(
streamName: N,
key: K,
callback: (...args: StreamerCallbackArgs<N, K>) => void,
): ReturnType<ClientStream['subscribe']>;
}
}
interface RocketchatSDKLegacy extends APILegacy, DPPLegacy {}
export class RocketchatSdkLegacyImpl extends DDPSDK implements RocketchatSDKLegacy {
private ev = new Emitter<RocketchatSdkLegacyEvents>();
get url(): string {
return this.connection.url;
}
get users() {
const self = this;
return {
all(fields?: { name: 1; username: 1; status: 1; type: 1 }): Promise<Serialized<OperationResult<'GET', '/v1/users.list'>>> {
return self.rest.get('/v1/users.list', { fields: JSON.stringify(fields) });
},
allNames(): Promise<Serialized<OperationResult<'GET', '/v1/users.list'>>> {
return self.rest.get('/v1/users.list', { fields: JSON.stringify({ name: 1 }) });
},
allIDs(): Promise<Serialized<OperationResult<'GET', '/v1/users.list'>>> {
return self.rest.get('/v1/users.list', { fields: JSON.stringify({ _id: 1 }) });
},
online(fields?: { name: 1; username: 1; status: 1; type: 1 }): Promise<Serialized<OperationResult<'GET', '/v1/users.list'>>> {
return self.rest.get('/v1/users.list', { fields: JSON.stringify(fields), query: JSON.stringify({ status: { $ne: 'offline' } }) });
},
onlineNames(): Promise<Serialized<OperationResult<'GET', '/v1/users.list'>>> {
return self.rest.get('/v1/users.list', {
fields: JSON.stringify({ name: 1 }),
query: JSON.stringify({ status: { $ne: 'offline' } }),
});
},
onlineIds(): Promise<Serialized<OperationResult<'GET', '/v1/users.list'>>> {
return self.rest.get('/v1/users.list', {
fields: JSON.stringify({ _id: 1 }),
query: JSON.stringify({ status: { $ne: 'offline' } }),
});
},
info(username: string): Promise<Serialized<OperationResult<'GET', '/v1/users.info'>>> {
return self.rest.get('/v1/users.info', { username });
},
};
}
get rooms() {
const self = this;
return {
info: (
args:
| {
roomId: string;
}
| {
roomName: string;
},
): Promise<Serialized<OperationResult<'GET', '/v1/rooms.info'>>> => {
return self.rest.get('/v1/rooms.info', args);
},
join: (rid: string): Promise<Serialized<OperationResult<'POST', '/v1/channels.join'>>> => {
return self.rest.post('/v1/channels.join', { roomId: rid });
},
load: (rid: string, lastUpdate: Date): Promise<Serialized<OperationResult<'GET', '/v1/chat.syncMessages'>>> => {
return self.rest.get('/v1/chat.syncMessages', { roomId: rid, lastUpdate: lastUpdate.toISOString() });
},
leave: (rid: string): Promise<Serialized<OperationResult<'POST', '/v1/channels.leave'>>> => {
return self.rest.post('/v1/channels.leave', { roomId: rid });
},
};
}
joinRoom(args: { rid: string }): Promise<Serialized<OperationResult<'POST', '/v1/channels.join'>>> {
return this.rest.post('/v1/channels.join', { roomId: args.rid });
}
loadHistory(rid: string, lastUpdate: Date): Promise<Serialized<OperationResult<'GET', '/v1/chat.syncMessages'>>> {
return this.rest.get('/v1/chat.syncMessages', { roomId: rid, lastUpdate: lastUpdate.toISOString() });
}
leaveRoom(rid: string): Promise<Serialized<OperationResult<'POST', '/v1/channels.leave'>>> {
return this.rest.post('/v1/channels.leave', { roomId: rid });
}
get dm() {
const self = this;
return {
create(username: string): Promise<Serialized<OperationResult<'POST', '/v1/im.create'>>> {
return self.rest.post('/v1/im.create', { username });
},
};
}
channelInfo(args: { roomName: string } | { roomId: string }): Promise<Serialized<OperationResult<'GET', '/v1/channels.info'>>> {
return this.rest.get('/v1/channels.info', args);
}
privateInfo(args: { roomName: string } | { roomId: string }): Promise<Serialized<OperationResult<'GET', '/v1/groups.info'>>> {
return this.rest.get('/v1/groups.info', args);
}
editMessage(args: OperationParams<'POST', '/v1/chat.update'>): Promise<Serialized<OperationResult<'POST', '/v1/chat.update'>>> {
return this.rest.post('/v1/chat.update', args);
}
setReaction(emoji: string, messageId: string): Promise<Serialized<OperationResult<'POST', '/v1/chat.react'>>> {
return this.rest.post('/v1/chat.react', { emoji, messageId });
}
createDirectMessage(username: string): Promise<Serialized<OperationResult<'POST', '/v1/im.create'>>> {
return this.rest.post('/v1/im.create', { username });
}
sendMessage(message: IMessage | string, rid: string): Promise<Serialized<OperationResult<'POST', '/v1/chat.sendMessage'>>> {
return this.rest.post('/v1/chat.sendMessage', {
message:
typeof message === 'string'
? {
msg: message,
rid,
}
: {
...message,
rid,
},
});
}
resume({ token }: { token: string }): Promise<unknown> {
return this.account.loginWithToken(token);
}
login(credentials: { username: string; password: string }): Promise<unknown> {
return this.account.loginWithPassword(credentials.username, credentials.password);
}
onMessage(cb: (data: any) => void) {
return this.ev.on('message', cb);
}
methodCall(method: string, ...args: any[]) {
return this.client.callAsync(method, ...args);
}
subscribe(topic: string, ...args: any[]) {
return this.client.subscribe(topic, ...args).ready();
}
subscribeRoom(rid: string): Promise<unknown> {
return Promise.all([
this.stream('room-messages', rid, (...args) => this.ev.emit('message', args)),
this.stream('notify-room', `${rid}/typing`, (...args) => this.ev.emit('typing', args)),
this.stream('notify-room', `${rid}/deleteMessage`, (...args) => this.ev.emit('deleteMessage', args)),
]);
}
subscribeNotifyAll(): Promise<any> {
return Promise.all([
// this.stream('notify-all', 'roles-change', (...args) => this.ev.emit('roles-change', args)),
// this.stream('notify-all', 'updateEmojiCustom', (...args) => this.ev.emit('updateEmojiCustom', args)),
// this.stream('notify-all', 'deleteEmojiCustom', (...args) => this.ev.emit('deleteEmojiCustom', args)),
// this.stream('notify-all', 'updateAvatar', (...args) => this.ev.emit('updateAvatar', args)),
this.stream('notify-all', 'public-settings-changed', (...args) => this.ev.emit('public-settings-changed', args)),
]);
}
subscribeLoggedNotify(): Promise<any> {
return Promise.all([
this.stream('notify-logged', 'Users:NameChanged', (...args) => this.ev.emit('Users:NameChanged', args)),
this.stream('notify-logged', 'Users:Deleted', (...args) => this.ev.emit('Users:Deleted', args)),
this.stream('notify-logged', 'updateAvatar', (...args) => this.ev.emit('updateAvatar', args)),
this.stream('notify-logged', 'updateEmojiCustom', (...args) => this.ev.emit('updateEmojiCustom', args)),
this.stream('notify-logged', 'deleteEmojiCustom', (...args) => this.ev.emit('deleteEmojiCustom', args)),
this.stream('notify-logged', 'roles-change', (...args) => this.ev.emit('roles-change', args)),
this.stream('notify-logged', 'permissions-changed', (...args) => this.ev.emit('permissions-changed', args)),
]);
}
subscribeNotifyUser = (): Promise<any> => {
return Promise.all([
this.stream('notify-user', `${this.account.uid}/message`, (...args) => this.ev.emit('user-message', args)),
this.stream('notify-user', `${this.account.uid}/notification`, (...args) => this.ev.emit('notification', args)),
this.stream('notify-user', `${this.account.uid}/rooms-changed`, (...args) => this.ev.emit('rooms-changed', args)),
this.stream('notify-user', `${this.account.uid}/subscriptions-changed`, (...args) => this.ev.emit('subscriptions-changed', args)),
this.stream('notify-user', `${this.account.uid}/uiInteraction`, (...args) => this.ev.emit('uiInteraction', args)),
]);
};
onStreamData<E extends RocketchatSdkLegacyEventsKeys>(event: E, cb: (...data: RocketchatSdkLegacyEventsValues<E>) => void): () => void {
return this.ev.on(event, cb as any);
}
async disconnect(): Promise<unknown> {
return this.connection.close();
}
connect(_options: { useSsl: boolean; host: string; port: number }) {
return this.connection.connect();
}
unsubscribe(subscription: string): Promise<unknown> {
return this.client.unsubscribe(subscription);
}
unsubscribeAll(): Promise<unknown> {
return Promise.all(Object.entries(this.client.subscriptions).map(([, subscription]) => this.client.unsubscribe(subscription)));
}
static override create(url: string, retryOptions = { retryCount: 1, retryTime: 100 }): RocketchatSdkLegacyImpl {
const ddp = new DDPDispatcher();
const connection = ConnectionImpl.create(url, WebSocket, ddp, retryOptions);
const stream = new ClientStreamImpl(ddp, ddp);
const account = new AccountImpl(stream);
const timeoutControl = TimeoutControl.create(ddp, connection);
const rest = new (class RestApiClient extends RestClient {
override getCredentials() {
if (!account.uid || !account.user?.token) {
return;
}
return {
'X-User-Id': account.uid,
'X-Auth-Token': account.user?.token,
};
}
})({ baseUrl: url });
const sdk = new RocketchatSdkLegacyImpl(connection, stream, account, timeoutControl, rest);
connection.on('connected', () => {
Object.entries(stream.subscriptions).forEach(([, sub]) => {
ddp.subscribeWithId(sub.id, sub.name, sub.params);
});
});
return sdk;
}
}

View File

@@ -0,0 +1,98 @@
import type { IMessage, Serialized } from '@rocket.chat/core-typings';
import type { OperationParams, OperationResult } from '@rocket.chat/rest-typings';
import type { StreamerCallbackArgs } from '../../types/streams';
export interface APILegacy {
users: {
all(fields?: { name: 1; username: 1; status: 1; type: 1 }): Promise<Serialized<OperationResult<'GET', '/v1/users.list'>>>;
allNames(): Promise<Serialized<OperationResult<'GET', '/v1/users.list'>>>;
allIDs(): Promise<Serialized<OperationResult<'GET', '/v1/users.list'>>>;
online(fields?: { name: 1; username: 1; status: 1; type: 1 }): Promise<Serialized<OperationResult<'GET', '/v1/users.list'>>>;
onlineNames(): Promise<Serialized<OperationResult<'GET', '/v1/users.list'>>>;
onlineIds(): Promise<Serialized<OperationResult<'GET', '/v1/users.list'>>>;
info(username: string): Promise<Serialized<OperationResult<'GET', '/v1/users.info'>>>;
};
rooms: {
info(args: { roomName: string } | { roomId: string }): Promise<Serialized<OperationResult<'GET', '/v1/rooms.info'>>>;
join(rid: string): Promise<Serialized<OperationResult<'POST', '/v1/channels.join'>>>;
load(rid: string, lastUpdate: Date): Promise<Serialized<OperationResult<'GET', '/v1/chat.syncMessages'>>>;
leave(rid: string): Promise<Serialized<OperationResult<'POST', '/v1/channels.leave'>>>;
};
joinRoom(args: { rid: string }): Promise<Serialized<OperationResult<'POST', '/v1/channels.join'>>>;
loadHistory(rid: string, lastUpdate: Date): Promise<Serialized<OperationResult<'GET', '/v1/chat.syncMessages'>>>;
leaveRoom(rid: string): Promise<Serialized<OperationResult<'POST', '/v1/channels.leave'>>>;
dm: {
create(username: string): Promise<Serialized<OperationResult<'POST', '/v1/im.create'>>>;
};
createDirectMessage(username: string): Promise<Serialized<OperationResult<'POST', '/v1/im.create'>>>;
sendMessage(message: IMessage | string, rid: string): Promise<Serialized<OperationResult<'POST', '/v1/chat.sendMessage'>>>;
// getRoomIdByNameOrId(name: string): Promise<Serialized<OperationResult<'GET', '/v1/chat.getRoomIdByNameOrId'>>>;
// getRoomNameById(rid: string): Promise<Serialized<OperationResult<'GET', '/v1/chat.getRoomNameById'>>>;
// getRoomName(rid: string): Promise<Serialized<OperationResult<'GET', '/v1/chat.getRoomNameById'>>>;
// getRoomId(name: string): Promise<Serialized<OperationResult<'GET', '/v1/chat.find'>>>;
editMessage(args: OperationParams<'POST', '/v1/chat.update'>): Promise<Serialized<OperationResult<'POST', '/v1/chat.update'>>>;
setReaction(emoji: string, messageId: string): Promise<Serialized<OperationResult<'POST', '/v1/chat.react'>>>;
channelInfo(args: { roomName: string } | { roomId: string }): Promise<Serialized<OperationResult<'GET', '/v1/channels.info'>>>;
privateInfo(args: { roomName: string } | { roomId: string }): Promise<Serialized<OperationResult<'GET', '/v1/groups.info'>>>;
}
export interface DPPLegacy {
resume({ token }: { token: string }): Promise<unknown>;
login(credentials: { username: string; password: string }): Promise<unknown>;
connect(options: { useSsl: boolean; host: string; port: number }): Promise<unknown>;
disconnect(): Promise<unknown>;
onStreamData<E extends RocketchatSdkLegacyEventsKeys>(event: E, cb: (...data: RocketchatSdkLegacyEventsValues<E>) => void): () => void;
subscribe(topic: string, ...args: any[]): Promise<unknown>;
unsubscribe(subscription: unknown): Promise<unknown>;
unsubscribeAll(): Promise<unknown>;
subscribeRoom(rid: string, ...args: any[]): Promise<unknown>;
subscribeNotifyAll(): Promise<unknown>;
subscribeLoggedNotify(): Promise<unknown>;
subscribeNotifyUser(): Promise<unknown>;
url: string;
onMessage(cb: (data: any) => void): () => void;
methodCall(method: string, ...args: any[]): Promise<unknown>;
}
export type RocketchatSdkLegacyEvents = {
'message': StreamerCallbackArgs<'room-messages', string>;
'typing': StreamerCallbackArgs<'notify-room', `${string}/typing`>;
'deleteMessage': StreamerCallbackArgs<'notify-room', `${string}/deleteMessage`>;
'roles-change': StreamerCallbackArgs<'notify-logged', 'roles-change'>;
'updateEmojiCustom': StreamerCallbackArgs<'notify-logged', 'updateEmojiCustom'>;
'deleteEmojiCustom': StreamerCallbackArgs<'notify-logged', 'deleteEmojiCustom'>;
'public-settings-changed': StreamerCallbackArgs<'notify-all', 'public-settings-changed'>;
'permissions-changed': StreamerCallbackArgs<'notify-logged', 'permissions-changed'>;
'Users:NameChanged': StreamerCallbackArgs<'notify-logged', 'Users:NameChanged'>;
'Users:Deleted': StreamerCallbackArgs<'notify-logged', 'Users:Deleted'>;
'updateAvatar': StreamerCallbackArgs<'notify-logged', 'updateAvatar'>;
'user-message': StreamerCallbackArgs<'notify-user', `${string}/message`>;
'notification': StreamerCallbackArgs<'notify-user', `${string}/notification`>;
'rooms-changed': StreamerCallbackArgs<'notify-user', `${string}/rooms-changed`>;
'subscriptions-changed': StreamerCallbackArgs<'notify-user', `${string}/subscriptions-changed`>;
'uiInteraction': StreamerCallbackArgs<'notify-user', `${string}/uiInteraction`>;
};
export type RocketchatSdkLegacyEventsKeys = keyof RocketchatSdkLegacyEvents;
export type RocketchatSdkLegacyEventsValues<E extends RocketchatSdkLegacyEventsKeys> = RocketchatSdkLegacyEvents[E];

View File

@@ -0,0 +1,380 @@
import { RestClient } from '@rocket.chat/api-client';
import type { IOmnichannelRoom, Serialized } from '@rocket.chat/core-typings';
import { Emitter } from '@rocket.chat/emitter';
import type { OperationParams, OperationResult } from '@rocket.chat/rest-typings';
import { ClientStreamImpl } from '../ClientStream';
import { ConnectionImpl } from '../Connection';
import { DDPDispatcher } from '../DDPDispatcher';
import { DDPSDK } from '../DDPSDK';
import { TimeoutControl } from '../TimeoutControl';
import { AccountImpl } from '../types/Account';
import type { ClientStream } from '../types/ClientStream';
import type { DDPDispatchOptions } from '../types/DDPClient';
import type { ServerMethodReturn, ServerMethods } from '../types/methods';
import type { StreamKeys, StreamNames, StreamerCallbackArgs } from '../types/streams';
import type { LivechatEndpoints, LivechatRoomEvents, LivechatStream } from './types/LivechatSDK';
declare module '../ClientStream' {
interface ClientStream {
callAsync<MethodName extends keyof ServerMethods>(
methodName: MethodName,
...params: Parameters<ServerMethods[MethodName]>
): Promise<ServerMethodReturn<MethodName>>;
callAsyncWithOptions<MethodName extends keyof ServerMethods>(
methodName: MethodName,
options: DDPDispatchOptions,
...params: Parameters<ServerMethods[MethodName]>
): Promise<ServerMethodReturn<MethodName>>;
}
}
declare module '../DDPSDK' {
interface DDPSDK {
stream<N extends StreamNames, K extends StreamKeys<N>>(
streamName: N,
data: K | [K, unknown],
callback: (...args: StreamerCallbackArgs<N, K>) => void,
): ReturnType<ClientStream['subscribe']>;
}
}
export class LivechatClientImpl extends DDPSDK implements LivechatStream, LivechatEndpoints {
public token?: string;
public readonly credentials: { token?: string } = { token: this.token };
private ev = new Emitter<{
userActivity: StreamerCallbackArgs<'notify-room', `${string}/user-activity`>;
message: StreamerCallbackArgs<'room-messages', string>;
delete: StreamerCallbackArgs<'notify-room', `${string}/deleteMessage`>;
}>();
subscribeRoom(rid: string) {
return Promise.all([
this.onRoomMessage(rid, (...args) => this.ev.emit('message', args)),
this.onRoomUserActivity(rid, (...args) => this.ev.emit('userActivity', args)),
this.onRoomDeleteMessage(rid, (...args) => this.ev.emit('delete', args)),
]);
}
onMessage(cb: (...args: StreamerCallbackArgs<'room-messages', string>) => void): () => void {
return this.ev.on('message', (args) => cb(...args));
}
onUserActivity(cb: (username: string, events: string[]) => void): () => void {
return this.ev.on('userActivity', (args) => args[1] && cb(args[0], args[1]));
}
onRoomMessage(rid: string, cb: (...args: StreamerCallbackArgs<'room-messages', string>) => void) {
return this.stream('room-messages', [rid, { token: this.token, visitorToken: this.token }], cb).stop;
}
onRoomUserActivity(rid: string, cb: (...args: StreamerCallbackArgs<'notify-room', `${string}/user-activity`>) => void) {
return this.stream('notify-room', [`${rid}/user-activity`, { token: this.token, visitorToken: this.token }], cb).stop;
}
onRoomDeleteMessage(rid: string, cb: (...args: StreamerCallbackArgs<'notify-room', `${string}/deleteMessage`>) => void) {
return this.stream('notify-room', [`${rid}/deleteMessage`, { token: this.token, visitorToken: this.token }], cb).stop;
}
onAgentChange(rid: string, cb: (data: LivechatRoomEvents<'agentData'>) => void): () => void {
return this.stream('livechat-room', [rid, { token: this.token, visitorToken: this.token }], (data) => {
if (data.type === 'agentData') {
cb(data.data);
}
}).stop;
}
onAgentStatusChange(rid: string, cb: (data: LivechatRoomEvents<'agentStatus'>) => void): () => void {
return this.stream('livechat-room', [rid, { token: this.token, visitorToken: this.token }], (data) => {
if (data.type === 'agentStatus') {
cb(data.status);
}
}).stop;
}
onQueuePositionChange(rid: string, cb: (data: LivechatRoomEvents<'queueData' | 'agentData'>) => void): () => void {
return this.stream('livechat-room', rid, (data) => {
if (data.type === 'queueData') {
cb(data.data);
}
}).stop;
}
onVisitorChange(rid: string, cb: (data: LivechatRoomEvents<'visitorData'>) => void): () => void {
return this.stream('livechat-room', [rid, { token: this.token, visitorToken: this.token }], (data) => {
if (data.type === 'visitorData') {
cb(data.visitor);
}
}).stop;
}
notifyVisitorActivity(rid: string, username: string, activity: string[]) {
return this.client.callAsync('stream-notify-room', `${rid}/user-activity`, username, activity, { token: this.token });
}
notifyCallDeclined(rid: string) {
return this.client.callAsync('stream-notify-room', `${rid}/call`, 'decline');
}
// API GETTERS
async config(
params: OperationParams<'GET', '/v1/livechat/config'>,
): Promise<Serialized<OperationResult<'GET', '/v1/livechat/config'>['config']>> {
const { config } = await this.rest.get('/v1/livechat/config', params);
return config;
}
async room(params: OperationParams<'GET', '/v1/livechat/room'>): Promise<Serialized<IOmnichannelRoom>> {
if (!this.token) {
throw new Error('Invalid token');
}
const result = await this.rest.get('/v1/livechat/room', { ...params, token: this.token });
// TODO: On major version bump, normalize the return of /v1/livechat/room
function isRoomObject(
room: Serialized<IOmnichannelRoom> | { room: Serialized<IOmnichannelRoom> },
): room is { room: Serialized<IOmnichannelRoom> } {
return (room as { room: Serialized<IOmnichannelRoom> }).room !== undefined;
}
if (isRoomObject(result)) {
return result.room;
}
return result;
}
visitor(
params: OperationParams<'GET', '/v1/livechat/visitor'>,
): Promise<Serialized<OperationResult<'GET', '/v1/livechat/visitor'>['visitor']>> {
if (!this.token) {
throw new Error('Invalid token');
}
const endpoint = `/v1/livechat/visitor/${this.token}`;
return this.rest.get(endpoint as '/v1/livechat/visitor/:token', params);
}
nextAgent(
params: OperationParams<'GET', '/v1/livechat/agent.next/:token'>,
): Promise<Serialized<OperationResult<'GET', '/v1/livechat/agent.next/:token'>>> {
if (!this.token) {
throw new Error('Invalid token');
}
return this.rest.get(`/v1/livechat/agent.next/${this.token}`, params);
}
async agent(rid: string): Promise<Serialized<OperationResult<'GET', '/v1/livechat/agent.info/:rid/:token'>['agent']>> {
if (!this.token) {
throw new Error('Invalid token');
}
const { agent } = await this.rest.get(`/v1/livechat/agent.info/${rid}/${this.token}`);
return agent;
}
message(
id: string,
params: OperationParams<'GET', '/v1/livechat/message/:_id'>,
): Promise<Serialized<OperationResult<'GET', '/v1/livechat/message/:_id'>>> {
if (!this.token) {
throw new Error('Invalid token');
}
return this.rest.get(`/v1/livechat/message/${id}`, { ...params, token: this.token });
}
async loadMessages(
rid: string,
params: OperationParams<'GET', '/v1/livechat/messages.history/:rid'>,
): Promise<Serialized<OperationResult<'GET', '/v1/livechat/messages.history/:rid'>['messages']>> {
if (!this.token) {
throw new Error('Invalid token');
}
const { messages } = await this.rest.get(`/v1/livechat/messages.history/${rid}`, { ...params, token: this.token });
return messages;
}
// API POST
transferChat({
rid,
department,
}: {
rid: string;
department: string;
}): Promise<Serialized<OperationResult<'POST', '/v1/livechat/visitor/department.transfer'>>> {
if (!this.token) {
throw new Error('Invalid token');
}
return this.rest.post('/v1/livechat/visitor/department.transfer', { rid, token: this.token, department });
}
async grantVisitor(
guest: OperationParams<'POST', '/v1/livechat/visitor'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/visitor'>>> {
const result = await this.rest.post('/v1/livechat/visitor', guest);
this.token = result?.visitor.token;
return result;
}
login(guest: OperationParams<'POST', '/v1/livechat/visitor'>) {
return this.grantVisitor(guest);
}
closeChat({ rid }: { rid: string }): Promise<Serialized<OperationResult<'POST', '/v1/livechat/room.close'>>> {
if (!this.token) {
throw new Error('Invalid token');
}
return this.rest.post('/v1/livechat/room.close', { rid, token: this.token });
}
chatSurvey(
params: OperationParams<'POST', '/v1/livechat/room.survey'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/room.survey'>>> {
if (!this.token) {
throw new Error('Invalid token');
}
return this.rest.post('/v1/livechat/room.survey', { rid: params.rid, token: this.token, data: params.data });
}
sendMessage(
params: OperationParams<'POST', '/v1/livechat/message'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/message'>>> {
if (!this.token) {
throw new Error('Invalid token');
}
return this.rest.post('/v1/livechat/message', { ...params, token: this.token });
}
sendOfflineMessage(
params: OperationParams<'POST', '/v1/livechat/offline.message'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/offline.message'>>> {
return this.rest.post('/v1/livechat/offline.message', params);
}
sendVisitorNavigation(
params: OperationParams<'POST', '/v1/livechat/page.visited'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/page.visited'>>> {
return this.rest.post('/v1/livechat/page.visited', params);
}
requestTranscript(email: string, { rid }: { rid: string }): Promise<Serialized<OperationResult<'POST', '/v1/livechat/transcript'>>> {
if (!this.token) {
throw new Error('Invalid token');
}
return this.rest.post('/v1/livechat/transcript', { token: this.token, rid, email });
}
sendCustomField(
params: OperationParams<'POST', '/v1/livechat/custom.field'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/custom.field'>>> {
return this.rest.post('/v1/livechat/custom.field', params);
}
sendCustomFields(
params: OperationParams<'POST', '/v1/livechat/custom.fields'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/custom.fields'>>> {
return this.rest.post('/v1/livechat/custom.fields', params);
}
async updateVisitorStatus(newStatus: string): Promise<Serialized<OperationResult<'POST', '/v1/livechat/visitor.status'>['status']>> {
if (!this.token) {
throw new Error('Invalid token');
}
const { status } = await this.rest.post('/v1/livechat/visitor.status', { token: this.token, status: newStatus });
return status;
}
uploadFile(rid: string, file: File): Promise<ProgressEvent<EventTarget>> {
if (!this.token) {
throw new Error('Invalid token');
}
if (!file) {
throw new Error('Invalid file');
}
return new Promise((resolve, reject) => {
if (!this.token) {
return reject(new Error('Invalid token'));
}
return this.rest.upload(
`/v1/livechat/upload/${rid}`,
{ file },
{
load: resolve,
error: reject,
},
{ headers: { 'x-visitor-token': this.token } },
);
});
}
async sendUiInteraction(
payload: OperationParams<'POST', '/apps/ui.interaction/:id'>,
appId: string,
): Promise<Serialized<OperationResult<'POST', '/apps/ui.interaction/:id'>>> {
if (!this.token) {
throw new Error('Invalid token');
}
return this.rest.post(`/apps/ui.interaction/${appId}`, payload, { headers: { 'x-visitor-token': this.token } });
}
// API DELETE
deleteMessage(id: string, { rid }: { rid: string }): Promise<Serialized<OperationResult<'DELETE', '/v1/livechat/message/:_id'>>> {
if (!this.token) {
throw new Error('Invalid token');
}
return this.rest.delete(`/v1/livechat/message/${id}`, { rid, token: this.token });
}
deleteVisitor(): Promise<Serialized<OperationResult<'DELETE', '/v1/livechat/visitor'>>> {
if (!this.token) {
throw new Error('Invalid token');
}
return this.rest.delete(`/v1/livechat/visitor/${this.token}` as any);
}
// API PUT
editMessage(
id: string,
params: OperationParams<'PUT', '/v1/livechat/message/:_id'>,
): Promise<Serialized<OperationResult<'PUT', '/v1/livechat/message/:_id'>>> {
return this.rest.put(`/v1/livechat/message/${id}`, params);
}
unsubscribeAll(): Promise<unknown> {
const subscriptions = Array.from(this.client.subscriptions.keys());
return Promise.all(subscriptions.map((subscription) => this.client.unsubscribe(subscription)));
}
static override create(url: string, retryOptions = { retryCount: 3, retryTime: 10000 }): LivechatClientImpl {
// TODO: Decide what to do with the EJSON objects
const ddp = new DDPDispatcher();
const connection = ConnectionImpl.create(url, WebSocket, ddp, retryOptions);
const stream = new ClientStreamImpl(ddp, ddp);
const account = new AccountImpl(stream);
const timeoutControl = TimeoutControl.create(ddp, connection);
const rest = new RestClient({ baseUrl: url.replace(/^ws/, 'http') });
const sdk = new LivechatClientImpl(connection, stream, account, timeoutControl, rest);
connection.on('connected', () => {
for (const [, sub] of stream.subscriptions.entries()) {
ddp.subscribeWithId(sub.id, sub.name, sub.params);
}
});
return sdk;
}
}

View File

@@ -0,0 +1,100 @@
import type { Serialized } from '@rocket.chat/core-typings';
import type { OperationParams, OperationResult } from '@rocket.chat/rest-typings';
import type { StreamerCallbackArgs } from '../../types/streams';
export type LivechatRoomEvents<T> =
StreamerCallbackArgs<'livechat-room', `${string}`> extends [infer A]
? A extends { type: T; data: unknown }
? A['data']
: A extends { type: T; status: unknown }
? A['status']
: A extends { type: T; visitor: unknown }
? A['visitor']
: never
: never;
export interface LivechatStream {
notifyVisitorActivity(rid: string, username: string, activities: string[]): Promise<unknown>;
notifyCallDeclined(rid: string): Promise<unknown>;
subscribeRoom(rid: string): Promise<any>;
onMessage(cb: (...args: StreamerCallbackArgs<'room-messages', string>) => void): () => void;
onUserActivity(cb: (...args: StreamerCallbackArgs<'notify-room', `${string}/user-activity`>) => void): () => void;
onRoomMessage(rid: string, cb: (...args: StreamerCallbackArgs<'room-messages', string>) => void): () => void;
onRoomUserActivity(rid: string, cb: (...args: StreamerCallbackArgs<'notify-room', `${string}/user-activity`>) => void): () => void;
onRoomDeleteMessage(rid: string, cb: (...args: StreamerCallbackArgs<'notify-room', `${string}/deleteMessage`>) => void): () => void;
onAgentChange(rid: string, cb: (args: LivechatRoomEvents<'agentData'>) => void): () => void;
onAgentStatusChange(rid: string, cb: (args: LivechatRoomEvents<'agentStatus'>) => void): () => void;
onQueuePositionChange(rid: string, cb: (args: LivechatRoomEvents<'queueData' | 'agentData'>) => void): () => void;
onVisitorChange(rid: string, cb: (data: LivechatRoomEvents<'visitorData'>) => void): () => void;
}
export interface LivechatEndpoints {
// GET
config(args: OperationParams<'GET', '/v1/livechat/config'>): Promise<Serialized<OperationResult<'GET', '/v1/livechat/config'>['config']>>;
room(args: OperationParams<'GET', '/v1/livechat/room'>): Promise<Serialized<OperationResult<'GET', '/v1/livechat/room'>>>;
visitor(
args: OperationParams<'GET', '/v1/livechat/visitor/:token'>,
): Promise<Serialized<OperationResult<'GET', '/v1/livechat/visitor/:token'>>>;
nextAgent(
args: OperationParams<'GET', '/v1/livechat/agent.next/:token'>,
): Promise<Serialized<OperationResult<'GET', '/v1/livechat/agent.next/:token'>>>;
agent(rid: string): Promise<Serialized<OperationResult<'GET', '/v1/livechat/agent.info/:rid/:token'>['agent']>>;
message(
id: string,
args: OperationParams<'GET', '/v1/livechat/message/:_id'>,
): Promise<Serialized<OperationResult<'GET', '/v1/livechat/message/:_id'>>>;
loadMessages(
rid: string,
args: OperationParams<'GET', '/v1/livechat/messages.history/:rid'>,
): Promise<Serialized<OperationResult<'GET', '/v1/livechat/messages.history/:rid'>['messages']>>;
// videoCall(args: OperationParams<'GET', '/v1/livechat/video.call/:token'>): Promise<void>;
// POST
transferChat(
args: OperationParams<'POST', '/v1/livechat/visitor/department.transfer'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/visitor/department.transfer'>>>;
grantVisitor(
guest: OperationParams<'POST', '/v1/livechat/visitor'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/visitor'>>>;
login(guest: OperationParams<'POST', '/v1/livechat/visitor'>): Promise<Serialized<OperationResult<'POST', '/v1/livechat/visitor'>>>;
closeChat(args: { rid: string }): Promise<Serialized<OperationResult<'POST', '/v1/livechat/room.close'>>>;
// shareScreen(args: OperationParams<'POST', '/v1/livechat/room.shareScreen'>): Promise<void>;
chatSurvey(
args: OperationParams<'POST', '/v1/livechat/room.survey'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/room.survey'>>>;
updateVisitorStatus(status: string): Promise<Serialized<OperationResult<'POST', '/v1/livechat/visitor.status'>['status']>>;
sendMessage(args: OperationParams<'POST', '/v1/livechat/message'>): Promise<Serialized<OperationResult<'POST', '/v1/livechat/message'>>>;
sendOfflineMessage(
args: OperationParams<'POST', '/v1/livechat/offline.message'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/offline.message'>>>;
sendVisitorNavigation(
args: OperationParams<'POST', '/v1/livechat/page.visited'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/page.visited'>>>;
requestTranscript(email: string, { rid }: { rid: string }): Promise<Serialized<OperationResult<'POST', '/v1/livechat/transcript'>>>;
sendCustomField(
params: OperationParams<'POST', '/v1/livechat/custom.field'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/custom.field'>>>;
sendCustomFields(
params: OperationParams<'POST', '/v1/livechat/custom.fields'>,
): Promise<Serialized<OperationResult<'POST', '/v1/livechat/custom.fields'>>>;
uploadFile(rid: string, file: File): Promise<ProgressEvent<EventTarget>>;
// DELETE
deleteMessage(id: string, { rid }: { rid: string }): Promise<Serialized<OperationResult<'DELETE', '/v1/livechat/message/:_id'>>>;
deleteVisitor(args: OperationParams<'DELETE', '/v1/livechat/visitor/:token'>): Promise<void>;
// PUT
editMessage(
id: string,
args: OperationParams<'PUT', '/v1/livechat/message/:_id'>,
): Promise<Serialized<OperationResult<'PUT', '/v1/livechat/message/:_id'>>>;
sendUiInteraction(
payload: OperationParams<'POST', '/apps/ui.interaction/:id'>,
appId: string,
): Promise<Serialized<OperationResult<'POST', '/apps/ui.interaction/:id'>>>;
}

View File

@@ -0,0 +1,157 @@
import { Emitter } from '@rocket.chat/emitter';
import type { ClientStream } from './ClientStream';
type User = {
id: string;
username?: string;
token?: string;
tokenExpires?: Date;
} & Record<string, unknown>;
type AccountEvents = {
uid: string | undefined;
user: User;
emailVerificationLink: string;
pageLoadLogin: unknown;
};
export interface Account extends Emitter<AccountEvents> {
uid?: string;
user?: User;
loginWithPassword(username: string, password: string): Promise<void>;
loginWithToken(token: string): Promise<{
id: string;
token: string;
tokenExpires: Date;
}>;
logout(): Promise<void>;
onLogin(fn: () => void): () => void;
onLogout(fn: () => void): () => void;
onEmailVerificationLink(fn: (token: string) => void): () => void;
onPageLoadLogin(fn: (loginAttempt: unknown) => void): () => void;
}
export class AccountImpl extends Emitter<AccountEvents> implements Account {
private _uid?: string;
user?: { id: string; username?: string; token?: string; tokenExpires?: Date };
get uid(): string | undefined {
return this._uid;
}
// Setter emits only on transition so onLogin/onLogout fire once per login/logout,
// not on every credential refresh. Direct writes from outside the SDK
// (adoptAccountFromMeteorLoginResult, teardownAuthenticatedConnection) flow through
// here and become the canonical login signal regardless of transport mode.
set uid(value: string | undefined) {
if (value === this._uid) return;
this._uid = value;
this.emit('uid', value);
}
constructor(private readonly client: ClientStream) {
super();
client.onCollection('users', (data) => {
if (data.collection !== 'users') {
return;
}
if (!('fields' in data) || !(data.fields && 'username' in data.fields)) {
return;
}
this.user = {
...this.user,
id: data.id,
username: data.fields.username,
};
this.emit('user', this.user);
});
}
private saveCredentials(id: string, token: string, tokenExpires: string) {
this.user = {
...this.user,
token,
tokenExpires: new Date(tokenExpires),
id,
};
this.uid = id;
this.emit('user', this.user);
}
async loginWithPassword(username: string, password: string): Promise<void> {
const {
id,
token: resultToken,
tokenExpires: { $date },
} = await this.client.callAsyncWithOptions(
'login',
{
wait: true,
},
{
user: { username },
password: { digest: password, algorithm: 'sha-256' },
},
);
this.saveCredentials(id, resultToken, $date);
}
async loginWithToken(token: string) {
const result = await this.client.callAsyncWithOptions(
'login',
{
wait: true,
},
{
resume: token,
},
);
const {
id,
token: resultToken,
tokenExpires: { $date },
} = result;
this.saveCredentials(id, resultToken, $date);
return result;
}
async logout(): Promise<void> {
await this.client.callAsyncWithOptions('logout', {
wait: true,
});
this.user = undefined;
this.uid = undefined;
}
onLogin(fn: () => void): () => void {
return this.on('uid', (uid) => {
if (uid !== undefined) fn();
});
}
onLogout(fn: () => void): () => void {
return this.on('uid', (uid) => {
if (uid === undefined) fn();
});
}
// emailVerificationLink and pageLoadLogin have no native source in the SDK — the actual
// events come from Meteor's accounts-base (URL hash routing for verification, pending
// login attempts for OAuth). The bridge in apps/meteor/client/lib/sdk/ddpSdk.ts forwards
// Meteor's events into this emitter; flag-OFF mode delegates directly via meteorBackedSdk.
onEmailVerificationLink(fn: (token: string) => void): () => void {
return this.on('emailVerificationLink', fn);
}
onPageLoadLogin(fn: (loginAttempt: unknown) => void): () => void {
return this.on('pageLoadLogin', fn);
}
}

View File

@@ -0,0 +1,126 @@
import type { Emitter } from '@rocket.chat/emitter';
import type { DDPDispatchOptions } from '../MinimalDDPClient';
import type { Subscription } from './Subscription';
import type { PublicationPayloads } from './publicationPayloads';
export interface ClientStream extends Emitter {
/**
* Calls a method on the server.
* @param method - The name of the method to be called.
* @param params - The parameters to be passed to the method.
* @param params.lastArgument - The last argument can be a callback function, the first argument of the callback function is the error, the second argument is the result.
*
* @returns {string} - The id of the method call.
*/
call(method: string, ...params: any[]): string;
/**
* Calls a method on the server. The same as `call` but with options.
* This options are passed to the dispatcher. The dispatcher is responsible for handling the method call.
* So depending on the dispatcher, the options may or may not be used. By default, the dispatcher only uses the `wait` option.
* One reason to use options is to make the call wait for the result to dispatch other calls.
* Mainly for authentication purposes.
*
* @param method - The name of the method to be called.
* @param options - The options to be passed to the method.
* @param options.wait - If true, the call will wait for the result to dispatch other calls.
* @param params - The parameters to be passed to the method.
* @param params.lastArgument - The last argument can be a callback function, the first argument of the callback function is the error, the second argument is the result.
* @returns {string} - The id of the method call.
**/
callWithOptions(method: string, options: DDPDispatchOptions, ...params: any[]): string;
/**
* Calls a method on the server. The same as `call` but returns a promise.
* @param method - The name of the method to be called.
* @param params - The parameters to be passed to the method.
* @returns {Promise} - A promise that resolves when the server returns the result.
* @example
* ```ts
* const result = await ddp.callAsync('login', {
* user: {
* username: 'my-username',
* password: 'my-password',
* },
* });
* ```
*/
callAsync(
method: string,
...params: any[]
): Promise<any> & {
id: string;
};
/**
* Calls a method on the server. The same as `callWithOptions` but returns a promise.
* @param method - The name of the method to be called.
* @param options - The options to be passed to the method.
* @param options.wait - If true, the call will wait for the result to dispatch other calls.
* @param params - The parameters to be passed to the method.
* @returns {Promise} - A promise that resolves when the server returns the result.
*/
callAsyncWithOptions(
method: string,
options: DDPDispatchOptions,
...params: any[]
): Promise<any> & {
id: string;
};
/**
* Subscribes to a publication on the server.
* @param name - The name of the publication to subscribe to.
* @param params - The parameters to be passed to the publication.
* @returns {Promise} - A promise that resolves when the server returns the subscription id.
*/
subscribe(name: string, ...params: any[]): Subscription;
/**
* Unsubscribes from a publication on the server.
* @param id - The id of the subscription to unsubscribe from.
* @returns {Promise} - A promise that resolves when the server unsubscribes the subscription.
*/
unsubscribe(id: string): Promise<any>;
/**
* Connects to the server.
* @returns {Promise} - A promise that resolves when the server connects.
* @example
* ```ts
* await ddp.connect();
* ```
*/
connect(): Promise<any>;
/**
* Fired when the server send any collection update.
* usually this used after subscribing to a publication.
* @param id - The id/name of the collection.
* @param callback - The callback function to be called when the server sends any collection update.
* @returns {Function} - A function to stop listening for collection updates.
* @example
* ```ts
* const stop = ddp.onCollection('users', (data) => {
* console.log(data);
* });
* ```
*/
onCollection(id: string, callback: (data: PublicationPayloads) => void): () => void;
/**
* The list of subscriptions.
* @type {Map<string, { id: string; status: 'ready' | 'loading'; name: string; params: any[]; }>}
* @example
* ```ts
* const subscription = ddp.subscribe('my-subscription');
* console.log(ddp.subscriptions.get(subscription.id));
* // prints:
* // {
* // id: 'my-subscription',
* // status: 'loading',
* // name: 'my-subscription',
* // params: [],
* // }
* ```
*/
subscriptions: Map<string, Subscription>;
}

View File

@@ -0,0 +1,128 @@
import type { IncomingPayload } from './IncomingPayload';
import type { RemoveListener } from './RemoveListener';
import type { ConnectedPayload, FailedPayload } from './connectionPayloads';
import type { MethodPayload, ResultPayload, UpdatedPayload } from './methodsPayloads';
import type { NosubPayload, PublicationPayloads, ServerPublicationPayloads } from './publicationPayloads';
export interface DDPDispatchOptions {
wait?: boolean;
}
/**
* A low-level DDP client that can be used to communicate with a DDP server.
*/
export interface DDPClient {
/**
* Invokes a remote method.
* @param method The name of the method to invoke.
* @param params The parameters to pass to the method.
* @param options The options to pass to the method.
* @returns A string representing the method call.
*/
call(method: string, params: any[], options?: DDPDispatchOptions): MethodPayload;
/**
* Subscribes to a queue of stream messages.
* @param name The name of the queue to subscribe to.
* @param params The parameters that the queue takes.
* @returns A string representing the subscription id.
*/
subscribe(name: string, params: any[]): string;
/**
* Subscribes to a queue of stream messages.
* @param id arbitrary id to use for the subscription
* @param name The name of the queue to subscribe to.
* @param params The parameters that the queue takes.
* @returns A string representing the subscription id.
*/
subscribeWithId(id: string, name: string, params: any[]): string;
/**
* Unsubscribes from a queue of stream messages.
* @param id The id of the subscription to unsubscribe from.
*/
unsubscribe(id: string): void;
/**
* Handles a message received from the server.
*/
handleMessage(serialized: string): void;
/**
* Registers a callback to be called every time a message is sent to the server.
*/
onDispatchMessage(callback: (msg: string) => void): RemoveListener;
/**
* Sends a connect message to the server.
*/
connect(): void;
/**
* Registers a callback to be called once after the subscription is ready or rejected.
* @param id The id of the subscription.
* @param callback The callback to be called.
* @returns A function to unregister the callback.
*/
onPublish(name: string, callback: (payload: ServerPublicationPayloads) => void): RemoveListener;
/**
* Registers a callback to be called once after the method is resolved or rejected.
* @param id The id of the method.
* @param callback The callback to be called.
* @returns A function to unregister the callback.
*/
onResult(id: string, callback: (payload: ResultPayload) => void): RemoveListener;
/**
* Registers a callback to be called every time the subscription is updated.
* @param id The id of the subscription.
* @param callback The callback to be called.
* @returns A function to unregister the callback.
*/
onUpdate(id: string, callback: (payload: UpdatedPayload) => void): RemoveListener;
/**
* Registers a callback to be called once after the subscription is stopped.
* @param id The id of the subscription.
* @param callback The callback to be called.
* @returns A function to unregister the callback.
*/
onNoSub(id: string, callback: (payload: NosubPayload) => void): RemoveListener;
/**
* Registers a callback to be called every time a new document is added/updated/removed from the collection.
* @param name The name of the collection.
* @param callback The callback to be called.
* @returns A function to unregister the callback.
*/
onCollection(name: string, callback: (payload: PublicationPayloads) => void): RemoveListener;
/**
* Registers a callback to be called once after the connection is established or rejected.
* @param callback The callback to be called.
* @returns A function to unregister the callback.
*/
onConnection(callback: (payload: ConnectedPayload | FailedPayload) => void): RemoveListener;
/**
* Registers a callback to be called every time a message is received.
* @param callback The callback to be called.
* @returns A function to unregister the callback.
*/
onMessage(callback: (payload: IncomingPayload) => void): RemoveListener;
/**
* Registers a callback to be called once after a message is received.
* @param callback The callback to be called.
* @returns A function to unregister the callback.
*/
onceMessage(callback: (payload: IncomingPayload) => void): RemoveListener;
/**
* Sends a ping message to the server.
* @returns nothing
*/
ping(id?: string): void;
}

View File

@@ -0,0 +1,10 @@
import type { ConnectedPayload, FailedPayload } from './connectionPayloads';
import type { PingPayload, PongPayload } from './heartbeatsPayloads';
import type { ServerMethodPayloads } from './methodsPayloads';
import type { ServerPublicationPayloads } from './publicationPayloads';
/**
* @category incoming
*/
export type IncomingPayload =
PingPayload | PongPayload | ServerPublicationPayloads | ServerMethodPayloads | ConnectedPayload | FailedPayload;

View File

@@ -0,0 +1,9 @@
import type { ConnectPayload } from './connectionPayloads';
import type { PingPayload, PongPayload } from './heartbeatsPayloads';
import type { MethodPayload } from './methodsPayloads';
import type { ClientPublicationPayloads } from './publicationPayloads';
/**
* @category outgoing
*/
export type OutgoingPayload = PingPayload | PongPayload | ConnectPayload | ClientPublicationPayloads | MethodPayload;

View File

@@ -0,0 +1 @@
export type RemoveListener = () => void;

View File

@@ -0,0 +1,33 @@
import type { RestClient } from '@rocket.chat/api-client';
import type { Connection } from '../Connection';
import type { TimeoutControl } from '../TimeoutControl';
import type { Account } from './Account';
import type { ClientStream } from './ClientStream';
/*
* The following procedure is used for streaming data:
* In the original Meteor DDP, Collections were used and publications and subscriptions were used to synchronize data between the client and server.
* However, controlling the `mergebox` can be expensive and doesn't scale well for many clients.
* To address this issue, we are using a specific part of the original implementation of the DDP protocol to send the data directly to the client without using the mergebox.
* This allows the client to receive more data directly from the server, even if the data is the same as before.
* To maintain compatibility with the original Meteor DDP, we use virtual collections.
* These collections are not real collections, but rather a way to send data to the client.
* They are named with the prefix stream- followed by the name of the stream.
* Instead of storing the data, they simply call the changed method.
* It's up to the application to handle the changed method and use the data it contains.
* In order for the server to function properly, it is important that it is aware of the 'agreement' and uses the same assumptions.
*/
export interface SDK {
stream(name: string, params: unknown[], cb: (...data: unknown[]) => void): ReturnType<ClientStream['subscribe']>;
connection: Connection;
account: Account;
client: ClientStream;
timeoutControl: TimeoutControl;
rest: RestClient;
}

View File

@@ -0,0 +1,12 @@
import type { ServerPublicationPayloads } from './publicationPayloads';
export interface Subscription {
stop: () => void;
ready: () => Promise<void>;
isReady: boolean;
onChange: (cb: (arg: ServerPublicationPayloads) => void) => void;
id: string;
params: any[];
name: string;
error?: unknown;
}

View File

@@ -0,0 +1,30 @@
/**
* @category outgoing
*/
export type ConnectPayload = {
msg: 'connect';
/** previous DDP session identifier (if trying to reconnect to an existing DDP session) */
session?: string;
/** the proposed protocol version */
version: string;
/** protocol versions supported by the client, in order of preference */
support: string[];
};
/**
* @category incoming
*/
export type ConnectedPayload = {
msg: 'connected';
/** an identifier for the DDP session */
session: string;
};
/**
* @category incoming
*/
export type FailedPayload = {
msg: 'failed';
/** a suggested protocol version to connect with */
version: string;
};

View File

@@ -0,0 +1,23 @@
/**
* @category incoming | outgoing
*
* At any time after the connection is established either side may send a ping message. The other side
* must respond with a pong message as soon as possible.
*/
export type PingPayload = {
msg: 'ping';
/** identifier used to correlate with response */
id?: string;
};
/**
* @category incoming | outgoing
*
* The pong message is sent in response to a ping message. If the ping message included an id field,
* the pong message must include the same id field.
*/
export type PongPayload = {
msg: 'pong';
/** identifier as received in the ping message */
id?: string;
};

View File

@@ -0,0 +1,10 @@
export interface ServerMethods {
resetPassword(token: string, password: string): { token: string };
checkRegistrationSecretURL(hash: string): boolean;
}
export type ServerMethodName = keyof ServerMethods;
export type ServerMethodParameters<MethodName extends ServerMethodName> = Parameters<ServerMethods[MethodName]>;
export type ServerMethodReturn<MethodName extends ServerMethodName> = Awaited<ReturnType<ServerMethods[MethodName]>>;

View File

@@ -0,0 +1,46 @@
/**
* @category outgoing
*/
export type MethodPayload = {
msg: 'method';
/** method name */
method: string;
/** EJSON parameters to the method */
params: any[];
/** an arbitrary client-determined identifier for this method call */
id: string;
/** an arbitrary client-determined seed for pseudo-random generators */
randomSeed?: Record<string, unknown>;
};
/**
* @category incoming
*/
export type ResultPayload = {
msg: 'result';
/** the id passed to `'method'` */
id: string;
/** an error thrown by the method (or method-not-found) */
error?: unknown;
/** the EJSON return value of the method, if any */
result?: any;
};
/**
* @category incoming
*/
export type UpdatedPayload = {
msg: 'updated';
/** array of method ids; all of whose writes have been reflected in data messages */
methods: string[];
};
/**
* @category outgoing
*/
export type ClientMethodPayloads = MethodPayload;
/**
* @category incoming
*/
export type ServerMethodPayloads = ResultPayload | UpdatedPayload;

View File

@@ -0,0 +1,126 @@
/**
* @category outgoing
*/
export type SubscribePayload = {
msg: 'sub';
/** an arbitrary client-determined identifier for this subscription */
id: string;
/** the name of the subscription */
name: string;
/** parameters to the subscription */
params?: any[];
};
/**
* @category outgoing
*/
export type UnsubscribePayload = {
msg: 'unsub';
/** the id passed to `'sub'` */
id: string;
};
/**
* @category outgoing
*/
export type ClientPublicationPayloads = SubscribePayload | UnsubscribePayload;
/**
* @category incoming
*/
export type NosubPayload = {
msg: 'nosub';
/** the id passed to `'sub'` */
id: string;
/** an error thrown by the subscription (or sub-not-found) */
error?: unknown;
};
/**
* @category incoming
*/
export type AddedPayload = {
msg: 'added';
/** the name of the collection */
collection: string;
/** the document ID */
id: string;
/** the document fields */
fields?: Record<string, any>;
};
/**
* @category incoming
*/
export type ChangedPayload = {
msg: 'changed';
/** the name of the collection */
collection: string;
/** the document ID */
id: string;
/** the document fields */
fields?: Record<string, any>;
/** the document fields to delete */
cleared?: string[];
};
/**
* @category incoming
*/
export type RemovedPayload = {
msg: 'removed';
/** the name of the collection */
collection: string;
/** the document ID */
id: string;
};
/**
* @category incoming
*/
export type ReadyPayload = {
msg: 'ready';
/** the ids passed to `'sub'` which have sent their initial batch of data */
subs: string[];
};
/**
* @category incoming
*/
export type AddedBeforePayload = {
msg: 'addedBefore';
/** the name of the collection */
collection: string;
/** the document ID */
id: string;
/** the document fields */
fields?: Record<string, any>;
/** the document ID to add the document before, or `null` to add at the end */
before: string | null;
};
// movedBefore (server -> client):
// collection: string
// id: string (the document ID)
// before: string or null (the document ID to move the document before, or null to move to the end)
/**
* @category incoming
*/
export type MovedBeforePayload = {
msg: 'movedBefore';
/** the name of the collection */
collection: string;
/** the document ID */
id: string;
/** the document ID to move the document before, or `null` to move to the end */
before: string | null;
};
/**
* @category incoming
*/
export type ServerPublicationPayloads =
NosubPayload | AddedPayload | ChangedPayload | RemovedPayload | ReadyPayload | AddedBeforePayload | MovedBeforePayload;
export type PublicationPayloads = AddedPayload | ChangedPayload | RemovedPayload;

View File

@@ -0,0 +1,499 @@
import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
import type { ISetting as AppsSetting } from '@rocket.chat/apps-engine/definition/settings';
import type {
IMessage,
IRoom,
ISetting,
ISubscription,
IRole,
IEmoji,
ICustomSound,
INotificationDesktop,
IUser,
IOmnichannelRoom,
VideoConference,
IOmnichannelCannedResponse,
IIntegrationHistory,
IUserDataEvent,
ICalendarNotification,
ILivechatInquiryRecord,
ILivechatAgent,
IImportProgress,
IBanner,
LicenseLimitKind,
ICustomUserStatus,
IWebdavAccount,
MessageAttachment,
ISession,
PresenceStatusCode,
} from '@rocket.chat/core-typings';
import type { ServerMediaSignal } from '@rocket.chat/media-signaling';
import type * as UiKit from '@rocket.chat/ui-kit';
type ClientAction = 'inserted' | 'updated' | 'removed' | 'changed';
export interface StreamerEvents {
'roles': [
{
key: 'roles';
args: [
IRole & {
type: 'inserted' | 'updated' | 'removed' | 'changed';
},
];
},
];
'notify-room': [
{ key: `${string}/user-activity`; args: [username: string, activities: string[]] },
{ key: `${string}/typing`; args: [username: string, typing: boolean] },
{
key: `${string}/deleteMessageBulk`;
args: [
args: {
rid: IMessage['rid'];
excludePinned: boolean;
ignoreDiscussion: boolean;
ts: Record<string, Date>;
users: string[];
ids?: string[]; // message ids have priority over ts
showDeletedStatus?: boolean;
} & (
| {
filesOnly: true;
replaceFileAttachmentsWith?: MessageAttachment;
}
| {
filesOnly?: false;
}
),
];
},
{ key: `${string}/deleteMessage`; args: [{ _id: IMessage['_id'] }] },
{ key: `${string}/e2e.keyRequest`; args: [unknown] },
{ key: `${string}/videoconf`; args: [id: string] },
{ key: `${string}/messagesRead`; args: [{ until: Date; tmid?: string }] },
{ key: `${string}/messagesImported`; args: [null] },
/* @deprecated over videoconf*/
// { key: `${string}/${string}`; args: [id: string] },
];
'room-messages': [{ key: '__my_messages__'; args: [IMessage] }, { key: string; args: [message: IMessage, user?: IUser, room?: IRoom] }];
'notify-all': [
{
key: 'public-info';
args: [
| [key: 'public-settings-changed', args: ['inserted' | 'updated' | 'removed' | 'changed', ISetting]]
| [key: 'deleteCustomSound', args: [{ soundData: ICustomSound }]]
| [key: 'updateCustomSound', args: [{ soundData: ICustomSound }]],
];
},
{ key: 'public-settings-changed'; args: ['inserted' | 'updated' | 'removed' | 'changed', ISetting] },
{ key: 'deleteCustomSound'; args: [{ soundData: ICustomSound }] },
{ key: 'updateCustomSound'; args: [{ soundData: ICustomSound }] },
{ key: 'license'; args: [{ preventedActions: Record<LicenseLimitKind, boolean> }] | [] },
];
'notify-user': [
{ key: `${string}/rooms-changed`; args: ['inserted' | 'updated' | 'removed' | 'changed', IRoom] },
{
key: `${string}/subscriptions-changed`;
args:
| [
'removed',
{
_id: string;
u?: Pick<IUser, '_id' | 'username' | 'name'>;
rid?: string;
t?: string;
},
]
| [
'inserted' | 'updated',
Pick<
ISubscription,
| 't'
| 'ts'
| 'ls'
| 'lr'
| 'name'
| 'fname'
| 'rid'
| 'code'
| 'f'
| 'u'
| 'open'
| 'alert'
| 'roles'
| 'unread'
| 'prid'
| 'userMentions'
| 'groupMentions'
| 'archived'
| 'audioNotificationValue'
| 'desktopNotifications'
| 'mobilePushNotifications'
| 'emailNotifications'
| 'desktopPrefOrigin'
| 'mobilePrefOrigin'
| 'emailPrefOrigin'
| 'unreadAlert'
| '_updatedAt'
| 'blocked'
| 'blocker'
| 'autoTranslate'
| 'autoTranslateLanguage'
| 'disableNotifications'
| 'hideUnreadStatus'
| 'hideMentionStatus'
| 'muteGroupMentions'
| 'ignored'
| 'E2EKey'
| 'E2ESuggestedKey'
| 'oldRoomKeys'
| 'tunread'
| 'tunreadGroup'
| 'tunreadUser'
// Omnichannel fields
| 'department'
| 'v'
| 'onHold'
>,
];
},
{ key: `${string}/message`; args: [IMessage] },
{ key: `${string}/force_logout`; args: [ISession['sessionId'] | undefined] },
{
key: `${string}/webdav`;
args: [{ type: 'changed'; account: Partial<IWebdavAccount> } | { type: 'removed'; account: { _id: IWebdavAccount['_id'] } }];
},
{ key: `${string}/e2ekeyRequest`; args: [string, string] },
{ key: `${string}/notification`; args: [INotificationDesktop] },
{ key: `${string}/call.hangup`; args: [{ roomId: string }] },
{ key: `${string}/uiInteraction`; args: [UiKit.ServerInteraction] },
{
key: `${string}/video-conference`;
args: [{ action: string; params: { callId: VideoConference['_id']; uid: IUser['_id']; rid: IRoom['_id'] } }];
},
{ key: `${string}/media-signal`; args: [ServerMediaSignal] },
{ key: `${string}/userData`; args: [IUserDataEvent] },
{ key: `${string}/updateInvites`; args: [unknown] },
{ key: `${string}/departmentAgentData`; args: [unknown] },
{ key: `${string}/calendar`; args: [ICalendarNotification] },
{ key: `${string}/banners`; args: [IBanner] },
];
'importers': [
{
key: 'progress';
args: [{ rate: number } | IImportProgress];
},
];
'notify-logged': [
{
key: 'updateCustomUserStatus';
args: [
{
userStatusData: Omit<ICustomUserStatus, '_updatedAt'>;
},
];
},
{ key: 'permissions-changed'; args: ['inserted' | 'updated' | 'removed' | 'changed', ISetting] },
{ key: 'deleteEmojiCustom'; args: [{ emojiData: IEmoji }] },
{ key: 'updateEmojiCustom'; args: [{ emojiData: IEmoji }] },
/* @deprecated */
{ key: 'new-banner'; args: [{ bannerId: string }] },
{ key: 'banner-changed'; args: [{ bannerId: string }] },
{
key: 'roles-change';
args: [
{
type: 'added' | 'removed' | 'changed';
_id: IRole['_id'];
u?: { _id: IUser['_id']; username: IUser['username']; name?: IUser['name'] };
scope?: string;
},
];
},
{ key: 'Users:NameChanged'; args: [Pick<IUser, '_id' | 'name' | 'username'>] },
{ key: 'private-settings-changed'; args: ['inserted' | 'updated' | 'removed' | 'changed', ISetting] },
{ key: 'deleteCustomUserStatus'; args: [{ userStatusData: Omit<ICustomUserStatus, '_updatedAt'> }] },
{
key: 'user-status';
args: [
[
uid: IUser['_id'],
username: IUser['username'],
status: PresenceStatusCode,
statusText: IUser['statusText'],
name: IUser['name'],
roles: IUser['roles'],
statusSource?: IUser['statusSource'],
statusExpiresAt?: IUser['statusExpiresAt'],
],
];
},
{
key: 'Users:Deleted';
args: [
| {
userId: IUser['_id'];
messageErasureType: 'Delete';
replaceByUser?: never;
}
| {
userId: IUser['_id'];
messageErasureType: 'Unlink';
replaceByUser?: { _id: IUser['_id']; username: IUser['username']; alias: string };
},
];
},
{
key: 'updateAvatar';
args: [{ username: IUser['username']; etag: IUser['avatarETag'] } | { rid: IRoom['_id']; etag: IRoom['avatarETag'] }];
},
{ key: 'omnichannel.priority-changed'; args: [{ id: string; clientAction: ClientAction; name?: string }] },
];
'room-data': [{ key: string; args: [IOmnichannelRoom | Pick<IOmnichannelRoom, '_id'>] }];
'notify-room-users': [
{
key: `${string}/video-conference`;
args: [{ action: string; params: { callId: VideoConference['_id']; uid: IUser['_id']; rid: IRoom['_id'] } }];
},
{ key: `${string}/userData`; args: unknown[] },
];
'livechat-room': [
{
key: string;
args: [
| {
type: 'agentStatus';
status: string;
}
| {
type: 'queueData';
data:
| {
[k: string]: unknown;
}
| undefined;
}
| {
type: 'agentData';
data: ILivechatAgent | undefined | { hiddenInfo: boolean };
}
| {
type: 'visitorData';
visitor: unknown;
},
];
},
];
'user-presence': [
{
key: string;
args: [
[
username: string,
statusChanged?: PresenceStatusCode,
statusText?: string,
statusSource?: IUser['statusSource'],
statusExpiresAt?: IUser['statusExpiresAt'],
],
];
},
];
// TODO: rename to 'integration-history'
'integrationHistory': [
{
key: string;
args: [
| { type: 'removed'; id: string }
| {
id: string;
diff: unknown;
type: 'updated';
}
| {
type: 'inserted';
data: Partial<IIntegrationHistory>;
},
];
},
];
'canned-responses': [
{
key: 'canned-responses';
args:
| [{ type: 'removed'; _id: string }, { agentsId: string }]
| [{ type: 'removed'; _id: string }]
| [
{ type: 'changed' } & Omit<IOmnichannelCannedResponse, '_updatedAt' | '_createdAt'> & {
_createdAt?: Date | undefined;
},
]
| [{ type: 'changed' } & IOmnichannelCannedResponse, { agentsId: string }];
},
];
'livechat-inquiry-queue-observer': [
{
key: 'public';
args: [
{
type: 'added' | 'removed' | 'changed';
} & ILivechatInquiryRecord,
];
},
{
key: `department/${string}`;
args: [
{
type: 'added' | 'removed' | 'changed';
} & ILivechatInquiryRecord,
];
},
{
key: `agent/${string}`;
args: [
{
type: 'added' | 'removed' | 'changed';
} & ILivechatInquiryRecord,
];
},
{
key: `${string}`;
args: [
{
_id: string;
clientAction: string;
},
];
},
];
'apps': [
{ key: 'app/added'; args: [string] },
{ key: 'app/removed'; args: [string] },
{ key: 'app/updated'; args: [string] },
{
key: 'app/statusUpdate';
args: [
{
appId: string;
status: AppStatus;
},
];
},
{
key: 'app/settingUpdated';
args: [
{
appId: string;
setting: AppsSetting;
},
];
},
{ key: 'command/added'; args: [string] },
{ key: 'command/disabled'; args: [string] },
{ key: 'command/updated'; args: [string] },
{ key: 'command/removed'; args: [string] },
{ key: 'actions/changed'; args: [] },
{
key: 'apps';
args: [
| [key: 'app/added', args: [string]]
| [key: 'app/removed', args: [string]]
| [key: 'app/updated', args: [string]]
| [
key: 'app/statusUpdate',
args: [
{
appId: string;
status: AppStatus;
},
],
]
| [
key: 'app/settingUpdated',
args: [
{
appId: string;
setting: AppsSetting;
},
],
]
| [key: 'command/added', args: [string]]
| [key: 'command/disabled', args: [string]]
| [key: 'command/updated', args: [string]]
| [key: 'command/removed', args: [string]]
| [key: 'actions/changed', args: []],
];
},
];
'apps-engine': [
{ key: 'app/added'; args: [string] },
{ key: 'app/removed'; args: [string] },
{ key: 'app/updated'; args: [string] },
{
key: 'app/statusUpdate';
args: [
{
appId: string;
status: AppStatus;
},
];
},
{
key: 'app/settingUpdated';
args: [
{
appId: string;
setting: AppsSetting;
},
];
},
{ key: 'command/added'; args: [string] },
{ key: 'command/disabled'; args: [string] },
{ key: 'command/updated'; args: [string] },
{ key: 'command/removed'; args: [string] },
{ key: 'actions/changed'; args: [] },
];
'local': [
{
key: 'broadcast';
args: any[];
},
];
}
export type StreamNames = keyof StreamerEvents;
export type StreamKeys<S extends StreamNames> = StreamerEvents[S][number]['key'];
export type StreamerConfigs<N extends StreamNames> = StreamerEvents[N][number];
export type StreamerConfig<N extends StreamNames, K extends StreamKeys<N>> =
StreamerConfigs<N> extends infer U ? (U extends any ? ({ key: K; args: any } extends U ? U : never) : never) : never;
export type StreamerCallbackArgs<N extends StreamNames, K extends StreamKeys<N>> =
StreamerConfig<N, K> extends {
args: any;
}
? StreamerConfig<N, K>['args']
: never;
export type StreamerCallback<N extends StreamNames, K extends StreamKeys<N>> = (...args: StreamerCallbackArgs<N, K>) => void;

View File

@@ -0,0 +1,25 @@
import type { Emitter } from '@rocket.chat/emitter';
/**
* This function takes an event emitter (an object that emits events) and an event name
* as arguments, and returns a `Promise` that is resolved when the event is emitted, or
* is rejected if the event object has an `error` property.
* @param emitter The event emitter
* @param event The event name
* @returns A `Promise` that is resolved when the event is emitted, or is rejected if the event object has an `error` property
*/
export function wrapOnceEventIntoPromise<T extends { error?: string }>(emitter: Emitter, event: string) {
// Create a new Promise and set up its resolve and reject callbacks
return new Promise<T>((resolve, reject) => {
// When the event is emitted, call the appropriate callback
emitter.once(event, (data: T) => {
// If the event object has an error property, reject
if (data.error) {
return reject(data.error);
}
// Otherwise, resolve with the event object
resolve(data);
});
});
}