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,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,
};
}
}

View File

@@ -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;
}
}

View File

@@ -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,
};
}
}

View 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;
}
}
}

View 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);
}
}

View 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,
},
},
],
},
};
};

View 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,
},
};
}
}
}

View 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', []]);
});
}
}

View 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;

View File

@@ -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);
}

View 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);
}
}

View 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>;
}