snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
This commit is contained in:
36
apps/meteor/server/modules/core-apps/banner.module.ts
Normal file
36
apps/meteor/server/modules/core-apps/banner.module.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Banner } from '@rocket.chat/core-services';
|
||||
import type { IUiKitCoreApp, UiKitCoreAppViewClosedPayload } from '@rocket.chat/core-services';
|
||||
import type * as UiKit from '@rocket.chat/ui-kit';
|
||||
|
||||
export class BannerModule implements IUiKitCoreApp {
|
||||
appId = 'banner-core';
|
||||
|
||||
// when banner view is closed we need to dismiss that banner for that user
|
||||
async viewClosed(payload: UiKitCoreAppViewClosedPayload): Promise<UiKit.ServerInteraction> {
|
||||
const {
|
||||
payload: { view: { viewId: bannerId } = {} },
|
||||
user: { _id: userId } = {},
|
||||
} = payload;
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('invalid user');
|
||||
}
|
||||
|
||||
if (!bannerId) {
|
||||
throw new Error('invalid banner');
|
||||
}
|
||||
|
||||
if (!payload.triggerId) {
|
||||
throw new Error('invalid triggerId');
|
||||
}
|
||||
|
||||
await Banner.dismiss(userId, bannerId);
|
||||
|
||||
return {
|
||||
type: 'banner.close',
|
||||
triggerId: payload.triggerId,
|
||||
appId: payload.appId,
|
||||
viewId: bannerId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { Banner } from '@rocket.chat/core-services';
|
||||
import type {
|
||||
IUiKitCoreApp,
|
||||
UiKitCoreAppBlockActionPayload,
|
||||
UiKitCoreAppViewClosedPayload,
|
||||
UiKitCoreAppViewSubmitPayload,
|
||||
} from '@rocket.chat/core-services';
|
||||
import type { Cloud, IUser } from '@rocket.chat/core-typings';
|
||||
import { Banners } from '@rocket.chat/models';
|
||||
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
|
||||
import { isTruthy } from '@rocket.chat/tools';
|
||||
import type * as UiKit from '@rocket.chat/ui-kit';
|
||||
|
||||
import { CloudWorkspaceConnectionError } from '../../../lib/errors/CloudWorkspaceConnectionError';
|
||||
import { InvalidCloudAnnouncementInteractionError } from '../../../lib/errors/InvalidCloudAnnouncementInteractionError';
|
||||
import { InvalidCoreAppInteractionError } from '../../../lib/errors/InvalidCoreAppInteractionError';
|
||||
import { getWorkspaceAccessToken } from '../../lib/cloud';
|
||||
import { assertNotOfflineLicense } from '../../lib/cloud/offlineLicense';
|
||||
import { syncWorkspace } from '../../lib/cloud/syncWorkspace';
|
||||
import { SystemLogger } from '../../lib/logger/system';
|
||||
import { settings } from '../../settings';
|
||||
|
||||
type CloudAnnouncementInteractant =
|
||||
| {
|
||||
user: Pick<IUser, '_id' | 'username' | 'name'>;
|
||||
}
|
||||
| {
|
||||
visitor: Pick<NonNullable<UiKitCoreAppBlockActionPayload['visitor']>, 'id' | 'username' | 'name' | 'department' | 'phone'>;
|
||||
};
|
||||
|
||||
type CloudAnnouncementInteractionRequest = UiKit.UserInteraction & CloudAnnouncementInteractant;
|
||||
|
||||
export class CloudAnnouncementsModule implements IUiKitCoreApp {
|
||||
appId = 'cloud-announcements-core';
|
||||
|
||||
protected async getWorkspaceAccessToken() {
|
||||
return getWorkspaceAccessToken(true);
|
||||
}
|
||||
|
||||
protected getCloudUrl() {
|
||||
return settings.get('Cloud_Url');
|
||||
}
|
||||
|
||||
blockAction(payload: UiKitCoreAppBlockActionPayload): Promise<UiKit.ServerInteraction | undefined> {
|
||||
return this.handlePayload(payload);
|
||||
}
|
||||
|
||||
viewSubmit(payload: UiKitCoreAppViewSubmitPayload): Promise<UiKit.ServerInteraction | undefined> {
|
||||
return this.handlePayload(payload);
|
||||
}
|
||||
|
||||
async viewClosed(payload: UiKitCoreAppViewClosedPayload): Promise<UiKit.ServerInteraction | undefined> {
|
||||
const {
|
||||
payload: { view: { viewId, id } = {} },
|
||||
user: { _id: userId } = {},
|
||||
} = payload;
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('invalid user');
|
||||
}
|
||||
|
||||
if (!id && !viewId) {
|
||||
throw new Error('invalid view');
|
||||
}
|
||||
|
||||
if (!payload.triggerId) {
|
||||
throw new Error('invalid triggerId');
|
||||
}
|
||||
|
||||
// For backwards compatibility: we prefer to use viewId, but some legacy banners
|
||||
// may only have id. We fetch all matching banners and prioritize viewId match.
|
||||
const bannerIds = [viewId, id].filter((bannerId) => isTruthy(bannerId));
|
||||
const banners = await Banners.findByIds(bannerIds).toArray();
|
||||
const announcement = banners.find((b) => b._id === viewId) || banners.find((b) => b._id === id);
|
||||
if (!announcement) {
|
||||
throw new Error('Banner not found');
|
||||
}
|
||||
|
||||
await Banner.dismiss(userId, announcement._id);
|
||||
|
||||
const type = announcement.surface === 'banner' ? 'banner.close' : 'modal.close';
|
||||
|
||||
// for viewClosed we just need to let Cloud know that the banner was closed, no need to wait for the response
|
||||
|
||||
void this.handlePayload(payload);
|
||||
|
||||
return {
|
||||
type,
|
||||
triggerId: payload.triggerId,
|
||||
appId: payload.appId,
|
||||
viewId: announcement._id,
|
||||
};
|
||||
}
|
||||
|
||||
protected async handlePayload(
|
||||
payload: UiKitCoreAppBlockActionPayload | UiKitCoreAppViewSubmitPayload | UiKitCoreAppViewClosedPayload,
|
||||
): Promise<UiKit.ServerInteraction | undefined> {
|
||||
const interactant = this.getInteractant(payload);
|
||||
const interaction = this.getInteraction(payload);
|
||||
|
||||
try {
|
||||
const serverInteraction = await this.pushUserInteraction(interactant, interaction);
|
||||
|
||||
if (serverInteraction.appId !== this.appId) {
|
||||
throw new InvalidCloudAnnouncementInteractionError(`Invalid appId`);
|
||||
}
|
||||
|
||||
if (serverInteraction.triggerId !== interaction.triggerId) {
|
||||
throw new InvalidCloudAnnouncementInteractionError(`Invalid triggerId`);
|
||||
}
|
||||
|
||||
return serverInteraction;
|
||||
} catch (err) {
|
||||
SystemLogger.error({ err });
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
protected getInteractant(
|
||||
payload: UiKitCoreAppBlockActionPayload | UiKitCoreAppViewSubmitPayload | UiKitCoreAppViewClosedPayload,
|
||||
): CloudAnnouncementInteractant {
|
||||
if (payload.user) {
|
||||
return {
|
||||
user: {
|
||||
_id: payload.user._id,
|
||||
username: payload.user.username,
|
||||
name: payload.user.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if ('visitor' in payload && payload.visitor) {
|
||||
return {
|
||||
visitor: {
|
||||
id: payload.visitor.id,
|
||||
username: payload.visitor.username,
|
||||
name: payload.visitor.name,
|
||||
department: payload.visitor.department,
|
||||
phone: payload.visitor.phone,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new CloudWorkspaceConnectionError(`Invalid user data received from Rocket.Chat Cloud`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the payload received from the Core App back to the format the UI sends from the client
|
||||
*/
|
||||
protected getInteraction(
|
||||
payload: UiKitCoreAppBlockActionPayload | UiKitCoreAppViewSubmitPayload | UiKitCoreAppViewClosedPayload,
|
||||
): UiKit.UserInteraction {
|
||||
if (payload.type === 'blockAction' && payload.container?.type === 'message') {
|
||||
const {
|
||||
actionId,
|
||||
payload: { blockId, value },
|
||||
message,
|
||||
room,
|
||||
triggerId,
|
||||
} = payload;
|
||||
|
||||
if (!actionId || !blockId || !triggerId) {
|
||||
throw new InvalidCoreAppInteractionError();
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'blockAction',
|
||||
actionId,
|
||||
payload: {
|
||||
blockId,
|
||||
value,
|
||||
},
|
||||
container: {
|
||||
type: 'message',
|
||||
id: String(message),
|
||||
},
|
||||
mid: String(message),
|
||||
tmid: undefined,
|
||||
rid: String(room),
|
||||
triggerId,
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.type === 'blockAction' && payload.container?.type === 'view') {
|
||||
const {
|
||||
actionId,
|
||||
payload: { blockId, value },
|
||||
container: { id },
|
||||
triggerId,
|
||||
} = payload;
|
||||
|
||||
if (!actionId || !blockId || !triggerId) {
|
||||
throw new InvalidCoreAppInteractionError();
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'blockAction',
|
||||
actionId,
|
||||
payload: {
|
||||
blockId,
|
||||
value,
|
||||
},
|
||||
container: {
|
||||
type: 'view',
|
||||
id,
|
||||
},
|
||||
triggerId,
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.type === 'viewClosed') {
|
||||
const {
|
||||
payload: { view, isCleared },
|
||||
triggerId,
|
||||
} = payload;
|
||||
|
||||
if (!view?.id || !triggerId) {
|
||||
throw new InvalidCoreAppInteractionError();
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'viewClosed',
|
||||
payload: {
|
||||
viewId: view.id,
|
||||
view: view as any,
|
||||
isCleared: Boolean(isCleared),
|
||||
},
|
||||
triggerId,
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.type === 'viewSubmit') {
|
||||
const {
|
||||
payload: { view },
|
||||
triggerId,
|
||||
} = payload;
|
||||
|
||||
if (!view?.id || !triggerId) {
|
||||
throw new InvalidCoreAppInteractionError();
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'viewSubmit',
|
||||
payload: {
|
||||
view: view as any,
|
||||
},
|
||||
triggerId,
|
||||
viewId: view.id,
|
||||
};
|
||||
}
|
||||
|
||||
throw new InvalidCoreAppInteractionError();
|
||||
}
|
||||
|
||||
protected async pushUserInteraction(
|
||||
interactant: CloudAnnouncementInteractant,
|
||||
userInteraction: UiKit.UserInteraction,
|
||||
): Promise<UiKit.ServerInteraction> {
|
||||
assertNotOfflineLicense();
|
||||
|
||||
const token = await this.getWorkspaceAccessToken();
|
||||
|
||||
const request: CloudAnnouncementInteractionRequest = {
|
||||
...interactant,
|
||||
...userInteraction,
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.getCloudUrl()}/api/v3/comms/workspace/interaction`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
ignoreSsrfValidation: true,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const { error } = await response.json();
|
||||
throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${error}`);
|
||||
}
|
||||
|
||||
const payload: Cloud.WorkspaceInteractionResponsePayload = await response.json();
|
||||
|
||||
const { serverInteraction, serverAction } = payload;
|
||||
|
||||
if (serverAction) {
|
||||
switch (serverAction) {
|
||||
case 'syncWorkspace': {
|
||||
await syncWorkspace();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return serverInteraction;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { UiKitCoreAppViewClosedPayload } from '@rocket.chat/core-services';
|
||||
import type * as UiKit from '@rocket.chat/ui-kit';
|
||||
|
||||
import { CloudAnnouncementsModule } from './cloudAnnouncements.module';
|
||||
|
||||
export class CloudSubscriptionCommunication extends CloudAnnouncementsModule {
|
||||
override appId = 'cloud-communication-core';
|
||||
|
||||
override async viewClosed(payload: UiKitCoreAppViewClosedPayload): Promise<UiKit.ServerInteraction> {
|
||||
const {
|
||||
payload: { view: { viewId } = {} },
|
||||
user: { _id: userId } = {},
|
||||
} = payload;
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('invalid user');
|
||||
}
|
||||
|
||||
if (!viewId) {
|
||||
throw new Error('invalid view');
|
||||
}
|
||||
|
||||
if (!payload.triggerId) {
|
||||
throw new Error('invalid triggerId');
|
||||
}
|
||||
|
||||
// for viewClosed we just need to let Cloud know that the banner was closed, no need to wait for the response
|
||||
|
||||
void this.handlePayload(payload);
|
||||
|
||||
return {
|
||||
type: 'modal.close',
|
||||
triggerId: payload.triggerId,
|
||||
appId: payload.appId,
|
||||
};
|
||||
}
|
||||
}
|
||||
118
apps/meteor/server/modules/core-apps/mention.module.ts
Normal file
118
apps/meteor/server/modules/core-apps/mention.module.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { api } from '@rocket.chat/core-services';
|
||||
import type { IUiKitCoreApp, UiKitCoreAppBlockActionPayload } from '@rocket.chat/core-services';
|
||||
import type { IMessage, IUser } from '@rocket.chat/core-typings';
|
||||
import { Subscriptions, Messages } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { i18n } from '../../lib/i18n';
|
||||
import { processWebhookMessage } from '../../lib/messages/processWebhookMessage';
|
||||
import { roomCoordinator } from '../../lib/rooms/roomCoordinator';
|
||||
import { addUsersToRoomMethod } from '../../meteor-methods/rooms/addUsersToRoom';
|
||||
|
||||
const retrieveMentionsFromPayload = (stringifiedMentions: string): Exclude<IMessage['mentions'], undefined> => {
|
||||
try {
|
||||
const mentions = JSON.parse(stringifiedMentions);
|
||||
if (!Array.isArray(mentions) || !mentions.length || !('username' in mentions[0])) {
|
||||
throw new Error('Invalid payload');
|
||||
}
|
||||
return mentions;
|
||||
} catch (error) {
|
||||
throw new Error('Invalid payload');
|
||||
}
|
||||
};
|
||||
|
||||
export class MentionModule implements IUiKitCoreApp {
|
||||
appId = 'mention-core';
|
||||
|
||||
async blockAction(payload: UiKitCoreAppBlockActionPayload): Promise<undefined> {
|
||||
const {
|
||||
actionId,
|
||||
payload: { value: stringifiedMentions, blockId: referenceMessageId },
|
||||
} = payload;
|
||||
|
||||
const user = payload.user!;
|
||||
const room = payload.room!;
|
||||
|
||||
const mentions = retrieveMentionsFromPayload(stringifiedMentions as string);
|
||||
|
||||
const usernames = mentions.map(({ username }) => username);
|
||||
|
||||
const message = await Messages.findOneById(referenceMessageId, { projection: { _id: 1, tmid: 1 } });
|
||||
|
||||
if (!message) {
|
||||
throw new Error('Mention bot - Failed to retrieve message information');
|
||||
}
|
||||
|
||||
const joinedUsernames = `@${usernames.join(', @')}`;
|
||||
|
||||
if (actionId === 'dismiss') {
|
||||
void api.broadcast('notify.ephemeralMessage', user._id, room, {
|
||||
msg: i18n.t('You_mentioned___mentions__but_theyre_not_in_this_room', {
|
||||
mentions: joinedUsernames,
|
||||
lng: user.language,
|
||||
}),
|
||||
_id: payload.message,
|
||||
tmid: message.tmid,
|
||||
mentions,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (actionId === 'add-users') {
|
||||
void addUsersToRoomMethod(user._id, { rid: room, users: usernames as string[] }, user);
|
||||
void api.broadcast('notify.ephemeralMessage', user._id, room, {
|
||||
msg: i18n.t('You_mentioned___mentions__but_theyre_not_in_this_room', {
|
||||
mentions: joinedUsernames,
|
||||
lng: user.language,
|
||||
}),
|
||||
tmid: message.tmid,
|
||||
_id: payload.message,
|
||||
mentions,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (actionId === 'share-message') {
|
||||
const sub = await Subscriptions.findOneByRoomIdAndUserId(room, user._id, { projection: { t: 1, rid: 1, name: 1 } });
|
||||
// this should exist since the event is fired from withing the room (e.g the user sent a message)
|
||||
if (!sub) {
|
||||
throw new Error('Mention bot - Failed to retrieve room information');
|
||||
}
|
||||
|
||||
const roomPath = roomCoordinator.getRouteLink(sub.t, { rid: sub.rid, name: sub.name });
|
||||
if (!roomPath) {
|
||||
throw new Error('Mention bot - Failed to retrieve path to room');
|
||||
}
|
||||
|
||||
const messageText = i18n.t('Youre_not_a_part_of__channel__and_I_mentioned_you_there', {
|
||||
channel: `#${sub.name}`,
|
||||
lng: user.language,
|
||||
});
|
||||
|
||||
const link = new URL(Meteor.absoluteUrl(roomPath));
|
||||
link.searchParams.set('msg', message._id);
|
||||
const text = `[ ](${link.toString()})\n${messageText}`;
|
||||
|
||||
// forwards message to all DMs
|
||||
await processWebhookMessage(
|
||||
{
|
||||
roomId: mentions.map(({ _id }) => _id),
|
||||
text,
|
||||
separateResponse: true, // so that messages are sent to other DMs even if one or more fails
|
||||
},
|
||||
user as IUser & { username: string },
|
||||
);
|
||||
|
||||
void api.broadcast('notify.ephemeralMessage', user._id, room, {
|
||||
msg: i18n.t('You_mentioned___mentions__but_theyre_not_in_this_room_You_let_them_know_via_dm', {
|
||||
mentions: joinedUsernames,
|
||||
lng: user.language,
|
||||
}),
|
||||
tmid: message.tmid,
|
||||
_id: payload.message,
|
||||
mentions,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
70
apps/meteor/server/modules/core-apps/nps.module.ts
Normal file
70
apps/meteor/server/modules/core-apps/nps.module.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { IUiKitCoreApp, UiKitCoreAppBlockActionPayload, UiKitCoreAppViewSubmitPayload } from '@rocket.chat/core-services';
|
||||
import { Banner, NPS } from '@rocket.chat/core-services';
|
||||
import type * as UiKit from '@rocket.chat/ui-kit';
|
||||
|
||||
import { createModal } from './nps/createModal';
|
||||
|
||||
export class Nps implements IUiKitCoreApp {
|
||||
appId = 'nps-core';
|
||||
|
||||
async blockAction(payload: UiKitCoreAppBlockActionPayload): Promise<UiKit.ServerInteraction> {
|
||||
const {
|
||||
triggerId,
|
||||
actionId,
|
||||
container: { id: viewId } = {},
|
||||
payload: { value: score, blockId: npsId },
|
||||
user,
|
||||
} = payload;
|
||||
|
||||
if (!viewId || !triggerId || !user || !npsId) {
|
||||
throw new Error('Invalid payload');
|
||||
}
|
||||
|
||||
const bannerId = viewId.replace(`${npsId}-`, '');
|
||||
|
||||
return createModal({
|
||||
type: actionId === 'nps-score' ? 'modal.update' : 'modal.open',
|
||||
id: `${npsId}-${bannerId}`,
|
||||
appId: this.appId,
|
||||
npsId,
|
||||
triggerId,
|
||||
score: String(score),
|
||||
user,
|
||||
});
|
||||
}
|
||||
|
||||
async viewSubmit(payload: UiKitCoreAppViewSubmitPayload): Promise<undefined> {
|
||||
if (!payload.payload?.view?.state || !payload.payload?.view?.id) {
|
||||
throw new Error('Invalid payload');
|
||||
}
|
||||
|
||||
const {
|
||||
payload: {
|
||||
view: { state, id: viewId },
|
||||
},
|
||||
user: { _id: userId, roles } = {},
|
||||
} = payload;
|
||||
|
||||
const [npsId] = Object.keys(state);
|
||||
|
||||
const bannerId = viewId.replace(`${npsId}-`, '');
|
||||
|
||||
const {
|
||||
[npsId]: { 'nps-score': score, comment },
|
||||
} = state;
|
||||
|
||||
await NPS.vote({
|
||||
npsId,
|
||||
userId,
|
||||
comment: String(comment),
|
||||
roles,
|
||||
score: Number(score),
|
||||
});
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('invalid user');
|
||||
}
|
||||
|
||||
await Banner.dismiss(userId, bannerId);
|
||||
}
|
||||
}
|
||||
89
apps/meteor/server/modules/core-apps/nps/createModal.ts
Normal file
89
apps/meteor/server/modules/core-apps/nps/createModal.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
|
||||
import { i18n } from '../../../lib/i18n';
|
||||
import { settings } from '../../../settings';
|
||||
|
||||
type ModalParams = {
|
||||
id: string;
|
||||
type: string;
|
||||
appId: string;
|
||||
npsId: string;
|
||||
triggerId: string;
|
||||
score: string;
|
||||
user: IUser;
|
||||
};
|
||||
|
||||
export const createModal = ({ type = 'modal.open', id, appId, npsId, triggerId, score, user }: ModalParams): any => {
|
||||
const language = user.language || settings.get('Language') || 'en';
|
||||
|
||||
return {
|
||||
type,
|
||||
triggerId,
|
||||
appId,
|
||||
view: {
|
||||
appId,
|
||||
type: 'modal',
|
||||
id,
|
||||
title: {
|
||||
type: 'plain_text',
|
||||
text: i18n.t('We_appreciate_your_feedback', { lng: language }),
|
||||
emoji: false,
|
||||
},
|
||||
submit: {
|
||||
type: 'button',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: i18n.t('Send', { lng: language }),
|
||||
emoji: false,
|
||||
},
|
||||
actionId: 'send-vote',
|
||||
},
|
||||
close: {
|
||||
type: 'button',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: i18n.t('Cancel', { lng: language }),
|
||||
emoji: false,
|
||||
},
|
||||
actionId: 'cancel',
|
||||
},
|
||||
blocks: [
|
||||
{
|
||||
blockId: npsId,
|
||||
type: 'actions',
|
||||
elements: [
|
||||
{
|
||||
type: 'linear_scale',
|
||||
initialValue: score,
|
||||
actionId: 'nps-score',
|
||||
preLabel: { type: 'plain_text', text: i18n.t('Not_likely', { lng: language }) },
|
||||
postLabel: {
|
||||
type: 'plain_text',
|
||||
text: i18n.t('Extremely_likely', { lng: language }),
|
||||
},
|
||||
},
|
||||
],
|
||||
label: {
|
||||
type: 'plain_text',
|
||||
text: i18n.t('Score', { lng: language }),
|
||||
emoji: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
blockId: npsId,
|
||||
type: 'input',
|
||||
element: {
|
||||
type: 'plain_text_input',
|
||||
multiline: true,
|
||||
actionId: 'comment',
|
||||
},
|
||||
label: {
|
||||
type: 'plain_text',
|
||||
text: i18n.t('Why_did_you_chose__score__', { score, lng: language }),
|
||||
emoji: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
57
apps/meteor/server/modules/core-apps/videoconf.module.ts
Normal file
57
apps/meteor/server/modules/core-apps/videoconf.module.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { IUiKitCoreApp, UiKitCoreAppBlockActionPayload } from '@rocket.chat/core-services';
|
||||
import { VideoConf } from '@rocket.chat/core-services';
|
||||
import type * as UiKit from '@rocket.chat/ui-kit';
|
||||
|
||||
import { i18n } from '../../lib/i18n';
|
||||
|
||||
export class VideoConfModule implements IUiKitCoreApp {
|
||||
appId = 'videoconf-core';
|
||||
|
||||
async blockAction(payload: UiKitCoreAppBlockActionPayload): Promise<UiKit.ServerInteraction | undefined> {
|
||||
const {
|
||||
triggerId,
|
||||
actionId,
|
||||
payload: { blockId: callId },
|
||||
user: { _id: userId } = {},
|
||||
} = payload;
|
||||
|
||||
if (!callId) {
|
||||
throw new Error('invalid call');
|
||||
}
|
||||
|
||||
if (actionId === 'join') {
|
||||
await VideoConf.join(userId, callId, {});
|
||||
}
|
||||
|
||||
if (actionId === 'info') {
|
||||
const blocks = await VideoConf.getInfo(callId, userId);
|
||||
|
||||
return {
|
||||
type: 'modal.open',
|
||||
triggerId,
|
||||
appId: this.appId,
|
||||
view: {
|
||||
appId: this.appId,
|
||||
id: `${callId}-info`,
|
||||
title: {
|
||||
type: 'plain_text',
|
||||
text: i18n.t('Video_Conference_Info'),
|
||||
emoji: false,
|
||||
},
|
||||
close: {
|
||||
type: 'button',
|
||||
appId: this.appId,
|
||||
blockId: callId,
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: i18n.t('Close'),
|
||||
emoji: false,
|
||||
},
|
||||
actionId: 'cancel',
|
||||
},
|
||||
blocks,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
552
apps/meteor/server/modules/listeners/listeners.module.ts
Normal file
552
apps/meteor/server/modules/listeners/listeners.module.ts
Normal file
@@ -0,0 +1,552 @@
|
||||
import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
|
||||
import type { ISetting as AppsSetting } from '@rocket.chat/apps-engine/definition/settings';
|
||||
import type { IServiceClass } from '@rocket.chat/core-services';
|
||||
import { EnterpriseSettings, StatusVisibility } from '@rocket.chat/core-services';
|
||||
import { isSettingColor, isSettingEnterprise, UserStatus } from '@rocket.chat/core-typings';
|
||||
import type { IUser, IRoom, IRole, VideoConference, ISetting, IOmnichannelRoom, PresenceStatusCode } from '@rocket.chat/core-typings';
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
import type { ServerMediaSignal } from '@rocket.chat/media-signaling';
|
||||
import { parse } from '@rocket.chat/message-parser';
|
||||
|
||||
import { refreshVisibility } from '../../lib/notifications/core/lib/Presence';
|
||||
import { statusVisibilityGate } from '../../lib/statusVisibility/StatusVisibilityGate';
|
||||
import { settings } from '../../settings/cached';
|
||||
import type { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
const isMessageParserDisabled = process.env.DISABLE_MESSAGE_PARSER === 'true';
|
||||
|
||||
const STATUS_MAP: Record<UserStatus, PresenceStatusCode> = {
|
||||
[UserStatus.OFFLINE]: 0,
|
||||
[UserStatus.ONLINE]: 1,
|
||||
[UserStatus.AWAY]: 2,
|
||||
[UserStatus.BUSY]: 3,
|
||||
[UserStatus.DISABLED]: 0,
|
||||
} as const;
|
||||
|
||||
const minimongoChangeMap: Record<string, string> = {
|
||||
inserted: 'added',
|
||||
updated: 'changed',
|
||||
removed: 'removed',
|
||||
} as const;
|
||||
|
||||
export class ListenersModule {
|
||||
constructor(service: IServiceClass, notifications: NotificationsModule) {
|
||||
statusVisibilityGate.watch(service);
|
||||
|
||||
const logger = new Logger('ListenersModule');
|
||||
|
||||
service.onEvent('license.sync', () => notifications.notifyAllInThisInstance('license'));
|
||||
service.onEvent('license.actions', () => notifications.notifyAllInThisInstance('license'));
|
||||
|
||||
service.onEvent('emoji.deleteCustom', (emoji) => {
|
||||
notifications.notifyLoggedInThisInstance('deleteEmojiCustom', {
|
||||
emojiData: emoji,
|
||||
});
|
||||
});
|
||||
|
||||
service.onEvent('emoji.updateCustom', (emoji) => {
|
||||
notifications.notifyLoggedInThisInstance('updateEmojiCustom', {
|
||||
emojiData: emoji,
|
||||
});
|
||||
});
|
||||
|
||||
service.onEvent('user.forceLogout', (uid: string, sessionId?: string) => {
|
||||
notifications.notifyUserInThisInstance(uid, 'force_logout', sessionId);
|
||||
});
|
||||
|
||||
service.onEvent('notify.ephemeralMessage', (uid, rid, message) => {
|
||||
if (!isMessageParserDisabled && message.msg) {
|
||||
const customDomains = settings.get<string>('Message_CustomDomain_AutoLink')
|
||||
? settings
|
||||
.get<string>('Message_CustomDomain_AutoLink')
|
||||
.split(',')
|
||||
.map((domain) => domain.trim())
|
||||
: [];
|
||||
|
||||
message.md = parse(message.msg, {
|
||||
colors: settings.get('HexColorPreview_Enabled'),
|
||||
emoticons: true,
|
||||
customDomains,
|
||||
...(settings.get('Katex_Enabled') && {
|
||||
katex: {
|
||||
dollarSyntax: settings.get('Katex_Dollar_Syntax'),
|
||||
parenthesisSyntax: settings.get('Katex_Parenthesis_Syntax'),
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
notifications.notifyUserInThisInstance(uid, 'message', {
|
||||
groupable: false,
|
||||
u: {
|
||||
_id: 'rocket.cat',
|
||||
username: 'rocket.cat',
|
||||
},
|
||||
private: true,
|
||||
_id: message._id || String(Date.now()),
|
||||
rid,
|
||||
ts: new Date(),
|
||||
_updatedAt: new Date(),
|
||||
...message,
|
||||
});
|
||||
});
|
||||
|
||||
service.onEvent('permission.changed', ({ clientAction, data }) => {
|
||||
notifications.notifyLoggedInThisInstance('permissions-changed', clientAction, data);
|
||||
});
|
||||
|
||||
service.onEvent('room.avatarUpdate', ({ _id: rid, avatarETag: etag }) => {
|
||||
notifications.notifyLoggedInThisInstance('updateAvatar', {
|
||||
rid,
|
||||
etag,
|
||||
});
|
||||
});
|
||||
|
||||
service.onEvent('user.avatarUpdate', ({ username, avatarETag: etag }) => {
|
||||
notifications.notifyLoggedInThisInstance('updateAvatar', {
|
||||
username,
|
||||
etag,
|
||||
});
|
||||
});
|
||||
|
||||
service.onEvent('user.deleted', ({ _id: userId }, data) => {
|
||||
notifications.notifyLoggedInThisInstance('Users:Deleted', {
|
||||
userId,
|
||||
...data,
|
||||
});
|
||||
});
|
||||
|
||||
service.onEvent('user.deleteCustomStatus', (userStatus) => {
|
||||
notifications.notifyLoggedInThisInstance('deleteCustomUserStatus', {
|
||||
userStatusData: userStatus,
|
||||
});
|
||||
});
|
||||
|
||||
service.onEvent('user.nameChanged', (user) => {
|
||||
notifications.notifyLoggedInThisInstance('Users:NameChanged', user);
|
||||
});
|
||||
|
||||
service.onEvent('user.roleUpdate', (update) => {
|
||||
notifications.notifyLoggedInThisInstance('roles-change', update);
|
||||
});
|
||||
|
||||
service.onEvent(
|
||||
'user.video-conference',
|
||||
({
|
||||
userId,
|
||||
action,
|
||||
params,
|
||||
}: {
|
||||
userId: string;
|
||||
action: string;
|
||||
params: {
|
||||
callId: VideoConference['_id'];
|
||||
uid: IUser['_id'];
|
||||
rid: IRoom['_id'];
|
||||
};
|
||||
}) => {
|
||||
notifications.notifyUserInThisInstance(userId, 'video-conference', { action, params });
|
||||
},
|
||||
);
|
||||
|
||||
service.onEvent('user.media-signal', ({ userId, signal }: { userId: string; signal: ServerMediaSignal }) => {
|
||||
notifications.notifyUserInThisInstance(userId, 'media-signal', signal);
|
||||
});
|
||||
|
||||
service.onEvent('room.video-conference', ({ rid, callId }) => {
|
||||
/* deprecated */
|
||||
(notifications.notifyRoom as any)(rid, callId);
|
||||
|
||||
notifications.notifyRoom(rid, 'videoconf', callId);
|
||||
});
|
||||
|
||||
service.onEvent('presence.invalidateVisibility', ({ targets, viewers }) => {
|
||||
void StatusVisibility.refresh(targets)
|
||||
.then(async (users) => {
|
||||
await Promise.all([refreshVisibility(viewers), statusVisibilityGate.syncRestrictedUsers()]);
|
||||
|
||||
for (const { _id, username, status, statusText, statusSource, statusExpiresAt } of users) {
|
||||
if (username) {
|
||||
notifications.sendPresence(
|
||||
_id,
|
||||
username,
|
||||
STATUS_MAP[status ?? UserStatus.OFFLINE],
|
||||
statusText,
|
||||
statusSource,
|
||||
statusExpiresAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => logger.error({ msg: 'Failed to refresh status visibility', err, targets }));
|
||||
});
|
||||
|
||||
service.onEvent('presence.status', ({ user }) => {
|
||||
const { _id, username, name, status, statusText, statusSource, statusExpiresAt, roles } = user;
|
||||
if (!status || !username) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.get('Presence_broadcast_disabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
notifications.notifyUserInThisInstance(_id, 'userData', {
|
||||
type: 'updated',
|
||||
id: _id,
|
||||
diff: {
|
||||
status,
|
||||
...(statusText && { statusText }),
|
||||
...(statusSource && { statusSource }),
|
||||
...(statusExpiresAt && { statusExpiresAt }),
|
||||
},
|
||||
unset: {
|
||||
...(!statusText && { statusText: 1 }),
|
||||
...(!statusSource && { statusSource: 1 }),
|
||||
...(!statusExpiresAt && { statusExpiresAt: 1 }),
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: no client in this repo subscribes to notify-logged/user-status; if mobile and the
|
||||
// SDK don't either, this check and the restricted mirror behind it can be dropped
|
||||
if (!statusVisibilityGate.hasRestrictions(_id)) {
|
||||
notifications.notifyLoggedInThisInstance('user-status', [
|
||||
_id,
|
||||
username,
|
||||
STATUS_MAP[status],
|
||||
statusText,
|
||||
name,
|
||||
roles,
|
||||
statusSource,
|
||||
statusExpiresAt,
|
||||
]);
|
||||
}
|
||||
|
||||
if (_id) {
|
||||
notifications.sendPresence(_id, username, STATUS_MAP[status], statusText, statusSource, statusExpiresAt);
|
||||
}
|
||||
});
|
||||
|
||||
service.onEvent('user.updateCustomStatus', (userStatus) => {
|
||||
notifications.notifyLoggedInThisInstance('updateCustomUserStatus', {
|
||||
userStatusData: userStatus,
|
||||
});
|
||||
});
|
||||
|
||||
service.onEvent('user.activity', ({ isTyping, roomId, user }) => {
|
||||
notifications.notifyRoomInThisInstance(roomId, 'user-activity', user, isTyping ? ['user-typing'] : []);
|
||||
});
|
||||
|
||||
service.onEvent('watch.messages', async ({ message }) => {
|
||||
if (!message.rid) {
|
||||
return;
|
||||
}
|
||||
|
||||
notifications.streamRoomMessage._emit('__my_messages__', [message], undefined, false, (streamer, _sub, eventName, args, allowed) =>
|
||||
streamer.changedPayload(streamer.subscriptionName, 'id', {
|
||||
eventName,
|
||||
args: [...args, allowed],
|
||||
}),
|
||||
);
|
||||
|
||||
notifications.streamRoomMessage.emitWithoutBroadcast(message.rid, message);
|
||||
});
|
||||
|
||||
service.onEvent('notify.messagesRead', ({ rid, until, tmid }): void => {
|
||||
notifications.notifyRoomInThisInstance(rid, 'messagesRead', { tmid, until });
|
||||
});
|
||||
|
||||
service.onEvent('watch.subscriptions', ({ clientAction, subscription }) => {
|
||||
if (!subscription.u?._id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// emit a removed event on msg stream to remove the user's stream-room-messages subscription when the user is removed from room
|
||||
if (clientAction === 'removed') {
|
||||
notifications.streamRoomMessage.__emit(subscription.u._id, clientAction, subscription);
|
||||
}
|
||||
|
||||
notifications.streamUser.__emit(subscription.u._id, clientAction, subscription);
|
||||
|
||||
notifications.notifyUserInThisInstance(subscription.u._id, 'subscriptions-changed', clientAction, subscription as any);
|
||||
});
|
||||
|
||||
service.onEvent('watch.roles', ({ clientAction, role }): void => {
|
||||
notifications.streamRoles.emitWithoutBroadcast('roles', {
|
||||
type: clientAction,
|
||||
...(role as IRole),
|
||||
});
|
||||
});
|
||||
|
||||
service.onEvent('watch.inquiries', async ({ clientAction, inquiry, diff }): Promise<void> => {
|
||||
const type = minimongoChangeMap[clientAction] as 'added' | 'changed' | 'removed';
|
||||
|
||||
const isOnlyQueueMetadataUpdate = (diff: Record<string, unknown> | undefined): boolean => {
|
||||
if (!diff) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const queueMetadataKeys = ['lockedAt', 'locked', '_updatedAt'];
|
||||
return Object.keys(diff).length === queueMetadataKeys.length && queueMetadataKeys.every((key) => diff.hasOwnProperty(key));
|
||||
};
|
||||
|
||||
// Always notify the specific inquiry channel
|
||||
notifications.streamLivechatQueueData.emitWithoutBroadcast(inquiry._id, {
|
||||
_id: inquiry._id,
|
||||
...(clientAction !== 'removed' && { ...inquiry }),
|
||||
clientAction,
|
||||
});
|
||||
|
||||
// Skip further notifications if it's just a queue metadata update
|
||||
if (clientAction === 'updated' && isOnlyQueueMetadataUpdate(diff)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Notify the defaultAgent if exists
|
||||
if (inquiry.defaultAgent?.agentId) {
|
||||
notifications.streamLivechatQueueData.emitWithoutBroadcast(`agent/${inquiry.defaultAgent.agentId}`, {
|
||||
type,
|
||||
...inquiry,
|
||||
});
|
||||
}
|
||||
|
||||
// Prioritize department-specific channel over public
|
||||
if (inquiry.department) {
|
||||
notifications.streamLivechatQueueData.emitWithoutBroadcast(`department/${inquiry.department}`, {
|
||||
type,
|
||||
...inquiry,
|
||||
});
|
||||
}
|
||||
|
||||
if (!inquiry.department && !inquiry.defaultAgent?.agentId) {
|
||||
notifications.streamLivechatQueueData.emitWithoutBroadcast('public', {
|
||||
type,
|
||||
...inquiry,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
service.onEvent('watch.settings', async ({ clientAction, setting }): Promise<void> => {
|
||||
// if a EE setting changed make sure we broadcast the correct value according to license
|
||||
if (clientAction !== 'removed' && isSettingEnterprise(setting)) {
|
||||
try {
|
||||
const result = await EnterpriseSettings.changeSettingValue(setting);
|
||||
if (result !== undefined && !(result instanceof Error)) {
|
||||
setting.value = result;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
logger.error({ msg: 'Error getting proper enterprise setting value. Returning `invalidValue` instead.', err });
|
||||
setting.value = setting.invalidValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (setting.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = {
|
||||
_id: setting._id,
|
||||
value: setting.value,
|
||||
...(isSettingColor(setting) && { editor: setting.editor }),
|
||||
properties: setting.properties,
|
||||
enterprise: setting.enterprise,
|
||||
requiredOnWizard: setting.requiredOnWizard,
|
||||
} as ISetting;
|
||||
|
||||
if (setting.public === true) {
|
||||
notifications.notifyAllInThisInstance('public-settings-changed', clientAction, value);
|
||||
notifications.notifyAllInThisInstance('public-info', ['public-settings-changed', [clientAction, value]]);
|
||||
}
|
||||
|
||||
notifications.notifyLoggedInThisInstance('private-settings-changed', clientAction, value);
|
||||
});
|
||||
|
||||
service.onEvent('watch.rooms', ({ clientAction, room }): void => {
|
||||
// this emit will cause the user to receive a 'rooms-changed' event
|
||||
notifications.streamUser.__emit(room._id, clientAction, room);
|
||||
|
||||
notifications.streamRoomData.emitWithoutBroadcast(room._id, room as IOmnichannelRoom);
|
||||
});
|
||||
|
||||
service.onEvent('watch.users', (event): void => {
|
||||
switch (event.clientAction) {
|
||||
case 'updated':
|
||||
notifications.notifyUserInThisInstance(event.id, 'userData', {
|
||||
id: event.id,
|
||||
diff: event.diff,
|
||||
unset: event.unset,
|
||||
type: 'updated',
|
||||
});
|
||||
break;
|
||||
case 'inserted':
|
||||
notifications.notifyUserInThisInstance(event.id, 'userData', { id: event.id, data: event.data, type: 'inserted' });
|
||||
break;
|
||||
case 'removed':
|
||||
notifications.notifyUserInThisInstance(event.id, 'userData', { id: event.id, type: 'removed' });
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
service.onEvent('watch.integrationHistory', ({ clientAction, data, diff, id }): void => {
|
||||
if (!data?.integration?._id) {
|
||||
return;
|
||||
}
|
||||
switch (clientAction) {
|
||||
case 'updated': {
|
||||
notifications.streamIntegrationHistory.emitWithoutBroadcast(data.integration._id, {
|
||||
id,
|
||||
diff,
|
||||
type: clientAction,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'inserted': {
|
||||
notifications.streamIntegrationHistory.emitWithoutBroadcast(data.integration._id, {
|
||||
data,
|
||||
type: clientAction,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
service.onEvent('watch.livechatDepartmentAgents', ({ clientAction, data }): void => {
|
||||
const { agentId } = data;
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
notifications.notifyUserInThisInstance(agentId, 'departmentAgentData', {
|
||||
action: clientAction,
|
||||
...data,
|
||||
});
|
||||
});
|
||||
|
||||
service.onEvent('banner.user', (userId, banner): void => {
|
||||
notifications.notifyUserInThisInstance(userId, 'banners', banner);
|
||||
});
|
||||
|
||||
service.onEvent('banner.new', (bannerId): void => {
|
||||
notifications.notifyLoggedInThisInstance('new-banner', { bannerId }); // deprecated
|
||||
notifications.notifyLoggedInThisInstance('banner-changed', { bannerId });
|
||||
});
|
||||
|
||||
service.onEvent('banner.disabled', (bannerId): void => {
|
||||
notifications.notifyLoggedInThisInstance('banner-changed', { bannerId });
|
||||
});
|
||||
|
||||
service.onEvent('banner.enabled', (bannerId): void => {
|
||||
notifications.notifyLoggedInThisInstance('banner-changed', { bannerId });
|
||||
});
|
||||
|
||||
service.onEvent('call.callerhangup', (userId, data): void => {
|
||||
notifications.notifyUserInThisInstance(userId, 'call.hangup', data);
|
||||
});
|
||||
|
||||
service.onEvent('notify.desktop', (uid, notification): void => {
|
||||
notifications.notifyUserInThisInstance(uid, 'notification', notification);
|
||||
});
|
||||
|
||||
service.onEvent('notify.uiInteraction', (uid, interaction): void => {
|
||||
notifications.notifyUserInThisInstance(uid, 'uiInteraction', interaction);
|
||||
});
|
||||
|
||||
service.onEvent('notify.updateInvites', (uid, data): void => {
|
||||
notifications.notifyUserInThisInstance(uid, 'updateInvites', data);
|
||||
});
|
||||
|
||||
service.onEvent('notify.webdav', (uid, data): void => {
|
||||
notifications.notifyUserInThisInstance(uid, 'webdav', data);
|
||||
});
|
||||
|
||||
service.onEvent('notify.e2e.keyRequest', (rid, data): void => {
|
||||
notifications.notifyRoomInThisInstance(rid, 'e2e.keyRequest', data);
|
||||
});
|
||||
|
||||
service.onEvent('notify.deleteMessage', (rid, data): void => {
|
||||
notifications.notifyRoomInThisInstance(rid, 'deleteMessage', data);
|
||||
});
|
||||
|
||||
service.onEvent('notify.deleteMessageBulk', (rid, data): void => {
|
||||
notifications.notifyRoomInThisInstance(rid, 'deleteMessageBulk', data);
|
||||
});
|
||||
|
||||
service.onEvent('notify.deleteCustomSound', (data): void => {
|
||||
notifications.notifyAllInThisInstance('deleteCustomSound', data);
|
||||
notifications.notifyAllInThisInstance('public-info', ['deleteCustomSound', [data]]);
|
||||
});
|
||||
|
||||
service.onEvent('notify.updateCustomSound', (data): void => {
|
||||
notifications.notifyAllInThisInstance('updateCustomSound', data);
|
||||
notifications.notifyAllInThisInstance('public-info', ['updateCustomSound', [data]]);
|
||||
});
|
||||
|
||||
service.onEvent('notify.calendar', (uid, data): void => {
|
||||
notifications.notifyUserInThisInstance(uid, 'calendar', data);
|
||||
});
|
||||
|
||||
service.onEvent('notify.importedMessages', ({ roomIds }): void => {
|
||||
roomIds.forEach((rid) => {
|
||||
// couldnt get TS happy by providing no data, so had to provide null
|
||||
notifications.notifyRoomInThisInstance(rid, 'messagesImported', null);
|
||||
});
|
||||
});
|
||||
|
||||
service.onEvent('omnichannel.room', (roomId, data): void => {
|
||||
notifications.streamLivechatRoom.emitWithoutBroadcast(roomId, data);
|
||||
});
|
||||
service.onEvent('watch.priorities', async ({ clientAction, diff, id }): Promise<void> => {
|
||||
notifications.notifyLoggedInThisInstance('omnichannel.priority-changed', { id, clientAction, name: diff?.name });
|
||||
});
|
||||
|
||||
service.onEvent('apps.added', (appId: string) => {
|
||||
notifications.streamApps.emitWithoutBroadcast('app/added', appId);
|
||||
notifications.streamApps.emitWithoutBroadcast('apps', ['app/added', [appId]]);
|
||||
});
|
||||
|
||||
service.onEvent('apps.removed', (appId: string) => {
|
||||
notifications.streamApps.emitWithoutBroadcast('app/removed', appId);
|
||||
notifications.streamApps.emitWithoutBroadcast('apps', ['app/removed', [appId]]);
|
||||
});
|
||||
|
||||
service.onEvent('apps.updated', (appId: string) => {
|
||||
notifications.streamApps.emitWithoutBroadcast('app/updated', appId);
|
||||
notifications.streamApps.emitWithoutBroadcast('apps', ['app/updated', [appId]]);
|
||||
});
|
||||
|
||||
service.onEvent('apps.statusUpdate', (appId: string, status: AppStatus) => {
|
||||
notifications.streamApps.emitWithoutBroadcast('app/statusUpdate', { appId, status });
|
||||
notifications.streamApps.emitWithoutBroadcast('apps', ['app/statusUpdate', [{ appId, status }]]);
|
||||
});
|
||||
|
||||
service.onEvent('apps.settingUpdated', (appId: string, setting: AppsSetting) => {
|
||||
notifications.streamApps.emitWithoutBroadcast('app/settingUpdated', { appId, setting });
|
||||
notifications.streamApps.emitWithoutBroadcast('apps', ['app/settingUpdated', [{ appId, setting }]]);
|
||||
});
|
||||
|
||||
service.onEvent('command.added', (command: string) => {
|
||||
notifications.streamApps.emitWithoutBroadcast('command/added', command);
|
||||
notifications.streamApps.emitWithoutBroadcast('apps', ['command/added', [command]]);
|
||||
});
|
||||
|
||||
service.onEvent('command.disabled', (command: string) => {
|
||||
notifications.streamApps.emitWithoutBroadcast('command/disabled', command);
|
||||
notifications.streamApps.emitWithoutBroadcast('apps', ['command/disabled', [command]]);
|
||||
});
|
||||
|
||||
service.onEvent('command.updated', (command: string) => {
|
||||
notifications.streamApps.emitWithoutBroadcast('command/updated', command);
|
||||
notifications.streamApps.emitWithoutBroadcast('apps', ['command/updated', [command]]);
|
||||
});
|
||||
|
||||
service.onEvent('command.removed', (command: string) => {
|
||||
notifications.streamApps.emitWithoutBroadcast('command/removed', command);
|
||||
notifications.streamApps.emitWithoutBroadcast('apps', ['command/removed', [command]]);
|
||||
});
|
||||
|
||||
service.onEvent('actions.changed', () => {
|
||||
notifications.streamApps.emitWithoutBroadcast('actions/changed');
|
||||
notifications.streamApps.emitWithoutBroadcast('apps', ['actions/changed', []]);
|
||||
});
|
||||
}
|
||||
}
|
||||
536
apps/meteor/server/modules/notifications/notifications.module.ts
Normal file
536
apps/meteor/server/modules/notifications/notifications.module.ts
Normal file
@@ -0,0 +1,536 @@
|
||||
import { Authorization, MediaCall, VideoConf, Settings } from '@rocket.chat/core-services';
|
||||
import type { ISubscription, IOmnichannelRoom, IUser, IUserDataEvent, PresenceSource, PresenceStatusCode } from '@rocket.chat/core-typings';
|
||||
import type { StreamerCallbackArgs, StreamKeys, StreamNames } from '@rocket.chat/ddp-client';
|
||||
import { Rooms, Subscriptions, Users } from '@rocket.chat/models';
|
||||
|
||||
import type { ImporterProgress } from '../../lib/import/classes/ImporterProgress';
|
||||
import { SystemLogger } from '../../lib/logger/system';
|
||||
import { emit, StreamPresence } from '../../lib/notifications/core/lib/Presence';
|
||||
import { getCachedUserForPublication } from '../streamer/publication-user-cache';
|
||||
import { Streamer as StreamerModule } from '../streamer/streamer.module';
|
||||
import type { IStreamer, IStreamerConstructor } from '../streamer/types';
|
||||
|
||||
export class NotificationsModule {
|
||||
public readonly streamLogged: IStreamer<'notify-logged'>;
|
||||
|
||||
public readonly streamAll: IStreamer<'notify-all'>;
|
||||
|
||||
public readonly streamRoom: IStreamer<'notify-room'>;
|
||||
|
||||
public readonly streamRoomUsers: IStreamer<'notify-room-users'>;
|
||||
|
||||
public readonly streamUser: IStreamer<'notify-user'> & {
|
||||
on(event: string, fn: (...data: any[]) => void): void;
|
||||
};
|
||||
|
||||
public readonly streamRoomMessage: IStreamer<'room-messages'>;
|
||||
|
||||
public readonly streamImporters: IStreamer<'importers'>;
|
||||
|
||||
public readonly streamRoles: IStreamer<'roles'>;
|
||||
|
||||
public readonly streamApps: IStreamer<'apps'>;
|
||||
|
||||
public readonly streamAppsEngine: IStreamer<'apps-engine'>;
|
||||
|
||||
public readonly streamCannedResponses: IStreamer<'canned-responses'>;
|
||||
|
||||
public readonly streamIntegrationHistory: IStreamer<'integrationHistory'>;
|
||||
|
||||
public readonly streamLivechatRoom: IStreamer<'livechat-room'>;
|
||||
|
||||
public readonly streamLivechatQueueData: IStreamer<'livechat-inquiry-queue-observer'>;
|
||||
|
||||
public readonly streamRoomData: IStreamer<'room-data'>;
|
||||
|
||||
public readonly streamLocal: IStreamer<'local'>;
|
||||
|
||||
public readonly streamPresence: IStreamer<'user-presence'>;
|
||||
|
||||
constructor(private Streamer: IStreamerConstructor) {
|
||||
this.streamAll = new this.Streamer('notify-all');
|
||||
this.streamLogged = new this.Streamer('notify-logged');
|
||||
this.streamRoom = new this.Streamer('notify-room');
|
||||
this.streamRoomUsers = new this.Streamer('notify-room-users');
|
||||
this.streamImporters = new this.Streamer('importers', { retransmit: false });
|
||||
this.streamRoles = new this.Streamer('roles');
|
||||
this.streamApps = new this.Streamer('apps', { retransmit: false });
|
||||
this.streamAppsEngine = new this.Streamer('apps-engine', { retransmit: false });
|
||||
this.streamCannedResponses = new this.Streamer('canned-responses');
|
||||
this.streamIntegrationHistory = new this.Streamer('integrationHistory');
|
||||
this.streamLivechatRoom = new this.Streamer('livechat-room');
|
||||
this.streamLivechatQueueData = new this.Streamer('livechat-inquiry-queue-observer');
|
||||
this.streamRoomData = new this.Streamer('room-data');
|
||||
this.streamPresence = StreamPresence.getInstance(Streamer, 'user-presence');
|
||||
this.streamRoomMessage = new this.Streamer('room-messages');
|
||||
|
||||
this.streamRoomMessage.on('_afterPublish', async (streamer, publication, eventName): Promise<void> => {
|
||||
if (!StreamerModule.isPublicationActive(publication)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { userId } = publication._session;
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userEvent = (clientAction: string, { rid }: { rid: string }): void => {
|
||||
switch (clientAction) {
|
||||
case 'removed':
|
||||
streamer.removeListener(userId, userEvent);
|
||||
const sub = [...streamer.subscriptions].find((sub) => sub.eventName === rid && sub.subscription.userId === userId);
|
||||
sub && streamer.removeSubscription(sub, eventName);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
streamer.on(userId, userEvent);
|
||||
|
||||
publication.onStop(() => streamer.removeListener(userId, userEvent));
|
||||
});
|
||||
|
||||
this.streamUser = new this.Streamer('notify-user');
|
||||
this.streamLocal = new this.Streamer('local');
|
||||
}
|
||||
|
||||
configure(): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
const self = this;
|
||||
|
||||
this.streamRoomMessage.allowWrite('none');
|
||||
this.streamRoomMessage.allowRead(async function (eventName, extraData) {
|
||||
const room = await Rooms.findOneById(eventName);
|
||||
if (!room) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const user = await getCachedUserForPublication(this);
|
||||
return Authorization.canReadRoom(room, user ?? undefined, extraData);
|
||||
});
|
||||
|
||||
this.streamRoomMessage.allowRead('__my_messages__', 'all');
|
||||
this.streamRoomMessage.allowEmit('__my_messages__', async function (_eventName, { rid }) {
|
||||
const user = await getCachedUserForPublication(this);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const room = await Rooms.findOneById(rid);
|
||||
if (!room) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const canAccess = await Authorization.canAccessRoom(room, user);
|
||||
if (!canAccess) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const roomParticipant = await Subscriptions.countByRoomIdAndUserId(room._id, user._id);
|
||||
|
||||
return {
|
||||
roomParticipant: roomParticipant > 0,
|
||||
roomType: room.t,
|
||||
roomName: room.name,
|
||||
};
|
||||
} catch (error) {
|
||||
/* error*/
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
this.streamAll.allowWrite('none');
|
||||
this.streamAll.allowRead('all');
|
||||
this.streamLogged.allowRead('private-settings-changed', async function () {
|
||||
const user = await getCachedUserForPublication(this);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
return Authorization.hasAtLeastOnePermission(user, [
|
||||
'view-privileged-setting',
|
||||
'edit-privileged-setting',
|
||||
'manage-selected-settings',
|
||||
]);
|
||||
});
|
||||
|
||||
this.streamLogged.allowWrite('none');
|
||||
this.streamLogged.allowRead('logged');
|
||||
|
||||
this.streamRoom.allowRead(async function (eventName, extraData): Promise<boolean> {
|
||||
const [rid] = eventName.split('/');
|
||||
|
||||
const room = await Rooms.findOneById<Pick<IOmnichannelRoom, 't' | 'v' | '_id'>>(rid, {
|
||||
projection: { 't': 1, 'v.token': 1 },
|
||||
});
|
||||
|
||||
if (!room) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// typing from livechat widget
|
||||
if (extraData?.token) {
|
||||
// TODO improve this to make a query 'v.token'
|
||||
const room = await Rooms.findOneById<Pick<IOmnichannelRoom, 't' | 'v'>>(rid, {
|
||||
projection: { 't': 1, 'v.token': 1 },
|
||||
});
|
||||
return !!room && room.t === 'l' && room.v.token === extraData.token;
|
||||
}
|
||||
|
||||
const user = await getCachedUserForPublication(this);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
const canAccess = await Authorization.canAccessRoom(room, user);
|
||||
|
||||
return canAccess;
|
||||
});
|
||||
|
||||
async function canType({
|
||||
userId,
|
||||
username,
|
||||
extraData,
|
||||
rid,
|
||||
}: {
|
||||
userId?: string;
|
||||
username: string;
|
||||
extraData?: { token: string };
|
||||
rid: string;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
// typing from livechat widget
|
||||
if (extraData?.token) {
|
||||
// TODO improve this to make a query 'v.token'
|
||||
const room = await Rooms.findOneById<Pick<IOmnichannelRoom, 't' | 'v'>>(rid, {
|
||||
projection: { 't': 1, 'v.token': 1 },
|
||||
});
|
||||
return !!room && room.t === 'l' && room.v.token === extraData.token;
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO consider using something to cache settings
|
||||
const key = (await Settings.get('UI_Use_Real_Name')) ? 'name' : 'username';
|
||||
|
||||
const user = await Users.findOneById<Pick<IUser, 'name' | 'username'>>(userId, {
|
||||
projection: {
|
||||
[key]: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return user[key] === username;
|
||||
} catch (err) {
|
||||
SystemLogger.error({ err });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this.streamRoom.allowWrite(async function (eventName, username, _activity, extraData): Promise<boolean> {
|
||||
const [rid, e] = eventName.split('/');
|
||||
|
||||
if (e !== 'user-activity') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!(await canType({ extraData, rid, username, userId: this.userId ?? undefined }))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
this.streamRoomUsers.allowRead('none');
|
||||
this.streamRoomUsers.allowWrite(async function (
|
||||
eventName: `${string}/video-conference` | `${string}/userData`,
|
||||
...args: [{ action: string; params: { callId: string; uid: string; rid: string } }] | [IUserDataEvent]
|
||||
) {
|
||||
const [roomId, e] = eventName.split('/') as [string, 'video-conference' | 'userData'];
|
||||
if (
|
||||
this.userId &&
|
||||
['video-conference', 'userData'].includes(e) &&
|
||||
(await Subscriptions.countByRoomIdAndUserId(roomId, this.userId)) > 0
|
||||
) {
|
||||
const subscriptions: ISubscription[] = await Subscriptions.findByRoomIdAndNotUserId(roomId, this.userId, {
|
||||
projection: { 'u._id': 1, '_id': 0 },
|
||||
}).toArray();
|
||||
|
||||
subscriptions.forEach((subscription) => self.notifyUser(subscription.u._id, e, ...args));
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
this.streamUser.allowWrite(async function (eventName, data: unknown) {
|
||||
const [, e] = eventName.split('/');
|
||||
if (e === 'video-conference') {
|
||||
if (!this.userId || !data || typeof data !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { action: videoAction, params } = data as {
|
||||
action: string | undefined;
|
||||
params: { callId?: string; uid?: string; rid?: string };
|
||||
};
|
||||
|
||||
if (!videoAction || typeof videoAction !== 'string' || !params || typeof params !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const callId = 'callId' in params && typeof params.callId === 'string' ? params.callId : '';
|
||||
const uid = 'uid' in params && typeof params.uid === 'string' ? params.uid : '';
|
||||
const rid = 'rid' in params && typeof params.rid === 'string' ? params.rid : '';
|
||||
|
||||
return VideoConf.validateAction(videoAction, this.userId, {
|
||||
callId,
|
||||
uid,
|
||||
rid,
|
||||
});
|
||||
}
|
||||
|
||||
if (e === 'media-calls') {
|
||||
if (!this.userId || !data || typeof data !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
void MediaCall.processSerializedSignal(this.userId, data).catch(() => null);
|
||||
|
||||
// media call signals don't ever need to be broadcasted
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
this.streamUser.allowRead(async function (eventName) {
|
||||
const [userId] = eventName.split('/');
|
||||
|
||||
return Boolean(this.userId) && this.userId === userId;
|
||||
});
|
||||
|
||||
this.streamImporters.allowRead('all');
|
||||
this.streamImporters.allowEmit('all');
|
||||
this.streamImporters.allowWrite('none');
|
||||
|
||||
this.streamApps.serverOnly = true;
|
||||
this.streamApps.allowRead('all');
|
||||
this.streamApps.allowEmit('all');
|
||||
this.streamApps.allowWrite('none');
|
||||
|
||||
this.streamAppsEngine.serverOnly = true;
|
||||
this.streamAppsEngine.allowRead('none');
|
||||
this.streamAppsEngine.allowEmit('all');
|
||||
this.streamAppsEngine.allowWrite('none');
|
||||
|
||||
this.streamCannedResponses.allowWrite('none');
|
||||
this.streamCannedResponses.allowRead(async function () {
|
||||
const user = await getCachedUserForPublication(this);
|
||||
return !!user && !!(await Settings.get('Canned_Responses_Enable')) && Authorization.hasPermission(user, 'view-canned-responses');
|
||||
});
|
||||
|
||||
this.streamIntegrationHistory.allowWrite('none');
|
||||
this.streamIntegrationHistory.allowRead(async function () {
|
||||
const user = await getCachedUserForPublication(this);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
return Authorization.hasAtLeastOnePermission(user, ['manage-outgoing-integrations', 'manage-own-outgoing-integrations']);
|
||||
});
|
||||
|
||||
this.streamLivechatRoom.allowRead(async (roomId, extraData) => {
|
||||
const room = await Rooms.findOneById<Pick<IOmnichannelRoom, 't' | 'v'>>(roomId, {
|
||||
projection: { _id: 0, t: 1, v: 1 },
|
||||
});
|
||||
|
||||
if (!room) {
|
||||
console.warn(`Invalid eventName: "${roomId}"`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (room.t === 'l' && extraData?.visitorToken && room.v.token === extraData.visitorToken) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
this.streamLivechatQueueData.allowWrite('none');
|
||||
this.streamLivechatQueueData.allowRead(async function () {
|
||||
const user = await getCachedUserForPublication(this);
|
||||
return user ? Authorization.hasPermission(user, 'view-l-room') : false;
|
||||
});
|
||||
|
||||
this.streamRoomData.allowWrite('none');
|
||||
this.streamRoomData.allowRead(async function (rid) {
|
||||
const user = await getCachedUserForPublication(this);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const room = await Rooms.findOneById(rid);
|
||||
if (!room) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const canAccess = await Authorization.canAccessRoom(room, user);
|
||||
if (!canAccess) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
this.streamRoles.allowWrite('none');
|
||||
this.streamRoles.allowRead('logged');
|
||||
|
||||
this.streamUser.on('_afterPublish', async (streamer, publication, eventName): Promise<void> => {
|
||||
// after meteor 3.4.1 immediately after a disconnection session becomes null (which is not wrong)
|
||||
// we were just not counting on this, session is _session so we actually should not use it
|
||||
// now after any await, the session can potentially be null, so we need to check for that
|
||||
if (!StreamerModule.isPublicationActive(publication)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { userId } = publication._session;
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (/rooms-changed/.test(eventName)) {
|
||||
// TODO: change this to serialize only once
|
||||
const roomEvent = (...args: any[]): void => {
|
||||
// TODO if receive a removed event could do => streamer.removeListener(rid, roomEvent);
|
||||
const payload = streamer.changedPayload(streamer.subscriptionName, 'id', {
|
||||
eventName: `${userId}/rooms-changed`,
|
||||
args,
|
||||
});
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
// after meteor 3.4.1 immediately after a disconnection session becomes null (which is not wrong)
|
||||
// we were just not counting on this, session is _session so we actually should not use it
|
||||
// now after any await, the session can potentially be null, so we need to check for that
|
||||
if (!StreamerModule.isPublicationActive(publication)) {
|
||||
return;
|
||||
}
|
||||
publication._session.socket.send(payload);
|
||||
};
|
||||
|
||||
const subscriptions = await Subscriptions.find<Pick<ISubscription, 'rid'>>(
|
||||
{ 'u._id': userId },
|
||||
{ projection: { rid: 1 } },
|
||||
).toArray();
|
||||
|
||||
subscriptions.forEach(({ rid }) => {
|
||||
streamer.on(rid, roomEvent);
|
||||
});
|
||||
|
||||
const userEvent = async (clientAction: string, { rid }: Partial<ISubscription> = {}): Promise<void> => {
|
||||
if (!rid) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (clientAction) {
|
||||
case 'inserted':
|
||||
subscriptions.push({ rid });
|
||||
streamer.on(rid, roomEvent);
|
||||
|
||||
// after a subscription is added need to emit the room again
|
||||
roomEvent('inserted', await Rooms.findOneById(rid));
|
||||
break;
|
||||
|
||||
case 'removed':
|
||||
streamer.removeListener(rid, roomEvent);
|
||||
break;
|
||||
}
|
||||
};
|
||||
streamer.on(userId, userEvent);
|
||||
|
||||
publication.onStop(() => {
|
||||
streamer.removeListener(userId, userEvent);
|
||||
subscriptions.forEach(({ rid }) => streamer.removeListener(rid, roomEvent));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.streamLocal.serverOnly = true;
|
||||
this.streamLocal.allowRead('none');
|
||||
this.streamLocal.allowEmit('all');
|
||||
this.streamLocal.allowWrite('none');
|
||||
|
||||
this.streamPresence.allowRead('logged');
|
||||
this.streamPresence.allowWrite('none');
|
||||
}
|
||||
|
||||
// notifyAll<E extends StreamKeys<'notify-all'>>(eventName: E, ...args: StreamerCallbackArgs<'notify-all', E>): void {
|
||||
// return this.streamAll.emit(eventName, ...args);
|
||||
// }
|
||||
|
||||
notifyLogged<E extends StreamKeys<'notify-logged'>>(eventName: E, ...args: StreamerCallbackArgs<'notify-logged', E>): void {
|
||||
return this.streamLogged.emit(eventName, ...args);
|
||||
}
|
||||
|
||||
notifyRoom<P extends string, E extends string>(
|
||||
room: P,
|
||||
eventName: E extends ExtractNotifyUserEventName<'notify-room', P> ? E : never,
|
||||
...args: E extends ExtractNotifyUserEventName<'notify-room', P> ? StreamerCallbackArgs<'notify-room', `${P}/${E}`> : never
|
||||
): void {
|
||||
return this.streamRoom.emit(`${room}/${eventName}`, ...args);
|
||||
}
|
||||
|
||||
notifyUser<P extends string, E extends string>(
|
||||
userId: P,
|
||||
eventName: E extends ExtractNotifyUserEventName<'notify-user', P> ? E : never,
|
||||
...args: E extends ExtractNotifyUserEventName<'notify-user', P> ? StreamerCallbackArgs<'notify-user', `${P}/${E}`> : never
|
||||
): void {
|
||||
return this.streamUser.emit(`${userId}/${eventName}`, ...args);
|
||||
}
|
||||
|
||||
notifyAllInThisInstance<E extends StreamKeys<'notify-all'>>(eventName: E, ...args: StreamerCallbackArgs<'notify-all', E>): void {
|
||||
return this.streamAll.emitWithoutBroadcast(eventName, ...args);
|
||||
}
|
||||
|
||||
notifyLoggedInThisInstance<E extends StreamKeys<'notify-logged'>>(eventName: E, ...args: StreamerCallbackArgs<'notify-logged', E>): void {
|
||||
return this.streamLogged.emitWithoutBroadcast(eventName, ...args);
|
||||
}
|
||||
|
||||
notifyRoomInThisInstance<P extends string, E extends string>(
|
||||
room: P,
|
||||
eventName: E extends ExtractNotifyUserEventName<'notify-room', P> ? E : never,
|
||||
...args: E extends ExtractNotifyUserEventName<'notify-room', P> ? StreamerCallbackArgs<'notify-room', `${P}/${E}`> : never
|
||||
): void {
|
||||
return this.streamRoom.emitWithoutBroadcast(`${room}/${eventName}`, ...args);
|
||||
}
|
||||
|
||||
notifyUserInThisInstance<P extends string, E extends string>(
|
||||
userId: P,
|
||||
eventName: E extends ExtractNotifyUserEventName<'notify-user', P> ? E : never,
|
||||
...args: E extends ExtractNotifyUserEventName<'notify-user', P> ? StreamerCallbackArgs<'notify-user', `${P}/${E}`> : never
|
||||
): void {
|
||||
return this.streamUser.emitWithoutBroadcast(`${userId}/${eventName}`, ...args);
|
||||
}
|
||||
|
||||
sendPresence(
|
||||
uid: string,
|
||||
...args: [username: string, status?: PresenceStatusCode, statusText?: string, statusSource?: PresenceSource, statusExpiresAt?: Date]
|
||||
): void {
|
||||
emit(uid, [args]);
|
||||
return this.streamPresence.emitWithoutBroadcast(uid, args);
|
||||
}
|
||||
|
||||
progressUpdated(progress: { rate: number } | ImporterProgress): void {
|
||||
this.streamImporters.emit('progress', progress);
|
||||
}
|
||||
}
|
||||
|
||||
type ExtractNotifyUserEventName<
|
||||
T extends StreamNames,
|
||||
P extends string,
|
||||
E extends StreamKeys<T> = StreamKeys<T>,
|
||||
> = E extends `${infer X}/${infer I}` ? (P extends X ? I : never) : never;
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
|
||||
import type { IPublication } from './types';
|
||||
|
||||
type CachedUser = IUser; // Pick<IUser, '_id' | 'roles'>;
|
||||
|
||||
type CacheEntry = {
|
||||
user: Promise<CachedUser | null>;
|
||||
timeout: NodeJS.Timeout;
|
||||
};
|
||||
|
||||
const CACHE_PROJECTION = { _id: 1, roles: 1 } as const;
|
||||
const CACHE_TIMEOUT = 1000 * 60;
|
||||
const cacheByUserId = new Map<string, CacheEntry>();
|
||||
|
||||
export function getCachedUserForPublication(publication: IPublication): Promise<CachedUser | null> {
|
||||
const userId = publication.userId ?? publication._session?.userId;
|
||||
if (!userId) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
const existing = cacheByUserId.get(userId);
|
||||
if (existing) {
|
||||
return existing.user;
|
||||
}
|
||||
|
||||
const userPromise = Users.findOneById<CachedUser>(userId, { projection: CACHE_PROJECTION });
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
invalidate(userId);
|
||||
}, CACHE_TIMEOUT);
|
||||
|
||||
cacheByUserId.set(userId, { user: userPromise, timeout });
|
||||
|
||||
userPromise.then(
|
||||
(user: CachedUser | null) => {
|
||||
if (!user) {
|
||||
invalidate(userId);
|
||||
}
|
||||
},
|
||||
() => invalidate(userId),
|
||||
);
|
||||
|
||||
return userPromise;
|
||||
}
|
||||
|
||||
export function invalidate(userId: string): void {
|
||||
const entry = cacheByUserId.get(userId);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(entry.timeout);
|
||||
cacheByUserId.delete(userId);
|
||||
}
|
||||
348
apps/meteor/server/modules/streamer/streamer.module.ts
Normal file
348
apps/meteor/server/modules/streamer/streamer.module.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import { MeteorError } from '@rocket.chat/core-services';
|
||||
import type { StreamerEvents } from '@rocket.chat/ddp-client';
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
|
||||
import type { IPublication, Rule, Connection, DDPSubscription, IStreamer, IRules, TransformMessage } from './types';
|
||||
import { SystemLogger } from '../../lib/logger/system';
|
||||
|
||||
class StreamerCentralClass<N extends keyof StreamerEvents> extends EventEmitter {
|
||||
public instances: Record<string, Streamer<N>> = {};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
type ActivePublication = IPublication & {
|
||||
_session: NonNullable<IPublication['_session']> & { socket: NonNullable<NonNullable<IPublication['_session']>['socket']> };
|
||||
};
|
||||
|
||||
export const StreamerCentral = new StreamerCentralClass();
|
||||
|
||||
export abstract class Streamer<N extends keyof StreamerEvents> extends EventEmitter implements IStreamer<N> {
|
||||
public subscriptions = new Set<DDPSubscription>();
|
||||
|
||||
protected subscriptionsByEventName = new Map<string, Set<DDPSubscription>>();
|
||||
|
||||
public retransmit = true;
|
||||
|
||||
public retransmitToSelf = false;
|
||||
|
||||
public serverOnly = false;
|
||||
|
||||
private _allowRead: IRules = {};
|
||||
|
||||
private _allowWrite: IRules = {};
|
||||
|
||||
private _allowEmit: IRules = {};
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
{ retransmit = true, retransmitToSelf = false }: { retransmit?: boolean; retransmitToSelf?: boolean } = {},
|
||||
) {
|
||||
super();
|
||||
|
||||
if (StreamerCentral.instances[name]) {
|
||||
console.warn('Streamer instance already exists:', name);
|
||||
return StreamerCentral.instances[name];
|
||||
}
|
||||
|
||||
StreamerCentral.instances[name] = this;
|
||||
|
||||
this.retransmit = retransmit;
|
||||
this.retransmitToSelf = retransmitToSelf;
|
||||
|
||||
this.iniPublication();
|
||||
// DDPStreamer doesn't have this
|
||||
this.initMethod();
|
||||
|
||||
this.allowRead('none');
|
||||
this.allowEmit('all');
|
||||
this.allowWrite('none');
|
||||
}
|
||||
|
||||
get subscriptionName(): string {
|
||||
return `stream-${this.name}`;
|
||||
}
|
||||
|
||||
private allow(rules: IRules, name: string) {
|
||||
return (eventName: string | boolean | Rule, fn?: string | boolean | Rule): void => {
|
||||
if (fn === undefined) {
|
||||
fn = eventName;
|
||||
eventName = '__all__';
|
||||
}
|
||||
|
||||
if (typeof eventName !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof fn === 'function') {
|
||||
rules[eventName] = fn;
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof fn === 'string' && ['all', 'none', 'logged'].indexOf(fn) === -1) {
|
||||
SystemLogger.error({ msg: 'shortcut is invalid', name, fn });
|
||||
}
|
||||
|
||||
if (fn === 'all' || fn === true) {
|
||||
rules[eventName] = async function (): Promise<boolean> {
|
||||
return true;
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (fn === 'none' || fn === false) {
|
||||
rules[eventName] = async function (): Promise<boolean> {
|
||||
return false;
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (fn === 'logged') {
|
||||
rules[eventName] = async function (): Promise<boolean> {
|
||||
return Boolean(this.userId);
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
allowRead(eventName: string | boolean | Rule, fn?: Rule | 'all' | 'none' | 'logged'): void {
|
||||
this.allow(this._allowRead, 'allowRead')(eventName, fn);
|
||||
}
|
||||
|
||||
allowWrite(eventName: string | boolean | Rule, fn?: Rule | 'all' | 'none' | 'logged'): void {
|
||||
this.allow(this._allowWrite, 'allowWrite')(eventName, fn);
|
||||
}
|
||||
|
||||
allowEmit(eventName: string | boolean | Rule, fn?: Rule | 'all' | 'none' | 'logged'): void {
|
||||
this.allow(this._allowEmit, 'allowEmit')(eventName, fn);
|
||||
}
|
||||
|
||||
private isAllowed(rules: IRules) {
|
||||
return async (scope: IPublication, eventName: string, args: any): Promise<boolean | object> => {
|
||||
if (rules[eventName]) {
|
||||
return rules[eventName].call(scope, eventName, ...args);
|
||||
}
|
||||
|
||||
return rules.__all__.call(scope, eventName, ...args);
|
||||
};
|
||||
}
|
||||
|
||||
async isReadAllowed(scope: IPublication, eventName: string, args: any): Promise<boolean | object> {
|
||||
return this.isAllowed(this._allowRead)(scope, eventName, args);
|
||||
}
|
||||
|
||||
async isEmitAllowed(scope: IPublication, eventName: string, ...args: any[]): Promise<boolean | object> {
|
||||
return this.isAllowed(this._allowEmit)(scope, eventName, args);
|
||||
}
|
||||
|
||||
async isWriteAllowed(scope: IPublication, eventName: string, args: any): Promise<boolean | object> {
|
||||
return this.isAllowed(this._allowWrite)(scope, eventName, args);
|
||||
}
|
||||
|
||||
addSubscription(subscription: DDPSubscription, eventName: string): void {
|
||||
this.subscriptions.add(subscription);
|
||||
|
||||
const subByEventName = this.subscriptionsByEventName.get(eventName) || new Set();
|
||||
subByEventName.add(subscription);
|
||||
|
||||
this.subscriptionsByEventName.set(eventName, subByEventName);
|
||||
}
|
||||
|
||||
removeSubscription(subscription: DDPSubscription, eventName: string): void {
|
||||
this.subscriptions.delete(subscription);
|
||||
|
||||
const subByEventName = this.subscriptionsByEventName.get(eventName);
|
||||
if (subByEventName) {
|
||||
subByEventName.delete(subscription);
|
||||
}
|
||||
}
|
||||
|
||||
async _publish(
|
||||
publication: IPublication,
|
||||
eventName: string,
|
||||
options: boolean | { useCollection?: boolean; args?: any } = false,
|
||||
): Promise<void> {
|
||||
let useCollection;
|
||||
let args = [];
|
||||
|
||||
if (typeof options === 'boolean') {
|
||||
useCollection = options;
|
||||
} else {
|
||||
if (options.useCollection) {
|
||||
useCollection = options.useCollection;
|
||||
}
|
||||
|
||||
if (options.args) {
|
||||
args = options.args;
|
||||
}
|
||||
}
|
||||
|
||||
if (eventName.length === 0) {
|
||||
throw new MeteorError('invalid-event-name');
|
||||
}
|
||||
|
||||
if ((await this.isReadAllowed(publication, eventName, args)) !== true) {
|
||||
throw new MeteorError('not-allowed');
|
||||
}
|
||||
|
||||
// after meteor 3.4.1 immediately after a disconnection session becomes null (which is not wrong)
|
||||
// we were just not counting on this, session is _session so we actually should not use it
|
||||
// now after any await, the session can potentially be null, so we need to check for that
|
||||
if (!Streamer.isPublicationActive(publication)) {
|
||||
// if the client is disconnected, we don't want to do anything, it will not have an disconnect event to undo anymore
|
||||
throw new MeteorError('publication-client-disconnected');
|
||||
}
|
||||
|
||||
const subscription = {
|
||||
subscription: publication,
|
||||
eventName,
|
||||
};
|
||||
|
||||
this.addSubscription(subscription, eventName);
|
||||
|
||||
publication.onStop(() => {
|
||||
this.removeSubscription(subscription, eventName);
|
||||
});
|
||||
|
||||
// DDPStreamer doesn't have this
|
||||
if (useCollection === true) {
|
||||
// Collection compatibility
|
||||
publication._session.sendAdded(this.subscriptionName, 'id', {
|
||||
eventName,
|
||||
});
|
||||
}
|
||||
|
||||
publication.ready();
|
||||
|
||||
super.emit('_afterPublish', this, publication, eventName, options);
|
||||
}
|
||||
|
||||
abstract registerPublication(
|
||||
name: string,
|
||||
fn: (eventName: string, options: boolean | { useCollection?: boolean; args?: any }) => Promise<void>,
|
||||
): void;
|
||||
|
||||
iniPublication(): void {
|
||||
const _publish = this._publish.bind(this);
|
||||
this.registerPublication(
|
||||
this.subscriptionName,
|
||||
async function (this: IPublication, eventName: string, options: boolean | { useCollection?: boolean; args?: any }) {
|
||||
return _publish(this, eventName, options);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
abstract registerMethod(methods: Record<string, (eventName: string, ...args: any[]) => any>): void;
|
||||
|
||||
initMethod(): void {
|
||||
const isWriteAllowed = this.isWriteAllowed.bind(this);
|
||||
const __emit = this.__emit.bind(this);
|
||||
const _emit = this._emit.bind(this);
|
||||
const { retransmit } = this;
|
||||
|
||||
const method: Record<string, (eventName: string, ...args: any[]) => any> = {
|
||||
async [this.subscriptionName](this: IPublication, eventName, ...args): Promise<void> {
|
||||
if ((await isWriteAllowed(this, eventName, args)) !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
__emit(eventName, ...args);
|
||||
|
||||
if (retransmit === true) {
|
||||
_emit(eventName, args, this.connection, true);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
this.registerMethod(method);
|
||||
} catch (err) {
|
||||
SystemLogger.error({ err });
|
||||
}
|
||||
}
|
||||
|
||||
abstract changedPayload(collection: string, id: string, fields: Record<string, any>): string | false;
|
||||
|
||||
_emit(eventName: string, args: any[], origin: Connection | undefined, broadcast: boolean, transform?: TransformMessage): boolean {
|
||||
if (broadcast === true) {
|
||||
StreamerCentral.emit('broadcast', this.name, eventName, args);
|
||||
}
|
||||
|
||||
const subscriptions = this.subscriptionsByEventName.get(eventName);
|
||||
if (!subscriptions?.size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (transform) {
|
||||
void this.sendToManySubscriptions(subscriptions, origin, eventName, args, transform);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const msg = this.changedPayload(this.subscriptionName, 'id', {
|
||||
eventName,
|
||||
args,
|
||||
});
|
||||
|
||||
if (!msg) {
|
||||
return false;
|
||||
}
|
||||
|
||||
void this.sendToManySubscriptions(subscriptions, origin, eventName, args, msg);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static isPublicationActive(publication: IPublication): publication is ActivePublication {
|
||||
// After Meteor 3.5 the socket can become null independently of session deactivation
|
||||
// (e.g. right after a disconnection), so "active" must also mean the socket is still there.
|
||||
return !publication._isDeactivated() && publication._session?.socket != null;
|
||||
}
|
||||
|
||||
async sendToManySubscriptions(
|
||||
subscriptions: Set<DDPSubscription>,
|
||||
origin: Connection | undefined,
|
||||
eventName: string,
|
||||
args: any[],
|
||||
getMsg: string | TransformMessage,
|
||||
): Promise<void> {
|
||||
await Promise.all(
|
||||
[...subscriptions].map(async (subscription) => {
|
||||
try {
|
||||
if (this.retransmitToSelf === false && origin && origin === subscription.subscription.connection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = await this.isEmitAllowed(subscription.subscription, eventName, ...args);
|
||||
if (allowed) {
|
||||
const msg = typeof getMsg === 'string' ? getMsg : getMsg(this, subscription, eventName, args, allowed);
|
||||
if (msg) {
|
||||
subscription.subscription._session?.socket?.send(msg);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
SystemLogger.error({
|
||||
msg: 'Error while delivering streamer event',
|
||||
eventName,
|
||||
streamName: this.name,
|
||||
err,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
override emit(eventName: string | symbol, ...args: any[]): boolean {
|
||||
return this._emit(eventName as string, args, undefined, true);
|
||||
}
|
||||
|
||||
__emit(eventName: string, ...args: any[]): boolean {
|
||||
return super.emit(eventName, ...args);
|
||||
}
|
||||
|
||||
emitWithoutBroadcast(eventName: string, ...args: any[]): void {
|
||||
this._emit(eventName, args, undefined, false);
|
||||
}
|
||||
}
|
||||
107
apps/meteor/server/modules/streamer/types.ts
Normal file
107
apps/meteor/server/modules/streamer/types.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { StreamNames, StreamKeys, StreamerCallbackArgs } from '@rocket.chat/ddp-client';
|
||||
|
||||
export type Connection = any;
|
||||
|
||||
export interface IPublication {
|
||||
connection: Connection;
|
||||
_isDeactivated(): this is IPublication & { _session: null };
|
||||
_session: {
|
||||
sendAdded(publicationName: string, id: string, fields: Record<string, any>): void;
|
||||
userId: string | undefined;
|
||||
socket: {
|
||||
send: (payload: string) => void;
|
||||
} | null;
|
||||
} | null;
|
||||
client: {
|
||||
meteorClient: boolean;
|
||||
ws: any;
|
||||
userId?: string;
|
||||
send: (payload: string) => void;
|
||||
};
|
||||
|
||||
onStop(func: () => void): void;
|
||||
/**
|
||||
* Call inside the publish function. Informs the subscriber that an initial, complete snapshot of the record set has been sent. This will trigger a call on the client to the `onReady`
|
||||
* callback passed to `Meteor.subscribe`, if any.
|
||||
*/
|
||||
ready(): void;
|
||||
/**
|
||||
* Call inside the publish function. Informs the subscriber that a document has been removed from the record set.
|
||||
* @param collection The name of the collection that the document has been removed from.
|
||||
* @param id The ID of the document that has been removed.
|
||||
*/
|
||||
removed(collection: string, id: string): void;
|
||||
/**
|
||||
* Access inside the publish function. The incoming connection for this subscription.
|
||||
*/
|
||||
stop(): void;
|
||||
/**
|
||||
* Call inside the publish function. Allows subsequent methods or subscriptions for the client of this subscription
|
||||
* to begin running without waiting for the publishing to become ready.
|
||||
*/
|
||||
unblock(): void;
|
||||
/** Access inside the publish function. The id of the logged-in user, or `null` if no user is logged in. */
|
||||
userId: string | null;
|
||||
}
|
||||
|
||||
export type Rule<K extends string = string> = (this: IPublication, eventName: K, ...args: any) => Promise<boolean | object>;
|
||||
|
||||
export interface IRules {
|
||||
[k: string]: Rule;
|
||||
}
|
||||
|
||||
export type DDPSubscription = {
|
||||
eventName: string;
|
||||
subscription: IPublication;
|
||||
};
|
||||
|
||||
export type TransformMessage = (
|
||||
streamer: IStreamer<StreamNames>,
|
||||
subscription: DDPSubscription,
|
||||
eventName: string,
|
||||
args: any[],
|
||||
allowed: boolean | object,
|
||||
) => string | false;
|
||||
|
||||
export interface IStreamer<N extends StreamNames> {
|
||||
serverOnly: boolean;
|
||||
|
||||
subscriptions: Set<DDPSubscription>;
|
||||
|
||||
subscriptionName: string;
|
||||
|
||||
allowEmit<K extends StreamKeys<N>>(eventName: K, fn?: Rule | 'all' | 'none' | 'logged'): void;
|
||||
allowEmit(rule: Rule<StreamKeys<N>> | 'all' | 'none' | 'logged'): void;
|
||||
|
||||
allowWrite<K extends StreamKeys<N>>(eventName: K | boolean, fn: Rule | 'all' | 'none' | 'logged'): void;
|
||||
allowWrite(rule: Rule<StreamKeys<N>> | 'all' | 'none' | 'logged'): void;
|
||||
|
||||
allowRead<K extends StreamKeys<N>>(eventName: K | boolean, fn: Rule | 'all' | 'none' | 'logged'): void;
|
||||
allowRead(rule: Rule<StreamKeys<N>> | 'all' | 'none' | 'logged'): void;
|
||||
|
||||
emit<K extends StreamKeys<N>>(event: K, ...data: StreamerCallbackArgs<N, K>): void;
|
||||
|
||||
on<K extends StreamKeys<N>>(event: K, fn: (...data: any[]) => void): void;
|
||||
|
||||
on(event: '_afterPublish', fn: (streamer: this, publication: IPublication, eventName: string, ...data: any[]) => void): void;
|
||||
|
||||
removeSubscription(subscription: DDPSubscription, eventName: string): void;
|
||||
|
||||
removeListener(event: string, fn: (...data: any[]) => void): void;
|
||||
|
||||
removeAllListeners(event: string): void;
|
||||
|
||||
__emit(...data: any[]): void;
|
||||
|
||||
_emit(eventName: string, args: any[], origin: Connection | undefined, broadcast: boolean, transform?: TransformMessage): boolean;
|
||||
|
||||
emitWithoutBroadcast<K extends StreamKeys<N>>(event: K, ...data: StreamerCallbackArgs<N, K>): void;
|
||||
|
||||
changedPayload(collection: string, id: string, fields: Record<string, any>): string | false;
|
||||
|
||||
_publish(publication: IPublication, eventName: string, options: boolean | { useCollection?: boolean; args?: any }): Promise<void>;
|
||||
}
|
||||
|
||||
export interface IStreamerConstructor {
|
||||
new <N extends StreamNames>(name: N, options?: { retransmit?: boolean; retransmitToSelf?: boolean }): IStreamer<N>;
|
||||
}
|
||||
Reference in New Issue
Block a user